Files
isokit/tools/check_web.py
T
grabowski 41554801ea
Deploy web Studio / deploy (push) Successful in 3m53s
agent window: a site's relay stands in for the endpoint it relays
On isokit.buildfor.life, entering https://inference.p0.contact/v1 in the
agent window's settings made the page call the inference server directly,
which browsers refuse (its nginx answers the CORS preflight with 401):
"The browser could not reach ... (Failed to fetch)".

- the relay meta names its upstream (data-upstream; build_web.py
  --agent-upstream, the deploy passes the inference server): that URL now
  goes through the relay and is saved as "follow the site's default"
- an unreachable endpoint says what to do: the relay's URL on a site with
  one, else isokit studio or isokit.buildfor.life
- check_web.py: relayed upstream and the unreachable message
2026-10-23 19:51:29 +07:00

360 lines
20 KiB
Python

"""Functional test of the web Studio (dist/web), headless Edge.
python tools/build_web.py
uv run --with playwright python tools/check_web.py <screenshot dir> [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, a folder project (the origin-private file system stands in for a
folder picked with showDirectoryPicker: same handle API) following outside
edits, and the agent window on a mock model (tools/mock_llm.py, started here).
"""
import io
import json
import subprocess
import sys
import time
import zipfile
from pathlib import Path
from playwright.sync_api import sync_playwright
import agent_checks
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)
mock = agent_checks.start_mock()
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('<svg'); }""")
check(icon, "favicon link loads an SVG")
shot(pg, "web-start.jpg")
# ---------------------------------------------------------------- edit: place, drag, props, undo
pg.locator('.st-tile[data-type="tree"]').click()
stage = pg.locator(".iso-stage").bounding_box()
pg.mouse.click(stage["x"] + stage["width"] * 0.30, stage["y"] + stage["height"] * 0.62)
pg.wait_for_timeout(500)
d = doc(pg)
check(len(d["objects"]) == n0 + 1 and d["objects"][-1]["type"] == "tree", "click on the map adds a tree")
check(pg.locator("#right h3").inner_text().strip().endswith("Tree"), "the new tree is selected")
check(pg.locator("#save").inner_text() == "Saved", "save indicator says Saved")
g = obj(pg, "genset")
box = pg.locator('.iso-svg g[data-obj="genset"] path.iso-t').nth(1).bounding_box()
cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
pg.mouse.move(cx, cy)
pg.mouse.down()
for i in range(1, 11):
pg.mouse.move(cx + 9 * i, cy + 3 * i)
pg.wait_for_timeout(20)
pg.mouse.up()
pg.wait_for_timeout(500)
g2 = obj(pg, "genset")
check((g2["x"], g2["y"]) != (g["x"], g["y"]), f"drag moves the generator ({g['x']},{g['y']}) -> ({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")
# ---------------------------------------------------------------- agent window (mock model, key kept in the browser)
pg.evaluate("(u) => localStorage.setItem('isokit-agent', JSON.stringify({url: u, key: 'test-key'}))", agent_checks.MOCK + "/v1")
agent_checks.run(pg, lambda: doc(pg)["objects"], lambda n: shot(pg, "web-" + n), check, can_set_key=True)
stored = pg.evaluate("() => JSON.parse(localStorage.getItem('isokit-agent'))")
check(stored.get("key") == "test-key" and not stored.get("model"), f"settings stay in this browser {stored}")
# a site relay (here: the mock) stands in for its upstream, which pages may not call directly
upstream = "https://inference.p0.contact/v1"
pg.evaluate("""([u, up]) => { const m = document.createElement('meta'); m.name = 'isokit-agent'; m.content = u;
m.setAttribute('data-upstream', up); document.head.appendChild(m); }""", [agent_checks.MOCK + "/v1", upstream])
pg.locator("#agent").click()
pg.locator("#agset").click()
pg.locator("#agurl").fill(upstream + "/")
n_chat = len(agent_checks.chats())
pg.locator('#agsetf button[type="submit"]').click()
pg.wait_for_function("() => document.querySelector('#agwho .ag-on')", timeout=5000)
check(pg.evaluate("() => JSON.parse(localStorage.getItem('isokit-agent')).url") is None,
"entering the relayed endpoint keeps following the site's default")
pg.locator("#agtext").fill("hello via the relay")
pg.locator("#agtext").press("Enter")
pg.wait_for_function("() => !document.querySelector('.ag-busy')", timeout=10000)
check(len(agent_checks.chats()) == n_chat + 1, "the relayed endpoint is called through the relay")
# no relay for another site: the message says what to do
pg.evaluate("() => document.querySelector('meta[name=isokit-agent]').remove()")
pg.locator("#agset").click() if pg.locator("#agsetf").is_hidden() else None
pg.locator("#agurl").fill("http://127.0.0.1:7939/v1")
pg.locator('#agsetf button[type="submit"]').click()
pg.wait_for_function("() => /could not reach/.test(document.getElementById('agsetmsg').textContent)", timeout=15000)
msg = pg.locator("#agsetmsg").inner_text()
check("CORS" in msg and "isokit studio" in msg, "an unreachable endpoint on another site explains CORS and the way out: " + msg[:90])
shot(pg, "web-agent-unreachable.jpg")
pg.evaluate("() => localStorage.setItem('isokit-agent', JSON.stringify({url: arguments[0], key: 'test-key'}))".replace("arguments[0]", "'" + agent_checks.MOCK + "/v1'"))
pg.locator("#agclose").click()
# ---------------------------------------------------------------- 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")
# the browser logs the 401 of the agent window's wrong-key check
errs = [e for e in errs if "status of 401" not in e and "ERR_CONNECTION_REFUSED" not in e]
check(not errs, f"no page errors {errs[:5]}")
b.close()
finally:
srv.terminate()
mock.terminate()
print("\n%d failure(s)" % len(fails))
sys.exit(1 if fails else 0)