[go: nahoru, domu]

blob: b4ba86d8cd8cfa88b7d3f93586d90451d37d2a38 [file] [log] [blame]
// 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 type * as puppeteer from '../../../third_party/puppeteer/puppeteer.js';
import * as SDK from '../../../core/sdk/sdk.js';
import * as Common from '../../../core/common/common.js';
import * as PuppeteerService from '../../../services/puppeteer/puppeteer.js';
import * as PuppeteerReplay from '../../../third_party/puppeteer-replay/puppeteer-replay.js';
// eslint-disable-next-line rulesdir/es_modules_import
import type * as Protocol from '../../../generated/protocol.js';
import {type Step, type UserFlow} from './Schema.js';
export const enum PlayRecordingSpeed {
Normal = 'normal',
Slow = 'slow',
VerySlow = 'very_slow',
ExtremelySlow = 'extremely_slow',
}
const speedDelayMap: Record<PlayRecordingSpeed, number> = {
[PlayRecordingSpeed.Normal]: 0,
[PlayRecordingSpeed.Slow]: 500,
[PlayRecordingSpeed.VerySlow]: 1000,
[PlayRecordingSpeed.ExtremelySlow]: 2000,
} as const;
export const enum ReplayResult {
Failure = 'Failure',
Success = 'Success',
}
export const defaultTimeout = 5000; // ms
export function shouldAttachToTarget(
mainTargetId: string,
targetInfo: Protocol.Target.TargetInfo,
): boolean {
// Ignore chrome extensions as we don't support them. This includes DevTools extensions.
if (targetInfo.url.startsWith('chrome-extension://')) {
return false;
}
// Allow DevTools-on-DevTools replay.
if (targetInfo.url.startsWith('devtools://') && targetInfo.targetId === mainTargetId) {
return true;
}
if (targetInfo.type !== 'page' && targetInfo.type !== 'iframe') {
return false;
}
// TODO only connect to iframes that are related to the main target. This requires refactoring in Puppeteer: https://github.com/puppeteer/puppeteer/issues/3667.
return (targetInfo.targetId === mainTargetId || targetInfo.openerId === mainTargetId || targetInfo.type === 'iframe');
}
function isPageTarget(target: Protocol.Target.TargetInfo): boolean {
// Treat DevTools targets as page targets too.
return (
target.url.startsWith('devtools://') || target.type === 'page' || target.type === 'background_page' ||
target.type === 'webview');
}
export class RecordingPlayer extends Common.ObjectWrapper.ObjectWrapper<EventTypes> {
#stopPromise: Promise<void>;
#resolveStopPromise?: Function;
userFlow: UserFlow;
speed: PlayRecordingSpeed;
timeout: number;
breakpointIndexes: Set<number>;
steppingOver: boolean = false;
aborted = false;
abortPromise: Promise<void>;
#abortResolveFn?: Function;
#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;
this.#stopPromise = new Promise(resolve => {
this.#resolveStopPromise = resolve;
});
this.abortPromise = new Promise(resolve => {
this.#abortResolveFn = resolve;
});
}
#resolveAndRefreshStopPromise(): void {
this.#resolveStopPromise?.();
this.#stopPromise = new Promise(resolve => {
this.#resolveStopPromise = resolve;
});
}
static async connectPuppeteer(): Promise<{
page: puppeteer.Page,
browser: puppeteer.Browser,
}> {
const mainTarget = SDK.TargetManager.TargetManager.instance().primaryPageTarget();
if (!mainTarget) {
throw new Error('Could not find main target');
}
const childTargetManager = mainTarget.model(
SDK.ChildTargetManager.ChildTargetManager,
);
if (!childTargetManager) {
throw new Error('Could not get childTargetManager');
}
const resourceTreeModel = mainTarget.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');
}
// Pass an empty message handler because it will be overwritten by puppeteer anyways.
const result = await childTargetManager.createParallelConnection(() => {});
const connection = result.connection as SDK.Connections.ParallelConnectionInterface;
const mainTargetId = await childTargetManager.getParentTargetId();
const {page, browser, puppeteerConnection} =
await PuppeteerService.PuppeteerConnection.PuppeteerConnectionHelper.connectPuppeteerToConnection(
{
connection,
mainFrameId: mainFrame.id,
targetInfos: childTargetManager.targetInfos(),
targetFilterCallback: shouldAttachToTarget.bind(null, mainTargetId),
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});
}
}
browser.disconnect();
} catch (err) {
console.error('Error disconnecting Puppeteer', err.message);
}
}
async stop(): Promise<void> {
await Promise.race([this.#stopPromise, this.abortPromise]);
}
abort(): void {
this.aborted = true;
this.#abortResolveFn?.();
this.#runner?.abort();
}
disposeForTesting(): void {
this.#resolveStopPromise?.();
this.#abortResolveFn?.();
}
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> {
let resolver: () => void = () => {};
const promise = new Promise<void>(r => {
resolver = r;
});
player.dispatchEventToListeners(Events.Step, {
step,
resolve: resolver,
});
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 (page?.url().startsWith('devtools://') && (step.type === 'setViewport' || step.type === 'navigate')) {
return;
}
return 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',
}
type EventTypes = {
[Events.Abort]: void,
[Events.Done]: void,
[Events.Step]: {step: Step, resolve: () => void},
[Events.Stop]: void,
[Events.Continue]: void,
[Events.Error]: Error,
};