| // 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; |
| } |
| |
| 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 => { |
| window.__pendingEvents = window.__pendingEvents || new Map(); |
| window.addEventListener(eventType, (e: Event) => { |
| let events = window.__pendingEvents.get(eventType); |
| if (!events) { |
| events = []; |
| window.__pendingEvents.set(eventType, events); |
| } |
| events.push(e); |
| }); |
| }, eventType); |
| } |
| |
| getPendingEvents(eventType: string): Promise<Event[]|undefined> { |
| return this.page.evaluate(eventType => { |
| if (!('__pendingEvents' in window)) { |
| return; |
| } |
| const pendingEvents = window.__pendingEvents.get(eventType); |
| window.__pendingEvents.set(eventType, []); |
| return pendingEvents; |
| }, 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(); |
| } |
| |
| /** |
| * 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> { |
| const session = await this.page.createCDPSession(); |
| await session.send('HeapProfiler.enable'); |
| await session.send('HeapProfiler.collectGarbage'); |
| |
| 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 { |
| enabledDevToolsExperiments: string[]; |
| disabledDevToolsExperiments: string[]; |
| 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 = { |
| enabledDevToolsExperiments: [], |
| disabledDevToolsExperiments: [], |
| 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 setDevToolsExperiments(devToolsPage: DevToolsPage, experiments: string[]) { |
| if (!experiments.length) { |
| return; |
| } |
| return await devToolsPage.evaluate(async experiments => { |
| // @ts-expect-error evaluate in DevTools page |
| const Root = await import('./core/root/root.js'); |
| for (const experiment of experiments) { |
| Root.Runtime.experiments.setEnabled(experiment, true); |
| } |
| }, experiments); |
| } |
| |
| /** |
| * @internal |
| */ |
| async function setDisabledDevToolsExperiments(devToolsPage: DevToolsPage, experiments: string[]) { |
| if (!experiments.length) { |
| return; |
| } |
| return await devToolsPage.evaluate(async experiments => { |
| // @ts-expect-error evaluate in DevTools page |
| const Root = await import('./core/root/root.js'); |
| for (const experiment of experiments) { |
| Root.Runtime.experiments.setEnabled(experiment, false); |
| } |
| }, experiments); |
| } |
| |
| /** |
| * @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), |
| setDevToolsExperiments(devToolsPage, settings.enabledDevToolsExperiments), |
| setDisabledDevToolsExperiments(devToolsPage, settings.disabledDevToolsExperiments), |
| ]); |
| |
| 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'; |
| } |
| }); |
| } |
| } |