summaryrefslogtreecommitdiffstats
path: root/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core
diff options
context:
space:
mode:
Diffstat (limited to 'remote/test/puppeteer/packages/puppeteer-core/src/bidi/core')
-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
10 files changed, 1992 insertions, 0 deletions
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';