/** * @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 { 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}, }, }, }; }