refactor: use puppeteer Extension API (#1911)

This PR refactor the extension usages using the new Puppeteer API.

Requires puppeteer release before merging
This commit is contained in:
Nicholas Roscino
2026-04-20 16:38:48 +02:00
committed by GitHub
parent 562c308550
commit ec895f195a
14 changed files with 137 additions and 161 deletions
+8 -8
View File
@@ -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": {
+1 -1
View File
@@ -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",
+1 -4
View File
@@ -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;
+12 -24
View File
@@ -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<string> {
const id = await this.browser.installExtension(extensionPath);
await this.#extensionRegistry.registerExtension(id, extensionPath);
return id;
}
async uninstallExtension(id: string): Promise<void> {
await this.browser.uninstallExtension(id);
this.#extensionRegistry.remove(id);
}
async triggerExtensionAction(id: string): Promise<void> {
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<Map<string, Extension>> {
return this.browser.extensions();
}
getExtension(id: string): InstalledExtension | undefined {
return this.#extensionRegistry.getById(id);
async getExtension(id: string): Promise<Extension | undefined> {
const pptrExtensions = await this.browser.extensions();
return pptrExtensions.get(id);
}
async getHeapSnapshotAggregates(
+9 -8
View File
@@ -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<string, Extension> | undefined;
if (this.#listExtensions) {
extensions = context.listExtensions();
extensions = await context.listExtensions();
}
let inPageTools: ToolGroup<ToolDefinition> | undefined;
@@ -665,7 +665,7 @@ export class McpResponse implements Response {
networkRequests?: NetworkFormatter[];
traceSummary?: TraceResult;
traceInsight?: TraceInsightData;
extensions?: InstalledExtension[];
extensions?: Map<string, Extension>;
lighthouseResult?: LighthouseData;
inPageTools?: ToolGroup<ToolDefinition>;
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);
+3 -3
View File
@@ -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<string>;
uninstallExtension(id: string): Promise<void>;
triggerExtensionAction(id: string): Promise<void>;
listExtensions(): InstalledExtension[];
getExtension(id: string): InstalledExtension | undefined;
listExtensions(): Promise<Map<string, Extension>>;
getExtension(id: string): Promise<Extension | undefined>;
getSelectedMcpPage(): McpPage;
getExtensionServiceWorkers(): ExtensionServiceWorker[];
getExtensionServiceWorkerId(
+1 -1
View File
@@ -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.`);
}
-53
View File
@@ -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<string, InstalledExtension>();
async registerExtension(
id: string,
extensionPath: string,
): Promise<InstalledExtension> {
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);
}
}
+2 -2
View File
@@ -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"
}
]
+29 -17
View File
@@ -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<string, Extension>([
[
'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',
+44 -32
View File
@@ -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,
+10 -7
View File
@@ -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', () => {
'<extension-id>',
);
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', () => {
'<extension-id>',
);
t.assert.snapshot?.(text);
await context.uninstallExtension(extensionId);
const targets = context.browser.targets();
assertNoServiceWorkerReported(targets, extensionId);
},
{},
{
@@ -193,10 +195,11 @@ describe('pages', () => {
'<extension-id>',
);
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,
+9 -1
View File
@@ -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,
+8
View File
@@ -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 {