From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- .../puppeteer-core/src/bidi/BidiOverCdp.ts | 209 +++++ .../packages/puppeteer-core/src/bidi/Browser.ts | 317 +++++++ .../puppeteer-core/src/bidi/BrowserConnector.ts | 123 +++ .../puppeteer-core/src/bidi/BrowserContext.ts | 145 ++++ .../puppeteer-core/src/bidi/BrowsingContext.ts | 187 +++++ .../puppeteer-core/src/bidi/Connection.test.ts | 50 ++ .../packages/puppeteer-core/src/bidi/Connection.ts | 256 ++++++ .../puppeteer-core/src/bidi/Deserializer.ts | 96 +++ .../packages/puppeteer-core/src/bidi/Dialog.ts | 45 + .../puppeteer-core/src/bidi/ElementHandle.ts | 87 ++ .../puppeteer-core/src/bidi/EmulationManager.ts | 35 + .../puppeteer-core/src/bidi/ExposedFunction.ts | 295 +++++++ .../packages/puppeteer-core/src/bidi/Frame.ts | 313 +++++++ .../puppeteer-core/src/bidi/HTTPRequest.ts | 163 ++++ .../puppeteer-core/src/bidi/HTTPResponse.ts | 107 +++ .../packages/puppeteer-core/src/bidi/Input.ts | 732 +++++++++++++++++ .../packages/puppeteer-core/src/bidi/JSHandle.ts | 101 +++ .../puppeteer-core/src/bidi/NetworkManager.ts | 155 ++++ .../packages/puppeteer-core/src/bidi/Page.ts | 913 +++++++++++++++++++++ .../packages/puppeteer-core/src/bidi/Realm.ts | 228 +++++ .../packages/puppeteer-core/src/bidi/Sandbox.ts | 123 +++ .../packages/puppeteer-core/src/bidi/Serializer.ts | 164 ++++ .../packages/puppeteer-core/src/bidi/Target.ts | 151 ++++ .../packages/puppeteer-core/src/bidi/bidi.ts | 22 + .../puppeteer-core/src/bidi/core/Browser.ts | 225 +++++ .../src/bidi/core/BrowsingContext.ts | 475 +++++++++++ .../puppeteer-core/src/bidi/core/Connection.ts | 139 ++++ .../puppeteer-core/src/bidi/core/Navigation.ts | 144 ++++ .../packages/puppeteer-core/src/bidi/core/Realm.ts | 351 ++++++++ .../puppeteer-core/src/bidi/core/Request.ts | 148 ++++ .../puppeteer-core/src/bidi/core/Session.ts | 180 ++++ .../puppeteer-core/src/bidi/core/UserContext.ts | 178 ++++ .../puppeteer-core/src/bidi/core/UserPrompt.ts | 137 ++++ .../packages/puppeteer-core/src/bidi/core/core.ts | 15 + .../packages/puppeteer-core/src/bidi/lifecycle.ts | 119 +++ .../packages/puppeteer-core/src/bidi/util.ts | 81 ++ 36 files changed, 7209 insertions(+) create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/BidiOverCdp.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/Browser.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserConnector.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserContext.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowsingContext.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.test.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/Deserializer.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/Dialog.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/ElementHandle.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/EmulationManager.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/ExposedFunction.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPRequest.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPResponse.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/Input.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/JSHandle.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/NetworkManager.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/Page.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/Realm.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/Sandbox.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/Serializer.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/Target.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/bidi.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Browser.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/BrowsingContext.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Connection.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Navigation.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Realm.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Request.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Session.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserContext.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserPrompt.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/core.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/lifecycle.ts create mode 100644 remote/test/puppeteer/packages/puppeteer-core/src/bidi/util.ts (limited to 'remote/test/puppeteer/packages/puppeteer-core/src/bidi') diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BidiOverCdp.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BidiOverCdp.ts new file mode 100644 index 0000000000..ace35a52b0 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BidiOverCdp.ts @@ -0,0 +1,209 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as BidiMapper from 'chromium-bidi/lib/cjs/bidiMapper/BidiMapper.js'; +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; +import type {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js'; + +import type {CDPEvents, CDPSession} from '../api/CDPSession.js'; +import type {Connection as CdpConnection} from '../cdp/Connection.js'; +import {debug} from '../common/Debug.js'; +import {TargetCloseError} from '../common/Errors.js'; +import type {Handler} from '../common/EventEmitter.js'; + +import {BidiConnection} from './Connection.js'; + +const bidiServerLogger = (prefix: string, ...args: unknown[]): void => { + debug(`bidi:${prefix}`)(args); +}; + +/** + * @internal + */ +export async function connectBidiOverCdp( + cdp: CdpConnection, + // TODO: replace with `BidiMapper.MapperOptions`, once it's exported in + // https://github.com/puppeteer/puppeteer/pull/11415. + options: {acceptInsecureCerts: boolean} +): Promise { + const transportBiDi = new NoOpTransport(); + const cdpConnectionAdapter = new CdpConnectionAdapter(cdp); + const pptrTransport = { + send(message: string): void { + // Forwards a BiDi command sent by Puppeteer to the input of the BidiServer. + transportBiDi.emitMessage(JSON.parse(message)); + }, + close(): void { + bidiServer.close(); + cdpConnectionAdapter.close(); + cdp.dispose(); + }, + onmessage(_message: string): void { + // The method is overridden by the Connection. + }, + }; + transportBiDi.on('bidiResponse', (message: object) => { + // Forwards a BiDi event sent by BidiServer to Puppeteer. + pptrTransport.onmessage(JSON.stringify(message)); + }); + const pptrBiDiConnection = new BidiConnection(cdp.url(), pptrTransport); + const bidiServer = await BidiMapper.BidiServer.createAndStart( + transportBiDi, + cdpConnectionAdapter, + // TODO: most likely need a little bit of refactoring + cdpConnectionAdapter.browserClient(), + '', + options, + undefined, + bidiServerLogger + ); + return pptrBiDiConnection; +} + +/** + * Manages CDPSessions for BidiServer. + * @internal + */ +class CdpConnectionAdapter { + #cdp: CdpConnection; + #adapters = new Map>(); + #browserCdpConnection: CDPClientAdapter; + + constructor(cdp: CdpConnection) { + this.#cdp = cdp; + this.#browserCdpConnection = new CDPClientAdapter(cdp); + } + + browserClient(): CDPClientAdapter { + return this.#browserCdpConnection; + } + + getCdpClient(id: string) { + const session = this.#cdp.session(id); + if (!session) { + throw new Error(`Unknown CDP session with id ${id}`); + } + if (!this.#adapters.has(session)) { + const adapter = new CDPClientAdapter( + session, + id, + this.#browserCdpConnection + ); + this.#adapters.set(session, adapter); + return adapter; + } + return this.#adapters.get(session)!; + } + + close() { + this.#browserCdpConnection.close(); + for (const adapter of this.#adapters.values()) { + adapter.close(); + } + } +} + +/** + * Wrapper on top of CDPSession/CDPConnection to satisfy CDP interface that + * BidiServer needs. + * + * @internal + */ +class CDPClientAdapter + extends BidiMapper.EventEmitter + implements BidiMapper.CdpClient +{ + #closed = false; + #client: T; + sessionId: string | undefined = undefined; + #browserClient?: BidiMapper.CdpClient; + + constructor( + client: T, + sessionId?: string, + browserClient?: BidiMapper.CdpClient + ) { + super(); + this.#client = client; + this.sessionId = sessionId; + this.#browserClient = browserClient; + this.#client.on('*', this.#forwardMessage as Handler); + } + + browserClient(): BidiMapper.CdpClient { + return this.#browserClient!; + } + + #forwardMessage = ( + method: T, + event: CDPEvents[T] + ) => { + this.emit(method, event); + }; + + async sendCommand( + method: T, + ...params: ProtocolMapping.Commands[T]['paramsType'] + ): Promise { + if (this.#closed) { + return; + } + try { + return await this.#client.send(method, ...params); + } catch (err) { + if (this.#closed) { + return; + } + throw err; + } + } + + close() { + this.#client.off('*', this.#forwardMessage as Handler); + this.#closed = true; + } + + isCloseError(error: unknown): boolean { + return error instanceof TargetCloseError; + } +} + +/** + * This transport is given to the BiDi server instance and allows Puppeteer + * to send and receive commands to the BiDiServer. + * @internal + */ +class NoOpTransport + extends BidiMapper.EventEmitter<{ + bidiResponse: Bidi.ChromiumBidi.Message; + }> + implements BidiMapper.BidiTransport +{ + #onMessage: (message: Bidi.ChromiumBidi.Command) => Promise | void = + async (_m: Bidi.ChromiumBidi.Command): Promise => { + return; + }; + + emitMessage(message: Bidi.ChromiumBidi.Command) { + void this.#onMessage(message); + } + + setOnMessage( + onMessage: (message: Bidi.ChromiumBidi.Command) => Promise | void + ): void { + this.#onMessage = onMessage; + } + + async sendMessage(message: Bidi.ChromiumBidi.Message): Promise { + this.emit('bidiResponse', message); + } + + close() { + this.#onMessage = async (_m: Bidi.ChromiumBidi.Command): Promise => { + return; + }; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Browser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Browser.ts new file mode 100644 index 0000000000..42979790c9 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Browser.ts @@ -0,0 +1,317 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ChildProcess} from 'child_process'; + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import { + Browser, + BrowserEvent, + type BrowserCloseCallback, + type BrowserContextOptions, + type DebugInfo, +} from '../api/Browser.js'; +import {BrowserContextEvent} from '../api/BrowserContext.js'; +import type {Page} from '../api/Page.js'; +import type {Target} from '../api/Target.js'; +import {UnsupportedOperation} from '../common/Errors.js'; +import type {Handler} from '../common/EventEmitter.js'; +import {debugError} from '../common/util.js'; +import type {Viewport} from '../common/Viewport.js'; + +import {BidiBrowserContext} from './BrowserContext.js'; +import {BrowsingContext, BrowsingContextEvent} from './BrowsingContext.js'; +import type {BidiConnection} from './Connection.js'; +import type {Browser as BrowserCore} from './core/Browser.js'; +import {Session} from './core/Session.js'; +import type {UserContext} from './core/UserContext.js'; +import { + BiDiBrowserTarget, + BiDiBrowsingContextTarget, + BiDiPageTarget, + type BidiTarget, +} from './Target.js'; + +/** + * @internal + */ +export interface BidiBrowserOptions { + process?: ChildProcess; + closeCallback?: BrowserCloseCallback; + connection: BidiConnection; + defaultViewport: Viewport | null; + ignoreHTTPSErrors?: boolean; +} + +/** + * @internal + */ +export class BidiBrowser extends Browser { + readonly protocol = 'webDriverBiDi'; + + // TODO: Update generator to include fully module + static readonly subscribeModules: string[] = [ + 'browsingContext', + 'network', + 'log', + 'script', + ]; + static readonly subscribeCdpEvents: Bidi.Cdp.EventNames[] = [ + // Coverage + 'cdp.Debugger.scriptParsed', + 'cdp.CSS.styleSheetAdded', + 'cdp.Runtime.executionContextsCleared', + // Tracing + 'cdp.Tracing.tracingComplete', + // TODO: subscribe to all CDP events in the future. + 'cdp.Network.requestWillBeSent', + 'cdp.Debugger.scriptParsed', + 'cdp.Page.screencastFrame', + ]; + + static async create(opts: BidiBrowserOptions): Promise { + const session = await Session.from(opts.connection, { + alwaysMatch: { + acceptInsecureCerts: opts.ignoreHTTPSErrors, + webSocketUrl: true, + }, + }); + + await session.subscribe( + session.capabilities.browserName.toLocaleLowerCase().includes('firefox') + ? BidiBrowser.subscribeModules + : [...BidiBrowser.subscribeModules, ...BidiBrowser.subscribeCdpEvents] + ); + + const browser = new BidiBrowser(session.browser, opts); + browser.#initialize(); + await browser.#getTree(); + return browser; + } + + #process?: ChildProcess; + #closeCallback?: BrowserCloseCallback; + #browserCore: BrowserCore; + #defaultViewport: Viewport | null; + #targets = new Map(); + #browserContexts = new WeakMap(); + #browserTarget: BiDiBrowserTarget; + + #connectionEventHandlers = new Map< + Bidi.BrowsingContextEvent['method'], + Handler + >([ + ['browsingContext.contextCreated', this.#onContextCreated.bind(this)], + ['browsingContext.contextDestroyed', this.#onContextDestroyed.bind(this)], + ['browsingContext.domContentLoaded', this.#onContextDomLoaded.bind(this)], + ['browsingContext.fragmentNavigated', this.#onContextNavigation.bind(this)], + ['browsingContext.navigationStarted', this.#onContextNavigation.bind(this)], + ]); + + private constructor(browserCore: BrowserCore, opts: BidiBrowserOptions) { + super(); + this.#process = opts.process; + this.#closeCallback = opts.closeCallback; + this.#browserCore = browserCore; + this.#defaultViewport = opts.defaultViewport; + this.#browserTarget = new BiDiBrowserTarget(this); + this.#createBrowserContext(this.#browserCore.defaultUserContext); + } + + #initialize() { + this.#browserCore.once('disconnected', () => { + this.emit(BrowserEvent.Disconnected, undefined); + }); + this.#process?.once('close', () => { + this.#browserCore.dispose('Browser process exited.', true); + this.connection.dispose(); + }); + + for (const [eventName, handler] of this.#connectionEventHandlers) { + this.connection.on(eventName, handler); + } + } + + get #browserName() { + return this.#browserCore.session.capabilities.browserName; + } + get #browserVersion() { + return this.#browserCore.session.capabilities.browserVersion; + } + + override userAgent(): never { + throw new UnsupportedOperation(); + } + + #createBrowserContext(userContext: UserContext) { + const browserContext = new BidiBrowserContext(this, userContext, { + defaultViewport: this.#defaultViewport, + }); + this.#browserContexts.set(userContext, browserContext); + return browserContext; + } + + #onContextDomLoaded(event: Bidi.BrowsingContext.Info) { + const target = this.#targets.get(event.context); + if (target) { + this.emit(BrowserEvent.TargetChanged, target); + } + } + + #onContextNavigation(event: Bidi.BrowsingContext.NavigationInfo) { + const target = this.#targets.get(event.context); + if (target) { + this.emit(BrowserEvent.TargetChanged, target); + target.browserContext().emit(BrowserContextEvent.TargetChanged, target); + } + } + + #onContextCreated(event: Bidi.BrowsingContext.ContextCreated['params']) { + const context = new BrowsingContext( + this.connection, + event, + this.#browserName + ); + this.connection.registerBrowsingContexts(context); + // TODO: once more browsing context types are supported, this should be + // updated to support those. Currently, all top-level contexts are treated + // as pages. + const browserContext = this.browserContexts().at(-1); + if (!browserContext) { + throw new Error('Missing browser contexts'); + } + const target = !context.parent + ? new BiDiPageTarget(browserContext, context) + : new BiDiBrowsingContextTarget(browserContext, context); + this.#targets.set(event.context, target); + + this.emit(BrowserEvent.TargetCreated, target); + target.browserContext().emit(BrowserContextEvent.TargetCreated, target); + + if (context.parent) { + const topLevel = this.connection.getTopLevelContext(context.parent); + topLevel.emit(BrowsingContextEvent.Created, context); + } + } + + async #getTree(): Promise { + const {result} = await this.connection.send('browsingContext.getTree', {}); + for (const context of result.contexts) { + this.#onContextCreated(context); + } + } + + async #onContextDestroyed( + event: Bidi.BrowsingContext.ContextDestroyed['params'] + ) { + const context = this.connection.getBrowsingContext(event.context); + const topLevelContext = this.connection.getTopLevelContext(event.context); + topLevelContext.emit(BrowsingContextEvent.Destroyed, context); + const target = this.#targets.get(event.context); + const page = await target?.page(); + await page?.close().catch(debugError); + this.#targets.delete(event.context); + if (target) { + this.emit(BrowserEvent.TargetDestroyed, target); + target.browserContext().emit(BrowserContextEvent.TargetDestroyed, target); + } + } + + get connection(): BidiConnection { + // SAFETY: We only have one implementation. + return this.#browserCore.session.connection as BidiConnection; + } + + override wsEndpoint(): string { + return this.connection.url; + } + + override async close(): Promise { + for (const [eventName, handler] of this.#connectionEventHandlers) { + this.connection.off(eventName, handler); + } + if (this.connection.closed) { + return; + } + + try { + await this.#browserCore.close(); + await this.#closeCallback?.call(null); + } catch (error) { + // Fail silently. + debugError(error); + } finally { + this.connection.dispose(); + } + } + + override get connected(): boolean { + return !this.#browserCore.disposed; + } + + override process(): ChildProcess | null { + return this.#process ?? null; + } + + override async createIncognitoBrowserContext( + _options?: BrowserContextOptions + ): Promise { + const userContext = await this.#browserCore.createUserContext(); + return this.#createBrowserContext(userContext); + } + + override async version(): Promise { + return `${this.#browserName}/${this.#browserVersion}`; + } + + override browserContexts(): BidiBrowserContext[] { + return [...this.#browserCore.userContexts].map(context => { + return this.#browserContexts.get(context)!; + }); + } + + override defaultBrowserContext(): BidiBrowserContext { + return this.#browserContexts.get(this.#browserCore.defaultUserContext)!; + } + + override newPage(): Promise { + return this.defaultBrowserContext().newPage(); + } + + override targets(): Target[] { + return [this.#browserTarget, ...Array.from(this.#targets.values())]; + } + + _getTargetById(id: string): BidiTarget { + const target = this.#targets.get(id); + if (!target) { + throw new Error('Target not found'); + } + return target; + } + + override target(): Target { + return this.#browserTarget; + } + + override async disconnect(): Promise { + try { + await this.#browserCore.session.end(); + } catch (error) { + // Fail silently. + debugError(error); + } finally { + this.connection.dispose(); + } + } + + override get debugInfo(): DebugInfo { + return { + pendingProtocolErrors: this.connection.getPendingProtocolErrors(), + }; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserConnector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserConnector.ts new file mode 100644 index 0000000000..f616e90561 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserConnector.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {BrowserCloseCallback} from '../api/Browser.js'; +import {Connection} from '../cdp/Connection.js'; +import type {ConnectionTransport} from '../common/ConnectionTransport.js'; +import type { + BrowserConnectOptions, + ConnectOptions, +} from '../common/ConnectOptions.js'; +import {ProtocolError, UnsupportedOperation} from '../common/Errors.js'; +import {debugError, DEFAULT_VIEWPORT} from '../common/util.js'; + +import type {BidiBrowser} from './Browser.js'; +import type {BidiConnection} from './Connection.js'; + +/** + * Users should never call this directly; it's called when calling `puppeteer.connect` + * with `protocol: 'webDriverBiDi'`. This method attaches Puppeteer to an existing browser + * instance. First it tries to connect to the browser using pure BiDi. If the protocol is + * not supported, connects to the browser using BiDi over CDP. + * + * @internal + */ +export async function _connectToBiDiBrowser( + connectionTransport: ConnectionTransport, + url: string, + options: BrowserConnectOptions & ConnectOptions +): Promise { + const {ignoreHTTPSErrors = false, defaultViewport = DEFAULT_VIEWPORT} = + options; + + const {bidiConnection, closeCallback} = await getBiDiConnection( + connectionTransport, + url, + options + ); + const BiDi = await import(/* webpackIgnore: true */ './bidi.js'); + const bidiBrowser = await BiDi.BidiBrowser.create({ + connection: bidiConnection, + closeCallback, + process: undefined, + defaultViewport: defaultViewport, + ignoreHTTPSErrors: ignoreHTTPSErrors, + }); + return bidiBrowser; +} + +/** + * Returns a BiDiConnection established to the endpoint specified by the options and a + * callback closing the browser. Callback depends on whether the connection is pure BiDi + * or BiDi over CDP. + * The method tries to connect to the browser using pure BiDi protocol, and falls back + * to BiDi over CDP. + */ +async function getBiDiConnection( + connectionTransport: ConnectionTransport, + url: string, + options: BrowserConnectOptions +): Promise<{ + bidiConnection: BidiConnection; + closeCallback: BrowserCloseCallback; +}> { + const BiDi = await import(/* webpackIgnore: true */ './bidi.js'); + const {ignoreHTTPSErrors = false, slowMo = 0, protocolTimeout} = options; + + // Try pure BiDi first. + const pureBidiConnection = new BiDi.BidiConnection( + url, + connectionTransport, + slowMo, + protocolTimeout + ); + try { + const result = await pureBidiConnection.send('session.status', {}); + if ('type' in result && result.type === 'success') { + // The `browserWSEndpoint` points to an endpoint supporting pure WebDriver BiDi. + return { + bidiConnection: pureBidiConnection, + closeCallback: async () => { + await pureBidiConnection.send('browser.close', {}).catch(debugError); + }, + }; + } + } catch (e) { + if (!(e instanceof ProtocolError)) { + // Unexpected exception not related to BiDi / CDP. Rethrow. + throw e; + } + } + // Unbind the connection to avoid memory leaks. + pureBidiConnection.unbind(); + + // Fall back to CDP over BiDi reusing the WS connection. + const cdpConnection = new Connection( + url, + connectionTransport, + slowMo, + protocolTimeout + ); + + const version = await cdpConnection.send('Browser.getVersion'); + if (version.product.toLowerCase().includes('firefox')) { + throw new UnsupportedOperation( + 'Firefox is not supported in BiDi over CDP mode.' + ); + } + + // TODO: use other options too. + const bidiOverCdpConnection = await BiDi.connectBidiOverCdp(cdpConnection, { + acceptInsecureCerts: ignoreHTTPSErrors, + }); + return { + bidiConnection: bidiOverCdpConnection, + closeCallback: async () => { + // In case of BiDi over CDP, we need to close browser via CDP. + await cdpConnection.send('Browser.close').catch(debugError); + }, + }; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserContext.ts new file mode 100644 index 0000000000..feb5e9951d --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserContext.ts @@ -0,0 +1,145 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import type {WaitForTargetOptions} from '../api/Browser.js'; +import {BrowserContext} from '../api/BrowserContext.js'; +import type {Page} from '../api/Page.js'; +import type {Target} from '../api/Target.js'; +import {UnsupportedOperation} from '../common/Errors.js'; +import {debugError} from '../common/util.js'; +import type {Viewport} from '../common/Viewport.js'; + +import type {BidiBrowser} from './Browser.js'; +import type {BidiConnection} from './Connection.js'; +import {UserContext} from './core/UserContext.js'; +import type {BidiPage} from './Page.js'; + +/** + * @internal + */ +export interface BidiBrowserContextOptions { + defaultViewport: Viewport | null; +} + +/** + * @internal + */ +export class BidiBrowserContext extends BrowserContext { + #browser: BidiBrowser; + #connection: BidiConnection; + #defaultViewport: Viewport | null; + #userContext: UserContext; + + constructor( + browser: BidiBrowser, + userContext: UserContext, + options: BidiBrowserContextOptions + ) { + super(); + this.#browser = browser; + this.#userContext = userContext; + this.#connection = this.#browser.connection; + this.#defaultViewport = options.defaultViewport; + } + + override targets(): Target[] { + return this.#browser.targets().filter(target => { + return target.browserContext() === this; + }); + } + + override waitForTarget( + predicate: (x: Target) => boolean | Promise, + options: WaitForTargetOptions = {} + ): Promise { + return this.#browser.waitForTarget(target => { + return target.browserContext() === this && predicate(target); + }, options); + } + + get connection(): BidiConnection { + return this.#connection; + } + + override async newPage(): Promise { + const {result} = await this.#connection.send('browsingContext.create', { + type: Bidi.BrowsingContext.CreateType.Tab, + }); + const target = this.#browser._getTargetById(result.context); + + // TODO: once BiDi has some concept matching BrowserContext, the newly + // created contexts should get automatically assigned to the right + // BrowserContext. For now, we assume that only explicitly created pages go + // to the current BrowserContext. Otherwise, the contexts get assigned to + // the default BrowserContext by the Browser. + target._setBrowserContext(this); + + const page = await target.page(); + if (!page) { + throw new Error('Page is not found'); + } + if (this.#defaultViewport) { + try { + await page.setViewport(this.#defaultViewport); + } catch { + // No support for setViewport in Firefox. + } + } + + return page; + } + + override async close(): Promise { + if (!this.isIncognito()) { + throw new Error('Default context cannot be closed!'); + } + + // TODO: Remove once we have adopted the new browsing contexts. + for (const target of this.targets()) { + const page = await target?.page(); + try { + await page?.close(); + } catch (error) { + debugError(error); + } + } + + try { + await this.#userContext.remove(); + } catch (error) { + debugError(error); + } + } + + override browser(): BidiBrowser { + return this.#browser; + } + + override async pages(): Promise { + const results = await Promise.all( + [...this.targets()].map(t => { + return t.page(); + }) + ); + return results.filter((p): p is BidiPage => { + return p !== null; + }); + } + + override isIncognito(): boolean { + return this.#userContext.id !== UserContext.DEFAULT; + } + + override overridePermissions(): never { + throw new UnsupportedOperation(); + } + + override clearPermissionOverrides(): never { + throw new UnsupportedOperation(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowsingContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowsingContext.ts new file mode 100644 index 0000000000..0804628c06 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowsingContext.ts @@ -0,0 +1,187 @@ +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; +import type ProtocolMapping from 'devtools-protocol/types/protocol-mapping.js'; + +import {CDPSession} from '../api/CDPSession.js'; +import type {Connection as CdpConnection} from '../cdp/Connection.js'; +import {TargetCloseError, UnsupportedOperation} from '../common/Errors.js'; +import type {EventType} from '../common/EventEmitter.js'; +import {debugError} from '../common/util.js'; +import {Deferred} from '../util/Deferred.js'; + +import type {BidiConnection} from './Connection.js'; +import {BidiRealm} from './Realm.js'; + +/** + * @internal + */ +export const cdpSessions = new Map(); + +/** + * @internal + */ +export class CdpSessionWrapper extends CDPSession { + #context: BrowsingContext; + #sessionId = Deferred.create(); + #detached = false; + + constructor(context: BrowsingContext, sessionId?: string) { + super(); + this.#context = context; + if (!this.#context.supportsCdp()) { + return; + } + if (sessionId) { + this.#sessionId.resolve(sessionId); + cdpSessions.set(sessionId, this); + } else { + context.connection + .send('cdp.getSession', { + context: context.id, + }) + .then(session => { + this.#sessionId.resolve(session.result.session!); + cdpSessions.set(session.result.session!, this); + }) + .catch(err => { + this.#sessionId.reject(err); + }); + } + } + + override connection(): CdpConnection | undefined { + return undefined; + } + + override async send( + method: T, + ...paramArgs: ProtocolMapping.Commands[T]['paramsType'] + ): Promise { + if (!this.#context.supportsCdp()) { + throw new UnsupportedOperation( + 'CDP support is required for this feature. The current browser does not support CDP.' + ); + } + if (this.#detached) { + throw new TargetCloseError( + `Protocol error (${method}): Session closed. Most likely the page has been closed.` + ); + } + const session = await this.#sessionId.valueOrThrow(); + const {result} = await this.#context.connection.send('cdp.sendCommand', { + method: method, + params: paramArgs[0], + session, + }); + return result.result; + } + + override async detach(): Promise { + cdpSessions.delete(this.id()); + if (!this.#detached && this.#context.supportsCdp()) { + await this.#context.cdpSession.send('Target.detachFromTarget', { + sessionId: this.id(), + }); + } + this.#detached = true; + } + + override id(): string { + const val = this.#sessionId.value(); + return val instanceof Error || val === undefined ? '' : val; + } +} + +/** + * Internal events that the BrowsingContext class emits. + * + * @internal + */ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace BrowsingContextEvent { + /** + * Emitted on the top-level context, when a descendant context is created. + */ + export const Created = Symbol('BrowsingContext.created'); + /** + * Emitted on the top-level context, when a descendant context or the + * top-level context itself is destroyed. + */ + export const Destroyed = Symbol('BrowsingContext.destroyed'); +} + +/** + * @internal + */ +export interface BrowsingContextEvents extends Record { + [BrowsingContextEvent.Created]: BrowsingContext; + [BrowsingContextEvent.Destroyed]: BrowsingContext; +} + +/** + * @internal + */ +export class BrowsingContext extends BidiRealm { + #id: string; + #url: string; + #cdpSession: CDPSession; + #parent?: string | null; + #browserName = ''; + + constructor( + connection: BidiConnection, + info: Bidi.BrowsingContext.Info, + browserName: string + ) { + super(connection); + this.#id = info.context; + this.#url = info.url; + this.#parent = info.parent; + this.#browserName = browserName; + this.#cdpSession = new CdpSessionWrapper(this, undefined); + + this.on('browsingContext.domContentLoaded', this.#updateUrl.bind(this)); + this.on('browsingContext.fragmentNavigated', this.#updateUrl.bind(this)); + this.on('browsingContext.load', this.#updateUrl.bind(this)); + } + + supportsCdp(): boolean { + return !this.#browserName.toLowerCase().includes('firefox'); + } + + #updateUrl(info: Bidi.BrowsingContext.NavigationInfo) { + this.#url = info.url; + } + + createRealmForSandbox(): BidiRealm { + return new BidiRealm(this.connection); + } + + get url(): string { + return this.#url; + } + + get id(): string { + return this.#id; + } + + get parent(): string | undefined | null { + return this.#parent; + } + + get cdpSession(): CDPSession { + return this.#cdpSession; + } + + async sendCdpCommand( + method: T, + ...paramArgs: ProtocolMapping.Commands[T]['paramsType'] + ): Promise { + return await this.#cdpSession.send(method, ...paramArgs); + } + + dispose(): void { + this.removeAllListeners(); + this.connection.unregisterBrowsingContexts(this.#id); + void this.#cdpSession.detach().catch(debugError); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.test.ts new file mode 100644 index 0000000000..9f37e38661 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.test.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, it} from 'node:test'; + +import expect from 'expect'; + +import type {ConnectionTransport} from '../common/ConnectionTransport.js'; + +import {BidiConnection} from './Connection.js'; + +describe('WebDriver BiDi Connection', () => { + class TestConnectionTransport implements ConnectionTransport { + sent: string[] = []; + closed = false; + + send(message: string) { + this.sent.push(message); + } + + close(): void { + this.closed = true; + } + } + + it('should work', async () => { + const transport = new TestConnectionTransport(); + const connection = new BidiConnection('ws://127.0.0.1', transport); + const responsePromise = connection.send('session.new', { + capabilities: {}, + }); + expect(transport.sent).toEqual([ + `{"id":1,"method":"session.new","params":{"capabilities":{}}}`, + ]); + const id = JSON.parse(transport.sent[0]!).id; + const rawResponse = { + id, + type: 'success', + result: {ready: false, message: 'already connected'}, + }; + (transport as ConnectionTransport).onmessage?.(JSON.stringify(rawResponse)); + const response = await responsePromise; + expect(response).toEqual(rawResponse); + connection.dispose(); + expect(transport.closed).toBeTruthy(); + }); +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.ts new file mode 100644 index 0000000000..bce952ba39 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.ts @@ -0,0 +1,256 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {CallbackRegistry} from '../common/CallbackRegistry.js'; +import type {ConnectionTransport} from '../common/ConnectionTransport.js'; +import {debug} from '../common/Debug.js'; +import type {EventsWithWildcard} from '../common/EventEmitter.js'; +import {EventEmitter} from '../common/EventEmitter.js'; +import {debugError} from '../common/util.js'; +import {assert} from '../util/assert.js'; + +import {cdpSessions, type BrowsingContext} from './BrowsingContext.js'; +import type { + BidiEvents, + Commands as BidiCommands, + Connection, +} from './core/Connection.js'; + +const debugProtocolSend = debug('puppeteer:webDriverBiDi:SEND ►'); +const debugProtocolReceive = debug('puppeteer:webDriverBiDi:RECV ◀'); + +/** + * @internal + */ +export interface Commands extends BidiCommands { + 'cdp.sendCommand': { + params: Bidi.Cdp.SendCommandParameters; + returnType: Bidi.Cdp.SendCommandResult; + }; + 'cdp.getSession': { + params: Bidi.Cdp.GetSessionParameters; + returnType: Bidi.Cdp.GetSessionResult; + }; +} + +/** + * @internal + */ +export class BidiConnection + extends EventEmitter + implements Connection +{ + #url: string; + #transport: ConnectionTransport; + #delay: number; + #timeout? = 0; + #closed = false; + #callbacks = new CallbackRegistry(); + #browsingContexts = new Map(); + #emitters: Array> = []; + + constructor( + url: string, + transport: ConnectionTransport, + delay = 0, + timeout?: number + ) { + super(); + this.#url = url; + this.#delay = delay; + this.#timeout = timeout ?? 180_000; + + this.#transport = transport; + this.#transport.onmessage = this.onMessage.bind(this); + this.#transport.onclose = this.unbind.bind(this); + } + + get closed(): boolean { + return this.#closed; + } + + get url(): string { + return this.#url; + } + + pipeTo(emitter: EventEmitter): void { + this.#emitters.push(emitter); + } + + override emit>( + type: Key, + event: EventsWithWildcard[Key] + ): boolean { + for (const emitter of this.#emitters) { + emitter.emit(type, event); + } + return super.emit(type, event); + } + + send( + method: T, + params: Commands[T]['params'] + ): Promise<{result: Commands[T]['returnType']}> { + assert(!this.#closed, 'Protocol error: Connection closed.'); + + return this.#callbacks.create(method, this.#timeout, id => { + const stringifiedMessage = JSON.stringify({ + id, + method, + params, + } as Bidi.Command); + debugProtocolSend(stringifiedMessage); + this.#transport.send(stringifiedMessage); + }) as Promise<{result: Commands[T]['returnType']}>; + } + + /** + * @internal + */ + protected async onMessage(message: string): Promise { + if (this.#delay) { + await new Promise(f => { + return setTimeout(f, this.#delay); + }); + } + debugProtocolReceive(message); + const object: Bidi.ChromiumBidi.Message = JSON.parse(message); + if ('type' in object) { + switch (object.type) { + case 'success': + this.#callbacks.resolve(object.id, object); + return; + case 'error': + if (object.id === null) { + break; + } + this.#callbacks.reject( + object.id, + createProtocolError(object), + object.message + ); + return; + case 'event': + if (isCdpEvent(object)) { + cdpSessions + .get(object.params.session) + ?.emit(object.params.event, object.params.params); + return; + } + this.#maybeEmitOnContext(object); + // SAFETY: We know the method and parameter still match here. + this.emit( + object.method, + object.params as BidiEvents[keyof BidiEvents] + ); + return; + } + } + // Even if the response in not in BiDi protocol format but `id` is provided, reject + // the callback. This can happen if the endpoint supports CDP instead of BiDi. + if ('id' in object) { + this.#callbacks.reject( + (object as {id: number}).id, + `Protocol Error. Message is not in BiDi protocol format: '${message}'`, + object.message + ); + } + debugError(object); + } + + #maybeEmitOnContext(event: Bidi.ChromiumBidi.Event) { + let context: BrowsingContext | undefined; + // Context specific events + if ('context' in event.params && event.params.context !== null) { + context = this.#browsingContexts.get(event.params.context); + // `log.entryAdded` specific context + } else if ( + 'source' in event.params && + event.params.source.context !== undefined + ) { + context = this.#browsingContexts.get(event.params.source.context); + } + context?.emit(event.method, event.params); + } + + registerBrowsingContexts(context: BrowsingContext): void { + this.#browsingContexts.set(context.id, context); + } + + getBrowsingContext(contextId: string): BrowsingContext { + const currentContext = this.#browsingContexts.get(contextId); + if (!currentContext) { + throw new Error(`BrowsingContext ${contextId} does not exist.`); + } + return currentContext; + } + + getTopLevelContext(contextId: string): BrowsingContext { + let currentContext = this.#browsingContexts.get(contextId); + if (!currentContext) { + throw new Error(`BrowsingContext ${contextId} does not exist.`); + } + while (currentContext.parent) { + contextId = currentContext.parent; + currentContext = this.#browsingContexts.get(contextId); + if (!currentContext) { + throw new Error(`BrowsingContext ${contextId} does not exist.`); + } + } + return currentContext; + } + + unregisterBrowsingContexts(id: string): void { + this.#browsingContexts.delete(id); + } + + /** + * Unbinds the connection, but keeps the transport open. Useful when the transport will + * be reused by other connection e.g. with different protocol. + * @internal + */ + unbind(): void { + if (this.#closed) { + return; + } + this.#closed = true; + // Both may still be invoked and produce errors + this.#transport.onmessage = () => {}; + this.#transport.onclose = () => {}; + + this.#browsingContexts.clear(); + this.#callbacks.clear(); + } + + /** + * Unbinds the connection and closes the transport. + */ + dispose(): void { + this.unbind(); + this.#transport.close(); + } + + getPendingProtocolErrors(): Error[] { + return this.#callbacks.getPendingProtocolErrors(); + } +} + +/** + * @internal + */ +function createProtocolError(object: Bidi.ErrorResponse): string { + let message = `${object.error} ${object.message}`; + if (object.stacktrace) { + message += ` ${object.stacktrace}`; + } + return message; +} + +function isCdpEvent(event: Bidi.ChromiumBidi.Event): event is Bidi.Cdp.Event { + return event.method.startsWith('cdp.'); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Deserializer.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Deserializer.ts new file mode 100644 index 0000000000..14b87d403b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Deserializer.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {debugError} from '../common/util.js'; + +/** + * @internal + */ +export class BidiDeserializer { + static deserializeNumber(value: Bidi.Script.SpecialNumber | number): number { + switch (value) { + case '-0': + return -0; + case 'NaN': + return NaN; + case 'Infinity': + return Infinity; + case '-Infinity': + return -Infinity; + default: + return value; + } + } + + static deserializeLocalValue(result: Bidi.Script.RemoteValue): unknown { + switch (result.type) { + case 'array': + return result.value?.map(value => { + return BidiDeserializer.deserializeLocalValue(value); + }); + case 'set': + return result.value?.reduce((acc: Set, value) => { + return acc.add(BidiDeserializer.deserializeLocalValue(value)); + }, new Set()); + case 'object': + return result.value?.reduce((acc: Record, tuple) => { + const {key, value} = BidiDeserializer.deserializeTuple(tuple); + acc[key as any] = value; + return acc; + }, {}); + case 'map': + return result.value?.reduce((acc: Map, tuple) => { + const {key, value} = BidiDeserializer.deserializeTuple(tuple); + return acc.set(key, value); + }, new Map()); + case 'promise': + return {}; + case 'regexp': + return new RegExp(result.value.pattern, result.value.flags); + case 'date': + return new Date(result.value); + case 'undefined': + return undefined; + case 'null': + return null; + case 'number': + return BidiDeserializer.deserializeNumber(result.value); + case 'bigint': + return BigInt(result.value); + case 'boolean': + return Boolean(result.value); + case 'string': + return result.value; + } + + debugError(`Deserialization of type ${result.type} not supported.`); + return undefined; + } + + static deserializeTuple([serializedKey, serializedValue]: [ + Bidi.Script.RemoteValue | string, + Bidi.Script.RemoteValue, + ]): {key: unknown; value: unknown} { + const key = + typeof serializedKey === 'string' + ? serializedKey + : BidiDeserializer.deserializeLocalValue(serializedKey); + const value = BidiDeserializer.deserializeLocalValue(serializedValue); + + return {key, value}; + } + + static deserialize(result: Bidi.Script.RemoteValue): any { + if (!result) { + debugError('Service did not produce a result.'); + return undefined; + } + + return BidiDeserializer.deserializeLocalValue(result); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Dialog.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Dialog.ts new file mode 100644 index 0000000000..ce22223461 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Dialog.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {Dialog} from '../api/Dialog.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; + +/** + * @internal + */ +export class BidiDialog extends Dialog { + #context: BrowsingContext; + + /** + * @internal + */ + constructor( + context: BrowsingContext, + type: Bidi.BrowsingContext.UserPromptOpenedParameters['type'], + message: string, + defaultValue?: string + ) { + super(type, message, defaultValue); + this.#context = context; + } + + /** + * @internal + */ + override async handle(options: { + accept: boolean; + text?: string; + }): Promise { + await this.#context.connection.send('browsingContext.handleUserPrompt', { + context: this.#context.id, + accept: options.accept, + userText: options.text, + }); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ElementHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ElementHandle.ts new file mode 100644 index 0000000000..fd886e8c26 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ElementHandle.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {type AutofillData, ElementHandle} from '../api/ElementHandle.js'; +import {UnsupportedOperation} from '../common/Errors.js'; +import {throwIfDisposed} from '../util/decorators.js'; + +import type {BidiFrame} from './Frame.js'; +import {BidiJSHandle} from './JSHandle.js'; +import type {BidiRealm} from './Realm.js'; +import type {Sandbox} from './Sandbox.js'; + +/** + * @internal + */ +export class BidiElementHandle< + ElementType extends Node = Element, +> extends ElementHandle { + declare handle: BidiJSHandle; + + constructor(sandbox: Sandbox, remoteValue: Bidi.Script.RemoteValue) { + super(new BidiJSHandle(sandbox, remoteValue)); + } + + override get realm(): Sandbox { + return this.handle.realm; + } + + override get frame(): BidiFrame { + return this.realm.environment; + } + + context(): BidiRealm { + return this.handle.context(); + } + + get isPrimitiveValue(): boolean { + return this.handle.isPrimitiveValue; + } + + remoteValue(): Bidi.Script.RemoteValue { + return this.handle.remoteValue(); + } + + @throwIfDisposed() + override async autofill(data: AutofillData): Promise { + const client = this.frame.client; + const nodeInfo = await client.send('DOM.describeNode', { + objectId: this.handle.id, + }); + const fieldId = nodeInfo.node.backendNodeId; + const frameId = this.frame._id; + await client.send('Autofill.trigger', { + fieldId, + frameId, + card: data.creditCard, + }); + } + + override async contentFrame( + this: BidiElementHandle + ): Promise; + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + override async contentFrame(): Promise { + using handle = (await this.evaluateHandle(element => { + if (element instanceof HTMLIFrameElement) { + return element.contentWindow; + } + return; + })) as BidiJSHandle; + const value = handle.remoteValue(); + if (value.type === 'window') { + return this.frame.page().frame(value.value.context); + } + return null; + } + + override uploadFile(this: ElementHandle): never { + throw new UnsupportedOperation(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/EmulationManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/EmulationManager.ts new file mode 100644 index 0000000000..de95695785 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/EmulationManager.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type {Viewport} from '../common/Viewport.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; + +/** + * @internal + */ +export class EmulationManager { + #browsingContext: BrowsingContext; + + constructor(browsingContext: BrowsingContext) { + this.#browsingContext = browsingContext; + } + + async emulateViewport(viewport: Viewport): Promise { + await this.#browsingContext.connection.send('browsingContext.setViewport', { + context: this.#browsingContext.id, + viewport: + viewport.width && viewport.height + ? { + width: viewport.width, + height: viewport.height, + } + : null, + devicePixelRatio: viewport.deviceScaleFactor + ? viewport.deviceScaleFactor + : null, + }); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ExposedFunction.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ExposedFunction.ts new file mode 100644 index 0000000000..62c6b5e37e --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ExposedFunction.ts @@ -0,0 +1,295 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import type {Awaitable, FlattenHandle} from '../common/types.js'; +import {debugError} from '../common/util.js'; +import {assert} from '../util/assert.js'; +import {Deferred} from '../util/Deferred.js'; +import {interpolateFunction, stringifyFunction} from '../util/Function.js'; + +import type {BidiConnection} from './Connection.js'; +import {BidiDeserializer} from './Deserializer.js'; +import type {BidiFrame} from './Frame.js'; +import {BidiSerializer} from './Serializer.js'; + +type SendArgsChannel = (value: [id: number, args: Args]) => void; +type SendResolveChannel = ( + value: [id: number, resolve: (ret: FlattenHandle>) => void] +) => void; +type SendRejectChannel = ( + value: [id: number, reject: (error: unknown) => void] +) => void; + +interface RemotePromiseCallbacks { + resolve: Deferred; + reject: Deferred; +} + +/** + * @internal + */ +export class ExposeableFunction { + readonly #frame; + + readonly name; + readonly #apply; + + readonly #channels; + readonly #callerInfos = new Map< + string, + Map + >(); + + #preloadScriptId?: Bidi.Script.PreloadScript; + + constructor( + frame: BidiFrame, + name: string, + apply: (...args: Args) => Awaitable + ) { + this.#frame = frame; + this.name = name; + this.#apply = apply; + + this.#channels = { + args: `__puppeteer__${this.#frame._id}_page_exposeFunction_${this.name}_args`, + resolve: `__puppeteer__${this.#frame._id}_page_exposeFunction_${this.name}_resolve`, + reject: `__puppeteer__${this.#frame._id}_page_exposeFunction_${this.name}_reject`, + }; + } + + async expose(): Promise { + const connection = this.#connection; + const channelArguments = this.#channelArguments; + + // TODO(jrandolf): Implement cleanup with removePreloadScript. + connection.on( + Bidi.ChromiumBidi.Script.EventNames.Message, + this.#handleArgumentsMessage + ); + connection.on( + Bidi.ChromiumBidi.Script.EventNames.Message, + this.#handleResolveMessage + ); + connection.on( + Bidi.ChromiumBidi.Script.EventNames.Message, + this.#handleRejectMessage + ); + + const functionDeclaration = stringifyFunction( + interpolateFunction( + ( + sendArgs: SendArgsChannel, + sendResolve: SendResolveChannel, + sendReject: SendRejectChannel + ) => { + let id = 0; + Object.assign(globalThis, { + [PLACEHOLDER('name') as string]: function (...args: Args) { + return new Promise>>( + (resolve, reject) => { + sendArgs([id, args]); + sendResolve([id, resolve]); + sendReject([id, reject]); + ++id; + } + ); + }, + }); + }, + {name: JSON.stringify(this.name)} + ) + ); + + const {result} = await connection.send('script.addPreloadScript', { + functionDeclaration, + arguments: channelArguments, + contexts: [this.#frame.page().mainFrame()._id], + }); + this.#preloadScriptId = result.script; + + await Promise.all( + this.#frame + .page() + .frames() + .map(async frame => { + return await connection.send('script.callFunction', { + functionDeclaration, + arguments: channelArguments, + awaitPromise: false, + target: frame.mainRealm().realm.target, + }); + }) + ); + } + + #handleArgumentsMessage = async (params: Bidi.Script.MessageParameters) => { + if (params.channel !== this.#channels.args) { + return; + } + const connection = this.#connection; + const {callbacks, remoteValue} = this.#getCallbacksAndRemoteValue(params); + const args = remoteValue.value?.[1]; + assert(args); + try { + const result = await this.#apply(...BidiDeserializer.deserialize(args)); + await connection.send('script.callFunction', { + functionDeclaration: stringifyFunction(([_, resolve]: any, result) => { + resolve(result); + }), + arguments: [ + (await callbacks.resolve.valueOrThrow()) as Bidi.Script.LocalValue, + BidiSerializer.serializeRemoteValue(result), + ], + awaitPromise: false, + target: { + realm: params.source.realm, + }, + }); + } catch (error) { + try { + if (error instanceof Error) { + await connection.send('script.callFunction', { + functionDeclaration: stringifyFunction( + ( + [_, reject]: [unknown, (error: Error) => void], + name: string, + message: string, + stack?: string + ) => { + const error = new Error(message); + error.name = name; + if (stack) { + error.stack = stack; + } + reject(error); + } + ), + arguments: [ + (await callbacks.reject.valueOrThrow()) as Bidi.Script.LocalValue, + BidiSerializer.serializeRemoteValue(error.name), + BidiSerializer.serializeRemoteValue(error.message), + BidiSerializer.serializeRemoteValue(error.stack), + ], + awaitPromise: false, + target: { + realm: params.source.realm, + }, + }); + } else { + await connection.send('script.callFunction', { + functionDeclaration: stringifyFunction( + ( + [_, reject]: [unknown, (error: unknown) => void], + error: unknown + ) => { + reject(error); + } + ), + arguments: [ + (await callbacks.reject.valueOrThrow()) as Bidi.Script.LocalValue, + BidiSerializer.serializeRemoteValue(error), + ], + awaitPromise: false, + target: { + realm: params.source.realm, + }, + }); + } + } catch (error) { + debugError(error); + } + } + }; + + get #connection(): BidiConnection { + return this.#frame.context().connection; + } + + get #channelArguments() { + return [ + { + type: 'channel' as const, + value: { + channel: this.#channels.args, + ownership: Bidi.Script.ResultOwnership.Root, + }, + }, + { + type: 'channel' as const, + value: { + channel: this.#channels.resolve, + ownership: Bidi.Script.ResultOwnership.Root, + }, + }, + { + type: 'channel' as const, + value: { + channel: this.#channels.reject, + ownership: Bidi.Script.ResultOwnership.Root, + }, + }, + ]; + } + + #handleResolveMessage = (params: Bidi.Script.MessageParameters) => { + if (params.channel !== this.#channels.resolve) { + return; + } + const {callbacks, remoteValue} = this.#getCallbacksAndRemoteValue(params); + callbacks.resolve.resolve(remoteValue); + }; + + #handleRejectMessage = (params: Bidi.Script.MessageParameters) => { + if (params.channel !== this.#channels.reject) { + return; + } + const {callbacks, remoteValue} = this.#getCallbacksAndRemoteValue(params); + callbacks.reject.resolve(remoteValue); + }; + + #getCallbacksAndRemoteValue(params: Bidi.Script.MessageParameters) { + const {data, source} = params; + assert(data.type === 'array'); + assert(data.value); + + const callerIdRemote = data.value[0]; + assert(callerIdRemote); + assert(callerIdRemote.type === 'number'); + assert(typeof callerIdRemote.value === 'number'); + + let bindingMap = this.#callerInfos.get(source.realm); + if (!bindingMap) { + bindingMap = new Map(); + this.#callerInfos.set(source.realm, bindingMap); + } + + const callerId = callerIdRemote.value; + let callbacks = bindingMap.get(callerId); + if (!callbacks) { + callbacks = { + resolve: new Deferred(), + reject: new Deferred(), + }; + bindingMap.set(callerId, callbacks); + } + return {callbacks, remoteValue: data}; + } + + [Symbol.dispose](): void { + void this[Symbol.asyncDispose]().catch(debugError); + } + + async [Symbol.asyncDispose](): Promise { + if (this.#preloadScriptId) { + await this.#connection.send('script.removePreloadScript', { + script: this.#preloadScriptId, + }); + } + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts new file mode 100644 index 0000000000..1638c2cbdf --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts @@ -0,0 +1,313 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import { + first, + firstValueFrom, + forkJoin, + from, + map, + merge, + raceWith, + zip, +} from '../../third_party/rxjs/rxjs.js'; +import type {CDPSession} from '../api/CDPSession.js'; +import type {ElementHandle} from '../api/ElementHandle.js'; +import { + Frame, + throwIfDetached, + type GoToOptions, + type WaitForOptions, +} from '../api/Frame.js'; +import type {WaitForSelectorOptions} from '../api/Page.js'; +import {UnsupportedOperation} from '../common/Errors.js'; +import type {TimeoutSettings} from '../common/TimeoutSettings.js'; +import type {Awaitable, NodeFor} from '../common/types.js'; +import { + fromEmitterEvent, + NETWORK_IDLE_TIME, + timeout, + UTILITY_WORLD_NAME, +} from '../common/util.js'; +import {Deferred} from '../util/Deferred.js'; +import {disposeSymbol} from '../util/disposable.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; +import {ExposeableFunction} from './ExposedFunction.js'; +import type {BidiHTTPResponse} from './HTTPResponse.js'; +import { + getBiDiLifecycleEvent, + getBiDiReadinessState, + rewriteNavigationError, +} from './lifecycle.js'; +import type {BidiPage} from './Page.js'; +import { + MAIN_SANDBOX, + PUPPETEER_SANDBOX, + Sandbox, + type SandboxChart, +} from './Sandbox.js'; + +/** + * Puppeteer's Frame class could be viewed as a BiDi BrowsingContext implementation + * @internal + */ +export class BidiFrame extends Frame { + #page: BidiPage; + #context: BrowsingContext; + #timeoutSettings: TimeoutSettings; + #abortDeferred = Deferred.create(); + #disposed = false; + sandboxes: SandboxChart; + override _id: string; + + constructor( + page: BidiPage, + context: BrowsingContext, + timeoutSettings: TimeoutSettings, + parentId?: string | null + ) { + super(); + this.#page = page; + this.#context = context; + this.#timeoutSettings = timeoutSettings; + this._id = this.#context.id; + this._parentId = parentId ?? undefined; + + this.sandboxes = { + [MAIN_SANDBOX]: new Sandbox(undefined, this, context, timeoutSettings), + [PUPPETEER_SANDBOX]: new Sandbox( + UTILITY_WORLD_NAME, + this, + context.createRealmForSandbox(), + timeoutSettings + ), + }; + } + + override get client(): CDPSession { + return this.context().cdpSession; + } + + override mainRealm(): Sandbox { + return this.sandboxes[MAIN_SANDBOX]; + } + + override isolatedRealm(): Sandbox { + return this.sandboxes[PUPPETEER_SANDBOX]; + } + + override page(): BidiPage { + return this.#page; + } + + override isOOPFrame(): never { + throw new UnsupportedOperation(); + } + + override url(): string { + return this.#context.url; + } + + override parentFrame(): BidiFrame | null { + return this.#page.frame(this._parentId ?? ''); + } + + override childFrames(): BidiFrame[] { + return this.#page.childFrames(this.#context.id); + } + + @throwIfDetached + override async goto( + url: string, + options: GoToOptions = {} + ): Promise { + const { + waitUntil = 'load', + timeout: ms = this.#timeoutSettings.navigationTimeout(), + } = options; + + const [readiness, networkIdle] = getBiDiReadinessState(waitUntil); + + const result$ = zip( + from( + this.#context.connection.send('browsingContext.navigate', { + context: this.#context.id, + url, + wait: readiness, + }) + ), + ...(networkIdle !== null + ? [ + this.#page.waitForNetworkIdle$({ + timeout: ms, + concurrency: networkIdle === 'networkidle2' ? 2 : 0, + idleTime: NETWORK_IDLE_TIME, + }), + ] + : []) + ).pipe( + map(([{result}]) => { + return result; + }), + raceWith(timeout(ms), from(this.#abortDeferred.valueOrThrow())), + rewriteNavigationError(url, ms) + ); + + const result = await firstValueFrom(result$); + return this.#page.getNavigationResponse(result.navigation); + } + + @throwIfDetached + override async setContent( + html: string, + options: WaitForOptions = {} + ): Promise { + const { + waitUntil = 'load', + timeout: ms = this.#timeoutSettings.navigationTimeout(), + } = options; + + const [waitEvent, networkIdle] = getBiDiLifecycleEvent(waitUntil); + + const result$ = zip( + forkJoin([ + fromEmitterEvent(this.#context, waitEvent).pipe(first()), + from(this.setFrameContent(html)), + ]).pipe( + map(() => { + return null; + }) + ), + ...(networkIdle !== null + ? [ + this.#page.waitForNetworkIdle$({ + timeout: ms, + concurrency: networkIdle === 'networkidle2' ? 2 : 0, + idleTime: NETWORK_IDLE_TIME, + }), + ] + : []) + ).pipe( + raceWith(timeout(ms), from(this.#abortDeferred.valueOrThrow())), + rewriteNavigationError('setContent', ms) + ); + + await firstValueFrom(result$); + } + + context(): BrowsingContext { + return this.#context; + } + + @throwIfDetached + override async waitForNavigation( + options: WaitForOptions = {} + ): Promise { + const { + waitUntil = 'load', + timeout: ms = this.#timeoutSettings.navigationTimeout(), + } = options; + + const [waitUntilEvent, networkIdle] = getBiDiLifecycleEvent(waitUntil); + + const navigation$ = merge( + forkJoin([ + fromEmitterEvent( + this.#context, + Bidi.ChromiumBidi.BrowsingContext.EventNames.NavigationStarted + ).pipe(first()), + fromEmitterEvent(this.#context, waitUntilEvent).pipe(first()), + ]), + fromEmitterEvent( + this.#context, + Bidi.ChromiumBidi.BrowsingContext.EventNames.FragmentNavigated + ) + ).pipe( + map(result => { + if (Array.isArray(result)) { + return {result: result[1]}; + } + return {result}; + }) + ); + + const result$ = zip( + navigation$, + ...(networkIdle !== null + ? [ + this.#page.waitForNetworkIdle$({ + timeout: ms, + concurrency: networkIdle === 'networkidle2' ? 2 : 0, + idleTime: NETWORK_IDLE_TIME, + }), + ] + : []) + ).pipe( + map(([{result}]) => { + return result; + }), + raceWith(timeout(ms), from(this.#abortDeferred.valueOrThrow())) + ); + + const result = await firstValueFrom(result$); + return this.#page.getNavigationResponse(result.navigation); + } + + override waitForDevicePrompt(): never { + throw new UnsupportedOperation(); + } + + override get detached(): boolean { + return this.#disposed; + } + + [disposeSymbol](): void { + if (this.#disposed) { + return; + } + this.#disposed = true; + this.#abortDeferred.reject(new Error('Frame detached')); + this.#context.dispose(); + this.sandboxes[MAIN_SANDBOX][disposeSymbol](); + this.sandboxes[PUPPETEER_SANDBOX][disposeSymbol](); + } + + #exposedFunctions = new Map>(); + async exposeFunction( + name: string, + apply: (...args: Args) => Awaitable + ): Promise { + if (this.#exposedFunctions.has(name)) { + throw new Error( + `Failed to add page binding with name ${name}: globalThis['${name}'] already exists!` + ); + } + const exposeable = new ExposeableFunction(this, name, apply); + this.#exposedFunctions.set(name, exposeable); + try { + await exposeable.expose(); + } catch (error) { + this.#exposedFunctions.delete(name); + throw error; + } + } + + override waitForSelector( + selector: Selector, + options?: WaitForSelectorOptions + ): Promise> | null> { + if (selector.startsWith('aria')) { + throw new UnsupportedOperation( + 'ARIA selector is not supported for BiDi!' + ); + } + + return super.waitForSelector(selector, options); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPRequest.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPRequest.ts new file mode 100644 index 0000000000..57cb801b8c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPRequest.ts @@ -0,0 +1,163 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import type {Frame} from '../api/Frame.js'; +import type { + ContinueRequestOverrides, + ResponseForRequest, +} from '../api/HTTPRequest.js'; +import {HTTPRequest, type ResourceType} from '../api/HTTPRequest.js'; +import {UnsupportedOperation} from '../common/Errors.js'; + +import type {BidiHTTPResponse} from './HTTPResponse.js'; + +/** + * @internal + */ +export class BidiHTTPRequest extends HTTPRequest { + override _response: BidiHTTPResponse | null = null; + override _redirectChain: BidiHTTPRequest[]; + _navigationId: string | null; + + #url: string; + #resourceType: ResourceType; + + #method: string; + #postData?: string; + #headers: Record = {}; + #initiator: Bidi.Network.Initiator; + #frame: Frame | null; + + constructor( + event: Bidi.Network.BeforeRequestSentParameters, + frame: Frame | null, + redirectChain: BidiHTTPRequest[] = [] + ) { + super(); + + this.#url = event.request.url; + this.#resourceType = event.initiator.type.toLowerCase() as ResourceType; + this.#method = event.request.method; + this.#postData = undefined; + this.#initiator = event.initiator; + this.#frame = frame; + + this._requestId = event.request.request; + this._redirectChain = redirectChain; + this._navigationId = event.navigation; + + for (const header of event.request.headers) { + // TODO: How to handle Binary Headers + // https://w3c.github.io/webdriver-bidi/#type-network-Header + if (header.value.type === 'string') { + this.#headers[header.name.toLowerCase()] = header.value.value; + } + } + } + + override get client(): never { + throw new UnsupportedOperation(); + } + + override url(): string { + return this.#url; + } + + override resourceType(): ResourceType { + return this.#resourceType; + } + + override method(): string { + return this.#method; + } + + override postData(): string | undefined { + return this.#postData; + } + + override hasPostData(): boolean { + return this.#postData !== undefined; + } + + override async fetchPostData(): Promise { + return this.#postData; + } + + override headers(): Record { + return this.#headers; + } + + override response(): BidiHTTPResponse | null { + return this._response; + } + + override isNavigationRequest(): boolean { + return Boolean(this._navigationId); + } + + override initiator(): Bidi.Network.Initiator { + return this.#initiator; + } + + override redirectChain(): BidiHTTPRequest[] { + return this._redirectChain.slice(); + } + + override enqueueInterceptAction( + pendingHandler: () => void | PromiseLike + ): void { + // Execute the handler when interception is not supported + void pendingHandler(); + } + + override frame(): Frame | null { + return this.#frame; + } + + override continueRequestOverrides(): never { + throw new UnsupportedOperation(); + } + + override continue(_overrides: ContinueRequestOverrides = {}): never { + throw new UnsupportedOperation(); + } + + override responseForRequest(): never { + throw new UnsupportedOperation(); + } + + override abortErrorReason(): never { + throw new UnsupportedOperation(); + } + + override interceptResolutionState(): never { + throw new UnsupportedOperation(); + } + + override isInterceptResolutionHandled(): never { + throw new UnsupportedOperation(); + } + + override finalizeInterceptions(): never { + throw new UnsupportedOperation(); + } + + override abort(): never { + throw new UnsupportedOperation(); + } + + override respond( + _response: Partial, + _priority?: number + ): never { + throw new UnsupportedOperation(); + } + + override failure(): never { + throw new UnsupportedOperation(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPResponse.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPResponse.ts new file mode 100644 index 0000000000..ce28820a65 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPResponse.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; +import type Protocol from 'devtools-protocol'; + +import type {Frame} from '../api/Frame.js'; +import { + HTTPResponse as HTTPResponse, + type RemoteAddress, +} from '../api/HTTPResponse.js'; +import {UnsupportedOperation} from '../common/Errors.js'; + +import type {BidiHTTPRequest} from './HTTPRequest.js'; + +/** + * @internal + */ +export class BidiHTTPResponse extends HTTPResponse { + #request: BidiHTTPRequest; + #remoteAddress: RemoteAddress; + #status: number; + #statusText: string; + #url: string; + #fromCache: boolean; + #headers: Record = {}; + #timings: Record | null; + + constructor( + request: BidiHTTPRequest, + {response}: Bidi.Network.ResponseCompletedParameters + ) { + super(); + this.#request = request; + + this.#remoteAddress = { + ip: '', + port: -1, + }; + + this.#url = response.url; + this.#fromCache = response.fromCache; + this.#status = response.status; + this.#statusText = response.statusText; + // TODO: File and issue with BiDi spec + this.#timings = null; + + // TODO: Removed once the Firefox implementation is compliant with https://w3c.github.io/webdriver-bidi/#get-the-response-data. + for (const header of response.headers || []) { + // TODO: How to handle Binary Headers + // https://w3c.github.io/webdriver-bidi/#type-network-Header + if (header.value.type === 'string') { + this.#headers[header.name.toLowerCase()] = header.value.value; + } + } + } + + override remoteAddress(): RemoteAddress { + return this.#remoteAddress; + } + + override url(): string { + return this.#url; + } + + override status(): number { + return this.#status; + } + + override statusText(): string { + return this.#statusText; + } + + override headers(): Record { + return this.#headers; + } + + override request(): BidiHTTPRequest { + return this.#request; + } + + override fromCache(): boolean { + return this.#fromCache; + } + + override timing(): Protocol.Network.ResourceTiming | null { + return this.#timings as any; + } + + override frame(): Frame | null { + return this.#request.frame(); + } + + override fromServiceWorker(): boolean { + return false; + } + + override securityDetails(): never { + throw new UnsupportedOperation(); + } + + override buffer(): never { + throw new UnsupportedOperation(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Input.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Input.ts new file mode 100644 index 0000000000..5406556d64 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Input.ts @@ -0,0 +1,732 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import type {Point} from '../api/ElementHandle.js'; +import { + Keyboard, + Mouse, + MouseButton, + Touchscreen, + type KeyDownOptions, + type KeyPressOptions, + type KeyboardTypeOptions, + type MouseClickOptions, + type MouseMoveOptions, + type MouseOptions, + type MouseWheelOptions, +} from '../api/Input.js'; +import {UnsupportedOperation} from '../common/Errors.js'; +import type {KeyInput} from '../common/USKeyboardLayout.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; +import type {BidiPage} from './Page.js'; + +const enum InputId { + Mouse = '__puppeteer_mouse', + Keyboard = '__puppeteer_keyboard', + Wheel = '__puppeteer_wheel', + Finger = '__puppeteer_finger', +} + +enum SourceActionsType { + None = 'none', + Key = 'key', + Pointer = 'pointer', + Wheel = 'wheel', +} + +enum ActionType { + Pause = 'pause', + KeyDown = 'keyDown', + KeyUp = 'keyUp', + PointerUp = 'pointerUp', + PointerDown = 'pointerDown', + PointerMove = 'pointerMove', + Scroll = 'scroll', +} + +const getBidiKeyValue = (key: KeyInput) => { + switch (key) { + case '\r': + case '\n': + key = 'Enter'; + break; + } + // Measures the number of code points rather than UTF-16 code units. + if ([...key].length === 1) { + return key; + } + switch (key) { + case 'Cancel': + return '\uE001'; + case 'Help': + return '\uE002'; + case 'Backspace': + return '\uE003'; + case 'Tab': + return '\uE004'; + case 'Clear': + return '\uE005'; + case 'Enter': + return '\uE007'; + case 'Shift': + case 'ShiftLeft': + return '\uE008'; + case 'Control': + case 'ControlLeft': + return '\uE009'; + case 'Alt': + case 'AltLeft': + return '\uE00A'; + case 'Pause': + return '\uE00B'; + case 'Escape': + return '\uE00C'; + case 'PageUp': + return '\uE00E'; + case 'PageDown': + return '\uE00F'; + case 'End': + return '\uE010'; + case 'Home': + return '\uE011'; + case 'ArrowLeft': + return '\uE012'; + case 'ArrowUp': + return '\uE013'; + case 'ArrowRight': + return '\uE014'; + case 'ArrowDown': + return '\uE015'; + case 'Insert': + return '\uE016'; + case 'Delete': + return '\uE017'; + case 'NumpadEqual': + return '\uE019'; + case 'Numpad0': + return '\uE01A'; + case 'Numpad1': + return '\uE01B'; + case 'Numpad2': + return '\uE01C'; + case 'Numpad3': + return '\uE01D'; + case 'Numpad4': + return '\uE01E'; + case 'Numpad5': + return '\uE01F'; + case 'Numpad6': + return '\uE020'; + case 'Numpad7': + return '\uE021'; + case 'Numpad8': + return '\uE022'; + case 'Numpad9': + return '\uE023'; + case 'NumpadMultiply': + return '\uE024'; + case 'NumpadAdd': + return '\uE025'; + case 'NumpadSubtract': + return '\uE027'; + case 'NumpadDecimal': + return '\uE028'; + case 'NumpadDivide': + return '\uE029'; + case 'F1': + return '\uE031'; + case 'F2': + return '\uE032'; + case 'F3': + return '\uE033'; + case 'F4': + return '\uE034'; + case 'F5': + return '\uE035'; + case 'F6': + return '\uE036'; + case 'F7': + return '\uE037'; + case 'F8': + return '\uE038'; + case 'F9': + return '\uE039'; + case 'F10': + return '\uE03A'; + case 'F11': + return '\uE03B'; + case 'F12': + return '\uE03C'; + case 'Meta': + case 'MetaLeft': + return '\uE03D'; + case 'ShiftRight': + return '\uE050'; + case 'ControlRight': + return '\uE051'; + case 'AltRight': + return '\uE052'; + case 'MetaRight': + return '\uE053'; + case 'Digit0': + return '0'; + case 'Digit1': + return '1'; + case 'Digit2': + return '2'; + case 'Digit3': + return '3'; + case 'Digit4': + return '4'; + case 'Digit5': + return '5'; + case 'Digit6': + return '6'; + case 'Digit7': + return '7'; + case 'Digit8': + return '8'; + case 'Digit9': + return '9'; + case 'KeyA': + return 'a'; + case 'KeyB': + return 'b'; + case 'KeyC': + return 'c'; + case 'KeyD': + return 'd'; + case 'KeyE': + return 'e'; + case 'KeyF': + return 'f'; + case 'KeyG': + return 'g'; + case 'KeyH': + return 'h'; + case 'KeyI': + return 'i'; + case 'KeyJ': + return 'j'; + case 'KeyK': + return 'k'; + case 'KeyL': + return 'l'; + case 'KeyM': + return 'm'; + case 'KeyN': + return 'n'; + case 'KeyO': + return 'o'; + case 'KeyP': + return 'p'; + case 'KeyQ': + return 'q'; + case 'KeyR': + return 'r'; + case 'KeyS': + return 's'; + case 'KeyT': + return 't'; + case 'KeyU': + return 'u'; + case 'KeyV': + return 'v'; + case 'KeyW': + return 'w'; + case 'KeyX': + return 'x'; + case 'KeyY': + return 'y'; + case 'KeyZ': + return 'z'; + case 'Semicolon': + return ';'; + case 'Equal': + return '='; + case 'Comma': + return ','; + case 'Minus': + return '-'; + case 'Period': + return '.'; + case 'Slash': + return '/'; + case 'Backquote': + return '`'; + case 'BracketLeft': + return '['; + case 'Backslash': + return '\\'; + case 'BracketRight': + return ']'; + case 'Quote': + return '"'; + default: + throw new Error(`Unknown key: "${key}"`); + } +}; + +/** + * @internal + */ +export class BidiKeyboard extends Keyboard { + #page: BidiPage; + + constructor(page: BidiPage) { + super(); + this.#page = page; + } + + override async down( + key: KeyInput, + _options?: Readonly + ): Promise { + await this.#page.connection.send('input.performActions', { + context: this.#page.mainFrame()._id, + actions: [ + { + type: SourceActionsType.Key, + id: InputId.Keyboard, + actions: [ + { + type: ActionType.KeyDown, + value: getBidiKeyValue(key), + }, + ], + }, + ], + }); + } + + override async up(key: KeyInput): Promise { + await this.#page.connection.send('input.performActions', { + context: this.#page.mainFrame()._id, + actions: [ + { + type: SourceActionsType.Key, + id: InputId.Keyboard, + actions: [ + { + type: ActionType.KeyUp, + value: getBidiKeyValue(key), + }, + ], + }, + ], + }); + } + + override async press( + key: KeyInput, + options: Readonly = {} + ): Promise { + const {delay = 0} = options; + const actions: Bidi.Input.KeySourceAction[] = [ + { + type: ActionType.KeyDown, + value: getBidiKeyValue(key), + }, + ]; + if (delay > 0) { + actions.push({ + type: ActionType.Pause, + duration: delay, + }); + } + actions.push({ + type: ActionType.KeyUp, + value: getBidiKeyValue(key), + }); + await this.#page.connection.send('input.performActions', { + context: this.#page.mainFrame()._id, + actions: [ + { + type: SourceActionsType.Key, + id: InputId.Keyboard, + actions, + }, + ], + }); + } + + override async type( + text: string, + options: Readonly = {} + ): Promise { + const {delay = 0} = options; + // This spread separates the characters into code points rather than UTF-16 + // code units. + const values = ([...text] as KeyInput[]).map(getBidiKeyValue); + const actions: Bidi.Input.KeySourceAction[] = []; + if (delay <= 0) { + for (const value of values) { + actions.push( + { + type: ActionType.KeyDown, + value, + }, + { + type: ActionType.KeyUp, + value, + } + ); + } + } else { + for (const value of values) { + actions.push( + { + type: ActionType.KeyDown, + value, + }, + { + type: ActionType.Pause, + duration: delay, + }, + { + type: ActionType.KeyUp, + value, + } + ); + } + } + await this.#page.connection.send('input.performActions', { + context: this.#page.mainFrame()._id, + actions: [ + { + type: SourceActionsType.Key, + id: InputId.Keyboard, + actions, + }, + ], + }); + } + + override async sendCharacter(char: string): Promise { + // Measures the number of code points rather than UTF-16 code units. + if ([...char].length > 1) { + throw new Error('Cannot send more than 1 character.'); + } + const frame = await this.#page.focusedFrame(); + await frame.isolatedRealm().evaluate(async char => { + document.execCommand('insertText', false, char); + }, char); + } +} + +/** + * @internal + */ +export interface BidiMouseClickOptions extends MouseClickOptions { + origin?: Bidi.Input.Origin; +} + +/** + * @internal + */ +export interface BidiMouseMoveOptions extends MouseMoveOptions { + origin?: Bidi.Input.Origin; +} + +/** + * @internal + */ +export interface BidiTouchMoveOptions { + origin?: Bidi.Input.Origin; +} + +const getBidiButton = (button: MouseButton) => { + switch (button) { + case MouseButton.Left: + return 0; + case MouseButton.Middle: + return 1; + case MouseButton.Right: + return 2; + case MouseButton.Back: + return 3; + case MouseButton.Forward: + return 4; + } +}; + +/** + * @internal + */ +export class BidiMouse extends Mouse { + #context: BrowsingContext; + #lastMovePoint: Point = {x: 0, y: 0}; + + constructor(context: BrowsingContext) { + super(); + this.#context = context; + } + + override async reset(): Promise { + this.#lastMovePoint = {x: 0, y: 0}; + await this.#context.connection.send('input.releaseActions', { + context: this.#context.id, + }); + } + + override async move( + x: number, + y: number, + options: Readonly = {} + ): Promise { + const from = this.#lastMovePoint; + const to = { + x: Math.round(x), + y: Math.round(y), + }; + const actions: Bidi.Input.PointerSourceAction[] = []; + const steps = options.steps ?? 0; + for (let i = 0; i < steps; ++i) { + actions.push({ + type: ActionType.PointerMove, + x: from.x + (to.x - from.x) * (i / steps), + y: from.y + (to.y - from.y) * (i / steps), + origin: options.origin, + }); + } + actions.push({ + type: ActionType.PointerMove, + ...to, + origin: options.origin, + }); + // https://w3c.github.io/webdriver-bidi/#command-input-performActions:~:text=input.PointerMoveAction%20%3D%20%7B%0A%20%20type%3A%20%22pointerMove%22%2C%0A%20%20x%3A%20js%2Dint%2C + this.#lastMovePoint = to; + await this.#context.connection.send('input.performActions', { + context: this.#context.id, + actions: [ + { + type: SourceActionsType.Pointer, + id: InputId.Mouse, + actions, + }, + ], + }); + } + + override async down(options: Readonly = {}): Promise { + await this.#context.connection.send('input.performActions', { + context: this.#context.id, + actions: [ + { + type: SourceActionsType.Pointer, + id: InputId.Mouse, + actions: [ + { + type: ActionType.PointerDown, + button: getBidiButton(options.button ?? MouseButton.Left), + }, + ], + }, + ], + }); + } + + override async up(options: Readonly = {}): Promise { + await this.#context.connection.send('input.performActions', { + context: this.#context.id, + actions: [ + { + type: SourceActionsType.Pointer, + id: InputId.Mouse, + actions: [ + { + type: ActionType.PointerUp, + button: getBidiButton(options.button ?? MouseButton.Left), + }, + ], + }, + ], + }); + } + + override async click( + x: number, + y: number, + options: Readonly = {} + ): Promise { + const actions: Bidi.Input.PointerSourceAction[] = [ + { + type: ActionType.PointerMove, + x: Math.round(x), + y: Math.round(y), + origin: options.origin, + }, + ]; + const pointerDownAction = { + type: ActionType.PointerDown, + button: getBidiButton(options.button ?? MouseButton.Left), + } as const; + const pointerUpAction = { + type: ActionType.PointerUp, + button: pointerDownAction.button, + } as const; + for (let i = 1; i < (options.count ?? 1); ++i) { + actions.push(pointerDownAction, pointerUpAction); + } + actions.push(pointerDownAction); + if (options.delay) { + actions.push({ + type: ActionType.Pause, + duration: options.delay, + }); + } + actions.push(pointerUpAction); + await this.#context.connection.send('input.performActions', { + context: this.#context.id, + actions: [ + { + type: SourceActionsType.Pointer, + id: InputId.Mouse, + actions, + }, + ], + }); + } + + override async wheel( + options: Readonly = {} + ): Promise { + await this.#context.connection.send('input.performActions', { + context: this.#context.id, + actions: [ + { + type: SourceActionsType.Wheel, + id: InputId.Wheel, + actions: [ + { + type: ActionType.Scroll, + ...(this.#lastMovePoint ?? { + x: 0, + y: 0, + }), + deltaX: options.deltaX ?? 0, + deltaY: options.deltaY ?? 0, + }, + ], + }, + ], + }); + } + + override drag(): never { + throw new UnsupportedOperation(); + } + + override dragOver(): never { + throw new UnsupportedOperation(); + } + + override dragEnter(): never { + throw new UnsupportedOperation(); + } + + override drop(): never { + throw new UnsupportedOperation(); + } + + override dragAndDrop(): never { + throw new UnsupportedOperation(); + } +} + +/** + * @internal + */ +export class BidiTouchscreen extends Touchscreen { + #context: BrowsingContext; + + constructor(context: BrowsingContext) { + super(); + this.#context = context; + } + + override async touchStart( + x: number, + y: number, + options: BidiTouchMoveOptions = {} + ): Promise { + await this.#context.connection.send('input.performActions', { + context: this.#context.id, + actions: [ + { + type: SourceActionsType.Pointer, + id: InputId.Finger, + parameters: { + pointerType: Bidi.Input.PointerType.Touch, + }, + actions: [ + { + type: ActionType.PointerMove, + x: Math.round(x), + y: Math.round(y), + origin: options.origin, + }, + { + type: ActionType.PointerDown, + button: 0, + }, + ], + }, + ], + }); + } + + override async touchMove( + x: number, + y: number, + options: BidiTouchMoveOptions = {} + ): Promise { + await this.#context.connection.send('input.performActions', { + context: this.#context.id, + actions: [ + { + type: SourceActionsType.Pointer, + id: InputId.Finger, + parameters: { + pointerType: Bidi.Input.PointerType.Touch, + }, + actions: [ + { + type: ActionType.PointerMove, + x: Math.round(x), + y: Math.round(y), + origin: options.origin, + }, + ], + }, + ], + }); + } + + override async touchEnd(): Promise { + await this.#context.connection.send('input.performActions', { + context: this.#context.id, + actions: [ + { + type: SourceActionsType.Pointer, + id: InputId.Finger, + parameters: { + pointerType: Bidi.Input.PointerType.Touch, + }, + actions: [ + { + type: ActionType.PointerUp, + button: 0, + }, + ], + }, + ], + }); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/JSHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/JSHandle.ts new file mode 100644 index 0000000000..7104601553 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/JSHandle.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import type {ElementHandle} from '../api/ElementHandle.js'; +import {JSHandle} from '../api/JSHandle.js'; +import {UnsupportedOperation} from '../common/Errors.js'; + +import {BidiDeserializer} from './Deserializer.js'; +import type {BidiRealm} from './Realm.js'; +import type {Sandbox} from './Sandbox.js'; +import {releaseReference} from './util.js'; + +/** + * @internal + */ +export class BidiJSHandle extends JSHandle { + #disposed = false; + readonly #sandbox: Sandbox; + readonly #remoteValue: Bidi.Script.RemoteValue; + + constructor(sandbox: Sandbox, remoteValue: Bidi.Script.RemoteValue) { + super(); + this.#sandbox = sandbox; + this.#remoteValue = remoteValue; + } + + context(): BidiRealm { + return this.realm.environment.context(); + } + + override get realm(): Sandbox { + return this.#sandbox; + } + + override get disposed(): boolean { + return this.#disposed; + } + + override async jsonValue(): Promise { + return await this.evaluate(value => { + return value; + }); + } + + override asElement(): ElementHandle | null { + return null; + } + + override async dispose(): Promise { + if (this.#disposed) { + return; + } + this.#disposed = true; + if ('handle' in this.#remoteValue) { + await releaseReference( + this.context(), + this.#remoteValue as Bidi.Script.RemoteReference + ); + } + } + + get isPrimitiveValue(): boolean { + switch (this.#remoteValue.type) { + case 'string': + case 'number': + case 'bigint': + case 'boolean': + case 'undefined': + case 'null': + return true; + + default: + return false; + } + } + + override toString(): string { + if (this.isPrimitiveValue) { + return 'JSHandle:' + BidiDeserializer.deserialize(this.#remoteValue); + } + + return 'JSHandle@' + this.#remoteValue.type; + } + + override get id(): string | undefined { + return 'handle' in this.#remoteValue ? this.#remoteValue.handle : undefined; + } + + remoteValue(): Bidi.Script.RemoteValue { + return this.#remoteValue; + } + + override remoteObject(): never { + throw new UnsupportedOperation('Not available in WebDriver BiDi'); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/NetworkManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/NetworkManager.ts new file mode 100644 index 0000000000..2caaf0ad50 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/NetworkManager.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter, EventSubscription} from '../common/EventEmitter.js'; +import { + NetworkManagerEvent, + type NetworkManagerEvents, +} from '../common/NetworkManagerEvents.js'; +import {DisposableStack} from '../util/disposable.js'; + +import type {BidiConnection} from './Connection.js'; +import type {BidiFrame} from './Frame.js'; +import {BidiHTTPRequest} from './HTTPRequest.js'; +import {BidiHTTPResponse} from './HTTPResponse.js'; +import type {BidiPage} from './Page.js'; + +/** + * @internal + */ +export class BidiNetworkManager extends EventEmitter { + #connection: BidiConnection; + #page: BidiPage; + #subscriptions = new DisposableStack(); + + #requestMap = new Map(); + #navigationMap = new Map(); + + constructor(connection: BidiConnection, page: BidiPage) { + super(); + this.#connection = connection; + this.#page = page; + + // TODO: Subscribe to the Frame individually + this.#subscriptions.use( + new EventSubscription( + this.#connection, + 'network.beforeRequestSent', + this.#onBeforeRequestSent.bind(this) + ) + ); + this.#subscriptions.use( + new EventSubscription( + this.#connection, + 'network.responseStarted', + this.#onResponseStarted.bind(this) + ) + ); + this.#subscriptions.use( + new EventSubscription( + this.#connection, + 'network.responseCompleted', + this.#onResponseCompleted.bind(this) + ) + ); + this.#subscriptions.use( + new EventSubscription( + this.#connection, + 'network.fetchError', + this.#onFetchError.bind(this) + ) + ); + } + + #onBeforeRequestSent(event: Bidi.Network.BeforeRequestSentParameters): void { + const frame = this.#page.frame(event.context ?? ''); + if (!frame) { + return; + } + const request = this.#requestMap.get(event.request.request); + let upsertRequest: BidiHTTPRequest; + if (request) { + request._redirectChain.push(request); + upsertRequest = new BidiHTTPRequest(event, frame, request._redirectChain); + } else { + upsertRequest = new BidiHTTPRequest(event, frame, []); + } + this.#requestMap.set(event.request.request, upsertRequest); + this.emit(NetworkManagerEvent.Request, upsertRequest); + } + + #onResponseStarted(_event: Bidi.Network.ResponseStartedParameters) {} + + #onResponseCompleted(event: Bidi.Network.ResponseCompletedParameters): void { + const request = this.#requestMap.get(event.request.request); + if (!request) { + return; + } + const response = new BidiHTTPResponse(request, event); + request._response = response; + if (event.navigation) { + this.#navigationMap.set(event.navigation, response); + } + if (response.fromCache()) { + this.emit(NetworkManagerEvent.RequestServedFromCache, request); + } + this.emit(NetworkManagerEvent.Response, response); + this.emit(NetworkManagerEvent.RequestFinished, request); + } + + #onFetchError(event: Bidi.Network.FetchErrorParameters) { + const request = this.#requestMap.get(event.request.request); + if (!request) { + return; + } + request._failureText = event.errorText; + this.emit(NetworkManagerEvent.RequestFailed, request); + this.#requestMap.delete(event.request.request); + } + + getNavigationResponse(navigationId?: string | null): BidiHTTPResponse | null { + if (!navigationId) { + return null; + } + const response = this.#navigationMap.get(navigationId); + + return response ?? null; + } + + inFlightRequestsCount(): number { + let inFlightRequestCounter = 0; + for (const request of this.#requestMap.values()) { + if (!request.response() || request._failureText) { + inFlightRequestCounter++; + } + } + + return inFlightRequestCounter; + } + + clearMapAfterFrameDispose(frame: BidiFrame): void { + for (const [id, request] of this.#requestMap.entries()) { + if (request.frame() === frame) { + this.#requestMap.delete(id); + } + } + + for (const [id, response] of this.#navigationMap.entries()) { + if (response.frame() === frame) { + this.#navigationMap.delete(id); + } + } + } + + dispose(): void { + this.removeAllListeners(); + this.#requestMap.clear(); + this.#navigationMap.clear(); + this.#subscriptions.dispose(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Page.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Page.ts new file mode 100644 index 0000000000..053d23b63a --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Page.ts @@ -0,0 +1,913 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Readable} from 'stream'; + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; +import type Protocol from 'devtools-protocol'; + +import { + firstValueFrom, + from, + map, + raceWith, + zip, +} from '../../third_party/rxjs/rxjs.js'; +import type {CDPSession} from '../api/CDPSession.js'; +import type {BoundingBox} from '../api/ElementHandle.js'; +import type {WaitForOptions} from '../api/Frame.js'; +import type {HTTPResponse} from '../api/HTTPResponse.js'; +import { + Page, + PageEvent, + type GeolocationOptions, + type MediaFeature, + type NewDocumentScriptEvaluation, + type ScreenshotOptions, +} from '../api/Page.js'; +import {Accessibility} from '../cdp/Accessibility.js'; +import {Coverage} from '../cdp/Coverage.js'; +import {EmulationManager as CdpEmulationManager} from '../cdp/EmulationManager.js'; +import {FrameTree} from '../cdp/FrameTree.js'; +import {Tracing} from '../cdp/Tracing.js'; +import { + ConsoleMessage, + type ConsoleMessageLocation, +} from '../common/ConsoleMessage.js'; +import {TargetCloseError, UnsupportedOperation} from '../common/Errors.js'; +import type {Handler} from '../common/EventEmitter.js'; +import {NetworkManagerEvent} from '../common/NetworkManagerEvents.js'; +import type {PDFOptions} from '../common/PDFOptions.js'; +import type {Awaitable} from '../common/types.js'; +import { + debugError, + evaluationString, + NETWORK_IDLE_TIME, + 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 {disposeSymbol} from '../util/disposable.js'; +import {isErrorLike} from '../util/ErrorLike.js'; + +import type {BidiBrowser} from './Browser.js'; +import type {BidiBrowserContext} from './BrowserContext.js'; +import { + BrowsingContextEvent, + CdpSessionWrapper, + type BrowsingContext, +} from './BrowsingContext.js'; +import type {BidiConnection} from './Connection.js'; +import {BidiDeserializer} from './Deserializer.js'; +import {BidiDialog} from './Dialog.js'; +import {BidiElementHandle} from './ElementHandle.js'; +import {EmulationManager} from './EmulationManager.js'; +import {BidiFrame} from './Frame.js'; +import type {BidiHTTPRequest} from './HTTPRequest.js'; +import type {BidiHTTPResponse} from './HTTPResponse.js'; +import {BidiKeyboard, BidiMouse, BidiTouchscreen} from './Input.js'; +import type {BidiJSHandle} from './JSHandle.js'; +import {getBiDiReadinessState, rewriteNavigationError} from './lifecycle.js'; +import {BidiNetworkManager} from './NetworkManager.js'; +import {createBidiHandle} from './Realm.js'; +import type {BiDiPageTarget} from './Target.js'; + +/** + * @internal + */ +export class BidiPage extends Page { + #accessibility: Accessibility; + #connection: BidiConnection; + #frameTree = new FrameTree(); + #networkManager: BidiNetworkManager; + #viewport: Viewport | null = null; + #closedDeferred = Deferred.create(); + #subscribedEvents = new Map>([ + ['log.entryAdded', this.#onLogEntryAdded.bind(this)], + ['browsingContext.load', this.#onFrameLoaded.bind(this)], + [ + 'browsingContext.fragmentNavigated', + this.#onFrameFragmentNavigated.bind(this), + ], + [ + 'browsingContext.domContentLoaded', + this.#onFrameDOMContentLoaded.bind(this), + ], + ['browsingContext.userPromptOpened', this.#onDialog.bind(this)], + ]); + readonly #networkManagerEvents = [ + [ + NetworkManagerEvent.Request, + (request: BidiHTTPRequest) => { + this.emit(PageEvent.Request, request); + }, + ], + [ + NetworkManagerEvent.RequestServedFromCache, + (request: BidiHTTPRequest) => { + this.emit(PageEvent.RequestServedFromCache, request); + }, + ], + [ + NetworkManagerEvent.RequestFailed, + (request: BidiHTTPRequest) => { + this.emit(PageEvent.RequestFailed, request); + }, + ], + [ + NetworkManagerEvent.RequestFinished, + (request: BidiHTTPRequest) => { + this.emit(PageEvent.RequestFinished, request); + }, + ], + [ + NetworkManagerEvent.Response, + (response: BidiHTTPResponse) => { + this.emit(PageEvent.Response, response); + }, + ], + ] as const; + + readonly #browsingContextEvents = new Map>([ + [BrowsingContextEvent.Created, this.#onContextCreated.bind(this)], + [BrowsingContextEvent.Destroyed, this.#onContextDestroyed.bind(this)], + ]); + #tracing: Tracing; + #coverage: Coverage; + #cdpEmulationManager: CdpEmulationManager; + #emulationManager: EmulationManager; + #mouse: BidiMouse; + #touchscreen: BidiTouchscreen; + #keyboard: BidiKeyboard; + #browsingContext: BrowsingContext; + #browserContext: BidiBrowserContext; + #target: BiDiPageTarget; + + _client(): CDPSession { + return this.mainFrame().context().cdpSession; + } + + constructor( + browsingContext: BrowsingContext, + browserContext: BidiBrowserContext, + target: BiDiPageTarget + ) { + super(); + this.#browsingContext = browsingContext; + this.#browserContext = browserContext; + this.#target = target; + this.#connection = browsingContext.connection; + + for (const [event, subscriber] of this.#browsingContextEvents) { + this.#browsingContext.on(event, subscriber); + } + + this.#networkManager = new BidiNetworkManager(this.#connection, this); + + for (const [event, subscriber] of this.#subscribedEvents) { + this.#connection.on(event, subscriber); + } + + for (const [event, subscriber] of this.#networkManagerEvents) { + // TODO: remove any + this.#networkManager.on(event, subscriber as any); + } + + const frame = new BidiFrame( + this, + this.#browsingContext, + this._timeoutSettings, + this.#browsingContext.parent + ); + this.#frameTree.addFrame(frame); + this.emit(PageEvent.FrameAttached, frame); + + // TODO: https://github.com/w3c/webdriver-bidi/issues/443 + this.#accessibility = new Accessibility( + this.mainFrame().context().cdpSession + ); + this.#tracing = new Tracing(this.mainFrame().context().cdpSession); + this.#coverage = new Coverage(this.mainFrame().context().cdpSession); + this.#cdpEmulationManager = new CdpEmulationManager( + this.mainFrame().context().cdpSession + ); + this.#emulationManager = new EmulationManager(browsingContext); + this.#mouse = new BidiMouse(this.mainFrame().context()); + this.#touchscreen = new BidiTouchscreen(this.mainFrame().context()); + this.#keyboard = new BidiKeyboard(this); + } + + /** + * @internal + */ + get connection(): BidiConnection { + return this.#connection; + } + + override async setUserAgent( + userAgent: string, + userAgentMetadata?: Protocol.Emulation.UserAgentMetadata | undefined + ): Promise { + // TODO: handle CDP-specific cases such as mprach. + await this._client().send('Network.setUserAgentOverride', { + userAgent: userAgent, + userAgentMetadata: userAgentMetadata, + }); + } + + override async setBypassCSP(enabled: boolean): Promise { + // TODO: handle CDP-specific cases such as mprach. + await this._client().send('Page.setBypassCSP', {enabled}); + } + + override async queryObjects( + prototypeHandle: BidiJSHandle + ): Promise> { + 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 createBidiHandle(this.mainFrame().mainRealm(), { + type: 'array', + handle: response.objects.objectId, + }) as BidiJSHandle; + } + + _setBrowserContext(browserContext: BidiBrowserContext): void { + this.#browserContext = browserContext; + } + + override get accessibility(): Accessibility { + return this.#accessibility; + } + + override get tracing(): Tracing { + return this.#tracing; + } + + override get coverage(): Coverage { + return this.#coverage; + } + + override get mouse(): BidiMouse { + return this.#mouse; + } + + override get touchscreen(): BidiTouchscreen { + return this.#touchscreen; + } + + override get keyboard(): BidiKeyboard { + return this.#keyboard; + } + + override browser(): BidiBrowser { + return this.browserContext().browser(); + } + + override browserContext(): BidiBrowserContext { + return this.#browserContext; + } + + override mainFrame(): BidiFrame { + const mainFrame = this.#frameTree.getMainFrame(); + assert(mainFrame, 'Requesting main frame too early!'); + return mainFrame; + } + + /** + * @internal + */ + async focusedFrame(): Promise { + using frame = await this.mainFrame() + .isolatedRealm() + .evaluateHandle(() => { + let frame: HTMLIFrameElement | undefined; + let win: Window | null = window; + while (win?.document.activeElement instanceof HTMLIFrameElement) { + frame = win.document.activeElement; + win = frame.contentWindow; + } + return frame; + }); + if (!(frame instanceof BidiElementHandle)) { + return this.mainFrame(); + } + return await frame.contentFrame(); + } + + override frames(): BidiFrame[] { + return Array.from(this.#frameTree.frames()); + } + + frame(frameId?: string): BidiFrame | null { + return this.#frameTree.getById(frameId ?? '') || null; + } + + childFrames(frameId: string): BidiFrame[] { + return this.#frameTree.childFrames(frameId); + } + + #onFrameLoaded(info: Bidi.BrowsingContext.NavigationInfo): void { + const frame = this.frame(info.context); + if (frame && this.mainFrame() === frame) { + this.emit(PageEvent.Load, undefined); + } + } + + #onFrameFragmentNavigated(info: Bidi.BrowsingContext.NavigationInfo): void { + const frame = this.frame(info.context); + if (frame) { + this.emit(PageEvent.FrameNavigated, frame); + } + } + + #onFrameDOMContentLoaded(info: Bidi.BrowsingContext.NavigationInfo): void { + const frame = this.frame(info.context); + if (frame) { + frame._hasStartedLoading = true; + if (this.mainFrame() === frame) { + this.emit(PageEvent.DOMContentLoaded, undefined); + } + this.emit(PageEvent.FrameNavigated, frame); + } + } + + #onContextCreated(context: BrowsingContext): void { + if ( + !this.frame(context.id) && + (this.frame(context.parent ?? '') || !this.#frameTree.getMainFrame()) + ) { + const frame = new BidiFrame( + this, + context, + this._timeoutSettings, + context.parent + ); + this.#frameTree.addFrame(frame); + if (frame !== this.mainFrame()) { + this.emit(PageEvent.FrameAttached, frame); + } + } + } + + #onContextDestroyed(context: BrowsingContext): void { + const frame = this.frame(context.id); + + if (frame) { + if (frame === this.mainFrame()) { + this.emit(PageEvent.Close, undefined); + } + this.#removeFramesRecursively(frame); + } + } + + #removeFramesRecursively(frame: BidiFrame): void { + for (const child of frame.childFrames()) { + this.#removeFramesRecursively(child); + } + frame[disposeSymbol](); + this.#networkManager.clearMapAfterFrameDispose(frame); + this.#frameTree.removeFrame(frame); + this.emit(PageEvent.FrameDetached, frame); + } + + #onLogEntryAdded(event: Bidi.Log.Entry): void { + const frame = this.frame(event.source.context); + if (!frame) { + return; + } + if (isConsoleLogEntry(event)) { + const args = event.args.map(arg => { + return createBidiHandle(frame.mainRealm(), arg); + }); + + const text = args + .reduce((value, arg) => { + const parsedValue = arg.isPrimitiveValue + ? BidiDeserializer.deserialize(arg.remoteValue()) + : arg.toString(); + return `${value} ${parsedValue}`; + }, '') + .slice(1); + + this.emit( + PageEvent.Console, + new ConsoleMessage( + event.method as any, + text, + args, + getStackTraceLocations(event.stackTrace) + ) + ); + } else if (isJavaScriptLogEntry(event)) { + const error = new Error(event.text ?? ''); + + const messageHeight = error.message.split('\n').length; + const messageLines = error.stack!.split('\n').splice(0, messageHeight); + + const stackLines = []; + if (event.stackTrace) { + for (const frame of event.stackTrace.callFrames) { + // Note we need to add `1` because the values are 0-indexed. + stackLines.push( + ` at ${frame.functionName || ''} (${frame.url}:${ + frame.lineNumber + 1 + }:${frame.columnNumber + 1})` + ); + if (stackLines.length >= Error.stackTraceLimit) { + break; + } + } + } + + error.stack = [...messageLines, ...stackLines].join('\n'); + this.emit(PageEvent.PageError, error); + } else { + debugError( + `Unhandled LogEntry with type "${event.type}", text "${event.text}" and level "${event.level}"` + ); + } + } + + #onDialog(event: Bidi.BrowsingContext.UserPromptOpenedParameters): void { + const frame = this.frame(event.context); + if (!frame) { + return; + } + const type = validateDialogType(event.type); + + const dialog = new BidiDialog( + frame.context(), + type, + event.message, + event.defaultValue + ); + this.emit(PageEvent.Dialog, dialog); + } + + getNavigationResponse(id?: string | null): BidiHTTPResponse | null { + return this.#networkManager.getNavigationResponse(id); + } + + override isClosed(): boolean { + return this.#closedDeferred.finished(); + } + + override async close(options?: {runBeforeUnload?: boolean}): Promise { + if (this.#closedDeferred.finished()) { + return; + } + + this.#closedDeferred.reject(new TargetCloseError('Page closed!')); + this.#networkManager.dispose(); + + await this.#connection.send('browsingContext.close', { + context: this.mainFrame()._id, + promptUnload: options?.runBeforeUnload ?? false, + }); + + this.emit(PageEvent.Close, undefined); + this.removeAllListeners(); + } + + override async reload( + options: WaitForOptions = {} + ): Promise { + const { + waitUntil = 'load', + timeout: ms = this._timeoutSettings.navigationTimeout(), + } = options; + + const [readiness, networkIdle] = getBiDiReadinessState(waitUntil); + + const result$ = zip( + from( + this.#connection.send('browsingContext.reload', { + context: this.mainFrame()._id, + wait: readiness, + }) + ), + ...(networkIdle !== null + ? [ + this.waitForNetworkIdle$({ + timeout: ms, + concurrency: networkIdle === 'networkidle2' ? 2 : 0, + idleTime: NETWORK_IDLE_TIME, + }), + ] + : []) + ).pipe( + map(([{result}]) => { + return result; + }), + raceWith(timeout(ms), from(this.#closedDeferred.valueOrThrow())), + rewriteNavigationError(this.url(), ms) + ); + + const result = await firstValueFrom(result$); + return this.getNavigationResponse(result.navigation); + } + + 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 isJavaScriptEnabled(): boolean { + return this.#cdpEmulationManager.javascriptEnabled; + } + + override async setGeolocation(options: GeolocationOptions): Promise { + return await this.#cdpEmulationManager.setGeolocation(options); + } + + override async setJavaScriptEnabled(enabled: boolean): Promise { + return await this.#cdpEmulationManager.setJavaScriptEnabled(enabled); + } + + override async emulateMediaType(type?: string): Promise { + return await this.#cdpEmulationManager.emulateMediaType(type); + } + + override async emulateCPUThrottling(factor: number | null): Promise { + return await this.#cdpEmulationManager.emulateCPUThrottling(factor); + } + + override async emulateMediaFeatures( + features?: MediaFeature[] + ): Promise { + return await this.#cdpEmulationManager.emulateMediaFeatures(features); + } + + override async emulateTimezone(timezoneId?: string): Promise { + return await this.#cdpEmulationManager.emulateTimezone(timezoneId); + } + + override async emulateIdleState(overrides?: { + isUserActive: boolean; + isScreenUnlocked: boolean; + }): Promise { + return await this.#cdpEmulationManager.emulateIdleState(overrides); + } + + override async emulateVisionDeficiency( + type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'] + ): Promise { + return await this.#cdpEmulationManager.emulateVisionDeficiency(type); + } + + override async setViewport(viewport: Viewport): Promise { + if (!this.#browsingContext.supportsCdp()) { + await this.#emulationManager.emulateViewport(viewport); + this.#viewport = viewport; + return; + } + const needsReload = + await this.#cdpEmulationManager.emulateViewport(viewport); + this.#viewport = viewport; + if (needsReload) { + await this.reload(); + } + } + + override viewport(): Viewport | null { + return this.#viewport; + } + + override async pdf(options: PDFOptions = {}): Promise { + const {timeout: ms = this._timeoutSettings.timeout(), path = undefined} = + options; + const { + printBackground: background, + margin, + landscape, + width, + height, + pageRanges: ranges, + scale, + preferCSSPageSize, + } = parsePDFOptions(options, 'cm'); + const pageRanges = ranges ? ranges.split(', ') : []; + const {result} = await firstValueFrom( + from( + this.#connection.send('browsingContext.print', { + context: this.mainFrame()._id, + background, + margin, + orientation: landscape ? 'landscape' : 'portrait', + page: { + width, + height, + }, + pageRanges, + scale, + shrinkToFit: !preferCSSPageSize, + }) + ).pipe(raceWith(timeout(ms))) + ); + + const buffer = Buffer.from(result.data, 'base64'); + + await this._maybeWriteBufferToFile(path, buffer); + + return buffer; + } + + override async createPDFStream( + options?: PDFOptions | undefined + ): Promise { + const buffer = await this.pdf(options); + try { + const {Readable} = await import('stream'); + return Readable.from(buffer); + } catch (error) { + if (error instanceof TypeError) { + throw new Error( + 'Can only pass a file path in a Node-like environment.' + ); + } + throw error; + } + } + + override async _screenshot( + options: Readonly + ): Promise { + const {clip, type, captureBeyondViewport, quality} = options; + if (options.omitBackground !== undefined && options.omitBackground) { + throw new UnsupportedOperation(`BiDi does not support 'omitBackground'.`); + } + if (options.optimizeForSpeed !== undefined && options.optimizeForSpeed) { + throw new UnsupportedOperation( + `BiDi does not support 'optimizeForSpeed'.` + ); + } + if (options.fromSurface !== undefined && !options.fromSurface) { + throw new UnsupportedOperation(`BiDi does not support 'fromSurface'.`); + } + if (clip !== undefined && clip.scale !== undefined && clip.scale !== 1) { + throw new UnsupportedOperation( + `BiDi does not support 'scale' in 'clip'.` + ); + } + + let box: BoundingBox | undefined; + if (clip) { + if (captureBeyondViewport) { + box = clip; + } else { + // The clip is always with respect to the document coordinates, so we + // need to convert this to viewport coordinates when we aren't capturing + // beyond the viewport. + const [pageLeft, pageTop] = await this.evaluate(() => { + if (!window.visualViewport) { + throw new Error('window.visualViewport is not supported.'); + } + return [ + window.visualViewport.pageLeft, + window.visualViewport.pageTop, + ] as const; + }); + box = { + ...clip, + x: clip.x - pageLeft, + y: clip.y - pageTop, + }; + } + } + + const { + result: {data}, + } = await this.#connection.send('browsingContext.captureScreenshot', { + context: this.mainFrame()._id, + origin: captureBeyondViewport ? 'document' : 'viewport', + format: { + type: `image/${type}`, + ...(quality !== undefined ? {quality: quality / 100} : {}), + }, + ...(box ? {clip: {type: 'box', ...box}} : {}), + }); + return data; + } + + override async createCDPSession(): Promise { + const {sessionId} = await this.mainFrame() + .context() + .cdpSession.send('Target.attachToTarget', { + targetId: this.mainFrame()._id, + flatten: true, + }); + return new CdpSessionWrapper(this.mainFrame().context(), sessionId); + } + + override async bringToFront(): Promise { + await this.#connection.send('browsingContext.activate', { + context: this.mainFrame()._id, + }); + } + + override async evaluateOnNewDocument< + Params extends unknown[], + Func extends (...args: Params) => unknown = (...args: Params) => unknown, + >( + pageFunction: Func | string, + ...args: Params + ): Promise { + const expression = evaluationExpression(pageFunction, ...args); + const {result} = await this.#connection.send('script.addPreloadScript', { + functionDeclaration: expression, + contexts: [this.mainFrame()._id], + }); + + return {identifier: result.script}; + } + + override async removeScriptToEvaluateOnNewDocument( + id: string + ): Promise { + await this.#connection.send('script.removePreloadScript', { + script: id, + }); + } + + override async exposeFunction( + name: string, + pptrFunction: + | ((...args: Args) => Awaitable) + | {default: (...args: Args) => Awaitable} + ): Promise { + return await this.mainFrame().exposeFunction( + name, + 'default' in pptrFunction ? pptrFunction.default : pptrFunction + ); + } + + override isDragInterceptionEnabled(): boolean { + return false; + } + + override async setCacheEnabled(enabled?: boolean): Promise { + // TODO: handle CDP-specific cases such as mprach. + await this._client().send('Network.setCacheDisabled', { + cacheDisabled: !enabled, + }); + } + + override isServiceWorkerBypassed(): never { + throw new UnsupportedOperation(); + } + + override target(): BiDiPageTarget { + return this.#target; + } + + override waitForFileChooser(): never { + throw new UnsupportedOperation(); + } + + override workers(): never { + throw new UnsupportedOperation(); + } + + override setRequestInterception(): never { + throw new UnsupportedOperation(); + } + + override setDragInterception(): never { + throw new UnsupportedOperation(); + } + + override setBypassServiceWorker(): never { + throw new UnsupportedOperation(); + } + + override setOfflineMode(): never { + throw new UnsupportedOperation(); + } + + override emulateNetworkConditions(): never { + throw new UnsupportedOperation(); + } + + override cookies(): never { + throw new UnsupportedOperation(); + } + + override setCookie(): never { + throw new UnsupportedOperation(); + } + + override deleteCookie(): never { + throw new UnsupportedOperation(); + } + + override removeExposedFunction(): never { + // TODO: Quick win? + throw new UnsupportedOperation(); + } + + override authenticate(): never { + throw new UnsupportedOperation(); + } + + override setExtraHTTPHeaders(): never { + throw new UnsupportedOperation(); + } + + override metrics(): never { + throw new UnsupportedOperation(); + } + + override async goBack( + options: WaitForOptions = {} + ): Promise { + return await this.#go(-1, options); + } + + override async goForward( + options: WaitForOptions = {} + ): Promise { + return await this.#go(+1, options); + } + + async #go( + delta: number, + options: WaitForOptions + ): Promise { + try { + const result = await Promise.all([ + this.waitForNavigation(options), + this.#connection.send('browsingContext.traverseHistory', { + delta, + context: this.mainFrame()._id, + }), + ]); + return result[0]; + } catch (err) { + // TODO: waitForNavigation should be cancelled if an error happens. + if (isErrorLike(err)) { + if (err.message.includes('no such history entry')) { + return null; + } + } + throw err; + } + } + + override waitForDevicePrompt(): never { + throw new UnsupportedOperation(); + } +} + +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; +} + +function evaluationExpression(fun: Function | string, ...args: unknown[]) { + return `() => {${evaluationString(fun, ...args)}}`; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Realm.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Realm.ts new file mode 100644 index 0000000000..84f13bc703 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Realm.ts @@ -0,0 +1,228 @@ +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter, type EventType} from '../common/EventEmitter.js'; +import {scriptInjector} from '../common/ScriptInjector.js'; +import type {EvaluateFunc, HandleFor} from '../common/types.js'; +import { + PuppeteerURL, + SOURCE_URL_REGEX, + getSourcePuppeteerURLIfAvailable, + getSourceUrlComment, + isString, +} from '../common/util.js'; +import type PuppeteerUtil from '../injected/injected.js'; +import {disposeSymbol} from '../util/disposable.js'; +import {stringifyFunction} from '../util/Function.js'; + +import type {BidiConnection} from './Connection.js'; +import {BidiDeserializer} from './Deserializer.js'; +import {BidiElementHandle} from './ElementHandle.js'; +import {BidiJSHandle} from './JSHandle.js'; +import type {Sandbox} from './Sandbox.js'; +import {BidiSerializer} from './Serializer.js'; +import {createEvaluationError} from './util.js'; + +/** + * @internal + */ +export class BidiRealm extends EventEmitter> { + readonly connection: BidiConnection; + + #id!: string; + #sandbox!: Sandbox; + + constructor(connection: BidiConnection) { + super(); + this.connection = connection; + } + + get target(): Bidi.Script.Target { + return { + context: this.#sandbox.environment._id, + sandbox: this.#sandbox.name, + }; + } + + handleRealmDestroyed = async ( + params: Bidi.Script.RealmDestroyed['params'] + ): Promise => { + if (params.realm === this.#id) { + // Note: The Realm is destroyed, so in theory the handle should be as + // well. + this.internalPuppeteerUtil = undefined; + this.#sandbox.environment.clearDocumentHandle(); + } + }; + + handleRealmCreated = (params: Bidi.Script.RealmCreated['params']): void => { + if ( + params.type === 'window' && + params.context === this.#sandbox.environment._id && + params.sandbox === this.#sandbox.name + ) { + this.#id = params.realm; + void this.#sandbox.taskManager.rerunAll(); + } + }; + + setSandbox(sandbox: Sandbox): void { + this.#sandbox = sandbox; + this.connection.on( + Bidi.ChromiumBidi.Script.EventNames.RealmCreated, + this.handleRealmCreated + ); + this.connection.on( + Bidi.ChromiumBidi.Script.EventNames.RealmDestroyed, + this.handleRealmDestroyed + ); + } + + protected internalPuppeteerUtil?: Promise>; + get puppeteerUtil(): Promise> { + const promise = Promise.resolve() as Promise; + scriptInjector.inject(script => { + if (this.internalPuppeteerUtil) { + void this.internalPuppeteerUtil.then(handle => { + void handle.dispose(); + }); + } + this.internalPuppeteerUtil = promise.then(() => { + return this.evaluateHandle(script) as Promise< + BidiJSHandle + >; + }); + }, !this.internalPuppeteerUtil); + return this.internalPuppeteerUtil as Promise>; + } + + async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFunc = EvaluateFunc, + >( + pageFunction: Func | string, + ...args: Params + ): Promise>>> { + return await this.#evaluate(false, pageFunction, ...args); + } + + async evaluate< + Params extends unknown[], + Func extends EvaluateFunc = EvaluateFunc, + >( + pageFunction: Func | string, + ...args: Params + ): Promise>> { + return await this.#evaluate(true, pageFunction, ...args); + } + + async #evaluate< + Params extends unknown[], + Func extends EvaluateFunc = EvaluateFunc, + >( + returnByValue: true, + pageFunction: Func | string, + ...args: Params + ): Promise>>; + async #evaluate< + Params extends unknown[], + Func extends EvaluateFunc = EvaluateFunc, + >( + returnByValue: false, + pageFunction: Func | string, + ...args: Params + ): Promise>>>; + async #evaluate< + Params extends unknown[], + Func extends EvaluateFunc = EvaluateFunc, + >( + returnByValue: boolean, + pageFunction: Func | string, + ...args: Params + ): Promise>> | Awaited>> { + const sourceUrlComment = getSourceUrlComment( + getSourcePuppeteerURLIfAvailable(pageFunction)?.toString() ?? + PuppeteerURL.INTERNAL_URL + ); + + const sandbox = this.#sandbox; + + let responsePromise; + const resultOwnership = returnByValue + ? Bidi.Script.ResultOwnership.None + : Bidi.Script.ResultOwnership.Root; + const serializationOptions: Bidi.Script.SerializationOptions = returnByValue + ? {} + : { + maxObjectDepth: 0, + maxDomDepth: 0, + }; + if (isString(pageFunction)) { + const expression = SOURCE_URL_REGEX.test(pageFunction) + ? pageFunction + : `${pageFunction}\n${sourceUrlComment}\n`; + + responsePromise = this.connection.send('script.evaluate', { + expression, + target: this.target, + resultOwnership, + awaitPromise: true, + userActivation: true, + serializationOptions, + }); + } else { + let functionDeclaration = stringifyFunction(pageFunction); + functionDeclaration = SOURCE_URL_REGEX.test(functionDeclaration) + ? functionDeclaration + : `${functionDeclaration}\n${sourceUrlComment}\n`; + responsePromise = this.connection.send('script.callFunction', { + functionDeclaration, + arguments: args.length + ? await Promise.all( + args.map(arg => { + return BidiSerializer.serialize(sandbox, arg); + }) + ) + : [], + target: this.target, + resultOwnership, + awaitPromise: true, + userActivation: true, + serializationOptions, + }); + } + + const {result} = await responsePromise; + + if ('type' in result && result.type === 'exception') { + throw createEvaluationError(result.exceptionDetails); + } + + return returnByValue + ? BidiDeserializer.deserialize(result.result) + : createBidiHandle(sandbox, result.result); + } + + [disposeSymbol](): void { + this.connection.off( + Bidi.ChromiumBidi.Script.EventNames.RealmCreated, + this.handleRealmCreated + ); + this.connection.off( + Bidi.ChromiumBidi.Script.EventNames.RealmDestroyed, + this.handleRealmDestroyed + ); + } +} + +/** + * @internal + */ +export function createBidiHandle( + sandbox: Sandbox, + result: Bidi.Script.RemoteValue +): BidiJSHandle | BidiElementHandle { + if (result.type === 'node' || result.type === 'window') { + return new BidiElementHandle(sandbox, result); + } + return new BidiJSHandle(sandbox, result); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Sandbox.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Sandbox.ts new file mode 100644 index 0000000000..4411b3dbcd --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Sandbox.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {JSHandle} from '../api/JSHandle.js'; +import {Realm} from '../api/Realm.js'; +import type {TimeoutSettings} from '../common/TimeoutSettings.js'; +import type {EvaluateFunc, HandleFor} from '../common/types.js'; +import {withSourcePuppeteerURLIfNone} from '../common/util.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; +import {BidiElementHandle} from './ElementHandle.js'; +import type {BidiFrame} from './Frame.js'; +import type {BidiRealm as BidiRealm} from './Realm.js'; +/** + * A unique key for {@link SandboxChart} to denote the default world. + * Realms are automatically created in the default sandbox. + * + * @internal + */ +export const MAIN_SANDBOX = Symbol('mainSandbox'); +/** + * A unique key for {@link SandboxChart} to denote the puppeteer sandbox. + * This world contains all puppeteer-internal bindings/code. + * + * @internal + */ +export const PUPPETEER_SANDBOX = Symbol('puppeteerSandbox'); + +/** + * @internal + */ +export interface SandboxChart { + [key: string]: Sandbox; + [MAIN_SANDBOX]: Sandbox; + [PUPPETEER_SANDBOX]: Sandbox; +} + +/** + * @internal + */ +export class Sandbox extends Realm { + readonly name: string | undefined; + readonly realm: BidiRealm; + #frame: BidiFrame; + + constructor( + name: string | undefined, + frame: BidiFrame, + // TODO: We should split the Realm and BrowsingContext + realm: BidiRealm | BrowsingContext, + timeoutSettings: TimeoutSettings + ) { + super(timeoutSettings); + this.name = name; + this.realm = realm; + this.#frame = frame; + this.realm.setSandbox(this); + } + + override get environment(): BidiFrame { + return this.#frame; + } + + async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFunc = EvaluateFunc, + >( + pageFunction: Func | string, + ...args: Params + ): Promise>>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluateHandle.name, + pageFunction + ); + return await this.realm.evaluateHandle(pageFunction, ...args); + } + + async evaluate< + Params extends unknown[], + Func extends EvaluateFunc = EvaluateFunc, + >( + pageFunction: Func | string, + ...args: Params + ): Promise>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluate.name, + pageFunction + ); + return await this.realm.evaluate(pageFunction, ...args); + } + + async adoptHandle>(handle: T): Promise { + return (await this.evaluateHandle(node => { + return node; + }, handle)) as unknown as T; + } + + async transferHandle>(handle: T): Promise { + if (handle.realm === this) { + return handle; + } + const transferredHandle = await this.evaluateHandle(node => { + return node; + }, handle); + await handle.dispose(); + return transferredHandle as unknown as T; + } + + override async adoptBackendNode( + backendNodeId?: number + ): Promise> { + const {object} = await this.environment.client.send('DOM.resolveNode', { + backendNodeId: backendNodeId, + }); + return new BidiElementHandle(this, { + handle: object.objectId, + type: 'node', + }); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Serializer.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Serializer.ts new file mode 100644 index 0000000000..c147ec9281 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Serializer.ts @@ -0,0 +1,164 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {LazyArg} from '../common/LazyArg.js'; +import {isDate, isPlainObject, isRegExp} from '../common/util.js'; + +import {BidiElementHandle} from './ElementHandle.js'; +import {BidiJSHandle} from './JSHandle.js'; +import type {Sandbox} from './Sandbox.js'; + +/** + * @internal + */ +class UnserializableError extends Error {} + +/** + * @internal + */ +export class BidiSerializer { + static serializeNumber(arg: number): Bidi.Script.LocalValue { + let value: Bidi.Script.SpecialNumber | number; + if (Object.is(arg, -0)) { + value = '-0'; + } else if (Object.is(arg, Infinity)) { + value = 'Infinity'; + } else if (Object.is(arg, -Infinity)) { + value = '-Infinity'; + } else if (Object.is(arg, NaN)) { + value = 'NaN'; + } else { + value = arg; + } + return { + type: 'number', + value, + }; + } + + static serializeObject(arg: object | null): Bidi.Script.LocalValue { + if (arg === null) { + return { + type: 'null', + }; + } else if (Array.isArray(arg)) { + const parsedArray = arg.map(subArg => { + return BidiSerializer.serializeRemoteValue(subArg); + }); + + return { + type: 'array', + value: parsedArray, + }; + } else if (isPlainObject(arg)) { + try { + JSON.stringify(arg); + } catch (error) { + if ( + error instanceof TypeError && + error.message.startsWith('Converting circular structure to JSON') + ) { + error.message += ' Recursive objects are not allowed.'; + } + throw error; + } + + const parsedObject: Bidi.Script.MappingLocalValue = []; + for (const key in arg) { + parsedObject.push([ + BidiSerializer.serializeRemoteValue(key), + BidiSerializer.serializeRemoteValue(arg[key]), + ]); + } + + return { + type: 'object', + value: parsedObject, + }; + } else if (isRegExp(arg)) { + return { + type: 'regexp', + value: { + pattern: arg.source, + flags: arg.flags, + }, + }; + } else if (isDate(arg)) { + return { + type: 'date', + value: arg.toISOString(), + }; + } + + throw new UnserializableError( + 'Custom object sterilization not possible. Use plain objects instead.' + ); + } + + static serializeRemoteValue(arg: unknown): Bidi.Script.LocalValue { + switch (typeof arg) { + case 'symbol': + case 'function': + throw new UnserializableError(`Unable to serializable ${typeof arg}`); + case 'object': + return BidiSerializer.serializeObject(arg); + + case 'undefined': + return { + type: 'undefined', + }; + case 'number': + return BidiSerializer.serializeNumber(arg); + case 'bigint': + return { + type: 'bigint', + value: arg.toString(), + }; + case 'string': + return { + type: 'string', + value: arg, + }; + case 'boolean': + return { + type: 'boolean', + value: arg, + }; + } + } + + static async serialize( + sandbox: Sandbox, + arg: unknown + ): Promise { + if (arg instanceof LazyArg) { + arg = await arg.get(sandbox.realm); + } + // eslint-disable-next-line rulesdir/use-using -- We want this to continue living. + const objectHandle = + arg && (arg instanceof BidiJSHandle || arg instanceof BidiElementHandle) + ? arg + : null; + if (objectHandle) { + if ( + objectHandle.realm.environment.context() !== + sandbox.environment.context() + ) { + throw new Error( + 'JSHandles can be evaluated only in the context they were created!' + ); + } + if (objectHandle.disposed) { + throw new Error('JSHandle is disposed!'); + } + return objectHandle.remoteValue() as Bidi.Script.RemoteReference; + } + + return BidiSerializer.serializeRemoteValue(arg); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Target.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Target.ts new file mode 100644 index 0000000000..fb01c34638 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Target.ts @@ -0,0 +1,151 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {CDPSession} from '../api/CDPSession.js'; +import type {Page} from '../api/Page.js'; +import {Target, TargetType} from '../api/Target.js'; +import {UnsupportedOperation} from '../common/Errors.js'; + +import type {BidiBrowser} from './Browser.js'; +import type {BidiBrowserContext} from './BrowserContext.js'; +import {type BrowsingContext, CdpSessionWrapper} from './BrowsingContext.js'; +import {BidiPage} from './Page.js'; + +/** + * @internal + */ +export abstract class BidiTarget extends Target { + protected _browserContext: BidiBrowserContext; + + constructor(browserContext: BidiBrowserContext) { + super(); + this._browserContext = browserContext; + } + + _setBrowserContext(browserContext: BidiBrowserContext): void { + this._browserContext = browserContext; + } + + override asPage(): Promise { + throw new UnsupportedOperation(); + } + + override browser(): BidiBrowser { + return this._browserContext.browser(); + } + + override browserContext(): BidiBrowserContext { + return this._browserContext; + } + + override opener(): never { + throw new UnsupportedOperation(); + } + + override createCDPSession(): Promise { + throw new UnsupportedOperation(); + } +} + +/** + * @internal + */ +export class BiDiBrowserTarget extends Target { + #browser: BidiBrowser; + + constructor(browser: BidiBrowser) { + super(); + this.#browser = browser; + } + + override url(): string { + return ''; + } + + override type(): TargetType { + return TargetType.BROWSER; + } + + override asPage(): Promise { + throw new UnsupportedOperation(); + } + + override browser(): BidiBrowser { + return this.#browser; + } + + override browserContext(): BidiBrowserContext { + return this.#browser.defaultBrowserContext(); + } + + override opener(): never { + throw new UnsupportedOperation(); + } + + override createCDPSession(): Promise { + throw new UnsupportedOperation(); + } +} + +/** + * @internal + */ +export class BiDiBrowsingContextTarget extends BidiTarget { + protected _browsingContext: BrowsingContext; + + constructor( + browserContext: BidiBrowserContext, + browsingContext: BrowsingContext + ) { + super(browserContext); + + this._browsingContext = browsingContext; + } + + override url(): string { + return this._browsingContext.url; + } + + override async createCDPSession(): Promise { + const {sessionId} = await this._browsingContext.cdpSession.send( + 'Target.attachToTarget', + { + targetId: this._browsingContext.id, + flatten: true, + } + ); + return new CdpSessionWrapper(this._browsingContext, sessionId); + } + + override type(): TargetType { + return TargetType.PAGE; + } +} + +/** + * @internal + */ +export class BiDiPageTarget extends BiDiBrowsingContextTarget { + #page: BidiPage; + + constructor( + browserContext: BidiBrowserContext, + browsingContext: BrowsingContext + ) { + super(browserContext, browsingContext); + + this.#page = new BidiPage(browsingContext, browserContext, this); + } + + override async page(): Promise { + return this.#page; + } + + override _setBrowserContext(browserContext: BidiBrowserContext): void { + super._setBrowserContext(browserContext); + this.#page._setBrowserContext(browserContext); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/bidi.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/bidi.ts new file mode 100644 index 0000000000..373d6d999c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/bidi.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './BidiOverCdp.js'; +export * from './Browser.js'; +export * from './BrowserContext.js'; +export * from './BrowsingContext.js'; +export * from './Connection.js'; +export * from './ElementHandle.js'; +export * from './Frame.js'; +export * from './HTTPRequest.js'; +export * from './HTTPResponse.js'; +export * from './Input.js'; +export * from './JSHandle.js'; +export * from './NetworkManager.js'; +export * from './Page.js'; +export * from './Realm.js'; +export * from './Sandbox.js'; +export * from './Target.js'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Browser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Browser.ts new file mode 100644 index 0000000000..7c4a8ed01c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Browser.ts @@ -0,0 +1,225 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js'; +import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; +import type {SharedWorkerRealm} from './Realm.js'; +import type {Session} from './Session.js'; +import {UserContext} from './UserContext.js'; + +/** + * @internal + */ +export type AddPreloadScriptOptions = Omit< + Bidi.Script.AddPreloadScriptParameters, + 'functionDeclaration' | 'contexts' +> & { + contexts?: [BrowsingContext, ...BrowsingContext[]]; +}; + +/** + * @internal + */ +export class Browser extends EventEmitter<{ + /** Emitted before the browser closes. */ + closed: { + /** The reason for closing the browser. */ + reason: string; + }; + /** Emitted after the browser disconnects. */ + disconnected: { + /** The reason for disconnecting the browser. */ + reason: string; + }; + /** Emitted when a shared worker is created. */ + sharedworker: { + /** The realm of the shared worker. */ + realm: SharedWorkerRealm; + }; +}> { + static async from(session: Session): Promise { + const browser = new Browser(session); + await browser.#initialize(); + return browser; + } + + // keep-sorted start + #closed = false; + #reason: string | undefined; + readonly #disposables = new DisposableStack(); + readonly #userContexts = new Map(); + readonly session: Session; + // keep-sorted end + + private constructor(session: Session) { + super(); + // keep-sorted start + this.session = session; + // keep-sorted end + + this.#userContexts.set( + UserContext.DEFAULT, + UserContext.create(this, UserContext.DEFAULT) + ); + } + + async #initialize() { + const sessionEmitter = this.#disposables.use( + new EventEmitter(this.session) + ); + sessionEmitter.once('ended', ({reason}) => { + this.dispose(reason); + }); + + sessionEmitter.on('script.realmCreated', info => { + if (info.type === 'shared-worker') { + // TODO: Create a SharedWorkerRealm. + } + }); + + await this.#syncBrowsingContexts(); + } + + async #syncBrowsingContexts() { + // In case contexts are created or destroyed during `getTree`, we use this + // set to detect them. + const contextIds = new Set(); + let contexts: Bidi.BrowsingContext.Info[]; + + { + using sessionEmitter = new EventEmitter(this.session); + sessionEmitter.on('browsingContext.contextCreated', info => { + contextIds.add(info.context); + }); + sessionEmitter.on('browsingContext.contextDestroyed', info => { + contextIds.delete(info.context); + }); + const {result} = await this.session.send('browsingContext.getTree', {}); + contexts = result.contexts; + } + + // Simulating events so contexts are created naturally. + for (const info of contexts) { + if (contextIds.has(info.context)) { + this.session.emit('browsingContext.contextCreated', info); + } + if (info.children) { + contexts.push(...info.children); + } + } + } + + // keep-sorted start block=yes + get closed(): boolean { + return this.#closed; + } + get defaultUserContext(): UserContext { + // SAFETY: A UserContext is always created for the default context. + return this.#userContexts.get(UserContext.DEFAULT)!; + } + get disconnected(): boolean { + return this.#reason !== undefined; + } + get disposed(): boolean { + return this.disconnected; + } + get userContexts(): Iterable { + return this.#userContexts.values(); + } + // keep-sorted end + + @inertIfDisposed + dispose(reason?: string, closed = false): void { + this.#closed = closed; + this.#reason = reason; + this[disposeSymbol](); + } + + @throwIfDisposed(browser => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return browser.#reason!; + }) + async close(): Promise { + try { + await this.session.send('browser.close', {}); + } finally { + this.dispose('Browser already closed.', true); + } + } + + @throwIfDisposed(browser => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return browser.#reason!; + }) + async addPreloadScript( + functionDeclaration: string, + options: AddPreloadScriptOptions = {} + ): Promise { + const { + result: {script}, + } = await this.session.send('script.addPreloadScript', { + functionDeclaration, + ...options, + contexts: options.contexts?.map(context => { + return context.id; + }) as [string, ...string[]], + }); + return script; + } + + @throwIfDisposed(browser => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return browser.#reason!; + }) + async removePreloadScript(script: string): Promise { + await this.session.send('script.removePreloadScript', { + script, + }); + } + + static userContextId = 0; + @throwIfDisposed(browser => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return browser.#reason!; + }) + async createUserContext(): Promise { + // TODO: implement incognito context https://github.com/w3c/webdriver-bidi/issues/289. + // TODO: Call `createUserContext` once available. + // Generating a monotonically increasing context id. + const context = `${++Browser.userContextId}`; + + const userContext = UserContext.create(this, context); + this.#userContexts.set(userContext.id, userContext); + + const userContextEmitter = this.#disposables.use( + new EventEmitter(userContext) + ); + userContextEmitter.once('closed', () => { + userContextEmitter.removeAllListeners(); + + this.#userContexts.delete(context); + }); + + return userContext; + } + + [disposeSymbol](): void { + this.#reason ??= + 'Browser was disconnected, probably because the session ended.'; + if (this.closed) { + this.emit('closed', {reason: this.#reason}); + } + this.emit('disconnected', {reason: this.#reason}); + + this.#disposables.dispose(); + super[disposeSymbol](); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/BrowsingContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/BrowsingContext.ts new file mode 100644 index 0000000000..9bec2a506c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/BrowsingContext.ts @@ -0,0 +1,475 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js'; +import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; + +import type {AddPreloadScriptOptions} from './Browser.js'; +import {Navigation} from './Navigation.js'; +import {WindowRealm} from './Realm.js'; +import {Request} from './Request.js'; +import type {UserContext} from './UserContext.js'; +import {UserPrompt} from './UserPrompt.js'; + +/** + * @internal + */ +export type CaptureScreenshotOptions = Omit< + Bidi.BrowsingContext.CaptureScreenshotParameters, + 'context' +>; + +/** + * @internal + */ +export type ReloadOptions = Omit< + Bidi.BrowsingContext.ReloadParameters, + 'context' +>; + +/** + * @internal + */ +export type PrintOptions = Omit< + Bidi.BrowsingContext.PrintParameters, + 'context' +>; + +/** + * @internal + */ +export type HandleUserPromptOptions = Omit< + Bidi.BrowsingContext.HandleUserPromptParameters, + 'context' +>; + +/** + * @internal + */ +export type SetViewportOptions = Omit< + Bidi.BrowsingContext.SetViewportParameters, + 'context' +>; + +/** + * @internal + */ +export class BrowsingContext extends EventEmitter<{ + /** Emitted when this context is closed. */ + closed: { + /** The reason the browsing context was closed */ + reason: string; + }; + /** Emitted when a child browsing context is created. */ + browsingcontext: { + /** The newly created child browsing context. */ + browsingContext: BrowsingContext; + }; + /** Emitted whenever a navigation occurs. */ + navigation: { + /** The navigation that occurred. */ + navigation: Navigation; + }; + /** Emitted whenever a request is made. */ + request: { + /** The request that was made. */ + request: Request; + }; + /** Emitted whenever a log entry is added. */ + log: { + /** Entry added to the log. */ + entry: Bidi.Log.Entry; + }; + /** Emitted whenever a prompt is opened. */ + userprompt: { + /** The prompt that was opened. */ + userPrompt: UserPrompt; + }; + /** Emitted whenever the frame emits `DOMContentLoaded` */ + DOMContentLoaded: void; + /** Emitted whenever the frame emits `load` */ + load: void; +}> { + static from( + userContext: UserContext, + parent: BrowsingContext | undefined, + id: string, + url: string + ): BrowsingContext { + const browsingContext = new BrowsingContext(userContext, parent, id, url); + browsingContext.#initialize(); + return browsingContext; + } + + // keep-sorted start + #navigation: Navigation | undefined; + #reason?: string; + #url: string; + readonly #children = new Map(); + readonly #disposables = new DisposableStack(); + readonly #realms = new Map(); + readonly #requests = new Map(); + readonly defaultRealm: WindowRealm; + readonly id: string; + readonly parent: BrowsingContext | undefined; + readonly userContext: UserContext; + // keep-sorted end + + private constructor( + context: UserContext, + parent: BrowsingContext | undefined, + id: string, + url: string + ) { + super(); + // keep-sorted start + this.#url = url; + this.id = id; + this.parent = parent; + this.userContext = context; + // keep-sorted end + + this.defaultRealm = WindowRealm.from(this); + } + + #initialize() { + const userContextEmitter = this.#disposables.use( + new EventEmitter(this.userContext) + ); + userContextEmitter.once('closed', ({reason}) => { + this.dispose(`Browsing context already closed: ${reason}`); + }); + + const sessionEmitter = this.#disposables.use( + new EventEmitter(this.#session) + ); + sessionEmitter.on('browsingContext.contextCreated', info => { + if (info.parent !== this.id) { + return; + } + + const browsingContext = BrowsingContext.from( + this.userContext, + this, + info.context, + info.url + ); + this.#children.set(info.context, browsingContext); + + const browsingContextEmitter = this.#disposables.use( + new EventEmitter(browsingContext) + ); + browsingContextEmitter.once('closed', () => { + browsingContextEmitter.removeAllListeners(); + + this.#children.delete(browsingContext.id); + }); + + this.emit('browsingcontext', {browsingContext}); + }); + sessionEmitter.on('browsingContext.contextDestroyed', info => { + if (info.context !== this.id) { + return; + } + this.dispose('Browsing context already closed.'); + }); + + sessionEmitter.on('browsingContext.domContentLoaded', info => { + if (info.context !== this.id) { + return; + } + this.#url = info.url; + this.emit('DOMContentLoaded', undefined); + }); + + sessionEmitter.on('browsingContext.load', info => { + if (info.context !== this.id) { + return; + } + this.#url = info.url; + this.emit('load', undefined); + }); + + sessionEmitter.on('browsingContext.navigationStarted', info => { + if (info.context !== this.id) { + return; + } + this.#url = info.url; + + this.#requests.clear(); + + // Note the navigation ID is null for this event. + this.#navigation = Navigation.from(this); + + const navigationEmitter = this.#disposables.use( + new EventEmitter(this.#navigation) + ); + for (const eventName of ['fragment', 'failed', 'aborted'] as const) { + navigationEmitter.once(eventName, ({url}) => { + navigationEmitter[disposeSymbol](); + + this.#url = url; + }); + } + + this.emit('navigation', {navigation: this.#navigation}); + }); + sessionEmitter.on('network.beforeRequestSent', event => { + if (event.context !== this.id) { + return; + } + if (this.#requests.has(event.request.request)) { + return; + } + + const request = Request.from(this, event); + this.#requests.set(request.id, request); + this.emit('request', {request}); + }); + + sessionEmitter.on('log.entryAdded', entry => { + if (entry.source.context !== this.id) { + return; + } + + this.emit('log', {entry}); + }); + + sessionEmitter.on('browsingContext.userPromptOpened', info => { + if (info.context !== this.id) { + return; + } + + const userPrompt = UserPrompt.from(this, info); + this.emit('userprompt', {userPrompt}); + }); + } + + // keep-sorted start block=yes + get #session() { + return this.userContext.browser.session; + } + get children(): Iterable { + return this.#children.values(); + } + get closed(): boolean { + return this.#reason !== undefined; + } + get disposed(): boolean { + return this.closed; + } + get realms(): Iterable { + return this.#realms.values(); + } + get top(): BrowsingContext { + let context = this as BrowsingContext; + for (let {parent} = context; parent; {parent} = context) { + context = parent; + } + return context; + } + get url(): string { + return this.#url; + } + // keep-sorted end + + @inertIfDisposed + private dispose(reason?: string): void { + this.#reason = reason; + this[disposeSymbol](); + } + + @throwIfDisposed(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async activate(): Promise { + await this.#session.send('browsingContext.activate', { + context: this.id, + }); + } + + @throwIfDisposed(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async captureScreenshot( + options: CaptureScreenshotOptions = {} + ): Promise { + const { + result: {data}, + } = await this.#session.send('browsingContext.captureScreenshot', { + context: this.id, + ...options, + }); + return data; + } + + @throwIfDisposed(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async close(promptUnload?: boolean): Promise { + await Promise.all( + [...this.#children.values()].map(async child => { + await child.close(promptUnload); + }) + ); + await this.#session.send('browsingContext.close', { + context: this.id, + promptUnload, + }); + } + + @throwIfDisposed(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async traverseHistory(delta: number): Promise { + await this.#session.send('browsingContext.traverseHistory', { + context: this.id, + delta, + }); + } + + @throwIfDisposed(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async navigate( + url: string, + wait?: Bidi.BrowsingContext.ReadinessState + ): Promise { + await this.#session.send('browsingContext.navigate', { + context: this.id, + url, + wait, + }); + return await new Promise(resolve => { + this.once('navigation', ({navigation}) => { + resolve(navigation); + }); + }); + } + + @throwIfDisposed(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async reload(options: ReloadOptions = {}): Promise { + await this.#session.send('browsingContext.reload', { + context: this.id, + ...options, + }); + return await new Promise(resolve => { + this.once('navigation', ({navigation}) => { + resolve(navigation); + }); + }); + } + + @throwIfDisposed(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async print(options: PrintOptions = {}): Promise { + const { + result: {data}, + } = await this.#session.send('browsingContext.print', { + context: this.id, + ...options, + }); + return data; + } + + @throwIfDisposed(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async handleUserPrompt(options: HandleUserPromptOptions = {}): Promise { + await this.#session.send('browsingContext.handleUserPrompt', { + context: this.id, + ...options, + }); + } + + @throwIfDisposed(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async setViewport(options: SetViewportOptions = {}): Promise { + await this.#session.send('browsingContext.setViewport', { + context: this.id, + ...options, + }); + } + + @throwIfDisposed(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async performActions(actions: Bidi.Input.SourceActions[]): Promise { + await this.#session.send('input.performActions', { + context: this.id, + actions, + }); + } + + @throwIfDisposed(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async releaseActions(): Promise { + await this.#session.send('input.releaseActions', { + context: this.id, + }); + } + + @throwIfDisposed(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + createWindowRealm(sandbox: string): WindowRealm { + return WindowRealm.from(this, sandbox); + } + + @throwIfDisposed(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async addPreloadScript( + functionDeclaration: string, + options: AddPreloadScriptOptions = {} + ): Promise { + return await this.userContext.browser.addPreloadScript( + functionDeclaration, + { + ...options, + contexts: [this, ...(options.contexts ?? [])], + } + ); + } + + @throwIfDisposed(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async removePreloadScript(script: string): Promise { + await this.userContext.browser.removePreloadScript(script); + } + + [disposeSymbol](): void { + this.#reason ??= + 'Browsing context already closed, probably because the user context closed.'; + this.emit('closed', {reason: this.#reason}); + + this.#disposables.dispose(); + super[disposeSymbol](); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Connection.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Connection.ts new file mode 100644 index 0000000000..b9de14372b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Connection.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import type {EventEmitter} from '../../common/EventEmitter.js'; + +/** + * @internal + */ +export interface Commands { + 'script.evaluate': { + params: Bidi.Script.EvaluateParameters; + returnType: Bidi.Script.EvaluateResult; + }; + 'script.callFunction': { + params: Bidi.Script.CallFunctionParameters; + returnType: Bidi.Script.EvaluateResult; + }; + 'script.disown': { + params: Bidi.Script.DisownParameters; + returnType: Bidi.EmptyResult; + }; + 'script.addPreloadScript': { + params: Bidi.Script.AddPreloadScriptParameters; + returnType: Bidi.Script.AddPreloadScriptResult; + }; + 'script.removePreloadScript': { + params: Bidi.Script.RemovePreloadScriptParameters; + returnType: Bidi.EmptyResult; + }; + + 'browser.close': { + params: Bidi.EmptyParams; + returnType: Bidi.EmptyResult; + }; + + 'browsingContext.activate': { + params: Bidi.BrowsingContext.ActivateParameters; + returnType: Bidi.EmptyResult; + }; + 'browsingContext.create': { + params: Bidi.BrowsingContext.CreateParameters; + returnType: Bidi.BrowsingContext.CreateResult; + }; + 'browsingContext.close': { + params: Bidi.BrowsingContext.CloseParameters; + returnType: Bidi.EmptyResult; + }; + 'browsingContext.getTree': { + params: Bidi.BrowsingContext.GetTreeParameters; + returnType: Bidi.BrowsingContext.GetTreeResult; + }; + 'browsingContext.navigate': { + params: Bidi.BrowsingContext.NavigateParameters; + returnType: Bidi.BrowsingContext.NavigateResult; + }; + 'browsingContext.reload': { + params: Bidi.BrowsingContext.ReloadParameters; + returnType: Bidi.BrowsingContext.NavigateResult; + }; + 'browsingContext.print': { + params: Bidi.BrowsingContext.PrintParameters; + returnType: Bidi.BrowsingContext.PrintResult; + }; + 'browsingContext.captureScreenshot': { + params: Bidi.BrowsingContext.CaptureScreenshotParameters; + returnType: Bidi.BrowsingContext.CaptureScreenshotResult; + }; + 'browsingContext.handleUserPrompt': { + params: Bidi.BrowsingContext.HandleUserPromptParameters; + returnType: Bidi.EmptyResult; + }; + 'browsingContext.setViewport': { + params: Bidi.BrowsingContext.SetViewportParameters; + returnType: Bidi.EmptyResult; + }; + 'browsingContext.traverseHistory': { + params: Bidi.BrowsingContext.TraverseHistoryParameters; + returnType: Bidi.EmptyResult; + }; + + 'input.performActions': { + params: Bidi.Input.PerformActionsParameters; + returnType: Bidi.EmptyResult; + }; + 'input.releaseActions': { + params: Bidi.Input.ReleaseActionsParameters; + returnType: Bidi.EmptyResult; + }; + + 'session.end': { + params: Bidi.EmptyParams; + returnType: Bidi.EmptyResult; + }; + 'session.new': { + params: Bidi.Session.NewParameters; + returnType: Bidi.Session.NewResult; + }; + 'session.status': { + params: object; + returnType: Bidi.Session.StatusResult; + }; + 'session.subscribe': { + params: Bidi.Session.SubscriptionRequest; + returnType: Bidi.EmptyResult; + }; + 'session.unsubscribe': { + params: Bidi.Session.SubscriptionRequest; + returnType: Bidi.EmptyResult; + }; +} + +/** + * @internal + */ +export type BidiEvents = { + [K in Bidi.ChromiumBidi.Event['method']]: Extract< + Bidi.ChromiumBidi.Event, + {method: K} + >['params']; +}; + +/** + * @internal + */ +export interface Connection + extends EventEmitter { + send( + method: T, + params: Commands[T]['params'] + ): Promise<{result: Commands[T]['returnType']}>; + + // This will pipe events into the provided emitter. + pipeTo(emitter: EventEmitter): void; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Navigation.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Navigation.ts new file mode 100644 index 0000000000..a7efbfeb2c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Navigation.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {inertIfDisposed} from '../../util/decorators.js'; +import {Deferred} from '../../util/Deferred.js'; +import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; +import type {Request} from './Request.js'; + +/** + * @internal + */ +export interface NavigationInfo { + url: string; + timestamp: Date; +} + +/** + * @internal + */ +export class Navigation extends EventEmitter<{ + /** Emitted when navigation has a request associated with it. */ + request: Request; + /** Emitted when fragment navigation occurred. */ + fragment: NavigationInfo; + /** Emitted when navigation failed. */ + failed: NavigationInfo; + /** Emitted when navigation was aborted. */ + aborted: NavigationInfo; +}> { + static from(context: BrowsingContext): Navigation { + const navigation = new Navigation(context); + navigation.#initialize(); + return navigation; + } + + // keep-sorted start + #request: Request | undefined; + readonly #browsingContext: BrowsingContext; + readonly #disposables = new DisposableStack(); + readonly #id = new Deferred(); + // keep-sorted end + + private constructor(context: BrowsingContext) { + super(); + // keep-sorted start + this.#browsingContext = context; + // keep-sorted end + } + + #initialize() { + const browsingContextEmitter = this.#disposables.use( + new EventEmitter(this.#browsingContext) + ); + browsingContextEmitter.once('closed', () => { + this.emit('failed', { + url: this.#browsingContext.url, + timestamp: new Date(), + }); + this.dispose(); + }); + + this.#browsingContext.on('request', ({request}) => { + if (request.navigation === this.#id.value()) { + this.#request = request; + this.emit('request', request); + } + }); + + const sessionEmitter = this.#disposables.use( + new EventEmitter(this.#session) + ); + // To get the navigation ID if any. + for (const eventName of [ + 'browsingContext.domContentLoaded', + 'browsingContext.load', + ] as const) { + sessionEmitter.on(eventName, info => { + if (info.context !== this.#browsingContext.id) { + return; + } + if (!info.navigation) { + return; + } + if (!this.#id.resolved()) { + this.#id.resolve(info.navigation); + } + }); + } + + for (const [eventName, event] of [ + ['browsingContext.fragmentNavigated', 'fragment'], + ['browsingContext.navigationFailed', 'failed'], + ['browsingContext.navigationAborted', 'aborted'], + ] as const) { + sessionEmitter.on(eventName, info => { + if (info.context !== this.#browsingContext.id) { + return; + } + if (!info.navigation) { + return; + } + if (!this.#id.resolved()) { + this.#id.resolve(info.navigation); + } + if (this.#id.value() !== info.navigation) { + return; + } + this.emit(event, { + url: info.url, + timestamp: new Date(info.timestamp), + }); + this.dispose(); + }); + } + } + + // keep-sorted start block=yes + get #session() { + return this.#browsingContext.userContext.browser.session; + } + get disposed(): boolean { + return this.#disposables.disposed; + } + get request(): Request | undefined { + return this.#request; + } + // keep-sorted end + + @inertIfDisposed + private dispose(): void { + this[disposeSymbol](); + } + + [disposeSymbol](): void { + this.#disposables.dispose(); + super[disposeSymbol](); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Realm.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Realm.ts new file mode 100644 index 0000000000..d9bbbede50 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Realm.ts @@ -0,0 +1,351 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js'; +import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; +import type {Session} from './Session.js'; + +/** + * @internal + */ +export type CallFunctionOptions = Omit< + Bidi.Script.CallFunctionParameters, + 'functionDeclaration' | 'awaitPromise' | 'target' +>; + +/** + * @internal + */ +export type EvaluateOptions = Omit< + Bidi.Script.EvaluateParameters, + 'expression' | 'awaitPromise' | 'target' +>; + +/** + * @internal + */ +export abstract class Realm extends EventEmitter<{ + /** Emitted when the realm is destroyed. */ + destroyed: {reason: string}; + /** Emitted when a dedicated worker is created in the realm. */ + worker: DedicatedWorkerRealm; + /** Emitted when a shared worker is created in the realm. */ + sharedworker: SharedWorkerRealm; +}> { + // keep-sorted start + #reason?: string; + protected readonly disposables = new DisposableStack(); + readonly id: string; + readonly origin: string; + // keep-sorted end + + protected constructor(id: string, origin: string) { + super(); + // keep-sorted start + this.id = id; + this.origin = origin; + // keep-sorted end + } + + protected initialize(): void { + const sessionEmitter = this.disposables.use(new EventEmitter(this.session)); + sessionEmitter.on('script.realmDestroyed', info => { + if (info.realm !== this.id) { + return; + } + this.dispose('Realm already destroyed.'); + }); + } + + // keep-sorted start block=yes + get disposed(): boolean { + return this.#reason !== undefined; + } + protected abstract get session(): Session; + protected get target(): Bidi.Script.Target { + return {realm: this.id}; + } + // keep-sorted end + + @inertIfDisposed + protected dispose(reason?: string): void { + this.#reason = reason; + this[disposeSymbol](); + } + + @throwIfDisposed(realm => { + // SAFETY: Disposal implies this exists. + return realm.#reason!; + }) + async disown(handles: string[]): Promise { + await this.session.send('script.disown', { + target: this.target, + handles, + }); + } + + @throwIfDisposed(realm => { + // SAFETY: Disposal implies this exists. + return realm.#reason!; + }) + async callFunction( + functionDeclaration: string, + awaitPromise: boolean, + options: CallFunctionOptions = {} + ): Promise { + const {result} = await this.session.send('script.callFunction', { + functionDeclaration, + awaitPromise, + target: this.target, + ...options, + }); + return result; + } + + @throwIfDisposed(realm => { + // SAFETY: Disposal implies this exists. + return realm.#reason!; + }) + async evaluate( + expression: string, + awaitPromise: boolean, + options: EvaluateOptions = {} + ): Promise { + const {result} = await this.session.send('script.evaluate', { + expression, + awaitPromise, + target: this.target, + ...options, + }); + return result; + } + + [disposeSymbol](): void { + this.#reason ??= + 'Realm already destroyed, probably because all associated browsing contexts closed.'; + this.emit('destroyed', {reason: this.#reason}); + + this.disposables.dispose(); + super[disposeSymbol](); + } +} + +/** + * @internal + */ +export class WindowRealm extends Realm { + static from(context: BrowsingContext, sandbox?: string): WindowRealm { + const realm = new WindowRealm(context, sandbox); + realm.initialize(); + return realm; + } + + // keep-sorted start + readonly browsingContext: BrowsingContext; + readonly sandbox?: string; + // keep-sorted end + + readonly #workers: { + dedicated: Map; + shared: Map; + } = { + dedicated: new Map(), + shared: new Map(), + }; + + private constructor(context: BrowsingContext, sandbox?: string) { + super('', ''); + // keep-sorted start + this.browsingContext = context; + this.sandbox = sandbox; + // keep-sorted end + } + + override initialize(): void { + super.initialize(); + + const sessionEmitter = this.disposables.use(new EventEmitter(this.session)); + sessionEmitter.on('script.realmCreated', info => { + if (info.type !== 'window') { + return; + } + (this as any).id = info.realm; + (this as any).origin = info.origin; + }); + sessionEmitter.on('script.realmCreated', info => { + if (info.type !== 'dedicated-worker') { + return; + } + if (!info.owners.includes(this.id)) { + return; + } + + const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin); + this.#workers.dedicated.set(realm.id, realm); + + const realmEmitter = this.disposables.use(new EventEmitter(realm)); + realmEmitter.once('destroyed', () => { + realmEmitter.removeAllListeners(); + this.#workers.dedicated.delete(realm.id); + }); + + this.emit('worker', realm); + }); + + this.browsingContext.userContext.browser.on('sharedworker', ({realm}) => { + if (!realm.owners.has(this)) { + return; + } + + this.#workers.shared.set(realm.id, realm); + + const realmEmitter = this.disposables.use(new EventEmitter(realm)); + realmEmitter.once('destroyed', () => { + realmEmitter.removeAllListeners(); + this.#workers.shared.delete(realm.id); + }); + + this.emit('sharedworker', realm); + }); + } + + override get session(): Session { + return this.browsingContext.userContext.browser.session; + } + + override get target(): Bidi.Script.Target { + return {context: this.browsingContext.id, sandbox: this.sandbox}; + } +} + +/** + * @internal + */ +export type DedicatedWorkerOwnerRealm = + | DedicatedWorkerRealm + | SharedWorkerRealm + | WindowRealm; + +/** + * @internal + */ +export class DedicatedWorkerRealm extends Realm { + static from( + owner: DedicatedWorkerOwnerRealm, + id: string, + origin: string + ): DedicatedWorkerRealm { + const realm = new DedicatedWorkerRealm(owner, id, origin); + realm.initialize(); + return realm; + } + + // keep-sorted start + readonly #workers = new Map(); + readonly owners: Set; + // keep-sorted end + + private constructor( + owner: DedicatedWorkerOwnerRealm, + id: string, + origin: string + ) { + super(id, origin); + this.owners = new Set([owner]); + } + + override initialize(): void { + super.initialize(); + + const sessionEmitter = this.disposables.use(new EventEmitter(this.session)); + sessionEmitter.on('script.realmCreated', info => { + if (info.type !== 'dedicated-worker') { + return; + } + if (!info.owners.includes(this.id)) { + return; + } + + const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin); + this.#workers.set(realm.id, realm); + + const realmEmitter = this.disposables.use(new EventEmitter(realm)); + realmEmitter.once('destroyed', () => { + this.#workers.delete(realm.id); + }); + + this.emit('worker', realm); + }); + } + + override get session(): Session { + // SAFETY: At least one owner will exist. + return this.owners.values().next().value.session; + } +} + +/** + * @internal + */ +export class SharedWorkerRealm extends Realm { + static from( + owners: [WindowRealm, ...WindowRealm[]], + id: string, + origin: string + ): SharedWorkerRealm { + const realm = new SharedWorkerRealm(owners, id, origin); + realm.initialize(); + return realm; + } + + // keep-sorted start + readonly #workers = new Map(); + readonly owners: Set; + // keep-sorted end + + private constructor( + owners: [WindowRealm, ...WindowRealm[]], + id: string, + origin: string + ) { + super(id, origin); + this.owners = new Set(owners); + } + + override initialize(): void { + super.initialize(); + + const sessionEmitter = this.disposables.use(new EventEmitter(this.session)); + sessionEmitter.on('script.realmCreated', info => { + if (info.type !== 'dedicated-worker') { + return; + } + if (!info.owners.includes(this.id)) { + return; + } + + const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin); + this.#workers.set(realm.id, realm); + + const realmEmitter = this.disposables.use(new EventEmitter(realm)); + realmEmitter.once('destroyed', () => { + this.#workers.delete(realm.id); + }); + + this.emit('worker', realm); + }); + } + + override get session(): Session { + // SAFETY: At least one owner will exist. + return this.owners.values().next().value.session; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Request.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Request.ts new file mode 100644 index 0000000000..2a445f7d87 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Request.ts @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {inertIfDisposed} from '../../util/decorators.js'; +import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; + +/** + * @internal + */ +export class Request extends EventEmitter<{ + /** Emitted when the request is redirected. */ + redirect: Request; + /** Emitted when the request succeeds. */ + success: Bidi.Network.ResponseData; + /** Emitted when the request fails. */ + error: string; +}> { + static from( + browsingContext: BrowsingContext, + event: Bidi.Network.BeforeRequestSentParameters + ): Request { + const request = new Request(browsingContext, event); + request.#initialize(); + return request; + } + + // keep-sorted start + #error?: string; + #redirect?: Request; + #response?: Bidi.Network.ResponseData; + readonly #browsingContext: BrowsingContext; + readonly #disposables = new DisposableStack(); + readonly #event: Bidi.Network.BeforeRequestSentParameters; + // keep-sorted end + + private constructor( + browsingContext: BrowsingContext, + event: Bidi.Network.BeforeRequestSentParameters + ) { + super(); + // keep-sorted start + this.#browsingContext = browsingContext; + this.#event = event; + // keep-sorted end + } + + #initialize() { + const browsingContextEmitter = this.#disposables.use( + new EventEmitter(this.#browsingContext) + ); + browsingContextEmitter.once('closed', ({reason}) => { + this.#error = reason; + this.emit('error', this.#error); + this.dispose(); + }); + + const sessionEmitter = this.#disposables.use( + new EventEmitter(this.#session) + ); + sessionEmitter.on('network.beforeRequestSent', event => { + if (event.context !== this.#browsingContext.id) { + return; + } + if (event.request.request !== this.id) { + return; + } + this.#redirect = Request.from(this.#browsingContext, event); + this.emit('redirect', this.#redirect); + this.dispose(); + }); + sessionEmitter.on('network.fetchError', event => { + if (event.context !== this.#browsingContext.id) { + return; + } + if (event.request.request !== this.id) { + return; + } + this.#error = event.errorText; + this.emit('error', this.#error); + this.dispose(); + }); + sessionEmitter.on('network.responseCompleted', event => { + if (event.context !== this.#browsingContext.id) { + return; + } + if (event.request.request !== this.id) { + return; + } + this.#response = event.response; + this.emit('success', this.#response); + this.dispose(); + }); + } + + // keep-sorted start block=yes + get #session() { + return this.#browsingContext.userContext.browser.session; + } + get disposed(): boolean { + return this.#disposables.disposed; + } + get error(): string | undefined { + return this.#error; + } + get headers(): Bidi.Network.Header[] { + return this.#event.request.headers; + } + get id(): string { + return this.#event.request.request; + } + get initiator(): Bidi.Network.Initiator { + return this.#event.initiator; + } + get method(): string { + return this.#event.request.method; + } + get navigation(): string | undefined { + return this.#event.navigation ?? undefined; + } + get redirect(): Request | undefined { + return this.redirect; + } + get response(): Bidi.Network.ResponseData | undefined { + return this.#response; + } + get url(): string { + return this.#event.request.url; + } + // keep-sorted end + + @inertIfDisposed + private dispose(): void { + this[disposeSymbol](); + } + + [disposeSymbol](): void { + this.#disposables.dispose(); + super[disposeSymbol](); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Session.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Session.ts new file mode 100644 index 0000000000..b6e28061f1 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Session.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {debugError} from '../../common/util.js'; +import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js'; +import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; + +import {Browser} from './Browser.js'; +import type {BidiEvents, Commands, Connection} from './Connection.js'; + +// TODO: Once Chrome supports session.status properly, uncomment this block. +// const MAX_RETRIES = 5; + +/** + * @internal + */ +export class Session + extends EventEmitter + implements Connection +{ + static async from( + connection: Connection, + capabilities: Bidi.Session.CapabilitiesRequest + ): Promise { + // Wait until the session is ready. + // + // TODO: Once Chrome supports session.status properly, uncomment this block + // and remove `getBiDiConnection` in BrowserConnector. + + // let status = {message: '', ready: false}; + // for (let i = 0; i < MAX_RETRIES; ++i) { + // status = (await connection.send('session.status', {})).result; + // if (status.ready) { + // break; + // } + // // Backoff a little bit each time. + // await new Promise(resolve => { + // return setTimeout(resolve, (1 << i) * 100); + // }); + // } + // if (!status.ready) { + // throw new Error(status.message); + // } + + let result; + try { + result = ( + await connection.send('session.new', { + capabilities, + }) + ).result; + } catch (err) { + // Chrome does not support session.new. + debugError(err); + result = { + sessionId: '', + capabilities: { + acceptInsecureCerts: false, + browserName: '', + browserVersion: '', + platformName: '', + setWindowRect: false, + webSocketUrl: '', + }, + }; + } + + const session = new Session(connection, result); + await session.#initialize(); + return session; + } + + // keep-sorted start + #reason: string | undefined; + readonly #disposables = new DisposableStack(); + readonly #info: Bidi.Session.NewResult; + readonly browser!: Browser; + readonly connection: Connection; + // keep-sorted end + + private constructor(connection: Connection, info: Bidi.Session.NewResult) { + super(); + // keep-sorted start + this.#info = info; + this.connection = connection; + // keep-sorted end + } + + async #initialize(): Promise { + this.connection.pipeTo(this); + + // SAFETY: We use `any` to allow assignment of the readonly property. + (this as any).browser = await Browser.from(this); + + const browserEmitter = this.#disposables.use(this.browser); + browserEmitter.once('closed', ({reason}) => { + this.dispose(reason); + }); + } + + // keep-sorted start block=yes + get capabilities(): Bidi.Session.NewResult['capabilities'] { + return this.#info.capabilities; + } + get disposed(): boolean { + return this.ended; + } + get ended(): boolean { + return this.#reason !== undefined; + } + get id(): string { + return this.#info.sessionId; + } + // keep-sorted end + + @inertIfDisposed + private dispose(reason?: string): void { + this.#reason = reason; + this[disposeSymbol](); + } + + pipeTo(emitter: EventEmitter): void { + this.connection.pipeTo(emitter); + } + + /** + * Currently, there is a 1:1 relationship between the session and the + * session. In the future, we might support multiple sessions and in that + * case we always needs to make sure that the session for the right session + * object is used, so we implement this method here, although it's not defined + * in the spec. + */ + @throwIfDisposed(session => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return session.#reason!; + }) + async send( + method: T, + params: Commands[T]['params'] + ): Promise<{result: Commands[T]['returnType']}> { + return await this.connection.send(method, params); + } + + @throwIfDisposed(session => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return session.#reason!; + }) + async subscribe(events: string[]): Promise { + await this.send('session.subscribe', { + events, + }); + } + + @throwIfDisposed(session => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return session.#reason!; + }) + async end(): Promise { + try { + await this.send('session.end', {}); + } finally { + this.dispose(`Session already ended.`); + } + } + + [disposeSymbol](): void { + this.#reason ??= + 'Session already destroyed, probably because the connection broke.'; + this.emit('ended', {reason: this.#reason}); + + this.#disposables.dispose(); + super[disposeSymbol](); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserContext.ts new file mode 100644 index 0000000000..01ee5c7649 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserContext.ts @@ -0,0 +1,178 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {assert} from '../../util/assert.js'; +import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js'; +import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; + +import type {Browser} from './Browser.js'; +import {BrowsingContext} from './BrowsingContext.js'; + +/** + * @internal + */ +export type CreateBrowsingContextOptions = Omit< + Bidi.BrowsingContext.CreateParameters, + 'type' | 'referenceContext' +> & { + referenceContext?: BrowsingContext; +}; + +/** + * @internal + */ +export class UserContext extends EventEmitter<{ + /** + * Emitted when a new browsing context is created. + */ + browsingcontext: { + /** The new browsing context. */ + browsingContext: BrowsingContext; + }; + /** + * Emitted when the user context is closed. + */ + closed: { + /** The reason the user context was closed. */ + reason: string; + }; +}> { + static DEFAULT = 'default'; + + static create(browser: Browser, id: string): UserContext { + const context = new UserContext(browser, id); + context.#initialize(); + return context; + } + + // keep-sorted start + #reason?: string; + // Note these are only top-level contexts. + readonly #browsingContexts = new Map(); + readonly #disposables = new DisposableStack(); + readonly #id: string; + readonly browser: Browser; + // keep-sorted end + + private constructor(browser: Browser, id: string) { + super(); + // keep-sorted start + this.#id = id; + this.browser = browser; + // keep-sorted end + } + + #initialize() { + const browserEmitter = this.#disposables.use( + new EventEmitter(this.browser) + ); + browserEmitter.once('closed', ({reason}) => { + this.dispose(`User context already closed: ${reason}`); + }); + + const sessionEmitter = this.#disposables.use( + new EventEmitter(this.#session) + ); + sessionEmitter.on('browsingContext.contextCreated', info => { + if (info.parent) { + return; + } + + const browsingContext = BrowsingContext.from( + this, + undefined, + info.context, + info.url + ); + this.#browsingContexts.set(browsingContext.id, browsingContext); + + const browsingContextEmitter = this.#disposables.use( + new EventEmitter(browsingContext) + ); + browsingContextEmitter.on('closed', () => { + browsingContextEmitter.removeAllListeners(); + + this.#browsingContexts.delete(browsingContext.id); + }); + + this.emit('browsingcontext', {browsingContext}); + }); + } + + // keep-sorted start block=yes + get #session() { + return this.browser.session; + } + get browsingContexts(): Iterable { + return this.#browsingContexts.values(); + } + get closed(): boolean { + return this.#reason !== undefined; + } + get disposed(): boolean { + return this.closed; + } + get id(): string { + return this.#id; + } + // keep-sorted end + + @inertIfDisposed + private dispose(reason?: string): void { + this.#reason = reason; + this[disposeSymbol](); + } + + @throwIfDisposed(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async createBrowsingContext( + type: Bidi.BrowsingContext.CreateType, + options: CreateBrowsingContextOptions = {} + ): Promise { + const { + result: {context: contextId}, + } = await this.#session.send('browsingContext.create', { + type, + ...options, + referenceContext: options.referenceContext?.id, + }); + + const browsingContext = this.#browsingContexts.get(contextId); + assert( + browsingContext, + 'The WebDriver BiDi implementation is failing to create a browsing context correctly.' + ); + + // We use an array to avoid the promise from being awaited. + return browsingContext; + } + + @throwIfDisposed(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async remove(): Promise { + try { + // TODO: Call `removeUserContext` once available. + } finally { + this.dispose('User context already closed.'); + } + } + + [disposeSymbol](): void { + this.#reason ??= + 'User context already closed, probably because the browser disconnected/closed.'; + this.emit('closed', {reason: this.#reason}); + + this.#disposables.dispose(); + super[disposeSymbol](); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserPrompt.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserPrompt.ts new file mode 100644 index 0000000000..073233bed0 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserPrompt.ts @@ -0,0 +1,137 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js'; +import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; + +/** + * @internal + */ +export type HandleOptions = Omit< + Bidi.BrowsingContext.HandleUserPromptParameters, + 'context' +>; + +/** + * @internal + */ +export type UserPromptResult = Omit< + Bidi.BrowsingContext.UserPromptClosedParameters, + 'context' +>; + +/** + * @internal + */ +export class UserPrompt extends EventEmitter<{ + /** Emitted when the user prompt is handled. */ + handled: UserPromptResult; + /** Emitted when the user prompt is closed. */ + closed: { + /** The reason the user prompt was closed. */ + reason: string; + }; +}> { + static from( + browsingContext: BrowsingContext, + info: Bidi.BrowsingContext.UserPromptOpenedParameters + ): UserPrompt { + const userPrompt = new UserPrompt(browsingContext, info); + userPrompt.#initialize(); + return userPrompt; + } + + // keep-sorted start + #reason?: string; + #result?: UserPromptResult; + readonly #disposables = new DisposableStack(); + readonly browsingContext: BrowsingContext; + readonly info: Bidi.BrowsingContext.UserPromptOpenedParameters; + // keep-sorted end + + private constructor( + context: BrowsingContext, + info: Bidi.BrowsingContext.UserPromptOpenedParameters + ) { + super(); + // keep-sorted start + this.browsingContext = context; + this.info = info; + // keep-sorted end + } + + #initialize() { + const browserContextEmitter = this.#disposables.use( + new EventEmitter(this.browsingContext) + ); + browserContextEmitter.once('closed', ({reason}) => { + this.dispose(`User prompt already closed: ${reason}`); + }); + + const sessionEmitter = this.#disposables.use( + new EventEmitter(this.#session) + ); + sessionEmitter.on('browsingContext.userPromptClosed', parameters => { + if (parameters.context !== this.browsingContext.id) { + return; + } + this.#result = parameters; + this.emit('handled', parameters); + this.dispose('User prompt already handled.'); + }); + } + + // keep-sorted start block=yes + get #session() { + return this.browsingContext.userContext.browser.session; + } + get closed(): boolean { + return this.#reason !== undefined; + } + get disposed(): boolean { + return this.closed; + } + get handled(): boolean { + return this.#result !== undefined; + } + get result(): UserPromptResult | undefined { + return this.#result; + } + // keep-sorted end + + @inertIfDisposed + private dispose(reason?: string): void { + this.#reason = reason; + this[disposeSymbol](); + } + + @throwIfDisposed(prompt => { + // SAFETY: Disposal implies this exists. + return prompt.#reason!; + }) + async handle(options: HandleOptions = {}): Promise { + await this.#session.send('browsingContext.handleUserPrompt', { + ...options, + context: this.info.context, + }); + // SAFETY: `handled` is triggered before the above promise resolved. + return this.#result!; + } + + [disposeSymbol](): void { + this.#reason ??= + 'User prompt already closed, probably because the associated browsing context was destroyed.'; + this.emit('closed', {reason: this.#reason}); + + this.#disposables.dispose(); + super[disposeSymbol](); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/core.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/core.ts new file mode 100644 index 0000000000..203281614b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/core.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './Browser.js'; +export * from './BrowsingContext.js'; +export * from './Connection.js'; +export * from './Navigation.js'; +export * from './Realm.js'; +export * from './Request.js'; +export * from './Session.js'; +export * from './UserContext.js'; +export * from './UserPrompt.js'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/lifecycle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/lifecycle.ts new file mode 100644 index 0000000000..73b86cba9c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/lifecycle.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import type { + ObservableInput, + ObservedValueOf, + OperatorFunction, +} from '../../third_party/rxjs/rxjs.js'; +import {catchError} from '../../third_party/rxjs/rxjs.js'; +import type {PuppeteerLifeCycleEvent} from '../cdp/LifecycleWatcher.js'; +import {ProtocolError, TimeoutError} from '../common/Errors.js'; + +/** + * @internal + */ +export type BiDiNetworkIdle = Extract< + PuppeteerLifeCycleEvent, + 'networkidle0' | 'networkidle2' +> | null; + +/** + * @internal + */ +export function getBiDiLifeCycles( + event: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[] +): [ + Extract, + BiDiNetworkIdle, +] { + if (Array.isArray(event)) { + const pageLifeCycle = event.some(lifeCycle => { + return lifeCycle !== 'domcontentloaded'; + }) + ? 'load' + : 'domcontentloaded'; + + const networkLifeCycle = event.reduce((acc, lifeCycle) => { + if (lifeCycle === 'networkidle0') { + return lifeCycle; + } else if (acc !== 'networkidle0' && lifeCycle === 'networkidle2') { + return lifeCycle; + } + return acc; + }, null as BiDiNetworkIdle); + + return [pageLifeCycle, networkLifeCycle]; + } + + if (event === 'networkidle0' || event === 'networkidle2') { + return ['load', event]; + } + + return [event, null]; +} + +/** + * @internal + */ +export const lifeCycleToReadinessState = new Map< + PuppeteerLifeCycleEvent, + Bidi.BrowsingContext.ReadinessState +>([ + ['load', Bidi.BrowsingContext.ReadinessState.Complete], + ['domcontentloaded', Bidi.BrowsingContext.ReadinessState.Interactive], +]); + +export function getBiDiReadinessState( + event: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[] +): [Bidi.BrowsingContext.ReadinessState, BiDiNetworkIdle] { + const lifeCycles = getBiDiLifeCycles(event); + const readiness = lifeCycleToReadinessState.get(lifeCycles[0])!; + return [readiness, lifeCycles[1]]; +} + +/** + * @internal + */ +export const lifeCycleToSubscribedEvent = new Map< + PuppeteerLifeCycleEvent, + 'browsingContext.load' | 'browsingContext.domContentLoaded' +>([ + ['load', 'browsingContext.load'], + ['domcontentloaded', 'browsingContext.domContentLoaded'], +]); + +/** + * @internal + */ +export function getBiDiLifecycleEvent( + event: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[] +): [ + 'browsingContext.load' | 'browsingContext.domContentLoaded', + BiDiNetworkIdle, +] { + const lifeCycles = getBiDiLifeCycles(event); + const bidiEvent = lifeCycleToSubscribedEvent.get(lifeCycles[0])!; + return [bidiEvent, lifeCycles[1]]; +} + +/** + * @internal + */ +export function rewriteNavigationError>( + message: string, + ms: number +): OperatorFunction> { + return catchError(error => { + if (error instanceof ProtocolError) { + error.message += ` at ${message}`; + } else if (error instanceof TimeoutError) { + error.message = `Navigation timeout of ${ms} ms exceeded`; + } + throw error; + }); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/util.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/util.ts new file mode 100644 index 0000000000..41e88e26c2 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/util.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {PuppeteerURL, debugError} from '../common/util.js'; + +import {BidiDeserializer} from './Deserializer.js'; +import type {BidiRealm} from './Realm.js'; + +/** + * @internal + */ +export async function releaseReference( + client: BidiRealm, + remoteReference: Bidi.Script.RemoteReference +): Promise { + if (!remoteReference.handle) { + return; + } + await client.connection + .send('script.disown', { + target: client.target, + handles: [remoteReference.handle], + }) + .catch(error => { + // Exceptions might happen in case of a page been navigated or closed. + // Swallow these since they are harmless and we don't leak anything in this case. + debugError(error); + }); +} + +/** + * @internal + */ +export function createEvaluationError( + details: Bidi.Script.ExceptionDetails +): unknown { + if (details.exception.type !== 'error') { + return BidiDeserializer.deserialize(details.exception); + } + const [name = '', ...parts] = details.text.split(': '); + const message = parts.join(': '); + const error = new Error(message); + error.name = name; + + // The first line is this function which we ignore. + const stackLines = []; + if (details.stackTrace && stackLines.length < Error.stackTraceLimit) { + for (const frame of details.stackTrace.callFrames.reverse()) { + if ( + PuppeteerURL.isPuppeteerURL(frame.url) && + frame.url !== PuppeteerURL.INTERNAL_URL + ) { + const url = PuppeteerURL.parse(frame.url); + stackLines.unshift( + ` at ${frame.functionName || url.functionName} (${ + url.functionName + } at ${url.siteString}, :${frame.lineNumber}:${ + frame.columnNumber + })` + ); + } else { + stackLines.push( + ` at ${frame.functionName || ''} (${frame.url}:${ + frame.lineNumber + }:${frame.columnNumber})` + ); + } + if (stackLines.length >= Error.stackTraceLimit) { + break; + } + } + } + + error.stack = [details.text, ...stackLines].join('\n'); + return error; +} -- cgit v1.2.3