diff options
Diffstat (limited to 'remote/test/puppeteer/packages/puppeteer-core/src/bidi/Page.ts')
-rw-r--r-- | remote/test/puppeteer/packages/puppeteer-core/src/bidi/Page.ts | 846 |
1 files changed, 361 insertions, 485 deletions
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; } |