Skip to content

Commit 162497e

Browse files
committed
chore: add UniverseManager to create DevTools Universes for each Page
1 parent 4695516 commit 162497e

File tree

3 files changed

+186
-0
lines changed

3 files changed

+186
-0
lines changed

scripts/post-build.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@ export const LOCAL_FETCH_PATTERN = './locales/@[email protected]';`;
8888
const runtimeContent = `
8989
export function getChromeVersion() { return ''; };
9090
export const hostConfig = {};
91+
export const Runtime = {
92+
isDescriptorEnabled: () => true,
93+
queryParam: () => null,
94+
}
95+
export const experiments = {
96+
isEnabled: () => false,
97+
}
9198
`;
9299
writeFile(runtimeFile, runtimeContent);
93100

src/DevtoolsUtils.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,25 @@ import {
88
type Issue,
99
type AggregatedIssue,
1010
type IssuesManagerEventTypes,
11+
type Target,
12+
DebuggerModel,
13+
Foundation,
14+
TargetManager,
1115
MarkdownIssueDescription,
1216
Marked,
1317
Common,
1418
I18n,
1519
} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js';
1620

21+
import {PuppeteerDevToolsConnection} from './DevToolsConnectionAdapter.js';
1722
import {ISSUE_UTILS} from './issue-descriptions.js';
1823
import {logger} from './logger.js';
24+
import {Mutex} from './Mutex.js';
25+
import type {
26+
Browser,
27+
Page,
28+
Target as PuppeteerTarget,
29+
} from './third_party/index.js';
1930

2031
export function extractUrlLikeFromDevToolsTitle(
2132
title: string,
@@ -134,3 +145,116 @@ I18n.DevToolsLocale.DevToolsLocale.instance({
134145
},
135146
});
136147
I18n.i18n.registerLocaleDataForTest('en-US', {});
148+
149+
export interface TargetUniverse {
150+
/** The DevTools target corresponding to the puppeteer Page */
151+
target: Target;
152+
universe: Foundation.Universe.Universe;
153+
}
154+
export type TargetUniverseFactoryFn = (page: Page) => Promise<TargetUniverse>;
155+
156+
export class UniverseManager {
157+
readonly #browser: Browser;
158+
readonly #createUniverseFor: TargetUniverseFactoryFn;
159+
readonly #includeAllPages: boolean;
160+
readonly #universes = new WeakMap<Page, TargetUniverse>();
161+
162+
/** Guard access to #universes so we don't create unnecessary universes */
163+
readonly #mutex = new Mutex();
164+
165+
constructor(
166+
browser: Browser,
167+
includeAllPages?: boolean,
168+
factory: TargetUniverseFactoryFn = DEFAULT_FACTORY,
169+
) {
170+
this.#browser = browser;
171+
this.#createUniverseFor = factory;
172+
this.#includeAllPages = Boolean(includeAllPages);
173+
}
174+
175+
async init() {
176+
const pages = await this.#browser.pages(this.#includeAllPages);
177+
try {
178+
await this.#mutex.acquire();
179+
const promises = [];
180+
for (const page of pages) {
181+
promises.push(
182+
this.#createUniverseFor(page).then(targetUniverse =>
183+
this.#universes.set(page, targetUniverse),
184+
),
185+
);
186+
}
187+
188+
this.#browser.on('targetcreated', this.#onTargetCreated);
189+
this.#browser.on('targetdestroyed', this.#onTargetDestroyed);
190+
191+
await Promise.all(promises);
192+
} finally {
193+
this.#mutex.release();
194+
}
195+
}
196+
197+
get(page: Page): TargetUniverse | null {
198+
return this.#universes.get(page) ?? null;
199+
}
200+
201+
dispose() {
202+
this.#browser.off('targetcreated', this.#onTargetCreated);
203+
this.#browser.off('targetdestroyed', this.#onTargetDestroyed);
204+
}
205+
206+
#onTargetCreated = async (target: PuppeteerTarget) => {
207+
const page = await target.page();
208+
try {
209+
await this.#mutex.acquire();
210+
if (!page || this.#universes.has(page)) {
211+
return;
212+
}
213+
214+
this.#universes.set(page, await this.#createUniverseFor(page));
215+
} finally {
216+
this.#mutex.release();
217+
}
218+
};
219+
220+
#onTargetDestroyed = async (target: PuppeteerTarget) => {
221+
const page = await target.page();
222+
try {
223+
await this.#mutex.acquire();
224+
if (!page || !this.#universes.has(page)) {
225+
return;
226+
}
227+
this.#universes.delete(page);
228+
} finally {
229+
this.#mutex.release();
230+
}
231+
};
232+
}
233+
234+
const DEFAULT_FACTORY: TargetUniverseFactoryFn = async (page: Page) => {
235+
const settingStorage = new Common.Settings.SettingsStorage({});
236+
const universe = new Foundation.Universe.Universe({
237+
settingsCreationOptions: {
238+
syncedStorage: settingStorage,
239+
globalStorage: settingStorage,
240+
localStorage: settingStorage,
241+
settingRegistrations: Common.SettingRegistration.getRegisteredSettings(),
242+
},
243+
overrideAutoStartModels: new Set([DebuggerModel]),
244+
});
245+
246+
const session = await page.createCDPSession();
247+
const connection = new PuppeteerDevToolsConnection(session);
248+
249+
const targetManager = universe.context.get(TargetManager);
250+
const target = targetManager.createTarget(
251+
'main',
252+
'',
253+
'frame' as any, // eslint-disable-line @typescript-eslint/no-explicit-any
254+
/* parentTarget */ null,
255+
session.id(),
256+
undefined,
257+
connection,
258+
);
259+
return {target, universe};
260+
};

tests/DevtoolsUtils.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,17 @@ import {
1414
extractUrlLikeFromDevToolsTitle,
1515
urlsEqual,
1616
mapIssueToMessageObject,
17+
UniverseManager,
1718
} from '../src/DevtoolsUtils.js';
1819
import {ISSUE_UTILS} from '../src/issue-descriptions.js';
20+
import type {Browser, Target} from '../src/third_party/index.js';
21+
22+
import {
23+
getMockBrowser,
24+
getMockPage,
25+
mockListener,
26+
withBrowser,
27+
} from './utils.js';
1928

2029
describe('extractUrlFromDevToolsTitle', () => {
2130
it('deals with no trailing /', () => {
@@ -187,3 +196,49 @@ describe('mapIssueToMessageObject', () => {
187196
assert.deepStrictEqual(mapIssueToMessageObject(mockAggregatedIssue), null);
188197
});
189198
});
199+
200+
describe('UniverseManager', () => {
201+
it('calls the factory for existing pages', async () => {
202+
const browser = getMockBrowser();
203+
const factory = sinon.stub().resolves({});
204+
const manager = new UniverseManager(browser, undefined, factory);
205+
await manager.init();
206+
207+
const page = (await browser.pages())[0];
208+
sinon.assert.calledOnceWithExactly(factory, page);
209+
});
210+
211+
it('calls the factory only once for the same page', async () => {
212+
const browser = {
213+
pages: async () => [],
214+
...mockListener(),
215+
} as unknown as Browser;
216+
// eslint-disable-next-line @typescript-eslint/no-empty-function
217+
const factory = sinon.stub().returns(new Promise(() => {})); // Don't resolve.
218+
const manager = new UniverseManager(browser, undefined, factory);
219+
await manager.init();
220+
221+
sinon.assert.notCalled(factory);
222+
223+
const page = getMockPage();
224+
browser.emit('targetcreated', {
225+
page: () => Promise.resolve(page),
226+
} as Target);
227+
browser.emit('targetcreated', {
228+
page: () => Promise.resolve(page),
229+
} as Target);
230+
231+
await new Promise(r => setTimeout(r, 0)); // One event loop tick for the micro task queue to run.
232+
233+
sinon.assert.calledOnceWithExactly(factory, page);
234+
});
235+
236+
it('works with a real browser', async () => {
237+
await withBrowser(async (browser, page) => {
238+
const manager = new UniverseManager(browser);
239+
await manager.init();
240+
241+
assert.notStrictEqual(manager.get(page), null);
242+
});
243+
});
244+
});

0 commit comments

Comments
 (0)