Deploy web Studio / deploy (push) Successful in 3m52s
Joints: line_joint (util.rhai) applies the house-wall rules to fences and site walls, so pieces never overlap and the depth sort stays clean. - A corner is closed: the thicker piece (or, if equal, the one along world x) runs through, and the other stops at its face. - On a straight run, two pieces share one post. - At a T, the stem stops at the other piece's face without a post of its own. - A fence that ends at a site or house wall stops at the wall's face. Fence designs (prop style): mesh, wooden boards, steel bars, mesh on a stone base, cobble wall, and brick wall with pillars. Board, stone and brick joints are drawn on the face turned to the viewer. Brick pillars are separate sprites. Studio: line prefabs (walls, fences, hedges, roads) can be drawn as a run by clicking corner after corner. Double-click or Enter ends the run, clicking the first corner closes it, and Backspace drops the last corner. A run is one undo step. A drag still draws one piece. Corners and ends snap onto the ends and lines of the pieces they meet, and a marker shows where the next corner will snap. The house demo gets a brick front wall with gate pillars and wooden side fences in place of the old east fence, which ran over the road. Tests cover every design's corners, Ts, straight runs and wall stops, with no depth-sort cycles in any rotation, plus site walls. check_studio draws and closes a fence run, undoes it in one step, snaps a dragged spur, and picks a design.
341 lines
18 KiB
Python
341 lines
18 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")
|
||
|
||
# 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)
|
||
|
||
# a fence run drawn corner by corner: a click starts it, the first corner closes it, as one undo step;
|
||
# the pieces join at their corners; a dragged fence snaps its ends onto the fences it meets
|
||
pg.evaluate("__studio.select(null)")
|
||
pg.evaluate("__studio.view.focus([29, -9.5, 0], 3)")
|
||
pg.wait_for_timeout(400)
|
||
n3 = len(doc()["objects"])
|
||
pg.locator('.st-tile[data-type="fence"]').click()
|
||
for x, y in [(24, -12), (32, -12.15), (32.1, -7), (24, -6.9)]:
|
||
pg.mouse.click(*at(x, y))
|
||
pg.wait_for_timeout(150)
|
||
pg.mouse.move(*at(24.1, -11.9))
|
||
pg.wait_for_timeout(150)
|
||
check("4 pieces" in pg.locator("#hint").inner_text() and "close" in pg.locator("#hint").inner_text(),
|
||
"drawing a run shows its pieces and offers to close it: " + pg.locator("#hint").inner_text()[:60])
|
||
pg.screenshot(path=str(OUT / "studio-fence-run.jpg"), type="jpeg", quality=80)
|
||
pg.mouse.click(*at(24.1, -11.9))
|
||
pg.wait_for_timeout(900)
|
||
run = doc()["objects"][n3:]
|
||
got = sorted((o["type"], o["x"], o["y"], o.get("rot", 0), o["props"]["w"]) for o in run)
|
||
want = sorted([("fence", 28, -12, 0, 8), ("fence", 32, -9.5, 90, 5), ("fence", 28, -7, 0, 8), ("fence", 24, -9.5, 90, 5)])
|
||
check(got == want, f"clicking the first corner closes the run: four fences {got}")
|
||
rv = get("/api/render?level=site&rot=1")
|
||
south = next((o["id"] for o in run if o["y"] == -12), None)
|
||
west = next((o["id"] for o in run if o["x"] == 24), None)
|
||
sb, wb = rv["boxes"].get(south, [0] * 6), rv["boxes"].get(west, [0] * 6)
|
||
check(abs(sb[0] - 23.96) < 1e-6 and abs(sb[3] - 32.04) < 1e-6 and abs(wb[1] + 11.96) < 1e-6,
|
||
f"the corners join (south runs through, west stops at it): {sb[0]:.2f}..{sb[3]:.2f}, {wb[1]:.2f}")
|
||
pg.locator("#undo").click()
|
||
pg.wait_for_timeout(700)
|
||
check(len(doc()["objects"]) == n3, "one undo takes the whole run back")
|
||
pg.locator("#redo").click()
|
||
pg.wait_for_timeout(700)
|
||
# a spur dragged from the run's east fence to the east boundary fence: both ends snap onto them
|
||
pg.locator('.st-tile[data-type="fence"]').click()
|
||
a, b2 = at(32.25, -9.6), at(34.3, -9.6)
|
||
pg.mouse.move(*a)
|
||
pg.mouse.down()
|
||
for i in range(1, 11):
|
||
pg.mouse.move(a[0] + (b2[0] - a[0]) * i / 10, a[1] + (b2[1] - a[1]) * i / 10)
|
||
pg.wait_for_timeout(15)
|
||
pg.mouse.up()
|
||
pg.wait_for_timeout(800)
|
||
sp = doc()["objects"][-1]
|
||
check(sp["type"] == "fence" and abs(sp["x"] - 33.3) < 1e-6 and abs(sp["props"]["w"] - 2.6) < 1e-6,
|
||
f"a dragged fence snaps its ends onto the fences it meets: x {sp['x']}, w {sp['props']['w']}")
|
||
# the design is a choice in the properties
|
||
pg.locator(f'#objects li[data-id="{south}"]').click()
|
||
pg.wait_for_timeout(300)
|
||
pg.locator('#right select[data-prop="style"]').select_option("brick")
|
||
pg.wait_for_timeout(800)
|
||
check(obj(south)["props"].get("style") == "brick", "a fence's design is picked in its properties")
|
||
pg.evaluate("__studio.select(null)")
|
||
pg.wait_for_timeout(500)
|
||
pg.screenshot(path=str(OUT / "studio-fences.jpg"), type="jpeg", quality=80)
|
||
|
||
# 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=<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)
|