"""Functional test of the web Studio (dist/web), headless Edge. python tools/build_web.py uv run --with playwright python tools/check_web.py [dist/web] Serves the build on a local port and checks: the demo loads from the bundled files, placing / dragging / props / undo, persistence across reloads, the code editor (script error and repair), new / export / import (.zip, stored and deflated), page build and preview, MCP in the page and WebMCP, two tabs on one project, and a folder project (the origin-private file system stands in for a folder picked with showDirectoryPicker: same handle API) following outside edits. """ import io import json import subprocess import sys import time import zipfile from pathlib import Path from playwright.sync_api import sync_playwright ROOT = Path(__file__).resolve().parent.parent OUT = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "dist" / "web-shots" DIST = Path(sys.argv[2]) if len(sys.argv) > 2 else ROOT / "dist" / "web" PORT = 7931 BASE = f"http://127.0.0.1:{PORT}/" OUT.mkdir(parents=True, exist_ok=True) fails = [] def check(cond, msg): print(("ok " if cond else "FAIL ") + msg, flush=True) if not cond: fails.append(msg) def doc(pg): return pg.evaluate("() => JSON.parse(isokit.studio.state()).doc") def obj(pg, id_): return next((o for o in doc(pg)["objects"] if o["id"] == id_), None) def ready(pg): pg.wait_for_selector(".iso-svg [data-obj]", timeout=15000) pg.wait_for_timeout(400) def shot(pg, name): pg.screenshot(path=str(OUT / name), type="jpeg", quality=80) def title(pg): return pg.locator("#title").inner_text().strip() WEBMCP_STUB = "navigator.modelContext = { provideContext: function (c) { window.__webmcp = c; } };" srv = subprocess.Popen([sys.executable, "-m", "http.server", str(PORT), "--bind", "127.0.0.1", "--directory", str(DIST)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) time.sleep(1.0) try: with sync_playwright() as p: b = p.chromium.launch(channel="msedge", headless=True) ctx = b.new_context(viewport={"width": 1500, "height": 950}, accept_downloads=True) ctx.add_init_script(WEBMCP_STUB) errs = [] def watch(page): page.on("pageerror", lambda e: errs.append(str(e))) page.on("console", lambda m: errs.append(m.text) if m.type == "error" else None) pg = ctx.new_page() watch(pg) # ---------------------------------------------------------------- first visit: the demo t0 = time.time() pg.goto(BASE) ready(pg) check(time.time() - t0 < 8, f"first visit opens the demo in {time.time() - t0:.1f} s") check(title(pg) == "isokit demo works", "the demo project is open") n0 = len(doc(pg)["objects"]) check(pg.locator("#objects li").count() == n0, f"object list shows {n0} objects") check(pg.locator(".st-tile").count() >= 20, "palette lists the prefabs") check(pg.locator("#wb-where").inner_text() == "in this browser", "header says where the project lives") check("ms" in pg.locator("#stats").inner_text(), "render stats show: " + pg.locator("#stats").inner_text()) check(pg.evaluate("() => getComputedStyle(document.querySelector('.iso-svg .iso-t')).fill") not in ("", "rgb(0, 0, 0)"), "theme CSS styles the scene") check(pg.evaluate("() => isokit.webmcp") == 15, "15 MCP tools registered with WebMCP") ver = pg.locator("#ver").inner_text() check(ver.startswith("isokit " + pg.evaluate("() => isokit.engine") + " ยท "), "status bar shows version and build: " + ver) icon = pg.evaluate("""async () => { const l = document.querySelector('link[rel=icon]'); const r = await fetch(l.href); return r.ok && (await r.text()).includes(' ({g2['x']},{g2['y']})") pg.locator('#objects li[data-id="hall"]').click() pg.locator("#p-floors").fill("3") pg.locator("#p-floors").press("Enter") pg.wait_for_timeout(500) check(obj(pg, "hall")["props"].get("floors") == 3, "prop form edits the building (3 floors)") pg.keyboard.press("Escape") pg.locator("#undo").click() pg.wait_for_timeout(400) check(obj(pg, "hall")["props"].get("floors") == 2, "undo restores 2 floors") pg.locator("#redo").click() pg.wait_for_timeout(400) check(obj(pg, "hall")["props"].get("floors") == 3, "redo") # ---------------------------------------------------------------- persistence n1 = len(doc(pg)["objects"]) pg.reload() ready(pg) check(len(doc(pg)["objects"]) == n1 and obj(pg, "hall")["props"].get("floors") == 3, "edits survive a reload (IndexedDB)") # ---------------------------------------------------------------- code editor pg.locator("#wb-code").click() pg.wait_for_selector("#wb-codedlg[open]") files = pg.locator("#wb-cfiles li").all_inner_texts() check({"isokit.toml", "scene.json", "scene.rhai", "lib/kiosk.rhai", "theme.toml"} <= set(files), f"code editor lists the files {files}") check(pg.locator("#wb-cpath").inner_text() == "scene.rhai", "it opens the main script") good = pg.locator("#wb-ctext").input_value() pg.locator("#wb-ctext").fill(good + "\nlet broken = ;\n") pg.keyboard.press("Control+s") pg.wait_for_timeout(1200) msg = pg.locator("#wb-cmsg").inner_text() check("scene.rhai:" in msg and pg.locator("#wb-cmsg.wb-bad").count() == 1, f"a script error shows with its line: {msg.strip()[:80]}") shot(pg, "web-code-error.jpg") pg.locator("#wb-ctext").fill(good + "\n// edited in the browser\n") pg.locator("#wb-capply").click() pg.wait_for_timeout(1200) check(pg.locator("#wb-cmsg.wb-bad").count() == 0 and "Applied" in pg.locator("#wb-cmsg").inner_text(), "repaired script applies cleanly") pg.locator("#wb-cfiles li", has_text="theme.toml").click() check("palette" in pg.locator("#wb-ctext").input_value() or "#" in pg.locator("#wb-ctext").input_value(), "switching files shows theme.toml") shot(pg, "web-code.jpg") pg.locator("#wb-cclose").click() pg.wait_for_timeout(1000) check("script error" not in pg.locator("#stats").inner_text(), "the scene renders again after the fix") # ---------------------------------------------------------------- build page and preview with pg.expect_download() as dl: pg.locator("#build").click() html = Path(dl.value.path()).read_text(encoding="utf-8") check(dl.value.suggested_filename.endswith(".html") and "IsoKit.mount" in html and '"site/1"' in html, f"Build page downloads {dl.value.suggested_filename} ({len(html) // 1024} kB)") with ctx.expect_page() as pop: pg.locator("#preview").click() prev = pop.value prev.wait_for_selector(".iso-svg [data-obj]", timeout=10000) check(prev.locator(".iso-svg [data-obj]").count() > 10, "Preview opens the built page in a tab") prev.close() # ---------------------------------------------------------------- MCP in the page and WebMCP r = pg.evaluate("""() => isokit.mcp({jsonrpc: "2.0", id: 7, method: "tools/call", params: {name: "add_object", arguments: {type: "lamp", x: 2, y: -14, id: "agent-lamp"}}})""") check(r["result"]["isError"] is False, "isokit.mcp(add_object) works") pg.wait_for_timeout(1200) check(pg.locator('#objects li[data-id="agent-lamp"]').count() == 1, "the Studio follows the agent's edit") check("agent" in pg.locator("#toast").inner_text().lower(), "toast names the agent: " + pg.locator("#toast").inner_text()) r = pg.evaluate("""async () => { const t = window.__webmcp.tools.find(t => t.name === "update_object"); return t.execute({id: "agent-lamp", tag: "via WebMCP"}); }""") check(r["isError"] is False and obj(pg, "agent-lamp")["tag"] == "via WebMCP", "a WebMCP tool call edits the scene") # ---------------------------------------------------------------- two tabs on one project pg2 = ctx.new_page() watch(pg2) pg2.goto(pg.url) ready(pg2) pg2.evaluate("() => __studio.op({op: 'remove', id: 'agent-lamp'})") pg.wait_for_timeout(1800) check(pg.locator('#objects li[data-id="agent-lamp"]').count() == 0, "an edit in another tab shows up here") pg2.close() # ---------------------------------------------------------------- export, new, import demo_id = pg.evaluate("() => location.hash") pg.locator("#wb-projects").click() pg.wait_for_selector("#wb-projdlg[open]") shot(pg, "web-projects.jpg") with pg.expect_download() as dl: pg.locator('#wb-cur button[data-act="export"]').click() zpath = OUT / dl.value.suggested_filename dl.value.save_as(zpath) with zipfile.ZipFile(zpath) as z: names = z.namelist() check(z.testzip() is None and any(n.endswith("/isokit.toml") for n in names), f"Export .zip is a valid zip ({len(names)} files)") check("// edited in the browser" in z.read(next(n for n in names if n.endswith("scene.rhai"))).decode(), "the export has the edited script") pg.locator("#wb-ntitle").fill("Test site") pg.locator("#wb-ncreate").click() pg.wait_for_load_state() ready(pg) check(title(pg) == "Test site" and len(doc(pg)["objects"]) == 5, "New project opens the starter scene") pg.locator("#wb-projects").click() pg.locator("#wb-fzip").set_input_files(str(zpath)) pg.wait_for_timeout(1500) ready(pg) check(title(pg) == "isokit demo works" and len(doc(pg)["objects"]) == n1, "importing the exported .zip restores the project") # a zip made elsewhere: deflated, nested one folder deeper, with junk buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: for f in (ROOT / "examples" / "demo").rglob("*"): if f.is_file(): z.write(f, "repo/scenes/plant/" + f.relative_to(ROOT / "examples" / "demo").as_posix()) z.writestr("repo/README.md", "not part of it") dz = OUT / "deflated.zip" dz.write_bytes(buf.getvalue()) pg.locator("#wb-projects").click() pg.locator("#wb-fzip").set_input_files(str(dz)) pg.wait_for_timeout(1500) ready(pg) fl = pg.evaluate("() => Object.keys(isokit.files()).sort()") check(title(pg) == "isokit demo works" and "dist/index.html" not in fl and "lib/kiosk.rhai" in fl, f"a deflated zip with the project in a subfolder imports: {fl}") bad = OUT / "bad.zip" with zipfile.ZipFile(bad, "w") as z: z.writestr("x/readme.txt", "no project") pg.locator("#wb-projects").click() pg.locator("#wb-fzip").set_input_files(str(bad)) pg.wait_for_timeout(600) check("isokit.toml" in pg.locator("#toast").inner_text(), "a zip without a project is refused: " + pg.locator("#toast").inner_text()) n_proj = pg.locator("#wb-plist li").count() pg.once("dialog", lambda d: d.accept()) pg.locator("#wb-plist li", has_text="Test site").locator('button[data-act="del"]').click() pg.wait_for_timeout(600) check(pg.locator("#wb-plist li").count() == n_proj - 1, "a project can be deleted") pg.keyboard.press("Escape") # ---------------------------------------------------------------- folder project (OPFS stands in for a picked folder) demo = {f.relative_to(ROOT / "examples" / "demo").as_posix(): f.read_text(encoding="utf-8") for f in (ROOT / "examples" / "demo").rglob("*") if f.is_file() and "dist" not in f.parts} fid = pg.evaluate("""async (files) => { const root = await navigator.storage.getDirectory(); await root.removeEntry("plant", {recursive: true}).catch(() => {}); const dir = await root.getDirectoryHandle("plant", {create: true}); for (const [path, text] of Object.entries(files)) { const parts = path.split("/"); let d = dir; for (const s of parts.slice(0, -1)) d = await d.getDirectoryHandle(s, {create: true}); const w = await (await d.getFileHandle(parts.at(-1), {create: true})).createWritable(); await w.write(text); await w.close(); } const id = "p-folder-test"; await new Promise((res, rej) => { const r = indexedDB.open("isokit-studio", 1); r.onsuccess = () => { const t = r.result.transaction("projects", "readwrite"); t.objectStore("projects").put({id, name: "plant", kind: "folder", handle: dir, updated: Date.now()}); t.oncomplete = res; t.onerror = rej; }; }); return id; }""", demo) pg.goto("about:blank") pg.goto(BASE + "#p=" + fid) ready(pg) check(pg.locator("#wb-where").inner_text() == "๐Ÿ“ plant", "a folder project opens from its handle") pg.locator('.st-tile[data-type="tank"]').click() stage = pg.locator(".iso-stage").bounding_box() pg.mouse.click(stage["x"] + stage["width"] * 0.25, stage["y"] + stage["height"] * 0.70) pg.wait_for_timeout(800) on_disk = pg.evaluate("""async () => { const d = await (await navigator.storage.getDirectory()).getDirectoryHandle("plant"); return JSON.parse(await (await (await d.getFileHandle("scene.json")).getFile()).text()); }""") check(any(o["type"] == "tank" and o["id"] != "tank" for o in on_disk["objects"]), "an edit is written to the folder's scene.json") # an agent edits the file on disk: the Studio follows pg.evaluate("""async () => { const d = await (await navigator.storage.getDirectory()).getDirectoryHandle("plant"); const h = await d.getFileHandle("scene.json"); const doc = JSON.parse(await (await h.getFile()).text()); doc.objects = doc.objects.filter(o => o.type !== "lamp"); const w = await h.createWritable(); await w.write(JSON.stringify(doc, null, 2)); await w.close(); }""") pg.wait_for_timeout(3500) check(not any(o["type"] == "lamp" for o in doc(pg)["objects"]) and pg.locator("#objects li", has_text="Street lamp").count() == 0, "a change made on disk shows up in the Studio") check("folder" in pg.locator("#toast").inner_text().lower(), "toast: " + pg.locator("#toast").inner_text()) pg.locator("#undo").click() pg.wait_for_timeout(800) check(any(o["type"] == "lamp" for o in doc(pg)["objects"]), "the outside change can be undone") pg.locator("#build").click() pg.wait_for_timeout(1500) check("written to the folder" in pg.locator("#toast").inner_text(), "toast: " + pg.locator("#toast").inner_text()) built = pg.evaluate("""async () => { const d = await (await (await navigator.storage.getDirectory()).getDirectoryHandle("plant")).getDirectoryHandle("dist"); return (await (await d.getFileHandle("index.html")).getFile()).size; }""") check(built > 10000 and "dist/index.html" not in pg.evaluate("() => Object.keys(isokit.files())"), f"Build page writes dist/index.html into the folder ({built // 1024} kB) and keeps it out of the project files") # ---------------------------------------------------------------- dark theme, narrow screen pg.locator("#theme").click() pg.wait_for_timeout(300) shot(pg, "web-dark.jpg") pg.set_viewport_size({"width": 820, "height": 900}) pg.wait_for_timeout(400) shot(pg, "web-narrow.jpg") check(not errs, f"no page errors {errs[:5]}") b.close() finally: srv.terminate() print("\n%d failure(s)" % len(fails)) sys.exit(1 if fails else 0)