/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {FakeIssuesManager} from './DevtoolsUtils.js';
import {logger} from './logger.js';
import type {
Target,
CDPSession,
ConsoleMessage,
Protocol,
} from './third_party/index.js';
import {DevTools} from './third_party/index.js';
import {
type Browser,
type Frame,
type Handler,
type HTTPRequest,
type Page,
type PageEvents as PuppeteerPageEvents,
} from './third_party/index.js';
interface PageEvents extends PuppeteerPageEvents {
issue: DevTools.AggregatedIssue;
}
export type ListenerMap<EventMap extends PageEvents = PageEvents> = {
[K in keyof EventMap]?: (event: EventMap[K]) => void;
};
function createIdGenerator() {
let i = 1;
return () => {
if (i === Number.MAX_SAFE_INTEGER) {
i = 0;
}
return i++;
};
}
export const stableIdSymbol = Symbol('stableIdSymbol');
type WithSymbolId<T> = T & {
[stableIdSymbol]?: number;
};
export class PageCollector<T> {
#browser: Browser;
#listenersInitializer: (
collector: (item: T) => void,
) => ListenerMap<PageEvents>;
#listeners = new WeakMap<Page, ListenerMap>();
#maxNavigationSaved = 3;
/**
* This maps a Page to a list of navigations with a sub-list
* of all collected resources.
* The newer navigations come first.
*/
protected storage = new WeakMap<Page, Array<Array<WithSymbolId<T>>>>();
constructor(
browser: Browser,
listeners: (collector: (item: T) => void) => ListenerMap<PageEvents>,
) {
this.#browser = browser;
this.#listenersInitializer = listeners;
}
async init(pages: Page[]) {
for (const page of pages) {
this.addPage(page);
}
this.#browser.on('targetcreated', this.#onTargetCreated);
this.#browser.on('targetdestroyed', this.#onTargetDestroyed);
}
dispose() {
this.#browser.off('targetcreated', this.#onTargetCreated);
this.#browser.off('targetdestroyed', this.#onTargetDestroyed);
}
#onTargetCreated = async (target: Target) => {
try {
const page = await target.page();
if (!page) {
return;
}
this.addPage(page);
} catch (err) {
logger('Error getting a page for a target onTargetCreated', err);
}
};
#onTargetDestroyed = async (target: Target) => {
try {
const page = await target.page();
if (!page) {
return;
}
this.cleanupPageDestroyed(page);
} catch (err) {
logger('Error getting a page for a target onTargetDestroyed', err);
}
};
public addPage(page: Page) {
this.#initializePage(page);
}
#initializePage(page: Page) {
if (this.storage.has(page)) {
return;
}
const idGenerator = createIdGenerator();
const storedLists: Array<Array<WithSymbolId<T>>> = [[]];
this.storage.set(page, storedLists);
const listeners = this.#listenersInitializer(value => {
const withId = value as WithSymbolId<T>;
withId[stableIdSymbol] = idGenerator();
const navigations = this.storage.get(page) ?? [[]];
navigations[0].push(withId);
});
listeners['framenavigated'] = (frame: Frame) => {
// Only split the storage on main frame navigation
if (frame !== page.mainFrame()) {
return;
}
this.splitAfterNavigation(page);
};
for (const [name, listener] of Object.entries(listeners)) {
page.on(name, listener as Handler<unknown>);
}
this.#listeners.set(page, listeners);
}
protected splitAfterNavigation(page: Page) {
const navigations = this.storage.get(page);
if (!navigations) {
return;
}
// Add the latest navigation first
navigations.unshift([]);
navigations.splice(this.#maxNavigationSaved);
}
protected cleanupPageDestroyed(page: Page) {
const listeners = this.#listeners.get(page);
if (listeners) {
for (const [name, listener] of Object.entries(listeners)) {
page.off(name, listener as Handler<unknown>);
}
}
this.storage.delete(page);
}
getData(page: Page, includePreservedData?: boolean): T[] {
const navigations = this.storage.get(page);
if (!navigations) {
return [];
}
if (!includePreservedData) {
return navigations[0];
}
const data: T[] = [];
for (let index = this.#maxNavigationSaved; index >= 0; index--) {
if (navigations[index]) {
data.push(...navigations[index]);
}
}
return data;
}
getIdForResource(resource: WithSymbolId<T>): number {
return resource[stableIdSymbol] ?? -1;
}
getById(page: Page, stableId: number): T {
const navigations = this.storage.get(page);
if (!navigations) {
throw new Error('No requests found for selected page');
}
const item = this.find(page, item => item[stableIdSymbol] === stableId);
if (item) {
return item;
}
throw new Error('Request not found for selected page');
}
find(
page: Page,
filter: (item: WithSymbolId<T>) => boolean,
): WithSymbolId<T> | undefined {
const navigations = this.storage.get(page);
if (!navigations) {
return;
}
for (const navigation of navigations) {
const item = navigation.find(filter);
if (item) {
return item;
}
}
return;
}
}
export class ConsoleCollector extends PageCollector<
ConsoleMessage | Error | DevTools.AggregatedIssue
> {
#subscribedPages = new WeakMap<Page, PageIssueSubscriber>();
override addPage(page: Page): void {
super.addPage(page);
if (!this.#subscribedPages.has(page)) {
const subscriber = new PageIssueSubscriber(page);
this.#subscribedPages.set(page, subscriber);
void subscriber.subscribe();
}
}
protected override cleanupPageDestroyed(page: Page): void {
super.cleanupPageDestroyed(page);
this.#subscribedPages.get(page)?.unsubscribe();
this.#subscribedPages.delete(page);
}
}
class PageIssueSubscriber {
#issueManager = new FakeIssuesManager();
#issueAggregator = new DevTools.IssueAggregator(this.#issueManager);
#seenKeys = new Set<string>();
#seenIssues = new Set<DevTools.AggregatedIssue>();
#page: Page;
#session: CDPSession;
constructor(page: Page) {
this.#page = page;
// @ts-expect-error use existing CDP client (internal Puppeteer API).
this.#session = this.#page._client() as CDPSession;
}
#resetIssueAggregator() {
this.#issueManager = new FakeIssuesManager();
if (this.#issueAggregator) {
this.#issueAggregator.removeEventListener(
DevTools.IssueAggregatorEvents.AGGREGATED_ISSUE_UPDATED,
this.#onAggregatedissue,
);
}
this.#issueAggregator = new DevTools.IssueAggregator(this.#issueManager);
this.#issueAggregator.addEventListener(
DevTools.IssueAggregatorEvents.AGGREGATED_ISSUE_UPDATED,
this.#onAggregatedissue,
);
}
async subscribe() {
this.#resetIssueAggregator();
this.#page.on('framenavigated', this.#onFrameNavigated);
this.#session.on('Audits.issueAdded', this.#onIssueAdded);
try {
await this.#session.send('Audits.enable');
} catch (error) {
logger('Error subscribing to issues', error);
}
}
unsubscribe() {
this.#seenKeys.clear();
this.#seenIssues.clear();
this.#page.off('framenavigated', this.#onFrameNavigated);
this.#session.off('Audits.issueAdded', this.#onIssueAdded);
if (this.#issueAggregator) {
this.#issueAggregator.removeEventListener(
DevTools.IssueAggregatorEvents.AGGREGATED_ISSUE_UPDATED,
this.#onAggregatedissue,
);
}
void this.#session.send('Audits.disable').catch(() => {
// might fail.
});
}
#onAggregatedissue = (
event: DevTools.Common.EventTarget.EventTargetEvent<DevTools.AggregatedIssue>,
) => {
if (this.#seenIssues.has(event.data)) {
return;
}
this.#seenIssues.add(event.data);
this.#page.emit('issue', event.data);
};
// On navigation, we reset issue aggregation.
#onFrameNavigated = (frame: Frame) => {
// Only split the storage on main frame navigation
if (frame !== frame.page().mainFrame()) {
return;
}
this.#seenKeys.clear();
this.#seenIssues.clear();
this.#resetIssueAggregator();
};
#onIssueAdded = (data: Protocol.Audits.IssueAddedEvent) => {
try {
const inspectorIssue = data.issue;
const issue = DevTools.createIssuesFromProtocolIssue(
null,
// @ts-expect-error Protocol types diverge.
inspectorIssue,
)[0];
if (!issue) {
logger('No issue mapping for for the issue: ', inspectorIssue.code);
return;
}
const primaryKey = issue.primaryKey();
if (this.#seenKeys.has(primaryKey)) {
return;
}
this.#seenKeys.add(primaryKey);
this.#issueManager.dispatchEventToListeners(
DevTools.IssuesManagerEvents.ISSUE_ADDED,
{
issue,
// @ts-expect-error We don't care that issues model is null
issuesModel: null,
},
);
} catch (error) {
logger('Error creating a new issue', error);
}
};
}
export class NetworkCollector extends PageCollector<HTTPRequest> {
constructor(
browser: Browser,
listeners: (
collector: (item: HTTPRequest) => void,
) => ListenerMap<PageEvents> = collect => {
return {
request: req => {
collect(req);
},
} as ListenerMap;
},
) {
super(browser, listeners);
}
override splitAfterNavigation(page: Page) {
const navigations = this.storage.get(page) ?? [];
if (!navigations) {
return;
}
const requests = navigations[0];
const lastRequestIdx = requests.findLastIndex(request => {
return request.frame() === page.mainFrame()
? request.isNavigationRequest()
: false;
});
// Keep all requests since the last navigation request including that
// navigation request itself.
// Keep the reference
if (lastRequestIdx !== -1) {
const fromCurrentNavigation = requests.splice(lastRequestIdx);
navigations.unshift(fromCurrentNavigation);
} else {
navigations.unshift([]);
}
}
}