Skip to content

Commit f104e03

Browse files
committed
Add real-browser verification for the JupyterLite companion (#1072)
build.sh proves the site builds; verify_browser.py proves the notebooks run. It serves lite/_output, loads real Pyodide in headless Chromium, installs the freshly-built aima wheel from the bundled offline index, and executes every notebook's real code cells (the piplite install cell included, via a micropip-backed shim). It loads ipython to mirror the JupyterLite kernel and exits non-zero if any notebook errors. Verified: all 15 companion notebooks run successfully end-to-end in a real browser (correct outputs: A* cost 418, alpha-beta never loses, Nash equilibria, MDP policy, decision tree / naive Bayes, CYK chart, n-gram models, ...). README documents how to run it.
1 parent 4293776 commit f104e03

2 files changed

Lines changed: 176 additions & 6 deletions

File tree

‎lite/README.md‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,28 @@ python -m http.server -d _output 8000 # then open http://localhost:8000
6868
`aima` wheel from the repo root, and bundles it into the JupyterLite site so the
6969
notebooks can `piplite.install("aima", deps=False)` offline.
7070

71+
## Verify in a real browser
72+
73+
`build.sh` proves the static site *builds*; `verify_browser.py` proves every
74+
notebook actually *runs* in Pyodide. It serves `_output`, loads real Pyodide in
75+
headless Chromium, installs the freshly-built wheel and executes each notebook's
76+
real code cells:
77+
78+
```bash
79+
pip install playwright nbformat
80+
python -m playwright install chromium
81+
cd lite && ./build.sh && python verify_browser.py
82+
```
83+
84+
It exits non-zero if any notebook errors. All 15 notebooks currently pass.
85+
7186
## Status / next steps
7287

7388
This is a **proof of concept** (issue #1072 stays open until it is a complete
74-
companion). Remaining work:
89+
companion). In-browser execution of all 15 notebooks has been verified with
90+
`verify_browser.py` (headless Chromium + real Pyodide + the deployed wheel).
91+
Remaining ideas:
7592

76-
- Verify in-browser execution across browsers (Pyodide runs only in a real
77-
browser, so this cannot be checked in headless CI — the CI job only proves the
78-
static site *builds*; the notebook code is separately validated against a
79-
numpy/networkx/sortedcontainers/scipy environment that mirrors Pyodide).
80-
- Decide whether to grow this into a full MyST / Jupyter Book textbook companion.
93+
- Wire `verify_browser.py` into CI (it needs a headless browser, so it would be a
94+
separate, heavier job than the build/deploy).
95+
- Grow this into a full MyST / Jupyter Book textbook companion.

‎lite/verify_browser.py‎

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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

Comments
 (0)