Skip to content

Commit 589d994

Browse files
authored
chore: add UniverseManager to create DevTools Universes for each Page (#621)
A follow-up PR will add a single instance of the `UniverseManager` to `McpContext` similarly to the *Collectors. We don't do that in this PR yet, as we need to wait for some chrome-devtools-frontend changes to roll.
1 parent 7114bb1 commit 589d994

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/@LOCALE@.json';`;
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,15 +8,26 @@ import {
88
type Issue,
99
type AggregatedIssue,
1010
type IssuesManagerEventTypes,
11+
type Target,
12+
DebuggerModel,
13+
Foundation,
14+
TargetManager,
1115
MarkdownIssueDescription,
1216
Marked,
1317
ProtocolClient,
1418
Common,
1519
I18n,
1620
} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js';
1721

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

2132
export function extractUrlLikeFromDevToolsTitle(
2233
title: string,
@@ -138,3 +149,112 @@ I18n.DevToolsLocale.DevToolsLocale.instance({
138149
},
139150
});
140151
I18n.i18n.registerLocaleDataForTest('en-US', {});
152+
153+
export interface TargetUniverse {
154+
/** The DevTools target corresponding to the puppeteer Page */
155+
target: Target;
156+
universe: Foundation.Universe.Universe;
157+
}
158+
export type TargetUniverseFactoryFn = (page: Page) => Promise<TargetUniverse>;
159+
160+
export class UniverseManager {
161+
readonly #browser: Browser;
162+
readonly #createUniverseFor: TargetUniverseFactoryFn;
163+
readonly #universes = new WeakMap<Page, TargetUniverse>();
164+
165+
/** Guard access to #universes so we don't create unnecessary universes */
166+
readonly #mutex = new Mutex();
167+
168+
constructor(
169+
browser: Browser,
170+
factory: TargetUniverseFactoryFn = DEFAULT_FACTORY,
171+
) {
172+
this.#browser = browser;
173+
this.#createUniverseFor = factory;
174+
}
175+
176+
async init(pages: Page[]) {
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: 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)