chrome-devtools-frontend
Version:
Chrome DevTools UI
325 lines (286 loc) • 10.5 kB
text/typescript
// Copyright 2023 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as Common from '../../../core/common/common.js';
import type * as Platform from '../../../core/platform/platform.js';
import * as SDK from '../../../core/sdk/sdk.js';
import type * as Protocol from '../../../generated/protocol.js';
import * as PuppeteerService from '../../../services/puppeteer/puppeteer.js';
import * as PuppeteerReplay from '../../../third_party/puppeteer-replay/puppeteer-replay.js';
import type * as puppeteer from '../../../third_party/puppeteer/puppeteer.js';
import type {Step, UserFlow} from './Schema.js';
export const enum PlayRecordingSpeed {
NORMAL = 'normal',
SLOW = 'slow',
VERY_SLOW = 'very_slow',
EXTREMELY_SLOW = 'extremely_slow',
}
const speedDelayMap: Record<PlayRecordingSpeed, number> = {
[PlayRecordingSpeed.NORMAL]: 0,
[PlayRecordingSpeed.SLOW]: 500,
[PlayRecordingSpeed.VERY_SLOW]: 1000,
[PlayRecordingSpeed.EXTREMELY_SLOW]: 2000,
} as const;
export const enum ReplayResult {
FAILURE = 'Failure',
SUCCESS = 'Success',
}
export const defaultTimeout = 5000; // ms
function isPageTarget(target: Protocol.Target.TargetInfo): boolean {
// Treat DevTools targets as page targets too.
return (
Common.ParsedURL.schemeIs(target.url as Platform.DevToolsPath.UrlString, 'devtools:') || target.type === 'page' ||
target.type === 'background_page' || target.type === 'webview');
}
export class RecordingPlayer extends Common.ObjectWrapper.ObjectWrapper<EventTypes> {
userFlow: UserFlow;
speed: PlayRecordingSpeed;
timeout: number;
breakpointIndexes: Set<number>;
steppingOver = false;
aborted = false;
#stopPromise = Promise.withResolvers<void>();
#abortPromise = Promise.withResolvers<void>();
#runner?: PuppeteerReplay.Runner;
constructor(
userFlow: UserFlow,
{
speed,
breakpointIndexes = new Set(),
}: {
speed: PlayRecordingSpeed,
breakpointIndexes?: Set<number>,
},
) {
super();
this.userFlow = userFlow;
this.speed = speed;
this.timeout = userFlow.timeout || defaultTimeout;
this.breakpointIndexes = breakpointIndexes;
}
#resolveAndRefreshStopPromise(): void {
this.#stopPromise.resolve();
this.#stopPromise = Promise.withResolvers();
}
static async connectPuppeteer(): Promise<{
page: puppeteer.Page,
browser: puppeteer.Browser,
}> {
const rootTarget = SDK.TargetManager.TargetManager.instance().rootTarget();
if (!rootTarget) {
throw new Error('Could not find the root target');
}
const primaryPageTarget = SDK.TargetManager.TargetManager.instance().primaryPageTarget();
if (!primaryPageTarget) {
throw new Error('Could not find the primary page target');
}
const childTargetManager = primaryPageTarget.model(
SDK.ChildTargetManager.ChildTargetManager,
);
if (!childTargetManager) {
throw new Error('Could not get childTargetManager');
}
const resourceTreeModel = primaryPageTarget.model(
SDK.ResourceTreeModel.ResourceTreeModel,
);
if (!resourceTreeModel) {
throw new Error('Could not get resource tree model');
}
const mainFrame = resourceTreeModel.mainFrame;
if (!mainFrame) {
throw new Error('Could not find main frame');
}
const rootChildTargetManager = rootTarget.model(SDK.ChildTargetManager.ChildTargetManager);
if (!rootChildTargetManager) {
throw new Error('Could not find the child target manager class for the root target');
}
// Pass an empty message handler because it will be overwritten by puppeteer anyways.
const result = await rootChildTargetManager.createParallelConnection(() => {});
const connection = result.connection as SDK.Connections.ParallelConnectionInterface;
const mainTargetId = await childTargetManager.getParentTargetId();
const rootTargetId = await rootChildTargetManager.getParentTargetId();
const {page, browser, puppeteerConnection} =
await PuppeteerService.PuppeteerConnection.PuppeteerConnectionHelper.connectPuppeteerToConnectionViaTab(
{
connection,
rootTargetId: rootTargetId as string,
isPageTargetCallback: isPageTarget,
},
);
if (!page) {
throw new Error('could not find main page!');
}
browser.on('targetdiscovered', (targetInfo: Protocol.Target.TargetInfo) => {
// Pop-ups opened by the main target won't be auto-attached. Therefore,
// we need to create a session for them explicitly. We user openedId
// and type to classify a target as requiring a session.
if (targetInfo.type !== 'page') {
return;
}
if (targetInfo.targetId === mainTargetId) {
return;
}
if (targetInfo.openerId !== mainTargetId) {
return;
}
void puppeteerConnection._createSession(
targetInfo,
/* emulateAutoAttach= */ true,
);
});
return {page, browser};
}
static async disconnectPuppeteer(browser: puppeteer.Browser): Promise<void> {
try {
const pages = await browser.pages();
for (const page of pages) {
const client = (page as puppeteer.Page)._client();
await client.send('Network.disable');
await client.send('Page.disable');
await client.send('Log.disable');
await client.send('Performance.disable');
await client.send('Runtime.disable');
await client.send('Emulation.clearDeviceMetricsOverride');
await client.send('Emulation.setAutomationOverride', {enabled: false});
for (const frame of page.frames()) {
const client = frame.client;
await client.send('Network.disable');
await client.send('Page.disable');
await client.send('Log.disable');
await client.send('Performance.disable');
await client.send('Runtime.disable');
await client.send('Emulation.setAutomationOverride', {enabled: false});
}
}
await browser.disconnect();
} catch (err) {
console.error('Error disconnecting Puppeteer', err.message);
}
}
async stop(): Promise<void> {
await Promise.race([this.#stopPromise, this.#abortPromise]);
}
get abortPromise(): Promise<void> {
return this.#abortPromise.promise;
}
abort(): void {
this.aborted = true;
this.#abortPromise.resolve();
this.#runner?.abort();
}
disposeForTesting(): void {
this.#stopPromise.resolve();
this.#abortPromise.resolve();
}
continue(): void {
this.steppingOver = false;
this.#resolveAndRefreshStopPromise();
}
stepOver(): void {
this.steppingOver = true;
this.#resolveAndRefreshStopPromise();
}
updateBreakpointIndexes(breakpointIndexes: Set<number>): void {
this.breakpointIndexes = breakpointIndexes;
}
async play(): Promise<void> {
const {page, browser} = await RecordingPlayer.connectPuppeteer();
this.aborted = false;
const player = this;
class ExtensionWithBreak extends PuppeteerReplay.PuppeteerRunnerExtension {
readonly #speed: PlayRecordingSpeed;
constructor(
browser: puppeteer.Browser,
page: puppeteer.Page,
{
timeout,
speed,
}: {
timeout: number,
speed: PlayRecordingSpeed,
},
) {
super(browser, page, {timeout});
this.#speed = speed;
}
override async beforeEachStep?(step: Step, flow: UserFlow): Promise<void> {
const {resolve, promise} = Promise.withResolvers<void>();
player.dispatchEventToListeners(Events.STEP, {
step,
resolve,
});
await promise;
const currentStepIndex = flow.steps.indexOf(step);
const shouldStopAtCurrentStep = player.steppingOver || player.breakpointIndexes.has(currentStepIndex);
const shouldWaitForSpeed = step.type !== 'setViewport' && step.type !== 'navigate' && !player.aborted;
if (shouldStopAtCurrentStep) {
player.dispatchEventToListeners(Events.STOP);
await player.stop();
player.dispatchEventToListeners(Events.CONTINUE);
} else if (shouldWaitForSpeed) {
await Promise.race([
new Promise(
resolve => setTimeout(resolve, speedDelayMap[this.#speed]),
),
player.abortPromise,
]);
}
}
override async runStep(
step: PuppeteerReplay.Schema.Step,
flow: PuppeteerReplay.Schema.UserFlow,
): Promise<void> {
// When replaying on a DevTools target we skip setViewport and navigate steps
// because navigation and viewport changes are not supported there.
if (Common.ParsedURL.schemeIs(page?.url() as Platform.DevToolsPath.UrlString, 'devtools:') &&
(step.type === 'setViewport' || step.type === 'navigate')) {
return;
}
if (step.type === 'navigate' &&
Common.ParsedURL.schemeIs(step.url as Platform.DevToolsPath.UrlString, 'chrome:')) {
throw new Error('Not allowed to replay on chrome:// URLs');
}
// Focus the target in case it's not focused.
await this.page.bringToFront();
await super.runStep(step, flow);
}
}
const extension = new ExtensionWithBreak(browser, page, {
timeout: this.timeout,
speed: this.speed,
});
this.#runner = await PuppeteerReplay.createRunner(this.userFlow, extension);
let error: Error|undefined;
try {
await this.#runner.run();
} catch (err) {
error = err;
console.error('Replay error', err.message);
} finally {
await RecordingPlayer.disconnectPuppeteer(browser);
}
if (this.aborted) {
this.dispatchEventToListeners(Events.ABORT);
} else if (error) {
this.dispatchEventToListeners(Events.ERROR, error);
} else {
this.dispatchEventToListeners(Events.DONE);
}
}
}
export const enum Events {
ABORT = 'Abort',
DONE = 'Done',
STEP = 'Step',
STOP = 'Stop',
ERROR = 'Error',
CONTINUE = 'Continue',
}
interface EventTypes {
[Events.ABORT]: void;
[Events.DONE]: void;
[Events.STEP]: {step: Step, resolve: () => void};
[Events.STOP]: void;
[Events.CONTINUE]: void;
[Events.ERROR]: Error;
}