|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Run every companion notebook in a real headless browser (Pyodide WASM). |
| 3 | +
|
| 4 | +This is the in-browser counterpart to the build: `build.sh` proves the static |
| 5 | +site *builds*; this proves every notebook's code actually *runs* in Pyodide. |
| 6 | +
|
| 7 | +It serves the locally-built site (`lite/_output`, so run `./build.sh` first), |
| 8 | +loads real Pyodide in headless Chromium, installs the freshly-built `aima` wheel |
| 9 | +from the bundled offline index, and executes each notebook's real code cells |
| 10 | +(the `piplite` install cell included, via a micropip-backed shim). `ipython` is |
| 11 | +loaded to mirror the JupyterLite kernel, which always provides it. |
| 12 | +
|
| 13 | +Setup (one-off): |
| 14 | + pip install playwright nbformat |
| 15 | + python -m playwright install chromium |
| 16 | +
|
| 17 | +Usage: |
| 18 | + cd lite && ./build.sh && python verify_browser.py |
| 19 | +""" |
| 20 | +import asyncio |
| 21 | +import functools |
| 22 | +import glob |
| 23 | +import http.server |
| 24 | +import os |
| 25 | +import pathlib |
| 26 | +import threading |
| 27 | + |
| 28 | +import nbformat |
| 29 | +from playwright.async_api import async_playwright |
| 30 | + |
| 31 | +HERE = pathlib.Path(__file__).resolve().parent |
| 32 | +SITE = HERE / "_output" |
| 33 | +CONTENT = HERE / "content" |
| 34 | +PORT = 8765 |
| 35 | +PYODIDE = "https://cdn.jsdelivr.net/pyodide/v0.27.2/full/pyodide.js" |
| 36 | + |
| 37 | +# notebooks first, in a sensible reading order; any extras are appended |
| 38 | +PREFERRED = ["Welcome", "agents", "search", "games", "csp", "logic", "planning", |
| 39 | + "probability", "mdp", "reinforcement_learning", "learning", |
| 40 | + "knowledge", "nlp", "text", "game_theory"] |
| 41 | + |
| 42 | +HTML = """<!doctype html><html><head><meta charset="utf-8"> |
| 43 | +<script src="{pyodide}"></script></head><body><script> |
| 44 | +window.pyReady = false; window.bootErr = ""; window.OUT = []; |
| 45 | +async function boot() {{ |
| 46 | + try {{ |
| 47 | + window.pyodide = await loadPyodide(); |
| 48 | + pyodide.setStdout({{batched: (s) => window.OUT.push(s)}}); |
| 49 | + pyodide.setStderr({{batched: (s) => window.OUT.push(s)}}); |
| 50 | + // ipython mirrors the JupyterLite kernel (aima.agents imports IPython.display) |
| 51 | + await pyodide.loadPackage(["micropip", "numpy", "scipy", "networkx", "ipython"]); |
| 52 | + // fetch the bundled wheel and install from the Pyodide FS (micropip's by-URL |
| 53 | + // path is unreliable on pyodide 0.27); still exercises the real wheel + WASM |
| 54 | + const resp = await fetch("{wheel_url}"); |
| 55 | + if (!resp.ok) throw new Error("wheel fetch HTTP " + resp.status); |
| 56 | + pyodide.FS.writeFile("/tmp/{wheel_name}", new Uint8Array(await resp.arrayBuffer())); |
| 57 | + await pyodide.runPythonAsync(` |
| 58 | +import micropip |
| 59 | +await micropip.install("sortedcontainers") |
| 60 | +import sys, types |
| 61 | +_pip = types.ModuleType("piplite") |
| 62 | +async def _install(pkgs, deps=True, **kw): |
| 63 | + import micropip |
| 64 | + names = [pkgs] if isinstance(pkgs, str) else list(pkgs) |
| 65 | + for n in names: |
| 66 | + if n == "aima": |
| 67 | + await micropip.install("emfs:/tmp/{wheel_name}", deps=False) |
| 68 | + elif n not in ("scipy", "numpy", "networkx", "sortedcontainers"): |
| 69 | + await micropip.install(n) |
| 70 | +_pip.install = _install |
| 71 | +sys.modules["piplite"] = _pip |
| 72 | +`); |
| 73 | + window.pyReady = true; |
| 74 | + }} catch (e) {{ window.bootErr = e.toString(); }} |
| 75 | +}} |
| 76 | +boot(); |
| 77 | +</script></body></html>""" |
| 78 | + |
| 79 | + |
| 80 | +def serve(directory): |
| 81 | + handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(directory)) |
| 82 | + httpd = http.server.ThreadingHTTPServer(("127.0.0.1", PORT), handler) |
| 83 | + threading.Thread(target=httpd.serve_forever, daemon=True).start() |
| 84 | + return httpd |
| 85 | + |
| 86 | + |
| 87 | +async def run_cell(page, code): |
| 88 | + return await page.evaluate( |
| 89 | + """async (code) => { |
| 90 | + window.OUT = []; |
| 91 | + try { |
| 92 | + await pyodide.runPythonAsync(code, {globals: window.__ns}); |
| 93 | + return {ok: true, out: window.OUT.join('')}; |
| 94 | + } catch (e) { |
| 95 | + return {ok: false, out: window.OUT.join('') + '\\n' + e.toString()}; |
| 96 | + } |
| 97 | + }""", code) |
| 98 | + |
| 99 | + |
| 100 | +def notebook_order(): |
| 101 | + names = [p.stem for p in CONTENT.glob("*.ipynb")] |
| 102 | + ordered = [n for n in PREFERRED if n in names] |
| 103 | + return ordered + sorted(n for n in names if n not in PREFERRED) |
| 104 | + |
| 105 | + |
| 106 | +async def main(): |
| 107 | + if not SITE.exists(): |
| 108 | + raise SystemExit("lite/_output not found — run ./build.sh first") |
| 109 | + wheels = glob.glob(str(SITE / "pypi" / "aima-*.whl")) |
| 110 | + if not wheels: |
| 111 | + raise SystemExit("no aima wheel in lite/_output/pypi — run ./build.sh first") |
| 112 | + wheel_name = os.path.basename(wheels[0]) |
| 113 | + |
| 114 | + html = HTML.format(pyodide=PYODIDE, wheel_url=f"pypi/{wheel_name}", wheel_name=wheel_name) |
| 115 | + (SITE / "verify.html").write_text(html) |
| 116 | + serve(SITE) |
| 117 | + |
| 118 | + results = {} |
| 119 | + async with async_playwright() as p: |
| 120 | + browser = await p.chromium.launch() |
| 121 | + page = await browser.new_page() |
| 122 | + page.on("pageerror", lambda e: print("PAGEERROR:", e)) |
| 123 | + await page.goto(f"http://127.0.0.1:{PORT}/verify.html") |
| 124 | + try: |
| 125 | + await page.wait_for_function("window.pyReady === true", timeout=180000) |
| 126 | + except Exception: |
| 127 | + raise SystemExit("boot failed: " + await page.evaluate("() => window.bootErr")) |
| 128 | + print(f"Pyodide booted, {wheel_name} installed.\n") |
| 129 | + |
| 130 | + for name in notebook_order(): |
| 131 | + await page.evaluate("() => { if (window.__ns) window.__ns.destroy();" |
| 132 | + " window.__ns = pyodide.globals.get('dict')(); }") |
| 133 | + nb = nbformat.read(str(CONTENT / f"{name}.ipynb"), as_version=4) |
| 134 | + ok, out = True, "" |
| 135 | + for cell in nb.cells: |
| 136 | + if cell.cell_type != "code": |
| 137 | + continue |
| 138 | + r = await run_cell(page, cell.source) |
| 139 | + out += r["out"] |
| 140 | + if not r["ok"]: |
| 141 | + ok = False |
| 142 | + break |
| 143 | + results[name] = ok |
| 144 | + print(f" {'PASS' if ok else 'FAIL'} {name}") |
| 145 | + if not ok: |
| 146 | + print("\n".join(" " + l for l in out.strip().splitlines()[-8:])) |
| 147 | + await browser.close() |
| 148 | + |
| 149 | + passed = sum(results.values()) |
| 150 | + print(f"\n{passed}/{len(results)} notebooks ran successfully in a real browser") |
| 151 | + raise SystemExit(0 if passed == len(results) else 1) |
| 152 | + |
| 153 | + |
| 154 | +if __name__ == "__main__": |
| 155 | + asyncio.run(main()) |
0 commit comments