Spaces:
Sleeping
Sleeping
Joshua Sundance Bailey
feat: open-world trust-loop UX (chat-first review, edge review, layout reshape)
a973dff | """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()) | |