import assert from "node:assert/strict"; import { createRequire } from "node:module"; import fs from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; const require = createRequire(import.meta.url); const { chromium } = require("playwright"); const target = process.env.GRAPH_STAGE_4_5_TARGET || "offline"; const offlineHtml = process.env.GRAPH_STAGE_4_5_OFFLINE_HTML || ""; const denseHtml = process.env.GRAPH_STAGE_4_5_DENSE_HTML || ""; const artifactDir = process.env.GRAPH_STAGE_4_5_ARTIFACT_DIR || ""; const workbenchUrl = process.env.GRAPH_STAGE_4_5_WORKBENCH_URL || ""; const executablePath = process.env.GRAPH_STAGE_4_5_CHROME_EXECUTABLE || ""; if (target !== "offline" && target !== "workbench") { throw new Error(`Unknown stage 4.5 browser target: ${target}`); } assert.notEqual(offlineHtml, "", "GRAPH_STAGE_4_5_OFFLINE_HTML must point at generated HTML"); assert.notEqual(denseHtml, "", "GRAPH_STAGE_4_5_DENSE_HTML must point at generated dense HTML"); const browser = await chromium.launch(executablePath ? { executablePath } : {}); try { if (target === "workbench") { await runWorkbenchChecks(browser); } else { await runOfflineChecks(browser); } } finally { if (browser.isConnected()) await browser.close(); } async function runOfflineChecks(browser) { const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }); await page.goto(pathToFileURL(offlineHtml).href); await page.waitForSelector("[data-llm-wiki-graph-root='true']"); await page.waitForSelector("[data-viewport-layer='true']"); await runOfflineThemeChecks(page); const initial = await layerTransform(page); await page.mouse.move(64, 120); await page.mouse.wheel(0, -600); const zoomed = await waitForLayerTransform(page, initial); assert.notEqual(zoomed, initial, "wheel zoom should update the content layer transform"); assert.notEqual(zoomed, "translate(0px, 0px) scale(1)", "wheel zoom should leave the default viewport"); await page.mouse.move(72, 132); await page.mouse.down(); await page.mouse.move(190, 182, { steps: 4 }); await page.mouse.up(); const panned = await waitForLayerTransform(page, zoomed); assert.notEqual(panned, zoomed, "blank drag should pan through the content layer transform"); await page.mouse.dblclick(48, 132); const fitted = await waitForLayerTransform(page, panned); assert.notEqual(fitted, panned, "blank double-click should fit the graph through the content layer transform"); await page.keyboard.down("Shift"); await page.locator(".node").nth(0).click(); await page.locator(".node").nth(1).click(); await page.keyboard.up("Shift"); const selectedAfterShiftClick = await page.locator(".node[aria-pressed='true']").count(); assert.equal(selectedAfterShiftClick, 2, "Shift-clicking two nodes should keep both nodes selected in the graph"); await assertOfflineSelectionPanel(page, "manual"); await page.keyboard.press("Escape"); await page.waitForSelector(".graph-selection-panel[data-state='closed']"); await expectNoPressedNodes(page, "Escape should clear Shift-click selections"); const transformBeforeReader = await layerTransform(page); await page.locator(".node[data-id='A']").click(); await page.waitForSelector(".graph-reader[data-state='open']"); const pressedAfterPlainClick = await page.locator(".node[aria-pressed='true']").count(); assert.equal(pressedAfterPlainClick, 1, "plain node click should highlight one readable node"); await assertOfflineReaderMeta(page, { title: "节点A", typeLabel: "实体", date: "2026-01-02", source: "不应作为实体来源链接", sourceLink: null }); await page.keyboard.press("Escape"); await page.waitForSelector(".graph-reader[data-state='closed']"); await expectNoPressedNodes(page, "Escape should clear the reader highlight"); assert.equal( await layerTransform(page), transformBeforeReader, "clearing graph interaction should not reset the viewport transform" ); await page.locator(".node[data-id='S']").click(); await page.waitForSelector(".graph-reader[data-state='open']"); await assertOfflineReaderMeta(page, { title: "节点来源S", typeLabel: "来源", date: "2026-01-03", source: "原始文章S", sourceLink: "/fake/wiki/sources/S.md" }); await page.keyboard.press("Escape"); await page.waitForSelector(".graph-reader[data-state='closed']"); await expectNoPressedNodes(page, "Escape should clear source reader highlight"); await runSearchKeyboardChecks(page, "offline graph search should support shortcut, cycling, focus, and Escape"); await runLegendChecks(page, { persistReload: true, expectWorkbenchDrawer: false }); await runOfflinePinReloadCheck(page); if (artifactDir) { await page.screenshot({ path: path.join(artifactDir, "stage-4.5-offline-navigation.png"), fullPage: true }); } await page.close(); if (browser.isConnected()) await browser.close(); const denseBrowser = await chromium.launch(executablePath ? { executablePath } : {}); const densePage = await denseBrowser.newPage({ viewport: { width: 1440, height: 960 } }); await densePage.goto(pathToFileURL(denseHtml).href); await densePage.waitForSelector("[data-llm-wiki-graph-root='true']"); await densePage.waitForSelector("[data-viewport-layer='true']"); await densePage.bringToFront(); await runInteractionDegradationChecks(densePage); const denseInitial = await layerTransform(densePage); const denseRoot = densePage.locator("[data-llm-wiki-graph-root='true']"); await denseRoot.click({ position: { x: 520, y: 420 } }); await densePage.waitForTimeout(120); const idleFrameSample = await sampleAnimationFrames(densePage, 1500); const frameSamplePromise = sampleAnimationFrames(densePage, 3000); const wheelStartedAt = Date.now(); while (Date.now() - wheelStartedAt < 3000) { await denseRoot.dispatchEvent("wheel", { deltaY: -180, deltaMode: 0, clientX: 520, clientY: 420, bubbles: true, cancelable: true }); await densePage.waitForTimeout(50); } const frameSample = await frameSamplePromise; const denseZoomed = await layerTransform(densePage); assert.notEqual(denseZoomed, denseInitial, "continuous dense wheel interaction should keep updating the viewport"); const minimumInteractionFps = Math.max(12, Math.min(30, idleFrameSample.fps * 0.5)); assert.ok( frameSample.fps >= minimumInteractionFps, `dense wheel interaction should stay near the local browser baseline, baseline=${idleFrameSample.fps.toFixed(1)}fps, got ${frameSample.fps.toFixed(1)}fps` ); if (artifactDir) { await densePage.screenshot({ path: path.join(artifactDir, "stage-4.5-offline-dense-wheel.png"), fullPage: true }); await fs.writeFile( path.join(artifactDir, "stage-4.5-offline-dense-wheel.json"), `${JSON.stringify({ viewport: "1440x960", durationMs: Math.round(frameSample.durationMs), frames: frameSample.frames, idleFps: Math.round(idleFrameSample.fps * 10) / 10, fps: Math.round(frameSample.fps * 10) / 10, minimumInteractionFps: Math.round(minimumInteractionFps * 10) / 10, transformChanged: denseZoomed !== denseInitial }, null, 2)}\n` ); } await densePage.close(); await denseBrowser.close(); } async function sampleAnimationFrames(page, durationMs) { return page.evaluate((durationMs) => new Promise((resolve) => { const start = performance.now(); let frames = 0; function tick() { frames += 1; const elapsed = performance.now() - start; if (elapsed >= durationMs) { resolve({ frames, durationMs: elapsed, fps: frames / (elapsed / 1000) }); return; } requestAnimationFrame(tick); } requestAnimationFrame(tick); }), durationMs); } async function runOfflineThemeChecks(page) { await page.waitForFunction(() => document.querySelector(".llm-wiki-graph-engine")?.dataset.theme === "shan-shui"); await page.getByRole("button", { name: "切换墨夜主题" }).click({ force: true }); await page.waitForFunction(() => document.querySelector(".llm-wiki-graph-engine")?.dataset.theme === "mo-ye"); await page.getByRole("button", { name: "切换山水主题" }).click({ force: true }); await page.waitForFunction(() => document.querySelector(".llm-wiki-graph-engine")?.dataset.theme === "shan-shui"); } async function runInteractionDegradationChecks(page) { const root = page.locator("[data-llm-wiki-graph-root='true']"); await page.waitForFunction(() => { const graph = document.querySelector("[data-llm-wiki-graph-root='true']"); return graph?.dataset.interactionMode === "idle" && Number(graph.dataset.interactionMaxUpdates || 0) > 0; }); await root.dispatchEvent("wheel", { deltaY: -180, deltaMode: 0, clientX: 520, clientY: 420, bubbles: true, cancelable: true }); const active = await root.evaluate((element) => ({ mode: element.dataset.interactionMode || "", updated: Number(element.dataset.interactionUpdatedObjects || 0), max: Number(element.dataset.interactionMaxUpdates || 0), preserved: Number(element.dataset.interactionPreservedNodes || 0), traceableNodes: element.querySelectorAll('.node[data-traceable="true"]').length, traceableEdges: element.querySelectorAll('.edge[data-traceable="true"]').length })); assert.equal(active.mode, "active", "wheel interaction should switch graph into lightweight interaction mode"); assert.ok(active.updated <= active.max, "interaction mode should keep updated objects within budget"); assert.ok(active.preserved > 0, "interaction mode should preserve traceable anchors"); assert.ok(active.traceableNodes > 0, "interaction mode should leave traceable nodes visible"); assert.ok(active.traceableEdges > 0, "interaction mode should leave traceable edges visible"); await page.waitForFunction(() => document.querySelector("[data-llm-wiki-graph-root='true']")?.dataset.interactionMode === "idle"); } async function layerTransform(page) { return page.locator("[data-viewport-layer='true']").evaluate((element) => element.style.transform); } async function waitForLayerTransform(page, previous) { const selector = "[data-viewport-layer='true']"; await page.waitForFunction( ({ selector, previous }) => { const element = document.querySelector(selector); return Boolean(element && element.style.transform && element.style.transform !== previous); }, { selector, previous }, { timeout: 3000 } ); return layerTransform(page); } async function expectNoPressedNodes(page, message) { const pressed = await page.locator(".node[aria-pressed='true']").count(); assert.equal(pressed, 0, message); } async function assertOfflineReaderMeta(page, expected) { const reader = page.locator(".graph-reader[data-state='open']"); await reader.locator(".graph-reader-title", { hasText: expected.title }).waitFor(); const meta = reader.locator(".graph-reader-meta"); const metaItems = await meta.locator("span").allInnerTexts(); assert.ok(metaItems.includes(expected.typeLabel), `offline reader meta should include type ${expected.typeLabel}`); assert.ok(metaItems.includes(expected.date), `offline reader meta should include date ${expected.date}`); assert.ok(metaItems.includes(expected.source), `offline reader meta should include source ${expected.source}`); if (expected.sourceLink) { const sourceLink = reader.locator(".graph-reader-source"); await sourceLink.waitFor(); assert.equal(await sourceLink.innerText(), expected.sourceLink, "source pages should expose their source path as a reader link"); } else { assert.equal(await reader.locator(".graph-reader-source").count(), 0, "non-source pages should not expose source path reader links"); } await assertNoOfflineAskActions(reader); } async function runOfflinePinReloadCheck(page) { const pinKey = await nodePinKey(page, "A"); await page.evaluate((pinKey) => { const key = window.__LLM_WIKI_GRAPH_PINS_KEY__; if (!key) throw new Error("offline graph pins key should be published"); window.localStorage.setItem(key, JSON.stringify({ [pinKey]: { x: 333, y: 222 } })); }, pinKey); await page.reload(); await page.waitForSelector("[data-llm-wiki-graph-root='true']"); await page.waitForSelector(".node[data-id='A'][data-pinned='true']"); const pinnedCount = await page.locator("[data-llm-wiki-graph-root='true']").evaluate((element) => element.dataset.pinnedCount); assert.equal(pinnedCount, "1", "offline localStorage pins should survive reload"); const measurement = await page.locator(".node[data-id='A']").evaluate((node) => { return { worldPoint: { x: Number(node.dataset.worldX || node.dataset.liveX || Number.NaN), y: Number(node.dataset.worldY || node.dataset.liveY || Number.NaN) } }; }); const expected = { x: 333, y: 222 }; const distance = Math.hypot(measurement.worldPoint.x - expected.x, measurement.worldPoint.y - expected.y); assert.ok(distance <= 6, `offline localStorage pin should reload at its world coordinate, distance=${distance.toFixed(2)}`); } async function nodePinKey(page, id) { return page.evaluate((id) => { const dataEl = document.getElementById("graph-data"); const graphData = dataEl?.textContent ? JSON.parse(dataEl.textContent) : null; const node = graphData?.nodes?.find((item) => item.id === id); if (!node) return id; const existing = node.source_path || node.path || node.source; if (existing) return String(existing); const nodeId = String(node.id || id); const baseId = nodeId.endsWith(".md") ? nodeId.slice(0, -3) : nodeId; const type = String(node.type || ""); const directory = type === "topic" ? "topics" : type === "source" ? "sources" : type === "comparison" ? "comparisons" : type === "synthesis" ? "synthesis" : type === "query" ? "queries" : "entities"; return `wiki/${directory}/${baseId}.md`; }, id); } async function runSearchKeyboardChecks(page, message) { const root = page.locator("[data-llm-wiki-graph-root='true']"); await root.click({ position: { x: 24, y: 24 } }); await root.evaluate((element) => element.focus({ preventScroll: true })); await page.keyboard.press(searchShortcut()); await waitForSearchState(page, "open"); const input = page.locator(".graph-search-input"); await input.fill("节点"); await page.waitForSelector('.node[data-search-state="match"]'); const matches = await page.locator('.node[data-search-state="match"]').count(); assert.ok(matches >= 2, `${message}: query should match multiple nodes`); assert.equal( await page.locator('.node[data-search-state="faded"]').count(), 0, `${message}: broad query should not fade matching fixture nodes` ); const beforeFocus = await layerTransform(page); await input.press("ArrowDown"); await page.waitForSelector('.node[data-search-focus="true"]'); const firstFocus = await focusedSearchNodeId(page); await input.press("ArrowDown"); await page.waitForFunction((previous) => { const focused = document.querySelector('.node[data-search-focus="true"]'); return Boolean(focused && focused.dataset.id && focused.dataset.id !== previous); }, firstFocus); const secondFocus = await focusedSearchNodeId(page); assert.notEqual(secondFocus, firstFocus, `${message}: ArrowDown should cycle to another result`); assert.notEqual( await waitForLayerTransform(page, beforeFocus), beforeFocus, `${message}: ArrowDown should move the viewport to the focused result` ); await input.press("Enter"); await page.waitForSelector(`.node[data-id="${secondFocus}"][aria-pressed="true"]`); await input.press("Escape"); await waitForSearchState(page, "closed"); assert.equal( await page.locator('.node[data-search-state="match"], .node[data-search-state="faded"]').count(), 0, `${message}: Escape should restore search visual state` ); assert.equal( await page.locator('.node[data-search-focus="true"]').count(), 0, `${message}: Escape should clear the focused search result` ); } async function focusedSearchNodeId(page) { return page.locator('.node[data-search-focus="true"]').evaluate((element) => element.dataset.id || ""); } function searchShortcut() { return process.platform === "darwin" ? "Meta+F" : "Control+F"; } async function waitForSearchState(page, state) { await page.waitForFunction((state) => { return document.querySelector(".graph-search")?.dataset.state === state; }, state); } async function runLegendChecks(page, options) { await waitForToolbarPanel(page, "closed"); await openToolbarFilters(page); await runTypeFilterChecks(page); await runEdgeLegendChecks(page); const row = page.locator(".community-legend-row").first(); await row.hover(); await page.waitForSelector('.node[data-community-state="faded"]'); await page.mouse.move(900, 120); if (options.persistReload) { await page.reload(); await page.waitForSelector("[data-llm-wiki-graph-root='true']"); await waitForToolbarPanel(page, "filters"); await closeToolbarWithBlankClick(page); await waitForToolbarPanel(page, "closed"); await page.reload(); await page.waitForSelector("[data-llm-wiki-graph-root='true']"); await waitForToolbarPanel(page, "closed"); await openToolbarFilters(page); } await page.waitForSelector(".edge"); const focusableCommunity = await firstCommunityWithInternalEdge(page); const focusRow = page.locator(`.community-legend-row[data-community-id="${cssString(focusableCommunity)}"]`); const beforeClick = await layerTransform(page); const initialNodes = await page.locator(".node").count(); await focusRow.click(); await page.waitForFunction(() => document.querySelectorAll(".node[aria-pressed='true']").length > 0); await page.waitForTimeout(120); assert.equal( await layerTransform(page), beforeClick, "legend click should keep the global viewport and show a community summary first" ); assert.equal( await page.locator(".node").count(), initialNodes, "legend click should not enter community focus before an explicit action" ); const selected = await page.locator(".node[aria-pressed='true']").count(); assert.ok(selected >= 1, "legend click should select the community nodes"); if (options.expectWorkbenchDrawer) { await page.waitForSelector(".drawer-panel-open"); await page.locator(".drawer-panel-open .drawer-title", { hasText: "选区" }).waitFor(); await page.locator(".drawer-header button").last().click(); await page.waitForSelector(".drawer-panel-open", { state: "detached" }); assert.equal( await page.locator(".node").count(), initialNodes, "closing the workbench selection drawer should keep the global graph visible" ); await expectNoPressedNodes(page, "closing the workbench selection drawer should clear highlights without changing global focus"); await closeToolbarWithBlankClick(page); await waitForToolbarPanel(page, "closed"); const target = await rightmostNodeSnapshot(page); await page.locator(`.node[data-id="${cssString(target.id)}"]`).click(); await page.waitForSelector(".drawer-panel-open"); await page.locator(".drawer-panel-open .drawer-title").waitFor(); const afterOpen = await nodeSnapshot(page, target.id); const drift = Math.abs(afterOpen.centerRatioX - target.centerRatioX); assert.ok( drift <= 0.16, `opening the workbench reader drawer should keep the clicked node visually anchored; drift=${drift.toFixed(3)}` ); assert.ok( afterOpen.centerRatioX >= 0.18 && afterOpen.centerRatioX <= 0.78, `clicked node should remain in a comfortable visible band after drawer opens; ratio=${afterOpen.centerRatioX.toFixed(3)}` ); await page.keyboard.press("Escape"); await page.waitForSelector(".drawer-panel-open", { state: "detached" }); } else { await assertOfflineSelectionPanel(page, "community"); } } async function firstCommunityWithInternalEdge(page) { const communityId = await page.evaluate(() => { const nodeCommunity = new Map(); for (const node of document.querySelectorAll(".node")) { nodeCommunity.set(node.getAttribute("data-id"), node.getAttribute("data-community")); } for (const edge of document.querySelectorAll(".edge")) { const from = edge.getAttribute("data-from"); const to = edge.getAttribute("data-to"); const fromCommunity = nodeCommunity.get(from); if (fromCommunity && fromCommunity === nodeCommunity.get(to)) return fromCommunity; } return ""; }); assert.notEqual(communityId, "", "fixture should include a community with at least one internal relation edge"); return communityId; } async function runEdgeLegendChecks(page) { await page.getByRole("button", { name: "图例" }).click(); await waitForToolbarPanel(page, "legend"); await page.locator(".graph-edge-legend").waitFor(); await page.locator(".graph-edge-legend-relation", { hasText: "矛盾" }).waitFor(); await page.locator(".graph-edge-legend-confidence", { hasText: "推断" }).waitFor(); await hoverFirstEdgeMidpoint(page); await page.waitForSelector(".graph-hover-preview[data-state='open'][data-kind='edge']"); const preview = page.locator(".graph-hover-preview"); await preview.locator(".graph-hover-preview-type", { hasText: "关系" }).waitFor(); await preview.locator(".graph-hover-preview-title").waitFor(); await assertBoxInsideViewport(page, ".graph-hover-preview", "edge relation hover preview should stay inside viewport"); await page.getByRole("button", { name: "筛选" }).click(); await waitForToolbarPanel(page, "filters"); } async function hoverFirstEdgeMidpoint(page) { const point = await page.locator(".edge").first().evaluate((edge) => { if (!(edge instanceof SVGPathElement)) throw new Error("edge should be an SVG path"); const svg = edge.ownerSVGElement; if (!svg) throw new Error("edge should have an owner SVG"); const length = edge.getTotalLength(); const mid = edge.getPointAtLength(length / 2); const rect = svg.getBoundingClientRect(); const viewBox = svg.viewBox.baseVal; return { x: rect.left + (mid.x - viewBox.x) / viewBox.width * rect.width, y: rect.top + (mid.y - viewBox.y) / viewBox.height * rect.height }; }); await page.mouse.move(point.x, point.y); } async function runTypeFilterChecks(page) { const option = page.locator(".graph-type-filter-option").first(); await option.waitFor(); const type = await option.locator("input").evaluate((input) => input.dataset.type || ""); assert.notEqual(type, "", "type filter option should publish its graph node type"); const toggle = option.locator("input"); const selector = `.node[data-type="${cssString(type)}"]`; const initialTypeNodes = await page.locator(selector).count(); const initialNodes = await page.locator(".node").count(); const beforeFilterTransform = await layerTransform(page); await toggle.uncheck(); await page.waitForFunction((selector) => { return [...document.querySelectorAll(selector)].every((node) => node.getAttribute("data-filter-state") === "hidden"); }, selector); const filteredNodes = await page.locator(".node").count(); const hiddenTypeNodes = await page.locator(`${selector}[data-filter-state="hidden"]`).count(); assert.equal(filteredNodes, initialNodes, "turning off a node type should preserve graph node elements"); assert.equal(hiddenTypeNodes, initialTypeNodes, "turning off a node type should mark that type hidden"); assert.equal(await layerTransform(page), beforeFilterTransform, "type filter changes should not move the graph layout"); await toggle.check(); await page.waitForFunction( ({ selector, initialTypeNodes }) => { const nodes = [...document.querySelectorAll(selector)]; return nodes.length === initialTypeNodes && nodes.every((node) => node.getAttribute("data-filter-state") !== "hidden"); }, { selector, initialTypeNodes } ); } function cssString(value) { return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } async function rightmostNodeSnapshot(page) { const nodes = await page.locator(".node").evaluateAll((elements) => { const root = document.querySelector("[data-llm-wiki-graph-root='true']"); if (!root) throw new Error("graph root should exist"); const rootRect = root.getBoundingClientRect(); return elements.map((element) => { const rect = element.getBoundingClientRect(); return { id: element.getAttribute("data-id") || "", centerRatioX: (rect.left + rect.width / 2 - rootRect.left) / Math.max(1, rootRect.width), centerRatioY: (rect.top + rect.height / 2 - rootRect.top) / Math.max(1, rootRect.height) }; }).filter((item) => item.id); }); assert.ok(nodes.length > 0, "focused community should expose at least one node"); return nodes.sort((a, b) => b.centerRatioX - a.centerRatioX)[0]; } async function nodeSnapshot(page, id) { return page.locator(`.node[data-id="${cssString(id)}"]`).evaluate((element) => { const root = document.querySelector("[data-llm-wiki-graph-root='true']"); if (!root) throw new Error("graph root should exist"); const rootRect = root.getBoundingClientRect(); const rect = element.getBoundingClientRect(); return { id: element.getAttribute("data-id") || "", centerRatioX: (rect.left + rect.width / 2 - rootRect.left) / Math.max(1, rootRect.width), centerRatioY: (rect.top + rect.height / 2 - rootRect.top) / Math.max(1, rootRect.height) }; }); } async function assertOfflineSelectionPanel(page, expectedMode) { await page.waitForSelector(".graph-selection-panel[data-state='open']"); const panel = page.locator(".graph-selection-panel"); await panel.getByText("Shift+点击 增删节点").waitFor(); await panel.getByText("内部关联").waitFor(); assert.ok(await panel.locator(".graph-selection-fact").count() >= 4, "offline selection panel should show structural facts"); assert.equal(await panel.getByText("提问选区").count(), 0, "offline selection panel should not show ask actions"); await assertNoOfflineAskActions(panel); if (expectedMode === "community") { await panel.getByText(/社区选区/).waitFor(); } else { await panel.getByText(/手动选区|选中页面/).waitFor(); } } async function assertNoOfflineAskActions(scope) { for (const label of ["提问选区", "在对话中引用", "它和谁有关", "总结这一组", "探索潜在联系"]) { assert.equal(await scope.getByText(label).count(), 0, `offline graph should not show ${label}`); } } async function openToolbarFilters(page) { const state = await page.locator("[data-llm-wiki-graph-root='true']").evaluate((element) => element.dataset.toolbarPanel || ""); if (state !== "filters") { await page.getByRole("button", { name: "筛选" }).click(); } await waitForToolbarPanel(page, "filters"); await page.waitForSelector('.graph-toolbar-panel[data-state="filters"] .community-legend-row'); } async function closeToolbarWithBlankClick(page) { const root = page.locator("[data-llm-wiki-graph-root='true']"); const pointer = { button: 0, pointerId: 1, clientX: 32, clientY: 128, bubbles: true, cancelable: true }; await root.dispatchEvent("pointerdown", { ...pointer, buttons: 1 }); await root.dispatchEvent("pointerup", pointer); } async function waitForToolbarPanel(page, state) { await page.waitForFunction((state) => { return document.querySelector(".llm-wiki-graph-engine")?.dataset.toolbarPanel === state; }, state); } async function runWorkbenchChecks(browser) { assert.notEqual(workbenchUrl, "", "GRAPH_STAGE_4_5_WORKBENCH_URL must point at the workbench dev server"); const page = await openWorkbenchGraphPage(browser, { width: 1440, height: 960 }, "dark"); await runSearchKeyboardChecks(page, "workbench graph search should support shortcut, cycling, focus, and Escape"); await page.locator(".node[data-id='A']").click(); await page.waitForSelector(".drawer-panel-open"); const drawer = page.locator(".drawer-panel-open"); await drawer.locator(".drawer-title", { hasText: "节点A" }).waitFor(); await drawer.getByText("这是节点A的正文").waitFor(); await drawer.getByRole("button", { name: "在对话中引用" }).waitFor(); await drawer.getByRole("button", { name: "它和谁有关" }).waitFor(); await drawer.getByRole("button", { name: "它和谁有关" }).click(); await drawer.locator(".drawer-title", { hasText: "选区" }).waitFor(); await drawer.getByText("Shift+点击 增删节点").waitFor(); const readerNeighborFacts = await drawer.locator(".graph-selection-fact").count(); assert.ok(readerNeighborFacts >= 3, "reader related action should upgrade the page to a neighbor selection"); await page.keyboard.press("Escape"); await page.waitForSelector(".drawer-panel-open", { state: "detached" }); await expectNoPressedNodes(page, "Escape should clear the reader related selection"); await page.locator(".node[data-id='A']").click(); await page.waitForSelector(".drawer-panel-open"); await drawer.locator(".drawer-title", { hasText: "节点A" }).waitFor(); await drawer.getByRole("button", { name: "在对话中引用" }).click(); await page.getByText("@[选区:节点A · 1页] 在对话中引用").waitFor(); await page.waitForSelector(".drawer-panel-open", { state: "detached" }); await page.getByRole("button", { name: /图谱/ }).click(); await page.waitForSelector("[data-llm-wiki-graph-root='true']"); await page.locator(".node[data-id='A']").click(); await page.waitForSelector(".drawer-panel-open"); await drawer.locator(".drawer-title", { hasText: "节点A" }).waitFor(); assert.equal(await drawer.getByText("学习队列").count(), 0, "graph reader should not show learning queue"); assert.equal(await drawer.getByText("摘要").count(), 0, "graph reader should not show a summary block"); assert.equal(await drawer.getByText("相邻节点").count(), 0, "graph reader should not show a neighbor section"); await drawer.getByRole("link", { name: "wiki/entities/B.md" }).click(); await drawer.locator(".drawer-title", { hasText: "wiki/entities/B.md" }).waitFor(); await drawer.getByText("这是节点B的正文").waitFor(); const focused = await page.locator(".node[aria-pressed='true']").count(); assert.equal(focused, 1, "wikilink navigation should keep one graph node highlighted"); assert.equal(await page.locator(".graph-selection-panel").count(), 0, "selection should not use the old floating canvas panel"); if (artifactDir) { await page.screenshot({ path: path.join(artifactDir, "stage-4.5-workbench-reader.png"), fullPage: true }); } await page.keyboard.press("Escape"); await page.waitForSelector(".drawer-panel-open", { state: "detached" }); await expectNoPressedNodes(page, "closing the graph reader should clear the graph highlight"); await page.keyboard.down("Shift"); await page.locator(".node[data-id='A']").click(); await page.keyboard.up("Shift"); await page.waitForSelector(".drawer-panel-open"); await drawer.locator(".drawer-title", { hasText: "选区" }).waitFor(); await drawer.getByText("Shift+点击 增删节点").waitFor(); assert.equal(await drawer.locator(".graph-selection-fact").count(), 0, "single-node selection should hide structural facts"); assert.equal(await drawer.getByText("探索潜在联系").count(), 0, "single-node selection should not show group exploration"); await page.keyboard.down("Shift"); await page.locator(".node[data-id='B']").click(); await page.keyboard.up("Shift"); await drawer.getByText("Shift+点击 增删节点").waitFor(); await drawer.getByText("总结这一组").waitFor(); assert.ok(await drawer.locator(".graph-selection-fact").count() >= 3, "multi-node selection should show structural facts"); await page.keyboard.press("Escape"); await page.waitForSelector(".drawer-panel-open", { state: "detached" }); await expectNoPressedNodes(page, "Escape should clear the workbench selection drawer and graph highlights"); await runLegendChecks(page, { persistReload: false, expectWorkbenchDrawer: true }); await runWorkbenchThemeViewportMatrix(browser); } 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: /Stage 4\.5 Workbench Test|workbench-kb/ }); 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().click(); } await page.waitForSelector("[data-llm-wiki-graph-root='true']"); const expectedGraphTheme = theme === "dark" ? "mo-ye" : "shan-shui"; await page.waitForFunction((expectedGraphTheme) => { return document.querySelector(".graph-screen")?.dataset.graphTheme === expectedGraphTheme && document.querySelector(".llm-wiki-graph-engine")?.dataset.theme === expectedGraphTheme; }, expectedGraphTheme); return page; } async function runWorkbenchThemeViewportMatrix(browser) { const cases = [ { name: "desktop-light", viewport: { width: 1440, height: 960 }, theme: "light" }, { name: "desktop-dark", viewport: { width: 1440, height: 960 }, theme: "dark" }, { name: "tablet-light", viewport: { width: 768, height: 1024 }, theme: "light" }, { name: "tablet-dark", viewport: { width: 768, height: 1024 }, theme: "dark" }, { name: "mobile-light", viewport: { width: 390, height: 844 }, theme: "light" }, { name: "mobile-dark", viewport: { width: 390, height: 844 }, theme: "dark" } ]; for (const item of cases) { const page = await openWorkbenchGraphPage(browser, item.viewport, item.theme); await runWorkbenchThemeViewportChecks(page, item); await page.close(); } } async function runWorkbenchThemeViewportChecks(page, item) { await runPreviewCheck(page, `${item.name} hover preview should open`); await runSearchKeyboardChecks(page, `${item.name} workbench graph search should work`); await openToolbarFilters(page); await page.locator(".community-legend-row").first().hover(); await page.waitForSelector('.node[data-community-state="faded"]'); await page.mouse.move(80, 80); await closeToolbarWithBlankClick(page); await waitForToolbarPanel(page, "closed"); await page.locator(".node[data-id='A']").click(); await page.waitForSelector(".drawer-panel-open"); const drawer = page.locator(".drawer-panel-open"); await drawer.locator(".drawer-title", { hasText: "节点A" }).waitFor(); await drawer.getByText("这是节点A的正文").waitFor(); await assertNoViewportOverflow(page, `.drawer reader ${item.name}`); assert.equal(await page.locator(".graph-selection-panel").count(), 0, `${item.name} should not use the old floating canvas panel`); if (artifactDir) { await page.screenshot({ path: path.join(artifactDir, `stage-4.5-workbench-${item.name}.png`), fullPage: true }); } await page.keyboard.press("Escape"); await page.waitForSelector(".drawer-panel-open", { state: "detached" }); await expectNoPressedNodes(page, `${item.name} Escape should clear the graph reader highlight`); await page.keyboard.down("Shift"); await page.locator(".node[data-id='A']").click(); await page.keyboard.up("Shift"); await page.waitForSelector(".drawer-panel-open"); await drawer.locator(".drawer-title", { hasText: "选区" }).waitFor(); await drawer.getByText("Shift+点击 增删节点").waitFor(); await assertNoViewportOverflow(page, `.drawer selection ${item.name}`); await page.keyboard.press("Escape"); await page.waitForSelector(".drawer-panel-open", { state: "detached" }); await expectNoPressedNodes(page, `${item.name} Escape should clear the selection drawer and graph highlights`); } async function runPreviewCheck(page, message) { const node = page.locator(".node[data-id='A']"); await node.hover(); await page.waitForSelector(".graph-hover-preview[data-state='open']"); const preview = page.locator(".graph-hover-preview"); await preview.locator(".graph-hover-preview-title").waitFor(); await preview.locator(".graph-hover-preview-type").waitFor(); await assertBoxInsideViewport(page, ".graph-hover-preview", message); await page.mouse.move(20, 20); await waitForPreviewState(page, "closed"); } async function waitForPreviewState(page, state) { await page.waitForFunction((state) => { return document.querySelector(".graph-hover-preview")?.dataset.state === state; }, state); } async function assertNoViewportOverflow(page, label) { await assertBoxInsideViewport(page, ".drawer-panel-open", label); await assertBoxInsideViewport(page, "[data-llm-wiki-graph-root='true']", label); } async function assertBoxInsideViewport(page, selector, label) { const box = await page.locator(selector).boundingBox(); assert.ok(box, `${label}: ${selector} should have a bounding box`); const viewport = page.viewportSize(); assert.ok(viewport, `${label}: viewport should be available`); assert.ok(box.x >= -1, `${label}: ${selector} should not overflow left`); assert.ok(box.y >= -1, `${label}: ${selector} should not overflow top`); assert.ok(box.x + box.width <= viewport.width + 1, `${label}: ${selector} should not overflow right`); assert.ok(box.y + box.height <= viewport.height + 1, `${label}: ${selector} should not overflow bottom`); }