loosecanvas / scripts /playwright_smoke.py
Joshua Sundance Bailey
feat: today's UI surgery + hackathon submission packet
4f89d70
Raw
History Blame Contribute Delete
5.66 kB
"""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-<value>` 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())