| // Copyright 2025 The Chromium Authors | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file. | |
| import {createWriteStream} from 'node:fs'; | |
| import {join} from 'node:path'; | |
| import type * as puppeteer from 'puppeteer-core'; | |
| import type * as Root from '../../../front_end/core/root/root.js'; | |
| import {installPageErrorHandlers} from '../../conductor/events.js'; | |
| import {TestConfig} from '../../conductor/test_config.js'; | |
| import type {InspectedPage} from './InspectedPage.js'; | |
| import {type DeducedElementType, PageWrapper} from './PageWrapper.js'; | |
| const envLatePromises = process.env['LATE_PROMISES'] !== undefined ? | |
| ['true', ''].includes(process.env['LATE_PROMISES'].toLowerCase()) ? 10 : Number(process.env['LATE_PROMISES']) : | |
| 0; | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| const globalThis: any = global; | |
| interface DevToolsReloadParams { | |
| canDock?: boolean; | |
| panel?: string; | |
| } | |
| /** | |
| * The DevTools window with the events collected by | |
| * {@link DevToolsPage.installEventListener}. | |
| */ | |
| interface WindowWithPendingEvents extends Window { | |
| // eslint-disable-next-line @typescript-eslint/naming-convention | |
| __pendingEvents?: Map<string, Event[]>; | |
| } | |
| let heapSnapshotCounter = 0; | |
| export class DevToolsPage extends PageWrapper { | |
| screenshotLog: Record<string, string> = {}; | |
| #currentHighlightedElement?: HighlightedElement; | |
| #cdpSession?: puppeteer.CDPSession; | |
| async delayPromisesIfRequired(): Promise<void> { | |
| if (envLatePromises === 0) { | |
| return; | |
| } | |
| /* eslint-disable-next-line no-console */ | |
| console.log(`Delaying promises by ${envLatePromises}ms`); | |
| await this.evaluate(delay => { | |
| global.Promise = class<T> extends Promise<T>{ | |
| constructor(executor: (resolve: (value: T|PromiseLike<T>) => void, reject: (reason?: unknown) => void) => | |
| void) { | |
| super((resolve, reject) => { | |
| executor(value => setTimeout(() => resolve(value), delay), | |
| reason => setTimeout(() => reject(reason), delay)); | |
| }); | |
| } | |
| }; | |
| }, envLatePromises); | |
| } | |
| async throttleCPUIfRequired(): Promise<void> { | |
| if (TestConfig.cpuThrottle === 1) { | |
| return; | |
| } | |
| /* eslint-disable-next-line no-console */ | |
| console.log(`Throttling CPU: ${TestConfig.cpuThrottle}x slowdown`); | |
| const client = await this.#getCDPSession(); | |
| await client.send('Emulation.setCPUThrottlingRate', { | |
| rate: TestConfig.cpuThrottle, | |
| }); | |
| } | |
| override async reload(options?: puppeteer.WaitForOptions): Promise<void> { | |
| await super.reload(options); | |
| await this.ensureReadyForTesting(); | |
| } | |
| /** | |
| * Use the Runtime.setQueryParamForTesting to mock the parameter before | |
| * DevTools is loaded. | |
| * | |
| * Important information for the implementation: | |
| * Trying to change the url and then reload or navigate to the new one will | |
| * hit this check in the back end: | |
| * https://crsrc.org/c/chrome/browser/devtools/devtools_ui_bindings.cc;l=406?q=devtools_ui_b&ss=chromium | |
| * | |
| * @param panel Mocks DevTools URL search params to make it open a specific panel on load. | |
| * @param canDock Mocks DevTools URL search params to make it think whether it can dock or not. | |
| * This does not control whether or not the panel can actually dock or not. | |
| * @param persistReloads If this is true running {@link DevToolsPage.reload} will reloading with | |
| * the provided options | |
| */ | |
| async reloadWithParams({panel, canDock}: DevToolsReloadParams, persistReloads = false): Promise<void> { | |
| if (!panel && !canDock) { | |
| await this.reload(); | |
| return; | |
| } | |
| // evaluateOnNewDocument is ran before all other JS is loaded | |
| // ES Modules are only resolved once and always resolved asynchronously | |
| // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#other_differences_between_modules_and_classic_scripts | |
| // This means this will always resolve before DevTools tries to read the values | |
| const token = await this.page.evaluateOnNewDocument(async (panelName, canDockDevTools) => { | |
| // @ts-expect-error Evaluated in DevTools context | |
| const Root = await import('./core/root/root.js'); | |
| if (panelName) { | |
| Root.Runtime.Runtime.setQueryParamForTesting('panel', panelName); | |
| } | |
| if (canDockDevTools) { | |
| Root.Runtime.Runtime.setQueryParamForTesting('can_dock', `${canDockDevTools}`); | |
| } | |
| }, panel, canDock); | |
| await this.reload(); | |
| if (!persistReloads) { | |
| await this.page.removeScriptToEvaluateOnNewDocument(token.identifier); | |
| } | |
| if (panel) { | |
| await this.waitFor(`.panel.${panel}`); | |
| } | |
| } | |
| async ensureReadyForTesting(): Promise<void> { | |
| const devToolsVeLogging = {enabled: true, testing: true}; | |
| await this.evaluateOnNewDocument(`globalThis.hostConfigForTesting = ${JSON.stringify({devToolsVeLogging})};`); | |
| await this.waitForFunction(async () => { | |
| try { | |
| const result = await this.page.evaluate(async function() { | |
| if (window.location.origin === 'null') { | |
| return false; | |
| } | |
| // @ts-expect-error Executed in DevTools realm | |
| const Main = await import('./entrypoints/main/main.js'); | |
| return Main.MainImpl.MainImpl.instanceForTest !== null; | |
| }); | |
| return result; | |
| } catch (err) { | |
| // We might be navigating, so we retry execution context destroyed | |
| // errors. | |
| if (err.message.startsWith('Execution context was destroyed') || | |
| err.message.includes('Failed to resolve module specifier')) { | |
| return false; | |
| } | |
| throw err; | |
| } | |
| }); | |
| await this.evaluate(async () => { | |
| // @ts-expect-error Executed in DevTools realm | |
| const Main = await import('./entrypoints/main/main.js'); | |
| await Main.MainImpl.MainImpl.instanceForTest.readyForTest(); | |
| }); | |
| } | |
| async useSoftMenu(): Promise<void> { | |
| await this.evaluate(() => { | |
| // @ts-expect-error different context | |
| DevToolsAPI.setUseSoftMenu(true); | |
| }); | |
| } | |
| override async $<ElementType extends Element|null = null, Selector extends string = string>( | |
| selector: Selector, root?: puppeteer.ElementHandle, | |
| handler = 'pierce'): Promise<puppeteer.ElementHandle<DeducedElementType<ElementType, Selector>>|null> { | |
| const element = await super.$<ElementType, Selector>(selector, root, handler); | |
| await this.#maybeHighlight(element); | |
| return element; | |
| } | |
| async #maybeHighlight(element: puppeteer.ElementHandle|null) { | |
| if (!TestConfig.debug) { | |
| return; | |
| } | |
| if (!element) { | |
| return; | |
| } | |
| if (this.#currentHighlightedElement) { | |
| await this.#currentHighlightedElement.reset(); | |
| } | |
| this.#currentHighlightedElement = new HighlightedElement(element); | |
| await this.#currentHighlightedElement.highlight(); | |
| } | |
| /** | |
| * @deprecated This method is not able to recover from unstable DOM. Use hover(selector) instead. | |
| */ | |
| async hoverElement(element: puppeteer.ElementHandle): Promise<void> { | |
| // Retries here just in case the element gets connected to DOM / becomes visible. | |
| await this.waitForFunction(async () => { | |
| try { | |
| await element.hover(); | |
| await this.drainTaskQueue(); | |
| return true; | |
| } catch { | |
| return false; | |
| } | |
| }); | |
| } | |
| debuggerStatement(): Promise<void> { | |
| return this.page.evaluate(() => { | |
| // eslint-disable-next-line no-debugger | |
| debugger; | |
| }); | |
| } | |
| async clickMoreTabsButton(root?: puppeteer.ElementHandle<Element>): Promise<void> { | |
| await this.click('.tabbed-pane-header-tabs-drop-down-container', {root}); | |
| } | |
| async closePanelTab(panelTabSelector: string): Promise<void> { | |
| // Get close button from tab element | |
| const selector = `${panelTabSelector} > .tabbed-pane-close-button`; | |
| await this.click(selector); | |
| await this.waitForNone(selector); | |
| } | |
| async closeAllCloseableTabs(): Promise<void> { | |
| // get all closeable tools by looking for the available x buttons on tabs | |
| const selector = '.tabbed-pane-close-button'; | |
| const allCloseButtons = await this.$$(selector); | |
| // Get all panel ids | |
| const panelTabIds = await Promise.all(allCloseButtons.map(button => { | |
| return button.evaluate(button => button.parentElement ? button.parentElement.id : ''); | |
| })); | |
| // Close each tab | |
| for (const tabId of panelTabIds) { | |
| const selector = `#${tabId}`; | |
| await this.closePanelTab(selector); | |
| } | |
| } | |
| // Noisy! Do not leave this in your test but it may be helpful | |
| // when debugging. | |
| async enableCDPLogging(): Promise<void> { | |
| await this.page.evaluate(() => { | |
| globalThis.ProtocolClient.test.dumpProtocol = console.log; // eslint-disable-line no-console | |
| }); | |
| } | |
| async enableCDPTracking(): Promise<void> { | |
| await this.page.evaluate(() => { | |
| globalThis.__messageMapForTest = new Map(); | |
| globalThis.ProtocolClient.test.onMessageSent = (message: {method: string, id: number}) => { | |
| globalThis.__messageMapForTest.set(message.id, message.method); | |
| }; | |
| globalThis.ProtocolClient.test.onMessageReceived = (message: {id?: number}) => { | |
| if (message.id) { | |
| globalThis.__messageMapForTest.delete(message.id); | |
| } | |
| }; | |
| }); | |
| } | |
| async logOutstandingCDP(): Promise<void> { | |
| await this.page.evaluate(() => { | |
| for (const entry of globalThis.__messageMapForTest) { | |
| console.error(entry); | |
| } | |
| }); | |
| } | |
| installEventListener(eventType: string): Promise<void> { | |
| return this.page.evaluate(eventType => { | |
| const pageWindow = window as WindowWithPendingEvents; | |
| const pendingEvents = pageWindow.__pendingEvents ?? new Map<string, Event[]>(); | |
| pageWindow.__pendingEvents = pendingEvents; | |
| window.addEventListener(eventType, (e: Event) => { | |
| let events = pendingEvents.get(eventType); | |
| if (!events) { | |
| events = []; | |
| pendingEvents.set(eventType, events); | |
| } | |
| events.push(e); | |
| }); | |
| }, eventType); | |
| } | |
| getPendingEvents(eventType: string): Promise<Event[]|undefined> { | |
| return this.page.evaluate(eventType => { | |
| const pendingEvents = (window as WindowWithPendingEvents).__pendingEvents; | |
| if (!pendingEvents) { | |
| return; | |
| } | |
| const events = pendingEvents.get(eventType); | |
| pendingEvents.set(eventType, []); | |
| return events; | |
| }, eventType); | |
| } | |
| async renderCoordinatorQueueEmpty(): Promise<void> { | |
| await this.page.evaluate(() => { | |
| return new Promise<void>(resolve => { | |
| const pendingFrames = globalThis.__getRenderCoordinatorPendingFrames(); | |
| if (pendingFrames < 1) { | |
| resolve(); | |
| return; | |
| } | |
| globalThis.addEventListener('renderqueueempty', resolve, {once: true}); | |
| }); | |
| }); | |
| } | |
| async summonSearchBox(): Promise<void> { | |
| await this.pressKey('f', {control: true}); | |
| } | |
| async readClipboard(): Promise<string> { | |
| await this.page.browserContext().overridePermissions(this.page.url(), ['clipboard-read', 'clipboard-write']); | |
| const clipboard = await this.page.evaluate(async () => await navigator.clipboard.readText()); | |
| await this.page.browserContext().clearPermissionOverrides(); | |
| return clipboard; | |
| } | |
| async setupOverridesFSMocks(): Promise<void> { | |
| await this.evaluateOnNewDocument(` | |
| Object.defineProperty(window, 'InspectorFrontendHost', { | |
| configurable: true, | |
| enumerable: true, | |
| get() { | |
| return this._InspectorFrontendHost; | |
| }, | |
| set(value) { | |
| this._InspectorFrontendHost = value; | |
| this._InspectorFrontendHost.fileSystem = null; | |
| this._InspectorFrontendHost.addFileSystem = (type) => { | |
| const onFileSystem = (fs) => { | |
| this._InspectorFrontendHost.fileSystem = fs; | |
| const fileSystem = { | |
| fileSystemName: 'sandboxedRequestedFileSystem', | |
| fileSystemPath: '/overrides', | |
| rootURL: 'filesystem:devtools://devtools/isolated/', | |
| type: 'overrides', | |
| }; | |
| this._InspectorFrontendHost.events.dispatchEventToListeners('fileSystemAdded', {fileSystem}); | |
| }; | |
| window.webkitRequestFileSystem(window.TEMPORARY, 1024 * 1024, onFileSystem); | |
| }; | |
| this._InspectorFrontendHost.removeFileSystem = (fileSystemPath) => { | |
| const removalCallback = (entries) => { | |
| entries.forEach(entry => { | |
| if (entry.isDirectory) { | |
| entry.removeRecursively(() => {}); | |
| } else if (entry.isFile) { | |
| entry.remove(() => {}); | |
| } | |
| }); | |
| }; | |
| if (this._InspectorFrontendHost.fileSystem) { | |
| this._InspectorFrontendHost.fileSystem.root.createReader().readEntries(removalCallback); | |
| } | |
| this._InspectorFrontendHost.fileSystem = null; | |
| this._InspectorFrontendHost.events.dispatchEventToListeners('fileSystemRemoved', '/overrides'); | |
| } | |
| this._InspectorFrontendHost.isolatedFileSystem = (_fileSystemId, _registeredName) => { | |
| return this._InspectorFrontendHost.fileSystem; | |
| }; | |
| } | |
| }); | |
| `); | |
| await this.reload(); | |
| } | |
| async setupMockHostConfigAndReload(hostConfig: Root.Runtime.HostConfig, aidaOverride?: string): Promise<string> { | |
| const syncInformation = { | |
| accountEmail: 'some-email', | |
| isSyncActive: true, | |
| arePreferencesSynced: false, | |
| }; | |
| const {identifier} = await this.evaluateOnNewDocument(` | |
| Object.defineProperty(window, 'InspectorFrontendHost', { | |
| configurable: true, | |
| enumerable: true, | |
| get() { | |
| return this._InspectorFrontendHost; | |
| }, | |
| set(value) { | |
| value.getHostConfig = (cb) => { | |
| cb({ | |
| ...globalThis.hostConfigForTesting ?? {}, | |
| ...JSON.parse('${JSON.stringify(hostConfig)}'), | |
| }); | |
| } | |
| value.getSyncInformation = (cb) => { | |
| cb(JSON.parse('${JSON.stringify(syncInformation)}')); | |
| }; | |
| ${aidaOverride} | |
| this._InspectorFrontendHost = value; | |
| } | |
| }); | |
| `); | |
| await this.reload({ | |
| waitUntil: 'networkidle0', | |
| }); | |
| return identifier; | |
| } | |
| async #getCDPSession() { | |
| if (!this.#cdpSession) { | |
| this.#cdpSession = await this.page.createCDPSession(); | |
| } | |
| return this.#cdpSession; | |
| } | |
| async disableAnimations(): Promise<void> { | |
| const session = await this.#getCDPSession(); | |
| await session.send('Animation.enable'); | |
| await session.send('Animation.setPlaybackRate', {playbackRate: 30_000}); | |
| } | |
| async enableAnimations(): Promise<void> { | |
| const session = await this.#getCDPSession(); | |
| await session.send('Animation.setPlaybackRate', {playbackRate: 1}); | |
| } | |
| // Debugging utility to be used around flaky code and hopefully reveal visual glitches. | |
| // Use it with the rdb wrapper to inspect the collected screenshots after a test failure. | |
| async captureScreenshot(name?: string): Promise<void> { | |
| const index = Object.keys(this.screenshotLog).length + 1; | |
| const fullName = index + ' ' + (name ?? 'screenshot'); | |
| this.screenshotLog[fullName] = await this.screenshot(); | |
| } | |
| /** | |
| * Forces a garbage collection in the DevTools page, e.g. before checking | |
| * that objects that are no longer needed are not retained. | |
| */ | |
| async collectGarbage(): Promise<void> { | |
| const session = await this.#getCDPSession(); | |
| await session.send('HeapProfiler.collectGarbage'); | |
| } | |
| /** | |
| * A helper that takes heap snapshots of the DevTools page that can be used to | |
| * detect memory leaks. The snapshots are stored in out/<OutDir>. | |
| * | |
| * Usage: | |
| * | |
| * ``` | |
| * ... prepare test state ... | |
| * await devToolsPsage.captureHeapSnapshot(); | |
| * ... perform actions that trigger the leak ... | |
| * await devToolsPsage.captureHeapSnapshot(); | |
| * ``` | |
| * | |
| * Now `out/<OutDir>/heap-snapshot-0.heapsnapshot` and | |
| * `out/<OutDir>/heap-snapshot-1.heapsnapshot` are two snapshots that can be | |
| * compared in the Memory panel of DevTools. | |
| */ | |
| async captureHeapSnapshot(snapshotName = 'heap-snapshot'): Promise<void> { | |
| await this.collectGarbage(); | |
| const session = await this.page.createCDPSession(); | |
| await session.send('HeapProfiler.enable'); | |
| const snapshotId = heapSnapshotCounter++; | |
| const fileName = `${snapshotName}-${snapshotId}.heapsnapshot`; | |
| const filePath = join(process.cwd(), fileName); | |
| const stream = createWriteStream(filePath); | |
| session.on('HeapProfiler.addHeapSnapshotChunk', ({chunk}) => { | |
| stream.write(chunk); | |
| }); | |
| await session.send('HeapProfiler.takeHeapSnapshot', {reportProgress: false}); | |
| await new Promise<void>(resolve => { | |
| stream.end(() => resolve()); | |
| }); | |
| await session.detach(); | |
| console.warn(`Heap snapshot saved to: ${filePath}`); | |
| } | |
| async setAiAssistanceStructuredLogEnabled(): Promise<void> { | |
| await this.evaluate(() => localStorage.removeItem('aiAssistanceStructuredLog')); | |
| await this.waitForFunction(async () => { | |
| return await this.evaluate(() => 'setAiAssistanceStructuredLogEnabled' in window); | |
| }); | |
| await this.evaluate(() => { | |
| // @ts-expect-error different context | |
| setAiAssistanceStructuredLogEnabled(true); | |
| }); | |
| } | |
| } | |
| export interface DevtoolsSettings { | |
| devToolsSettings: Record<string, unknown>; | |
| /** | |
| * Defined in front_end/ui/legacy/DockController.ts DockState | |
| */ | |
| dockingMode: 'bottom'|'right'|'left'|'undocked'; | |
| // DevTools panel to open on load | |
| /** | |
| * The name of the panel to be loaded initially | |
| * This persist after {@link DevToolsPage.reload} | |
| * | |
| * To reload into a panel use {@link DevToolsPage.reloadWithParams} | |
| */ | |
| panel?: string; | |
| } | |
| export const DEFAULT_DEVTOOLS_SETTINGS: DevtoolsSettings = { | |
| devToolsSettings: { | |
| veLogsTestMode: true, | |
| }, | |
| dockingMode: 'right', | |
| }; | |
| /** | |
| * @internal | |
| */ | |
| async function setDevToolsSettings(devToolsPata: DevToolsPage, settings: Record<string, unknown>) { | |
| if (!Object.keys(settings).length) { | |
| return; | |
| } | |
| const rawValues = Object.entries(settings).map(value => { | |
| switch (typeof value[1]) { | |
| case 'boolean': | |
| return [value[0], value[1].toString()]; | |
| case 'string': | |
| case 'number': | |
| case 'bigint': | |
| return [value[0], `'${value[1]}'`]; | |
| default: | |
| return [value[0], JSON.stringify(value[1])]; | |
| } | |
| }); | |
| const expression = `(async () => { | |
| const Common = await import('./core/common/common.js'); | |
| var setting; | |
| ${ | |
| rawValues | |
| .map(([settingName, value]) => `setting = Common.Settings.Settings.instance().createSetting('${settingName}'); | |
| setting.set(${value})`) | |
| .join('\n ')} | |
| })()`; | |
| return await devToolsPata.evaluate(expression); | |
| } | |
| /** | |
| * @internal | |
| */ | |
| async function setDockingSide(devToolsPage: DevToolsPage, side: string) { | |
| await devToolsPage.evaluate(` | |
| (async function() { | |
| const UI = await import('./ui/legacy/legacy.js'); | |
| UI.DockController.DockController.instance().setDockSide('${side}'); | |
| })(); | |
| `); | |
| } | |
| export async function setupDevToolsPage( | |
| inspectedPage: InspectedPage, | |
| settings: DevtoolsSettings, | |
| ): Promise<DevToolsPage> { | |
| const frontend = await inspectedPage.page.openDevTools(); | |
| installPageErrorHandlers(frontend); | |
| const devToolsPage = new DevToolsPage(frontend); | |
| await devToolsPage.ensureReadyForTesting(); | |
| await Promise.all([ | |
| devToolsPage.disableAnimations(), | |
| setDevToolsSettings(devToolsPage, settings.devToolsSettings), | |
| ]); | |
| await devToolsPage.reloadWithParams({panel: settings.panel}, true); | |
| await Promise.all([ | |
| devToolsPage.throttleCPUIfRequired(), | |
| devToolsPage.delayPromisesIfRequired(), | |
| devToolsPage.useSoftMenu(), | |
| ]); | |
| await setDockingSide(devToolsPage, settings.dockingMode); | |
| return devToolsPage; | |
| } | |
| class HighlightedElement { | |
| constructor(readonly element: puppeteer.ElementHandle) { | |
| } | |
| async reset() { | |
| await this.element.evaluate(el => { | |
| if (el instanceof HTMLElement) { | |
| el.style.outline = ''; | |
| } | |
| }); | |
| } | |
| async highlight() { | |
| await this.element.evaluate(el => { | |
| if (el instanceof HTMLElement) { | |
| el.style.outline = '2px solid red'; | |
| } | |
| }); | |
| } | |
| } |