/** * @license * Copyright 2017 Google Inc. * SPDX-License-Identifier: Apache-2.0 */ import type {Protocol} from 'devtools-protocol'; import {firstValueFrom, from, raceWith} from '../../third_party/rxjs/rxjs.js'; import type {Browser} from '../api/Browser.js'; import type {BrowserContext} from '../api/BrowserContext.js'; import {CDPSessionEvent, type CDPSession} from '../api/CDPSession.js'; import type {ElementHandle} from '../api/ElementHandle.js'; import type {Frame, WaitForOptions} from '../api/Frame.js'; import type {HTTPResponse} from '../api/HTTPResponse.js'; import type {JSHandle} from '../api/JSHandle.js'; import type {Credentials} from '../api/Page.js'; import { Page, PageEvent, type GeolocationOptions, type MediaFeature, type Metrics, type NewDocumentScriptEvaluation, type ScreenshotClip, type ScreenshotOptions, type WaitTimeoutOptions, } from '../api/Page.js'; import { ConsoleMessage, type ConsoleMessageType, } from '../common/ConsoleMessage.js'; import type { Cookie, DeleteCookiesRequest, CookieParam, } from '../common/Cookie.js'; import {TargetCloseError} from '../common/Errors.js'; import {EventEmitter} from '../common/EventEmitter.js'; import {FileChooser} from '../common/FileChooser.js'; import {NetworkManagerEvent} from '../common/NetworkManagerEvents.js'; import type {PDFOptions} from '../common/PDFOptions.js'; import type {BindingPayload, HandleFor} from '../common/types.js'; import { debugError, evaluationString, getReadableAsTypedArray, getReadableFromProtocolStream, parsePDFOptions, timeout, validateDialogType, } from '../common/util.js'; import type {Viewport} from '../common/Viewport.js'; import {assert} from '../util/assert.js'; import {Deferred} from '../util/Deferred.js'; import {AsyncDisposableStack} from '../util/disposable.js'; import {isErrorLike} from '../util/ErrorLike.js'; import {Binding} from './Binding.js'; import {CdpCDPSession} from './CDPSession.js'; import {isTargetClosedError} from './Connection.js'; import {Coverage} from './Coverage.js'; import type {DeviceRequestPrompt} from './DeviceRequestPrompt.js'; import {CdpDialog} from './Dialog.js'; import {EmulationManager} from './EmulationManager.js'; import {FirefoxTargetManager} from './FirefoxTargetManager.js'; import type {CdpFrame} from './Frame.js'; import {FrameManager} from './FrameManager.js'; import {FrameManagerEvent} from './FrameManagerEvents.js'; import {CdpKeyboard, CdpMouse, CdpTouchscreen} from './Input.js'; import type {IsolatedWorld} from './IsolatedWorld.js'; import {MAIN_WORLD} from './IsolatedWorlds.js'; import {releaseObject} from './JSHandle.js'; import type {NetworkConditions} from './NetworkManager.js'; import type {CdpTarget} from './Target.js'; import type {TargetManager} from './TargetManager.js'; import {TargetManagerEvent} from './TargetManager.js'; import {Tracing} from './Tracing.js'; import { createClientError, pageBindingInitString, valueFromRemoteObject, } from './utils.js'; import {CdpWebWorker} from './WebWorker.js'; function convertConsoleMessageLevel(method: string): ConsoleMessageType { switch (method) { case 'warning': return 'warn'; default: return method as ConsoleMessageType; } } /** * @internal */ export class CdpPage extends Page { static async _create( client: CDPSession, target: CdpTarget, defaultViewport: Viewport | null ): Promise<CdpPage> { const page = new CdpPage(client, target); await page.#initialize(); if (defaultViewport) { try { await page.setViewport(defaultViewport); } catch (err) { if (isErrorLike(err) && isTargetClosedError(err)) { debugError(err); } else { throw err; } } } return page; } #closed = false; readonly #targetManager: TargetManager; #primaryTargetClient: CDPSession; #primaryTarget: CdpTarget; #tabTargetClient: CDPSession; #tabTarget: CdpTarget; #keyboard: CdpKeyboard; #mouse: CdpMouse; #touchscreen: CdpTouchscreen; #frameManager: FrameManager; #emulationManager: EmulationManager; #tracing: Tracing; #bindings = new Map<string, Binding>(); #exposedFunctions = new Map<string, string>(); #coverage: Coverage; #viewport: Viewport | null; #workers = new Map<string, CdpWebWorker>(); #fileChooserDeferreds = new Set<Deferred<FileChooser>>(); #sessionCloseDeferred = Deferred.create<never, TargetCloseError>(); #serviceWorkerBypassed = false; #userDragInterceptionEnabled = false; constructor(client: CDPSession, target: CdpTarget) { super(); this.#primaryTargetClient = client; this.#tabTargetClient = client.parentSession()!; assert(this.#tabTargetClient, 'Tab target session is not defined.'); this.#tabTarget = (this.#tabTargetClient as CdpCDPSession)._target(); assert(this.#tabTarget, 'Tab target is not defined.'); this.#primaryTarget = target; this.#targetManager = target._targetManager(); this.#keyboard = new CdpKeyboard(client); this.#mouse = new CdpMouse(client, this.#keyboard); this.#touchscreen = new CdpTouchscreen(client, this.#keyboard); this.#frameManager = new FrameManager(client, this, this._timeoutSettings); this.#emulationManager = new EmulationManager(client); this.#tracing = new Tracing(client); this.#coverage = new Coverage(client); this.#viewport = null; const frameManagerEmitter = new EventEmitter(this.#frameManager); frameManagerEmitter.on(FrameManagerEvent.FrameAttached, frame => { this.emit(PageEvent.FrameAttached, frame); }); frameManagerEmitter.on(FrameManagerEvent.FrameDetached, frame => { this.emit(PageEvent.FrameDetached, frame); }); frameManagerEmitter.on(FrameManagerEvent.FrameNavigated, frame => { this.emit(PageEvent.FrameNavigated, frame); }); frameManagerEmitter.on( FrameManagerEvent.ConsoleApiCalled, ([world, event]) => { this.#onConsoleAPI(world, event); } ); frameManagerEmitter.on( FrameManagerEvent.BindingCalled, ([world, event]) => { void this.#onBindingCalled(world, event); } ); const networkManagerEmitter = new EventEmitter( this.#frameManager.networkManager ); networkManagerEmitter.on(NetworkManagerEvent.Request, request => { this.emit(PageEvent.Request, request); }); networkManagerEmitter.on( NetworkManagerEvent.RequestServedFromCache, request => { this.emit(PageEvent.RequestServedFromCache, request!); } ); networkManagerEmitter.on(NetworkManagerEvent.Response, response => { this.emit(PageEvent.Response, response); }); networkManagerEmitter.on(NetworkManagerEvent.RequestFailed, request => { this.emit(PageEvent.RequestFailed, request); }); networkManagerEmitter.on(NetworkManagerEvent.RequestFinished, request => { this.emit(PageEvent.RequestFinished, request); }); this.#tabTargetClient.on( CDPSessionEvent.Swapped, this.#onActivation.bind(this) ); this.#tabTargetClient.on( CDPSessionEvent.Ready, this.#onSecondaryTarget.bind(this) ); this.#targetManager.on( TargetManagerEvent.TargetGone, this.#onDetachedFromTarget ); this.#tabTarget._isClosedDeferred .valueOrThrow() .then(() => { this.#targetManager.off( TargetManagerEvent.TargetGone, this.#onDetachedFromTarget ); this.emit(PageEvent.Close, undefined); this.#closed = true; }) .catch(debugError); this.#setupPrimaryTargetListeners(); this.#attachExistingTargets(); } #attachExistingTargets(): void { const queue = []; for (const childTarget of this.#targetManager.getChildTargets( this.#primaryTarget )) { queue.push(childTarget); } let idx = 0; while (idx < queue.length) { const next = queue[idx] as CdpTarget; idx++; const session = next._session(); if (session) { this.#onAttachedToTarget(session); } for (const childTarget of this.#targetManager.getChildTargets(next)) { queue.push(childTarget); } } } async #onActivation(newSession: CDPSession): Promise<void> { this.#primaryTargetClient = newSession; assert( this.#primaryTargetClient instanceof CdpCDPSession, 'CDPSession is not instance of CDPSessionImpl' ); this.#primaryTarget = this.#primaryTargetClient._target(); assert(this.#primaryTarget, 'Missing target on swap'); this.#keyboard.updateClient(newSession); this.#mouse.updateClient(newSession); this.#touchscreen.updateClient(newSession); this.#emulationManager.updateClient(newSession); this.#tracing.updateClient(newSession); this.#coverage.updateClient(newSession); await this.#frameManager.swapFrameTree(newSession); this.#setupPrimaryTargetListeners(); } async #onSecondaryTarget(session: CDPSession): Promise<void> { assert(session instanceof CdpCDPSession); if (session._target()._subtype() !== 'prerender') { return; } this.#frameManager.registerSpeculativeSession(session).catch(debugError); this.#emulationManager .registerSpeculativeSession(session) .catch(debugError); } /** * Sets up listeners for the primary target. The primary target can change * during a navigation to a prerended page. */ #setupPrimaryTargetListeners() { const clientEmitter = new EventEmitter(this.#primaryTargetClient); clientEmitter.on(CDPSessionEvent.Ready, this.#onAttachedToTarget); clientEmitter.on(CDPSessionEvent.Disconnected, () => { this.#sessionCloseDeferred.reject(new TargetCloseError('Target closed')); }); clientEmitter.on('Page.domContentEventFired', () => { this.emit(PageEvent.DOMContentLoaded, undefined); }); clientEmitter.on('Page.loadEventFired', () => { this.emit(PageEvent.Load, undefined); }); clientEmitter.on('Page.javascriptDialogOpening', this.#onDialog.bind(this)); clientEmitter.on( 'Runtime.exceptionThrown', this.#handleException.bind(this) ); clientEmitter.on( 'Inspector.targetCrashed', this.#onTargetCrashed.bind(this) ); clientEmitter.on('Performance.metrics', this.#emitMetrics.bind(this)); clientEmitter.on('Log.entryAdded', this.#onLogEntryAdded.bind(this)); clientEmitter.on('Page.fileChooserOpened', this.#onFileChooser.bind(this)); } #onDetachedFromTarget = (target: CdpTarget) => { const sessionId = target._session()?.id(); const worker = this.#workers.get(sessionId!); if (!worker) { return; } this.#workers.delete(sessionId!); this.emit(PageEvent.WorkerDestroyed, worker); }; #onAttachedToTarget = (session: CDPSession) => { assert(session instanceof CdpCDPSession); this.#frameManager.onAttachedToTarget(session._target()); if (session._target()._getTargetInfo().type === 'worker') { const worker = new CdpWebWorker( session, session._target().url(), session._target()._targetId, session._target().type(), this.#addConsoleMessage.bind(this), this.#handleException.bind(this) ); this.#workers.set(session.id(), worker); this.emit(PageEvent.WorkerCreated, worker); } session.on(CDPSessionEvent.Ready, this.#onAttachedToTarget); }; async #initialize(): Promise<void> { try { await Promise.all([ this.#frameManager.initialize(this.#primaryTargetClient), this.#primaryTargetClient.send('Performance.enable'), this.#primaryTargetClient.send('Log.enable'), ]); } catch (err) { if (isErrorLike(err) && isTargetClosedError(err)) { debugError(err); } else { throw err; } } } async #onFileChooser( event: Protocol.Page.FileChooserOpenedEvent ): Promise<void> { if (!this.#fileChooserDeferreds.size) { return; } const frame = this.#frameManager.frame(event.frameId); assert(frame, 'This should never happen.'); // This is guaranteed to be an HTMLInputElement handle by the event. using handle = (await frame.worlds[MAIN_WORLD].adoptBackendNode( event.backendNodeId )) as ElementHandle<HTMLInputElement>; const fileChooser = new FileChooser(handle.move(), event); for (const promise of this.#fileChooserDeferreds) { promise.resolve(fileChooser); } this.#fileChooserDeferreds.clear(); } _client(): CDPSession { return this.#primaryTargetClient; } override isServiceWorkerBypassed(): boolean { return this.#serviceWorkerBypassed; } override isDragInterceptionEnabled(): boolean { return this.#userDragInterceptionEnabled; } override isJavaScriptEnabled(): boolean { return this.#emulationManager.javascriptEnabled; } override async waitForFileChooser( options: WaitTimeoutOptions = {} ): Promise<FileChooser> { const needsEnable = this.#fileChooserDeferreds.size === 0; const {timeout = this._timeoutSettings.timeout()} = options; const deferred = Deferred.create<FileChooser>({ message: `Waiting for \`FileChooser\` failed: ${timeout}ms exceeded`, timeout, }); if (options.signal) { options.signal.addEventListener( 'abort', () => { deferred.reject(options.signal?.reason); }, {once: true} ); } this.#fileChooserDeferreds.add(deferred); let enablePromise: Promise<void> | undefined; if (needsEnable) { enablePromise = this.#primaryTargetClient.send( 'Page.setInterceptFileChooserDialog', { enabled: true, } ); } try { const [result] = await Promise.all([ deferred.valueOrThrow(), enablePromise, ]); return result; } catch (error) { this.#fileChooserDeferreds.delete(deferred); throw error; } } override async setGeolocation(options: GeolocationOptions): Promise<void> { return await this.#emulationManager.setGeolocation(options); } override target(): CdpTarget { return this.#primaryTarget; } override browser(): Browser { return this.#primaryTarget.browser(); } override browserContext(): BrowserContext { return this.#primaryTarget.browserContext(); } #onTargetCrashed(): void { this.emit(PageEvent.Error, new Error('Page crashed!')); } #onLogEntryAdded(event: Protocol.Log.EntryAddedEvent): void { const {level, text, args, source, url, lineNumber} = event.entry; if (args) { args.map(arg => { void releaseObject(this.#primaryTargetClient, arg); }); } if (source !== 'worker') { this.emit( PageEvent.Console, new ConsoleMessage( convertConsoleMessageLevel(level), text, [], [{url, lineNumber}] ) ); } } override mainFrame(): CdpFrame { return this.#frameManager.mainFrame(); } override get keyboard(): CdpKeyboard { return this.#keyboard; } override get touchscreen(): CdpTouchscreen { return this.#touchscreen; } override get coverage(): Coverage { return this.#coverage; } override get tracing(): Tracing { return this.#tracing; } override frames(): Frame[] { return this.#frameManager.frames(); } override workers(): CdpWebWorker[] { return Array.from(this.#workers.values()); } override async setRequestInterception(value: boolean): Promise<void> { return await this.#frameManager.networkManager.setRequestInterception( value ); } override async setBypassServiceWorker(bypass: boolean): Promise<void> { this.#serviceWorkerBypassed = bypass; return await this.#primaryTargetClient.send( 'Network.setBypassServiceWorker', {bypass} ); } override async setDragInterception(enabled: boolean): Promise<void> { this.#userDragInterceptionEnabled = enabled; return await this.#primaryTargetClient.send('Input.setInterceptDrags', { enabled, }); } override async setOfflineMode(enabled: boolean): Promise<void> { return await this.#frameManager.networkManager.setOfflineMode(enabled); } override async emulateNetworkConditions( networkConditions: NetworkConditions | null ): Promise<void> { return await this.#frameManager.networkManager.emulateNetworkConditions( networkConditions ); } override setDefaultNavigationTimeout(timeout: number): void { this._timeoutSettings.setDefaultNavigationTimeout(timeout); } override setDefaultTimeout(timeout: number): void { this._timeoutSettings.setDefaultTimeout(timeout); } override getDefaultTimeout(): number { return this._timeoutSettings.timeout(); } override async queryObjects<Prototype>( prototypeHandle: JSHandle<Prototype> ): Promise<JSHandle<Prototype[]>> { assert(!prototypeHandle.disposed, 'Prototype JSHandle is disposed!'); assert( prototypeHandle.id, 'Prototype JSHandle must not be referencing primitive value' ); const response = await this.mainFrame().client.send( 'Runtime.queryObjects', { prototypeObjectId: prototypeHandle.id, } ); return this.mainFrame() .mainRealm() .createCdpHandle(response.objects) as HandleFor<Prototype[]>; } override async cookies(...urls: string[]): Promise<Cookie[]> { const originalCookies = ( await this.#primaryTargetClient.send('Network.getCookies', { urls: urls.length ? urls : [this.url()], }) ).cookies; const unsupportedCookieAttributes = ['sourcePort']; const filterUnsupportedAttributes = ( cookie: Protocol.Network.Cookie ): Protocol.Network.Cookie => { for (const attr of unsupportedCookieAttributes) { delete (cookie as unknown as Record<string, unknown>)[attr]; } return cookie; }; return originalCookies.map(filterUnsupportedAttributes).map(cookie => { return { ...cookie, // TODO: a breaking change is needed in Puppeteer types to support other // partition keys. partitionKey: cookie.partitionKey ? cookie.partitionKey.topLevelSite : undefined, }; }); } override async deleteCookie( ...cookies: DeleteCookiesRequest[] ): Promise<void> { const pageURL = this.url(); for (const cookie of cookies) { const item = { ...cookie, // TODO: a breaking change neeeded to change the partition key // type in Puppeteer. partitionKey: cookie.partitionKey ? {topLevelSite: cookie.partitionKey, hasCrossSiteAncestor: false} : undefined, }; if (!cookie.url && pageURL.startsWith('http')) { item.url = pageURL; } await this.#primaryTargetClient.send('Network.deleteCookies', item); if (pageURL.startsWith('http') && !item.partitionKey) { const url = new URL(pageURL); // Delete also cookies from the page's partition. await this.#primaryTargetClient.send('Network.deleteCookies', { ...item, partitionKey: { topLevelSite: url.origin.replace(`:${url.port}`, ''), hasCrossSiteAncestor: false, }, }); } } } override async setCookie(...cookies: CookieParam[]): Promise<void> { const pageURL = this.url(); const startsWithHTTP = pageURL.startsWith('http'); const items = cookies.map(cookie => { const item = Object.assign({}, cookie); if (!item.url && startsWithHTTP) { item.url = pageURL; } assert( item.url !== 'about:blank', `Blank page can not have cookie "${item.name}"` ); assert( !String.prototype.startsWith.call(item.url || '', 'data:'), `Data URL page can not have cookie "${item.name}"` ); return item; }); await this.deleteCookie(...items); if (items.length) { await this.#primaryTargetClient.send('Network.setCookies', { cookies: items.map(cookieParam => { return { ...cookieParam, partitionKey: cookieParam.partitionKey ? { // TODO: a breaking change neeeded to change the partition key // type in Puppeteer. topLevelSite: cookieParam.partitionKey, hasCrossSiteAncestor: false, } : undefined, }; }), }); } } override async exposeFunction( name: string, pptrFunction: Function | {default: Function} ): Promise<void> { if (this.#bindings.has(name)) { throw new Error( `Failed to add page binding with name ${name}: window['${name}'] already exists!` ); } const source = pageBindingInitString('exposedFun', name); let binding: Binding; switch (typeof pptrFunction) { case 'function': binding = new Binding( name, pptrFunction as (...args: unknown[]) => unknown, source ); break; default: binding = new Binding( name, pptrFunction.default as (...args: unknown[]) => unknown, source ); break; } this.#bindings.set(name, binding); const [{identifier}] = await Promise.all([ this.#frameManager.evaluateOnNewDocument(source), this.#frameManager.addExposedFunctionBinding(binding), ]); this.#exposedFunctions.set(name, identifier); } override async removeExposedFunction(name: string): Promise<void> { const exposedFunctionId = this.#exposedFunctions.get(name); if (!exposedFunctionId) { throw new Error(`Function with name "${name}" does not exist`); } // #bindings must be updated together with #exposedFunctions. const binding = this.#bindings.get(name)!; this.#exposedFunctions.delete(name); this.#bindings.delete(name); await Promise.all([ this.#frameManager.removeScriptToEvaluateOnNewDocument(exposedFunctionId), this.#frameManager.removeExposedFunctionBinding(binding), ]); } override async authenticate(credentials: Credentials | null): Promise<void> { return await this.#frameManager.networkManager.authenticate(credentials); } override async setExtraHTTPHeaders( headers: Record<string, string> ): Promise<void> { return await this.#frameManager.networkManager.setExtraHTTPHeaders(headers); } override async setUserAgent( userAgent: string, userAgentMetadata?: Protocol.Emulation.UserAgentMetadata ): Promise<void> { return await this.#frameManager.networkManager.setUserAgent( userAgent, userAgentMetadata ); } override async metrics(): Promise<Metrics> { const response = await this.#primaryTargetClient.send( 'Performance.getMetrics' ); return this.#buildMetricsObject(response.metrics); } #emitMetrics(event: Protocol.Performance.MetricsEvent): void { this.emit(PageEvent.Metrics, { title: event.title, metrics: this.#buildMetricsObject(event.metrics), }); } #buildMetricsObject(metrics?: Protocol.Performance.Metric[]): Metrics { const result: Record< Protocol.Performance.Metric['name'], Protocol.Performance.Metric['value'] > = {}; for (const metric of metrics || []) { if (supportedMetrics.has(metric.name)) { result[metric.name] = metric.value; } } return result; } #handleException(exception: Protocol.Runtime.ExceptionThrownEvent): void { this.emit( PageEvent.PageError, createClientError(exception.exceptionDetails) ); } #onConsoleAPI( world: IsolatedWorld, event: Protocol.Runtime.ConsoleAPICalledEvent ): void { const values = event.args.map(arg => { return world.createCdpHandle(arg); }); this.#addConsoleMessage( convertConsoleMessageLevel(event.type), values, event.stackTrace ); } async #onBindingCalled( world: IsolatedWorld, event: Protocol.Runtime.BindingCalledEvent ): Promise<void> { let payload: BindingPayload; try { payload = JSON.parse(event.payload); } catch { // The binding was either called by something in the page or it was // called before our wrapper was initialized. return; } const {type, name, seq, args, isTrivial} = payload; if (type !== 'exposedFun') { return; } const context = world.context; if (!context) { return; } const binding = this.#bindings.get(name); await binding?.run(context, seq, args, isTrivial); } #addConsoleMessage( eventType: string, args: JSHandle[], stackTrace?: Protocol.Runtime.StackTrace ): void { if (!this.listenerCount(PageEvent.Console)) { args.forEach(arg => { return arg.dispose(); }); return; } const textTokens = []; // eslint-disable-next-line max-len -- The comment is long. // eslint-disable-next-line rulesdir/use-using -- These are not owned by this function. for (const arg of args) { const remoteObject = arg.remoteObject(); if (remoteObject.objectId) { textTokens.push(arg.toString()); } else { textTokens.push(valueFromRemoteObject(remoteObject)); } } const stackTraceLocations = []; if (stackTrace) { for (const callFrame of stackTrace.callFrames) { stackTraceLocations.push({ url: callFrame.url, lineNumber: callFrame.lineNumber, columnNumber: callFrame.columnNumber, }); } } const message = new ConsoleMessage( convertConsoleMessageLevel(eventType), textTokens.join(' '), args, stackTraceLocations ); this.emit(PageEvent.Console, message); } #onDialog(event: Protocol.Page.JavascriptDialogOpeningEvent): void { const type = validateDialogType(event.type); const dialog = new CdpDialog( this.#primaryTargetClient, type, event.message, event.defaultPrompt ); this.emit(PageEvent.Dialog, dialog); } override async reload( options?: WaitForOptions ): Promise<HTTPResponse | null> { const [result] = await Promise.all([ this.waitForNavigation({ ...options, ignoreSameDocumentNavigation: true, }), this.#primaryTargetClient.send('Page.reload'), ]); return result; } override async createCDPSession(): Promise<CDPSession> { return await this.target().createCDPSession(); } override async goBack( options: WaitForOptions = {} ): Promise<HTTPResponse | null> { return await this.#go(-1, options); } override async goForward( options: WaitForOptions = {} ): Promise<HTTPResponse | null> { return await this.#go(+1, options); } async #go( delta: number, options: WaitForOptions ): Promise<HTTPResponse | null> { const history = await this.#primaryTargetClient.send( 'Page.getNavigationHistory' ); const entry = history.entries[history.currentIndex + delta]; if (!entry) { return null; } const result = await Promise.all([ this.waitForNavigation(options), this.#primaryTargetClient.send('Page.navigateToHistoryEntry', { entryId: entry.id, }), ]); return result[0]; } override async bringToFront(): Promise<void> { await this.#primaryTargetClient.send('Page.bringToFront'); } override async setJavaScriptEnabled(enabled: boolean): Promise<void> { return await this.#emulationManager.setJavaScriptEnabled(enabled); } override async setBypassCSP(enabled: boolean): Promise<void> { await this.#primaryTargetClient.send('Page.setBypassCSP', {enabled}); } override async emulateMediaType(type?: string): Promise<void> { return await this.#emulationManager.emulateMediaType(type); } override async emulateCPUThrottling(factor: number | null): Promise<void> { return await this.#emulationManager.emulateCPUThrottling(factor); } override async emulateMediaFeatures( features?: MediaFeature[] ): Promise<void> { return await this.#emulationManager.emulateMediaFeatures(features); } override async emulateTimezone(timezoneId?: string): Promise<void> { return await this.#emulationManager.emulateTimezone(timezoneId); } override async emulateIdleState(overrides?: { isUserActive: boolean; isScreenUnlocked: boolean; }): Promise<void> { return await this.#emulationManager.emulateIdleState(overrides); } override async emulateVisionDeficiency( type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'] ): Promise<void> { return await this.#emulationManager.emulateVisionDeficiency(type); } override async setViewport(viewport: Viewport | null): Promise<void> { const needsReload = await this.#emulationManager.emulateViewport(viewport); this.#viewport = viewport; if (needsReload) { await this.reload(); } } override viewport(): Viewport | null { return this.#viewport; } override async evaluateOnNewDocument< Params extends unknown[], Func extends (...args: Params) => unknown = (...args: Params) => unknown, >( pageFunction: Func | string, ...args: Params ): Promise<NewDocumentScriptEvaluation> { const source = evaluationString(pageFunction, ...args); return await this.#frameManager.evaluateOnNewDocument(source); } override async removeScriptToEvaluateOnNewDocument( identifier: string ): Promise<void> { return await this.#frameManager.removeScriptToEvaluateOnNewDocument( identifier ); } override async setCacheEnabled(enabled = true): Promise<void> { await this.#frameManager.networkManager.setCacheEnabled(enabled); } override async _screenshot( options: Readonly<ScreenshotOptions> ): Promise<string> { const { fromSurface, omitBackground, optimizeForSpeed, quality, clip: userClip, type, captureBeyondViewport, } = options; const isFirefox = this.target()._targetManager() instanceof FirefoxTargetManager; await using stack = new AsyncDisposableStack(); // Firefox omits background by default; it's not configurable. if (!isFirefox && omitBackground && (type === 'png' || type === 'webp')) { await this.#emulationManager.setTransparentBackgroundColor(); stack.defer(async () => { await this.#emulationManager .resetDefaultBackgroundColor() .catch(debugError); }); } let clip = userClip; if (clip && !captureBeyondViewport) { const viewport = await this.mainFrame() .isolatedRealm() .evaluate(() => { const { height, pageLeft: x, pageTop: y, width, } = window.visualViewport!; return {x, y, height, width}; }); clip = getIntersectionRect(clip, viewport); } // We need to do these spreads because Firefox doesn't allow unknown options. const {data} = await this.#primaryTargetClient.send( 'Page.captureScreenshot', { format: type, ...(optimizeForSpeed ? {optimizeForSpeed} : {}), ...(quality !== undefined ? {quality: Math.round(quality)} : {}), ...(clip ? {clip: {...clip, scale: clip.scale ?? 1}} : {}), ...(!fromSurface ? {fromSurface} : {}), captureBeyondViewport, } ); return data; } override async createPDFStream( options: PDFOptions = {} ): Promise<ReadableStream<Uint8Array>> { const {timeout: ms = this._timeoutSettings.timeout()} = options; const { landscape, displayHeaderFooter, headerTemplate, footerTemplate, printBackground, scale, width: paperWidth, height: paperHeight, margin, pageRanges, preferCSSPageSize, omitBackground, tagged: generateTaggedPDF, outline: generateDocumentOutline, waitForFonts, } = parsePDFOptions(options); if (omitBackground) { await this.#emulationManager.setTransparentBackgroundColor(); } if (waitForFonts) { await firstValueFrom( from( this.mainFrame() .isolatedRealm() .evaluate(() => { return document.fonts.ready; }) ).pipe(raceWith(timeout(ms))) ); } const printCommandPromise = this.#primaryTargetClient.send( 'Page.printToPDF', { transferMode: 'ReturnAsStream', landscape, displayHeaderFooter, headerTemplate, footerTemplate, printBackground, scale, paperWidth, paperHeight, marginTop: margin.top, marginBottom: margin.bottom, marginLeft: margin.left, marginRight: margin.right, pageRanges, preferCSSPageSize, generateTaggedPDF, generateDocumentOutline, } ); const result = await firstValueFrom( from(printCommandPromise).pipe(raceWith(timeout(ms))) ); if (omitBackground) { await this.#emulationManager.resetDefaultBackgroundColor(); } assert(result.stream, '`stream` is missing from `Page.printToPDF'); return await getReadableFromProtocolStream( this.#primaryTargetClient, result.stream ); } override async pdf(options: PDFOptions = {}): Promise<Uint8Array> { const {path = undefined} = options; const readable = await this.createPDFStream(options); const typedArray = await getReadableAsTypedArray(readable, path); assert(typedArray, 'Could not create typed array'); return typedArray; } override async close( options: {runBeforeUnload?: boolean} = {runBeforeUnload: undefined} ): Promise<void> { using _guard = await this.browserContext().waitForScreenshotOperations(); const connection = this.#primaryTargetClient.connection(); assert( connection, 'Protocol error: Connection closed. Most likely the page has been closed.' ); const runBeforeUnload = !!options.runBeforeUnload; if (runBeforeUnload) { await this.#primaryTargetClient.send('Page.close'); } else { await connection.send('Target.closeTarget', { targetId: this.#primaryTarget._targetId, }); await this.#tabTarget._isClosedDeferred.valueOrThrow(); } } override isClosed(): boolean { return this.#closed; } override get mouse(): CdpMouse { return this.#mouse; } /** * This method is typically coupled with an action that triggers a device * request from an api such as WebBluetooth. * * :::caution * * This must be called before the device request is made. It will not return a * currently active device prompt. * * ::: * * @example * * ```ts * const [devicePrompt] = Promise.all([ * page.waitForDevicePrompt(), * page.click('#connect-bluetooth'), * ]); * await devicePrompt.select( * await devicePrompt.waitForDevice(({name}) => name.includes('My Device')) * ); * ``` */ override async waitForDevicePrompt( options: WaitTimeoutOptions = {} ): Promise<DeviceRequestPrompt> { return await this.mainFrame().waitForDevicePrompt(options); } } const supportedMetrics = new Set<string>([ 'Timestamp', 'Documents', 'Frames', 'JSEventListeners', 'Nodes', 'LayoutCount', 'RecalcStyleCount', 'LayoutDuration', 'RecalcStyleDuration', 'ScriptDuration', 'TaskDuration', 'JSHeapUsedSize', 'JSHeapTotalSize', ]); /** @see https://w3c.github.io/webdriver-bidi/#rectangle-intersection */ function getIntersectionRect( clip: Readonly<ScreenshotClip>, viewport: Readonly<Protocol.DOM.Rect> ): ScreenshotClip { // Note these will already be normalized. const x = Math.max(clip.x, viewport.x); const y = Math.max(clip.y, viewport.y); return { x, y, width: Math.max( Math.min(clip.x + clip.width, viewport.x + viewport.width) - x, 0 ), height: Math.max( Math.min(clip.y + clip.height, viewport.y + viewport.height) - y, 0 ), }; }