From 6caed10fa4d1cb49deb3dde5b558ae8a36a0da25 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Mon, 27 Oct 2025 13:46:48 -0700 Subject: [PATCH 1/8] initial Change-Id: I8a927a6b4d41ade5bd6b77996f27a4b1850f4316 --- src/tools/performance.ts | 73 +++++++++++++++++++++ tests/tools/performance.test.ts | 110 ++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+) diff --git a/src/tools/performance.ts b/src/tools/performance.ts index 420a62132..f2a0f53df 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -185,3 +185,76 @@ async function stopTracingAndAppendOutput( context.setIsRunningPerformanceTrace(false); } } + +const CRUX_API_KEY = 'AIzaSyCCSOx25vrb5z0tbedCB3_JRzzbVW6Uwgw'; +const CRUX_ENDPOINT = `https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=${CRUX_API_KEY}`; + +export const queryChromeUXReport = defineTool({ + name: 'performance_query_chrome_ux_report', + description: + 'Queries the Chrome UX Report (CrUX) API to get real-user experience metrics (like Core Web Vitals) for a given URL or origin. You must provide EITHER "origin" OR "url", but not both. You can optionally filter by "formFactor".', + annotations: { + category: ToolCategory.PERFORMANCE, + readOnlyHint: true, + }, + schema: { + origin: zod + .string() + .describe( + 'The origin to query, e.g., "https://www.google.com". Do not provide this if "url" is specified.', + ) + .optional(), + url: zod + .string() + .describe( + 'The specific page URL to query, e.g., "https://www.google.com/search?q=puppies". Do not provide this if "origin" is specified.', + ) + .optional(), + formFactor: zod + .enum(['DESKTOP', 'PHONE', 'TABLET']) + .describe( + 'The form factor to filter by. If omitted, data for all form factors is aggregated.', + ) + .optional(), + }, + handler: async (request, response) => { + const {origin, url, formFactor} = request.params; + + if ((!origin && !url) || (origin && url)) { + response.appendResponseLine( + 'Error: you must provide either "origin" or "url", but not both.', + ); + return; + } + + const body = JSON.stringify({ + origin, + url, + formFactor, + metrics: [ + 'first_contentful_paint', + 'largest_contentful_paint', + 'cumulative_layout_shift', + 'interaction_to_next_paint', + ], + }); + + try { + const cruxResponse = await fetch(CRUX_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body, + }); + + const data = await cruxResponse.json(); + response.appendResponseLine(JSON.stringify(data, null, 2)); + } catch (e) { + const errorText = e instanceof Error ? e.message : JSON.stringify(e); + logger(`Error fetching CrUX data: ${errorText}`); + response.appendResponseLine('An error occurred fetching CrUX data:'); + response.appendResponseLine(errorText); + } + }, +}); diff --git a/tests/tools/performance.test.ts b/tests/tools/performance.test.ts index b8ac55338..60facb6a0 100644 --- a/tests/tools/performance.test.ts +++ b/tests/tools/performance.test.ts @@ -10,6 +10,7 @@ import sinon from 'sinon'; import { analyzeInsight, + queryChromeUXReport, startTrace, stopTrace, } from '../../src/tools/performance.js'; @@ -272,4 +273,113 @@ describe('performance', () => { }); }); }); + + describe('performance_query_chrome_ux_report', () => { + it('successfully queries with origin', async () => { + const mockResponse = {record: {key: {origin: 'https://example.com'}}}; + const fetchStub = sinon.stub(global, 'fetch').resolves( + new Response(JSON.stringify(mockResponse), { + status: 200, + headers: {'Content-Type': 'application/json'}, + }), + ); + + await withBrowser(async response => { + await queryChromeUXReport.handler( + {params: {origin: 'https://example.com'}}, + response, + {} as any, + ); + + assert.ok(fetchStub.calledOnce); + assert.strictEqual( + response.responseLines[0], + JSON.stringify(mockResponse, null, 2), + ); + }); + }); + + it('successfully queries with url', async () => { + const mockResponse = {record: {key: {url: 'https://example.com'}}}; + const fetchStub = sinon.stub(global, 'fetch').resolves( + new Response(JSON.stringify(mockResponse), { + status: 200, + headers: {'Content-Type': 'application/json'}, + }), + ); + + await withBrowser(async response => { + await queryChromeUXReport.handler( + {params: {url: 'https://example.com'}}, + response, + {} as any, + ); + + assert.ok(fetchStub.calledOnce); + assert.strictEqual( + response.responseLines[0], + JSON.stringify(mockResponse, null, 2), + ); + }); + }); + + it('errors if both origin and url are provided', async () => { + await withBrowser(async response => { + await queryChromeUXReport.handler( + { + params: { + origin: 'https://example.com', + url: 'https://example.com', + }, + }, + response, + {} as any, + ); + + assert.ok( + response.responseLines[0]?.includes( + 'Error: you must provide either "origin" or "url", but not both.', + ), + ); + }); + }); + + it('errors if neither origin nor url are provided', async () => { + await withBrowser(async response => { + await queryChromeUXReport.handler( + {params: {}}, + response, + {} as any, + ); + + assert.ok( + response.responseLines[0]?.includes( + 'Error: you must provide either "origin" or "url", but not both.', + ), + ); + }); + }); + + it('handles fetch API error', async () => { + const fetchStub = sinon + .stub(global, 'fetch') + .rejects(new Error('API is down')); + + await withBrowser(async response => { + await queryChromeUXReport.handler( + {params: {origin: 'https://example.com'}}, + response, + {} as any, + ); + + assert.ok(fetchStub.calledOnce); + assert.ok( + response.responseLines[0]?.includes( + 'An error occurred fetching CrUX data:', + ), + ); + assert.strictEqual(response.responseLines[1], 'API is down'); + }); + }); + }); }); From bbbb453120c38ad3d249faaaf15610a940329bad Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Mon, 27 Oct 2025 13:51:51 -0700 Subject: [PATCH 2/8] docs Change-Id: I00e21d0f39adc99a6a9754dca73ab8f4c1d421ea --- README.md | 3 ++- docs/tool-reference.md | 20 +++++++++++++++++--- tests/tools/performance.test.ts | 1 + 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 78c37c182..a339cb62b 100644 --- a/README.md +++ b/README.md @@ -258,8 +258,9 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles - [`emulate_cpu`](docs/tool-reference.md#emulate_cpu) - [`emulate_network`](docs/tool-reference.md#emulate_network) - [`resize_page`](docs/tool-reference.md#resize_page) -- **Performance** (3 tools) +- **Performance** (4 tools) - [`performance_analyze_insight`](docs/tool-reference.md#performance_analyze_insight) + - [`performance_query_chrome_ux_report`](docs/tool-reference.md#performance_query_chrome_ux_report) - [`performance_start_trace`](docs/tool-reference.md#performance_start_trace) - [`performance_stop_trace`](docs/tool-reference.md#performance_stop_trace) - **Network** (2 tools) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index b9bc6d286..1884e74a1 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -22,8 +22,9 @@ - [`emulate_cpu`](#emulate_cpu) - [`emulate_network`](#emulate_network) - [`resize_page`](#resize_page) -- **[Performance](#performance)** (3 tools) +- **[Performance](#performance)** (4 tools) - [`performance_analyze_insight`](#performance_analyze_insight) + - [`performance_query_chrome_ux_report`](#performance_query_chrome_ux_report) - [`performance_start_trace`](#performance_start_trace) - [`performance_stop_trace`](#performance_stop_trace) - **[Network](#network)** (2 tools) @@ -232,6 +233,18 @@ --- +### `performance_query_chrome_ux_report` + +**Description:** Queries the Chrome UX Report (CrUX) API to get real-user experience metrics (like Core Web Vitals) for a given URL or origin. You must provide EITHER "origin" OR "url", but not both. You can optionally filter by "formFactor". + +**Parameters:** + +- **formFactor** (enum: "DESKTOP", "PHONE", "TABLET") _(optional)_: The form factor to filter by. If omitted, data for all form factors is aggregated. +- **origin** (string) _(optional)_: The origin to query, e.g., "https://www.google.com". Do not provide this if "url" is specified. +- **url** (string) _(optional)_: The specific page URL to query, e.g., "https://www.google.com/search?q=puppies". Do not provide this if "origin" is specified. + +--- + ### `performance_start_trace` **Description:** Starts a performance trace recording on the selected page. This can be used to look for performance problems and insights to improve the performance of the page. It will also report Core Web Vital (CWV) scores for the page. @@ -288,15 +301,16 @@ so returned values have to JSON-serializable. - **args** (array) _(optional)_: An optional list of arguments to pass to the function. - **function** (string) **(required)**: A JavaScript function declaration to be executed by the tool in the currently selected page. - Example without arguments: `() => { +Example without arguments: `() => { return document.title }` or `async () => { return await fetch("example.com") }`. - Example with arguments: `(el) => { +Example with arguments: `(el) => { return el.innerText; }` + --- ### `get_console_message` diff --git a/tests/tools/performance.test.ts b/tests/tools/performance.test.ts index 60facb6a0..aec1be4ec 100644 --- a/tests/tools/performance.test.ts +++ b/tests/tools/performance.test.ts @@ -8,6 +8,7 @@ import {describe, it, afterEach} from 'node:test'; import sinon from 'sinon'; +import type {Context} from '../../src/tools/ToolDefinition.js'; import { analyzeInsight, queryChromeUXReport, From 8647b4601fc9befe23a8da71aabe5c6c36581421 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Mon, 27 Oct 2025 13:56:19 -0700 Subject: [PATCH 3/8] types Change-Id: I8aacc3dd26acedfd27053802413ab806c6f05bf7 --- docs/tool-reference.md | 5 ++--- src/tools/performance.ts | 2 ++ tests/tools/performance.test.ts | 12 ++++++------ 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 1884e74a1..aa76790fb 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -301,16 +301,15 @@ so returned values have to JSON-serializable. - **args** (array) _(optional)_: An optional list of arguments to pass to the function. - **function** (string) **(required)**: A JavaScript function declaration to be executed by the tool in the currently selected page. -Example without arguments: `() => { + Example without arguments: `() => { return document.title }` or `async () => { return await fetch("example.com") }`. -Example with arguments: `(el) => { + Example with arguments: `(el) => { return el.innerText; }` - --- ### `get_console_message` diff --git a/src/tools/performance.ts b/src/tools/performance.ts index f2a0f53df..da50609bf 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -186,6 +186,8 @@ async function stopTracingAndAppendOutput( } } +// This key is expected to be visible. +// b/349721878 const CRUX_API_KEY = 'AIzaSyCCSOx25vrb5z0tbedCB3_JRzzbVW6Uwgw'; const CRUX_ENDPOINT = `https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=${CRUX_API_KEY}`; diff --git a/tests/tools/performance.test.ts b/tests/tools/performance.test.ts index aec1be4ec..c2a125f68 100644 --- a/tests/tools/performance.test.ts +++ b/tests/tools/performance.test.ts @@ -8,13 +8,13 @@ import {describe, it, afterEach} from 'node:test'; import sinon from 'sinon'; -import type {Context} from '../../src/tools/ToolDefinition.js'; import { analyzeInsight, queryChromeUXReport, startTrace, stopTrace, } from '../../src/tools/performance.js'; +import type {Context} from '../../src/tools/ToolDefinition.js'; import type {TraceResult} from '../../src/trace-processing/parse.js'; import { parseRawTraceBuffer, @@ -289,7 +289,7 @@ describe('performance', () => { await queryChromeUXReport.handler( {params: {origin: 'https://example.com'}}, response, - {} as any, + {} as Context, ); assert.ok(fetchStub.calledOnce); @@ -313,7 +313,7 @@ describe('performance', () => { await queryChromeUXReport.handler( {params: {url: 'https://example.com'}}, response, - {} as any, + {} as Context, ); assert.ok(fetchStub.calledOnce); @@ -334,7 +334,7 @@ describe('performance', () => { }, }, response, - {} as any, + {} as Context, ); assert.ok( @@ -350,7 +350,7 @@ describe('performance', () => { await queryChromeUXReport.handler( {params: {}}, response, - {} as any, + {} as Context, ); assert.ok( @@ -370,7 +370,7 @@ describe('performance', () => { await queryChromeUXReport.handler( {params: {origin: 'https://example.com'}}, response, - {} as any, + {} as Context, ); assert.ok(fetchStub.calledOnce); From 27fb8137f8d0f87e4c39841bf709f6f853af2ad0 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Mon, 27 Oct 2025 14:17:32 -0700 Subject: [PATCH 4/8] trying to mock out page/browser but not really worth it Change-Id: I704225b848c3e039ab8e7831ef1a45453ee83b65 --- package.json | 2 +- tests/tools/performance.test.ts | 584 ++++++++++++++------------------ tests/utils.ts | 22 +- 3 files changed, 258 insertions(+), 350 deletions(-) diff --git a/package.json b/package.json index 9689137aa..f5cb82555 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "start": "npm run build && node build/src/index.js", "start-debug": "DEBUG=mcp:* DEBUG_COLORS=false npm run build && node build/src/index.js", "test:node20": "node --require ./build/tests/setup.js --test-reporter spec --test-force-exit --test build/tests", - "test": "npm run build && node --require ./build/tests/setup.js --no-warnings=ExperimentalWarning --test-reporter spec --test-force-exit --test \"build/tests/**/*.test.js\"", + "test": "npm run build && node --require ./build/tests/setup.js --no-warnings=ExperimentalWarning --test-reporter spec --test-force-exit --test \"build/tests/**/performance.test.js\"", "test:only": "npm run build && node --require ./build/tests/setup.js --no-warnings=ExperimentalWarning --test-reporter spec --test-force-exit --test --test-only \"build/tests/**/*.test.js\"", "test:only:no-build": "node --require ./build/tests/setup.js --no-warnings=ExperimentalWarning --test-reporter spec --test-force-exit --test --test-only \"build/tests/**/*.test.js\"", "test:update-snapshots": "npm run build && node --require ./build/tests/setup.js --no-warnings=ExperimentalWarning --test-force-exit --test --test-update-snapshots \"build/tests/**/*.test.js\"", diff --git a/tests/tools/performance.test.ts b/tests/tools/performance.test.ts index c2a125f68..2ae4eddff 100644 --- a/tests/tools/performance.test.ts +++ b/tests/tools/performance.test.ts @@ -7,273 +7,225 @@ import assert from 'node:assert'; import {describe, it, afterEach} from 'node:test'; import sinon from 'sinon'; - -import { - analyzeInsight, - queryChromeUXReport, - startTrace, - stopTrace, -} from '../../src/tools/performance.js'; +import logger from 'debug'; +import puppeteer, {Locator} from 'puppeteer'; +import {analyzeInsight, queryChromeUXReport, startTrace, stopTrace} from '../../src/tools/performance.js'; import type {Context} from '../../src/tools/ToolDefinition.js'; import type {TraceResult} from '../../src/trace-processing/parse.js'; -import { - parseRawTraceBuffer, - traceResultIsSuccess, -} from '../../src/trace-processing/parse.js'; +import {parseRawTraceBuffer, traceResultIsSuccess} from '../../src/trace-processing/parse.js'; import {loadTraceAsBuffer} from '../trace-processing/fixtures/load.js'; import {withBrowser} from '../utils.js'; +import {McpResponse} from '../../src/McpResponse.js'; +import {McpContext} from '../../src/McpContext.js'; + +function getBrowserlessContext(): Promise { + const page = { + isClosed: () => false, + url: () => '', + on: () => {}, + off: () => {}, + setDefaultTimeout: () => {}, + setDefaultNavigationTimeout: () => {}, + } as unknown as import('puppeteer').Page; + const browser = {pages: async () => [page]} as unknown as import('puppeteer').Browser; + return McpContext.from(browser, logger('test'), {experimentalDevToolsDebugging: false}, Locator); +} describe('performance', () => { afterEach(() => { sinon.restore(); }); - describe('performance_start_trace', () => { - it('starts a trace recording', async () => { - await withBrowser(async (response, context) => { - context.setIsRunningPerformanceTrace(false); - const selectedPage = context.getSelectedPage(); - const startTracingStub = sinon.stub(selectedPage.tracing, 'start'); - await startTrace.handler( - {params: {reload: true, autoStop: false}}, - 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 withBrowser(async (response, context) => { - const selectedPage = context.getSelectedPage(); - 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}}, - 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 withBrowser(async (response, context) => { - const selectedPage = context.getSelectedPage(); - 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}}, - 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 withBrowser(async (response, context) => { - context.setIsRunningPerformanceTrace(true); - const selectedPage = context.getSelectedPage(); - const startTracingStub = sinon.stub(selectedPage.tracing, 'start'); - await startTrace.handler( - {params: {reload: true, autoStop: false}}, - response, - context, - ); - sinon.assert.notCalled(startTracingStub); - assert.ok( - response.responseLines - .join('\n') - .match(/a performance trace is already running/), - ); - }); - }); - }); - - 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 t => { - const trace = await parseTrace('web-dev-with-commit.json.gz'); - await withBrowser(async (response, context) => { - context.storeTraceRecording(trace); - context.setIsRunningPerformanceTrace(false); - - await analyzeInsight.handler( - { - params: { - insightName: 'LCPBreakdown', - }, - }, - response, - context, - ); - - t.assert.snapshot?.(response.responseLines.join('\n')); - }); - }); - - it('returns an error if the insight does not exist', async () => { - const trace = await parseTrace('web-dev-with-commit.json.gz'); - await withBrowser(async (response, context) => { - context.storeTraceRecording(trace); - context.setIsRunningPerformanceTrace(false); - - await analyzeInsight.handler( - { - params: { - insightName: 'MadeUpInsightName', - }, - }, - response, - context, - ); - assert.ok( - response.responseLines - .join('\n') - .match(/No Insight with the name MadeUpInsightName found./), - ); - }); - }); - - it('returns an error if no trace has been recorded', async () => { - await withBrowser(async (response, context) => { - await analyzeInsight.handler( - { - params: { - insightName: 'LCPBreakdown', - }, - }, - 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 withBrowser(async (response, context) => { - context.setIsRunningPerformanceTrace(false); - const selectedPage = context.getSelectedPage(); - const stopTracingStub = sinon.stub(selectedPage.tracing, 'stop'); - await stopTrace.handler({params: {}}, 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 withBrowser(async (response, context) => { - context.setIsRunningPerformanceTrace(true); - const selectedPage = context.getSelectedPage(); - const stopTracingStub = sinon - .stub(selectedPage.tracing, 'stop') - .callsFake(async () => { - return rawData; - }); - await stopTrace.handler({params: {}}, response, context); - assert.ok( - response.responseLines.includes( - 'The performance trace has been stopped.', - ), - ); - assert.strictEqual(context.recordedTraces().length, 1); - sinon.assert.calledOnce(stopTracingStub); - }); - }); - - it('returns an error message if parsing the trace buffer fails', async t => { - await withBrowser(async (response, context) => { - context.setIsRunningPerformanceTrace(true); - const selectedPage = context.getSelectedPage(); - sinon - .stub(selectedPage.tracing, 'stop') - .returns(Promise.resolve(undefined)); - await stopTrace.handler({params: {}}, response, context); - t.assert.snapshot?.(response.responseLines.join('\n')); - }); - }); - - it('returns the high level summary of the performance trace', async t => { - const rawData = loadTraceAsBuffer('web-dev-with-commit.json.gz'); - await withBrowser(async (response, context) => { - context.setIsRunningPerformanceTrace(true); - const selectedPage = context.getSelectedPage(); - sinon.stub(selectedPage.tracing, 'stop').callsFake(async () => { - return rawData; - }); - await stopTrace.handler({params: {}}, response, context); - t.assert.snapshot?.(response.responseLines.join('\n')); - }); - }); - }); + // describe('performance_start_trace', () => { + // it('starts a trace recording', async () => { + // await withBrowser(async (response, context) => { + // context.setIsRunningPerformanceTrace(false); + // const selectedPage = context.getSelectedPage(); + // const startTracingStub = sinon.stub(selectedPage.tracing, 'start'); + // await startTrace.handler({params: {reload: true, autoStop: false}}, 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 withBrowser(async (response, context) => { + // const selectedPage = context.getSelectedPage(); + // 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}}, 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 withBrowser(async (response, context) => { + // const selectedPage = context.getSelectedPage(); + // 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}}, 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 withBrowser(async (response, context) => { + // context.setIsRunningPerformanceTrace(true); + // const selectedPage = context.getSelectedPage(); + // const startTracingStub = sinon.stub(selectedPage.tracing, 'start'); + // await startTrace.handler({params: {reload: true, autoStop: false}}, response, context); + // sinon.assert.notCalled(startTracingStub); + // assert.ok(response.responseLines.join('\n').match(/a performance trace is already running/)); + // }); + // }); + // }); + + // 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 t => { + // const trace = await parseTrace('web-dev-with-commit.json.gz'); + // await withBrowser(async (response, context) => { + // context.storeTraceRecording(trace); + // context.setIsRunningPerformanceTrace(false); + + // await analyzeInsight.handler( + // { + // params: { + // insightName: 'LCPBreakdown', + // }, + // }, + // response, + // context + // ); + + // t.assert.snapshot?.(response.responseLines.join('\n')); + // }); + // }); + + // it('returns an error if the insight does not exist', async () => { + // const trace = await parseTrace('web-dev-with-commit.json.gz'); + // await withBrowser(async (response, context) => { + // context.storeTraceRecording(trace); + // context.setIsRunningPerformanceTrace(false); + + // await analyzeInsight.handler( + // { + // params: { + // insightName: 'MadeUpInsightName', + // }, + // }, + // response, + // context + // ); + // assert.ok(response.responseLines.join('\n').match(/No Insight with the name MadeUpInsightName found./)); + // }); + // }); + + // it('returns an error if no trace has been recorded', async () => { + // await withBrowser(async (response, context) => { + // await analyzeInsight.handler( + // { + // params: { + // insightName: 'LCPBreakdown', + // }, + // }, + // 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 withBrowser(async (response, context) => { + // context.setIsRunningPerformanceTrace(false); + // const selectedPage = context.getSelectedPage(); + // const stopTracingStub = sinon.stub(selectedPage.tracing, 'stop'); + // await stopTrace.handler({params: {}}, 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 withBrowser(async (response, context) => { + // context.setIsRunningPerformanceTrace(true); + // const selectedPage = context.getSelectedPage(); + // const stopTracingStub = sinon.stub(selectedPage.tracing, 'stop').callsFake(async () => { + // return rawData; + // }); + // await stopTrace.handler({params: {}}, response, context); + // assert.ok(response.responseLines.includes('The performance trace has been stopped.')); + // assert.strictEqual(context.recordedTraces().length, 1); + // sinon.assert.calledOnce(stopTracingStub); + // }); + // }); + + // it('returns an error message if parsing the trace buffer fails', async t => { + // await withBrowser(async (response, context) => { + // context.setIsRunningPerformanceTrace(true); + // const selectedPage = context.getSelectedPage(); + // sinon.stub(selectedPage.tracing, 'stop').returns(Promise.resolve(undefined)); + // await stopTrace.handler({params: {}}, response, context); + // t.assert.snapshot?.(response.responseLines.join('\n')); + // }); + // }); + + // it('returns the high level summary of the performance trace', async t => { + // const rawData = loadTraceAsBuffer('web-dev-with-commit.json.gz'); + // await withBrowser(async (response, context) => { + // context.setIsRunningPerformanceTrace(true); + // const selectedPage = context.getSelectedPage(); + // sinon.stub(selectedPage.tracing, 'stop').callsFake(async () => { + // return rawData; + // }); + // await stopTrace.handler({params: {}}, response, context); + // t.assert.snapshot?.(response.responseLines.join('\n')); + // }); + // }); + // }); describe('performance_query_chrome_ux_report', () => { it('successfully queries with origin', async () => { @@ -282,22 +234,15 @@ describe('performance', () => { new Response(JSON.stringify(mockResponse), { status: 200, headers: {'Content-Type': 'application/json'}, - }), + }) ); - await withBrowser(async response => { - await queryChromeUXReport.handler( - {params: {origin: 'https://example.com'}}, - response, - {} as Context, - ); - - assert.ok(fetchStub.calledOnce); - assert.strictEqual( - response.responseLines[0], - JSON.stringify(mockResponse, null, 2), - ); - }); + const response = new McpResponse(); + const context = await getBrowserlessContext(); + await queryChromeUXReport.handler({params: {origin: 'https://example.com'}}, response, context); + + assert.ok(fetchStub.calledOnce); + assert.strictEqual(response.responseLines[0], JSON.stringify(mockResponse, null, 2)); }); it('successfully queries with url', async () => { @@ -306,81 +251,52 @@ describe('performance', () => { new Response(JSON.stringify(mockResponse), { status: 200, headers: {'Content-Type': 'application/json'}, - }), + }) ); - await withBrowser(async response => { - await queryChromeUXReport.handler( - {params: {url: 'https://example.com'}}, - response, - {} as Context, - ); - - assert.ok(fetchStub.calledOnce); - assert.strictEqual( - response.responseLines[0], - JSON.stringify(mockResponse, null, 2), - ); - }); + const response = new McpResponse(); + const context = await getBrowserlessContext(); + await queryChromeUXReport.handler({params: {url: 'https://example.com'}}, response, context); + + assert.ok(fetchStub.calledOnce); + assert.strictEqual(response.responseLines[0], JSON.stringify(mockResponse, null, 2)); }); it('errors if both origin and url are provided', async () => { - await withBrowser(async response => { - await queryChromeUXReport.handler( - { - params: { - origin: 'https://example.com', - url: 'https://example.com', - }, + const response = new McpResponse(); + const context = await getBrowserlessContext(); + await queryChromeUXReport.handler( + { + params: { + origin: 'https://example.com', + url: 'https://example.com', }, - response, - {} as Context, - ); - - assert.ok( - response.responseLines[0]?.includes( - 'Error: you must provide either "origin" or "url", but not both.', - ), - ); - }); + }, + response, + context + ); + + assert.ok(response.responseLines[0]?.includes('Error: you must provide either "origin" or "url", but not both.')); }); it('errors if neither origin nor url are provided', async () => { - await withBrowser(async response => { - await queryChromeUXReport.handler( - {params: {}}, - response, - {} as Context, - ); - - assert.ok( - response.responseLines[0]?.includes( - 'Error: you must provide either "origin" or "url", but not both.', - ), - ); - }); + const response = new McpResponse(); + const context = await getBrowserlessContext(); + await queryChromeUXReport.handler({params: {}}, response, context); + + assert.ok(response.responseLines[0]?.includes('Error: you must provide either "origin" or "url", but not both.')); }); it('handles fetch API error', async () => { - const fetchStub = sinon - .stub(global, 'fetch') - .rejects(new Error('API is down')); - - await withBrowser(async response => { - await queryChromeUXReport.handler( - {params: {origin: 'https://example.com'}}, - response, - {} as Context, - ); - - assert.ok(fetchStub.calledOnce); - assert.ok( - response.responseLines[0]?.includes( - 'An error occurred fetching CrUX data:', - ), - ); - assert.strictEqual(response.responseLines[1], 'API is down'); - }); + const fetchStub = sinon.stub(global, 'fetch').rejects(new Error('API is down')); + + const response = new McpResponse(); + const context = await getBrowserlessContext(); + await queryChromeUXReport.handler({params: {origin: 'https://example.com'}}, response, context); + + assert.ok(fetchStub.calledOnce); + assert.ok(response.responseLines[0]?.includes('An error occurred fetching CrUX data:')); + assert.strictEqual(response.responseLines[1], 'API is down'); }); }); }); diff --git a/tests/utils.ts b/tests/utils.ts index 6dca14a71..2bb8ce085 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -6,12 +6,7 @@ import logger from 'debug'; import type {Browser} from 'puppeteer'; import puppeteer, {Locator} from 'puppeteer'; -import type { - Frame, - HTTPRequest, - HTTPResponse, - LaunchOptions, -} from 'puppeteer-core'; +import type {Frame, HTTPRequest, HTTPResponse, LaunchOptions} from 'puppeteer-core'; import {McpContext} from '../src/McpContext.js'; import {McpResponse} from '../src/McpResponse.js'; @@ -21,7 +16,7 @@ const browsers = new Map(); export async function withBrowser( cb: (response: McpResponse, context: McpContext) => Promise, - options: {debug?: boolean; autoOpenDevTools?: boolean} = {}, + options: {debug?: boolean; autoOpenDevTools?: boolean} = {} ) { const launchOptions: LaunchOptions = { executablePath: process.env.PUPPETEER_EXECUTABLE_PATH, @@ -45,7 +40,7 @@ export async function withBrowser( if (page !== newPage) { await page.close(); } - }), + }) ); const response = new McpResponse(); const context = await McpContext.from( @@ -54,7 +49,7 @@ export async function withBrowser( { experimentalDevToolsDebugging: false, }, - Locator, + Locator ); await cb(response, context); @@ -72,7 +67,7 @@ export function getMockRequest( stableId?: number; navigationRequest?: boolean; frame?: Frame; - } = {}, + } = {} ): HTTPRequest { return { url() { @@ -120,7 +115,7 @@ export function getMockRequest( export function getMockResponse( options: { status?: number; - } = {}, + } = {} ): HTTPResponse { return { status() { @@ -129,10 +124,7 @@ export function getMockResponse( } as HTTPResponse; } -export function html( - strings: TemplateStringsArray, - ...values: unknown[] -): string { +export function html(strings: TemplateStringsArray, ...values: unknown[]): string { const bodyContent = strings.reduce((acc, str, i) => { return acc + str + (values[i] || ''); }, ''); From 187041c5dbf1d937d01cf5065f03ea461e9fbaa4 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Mon, 27 Oct 2025 15:29:30 -0700 Subject: [PATCH 5/8] it works Change-Id: I02b74ae07dde27aca0c4c44266b54734bc955d89 --- src/tools/performance.ts | 95 ++----- tests/tools/performance.test.ts | 459 +++++++++++++++----------------- 2 files changed, 240 insertions(+), 314 deletions(-) diff --git a/src/tools/performance.ts b/src/tools/performance.ts index da50609bf..4a6ad2881 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -8,12 +8,7 @@ import {logger} from '../logger.js'; import {zod} from '../third_party/index.js'; import type {Page} from '../third_party/index.js'; import type {InsightName} from '../trace-processing/parse.js'; -import { - getInsightOutput, - getTraceSummary, - parseRawTraceBuffer, - traceResultIsSuccess, -} from '../trace-processing/parse.js'; +import {getInsightOutput, getTraceSummary, parseRawTraceBuffer, traceResultIsSuccess} from '../trace-processing/parse.js'; import {ToolCategory} from './categories.js'; import type {Context, Response} from './ToolDefinition.js'; @@ -28,21 +23,13 @@ export const startTrace = defineTool({ readOnlyHint: true, }, schema: { - reload: zod - .boolean() - .describe( - 'Determines if, once tracing has started, the page should be automatically reloaded.', - ), - autoStop: zod - .boolean() - .describe( - 'Determines if the trace recording should be automatically stopped.', - ), + reload: zod.boolean().describe('Determines if, once tracing has started, the page should be automatically reloaded.'), + autoStop: zod.boolean().describe('Determines if the trace recording should be automatically stopped.'), }, handler: async (request, response, context) => { if (context.isRunningPerformanceTrace()) { response.appendResponseLine( - 'Error: a performance trace is already running. Use performance_stop_trace to stop it. Only one trace can be running at any given time.', + 'Error: a performance trace is already running. Use performance_stop_trace to stop it. Only one trace can be running at any given time.' ); return; } @@ -93,17 +80,14 @@ export const startTrace = defineTool({ await new Promise(resolve => setTimeout(resolve, 5_000)); await stopTracingAndAppendOutput(page, response, context); } else { - response.appendResponseLine( - `The performance trace is being recorded. Use performance_stop_trace to stop it.`, - ); + response.appendResponseLine(`The performance trace is being recorded. Use performance_stop_trace to stop it.`); } }, }); export const stopTrace = defineTool({ name: 'performance_stop_trace', - description: - 'Stops the active performance trace recording on the selected page.', + description: 'Stops the active performance trace recording on the selected page.', annotations: { category: ToolCategory.PERFORMANCE, readOnlyHint: true, @@ -120,32 +104,22 @@ export const stopTrace = defineTool({ export const analyzeInsight = defineTool({ name: 'performance_analyze_insight', - description: - 'Provides more detailed information on a specific Performance Insight that was highlighted in the results of a trace recording.', + description: 'Provides more detailed information on a specific Performance Insight that was highlighted in the results of a trace recording.', annotations: { category: ToolCategory.PERFORMANCE, readOnlyHint: true, }, schema: { - insightName: zod - .string() - .describe( - 'The name of the Insight you want more information on. For example: "DocumentLatency" or "LCPBreakdown"', - ), + insightName: zod.string().describe('The name of the Insight you want more information on. For example: "DocumentLatency" or "LCPBreakdown"'), }, handler: async (request, response, context) => { const lastRecording = context.recordedTraces().at(-1); if (!lastRecording) { - response.appendResponseLine( - 'No recorded traces found. Record a performance trace so you have Insights to analyze.', - ); + response.appendResponseLine('No recorded traces found. Record a performance trace so you have Insights to analyze.'); return; } - const insightOutput = getInsightOutput( - lastRecording, - request.params.insightName as InsightName, - ); + const insightOutput = getInsightOutput(lastRecording, request.params.insightName as InsightName); if ('error' in insightOutput) { response.appendResponseLine(insightOutput.error); return; @@ -155,11 +129,7 @@ export const analyzeInsight = defineTool({ }, }); -async function stopTracingAndAppendOutput( - page: Page, - response: Response, - context: Context, -): Promise { +async function stopTracingAndAppendOutput(page: Page, response: Response, context: Context): Promise { try { const traceEventsBuffer = await page.tracing.stop(); const result = await parseRawTraceBuffer(traceEventsBuffer); @@ -169,26 +139,21 @@ async function stopTracingAndAppendOutput( const traceSummaryText = getTraceSummary(result); response.appendResponseLine(traceSummaryText); } else { - response.appendResponseLine( - 'There was an unexpected error parsing the trace:', - ); + response.appendResponseLine('There was an unexpected error parsing the trace:'); response.appendResponseLine(result.error); } } catch (e) { const errorText = e instanceof Error ? e.message : JSON.stringify(e); logger(`Error stopping performance trace: ${errorText}`); - response.appendResponseLine( - 'An error occurred generating the response for this trace:', - ); + response.appendResponseLine('An error occurred generating the response for this trace:'); response.appendResponseLine(errorText); } finally { context.setIsRunningPerformanceTrace(false); } } -// This key is expected to be visible. -// b/349721878 -const CRUX_API_KEY = 'AIzaSyCCSOx25vrb5z0tbedCB3_JRzzbVW6Uwgw'; +// This key is expected to be visible. b/349721878 +const CRUX_API_KEY = 'AIzaSyBn5gimNjhiEyA_euicSKko6IlD3HdgUfk'; const CRUX_ENDPOINT = `https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=${CRUX_API_KEY}`; export const queryChromeUXReport = defineTool({ @@ -200,32 +165,21 @@ export const queryChromeUXReport = defineTool({ readOnlyHint: true, }, schema: { - origin: zod - .string() - .describe( - 'The origin to query, e.g., "https://www.google.com". Do not provide this if "url" is specified.', - ) - .optional(), + origin: zod.string().describe('The origin to query, e.g., "https://www.google.com". Do not provide this if "url" is specified.').optional(), url: zod .string() - .describe( - 'The specific page URL to query, e.g., "https://www.google.com/search?q=puppies". Do not provide this if "origin" is specified.', - ) + .describe('The specific page URL to query, e.g., "https://www.google.com/search?q=puppies". Do not provide this if "origin" is specified.') .optional(), formFactor: zod .enum(['DESKTOP', 'PHONE', 'TABLET']) - .describe( - 'The form factor to filter by. If omitted, data for all form factors is aggregated.', - ) + .describe('The form factor to filter by. If omitted, data for all form factors is aggregated.') .optional(), }, handler: async (request, response) => { const {origin, url, formFactor} = request.params; if ((!origin && !url) || (origin && url)) { - response.appendResponseLine( - 'Error: you must provide either "origin" or "url", but not both.', - ); + response.appendResponseLine('Error: you must provide either "origin" or "url", but not both.'); return; } @@ -233,20 +187,13 @@ export const queryChromeUXReport = defineTool({ origin, url, formFactor, - metrics: [ - 'first_contentful_paint', - 'largest_contentful_paint', - 'cumulative_layout_shift', - 'interaction_to_next_paint', - ], + metrics: ['first_contentful_paint', 'largest_contentful_paint', 'cumulative_layout_shift', 'interaction_to_next_paint'], }); try { const cruxResponse = await fetch(CRUX_ENDPOINT, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: {'Content-Type': 'application/json', referer: 'devtools://mcp'}, body, }); diff --git a/tests/tools/performance.test.ts b/tests/tools/performance.test.ts index 2ae4eddff..2a90e62d4 100644 --- a/tests/tools/performance.test.ts +++ b/tests/tools/performance.test.ts @@ -7,225 +7,207 @@ import assert from 'node:assert'; import {describe, it, afterEach} from 'node:test'; import sinon from 'sinon'; -import logger from 'debug'; -import puppeteer, {Locator} from 'puppeteer'; import {analyzeInsight, queryChromeUXReport, startTrace, stopTrace} from '../../src/tools/performance.js'; -import type {Context} from '../../src/tools/ToolDefinition.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 {withBrowser} from '../utils.js'; -import {McpResponse} from '../../src/McpResponse.js'; -import {McpContext} from '../../src/McpContext.js'; - -function getBrowserlessContext(): Promise { - const page = { - isClosed: () => false, - url: () => '', - on: () => {}, - off: () => {}, - setDefaultTimeout: () => {}, - setDefaultNavigationTimeout: () => {}, - } as unknown as import('puppeteer').Page; - const browser = {pages: async () => [page]} as unknown as import('puppeteer').Browser; - return McpContext.from(browser, logger('test'), {experimentalDevToolsDebugging: false}, Locator); -} describe('performance', () => { afterEach(() => { sinon.restore(); }); - // describe('performance_start_trace', () => { - // it('starts a trace recording', async () => { - // await withBrowser(async (response, context) => { - // context.setIsRunningPerformanceTrace(false); - // const selectedPage = context.getSelectedPage(); - // const startTracingStub = sinon.stub(selectedPage.tracing, 'start'); - // await startTrace.handler({params: {reload: true, autoStop: false}}, 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 withBrowser(async (response, context) => { - // const selectedPage = context.getSelectedPage(); - // 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}}, 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 withBrowser(async (response, context) => { - // const selectedPage = context.getSelectedPage(); - // 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}}, 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 withBrowser(async (response, context) => { - // context.setIsRunningPerformanceTrace(true); - // const selectedPage = context.getSelectedPage(); - // const startTracingStub = sinon.stub(selectedPage.tracing, 'start'); - // await startTrace.handler({params: {reload: true, autoStop: false}}, response, context); - // sinon.assert.notCalled(startTracingStub); - // assert.ok(response.responseLines.join('\n').match(/a performance trace is already running/)); - // }); - // }); - // }); - - // 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 t => { - // const trace = await parseTrace('web-dev-with-commit.json.gz'); - // await withBrowser(async (response, context) => { - // context.storeTraceRecording(trace); - // context.setIsRunningPerformanceTrace(false); - - // await analyzeInsight.handler( - // { - // params: { - // insightName: 'LCPBreakdown', - // }, - // }, - // response, - // context - // ); - - // t.assert.snapshot?.(response.responseLines.join('\n')); - // }); - // }); - - // it('returns an error if the insight does not exist', async () => { - // const trace = await parseTrace('web-dev-with-commit.json.gz'); - // await withBrowser(async (response, context) => { - // context.storeTraceRecording(trace); - // context.setIsRunningPerformanceTrace(false); - - // await analyzeInsight.handler( - // { - // params: { - // insightName: 'MadeUpInsightName', - // }, - // }, - // response, - // context - // ); - // assert.ok(response.responseLines.join('\n').match(/No Insight with the name MadeUpInsightName found./)); - // }); - // }); - - // it('returns an error if no trace has been recorded', async () => { - // await withBrowser(async (response, context) => { - // await analyzeInsight.handler( - // { - // params: { - // insightName: 'LCPBreakdown', - // }, - // }, - // 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 withBrowser(async (response, context) => { - // context.setIsRunningPerformanceTrace(false); - // const selectedPage = context.getSelectedPage(); - // const stopTracingStub = sinon.stub(selectedPage.tracing, 'stop'); - // await stopTrace.handler({params: {}}, 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 withBrowser(async (response, context) => { - // context.setIsRunningPerformanceTrace(true); - // const selectedPage = context.getSelectedPage(); - // const stopTracingStub = sinon.stub(selectedPage.tracing, 'stop').callsFake(async () => { - // return rawData; - // }); - // await stopTrace.handler({params: {}}, response, context); - // assert.ok(response.responseLines.includes('The performance trace has been stopped.')); - // assert.strictEqual(context.recordedTraces().length, 1); - // sinon.assert.calledOnce(stopTracingStub); - // }); - // }); - - // it('returns an error message if parsing the trace buffer fails', async t => { - // await withBrowser(async (response, context) => { - // context.setIsRunningPerformanceTrace(true); - // const selectedPage = context.getSelectedPage(); - // sinon.stub(selectedPage.tracing, 'stop').returns(Promise.resolve(undefined)); - // await stopTrace.handler({params: {}}, response, context); - // t.assert.snapshot?.(response.responseLines.join('\n')); - // }); - // }); - - // it('returns the high level summary of the performance trace', async t => { - // const rawData = loadTraceAsBuffer('web-dev-with-commit.json.gz'); - // await withBrowser(async (response, context) => { - // context.setIsRunningPerformanceTrace(true); - // const selectedPage = context.getSelectedPage(); - // sinon.stub(selectedPage.tracing, 'stop').callsFake(async () => { - // return rawData; - // }); - // await stopTrace.handler({params: {}}, response, context); - // t.assert.snapshot?.(response.responseLines.join('\n')); - // }); - // }); - // }); + describe('performance_start_trace', () => { + it('starts a trace recording', async () => { + await withBrowser(async (response, context) => { + context.setIsRunningPerformanceTrace(false); + const selectedPage = context.getSelectedPage(); + const startTracingStub = sinon.stub(selectedPage.tracing, 'start'); + await startTrace.handler({params: {reload: true, autoStop: false}}, 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 withBrowser(async (response, context) => { + const selectedPage = context.getSelectedPage(); + 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}}, 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 withBrowser(async (response, context) => { + const selectedPage = context.getSelectedPage(); + 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}}, 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 withBrowser(async (response, context) => { + context.setIsRunningPerformanceTrace(true); + const selectedPage = context.getSelectedPage(); + const startTracingStub = sinon.stub(selectedPage.tracing, 'start'); + await startTrace.handler({params: {reload: true, autoStop: false}}, response, context); + sinon.assert.notCalled(startTracingStub); + assert.ok(response.responseLines.join('\n').match(/a performance trace is already running/)); + }); + }); + }); + + 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 t => { + const trace = await parseTrace('web-dev-with-commit.json.gz'); + await withBrowser(async (response, context) => { + context.storeTraceRecording(trace); + context.setIsRunningPerformanceTrace(false); + + await analyzeInsight.handler( + { + params: { + insightName: 'LCPBreakdown', + }, + }, + response, + context + ); + + t.assert.snapshot?.(response.responseLines.join('\n')); + }); + }); + + it('returns an error if the insight does not exist', async () => { + const trace = await parseTrace('web-dev-with-commit.json.gz'); + await withBrowser(async (response, context) => { + context.storeTraceRecording(trace); + context.setIsRunningPerformanceTrace(false); + + await analyzeInsight.handler( + { + params: { + insightName: 'MadeUpInsightName', + }, + }, + response, + context + ); + assert.ok(response.responseLines.join('\n').match(/No Insight with the name MadeUpInsightName found./)); + }); + }); + + it('returns an error if no trace has been recorded', async () => { + await withBrowser(async (response, context) => { + await analyzeInsight.handler( + { + params: { + insightName: 'LCPBreakdown', + }, + }, + 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 withBrowser(async (response, context) => { + context.setIsRunningPerformanceTrace(false); + const selectedPage = context.getSelectedPage(); + const stopTracingStub = sinon.stub(selectedPage.tracing, 'stop'); + await stopTrace.handler({params: {}}, 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 withBrowser(async (response, context) => { + context.setIsRunningPerformanceTrace(true); + const selectedPage = context.getSelectedPage(); + const stopTracingStub = sinon.stub(selectedPage.tracing, 'stop').callsFake(async () => { + return rawData; + }); + await stopTrace.handler({params: {}}, response, context); + assert.ok(response.responseLines.includes('The performance trace has been stopped.')); + assert.strictEqual(context.recordedTraces().length, 1); + sinon.assert.calledOnce(stopTracingStub); + }); + }); + + it('returns an error message if parsing the trace buffer fails', async t => { + await withBrowser(async (response, context) => { + context.setIsRunningPerformanceTrace(true); + const selectedPage = context.getSelectedPage(); + sinon.stub(selectedPage.tracing, 'stop').returns(Promise.resolve(undefined)); + await stopTrace.handler({params: {}}, response, context); + t.assert.snapshot?.(response.responseLines.join('\n')); + }); + }); + + it('returns the high level summary of the performance trace', async t => { + const rawData = loadTraceAsBuffer('web-dev-with-commit.json.gz'); + await withBrowser(async (response, context) => { + context.setIsRunningPerformanceTrace(true); + const selectedPage = context.getSelectedPage(); + sinon.stub(selectedPage.tracing, 'stop').callsFake(async () => { + return rawData; + }); + await stopTrace.handler({params: {}}, response, context); + t.assert.snapshot?.(response.responseLines.join('\n')); + }); + }); + }); describe('performance_query_chrome_ux_report', () => { it('successfully queries with origin', async () => { @@ -237,12 +219,12 @@ describe('performance', () => { }) ); - const response = new McpResponse(); - const context = await getBrowserlessContext(); - await queryChromeUXReport.handler({params: {origin: 'https://example.com'}}, response, context); + await withBrowser(async (response, context) => { + await queryChromeUXReport.handler({params: {origin: 'https://example.com'}}, response, context); - assert.ok(fetchStub.calledOnce); - assert.strictEqual(response.responseLines[0], JSON.stringify(mockResponse, null, 2)); + assert.ok(fetchStub.calledOnce); + assert.strictEqual(response.responseLines[0], JSON.stringify(mockResponse, null, 2)); + }); }); it('successfully queries with url', async () => { @@ -254,49 +236,46 @@ describe('performance', () => { }) ); - const response = new McpResponse(); - const context = await getBrowserlessContext(); - await queryChromeUXReport.handler({params: {url: 'https://example.com'}}, response, context); + await withBrowser(async (response, context) => { + await queryChromeUXReport.handler({params: {url: 'https://example.com'}}, response, context); - assert.ok(fetchStub.calledOnce); - assert.strictEqual(response.responseLines[0], JSON.stringify(mockResponse, null, 2)); + assert.ok(fetchStub.calledOnce); + assert.strictEqual(response.responseLines[0], JSON.stringify(mockResponse, null, 2)); + }); }); it('errors if both origin and url are provided', async () => { - const response = new McpResponse(); - const context = await getBrowserlessContext(); - await queryChromeUXReport.handler( - { - params: { - origin: 'https://example.com', - url: 'https://example.com', + await withBrowser(async (response, context) => { + await queryChromeUXReport.handler( + { + params: {origin: 'https://example.com', url: 'https://example.com'}, }, - }, - response, - context - ); + response, + context + ); - assert.ok(response.responseLines[0]?.includes('Error: you must provide either "origin" or "url", but not both.')); + assert.ok(response.responseLines[0]?.includes('Error: you must provide either "origin" or "url", but not both.')); + }); }); it('errors if neither origin nor url are provided', async () => { - const response = new McpResponse(); - const context = await getBrowserlessContext(); - await queryChromeUXReport.handler({params: {}}, response, context); + await withBrowser(async (response, context) => { + await queryChromeUXReport.handler({params: {}}, response, context); - assert.ok(response.responseLines[0]?.includes('Error: you must provide either "origin" or "url", but not both.')); + assert.ok(response.responseLines[0]?.includes('Error: you must provide either "origin" or "url", but not both.')); + }); }); it('handles fetch API error', async () => { const fetchStub = sinon.stub(global, 'fetch').rejects(new Error('API is down')); - const response = new McpResponse(); - const context = await getBrowserlessContext(); - await queryChromeUXReport.handler({params: {origin: 'https://example.com'}}, response, context); + await withBrowser(async (response, context) => { + await queryChromeUXReport.handler({params: {origin: 'https://example.com'}}, response, context); - assert.ok(fetchStub.calledOnce); - assert.ok(response.responseLines[0]?.includes('An error occurred fetching CrUX data:')); - assert.strictEqual(response.responseLines[1], 'API is down'); + assert.ok(fetchStub.calledOnce); + assert.ok(response.responseLines[0]?.includes('An error occurred fetching CrUX data:')); + assert.strictEqual(response.responseLines[1], 'API is down'); + }); }); }); }); From fcab2abdc09e294e94343bdf518ba07cce244db0 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Mon, 27 Oct 2025 15:33:29 -0700 Subject: [PATCH 6/8] aggregated and format Change-Id: I479ca2b692498b16a0f64f2f28a7ee710ce3e317 --- src/tools/performance.ts | 93 ++++++++++++++---- tests/tools/performance.test.ts | 169 +++++++++++++++++++++++++------- tests/utils.ts | 22 +++-- 3 files changed, 221 insertions(+), 63 deletions(-) diff --git a/src/tools/performance.ts b/src/tools/performance.ts index 4a6ad2881..ea40dc870 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -8,7 +8,12 @@ import {logger} from '../logger.js'; import {zod} from '../third_party/index.js'; import type {Page} from '../third_party/index.js'; import type {InsightName} from '../trace-processing/parse.js'; -import {getInsightOutput, getTraceSummary, parseRawTraceBuffer, traceResultIsSuccess} from '../trace-processing/parse.js'; +import { + getInsightOutput, + getTraceSummary, + parseRawTraceBuffer, + traceResultIsSuccess, +} from '../trace-processing/parse.js'; import {ToolCategory} from './categories.js'; import type {Context, Response} from './ToolDefinition.js'; @@ -23,13 +28,21 @@ export const startTrace = defineTool({ readOnlyHint: true, }, schema: { - reload: zod.boolean().describe('Determines if, once tracing has started, the page should be automatically reloaded.'), - autoStop: zod.boolean().describe('Determines if the trace recording should be automatically stopped.'), + reload: zod + .boolean() + .describe( + 'Determines if, once tracing has started, the page should be automatically reloaded.', + ), + autoStop: zod + .boolean() + .describe( + 'Determines if the trace recording should be automatically stopped.', + ), }, handler: async (request, response, context) => { if (context.isRunningPerformanceTrace()) { response.appendResponseLine( - 'Error: a performance trace is already running. Use performance_stop_trace to stop it. Only one trace can be running at any given time.' + 'Error: a performance trace is already running. Use performance_stop_trace to stop it. Only one trace can be running at any given time.', ); return; } @@ -80,14 +93,17 @@ export const startTrace = defineTool({ await new Promise(resolve => setTimeout(resolve, 5_000)); await stopTracingAndAppendOutput(page, response, context); } else { - response.appendResponseLine(`The performance trace is being recorded. Use performance_stop_trace to stop it.`); + response.appendResponseLine( + `The performance trace is being recorded. Use performance_stop_trace to stop it.`, + ); } }, }); export const stopTrace = defineTool({ name: 'performance_stop_trace', - description: 'Stops the active performance trace recording on the selected page.', + description: + 'Stops the active performance trace recording on the selected page.', annotations: { category: ToolCategory.PERFORMANCE, readOnlyHint: true, @@ -104,22 +120,32 @@ export const stopTrace = defineTool({ export const analyzeInsight = defineTool({ name: 'performance_analyze_insight', - description: 'Provides more detailed information on a specific Performance Insight that was highlighted in the results of a trace recording.', + description: + 'Provides more detailed information on a specific Performance Insight that was highlighted in the results of a trace recording.', annotations: { category: ToolCategory.PERFORMANCE, readOnlyHint: true, }, schema: { - insightName: zod.string().describe('The name of the Insight you want more information on. For example: "DocumentLatency" or "LCPBreakdown"'), + insightName: zod + .string() + .describe( + 'The name of the Insight you want more information on. For example: "DocumentLatency" or "LCPBreakdown"', + ), }, handler: async (request, response, context) => { const lastRecording = context.recordedTraces().at(-1); if (!lastRecording) { - response.appendResponseLine('No recorded traces found. Record a performance trace so you have Insights to analyze.'); + response.appendResponseLine( + 'No recorded traces found. Record a performance trace so you have Insights to analyze.', + ); return; } - const insightOutput = getInsightOutput(lastRecording, request.params.insightName as InsightName); + const insightOutput = getInsightOutput( + lastRecording, + request.params.insightName as InsightName, + ); if ('error' in insightOutput) { response.appendResponseLine(insightOutput.error); return; @@ -129,7 +155,11 @@ export const analyzeInsight = defineTool({ }, }); -async function stopTracingAndAppendOutput(page: Page, response: Response, context: Context): Promise { +async function stopTracingAndAppendOutput( + page: Page, + response: Response, + context: Context, +): Promise { try { const traceEventsBuffer = await page.tracing.stop(); const result = await parseRawTraceBuffer(traceEventsBuffer); @@ -139,13 +169,17 @@ async function stopTracingAndAppendOutput(page: Page, response: Response, contex const traceSummaryText = getTraceSummary(result); response.appendResponseLine(traceSummaryText); } else { - response.appendResponseLine('There was an unexpected error parsing the trace:'); + response.appendResponseLine( + 'There was an unexpected error parsing the trace:', + ); response.appendResponseLine(result.error); } } catch (e) { const errorText = e instanceof Error ? e.message : JSON.stringify(e); logger(`Error stopping performance trace: ${errorText}`); - response.appendResponseLine('An error occurred generating the response for this trace:'); + response.appendResponseLine( + 'An error occurred generating the response for this trace:', + ); response.appendResponseLine(errorText); } finally { context.setIsRunningPerformanceTrace(false); @@ -159,27 +193,38 @@ const CRUX_ENDPOINT = `https://chromeuxreport.googleapis.com/v1/records:queryRec export const queryChromeUXReport = defineTool({ name: 'performance_query_chrome_ux_report', description: - 'Queries the Chrome UX Report (CrUX) API to get real-user experience metrics (like Core Web Vitals) for a given URL or origin. You must provide EITHER "origin" OR "url", but not both. You can optionally filter by "formFactor".', + 'Queries the Chrome UX Report (aka CrUX) to get aggregated real-user experience metrics (like Core Web Vitals) for a given URL or origin. You must provide EITHER "origin" OR "url", but not both. You can optionally filter by "formFactor".', annotations: { category: ToolCategory.PERFORMANCE, readOnlyHint: true, }, schema: { - origin: zod.string().describe('The origin to query, e.g., "https://www.google.com". Do not provide this if "url" is specified.').optional(), + origin: zod + .string() + .describe( + 'The origin to query, e.g., "https://www.google.com". Do not provide this if "url" is specified.', + ) + .optional(), url: zod .string() - .describe('The specific page URL to query, e.g., "https://www.google.com/search?q=puppies". Do not provide this if "origin" is specified.') + .describe( + 'The specific page URL to query, e.g., "https://www.google.com/search?q=puppies". Do not provide this if "origin" is specified.', + ) .optional(), formFactor: zod .enum(['DESKTOP', 'PHONE', 'TABLET']) - .describe('The form factor to filter by. If omitted, data for all form factors is aggregated.') + .describe( + 'The form factor to filter by. If omitted, data for all form factors is aggregated.', + ) .optional(), }, handler: async (request, response) => { const {origin, url, formFactor} = request.params; if ((!origin && !url) || (origin && url)) { - response.appendResponseLine('Error: you must provide either "origin" or "url", but not both.'); + response.appendResponseLine( + 'Error: you must provide either "origin" or "url", but not both.', + ); return; } @@ -187,13 +232,21 @@ export const queryChromeUXReport = defineTool({ origin, url, formFactor, - metrics: ['first_contentful_paint', 'largest_contentful_paint', 'cumulative_layout_shift', 'interaction_to_next_paint'], + metrics: [ + 'first_contentful_paint', + 'largest_contentful_paint', + 'cumulative_layout_shift', + 'interaction_to_next_paint', + ], }); try { const cruxResponse = await fetch(CRUX_ENDPOINT, { method: 'POST', - headers: {'Content-Type': 'application/json', referer: 'devtools://mcp'}, + headers: { + 'Content-Type': 'application/json', + referer: 'devtools://mcp', + }, body, }); diff --git a/tests/tools/performance.test.ts b/tests/tools/performance.test.ts index 2a90e62d4..b40293a59 100644 --- a/tests/tools/performance.test.ts +++ b/tests/tools/performance.test.ts @@ -7,9 +7,18 @@ import assert from 'node:assert'; import {describe, it, afterEach} from 'node:test'; import sinon from 'sinon'; -import {analyzeInsight, queryChromeUXReport, startTrace, stopTrace} from '../../src/tools/performance.js'; + +import { + analyzeInsight, + queryChromeUXReport, + 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 { + parseRawTraceBuffer, + traceResultIsSuccess, +} from '../../src/trace-processing/parse.js'; import {loadTraceAsBuffer} from '../trace-processing/fixtures/load.js'; import {withBrowser} from '../utils.js'; @@ -24,10 +33,18 @@ describe('performance', () => { context.setIsRunningPerformanceTrace(false); const selectedPage = context.getSelectedPage(); const startTracingStub = sinon.stub(selectedPage.tracing, 'start'); - await startTrace.handler({params: {reload: true, autoStop: false}}, response, context); + await startTrace.handler( + {params: {reload: true, autoStop: false}}, + response, + context, + ); sinon.assert.calledOnce(startTracingStub); assert.ok(context.isRunningPerformanceTrace()); - assert.ok(response.responseLines.join('\n').match(/The performance trace is being recorded/)); + assert.ok( + response.responseLines + .join('\n') + .match(/The performance trace is being recorded/), + ); }); }); @@ -37,7 +54,11 @@ describe('performance', () => { 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}}, response, context); + await startTrace.handler( + {params: {reload: true, autoStop: false}}, + response, + context, + ); sinon.assert.calledOnce(startTracingStub); sinon.assert.calledWithExactly(gotoStub, 'about:blank', { waitUntil: ['networkidle0'], @@ -46,7 +67,11 @@ describe('performance', () => { waitUntil: ['load'], }); assert.ok(context.isRunningPerformanceTrace()); - assert.ok(response.responseLines.join('\n').match(/The performance trace is being recorded/)); + assert.ok( + response.responseLines + .join('\n') + .match(/The performance trace is being recorded/), + ); }); }); @@ -58,12 +83,18 @@ describe('performance', () => { 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 stopTracingStub = sinon + .stub(selectedPage.tracing, 'stop') + .callsFake(() => { + return Promise.resolve(rawData); + }); const clock = sinon.useFakeTimers(); - const handlerPromise = startTrace.handler({params: {reload: true, autoStop: true}}, response, context); + const handlerPromise = startTrace.handler( + {params: {reload: true, autoStop: true}}, + 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 @@ -75,9 +106,17 @@ describe('performance', () => { sinon.assert.calledOnce(startTracingStub); sinon.assert.calledOnce(stopTracingStub); - assert.strictEqual(context.isRunningPerformanceTrace(), false, 'Tracing was stopped'); + 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/)); + assert.ok( + response.responseLines + .join('\n') + .match(/The performance trace has been stopped/), + ); }); }); @@ -86,9 +125,17 @@ describe('performance', () => { context.setIsRunningPerformanceTrace(true); const selectedPage = context.getSelectedPage(); const startTracingStub = sinon.stub(selectedPage.tracing, 'start'); - await startTrace.handler({params: {reload: true, autoStop: false}}, response, context); + await startTrace.handler( + {params: {reload: true, autoStop: false}}, + response, + context, + ); sinon.assert.notCalled(startTracingStub); - assert.ok(response.responseLines.join('\n').match(/a performance trace is already running/)); + assert.ok( + response.responseLines + .join('\n') + .match(/a performance trace is already running/), + ); }); }); }); @@ -116,7 +163,7 @@ describe('performance', () => { }, }, response, - context + context, ); t.assert.snapshot?.(response.responseLines.join('\n')); @@ -136,9 +183,13 @@ describe('performance', () => { }, }, response, - context + context, + ); + assert.ok( + response.responseLines + .join('\n') + .match(/No Insight with the name MadeUpInsightName found./), ); - assert.ok(response.responseLines.join('\n').match(/No Insight with the name MadeUpInsightName found./)); }); }); @@ -151,9 +202,15 @@ describe('performance', () => { }, }, response, - context + context, + ); + assert.ok( + response.responseLines + .join('\n') + .match( + /No recorded traces found. Record a performance trace so you have Insights to analyze./, + ), ); - assert.ok(response.responseLines.join('\n').match(/No recorded traces found. Record a performance trace so you have Insights to analyze./)); }); }); }); @@ -175,11 +232,17 @@ describe('performance', () => { await withBrowser(async (response, context) => { context.setIsRunningPerformanceTrace(true); const selectedPage = context.getSelectedPage(); - const stopTracingStub = sinon.stub(selectedPage.tracing, 'stop').callsFake(async () => { - return rawData; - }); + const stopTracingStub = sinon + .stub(selectedPage.tracing, 'stop') + .callsFake(async () => { + return rawData; + }); await stopTrace.handler({params: {}}, response, context); - assert.ok(response.responseLines.includes('The performance trace has been stopped.')); + assert.ok( + response.responseLines.includes( + 'The performance trace has been stopped.', + ), + ); assert.strictEqual(context.recordedTraces().length, 1); sinon.assert.calledOnce(stopTracingStub); }); @@ -189,7 +252,9 @@ describe('performance', () => { await withBrowser(async (response, context) => { context.setIsRunningPerformanceTrace(true); const selectedPage = context.getSelectedPage(); - sinon.stub(selectedPage.tracing, 'stop').returns(Promise.resolve(undefined)); + sinon + .stub(selectedPage.tracing, 'stop') + .returns(Promise.resolve(undefined)); await stopTrace.handler({params: {}}, response, context); t.assert.snapshot?.(response.responseLines.join('\n')); }); @@ -216,14 +281,21 @@ describe('performance', () => { new Response(JSON.stringify(mockResponse), { status: 200, headers: {'Content-Type': 'application/json'}, - }) + }), ); await withBrowser(async (response, context) => { - await queryChromeUXReport.handler({params: {origin: 'https://example.com'}}, response, context); + await queryChromeUXReport.handler( + {params: {origin: 'https://example.com'}}, + response, + context, + ); assert.ok(fetchStub.calledOnce); - assert.strictEqual(response.responseLines[0], JSON.stringify(mockResponse, null, 2)); + assert.strictEqual( + response.responseLines[0], + JSON.stringify(mockResponse, null, 2), + ); }); }); @@ -233,14 +305,21 @@ describe('performance', () => { new Response(JSON.stringify(mockResponse), { status: 200, headers: {'Content-Type': 'application/json'}, - }) + }), ); await withBrowser(async (response, context) => { - await queryChromeUXReport.handler({params: {url: 'https://example.com'}}, response, context); + await queryChromeUXReport.handler( + {params: {url: 'https://example.com'}}, + response, + context, + ); assert.ok(fetchStub.calledOnce); - assert.strictEqual(response.responseLines[0], JSON.stringify(mockResponse, null, 2)); + assert.strictEqual( + response.responseLines[0], + JSON.stringify(mockResponse, null, 2), + ); }); }); @@ -251,10 +330,14 @@ describe('performance', () => { params: {origin: 'https://example.com', url: 'https://example.com'}, }, response, - context + context, ); - assert.ok(response.responseLines[0]?.includes('Error: you must provide either "origin" or "url", but not both.')); + assert.ok( + response.responseLines[0]?.includes( + 'Error: you must provide either "origin" or "url", but not both.', + ), + ); }); }); @@ -262,18 +345,32 @@ describe('performance', () => { await withBrowser(async (response, context) => { await queryChromeUXReport.handler({params: {}}, response, context); - assert.ok(response.responseLines[0]?.includes('Error: you must provide either "origin" or "url", but not both.')); + assert.ok( + response.responseLines[0]?.includes( + 'Error: you must provide either "origin" or "url", but not both.', + ), + ); }); }); it('handles fetch API error', async () => { - const fetchStub = sinon.stub(global, 'fetch').rejects(new Error('API is down')); + const fetchStub = sinon + .stub(global, 'fetch') + .rejects(new Error('API is down')); await withBrowser(async (response, context) => { - await queryChromeUXReport.handler({params: {origin: 'https://example.com'}}, response, context); + await queryChromeUXReport.handler( + {params: {origin: 'https://example.com'}}, + response, + context, + ); assert.ok(fetchStub.calledOnce); - assert.ok(response.responseLines[0]?.includes('An error occurred fetching CrUX data:')); + assert.ok( + response.responseLines[0]?.includes( + 'An error occurred fetching CrUX data:', + ), + ); assert.strictEqual(response.responseLines[1], 'API is down'); }); }); diff --git a/tests/utils.ts b/tests/utils.ts index 2bb8ce085..6dca14a71 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -6,7 +6,12 @@ import logger from 'debug'; import type {Browser} from 'puppeteer'; import puppeteer, {Locator} from 'puppeteer'; -import type {Frame, HTTPRequest, HTTPResponse, LaunchOptions} from 'puppeteer-core'; +import type { + Frame, + HTTPRequest, + HTTPResponse, + LaunchOptions, +} from 'puppeteer-core'; import {McpContext} from '../src/McpContext.js'; import {McpResponse} from '../src/McpResponse.js'; @@ -16,7 +21,7 @@ const browsers = new Map(); export async function withBrowser( cb: (response: McpResponse, context: McpContext) => Promise, - options: {debug?: boolean; autoOpenDevTools?: boolean} = {} + options: {debug?: boolean; autoOpenDevTools?: boolean} = {}, ) { const launchOptions: LaunchOptions = { executablePath: process.env.PUPPETEER_EXECUTABLE_PATH, @@ -40,7 +45,7 @@ export async function withBrowser( if (page !== newPage) { await page.close(); } - }) + }), ); const response = new McpResponse(); const context = await McpContext.from( @@ -49,7 +54,7 @@ export async function withBrowser( { experimentalDevToolsDebugging: false, }, - Locator + Locator, ); await cb(response, context); @@ -67,7 +72,7 @@ export function getMockRequest( stableId?: number; navigationRequest?: boolean; frame?: Frame; - } = {} + } = {}, ): HTTPRequest { return { url() { @@ -115,7 +120,7 @@ export function getMockRequest( export function getMockResponse( options: { status?: number; - } = {} + } = {}, ): HTTPResponse { return { status() { @@ -124,7 +129,10 @@ export function getMockResponse( } as HTTPResponse; } -export function html(strings: TemplateStringsArray, ...values: unknown[]): string { +export function html( + strings: TemplateStringsArray, + ...values: unknown[] +): string { const bodyContent = strings.reduce((acc, str, i) => { return acc + str + (values[i] || ''); }, ''); From cee7b13837ab34d658a6fdecce1f55ce7ce22270 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Mon, 27 Oct 2025 15:35:41 -0700 Subject: [PATCH 7/8] query all metrics Change-Id: Ibb753c5420ac89ff43a8fed59b0651e7c4672345 --- package.json | 2 +- src/tools/performance.ts | 18 +++++------------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index f5cb82555..9689137aa 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "start": "npm run build && node build/src/index.js", "start-debug": "DEBUG=mcp:* DEBUG_COLORS=false npm run build && node build/src/index.js", "test:node20": "node --require ./build/tests/setup.js --test-reporter spec --test-force-exit --test build/tests", - "test": "npm run build && node --require ./build/tests/setup.js --no-warnings=ExperimentalWarning --test-reporter spec --test-force-exit --test \"build/tests/**/performance.test.js\"", + "test": "npm run build && node --require ./build/tests/setup.js --no-warnings=ExperimentalWarning --test-reporter spec --test-force-exit --test \"build/tests/**/*.test.js\"", "test:only": "npm run build && node --require ./build/tests/setup.js --no-warnings=ExperimentalWarning --test-reporter spec --test-force-exit --test --test-only \"build/tests/**/*.test.js\"", "test:only:no-build": "node --require ./build/tests/setup.js --no-warnings=ExperimentalWarning --test-reporter spec --test-force-exit --test --test-only \"build/tests/**/*.test.js\"", "test:update-snapshots": "npm run build && node --require ./build/tests/setup.js --no-warnings=ExperimentalWarning --test-force-exit --test --test-update-snapshots \"build/tests/**/*.test.js\"", diff --git a/src/tools/performance.ts b/src/tools/performance.ts index ea40dc870..04ff5e040 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -228,18 +228,6 @@ export const queryChromeUXReport = defineTool({ return; } - const body = JSON.stringify({ - origin, - url, - formFactor, - metrics: [ - 'first_contentful_paint', - 'largest_contentful_paint', - 'cumulative_layout_shift', - 'interaction_to_next_paint', - ], - }); - try { const cruxResponse = await fetch(CRUX_ENDPOINT, { method: 'POST', @@ -247,7 +235,11 @@ export const queryChromeUXReport = defineTool({ 'Content-Type': 'application/json', referer: 'devtools://mcp', }, - body, + body: JSON.stringify({ + origin, + url, + formFactor, + }), }); const data = await cruxResponse.json(); From 3b3001ca085660bcacf1ab6981fc982e42cbe2b7 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Tue, 28 Oct 2025 08:48:15 -0700 Subject: [PATCH 8/8] tighten up Change-Id: Ie2bd50af8f24f3bf1b74168b1f5db5a4b593c8ad --- docs/tool-reference.md | 6 +++--- src/tools/performance.ts | 13 +++++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index aa76790fb..a18973cf7 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -235,13 +235,13 @@ ### `performance_query_chrome_ux_report` -**Description:** Queries the Chrome UX Report (CrUX) API to get real-user experience metrics (like Core Web Vitals) for a given URL or origin. You must provide EITHER "origin" OR "url", but not both. You can optionally filter by "formFactor". +**Description:** Queries the Chrome UX Report (aka CrUX) to get aggregated real-user experience metrics (like Core Web Vitals) for a given URL or origin. **Parameters:** +- **origin** (string) _(optional)_: The origin to query, e.g., "https://web.dev". Do not provide this if "url" is specified. +- **url** (string) _(optional)_: The specific page URL to query, e.g., "https://web.dev/s/results?q=puppies". Do not provide this if "origin" is specified. - **formFactor** (enum: "DESKTOP", "PHONE", "TABLET") _(optional)_: The form factor to filter by. If omitted, data for all form factors is aggregated. -- **origin** (string) _(optional)_: The origin to query, e.g., "https://www.google.com". Do not provide this if "url" is specified. -- **url** (string) _(optional)_: The specific page URL to query, e.g., "https://www.google.com/search?q=puppies". Do not provide this if "origin" is specified. --- diff --git a/src/tools/performance.ts b/src/tools/performance.ts index 04ff5e040..801fc5c3e 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -193,7 +193,7 @@ const CRUX_ENDPOINT = `https://chromeuxreport.googleapis.com/v1/records:queryRec export const queryChromeUXReport = defineTool({ name: 'performance_query_chrome_ux_report', description: - 'Queries the Chrome UX Report (aka CrUX) to get aggregated real-user experience metrics (like Core Web Vitals) for a given URL or origin. You must provide EITHER "origin" OR "url", but not both. You can optionally filter by "formFactor".', + 'Queries the Chrome UX Report (aka CrUX) to get aggregated real-user experience metrics (like Core Web Vitals) for a given URL or origin.', annotations: { category: ToolCategory.PERFORMANCE, readOnlyHint: true, @@ -202,13 +202,13 @@ export const queryChromeUXReport = defineTool({ origin: zod .string() .describe( - 'The origin to query, e.g., "https://www.google.com". Do not provide this if "url" is specified.', + 'The origin to query, e.g., "https://web.dev". Do not provide this if "url" is specified.', ) .optional(), url: zod .string() .describe( - 'The specific page URL to query, e.g., "https://www.google.com/search?q=puppies". Do not provide this if "origin" is specified.', + 'The specific page URL to query, e.g., "https://web.dev/s/results?q=puppies". Do not provide this if "origin" is specified.', ) .optional(), formFactor: zod @@ -219,13 +219,14 @@ export const queryChromeUXReport = defineTool({ .optional(), }, handler: async (request, response) => { - const {origin, url, formFactor} = request.params; + const {origin: origin_, url, formFactor} = request.params; + // Ensure probably formatted origin (no trailing slash); + const origin = URL.parse(origin_ ?? '')?.origin; if ((!origin && !url) || (origin && url)) { - response.appendResponseLine( + return response.appendResponseLine( 'Error: you must provide either "origin" or "url", but not both.', ); - return; } try {