diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /remote/test/puppeteer/packages/puppeteer-core/src/cdp/Page.ts | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'remote/test/puppeteer/packages/puppeteer-core/src/cdp/Page.ts')
-rw-r--r-- | remote/test/puppeteer/packages/puppeteer-core/src/cdp/Page.ts | 1249 |
1 files changed, 1249 insertions, 0 deletions
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Page.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Page.ts new file mode 100644 index 0000000000..491637f0ea --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Page.ts @@ -0,0 +1,1249 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Readable} from 'stream'; + +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 {HTTPRequest} from '../api/HTTPRequest.js'; +import type {HTTPResponse} from '../api/HTTPResponse.js'; +import type {JSHandle} from '../api/JSHandle.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 {TargetCloseError} from '../common/Errors.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, + getReadableAsBuffer, + 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 {Accessibility} from './Accessibility.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 {createCdpHandle} from './ExecutionContext.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 {MAIN_WORLD} from './IsolatedWorlds.js'; +import {releaseObject} from './JSHandle.js'; +import type {Credentials, 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'; + +/** + * @internal + */ +export class CdpPage extends Page { + static async _create( + client: CDPSession, + target: CdpTarget, + ignoreHTTPSErrors: boolean, + defaultViewport: Viewport | null + ): Promise<CdpPage> { + const page = new CdpPage(client, target, ignoreHTTPSErrors); + 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; + #accessibility: Accessibility; + #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; + + readonly #frameManagerHandlers = [ + [ + FrameManagerEvent.FrameAttached, + (frame: CdpFrame) => { + this.emit(PageEvent.FrameAttached, frame); + }, + ], + [ + FrameManagerEvent.FrameDetached, + (frame: CdpFrame) => { + this.emit(PageEvent.FrameDetached, frame); + }, + ], + [ + FrameManagerEvent.FrameNavigated, + (frame: CdpFrame) => { + this.emit(PageEvent.FrameNavigated, frame); + }, + ], + ] as const; + + readonly #networkManagerHandlers = [ + [ + NetworkManagerEvent.Request, + (request: HTTPRequest) => { + this.emit(PageEvent.Request, request); + }, + ], + [ + NetworkManagerEvent.RequestServedFromCache, + (request: HTTPRequest) => { + this.emit(PageEvent.RequestServedFromCache, request); + }, + ], + [ + NetworkManagerEvent.Response, + (response: HTTPResponse) => { + this.emit(PageEvent.Response, response); + }, + ], + [ + NetworkManagerEvent.RequestFailed, + (request: HTTPRequest) => { + this.emit(PageEvent.RequestFailed, request); + }, + ], + [ + NetworkManagerEvent.RequestFinished, + (request: HTTPRequest) => { + this.emit(PageEvent.RequestFinished, request); + }, + ], + ] as const; + + readonly #sessionHandlers = [ + [ + CDPSessionEvent.Disconnected, + () => { + this.#sessionCloseDeferred.reject( + new TargetCloseError('Target closed') + ); + }, + ], + [ + 'Page.domContentEventFired', + () => { + return this.emit(PageEvent.DOMContentLoaded, undefined); + }, + ], + [ + 'Page.loadEventFired', + () => { + return this.emit(PageEvent.Load, undefined); + }, + ], + ['Runtime.consoleAPICalled', this.#onConsoleAPI.bind(this)], + ['Runtime.bindingCalled', this.#onBindingCalled.bind(this)], + ['Page.javascriptDialogOpening', this.#onDialog.bind(this)], + ['Runtime.exceptionThrown', this.#handleException.bind(this)], + ['Inspector.targetCrashed', this.#onTargetCrashed.bind(this)], + ['Performance.metrics', this.#emitMetrics.bind(this)], + ['Log.entryAdded', this.#onLogEntryAdded.bind(this)], + ['Page.fileChooserOpened', this.#onFileChooser.bind(this)], + ] as const; + + constructor( + client: CDPSession, + target: CdpTarget, + ignoreHTTPSErrors: boolean + ) { + 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.#accessibility = new Accessibility(client); + this.#frameManager = new FrameManager( + client, + this, + ignoreHTTPSErrors, + this._timeoutSettings + ); + this.#emulationManager = new EmulationManager(client); + this.#tracing = new Tracing(client); + this.#coverage = new Coverage(client); + this.#viewport = null; + + for (const [eventName, handler] of this.#frameManagerHandlers) { + this.#frameManager.on(eventName, handler); + } + + for (const [eventName, handler] of this.#networkManagerHandlers) { + // TODO: Remove any. + this.#frameManager.networkManager.on(eventName, handler as any); + } + + 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(); + } + + 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.#accessibility.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() { + this.#primaryTargetClient.on( + CDPSessionEvent.Ready, + this.#onAttachedToTarget + ); + + for (const [eventName, handler] of this.#sessionHandlers) { + // TODO: Remove any. + this.#primaryTargetClient.on(eventName, handler as any); + } + } + + #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(), + 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, + }); + 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(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 get accessibility(): Accessibility { + return this.#accessibility; + } + + 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 createCdpHandle( + this.mainFrame().mainRealm(), + response.objects + ) as HandleFor<Prototype[]>; + } + + override async cookies( + ...urls: string[] + ): Promise<Protocol.Network.Cookie[]> { + const originalCookies = ( + await this.#primaryTargetClient.send('Network.getCookies', { + urls: urls.length ? urls : [this.url()], + }) + ).cookies; + + const unsupportedCookieAttributes = ['priority']; + 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); + } + + override async deleteCookie( + ...cookies: Protocol.Network.DeleteCookiesRequest[] + ): Promise<void> { + const pageURL = this.url(); + for (const cookie of cookies) { + const item = Object.assign({}, cookie); + if (!cookie.url && pageURL.startsWith('http')) { + item.url = pageURL; + } + await this.#primaryTargetClient.send('Network.deleteCookies', item); + } + } + + override async setCookie( + ...cookies: Protocol.Network.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, + }); + } + } + + 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!` + ); + } + + let binding: Binding; + switch (typeof pptrFunction) { + case 'function': + binding = new Binding( + name, + pptrFunction as (...args: unknown[]) => unknown + ); + break; + default: + binding = new Binding( + name, + pptrFunction.default as (...args: unknown[]) => unknown + ); + break; + } + + this.#bindings.set(name, binding); + + const expression = pageBindingInitString('exposedFun', name); + await this.#primaryTargetClient.send('Runtime.addBinding', {name}); + // TODO: investigate this as it appears to only apply to the main frame and + // local subframes instead of the entire frame tree (including future + // frame). + const {identifier} = await this.#primaryTargetClient.send( + 'Page.addScriptToEvaluateOnNewDocument', + { + source: expression, + } + ); + + this.#exposedFunctions.set(name, identifier); + + await Promise.all( + this.frames().map(frame => { + // If a frame has not started loading, it might never start. Rely on + // addScriptToEvaluateOnNewDocument in that case. + if (frame !== this.mainFrame() && !frame._hasStartedLoading) { + return; + } + return frame.evaluate(expression).catch(debugError); + }) + ); + } + + override async removeExposedFunction(name: string): Promise<void> { + const exposedFun = this.#exposedFunctions.get(name); + if (!exposedFun) { + throw new Error( + `Failed to remove page binding with name ${name}: window['${name}'] does not exists!` + ); + } + + await this.#primaryTargetClient.send('Runtime.removeBinding', {name}); + await this.removeScriptToEvaluateOnNewDocument(exposedFun); + + await Promise.all( + this.frames().map(frame => { + // If a frame has not started loading, it might never start. Rely on + // addScriptToEvaluateOnNewDocument in that case. + if (frame !== this.mainFrame() && !frame._hasStartedLoading) { + return; + } + return frame + .evaluate(name => { + // Removes the dangling Puppeteer binding wrapper. + // @ts-expect-error: In a different context. + globalThis[name] = undefined; + }, name) + .catch(debugError); + }) + ); + + this.#exposedFunctions.delete(name); + this.#bindings.delete(name); + } + + override async authenticate(credentials: Credentials): 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) + ); + } + + async #onConsoleAPI( + event: Protocol.Runtime.ConsoleAPICalledEvent + ): Promise<void> { + if (event.executionContextId === 0) { + // DevTools protocol stores the last 1000 console messages. These + // messages are always reported even for removed execution contexts. In + // this case, they are marked with executionContextId = 0 and are + // reported upon enabling Runtime agent. + // + // Ignore these messages since: + // - there's no execution context we can use to operate with message + // arguments + // - these messages are reported before Puppeteer clients can subscribe + // to the 'console' + // page event. + // + // @see https://github.com/puppeteer/puppeteer/issues/3865 + return; + } + const context = this.#frameManager.getExecutionContextById( + event.executionContextId, + this.#primaryTargetClient + ); + if (!context) { + debugError( + new Error( + `ExecutionContext not found for a console message: ${JSON.stringify( + event + )}` + ) + ); + return; + } + const values = event.args.map(arg => { + return createCdpHandle(context._world, arg); + }); + this.#addConsoleMessage(event.type, values, event.stackTrace); + } + + async #onBindingCalled( + 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 = this.#frameManager.executionContextById( + event.executionContextId, + this.#primaryTargetClient + ); + if (!context) { + return; + } + + const binding = this.#bindings.get(name); + await binding?.run(context, seq, args, isTrivial); + } + + #addConsoleMessage( + eventType: ConsoleMessageType, + 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( + 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), + 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): 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); + const {identifier} = await this.#primaryTargetClient.send( + 'Page.addScriptToEvaluateOnNewDocument', + { + source, + } + ); + + return {identifier}; + } + + override async removeScriptToEvaluateOnNewDocument( + identifier: string + ): Promise<void> { + await this.#primaryTargetClient.send( + 'Page.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<Readable> { + const {timeout: ms = this._timeoutSettings.timeout()} = options; + const { + landscape, + displayHeaderFooter, + headerTemplate, + footerTemplate, + printBackground, + scale, + width: paperWidth, + height: paperHeight, + margin, + pageRanges, + preferCSSPageSize, + omitBackground, + tagged: generateTaggedPDF, + } = parsePDFOptions(options); + + if (omitBackground) { + await this.#emulationManager.setTransparentBackgroundColor(); + } + + 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, + } + ); + + 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<Buffer> { + const {path = undefined} = options; + const readable = await this.createPDFStream(options); + const buffer = await getReadableAsBuffer(readable, path); + assert(buffer, 'Could not create buffer'); + return buffer; + } + + override async close( + options: {runBeforeUnload?: boolean} = {runBeforeUnload: undefined} + ): Promise<void> { + 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 + ), + }; +} |