From be31b472b2b4eabd4864db2f58b9d04b449bf715 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Mon, 22 Dec 2025 14:21:59 -0800 Subject: [PATCH 01/16] revert these lockfile changes Change-Id: Ibda3954500250fa9c2d0ae95dd996409ea525080 --- package-lock.json | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6a023974d..89ba94bfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1226,7 +1226,6 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -1701,7 +1700,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2609,8 +2607,7 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz", "integrity": "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/diff": { "version": "7.0.0", @@ -2911,7 +2908,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3082,7 +3078,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3353,7 +3348,6 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5535,7 +5529,6 @@ "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -6504,7 +6497,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6580,7 +6572,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -6879,7 +6870,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 9623ae6ad50c6d1240e5c62688a84e44d4422c86 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Mon, 22 Dec 2025 15:29:00 -0800 Subject: [PATCH 02/16] implemented but imma change it Change-Id: Ib9b76305409bb660a25182497d9b54591e2297a0 --- README.md | 8 +- docs/tool-reference.md | 13 ++- src/main.ts | 3 + src/third_party/devtools.ts | 3 + src/tools/performance.ts | 33 +++++- src/utils/crux.ts | 218 ++++++++++++++++++++++++++++++++++++ tests/utils/crux.test.ts | 157 ++++++++++++++++++++++++++ 7 files changed, 432 insertions(+), 3 deletions(-) create mode 100644 src/utils/crux.ts create mode 100644 tests/utils/crux.test.ts diff --git a/README.md b/README.md index 45ca0f86c..febd36e3a 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,11 @@ allowing them to inspect, debug, and modify any data in the browser or DevTools. Avoid sharing sensitive or personal information that you don't want to share with MCP clients. +Performance tools may send trace URLs to the Google CrUX API to fetch real-user +experience data. This helps provide a holistic performance picture by +presenting field data alongside lab data. This data is collected by the [Chrome +User Experience Report (CrUX)](https://developer.chrome.com/docs/crux). + ## Requirements - [Node.js](https://nodejs.org/) v20.19 or a newer [latest maintenance LTS](https://github.com/nodejs/Release#release-schedule) version. @@ -328,10 +333,11 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles - **Emulation** (2 tools) - [`emulate`](docs/tool-reference.md#emulate) - [`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_start_trace`](docs/tool-reference.md#performance_start_trace) - [`performance_stop_trace`](docs/tool-reference.md#performance_stop_trace) + - [`performance_toggle_crux`](docs/tool-reference.md#performance_toggle_crux) - **Network** (2 tools) - [`get_network_request`](docs/tool-reference.md#get_network_request) - [`list_network_requests`](docs/tool-reference.md#list_network_requests) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 618a1f486..baef40b78 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -21,10 +21,11 @@ - **[Emulation](#emulation)** (2 tools) - [`emulate`](#emulate) - [`resize_page`](#resize_page) -- **[Performance](#performance)** (3 tools) +- **[Performance](#performance)** (4 tools) - [`performance_analyze_insight`](#performance_analyze_insight) - [`performance_start_trace`](#performance_start_trace) - [`performance_stop_trace`](#performance_stop_trace) + - [`performance_toggle_crux`](#performance_toggle_crux) - **[Network](#network)** (2 tools) - [`get_network_request`](#get_network_request) - [`list_network_requests`](#list_network_requests) @@ -245,6 +246,16 @@ --- +### `performance_toggle_crux` + +**Description:** Enables or disables the fetching of real-user experience data from the Chrome User Experience Report (CrUX) API during performance traces. When enabled, performance summaries will include field data (LCP, INP, CLS) for the URLs in the trace. + +**Parameters:** + +- **enabled** (boolean) **(required)**: Whether to enable or disable CrUX data fetching. + +--- + ## Network ### `get_network_request` diff --git a/src/main.ts b/src/main.ts index 84bb6d9b5..103108fff 100644 --- a/src/main.ts +++ b/src/main.ts @@ -98,6 +98,9 @@ const logDisclaimers = () => { debug, and modify any data in the browser or DevTools. Avoid sharing sensitive or personal information that you do not want to share with MCP clients.`, ); + console.error( + `Performance tools may send trace URLs to the Google CrUX API to fetch real-user experience data.`, + ); }; const toolMutex = new Mutex(); diff --git a/src/third_party/devtools.ts b/src/third_party/devtools.ts index 5e06db4ee..75d8531c0 100644 --- a/src/third_party/devtools.ts +++ b/src/third_party/devtools.ts @@ -29,3 +29,6 @@ export { createIssuesFromProtocolIssue, IssueAggregator, } from '../../node_modules/chrome-devtools-frontend/mcp/mcp.js'; +/* eslint-disable no-restricted-imports */ +export * as CrUXManager from '../../node_modules/chrome-devtools-frontend/front_end/models/crux-manager/crux-manager.js'; +/* eslint-enable no-restricted-imports */ diff --git a/src/tools/performance.ts b/src/tools/performance.ts index a8b24903c..9e053da24 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -5,7 +5,7 @@ */ import {logger} from '../logger.js'; -import {zod} from '../third_party/index.js'; +import {DevTools, zod} from '../third_party/index.js'; import type {Page} from '../third_party/index.js'; import type {InsightName} from '../trace-processing/parse.js'; import { @@ -14,6 +14,7 @@ import { parseRawTraceBuffer, traceResultIsSuccess, } from '../trace-processing/parse.js'; +import {populateCruxData} from '../utils/crux.js'; import {ToolCategory} from './categories.js'; import type {Context, Response} from './ToolDefinition.js'; @@ -161,6 +162,35 @@ export const analyzeInsight = defineTool({ }, }); +export const toggleCrux = defineTool({ + name: 'performance_toggle_crux', + description: + 'Enables or disables the fetching of real-user experience data from the Chrome User Experience Report (CrUX) API during performance traces. When enabled, performance summaries will include field data (LCP, INP, CLS) for the URLs in the trace.', + annotations: { + category: ToolCategory.PERFORMANCE, + readOnlyHint: false, + }, + schema: { + enabled: zod + .boolean() + .describe('Whether to enable or disable CrUX data fetching.'), + }, + handler: async (request, response) => { + try { + const settings = DevTools.Common.Settings.Settings.instance(); + const cruxSetting = settings.createSetting('field-data-enabled', true); + cruxSetting.set(request.params.enabled); + response.appendResponseLine( + `CrUX data fetching has been ${request.params.enabled ? 'enabled' : 'disabled'}.`, + ); + } catch { + response.appendResponseLine( + 'Error: Could not update the CrUX setting. It might not be available in this environment.', + ); + } + }, +}); + async function stopTracingAndAppendOutput( page: Page, response: Response, @@ -171,6 +201,7 @@ async function stopTracingAndAppendOutput( const result = await parseRawTraceBuffer(traceEventsBuffer); response.appendResponseLine('The performance trace has been stopped.'); if (traceResultIsSuccess(result)) { + await populateCruxData(result.parsedTrace); context.storeTraceRecording(result); const traceSummaryText = getTraceSummary(result); response.appendResponseLine(traceSummaryText); diff --git a/src/utils/crux.ts b/src/utils/crux.ts new file mode 100644 index 000000000..25fd7451c --- /dev/null +++ b/src/utils/crux.ts @@ -0,0 +1,218 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {logger} from '../logger.js'; +import {DevTools} from '../third_party/index.js'; + +// 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 type PageScope = 'url' | 'origin'; +export type DeviceScope = 'ALL' | 'DESKTOP' | 'PHONE' | 'TABLET'; + +export interface CrUXResponse { + record: { + key: { + url?: string; + origin?: string; + formFactor?: string; + }; + metrics: Record; + collectionPeriod: unknown; + }; +} + +const DEVICE_SCOPE_LIST: DeviceScope[] = ['ALL', 'DESKTOP', 'PHONE']; +const PAGE_SCOPE_LIST: PageScope[] = ['origin', 'url']; + +function mockCrUXManager(): void { + const originalInstance = DevTools.CrUXManager.CrUXManager.instance; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (DevTools.CrUXManager.CrUXManager as any).instance = (opts: any) => { + try { + return originalInstance.call(DevTools.CrUXManager.CrUXManager, opts); + } catch { + return { + getSelectedScope: () => ({pageScope: 'url', deviceScope: 'ALL'}), + }; + } + }; +} + +export function ensureCrUXManager(): void { + try { + // Ensure Settings instance + try { + DevTools.Common.Settings.Settings.instance(); + } catch { + const storage = new DevTools.Common.Settings.SettingsStorage({}); + DevTools.Common.Settings.Settings.instance({ + forceNew: true, + syncedStorage: storage, + globalStorage: storage, + localStorage: storage, + settingRegistrations: + DevTools.Common.SettingRegistration.getRegisteredSettings(), + }); + } + + // Ensure TargetManager instance + DevTools.TargetManager.instance(); + + // Ensure CrUXManager instance + DevTools.CrUXManager.CrUXManager.instance(); + } catch { + mockCrUXManager(); + } +} + +async function makeRequest(params: { + url?: string; + origin?: string; + formFactor?: string; +}): Promise { + try { + const response = await fetch(CRUX_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + referer: 'devtools://mcp', + }, + body: JSON.stringify(params), + }); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + logger(`CrUX API error: ${response.status} ${response.statusText}`); + return null; + } + + return (await response.json()) as CrUXResponse; + } catch (e) { + logger(`CrUX API fetch failed: ${e}`); + return null; + } +} + +export async function getFieldDataForPage( + pageUrl: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Promise { + const url = new URL(pageUrl); + url.hash = ''; + url.search = ''; + const normalizedUrl = url.href; + const origin = url.origin; + const hostname = url.hostname; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pageResult: any = { + 'origin-ALL': null, + 'origin-DESKTOP': null, + 'origin-PHONE': null, + 'origin-TABLET': null, + 'url-ALL': null, + 'url-DESKTOP': null, + 'url-PHONE': null, + 'url-TABLET': null, + warnings: [], + normalizedUrl, + }; + + if ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + !origin.startsWith('http') + ) { + return pageResult; + } + + const promises: Array> = []; + + for (const pageScope of PAGE_SCOPE_LIST) { + for (const deviceScope of DEVICE_SCOPE_LIST) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const params: any = { + metrics: [ + 'first_contentful_paint', + 'largest_contentful_paint', + 'cumulative_layout_shift', + 'interaction_to_next_paint', + 'round_trip_time', + 'form_factors', + 'largest_contentful_paint_image_time_to_first_byte', + 'largest_contentful_paint_image_resource_load_delay', + 'largest_contentful_paint_image_resource_load_duration', + 'largest_contentful_paint_image_element_render_delay', + ], + }; + if (pageScope === 'url') { + params.url = normalizedUrl; + } else { + params.origin = origin; + } + + if (deviceScope !== 'ALL') { + params.formFactor = deviceScope; + } + + const promise = makeRequest(params).then(response => { + pageResult[`${pageScope}-${deviceScope}`] = response; + }); + promises.push(promise); + } + } + + // Implement timeout + const timeoutPromise = new Promise(resolve => + setTimeout(resolve, 1000), + ); + await Promise.race([Promise.all(promises), timeoutPromise]); + + return pageResult; +} + +export async function populateCruxData( + parsedTrace: DevTools.TraceEngine.TraceModel.ParsedTrace, +): Promise { + ensureCrUXManager(); + try { + const settings = DevTools.Common.Settings.Settings.instance(); + const cruxSetting = settings.createSetting('field-data-enabled', true); + if (!cruxSetting.get()) { + return; + } + } catch { + // Fallback if settings are not available + } + + const urls = new Set(); + if (parsedTrace.insights) { + for (const insightSet of parsedTrace.insights.values()) { + urls.add(insightSet.url.href); + } + } else { + // Fallback to main frame URL if no insights + const mainUrl = parsedTrace.data.Meta.mainFrameURL; + if (mainUrl) { + urls.add(mainUrl); + } + } + + if (urls.size === 0) { + return; + } + + const cruxData = await Promise.all( + Array.from(urls).map(url => getFieldDataForPage(url)), + ); + + parsedTrace.metadata.cruxFieldData = cruxData; +} diff --git a/tests/utils/crux.test.ts b/tests/utils/crux.test.ts new file mode 100644 index 000000000..240345810 --- /dev/null +++ b/tests/utils/crux.test.ts @@ -0,0 +1,157 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {describe, it, afterEach} from 'node:test'; + +import sinon from 'sinon'; + +import {DevTools} from '../../src/third_party/index.js'; +import { + getTraceSummary, + parseRawTraceBuffer, + traceResultIsSuccess, +} from '../../src/trace-processing/parse.js'; +import {populateCruxData} from '../../src/utils/crux.js'; +import {loadTraceAsBuffer} from '../trace-processing/fixtures/load.js'; + +describe('crux util', () => { + afterEach(() => { + sinon.restore(); + }); + + it('summary includes crux metrics', async () => { + const rawData = loadTraceAsBuffer('basic-trace.json.gz'); + const result = await parseRawTraceBuffer(rawData); + if (!traceResultIsSuccess(result)) { + assert.fail('Failed to parse trace'); + } + + // Mock the URL to a non-localhost one so it doesn't get skipped + const targetUrl = 'https://developers.google.com/'; + if (result.insights && result.insights.size > 0) { + const firstInsightSet = result.insights.values().next().value; + if (firstInsightSet) { + firstInsightSet.url = new URL(targetUrl); + } + } else { + // If no insights, we need to add one or mock the main URL + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (result.parsedTrace.data.Meta as any).mainFrameURL = targetUrl; + } + + const mockResponse = { + record: { + key: {url: targetUrl}, + metrics: { + largest_contentful_paint: {percentiles: {p75: 1234}}, + interaction_to_next_paint: {percentiles: {p75: 123}}, + cumulative_layout_shift: {percentiles: {p75: 0.12}}, + }, + }, + }; + + sinon.stub(global, 'fetch').resolves({ + ok: true, + status: 200, + json: async () => mockResponse, + } as Response); + + // Mock CrUXManager to avoid initialization issues + const mockCrUXManager = { + getSelectedScope: () => ({pageScope: 'url', deviceScope: 'ALL'}), + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sinon + .stub(DevTools.CrUXManager.CrUXManager, 'instance') + .returns(mockCrUXManager as any); + + await populateCruxData(result.parsedTrace); + const summary = getTraceSummary(result); + + assert.ok(summary.includes('Metrics (field / real users):')); + assert.ok(summary.includes('LCP: 1234 ms')); + assert.ok(summary.includes('INP: 123 ms')); + assert.ok(summary.includes('CLS: 0.12')); + }); + + it('populates cruxFieldData in metadata', async () => { + const fakeParsedTrace = { + insights: new Map([ + [ + 'NAVIGATION_0', + { + url: new URL('https://example.com'), + }, + ], + ]), + metadata: {}, + data: { + Meta: { + mainFrameURL: 'https://example.com', + }, + }, + } as unknown as DevTools.TraceEngine.TraceModel.ParsedTrace; + + const mockResponse = { + record: { + key: {url: 'https://example.com/'}, + metrics: { + largest_contentful_paint: {percentiles: {p75: 1000}}, + }, + }, + }; + + const fetchStub = sinon.stub(global, 'fetch').resolves({ + ok: true, + status: 200, + json: async () => mockResponse, + } as Response); + + await populateCruxData(fakeParsedTrace); + + assert.ok(fakeParsedTrace.metadata.cruxFieldData); + assert.strictEqual(fakeParsedTrace.metadata.cruxFieldData.length, 1); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const firstResult = fakeParsedTrace.metadata.cruxFieldData[0] as any; + assert.strictEqual( + firstResult['url-ALL'].record.key.url, + 'https://example.com/', + ); + + // Check that fetch was called multiple times (for different scopes/device scopes) + // 2 (url, origin) * 3 (ALL, DESKTOP, PHONE) = 6 calls per URL + assert.strictEqual(fetchStub.callCount, 6); + }); + + it('handles 404 from CrUX API', async () => { + const fakeParsedTrace = { + insights: new Map([ + [ + 'NAVIGATION_0', + { + url: new URL('https://nonexistent.com'), + }, + ], + ]), + metadata: {}, + } as unknown as DevTools.TraceEngine.TraceModel.ParsedTrace; + + sinon.stub(global, 'fetch').resolves({ + ok: false, + status: 404, + } as Response); + + await populateCruxData(fakeParsedTrace); + + assert.ok(fakeParsedTrace.metadata.cruxFieldData); + assert.strictEqual(fakeParsedTrace.metadata.cruxFieldData.length, 1); + assert.strictEqual( + fakeParsedTrace.metadata.cruxFieldData[0]['url-ALL'], + null, + ); + }); +}); From 31a6cf1ec55143f80db7385ec34deaefc4f82360 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Mon, 22 Dec 2025 16:21:24 -0800 Subject: [PATCH 03/16] collapsed to a single setting Change-Id: I3886ba86705ce423ccf8024133bc58fff16fa6c8 --- src/tools/performance.ts | 8 ++++++-- src/utils/crux.ts | 9 +++++++-- tests/utils/crux.test.ts | 10 ++++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/tools/performance.ts b/src/tools/performance.ts index 9e053da24..ce58fd80f 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -178,8 +178,12 @@ export const toggleCrux = defineTool({ handler: async (request, response) => { try { const settings = DevTools.Common.Settings.Settings.instance(); - const cruxSetting = settings.createSetting('field-data-enabled', true); - cruxSetting.set(request.params.enabled); + const cruxSetting = settings.createSetting( + 'field-data', + {enabled: false}, + DevTools.Common.Settings.SettingStorageType.GLOBAL, + ); + cruxSetting.set({...cruxSetting.get(), enabled: request.params.enabled}); response.appendResponseLine( `CrUX data fetching has been ${request.params.enabled ? 'enabled' : 'disabled'}.`, ); diff --git a/src/utils/crux.ts b/src/utils/crux.ts index 25fd7451c..ebecc3cf3 100644 --- a/src/utils/crux.ts +++ b/src/utils/crux.ts @@ -185,8 +185,13 @@ export async function populateCruxData( ensureCrUXManager(); try { const settings = DevTools.Common.Settings.Settings.instance(); - const cruxSetting = settings.createSetting('field-data-enabled', true); - if (!cruxSetting.get()) { + const cruxSetting = settings.createSetting( + 'field-data', + {enabled: false}, + DevTools.Common.Settings.SettingStorageType.GLOBAL, + ); + + if (!cruxSetting.get().enabled) { return; } } catch { diff --git a/tests/utils/crux.test.ts b/tests/utils/crux.test.ts index 240345810..3e370dc0b 100644 --- a/tests/utils/crux.test.ts +++ b/tests/utils/crux.test.ts @@ -16,6 +16,7 @@ import { traceResultIsSuccess, } from '../../src/trace-processing/parse.js'; import {populateCruxData} from '../../src/utils/crux.js'; +import {ensureCrUXManager} from '../../src/utils/crux.js'; import {loadTraceAsBuffer} from '../trace-processing/fixtures/load.js'; describe('crux util', () => { @@ -69,6 +70,9 @@ describe('crux util', () => { .stub(DevTools.CrUXManager.CrUXManager, 'instance') .returns(mockCrUXManager as any); + const settings = DevTools.Common.Settings.Settings.instance(); + settings.createSetting('field-data', {enabled: false}).set({enabled: true}); + await populateCruxData(result.parsedTrace); const summary = getTraceSummary(result); @@ -111,6 +115,9 @@ describe('crux util', () => { json: async () => mockResponse, } as Response); + const settings = DevTools.Common.Settings.Settings.instance(); + settings.createSetting('field-data', {enabled: false}).set({enabled: true}); + await populateCruxData(fakeParsedTrace); assert.ok(fakeParsedTrace.metadata.cruxFieldData); @@ -145,6 +152,9 @@ describe('crux util', () => { status: 404, } as Response); + const settings = DevTools.Common.Settings.Settings.instance(); + settings.createSetting('field-data', {enabled: false}).set({enabled: true}); + await populateCruxData(fakeParsedTrace); assert.ok(fakeParsedTrace.metadata.cruxFieldData); From 1f177415e1cd50a14bbb3a34a446fc1cc7a6643b Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Mon, 22 Dec 2025 17:03:21 -0800 Subject: [PATCH 04/16] remove toggle tool. bump up timeouts while i investigate them Change-Id: I921880b40788863151962d13dc9838c4793a840f --- README.md | 3 +-- docs/tool-reference.md | 13 +------------ src/McpContext.ts | 4 ++-- src/tools/performance.ts | 35 +---------------------------------- tests/utils/crux.test.ts | 3 ++- 5 files changed, 7 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index febd36e3a..67ff740b4 100644 --- a/README.md +++ b/README.md @@ -333,11 +333,10 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles - **Emulation** (2 tools) - [`emulate`](docs/tool-reference.md#emulate) - [`resize_page`](docs/tool-reference.md#resize_page) -- **Performance** (4 tools) +- **Performance** (3 tools) - [`performance_analyze_insight`](docs/tool-reference.md#performance_analyze_insight) - [`performance_start_trace`](docs/tool-reference.md#performance_start_trace) - [`performance_stop_trace`](docs/tool-reference.md#performance_stop_trace) - - [`performance_toggle_crux`](docs/tool-reference.md#performance_toggle_crux) - **Network** (2 tools) - [`get_network_request`](docs/tool-reference.md#get_network_request) - [`list_network_requests`](docs/tool-reference.md#list_network_requests) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index baef40b78..618a1f486 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -21,11 +21,10 @@ - **[Emulation](#emulation)** (2 tools) - [`emulate`](#emulate) - [`resize_page`](#resize_page) -- **[Performance](#performance)** (4 tools) +- **[Performance](#performance)** (3 tools) - [`performance_analyze_insight`](#performance_analyze_insight) - [`performance_start_trace`](#performance_start_trace) - [`performance_stop_trace`](#performance_stop_trace) - - [`performance_toggle_crux`](#performance_toggle_crux) - **[Network](#network)** (2 tools) - [`get_network_request`](#get_network_request) - [`list_network_requests`](#list_network_requests) @@ -246,16 +245,6 @@ --- -### `performance_toggle_crux` - -**Description:** Enables or disables the fetching of real-user experience data from the Chrome User Experience Report (CrUX) API during performance traces. When enabled, performance summaries will include field data (LCP, INP, CLS) for the URLs in the trace. - -**Parameters:** - -- **enabled** (boolean) **(required)**: Whether to enable or disable CrUX data fetching. - ---- - ## Network ### `get_network_request` diff --git a/src/McpContext.ts b/src/McpContext.ts index 11bb3d971..8b07e4e6d 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -60,8 +60,8 @@ interface McpContextOptions { experimentalIncludeAllPages?: boolean; } -const DEFAULT_TIMEOUT = 5_000; -const NAVIGATION_TIMEOUT = 10_000; +const DEFAULT_TIMEOUT = 30_000; +const NAVIGATION_TIMEOUT = 60_000; function getNetworkMultiplierFromString(condition: string | null): number { const puppeteerCondition = diff --git a/src/tools/performance.ts b/src/tools/performance.ts index ce58fd80f..6ec963ec1 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -5,7 +5,7 @@ */ import {logger} from '../logger.js'; -import {DevTools, zod} from '../third_party/index.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 { @@ -162,39 +162,6 @@ export const analyzeInsight = defineTool({ }, }); -export const toggleCrux = defineTool({ - name: 'performance_toggle_crux', - description: - 'Enables or disables the fetching of real-user experience data from the Chrome User Experience Report (CrUX) API during performance traces. When enabled, performance summaries will include field data (LCP, INP, CLS) for the URLs in the trace.', - annotations: { - category: ToolCategory.PERFORMANCE, - readOnlyHint: false, - }, - schema: { - enabled: zod - .boolean() - .describe('Whether to enable or disable CrUX data fetching.'), - }, - handler: async (request, response) => { - try { - const settings = DevTools.Common.Settings.Settings.instance(); - const cruxSetting = settings.createSetting( - 'field-data', - {enabled: false}, - DevTools.Common.Settings.SettingStorageType.GLOBAL, - ); - cruxSetting.set({...cruxSetting.get(), enabled: request.params.enabled}); - response.appendResponseLine( - `CrUX data fetching has been ${request.params.enabled ? 'enabled' : 'disabled'}.`, - ); - } catch { - response.appendResponseLine( - 'Error: Could not update the CrUX setting. It might not be available in this environment.', - ); - } - }, -}); - async function stopTracingAndAppendOutput( page: Page, response: Response, diff --git a/tests/utils/crux.test.ts b/tests/utils/crux.test.ts index 3e370dc0b..4e8a18876 100644 --- a/tests/utils/crux.test.ts +++ b/tests/utils/crux.test.ts @@ -65,9 +65,10 @@ describe('crux util', () => { const mockCrUXManager = { getSelectedScope: () => ({pageScope: 'url', deviceScope: 'ALL'}), }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any + sinon .stub(DevTools.CrUXManager.CrUXManager, 'instance') + // eslint-disable-next-line @typescript-eslint/no-explicit-any .returns(mockCrUXManager as any); const settings = DevTools.Common.Settings.Settings.instance(); From de66b445cdb6e830e6bc1682bfd890e4d34d3612 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Mon, 22 Dec 2025 17:21:10 -0800 Subject: [PATCH 05/16] ensureCrux for test Change-Id: I94c55d08bad7c58b28a5febccdc126dc0b012e5f --- src/utils/crux.ts | 23 ++++++++++------------- tests/utils/crux.test.ts | 4 +++- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/utils/crux.ts b/src/utils/crux.ts index ebecc3cf3..00031e3c5 100644 --- a/src/utils/crux.ts +++ b/src/utils/crux.ts @@ -183,19 +183,16 @@ export async function populateCruxData( parsedTrace: DevTools.TraceEngine.TraceModel.ParsedTrace, ): Promise { ensureCrUXManager(); - try { - const settings = DevTools.Common.Settings.Settings.instance(); - const cruxSetting = settings.createSetting( - 'field-data', - {enabled: false}, - DevTools.Common.Settings.SettingStorageType.GLOBAL, - ); - - if (!cruxSetting.get().enabled) { - return; - } - } catch { - // Fallback if settings are not available + + const settings = DevTools.Common.Settings.Settings.instance(); + const cruxSetting = settings.createSetting( + 'field-data', + {enabled: true}, + DevTools.Common.Settings.SettingStorageType.GLOBAL, + ); + + if (!cruxSetting.get().enabled) { + return; } const urls = new Set(); diff --git a/tests/utils/crux.test.ts b/tests/utils/crux.test.ts index 4e8a18876..619319723 100644 --- a/tests/utils/crux.test.ts +++ b/tests/utils/crux.test.ts @@ -5,7 +5,7 @@ */ import assert from 'node:assert'; -import {describe, it, afterEach} from 'node:test'; +import {describe, it, afterEach, before} from 'node:test'; import sinon from 'sinon'; @@ -20,6 +20,8 @@ import {ensureCrUXManager} from '../../src/utils/crux.js'; import {loadTraceAsBuffer} from '../trace-processing/fixtures/load.js'; describe('crux util', () => { + before(() => ensureCrUXManager()); + afterEach(() => { sinon.restore(); }); From a2894a97e3f05e3a8c0a6a71f0f270498d42aa07 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Mon, 22 Dec 2025 17:42:52 -0800 Subject: [PATCH 06/16] manual test yayyyyyyyyyyy Change-Id: I70fa78a46ebb1bc43be66dcd80177cec2ab34f1c --- src/manual-perf-test.ts | 113 ++++++++++++++++++++++++++++++++ src/tools/performance.ts | 2 +- tests/tools/performance.test.ts | 2 +- 3 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 src/manual-perf-test.ts diff --git a/src/manual-perf-test.ts b/src/manual-perf-test.ts new file mode 100644 index 000000000..1e59f4b32 --- /dev/null +++ b/src/manual-perf-test.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import puppeteer, {Locator} from 'puppeteer'; +import logger from 'debug'; +import {McpContext} from './McpContext.js'; +import {McpResponse} from './McpResponse.js'; +import {startTrace} from './tools/performance.js'; + +async function run() { + console.log('Launching browser...'); + const browser = await puppeteer.launch({ + headless: false, // Visible for manual observation if needed + defaultViewport: null, + handleDevToolsAsPage: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], // Useful for some envs + }); + + try { + const page = await browser.newPage(); + // Close other pages (like about:blank opened by default if any) + const pages = await browser.pages(); + for (const p of pages) { + if (p !== page) await p.close(); + } + + console.log('Setting up McpContext...'); + const context = await McpContext.from( + browser, + logger('test'), + { + experimentalDevToolsDebugging: false, + }, + Locator, + ); + + // Ensure we have a page selected (McpContext selects one by default but good to be sure) + console.log('Context initialized.'); + + // Pre-navigate to something so we can reload it + const targetUrl = 'https://example.com'; + console.log(`Navigating to ${targetUrl}...`); + await context.getSelectedPage().goto(targetUrl); + + console.log('Starting trace with autoStop: true, reload: true...'); + const response = new McpResponse(); + const request = { + params: { + reload: true, + autoStop: true, + }, + }; + + // startTrace.handler is async. It waits for the trace to finish if autoStop is true. + await startTrace.handler(request, response, context); + + console.log('Trace handler returned.'); + + // Assertions + const lines = response.responseLines; + + // Check for stop message + const stoppedMsg = lines.find(l => + l.includes('The performance trace has been stopped'), + ); + if (!stoppedMsg) { + console.error('Response lines:', lines); + throw new Error('FAILED: Did not find stop message'); + } else { + console.log('Verified: Stop message found.'); + } + + // Check if context thinks it is running + const isRunning = context.isRunningPerformanceTrace(); + if (isRunning) { + throw new Error('FAILED: Trace is still marked as running in context'); + } else { + console.log('Verified: Context marks trace as stopped.'); + } + + // Check if trace is recorded + const traces = context.recordedTraces(); + if (traces.length !== 1) { + throw new Error( + `FAILED: Expected 1 recorded trace, got ${traces.length}`, + ); + } else { + console.log('Verified: 1 trace recorded.'); + } + + // Check trace content (basic check) + const traceResult = traces[0]; + // We assume traceResult is valid if we got here and storeTraceRecording was called. + // startTrace.handler calls storeTraceRecording only on success. + console.log( + 'Trace result summary:', + lines.find(l => l.includes('Trace duration')), + ); + + console.log('SUCCESS: Trace recorded and stopped automatically.'); + } catch (err) { + console.error('Test FAILED with error:', err); + process.exit(1); + } finally { + console.log('Closing browser...'); + await browser.close(); + } +} + +run(); diff --git a/src/tools/performance.ts b/src/tools/performance.ts index 6ec963ec1..0c2bcc972 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -172,7 +172,7 @@ async function stopTracingAndAppendOutput( const result = await parseRawTraceBuffer(traceEventsBuffer); response.appendResponseLine('The performance trace has been stopped.'); if (traceResultIsSuccess(result)) { - await populateCruxData(result.parsedTrace); + // await populateCruxData(result.parsedTrace); context.storeTraceRecording(result); const traceSummaryText = getTraceSummary(result); response.appendResponseLine(traceSummaryText); diff --git a/tests/tools/performance.test.ts b/tests/tools/performance.test.ts index cb390b33a..9e98088e7 100644 --- a/tests/tools/performance.test.ts +++ b/tests/tools/performance.test.ts @@ -75,7 +75,7 @@ describe('performance', () => { }); }); - it('can autostop and store a recording', async () => { + it.only('can autostop and store a recording', async () => { const rawData = loadTraceAsBuffer('basic-trace.json.gz'); await withMcpContext(async (response, context) => { From e2da11ada63b988393f86db62b8de28f5a45b8a2 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Mon, 22 Dec 2025 17:47:55 -0800 Subject: [PATCH 07/16] universe mgr added to ctx Change-Id: Id8ff5120b5f02cd57ed42427b182763afbf65dff --- src/McpContext.ts | 10 +++++++++- src/manual-perf-test.ts | 9 +++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/McpContext.ts b/src/McpContext.ts index 8b07e4e6d..ce5bd49a6 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -8,7 +8,11 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import {extractUrlLikeFromDevToolsTitle, urlsEqual} from './DevtoolsUtils.js'; +import { + extractUrlLikeFromDevToolsTitle, + urlsEqual, + UniverseManager, +} from './DevtoolsUtils.js'; import type {ListenerMap} from './PageCollector.js'; import {NetworkCollector, ConsoleCollector} from './PageCollector.js'; import {Locator} from './third_party/index.js'; @@ -113,6 +117,7 @@ export class McpContext implements Context { #nextSnapshotId = 1; #traceResults: TraceResult[] = []; + #universeManager: UniverseManager; #locatorClass: typeof Locator; #options: McpContextOptions; @@ -127,6 +132,7 @@ export class McpContext implements Context { this.logger = logger; this.#locatorClass = locatorClass; this.#options = options; + this.#universeManager = new UniverseManager(this.browser); this.#networkCollector = new NetworkCollector(this.browser); @@ -153,11 +159,13 @@ export class McpContext implements Context { async #init() { const pages = await this.createPagesSnapshot(); + await this.#universeManager.init(pages); await this.#networkCollector.init(pages); await this.#consoleCollector.init(pages); } dispose() { + this.#universeManager.dispose(); this.#networkCollector.dispose(); this.#consoleCollector.dispose(); } diff --git a/src/manual-perf-test.ts b/src/manual-perf-test.ts index 1e59f4b32..e48734b20 100644 --- a/src/manual-perf-test.ts +++ b/src/manual-perf-test.ts @@ -93,12 +93,9 @@ async function run() { // Check trace content (basic check) const traceResult = traces[0]; - // We assume traceResult is valid if we got here and storeTraceRecording was called. - // startTrace.handler calls storeTraceRecording only on success. - console.log( - 'Trace result summary:', - lines.find(l => l.includes('Trace duration')), - ); + console.log('--- Response Lines ---'); + console.log(lines.join('\n')); + console.log('----------------------'); console.log('SUCCESS: Trace recorded and stopped automatically.'); } catch (err) { From 7e71d8f8814532af6e7edf832883c068cd19bd15 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Mon, 22 Dec 2025 17:52:09 -0800 Subject: [PATCH 08/16] settings is resolve but now we need to give cruxmgr some data to report Change-Id: I22e211eebdca6bf25878cb4dd4182d9b6ca34cf8 --- src/manual-perf-test.ts | 2 +- src/tools/performance.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/manual-perf-test.ts b/src/manual-perf-test.ts index e48734b20..de25c61e5 100644 --- a/src/manual-perf-test.ts +++ b/src/manual-perf-test.ts @@ -41,7 +41,7 @@ async function run() { console.log('Context initialized.'); // Pre-navigate to something so we can reload it - const targetUrl = 'https://example.com'; + const targetUrl = 'https://web.dev'; console.log(`Navigating to ${targetUrl}...`); await context.getSelectedPage().goto(targetUrl); diff --git a/src/tools/performance.ts b/src/tools/performance.ts index 0c2bcc972..6ec963ec1 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -172,7 +172,7 @@ async function stopTracingAndAppendOutput( const result = await parseRawTraceBuffer(traceEventsBuffer); response.appendResponseLine('The performance trace has been stopped.'); if (traceResultIsSuccess(result)) { - // await populateCruxData(result.parsedTrace); + await populateCruxData(result.parsedTrace); context.storeTraceRecording(result); const traceSummaryText = getTraceSummary(result); response.appendResponseLine(traceSummaryText); From 647f0d9ce05dc1aa1572e223641c89071854cab2 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Mon, 22 Dec 2025 17:55:44 -0800 Subject: [PATCH 09/16] drop util crux. trying to get proper data from real thing Change-Id: Ie1905a1b746cce82b27dfe89c56d2b591c050291 --- src/tools/performance.ts | 52 ++++++++- src/utils/crux.ts | 220 --------------------------------------- tests/utils/crux.test.ts | 170 ------------------------------ 3 files changed, 48 insertions(+), 394 deletions(-) delete mode 100644 src/utils/crux.ts delete mode 100644 tests/utils/crux.test.ts diff --git a/src/tools/performance.ts b/src/tools/performance.ts index 6ec963ec1..e678358f7 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -5,16 +5,15 @@ */ import {logger} from '../logger.js'; -import {zod} from '../third_party/index.js'; +import {zod, DevTools} from '../third_party/index.js'; import type {Page} from '../third_party/index.js'; -import type {InsightName} from '../trace-processing/parse.js'; +import type {InsightName, TraceResult} from '../trace-processing/parse.js'; import { getInsightOutput, getTraceSummary, parseRawTraceBuffer, traceResultIsSuccess, } from '../trace-processing/parse.js'; -import {populateCruxData} from '../utils/crux.js'; import {ToolCategory} from './categories.js'; import type {Context, Response} from './ToolDefinition.js'; @@ -172,7 +171,7 @@ async function stopTracingAndAppendOutput( const result = await parseRawTraceBuffer(traceEventsBuffer); response.appendResponseLine('The performance trace has been stopped.'); if (traceResultIsSuccess(result)) { - await populateCruxData(result.parsedTrace); + await populateCruxData(result); context.storeTraceRecording(result); const traceSummaryText = getTraceSummary(result); response.appendResponseLine(traceSummaryText); @@ -193,3 +192,48 @@ async function stopTracingAndAppendOutput( context.setIsRunningPerformanceTrace(false); } } + +async function populateCruxData(result: TraceResult): Promise { + try { + logger('populateCruxData called'); + const cruxManager = DevTools.CrUXManager.CrUXManager.instance(); + const settings = DevTools.Common.Settings.Settings.instance(); + const cruxSetting = settings.createSetting('field-data', {enabled: true}); + cruxSetting.set({...cruxSetting.get(), enabled: true}); + + if (!cruxSetting.get().enabled) { + logger('CrUX is disabled in settings'); + return; + } + + const urls = new Set(); + if (result.insights) { + for (const insightSet of result.insights.values()) { + urls.add(insightSet.url.href); + } + } else { + const mainUrl = result.parsedTrace.data.Meta.mainFrameURL; + if (mainUrl) { + urls.add(mainUrl); + } + } + + if (urls.size === 0) { + logger('No URLs found for CrUX data'); + return; + } + + logger(`Fetching CrUX data for ${urls.size} URLs: ${Array.from(urls).join(', ')}`); + const cruxData = await Promise.all( + Array.from(urls).map(async url => { + const data = await cruxManager.getFieldDataForPage(url); + logger(`CrUX data for ${url}: ${data ? 'found' : 'not found'}`); + return data; + }), + ); + + result.parsedTrace.metadata.cruxFieldData = cruxData; + } catch (err) { + logger('Error populating CrUX data:', err); + } +} diff --git a/src/utils/crux.ts b/src/utils/crux.ts deleted file mode 100644 index 00031e3c5..000000000 --- a/src/utils/crux.ts +++ /dev/null @@ -1,220 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {logger} from '../logger.js'; -import {DevTools} from '../third_party/index.js'; - -// 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 type PageScope = 'url' | 'origin'; -export type DeviceScope = 'ALL' | 'DESKTOP' | 'PHONE' | 'TABLET'; - -export interface CrUXResponse { - record: { - key: { - url?: string; - origin?: string; - formFactor?: string; - }; - metrics: Record; - collectionPeriod: unknown; - }; -} - -const DEVICE_SCOPE_LIST: DeviceScope[] = ['ALL', 'DESKTOP', 'PHONE']; -const PAGE_SCOPE_LIST: PageScope[] = ['origin', 'url']; - -function mockCrUXManager(): void { - const originalInstance = DevTools.CrUXManager.CrUXManager.instance; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (DevTools.CrUXManager.CrUXManager as any).instance = (opts: any) => { - try { - return originalInstance.call(DevTools.CrUXManager.CrUXManager, opts); - } catch { - return { - getSelectedScope: () => ({pageScope: 'url', deviceScope: 'ALL'}), - }; - } - }; -} - -export function ensureCrUXManager(): void { - try { - // Ensure Settings instance - try { - DevTools.Common.Settings.Settings.instance(); - } catch { - const storage = new DevTools.Common.Settings.SettingsStorage({}); - DevTools.Common.Settings.Settings.instance({ - forceNew: true, - syncedStorage: storage, - globalStorage: storage, - localStorage: storage, - settingRegistrations: - DevTools.Common.SettingRegistration.getRegisteredSettings(), - }); - } - - // Ensure TargetManager instance - DevTools.TargetManager.instance(); - - // Ensure CrUXManager instance - DevTools.CrUXManager.CrUXManager.instance(); - } catch { - mockCrUXManager(); - } -} - -async function makeRequest(params: { - url?: string; - origin?: string; - formFactor?: string; -}): Promise { - try { - const response = await fetch(CRUX_ENDPOINT, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - referer: 'devtools://mcp', - }, - body: JSON.stringify(params), - }); - - if (response.status === 404) { - return null; - } - - if (!response.ok) { - logger(`CrUX API error: ${response.status} ${response.statusText}`); - return null; - } - - return (await response.json()) as CrUXResponse; - } catch (e) { - logger(`CrUX API fetch failed: ${e}`); - return null; - } -} - -export async function getFieldDataForPage( - pageUrl: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): Promise { - const url = new URL(pageUrl); - url.hash = ''; - url.search = ''; - const normalizedUrl = url.href; - const origin = url.origin; - const hostname = url.hostname; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const pageResult: any = { - 'origin-ALL': null, - 'origin-DESKTOP': null, - 'origin-PHONE': null, - 'origin-TABLET': null, - 'url-ALL': null, - 'url-DESKTOP': null, - 'url-PHONE': null, - 'url-TABLET': null, - warnings: [], - normalizedUrl, - }; - - if ( - hostname === 'localhost' || - hostname === '127.0.0.1' || - !origin.startsWith('http') - ) { - return pageResult; - } - - const promises: Array> = []; - - for (const pageScope of PAGE_SCOPE_LIST) { - for (const deviceScope of DEVICE_SCOPE_LIST) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const params: any = { - metrics: [ - 'first_contentful_paint', - 'largest_contentful_paint', - 'cumulative_layout_shift', - 'interaction_to_next_paint', - 'round_trip_time', - 'form_factors', - 'largest_contentful_paint_image_time_to_first_byte', - 'largest_contentful_paint_image_resource_load_delay', - 'largest_contentful_paint_image_resource_load_duration', - 'largest_contentful_paint_image_element_render_delay', - ], - }; - if (pageScope === 'url') { - params.url = normalizedUrl; - } else { - params.origin = origin; - } - - if (deviceScope !== 'ALL') { - params.formFactor = deviceScope; - } - - const promise = makeRequest(params).then(response => { - pageResult[`${pageScope}-${deviceScope}`] = response; - }); - promises.push(promise); - } - } - - // Implement timeout - const timeoutPromise = new Promise(resolve => - setTimeout(resolve, 1000), - ); - await Promise.race([Promise.all(promises), timeoutPromise]); - - return pageResult; -} - -export async function populateCruxData( - parsedTrace: DevTools.TraceEngine.TraceModel.ParsedTrace, -): Promise { - ensureCrUXManager(); - - const settings = DevTools.Common.Settings.Settings.instance(); - const cruxSetting = settings.createSetting( - 'field-data', - {enabled: true}, - DevTools.Common.Settings.SettingStorageType.GLOBAL, - ); - - if (!cruxSetting.get().enabled) { - return; - } - - const urls = new Set(); - if (parsedTrace.insights) { - for (const insightSet of parsedTrace.insights.values()) { - urls.add(insightSet.url.href); - } - } else { - // Fallback to main frame URL if no insights - const mainUrl = parsedTrace.data.Meta.mainFrameURL; - if (mainUrl) { - urls.add(mainUrl); - } - } - - if (urls.size === 0) { - return; - } - - const cruxData = await Promise.all( - Array.from(urls).map(url => getFieldDataForPage(url)), - ); - - parsedTrace.metadata.cruxFieldData = cruxData; -} diff --git a/tests/utils/crux.test.ts b/tests/utils/crux.test.ts deleted file mode 100644 index 619319723..000000000 --- a/tests/utils/crux.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import {describe, it, afterEach, before} from 'node:test'; - -import sinon from 'sinon'; - -import {DevTools} from '../../src/third_party/index.js'; -import { - getTraceSummary, - parseRawTraceBuffer, - traceResultIsSuccess, -} from '../../src/trace-processing/parse.js'; -import {populateCruxData} from '../../src/utils/crux.js'; -import {ensureCrUXManager} from '../../src/utils/crux.js'; -import {loadTraceAsBuffer} from '../trace-processing/fixtures/load.js'; - -describe('crux util', () => { - before(() => ensureCrUXManager()); - - afterEach(() => { - sinon.restore(); - }); - - it('summary includes crux metrics', async () => { - const rawData = loadTraceAsBuffer('basic-trace.json.gz'); - const result = await parseRawTraceBuffer(rawData); - if (!traceResultIsSuccess(result)) { - assert.fail('Failed to parse trace'); - } - - // Mock the URL to a non-localhost one so it doesn't get skipped - const targetUrl = 'https://developers.google.com/'; - if (result.insights && result.insights.size > 0) { - const firstInsightSet = result.insights.values().next().value; - if (firstInsightSet) { - firstInsightSet.url = new URL(targetUrl); - } - } else { - // If no insights, we need to add one or mock the main URL - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (result.parsedTrace.data.Meta as any).mainFrameURL = targetUrl; - } - - const mockResponse = { - record: { - key: {url: targetUrl}, - metrics: { - largest_contentful_paint: {percentiles: {p75: 1234}}, - interaction_to_next_paint: {percentiles: {p75: 123}}, - cumulative_layout_shift: {percentiles: {p75: 0.12}}, - }, - }, - }; - - sinon.stub(global, 'fetch').resolves({ - ok: true, - status: 200, - json: async () => mockResponse, - } as Response); - - // Mock CrUXManager to avoid initialization issues - const mockCrUXManager = { - getSelectedScope: () => ({pageScope: 'url', deviceScope: 'ALL'}), - }; - - sinon - .stub(DevTools.CrUXManager.CrUXManager, 'instance') - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .returns(mockCrUXManager as any); - - const settings = DevTools.Common.Settings.Settings.instance(); - settings.createSetting('field-data', {enabled: false}).set({enabled: true}); - - await populateCruxData(result.parsedTrace); - const summary = getTraceSummary(result); - - assert.ok(summary.includes('Metrics (field / real users):')); - assert.ok(summary.includes('LCP: 1234 ms')); - assert.ok(summary.includes('INP: 123 ms')); - assert.ok(summary.includes('CLS: 0.12')); - }); - - it('populates cruxFieldData in metadata', async () => { - const fakeParsedTrace = { - insights: new Map([ - [ - 'NAVIGATION_0', - { - url: new URL('https://example.com'), - }, - ], - ]), - metadata: {}, - data: { - Meta: { - mainFrameURL: 'https://example.com', - }, - }, - } as unknown as DevTools.TraceEngine.TraceModel.ParsedTrace; - - const mockResponse = { - record: { - key: {url: 'https://example.com/'}, - metrics: { - largest_contentful_paint: {percentiles: {p75: 1000}}, - }, - }, - }; - - const fetchStub = sinon.stub(global, 'fetch').resolves({ - ok: true, - status: 200, - json: async () => mockResponse, - } as Response); - - const settings = DevTools.Common.Settings.Settings.instance(); - settings.createSetting('field-data', {enabled: false}).set({enabled: true}); - - await populateCruxData(fakeParsedTrace); - - assert.ok(fakeParsedTrace.metadata.cruxFieldData); - assert.strictEqual(fakeParsedTrace.metadata.cruxFieldData.length, 1); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const firstResult = fakeParsedTrace.metadata.cruxFieldData[0] as any; - assert.strictEqual( - firstResult['url-ALL'].record.key.url, - 'https://example.com/', - ); - - // Check that fetch was called multiple times (for different scopes/device scopes) - // 2 (url, origin) * 3 (ALL, DESKTOP, PHONE) = 6 calls per URL - assert.strictEqual(fetchStub.callCount, 6); - }); - - it('handles 404 from CrUX API', async () => { - const fakeParsedTrace = { - insights: new Map([ - [ - 'NAVIGATION_0', - { - url: new URL('https://nonexistent.com'), - }, - ], - ]), - metadata: {}, - } as unknown as DevTools.TraceEngine.TraceModel.ParsedTrace; - - sinon.stub(global, 'fetch').resolves({ - ok: false, - status: 404, - } as Response); - - const settings = DevTools.Common.Settings.Settings.instance(); - settings.createSetting('field-data', {enabled: false}).set({enabled: true}); - - await populateCruxData(fakeParsedTrace); - - assert.ok(fakeParsedTrace.metadata.cruxFieldData); - assert.strictEqual(fakeParsedTrace.metadata.cruxFieldData.length, 1); - assert.strictEqual( - fakeParsedTrace.metadata.cruxFieldData[0]['url-ALL'], - null, - ); - }); -}); From eb5bc5141d01d9d98a0930d915e021429db77cbc Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Mon, 22 Dec 2025 18:11:07 -0800 Subject: [PATCH 10/16] new endpoint. will work when i tweak api in GCP C Change-Id: I331aa991818b151e7b4080490475736afd30120f --- src/tools/performance.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/tools/performance.ts b/src/tools/performance.ts index e678358f7..99585b02e 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -197,9 +197,11 @@ async function populateCruxData(result: TraceResult): Promise { try { logger('populateCruxData called'); const cruxManager = DevTools.CrUXManager.CrUXManager.instance(); + cruxManager.setEndpointForTesting( + 'https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=AIzaSyBn5gimNjhiEyA_euicSKko6IlD3HdgUfk', + ); const settings = DevTools.Common.Settings.Settings.instance(); const cruxSetting = settings.createSetting('field-data', {enabled: true}); - cruxSetting.set({...cruxSetting.get(), enabled: true}); if (!cruxSetting.get().enabled) { logger('CrUX is disabled in settings'); @@ -223,7 +225,9 @@ async function populateCruxData(result: TraceResult): Promise { return; } - logger(`Fetching CrUX data for ${urls.size} URLs: ${Array.from(urls).join(', ')}`); + logger( + `Fetching CrUX data for ${urls.size} URLs: ${Array.from(urls).join(', ')}`, + ); const cruxData = await Promise.all( Array.from(urls).map(async url => { const data = await cruxManager.getFieldDataForPage(url); From 7d8ae17399f887dfac0f6021026e5b70571db7e2 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Tue, 6 Jan 2026 12:31:57 -0800 Subject: [PATCH 11/16] works Change-Id: I2b5dcda9b32db8ec0ca1270e267edfeb562ff7b5 --- package-lock.json | 12 +++- src/tools/performance.ts | 77 ++++++++++-------------- tests/McpResponse.test.js.snapshot | 2 +- tests/tools/performance.test.js.snapshot | 14 ++++- tests/tools/performance.test.ts | 2 +- 5 files changed, 58 insertions(+), 49 deletions(-) diff --git a/package-lock.json b/package-lock.json index d624cb8d4..acdb47ed0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1226,6 +1226,7 @@ "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", @@ -1700,6 +1701,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2607,7 +2609,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz", "integrity": "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/diff": { "version": "8.0.2", @@ -2908,6 +2911,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3078,6 +3082,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3348,6 +3353,7 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5529,6 +5535,7 @@ "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -6497,6 +6504,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6572,6 +6580,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -6870,6 +6879,7 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/tools/performance.ts b/src/tools/performance.ts index 99585b02e..58ef8ae88 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -194,50 +194,37 @@ async function stopTracingAndAppendOutput( } async function populateCruxData(result: TraceResult): Promise { - try { - logger('populateCruxData called'); - const cruxManager = DevTools.CrUXManager.CrUXManager.instance(); - cruxManager.setEndpointForTesting( - 'https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=AIzaSyBn5gimNjhiEyA_euicSKko6IlD3HdgUfk', - ); - const settings = DevTools.Common.Settings.Settings.instance(); - const cruxSetting = settings.createSetting('field-data', {enabled: true}); - - if (!cruxSetting.get().enabled) { - logger('CrUX is disabled in settings'); - return; - } - - const urls = new Set(); - if (result.insights) { - for (const insightSet of result.insights.values()) { - urls.add(insightSet.url.href); - } - } else { - const mainUrl = result.parsedTrace.data.Meta.mainFrameURL; - if (mainUrl) { - urls.add(mainUrl); - } - } - - if (urls.size === 0) { - logger('No URLs found for CrUX data'); - return; - } - - logger( - `Fetching CrUX data for ${urls.size} URLs: ${Array.from(urls).join(', ')}`, - ); - const cruxData = await Promise.all( - Array.from(urls).map(async url => { - const data = await cruxManager.getFieldDataForPage(url); - logger(`CrUX data for ${url}: ${data ? 'found' : 'not found'}`); - return data; - }), - ); - - result.parsedTrace.metadata.cruxFieldData = cruxData; - } catch (err) { - logger('Error populating CrUX data:', err); + logger('populateCruxData called'); + const cruxManager = DevTools.CrUXManager.CrUXManager.instance(); + // go/jtfbx + cruxManager.setEndpointForTesting( + 'https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=AIzaSyBn5gimNjhiEyA_euicSKko6IlD3HdgUfk', + ); + const settings = DevTools.Common.Settings.Settings.instance(); + const cruxSetting = settings.createSetting('field-data', {enabled: true}); + cruxSetting.set({enabled: true}); + + // Gather URLs to fetch CrUX data for + const urls = [...(result.parsedTrace.insights?.values() ?? [])].map(c => + c.url.toString(), + ); + urls.push(result.parsedTrace.data.Meta.mainFrameURL); + const urlSet = new Set(urls); + + if (urlSet.size === 0) { + logger('No URLs found for CrUX data'); + return; } + logger( + `Fetching CrUX data for ${urlSet.size} URLs: ${Array.from(urlSet).join(', ')}`, + ); + const cruxData = await Promise.all( + Array.from(urlSet).map(async url => { + const data = await cruxManager.getFieldDataForPage(url); + logger(`CrUX data for ${url}: ${data ? 'found' : 'not found'}`); + return data; + }), + ); + + result.parsedTrace.metadata.cruxFieldData = cruxData; } diff --git a/tests/McpResponse.test.js.snapshot b/tests/McpResponse.test.js.snapshot index 61a417c81..12a5405a3 100644 --- a/tests/McpResponse.test.js.snapshot +++ b/tests/McpResponse.test.js.snapshot @@ -71,7 +71,7 @@ exports[`McpResponse > adds throttling setting when it is not null 1`] = ` # test response ## Network emulation Emulating: Slow 3G -Default navigation timeout set to 100000 ms +Default navigation timeout set to 600000 ms `; exports[`McpResponse > allows response text lines to be added 1`] = ` diff --git a/tests/tools/performance.test.js.snapshot b/tests/tools/performance.test.js.snapshot index 9cda7250b..e5bca155c 100644 --- a/tests/tools/performance.test.js.snapshot +++ b/tests/tools/performance.test.js.snapshot @@ -73,7 +73,19 @@ Metrics (lab / observed): - Load duration: 15 ms, bounds: {min: 122411037986, max: 122411052690} - Render delay: 73 ms, bounds: {min: 122411052690, max: 122411126100} - CLS: 0.00 -Metrics (field / real users): n/a – no data for this page in CrUX +Metrics (field / real users): + - LCP: 2595 ms (scope: url) + - LCP breakdown: + - TTFB: 1273 ms (scope: url) + - Load delay: 86 ms (scope: url) + - Load duration: 451 ms (scope: url) + - Render delay: 786 ms (scope: url) + - INP: 140 ms (scope: url) + - CLS: 0.06 (scope: url) + - The above data is from CrUX–Chrome User Experience Report. It's how the page performs for real users. + - The values shown above are the p75 measure of all real Chrome users + - The scope indicates if the data came from the entire origin, or a specific url + - Lab metrics describe how this specific page load performed, while field metrics are an aggregation of results from real-world users. Best practice is to prioritize metrics that are bad in field data. Lab metrics may be better or worse than fields metrics depending on the developer's machine, network, or the actions performed while tracing. Available insights: - insight name: LCPBreakdown description: Each [subpart has specific improvement strategies](https://developer.chrome.com/docs/performance/insights/lcp-breakdown). Ideally, most of the LCP time should be spent on loading the resources, not within delays. diff --git a/tests/tools/performance.test.ts b/tests/tools/performance.test.ts index 9e98088e7..cb390b33a 100644 --- a/tests/tools/performance.test.ts +++ b/tests/tools/performance.test.ts @@ -75,7 +75,7 @@ describe('performance', () => { }); }); - it.only('can autostop and store a recording', async () => { + it('can autostop and store a recording', async () => { const rawData = loadTraceAsBuffer('basic-trace.json.gz'); await withMcpContext(async (response, context) => { From 9280192061447376a1e3d32396d6b000d481cd50 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Tue, 6 Jan 2026 12:43:29 -0800 Subject: [PATCH 12/16] mock fetch to avoid network RT in test Change-Id: I5e824dbb077b94accb65420d97f5fc3de61fbd53 --- tests/tools/performance.test.ts | 87 ++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/tests/tools/performance.test.ts b/tests/tools/performance.test.ts index cb390b33a..16c22ea7d 100644 --- a/tests/tools/performance.test.ts +++ b/tests/tools/performance.test.ts @@ -5,7 +5,7 @@ */ import assert from 'node:assert'; -import {describe, it, afterEach} from 'node:test'; +import {describe, it, afterEach, beforeEach} from 'node:test'; import sinon from 'sinon'; @@ -27,6 +27,20 @@ describe('performance', () => { 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) => { @@ -277,3 +291,74 @@ describe('performance', () => { }); }); }); + +function cruxResponseFixture() { + return { + record: { + key: { + url: 'https://web.dev/', + }, + 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: 2595}, + }, + 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}, + }, + }, + }; +} From 295b192a2dd60185ad1b027e9f2d421c52257ceb Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Tue, 6 Jan 2026 12:47:58 -0800 Subject: [PATCH 13/16] drop diff Change-Id: Ibf10ea910e34bac5fc44bf13760ceb971680dd35 --- src/McpContext.ts | 4 +- src/main.ts | 6 +- src/manual-perf-test.ts | 110 ----------------------------- src/tools/performance.ts | 8 ++- tests/McpResponse.test.js.snapshot | 2 +- 5 files changed, 11 insertions(+), 119 deletions(-) delete mode 100644 src/manual-perf-test.ts diff --git a/src/McpContext.ts b/src/McpContext.ts index ce5bd49a6..d9e2bbf47 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -64,8 +64,8 @@ interface McpContextOptions { experimentalIncludeAllPages?: boolean; } -const DEFAULT_TIMEOUT = 30_000; -const NAVIGATION_TIMEOUT = 60_000; +const DEFAULT_TIMEOUT = 5_000; +const NAVIGATION_TIMEOUT = 10_000; function getNetworkMultiplierFromString(condition: string | null): number { const puppeteerCondition = diff --git a/src/main.ts b/src/main.ts index 103108fff..f2a92a263 100644 --- a/src/main.ts +++ b/src/main.ts @@ -96,10 +96,8 @@ const logDisclaimers = () => { console.error( `chrome-devtools-mcp exposes content of the browser instance to the MCP clients allowing them to inspect, debug, and modify any data in the browser or DevTools. -Avoid sharing sensitive or personal information that you do not want to share with MCP clients.`, - ); - console.error( - `Performance tools may send trace URLs to the Google CrUX API to fetch real-user experience data.`, +Avoid sharing sensitive or personal information that you do not want to share with MCP clients. +Performance tools may send trace URLs to the Google CrUX API to fetch real-user experience data.`, ); }; diff --git a/src/manual-perf-test.ts b/src/manual-perf-test.ts deleted file mode 100644 index de25c61e5..000000000 --- a/src/manual-perf-test.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import puppeteer, {Locator} from 'puppeteer'; -import logger from 'debug'; -import {McpContext} from './McpContext.js'; -import {McpResponse} from './McpResponse.js'; -import {startTrace} from './tools/performance.js'; - -async function run() { - console.log('Launching browser...'); - const browser = await puppeteer.launch({ - headless: false, // Visible for manual observation if needed - defaultViewport: null, - handleDevToolsAsPage: true, - args: ['--no-sandbox', '--disable-setuid-sandbox'], // Useful for some envs - }); - - try { - const page = await browser.newPage(); - // Close other pages (like about:blank opened by default if any) - const pages = await browser.pages(); - for (const p of pages) { - if (p !== page) await p.close(); - } - - console.log('Setting up McpContext...'); - const context = await McpContext.from( - browser, - logger('test'), - { - experimentalDevToolsDebugging: false, - }, - Locator, - ); - - // Ensure we have a page selected (McpContext selects one by default but good to be sure) - console.log('Context initialized.'); - - // Pre-navigate to something so we can reload it - const targetUrl = 'https://web.dev'; - console.log(`Navigating to ${targetUrl}...`); - await context.getSelectedPage().goto(targetUrl); - - console.log('Starting trace with autoStop: true, reload: true...'); - const response = new McpResponse(); - const request = { - params: { - reload: true, - autoStop: true, - }, - }; - - // startTrace.handler is async. It waits for the trace to finish if autoStop is true. - await startTrace.handler(request, response, context); - - console.log('Trace handler returned.'); - - // Assertions - const lines = response.responseLines; - - // Check for stop message - const stoppedMsg = lines.find(l => - l.includes('The performance trace has been stopped'), - ); - if (!stoppedMsg) { - console.error('Response lines:', lines); - throw new Error('FAILED: Did not find stop message'); - } else { - console.log('Verified: Stop message found.'); - } - - // Check if context thinks it is running - const isRunning = context.isRunningPerformanceTrace(); - if (isRunning) { - throw new Error('FAILED: Trace is still marked as running in context'); - } else { - console.log('Verified: Context marks trace as stopped.'); - } - - // Check if trace is recorded - const traces = context.recordedTraces(); - if (traces.length !== 1) { - throw new Error( - `FAILED: Expected 1 recorded trace, got ${traces.length}`, - ); - } else { - console.log('Verified: 1 trace recorded.'); - } - - // Check trace content (basic check) - const traceResult = traces[0]; - console.log('--- Response Lines ---'); - console.log(lines.join('\n')); - console.log('----------------------'); - - console.log('SUCCESS: Trace recorded and stopped automatically.'); - } catch (err) { - console.error('Test FAILED with error:', err); - process.exit(1); - } finally { - console.log('Closing browser...'); - await browser.close(); - } -} - -run(); diff --git a/src/tools/performance.ts b/src/tools/performance.ts index 58ef8ae88..367973720 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -193,6 +193,7 @@ async function stopTracingAndAppendOutput( } } +/** We tell CrUXManager to fetch data so it's available when DevTools.PerformanceTraceFormatter is invoked */ async function populateCruxData(result: TraceResult): Promise { logger('populateCruxData called'); const cruxManager = DevTools.CrUXManager.CrUXManager.instance(); @@ -200,8 +201,10 @@ async function populateCruxData(result: TraceResult): Promise { cruxManager.setEndpointForTesting( 'https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=AIzaSyBn5gimNjhiEyA_euicSKko6IlD3HdgUfk', ); - const settings = DevTools.Common.Settings.Settings.instance(); - const cruxSetting = settings.createSetting('field-data', {enabled: true}); + const cruxSetting = + DevTools.Common.Settings.Settings.instance().createSetting('field-data', { + enabled: true, + }); cruxSetting.set({enabled: true}); // Gather URLs to fetch CrUX data for @@ -215,6 +218,7 @@ async function populateCruxData(result: TraceResult): Promise { logger('No URLs found for CrUX data'); return; } + logger( `Fetching CrUX data for ${urlSet.size} URLs: ${Array.from(urlSet).join(', ')}`, ); diff --git a/tests/McpResponse.test.js.snapshot b/tests/McpResponse.test.js.snapshot index 12a5405a3..61a417c81 100644 --- a/tests/McpResponse.test.js.snapshot +++ b/tests/McpResponse.test.js.snapshot @@ -71,7 +71,7 @@ exports[`McpResponse > adds throttling setting when it is not null 1`] = ` # test response ## Network emulation Emulating: Slow 3G -Default navigation timeout set to 600000 ms +Default navigation timeout set to 100000 ms `; exports[`McpResponse > allows response text lines to be added 1`] = ` From 1662b6d28e4574a4c2eaeefd72fe0bc813e144cb Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Mon, 12 Jan 2026 13:18:55 -0800 Subject: [PATCH 14/16] use proper export Change-Id: I8eff31fd3b8d01f7ac466ed22117696844227ac4 --- src/third_party/devtools.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/third_party/devtools.ts b/src/third_party/devtools.ts index 75d8531c0..e4a9005c0 100644 --- a/src/third_party/devtools.ts +++ b/src/third_party/devtools.ts @@ -24,11 +24,9 @@ export { ProtocolClient, Common, I18n, + CrUXManager, IssueAggregatorEvents, IssuesManagerEvents, createIssuesFromProtocolIssue, IssueAggregator, } from '../../node_modules/chrome-devtools-frontend/mcp/mcp.js'; -/* eslint-disable no-restricted-imports */ -export * as CrUXManager from '../../node_modules/chrome-devtools-frontend/front_end/models/crux-manager/crux-manager.js'; -/* eslint-enable no-restricted-imports */ From 4cef374f4688124e67b5f24e24c3701f005e085f Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Mon, 12 Jan 2026 17:45:08 -0800 Subject: [PATCH 15/16] prep for crrev.com/c/7421201 Change-Id: I5b9c78336bf0d7ca8ae42f62fc59bfe28da628ee --- package-lock.json | 12 +----------- tests/tools/performance.test.ts | 2 ++ 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index acdb47ed0..d624cb8d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1226,7 +1226,6 @@ "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", @@ -1701,7 +1700,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2609,8 +2607,7 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz", "integrity": "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/diff": { "version": "8.0.2", @@ -2911,7 +2908,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3082,7 +3078,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3353,7 +3348,6 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5535,7 +5529,6 @@ "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -6504,7 +6497,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6580,7 +6572,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -6879,7 +6870,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/tests/tools/performance.test.ts b/tests/tools/performance.test.ts index 16c22ea7d..205e59962 100644 --- a/tests/tools/performance.test.ts +++ b/tests/tools/performance.test.ts @@ -293,6 +293,8 @@ describe('performance', () => { }); function cruxResponseFixture() { + // 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: { From 009064a65226375338a10abd0616e9663f46b132 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Tue, 13 Jan 2026 16:04:35 -0800 Subject: [PATCH 16/16] skip manager? nahh Change-Id: Ib6eacf5cc90e858b3c2cbce67ceb41f4d35b0065 --- src/tools/performance.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/performance.ts b/src/tools/performance.ts index 44d3e1850..6ab42f6b6 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -237,7 +237,7 @@ async function stopTracingAndAppendOutput( /** We tell CrUXManager to fetch data so it's available when DevTools.PerformanceTraceFormatter is invoked */ async function populateCruxData(result: TraceResult): Promise { logger('populateCruxData called'); - const cruxManager = DevTools.CrUXManager.CrUXManager.instance(); + const cruxManager = DevTools.CrUXManager.instance(); // go/jtfbx cruxManager.setEndpointForTesting( 'https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=AIzaSyBn5gimNjhiEyA_euicSKko6IlD3HdgUfk',