feat: implement extension service worker logs (#1915)

This PR introduces the possiblity to collect console messages from
service workers.
This commit is contained in:
Nicholas Roscino
2026-06-08 14:30:37 +02:00
committed by GitHub
parent 02b4492ca6
commit 29e389848f
18 changed files with 516 additions and 12 deletions
+1
View File
@@ -394,6 +394,7 @@ so returned values have to be JSON-serializable.
- **includePreservedMessages** (boolean) _(optional)_: Set to true to return the preserved messages over the last 3 navigations.
- **pageIdx** (integer) _(optional)_: Page number to return (0-based). When omitted, returns the first page.
- **pageSize** (integer) _(optional)_: Maximum number of messages to return. When omitted, returns all messages.
- **serviceWorkerId** (string) _(optional)_: Filter messages to only return messages of the specified service worker.
- **types** (array) _(optional)_: Filter messages to only return messages of the specified resource types. When omitted or empty, returns all messages.
---
+14 -1
View File
@@ -21,6 +21,7 @@ import {
type ListenerMap,
type UncaughtError,
} from './PageCollector.js';
import {ServiceWorkerConsoleCollector} from './ServiceWorkerCollector.js';
import {
Locator,
PredefinedNetworkConditions,
@@ -84,6 +85,7 @@ export class McpContext implements Context {
#networkCollector: NetworkCollector;
#consoleCollector: ConsoleCollector;
#devtoolsUniverseManager: UniverseManager;
#serviceWorkerConsoleCollector: ServiceWorkerConsoleCollector;
#isRunningTrace = false;
#screenRecorderData: {recorder: ScreenRecorder; filePath: string} | null =
@@ -128,21 +130,26 @@ export class McpContext implements Context {
},
} as ListenerMap;
});
this.#serviceWorkerConsoleCollector = new ServiceWorkerConsoleCollector(
this.browser,
);
this.#devtoolsUniverseManager = new UniverseManager(this.browser);
}
async #init() {
const pages = await this.createPagesSnapshot();
await this.createExtensionServiceWorkersSnapshot();
const workers = await this.createExtensionServiceWorkersSnapshot();
await this.#networkCollector.init(pages);
await this.#consoleCollector.init(pages);
await this.#devtoolsUniverseManager.init(pages);
await this.#serviceWorkerConsoleCollector.init(workers);
}
dispose() {
this.#networkCollector.dispose();
this.#consoleCollector.dispose();
this.#devtoolsUniverseManager.dispose();
this.#serviceWorkerConsoleCollector.dispose();
for (const mcpPage of this.#mcpPages.values()) {
mcpPage.dispose();
}
@@ -590,6 +597,12 @@ export class McpContext implements Context {
return this.#extensionServiceWorkers;
}
getServiceWorkerConsoleData(
extensionId: string,
): Array<ConsoleMessage | UncaughtError> {
return this.#serviceWorkerConsoleCollector.getData(extensionId);
}
async createPagesSnapshot(): Promise<Page[]> {
const {pages: allPages, isolatedContextNames} = await this.#getAllPages();
+22 -8
View File
@@ -232,6 +232,7 @@ export class McpResponse implements Response {
pagination?: PaginationOptions;
types?: string[];
includePreservedMessages?: boolean;
serviceWorkerId?: string;
};
#listExtensions?: boolean;
#listThirdPartyDeveloperTools?: boolean;
@@ -328,6 +329,7 @@ export class McpResponse implements Response {
options?: PaginationOptions & {
types?: string[];
includePreservedMessages?: boolean;
serviceWorkerId?: string;
},
): void {
if (!value) {
@@ -346,6 +348,7 @@ export class McpResponse implements Response {
: undefined,
types: options?.types,
includePreservedMessages: options?.includePreservedMessages,
serviceWorkerId: options?.serviceWorkerId,
};
}
@@ -620,14 +623,23 @@ export class McpResponse implements Response {
let consoleMessages: Array<ConsoleFormatter | IssueFormatter> | undefined;
if (this.#consoleDataOptions?.include) {
if (!this.#page) {
throw new Error(`Response must have an McpPage`);
let messages;
let page: McpPage | undefined;
if (this.#consoleDataOptions.serviceWorkerId) {
messages = context.getServiceWorkerConsoleData(
this.#consoleDataOptions.serviceWorkerId,
);
} else {
page = this.#page;
if (!page) {
throw new Error(`Response must have an McpPage`);
}
messages = context.getConsoleData(
page,
this.#consoleDataOptions.includePreservedMessages,
);
}
const page = this.#page;
let messages = context.getConsoleData(
this.#page,
this.#consoleDataOptions.includePreservedMessages,
);
if (this.#consoleDataOptions.types?.length) {
const normalizedTypes = new Set(this.#consoleDataOptions.types);
@@ -650,7 +662,9 @@ export class McpResponse implements Response {
context.getConsoleMessageStableId(item);
if ('args' in item || item instanceof UncaughtError) {
const consoleMessage = item as ConsoleMessage | UncaughtError;
const devTools = context.getDevToolsUniverse(page);
const devTools = page
? context.getDevToolsUniverse(page)
: null;
return await ConsoleFormatter.from(consoleMessage, {
id: consoleMessageStableId,
fetchDetailedData: false,
+3 -3
View File
@@ -194,11 +194,11 @@ export class PageCollector<T> {
const item = this.find(page, item => item[stableIdSymbol] === stableId);
if (item) {
return item;
if (!item) {
throw new Error('Request not found for selected page');
}
throw new Error('Request not found for selected page');
return item;
}
find(
+227
View File
@@ -0,0 +1,227 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {UncaughtError} from './PageCollector.js';
import type {
ConsoleMessage,
WebWorker,
Target,
CDPSession,
Protocol,
Browser,
} from './third_party/index.js';
import type {ExtensionServiceWorker} from './types.js';
import type {WithSymbolId} from './utils/id.js';
import {createIdGenerator, stableIdSymbol} from './utils/id.js';
const CHROME_EXTENSION_PREFIX = 'chrome-extension://';
export class ServiceWorkerSubscriber {
#target: Target;
#callback: (item: ConsoleMessage | UncaughtError) => void;
#session?: CDPSession;
#worker?: WebWorker;
constructor(
target: Target,
callback: (item: ConsoleMessage | UncaughtError) => void,
) {
this.#target = target;
this.#callback = callback;
}
async subscribe() {
this.#session = await this.#target.createCDPSession();
await this.#session.send('Runtime.enable');
this.#session.on('Runtime.exceptionThrown', this.#onExceptionThrown);
this.#worker = (await this.#target.worker()) ?? undefined;
if (this.#worker) {
this.#worker.on('console', this.#onConsole);
}
}
async unsubscribe() {
if (this.#worker) {
this.#worker.off('console', this.#onConsole);
}
await this.#session?.detach();
}
#onConsole = (message: ConsoleMessage) => {
this.#callback(message);
};
#onExceptionThrown = (event: Protocol.Runtime.ExceptionThrownEvent) => {
const url = this.#target.url();
const extensionId = extractExtensionId(url);
if (extensionId) {
this.#callback(new UncaughtError(event.exceptionDetails, extensionId));
}
};
}
export class ServiceWorkerConsoleCollector {
#storage = new Map<
string,
Array<WithSymbolId<ConsoleMessage | UncaughtError>>
>();
#maxLogs: number;
#browser?: Browser;
#serviceWorkerSubscribers = new Map<Target, ServiceWorkerSubscriber>();
#idGenerator = createIdGenerator();
constructor(browser?: Browser, maxLogs = 1000) {
this.#browser = browser;
this.#maxLogs = maxLogs;
}
async init(workers: ExtensionServiceWorker[]) {
if (!this.#browser) {
return;
}
this.#browser.on('targetcreated', this.#onTargetCreated);
this.#browser.on('targetdestroyed', this.#onTargetDestroyed);
for (const worker of workers) {
void this.#onTargetCreated(worker.target);
}
}
dispose() {
if (!this.#browser) {
return;
}
this.#browser.off('targetcreated', this.#onTargetCreated);
this.#browser.off('targetdestroyed', this.#onTargetDestroyed);
for (const subscriber of this.#serviceWorkerSubscribers.values()) {
subscriber.unsubscribe().catch(err => {
if (
err instanceof Error &&
!err.message.includes('Target closed') &&
!err.message.includes('Session closed')
) {
// Swallow error as we are tearing down the system
}
});
}
this.#serviceWorkerSubscribers.clear();
}
#onTargetCreated = async (target: Target) => {
if (this.#serviceWorkerSubscribers.has(target)) {
return;
}
const origin = target.url();
if (target.type() === 'service_worker' && isExtensionOrigin(origin)) {
const extensionId = extractExtensionId(origin);
if (!extensionId) {
return;
}
const subscriber = new ServiceWorkerSubscriber(target, item => {
this.addLog(extensionId, item);
});
try {
await subscriber.subscribe();
} catch (err) {
if (
err instanceof Error &&
!err.message.includes('Target closed') &&
!err.message.includes('Session closed')
) {
throw err;
}
}
this.#serviceWorkerSubscribers.set(target, subscriber);
}
};
#onTargetDestroyed = async (target: Target) => {
const subscriber = this.#serviceWorkerSubscribers.get(target);
if (subscriber) {
try {
await subscriber.unsubscribe();
} catch (err) {
if (
err instanceof Error &&
!err.message.includes('Target closed') &&
!err.message.includes('Session closed')
) {
throw err;
}
}
this.#serviceWorkerSubscribers.delete(target);
}
};
addLog(extensionId: string, log: ConsoleMessage | UncaughtError) {
const logs = this.#storage.get(extensionId) ?? [];
const withId = log as WithSymbolId<ConsoleMessage | UncaughtError>;
withId[stableIdSymbol] = this.#idGenerator();
logs.push(withId);
if (logs.length > this.#maxLogs) {
logs.shift();
}
this.#storage.set(extensionId, logs);
}
getData(
extensionId: string,
): Array<WithSymbolId<ConsoleMessage | UncaughtError>> {
return this.#storage.get(extensionId) ?? [];
}
getById(
extensionId: string,
stableId: number,
): WithSymbolId<ConsoleMessage | UncaughtError> {
const logs = this.#storage.get(extensionId);
if (!logs) {
throw new Error('No logs found for selected extension');
}
const item = logs.find(item => item[stableIdSymbol] === stableId);
if (item) {
return item;
}
throw new Error('Log not found for selected extension');
}
find(
extensionId: string,
filter: (item: WithSymbolId<ConsoleMessage | UncaughtError>) => boolean,
): WithSymbolId<ConsoleMessage | UncaughtError> | undefined {
const logs = this.#storage.get(extensionId);
if (!logs) {
return;
}
return logs.find(filter);
}
clearLogs(extensionId: string) {
this.#storage.delete(extensionId);
}
}
function extractExtensionId(origin: string): string | null {
if (!origin || !isExtensionOrigin(origin)) {
return null;
}
const pathPart = origin.substring(CHROME_EXTENSION_PREFIX.length);
const slashIndex = pathPart.indexOf('/');
// if there's no / it means that pathPart is now the extensionId, otherwise
// we take everything until the first /
return slashIndex === -1 ? pathPart : pathPart.substring(0, slashIndex);
}
function isExtensionOrigin(origin: string) {
return origin.startsWith(CHROME_EXTENSION_PREFIX);
}
+7
View File
@@ -555,6 +555,13 @@ export const commands: Commands = {
required: false,
default: false,
},
serviceWorkerId: {
name: 'serviceWorkerId',
type: 'string',
description:
'Filter messages to only return messages of the specified service worker.',
required: false,
},
},
},
list_extensions: {
+4
View File
@@ -254,6 +254,10 @@
{
"name": "include_preserved_messages",
"argType": "boolean"
},
{
"name": "service_worker_id_length",
"argType": "number"
}
]
},
+3
View File
@@ -47,7 +47,10 @@ export {default as puppeteer} from 'puppeteer-core';
export type * from 'puppeteer-core';
export {PipeTransport} from 'puppeteer-core/internal/node/PipeTransport.js';
export type {CdpPage} from 'puppeteer-core/internal/cdp/Page.js';
export type {CdpWebWorker} from 'puppeteer-core/internal/cdp/WebWorker.js';
export type {Realm} from 'puppeteer-core/internal/api/Realm.js';
export type {JSONSchema7, JSONSchema7Definition} from 'json-schema';
export {
resolveDefaultUserDataDir,
detectBrowserPlatform,
+1
View File
@@ -130,6 +130,7 @@ export interface Response {
options?: PaginationOptions & {
types?: string[];
includePreservedMessages?: boolean;
serviceWorkerId?: string;
},
): void;
includeSnapshot(params?: SnapshotParams): void;
+7
View File
@@ -77,6 +77,12 @@ export const listConsoleMessages = definePageTool(cliArgs => {
.describe(
'Set to true to return the preserved messages over the last 3 navigations.',
),
serviceWorkerId: zod
.string()
.optional()
.describe(
'Filter messages to only return messages of the specified service worker.',
),
},
blockedByDialog: false,
verifyFilesSchema: [],
@@ -86,6 +92,7 @@ export const listConsoleMessages = definePageTool(cliArgs => {
pageIdx: request.params.pageIdx,
types: request.params.types,
includePreservedMessages: request.params.includePreservedMessages,
serviceWorkerId: request.params.serviceWorkerId,
});
},
};
+75
View File
@@ -0,0 +1,75 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import {describe, it} from 'node:test';
import {UncaughtError} from '../src/PageCollector.js';
import {ServiceWorkerConsoleCollector} from '../src/ServiceWorkerCollector.js';
import type {Protocol} from '../src/third_party/index.js';
import {stableIdSymbol} from '../src/utils/id.js';
describe('ServiceWorkerConsoleCollector', () => {
it('limits logs to 1000 per extension', () => {
const collector = new ServiceWorkerConsoleCollector(undefined, 10);
const extensionId = 'test-extension';
const mockDetails: Protocol.Runtime.ExceptionDetails = {
exceptionId: 1,
text: 'Error',
lineNumber: 1,
columnNumber: 1,
};
for (let i = 0; i < 15; i++) {
const error = new UncaughtError(
{...mockDetails, exceptionId: i},
extensionId,
);
collector.addLog(extensionId, error);
}
const logs = collector.getData(extensionId);
assert.strictEqual(logs.length, 10, 'Should limit logs to 10');
const firstLog = logs[0] as UncaughtError;
assert.strictEqual(
firstLog.details.exceptionId,
5,
'Oldest log should be Log 5',
);
const lastLog = logs[logs.length - 1] as UncaughtError;
assert.strictEqual(
lastLog.details.exceptionId,
14,
'Last log should be Log 14',
);
const data = collector.getData(extensionId);
assert.strictEqual(data.length, 10, 'getData should return limited logs');
const logToFind = data[0];
const logId = logToFind[stableIdSymbol];
assert.ok(logId, 'Log should have a stable ID');
const foundLog = collector.getById(extensionId, logId);
assert.strictEqual(
foundLog,
logToFind,
'getById should return correct log',
);
const foundViaFind = collector.find(extensionId, item => {
return item[stableIdSymbol] === logId;
});
assert.strictEqual(
foundViaFind,
logToFind,
'find should return correct log',
);
});
});
+8
View File
@@ -1,3 +1,11 @@
exports[`console > captures logs and errors from extension service worker 1`] = `
## Console messages
Showing 1-3 of 3 (Page 1 of 1).
msgid=1 [log] Service Worker starting... (1 args)
msgid=2 [warn] This is a warning from Service Worker (1 args)
msgid=3 [error] Uncaught Error: Intentional error from Service Worker (0 args)
`;
exports[`console > get_console_message > applies source maps to stack traces of Error object (with cause) console.log arguments 1`] = `
ID: 1
Message: log> foo failed Error: bar failed
+113
View File
@@ -5,6 +5,7 @@
*/
import assert from 'node:assert';
import path from 'node:path';
import {before, describe, it} from 'node:test';
import type {Dialog} from 'puppeteer-core';
@@ -13,22 +14,134 @@ import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-option
import {loadIssueDescriptions} from '../../src/issue-descriptions.js';
import {McpResponse} from '../../src/McpResponse.js';
import {TextSnapshot} from '../../src/TextSnapshot.js';
import type {CdpWebWorker} from '../../src/third_party/index.js';
import {DevTools} from '../../src/third_party/index.js';
import {
getConsoleMessage,
listConsoleMessages,
} from '../../src/tools/console.js';
import {installExtension} from '../../src/tools/extensions.js';
import {serverHooks} from '../server.js';
import {
getTextContent,
withMcpContext,
stabilizeStructuredContent,
extractExtensionId,
assertNoServiceWorkerReported,
waitExecutionFor,
} from '../utils.js';
const EXTENSION_LOGGING_PATH = path.join(
import.meta.dirname,
'../../../tests/tools/fixtures/extension-logging',
);
describe('console', () => {
before(async () => {
await loadIssueDescriptions();
});
it('captures logs and errors from extension service worker', async t => {
await withMcpContext(
async (response, context) => {
await installExtension.handler(
{params: {path: EXTENSION_LOGGING_PATH}},
response,
context,
);
const extensionId = extractExtensionId(response);
assert.ok(extensionId, 'Extension ID should be returned');
const swTarget = await context.browser.waitForTarget(
t => t.type() === 'service_worker' && t.url().includes(extensionId),
);
const swList = await context.createExtensionServiceWorkersSnapshot();
const sw = swList.find(s => s.target === swTarget);
assert(sw, 'Service worker not found in context list');
const response2 = new McpResponse({} as ParsedArguments);
await context.triggerExtensionAction(extensionId);
const worker = await swTarget.worker();
const timeout = 10000;
await waitExecutionFor(async () => {
await worker?.evaluate(() => {
if (typeof globalThis.setTimeout !== 'function') {
throw new Error('Not ready');
}
});
}, timeout);
const errorPromise = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Timeout waiting for Service Worker error'));
}, 5000);
(worker as unknown as CdpWebWorker).client.on(
'Runtime.exceptionThrown',
() => {
clearTimeout(timeout);
resolve(undefined);
},
);
});
worker?.evaluate(() => {
globalThis.setTimeout(() => {
throw new Error('Intentional error from Service Worker');
}, 100);
});
await errorPromise;
response2.resetResponseLineForTesting();
await listConsoleMessages({
categoryExtensions: true,
} as ParsedArguments).handler(
{
params: {serviceWorkerId: extensionId},
page: context.getSelectedMcpPage(),
},
response2,
context,
);
const formattedResponse = await response2.handle('test', context);
const textContent = getTextContent(formattedResponse.content[0]);
const sanitizedText = textContent.replaceAll(
new RegExp(extensionId, 'g'),
'<extension-id>',
);
t.assert.snapshot?.(sanitizedText);
assert.ok(
sanitizedText.includes('Service Worker starting...'),
'Should contain start log',
);
assert.ok(
sanitizedText.includes('This is a warning from Service Worker'),
'Should contain warning log',
);
assert.ok(
sanitizedText.includes('Intentional error from Service Worker'),
'Should contain error log',
);
await context.uninstallExtension(extensionId);
const targets = context.browser.targets();
assertNoServiceWorkerReported(targets, extensionId);
},
{},
{
categoryExtensions: true,
} as ParsedArguments,
);
});
describe('list_console_messages', () => {
it('list messages', async () => {
await withMcpContext(async (response, context) => {
+1
View File
@@ -138,6 +138,7 @@ describe('extension', () => {
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');
await context.uninstallExtension(extensionId!);
},
{},
{
@@ -0,0 +1,8 @@
{
"manifest_version": 3,
"name": "Test Extension for Logging",
"version": "1.0",
"background": {
"service_worker": "sw.js"
}
}
@@ -0,0 +1,3 @@
// Minimal service worker for testing
console.log('Service Worker starting...');
console.warn('This is a warning from Service Worker');
+2
View File
@@ -332,6 +332,8 @@ describe('script', () => {
const swId = context.getExtensionServiceWorkerId(sw);
await context.triggerExtensionAction(extensionId);
response.resetResponseLineForTesting();
await evaluateScript({
categoryExtensions: true,
+17
View File
@@ -408,3 +408,20 @@ export async function assertDaemonIsRunning(sessionId?: string) {
'chrome-devtools-mcp daemon is not running',
);
}
export async function waitExecutionFor(
func: () => Promise<void>,
timeout: number,
) {
const start = Date.now();
while (Date.now() - start < 10000) {
try {
await func();
return;
} catch {
await new Promise(resolve => setTimeout(resolve, 100)); // wait and retry
}
}
throw new Error(`Timeout of ${timeout} reached.`);
}