blob: 1340dd9f8c4a60795e57f62f9cc3e8e1a9bbe1ea [file] [log] [blame]
// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as fs from 'node:fs';
import * as path from 'node:path';
import type * as puppeteer from 'puppeteer-core';
import {installPageErrorHandlers} from './events.js';
import {BUILD_ROOT} from './paths.js';
// When loading DevTools with target.goto, we wait for it to be fully loaded using these events.
const DEVTOOLS_WAITUNTIL_EVENTS: puppeteer.PuppeteerLifeCycleEvent[] = ['networkidle2', 'domcontentloaded'];
// When loading an empty page (including within the devtools window), we wait for it to be loaded using these events.
const EMPTY_PAGE_WAITUNTIL_EVENTS: puppeteer.PuppeteerLifeCycleEvent[] = ['domcontentloaded'];
const EMPTY_PAGE = 'data:text/html,<!DOCTYPE html>';
export interface DevToolsFrontendCreationOptions {
browser: puppeteer.Browser;
testServerPort: number;
targetId: string;
}
export interface DevToolsFrontendReloadOptions {
selectedPanel?: {name: string, selector?: string};
canDock?: boolean;
queryParams?: {panel?: string};
drawerShown?: boolean;
}
/**
* Wrapper class around `puppeteer.Page` that helps with setting up and
* managing a DevTools frontend tab.
*/
export class DevToolsFrontendTab {
readonly #frontendUrl: string;
private static readonly DEFAULT_TAB = {
name: 'elements',
selector: '.elements',
};
// We use the counter to give each tab a unique origin.
private static tabCounter = 0;
private constructor(readonly page: puppeteer.Page, frontendUrl: string) {
this.#frontendUrl = frontendUrl;
}
static async create({browser, testServerPort, targetId}: DevToolsFrontendCreationOptions):
Promise<DevToolsFrontendTab> {
// TODO(pfaffe): Remove the distinction once it's uniform in chrome-branded builds
let devToolsAppURL = 'devtools_app.html';
if (!fs.existsSync(path.join(BUILD_ROOT, 'gen', devToolsAppURL))) {
devToolsAppURL = 'front_end/devtools_app.html';
}
// We load the DevTools frontend on a unique origin. Otherwise we would share 'localhost' with
// target pages. This could cause difficult to debug problems as things like window.localStorage
// would be shared and requests would be "same-origin".
// We also use a unique ID per DevTools frontend instance, to avoid the same issue with other
// frontend instances.
const id = DevToolsFrontendTab.tabCounter++;
const frontendUrl = `https://i${id}.devtools-frontend.test:${testServerPort}/${devToolsAppURL}?ws=localhost:${
getDebugPort(browser)}/devtools/page/${targetId}&targetType=tab`;
const frontend = await browser.newPage();
installPageErrorHandlers(frontend);
const devToolsVeLogging = {enabled: true, testing: true};
await frontend.evaluateOnNewDocument(`globalThis.hostConfigForTesting = ${JSON.stringify({devToolsVeLogging})};`);
await frontend.goto(frontendUrl, {waitUntil: DEVTOOLS_WAITUNTIL_EVENTS});
const tab = new DevToolsFrontendTab(frontend, frontendUrl);
return tab;
}
/** Same as `reload` but also clears out experiments and settings (window.localStorage really) */
async reset(): Promise<void> {
// Clear any local storage settings.
await this.page.evaluate(() => {
localStorage.clear();
// Test logging needs debug event logging,
// which is controlled via localStorage, hence we need to restart test logging here
// This can be called after a page fails to load DevTools so make it conditional
// @ts-expect-error
globalThis?.setVeDebugLoggingEnabled?.(true, 'Test');
});
await this.reload();
}
async reload(options: DevToolsFrontendReloadOptions = {}): Promise<void> {
// For the unspecified case wait for loading, then wait for the elements panel.
const {selectedPanel = DevToolsFrontendTab.DEFAULT_TAB, canDock = false, queryParams = {}, drawerShown = false} =
options;
if (selectedPanel.name !== DevToolsFrontendTab.DEFAULT_TAB.name) {
await this.page.evaluate(name => {
globalThis.localStorage.setItem('panel-selected-tab', `"${name}"`);
}, selectedPanel.name);
}
if (drawerShown) {
await this.page.evaluate(() => {
globalThis.localStorage.setItem('inspector.drawer-split-view-state', '{"horizontal" : {"showMode": "Both"}}');
});
}
// Reload the DevTools frontend and await the elements panel.
await loadEmptyPageAndWaitForContent(this.page);
// omit "can_dock=" when it's false because appending "can_dock=false"
// will make getElementPosition in shared helpers unhappy
let url = canDock ? `${this.#frontendUrl}&can_dock=true` : this.#frontendUrl;
if (queryParams.panel) {
url += `&panel=${queryParams.panel}`;
}
await this.page.goto(url, {waitUntil: DEVTOOLS_WAITUNTIL_EVENTS});
if (!queryParams.panel && selectedPanel.selector) {
await this.page.waitForSelector(selectedPanel.selector);
}
}
/**
* Returns the current hostname of this frontend tab. This might not be
* consistent with the intial URL in case the page was navigated to
* a different origin.
*/
hostname(): string {
const url = new URL(this.page.url());
return url.hostname;
}
}
export async function loadEmptyPageAndWaitForContent(target: puppeteer.Page) {
await target.goto(EMPTY_PAGE, {waitUntil: EMPTY_PAGE_WAITUNTIL_EVENTS});
}
function getDebugPort(browser: puppeteer.Browser) {
const websocketUrl = browser.wsEndpoint();
const url = new URL(websocketUrl);
if (url.port) {
return url.port;
}
throw new Error(`Unable to find debug port: ${websocketUrl}`);
}