Skip to content

Commit d6db780

Browse files
committed
feat: implement console logs listener for extension workers
1 parent 57648b7 commit d6db780

13 files changed

Lines changed: 415 additions & 14 deletions

src/McpContext.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
type ListenerMap,
1919
type UncaughtError,
2020
} from './PageCollector.js';
21+
import {ServiceWorkerConsoleCollector} from './ServiceWorkerCollector.js';
22+
import type {DevTools} from './third_party/index.js';
2123
import type {
2224
Browser,
2325
BrowserContext,
@@ -31,7 +33,6 @@ import type {
3133
Target,
3234
Extension,
3335
} from './third_party/index.js';
34-
import type {DevTools} from './third_party/index.js';
3536
import {Locator} from './third_party/index.js';
3637
import {PredefinedNetworkConditions} from './third_party/index.js';
3738
import {listPages} from './tools/pages.js';
@@ -81,6 +82,7 @@ export class McpContext implements Context {
8182
#networkCollector: NetworkCollector;
8283
#consoleCollector: ConsoleCollector;
8384
#devtoolsUniverseManager: UniverseManager;
85+
#serviceWorkerConsoleCollector: ServiceWorkerConsoleCollector;
8486

8587
#isRunningTrace = false;
8688
#screenRecorderData: {recorder: ScreenRecorder; filePath: string} | null =
@@ -125,21 +127,26 @@ export class McpContext implements Context {
125127
},
126128
} as ListenerMap;
127129
});
130+
this.#serviceWorkerConsoleCollector = new ServiceWorkerConsoleCollector(
131+
this.browser,
132+
);
128133
this.#devtoolsUniverseManager = new UniverseManager(this.browser);
129134
}
130135

131136
async #init() {
132137
const pages = await this.createPagesSnapshot();
133-
await this.createExtensionServiceWorkersSnapshot();
138+
const workers = await this.createExtensionServiceWorkersSnapshot();
134139
await this.#networkCollector.init(pages);
135140
await this.#consoleCollector.init(pages);
136141
await this.#devtoolsUniverseManager.init(pages);
142+
await this.#serviceWorkerConsoleCollector.init(workers);
137143
}
138144

139145
dispose() {
140146
this.#networkCollector.dispose();
141147
this.#consoleCollector.dispose();
142148
this.#devtoolsUniverseManager.dispose();
149+
this.#serviceWorkerConsoleCollector.dispose();
143150
for (const mcpPage of this.#mcpPages.values()) {
144151
mcpPage.dispose();
145152
}
@@ -515,6 +522,12 @@ export class McpContext implements Context {
515522
return this.#extensionServiceWorkers;
516523
}
517524

525+
getServiceWorkerConsoleData(
526+
extensionId: string,
527+
): Array<ConsoleMessage | UncaughtError> {
528+
return this.#serviceWorkerConsoleCollector.getData(extensionId);
529+
}
530+
518531
async createPagesSnapshot(): Promise<Page[]> {
519532
const {pages: allPages, isolatedContextNames} = await this.#getAllPages();
520533

src/McpResponse.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ export class McpResponse implements Response {
191191
pagination?: PaginationOptions;
192192
types?: string[];
193193
includePreservedMessages?: boolean;
194+
serviceWorkerId?: string;
194195
};
195196
#listExtensions?: boolean;
196197
#listInPageTools?: boolean;
@@ -283,6 +284,7 @@ export class McpResponse implements Response {
283284
options?: PaginationOptions & {
284285
types?: string[];
285286
includePreservedMessages?: boolean;
287+
serviceWorkerId?: string;
286288
},
287289
): void {
288290
if (!value) {
@@ -301,6 +303,7 @@ export class McpResponse implements Response {
301303
: undefined,
302304
types: options?.types,
303305
includePreservedMessages: options?.includePreservedMessages,
306+
serviceWorkerId: options?.serviceWorkerId,
304307
};
305308
}
306309

@@ -547,14 +550,23 @@ export class McpResponse implements Response {
547550

548551
let consoleMessages: Array<ConsoleFormatter | IssueFormatter> | undefined;
549552
if (this.#consoleDataOptions?.include) {
550-
if (!this.#page) {
551-
throw new Error(`Response must have an McpPage`);
553+
let messages;
554+
let page: McpPage | undefined;
555+
556+
if (this.#consoleDataOptions.serviceWorkerId) {
557+
messages = context.getServiceWorkerConsoleData(
558+
this.#consoleDataOptions.serviceWorkerId,
559+
);
560+
} else {
561+
page = this.#page;
562+
if (!page) {
563+
throw new Error(`Response must have an McpPage`);
564+
}
565+
messages = context.getConsoleData(
566+
page,
567+
this.#consoleDataOptions.includePreservedMessages,
568+
);
552569
}
553-
const page = this.#page;
554-
let messages = context.getConsoleData(
555-
this.#page,
556-
this.#consoleDataOptions.includePreservedMessages,
557-
);
558570

559571
if (this.#consoleDataOptions.types?.length) {
560572
const normalizedTypes = new Set(this.#consoleDataOptions.types);
@@ -577,7 +589,9 @@ export class McpResponse implements Response {
577589
context.getConsoleMessageStableId(item);
578590
if ('args' in item || item instanceof UncaughtError) {
579591
const consoleMessage = item as ConsoleMessage | UncaughtError;
580-
const devTools = context.getDevToolsUniverse(page);
592+
const devTools = page
593+
? context.getDevToolsUniverse(page)
594+
: null;
581595
return await ConsoleFormatter.from(consoleMessage, {
582596
id: consoleMessageStableId,
583597
fetchDetailedData: false,

src/PageCollector.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,11 @@ export class PageCollector<T> {
194194

195195
const item = this.find(page, item => item[stableIdSymbol] === stableId);
196196

197-
if (item) {
198-
return item;
197+
if (!item) {
198+
throw new Error('Request not found for selected page');
199199
}
200200

201-
throw new Error('Request not found for selected page');
201+
return item;
202202
}
203203

204204
find(

src/ServiceWorkerCollector.ts

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {UncaughtError} from './PageCollector.js';
8+
import type {
9+
ConsoleMessage,
10+
WebWorker,
11+
Target,
12+
CDPSession,
13+
Protocol,
14+
Browser,
15+
} from './third_party/index.js';
16+
import type {ExtensionServiceWorker} from './types.js';
17+
import type {WithSymbolId} from './utils/id.js';
18+
import {createIdGenerator, stableIdSymbol} from './utils/id.js';
19+
20+
const CHROME_EXTENSION_PREFIX = 'chrome-extension://';
21+
22+
export class ServiceWorkerSubscriber {
23+
#target: Target;
24+
#callback: (item: ConsoleMessage | UncaughtError) => void;
25+
#session?: CDPSession;
26+
#worker?: WebWorker;
27+
28+
constructor(
29+
target: Target,
30+
callback: (item: ConsoleMessage | UncaughtError) => void,
31+
) {
32+
this.#target = target;
33+
this.#callback = callback;
34+
}
35+
36+
async subscribe() {
37+
this.#session = await this.#target.createCDPSession();
38+
await this.#session.send('Runtime.enable');
39+
this.#session.on('Runtime.exceptionThrown', this.#onExceptionThrown);
40+
41+
this.#worker = (await this.#target.worker()) ?? undefined;
42+
if (this.#worker) {
43+
this.#worker.on('console', this.#onConsole);
44+
}
45+
}
46+
47+
async unsubscribe() {
48+
if (this.#worker) {
49+
this.#worker.off('console', this.#onConsole);
50+
}
51+
if (this.#session) {
52+
this.#session.off('Runtime.exceptionThrown', this.#onExceptionThrown);
53+
await this.#session.send('Runtime.disable');
54+
}
55+
}
56+
57+
#onConsole = (message: ConsoleMessage) => {
58+
this.#callback(message);
59+
};
60+
61+
#onExceptionThrown = (event: Protocol.Runtime.ExceptionThrownEvent) => {
62+
const url = this.#target.url();
63+
64+
const extensionId = extractExtensionId(url);
65+
66+
if (extensionId) {
67+
this.#callback(new UncaughtError(event.exceptionDetails, extensionId));
68+
}
69+
};
70+
}
71+
72+
export class ServiceWorkerConsoleCollector {
73+
#storage = new Map<
74+
string,
75+
Array<WithSymbolId<ConsoleMessage | UncaughtError>>
76+
>();
77+
#maxLogs: number;
78+
#browser?: Browser;
79+
#serviceWorkerSubscribers = new Map<Target, ServiceWorkerSubscriber>();
80+
#idGenerator = createIdGenerator();
81+
82+
constructor(browser?: Browser, maxLogs = 1000) {
83+
this.#browser = browser;
84+
this.#maxLogs = maxLogs;
85+
}
86+
87+
async init(workers: ExtensionServiceWorker[]) {
88+
if (!this.#browser) {
89+
return;
90+
}
91+
this.#browser.on('targetcreated', this.#onTargetCreated);
92+
this.#browser.on('targetdestroyed', this.#onTargetDestroyed);
93+
94+
for (const worker of workers) {
95+
void this.#onTargetCreated(worker.target);
96+
}
97+
}
98+
99+
dispose() {
100+
if (!this.#browser) {
101+
return;
102+
}
103+
this.#browser.off('targetcreated', this.#onTargetCreated);
104+
this.#browser.off('targetdestroyed', this.#onTargetDestroyed);
105+
for (const subscriber of this.#serviceWorkerSubscribers.values()) {
106+
void subscriber.unsubscribe();
107+
}
108+
this.#serviceWorkerSubscribers.clear();
109+
}
110+
111+
#onTargetCreated = async (target: Target) => {
112+
if (this.#serviceWorkerSubscribers.has(target)) {
113+
return;
114+
}
115+
const origin = target.url();
116+
if (target.type() === 'service_worker' && isExtensionOrigin(origin)) {
117+
const extensionId = extractExtensionId(origin);
118+
119+
if (!extensionId) {
120+
return;
121+
}
122+
123+
const subscriber = new ServiceWorkerSubscriber(target, item => {
124+
this.addLog(extensionId, item);
125+
});
126+
void subscriber.subscribe();
127+
this.#serviceWorkerSubscribers.set(target, subscriber);
128+
}
129+
};
130+
131+
#onTargetDestroyed = async (target: Target) => {
132+
const subscriber = this.#serviceWorkerSubscribers.get(target);
133+
if (subscriber) {
134+
void subscriber.unsubscribe();
135+
this.#serviceWorkerSubscribers.delete(target);
136+
}
137+
};
138+
139+
addLog(extensionId: string, log: ConsoleMessage | UncaughtError) {
140+
const logs = this.#storage.get(extensionId) ?? [];
141+
const withId = log as WithSymbolId<ConsoleMessage | UncaughtError>;
142+
withId[stableIdSymbol] = this.#idGenerator();
143+
logs.push(withId);
144+
if (logs.length > this.#maxLogs) {
145+
logs.shift();
146+
}
147+
this.#storage.set(extensionId, logs);
148+
}
149+
150+
getData(
151+
extensionId: string,
152+
): Array<WithSymbolId<ConsoleMessage | UncaughtError>> {
153+
return this.#storage.get(extensionId) ?? [];
154+
}
155+
156+
getById(
157+
extensionId: string,
158+
stableId: number,
159+
): WithSymbolId<ConsoleMessage | UncaughtError> {
160+
const logs = this.#storage.get(extensionId);
161+
if (!logs) {
162+
throw new Error('No logs found for selected extension');
163+
}
164+
const item = logs.find(item => item[stableIdSymbol] === stableId);
165+
if (item) {
166+
return item;
167+
}
168+
throw new Error('Log not found for selected extension');
169+
}
170+
171+
find(
172+
extensionId: string,
173+
filter: (item: WithSymbolId<ConsoleMessage | UncaughtError>) => boolean,
174+
): WithSymbolId<ConsoleMessage | UncaughtError> | undefined {
175+
const logs = this.#storage.get(extensionId);
176+
if (!logs) {
177+
return;
178+
}
179+
return logs.find(filter);
180+
}
181+
182+
clearLogs(extensionId: string) {
183+
this.#storage.delete(extensionId);
184+
}
185+
}
186+
187+
function extractExtensionId(origin: string): string | null {
188+
if (!origin || !isExtensionOrigin(origin)) {
189+
return null;
190+
}
191+
192+
const pathPart = origin.substring(CHROME_EXTENSION_PREFIX.length);
193+
const slashIndex = pathPart.indexOf('/');
194+
195+
// if there's no / it means that pathPart is now the extensionId, otherwise
196+
// we take everything until the first /
197+
return slashIndex === -1 ? pathPart : pathPart.substring(0, slashIndex);
198+
}
199+
200+
function isExtensionOrigin(origin: string) {
201+
return origin.startsWith(CHROME_EXTENSION_PREFIX);
202+
}

src/tools/ToolDefinition.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ export interface Response {
126126
options?: PaginationOptions & {
127127
types?: string[];
128128
includePreservedMessages?: boolean;
129+
serviceWorkerId?: string;
129130
},
130131
): void;
131132
includeSnapshot(params?: SnapshotParams): void;

src/tools/console.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,20 @@ export const listConsoleMessages = definePageTool({
7575
.describe(
7676
'Set to true to return the preserved messages over the last 3 navigations.',
7777
),
78+
serviceWorkerId: zod
79+
.string()
80+
.optional()
81+
.describe(
82+
'The ID of the service worker to list messages for. When omitted, returns messages for the currently selected page.',
83+
),
7884
},
7985
handler: async (request, response) => {
8086
response.setIncludeConsoleData(true, {
8187
pageSize: request.params.pageSize,
8288
pageIdx: request.params.pageIdx,
8389
types: request.params.types,
8490
includePreservedMessages: request.params.includePreservedMessages,
91+
serviceWorkerId: request.params.serviceWorkerId,
8592
});
8693
},
8794
});

0 commit comments

Comments
 (0)