574 lines
20 KiB
TypeScript
574 lines
20 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import assert from 'node:assert';
|
|
import {describe, it, afterEach, beforeEach} from 'node:test';
|
|
import zlib from 'node:zlib';
|
|
|
|
import sinon from 'sinon';
|
|
|
|
import {
|
|
analyzeInsight,
|
|
startTrace,
|
|
stopTrace,
|
|
} from '../../src/tools/performance.js';
|
|
import type {TraceResult} from '../../src/trace-processing/parse.js';
|
|
import {
|
|
parseRawTraceBuffer,
|
|
traceResultIsSuccess,
|
|
} from '../../src/trace-processing/parse.js';
|
|
import {loadTraceAsBuffer} from '../trace-processing/fixtures/load.js';
|
|
import {withMcpContext} from '../utils.js';
|
|
|
|
describe('performance', () => {
|
|
afterEach(() => {
|
|
sinon.restore();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
sinon.stub(globalThis, 'fetch').callsFake(async url => {
|
|
const cruxEndpoint =
|
|
'https://chromeuxreport.googleapis.com/v1/records:queryRecord';
|
|
if (url.toString().startsWith(cruxEndpoint)) {
|
|
return new Response(JSON.stringify(cruxResponseFixture()), {
|
|
status: 200,
|
|
headers: {'Content-Type': 'application/json'},
|
|
});
|
|
}
|
|
throw new Error(`Unexpected fetch to ${url}`);
|
|
});
|
|
});
|
|
|
|
describe('performance_start_trace', () => {
|
|
it('starts a trace recording', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
context.setIsRunningPerformanceTrace(false);
|
|
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
|
const startTracingStub = sinon.stub(selectedPage.tracing, 'start');
|
|
await startTrace.handler(
|
|
{
|
|
params: {reload: true, autoStop: false},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
sinon.assert.calledOnce(startTracingStub);
|
|
assert.ok(context.isRunningPerformanceTrace());
|
|
assert.ok(
|
|
response.responseLines
|
|
.join('\n')
|
|
.match(/The performance trace is being recorded/),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('can navigate to about:blank and record a page reload', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
|
sinon.stub(selectedPage, 'url').callsFake(() => 'https://www.test.com');
|
|
const gotoStub = sinon.stub(selectedPage, 'goto');
|
|
const startTracingStub = sinon.stub(selectedPage.tracing, 'start');
|
|
await startTrace.handler(
|
|
{
|
|
params: {reload: true, autoStop: false},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
sinon.assert.calledOnce(startTracingStub);
|
|
sinon.assert.calledWithExactly(gotoStub, 'about:blank', {
|
|
waitUntil: ['networkidle0'],
|
|
});
|
|
sinon.assert.calledWithExactly(gotoStub, 'https://www.test.com', {
|
|
waitUntil: ['load'],
|
|
});
|
|
assert.ok(context.isRunningPerformanceTrace());
|
|
assert.ok(
|
|
response.responseLines
|
|
.join('\n')
|
|
.match(/The performance trace is being recorded/),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('can autostop and store a recording', async () => {
|
|
const rawData = loadTraceAsBuffer('basic-trace.json.gz');
|
|
|
|
await withMcpContext(async (response, context) => {
|
|
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
|
sinon.stub(selectedPage, 'url').callsFake(() => 'https://www.test.com');
|
|
sinon.stub(selectedPage, 'goto').callsFake(() => Promise.resolve(null));
|
|
const startTracingStub = sinon.stub(selectedPage.tracing, 'start');
|
|
const stopTracingStub = sinon
|
|
.stub(selectedPage.tracing, 'stop')
|
|
.callsFake(() => {
|
|
return Promise.resolve(rawData);
|
|
});
|
|
|
|
const clock = sinon.useFakeTimers();
|
|
const handlerPromise = startTrace.handler(
|
|
{
|
|
params: {reload: true, autoStop: true},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
// In the handler we wait 5 seconds after the page load event (which is
|
|
// what DevTools does), hence we now fake-progress time to allow
|
|
// the handler to complete. We allow extra time because the Trace
|
|
// Engine also uses some timers to yield updates and we need those to
|
|
// execute.
|
|
await clock.tickAsync(6_000);
|
|
await handlerPromise;
|
|
clock.restore();
|
|
|
|
sinon.assert.calledOnce(startTracingStub);
|
|
sinon.assert.calledOnce(stopTracingStub);
|
|
assert.strictEqual(
|
|
context.isRunningPerformanceTrace(),
|
|
false,
|
|
'Tracing was stopped',
|
|
);
|
|
assert.strictEqual(context.recordedTraces().length, 1);
|
|
assert.ok(
|
|
response.responseLines
|
|
.join('\n')
|
|
.match(/The performance trace has been stopped/),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('errors if a recording is already active', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
context.setIsRunningPerformanceTrace(true);
|
|
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
|
const startTracingStub = sinon.stub(selectedPage.tracing, 'start');
|
|
await startTrace.handler(
|
|
{
|
|
params: {reload: true, autoStop: false},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
sinon.assert.notCalled(startTracingStub);
|
|
assert.ok(
|
|
response.responseLines
|
|
.join('\n')
|
|
.match(/a performance trace is already running/),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('supports filePath', async () => {
|
|
const rawData = loadTraceAsBuffer('basic-trace.json.gz');
|
|
// rawData is the decompressed buffer (based on loadTraceAsBuffer implementation).
|
|
// We want to simulate saving it as a .gz file, so the tool should compress it.
|
|
const expectedCompressedData = zlib.gzipSync(rawData);
|
|
|
|
await withMcpContext(async (response, context) => {
|
|
const filePath = 'test-trace.json.gz';
|
|
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
|
sinon.stub(selectedPage, 'url').callsFake(() => 'https://www.test.com');
|
|
sinon.stub(selectedPage, 'goto').callsFake(() => Promise.resolve(null));
|
|
sinon.stub(selectedPage.tracing, 'start');
|
|
sinon.stub(selectedPage.tracing, 'stop').resolves(rawData);
|
|
const saveFileStub = sinon
|
|
.stub(context, 'saveFile')
|
|
.resolves({filename: filePath});
|
|
|
|
const handlerPromise = startTrace.handler(
|
|
{
|
|
params: {reload: true, autoStop: true, filePath},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
// In the handler we wait 5 seconds after the page load event (which is
|
|
// what DevTools does), hence we now fake-progress time to allow
|
|
// the handler to complete. We allow extra time because the Trace
|
|
// Engine also uses some timers to yield updates and we need those to
|
|
// execute.
|
|
await handlerPromise;
|
|
|
|
assert.ok(
|
|
response.responseLines.includes(
|
|
`The raw trace data was saved to ${filePath}.`,
|
|
),
|
|
);
|
|
sinon.assert.calledOnce(saveFileStub);
|
|
const [savedData, savedPath] = saveFileStub.firstCall.args;
|
|
assert.strictEqual(savedPath, filePath);
|
|
// Compare the saved data with expected compressed data
|
|
// We can't compare buffers directly with strictEqual easily if they are different instances, but deepStrictEqual works for Buffers.
|
|
assert.deepStrictEqual(savedData, expectedCompressedData);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('performance_analyze_insight', () => {
|
|
async function parseTrace(fileName: string): Promise<TraceResult> {
|
|
const rawData = loadTraceAsBuffer(fileName);
|
|
const result = await parseRawTraceBuffer(rawData);
|
|
if (!traceResultIsSuccess(result)) {
|
|
assert.fail(`Unexpected trace parse error: ${result.error}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
it('returns the information on the insight', async () => {
|
|
const trace = await parseTrace('web-dev-with-commit.json.gz');
|
|
await withMcpContext(async (response, context) => {
|
|
context.storeTraceRecording(trace);
|
|
context.setIsRunningPerformanceTrace(false);
|
|
|
|
await analyzeInsight.handler(
|
|
{
|
|
params: {
|
|
insightSetId: 'NAVIGATION_0',
|
|
insightName: 'LCPBreakdown',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
assert.ok(response.attachedTracedInsight);
|
|
});
|
|
});
|
|
|
|
it('returns an error if no trace has been recorded', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
await analyzeInsight.handler(
|
|
{
|
|
params: {
|
|
insightSetId: '8463DF94CD61B265B664E7F768183DE3',
|
|
insightName: 'LCPBreakdown',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.ok(
|
|
response.responseLines
|
|
.join('\n')
|
|
.match(
|
|
/No recorded traces found. Record a performance trace so you have Insights to analyze./,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('performance_stop_trace', () => {
|
|
it('does nothing if the trace is not running and does not error', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
context.setIsRunningPerformanceTrace(false);
|
|
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
|
const stopTracingStub = sinon.stub(selectedPage.tracing, 'stop');
|
|
await stopTrace.handler(
|
|
{params: {}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
sinon.assert.notCalled(stopTracingStub);
|
|
assert.strictEqual(context.isRunningPerformanceTrace(), false);
|
|
});
|
|
});
|
|
|
|
it('will stop the trace and return trace info when a trace is running', async () => {
|
|
const rawData = loadTraceAsBuffer('basic-trace.json.gz');
|
|
await withMcpContext(async (response, context) => {
|
|
context.setIsRunningPerformanceTrace(true);
|
|
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
|
const stopTracingStub = sinon
|
|
.stub(selectedPage.tracing, 'stop')
|
|
.callsFake(async () => {
|
|
return rawData;
|
|
});
|
|
await stopTrace.handler(
|
|
{params: {}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
assert.ok(
|
|
response.responseLines.includes(
|
|
'The performance trace has been stopped.',
|
|
),
|
|
);
|
|
assert.strictEqual(context.recordedTraces().length, 1);
|
|
sinon.assert.calledOnce(stopTracingStub);
|
|
});
|
|
});
|
|
|
|
it('throws an error if parsing the trace buffer fails', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
context.setIsRunningPerformanceTrace(true);
|
|
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
|
sinon
|
|
.stub(selectedPage.tracing, 'stop')
|
|
.returns(Promise.resolve(undefined));
|
|
|
|
await assert.rejects(
|
|
stopTrace.handler(
|
|
{params: {}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
),
|
|
/There was an unexpected error parsing the trace/,
|
|
);
|
|
});
|
|
});
|
|
|
|
it('supports filePath', async () => {
|
|
const rawData = loadTraceAsBuffer('basic-trace.json.gz');
|
|
await withMcpContext(async (response, context) => {
|
|
const filePath = 'test-trace.json';
|
|
context.setIsRunningPerformanceTrace(true);
|
|
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
|
const stopTracingStub = sinon
|
|
.stub(selectedPage.tracing, 'stop')
|
|
.resolves(rawData);
|
|
const saveFileStub = sinon
|
|
.stub(context, 'saveFile')
|
|
.resolves({filename: filePath});
|
|
|
|
await stopTrace.handler(
|
|
{params: {filePath}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
sinon.assert.calledOnce(stopTracingStub);
|
|
sinon.assert.calledOnce(saveFileStub);
|
|
sinon.assert.calledWith(saveFileStub, rawData, filePath);
|
|
assert.ok(
|
|
response.responseLines.includes(
|
|
`The raw trace data was saved to ${filePath}.`,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('does not fetch CrUX data if performanceCrux is false', async () => {
|
|
const rawData = loadTraceAsBuffer('basic-trace.json.gz');
|
|
await withMcpContext(
|
|
async (response, context) => {
|
|
context.setIsRunningPerformanceTrace(true);
|
|
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
|
sinon.stub(selectedPage.tracing, 'stop').resolves(rawData);
|
|
|
|
await stopTrace.handler(
|
|
{params: {}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
const cruxEndpoint =
|
|
'https://chromeuxreport.googleapis.com/v1/records:queryRecord';
|
|
const cruxCall = (globalThis.fetch as sinon.SinonStub)
|
|
.getCalls()
|
|
.find(call => call.args[0].toString().startsWith(cruxEndpoint));
|
|
assert.strictEqual(
|
|
cruxCall,
|
|
undefined,
|
|
'CrUX fetch should not have been called',
|
|
);
|
|
},
|
|
{performanceCrux: false},
|
|
);
|
|
});
|
|
|
|
it('fetches CrUX data for desktop and includes it in the summary', async () => {
|
|
const rawData = loadTraceAsBuffer('web-dev-with-commit.json.gz');
|
|
await withMcpContext(async (response, context) => {
|
|
context.setIsRunningPerformanceTrace(true);
|
|
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
|
sinon.stub(selectedPage.tracing, 'stop').resolves(rawData);
|
|
|
|
const fetchStub = globalThis.fetch as sinon.SinonStub;
|
|
fetchStub.resetHistory();
|
|
fetchStub.callsFake(async (url, options) => {
|
|
const body = options?.body ? JSON.parse(options.body as string) : {};
|
|
const requestedUrl = body.url || body.origin || 'https://web.dev/';
|
|
const lcp = body.formFactor === 'DESKTOP' ? 1000 : 2595;
|
|
return new Response(
|
|
JSON.stringify(cruxResponseFixture(requestedUrl, lcp)),
|
|
{
|
|
status: 200,
|
|
headers: {'Content-Type': 'application/json'},
|
|
},
|
|
);
|
|
});
|
|
|
|
await stopTrace.handler(
|
|
{params: {}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
const result = await response.handle('performance_stop_trace', context);
|
|
const fullOutput = result.content
|
|
.map(c => (c.type === 'text' ? c.text : ''))
|
|
.join('\n');
|
|
|
|
assert.ok(fetchStub.called, 'CrUX fetch should have been called');
|
|
assert.ok(
|
|
fullOutput.includes('Metrics (field / real users)'),
|
|
'Summary should include field data',
|
|
);
|
|
assert.ok(
|
|
fullOutput.includes('LCP: 1000 ms'),
|
|
'Summary should include desktop LCP value',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('fetches CrUX data for mobile and includes it in the summary', async () => {
|
|
const rawData = loadTraceAsBuffer('web-dev-with-commit.json.gz');
|
|
// Use a unique URL to avoid cache issues
|
|
const jsonString = new TextDecoder().decode(rawData);
|
|
const modifiedJsonString = jsonString.replaceAll(
|
|
'https://web.dev/',
|
|
'https://mobile.web.dev/',
|
|
);
|
|
const modifiedData = new TextEncoder().encode(modifiedJsonString);
|
|
|
|
await withMcpContext(async (response, context) => {
|
|
context.setIsRunningPerformanceTrace(true);
|
|
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
|
sinon.stub(selectedPage.tracing, 'stop').resolves(modifiedData);
|
|
|
|
// Emulate mobile
|
|
await context.getSelectedMcpPage().emulate({
|
|
viewport: {
|
|
width: 375,
|
|
height: 667,
|
|
isMobile: true,
|
|
hasTouch: true,
|
|
deviceScaleFactor: 2,
|
|
},
|
|
});
|
|
|
|
const fetchStub = globalThis.fetch as sinon.SinonStub;
|
|
fetchStub.resetHistory();
|
|
fetchStub.callsFake(async (url, options) => {
|
|
const body = options?.body ? JSON.parse(options.body as string) : {};
|
|
const requestedUrl = body.url || body.origin || 'https://web.dev/';
|
|
const lcp = body.formFactor === 'PHONE' ? 2000 : 2595;
|
|
return new Response(
|
|
JSON.stringify(cruxResponseFixture(requestedUrl, lcp)),
|
|
{
|
|
status: 200,
|
|
headers: {'Content-Type': 'application/json'},
|
|
},
|
|
);
|
|
});
|
|
|
|
await stopTrace.handler(
|
|
{params: {}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
const result = await response.handle('performance_stop_trace', context);
|
|
const fullOutput = result.content
|
|
.map(c => (c.type === 'text' ? c.text : ''))
|
|
.join('\n');
|
|
|
|
assert.ok(fetchStub.called, 'CrUX fetch should have been called');
|
|
assert.ok(
|
|
fullOutput.includes('Metrics (field / real users)'),
|
|
'Summary should include field data',
|
|
);
|
|
assert.ok(
|
|
fullOutput.includes('LCP: 2000 ms'),
|
|
'Summary should include mobile LCP value',
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
function cruxResponseFixture(url = 'https://web.dev/', lcp = 2595) {
|
|
// Ideally we could use `mockResponse` from 'chrome-devtools-frontend/front_end/models/crux-manager/CrUXManager.test.ts'
|
|
// But test files are not published in the cdtf npm package.
|
|
return {
|
|
record: {
|
|
key: {
|
|
url,
|
|
},
|
|
metrics: {
|
|
form_factors: {
|
|
fractions: {desktop: 0.5056, phone: 0.4796, tablet: 0.0148},
|
|
},
|
|
largest_contentful_paint: {
|
|
histogram: [
|
|
{start: 0, end: 2500, density: 0.7309},
|
|
{start: 2500, end: 4000, density: 0.163},
|
|
{start: 4000, density: 0.1061},
|
|
],
|
|
percentiles: {p75: lcp},
|
|
},
|
|
largest_contentful_paint_image_element_render_delay: {
|
|
percentiles: {p75: 786},
|
|
},
|
|
largest_contentful_paint_image_resource_load_delay: {
|
|
percentiles: {p75: 86},
|
|
},
|
|
largest_contentful_paint_image_time_to_first_byte: {
|
|
percentiles: {p75: 1273},
|
|
},
|
|
cumulative_layout_shift: {
|
|
histogram: [
|
|
{start: '0.00', end: '0.10', density: 0.8665},
|
|
{start: '0.10', end: '0.25', density: 0.0716},
|
|
{start: '0.25', density: 0.0619},
|
|
],
|
|
percentiles: {p75: '0.06'},
|
|
},
|
|
interaction_to_next_paint: {
|
|
histogram: [
|
|
{start: 0, end: 200, density: 0.8414},
|
|
{start: 200, end: 500, density: 0.1081},
|
|
{start: 500, density: 0.0505},
|
|
],
|
|
percentiles: {p75: 140},
|
|
},
|
|
largest_contentful_paint_image_resource_load_duration: {
|
|
percentiles: {p75: 451},
|
|
},
|
|
round_trip_time: {
|
|
histogram: [
|
|
{start: 0, end: 75, density: 0.3663},
|
|
{start: 75, end: 275, density: 0.5089},
|
|
{start: 275, density: 0.1248},
|
|
],
|
|
percentiles: {p75: 178},
|
|
},
|
|
first_contentful_paint: {
|
|
histogram: [
|
|
{start: 0, end: 1800, density: 0.5899},
|
|
{start: 1800, end: 3000, density: 0.2439},
|
|
{start: 3000, density: 0.1662},
|
|
],
|
|
percentiles: {p75: 2425},
|
|
},
|
|
},
|
|
collectionPeriod: {
|
|
firstDate: {year: 2025, month: 12, day: 8},
|
|
lastDate: {year: 2026, month: 1, day: 4},
|
|
},
|
|
},
|
|
};
|
|
}
|