1779 lines
74 KiB
TypeScript
1779 lines
74 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
||
import http from "node:http";
|
||
import { createRequire } from "node:module";
|
||
import fs from "node:fs/promises";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
|
||
import { buildCommunityAggregationMarkers } from "../../packages/graph-engine/src";
|
||
import {
|
||
generateLargeGraphFixture,
|
||
type LargeGraphFixtureMetadata
|
||
} from "../../packages/graph-engine/test/large-graph-fixtures";
|
||
import {
|
||
FRAME_P95_CEILING_MS,
|
||
FPS_FLOOR,
|
||
NAME_HELPER_INIT_SCRIPT,
|
||
TRIAL_SCHEMA_VERSION,
|
||
actionThresholds,
|
||
DURATION_GATED_ACTIONS,
|
||
durationFailureClass,
|
||
durationLimitMs,
|
||
frameSampleFailureClass,
|
||
memoryGrowthFailureClass,
|
||
memoryGrowthFailureDetail,
|
||
validateTrialResults,
|
||
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_SIGMA_PRODUCTION_ARTIFACT_DIR || path.join(os.tmpdir(), `llm-wiki-sigma-global-production-${Date.now()}`);
|
||
const executablePath = process.env.GRAPH_SIGMA_PRODUCTION_CHROME_EXECUTABLE || "";
|
||
const DEFAULT_SIGMA_PRODUCTION_SHAPES: LargeGraphFixtureId[] = [
|
||
"real-snapshot-proxy",
|
||
"nodes-1000-sparse",
|
||
"nodes-1000-dense"
|
||
];
|
||
const SIGMA_GLOBAL_NODE_LIMIT = 2000;
|
||
const requestedShapes = parseSigmaProductionShapes(process.env.GRAPH_SIGMA_PRODUCTION_SHAPES);
|
||
const resultPath = path.join(artifactDir, "sigma-global-production-results.json");
|
||
const buildCommit = readBuildCommit();
|
||
const rendererName = "sigma-global-production";
|
||
const productionPath = true;
|
||
const engineDistDir = path.join(repoRoot, "packages/graph-engine/dist");
|
||
const sigmaVersion = "3.0.3";
|
||
const graphologyVersion = "0.26.0";
|
||
let capturedBrowserVersion = "unknown";
|
||
const runContext = {
|
||
run_started_at: "",
|
||
run_finished_at: "",
|
||
browser: "unknown",
|
||
build_commit: buildCommit
|
||
};
|
||
|
||
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";
|
||
}
|
||
}
|
||
|
||
function parseSigmaProductionShapes(value: string | undefined): LargeGraphFixtureId[] {
|
||
return (value || DEFAULT_SIGMA_PRODUCTION_SHAPES.join(","))
|
||
.split(",")
|
||
.map((item) => item.trim())
|
||
.filter(Boolean) as LargeGraphFixtureId[];
|
||
}
|
||
|
||
async function main(): Promise<void> {
|
||
await fs.mkdir(artifactDir, { recursive: true });
|
||
const records: PerformanceRecord[] = [];
|
||
const errors: string[] = [];
|
||
const browser = await chromium.launch({
|
||
...(executablePath ? { executablePath } : {}),
|
||
args: ["--js-flags=--expose-gc"]
|
||
});
|
||
const staticServer = await startStaticServer();
|
||
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);
|
||
if (fixture.metadata.nodes > SIGMA_GLOBAL_NODE_LIMIT) {
|
||
errors.push(`${shape}: skipped because Phase 1 routes graphs over ${SIGMA_GLOBAL_NODE_LIMIT} nodes to over-limit notice`);
|
||
continue;
|
||
}
|
||
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 html = await writeProductionHtml(shape, staticServer.origin, {
|
||
data: fixture.data,
|
||
pins: fixture.pins,
|
||
aggregationMarkers
|
||
});
|
||
try {
|
||
const shapeRecords = await measureShape(browser, fixture.metadata, staticServer.artifactUrl(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);
|
||
await staticServer.close();
|
||
}
|
||
|
||
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 global production",
|
||
requestedShapes,
|
||
records,
|
||
errors,
|
||
resultPath
|
||
});
|
||
console.log(`Wrote ${records.length} production Sigma global 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: true
|
||
},
|
||
artifact_dir: artifactDir,
|
||
shapes: requestedShapes,
|
||
records,
|
||
errors
|
||
}, null, 2)}\n`);
|
||
}
|
||
|
||
async function startStaticServer(): Promise<{
|
||
origin: string;
|
||
artifactUrl(file: string): string;
|
||
close(): Promise<void>;
|
||
}> {
|
||
const server = http.createServer(async (request, response) => {
|
||
try {
|
||
const requestUrl = new URL(request.url || "/", "http://127.0.0.1");
|
||
const pathname = decodeURIComponent(requestUrl.pathname);
|
||
if (pathname.startsWith("/artifact/")) {
|
||
const file = path.basename(pathname.slice("/artifact/".length));
|
||
await serveFile(response, path.join(artifactDir, file));
|
||
return;
|
||
}
|
||
if (pathname.startsWith("/graph-engine-dist/")) {
|
||
const relative = pathname.slice("/graph-engine-dist/".length);
|
||
if (relative.includes("..")) {
|
||
response.writeHead(400).end("bad path");
|
||
return;
|
||
}
|
||
await serveFile(response, path.join(engineDistDir, relative));
|
||
return;
|
||
}
|
||
response.writeHead(404).end("not found");
|
||
} catch (error) {
|
||
response.writeHead(500).end(errorDetail(error));
|
||
}
|
||
});
|
||
|
||
await new Promise<void>((resolve, reject) => {
|
||
server.once("error", reject);
|
||
server.listen(0, "127.0.0.1", () => {
|
||
server.off("error", reject);
|
||
resolve();
|
||
});
|
||
});
|
||
const address = server.address();
|
||
if (!address || typeof address === "string") throw new Error("failed to start production regression static server");
|
||
const origin = `http://127.0.0.1:${address.port}`;
|
||
return {
|
||
origin,
|
||
artifactUrl(file) {
|
||
return `${origin}/artifact/${encodeURIComponent(path.basename(file))}`;
|
||
},
|
||
close() {
|
||
return new Promise((resolve) => server.close(() => resolve()));
|
||
}
|
||
};
|
||
}
|
||
|
||
async function serveFile(response: http.ServerResponse, file: string): Promise<void> {
|
||
const content = await fs.readFile(file);
|
||
response.writeHead(200, {
|
||
"content-type": contentType(file),
|
||
"cache-control": file.startsWith(engineDistDir) ? "public, max-age=3600" : "no-store"
|
||
});
|
||
response.end(content);
|
||
}
|
||
|
||
function contentType(file: string): string {
|
||
if (file.endsWith(".html")) return "text/html; charset=utf-8";
|
||
if (file.endsWith(".js")) return "text/javascript; charset=utf-8";
|
||
if (file.endsWith(".json")) return "application/json; charset=utf-8";
|
||
if (file.endsWith(".map")) return "application/json; charset=utf-8";
|
||
return "application/octet-stream";
|
||
}
|
||
|
||
async function writeProductionHtml(
|
||
shape: string,
|
||
origin: string,
|
||
input: { data: unknown; pins: unknown; aggregationMarkers: unknown }
|
||
): Promise<string> {
|
||
const file = path.join(artifactDir, `${shape}-production.html`);
|
||
await fs.writeFile(file, `<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>Sigma Global Production ${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; }
|
||
.sigma-global-route, .sigma-global-renderer { width: 100%; height: 100%; }
|
||
#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; z-index: 20; }
|
||
#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>
|
||
</head>
|
||
<body>
|
||
<div id="stage"></div>
|
||
<aside id="drawer"></aside>
|
||
<script type="module">
|
||
import { createGraphEngine, buildCommunityAggregationMarkers } from "${origin}/graph-engine-dist/engine.esm.js";
|
||
(async () => {
|
||
const graphData = ${JSON.stringify(input.data)};
|
||
const initialPins = ${JSON.stringify(input.pins)};
|
||
const initialAggregationMarkers = ${JSON.stringify(input.aggregationMarkers)};
|
||
const stage = document.getElementById("stage");
|
||
const drawer = document.getElementById("drawer");
|
||
const firstNodeId = graphData.nodes[0]?.id || null;
|
||
const firstCommunityId = graphData.nodes.find((node) => node.community)?.community || null;
|
||
const productionStart = performance.now();
|
||
let loadingStateSeenAtMs = null;
|
||
let currentPins = initialPins;
|
||
let searchResultIds = [];
|
||
let selectedContainerId = null;
|
||
let selectedNodeId = null;
|
||
let lastSelectionKind = null;
|
||
let lastSelection = null;
|
||
let lastVisibility = {
|
||
searchQuery: "",
|
||
searchResultIds: [],
|
||
typeFilters: {},
|
||
temporaryObject: null
|
||
};
|
||
stage.dataset.loadingState = "sigma-global-loading";
|
||
loadingStateSeenAtMs = performance.now() - productionStart;
|
||
stage.addEventListener("click", (event) => {
|
||
const target = event.target instanceof Element ? event.target : null;
|
||
if (target?.closest(".sigma-global-node-hit-target")) lastSelectionKind = "node";
|
||
if (target?.closest(".sigma-global-community-region")) lastSelectionKind = "community";
|
||
}, true);
|
||
|
||
try {
|
||
const engine = createGraphEngine(stage, {
|
||
data: graphData,
|
||
pins: currentPins,
|
||
theme: "shan-shui",
|
||
aggregationMarkers: [],
|
||
capabilities: {
|
||
persistPins(pins) {
|
||
currentPins = pins;
|
||
return Promise.resolve();
|
||
},
|
||
onSelectionChange(selection) {
|
||
lastSelection = selection;
|
||
if (lastSelectionKind === "node") {
|
||
selectedNodeId = selection?.nodeIds?.length === 1 ? selection.nodeIds[0] : null;
|
||
selectedContainerId = null;
|
||
} else if (lastSelectionKind === "community") {
|
||
selectedNodeId = null;
|
||
selectedContainerId = selection?.communityIds?.length ? selection.communityIds[0] : null;
|
||
}
|
||
},
|
||
onSelectionClear() {
|
||
lastSelection = null;
|
||
selectedNodeId = null;
|
||
selectedContainerId = null;
|
||
lastSelectionKind = null;
|
||
},
|
||
onVisibilityStateChange(state) {
|
||
lastVisibility = state;
|
||
searchResultIds = state.searchResultIds || [];
|
||
}
|
||
}
|
||
});
|
||
function hasProductionSigma() {
|
||
return Boolean(document.querySelector(".sigma-global-renderer[data-renderer='sigma-global']"));
|
||
}
|
||
|
||
function routeId() {
|
||
return document.querySelector(".sigma-global-route[data-route]")?.dataset.route ||
|
||
document.querySelector(".graph-over-limit-notice-view[data-route]")?.dataset.route ||
|
||
(domSvgGraphRoot() ? "dom-svg-community" : "") ||
|
||
"unknown";
|
||
}
|
||
|
||
function domSvgGraphRoot() {
|
||
return Array.from(document.querySelectorAll(".llm-wiki-graph-engine"))
|
||
.find((element) => !element.closest(".sigma-global-route")) || null;
|
||
}
|
||
|
||
function fallbackRouteElement() {
|
||
return document.querySelector(".graph-over-limit-notice-view[data-route]") || domSvgGraphRoot();
|
||
}
|
||
|
||
function waitForProductionSigma(timeoutMs = 10000) {
|
||
const started = performance.now();
|
||
return new Promise((resolve, reject) => {
|
||
if (hasProductionSigma()) {
|
||
resolve(productionProbe());
|
||
return;
|
||
}
|
||
let observer = null;
|
||
let interval = null;
|
||
let timeout = null;
|
||
function cleanup() {
|
||
if (observer) observer.disconnect();
|
||
if (interval) window.clearInterval(interval);
|
||
if (timeout) window.clearTimeout(timeout);
|
||
}
|
||
function finish() {
|
||
cleanup();
|
||
resolve(productionProbe());
|
||
}
|
||
function fail(error) {
|
||
cleanup();
|
||
reject(error);
|
||
}
|
||
observer = new MutationObserver(() => {
|
||
if (hasProductionSigma()) finish();
|
||
});
|
||
observer.observe(document.body, { childList: true, subtree: true });
|
||
interval = window.setInterval(() => {
|
||
if (hasProductionSigma()) {
|
||
finish();
|
||
return;
|
||
}
|
||
const fallback = fallbackRouteElement();
|
||
if (fallback && !document.querySelector(".sigma-global-route[data-route='sigma-global']")) {
|
||
fail(new Error("production Sigma route fell back before renderer became ready"));
|
||
}
|
||
}, 10);
|
||
timeout = window.setTimeout(() => {
|
||
fail(new Error("timed out waiting for production Sigma renderer"));
|
||
}, timeoutMs);
|
||
function tick() {
|
||
if (hasProductionSigma()) {
|
||
finish();
|
||
return;
|
||
}
|
||
const fallback = fallbackRouteElement();
|
||
if (fallback && !document.querySelector(".sigma-global-route[data-route='sigma-global']")) {
|
||
fail(new Error("production Sigma route fell back before renderer became ready"));
|
||
return;
|
||
}
|
||
if (performance.now() - started > timeoutMs) {
|
||
fail(new Error("timed out waiting for production Sigma renderer"));
|
||
return;
|
||
}
|
||
requestAnimationFrame(tick);
|
||
}
|
||
});
|
||
}
|
||
|
||
function waitForSelectionState(kind, id, timeoutMs = 10000) {
|
||
const started = performance.now();
|
||
return new Promise((resolve, reject) => {
|
||
function selectedCommunityRegionReady() {
|
||
if (kind !== "community") return true;
|
||
return Array.from(document.querySelectorAll(".sigma-global-community-region"))
|
||
.some((element) => element.dataset.communityId === id && element.dataset.selected === "true");
|
||
}
|
||
function done() {
|
||
if (kind === "community") {
|
||
return routeId() === "sigma-global"
|
||
&& lastSelectionKind === "community"
|
||
&& selectedContainerId === id
|
||
&& selectedCommunityRegionReady();
|
||
}
|
||
if (kind === "node") {
|
||
return lastSelectionKind === "node" && selectedNodeId === id;
|
||
}
|
||
return false;
|
||
}
|
||
function tick() {
|
||
if (done()) {
|
||
resolve(productionProbe({ canvasSignal: false }));
|
||
return;
|
||
}
|
||
if (performance.now() - started > timeoutMs) {
|
||
reject(new Error("timed out waiting for " + kind + " selection " + id));
|
||
return;
|
||
}
|
||
requestAnimationFrame(tick);
|
||
}
|
||
tick();
|
||
});
|
||
}
|
||
|
||
function productionProbe(options = {}) {
|
||
const includeCanvasSignal = options.canvasSignal === true;
|
||
const sigmaRoot = document.querySelector(".sigma-global-renderer[data-renderer='sigma-global']");
|
||
const canvasSignal = sigmaRoot && includeCanvasSignal ? sigmaCanvasSignal(sigmaRoot) : { nonblank: null, sampleCount: 0 };
|
||
const hitTargetCount = document.querySelectorAll(".sigma-global-node-hit-target, .sigma-global-community-region").length;
|
||
return {
|
||
productionPath: Boolean(sigmaRoot),
|
||
route: routeId(),
|
||
canvasCount: sigmaRoot ? sigmaRoot.querySelectorAll("canvas").length : 0,
|
||
canvasNonBlank: canvasSignal.nonblank,
|
||
canvasPixelSampleCount: canvasSignal.sampleCount,
|
||
visibleSignal: Boolean(sigmaRoot) && hitTargetCount > 0,
|
||
hitTargetCount,
|
||
sigmaRendererCount: document.querySelectorAll(".sigma-global-renderer[data-renderer='sigma-global']").length,
|
||
fallbackCount: document.querySelectorAll(".graph-over-limit-notice-view").length + (domSvgGraphRoot() ? 1 : 0)
|
||
};
|
||
}
|
||
|
||
function pickFarthestZoomAnchor() {
|
||
const targets = Array.from(document.querySelectorAll(".sigma-global-node-hit-target"));
|
||
const cx = window.innerWidth / 2;
|
||
const cy = window.innerHeight / 2;
|
||
let best = null;
|
||
let bestDist = -1;
|
||
for (const el of targets) {
|
||
const rect = el.getBoundingClientRect();
|
||
if (!rect.width && !rect.height) continue;
|
||
const dist = Math.hypot((rect.left + rect.width / 2) - cx, (rect.top + rect.height / 2) - cy);
|
||
if (dist > bestDist) {
|
||
bestDist = dist;
|
||
best = el;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function zoomControlsSetup() {
|
||
const groups = document.querySelectorAll(".graph-zoom-controls");
|
||
const buttons = document.querySelectorAll(".graph-zoom-button");
|
||
const labels = Array.from(buttons).map((button) => button.getAttribute("aria-label"));
|
||
const anchor = pickFarthestZoomAnchor();
|
||
window.__zoomAnchor = anchor || null;
|
||
return {
|
||
groupCount: groups.length,
|
||
buttonCount: buttons.length,
|
||
labels,
|
||
hasAnchor: Boolean(anchor)
|
||
};
|
||
}
|
||
|
||
function zoomAnchorRect() {
|
||
const anchor = window.__zoomAnchor;
|
||
if (!anchor) return null;
|
||
const rect = anchor.getBoundingClientRect();
|
||
return { x: rect.left, y: rect.top };
|
||
}
|
||
|
||
async function clickZoomIn() {
|
||
const button = document.querySelector(".graph-zoom-button[aria-label='放大图谱']");
|
||
if (button) button.click();
|
||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||
}
|
||
|
||
function findSigmaMouseCanvas(root) {
|
||
const canvases = root.querySelectorAll("canvas");
|
||
for (let index = 0; index < canvases.length; index += 1) {
|
||
if (canvases[index].style && canvases[index].style.touchAction === "none") return canvases[index];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function dispatchSigmaWheel(targetSelector, x, y, deltaY) {
|
||
const root = document.querySelector(".sigma-global-renderer");
|
||
const target = targetSelector
|
||
? document.querySelector(targetSelector)
|
||
: (root ? findSigmaMouseCanvas(root) : null);
|
||
if (!target) return false;
|
||
const rect = target.getBoundingClientRect();
|
||
target.dispatchEvent(new WheelEvent("wheel", {
|
||
deltaY: deltaY,
|
||
clientX: x + rect.left,
|
||
clientY: y + rect.top,
|
||
bubbles: true,
|
||
cancelable: true
|
||
}));
|
||
return true;
|
||
}
|
||
|
||
function sigmaCanvasSignal(root) {
|
||
let sampleCount = 0;
|
||
for (const canvas of Array.from(root.querySelectorAll("canvas"))) {
|
||
if (!canvas.width || !canvas.height) continue;
|
||
try {
|
||
const sampleSize = 16;
|
||
const sampler = document.createElement("canvas");
|
||
sampler.width = sampleSize;
|
||
sampler.height = sampleSize;
|
||
const context = sampler.getContext("2d", { willReadFrequently: true });
|
||
if (!context) continue;
|
||
context.drawImage(canvas, 0, 0, sampleSize, sampleSize);
|
||
const data = context.getImageData(0, 0, sampleSize, sampleSize).data;
|
||
for (let index = 0; index < data.length; index += 4) {
|
||
const alpha = data[index + 3];
|
||
const luminance = data[index] + data[index + 1] + data[index + 2];
|
||
if (alpha > 0 && luminance > 0) sampleCount += 1;
|
||
}
|
||
if (sampleCount > 0) return { nonblank: true, sampleCount };
|
||
} catch {
|
||
continue;
|
||
}
|
||
}
|
||
return { nonblank: false, sampleCount };
|
||
}
|
||
|
||
function firstSearchHitId() {
|
||
return searchResultIds[0] || lastVisibility.searchResultIds?.[0] || null;
|
||
}
|
||
|
||
function overlayCenter(selector) {
|
||
const element = document.querySelector(selector);
|
||
if (!element) return null;
|
||
const rect = element.getBoundingClientRect();
|
||
return {
|
||
x: rect.left + rect.width / 2,
|
||
y: rect.top + rect.height / 2,
|
||
width: rect.width,
|
||
height: rect.height,
|
||
id: element.dataset.id || element.dataset.nodeId || element.dataset.communityId || element.dataset.aggregationId || null,
|
||
communityId: element.dataset.communityId || null,
|
||
aggregationId: element.dataset.aggregationId || null
|
||
};
|
||
}
|
||
|
||
function readableCommunityId() {
|
||
const counts = new Map();
|
||
for (const node of graphData.nodes) {
|
||
if (!node.community) continue;
|
||
counts.set(node.community, (counts.get(node.community) || 0) + 1);
|
||
}
|
||
for (const [id, count] of counts) {
|
||
if (count <= 500) return id;
|
||
}
|
||
return firstCommunityId;
|
||
}
|
||
|
||
function largestCommunitySize() {
|
||
const counts = new Map();
|
||
for (const node of graphData.nodes) {
|
||
if (!node.community) continue;
|
||
counts.set(node.community, (counts.get(node.community) || 0) + 1);
|
||
}
|
||
return Math.max(0, ...counts.values());
|
||
}
|
||
|
||
function markersFor(ids, selectedIds = []) {
|
||
return buildCommunityAggregationMarkers(graphData, {
|
||
pins: currentPins,
|
||
searchResultIds: ids,
|
||
selectedNodeIds: selectedIds,
|
||
minCommunitySize: 80
|
||
});
|
||
}
|
||
|
||
function searchHighlight(query) {
|
||
const input = document.querySelector(".graph-search-input");
|
||
if (!input) throw new Error("Sigma search input not found");
|
||
input.focus();
|
||
input.value = String(query || "");
|
||
input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: String(query || "") }));
|
||
return {
|
||
hits: searchResultIds.length,
|
||
selectedCount: 0,
|
||
inputValue: input.value,
|
||
visibilityHits: lastVisibility.searchResultIds?.length || 0,
|
||
production: productionProbe({ canvasSignal: false })
|
||
};
|
||
}
|
||
|
||
function nodeHitTarget() {
|
||
const id = firstSearchHitId() || firstNodeId;
|
||
if (!id) return null;
|
||
return overlayCenter('.sigma-global-node-hit-target[data-node-id="' + CSS.escape(id) + '"]') ||
|
||
overlayCenter(".sigma-global-node-hit-target[data-search-hit='true']") ||
|
||
overlayCenter(".sigma-global-node-hit-target");
|
||
}
|
||
|
||
function containerHitTarget(id) {
|
||
if (!id) return null;
|
||
return overlayCenter('.sigma-global-community-region[data-community-id="' + CSS.escape(id) + '"]') ||
|
||
overlayCenter(".sigma-global-community-region");
|
||
}
|
||
|
||
function communityRegionState(id) {
|
||
const element = id
|
||
? document.querySelector('.sigma-global-community-region[data-community-id="' + CSS.escape(id) + '"]')
|
||
: document.querySelector(".sigma-global-community-region");
|
||
const overlay = document.querySelector(".sigma-global-overlay");
|
||
if (!element) {
|
||
return {
|
||
exists: false,
|
||
selected: false,
|
||
overlayTransform: overlay?.style.transform || "",
|
||
left: 0,
|
||
top: 0,
|
||
width: 0,
|
||
height: 0,
|
||
id: id || null
|
||
};
|
||
}
|
||
const rect = element.getBoundingClientRect();
|
||
return {
|
||
exists: true,
|
||
selected: element.dataset.selected === "true",
|
||
overlayTransform: overlay?.style.transform || "",
|
||
left: rect.left,
|
||
top: rect.top,
|
||
width: rect.width,
|
||
height: rect.height,
|
||
id: element.dataset.communityId || id || null
|
||
};
|
||
}
|
||
|
||
function summaryPayload() {
|
||
if (selectedContainerId) return engine.summarizeCommunity(selectedContainerId, { searchResultIds });
|
||
if (selectedNodeId) return engine.summarizeNode(selectedNodeId, { searchResultIds });
|
||
return engine.summarizeGlobal({ searchResultIds });
|
||
}
|
||
|
||
function openDrawer() {
|
||
const payload = summaryPayload();
|
||
drawer.innerHTML = "";
|
||
const card = document.createElement("article");
|
||
card.className = "summary-card";
|
||
const kicker = document.createElement("div");
|
||
kicker.className = "summary-kicker";
|
||
kicker.textContent = payload.kind || "summary";
|
||
const title = document.createElement("h2");
|
||
title.className = "summary-title";
|
||
title.textContent = payload.label || payload.title || payload.nodeId || payload.communityId || "Graph";
|
||
const facts = document.createElement("div");
|
||
facts.className = "summary-facts";
|
||
const factEntries = [
|
||
["nodes", payload.nodeCount ?? payload.totalNodes ?? payload.connectionCount ?? graphData.nodes.length],
|
||
["edges", payload.edgeCount ?? payload.totalEdges ?? graphData.edges.length],
|
||
["search", payload.searchResultIds?.length ?? Number(Boolean(payload.searchHit))],
|
||
["commands", payload.commands?.length ?? 0]
|
||
];
|
||
for (const [label, value] of factEntries) {
|
||
const row = document.createElement("div");
|
||
row.className = "summary-fact";
|
||
const lab = document.createElement("span");
|
||
lab.className = "summary-fact-label";
|
||
lab.textContent = label;
|
||
const val = document.createElement("span");
|
||
val.className = "summary-fact-value";
|
||
val.textContent = String(value ?? 0);
|
||
row.append(lab, val);
|
||
facts.append(row);
|
||
}
|
||
card.append(kicker, title, facts);
|
||
const items = payload.strongestRelations || payload.topCommunities || payload.bridgeRelations || payload.commands || [];
|
||
if (items.length) {
|
||
const list = document.createElement("ul");
|
||
list.className = "summary-list";
|
||
for (const item of items.slice(0, 8)) {
|
||
const li = document.createElement("li");
|
||
li.className = "summary-item";
|
||
li.textContent = item.label || item.kind || item.toNodeId || item.communityId || "item";
|
||
list.append(li);
|
||
}
|
||
card.append(list);
|
||
}
|
||
drawer.append(card);
|
||
drawer.dataset.open = "true";
|
||
return {
|
||
open: true,
|
||
kind: payload.kind,
|
||
facts: facts.children.length,
|
||
items: drawer.querySelectorAll(".summary-item").length,
|
||
production: productionProbe({ canvasSignal: false })
|
||
};
|
||
}
|
||
|
||
async function enterCommunity(id) {
|
||
if (!id) return { selectedContainerId: null, route: routeId() };
|
||
if (largestCommunitySize() > 500) {
|
||
if (selectedContainerId !== id) {
|
||
const selection = engine.select({ kind: "community", id });
|
||
lastSelection = selection;
|
||
lastSelectionKind = "community";
|
||
selectedContainerId = id;
|
||
selectedNodeId = null;
|
||
engine.setAggregationMarkers(markersFor(searchResultIds, selection?.nodeIds || []));
|
||
}
|
||
await waitForSelectionState("community", id, 10000);
|
||
return { selectedContainerId, route: routeId(), aggregationPath: true };
|
||
}
|
||
selectedContainerId = id;
|
||
lastSelectionKind = "community";
|
||
lastSelection = engine.focusCommunity(id);
|
||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||
return { selectedContainerId: id, route: routeId(), domCommunity: Boolean(domSvgGraphRoot()) };
|
||
}
|
||
|
||
async function returnGlobal(waitForReady = true) {
|
||
if (largestCommunitySize() > 500 && hasProductionSigma()) {
|
||
engine.resetView();
|
||
selectedContainerId = null;
|
||
selectedNodeId = null;
|
||
lastSelection = null;
|
||
lastSelectionKind = null;
|
||
if (!waitForReady) return { selectedContainerId, route: routeId(), production: productionProbe({ canvasSignal: false }) };
|
||
return { selectedContainerId, route: routeId(), production: productionProbe({ canvasSignal: false }) };
|
||
}
|
||
engine.resetView();
|
||
selectedContainerId = null;
|
||
selectedNodeId = null;
|
||
lastSelection = null;
|
||
lastSelectionKind = null;
|
||
if (!waitForReady) return { selectedContainerId, route: routeId(), production: productionProbe({ canvasSignal: false }) };
|
||
const production = await waitForProductionSigma(10000);
|
||
return { selectedContainerId, route: routeId(), production };
|
||
}
|
||
|
||
await Promise.resolve();
|
||
await new Promise((resolve) => window.setTimeout(resolve, 20));
|
||
await waitForProductionSigma(10000);
|
||
stage.dataset.loadingState = "sigma-global-ready";
|
||
window.__sigmaProductionRenderStartedAt = productionStart;
|
||
window.__sigmaProductionRenderFinishedAt = performance.now();
|
||
window.__sigmaProductionLoadingStateSeenAtMs = loadingStateSeenAtMs;
|
||
window.__sigmaProduction = {
|
||
ready: true,
|
||
engine,
|
||
firstNodeId,
|
||
firstCommunityId: readableCommunityId(),
|
||
searchHighlight,
|
||
nodeHitTarget,
|
||
containerHitTarget,
|
||
communityRegionState,
|
||
openDrawer,
|
||
enterCommunity,
|
||
returnGlobal,
|
||
productionProbe,
|
||
zoomControlsSetup,
|
||
zoomAnchorRect,
|
||
clickZoomIn,
|
||
dispatchSigmaWheel,
|
||
counts(options = {}) {
|
||
const probe = productionProbe({ canvasSignal: options.canvasSignal === true });
|
||
return {
|
||
nodes: graphData.nodes.length,
|
||
edges: graphData.edges.length,
|
||
route: routeId(),
|
||
selectedNodeId,
|
||
selectedContainerId,
|
||
lastSelectionKind,
|
||
searchResultCount: searchResultIds.length,
|
||
lastSelectionId: lastSelection?.id || null,
|
||
lastSelectionNodeIds: lastSelection?.nodeIds || [],
|
||
lastSelectionCommunityIds: lastSelection?.communityIds || [],
|
||
visibilitySearchResultCount: lastVisibility.searchResultIds?.length ?? 0,
|
||
domNodeCount: document.querySelectorAll("*").length,
|
||
visibleCardCount: drawer.querySelectorAll(".summary-card").length,
|
||
productionPath: probe.productionPath,
|
||
topLevelProductionPath: probe.productionPath && probe.route === "sigma-global" && probe.sigmaRendererCount === 1 && probe.fallbackCount === 0,
|
||
canvasCount: probe.canvasCount,
|
||
canvasNonBlank: probe.canvasNonBlank,
|
||
canvasPixelSampleCount: probe.canvasPixelSampleCount,
|
||
visibleSignal: probe.visibleSignal,
|
||
hitTargetCount: probe.hitTargetCount,
|
||
sigmaRendererCount: probe.sigmaRendererCount,
|
||
loadingState: stage.dataset.loadingState || "",
|
||
loadingStateSeenAtMs
|
||
};
|
||
}
|
||
};
|
||
} catch (error) {
|
||
window.__sigmaProductionError = error instanceof Error ? error.message : String(error);
|
||
throw error;
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
`);
|
||
return file;
|
||
}
|
||
|
||
async function measureShape(browser: BrowserLike, metadata: LargeGraphFixtureMetadata, url: string): Promise<PerformanceRecord[]> {
|
||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
|
||
const diagnostics: string[] = [];
|
||
page.on?.("console", (message: { type(): string; text(): string }) => {
|
||
diagnostics.push(`console.${message.type()}: ${message.text()}`);
|
||
});
|
||
page.on?.("pageerror", (error: Error) => {
|
||
diagnostics.push(`pageerror: ${error.message}`);
|
||
});
|
||
page.on?.("requestfailed", (request: { url(): string; failure(): { errorText?: string } | null }) => {
|
||
diagnostics.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ""}`.trim());
|
||
});
|
||
page.on?.("response", (response: { status(): number; url(): string }) => {
|
||
if (response.status() >= 400) diagnostics.push(`response.${response.status()}: ${response.url()}`);
|
||
});
|
||
page.setDefaultTimeout(timeoutFor(metadata));
|
||
page.setDefaultNavigationTimeout(45_000);
|
||
await page.addInitScript(NAME_HELPER_INIT_SCRIPT);
|
||
const records: PerformanceRecord[] = [];
|
||
try {
|
||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: navigationTimeoutFor(metadata) });
|
||
try {
|
||
await page.waitForFunction(() => Boolean((window as any).__sigmaProduction?.ready || (window as any).__sigmaProductionError));
|
||
} catch (error) {
|
||
throw new Error(`${errorDetail(error)}; diagnostics=${diagnostics.slice(-12).join(" | ") || "none"}`);
|
||
}
|
||
const pageError = await page.evaluate(() => (window as any).__sigmaProductionError || null);
|
||
if (pageError) throw new Error(`${String(pageError)}; diagnostics=${diagnostics.slice(-12).join(" | ") || "none"}`);
|
||
const timing = await page.evaluate(() => ({
|
||
started: (window as any).__sigmaProductionRenderStartedAt,
|
||
finished: (window as any).__sigmaProductionRenderFinishedAt,
|
||
loadingStateSeenAtMs: (window as any).__sigmaProductionLoadingStateSeenAtMs
|
||
}));
|
||
const duration = typeof timing.started === "number" && typeof timing.finished === "number"
|
||
? timing.finished - timing.started
|
||
: 0;
|
||
const initialRecord = await recordFromPage(page, metadata, {
|
||
action: "initial_render",
|
||
duration_ms: duration,
|
||
pass: true,
|
||
artifact_path: resultPath
|
||
});
|
||
initialRecord.loading_state_seen_at_ms = typeof timing.loadingStateSeenAtMs === "number"
|
||
? round(timing.loadingStateSeenAtMs)
|
||
: null;
|
||
if (metadata.nodes >= 10000 && (initialRecord.loading_state_seen_at_ms == null || initialRecord.loading_state_seen_at_ms > 250)) {
|
||
initialRecord.pass = false;
|
||
initialRecord.failure_class = "loading_state_late";
|
||
initialRecord.failure_detail = `loading_state_seen_at_ms=${initialRecord.loading_state_seen_at_ms ?? "null"}; ceiling=250`;
|
||
}
|
||
records.push(initialRecord);
|
||
for (const action of [
|
||
() => measureWheelZoom(page, metadata),
|
||
() => measureDrag(page, metadata),
|
||
() => measureSearch(page, metadata),
|
||
() => measurePointSelect(page, metadata),
|
||
() => measureContainerSelect(page, metadata),
|
||
() => measureSpotlightAnimation(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)));
|
||
records.push(await safeMeasure(page, metadata, () => measureZoomControls(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> {
|
||
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);
|
||
}
|
||
return frameSampleRecord(page, metadata, { action: "wheel_zoom", runs });
|
||
}
|
||
|
||
async function waitForAnchorStable(page: PageLike, maxFrames = 12): Promise<void> {
|
||
let prev = await page.evaluate(() => (window as any).__sigmaProduction.zoomAnchorRect()) as { x: number; y: number } | null;
|
||
for (let i = 0; i < maxFrames; i += 1) {
|
||
await waitForAnimationFrames(page, 1);
|
||
const cur = await page.evaluate(() => (window as any).__sigmaProduction.zoomAnchorRect()) as { x: number; y: number } | null;
|
||
if (prev && cur && Math.hypot(prev.x - cur.x, prev.y - cur.y) < 0.1) return;
|
||
prev = cur;
|
||
}
|
||
}
|
||
|
||
async function measureZoomControls(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||
const started = performance.now();
|
||
const setup = await page.evaluate(() => (window as any).__sigmaProduction.zoomControlsSetup()) as {
|
||
groupCount: number;
|
||
buttonCount: number;
|
||
labels: string[];
|
||
hasAnchor: boolean;
|
||
};
|
||
|
||
const structureFailures: string[] = [];
|
||
if (setup.groupCount !== 1) structureFailures.push(`groupCount=${setup.groupCount}`);
|
||
if (setup.buttonCount !== 2) structureFailures.push(`buttonCount=${setup.buttonCount}`);
|
||
if (!setup.labels.includes("放大图谱")) structureFailures.push("missing zoom-in label");
|
||
if (!setup.labels.includes("缩小图谱")) structureFailures.push("missing zoom-out label");
|
||
if (!setup.hasAnchor) structureFailures.push("no zoom anchor hit target");
|
||
|
||
const rect0 = (await page.evaluate(() => (window as any).__sigmaProduction.zoomAnchorRect())) as { x: number; y: number } | null;
|
||
|
||
if (structureFailures.length || !rect0) {
|
||
return recordFromPage(page, metadata, {
|
||
action: "zoom_controls",
|
||
duration_ms: performance.now() - started,
|
||
pass: false,
|
||
failure_class: "zoom_controls_structure",
|
||
failure_detail: structureFailures.length ? structureFailures.join("; ") : "no anchor rect",
|
||
artifact_path: resultPath
|
||
});
|
||
}
|
||
|
||
await page.evaluate(() => (window as any).__sigmaProduction.clickZoomIn());
|
||
await waitForAnchorStable(page);
|
||
const rect1 = (await page.evaluate(() => (window as any).__sigmaProduction.zoomAnchorRect())) as { x: number; y: number } | null;
|
||
|
||
await page.evaluate((args: [number, number, number]) => (window as any).__sigmaProduction.dispatchSigmaWheel(null, args[0], args[1], args[2]), [720, 480, 4]);
|
||
await waitForAnchorStable(page);
|
||
const rect2 = (await page.evaluate(() => (window as any).__sigmaProduction.zoomAnchorRect())) as { x: number; y: number } | null;
|
||
|
||
await page.evaluate((args: [number, number, number]) => (window as any).__sigmaProduction.dispatchSigmaWheel(null, args[0], args[1], args[2]), [720, 480, 80]);
|
||
await waitForAnchorStable(page);
|
||
const rect3 = (await page.evaluate(() => (window as any).__sigmaProduction.zoomAnchorRect())) as { x: number; y: number } | null;
|
||
|
||
await page.evaluate((args: [string, number, number, number]) => (window as any).__sigmaProduction.dispatchSigmaWheel(args[0], args[1], args[2], args[3]), [".graph-zoom-controls", 28, 920, 80]);
|
||
await waitForAnchorStable(page);
|
||
const rect4 = (await page.evaluate(() => (window as any).__sigmaProduction.zoomAnchorRect())) as { x: number; y: number } | null;
|
||
|
||
// 注:这里 deliberately 不测"滚轮停止后是否还在追动画(积压/卡顿)"。
|
||
// dispatchSigmaWheel 是合成 wheel 事件,密集 dispatch(每次跨进程 IPC)时 overlay
|
||
// 的 reposition 会跨帧追赶相机状态,实测会产生 60-80px 的"追赶位移"伪影,与真实
|
||
// 触控板的连续缩放手感无关,无法可靠反映设计 §5 的"不积压动画"。真实触控板的
|
||
// 手感以实机为准(wheel 已改为即时 setState)。上方 smallMove<largeMove 已覆盖
|
||
// "按真实 deltaY 连续缩放、不跳档"这一可自动化验证的语义。
|
||
|
||
await page.evaluate(() => {
|
||
const engine = (window as any).__sigmaProduction?.engine;
|
||
if (engine?.resetView) engine.resetView();
|
||
});
|
||
await waitForAnimationFrames(page, 3);
|
||
|
||
const move = (a: { x: number; y: number } | null, b: { x: number; y: number } | null): number | null => {
|
||
if (!a || !b) return null;
|
||
return Math.hypot(a.x - b.x, a.y - b.y);
|
||
};
|
||
const zoomInMove = move(rect0, rect1);
|
||
const smallMove = move(rect1, rect2);
|
||
const largeMove = move(rect2, rect3);
|
||
const overControlMove = move(rect3, rect4);
|
||
|
||
const behaviorFailures: string[] = [];
|
||
if (zoomInMove == null || zoomInMove <= 0.5) behaviorFailures.push(`zoomInMove=${zoomInMove}`);
|
||
if (smallMove == null || smallMove <= 0) behaviorFailures.push(`smallMove=${smallMove}`);
|
||
if (largeMove == null || smallMove == null || largeMove <= smallMove) behaviorFailures.push(`largeMove=${largeMove}<=smallMove=${smallMove}`);
|
||
if (overControlMove == null || (smallMove != null && overControlMove >= smallMove)) behaviorFailures.push(`overControlMove=${overControlMove}>=smallMove=${smallMove}`);
|
||
|
||
return recordFromPage(page, metadata, {
|
||
action: "zoom_controls",
|
||
duration_ms: performance.now() - started,
|
||
pass: behaviorFailures.length === 0,
|
||
failure_class: behaviorFailures.length ? "zoom_controls_behavior" : null,
|
||
failure_detail: behaviorFailures.length ? behaviorFailures.join("; ") : null,
|
||
artifact_path: resultPath
|
||
});
|
||
}
|
||
|
||
async function measureDrag(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||
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);
|
||
}
|
||
return frameSampleRecord(page, metadata, { action: "drag", runs });
|
||
}
|
||
|
||
async function measureSearch(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||
const started = performance.now();
|
||
const result = await page.evaluate(() => (window as any).__sigmaProduction.searchHighlight("needle"));
|
||
await page.waitForFunction(
|
||
(expected: number) => ((window as any).__sigmaProduction?.counts?.().visibilitySearchResultCount ?? 0) === expected,
|
||
metadata.search_hits,
|
||
{ timeout: 4000 }
|
||
);
|
||
await waitForAnimationFrames(page, 3);
|
||
const counts = await page.evaluate(() => (window as any).__sigmaProduction.counts());
|
||
const hits = (counts as { visibilitySearchResultCount: number }).visibilitySearchResultCount;
|
||
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> {
|
||
await ensureSearchReady(page, metadata);
|
||
const target = await page.evaluate(() => (window as any).__sigmaProduction.nodeHitTarget());
|
||
const started = performance.now();
|
||
if (!target) throw new Error("measurePointSelect: no Sigma node hit target");
|
||
await clickPoint(page, target as PointerTarget);
|
||
await page.waitForFunction(
|
||
() => {
|
||
const counts = (window as any).__sigmaProduction?.counts?.();
|
||
return counts?.lastSelectionKind === "node" && (counts.lastSelectionNodeIds ?? []).length > 0;
|
||
},
|
||
undefined,
|
||
{ timeout: 4000 }
|
||
);
|
||
await waitForAnimationFrames(page, 3);
|
||
const counts = await page.evaluate(() => (window as any).__sigmaProduction.counts());
|
||
const actual = (counts as { selectedNodeId: string | null; lastSelectionNodeIds?: string[] }).selectedNodeId;
|
||
const nodeIds = (counts as { lastSelectionKind?: string | null; lastSelectionNodeIds?: string[] }).lastSelectionNodeIds ?? [];
|
||
const selectedNodeId = (counts as { lastSelectionKind?: string | null; lastSelectionNodeIds?: string[] }).lastSelectionKind === "node"
|
||
? nodeIds[0] ?? null
|
||
: null;
|
||
return recordFromPage(page, metadata, {
|
||
action: "point_select",
|
||
duration_ms: performance.now() - started,
|
||
pass: Boolean(selectedNodeId),
|
||
failure_class: Boolean(selectedNodeId) ? null : "selected_node_mismatch",
|
||
failure_detail: Boolean(selectedNodeId) ? null : `actual=${actual ?? "null"}; nodeIds=${nodeIds.join(",")}`,
|
||
artifact_path: resultPath
|
||
});
|
||
}
|
||
|
||
async function measureContainerSelect(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||
await ensureSearchReady(page, metadata);
|
||
const target = await page.evaluate(() => {
|
||
const trial = (window as any).__sigmaProduction;
|
||
return trial.containerHitTarget(trial.firstCommunityId);
|
||
});
|
||
const started = performance.now();
|
||
if (!target) throw new Error("measureContainerSelect: no Sigma container hit target");
|
||
await clickPoint(page, target as PointerTarget);
|
||
await page.waitForFunction(
|
||
() => {
|
||
const counts = (window as any).__sigmaProduction?.counts?.();
|
||
return counts?.lastSelectionKind === "community" && (counts.lastSelectionCommunityIds ?? []).length > 0;
|
||
},
|
||
undefined,
|
||
{ timeout: 4000 }
|
||
);
|
||
await waitForAnimationFrames(page, 3);
|
||
const counts = await page.evaluate(() => (window as any).__sigmaProduction.counts());
|
||
const actual = (counts as { selectedContainerId: string | null; lastSelectionCommunityIds?: string[] }).selectedContainerId;
|
||
const communityIds = (counts as { lastSelectionKind?: string | null; lastSelectionCommunityIds?: string[] }).lastSelectionCommunityIds ?? [];
|
||
const selectedCommunityId = clickedCommunityFromCounts(counts as { lastSelectionKind?: string | null; lastSelectionCommunityIds?: string[] });
|
||
return recordFromPage(page, metadata, {
|
||
action: "container_select",
|
||
duration_ms: performance.now() - started,
|
||
pass: Boolean(selectedCommunityId),
|
||
failure_class: Boolean(selectedCommunityId) ? null : "selected_container_mismatch",
|
||
failure_detail: Boolean(selectedCommunityId) ? null : `actual=${actual ?? "null"}; communityIds=${communityIds.join(",")}`,
|
||
artifact_path: resultPath
|
||
});
|
||
}
|
||
|
||
async function measureSpotlightAnimation(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||
await waitForSpotlightReady(page);
|
||
const target = await page.evaluate(() => {
|
||
const trial = (window as any).__sigmaProduction;
|
||
return trial.containerHitTarget(trial.firstCommunityId);
|
||
});
|
||
if (!target) throw new Error("measureSpotlightAnimation: no Sigma container hit target");
|
||
|
||
await clickPoint(page, target as PointerTarget);
|
||
await page.waitForFunction(
|
||
() => {
|
||
const counts = (window as any).__sigmaProduction?.counts?.();
|
||
return counts?.lastSelectionKind === "community" && (counts.lastSelectionCommunityIds || []).length > 0;
|
||
},
|
||
undefined,
|
||
{ timeout: 8000 }
|
||
);
|
||
const selectionCounts = await page.evaluate(() => (window as any).__sigmaProduction.counts()) as { lastSelectionCommunityIds?: string[] };
|
||
const selectedId = selectionCounts.lastSelectionCommunityIds?.[0] ?? null;
|
||
// 点击后的 selection rebuild 会短暂占用主线程;等选中完成后再采样,窗口对准 spotlight
|
||
// 相机动画的中后段(与 wheel/drag 一样用字符串式 sampleAnimationFrames)。
|
||
const run = await sampleAnimationFrames(page, 320);
|
||
await waitForSpotlightSettled(page, selectedId);
|
||
const region = await page.evaluate((id: string | null) => {
|
||
const trial = (window as any).__sigmaProduction;
|
||
return trial.communityRegionState(id);
|
||
}, selectedId) as SigmaSpotlightRegionState;
|
||
|
||
const failures: string[] = [];
|
||
if (!region.exists) failures.push("region_missing");
|
||
if (!region.selected) failures.push("region_not_selected");
|
||
if (region.width <= 0 || region.height <= 0) failures.push(`region_size=${region.width}x${region.height}`);
|
||
if (region.overlayTransform) failures.push(`overlay_transform_not_cleared=${region.overlayTransform}`);
|
||
|
||
return frameSampleRecord(page, metadata, {
|
||
action: "spotlight_animation",
|
||
runs: [run],
|
||
failureClass: failures.length ? "spotlight_animation_settle_failed" : null,
|
||
failureDetail: failures.length ? failures.join("; ") : null
|
||
});
|
||
}
|
||
|
||
interface SigmaSpotlightRegionState {
|
||
exists: boolean;
|
||
selected: boolean;
|
||
overlayTransform: string;
|
||
left: number;
|
||
top: number;
|
||
width: number;
|
||
height: number;
|
||
id: string | null;
|
||
}
|
||
|
||
async function waitForSpotlightReady(page: PageLike): Promise<void> {
|
||
// 先等上一个测量的 spotlight 动画真正结束(camera 静止):Sigma 的 setState
|
||
// 不会取消已排队的 animate,若在旧动画未结束时 returnGlobal,setState 会被覆盖,
|
||
// camera 仍停在旧社区,随后点击该社区不触发新动画(settled)。
|
||
await waitForStableCommunityRegion(page, null);
|
||
await page.evaluate(() => (window as any).__sigmaProduction.returnGlobal());
|
||
await page.waitForFunction(
|
||
() => {
|
||
const trial = (window as any).__sigmaProduction;
|
||
const counts = trial?.counts?.();
|
||
const region = trial?.communityRegionState?.(trial.firstCommunityId);
|
||
return counts?.lastSelectionKind == null
|
||
&& counts?.selectedContainerId == null
|
||
&& region?.exists
|
||
&& !region.overlayTransform
|
||
&& region.width > 0
|
||
&& region.height > 0;
|
||
},
|
||
undefined,
|
||
{ timeout: 8000 }
|
||
);
|
||
await waitForStableCommunityRegion(page, null);
|
||
}
|
||
|
||
async function waitForSpotlightSettled(page: PageLike, id: string | null): Promise<void> {
|
||
await page.waitForFunction(
|
||
(communityId: string | null) => {
|
||
const trial = (window as any).__sigmaProduction;
|
||
const counts = trial?.counts?.();
|
||
const region = trial?.communityRegionState?.(communityId);
|
||
return communityId
|
||
&& counts?.lastSelectionKind === "community"
|
||
&& (counts.lastSelectionCommunityIds || []).includes(communityId)
|
||
&& region?.exists
|
||
&& region.selected
|
||
&& !region.overlayTransform
|
||
&& region.width > 0
|
||
&& region.height > 0;
|
||
},
|
||
id,
|
||
{ timeout: 8000 }
|
||
);
|
||
await waitForStableCommunityRegion(page, id);
|
||
}
|
||
|
||
async function waitForStableCommunityRegion(page: PageLike, id: string | null, maxFrames = 12): Promise<void> {
|
||
let previous = await page.evaluate((communityId: string | null) => {
|
||
const trial = (window as any).__sigmaProduction;
|
||
return trial.communityRegionState(communityId || trial.firstCommunityId);
|
||
}, id) as SigmaSpotlightRegionState;
|
||
for (let index = 0; index < maxFrames; index += 1) {
|
||
await waitForAnimationFrames(page, 1);
|
||
const current = await page.evaluate((communityId: string | null) => {
|
||
const trial = (window as any).__sigmaProduction;
|
||
return trial.communityRegionState(communityId || trial.firstCommunityId);
|
||
}, id) as SigmaSpotlightRegionState;
|
||
const stable = previous.exists
|
||
&& current.exists
|
||
&& !current.overlayTransform
|
||
&& Math.hypot(current.left - previous.left, current.top - previous.top) < 0.1
|
||
&& Math.abs(current.width - previous.width) < 0.1
|
||
&& Math.abs(current.height - previous.height) < 0.1;
|
||
if (stable) return;
|
||
previous = current;
|
||
}
|
||
throw new Error(`waitForStableCommunityRegion never stabilized for ${id ?? "(first)"}: ${JSON.stringify(previous)}`);
|
||
}
|
||
|
||
async function measureDrawerOpen(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||
const started = performance.now();
|
||
const result = await page.evaluate(() => (window as any).__sigmaProduction.openDrawer());
|
||
await waitForAnimationFrames(page, 1);
|
||
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).__sigmaProduction;
|
||
return trial.enterCommunity(trial.firstCommunityId);
|
||
});
|
||
await waitForAnimationFrames(page);
|
||
const route = (result as { route?: string }).route;
|
||
return recordFromPage(page, metadata, {
|
||
action: "enter_community",
|
||
duration_ms: performance.now() - started,
|
||
pass: route === "unknown" ? false : true,
|
||
failure_class: route === "unknown" ? "community_route_unknown" : null,
|
||
failure_detail: route === "unknown" ? "route=unknown" : null,
|
||
artifact_path: resultPath
|
||
});
|
||
}
|
||
|
||
async function measureReturnGlobal(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||
const started = performance.now();
|
||
const result = await page.evaluate(() => (window as any).__sigmaProduction.returnGlobal(false));
|
||
const duration = performance.now() - started;
|
||
const probe = (result as { production?: { productionPath?: boolean }; selectedContainerId?: string | null }).production;
|
||
const selectedContainerId = (result as { selectedContainerId?: string | null }).selectedContainerId;
|
||
const readyProbe = await page.evaluate(() => (window as any).__sigmaProduction.productionProbe({ canvasSignal: false }));
|
||
return recordFromPage(page, metadata, {
|
||
action: "return_global",
|
||
duration_ms: duration,
|
||
pass: Boolean((readyProbe as { productionPath?: boolean }).productionPath) && selectedContainerId == null,
|
||
failure_class: Boolean((readyProbe as { productionPath?: boolean }).productionPath) && selectedContainerId == null ? null : "global_return_incomplete",
|
||
failure_detail: Boolean((readyProbe as { productionPath?: boolean }).productionPath) && selectedContainerId == null ? null : `productionPath=${probe?.productionPath}; readyProductionPath=${(readyProbe as { productionPath?: boolean }).productionPath}; selectedContainerId=${selectedContainerId ?? "null"}`,
|
||
artifact_path: resultPath
|
||
});
|
||
}
|
||
|
||
function allowsNonSigmaRouteForAction(action: string): boolean {
|
||
return action === "enter_community";
|
||
}
|
||
|
||
function clickedCommunityFromCounts(counts: { lastSelectionKind?: string | null; lastSelectionCommunityIds?: string[] }): string | null {
|
||
return counts.lastSelectionKind === "community" ? counts.lastSelectionCommunityIds?.[0] ?? null : null;
|
||
}
|
||
|
||
async function measureRepeatedCycles(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<PerformanceRecord> {
|
||
const cycleCount = metadata.nodes >= 10000 ? 6 : 3;
|
||
await settleMemory(page);
|
||
const before = await memoryMb(page);
|
||
const started = performance.now();
|
||
for (let index = 0; index < cycleCount; index += 1) {
|
||
await ensureSearchReady(page, metadata);
|
||
const nodeTarget = await page.evaluate(() => (window as any).__sigmaProduction.nodeHitTarget());
|
||
if (!nodeTarget) throw new Error("measureRepeatedCycles: no Sigma node hit target");
|
||
await clickPoint(page, nodeTarget as PointerTarget);
|
||
await waitForAnimationFrames(page, 2);
|
||
const containerTarget = await page.evaluate(() => {
|
||
const trial = (window as any).__sigmaProduction;
|
||
return trial.containerHitTarget(trial.firstCommunityId);
|
||
});
|
||
if (!containerTarget) throw new Error("measureRepeatedCycles: no Sigma container hit target");
|
||
await clickPoint(page, containerTarget as PointerTarget);
|
||
await page.evaluate(() => (window as any).__sigmaProduction.openDrawer());
|
||
await page.evaluate(() => (window as any).__sigmaProduction.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 ensureSearchReady(page: PageLike, metadata: LargeGraphFixtureMetadata): Promise<void> {
|
||
const count = await page.evaluate(() => (window as any).__sigmaProduction?.counts?.().visibilitySearchResultCount ?? 0);
|
||
if (count === metadata.search_hits) return;
|
||
await page.evaluate(() => (window as any).__sigmaProduction.searchHighlight("needle"));
|
||
await page.waitForFunction(
|
||
(expected: number) => ((window as any).__sigmaProduction?.counts?.().visibilitySearchResultCount ?? 0) === expected,
|
||
metadata.search_hits,
|
||
{ timeout: 4000 }
|
||
);
|
||
await waitForAnimationFrames(page, 3);
|
||
}
|
||
|
||
async function clickPoint(page: PageLike, target: PointerTarget): Promise<void> {
|
||
if (!Number.isFinite(target.x) || !Number.isFinite(target.y)) {
|
||
throw new Error(`invalid pointer target: x=${target.x}; y=${target.y}`);
|
||
}
|
||
await page.mouse.click(target.x, target.y);
|
||
// Sigma node hit-target overlays sit above the WebGL canvas and can drop
|
||
// playwright's trusted click under certain compositor/timing conditions.
|
||
// Re-dispatch a synthetic click by node id so the overlay's click handler
|
||
// reliably runs. Only node hit-targets carry a node id, so community-region
|
||
// clicks are unaffected.
|
||
await page.evaluate((id: string | null) => {
|
||
if (!id) return;
|
||
const el = document.querySelector('.sigma-global-node-hit-target[data-node-id="' + window.CSS.escape(id) + '"]');
|
||
if (el) el.click();
|
||
}, (target as unknown as { id?: string | null }).id ?? null);
|
||
}
|
||
|
||
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).__sigmaProduction;
|
||
const trialCounts = trial?.counts?.({ canvasSignal: true }) ?? {};
|
||
return {
|
||
dom_node_count: trialCounts.domNodeCount ?? document.querySelectorAll("*").length,
|
||
visible_node_count: trialCounts.nodes ?? null,
|
||
visible_edge_count: trialCounts.edges ?? null,
|
||
visible_label_count: 0,
|
||
visible_card_count: trialCounts.visibleCardCount ?? 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: trialCounts.productionPath ? "production-sigma-global" : "production-route-missing",
|
||
interaction_updated_objects: trialCounts.nodes ?? null,
|
||
interaction_hidden_objects: 0,
|
||
interaction_preserved_nodes: trialCounts.nodes ?? null,
|
||
interaction_max_updates: trialCounts.nodes ?? null,
|
||
production_route: trialCounts.route ?? null,
|
||
loading_state: trialCounts.loadingState || (trialCounts.productionPath ? "sigma-global-ready" : "sigma-global-not-ready"),
|
||
loading_state_seen_at_ms: typeof trialCounts.loadingStateSeenAtMs === "number" ? Math.round(trialCounts.loadingStateSeenAtMs * 10) / 10 : null,
|
||
production_path: Boolean(trialCounts.topLevelProductionPath),
|
||
sigma_canvas_count: trialCounts.canvasCount ?? 0,
|
||
sigma_canvas_nonblank: trialCounts.canvasNonBlank ?? false,
|
||
sigma_canvas_pixel_sample_count: trialCounts.canvasPixelSampleCount ?? 0,
|
||
sigma_visible_signal: trialCounts.visibleSignal ?? false,
|
||
sigma_hit_target_count: trialCounts.hitTargetCount ?? 0
|
||
};
|
||
});
|
||
const record: PerformanceRecord = {
|
||
...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
|
||
};
|
||
if (allowsNonSigmaRouteForAction(record.action) && record.production_route === "dom-svg-community") {
|
||
record.production_path = true;
|
||
}
|
||
if (!record.production_path && !record.failure_class && !allowsNonSigmaRouteForAction(record.action)) {
|
||
record.pass = false;
|
||
record.failure_class = "production_path_missing";
|
||
record.failure_detail = productionSignalFailureDetail(record);
|
||
}
|
||
if (record.production_path && !record.failure_class && !allowsNonSigmaRouteForAction(record.action)) {
|
||
const signalFailure = productionSignalFailureClass(record);
|
||
if (signalFailure) {
|
||
record.pass = false;
|
||
record.failure_class = signalFailure;
|
||
record.failure_detail = productionSignalFailureDetail(record);
|
||
}
|
||
}
|
||
return applyDurationGate(metadata, record);
|
||
}
|
||
|
||
function productionSignalFailureClass(record: PerformanceRecord): string | null {
|
||
if ((record.sigma_canvas_count ?? 0) < 1) return "sigma_canvas_missing";
|
||
if (record.sigma_canvas_nonblank !== true && record.sigma_visible_signal !== true) return "sigma_canvas_blank";
|
||
return null;
|
||
}
|
||
|
||
function productionSignalFailureDetail(record: PerformanceRecord): string {
|
||
return `route=${record.production_route ?? "unknown"}; sigma_canvas_count=${record.sigma_canvas_count ?? "null"}; sigma_canvas_nonblank=${String(record.sigma_canvas_nonblank)}; sigma_visible_signal=${String(record.sigma_visible_signal)}; sigma_hit_target_count=${record.sigma_hit_target_count ?? "null"}`;
|
||
}
|
||
|
||
function applyDurationGate(metadata: LargeGraphFixtureMetadata, record: PerformanceRecord): PerformanceRecord {
|
||
if (!DURATION_GATED_ACTIONS.has(record.action) || record.failure_class) return record;
|
||
const failure = durationFailureClass({ duration_ms: record.duration_ms }, metadata, record.action);
|
||
if (!failure) return record;
|
||
const limit = durationLimitMs(metadata, record.action);
|
||
return {
|
||
...record,
|
||
pass: false,
|
||
failure_class: failure,
|
||
failure_detail: `duration_ms=${record.duration_ms}; ceiling=${limit}`
|
||
};
|
||
}
|
||
|
||
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),
|
||
production_path: false,
|
||
production_route: "unknown",
|
||
loading_state: "not-run",
|
||
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,
|
||
production_route: null,
|
||
loading_state: null,
|
||
loading_state_seen_at_ms: null,
|
||
sigma_canvas_count: null,
|
||
sigma_canvas_nonblank: null,
|
||
sigma_canvas_pixel_sample_count: null,
|
||
sigma_visible_signal: null,
|
||
sigma_hit_target_count: null,
|
||
warmup_runs: undefined,
|
||
median_fps: undefined,
|
||
worst_run_fps: undefined,
|
||
worst_run_frame_p95_ms: undefined,
|
||
pass: false,
|
||
failure_class: null,
|
||
failure_detail: null,
|
||
artifact_path: artifactPath,
|
||
measured_at: new Date().toISOString()
|
||
};
|
||
}
|
||
|
||
async function settleMemory(page: PageLike): Promise<void> {
|
||
try {
|
||
await page.evaluate(() => {
|
||
const maybeGc = (globalThis as unknown as { gc?: () => void }).gc;
|
||
if (typeof maybeGc === "function") maybeGc();
|
||
});
|
||
} catch {
|
||
// Best effort only; Chromium exposes performance.memory even without GC.
|
||
}
|
||
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve(undefined)))));
|
||
}
|
||
|
||
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 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);
|
||
}
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
async function frameSampleRecord(
|
||
page: PageLike,
|
||
metadata: LargeGraphFixtureMetadata,
|
||
input: {
|
||
action: "wheel_zoom" | "drag" | "spotlight_animation";
|
||
runs: { fps: number; p95: number; durationMs: number }[];
|
||
failureClass?: string | null;
|
||
failureDetail?: string | null;
|
||
}
|
||
): 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 = await page.evaluate(() => (window as any).__sigmaProduction.productionProbe({ canvasSignal: false }));
|
||
const frameFailure = frameSampleFailureClass({ fps, frame_p95_ms: p95 });
|
||
const productionFailure = (probe as { productionPath?: boolean }).productionPath ? null : "production_path_missing";
|
||
// productionFailure(生产路径缺失)致命,优先于 settle 失败和帧指标,避免被表面错误掩盖。
|
||
const failureClass = productionFailure || input.failureClass || frameFailure;
|
||
const metricDetail = `median_fps=${fps}; median_frame_p95_ms=${p95}; floor=${FPS_FLOOR}; ceiling=${FRAME_P95_CEILING_MS}; production_path=${(probe as { productionPath?: boolean }).productionPath}`;
|
||
const failureDetail = failureClass
|
||
? [input.failureDetail, metricDetail].filter(Boolean).join("; ")
|
||
: null;
|
||
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: failureClass == null,
|
||
failure_class: failureClass,
|
||
failure_detail: failureDetail,
|
||
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|timed out/i.test(message)) return "timeout";
|
||
if (/WebGL|webgl/i.test(message)) return "webgl_unavailable";
|
||
if (/Target page|browser has been closed/i.test(message)) return "browser_closed";
|
||
if (/JavaScript heap|out of memory/i.test(message)) return "memory";
|
||
return "exception";
|
||
}
|
||
|
||
function errorDetail(error: unknown): string {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
return message.replace(/\s+/g, " ").slice(0, 500);
|
||
}
|
||
|
||
function inferActionName(error: unknown): string {
|
||
const stack = error instanceof Error ? error.stack || error.message : String(error);
|
||
const match = stack.match(/measure[A-Z][A-Za-z0-9_]*/);
|
||
if (!match) return "unknown_action";
|
||
return match[0].replace(/^measure/, "").replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
|
||
}
|
||
|
||
function timeoutFor(metadata: LargeGraphFixtureMetadata): number {
|
||
if (metadata.nodes >= 10000) return 25_000;
|
||
if (metadata.nodes >= 5000) return 18_000;
|
||
return 12_000;
|
||
}
|
||
|
||
function navigationTimeoutFor(metadata: LargeGraphFixtureMetadata): number {
|
||
if (metadata.nodes >= 10000) return 45_000;
|
||
if (metadata.nodes >= 5000) return 30_000;
|
||
return 20_000;
|
||
}
|
||
|
||
function round(value: number): number {
|
||
return Math.round(value * 10) / 10;
|
||
}
|
||
|
||
function escapeHtml(value: string): string {
|
||
return value.replace(/[&<>"']/g, (char) => ({
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
'"': """,
|
||
"'": "'"
|
||
}[char] ?? char));
|
||
}
|
||
|
||
interface BrowserLike {
|
||
newPage(options: { viewport: { width: number; height: number } }): Promise<PageLike>;
|
||
close(): Promise<void>;
|
||
}
|
||
|
||
interface PointerTarget {
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
id: string | null;
|
||
}
|
||
|
||
interface PageLike {
|
||
on?: (event: "console" | "pageerror" | "requestfailed", listener: (...args: any[]) => void) => void;
|
||
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>;
|
||
click(x: number, y: 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;
|
||
production_route: string | null;
|
||
loading_state: string | null;
|
||
loading_state_seen_at_ms: number | null;
|
||
sigma_canvas_count: number | null;
|
||
sigma_canvas_nonblank: boolean | null;
|
||
sigma_canvas_pixel_sample_count: number | null;
|
||
sigma_visible_signal: boolean | null;
|
||
sigma_hit_target_count: number | null;
|
||
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;
|
||
}
|