diff --git a/config.yml b/config.yml index 3beec21b9..ded2226d2 100644 --- a/config.yml +++ b/config.yml @@ -122,3 +122,23 @@ notebooks: - ssl - h5py - lmdb + - name: mattersim + packages_pyodide: + # Packages with dependencies + - opt_einsum + - orjson + - pyyaml + - setuptools + # Packages without dependencies + - nodeps:opt_einsum_fx + - nodeps:e3nn>=0.5 + - nodeps:ase + - nodeps:monty + - nodeps:deprecated + - wrapt + # MatterSim local wheel (pure Python, Cython replaced with NumPy) + - emfs:/drive/packages/mattersim-1.1.2-py3-none-any.whl + # Stubbed packages (patched by torch_pyodide with include_mattersim=True) + - ssl + - h5py + - lmdb diff --git a/other/experiments/jupyterlite/relax_structure_with_mattersim.ipynb b/other/experiments/jupyterlite/relax_structure_with_mattersim.ipynb new file mode 100644 index 000000000..1df57be89 --- /dev/null +++ b/other/experiments/jupyterlite/relax_structure_with_mattersim.ipynb @@ -0,0 +1,306 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cd6318a0", + "metadata": {}, + "source": [ + "# Relax Structure with MatterSim \u2014 M3GNet Interatomic Potential\n", + "\n", + "This notebook demonstrates structural relaxation using the **MatterSim** model,\n", + "a deep learning atomistic model based on the M3GNet architecture (Microsoft Research)." + ] + }, + { + "cell_type": "markdown", + "id": "ec82aac5", + "metadata": {}, + "source": [ + "## 1. Set Input Parameters\n", + "### 1.1. Structure and Relaxation" + ] + }, + { + "cell_type": "code", + "id": "00e95d72", + "metadata": {}, + "source": [ + "FOLDER = \"uploads\"\n", + "STRUCTURE_NAME = \"Interface\" # Name of the structure to load from local file\n", + "\n", + "RELAXATION_PARAMETERS = {\n", + " \"FMAX\": 0.05,\n", + "}" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "5fb03403", + "metadata": {}, + "source": [ + "MATTERSIM_MODEL_PATH = \"/drive/packages/models/mattersim-v1.0.0-1M.pth\"" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "e2a90d7e", + "metadata": {}, + "source": [ + "## 2. Install Packages" + ] + }, + { + "cell_type": "code", + "id": "9634f33c", + "metadata": {}, + "source": [ + "from mat3ra.notebooks_utils.packages import install_packages\n", + "\n", + "await install_packages(\"made|api_examples|torch|mattersim\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "46e728fa", + "metadata": {}, + "source": [ + "from mat3ra.notebooks_utils.pyodide.packages.torch import apply_all_patches\n", + "\n", + "apply_all_patches(include_mattersim=True)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "fdebcddf", + "metadata": {}, + "source": [ + "## 3. Load Materials" + ] + }, + { + "cell_type": "code", + "id": "bb76e05d", + "metadata": {}, + "source": [ + "from mat3ra.made.material import Material\n", + "from mat3ra.notebooks_utils.material import load_material_from_folder\n", + "from mat3ra.standata.materials import Materials\n", + "\n", + "structure = load_material_from_folder(FOLDER, STRUCTURE_NAME) or Material.create(\n", + " Materials.get_by_name_first_match(STRUCTURE_NAME))\n", + "\n", + "print(f\"INFO: Found: '{structure.name}'\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "9fe97f75", + "metadata": {}, + "source": [ + "### 3.1. Visualize Input Structure" + ] + }, + { + "cell_type": "code", + "id": "cbf71a5f", + "metadata": {}, + "source": [ + "from mat3ra.notebooks_utils.ipython.entity.material.visualize import ViewersEnum, visualize_materials as visualize\n", + "\n", + "visualize(structure, repetitions=[1, 1, 1], rotation=\"-90x\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "92dab479", + "metadata": {}, + "source": [ + "## 4. Apply Relaxation\n", + "### 4.1. Load MatterSim Model and Create Calculator" + ] + }, + { + "cell_type": "code", + "id": "7e4b8e07", + "metadata": {}, + "source": [ + "from mattersim.forcefield import MatterSimCalculator\n", + "\n", + "calculator = MatterSimCalculator.from_checkpoint(\n", + " load_path=MATTERSIM_MODEL_PATH,\n", + " device=\"cpu\",\n", + ")\n", + "\n", + "print(f\"MatterSim model loaded from {MATTERSIM_MODEL_PATH}\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "4ed221b9", + "metadata": {}, + "source": [ + "### 4.2. Relax with MatterSim" + ] + }, + { + "cell_type": "code", + "id": "42fac903", + "metadata": {}, + "source": [ + "import plotly.graph_objs as go\n", + "from IPython.display import display\n", + "from plotly.subplots import make_subplots\n", + "\n", + "from mat3ra.made.tools.convert import to_ase\n", + "from ase.optimize import BFGS\n", + "\n", + "ase_structure = to_ase(structure)\n", + "ase_structure.calc = calculator\n", + "dyn = BFGS(ase_structure)\n", + "\n", + "steps = []\n", + "energies = []\n", + "\n", + "fig = make_subplots(rows=1, cols=1, specs=[[{\"type\": \"scatter\"}]])\n", + "scatter = go.Scatter(x=[], y=[], mode=\"lines+markers\", name=\"Energy\")\n", + "fig.add_trace(scatter)\n", + "fig.update_layout(title_text=\"Real-time Optimization Progress\", xaxis_title=\"Step\", yaxis_title=\"Energy (eV)\")\n", + "\n", + "try:\n", + " f = go.FigureWidget(fig)\n", + "except ImportError:\n", + " f = go.Figure(fig)\n", + "display(f)\n", + "\n", + "\n", + "def plotly_callback():\n", + " step = dyn.nsteps\n", + " energy = ase_structure.get_total_energy()\n", + " steps.append(step)\n", + " energies.append(energy)\n", + " print(f\"Step: {step}, Energy: {energy:.4f} eV\")\n", + " if hasattr(f, \"batch_update\"):\n", + " with f.batch_update():\n", + " f.data[0].x = steps\n", + " f.data[0].y = energies\n", + " else:\n", + " f.data[0].x = steps\n", + " f.data[0].y = energies\n", + "\n", + "\n", + "dyn.attach(plotly_callback, interval=1)\n", + "dyn.run(fmax=RELAXATION_PARAMETERS[\"FMAX\"])\n", + "\n", + "ase_original_structure = to_ase(structure)\n", + "ase_original_structure.calc = calculator\n", + "ase_final_structure = ase_structure\n", + "\n", + "original_energy = ase_original_structure.get_total_energy()\n", + "relaxed_energy = ase_structure.get_total_energy()\n", + "\n", + "print(f\"The final energy is {float(relaxed_energy):.3f} eV.\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "abf2e592", + "metadata": {}, + "source": [ + "## 5. Analyze Results\n", + "### 5.1. View Structure Before and After Relaxation" + ] + }, + { + "cell_type": "code", + "id": "fc047887", + "metadata": {}, + "source": [ + "from mat3ra.made.tools.convert import from_ase\n", + "\n", + "material_original = Material.create(from_ase(ase_original_structure))\n", + "material_relaxed = Material.create(from_ase(ase_final_structure))\n", + "material_original.name = structure.name\n", + "material_relaxed.name = structure.name + \" (MatterSim Relaxed)\"\n", + "\n", + "visualize([material_original, material_relaxed], viewer=ViewersEnum.wave)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "b540fa95", + "metadata": {}, + "source": [ + "### 5.2. Output interlayer distance before and after relaxation" + ] + }, + { + "cell_type": "code", + "id": "fbc98fe8", + "metadata": {}, + "source": [ + "from mat3ra.made.tools.analyze.other import get_average_interlayer_distance\n", + "\n", + "SUBSTRATE_TAG = 0\n", + "FILM_TAG = 1\n", + "\n", + "print(\n", + " f\"Interlayer distance before relaxation: {get_average_interlayer_distance(material_original, SUBSTRATE_TAG, FILM_TAG):.4f} \u00c5\")\n", + "print(\n", + " f\"Interlayer distance after relaxation: {get_average_interlayer_distance(material_relaxed, SUBSTRATE_TAG, FILM_TAG):.4f} \u00c5\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "0989b7a7", + "metadata": {}, + "source": [ + "## References\n", + "\n", + "[1] MatterSim: https://github.com/microsoft/mattersim\n", + "\n", + "[2] Han Yang et al., \"MatterSim: A Deep Learning Atomistic Model Across Elements, Temperatures and Pressures,\" arXiv:2405.04967 (2024)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (Pyodide)", + "language": "python", + "name": "python" + }, + "language_info": { + "codemirror_mode": { + "name": "python", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/packages/mattersim-1.1.2-py3-none-any.whl b/packages/mattersim-1.1.2-py3-none-any.whl new file mode 100644 index 000000000..a871dc1f0 --- /dev/null +++ b/packages/mattersim-1.1.2-py3-none-any.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ab6c9bdcded031f2657a38837b4e4efdc6b211023078c0555c91c9d0b8422fe +size 72010 diff --git a/src/py/mat3ra/notebooks_utils/pyodide/packages/torch.py b/src/py/mat3ra/notebooks_utils/pyodide/packages/torch.py index bfb2b43a1..1cf7cf829 100644 --- a/src/py/mat3ra/notebooks_utils/pyodide/packages/torch.py +++ b/src/py/mat3ra/notebooks_utils/pyodide/packages/torch.py @@ -826,16 +826,22 @@ def _int8_aware_torch_load(f, *args, **kwargs): import gc as _gc from fairchem.core.units.mlip_unit.api.inference import MLIPInferenceCheckpoint - print(" Dequantizing INT8 → FP16...") - quantized_ema = result["quantized_ema_state_dict"] - scales = result["quantization_scales"] + print(" Dequantizing INT8 → FP16 (streaming)...") + quantized_ema = result.pop("quantized_ema_state_dict") + scales = result.pop("quantization_scales") ema_state_dict = {} - for name, tensor in quantized_ema.items(): + names = list(quantized_ema.keys()) + for name in names: + tensor = quantized_ema.pop(name) if name in scales: - ema_state_dict[name] = (tensor.float() * scales[name].float()).half() + scale = scales.pop(name) + ema_state_dict[name] = (tensor.float() * scale.float()).half() + del scale else: ema_state_dict[name] = tensor + del tensor del quantized_ema, scales + _gc.collect() checkpoint = MLIPInferenceCheckpoint( model_config=result["model_config"], model_state_dict=result.get("model_state_dict", {}), @@ -953,7 +959,7 @@ def patch_mace_tools(): # ============================================================================== -def apply_all_patches(include_fairchem=False): +def apply_all_patches(include_fairchem=False, include_mattersim=False): """ Apply all torch and model patches for Pyodide in one call. @@ -961,6 +967,9 @@ def apply_all_patches(include_fairchem=False): include_fairchem: If True, also apply FAIRChem-specific patches (torch.distributed, heavy dependency stubs). Set this when using fairchem-core / UMA models. + include_mattersim: If True, also apply MatterSim-specific patches + (loguru, azure, e3nn JIT stubs). Set this when + using MatterSim / M3GNet models. """ patch_torch_linalg() patch_torch_compiler() @@ -973,4 +982,228 @@ def apply_all_patches(include_fairchem=False): patch_torch_distributed() patch_fairchem_deps() + if include_mattersim: + patch_torch_distributed() + patch_mattersim_deps() + print("\n✅ All Pyodide patches applied successfully!") + + +# ============================================================================== +# MatterSim patches +# ============================================================================== + + +def patch_mattersim_deps(): + """ + Stub heavy dependencies required by MatterSim but not needed for inference. + + Stubs: loguru, azure.*, atomate2, seekpath, phonopy, phono3py, mp_api, + sklearn, and patches e3nn to disable JIT/torch.compile. + """ + # --- loguru --- + loguru_mod = _make_stub_module("loguru") + + class _Logger: + def info(self, msg, *a, **k): + print(f"INFO: {msg}") + + def warning(self, msg, *a, **k): + print(f"WARNING: {msg}") + + def error(self, msg, *a, **k): + print(f"ERROR: {msg}") + + def debug(self, msg, *a, **k): + pass + + def trace(self, msg, *a, **k): + pass + + def success(self, msg, *a, **k): + print(f"✓ {msg}") + + def __getattr__(self, name): + return lambda *a, **k: None + + loguru_mod.logger = _Logger() + + # --- azure (not needed for inference) --- + _make_stub_module("azure", submodules=[ + "identity", "storage", "storage.blob", + ]) + + # --- heavy optional deps (not needed for inference) --- + for pkg in [ + "atomate2", + "seekpath", + "phonopy", + "phono3py", + "mp_api", + "jobflow", + "emmet", + "emmet.core", + "emmet.core.tasks", + "maggma", + ]: + _make_stub_module(pkg) + + # --- scikit-learn (stub) --- + sk_mod = _make_stub_module("sklearn", submodules=[ + "base", "utils", "utils.validation", + "preprocessing", "model_selection", + "gaussian_process", "gaussian_process.kernels", + ]) + + # Stub GaussianProcessRegressor + class _GPR: + def __init__(self, *a, **k): + pass + def fit(self, *a, **k): + return self + def predict(self, X, return_std=False): + import numpy as _np + mean = _np.zeros(X.shape[0]) + if return_std: + return mean, _np.ones(X.shape[0]) + return mean + def log_marginal_likelihood(self): + return 0.0 + sys.modules["sklearn.gaussian_process"].GaussianProcessRegressor = _GPR + + # Stub kernels + class _Kernel: + pass + class _DotProduct(_Kernel): + def __init__(self, *a, **k): + pass + class _Hyperparameter: + def __init__(self, *a, **k): + pass + _sk_kernels = sys.modules["sklearn.gaussian_process.kernels"] + _sk_kernels.Kernel = _Kernel + _sk_kernels.DotProduct = _DotProduct + _sk_kernels.Hyperparameter = _Hyperparameter + + # --- requests (for pyodide-http compat) --- + try: + import pyodide_http # noqa: F401 + pyodide_http.patch_all() + except ImportError: + pass + + # --- patch e3nn to skip JIT compilation --- + try: + import e3nn + e3nn._SO3_INITIALIZED = True # skip init + except Exception: + pass + + # --- patch torch.jit for e3nn --- + import torch + if not hasattr(torch.jit, '_original_script'): + _orig_script = torch.jit.script + + def _noop_script(obj=None, *a, **k): + if obj is not None: + return obj + return lambda fn: fn + + torch.jit.script = _noop_script + + # --- torch_ema (training only) --- + _te = _make_stub_module("torch_ema") + + class _EMA: + def __init__(self, *a, **k): + pass + _te.ExponentialMovingAverage = _EMA + + # --- torchmetrics (training only) --- + _tm = _make_stub_module("torchmetrics") + + class _MeanMetric: + def __init__(self, *a, **k): + pass + _tm.MeanMetric = _MeanMetric + + # --- torch_geometric (data loading only) --- + _tg = _make_stub_module("torch_geometric", submodules=[ + "data", "loader", "utils", + ]) + + class _Data: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + def to(self, device): + import torch as _t + for k, v in self.__dict__.items(): + if isinstance(v, _t.Tensor): + setattr(self, k, v.to(device)) + return self + + sys.modules["torch_geometric.data"].Data = _Data + + class _DataLoader: + """Minimal DataLoader that yields items with Batch-style attributes.""" + def __init__(self, dataset, batch_size=1, shuffle=False, **kwargs): + self._dataset = list(dataset) + + def __iter__(self): + import torch as _t + for item in self._dataset: + # Convert scalar int/float fields to 1-element tensors + # (torch_geometric Batch collation does this automatically) + for attr in list(vars(item).keys()): + val = getattr(item, attr) + if isinstance(val, (int, float)): + setattr(item, attr, _t.tensor([val])) + # Add torch_geometric Batch-style attributes for single graph + if not hasattr(item, 'num_graphs'): + item.num_graphs = 1 + if not hasattr(item, 'batch'): + n_atoms = item.num_atoms if hasattr(item, 'num_atoms') else _t.tensor([0]) + if isinstance(n_atoms, _t.Tensor): + n_atoms = int(n_atoms.item()) + item.batch = _t.zeros(n_atoms, dtype=_t.long) + yield item + + def __len__(self): + return len(self._dataset) + + sys.modules["torch_geometric.loader"].DataLoader = _DataLoader + + # --- torch_runstats (scatter ops) --- + _trs = _make_stub_module("torch_runstats", submodules=["scatter"]) + import torch as _torch + + def _scatter(src, index, dim_size=None, dim=0, reduce="sum"): + if dim_size is None: + dim_size = int(index.max()) + 1 + out = _torch.zeros(dim_size, *src.shape[1:], dtype=src.dtype, device=src.device) + if src.dim() == 1: + idx = index + else: + idx = index.unsqueeze(-1).expand_as(src) + if reduce == "sum" or reduce == "add": + out.scatter_add_(0, idx, src) + elif reduce == "mean": + out.scatter_add_(0, idx, src) + count = _torch.zeros(dim_size, dtype=src.dtype, device=src.device) + count.scatter_add_(0, index, _torch.ones(index.shape[0], dtype=src.dtype, device=src.device)) + count = count.clamp(min=1) + if src.dim() > 1: + count = count.unsqueeze(-1) + out = out / count + return out + + sys.modules["torch_runstats.scatter"].scatter = _scatter + + def _scatter_mean(src, index, dim_size=None, dim=0): + return _scatter(src, index, dim_size=dim_size, dim=dim, reduce="mean") + + sys.modules["torch_runstats.scatter"].scatter_mean = _scatter_mean + + print("✓ MatterSim dependency stubs applied") diff --git a/tests/playwright/debug_cell8.mjs b/tests/playwright/debug_cell8.mjs new file mode 100644 index 000000000..e5a68af8a --- /dev/null +++ b/tests/playwright/debug_cell8.mjs @@ -0,0 +1,47 @@ +import { chromium } from "playwright"; + +const URL = "http://localhost:8000/lab/index.html?path=experiments/relax_structure_with_mattersim.ipynb"; + +const b = await chromium.launch({ channel: "chrome", headless: false }); +const p = await b.newPage(); +await p.goto(URL, { waitUntil: "domcontentloaded", timeout: 60000 }); +await p.waitForFunction(() => { + const ind = document.querySelector(".jp-Notebook-ExecutionIndicator"); + return ind?.dataset?.status === "idle" && document.querySelectorAll(".jp-CodeCell").length > 5; +}, null, { timeout: 120000, polling: 2000 }); +await p.locator('button[data-command="runmenu:restart-and-run-all"]').click(); +await p.waitForTimeout(1000); +try { await p.locator(".jp-Dialog button", { hasText: /restart/i }).click({ timeout: 3000 }); } catch {} + +for (let i = 0; i < 30; i++) { + await p.waitForTimeout(5000); + const s = await p.evaluate(() => { + const k = document.querySelector(".jp-Notebook-ExecutionIndicator")?.dataset?.status; + let running = 0; + document.querySelectorAll(".jp-CodeCell .jp-InputPrompt").forEach(e => { + if (e.textContent.trim() === "[*]:") running++; + }); + return { k, running }; + }); + if (s.k === "idle" && s.running === 0) break; +} + +// Get cell 8 error (skip plotly JS) +const err = await p.evaluate(() => { + const cells = document.querySelectorAll(".jp-CodeCell"); + const cell8 = cells[7]; + if (!cell8) return "no cell 8"; + let text = ""; + cell8.querySelectorAll(".jp-OutputArea-output").forEach((e, i) => { + const t = e.textContent || ""; + // Skip plotly JS blobs + if (t.length > 100000) { + text += `[output ${i}: ${t.length} chars, skipped]\n`; + } else { + text += `[output ${i}]: ${t}\n`; + } + }); + return text; +}); +console.log(err); +await b.close(); diff --git a/tests/playwright/test_mattersim_notebook.mjs b/tests/playwright/test_mattersim_notebook.mjs new file mode 100644 index 000000000..e179c8d62 --- /dev/null +++ b/tests/playwright/test_mattersim_notebook.mjs @@ -0,0 +1,93 @@ +import { chromium } from "playwright"; + +const URL = + "http://localhost:8000/lab/index.html?path=experiments/relax_structure_with_mattersim.ipynb"; + +(async () => { + const browser = await chromium.launch({ channel: "chrome", headless: false }); + const page = await browser.newPage(); + + console.log("⏳ Navigating to JupyterLite..."); + await page.goto(URL, { waitUntil: "domcontentloaded", timeout: 60_000 }); + + // Wait for kernel idle + console.log("⏳ Waiting for Pyodide kernel..."); + await page.waitForFunction( + () => { + const ind = document.querySelector(".jp-Notebook-ExecutionIndicator"); + return ind?.dataset?.status === "idle" && document.querySelectorAll(".jp-CodeCell").length > 5; + }, + null, + { timeout: 120_000, polling: 2_000 } + ); + console.log("✅ Kernel idle!"); + + // Click Restart & Run All + await page.locator('button[data-command="runmenu:restart-and-run-all"]').click(); + await page.waitForTimeout(1_000); + try { + await page.locator(".jp-Dialog button", { hasText: /restart/i }).click({ timeout: 3_000 }); + console.log("✅ Restart & Run All confirmed!"); + } catch { + console.log("⚠ No dialog"); + } + + // Monitor with logging + console.log("\n📊 Monitoring..."); + const start = Date.now(); + + while (Date.now() - start < 300_000) { + await page.waitForTimeout(10_000); + + const s = await page.evaluate(() => { + const cells = document.querySelectorAll(".jp-CodeCell"); + let running = 0, done = 0, errors = 0; + cells.forEach((c) => { + const p = c.querySelector(".jp-InputPrompt")?.textContent?.trim() || ""; + if (p === "[*]:") running++; + else if (/\[\d+\]:/.test(p)) done++; + if (c.querySelector(".jp-RenderedText[data-mime-type='application/vnd.jupyter.stderr']")) errors++; + }); + const k = document.querySelector(".jp-Notebook-ExecutionIndicator")?.dataset?.status; + return { running, done, errors, total: cells.length, k }; + }); + + const t = ((Date.now() - start) / 1000) | 0; + console.log(` [${t}s] kernel=${s.k} running=${s.running} done=${s.done}/${s.total} errors=${s.errors}`); + + if (s.k === "idle" && s.running === 0 && s.done > 0) break; + } + + // Final report + console.log("\n" + "=".repeat(60)); + const results = await page.evaluate(() => + Array.from(document.querySelectorAll(".jp-CodeCell")).map((c, i) => { + let t = ""; + for (const o of c.querySelectorAll(".jp-OutputArea-output")) t += o.textContent; + return { cell: i + 1, err: t.includes("Traceback"), out: t }; + }) + ); + + let failures = 0; + for (const r of results) { + if (r.err) { + failures++; + console.log(`Cell ${r.cell} ❌`); + console.log(r.out.substring(0, 2000)); + console.log(); + } else { + const preview = r.out.trim().substring(0, 120).replace(/\n/g, " | "); + console.log(`Cell ${r.cell} ✅${preview ? ": " + preview : ""}`); + } + } + + console.log("=".repeat(60)); + if (failures > 0) { + console.log(`\n⚠ ${failures} cell(s) failed`); + process.exitCode = 1; + } else { + console.log(`\n🎉 ALL ${results.length} CELLS PASSED!`); + } + + await browser.close(); +})();