diff --git a/scripts/post-build.ts b/scripts/post-build.ts index 0010e22ef..011b71aa5 100644 --- a/scripts/post-build.ts +++ b/scripts/post-build.ts @@ -88,6 +88,13 @@ export const LOCAL_FETCH_PATTERN = './locales/@LOCALE@.json';`; const runtimeContent = ` export function getChromeVersion() { return ''; }; export const hostConfig = {}; +export const Runtime = { + isDescriptorEnabled: () => true, + queryParam: () => null, +} +export const experiments = { + isEnabled: () => false, +} `; writeFile(runtimeFile, runtimeContent); diff --git a/src/DevtoolsUtils.ts b/src/DevtoolsUtils.ts index 26f3da991..9ec320441 100644 --- a/src/DevtoolsUtils.ts +++ b/src/DevtoolsUtils.ts @@ -8,6 +8,10 @@ import { type Issue, type AggregatedIssue, type IssuesManagerEventTypes, + type Target, + DebuggerModel, + Foundation, + TargetManager, MarkdownIssueDescription, Marked, ProtocolClient, @@ -15,8 +19,15 @@ import { I18n, } from '../node_modules/chrome-devtools-frontend/mcp/mcp.js'; +import {PuppeteerDevToolsConnection} from './DevToolsConnectionAdapter.js'; import {ISSUE_UTILS} from './issue-descriptions.js'; import {logger} from './logger.js'; +import {Mutex} from './Mutex.js'; +import type { + Browser, + Page, + Target as PuppeteerTarget, +} from './third_party/index.js'; export function extractUrlLikeFromDevToolsTitle( title: string, @@ -138,3 +149,112 @@ I18n.DevToolsLocale.DevToolsLocale.instance({ }, }); I18n.i18n.registerLocaleDataForTest('en-US', {}); + +export interface TargetUniverse { + /** The DevTools target corresponding to the puppeteer Page */ + target: Target; + universe: Foundation.Universe.Universe; +} +export type TargetUniverseFactoryFn = (page: Page) => Promise; + +export class UniverseManager { + readonly #browser: Browser; + readonly #createUniverseFor: TargetUniverseFactoryFn; + readonly #universes = new WeakMap(); + + /** 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) => { + const settingStorage = new Common.Settings.SettingsStorage({}); + const universe = new Foundation.Universe.Universe({ + settingsCreationOptions: { + syncedStorage: settingStorage, + globalStorage: settingStorage, + localStorage: settingStorage, + settingRegistrations: Common.SettingRegistration.getRegisteredSettings(), + }, + overrideAutoStartModels: new Set([DebuggerModel]), + }); + + const session = await page.createCDPSession(); + const connection = new PuppeteerDevToolsConnection(session); + + const targetManager = universe.context.get(TargetManager); + const target = targetManager.createTarget( + 'main', + '', + 'frame' as any, // eslint-disable-line @typescript-eslint/no-explicit-any + /* parentTarget */ null, + session.id(), + undefined, + connection, + ); + return {target, universe}; +}; diff --git a/tests/DevtoolsUtils.test.ts b/tests/DevtoolsUtils.test.ts index 947e55122..2137ad17a 100644 --- a/tests/DevtoolsUtils.test.ts +++ b/tests/DevtoolsUtils.test.ts @@ -14,8 +14,17 @@ import { extractUrlLikeFromDevToolsTitle, urlsEqual, mapIssueToMessageObject, + UniverseManager, } from '../src/DevtoolsUtils.js'; import {ISSUE_UTILS} from '../src/issue-descriptions.js'; +import type {Browser, Target} from '../src/third_party/index.js'; + +import { + getMockBrowser, + getMockPage, + mockListener, + withBrowser, +} from './utils.js'; describe('extractUrlFromDevToolsTitle', () => { it('deals with no trailing /', () => { @@ -187,3 +196,48 @@ describe('mapIssueToMessageObject', () => { assert.deepStrictEqual(mapIssueToMessageObject(mockAggregatedIssue), null); }); }); + +describe('UniverseManager', () => { + 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]); + + assert.notStrictEqual(manager.get(page), null); + }); + }); +});