diff options
Diffstat (limited to 'remote/test/puppeteer/packages/puppeteer-core/src/bidi/core')
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'; |