import assert from "node:assert/strict"; import { createRequire } from "node:module"; import { pathToFileURL } from "node:url"; const require = createRequire(import.meta.url); const { chromium } = require("playwright"); const html = process.env.GRAPH_DEFAULT_BEHAVIOR_HTML || ""; assert.notEqual(html, "", "GRAPH_DEFAULT_BEHAVIOR_HTML must point at generated HTML"); const executablePath = process.env.GRAPH_DEFAULT_BEHAVIOR_CHROME_EXECUTABLE || ""; const browser = await chromium.launch(executablePath ? { executablePath } : {}); try { const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }); await page.goto(pathToFileURL(html).href); await page.waitForSelector("[data-llm-wiki-graph-root='true']"); await page.waitForSelector("[data-viewport-layer='true']"); await page.waitForSelector(".node"); await page.locator(".node").first().click(); await page.waitForSelector(".graph-reader-body"); const computedPolicy = await graphDefaultPolicy(page); await page.locator(".graph-reader-close").click(); await page.waitForSelector('.graph-reader[data-state="closed"]'); assert.equal(computedPolicy.root.userSelect, "none", "graph root should prevent browser text selection"); assert.equal(computedPolicy.root.touchAction, "none", "graph root should own touch and pinch gestures"); assert.equal(computedPolicy.root.overscrollBehavior, "contain", "graph root should contain scroll chaining"); assert.equal(computedPolicy.readerBody.userSelect, "text", "reader body should still allow selecting text"); assert.equal(computedPolicy.searchInput.userSelect, "text", "search input should still allow text editing"); const nodeShortcutWheel = await assertShortcutWheelZoomsGraph(page, await visibleNodeCenter(page), "node", { ctrlKey: true }); const blankShortcutWheel = await assertShortcutWheelZoomsGraph(page, await findBlankPoint(page), "blank graph", { metaKey: true }); const searchWheel = await assertShortcutWheelDoesNotZoomGraph(page, ".graph-search-input", "search input", { ctrlKey: true }); const blankDrag = await assertBlankDragDoesNotSelectText(page); const cancellationBoundaries = await assertCancellationAndBlockerBoundaries(page); const fastReleaseDrag = await assertFastReleasePinsFinalPointerPosition(page); const keyboardOwnership = await assertKeyboardOwnership(page); console.log(JSON.stringify({ html, computedPolicy, shortcutWheel: { node: nodeShortcutWheel, blank: blankShortcutWheel, searchInput: searchWheel }, blankDrag, cancellationBoundaries, fastReleaseDrag, keyboardOwnership }, null, 2)); } finally { await browser.close(); } async function assertShortcutWheelZoomsGraph(page, point, label, options) { await resetSelection(page); const beforeMetrics = await pageMetrics(page); const beforeTransform = await layerTransform(page); const eventResult = await dispatchWheelAt(page, point, { deltaY: -420, ctrlKey: options.ctrlKey === true, metaKey: options.metaKey === true }); assert.equal(eventResult.cancelled, true, `shortcut wheel over ${label} should cancel browser default`); const afterTransform = await waitForLayerTransform(page, beforeTransform); const afterMetrics = await pageMetrics(page); assert.deepEqual(afterMetrics, beforeMetrics, `shortcut wheel over ${label} should not zoom the page`); const realWheel = await assertModifiedMouseWheelZoomsGraph(page, point, label, options); return { point: roundedPoint(point), beforeTransform, afterTransform, beforeMetrics, afterMetrics, eventResult, realWheel }; } async function assertModifiedMouseWheelZoomsGraph(page, point, label, options) { const beforeMetrics = await pageMetrics(page); const beforeTransform = await layerTransform(page); const modifier = options.metaKey === true ? "Meta" : "Control"; const client = await page.context().newCDPSession(page); try { await client.send("Input.dispatchMouseEvent", { type: "mouseWheel", x: point.x, y: point.y, deltaX: 0, deltaY: -420, modifiers: options.metaKey === true ? 4 : 2 }); } finally { await client.detach().catch(() => {}); } const afterTransform = await waitForLayerTransform(page, beforeTransform); const afterMetrics = await pageMetrics(page); assert.deepEqual(afterMetrics, beforeMetrics, `real modified wheel over ${label} should not zoom the page`); return { modifier, beforeTransform, afterTransform, beforeMetrics, afterMetrics }; } async function assertShortcutWheelDoesNotZoomGraph(page, selector, label, options) { const beforeMetrics = await pageMetrics(page); const beforeTransform = await layerTransform(page); const eventResult = await dispatchWheelOnSelector(page, selector, { deltaY: -420, ctrlKey: options.ctrlKey === true, metaKey: options.metaKey === true }); assert.equal(eventResult.cancelled, false, `shortcut wheel over ${label} should remain a blocker`); await page.waitForTimeout(80); const afterTransform = await layerTransform(page); const afterMetrics = await pageMetrics(page); assert.equal(afterTransform, beforeTransform, `shortcut wheel over ${label} should not zoom graph`); assert.deepEqual(afterMetrics, beforeMetrics, `synthetic shortcut wheel over ${label} should not change page metrics`); return { beforeTransform, afterTransform, beforeMetrics, afterMetrics, eventResult }; } async function assertBlankDragDoesNotSelectText(page) { await resetSelection(page); const point = await findBlankPoint(page); const toolbar = await page.locator(".graph-toolbar-actions").boundingBox(); assert.ok(toolbar, "toolbar actions should exist for blank drag selection regression"); await page.mouse.move(point.x, point.y); await page.mouse.down(); await page.mouse.move(toolbar.x + toolbar.width - 4, toolbar.y + toolbar.height / 2, { steps: 8 }); await page.mouse.up(); await page.waitForTimeout(80); const state = await page.evaluate(() => { const root = document.querySelector("[data-llm-wiki-graph-root='true']"); return { selectionText: window.getSelection()?.toString() || "", viewportDragging: root?.dataset.viewportDragging || "", dragging: root?.dataset.dragging || "" }; }); assert.equal(state.selectionText, "", "blank graph drag should not select toolbar text"); assert.equal(state.viewportDragging, "", "blank graph drag should not leave viewport dragging active"); assert.equal(state.dragging, "", "blank graph drag should not leave node dragging active"); return { start: roundedPoint(point), state }; } async function assertCancellationAndBlockerBoundaries(page) { await page.reload(); await page.waitForSelector("[data-llm-wiki-graph-root='true']"); await page.waitForSelector("[data-viewport-layer='true']"); await page.waitForSelector(".node"); const pointerCancel = await assertPointerCancelClearsNodeDrag(page); const lostPointerCapture = await assertLostPointerCaptureClearsBlankPan(page); const blockers = await assertPointerBlockersDoNotStartGestures(page); return { pointerCancel, lostPointerCapture, blockers }; } async function assertPointerCancelClearsNodeDrag(page) { const before = await page.locator(".node").first().evaluate((node) => { const rect = node.getBoundingClientRect(); return { id: node.dataset.id || "", pinned: node.dataset.pinned || "", center: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } }; }); assert.notEqual(before.id, "", "pointercancel regression should have a node target"); const move = { x: before.center.x + 80, y: before.center.y + 8 }; await dispatchPointer(page, `.node[data-id="${cssString(before.id)}"]`, "pointerdown", before.center, { pointerId: 88, button: 0, buttons: 1 }); await dispatchPointer(page, "[data-llm-wiki-graph-root='true']", "pointermove", move, { pointerId: 88, button: 0, buttons: 1 }); await page.waitForFunction( (id) => { const root = document.querySelector("[data-llm-wiki-graph-root='true']"); const node = document.querySelector(`.node[data-id="${CSS.escape(id)}"]`); return root?.dataset.dragging === id && node?.classList.contains("is-dragging"); }, before.id, { timeout: 3000 } ); const cancelEvent = await dispatchPointer(page, "[data-llm-wiki-graph-root='true']", "pointercancel", move, { pointerId: 88, button: 0, buttons: 0 }); await waitForClearInteraction(page, before.id); const after = await graphInteractionState(page, before.id); assert.equal(cancelEvent.cancelled, true, "pointercancel during node drag should prevent browser default"); assert.equal(after.dragging, "", "pointercancel should clear root dragging state"); assert.equal(after.nodeDragging, false, "pointercancel should clear node dragging class"); assert.equal(after.pinned, before.pinned, "pointercancel should not create or remove a node pin"); return { id: before.id, move: roundedPoint(move), cancelEvent, beforePinned: before.pinned, after }; } async function assertLostPointerCaptureClearsBlankPan(page) { const start = await findBlankPoint(page); const move = { x: start.x + 96, y: start.y + 12 }; await dispatchPointer(page, "[data-llm-wiki-graph-root='true']", "pointerdown", start, { pointerId: 89, button: 0, buttons: 1 }); await dispatchPointer(page, "[data-llm-wiki-graph-root='true']", "pointermove", move, { pointerId: 89, button: 0, buttons: 1 }); await page.waitForFunction( () => document.querySelector("[data-llm-wiki-graph-root='true']")?.dataset.viewportDragging === "true", undefined, { timeout: 3000 } ); const lostEvent = await dispatchPointer(page, "[data-llm-wiki-graph-root='true']", "lostpointercapture", move, { pointerId: 89, button: 0, buttons: 0 }); await page.waitForFunction( () => (document.querySelector("[data-llm-wiki-graph-root='true']")?.dataset.viewportDragging || "") === "", undefined, { timeout: 3000 } ); const state = await graphInteractionState(page); assert.equal(state.viewportDragging, "", "lostpointercapture should clear viewport dragging state"); assert.equal(state.dragging, "", "lostpointercapture should not leave node dragging state"); return { start: roundedPoint(start), move: roundedPoint(move), lostEvent, state }; } async function assertPointerBlockersDoNotStartGestures(page) { await page.locator(".node").first().click(); await page.waitForSelector('.graph-reader[data-state="open"]'); const cases = [ [".graph-search-input", "search input"], [".graph-toolbar", "toolbar"], [".mini-map", "minimap"], [".graph-reader-body", "reader drawer"] ]; const results = []; for (let index = 0; index < cases.length; index += 1) { const [selector, label] = cases[index]; const beforeTransform = await layerTransform(page); const eventResult = await dispatchPointerOnSelector(page, selector, "pointerdown", { pointerId: 100 + index, button: 0, buttons: 1 }); await page.waitForTimeout(60); const state = await graphInteractionState(page); const afterTransform = await layerTransform(page); assert.equal(eventResult.cancelled, false, `${label} pointerdown should remain outside graph gesture ownership`); assert.equal(state.dragging, "", `${label} pointerdown should not start node dragging`); assert.equal(state.viewportDragging, "", `${label} pointerdown should not start blank panning`); assert.equal(afterTransform, beforeTransform, `${label} pointerdown should not move the graph`); results.push({ selector, label, eventResult, state }); } await page.locator(".graph-reader-close").click(); await page.waitForSelector('.graph-reader[data-state="closed"]'); return results; } async function assertFastReleasePinsFinalPointerPosition(page) { await page.reload(); await page.waitForSelector("[data-llm-wiki-graph-root='true']"); await page.waitForSelector("[data-viewport-layer='true']"); await page.waitForSelector(".node"); const before = await page.locator(".node").first().evaluate((node) => { const rect = node.getBoundingClientRect(); return { id: node.dataset.id || "", center: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }, pinned: node.dataset.pinned || "" }; }); assert.notEqual(before.id, "", "fast-release drag should have a node target"); const intermediate = { x: before.center.x + 18, y: before.center.y + 4 }; const release = { x: before.center.x + 280, y: before.center.y + 12 }; await dispatchPointer(page, `.node[data-id="${cssString(before.id)}"]`, "pointerdown", before.center, { pointerId: 77, button: 0, buttons: 1 }); await dispatchPointer(page, "[data-llm-wiki-graph-root='true']", "pointermove", intermediate, { pointerId: 77, button: 0, buttons: 1 }); await dispatchPointer(page, "[data-llm-wiki-graph-root='true']", "pointerup", release, { pointerId: 77, button: 0, buttons: 0 }); await page.waitForSelector(`.node[data-id="${cssString(before.id)}"][data-pinned="true"]`); await page.waitForTimeout(120); const after = await page.locator(`.node[data-id="${cssString(before.id)}"]`).evaluate((node) => { const rect = node.getBoundingClientRect(); return { center: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }, pinned: node.dataset.pinned || "" }; }); assert.ok( after.center.x > before.center.x + 190, `fast-release drag should commit close to the release point, not the last move point: ${JSON.stringify({ before, intermediate, release, after })}` ); assert.equal(after.pinned, "true", "fast-release drag should still pin the node"); return { id: before.id, before: roundedPoint(before.center), intermediate: roundedPoint(intermediate), release: roundedPoint(release), after: roundedPoint(after.center), pinned: after.pinned }; } async function assertKeyboardOwnership(page) { await page.reload(); await page.waitForSelector("[data-llm-wiki-graph-root='true']"); await page.waitForSelector(".graph-search"); await page.evaluate(() => { if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); document.body.focus(); }); const outsideGraph = await dispatchDocumentKey(page, "f", { ctrlKey: true }); await page.waitForTimeout(80); assert.equal(outsideGraph.defaultPrevented, false, "graph should not steal search shortcut outside graph focus"); assert.equal(await graphSearchState(page), "closed", "search shortcut outside graph focus should not open graph search"); await page.locator("[data-llm-wiki-graph-root='true']").evaluate((root) => root.focus({ preventScroll: true })); const insideGraph = await dispatchDocumentKey(page, "f", { ctrlKey: true }); await page.waitForSelector('.graph-search[data-state="open"]'); assert.equal(insideGraph.defaultPrevented, true, "graph should own search shortcut when graph is focused"); await page.evaluate(() => { if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); document.body.focus(); }); const outsideEscape = await dispatchDocumentKey(page, "Escape"); await page.waitForTimeout(80); assert.equal(outsideEscape.defaultPrevented, false, "graph should not steal Escape outside graph focus"); assert.equal(await graphSearchState(page), "open", "Escape outside graph focus should not close graph search"); return { outsideGraph, insideGraph, outsideEscape, finalSearchState: await graphSearchState(page) }; } async function layerTransform(page) { return page.locator("[data-viewport-layer='true']").evaluate((element) => element.style.transform); } async function waitForLayerTransform(page, previous) { await page.waitForFunction( (previous) => document.querySelector("[data-viewport-layer='true']")?.style.transform !== previous, previous, { timeout: 3000 } ); return layerTransform(page); } async function pageMetrics(page) { return page.evaluate(() => ({ devicePixelRatio: window.devicePixelRatio, visualViewportScale: window.visualViewport?.scale || 1, clientWidth: document.documentElement.clientWidth, clientHeight: document.documentElement.clientHeight })); } async function graphDefaultPolicy(page) { return page.evaluate(() => { const root = document.querySelector(".llm-wiki-graph-engine"); const readerBody = document.querySelector(".graph-reader-body"); const searchInput = document.querySelector(".graph-search-input"); if (!root || !readerBody || !searchInput) throw new Error("Missing graph policy elements"); const styleOf = (element) => { const style = getComputedStyle(element); return { userSelect: style.userSelect, touchAction: style.touchAction, overscrollBehavior: style.overscrollBehavior }; }; return { root: styleOf(root), readerBody: styleOf(readerBody), searchInput: styleOf(searchInput) }; }); } async function visibleNodeCenter(page) { return page.locator(".node").first().evaluate((node) => { const rect = node.getBoundingClientRect(); return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; }); } async function findBlankPoint(page) { return page.evaluate(() => { const root = document.querySelector("[data-llm-wiki-graph-root='true']"); if (!root) throw new Error("Missing graph root"); const rootRect = root.getBoundingClientRect(); const blocked = ".node,.community-wash,.edge,.graph-toolbar,.mini-map,.graph-search,.graph-reader,.graph-selection-panel"; for (let y = rootRect.top + 86; y < rootRect.bottom - 48; y += 34) { for (let x = rootRect.left + 40; x < rootRect.right - 40; x += 42) { const element = document.elementFromPoint(x, y); if (element && root.contains(element) && !element.closest(blocked)) return { x, y }; } } throw new Error("Could not find blank graph point"); }); } async function dispatchWheelAt(page, point, options) { return page.evaluate(({ point, options }) => { const target = document.elementFromPoint(point.x, point.y); if (!target) throw new Error(`No event target at ${JSON.stringify(point)}`); const event = new WheelEvent("wheel", { bubbles: true, cancelable: true, clientX: point.x, clientY: point.y, deltaY: options.deltaY, deltaMode: 0, ctrlKey: options.ctrlKey, metaKey: options.metaKey }); const dispatchResult = target.dispatchEvent(event); return { cancelled: !dispatchResult, defaultPrevented: event.defaultPrevented, target: target.closest(".node") ? "node" : target.closest(".community-wash") ? "community-wash" : target.className?.toString?.() || target.tagName }; }, { point, options }); } async function dispatchWheelOnSelector(page, selector, options) { return page.evaluate(({ selector, options }) => { const target = document.querySelector(selector); if (!target) throw new Error(`Missing selector ${selector}`); const rect = target.getBoundingClientRect(); const event = new WheelEvent("wheel", { bubbles: true, cancelable: true, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, deltaY: options.deltaY, deltaMode: 0, ctrlKey: options.ctrlKey, metaKey: options.metaKey }); const dispatchResult = target.dispatchEvent(event); return { cancelled: !dispatchResult, defaultPrevented: event.defaultPrevented, target: selector }; }, { selector, options }); } async function dispatchPointer(page, selector, type, point, options) { return page.evaluate(({ selector, type, point, options }) => { const target = document.querySelector(selector); if (!target) throw new Error(`Missing selector ${selector}`); const event = new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: options.pointerId, pointerType: "mouse", clientX: point.x, clientY: point.y, button: options.button, buttons: options.buttons, isPrimary: true }); const dispatchResult = target.dispatchEvent(event); return { cancelled: !dispatchResult, defaultPrevented: event.defaultPrevented }; }, { selector, type, point, options }); } async function dispatchDocumentKey(page, key, options = {}) { return page.evaluate(({ key, options }) => { const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ctrlKey: options.ctrlKey === true, metaKey: options.metaKey === true }); const dispatchResult = document.dispatchEvent(event); return { cancelled: !dispatchResult, defaultPrevented: event.defaultPrevented, activeTag: document.activeElement?.tagName || "" }; }, { key, options }); } async function graphSearchState(page) { return page.locator(".graph-search").evaluate((element) => element.dataset.state || ""); } async function graphInteractionState(page, nodeId = "") { return page.evaluate((nodeId) => { const root = document.querySelector("[data-llm-wiki-graph-root='true']"); const node = nodeId ? document.querySelector(`.node[data-id="${CSS.escape(nodeId)}"]`) : null; return { dragging: root?.dataset.dragging || "", viewportDragging: root?.dataset.viewportDragging || "", pinned: node?.dataset.pinned || "", nodeDragging: Boolean(node?.classList.contains("is-dragging")) }; }, nodeId); } async function waitForClearInteraction(page, nodeId = "") { await page.waitForFunction( (nodeId) => { const root = document.querySelector("[data-llm-wiki-graph-root='true']"); const node = nodeId ? document.querySelector(`.node[data-id="${CSS.escape(nodeId)}"]`) : null; return (root?.dataset.dragging || "") === "" && (root?.dataset.viewportDragging || "") === "" && !node?.classList.contains("is-dragging"); }, nodeId, { timeout: 3000 } ); } async function resetSelection(page) { await page.evaluate(() => window.getSelection()?.removeAllRanges()); } function roundedPoint(point) { return { x: Math.round(point.x * 1000) / 1000, y: Math.round(point.y * 1000) / 1000 }; } function cssString(value) { return String(value).replace(/\\/g, "\\\\").replace(/"/g, "\\\""); } async function dispatchPointerOnSelector(page, selector, type, options) { return page.evaluate(({ selector, type, options }) => { const target = document.querySelector(selector); if (!target) throw new Error(`Missing selector ${selector}`); const rect = target.getBoundingClientRect(); const point = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; const event = new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: options.pointerId, pointerType: "mouse", clientX: point.x, clientY: point.y, button: options.button, buttons: options.buttons, isPrimary: true }); const dispatchResult = target.dispatchEvent(event); return { cancelled: !dispatchResult, defaultPrevented: event.defaultPrevented, point }; }, { selector, type, options }); }