Deploy web Studio / deploy (push) Successful in 3m3s
✦ Agent in the Studio's top bar opens a floating chat window. The model (any OpenAI-compatible endpoint with tool calling; default B4L's inference server, https://inference.p0.contact/v1) gets the 15 MCP tools plus two page tools (select_object, set_view) and the Studio's state (selection, level, rotation) in its system prompt. Answers and reasoning stream in; tool calls show as rows with arguments and results; object ids link to the object; Stop ends a turn; Undo takes the agent's edits back. The window moves by its title bar and resizes at its top-left corner. - studio/agent.js: shared by both front ends (waits for studio.js's isokit:studio event); MCP over /mcp or isokit.mcp in the web Studio - isokit studio: /api/agent/{config,models,chat} relay the calls on their own threads (ureq, chunked and flushed per read), so the key stays on the machine and the endpoint needs no CORS; settings from ISOKIT_LLM_URL / _KEY / _MODEL or agent.json in the user's config folder - web Studio: calls the endpoint from the page with a key kept in the browser; build_web.py --agent-url names a same-site relay as default; the deploy builds with /llm/v1, which deploy/Caddyfile forwards to the inference server (its nginx refuses the CORS preflight) - tools/mock_llm.py + tools/agent_checks.py: the agent window checks in both check_studio.py and check_web.py
332 lines
18 KiB
Python
332 lines
18 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}")
|
|
|
|
# ---------------------------------------------------------------- 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]
|
|
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)
|