Files
llm-wiki/tests/browser/graph-workbench-interactions.mjs
T
2026-07-12 21:26:08 +08:00

809 lines
38 KiB
JavaScript

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('"', '\\"');
}