267 lines
11 KiB
TypeScript
267 lines
11 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
import { test } from "bun:test";
|
|
|
|
import { nodeWebObserveCollectViewNodeScript } from "../hwlab-node-web-observe-collect";
|
|
|
|
function shellQuote(value: string): string {
|
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
}
|
|
|
|
test("performance-summary labels LoAF-only evidence when CPU profile capture is pending", async () => {
|
|
const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-performance-"));
|
|
await mkdir(join(stateDir, "analysis"), { recursive: true });
|
|
await mkdir(join(stateDir, "commands", "pending"), { recursive: true });
|
|
await writeFile(join(stateDir, "manifest.json"), JSON.stringify({ jobId: "webobs-perf-test", status: "failed" }) + "\n");
|
|
await writeFile(join(stateDir, "heartbeat.json"), JSON.stringify({ status: "failed", updatedAt: "2026-07-02T12:08:06Z" }) + "\n");
|
|
await writeFile(join(stateDir, "performance-events.jsonl"), [
|
|
JSON.stringify({
|
|
type: "performance-event",
|
|
ts: "2026-07-02T12:07:00Z",
|
|
performance: { kind: "long-animation-frame", duration: 2083.8 },
|
|
}),
|
|
].join("\n") + "\n");
|
|
await writeFile(join(stateDir, "commands", "pending", "cmd-perf.json"), JSON.stringify({
|
|
id: "cmd-perf",
|
|
type: "performanceCapture",
|
|
createdAt: "2026-07-02T12:08:00Z",
|
|
}) + "\n");
|
|
await writeFile(join(stateDir, "analysis", "report.json"), JSON.stringify({
|
|
manifest: { status: "failed" },
|
|
heartbeat: { status: "failed", updatedAt: "2026-07-02T12:08:06Z" },
|
|
frontendPerformance: {
|
|
summary: {
|
|
eventCount: 1,
|
|
longTaskCount: 0,
|
|
longAnimationFrameCount: 1,
|
|
eventLoopGapCount: 0,
|
|
captureCount: 0,
|
|
scriptHotspotCount: 1,
|
|
maxLongAnimationFrameMs: 2083.8,
|
|
longAnimationFrameRedMs: 200,
|
|
cpuProfileStatus: "missing",
|
|
attributionMode: "loaf-only-no-cpu-profile",
|
|
noCpuProfile: true,
|
|
loafOnly: true,
|
|
valuesRedacted: true,
|
|
},
|
|
scriptHotspots: [{
|
|
sourceFunctionName: "Response.json.then",
|
|
sourceURL: "https://hwlab.example.test/app.js",
|
|
sourceFile: "app.js",
|
|
sourceCharPosition: 123456,
|
|
sourceMapStatus: "missing",
|
|
totalDurationMs: 2108,
|
|
count: 2,
|
|
valuesRedacted: true,
|
|
}],
|
|
profileHotspots: [],
|
|
profileStacks: [],
|
|
captures: [],
|
|
longAnimationFrames: [{ ts: "2026-07-02T12:07:00Z", kind: "long-animation-frame", durationMs: 2083.8, sampleSeq: 42, pageRole: "control", scriptCount: 1 }],
|
|
},
|
|
findings: [{
|
|
id: "frontend-performance-loaf-only-no-cpu-profile",
|
|
severity: "amber",
|
|
summary: "frontend performance evidence is LoAF/LongTask/event-loop only because no completed CPU profile capture is present",
|
|
count: 1,
|
|
}, {
|
|
id: "tool-pending-commands-unconsumed",
|
|
severity: "red",
|
|
summary: "web-probe observe has pending/processing control commands that were not consumed by the runner",
|
|
count: 1,
|
|
}],
|
|
}) + "\n");
|
|
|
|
const script = nodeWebObserveCollectViewNodeScript({
|
|
maxFiles: 100,
|
|
view: "performance-summary",
|
|
traceId: null,
|
|
sampleSeq: null,
|
|
timestamp: null,
|
|
turn: null,
|
|
commandId: null,
|
|
windowMs: null,
|
|
});
|
|
const result = spawnSync("bash", ["-lc", `state_dir=${shellQuote(stateDir)}\n${script}`], {
|
|
cwd: join(import.meta.dir, "../../.."),
|
|
encoding: "utf8",
|
|
});
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
|
|
const output = JSON.parse(result.stdout);
|
|
const text = String(output.renderedText ?? "");
|
|
const debug = JSON.stringify({ summary: output.summary, evidenceMode: output.evidenceMode, text }, null, 2);
|
|
assert.equal(output.evidenceMode.attributionMode, "loaf-only-no-cpu-profile", debug);
|
|
assert.equal(output.evidenceMode.cpuProfileStatus, "pending-command-no-cpu-profile");
|
|
assert.equal(output.evidenceMode.pendingPerformanceCapture, true);
|
|
assert.match(text, /LoAF-only \/ no CPU profile/u);
|
|
assert.match(text, /pending performanceCapture: cmd-perf\(pending\)/u);
|
|
assert.match(text, /runner state: not-running\/failed/u);
|
|
assert.match(text, /no CPU profile hotspots; no completed performanceCapture artifact/u);
|
|
assert.match(text, /sourceMap=missing/u);
|
|
assert.match(text, /char=123456/u);
|
|
}, 20_000);
|
|
|
|
test("performance-summary renders CPU profile window misses and source attribution context", async () => {
|
|
const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-performance-window-"));
|
|
await mkdir(join(stateDir, "analysis"), { recursive: true });
|
|
await writeFile(join(stateDir, "manifest.json"), JSON.stringify({ jobId: "webobs-perf-window-test", status: "completed" }) + "\n");
|
|
await writeFile(join(stateDir, "heartbeat.json"), JSON.stringify({ status: "completed", updatedAt: "2026-07-02T12:08:06Z" }) + "\n");
|
|
await writeFile(join(stateDir, "performance-events.jsonl"), "");
|
|
await writeFile(join(stateDir, "analysis", "report.json"), JSON.stringify({
|
|
manifest: { status: "completed" },
|
|
heartbeat: { status: "completed", updatedAt: "2026-07-02T12:08:06Z" },
|
|
frontendPerformance: {
|
|
summary: {
|
|
eventCount: 2,
|
|
longTaskCount: 0,
|
|
longAnimationFrameCount: 1,
|
|
eventLoopGapCount: 1,
|
|
captureCount: 1,
|
|
profileHotspotCount: 1,
|
|
scriptHotspotCount: 1,
|
|
windowCorrelationCount: 1,
|
|
cpuProfileWindowCoveredCount: 0,
|
|
cpuProfileWindowOverlappedCount: 0,
|
|
cpuProfileWindowMissedCount: 1,
|
|
cpuProfileWindowMissingCount: 0,
|
|
maxLongAnimationFrameMs: 1811,
|
|
maxEventLoopGapMs: 4490,
|
|
longAnimationFrameRedMs: 200,
|
|
eventLoopGapRedMs: 1000,
|
|
cpuProfileStatus: "captured-hotspots-outside-performance-window",
|
|
attributionMode: "cpu-profile-missed-performance-window",
|
|
sourceMapStatus: "missing",
|
|
valuesRedacted: true,
|
|
},
|
|
performanceWindows: [{
|
|
kind: "long-animation-frame",
|
|
ts: "2026-07-02T12:00:00Z",
|
|
startAt: "2026-07-02T12:00:00.000Z",
|
|
endAt: "2026-07-02T12:00:01.811Z",
|
|
durationMs: 1811,
|
|
sampleSeq: 7,
|
|
pageRole: "control",
|
|
pageId: "page-1",
|
|
cpuProfileCoverageStatus: "missed",
|
|
coverageReason: "nearest performanceCapture started after this frontend performance window ended",
|
|
capture: {
|
|
captureId: "perf-late",
|
|
commandId: "cmd-late",
|
|
startAt: "2026-07-02T12:00:10.000Z",
|
|
endAt: "2026-07-02T12:00:18.158Z",
|
|
durationMs: 8158,
|
|
windowDistanceMs: 8189,
|
|
profileSampleCount: 812,
|
|
valuesRedacted: true,
|
|
},
|
|
topLoafScript: {
|
|
sourceFunctionName: "Response.json.then",
|
|
sourceURL: "https://hwlab.example.test/assets/CodeWorkbenchView.js",
|
|
sourceFile: "CodeWorkbenchView.js",
|
|
sourceCharPosition: 456789,
|
|
lineNumber: 91,
|
|
sourceMapStatus: "missing",
|
|
totalDurationMs: 1811,
|
|
count: 5,
|
|
valuesRedacted: true,
|
|
},
|
|
relatedSamples: [{
|
|
ts: "2026-07-02T12:00:01Z",
|
|
sampleSeq: 7,
|
|
pageRole: "control",
|
|
activeSessionId: "ses_test",
|
|
traceIds: ["trc_test"],
|
|
valuesRedacted: true,
|
|
}],
|
|
relatedNetwork: [{
|
|
ts: "2026-07-02T12:00:00.500Z",
|
|
sampleSeq: 7,
|
|
phase: "response",
|
|
method: "GET",
|
|
status: 200,
|
|
urlPath: "/v1/workbench/sessions/ses_test/turns",
|
|
bodyByteCount: 1048576,
|
|
traceId: "trc_test",
|
|
valuesRedacted: true,
|
|
}],
|
|
valuesRedacted: true,
|
|
}],
|
|
scriptHotspots: [{
|
|
sourceFunctionName: "Response.json.then",
|
|
sourceURL: "https://hwlab.example.test/assets/CodeWorkbenchView.js",
|
|
sourceFile: "CodeWorkbenchView.js",
|
|
sourceCharPosition: 456789,
|
|
lineNumber: 91,
|
|
sourceMapStatus: "missing",
|
|
totalDurationMs: 1811,
|
|
count: 5,
|
|
valuesRedacted: true,
|
|
}],
|
|
profileHotspots: [{
|
|
functionName: "(program)",
|
|
sourceFile: "CodeWorkbenchView.js",
|
|
sourceMapStatus: "missing",
|
|
selfTimeMs: 120,
|
|
totalTimeMs: 200,
|
|
captureCount: 1,
|
|
valuesRedacted: true,
|
|
}],
|
|
profileStacks: [],
|
|
captures: [{
|
|
captureId: "perf-late",
|
|
commandId: "cmd-late",
|
|
startAt: "2026-07-02T12:00:10.000Z",
|
|
endAt: "2026-07-02T12:00:18.158Z",
|
|
durationMs: 8158,
|
|
profileSampleCount: 812,
|
|
valuesRedacted: true,
|
|
}],
|
|
longAnimationFrames: [{ ts: "2026-07-02T12:00:00Z", startAt: "2026-07-02T12:00:00.000Z", endAt: "2026-07-02T12:00:01.811Z", kind: "long-animation-frame", durationMs: 1811, sampleSeq: 7, pageRole: "control", scriptCount: 1 }],
|
|
sourceAttribution: {
|
|
sourceMapStatus: "missing",
|
|
note: "No source-map artifact is loaded by this analyzer; function/file/line fields are raw browser URLs or CPU profile callFrame locations.",
|
|
sourceFiles: [{ sourceFile: "CodeWorkbenchView.js", count: 2, sourceMapStatus: "missing", valuesRedacted: true }],
|
|
valuesRedacted: true,
|
|
},
|
|
},
|
|
findings: [],
|
|
}) + "\n");
|
|
|
|
const script = nodeWebObserveCollectViewNodeScript({
|
|
maxFiles: 100,
|
|
view: "performance-summary",
|
|
traceId: null,
|
|
sampleSeq: null,
|
|
timestamp: null,
|
|
turn: null,
|
|
commandId: null,
|
|
windowMs: null,
|
|
});
|
|
const result = spawnSync("bash", ["-lc", `state_dir=${shellQuote(stateDir)}\n${script}`], {
|
|
cwd: join(import.meta.dir, "../../.."),
|
|
encoding: "utf8",
|
|
});
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
|
|
const output = JSON.parse(result.stdout);
|
|
const text = String(output.renderedText ?? "");
|
|
const debug = JSON.stringify({ summary: output.summary, evidenceMode: output.evidenceMode, text }, null, 2);
|
|
assert.equal(output.summary.cpuProfileWindowMissedCount, 1);
|
|
assert.equal(output.evidenceMode.attributionMode, "cpu-profile-missed-performance-window", debug);
|
|
assert.equal(output.evidenceMode.cpuProfileMissedPerformanceWindows, true, debug);
|
|
assert.match(text, /window correlation=1 covered=0 overlapped=0 missed=1 missing=0 sourceMap=missing/u);
|
|
assert.match(text, /evidence attribution=cpu-profile-missed-performance-window/u);
|
|
assert.match(text, /CPU profile artifacts exist but missed severe performance windows; do not cite CPU profile hotspots as same-window evidence/u);
|
|
assert.doesNotMatch(text, /hotspot rows may be used as CPU profile evidence/u);
|
|
assert.match(text, /cpuWindow=missed/u);
|
|
assert.match(text, /nearest performanceCapture started after/u);
|
|
assert.match(text, /CodeWorkbenchView\.js/u);
|
|
assert.match(text, /bytes=1048576/u);
|
|
assert.match(text, /session=ses_test trace=trc_test/u);
|
|
}, 20_000);
|