isokit: web Studio (engine as WASM, projects in the browser or a live folder)
- isokit-wasm: wasm-bindgen build of the engine; a project in memory behind the same JSON API as `isokit studio`, plus the MCP tools - isokit-project: Store trait (FsStore folder, MemStore memory) under a generic Workspace; Project::from_files; shared Studio API module (api.rs) used by serve.rs and the WASM build; web_time for clocks in the browser - studio.js runs on a pluggable backend (window.IsoKitBackend); no Studio keys while a dialog is open - web/: projects in IndexedDB, new/demo/delete, zip export/import (stored and deflated), folder import by drop or picker, folders opened live via File System Access with outside edits followed, code editor, page build and preview, two tabs in step, isokit.mcp() and WebMCP registration - tools/build_web.py -> dist/web (static), tools/check_web.py (44 checks)
This commit is contained in:
1 parent
564aeb75f6
commit
5eb60d2a0c
20 files changed
+1950
-178
No files matched your search
@@ -0,0 +1,125 @@
|
||||
"""Build the web Studio: the Studio GUI with the isokit engine compiled to WASM.
|
||||
|
||||
python tools/build_web.py [--out dist/web] [--skip-cargo]
|
||||
|
||||
Needs the wasm32-unknown-unknown target (rustup target add wasm32-unknown-unknown)
|
||||
and the wasm-bindgen CLI at the version in Cargo.lock. wasm-opt (binaryen) is
|
||||
used when it is on PATH. The output folder is static files: serve it from any
|
||||
web server (it needs http(s), not file://).
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SKIP_DIRS = {"dist", "target", "node_modules"}
|
||||
|
||||
|
||||
def run(cmd, **kw):
|
||||
print("+", " ".join(str(c) for c in cmd), flush=True)
|
||||
subprocess.run([str(c) for c in cmd], check=True, cwd=ROOT, **kw)
|
||||
|
||||
|
||||
def lock_version(name):
|
||||
lock = (ROOT / "Cargo.lock").read_text(encoding="utf-8")
|
||||
m = re.search(r'name = "%s"\nversion = "([^"]+)"' % re.escape(name), lock)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def demo_files():
|
||||
"""The demo project as the web Studio stores projects: path -> text."""
|
||||
base = ROOT / "examples" / "demo"
|
||||
out = {}
|
||||
for p in sorted(base.rglob("*")):
|
||||
rel = p.relative_to(base).as_posix()
|
||||
parts = rel.split("/")
|
||||
if p.is_dir() or any(s.startswith(".") or s in SKIP_DIRS for s in parts[:-1]):
|
||||
continue
|
||||
if p.suffix in (".rhai", ".toml", ".json") or p.name == "README.md":
|
||||
out[rel] = p.read_text(encoding="utf-8")
|
||||
assert "isokit.toml" in out, "examples/demo has no isokit.toml"
|
||||
return out
|
||||
|
||||
|
||||
def replace_once(s, old, new):
|
||||
n = s.count(old)
|
||||
assert n == 1, f"studio/index.html: expected {old!r} once, found {n}"
|
||||
return s.replace(old, new)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--out", default=str(ROOT / "dist" / "web"))
|
||||
ap.add_argument("--skip-cargo", action="store_true", help="reuse the last WASM build (front-end changes only)")
|
||||
a = ap.parse_args()
|
||||
out = Path(a.out).resolve()
|
||||
web = out / "assets" / "web"
|
||||
wasm = ROOT / "target" / "wasm32-unknown-unknown" / "web" / "isokit_wasm.wasm"
|
||||
|
||||
if not a.skip_cargo:
|
||||
want = lock_version("wasm-bindgen")
|
||||
try:
|
||||
have = subprocess.run(["wasm-bindgen", "--version"], capture_output=True, text=True, check=True).stdout.split()[-1]
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
sys.exit(f"wasm-bindgen CLI not found: cargo install wasm-bindgen-cli --version {want}")
|
||||
if have != want:
|
||||
sys.exit(f"wasm-bindgen CLI is {have}, Cargo.lock has {want}: cargo install wasm-bindgen-cli --version {want}")
|
||||
run(["cargo", "build", "-p", "isokit-wasm", "--target", "wasm32-unknown-unknown", "--profile", "web"])
|
||||
|
||||
if out.exists():
|
||||
shutil.rmtree(out)
|
||||
web.mkdir(parents=True)
|
||||
if not wasm.exists():
|
||||
sys.exit(f"{wasm} missing: run without --skip-cargo")
|
||||
run(["wasm-bindgen", "--target", "web", "--no-typescript", "--remove-name-section",
|
||||
"--remove-producers-section", "--out-dir", web.as_posix(), wasm.as_posix()])
|
||||
bg = web / "isokit_wasm_bg.wasm"
|
||||
if shutil.which("wasm-opt"):
|
||||
run(["wasm-opt", "-Oz", "--all-features", bg.as_posix(), "-o", bg.as_posix()])
|
||||
else:
|
||||
print(" (wasm-opt not found: the .wasm stays unoptimised by binaryen, about 15% larger)")
|
||||
|
||||
for src, dst in [("viewer/isokit-view.js", "viewer"), ("viewer/isokit-view.css", "viewer"),
|
||||
("studio/studio.js", "studio"), ("studio/studio.css", "studio"),
|
||||
("web/web.js", "web"), ("web/web.css", "web")]:
|
||||
(out / "assets" / dst).mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(ROOT / src, out / "assets" / dst / Path(src).name)
|
||||
(web / "demo.json").write_text(json.dumps(demo_files(), ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
h = hashlib.sha256()
|
||||
for p in sorted((out / "assets").rglob("*")):
|
||||
if p.is_file():
|
||||
h.update(p.read_bytes())
|
||||
v = "v=" + h.hexdigest()[:10]
|
||||
|
||||
html = (ROOT / "studio" / "index.html").read_text(encoding="utf-8")
|
||||
html = replace_once(html, '<title>isokit Studio</title>',
|
||||
'<title>isokit Studio</title>\n<meta name="description" content="isokit Studio: edit isometric site plans in the browser.">')
|
||||
html = replace_once(html, 'href="/assets/viewer/isokit-view.css"', f'href="assets/viewer/isokit-view.css?{v}"')
|
||||
html = replace_once(html, '<link rel="stylesheet" href="/assets/theme.css">', '<style id="iso-theme"></style>')
|
||||
html = replace_once(html, '<link rel="stylesheet" href="/assets/studio/studio.css">',
|
||||
f'<link rel="stylesheet" href="assets/studio/studio.css?{v}">\n<link rel="stylesheet" href="assets/web/web.css?{v}">')
|
||||
html = replace_once(html, 'href="/view"', 'href="#"')
|
||||
html = replace_once(html, '<script src="/assets/viewer/isokit-view.js"></script>', f'<script src="assets/viewer/isokit-view.js?{v}"></script>')
|
||||
html = replace_once(html, '<script src="/assets/studio/studio.js"></script>',
|
||||
f'<script type="module" src="assets/web/web.js?{v}"></script>\n'
|
||||
'<noscript>The isokit Studio needs JavaScript and WebAssembly.</noscript>')
|
||||
assert "/assets/" not in html, "an absolute /assets/ URL is left in index.html"
|
||||
(out / "index.html").write_text(html, encoding="utf-8")
|
||||
|
||||
total = 0
|
||||
for p in sorted(out.rglob("*")):
|
||||
if p.is_file():
|
||||
total += p.stat().st_size
|
||||
print(f" {p.relative_to(out).as_posix():40} {p.stat().st_size / 1024:8.1f} kB")
|
||||
print(f"{out} ({total / 1024 / 1024:.1f} MB, {v})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,313 @@
|
||||
"""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, 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")
|
||||
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")
|
||||
|
||||
# ---------------------------------------------------------------- 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)
|
||||
Reference in new issue
Block a user