first
This commit is contained in:
@@ -0,0 +1,737 @@
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { buildCommunityAggregationMarkers } from "../../packages/graph-engine/src";
|
||||
import {
|
||||
generateLargeGraphFixture,
|
||||
type LargeGraphFixtureMetadata
|
||||
} from "../../packages/graph-engine/test/large-graph-fixtures";
|
||||
import { buildAggregationFallbackTrialModel } from "../../packages/graph-engine/test/aggregation-fallback-trial-adapter";
|
||||
import {
|
||||
memoryGrowthFailureClass,
|
||||
memoryGrowthFailureDetail,
|
||||
parseRequestedShapes,
|
||||
validateTrialResults,
|
||||
waitForAnimationFrames
|
||||
} from "./graph-renderer-trial-shared";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const artifactDir = process.env.GRAPH_AGGREGATION_TRIAL_ARTIFACT_DIR || path.join(os.tmpdir(), `llm-wiki-graph-aggregation-trial-${Date.now()}`);
|
||||
const executablePath = process.env.GRAPH_AGGREGATION_TRIAL_CHROME_EXECUTABLE || "";
|
||||
const requestedShapes = parseRequestedShapes(process.env.GRAPH_AGGREGATION_TRIAL_SHAPES);
|
||||
const resultPath = path.join(artifactDir, "aggregation-fallback-trial-results.json");
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await fs.mkdir(artifactDir, { recursive: true });
|
||||
const runStartedAt = new Date().toISOString();
|
||||
const records: PerformanceRecord[] = [];
|
||||
const errors: string[] = [];
|
||||
const browser = await chromium.launch(executablePath ? { executablePath } : {});
|
||||
|
||||
try {
|
||||
for (const shape of requestedShapes) {
|
||||
const fixture = generateLargeGraphFixture(shape);
|
||||
const searchResultIds = fixture.data.nodes.filter((node) => node.label.includes("needle")).map((node) => node.id);
|
||||
const selectedNodeIds = fixture.data.nodes.slice(0, Math.min(8, fixture.data.nodes.length)).map((node) => node.id);
|
||||
const aggregationMarkers = buildCommunityAggregationMarkers(fixture.data, {
|
||||
pins: fixture.pins,
|
||||
searchResultIds,
|
||||
selectedNodeIds,
|
||||
minCommunitySize: 80
|
||||
});
|
||||
const model = buildAggregationFallbackTrialModel(fixture.data, {
|
||||
pins: fixture.pins,
|
||||
searchResultIds,
|
||||
selection: selectedNodeIds[0] ? { kind: "node", id: selectedNodeIds[0] } : null,
|
||||
aggregationMarkers
|
||||
});
|
||||
const html = await writeTrialHtml(shape, model);
|
||||
try {
|
||||
const shapeRecords = await measureShape(browser, fixture.metadata, html);
|
||||
records.push(...shapeRecords);
|
||||
} catch (error) {
|
||||
errors.push(`${shape}: ${errorDetail(error)}`);
|
||||
records.push(failedRecord(fixture.metadata, {
|
||||
action: "fixture_load_or_action",
|
||||
failure_class: classifyError(error),
|
||||
failure_detail: errorDetail(error),
|
||||
artifact_path: resultPath
|
||||
}));
|
||||
} finally {
|
||||
await writeResult(runStartedAt, records, errors);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close().catch(() => undefined);
|
||||
await writeResult(runStartedAt, records, errors);
|
||||
}
|
||||
|
||||
validateTrialResults({
|
||||
renderer: "aggregation fallback",
|
||||
requestedShapes,
|
||||
records,
|
||||
errors,
|
||||
resultPath,
|
||||
requireSchema: false
|
||||
});
|
||||
console.log(`Wrote ${records.length} aggregation fallback trial records to ${resultPath}`);
|
||||
}
|
||||
|
||||
async function writeResult(runStartedAt: string, records: PerformanceRecord[], errors: string[]): Promise<void> {
|
||||
await fs.writeFile(resultPath, `${JSON.stringify({
|
||||
run_started_at: runStartedAt,
|
||||
run_finished_at: new Date().toISOString(),
|
||||
renderer: "aggregation-fallback-current-stack-trial",
|
||||
candidate: {
|
||||
new_dependency_added: false,
|
||||
production_path_switched: false
|
||||
},
|
||||
artifact_dir: artifactDir,
|
||||
shapes: requestedShapes,
|
||||
records,
|
||||
errors
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function writeTrialHtml(shape: string, model: unknown): Promise<string> {
|
||||
const file = path.join(artifactDir, `${shape}.html`);
|
||||
await fs.writeFile(file, `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Aggregation Fallback Trial ${escapeHtml(shape)}</title>
|
||||
<style>
|
||||
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
|
||||
body { background: #f8fafc; font-family: system-ui, sans-serif; }
|
||||
#stage { position: relative; width: 100%; height: 100%; overflow: hidden; }
|
||||
#world { position: absolute; left: 0; top: 0; width: 100%; height: 100%; transform-origin: 0 0; }
|
||||
svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
|
||||
.node { position: absolute; width: 6px; height: 6px; border-radius: 999px; transform: translate(-50%, -50%); border: 0; }
|
||||
.node[data-selected="true"] { outline: 2px solid #ef4444; }
|
||||
.node[data-search-hit="true"] { outline: 2px solid #f59e0b; }
|
||||
.container { position: absolute; border-radius: 999px; transform: translate(-50%, -50%); border: 1px solid rgba(15, 23, 42, 0.16); background: rgba(255,255,255,0.78); display: grid; place-items: center; font-size: 10px; color: #334155; }
|
||||
.label { position: absolute; transform: translate(6px, -50%); font-size: 10px; color: #334155; white-space: nowrap; pointer-events: none; }
|
||||
#drawer { position: absolute; right: 0; top: 0; width: 320px; height: 100%; background: white; border-left: 1px solid #e5e7eb; padding: 16px; box-sizing: border-box; display: none; }
|
||||
#drawer[data-open="true"] { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="stage"><div id="world"><svg id="edges"></svg><div id="nodes"></div><div id="containers"></div></div></div>
|
||||
<aside id="drawer"></aside>
|
||||
<script>
|
||||
const model = ${JSON.stringify(model)};
|
||||
const nodeById = new Map(model.nodes.map((node) => [node.id, node]));
|
||||
const containerById = new Map(model.containers.map((container) => [container.id, container]));
|
||||
const communityById = new Map(model.communities.map((community) => [community.id, community]));
|
||||
let scale = 1;
|
||||
let tx = 0;
|
||||
let ty = 0;
|
||||
let selectedNodeId = model.nodes.find((node) => node.selected)?.id || null;
|
||||
let selectedContainerId = null;
|
||||
const world = document.getElementById("world");
|
||||
const edgeRoot = document.getElementById("edges");
|
||||
const nodeRoot = document.getElementById("nodes");
|
||||
const containerRoot = document.getElementById("containers");
|
||||
function bounds() {
|
||||
const points = [...model.nodes.map((node) => [node.x, node.y]), ...model.containers.map((container) => [container.x, container.y])];
|
||||
const xs = points.map((point) => point[0]);
|
||||
const ys = points.map((point) => point[1]);
|
||||
return { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
|
||||
}
|
||||
const worldBounds = bounds();
|
||||
function px(point) {
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
const x = ((point.x - worldBounds.minX) / Math.max(1, worldBounds.maxX - worldBounds.minX)) * (width - 80) + 40;
|
||||
const y = ((point.y - worldBounds.minY) / Math.max(1, worldBounds.maxY - worldBounds.minY)) * (height - 80) + 40;
|
||||
return { x, y };
|
||||
}
|
||||
function applyCamera() {
|
||||
world.style.transform = "translate(" + tx + "px, " + ty + "px) scale(" + scale + ")";
|
||||
}
|
||||
function cameraState() {
|
||||
return { x: tx, y: ty, scale };
|
||||
}
|
||||
function drawInitial() {
|
||||
const edgeParts = [];
|
||||
for (const edge of model.edges) {
|
||||
const source = nodeById.get(edge.source);
|
||||
const target = nodeById.get(edge.target);
|
||||
if (!source || !target) continue;
|
||||
const a = px(source);
|
||||
const b = px(target);
|
||||
edgeParts.push('<line data-edge-id="' + edge.id + '" x1="' + a.x + '" y1="' + a.y + '" x2="' + b.x + '" y2="' + b.y + '" stroke="#94a3b8" stroke-opacity="' + edge.opacity + '" stroke-width="' + edge.strokeWidth + '" />');
|
||||
}
|
||||
edgeRoot.innerHTML = edgeParts.join("");
|
||||
const nodeParts = [];
|
||||
const labelParts = [];
|
||||
for (const node of model.nodes) {
|
||||
const point = px(node);
|
||||
nodeParts.push('<button class="node" data-node-id="' + node.id + '" data-selected="' + node.selected + '" data-search-hit="' + node.searchHit + '" style="left:' + point.x + 'px;top:' + point.y + 'px;width:' + (node.radius * 2) + 'px;height:' + (node.radius * 2) + 'px;background:' + node.color + '"></button>');
|
||||
if (node.labelVisible) labelParts.push('<span class="label" data-label-node-id="' + node.id + '" style="left:' + point.x + 'px;top:' + point.y + 'px">' + escapeHtmlClient(node.label) + '</span>');
|
||||
}
|
||||
nodeRoot.innerHTML = nodeParts.join("") + labelParts.join("");
|
||||
const containerParts = [];
|
||||
for (const container of model.containers) {
|
||||
const point = px(container);
|
||||
const size = Math.max(18, container.radius * 2);
|
||||
containerParts.push('<button class="container" data-container-id="' + container.id + '" style="left:' + point.x + 'px;top:' + point.y + 'px;width:' + size + 'px;height:' + size + 'px;border-color:' + container.color + '">' + container.totalCount + '</button>');
|
||||
}
|
||||
containerRoot.innerHTML = containerParts.join("");
|
||||
applyCamera();
|
||||
}
|
||||
function escapeHtmlClient(value) {
|
||||
return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char] || char));
|
||||
}
|
||||
function searchHighlight(query) {
|
||||
const normalized = String(query || "").toLowerCase();
|
||||
let hits = 0;
|
||||
for (const node of model.nodes) {
|
||||
const hit = normalized && node.label.toLowerCase().includes(normalized);
|
||||
if (hit) hits += 1;
|
||||
const element = document.querySelector('[data-node-id="' + node.id + '"]');
|
||||
if (element) element.dataset.searchHit = String(Boolean(hit));
|
||||
}
|
||||
return { hits };
|
||||
}
|
||||
function pointSelect(id) {
|
||||
if (!nodeById.has(id)) return { selectedNodeId: null };
|
||||
if (selectedNodeId) {
|
||||
const previous = document.querySelector('[data-node-id="' + selectedNodeId + '"]');
|
||||
if (previous) previous.dataset.selected = "false";
|
||||
}
|
||||
selectedNodeId = id;
|
||||
const element = document.querySelector('[data-node-id="' + id + '"]');
|
||||
if (element) element.dataset.selected = "true";
|
||||
return { selectedNodeId: id };
|
||||
}
|
||||
function containerSelect(id) {
|
||||
const container = containerById.get(id);
|
||||
const community = communityById.get(id);
|
||||
const nodeIds = container?.nodeIds || community?.nodeIds || [];
|
||||
selectedContainerId = id;
|
||||
return { selectedContainerId: id, nodeCount: nodeIds.length };
|
||||
}
|
||||
function openDrawer() {
|
||||
const drawer = document.getElementById("drawer");
|
||||
drawer.dataset.open = "true";
|
||||
drawer.textContent = selectedContainerId
|
||||
? "container:" + selectedContainerId
|
||||
: selectedNodeId
|
||||
? "node:" + selectedNodeId
|
||||
: "global";
|
||||
return { open: true, text: drawer.textContent };
|
||||
}
|
||||
function enterCommunity(id) {
|
||||
return containerSelect(id);
|
||||
}
|
||||
function returnGlobal() {
|
||||
scale = 1;
|
||||
tx = 0;
|
||||
ty = 0;
|
||||
selectedContainerId = null;
|
||||
applyCamera();
|
||||
return { camera: cameraState() };
|
||||
}
|
||||
function panBy(dx, dy) {
|
||||
tx += dx;
|
||||
ty += dy;
|
||||
applyCamera();
|
||||
return cameraState();
|
||||
}
|
||||
function zoomBy(factor) {
|
||||
scale *= factor;
|
||||
applyCamera();
|
||||
return cameraState();
|
||||
}
|
||||
drawInitial();
|
||||
window.__aggregationTrial = {
|
||||
ready: true,
|
||||
model,
|
||||
cameraState,
|
||||
searchHighlight,
|
||||
pointSelect,
|
||||
containerSelect,
|
||||
openDrawer,
|
||||
enterCommunity,
|
||||
returnGlobal,
|
||||
panBy,
|
||||
zoomBy,
|
||||
firstNodeId: model.nodes[0]?.id || null,
|
||||
firstCommunityId: model.communities[0]?.id || null,
|
||||
firstContainerId: model.containers[0]?.id || model.communities[0]?.id || null,
|
||||
counts() {
|
||||
return {
|
||||
nodes: model.nodes.length,
|
||||
edges: model.edges.length,
|
||||
communities: model.communities.length,
|
||||
containers: model.containers.length,
|
||||
labels: document.querySelectorAll(".label").length,
|
||||
cards: model.budget.visibleCards,
|
||||
selectedNodeId,
|
||||
selectedContainerId,
|
||||
camera: cameraState(),
|
||||
budget: model.budget
|
||||
};
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
return file;
|
||||
}
|
||||
|
||||
async function measureShape(browser: BrowserLike, metadata: LargeGraphFixtureMetadata, html: string): Promise<PerformanceRecord[]> {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
|
||||
page.setDefaultTimeout(timeoutFor(metadata));
|
||||
page.setDefaultNavigationTimeout(45_000);
|
||||
const records: PerformanceRecord[] = [];
|
||||
try {
|
||||
const renderStarted = performance.now();
|
||||
await page.goto(pathToFileURL(html).href, { waitUntil: "domcontentloaded", timeout: navigationTimeoutFor(metadata) });
|
||||
await page.waitForFunction(() => Boolean((window as any).__aggregationTrial?.ready));
|
||||
records.push(await recordFromPage(page, metadata, {
|
||||
action: "initial_render",
|
||||
duration_ms: performance.now() - renderStarted,
|
||||
pass: true,
|
||||
artifact_path: resultPath
|
||||
}));
|
||||
for (const action of [
|
||||
() => measureWheelZoom(page, metadata),
|
||||
() => measurePan(page, metadata),
|
||||
() => measureSearch(page, metadata),
|
||||
() => measurePointSelect(page, metadata),
|
||||
() => measureContainerSelect(page, metadata),
|
||||
() => measureDrawerOpen(page, metadata),
|
||||
() => measureEnterCommunity(page, metadata),
|
||||
() => measureReturnGlobal(page, metadata)
|
||||
]) {
|
||||
records.push(await safeMeasure(page, metadata, action));
|
||||
}
|
||||
records.push(await safeMeasure(page, metadata, () => measureRepeatedCycles(page, metadata)));
|
||||
} finally {
|
||||
await page.close().catch(() => undefined);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
async function safeMeasure(page: PageLike, metadata: LargeGraphFixtureMetadata, action: () => Promise<PerformanceRecord>): Promise<PerformanceRecord> {
|
||||
try {
|
||||
return await action();
|
||||
} catch (error) {
|
||||
return failedRecord(metadata, {
|
||||
action: inferActionName(error),
|
||||
failure_class: classifyError(error),
|
||||
failure_detail: errorDetail(error),
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function measureWheelZoom(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const before = await cameraState(page);
|
||||
const samplePromise = sampleAnimationFrames(page, 1000);
|
||||
const started = performance.now();
|
||||
while (performance.now() - started < 1000) {
|
||||
await page.evaluate(() => (window as any).__aggregationTrial.zoomBy(1.04));
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
await waitForAnimationFrames(page);
|
||||
const sample = await samplePromise;
|
||||
const after = await cameraState(page);
|
||||
const changed = JSON.stringify(after) !== JSON.stringify(before);
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "wheel_zoom",
|
||||
duration_ms: sample.durationMs,
|
||||
fps: sample.fps,
|
||||
frame_p95_ms: sample.p95,
|
||||
pass: changed && sample.fps >= 10,
|
||||
failure_class: changed ? (sample.fps < 10 ? "fps_below_floor" : null) : "camera_unchanged",
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measurePan(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const before = await cameraState(page);
|
||||
const started = performance.now();
|
||||
await page.evaluate(() => (window as any).__aggregationTrial.panBy(180, 140));
|
||||
await waitForAnimationFrames(page);
|
||||
const after = await cameraState(page);
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "pan",
|
||||
duration_ms: performance.now() - started,
|
||||
pass: JSON.stringify(after) !== JSON.stringify(before),
|
||||
failure_class: JSON.stringify(after) === JSON.stringify(before) ? "camera_unchanged" : null,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measureSearch(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const started = performance.now();
|
||||
const result = await page.evaluate(() => (window as any).__aggregationTrial.searchHighlight("needle"));
|
||||
await waitForAnimationFrames(page);
|
||||
const hits = (result as { hits: number }).hits;
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "search_highlight",
|
||||
duration_ms: performance.now() - started,
|
||||
pass: hits === metadata.search_hits,
|
||||
failure_class: hits === metadata.search_hits ? null : "search_hit_mismatch",
|
||||
failure_detail: hits === metadata.search_hits ? null : `expected=${metadata.search_hits}; actual=${hits}`,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measurePointSelect(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const started = performance.now();
|
||||
const result = await page.evaluate(() => {
|
||||
const trial = (window as any).__aggregationTrial;
|
||||
return trial.pointSelect(trial.firstNodeId);
|
||||
});
|
||||
await waitForAnimationFrames(page);
|
||||
const counts = await page.evaluate(() => (window as any).__aggregationTrial.counts());
|
||||
const expected = (result as { selectedNodeId: string | null }).selectedNodeId;
|
||||
const actual = (counts as { selectedNodeId: string | null }).selectedNodeId;
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "point_select",
|
||||
duration_ms: performance.now() - started,
|
||||
pass: Boolean(expected) && actual === expected,
|
||||
failure_class: Boolean(expected) && actual === expected ? null : "selected_node_mismatch",
|
||||
failure_detail: Boolean(expected) && actual === expected ? null : `expected=${expected ?? "null"}; actual=${actual ?? "null"}`,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measureContainerSelect(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const started = performance.now();
|
||||
const result = await page.evaluate(() => {
|
||||
const trial = (window as any).__aggregationTrial;
|
||||
return trial.containerSelect(trial.firstContainerId);
|
||||
});
|
||||
await waitForAnimationFrames(page);
|
||||
const counts = await page.evaluate(() => (window as any).__aggregationTrial.counts());
|
||||
const expected = (result as { selectedContainerId: string | null }).selectedContainerId;
|
||||
const actual = (counts as { selectedContainerId: string | null }).selectedContainerId;
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "container_select",
|
||||
duration_ms: performance.now() - started,
|
||||
pass: Boolean(expected) && actual === expected,
|
||||
failure_class: Boolean(expected) && actual === expected ? null : "selected_container_mismatch",
|
||||
failure_detail: Boolean(expected) && actual === expected ? null : `expected=${expected ?? "null"}; actual=${actual ?? "null"}`,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measureDrawerOpen(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const started = performance.now();
|
||||
const result = await page.evaluate(() => (window as any).__aggregationTrial.openDrawer());
|
||||
await waitForAnimationFrames(page);
|
||||
const opened = Boolean((result as { open: boolean; text?: string }).open);
|
||||
const text = (result as { open: boolean; text?: string }).text || "";
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "drawer_open",
|
||||
duration_ms: performance.now() - started,
|
||||
pass: opened && /^(container|node|global)/.test(text),
|
||||
failure_class: opened && /^(container|node|global)/.test(text) ? null : "drawer_not_opened",
|
||||
failure_detail: opened && /^(container|node|global)/.test(text) ? null : `text=${text || "empty"}`,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measureEnterCommunity(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const started = performance.now();
|
||||
const result = await page.evaluate(() => {
|
||||
const trial = (window as any).__aggregationTrial;
|
||||
return trial.enterCommunity(trial.firstCommunityId);
|
||||
});
|
||||
await waitForAnimationFrames(page);
|
||||
const counts = await page.evaluate(() => (window as any).__aggregationTrial.counts());
|
||||
const expected = (result as { selectedContainerId: string | null }).selectedContainerId;
|
||||
const actual = (counts as { selectedContainerId: string | null }).selectedContainerId;
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "enter_community",
|
||||
duration_ms: performance.now() - started,
|
||||
pass: Boolean(expected) && actual === expected,
|
||||
failure_class: Boolean(expected) && actual === expected ? null : "community_selection_mismatch",
|
||||
failure_detail: Boolean(expected) && actual === expected ? null : `expected=${expected ?? "null"}; actual=${actual ?? "null"}`,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measureReturnGlobal(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const started = performance.now();
|
||||
await page.evaluate(() => (window as any).__aggregationTrial.returnGlobal());
|
||||
await waitForAnimationFrames(page);
|
||||
const counts = await page.evaluate(() => (window as any).__aggregationTrial.counts());
|
||||
const selectedContainerId = (counts as { selectedContainerId: string | null }).selectedContainerId;
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "return_global",
|
||||
duration_ms: performance.now() - started,
|
||||
pass: selectedContainerId == null,
|
||||
failure_class: selectedContainerId == null ? null : "global_return_incomplete",
|
||||
failure_detail: selectedContainerId == null ? null : `selectedContainerId=${selectedContainerId}`,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measureRepeatedCycles(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const before = await memoryMb(page);
|
||||
const started = performance.now();
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
await page.evaluate(() => {
|
||||
const trial = (window as any).__aggregationTrial;
|
||||
trial.searchHighlight("needle");
|
||||
trial.pointSelect(trial.firstNodeId);
|
||||
trial.containerSelect(trial.firstContainerId);
|
||||
trial.openDrawer();
|
||||
trial.returnGlobal();
|
||||
});
|
||||
await waitForAnimationFrames(page, 2);
|
||||
}
|
||||
const after = await memoryMb(page);
|
||||
const memoryGrowth = before == null || after == null ? null : round(after - before);
|
||||
const failureClass = memoryGrowthFailureClass(memoryGrowth, metadata);
|
||||
const record = await recordFromPage(page, metadata, {
|
||||
action: "repeated_search_community_drawer_cycles",
|
||||
duration_ms: performance.now() - started,
|
||||
pass: failureClass == null,
|
||||
failure_class: failureClass,
|
||||
failure_detail: memoryGrowthFailureDetail(memoryGrowth, metadata),
|
||||
artifact_path: resultPath
|
||||
});
|
||||
record.memory_after_cycles_mb = after;
|
||||
record.memory_growth_mb = memoryGrowth;
|
||||
return record;
|
||||
}
|
||||
|
||||
async function recordFromPage(
|
||||
page: PageLike,
|
||||
metadata: LargeGraphFixtureMetadata,
|
||||
input: Partial<PerformanceRecord> & { action: string; artifact_path: string }
|
||||
): Promise<PerformanceRecord> {
|
||||
const counts = await page.evaluate(() => {
|
||||
const trial = (window as any).__aggregationTrial;
|
||||
const trialCounts = trial?.counts?.() ?? {};
|
||||
const budget = trialCounts.budget ?? {};
|
||||
return {
|
||||
dom_node_count: document.querySelectorAll("*").length,
|
||||
visible_node_count: trialCounts.nodes ?? null,
|
||||
visible_edge_count: trialCounts.edges ?? null,
|
||||
visible_label_count: trialCounts.labels ?? null,
|
||||
visible_card_count: trialCounts.cards ?? null,
|
||||
memory_peak_mb: typeof performance !== "undefined" && "memory" in performance
|
||||
? Math.round((((performance as any).memory?.usedJSHeapSize || 0) / 1024 / 1024) * 10) / 10
|
||||
: null,
|
||||
long_task_count: performance.getEntriesByType ? performance.getEntriesByType("longtask").length : null,
|
||||
interaction_mode: "aggregation-fallback",
|
||||
interaction_updated_objects: budget.interactionUpdatedObjects ?? null,
|
||||
interaction_hidden_objects: budget.interactionHiddenObjects ?? null,
|
||||
interaction_preserved_nodes: Array.isArray(budget.preservedNodeIds) ? budget.preservedNodeIds.length : null,
|
||||
interaction_max_updates: budget.maxInteractionUpdates ?? null
|
||||
};
|
||||
});
|
||||
return {
|
||||
...baseRecord(metadata, input.action, input.artifact_path),
|
||||
...counts,
|
||||
duration_ms: round(input.duration_ms ?? 0),
|
||||
fps: input.fps == null ? null : round(input.fps),
|
||||
frame_p95_ms: input.frame_p95_ms == null ? null : round(input.frame_p95_ms),
|
||||
pass: input.pass ?? true,
|
||||
failure_class: input.failure_class ?? null,
|
||||
failure_detail: input.failure_detail ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function failedRecord(
|
||||
metadata: LargeGraphFixtureMetadata,
|
||||
input: { action: string; failure_class: string; failure_detail?: string; artifact_path: string }
|
||||
): PerformanceRecord {
|
||||
return {
|
||||
...baseRecord(metadata, input.action, input.artifact_path),
|
||||
failure_class: input.failure_class,
|
||||
failure_detail: input.failure_detail ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function baseRecord(metadata: LargeGraphFixtureMetadata, action: string, artifactPath: string): PerformanceRecord {
|
||||
return {
|
||||
phase: "phase-6",
|
||||
task: "6.3",
|
||||
renderer: "aggregation-fallback-current-stack-trial",
|
||||
graph_shape: metadata.id,
|
||||
nodes: metadata.nodes,
|
||||
edges: metadata.edges,
|
||||
communities: metadata.communities,
|
||||
largest_community: metadata.largest_community,
|
||||
largest_connected_density: metadata.largest_connected_density,
|
||||
search_hits: metadata.search_hits,
|
||||
pin_count: metadata.pin_count,
|
||||
oversized_community: metadata.oversized_community,
|
||||
action,
|
||||
duration_ms: null,
|
||||
fps: null,
|
||||
frame_p95_ms: null,
|
||||
long_task_count: null,
|
||||
dom_node_count: null,
|
||||
visible_node_count: null,
|
||||
visible_edge_count: null,
|
||||
visible_label_count: null,
|
||||
visible_card_count: null,
|
||||
interaction_mode: null,
|
||||
interaction_updated_objects: null,
|
||||
interaction_hidden_objects: null,
|
||||
interaction_preserved_nodes: null,
|
||||
interaction_max_updates: null,
|
||||
memory_peak_mb: null,
|
||||
memory_after_cycles_mb: null,
|
||||
memory_growth_mb: null,
|
||||
pass: false,
|
||||
failure_class: null,
|
||||
failure_detail: null,
|
||||
artifact_path: artifactPath,
|
||||
measured_at: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
async function cameraState(page: PageLike): Promise<unknown> {
|
||||
return page.evaluate(() => (window as any).__aggregationTrial.cameraState());
|
||||
}
|
||||
|
||||
async function memoryMb(page: PageLike): Promise<number | null> {
|
||||
return page.evaluate(() => {
|
||||
if (typeof performance === "undefined" || !("memory" in performance)) return null;
|
||||
const used = (performance as any).memory?.usedJSHeapSize;
|
||||
return typeof used === "number" ? Math.round((used / 1024 / 1024) * 10) / 10 : null;
|
||||
});
|
||||
}
|
||||
|
||||
async function sampleAnimationFrames(page: PageLike, durationMs: number): Promise<{ durationMs: number; fps: number; p95: number }> {
|
||||
return page.evaluate(`(() => new Promise((resolve) => {
|
||||
const durationMs = ${JSON.stringify(durationMs)};
|
||||
const started = performance.now();
|
||||
const deltas = [];
|
||||
let last = started;
|
||||
function tick(now) {
|
||||
deltas.push(now - last);
|
||||
last = now;
|
||||
const elapsed = now - started;
|
||||
if (elapsed >= durationMs) {
|
||||
const sorted = [...deltas].sort((a, b) => a - b);
|
||||
const p95 = sorted[Math.max(0, Math.floor(sorted.length * 0.95) - 1)] || 0;
|
||||
resolve({ durationMs: elapsed, fps: deltas.length / (elapsed / 1000), p95 });
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}))()`) as Promise<{ durationMs: number; fps: number; p95: number }>;
|
||||
}
|
||||
|
||||
function classifyError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (/Timeout/i.test(message)) return "timeout";
|
||||
if (/Target page|browser has been closed/i.test(message)) return "browser_closed";
|
||||
if (/JavaScript heap|out of memory/i.test(message)) return "memory";
|
||||
return "exception";
|
||||
}
|
||||
|
||||
function errorDetail(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.replace(/\s+/g, " ").slice(0, 500);
|
||||
}
|
||||
|
||||
function inferActionName(error: unknown): string {
|
||||
const stack = error instanceof Error ? error.stack || error.message : String(error);
|
||||
const match = stack.match(/measure[A-Z][A-Za-z0-9_]*/);
|
||||
if (!match) return "unknown_action";
|
||||
return match[0].replace(/^measure/, "").replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
|
||||
}
|
||||
|
||||
function timeoutFor(metadata: LargeGraphFixtureMetadata): number {
|
||||
if (metadata.nodes >= 10000) return 25_000;
|
||||
if (metadata.nodes >= 5000) return 18_000;
|
||||
return 12_000;
|
||||
}
|
||||
|
||||
function navigationTimeoutFor(metadata: LargeGraphFixtureMetadata): number {
|
||||
if (metadata.nodes >= 10000) return 45_000;
|
||||
if (metadata.nodes >= 5000) return 30_000;
|
||||
return 20_000;
|
||||
}
|
||||
|
||||
function round(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (char) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
}[char] ?? char));
|
||||
}
|
||||
|
||||
interface BrowserLike {
|
||||
newPage(options: { viewport: { width: number; height: number } }): Promise<PageLike>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface PageLike {
|
||||
setDefaultTimeout(timeout: number): void;
|
||||
setDefaultNavigationTimeout(timeout: number): void;
|
||||
goto(url: string, options?: unknown): Promise<unknown>;
|
||||
waitForFunction(fn: Function | string, arg?: unknown, options?: unknown): Promise<unknown>;
|
||||
waitForTimeout(timeout: number): Promise<void>;
|
||||
evaluate<T>(fn: Function | string, arg?: unknown): Promise<T>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface PerformanceRecord {
|
||||
phase: string;
|
||||
task: string;
|
||||
renderer: string;
|
||||
graph_shape: string;
|
||||
nodes: number;
|
||||
edges: number;
|
||||
communities: number;
|
||||
largest_community: number;
|
||||
largest_connected_density: number;
|
||||
search_hits: number;
|
||||
pin_count: number;
|
||||
oversized_community: boolean;
|
||||
action: string;
|
||||
duration_ms: number | null;
|
||||
fps: number | null;
|
||||
frame_p95_ms: number | null;
|
||||
long_task_count: number | null;
|
||||
dom_node_count: number | null;
|
||||
visible_node_count: number | null;
|
||||
visible_edge_count: number | null;
|
||||
visible_label_count: number | null;
|
||||
visible_card_count: number | null;
|
||||
interaction_mode: string | null;
|
||||
interaction_updated_objects: number | null;
|
||||
interaction_hidden_objects: number | null;
|
||||
interaction_preserved_nodes: number | null;
|
||||
interaction_max_updates: number | null;
|
||||
memory_peak_mb: number | null;
|
||||
memory_after_cycles_mb: number | null;
|
||||
memory_growth_mb: number | null;
|
||||
pass: boolean;
|
||||
failure_class: string | null;
|
||||
failure_detail?: string | null;
|
||||
artifact_path: string;
|
||||
measured_at: string;
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const html = process.env.GRAPH_COMMUNITY_NODE_MAP_HTML || "";
|
||||
assert.notEqual(html, "", "GRAPH_COMMUNITY_NODE_MAP_HTML must point at generated HTML");
|
||||
const screenshotPath = process.env.GRAPH_COMMUNITY_NODE_MAP_SCREENSHOT || "";
|
||||
|
||||
const executablePath = process.env.GRAPH_COMMUNITY_NODE_MAP_CHROME_EXECUTABLE || "";
|
||||
const browser = await chromium.launch(executablePath ? { executablePath } : {});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
|
||||
await page.goto(pathToFileURL(html).href);
|
||||
await page.waitForSelector('[data-testid="offline-graph-root"]');
|
||||
await page.evaluate(() => {
|
||||
const engine = window.__LLM_WIKI_GRAPH_ENGINE__;
|
||||
if (!engine?.focusCommunity) throw new Error("offline graph engine is missing focusCommunity");
|
||||
engine.focusCommunity("t1");
|
||||
});
|
||||
await page.waitForSelector('[data-community-map-state="lightweight"]');
|
||||
await page.waitForSelector('.node[data-id="A"]');
|
||||
|
||||
const initial = await snapshot(page);
|
||||
assert.equal(initial.communityMapState, "lightweight");
|
||||
assert.equal(initial.relationFocus, "idle");
|
||||
assert.equal(initial.fullCardCount, 0, "community map should not render full card nodes");
|
||||
assert.ok(initial.visibleLabelCount > 0 && initial.visibleLabelCount <= 12, `default community map should expose only budgeted key labels, got ${initial.visibleLabelCount}`);
|
||||
assert.ok(initial.visibleLabelCount < initial.nodeCount, `default community map should not label every node, got ${initial.visibleLabelCount}/${initial.nodeCount}`);
|
||||
assert.ok(initial.defaultEdgeOpacity > 0 && initial.defaultEdgeOpacity < 0.35, `default community edges should stay quiet, got ${initial.defaultEdgeOpacity}`);
|
||||
assert.equal(initial.rootPaperToken.toLowerCase(), "#f8f1e6", "community map should expose the approved warm paper token");
|
||||
assert.match(initial.rootBackgroundSize, /42px 42px/, "community map should render the paper-grid background size");
|
||||
assert.match(initial.rootBackground, /linear-gradient|radial-gradient/, "community map should keep a visible paper-style background treatment");
|
||||
assert.ok(initial.sampleNodeStyles.dot, "community map nodes should render a dot-core element");
|
||||
assert.ok(initial.sampleNodeStyles.label, "community map labels should still be measurable when visible");
|
||||
assert.match(initial.sampleNodeStyles.label.fontFamily, /(Songti|Noto Serif|STSong|serif)/i, "community map labels should use the paper-map serif stack");
|
||||
assert.equal(initial.nodeTypes.F, "comparison", "community map should preserve comparison nodes instead of collapsing them to entity");
|
||||
assert.notEqual(initial.nodeDotColors.topic, initial.nodeDotColors.entity, "topic and entity dots should use different colors");
|
||||
assert.notEqual(initial.nodeDotColors.source, initial.nodeDotColors.entity, "source and entity dots should use different colors");
|
||||
assert.notEqual(initial.nodeDotColors.comparison, initial.nodeDotColors.entity, "comparison and entity dots should use different colors");
|
||||
assert.equal(initial.edgeDashes.extracted, "none", "extracted confidence should render as a solid edge");
|
||||
assert.notEqual(initial.edgeDashes.inferred, "none", "inferred confidence should render as a dashed edge");
|
||||
assert.notEqual(initial.edgeDashes.ambiguous, "none", "ambiguous confidence should render as a dashed edge");
|
||||
assert.notEqual(initial.edgeDashes.inferred, initial.edgeDashes.ambiguous, "inferred and ambiguous confidence should use distinct dash patterns");
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__LLM_WIKI_GRAPH_ENGINE__?.setTypeFilters?.({ entity: false });
|
||||
});
|
||||
await page.locator('.node[data-id="A"]').hover();
|
||||
await page.waitForFunction(() => document.querySelector('.edge[data-edge-id="eAB"]')?.getAttribute("data-filter-state") === "hidden");
|
||||
const hiddenFilter = await snapshot(page);
|
||||
const hiddenOpacity = Number.parseFloat(hiddenFilter.sampleNodeStyles.hiddenEdge?.opacity || "1");
|
||||
assert.ok(hiddenOpacity <= 0.04, `filtered hidden edge should stay visually hidden, got ${hiddenOpacity}`);
|
||||
await page.evaluate(() => {
|
||||
window.__LLM_WIKI_GRAPH_ENGINE__?.setTypeFilters?.({ entity: true, source: true, topic: true });
|
||||
});
|
||||
await page.mouse.move(20, 20);
|
||||
await page.waitForFunction(() => document.querySelector('.edge[data-edge-id="eAB"]')?.getAttribute("data-filter-state") === "visible");
|
||||
|
||||
const beforeHoverCenter = await nodeCenter(page, "B");
|
||||
await page.locator('.node[data-id="A"]').hover();
|
||||
await page.waitForFunction(() => (
|
||||
document.querySelector("[data-llm-wiki-graph-root='true']")?.getAttribute("data-relation-focus") === "active" &&
|
||||
document.querySelector('.node[data-id="B"]')?.getAttribute("data-relation-focus-depth") === "first"
|
||||
));
|
||||
const hover = await snapshot(page);
|
||||
const afterHoverCenter = await nodeCenter(page, "B");
|
||||
assert.equal(hover.relationFocusNode, "A");
|
||||
assert.equal(hover.nodeDepths.A, "focus");
|
||||
assert.equal(hover.nodeDepths.B, "first");
|
||||
assert.equal(hover.nodeDepths.C, "first");
|
||||
assert.equal(hover.nodeDepths.D, "second");
|
||||
assert.equal(hover.nodeDepths.F, "unrelated");
|
||||
assert.equal(hover.edgeDepths.eAB, "first");
|
||||
assert.equal(hover.edgeDepths.eBD, "second");
|
||||
assert.equal(hover.edgeDepths.eEF, "unrelated");
|
||||
assert.ok(hover.firstEdgeOpacity > hover.secondEdgeOpacity, "direct edges should be clearer than second-degree edges");
|
||||
assert.ok(hover.secondEdgeOpacity > hover.unrelatedEdgeOpacity, "second-degree edges should remain clearer than unrelated edges");
|
||||
assert.ok(hover.nodeDotOpacity.first > hover.nodeDotOpacity.second, "first-degree nodes should be clearer than second-degree nodes");
|
||||
assert.ok(hover.nodeDotOpacity.second > hover.nodeDotOpacity.unrelated, "second-degree nodes should be clearer than unrelated nodes");
|
||||
assert.ok(Math.abs(beforeHoverCenter.x - afterHoverCenter.x) < 0.5, "hover should not shift node x");
|
||||
assert.ok(Math.abs(beforeHoverCenter.y - afterHoverCenter.y) < 0.5, "hover should not shift node y");
|
||||
|
||||
assert.ok(hover.labelSideCount === hover.nodeCount, "each community map node should expose a label-side placement");
|
||||
assert.ok(hover.firstDegreeCount >= 10, "fixture should be dense enough to catch first-degree label walls");
|
||||
assert.ok(hover.visibleFirstLabels < hover.firstDegreeCount, "first-degree labels should stay sparse instead of labeling every direct neighbor");
|
||||
assert.ok(hover.visibleFirstLabels <= hover.visibleLabelCount, "first-degree labels should stay within the visible label budget");
|
||||
|
||||
await page.waitForTimeout(360);
|
||||
const hoverAfterPreviewDelay = await snapshot(page);
|
||||
assert.notEqual(hoverAfterPreviewDelay.hoverPreviewState, "open", "focused community node hover should not open the old content preview card");
|
||||
|
||||
await page.locator('.node[data-id="B"]').hover();
|
||||
await page.waitForFunction(() => (
|
||||
document.querySelector("[data-llm-wiki-graph-root='true']")?.getAttribute("data-relation-focus-node") === "B"
|
||||
));
|
||||
await page.waitForTimeout(120);
|
||||
const hoverB = await snapshot(page);
|
||||
assert.equal(hoverB.relationFocusNode, "B", "hover should move directly from A focus to B focus");
|
||||
|
||||
await page.mouse.move(20, 20);
|
||||
await page.waitForFunction(() => (
|
||||
document.querySelector("[data-llm-wiki-graph-root='true']")?.getAttribute("data-relation-focus") === "idle"
|
||||
));
|
||||
const afterLeave = await snapshot(page);
|
||||
assert.equal(afterLeave.relationFocusNode, "");
|
||||
assert.equal(afterLeave.nodeDepths.A, "none");
|
||||
|
||||
await page.locator('.node[data-id="A"]').click();
|
||||
await page.waitForSelector('.graph-reader[data-state="open"]');
|
||||
await page.waitForFunction(() => (
|
||||
document.querySelector("[data-llm-wiki-graph-root='true']")?.getAttribute("data-relation-focus-node") === "A"
|
||||
));
|
||||
const clicked = await snapshot(page);
|
||||
assert.equal(clicked.readerOpen, true);
|
||||
assert.equal(clicked.relationFocusNode, "A");
|
||||
assert.equal(clicked.nodeDepths.B, "first");
|
||||
|
||||
await page.locator('.node[data-id="D"]').hover();
|
||||
await page.waitForFunction(() => (
|
||||
document.querySelector("[data-llm-wiki-graph-root='true']")?.getAttribute("data-relation-focus-node") === "D"
|
||||
));
|
||||
const override = await snapshot(page);
|
||||
assert.equal(override.relationFocusNode, "D");
|
||||
assert.equal(override.nodeDepths.A, "second");
|
||||
|
||||
await page.mouse.move(20, 20);
|
||||
await page.waitForFunction(() => (
|
||||
document.querySelector("[data-llm-wiki-graph-root='true']")?.getAttribute("data-relation-focus-node") === "A"
|
||||
));
|
||||
const restored = await snapshot(page);
|
||||
assert.equal(restored.relationFocusNode, "A");
|
||||
assert.equal(restored.nodeDepths.A, "focus");
|
||||
|
||||
console.log(JSON.stringify({ initial, hover, afterLeave, clicked, override, restored }, null, 2));
|
||||
|
||||
if (screenshotPath) {
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
async function snapshot(page) {
|
||||
return page.evaluate(() => {
|
||||
const root = document.querySelector("[data-llm-wiki-graph-root='true']");
|
||||
const node = (id) => document.querySelector(`.node[data-id="${CSS.escape(id)}"]`);
|
||||
const edge = (id) => document.querySelector(`.edge[data-edge-id="${CSS.escape(id)}"]`);
|
||||
const edgeOpacity = (id) => {
|
||||
const element = edge(id);
|
||||
if (!element) return -1;
|
||||
return Number.parseFloat(getComputedStyle(element).opacity || "0");
|
||||
};
|
||||
const stylesFor = (selector) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) return null;
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
display: style.display,
|
||||
background: style.backgroundColor,
|
||||
boxShadow: style.boxShadow,
|
||||
border: style.border,
|
||||
borderRadius: style.borderRadius,
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
left: style.left,
|
||||
right: style.right,
|
||||
top: style.top,
|
||||
bottom: style.bottom,
|
||||
opacity: style.opacity,
|
||||
strokeDasharray: style.strokeDasharray,
|
||||
transform: style.transform,
|
||||
width: style.width,
|
||||
height: style.height
|
||||
};
|
||||
};
|
||||
return {
|
||||
communityMapState: root?.getAttribute("data-community-map-state") || "",
|
||||
relationFocus: root?.getAttribute("data-relation-focus") || "",
|
||||
relationFocusNode: root?.getAttribute("data-relation-focus-node") || "",
|
||||
nodeCount: document.querySelectorAll(".node").length,
|
||||
fullCardCount: document.querySelectorAll('.node[data-density-mode="card"]').length,
|
||||
visibleLabelCount: Array.from(document.querySelectorAll(".node-name")).filter((element) => getComputedStyle(element).display !== "none").length,
|
||||
defaultEdgeOpacity: edgeOpacity("eAB"),
|
||||
firstEdgeOpacity: edgeOpacity("eAB"),
|
||||
secondEdgeOpacity: edgeOpacity("eBD"),
|
||||
unrelatedEdgeOpacity: edgeOpacity("eEF"),
|
||||
readerOpen: document.querySelector(".graph-reader")?.getAttribute("data-state") === "open",
|
||||
nodeDepths: {
|
||||
A: node("A")?.getAttribute("data-relation-focus-depth") || "",
|
||||
B: node("B")?.getAttribute("data-relation-focus-depth") || "",
|
||||
C: node("C")?.getAttribute("data-relation-focus-depth") || "",
|
||||
D: node("D")?.getAttribute("data-relation-focus-depth") || "",
|
||||
F: node("F")?.getAttribute("data-relation-focus-depth") || ""
|
||||
},
|
||||
edgeDepths: {
|
||||
eAB: edge("eAB")?.getAttribute("data-relation-focus-depth") || "",
|
||||
eBD: edge("eBD")?.getAttribute("data-relation-focus-depth") || "",
|
||||
eEF: edge("eEF")?.getAttribute("data-relation-focus-depth") || ""
|
||||
},
|
||||
nodeTypes: {
|
||||
A: node("A")?.getAttribute("data-type") || "",
|
||||
B: node("B")?.getAttribute("data-type") || "",
|
||||
C: node("C")?.getAttribute("data-type") || "",
|
||||
F: node("F")?.getAttribute("data-type") || ""
|
||||
},
|
||||
nodeDotColors: {
|
||||
topic: stylesFor('.node[data-id="A"] .dot-core')?.background || "",
|
||||
entity: stylesFor('.node[data-id="B"] .dot-core')?.background || "",
|
||||
source: stylesFor('.node[data-id="C"] .dot-core')?.background || "",
|
||||
comparison: stylesFor('.node[data-id="F"] .dot-core')?.background || ""
|
||||
},
|
||||
nodeDotOpacity: {
|
||||
first: Number.parseFloat(stylesFor('.node[data-id="B"] .dot-core')?.opacity || "0"),
|
||||
second: Number.parseFloat(stylesFor('.node[data-id="D"] .dot-core')?.opacity || "0"),
|
||||
unrelated: Number.parseFloat(stylesFor('.node[data-id="F"] .dot-core')?.opacity || "0")
|
||||
},
|
||||
edgeDashes: {
|
||||
extracted: stylesFor('.edge[data-edge-id="eAB"]')?.strokeDasharray || "",
|
||||
inferred: stylesFor('.edge[data-edge-id="eAC"]')?.strokeDasharray || "",
|
||||
ambiguous: stylesFor('.edge[data-edge-id="eEF"]')?.strokeDasharray || ""
|
||||
},
|
||||
rootBackground: root ? getComputedStyle(root).backgroundImage : "",
|
||||
rootBackgroundSize: root ? getComputedStyle(root).backgroundSize : "",
|
||||
rootPaperToken: root ? getComputedStyle(root).getPropertyValue("--community-map-paper").trim() : "",
|
||||
sampleNodeStyles: {
|
||||
node: stylesFor('.node[data-id="A"]'),
|
||||
dot: stylesFor('.node[data-id="A"] .dot-core'),
|
||||
label: stylesFor('.node[data-id="A"] .node-name'),
|
||||
hiddenEdge: stylesFor('.edge[data-edge-id="eAB"][data-filter-state="hidden"]'),
|
||||
firstEdge: stylesFor('.edge[data-edge-id="eAB"]')
|
||||
},
|
||||
hoverPreviewState: document.querySelector(".graph-hover-preview")?.getAttribute("data-state") || "",
|
||||
hoverPreviewKind: document.querySelector(".graph-hover-preview")?.getAttribute("data-kind") || "",
|
||||
labelSideCount: Array.from(document.querySelectorAll(".node[data-label-side]")).length,
|
||||
firstDegreeCount: Array.from(document.querySelectorAll('.node[data-relation-focus-depth="first"]')).length,
|
||||
visibleFirstLabels: Array.from(document.querySelectorAll('.node[data-relation-focus-depth="first"] .node-name'))
|
||||
.filter((element) => getComputedStyle(element).display !== "none").length
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function nodeCenter(page, id) {
|
||||
return page.locator(`.node[data-id="${cssString(id)}"]`).evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||
});
|
||||
}
|
||||
|
||||
function cssString(value) {
|
||||
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const html = process.env.GRAPH_COMMUNITY_WASH_HTML || "";
|
||||
assert.notEqual(html, "", "GRAPH_COMMUNITY_WASH_HTML must point at generated HTML");
|
||||
|
||||
const executablePath = process.env.GRAPH_COMMUNITY_WASH_CHROME_EXECUTABLE || "";
|
||||
const browser = await chromium.launch(executablePath ? { executablePath } : {});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
|
||||
await page.goto(pathToFileURL(html).href);
|
||||
await waitForSigmaGlobal(page);
|
||||
|
||||
const initial = await sigmaSnapshot(page);
|
||||
assert.equal(initial.route, "sigma-global", "offline global route should use Sigma");
|
||||
assert.equal(initial.oldDomNodeCount, 0, "Sigma global should not render the old DOM full graph");
|
||||
assert.ok(initial.communityRegionCount >= 1, "Sigma global should expose community regions");
|
||||
assert.ok(initial.communityLabelCount >= 1 && initial.communityLabelCount <= 8, "Sigma global should expose passive map labels");
|
||||
assert.equal(initial.circularCommunityButtonCount, 0, "Sigma global should not expose circular community buttons");
|
||||
assert.equal(initial.aggregationButtonCount, 0, "Sigma global should not expose aggregation buttons");
|
||||
|
||||
const communityId = await firstCommunityRegionId(page);
|
||||
const regionPoint = await findCommunityRegionPoint(page, communityId);
|
||||
await page.mouse.click(regionPoint.x, regionPoint.y);
|
||||
await waitForCommunitySelection(page, communityId);
|
||||
const selected = await sigmaSnapshot(page);
|
||||
assert.equal(selected.focus, "", "clicking a community region should not enter a DOM community route");
|
||||
assert.equal(selected.selectedCommunityId, communityId, "clicking a community region should select that community");
|
||||
assert.equal(selected.oldDomNodeCount, 0, "community selection should keep the Sigma global route mounted");
|
||||
|
||||
await setSearchQuery(page, "节点A");
|
||||
await page.waitForSelector('.sigma-global-node-hit-target[data-node-id="A"]');
|
||||
await page.locator('.sigma-global-node-hit-target[data-node-id="A"]').click({ force: true });
|
||||
await page.waitForFunction(() => (
|
||||
document.querySelector('.sigma-global-node-hit-target[data-node-id="A"]')?.getAttribute("data-selected") === "true"
|
||||
));
|
||||
const nodeSelected = await sigmaSnapshot(page);
|
||||
assert.equal(nodeSelected.selectedNodeId, "A", "node hit target should win over the community region beneath it");
|
||||
assert.equal(nodeSelected.route, "sigma-global", "node hit target should keep the Sigma global route active");
|
||||
|
||||
const labelPoint = await communityLabelCenter(page, communityId);
|
||||
await page.mouse.click(labelPoint.x, labelPoint.y);
|
||||
await waitForCommunitySelection(page, communityId);
|
||||
const labelClick = await sigmaSnapshot(page);
|
||||
assert.equal(labelClick.selectedCommunityId, communityId, "passive label area should fall through to the community region");
|
||||
assert.equal(labelClick.labelPointerEvents, "none", "community labels should stay passive");
|
||||
|
||||
console.log(JSON.stringify({
|
||||
communityId,
|
||||
initial,
|
||||
regionPoint,
|
||||
selected,
|
||||
nodeSelected,
|
||||
labelClick
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
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-community-region[data-community-id]");
|
||||
}
|
||||
|
||||
async function sigmaSnapshot(page) {
|
||||
return page.evaluate(() => {
|
||||
const selectedRegion = document.querySelector('.sigma-global-community-region[data-selected="true"]');
|
||||
const selectedNode = document.querySelector('.sigma-global-node-hit-target[data-selected="true"]');
|
||||
const firstLabel = document.querySelector(".sigma-global-community-label");
|
||||
return {
|
||||
route: document.querySelector(".sigma-global-route")?.getAttribute("data-route") || "",
|
||||
renderer: document.querySelector(".sigma-global-renderer")?.getAttribute("data-renderer") || "",
|
||||
focus: document.querySelector("[data-testid='offline-graph-root']")?.getAttribute("data-llm-wiki-graph-focus")
|
||||
|| document.querySelector("[data-llm-wiki-graph-root='true']")?.getAttribute("data-focus")
|
||||
|| "",
|
||||
oldDomNodeCount: document.querySelectorAll(".node").length,
|
||||
oldCommunityWashCount: document.querySelectorAll(".community-wash").length,
|
||||
communityRegionCount: document.querySelectorAll(".sigma-global-community-region").length,
|
||||
communityLabelCount: document.querySelectorAll(".sigma-global-community-label").length,
|
||||
circularCommunityButtonCount: 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,
|
||||
selectedCommunityId: selectedRegion?.getAttribute("data-community-id") || "",
|
||||
selectedNodeId: selectedNode?.getAttribute("data-node-id") || "",
|
||||
labelPointerEvents: firstLabel ? window.getComputedStyle(firstLabel).pointerEvents : ""
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function firstCommunityRegionId(page) {
|
||||
return page.evaluate(() => {
|
||||
const region = document.querySelector(".sigma-global-community-region[data-community-id]:not([data-community-id='_none'])")
|
||||
|| document.querySelector(".sigma-global-community-region[data-community-id]");
|
||||
const id = region?.getAttribute("data-community-id") || "";
|
||||
if (!id) throw new Error("Missing Sigma community region id");
|
||||
return id;
|
||||
});
|
||||
}
|
||||
|
||||
async function findCommunityRegionPoint(page, communityId) {
|
||||
return page.evaluate((communityId) => {
|
||||
const region = document.querySelector(`.sigma-global-community-region[data-community-id="${CSS.escape(communityId)}"]`);
|
||||
if (!region) throw new Error(`Missing Sigma community region ${communityId}`);
|
||||
const rect = region.getBoundingClientRect();
|
||||
const candidates = [
|
||||
[0.5, 0.18],
|
||||
[0.2, 0.5],
|
||||
[0.8, 0.5],
|
||||
[0.5, 0.82],
|
||||
[0.32, 0.32],
|
||||
[0.68, 0.68],
|
||||
[0.5, 0.5]
|
||||
];
|
||||
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);
|
||||
if (hit?.closest?.(".sigma-global-node-hit-target")) continue;
|
||||
if (hit?.closest?.(".sigma-global-community-region") === region) {
|
||||
return { x, y, communityId };
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not find exposed Sigma community region point: ${JSON.stringify({
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
})}`);
|
||||
}, communityId);
|
||||
}
|
||||
|
||||
async function communityLabelCenter(page, communityId) {
|
||||
return page.evaluate((communityId) => {
|
||||
const label = document.querySelector(`.sigma-global-community-label[data-community-id="${CSS.escape(communityId)}"]`);
|
||||
if (!label) throw new Error(`Missing Sigma community label ${communityId}`);
|
||||
const rect = label.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2
|
||||
};
|
||||
}, communityId);
|
||||
}
|
||||
|
||||
async function waitForCommunitySelection(page, communityId) {
|
||||
await page.waitForFunction((communityId) => (
|
||||
document.querySelector(`.sigma-global-community-region[data-community-id="${CSS.escape(communityId)}"]`)?.getAttribute("data-selected") === "true"
|
||||
), communityId);
|
||||
}
|
||||
|
||||
async function setSearchQuery(page, query) {
|
||||
await page.locator(".graph-search-input").focus();
|
||||
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: "insertText",
|
||||
data: query
|
||||
}));
|
||||
}, query);
|
||||
await page.waitForFunction((query) => (
|
||||
document.querySelector(".graph-search-input")?.value === query
|
||||
), query);
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const html = process.env.GRAPH_DEFAULT_BEHAVIOR_HTML || "";
|
||||
assert.notEqual(html, "", "GRAPH_DEFAULT_BEHAVIOR_HTML must point at generated HTML");
|
||||
|
||||
const executablePath = process.env.GRAPH_DEFAULT_BEHAVIOR_CHROME_EXECUTABLE || "";
|
||||
const browser = await chromium.launch(executablePath ? { executablePath } : {});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
|
||||
await page.goto(pathToFileURL(html).href);
|
||||
await page.waitForSelector("[data-llm-wiki-graph-root='true']");
|
||||
await page.waitForSelector("[data-viewport-layer='true']");
|
||||
await page.waitForSelector(".node");
|
||||
|
||||
await page.locator(".node").first().click();
|
||||
await page.waitForSelector(".graph-reader-body");
|
||||
const computedPolicy = await graphDefaultPolicy(page);
|
||||
await page.locator(".graph-reader-close").click();
|
||||
await page.waitForSelector('.graph-reader[data-state="closed"]');
|
||||
|
||||
assert.equal(computedPolicy.root.userSelect, "none", "graph root should prevent browser text selection");
|
||||
assert.equal(computedPolicy.root.touchAction, "none", "graph root should own touch and pinch gestures");
|
||||
assert.equal(computedPolicy.root.overscrollBehavior, "contain", "graph root should contain scroll chaining");
|
||||
assert.equal(computedPolicy.readerBody.userSelect, "text", "reader body should still allow selecting text");
|
||||
assert.equal(computedPolicy.searchInput.userSelect, "text", "search input should still allow text editing");
|
||||
|
||||
const nodeShortcutWheel = await assertShortcutWheelZoomsGraph(page, await visibleNodeCenter(page), "node", { ctrlKey: true });
|
||||
const blankShortcutWheel = await assertShortcutWheelZoomsGraph(page, await findBlankPoint(page), "blank graph", { metaKey: true });
|
||||
const searchWheel = await assertShortcutWheelDoesNotZoomGraph(page, ".graph-search-input", "search input", { ctrlKey: true });
|
||||
const blankDrag = await assertBlankDragDoesNotSelectText(page);
|
||||
const cancellationBoundaries = await assertCancellationAndBlockerBoundaries(page);
|
||||
const fastReleaseDrag = await assertFastReleasePinsFinalPointerPosition(page);
|
||||
const keyboardOwnership = await assertKeyboardOwnership(page);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
html,
|
||||
computedPolicy,
|
||||
shortcutWheel: {
|
||||
node: nodeShortcutWheel,
|
||||
blank: blankShortcutWheel,
|
||||
searchInput: searchWheel
|
||||
},
|
||||
blankDrag,
|
||||
cancellationBoundaries,
|
||||
fastReleaseDrag,
|
||||
keyboardOwnership
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
async function assertShortcutWheelZoomsGraph(page, point, label, options) {
|
||||
await resetSelection(page);
|
||||
const beforeMetrics = await pageMetrics(page);
|
||||
const beforeTransform = await layerTransform(page);
|
||||
const eventResult = await dispatchWheelAt(page, point, {
|
||||
deltaY: -420,
|
||||
ctrlKey: options.ctrlKey === true,
|
||||
metaKey: options.metaKey === true
|
||||
});
|
||||
assert.equal(eventResult.cancelled, true, `shortcut wheel over ${label} should cancel browser default`);
|
||||
const afterTransform = await waitForLayerTransform(page, beforeTransform);
|
||||
const afterMetrics = await pageMetrics(page);
|
||||
assert.deepEqual(afterMetrics, beforeMetrics, `shortcut wheel over ${label} should not zoom the page`);
|
||||
const realWheel = await assertModifiedMouseWheelZoomsGraph(page, point, label, options);
|
||||
return {
|
||||
point: roundedPoint(point),
|
||||
beforeTransform,
|
||||
afterTransform,
|
||||
beforeMetrics,
|
||||
afterMetrics,
|
||||
eventResult,
|
||||
realWheel
|
||||
};
|
||||
}
|
||||
|
||||
async function assertModifiedMouseWheelZoomsGraph(page, point, label, options) {
|
||||
const beforeMetrics = await pageMetrics(page);
|
||||
const beforeTransform = await layerTransform(page);
|
||||
const modifier = options.metaKey === true ? "Meta" : "Control";
|
||||
const client = await page.context().newCDPSession(page);
|
||||
try {
|
||||
await client.send("Input.dispatchMouseEvent", {
|
||||
type: "mouseWheel",
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
deltaX: 0,
|
||||
deltaY: -420,
|
||||
modifiers: options.metaKey === true ? 4 : 2
|
||||
});
|
||||
} finally {
|
||||
await client.detach().catch(() => {});
|
||||
}
|
||||
const afterTransform = await waitForLayerTransform(page, beforeTransform);
|
||||
const afterMetrics = await pageMetrics(page);
|
||||
assert.deepEqual(afterMetrics, beforeMetrics, `real modified wheel over ${label} should not zoom the page`);
|
||||
return {
|
||||
modifier,
|
||||
beforeTransform,
|
||||
afterTransform,
|
||||
beforeMetrics,
|
||||
afterMetrics
|
||||
};
|
||||
}
|
||||
|
||||
async function assertShortcutWheelDoesNotZoomGraph(page, selector, label, options) {
|
||||
const beforeMetrics = await pageMetrics(page);
|
||||
const beforeTransform = await layerTransform(page);
|
||||
const eventResult = await dispatchWheelOnSelector(page, selector, {
|
||||
deltaY: -420,
|
||||
ctrlKey: options.ctrlKey === true,
|
||||
metaKey: options.metaKey === true
|
||||
});
|
||||
assert.equal(eventResult.cancelled, false, `shortcut wheel over ${label} should remain a blocker`);
|
||||
await page.waitForTimeout(80);
|
||||
const afterTransform = await layerTransform(page);
|
||||
const afterMetrics = await pageMetrics(page);
|
||||
assert.equal(afterTransform, beforeTransform, `shortcut wheel over ${label} should not zoom graph`);
|
||||
assert.deepEqual(afterMetrics, beforeMetrics, `synthetic shortcut wheel over ${label} should not change page metrics`);
|
||||
return {
|
||||
beforeTransform,
|
||||
afterTransform,
|
||||
beforeMetrics,
|
||||
afterMetrics,
|
||||
eventResult
|
||||
};
|
||||
}
|
||||
|
||||
async function assertBlankDragDoesNotSelectText(page) {
|
||||
await resetSelection(page);
|
||||
const point = await findBlankPoint(page);
|
||||
const toolbar = await page.locator(".graph-toolbar-actions").boundingBox();
|
||||
assert.ok(toolbar, "toolbar actions should exist for blank drag selection regression");
|
||||
|
||||
await page.mouse.move(point.x, point.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(toolbar.x + toolbar.width - 4, toolbar.y + toolbar.height / 2, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(80);
|
||||
|
||||
const state = await page.evaluate(() => {
|
||||
const root = document.querySelector("[data-llm-wiki-graph-root='true']");
|
||||
return {
|
||||
selectionText: window.getSelection()?.toString() || "",
|
||||
viewportDragging: root?.dataset.viewportDragging || "",
|
||||
dragging: root?.dataset.dragging || ""
|
||||
};
|
||||
});
|
||||
assert.equal(state.selectionText, "", "blank graph drag should not select toolbar text");
|
||||
assert.equal(state.viewportDragging, "", "blank graph drag should not leave viewport dragging active");
|
||||
assert.equal(state.dragging, "", "blank graph drag should not leave node dragging active");
|
||||
return { start: roundedPoint(point), state };
|
||||
}
|
||||
|
||||
async function assertCancellationAndBlockerBoundaries(page) {
|
||||
await page.reload();
|
||||
await page.waitForSelector("[data-llm-wiki-graph-root='true']");
|
||||
await page.waitForSelector("[data-viewport-layer='true']");
|
||||
await page.waitForSelector(".node");
|
||||
|
||||
const pointerCancel = await assertPointerCancelClearsNodeDrag(page);
|
||||
const lostPointerCapture = await assertLostPointerCaptureClearsBlankPan(page);
|
||||
const blockers = await assertPointerBlockersDoNotStartGestures(page);
|
||||
|
||||
return {
|
||||
pointerCancel,
|
||||
lostPointerCapture,
|
||||
blockers
|
||||
};
|
||||
}
|
||||
|
||||
async function assertPointerCancelClearsNodeDrag(page) {
|
||||
const before = await page.locator(".node").first().evaluate((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return {
|
||||
id: node.dataset.id || "",
|
||||
pinned: node.dataset.pinned || "",
|
||||
center: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
|
||||
};
|
||||
});
|
||||
assert.notEqual(before.id, "", "pointercancel regression should have a node target");
|
||||
|
||||
const move = { x: before.center.x + 80, y: before.center.y + 8 };
|
||||
await dispatchPointer(page, `.node[data-id="${cssString(before.id)}"]`, "pointerdown", before.center, {
|
||||
pointerId: 88,
|
||||
button: 0,
|
||||
buttons: 1
|
||||
});
|
||||
await dispatchPointer(page, "[data-llm-wiki-graph-root='true']", "pointermove", move, {
|
||||
pointerId: 88,
|
||||
button: 0,
|
||||
buttons: 1
|
||||
});
|
||||
await page.waitForFunction(
|
||||
(id) => {
|
||||
const root = document.querySelector("[data-llm-wiki-graph-root='true']");
|
||||
const node = document.querySelector(`.node[data-id="${CSS.escape(id)}"]`);
|
||||
return root?.dataset.dragging === id && node?.classList.contains("is-dragging");
|
||||
},
|
||||
before.id,
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
|
||||
const cancelEvent = await dispatchPointer(page, "[data-llm-wiki-graph-root='true']", "pointercancel", move, {
|
||||
pointerId: 88,
|
||||
button: 0,
|
||||
buttons: 0
|
||||
});
|
||||
await waitForClearInteraction(page, before.id);
|
||||
|
||||
const after = await graphInteractionState(page, before.id);
|
||||
assert.equal(cancelEvent.cancelled, true, "pointercancel during node drag should prevent browser default");
|
||||
assert.equal(after.dragging, "", "pointercancel should clear root dragging state");
|
||||
assert.equal(after.nodeDragging, false, "pointercancel should clear node dragging class");
|
||||
assert.equal(after.pinned, before.pinned, "pointercancel should not create or remove a node pin");
|
||||
|
||||
return {
|
||||
id: before.id,
|
||||
move: roundedPoint(move),
|
||||
cancelEvent,
|
||||
beforePinned: before.pinned,
|
||||
after
|
||||
};
|
||||
}
|
||||
|
||||
async function assertLostPointerCaptureClearsBlankPan(page) {
|
||||
const start = await findBlankPoint(page);
|
||||
const move = { x: start.x + 96, y: start.y + 12 };
|
||||
|
||||
await dispatchPointer(page, "[data-llm-wiki-graph-root='true']", "pointerdown", start, {
|
||||
pointerId: 89,
|
||||
button: 0,
|
||||
buttons: 1
|
||||
});
|
||||
await dispatchPointer(page, "[data-llm-wiki-graph-root='true']", "pointermove", move, {
|
||||
pointerId: 89,
|
||||
button: 0,
|
||||
buttons: 1
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector("[data-llm-wiki-graph-root='true']")?.dataset.viewportDragging === "true",
|
||||
undefined,
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
|
||||
const lostEvent = await dispatchPointer(page, "[data-llm-wiki-graph-root='true']", "lostpointercapture", move, {
|
||||
pointerId: 89,
|
||||
button: 0,
|
||||
buttons: 0
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector("[data-llm-wiki-graph-root='true']")?.dataset.viewportDragging || "") === "",
|
||||
undefined,
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
|
||||
const state = await graphInteractionState(page);
|
||||
assert.equal(state.viewportDragging, "", "lostpointercapture should clear viewport dragging state");
|
||||
assert.equal(state.dragging, "", "lostpointercapture should not leave node dragging state");
|
||||
|
||||
return {
|
||||
start: roundedPoint(start),
|
||||
move: roundedPoint(move),
|
||||
lostEvent,
|
||||
state
|
||||
};
|
||||
}
|
||||
|
||||
async function assertPointerBlockersDoNotStartGestures(page) {
|
||||
await page.locator(".node").first().click();
|
||||
await page.waitForSelector('.graph-reader[data-state="open"]');
|
||||
|
||||
const cases = [
|
||||
[".graph-search-input", "search input"],
|
||||
[".graph-toolbar", "toolbar"],
|
||||
[".mini-map", "minimap"],
|
||||
[".graph-reader-body", "reader drawer"]
|
||||
];
|
||||
const results = [];
|
||||
for (let index = 0; index < cases.length; index += 1) {
|
||||
const [selector, label] = cases[index];
|
||||
const beforeTransform = await layerTransform(page);
|
||||
const eventResult = await dispatchPointerOnSelector(page, selector, "pointerdown", {
|
||||
pointerId: 100 + index,
|
||||
button: 0,
|
||||
buttons: 1
|
||||
});
|
||||
await page.waitForTimeout(60);
|
||||
const state = await graphInteractionState(page);
|
||||
const afterTransform = await layerTransform(page);
|
||||
|
||||
assert.equal(eventResult.cancelled, false, `${label} pointerdown should remain outside graph gesture ownership`);
|
||||
assert.equal(state.dragging, "", `${label} pointerdown should not start node dragging`);
|
||||
assert.equal(state.viewportDragging, "", `${label} pointerdown should not start blank panning`);
|
||||
assert.equal(afterTransform, beforeTransform, `${label} pointerdown should not move the graph`);
|
||||
results.push({ selector, label, eventResult, state });
|
||||
}
|
||||
|
||||
await page.locator(".graph-reader-close").click();
|
||||
await page.waitForSelector('.graph-reader[data-state="closed"]');
|
||||
return results;
|
||||
}
|
||||
|
||||
async function assertFastReleasePinsFinalPointerPosition(page) {
|
||||
await page.reload();
|
||||
await page.waitForSelector("[data-llm-wiki-graph-root='true']");
|
||||
await page.waitForSelector("[data-viewport-layer='true']");
|
||||
await page.waitForSelector(".node");
|
||||
|
||||
const before = await page.locator(".node").first().evaluate((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return {
|
||||
id: node.dataset.id || "",
|
||||
center: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 },
|
||||
pinned: node.dataset.pinned || ""
|
||||
};
|
||||
});
|
||||
assert.notEqual(before.id, "", "fast-release drag should have a node target");
|
||||
|
||||
const intermediate = { x: before.center.x + 18, y: before.center.y + 4 };
|
||||
const release = { x: before.center.x + 280, y: before.center.y + 12 };
|
||||
await dispatchPointer(page, `.node[data-id="${cssString(before.id)}"]`, "pointerdown", before.center, {
|
||||
pointerId: 77,
|
||||
button: 0,
|
||||
buttons: 1
|
||||
});
|
||||
await dispatchPointer(page, "[data-llm-wiki-graph-root='true']", "pointermove", intermediate, {
|
||||
pointerId: 77,
|
||||
button: 0,
|
||||
buttons: 1
|
||||
});
|
||||
await dispatchPointer(page, "[data-llm-wiki-graph-root='true']", "pointerup", release, {
|
||||
pointerId: 77,
|
||||
button: 0,
|
||||
buttons: 0
|
||||
});
|
||||
|
||||
await page.waitForSelector(`.node[data-id="${cssString(before.id)}"][data-pinned="true"]`);
|
||||
await page.waitForTimeout(120);
|
||||
|
||||
const after = await page.locator(`.node[data-id="${cssString(before.id)}"]`).evaluate((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return {
|
||||
center: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 },
|
||||
pinned: node.dataset.pinned || ""
|
||||
};
|
||||
});
|
||||
assert.ok(
|
||||
after.center.x > before.center.x + 190,
|
||||
`fast-release drag should commit close to the release point, not the last move point: ${JSON.stringify({ before, intermediate, release, after })}`
|
||||
);
|
||||
assert.equal(after.pinned, "true", "fast-release drag should still pin the node");
|
||||
return {
|
||||
id: before.id,
|
||||
before: roundedPoint(before.center),
|
||||
intermediate: roundedPoint(intermediate),
|
||||
release: roundedPoint(release),
|
||||
after: roundedPoint(after.center),
|
||||
pinned: after.pinned
|
||||
};
|
||||
}
|
||||
|
||||
async function assertKeyboardOwnership(page) {
|
||||
await page.reload();
|
||||
await page.waitForSelector("[data-llm-wiki-graph-root='true']");
|
||||
await page.waitForSelector(".graph-search");
|
||||
|
||||
await page.evaluate(() => {
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
|
||||
document.body.focus();
|
||||
});
|
||||
const outsideGraph = await dispatchDocumentKey(page, "f", { ctrlKey: true });
|
||||
await page.waitForTimeout(80);
|
||||
assert.equal(outsideGraph.defaultPrevented, false, "graph should not steal search shortcut outside graph focus");
|
||||
assert.equal(await graphSearchState(page), "closed", "search shortcut outside graph focus should not open graph search");
|
||||
|
||||
await page.locator("[data-llm-wiki-graph-root='true']").evaluate((root) => root.focus({ preventScroll: true }));
|
||||
const insideGraph = await dispatchDocumentKey(page, "f", { ctrlKey: true });
|
||||
await page.waitForSelector('.graph-search[data-state="open"]');
|
||||
assert.equal(insideGraph.defaultPrevented, true, "graph should own search shortcut when graph is focused");
|
||||
|
||||
await page.evaluate(() => {
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
|
||||
document.body.focus();
|
||||
});
|
||||
const outsideEscape = await dispatchDocumentKey(page, "Escape");
|
||||
await page.waitForTimeout(80);
|
||||
assert.equal(outsideEscape.defaultPrevented, false, "graph should not steal Escape outside graph focus");
|
||||
assert.equal(await graphSearchState(page), "open", "Escape outside graph focus should not close graph search");
|
||||
|
||||
return {
|
||||
outsideGraph,
|
||||
insideGraph,
|
||||
outsideEscape,
|
||||
finalSearchState: await graphSearchState(page)
|
||||
};
|
||||
}
|
||||
|
||||
async function layerTransform(page) {
|
||||
return page.locator("[data-viewport-layer='true']").evaluate((element) => element.style.transform);
|
||||
}
|
||||
|
||||
async function waitForLayerTransform(page, previous) {
|
||||
await page.waitForFunction(
|
||||
(previous) => document.querySelector("[data-viewport-layer='true']")?.style.transform !== previous,
|
||||
previous,
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
return layerTransform(page);
|
||||
}
|
||||
|
||||
async function pageMetrics(page) {
|
||||
return page.evaluate(() => ({
|
||||
devicePixelRatio: window.devicePixelRatio,
|
||||
visualViewportScale: window.visualViewport?.scale || 1,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
clientHeight: document.documentElement.clientHeight
|
||||
}));
|
||||
}
|
||||
|
||||
async function graphDefaultPolicy(page) {
|
||||
return page.evaluate(() => {
|
||||
const root = document.querySelector(".llm-wiki-graph-engine");
|
||||
const readerBody = document.querySelector(".graph-reader-body");
|
||||
const searchInput = document.querySelector(".graph-search-input");
|
||||
if (!root || !readerBody || !searchInput) throw new Error("Missing graph policy elements");
|
||||
const styleOf = (element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
userSelect: style.userSelect,
|
||||
touchAction: style.touchAction,
|
||||
overscrollBehavior: style.overscrollBehavior
|
||||
};
|
||||
};
|
||||
return {
|
||||
root: styleOf(root),
|
||||
readerBody: styleOf(readerBody),
|
||||
searchInput: styleOf(searchInput)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function visibleNodeCenter(page) {
|
||||
return page.locator(".node").first().evaluate((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||
});
|
||||
}
|
||||
|
||||
async function findBlankPoint(page) {
|
||||
return page.evaluate(() => {
|
||||
const root = document.querySelector("[data-llm-wiki-graph-root='true']");
|
||||
if (!root) throw new Error("Missing graph root");
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const blocked = ".node,.community-wash,.edge,.graph-toolbar,.mini-map,.graph-search,.graph-reader,.graph-selection-panel";
|
||||
for (let y = rootRect.top + 86; y < rootRect.bottom - 48; y += 34) {
|
||||
for (let x = rootRect.left + 40; x < rootRect.right - 40; x += 42) {
|
||||
const element = document.elementFromPoint(x, y);
|
||||
if (element && root.contains(element) && !element.closest(blocked)) return { x, y };
|
||||
}
|
||||
}
|
||||
throw new Error("Could not find blank graph point");
|
||||
});
|
||||
}
|
||||
|
||||
async function dispatchWheelAt(page, point, options) {
|
||||
return page.evaluate(({ point, options }) => {
|
||||
const target = document.elementFromPoint(point.x, point.y);
|
||||
if (!target) throw new Error(`No event target at ${JSON.stringify(point)}`);
|
||||
const event = new WheelEvent("wheel", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: point.x,
|
||||
clientY: point.y,
|
||||
deltaY: options.deltaY,
|
||||
deltaMode: 0,
|
||||
ctrlKey: options.ctrlKey,
|
||||
metaKey: options.metaKey
|
||||
});
|
||||
const dispatchResult = target.dispatchEvent(event);
|
||||
return {
|
||||
cancelled: !dispatchResult,
|
||||
defaultPrevented: event.defaultPrevented,
|
||||
target: target.closest(".node") ? "node" : target.closest(".community-wash") ? "community-wash" : target.className?.toString?.() || target.tagName
|
||||
};
|
||||
}, { point, options });
|
||||
}
|
||||
|
||||
async function dispatchWheelOnSelector(page, selector, options) {
|
||||
return page.evaluate(({ selector, options }) => {
|
||||
const target = document.querySelector(selector);
|
||||
if (!target) throw new Error(`Missing selector ${selector}`);
|
||||
const rect = target.getBoundingClientRect();
|
||||
const event = new WheelEvent("wheel", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: rect.left + rect.width / 2,
|
||||
clientY: rect.top + rect.height / 2,
|
||||
deltaY: options.deltaY,
|
||||
deltaMode: 0,
|
||||
ctrlKey: options.ctrlKey,
|
||||
metaKey: options.metaKey
|
||||
});
|
||||
const dispatchResult = target.dispatchEvent(event);
|
||||
return {
|
||||
cancelled: !dispatchResult,
|
||||
defaultPrevented: event.defaultPrevented,
|
||||
target: selector
|
||||
};
|
||||
}, { selector, options });
|
||||
}
|
||||
|
||||
async function dispatchPointer(page, selector, type, point, options) {
|
||||
return page.evaluate(({ selector, type, point, options }) => {
|
||||
const target = document.querySelector(selector);
|
||||
if (!target) throw new Error(`Missing selector ${selector}`);
|
||||
const event = new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
pointerId: options.pointerId,
|
||||
pointerType: "mouse",
|
||||
clientX: point.x,
|
||||
clientY: point.y,
|
||||
button: options.button,
|
||||
buttons: options.buttons,
|
||||
isPrimary: true
|
||||
});
|
||||
const dispatchResult = target.dispatchEvent(event);
|
||||
return {
|
||||
cancelled: !dispatchResult,
|
||||
defaultPrevented: event.defaultPrevented
|
||||
};
|
||||
}, { selector, type, point, options });
|
||||
}
|
||||
|
||||
async function dispatchDocumentKey(page, key, options = {}) {
|
||||
return page.evaluate(({ key, options }) => {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
ctrlKey: options.ctrlKey === true,
|
||||
metaKey: options.metaKey === true
|
||||
});
|
||||
const dispatchResult = document.dispatchEvent(event);
|
||||
return {
|
||||
cancelled: !dispatchResult,
|
||||
defaultPrevented: event.defaultPrevented,
|
||||
activeTag: document.activeElement?.tagName || ""
|
||||
};
|
||||
}, { key, options });
|
||||
}
|
||||
|
||||
async function graphSearchState(page) {
|
||||
return page.locator(".graph-search").evaluate((element) => element.dataset.state || "");
|
||||
}
|
||||
|
||||
async function graphInteractionState(page, nodeId = "") {
|
||||
return page.evaluate((nodeId) => {
|
||||
const root = document.querySelector("[data-llm-wiki-graph-root='true']");
|
||||
const node = nodeId ? document.querySelector(`.node[data-id="${CSS.escape(nodeId)}"]`) : null;
|
||||
return {
|
||||
dragging: root?.dataset.dragging || "",
|
||||
viewportDragging: root?.dataset.viewportDragging || "",
|
||||
pinned: node?.dataset.pinned || "",
|
||||
nodeDragging: Boolean(node?.classList.contains("is-dragging"))
|
||||
};
|
||||
}, nodeId);
|
||||
}
|
||||
|
||||
async function waitForClearInteraction(page, nodeId = "") {
|
||||
await page.waitForFunction(
|
||||
(nodeId) => {
|
||||
const root = document.querySelector("[data-llm-wiki-graph-root='true']");
|
||||
const node = nodeId ? document.querySelector(`.node[data-id="${CSS.escape(nodeId)}"]`) : null;
|
||||
return (root?.dataset.dragging || "") === ""
|
||||
&& (root?.dataset.viewportDragging || "") === ""
|
||||
&& !node?.classList.contains("is-dragging");
|
||||
},
|
||||
nodeId,
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
}
|
||||
|
||||
async function resetSelection(page) {
|
||||
await page.evaluate(() => window.getSelection()?.removeAllRanges());
|
||||
}
|
||||
|
||||
function roundedPoint(point) {
|
||||
return {
|
||||
x: Math.round(point.x * 1000) / 1000,
|
||||
y: Math.round(point.y * 1000) / 1000
|
||||
};
|
||||
}
|
||||
|
||||
function cssString(value) {
|
||||
return String(value).replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
||||
}
|
||||
|
||||
async function dispatchPointerOnSelector(page, selector, type, options) {
|
||||
return page.evaluate(({ selector, type, options }) => {
|
||||
const target = document.querySelector(selector);
|
||||
if (!target) throw new Error(`Missing selector ${selector}`);
|
||||
const rect = target.getBoundingClientRect();
|
||||
const point = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||
const event = new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
pointerId: options.pointerId,
|
||||
pointerType: "mouse",
|
||||
clientX: point.x,
|
||||
clientY: point.y,
|
||||
button: options.button,
|
||||
buttons: options.buttons,
|
||||
isPrimary: true
|
||||
});
|
||||
const dispatchResult = target.dispatchEvent(event);
|
||||
return {
|
||||
cancelled: !dispatchResult,
|
||||
defaultPrevented: event.defaultPrevented,
|
||||
point
|
||||
};
|
||||
}, { selector, type, options });
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const html = process.env.GRAPH_DENSITY_PREVIEW_HTML || "";
|
||||
assert.notEqual(html, "", "GRAPH_DENSITY_PREVIEW_HTML must point at generated HTML");
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
|
||||
await page.goto(pathToFileURL(html).href);
|
||||
await page.waitForSelector("[data-llm-wiki-graph-root='true']");
|
||||
|
||||
const root = page.locator("[data-llm-wiki-graph-root='true']");
|
||||
const totalNodes = await page.locator(".node").count();
|
||||
const fullCards = await page.locator(".node:not(.is-compact):not(.is-point):not(.is-overview)").count();
|
||||
assert.ok(totalNodes >= 120, `dense fixture should render many nodes, got ${totalNodes}`);
|
||||
assert.ok(fullCards < totalNodes / 2, "dense fixture should not render every node as a full card");
|
||||
|
||||
const compact = page.locator(".node.is-compact").first();
|
||||
await compact.waitFor();
|
||||
await compact.hover();
|
||||
await assertPreviewOpen(page, "compact node should open hover preview");
|
||||
|
||||
await page.mouse.move(20, 20);
|
||||
await waitForPreviewState(page, "closed");
|
||||
|
||||
await page.waitForSelector(".node.is-point");
|
||||
const point = page.locator(".node.is-point").first();
|
||||
await point.evaluate((element) => {
|
||||
element.dispatchEvent(new PointerEvent("pointerenter", { bubbles: true, pointerType: "mouse" }));
|
||||
});
|
||||
await assertPreviewOpen(page, "point node should open hover preview");
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
async function assertPreviewOpen(page, message) {
|
||||
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();
|
||||
assert.equal(await preview.locator(".graph-hover-preview-summary").count(), 1, message);
|
||||
}
|
||||
|
||||
async function waitForPreviewState(page, state) {
|
||||
await page.waitForFunction((state) => {
|
||||
return document.querySelector(".graph-hover-preview")?.dataset.state === state;
|
||||
}, state);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const html = process.env.GRAPH_HTML_INSIGHTS_HTML || "";
|
||||
assert.notEqual(html, "", "GRAPH_HTML_INSIGHTS_HTML must point at generated HTML");
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
|
||||
await page.goto(pathToFileURL(html).href);
|
||||
await page.waitForSelector("[data-llm-wiki-graph-root='true']");
|
||||
|
||||
await page.keyboard.down("Shift");
|
||||
await page.locator(".node[data-id='A']").click();
|
||||
await page.locator(".node[data-id='B']").click();
|
||||
await page.keyboard.up("Shift");
|
||||
await page.waitForSelector(".graph-selection-panel[data-state='open']");
|
||||
const panel = page.locator(".graph-selection-panel");
|
||||
await panel.getByText("Shift+点击 增删节点").waitFor();
|
||||
await panel.getByText("节点A").waitFor();
|
||||
await panel.getByText("节点B").waitFor();
|
||||
assert.ok(await panel.locator(".graph-selection-fact").count() >= 4, "Shift selection should show structural facts");
|
||||
assert.equal(await panel.getByText("提问选区").count(), 0, "offline selection panel must not show ask actions");
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForSelector(".graph-selection-panel[data-state='closed']");
|
||||
|
||||
await page.getByRole("button", { name: "筛选" }).click();
|
||||
await page.waitForSelector('.graph-toolbar-panel[data-state="filters"] .community-legend-row');
|
||||
await page.locator(".community-legend-row").first().click();
|
||||
await page.waitForSelector(".graph-selection-panel[data-state='open']");
|
||||
await panel.getByText(/社区选区/).waitFor();
|
||||
await panel.getByText("内部关联").waitFor();
|
||||
assert.equal(await panel.locator("button", { hasText: "提问选区" }).count(), 0, "offline community selection must not show ask actions");
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
generateLargeGraphFixture,
|
||||
type LargeGraphFixtureMetadata
|
||||
} from "../../packages/graph-engine/test/large-graph-fixtures";
|
||||
import type { GraphData } from "../../packages/graph-engine/src/types";
|
||||
|
||||
type LargeGraphFixtureId = Parameters<typeof generateLargeGraphFixture>[0];
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../..");
|
||||
const artifactDir = process.env.GRAPH_LARGE_PERF_ARTIFACT_DIR || path.join(os.tmpdir(), `llm-wiki-graph-large-perf-${Date.now()}`);
|
||||
const executablePath = process.env.GRAPH_LARGE_PERF_CHROME_EXECUTABLE || "";
|
||||
const requestedShapes = (process.env.GRAPH_LARGE_PERF_SHAPES || [
|
||||
"nodes-1000-sparse",
|
||||
"nodes-1000-dense",
|
||||
"nodes-5000-sparse",
|
||||
"nodes-10000-aggregation",
|
||||
"oversized-community"
|
||||
].join(","))
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean) as LargeGraphFixtureId[];
|
||||
const resultPath = path.join(artifactDir, "large-graph-performance-results.json");
|
||||
const resultPhase = process.env.GRAPH_LARGE_PERF_PHASE || "phase-1";
|
||||
const resultTask = process.env.GRAPH_LARGE_PERF_TASK || "1.2";
|
||||
let fixtureRoot = "";
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await fs.mkdir(artifactDir, { recursive: true });
|
||||
const runStartedAt = new Date().toISOString();
|
||||
const records: PerformanceRecord[] = [];
|
||||
const errors: string[] = [];
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "llm-wiki-large-graph-fixtures-"));
|
||||
|
||||
for (const shape of requestedShapes) {
|
||||
let metadata: LargeGraphFixtureMetadata | null = null;
|
||||
let browser: BrowserLike | null = null;
|
||||
try {
|
||||
const fixture = generateLargeGraphFixture(shape);
|
||||
metadata = fixture.metadata;
|
||||
const html = await buildOfflineHtml(shape, fixture.data, fixture.pins, fixture.metadata);
|
||||
browser = await chromium.launch(executablePath ? { executablePath } : {});
|
||||
try {
|
||||
const shapeRecords = await measureShape(browser, fixture.metadata, html);
|
||||
records.push(...shapeRecords);
|
||||
} finally {
|
||||
await browser.close().catch(() => undefined);
|
||||
browser = null;
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`${shape}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
if (metadata) {
|
||||
records.push(failedRecord(metadata, {
|
||||
action: "fixture_build_or_measure",
|
||||
failure_class: classifyError(error),
|
||||
failure_detail: errorDetail(error),
|
||||
artifact_path: resultPath
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
if (browser) await browser.close().catch(() => undefined);
|
||||
await writeResult(runStartedAt, records, errors);
|
||||
}
|
||||
}
|
||||
|
||||
const required1000 = new Set(["nodes-1000-sparse", "nodes-1000-dense"]);
|
||||
const failedRequired = records.filter((record) => required1000.has(record.graph_shape) && !record.pass);
|
||||
const missingRequired = [...required1000].filter((shape) => requestedShapes.includes(shape as LargeGraphFixtureId) && !records.some((record) => record.graph_shape === shape));
|
||||
if (failedRequired.length > 0 || missingRequired.length > 0) {
|
||||
throw new Error(
|
||||
`Large graph performance runner recorded blocking failures. result=${resultPath}, errors=${errors.length}, failedRequired=${failedRequired.length}, missingRequired=${missingRequired.join(",")}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Wrote ${records.length} performance records to ${resultPath}`);
|
||||
}
|
||||
|
||||
async function writeResult(runStartedAt: string, records: PerformanceRecord[], errors: string[]): Promise<void> {
|
||||
const result = {
|
||||
run_started_at: runStartedAt,
|
||||
run_finished_at: new Date().toISOString(),
|
||||
renderer: "dom-svg-current",
|
||||
artifact_dir: artifactDir,
|
||||
shapes: requestedShapes,
|
||||
records,
|
||||
errors
|
||||
};
|
||||
await fs.writeFile(resultPath, `${JSON.stringify(result, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function buildOfflineHtml(
|
||||
shape: LargeGraphFixtureId,
|
||||
graphData: GraphData,
|
||||
pins: Record<string, unknown>,
|
||||
metadata: LargeGraphFixtureMetadata
|
||||
): Promise<string> {
|
||||
const root = path.join(fixtureRoot, shape);
|
||||
const wikiDir = path.join(root, "wiki");
|
||||
await fs.mkdir(wikiDir, { recursive: true });
|
||||
await fs.writeFile(path.join(wikiDir, "graph-data.json"), `${JSON.stringify(graphData, null, 2)}\n`);
|
||||
await fs.writeFile(path.join(wikiDir, "graph-pins.json"), `${JSON.stringify(pins, null, 2)}\n`);
|
||||
const build = spawnSync("bash", ["scripts/build-graph-html.sh", root], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
timeout: buildTimeoutFor(metadata)
|
||||
});
|
||||
if (build.error) {
|
||||
throw build.error;
|
||||
}
|
||||
if (build.status !== 0) {
|
||||
throw new Error(`build-graph-html.sh failed for ${shape}: ${build.stderr || build.stdout}`);
|
||||
}
|
||||
return path.join(wikiDir, "knowledge-graph.html");
|
||||
}
|
||||
|
||||
async function measureShape(
|
||||
browser: BrowserLike,
|
||||
metadata: LargeGraphFixtureMetadata,
|
||||
html: string
|
||||
): Promise<PerformanceRecord[]> {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
|
||||
page.setDefaultTimeout(timeoutFor(metadata));
|
||||
page.setDefaultNavigationTimeout(45_000);
|
||||
const records: PerformanceRecord[] = [];
|
||||
try {
|
||||
const renderStarted = performance.now();
|
||||
await page.goto(pathToFileURL(html).href, { waitUntil: "domcontentloaded", timeout: navigationTimeoutFor(metadata) });
|
||||
await page.waitForSelector("[data-llm-wiki-graph-root='true']");
|
||||
await page.waitForSelector("[data-viewport-layer='true']");
|
||||
await page.waitForSelector(".node");
|
||||
const renderDuration = performance.now() - renderStarted;
|
||||
records.push(await recordFromPage(page, metadata, {
|
||||
action: "initial_render",
|
||||
duration_ms: renderDuration,
|
||||
pass: true,
|
||||
artifact_path: resultPath
|
||||
}));
|
||||
|
||||
const actions: Array<() => Promise<PerformanceRecord>> = [
|
||||
() => measureWheelZoom(page, metadata),
|
||||
() => measurePan(page, metadata),
|
||||
() => measureHover(page, metadata),
|
||||
() => measureSearch(page, metadata),
|
||||
() => measureNodeClick(page, metadata),
|
||||
() => measureDrawerOpen(page, metadata),
|
||||
() => measureEnterCommunity(page, metadata),
|
||||
() => measureReturnGlobal(page, metadata)
|
||||
];
|
||||
if (metadata.nodes <= 1000) {
|
||||
actions.push(() => measureRepeatedCycles(page, metadata));
|
||||
}
|
||||
for (const action of actions) {
|
||||
records.push(await safeMeasure(page, metadata, action));
|
||||
}
|
||||
} catch (error) {
|
||||
records.push(failedRecord(metadata, {
|
||||
action: "fixture_load_or_action",
|
||||
failure_class: classifyError(error),
|
||||
failure_detail: errorDetail(error),
|
||||
artifact_path: resultPath
|
||||
}));
|
||||
} finally {
|
||||
await page.close().catch(() => undefined);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
async function safeMeasure(
|
||||
page: PageLike,
|
||||
metadata: LargeGraphFixtureMetadata,
|
||||
action: () => Promise<PerformanceRecord>
|
||||
): Promise<PerformanceRecord> {
|
||||
try {
|
||||
return await action();
|
||||
} catch (error) {
|
||||
return failedRecord(metadata, {
|
||||
action: inferActionName(error),
|
||||
failure_class: classifyError(error),
|
||||
failure_detail: errorDetail(error),
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function measureWheelZoom(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const root = page.locator("[data-llm-wiki-graph-root='true']");
|
||||
const before = await layerTransform(page);
|
||||
const samplePromise = sampleAnimationFrames(page, 1000);
|
||||
const started = performance.now();
|
||||
while (performance.now() - started < 1000) {
|
||||
await root.dispatchEvent("wheel", {
|
||||
deltaY: -180,
|
||||
deltaMode: 0,
|
||||
clientX: 520,
|
||||
clientY: 420,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
const sample = await samplePromise;
|
||||
const after = await layerTransform(page);
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "wheel_zoom",
|
||||
duration_ms: sample.durationMs,
|
||||
fps: sample.fps,
|
||||
frame_p95_ms: sample.p95,
|
||||
pass: after !== before && sample.fps >= 10,
|
||||
failure_class: after === before ? "viewport_transform_unchanged" : sample.fps < 10 ? "fps_below_floor" : null,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measurePan(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const before = await layerTransform(page);
|
||||
const blank = await blankPoint(page);
|
||||
const started = performance.now();
|
||||
await page.mouse.move(blank.x, blank.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(blank.x + 180, blank.y + 120, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
await waitForTransformChange(page, before, 3000);
|
||||
const duration = performance.now() - started;
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "pan",
|
||||
duration_ms: duration,
|
||||
pass: true,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measureHover(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const started = performance.now();
|
||||
const node = await visibleNodeTarget(page);
|
||||
await page.mouse.move(node.x, node.y);
|
||||
await page.waitForFunction(() => document.querySelectorAll(".graph-hover-preview[data-state='open']").length > 0, undefined, { timeout: 5000 });
|
||||
const duration = performance.now() - started;
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "hover",
|
||||
duration_ms: duration,
|
||||
pass: true,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measureSearch(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const started = performance.now();
|
||||
const input = page.locator(".graph-search-input");
|
||||
await input.fill("needle");
|
||||
await page.waitForFunction(() => document.querySelectorAll('.node[data-search-state="match"]').length > 0);
|
||||
const duration = performance.now() - started;
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "search_highlight",
|
||||
duration_ms: duration,
|
||||
pass: true,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measureNodeClick(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const started = performance.now();
|
||||
await clickVisibleNode(page);
|
||||
await page.waitForFunction(() => document.querySelectorAll('.node[aria-pressed="true"]').length > 0);
|
||||
const duration = performance.now() - started;
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "node_click",
|
||||
duration_ms: duration,
|
||||
pass: true,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measureDrawerOpen(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
const started = performance.now();
|
||||
await clickVisibleNode(page);
|
||||
await waitForGraphDrawerOpen(page);
|
||||
const duration = performance.now() - started;
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "drawer_open",
|
||||
duration_ms: duration,
|
||||
pass: true,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measureEnterCommunity(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
const started = performance.now();
|
||||
await ensureFiltersOpen(page);
|
||||
await page.locator(".community-legend-row").first().click({ force: true });
|
||||
await page.waitForFunction(() => document.querySelectorAll('.node[aria-pressed="true"]').length > 1);
|
||||
const duration = performance.now() - started;
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "enter_community",
|
||||
duration_ms: duration,
|
||||
pass: true,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measureReturnGlobal(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const before = await layerTransform(page);
|
||||
const started = performance.now();
|
||||
await page.getByRole("button", { name: "回全图" }).click({ force: true });
|
||||
await waitForTransformChange(page, before, 3000).catch(() => undefined);
|
||||
const duration = performance.now() - started;
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "return_global",
|
||||
duration_ms: duration,
|
||||
pass: true,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measureRepeatedCycles(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const before = await memoryMb(page);
|
||||
const started = performance.now();
|
||||
const cycles = 1;
|
||||
for (let index = 0; index < cycles; index += 1) {
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
await page.getByRole("button", { name: "回全图" }).click({ force: true }).catch(() => undefined);
|
||||
await page.waitForFunction(() => document.querySelectorAll(".node").length >= 250);
|
||||
await page.locator(".graph-search-input").fill(index % 2 === 0 ? "needle" : "node");
|
||||
await page.waitForFunction(() => document.querySelectorAll('.node[data-search-state="match"]').length > 0);
|
||||
await clickVisibleNode(page);
|
||||
await waitForGraphDrawerOpen(page);
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
await ensureFiltersOpen(page);
|
||||
await page.locator(".community-legend-row").nth(index % 3).click({ force: true });
|
||||
await page.waitForFunction(() => document.querySelectorAll('.node[aria-pressed="true"]').length > 1);
|
||||
await page.getByRole("button", { name: "回全图" }).click({ force: true });
|
||||
await page.waitForFunction(() => document.querySelectorAll(".node").length >= 250);
|
||||
await page.waitForTimeout(120);
|
||||
}
|
||||
const duration = performance.now() - started;
|
||||
const after = await memoryMb(page);
|
||||
const record = await recordFromPage(page, metadata, {
|
||||
action: "repeated_search_community_drawer_cycles",
|
||||
duration_ms: duration,
|
||||
pass: true,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
record.memory_after_cycles_mb = after;
|
||||
record.memory_growth_mb = before == null || after == null ? null : round(after - before);
|
||||
return record;
|
||||
}
|
||||
|
||||
async function recordFromPage(
|
||||
page: PageLike,
|
||||
metadata: LargeGraphFixtureMetadata,
|
||||
input: Partial<PerformanceRecord> & { action: string; artifact_path: string }
|
||||
): Promise<PerformanceRecord> {
|
||||
const counts = await page.evaluate(`(() => {
|
||||
const root = document.querySelector("[data-llm-wiki-graph-root='true']");
|
||||
return {
|
||||
dom_node_count: document.querySelectorAll("*").length,
|
||||
visible_node_count: document.querySelectorAll(".node").length,
|
||||
visible_edge_count: document.querySelectorAll(".edge").length,
|
||||
visible_label_count: document.querySelectorAll(".node:not(.is-label-hidden) .node-name").length,
|
||||
visible_card_count: document.querySelectorAll(".node:not(.is-point)").length,
|
||||
interaction_mode: root?.dataset.interactionMode || null,
|
||||
interaction_updated_objects: root ? Number(root.dataset.interactionUpdatedObjects || 0) : null,
|
||||
interaction_hidden_objects: root ? Number(root.dataset.interactionHiddenObjects || 0) : null,
|
||||
interaction_preserved_nodes: root ? Number(root.dataset.interactionPreservedNodes || 0) : null,
|
||||
interaction_max_updates: root ? Number(root.dataset.interactionMaxUpdates || 0) : null,
|
||||
memory_peak_mb: typeof performance !== "undefined" && "memory" in performance
|
||||
? Math.round((performance.memory?.usedJSHeapSize || 0) / 1024 / 1024 * 10) / 10
|
||||
: null,
|
||||
long_task_count: performance.getEntriesByType ? performance.getEntriesByType("longtask").length : null,
|
||||
viewport_scale: root ? Number(root.dataset.viewportScale || 1) : 1
|
||||
};
|
||||
})()`);
|
||||
return {
|
||||
...baseRecord(metadata, input.action, input.artifact_path),
|
||||
...counts,
|
||||
duration_ms: round(input.duration_ms ?? 0),
|
||||
fps: input.fps == null ? null : round(input.fps),
|
||||
frame_p95_ms: input.frame_p95_ms == null ? null : round(input.frame_p95_ms),
|
||||
pass: (input.pass ?? true) && (counts.interaction_updated_objects == null || counts.interaction_max_updates == null || counts.interaction_updated_objects <= counts.interaction_max_updates),
|
||||
failure_class: input.failure_class ?? (counts.interaction_updated_objects != null && counts.interaction_max_updates != null && counts.interaction_updated_objects > counts.interaction_max_updates ? "interaction_updates_over_budget" : null)
|
||||
};
|
||||
}
|
||||
|
||||
function failedRecord(
|
||||
metadata: LargeGraphFixtureMetadata,
|
||||
input: { action: string; failure_class: string; failure_detail?: string; artifact_path: string }
|
||||
): PerformanceRecord {
|
||||
return {
|
||||
...baseRecord(metadata, input.action, input.artifact_path),
|
||||
duration_ms: null,
|
||||
fps: null,
|
||||
frame_p95_ms: null,
|
||||
long_task_count: null,
|
||||
dom_node_count: null,
|
||||
visible_node_count: null,
|
||||
visible_edge_count: null,
|
||||
visible_label_count: null,
|
||||
visible_card_count: null,
|
||||
interaction_mode: null,
|
||||
interaction_updated_objects: null,
|
||||
interaction_hidden_objects: null,
|
||||
interaction_preserved_nodes: null,
|
||||
interaction_max_updates: null,
|
||||
memory_peak_mb: null,
|
||||
memory_after_cycles_mb: null,
|
||||
memory_growth_mb: null,
|
||||
pass: false,
|
||||
failure_class: input.failure_class,
|
||||
failure_detail: input.failure_detail ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function baseRecord(metadata: LargeGraphFixtureMetadata, action: string, artifactPath: string): PerformanceRecord {
|
||||
return {
|
||||
phase: resultPhase,
|
||||
task: resultTask,
|
||||
renderer: "dom-svg-current",
|
||||
graph_shape: metadata.id,
|
||||
nodes: metadata.nodes,
|
||||
edges: metadata.edges,
|
||||
communities: metadata.communities,
|
||||
largest_community: metadata.largest_community,
|
||||
largest_connected_density: metadata.largest_connected_density,
|
||||
search_hits: metadata.search_hits,
|
||||
pin_count: metadata.pin_count,
|
||||
oversized_community: metadata.oversized_community,
|
||||
action,
|
||||
duration_ms: null,
|
||||
fps: null,
|
||||
frame_p95_ms: null,
|
||||
long_task_count: null,
|
||||
dom_node_count: null,
|
||||
visible_node_count: null,
|
||||
visible_edge_count: null,
|
||||
visible_label_count: null,
|
||||
visible_card_count: null,
|
||||
interaction_mode: null,
|
||||
interaction_updated_objects: null,
|
||||
interaction_hidden_objects: null,
|
||||
interaction_preserved_nodes: null,
|
||||
interaction_max_updates: null,
|
||||
memory_peak_mb: null,
|
||||
memory_after_cycles_mb: null,
|
||||
memory_growth_mb: null,
|
||||
pass: false,
|
||||
failure_class: null,
|
||||
artifact_path: artifactPath,
|
||||
measured_at: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
async function layerTransform(page: PageLike): Promise<string> {
|
||||
return page.evaluate(`document.querySelector("[data-viewport-layer='true']")?.style.transform || ""`);
|
||||
}
|
||||
|
||||
async function waitForTransformChange(page: PageLike, previous: string, timeout: number): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
`(() => {
|
||||
const previous = ${JSON.stringify(previous)};
|
||||
const element = document.querySelector("[data-viewport-layer='true']");
|
||||
return Boolean(element && element.style.transform && element.style.transform !== previous);
|
||||
})()`,
|
||||
undefined,
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForGraphDrawerOpen(page: PageLike): Promise<void> {
|
||||
await page.waitForFunction(`(() => {
|
||||
return Boolean(
|
||||
document.querySelector(".graph-reader[data-state='open']")
|
||||
|| document.querySelector(".graph-selection-panel[data-state='open']")
|
||||
|| document.querySelector("[data-testid='graph-node-summary']")
|
||||
|| document.querySelector("[data-testid='graph-community-summary']")
|
||||
);
|
||||
})()`, undefined, { timeout: 12000 });
|
||||
}
|
||||
|
||||
async function visibleNodeTarget(page: PageLike): Promise<{ id: string; x: number; y: number }> {
|
||||
return page.evaluate(`(() => {
|
||||
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
for (const element of document.querySelectorAll(".node")) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = rect.top + rect.height / 2;
|
||||
const top = document.elementFromPoint(x, y);
|
||||
const topNode = top ? top.closest(".node") : null;
|
||||
if (rect.width > 0 && rect.height > 0 && x >= 8 && y >= 8 && x <= viewportWidth - 8 && y <= viewportHeight - 8 && topNode === element) {
|
||||
return { id: element.dataset.id || "", x, y };
|
||||
}
|
||||
}
|
||||
throw new Error("no_clickable_node");
|
||||
})()`);
|
||||
}
|
||||
|
||||
async function blankPoint(page: PageLike): Promise<{ x: number; y: number }> {
|
||||
return page.evaluate(`(() => {
|
||||
const blocked = ".node,.community-wash,.edge,.graph-toolbar,.mini-map,.graph-search,.graph-reader,.graph-selection-panel,.graph-hover-preview";
|
||||
const candidates = [
|
||||
[72, 132],
|
||||
[96, 220],
|
||||
[180, 180],
|
||||
[window.innerWidth * 0.36, window.innerHeight * 0.36],
|
||||
[window.innerWidth * 0.5, window.innerHeight * 0.5],
|
||||
[window.innerWidth * 0.72, window.innerHeight * 0.48]
|
||||
];
|
||||
for (const [x, y] of candidates) {
|
||||
const element = document.elementFromPoint(x, y);
|
||||
if (element && !element.closest(blocked)) return { x, y };
|
||||
}
|
||||
return { x: 72, y: 132 };
|
||||
})()`);
|
||||
}
|
||||
|
||||
async function clickVisibleNode(page: PageLike): Promise<void> {
|
||||
const target = await visibleNodeTarget(page);
|
||||
await page.mouse.move(target.x, target.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function ensureFiltersOpen(page: PageLike): Promise<void> {
|
||||
const open = await page.evaluate(`document.querySelector("[data-llm-wiki-graph-root='true']")?.dataset.toolbarPanel === "filters"`);
|
||||
if (!open) {
|
||||
await page.getByRole("button", { name: "筛选" }).click({ force: true });
|
||||
}
|
||||
await page.waitForSelector('.graph-toolbar-panel[data-state="filters"] .community-legend-row');
|
||||
}
|
||||
|
||||
async function memoryMb(page: PageLike): Promise<number | null> {
|
||||
return page.evaluate(`(() => {
|
||||
if (typeof performance === "undefined" || !("memory" in performance)) return null;
|
||||
const used = performance.memory?.usedJSHeapSize;
|
||||
return typeof used === "number" ? Math.round((used / 1024 / 1024) * 10) / 10 : null;
|
||||
})()`) as Promise<number | null>;
|
||||
}
|
||||
|
||||
async function sampleAnimationFrames(page: PageLike, durationMs: number): Promise<{ durationMs: number; fps: number; p95: number }> {
|
||||
return page.evaluate(`(() => new Promise((resolve) => {
|
||||
const durationMs = ${JSON.stringify(durationMs)};
|
||||
const started = performance.now();
|
||||
const deltas = [];
|
||||
let last = started;
|
||||
function tick(now) {
|
||||
deltas.push(now - last);
|
||||
last = now;
|
||||
const elapsed = now - started;
|
||||
if (elapsed >= durationMs) {
|
||||
const sorted = [...deltas].sort((a, b) => a - b);
|
||||
const p95 = sorted[Math.max(0, Math.floor(sorted.length * 0.95) - 1)] || 0;
|
||||
resolve({ durationMs: elapsed, fps: deltas.length / (elapsed / 1000), p95 });
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}))()`) as Promise<{ durationMs: number; fps: number; p95: number }>;
|
||||
}
|
||||
|
||||
function classifyError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (/Timeout/i.test(message)) return "timeout";
|
||||
if (/Target page|browser has been closed/i.test(message)) return "browser_closed";
|
||||
if (/JavaScript heap|out of memory/i.test(message)) return "memory";
|
||||
return "exception";
|
||||
}
|
||||
|
||||
function errorDetail(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.replace(/\s+/g, " ").slice(0, 500);
|
||||
}
|
||||
|
||||
function inferActionName(error: unknown): string {
|
||||
const stack = error instanceof Error ? error.stack || error.message : String(error);
|
||||
const match = stack.match(/measure[A-Z][A-Za-z0-9_]*/);
|
||||
if (!match) return "unknown_action";
|
||||
return match[0]
|
||||
.replace(/^measure/, "")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function timeoutFor(metadata: LargeGraphFixtureMetadata): number {
|
||||
if (metadata.nodes >= 10000) return 12_000;
|
||||
if (metadata.nodes >= 5000) return 10_000;
|
||||
return 20_000;
|
||||
}
|
||||
|
||||
function navigationTimeoutFor(metadata: LargeGraphFixtureMetadata): number {
|
||||
if (metadata.nodes >= 10000) return 25_000;
|
||||
if (metadata.nodes >= 5000) return 20_000;
|
||||
return 45_000;
|
||||
}
|
||||
|
||||
function buildTimeoutFor(metadata: LargeGraphFixtureMetadata): number {
|
||||
if (metadata.nodes >= 10000) return 60_000;
|
||||
if (metadata.nodes >= 5000) return 45_000;
|
||||
return 30_000;
|
||||
}
|
||||
|
||||
function round(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
interface BrowserLike {
|
||||
newPage(options: { viewport: { width: number; height: number } }): Promise<PageLike>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface PageLike {
|
||||
setDefaultTimeout(timeout: number): void;
|
||||
setDefaultNavigationTimeout(timeout: number): void;
|
||||
goto(url: string): Promise<unknown>;
|
||||
waitForSelector(selector: string, options?: unknown): Promise<unknown>;
|
||||
waitForFunction(fn: Function | string, arg?: unknown, options?: unknown): Promise<unknown>;
|
||||
waitForTimeout(timeout: number): Promise<void>;
|
||||
evaluate<T>(fn: Function | string, arg?: unknown): Promise<T>;
|
||||
locator(selector: string): LocatorLike;
|
||||
getByRole(role: string, options: { name: string | RegExp }): LocatorLike;
|
||||
keyboard: { press(key: string): Promise<void> };
|
||||
mouse: {
|
||||
move(x: number, y: number, options?: { steps?: number }): Promise<void>;
|
||||
down(): Promise<void>;
|
||||
up(): Promise<void>;
|
||||
};
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface LocatorLike {
|
||||
first(): LocatorLike;
|
||||
nth(index: number): LocatorLike;
|
||||
fill(value: string): Promise<void>;
|
||||
click(options?: { force?: boolean }): Promise<void>;
|
||||
dispatchEvent(name: string, eventInit: Record<string, unknown>): Promise<void>;
|
||||
evaluate<T>(fn: Function | string): Promise<T>;
|
||||
}
|
||||
|
||||
interface PerformanceRecord {
|
||||
phase: string;
|
||||
task: string;
|
||||
renderer: string;
|
||||
graph_shape: string;
|
||||
nodes: number;
|
||||
edges: number;
|
||||
communities: number;
|
||||
largest_community: number;
|
||||
largest_connected_density: number;
|
||||
search_hits: number;
|
||||
pin_count: number;
|
||||
oversized_community: boolean;
|
||||
action: string;
|
||||
duration_ms: number | null;
|
||||
fps: number | null;
|
||||
frame_p95_ms: number | null;
|
||||
long_task_count: number | null;
|
||||
dom_node_count: number | null;
|
||||
visible_node_count: number | null;
|
||||
visible_edge_count: number | null;
|
||||
visible_label_count: number | null;
|
||||
visible_card_count: number | null;
|
||||
interaction_mode: string | null;
|
||||
interaction_updated_objects: number | null;
|
||||
interaction_hidden_objects: number | null;
|
||||
interaction_preserved_nodes: number | null;
|
||||
interaction_max_updates: number | null;
|
||||
memory_peak_mb: number | null;
|
||||
memory_after_cycles_mb: number | null;
|
||||
memory_growth_mb: number | null;
|
||||
pass: boolean;
|
||||
failure_class: string | null;
|
||||
failure_detail?: string | null;
|
||||
artifact_path: string;
|
||||
measured_at: string;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const html = process.env.GRAPH_NODE_SLIM_HTML || "";
|
||||
assert.notEqual(html, "", "GRAPH_NODE_SLIM_HTML must point at generated HTML");
|
||||
|
||||
const browser = await chromium.launch({
|
||||
// This regression targets the DOM/SVG small-graph fallback. The default offline
|
||||
// global route uses Sigma when WebGL is available, which has a different DOM.
|
||||
args: ["--disable-webgl", "--disable-webgl2", "--disable-3d-apis"]
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
|
||||
await page.goto(pathToFileURL(html).href);
|
||||
await page.waitForSelector("[data-llm-wiki-graph-root='true']");
|
||||
|
||||
const node = page.locator(".node[data-id='A']");
|
||||
await node.waitFor();
|
||||
await assertNodeDetailDisplay(node, "none", "none", "default card node should hide type and weight details");
|
||||
assert.equal(await node.locator(".node-name").innerText(), "节点A", "default card node should keep the title visible");
|
||||
|
||||
await node.hover();
|
||||
await assertNodeDetailDisplay(node, "block", "flex", "hovered card node should expose type and weight details");
|
||||
await page.waitForSelector(".graph-hover-preview[data-state='open']");
|
||||
const preview = page.locator(".graph-hover-preview");
|
||||
await preview.locator(".graph-hover-preview-title", { hasText: /^节点A$/ }).waitFor();
|
||||
await preview.getByText("实体").waitFor();
|
||||
await preview.getByText("这是节点A的内容。").waitFor();
|
||||
assert.equal(await preview.locator(".graph-hover-preview-summary").count(), 1, "content nodes should show a preview summary");
|
||||
|
||||
await page.mouse.move(20, 20);
|
||||
await waitForPreviewState(page, "closed");
|
||||
await assertNodeDetailDisplay(node, "none", "none", "card node details should hide again after hover leaves");
|
||||
|
||||
await node.click();
|
||||
await page.waitForSelector(".graph-reader[data-state='open']");
|
||||
await assertNodeDetailDisplay(node, "block", "flex", "selected card node should expose type and weight details");
|
||||
|
||||
const emptyNode = page.locator(".node[data-id='empty-preview']");
|
||||
if (await emptyNode.count()) {
|
||||
await emptyNode.hover();
|
||||
await page.waitForSelector(".graph-hover-preview[data-state='open']");
|
||||
await preview.locator(".graph-hover-preview-title", { hasText: /^空内容节点$/ }).waitFor();
|
||||
await preview.getByText("主题").waitFor();
|
||||
assert.equal(await preview.locator(".graph-hover-preview-summary").count(), 0, "empty content nodes should omit summary text");
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
async function waitForPreviewState(page, state) {
|
||||
await page.waitForFunction((state) => {
|
||||
return document.querySelector(".graph-hover-preview")?.dataset.state === state;
|
||||
}, state);
|
||||
}
|
||||
|
||||
async function assertNodeDetailDisplay(node, expectedKind, expectedMeta, message) {
|
||||
const actual = await node.evaluate((element) => {
|
||||
const kind = element.querySelector(".node-kind");
|
||||
const meta = element.querySelector(".node-meta");
|
||||
const styles = element.ownerDocument.defaultView;
|
||||
return {
|
||||
kind: kind && styles ? styles.getComputedStyle(kind).display : "",
|
||||
meta: meta && styles ? styles.getComputedStyle(meta).display : "",
|
||||
metaText: meta?.textContent?.trim() || ""
|
||||
};
|
||||
});
|
||||
assert.equal(actual.kind, expectedKind, `${message}: node kind display`);
|
||||
assert.equal(actual.meta, expectedMeta, `${message}: node meta display`);
|
||||
assert.match(actual.metaText, /\d+|来源暂不可用/, `${message}: node meta text should remain available`);
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
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 basicHtml = process.env.GRAPH_OFFLINE_PHASE_6_BASIC_HTML || "";
|
||||
const denseHtml = process.env.GRAPH_OFFLINE_PHASE_6_DENSE_HTML || "";
|
||||
const multicommHtml = process.env.GRAPH_OFFLINE_PHASE_6_MULTICOMM_HTML || "";
|
||||
const artifactDir = process.env.GRAPH_OFFLINE_PHASE_6_ARTIFACT_DIR || "";
|
||||
const executablePath = process.env.GRAPH_OFFLINE_PHASE_6_CHROME_EXECUTABLE || "";
|
||||
|
||||
assert.notEqual(basicHtml, "", "GRAPH_OFFLINE_PHASE_6_BASIC_HTML must point at generated basic HTML");
|
||||
assert.notEqual(denseHtml, "", "GRAPH_OFFLINE_PHASE_6_DENSE_HTML must point at generated dense HTML");
|
||||
assert.notEqual(multicommHtml, "", "GRAPH_OFFLINE_PHASE_6_MULTICOMM_HTML must point at generated multicomm HTML");
|
||||
|
||||
const browser = await chromium.launch(executablePath ? { executablePath } : {});
|
||||
try {
|
||||
const basic = await runBasicOfflineChecks(browser);
|
||||
const dense = await runFixtureSmoke(browser, denseHtml, "dense");
|
||||
const multicomm = await runFixtureSmoke(browser, multicommHtml, "multicomm");
|
||||
const evidence = { basic, dense, multicomm };
|
||||
if (artifactDir) {
|
||||
await fs.mkdir(artifactDir, { recursive: true });
|
||||
await fs.writeFile(path.join(artifactDir, "phase-6-offline-interactions.json"), `${JSON.stringify(evidence, null, 2)}\n`);
|
||||
}
|
||||
console.log(JSON.stringify(evidence, null, 2));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
async function runBasicOfflineChecks(browser) {
|
||||
const page = await openOfflinePage(browser, basicHtml, { width: 1440, height: 960 });
|
||||
const evidence = {
|
||||
html: basicHtml,
|
||||
viewport: "1440x960",
|
||||
wheelTargets: {},
|
||||
rootScroll: {},
|
||||
hover: {},
|
||||
drag: {},
|
||||
reader: {},
|
||||
pinPersistence: {},
|
||||
theme: {}
|
||||
};
|
||||
|
||||
evidence.wheelTargets.blank = await assertWheelZoomsFromReset(page, () => findBlankPoint(page), "blank");
|
||||
evidence.wheelTargets.node = await assertWheelZoomsFromReset(page, () => visibleNodeCenter(page), "node");
|
||||
evidence.wheelTargets.communityWash = await assertWheelZoomsFromReset(page, () => findCommunityWashPoint(page, "t1"), "community wash");
|
||||
|
||||
await resetGraphView(page);
|
||||
evidence.rootScroll = await assertGraphRootScrollResets(page);
|
||||
evidence.hover.beforeZoom = await hoverNodeAndMeasure(page, null, {
|
||||
preferCenter: true,
|
||||
roomLeft: 120,
|
||||
roomRight: 360,
|
||||
roomTop: 120,
|
||||
roomBottom: 180
|
||||
});
|
||||
evidence.hover.node = evidence.hover.beforeZoom.target;
|
||||
evidence.hover.zoom = await assertWheelZooms(page, await nodeCenter(page, evidence.hover.node.id), "hovered node");
|
||||
evidence.hover.afterZoom = await hoverNodeAndMeasure(page, evidence.hover.node.id);
|
||||
assert.equal(
|
||||
evidence.hover.afterZoom.target.id,
|
||||
evidence.hover.node.id,
|
||||
"offline hover after zoom should stay on the same node"
|
||||
);
|
||||
assertHoverStaysNearAnchor(evidence.hover.beforeZoom, "offline hover before zoom should stay near its node");
|
||||
assertHoverStaysNearAnchor(evidence.hover.afterZoom, "offline hover after zoom should stay near its node");
|
||||
|
||||
await resetGraphView(page);
|
||||
const dragNode = await visibleNode(page, { roomRight: 360 });
|
||||
evidence.drag = await dragNodeAndMeasure(page, dragNode.id);
|
||||
evidence.pinPersistence = await assertPinPersistsAfterReload(page, dragNode.id);
|
||||
|
||||
evidence.reader = await assertBuiltInReader(page);
|
||||
evidence.theme = await assertThemeToggle(page);
|
||||
|
||||
if (artifactDir) {
|
||||
await page.screenshot({ path: path.join(artifactDir, "phase-6-offline-basic.png"), fullPage: true });
|
||||
}
|
||||
await page.close();
|
||||
return roundObject(evidence);
|
||||
}
|
||||
|
||||
async function runFixtureSmoke(browser, html, name) {
|
||||
const page = await openOfflinePage(browser, html, { width: 1280, height: 860 });
|
||||
const before = await layerTransform(page);
|
||||
const point = await visibleNodeCenter(page);
|
||||
await page.mouse.move(point.x, point.y);
|
||||
await page.mouse.wheel(0, -360);
|
||||
const after = await waitForLayerTransform(page, before);
|
||||
assert.notEqual(after, before, `${name} offline graph should zoom over a node`);
|
||||
const counts = await page.evaluate(() => ({
|
||||
nodes: document.querySelectorAll(".node").length,
|
||||
edges: document.querySelectorAll(".edge").length,
|
||||
communities: document.querySelectorAll(".community-wash").length,
|
||||
hasReactDrawer: Boolean(document.querySelector(".drawer-panel-open"))
|
||||
}));
|
||||
assert.ok(counts.nodes > 0, `${name} offline graph should render nodes`);
|
||||
assert.equal(counts.hasReactDrawer, false, `${name} offline graph should not depend on the React host drawer`);
|
||||
await page.close();
|
||||
return { html, before, after, counts };
|
||||
}
|
||||
|
||||
async function openOfflinePage(browser, html, viewport) {
|
||||
const page = await browser.newPage({ viewport });
|
||||
await page.addInitScript(() => {
|
||||
window.__graphOfflinePhase6 = {
|
||||
round(value) {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
},
|
||||
roundRect(rect) {
|
||||
return {
|
||||
left: this.round(rect.left),
|
||||
top: this.round(rect.top),
|
||||
right: this.round(rect.right),
|
||||
bottom: this.round(rect.bottom),
|
||||
width: this.round(rect.width),
|
||||
height: this.round(rect.height)
|
||||
};
|
||||
},
|
||||
relativeRect(rect, rootRect) {
|
||||
return {
|
||||
left: this.round(rect.left - rootRect.left),
|
||||
top: this.round(rect.top - rootRect.top),
|
||||
right: this.round(rect.right - rootRect.left),
|
||||
bottom: this.round(rect.bottom - rootRect.top),
|
||||
width: this.round(rect.width),
|
||||
height: this.round(rect.height)
|
||||
};
|
||||
},
|
||||
relativePoint(point, rootRect) {
|
||||
return {
|
||||
x: this.round(point.x - rootRect.left),
|
||||
y: this.round(point.y - rootRect.top)
|
||||
};
|
||||
}
|
||||
};
|
||||
});
|
||||
await page.goto(pathToFileURL(html).href);
|
||||
await page.waitForSelector("[data-llm-wiki-graph-root='true']");
|
||||
await page.waitForSelector("[data-viewport-layer='true']");
|
||||
await page.waitForSelector(".node");
|
||||
return page;
|
||||
}
|
||||
|
||||
async function assertWheelZooms(page, point, label) {
|
||||
const before = await layerTransform(page);
|
||||
await page.mouse.move(point.x, point.y);
|
||||
await page.mouse.wheel(0, -420);
|
||||
const after = await waitForLayerTransform(page, before);
|
||||
assert.notEqual(after, before, `offline wheel over ${label} should zoom`);
|
||||
return { before, after, point: roundedPoint(point) };
|
||||
}
|
||||
|
||||
async function assertWheelZoomsFromReset(page, pointFactory, label) {
|
||||
await resetGraphView(page);
|
||||
await waitForGraphMotionSettled(page);
|
||||
const point = await pointFactory();
|
||||
return assertWheelZooms(page, point, label);
|
||||
}
|
||||
|
||||
async function hoverNodeAndMeasure(page, preferredId = null, targetOptions = {}) {
|
||||
const target = await visibleNode(page, { ...targetOptions, preferredId });
|
||||
const id = target.id;
|
||||
await waitForNodeMotionSettled(page, id);
|
||||
let lastProbe = null;
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
await movePointerAwayFromGraphObjects(page);
|
||||
await waitForNodeMotionSettled(page, id);
|
||||
const point = await nodeCenter(page, id);
|
||||
await page.mouse.move(point.x, point.y, { steps: 4 });
|
||||
await page.waitForTimeout(450);
|
||||
const probe = await hoverProbe(page, id, point);
|
||||
lastProbe = probe;
|
||||
if (probe.preview.state === "open" && probe.preview.kind === "node" && probe.hit.closestNode === id) break;
|
||||
}
|
||||
assert.ok(
|
||||
lastProbe?.preview.state === "open" && lastProbe.preview.kind === "node" && lastProbe.hit.closestNode === id,
|
||||
`offline hover preview for ${id} should open after pointer enters node: ${JSON.stringify(lastProbe)}`
|
||||
);
|
||||
await page.locator(".graph-hover-preview-title").waitFor();
|
||||
const measurement = await page.evaluate((id) => {
|
||||
const root = document.querySelector("[data-llm-wiki-graph-root='true']");
|
||||
const node = document.querySelector(`.node[data-id="${CSS.escape(id)}"]`);
|
||||
const preview = document.querySelector(".graph-hover-preview");
|
||||
if (!root || !node || !preview) throw new Error("Missing hover measurement elements");
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const nodeRect = node.getBoundingClientRect();
|
||||
const previewRect = preview.getBoundingClientRect();
|
||||
const nodeAnchor = {
|
||||
x: nodeRect.left + nodeRect.width / 2,
|
||||
y: nodeRect.top + nodeRect.height / 2
|
||||
};
|
||||
return {
|
||||
target: {
|
||||
id,
|
||||
label: node.querySelector(".node-name")?.textContent || node.textContent?.trim() || id
|
||||
},
|
||||
nodeAnchor: window.__graphOfflinePhase6.relativePoint(nodeAnchor, rootRect),
|
||||
preview: window.__graphOfflinePhase6.relativeRect(previewRect, rootRect),
|
||||
offset: {
|
||||
x: previewRect.left - nodeAnchor.x,
|
||||
y: previewRect.bottom - nodeAnchor.y
|
||||
}
|
||||
};
|
||||
}, id);
|
||||
await assertBoxInsideViewport(page, ".graph-hover-preview", `offline hover preview for ${id}`);
|
||||
await assertGraphRootNotScrolled(page, `offline hover preview for ${id}`);
|
||||
return roundObject({ ...measurement, target });
|
||||
}
|
||||
|
||||
async function hoverProbe(page, id, point) {
|
||||
return page.evaluate(({ id, point }) => {
|
||||
const root = document.querySelector("[data-llm-wiki-graph-root='true']");
|
||||
const node = document.querySelector(`.node[data-id="${CSS.escape(id)}"]`);
|
||||
const preview = document.querySelector(".graph-hover-preview");
|
||||
if (!root || !node || !preview) throw new Error("Missing hover probe elements");
|
||||
const hit = document.elementFromPoint(point.x, point.y);
|
||||
const nodeRect = node.getBoundingClientRect();
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
return {
|
||||
point: window.__graphOfflinePhase6.relativePoint(point, rootRect),
|
||||
rootScroll: { left: root.scrollLeft, top: root.scrollTop },
|
||||
nodeRect: window.__graphOfflinePhase6.relativeRect(nodeRect, rootRect),
|
||||
nodeHovered: node.matches(":hover"),
|
||||
hit: {
|
||||
tag: hit?.tagName || "",
|
||||
className: typeof hit?.className === "string" ? hit.className : hit?.className?.baseVal || "",
|
||||
closestNode: hit?.closest?.(".node")?.dataset?.id || "",
|
||||
closestWash: hit?.closest?.(".community-wash")?.dataset?.communityId || ""
|
||||
},
|
||||
preview: {
|
||||
state: preview.dataset.state || "",
|
||||
kind: preview.dataset.kind || "",
|
||||
textLength: preview.textContent?.length || 0
|
||||
}
|
||||
};
|
||||
}, { id, point });
|
||||
}
|
||||
|
||||
async function movePointerAwayFromGraphObjects(page) {
|
||||
const point = await findBlankPoint(page);
|
||||
await page.mouse.move(point.x, point.y, { steps: 3 });
|
||||
await page.waitForTimeout(80);
|
||||
}
|
||||
|
||||
async function dragNodeAndMeasure(page, id) {
|
||||
await waitForNodeMotionSettled(page, id);
|
||||
const start = await nodeCenter(page, id);
|
||||
const target = { x: start.x + 360, y: start.y + 28 };
|
||||
await page.mouse.move(start.x, start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(target.x, target.y, { steps: 8 });
|
||||
await page.waitForTimeout(80);
|
||||
const during = await nodeCenter(page, id);
|
||||
const pointerDistance = Math.hypot(during.x - target.x, during.y - target.y);
|
||||
assert.ok(pointerDistance <= 48, `offline dragged node should stay under pointer, distance=${pointerDistance.toFixed(1)}`);
|
||||
await page.mouse.up();
|
||||
await page.waitForSelector(`.node[data-id="${cssString(id)}"][data-pinned="true"]`);
|
||||
await page.waitForTimeout(140);
|
||||
const after = await page.locator(`.node[data-id="${cssString(id)}"]`).evaluate((node) => {
|
||||
const root = document.querySelector("[data-llm-wiki-graph-root='true']");
|
||||
if (!root) throw new Error("Missing root after drag");
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const rect = node.getBoundingClientRect();
|
||||
return {
|
||||
center: {
|
||||
x: rect.left + rect.width / 2 - rootRect.left,
|
||||
y: rect.top + rect.height / 2 - rootRect.top
|
||||
},
|
||||
pinned: node.dataset.pinned,
|
||||
dragging: root.dataset.dragging || ""
|
||||
};
|
||||
});
|
||||
assert.equal(after.pinned, "true", "offline drag should commit a pin");
|
||||
assert.equal(after.dragging, "", "offline drag should not remain stuck");
|
||||
return roundObject({ start, target, during, pointerDistance, after });
|
||||
}
|
||||
|
||||
async function assertPinPersistsAfterReload(page, id) {
|
||||
const key = await page.evaluate(() => window.__LLM_WIKI_GRAPH_PINS_KEY__ || "");
|
||||
assert.notEqual(key, "", "offline graph pins key should be exposed");
|
||||
const before = await page.evaluate((key) => JSON.parse(window.localStorage.getItem(key) || "{}"), key);
|
||||
assert.ok(Object.keys(before).length >= 1, "offline drag should write at least one pin");
|
||||
for (const pin of Object.values(before)) {
|
||||
assert.equal(pin.coordinateSpace, "world", "offline persisted pins should be explicit world coordinates");
|
||||
}
|
||||
await page.reload();
|
||||
await page.waitForSelector("[data-llm-wiki-graph-root='true']");
|
||||
await page.waitForSelector(`.node[data-id="${cssString(id)}"][data-pinned="true"]`);
|
||||
const pinnedCount = await page.locator("[data-llm-wiki-graph-root='true']").evaluate((root) => root.dataset.pinnedCount || "");
|
||||
assert.equal(pinnedCount, "1", "offline pin should survive reload");
|
||||
const unmarkedWorldPin = { x: 333, y: 222 };
|
||||
const pinKey = await nodePinKey(page, id);
|
||||
await page.evaluate(({ key, id, pin }) => {
|
||||
window.localStorage.setItem(key, JSON.stringify({
|
||||
[id]: pin
|
||||
}));
|
||||
}, { key, id: pinKey, pin: unmarkedWorldPin });
|
||||
await page.reload();
|
||||
await page.waitForSelector("[data-llm-wiki-graph-root='true']");
|
||||
await page.waitForSelector(`.node[data-id="${cssString(id)}"][data-pinned="true"]`);
|
||||
await resetGraphView(page);
|
||||
await page.waitForTimeout(120);
|
||||
const unmarkedWorldCenter = await page.locator(`.node[data-id="${cssString(id)}"]`).evaluate((node) => {
|
||||
return {
|
||||
worldPoint: {
|
||||
x: Number(node.dataset.worldX || node.dataset.liveX || Number.NaN),
|
||||
y: Number(node.dataset.worldY || node.dataset.liveY || Number.NaN)
|
||||
},
|
||||
pinned: node.dataset.pinned || ""
|
||||
};
|
||||
});
|
||||
assert.equal(unmarkedWorldCenter.pinned, "true", "offline unmarked localStorage pin should reload as pinned");
|
||||
const worldDistance = Math.hypot(
|
||||
unmarkedWorldCenter.worldPoint.x - unmarkedWorldPin.x,
|
||||
unmarkedWorldCenter.worldPoint.y - unmarkedWorldPin.y
|
||||
);
|
||||
assert.ok(
|
||||
worldDistance <= 0.01,
|
||||
`offline unmarked localStorage pin should stay in world coordinates, distance=${worldDistance.toFixed(2)}`
|
||||
);
|
||||
return { key, storedPins: before, pinnedCount, unmarkedWorldPin, pinKey, unmarkedWorldCenter, worldDistance };
|
||||
}
|
||||
|
||||
async function assertBuiltInReader(page, id) {
|
||||
await resetGraphView(page);
|
||||
const target = id ? await visibleNode(page, { preferredId: id }) : await visibleNode(page);
|
||||
await clickNodeByPointer(page, target.id);
|
||||
await page.waitForSelector(".graph-reader[data-state='open']");
|
||||
await page.locator(".graph-reader-title", { hasText: target.label }).waitFor();
|
||||
await page.locator(".graph-reader-body").waitFor();
|
||||
assert.equal(await page.locator(".drawer-panel-open").count(), 0, "offline graph should not open the React host drawer");
|
||||
const snapshot = await page.locator(".graph-reader[data-state='open']").evaluate((reader) => ({
|
||||
title: reader.querySelector(".graph-reader-title")?.textContent || "",
|
||||
bodyLength: reader.querySelector(".graph-reader-body")?.textContent?.length || 0,
|
||||
hasHostDrawer: Boolean(document.querySelector(".drawer-panel-open"))
|
||||
}));
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForSelector(".graph-reader[data-state='closed']");
|
||||
return { ...snapshot, target };
|
||||
}
|
||||
|
||||
async function assertThemeToggle(page) {
|
||||
const before = await graphTheme(page);
|
||||
await page.getByRole("button", { name: before === "shan-shui" ? "切换墨夜主题" : "切换山水主题" }).click();
|
||||
const after = await waitForGraphThemeChange(page, before);
|
||||
await page.getByRole("button", { name: after === "shan-shui" ? "切换墨夜主题" : "切换山水主题" }).click();
|
||||
const restored = await waitForGraphThemeChange(page, after);
|
||||
assert.equal(restored, before, "offline theme toggle should restore the original theme");
|
||||
return { before, after, restored };
|
||||
}
|
||||
|
||||
async function assertGraphRootScrollResets(page) {
|
||||
const before = await graphRootScroll(page);
|
||||
await page.locator("[data-llm-wiki-graph-root='true']").evaluate((root) => {
|
||||
root.scrollLeft = 120;
|
||||
root.scrollTop = 240;
|
||||
});
|
||||
await page.waitForFunction(() => {
|
||||
const root = document.querySelector("[data-llm-wiki-graph-root='true']");
|
||||
return root && root.scrollLeft === 0 && root.scrollTop === 0;
|
||||
});
|
||||
const after = await graphRootScroll(page);
|
||||
assert.deepEqual(after, { left: 0, top: 0 }, "offline graph root native scroll should reset to zero");
|
||||
return { before, after };
|
||||
}
|
||||
|
||||
async function assertGraphRootNotScrolled(page, label) {
|
||||
const scroll = await graphRootScroll(page);
|
||||
assert.deepEqual(scroll, { left: 0, top: 0 }, `${label}: graph root native scroll should stay at zero`);
|
||||
}
|
||||
|
||||
async function resetGraphView(page) {
|
||||
const before = await layerTransform(page);
|
||||
await page.getByRole("button", { name: "回全图" }).click();
|
||||
await page.waitForTimeout(220);
|
||||
const after = await layerTransform(page);
|
||||
if (after === before) await page.waitForTimeout(120);
|
||||
}
|
||||
|
||||
async function layerTransform(page) {
|
||||
return page.locator("[data-viewport-layer='true']").evaluate((element) => element.style.transform);
|
||||
}
|
||||
|
||||
async function waitForLayerTransform(page, previous) {
|
||||
await page.waitForFunction(
|
||||
(previous) => document.querySelector("[data-viewport-layer='true']")?.style.transform !== previous,
|
||||
previous,
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
return layerTransform(page);
|
||||
}
|
||||
|
||||
async function nodeCenter(page, id) {
|
||||
return page.locator(`.node[data-id="${cssString(id)}"]`).evaluate((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 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 visibleNodeCenter(page, preferredId = null, options = {}) {
|
||||
const targetOptions = typeof preferredId === "object" && preferredId !== null
|
||||
? preferredId
|
||||
: { ...options, preferredId };
|
||||
const node = await visibleNode(page, targetOptions);
|
||||
return node.center;
|
||||
}
|
||||
|
||||
async function visibleNode(page, options = {}) {
|
||||
const {
|
||||
preferredId = null,
|
||||
roomLeft = 0,
|
||||
roomRight = 0,
|
||||
roomTop = 0,
|
||||
roomBottom = 0,
|
||||
preferCenter = false
|
||||
} = options;
|
||||
await waitForGraphMotionSettled(page);
|
||||
const candidate = await page.evaluate((options) => {
|
||||
const { preferredId, roomLeft, roomRight, roomTop, roomBottom, preferCenter } = options;
|
||||
const root = document.querySelector("[data-llm-wiki-graph-root='true']");
|
||||
if (!root) throw new Error("Missing graph root");
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const minX = Math.max(rootRect.left + roomLeft, 0);
|
||||
const maxX = Math.min(rootRect.right - roomRight, viewportWidth);
|
||||
const minY = Math.max(rootRect.top + roomTop, 0);
|
||||
const maxY = Math.min(rootRect.bottom - roomBottom, viewportHeight);
|
||||
const ideal = {
|
||||
x: minX + Math.max(0, maxX - minX) / 2,
|
||||
y: minY + Math.max(0, maxY - minY) / 2
|
||||
};
|
||||
const nodes = Array.from(document.querySelectorAll(".node"));
|
||||
const measurements = nodes.map((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
const center = {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2
|
||||
};
|
||||
const hit = document.elementFromPoint(center.x, center.y);
|
||||
const centerHitsNode = hit?.closest?.(".node") === node;
|
||||
const visibleLeft = Math.max(rect.left, minX);
|
||||
const visibleRight = Math.min(rect.right, maxX);
|
||||
const visibleTop = Math.max(rect.top, minY);
|
||||
const visibleBottom = Math.min(rect.bottom, maxY);
|
||||
const visibleArea = Math.max(0, visibleRight - visibleLeft) * Math.max(0, visibleBottom - visibleTop);
|
||||
const centerInViewport = center.x >= rootRect.left
|
||||
&& center.x >= minX
|
||||
&& center.x <= maxX
|
||||
&& center.y >= minY
|
||||
&& center.y <= maxY;
|
||||
const centerDistance = Math.hypot(center.x - ideal.x, center.y - ideal.y);
|
||||
return {
|
||||
id: node.dataset.id || "",
|
||||
label: node.querySelector(".node-name")?.textContent || node.textContent?.trim() || node.dataset.id || "",
|
||||
center,
|
||||
rect: window.__graphOfflinePhase6.relativeRect(rect, rootRect),
|
||||
visibleArea,
|
||||
centerDistance,
|
||||
centerHitsNode,
|
||||
centerInViewport
|
||||
};
|
||||
}).filter((item) => item.id && item.visibleArea > 0);
|
||||
const preferred = preferredId
|
||||
? measurements.find((item) => item.id === preferredId && item.centerInViewport && item.centerHitsNode)
|
||||
: null;
|
||||
if (preferred) return preferred;
|
||||
const best = measurements
|
||||
.filter((item) => item.centerInViewport && item.centerHitsNode)
|
||||
.sort((a, b) => preferCenter
|
||||
? a.centerDistance - b.centerDistance || b.visibleArea - a.visibleArea
|
||||
: b.visibleArea - a.visibleArea || a.centerDistance - b.centerDistance
|
||||
)[0];
|
||||
if (best) return best;
|
||||
throw new Error(`Could not find an interactable visible node: ${JSON.stringify(measurements)}`);
|
||||
}, { preferredId, roomLeft, roomRight, roomTop, roomBottom, preferCenter });
|
||||
return roundObject(candidate);
|
||||
}
|
||||
|
||||
async function clickNodeByPointer(page, id) {
|
||||
const node = await visibleNode(page, { preferredId: id });
|
||||
await movePointerAwayFromGraphObjects(page);
|
||||
await page.mouse.move(node.center.x, node.center.y, { steps: 4 });
|
||||
await page.mouse.down();
|
||||
await page.mouse.up();
|
||||
return node;
|
||||
}
|
||||
|
||||
async function waitForGraphMotionSettled(page) {
|
||||
const firstId = await firstNodeId(page);
|
||||
await waitForNodeMotionSettled(page, firstId);
|
||||
}
|
||||
|
||||
async function waitForNodeMotionSettled(page, id) {
|
||||
let previous = await nodeCenter(page, id);
|
||||
for (let sample = 0; sample < 12; sample += 1) {
|
||||
await page.waitForTimeout(140);
|
||||
const next = await nodeCenter(page, id);
|
||||
const distance = Math.hypot(next.x - previous.x, next.y - previous.y);
|
||||
if (distance <= 1.5) return;
|
||||
previous = next;
|
||||
}
|
||||
}
|
||||
|
||||
async function firstNodeId(page) {
|
||||
return page.locator(".node").first().evaluate((node) => node.dataset.id || "");
|
||||
}
|
||||
|
||||
async function findBlankPoint(page) {
|
||||
return page.evaluate(() => {
|
||||
const root = document.querySelector("[data-llm-wiki-graph-root='true']");
|
||||
if (!root) throw new Error("Missing graph root");
|
||||
const rect = root.getBoundingClientRect();
|
||||
const blocked = ".node,.community-wash,.edge,.graph-toolbar,.mini-map,.graph-search,.graph-reader,.graph-selection-panel";
|
||||
const xs = [0.08, 0.16, 0.28, 0.42, 0.58, 0.74, 0.88];
|
||||
const ys = [0.18, 0.32, 0.48, 0.64, 0.8, 0.9];
|
||||
for (const ry of ys) {
|
||||
for (const rx of xs) {
|
||||
const x = rect.left + rect.width * rx;
|
||||
const y = rect.top + rect.height * ry;
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
if (!hit || !root.contains(hit)) continue;
|
||||
if (hit.closest(blocked)) continue;
|
||||
return { x, y };
|
||||
}
|
||||
}
|
||||
throw new Error("Could not find blank graph point");
|
||||
});
|
||||
}
|
||||
|
||||
async function findCommunityWashPoint(page, communityId) {
|
||||
return page.evaluate((communityId) => {
|
||||
const wash = document.querySelector(`.community-wash[data-community-id="${CSS.escape(communityId)}"]`) || document.querySelector(".community-wash");
|
||||
if (!wash) throw new Error("Missing community wash");
|
||||
const rect = wash.getBoundingClientRect();
|
||||
const candidates = [
|
||||
[0.5, 0.18],
|
||||
[0.2, 0.5],
|
||||
[0.8, 0.5],
|
||||
[0.5, 0.82],
|
||||
[0.32, 0.32],
|
||||
[0.68, 0.68],
|
||||
[0.5, 0.5]
|
||||
];
|
||||
for (const [rx, ry] of candidates) {
|
||||
const x = rect.left + rect.width * rx;
|
||||
const y = rect.top + rect.height * ry;
|
||||
if (document.elementFromPoint(x, y)?.closest?.(".community-wash") === wash) {
|
||||
return { x, y, communityId: wash.dataset.communityId || "" };
|
||||
}
|
||||
}
|
||||
throw new Error("Could not find exposed community wash point");
|
||||
}, communityId);
|
||||
}
|
||||
|
||||
async function assertBoxInsideViewport(page, selector, label) {
|
||||
const box = await page.locator(selector).first().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`);
|
||||
}
|
||||
|
||||
async function graphRootScroll(page) {
|
||||
return page.locator("[data-llm-wiki-graph-root='true']").evaluate((root) => ({
|
||||
left: root.scrollLeft,
|
||||
top: root.scrollTop
|
||||
}));
|
||||
}
|
||||
|
||||
async function graphTheme(page) {
|
||||
return page.locator(".llm-wiki-graph-engine").evaluate((root) => root.dataset.theme || "");
|
||||
}
|
||||
|
||||
async function waitForGraphThemeChange(page, previous) {
|
||||
await page.waitForFunction((previous) => {
|
||||
const next = document.querySelector(".llm-wiki-graph-engine")?.dataset.theme || "";
|
||||
return next && next !== previous;
|
||||
}, previous);
|
||||
return graphTheme(page);
|
||||
}
|
||||
|
||||
function assertHoverStaysNearAnchor(measurement, message) {
|
||||
const preview = measurement.preview;
|
||||
const anchor = measurement.nodeAnchor;
|
||||
const horizontalGap = preview.left > anchor.x
|
||||
? preview.left - anchor.x
|
||||
: anchor.x > preview.right
|
||||
? anchor.x - preview.right
|
||||
: 0;
|
||||
const verticalGap = preview.top > anchor.y
|
||||
? preview.top - anchor.y
|
||||
: anchor.y > preview.bottom
|
||||
? anchor.y - preview.bottom
|
||||
: 0;
|
||||
assert.ok(horizontalGap <= 180, `${message}: horizontal gap ${horizontalGap.toFixed(2)}px`);
|
||||
assert.ok(verticalGap <= 140, `${message}: vertical gap ${verticalGap.toFixed(2)}px`);
|
||||
}
|
||||
|
||||
function cssString(value) {
|
||||
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function roundedPoint(point) {
|
||||
return {
|
||||
x: round(point.x),
|
||||
y: round(point.y)
|
||||
};
|
||||
}
|
||||
|
||||
function roundObject(value) {
|
||||
if (Array.isArray(value)) return value.map(roundObject);
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, roundObject(item)]));
|
||||
}
|
||||
if (typeof value === "number") return round(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function round(value) {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import type { LargeGraphFixtureId, LargeGraphFixtureMetadata } from "../../packages/graph-engine/test/large-graph-fixtures";
|
||||
|
||||
export const FULL_TRIAL_SHAPES: LargeGraphFixtureId[] = [
|
||||
"real-snapshot-proxy",
|
||||
"nodes-1000-sparse",
|
||||
"nodes-1000-dense",
|
||||
"nodes-1000-many-communities",
|
||||
"nodes-5000-sparse",
|
||||
"nodes-5000-dense",
|
||||
"nodes-10000-aggregation",
|
||||
"nodes-10000-high-edge",
|
||||
"oversized-community",
|
||||
"many-small-communities",
|
||||
"many-search-hits",
|
||||
"many-pin-nodes"
|
||||
];
|
||||
|
||||
export const REQUIRED_TRIAL_ACTIONS = [
|
||||
"initial_render",
|
||||
"wheel_zoom",
|
||||
"drag",
|
||||
"search_highlight",
|
||||
"point_select",
|
||||
"container_select",
|
||||
"spotlight_animation",
|
||||
"drawer_open",
|
||||
"enter_community",
|
||||
"return_global",
|
||||
"repeated_search_community_drawer_cycles"
|
||||
] as const;
|
||||
|
||||
export type TrialAction = typeof REQUIRED_TRIAL_ACTIONS[number];
|
||||
|
||||
// Actions where fps + frame p95 are mandatory-to-measure (wheel/drag).
|
||||
export const FRAME_SAMPLED_ACTIONS = new Set<string>(["wheel_zoom", "drag", "spotlight_animation"]);
|
||||
// Actions where wall-clock duration has an upper bound per the hard-gate table.
|
||||
export const DURATION_GATED_ACTIONS = new Set<string>([
|
||||
"initial_render",
|
||||
"search_highlight",
|
||||
"drawer_open",
|
||||
"return_global"
|
||||
]);
|
||||
// The memory-gated repeated-cycle action.
|
||||
export const MEMORY_GATED_ACTION = "repeated_search_community_drawer_cycles";
|
||||
|
||||
export const TRIAL_SCHEMA_VERSION = "1.0.0";
|
||||
|
||||
// Hard-gate table from the plan. fps and frame p95 are uniform across scales;
|
||||
// duration and memory ceilings scale with node count.
|
||||
export const FPS_FLOOR = 45;
|
||||
export const FRAME_P95_CEILING_MS = 22.3;
|
||||
|
||||
export interface TrialRecordLike {
|
||||
graph_shape: string;
|
||||
action: string;
|
||||
pass: boolean;
|
||||
nodes?: number | null;
|
||||
fps?: number | null;
|
||||
frame_p95_ms?: number | null;
|
||||
duration_ms?: number | null;
|
||||
memory_growth_mb?: number | null;
|
||||
failure_class: string | null;
|
||||
failure_detail?: string | null;
|
||||
schema_version?: string;
|
||||
production_path?: boolean;
|
||||
thresholds?: Record<string, number> | null;
|
||||
browser?: string | null;
|
||||
build_commit?: string | null;
|
||||
run_started_at?: string | null;
|
||||
run_finished_at?: string | null;
|
||||
}
|
||||
|
||||
export function parseRequestedShapes(value: string | undefined): LargeGraphFixtureId[] {
|
||||
return (value || FULL_TRIAL_SHAPES.join(","))
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean) as LargeGraphFixtureId[];
|
||||
}
|
||||
|
||||
export async function waitForAnimationFrames(page: PageLike, count = 2): Promise<void> {
|
||||
await page.evaluate((frameCount: number) => new Promise<void>((resolve) => {
|
||||
let remaining = Math.max(1, frameCount);
|
||||
const tick = () => {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}), count);
|
||||
}
|
||||
|
||||
export async function waitForAfterDrawing(page: PageLike): Promise<void> {
|
||||
await page.evaluate(() => new Promise<void>((resolve, reject) => {
|
||||
const trial = (window as any).__visTrial;
|
||||
if (!trial?.network?.once) {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
return;
|
||||
}
|
||||
let finished = false;
|
||||
const timeout = window.setTimeout(() => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
reject(new Error("vis-network afterDrawing did not fire after action"));
|
||||
}, 2500);
|
||||
const finish = () => requestAnimationFrame(() => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
window.clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
trial.network.once("afterDrawing", finish);
|
||||
trial.network.redraw();
|
||||
}));
|
||||
}
|
||||
|
||||
export function memoryGrowthLimitMb(metadata: LargeGraphFixtureMetadata): number {
|
||||
if (metadata.nodes >= 10000) return 100;
|
||||
if (metadata.nodes >= 5000) return 75;
|
||||
return 50;
|
||||
}
|
||||
|
||||
export function memoryGrowthFailureClass(memoryGrowthMb: number | null, metadata: LargeGraphFixtureMetadata): string | null {
|
||||
if (memoryGrowthMb == null) return null;
|
||||
return memoryGrowthMb <= memoryGrowthLimitMb(metadata) ? null : "memory_growth_above_floor";
|
||||
}
|
||||
|
||||
export function memoryGrowthFailureDetail(memoryGrowthMb: number | null, metadata: LargeGraphFixtureMetadata): string | null {
|
||||
if (memoryGrowthMb == null) return "memory_growth_unavailable";
|
||||
const limit = memoryGrowthLimitMb(metadata);
|
||||
return memoryGrowthMb <= limit ? null : `memory_growth_mb=${memoryGrowthMb}; limit_mb=${limit}`;
|
||||
}
|
||||
|
||||
export function initialRenderLimitMs(metadata: LargeGraphFixtureMetadata): number {
|
||||
if (metadata.nodes >= 10000) return 2000;
|
||||
if (metadata.nodes >= 5000) return 1200;
|
||||
return 500;
|
||||
}
|
||||
|
||||
export function searchHighlightLimitMs(metadata: LargeGraphFixtureMetadata): number {
|
||||
if (metadata.nodes >= 10000) return 700;
|
||||
if (metadata.nodes >= 5000) return 400;
|
||||
return 200;
|
||||
}
|
||||
|
||||
export function drawerOpenLimitMs(metadata: LargeGraphFixtureMetadata): number {
|
||||
if (metadata.nodes >= 10000) return 500;
|
||||
if (metadata.nodes >= 5000) return 400;
|
||||
return 200;
|
||||
}
|
||||
|
||||
export function returnGlobalLimitMs(metadata: LargeGraphFixtureMetadata): number {
|
||||
if (metadata.nodes >= 10000) return 800;
|
||||
if (metadata.nodes >= 5000) return 500;
|
||||
return 250;
|
||||
}
|
||||
|
||||
export function durationLimitMs(metadata: LargeGraphFixtureMetadata, action: string): number | null {
|
||||
switch (action) {
|
||||
case "initial_render":
|
||||
return initialRenderLimitMs(metadata);
|
||||
case "search_highlight":
|
||||
return searchHighlightLimitMs(metadata);
|
||||
case "drawer_open":
|
||||
return drawerOpenLimitMs(metadata);
|
||||
case "return_global":
|
||||
return returnGlobalLimitMs(metadata);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Build the per-action thresholds object the schema requires. It records the
|
||||
// fps, frame p95, duration and memory ceilings actually applied so reviewers
|
||||
// can audit each record without re-deriving them from node count.
|
||||
export function actionThresholds(metadata: LargeGraphFixtureMetadata, action: string): Record<string, number> {
|
||||
const thresholds: Record<string, number> = {
|
||||
fps_floor: FPS_FLOOR,
|
||||
frame_p95_ms_ceiling: FRAME_P95_CEILING_MS
|
||||
};
|
||||
const duration = durationLimitMs(metadata, action);
|
||||
if (duration != null) thresholds.duration_ms_ceiling = duration;
|
||||
thresholds.memory_growth_mb_ceiling = memoryGrowthLimitMb(metadata);
|
||||
return thresholds;
|
||||
}
|
||||
|
||||
// esbuild (used by tsx with keepNames:true) wraps named functions in a __name
|
||||
// helper. When such a function is serialized into the browser via page.evaluate
|
||||
// the helper is undefined there. Shim it to a no-op on each page so the
|
||||
// serialized arrow functions resolve. Applied through addInitScript-equivalent.
|
||||
export const NAME_HELPER_INIT_SCRIPT = "window.__name = typeof window.__name === 'function' ? window.__name : function (fn) { return fn; };";
|
||||
|
||||
// Judge a frame-sampled action (wheel/drag). Returns a failure_class string when
|
||||
// the mandatory fps / frame-p95 metrics breach the hard gate, or null when clean.
|
||||
export function frameSampleFailureClass(
|
||||
record: { fps?: number | null; frame_p95_ms?: number | null }
|
||||
): string | null {
|
||||
if (record.fps == null) return "fps_missing";
|
||||
if (record.frame_p95_ms == null) return "frame_p95_missing";
|
||||
if (record.fps < FPS_FLOOR) return "fps_below_floor";
|
||||
if (record.frame_p95_ms > FRAME_P95_CEILING_MS) return "frame_p95_above_ceiling";
|
||||
return null;
|
||||
}
|
||||
|
||||
// Judge a duration-gated action. Null metrics fail; over-budget durations fail.
|
||||
export function durationFailureClass(
|
||||
record: { duration_ms?: number | null },
|
||||
metadata: LargeGraphFixtureMetadata,
|
||||
action: string
|
||||
): string | null {
|
||||
if (record.duration_ms == null) return "duration_missing";
|
||||
const limit = durationLimitMs(metadata, action);
|
||||
if (limit != null && record.duration_ms > limit) return "duration_above_ceiling";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateTrialResults(input: {
|
||||
renderer: string;
|
||||
requestedShapes: readonly string[];
|
||||
requiredActions?: readonly string[];
|
||||
// Default true: enforce the hard-gate schema (schema_version, thresholds,
|
||||
// browser, build_commit, run timestamps) and frame/duration mandatory metrics.
|
||||
// Sibling trials that have not yet been upgraded opt out with requireSchema:false.
|
||||
requireSchema?: boolean;
|
||||
records: readonly TrialRecordLike[];
|
||||
errors: readonly string[];
|
||||
resultPath: string;
|
||||
}): void {
|
||||
const requiredActions = input.requiredActions ?? REQUIRED_TRIAL_ACTIONS;
|
||||
const requireSchema = input.requireSchema !== false;
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const error of input.errors) {
|
||||
failures.push(`error: ${error}`);
|
||||
}
|
||||
|
||||
for (const shape of input.requestedShapes) {
|
||||
const shapeRecords = input.records.filter((record) => record.graph_shape === shape);
|
||||
if (!shapeRecords.length) {
|
||||
failures.push(`${shape}: no records`);
|
||||
continue;
|
||||
}
|
||||
for (const action of requiredActions) {
|
||||
if (!shapeRecords.some((record) => record.action === action)) {
|
||||
failures.push(`${shape}: missing action ${action}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (requireSchema) for (const record of input.records) {
|
||||
const shapeAction = `${record.graph_shape}/${record.action}`;
|
||||
// Schema completeness: every record must carry the documented fields.
|
||||
if (!record.schema_version) failures.push(`${shapeAction}: missing schema_version`);
|
||||
if (typeof record.production_path !== "boolean") failures.push(`${shapeAction}: missing production_path`);
|
||||
if (!record.thresholds) failures.push(`${shapeAction}: missing thresholds`);
|
||||
if (!record.browser) failures.push(`${shapeAction}: missing browser`);
|
||||
if (!record.build_commit) failures.push(`${shapeAction}: missing build_commit`);
|
||||
if (!record.run_started_at) failures.push(`${shapeAction}: missing run_started_at`);
|
||||
if (!record.run_finished_at) failures.push(`${shapeAction}: missing run_finished_at`);
|
||||
|
||||
// Mandatory-metric gates: a null or not-run value on a mandatory metric
|
||||
// blocks pass (the artifact is treated as incomplete), per the hard gate.
|
||||
if (FRAME_SAMPLED_ACTIONS.has(record.action)) {
|
||||
const frameFailure = frameSampleFailureClass(record);
|
||||
if (frameFailure) failures.push(`${shapeAction}: ${frameFailure}; fps=${record.fps ?? "null"}; frame_p95_ms=${record.frame_p95_ms ?? "null"}`);
|
||||
}
|
||||
if (DURATION_GATED_ACTIONS.has(record.action)) {
|
||||
const metadata = { nodes: typeof record.nodes === "number" ? record.nodes : 0 };
|
||||
const durationFailure = durationFailureClass(record, metadata, record.action);
|
||||
if (durationFailure) failures.push(`${shapeAction}: ${durationFailure}; duration_ms=${record.duration_ms ?? "null"}`);
|
||||
}
|
||||
if (record.action === MEMORY_GATED_ACTION && record.memory_growth_mb == null) {
|
||||
failures.push(`${shapeAction}: memory_growth_missing`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const record of input.records) {
|
||||
if (record.pass === false || record.failure_class) {
|
||||
failures.push(`${record.graph_shape}/${record.action}: pass=${record.pass}; failure=${record.failure_class ?? "none"}; detail=${record.failure_detail ?? "none"}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
throw new Error(`${input.renderer} trial failed validation (${failures.length} issue(s)). result=${input.resultPath}\n${failures.slice(0, 40).join("\n")}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
interface PageLike {
|
||||
evaluate<T>(fn: Function | string, arg?: unknown): Promise<T>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,949 @@
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { buildCommunityAggregationMarkers } from "../../packages/graph-engine/src";
|
||||
import {
|
||||
generateLargeGraphFixture,
|
||||
type LargeGraphFixtureMetadata
|
||||
} from "../../packages/graph-engine/test/large-graph-fixtures";
|
||||
import { buildSigmaGraphologyTrialModel } from "../../packages/graph-engine/test/sigma-trial-adapter";
|
||||
import {
|
||||
FRAME_P95_CEILING_MS,
|
||||
FPS_FLOOR,
|
||||
NAME_HELPER_INIT_SCRIPT,
|
||||
TRIAL_SCHEMA_VERSION,
|
||||
actionThresholds,
|
||||
DURATION_GATED_ACTIONS,
|
||||
durationFailureClass,
|
||||
durationLimitMs,
|
||||
frameSampleFailureClass,
|
||||
memoryGrowthFailureClass,
|
||||
memoryGrowthFailureDetail,
|
||||
parseRequestedShapes,
|
||||
validateTrialResults,
|
||||
waitForAnimationFrames
|
||||
} from "./graph-renderer-trial-shared";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../..");
|
||||
const artifactDir = process.env.GRAPH_SIGMA_TRIAL_ARTIFACT_DIR || path.join(os.tmpdir(), `llm-wiki-graph-sigma-trial-${Date.now()}`);
|
||||
const executablePath = process.env.GRAPH_SIGMA_TRIAL_CHROME_EXECUTABLE || "";
|
||||
const requestedShapes = parseRequestedShapes(process.env.GRAPH_SIGMA_TRIAL_SHAPES);
|
||||
const resultPath = path.join(artifactDir, "sigma-graphology-trial-results.json");
|
||||
const buildCommit = readBuildCommit();
|
||||
const rendererName = "sigma-graphology-webgl-trial";
|
||||
const productionPath = false;
|
||||
let capturedBrowserVersion = "unknown";
|
||||
const runContext = {
|
||||
run_started_at: "",
|
||||
run_finished_at: "",
|
||||
browser: "unknown",
|
||||
build_commit: buildCommit
|
||||
};
|
||||
const sigmaVersion = "3.0.3";
|
||||
const graphologyVersion = "0.26.0";
|
||||
const sigmaScript = path.join(repoRoot, "node_modules/sigma/dist/sigma.min.js");
|
||||
const graphologyScript = path.join(repoRoot, "node_modules/graphology/dist/graphology.umd.min.js");
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
|
||||
function readBuildCommit(): string {
|
||||
try {
|
||||
return execFileSync("git", ["-C", repoRoot, "rev-parse", "--short", "HEAD"], { encoding: "utf8" }).trim();
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
async function main(): Promise<void> {
|
||||
await fs.mkdir(artifactDir, { recursive: true });
|
||||
const records: PerformanceRecord[] = [];
|
||||
const errors: string[] = [];
|
||||
const browser = await chromium.launch(executablePath ? { executablePath } : {});
|
||||
runContext.run_started_at = new Date().toISOString();
|
||||
try {
|
||||
capturedBrowserVersion = await browser.version();
|
||||
runContext.browser = capturedBrowserVersion;
|
||||
} catch {
|
||||
runContext.browser = capturedBrowserVersion;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const shape of requestedShapes) {
|
||||
const fixture = generateLargeGraphFixture(shape);
|
||||
const searchResultIds = fixture.data.nodes.filter((node) => node.label.includes("needle")).map((node) => node.id);
|
||||
const selectedNodeIds = fixture.data.nodes.slice(0, Math.min(8, fixture.data.nodes.length)).map((node) => node.id);
|
||||
const aggregationMarkers = buildCommunityAggregationMarkers(fixture.data, {
|
||||
pins: fixture.pins,
|
||||
searchResultIds,
|
||||
selectedNodeIds,
|
||||
minCommunitySize: 80
|
||||
});
|
||||
const model = buildSigmaGraphologyTrialModel(fixture.data, {
|
||||
pins: fixture.pins,
|
||||
searchResultIds,
|
||||
selection: selectedNodeIds[0] ? { kind: "node", id: selectedNodeIds[0] } : null,
|
||||
aggregationMarkers
|
||||
});
|
||||
const html = await writeTrialHtml(shape, model);
|
||||
try {
|
||||
const shapeRecords = await measureShape(browser, fixture.metadata, html);
|
||||
records.push(...shapeRecords);
|
||||
} catch (error) {
|
||||
errors.push(`${shape}: ${errorDetail(error)}`);
|
||||
records.push(failedRecord(fixture.metadata, {
|
||||
action: "fixture_load_or_action",
|
||||
failure_class: classifyError(error),
|
||||
failure_detail: errorDetail(error),
|
||||
artifact_path: resultPath
|
||||
}));
|
||||
} finally {
|
||||
await writeResult(records, errors);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
// Stamp the run-finish timestamp once the whole run is done, then write the
|
||||
// final artifact and validate. Records are built during measurement when the
|
||||
// finish time is unknown, so we backfill it before persisting.
|
||||
runContext.run_finished_at = new Date().toISOString();
|
||||
for (const record of records) record.run_finished_at = runContext.run_finished_at;
|
||||
await writeResult(records, errors);
|
||||
|
||||
validateTrialResults({
|
||||
renderer: "Sigma/Graphology",
|
||||
requestedShapes,
|
||||
records,
|
||||
errors,
|
||||
resultPath
|
||||
});
|
||||
console.log(`Wrote ${records.length} Sigma/Graphology trial records to ${resultPath}`);
|
||||
}
|
||||
|
||||
async function writeResult(records: PerformanceRecord[], errors: string[]): Promise<void> {
|
||||
runContext.run_finished_at = new Date().toISOString();
|
||||
await fs.writeFile(resultPath, `${JSON.stringify({
|
||||
schema_version: TRIAL_SCHEMA_VERSION,
|
||||
run_started_at: runContext.run_started_at,
|
||||
run_finished_at: runContext.run_finished_at,
|
||||
renderer: rendererName,
|
||||
production_path: productionPath,
|
||||
browser: runContext.browser,
|
||||
build_commit: runContext.build_commit,
|
||||
candidate: {
|
||||
sigma: sigmaVersion,
|
||||
graphology: graphologyVersion,
|
||||
production_path_switched: false
|
||||
},
|
||||
artifact_dir: artifactDir,
|
||||
shapes: requestedShapes,
|
||||
records,
|
||||
errors
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function writeTrialHtml(shape: string, model: unknown): Promise<string> {
|
||||
const file = path.join(artifactDir, `${shape}.html`);
|
||||
await fs.writeFile(file, `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Sigma Graphology Trial ${escapeHtml(shape)}</title>
|
||||
<style>
|
||||
html, body, #stage { width: 100%; height: 100%; margin: 0; overflow: hidden; }
|
||||
body { background: #f8fafc; font-family: system-ui, sans-serif; }
|
||||
#stage { position: relative; }
|
||||
#drawer { position: absolute; right: 0; top: 0; width: 320px; height: 100%; background: white; border-left: 1px solid #e5e7eb; padding: 16px; box-sizing: border-box; display: none; }
|
||||
|
||||
#drawer[data-open="true"] { display: block; }
|
||||
.summary-card { display: flex; flex-direction: column; gap: 8px; }
|
||||
.summary-kicker { font-size: 12px; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.summary-title { font-size: 18px; margin: 0; }
|
||||
.summary-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
|
||||
.summary-fact { display: flex; justify-content: space-between; font-size: 13px; color: #334155; border-bottom: 1px solid #f1f5f9; padding: 2px 0; }
|
||||
.summary-list { list-style: none; margin: 8px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.summary-item { font-size: 13px; color: #1e293b; padding: 4px 6px; background: #f8fafc; border-radius: 4px; }
|
||||
</style>
|
||||
<script src="${pathToFileURL(graphologyScript).href}"></script>
|
||||
<script src="${pathToFileURL(sigmaScript).href}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="stage"></div>
|
||||
<aside id="drawer"></aside>
|
||||
<script>
|
||||
const model = ${JSON.stringify(model)};
|
||||
const GraphClass = globalThis.graphology?.Graph || globalThis.graphology?.default || globalThis.graphology;
|
||||
const SigmaClass = globalThis.Sigma?.default || globalThis.Sigma;
|
||||
const graph = new GraphClass({ multi: true, type: "undirected", allowSelfLoops: false });
|
||||
const communityById = new Map(model.communities.map((community) => [community.id, community]));
|
||||
const aggregationById = new Map(model.aggregations.map((aggregation) => [aggregation.id, aggregation]));
|
||||
for (const node of model.nodes) {
|
||||
graph.addNode(node.id, {
|
||||
label: node.label,
|
||||
x: node.x,
|
||||
y: node.y,
|
||||
size: node.size,
|
||||
color: node.color,
|
||||
communityId: node.communityId,
|
||||
searchHit: node.searchHit,
|
||||
selected: node.selected,
|
||||
pinned: node.pinned,
|
||||
aggregationIds: node.aggregationIds
|
||||
});
|
||||
}
|
||||
for (const edge of model.edges) {
|
||||
if (graph.hasNode(edge.source) && graph.hasNode(edge.target)) {
|
||||
graph.addEdgeWithKey(edge.id, edge.source, edge.target, {
|
||||
color: edge.color,
|
||||
size: edge.size,
|
||||
relationType: edge.relationType
|
||||
});
|
||||
}
|
||||
}
|
||||
const renderer = new SigmaClass(graph, document.getElementById("stage"), {
|
||||
renderEdgeLabels: false,
|
||||
hideEdgesOnMove: true,
|
||||
hideLabelsOnMove: true,
|
||||
labelRenderedSizeThreshold: 14,
|
||||
minCameraRatio: 0.02,
|
||||
maxCameraRatio: 50
|
||||
});
|
||||
let selectedNodeId = model.nodes.find((node) => node.selected)?.id || null;
|
||||
let selectedContainerId = null;
|
||||
function cameraState() {
|
||||
const camera = renderer.getCamera();
|
||||
return camera && typeof camera.getState === "function" ? camera.getState() : {};
|
||||
}
|
||||
function refresh() {
|
||||
if (typeof renderer.refresh === "function") renderer.refresh();
|
||||
}
|
||||
function setNodeVisual(id, patch) {
|
||||
if (!graph.hasNode(id)) return;
|
||||
for (const [key, value] of Object.entries(patch)) graph.setNodeAttribute(id, key, value);
|
||||
}
|
||||
function searchHighlight(query) {
|
||||
const normalized = String(query || "").toLowerCase();
|
||||
let hits = 0;
|
||||
for (const node of model.nodes) {
|
||||
const hit = normalized && node.label.toLowerCase().includes(normalized);
|
||||
if (hit) hits += 1;
|
||||
setNodeVisual(node.id, {
|
||||
searchHit: Boolean(hit),
|
||||
color: hit ? "#f59e0b" : node.selected ? "#ef4444" : node.color,
|
||||
size: hit ? Math.max(node.size, 4) : node.size
|
||||
});
|
||||
}
|
||||
refresh();
|
||||
return { hits };
|
||||
}
|
||||
function pointSelect(id) {
|
||||
if (!graph.hasNode(id)) return { selectedNodeId: null };
|
||||
if (selectedNodeId && graph.hasNode(selectedNodeId)) {
|
||||
const original = model.nodes.find((node) => node.id === selectedNodeId);
|
||||
setNodeVisual(selectedNodeId, { selected: false, color: original?.color || "#64748b" });
|
||||
}
|
||||
selectedNodeId = id;
|
||||
setNodeVisual(id, { selected: true, color: "#ef4444", size: Math.max(graph.getNodeAttribute(id, "size") || 2, 5) });
|
||||
refresh();
|
||||
return { selectedNodeId: id };
|
||||
}
|
||||
function containerSelect(id) {
|
||||
const aggregation = aggregationById.get(id);
|
||||
const community = communityById.get(id);
|
||||
const nodeIds = aggregation?.nodeIds || community?.nodeIds || [];
|
||||
selectedContainerId = id;
|
||||
for (const nodeId of nodeIds.slice(0, 250)) {
|
||||
if (graph.hasNode(nodeId)) setNodeVisual(nodeId, { color: "#7c3aed", size: Math.max(graph.getNodeAttribute(nodeId, "size") || 2, 3) });
|
||||
}
|
||||
refresh();
|
||||
return { selectedContainerId: id, nodeCount: nodeIds.length };
|
||||
}
|
||||
function resolveDrawerPayload() {
|
||||
if (selectedContainerId) {
|
||||
return model.drawer.communities[selectedContainerId] || model.drawer.global;
|
||||
}
|
||||
if (selectedNodeId && model.drawer.nodes[selectedNodeId]) {
|
||||
return model.drawer.nodes[selectedNodeId];
|
||||
}
|
||||
return model.drawer.global;
|
||||
}
|
||||
function openDrawer() {
|
||||
const drawer = document.getElementById("drawer");
|
||||
const payload = resolveDrawerPayload();
|
||||
// Render a representative summary card (kicker + title + fact grid + list)
|
||||
// so the drawer DOM cost mirrors the production GraphSummaryDrawer instead
|
||||
// of a single text node.
|
||||
drawer.innerHTML = "";
|
||||
const card = document.createElement("article");
|
||||
card.className = "summary-card";
|
||||
const kicker = document.createElement("div");
|
||||
kicker.className = "summary-kicker";
|
||||
kicker.textContent = payload.kicker;
|
||||
const title = document.createElement("h2");
|
||||
title.className = "summary-title";
|
||||
title.textContent = payload.title;
|
||||
const facts = document.createElement("div");
|
||||
facts.className = "summary-facts";
|
||||
for (const fact of payload.facts) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "summary-fact";
|
||||
const lab = document.createElement("span");
|
||||
lab.className = "summary-fact-label";
|
||||
lab.textContent = fact.label;
|
||||
const val = document.createElement("span");
|
||||
val.className = "summary-fact-value";
|
||||
val.textContent = fact.value;
|
||||
row.appendChild(lab);
|
||||
row.appendChild(val);
|
||||
facts.appendChild(row);
|
||||
}
|
||||
card.appendChild(kicker);
|
||||
card.appendChild(title);
|
||||
card.appendChild(facts);
|
||||
if (payload.items.length) {
|
||||
const list = document.createElement("ul");
|
||||
list.className = "summary-list";
|
||||
for (const item of payload.items) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "summary-item";
|
||||
li.textContent = item.label;
|
||||
list.appendChild(li);
|
||||
}
|
||||
card.appendChild(list);
|
||||
}
|
||||
drawer.appendChild(card);
|
||||
drawer.dataset.open = "true";
|
||||
return { open: true, kind: payload.kind, itemCount: payload.items.length, factCount: payload.facts.length };
|
||||
}
|
||||
function enterCommunity(id) {
|
||||
return containerSelect(id);
|
||||
}
|
||||
function returnGlobal() {
|
||||
selectedContainerId = null;
|
||||
const camera = renderer.getCamera();
|
||||
if (camera && typeof camera.setState === "function") camera.setState({ x: 0.5, y: 0.5, ratio: 1 });
|
||||
refresh();
|
||||
return { camera: cameraState() };
|
||||
}
|
||||
window.__sigmaTrial = {
|
||||
ready: false,
|
||||
graph,
|
||||
renderer,
|
||||
model,
|
||||
cameraState,
|
||||
searchHighlight,
|
||||
pointSelect,
|
||||
containerSelect,
|
||||
openDrawer,
|
||||
enterCommunity,
|
||||
returnGlobal,
|
||||
firstNodeId: model.nodes[0]?.id || null,
|
||||
firstCommunityId: model.communities[0]?.id || null,
|
||||
firstContainerId: model.aggregations[0]?.id || model.communities[0]?.id || null,
|
||||
counts() {
|
||||
return {
|
||||
nodes: graph.order,
|
||||
edges: graph.size,
|
||||
communities: model.communities.length,
|
||||
aggregations: model.aggregations.length,
|
||||
selectedNodeId,
|
||||
selectedContainerId,
|
||||
camera: cameraState()
|
||||
};
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
window.__sigmaTrial.ready = true;
|
||||
}));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
return file;
|
||||
}
|
||||
|
||||
async function measureShape(browser: BrowserLike, metadata: LargeGraphFixtureMetadata, html: string): Promise<PerformanceRecord[]> {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
|
||||
page.setDefaultTimeout(timeoutFor(metadata));
|
||||
page.setDefaultNavigationTimeout(45_000);
|
||||
// esbuild (tsx, keepNames:true) injects a __name helper into serialized fns.
|
||||
// Define it as a no-op on every document so page.evaluate arrow fns resolve.
|
||||
await page.addInitScript(NAME_HELPER_INIT_SCRIPT);
|
||||
const records: PerformanceRecord[] = [];
|
||||
try {
|
||||
await page.goto(pathToFileURL(html).href, { waitUntil: "domcontentloaded", timeout: navigationTimeoutFor(metadata) });
|
||||
// Record the graph-engine first-paint mark the page stamps when Sigma
|
||||
// begins its first draw, so we measure rendering, not page navigation.
|
||||
const renderStarted = await page.evaluate(() => performance.now());
|
||||
await page.waitForFunction(() => Boolean((window as any).__sigmaTrial?.ready));
|
||||
const renderFinished = await page.evaluate(() => performance.now());
|
||||
records.push(await recordFromPage(page, metadata, {
|
||||
action: "initial_render",
|
||||
duration_ms: renderFinished - renderStarted,
|
||||
pass: true,
|
||||
artifact_path: resultPath
|
||||
}));
|
||||
for (const action of [
|
||||
() => measureWheelZoom(page, metadata),
|
||||
() => measureDrag(page, metadata),
|
||||
() => measureSearch(page, metadata),
|
||||
() => measurePointSelect(page, metadata),
|
||||
() => measureContainerSelect(page, metadata),
|
||||
() => measureDrawerOpen(page, metadata),
|
||||
() => measureEnterCommunity(page, metadata),
|
||||
() => measureReturnGlobal(page, metadata)
|
||||
]) {
|
||||
records.push(await safeMeasure(page, metadata, action));
|
||||
}
|
||||
records.push(await safeMeasure(page, metadata, () => measureRepeatedCycles(page, metadata)));
|
||||
} finally {
|
||||
await page.close().catch(() => undefined);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
async function safeMeasure(page: PageLike, metadata: LargeGraphFixtureMetadata, action: () => Promise<PerformanceRecord>): Promise<PerformanceRecord> {
|
||||
try {
|
||||
return await action();
|
||||
} catch (error) {
|
||||
return failedRecord(metadata, {
|
||||
action: inferActionName(error),
|
||||
failure_class: classifyError(error),
|
||||
failure_detail: errorDetail(error),
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function measureWheelZoom(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const before = await cameraState(page);
|
||||
// Warmup so the first frames (layout/shader compile) are not scored.
|
||||
await driveWheel(page, 500);
|
||||
const runs: { fps: number; p95: number; durationMs: number }[] = [];
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const samplePromise = sampleAnimationFrames(page, 900);
|
||||
await driveWheel(page, 900);
|
||||
runs.push(await samplePromise);
|
||||
}
|
||||
const after = await cameraState(page);
|
||||
return frameSampleRecord(page, metadata, {
|
||||
action: "wheel_zoom",
|
||||
changed: JSON.stringify(after) !== JSON.stringify(before),
|
||||
runs
|
||||
});
|
||||
}
|
||||
|
||||
async function measureDrag(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const before = await cameraState(page);
|
||||
await driveDrag(page, 500);
|
||||
const runs: { fps: number; p95: number; durationMs: number }[] = [];
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const samplePromise = sampleAnimationFrames(page, 900);
|
||||
await driveDrag(page, 900);
|
||||
runs.push(await samplePromise);
|
||||
}
|
||||
const after = await cameraState(page);
|
||||
return frameSampleRecord(page, metadata, {
|
||||
action: "drag",
|
||||
changed: JSON.stringify(after) !== JSON.stringify(before),
|
||||
runs
|
||||
});
|
||||
}
|
||||
|
||||
async function measureSearch(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const started = performance.now();
|
||||
const result = await page.evaluate(() => (window as any).__sigmaTrial.searchHighlight("needle"));
|
||||
await waitForAnimationFrames(page);
|
||||
const hits = (result as { hits: number }).hits;
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "search_highlight",
|
||||
duration_ms: performance.now() - started,
|
||||
pass: hits === metadata.search_hits,
|
||||
failure_class: hits === metadata.search_hits ? null : "search_hit_mismatch",
|
||||
failure_detail: hits === metadata.search_hits ? null : `expected=${metadata.search_hits}; actual=${hits}`,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measurePointSelect(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const started = performance.now();
|
||||
const result = await page.evaluate(() => {
|
||||
const trial = (window as any).__sigmaTrial;
|
||||
return trial.pointSelect(trial.firstNodeId);
|
||||
});
|
||||
await waitForAnimationFrames(page);
|
||||
const counts = await page.evaluate(() => (window as any).__sigmaTrial.counts());
|
||||
const expected = (result as { selectedNodeId: string | null }).selectedNodeId;
|
||||
const actual = (counts as { selectedNodeId: string | null }).selectedNodeId;
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "point_select",
|
||||
duration_ms: performance.now() - started,
|
||||
pass: Boolean(expected) && actual === expected,
|
||||
failure_class: Boolean(expected) && actual === expected ? null : "selected_node_mismatch",
|
||||
failure_detail: Boolean(expected) && actual === expected ? null : `expected=${expected ?? "null"}; actual=${actual ?? "null"}`,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measureContainerSelect(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const started = performance.now();
|
||||
const result = await page.evaluate(() => {
|
||||
const trial = (window as any).__sigmaTrial;
|
||||
return trial.containerSelect(trial.firstContainerId);
|
||||
});
|
||||
await waitForAnimationFrames(page);
|
||||
const counts = await page.evaluate(() => (window as any).__sigmaTrial.counts());
|
||||
const expected = (result as { selectedContainerId: string | null }).selectedContainerId;
|
||||
const actual = (counts as { selectedContainerId: string | null }).selectedContainerId;
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "container_select",
|
||||
duration_ms: performance.now() - started,
|
||||
pass: Boolean(expected) && actual === expected,
|
||||
failure_class: Boolean(expected) && actual === expected ? null : "selected_container_mismatch",
|
||||
failure_detail: Boolean(expected) && actual === expected ? null : `expected=${expected ?? "null"}; actual=${actual ?? "null"}`,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measureDrawerOpen(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const started = performance.now();
|
||||
const result = await page.evaluate(() => (window as any).__sigmaTrial.openDrawer());
|
||||
await waitForAnimationFrames(page);
|
||||
const card = await page.evaluate(() => {
|
||||
const drawer = document.getElementById("drawer");
|
||||
return {
|
||||
open: drawer?.dataset.open === "true",
|
||||
cards: drawer?.querySelectorAll(".summary-card").length ?? 0,
|
||||
facts: drawer?.querySelectorAll(".summary-fact").length ?? 0,
|
||||
items: drawer?.querySelectorAll(".summary-item").length ?? 0
|
||||
};
|
||||
});
|
||||
const opened = Boolean((result as { open: boolean }).open) && Boolean(card.open);
|
||||
const rendered = opened && (card.cards ?? 0) > 0 && (card.facts ?? 0) > 0;
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "drawer_open",
|
||||
duration_ms: performance.now() - started,
|
||||
pass: rendered,
|
||||
failure_class: rendered ? null : "drawer_not_opened",
|
||||
failure_detail: rendered ? null : `cards=${card.cards}; facts=${card.facts}; items=${card.items}`,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measureEnterCommunity(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const started = performance.now();
|
||||
const result = await page.evaluate(() => {
|
||||
const trial = (window as any).__sigmaTrial;
|
||||
return trial.enterCommunity(trial.firstCommunityId);
|
||||
});
|
||||
await waitForAnimationFrames(page);
|
||||
const counts = await page.evaluate(() => (window as any).__sigmaTrial.counts());
|
||||
const expected = (result as { selectedContainerId: string | null }).selectedContainerId;
|
||||
const actual = (counts as { selectedContainerId: string | null }).selectedContainerId;
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "enter_community",
|
||||
duration_ms: performance.now() - started,
|
||||
pass: Boolean(expected) && actual === expected,
|
||||
failure_class: Boolean(expected) && actual === expected ? null : "community_selection_mismatch",
|
||||
failure_detail: Boolean(expected) && actual === expected ? null : `expected=${expected ?? "null"}; actual=${actual ?? "null"}`,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measureReturnGlobal(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const started = performance.now();
|
||||
await page.evaluate(() => (window as any).__sigmaTrial.returnGlobal());
|
||||
await waitForAnimationFrames(page);
|
||||
const counts = await page.evaluate(() => (window as any).__sigmaTrial.counts());
|
||||
const selectedContainerId = (counts as { selectedContainerId: string | null }).selectedContainerId;
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "return_global",
|
||||
duration_ms: performance.now() - started,
|
||||
pass: selectedContainerId == null,
|
||||
failure_class: selectedContainerId == null ? null : "global_return_incomplete",
|
||||
failure_detail: selectedContainerId == null ? null : `selectedContainerId=${selectedContainerId}`,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measureRepeatedCycles(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
// Larger graphs run more repeated search/community/drawer/return cycles so the
|
||||
// recorded memory growth is meaningful and the field is never "not run".
|
||||
const cycleCount = metadata.nodes >= 10000 ? 6 : metadata.nodes >= 5000 ? 5 : 3;
|
||||
await settleMemory(page);
|
||||
const before = await memoryMb(page);
|
||||
const started = performance.now();
|
||||
for (let index = 0; index < cycleCount; index += 1) {
|
||||
await page.evaluate(() => {
|
||||
const trial = (window as any).__sigmaTrial;
|
||||
trial.searchHighlight("needle");
|
||||
trial.pointSelect(trial.firstNodeId);
|
||||
trial.containerSelect(trial.firstContainerId);
|
||||
trial.openDrawer();
|
||||
trial.returnGlobal();
|
||||
});
|
||||
await waitForAnimationFrames(page, 2);
|
||||
}
|
||||
await settleMemory(page);
|
||||
const after = await memoryMb(page);
|
||||
const memoryGrowth = before == null || after == null ? null : round(after - before);
|
||||
const failureClass = memoryGrowthFailureClass(memoryGrowth, metadata);
|
||||
const record = await recordFromPage(page, metadata, {
|
||||
action: "repeated_search_community_drawer_cycles",
|
||||
duration_ms: performance.now() - started,
|
||||
pass: failureClass == null,
|
||||
failure_class: failureClass,
|
||||
failure_detail: memoryGrowthFailureDetail(memoryGrowth, metadata),
|
||||
artifact_path: resultPath
|
||||
});
|
||||
record.memory_after_cycles_mb = after;
|
||||
record.memory_growth_mb = memoryGrowth;
|
||||
return record;
|
||||
}
|
||||
|
||||
async function recordFromPage(
|
||||
page: PageLike,
|
||||
metadata: LargeGraphFixtureMetadata,
|
||||
input: Partial<PerformanceRecord> & { action: string; artifact_path: string }
|
||||
): Promise<PerformanceRecord> {
|
||||
const counts = await page.evaluate(() => {
|
||||
const trial = (window as any).__sigmaTrial;
|
||||
const trialCounts = trial?.counts?.() ?? {};
|
||||
return {
|
||||
dom_node_count: document.querySelectorAll("*").length,
|
||||
visible_node_count: trialCounts.nodes ?? null,
|
||||
visible_edge_count: trialCounts.edges ?? null,
|
||||
visible_label_count: 0,
|
||||
visible_card_count: 0,
|
||||
memory_peak_mb: typeof performance !== "undefined" && "memory" in performance
|
||||
? Math.round((((performance as any).memory?.usedJSHeapSize || 0) / 1024 / 1024) * 10) / 10
|
||||
: null,
|
||||
long_task_count: performance.getEntriesByType ? performance.getEntriesByType("longtask").length : null,
|
||||
interaction_mode: "candidate-global",
|
||||
interaction_updated_objects: trialCounts.nodes ?? null,
|
||||
interaction_hidden_objects: 0,
|
||||
interaction_preserved_nodes: trialCounts.nodes ?? null,
|
||||
interaction_max_updates: trialCounts.nodes ?? null
|
||||
};
|
||||
});
|
||||
return {
|
||||
...baseRecord(metadata, input.action, input.artifact_path),
|
||||
...counts,
|
||||
duration_ms: round(input.duration_ms ?? 0),
|
||||
fps: input.fps == null ? null : round(input.fps),
|
||||
frame_p95_ms: input.frame_p95_ms == null ? null : round(input.frame_p95_ms),
|
||||
pass: input.pass ?? true,
|
||||
failure_class: input.failure_class ?? null,
|
||||
failure_detail: input.failure_detail ?? null
|
||||
};
|
||||
return applyDurationGate(metadata, record);
|
||||
|
||||
function applyDurationGate(meta: LargeGraphFixtureMetadata, record: PerformanceRecord): PerformanceRecord {
|
||||
if (!DURATION_GATED_ACTIONS.has(record.action)) return record;
|
||||
if (record.failure_class) return record;
|
||||
const probe = { duration_ms: record.duration_ms };
|
||||
const metadataForGate = { nodes: meta.nodes };
|
||||
const failure = durationFailureClass(probe, metadataForGate, record.action);
|
||||
if (!failure) return record;
|
||||
const limit = durationLimitMs(metadataForGate, record.action);
|
||||
record.pass = false;
|
||||
record.failure_class = failure;
|
||||
record.failure_detail = `duration_ms=${record.duration_ms}; ceiling=${limit}`;
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
function failedRecord(
|
||||
metadata: LargeGraphFixtureMetadata,
|
||||
input: { action: string; failure_class: string; failure_detail?: string; artifact_path: string }
|
||||
): PerformanceRecord {
|
||||
return {
|
||||
...baseRecord(metadata, input.action, input.artifact_path),
|
||||
failure_class: input.failure_class,
|
||||
failure_detail: input.failure_detail ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function baseRecord(metadata: LargeGraphFixtureMetadata, action: string, artifactPath: string): PerformanceRecord {
|
||||
return {
|
||||
schema_version: TRIAL_SCHEMA_VERSION,
|
||||
renderer: rendererName,
|
||||
production_path: productionPath,
|
||||
graph_shape: metadata.id,
|
||||
nodes: metadata.nodes,
|
||||
edges: metadata.edges,
|
||||
communities: metadata.communities,
|
||||
largest_community: metadata.largest_community,
|
||||
largest_connected_density: metadata.largest_connected_density,
|
||||
search_hits: metadata.search_hits,
|
||||
pin_count: metadata.pin_count,
|
||||
oversized_community: metadata.oversized_community,
|
||||
action,
|
||||
duration_ms: null,
|
||||
fps: null,
|
||||
frame_p95_ms: null,
|
||||
long_task_count: null,
|
||||
dom_node_count: null,
|
||||
visible_node_count: null,
|
||||
visible_edge_count: null,
|
||||
visible_label_count: null,
|
||||
visible_card_count: null,
|
||||
interaction_mode: null,
|
||||
interaction_updated_objects: null,
|
||||
interaction_hidden_objects: null,
|
||||
interaction_preserved_nodes: null,
|
||||
interaction_max_updates: null,
|
||||
memory_peak_mb: null,
|
||||
memory_after_cycles_mb: null,
|
||||
memory_growth_mb: null,
|
||||
thresholds: actionThresholds(metadata, action),
|
||||
browser: runContext.browser,
|
||||
build_commit: runContext.build_commit,
|
||||
run_started_at: runContext.run_started_at,
|
||||
run_finished_at: runContext.run_finished_at,
|
||||
pass: false,
|
||||
failure_class: null,
|
||||
failure_detail: null,
|
||||
artifact_path: artifactPath,
|
||||
measured_at: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
async function cameraState(page: PageLike): Promise<unknown> {
|
||||
return page.evaluate(() => (window as any).__sigmaTrial.cameraState());
|
||||
}
|
||||
|
||||
// Best-effort GC + idle settle so memory deltas reflect retained growth
|
||||
// rather than transient allocations from the just-finished interaction burst.
|
||||
async function settleMemory(page: PageLike): Promise<void> {
|
||||
try {
|
||||
await page.evaluate("() => { if (typeof gc === 'function') gc(); }");
|
||||
} catch {
|
||||
// gc is not exposed unless --js-flags=--expose-gc; fall through to rAF settle.
|
||||
}
|
||||
await page.evaluate("() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))");
|
||||
}
|
||||
|
||||
async function memoryMb(page: PageLike): Promise<number | null> {
|
||||
return page.evaluate(() => {
|
||||
if (typeof performance === "undefined" || !("memory" in performance)) return null;
|
||||
const used = (performance as any).memory?.usedJSHeapSize;
|
||||
return typeof used === "number" ? Math.round((used / 1024 / 1024) * 10) / 10 : null;
|
||||
});
|
||||
}
|
||||
|
||||
// Drive a wheel-zoom burst for the given window with a coarse step so the host
|
||||
// is not polling on every frame and starving the page requestAnimationFrame loop.
|
||||
async function driveWheel(page: PageLike, durationMs: number): Promise<void> {
|
||||
const end = performance.now() + durationMs;
|
||||
while (performance.now() < end) {
|
||||
await page.mouse.move(720, 480);
|
||||
await page.mouse.wheel(0, -240);
|
||||
await page.waitForTimeout(60);
|
||||
}
|
||||
}
|
||||
|
||||
// Drive a continuous canvas drag for the given window.
|
||||
async function driveDrag(page: PageLike, durationMs: number): Promise<void> {
|
||||
await page.mouse.move(640, 400);
|
||||
await page.mouse.down();
|
||||
const end = performance.now() + durationMs;
|
||||
let dx = 640;
|
||||
let dy = 400;
|
||||
while (performance.now() < end) {
|
||||
dx += 16;
|
||||
dy += 12;
|
||||
if (dx > 1260) dx = 580;
|
||||
if (dy > 820) dy = 380;
|
||||
await page.mouse.move(dx, dy);
|
||||
await page.waitForTimeout(55);
|
||||
}
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
// Reduce warmup-then-3-run frame samples to a single record: the scored metrics
|
||||
// are the MEDIAN fps and median frame p95 (the plan's median-must-pass rule),
|
||||
// while the worst run is preserved on the record for auditability.
|
||||
async function frameSampleRecord(
|
||||
page: PageLike,
|
||||
metadata: LargeGraphFixtureMetadata,
|
||||
input: { action: "wheel_zoom" | "drag"; changed: boolean; runs: { fps: number; p95: number; durationMs: number }[] }
|
||||
): Promise<PerformanceRecord> {
|
||||
const byFps = [...input.runs].sort((a, b) => a.fps - b.fps);
|
||||
const byP95 = [...input.runs].sort((a, b) => a.p95 - b.p95);
|
||||
const median = (arr: { fps: number; p95: number }[], key: "fps" | "p95") => {
|
||||
if (!arr.length) return 0;
|
||||
const mid = Math.floor(arr.length / 2);
|
||||
return arr.length % 2 ? arr[mid][key] : (arr[mid - 1][key] + arr[mid][key]) / 2;
|
||||
};
|
||||
const fps = median(byFps, "fps");
|
||||
const p95 = median(byP95, "p95");
|
||||
const worst = byFps[0];
|
||||
const probe = { fps, frame_p95_ms: p95 };
|
||||
const frameFailure = frameSampleFailureClass(probe);
|
||||
const failureClass = !input.changed ? "camera_unchanged" : frameFailure;
|
||||
const record = await recordFromPage(page, metadata, {
|
||||
action: input.action,
|
||||
duration_ms: input.runs.reduce((sum, run) => sum + run.durationMs, 0),
|
||||
fps,
|
||||
frame_p95_ms: p95,
|
||||
pass: input.changed && failureClass == null,
|
||||
failure_class: failureClass,
|
||||
failure_detail: failureClass ? `median_fps=${fps}; median_frame_p95_ms=${p95}; floor=${FPS_FLOOR}; ceiling=${FRAME_P95_CEILING_MS}` : null,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
record.warmup_runs = input.runs.length;
|
||||
record.median_fps = fps;
|
||||
record.worst_run_fps = worst ? worst.fps : null;
|
||||
record.worst_run_frame_p95_ms = worst ? worst.p95 : null;
|
||||
return record;
|
||||
}
|
||||
|
||||
async function sampleAnimationFrames(page: PageLike, durationMs: number): Promise<{ durationMs: number; fps: number; p95: number }> {
|
||||
return page.evaluate(`(() => new Promise((resolve) => {
|
||||
const durationMs = ${JSON.stringify(durationMs)};
|
||||
const started = performance.now();
|
||||
const deltas = [];
|
||||
let last = started;
|
||||
function tick(now) {
|
||||
deltas.push(now - last);
|
||||
last = now;
|
||||
const elapsed = now - started;
|
||||
if (elapsed >= durationMs) {
|
||||
const sorted = [...deltas].sort((a, b) => a - b);
|
||||
const p95 = sorted[Math.max(0, Math.floor(sorted.length * 0.95) - 1)] || 0;
|
||||
resolve({ durationMs: elapsed, fps: deltas.length / (elapsed / 1000), p95 });
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}))()`) as Promise<{ durationMs: number; fps: number; p95: number }>;
|
||||
}
|
||||
|
||||
function classifyError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (/Timeout/i.test(message)) return "timeout";
|
||||
if (/WebGL|webgl/i.test(message)) return "webgl_unavailable";
|
||||
if (/Target page|browser has been closed/i.test(message)) return "browser_closed";
|
||||
if (/JavaScript heap|out of memory/i.test(message)) return "memory";
|
||||
return "exception";
|
||||
}
|
||||
|
||||
function errorDetail(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.replace(/\s+/g, " ").slice(0, 500);
|
||||
}
|
||||
|
||||
function inferActionName(error: unknown): string {
|
||||
const stack = error instanceof Error ? error.stack || error.message : String(error);
|
||||
const match = stack.match(/measure[A-Z][A-Za-z0-9_]*/);
|
||||
if (!match) return "unknown_action";
|
||||
return match[0].replace(/^measure/, "").replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
|
||||
}
|
||||
|
||||
function timeoutFor(metadata: LargeGraphFixtureMetadata): number {
|
||||
if (metadata.nodes >= 10000) return 25_000;
|
||||
if (metadata.nodes >= 5000) return 18_000;
|
||||
return 12_000;
|
||||
}
|
||||
|
||||
function navigationTimeoutFor(metadata: LargeGraphFixtureMetadata): number {
|
||||
if (metadata.nodes >= 10000) return 45_000;
|
||||
if (metadata.nodes >= 5000) return 30_000;
|
||||
return 20_000;
|
||||
}
|
||||
|
||||
function round(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (char) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
}[char] ?? char));
|
||||
}
|
||||
|
||||
interface BrowserLike {
|
||||
newPage(options: { viewport: { width: number; height: number } }): Promise<PageLike>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface PageLike {
|
||||
addInitScript(script: string): Promise<void>;
|
||||
setDefaultTimeout(timeout: number): void;
|
||||
setDefaultNavigationTimeout(timeout: number): void;
|
||||
goto(url: string, options?: unknown): Promise<unknown>;
|
||||
waitForFunction(fn: Function | string, arg?: unknown, options?: unknown): Promise<unknown>;
|
||||
waitForTimeout(timeout: number): Promise<void>;
|
||||
evaluate<T>(fn: Function | string, arg?: unknown): Promise<T>;
|
||||
mouse: {
|
||||
move(x: number, y: number, options?: { steps?: number }): Promise<void>;
|
||||
down(): Promise<void>;
|
||||
up(): Promise<void>;
|
||||
wheel(deltaX: number, deltaY: number): Promise<void>;
|
||||
};
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface PerformanceRecord {
|
||||
schema_version: string;
|
||||
renderer: string;
|
||||
production_path: boolean;
|
||||
graph_shape: string;
|
||||
nodes: number;
|
||||
edges: number;
|
||||
communities: number;
|
||||
largest_community: number;
|
||||
largest_connected_density: number;
|
||||
search_hits: number;
|
||||
pin_count: number;
|
||||
oversized_community: boolean;
|
||||
action: string;
|
||||
duration_ms: number | null;
|
||||
fps: number | null;
|
||||
frame_p95_ms: number | null;
|
||||
long_task_count: number | null;
|
||||
dom_node_count: number | null;
|
||||
visible_node_count: number | null;
|
||||
visible_edge_count: number | null;
|
||||
visible_label_count: number | null;
|
||||
visible_card_count: number | null;
|
||||
interaction_mode: string | null;
|
||||
interaction_updated_objects: number | null;
|
||||
interaction_hidden_objects: number | null;
|
||||
interaction_preserved_nodes: number | null;
|
||||
interaction_max_updates: number | null;
|
||||
memory_peak_mb: number | null;
|
||||
memory_after_cycles_mb: number | null;
|
||||
memory_growth_mb: number | null;
|
||||
thresholds: Record<string, number>;
|
||||
browser: string;
|
||||
build_commit: string;
|
||||
run_started_at: string;
|
||||
run_finished_at: string;
|
||||
warmup_runs?: number;
|
||||
median_fps?: number | null;
|
||||
worst_run_fps?: number | null;
|
||||
worst_run_frame_p95_ms?: number | null;
|
||||
pass: boolean;
|
||||
failure_class: string | null;
|
||||
failure_detail?: string | null;
|
||||
artifact_path: string;
|
||||
measured_at: string;
|
||||
}
|
||||
@@ -0,0 +1,815 @@
|
||||
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`);
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { buildCommunityAggregationMarkers } from "../../packages/graph-engine/src";
|
||||
import {
|
||||
generateLargeGraphFixture,
|
||||
type LargeGraphFixtureMetadata
|
||||
} from "../../packages/graph-engine/test/large-graph-fixtures";
|
||||
import { buildVisNetworkTrialModel } from "../../packages/graph-engine/test/vis-network-trial-adapter";
|
||||
import {
|
||||
memoryGrowthFailureClass,
|
||||
memoryGrowthFailureDetail,
|
||||
parseRequestedShapes,
|
||||
validateTrialResults,
|
||||
waitForAfterDrawing,
|
||||
waitForAnimationFrames
|
||||
} from "./graph-renderer-trial-shared";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../..");
|
||||
const artifactDir = process.env.GRAPH_VIS_TRIAL_ARTIFACT_DIR || path.join(os.tmpdir(), `llm-wiki-graph-vis-trial-${Date.now()}`);
|
||||
const executablePath = process.env.GRAPH_VIS_TRIAL_CHROME_EXECUTABLE || "";
|
||||
const requestedShapes = parseRequestedShapes(process.env.GRAPH_VIS_TRIAL_SHAPES);
|
||||
const resultPath = path.join(artifactDir, "vis-network-trial-results.json");
|
||||
const visNetworkVersion = "10.1.0";
|
||||
const visNetworkScript = path.join(repoRoot, "node_modules/vis-network/standalone/umd/vis-network.min.js");
|
||||
const visNetworkCss = path.join(repoRoot, "node_modules/vis-network/styles/vis-network.min.css");
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await fs.mkdir(artifactDir, { recursive: true });
|
||||
const runStartedAt = new Date().toISOString();
|
||||
const records: PerformanceRecord[] = [];
|
||||
const errors: string[] = [];
|
||||
const browser = await chromium.launch(executablePath ? { executablePath } : {});
|
||||
|
||||
try {
|
||||
for (const shape of requestedShapes) {
|
||||
const fixture = generateLargeGraphFixture(shape);
|
||||
const searchResultIds = fixture.data.nodes.filter((node) => node.label.includes("needle")).map((node) => node.id);
|
||||
const selectedNodeIds = fixture.data.nodes.slice(0, Math.min(8, fixture.data.nodes.length)).map((node) => node.id);
|
||||
const aggregationMarkers = buildCommunityAggregationMarkers(fixture.data, {
|
||||
pins: fixture.pins,
|
||||
searchResultIds,
|
||||
selectedNodeIds,
|
||||
minCommunitySize: 80
|
||||
});
|
||||
const model = buildVisNetworkTrialModel(fixture.data, {
|
||||
pins: fixture.pins,
|
||||
searchResultIds,
|
||||
selection: selectedNodeIds[0] ? { kind: "node", id: selectedNodeIds[0] } : null,
|
||||
aggregationMarkers
|
||||
});
|
||||
const html = await writeTrialHtml(shape, model);
|
||||
try {
|
||||
const shapeRecords = await measureShape(browser, fixture.metadata, html);
|
||||
records.push(...shapeRecords);
|
||||
} catch (error) {
|
||||
errors.push(`${shape}: ${errorDetail(error)}`);
|
||||
records.push(failedRecord(fixture.metadata, {
|
||||
action: "fixture_load_or_action",
|
||||
failure_class: classifyError(error),
|
||||
failure_detail: errorDetail(error),
|
||||
artifact_path: resultPath
|
||||
}));
|
||||
} finally {
|
||||
await writeResult(runStartedAt, records, errors);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close().catch(() => undefined);
|
||||
await writeResult(runStartedAt, records, errors);
|
||||
}
|
||||
|
||||
validateTrialResults({
|
||||
renderer: "vis-network",
|
||||
requestedShapes,
|
||||
records,
|
||||
errors,
|
||||
resultPath,
|
||||
requireSchema: false
|
||||
});
|
||||
console.log(`Wrote ${records.length} vis-network trial records to ${resultPath}`);
|
||||
}
|
||||
|
||||
async function writeResult(runStartedAt: string, records: PerformanceRecord[], errors: string[]): Promise<void> {
|
||||
await fs.writeFile(resultPath, `${JSON.stringify({
|
||||
run_started_at: runStartedAt,
|
||||
run_finished_at: new Date().toISOString(),
|
||||
renderer: "vis-network-canvas-trial",
|
||||
candidate: {
|
||||
vis_network: visNetworkVersion,
|
||||
production_path_switched: false
|
||||
},
|
||||
artifact_dir: artifactDir,
|
||||
shapes: requestedShapes,
|
||||
records,
|
||||
errors
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function writeTrialHtml(shape: string, model: unknown): Promise<string> {
|
||||
const file = path.join(artifactDir, `${shape}.html`);
|
||||
await fs.writeFile(file, `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>vis-network Trial ${escapeHtml(shape)}</title>
|
||||
<link rel="stylesheet" href="${pathToFileURL(visNetworkCss).href}" />
|
||||
<style>
|
||||
html, body, #stage { width: 100%; height: 100%; margin: 0; overflow: hidden; }
|
||||
body { background: #f8fafc; font-family: system-ui, sans-serif; }
|
||||
#stage { position: relative; }
|
||||
#drawer { position: absolute; right: 0; top: 0; width: 320px; height: 100%; background: white; border-left: 1px solid #e5e7eb; padding: 16px; box-sizing: border-box; display: none; }
|
||||
#drawer[data-open="true"] { display: block; }
|
||||
</style>
|
||||
<script src="${pathToFileURL(visNetworkScript).href}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="stage"></div>
|
||||
<aside id="drawer"></aside>
|
||||
<script>
|
||||
const model = ${JSON.stringify(model)};
|
||||
const vis = globalThis.vis;
|
||||
const communityById = new Map(model.communities.map((community) => [community.id, community]));
|
||||
const aggregationById = new Map(model.aggregations.map((aggregation) => [aggregation.id, aggregation]));
|
||||
const originalNodeById = new Map(model.nodes.map((node) => [node.id, node]));
|
||||
const nodes = new vis.DataSet(model.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
label: "",
|
||||
title: node.label,
|
||||
x: node.x,
|
||||
y: node.y,
|
||||
value: node.size,
|
||||
color: node.color,
|
||||
communityId: node.communityId,
|
||||
searchHit: node.searchHit,
|
||||
selected: node.selected,
|
||||
pinned: node.pinned,
|
||||
aggregationIds: node.aggregationIds,
|
||||
fixed: false
|
||||
})));
|
||||
const edges = new vis.DataSet(model.edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
from: edge.from,
|
||||
to: edge.to,
|
||||
color: { color: edge.color, opacity: 0.48 },
|
||||
width: edge.width,
|
||||
relationType: edge.relationType
|
||||
})));
|
||||
const network = new vis.Network(document.getElementById("stage"), { nodes, edges }, {
|
||||
autoResize: true,
|
||||
physics: false,
|
||||
interaction: {
|
||||
dragNodes: false,
|
||||
hover: false,
|
||||
multiselect: false,
|
||||
navigationButtons: false,
|
||||
tooltipDelay: 200
|
||||
},
|
||||
nodes: {
|
||||
shape: "dot",
|
||||
scaling: { min: 1, max: 12 },
|
||||
borderWidth: 0
|
||||
},
|
||||
edges: {
|
||||
smooth: false,
|
||||
selectionWidth: 1,
|
||||
hoverWidth: 0
|
||||
}
|
||||
});
|
||||
let drawCount = 0;
|
||||
network.on("afterDrawing", () => {
|
||||
drawCount += 1;
|
||||
});
|
||||
let selectedNodeId = model.nodes.find((node) => node.selected)?.id || null;
|
||||
let selectedContainerId = null;
|
||||
function cameraState() {
|
||||
const scale = network.getScale();
|
||||
const position = network.getViewPosition();
|
||||
return { x: position.x, y: position.y, scale };
|
||||
}
|
||||
function refresh() {
|
||||
network.redraw();
|
||||
}
|
||||
function setNodeVisual(id, patch) {
|
||||
const original = originalNodeById.get(id);
|
||||
if (!original) return;
|
||||
const update = { id };
|
||||
if ("color" in patch) update.color = patch.color;
|
||||
if ("size" in patch) update.value = patch.size;
|
||||
if ("searchHit" in patch) update.searchHit = patch.searchHit;
|
||||
if ("selected" in patch) update.selected = patch.selected;
|
||||
nodes.update(update);
|
||||
}
|
||||
function searchHighlight(query) {
|
||||
const normalized = String(query || "").toLowerCase();
|
||||
let hits = 0;
|
||||
const updates = [];
|
||||
for (const node of model.nodes) {
|
||||
const hit = normalized && node.label.toLowerCase().includes(normalized);
|
||||
if (hit) hits += 1;
|
||||
updates.push({
|
||||
id: node.id,
|
||||
searchHit: Boolean(hit),
|
||||
color: hit ? "#f59e0b" : node.selected ? "#ef4444" : node.color,
|
||||
value: hit ? Math.max(node.size, 7) : node.size
|
||||
});
|
||||
}
|
||||
nodes.update(updates);
|
||||
refresh();
|
||||
return { hits };
|
||||
}
|
||||
function pointSelect(id) {
|
||||
if (!originalNodeById.has(id)) return { selectedNodeId: null };
|
||||
if (selectedNodeId && originalNodeById.has(selectedNodeId)) {
|
||||
const original = originalNodeById.get(selectedNodeId);
|
||||
setNodeVisual(selectedNodeId, { selected: false, color: original.color, size: original.size });
|
||||
}
|
||||
selectedNodeId = id;
|
||||
const current = originalNodeById.get(id);
|
||||
setNodeVisual(id, { selected: true, color: "#ef4444", size: Math.max(current?.size || 4, 8) });
|
||||
network.selectNodes([id], false);
|
||||
refresh();
|
||||
return { selectedNodeId: id };
|
||||
}
|
||||
function containerSelect(id) {
|
||||
const aggregation = aggregationById.get(id);
|
||||
const community = communityById.get(id);
|
||||
const nodeIds = aggregation?.nodeIds || community?.nodeIds || [];
|
||||
selectedContainerId = id;
|
||||
const updates = [];
|
||||
for (const nodeId of nodeIds.slice(0, 250)) {
|
||||
const current = originalNodeById.get(nodeId);
|
||||
if (current) updates.push({ id: nodeId, color: "#7c3aed", value: Math.max(current.size, 5) });
|
||||
}
|
||||
nodes.update(updates);
|
||||
refresh();
|
||||
return { selectedContainerId: id, nodeCount: nodeIds.length };
|
||||
}
|
||||
function openDrawer() {
|
||||
const drawer = document.getElementById("drawer");
|
||||
drawer.dataset.open = "true";
|
||||
drawer.textContent = selectedContainerId
|
||||
? "container:" + selectedContainerId
|
||||
: selectedNodeId
|
||||
? "node:" + selectedNodeId
|
||||
: "global";
|
||||
return { open: true, text: drawer.textContent };
|
||||
}
|
||||
function enterCommunity(id) {
|
||||
return containerSelect(id);
|
||||
}
|
||||
function returnGlobal() {
|
||||
selectedContainerId = null;
|
||||
network.fit({ animation: false });
|
||||
refresh();
|
||||
return { camera: cameraState() };
|
||||
}
|
||||
window.__visTrial = {
|
||||
ready: false,
|
||||
nodes,
|
||||
edges,
|
||||
network,
|
||||
model,
|
||||
cameraState,
|
||||
searchHighlight,
|
||||
pointSelect,
|
||||
containerSelect,
|
||||
openDrawer,
|
||||
enterCommunity,
|
||||
returnGlobal,
|
||||
firstNodeId: model.nodes[0]?.id || null,
|
||||
firstCommunityId: model.communities[0]?.id || null,
|
||||
firstContainerId: model.aggregations[0]?.id || model.communities[0]?.id || null,
|
||||
drawCount() {
|
||||
return drawCount;
|
||||
},
|
||||
counts() {
|
||||
return {
|
||||
nodes: nodes.length,
|
||||
edges: edges.length,
|
||||
communities: model.communities.length,
|
||||
aggregations: model.aggregations.length,
|
||||
selectedNodeId,
|
||||
selectedContainerId,
|
||||
camera: cameraState()
|
||||
};
|
||||
}
|
||||
};
|
||||
network.once("afterDrawing", () => requestAnimationFrame(() => {
|
||||
window.__visTrial.ready = true;
|
||||
}));
|
||||
network.redraw();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
return file;
|
||||
}
|
||||
|
||||
async function measureShape(browser: BrowserLike, metadata: LargeGraphFixtureMetadata, html: string): Promise<PerformanceRecord[]> {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
|
||||
page.setDefaultTimeout(timeoutFor(metadata));
|
||||
page.setDefaultNavigationTimeout(45_000);
|
||||
const records: PerformanceRecord[] = [];
|
||||
try {
|
||||
const renderStarted = performance.now();
|
||||
await page.goto(pathToFileURL(html).href, { waitUntil: "domcontentloaded", timeout: navigationTimeoutFor(metadata) });
|
||||
await page.waitForFunction(() => Boolean((window as any).__visTrial?.ready));
|
||||
records.push(await recordFromPage(page, metadata, {
|
||||
action: "initial_render",
|
||||
duration_ms: performance.now() - renderStarted,
|
||||
pass: true,
|
||||
artifact_path: resultPath
|
||||
}));
|
||||
for (const action of [
|
||||
() => measureWheelZoom(page, metadata),
|
||||
() => measurePan(page, metadata),
|
||||
() => measureSearch(page, metadata),
|
||||
() => measurePointSelect(page, metadata),
|
||||
() => measureContainerSelect(page, metadata),
|
||||
() => measureDrawerOpen(page, metadata),
|
||||
() => measureEnterCommunity(page, metadata),
|
||||
() => measureReturnGlobal(page, metadata)
|
||||
]) {
|
||||
records.push(await safeMeasure(page, metadata, action));
|
||||
}
|
||||
records.push(await safeMeasure(page, metadata, () => measureRepeatedCycles(page, metadata)));
|
||||
} finally {
|
||||
await page.close().catch(() => undefined);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
async function safeMeasure(page: PageLike, metadata: LargeGraphFixtureMetadata, action: () => Promise<PerformanceRecord>): Promise<PerformanceRecord> {
|
||||
try {
|
||||
return await action();
|
||||
} catch (error) {
|
||||
return failedRecord(metadata, {
|
||||
action: inferActionName(error),
|
||||
failure_class: classifyError(error),
|
||||
failure_detail: errorDetail(error),
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function measureWheelZoom(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const before = await cameraState(page);
|
||||
const samplePromise = sampleAnimationFrames(page, 1000);
|
||||
const started = performance.now();
|
||||
while (performance.now() - started < 1000) {
|
||||
await page.mouse.move(720, 480);
|
||||
await page.mouse.wheel(0, -280);
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
const sample = await samplePromise;
|
||||
const after = await cameraState(page);
|
||||
const changed = JSON.stringify(after) !== JSON.stringify(before);
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "wheel_zoom",
|
||||
duration_ms: sample.durationMs,
|
||||
fps: sample.fps,
|
||||
frame_p95_ms: sample.p95,
|
||||
pass: changed && sample.fps >= 10,
|
||||
failure_class: changed ? (sample.fps < 10 ? "fps_below_floor" : null) : "camera_unchanged",
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measurePan(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const before = await cameraState(page);
|
||||
const started = performance.now();
|
||||
await page.mouse.move(720, 480);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(900, 620, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(120);
|
||||
const after = await cameraState(page);
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "pan",
|
||||
duration_ms: performance.now() - started,
|
||||
pass: JSON.stringify(after) !== JSON.stringify(before),
|
||||
failure_class: JSON.stringify(after) === JSON.stringify(before) ? "camera_unchanged" : null,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measureSearch(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const started = performance.now();
|
||||
const result = await page.evaluate(() => (window as any).__visTrial.searchHighlight("needle"));
|
||||
await waitForAfterDrawing(page);
|
||||
const hits = (result as { hits: number }).hits;
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "search_highlight",
|
||||
duration_ms: performance.now() - started,
|
||||
pass: hits === metadata.search_hits,
|
||||
failure_class: hits === metadata.search_hits ? null : "search_hit_mismatch",
|
||||
failure_detail: hits === metadata.search_hits ? null : `expected=${metadata.search_hits}; actual=${hits}`,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measurePointSelect(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const started = performance.now();
|
||||
const result = await page.evaluate(() => {
|
||||
const trial = (window as any).__visTrial;
|
||||
return trial.pointSelect(trial.firstNodeId);
|
||||
});
|
||||
await waitForAfterDrawing(page);
|
||||
const counts = await page.evaluate(() => (window as any).__visTrial.counts());
|
||||
const expected = (result as { selectedNodeId: string | null }).selectedNodeId;
|
||||
const actual = (counts as { selectedNodeId: string | null }).selectedNodeId;
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "point_select",
|
||||
duration_ms: performance.now() - started,
|
||||
pass: Boolean(expected) && actual === expected,
|
||||
failure_class: Boolean(expected) && actual === expected ? null : "selected_node_mismatch",
|
||||
failure_detail: Boolean(expected) && actual === expected ? null : `expected=${expected ?? "null"}; actual=${actual ?? "null"}`,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measureContainerSelect(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const started = performance.now();
|
||||
const result = await page.evaluate(() => {
|
||||
const trial = (window as any).__visTrial;
|
||||
return trial.containerSelect(trial.firstContainerId);
|
||||
});
|
||||
await waitForAfterDrawing(page);
|
||||
const counts = await page.evaluate(() => (window as any).__visTrial.counts());
|
||||
const expected = (result as { selectedContainerId: string | null }).selectedContainerId;
|
||||
const actual = (counts as { selectedContainerId: string | null }).selectedContainerId;
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "container_select",
|
||||
duration_ms: performance.now() - started,
|
||||
pass: Boolean(expected) && actual === expected,
|
||||
failure_class: Boolean(expected) && actual === expected ? null : "selected_container_mismatch",
|
||||
failure_detail: Boolean(expected) && actual === expected ? null : `expected=${expected ?? "null"}; actual=${actual ?? "null"}`,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measureDrawerOpen(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const started = performance.now();
|
||||
const result = await page.evaluate(() => (window as any).__visTrial.openDrawer());
|
||||
await waitForAnimationFrames(page);
|
||||
const opened = Boolean((result as { open: boolean; text?: string }).open);
|
||||
const text = (result as { open: boolean; text?: string }).text || "";
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "drawer_open",
|
||||
duration_ms: performance.now() - started,
|
||||
pass: opened && /^(container|node|global)/.test(text),
|
||||
failure_class: opened && /^(container|node|global)/.test(text) ? null : "drawer_not_opened",
|
||||
failure_detail: opened && /^(container|node|global)/.test(text) ? null : `text=${text || "empty"}`,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measureEnterCommunity(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const started = performance.now();
|
||||
const result = await page.evaluate(() => {
|
||||
const trial = (window as any).__visTrial;
|
||||
return trial.enterCommunity(trial.firstCommunityId);
|
||||
});
|
||||
await waitForAfterDrawing(page);
|
||||
const counts = await page.evaluate(() => (window as any).__visTrial.counts());
|
||||
const expected = (result as { selectedContainerId: string | null }).selectedContainerId;
|
||||
const actual = (counts as { selectedContainerId: string | null }).selectedContainerId;
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "enter_community",
|
||||
duration_ms: performance.now() - started,
|
||||
pass: Boolean(expected) && actual === expected,
|
||||
failure_class: Boolean(expected) && actual === expected ? null : "community_selection_mismatch",
|
||||
failure_detail: Boolean(expected) && actual === expected ? null : `expected=${expected ?? "null"}; actual=${actual ?? "null"}`,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measureReturnGlobal(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const started = performance.now();
|
||||
await page.evaluate(() => (window as any).__visTrial.returnGlobal());
|
||||
await waitForAfterDrawing(page);
|
||||
const counts = await page.evaluate(() => (window as any).__visTrial.counts());
|
||||
const selectedContainerId = (counts as { selectedContainerId: string | null }).selectedContainerId;
|
||||
return recordFromPage(page, metadata, {
|
||||
action: "return_global",
|
||||
duration_ms: performance.now() - started,
|
||||
pass: selectedContainerId == null,
|
||||
failure_class: selectedContainerId == null ? null : "global_return_incomplete",
|
||||
failure_detail: selectedContainerId == null ? null : `selectedContainerId=${selectedContainerId}`,
|
||||
artifact_path: resultPath
|
||||
});
|
||||
}
|
||||
|
||||
async function measureRepeatedCycles(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||||
const before = await memoryMb(page);
|
||||
const started = performance.now();
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
await page.evaluate(() => {
|
||||
const trial = (window as any).__visTrial;
|
||||
trial.searchHighlight("needle");
|
||||
trial.pointSelect(trial.firstNodeId);
|
||||
trial.containerSelect(trial.firstContainerId);
|
||||
trial.openDrawer();
|
||||
trial.returnGlobal();
|
||||
});
|
||||
await waitForAfterDrawing(page);
|
||||
}
|
||||
const after = await memoryMb(page);
|
||||
const memoryGrowth = before == null || after == null ? null : round(after - before);
|
||||
const failureClass = memoryGrowthFailureClass(memoryGrowth, metadata);
|
||||
const record = await recordFromPage(page, metadata, {
|
||||
action: "repeated_search_community_drawer_cycles",
|
||||
duration_ms: performance.now() - started,
|
||||
pass: failureClass == null,
|
||||
failure_class: failureClass,
|
||||
failure_detail: memoryGrowthFailureDetail(memoryGrowth, metadata),
|
||||
artifact_path: resultPath
|
||||
});
|
||||
record.memory_after_cycles_mb = after;
|
||||
record.memory_growth_mb = memoryGrowth;
|
||||
return record;
|
||||
}
|
||||
|
||||
async function recordFromPage(
|
||||
page: PageLike,
|
||||
metadata: LargeGraphFixtureMetadata,
|
||||
input: Partial<PerformanceRecord> & { action: string; artifact_path: string }
|
||||
): Promise<PerformanceRecord> {
|
||||
const counts = await page.evaluate(() => {
|
||||
const trial = (window as any).__visTrial;
|
||||
const trialCounts = trial?.counts?.() ?? {};
|
||||
return {
|
||||
dom_node_count: document.querySelectorAll("*").length,
|
||||
visible_node_count: trialCounts.nodes ?? null,
|
||||
visible_edge_count: trialCounts.edges ?? null,
|
||||
visible_label_count: 0,
|
||||
visible_card_count: 0,
|
||||
memory_peak_mb: typeof performance !== "undefined" && "memory" in performance
|
||||
? Math.round((((performance as any).memory?.usedJSHeapSize || 0) / 1024 / 1024) * 10) / 10
|
||||
: null,
|
||||
long_task_count: performance.getEntriesByType ? performance.getEntriesByType("longtask").length : null,
|
||||
interaction_mode: "candidate-global",
|
||||
interaction_updated_objects: trialCounts.nodes ?? null,
|
||||
interaction_hidden_objects: 0,
|
||||
interaction_preserved_nodes: trialCounts.nodes ?? null,
|
||||
interaction_max_updates: trialCounts.nodes ?? null
|
||||
};
|
||||
});
|
||||
return {
|
||||
...baseRecord(metadata, input.action, input.artifact_path),
|
||||
...counts,
|
||||
duration_ms: round(input.duration_ms ?? 0),
|
||||
fps: input.fps == null ? null : round(input.fps),
|
||||
frame_p95_ms: input.frame_p95_ms == null ? null : round(input.frame_p95_ms),
|
||||
pass: input.pass ?? true,
|
||||
failure_class: input.failure_class ?? null,
|
||||
failure_detail: input.failure_detail ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function failedRecord(
|
||||
metadata: LargeGraphFixtureMetadata,
|
||||
input: { action: string; failure_class: string; failure_detail?: string; artifact_path: string }
|
||||
): PerformanceRecord {
|
||||
return {
|
||||
...baseRecord(metadata, input.action, input.artifact_path),
|
||||
failure_class: input.failure_class,
|
||||
failure_detail: input.failure_detail ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function baseRecord(metadata: LargeGraphFixtureMetadata, action: string, artifactPath: string): PerformanceRecord {
|
||||
return {
|
||||
phase: "phase-6",
|
||||
task: "6.2",
|
||||
renderer: "vis-network-canvas-trial",
|
||||
graph_shape: metadata.id,
|
||||
nodes: metadata.nodes,
|
||||
edges: metadata.edges,
|
||||
communities: metadata.communities,
|
||||
largest_community: metadata.largest_community,
|
||||
largest_connected_density: metadata.largest_connected_density,
|
||||
search_hits: metadata.search_hits,
|
||||
pin_count: metadata.pin_count,
|
||||
oversized_community: metadata.oversized_community,
|
||||
action,
|
||||
duration_ms: null,
|
||||
fps: null,
|
||||
frame_p95_ms: null,
|
||||
long_task_count: null,
|
||||
dom_node_count: null,
|
||||
visible_node_count: null,
|
||||
visible_edge_count: null,
|
||||
visible_label_count: null,
|
||||
visible_card_count: null,
|
||||
interaction_mode: null,
|
||||
interaction_updated_objects: null,
|
||||
interaction_hidden_objects: null,
|
||||
interaction_preserved_nodes: null,
|
||||
interaction_max_updates: null,
|
||||
memory_peak_mb: null,
|
||||
memory_after_cycles_mb: null,
|
||||
memory_growth_mb: null,
|
||||
pass: false,
|
||||
failure_class: null,
|
||||
failure_detail: null,
|
||||
artifact_path: artifactPath,
|
||||
measured_at: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
async function cameraState(page: PageLike): Promise<unknown> {
|
||||
return page.evaluate(() => (window as any).__visTrial.cameraState());
|
||||
}
|
||||
|
||||
async function memoryMb(page: PageLike): Promise<number | null> {
|
||||
return page.evaluate(() => {
|
||||
if (typeof performance === "undefined" || !("memory" in performance)) return null;
|
||||
const used = (performance as any).memory?.usedJSHeapSize;
|
||||
return typeof used === "number" ? Math.round((used / 1024 / 1024) * 10) / 10 : null;
|
||||
});
|
||||
}
|
||||
|
||||
async function sampleAnimationFrames(page: PageLike, durationMs: number): Promise<{ durationMs: number; fps: number; p95: number }> {
|
||||
return page.evaluate(`(() => new Promise((resolve) => {
|
||||
const durationMs = ${JSON.stringify(durationMs)};
|
||||
const started = performance.now();
|
||||
const deltas = [];
|
||||
let last = started;
|
||||
function tick(now) {
|
||||
deltas.push(now - last);
|
||||
last = now;
|
||||
const elapsed = now - started;
|
||||
if (elapsed >= durationMs) {
|
||||
const sorted = [...deltas].sort((a, b) => a - b);
|
||||
const p95 = sorted[Math.max(0, Math.floor(sorted.length * 0.95) - 1)] || 0;
|
||||
resolve({ durationMs: elapsed, fps: deltas.length / (elapsed / 1000), p95 });
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}))()`) as Promise<{ durationMs: number; fps: number; p95: number }>;
|
||||
}
|
||||
|
||||
function classifyError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (/Timeout/i.test(message)) return "timeout";
|
||||
if (/Canvas|canvas/i.test(message)) return "canvas_unavailable";
|
||||
if (/Target page|browser has been closed/i.test(message)) return "browser_closed";
|
||||
if (/JavaScript heap|out of memory/i.test(message)) return "memory";
|
||||
return "exception";
|
||||
}
|
||||
|
||||
function errorDetail(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.replace(/\s+/g, " ").slice(0, 500);
|
||||
}
|
||||
|
||||
function inferActionName(error: unknown): string {
|
||||
const stack = error instanceof Error ? error.stack || error.message : String(error);
|
||||
const match = stack.match(/measure[A-Z][A-Za-z0-9_]*/);
|
||||
if (!match) return "unknown_action";
|
||||
return match[0].replace(/^measure/, "").replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
|
||||
}
|
||||
|
||||
function timeoutFor(metadata: LargeGraphFixtureMetadata): number {
|
||||
if (metadata.nodes >= 10000) return 25_000;
|
||||
if (metadata.nodes >= 5000) return 18_000;
|
||||
return 12_000;
|
||||
}
|
||||
|
||||
function navigationTimeoutFor(metadata: LargeGraphFixtureMetadata): number {
|
||||
if (metadata.nodes >= 10000) return 45_000;
|
||||
if (metadata.nodes >= 5000) return 30_000;
|
||||
return 20_000;
|
||||
}
|
||||
|
||||
function round(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (char) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
}[char] ?? char));
|
||||
}
|
||||
|
||||
interface BrowserLike {
|
||||
newPage(options: { viewport: { width: number; height: number } }): Promise<PageLike>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface PageLike {
|
||||
setDefaultTimeout(timeout: number): void;
|
||||
setDefaultNavigationTimeout(timeout: number): void;
|
||||
goto(url: string, options?: unknown): Promise<unknown>;
|
||||
waitForFunction(fn: Function | string, arg?: unknown, options?: unknown): Promise<unknown>;
|
||||
waitForTimeout(timeout: number): Promise<void>;
|
||||
evaluate<T>(fn: Function | string, arg?: unknown): Promise<T>;
|
||||
mouse: {
|
||||
move(x: number, y: number, options?: { steps?: number }): Promise<void>;
|
||||
down(): Promise<void>;
|
||||
up(): Promise<void>;
|
||||
wheel(deltaX: number, deltaY: number): Promise<void>;
|
||||
};
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface PerformanceRecord {
|
||||
phase: string;
|
||||
task: string;
|
||||
renderer: string;
|
||||
graph_shape: string;
|
||||
nodes: number;
|
||||
edges: number;
|
||||
communities: number;
|
||||
largest_community: number;
|
||||
largest_connected_density: number;
|
||||
search_hits: number;
|
||||
pin_count: number;
|
||||
oversized_community: boolean;
|
||||
action: string;
|
||||
duration_ms: number | null;
|
||||
fps: number | null;
|
||||
frame_p95_ms: number | null;
|
||||
long_task_count: number | null;
|
||||
dom_node_count: number | null;
|
||||
visible_node_count: number | null;
|
||||
visible_edge_count: number | null;
|
||||
visible_label_count: number | null;
|
||||
visible_card_count: number | null;
|
||||
interaction_mode: string | null;
|
||||
interaction_updated_objects: number | null;
|
||||
interaction_hidden_objects: number | null;
|
||||
interaction_preserved_nodes: number | null;
|
||||
interaction_max_updates: number | null;
|
||||
memory_peak_mb: number | null;
|
||||
memory_after_cycles_mb: number | null;
|
||||
memory_growth_mb: number | null;
|
||||
pass: boolean;
|
||||
failure_class: string | null;
|
||||
failure_detail?: string | null;
|
||||
artifact_path: string;
|
||||
measured_at: string;
|
||||
}
|
||||
@@ -0,0 +1,808 @@
|
||||
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('"', '\\"');
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
|
||||
const resultPath = process.argv[2];
|
||||
if (!resultPath) {
|
||||
console.error("Usage: validate-graph-trial-result.mjs <result-json>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const requiredActions = [
|
||||
"initial_render",
|
||||
"wheel_zoom",
|
||||
"drag",
|
||||
"search_highlight",
|
||||
"point_select",
|
||||
"container_select",
|
||||
"spotlight_animation",
|
||||
"drawer_open",
|
||||
"enter_community",
|
||||
"return_global",
|
||||
"repeated_search_community_drawer_cycles"
|
||||
];
|
||||
|
||||
// Actions where fps + frame p95 are mandatory (wheel/drag), and the hard gates.
|
||||
const FRAME_SAMPLED_ACTIONS = new Set(["wheel_zoom", "drag", "spotlight_animation"]);
|
||||
const FPS_FLOOR = 45;
|
||||
const FRAME_P95_CEILING_MS = 22.3;
|
||||
const DURATION_GATED_ACTIONS = new Set(["initial_render", "search_highlight", "drawer_open", "return_global"]);
|
||||
const MEMORY_GATED_ACTION = "repeated_search_community_drawer_cycles";
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(resultPath, "utf8"));
|
||||
const records = Array.isArray(data.records) ? data.records : [];
|
||||
const shapes = Array.isArray(data.shapes) ? data.shapes : [];
|
||||
const errors = Array.isArray(data.errors) ? data.errors : [];
|
||||
const requireProductionPath = data.production_path === true || String(data.renderer || "").includes("production");
|
||||
const failures = [];
|
||||
|
||||
for (const error of errors) {
|
||||
failures.push(`error: ${error}`);
|
||||
}
|
||||
|
||||
for (const shape of shapes) {
|
||||
const shapeRecords = records.filter((record) => record.graph_shape === shape);
|
||||
if (!shapeRecords.length) {
|
||||
failures.push(`${shape}: no records`);
|
||||
continue;
|
||||
}
|
||||
for (const action of requiredActions) {
|
||||
if (!shapeRecords.some((record) => record.action === action)) {
|
||||
failures.push(`${shape}: missing action ${action}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const record of records) {
|
||||
const shapeAction = `${record.graph_shape}/${record.action}`;
|
||||
if (!record.schema_version) failures.push(`${shapeAction}: missing schema_version`);
|
||||
if (typeof record.production_path !== "boolean") failures.push(`${shapeAction}: missing production_path`);
|
||||
else if (requireProductionPath && record.production_path !== true) failures.push(`${shapeAction}: production_path_not_true`);
|
||||
if (!record.thresholds) failures.push(`${shapeAction}: missing thresholds`);
|
||||
if (!record.browser) failures.push(`${shapeAction}: missing browser`);
|
||||
if (!record.build_commit) failures.push(`${shapeAction}: missing build_commit`);
|
||||
if (!record.run_started_at) failures.push(`${shapeAction}: missing run_started_at`);
|
||||
if (!record.run_finished_at) failures.push(`${shapeAction}: missing run_finished_at`);
|
||||
if (FRAME_SAMPLED_ACTIONS.has(record.action)) {
|
||||
if (record.fps == null) failures.push(`${shapeAction}: fps_missing`);
|
||||
else if (record.fps < FPS_FLOOR) failures.push(`${shapeAction}: fps_below_floor; fps=${record.fps}; floor=${FPS_FLOOR}`);
|
||||
if (record.frame_p95_ms == null) failures.push(`${shapeAction}: frame_p95_missing`);
|
||||
else if (record.frame_p95_ms > FRAME_P95_CEILING_MS) failures.push(`${shapeAction}: frame_p95_above_ceiling; frame_p95_ms=${record.frame_p95_ms}; ceiling=${FRAME_P95_CEILING_MS}`);
|
||||
}
|
||||
if (DURATION_GATED_ACTIONS.has(record.action)) {
|
||||
const limit = durationLimitMs(record);
|
||||
if (record.duration_ms == null) failures.push(`${shapeAction}: duration_missing`);
|
||||
else if (record.duration_ms > limit) failures.push(`${shapeAction}: duration_above_ceiling; duration_ms=${record.duration_ms}; ceiling=${limit}`);
|
||||
}
|
||||
if (record.action === MEMORY_GATED_ACTION) {
|
||||
const limit = memoryGrowthLimitMb(record);
|
||||
if (record.memory_growth_mb == null) failures.push(`${shapeAction}: memory_growth_missing`);
|
||||
else if (record.memory_growth_mb > limit) failures.push(`${shapeAction}: memory_growth_above_ceiling; memory_growth_mb=${record.memory_growth_mb}; ceiling=${limit}`);
|
||||
}
|
||||
if (requireProductionPath && record.action === "initial_render" && Number(record.nodes) >= 10000) {
|
||||
if (record.loading_state_seen_at_ms == null) failures.push(`${shapeAction}: loading_state_seen_missing`);
|
||||
else if (record.loading_state_seen_at_ms > 250) failures.push(`${shapeAction}: loading_state_late; loading_state_seen_at_ms=${record.loading_state_seen_at_ms}; ceiling=250`);
|
||||
}
|
||||
if (requireProductionPath && (!record.loading_state || record.loading_state === "not-run")) failures.push(`${shapeAction}: loading_state_missing`);
|
||||
if (requireProductionPath && !allowsNonSigmaRouteForAction(record.action)) {
|
||||
if ((Number(record.sigma_canvas_count) || 0) < 1) {
|
||||
failures.push(`${shapeAction}: sigma_canvas_missing`);
|
||||
}
|
||||
if (record.sigma_canvas_nonblank !== true && record.sigma_visible_signal !== true) {
|
||||
failures.push(`${shapeAction}: sigma_canvas_blank; nonblank=${String(record.sigma_canvas_nonblank)}; visible_signal=${String(record.sigma_visible_signal)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const record of records) {
|
||||
if (record.pass === false || record.failure_class) {
|
||||
failures.push(`${record.graph_shape}/${record.action}: pass=${record.pass}; failure=${record.failure_class || "none"}; detail=${record.failure_detail || "none"}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`FAIL: graph trial result validation found ${failures.length} issue(s) in ${resultPath}`);
|
||||
for (const failure of failures.slice(0, 30)) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`PASS: graph trial result validation (${records.length} records, ${shapes.length} shapes)`);
|
||||
|
||||
function durationLimitMs(record) {
|
||||
if (record.thresholds && typeof record.thresholds.duration_ms_ceiling === "number") {
|
||||
return record.thresholds.duration_ms_ceiling;
|
||||
}
|
||||
const nodes = Number(record.nodes) || 0;
|
||||
if (record.action === "initial_render") {
|
||||
if (nodes >= 10000) return 2000;
|
||||
if (nodes >= 5000) return 1200;
|
||||
return 500;
|
||||
}
|
||||
if (record.action === "search_highlight") {
|
||||
if (nodes >= 10000) return 700;
|
||||
if (nodes >= 5000) return 400;
|
||||
return 200;
|
||||
}
|
||||
if (record.action === "drawer_open") {
|
||||
if (nodes >= 10000) return 500;
|
||||
if (nodes >= 5000) return 400;
|
||||
return 200;
|
||||
}
|
||||
if (record.action === "return_global") {
|
||||
if (nodes >= 10000) return 800;
|
||||
if (nodes >= 5000) return 500;
|
||||
return 250;
|
||||
}
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function memoryGrowthLimitMb(record) {
|
||||
if (record.thresholds && typeof record.thresholds.memory_growth_mb_ceiling === "number") {
|
||||
return record.thresholds.memory_growth_mb_ceiling;
|
||||
}
|
||||
const nodes = Number(record.nodes) || 0;
|
||||
if (nodes >= 10000) return 100;
|
||||
if (nodes >= 5000) return 75;
|
||||
return 50;
|
||||
}
|
||||
|
||||
function allowsNonSigmaRouteForAction(action) {
|
||||
return action === "enter_community";
|
||||
}
|
||||
Reference in New Issue
Block a user