/** * Copyright 2017 Google Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import {Protocol} from 'devtools-protocol'; import type {Readable} from 'stream'; import type {Browser} from '../api/Browser.js'; import type {BrowserContext} from '../api/BrowserContext.js'; import { GeolocationOptions, MediaFeature, Metrics, Page, PageEmittedEvents, ScreenshotClip, ScreenshotOptions, WaitForOptions, WaitTimeoutOptions, } from '../api/Page.js'; import {assert} from '../util/assert.js'; import { createDeferredPromise, DeferredPromise, } from '../util/DeferredPromise.js'; import {isErrorLike} from '../util/ErrorLike.js'; import {Accessibility} from './Accessibility.js'; import { CDPSession, CDPSessionEmittedEvents, isTargetClosedError, } from './Connection.js'; import {ConsoleMessage, ConsoleMessageType} from './ConsoleMessage.js'; import {Coverage} from './Coverage.js'; import {Dialog} from './Dialog.js'; import {ElementHandle} from './ElementHandle.js'; import {EmulationManager} from './EmulationManager.js'; import {FileChooser} from './FileChooser.js'; import { Frame, FrameAddScriptTagOptions, FrameAddStyleTagOptions, FrameWaitForFunctionOptions, } from './Frame.js'; import {FrameManager, FrameManagerEmittedEvents} from './FrameManager.js'; import {HTTPRequest} from './HTTPRequest.js'; import {HTTPResponse} from './HTTPResponse.js'; import {Keyboard, Mouse, MouseButton, Touchscreen} from './Input.js'; import {WaitForSelectorOptions} from './IsolatedWorld.js'; import {MAIN_WORLD} from './IsolatedWorlds.js'; import {JSHandle} from './JSHandle.js'; import { Credentials, NetworkConditions, NetworkManagerEmittedEvents, } from './NetworkManager.js'; import {LowerCasePaperFormat, PDFOptions, _paperFormats} from './PDFOptions.js'; import {Viewport} from './PuppeteerViewport.js'; import {Target} from './Target.js'; import {TargetManagerEmittedEvents} from './TargetManager.js'; import {TaskQueue} from './TaskQueue.js'; import {TimeoutSettings} from './TimeoutSettings.js'; import {Tracing} from './Tracing.js'; import {EvaluateFunc, HandleFor, NodeFor} from './types.js'; import { createJSHandle, debugError, evaluationString, getExceptionMessage, getReadableAsBuffer, getReadableFromProtocolStream, importFS, isNumber, isString, pageBindingDeliverErrorString, pageBindingDeliverErrorValueString, pageBindingDeliverResultString, pageBindingInitString, releaseObject, valueFromRemoteObject, waitForEvent, waitWithTimeout, } from './util.js'; import {WebWorker} from './WebWorker.js'; /** * @internal */ export class CDPPage extends Page { /** * @internal */ static async _create( client: CDPSession, target: Target, ignoreHTTPSErrors: boolean, defaultViewport: Viewport | null, screenshotTaskQueue: TaskQueue ): Promise { const page = new CDPPage( client, target, ignoreHTTPSErrors, screenshotTaskQueue ); 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; #client: CDPSession; #target: Target; #keyboard: Keyboard; #mouse: Mouse; #timeoutSettings = new TimeoutSettings(); #touchscreen: Touchscreen; #accessibility: Accessibility; #frameManager: FrameManager; #emulationManager: EmulationManager; #tracing: Tracing; #pageBindings = new Map(); #coverage: Coverage; #javascriptEnabled = true; #viewport: Viewport | null; #screenshotTaskQueue: TaskQueue; #workers = new Map(); #fileChooserPromises = new Set>(); #disconnectPromise?: Promise; #userDragInterceptionEnabled = false; /** * @internal */ constructor( client: CDPSession, target: Target, ignoreHTTPSErrors: boolean, screenshotTaskQueue: TaskQueue ) { super(); this.#client = client; this.#target = target; this.#keyboard = new Keyboard(client); this.#mouse = new Mouse(client, this.#keyboard); this.#touchscreen = new Touchscreen(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.#screenshotTaskQueue = screenshotTaskQueue; this.#viewport = null; this.#target ._targetManager() .addTargetInterceptor(this.#client, this.#onAttachedToTarget); this.#target ._targetManager() .on(TargetManagerEmittedEvents.TargetGone, this.#onDetachedFromTarget); this.#frameManager.on(FrameManagerEmittedEvents.FrameAttached, event => { return this.emit(PageEmittedEvents.FrameAttached, event); }); this.#frameManager.on(FrameManagerEmittedEvents.FrameDetached, event => { return this.emit(PageEmittedEvents.FrameDetached, event); }); this.#frameManager.on(FrameManagerEmittedEvents.FrameNavigated, event => { return this.emit(PageEmittedEvents.FrameNavigated, event); }); const networkManager = this.#frameManager.networkManager; networkManager.on(NetworkManagerEmittedEvents.Request, event => { return this.emit(PageEmittedEvents.Request, event); }); networkManager.on( NetworkManagerEmittedEvents.RequestServedFromCache, event => { return this.emit(PageEmittedEvents.RequestServedFromCache, event); } ); networkManager.on(NetworkManagerEmittedEvents.Response, event => { return this.emit(PageEmittedEvents.Response, event); }); networkManager.on(NetworkManagerEmittedEvents.RequestFailed, event => { return this.emit(PageEmittedEvents.RequestFailed, event); }); networkManager.on(NetworkManagerEmittedEvents.RequestFinished, event => { return this.emit(PageEmittedEvents.RequestFinished, event); }); client.on('Page.domContentEventFired', () => { return this.emit(PageEmittedEvents.DOMContentLoaded); }); client.on('Page.loadEventFired', () => { return this.emit(PageEmittedEvents.Load); }); client.on('Runtime.consoleAPICalled', event => { return this.#onConsoleAPI(event); }); client.on('Runtime.bindingCalled', event => { return this.#onBindingCalled(event); }); client.on('Page.javascriptDialogOpening', event => { return this.#onDialog(event); }); client.on('Runtime.exceptionThrown', exception => { return this.#handleException(exception.exceptionDetails); }); client.on('Inspector.targetCrashed', () => { return this.#onTargetCrashed(); }); client.on('Performance.metrics', event => { return this.#emitMetrics(event); }); client.on('Log.entryAdded', event => { return this.#onLogEntryAdded(event); }); client.on('Page.fileChooserOpened', event => { return this.#onFileChooser(event); }); this.#target._isClosedPromise.then(() => { this.#target ._targetManager() .removeTargetInterceptor(this.#client, this.#onAttachedToTarget); this.#target ._targetManager() .off(TargetManagerEmittedEvents.TargetGone, this.#onDetachedFromTarget); this.emit(PageEmittedEvents.Close); this.#closed = true; }); } #onDetachedFromTarget = (target: Target) => { const sessionId = target._session()?.id(); this.#frameManager.onDetachedFromTarget(target); const worker = this.#workers.get(sessionId!); if (!worker) { return; } this.#workers.delete(sessionId!); this.emit(PageEmittedEvents.WorkerDestroyed, worker); }; #onAttachedToTarget = async (createdTarget: Target) => { this.#frameManager.onAttachedToTarget(createdTarget); if (createdTarget._getTargetInfo().type === 'worker') { const session = createdTarget._session(); assert(session); const worker = new WebWorker( session, createdTarget.url(), this.#addConsoleMessage.bind(this), this.#handleException.bind(this) ); this.#workers.set(session.id(), worker); this.emit(PageEmittedEvents.WorkerCreated, worker); } if (createdTarget._session()) { this.#target ._targetManager() .addTargetInterceptor( createdTarget._session()!, this.#onAttachedToTarget ); } }; async #initialize(): Promise { try { await Promise.all([ this.#frameManager.initialize(), this.#client.send('Performance.enable'), this.#client.send('Log.enable'), ]); } catch (err) { if (isErrorLike(err) && isTargetClosedError(err)) { debugError(err); } else { throw err; } } } async #onFileChooser( event: Protocol.Page.FileChooserOpenedEvent ): Promise { if (!this.#fileChooserPromises.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. const handle = (await frame.worlds[MAIN_WORLD].adoptBackendNode( event.backendNodeId )) as ElementHandle; const fileChooser = new FileChooser(handle, event); for (const promise of this.#fileChooserPromises) { promise.resolve(fileChooser); } this.#fileChooserPromises.clear(); } /** * @internal */ _client(): CDPSession { return this.#client; } override isDragInterceptionEnabled(): boolean { return this.#userDragInterceptionEnabled; } override isJavaScriptEnabled(): boolean { return this.#javascriptEnabled; } override waitForFileChooser( options: WaitTimeoutOptions = {} ): Promise { const needsEnable = this.#fileChooserPromises.size === 0; const {timeout = this.#timeoutSettings.timeout()} = options; const promise = createDeferredPromise({ message: `Waiting for \`FileChooser\` failed: ${timeout}ms exceeded`, timeout, }); this.#fileChooserPromises.add(promise); let enablePromise: Promise | undefined; if (needsEnable) { enablePromise = this.#client.send('Page.setInterceptFileChooserDialog', { enabled: true, }); } return Promise.all([promise, enablePromise]) .then(([result]) => { return result; }) .catch(error => { this.#fileChooserPromises.delete(promise); throw error; }); } override async setGeolocation(options: GeolocationOptions): Promise { const {longitude, latitude, accuracy = 0} = options; if (longitude < -180 || longitude > 180) { throw new Error( `Invalid longitude "${longitude}": precondition -180 <= LONGITUDE <= 180 failed.` ); } if (latitude < -90 || latitude > 90) { throw new Error( `Invalid latitude "${latitude}": precondition -90 <= LATITUDE <= 90 failed.` ); } if (accuracy < 0) { throw new Error( `Invalid accuracy "${accuracy}": precondition 0 <= ACCURACY failed.` ); } await this.#client.send('Emulation.setGeolocationOverride', { longitude, latitude, accuracy, }); } override target(): Target { return this.#target; } override browser(): Browser { return this.#target.browser(); } override browserContext(): BrowserContext { return this.#target.browserContext(); } #onTargetCrashed(): void { this.emit('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 => { return releaseObject(this.#client, arg); }); } if (source !== 'worker') { this.emit( PageEmittedEvents.Console, new ConsoleMessage(level, text, [], [{url, lineNumber}]) ); } } override mainFrame(): Frame { return this.#frameManager.mainFrame(); } override get keyboard(): Keyboard { return this.#keyboard; } override get touchscreen(): Touchscreen { 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(): WebWorker[] { return Array.from(this.#workers.values()); } override async setRequestInterception(value: boolean): Promise { return this.#frameManager.networkManager.setRequestInterception(value); } override async setDragInterception(enabled: boolean): Promise { this.#userDragInterceptionEnabled = enabled; return this.#client.send('Input.setInterceptDrags', {enabled}); } override setOfflineMode(enabled: boolean): Promise { return this.#frameManager.networkManager.setOfflineMode(enabled); } override emulateNetworkConditions( networkConditions: NetworkConditions | null ): Promise { return 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 $( selector: Selector ): Promise> | null> { return this.mainFrame().$(selector); } override async $$( selector: Selector ): Promise>>> { return this.mainFrame().$$(selector); } override async evaluateHandle< Params extends unknown[], Func extends EvaluateFunc = EvaluateFunc >( pageFunction: Func | string, ...args: Params ): Promise>>> { const context = await this.mainFrame().executionContext(); return context.evaluateHandle(pageFunction, ...args); } override async queryObjects( prototypeHandle: JSHandle ): Promise> { const context = await this.mainFrame().executionContext(); assert(!prototypeHandle.disposed, 'Prototype JSHandle is disposed!'); const remoteObject = prototypeHandle.remoteObject(); assert( remoteObject.objectId, 'Prototype JSHandle must not be referencing primitive value' ); const response = await context._client.send('Runtime.queryObjects', { prototypeObjectId: remoteObject.objectId, }); return createJSHandle(context, response.objects) as HandleFor; } override async $eval< Selector extends string, Params extends unknown[], Func extends EvaluateFunc< [ElementHandle>, ...Params] > = EvaluateFunc<[ElementHandle>, ...Params]> >( selector: Selector, pageFunction: Func | string, ...args: Params ): Promise>> { return this.mainFrame().$eval(selector, pageFunction, ...args); } override async $$eval< Selector extends string, Params extends unknown[], Func extends EvaluateFunc< [Array>, ...Params] > = EvaluateFunc<[Array>, ...Params]> >( selector: Selector, pageFunction: Func | string, ...args: Params ): Promise>> { return this.mainFrame().$$eval(selector, pageFunction, ...args); } override async $x(expression: string): Promise>> { return this.mainFrame().$x(expression); } override async cookies( ...urls: string[] ): Promise { const originalCookies = ( await this.#client.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)[attr]; } return cookie; }; return originalCookies.map(filterUnsupportedAttributes); } override async deleteCookie( ...cookies: Protocol.Network.DeleteCookiesRequest[] ): Promise { 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.#client.send('Network.deleteCookies', item); } } override async setCookie( ...cookies: Protocol.Network.CookieParam[] ): Promise { 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.#client.send('Network.setCookies', {cookies: items}); } } override async addScriptTag( options: FrameAddScriptTagOptions ): Promise> { return this.mainFrame().addScriptTag(options); } override async addStyleTag( options: Omit ): Promise>; override async addStyleTag( options: FrameAddStyleTagOptions ): Promise>; override async addStyleTag( options: FrameAddStyleTagOptions ): Promise> { return this.mainFrame().addStyleTag(options); } override async exposeFunction( name: string, pptrFunction: Function | {default: Function} ): Promise { if (this.#pageBindings.has(name)) { throw new Error( `Failed to add page binding with name ${name}: window['${name}'] already exists!` ); } let exposedFunction: Function; switch (typeof pptrFunction) { case 'function': exposedFunction = pptrFunction; break; default: exposedFunction = pptrFunction.default; break; } this.#pageBindings.set(name, exposedFunction); const expression = pageBindingInitString('exposedFun', name); await this.#client.send('Runtime.addBinding', {name: name}); await this.#client.send('Page.addScriptToEvaluateOnNewDocument', { source: expression, }); await Promise.all( this.frames().map(frame => { return frame.evaluate(expression).catch(debugError); }) ); } override async authenticate(credentials: Credentials): Promise { return this.#frameManager.networkManager.authenticate(credentials); } override async setExtraHTTPHeaders( headers: Record ): Promise { return this.#frameManager.networkManager.setExtraHTTPHeaders(headers); } override async setUserAgent( userAgent: string, userAgentMetadata?: Protocol.Emulation.UserAgentMetadata ): Promise { return this.#frameManager.networkManager.setUserAgent( userAgent, userAgentMetadata ); } override async metrics(): Promise { const response = await this.#client.send('Performance.getMetrics'); return this.#buildMetricsObject(response.metrics); } #emitMetrics(event: Protocol.Performance.MetricsEvent): void { this.emit(PageEmittedEvents.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(exceptionDetails: Protocol.Runtime.ExceptionDetails): void { const message = getExceptionMessage(exceptionDetails); const err = new Error(message); err.stack = ''; // Don't report clientside error with a node stack attached this.emit(PageEmittedEvents.PageError, err); } async #onConsoleAPI( event: Protocol.Runtime.ConsoleAPICalledEvent ): Promise { 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.executionContextById( event.executionContextId, this.#client ); const values = event.args.map(arg => { return createJSHandle(context, arg); }); this.#addConsoleMessage(event.type, values, event.stackTrace); } async #onBindingCalled( event: Protocol.Runtime.BindingCalledEvent ): Promise { let payload: {type: string; name: string; seq: number; args: unknown[]}; 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} = payload; if (type !== 'exposedFun' || !this.#pageBindings.has(name)) { return; } let expression = null; try { const pageBinding = this.#pageBindings.get(name); assert(pageBinding); const result = await pageBinding(...args); expression = pageBindingDeliverResultString(name, seq, result); } catch (error) { if (isErrorLike(error)) { expression = pageBindingDeliverErrorString( name, seq, error.message, error.stack ); } else { expression = pageBindingDeliverErrorValueString(name, seq, error); } } this.#client .send('Runtime.evaluate', { expression, contextId: event.executionContextId, }) .catch(debugError); } #addConsoleMessage( eventType: ConsoleMessageType, args: JSHandle[], stackTrace?: Protocol.Runtime.StackTrace ): void { if (!this.listenerCount(PageEmittedEvents.Console)) { args.forEach(arg => { return arg.dispose(); }); return; } const textTokens = []; 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(PageEmittedEvents.Console, message); } #onDialog(event: Protocol.Page.JavascriptDialogOpeningEvent): void { let dialogType = null; const validDialogTypes = new Set([ 'alert', 'confirm', 'prompt', 'beforeunload', ]); if (validDialogTypes.has(event.type)) { dialogType = event.type as Protocol.Page.DialogType; } assert(dialogType, 'Unknown javascript dialog type: ' + event.type); const dialog = new Dialog( this.#client, dialogType, event.message, event.defaultPrompt ); this.emit(PageEmittedEvents.Dialog, dialog); } /** * Resets default white background */ async #resetDefaultBackgroundColor() { await this.#client.send('Emulation.setDefaultBackgroundColorOverride'); } /** * Hides default white background */ async #setTransparentBackgroundColor(): Promise { await this.#client.send('Emulation.setDefaultBackgroundColorOverride', { color: {r: 0, g: 0, b: 0, a: 0}, }); } override url(): string { return this.mainFrame().url(); } override async content(): Promise { return await this.#frameManager.mainFrame().content(); } override async setContent( html: string, options: WaitForOptions = {} ): Promise { await this.#frameManager.mainFrame().setContent(html, options); } override async goto( url: string, options: WaitForOptions & {referer?: string} = {} ): Promise { return await this.#frameManager.mainFrame().goto(url, options); } override async reload( options?: WaitForOptions ): Promise { const result = await Promise.all([ this.waitForNavigation(options), this.#client.send('Page.reload'), ]); return result[0]; } override async waitForNavigation( options: WaitForOptions = {} ): Promise { return await this.#frameManager.mainFrame().waitForNavigation(options); } #sessionClosePromise(): Promise { if (!this.#disconnectPromise) { this.#disconnectPromise = new Promise(fulfill => { return this.#client.once(CDPSessionEmittedEvents.Disconnected, () => { return fulfill(new Error('Target closed')); }); }); } return this.#disconnectPromise; } override async waitForRequest( urlOrPredicate: string | ((req: HTTPRequest) => boolean | Promise), options: {timeout?: number} = {} ): Promise { const {timeout = this.#timeoutSettings.timeout()} = options; return waitForEvent( this.#frameManager.networkManager, NetworkManagerEmittedEvents.Request, async request => { if (isString(urlOrPredicate)) { return urlOrPredicate === request.url(); } if (typeof urlOrPredicate === 'function') { return !!(await urlOrPredicate(request)); } return false; }, timeout, this.#sessionClosePromise() ); } override async waitForResponse( urlOrPredicate: | string | ((res: HTTPResponse) => boolean | Promise), options: {timeout?: number} = {} ): Promise { const {timeout = this.#timeoutSettings.timeout()} = options; return waitForEvent( this.#frameManager.networkManager, NetworkManagerEmittedEvents.Response, async response => { if (isString(urlOrPredicate)) { return urlOrPredicate === response.url(); } if (typeof urlOrPredicate === 'function') { return !!(await urlOrPredicate(response)); } return false; }, timeout, this.#sessionClosePromise() ); } override async waitForNetworkIdle( options: {idleTime?: number; timeout?: number} = {} ): Promise { const {idleTime = 500, timeout = this.#timeoutSettings.timeout()} = options; const networkManager = this.#frameManager.networkManager; let idleResolveCallback: () => void; const idlePromise = new Promise(resolve => { idleResolveCallback = resolve; }); let abortRejectCallback: (error: Error) => void; const abortPromise = new Promise((_, reject) => { abortRejectCallback = reject; }); let idleTimer: NodeJS.Timeout; const onIdle = () => { return idleResolveCallback(); }; const cleanup = () => { idleTimer && clearTimeout(idleTimer); abortRejectCallback(new Error('abort')); }; const evaluate = () => { idleTimer && clearTimeout(idleTimer); if (networkManager.numRequestsInProgress() === 0) { idleTimer = setTimeout(onIdle, idleTime); } }; evaluate(); const eventHandler = () => { evaluate(); return false; }; const listenToEvent = (event: symbol) => { return waitForEvent( networkManager, event, eventHandler, timeout, abortPromise ); }; const eventPromises = [ listenToEvent(NetworkManagerEmittedEvents.Request), listenToEvent(NetworkManagerEmittedEvents.Response), ]; await Promise.race([ idlePromise, ...eventPromises, this.#sessionClosePromise(), ]).then( r => { cleanup(); return r; }, error => { cleanup(); throw error; } ); } override async waitForFrame( urlOrPredicate: string | ((frame: Frame) => boolean | Promise), options: {timeout?: number} = {} ): Promise { const {timeout = this.#timeoutSettings.timeout()} = options; let predicate: (frame: Frame) => Promise; if (isString(urlOrPredicate)) { predicate = (frame: Frame) => { return Promise.resolve(urlOrPredicate === frame.url()); }; } else { predicate = (frame: Frame) => { const value = urlOrPredicate(frame); if (typeof value === 'boolean') { return Promise.resolve(value); } return value; }; } const eventRace: Promise = Promise.race([ waitForEvent( this.#frameManager, FrameManagerEmittedEvents.FrameAttached, predicate, timeout, this.#sessionClosePromise() ), waitForEvent( this.#frameManager, FrameManagerEmittedEvents.FrameNavigated, predicate, timeout, this.#sessionClosePromise() ), ...this.frames().map(async frame => { if (await predicate(frame)) { return frame; } return await eventRace; }), ]); return eventRace; } override async goBack( options: WaitForOptions = {} ): Promise { return this.#go(-1, options); } override async goForward( options: WaitForOptions = {} ): Promise { return this.#go(+1, options); } async #go( delta: number, options: WaitForOptions ): Promise { const history = await this.#client.send('Page.getNavigationHistory'); const entry = history.entries[history.currentIndex + delta]; if (!entry) { return null; } const result = await Promise.all([ this.waitForNavigation(options), this.#client.send('Page.navigateToHistoryEntry', {entryId: entry.id}), ]); return result[0]; } override async bringToFront(): Promise { await this.#client.send('Page.bringToFront'); } override async setJavaScriptEnabled(enabled: boolean): Promise { if (this.#javascriptEnabled === enabled) { return; } this.#javascriptEnabled = enabled; await this.#client.send('Emulation.setScriptExecutionDisabled', { value: !enabled, }); } override async setBypassCSP(enabled: boolean): Promise { await this.#client.send('Page.setBypassCSP', {enabled}); } override async emulateMediaType(type?: string): Promise { assert( type === 'screen' || type === 'print' || (type ?? undefined) === undefined, 'Unsupported media type: ' + type ); await this.#client.send('Emulation.setEmulatedMedia', { media: type || '', }); } override async emulateCPUThrottling(factor: number | null): Promise { assert( factor === null || factor >= 1, 'Throttling rate should be greater or equal to 1' ); await this.#client.send('Emulation.setCPUThrottlingRate', { rate: factor !== null ? factor : 1, }); } override async emulateMediaFeatures( features?: MediaFeature[] ): Promise { if (!features) { await this.#client.send('Emulation.setEmulatedMedia', {}); } if (Array.isArray(features)) { for (const mediaFeature of features) { const name = mediaFeature.name; assert( /^(?:prefers-(?:color-scheme|reduced-motion)|color-gamut)$/.test( name ), 'Unsupported media feature: ' + name ); } await this.#client.send('Emulation.setEmulatedMedia', { features: features, }); } } override async emulateTimezone(timezoneId?: string): Promise { try { await this.#client.send('Emulation.setTimezoneOverride', { timezoneId: timezoneId || '', }); } catch (error) { if (isErrorLike(error) && error.message.includes('Invalid timezone')) { throw new Error(`Invalid timezone ID: ${timezoneId}`); } throw error; } } override async emulateIdleState(overrides?: { isUserActive: boolean; isScreenUnlocked: boolean; }): Promise { if (overrides) { await this.#client.send('Emulation.setIdleOverride', { isUserActive: overrides.isUserActive, isScreenUnlocked: overrides.isScreenUnlocked, }); } else { await this.#client.send('Emulation.clearIdleOverride'); } } override async emulateVisionDeficiency( type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'] ): Promise { const visionDeficiencies = new Set< Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'] >([ 'none', 'achromatopsia', 'blurredVision', 'deuteranopia', 'protanopia', 'tritanopia', ]); try { assert( !type || visionDeficiencies.has(type), `Unsupported vision deficiency: ${type}` ); await this.#client.send('Emulation.setEmulatedVisionDeficiency', { type: type || 'none', }); } catch (error) { throw error; } } override async setViewport(viewport: Viewport): Promise { const needsReload = await this.#emulationManager.emulateViewport(viewport); this.#viewport = viewport; if (needsReload) { await this.reload(); } } override viewport(): Viewport | null { return this.#viewport; } override async evaluate< Params extends unknown[], Func extends EvaluateFunc = EvaluateFunc >( pageFunction: Func | string, ...args: Params ): Promise>> { return this.#frameManager.mainFrame().evaluate(pageFunction, ...args); } override async evaluateOnNewDocument< Params extends unknown[], Func extends (...args: Params) => unknown = (...args: Params) => unknown >(pageFunction: Func | string, ...args: Params): Promise { const source = evaluationString(pageFunction, ...args); await this.#client.send('Page.addScriptToEvaluateOnNewDocument', { source, }); } override async setCacheEnabled(enabled = true): Promise { await this.#frameManager.networkManager.setCacheEnabled(enabled); } override async screenshot( options: ScreenshotOptions = {} ): Promise { let screenshotType = Protocol.Page.CaptureScreenshotRequestFormat.Png; // options.type takes precedence over inferring the type from options.path // because it may be a 0-length file with no extension created beforehand // (i.e. as a temp file). if (options.type) { screenshotType = options.type as Protocol.Page.CaptureScreenshotRequestFormat; } else if (options.path) { const filePath = options.path; const extension = filePath .slice(filePath.lastIndexOf('.') + 1) .toLowerCase(); switch (extension) { case 'png': screenshotType = Protocol.Page.CaptureScreenshotRequestFormat.Png; break; case 'jpeg': case 'jpg': screenshotType = Protocol.Page.CaptureScreenshotRequestFormat.Jpeg; break; case 'webp': screenshotType = Protocol.Page.CaptureScreenshotRequestFormat.Webp; break; default: throw new Error( `Unsupported screenshot type for extension \`.${extension}\`` ); } } if (options.quality) { assert( screenshotType === Protocol.Page.CaptureScreenshotRequestFormat.Jpeg || screenshotType === Protocol.Page.CaptureScreenshotRequestFormat.Webp, 'options.quality is unsupported for the ' + screenshotType + ' screenshots' ); assert( typeof options.quality === 'number', 'Expected options.quality to be a number but found ' + typeof options.quality ); assert( Number.isInteger(options.quality), 'Expected options.quality to be an integer' ); assert( options.quality >= 0 && options.quality <= 100, 'Expected options.quality to be between 0 and 100 (inclusive), got ' + options.quality ); } assert( !options.clip || !options.fullPage, 'options.clip and options.fullPage are exclusive' ); if (options.clip) { assert( typeof options.clip.x === 'number', 'Expected options.clip.x to be a number but found ' + typeof options.clip.x ); assert( typeof options.clip.y === 'number', 'Expected options.clip.y to be a number but found ' + typeof options.clip.y ); assert( typeof options.clip.width === 'number', 'Expected options.clip.width to be a number but found ' + typeof options.clip.width ); assert( typeof options.clip.height === 'number', 'Expected options.clip.height to be a number but found ' + typeof options.clip.height ); assert( options.clip.width !== 0, 'Expected options.clip.width not to be 0.' ); assert( options.clip.height !== 0, 'Expected options.clip.height not to be 0.' ); } return this.#screenshotTaskQueue.postTask(() => { return this.#screenshotTask(screenshotType, options); }); } async #screenshotTask( format: Protocol.Page.CaptureScreenshotRequestFormat, options: ScreenshotOptions = {} ): Promise { await this.#client.send('Target.activateTarget', { targetId: this.#target._targetId, }); let clip = options.clip ? processClip(options.clip) : undefined; let captureBeyondViewport = options.captureBeyondViewport ?? true; const fromSurface = options.fromSurface; if (options.fullPage) { const metrics = await this.#client.send('Page.getLayoutMetrics'); // Fallback to `contentSize` in case of using Firefox. const {width, height} = metrics.cssContentSize || metrics.contentSize; // Overwrite clip for full page. clip = undefined; if (!captureBeyondViewport) { const { isMobile = false, deviceScaleFactor = 1, isLandscape = false, } = this.#viewport || {}; const screenOrientation: Protocol.Emulation.ScreenOrientation = isLandscape ? {angle: 90, type: 'landscapePrimary'} : {angle: 0, type: 'portraitPrimary'}; await this.#client.send('Emulation.setDeviceMetricsOverride', { mobile: isMobile, width, height, deviceScaleFactor, screenOrientation, }); } } else if (!clip) { captureBeyondViewport = false; } const shouldSetDefaultBackground = options.omitBackground && (format === 'png' || format === 'webp'); if (shouldSetDefaultBackground) { await this.#setTransparentBackgroundColor(); } const result = await this.#client.send('Page.captureScreenshot', { format, quality: options.quality, clip: clip && { ...clip, scale: clip.scale ?? 1, }, captureBeyondViewport, fromSurface, }); if (shouldSetDefaultBackground) { await this.#resetDefaultBackgroundColor(); } if (options.fullPage && this.#viewport) { await this.setViewport(this.#viewport); } const buffer = options.encoding === 'base64' ? result.data : Buffer.from(result.data, 'base64'); if (options.path) { try { const fs = (await importFS()).promises; await fs.writeFile(options.path, buffer); } catch (error) { if (error instanceof TypeError) { throw new Error( 'Screenshots can only be written to a file path in a Node-like environment.' ); } throw error; } } return buffer; function processClip(clip: ScreenshotClip): ScreenshotClip { const x = Math.round(clip.x); const y = Math.round(clip.y); const width = Math.round(clip.width + clip.x - x); const height = Math.round(clip.height + clip.y - y); return {x, y, width, height, scale: clip.scale}; } } override async createPDFStream(options: PDFOptions = {}): Promise { const { scale = 1, displayHeaderFooter = false, headerTemplate = '', footerTemplate = '', printBackground = false, landscape = false, pageRanges = '', preferCSSPageSize = false, margin = {}, omitBackground = false, timeout = 30000, } = options; let paperWidth = 8.5; let paperHeight = 11; if (options.format) { const format = _paperFormats[options.format.toLowerCase() as LowerCasePaperFormat]; assert(format, 'Unknown paper format: ' + options.format); paperWidth = format.width; paperHeight = format.height; } else { paperWidth = convertPrintParameterToInches(options.width) || paperWidth; paperHeight = convertPrintParameterToInches(options.height) || paperHeight; } const marginTop = convertPrintParameterToInches(margin.top) || 0; const marginLeft = convertPrintParameterToInches(margin.left) || 0; const marginBottom = convertPrintParameterToInches(margin.bottom) || 0; const marginRight = convertPrintParameterToInches(margin.right) || 0; if (omitBackground) { await this.#setTransparentBackgroundColor(); } const printCommandPromise = this.#client.send('Page.printToPDF', { transferMode: 'ReturnAsStream', landscape, displayHeaderFooter, headerTemplate, footerTemplate, printBackground, scale, paperWidth, paperHeight, marginTop, marginBottom, marginLeft, marginRight, pageRanges, preferCSSPageSize, }); const result = await waitWithTimeout( printCommandPromise, 'Page.printToPDF', timeout ); if (omitBackground) { await this.#resetDefaultBackgroundColor(); } assert(result.stream, '`stream` is missing from `Page.printToPDF'); return getReadableFromProtocolStream(this.#client, result.stream); } override async pdf(options: PDFOptions = {}): Promise { 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 title(): Promise { return this.mainFrame().title(); } override async close( options: {runBeforeUnload?: boolean} = {runBeforeUnload: undefined} ): Promise { const connection = this.#client.connection(); assert( connection, 'Protocol error: Connection closed. Most likely the page has been closed.' ); const runBeforeUnload = !!options.runBeforeUnload; if (runBeforeUnload) { await this.#client.send('Page.close'); } else { await connection.send('Target.closeTarget', { targetId: this.#target._targetId, }); await this.#target._isClosedPromise; } } override isClosed(): boolean { return this.#closed; } override get mouse(): Mouse { return this.#mouse; } override click( selector: string, options: { delay?: number; button?: MouseButton; clickCount?: number; } = {} ): Promise { return this.mainFrame().click(selector, options); } override focus(selector: string): Promise { return this.mainFrame().focus(selector); } override hover(selector: string): Promise { return this.mainFrame().hover(selector); } override select(selector: string, ...values: string[]): Promise { return this.mainFrame().select(selector, ...values); } override tap(selector: string): Promise { return this.mainFrame().tap(selector); } override type( selector: string, text: string, options?: {delay: number} ): Promise { return this.mainFrame().type(selector, text, options); } override waitForTimeout(milliseconds: number): Promise { return this.mainFrame().waitForTimeout(milliseconds); } override async waitForSelector( selector: Selector, options: WaitForSelectorOptions = {} ): Promise> | null> { return await this.mainFrame().waitForSelector(selector, options); } override waitForXPath( xpath: string, options: { visible?: boolean; hidden?: boolean; timeout?: number; } = {} ): Promise | null> { return this.mainFrame().waitForXPath(xpath, options); } override waitForFunction< Params extends unknown[], Func extends EvaluateFunc = EvaluateFunc >( pageFunction: Func | string, options: FrameWaitForFunctionOptions = {}, ...args: Params ): Promise>>> { return this.mainFrame().waitForFunction(pageFunction, options, ...args); } } const supportedMetrics = new Set([ 'Timestamp', 'Documents', 'Frames', 'JSEventListeners', 'Nodes', 'LayoutCount', 'RecalcStyleCount', 'LayoutDuration', 'RecalcStyleDuration', 'ScriptDuration', 'TaskDuration', 'JSHeapUsedSize', 'JSHeapTotalSize', ]); const unitToPixels = { px: 1, in: 96, cm: 37.8, mm: 3.78, }; function convertPrintParameterToInches( parameter?: string | number ): number | undefined { if (typeof parameter === 'undefined') { return undefined; } let pixels; if (isNumber(parameter)) { // Treat numbers as pixel values to be aligned with phantom's paperSize. pixels = parameter; } else if (isString(parameter)) { const text = parameter; let unit = text.substring(text.length - 2).toLowerCase(); let valueText = ''; if (unit in unitToPixels) { valueText = text.substring(0, text.length - 2); } else { // In case of unknown unit try to parse the whole parameter as number of pixels. // This is consistent with phantom's paperSize behavior. unit = 'px'; valueText = text; } const value = Number(valueText); assert(!isNaN(value), 'Failed to parse parameter value: ' + text); pixels = value * unitToPixels[unit as keyof typeof unitToPixels]; } else { throw new Error( 'page.pdf() Cannot handle parameter type: ' + typeof parameter ); } return pixels / 96; }