refactor: move DevTools universe to McpPage (#2341)

This commit is contained in:
Alex Rudenko
2026-07-10 11:48:37 +02:00
committed by GitHub
parent 515f8afb58
commit c006c9ba5e
4 changed files with 44 additions and 147 deletions
+8 -14
View File
@@ -11,10 +11,7 @@ import path from 'node:path';
import {fileURLToPath, pathToFileURL} from 'node:url';
import type {TargetUniverse} from './devtools/DevtoolsUtils.js';
import {
overrideDevToolsGlobals,
UniverseManager,
} from './devtools/DevtoolsUtils.js';
import {overrideDevToolsGlobals} from './devtools/DevtoolsUtils.js';
import {HeapSnapshotManager} from './HeapSnapshotManager.js';
import type {
HeapSnapshotAggregateData,
@@ -92,7 +89,7 @@ export class McpContext implements Context {
#mcpPages = new Map<Page, McpPage>();
#selectedPage?: McpPage;
#selectedPageFallback?: {wasClosed: boolean};
#devtoolsUniverseManager: UniverseManager;
#serviceWorkerConsoleCollector: ServiceWorkerConsoleCollector;
#isRunningTrace = false;
@@ -134,13 +131,12 @@ export class McpContext implements Context {
this.#serviceWorkerConsoleCollector = new ServiceWorkerConsoleCollector(
this.browser,
);
this.#devtoolsUniverseManager = new UniverseManager(this.browser);
}
async #init() {
const pages = await this.createPagesSnapshot();
await this.createPagesSnapshot();
const workers = await this.createExtensionServiceWorkersSnapshot();
await this.#devtoolsUniverseManager.init(pages);
await this.#serviceWorkerConsoleCollector.init(workers);
this.browser.on('targetcreated', this.#onTargetCreated);
this.browser.on('targetdestroyed', this.#onTargetDestroyed);
@@ -149,7 +145,7 @@ export class McpContext implements Context {
dispose() {
this.browser.off('targetcreated', this.#onTargetCreated);
this.browser.off('targetdestroyed', this.#onTargetDestroyed);
this.#devtoolsUniverseManager.dispose();
this.#serviceWorkerConsoleCollector.dispose();
for (const mcpPage of this.#mcpPages.values()) {
mcpPage.dispose();
@@ -304,8 +300,9 @@ export class McpContext implements Context {
await this.validatePath(outputPath);
return outputPath;
}
getDevToolsUniverse(page: McpPage): TargetUniverse | null {
return this.#devtoolsUniverseManager.get(page.pptrPage);
return page.devtoolsUniverse ?? null;
}
getConsoleMessageStableId(
@@ -616,10 +613,7 @@ export class McpContext implements Context {
if (!mcpPage) {
mcpPage = new McpPage(page, this.#nextPageId++);
this.#mcpPages.set(page, mcpPage);
// We emulate a focused page for all pages to support multi-agent workflows.
void page.emulateFocusedPage(true).catch(error => {
this.logger?.('Error turning on focused page emulation', error);
});
void mcpPage.init();
}
return mcpPage;
}
+25
View File
@@ -4,6 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
createTargetUniverse,
type TargetUniverse,
} from './devtools/DevtoolsUtils.js';
import {logger} from './logger.js';
import {
ConsoleCollector,
@@ -65,6 +69,7 @@ export class McpPage implements ContextPage {
// Metadata
isolatedContextName?: string;
devToolsPage?: Page;
#devtoolsUniverse?: TargetUniverse;
// Dialog
#dialog?: Dialog;
@@ -99,6 +104,26 @@ export class McpPage implements ContextPage {
});
}
async init(): Promise<void> {
if (this.#devtoolsUniverse) {
return;
}
try {
this.#devtoolsUniverse = await createTargetUniverse(this.pptrPage);
} catch (e) {
logger?.('Failed to initialize DevTools universe', e);
}
// We emulate a focused page for all pages to support multi-agent workflows.
void this.pptrPage.emulateFocusedPage(true).catch(error => {
logger?.('Error turning on focused page emulation', error);
});
}
get devtoolsUniverse(): TargetUniverse | undefined {
return this.#devtoolsUniverse;
}
getDialog(): Dialog | undefined {
return this.#dialog;
}
+4 -80
View File
@@ -4,15 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {Mutex} from '../Mutex.js';
import {DevTools} from '../third_party/index.js';
import type {
Browser,
CDPSession,
ConsoleMessage,
Page,
Protocol,
Target as PuppeteerTarget,
} from '../third_party/index.js';
import {PuppeteerDevToolsConnection} from './DevToolsConnectionAdapter.js';
@@ -135,83 +132,10 @@ export interface TargetUniverse {
/** The secondary session created for this page */
session: CDPSession;
}
export type TargetUniverseFactoryFn = (page: Page) => Promise<TargetUniverse>;
export class UniverseManager {
readonly #browser: Browser;
readonly #createUniverseFor: TargetUniverseFactoryFn;
readonly #universes = new WeakMap<Page, TargetUniverse>();
/** Guard access to #universes so we don't create unnecessary universes */
readonly #mutex = new Mutex();
constructor(
browser: Browser,
factory: TargetUniverseFactoryFn = DEFAULT_FACTORY,
) {
this.#browser = browser;
this.#createUniverseFor = factory;
}
async init(pages: Page[]) {
try {
await this.#mutex.acquire();
const promises = [];
for (const page of pages) {
promises.push(
this.#createUniverseFor(page).then(targetUniverse =>
this.#universes.set(page, targetUniverse),
),
);
}
this.#browser.on('targetcreated', this.#onTargetCreated);
this.#browser.on('targetdestroyed', this.#onTargetDestroyed);
await Promise.all(promises);
} finally {
this.#mutex.release();
}
}
get(page: Page): TargetUniverse | null {
return this.#universes.get(page) ?? null;
}
dispose() {
this.#browser.off('targetcreated', this.#onTargetCreated);
this.#browser.off('targetdestroyed', this.#onTargetDestroyed);
}
#onTargetCreated = async (target: PuppeteerTarget) => {
const page = await target.page();
try {
await this.#mutex.acquire();
if (!page || this.#universes.has(page)) {
return;
}
this.#universes.set(page, await this.#createUniverseFor(page));
} finally {
this.#mutex.release();
}
};
#onTargetDestroyed = async (target: PuppeteerTarget) => {
const page = await target.page();
try {
await this.#mutex.acquire();
if (!page || !this.#universes.has(page)) {
return;
}
this.#universes.delete(page);
} finally {
this.#mutex.release();
}
};
}
const DEFAULT_FACTORY: TargetUniverseFactoryFn = async (page: Page) => {
export async function createTargetUniverse(
page: Page,
): Promise<TargetUniverse> {
const settingStorage = new DevTools.Common.Settings.SettingsStorage({});
const universe = new DevTools.Foundation.Universe.Universe({
settingsCreationOptions: {
@@ -245,7 +169,7 @@ const DEFAULT_FACTORY: TargetUniverseFactoryFn = async (page: Page) => {
connection,
);
return {target, universe, session};
};
}
// We don't want to pause any DevTools universe session ever on the MCP side.
//
+7 -53
View File
@@ -9,73 +9,29 @@ import {afterEach, describe, it} from 'node:test';
import sinon from 'sinon';
import {UniverseManager} from '../../src/devtools/DevtoolsUtils.js';
import {createTargetUniverse} from '../../src/devtools/DevtoolsUtils.js';
import {DevTools} from '../../src/third_party/index.js';
import type {Browser, Target} from '../../src/third_party/index.js';
import {serverHooks} from '../server.js';
import {
getMockBrowser,
getMockPage,
html,
mockListener,
withBrowser,
} from '../utils.js';
import {html, withBrowser} from '../utils.js';
describe('UniverseManager', () => {
describe('createTargetUniverse', () => {
const server = serverHooks();
afterEach(() => {
sinon.restore();
});
it('calls the factory for existing pages', async () => {
const browser = getMockBrowser();
const factory = sinon.stub().resolves({});
const manager = new UniverseManager(browser, factory);
await manager.init(await browser.pages());
const page = (await browser.pages())[0];
sinon.assert.calledOnceWithExactly(factory, page);
});
it('calls the factory only once for the same page', async () => {
const browser = {
...mockListener(),
} as unknown as Browser;
// eslint-disable-next-line @typescript-eslint/no-empty-function
const factory = sinon.stub().returns(new Promise(() => {})); // Don't resolve.
const manager = new UniverseManager(browser, factory);
await manager.init([]);
sinon.assert.notCalled(factory);
const page = getMockPage();
browser.emit('targetcreated', {
page: () => Promise.resolve(page),
} as Target);
browser.emit('targetcreated', {
page: () => Promise.resolve(page),
} as Target);
await new Promise(r => setTimeout(r, 0)); // One event loop tick for the micro task queue to run.
sinon.assert.calledOnceWithExactly(factory, page);
});
it('works with a real browser', async () => {
await withBrowser(async (browser, page) => {
const manager = new UniverseManager(browser);
await manager.init([page]);
const targetUniverse = await createTargetUniverse(page);
assert.notStrictEqual(manager.get(page), null);
assert.notStrictEqual(targetUniverse, null);
});
});
it('ignores pauses', async () => {
await withBrowser(async (browser, page) => {
const manager = new UniverseManager(browser);
await manager.init([page]);
const targetUniverse = manager.get(page);
const targetUniverse = await createTargetUniverse(page);
assert.ok(targetUniverse);
const model = targetUniverse.target.model(DevTools.DebuggerModel);
assert.ok(model);
@@ -94,9 +50,7 @@ describe('UniverseManager', () => {
server.addHtmlRoute('/test', html`<div>Test</div>`);
await withBrowser(async (browser, page) => {
const manager = new UniverseManager(browser);
await manager.init([page]);
const targetUniverse = manager.get(page);
const targetUniverse = await createTargetUniverse(page);
assert.ok(targetUniverse);
const networkManager = targetUniverse.target.model(