"""Browser validation for the B2 radial context menu (cytoscape-cxtmenu). No LLM / llama.cpp needed — loads a deterministic fixture, then asserts the live integration that unit tests can't reach: A. Registration: ``cytoscape.use(cxtmenu)`` + ``cy.cxtmenu()`` actually ran at runtime (a ``.cxtmenu`` overlay container exists) — a registration throw would otherwise silently break the whole canvas. B. Open/close: a real right-mouse-down on a non-fogged node opens the radial menu (its panel flips to ``display: block``); releasing without swiping a wedge cancels it (panel back to ``display: none``) — no review is fired. C. No console / page errors (esp. no "extension already registered" from a per-instance ``use()``). .venv/Scripts/python.exe scripts/playwright_cxtmenu.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) # Page coords (container rect + renderedPosition) of the first non-fogged node. PICK_NODE = """ () => { const cy = window.__cy; if (!cy) return null; const n = cy.nodes().filter((n) => !n.hasClass('fogged'))[0]; if (!n) return null; const rp = n.renderedPosition(); const rect = cy.container().getBoundingClientRect(); return { id: n.id(), x: rect.left + rp.x, y: rect.top + rp.y }; } """ # Display state across ALL cxtmenu overlays (there are two now: node + core). # "present" = at least one container exists; "display" = block if ANY panel is open. MENU_STATE = """ () => { const wraps = Array.from(document.querySelectorAll('.cxtmenu')); if (wraps.length === 0) return { present: false, display: null }; const open = wraps.some((w) => { const p = w.querySelector('div'); return p && getComputedStyle(p).display === 'block'; }); return { present: true, display: open ? 'block' : 'none' }; } """ # Wedge texts of the CURRENTLY-OPEN menu only (scoped to the open container so the # node menu's items don't leak into the background menu's assertion). MENU_ITEMS = """ () => { const wraps = Array.from(document.querySelectorAll('.cxtmenu')); const open = wraps.find((w) => { const p = w.querySelector('div'); return p && getComputedStyle(p).display === 'block'; }); if (!open) return []; return Array.from(open.querySelectorAll('.cxtmenu-content')) .map((e) => (e.textContent || '').trim()).filter(Boolean); } """ # A canvas point >70px from every node (so a right-click there hits the core menu). EMPTY_POINT = """ () => { const cy = window.__cy; if (!cy) return null; const rect = cy.container().getBoundingClientRect(); const nodes = cy.nodes().map((n) => { const rp = n.renderedPosition(); return { x: rect.left + rp.x, y: rect.top + rp.y }; }); for (let gx = 0.05; gx <= 0.95; gx += 0.1) { for (let gy = 0.05; gy <= 0.95; gy += 0.1) { const x = rect.left + rect.width * gx; const y = rect.top + rect.height * gy; if (nodes.every((n) => Math.hypot(n.x - x, n.y - y) > 70)) return { x, y }; } } return null; } """ # Page coords of the midpoint of the first visible edge (to right-click the edge). EDGE_POINT = """ () => { const cy = window.__cy; if (!cy) return null; const edges = cy.edges().filter((e) => !e.hasClass('fogged')); if (edges.length === 0) return null; const e = edges[0]; const rect = cy.container().getBoundingClientRect(); const s = e.source().renderedPosition(); const t = e.target().renderedPosition(); return { id: e.id(), x: rect.left + (s.x + t.x) / 2, y: rect.top + (s.y + t.y) / 2 }; } """ # First non-fogged node, preferring one still awaiting review (amber). Used to # verify the reject-removes-node FIX: rejecting must KEEP the node on canvas. PICK_REVIEWABLE_NODE = """ () => { const cy = window.__cy; if (!cy) return null; const pending = cy.nodes().filter((n) => !n.hasClass('fogged') && n.hasClass('review-pending')); const n = pending[0] || cy.nodes().filter((n) => !n.hasClass('fogged'))[0]; if (!n) return null; const rp = n.renderedPosition(); const rect = cy.container().getBoundingClientRect(); return { id: n.id(), x: rect.left + rp.x, y: rect.top + rp.y, reviewPending: n.hasClass('review-pending'), }; } """ # Live state of a specific node id (plus total node count) — for before/after a reject. NODE_STATE = """ (id) => { const cy = window.__cy; if (!cy) return null; const n = cy.getElementById(id); return { exists: n.length > 0, classes: n.length ? n.classes() : [], total: cy.nodes().length }; } """ # Page-coord center of the OPEN menu wedge whose text matches `label` (case-insensitive). WEDGE_BBOX = """ (label) => { const wraps = Array.from(document.querySelectorAll('.cxtmenu')); const open = wraps.find((w) => { const p = w.querySelector('div'); return p && getComputedStyle(p).display === 'block'; }); if (!open) return null; const el = Array.from(open.querySelectorAll('.cxtmenu-content')) .find((e) => (e.textContent || '').toLowerCase().includes(label.toLowerCase())); if (!el) return null; const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; } """ def main() -> int: console: list[str] = [] report: dict[str, object] = {"base": BASE} failures: 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.wait_for_timeout(400) # A. Registration — a cxtmenu overlay container must exist. menu0 = page.evaluate(MENU_STATE) report["cxtmenu_container_present"] = menu0["present"] report["cxtmenu_panel_display_initial"] = menu0["display"] if not menu0["present"]: failures.append( "cxtmenu not registered: no .cxtmenu overlay container " "(cytoscape.use(cxtmenu) / cy.cxtmenu() did not run)" ) # B. Open/close on a real right-click. node = page.evaluate(PICK_NODE) report["node_under_test"] = node if not node: failures.append("no non-fogged node to right-click") else: page.mouse.move(node["x"], node["y"]) page.mouse.down(button="right") page.wait_for_timeout(250) opened = page.evaluate(MENU_STATE) report["cxtmenu_panel_display_on_press"] = opened["display"] page.screenshot(path=str(OUT / "cxtmenu_01_open.png")) if opened["display"] != "block": failures.append( f"radial menu did not open on right-click (panel display=" f"{opened['display']!r}, expected 'block')" ) # The menu must render all node commands (C1 review + C2 focus/rename). items = page.evaluate(MENU_ITEMS) report["cxtmenu_wedges"] = items joined = " ".join(items).lower() for want in ("accept", "reject", "focus", "expand", "rename"): if want not in joined: failures.append( f"radial menu missing a '{want}' wedge; got {items}" ) # Release without swiping a wedge → cancel (no review fired). page.mouse.up(button="right") page.wait_for_timeout(250) closed = page.evaluate(MENU_STATE) report["cxtmenu_panel_display_after_release"] = closed["display"] page.screenshot(path=str(OUT / "cxtmenu_02_closed.png")) if closed["display"] != "none": failures.append( f"radial menu did not close after release (panel display=" f"{closed['display']!r}, expected 'none')" ) # D. Background (core) menu: right-click empty canvas → Fit + Clusters. empty = page.evaluate(EMPTY_POINT) report["empty_point"] = empty if not empty: failures.append("no empty canvas point found to test the background menu") else: page.mouse.move(empty["x"], empty["y"]) page.mouse.down(button="right") page.wait_for_timeout(250) bg_open = page.evaluate(MENU_STATE) report["bg_menu_display_on_press"] = bg_open["display"] bg_items = page.evaluate(MENU_ITEMS) report["bg_menu_wedges"] = bg_items page.screenshot(path=str(OUT / "cxtmenu_03_background.png")) if bg_open["display"] != "block": failures.append("background right-click did not open the core menu") bg_joined = " ".join(bg_items).lower() for want in ("fit", "cluster"): if want not in bg_joined: failures.append( f"background menu missing a '{want}' wedge; got {bg_items}" ) page.mouse.up(button="right") page.wait_for_timeout(150) # E. Edge menu: right-click an edge → first step is "Delete" (arms only). edge = page.evaluate(EDGE_POINT) report["edge_point"] = edge if not edge: report["edge_menu_skipped"] = "no visible edge in fixture" else: page.mouse.move(edge["x"], edge["y"]) page.mouse.down(button="right") page.wait_for_timeout(250) edge_items = page.evaluate(MENU_ITEMS) report["edge_menu_wedges"] = edge_items page.screenshot(path=str(OUT / "cxtmenu_04_edge.png")) edge_joined = " ".join(edge_items).lower() # WF-A: the edge menu now offers Accept / Reject / Edit label / Delete # (parity with the node review menu), not just Delete. for want in ("accept", "reject", "edit", "delete"): if want not in edge_joined: failures.append( f"edge menu missing a '{want}' wedge; got {edge_items}" ) page.mouse.up(button="right") page.wait_for_timeout(150) # F. Reject a node's claim → the node must STAY on the canvas. # This is the rendered proof of the reject-removes-node FIX: before the fix, # a node-claim rejection emitted remove_element(#node_id) and the canvas # deleted the whole node; now it clears the amber badge but keeps the node. rnode = page.evaluate(PICK_REVIEWABLE_NODE) report["reject_node"] = rnode if not rnode: failures.append("no node available to test reject") else: before = page.evaluate(NODE_STATE, rnode["id"]) report["reject_node_before"] = before page.mouse.move(rnode["x"], rnode["y"]) page.mouse.down(button="right") page.wait_for_timeout(250) wedge = page.evaluate(WEDGE_BBOX, "reject") report["reject_wedge_bbox"] = wedge if not wedge: failures.append("no Reject wedge found in the node menu") page.mouse.up(button="right") else: # Swipe to the Reject wedge and release → fires the reject dispatch. page.mouse.move(wedge["x"], wedge["y"]) page.wait_for_timeout(150) page.mouse.up(button="right") # Wait for the backend round-trip + renderer patch to apply. page.wait_for_timeout(2000) after = page.evaluate(NODE_STATE, rnode["id"]) report["reject_node_after"] = after page.screenshot(path=str(OUT / "cxtmenu_05_after_reject.png")) if not after["exists"]: failures.append( f"REJECT REMOVED NODE {rnode['id']!r} — the reject-removes-node " "bug is NOT fixed (node gone from cy after reject)" ) if before and after["total"] != before["total"]: failures.append( f"node count changed on reject: {before['total']} -> " f"{after['total']} (reject must not delete nodes)" ) # Soft signal: a single-claim node should also lose its amber badge. report["reject_amber_cleared"] = ( rnode.get("reviewPending") and "review-pending" not in after["classes"] ) browser.close() # C. No page errors (console 'error' lines or pageerror entries). errors = [c for c in console if c.startswith(("error:", "pageerror:"))] report["console_errors"] = errors report["console_tail"] = console[-25:] if errors: failures.append(f"{len(errors)} console/page error(s): {errors[:3]}") report["failures"] = failures report["ok"] = not failures (OUT / "cxtmenu_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())