/** * @license * Copyright 2017 Google Inc. * SPDX-License-Identifier: Apache-2.0 */ import type {Protocol} from 'devtools-protocol'; import type {CDPSession} from '../api/CDPSession.js'; import type {WaitForOptions} from '../api/Frame.js'; import {Frame, FrameEvent, throwIfDetached} from '../api/Frame.js'; import type {HTTPResponse} from '../api/HTTPResponse.js'; import type {WaitTimeoutOptions} from '../api/Page.js'; import {UnsupportedOperation} from '../common/Errors.js'; import {debugError} from '../common/util.js'; import {Deferred} from '../util/Deferred.js'; import {disposeSymbol} from '../util/disposable.js'; import {isErrorLike} from '../util/ErrorLike.js'; import {Accessibility} from './Accessibility.js'; import type {Binding} from './Binding.js'; import type {CdpPreloadScript} from './CdpPreloadScript.js'; import type { DeviceRequestPrompt, DeviceRequestPromptManager, } from './DeviceRequestPrompt.js'; import type {FrameManager} from './FrameManager.js'; import {FrameManagerEvent} from './FrameManagerEvents.js'; import type {IsolatedWorldChart} from './IsolatedWorld.js'; import {IsolatedWorld} from './IsolatedWorld.js'; import {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js'; import { LifecycleWatcher, type PuppeteerLifeCycleEvent, } from './LifecycleWatcher.js'; import type {CdpPage} from './Page.js'; import {CDP_BINDING_PREFIX} from './utils.js'; /** * @internal */ export class CdpFrame extends Frame { #url = ''; #detached = false; #client: CDPSession; _frameManager: FrameManager; _loaderId = ''; _lifecycleEvents = new Set<string>(); override _id: string; override _parentId?: string; override accessibility: Accessibility; worlds: IsolatedWorldChart; constructor( frameManager: FrameManager, frameId: string, parentFrameId: string | undefined, client: CDPSession ) { super(); this._frameManager = frameManager; this.#url = ''; this._id = frameId; this._parentId = parentFrameId; this.#detached = false; this.#client = client; this._loaderId = ''; this.worlds = { [MAIN_WORLD]: new IsolatedWorld(this, this._frameManager.timeoutSettings), [PUPPETEER_WORLD]: new IsolatedWorld( this, this._frameManager.timeoutSettings ), }; this.accessibility = new Accessibility(this.worlds[MAIN_WORLD]); this.on(FrameEvent.FrameSwappedByActivation, () => { // Emulate loading process for swapped frames. this._onLoadingStarted(); this._onLoadingStopped(); }); this.worlds[MAIN_WORLD].emitter.on( 'consoleapicalled', this.#onMainWorldConsoleApiCalled.bind(this) ); this.worlds[MAIN_WORLD].emitter.on( 'bindingcalled', this.#onMainWorldBindingCalled.bind(this) ); } #onMainWorldConsoleApiCalled( event: Protocol.Runtime.ConsoleAPICalledEvent ): void { this._frameManager.emit(FrameManagerEvent.ConsoleApiCalled, [ this.worlds[MAIN_WORLD], event, ]); } #onMainWorldBindingCalled(event: Protocol.Runtime.BindingCalledEvent) { this._frameManager.emit(FrameManagerEvent.BindingCalled, [ this.worlds[MAIN_WORLD], event, ]); } /** * This is used internally in DevTools. * * @internal */ _client(): CDPSession { return this.#client; } /** * Updates the frame ID with the new ID. This happens when the main frame is * replaced by a different frame. */ updateId(id: string): void { this._id = id; } updateClient(client: CDPSession): void { this.#client = client; } override page(): CdpPage { return this._frameManager.page(); } @throwIfDetached override async goto( url: string, options: { referer?: string; referrerPolicy?: string; timeout?: number; waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; } = {} ): Promise<HTTPResponse | null> { const { referer = this._frameManager.networkManager.extraHTTPHeaders()['referer'], referrerPolicy = this._frameManager.networkManager.extraHTTPHeaders()[ 'referer-policy' ], waitUntil = ['load'], timeout = this._frameManager.timeoutSettings.navigationTimeout(), } = options; let ensureNewDocumentNavigation = false; const watcher = new LifecycleWatcher( this._frameManager.networkManager, this, waitUntil, timeout ); let error = await Deferred.race([ navigate( this.#client, url, referer, referrerPolicy as Protocol.Page.ReferrerPolicy, this._id ), watcher.terminationPromise(), ]); if (!error) { error = await Deferred.race([ watcher.terminationPromise(), ensureNewDocumentNavigation ? watcher.newDocumentNavigationPromise() : watcher.sameDocumentNavigationPromise(), ]); } try { if (error) { throw error; } return await watcher.navigationResponse(); } finally { watcher.dispose(); } async function navigate( client: CDPSession, url: string, referrer: string | undefined, referrerPolicy: Protocol.Page.ReferrerPolicy | undefined, frameId: string ): Promise<Error | null> { try { const response = await client.send('Page.navigate', { url, referrer, frameId, referrerPolicy, }); ensureNewDocumentNavigation = !!response.loaderId; if (response.errorText === 'net::ERR_HTTP_RESPONSE_CODE_FAILURE') { return null; } return response.errorText ? new Error(`${response.errorText} at ${url}`) : null; } catch (error) { if (isErrorLike(error)) { return error; } throw error; } } } @throwIfDetached override async waitForNavigation( options: WaitForOptions = {} ): Promise<HTTPResponse | null> { const { waitUntil = ['load'], timeout = this._frameManager.timeoutSettings.navigationTimeout(), signal, } = options; const watcher = new LifecycleWatcher( this._frameManager.networkManager, this, waitUntil, timeout, signal ); const error = await Deferred.race([ watcher.terminationPromise(), ...(options.ignoreSameDocumentNavigation ? [] : [watcher.sameDocumentNavigationPromise()]), watcher.newDocumentNavigationPromise(), ]); try { if (error) { throw error; } const result = await Deferred.race< Error | HTTPResponse | null | undefined >([watcher.terminationPromise(), watcher.navigationResponse()]); if (result instanceof Error) { throw error; } return result || null; } finally { watcher.dispose(); } } override get client(): CDPSession { return this.#client; } override mainRealm(): IsolatedWorld { return this.worlds[MAIN_WORLD]; } override isolatedRealm(): IsolatedWorld { return this.worlds[PUPPETEER_WORLD]; } @throwIfDetached override async setContent( html: string, options: { timeout?: number; waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; } = {} ): Promise<void> { const { waitUntil = ['load'], timeout = this._frameManager.timeoutSettings.navigationTimeout(), } = options; // We rely upon the fact that document.open() will reset frame lifecycle with "init" // lifecycle event. @see https://crrev.com/608658 await this.setFrameContent(html); const watcher = new LifecycleWatcher( this._frameManager.networkManager, this, waitUntil, timeout ); const error = await Deferred.race<void | Error | undefined>([ watcher.terminationPromise(), watcher.lifecyclePromise(), ]); watcher.dispose(); if (error) { throw error; } } override url(): string { return this.#url; } override parentFrame(): CdpFrame | null { return this._frameManager._frameTree.parentFrame(this._id) || null; } override childFrames(): CdpFrame[] { return this._frameManager._frameTree.childFrames(this._id); } #deviceRequestPromptManager(): DeviceRequestPromptManager { return this._frameManager._deviceRequestPromptManager(this.#client); } @throwIfDetached async addPreloadScript(preloadScript: CdpPreloadScript): Promise<void> { const parentFrame = this.parentFrame(); if (parentFrame && this.#client === parentFrame.client) { return; } if (preloadScript.getIdForFrame(this)) { return; } const {identifier} = await this.#client.send( 'Page.addScriptToEvaluateOnNewDocument', { source: preloadScript.source, } ); preloadScript.setIdForFrame(this, identifier); } @throwIfDetached async addExposedFunctionBinding(binding: Binding): Promise<void> { // If a frame has not started loading, it might never start. Rely on // addScriptToEvaluateOnNewDocument in that case. if (this !== this._frameManager.mainFrame() && !this._hasStartedLoading) { return; } await Promise.all([ this.#client.send('Runtime.addBinding', { name: CDP_BINDING_PREFIX + binding.name, }), this.evaluate(binding.initSource).catch(debugError), ]); } @throwIfDetached async removeExposedFunctionBinding(binding: Binding): Promise<void> { // If a frame has not started loading, it might never start. Rely on // addScriptToEvaluateOnNewDocument in that case. if (this !== this._frameManager.mainFrame() && !this._hasStartedLoading) { return; } await Promise.all([ this.#client.send('Runtime.removeBinding', { name: CDP_BINDING_PREFIX + binding.name, }), this.evaluate(name => { // Removes the dangling Puppeteer binding wrapper. // @ts-expect-error: In a different context. globalThis[name] = undefined; }, binding.name).catch(debugError), ]); } @throwIfDetached override async waitForDevicePrompt( options: WaitTimeoutOptions = {} ): Promise<DeviceRequestPrompt> { return await this.#deviceRequestPromptManager().waitForDevicePrompt( options ); } _navigated(framePayload: Protocol.Page.Frame): void { this._name = framePayload.name; this.#url = `${framePayload.url}${framePayload.urlFragment || ''}`; } _navigatedWithinDocument(url: string): void { this.#url = url; } _onLifecycleEvent(loaderId: string, name: string): void { if (name === 'init') { this._loaderId = loaderId; this._lifecycleEvents.clear(); } this._lifecycleEvents.add(name); } _onLoadingStopped(): void { this._lifecycleEvents.add('DOMContentLoaded'); this._lifecycleEvents.add('load'); } _onLoadingStarted(): void { this._hasStartedLoading = true; } override get detached(): boolean { return this.#detached; } [disposeSymbol](): void { if (this.#detached) { return; } this.#detached = true; this.worlds[MAIN_WORLD][disposeSymbol](); this.worlds[PUPPETEER_WORLD][disposeSymbol](); } exposeFunction(): never { throw new UnsupportedOperation(); } }