/** * @license * Copyright 2023 Google Inc. * SPDX-License-Identifier: Apache-2.0 */ import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; import type {Observable} from '../../third_party/rxjs/rxjs.js'; import { combineLatest, defer, delayWhen, filter, first, firstValueFrom, map, of, raceWith, switchMap, } from '../../third_party/rxjs/rxjs.js'; import type {CDPSession} from '../api/CDPSession.js'; import { Frame, throwIfDetached, type GoToOptions, type WaitForOptions, } from '../api/Frame.js'; import {PageEvent} from '../api/Page.js'; import {Accessibility} from '../cdp/Accessibility.js'; import type {ConsoleMessageType} from '../common/ConsoleMessage.js'; import { ConsoleMessage, type ConsoleMessageLocation, } from '../common/ConsoleMessage.js'; import {TargetCloseError, UnsupportedOperation} from '../common/Errors.js'; import type {TimeoutSettings} from '../common/TimeoutSettings.js'; import type {Awaitable} from '../common/types.js'; import { debugError, fromAbortSignal, fromEmitterEvent, timeout, } from '../common/util.js'; import {isErrorLike} from '../util/ErrorLike.js'; import {BidiCdpSession} from './CDPSession.js'; import type {BrowsingContext} from './core/BrowsingContext.js'; import type {Navigation} from './core/Navigation.js'; import type {Request} from './core/Request.js'; import {BidiDeserializer} from './Deserializer.js'; import {BidiDialog} from './Dialog.js'; import type {BidiElementHandle} from './ElementHandle.js'; import {ExposeableFunction} from './ExposedFunction.js'; import {BidiHTTPRequest, requests} from './HTTPRequest.js'; import type {BidiHTTPResponse} from './HTTPResponse.js'; import {BidiJSHandle} from './JSHandle.js'; import type {BidiPage} from './Page.js'; import type {BidiRealm} from './Realm.js'; import {BidiFrameRealm} from './Realm.js'; import {rewriteNavigationError} from './util.js'; import {BidiWebWorker} from './WebWorker.js'; export class BidiFrame extends Frame { static from( parent: BidiPage | BidiFrame, browsingContext: BrowsingContext ): BidiFrame { const frame = new BidiFrame(parent, browsingContext); frame.#initialize(); return frame; } readonly #parent: BidiPage | BidiFrame; readonly browsingContext: BrowsingContext; readonly #frames = new WeakMap<BrowsingContext, BidiFrame>(); readonly realms: {default: BidiFrameRealm; internal: BidiFrameRealm}; override readonly _id: string; override readonly client: BidiCdpSession; override readonly accessibility: Accessibility; private constructor( parent: BidiPage | BidiFrame, browsingContext: BrowsingContext ) { super(); this.#parent = parent; this.browsingContext = browsingContext; this._id = browsingContext.id; this.client = new BidiCdpSession(this); this.realms = { default: BidiFrameRealm.from(this.browsingContext.defaultRealm, this), internal: BidiFrameRealm.from( this.browsingContext.createWindowRealm( `__puppeteer_internal_${Math.ceil(Math.random() * 10000)}` ), this ), }; this.accessibility = new Accessibility(this.realms.default); } #initialize(): void { for (const browsingContext of this.browsingContext.children) { this.#createFrameTarget(browsingContext); } this.browsingContext.on('browsingcontext', ({browsingContext}) => { this.#createFrameTarget(browsingContext); }); this.browsingContext.on('closed', () => { for (const session of BidiCdpSession.sessions.values()) { if (session.frame === this) { session.onClose(); } } this.page().trustedEmitter.emit(PageEvent.FrameDetached, this); }); this.browsingContext.on('request', ({request}) => { const httpRequest = BidiHTTPRequest.from(request, this); request.once('success', () => { this.page().trustedEmitter.emit(PageEvent.RequestFinished, httpRequest); }); request.once('error', () => { this.page().trustedEmitter.emit(PageEvent.RequestFailed, httpRequest); }); void httpRequest.finalizeInterceptions(); }); this.browsingContext.on('navigation', ({navigation}) => { navigation.once('fragment', () => { this.page().trustedEmitter.emit(PageEvent.FrameNavigated, this); }); }); this.browsingContext.on('load', () => { this.page().trustedEmitter.emit(PageEvent.Load, undefined); }); this.browsingContext.on('DOMContentLoaded', () => { this._hasStartedLoading = true; this.page().trustedEmitter.emit(PageEvent.DOMContentLoaded, undefined); this.page().trustedEmitter.emit(PageEvent.FrameNavigated, this); }); this.browsingContext.on('userprompt', ({userPrompt}) => { this.page().trustedEmitter.emit( PageEvent.Dialog, BidiDialog.from(userPrompt) ); }); this.browsingContext.on('log', ({entry}) => { if (this._id !== entry.source.context) { return; } if (isConsoleLogEntry(entry)) { const args = entry.args.map(arg => { return this.mainRealm().createHandle(arg); }); const text = args .reduce((value, arg) => { const parsedValue = arg instanceof BidiJSHandle && arg.isPrimitiveValue ? BidiDeserializer.deserialize(arg.remoteValue()) : arg.toString(); return `${value} ${parsedValue}`; }, '') .slice(1); this.page().trustedEmitter.emit( PageEvent.Console, new ConsoleMessage( entry.method as ConsoleMessageType, text, args, getStackTraceLocations(entry.stackTrace) ) ); } else if (isJavaScriptLogEntry(entry)) { const error = new Error(entry.text ?? ''); const messageHeight = error.message.split('\n').length; const messageLines = error.stack!.split('\n').splice(0, messageHeight); const stackLines = []; if (entry.stackTrace) { for (const frame of entry.stackTrace.callFrames) { // Note we need to add `1` because the values are 0-indexed. stackLines.push( ` at ${frame.functionName || '<anonymous>'} (${frame.url}:${ frame.lineNumber + 1 }:${frame.columnNumber + 1})` ); if (stackLines.length >= Error.stackTraceLimit) { break; } } } error.stack = [...messageLines, ...stackLines].join('\n'); this.page().trustedEmitter.emit(PageEvent.PageError, error); } else { debugError( `Unhandled LogEntry with type "${entry.type}", text "${entry.text}" and level "${entry.level}"` ); } }); this.browsingContext.on('worker', ({realm}) => { const worker = BidiWebWorker.from(this, realm); realm.on('destroyed', () => { this.page().trustedEmitter.emit(PageEvent.WorkerDestroyed, worker); }); this.page().trustedEmitter.emit(PageEvent.WorkerCreated, worker); }); } #createFrameTarget(browsingContext: BrowsingContext) { const frame = BidiFrame.from(this, browsingContext); this.#frames.set(browsingContext, frame); this.page().trustedEmitter.emit(PageEvent.FrameAttached, frame); browsingContext.on('closed', () => { this.#frames.delete(browsingContext); }); return frame; } get timeoutSettings(): TimeoutSettings { return this.page()._timeoutSettings; } override mainRealm(): BidiFrameRealm { return this.realms.default; } override isolatedRealm(): BidiFrameRealm { return this.realms.internal; } realm(id: string): BidiRealm | undefined { for (const realm of Object.values(this.realms)) { if (realm.realm.id === id) { return realm; } } return; } override page(): BidiPage { let parent = this.#parent; while (parent instanceof BidiFrame) { parent = parent.#parent; } return parent; } override url(): string { return this.browsingContext.url; } override parentFrame(): BidiFrame | null { if (this.#parent instanceof BidiFrame) { return this.#parent; } return null; } override childFrames(): BidiFrame[] { return [...this.browsingContext.children].map(child => { return this.#frames.get(child)!; }); } #detached$() { return defer(() => { if (this.detached) { return of(this as Frame); } return fromEmitterEvent( this.page().trustedEmitter, PageEvent.FrameDetached ).pipe( filter(detachedFrame => { return detachedFrame === this; }) ); }); } @throwIfDetached override async goto( url: string, options: GoToOptions = {} ): Promise<BidiHTTPResponse | null> { const [response] = await Promise.all([ this.waitForNavigation(options), // Some implementations currently only report errors when the // readiness=interactive. // // Related: https://bugzilla.mozilla.org/show_bug.cgi?id=1846601 this.browsingContext .navigate(url, Bidi.BrowsingContext.ReadinessState.Interactive) .catch(error => { if ( isErrorLike(error) && error.message.includes('net::ERR_HTTP_RESPONSE_CODE_FAILURE') ) { return; } throw error; }), ]).catch( rewriteNavigationError( url, options.timeout ?? this.timeoutSettings.navigationTimeout() ) ); return response; } @throwIfDetached override async setContent( html: string, options: WaitForOptions = {} ): Promise<void> { await Promise.all([ this.setFrameContent(html), firstValueFrom( combineLatest([ this.#waitForLoad$(options), this.#waitForNetworkIdle$(options), ]) ), ]); } @throwIfDetached override async waitForNavigation( options: WaitForOptions = {} ): Promise<BidiHTTPResponse | null> { const {timeout: ms = this.timeoutSettings.navigationTimeout(), signal} = options; const frames = this.childFrames().map(frame => { return frame.#detached$(); }); return await firstValueFrom( combineLatest([ fromEmitterEvent(this.browsingContext, 'navigation') .pipe(first()) .pipe( switchMap(({navigation}) => { return this.#waitForLoad$(options).pipe( delayWhen(() => { if (frames.length === 0) { return of(undefined); } return combineLatest(frames); }), raceWith( fromEmitterEvent(navigation, 'fragment'), fromEmitterEvent(navigation, 'failed'), fromEmitterEvent(navigation, 'aborted').pipe( map(({url}) => { throw new Error(`Navigation aborted: ${url}`); }) ) ), switchMap(() => { if (navigation.request) { function requestFinished$( request: Request ): Observable<Navigation> { // Reduces flakiness if the response events arrive after // the load event. // Usually, the response or error is already there at this point. if (request.response || request.error) { return of(navigation); } if (request.redirect) { return requestFinished$(request.redirect); } return fromEmitterEvent(request, 'success') .pipe( raceWith(fromEmitterEvent(request, 'error')), raceWith(fromEmitterEvent(request, 'redirect')) ) .pipe( switchMap(() => { return requestFinished$(request); }) ); } return requestFinished$(navigation.request); } return of(navigation); }) ); }) ), this.#waitForNetworkIdle$(options), ]).pipe( map(([navigation]) => { const request = navigation.request; if (!request) { return null; } const lastRequest = request.lastRedirect ?? request; const httpRequest = requests.get(lastRequest)!; return httpRequest.response(); }), raceWith( timeout(ms), fromAbortSignal(signal), this.#detached$().pipe( map(() => { throw new TargetCloseError('Frame detached.'); }) ) ) ) ); } override waitForDevicePrompt(): never { throw new UnsupportedOperation(); } override get detached(): boolean { return this.browsingContext.closed; } #exposedFunctions = new Map<string, ExposeableFunction<never[], unknown>>(); async exposeFunction<Args extends unknown[], Ret>( name: string, apply: (...args: Args) => Awaitable<Ret> ): Promise<void> { if (this.#exposedFunctions.has(name)) { throw new Error( `Failed to add page binding with name ${name}: globalThis['${name}'] already exists!` ); } const exposeable = await ExposeableFunction.from(this, name, apply); this.#exposedFunctions.set(name, exposeable); } async removeExposedFunction(name: string): Promise<void> { const exposedFunction = this.#exposedFunctions.get(name); if (!exposedFunction) { throw new Error( `Failed to remove page binding with name ${name}: window['${name}'] does not exists!` ); } this.#exposedFunctions.delete(name); await exposedFunction[Symbol.asyncDispose](); } async createCDPSession(): Promise<CDPSession> { if (!this.page().browser().cdpSupported) { throw new UnsupportedOperation(); } const cdpConnection = this.page().browser().cdpConnection!; return await cdpConnection._createSession({targetId: this._id}); } @throwIfDetached #waitForLoad$(options: WaitForOptions = {}): Observable<void> { let {waitUntil = 'load'} = options; const {timeout: ms = this.timeoutSettings.navigationTimeout()} = options; if (!Array.isArray(waitUntil)) { waitUntil = [waitUntil]; } const events = new Set<'load' | 'DOMContentLoaded'>(); for (const lifecycleEvent of waitUntil) { switch (lifecycleEvent) { case 'load': { events.add('load'); break; } case 'domcontentloaded': { events.add('DOMContentLoaded'); break; } } } if (events.size === 0) { return of(undefined); } return combineLatest( [...events].map(event => { return fromEmitterEvent(this.browsingContext, event); }) ).pipe( map(() => {}), first(), raceWith( timeout(ms), this.#detached$().pipe( map(() => { throw new Error('Frame detached.'); }) ) ) ); } @throwIfDetached #waitForNetworkIdle$(options: WaitForOptions = {}): Observable<void> { let {waitUntil = 'load'} = options; if (!Array.isArray(waitUntil)) { waitUntil = [waitUntil]; } let concurrency = Infinity; for (const event of waitUntil) { switch (event) { case 'networkidle0': { concurrency = Math.min(0, concurrency); break; } case 'networkidle2': { concurrency = Math.min(2, concurrency); break; } } } if (concurrency === Infinity) { return of(undefined); } return this.page().waitForNetworkIdle$({ idleTime: 500, timeout: options.timeout ?? this.timeoutSettings.timeout(), concurrency, }); } @throwIfDetached async setFiles(element: BidiElementHandle, files: string[]): Promise<void> { await this.browsingContext.setFiles( // SAFETY: ElementHandles are always remote references. element.remoteValue() as Bidi.Script.SharedReference, files ); } @throwIfDetached async locateNodes( element: BidiElementHandle, locator: Bidi.BrowsingContext.Locator ): Promise<Bidi.Script.NodeRemoteValue[]> { return await this.browsingContext.locateNodes( locator, // SAFETY: ElementHandles are always remote references. [element.remoteValue() as Bidi.Script.SharedReference] ); } } function isConsoleLogEntry( event: Bidi.Log.Entry ): event is Bidi.Log.ConsoleLogEntry { return event.type === 'console'; } function isJavaScriptLogEntry( event: Bidi.Log.Entry ): event is Bidi.Log.JavascriptLogEntry { return event.type === 'javascript'; } function getStackTraceLocations( stackTrace?: Bidi.Script.StackTrace ): ConsoleMessageLocation[] { const stackTraceLocations: ConsoleMessageLocation[] = []; if (stackTrace) { for (const callFrame of stackTrace.callFrames) { stackTraceLocations.push({ url: callFrame.url, lineNumber: callFrame.lineNumber, columnNumber: callFrame.columnNumber, }); } } return stackTraceLocations; }