Deploy web Studio / deploy (push) Successful in 1m3s
With Windows "Animation effects" off, browsers report prefers-reduced-motion, and the viewer
turned every animation off and hid the animation button, so presses, belts and robot arms
stood still in Brave and Firefox with no way to start them. Now that preference only picks
the starting mode ("off"); the button stays (its tooltip says why it is off) and the
viewer's choice wins and is remembered. The CSS still-picture rules apply only until the
viewer turns animation on (.iso-anim-on).
Also: switching light -> off -> smooth redraws the scene so the animations paused by script
run again (they ignore CSS play states).
Checks: web 93/93 (reduced motion: starts still, one click runs it, kept after reload),
Studio 109/109, 70 Rust tests. Tried in real Brave (system setting) and Firefox.
417 lines
24 KiB
Python
417 lines
24 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")
|
|
|
|
# ---------------------------------------------------------------- the house demo: a house built from parts
|
|
pg.locator("#wb-projects").click()
|
|
pg.locator("#wb-nhouse").click()
|
|
pg.wait_for_load_state()
|
|
ready(pg)
|
|
pg.wait_for_timeout(500)
|
|
nh = len(doc(pg)["objects"])
|
|
check(title(pg) == "isokit house" and nh > 50, f"House demo opens a furnished house ({nh} objects)")
|
|
check(pg.evaluate("() => __studio.S.warnings.length") == 0 and pg.locator('.st-tile[data-type="@room"]').count() == 1,
|
|
"it draws without warnings, the palette has the Room tool")
|
|
shot(pg, "web-house.jpg")
|
|
# low-power default: the pond's fish (and every other animation) step on about ten times a
|
|
# second instead of running as CSS animations that repaint the drawing every frame
|
|
an = pg.evaluate("""() => { const a = document.querySelector('.iso-svg').getAnimations({subtree: true});
|
|
return [__studio.view.animation(), a.length, a.filter(x => x.playState === 'running').length, a.length ? a[0].currentTime : 0]; }""")
|
|
pg.wait_for_timeout(500)
|
|
t1 = pg.evaluate("() => { const a = document.querySelector('.iso-svg').getAnimations({subtree: true}); return a.length ? a[0].currentTime : 0; }")
|
|
check(an[0] == "light" and an[1] >= 6 and an[2] == 0 and t1 > an[3] and pg.locator('.iso-bar [data-a="anim"]').is_visible(),
|
|
f"animations are light by default: {an[1]} stepped, none running, time moves on; the toolbar can switch")
|
|
|
|
# ---------------------------------------------------------------- the factory demo: presses and conveyors
|
|
pg.locator("#wb-projects").click()
|
|
pg.locator("#wb-nfactory").click()
|
|
pg.wait_for_load_state()
|
|
ready(pg)
|
|
pg.wait_for_timeout(500)
|
|
nf = len(doc(pg)["objects"])
|
|
rides = pg.evaluate("() => document.querySelectorAll('.iso-svg .iso-ride').length")
|
|
check(title(pg) == "isokit factory" and nf > 15 and rides >= 5 and pg.evaluate("() => __studio.S.warnings.length") == 0,
|
|
f"Factory demo opens a production yard ({nf} objects, goods riding {rides} belts, no warnings)")
|
|
carries = pg.evaluate("() => Object.fromEntries((__studio.view.info('belt-a3') || {rows: []}).rows)['Carries'] || ''")
|
|
check("20 cartons a minute" in carries and "(from belt-a2)" in carries, "goods come down the line: " + carries)
|
|
loads = pg.evaluate("() => Object.fromEntries((__studio.view.info('belt-load') || {rows: []}).rows)['Carries'] || ''")
|
|
flips = pg.evaluate("() => document.querySelectorAll('.iso-svg .iso-flip').length")
|
|
check("(from robot-b)" in loads and flips == 32, f"robot arms pass the goods on ({flips} poses): " + loads)
|
|
shot(pg, "web-factory.jpg")
|
|
|
|
# ---------------------------------------------------------------- a system asking for less motion
|
|
# (Windows: Animation effects off): the view starts still, but the button stays and turns it on
|
|
rctx = b.new_context(viewport={"width": 1200, "height": 800}, reduced_motion="reduce")
|
|
rp = rctx.new_page()
|
|
rp.goto(BASE)
|
|
ready(rp)
|
|
rp.wait_for_timeout(500)
|
|
ride = "() => Array.from(document.querySelectorAll('.iso-svg .iso-a')).map(e => getComputedStyle(e).transform + getComputedStyle(e).opacity).join('|')"
|
|
moves = lambda: (lambda a: (rp.wait_for_timeout(700), a != rp.evaluate(ride))[1])(rp.evaluate(ride))
|
|
check(rp.evaluate("() => __studio.view.animation()") == "off" and rp.locator('.iso-bar [data-a="anim"]').is_visible() and not moves(),
|
|
"reduced motion: the view starts still, with the animation button there")
|
|
rp.locator('.iso-bar [data-a="anim"]').click()
|
|
rp.wait_for_timeout(500)
|
|
check(rp.evaluate("() => __studio.view.animation()") == "light" and moves(), "one click turns animation on despite reduced motion")
|
|
rp.reload()
|
|
ready(rp)
|
|
rp.wait_for_timeout(500)
|
|
check(rp.evaluate("() => __studio.view.animation()") == "light" and moves(), "and the choice is remembered")
|
|
rctx.close()
|
|
|
|
# ---------------------------------------------------------------- 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)
|