Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions scripts/post-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ export const LOCAL_FETCH_PATTERN = './locales/@[email protected]';`;
const runtimeContent = `
export function getChromeVersion() { return ''; };
export const hostConfig = {};
export const Runtime = {
isDescriptorEnabled: () => true,
queryParam: () => null,
}
export const experiments = {
isEnabled: () => false,
}
`;
writeFile(runtimeFile, runtimeContent);

Expand Down
120 changes: 120 additions & 0 deletions src/DevtoolsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,26 @@ import {
type Issue,
type AggregatedIssue,
type IssuesManagerEventTypes,
type Target,
DebuggerModel,
Foundation,
TargetManager,
MarkdownIssueDescription,
Marked,
ProtocolClient,
Common,
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,
Expand Down Expand Up @@ -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<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) => {
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};
};
54 changes: 54 additions & 0 deletions tests/DevtoolsUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 /', () => {
Expand Down Expand Up @@ -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);
});
});
});