| """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}")) |
| |
| 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() |
| |
| 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"]) |
|
|
| |
| |
| |
| |
| 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" |
| ) |
|
|
| |
| 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)") |
|
|
| |
| 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: |
| 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()) |
|
|