UNPKG

chrome-devtools-frontend

Version:
348 lines (312 loc) • 11.2 kB
// 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 mainTarget = SDK.TargetManager.TargetManager.instance().primaryPageTarget(); if (mainTarget) { await mainTarget.pageAgent().invoke_setPrerenderingAllowed({ isAllowed: false, }); } 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 { const mainTarget = SDK.TargetManager.TargetManager.instance().primaryPageTarget(); if (mainTarget) { await mainTarget.pageAgent().invoke_setPrerenderingAllowed({ isAllowed: true, }); } 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, };