This commit is contained in:
2026-07-12 21:26:08 +08:00
commit 9dd41afd48
502 changed files with 129901 additions and 0 deletions
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;"
}[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;
}
+255
View File
@@ -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);
}
+630
View File
@@ -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 });
}
+53
View File
@@ -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);
}
+41
View File
@@ -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();
}
+689
View File
@@ -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;
}
+75
View File
@@ -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`);
}
+660
View File
@@ -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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;"
}[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;
}
+815
View File
@@ -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`);
}
+762
View File
@@ -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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;"
}[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";
}