"""Functional test of the isokit Studio (GUI), its MCP endpoint and the agent window, headless Edge. The agent window runs on a mock model this script starts: ISOKIT_LLM_URL=http://127.0.0.1:7932/v1 ISOKIT_LLM_KEY=test-key ISOKIT_CONFIG_DIR= \ isokit studio --port 7890 --no-open uv run --with playwright python tools/check_studio.py http://127.0.0.1:7890 """ import json import sys import urllib.request from pathlib import Path from playwright.sync_api import sync_playwright import agent_checks 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) mock = agent_checks.start_mock() 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") # build a house: a room (floor + 4 walls in one step), a window and a door snapped onto its walls, # the wall carrying its window when dragged and deleted, furniture on the first floor, the cut-open level to_client = """(w) => { const v = __studio.view, s = v.screenOf(w[0], w[1], w[2] || 0), svg = v.svg, pt = svg.createSVGPoint(); pt.x = s[0]; pt.y = s[1]; const q = pt.matrixTransform(svg.getScreenCTM()); return [q.x, q.y]; }""" at = lambda x, y: pg.evaluate(to_client, [x, y, 0]) pg.evaluate("__studio.select(null)") pg.evaluate("__studio.view.focus([25.5, 4, 0], 3)") pg.wait_for_timeout(400) n1 = len(doc()["objects"]) pg.locator('.st-tile[data-type="@room"]').click() a, b2 = at(23, 2), at(28, 6) pg.mouse.move(*a) pg.mouse.down() for i in range(1, 13): pg.mouse.move(a[0] + (b2[0] - a[0]) * i / 12, a[1] + (b2[1] - a[1]) * i / 12) pg.wait_for_timeout(15) check(pg.locator(".st-ghost").count() == 5 and "5.00 × 4.00" in pg.locator("#hint").inner_text(), "the Room tool previews a 5 x 4 m room while dragging") pg.screenshot(path=str(OUT / "studio-room-drag.jpg"), type="jpeg", quality=80) pg.mouse.up() pg.wait_for_timeout(800) new = doc()["objects"][n1:] kinds = sorted(o["type"] for o in new) check(kinds == ["floor", "house_wall", "house_wall", "house_wall", "house_wall"], f"a room is a floor and four walls: {kinds}") fl = next((o for o in new if o["type"] == "floor"), {}) check(fl.get("props") == {"w": 5, "d": 4} or fl.get("props") == {"w": 5.0, "d": 4.0}, f"the floor is 5 x 4 m: {fl.get('props')}") south = next((o for o in new if o["type"] == "house_wall" and abs(o["y"] - 2) < 1e-6), None) pg.locator('.st-tile[data-type="window"]').click() pg.mouse.move(*at(25.1, 1.6)) pg.mouse.move(*at(25.2, 1.7)) check(pg.locator(".st-ghost").count() == 1, "hovering a wall with a window shows where it goes") pg.mouse.click(*at(25.2, 1.7)) pg.wait_for_timeout(800) win = doc()["objects"][-1] check(win["type"] == "window" and abs(win["y"] - 2) < 1e-6 and abs(win["x"] - 25) < 1e-6, f"the window snaps onto the wall: ({win['x']}, {win['y']})") pg.locator('.st-tile[data-type="door"]').click() pg.mouse.click(*at(27.6, 4.2)) pg.wait_for_timeout(800) door = doc()["objects"][-1] check(door["type"] == "door" and abs(door["x"] - 28) < 1e-6 and door.get("rot") == 90, f"the door snaps onto the east wall and opens into the room: {door}") # drag the south wall 1 m south: its window comes along, as one undo step pg.evaluate("__studio.select(null)") wb = pg.locator(f'.iso-svg g[data-obj="{south["id"]}"] path.iso-t').first.bounding_box() cx, cy = wb["x"] + wb["width"] / 2, wb["y"] + wb["height"] / 2 d0, d1 = at(25, 2), at(25, 1) pg.mouse.move(cx, cy) pg.mouse.down() for i in range(1, 11): pg.mouse.move(cx + (d1[0] - d0[0]) * i / 10, cy + (d1[1] - d0[1]) * i / 10) pg.wait_for_timeout(15) pg.mouse.up() pg.wait_for_timeout(800) check(abs(obj(south["id"])["y"] - 1) < 1e-6 and abs(obj(win["id"])["y"] - 1) < 1e-6, "dragging the wall moves its window along") pg.locator("#undo").click() pg.wait_for_timeout(700) check(abs(obj(south["id"])["y"] - 2) < 1e-6 and abs(obj(win["id"])["y"] - 2) < 1e-6, "one undo puts wall and window back") pg.locator(f'#objects li[data-id="{south["id"]}"]').click() pg.locator(".iso-svg").focus() pg.keyboard.press("Delete") pg.wait_for_timeout(800) check(obj(south["id"]) is None and obj(win["id"]) is None, "deleting the wall deletes its window") pg.keyboard.press("Control+z") pg.wait_for_timeout(800) check(obj(win["id"]) is not None, "undo brings both back") # the cut-open level: walls in front of the room low, the others full height rv = get("/api/render?level=gf&rot=1") tops = sorted(round(rv["boxes"][o["id"]][5], 2) for o in new if o["type"] == "house_wall") check(tops == [1.0, 1.0, 2.8, 2.8], f"GF cuts the two walls in front of the room low: {tops}") # furniture placed while 1F is shown goes onto the first floor pg.evaluate("__studio.select(null)") pg.locator('.iso-seg button[data-level="1f"]').click() pg.wait_for_timeout(600) pg.locator('.st-tile[data-type="chair"]').click() pg.mouse.click(*at(25, 4)) pg.wait_for_timeout(800) chair = doc()["objects"][-1] check(chair["type"] == "chair" and chair.get("z") == 3, f"a chair placed on 1F stands at z = 3: {chair.get('z')}") pg.locator('.iso-seg button[data-level="gf"]').click() pg.wait_for_timeout(600) pg.screenshot(path=str(OUT / "studio-house-gf.jpg"), type="jpeg", quality=80) pg.locator('.iso-seg button[data-level="site"]').click() pg.evaluate("__studio.select(null)") pg.wait_for_timeout(400) # the middle mouse button only moves the camera: it never drags, selects or places anything pg.locator('#objects li[data-id="truck"]').click() pg.evaluate("__studio.view.focus('truck', 3)") pg.wait_for_timeout(400) t0 = obj("truck") p0 = at(t0["x"], t0["y"]) tb = pg.locator('.iso-svg g[data-obj="truck"] path.iso-t').first.bounding_box() mx, my = tb["x"] + tb["width"] / 2, tb["y"] + tb["height"] / 2 pg.mouse.move(mx, my) pg.mouse.down(button="middle") for i in range(1, 11): pg.mouse.move(mx + 8 * i, my + 5 * i) pg.wait_for_timeout(15) pg.mouse.up(button="middle") pg.wait_for_timeout(600) t1, p1 = obj("truck"), at(t0["x"], t0["y"]) check((t1["x"], t1["y"]) == (t0["x"], t0["y"]) and abs(p1[0] - p0[0] - 80) < 3 and abs(p1[1] - p0[1] - 50) < 3, f"a middle-drag on the selected truck pans the view and leaves the truck where it was (moved {p1[0] - p0[0]:.0f}, {p1[1] - p0[1]:.0f} px)") gb = pg.locator('.iso-svg g[data-obj="genset"] path.iso-t').first.bounding_box() pg.mouse.click(gb["x"] + gb["width"] / 2, gb["y"] + gb["height"] / 2, button="middle") pg.wait_for_timeout(400) check(pg.evaluate("__studio.S.sel") == "truck", "a middle click on another object does not select it") n2 = len(doc()["objects"]) pg.locator('.st-tile[data-type="tree"]').click() pg.mouse.move(mx, my) pg.mouse.down(button="middle") for i in range(1, 11): pg.mouse.move(mx - 8 * i, my - 5 * i) pg.wait_for_timeout(15) pg.mouse.up(button="middle") pg.wait_for_timeout(600) check(len(doc()["objects"]) == n2 and pg.evaluate("__studio.S.placing") == "tree", "while placing, a middle-drag pans and places nothing") pg.keyboard.press("Escape") pg.evaluate("__studio.select(null)") pg.wait_for_timeout(300) # 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) # the agent window, on the mock model (the Studio must run with ISOKIT_LLM_URL=/v1, see the docstring) cfg = get("/api/agent/config") if agent_checks.MOCK not in cfg["url"]: check(False, "agent window checks need the Studio started with ISOKIT_LLM_URL=" + agent_checks.MOCK + "/v1 (found " + cfg["url"] + ")") else: check(not cfg.get("key") and cfg["has_key"] and cfg["key_from"] == "env", "/api/agent/config says a key is set, without the key") pg.locator('.iso-seg button[data-level="site"]').click() agent_checks.run(pg, lambda: doc()["objects"], lambda n: pg.screenshot(path=str(OUT / n), type="jpeg", quality=80), check, can_set_key=False) check(not errs, f"no script errors {errs}") b.close() mock.terminate() print("FAILED:" if fails else "ALL PASSED", fails) sys.exit(1 if fails else 0)