diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 238d5981a..f98784a9e 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -391,6 +391,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. --- diff --git a/src/McpContext.ts b/src/McpContext.ts index 0b8107693..33b60d78e 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -20,22 +20,23 @@ import { type ListenerMap, type UncaughtError, } from './PageCollector.js'; -import { - Locator, - PredefinedNetworkConditions, - type Browser, - type BrowserContext, - type ConsoleMessage, - type Debugger, - type HTTPRequest, - type Page, - type ScreenRecorder, - type Viewport, - type Target, - type Extension, - type Root, - type DevTools, +import {ServiceWorkerConsoleCollector} from './ServiceWorkerCollector.js'; +import type {DevTools} from './third_party/index.js'; +import type { + Browser, + BrowserContext, + ConsoleMessage, + Debugger, + HTTPRequest, + Page, + ScreenRecorder, + Viewport, + Target, + Extension, + Root, } from './third_party/index.js'; +import {Locator} from './third_party/index.js'; +import {PredefinedNetworkConditions} from './third_party/index.js'; import {listPages} from './tools/pages.js'; import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js'; import type {Context, SupportedExtensions} from './tools/ToolDefinition.js'; @@ -77,6 +78,7 @@ export class McpContext implements Context { #networkCollector: NetworkCollector; #consoleCollector: ConsoleCollector; #devtoolsUniverseManager: UniverseManager; + #serviceWorkerConsoleCollector: ServiceWorkerConsoleCollector; #isRunningTrace = false; #screenRecorderData: {recorder: ScreenRecorder; filePath: string} | null = @@ -121,21 +123,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(); } @@ -524,6 +531,12 @@ export class McpContext implements Context { return this.#extensionServiceWorkers; } + getServiceWorkerConsoleData( + extensionId: string, + ): Array { + return this.#serviceWorkerConsoleCollector.getData(extensionId); + } + async createPagesSnapshot(): Promise { const {pages: allPages, isolatedContextNames} = await this.#getAllPages(); diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 3c4044b36..6e00584c1 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -197,6 +197,7 @@ export class McpResponse implements Response { pagination?: PaginationOptions; types?: string[]; includePreservedMessages?: boolean; + serviceWorkerId?: string; }; #listExtensions?: boolean; #listThirdPartyDeveloperTools?: boolean; @@ -289,6 +290,7 @@ export class McpResponse implements Response { options?: PaginationOptions & { types?: string[]; includePreservedMessages?: boolean; + serviceWorkerId?: string; }, ): void { if (!value) { @@ -307,6 +309,7 @@ export class McpResponse implements Response { : undefined, types: options?.types, includePreservedMessages: options?.includePreservedMessages, + serviceWorkerId: options?.serviceWorkerId, }; } @@ -582,14 +585,23 @@ export class McpResponse implements Response { let consoleMessages: Array | 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); @@ -612,7 +624,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, diff --git a/src/PageCollector.ts b/src/PageCollector.ts index 962026491..5b26bf2ac 100644 --- a/src/PageCollector.ts +++ b/src/PageCollector.ts @@ -194,11 +194,11 @@ export class PageCollector { 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( diff --git a/src/ServiceWorkerCollector.ts b/src/ServiceWorkerCollector.ts new file mode 100644 index 000000000..b6cbb4b3c --- /dev/null +++ b/src/ServiceWorkerCollector.ts @@ -0,0 +1,202 @@ +/** + * @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); + } + if (this.#session) { + this.#session.off('Runtime.exceptionThrown', this.#onExceptionThrown); + await this.#session.send('Runtime.disable'); + } + } + + #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> + >(); + #maxLogs: number; + #browser?: Browser; + #serviceWorkerSubscribers = new Map(); + #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()) { + void subscriber.unsubscribe(); + } + 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); + }); + void subscriber.subscribe(); + this.#serviceWorkerSubscribers.set(target, subscriber); + } + }; + + #onTargetDestroyed = async (target: Target) => { + const subscriber = this.#serviceWorkerSubscribers.get(target); + if (subscriber) { + void subscriber.unsubscribe(); + this.#serviceWorkerSubscribers.delete(target); + } + }; + + addLog(extensionId: string, log: ConsoleMessage | UncaughtError) { + const logs = this.#storage.get(extensionId) ?? []; + const withId = log as WithSymbolId; + withId[stableIdSymbol] = this.#idGenerator(); + logs.push(withId); + if (logs.length > this.#maxLogs) { + logs.shift(); + } + this.#storage.set(extensionId, logs); + } + + getData( + extensionId: string, + ): Array> { + return this.#storage.get(extensionId) ?? []; + } + + getById( + extensionId: string, + stableId: number, + ): WithSymbolId { + 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) => boolean, + ): WithSymbolId | 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); +} diff --git a/src/bin/chrome-devtools-cli-options.ts b/src/bin/chrome-devtools-cli-options.ts index a0e315c42..fc5345486 100644 --- a/src/bin/chrome-devtools-cli-options.ts +++ b/src/bin/chrome-devtools-cli-options.ts @@ -492,6 +492,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: { diff --git a/src/telemetry/tool_call_metrics.json b/src/telemetry/tool_call_metrics.json index 2da34dc5f..3b4748e4c 100644 --- a/src/telemetry/tool_call_metrics.json +++ b/src/telemetry/tool_call_metrics.json @@ -250,6 +250,10 @@ { "name": "include_preserved_messages", "argType": "boolean" + }, + { + "name": "service_worker_id_length", + "argType": "number" } ] }, diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 54fb19109..60ef99188 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -132,6 +132,7 @@ export interface Response { options?: PaginationOptions & { types?: string[]; includePreservedMessages?: boolean; + serviceWorkerId?: string; }, ): void; includeSnapshot(params?: SnapshotParams): void; diff --git a/src/tools/console.ts b/src/tools/console.ts index c0c0fd6c6..d975d53ca 100644 --- a/src/tools/console.ts +++ b/src/tools/console.ts @@ -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, handler: async (request, response) => { @@ -85,6 +91,7 @@ export const listConsoleMessages = definePageTool(cliArgs => { pageIdx: request.params.pageIdx, types: request.params.types, includePreservedMessages: request.params.includePreservedMessages, + serviceWorkerId: request.params.serviceWorkerId, }); }, }; diff --git a/tests/ServiceWorkerCollector.test.ts b/tests/ServiceWorkerCollector.test.ts new file mode 100644 index 000000000..2417e7a62 --- /dev/null +++ b/tests/ServiceWorkerCollector.test.ts @@ -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', + ); + }); +}); diff --git a/tests/tools/console.test.js.snapshot b/tests/tools/console.test.js.snapshot index 68ee72568..4e5d8d95a 100644 --- a/tests/tools/console.test.js.snapshot +++ b/tests/tools/console.test.js.snapshot @@ -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 diff --git a/tests/tools/console.test.ts b/tests/tools/console.test.ts index 41e310cec..f79d60fcd 100644 --- a/tests/tools/console.test.ts +++ b/tests/tools/console.test.ts @@ -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'; @@ -18,17 +19,118 @@ 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, } 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); + if (!sw) { + assert.fail('Service worker not found in context list'); + } + + const response2 = new McpResponse({} as ParsedArguments); + + await context.triggerExtensionAction(extensionId); + const worker = await swTarget.worker(); + + // On Windows, the service worker context might not be fully initialized + // with all global APIs yet. + await worker?.evaluate(` + (async () => { + while (typeof globalThis.setTimeout !== 'function') { + await new Promise(resolve => Promise.resolve().then(resolve)); + } + })() + `); + + await worker?.evaluate( + ` + console.log('Service Worker starting...'); + console.warn('This is a warning from Service Worker'); + globalThis.setTimeout(() => { + throw new Error('Intentional error from Service Worker'); + }, 100); + `, + ); + + // This is important to wait logs from extension. + await new Promise(resolve => setTimeout(resolve, 500)); + + 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'), + '', + ); + + 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', + ); + }, + {}, + { + categoryExtensions: true, + } as ParsedArguments, + ); + }); + describe('list_console_messages', () => { it('list messages', async () => { await withMcpContext(async (response, context) => { diff --git a/tests/tools/extensions.test.ts b/tests/tools/extensions.test.ts index 59580ddd8..d760be152 100644 --- a/tests/tools/extensions.test.ts +++ b/tests/tools/extensions.test.ts @@ -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!); }, {}, { diff --git a/tests/tools/fixtures/extension-logging/manifest.json b/tests/tools/fixtures/extension-logging/manifest.json new file mode 100644 index 000000000..a11cf0ac2 --- /dev/null +++ b/tests/tools/fixtures/extension-logging/manifest.json @@ -0,0 +1,8 @@ +{ + "manifest_version": 3, + "name": "Test Extension for Logging", + "version": "1.0", + "background": { + "service_worker": "sw.js" + } +} diff --git a/tests/tools/fixtures/extension-logging/sw.js b/tests/tools/fixtures/extension-logging/sw.js new file mode 100644 index 000000000..e4ddc27a8 --- /dev/null +++ b/tests/tools/fixtures/extension-logging/sw.js @@ -0,0 +1 @@ +// Minimal service worker for testing diff --git a/tests/tools/script.test.ts b/tests/tools/script.test.ts index 2718deb21..5f69e5de2 100644 --- a/tests/tools/script.test.ts +++ b/tests/tools/script.test.ts @@ -332,6 +332,8 @@ describe('script', () => { const swId = context.getExtensionServiceWorkerId(sw); + await context.triggerExtensionAction(extensionId); + response.resetResponseLineForTesting(); await evaluateScript({ categoryExtensions: true,