From d8a54d8c77e3fb9ca4fa7b5ba8f965f0eb62ab2b Mon Sep 17 00:00:00 2001 From: Nicholas Roscino Date: Mon, 20 Apr 2026 16:23:08 +0200 Subject: [PATCH] refactor: use puppeteer Extension API --- package-lock.json | 16 +++---- package.json | 2 +- scripts/test.mjs | 5 +- src/McpContext.ts | 36 +++++--------- src/McpResponse.ts | 17 +++---- src/tools/ToolDefinition.ts | 6 +-- src/tools/extensions.ts | 2 +- src/utils/ExtensionRegistry.ts | 53 --------------------- tests/McpResponse.test.js.snapshot | 4 +- tests/McpResponse.test.ts | 46 +++++++++++------- tests/tools/extensions.test.ts | 76 +++++++++++++++++------------- tests/tools/pages.test.ts | 17 ++++--- tests/tools/script.test.ts | 10 +++- tests/utils.ts | 8 ++++ 14 files changed, 137 insertions(+), 161 deletions(-) delete mode 100644 src/utils/ExtensionRegistry.ts diff --git a/package-lock.json b/package-lock.json index 73246f61f..668343371 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,7 @@ "globals": "^17.0.0", "lighthouse": "13.1.0", "prettier": "^3.6.2", - "puppeteer": "24.41.0", + "puppeteer": "24.42.0", "rollup": "4.60.2", "rollup-plugin-cleanup": "^3.2.1", "rollup-plugin-license": "^3.6.0", @@ -7486,9 +7486,9 @@ } }, "node_modules/puppeteer": { - "version": "24.41.0", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.41.0.tgz", - "integrity": "sha512-W6Fk0J3TPjjtwjXOyR/qf+YaL0H/Uq8HIgHcXG4mNM/IgbKMCH/HPyK0Fi2qbTU/QpSl9bCte2yBpGHKejTpIw==", + "version": "24.42.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.42.0.tgz", + "integrity": "sha512-94MoPfFp2eY3eYIMdINkez4IOP5TMHntlZbVx06fHlQTtiQiYgaY0L2Zzfod8PVUkPqP7m3Qlre2v8YS8cudPA==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -7497,7 +7497,7 @@ "chromium-bidi": "14.0.0", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1595872", - "puppeteer-core": "24.41.0", + "puppeteer-core": "24.42.0", "typed-query-selector": "^2.12.1" }, "bin": { @@ -7508,9 +7508,9 @@ } }, "node_modules/puppeteer-core": { - "version": "24.41.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.41.0.tgz", - "integrity": "sha512-rLIUri7E/NQ3APSEYCCozaSJx0u8Tu9wxO6BJwnvXmIgILSK3L0TombaVh3izp1njAGrO6H2ru0hcIrLF+gWLw==", + "version": "24.42.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.42.0.tgz", + "integrity": "sha512-T4zXokk/izH01fYPhyyev1A4piWiOKrYq7CUFpdoYQxmOnXoV6YjUabmfIjCYkNspSoAXIxRid3Tw+Vg0fthYg==", "dev": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 23cd72c47..328ec1023 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "globals": "^17.0.0", "lighthouse": "13.1.0", "prettier": "^3.6.2", - "puppeteer": "24.41.0", + "puppeteer": "24.42.0", "rollup": "4.60.2", "rollup-plugin-cleanup": "^3.2.1", "rollup-plugin-license": "^3.6.0", diff --git a/scripts/test.mjs b/scripts/test.mjs index 004d344bc..5e0457da4 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -77,7 +77,7 @@ const nodeArgs = [ ...files, ]; -function installChrome(version) { +function _installChrome(version) { try { return execSync( `npx puppeteer browsers install chrome@${version} --format "{{path}}"`, @@ -112,9 +112,6 @@ async function runTests(attempt) { }); } -const chromePath = installChrome('146.0.7680.31'); -process.env.CHROME_M146_EXECUTABLE_PATH = chromePath; - const maxAttempts = shouldRetry ? 3 : 1; let exitCode = 1; diff --git a/src/McpContext.ts b/src/McpContext.ts index 198a91a59..c97715cbb 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -28,6 +28,7 @@ import type { SerializedAXNode, Viewport, Target, + Extension, } from './third_party/index.js'; import type {DevTools} from './third_party/index.js'; import {Locator} from './third_party/index.js'; @@ -47,10 +48,6 @@ import type { TextSnapshotNode, ExtensionServiceWorker, } from './types.js'; -import { - ExtensionRegistry, - type InstalledExtension, -} from './utils/ExtensionRegistry.js'; import {ensureExtension, saveTemporaryFile} from './utils/files.js'; import {getNetworkMultiplierFromString} from './WaitForHelper.js'; @@ -83,7 +80,6 @@ export class McpContext implements Context { #networkCollector: NetworkCollector; #consoleCollector: ConsoleCollector; #devtoolsUniverseManager: UniverseManager; - #extensionRegistry = new ExtensionRegistry(); #isRunningTrace = false; #screenRecorderData: {recorder: ScreenRecorder; filePath: string} | null = @@ -882,38 +878,30 @@ export class McpContext implements Context { async installExtension(extensionPath: string): Promise { const id = await this.browser.installExtension(extensionPath); - await this.#extensionRegistry.registerExtension(id, extensionPath); return id; } async uninstallExtension(id: string): Promise { await this.browser.uninstallExtension(id); - this.#extensionRegistry.remove(id); } async triggerExtensionAction(id: string): Promise { - const page = this.getSelectedPptrPage(); - // @ts-expect-error internal puppeteer api is needed since we don't have a way to get - // a tab id at the moment - const theTarget = page._tabId; - const session = await this.browser.target().createCDPSession(); - - try { - await session.send('Extensions.triggerAction', { - id, - targetId: theTarget, - }); - } finally { - await session.detach(); + const extensions = await this.browser.extensions(); + const extension = extensions.get(id); + if (!extension) { + throw new Error(`Extension with ID ${id} not found.`); } + const page = this.getSelectedPptrPage(); + await extension.triggerAction(page); } - listExtensions(): InstalledExtension[] { - return this.#extensionRegistry.list(); + listExtensions(): Promise> { + return this.browser.extensions(); } - getExtension(id: string): InstalledExtension | undefined { - return this.#extensionRegistry.getById(id); + async getExtension(id: string): Promise { + const pptrExtensions = await this.browser.extensions(); + return pptrExtensions.get(id); } async getHeapSnapshotAggregates( diff --git a/src/McpResponse.ts b/src/McpResponse.ts index c6e47908c..0d5f926ec 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -23,6 +23,7 @@ import type { ResourceType, TextContent, JSONSchema7Definition, + Extension, } from './third_party/index.js'; import type {ToolGroup, ToolDefinition} from './tools/inPage.js'; import {handleDialog} from './tools/pages.js'; @@ -35,7 +36,6 @@ import type { } from './tools/ToolDefinition.js'; import type {InsightName, TraceResult} from './trace-processing/parse.js'; import {getInsightOutput, getTraceSummary} from './trace-processing/parse.js'; -import type {InstalledExtension} from './utils/ExtensionRegistry.js'; import {paginate} from './utils/pagination.js'; import type {PaginationOptions} from './utils/types.js'; @@ -527,9 +527,9 @@ export class McpResponse implements Response { } } - let extensions: InstalledExtension[] | undefined; + let extensions: Map | undefined; if (this.#listExtensions) { - extensions = context.listExtensions(); + extensions = await context.listExtensions(); } let inPageTools: ToolGroup | undefined; @@ -665,7 +665,7 @@ export class McpResponse implements Response { networkRequests?: NetworkFormatter[]; traceSummary?: TraceResult; traceInsight?: TraceInsightData; - extensions?: InstalledExtension[]; + extensions?: Map; lighthouseResult?: LighthouseData; inPageTools?: ToolGroup; webmcpTools?: WebMCPTool[]; @@ -947,14 +947,15 @@ Call ${handleDialog.name} to handle it before continuing.`); } if (data.extensions) { - structuredContent.extensions = data.extensions; + const extensionArray = Array.from(data.extensions.values()); + structuredContent.extensions = extensionArray; response.push('## Extensions'); - if (data.extensions.length === 0) { + if (extensionArray.length === 0) { response.push('No extensions installed.'); } else { - const extensionsMessage = data.extensions + const extensionsMessage = extensionArray .map(extension => { - return `id=${extension.id} "${extension.name}" v${extension.version} ${extension.isEnabled ? 'Enabled' : 'Disabled'}`; + return `id=${extension.id} "${extension.name}" v${extension.version} ${extension.enabled ? 'Enabled' : 'Disabled'}`; }) .join('\n'); response.push(extensionsMessage); diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index b9b4a6af0..1cf84b634 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -10,6 +10,7 @@ import {zod} from '../third_party/index.js'; import type { Dialog, ElementHandle, + Extension, Page, ScreenRecorder, Viewport, @@ -21,7 +22,6 @@ import type { GeolocationOptions, ExtensionServiceWorker, } from '../types.js'; -import type {InstalledExtension} from '../utils/ExtensionRegistry.js'; import type {PaginationOptions} from '../utils/types.js'; import type {ToolCategory} from './categories.js'; @@ -218,8 +218,8 @@ export type Context = Readonly<{ installExtension(path: string): Promise; uninstallExtension(id: string): Promise; triggerExtensionAction(id: string): Promise; - listExtensions(): InstalledExtension[]; - getExtension(id: string): InstalledExtension | undefined; + listExtensions(): Promise>; + getExtension(id: string): Promise; getSelectedMcpPage(): McpPage; getExtensionServiceWorkers(): ExtensionServiceWorker[]; getExtensionServiceWorkerId( diff --git a/src/tools/extensions.ts b/src/tools/extensions.ts index 76cec7fb3..d5d2f2557 100644 --- a/src/tools/extensions.ts +++ b/src/tools/extensions.ts @@ -77,7 +77,7 @@ export const reloadExtension = defineTool({ }, handler: async (request, response, context) => { const {id} = request.params; - const extension = context.getExtension(id); + const extension = await context.getExtension(id); if (!extension) { throw new Error(`Extension with ID ${id} not found.`); } diff --git a/src/utils/ExtensionRegistry.ts b/src/utils/ExtensionRegistry.ts deleted file mode 100644 index e652d64fe..000000000 --- a/src/utils/ExtensionRegistry.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import fs from 'node:fs/promises'; -import path from 'node:path'; - -export interface InstalledExtension { - id: string; - name: string; - version: string; - isEnabled: boolean; - path: string; -} - -export class ExtensionRegistry { - #extensions = new Map(); - - async registerExtension( - id: string, - extensionPath: string, - ): Promise { - const manifestPath = path.join(extensionPath, 'manifest.json'); - const manifestContent = await fs.readFile(manifestPath, 'utf-8'); - const manifest = JSON.parse(manifestContent); - const name = manifest.name ?? 'Unknown'; - const version = manifest.version ?? 'Unknown'; - - const extension = { - id, - name, - version, - isEnabled: true, - path: extensionPath, - }; - this.#extensions.set(extension.id, extension); - return extension; - } - - remove(id: string): void { - this.#extensions.delete(id); - } - - list(): InstalledExtension[] { - return Array.from(this.#extensions.values()); - } - - getById(id: string): InstalledExtension | undefined { - return this.#extensions.get(id); - } -} diff --git a/tests/McpResponse.test.js.snapshot b/tests/McpResponse.test.js.snapshot index 641187d2f..11a7be78e 100644 --- a/tests/McpResponse.test.js.snapshot +++ b/tests/McpResponse.test.js.snapshot @@ -1192,14 +1192,14 @@ exports[`extensions > lists extensions 2`] = ` "id": "id1", "name": "Extension 1", "version": "1.0", - "isEnabled": true, + "enabled": true, "path": "/path/to/ext1" }, { "id": "id2", "name": "Extension 2", "version": "2.0", - "isEnabled": false, + "enabled": false, "path": "/path/to/ext2" } ] diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index 30ba25d83..d49d94057 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -16,7 +16,10 @@ import type {ParsedArguments} from '../src/bin/chrome-devtools-mcp-cli-options.j import type {McpContext} from '../src/McpContext.js'; import type {McpResponse} from '../src/McpResponse.js'; import {replaceHtmlElementsWithUids} from '../src/McpResponse.js'; -import type {JSONSchema7Definition} from '../src/third_party/index.js'; +import type { + Extension, + JSONSchema7Definition, +} from '../src/third_party/index.js'; import { closePage, listPages, @@ -955,22 +958,31 @@ describe('extensions', () => { response.resetResponseLineForTesting(); // Testing with extensions - context.listExtensions = () => [ - { - id: 'id1', - name: 'Extension 1', - version: '1.0', - isEnabled: true, - path: '/path/to/ext1', - }, - { - id: 'id2', - name: 'Extension 2', - version: '2.0', - isEnabled: false, - path: '/path/to/ext2', - }, - ]; + context.listExtensions = async () => + Promise.resolve( + new Map([ + [ + 'id1', + { + id: 'id1', + name: 'Extension 1', + version: '1.0', + enabled: true, + path: '/path/to/ext1', + } as Extension, + ], + [ + 'id2', + { + id: 'id2', + name: 'Extension 2', + version: '2.0', + enabled: false, + path: '/path/to/ext2', + } as Extension, + ], + ]), + ); response.setListExtensions(); const {content, structuredContent} = await response.handle( 'test', diff --git a/tests/tools/extensions.test.ts b/tests/tools/extensions.test.ts index 8f8b5a878..c7374ea2d 100644 --- a/tests/tools/extensions.test.ts +++ b/tests/tools/extensions.test.ts @@ -18,7 +18,11 @@ import { reloadExtension, triggerExtensionAction, } from '../../src/tools/extensions.js'; -import {extractExtensionId, withMcpContext} from '../utils.js'; +import { + assertNoServiceWorkerReported, + extractExtensionId, + withMcpContext, +} from '../utils.js'; const EXTENSION_WITH_SW_PATH = path.join( import.meta.dirname, @@ -91,38 +95,45 @@ describe('extension', () => { }); }); it('reloads an extension', async () => { - await withMcpContext(async (response, context) => { - await installExtension.handler( - {params: {path: EXTENSION_PATH}}, - response, - context, - ); + await withMcpContext( + async (response, context) => { + await installExtension.handler( + {params: {path: EXTENSION_PATH}}, + response, + context, + ); - const extensionId = extractExtensionId(response); - const installSpy = sinon.spy(context, 'installExtension'); - response.resetResponseLineForTesting(); + const extensionId = extractExtensionId(response); + const installSpy = sinon.spy(context, 'installExtension'); + response.resetResponseLineForTesting(); - await reloadExtension.handler( - {params: {id: extensionId!}}, - response, - context, - ); - assert.ok( - installSpy.calledOnceWithExactly(EXTENSION_PATH), - 'installExtension should be called with the extension path', - ); + await reloadExtension.handler( + {params: {id: extensionId!}}, + response, + context, + ); + assert.ok( + installSpy.calledOnceWithExactly(EXTENSION_PATH), + 'installExtension should be called with the extension path', + ); - const reloadResponseLine = response.responseLines[0]; - assert.ok( - reloadResponseLine.includes('Extension reloaded'), - 'Response should indicate reload', - ); + const reloadResponseLine = response.responseLines[0]; + assert.ok( + reloadResponseLine.includes('Extension reloaded'), + 'Response should indicate reload', + ); - const list = context.listExtensions(); - assert.ok(list.length === 1, 'List should have only one extension'); - const reinstalled = list.find(e => e.id === extensionId); - assert.ok(reinstalled, 'Extension should be present after reload'); - }); + const list = Array.from((await context.listExtensions()).values()); + + assert.ok(list.length === 1, 'List should have only one extension'); + const reinstalled = list.find(e => e.id === extensionId); + assert.ok(reinstalled, 'Extension should be present after reload'); + }, + {}, + { + categoryExtensions: true, + } as ParsedArguments, + ); }); it('triggers an extension action', async () => { await withMcpContext( @@ -147,10 +158,11 @@ describe('extension', () => { t => t.type() === 'page' && t.url().includes(extensionId), ); assert.ok(pageTargetAfter, 'Page should exist after action'); + await context.uninstallExtension(extensionId); + const targets = context.browser.targets(); + assertNoServiceWorkerReported(targets, extensionId); }, - { - executablePath: process.env.CHROME_M146_EXECUTABLE_PATH, - }, + {}, { categoryExtensions: true, } as ParsedArguments, diff --git a/tests/tools/pages.test.ts b/tests/tools/pages.test.ts index 3fb5892b6..1617f9f6b 100644 --- a/tests/tools/pages.test.ts +++ b/tests/tools/pages.test.ts @@ -22,7 +22,7 @@ import { handleDialog, getTabId, } from '../../src/tools/pages.js'; -import {html, withMcpContext} from '../utils.js'; +import {assertNoServiceWorkerReported, html, withMcpContext} from '../utils.js'; const EXTENSION_SW_PATH = path.join( import.meta.dirname, @@ -94,10 +94,9 @@ describe('pages', () => { '', ); t.assert.snapshot?.(text); + await context.uninstallExtension(extensionId); }, - { - executablePath: process.env.CHROME_M146_EXECUTABLE_PATH, - }, + {}, { categoryExtensions: true, } as ParsedArguments, @@ -146,6 +145,9 @@ describe('pages', () => { '', ); t.assert.snapshot?.(text); + await context.uninstallExtension(extensionId); + const targets = context.browser.targets(); + assertNoServiceWorkerReported(targets, extensionId); }, {}, { @@ -193,10 +195,11 @@ describe('pages', () => { '', ); t.assert.snapshot?.(text); + await context.uninstallExtension(extensionId); + const targets = context.browser.targets(); + assertNoServiceWorkerReported(targets, extensionId); }, - { - executablePath: process.env.CHROME_M146_EXECUTABLE_PATH, - }, + {}, { categoryExtensions: true, } as ParsedArguments, diff --git a/tests/tools/script.test.ts b/tests/tools/script.test.ts index 5057f8a09..b0d57678a 100644 --- a/tests/tools/script.test.ts +++ b/tests/tools/script.test.ts @@ -12,7 +12,12 @@ import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-option import {installExtension} from '../../src/tools/extensions.js'; import {evaluateScript} from '../../src/tools/script.js'; import {serverHooks} from '../server.js'; -import {extractExtensionId, html, withMcpContext} from '../utils.js'; +import { + assertNoServiceWorkerReported, + extractExtensionId, + html, + withMcpContext, +} from '../utils.js'; const EXTENSION_PATH = path.join( import.meta.dirname, @@ -309,6 +314,9 @@ describe('script', () => { const lineEvaluation = response.responseLines.at(2)!; assert.strictEqual(JSON.parse(lineEvaluation), 'has-chrome'); + await context.uninstallExtension(extensionId); + const targets = context.browser.targets(); + assertNoServiceWorkerReported(targets, extensionId); }, {}, {categoryExtensions: true} as ParsedArguments, diff --git a/tests/utils.ts b/tests/utils.ts index 6d4668fbf..6afa521de 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -18,6 +18,7 @@ import type { HTTPResponse, LaunchOptions, Page, + Target, } from 'puppeteer-core'; import sinon from 'sinon'; @@ -27,6 +28,13 @@ import {McpResponse} from '../src/McpResponse.js'; import {stableIdSymbol} from '../src/PageCollector.js'; import {DevTools} from '../src/third_party/index.js'; +export function assertNoServiceWorkerReported(targets: Target[], id: string) { + const target = targets.find(target => { + return target.url().includes(id) && target.type() === 'service_worker'; + }); + assert(target === undefined); +} + export function getTextContent( content: CallToolResult['content'][number], ): string {