Skip to content

Commit 73ed080

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

File tree

3 files changed

+181
-0
lines changed

3 files changed

+181
-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: 120 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,112 @@ 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 #universes = new WeakMap<Page, TargetUniverse>();
160+
161+
/** Guard access to #universes so we don't create unnecessary universes */
162+
readonly #mutex = new Mutex();
163+
164+
constructor(
165+
browser: Browser,
166+
factory: TargetUniverseFactoryFn = DEFAULT_FACTORY,
167+
) {
168+
this.#browser = browser;
169+
this.#createUniverseFor = factory;
170+
}
171+
172+
async init(pages: Page[]) {
173+
try {
174+
await this.#mutex.acquire();
175+
const promises = [];
176+
for (const page of pages) {
177+
promises.push(
178+
this.#createUniverseFor(page).then(targetUniverse =>
179+
this.#universes.set(page, targetUniverse),
180+
),
181+
);
182+
}
183+
184+
this.#browser.on('targetcreated', this.#onTargetCreated);
185+
this.#browser.on('targetdestroyed', this.#onTargetDestroyed);
186+
187+
await Promise.all(promises);
188+
} finally {
189+
this.#mutex.release();
190+
}
191+
}
192+
193+
get(page: Page): TargetUniverse | null {
194+
return this.#universes.get(page) ?? null;
195+
}
196+
197+
dispose() {
198+
this.#browser.off('targetcreated', this.#onTargetCreated);
199+
this.#browser.off('targetdestroyed', this.#onTargetDestroyed);
200+
}
201+
202+
#onTargetCreated = async (target: PuppeteerTarget) => {
203+
const page = await target.page();
204+
try {
205+
await this.#mutex.acquire();
206+
if (!page || this.#universes.has(page)) {
207+
return;
208+
}
209+
210+
this.#universes.set(page, await this.#createUniverseFor(page));
211+
} finally {
212+
this.#mutex.release();
213+
}
214+
};
215+
216+
#onTargetDestroyed = async (target: PuppeteerTarget) => {
217+
const page = await target.page();
218+
try {
219+
await this.#mutex.acquire();
220+
if (!page || !this.#universes.has(page)) {
221+
return;
222+
}
223+
this.#universes.delete(page);
224+
} finally {
225+
this.#mutex.release();
226+
}
227+
};
228+
}
229+
230+
const DEFAULT_FACTORY: TargetUniverseFactoryFn = async (page: Page) => {
231+
const settingStorage = new Common.Settings.SettingsStorage({});
232+
const universe = new Foundation.Universe.Universe({
233+
settingsCreationOptions: {
234+
syncedStorage: settingStorage,
235+
globalStorage: settingStorage,
236+
localStorage: settingStorage,
237+
settingRegistrations: Common.SettingRegistration.getRegisteredSettings(),
238+
},
239+
overrideAutoStartModels: new Set([DebuggerModel]),
240+
});
241+
242+
const session = await page.createCDPSession();
243+
const connection = new PuppeteerDevToolsConnection(session);
244+
245+
const targetManager = universe.context.get(TargetManager);
246+
const target = targetManager.createTarget(
247+
'main',
248+
'',
249+
'frame' as any, // eslint-disable-line @typescript-eslint/no-explicit-any
250+
/* parentTarget */ null,
251+
session.id(),
252+
undefined,
253+
connection,
254+
);
255+
return {target, universe};
256+
};

tests/DevtoolsUtils.test.ts

Lines changed: 54 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,48 @@ 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, factory);
205+
await manager.init(await browser.pages());
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+
...mockListener(),
214+
} as unknown as Browser;
215+
// eslint-disable-next-line @typescript-eslint/no-empty-function
216+
const factory = sinon.stub().returns(new Promise(() => {})); // Don't resolve.
217+
const manager = new UniverseManager(browser, factory);
218+
await manager.init([]);
219+
220+
sinon.assert.notCalled(factory);
221+
222+
const page = getMockPage();
223+
browser.emit('targetcreated', {
224+
page: () => Promise.resolve(page),
225+
} as Target);
226+
browser.emit('targetcreated', {
227+
page: () => Promise.resolve(page),
228+
} as Target);
229+
230+
await new Promise(r => setTimeout(r, 0)); // One event loop tick for the micro task queue to run.
231+
232+
sinon.assert.calledOnceWithExactly(factory, page);
233+
});
234+
235+
it('works with a real browser', async () => {
236+
await withBrowser(async (browser, page) => {
237+
const manager = new UniverseManager(browser);
238+
await manager.init([page]);
239+
240+
assert.notStrictEqual(manager.get(page), null);
241+
});
242+
});
243+
});

0 commit comments

Comments
 (0)