"""Browser assertion harness for the loosecanvas Gradio + Cytoscape UI. Loads a deterministic fixture (no LLM / llama.cpp needed), then reads the live cytoscape instance (via the test-only ``window.__cy`` handle) to ASSERT the visual grammar — origin→shape classes, edge ``kind`` data, and Louvain ``cluster-N`` colors after pressing "Color clusters". Screenshots land in .playwright-out/. .venv/Scripts/python.exe scripts/playwright_smoke.py Requires a live `uvicorn loosecanvas.main:app` on APP_PORT (default 8000) built from the current frontend (the ``window.__cy`` handle ships only in a fresh build). """ from __future__ import annotations import json import os import sys from pathlib import Path from playwright.sync_api import sync_playwright BASE = f"http://127.0.0.1:{os.environ.get('APP_PORT', '8000')}/" OUT = Path(__file__).resolve().parent.parent / ".playwright-out" OUT.mkdir(exist_ok=True) READ_NODES = """ () => { const cy = window.__cy; if (!cy) return null; return { nodes: cy.nodes().map(n => ({ id: n.id(), classes: n.classes() })), edges: cy.edges().map(e => ({ id: e.id(), kind: e.data('kind') ?? null })), }; } """ def main() -> int: console: list[str] = [] report: dict[str, object] = {"base": BASE} failures: list[str] = [] warnings: list[str] = [] with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page(viewport={"width": 1400, "height": 900}) page.on("console", lambda m: console.append(f"{m.type}: {m.text}")) page.on("pageerror", lambda e: console.append(f"pageerror: {e}")) # Opt in to the test-only window.__cy handle before any page script runs. page.add_init_script("window.__PLAYWRIGHT_TEST = true;") page.goto(BASE, wait_until="domcontentloaded") page.wait_for_selector("#lc-canvas", timeout=30_000) page.get_by_role("button", name="Load graph").click() # Wait until the cytoscape handle exists and the fixture's nodes are loaded. page.wait_for_function( "() => window.__cy && window.__cy.nodes().length > 0", timeout=15_000 ) page.screenshot(path=str(OUT / "02_after_load.png")) state = page.evaluate(READ_NODES) report["node_count"] = len(state["nodes"]) report["edge_count"] = len(state["edges"]) # Assertion 1: the origin data→class pipeline is alive (renderer.ts turns a # node's `origin` data into an `origin-` class). A node only gets one # if it has a governing claim. Hard-fail only if the pipeline is dead; warn if # the values don't match the shape stylesheet (stale fixture vocabulary). styled = { "origin-deterministic", "origin-user-asserted", "origin-model-inferred", } origin_seen = sorted( {c for n in state["nodes"] for c in n["classes"] if c.startswith("origin-")} ) report["origin_classes_seen"] = origin_seen report["origin_classes_styled_match"] = sorted(set(origin_seen) & styled) report["nodes_with_origin_class"] = sum( any(c.startswith("origin-") for c in n["classes"]) for n in state["nodes"] ) if not origin_seen: failures.append( "origin data→class pipeline dead: no origin-* class on any node" ) elif not (set(origin_seen) & styled): failures.append( f"stale origin vocabulary {origin_seen}: classes are applied but match no " "shape rule (styled set = deterministic/user_asserted/model_inferred), so " "origin-shape encoding does not render" ) # Assertion 2: at least one edge carries a `kind` (arrow-style mapping data). kinds = sorted({e["kind"] for e in state["edges"] if e["kind"]}) report["edge_kinds_present"] = kinds if not kinds: failures.append("no edges carry a `kind` data field (arrow mapping dead)") # Assertion 3: pressing "Color clusters" assigns cluster-N classes (B4). page.get_by_role("button", name="Color clusters").click() try: page.wait_for_function( "() => window.__cy && window.__cy.nodes()" ".some(n => n.classes().some(c => c.startsWith('cluster-')))", timeout=15_000, ) except Exception as exc: # noqa: BLE001 - record, don't crash the report failures.append(f"no cluster-N class appeared after Color clusters: {exc}") page.wait_for_timeout(800) page.screenshot(path=str(OUT / "03_clustered.png")) after = page.evaluate(READ_NODES) clustered = [ n["id"] for n in after["nodes"] if any(c.startswith("cluster-") for c in n["classes"]) ] report["clustered_node_count"] = len(clustered) report["distinct_clusters"] = sorted( { c for n in after["nodes"] for c in n["classes"] if c.startswith("cluster-") } ) if not clustered: failures.append("no node received a cluster-N class") browser.close() report["console_tail"] = console[-25:] report["warnings"] = warnings report["failures"] = failures report["ok"] = not failures (OUT / "report.json").write_text(json.dumps(report, indent=2), encoding="utf-8") print(json.dumps(report, indent=2)) return 0 if not failures else 1 if __name__ == "__main__": sys.exit(main())