diff --git a/README.md b/README.md index 4149e57f..ba5980e2 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,8 @@ claude mcp add chrome-devtools npx chrome-devtools-mcp@latest - [`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** (2 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) - **Network** (2 tools) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index cd71b2c1..40854d34 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -22,7 +22,8 @@ - [`emulate_cpu`](#emulate_cpu) - [`emulate_network`](#emulate_network) - [`resize_page`](#resize_page) -- **[Performance](#performance)** (2 tools) +- **[Performance](#performance)** (3 tools) + - [`performance_analyze_insight`](#performance_analyze_insight) - [`performance_start_trace`](#performance_start_trace) - [`performance_stop_trace`](#performance_stop_trace) - **[Network](#network)** (2 tools) @@ -216,6 +217,16 @@ ## Performance +### `performance_analyze_insight` + +**Description:** Provides more detailed information on a specific Performance Insight that was highlighed in the results of a trace recording + +**Parameters:** + +- **insightName** (string) **(required)**: The name of the Insight you want more information on. For example: "DocumentLatency" or "LCPBreakdown" + +--- + ### `performance_start_trace` **Description:** Starts a performance trace recording diff --git a/package.json b/package.json index fc605aa4..03949b06 100644 --- a/package.json +++ b/package.json @@ -9,15 +9,16 @@ "main": "index.js", "scripts": { "build": "tsc && node --experimental-strip-types scripts/post-build.ts", + "typecheck": "tsc --noEmit", "format": "eslint --cache --fix . ;prettier --write --cache .", "check-format": "eslint --cache .; prettier --check --cache .;", "generate-docs": "npm run build && node --experimental-strip-types scripts/generate-docs.ts", "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": "npm run build && node --test-reporter spec --test-force-exit --test 'build/tests/**/*.test.js'", - "test:only": "npm run build && node --test-reporter spec --test-force-exit --test --test-only 'build/tests/**/*.test.js'", - "test:only:no-build": "node --test-reporter spec --test-force-exit --test --test-only 'build/tests/**/*.test.js'", - "test:update-snapshots": "npm run build && node --test-force-exit --test --test-update-snapshots 'build/tests/**/*.test.js'", + "test": "npm run build && node --require ./build/tests/setup.js --test-reporter spec --test-force-exit --test 'build/tests/**/*.test.js'", + "test:only": "npm run build && node --require ./build/tests/setup.js --test-reporter spec --test-force-exit --test --test-only 'build/tests/**/*.test.js'", + "test:only:no-build": "node --require ./build/tests/setup.js --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 --test-force-exit --test --test-update-snapshots 'build/tests/**/*.test.js'", "prepare": "node --experimental-strip-types scripts/prepare.ts" }, "files": [ diff --git a/src/McpContext.ts b/src/McpContext.ts index db456191..53aabfa1 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -19,6 +19,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import {listPages} from './tools/pages.js'; +import {TraceResult} from './trace-processing/parse.js'; export interface TextSnapshotNode extends SerializedAXNode { id: string; @@ -49,6 +50,7 @@ export class McpContext implements Context { #dialog?: Dialog; #nextSnapshotId = 1; + #traceResults: TraceResult[] = []; private constructor(browser: Browser, logger: Debugger) { this.browser = browser; @@ -292,4 +294,12 @@ export class McpContext implements Context { throw new Error('Could not save a screenshot to a file'); } } + + storeTraceRecording(result: TraceResult): void { + this.#traceResults.push(result); + } + + recordedTraces(): TraceResult[] { + return this.#traceResults; + } } diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index f141bd03..3f32652d 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -7,6 +7,7 @@ import z from 'zod'; import {Dialog, ElementHandle, Page} from 'puppeteer-core'; import {ToolCategories} from './categories.js'; +import {TraceResult} from '../trace-processing/parse.js'; export interface ToolDefinition< Schema extends Zod.ZodRawShape = Zod.ZodRawShape, @@ -54,6 +55,8 @@ export interface Response { export type Context = Readonly<{ isRunningPerformanceTrace(): boolean; setIsRunningPerformanceTrace(x: boolean): void; + recordedTraces(): TraceResult[]; + storeTraceRecording(result: TraceResult): void; getSelectedPage(): Page; getDialog(): Dialog | undefined; clearDialog(): void; diff --git a/src/tools/performance.ts b/src/tools/performance.ts index eaed8904..a59e73a3 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -6,7 +6,12 @@ import z from 'zod'; import {Context, defineTool, Response} from './ToolDefinition.js'; -import {insightOutput, parseRawTraceBuffer} from '../trace-processing/parse.js'; +import { + getInsightOutput, + getTraceSummary, + InsightName, + parseRawTraceBuffer, +} from '../trace-processing/parse.js'; import {logger} from '../logger.js'; import {Page} from 'puppeteer-core'; import {ToolCategories} from './categories.js'; @@ -108,6 +113,43 @@ export const stopTrace = defineTool({ }, }); +export const analyzeInsight = defineTool({ + name: 'performance_analyze_insight', + description: + 'Provides more detailed information on a specific Performance Insight that was highlighed in the results of a trace recording', + annotations: { + category: ToolCategories.PERFORMANCE, + readOnlyHint: true, + }, + schema: { + insightName: z + .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.', + ); + return; + } + + const insightOutput = getInsightOutput( + lastRecording, + request.params.insightName as InsightName, + ); + if ('error' in insightOutput) { + response.appendResponseLine(insightOutput.error); + return; + } + + response.appendResponseLine(insightOutput.output); + }, +}); + async function stopTracingAndAppendOutput( page: Page, response: Response, @@ -118,7 +160,8 @@ async function stopTracingAndAppendOutput( const result = await parseRawTraceBuffer(traceEventsBuffer); response.appendResponseLine('The performance trace has been stopped.'); if (result) { - const insightText = insightOutput(result); + context.storeTraceRecording(result); + const insightText = getTraceSummary(result); if (insightText) { response.appendResponseLine('Insights with performance opportunities:'); response.appendResponseLine(insightText); diff --git a/src/trace-processing/parse.ts b/src/trace-processing/parse.ts index c6a018a7..e1120681 100644 --- a/src/trace-processing/parse.ts +++ b/src/trace-processing/parse.ts @@ -5,6 +5,7 @@ */ import {PerformanceTraceFormatter} from '../../node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceTraceFormatter.js'; +import {PerformanceInsightFormatter} from '../../node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceInsightFormatter.js'; import * as TraceEngine from '../../node_modules/chrome-devtools-frontend/front_end/models/trace/trace.js'; import {logger} from '../logger.js'; import {AgentFocus} from '../../node_modules/chrome-devtools-frontend/front_end/models/ai_assistance/performance/AIContext.js'; @@ -60,12 +61,51 @@ export async function parseRawTraceBuffer( } } -// TODO(jactkfranklin): move the formatters from DevTools to use here. -// This is a very temporary helper to output some text from the tool call to aid development. -export function insightOutput(result: TraceResult): string { +export function getTraceSummary(result: TraceResult): string { const focus = AgentFocus.full(result.parsedTrace); const serializer = new TraceEngine.EventsSerializer.EventsSerializer(); const formatter = new PerformanceTraceFormatter(focus, serializer); const output = formatter.formatTraceSummary(); return output; } + +export type InsightName = keyof TraceEngine.Insights.Types.InsightModels; +export type InsightOutput = {output: string} | {error: string}; + +export function getInsightOutput( + result: TraceResult, + insightName: InsightName, +): InsightOutput { + // Currently, we do not support inspecting traces with multiple navigations. We either: + // 1. Find Insights from the first navigation (common case: user records a trace with a page reload to test load performance) + // 2. Fall back to finding Insights not associated with a navigation (common case: user tests an interaction without a page load). + const mainNavigationId = + result.parsedTrace.data.Meta.mainFrameNavigations.at(0)?.args.data + ?.navigationId; + + const insightsForNav = result.insights.get( + mainNavigationId ?? TraceEngine.Types.Events.NO_NAVIGATION, + ); + + if (!insightsForNav) { + return { + error: 'No Performance Insights for this trace.', + }; + } + + const matchingInsight = + insightName in insightsForNav.model + ? insightsForNav.model[insightName] + : null; + if (!matchingInsight) { + return { + error: `No Insight with the name ${insightName} found. Double check the name you provided is accurate and try again.`, + }; + } + + const formatter = new PerformanceInsightFormatter( + result.parsedTrace, + matchingInsight, + ); + return {output: formatter.formatInsight()}; +} diff --git a/tests/McpContext.test.ts b/tests/McpContext.test.ts index 37ec3450..c75d300a 100644 --- a/tests/McpContext.test.ts +++ b/tests/McpContext.test.ts @@ -5,12 +5,12 @@ */ import {describe, it} from 'node:test'; import assert from 'assert'; - +import {TraceResult} from '../src/trace-processing/parse.js'; import {withBrowser} from './utils.js'; -describe('McpResponse', () => { +describe('McpContext', () => { it('list pages', async () => { - await withBrowser(async (response, context) => { + await withBrowser(async (_response, context) => { const page = context.getSelectedPage(); await page.setContent(` `); @@ -28,4 +28,14 @@ describe('McpResponse', () => { } }); }); + + it('can store and retrieve performance traces', async () => { + await withBrowser(async (_response, context) => { + const fakeTrace1 = {} as unknown as TraceResult; + const fakeTrace2 = {} as unknown as TraceResult; + context.storeTraceRecording(fakeTrace1); + context.storeTraceRecording(fakeTrace2); + assert.deepEqual(context.recordedTraces(), [fakeTrace1, fakeTrace2]); + }); + }); }); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 00000000..40ea2b91 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {it} from 'node:test'; + +// This is run by Node when we execute the tests via the --require flag. +it.snapshot.setResolveSnapshotPath(testPath => { + // By default the snapshots go into the build directory, but we want them + // in the tests/ directory. + const correctPath = testPath?.replace('/build/tests', '/tests'); + return correctPath + '.snapshot'; +}); + +// The default serializer is JSON.stringify which outputs a very hard to read +// snapshot. So we override it to one that shows new lines literally rather +// than via `\n`. +it.snapshot.setDefaultSnapshotSerializers([String]); diff --git a/tests/tools/performance.test.js.snapshot b/tests/tools/performance.test.js.snapshot new file mode 100644 index 00000000..305647fb --- /dev/null +++ b/tests/tools/performance.test.js.snapshot @@ -0,0 +1,42 @@ +exports[`performance > performance_analyze_insight > returns the information on the insight 1`] = ` +## Insight Title: LCP breakdown + +## Insight Summary: +This insight is used to analyze the time spent that contributed to the final LCP time and identify which of the 4 phases (or 2 if there was no LCP resource) are contributing most to the delay in rendering the LCP element. + +## Detailed analysis: +The Largest Contentful Paint (LCP) time for this navigation was 129.2 ms. +The LCP element is an image fetched from \`https://web-dev.imgix.net/image/kheDArv5csY6rvQUJDbWRscckLr1/4i7JstVZvgTFk9dxCe4a.svg\`. +## LCP resource network request: https://web-dev.imgix.net/image/kheDArv5csY6rvQUJDbWRscckLr1/4i7JstVZvgTFk9dxCe4a.svg +Timings: +- Queued at: 41.1 ms +- Request sent at: 46.6 ms +- Download complete at: 55.8 ms +- Main thread processing completed at: 58.2 ms +Durations: +- Download time: 0.3 ms +- Main thread processing time: 2.3 ms +- Total duration: 17.1 ms +Redirects: no redirects +Status code: 200 +MIME Type: image/svg+xml +Protocol: unknown +Priority: VeryHigh +Render blocking: No +From a service worker: No +Initiators (root request to the request that directly loaded this one): none + + +We can break this time down into the 4 phases that combine to make the LCP time: + +- Time to first byte: 7.9 ms (6.1% of total LCP time) +- Resource load delay: 33.2 ms (25.7% of total LCP time) +- Resource load duration: 14.7 ms (11.4% of total LCP time) +- Element render delay: 73.4 ms (56.8% of total LCP time) + +## Estimated savings: none + +## External resources: +- https://web.dev/articles/lcp +- https://web.dev/articles/optimize-lcp +`; diff --git a/tests/tools/performance.test.ts b/tests/tools/performance.test.ts index 1b293fda..e661811a 100644 --- a/tests/tools/performance.test.ts +++ b/tests/tools/performance.test.ts @@ -7,9 +7,17 @@ import {describe, it, afterEach} from 'node:test'; import assert from 'assert'; import sinon from 'sinon'; -import {startTrace, stopTrace} from '../../src/tools/performance.js'; +import { + analyzeInsight, + startTrace, + stopTrace, +} from '../../src/tools/performance.js'; import {withBrowser} from '../utils.js'; import {loadTraceAsBuffer} from '../trace-processing/fixtures/load.js'; +import { + parseRawTraceBuffer, + TraceResult, +} from '../../src/trace-processing/parse.js'; describe('performance', () => { afterEach(() => { @@ -20,7 +28,7 @@ describe('performance', () => { it('starts a trace recording', async () => { await withBrowser(async (response, context) => { context.setIsRunningPerformanceTrace(false); - const selectedPage = await context.getSelectedPage(); + const selectedPage = context.getSelectedPage(); const startTracingStub = sinon.stub(selectedPage.tracing, 'start'); await startTrace.handler( {params: {reload: true, autoStop: false}}, @@ -39,7 +47,7 @@ describe('performance', () => { it('can navigate to about:blank and record a page reload', async () => { await withBrowser(async (response, context) => { - const selectedPage = await context.getSelectedPage(); + 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'); @@ -64,11 +72,11 @@ describe('performance', () => { }); }); - it('can autostop a recording', async () => { + it('can autostop and store a recording', async () => { const rawData = loadTraceAsBuffer('basic-trace.json.gz'); await withBrowser(async (response, context) => { - const selectedPage = await context.getSelectedPage(); + 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'); @@ -100,6 +108,7 @@ describe('performance', () => { false, 'Tracing was stopped', ); + assert.strictEqual(context.recordedTraces().length, 1); assert.ok( response.responseLines .join('\n') @@ -111,7 +120,7 @@ describe('performance', () => { it('errors if a recording is already active', async () => { await withBrowser(async (response, context) => { context.setIsRunningPerformanceTrace(true); - const selectedPage = await context.getSelectedPage(); + const selectedPage = context.getSelectedPage(); const startTracingStub = sinon.stub(selectedPage.tracing, 'start'); await startTrace.handler( {params: {reload: true, autoStop: false}}, @@ -128,6 +137,79 @@ describe('performance', () => { }); }); + describe('performance_analyze_insight', () => { + async function parseTrace(fileName: string): Promise { + const rawData = loadTraceAsBuffer(fileName); + const result = await parseRawTraceBuffer(rawData); + assert.ok(result); + 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) => { @@ -140,7 +222,7 @@ describe('performance', () => { const rawData = loadTraceAsBuffer('basic-trace.json.gz'); await withBrowser(async (response, context) => { context.setIsRunningPerformanceTrace(true); - const selectedPage = await context.getSelectedPage(); + const selectedPage = context.getSelectedPage(); const stopTracingStub = sinon .stub(selectedPage.tracing, 'stop') .callsFake(async () => { @@ -152,6 +234,7 @@ describe('performance', () => { 'The performance trace has been stopped.', ), ); + assert.strictEqual(context.recordedTraces().length, 1); sinon.assert.calledOnce(stopTracingStub); }); }); diff --git a/tests/trace-processing/parse.test.ts b/tests/trace-processing/parse.test.ts index 30ec41c1..c8c11864 100644 --- a/tests/trace-processing/parse.test.ts +++ b/tests/trace-processing/parse.test.ts @@ -6,24 +6,12 @@ import {describe, it} from 'node:test'; import assert from 'node:assert'; import { - insightOutput, + getTraceSummary, parseRawTraceBuffer, } from '../../src/trace-processing/parse.js'; import {loadTraceAsBuffer} from './fixtures/load.js'; describe('Trace parsing', async () => { - it.snapshot.setResolveSnapshotPath(testPath => { - // By default the snapshots go into the build directory, but we want them - // in the tests/ directory. - const correctPath = testPath?.replace('/build/tests', '/tests'); - return correctPath + '.snapshot'; - }); - - // The default serializer is JSON.stringify which outputs a very hard to read - // snapshot. So we override it to one that shows new lines literally rather - // than via `\n`. - it.snapshot.setDefaultSnapshotSerializers([String]); - it('can parse a Uint8Array from Tracing.stop())', async () => { const rawData = loadTraceAsBuffer('basic-trace.json.gz'); const result = await parseRawTraceBuffer(rawData); @@ -37,7 +25,7 @@ describe('Trace parsing', async () => { assert.ok(result?.parsedTrace); assert.ok(result?.insights); - const output = insightOutput(result); + const output = getTraceSummary(result); t.assert.snapshot(output); }); });