loosecanvas / scripts /playwright_cxtmenu.py
Joshua Sundance Bailey
feat: open-world trust-loop UX (chat-first review, edge review, layout reshape)
a973dff
Raw
History Blame Contribute Delete
14 kB
"""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())