refactor: move DevTools universe to McpPage (#2341)
This commit is contained in:
+8
-14
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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.
|
||||
//
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user