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
166 lines
8.4 KiB
Python
166 lines
8.4 KiB
Python
"""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=<scratch> \
|
|
isokit studio <copy of examples/demo> --port 7890 --no-open
|
|
uv run --with playwright python tools/check_studio.py <screenshot dir> 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"<svg" in icon.read(), "favicon served as SVG")
|
|
pg.screenshot(path=str(OUT / "studio-start.jpg"), type="jpeg", quality=80)
|
|
|
|
# place a tree: pick it in the palette, click an empty spot of the map
|
|
pg.locator('.st-tile[data-type="tree"]').click()
|
|
check(pg.locator("#hint").is_visible(), "placing hint shows")
|
|
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(700)
|
|
d = doc()
|
|
check(len(d["objects"]) == n0 + 1, "click on the map adds an object")
|
|
new = d["objects"][-1]
|
|
check(new["type"] == "tree", f"the new object is a tree at ({new['x']}, {new['y']})")
|
|
check(pg.locator("#right h3").inner_text().strip().endswith("Tree"), "the new object is selected in the panel")
|
|
|
|
# drag the generator
|
|
g = obj("genset")
|
|
# aim at the generator body (its group's box also spans the exhaust puffs above it)
|
|
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
|
|
hit = pg.evaluate("([x, y]) => { 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)
|
|
# the agent window, on the mock model (the Studio must run with ISOKIT_LLM_URL=<mock>/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)
|