"""Functional test of the isokit Studio (GUI) and its MCP endpoint, headless Edge.""" import json import sys import urllib.request from pathlib import Path from playwright.sync_api import sync_playwright BASE = sys.argv[2] if len(sys.argv) > 2 else "http://127.0.0.1:7878" OUT = Path(sys.argv[1]) OUT.mkdir(parents=True, exist_ok=True) fails = [] def check(cond, msg): print(("ok " if cond else "FAIL ") + msg) if not cond: fails.append(msg) def get(path): return json.loads(urllib.request.urlopen(BASE + path).read()) def mcp(msg): req = urllib.request.Request(BASE + "/mcp", data=json.dumps(msg).encode(), headers={"Content-Type": "application/json"}) r = urllib.request.urlopen(req) body = r.read() return json.loads(body) if body else None def doc(): return get("/api/doc")["doc"] def obj(id_): return next((o for o in doc()["objects"] if o["id"] == id_), None) with sync_playwright() as p: b = p.chromium.launch(channel="msedge", headless=True) pg = b.new_page(viewport={"width": 1500, "height": 950}) errs = [] pg.on("pageerror", lambda e: errs.append(str(e))) pg.on("console", lambda m: errs.append(m.text) if m.type == "error" else None) pg.goto(BASE + "/") pg.wait_for_selector(".iso-svg [data-obj]", timeout=10000) pg.wait_for_timeout(500) n0 = len(doc()["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") ver = pg.locator("#ver").inner_text() check(ver == "isokit " + get("/api/meta")["engine"], "status bar shows the engine version: " + ver) icon = urllib.request.urlopen(BASE + "/assets/studio/favicon.svg") check(icon.headers.get_content_type() == "image/svg+xml" and b" { const e = document.elementFromPoint(x, y); const g = e && e.closest('[data-obj]'); return g && g.getAttribute('data-obj'); }", [cx, cy]) check(hit == "genset", f"pointer is on the generator ({hit})") 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(700) g2 = obj("genset") check((g2["x"], g2["y"]) != (g["x"], g["y"]), f"drag moves the generator ({g['x']},{g['y']}) -> ({g2['x']},{g2['y']})") check(abs(g2["x"] * 2 - round(g2["x"] * 2)) < 1e-9, "the move snaps to 0.5 m") # edit a property of the building in the form pg.locator('#objects li[data-id="hall"]').click() pg.wait_for_timeout(300) inp = pg.locator('#right input[type=number][data-prop="floors"]') inp.fill("3") inp.press("Enter") pg.wait_for_timeout(700) check(obj("hall")["props"].get("floors") == 3, "the form sets floors = 3") pg.screenshot(path=str(OUT / "studio-selected.jpg"), type="jpeg", quality=80) # undo the floors change pg.locator("#undo").click() pg.wait_for_timeout(700) check(obj("hall")["props"].get("floors") == 2, "undo restores floors = 2") # keyboard: rotate and delete the new tree pg.locator(f'#objects li[data-id="{new["id"]}"]').click() pg.locator(".iso-svg").focus() pg.keyboard.press("Delete") pg.wait_for_timeout(700) check(obj(new["id"]) is None, "Delete removes the selected object") # an agent edits over MCP: the open Studio follows init = mcp({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "0"}}}) check(init["result"]["serverInfo"]["name"] == "isokit", "MCP initialize") check(mcp({"jsonrpc": "2.0", "method": "notifications/initialized"}) is None, "MCP notification gets no body") tools = mcp({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})["result"]["tools"] check(len(tools) >= 14, f"MCP lists {len(tools)} tools") r = mcp({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "add_object", "arguments": {"type": "container", "x": 20, "y": -1, "rot": 90, "name": "Spare parts", "props": {"stack": 2}}}}) res = json.loads(r["result"]["content"][0]["text"]) check(not r["result"]["isError"] and res["id"] == "container", f"MCP add_object -> {res.get('id')}, box {res.get('object', {}).get('box')}") bad = mcp({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "add_object", "arguments": {"type": "ufo", "x": 0, "y": 0}}}) check(bad["result"]["isError"] and "available" in bad["result"]["content"][0]["text"], "MCP explains a bad type") pg.wait_for_timeout(2000) check(pg.locator('#objects li[data-id="container"]').count() == 1, "the Studio shows the agent's container") check(pg.locator('.iso-svg g[data-obj="container"]').count() >= 1, "the map draws the agent's container") summ = json.loads(mcp({"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "render_view", "arguments": {"level": "gf"}}})["result"]["content"][0]["text"]) check(summ["sprites"] > 20 and "container" in summ["boxes"], f"MCP render_view summary: {summ['sprites']} sprites, {summ['nodes']} nodes") chk = json.loads(mcp({"jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": {"name": "check_project", "arguments": {}}})["result"]["content"][0]["text"]) check(chk["ok"], f"MCP check_project ok ({len(chk['findings'])} findings)") # cross-origin edits are refused req = urllib.request.Request(BASE + "/api/op", data=b'{"op":"remove","id":"hall"}', headers={"Content-Type": "application/json", "Origin": "http://evil.example"}) try: urllib.request.urlopen(req) check(False, "cross-origin POST refused") except urllib.error.HTTPError as e: check(e.code == 403, "cross-origin POST refused") # view controls still work in the Studio pg.locator('.iso-seg button[data-level="gf"]').click() pg.wait_for_timeout(600) check(pg.locator('.iso-seg button[data-level="gf"]').get_attribute("aria-pressed") == "true", "level switch") pg.locator('[data-a="rotr"]').click() pg.wait_for_timeout(600) pg.screenshot(path=str(OUT / "studio-gf.jpg"), type="jpeg", quality=80) check(not errs, f"no script errors {errs}") b.close() print("FAILED:" if fails else "ALL PASSED", fails) sys.exit(1 if fails else 0)