mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-09-28 11:22:57 +08:00
feat(performance): chunked trace buffer parser for large recordings (#2721)
> [!NOTE]
> This PR is marked as **WIP (Work in Progress)** / Draft to gather
feedback and extra opinions on the streaming / chunked parsing strategy
for large performance traces.
## Problem Statement
When recording performance traces on busy sites or over long recording
durations, the resulting trace buffer often exceeds 512 MB (and can
reach 1 GB+).
Currently, `parseRawTraceBuffer` decodes the entire raw buffer into a
single string via `new TextDecoder().decode(buffer)` and parses it with
`JSON.parse(asString)`. In V8 (64-bit), strings are strictly limited to
$2^{29} - 24$ characters (~512 MB). Any trace exceeding ~512 MB throws a
fatal error:
```
RangeError: Cannot create a string longer than 0x1fffffe8 characters
```
This prevents the MCP server from parsing and analyzing large traces
even when sufficient heap memory is available.
## Proposed Solution: Chunked Trace Buffer Scanner
This change introduces `ChunkedTraceParser.ts` to extract trace events
directly from the raw binary buffer in bounded batches without
materializing the full trace JSON into a single string:
1. **Byte-Level Delimiter & Escape Scanning**:
- Scans the `Uint8Array` directly using ASCII byte codes (`0x7B` `{`,
`0x7D` `}`, `0x22` `"`, `0x5C` `\`).
- Tracks brace nesting depth while properly handling escaped quotes
within strings.
- Supports both bare JSON arrays (`[ {...}, ... ]`) and top-level object
envelopes (`{ "traceEvents": [ ... ], "metadata": { ... } }`).
2. **Batched Native `JSON.parse`**:
- Accumulates slices of events into bounded batches (default: 5,000
events) wrapped in `[` and `]`.
- Parses each batch using native V8 `JSON.parse`, keeping intermediate
string allocations small (< 5 MB) and short-lived.
- Pushes parsed events iteratively to avoid V8 function call argument
stack limits.
3. **Metadata Preservation**:
- Extracts top-level metadata from object envelopes (such as
`cpuThrottling` and `networkThrottling`) and merges it with caller
options.
4. **Strict Structural Validation**:
- Enforces valid structural tokens (closing brackets, braces, colons,
commas) and prevents silent truncation on incomplete buffers or
unexpected EOF.
## Testing
- Unit tests in `tests/trace-processing/ChunkedTraceParser.test.ts`
covering:
- Both top-level array and object formats.
- Custom batch sizes and large batches exceeding function call argument
limits (70,000+ events).
- Malformed inputs: truncated arrays, unterminated strings, unquoted
keys, trailing characters.
- Multibyte UTF-8 characters split across byte boundaries.
- Full regression tests against existing trace fixtures
(`basic-trace.json.gz`, `web-dev-with-commit.json.gz`).
This commit is contained in:
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Provides memory-efficient chunked parsing for Chrome DevTools trace files.
|
||||
* Large performance traces can exceed V8 string length limits and cause heap exhaustion.
|
||||
* This parser scans raw byte buffers, finds event boundaries, and parses events in batches.
|
||||
*/
|
||||
|
||||
import type {DevTools} from '../third_party/index.js';
|
||||
|
||||
/**
|
||||
* Represents the output of a parsed trace buffer.
|
||||
*/
|
||||
export interface ParsedTraceBuffer {
|
||||
/** The collection of parsed trace events extracted from the buffer. */
|
||||
events: DevTools.TraceEngine.Types.Events.Event[];
|
||||
/** Optional file metadata extracted from the trace object container. */
|
||||
metadata?: DevTools.TraceEngine.Types.File.MetaData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for trace buffer parsing.
|
||||
*/
|
||||
export interface ParseTraceBufferOptions {
|
||||
/**
|
||||
* The maximum number of trace events to decode and parse in a single batch.
|
||||
* Lower values reduce memory spikes during string decoding.
|
||||
* Higher values decrease the total number of JSON.parse calls.
|
||||
* Non-positive or non-finite values fall back to the default.
|
||||
* @defaultValue 10_000
|
||||
*/
|
||||
eventsPerBatch?: number;
|
||||
/**
|
||||
* The maximum byte size of raw trace event data to decode in a single batch.
|
||||
* Flushes batches before V8 string length limits are approached.
|
||||
* Non-positive or non-finite values fall back to the default.
|
||||
* @defaultValue 33_554_432 (32 MB)
|
||||
*/
|
||||
maxBatchBytes?: number;
|
||||
}
|
||||
|
||||
interface BatchConfig {
|
||||
eventsPerBatch: number;
|
||||
maxBatchBytes: number;
|
||||
decoder: TextDecoder;
|
||||
}
|
||||
|
||||
const DEFAULT_EVENTS_PER_BATCH = 10_000;
|
||||
const DEFAULT_MAX_BATCH_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
// ASCII character byte constants used by the state machine.
|
||||
// All values are strictly less than 0x80 (standard ASCII).
|
||||
// In UTF-8 encoding, multi-byte code units only contain bytes from 0x80 through 0xFF.
|
||||
// Therefore, scanning for these byte values will never match bytes inside multi-byte characters.
|
||||
const BYTE_QUOTE = 0x22;
|
||||
const BYTE_BACKSLASH = 0x5c;
|
||||
const BYTE_OPEN_BRACE = 0x7b;
|
||||
const BYTE_CLOSE_BRACE = 0x7d;
|
||||
const BYTE_OPEN_BRACKET = 0x5b;
|
||||
const BYTE_CLOSE_BRACKET = 0x5d;
|
||||
const BYTE_COLON = 0x3a;
|
||||
const BYTE_COMMA = 0x2c;
|
||||
|
||||
/**
|
||||
* Advances past any RFC 8259 JSON whitespace bytes.
|
||||
*
|
||||
* @param buffer - The trace byte buffer to inspect.
|
||||
* @param start - The buffer index where scanning begins.
|
||||
* @returns The index of the first non-whitespace byte, or the buffer length if the buffer ends.
|
||||
*/
|
||||
function skipWhitespace(
|
||||
buffer: Uint8Array<ArrayBufferLike>,
|
||||
start: number,
|
||||
): number {
|
||||
let i = start;
|
||||
while (i < buffer.length) {
|
||||
const byte = buffer[i];
|
||||
if (byte !== 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d) {
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans past a JSON string starting at the opening quotation mark.
|
||||
*
|
||||
* @param buffer - The trace byte buffer to scan.
|
||||
* @param startQuotePos - The buffer index of the opening quotation mark.
|
||||
* @returns The index immediately following the closing quotation mark.
|
||||
* @throws {SyntaxError} If the string is unterminated before the end of the buffer.
|
||||
*/
|
||||
function skipString(
|
||||
buffer: Uint8Array<ArrayBufferLike>,
|
||||
startQuotePos: number,
|
||||
): number {
|
||||
let pos = startQuotePos + 1;
|
||||
while (pos < buffer.length) {
|
||||
const byte = buffer[pos];
|
||||
if (byte === BYTE_BACKSLASH) {
|
||||
if (pos + 1 >= buffer.length) {
|
||||
throw new SyntaxError('Unterminated string in JSON');
|
||||
}
|
||||
pos += 2;
|
||||
continue;
|
||||
}
|
||||
if (byte === BYTE_QUOTE) {
|
||||
return pos + 1;
|
||||
}
|
||||
pos++;
|
||||
}
|
||||
throw new SyntaxError('Unterminated string in JSON');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans past a complete JSON value starting at the specified buffer position.
|
||||
* Skips strings, compound objects, arrays, and primitive values without parsing them into memory.
|
||||
*
|
||||
* @param buffer - The trace byte buffer to scan.
|
||||
* @param startPos - The buffer index where the value begins.
|
||||
* @returns The index immediately following the skipped JSON value.
|
||||
* @throws {SyntaxError} If the value contains unbalanced delimiters, unterminated strings, or empty primitive tokens.
|
||||
*/
|
||||
function skipValue(
|
||||
buffer: Uint8Array<ArrayBufferLike>,
|
||||
startPos: number,
|
||||
): number {
|
||||
let pos = skipWhitespace(buffer, startPos);
|
||||
if (pos >= buffer.length) {
|
||||
return pos;
|
||||
}
|
||||
const firstByte = buffer[pos];
|
||||
if (firstByte === BYTE_QUOTE) {
|
||||
return skipString(buffer, pos);
|
||||
}
|
||||
if (firstByte === BYTE_OPEN_BRACE || firstByte === BYTE_OPEN_BRACKET) {
|
||||
const delimiterStack: number[] = [];
|
||||
delimiterStack.push(
|
||||
firstByte === BYTE_OPEN_BRACE ? BYTE_CLOSE_BRACE : BYTE_CLOSE_BRACKET,
|
||||
);
|
||||
pos++;
|
||||
while (pos < buffer.length) {
|
||||
const byte = buffer[pos];
|
||||
if (byte === BYTE_QUOTE) {
|
||||
pos = skipString(buffer, pos);
|
||||
continue;
|
||||
}
|
||||
if (byte === BYTE_OPEN_BRACE) {
|
||||
delimiterStack.push(BYTE_CLOSE_BRACE);
|
||||
} else if (byte === BYTE_OPEN_BRACKET) {
|
||||
delimiterStack.push(BYTE_CLOSE_BRACKET);
|
||||
} else if (byte === BYTE_CLOSE_BRACE || byte === BYTE_CLOSE_BRACKET) {
|
||||
const expected = delimiterStack.pop();
|
||||
if (expected !== byte) {
|
||||
throw new SyntaxError('Unexpected delimiter in JSON');
|
||||
}
|
||||
if (delimiterStack.length === 0) {
|
||||
pos++;
|
||||
return pos;
|
||||
}
|
||||
}
|
||||
pos++;
|
||||
}
|
||||
throw new SyntaxError('Unexpected end of JSON input');
|
||||
}
|
||||
|
||||
const valStart = pos;
|
||||
while (pos < buffer.length) {
|
||||
const byte = buffer[pos];
|
||||
if (
|
||||
byte === BYTE_COMMA ||
|
||||
byte === BYTE_CLOSE_BRACE ||
|
||||
byte === BYTE_CLOSE_BRACKET ||
|
||||
byte === 0x20 ||
|
||||
byte === 0x09 ||
|
||||
byte === 0x0a ||
|
||||
byte === 0x0d
|
||||
) {
|
||||
break;
|
||||
}
|
||||
pos++;
|
||||
}
|
||||
if (pos === valStart) {
|
||||
throw new SyntaxError('Expected JSON value');
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans trace events from a JSON array in bounded batches to limit memory consumption.
|
||||
*
|
||||
* @param buffer - The raw byte buffer containing the events array.
|
||||
* @param startPos - The index immediately after the opening array bracket.
|
||||
* @param config - Configuration settings controlling batch sizes and text decoding.
|
||||
* @returns An object containing the parsed events and the ending buffer position.
|
||||
* @throws {SyntaxError} If the event array contains malformed JSON, unbalanced delimiters, or unexpected tokens.
|
||||
*/
|
||||
function parseEventsArray(
|
||||
buffer: Uint8Array<ArrayBufferLike>,
|
||||
startPos: number,
|
||||
config: BatchConfig,
|
||||
): {events: DevTools.TraceEngine.Types.Events.Event[]; endPos: number} {
|
||||
const events: DevTools.TraceEngine.Types.Events.Event[] = [];
|
||||
let batchStart = -1;
|
||||
let lastEventEnd = -1;
|
||||
let batchEventCount = 0;
|
||||
let depth = 0;
|
||||
let i = startPos;
|
||||
let closed = false;
|
||||
let state:
|
||||
'expect_element_or_end' | 'expect_element' | 'expect_comma_or_end' =
|
||||
'expect_element_or_end';
|
||||
|
||||
const flushBatch = (): void => {
|
||||
if (batchStart !== -1 && lastEventEnd > batchStart) {
|
||||
// Slicing between batchStart and lastEventEnd is safe for UTF-8 decoding.
|
||||
// Both boundaries are ASCII structural braces ({ and }) that align with code unit boundaries.
|
||||
const slice = buffer.subarray(batchStart, lastEventEnd);
|
||||
const chunkText = config.decoder.decode(slice);
|
||||
// Wrapping comma-delimited event objects in brackets creates a valid JSON array for the parser.
|
||||
const parsed: DevTools.TraceEngine.Types.Events.Event[] = JSON.parse(
|
||||
`[${chunkText}]`,
|
||||
);
|
||||
// Append items iteratively to avoid exceeding V8 call stack size limits on large batches.
|
||||
for (const event of parsed) {
|
||||
events.push(event);
|
||||
}
|
||||
batchStart = -1;
|
||||
batchEventCount = 0;
|
||||
}
|
||||
};
|
||||
|
||||
while (i < buffer.length) {
|
||||
const byte = buffer[i];
|
||||
|
||||
if (depth > 0) {
|
||||
if (byte === BYTE_QUOTE) {
|
||||
i = skipString(buffer, i);
|
||||
continue;
|
||||
}
|
||||
if (byte === BYTE_OPEN_BRACE) {
|
||||
depth++;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (byte === BYTE_CLOSE_BRACE) {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
lastEventEnd = i + 1;
|
||||
batchEventCount++;
|
||||
state = 'expect_comma_or_end';
|
||||
|
||||
// Check thresholds after recording the completed event.
|
||||
// Checking batchEventCount after incrementing ensures batches cap at exactly eventsPerBatch.
|
||||
// Individual events are atomic JSON objects and cannot be split mid-event; when an event
|
||||
// pushes currentBatchBytes over maxBatchBytes, the completed event is flushed immediately.
|
||||
const currentBatchBytes = lastEventEnd - batchStart;
|
||||
if (
|
||||
batchEventCount >= config.eventsPerBatch ||
|
||||
currentBatchBytes >= config.maxBatchBytes
|
||||
) {
|
||||
flushBatch();
|
||||
}
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// When depth is 0, scan top-level array delimiter and separator tokens.
|
||||
if (byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (byte === BYTE_OPEN_BRACE) {
|
||||
if (state !== 'expect_element_or_end' && state !== 'expect_element') {
|
||||
throw new SyntaxError('Expected "," or "]" in JSON array');
|
||||
}
|
||||
depth = 1;
|
||||
if (batchStart === -1) {
|
||||
batchStart = i;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (byte === BYTE_COMMA) {
|
||||
if (state !== 'expect_comma_or_end') {
|
||||
throw new SyntaxError('Unexpected token "," in JSON array');
|
||||
}
|
||||
state = 'expect_element';
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (byte === BYTE_CLOSE_BRACKET) {
|
||||
if (state === 'expect_element') {
|
||||
throw new SyntaxError('Unexpected token "]" in JSON array');
|
||||
}
|
||||
closed = true;
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
|
||||
if (byte === BYTE_CLOSE_BRACE) {
|
||||
throw new SyntaxError('Unexpected token "}" in JSON');
|
||||
}
|
||||
|
||||
throw new SyntaxError('Unexpected token in JSON array');
|
||||
}
|
||||
|
||||
if (!closed || depth !== 0) {
|
||||
throw new SyntaxError('Unexpected end of JSON input');
|
||||
}
|
||||
|
||||
flushBatch();
|
||||
return {events, endPos: i};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses Chrome DevTools trace events and metadata from a raw byte buffer.
|
||||
* Supports both root array format ([...]) and object container format ({"traceEvents": [...]}).
|
||||
* Parses events in chunks to prevent V8 string length and memory exhaustion errors.
|
||||
*
|
||||
* @param buffer - The raw trace buffer to parse.
|
||||
* @param options - Optional configuration settings for batch processing.
|
||||
* @returns A {@link ParsedTraceBuffer} containing parsed trace events and optional file metadata, or an object with an empty events array if the buffer is empty or contains only whitespace.
|
||||
* @throws {Error} If the buffer contains non-whitespace data that does not begin with a valid JSON array or object.
|
||||
* @throws {SyntaxError} If the underlying JSON syntax within batches or metadata is invalid.
|
||||
*/
|
||||
export function parseTraceEventsFromBuffer(
|
||||
buffer: Uint8Array<ArrayBufferLike>,
|
||||
options: ParseTraceBufferOptions = {},
|
||||
): ParsedTraceBuffer {
|
||||
const eventsPerBatch =
|
||||
typeof options.eventsPerBatch === 'number' &&
|
||||
Number.isFinite(options.eventsPerBatch) &&
|
||||
options.eventsPerBatch > 0
|
||||
? options.eventsPerBatch
|
||||
: DEFAULT_EVENTS_PER_BATCH;
|
||||
|
||||
const maxBatchBytes =
|
||||
typeof options.maxBatchBytes === 'number' &&
|
||||
Number.isFinite(options.maxBatchBytes) &&
|
||||
options.maxBatchBytes > 0
|
||||
? options.maxBatchBytes
|
||||
: DEFAULT_MAX_BATCH_BYTES;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const config: BatchConfig = {
|
||||
eventsPerBatch,
|
||||
maxBatchBytes,
|
||||
decoder,
|
||||
};
|
||||
|
||||
let pos = skipWhitespace(buffer, 0);
|
||||
if (pos >= buffer.length) {
|
||||
return {events: []};
|
||||
}
|
||||
|
||||
const firstByte = buffer[pos];
|
||||
|
||||
if (firstByte === BYTE_OPEN_BRACKET) {
|
||||
const {events, endPos} = parseEventsArray(buffer, pos + 1, config);
|
||||
const trailingPos = skipWhitespace(buffer, endPos);
|
||||
if (trailingPos < buffer.length) {
|
||||
throw new SyntaxError('Unexpected non-whitespace character after JSON');
|
||||
}
|
||||
return {events};
|
||||
}
|
||||
|
||||
if (firstByte === BYTE_OPEN_BRACE) {
|
||||
pos++;
|
||||
let events: DevTools.TraceEngine.Types.Events.Event[] = [];
|
||||
let metadata: DevTools.TraceEngine.Types.File.MetaData | undefined;
|
||||
let closed = false;
|
||||
let expectComma = false;
|
||||
|
||||
while (pos < buffer.length) {
|
||||
pos = skipWhitespace(buffer, pos);
|
||||
if (pos >= buffer.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (buffer[pos] === BYTE_CLOSE_BRACE) {
|
||||
closed = true;
|
||||
pos++;
|
||||
break;
|
||||
}
|
||||
|
||||
if (expectComma) {
|
||||
if (buffer[pos] !== BYTE_COMMA) {
|
||||
throw new SyntaxError(
|
||||
'Expected "," or "}" after property value in JSON',
|
||||
);
|
||||
}
|
||||
pos++;
|
||||
pos = skipWhitespace(buffer, pos);
|
||||
}
|
||||
|
||||
if (pos >= buffer.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (buffer[pos] !== BYTE_QUOTE) {
|
||||
throw new SyntaxError('Expected property name or "}" in JSON');
|
||||
}
|
||||
|
||||
const keyStart = pos;
|
||||
pos = skipString(buffer, pos);
|
||||
const keyJson = decoder.decode(buffer.subarray(keyStart, pos));
|
||||
const parsedKey: unknown = JSON.parse(keyJson);
|
||||
if (typeof parsedKey !== 'string') {
|
||||
throw new SyntaxError('Expected property name in JSON');
|
||||
}
|
||||
const key = parsedKey;
|
||||
|
||||
pos = skipWhitespace(buffer, pos);
|
||||
if (pos >= buffer.length || buffer[pos] !== BYTE_COLON) {
|
||||
throw new SyntaxError('Expected ":" after property name in JSON');
|
||||
}
|
||||
pos++;
|
||||
pos = skipWhitespace(buffer, pos);
|
||||
|
||||
if (key === 'traceEvents') {
|
||||
if (pos >= buffer.length || buffer[pos] !== BYTE_OPEN_BRACKET) {
|
||||
throw new SyntaxError('Expected "[" for "traceEvents" in JSON');
|
||||
}
|
||||
const arrayResult = parseEventsArray(buffer, pos + 1, config);
|
||||
events = arrayResult.events;
|
||||
pos = arrayResult.endPos;
|
||||
} else if (key === 'metadata') {
|
||||
if (pos >= buffer.length || buffer[pos] !== BYTE_OPEN_BRACE) {
|
||||
throw new SyntaxError('Expected "{" for "metadata" in JSON');
|
||||
}
|
||||
const metaStart = pos;
|
||||
pos = skipValue(buffer, pos);
|
||||
const metaSlice = buffer.subarray(metaStart, pos);
|
||||
const metaText = decoder.decode(metaSlice);
|
||||
const parsedMetadata: DevTools.TraceEngine.Types.File.MetaData =
|
||||
JSON.parse(metaText);
|
||||
metadata = parsedMetadata;
|
||||
} else {
|
||||
pos = skipValue(buffer, pos);
|
||||
}
|
||||
expectComma = true;
|
||||
}
|
||||
|
||||
if (!closed) {
|
||||
throw new SyntaxError('Unexpected end of JSON input');
|
||||
}
|
||||
|
||||
const trailingPos = skipWhitespace(buffer, pos);
|
||||
if (trailingPos < buffer.length) {
|
||||
throw new SyntaxError('Unexpected non-whitespace character after JSON');
|
||||
}
|
||||
|
||||
return {events, metadata};
|
||||
}
|
||||
|
||||
throw new Error('Invalid trace buffer: expected JSON object or array.');
|
||||
}
|
||||
@@ -6,19 +6,35 @@
|
||||
|
||||
import {DevTools} from '../third_party/index.js';
|
||||
import {logger} from '../utils/logger.js';
|
||||
import {parseTraceEventsFromBuffer} from './ChunkedTraceParser.js';
|
||||
|
||||
/**
|
||||
* Represents the successful output of processing a performance trace.
|
||||
*/
|
||||
export interface TraceResult {
|
||||
/** The fully processed trace model output containing event graphs and handler data. */
|
||||
parsedTrace: DevTools.TraceEngine.TraceModel.ParsedTrace;
|
||||
/** Computed performance insights for navigations in the trace, or null if unavailable. */
|
||||
insights: DevTools.TraceEngine.Insights.Types.TraceInsightSets | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard that verifies if an operation returned a valid TraceResult.
|
||||
*
|
||||
* @param x - The result or error object to inspect.
|
||||
* @returns True if the object is a TraceResult; otherwise false.
|
||||
*/
|
||||
export function traceResultIsSuccess(
|
||||
x: TraceResult | TraceParseError,
|
||||
): x is TraceResult {
|
||||
return 'parsedTrace' in x;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an error encountered while reading or parsing a trace buffer.
|
||||
*/
|
||||
export interface TraceParseError {
|
||||
/** The descriptive error message detailing why trace processing failed. */
|
||||
error: string;
|
||||
}
|
||||
|
||||
@@ -26,12 +42,13 @@ export interface TraceParseError {
|
||||
* Parses raw JSON trace buffer bytes into a DevTools TraceEngine representation.
|
||||
*
|
||||
* Accepts either a JSON array of trace events or an object with a `traceEvents` field.
|
||||
* A new trace engine model is created per call to ensure session isolation and prevent
|
||||
* memory retention.
|
||||
* Events are parsed in chunks directly from the raw byte buffer to avoid V8 string
|
||||
* allocation limits. A new trace engine model is created per call to ensure session
|
||||
* isolation and prevent memory retention.
|
||||
*
|
||||
* @param buffer Raw UTF-8 encoded JSON bytes representing trace data.
|
||||
* @param metadata Optional throttling configurations applied during recording.
|
||||
* @returns A {@link TraceResult} with parsed traces and insights, or a {@link TraceParseError} on failure.
|
||||
* @param buffer - Raw binary trace data representing trace events and metadata.
|
||||
* @param metadata - Optional throttling configurations applied during recording; overrides embedded file metadata when defined.
|
||||
* @returns A promise resolving to a {@link TraceResult} with parsed traces and insights, or a {@link TraceParseError} on failure.
|
||||
*/
|
||||
export async function parseRawTraceBuffer(
|
||||
buffer: Uint8Array<ArrayBufferLike> | undefined,
|
||||
@@ -40,31 +57,37 @@ export async function parseRawTraceBuffer(
|
||||
networkThrottling?: string;
|
||||
},
|
||||
): Promise<TraceResult | TraceParseError> {
|
||||
if (!buffer) {
|
||||
if (!buffer || buffer.length === 0) {
|
||||
return {
|
||||
error: 'No buffer was provided.',
|
||||
};
|
||||
}
|
||||
const asString = new TextDecoder().decode(buffer);
|
||||
if (!asString) {
|
||||
return {
|
||||
error: 'Decoding the trace buffer returned an empty string.',
|
||||
};
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(asString) as
|
||||
| {
|
||||
traceEvents: DevTools.TraceEngine.Types.Events.Event[];
|
||||
}
|
||||
| DevTools.TraceEngine.Types.Events.Event[];
|
||||
const {events, metadata: fileMetadata} = parseTraceEventsFromBuffer(buffer);
|
||||
if (events.length === 0) {
|
||||
return {
|
||||
error: 'No trace events were found in the trace buffer.',
|
||||
};
|
||||
}
|
||||
const combinedMetadata: DevTools.TraceEngine.Types.File.MetaData = {
|
||||
...fileMetadata,
|
||||
...(metadata?.cpuThrottling !== undefined
|
||||
? {cpuThrottling: metadata.cpuThrottling}
|
||||
: {}),
|
||||
...(metadata?.networkThrottling !== undefined
|
||||
? {networkThrottling: metadata.networkThrottling}
|
||||
: {}),
|
||||
};
|
||||
const hasMetadata = Object.keys(combinedMetadata).length > 0;
|
||||
|
||||
const events = Array.isArray(data) ? data : data.traceEvents;
|
||||
// Instantiate a fresh TraceModel per invocation because Model permanently
|
||||
// retains parsed traces in its internal `#traces` array, which causes an
|
||||
// unbounded memory leak if reused across sessions.
|
||||
const engine =
|
||||
DevTools.TraceEngine.TraceModel.Model.createWithAllHandlers();
|
||||
await engine.parse(events, {metadata});
|
||||
await engine.parse(events, {
|
||||
metadata: hasMetadata ? combinedMetadata : undefined,
|
||||
});
|
||||
const parsedTrace = engine.parsedTrace();
|
||||
if (!parsedTrace) {
|
||||
return {
|
||||
@@ -93,6 +116,13 @@ ${DevTools.PerformanceTraceFormatter.callFrameDataFormatDescription}
|
||||
|
||||
${DevTools.PerformanceTraceFormatter.networkDataFormatDescription}`;
|
||||
|
||||
/**
|
||||
* Generates a Markdown summary of main thread activity and network metrics from a parsed trace.
|
||||
*
|
||||
* @param result - The parsed trace result to summarize.
|
||||
* @param deviceScope - Optional CrUX device scope to filter field data.
|
||||
* @returns Formatted Markdown text describing performance findings.
|
||||
*/
|
||||
export function getTraceSummary(
|
||||
result: TraceResult,
|
||||
deviceScope?: DevTools.CrUXManager.DeviceScope | null,
|
||||
@@ -107,10 +137,22 @@ ${summaryText}
|
||||
${extraFormatDescriptions}`;
|
||||
}
|
||||
|
||||
/** Identifies a specific performance insight model type supported by the trace engine. */
|
||||
export type InsightName =
|
||||
keyof DevTools.TraceEngine.Insights.Types.InsightModels;
|
||||
|
||||
/** Represents the result of an insight formatting request. */
|
||||
export type InsightOutput = {output: string} | {error: string};
|
||||
|
||||
/**
|
||||
* Formats a specific performance insight from a parsed trace for display.
|
||||
*
|
||||
* @param result - The parsed trace result containing computed insight sets.
|
||||
* @param insightSetId - The identifier of the target insight set.
|
||||
* @param insightName - The name of the insight model to extract.
|
||||
* @param deviceScope - Optional CrUX device scope to contextualize metrics.
|
||||
* @returns An object containing the formatted insight output text or an error message.
|
||||
*/
|
||||
export function getInsightOutput(
|
||||
result: TraceResult,
|
||||
insightSetId: string,
|
||||
|
||||
@@ -786,8 +786,8 @@ exports[`McpResponse network pagination > trace summaries > includes the trace s
|
||||
## Summary of Performance trace findings:
|
||||
URL: https://web.dev/
|
||||
Trace bounds: {min: 122410994891µs, max: 122416385853µs}
|
||||
CPU throttling: none
|
||||
Network throttling: none
|
||||
CPU throttling: 1x
|
||||
Network throttling: No throttling
|
||||
|
||||
# Available insight sets
|
||||
|
||||
@@ -903,8 +903,8 @@ exports[`McpResponse network pagination > trace summaries > includes the trace s
|
||||
## Summary of Performance trace findings:
|
||||
URL: https://web.dev/
|
||||
Trace bounds: {min: 122410994891µs, max: 122416385853µs}
|
||||
CPU throttling: none
|
||||
Network throttling: none
|
||||
CPU throttling: 1x
|
||||
Network throttling: No throttling
|
||||
|
||||
# Available insight sets
|
||||
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
import {describe, it} from 'node:test';
|
||||
|
||||
import {parseTraceEventsFromBuffer} from '../../src/processors/ChunkedTraceParser.js';
|
||||
|
||||
import {loadTraceAsBuffer} from './fixtures/load.js';
|
||||
|
||||
describe('ChunkedTraceParser', () => {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
it('parses an empty trace array', () => {
|
||||
const buffer = encoder.encode('[]');
|
||||
const result = parseTraceEventsFromBuffer(buffer);
|
||||
assert.strictEqual(result.events.length, 0);
|
||||
});
|
||||
|
||||
it('parses an empty traceEvents object', () => {
|
||||
const buffer = encoder.encode('{"traceEvents": []}');
|
||||
const result = parseTraceEventsFromBuffer(buffer);
|
||||
assert.strictEqual(result.events.length, 0);
|
||||
});
|
||||
|
||||
it('parses a bare array of events', () => {
|
||||
const json = JSON.stringify([
|
||||
{name: 'event-1', ph: 'X', ts: 100},
|
||||
{name: 'event-2', ph: 'B', ts: 200},
|
||||
]);
|
||||
const buffer = encoder.encode(json);
|
||||
const result = parseTraceEventsFromBuffer(buffer);
|
||||
|
||||
assert.strictEqual(result.events.length, 2);
|
||||
assert.strictEqual(result.events[0]?.name, 'event-1');
|
||||
assert.strictEqual(result.events[1]?.name, 'event-2');
|
||||
});
|
||||
|
||||
it('parses an object with traceEvents and metadata', () => {
|
||||
const json = JSON.stringify({
|
||||
traceEvents: [
|
||||
{name: 'event-1', ph: 'X', ts: 100},
|
||||
{name: 'event-2', ph: 'E', ts: 150},
|
||||
],
|
||||
metadata: {
|
||||
cpuThrottling: 4,
|
||||
},
|
||||
});
|
||||
const buffer = encoder.encode(json);
|
||||
const result = parseTraceEventsFromBuffer(buffer);
|
||||
|
||||
assert.strictEqual(result.events.length, 2);
|
||||
assert.strictEqual(result.events[0]?.name, 'event-1');
|
||||
assert.strictEqual(result.events[1]?.name, 'event-2');
|
||||
assert.strictEqual(result.metadata?.cpuThrottling, 4);
|
||||
});
|
||||
|
||||
it('parses metadata when metadata appears before traceEvents', () => {
|
||||
const json = JSON.stringify({
|
||||
metadata: {
|
||||
cpuThrottling: 2,
|
||||
},
|
||||
traceEvents: [{name: 'event-1', ph: 'X', ts: 50}],
|
||||
});
|
||||
const buffer = encoder.encode(json);
|
||||
const result = parseTraceEventsFromBuffer(buffer);
|
||||
|
||||
assert.strictEqual(result.events.length, 1);
|
||||
assert.strictEqual(result.events[0]?.name, 'event-1');
|
||||
assert.strictEqual(result.metadata?.cpuThrottling, 2);
|
||||
});
|
||||
|
||||
it('skips unrelated top-level properties', () => {
|
||||
const json = JSON.stringify({
|
||||
displayTimeUnit: 'ms',
|
||||
traceEvents: [{name: 'event-1', ph: 'X', ts: 10}],
|
||||
otherData: {nested: true, count: 42},
|
||||
});
|
||||
const buffer = encoder.encode(json);
|
||||
const result = parseTraceEventsFromBuffer(buffer);
|
||||
|
||||
assert.strictEqual(result.events.length, 1);
|
||||
assert.strictEqual(result.events[0]?.name, 'event-1');
|
||||
});
|
||||
|
||||
it('handles strings containing braces, brackets, and escaped quotes', () => {
|
||||
const json = JSON.stringify({
|
||||
traceEvents: [
|
||||
{
|
||||
name: 'event-{with-braces}',
|
||||
args: {
|
||||
data: {
|
||||
url: 'https://example.com/hello?q=} { [bracket] "escaped" \\\\ backslash',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
title: 'nested } braces { inside',
|
||||
},
|
||||
});
|
||||
const buffer = encoder.encode(json);
|
||||
const result = parseTraceEventsFromBuffer(buffer);
|
||||
|
||||
assert.strictEqual(result.events.length, 1);
|
||||
assert.strictEqual(result.events[0]?.name, 'event-{with-braces}');
|
||||
assert.strictEqual(
|
||||
result.events[0]?.args?.data?.url,
|
||||
'https://example.com/hello?q=} { [bracket] "escaped" \\\\ backslash',
|
||||
);
|
||||
assert.strictEqual(result.metadata?.title, 'nested } braces { inside');
|
||||
});
|
||||
|
||||
it('handles nested objects in event args data', () => {
|
||||
const json = JSON.stringify({
|
||||
traceEvents: [
|
||||
{
|
||||
name: 'deep',
|
||||
args: {
|
||||
data: {
|
||||
url: 'https://example.com',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const buffer = encoder.encode(json);
|
||||
const result = parseTraceEventsFromBuffer(buffer);
|
||||
|
||||
assert.strictEqual(result.events.length, 1);
|
||||
assert.strictEqual(result.events[0]?.name, 'deep');
|
||||
assert.strictEqual(
|
||||
result.events[0]?.args?.data?.url,
|
||||
'https://example.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('batches events correctly according to eventsPerBatch', () => {
|
||||
const json = JSON.stringify({
|
||||
traceEvents: [
|
||||
{name: 'e1', ts: 1},
|
||||
{name: 'e2', ts: 2},
|
||||
{name: 'e3', ts: 3},
|
||||
{name: 'e4', ts: 4},
|
||||
{name: 'e5', ts: 5},
|
||||
],
|
||||
});
|
||||
const buffer = encoder.encode(json);
|
||||
// Batch size of 2 on 5 items tests full batches and remainder batch.
|
||||
const result = parseTraceEventsFromBuffer(buffer, {eventsPerBatch: 2});
|
||||
|
||||
assert.strictEqual(result.events.length, 5);
|
||||
assert.strictEqual(result.events[0]?.name, 'e1');
|
||||
assert.strictEqual(result.events[1]?.name, 'e2');
|
||||
assert.strictEqual(result.events[2]?.name, 'e3');
|
||||
assert.strictEqual(result.events[3]?.name, 'e4');
|
||||
assert.strictEqual(result.events[4]?.name, 'e5');
|
||||
});
|
||||
|
||||
it('parses a real trace fixture matching standard JSON.parse', () => {
|
||||
const rawData = loadTraceAsBuffer('basic-trace.json.gz');
|
||||
const result = parseTraceEventsFromBuffer(rawData, {eventsPerBatch: 10});
|
||||
|
||||
const standardJsonText = new TextDecoder().decode(rawData);
|
||||
const standardParsed:
|
||||
| Array<{name: string; ts: number}>
|
||||
| {traceEvents: Array<{name: string; ts: number}>} =
|
||||
JSON.parse(standardJsonText);
|
||||
const expectedEvents = Array.isArray(standardParsed)
|
||||
? standardParsed
|
||||
: standardParsed.traceEvents;
|
||||
|
||||
assert.strictEqual(result.events.length, expectedEvents.length);
|
||||
assert.strictEqual(result.events[0]?.name, expectedEvents[0]?.name);
|
||||
assert.strictEqual(result.events[0]?.ts, expectedEvents[0]?.ts);
|
||||
const lastIndex = result.events.length - 1;
|
||||
assert.strictEqual(
|
||||
result.events[lastIndex]?.name,
|
||||
expectedEvents[lastIndex]?.name,
|
||||
);
|
||||
});
|
||||
|
||||
it('parses a larger trace fixture in multiple batches', () => {
|
||||
const rawData = loadTraceAsBuffer('web-dev-with-commit.json.gz');
|
||||
const result = parseTraceEventsFromBuffer(rawData, {eventsPerBatch: 5000});
|
||||
|
||||
const standardJsonText = new TextDecoder().decode(rawData);
|
||||
const standardParsed:
|
||||
| Array<{name: string; ts: number}>
|
||||
| {traceEvents: Array<{name: string; ts: number}>} =
|
||||
JSON.parse(standardJsonText);
|
||||
const expectedEvents = Array.isArray(standardParsed)
|
||||
? standardParsed
|
||||
: standardParsed.traceEvents;
|
||||
|
||||
assert.strictEqual(result.events.length, expectedEvents.length);
|
||||
assert.strictEqual(result.events.length, 47705);
|
||||
assert.strictEqual(result.events[0]?.name, expectedEvents[0]?.name);
|
||||
const lastIndex = result.events.length - 1;
|
||||
assert.strictEqual(
|
||||
result.events[lastIndex]?.name,
|
||||
expectedEvents[lastIndex]?.name,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws SyntaxError when a bare array is truncated before closing bracket', () => {
|
||||
const buffer = encoder.encode('[{"name": "e1", "ts": 1}');
|
||||
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
|
||||
});
|
||||
|
||||
it('throws SyntaxError when a string inside an event is unterminated', () => {
|
||||
const buffer = encoder.encode('[{"name": "unterminated');
|
||||
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
|
||||
});
|
||||
|
||||
it('throws SyntaxError when an object container is truncated before closing brace', () => {
|
||||
const buffer = encoder.encode('{"traceEvents": [{"name": "e1"}]');
|
||||
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
|
||||
});
|
||||
|
||||
it('throws SyntaxError on stray closing braces', () => {
|
||||
const buffer = encoder.encode('[{"name": "e1"}}]');
|
||||
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
|
||||
});
|
||||
|
||||
it('handles multibyte UTF-8 characters across batch boundaries', () => {
|
||||
const json = JSON.stringify({
|
||||
traceEvents: [
|
||||
{
|
||||
name: '日本語テスト-1',
|
||||
args: {data: {url: 'https://example.com/こんにちは世界-🚀'}},
|
||||
},
|
||||
{
|
||||
name: 'emoji-🎉-2',
|
||||
args: {data: {url: 'https://example.com/€100-and-50¢'}},
|
||||
},
|
||||
{
|
||||
name: 'umlaut-äöü-3',
|
||||
args: {data: {url: 'https://example.com/Deutsch-äöü'}},
|
||||
},
|
||||
],
|
||||
});
|
||||
const buffer = encoder.encode(json);
|
||||
const result = parseTraceEventsFromBuffer(buffer, {eventsPerBatch: 1});
|
||||
|
||||
assert.strictEqual(result.events.length, 3);
|
||||
assert.strictEqual(result.events[0]?.name, '日本語テスト-1');
|
||||
assert.strictEqual(
|
||||
result.events[0]?.args?.data?.url,
|
||||
'https://example.com/こんにちは世界-🚀',
|
||||
);
|
||||
assert.strictEqual(result.events[1]?.name, 'emoji-🎉-2');
|
||||
assert.strictEqual(
|
||||
result.events[1]?.args?.data?.url,
|
||||
'https://example.com/€100-and-50¢',
|
||||
);
|
||||
assert.strictEqual(result.events[2]?.name, 'umlaut-äöü-3');
|
||||
assert.strictEqual(
|
||||
result.events[2]?.args?.data?.url,
|
||||
'https://example.com/Deutsch-äöü',
|
||||
);
|
||||
});
|
||||
|
||||
it('parses large batches exceeding function argument limits without call stack errors', () => {
|
||||
const eventCount = 70_000;
|
||||
const items: string[] = [];
|
||||
for (let idx = 0; idx < eventCount; idx++) {
|
||||
items.push(`{"name":"evt-${idx}","ts":${idx}}`);
|
||||
}
|
||||
const buffer = encoder.encode(`[${items.join(',')}]`);
|
||||
const result = parseTraceEventsFromBuffer(buffer, {
|
||||
eventsPerBatch: eventCount,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.events.length, eventCount);
|
||||
assert.strictEqual(result.events[0]?.name, 'evt-0');
|
||||
assert.strictEqual(result.events[69_999]?.name, 'evt-69999');
|
||||
});
|
||||
|
||||
it('throws SyntaxError when non-whitespace characters follow a closed array', () => {
|
||||
const buffer = encoder.encode('[{"name": "e1"}] extra');
|
||||
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
|
||||
});
|
||||
|
||||
it('throws SyntaxError when non-whitespace characters follow a closed object', () => {
|
||||
const buffer = encoder.encode('{"traceEvents": []} extra');
|
||||
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
|
||||
});
|
||||
|
||||
it('throws SyntaxError when a property key is missing a colon in object container', () => {
|
||||
const buffer = encoder.encode('{"traceEvents" []}');
|
||||
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
|
||||
});
|
||||
|
||||
it('throws SyntaxError when a comma is missing between object properties', () => {
|
||||
const buffer = encoder.encode('{"traceEvents": [] "metadata": {}}');
|
||||
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
|
||||
});
|
||||
|
||||
it('throws SyntaxError when an object key string is unterminated', () => {
|
||||
const buffer = encoder.encode('{"traceEvents');
|
||||
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
|
||||
});
|
||||
|
||||
it('batches events by maxBatchBytes when byte threshold is exceeded', () => {
|
||||
const e1 = {
|
||||
name: 'event-one-long-name',
|
||||
args: {detail: 'some-payload-string-1'},
|
||||
};
|
||||
const e2 = {
|
||||
name: 'event-two-long-name',
|
||||
args: {detail: 'some-payload-string-2'},
|
||||
};
|
||||
const json = JSON.stringify([e1, e2]);
|
||||
const buffer = encoder.encode(json);
|
||||
const result = parseTraceEventsFromBuffer(buffer, {
|
||||
eventsPerBatch: 100,
|
||||
maxBatchBytes: 40,
|
||||
});
|
||||
assert.strictEqual(result.events.length, 2);
|
||||
assert.strictEqual(result.events[0]?.name, 'event-one-long-name');
|
||||
assert.strictEqual(result.events[1]?.name, 'event-two-long-name');
|
||||
});
|
||||
|
||||
it('falls back to defaults when invalid batch options are provided', () => {
|
||||
const json = JSON.stringify([{name: 'e1'}, {name: 'e2'}]);
|
||||
const buffer = encoder.encode(json);
|
||||
const result = parseTraceEventsFromBuffer(buffer, {
|
||||
eventsPerBatch: -5,
|
||||
maxBatchBytes: NaN,
|
||||
});
|
||||
assert.strictEqual(result.events.length, 2);
|
||||
assert.strictEqual(result.events[0]?.name, 'e1');
|
||||
assert.strictEqual(result.events[1]?.name, 'e2');
|
||||
});
|
||||
|
||||
it('throws SyntaxError when a comma is missing between events', () => {
|
||||
const buffer = encoder.encode('[{"name": "e1"} {"name": "e2"}]');
|
||||
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
|
||||
});
|
||||
|
||||
it('throws SyntaxError on trailing comma in events array', () => {
|
||||
const buffer = encoder.encode('[{"name": "e1"}, ]');
|
||||
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
|
||||
});
|
||||
|
||||
it('throws SyntaxError on non-object items in events array', () => {
|
||||
const buffer1 = encoder.encode('[null, {"name": "e1"}]');
|
||||
assert.throws(() => parseTraceEventsFromBuffer(buffer1), SyntaxError);
|
||||
|
||||
const buffer2 = encoder.encode('[123, {"name": "e1"}]');
|
||||
assert.throws(() => parseTraceEventsFromBuffer(buffer2), SyntaxError);
|
||||
|
||||
const buffer3 = encoder.encode('["invalid", {"name": "e1"}]');
|
||||
assert.throws(() => parseTraceEventsFromBuffer(buffer3), SyntaxError);
|
||||
});
|
||||
|
||||
it('throws SyntaxError on nested array elements in events array', () => {
|
||||
const buffer = encoder.encode('[ [], {"name": "e1"} ]');
|
||||
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
|
||||
});
|
||||
|
||||
it('throws SyntaxError on mismatched delimiters in skipped values', () => {
|
||||
const buffer = encoder.encode('{"other": {"a": ]}, "traceEvents": []}');
|
||||
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
|
||||
});
|
||||
|
||||
it('throws SyntaxError on empty primitive value in container object', () => {
|
||||
const buffer = encoder.encode('{"other": , "traceEvents": []}');
|
||||
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
|
||||
});
|
||||
|
||||
it('throws SyntaxError on unterminated escape in skipped string', () => {
|
||||
const buffer = encoder.encode('{"other": "\\');
|
||||
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
|
||||
});
|
||||
|
||||
it('correctly decodes escaped container object keys', () => {
|
||||
const json =
|
||||
'{"\\u0074raceEvents": [{"name": "e1"}], "\\u006d\\u0065\\u0074\\u0061\\u0064\\u0061\\u0074\\u0061": {"cpuThrottling": 2}}';
|
||||
const buffer = encoder.encode(json);
|
||||
const result = parseTraceEventsFromBuffer(buffer);
|
||||
assert.strictEqual(result.events.length, 1);
|
||||
assert.strictEqual(result.events[0]?.name, 'e1');
|
||||
assert.strictEqual(result.metadata?.cpuThrottling, 2);
|
||||
});
|
||||
|
||||
it('throws SyntaxError when traceEvents property value is not an array', () => {
|
||||
const buffer = encoder.encode('{"traceEvents": "not-an-array"}');
|
||||
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
|
||||
});
|
||||
|
||||
it('throws SyntaxError when metadata property value is not an object', () => {
|
||||
const buffer = encoder.encode(
|
||||
'{"metadata": "not-an-object", "traceEvents": []}',
|
||||
);
|
||||
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
|
||||
});
|
||||
|
||||
it('throws SyntaxError on trailing comma in container object', () => {
|
||||
const buffer = encoder.encode('{"traceEvents": [],}');
|
||||
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,8 @@ exports[`Trace parsing > can format results of a trace 1`] = `
|
||||
## Summary of Performance trace findings:
|
||||
URL: https://web.dev/
|
||||
Trace bounds: {min: 122410994891µs, max: 122416385853µs}
|
||||
CPU throttling: none
|
||||
Network throttling: none
|
||||
CPU throttling: 1x
|
||||
Network throttling: No throttling
|
||||
|
||||
# Available insight sets
|
||||
|
||||
|
||||
@@ -78,4 +78,36 @@ describe('Trace parsing', () => {
|
||||
error: 'No buffer was provided.',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not clobber file metadata with undefined caller properties', async () => {
|
||||
const rawData = loadTraceAsBuffer('web-dev-with-commit.json.gz');
|
||||
// The caller passes explicit undefined for networkThrottling (matching production behavior when conditions are null).
|
||||
const result = await parseRawTraceBuffer(rawData, {
|
||||
cpuThrottling: undefined,
|
||||
networkThrottling: undefined,
|
||||
});
|
||||
if ('error' in result) {
|
||||
assert.fail(`Unexpected parse failure: ${result.error}`);
|
||||
}
|
||||
const summary = getTraceSummary(result);
|
||||
// Verify that throttling from file metadata is preserved rather than reverting to 'none'.
|
||||
assert.ok(summary.includes('CPU throttling: 1x'));
|
||||
assert.ok(summary.includes('Network throttling: No throttling'));
|
||||
});
|
||||
|
||||
it('overrides file metadata when caller specifies defined throttling options', async () => {
|
||||
const rawData = loadTraceAsBuffer('web-dev-with-commit.json.gz');
|
||||
// The caller passes explicit defined overrides.
|
||||
const result = await parseRawTraceBuffer(rawData, {
|
||||
cpuThrottling: 4,
|
||||
networkThrottling: 'Slow 3G',
|
||||
});
|
||||
if ('error' in result) {
|
||||
assert.fail(`Unexpected parse failure: ${result.error}`);
|
||||
}
|
||||
const summary = getTraceSummary(result);
|
||||
// Verify that caller-specified throttling overrides file metadata.
|
||||
assert.ok(summary.includes('CPU throttling: 4x'));
|
||||
assert.ok(summary.includes('Network throttling: Slow 3G'));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user