summaryrefslogtreecommitdiffstats
path: root/remote/test/puppeteer/packages/puppeteer-core/src/bidi
diff options
context:
space:
mode:
Diffstat (limited to 'remote/test/puppeteer/packages/puppeteer-core/src/bidi')
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/BidiOverCdp.ts209
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Browser.ts317
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserConnector.ts123
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserContext.ts145
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowsingContext.ts187
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.test.ts50
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.ts256
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Deserializer.ts96
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Dialog.ts45
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/ElementHandle.ts87
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/EmulationManager.ts35
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/ExposedFunction.ts295
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts313
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPRequest.ts163
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPResponse.ts107
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Input.ts732
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/JSHandle.ts101
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/NetworkManager.ts155
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Page.ts913
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Realm.ts228
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Sandbox.ts123
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Serializer.ts164
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Target.ts151
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/bidi.ts22
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Browser.ts225
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/BrowsingContext.ts475
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Connection.ts139
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Navigation.ts144
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Realm.ts351
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Request.ts148
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Session.ts180
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserContext.ts178
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserPrompt.ts137
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/core.ts15
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/lifecycle.ts119
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/util.ts81
36 files changed, 7209 insertions, 0 deletions
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<BidiConnection> {
+ 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<CDPSession, CDPClientAdapter<CDPSession>>();
+ #browserCdpConnection: CDPClientAdapter<CdpConnection>;
+
+ constructor(cdp: CdpConnection) {
+ this.#cdp = cdp;
+ this.#browserCdpConnection = new CDPClientAdapter(cdp);
+ }
+
+ browserClient(): CDPClientAdapter<CdpConnection> {
+ 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<T extends CDPSession | CdpConnection>
+ extends BidiMapper.EventEmitter<CDPEvents>
+ 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<any>);
+ }
+
+ browserClient(): BidiMapper.CdpClient {
+ return this.#browserClient!;
+ }
+
+ #forwardMessage = <T extends keyof CDPEvents>(
+ method: T,
+ event: CDPEvents[T]
+ ) => {
+ this.emit(method, event);
+ };
+
+ async sendCommand<T extends keyof ProtocolMapping.Commands>(
+ method: T,
+ ...params: ProtocolMapping.Commands[T]['paramsType']
+ ): Promise<ProtocolMapping.Commands[T]['returnType']> {
+ 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<any>);
+ 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> | void =
+ async (_m: Bidi.ChromiumBidi.Command): Promise<void> => {
+ return;
+ };
+
+ emitMessage(message: Bidi.ChromiumBidi.Command) {
+ void this.#onMessage(message);
+ }
+
+ setOnMessage(
+ onMessage: (message: Bidi.ChromiumBidi.Command) => Promise<void> | void
+ ): void {
+ this.#onMessage = onMessage;
+ }
+
+ async sendMessage(message: Bidi.ChromiumBidi.Message): Promise<void> {
+ this.emit('bidiResponse', message);
+ }
+
+ close() {
+ this.#onMessage = async (_m: Bidi.ChromiumBidi.Command): Promise<void> => {
+ 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<BidiBrowser> {
+ 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<string, BidiTarget>();
+ #browserContexts = new WeakMap<UserContext, BidiBrowserContext>();
+ #browserTarget: BiDiBrowserTarget;
+
+ #connectionEventHandlers = new Map<
+ Bidi.BrowsingContextEvent['method'],
+ Handler<any>
+ >([
+ ['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<void> {
+ 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<void> {
+ 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<BidiBrowserContext> {
+ const userContext = await this.#browserCore.createUserContext();
+ return this.#createBrowserContext(userContext);
+ }
+
+ override async version(): Promise<string> {
+ 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<Page> {
+ 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<void> {
+ 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<BidiBrowser> {
+ 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<boolean>,
+ options: WaitForTargetOptions = {}
+ ): Promise<Target> {
+ return this.#browser.waitForTarget(target => {
+ return target.browserContext() === this && predicate(target);
+ }, options);
+ }
+
+ get connection(): BidiConnection {
+ return this.#connection;
+ }
+
+ override async newPage(): Promise<Page> {
+ 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<void> {
+ 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<BidiPage[]> {
+ 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<string, CdpSessionWrapper>();
+
+/**
+ * @internal
+ */
+export class CdpSessionWrapper extends CDPSession {
+ #context: BrowsingContext;
+ #sessionId = Deferred.create<string>();
+ #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<T extends keyof ProtocolMapping.Commands>(
+ method: T,
+ ...paramArgs: ProtocolMapping.Commands[T]['paramsType']
+ ): Promise<ProtocolMapping.Commands[T]['returnType']> {
+ 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<void> {
+ 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<EventType, unknown> {
+ [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<T extends keyof ProtocolMapping.Commands>(
+ method: T,
+ ...paramArgs: ProtocolMapping.Commands[T]['paramsType']
+ ): Promise<ProtocolMapping.Commands[T]['returnType']> {
+ 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<BidiEvents>
+ implements Connection
+{
+ #url: string;
+ #transport: ConnectionTransport;
+ #delay: number;
+ #timeout? = 0;
+ #closed = false;
+ #callbacks = new CallbackRegistry();
+ #browsingContexts = new Map<string, BrowsingContext>();
+ #emitters: Array<EventEmitter<any>> = [];
+
+ 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<Events extends BidiEvents>(emitter: EventEmitter<Events>): void {
+ this.#emitters.push(emitter);
+ }
+
+ override emit<Key extends keyof EventsWithWildcard<BidiEvents>>(
+ type: Key,
+ event: EventsWithWildcard<BidiEvents>[Key]
+ ): boolean {
+ for (const emitter of this.#emitters) {
+ emitter.emit(type, event);
+ }
+ return super.emit(type, event);
+ }
+
+ send<T extends keyof Commands>(
+ 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<void> {
+ 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<unknown>, value) => {
+ return acc.add(BidiDeserializer.deserializeLocalValue(value));
+ }, new Set());
+ case 'object':
+ return result.value?.reduce((acc: Record<any, unknown>, tuple) => {
+ const {key, value} = BidiDeserializer.deserializeTuple(tuple);
+ acc[key as any] = value;
+ return acc;
+ }, {});
+ case 'map':
+ return result.value?.reduce((acc: Map<unknown, unknown>, 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<void> {
+ 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<ElementType> {
+ declare handle: BidiJSHandle<ElementType>;
+
+ 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<void> {
+ 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<HTMLIFrameElement>
+ ): Promise<BidiFrame>;
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ override async contentFrame(): Promise<BidiFrame | null> {
+ 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<HTMLInputElement>): 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<void> {
+ 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<Args> = (value: [id: number, args: Args]) => void;
+type SendResolveChannel<Ret> = (
+ value: [id: number, resolve: (ret: FlattenHandle<Awaited<Ret>>) => void]
+) => void;
+type SendRejectChannel = (
+ value: [id: number, reject: (error: unknown) => void]
+) => void;
+
+interface RemotePromiseCallbacks {
+ resolve: Deferred<Bidi.Script.RemoteValue>;
+ reject: Deferred<Bidi.Script.RemoteValue>;
+}
+
+/**
+ * @internal
+ */
+export class ExposeableFunction<Args extends unknown[], Ret> {
+ readonly #frame;
+
+ readonly name;
+ readonly #apply;
+
+ readonly #channels;
+ readonly #callerInfos = new Map<
+ string,
+ Map<number, RemotePromiseCallbacks>
+ >();
+
+ #preloadScriptId?: Bidi.Script.PreloadScript;
+
+ constructor(
+ frame: BidiFrame,
+ name: string,
+ apply: (...args: Args) => Awaitable<Ret>
+ ) {
+ 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<void> {
+ 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<Args>,
+ sendResolve: SendResolveChannel<Ret>,
+ sendReject: SendRejectChannel
+ ) => {
+ let id = 0;
+ Object.assign(globalThis, {
+ [PLACEHOLDER('name') as string]: function (...args: Args) {
+ return new Promise<FlattenHandle<Awaited<Ret>>>(
+ (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<void> {
+ 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<never>();
+ #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<BidiHTTPResponse | null> {
+ 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<void> {
+ 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<BidiHTTPResponse | null> {
+ 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<string, ExposeableFunction<never[], unknown>>();
+ async exposeFunction<Args extends unknown[], Ret>(
+ name: string,
+ apply: (...args: Args) => Awaitable<Ret>
+ ): Promise<void> {
+ if (this.#exposedFunctions.has(name)) {
+ throw new Error(
+ `Failed to add page binding with name ${name}: globalThis['${name}'] already exists!`
+ );
+ }
+ const exposeable = 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 extends string>(
+ selector: Selector,
+ options?: WaitForSelectorOptions
+ ): Promise<ElementHandle<NodeFor<Selector>> | 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<string, string> = {};
+ #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<string | undefined> {
+ return this.#postData;
+ }
+
+ override headers(): Record<string, string> {
+ 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<unknown>
+ ): 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<ResponseForRequest>,
+ _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<string, string> = {};
+ #timings: Record<string, string> | 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<string, string> {
+ 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<KeyDownOptions>
+ ): Promise<void> {
+ 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<void> {
+ 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<KeyPressOptions> = {}
+ ): Promise<void> {
+ 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<KeyboardTypeOptions> = {}
+ ): Promise<void> {
+ 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<void> {
+ // 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<void> {
+ 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<BidiMouseMoveOptions> = {}
+ ): Promise<void> {
+ 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<MouseOptions> = {}): Promise<void> {
+ 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<MouseOptions> = {}): Promise<void> {
+ 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<BidiMouseClickOptions> = {}
+ ): Promise<void> {
+ 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<MouseWheelOptions> = {}
+ ): Promise<void> {
+ 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<void> {
+ 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<void> {
+ 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<void> {
+ 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<T = unknown> extends JSHandle<T> {
+ #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<T> {
+ return await this.evaluate(value => {
+ return value;
+ });
+ }
+
+ override asElement(): ElementHandle<Node> | null {
+ return null;
+ }
+
+ override async dispose(): Promise<void> {
+ 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<NetworkManagerEvents> {
+ #connection: BidiConnection;
+ #page: BidiPage;
+ #subscriptions = new DisposableStack();
+
+ #requestMap = new Map<string, BidiHTTPRequest>();
+ #navigationMap = new Map<string, BidiHTTPResponse>();
+
+ 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<BidiFrame>();
+ #networkManager: BidiNetworkManager;
+ #viewport: Viewport | null = null;
+ #closedDeferred = Deferred.create<never, TargetCloseError>();
+ #subscribedEvents = new Map<Bidi.Event['method'], Handler<any>>([
+ ['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<symbol, Handler<any>>([
+ [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<void> {
+ // TODO: handle CDP-specific cases such as mprach.
+ await this._client().send('Network.setUserAgentOverride', {
+ userAgent: userAgent,
+ userAgentMetadata: userAgentMetadata,
+ });
+ }
+
+ override async setBypassCSP(enabled: boolean): Promise<void> {
+ // TODO: handle CDP-specific cases such as mprach.
+ await this._client().send('Page.setBypassCSP', {enabled});
+ }
+
+ override async queryObjects<Prototype>(
+ prototypeHandle: BidiJSHandle<Prototype>
+ ): Promise<BidiJSHandle<Prototype[]>> {
+ 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<Prototype[]>;
+ }
+
+ _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<BidiFrame> {
+ 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 || '<anonymous>'} (${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<void> {
+ 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<BidiHTTPResponse | null> {
+ 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<void> {
+ return await this.#cdpEmulationManager.setGeolocation(options);
+ }
+
+ override async setJavaScriptEnabled(enabled: boolean): Promise<void> {
+ return await this.#cdpEmulationManager.setJavaScriptEnabled(enabled);
+ }
+
+ override async emulateMediaType(type?: string): Promise<void> {
+ return await this.#cdpEmulationManager.emulateMediaType(type);
+ }
+
+ override async emulateCPUThrottling(factor: number | null): Promise<void> {
+ return await this.#cdpEmulationManager.emulateCPUThrottling(factor);
+ }
+
+ override async emulateMediaFeatures(
+ features?: MediaFeature[]
+ ): Promise<void> {
+ return await this.#cdpEmulationManager.emulateMediaFeatures(features);
+ }
+
+ override async emulateTimezone(timezoneId?: string): Promise<void> {
+ return await this.#cdpEmulationManager.emulateTimezone(timezoneId);
+ }
+
+ override async emulateIdleState(overrides?: {
+ isUserActive: boolean;
+ isScreenUnlocked: boolean;
+ }): Promise<void> {
+ return await this.#cdpEmulationManager.emulateIdleState(overrides);
+ }
+
+ override async emulateVisionDeficiency(
+ type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type']
+ ): Promise<void> {
+ return await this.#cdpEmulationManager.emulateVisionDeficiency(type);
+ }
+
+ override async setViewport(viewport: Viewport): Promise<void> {
+ 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<Buffer> {
+ 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<Readable> {
+ 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<ScreenshotOptions>
+ ): Promise<string> {
+ 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<CDPSession> {
+ 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<void> {
+ 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<NewDocumentScriptEvaluation> {
+ 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<void> {
+ await this.#connection.send('script.removePreloadScript', {
+ script: id,
+ });
+ }
+
+ override async exposeFunction<Args extends unknown[], Ret>(
+ name: string,
+ pptrFunction:
+ | ((...args: Args) => Awaitable<Ret>)
+ | {default: (...args: Args) => Awaitable<Ret>}
+ ): Promise<void> {
+ return await this.mainFrame().exposeFunction(
+ name,
+ 'default' in pptrFunction ? pptrFunction.default : pptrFunction
+ );
+ }
+
+ override isDragInterceptionEnabled(): boolean {
+ return false;
+ }
+
+ override async setCacheEnabled(enabled?: boolean): Promise<void> {
+ // 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<HTTPResponse | null> {
+ return await this.#go(-1, options);
+ }
+
+ override async goForward(
+ options: WaitForOptions = {}
+ ): Promise<HTTPResponse | null> {
+ return await this.#go(+1, options);
+ }
+
+ async #go(
+ delta: number,
+ options: WaitForOptions
+ ): Promise<HTTPResponse | null> {
+ 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<Record<EventType, any>> {
+ 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<void> => {
+ 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<BidiJSHandle<PuppeteerUtil>>;
+ get puppeteerUtil(): Promise<BidiJSHandle<PuppeteerUtil>> {
+ const promise = Promise.resolve() as Promise<unknown>;
+ 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<PuppeteerUtil>
+ >;
+ });
+ }, !this.internalPuppeteerUtil);
+ return this.internalPuppeteerUtil as Promise<BidiJSHandle<PuppeteerUtil>>;
+ }
+
+ async evaluateHandle<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
+ return await this.#evaluate(false, pageFunction, ...args);
+ }
+
+ async evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ return await this.#evaluate(true, pageFunction, ...args);
+ }
+
+ async #evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ returnByValue: true,
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>>;
+ async #evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ returnByValue: false,
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>>;
+ async #evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ returnByValue: boolean,
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>> | Awaited<ReturnType<Func>>> {
+ 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<unknown> | BidiElementHandle<Node> {
+ 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<Params> = EvaluateFunc<Params>,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
+ pageFunction = withSourcePuppeteerURLIfNone(
+ this.evaluateHandle.name,
+ pageFunction
+ );
+ return await this.realm.evaluateHandle(pageFunction, ...args);
+ }
+
+ async evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ pageFunction = withSourcePuppeteerURLIfNone(
+ this.evaluate.name,
+ pageFunction
+ );
+ return await this.realm.evaluate(pageFunction, ...args);
+ }
+
+ async adoptHandle<T extends JSHandle<Node>>(handle: T): Promise<T> {
+ return (await this.evaluateHandle(node => {
+ return node;
+ }, handle)) as unknown as T;
+ }
+
+ async transferHandle<T extends JSHandle<Node>>(handle: T): Promise<T> {
+ 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<JSHandle<Node>> {
+ 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<Bidi.Script.LocalValue> {
+ 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<Page> {
+ 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<CDPSession> {
+ 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<Page> {
+ 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<CDPSession> {
+ 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<CDPSession> {
+ 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<BidiPage> {
+ 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<Browser> {
+ 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<string, UserContext>();
+ 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<string>();
+ 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<UserContext> {
+ return this.#userContexts.values();
+ }
+ // keep-sorted end
+
+ @inertIfDisposed
+ dispose(reason?: string, closed = false): void {
+ this.#closed = closed;
+ this.#reason = reason;
+ this[disposeSymbol]();
+ }
+
+ @throwIfDisposed<Browser>(browser => {
+ // SAFETY: By definition of `disposed`, `#reason` is defined.
+ return browser.#reason!;
+ })
+ async close(): Promise<void> {
+ try {
+ await this.session.send('browser.close', {});
+ } finally {
+ this.dispose('Browser already closed.', true);
+ }
+ }
+
+ @throwIfDisposed<Browser>(browser => {
+ // SAFETY: By definition of `disposed`, `#reason` is defined.
+ return browser.#reason!;
+ })
+ async addPreloadScript(
+ functionDeclaration: string,
+ options: AddPreloadScriptOptions = {}
+ ): Promise<string> {
+ 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>(browser => {
+ // SAFETY: By definition of `disposed`, `#reason` is defined.
+ return browser.#reason!;
+ })
+ async removePreloadScript(script: string): Promise<void> {
+ await this.session.send('script.removePreloadScript', {
+ script,
+ });
+ }
+
+ static userContextId = 0;
+ @throwIfDisposed<Browser>(browser => {
+ // SAFETY: By definition of `disposed`, `#reason` is defined.
+ return browser.#reason!;
+ })
+ async createUserContext(): Promise<UserContext> {
+ // 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<string, BrowsingContext>();
+ readonly #disposables = new DisposableStack();
+ readonly #realms = new Map<string, WindowRealm>();
+ readonly #requests = new Map<string, Request>();
+ 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<BrowsingContext> {
+ return this.#children.values();
+ }
+ get closed(): boolean {
+ return this.#reason !== undefined;
+ }
+ get disposed(): boolean {
+ return this.closed;
+ }
+ get realms(): Iterable<WindowRealm> {
+ 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<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async activate(): Promise<void> {
+ await this.#session.send('browsingContext.activate', {
+ context: this.id,
+ });
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async captureScreenshot(
+ options: CaptureScreenshotOptions = {}
+ ): Promise<string> {
+ const {
+ result: {data},
+ } = await this.#session.send('browsingContext.captureScreenshot', {
+ context: this.id,
+ ...options,
+ });
+ return data;
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async close(promptUnload?: boolean): Promise<void> {
+ await Promise.all(
+ [...this.#children.values()].map(async child => {
+ await child.close(promptUnload);
+ })
+ );
+ await this.#session.send('browsingContext.close', {
+ context: this.id,
+ promptUnload,
+ });
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async traverseHistory(delta: number): Promise<void> {
+ await this.#session.send('browsingContext.traverseHistory', {
+ context: this.id,
+ delta,
+ });
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async navigate(
+ url: string,
+ wait?: Bidi.BrowsingContext.ReadinessState
+ ): Promise<Navigation> {
+ await this.#session.send('browsingContext.navigate', {
+ context: this.id,
+ url,
+ wait,
+ });
+ return await new Promise(resolve => {
+ this.once('navigation', ({navigation}) => {
+ resolve(navigation);
+ });
+ });
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async reload(options: ReloadOptions = {}): Promise<Navigation> {
+ await this.#session.send('browsingContext.reload', {
+ context: this.id,
+ ...options,
+ });
+ return await new Promise(resolve => {
+ this.once('navigation', ({navigation}) => {
+ resolve(navigation);
+ });
+ });
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async print(options: PrintOptions = {}): Promise<string> {
+ const {
+ result: {data},
+ } = await this.#session.send('browsingContext.print', {
+ context: this.id,
+ ...options,
+ });
+ return data;
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async handleUserPrompt(options: HandleUserPromptOptions = {}): Promise<void> {
+ await this.#session.send('browsingContext.handleUserPrompt', {
+ context: this.id,
+ ...options,
+ });
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async setViewport(options: SetViewportOptions = {}): Promise<void> {
+ await this.#session.send('browsingContext.setViewport', {
+ context: this.id,
+ ...options,
+ });
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async performActions(actions: Bidi.Input.SourceActions[]): Promise<void> {
+ await this.#session.send('input.performActions', {
+ context: this.id,
+ actions,
+ });
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async releaseActions(): Promise<void> {
+ await this.#session.send('input.releaseActions', {
+ context: this.id,
+ });
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ createWindowRealm(sandbox: string): WindowRealm {
+ return WindowRealm.from(this, sandbox);
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async addPreloadScript(
+ functionDeclaration: string,
+ options: AddPreloadScriptOptions = {}
+ ): Promise<string> {
+ return await this.userContext.browser.addPreloadScript(
+ functionDeclaration,
+ {
+ ...options,
+ contexts: [this, ...(options.contexts ?? [])],
+ }
+ );
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async removePreloadScript(script: string): Promise<void> {
+ 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<Events extends BidiEvents = BidiEvents>
+ extends EventEmitter<Events> {
+ send<T extends keyof Commands>(
+ method: T,
+ params: Commands[T]['params']
+ ): Promise<{result: Commands[T]['returnType']}>;
+
+ // This will pipe events into the provided emitter.
+ pipeTo<Events extends BidiEvents>(emitter: EventEmitter<Events>): 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<string>();
+ // 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>(realm => {
+ // SAFETY: Disposal implies this exists.
+ return realm.#reason!;
+ })
+ async disown(handles: string[]): Promise<void> {
+ await this.session.send('script.disown', {
+ target: this.target,
+ handles,
+ });
+ }
+
+ @throwIfDisposed<Realm>(realm => {
+ // SAFETY: Disposal implies this exists.
+ return realm.#reason!;
+ })
+ async callFunction(
+ functionDeclaration: string,
+ awaitPromise: boolean,
+ options: CallFunctionOptions = {}
+ ): Promise<Bidi.Script.EvaluateResult> {
+ const {result} = await this.session.send('script.callFunction', {
+ functionDeclaration,
+ awaitPromise,
+ target: this.target,
+ ...options,
+ });
+ return result;
+ }
+
+ @throwIfDisposed<Realm>(realm => {
+ // SAFETY: Disposal implies this exists.
+ return realm.#reason!;
+ })
+ async evaluate(
+ expression: string,
+ awaitPromise: boolean,
+ options: EvaluateOptions = {}
+ ): Promise<Bidi.Script.EvaluateResult> {
+ 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<string, DedicatedWorkerRealm>;
+ shared: Map<string, SharedWorkerRealm>;
+ } = {
+ 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<string, DedicatedWorkerRealm>();
+ readonly owners: Set<DedicatedWorkerOwnerRealm>;
+ // 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<string, DedicatedWorkerRealm>();
+ readonly owners: Set<WindowRealm>;
+ // 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<BidiEvents & {ended: {reason: string}}>
+ implements Connection<BidiEvents & {ended: {reason: string}}>
+{
+ static async from(
+ connection: Connection,
+ capabilities: Bidi.Session.CapabilitiesRequest
+ ): Promise<Session> {
+ // 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<void> {
+ 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<Events extends BidiEvents>(emitter: EventEmitter<Events>): 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>(session => {
+ // SAFETY: By definition of `disposed`, `#reason` is defined.
+ return session.#reason!;
+ })
+ async send<T extends keyof Commands>(
+ method: T,
+ params: Commands[T]['params']
+ ): Promise<{result: Commands[T]['returnType']}> {
+ return await this.connection.send(method, params);
+ }
+
+ @throwIfDisposed<Session>(session => {
+ // SAFETY: By definition of `disposed`, `#reason` is defined.
+ return session.#reason!;
+ })
+ async subscribe(events: string[]): Promise<void> {
+ await this.send('session.subscribe', {
+ events,
+ });
+ }
+
+ @throwIfDisposed<Session>(session => {
+ // SAFETY: By definition of `disposed`, `#reason` is defined.
+ return session.#reason!;
+ })
+ async end(): Promise<void> {
+ 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<string, BrowsingContext>();
+ 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<BrowsingContext> {
+ 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<UserContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async createBrowsingContext(
+ type: Bidi.BrowsingContext.CreateType,
+ options: CreateBrowsingContextOptions = {}
+ ): Promise<BrowsingContext> {
+ 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<UserContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async remove(): Promise<void> {
+ 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<UserPrompt>(prompt => {
+ // SAFETY: Disposal implies this exists.
+ return prompt.#reason!;
+ })
+ async handle(options: HandleOptions = {}): Promise<UserPromptResult> {
+ 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<PuppeteerLifeCycleEvent, 'load' | 'domcontentloaded'>,
+ 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<T, R extends ObservableInput<T>>(
+ message: string,
+ ms: number
+): OperatorFunction<T, T | ObservedValueOf<R>> {
+ return catchError<T, R>(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<void> {
+ 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}, <anonymous>:${frame.lineNumber}:${
+ frame.columnNumber
+ })`
+ );
+ } else {
+ stackLines.push(
+ ` at ${frame.functionName || '<anonymous>'} (${frame.url}:${
+ frame.lineNumber
+ }:${frame.columnNumber})`
+ );
+ }
+ if (stackLines.length >= Error.stackTraceLimit) {
+ break;
+ }
+ }
+ }
+
+ error.stack = [details.text, ...stackLines].join('\n');
+ return error;
+}