import assert from "node:assert/strict"; import { createRequire } from "node:module"; import fs from "node:fs/promises"; import path from "node:path"; const require = createRequire(import.meta.url); const { chromium } = require("playwright"); const workbenchUrl = process.env.GRAPH_WORKBENCH_URL || ""; const artifactDir = process.env.GRAPH_WORKBENCH_ARTIFACT_DIR || ""; const executablePath = process.env.GRAPH_WORKBENCH_CHROME_EXECUTABLE || ""; const GLOBAL_NODE_IDS = ["A", "B", "C", "D", "E", "F", "G"]; const T1_NODE_IDS = ["A", "B", "D", "E", "F", "G"]; assert.notEqual(workbenchUrl, "", "GRAPH_WORKBENCH_URL must point at the workbench dev server"); const browser = await chromium.launch(executablePath ? { executablePath } : {}); try { const desktop = await runPhaseOneChecks(browser, { width: 1440, height: 960 }, "dark"); const narrow = await runNarrowChecks(browser); const evidence = { desktop, narrow }; if (artifactDir) { await fs.mkdir(artifactDir, { recursive: true }); await fs.writeFile(path.join(artifactDir, "phase-1-graph-route-regression.json"), `${JSON.stringify(evidence, null, 2)}\n`); } console.log(JSON.stringify(evidence, null, 2)); } finally { await closeBrowserForRegression(browser); } async function runPhaseOneChecks(browser, viewport, theme) { const page = await openWorkbenchGraphPage(browser, viewport, theme); const evidence = { viewport: `${viewport.width}x${viewport.height}`, initial: await sigmaGlobalSnapshot(page), communitySummary: null, focusedCommunity: null, returnedGlobal: null, statePreservation: null, labelClick: null, routeCycles: null }; assertSigmaGlobal(evidence.initial, "initial graph view"); assert.equal(evidence.initial.oldDomGlobalNodeCount, 0, "initial Sigma global should not render old DOM global nodes"); assert.equal(evidence.initial.communityButtonCount, 0, "initial Sigma global should not render circular community buttons"); assert.ok(evidence.initial.communityRegionCount >= 1, "initial Sigma global should keep passive community regions"); assert.ok(evidence.initial.communityLabelCount >= 1 && evidence.initial.communityLabelCount <= 8, "initial Sigma global should show 1-8 passive community labels"); assert.ok(evidence.initial.edgeCount >= 1, "initial Sigma global should render relationship skeleton"); evidence.communitySummary = await openCommunitySummaryFromRegion(page, "t1"); assert.equal(evidence.communitySummary.route.renderer, "sigma-global", "community region selection should stay in Sigma global"); assert.equal(evidence.communitySummary.drawerTestId, "graph-community-summary", "community region selection should open community summary"); evidence.labelClick = await clickPassiveCommunityLabel(page, "t1"); assert.equal(evidence.labelClick.drawerTestId, "graph-community-summary", "passive community label area should delegate to map hit handling"); assert.equal(evidence.labelClick.route.renderer, "sigma-global", "passive label click should not enter a DOM control route"); evidence.communitySummary = await openCommunitySummaryFromRegion(page, "t1"); await page.locator('[data-testid="graph-community-summary"] button', { hasText: "进入社区" }).click(); await waitForDomCommunity(page, "t1"); evidence.focusedCommunity = await domCommunitySnapshot(page); assert.deepEqual(evidence.focusedCommunity.visibleNodes, T1_NODE_IDS, "enter community should use DOM/SVG community reading"); assert.equal(evidence.focusedCommunity.sigmaRendererCount, 0, "community reading route should not keep Sigma global mounted"); evidence.communityMultiSelect = await runCommunityNodeMultiSelectCheck(page); await clickReturnGlobal(page); await waitForSigmaGlobal(page); evidence.returnedGlobal = await sigmaGlobalSnapshot(page); assertSigmaGlobal(evidence.returnedGlobal, "return global"); assert.equal(evidence.returnedGlobal.oldDomGlobalNodeCount, 0, "return global should not show old DOM full graph"); assert.equal(evidence.returnedGlobal.communityButtonCount, 0, "return global should not reintroduce circular community controls"); evidence.statePreservation = await runStatePreservationCheck(page); evidence.routeCycles = await runRouteCycleAccumulationCheck(page, "t1", 3); if (artifactDir) { await page.screenshot({ path: path.join(artifactDir, "phase-1-graph-route-desktop.png"), fullPage: true }); } await page.close(); return evidence; } async function runNarrowChecks(browser) { const page = await openWorkbenchGraphPage(browser, { width: 390, height: 844 }, "light"); const initial = await sigmaGlobalSnapshot(page); assertSigmaGlobal(initial, "narrow initial graph view"); assert.ok(initial.communityLabelCount >= 1 && initial.communityLabelCount <= 8, "narrow Sigma global should keep passive map labels"); if (artifactDir) { await page.screenshot({ path: path.join(artifactDir, "phase-1-graph-route-narrow.png"), fullPage: true }); } await page.close(); return { viewport: "390x844", initial }; } async function openWorkbenchGraphPage(browser, viewport, theme) { const page = await browser.newPage({ viewport }); await page.addInitScript(({ theme }) => { window.localStorage.setItem("llm-wiki-agent-main-view", "graph"); window.localStorage.setItem("llm-wiki-agent-theme", theme); }, { theme }); await page.goto(workbenchUrl); await page.waitForSelector(".app-shell"); const kbButton = page.getByRole("button", { name: /Phase 6 Workbench Test|phase-6-workbench/ }); if (await kbButton.count() && await kbButton.first().isVisible()) await kbButton.first().click(); const graphButton = page.getByRole("button", { name: /图谱/ }); if (await graphButton.count() && await graphButton.first().isVisible() && await graphButton.first().isEnabled()) { await graphButton.first().click(); } await page.waitForSelector(".graph-host"); await waitForSigmaGlobal(page); const expectedGraphTheme = theme === "dark" ? "mo-ye" : "shan-shui"; await page.waitForFunction((expectedGraphTheme) => { return document.querySelector(".graph-screen")?.dataset.graphTheme === expectedGraphTheme && document.querySelector(".sigma-global-renderer")?.dataset.theme === expectedGraphTheme; }, expectedGraphTheme); return page; } async function waitForSigmaGlobal(page) { await page.waitForSelector(".sigma-global-route[data-route='sigma-global']"); await page.waitForSelector(".sigma-global-renderer[data-renderer='sigma-global']"); await page.waitForSelector(".sigma-global-renderer canvas"); await page.waitForSelector(".sigma-global-community-region"); await page.waitForSelector(".sigma-global-community-label"); } async function waitForDomCommunity(page, communityId) { await page.waitForSelector("[data-llm-wiki-graph-root='true']"); await page.waitForFunction((communityId) => { return document.querySelector(".graph-host")?.dataset.llmWikiGraphFocus === `community:${communityId}` && document.querySelectorAll(".node").length > 0 && !document.querySelector(".sigma-global-renderer"); }, communityId); await waitForVisibleDomNodeIds(page, T1_NODE_IDS); } async function sigmaGlobalSnapshot(page) { return page.evaluate(() => { const sigma = document.querySelector(".sigma-global-renderer"); const canvas = sigma?.querySelector("canvas"); const graph = document.querySelector(".sigma-global-route"); const labels = [...document.querySelectorAll(".sigma-global-community-label")]; const regions = [...document.querySelectorAll(".sigma-global-community-region")]; const nodeIds = [...document.querySelectorAll(".sigma-global-node-hit-target")] .map((node) => node.getAttribute("data-node-id") || "") .filter(Boolean) .sort(); const regionIds = regions.map((region) => region.getAttribute("data-community-id") || "").filter(Boolean).sort(); const labelIds = labels.map((label) => label.getAttribute("data-community-id") || "").filter(Boolean).sort(); return { route: graph?.getAttribute("data-route") || "", renderer: sigma?.getAttribute("data-renderer") || "", canvasCount: sigma?.querySelectorAll("canvas").length || 0, canvasBox: canvas ? (() => { const rect = canvas.getBoundingClientRect(); const round = (value) => Math.round(value * 1000) / 1000; return { left: round(rect.left), top: round(rect.top), width: round(rect.width), height: round(rect.height) }; })() : null, nodeHitTargetCount: document.querySelectorAll(".sigma-global-node-hit-target").length, nodeHitTargetIds: nodeIds, sigmaRouteCount: document.querySelectorAll(".sigma-global-route[data-route='sigma-global']").length, sigmaRendererCount: document.querySelectorAll(".sigma-global-renderer[data-renderer='sigma-global']").length, edgeCount: document.querySelectorAll("canvas").length, communityRegionCount: regions.length, communityRegionIds: regionIds, communityLabelCount: labels.length, communityLabelIds: labelIds, communityButtonCount: document.querySelectorAll(".sigma-global-community-wash, button.sigma-global-community-label, [role='button'].sigma-global-community-label").length, aggregationButtonCount: document.querySelectorAll(".sigma-global-aggregation-container").length, oldDomGlobalNodeCount: document.querySelectorAll(".node").length, labels: labels.map((label) => ({ communityId: label.getAttribute("data-community-id") || "", ariaHidden: label.getAttribute("aria-hidden") || "", tabIndex: label instanceof HTMLElement ? label.tabIndex : null, pointerEvents: label instanceof HTMLElement ? getComputedStyle(label).pointerEvents : "", text: label.textContent || "" })) }; }); } async function domCommunitySnapshot(page) { return page.evaluate(() => ({ focus: document.querySelector(".graph-host")?.dataset.llmWikiGraphFocus || "", sigmaRendererCount: document.querySelectorAll(".sigma-global-renderer").length, visibleNodes: [...document.querySelectorAll(".node")] .filter((node) => node.getAttribute("data-filter-state") !== "hidden") .map((node) => node.getAttribute("data-id")) .filter(Boolean) .sort(), toolbarReturnCount: [...document.querySelectorAll("button")].filter((button) => button.textContent?.includes("回全图")).length })); } async function runCommunityNodeMultiSelectCheck(page) { await clickDomNode(page, "A", { shiftKey: false }); await page.waitForSelector('[data-testid="graph-node-summary"]'); const single = await drawerSelectionSnapshot(page); assert.equal(single.drawerTestId, "graph-node-summary", "community node click should select that node"); assert.equal(single.title, "节点A", "community node click should open node summary"); await clickDomNode(page, "B", { modifiers: ["Shift"] }); await page.waitForSelector('[data-testid="graph-selection-drawer"]'); const multi = await drawerSelectionSnapshot(page); assert.equal(multi.drawerTestId, "graph-selection-drawer", "Shift+click should show an exact multi-node selection"); assert.match(multi.title, /选中 2 个节点/, "Shift+click should not widen the selection to the whole community"); assert.equal(multi.hasEnterCommunity, false, "manual multi-node selection should not show the community enter action"); return { single, multi }; } async function clickDomNode(page, nodeId, options = {}) { const locator = page.locator(`.node[data-id="${cssString(nodeId)}"]`); await locator.waitFor(); await locator.click(options); } async function drawerSelectionSnapshot(page) { return page.evaluate(() => ({ drawerTestId: document.querySelector(".drawer-panel-open [data-testid]")?.getAttribute("data-testid") || "", title: document.querySelector(".drawer-panel-open h2")?.textContent || "", hasEnterCommunity: [...document.querySelectorAll(".drawer-panel-open button")] .some((button) => button.textContent?.includes("进入社区")), text: document.querySelector(".drawer-panel-open")?.textContent || "" })); } async function runRouteCycleAccumulationCheck(page, communityId, cycles) { await closeDrawerIfOpen(page); await waitForSigmaGlobal(page); await openSearch(page); await setGraphSearchQuery(page, "节点"); try { await page.waitForFunction(() => document.querySelectorAll(".sigma-global-node-hit-target").length > 1); } catch (err) { const diagnostics = await searchDiagnostics(page); throw new assert.AssertionError({ message: `graph search should expose multiple nodes before route cycles: ${JSON.stringify(diagnostics)}`, actual: err, expected: "more than one node hit target", operator: "strictEqual" }); } const baseline = await sigmaGlobalSnapshot(page); const snapshots = []; for (let index = 0; index < cycles; index += 1) { const summary = await openCommunitySummaryFromRegion(page, communityId); assert.equal(summary.route.sigmaRouteCount, 1, `cycle ${index + 1}: should have one Sigma route before entering community`); await page.locator('[data-testid="graph-community-summary"] button', { hasText: "进入社区" }).click(); await waitForDomCommunity(page, communityId); const focused = await domCommunitySnapshot(page); assert.equal(focused.sigmaRendererCount, 0, `cycle ${index + 1}: community route should not retain Sigma renderer`); assert.equal(focused.toolbarReturnCount, 1, `cycle ${index + 1}: community route should expose one return-global control`); await clickReturnGlobal(page); await waitForSigmaGlobal(page); const returned = await sigmaGlobalSnapshot(page); assertSigmaGlobal(returned, `cycle ${index + 1} return global`); assert.equal(returned.sigmaRouteCount, 1, `cycle ${index + 1}: should have one Sigma route after return`); assert.equal(returned.sigmaRendererCount, 1, `cycle ${index + 1}: should have one Sigma renderer after return`); assert.equal(returned.oldDomGlobalNodeCount, 0, `cycle ${index + 1}: should not accumulate old DOM global nodes`); assert.equal(returned.communityButtonCount, 0, `cycle ${index + 1}: should not reintroduce circular community controls`); assert.equal(returned.aggregationButtonCount, 0, `cycle ${index + 1}: should not show aggregation controls`); assert.deepEqual(returned.nodeHitTargetIds, baseline.nodeHitTargetIds, `cycle ${index + 1}: node hit targets should match the baseline set`); assert.deepEqual(returned.communityRegionIds, baseline.communityRegionIds, `cycle ${index + 1}: community regions should match the baseline set`); assert.deepEqual(returned.communityLabelIds, baseline.communityLabelIds, `cycle ${index + 1}: community labels should match the baseline set`); snapshots.push({ summary: summary.route, focused, returned }); } return { cycles, baseline, snapshots }; } async function openCommunitySummaryFromRegion(page, communityId) { await closeDrawerIfOpen(page); const point = await clickCommunityRegion(page, communityId); try { await page.waitForSelector('[data-testid="graph-community-summary"]'); } catch (err) { const diagnostics = await communityClickDiagnostics(page, point, communityId); throw new assert.AssertionError({ message: `community region click should open community summary: ${JSON.stringify(diagnostics)}`, actual: err, expected: "graph-community-summary", operator: "strictEqual" }); } const summary = await communitySummaryDrawerSnapshot(page); assert.ok( summary.hasEnterCommunity, `community ${communityId} summary should expose enter-community: ${JSON.stringify({ point, summary })}` ); return { route: await sigmaGlobalSnapshot(page), drawerTestId: await drawerTestId(page), summary }; } async function clickPassiveCommunityLabel(page, communityId) { const point = await page.locator(`.sigma-global-community-label[data-community-id="${cssString(communityId)}"]`).evaluate((label) => { const rect = label.getBoundingClientRect(); return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; }); await page.mouse.click(point.x, point.y); await page.waitForSelector('[data-testid="graph-community-summary"]'); return { point: { x: round(point.x), y: round(point.y) }, route: await sigmaGlobalSnapshot(page), drawerTestId: await drawerTestId(page) }; } async function clickCommunityRegion(page, communityId) { const point = await findCommunityRegionPoint(page, communityId); await page.evaluate(() => { window.__llmWikiRegionClickProbe = []; for (const region of document.querySelectorAll(".sigma-global-community-region")) { region.addEventListener("click", (event) => { window.__llmWikiRegionClickProbe.push({ communityId: region.getAttribute("data-community-id") || "", clientX: event.clientX, clientY: event.clientY }); }, { once: true }); } }); await page.mouse.click(point.x, point.y); return point; } async function findCommunityRegionPoint(page, communityId) { return page.evaluate((communityId) => { const region = document.querySelector(`.sigma-global-community-region[data-community-id="${CSS.escape(communityId)}"]`) || document.querySelector(".sigma-global-community-region"); if (!region) throw new Error(`Missing Sigma community region ${communityId}`); const rect = region.getBoundingClientRect(); const candidates = [ [0.5, 0.5], [0.5, 0.32], [0.32, 0.5], [0.68, 0.5], [0.5, 0.68] ]; for (const [rx, ry] of candidates) { const x = rect.left + rect.width * rx; const y = rect.top + rect.height * ry; const hit = document.elementFromPoint(x, y); const hitRegionId = hit?.closest?.(".sigma-global-community-region")?.getAttribute("data-community-id") || ""; if (hitRegionId === communityId && !hit?.closest?.(".sigma-global-node-hit-target")) return { x, y }; } return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; }, communityId); } async function communitySummaryDrawerSnapshot(page) { return page.evaluate(() => { const drawer = document.querySelector('[data-testid="graph-community-summary"]'); return { title: drawer?.querySelector("h2")?.textContent || "", buttons: [...(drawer?.querySelectorAll("button") || [])].map((button) => button.textContent?.trim() || ""), hasEnterCommunity: [...(drawer?.querySelectorAll("button") || [])].some((button) => button.textContent?.includes("进入社区")), selectedRegions: [...document.querySelectorAll(".sigma-global-community-region[data-selected='true']")].map((region) => region.getAttribute("data-community-id") || ""), selectedLabels: [...document.querySelectorAll(".sigma-global-community-label[data-selected='true']")].map((label) => label.getAttribute("data-community-id") || "") }; }); } async function communityClickDiagnostics(page, point, communityId) { return page.evaluate(({ point, communityId }) => { const hit = document.elementFromPoint(point.x, point.y); const regions = [...document.querySelectorAll(".sigma-global-community-region")].map((region) => { const rect = region.getBoundingClientRect(); return { communityId: region.getAttribute("data-community-id") || "", selected: region.getAttribute("data-selected") || "", pointerEvents: region instanceof HTMLElement ? getComputedStyle(region).pointerEvents : "", inlinePointerEvents: region instanceof HTMLElement ? region.style.pointerEvents : "", rect: { left: Math.round(rect.left * 1000) / 1000, top: Math.round(rect.top * 1000) / 1000, width: Math.round(rect.width * 1000) / 1000, height: Math.round(rect.height * 1000) / 1000 } }; }); return { expectedCommunityId: communityId, point, hitTag: hit?.tagName || "", hitClass: hit instanceof Element ? hit.className : "", hitCommunityId: hit instanceof Element ? hit.closest(".sigma-global-community-region")?.getAttribute("data-community-id") || "" : "", probe: window.__llmWikiRegionClickProbe || [], drawerTestId: document.querySelector(".drawer-panel-open [data-testid]")?.getAttribute("data-testid") || "", route: document.querySelector(".sigma-global-route")?.getAttribute("data-route") || "", renderer: document.querySelector(".sigma-global-renderer")?.getAttribute("data-renderer") || "", regions }; }, { point, communityId }); } async function runStatePreservationCheck(page) { await closeDrawerIfOpen(page); await waitForSigmaGlobal(page); await openSearch(page); await setGraphSearchQuery(page, "节点A"); try { await page.waitForFunction(() => document.querySelector(".graph-search-status")?.textContent?.includes("1 个结果")); } catch (err) { const diagnostics = await searchDiagnostics(page); throw new assert.AssertionError({ message: `graph search should find node A: ${JSON.stringify(diagnostics)}`, actual: err, expected: "1 个结果", operator: "strictEqual" }); } await page.waitForSelector(".sigma-global-node-hit-target[data-node-id='A'][data-search-hit='true']"); await clickNodeHitTarget(page, "A"); await page.waitForSelector('[data-testid="graph-node-summary"]'); const beforeDrag = await sigmaStateSnapshot(page); const drag = await dragSigmaGlobalNode(page, "A", { dx: 56, dy: 32 }); try { await page.waitForFunction(() => document.querySelector(".sigma-global-node-hit-target[data-node-id='A']")?.getAttribute("data-pinned") === "true"); } catch (err) { const diagnostics = await dragDiagnostics(page, "A", drag); throw new assert.AssertionError({ message: `dragging Sigma global node should pin node A: ${JSON.stringify(diagnostics)}`, actual: err, expected: "data-pinned=true", operator: "strictEqual" }); } const afterDrag = await sigmaStateSnapshot(page); assertPointShifted(afterDrag.nodeABox, beforeDrag.nodeABox, "drag should move node A before it is treated as fixed"); await waitForPersistedGraphPin(page, "wiki/entities/A.md"); await page.locator('[data-testid="graph-node-summary"] button', { hasText: "打开详情" }).click(); await page.waitForSelector(".graph-reader-drawer"); await waitForDomCommunity(page, "t1"); const focused = await domCommunitySnapshot(page); await clickReturnGlobal(page); await waitForSigmaGlobal(page); await page.waitForSelector('[data-testid="graph-node-summary"]'); await page.waitForFunction(() => document.querySelector(".graph-search-input")?.value === "节点A"); await page.waitForFunction(() => document.querySelector(".sigma-global-node-hit-target[data-node-id='A']")?.getAttribute("data-pinned") === "true"); await page.waitForFunction(() => document.querySelector(".sigma-global-node-hit-target[data-node-id='A']")?.getAttribute("data-selected") === "true"); const returned = await sigmaStateSnapshot(page); assert.equal(returned.drawerTestId, "graph-node-summary", "node summary should survive community return"); assert.equal(returned.searchQuery, "节点A", "search query should survive community return"); assert.equal(returned.nodeASelected, "true", "selected node should survive community return"); assert.equal(returned.nodeAPinned, "true", "fixed node should survive community return"); assertPointStable(returned.nodeABox, afterDrag.nodeABox, "dragged fixed node should remain at the released global position after community return"); assert.equal(returned.oldDomGlobalNodeCount, 0, "state-preserving return should still use Sigma, not old DOM global"); await page.reload(); await waitForSigmaGlobal(page); const reloadedGlobal = await sigmaGlobalSnapshot(page); assertSigmaGlobal(reloadedGlobal, "reloaded graph view"); assert.equal(reloadedGlobal.communityButtonCount, 0, "reload should not reintroduce circular community controls"); assert.equal(reloadedGlobal.aggregationButtonCount, 0, "reload should not show aggregation UI"); assert.equal(reloadedGlobal.oldDomGlobalNodeCount, 0, "reload should stay on Sigma, not old DOM global"); await openSearch(page); await setGraphSearchQuery(page, "节点A"); try { await page.waitForFunction(() => document.querySelector(".sigma-global-node-hit-target[data-node-id='A']")?.getAttribute("data-pinned") === "true"); } catch (err) { const diagnostics = await reloadPinDiagnostics(page, "A"); throw new assert.AssertionError({ message: `fixed node should survive reload: ${JSON.stringify(diagnostics)}`, actual: err, expected: "data-pinned=true", operator: "strictEqual" }); } const reloaded = await sigmaStateSnapshot(page); assert.equal(reloaded.nodeAPinned, "true", "fixed node should survive a page reload"); assertPointShifted(reloaded.nodeABox, beforeDrag.nodeABox, "reloaded fixed node should not fall back to its pre-drag position"); return { drag, beforeDrag, afterDrag, focused, returned, reloadedGlobal, reloaded }; } async function sigmaStateSnapshot(page) { return page.evaluate(() => { const nodeA = document.querySelector(".sigma-global-node-hit-target[data-node-id='A']"); const nodeABox = nodeA ? (() => { const rect = nodeA.getBoundingClientRect(); return { left: Math.round(rect.left * 1000) / 1000, top: Math.round(rect.top * 1000) / 1000, width: Math.round(rect.width * 1000) / 1000, height: Math.round(rect.height * 1000) / 1000, centerX: Math.round((rect.left + rect.width / 2) * 1000) / 1000, centerY: Math.round((rect.top + rect.height / 2) * 1000) / 1000 }; })() : null; return { route: document.querySelector(".sigma-global-route")?.getAttribute("data-route") || "", renderer: document.querySelector(".sigma-global-renderer")?.getAttribute("data-renderer") || "", drawerTestId: document.querySelector(".drawer-panel-open [data-testid]")?.getAttribute("data-testid") || "", searchQuery: document.querySelector(".graph-search-input")?.value || "", nodeASelected: nodeA?.getAttribute("data-selected") || "", nodeAPinned: nodeA?.getAttribute("data-pinned") || "", nodeABox, oldDomGlobalNodeCount: document.querySelectorAll(".node").length }; }); } async function searchDiagnostics(page) { return page.evaluate(() => ({ searchState: document.querySelector(".graph-search")?.getAttribute("data-state") || "", searchOpen: document.querySelector(".sigma-global-route")?.getAttribute("data-search-open") || "", inputValue: document.querySelector(".graph-search-input")?.value || "", statusText: document.querySelector(".graph-search-status")?.textContent || "", nodeTargets: [...document.querySelectorAll(".sigma-global-node-hit-target")].map((target) => ({ nodeId: target.getAttribute("data-node-id") || "", searchHit: target.getAttribute("data-search-hit") || "", selected: target.getAttribute("data-selected") || "", pinned: target.getAttribute("data-pinned") || "" })) })); } async function clickNodeHitTarget(page, nodeId) { const locator = page.locator(`.sigma-global-node-hit-target[data-node-id="${cssString(nodeId)}"]`); await locator.waitFor(); await locator.click({ force: true }); } async function dragSigmaGlobalNode(page, nodeId, delta) { const locator = page.locator(`.sigma-global-node-hit-target[data-node-id="${cssString(nodeId)}"]`); await locator.waitFor(); await waitForStableNodeHitTarget(page, nodeId); const start = await locator.evaluate((node) => { const rect = node.getBoundingClientRect(); return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; }); const end = { x: start.x + delta.dx, y: start.y + delta.dy }; await page.mouse.move(start.x, start.y); await page.mouse.down(); await page.mouse.move(start.x + delta.dx / 2, start.y + delta.dy / 2, { steps: 6 }); await page.mouse.move(end.x, end.y, { steps: 6 }); await page.mouse.up(); return { start: { x: round(start.x), y: round(start.y) }, end: { x: round(end.x), y: round(end.y) }, delta }; } async function waitForStableNodeHitTarget(page, nodeId) { await page.waitForFunction(async (nodeId) => { const target = document.querySelector(`.sigma-global-node-hit-target[data-node-id="${CSS.escape(nodeId)}"]`); if (!(target instanceof HTMLElement)) return false; const snapshot = () => { const rect = target.getBoundingClientRect(); return { centerX: Math.round((rect.left + rect.width / 2) * 1000) / 1000, centerY: Math.round((rect.top + rect.height / 2) * 1000) / 1000 }; }; const first = snapshot(); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); const second = snapshot(); const hit = document.elementFromPoint(second.centerX, second.centerY); const hitNodeId = hit instanceof Element ? hit.closest(".sigma-global-node-hit-target")?.getAttribute("data-node-id") || "" : ""; return hitNodeId === nodeId && Math.abs(first.centerX - second.centerX) < 1 && Math.abs(first.centerY - second.centerY) < 1; }, nodeId, { timeout: 5000 }); } async function dragDiagnostics(page, nodeId, drag) { return page.evaluate(({ nodeId, drag }) => { const roundInPage = (value) => Math.round(value * 1000) / 1000; const target = document.querySelector(`.sigma-global-node-hit-target[data-node-id="${CSS.escape(nodeId)}"]`); const hit = document.elementFromPoint(drag.start.x, drag.start.y); return { drag, hitTag: hit?.tagName || "", hitClass: hit instanceof Element ? hit.className : "", hitNodeId: hit instanceof Element ? hit.closest(".sigma-global-node-hit-target")?.getAttribute("data-node-id") || "" : "", targetPinned: target?.getAttribute("data-pinned") || "", targetSelected: target?.getAttribute("data-selected") || "", targetSearchHit: target?.getAttribute("data-search-hit") || "", targetBox: target instanceof HTMLElement ? (() => { const rect = target.getBoundingClientRect(); return { left: roundInPage(rect.left), top: roundInPage(rect.top), width: roundInPage(rect.width), height: roundInPage(rect.height), centerX: roundInPage(rect.left + rect.width / 2), centerY: roundInPage(rect.top + rect.height / 2), pointerEvents: getComputedStyle(target).pointerEvents, touchAction: getComputedStyle(target).touchAction, userSelect: getComputedStyle(target).userSelect }; })() : null, route: document.querySelector(".sigma-global-route")?.getAttribute("data-route") || "", renderer: document.querySelector(".sigma-global-renderer")?.getAttribute("data-renderer") || "", draggingNodeId: document.querySelector(".sigma-global-renderer")?.getAttribute("data-dragging-node-id") || "" }; }, { nodeId, drag }); } async function reloadPinDiagnostics(page, nodeId) { return page.evaluate(async (nodeId) => { const currentGraphLayoutUrl = async () => { const response = await fetch("/api/knowledge-base"); const payload = await response.json(); const kbPath = payload?.active?.kb?.path || ""; return kbPath ? `/api/graph/layout?kb=${encodeURIComponent(kbPath)}` : "/api/graph/layout"; }; const target = document.querySelector(`.sigma-global-node-hit-target[data-node-id="${CSS.escape(nodeId)}"]`); const layoutUrl = await currentGraphLayoutUrl(); const layoutResponse = await fetch(layoutUrl); const layoutPayload = await layoutResponse.json().catch(() => null); return { nodeId, targetExists: Boolean(target), targetPinned: target?.getAttribute("data-pinned") || "", targetSelected: target?.getAttribute("data-selected") || "", targetSearchHit: target?.getAttribute("data-search-hit") || "", searchQuery: document.querySelector(".graph-search-input")?.value || "", statusText: document.querySelector(".graph-search-status")?.textContent || "", route: document.querySelector(".sigma-global-route")?.getAttribute("data-route") || "", renderer: document.querySelector(".sigma-global-renderer")?.getAttribute("data-renderer") || "", layoutUrl, layoutPins: layoutPayload?.layout?.pins || null, allTargets: [...document.querySelectorAll(".sigma-global-node-hit-target")].map((node) => ({ id: node.getAttribute("data-node-id") || "", pinned: node.getAttribute("data-pinned") || "", selected: node.getAttribute("data-selected") || "", searchHit: node.getAttribute("data-search-hit") || "" })) }; }, nodeId); } async function waitForPersistedGraphPin(page, pinKey) { try { await page.waitForFunction(async (pinKey) => { const currentGraphLayoutUrl = async () => { const response = await fetch("/api/knowledge-base"); const payload = await response.json(); const kbPath = payload?.active?.kb?.path || ""; return kbPath ? `/api/graph/layout?kb=${encodeURIComponent(kbPath)}` : "/api/graph/layout"; }; const layoutUrl = await currentGraphLayoutUrl(); const response = await fetch(layoutUrl); if (!response.ok) return false; const payload = await response.json(); return Boolean(payload?.layout?.pins?.[pinKey]); }, pinKey, { timeout: 5000 }); } catch (err) { const diagnostics = await page.evaluate(async (pinKey) => { const currentGraphLayoutUrl = async () => { const response = await fetch("/api/knowledge-base"); const payload = await response.json(); const kbPath = payload?.active?.kb?.path || ""; return kbPath ? `/api/graph/layout?kb=${encodeURIComponent(kbPath)}` : "/api/graph/layout"; }; const layoutUrl = await currentGraphLayoutUrl(); const response = await fetch(layoutUrl); const text = await response.text(); return { pinKey, layoutUrl, ok: response.ok, status: response.status, body: text.slice(0, 500) }; }, pinKey); throw new assert.AssertionError({ message: `graph pin should be persisted before reload: ${JSON.stringify(diagnostics)}`, actual: err, expected: `layout.pins[${pinKey}]`, operator: "strictEqual" }); } } async function clickReturnGlobal(page) { await page.getByRole("button", { name: "回全图" }).click(); } async function openSearch(page) { await page.locator(".graph-search-input").focus(); await page.waitForFunction(() => document.querySelector(".graph-search")?.getAttribute("data-state") === "open"); } async function setGraphSearchQuery(page, query) { await page.evaluate((query) => { const input = document.querySelector(".graph-search[data-state='open'] .graph-search-input"); if (!(input instanceof HTMLInputElement)) throw new Error("Graph search input is not open"); input.value = query; input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: query ? "insertText" : "deleteContentBackward", data: query || null })); }, query); try { await page.waitForFunction((query) => { const input = document.querySelector(".graph-search-input"); return input instanceof HTMLInputElement && input.value === query; }, query, { timeout: 5000 }); } catch (err) { const diagnostics = await searchDiagnostics(page); throw new assert.AssertionError({ message: `graph search input should settle to ${JSON.stringify(query)}: ${JSON.stringify(diagnostics)}`, actual: err, expected: query, operator: "strictEqual" }); } } async function drawerTestId(page) { return page.evaluate(() => document.querySelector(".drawer-panel-open [data-testid]")?.getAttribute("data-testid") || ""); } async function closeDrawerIfOpen(page) { const button = page.locator(".drawer-header button[aria-label='关闭']"); if (await button.count()) { await button.first().click({ force: true }); await page.waitForSelector(".drawer-panel-open", { state: "detached", timeout: 3000 }).catch(() => undefined); } } async function waitForVisibleDomNodeIds(page, expected) { await page.waitForFunction((expected) => { const actual = [...document.querySelectorAll(".node")] .filter((node) => node.getAttribute("data-filter-state") !== "hidden") .map((node) => node.getAttribute("data-id")) .filter(Boolean) .sort(); return actual.length === expected.length && actual.every((id, index) => id === expected[index]); }, expected, { timeout: 5000 }); } function assertSigmaGlobal(snapshot, label) { assert.equal(snapshot.route, "sigma-global", `${label}: route should be Sigma global`); assert.equal(snapshot.renderer, "sigma-global", `${label}: renderer should be Sigma global`); assert.ok(snapshot.canvasCount >= 1, `${label}: Sigma canvas should exist`); assert.ok(snapshot.canvasBox?.width > 120 && snapshot.canvasBox?.height > 120, `${label}: Sigma canvas should be visible`); assert.equal(snapshot.aggregationButtonCount, 0, `${label}: no visible aggregation controls`); for (const labelInfo of snapshot.labels) { assert.equal(labelInfo.ariaHidden, "true", `${label}: community labels should be aria-hidden`); assert.equal(labelInfo.tabIndex, -1, `${label}: community labels should not be tab-focusable`); assert.equal(labelInfo.pointerEvents, "none", `${label}: community labels should be passive`); } } function assertPointStable(actual, expected, message) { assert.ok(actual, `${message}: missing actual node box`); assert.ok(expected, `${message}: missing expected node box`); assert.ok(Math.abs(actual.centerX - expected.centerX) <= 3, `${message}: x drifted from ${expected.centerX} to ${actual.centerX}`); assert.ok(Math.abs(actual.centerY - expected.centerY) <= 3, `${message}: y drifted from ${expected.centerY} to ${actual.centerY}`); } function assertPointShifted(actual, expected, message) { assert.ok(actual, `${message}: missing actual node box`); assert.ok(expected, `${message}: missing expected node box`); const dx = Math.abs(actual.centerX - expected.centerX); const dy = Math.abs(actual.centerY - expected.centerY); assert.ok(dx >= 20 || dy >= 20, `${message}: expected a visible move, got dx=${dx}, dy=${dy}`); } async function closeBrowserForRegression(browser) { let closed = false; const closePromise = browser.close() .then(() => { closed = true; }) .catch(() => { closed = true; }); await Promise.race([ closePromise, new Promise((resolve) => setTimeout(resolve, 5000)) ]); const browserProcess = typeof browser.process === "function" ? browser.process() : null; if (!closed) browserProcess?.kill("SIGKILL"); } function round(value) { return Math.round(value * 1000) / 1000; } function cssString(value) { return String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"'); }