816 lines
37 KiB
JavaScript
816 lines
37 KiB
JavaScript
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`);
|
|
}
|