| /** |
| * @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 {ElementHandle} from '../api/ElementHandle.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], frameId); |
| |
| 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 ? referrerPolicyToProtocol(referrerPolicy) : undefined, |
| 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; |
| } |
| |
| override [disposeSymbol](): void { |
| if (this.#detached) { |
| return; |
| } |
| this.#detached = true; |
| this.worlds[MAIN_WORLD][disposeSymbol](); |
| this.worlds[PUPPETEER_WORLD][disposeSymbol](); |
| } |
| |
| exposeFunction(): never { |
| throw new UnsupportedOperation(); |
| } |
| |
| override async frameElement(): Promise<ElementHandle<HTMLIFrameElement> | null> { |
| const parent = this.parentFrame(); |
| if (!parent) { |
| return null; |
| } |
| const {backendNodeId} = await parent.client.send('DOM.getFrameOwner', { |
| frameId: this._id, |
| }); |
| return (await parent |
| .mainRealm() |
| .adoptBackendNode(backendNodeId)) as ElementHandle<HTMLIFrameElement>; |
| } |
| } |
| |
| /** |
| * @internal |
| */ |
| export function referrerPolicyToProtocol( |
| referrerPolicy: string, |
| ): Protocol.Page.ReferrerPolicy { |
| // See |
| // https://chromedevtools.github.io/devtools-protocol/tot/Page/#type-ReferrerPolicy |
| // We need to conver from Web-facing phase to CDP's camelCase. |
| return referrerPolicy.replaceAll(/-./g, match => { |
| return match[1]!.toUpperCase(); |
| }) as Protocol.Page.ReferrerPolicy; |
| } |