diff options
Diffstat (limited to 'remote/test/puppeteer/packages/puppeteer-core/src/bidi')
33 files changed, 2557 insertions, 2538 deletions
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Browser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Browser.ts index 42979790c9..8798d8325d 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Browser.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Browser.ts @@ -8,6 +8,7 @@ import type {ChildProcess} from 'child_process'; import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; +import type {BrowserEvents} from '../api/Browser.js'; import { Browser, BrowserEvent, @@ -19,22 +20,17 @@ import {BrowserContextEvent} from '../api/BrowserContext.js'; import type {Page} from '../api/Page.js'; import type {Target} from '../api/Target.js'; import {UnsupportedOperation} from '../common/Errors.js'; -import type {Handler} from '../common/EventEmitter.js'; +import {EventEmitter} from '../common/EventEmitter.js'; import {debugError} from '../common/util.js'; import type {Viewport} from '../common/Viewport.js'; +import {bubble} from '../util/decorators.js'; import {BidiBrowserContext} from './BrowserContext.js'; -import {BrowsingContext, BrowsingContextEvent} from './BrowsingContext.js'; import type {BidiConnection} from './Connection.js'; import type {Browser as BrowserCore} from './core/Browser.js'; import {Session} from './core/Session.js'; import type {UserContext} from './core/UserContext.js'; -import { - BiDiBrowserTarget, - BiDiBrowsingContextTarget, - BiDiPageTarget, - type BidiTarget, -} from './Target.js'; +import {BidiBrowserTarget} from './Target.js'; /** * @internal @@ -89,28 +85,18 @@ export class BidiBrowser extends Browser { const browser = new BidiBrowser(session.browser, opts); browser.#initialize(); - await browser.#getTree(); return browser; } + @bubble() + accessor #trustedEmitter = new EventEmitter<BrowserEvents>(); + #process?: ChildProcess; #closeCallback?: BrowserCloseCallback; #browserCore: BrowserCore; #defaultViewport: Viewport | null; - #targets = new Map<string, BidiTarget>(); #browserContexts = new WeakMap<UserContext, BidiBrowserContext>(); - #browserTarget: BiDiBrowserTarget; - - #connectionEventHandlers = new Map< - Bidi.BrowsingContextEvent['method'], - Handler<any> - >([ - ['browsingContext.contextCreated', this.#onContextCreated.bind(this)], - ['browsingContext.contextDestroyed', this.#onContextDestroyed.bind(this)], - ['browsingContext.domContentLoaded', this.#onContextDomLoaded.bind(this)], - ['browsingContext.fragmentNavigated', this.#onContextNavigation.bind(this)], - ['browsingContext.navigationStarted', this.#onContextNavigation.bind(this)], - ]); + #target = new BidiBrowserTarget(this); private constructor(browserCore: BrowserCore, opts: BidiBrowserOptions) { super(); @@ -118,22 +104,22 @@ export class BidiBrowser extends Browser { this.#closeCallback = opts.closeCallback; this.#browserCore = browserCore; this.#defaultViewport = opts.defaultViewport; - this.#browserTarget = new BiDiBrowserTarget(this); - this.#createBrowserContext(this.#browserCore.defaultUserContext); } #initialize() { + // Initializing existing contexts. + for (const userContext of this.#browserCore.userContexts) { + this.#createBrowserContext(userContext); + } + this.#browserCore.once('disconnected', () => { - this.emit(BrowserEvent.Disconnected, undefined); + this.#trustedEmitter.emit(BrowserEvent.Disconnected, undefined); + this.#trustedEmitter.removeAllListeners(); }); this.#process?.once('close', () => { this.#browserCore.dispose('Browser process exited.', true); this.connection.dispose(); }); - - for (const [eventName, handler] of this.#connectionEventHandlers) { - this.connection.on(eventName, handler); - } } get #browserName() { @@ -143,82 +129,40 @@ export class BidiBrowser extends Browser { return this.#browserCore.session.capabilities.browserVersion; } + get cdpSupported(): boolean { + return !this.#browserName.toLocaleLowerCase().includes('firefox'); + } + override userAgent(): never { throw new UnsupportedOperation(); } #createBrowserContext(userContext: UserContext) { - const browserContext = new BidiBrowserContext(this, userContext, { + const browserContext = BidiBrowserContext.from(this, userContext, { defaultViewport: this.#defaultViewport, }); this.#browserContexts.set(userContext, browserContext); - return browserContext; - } - - #onContextDomLoaded(event: Bidi.BrowsingContext.Info) { - const target = this.#targets.get(event.context); - if (target) { - this.emit(BrowserEvent.TargetChanged, target); - } - } - - #onContextNavigation(event: Bidi.BrowsingContext.NavigationInfo) { - const target = this.#targets.get(event.context); - if (target) { - this.emit(BrowserEvent.TargetChanged, target); - target.browserContext().emit(BrowserContextEvent.TargetChanged, target); - } - } - #onContextCreated(event: Bidi.BrowsingContext.ContextCreated['params']) { - const context = new BrowsingContext( - this.connection, - event, - this.#browserName + browserContext.trustedEmitter.on( + BrowserContextEvent.TargetCreated, + target => { + this.#trustedEmitter.emit(BrowserEvent.TargetCreated, target); + } + ); + browserContext.trustedEmitter.on( + BrowserContextEvent.TargetChanged, + target => { + this.#trustedEmitter.emit(BrowserEvent.TargetChanged, target); + } + ); + browserContext.trustedEmitter.on( + BrowserContextEvent.TargetDestroyed, + target => { + this.#trustedEmitter.emit(BrowserEvent.TargetDestroyed, target); + } ); - this.connection.registerBrowsingContexts(context); - // TODO: once more browsing context types are supported, this should be - // updated to support those. Currently, all top-level contexts are treated - // as pages. - const browserContext = this.browserContexts().at(-1); - if (!browserContext) { - throw new Error('Missing browser contexts'); - } - const target = !context.parent - ? new BiDiPageTarget(browserContext, context) - : new BiDiBrowsingContextTarget(browserContext, context); - this.#targets.set(event.context, target); - - this.emit(BrowserEvent.TargetCreated, target); - target.browserContext().emit(BrowserContextEvent.TargetCreated, target); - - if (context.parent) { - const topLevel = this.connection.getTopLevelContext(context.parent); - topLevel.emit(BrowsingContextEvent.Created, context); - } - } - - async #getTree(): Promise<void> { - const {result} = await this.connection.send('browsingContext.getTree', {}); - for (const context of result.contexts) { - this.#onContextCreated(context); - } - } - async #onContextDestroyed( - event: Bidi.BrowsingContext.ContextDestroyed['params'] - ) { - const context = this.connection.getBrowsingContext(event.context); - const topLevelContext = this.connection.getTopLevelContext(event.context); - topLevelContext.emit(BrowsingContextEvent.Destroyed, context); - const target = this.#targets.get(event.context); - const page = await target?.page(); - await page?.close().catch(debugError); - this.#targets.delete(event.context); - if (target) { - this.emit(BrowserEvent.TargetDestroyed, target); - target.browserContext().emit(BrowserContextEvent.TargetDestroyed, target); - } + return browserContext; } get connection(): BidiConnection { @@ -231,9 +175,6 @@ export class BidiBrowser extends Browser { } override async close(): Promise<void> { - for (const [eventName, handler] of this.#connectionEventHandlers) { - this.connection.off(eventName, handler); - } if (this.connection.closed) { return; } @@ -250,14 +191,14 @@ export class BidiBrowser extends Browser { } override get connected(): boolean { - return !this.#browserCore.disposed; + return !this.#browserCore.disconnected; } override process(): ChildProcess | null { return this.#process ?? null; } - override async createIncognitoBrowserContext( + override async createBrowserContext( _options?: BrowserContextOptions ): Promise<BidiBrowserContext> { const userContext = await this.#browserCore.createUserContext(); @@ -283,19 +224,16 @@ export class BidiBrowser extends Browser { } override targets(): Target[] { - return [this.#browserTarget, ...Array.from(this.#targets.values())]; - } - - _getTargetById(id: string): BidiTarget { - const target = this.#targets.get(id); - if (!target) { - throw new Error('Target not found'); - } - return target; + return [ + this.#target, + ...this.browserContexts().flatMap(context => { + return context.targets(); + }), + ]; } - override target(): Target { - return this.#browserTarget; + override target(): BidiBrowserTarget { + return this.#target; } override async disconnect(): Promise<void> { diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserContext.ts index feb5e9951d..9976e4cc6a 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserContext.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserContext.ts @@ -6,18 +6,25 @@ import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; -import type {WaitForTargetOptions} from '../api/Browser.js'; -import {BrowserContext} from '../api/BrowserContext.js'; -import type {Page} from '../api/Page.js'; +import type {Permission} from '../api/Browser.js'; +import {WEB_PERMISSION_TO_PROTOCOL_PERMISSION} from '../api/Browser.js'; +import type {BrowserContextEvents} from '../api/BrowserContext.js'; +import {BrowserContext, BrowserContextEvent} from '../api/BrowserContext.js'; +import {PageEvent, type Page} from '../api/Page.js'; import type {Target} from '../api/Target.js'; -import {UnsupportedOperation} from '../common/Errors.js'; +import {EventEmitter} from '../common/EventEmitter.js'; import {debugError} from '../common/util.js'; import type {Viewport} from '../common/Viewport.js'; +import {bubble} from '../util/decorators.js'; import type {BidiBrowser} from './Browser.js'; -import type {BidiConnection} from './Connection.js'; +import type {BrowsingContext} from './core/BrowsingContext.js'; import {UserContext} from './core/UserContext.js'; -import type {BidiPage} from './Page.js'; +import type {BidiFrame} from './Frame.js'; +import {BidiPage} from './Page.js'; +import {BidiWorkerTarget} from './Target.js'; +import {BidiFrameTarget, BidiPageTarget} from './Target.js'; +import type {BidiWebWorker} from './WebWorker.js'; /** * @internal @@ -30,56 +37,134 @@ export interface BidiBrowserContextOptions { * @internal */ export class BidiBrowserContext extends BrowserContext { - #browser: BidiBrowser; - #connection: BidiConnection; - #defaultViewport: Viewport | null; - #userContext: UserContext; + static from( + browser: BidiBrowser, + userContext: UserContext, + options: BidiBrowserContextOptions + ): BidiBrowserContext { + const context = new BidiBrowserContext(browser, userContext, options); + context.#initialize(); + return context; + } + + @bubble() + accessor trustedEmitter = new EventEmitter<BrowserContextEvents>(); + + readonly #browser: BidiBrowser; + readonly #defaultViewport: Viewport | null; + // This is public because of cookies. + readonly userContext: UserContext; + readonly #pages = new WeakMap<BrowsingContext, BidiPage>(); + readonly #targets = new Map< + BidiPage, + [ + BidiPageTarget, + Map<BidiFrame | BidiWebWorker, BidiFrameTarget | BidiWorkerTarget>, + ] + >(); - constructor( + #overrides: Array<{origin: string; permission: Permission}> = []; + + private constructor( browser: BidiBrowser, userContext: UserContext, options: BidiBrowserContextOptions ) { super(); this.#browser = browser; - this.#userContext = userContext; - this.#connection = this.#browser.connection; + this.userContext = userContext; this.#defaultViewport = options.defaultViewport; } - override targets(): Target[] { - return this.#browser.targets().filter(target => { - return target.browserContext() === this; + #initialize() { + // Create targets for existing browsing contexts. + for (const browsingContext of this.userContext.browsingContexts) { + this.#createPage(browsingContext); + } + + this.userContext.on('browsingcontext', ({browsingContext}) => { + this.#createPage(browsingContext); + }); + this.userContext.on('closed', () => { + this.trustedEmitter.removeAllListeners(); }); } - override waitForTarget( - predicate: (x: Target) => boolean | Promise<boolean>, - options: WaitForTargetOptions = {} - ): Promise<Target> { - return this.#browser.waitForTarget(target => { - return target.browserContext() === this && predicate(target); - }, options); - } + #createPage(browsingContext: BrowsingContext): BidiPage { + const page = BidiPage.from(this, browsingContext); + this.#pages.set(browsingContext, page); + page.trustedEmitter.on(PageEvent.Close, () => { + this.#pages.delete(browsingContext); + }); - get connection(): BidiConnection { - return this.#connection; - } + // -- Target stuff starts here -- + const pageTarget = new BidiPageTarget(page); + const pageTargets = new Map(); + this.#targets.set(page, [pageTarget, pageTargets]); - override async newPage(): Promise<Page> { - const {result} = await this.#connection.send('browsingContext.create', { - type: Bidi.BrowsingContext.CreateType.Tab, + page.trustedEmitter.on(PageEvent.FrameAttached, frame => { + const bidiFrame = frame as BidiFrame; + const target = new BidiFrameTarget(bidiFrame); + pageTargets.set(bidiFrame, target); + this.trustedEmitter.emit(BrowserContextEvent.TargetCreated, target); + }); + page.trustedEmitter.on(PageEvent.FrameNavigated, frame => { + const bidiFrame = frame as BidiFrame; + const target = pageTargets.get(bidiFrame); + // If there is no target, then this is the page's frame. + if (target === undefined) { + this.trustedEmitter.emit(BrowserContextEvent.TargetChanged, pageTarget); + } else { + this.trustedEmitter.emit(BrowserContextEvent.TargetChanged, target); + } + }); + page.trustedEmitter.on(PageEvent.FrameDetached, frame => { + const bidiFrame = frame as BidiFrame; + const target = pageTargets.get(bidiFrame); + if (target === undefined) { + return; + } + pageTargets.delete(bidiFrame); + this.trustedEmitter.emit(BrowserContextEvent.TargetDestroyed, target); + }); + + page.trustedEmitter.on(PageEvent.WorkerCreated, worker => { + const bidiWorker = worker as BidiWebWorker; + const target = new BidiWorkerTarget(bidiWorker); + pageTargets.set(bidiWorker, target); + this.trustedEmitter.emit(BrowserContextEvent.TargetCreated, target); + }); + page.trustedEmitter.on(PageEvent.WorkerDestroyed, worker => { + const bidiWorker = worker as BidiWebWorker; + const target = pageTargets.get(bidiWorker); + if (target === undefined) { + return; + } + pageTargets.delete(worker); + this.trustedEmitter.emit(BrowserContextEvent.TargetDestroyed, target); }); - const target = this.#browser._getTargetById(result.context); - // TODO: once BiDi has some concept matching BrowserContext, the newly - // created contexts should get automatically assigned to the right - // BrowserContext. For now, we assume that only explicitly created pages go - // to the current BrowserContext. Otherwise, the contexts get assigned to - // the default BrowserContext by the Browser. - target._setBrowserContext(this); + page.trustedEmitter.on(PageEvent.Close, () => { + this.#targets.delete(page); + this.trustedEmitter.emit(BrowserContextEvent.TargetDestroyed, pageTarget); + }); + this.trustedEmitter.emit(BrowserContextEvent.TargetCreated, pageTarget); + // -- Target stuff ends here -- + + return page; + } + + override targets(): Target[] { + return [...this.#targets.values()].flatMap(([target, frames]) => { + return [target, ...frames.values()]; + }); + } - const page = await target.page(); + override async newPage(): Promise<Page> { + const context = await this.userContext.createBrowsingContext( + Bidi.BrowsingContext.CreateType.Tab + ); + const page = this.#pages.get(context)!; if (!page) { throw new Error('Page is not found'); } @@ -99,18 +184,8 @@ export class BidiBrowserContext extends BrowserContext { throw new Error('Default context cannot be closed!'); } - // TODO: Remove once we have adopted the new browsing contexts. - for (const target of this.targets()) { - const page = await target?.page(); - try { - await page?.close(); - } catch (error) { - debugError(error); - } - } - try { - await this.#userContext.remove(); + await this.userContext.remove(); } catch (error) { debugError(error); } @@ -121,25 +196,73 @@ export class BidiBrowserContext extends BrowserContext { } override async pages(): Promise<BidiPage[]> { - const results = await Promise.all( - [...this.targets()].map(t => { - return t.page(); - }) - ); - return results.filter((p): p is BidiPage => { - return p !== null; + return [...this.userContext.browsingContexts].map(context => { + return this.#pages.get(context)!; }); } override isIncognito(): boolean { - return this.#userContext.id !== UserContext.DEFAULT; + return this.userContext.id !== UserContext.DEFAULT; } - override overridePermissions(): never { - throw new UnsupportedOperation(); + override async overridePermissions( + origin: string, + permissions: Permission[] + ): Promise<void> { + const permissionsSet = new Set( + permissions.map(permission => { + const protocolPermission = + WEB_PERMISSION_TO_PROTOCOL_PERMISSION.get(permission); + if (!protocolPermission) { + throw new Error('Unknown permission: ' + permission); + } + return permission; + }) + ); + await Promise.all( + Array.from(WEB_PERMISSION_TO_PROTOCOL_PERMISSION.keys()).map( + permission => { + const result = this.userContext.setPermissions( + origin, + { + name: permission, + }, + permissionsSet.has(permission) + ? Bidi.Permissions.PermissionState.Granted + : Bidi.Permissions.PermissionState.Denied + ); + this.#overrides.push({origin, permission}); + // TODO: some permissions are outdated and setting them to denied does + // not work. + if (!permissionsSet.has(permission)) { + return result.catch(debugError); + } + return result; + } + ) + ); } - override clearPermissionOverrides(): never { - throw new UnsupportedOperation(); + override async clearPermissionOverrides(): Promise<void> { + const promises = this.#overrides.map(({permission, origin}) => { + return this.userContext + .setPermissions( + origin, + { + name: permission, + }, + Bidi.Permissions.PermissionState.Prompt + ) + .catch(debugError); + }); + this.#overrides = []; + await Promise.all(promises); + } + + override get id(): string | undefined { + if (this.userContext.id === UserContext.DEFAULT) { + return undefined; + } + return this.userContext.id; } } diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowsingContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowsingContext.ts deleted file mode 100644 index 0804628c06..0000000000 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowsingContext.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; -import type ProtocolMapping from 'devtools-protocol/types/protocol-mapping.js'; - -import {CDPSession} from '../api/CDPSession.js'; -import type {Connection as CdpConnection} from '../cdp/Connection.js'; -import {TargetCloseError, UnsupportedOperation} from '../common/Errors.js'; -import type {EventType} from '../common/EventEmitter.js'; -import {debugError} from '../common/util.js'; -import {Deferred} from '../util/Deferred.js'; - -import type {BidiConnection} from './Connection.js'; -import {BidiRealm} from './Realm.js'; - -/** - * @internal - */ -export const cdpSessions = new Map<string, CdpSessionWrapper>(); - -/** - * @internal - */ -export class CdpSessionWrapper extends CDPSession { - #context: BrowsingContext; - #sessionId = Deferred.create<string>(); - #detached = false; - - constructor(context: BrowsingContext, sessionId?: string) { - super(); - this.#context = context; - if (!this.#context.supportsCdp()) { - return; - } - if (sessionId) { - this.#sessionId.resolve(sessionId); - cdpSessions.set(sessionId, this); - } else { - context.connection - .send('cdp.getSession', { - context: context.id, - }) - .then(session => { - this.#sessionId.resolve(session.result.session!); - cdpSessions.set(session.result.session!, this); - }) - .catch(err => { - this.#sessionId.reject(err); - }); - } - } - - override connection(): CdpConnection | undefined { - return undefined; - } - - override async send<T extends keyof ProtocolMapping.Commands>( - method: T, - ...paramArgs: ProtocolMapping.Commands[T]['paramsType'] - ): Promise<ProtocolMapping.Commands[T]['returnType']> { - if (!this.#context.supportsCdp()) { - throw new UnsupportedOperation( - 'CDP support is required for this feature. The current browser does not support CDP.' - ); - } - if (this.#detached) { - throw new TargetCloseError( - `Protocol error (${method}): Session closed. Most likely the page has been closed.` - ); - } - const session = await this.#sessionId.valueOrThrow(); - const {result} = await this.#context.connection.send('cdp.sendCommand', { - method: method, - params: paramArgs[0], - session, - }); - return result.result; - } - - override async detach(): Promise<void> { - cdpSessions.delete(this.id()); - if (!this.#detached && this.#context.supportsCdp()) { - await this.#context.cdpSession.send('Target.detachFromTarget', { - sessionId: this.id(), - }); - } - this.#detached = true; - } - - override id(): string { - const val = this.#sessionId.value(); - return val instanceof Error || val === undefined ? '' : val; - } -} - -/** - * Internal events that the BrowsingContext class emits. - * - * @internal - */ -// eslint-disable-next-line @typescript-eslint/no-namespace -export namespace BrowsingContextEvent { - /** - * Emitted on the top-level context, when a descendant context is created. - */ - export const Created = Symbol('BrowsingContext.created'); - /** - * Emitted on the top-level context, when a descendant context or the - * top-level context itself is destroyed. - */ - export const Destroyed = Symbol('BrowsingContext.destroyed'); -} - -/** - * @internal - */ -export interface BrowsingContextEvents extends Record<EventType, unknown> { - [BrowsingContextEvent.Created]: BrowsingContext; - [BrowsingContextEvent.Destroyed]: BrowsingContext; -} - -/** - * @internal - */ -export class BrowsingContext extends BidiRealm { - #id: string; - #url: string; - #cdpSession: CDPSession; - #parent?: string | null; - #browserName = ''; - - constructor( - connection: BidiConnection, - info: Bidi.BrowsingContext.Info, - browserName: string - ) { - super(connection); - this.#id = info.context; - this.#url = info.url; - this.#parent = info.parent; - this.#browserName = browserName; - this.#cdpSession = new CdpSessionWrapper(this, undefined); - - this.on('browsingContext.domContentLoaded', this.#updateUrl.bind(this)); - this.on('browsingContext.fragmentNavigated', this.#updateUrl.bind(this)); - this.on('browsingContext.load', this.#updateUrl.bind(this)); - } - - supportsCdp(): boolean { - return !this.#browserName.toLowerCase().includes('firefox'); - } - - #updateUrl(info: Bidi.BrowsingContext.NavigationInfo) { - this.#url = info.url; - } - - createRealmForSandbox(): BidiRealm { - return new BidiRealm(this.connection); - } - - get url(): string { - return this.#url; - } - - get id(): string { - return this.#id; - } - - get parent(): string | undefined | null { - return this.#parent; - } - - get cdpSession(): CDPSession { - return this.#cdpSession; - } - - async sendCdpCommand<T extends keyof ProtocolMapping.Commands>( - method: T, - ...paramArgs: ProtocolMapping.Commands[T]['paramsType'] - ): Promise<ProtocolMapping.Commands[T]['returnType']> { - return await this.#cdpSession.send(method, ...paramArgs); - } - - dispose(): void { - this.removeAllListeners(); - this.connection.unregisterBrowsingContexts(this.#id); - void this.#cdpSession.detach().catch(debugError); - } -} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/CDPSession.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/CDPSession.ts new file mode 100644 index 0000000000..1e0c503498 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/CDPSession.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type ProtocolMapping from 'devtools-protocol/types/protocol-mapping.js'; + +import {CDPSession} from '../api/CDPSession.js'; +import type {Connection as CdpConnection} from '../cdp/Connection.js'; +import {TargetCloseError, UnsupportedOperation} from '../common/Errors.js'; +import {Deferred} from '../util/Deferred.js'; + +import type {BidiConnection} from './Connection.js'; +import type {BidiFrame} from './Frame.js'; + +/** + * @internal + */ +export class BidiCdpSession extends CDPSession { + static sessions = new Map<string, BidiCdpSession>(); + + #detached = false; + readonly #connection: BidiConnection | undefined = undefined; + readonly #sessionId = Deferred.create<string>(); + readonly frame: BidiFrame; + + constructor(frame: BidiFrame, sessionId?: string) { + super(); + this.frame = frame; + if (!this.frame.page().browser().cdpSupported) { + return; + } + + const connection = this.frame.page().browser().connection; + this.#connection = connection; + + if (sessionId) { + this.#sessionId.resolve(sessionId); + BidiCdpSession.sessions.set(sessionId, this); + } else { + (async () => { + try { + const session = await connection.send('cdp.getSession', { + context: frame._id, + }); + this.#sessionId.resolve(session.result.session!); + BidiCdpSession.sessions.set(session.result.session!, this); + } catch (error) { + this.#sessionId.reject(error as Error); + } + })(); + } + + // SAFETY: We never throw #sessionId. + BidiCdpSession.sessions.set(this.#sessionId.value() as string, this); + } + + override connection(): CdpConnection | undefined { + return undefined; + } + + override async send<T extends keyof ProtocolMapping.Commands>( + method: T, + params?: ProtocolMapping.Commands[T]['paramsType'][0] + ): Promise<ProtocolMapping.Commands[T]['returnType']> { + if (this.#connection === undefined) { + throw new UnsupportedOperation( + 'CDP support is required for this feature. The current browser does not support CDP.' + ); + } + if (this.#detached) { + throw new TargetCloseError( + `Protocol error (${method}): Session closed. Most likely the page has been closed.` + ); + } + const session = await this.#sessionId.valueOrThrow(); + const {result} = await this.#connection.send('cdp.sendCommand', { + method: method, + params: params, + session, + }); + return result.result; + } + + override async detach(): Promise<void> { + if (this.#connection === undefined || this.#detached) { + return; + } + try { + await this.frame.client.send('Target.detachFromTarget', { + sessionId: this.id(), + }); + } finally { + BidiCdpSession.sessions.delete(this.id()); + this.#detached = true; + } + } + + override id(): string { + const value = this.#sessionId.value(); + return typeof value === 'string' ? value : ''; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.ts index bce952ba39..dd688c309a 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.ts @@ -14,10 +14,10 @@ import {EventEmitter} from '../common/EventEmitter.js'; import {debugError} from '../common/util.js'; import {assert} from '../util/assert.js'; -import {cdpSessions, type BrowsingContext} from './BrowsingContext.js'; +import {BidiCdpSession} from './CDPSession.js'; import type { - BidiEvents, Commands as BidiCommands, + BidiEvents, Connection, } from './core/Connection.js'; @@ -36,6 +36,10 @@ export interface Commands extends BidiCommands { params: Bidi.Cdp.GetSessionParameters; returnType: Bidi.Cdp.GetSessionResult; }; + 'cdp.resolveRealm': { + params: Bidi.Cdp.ResolveRealmParameters; + returnType: Bidi.Cdp.ResolveRealmResult; + }; } /** @@ -51,7 +55,6 @@ export class BidiConnection #timeout? = 0; #closed = false; #callbacks = new CallbackRegistry(); - #browsingContexts = new Map<string, BrowsingContext>(); #emitters: Array<EventEmitter<any>> = []; constructor( @@ -137,12 +140,11 @@ export class BidiConnection return; case 'event': if (isCdpEvent(object)) { - cdpSessions + BidiCdpSession.sessions .get(object.params.session) ?.emit(object.params.event, object.params.params); return; } - this.#maybeEmitOnContext(object); // SAFETY: We know the method and parameter still match here. this.emit( object.method, @@ -163,52 +165,6 @@ export class BidiConnection debugError(object); } - #maybeEmitOnContext(event: Bidi.ChromiumBidi.Event) { - let context: BrowsingContext | undefined; - // Context specific events - if ('context' in event.params && event.params.context !== null) { - context = this.#browsingContexts.get(event.params.context); - // `log.entryAdded` specific context - } else if ( - 'source' in event.params && - event.params.source.context !== undefined - ) { - context = this.#browsingContexts.get(event.params.source.context); - } - context?.emit(event.method, event.params); - } - - registerBrowsingContexts(context: BrowsingContext): void { - this.#browsingContexts.set(context.id, context); - } - - getBrowsingContext(contextId: string): BrowsingContext { - const currentContext = this.#browsingContexts.get(contextId); - if (!currentContext) { - throw new Error(`BrowsingContext ${contextId} does not exist.`); - } - return currentContext; - } - - getTopLevelContext(contextId: string): BrowsingContext { - let currentContext = this.#browsingContexts.get(contextId); - if (!currentContext) { - throw new Error(`BrowsingContext ${contextId} does not exist.`); - } - while (currentContext.parent) { - contextId = currentContext.parent; - currentContext = this.#browsingContexts.get(contextId); - if (!currentContext) { - throw new Error(`BrowsingContext ${contextId} does not exist.`); - } - } - return currentContext; - } - - unregisterBrowsingContexts(id: string): void { - this.#browsingContexts.delete(id); - } - /** * Unbinds the connection, but keeps the transport open. Useful when the transport will * be reused by other connection e.g. with different protocol. @@ -223,7 +179,6 @@ export class BidiConnection this.#transport.onmessage = () => {}; this.#transport.onclose = () => {}; - this.#browsingContexts.clear(); this.#callbacks.clear(); } diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Deserializer.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Deserializer.ts index 14b87d403b..20dc8d9fc9 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Deserializer.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Deserializer.ts @@ -12,40 +12,30 @@ import {debugError} from '../common/util.js'; * @internal */ export class BidiDeserializer { - static deserializeNumber(value: Bidi.Script.SpecialNumber | number): number { - switch (value) { - case '-0': - return -0; - case 'NaN': - return NaN; - case 'Infinity': - return Infinity; - case '-Infinity': - return -Infinity; - default: - return value; + static deserialize(result: Bidi.Script.RemoteValue): any { + if (!result) { + debugError('Service did not produce a result.'); + return undefined; } - } - static deserializeLocalValue(result: Bidi.Script.RemoteValue): unknown { switch (result.type) { case 'array': return result.value?.map(value => { - return BidiDeserializer.deserializeLocalValue(value); + return this.deserialize(value); }); case 'set': return result.value?.reduce((acc: Set<unknown>, value) => { - return acc.add(BidiDeserializer.deserializeLocalValue(value)); + return acc.add(this.deserialize(value)); }, new Set()); case 'object': return result.value?.reduce((acc: Record<any, unknown>, tuple) => { - const {key, value} = BidiDeserializer.deserializeTuple(tuple); + const {key, value} = this.#deserializeTuple(tuple); acc[key as any] = value; return acc; }, {}); case 'map': return result.value?.reduce((acc: Map<unknown, unknown>, tuple) => { - const {key, value} = BidiDeserializer.deserializeTuple(tuple); + const {key, value} = this.#deserializeTuple(tuple); return acc.set(key, value); }, new Map()); case 'promise': @@ -59,7 +49,7 @@ export class BidiDeserializer { case 'null': return null; case 'number': - return BidiDeserializer.deserializeNumber(result.value); + return this.#deserializeNumber(result.value); case 'bigint': return BigInt(result.value); case 'boolean': @@ -72,25 +62,31 @@ export class BidiDeserializer { return undefined; } - static deserializeTuple([serializedKey, serializedValue]: [ + static #deserializeNumber(value: Bidi.Script.SpecialNumber | number): number { + switch (value) { + case '-0': + return -0; + case 'NaN': + return NaN; + case 'Infinity': + return Infinity; + case '-Infinity': + return -Infinity; + default: + return value; + } + } + + static #deserializeTuple([serializedKey, serializedValue]: [ Bidi.Script.RemoteValue | string, Bidi.Script.RemoteValue, ]): {key: unknown; value: unknown} { const key = typeof serializedKey === 'string' ? serializedKey - : BidiDeserializer.deserializeLocalValue(serializedKey); - const value = BidiDeserializer.deserializeLocalValue(serializedValue); + : this.deserialize(serializedKey); + const value = this.deserialize(serializedValue); return {key, value}; } - - static deserialize(result: Bidi.Script.RemoteValue): any { - if (!result) { - debugError('Service did not produce a result.'); - return undefined; - } - - return BidiDeserializer.deserializeLocalValue(result); - } } diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Dialog.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Dialog.ts index ce22223461..1774a29f6b 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Dialog.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Dialog.ts @@ -4,40 +4,26 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; - import {Dialog} from '../api/Dialog.js'; -import type {BrowsingContext} from './BrowsingContext.js'; +import type {UserPrompt} from './core/UserPrompt.js'; -/** - * @internal - */ export class BidiDialog extends Dialog { - #context: BrowsingContext; + static from(prompt: UserPrompt): BidiDialog { + return new BidiDialog(prompt); + } - /** - * @internal - */ - constructor( - context: BrowsingContext, - type: Bidi.BrowsingContext.UserPromptOpenedParameters['type'], - message: string, - defaultValue?: string - ) { - super(type, message, defaultValue); - this.#context = context; + #prompt: UserPrompt; + private constructor(prompt: UserPrompt) { + super(prompt.info.type, prompt.info.message, prompt.info.defaultValue); + this.#prompt = prompt; } - /** - * @internal - */ override async handle(options: { accept: boolean; text?: string; }): Promise<void> { - await this.#context.connection.send('browsingContext.handleUserPrompt', { - context: this.#context.id, + await this.#prompt.handle({ accept: options.accept, userText: options.text, }); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ElementHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ElementHandle.ts index fd886e8c26..4263697671 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ElementHandle.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ElementHandle.ts @@ -6,14 +6,12 @@ import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; -import {type AutofillData, ElementHandle} from '../api/ElementHandle.js'; -import {UnsupportedOperation} from '../common/Errors.js'; +import {ElementHandle, type AutofillData} from '../api/ElementHandle.js'; import {throwIfDisposed} from '../util/decorators.js'; import type {BidiFrame} from './Frame.js'; import {BidiJSHandle} from './JSHandle.js'; -import type {BidiRealm} from './Realm.js'; -import type {Sandbox} from './Sandbox.js'; +import type {BidiFrameRealm} from './Realm.js'; /** * @internal @@ -21,28 +19,28 @@ import type {Sandbox} from './Sandbox.js'; export class BidiElementHandle< ElementType extends Node = Element, > extends ElementHandle<ElementType> { + static from<ElementType extends Node = Element>( + value: Bidi.Script.RemoteValue, + realm: BidiFrameRealm + ): BidiElementHandle<ElementType> { + return new BidiElementHandle(value, realm); + } + declare handle: BidiJSHandle<ElementType>; - constructor(sandbox: Sandbox, remoteValue: Bidi.Script.RemoteValue) { - super(new BidiJSHandle(sandbox, remoteValue)); + constructor(value: Bidi.Script.RemoteValue, realm: BidiFrameRealm) { + super(BidiJSHandle.from(value, realm)); } - override get realm(): Sandbox { - return this.handle.realm; + override get realm(): BidiFrameRealm { + // SAFETY: See the super call in the constructor. + return this.handle.realm as BidiFrameRealm; } override get frame(): BidiFrame { return this.realm.environment; } - context(): BidiRealm { - return this.handle.context(); - } - - get isPrimitiveValue(): boolean { - return this.handle.isPrimitiveValue; - } - remoteValue(): Bidi.Script.RemoteValue { return this.handle.remoteValue(); } @@ -69,19 +67,53 @@ export class BidiElementHandle< @ElementHandle.bindIsolatedHandle override async contentFrame(): Promise<BidiFrame | null> { using handle = (await this.evaluateHandle(element => { - if (element instanceof HTMLIFrameElement) { + if ( + element instanceof HTMLIFrameElement || + element instanceof HTMLFrameElement + ) { return element.contentWindow; } return; })) as BidiJSHandle; const value = handle.remoteValue(); if (value.type === 'window') { - return this.frame.page().frame(value.value.context); + return ( + this.frame + .page() + .frames() + .find(frame => { + return frame._id === value.value.context; + }) ?? null + ); } return null; } - override uploadFile(this: ElementHandle<HTMLInputElement>): never { - throw new UnsupportedOperation(); + override async uploadFile( + this: BidiElementHandle<HTMLInputElement>, + ...files: string[] + ): Promise<void> { + // Locate all files and confirm that they exist. + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + let path: typeof import('path'); + try { + path = await import('path'); + } catch (error) { + if (error instanceof TypeError) { + throw new Error( + `JSHandle#uploadFile can only be used in Node-like environments.` + ); + } + throw error; + } + + files = files.map(file => { + if (path.win32.isAbsolute(file) || path.posix.isAbsolute(file)) { + return file; + } else { + return path.resolve(file); + } + }); + await this.frame.setFiles(this, files); } } diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/EmulationManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/EmulationManager.ts deleted file mode 100644 index de95695785..0000000000 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/EmulationManager.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * @license - * Copyright 2023 Google Inc. - * SPDX-License-Identifier: Apache-2.0 - */ -import type {Viewport} from '../common/Viewport.js'; - -import type {BrowsingContext} from './BrowsingContext.js'; - -/** - * @internal - */ -export class EmulationManager { - #browsingContext: BrowsingContext; - - constructor(browsingContext: BrowsingContext) { - this.#browsingContext = browsingContext; - } - - async emulateViewport(viewport: Viewport): Promise<void> { - await this.#browsingContext.connection.send('browsingContext.setViewport', { - context: this.#browsingContext.id, - viewport: - viewport.width && viewport.height - ? { - width: viewport.width, - height: viewport.height, - } - : null, - devicePixelRatio: viewport.deviceScaleFactor - ? viewport.deviceScaleFactor - : null, - }); - } -} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ExposedFunction.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ExposedFunction.ts index 62c6b5e37e..f6e1304a55 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ExposedFunction.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ExposedFunction.ts @@ -6,97 +6,91 @@ import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; +import {EventEmitter} from '../common/EventEmitter.js'; import type {Awaitable, FlattenHandle} from '../common/types.js'; import {debugError} from '../common/util.js'; -import {assert} from '../util/assert.js'; -import {Deferred} from '../util/Deferred.js'; +import {DisposableStack} from '../util/disposable.js'; import {interpolateFunction, stringifyFunction} from '../util/Function.js'; -import type {BidiConnection} from './Connection.js'; -import {BidiDeserializer} from './Deserializer.js'; +import type {Connection} from './core/Connection.js'; +import {BidiElementHandle} from './ElementHandle.js'; import type {BidiFrame} from './Frame.js'; -import {BidiSerializer} from './Serializer.js'; - -type SendArgsChannel<Args> = (value: [id: number, args: Args]) => void; -type SendResolveChannel<Ret> = ( - value: [id: number, resolve: (ret: FlattenHandle<Awaited<Ret>>) => void] -) => void; -type SendRejectChannel = ( - value: [id: number, reject: (error: unknown) => void] +import {BidiJSHandle} from './JSHandle.js'; + +type CallbackChannel<Args, Ret> = ( + value: [ + resolve: (ret: FlattenHandle<Awaited<Ret>>) => void, + reject: (error: unknown) => void, + args: Args, + ] ) => void; -interface RemotePromiseCallbacks { - resolve: Deferred<Bidi.Script.RemoteValue>; - reject: Deferred<Bidi.Script.RemoteValue>; -} - /** * @internal */ export class ExposeableFunction<Args extends unknown[], Ret> { + static async from<Args extends unknown[], Ret>( + frame: BidiFrame, + name: string, + apply: (...args: Args) => Awaitable<Ret>, + isolate = false + ): Promise<ExposeableFunction<Args, Ret>> { + const func = new ExposeableFunction(frame, name, apply, isolate); + await func.#initialize(); + return func; + } + readonly #frame; readonly name; readonly #apply; + readonly #isolate; - readonly #channels; - readonly #callerInfos = new Map< - string, - Map<number, RemotePromiseCallbacks> - >(); + readonly #channel; - #preloadScriptId?: Bidi.Script.PreloadScript; + #scripts: Array<[BidiFrame, Bidi.Script.PreloadScript]> = []; + #disposables = new DisposableStack(); constructor( frame: BidiFrame, name: string, - apply: (...args: Args) => Awaitable<Ret> + apply: (...args: Args) => Awaitable<Ret>, + isolate = false ) { this.#frame = frame; this.name = name; this.#apply = apply; + this.#isolate = isolate; - this.#channels = { - args: `__puppeteer__${this.#frame._id}_page_exposeFunction_${this.name}_args`, - resolve: `__puppeteer__${this.#frame._id}_page_exposeFunction_${this.name}_resolve`, - reject: `__puppeteer__${this.#frame._id}_page_exposeFunction_${this.name}_reject`, - }; + this.#channel = `__puppeteer__${this.#frame._id}_page_exposeFunction_${this.name}`; } - async expose(): Promise<void> { + async #initialize() { const connection = this.#connection; - const channelArguments = this.#channelArguments; + const channel = { + type: 'channel' as const, + value: { + channel: this.#channel, + ownership: Bidi.Script.ResultOwnership.Root, + }, + }; - // TODO(jrandolf): Implement cleanup with removePreloadScript. - connection.on( - Bidi.ChromiumBidi.Script.EventNames.Message, - this.#handleArgumentsMessage + const connectionEmitter = this.#disposables.use( + new EventEmitter(connection) ); - connection.on( + connectionEmitter.on( Bidi.ChromiumBidi.Script.EventNames.Message, - this.#handleResolveMessage - ); - connection.on( - Bidi.ChromiumBidi.Script.EventNames.Message, - this.#handleRejectMessage + this.#handleMessage ); const functionDeclaration = stringifyFunction( interpolateFunction( - ( - sendArgs: SendArgsChannel<Args>, - sendResolve: SendResolveChannel<Ret>, - sendReject: SendRejectChannel - ) => { - let id = 0; + (callback: CallbackChannel<Args, Ret>) => { Object.assign(globalThis, { [PLACEHOLDER('name') as string]: function (...args: Args) { return new Promise<FlattenHandle<Awaited<Ret>>>( (resolve, reject) => { - sendArgs([id, args]); - sendResolve([id, resolve]); - sendReject([id, reject]); - ++id; + callback([resolve, reject, args]); } ); }, @@ -106,179 +100,133 @@ export class ExposeableFunction<Args extends unknown[], Ret> { ) ); - const {result} = await connection.send('script.addPreloadScript', { - functionDeclaration, - arguments: channelArguments, - contexts: [this.#frame.page().mainFrame()._id], - }); - this.#preloadScriptId = result.script; + const frames = [this.#frame]; + for (const frame of frames) { + frames.push(...frame.childFrames()); + } await Promise.all( - this.#frame - .page() - .frames() - .map(async frame => { - return await connection.send('script.callFunction', { - functionDeclaration, - arguments: channelArguments, - awaitPromise: false, - target: frame.mainRealm().realm.target, - }); - }) + frames.map(async frame => { + const realm = this.#isolate ? frame.isolatedRealm() : frame.mainRealm(); + try { + const [script] = await Promise.all([ + frame.browsingContext.addPreloadScript(functionDeclaration, { + arguments: [channel], + sandbox: realm.sandbox, + }), + realm.realm.callFunction(functionDeclaration, false, { + arguments: [channel], + }), + ]); + this.#scripts.push([frame, script]); + } catch (error) { + // If it errors, the frame probably doesn't support call function. We + // fail gracefully. + debugError(error); + } + }) ); } - #handleArgumentsMessage = async (params: Bidi.Script.MessageParameters) => { - if (params.channel !== this.#channels.args) { + get #connection(): Connection { + return this.#frame.page().browser().connection; + } + + #handleMessage = async (params: Bidi.Script.MessageParameters) => { + if (params.channel !== this.#channel) { return; } - const connection = this.#connection; - const {callbacks, remoteValue} = this.#getCallbacksAndRemoteValue(params); - const args = remoteValue.value?.[1]; - assert(args); + const realm = this.#getRealm(params.source); + if (!realm) { + // Unrelated message. + return; + } + + using dataHandle = BidiJSHandle.from< + [ + resolve: (ret: FlattenHandle<Awaited<Ret>>) => void, + reject: (error: unknown) => void, + args: Args, + ] + >(params.data, realm); + + using argsHandle = await dataHandle.evaluateHandle(([, , args]) => { + return args; + }); + + using stack = new DisposableStack(); + const args = []; + for (const [index, handle] of await argsHandle.getProperties()) { + stack.use(handle); + + // Element handles are passed as is. + if (handle instanceof BidiElementHandle) { + args[+index] = handle; + stack.use(handle); + continue; + } + + // Everything else is passed as the JS value. + args[+index] = handle.jsonValue(); + } + + let result; try { - const result = await this.#apply(...BidiDeserializer.deserialize(args)); - await connection.send('script.callFunction', { - functionDeclaration: stringifyFunction(([_, resolve]: any, result) => { - resolve(result); - }), - arguments: [ - (await callbacks.resolve.valueOrThrow()) as Bidi.Script.LocalValue, - BidiSerializer.serializeRemoteValue(result), - ], - awaitPromise: false, - target: { - realm: params.source.realm, - }, - }); + result = await this.#apply(...((await Promise.all(args)) as Args)); } catch (error) { try { if (error instanceof Error) { - await connection.send('script.callFunction', { - functionDeclaration: stringifyFunction( - ( - [_, reject]: [unknown, (error: Error) => void], - name: string, - message: string, - stack?: string - ) => { - const error = new Error(message); - error.name = name; - if (stack) { - error.stack = stack; - } - reject(error); + await dataHandle.evaluate( + ([, reject], name, message, stack) => { + const error = new Error(message); + error.name = name; + if (stack) { + error.stack = stack; } - ), - arguments: [ - (await callbacks.reject.valueOrThrow()) as Bidi.Script.LocalValue, - BidiSerializer.serializeRemoteValue(error.name), - BidiSerializer.serializeRemoteValue(error.message), - BidiSerializer.serializeRemoteValue(error.stack), - ], - awaitPromise: false, - target: { - realm: params.source.realm, + reject(error); }, - }); + error.name, + error.message, + error.stack + ); } else { - await connection.send('script.callFunction', { - functionDeclaration: stringifyFunction( - ( - [_, reject]: [unknown, (error: unknown) => void], - error: unknown - ) => { - reject(error); - } - ), - arguments: [ - (await callbacks.reject.valueOrThrow()) as Bidi.Script.LocalValue, - BidiSerializer.serializeRemoteValue(error), - ], - awaitPromise: false, - target: { - realm: params.source.realm, - }, - }); + await dataHandle.evaluate(([, reject], error) => { + reject(error); + }, error); } } catch (error) { debugError(error); } - } - }; - - get #connection(): BidiConnection { - return this.#frame.context().connection; - } - - get #channelArguments() { - return [ - { - type: 'channel' as const, - value: { - channel: this.#channels.args, - ownership: Bidi.Script.ResultOwnership.Root, - }, - }, - { - type: 'channel' as const, - value: { - channel: this.#channels.resolve, - ownership: Bidi.Script.ResultOwnership.Root, - }, - }, - { - type: 'channel' as const, - value: { - channel: this.#channels.reject, - ownership: Bidi.Script.ResultOwnership.Root, - }, - }, - ]; - } - - #handleResolveMessage = (params: Bidi.Script.MessageParameters) => { - if (params.channel !== this.#channels.resolve) { return; } - const {callbacks, remoteValue} = this.#getCallbacksAndRemoteValue(params); - callbacks.resolve.resolve(remoteValue); - }; - #handleRejectMessage = (params: Bidi.Script.MessageParameters) => { - if (params.channel !== this.#channels.reject) { - return; + try { + await dataHandle.evaluate(([resolve], result) => { + resolve(result); + }, result); + } catch (error) { + debugError(error); } - const {callbacks, remoteValue} = this.#getCallbacksAndRemoteValue(params); - callbacks.reject.resolve(remoteValue); }; - #getCallbacksAndRemoteValue(params: Bidi.Script.MessageParameters) { - const {data, source} = params; - assert(data.type === 'array'); - assert(data.value); - - const callerIdRemote = data.value[0]; - assert(callerIdRemote); - assert(callerIdRemote.type === 'number'); - assert(typeof callerIdRemote.value === 'number'); - - let bindingMap = this.#callerInfos.get(source.realm); - if (!bindingMap) { - bindingMap = new Map(); - this.#callerInfos.set(source.realm, bindingMap); + #getRealm(source: Bidi.Script.Source) { + const frame = this.#findFrame(source.context as string); + if (!frame) { + // Unrelated message. + return; } + return frame.realm(source.realm); + } - const callerId = callerIdRemote.value; - let callbacks = bindingMap.get(callerId); - if (!callbacks) { - callbacks = { - resolve: new Deferred(), - reject: new Deferred(), - }; - bindingMap.set(callerId, callbacks); + #findFrame(id: string) { + const frames = [this.#frame]; + for (const frame of frames) { + if (frame._id === id) { + return frame; + } + frames.push(...frame.childFrames()); } - return {callbacks, remoteValue: data}; + return; } [Symbol.dispose](): void { @@ -286,10 +234,21 @@ export class ExposeableFunction<Args extends unknown[], Ret> { } async [Symbol.asyncDispose](): Promise<void> { - if (this.#preloadScriptId) { - await this.#connection.send('script.removePreloadScript', { - script: this.#preloadScriptId, - }); - } + this.#disposables.dispose(); + await Promise.all( + this.#scripts.map(async ([frame, script]) => { + const realm = this.#isolate ? frame.isolatedRealm() : frame.mainRealm(); + try { + await Promise.all([ + realm.evaluate(name => { + delete (globalThis as any)[name]; + }, this.name), + frame.browsingContext.removePreloadScript(script), + ]); + } catch (error) { + debugError(error); + } + }) + ); } } diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts index 1638c2cbdf..f2bfd5f64e 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts @@ -6,15 +6,18 @@ import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; +import type {Observable} from '../../third_party/rxjs/rxjs.js'; import { + combineLatest, + defer, + delayWhen, + filter, first, firstValueFrom, - forkJoin, - from, map, - merge, + of, raceWith, - zip, + switchMap, } from '../../third_party/rxjs/rxjs.js'; import type {CDPSession} from '../api/CDPSession.js'; import type {ElementHandle} from '../api/ElementHandle.js'; @@ -25,85 +28,228 @@ import { type WaitForOptions, } from '../api/Frame.js'; import type {WaitForSelectorOptions} from '../api/Page.js'; -import {UnsupportedOperation} from '../common/Errors.js'; +import {PageEvent} from '../api/Page.js'; +import { + ConsoleMessage, + type ConsoleMessageLocation, +} from '../common/ConsoleMessage.js'; +import {TargetCloseError, UnsupportedOperation} from '../common/Errors.js'; import type {TimeoutSettings} from '../common/TimeoutSettings.js'; import type {Awaitable, NodeFor} from '../common/types.js'; -import { - fromEmitterEvent, - NETWORK_IDLE_TIME, - timeout, - UTILITY_WORLD_NAME, -} from '../common/util.js'; -import {Deferred} from '../util/Deferred.js'; -import {disposeSymbol} from '../util/disposable.js'; - -import type {BrowsingContext} from './BrowsingContext.js'; +import {debugError, fromEmitterEvent, timeout} from '../common/util.js'; + +import {BidiCdpSession} from './CDPSession.js'; +import type {BrowsingContext} from './core/BrowsingContext.js'; +import type {Navigation} from './core/Navigation.js'; +import type {Request} from './core/Request.js'; +import {BidiDeserializer} from './Deserializer.js'; +import {BidiDialog} from './Dialog.js'; +import type {BidiElementHandle} from './ElementHandle.js'; import {ExposeableFunction} from './ExposedFunction.js'; +import {BidiHTTPRequest, requests} from './HTTPRequest.js'; import type {BidiHTTPResponse} from './HTTPResponse.js'; -import { - getBiDiLifecycleEvent, - getBiDiReadinessState, - rewriteNavigationError, -} from './lifecycle.js'; +import {BidiJSHandle} from './JSHandle.js'; import type {BidiPage} from './Page.js'; -import { - MAIN_SANDBOX, - PUPPETEER_SANDBOX, - Sandbox, - type SandboxChart, -} from './Sandbox.js'; +import type {BidiRealm} from './Realm.js'; +import {BidiFrameRealm} from './Realm.js'; +import {rewriteNavigationError} from './util.js'; +import {BidiWebWorker} from './WebWorker.js'; -/** - * Puppeteer's Frame class could be viewed as a BiDi BrowsingContext implementation - * @internal - */ export class BidiFrame extends Frame { - #page: BidiPage; - #context: BrowsingContext; - #timeoutSettings: TimeoutSettings; - #abortDeferred = Deferred.create<never>(); - #disposed = false; - sandboxes: SandboxChart; - override _id: string; - - constructor( - page: BidiPage, - context: BrowsingContext, - timeoutSettings: TimeoutSettings, - parentId?: string | null + static from( + parent: BidiPage | BidiFrame, + browsingContext: BrowsingContext + ): BidiFrame { + const frame = new BidiFrame(parent, browsingContext); + frame.#initialize(); + return frame; + } + + readonly #parent: BidiPage | BidiFrame; + readonly browsingContext: BrowsingContext; + readonly #frames = new WeakMap<BrowsingContext, BidiFrame>(); + readonly realms: {default: BidiFrameRealm; internal: BidiFrameRealm}; + + override readonly _id: string; + override readonly client: BidiCdpSession; + + private constructor( + parent: BidiPage | BidiFrame, + browsingContext: BrowsingContext ) { super(); - this.#page = page; - this.#context = context; - this.#timeoutSettings = timeoutSettings; - this._id = this.#context.id; - this._parentId = parentId ?? undefined; - - this.sandboxes = { - [MAIN_SANDBOX]: new Sandbox(undefined, this, context, timeoutSettings), - [PUPPETEER_SANDBOX]: new Sandbox( - UTILITY_WORLD_NAME, - this, - context.createRealmForSandbox(), - timeoutSettings + this.#parent = parent; + this.browsingContext = browsingContext; + + this._id = browsingContext.id; + this.client = new BidiCdpSession(this); + this.realms = { + default: BidiFrameRealm.from(this.browsingContext.defaultRealm, this), + internal: BidiFrameRealm.from( + this.browsingContext.createWindowRealm( + `__puppeteer_internal_${Math.ceil(Math.random() * 10000)}` + ), + this ), }; } - override get client(): CDPSession { - return this.context().cdpSession; + #initialize(): void { + for (const browsingContext of this.browsingContext.children) { + this.#createFrameTarget(browsingContext); + } + + this.browsingContext.on('browsingcontext', ({browsingContext}) => { + this.#createFrameTarget(browsingContext); + }); + this.browsingContext.on('closed', () => { + for (const session of BidiCdpSession.sessions.values()) { + if (session.frame === this) { + void session.detach().catch(debugError); + } + } + this.page().trustedEmitter.emit(PageEvent.FrameDetached, this); + }); + + this.browsingContext.on('request', ({request}) => { + const httpRequest = BidiHTTPRequest.from(request, this); + request.once('success', () => { + // SAFETY: BidiHTTPRequest will create this before here. + this.page().trustedEmitter.emit(PageEvent.RequestFinished, httpRequest); + }); + + request.once('error', () => { + this.page().trustedEmitter.emit(PageEvent.RequestFailed, httpRequest); + }); + }); + + this.browsingContext.on('navigation', ({navigation}) => { + navigation.once('fragment', () => { + this.page().trustedEmitter.emit(PageEvent.FrameNavigated, this); + }); + }); + this.browsingContext.on('load', () => { + this.page().trustedEmitter.emit(PageEvent.Load, undefined); + }); + this.browsingContext.on('DOMContentLoaded', () => { + this._hasStartedLoading = true; + this.page().trustedEmitter.emit(PageEvent.DOMContentLoaded, undefined); + this.page().trustedEmitter.emit(PageEvent.FrameNavigated, this); + }); + + this.browsingContext.on('userprompt', ({userPrompt}) => { + this.page().trustedEmitter.emit( + PageEvent.Dialog, + BidiDialog.from(userPrompt) + ); + }); + + this.browsingContext.on('log', ({entry}) => { + if (this._id !== entry.source.context) { + return; + } + if (isConsoleLogEntry(entry)) { + const args = entry.args.map(arg => { + return this.mainRealm().createHandle(arg); + }); + + const text = args + .reduce((value, arg) => { + const parsedValue = + arg instanceof BidiJSHandle && arg.isPrimitiveValue + ? BidiDeserializer.deserialize(arg.remoteValue()) + : arg.toString(); + return `${value} ${parsedValue}`; + }, '') + .slice(1); + + this.page().trustedEmitter.emit( + PageEvent.Console, + new ConsoleMessage( + entry.method as any, + text, + args, + getStackTraceLocations(entry.stackTrace) + ) + ); + } else if (isJavaScriptLogEntry(entry)) { + const error = new Error(entry.text ?? ''); + + const messageHeight = error.message.split('\n').length; + const messageLines = error.stack!.split('\n').splice(0, messageHeight); + + const stackLines = []; + if (entry.stackTrace) { + for (const frame of entry.stackTrace.callFrames) { + // Note we need to add `1` because the values are 0-indexed. + stackLines.push( + ` at ${frame.functionName || '<anonymous>'} (${frame.url}:${ + frame.lineNumber + 1 + }:${frame.columnNumber + 1})` + ); + if (stackLines.length >= Error.stackTraceLimit) { + break; + } + } + } + + error.stack = [...messageLines, ...stackLines].join('\n'); + this.page().trustedEmitter.emit(PageEvent.PageError, error); + } else { + debugError( + `Unhandled LogEntry with type "${entry.type}", text "${entry.text}" and level "${entry.level}"` + ); + } + }); + + this.browsingContext.on('worker', ({realm}) => { + const worker = BidiWebWorker.from(this, realm); + realm.on('destroyed', () => { + this.page().trustedEmitter.emit(PageEvent.WorkerDestroyed, worker); + }); + this.page().trustedEmitter.emit(PageEvent.WorkerCreated, worker); + }); + } + + #createFrameTarget(browsingContext: BrowsingContext) { + const frame = BidiFrame.from(this, browsingContext); + this.#frames.set(browsingContext, frame); + this.page().trustedEmitter.emit(PageEvent.FrameAttached, frame); + + browsingContext.on('closed', () => { + this.#frames.delete(browsingContext); + }); + + return frame; + } + + get timeoutSettings(): TimeoutSettings { + return this.page()._timeoutSettings; } - override mainRealm(): Sandbox { - return this.sandboxes[MAIN_SANDBOX]; + override mainRealm(): BidiFrameRealm { + return this.realms.default; } - override isolatedRealm(): Sandbox { - return this.sandboxes[PUPPETEER_SANDBOX]; + override isolatedRealm(): BidiFrameRealm { + return this.realms.internal; + } + + realm(id: string): BidiRealm | undefined { + for (const realm of Object.values(this.realms)) { + if (realm.realm.id === id) { + return realm; + } + } + return; } override page(): BidiPage { - return this.#page; + let parent = this.#parent; + while (parent instanceof BidiFrame) { + parent = parent.#parent; + } + return parent; } override isOOPFrame(): never { @@ -111,15 +257,36 @@ export class BidiFrame extends Frame { } override url(): string { - return this.#context.url; + return this.browsingContext.url; } override parentFrame(): BidiFrame | null { - return this.#page.frame(this._parentId ?? ''); + if (this.#parent instanceof BidiFrame) { + return this.#parent; + } + return null; } override childFrames(): BidiFrame[] { - return this.#page.childFrames(this.#context.id); + return [...this.browsingContext.children].map(child => { + return this.#frames.get(child)!; + }); + } + + #detached$() { + return defer(() => { + if (this.detached) { + return of(this as Frame); + } + return fromEmitterEvent( + this.page().trustedEmitter, + PageEvent.FrameDetached + ).pipe( + filter(detachedFrame => { + return detachedFrame === this; + }) + ); + }); } @throwIfDetached @@ -127,40 +294,23 @@ export class BidiFrame extends Frame { url: string, options: GoToOptions = {} ): Promise<BidiHTTPResponse | null> { - const { - waitUntil = 'load', - timeout: ms = this.#timeoutSettings.navigationTimeout(), - } = options; - - const [readiness, networkIdle] = getBiDiReadinessState(waitUntil); - - const result$ = zip( - from( - this.#context.connection.send('browsingContext.navigate', { - context: this.#context.id, - url, - wait: readiness, - }) + const [response] = await Promise.all([ + this.waitForNavigation(options), + // Some implementations currently only report errors when the + // readiness=interactive. + // + // Related: https://bugzilla.mozilla.org/show_bug.cgi?id=1846601 + this.browsingContext.navigate( + url, + Bidi.BrowsingContext.ReadinessState.Interactive ), - ...(networkIdle !== null - ? [ - this.#page.waitForNetworkIdle$({ - timeout: ms, - concurrency: networkIdle === 'networkidle2' ? 2 : 0, - idleTime: NETWORK_IDLE_TIME, - }), - ] - : []) - ).pipe( - map(([{result}]) => { - return result; - }), - raceWith(timeout(ms), from(this.#abortDeferred.valueOrThrow())), - rewriteNavigationError(url, ms) + ]).catch( + rewriteNavigationError( + url, + options.timeout ?? this.timeoutSettings.navigationTimeout() + ) ); - - const result = await firstValueFrom(result$); - return this.#page.getNavigationResponse(result.navigation); + return response; } @throwIfDetached @@ -168,95 +318,105 @@ export class BidiFrame extends Frame { html: string, options: WaitForOptions = {} ): Promise<void> { - const { - waitUntil = 'load', - timeout: ms = this.#timeoutSettings.navigationTimeout(), - } = options; - - const [waitEvent, networkIdle] = getBiDiLifecycleEvent(waitUntil); - - const result$ = zip( - forkJoin([ - fromEmitterEvent(this.#context, waitEvent).pipe(first()), - from(this.setFrameContent(html)), - ]).pipe( - map(() => { - return null; - }) + await Promise.all([ + this.setFrameContent(html), + firstValueFrom( + combineLatest([ + this.#waitForLoad$(options), + this.#waitForNetworkIdle$(options), + ]) ), - ...(networkIdle !== null - ? [ - this.#page.waitForNetworkIdle$({ - timeout: ms, - concurrency: networkIdle === 'networkidle2' ? 2 : 0, - idleTime: NETWORK_IDLE_TIME, - }), - ] - : []) - ).pipe( - raceWith(timeout(ms), from(this.#abortDeferred.valueOrThrow())), - rewriteNavigationError('setContent', ms) - ); - - await firstValueFrom(result$); - } - - context(): BrowsingContext { - return this.#context; + ]); } @throwIfDetached override async waitForNavigation( options: WaitForOptions = {} ): Promise<BidiHTTPResponse | null> { - const { - waitUntil = 'load', - timeout: ms = this.#timeoutSettings.navigationTimeout(), - } = options; - - const [waitUntilEvent, networkIdle] = getBiDiLifecycleEvent(waitUntil); - - const navigation$ = merge( - forkJoin([ - fromEmitterEvent( - this.#context, - Bidi.ChromiumBidi.BrowsingContext.EventNames.NavigationStarted - ).pipe(first()), - fromEmitterEvent(this.#context, waitUntilEvent).pipe(first()), - ]), - fromEmitterEvent( - this.#context, - Bidi.ChromiumBidi.BrowsingContext.EventNames.FragmentNavigated + const {timeout: ms = this.timeoutSettings.navigationTimeout()} = options; + + const frames = this.childFrames().map(frame => { + return frame.#detached$(); + }); + return await firstValueFrom( + combineLatest([ + fromEmitterEvent(this.browsingContext, 'navigation').pipe( + switchMap(({navigation}) => { + return this.#waitForLoad$(options).pipe( + delayWhen(() => { + if (frames.length === 0) { + return of(undefined); + } + return combineLatest(frames); + }), + raceWith( + fromEmitterEvent(navigation, 'fragment'), + fromEmitterEvent(navigation, 'failed').pipe( + map(({url}) => { + throw new Error(`Navigation failed: ${url}`); + }) + ), + fromEmitterEvent(navigation, 'aborted').pipe( + map(({url}) => { + throw new Error(`Navigation aborted: ${url}`); + }) + ) + ), + switchMap(() => { + if (navigation.request) { + function requestFinished$( + request: Request + ): Observable<Navigation> { + // Reduces flakiness if the response events arrive after + // the load event. + // Usually, the response or error is already there at this point. + if (request.response || request.error) { + return of(navigation); + } + if (request.redirect) { + return requestFinished$(request.redirect); + } + return fromEmitterEvent(request, 'success') + .pipe( + raceWith(fromEmitterEvent(request, 'error')), + raceWith(fromEmitterEvent(request, 'redirect')) + ) + .pipe( + switchMap(() => { + return requestFinished$(request); + }) + ); + } + return requestFinished$(navigation.request); + } + return of(navigation); + }) + ); + }) + ), + this.#waitForNetworkIdle$(options), + ]).pipe( + map(([navigation]) => { + const request = navigation.request; + if (!request) { + return null; + } + const httpRequest = requests.get(request)!; + const lastRedirect = httpRequest.redirectChain().at(-1); + return ( + lastRedirect !== undefined ? lastRedirect : httpRequest + ).response(); + }), + raceWith( + timeout(ms), + this.#detached$().pipe( + map(() => { + throw new TargetCloseError('Frame detached.'); + }) + ) + ) ) - ).pipe( - map(result => { - if (Array.isArray(result)) { - return {result: result[1]}; - } - return {result}; - }) ); - - const result$ = zip( - navigation$, - ...(networkIdle !== null - ? [ - this.#page.waitForNetworkIdle$({ - timeout: ms, - concurrency: networkIdle === 'networkidle2' ? 2 : 0, - idleTime: NETWORK_IDLE_TIME, - }), - ] - : []) - ).pipe( - map(([{result}]) => { - return result; - }), - raceWith(timeout(ms), from(this.#abortDeferred.valueOrThrow())) - ); - - const result = await firstValueFrom(result$); - return this.#page.getNavigationResponse(result.navigation); } override waitForDevicePrompt(): never { @@ -264,18 +424,7 @@ export class BidiFrame extends Frame { } override get detached(): boolean { - return this.#disposed; - } - - [disposeSymbol](): void { - if (this.#disposed) { - return; - } - this.#disposed = true; - this.#abortDeferred.reject(new Error('Frame detached')); - this.#context.dispose(); - this.sandboxes[MAIN_SANDBOX][disposeSymbol](); - this.sandboxes[PUPPETEER_SANDBOX][disposeSymbol](); + return this.browsingContext.closed; } #exposedFunctions = new Map<string, ExposeableFunction<never[], unknown>>(); @@ -288,21 +437,27 @@ export class BidiFrame extends Frame { `Failed to add page binding with name ${name}: globalThis['${name}'] already exists!` ); } - const exposeable = new ExposeableFunction(this, name, apply); + const exposeable = await ExposeableFunction.from(this, name, apply); this.#exposedFunctions.set(name, exposeable); - try { - await exposeable.expose(); - } catch (error) { - this.#exposedFunctions.delete(name); - throw error; + } + + async removeExposedFunction(name: string): Promise<void> { + const exposedFunction = this.#exposedFunctions.get(name); + if (!exposedFunction) { + throw new Error( + `Failed to remove page binding with name ${name}: window['${name}'] does not exists!` + ); } + + this.#exposedFunctions.delete(name); + await exposedFunction[Symbol.asyncDispose](); } override waitForSelector<Selector extends string>( selector: Selector, options?: WaitForSelectorOptions ): Promise<ElementHandle<NodeFor<Selector>> | null> { - if (selector.startsWith('aria')) { + if (selector.startsWith('aria') && !this.page().browser().cdpSupported) { throw new UnsupportedOperation( 'ARIA selector is not supported for BiDi!' ); @@ -310,4 +465,124 @@ export class BidiFrame extends Frame { return super.waitForSelector(selector, options); } + + async createCDPSession(): Promise<CDPSession> { + const {sessionId} = await this.client.send('Target.attachToTarget', { + targetId: this._id, + flatten: true, + }); + return new BidiCdpSession(this, sessionId); + } + + @throwIfDetached + #waitForLoad$(options: WaitForOptions = {}): Observable<void> { + let {waitUntil = 'load'} = options; + const {timeout: ms = this.timeoutSettings.navigationTimeout()} = options; + + if (!Array.isArray(waitUntil)) { + waitUntil = [waitUntil]; + } + + const events = new Set<'load' | 'DOMContentLoaded'>(); + for (const lifecycleEvent of waitUntil) { + switch (lifecycleEvent) { + case 'load': { + events.add('load'); + break; + } + case 'domcontentloaded': { + events.add('DOMContentLoaded'); + break; + } + } + } + if (events.size === 0) { + return of(undefined); + } + + return combineLatest( + [...events].map(event => { + return fromEmitterEvent(this.browsingContext, event); + }) + ).pipe( + map(() => {}), + first(), + raceWith( + timeout(ms), + this.#detached$().pipe( + map(() => { + throw new Error('Frame detached.'); + }) + ) + ) + ); + } + + @throwIfDetached + #waitForNetworkIdle$(options: WaitForOptions = {}): Observable<void> { + let {waitUntil = 'load'} = options; + if (!Array.isArray(waitUntil)) { + waitUntil = [waitUntil]; + } + + let concurrency = Infinity; + for (const event of waitUntil) { + switch (event) { + case 'networkidle0': { + concurrency = Math.min(0, concurrency); + break; + } + case 'networkidle2': { + concurrency = Math.min(2, concurrency); + break; + } + } + } + if (concurrency === Infinity) { + return of(undefined); + } + + return this.page().waitForNetworkIdle$({ + idleTime: 500, + timeout: options.timeout ?? this.timeoutSettings.timeout(), + concurrency, + }); + } + + @throwIfDetached + async setFiles(element: BidiElementHandle, files: string[]): Promise<void> { + await this.browsingContext.setFiles( + // SAFETY: ElementHandles are always remote references. + element.remoteValue() as Bidi.Script.SharedReference, + files + ); + } +} + +function isConsoleLogEntry( + event: Bidi.Log.Entry +): event is Bidi.Log.ConsoleLogEntry { + return event.type === 'console'; +} + +function isJavaScriptLogEntry( + event: Bidi.Log.Entry +): event is Bidi.Log.JavascriptLogEntry { + return event.type === 'javascript'; +} + +function getStackTraceLocations( + stackTrace?: Bidi.Script.StackTrace +): ConsoleMessageLocation[] { + const stackTraceLocations: ConsoleMessageLocation[] = []; + if (stackTrace) { + for (const callFrame of stackTrace.callFrames) { + stackTraceLocations.push({ + url: callFrame.url, + lineNumber: callFrame.lineNumber, + columnNumber: callFrame.columnNumber, + }); + } + } + return stackTraceLocations; } diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPRequest.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPRequest.ts index 57cb801b8c..e75bb0cf3c 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPRequest.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPRequest.ts @@ -5,106 +5,126 @@ */ import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; -import type {Frame} from '../api/Frame.js'; +import type {CDPSession} from '../api/CDPSession.js'; import type { ContinueRequestOverrides, ResponseForRequest, } from '../api/HTTPRequest.js'; import {HTTPRequest, type ResourceType} from '../api/HTTPRequest.js'; +import {PageEvent} from '../api/Page.js'; import {UnsupportedOperation} from '../common/Errors.js'; -import type {BidiHTTPResponse} from './HTTPResponse.js'; +import type {Request} from './core/Request.js'; +import type {BidiFrame} from './Frame.js'; +import {BidiHTTPResponse} from './HTTPResponse.js'; + +export const requests = new WeakMap<Request, BidiHTTPRequest>(); /** * @internal */ export class BidiHTTPRequest extends HTTPRequest { - override _response: BidiHTTPResponse | null = null; - override _redirectChain: BidiHTTPRequest[]; - _navigationId: string | null; - - #url: string; - #resourceType: ResourceType; - - #method: string; - #postData?: string; - #headers: Record<string, string> = {}; - #initiator: Bidi.Network.Initiator; - #frame: Frame | null; - - constructor( - event: Bidi.Network.BeforeRequestSentParameters, - frame: Frame | null, - redirectChain: BidiHTTPRequest[] = [] - ) { + static from( + bidiRequest: Request, + frame: BidiFrame | undefined + ): BidiHTTPRequest { + const request = new BidiHTTPRequest(bidiRequest, frame); + request.#initialize(); + return request; + } + + #redirect: BidiHTTPRequest | undefined; + #response: BidiHTTPResponse | null = null; + override readonly id: string; + readonly #frame: BidiFrame | undefined; + readonly #request: Request; + + private constructor(request: Request, frame: BidiFrame | undefined) { super(); + requests.set(request, this); - this.#url = event.request.url; - this.#resourceType = event.initiator.type.toLowerCase() as ResourceType; - this.#method = event.request.method; - this.#postData = undefined; - this.#initiator = event.initiator; + this.#request = request; this.#frame = frame; - - this._requestId = event.request.request; - this._redirectChain = redirectChain; - this._navigationId = event.navigation; - - for (const header of event.request.headers) { - // TODO: How to handle Binary Headers - // https://w3c.github.io/webdriver-bidi/#type-network-Header - if (header.value.type === 'string') { - this.#headers[header.name.toLowerCase()] = header.value.value; - } - } + this.id = request.id; } - override get client(): never { + override get client(): CDPSession { throw new UnsupportedOperation(); } + #initialize() { + this.#request.on('redirect', request => { + this.#redirect = BidiHTTPRequest.from(request, this.#frame); + }); + this.#request.once('success', data => { + this.#response = BidiHTTPResponse.from(data, this); + }); + + this.#frame?.page().trustedEmitter.emit(PageEvent.Request, this); + } + override url(): string { - return this.#url; + return this.#request.url; } override resourceType(): ResourceType { - return this.#resourceType; + return this.initiator().type.toLowerCase() as ResourceType; } override method(): string { - return this.#method; + return this.#request.method; } override postData(): string | undefined { - return this.#postData; + throw new UnsupportedOperation(); } override hasPostData(): boolean { - return this.#postData !== undefined; + throw new UnsupportedOperation(); } override async fetchPostData(): Promise<string | undefined> { - return this.#postData; + throw new UnsupportedOperation(); } override headers(): Record<string, string> { - return this.#headers; + const headers: Record<string, string> = {}; + for (const header of this.#request.headers) { + headers[header.name.toLowerCase()] = header.value.value; + } + return headers; } override response(): BidiHTTPResponse | null { - return this._response; + return this.#response; + } + + override failure(): {errorText: string} | null { + if (this.#request.error === undefined) { + return null; + } + return {errorText: this.#request.error}; } override isNavigationRequest(): boolean { - return Boolean(this._navigationId); + return this.#request.navigation !== undefined; } override initiator(): Bidi.Network.Initiator { - return this.#initiator; + return this.#request.initiator; } override redirectChain(): BidiHTTPRequest[] { - return this._redirectChain.slice(); + if (this.#redirect === undefined) { + return []; + } + const redirects = [this.#redirect]; + for (const redirect of redirects) { + if (redirect.#redirect !== undefined) { + redirects.push(redirect.#redirect); + } + } + return redirects; } override enqueueInterceptAction( @@ -114,8 +134,8 @@ export class BidiHTTPRequest extends HTTPRequest { void pendingHandler(); } - override frame(): Frame | null { - return this.#frame; + override frame(): BidiFrame | null { + return this.#frame ?? null; } override continueRequestOverrides(): never { @@ -156,8 +176,4 @@ export class BidiHTTPRequest extends HTTPRequest { ): never { throw new UnsupportedOperation(); } - - override failure(): never { - throw new UnsupportedOperation(); - } } diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPResponse.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPResponse.ts index ce28820a65..bad44ff089 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPResponse.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPResponse.ts @@ -7,11 +7,10 @@ import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; import type Protocol from 'devtools-protocol'; import type {Frame} from '../api/Frame.js'; -import { - HTTPResponse as HTTPResponse, - type RemoteAddress, -} from '../api/HTTPResponse.js'; +import {HTTPResponse, type RemoteAddress} from '../api/HTTPResponse.js'; +import {PageEvent} from '../api/Page.js'; import {UnsupportedOperation} from '../common/Errors.js'; +import {invokeAtMostOnceForArguments} from '../util/decorators.js'; import type {BidiHTTPRequest} from './HTTPRequest.js'; @@ -19,62 +18,62 @@ import type {BidiHTTPRequest} from './HTTPRequest.js'; * @internal */ export class BidiHTTPResponse extends HTTPResponse { + static from( + data: Bidi.Network.ResponseData, + request: BidiHTTPRequest + ): BidiHTTPResponse { + const response = new BidiHTTPResponse(data, request); + response.#initialize(); + return response; + } + + #data: Bidi.Network.ResponseData; #request: BidiHTTPRequest; - #remoteAddress: RemoteAddress; - #status: number; - #statusText: string; - #url: string; - #fromCache: boolean; - #headers: Record<string, string> = {}; - #timings: Record<string, string> | null; - - constructor( - request: BidiHTTPRequest, - {response}: Bidi.Network.ResponseCompletedParameters + + private constructor( + data: Bidi.Network.ResponseData, + request: BidiHTTPRequest ) { super(); + this.#data = data; this.#request = request; + } - this.#remoteAddress = { - ip: '', - port: -1, - }; - - this.#url = response.url; - this.#fromCache = response.fromCache; - this.#status = response.status; - this.#statusText = response.statusText; - // TODO: File and issue with BiDi spec - this.#timings = null; - - // TODO: Removed once the Firefox implementation is compliant with https://w3c.github.io/webdriver-bidi/#get-the-response-data. - for (const header of response.headers || []) { - // TODO: How to handle Binary Headers - // https://w3c.github.io/webdriver-bidi/#type-network-Header - if (header.value.type === 'string') { - this.#headers[header.name.toLowerCase()] = header.value.value; - } - } + #initialize() { + this.#request.frame()?.page().trustedEmitter.emit(PageEvent.Response, this); } + @invokeAtMostOnceForArguments override remoteAddress(): RemoteAddress { - return this.#remoteAddress; + return { + ip: '', + port: -1, + }; } override url(): string { - return this.#url; + return this.#data.url; } override status(): number { - return this.#status; + return this.#data.status; } override statusText(): string { - return this.#statusText; + return this.#data.statusText; } override headers(): Record<string, string> { - return this.#headers; + const headers: Record<string, string> = {}; + // TODO: Remove once the Firefox implementation is compliant with https://w3c.github.io/webdriver-bidi/#get-the-response-data. + for (const header of this.#data.headers || []) { + // TODO: How to handle Binary Headers + // https://w3c.github.io/webdriver-bidi/#type-network-Header + if (header.value.type === 'string') { + headers[header.name.toLowerCase()] = header.value.value; + } + } + return headers; } override request(): BidiHTTPRequest { @@ -82,11 +81,12 @@ export class BidiHTTPResponse extends HTTPResponse { } override fromCache(): boolean { - return this.#fromCache; + return this.#data.fromCache; } override timing(): Protocol.Network.ResourceTiming | null { - return this.#timings as any; + // TODO: File and issue with BiDi spec + throw new UnsupportedOperation(); } override frame(): Frame | null { diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Input.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Input.ts index 5406556d64..dc70850c12 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Input.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Input.ts @@ -12,9 +12,9 @@ import { Mouse, MouseButton, Touchscreen, + type KeyboardTypeOptions, type KeyDownOptions, type KeyPressOptions, - type KeyboardTypeOptions, type MouseClickOptions, type MouseMoveOptions, type MouseOptions, @@ -23,7 +23,6 @@ import { import {UnsupportedOperation} from '../common/Errors.js'; import type {KeyInput} from '../common/USKeyboardLayout.js'; -import type {BrowsingContext} from './BrowsingContext.js'; import type {BidiPage} from './Page.js'; const enum InputId { @@ -288,39 +287,33 @@ export class BidiKeyboard extends Keyboard { key: KeyInput, _options?: Readonly<KeyDownOptions> ): Promise<void> { - await this.#page.connection.send('input.performActions', { - context: this.#page.mainFrame()._id, - actions: [ - { - type: SourceActionsType.Key, - id: InputId.Keyboard, - actions: [ - { - type: ActionType.KeyDown, - value: getBidiKeyValue(key), - }, - ], - }, - ], - }); + await this.#page.mainFrame().browsingContext.performActions([ + { + type: SourceActionsType.Key, + id: InputId.Keyboard, + actions: [ + { + type: ActionType.KeyDown, + value: getBidiKeyValue(key), + }, + ], + }, + ]); } override async up(key: KeyInput): Promise<void> { - await this.#page.connection.send('input.performActions', { - context: this.#page.mainFrame()._id, - actions: [ - { - type: SourceActionsType.Key, - id: InputId.Keyboard, - actions: [ - { - type: ActionType.KeyUp, - value: getBidiKeyValue(key), - }, - ], - }, - ], - }); + await this.#page.mainFrame().browsingContext.performActions([ + { + type: SourceActionsType.Key, + id: InputId.Keyboard, + actions: [ + { + type: ActionType.KeyUp, + value: getBidiKeyValue(key), + }, + ], + }, + ]); } override async press( @@ -344,16 +337,13 @@ export class BidiKeyboard extends Keyboard { type: ActionType.KeyUp, value: getBidiKeyValue(key), }); - await this.#page.connection.send('input.performActions', { - context: this.#page.mainFrame()._id, - actions: [ - { - type: SourceActionsType.Key, - id: InputId.Keyboard, - actions, - }, - ], - }); + await this.#page.mainFrame().browsingContext.performActions([ + { + type: SourceActionsType.Key, + id: InputId.Keyboard, + actions, + }, + ]); } override async type( @@ -396,16 +386,13 @@ export class BidiKeyboard extends Keyboard { ); } } - await this.#page.connection.send('input.performActions', { - context: this.#page.mainFrame()._id, - actions: [ - { - type: SourceActionsType.Key, - id: InputId.Keyboard, - actions, - }, - ], - }); + await this.#page.mainFrame().browsingContext.performActions([ + { + type: SourceActionsType.Key, + id: InputId.Keyboard, + actions, + }, + ]); } override async sendCharacter(char: string): Promise<void> { @@ -460,19 +447,17 @@ const getBidiButton = (button: MouseButton) => { * @internal */ export class BidiMouse extends Mouse { - #context: BrowsingContext; + #page: BidiPage; #lastMovePoint: Point = {x: 0, y: 0}; - constructor(context: BrowsingContext) { + constructor(page: BidiPage) { super(); - this.#context = context; + this.#page = page; } override async reset(): Promise<void> { this.#lastMovePoint = {x: 0, y: 0}; - await this.#context.connection.send('input.releaseActions', { - context: this.#context.id, - }); + await this.#page.mainFrame().browsingContext.releaseActions(); } override async move( @@ -502,52 +487,43 @@ export class BidiMouse extends Mouse { }); // https://w3c.github.io/webdriver-bidi/#command-input-performActions:~:text=input.PointerMoveAction%20%3D%20%7B%0A%20%20type%3A%20%22pointerMove%22%2C%0A%20%20x%3A%20js%2Dint%2C this.#lastMovePoint = to; - await this.#context.connection.send('input.performActions', { - context: this.#context.id, - actions: [ - { - type: SourceActionsType.Pointer, - id: InputId.Mouse, - actions, - }, - ], - }); + await this.#page.mainFrame().browsingContext.performActions([ + { + type: SourceActionsType.Pointer, + id: InputId.Mouse, + actions, + }, + ]); } override async down(options: Readonly<MouseOptions> = {}): Promise<void> { - await this.#context.connection.send('input.performActions', { - context: this.#context.id, - actions: [ - { - type: SourceActionsType.Pointer, - id: InputId.Mouse, - actions: [ - { - type: ActionType.PointerDown, - button: getBidiButton(options.button ?? MouseButton.Left), - }, - ], - }, - ], - }); + await this.#page.mainFrame().browsingContext.performActions([ + { + type: SourceActionsType.Pointer, + id: InputId.Mouse, + actions: [ + { + type: ActionType.PointerDown, + button: getBidiButton(options.button ?? MouseButton.Left), + }, + ], + }, + ]); } override async up(options: Readonly<MouseOptions> = {}): Promise<void> { - await this.#context.connection.send('input.performActions', { - context: this.#context.id, - actions: [ - { - type: SourceActionsType.Pointer, - id: InputId.Mouse, - actions: [ - { - type: ActionType.PointerUp, - button: getBidiButton(options.button ?? MouseButton.Left), - }, - ], - }, - ], - }); + await this.#page.mainFrame().browsingContext.performActions([ + { + type: SourceActionsType.Pointer, + id: InputId.Mouse, + actions: [ + { + type: ActionType.PointerUp, + button: getBidiButton(options.button ?? MouseButton.Left), + }, + ], + }, + ]); } override async click( @@ -582,41 +558,35 @@ export class BidiMouse extends Mouse { }); } actions.push(pointerUpAction); - await this.#context.connection.send('input.performActions', { - context: this.#context.id, - actions: [ - { - type: SourceActionsType.Pointer, - id: InputId.Mouse, - actions, - }, - ], - }); + await this.#page.mainFrame().browsingContext.performActions([ + { + type: SourceActionsType.Pointer, + id: InputId.Mouse, + actions, + }, + ]); } override async wheel( options: Readonly<MouseWheelOptions> = {} ): Promise<void> { - await this.#context.connection.send('input.performActions', { - context: this.#context.id, - actions: [ - { - type: SourceActionsType.Wheel, - id: InputId.Wheel, - actions: [ - { - type: ActionType.Scroll, - ...(this.#lastMovePoint ?? { - x: 0, - y: 0, - }), - deltaX: options.deltaX ?? 0, - deltaY: options.deltaY ?? 0, - }, - ], - }, - ], - }); + await this.#page.mainFrame().browsingContext.performActions([ + { + type: SourceActionsType.Wheel, + id: InputId.Wheel, + actions: [ + { + type: ActionType.Scroll, + ...(this.#lastMovePoint ?? { + x: 0, + y: 0, + }), + deltaX: options.deltaX ?? 0, + deltaY: options.deltaY ?? 0, + }, + ], + }, + ]); } override drag(): never { @@ -644,11 +614,11 @@ export class BidiMouse extends Mouse { * @internal */ export class BidiTouchscreen extends Touchscreen { - #context: BrowsingContext; + #page: BidiPage; - constructor(context: BrowsingContext) { + constructor(page: BidiPage) { super(); - this.#context = context; + this.#page = page; } override async touchStart( @@ -656,30 +626,27 @@ export class BidiTouchscreen extends Touchscreen { y: number, options: BidiTouchMoveOptions = {} ): Promise<void> { - await this.#context.connection.send('input.performActions', { - context: this.#context.id, - actions: [ - { - type: SourceActionsType.Pointer, - id: InputId.Finger, - parameters: { - pointerType: Bidi.Input.PointerType.Touch, - }, - actions: [ - { - type: ActionType.PointerMove, - x: Math.round(x), - y: Math.round(y), - origin: options.origin, - }, - { - type: ActionType.PointerDown, - button: 0, - }, - ], + await this.#page.mainFrame().browsingContext.performActions([ + { + type: SourceActionsType.Pointer, + id: InputId.Finger, + parameters: { + pointerType: Bidi.Input.PointerType.Touch, }, - ], - }); + actions: [ + { + type: ActionType.PointerMove, + x: Math.round(x), + y: Math.round(y), + origin: options.origin, + }, + { + type: ActionType.PointerDown, + button: 0, + }, + ], + }, + ]); } override async touchMove( @@ -687,46 +654,40 @@ export class BidiTouchscreen extends Touchscreen { y: number, options: BidiTouchMoveOptions = {} ): Promise<void> { - await this.#context.connection.send('input.performActions', { - context: this.#context.id, - actions: [ - { - type: SourceActionsType.Pointer, - id: InputId.Finger, - parameters: { - pointerType: Bidi.Input.PointerType.Touch, - }, - actions: [ - { - type: ActionType.PointerMove, - x: Math.round(x), - y: Math.round(y), - origin: options.origin, - }, - ], + await this.#page.mainFrame().browsingContext.performActions([ + { + type: SourceActionsType.Pointer, + id: InputId.Finger, + parameters: { + pointerType: Bidi.Input.PointerType.Touch, }, - ], - }); + actions: [ + { + type: ActionType.PointerMove, + x: Math.round(x), + y: Math.round(y), + origin: options.origin, + }, + ], + }, + ]); } override async touchEnd(): Promise<void> { - await this.#context.connection.send('input.performActions', { - context: this.#context.id, - actions: [ - { - type: SourceActionsType.Pointer, - id: InputId.Finger, - parameters: { - pointerType: Bidi.Input.PointerType.Touch, - }, - actions: [ - { - type: ActionType.PointerUp, - button: 0, - }, - ], + await this.#page.mainFrame().browsingContext.performActions([ + { + type: SourceActionsType.Pointer, + id: InputId.Finger, + parameters: { + pointerType: Bidi.Input.PointerType.Touch, }, - ], - }); + actions: [ + { + type: ActionType.PointerUp, + button: 0, + }, + ], + }, + ]); } } diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/JSHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/JSHandle.ts index 7104601553..10f564f78a 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/JSHandle.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/JSHandle.ts @@ -12,29 +12,28 @@ import {UnsupportedOperation} from '../common/Errors.js'; import {BidiDeserializer} from './Deserializer.js'; import type {BidiRealm} from './Realm.js'; -import type {Sandbox} from './Sandbox.js'; -import {releaseReference} from './util.js'; /** * @internal */ export class BidiJSHandle<T = unknown> extends JSHandle<T> { - #disposed = false; - readonly #sandbox: Sandbox; + static from<T>( + value: Bidi.Script.RemoteValue, + realm: BidiRealm + ): BidiJSHandle<T> { + return new BidiJSHandle(value, realm); + } + readonly #remoteValue: Bidi.Script.RemoteValue; - constructor(sandbox: Sandbox, remoteValue: Bidi.Script.RemoteValue) { - super(); - this.#sandbox = sandbox; - this.#remoteValue = remoteValue; - } + override readonly realm: BidiRealm; - context(): BidiRealm { - return this.realm.environment.context(); - } + #disposed = false; - override get realm(): Sandbox { - return this.#sandbox; + constructor(value: Bidi.Script.RemoteValue, realm: BidiRealm) { + super(); + this.#remoteValue = value; + this.realm = realm; } override get disposed(): boolean { @@ -56,12 +55,7 @@ export class BidiJSHandle<T = unknown> extends JSHandle<T> { return; } this.#disposed = true; - if ('handle' in this.#remoteValue) { - await releaseReference( - this.context(), - this.#remoteValue as Bidi.Script.RemoteReference - ); - } + await this.realm.destroyHandles([this]); } get isPrimitiveValue(): boolean { diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/NetworkManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/NetworkManager.ts deleted file mode 100644 index 2caaf0ad50..0000000000 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/NetworkManager.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * @license - * Copyright 2023 Google Inc. - * SPDX-License-Identifier: Apache-2.0 - */ - -import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; - -import {EventEmitter, EventSubscription} from '../common/EventEmitter.js'; -import { - NetworkManagerEvent, - type NetworkManagerEvents, -} from '../common/NetworkManagerEvents.js'; -import {DisposableStack} from '../util/disposable.js'; - -import type {BidiConnection} from './Connection.js'; -import type {BidiFrame} from './Frame.js'; -import {BidiHTTPRequest} from './HTTPRequest.js'; -import {BidiHTTPResponse} from './HTTPResponse.js'; -import type {BidiPage} from './Page.js'; - -/** - * @internal - */ -export class BidiNetworkManager extends EventEmitter<NetworkManagerEvents> { - #connection: BidiConnection; - #page: BidiPage; - #subscriptions = new DisposableStack(); - - #requestMap = new Map<string, BidiHTTPRequest>(); - #navigationMap = new Map<string, BidiHTTPResponse>(); - - constructor(connection: BidiConnection, page: BidiPage) { - super(); - this.#connection = connection; - this.#page = page; - - // TODO: Subscribe to the Frame individually - this.#subscriptions.use( - new EventSubscription( - this.#connection, - 'network.beforeRequestSent', - this.#onBeforeRequestSent.bind(this) - ) - ); - this.#subscriptions.use( - new EventSubscription( - this.#connection, - 'network.responseStarted', - this.#onResponseStarted.bind(this) - ) - ); - this.#subscriptions.use( - new EventSubscription( - this.#connection, - 'network.responseCompleted', - this.#onResponseCompleted.bind(this) - ) - ); - this.#subscriptions.use( - new EventSubscription( - this.#connection, - 'network.fetchError', - this.#onFetchError.bind(this) - ) - ); - } - - #onBeforeRequestSent(event: Bidi.Network.BeforeRequestSentParameters): void { - const frame = this.#page.frame(event.context ?? ''); - if (!frame) { - return; - } - const request = this.#requestMap.get(event.request.request); - let upsertRequest: BidiHTTPRequest; - if (request) { - request._redirectChain.push(request); - upsertRequest = new BidiHTTPRequest(event, frame, request._redirectChain); - } else { - upsertRequest = new BidiHTTPRequest(event, frame, []); - } - this.#requestMap.set(event.request.request, upsertRequest); - this.emit(NetworkManagerEvent.Request, upsertRequest); - } - - #onResponseStarted(_event: Bidi.Network.ResponseStartedParameters) {} - - #onResponseCompleted(event: Bidi.Network.ResponseCompletedParameters): void { - const request = this.#requestMap.get(event.request.request); - if (!request) { - return; - } - const response = new BidiHTTPResponse(request, event); - request._response = response; - if (event.navigation) { - this.#navigationMap.set(event.navigation, response); - } - if (response.fromCache()) { - this.emit(NetworkManagerEvent.RequestServedFromCache, request); - } - this.emit(NetworkManagerEvent.Response, response); - this.emit(NetworkManagerEvent.RequestFinished, request); - } - - #onFetchError(event: Bidi.Network.FetchErrorParameters) { - const request = this.#requestMap.get(event.request.request); - if (!request) { - return; - } - request._failureText = event.errorText; - this.emit(NetworkManagerEvent.RequestFailed, request); - this.#requestMap.delete(event.request.request); - } - - getNavigationResponse(navigationId?: string | null): BidiHTTPResponse | null { - if (!navigationId) { - return null; - } - const response = this.#navigationMap.get(navigationId); - - return response ?? null; - } - - inFlightRequestsCount(): number { - let inFlightRequestCounter = 0; - for (const request of this.#requestMap.values()) { - if (!request.response() || request._failureText) { - inFlightRequestCounter++; - } - } - - return inFlightRequestCounter; - } - - clearMapAfterFrameDispose(frame: BidiFrame): void { - for (const [id, request] of this.#requestMap.entries()) { - if (request.frame() === frame) { - this.#requestMap.delete(id); - } - } - - for (const [id, response] of this.#navigationMap.entries()) { - if (response.frame() === frame) { - this.#navigationMap.delete(id); - } - } - } - - dispose(): void { - this.removeAllListeners(); - this.#requestMap.clear(); - this.#navigationMap.clear(); - this.#subscriptions.dispose(); - } -} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Page.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Page.ts index 053d23b63a..c662496a18 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Page.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Page.ts @@ -4,210 +4,115 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {Readable} from 'stream'; - -import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; import type Protocol from 'devtools-protocol'; -import { - firstValueFrom, - from, - map, - raceWith, - zip, -} from '../../third_party/rxjs/rxjs.js'; +import {firstValueFrom, from, raceWith} from '../../third_party/rxjs/rxjs.js'; import type {CDPSession} from '../api/CDPSession.js'; import type {BoundingBox} from '../api/ElementHandle.js'; import type {WaitForOptions} from '../api/Frame.js'; import type {HTTPResponse} from '../api/HTTPResponse.js'; +import type { + MediaFeature, + GeolocationOptions, + PageEvents, +} from '../api/Page.js'; import { Page, PageEvent, - type GeolocationOptions, - type MediaFeature, type NewDocumentScriptEvaluation, type ScreenshotOptions, } from '../api/Page.js'; import {Accessibility} from '../cdp/Accessibility.js'; import {Coverage} from '../cdp/Coverage.js'; -import {EmulationManager as CdpEmulationManager} from '../cdp/EmulationManager.js'; -import {FrameTree} from '../cdp/FrameTree.js'; +import {EmulationManager} from '../cdp/EmulationManager.js'; import {Tracing} from '../cdp/Tracing.js'; -import { - ConsoleMessage, - type ConsoleMessageLocation, -} from '../common/ConsoleMessage.js'; -import {TargetCloseError, UnsupportedOperation} from '../common/Errors.js'; -import type {Handler} from '../common/EventEmitter.js'; -import {NetworkManagerEvent} from '../common/NetworkManagerEvents.js'; +import type {Cookie, CookieParam, CookieSameSite} from '../common/Cookie.js'; +import type {DeleteCookiesRequest} from '../common/Cookie.js'; +import {UnsupportedOperation} from '../common/Errors.js'; +import {EventEmitter} from '../common/EventEmitter.js'; import type {PDFOptions} from '../common/PDFOptions.js'; import type {Awaitable} from '../common/types.js'; -import { - debugError, - evaluationString, - NETWORK_IDLE_TIME, - parsePDFOptions, - timeout, - validateDialogType, -} from '../common/util.js'; +import {evaluationString, parsePDFOptions, timeout} from '../common/util.js'; import type {Viewport} from '../common/Viewport.js'; import {assert} from '../util/assert.js'; -import {Deferred} from '../util/Deferred.js'; -import {disposeSymbol} from '../util/disposable.js'; +import {bubble} from '../util/decorators.js'; import {isErrorLike} from '../util/ErrorLike.js'; import type {BidiBrowser} from './Browser.js'; import type {BidiBrowserContext} from './BrowserContext.js'; -import { - BrowsingContextEvent, - CdpSessionWrapper, - type BrowsingContext, -} from './BrowsingContext.js'; -import type {BidiConnection} from './Connection.js'; -import {BidiDeserializer} from './Deserializer.js'; -import {BidiDialog} from './Dialog.js'; +import type {BidiCdpSession} from './CDPSession.js'; +import type {BrowsingContext} from './core/BrowsingContext.js'; import {BidiElementHandle} from './ElementHandle.js'; -import {EmulationManager} from './EmulationManager.js'; import {BidiFrame} from './Frame.js'; -import type {BidiHTTPRequest} from './HTTPRequest.js'; import type {BidiHTTPResponse} from './HTTPResponse.js'; import {BidiKeyboard, BidiMouse, BidiTouchscreen} from './Input.js'; import type {BidiJSHandle} from './JSHandle.js'; -import {getBiDiReadinessState, rewriteNavigationError} from './lifecycle.js'; -import {BidiNetworkManager} from './NetworkManager.js'; -import {createBidiHandle} from './Realm.js'; -import type {BiDiPageTarget} from './Target.js'; +import {rewriteNavigationError} from './util.js'; +import type {BidiWebWorker} from './WebWorker.js'; /** * @internal */ export class BidiPage extends Page { - #accessibility: Accessibility; - #connection: BidiConnection; - #frameTree = new FrameTree<BidiFrame>(); - #networkManager: BidiNetworkManager; - #viewport: Viewport | null = null; - #closedDeferred = Deferred.create<never, TargetCloseError>(); - #subscribedEvents = new Map<Bidi.Event['method'], Handler<any>>([ - ['log.entryAdded', this.#onLogEntryAdded.bind(this)], - ['browsingContext.load', this.#onFrameLoaded.bind(this)], - [ - 'browsingContext.fragmentNavigated', - this.#onFrameFragmentNavigated.bind(this), - ], - [ - 'browsingContext.domContentLoaded', - this.#onFrameDOMContentLoaded.bind(this), - ], - ['browsingContext.userPromptOpened', this.#onDialog.bind(this)], - ]); - readonly #networkManagerEvents = [ - [ - NetworkManagerEvent.Request, - (request: BidiHTTPRequest) => { - this.emit(PageEvent.Request, request); - }, - ], - [ - NetworkManagerEvent.RequestServedFromCache, - (request: BidiHTTPRequest) => { - this.emit(PageEvent.RequestServedFromCache, request); - }, - ], - [ - NetworkManagerEvent.RequestFailed, - (request: BidiHTTPRequest) => { - this.emit(PageEvent.RequestFailed, request); - }, - ], - [ - NetworkManagerEvent.RequestFinished, - (request: BidiHTTPRequest) => { - this.emit(PageEvent.RequestFinished, request); - }, - ], - [ - NetworkManagerEvent.Response, - (response: BidiHTTPResponse) => { - this.emit(PageEvent.Response, response); - }, - ], - ] as const; - - readonly #browsingContextEvents = new Map<symbol, Handler<any>>([ - [BrowsingContextEvent.Created, this.#onContextCreated.bind(this)], - [BrowsingContextEvent.Destroyed, this.#onContextDestroyed.bind(this)], - ]); - #tracing: Tracing; - #coverage: Coverage; - #cdpEmulationManager: CdpEmulationManager; - #emulationManager: EmulationManager; - #mouse: BidiMouse; - #touchscreen: BidiTouchscreen; - #keyboard: BidiKeyboard; - #browsingContext: BrowsingContext; - #browserContext: BidiBrowserContext; - #target: BiDiPageTarget; - - _client(): CDPSession { - return this.mainFrame().context().cdpSession; - } - - constructor( - browsingContext: BrowsingContext, + static from( browserContext: BidiBrowserContext, - target: BiDiPageTarget - ) { - super(); - this.#browsingContext = browsingContext; - this.#browserContext = browserContext; - this.#target = target; - this.#connection = browsingContext.connection; + browsingContext: BrowsingContext + ): BidiPage { + const page = new BidiPage(browserContext, browsingContext); + page.#initialize(); + return page; + } - for (const [event, subscriber] of this.#browsingContextEvents) { - this.#browsingContext.on(event, subscriber); - } + @bubble() + accessor trustedEmitter = new EventEmitter<PageEvents>(); - this.#networkManager = new BidiNetworkManager(this.#connection, this); + readonly #browserContext: BidiBrowserContext; + readonly #frame: BidiFrame; + #viewport: Viewport | null = null; + readonly #workers = new Set<BidiWebWorker>(); - for (const [event, subscriber] of this.#subscribedEvents) { - this.#connection.on(event, subscriber); - } + readonly keyboard: BidiKeyboard; + readonly mouse: BidiMouse; + readonly touchscreen: BidiTouchscreen; + readonly accessibility: Accessibility; + readonly tracing: Tracing; + readonly coverage: Coverage; + readonly #cdpEmulationManager: EmulationManager; - for (const [event, subscriber] of this.#networkManagerEvents) { - // TODO: remove any - this.#networkManager.on(event, subscriber as any); - } + _client(): BidiCdpSession { + return this.#frame.client; + } - const frame = new BidiFrame( - this, - this.#browsingContext, - this._timeoutSettings, - this.#browsingContext.parent - ); - this.#frameTree.addFrame(frame); - this.emit(PageEvent.FrameAttached, frame); + private constructor( + browserContext: BidiBrowserContext, + browsingContext: BrowsingContext + ) { + super(); + this.#browserContext = browserContext; + this.#frame = BidiFrame.from(this, browsingContext); - // TODO: https://github.com/w3c/webdriver-bidi/issues/443 - this.#accessibility = new Accessibility( - this.mainFrame().context().cdpSession - ); - this.#tracing = new Tracing(this.mainFrame().context().cdpSession); - this.#coverage = new Coverage(this.mainFrame().context().cdpSession); - this.#cdpEmulationManager = new CdpEmulationManager( - this.mainFrame().context().cdpSession - ); - this.#emulationManager = new EmulationManager(browsingContext); - this.#mouse = new BidiMouse(this.mainFrame().context()); - this.#touchscreen = new BidiTouchscreen(this.mainFrame().context()); - this.#keyboard = new BidiKeyboard(this); + this.#cdpEmulationManager = new EmulationManager(this.#frame.client); + this.accessibility = new Accessibility(this.#frame.client); + this.tracing = new Tracing(this.#frame.client); + this.coverage = new Coverage(this.#frame.client); + this.keyboard = new BidiKeyboard(this); + this.mouse = new BidiMouse(this); + this.touchscreen = new BidiTouchscreen(this); } - /** - * @internal - */ - get connection(): BidiConnection { - return this.#connection; + #initialize() { + this.#frame.browsingContext.on('closed', () => { + this.trustedEmitter.emit(PageEvent.Close, undefined); + this.trustedEmitter.removeAllListeners(); + }); + + this.trustedEmitter.on(PageEvent.WorkerCreated, worker => { + this.#workers.add(worker as BidiWebWorker); + }); + this.trustedEmitter.on(PageEvent.WorkerDestroyed, worker => { + this.#workers.delete(worker as BidiWebWorker); + }); } override async setUserAgent( @@ -234,46 +139,15 @@ export class BidiPage extends Page { prototypeHandle.id, 'Prototype JSHandle must not be referencing primitive value' ); - const response = await this.mainFrame().client.send( - 'Runtime.queryObjects', - { - prototypeObjectId: prototypeHandle.id, - } - ); - return createBidiHandle(this.mainFrame().mainRealm(), { + const response = await this.#frame.client.send('Runtime.queryObjects', { + prototypeObjectId: prototypeHandle.id, + }); + return this.#frame.mainRealm().createHandle({ type: 'array', handle: response.objects.objectId, }) as BidiJSHandle<Prototype[]>; } - _setBrowserContext(browserContext: BidiBrowserContext): void { - this.#browserContext = browserContext; - } - - override get accessibility(): Accessibility { - return this.#accessibility; - } - - override get tracing(): Tracing { - return this.#tracing; - } - - override get coverage(): Coverage { - return this.#coverage; - } - - override get mouse(): BidiMouse { - return this.#mouse; - } - - override get touchscreen(): BidiTouchscreen { - return this.#touchscreen; - } - - override get keyboard(): BidiKeyboard { - return this.#keyboard; - } - override browser(): BidiBrowser { return this.browserContext().browser(); } @@ -283,14 +157,9 @@ export class BidiPage extends Page { } override mainFrame(): BidiFrame { - const mainFrame = this.#frameTree.getMainFrame(); - assert(mainFrame, 'Requesting main frame too early!'); - return mainFrame; + return this.#frame; } - /** - * @internal - */ async focusedFrame(): Promise<BidiFrame> { using frame = await this.mainFrame() .isolatedRealm() @@ -310,216 +179,38 @@ export class BidiPage extends Page { } override frames(): BidiFrame[] { - return Array.from(this.#frameTree.frames()); - } - - frame(frameId?: string): BidiFrame | null { - return this.#frameTree.getById(frameId ?? '') || null; - } - - childFrames(frameId: string): BidiFrame[] { - return this.#frameTree.childFrames(frameId); - } - - #onFrameLoaded(info: Bidi.BrowsingContext.NavigationInfo): void { - const frame = this.frame(info.context); - if (frame && this.mainFrame() === frame) { - this.emit(PageEvent.Load, undefined); - } - } - - #onFrameFragmentNavigated(info: Bidi.BrowsingContext.NavigationInfo): void { - const frame = this.frame(info.context); - if (frame) { - this.emit(PageEvent.FrameNavigated, frame); - } - } - - #onFrameDOMContentLoaded(info: Bidi.BrowsingContext.NavigationInfo): void { - const frame = this.frame(info.context); - if (frame) { - frame._hasStartedLoading = true; - if (this.mainFrame() === frame) { - this.emit(PageEvent.DOMContentLoaded, undefined); - } - this.emit(PageEvent.FrameNavigated, frame); - } - } - - #onContextCreated(context: BrowsingContext): void { - if ( - !this.frame(context.id) && - (this.frame(context.parent ?? '') || !this.#frameTree.getMainFrame()) - ) { - const frame = new BidiFrame( - this, - context, - this._timeoutSettings, - context.parent - ); - this.#frameTree.addFrame(frame); - if (frame !== this.mainFrame()) { - this.emit(PageEvent.FrameAttached, frame); - } - } - } - - #onContextDestroyed(context: BrowsingContext): void { - const frame = this.frame(context.id); - - if (frame) { - if (frame === this.mainFrame()) { - this.emit(PageEvent.Close, undefined); - } - this.#removeFramesRecursively(frame); - } - } - - #removeFramesRecursively(frame: BidiFrame): void { - for (const child of frame.childFrames()) { - this.#removeFramesRecursively(child); - } - frame[disposeSymbol](); - this.#networkManager.clearMapAfterFrameDispose(frame); - this.#frameTree.removeFrame(frame); - this.emit(PageEvent.FrameDetached, frame); - } - - #onLogEntryAdded(event: Bidi.Log.Entry): void { - const frame = this.frame(event.source.context); - if (!frame) { - return; - } - if (isConsoleLogEntry(event)) { - const args = event.args.map(arg => { - return createBidiHandle(frame.mainRealm(), arg); - }); - - const text = args - .reduce((value, arg) => { - const parsedValue = arg.isPrimitiveValue - ? BidiDeserializer.deserialize(arg.remoteValue()) - : arg.toString(); - return `${value} ${parsedValue}`; - }, '') - .slice(1); - - this.emit( - PageEvent.Console, - new ConsoleMessage( - event.method as any, - text, - args, - getStackTraceLocations(event.stackTrace) - ) - ); - } else if (isJavaScriptLogEntry(event)) { - const error = new Error(event.text ?? ''); - - const messageHeight = error.message.split('\n').length; - const messageLines = error.stack!.split('\n').splice(0, messageHeight); - - const stackLines = []; - if (event.stackTrace) { - for (const frame of event.stackTrace.callFrames) { - // Note we need to add `1` because the values are 0-indexed. - stackLines.push( - ` at ${frame.functionName || '<anonymous>'} (${frame.url}:${ - frame.lineNumber + 1 - }:${frame.columnNumber + 1})` - ); - if (stackLines.length >= Error.stackTraceLimit) { - break; - } - } - } - - error.stack = [...messageLines, ...stackLines].join('\n'); - this.emit(PageEvent.PageError, error); - } else { - debugError( - `Unhandled LogEntry with type "${event.type}", text "${event.text}" and level "${event.level}"` - ); - } - } - - #onDialog(event: Bidi.BrowsingContext.UserPromptOpenedParameters): void { - const frame = this.frame(event.context); - if (!frame) { - return; + const frames = [this.#frame]; + for (const frame of frames) { + frames.push(...frame.childFrames()); } - const type = validateDialogType(event.type); - - const dialog = new BidiDialog( - frame.context(), - type, - event.message, - event.defaultValue - ); - this.emit(PageEvent.Dialog, dialog); - } - - getNavigationResponse(id?: string | null): BidiHTTPResponse | null { - return this.#networkManager.getNavigationResponse(id); + return frames; } override isClosed(): boolean { - return this.#closedDeferred.finished(); + return this.#frame.detached; } override async close(options?: {runBeforeUnload?: boolean}): Promise<void> { - if (this.#closedDeferred.finished()) { + try { + await this.#frame.browsingContext.close(options?.runBeforeUnload); + } catch { return; } - - this.#closedDeferred.reject(new TargetCloseError('Page closed!')); - this.#networkManager.dispose(); - - await this.#connection.send('browsingContext.close', { - context: this.mainFrame()._id, - promptUnload: options?.runBeforeUnload ?? false, - }); - - this.emit(PageEvent.Close, undefined); - this.removeAllListeners(); } override async reload( options: WaitForOptions = {} ): Promise<BidiHTTPResponse | null> { - const { - waitUntil = 'load', - timeout: ms = this._timeoutSettings.navigationTimeout(), - } = options; - - const [readiness, networkIdle] = getBiDiReadinessState(waitUntil); - - const result$ = zip( - from( - this.#connection.send('browsingContext.reload', { - context: this.mainFrame()._id, - wait: readiness, - }) - ), - ...(networkIdle !== null - ? [ - this.waitForNetworkIdle$({ - timeout: ms, - concurrency: networkIdle === 'networkidle2' ? 2 : 0, - idleTime: NETWORK_IDLE_TIME, - }), - ] - : []) - ).pipe( - map(([{result}]) => { - return result; - }), - raceWith(timeout(ms), from(this.#closedDeferred.valueOrThrow())), - rewriteNavigationError(this.url(), ms) + const [response] = await Promise.all([ + this.#frame.waitForNavigation(options), + this.#frame.browsingContext.reload(), + ]).catch( + rewriteNavigationError( + this.url(), + options.timeout ?? this._timeoutSettings.navigationTimeout() + ) ); - - const result = await firstValueFrom(result$); - return this.getNavigationResponse(result.navigation); + return response; } override setDefaultNavigationTimeout(timeout: number): void { @@ -578,8 +269,19 @@ export class BidiPage extends Page { } override async setViewport(viewport: Viewport): Promise<void> { - if (!this.#browsingContext.supportsCdp()) { - await this.#emulationManager.emulateViewport(viewport); + if (!this.browser().cdpSupported) { + await this.#frame.browsingContext.setViewport({ + viewport: + viewport.width && viewport.height + ? { + width: viewport.width, + height: viewport.height, + } + : null, + devicePixelRatio: viewport.deviceScaleFactor + ? viewport.deviceScaleFactor + : null, + }); this.#viewport = viewport; return; } @@ -609,10 +311,9 @@ export class BidiPage extends Page { preferCSSPageSize, } = parsePDFOptions(options, 'cm'); const pageRanges = ranges ? ranges.split(', ') : []; - const {result} = await firstValueFrom( + const data = await firstValueFrom( from( - this.#connection.send('browsingContext.print', { - context: this.mainFrame()._id, + this.#frame.browsingContext.print({ background, margin, orientation: landscape ? 'landscape' : 'portrait', @@ -627,7 +328,7 @@ export class BidiPage extends Page { ).pipe(raceWith(timeout(ms))) ); - const buffer = Buffer.from(result.data, 'base64'); + const buffer = Buffer.from(data, 'base64'); await this._maybeWriteBufferToFile(path, buffer); @@ -636,19 +337,15 @@ export class BidiPage extends Page { override async createPDFStream( options?: PDFOptions | undefined - ): Promise<Readable> { + ): Promise<ReadableStream<Uint8Array>> { const buffer = await this.pdf(options); - try { - const {Readable} = await import('stream'); - return Readable.from(buffer); - } catch (error) { - if (error instanceof TypeError) { - throw new Error( - 'Can only pass a file path in a Node-like environment.' - ); - } - throw error; - } + + return new ReadableStream({ + start(controller) { + controller.enqueue(buffer); + controller.close(); + }, + }); } override async _screenshot( @@ -697,10 +394,7 @@ export class BidiPage extends Page { } } - const { - result: {data}, - } = await this.#connection.send('browsingContext.captureScreenshot', { - context: this.mainFrame()._id, + const data = await this.#frame.browsingContext.captureScreenshot({ origin: captureBeyondViewport ? 'document' : 'viewport', format: { type: `image/${type}`, @@ -712,19 +406,11 @@ export class BidiPage extends Page { } override async createCDPSession(): Promise<CDPSession> { - const {sessionId} = await this.mainFrame() - .context() - .cdpSession.send('Target.attachToTarget', { - targetId: this.mainFrame()._id, - flatten: true, - }); - return new CdpSessionWrapper(this.mainFrame().context(), sessionId); + return await this.#frame.createCDPSession(); } override async bringToFront(): Promise<void> { - await this.#connection.send('browsingContext.activate', { - context: this.mainFrame()._id, - }); + await this.#frame.browsingContext.activate(); } override async evaluateOnNewDocument< @@ -735,20 +421,16 @@ export class BidiPage extends Page { ...args: Params ): Promise<NewDocumentScriptEvaluation> { const expression = evaluationExpression(pageFunction, ...args); - const {result} = await this.#connection.send('script.addPreloadScript', { - functionDeclaration: expression, - contexts: [this.mainFrame()._id], - }); + const script = + await this.#frame.browsingContext.addPreloadScript(expression); - return {identifier: result.script}; + return {identifier: script}; } override async removeScriptToEvaluateOnNewDocument( id: string ): Promise<void> { - await this.#connection.send('script.removePreloadScript', { - script: id, - }); + await this.#frame.browsingContext.removePreloadScript(id); } override async exposeFunction<Args extends unknown[], Ret>( @@ -774,20 +456,37 @@ export class BidiPage extends Page { }); } + override async cookies(...urls: string[]): Promise<Cookie[]> { + const normalizedUrls = (urls.length ? urls : [this.url()]).map(url => { + return new URL(url); + }); + + const cookies = await this.#frame.browsingContext.getCookies(); + return cookies + .map(cookie => { + return bidiToPuppeteerCookie(cookie); + }) + .filter(cookie => { + return normalizedUrls.some(url => { + return testUrlMatchCookie(cookie, url); + }); + }); + } + override isServiceWorkerBypassed(): never { throw new UnsupportedOperation(); } - override target(): BiDiPageTarget { - return this.#target; + override target(): never { + throw new UnsupportedOperation(); } override waitForFileChooser(): never { throw new UnsupportedOperation(); } - override workers(): never { - throw new UnsupportedOperation(); + override workers(): BidiWebWorker[] { + return [...this.#workers]; } override setRequestInterception(): never { @@ -810,21 +509,98 @@ export class BidiPage extends Page { throw new UnsupportedOperation(); } - override cookies(): never { - throw new UnsupportedOperation(); - } + override async setCookie(...cookies: CookieParam[]): Promise<void> { + const pageURL = this.url(); + const pageUrlStartsWithHTTP = pageURL.startsWith('http'); + for (const cookie of cookies) { + let cookieUrl = cookie.url || ''; + if (!cookieUrl && pageUrlStartsWithHTTP) { + cookieUrl = pageURL; + } + assert( + cookieUrl !== 'about:blank', + `Blank page can not have cookie "${cookie.name}"` + ); + assert( + !String.prototype.startsWith.call(cookieUrl || '', 'data:'), + `Data URL page can not have cookie "${cookie.name}"` + ); - override setCookie(): never { - throw new UnsupportedOperation(); + const normalizedUrl = URL.canParse(cookieUrl) + ? new URL(cookieUrl) + : undefined; + + const domain = cookie.domain ?? normalizedUrl?.hostname; + assert( + domain !== undefined, + `At least one of the url and domain needs to be specified` + ); + + const bidiCookie: Bidi.Storage.PartialCookie = { + domain: domain, + name: cookie.name, + value: { + type: 'string', + value: cookie.value, + }, + ...(cookie.path !== undefined ? {path: cookie.path} : {}), + ...(cookie.httpOnly !== undefined ? {httpOnly: cookie.httpOnly} : {}), + ...(cookie.secure !== undefined ? {secure: cookie.secure} : {}), + ...(cookie.sameSite !== undefined + ? {sameSite: convertCookiesSameSiteCdpToBiDi(cookie.sameSite)} + : {}), + ...(cookie.expires !== undefined ? {expiry: cookie.expires} : {}), + // Chrome-specific properties. + ...cdpSpecificCookiePropertiesFromPuppeteerToBidi( + cookie, + 'sameParty', + 'sourceScheme', + 'priority', + 'url' + ), + }; + + if (cookie.partitionKey !== undefined) { + await this.browserContext().userContext.setCookie( + bidiCookie, + cookie.partitionKey + ); + } else { + await this.#frame.browsingContext.setCookie(bidiCookie); + } + } } - override deleteCookie(): never { - throw new UnsupportedOperation(); + override async deleteCookie( + ...cookies: DeleteCookiesRequest[] + ): Promise<void> { + await Promise.all( + cookies.map(async deleteCookieRequest => { + const cookieUrl = deleteCookieRequest.url ?? this.url(); + const normalizedUrl = URL.canParse(cookieUrl) + ? new URL(cookieUrl) + : undefined; + + const domain = deleteCookieRequest.domain ?? normalizedUrl?.hostname; + assert( + domain !== undefined, + `At least one of the url and domain needs to be specified` + ); + + const filter = { + domain: domain, + name: deleteCookieRequest.name, + ...(deleteCookieRequest.path !== undefined + ? {path: deleteCookieRequest.path} + : {}), + }; + await this.#frame.browsingContext.deleteCookie(filter); + }) + ); } - override removeExposedFunction(): never { - // TODO: Quick win? - throw new UnsupportedOperation(); + override async removeExposedFunction(name: string): Promise<void> { + await this.#frame.removeExposedFunction(name); } override authenticate(): never { @@ -848,7 +624,7 @@ export class BidiPage extends Page { override async goForward( options: WaitForOptions = {} ): Promise<HTTPResponse | null> { - return await this.#go(+1, options); + return await this.#go(1, options); } async #go( @@ -856,22 +632,19 @@ export class BidiPage extends Page { options: WaitForOptions ): Promise<HTTPResponse | null> { try { - const result = await Promise.all([ + const [response] = await Promise.all([ this.waitForNavigation(options), - this.#connection.send('browsingContext.traverseHistory', { - delta, - context: this.mainFrame()._id, - }), + this.#frame.browsingContext.traverseHistory(delta), ]); - return result[0]; - } catch (err) { + return response; + } catch (error) { // TODO: waitForNavigation should be cancelled if an error happens. - if (isErrorLike(err)) { - if (err.message.includes('no such history entry')) { + if (isErrorLike(error)) { + if (error.message.includes('no such history entry')) { return null; } } - throw err; + throw error; } } @@ -880,34 +653,137 @@ export class BidiPage extends Page { } } -function isConsoleLogEntry( - event: Bidi.Log.Entry -): event is Bidi.Log.ConsoleLogEntry { - return event.type === 'console'; +function evaluationExpression(fun: Function | string, ...args: unknown[]) { + return `() => {${evaluationString(fun, ...args)}}`; } -function isJavaScriptLogEntry( - event: Bidi.Log.Entry -): event is Bidi.Log.JavascriptLogEntry { - return event.type === 'javascript'; +/** + * Check domains match. + * According to cookies spec, this check should match subdomains as well, but CDP + * implementation does not do that, so this method matches only the exact domains, not + * what is written in the spec: + * https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3 + */ +function testUrlMatchCookieHostname( + cookie: Cookie, + normalizedUrl: URL +): boolean { + const cookieDomain = cookie.domain.toLowerCase(); + const urlHostname = normalizedUrl.hostname.toLowerCase(); + return cookieDomain === urlHostname; } -function getStackTraceLocations( - stackTrace?: Bidi.Script.StackTrace -): ConsoleMessageLocation[] { - const stackTraceLocations: ConsoleMessageLocation[] = []; - if (stackTrace) { - for (const callFrame of stackTrace.callFrames) { - stackTraceLocations.push({ - url: callFrame.url, - lineNumber: callFrame.lineNumber, - columnNumber: callFrame.columnNumber, - }); +/** + * Check paths match. + * Spec: https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4 + */ +function testUrlMatchCookiePath(cookie: Cookie, normalizedUrl: URL): boolean { + const uriPath = normalizedUrl.pathname; + const cookiePath = cookie.path; + + if (uriPath === cookiePath) { + // The cookie-path and the request-path are identical. + return true; + } + if (uriPath.startsWith(cookiePath)) { + // The cookie-path is a prefix of the request-path. + if (cookiePath.endsWith('/')) { + // The last character of the cookie-path is %x2F ("/"). + return true; + } + if (uriPath[cookiePath.length] === '/') { + // The first character of the request-path that is not included in the cookie-path + // is a %x2F ("/") character. + return true; } } - return stackTraceLocations; + return false; } -function evaluationExpression(fun: Function | string, ...args: unknown[]) { - return `() => {${evaluationString(fun, ...args)}}`; +/** + * Checks the cookie matches the URL according to the spec: + */ +function testUrlMatchCookie(cookie: Cookie, url: URL): boolean { + const normalizedUrl = new URL(url); + assert(cookie !== undefined); + if (!testUrlMatchCookieHostname(cookie, normalizedUrl)) { + return false; + } + return testUrlMatchCookiePath(cookie, normalizedUrl); +} + +function bidiToPuppeteerCookie(bidiCookie: Bidi.Network.Cookie): Cookie { + return { + name: bidiCookie.name, + // Presents binary value as base64 string. + value: bidiCookie.value.value, + domain: bidiCookie.domain, + path: bidiCookie.path, + size: bidiCookie.size, + httpOnly: bidiCookie.httpOnly, + secure: bidiCookie.secure, + sameSite: convertCookiesSameSiteBiDiToCdp(bidiCookie.sameSite), + expires: bidiCookie.expiry ?? -1, + session: bidiCookie.expiry === undefined || bidiCookie.expiry <= 0, + // Extending with CDP-specific properties with `goog:` prefix. + ...cdpSpecificCookiePropertiesFromBidiToPuppeteer( + bidiCookie, + 'sameParty', + 'sourceScheme', + 'partitionKey', + 'partitionKeyOpaque', + 'priority' + ), + }; +} + +const CDP_SPECIFIC_PREFIX = 'goog:'; + +/** + * Gets CDP-specific properties from the BiDi cookie and returns them as a new object. + */ +function cdpSpecificCookiePropertiesFromBidiToPuppeteer( + bidiCookie: Bidi.Network.Cookie, + ...propertyNames: Array<keyof Cookie> +): Partial<Cookie> { + const result: Partial<Cookie> = {}; + for (const property of propertyNames) { + if (bidiCookie[CDP_SPECIFIC_PREFIX + property] !== undefined) { + result[property] = bidiCookie[CDP_SPECIFIC_PREFIX + property]; + } + } + return result; +} + +/** + * Gets CDP-specific properties from the cookie, adds CDP-specific prefixes and returns + * them as a new object which can be used in BiDi. + */ +function cdpSpecificCookiePropertiesFromPuppeteerToBidi( + cookieParam: CookieParam, + ...propertyNames: Array<keyof CookieParam> +): Record<string, unknown> { + const result: Record<string, unknown> = {}; + for (const property of propertyNames) { + if (cookieParam[property] !== undefined) { + result[CDP_SPECIFIC_PREFIX + property] = cookieParam[property]; + } + } + return result; +} + +function convertCookiesSameSiteBiDiToCdp( + sameSite: Bidi.Network.SameSite | undefined +): CookieSameSite { + return sameSite === 'strict' ? 'Strict' : sameSite === 'lax' ? 'Lax' : 'None'; +} + +function convertCookiesSameSiteCdpToBiDi( + sameSite: CookieSameSite | undefined +): Bidi.Network.SameSite { + return sameSite === 'Strict' + ? Bidi.Network.SameSite.Strict + : sameSite === 'Lax' + ? Bidi.Network.SameSite.Lax + : Bidi.Network.SameSite.None; } diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Realm.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Realm.ts index 84f13bc703..1027941e2f 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Realm.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Realm.ts @@ -1,80 +1,63 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; -import {EventEmitter, type EventType} from '../common/EventEmitter.js'; +import type {JSHandle} from '../api/JSHandle.js'; +import {Realm} from '../api/Realm.js'; +import {ARIAQueryHandler} from '../cdp/AriaQueryHandler.js'; +import {LazyArg} from '../common/LazyArg.js'; import {scriptInjector} from '../common/ScriptInjector.js'; +import type {TimeoutSettings} from '../common/TimeoutSettings.js'; import type {EvaluateFunc, HandleFor} from '../common/types.js'; import { - PuppeteerURL, - SOURCE_URL_REGEX, + debugError, getSourcePuppeteerURLIfAvailable, getSourceUrlComment, isString, + PuppeteerURL, + SOURCE_URL_REGEX, } from '../common/util.js'; import type PuppeteerUtil from '../injected/injected.js'; -import {disposeSymbol} from '../util/disposable.js'; +import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js'; import {stringifyFunction} from '../util/Function.js'; -import type {BidiConnection} from './Connection.js'; +import type { + Realm as BidiRealmCore, + DedicatedWorkerRealm, + SharedWorkerRealm, +} from './core/Realm.js'; +import type {WindowRealm} from './core/Realm.js'; import {BidiDeserializer} from './Deserializer.js'; import {BidiElementHandle} from './ElementHandle.js'; +import {ExposeableFunction} from './ExposedFunction.js'; +import type {BidiFrame} from './Frame.js'; import {BidiJSHandle} from './JSHandle.js'; -import type {Sandbox} from './Sandbox.js'; import {BidiSerializer} from './Serializer.js'; import {createEvaluationError} from './util.js'; +import type {BidiWebWorker} from './WebWorker.js'; /** * @internal */ -export class BidiRealm extends EventEmitter<Record<EventType, any>> { - readonly connection: BidiConnection; - - #id!: string; - #sandbox!: Sandbox; +export abstract class BidiRealm extends Realm { + readonly realm: BidiRealmCore; - constructor(connection: BidiConnection) { - super(); - this.connection = connection; + constructor(realm: BidiRealmCore, timeoutSettings: TimeoutSettings) { + super(timeoutSettings); + this.realm = realm; } - get target(): Bidi.Script.Target { - return { - context: this.#sandbox.environment._id, - sandbox: this.#sandbox.name, - }; - } - - handleRealmDestroyed = async ( - params: Bidi.Script.RealmDestroyed['params'] - ): Promise<void> => { - if (params.realm === this.#id) { - // Note: The Realm is destroyed, so in theory the handle should be as - // well. + protected initialize(): void { + this.realm.on('destroyed', ({reason}) => { + this.taskManager.terminateAll(new Error(reason)); + }); + this.realm.on('updated', () => { this.internalPuppeteerUtil = undefined; - this.#sandbox.environment.clearDocumentHandle(); - } - }; - - handleRealmCreated = (params: Bidi.Script.RealmCreated['params']): void => { - if ( - params.type === 'window' && - params.context === this.#sandbox.environment._id && - params.sandbox === this.#sandbox.name - ) { - this.#id = params.realm; - void this.#sandbox.taskManager.rerunAll(); - } - }; - - setSandbox(sandbox: Sandbox): void { - this.#sandbox = sandbox; - this.connection.on( - Bidi.ChromiumBidi.Script.EventNames.RealmCreated, - this.handleRealmCreated - ); - this.connection.on( - Bidi.ChromiumBidi.Script.EventNames.RealmDestroyed, - this.handleRealmDestroyed - ); + void this.taskManager.rerunAll(); + }); } protected internalPuppeteerUtil?: Promise<BidiJSHandle<PuppeteerUtil>>; @@ -95,7 +78,7 @@ export class BidiRealm extends EventEmitter<Record<EventType, any>> { return this.internalPuppeteerUtil as Promise<BidiJSHandle<PuppeteerUtil>>; } - async evaluateHandle< + override async evaluateHandle< Params extends unknown[], Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, >( @@ -105,7 +88,7 @@ export class BidiRealm extends EventEmitter<Record<EventType, any>> { return await this.#evaluate(false, pageFunction, ...args); } - async evaluate< + override async evaluate< Params extends unknown[], Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, >( @@ -144,8 +127,6 @@ export class BidiRealm extends EventEmitter<Record<EventType, any>> { PuppeteerURL.INTERNAL_URL ); - const sandbox = this.#sandbox; - let responsePromise; const resultOwnership = returnByValue ? Bidi.Script.ResultOwnership.None @@ -161,11 +142,8 @@ export class BidiRealm extends EventEmitter<Record<EventType, any>> { ? pageFunction : `${pageFunction}\n${sourceUrlComment}\n`; - responsePromise = this.connection.send('script.evaluate', { - expression, - target: this.target, + responsePromise = this.realm.evaluate(expression, true, { resultOwnership, - awaitPromise: true, userActivation: true, serializationOptions, }); @@ -174,24 +152,25 @@ export class BidiRealm extends EventEmitter<Record<EventType, any>> { functionDeclaration = SOURCE_URL_REGEX.test(functionDeclaration) ? functionDeclaration : `${functionDeclaration}\n${sourceUrlComment}\n`; - responsePromise = this.connection.send('script.callFunction', { + responsePromise = this.realm.callFunction( functionDeclaration, - arguments: args.length - ? await Promise.all( - args.map(arg => { - return BidiSerializer.serialize(sandbox, arg); - }) - ) - : [], - target: this.target, - resultOwnership, - awaitPromise: true, - userActivation: true, - serializationOptions, - }); + /* awaitPromise= */ true, + { + arguments: args.length + ? await Promise.all( + args.map(arg => { + return this.serialize(arg); + }) + ) + : [], + resultOwnership, + userActivation: true, + serializationOptions, + } + ); } - const {result} = await responsePromise; + const result = await responsePromise; if ('type' in result && result.type === 'exception') { throw createEvaluationError(result.exceptionDetails); @@ -199,30 +178,211 @@ export class BidiRealm extends EventEmitter<Record<EventType, any>> { return returnByValue ? BidiDeserializer.deserialize(result.result) - : createBidiHandle(sandbox, result.result); + : this.createHandle(result.result); } - [disposeSymbol](): void { - this.connection.off( - Bidi.ChromiumBidi.Script.EventNames.RealmCreated, - this.handleRealmCreated - ); - this.connection.off( - Bidi.ChromiumBidi.Script.EventNames.RealmDestroyed, - this.handleRealmDestroyed + createHandle( + result: Bidi.Script.RemoteValue + ): BidiJSHandle<unknown> | BidiElementHandle<Node> { + if ( + (result.type === 'node' || result.type === 'window') && + this instanceof BidiFrameRealm + ) { + return BidiElementHandle.from(result, this); + } + return BidiJSHandle.from(result, this); + } + + async serialize(arg: unknown): Promise<Bidi.Script.LocalValue> { + if (arg instanceof LazyArg) { + arg = await arg.get(this); + } + + if (arg instanceof BidiJSHandle || arg instanceof BidiElementHandle) { + if (arg.realm !== this) { + if ( + !(arg.realm instanceof BidiFrameRealm) || + !(this instanceof BidiFrameRealm) + ) { + throw new Error( + "Trying to evaluate JSHandle from different global types. Usually this means you're using a handle from a worker in a page or vice versa." + ); + } + if (arg.realm.environment !== this.environment) { + throw new Error( + "Trying to evaluate JSHandle from different frames. Usually this means you're using a handle from a page on a different page." + ); + } + } + if (arg.disposed) { + throw new Error('JSHandle is disposed!'); + } + return arg.remoteValue() as Bidi.Script.RemoteReference; + } + + return BidiSerializer.serialize(arg); + } + + async destroyHandles(handles: Array<BidiJSHandle<unknown>>): Promise<void> { + const handleIds = handles + .map(({id}) => { + return id; + }) + .filter((id): id is string => { + return id !== undefined; + }); + + if (handleIds.length === 0) { + return; + } + + await this.realm.disown(handleIds).catch(error => { + // Exceptions might happen in case of a page been navigated or closed. + // Swallow these since they are harmless and we don't leak anything in this case. + debugError(error); + }); + } + + override async adoptHandle<T extends JSHandle<Node>>(handle: T): Promise<T> { + return (await this.evaluateHandle(node => { + return node; + }, handle)) as unknown as T; + } + + override async transferHandle<T extends JSHandle<Node>>( + handle: T + ): Promise<T> { + if (handle.realm === this) { + return handle; + } + const transferredHandle = this.adoptHandle(handle); + await handle.dispose(); + return await transferredHandle; + } +} + +/** + * @internal + */ +export class BidiFrameRealm extends BidiRealm { + static from(realm: WindowRealm, frame: BidiFrame): BidiFrameRealm { + const frameRealm = new BidiFrameRealm(realm, frame); + frameRealm.#initialize(); + return frameRealm; + } + declare readonly realm: WindowRealm; + + readonly #frame: BidiFrame; + + private constructor(realm: WindowRealm, frame: BidiFrame) { + super(realm, frame.timeoutSettings); + this.#frame = frame; + } + + #initialize() { + super.initialize(); + + // This should run first. + this.realm.on('updated', () => { + this.environment.clearDocumentHandle(); + this.#bindingsInstalled = false; + }); + } + + #bindingsInstalled = false; + override get puppeteerUtil(): Promise<BidiJSHandle<PuppeteerUtil>> { + let promise = Promise.resolve() as Promise<unknown>; + if (!this.#bindingsInstalled) { + promise = Promise.all([ + ExposeableFunction.from( + this.environment as BidiFrame, + '__ariaQuerySelector', + ARIAQueryHandler.queryOne, + !!this.sandbox + ), + ExposeableFunction.from( + this.environment as BidiFrame, + '__ariaQuerySelectorAll', + async ( + element: BidiElementHandle<Node>, + selector: string + ): Promise<JSHandle<Node[]>> => { + const results = ARIAQueryHandler.queryAll(element, selector); + return await element.realm.evaluateHandle( + (...elements) => { + return elements; + }, + ...(await AsyncIterableUtil.collect(results)) + ); + }, + !!this.sandbox + ), + ]); + this.#bindingsInstalled = true; + } + return promise.then(() => { + return super.puppeteerUtil; + }); + } + + get sandbox(): string | undefined { + return this.realm.sandbox; + } + + override get environment(): BidiFrame { + return this.#frame; + } + + override async adoptBackendNode( + backendNodeId?: number | undefined + ): Promise<JSHandle<Node>> { + const {object} = await this.#frame.client.send('DOM.resolveNode', { + backendNodeId, + executionContextId: await this.realm.resolveExecutionContextId(), + }); + using handle = BidiElementHandle.from( + { + handle: object.objectId, + type: 'node', + }, + this ); + // We need the sharedId, so we perform the following to obtain it. + return await handle.evaluateHandle(element => { + return element; + }); } } /** * @internal */ -export function createBidiHandle( - sandbox: Sandbox, - result: Bidi.Script.RemoteValue -): BidiJSHandle<unknown> | BidiElementHandle<Node> { - if (result.type === 'node' || result.type === 'window') { - return new BidiElementHandle(sandbox, result); - } - return new BidiJSHandle(sandbox, result); +export class BidiWorkerRealm extends BidiRealm { + static from( + realm: DedicatedWorkerRealm | SharedWorkerRealm, + worker: BidiWebWorker + ): BidiWorkerRealm { + const workerRealm = new BidiWorkerRealm(realm, worker); + workerRealm.initialize(); + return workerRealm; + } + declare readonly realm: DedicatedWorkerRealm | SharedWorkerRealm; + + readonly #worker: BidiWebWorker; + + private constructor( + realm: DedicatedWorkerRealm | SharedWorkerRealm, + frame: BidiWebWorker + ) { + super(realm, frame.timeoutSettings); + this.#worker = frame; + } + + override get environment(): BidiWebWorker { + return this.#worker; + } + + override async adoptBackendNode(): Promise<JSHandle<Node>> { + throw new Error('Cannot adopt DOM nodes into a worker.'); + } } diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Sandbox.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Sandbox.ts deleted file mode 100644 index 4411b3dbcd..0000000000 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Sandbox.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * @license - * Copyright 2023 Google Inc. - * SPDX-License-Identifier: Apache-2.0 - */ - -import type {JSHandle} from '../api/JSHandle.js'; -import {Realm} from '../api/Realm.js'; -import type {TimeoutSettings} from '../common/TimeoutSettings.js'; -import type {EvaluateFunc, HandleFor} from '../common/types.js'; -import {withSourcePuppeteerURLIfNone} from '../common/util.js'; - -import type {BrowsingContext} from './BrowsingContext.js'; -import {BidiElementHandle} from './ElementHandle.js'; -import type {BidiFrame} from './Frame.js'; -import type {BidiRealm as BidiRealm} from './Realm.js'; -/** - * A unique key for {@link SandboxChart} to denote the default world. - * Realms are automatically created in the default sandbox. - * - * @internal - */ -export const MAIN_SANDBOX = Symbol('mainSandbox'); -/** - * A unique key for {@link SandboxChart} to denote the puppeteer sandbox. - * This world contains all puppeteer-internal bindings/code. - * - * @internal - */ -export const PUPPETEER_SANDBOX = Symbol('puppeteerSandbox'); - -/** - * @internal - */ -export interface SandboxChart { - [key: string]: Sandbox; - [MAIN_SANDBOX]: Sandbox; - [PUPPETEER_SANDBOX]: Sandbox; -} - -/** - * @internal - */ -export class Sandbox extends Realm { - readonly name: string | undefined; - readonly realm: BidiRealm; - #frame: BidiFrame; - - constructor( - name: string | undefined, - frame: BidiFrame, - // TODO: We should split the Realm and BrowsingContext - realm: BidiRealm | BrowsingContext, - timeoutSettings: TimeoutSettings - ) { - super(timeoutSettings); - this.name = name; - this.realm = realm; - this.#frame = frame; - this.realm.setSandbox(this); - } - - override get environment(): BidiFrame { - return this.#frame; - } - - async evaluateHandle< - Params extends unknown[], - Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, - >( - pageFunction: Func | string, - ...args: Params - ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { - pageFunction = withSourcePuppeteerURLIfNone( - this.evaluateHandle.name, - pageFunction - ); - return await this.realm.evaluateHandle(pageFunction, ...args); - } - - async evaluate< - Params extends unknown[], - Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, - >( - pageFunction: Func | string, - ...args: Params - ): Promise<Awaited<ReturnType<Func>>> { - pageFunction = withSourcePuppeteerURLIfNone( - this.evaluate.name, - pageFunction - ); - return await this.realm.evaluate(pageFunction, ...args); - } - - async adoptHandle<T extends JSHandle<Node>>(handle: T): Promise<T> { - return (await this.evaluateHandle(node => { - return node; - }, handle)) as unknown as T; - } - - async transferHandle<T extends JSHandle<Node>>(handle: T): Promise<T> { - if (handle.realm === this) { - return handle; - } - const transferredHandle = await this.evaluateHandle(node => { - return node; - }, handle); - await handle.dispose(); - return transferredHandle as unknown as T; - } - - override async adoptBackendNode( - backendNodeId?: number - ): Promise<JSHandle<Node>> { - const {object} = await this.environment.client.send('DOM.resolveNode', { - backendNodeId: backendNodeId, - }); - return new BidiElementHandle(this, { - handle: object.objectId, - type: 'node', - }); - } -} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Serializer.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Serializer.ts index c147ec9281..523380782b 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Serializer.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Serializer.ts @@ -6,13 +6,8 @@ import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; -import {LazyArg} from '../common/LazyArg.js'; import {isDate, isPlainObject, isRegExp} from '../common/util.js'; -import {BidiElementHandle} from './ElementHandle.js'; -import {BidiJSHandle} from './JSHandle.js'; -import type {Sandbox} from './Sandbox.js'; - /** * @internal */ @@ -22,7 +17,39 @@ class UnserializableError extends Error {} * @internal */ export class BidiSerializer { - static serializeNumber(arg: number): Bidi.Script.LocalValue { + static serialize(arg: unknown): Bidi.Script.LocalValue { + switch (typeof arg) { + case 'symbol': + case 'function': + throw new UnserializableError(`Unable to serializable ${typeof arg}`); + case 'object': + return this.#serializeObject(arg); + + case 'undefined': + return { + type: 'undefined', + }; + case 'number': + return this.#serializeNumber(arg); + case 'bigint': + return { + type: 'bigint', + value: arg.toString(), + }; + case 'string': + return { + type: 'string', + value: arg, + }; + case 'boolean': + return { + type: 'boolean', + value: arg, + }; + } + } + + static #serializeNumber(arg: number): Bidi.Script.LocalValue { let value: Bidi.Script.SpecialNumber | number; if (Object.is(arg, -0)) { value = '-0'; @@ -41,14 +68,14 @@ export class BidiSerializer { }; } - static serializeObject(arg: object | null): Bidi.Script.LocalValue { + static #serializeObject(arg: object | null): Bidi.Script.LocalValue { if (arg === null) { return { type: 'null', }; } else if (Array.isArray(arg)) { const parsedArray = arg.map(subArg => { - return BidiSerializer.serializeRemoteValue(subArg); + return this.serialize(subArg); }); return { @@ -70,10 +97,7 @@ export class BidiSerializer { const parsedObject: Bidi.Script.MappingLocalValue = []; for (const key in arg) { - parsedObject.push([ - BidiSerializer.serializeRemoteValue(key), - BidiSerializer.serializeRemoteValue(arg[key]), - ]); + parsedObject.push([this.serialize(key), this.serialize(arg[key])]); } return { @@ -99,66 +123,4 @@ export class BidiSerializer { 'Custom object sterilization not possible. Use plain objects instead.' ); } - - static serializeRemoteValue(arg: unknown): Bidi.Script.LocalValue { - switch (typeof arg) { - case 'symbol': - case 'function': - throw new UnserializableError(`Unable to serializable ${typeof arg}`); - case 'object': - return BidiSerializer.serializeObject(arg); - - case 'undefined': - return { - type: 'undefined', - }; - case 'number': - return BidiSerializer.serializeNumber(arg); - case 'bigint': - return { - type: 'bigint', - value: arg.toString(), - }; - case 'string': - return { - type: 'string', - value: arg, - }; - case 'boolean': - return { - type: 'boolean', - value: arg, - }; - } - } - - static async serialize( - sandbox: Sandbox, - arg: unknown - ): Promise<Bidi.Script.LocalValue> { - if (arg instanceof LazyArg) { - arg = await arg.get(sandbox.realm); - } - // eslint-disable-next-line rulesdir/use-using -- We want this to continue living. - const objectHandle = - arg && (arg instanceof BidiJSHandle || arg instanceof BidiElementHandle) - ? arg - : null; - if (objectHandle) { - if ( - objectHandle.realm.environment.context() !== - sandbox.environment.context() - ) { - throw new Error( - 'JSHandles can be evaluated only in the context they were created!' - ); - } - if (objectHandle.disposed) { - throw new Error('JSHandle is disposed!'); - } - return objectHandle.remoteValue() as Bidi.Script.RemoteReference; - } - - return BidiSerializer.serializeRemoteValue(arg); - } } diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Target.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Target.ts index fb01c34638..b9d78538aa 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Target.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Target.ts @@ -4,48 +4,46 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {CDPSession} from '../api/CDPSession.js'; -import type {Page} from '../api/Page.js'; import {Target, TargetType} from '../api/Target.js'; import {UnsupportedOperation} from '../common/Errors.js'; +import type {CDPSession} from '../puppeteer-core.js'; import type {BidiBrowser} from './Browser.js'; import type {BidiBrowserContext} from './BrowserContext.js'; -import {type BrowsingContext, CdpSessionWrapper} from './BrowsingContext.js'; +import type {BidiFrame} from './Frame.js'; import {BidiPage} from './Page.js'; +import type {BidiWebWorker} from './WebWorker.js'; /** * @internal */ -export abstract class BidiTarget extends Target { - protected _browserContext: BidiBrowserContext; +export class BidiBrowserTarget extends Target { + #browser: BidiBrowser; - constructor(browserContext: BidiBrowserContext) { + constructor(browser: BidiBrowser) { super(); - this._browserContext = browserContext; + this.#browser = browser; } - _setBrowserContext(browserContext: BidiBrowserContext): void { - this._browserContext = browserContext; + override asPage(): Promise<BidiPage> { + throw new UnsupportedOperation(); } - - override asPage(): Promise<Page> { + override url(): string { + return ''; + } + override createCDPSession(): Promise<CDPSession> { throw new UnsupportedOperation(); } - + override type(): TargetType { + return TargetType.BROWSER; + } override browser(): BidiBrowser { - return this._browserContext.browser(); + return this.#browser; } - override browserContext(): BidiBrowserContext { - return this._browserContext; - } - - override opener(): never { - throw new UnsupportedOperation(); + return this.#browser.defaultBrowserContext(); } - - override createCDPSession(): Promise<CDPSession> { + override opener(): Target | undefined { throw new UnsupportedOperation(); } } @@ -53,39 +51,39 @@ export abstract class BidiTarget extends Target { /** * @internal */ -export class BiDiBrowserTarget extends Target { - #browser: BidiBrowser; +export class BidiPageTarget extends Target { + #page: BidiPage; - constructor(browser: BidiBrowser) { + constructor(page: BidiPage) { super(); - this.#browser = browser; + this.#page = page; } + override async page(): Promise<BidiPage> { + return this.#page; + } + override async asPage(): Promise<BidiPage> { + return BidiPage.from( + this.browserContext(), + this.#page.mainFrame().browsingContext + ); + } override url(): string { - return ''; + return this.#page.url(); } - - override type(): TargetType { - return TargetType.BROWSER; + override createCDPSession(): Promise<CDPSession> { + return this.#page.createCDPSession(); } - - override asPage(): Promise<Page> { - throw new UnsupportedOperation(); + override type(): TargetType { + return TargetType.PAGE; } - override browser(): BidiBrowser { - return this.#browser; + return this.browserContext().browser(); } - override browserContext(): BidiBrowserContext { - return this.#browser.defaultBrowserContext(); - } - - override opener(): never { - throw new UnsupportedOperation(); + return this.#page.browserContext(); } - - override createCDPSession(): Promise<CDPSession> { + override opener(): Target | undefined { throw new UnsupportedOperation(); } } @@ -93,59 +91,80 @@ export class BiDiBrowserTarget extends Target { /** * @internal */ -export class BiDiBrowsingContextTarget extends BidiTarget { - protected _browsingContext: BrowsingContext; +export class BidiFrameTarget extends Target { + #frame: BidiFrame; + #page: BidiPage | undefined; - constructor( - browserContext: BidiBrowserContext, - browsingContext: BrowsingContext - ) { - super(browserContext); - - this._browsingContext = browsingContext; + constructor(frame: BidiFrame) { + super(); + this.#frame = frame; } + override async page(): Promise<BidiPage> { + if (this.#page === undefined) { + this.#page = BidiPage.from( + this.browserContext(), + this.#frame.browsingContext + ); + } + return this.#page; + } + override async asPage(): Promise<BidiPage> { + return BidiPage.from(this.browserContext(), this.#frame.browsingContext); + } override url(): string { - return this._browsingContext.url; + return this.#frame.url(); } - - override async createCDPSession(): Promise<CDPSession> { - const {sessionId} = await this._browsingContext.cdpSession.send( - 'Target.attachToTarget', - { - targetId: this._browsingContext.id, - flatten: true, - } - ); - return new CdpSessionWrapper(this._browsingContext, sessionId); + override createCDPSession(): Promise<CDPSession> { + return this.#frame.createCDPSession(); } - override type(): TargetType { return TargetType.PAGE; } + override browser(): BidiBrowser { + return this.browserContext().browser(); + } + override browserContext(): BidiBrowserContext { + return this.#frame.page().browserContext(); + } + override opener(): Target | undefined { + throw new UnsupportedOperation(); + } } /** * @internal */ -export class BiDiPageTarget extends BiDiBrowsingContextTarget { - #page: BidiPage; - - constructor( - browserContext: BidiBrowserContext, - browsingContext: BrowsingContext - ) { - super(browserContext, browsingContext); +export class BidiWorkerTarget extends Target { + #worker: BidiWebWorker; - this.#page = new BidiPage(browsingContext, browserContext, this); + constructor(worker: BidiWebWorker) { + super(); + this.#worker = worker; } override async page(): Promise<BidiPage> { - return this.#page; + throw new UnsupportedOperation(); } - - override _setBrowserContext(browserContext: BidiBrowserContext): void { - super._setBrowserContext(browserContext); - this.#page._setBrowserContext(browserContext); + override async asPage(): Promise<BidiPage> { + throw new UnsupportedOperation(); + } + override url(): string { + return this.#worker.url(); + } + override createCDPSession(): Promise<CDPSession> { + throw new UnsupportedOperation(); + } + override type(): TargetType { + return TargetType.OTHER; + } + override browser(): BidiBrowser { + return this.browserContext().browser(); + } + override browserContext(): BidiBrowserContext { + return this.#worker.frame.page().browserContext(); + } + override opener(): Target | undefined { + throw new UnsupportedOperation(); } } diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/WebWorker.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/WebWorker.ts new file mode 100644 index 0000000000..a8b0e28846 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/WebWorker.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import {WebWorker} from '../api/WebWorker.js'; +import {UnsupportedOperation} from '../common/Errors.js'; +import type {CDPSession} from '../puppeteer-core.js'; + +import type {DedicatedWorkerRealm, SharedWorkerRealm} from './core/Realm.js'; +import type {BidiFrame} from './Frame.js'; +import {BidiWorkerRealm} from './Realm.js'; + +/** + * @internal + */ +export class BidiWebWorker extends WebWorker { + static from( + frame: BidiFrame, + realm: DedicatedWorkerRealm | SharedWorkerRealm + ): BidiWebWorker { + const worker = new BidiWebWorker(frame, realm); + return worker; + } + + readonly #frame: BidiFrame; + readonly #realm: BidiWorkerRealm; + private constructor( + frame: BidiFrame, + realm: DedicatedWorkerRealm | SharedWorkerRealm + ) { + super(realm.origin); + this.#frame = frame; + this.#realm = BidiWorkerRealm.from(realm, this); + } + + get frame(): BidiFrame { + return this.#frame; + } + + mainRealm(): BidiWorkerRealm { + return this.#realm; + } + + get client(): CDPSession { + throw new UnsupportedOperation(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/bidi.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/bidi.ts index 373d6d999c..4279ba96fd 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/bidi.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/bidi.ts @@ -7,7 +7,6 @@ export * from './BidiOverCdp.js'; export * from './Browser.js'; export * from './BrowserContext.js'; -export * from './BrowsingContext.js'; export * from './Connection.js'; export * from './ElementHandle.js'; export * from './Frame.js'; @@ -15,8 +14,5 @@ export * from './HTTPRequest.js'; export * from './HTTPResponse.js'; export * from './Input.js'; export * from './JSHandle.js'; -export * from './NetworkManager.js'; export * from './Page.js'; export * from './Realm.js'; -export * from './Sandbox.js'; -export * from './Target.js'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Browser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Browser.ts index 7c4a8ed01c..efeabc3a59 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Browser.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Browser.ts @@ -11,7 +11,7 @@ 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 {SharedWorkerRealm} from './Realm.js'; import type {Session} from './Session.js'; import {UserContext} from './UserContext.js'; @@ -57,6 +57,7 @@ export class Browser extends EventEmitter<{ readonly #disposables = new DisposableStack(); readonly #userContexts = new Map<string, UserContext>(); readonly session: Session; + readonly #sharedWorkers = new Map<string, SharedWorkerRealm>(); // keep-sorted end private constructor(session: Session) { @@ -64,11 +65,6 @@ export class Browser extends EventEmitter<{ // keep-sorted start this.session = session; // keep-sorted end - - this.#userContexts.set( - UserContext.DEFAULT, - UserContext.create(this, UserContext.DEFAULT) - ); } async #initialize() { @@ -80,14 +76,29 @@ export class Browser extends EventEmitter<{ }); sessionEmitter.on('script.realmCreated', info => { - if (info.type === 'shared-worker') { - // TODO: Create a SharedWorkerRealm. + if (info.type !== 'shared-worker') { + return; } + this.#sharedWorkers.set( + info.realm, + SharedWorkerRealm.from(this, info.realm, info.origin) + ); }); + await this.#syncUserContexts(); await this.#syncBrowsingContexts(); } + async #syncUserContexts() { + const { + result: {userContexts}, + } = await this.session.send('browser.getUserContexts', {}); + + for (const context of userContexts) { + this.#createUserContext(context.userContext); + } + } + async #syncBrowsingContexts() { // In case contexts are created or destroyed during `getTree`, we use this // set to detect them. @@ -99,16 +110,13 @@ export class Browser extends EventEmitter<{ 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)) { + if (!contextIds.has(info.context)) { this.session.emit('browsingContext.contextCreated', info); } if (info.children) { @@ -117,6 +125,22 @@ export class Browser extends EventEmitter<{ } } + #createUserContext(id: string) { + const userContext = UserContext.create(this, id); + this.#userContexts.set(userContext.id, userContext); + + const userContextEmitter = this.#disposables.use( + new EventEmitter(userContext) + ); + userContextEmitter.once('closed', () => { + userContextEmitter.removeAllListeners(); + + this.#userContexts.delete(userContext.id); + }); + + return userContext; + } + // keep-sorted start block=yes get closed(): boolean { return this.#closed; @@ -185,30 +209,15 @@ export class Browser extends EventEmitter<{ }); } - 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; + const { + result: {userContext: context}, + } = await this.session.send('browser.createUserContext', {}); + return this.#createUserContext(context); } [disposeSymbol](): void { 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 index 9bec2a506c..07309576a3 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/BrowsingContext.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/BrowsingContext.ts @@ -12,6 +12,7 @@ import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; import type {AddPreloadScriptOptions} from './Browser.js'; import {Navigation} from './Navigation.js'; +import type {DedicatedWorkerRealm} from './Realm.js'; import {WindowRealm} from './Realm.js'; import {Request} from './Request.js'; import type {UserContext} from './UserContext.js'; @@ -60,6 +61,14 @@ export type SetViewportOptions = Omit< /** * @internal */ +export type GetCookiesOptions = Omit< + Bidi.Storage.GetCookiesParameters, + 'partition' +>; + +/** + * @internal + */ export class BrowsingContext extends EventEmitter<{ /** Emitted when this context is closed. */ closed: { @@ -95,6 +104,11 @@ export class BrowsingContext extends EventEmitter<{ DOMContentLoaded: void; /** Emitted whenever the frame emits `load` */ load: void; + /** Emitted whenever a dedicated worker is created */ + worker: { + /** The realm for the new dedicated worker */ + realm: DedicatedWorkerRealm; + }; }> { static from( userContext: UserContext, @@ -135,7 +149,7 @@ export class BrowsingContext extends EventEmitter<{ this.userContext = context; // keep-sorted end - this.defaultRealm = WindowRealm.from(this); + this.defaultRealm = this.#createWindowRealm(); } #initialize() { @@ -202,7 +216,16 @@ export class BrowsingContext extends EventEmitter<{ } this.#url = info.url; - this.#requests.clear(); + for (const [id, request] of this.#requests) { + if (request.disposed) { + this.#requests.delete(id); + } + } + // If the navigation hasn't finished, then this is nested navigation. The + // current navigation will handle this. + if (this.#navigation !== undefined && !this.#navigation.disposed) { + return; + } // Note the navigation ID is null for this event. this.#navigation = Navigation.from(this); @@ -224,7 +247,8 @@ export class BrowsingContext extends EventEmitter<{ if (event.context !== this.id) { return; } - if (this.#requests.has(event.request.request)) { + if (event.redirectCount !== 0) { + // Means the request is a redirect. This is handled in Request. return; } @@ -265,7 +289,12 @@ export class BrowsingContext extends EventEmitter<{ return this.closed; } get realms(): Iterable<WindowRealm> { - return this.#realms.values(); + // eslint-disable-next-line @typescript-eslint/no-this-alias -- Required + const self = this; + return (function* () { + yield self.defaultRealm; + yield* self.#realms.values(); + })(); } get top(): BrowsingContext { let context = this as BrowsingContext; @@ -279,6 +308,14 @@ export class BrowsingContext extends EventEmitter<{ } // keep-sorted end + #createWindowRealm(sandbox?: string) { + const realm = WindowRealm.from(this, sandbox); + realm.on('worker', realm => { + this.emit('worker', {realm}); + }); + return realm; + } + @inertIfDisposed private dispose(reason?: string): void { this.#reason = reason; @@ -345,33 +382,23 @@ export class BrowsingContext extends EventEmitter<{ async navigate( url: string, wait?: Bidi.BrowsingContext.ReadinessState - ): Promise<Navigation> { + ): Promise<void> { 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> { + async reload(options: ReloadOptions = {}): Promise<void> { await this.#session.send('browsingContext.reload', { context: this.id, ...options, }); - return await new Promise(resolve => { - this.once('navigation', ({navigation}) => { - resolve(navigation); - }); - }); } @throwIfDisposed<BrowsingContext>(context => { @@ -436,7 +463,7 @@ export class BrowsingContext extends EventEmitter<{ return context.#reason!; }) createWindowRealm(sandbox: string): WindowRealm { - return WindowRealm.from(this, sandbox); + return this.#createWindowRealm(sandbox); } @throwIfDisposed<BrowsingContext>(context => { @@ -464,6 +491,54 @@ export class BrowsingContext extends EventEmitter<{ await this.userContext.browser.removePreloadScript(script); } + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async getCookies( + options: GetCookiesOptions = {} + ): Promise<Bidi.Network.Cookie[]> { + const { + result: {cookies}, + } = await this.#session.send('storage.getCookies', { + ...options, + partition: { + type: 'context', + context: this.id, + }, + }); + return cookies; + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async setCookie(cookie: Bidi.Storage.PartialCookie): Promise<void> { + await this.#session.send('storage.setCookie', { + cookie, + partition: { + type: 'context', + context: this.id, + }, + }); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async setFiles( + element: Bidi.Script.SharedReference, + files: string[] + ): Promise<void> { + await this.#session.send('input.setFiles', { + context: this.id, + element, + files, + }); + } + [disposeSymbol](): void { this.#reason ??= 'Browsing context already closed, probably because the user context closed.'; @@ -472,4 +547,24 @@ export class BrowsingContext extends EventEmitter<{ this.#disposables.dispose(); super[disposeSymbol](); } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async deleteCookie( + ...cookieFilters: Bidi.Storage.CookieFilter[] + ): Promise<void> { + await Promise.all( + cookieFilters.map(async filter => { + await this.#session.send('storage.deleteCookies', { + filter: filter, + partition: { + type: 'context', + context: this.id, + }, + }); + }) + ); + } } 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 index b9de14372b..9c26a03503 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Connection.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Connection.ts @@ -38,6 +38,21 @@ export interface Commands { returnType: Bidi.EmptyResult; }; + 'browser.createUserContext': { + params: Bidi.EmptyParams; + returnType: Bidi.Browser.CreateUserContextResult; + }; + 'browser.getUserContexts': { + params: Bidi.EmptyParams; + returnType: Bidi.Browser.GetUserContextsResult; + }; + 'browser.removeUserContext': { + params: { + userContext: Bidi.Browser.UserContext; + }; + returnType: Bidi.Browser.RemoveUserContext; + }; + 'browsingContext.activate': { params: Bidi.BrowsingContext.ActivateParameters; returnType: Bidi.EmptyResult; @@ -91,6 +106,15 @@ export interface Commands { params: Bidi.Input.ReleaseActionsParameters; returnType: Bidi.EmptyResult; }; + 'input.setFiles': { + params: Bidi.Input.SetFilesParameters; + returnType: Bidi.EmptyResult; + }; + + 'permissions.setPermission': { + params: Bidi.Permissions.SetPermissionParameters; + returnType: Bidi.EmptyResult; + }; 'session.end': { params: Bidi.EmptyParams; @@ -112,6 +136,19 @@ export interface Commands { params: Bidi.Session.SubscriptionRequest; returnType: Bidi.EmptyResult; }; + + 'storage.deleteCookies': { + params: Bidi.Storage.DeleteCookiesParameters; + returnType: Bidi.Storage.DeleteCookiesResult; + }; + 'storage.getCookies': { + params: Bidi.Storage.GetCookiesParameters; + returnType: Bidi.Storage.GetCookiesResult; + }; + 'storage.setCookie': { + params: Bidi.Storage.SetCookieParameters; + returnType: Bidi.Storage.SetCookieParameters; + }; } /** @@ -133,7 +170,4 @@ export interface Connection<Events extends BidiEvents = BidiEvents> 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 index a7efbfeb2c..50040164a5 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Navigation.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Navigation.ts @@ -41,9 +41,10 @@ export class Navigation extends EventEmitter<{ // keep-sorted start #request: Request | undefined; + #navigation: Navigation | undefined; readonly #browsingContext: BrowsingContext; readonly #disposables = new DisposableStack(); - readonly #id = new Deferred<string>(); + readonly #id = new Deferred<string | null>(); // keep-sorted end private constructor(context: BrowsingContext) { @@ -65,31 +66,48 @@ export class Navigation extends EventEmitter<{ this.dispose(); }); - this.#browsingContext.on('request', ({request}) => { - if (request.navigation === this.#id.value()) { - this.#request = request; - this.emit('request', request); + browsingContextEmitter.on('request', ({request}) => { + if ( + request.navigation === undefined || + this.#request !== undefined || + // If a request with a navigation ID comes in, then the navigation ID is + // for this navigation. + !this.#matches(request.navigation) + ) { + return; } + + this.#request = request; + this.emit('request', request); }); const sessionEmitter = this.#disposables.use( new EventEmitter(this.#session) ); - // To get the navigation ID if any. + sessionEmitter.on('browsingContext.navigationStarted', info => { + if ( + info.context !== this.#browsingContext.id || + this.#navigation !== undefined + ) { + return; + } + this.#navigation = Navigation.from(this.#browsingContext); + }); + for (const eventName of [ 'browsingContext.domContentLoaded', 'browsingContext.load', ] as const) { sessionEmitter.on(eventName, info => { - if (info.context !== this.#browsingContext.id) { - return; - } - if (!info.navigation) { + if ( + info.context !== this.#browsingContext.id || + info.navigation === null || + !this.#matches(info.navigation) + ) { return; } - if (!this.#id.resolved()) { - this.#id.resolve(info.navigation); - } + + this.dispose(); }); } @@ -99,18 +117,15 @@ export class Navigation extends EventEmitter<{ ['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) { + if ( + info.context !== this.#browsingContext.id || + // Note we don't check if `navigation` is null since `null` means the + // fragment navigated. + !this.#matches(info.navigation) + ) { return; } + this.emit(event, { url: info.url, timestamp: new Date(info.timestamp), @@ -120,6 +135,17 @@ export class Navigation extends EventEmitter<{ } } + #matches(navigation: string | null): boolean { + if (this.#navigation !== undefined && !this.#navigation.disposed) { + return false; + } + if (!this.#id.resolved()) { + this.#id.resolve(navigation); + return true; + } + return this.#id.value() === navigation; + } + // keep-sorted start block=yes get #session() { return this.#browsingContext.userContext.browser.session; @@ -130,6 +156,9 @@ export class Navigation extends EventEmitter<{ get request(): Request | undefined { return this.#request; } + get navigation(): Navigation | undefined { + return this.#navigation; + } // keep-sorted end @inertIfDisposed 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 index d9bbbede50..392194cec8 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Realm.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Realm.ts @@ -9,7 +9,9 @@ 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 {BidiConnection} from '../Connection.js'; +import type {Browser} from './Browser.js'; import type {BrowsingContext} from './BrowsingContext.js'; import type {Session} from './Session.js'; @@ -33,6 +35,8 @@ export type EvaluateOptions = Omit< * @internal */ export abstract class Realm extends EventEmitter<{ + /** Emitted whenever the realm has updated. */ + updated: Realm; /** Emitted when the realm is destroyed. */ destroyed: {reason: string}; /** Emitted when a dedicated worker is created in the realm. */ @@ -55,22 +59,12 @@ export abstract class Realm extends EventEmitter<{ // 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 { + get target(): Bidi.Script.Target { return {realm: this.id}; } // keep-sorted end @@ -128,6 +122,18 @@ export abstract class Realm extends EventEmitter<{ return result; } + @throwIfDisposed<Realm>(realm => { + // SAFETY: Disposal implies this exists. + return realm.#reason!; + }) + async resolveExecutionContextId(): Promise<number> { + const {result} = await (this.session.connection as BidiConnection).send( + 'cdp.resolveRealm', + {realm: this.id} + ); + return result.executionContextId; + } + [disposeSymbol](): void { this.#reason ??= 'Realm already destroyed, probably because all associated browsing contexts closed.'; @@ -144,7 +150,7 @@ export abstract class Realm extends EventEmitter<{ export class WindowRealm extends Realm { static from(context: BrowsingContext, sandbox?: string): WindowRealm { const realm = new WindowRealm(context, sandbox); - realm.initialize(); + realm.#initialize(); return realm; } @@ -153,13 +159,7 @@ export class WindowRealm extends Realm { readonly sandbox?: string; // keep-sorted end - readonly #workers: { - dedicated: Map<string, DedicatedWorkerRealm>; - shared: Map<string, SharedWorkerRealm>; - } = { - dedicated: new Map(), - shared: new Map(), - }; + readonly #workers = new Map<string, DedicatedWorkerRealm>(); private constructor(context: BrowsingContext, sandbox?: string) { super('', ''); @@ -169,16 +169,26 @@ export class WindowRealm extends Realm { // keep-sorted end } - override initialize(): void { - super.initialize(); + #initialize(): void { + const browsingContextEmitter = this.disposables.use( + new EventEmitter(this.browsingContext) + ); + browsingContextEmitter.on('closed', ({reason}) => { + this.dispose(reason); + }); const sessionEmitter = this.disposables.use(new EventEmitter(this.session)); sessionEmitter.on('script.realmCreated', info => { - if (info.type !== 'window') { + if ( + info.type !== 'window' || + info.context !== this.browsingContext.id || + info.sandbox !== this.sandbox + ) { return; } (this as any).id = info.realm; (this as any).origin = info.origin; + this.emit('updated', this); }); sessionEmitter.on('script.realmCreated', info => { if (info.type !== 'dedicated-worker') { @@ -189,32 +199,16 @@ export class WindowRealm extends Realm { } const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin); - this.#workers.dedicated.set(realm.id, realm); + this.#workers.set(realm.id, realm); const realmEmitter = this.disposables.use(new EventEmitter(realm)); realmEmitter.once('destroyed', () => { realmEmitter.removeAllListeners(); - this.#workers.dedicated.delete(realm.id); + this.#workers.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 { @@ -244,7 +238,7 @@ export class DedicatedWorkerRealm extends Realm { origin: string ): DedicatedWorkerRealm { const realm = new DedicatedWorkerRealm(owner, id, origin); - realm.initialize(); + realm.#initialize(); return realm; } @@ -262,10 +256,14 @@ export class DedicatedWorkerRealm extends Realm { this.owners = new Set([owner]); } - override initialize(): void { - super.initialize(); - + #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.'); + }); sessionEmitter.on('script.realmCreated', info => { if (info.type !== 'dedicated-worker') { return; @@ -296,34 +294,30 @@ export class DedicatedWorkerRealm extends Realm { * @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(); + static from(browser: Browser, id: string, origin: string): SharedWorkerRealm { + const realm = new SharedWorkerRealm(browser, id, origin); + realm.#initialize(); return realm; } // keep-sorted start readonly #workers = new Map<string, DedicatedWorkerRealm>(); - readonly owners: Set<WindowRealm>; + readonly browser: Browser; // keep-sorted end - private constructor( - owners: [WindowRealm, ...WindowRealm[]], - id: string, - origin: string - ) { + private constructor(browser: Browser, id: string, origin: string) { super(id, origin); - this.owners = new Set(owners); + this.browser = browser; } - override initialize(): void { - super.initialize(); - + #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.'); + }); sessionEmitter.on('script.realmCreated', info => { if (info.type !== 'dedicated-worker') { return; @@ -345,7 +339,6 @@ export class SharedWorkerRealm extends Realm { } override get session(): Session { - // SAFETY: At least one owner will exist. - return this.owners.values().next().value.session; + return this.browser.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 index 2a445f7d87..fd616b668d 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Request.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Request.ts @@ -66,10 +66,11 @@ export class Request extends EventEmitter<{ new EventEmitter(this.#session) ); sessionEmitter.on('network.beforeRequestSent', event => { - if (event.context !== this.#browsingContext.id) { - return; - } - if (event.request.request !== this.id) { + if ( + event.context !== this.#browsingContext.id || + event.request.request !== this.id || + event.redirectCount !== this.#event.redirectCount + 1 + ) { return; } this.#redirect = Request.from(this.#browsingContext, event); @@ -77,10 +78,11 @@ export class Request extends EventEmitter<{ this.dispose(); }); sessionEmitter.on('network.fetchError', event => { - if (event.context !== this.#browsingContext.id) { - return; - } - if (event.request.request !== this.id) { + if ( + event.context !== this.#browsingContext.id || + event.request.request !== this.id || + this.#event.redirectCount !== event.redirectCount + ) { return; } this.#error = event.errorText; @@ -88,14 +90,19 @@ export class Request extends EventEmitter<{ this.dispose(); }); sessionEmitter.on('network.responseCompleted', event => { - if (event.context !== this.#browsingContext.id) { - return; - } - if (event.request.request !== this.id) { + if ( + event.context !== this.#browsingContext.id || + event.request.request !== this.id || + this.#event.redirectCount !== event.redirectCount + ) { return; } this.#response = event.response; this.emit('success', this.#response); + // In case this is a redirect. + if (this.#response.status >= 300 && this.#response.status < 400) { + return; + } this.dispose(); }); } @@ -126,7 +133,7 @@ export class Request extends EventEmitter<{ return this.#event.navigation ?? undefined; } get redirect(): Request | undefined { - return this.redirect; + return this.#redirect; } get response(): Bidi.Network.ResponseData | undefined { return this.#response; 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 index b6e28061f1..ffd39769e7 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Session.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Session.ts @@ -8,7 +8,11 @@ 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 { + bubble, + inertIfDisposed, + throwIfDisposed, +} from '../../util/decorators.js'; import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; import {Browser} from './Browser.js'; @@ -81,7 +85,8 @@ export class Session readonly #disposables = new DisposableStack(); readonly #info: Bidi.Session.NewResult; readonly browser!: Browser; - readonly connection: Connection; + @bubble() + accessor connection: Connection; // keep-sorted end private constructor(connection: Connection, info: Bidi.Session.NewResult) { @@ -93,8 +98,6 @@ export class Session } 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); @@ -102,6 +105,19 @@ export class Session browserEmitter.once('closed', ({reason}) => { this.dispose(reason); }); + + // TODO: Currently, some implementations do not emit navigationStarted event + // for fragment navigations (as per spec) and some do. This could emits a + // synthetic navigationStarted to work around this inconsistency. + const seen = new WeakSet(); + this.on('browsingContext.fragmentNavigated', info => { + if (seen.has(info)) { + return; + } + seen.add(info); + this.emit('browsingContext.navigationStarted', info); + this.emit('browsingContext.fragmentNavigated', info); + }); } // keep-sorted start block=yes @@ -125,10 +141,6 @@ export class Session 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 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 index 01ee5c7649..72859c6a53 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserContext.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserContext.ts @@ -12,6 +12,7 @@ import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js'; import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; import type {Browser} from './Browser.js'; +import type {GetCookiesOptions} from './BrowsingContext.js'; import {BrowsingContext} from './BrowsingContext.js'; /** @@ -43,7 +44,7 @@ export class UserContext extends EventEmitter<{ reason: string; }; }> { - static DEFAULT = 'default'; + static DEFAULT = 'default' as const; static create(browser: Browser, id: string): UserContext { const context = new UserContext(browser, id); @@ -84,6 +85,10 @@ export class UserContext extends EventEmitter<{ return; } + if (info.userContext !== this.#id) { + return; + } + const browsingContext = BrowsingContext.from( this, undefined, @@ -143,6 +148,7 @@ export class UserContext extends EventEmitter<{ type, ...options, referenceContext: options.referenceContext?.id, + userContext: this.#id, }); const browsingContext = this.#browsingContexts.get(contextId); @@ -161,12 +167,71 @@ export class UserContext extends EventEmitter<{ }) async remove(): Promise<void> { try { - // TODO: Call `removeUserContext` once available. + await this.#session.send('browser.removeUserContext', { + userContext: this.#id, + }); } finally { this.dispose('User context already closed.'); } } + @throwIfDisposed<UserContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async getCookies( + options: GetCookiesOptions = {}, + sourceOrigin: string | undefined = undefined + ): Promise<Bidi.Network.Cookie[]> { + const { + result: {cookies}, + } = await this.#session.send('storage.getCookies', { + ...options, + partition: { + type: 'storageKey', + userContext: this.#id, + sourceOrigin, + }, + }); + return cookies; + } + + @throwIfDisposed<UserContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async setCookie( + cookie: Bidi.Storage.PartialCookie, + sourceOrigin?: string + ): Promise<void> { + await this.#session.send('storage.setCookie', { + cookie, + partition: { + type: 'storageKey', + sourceOrigin, + userContext: this.id, + }, + }); + } + + @throwIfDisposed<UserContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async setPermissions( + origin: string, + descriptor: Bidi.Permissions.PermissionDescriptor, + state: Bidi.Permissions.PermissionState + ): Promise<void> { + await this.#session.send('permissions.setPermission', { + origin, + descriptor, + state, + // @ts-expect-error not standard implementation. + 'goog:userContext': this.#id, + }); + } + [disposeSymbol](): void { this.#reason ??= 'User context already closed, probably because the browser disconnected/closed.'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/lifecycle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/lifecycle.ts deleted file mode 100644 index 73b86cba9c..0000000000 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/lifecycle.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * @license - * Copyright 2023 Google Inc. - * SPDX-License-Identifier: Apache-2.0 - */ -import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; - -import type { - ObservableInput, - ObservedValueOf, - OperatorFunction, -} from '../../third_party/rxjs/rxjs.js'; -import {catchError} from '../../third_party/rxjs/rxjs.js'; -import type {PuppeteerLifeCycleEvent} from '../cdp/LifecycleWatcher.js'; -import {ProtocolError, TimeoutError} from '../common/Errors.js'; - -/** - * @internal - */ -export type BiDiNetworkIdle = Extract< - PuppeteerLifeCycleEvent, - 'networkidle0' | 'networkidle2' -> | null; - -/** - * @internal - */ -export function getBiDiLifeCycles( - event: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[] -): [ - Extract<PuppeteerLifeCycleEvent, 'load' | 'domcontentloaded'>, - BiDiNetworkIdle, -] { - if (Array.isArray(event)) { - const pageLifeCycle = event.some(lifeCycle => { - return lifeCycle !== 'domcontentloaded'; - }) - ? 'load' - : 'domcontentloaded'; - - const networkLifeCycle = event.reduce((acc, lifeCycle) => { - if (lifeCycle === 'networkidle0') { - return lifeCycle; - } else if (acc !== 'networkidle0' && lifeCycle === 'networkidle2') { - return lifeCycle; - } - return acc; - }, null as BiDiNetworkIdle); - - return [pageLifeCycle, networkLifeCycle]; - } - - if (event === 'networkidle0' || event === 'networkidle2') { - return ['load', event]; - } - - return [event, null]; -} - -/** - * @internal - */ -export const lifeCycleToReadinessState = new Map< - PuppeteerLifeCycleEvent, - Bidi.BrowsingContext.ReadinessState ->([ - ['load', Bidi.BrowsingContext.ReadinessState.Complete], - ['domcontentloaded', Bidi.BrowsingContext.ReadinessState.Interactive], -]); - -export function getBiDiReadinessState( - event: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[] -): [Bidi.BrowsingContext.ReadinessState, BiDiNetworkIdle] { - const lifeCycles = getBiDiLifeCycles(event); - const readiness = lifeCycleToReadinessState.get(lifeCycles[0])!; - return [readiness, lifeCycles[1]]; -} - -/** - * @internal - */ -export const lifeCycleToSubscribedEvent = new Map< - PuppeteerLifeCycleEvent, - 'browsingContext.load' | 'browsingContext.domContentLoaded' ->([ - ['load', 'browsingContext.load'], - ['domcontentloaded', 'browsingContext.domContentLoaded'], -]); - -/** - * @internal - */ -export function getBiDiLifecycleEvent( - event: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[] -): [ - 'browsingContext.load' | 'browsingContext.domContentLoaded', - BiDiNetworkIdle, -] { - const lifeCycles = getBiDiLifeCycles(event); - const bidiEvent = lifeCycleToSubscribedEvent.get(lifeCycles[0])!; - return [bidiEvent, lifeCycles[1]]; -} - -/** - * @internal - */ -export function rewriteNavigationError<T, R extends ObservableInput<T>>( - message: string, - ms: number -): OperatorFunction<T, T | ObservedValueOf<R>> { - return catchError<T, R>(error => { - if (error instanceof ProtocolError) { - error.message += ` at ${message}`; - } else if (error instanceof TimeoutError) { - error.message = `Navigation timeout of ${ms} ms exceeded`; - } - throw error; - }); -} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/util.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/util.ts index 41e88e26c2..e1d64c2f4c 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/util.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/util.ts @@ -6,32 +6,10 @@ import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; -import {PuppeteerURL, debugError} from '../common/util.js'; +import {ProtocolError, TimeoutError} from '../common/Errors.js'; +import {PuppeteerURL} from '../common/util.js'; import {BidiDeserializer} from './Deserializer.js'; -import type {BidiRealm} from './Realm.js'; - -/** - * @internal - */ -export async function releaseReference( - client: BidiRealm, - remoteReference: Bidi.Script.RemoteReference -): Promise<void> { - if (!remoteReference.handle) { - return; - } - await client.connection - .send('script.disown', { - target: client.target, - handles: [remoteReference.handle], - }) - .catch(error => { - // Exceptions might happen in case of a page been navigated or closed. - // Swallow these since they are harmless and we don't leak anything in this case. - debugError(error); - }); -} /** * @internal @@ -79,3 +57,20 @@ export function createEvaluationError( error.stack = [details.text, ...stackLines].join('\n'); return error; } + +/** + * @internal + */ +export function rewriteNavigationError( + message: string, + ms: number +): (error: unknown) => never { + return error => { + if (error instanceof ProtocolError) { + error.message += ` at ${message}`; + } else if (error instanceof TimeoutError) { + error.message = `Navigation timeout of ${ms} ms exceeded`; + } + throw error; + }; +} |