fix(web-probe): correlate CPU samples with performance windows
This commit is contained in:
@@ -3,10 +3,14 @@
|
||||
|
||||
export function nodeWebObserveAnalyzerPerformanceSource(): string {
|
||||
return String.raw`
|
||||
function buildFrontendPerformanceReport(rows, artifacts, samples, network) {
|
||||
var frontendPerformanceSourceMapper = null;
|
||||
|
||||
async function buildFrontendPerformanceReport(rows, artifacts, samples, network) {
|
||||
const sourceRows = Array.isArray(rows) ? rows : [];
|
||||
const sourceSamples = Array.isArray(samples) ? samples : [];
|
||||
const sourceNetwork = Array.isArray(network) ? network : [];
|
||||
const sourceMapState = await buildFrontendPerformanceSourceMapState(artifacts);
|
||||
frontendPerformanceSourceMapper = createFrontendPerformanceSourceMapper(sourceMapState.maps);
|
||||
const events = [];
|
||||
const drainErrors = [];
|
||||
const captures = [];
|
||||
@@ -33,6 +37,7 @@ function buildFrontendPerformanceReport(rows, artifacts, samples, network) {
|
||||
events.push(event);
|
||||
for (const script of Array.isArray(perf.scripts) ? perf.scripts : []) mergeLoafScript(scriptGroups, script, event);
|
||||
}
|
||||
await attachCpuProfileTimelines(captures);
|
||||
const longTasks = events.filter((item) => item.kind === "longtask");
|
||||
const loafs = events.filter((item) => item.kind === "long-animation-frame");
|
||||
const gaps = events.filter((item) => item.kind === "event-loop-gap");
|
||||
@@ -49,7 +54,7 @@ function buildFrontendPerformanceReport(rows, artifacts, samples, network) {
|
||||
.sort((left, right) => Number(right.selfTimeMs ?? 0) - Number(left.selfTimeMs ?? 0))
|
||||
.slice(0, 30);
|
||||
const performanceWindows = buildFrontendPerformanceWindows({ events, captures, profileHotspots, profileStacks, scriptHotspots, samples: sourceSamples, network: sourceNetwork });
|
||||
const sourceAttribution = buildFrontendSourceAttribution({ scriptHotspots, profileHotspots, profileStacks });
|
||||
const sourceAttribution = buildFrontendSourceAttribution({ scriptHotspots, profileHotspots, profileStacks, sourceMapState });
|
||||
const attribution = frontendPerformanceAttributionStatus({ captures, profileHotspots, profileStacks, scriptHotspots, longTasks, loafs, gaps, performanceWindows });
|
||||
return {
|
||||
summary: {
|
||||
@@ -280,6 +285,12 @@ function compactPerformanceCapture(row) {
|
||||
windowSource: startEpochMs === null ? "missing-capture-window" : "completed-event-ts-minus-artifact-duration",
|
||||
profileTotalTimeMs: numberOrNull(summary.totalTimeMs),
|
||||
profileSampleCount: numberOrNull(summary.sampleCount),
|
||||
profileTimelineStatus: "not-loaded",
|
||||
profileTimelineSampleCount: null,
|
||||
profileTimelineTotalTimeMs: null,
|
||||
profileTimelineStartAt: null,
|
||||
profileTimelineEndAt: null,
|
||||
profileTimeline: null,
|
||||
path: artifact.path ?? null,
|
||||
summaryPath: artifact.summaryPath ?? null,
|
||||
sha256: artifact.sha256 ?? null,
|
||||
@@ -396,6 +407,225 @@ function performanceCaptureArtifacts(artifacts) {
|
||||
.map((item) => ({ ts: item.ts ?? null, captureId: item.captureId ?? null, commandId: item.commandId ?? null, path: item.path ?? null, summaryPath: item.summaryPath ?? null, sha256: item.sha256 ?? null, summarySha256: item.summarySha256 ?? null, valuesRedacted: true }));
|
||||
}
|
||||
|
||||
async function attachCpuProfileTimelines(captures) {
|
||||
for (const capture of Array.isArray(captures) ? captures : []) {
|
||||
const profilePath = typeof capture?.path === "string" && capture.path ? capture.path : null;
|
||||
if (!profilePath) {
|
||||
capture.profileTimelineStatus = "missing-profile-path";
|
||||
continue;
|
||||
}
|
||||
const profile = await readJson(profilePath);
|
||||
const timeline = buildCpuProfileSampleTimeline(profile, capture);
|
||||
capture.profileTimelineStatus = timeline.status;
|
||||
capture.profileTimelineSampleCount = timeline.sampleCount;
|
||||
capture.profileTimelineTotalTimeMs = timeline.totalTimeMs;
|
||||
capture.profileTimelineStartAt = timeline.startAt;
|
||||
capture.profileTimelineEndAt = timeline.endAt;
|
||||
capture.profileTimeline = timeline.samples;
|
||||
}
|
||||
}
|
||||
|
||||
function buildCpuProfileSampleTimeline(profile, capture) {
|
||||
if (!profile || typeof profile !== "object") return emptyCpuProfileTimeline("profile-read-failed");
|
||||
const nodes = Array.isArray(profile.nodes) ? profile.nodes : [];
|
||||
const sampleIds = Array.isArray(profile.samples) ? profile.samples : [];
|
||||
if (nodes.length === 0 || sampleIds.length === 0) return emptyCpuProfileTimeline("profile-has-no-samples");
|
||||
const byId = new Map();
|
||||
const parentById = new Map();
|
||||
for (const node of nodes) {
|
||||
const id = Number(node?.id);
|
||||
if (!Number.isFinite(id)) continue;
|
||||
byId.set(id, node);
|
||||
for (const childId of Array.isArray(node?.children) ? node.children : []) {
|
||||
const child = Number(childId);
|
||||
if (Number.isFinite(child)) parentById.set(child, id);
|
||||
}
|
||||
}
|
||||
const profileStart = Number(profile.startTime);
|
||||
const profileEnd = Number(profile.endTime);
|
||||
const profileDurationMs = Number.isFinite(profileStart) && Number.isFinite(profileEnd) && profileEnd > profileStart ? (profileEnd - profileStart) / 1000 : null;
|
||||
const captureStart = Number(capture?.startEpochMs);
|
||||
const captureEnd = Number(capture?.endEpochMs);
|
||||
const baseEpochMs = Number.isFinite(captureStart)
|
||||
? captureStart
|
||||
: Number.isFinite(captureEnd) && Number.isFinite(profileDurationMs)
|
||||
? captureEnd - Number(profileDurationMs)
|
||||
: null;
|
||||
if (!Number.isFinite(baseEpochMs)) return emptyCpuProfileTimeline("missing-capture-timebase");
|
||||
const deltas = Array.isArray(profile.timeDeltas) ? profile.timeDeltas : [];
|
||||
const fallbackDeltaMs = Number.isFinite(profileDurationMs) && sampleIds.length > 0 ? Number(profileDurationMs) / sampleIds.length : 0;
|
||||
const samples = [];
|
||||
let cursor = Number(baseEpochMs);
|
||||
for (let index = 0; index < sampleIds.length; index += 1) {
|
||||
const nodeId = Number(sampleIds[index]);
|
||||
const rawDelta = Number(deltas[index]);
|
||||
const durationMs = Number.isFinite(rawDelta) && rawDelta > 0 ? rawDelta / 1000 : fallbackDeltaMs;
|
||||
const startEpochMs = cursor;
|
||||
const endEpochMs = cursor + (Number.isFinite(durationMs) && durationMs > 0 ? durationMs : 0);
|
||||
cursor = endEpochMs;
|
||||
const frames = cpuProfileFramesForNode(nodeId, byId, parentById).map(annotateProfileFrameSource).filter(Boolean);
|
||||
samples.push({
|
||||
index,
|
||||
nodeId: Number.isFinite(nodeId) ? nodeId : null,
|
||||
startEpochMs: roundMs(startEpochMs),
|
||||
endEpochMs: roundMs(endEpochMs),
|
||||
durationMs: roundMs(endEpochMs - startEpochMs),
|
||||
leaf: frames[frames.length - 1] || null,
|
||||
frames,
|
||||
valuesRedacted: true,
|
||||
});
|
||||
}
|
||||
const totalTimeMs = samples.reduce((sum, sample) => sum + Number(sample.durationMs || 0), 0);
|
||||
const startAt = samples.length > 0 ? new Date(Number(samples[0].startEpochMs)).toISOString() : null;
|
||||
const endAt = samples.length > 0 ? new Date(Number(samples[samples.length - 1].endEpochMs)).toISOString() : null;
|
||||
return {
|
||||
status: "loaded",
|
||||
sampleCount: samples.length,
|
||||
totalTimeMs: roundMs(totalTimeMs),
|
||||
startAt,
|
||||
endAt,
|
||||
samples,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyCpuProfileTimeline(status) {
|
||||
return { status, sampleCount: 0, totalTimeMs: null, startAt: null, endAt: null, samples: [], valuesRedacted: true };
|
||||
}
|
||||
|
||||
function cpuProfileFramesForNode(nodeId, byId, parentById) {
|
||||
const frames = [];
|
||||
const seen = new Set();
|
||||
let current = nodeId;
|
||||
while (Number.isFinite(current) && byId.has(current) && !seen.has(current)) {
|
||||
seen.add(current);
|
||||
const node = byId.get(current);
|
||||
const callFrame = node?.callFrame && typeof node.callFrame === "object" ? node.callFrame : {};
|
||||
frames.push(compactCpuProfileTimelineFrame(callFrame, node));
|
||||
current = parentById.get(current);
|
||||
}
|
||||
return frames.reverse();
|
||||
}
|
||||
|
||||
function compactCpuProfileTimelineFrame(callFrame, node) {
|
||||
return {
|
||||
functionName: limitText(callFrame?.functionName || "(anonymous)", 160),
|
||||
url: safeReportUrl(callFrame?.url || ""),
|
||||
scriptId: callFrame?.scriptId ?? node?.callFrame?.scriptId ?? null,
|
||||
lineNumber: numberOrNull(callFrame?.lineNumber),
|
||||
columnNumber: numberOrNull(callFrame?.columnNumber),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function sameWindowProfileEvidence(event, capture, input) {
|
||||
const timeline = Array.isArray(capture?.profileTimeline) ? capture.profileTimeline : [];
|
||||
const eventStart = Number(event?.startEpochMs);
|
||||
const eventEnd = Number(event?.endEpochMs);
|
||||
if (!capture) return null;
|
||||
if (timeline.length === 0 || ![eventStart, eventEnd].every(Number.isFinite)) {
|
||||
return {
|
||||
captureId: capture.captureId ?? null,
|
||||
commandId: capture.commandId ?? null,
|
||||
evidenceScope: "same-capture-aggregate-context",
|
||||
timelineStatus: capture.profileTimelineStatus || "missing-profile-timeline",
|
||||
topFunctions: (Array.isArray(input?.profileHotspots) ? input.profileHotspots : []).slice(0, 5),
|
||||
topStacks: (Array.isArray(input?.profileStacks) ? input.profileStacks : []).slice(0, 3),
|
||||
note: "CPU profile sample timeline is unavailable; hotspot rows are capture-level context, not same-window evidence.",
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
const functionGroups = new Map();
|
||||
const stackGroups = new Map();
|
||||
let sampleCount = 0;
|
||||
let totalTimeMs = 0;
|
||||
for (const sample of timeline) {
|
||||
const start = Number(sample?.startEpochMs);
|
||||
const end = Number(sample?.endEpochMs);
|
||||
if (![start, end].every(Number.isFinite)) continue;
|
||||
const overlapMs = Math.max(0, Math.min(end, eventEnd) - Math.max(start, eventStart));
|
||||
if (overlapMs <= 0) continue;
|
||||
sampleCount += 1;
|
||||
totalTimeMs += overlapMs;
|
||||
const frames = Array.isArray(sample?.frames) ? sample.frames : [];
|
||||
const leaf = sample?.leaf || frames[frames.length - 1] || null;
|
||||
mergeWindowProfileFunction(functionGroups, leaf, overlapMs, overlapMs);
|
||||
for (const frame of frames) mergeWindowProfileFunction(functionGroups, frame, 0, overlapMs);
|
||||
mergeWindowProfileStack(stackGroups, frames, leaf, overlapMs);
|
||||
}
|
||||
const topFunctions = Array.from(functionGroups.values())
|
||||
.map(finalizeWindowProfileFunction)
|
||||
.sort((left, right) => Number(right.selfTimeMs ?? 0) - Number(left.selfTimeMs ?? 0) || Number(right.totalTimeMs ?? 0) - Number(left.totalTimeMs ?? 0))
|
||||
.slice(0, 8);
|
||||
const topStacks = Array.from(stackGroups.values())
|
||||
.map(finalizeWindowProfileStack)
|
||||
.sort((left, right) => Number(right.selfTimeMs ?? 0) - Number(left.selfTimeMs ?? 0))
|
||||
.slice(0, 5);
|
||||
return {
|
||||
captureId: capture.captureId ?? null,
|
||||
commandId: capture.commandId ?? null,
|
||||
evidenceScope: "same-window-cpu-samples",
|
||||
timelineStatus: sampleCount > 0 ? "window-samples" : "no-window-samples",
|
||||
sampleCount,
|
||||
totalTimeMs: roundMs(totalTimeMs),
|
||||
windowStartAt: Number.isFinite(eventStart) ? new Date(eventStart).toISOString() : null,
|
||||
windowEndAt: Number.isFinite(eventEnd) ? new Date(eventEnd).toISOString() : null,
|
||||
topFunctions,
|
||||
topStacks,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeWindowProfileFunction(groups, frame, selfTimeMs, totalTimeMs) {
|
||||
if (!frame || typeof frame !== "object") return;
|
||||
const source = frame.sourceMapStatus ? {
|
||||
sourceFile: frame.sourceFile ?? sourceFileFromUrl(frame.url),
|
||||
sourceLine: frame.sourceLine ?? numberOrNull(frame.lineNumber),
|
||||
sourceColumn: frame.sourceColumn ?? numberOrNull(frame.columnNumber),
|
||||
sourceMapStatus: frame.sourceMapStatus,
|
||||
sourceAttributionMode: frame.sourceAttributionMode ?? "browser-raw-url-line-column",
|
||||
} : sourceAttributionFor(frame.url, frame.functionName, frame.lineNumber, frame.columnNumber, null);
|
||||
const key = [frame.functionName || "(anonymous)", frame.url || frame.scriptId || "", frame.lineNumber ?? "", frame.columnNumber ?? ""].join("@");
|
||||
const group = groups.get(key) || {
|
||||
functionName: limitText(frame.functionName || "(anonymous)", 160),
|
||||
url: safeReportUrl(frame.url || ""),
|
||||
scriptId: frame.scriptId ?? null,
|
||||
sourceFile: source.sourceFile,
|
||||
sourceLine: source.sourceLine,
|
||||
sourceColumn: source.sourceColumn,
|
||||
sourceMapStatus: source.sourceMapStatus,
|
||||
sourceAttributionMode: source.sourceAttributionMode,
|
||||
lineNumber: numberOrNull(frame.lineNumber),
|
||||
columnNumber: numberOrNull(frame.columnNumber),
|
||||
sampleCount: 0,
|
||||
selfTimeMs: 0,
|
||||
totalTimeMs: 0,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
group.sampleCount += selfTimeMs > 0 ? 1 : 0;
|
||||
group.selfTimeMs += Number(selfTimeMs || 0);
|
||||
group.totalTimeMs += Number(totalTimeMs || 0);
|
||||
groups.set(key, group);
|
||||
}
|
||||
|
||||
function finalizeWindowProfileFunction(group) {
|
||||
return { ...group, selfTimeMs: roundMs(group.selfTimeMs), totalTimeMs: roundMs(group.totalTimeMs), valuesRedacted: true };
|
||||
}
|
||||
|
||||
function mergeWindowProfileStack(groups, frames, leaf, selfTimeMs) {
|
||||
const stackFrames = Array.isArray(frames) ? frames.filter(Boolean) : [];
|
||||
if (stackFrames.length === 0) return;
|
||||
const key = stackFrames.map((frame) => [frame.functionName || "(anonymous)", frame.url || frame.scriptId || "", frame.lineNumber ?? "", frame.columnNumber ?? ""].join("@")).join(" <- ");
|
||||
const group = groups.get(key) || { key, leaf: annotateProfileFrameSource(leaf), frames: stackFrames.slice(-10).map(annotateProfileFrameSource), sampleCount: 0, selfTimeMs: 0, valuesRedacted: true };
|
||||
group.sampleCount += 1;
|
||||
group.selfTimeMs += Number(selfTimeMs || 0);
|
||||
groups.set(key, group);
|
||||
}
|
||||
|
||||
function finalizeWindowProfileStack(group) {
|
||||
return { ...group, selfTimeMs: roundMs(group.selfTimeMs), valuesRedacted: true };
|
||||
}
|
||||
|
||||
function buildFrontendPerformanceWindows(input) {
|
||||
const aggregatedLoafHotspotKeys = new Set(
|
||||
(Array.isArray(input?.scriptHotspots) ? input.scriptHotspots : [])
|
||||
@@ -428,12 +658,9 @@ function correlateFrontendPerformanceWindow(event, input) {
|
||||
const coverageStatus = selectedCapture
|
||||
? covering.length > 0 ? "covered" : overlapping.length > 0 ? "overlapped" : "missed"
|
||||
: "no-cpu-profile";
|
||||
const profileEvidence = coverageStatus === "covered" || coverageStatus === "overlapped" ? {
|
||||
topFunctions: (Array.isArray(input?.profileHotspots) ? input.profileHotspots : []).slice(0, 5),
|
||||
topStacks: (Array.isArray(input?.profileStacks) ? input.profileStacks : []).slice(0, 3),
|
||||
evidenceScope: "same-capture-window-candidate",
|
||||
valuesRedacted: true,
|
||||
} : null;
|
||||
const profileEvidence = coverageStatus === "covered" || coverageStatus === "overlapped"
|
||||
? sameWindowProfileEvidence(event, selectedCapture, input)
|
||||
: null;
|
||||
const sourceHint = event.kind === "long-animation-frame" ? topLoafScriptHint(event) : null;
|
||||
return {
|
||||
kind: event.kind,
|
||||
@@ -570,6 +797,11 @@ function compactWindowCapture(capture, event) {
|
||||
windowDistanceMs: Number.isFinite(distanceMs) ? roundMs(distanceMs) : null,
|
||||
profileSampleCount: capture.profileSampleCount ?? null,
|
||||
profileTotalTimeMs: capture.profileTotalTimeMs ?? null,
|
||||
profileTimelineStatus: capture.profileTimelineStatus ?? null,
|
||||
profileTimelineSampleCount: capture.profileTimelineSampleCount ?? null,
|
||||
profileTimelineTotalTimeMs: capture.profileTimelineTotalTimeMs ?? null,
|
||||
profileTimelineStartAt: capture.profileTimelineStartAt ?? null,
|
||||
profileTimelineEndAt: capture.profileTimelineEndAt ?? null,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
@@ -650,6 +882,183 @@ function topLoafScriptHint(event) {
|
||||
.sort((left, right) => Number(right.durationMs ?? 0) - Number(left.durationMs ?? 0))[0] || null;
|
||||
}
|
||||
|
||||
async function buildFrontendPerformanceSourceMapState(artifacts) {
|
||||
const candidates = sourceMapArtifactCandidates(artifacts);
|
||||
const maps = [];
|
||||
let failedCount = 0;
|
||||
for (const candidate of candidates) {
|
||||
const parsed = await readJson(candidate.path);
|
||||
const map = parseFrontendSourceMap(parsed, candidate);
|
||||
if (map) maps.push(map);
|
||||
else failedCount += 1;
|
||||
}
|
||||
return { artifactCount: candidates.length, loadedCount: maps.length, failedCount, maps, valuesRedacted: true };
|
||||
}
|
||||
|
||||
function sourceMapArtifactCandidates(artifacts) {
|
||||
const rows = [];
|
||||
const seen = new Set();
|
||||
for (const item of Array.isArray(artifacts) ? artifacts : []) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const paths = [item.path, item.sourceMapPath, item.mapPath, item.summaryPath].filter((value) => typeof value === "string" && value);
|
||||
for (const candidatePath of paths) {
|
||||
const kind = String(item.kind || item.type || "").toLowerCase();
|
||||
const url = String(item.url || item.sourceURL || item.sourceMapUrl || "");
|
||||
const pathText = String(candidatePath);
|
||||
const looksLikeMap = kind.includes("source-map") || kind.includes("sourcemap") || pathText.endsWith(".map") || url.endsWith(".map");
|
||||
if (!looksLikeMap || seen.has(pathText)) continue;
|
||||
seen.add(pathText);
|
||||
rows.push({ path: pathText, url: url || null, kind: item.kind ?? null, ts: item.ts ?? null, valuesRedacted: true });
|
||||
}
|
||||
}
|
||||
return rows.slice(-40);
|
||||
}
|
||||
|
||||
function parseFrontendSourceMap(raw, candidate) {
|
||||
if (!raw || typeof raw !== "object" || typeof raw.mappings !== "string" || !Array.isArray(raw.sources)) return null;
|
||||
const parsedMappings = parseSourceMapMappings(raw.mappings, raw.sources, Array.isArray(raw.names) ? raw.names : []);
|
||||
return {
|
||||
path: candidate.path,
|
||||
url: candidate.url ?? null,
|
||||
file: typeof raw.file === "string" ? raw.file : null,
|
||||
sources: raw.sources,
|
||||
names: Array.isArray(raw.names) ? raw.names : [],
|
||||
lines: parsedMappings.lines,
|
||||
mappingCount: parsedMappings.mappingCount,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSourceMapMappings(mappings, sources, names) {
|
||||
const lines = [];
|
||||
let sourceIndex = 0;
|
||||
let originalLine = 0;
|
||||
let originalColumn = 0;
|
||||
let nameIndex = 0;
|
||||
const rawLines = String(mappings || "").split(";");
|
||||
for (let generatedLine = 0; generatedLine < rawLines.length; generatedLine += 1) {
|
||||
let generatedColumn = 0;
|
||||
const segments = [];
|
||||
for (const segmentText of rawLines[generatedLine].split(",")) {
|
||||
if (!segmentText) continue;
|
||||
const values = decodeSourceMapVlqSegment(segmentText);
|
||||
if (values.length === 0) continue;
|
||||
generatedColumn += Number(values[0] || 0);
|
||||
if (values.length >= 4) {
|
||||
sourceIndex += Number(values[1] || 0);
|
||||
originalLine += Number(values[2] || 0);
|
||||
originalColumn += Number(values[3] || 0);
|
||||
if (values.length >= 5) nameIndex += Number(values[4] || 0);
|
||||
if (sources[sourceIndex]) {
|
||||
segments.push({
|
||||
generatedColumn,
|
||||
source: String(sources[sourceIndex]),
|
||||
originalLine,
|
||||
originalColumn,
|
||||
name: names[nameIndex] ? String(names[nameIndex]) : null,
|
||||
valuesRedacted: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
lines[generatedLine] = segments;
|
||||
}
|
||||
return { lines, mappingCount: lines.reduce((sum, line) => sum + (Array.isArray(line) ? line.length : 0), 0), valuesRedacted: true };
|
||||
}
|
||||
|
||||
function decodeSourceMapVlqSegment(segmentText) {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
const values = [];
|
||||
let value = 0;
|
||||
let shift = 0;
|
||||
for (const char of String(segmentText || "")) {
|
||||
const digit = chars.indexOf(char);
|
||||
if (digit < 0) return [];
|
||||
const continuation = (digit & 32) !== 0;
|
||||
value += (digit & 31) << shift;
|
||||
if (continuation) {
|
||||
shift += 5;
|
||||
continue;
|
||||
}
|
||||
const negative = (value & 1) === 1;
|
||||
const decoded = value >> 1;
|
||||
values.push(negative ? -decoded : decoded);
|
||||
value = 0;
|
||||
shift = 0;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function createFrontendPerformanceSourceMapper(maps) {
|
||||
const loadedMaps = Array.isArray(maps) ? maps.filter((item) => item && Array.isArray(item.lines)) : [];
|
||||
return {
|
||||
loadedCount: loadedMaps.length,
|
||||
resolve(url, lineNumber, columnNumber, sourceCharPosition) {
|
||||
if (loadedMaps.length === 0) return null;
|
||||
const matching = loadedMaps.filter((map) => sourceMapMatchesUrl(map, url));
|
||||
if (matching.length === 0) return { status: "missing", mapped: null };
|
||||
const generatedLine = sourceMapGeneratedLine(lineNumber);
|
||||
const generatedColumn = sourceMapGeneratedColumn(columnNumber, sourceCharPosition);
|
||||
if (![generatedLine, generatedColumn].every(Number.isFinite)) return { status: "unmapped", mapped: null };
|
||||
for (const map of matching) {
|
||||
const mapped = lookupSourceMapPosition(map, generatedLine, generatedColumn);
|
||||
if (mapped) return { status: "mapped", mapped, map };
|
||||
}
|
||||
return { status: "unmapped", mapped: null };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sourceMapMatchesUrl(map, url) {
|
||||
const raw = String(url || "");
|
||||
if (!raw) return false;
|
||||
const urlBase = sourceFileFromUrl(raw);
|
||||
const mapFile = sourceFileFromUrl(map?.file || "");
|
||||
const mapPathBase = sourceFileFromUrl(map?.path || "");
|
||||
const mapUrlBase = sourceFileFromUrl(map?.url || "");
|
||||
const expectedMapBase = urlBase ? urlBase + ".map" : "";
|
||||
return Boolean(
|
||||
(mapFile && urlBase && mapFile === urlBase) ||
|
||||
(mapPathBase && expectedMapBase && mapPathBase === expectedMapBase) ||
|
||||
(mapUrlBase && expectedMapBase && mapUrlBase === expectedMapBase) ||
|
||||
(mapPathBase && urlBase && mapPathBase.replace(/\.map$/u, "") === urlBase) ||
|
||||
(mapUrlBase && urlBase && mapUrlBase.replace(/\.map$/u, "") === urlBase)
|
||||
);
|
||||
}
|
||||
|
||||
function sourceMapGeneratedLine(lineNumber) {
|
||||
const line = Number(lineNumber);
|
||||
return Number.isFinite(line) && line >= 0 ? Math.floor(line) : 0;
|
||||
}
|
||||
|
||||
function sourceMapGeneratedColumn(columnNumber, sourceCharPosition) {
|
||||
if (sourceCharPosition !== null && sourceCharPosition !== undefined) {
|
||||
const charPosition = Number(sourceCharPosition);
|
||||
if (Number.isFinite(charPosition) && charPosition >= 0) return Math.floor(charPosition);
|
||||
}
|
||||
const column = Number(columnNumber);
|
||||
return Number.isFinite(column) && column >= 0 ? Math.floor(column) : null;
|
||||
}
|
||||
|
||||
function lookupSourceMapPosition(map, generatedLine, generatedColumn) {
|
||||
const line = Array.isArray(map?.lines?.[generatedLine]) ? map.lines[generatedLine] : [];
|
||||
let selected = null;
|
||||
for (const segment of line) {
|
||||
if (Number(segment?.generatedColumn) <= generatedColumn) selected = segment;
|
||||
else break;
|
||||
}
|
||||
if (!selected) return null;
|
||||
return {
|
||||
sourceFile: limitText(selected.source || "", 180),
|
||||
sourceLine: Number.isFinite(Number(selected.originalLine)) ? Number(selected.originalLine) + 1 : null,
|
||||
sourceColumn: numberOrNull(selected.originalColumn),
|
||||
sourceName: selected.name ? limitText(selected.name, 160) : null,
|
||||
generatedLine,
|
||||
generatedColumn,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function buildFrontendSourceAttribution(input) {
|
||||
const sourceFiles = new Map();
|
||||
const add = (row) => {
|
||||
@@ -665,10 +1074,24 @@ function buildFrontendSourceAttribution(input) {
|
||||
for (const stack of Array.isArray(input?.profileStacks) ? input.profileStacks : []) {
|
||||
for (const frame of Array.isArray(stack?.frames) ? stack.frames : []) add(frame);
|
||||
}
|
||||
const rows = Array.from(sourceFiles.values());
|
||||
const mappedCount = rows.filter((item) => item.sourceMapStatus === "mapped").length;
|
||||
const unmappedCount = rows.filter((item) => item.sourceMapStatus === "unmapped").length;
|
||||
const state = input?.sourceMapState && typeof input.sourceMapState === "object" ? input.sourceMapState : {};
|
||||
const loadedCount = Number(state.loadedCount ?? 0);
|
||||
const status = mappedCount > 0 ? "mapped" : loadedCount > 0 || unmappedCount > 0 ? "unmapped" : "missing";
|
||||
const note = status === "mapped"
|
||||
? "Source-map artifact was loaded; mapped rows include original source file and line while raw browser locations remain present."
|
||||
: loadedCount > 0
|
||||
? "Source-map artifact was loaded, but observed browser locations did not resolve to original source positions."
|
||||
: "No source-map artifact is loaded by this analyzer; function/file/line fields are raw browser URLs or CPU profile callFrame locations.";
|
||||
return {
|
||||
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: Array.from(sourceFiles.values()).sort((left, right) => Number(right.count ?? 0) - Number(left.count ?? 0)).slice(0, 20),
|
||||
sourceMapStatus: status,
|
||||
note,
|
||||
sourceMapArtifactCount: numberOrNull(state.artifactCount),
|
||||
sourceMapLoadedCount: numberOrNull(state.loadedCount),
|
||||
sourceMapFailedCount: numberOrNull(state.failedCount),
|
||||
sourceFiles: rows.sort((left, right) => Number(right.count ?? 0) - Number(left.count ?? 0)).slice(0, 20),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
@@ -680,14 +1103,30 @@ function annotateProfileFrameSource(frame) {
|
||||
}
|
||||
|
||||
function sourceAttributionFor(url, functionName, lineNumber, columnNumber, sourceCharPosition) {
|
||||
const resolved = frontendPerformanceSourceMapper?.resolve ? frontendPerformanceSourceMapper.resolve(url, lineNumber, columnNumber, sourceCharPosition) : null;
|
||||
if (resolved?.status === "mapped" && resolved.mapped) {
|
||||
return {
|
||||
functionName: limitText(resolved.mapped.sourceName || functionName || "", 160),
|
||||
sourceFile: resolved.mapped.sourceFile,
|
||||
sourceLine: resolved.mapped.sourceLine,
|
||||
sourceColumn: resolved.mapped.sourceColumn,
|
||||
sourceCharPosition: numberOrNull(sourceCharPosition),
|
||||
sourceMapStatus: "mapped",
|
||||
sourceAttributionMode: "source-map-original-position",
|
||||
rawSourceFile: sourceFileFromUrl(url),
|
||||
rawLineNumber: numberOrNull(lineNumber),
|
||||
rawColumnNumber: numberOrNull(columnNumber),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
functionName: limitText(functionName ?? "", 160),
|
||||
sourceFile: sourceFileFromUrl(url),
|
||||
sourceLine: numberOrNull(lineNumber),
|
||||
sourceColumn: numberOrNull(columnNumber),
|
||||
sourceCharPosition: numberOrNull(sourceCharPosition),
|
||||
sourceMapStatus: "missing",
|
||||
sourceAttributionMode: "browser-raw-url-line-column",
|
||||
sourceMapStatus: resolved?.status === "unmapped" ? "unmapped" : "missing",
|
||||
sourceAttributionMode: resolved?.status === "unmapped" ? "source-map-loaded-no-match" : "browser-raw-url-line-column",
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ const promptNetwork = buildPromptNetworkReport(control, promptNetworkRows);
|
||||
const runtimeAlerts = buildRuntimeAlerts(samples, control, network, consoleEvents, errors);
|
||||
const apiDomLag = buildApiDomLagReport(samples, network);
|
||||
const browserProcess = buildBrowserProcessReport(browserProcessRows);
|
||||
const frontendPerformance = buildFrontendPerformanceReport(performanceRows, artifacts, samples, network);
|
||||
const frontendPerformance = await buildFrontendPerformanceReport(performanceRows, artifacts, samples, network);
|
||||
const projectManagement = buildProjectManagementReport(samples, control, network, pagePerformance, projectManagementConfig);
|
||||
const runnerErrors = errors.slice(-8).map((item) => {
|
||||
const details = item.error?.details && typeof item.error.details === "object" ? item.error.details : {};
|
||||
@@ -124,7 +124,7 @@ const commandFailures = summarizeCommandFailures(control);
|
||||
const toolFindings = buildToolFindings({ manifest, heartbeat, commandState });
|
||||
const findings = [...toolFindings, ...buildProjectManagementFindings(projectManagement), ...buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, requestRate, pageProvenance, commandFailures, manifest, apiDomLag, browserProcess, frontendPerformance)];
|
||||
if (jsonlReadIssues.length > 0) findings.unshift({ id: "jsonl-read-issues", severity: "red", summary: "observer analyzer hit JSONL read/parse issues", count: jsonlReadIssues.length, issues: jsonlReadIssues.slice(0, 20) });
|
||||
const recentWindow = buildRecentAnalysisWindow({ samples, control, network, consoleEvents, errors, artifacts, browserProcessRows, performanceRows, manifest });
|
||||
const recentWindow = await buildRecentAnalysisWindow({ samples, control, network, consoleEvents, errors, artifacts, browserProcessRows, performanceRows, manifest });
|
||||
const commandTimeline = control.filter((item) => item.phase === "completed" || item.phase === "failed").map((item) => ({ ts: item.ts, phase: item.phase, commandId: item.commandId, type: item.type, input: item.input, afterUrl: item.afterUrl }));
|
||||
const report = {
|
||||
ok: findings.filter((item) => item.severity === "red").length === 0,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Responsibility: Analyzer recent-window, page provenance, and Navigation Timing API performance source fragment.
|
||||
|
||||
export function nodeWebObserveAnalyzerWindowPageSource(): string {
|
||||
return String.raw`function buildRecentAnalysisWindow({ samples, control, network, consoleEvents, errors, artifacts, browserProcessRows, performanceRows, manifest }) {
|
||||
return String.raw`async function buildRecentAnalysisWindow({ samples, control, network, consoleEvents, errors, artifacts, browserProcessRows, performanceRows, manifest }) {
|
||||
const latestSampleMs = latestTimestampMs(samples);
|
||||
const windowMs = 5 * 60 * 1000;
|
||||
const fromMs = Number.isFinite(latestSampleMs) ? latestSampleMs - windowMs : Number.NEGATIVE_INFINITY;
|
||||
@@ -27,7 +27,7 @@ export function nodeWebObserveAnalyzerWindowPageSource(): string {
|
||||
const runtimeAlerts = buildRuntimeAlerts(windowSamples, control, windowNetwork, windowConsole, windowErrors);
|
||||
const apiDomLag = buildApiDomLagReport(windowSamples, windowNetwork);
|
||||
const browserProcess = buildBrowserProcessReport(windowBrowserProcessRows);
|
||||
const frontendPerformance = buildFrontendPerformanceReport(windowPerformanceRows, windowArtifacts, windowSamples, windowNetwork);
|
||||
const frontendPerformance = await buildFrontendPerformanceReport(windowPerformanceRows, windowArtifacts, windowSamples, windowNetwork);
|
||||
const findings = buildFindings(windowSamples, control, windowNetwork, windowErrors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, requestRate, pageProvenance, [], {}, apiDomLag, browserProcess, frontendPerformance);
|
||||
return {
|
||||
summary: {
|
||||
|
||||
@@ -726,28 +726,29 @@ function targetNodeFromStateDir(){
|
||||
return index>=0&&parts[index+1]?parts[index+1]:null;
|
||||
}
|
||||
function perfFrameKey(frame){
|
||||
return {functionName:short(frame?.functionName||frame?.name||'(anonymous)',48),url:short(frame?.url||frame?.sourceURL||frame?.scriptUrl||'',72),sourceFile:short(frame?.sourceFile||'',48),sourceMapStatus:frame?.sourceMapStatus??null,scriptId:frame?.scriptId??null,lineNumber:frame?.lineNumber??frame?.line??null,columnNumber:frame?.columnNumber??frame?.column??null,valuesRedacted:true};
|
||||
return {functionName:short(frame?.functionName||frame?.name||'(anonymous)',48),url:short(frame?.url||frame?.sourceURL||frame?.scriptUrl||'',72),sourceFile:short(frame?.sourceFile||'',48),sourceLine:frame?.sourceLine??null,sourceColumn:frame?.sourceColumn??null,sourceMapStatus:frame?.sourceMapStatus??null,scriptId:frame?.scriptId??null,lineNumber:frame?.lineNumber??frame?.line??null,columnNumber:frame?.columnNumber??frame?.column??null,valuesRedacted:true};
|
||||
}
|
||||
function compactProfileHotspot(item){
|
||||
return {functionName:short(item?.functionName||item?.name||'(anonymous)',64),url:short(item?.url||item?.sourceURL||item?.scriptUrl||'',88),sourceFile:short(item?.sourceFile||'',48),sourceMapStatus:item?.sourceMapStatus??null,scriptId:item?.scriptId??null,lineNumber:item?.lineNumber??item?.line??null,columnNumber:item?.columnNumber??item?.column??null,selfTimeMs:item?.selfTimeMs??item?.selfMs??null,totalTimeMs:item?.totalTimeMs??item?.totalMs??null,hitCount:item?.hitCount??item?.sampleCount??null,captureCount:item?.captureCount??null,valuesRedacted:true};
|
||||
return {functionName:short(item?.functionName||item?.name||'(anonymous)',64),url:short(item?.url||item?.sourceURL||item?.scriptUrl||'',88),sourceFile:short(item?.sourceFile||'',48),sourceLine:item?.sourceLine??null,sourceColumn:item?.sourceColumn??null,sourceMapStatus:item?.sourceMapStatus??null,scriptId:item?.scriptId??null,lineNumber:item?.lineNumber??item?.line??null,columnNumber:item?.columnNumber??item?.column??null,selfTimeMs:item?.selfTimeMs??item?.selfMs??null,totalTimeMs:item?.totalTimeMs??item?.totalMs??null,hitCount:item?.hitCount??item?.sampleCount??null,captureCount:item?.captureCount??null,valuesRedacted:true};
|
||||
}
|
||||
function compactProfileStack(item){
|
||||
const leaf=item?.leaf&&typeof item.leaf==='object'?item.leaf:null;
|
||||
const frames=Array.isArray(item?.frames)?item.frames.slice(0,3).map(perfFrameKey):[];
|
||||
return {functionName:short(item?.functionName||item?.name||'(anonymous)',64),url:short(item?.url||item?.sourceURL||item?.scriptUrl||'',88),scriptId:item?.scriptId??null,lineNumber:item?.lineNumber??item?.line??null,columnNumber:item?.columnNumber??item?.column??null,selfTimeMs:item?.selfTimeMs??item?.selfMs??null,totalTimeMs:item?.totalTimeMs??item?.totalMs??null,hitCount:item?.hitCount??item?.sampleCount??null,frameCount:Array.isArray(item?.frames)?item.frames.length:null,frames,framesOmitted:Math.max(0,(Array.isArray(item?.frames)?item.frames.length:0)-frames.length),valuesRedacted:true};
|
||||
return {functionName:short(item?.functionName||item?.name||leaf?.functionName||'(anonymous)',64),url:short(item?.url||item?.sourceURL||item?.scriptUrl||leaf?.url||'',88),sourceFile:short(item?.sourceFile||leaf?.sourceFile||'',48),sourceLine:item?.sourceLine??leaf?.sourceLine??null,sourceColumn:item?.sourceColumn??leaf?.sourceColumn??null,sourceMapStatus:item?.sourceMapStatus??leaf?.sourceMapStatus??null,scriptId:item?.scriptId??leaf?.scriptId??null,lineNumber:item?.lineNumber??item?.line??leaf?.lineNumber??null,columnNumber:item?.columnNumber??item?.column??leaf?.columnNumber??null,selfTimeMs:item?.selfTimeMs??item?.selfMs??null,totalTimeMs:item?.totalTimeMs??item?.totalMs??null,hitCount:item?.hitCount??item?.sampleCount??null,frameCount:Array.isArray(item?.frames)?item.frames.length:null,frames,framesOmitted:Math.max(0,(Array.isArray(item?.frames)?item.frames.length:0)-frames.length),valuesRedacted:true};
|
||||
}
|
||||
function compactScriptHotspot(item){
|
||||
return {sourceFunctionName:short(item?.sourceFunctionName||item?.functionName||item?.invoker||'(anonymous)',64),sourceURL:short(item?.sourceURL||item?.url||'',88),sourceFile:short(item?.sourceFile||'',48),sourceMapStatus:item?.sourceMapStatus??null,sourceCharPosition:item?.sourceCharPosition??null,lineNumber:item?.lineNumber??item?.line??null,columnNumber:item?.columnNumber??item?.column??null,totalDurationMs:item?.totalDurationMs??item?.durationMs??null,count:item?.count??item?.hitCount??null,invoker:short(item?.invoker||'',64),valuesRedacted:true};
|
||||
return {sourceFunctionName:short(item?.sourceFunctionName||item?.functionName||item?.invoker||'(anonymous)',64),sourceURL:short(item?.sourceURL||item?.url||'',88),sourceFile:short(item?.sourceFile||'',48),sourceLine:item?.sourceLine??null,sourceColumn:item?.sourceColumn??null,sourceMapStatus:item?.sourceMapStatus??null,sourceCharPosition:item?.sourceCharPosition??null,lineNumber:item?.lineNumber??item?.line??null,columnNumber:item?.columnNumber??item?.column??null,totalDurationMs:item?.totalDurationMs??item?.durationMs??null,count:item?.count??item?.hitCount??null,invoker:short(item?.invoker||'',64),valuesRedacted:true};
|
||||
}
|
||||
function compactPerfEvent(item){
|
||||
return {ts:item?.ts??null,startAt:item?.startAt??null,endAt:item?.endAt??null,kind:item?.kind??null,durationMs:item?.durationMs??null,pageRole:item?.pageRole??null,pageId:item?.pageId??null,sampleSeq:item?.sampleSeq??item?.seq??null,scriptCount:item?.scriptCount??(Array.isArray(item?.scripts)?item.scripts.length:null),url:short(item?.url||item?.sourceURL||'',88),valuesRedacted:true};
|
||||
}
|
||||
function compactPerfCapture(item){
|
||||
return {ts:item?.ts??null,startAt:item?.startAt??null,endAt:item?.endAt??null,captureId:item?.captureId??null,commandId:item?.commandId??null,status:item?.status??null,durationMs:item?.durationMs??item?.profileDurationMs??null,sampleCount:item?.sampleCount??item?.profileSampleCount??item?.samples??null,nodeCount:item?.nodeCount??item?.nodes??null,file:short(item?.file||item?.path||item?.relativePath||'',88),valuesRedacted:true};
|
||||
return {ts:item?.ts??null,startAt:item?.startAt??null,endAt:item?.endAt??null,captureId:item?.captureId??null,commandId:item?.commandId??null,status:item?.status??null,durationMs:item?.durationMs??item?.profileDurationMs??null,sampleCount:item?.sampleCount??item?.profileSampleCount??item?.samples??null,nodeCount:item?.nodeCount??item?.nodes??null,profileTimelineStatus:item?.profileTimelineStatus??null,profileTimelineSampleCount:item?.profileTimelineSampleCount??null,profileTimelineTotalTimeMs:item?.profileTimelineTotalTimeMs??null,file:short(item?.file||item?.path||item?.relativePath||'',88),valuesRedacted:true};
|
||||
}
|
||||
function compactPerformanceWindow(item){
|
||||
const capture=item?.capture&&typeof item.capture==='object'?item.capture:null;
|
||||
const script=item?.topLoafScript&&typeof item.topLoafScript==='object'?item.topLoafScript:null;
|
||||
return {kind:item?.kind??null,ts:item?.ts??null,startAt:item?.startAt??null,endAt:item?.endAt??null,durationMs:item?.durationMs??null,blockingDurationMs:item?.blockingDurationMs??null,pageRole:item?.pageRole??null,pageId:item?.pageId??null,sampleSeq:item?.sampleSeq??null,cpuProfileCoverageStatus:item?.cpuProfileCoverageStatus??null,coverageReason:short(item?.coverageReason||'',90),capture:capture?{captureId:capture.captureId??null,commandId:capture.commandId??null,startAt:capture.startAt??null,endAt:capture.endAt??null,durationMs:capture.durationMs??null,windowDistanceMs:capture.windowDistanceMs??null,profileSampleCount:capture.profileSampleCount??null,valuesRedacted:true}:null,topLoafScript:script?compactScriptHotspot(script):null,relatedNetwork:Array.isArray(item?.relatedNetwork)?item.relatedNetwork.slice(0,1).map((row)=>({ts:row?.ts??null,sampleSeq:row?.sampleSeq??null,phase:row?.phase??null,method:row?.method??null,status:row?.status??null,urlPath:short(row?.urlPath||'',56),bodyByteCount:row?.bodyByteCount??null,traceId:short(row?.traceId||'',32),routeSessionId:short(row?.routeSessionId||'',32),valuesRedacted:true})):[],relatedSamples:Array.isArray(item?.relatedSamples)?item.relatedSamples.slice(0,1).map((row)=>({ts:row?.ts??null,sampleSeq:row?.sampleSeq??null,pageRole:row?.pageRole??null,path:short(row?.path||'',40),routeSessionId:short(row?.routeSessionId||'',32),activeSessionId:short(row?.activeSessionId||'',32),traceIds:Array.isArray(row?.traceIds)?row.traceIds.slice(0,1).map((id)=>short(id,32)):[],valuesRedacted:true})):[],profileEvidence:item?.profileEvidence?{evidenceScope:item.profileEvidence.evidenceScope??null,topFunctions:Array.isArray(item.profileEvidence.topFunctions)?item.profileEvidence.topFunctions.slice(0,1).map(compactProfileHotspot):[],valuesRedacted:true}:null,valuesRedacted:true};
|
||||
return {kind:item?.kind??null,ts:item?.ts??null,startAt:item?.startAt??null,endAt:item?.endAt??null,durationMs:item?.durationMs??null,blockingDurationMs:item?.blockingDurationMs??null,pageRole:item?.pageRole??null,pageId:item?.pageId??null,sampleSeq:item?.sampleSeq??null,cpuProfileCoverageStatus:item?.cpuProfileCoverageStatus??null,coverageReason:short(item?.coverageReason||'',90),capture:capture?{captureId:capture.captureId??null,commandId:capture.commandId??null,startAt:capture.startAt??null,endAt:capture.endAt??null,durationMs:capture.durationMs??null,windowDistanceMs:capture.windowDistanceMs??null,profileSampleCount:capture.profileSampleCount??null,profileTimelineStatus:capture.profileTimelineStatus??null,profileTimelineSampleCount:capture.profileTimelineSampleCount??null,valuesRedacted:true}:null,topLoafScript:script?compactScriptHotspot(script):null,relatedNetwork:Array.isArray(item?.relatedNetwork)?item.relatedNetwork.slice(0,1).map((row)=>({ts:row?.ts??null,sampleSeq:row?.sampleSeq??null,phase:row?.phase??null,method:row?.method??null,status:row?.status??null,urlPath:short(row?.urlPath||'',56),bodyByteCount:row?.bodyByteCount??null,traceId:short(row?.traceId||'',32),routeSessionId:short(row?.routeSessionId||'',32),valuesRedacted:true})):[],relatedSamples:Array.isArray(item?.relatedSamples)?item.relatedSamples.slice(0,1).map((row)=>({ts:row?.ts??null,sampleSeq:row?.sampleSeq??null,pageRole:row?.pageRole??null,path:short(row?.path||'',40),routeSessionId:short(row?.routeSessionId||'',32),activeSessionId:short(row?.activeSessionId||'',32),traceIds:Array.isArray(row?.traceIds)?row.traceIds.slice(0,1).map((id)=>short(id,32)):[],valuesRedacted:true})):[],profileEvidence:item?.profileEvidence?{evidenceScope:item.profileEvidence.evidenceScope??null,timelineStatus:item.profileEvidence.timelineStatus??null,sampleCount:item.profileEvidence.sampleCount??null,totalTimeMs:item.profileEvidence.totalTimeMs??null,note:short(item.profileEvidence.note||'',100),topFunctions:Array.isArray(item.profileEvidence.topFunctions)?item.profileEvidence.topFunctions.slice(0,1).map(compactProfileHotspot):[],topStacks:Array.isArray(item.profileEvidence.topStacks)?item.profileEvidence.topStacks.slice(0,1).map(compactProfileStack):[],valuesRedacted:true}:null,valuesRedacted:true};
|
||||
}
|
||||
function compactPerfFinding(item){
|
||||
return {severity:item?.severity??item?.level??null,id:short(item?.id||item?.kind||item?.code||'',56),count:item?.count??item?.sampleCount??null,rootCause:item?.rootCause??item?.rootCauseStatus??null,summary:short(item?.summary||item?.message||'',110),valuesRedacted:true};
|
||||
@@ -807,12 +808,25 @@ function performanceEvidenceMode(perf, commandFiles){
|
||||
const statement=hasCpuProfile
|
||||
? cpuProfileMissedPerformanceWindows
|
||||
? 'CPU profile artifacts exist but missed severe performance windows; do not cite CPU profile hotspots as same-window evidence.'
|
||||
: 'CPU profile artifacts are present; hotspot rows may be used as CPU profile evidence.'
|
||||
: missedWindowCount>0&&(coveredWindowCount>0||overlappedWindowCount>0)
|
||||
? 'CPU profile artifacts overlap some severe performance windows; cite CPU hotspots as same-window evidence only for rows marked covered/overlapped.'
|
||||
: 'CPU profile artifacts are present; hotspot rows may be used as CPU profile evidence.'
|
||||
: pendingPerformanceCapture
|
||||
? 'LoAF-only / no CPU profile: performanceCapture is pending or processing, so CPU profile hotspots are unavailable for this run.'
|
||||
: 'LoAF-only / no CPU profile: no completed performanceCapture artifact is present, so do not cite CPU profile hotspots.';
|
||||
return {attributionMode,cpuProfileStatus,hasCpuProfile,noCpuProfile:!hasCpuProfile,loafOnly:!hasCpuProfile&&hasPerformanceObserverEvidence,pendingPerformanceCapture,cpuProfileMissedPerformanceWindows,runner,performanceCaptureCommands:commands,statement,valuesRedacted:true};
|
||||
}
|
||||
function performanceWindowsForSummary(rows){
|
||||
const source=Array.isArray(rows)?rows:[];
|
||||
const selected=source.slice(0,3);
|
||||
const hasCovered=selected.some((item)=>item?.cpuProfileCoverageStatus==='covered'||item?.cpuProfileCoverageStatus==='overlapped');
|
||||
const covered=hasCovered?null:source.find((item)=>item?.cpuProfileCoverageStatus==='covered'||item?.cpuProfileCoverageStatus==='overlapped');
|
||||
if(covered){
|
||||
if(selected.length>=3) selected[2]=covered;
|
||||
else selected.push(covered);
|
||||
}
|
||||
return selected.map(compactPerformanceWindow);
|
||||
}
|
||||
function performanceSummaryFromReport(){
|
||||
const perf=report.frontendPerformance&&typeof report.frontendPerformance==='object'?report.frontendPerformance:{};
|
||||
const summary=perf.summary&&typeof perf.summary==='object'?perf.summary:{};
|
||||
@@ -823,7 +837,7 @@ function performanceSummaryFromReport(){
|
||||
longTasks:Array.isArray(perf.longTasks)?perf.longTasks.slice(0,3).map(compactPerfEvent):[],
|
||||
longAnimationFrames:Array.isArray(perf.longAnimationFrames)?perf.longAnimationFrames.slice(0,3).map(compactPerfEvent):[],
|
||||
eventLoopGaps:Array.isArray(perf.eventLoopGaps)?perf.eventLoopGaps.slice(0,3).map(compactPerfEvent):[],
|
||||
performanceWindows:Array.isArray(perf.performanceWindows)?perf.performanceWindows.slice(0,3).map(compactPerformanceWindow):[],
|
||||
performanceWindows:performanceWindowsForSummary(perf.performanceWindows),
|
||||
scriptHotspots:Array.isArray(perf.scriptHotspots)?perf.scriptHotspots.slice(0,4).map(compactScriptHotspot):[],
|
||||
profileHotspots:Array.isArray(perf.profileHotspots)?perf.profileHotspots.slice(0,4).map(compactProfileHotspot):[],
|
||||
profileStacks:Array.isArray(perf.profileStacks)?perf.profileStacks.slice(0,2).map(compactProfileStack):[],
|
||||
@@ -857,28 +871,36 @@ function renderPerformanceSummary(perf){
|
||||
if(perf.sourceAttribution?.note) lines.push('source attribution: '+perf.sourceAttribution.note);
|
||||
lines.push('','CPU profile hotspots');
|
||||
if(perf.profileHotspots.length===0) lines.push(mode.noCpuProfile===true?'(no CPU profile hotspots; no completed performanceCapture artifact in this run)':'-');
|
||||
for(const item of perf.profileHotspots.slice(0,4)) lines.push(String(item.selfTimeMs??0)+'ms self '+short(item.functionName||'(anonymous)',40)+' '+short(item.sourceFile||item.url||item.scriptId||'-',70)+' line='+String(item.lineNumber??'-')+' sourceMap='+String(item.sourceMapStatus||'-')+' captures='+String(item.captureCount??'-'));
|
||||
for(const item of perf.profileHotspots.slice(0,4)) lines.push(String(item.selfTimeMs??0)+'ms self '+short(item.functionName||'(anonymous)',40)+' '+short(item.sourceFile||item.url||item.scriptId||'-',70)+' sourceLine='+String(item.sourceLine??'-')+' rawLine='+String(item.lineNumber??'-')+' sourceMap='+String(item.sourceMapStatus||'-')+' captures='+String(item.captureCount??'-'));
|
||||
lines.push('','CPU profile stacks');
|
||||
if(perf.profileStacks.length===0) lines.push('-');
|
||||
for(const item of perf.profileStacks.slice(0,2)){
|
||||
const frames=Array.isArray(item.frames)?item.frames.slice(0,3).map((frame)=>short(frame.functionName||'(anonymous)',28)+'@'+short(frame.sourceFile||frame.url||frame.scriptId||'-',36)+':'+String(frame.lineNumber??'-')).join(' <- '):'-';
|
||||
lines.push(String(item.selfTimeMs??0)+'ms self '+short(item.functionName||'(anonymous)',44)+' line='+String(item.lineNumber??'-')+' frames='+frames+(item.framesOmitted?(' omitted='+String(item.framesOmitted)):''));
|
||||
const frames=Array.isArray(item.frames)?item.frames.slice(0,3).map((frame)=>short(frame.functionName||'(anonymous)',28)+'@'+short(frame.sourceFile||frame.url||frame.scriptId||'-',36)+':'+String(frame.sourceLine??frame.lineNumber??'-')).join(' <- '):'-';
|
||||
lines.push(String(item.selfTimeMs??0)+'ms self '+short(item.functionName||'(anonymous)',44)+' sourceLine='+String(item.sourceLine??'-')+' rawLine='+String(item.lineNumber??'-')+' frames='+frames+(item.framesOmitted?(' omitted='+String(item.framesOmitted)):''));
|
||||
}
|
||||
lines.push('','LoAF script hotspots');
|
||||
if(perf.scriptHotspots.length===0) lines.push('-');
|
||||
for(const item of perf.scriptHotspots.slice(0,4)) lines.push(String(item.totalDurationMs??0)+'ms total count='+String(item.count??0)+' '+short(item.sourceFunctionName||item.invoker||'(anonymous)',40)+' '+short(item.sourceFile||item.sourceURL||'-',70)+' line='+String(item.lineNumber??'-')+' char='+String(item.sourceCharPosition??'-')+' sourceMap='+String(item.sourceMapStatus||'-'));
|
||||
for(const item of perf.scriptHotspots.slice(0,4)) lines.push(String(item.totalDurationMs??0)+'ms total count='+String(item.count??0)+' '+short(item.sourceFunctionName||item.invoker||'(anonymous)',40)+' '+short(item.sourceFile||item.sourceURL||'-',70)+' sourceLine='+String(item.sourceLine??'-')+' rawLine='+String(item.lineNumber??'-')+' char='+String(item.sourceCharPosition??'-')+' sourceMap='+String(item.sourceMapStatus||'-'));
|
||||
lines.push('','Time-window correlation');
|
||||
if(!Array.isArray(perf.performanceWindows)||perf.performanceWindows.length===0) lines.push('-');
|
||||
for(const item of (perf.performanceWindows||[]).slice(0,3)){
|
||||
const capture=item.capture?String(item.capture.captureId||item.capture.commandId||'-')+' dist='+String(item.capture.windowDistanceMs??'-')+'ms':'-';
|
||||
const script=item.topLoafScript?short(item.topLoafScript.sourceFunctionName||item.topLoafScript.invoker||'(anonymous)',40)+'@'+short(item.topLoafScript.sourceFile||item.topLoafScript.sourceURL||'-',48)+':'+String(item.topLoafScript.lineNumber??'-'):'-';
|
||||
const script=item.topLoafScript?short(item.topLoafScript.sourceFunctionName||item.topLoafScript.invoker||'(anonymous)',40)+'@'+short(item.topLoafScript.sourceFile||item.topLoafScript.sourceURL||'-',48)+':'+String(item.topLoafScript.sourceLine??item.topLoafScript.lineNumber??'-'):'-';
|
||||
const samples=(item.relatedSamples||[]).map((row)=>'#'+String(row.sampleSeq??'-')+' session='+String(row.activeSessionId||row.routeSessionId||'-')+' trace='+String((row.traceIds||[])[0]||'-')).join('; ');
|
||||
const network=(item.relatedNetwork||[]).map((row)=>String(row.status??'-')+' '+String(row.method||'-')+' '+short(row.urlPath||'-',44)+' bytes='+String(row.bodyByteCount??'-')).join('; ');
|
||||
lines.push(String(item.startAt||item.ts||'-')+' '+String(item.kind||'-')+' duration='+String(item.durationMs??'-')+'ms sample='+String(item.sampleSeq??'-')+' cpuWindow='+String(item.cpuProfileCoverageStatus||'-')+' capture='+capture);
|
||||
lines.push(' reason='+String(item.coverageReason||'-')+' script='+script);
|
||||
if(samples) lines.push(' samples='+samples);
|
||||
if(network) lines.push(' network='+network);
|
||||
if(item.profileEvidence?.topFunctions?.length>0) lines.push(' same-capture topFunction='+item.profileEvidence.topFunctions.map((fn)=>String(fn.selfTimeMs??0)+'ms '+short(fn.functionName||'(anonymous)',28)+'@'+short(fn.sourceFile||fn.url||'-',32)).join('; '));
|
||||
if(item.profileEvidence?.topFunctions?.length>0){
|
||||
const scope=item.profileEvidence.timelineStatus==='window-samples'?'same-window topFunction':'cpu profile context topFunction';
|
||||
const suffix=item.profileEvidence.timelineStatus?(' timeline='+String(item.profileEvidence.timelineStatus)+' samples='+String(item.profileEvidence.sampleCount??'-')+' total='+String(item.profileEvidence.totalTimeMs??'-')+'ms'):'';
|
||||
lines.push(' '+scope+'='+item.profileEvidence.topFunctions.map((fn)=>String(fn.selfTimeMs??0)+'ms self '+String(fn.totalTimeMs??0)+'ms total '+short(fn.functionName||'(anonymous)',28)+'@'+short(fn.sourceFile||fn.url||'-',32)+' sourceMap='+String(fn.sourceMapStatus||'-')).join('; ')+suffix);
|
||||
}
|
||||
if(item.profileEvidence?.topStacks?.length>0){
|
||||
lines.push(' same-window topStack='+item.profileEvidence.topStacks.map((stack)=>String(stack.selfTimeMs??0)+'ms '+short(stack.functionName||stack.leaf?.functionName||'(anonymous)',28)+' frames='+String(stack.frameCount??'-')).join('; '));
|
||||
}
|
||||
if(item.profileEvidence?.note) lines.push(' cpu profile note='+item.profileEvidence.note);
|
||||
}
|
||||
lines.push('','Longest events');
|
||||
for(const item of [...perf.longTasks.slice(0,3),...perf.longAnimationFrames.slice(0,3),...perf.eventLoopGaps.slice(0,3)].sort((a,b)=>Number(b.durationMs??0)-Number(a.durationMs??0)).slice(0,6)) lines.push(String(item.ts||'-')+' '+String(item.kind||'-')+' duration='+String(item.durationMs??'-')+'ms role='+String(item.pageRole||'-')+' sample='+String(item.sampleSeq??'-')+' scripts='+String(item.scriptCount??0));
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, readFile, 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 { nodeWebObserveAnalyzerSource } from "../hwlab-node-web-observe-analyzer-source";
|
||||
|
||||
const alertThresholds = {
|
||||
sameOriginApiSlowMs: 60000,
|
||||
partialApiSlowMs: 60000,
|
||||
longLivedStreamOpenSlowMs: 60000,
|
||||
visibleLoadingSlowMs: 60000,
|
||||
turnTimingSampleSlackSeconds: 60,
|
||||
turnElapsedSevereTimeoutSeconds: 3600,
|
||||
domEvaluateTimeoutRedCount: 99,
|
||||
domEvaluateTimeoutRedWindowMs: 60000,
|
||||
screenshotTimeoutRedCount: 99,
|
||||
pageErrorRedCount: 99,
|
||||
longTaskRedMs: 50,
|
||||
longAnimationFrameRedMs: 50,
|
||||
eventLoopGapRedMs: 50,
|
||||
browserProcessSampleIntervalMs: 1000,
|
||||
requestRateBucketMs: 10000,
|
||||
requestRateTotalRedPerMinute: 999999,
|
||||
requestRatePageRedPerMinute: 999999,
|
||||
requestRateApiPathRedPerMinute: 999999,
|
||||
browserTotalRssRedMb: 999999,
|
||||
browserProcessRssRedMb: 999999,
|
||||
browserRssGrowthRedMb: 999999,
|
||||
browserRssGrowthWindowMs: 60000,
|
||||
playwrightResponsivenessRedMs: 60000,
|
||||
playwrightResponsivenessTimeoutRedCount: 99,
|
||||
cdpMetricsTimeoutRedCount: 99,
|
||||
uncommandedStateChangeCommandWindowMs: 1000,
|
||||
scrollJumpCommandWindowMs: 1000,
|
||||
scrollJumpFromY: 999999,
|
||||
scrollJumpToY: 999999,
|
||||
sessionRailFallbackRatio: 0.5,
|
||||
};
|
||||
|
||||
const browserFreezePolicy = {
|
||||
enabled: true,
|
||||
blockerWindowMs: 60000,
|
||||
memory: {
|
||||
totalRssBlockerMb: 999999,
|
||||
processRssBlockerMb: 999999,
|
||||
growthBlockerMb: 999999,
|
||||
},
|
||||
responsiveness: {
|
||||
latencyBlockerMs: 60000,
|
||||
eventBlockerCount: 99,
|
||||
},
|
||||
cdp: {
|
||||
metricsTimeoutBlockerCount: 99,
|
||||
},
|
||||
kill: {
|
||||
enabled: false,
|
||||
gracefulSignal: "SIGTERM",
|
||||
forceSignal: "SIGKILL",
|
||||
graceMs: 1000,
|
||||
pollIntervalMs: 100,
|
||||
exitCode: 124,
|
||||
},
|
||||
};
|
||||
|
||||
async function writeJsonl(path: string, rows: Array<Record<string, unknown>>): Promise<void> {
|
||||
await writeFile(path, rows.map((row) => JSON.stringify(row)).join("\n") + (rows.length > 0 ? "\n" : ""));
|
||||
}
|
||||
|
||||
async function runAnalyzer(stateDir: string): Promise<Record<string, unknown>> {
|
||||
const analyzerPath = join(stateDir, "analyze.mjs");
|
||||
await writeFile(analyzerPath, nodeWebObserveAnalyzerSource(), { mode: 0o700 });
|
||||
const result = spawnSync("bun", [analyzerPath, stateDir], {
|
||||
cwd: join(import.meta.dir, "../../.."),
|
||||
env: {
|
||||
...process.env,
|
||||
UNIDESK_WEB_OBSERVE_ANALYZE_TAIL_SAMPLES: "0",
|
||||
UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON: JSON.stringify(alertThresholds),
|
||||
UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON: JSON.stringify(browserFreezePolicy),
|
||||
},
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
return JSON.parse(await readFile(join(stateDir, "analysis", "report.json"), "utf8")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function writeBaseState(options: { withSourceMap: boolean; captureEndAt: string }): Promise<string> {
|
||||
const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-analyzer-performance-"));
|
||||
await mkdir(join(stateDir, "analysis"), { recursive: true });
|
||||
const profilePath = join(stateDir, "profile.cpuprofile");
|
||||
const sourceMapPath = join(stateDir, "app.js.map");
|
||||
await writeFile(join(stateDir, "manifest.json"), JSON.stringify({ jobId: "webobs-performance-test", status: "completed" }) + "\n");
|
||||
await writeFile(join(stateDir, "heartbeat.json"), JSON.stringify({ status: "completed", updatedAt: "2026-07-02T00:00:03.000Z" }) + "\n");
|
||||
await writeFile(profilePath, JSON.stringify({
|
||||
startTime: 0,
|
||||
endTime: 2000000,
|
||||
nodes: [
|
||||
{ id: 1, callFrame: { functionName: "(root)", url: "", lineNumber: -1, columnNumber: -1 }, children: [2] },
|
||||
{ id: 2, callFrame: { functionName: "hotParse", url: "https://hwlab.example.test/assets/app.js", scriptId: "7", lineNumber: 0, columnNumber: 10 } },
|
||||
],
|
||||
samples: [2, 2, 2],
|
||||
timeDeltas: [600000, 600000, 800000],
|
||||
}) + "\n");
|
||||
if (options.withSourceMap) {
|
||||
await writeFile(sourceMapPath, JSON.stringify({
|
||||
version: 3,
|
||||
file: "app.js",
|
||||
sources: ["src/api/client.ts"],
|
||||
names: ["responseJson"],
|
||||
mappings: "UAKEA",
|
||||
}) + "\n");
|
||||
}
|
||||
await writeJsonl(join(stateDir, "samples.jsonl"), []);
|
||||
await writeJsonl(join(stateDir, "network.jsonl"), []);
|
||||
await writeJsonl(join(stateDir, "control.jsonl"), []);
|
||||
await writeJsonl(join(stateDir, "console.jsonl"), []);
|
||||
await writeJsonl(join(stateDir, "errors.jsonl"), []);
|
||||
await writeJsonl(join(stateDir, "browser-process.jsonl"), []);
|
||||
await writeJsonl(join(stateDir, "artifacts.jsonl"), options.withSourceMap ? [{
|
||||
ts: "2026-07-02T00:00:00.100Z",
|
||||
kind: "source-map",
|
||||
path: sourceMapPath,
|
||||
url: "https://hwlab.example.test/assets/app.js.map",
|
||||
}] : []);
|
||||
await writeJsonl(join(stateDir, "performance-events.jsonl"), [
|
||||
{
|
||||
type: "performance-event",
|
||||
ts: "2026-07-02T00:00:01.080Z",
|
||||
sampleSeq: 11,
|
||||
pageRole: "control",
|
||||
pageId: "page-1",
|
||||
performance: {
|
||||
kind: "long-animation-frame",
|
||||
name: "long-animation-frame",
|
||||
duration: 80,
|
||||
blockingDuration: 80,
|
||||
timeOrigin: Date.parse("2026-07-02T00:00:00.000Z"),
|
||||
startTime: 1000,
|
||||
path: "/workbench",
|
||||
url: "https://hwlab.example.test/workbench",
|
||||
scripts: [{
|
||||
invoker: "then",
|
||||
invokerType: "promise",
|
||||
sourceURL: "https://hwlab.example.test/assets/app.js",
|
||||
sourceFunctionName: "Response.json.then",
|
||||
sourceCharPosition: 10,
|
||||
lineNumber: 0,
|
||||
columnNumber: 10,
|
||||
duration: 80,
|
||||
}],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "performance-capture-completed",
|
||||
ts: options.captureEndAt,
|
||||
commandId: "cmd-profile",
|
||||
captureId: "cap-profile",
|
||||
pageRole: "control",
|
||||
pageId: "page-1",
|
||||
artifact: { kind: "performance-cpu-profile", captureId: "cap-profile", commandId: "cmd-profile", path: profilePath, durationMs: 2000 },
|
||||
summary: {
|
||||
durationMs: 2000,
|
||||
sampleCount: 3,
|
||||
totalTimeMs: 2000,
|
||||
topFunctions: [{
|
||||
functionName: "hotParse",
|
||||
url: "https://hwlab.example.test/assets/app.js",
|
||||
scriptId: "7",
|
||||
lineNumber: 0,
|
||||
columnNumber: 10,
|
||||
selfTimeMs: 2000,
|
||||
totalTimeMs: 2000,
|
||||
sampleCount: 3,
|
||||
}],
|
||||
},
|
||||
},
|
||||
]);
|
||||
return stateDir;
|
||||
}
|
||||
|
||||
test("observe analyzer maps LoAF and same-window CPU samples through loaded source map", async () => {
|
||||
const stateDir = await writeBaseState({ withSourceMap: true, captureEndAt: "2026-07-02T00:00:02.500Z" });
|
||||
const report = await runAnalyzer(stateDir);
|
||||
const frontendPerformance = report.frontendPerformance as Record<string, unknown>;
|
||||
const summary = frontendPerformance.summary as Record<string, unknown>;
|
||||
assert.equal(summary.sourceMapStatus, "mapped");
|
||||
assert.equal(summary.cpuProfileWindowCoveredCount, 1);
|
||||
|
||||
const scriptHotspot = (frontendPerformance.scriptHotspots as Array<Record<string, unknown>>)[0];
|
||||
assert.equal(scriptHotspot.sourceMapStatus, "mapped");
|
||||
assert.equal(scriptHotspot.sourceFile, "src/api/client.ts");
|
||||
assert.equal(scriptHotspot.sourceLine, 6);
|
||||
assert.equal(scriptHotspot.lineNumber, 0);
|
||||
assert.equal(scriptHotspot.sourceCharPosition, 10);
|
||||
|
||||
const windowRow = (frontendPerformance.performanceWindows as Array<Record<string, unknown>>)[0];
|
||||
assert.equal(windowRow.cpuProfileCoverageStatus, "covered");
|
||||
const evidence = windowRow.profileEvidence as Record<string, unknown>;
|
||||
assert.equal(evidence.timelineStatus, "window-samples");
|
||||
assert.equal(evidence.evidenceScope, "same-window-cpu-samples");
|
||||
assert.equal(evidence.sampleCount, 1);
|
||||
assert.equal(evidence.totalTimeMs, 80);
|
||||
const topFunction = (evidence.topFunctions as Array<Record<string, unknown>>)[0];
|
||||
assert.equal(topFunction.functionName, "hotParse");
|
||||
assert.equal(topFunction.sourceMapStatus, "mapped");
|
||||
assert.equal(topFunction.sourceFile, "src/api/client.ts");
|
||||
assert.equal(topFunction.sourceLine, 6);
|
||||
assert.equal(topFunction.selfTimeMs, 80);
|
||||
}, 20_000);
|
||||
|
||||
test("observe analyzer preserves raw attribution when source map is missing and marks missed CPU timeline windows", async () => {
|
||||
const stateDir = await writeBaseState({ withSourceMap: false, captureEndAt: "2026-07-02T00:00:05.000Z" });
|
||||
const report = await runAnalyzer(stateDir);
|
||||
const frontendPerformance = report.frontendPerformance as Record<string, unknown>;
|
||||
const summary = frontendPerformance.summary as Record<string, unknown>;
|
||||
assert.equal(summary.sourceMapStatus, "missing");
|
||||
assert.equal(summary.cpuProfileWindowMissedCount, 1);
|
||||
assert.equal(summary.attributionMode, "cpu-profile-missed-performance-window");
|
||||
|
||||
const scriptHotspot = (frontendPerformance.scriptHotspots as Array<Record<string, unknown>>)[0];
|
||||
assert.equal(scriptHotspot.sourceMapStatus, "missing");
|
||||
assert.equal(scriptHotspot.sourceFile, "app.js");
|
||||
assert.equal(scriptHotspot.sourceLine, 0);
|
||||
|
||||
const windowRow = (frontendPerformance.performanceWindows as Array<Record<string, unknown>>)[0];
|
||||
assert.equal(windowRow.cpuProfileCoverageStatus, "missed");
|
||||
assert.equal(windowRow.profileEvidence, null);
|
||||
const capture = windowRow.capture as Record<string, unknown>;
|
||||
assert.equal(capture.profileTimelineStatus, "loaded");
|
||||
assert.equal(capture.windowDistanceMs, 1920);
|
||||
}, 20_000);
|
||||
Reference in New Issue
Block a user