diff options
Diffstat (limited to 'remote/test/puppeteer/packages/puppeteer-core')
88 files changed, 3471 insertions, 3279 deletions
diff --git a/remote/test/puppeteer/packages/puppeteer-core/.gitignore b/remote/test/puppeteer/packages/puppeteer-core/.gitignore index 42061c01a1..3d32a7ba82 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/.gitignore +++ b/remote/test/puppeteer/packages/puppeteer-core/.gitignore @@ -1 +1 @@ -README.md
\ No newline at end of file +/README.md
\ No newline at end of file diff --git a/remote/test/puppeteer/packages/puppeteer-core/CHANGELOG.md b/remote/test/puppeteer/packages/puppeteer-core/CHANGELOG.md index 341d706fb4..5076077c9f 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/CHANGELOG.md +++ b/remote/test/puppeteer/packages/puppeteer-core/CHANGELOG.md @@ -20,6 +20,146 @@ All notable changes to this project will be documented in this file. See [standa * dependencies * @puppeteer/browsers bumped from 1.5.1 to 1.6.0 +## [22.4.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v22.3.0...puppeteer-core-v22.4.0) (2024-03-05) + + +### Features + +* implement ElementHandle.uploadFile for WebDriver BiDi ([#11963](https://github.com/puppeteer/puppeteer/issues/11963)) ([accf2b6](https://github.com/puppeteer/puppeteer/commit/accf2b6ca84c93bc700277b4e3382d894fb45a76)) +* **webdriver:** support `Page.deleteCookie()` for WebDriver BiDi ([#12031](https://github.com/puppeteer/puppeteer/issues/12031)) ([7fe22b5](https://github.com/puppeteer/puppeteer/commit/7fe22b533dc96104f28696eb4ff96b2543fd8e5b)) + + +### Bug Fixes + +* roll to Chrome 122.0.6261.94 (r1250580) ([#12012](https://github.com/puppeteer/puppeteer/issues/12012)) ([7ba5529](https://github.com/puppeteer/puppeteer/commit/7ba5529f8d6f8ed085968b7a9bc6f25f8d91abd5)) +* **webdriver:** wait for response if the response has not completed once navigation has finished ([#12018](https://github.com/puppeteer/puppeteer/issues/12018)) ([6d8831a](https://github.com/puppeteer/puppeteer/commit/6d8831a9c398230f2543c3862d3fe5fc7cd2b940)) + +## [22.3.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v22.2.0...puppeteer-core-v22.3.0) (2024-02-25) + + +### Features + +* implement permissions for WebDriver BiDi ([#11979](https://github.com/puppeteer/puppeteer/issues/11979)) ([3a467c3](https://github.com/puppeteer/puppeteer/commit/3a467c39cb60de4237081ee201bd86051887c2f2)) + + +### Bug Fixes + +* roll to Chrome 122.0.6261.69 (r1250580) ([#11991](https://github.com/puppeteer/puppeteer/issues/11991)) ([eb2c334](https://github.com/puppeteer/puppeteer/commit/eb2c33485ec473e085c6b76b45554758764349d6)) +* supress viewport errors for pages that do not support changing it ([#11970](https://github.com/puppeteer/puppeteer/issues/11970)) ([753a954](https://github.com/puppeteer/puppeteer/commit/753a954456699fc06adf67837225f306711af856)) + +## [22.2.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v22.1.0...puppeteer-core-v22.2.0) (2024-02-21) + + +### Features + +* roll to Chrome 122.0.6261.57 (r1250580) ([#11958](https://github.com/puppeteer/puppeteer/issues/11958)) ([70ad3b2](https://github.com/puppeteer/puppeteer/commit/70ad3b244826ca102737e93cd2316e451ea310e8)) + + +### Bug Fixes + +* deprecate isIncognito ([#11962](https://github.com/puppeteer/puppeteer/issues/11962)) ([ceab7a9](https://github.com/puppeteer/puppeteer/commit/ceab7a9042fe5fc3f71875e75327bb370f1c43a5)) +* roll to Chrome 121.0.6167.184 (r1233107) ([#11948](https://github.com/puppeteer/puppeteer/issues/11948)) ([03ef7a6](https://github.com/puppeteer/puppeteer/commit/03ef7a62c23f2339e4d508d9abfe0894bd790cdd)) +* update touchscreen tests ([#11960](https://github.com/puppeteer/puppeteer/issues/11960)) ([013bd0b](https://github.com/puppeteer/puppeteer/commit/013bd0b12d3a69f9d62fffe7911a327ad26d33d8)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 2.0.1 to 2.1.0 + +## [22.1.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v22.0.0...puppeteer-core-v22.1.0) (2024-02-17) + + +### Features + +* support closing workers ([#11870](https://github.com/puppeteer/puppeteer/issues/11870)) ([1bdae40](https://github.com/puppeteer/puppeteer/commit/1bdae40ec865326fcb365320939869a6efb18c8a)) + + +### Bug Fixes + +* Chrome for Testing downloads have a new URL ([#11923](https://github.com/puppeteer/puppeteer/issues/11923)) ([f00a94a](https://github.com/puppeteer/puppeteer/commit/f00a94a809d38ee1c2c8cfc8597c66db9f3d243d)) +* deprecate `Page.prototype.target` ([#11872](https://github.com/puppeteer/puppeteer/issues/11872)) ([15c986c](https://github.com/puppeteer/puppeteer/commit/15c986c2bc5f5005a738187674cd6c44bcb3df3d)) +* frameElement should work for framesets ([#11842](https://github.com/puppeteer/puppeteer/issues/11842)) ([c5cee0e](https://github.com/puppeteer/puppeteer/commit/c5cee0e37dec8b90a17bf13400ede7ebdf453ac8)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 2.0.0 to 2.0.1 + +## [22.0.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.11.0...puppeteer-core-v22.0.0) (2024-02-05) + + +### ⚠ BREAKING CHANGES + +* rename createIncognitoBrowserContext to createBrowserContext ([#11834](https://github.com/puppeteer/puppeteer/issues/11834)) +* enable the new-headless mode by default ([#11815](https://github.com/puppeteer/puppeteer/issues/11815)) +* remove networkConditions in favor of PredefinedNetworkConditions ([#11806](https://github.com/puppeteer/puppeteer/issues/11806)) +* use ReadableStreams ([#11805](https://github.com/puppeteer/puppeteer/issues/11805)) +* remove duplicate type names ([#11803](https://github.com/puppeteer/puppeteer/issues/11803)) +* remove add/removeEventListener in favor of on/off ([#11792](https://github.com/puppeteer/puppeteer/issues/11792)) +* make console warn level compatible with WebDriver BiDi ([#11790](https://github.com/puppeteer/puppeteer/issues/11790)) +* remove InterceptResolutionStrategy ([#11788](https://github.com/puppeteer/puppeteer/issues/11788)) +* remove devices in favor of KnownDevices ([#11787](https://github.com/puppeteer/puppeteer/issues/11787)) +* remove `$x` and `waitForXpath` ([#11782](https://github.com/puppeteer/puppeteer/issues/11782)) +* remove waitForTimeout ([#11780](https://github.com/puppeteer/puppeteer/issues/11780)) +* generate accessible PDFs by default ([#11778](https://github.com/puppeteer/puppeteer/issues/11778)) +* remove `error` const, change CustomError to PuppeteerError ([#11777](https://github.com/puppeteer/puppeteer/issues/11777)) +* remove viewport resizing from ElementHandle.screenshot ([#11774](https://github.com/puppeteer/puppeteer/issues/11774)) +* remove PUPPETEER_DOWNLOAD_PATH in favor of PUPPETEER_CACHE_DIR ([#11605](https://github.com/puppeteer/puppeteer/issues/11605)) +* BiDi cookies ([#11532](https://github.com/puppeteer/puppeteer/issues/11532)) +* drop support for node16 ([#10912](https://github.com/puppeteer/puppeteer/issues/10912)) + +### Features + +* BiDi cookies ([#11532](https://github.com/puppeteer/puppeteer/issues/11532)) ([9cb1fde](https://github.com/puppeteer/puppeteer/commit/9cb1fde58949811532644decb79b691318031d8c)) +* drop support for node16 ([#10912](https://github.com/puppeteer/puppeteer/issues/10912)) ([953f420](https://github.com/puppeteer/puppeteer/commit/953f4207b17210fa7231225e6f29a826f77e0832)) +* generate accessible PDFs by default ([#11778](https://github.com/puppeteer/puppeteer/issues/11778)) ([4fc1402](https://github.com/puppeteer/puppeteer/commit/4fc14026e9bfffeedf317e9b61c7cda8509091ba)) +* remove PUPPETEER_DOWNLOAD_PATH in favor of PUPPETEER_CACHE_DIR ([#11605](https://github.com/puppeteer/puppeteer/issues/11605)) ([4677281](https://github.com/puppeteer/puppeteer/commit/467728187737283191f6528676e50d53dae6e5ef)) + + +### Bug Fixes + +* make console warn level compatible with WebDriver BiDi ([#11790](https://github.com/puppeteer/puppeteer/issues/11790)) ([d4e9d8d](https://github.com/puppeteer/puppeteer/commit/d4e9d8d591e4fb1e2a33fe3a586a8beaccf263e8)) +* remove viewport resizing from ElementHandle.screenshot ([#11774](https://github.com/puppeteer/puppeteer/issues/11774)) ([ced2235](https://github.com/puppeteer/puppeteer/commit/ced2235ada95ad67227df0ce579070ccb501a47b)) + + +### Code Refactoring + +* enable the new-headless mode by default ([#11815](https://github.com/puppeteer/puppeteer/issues/11815)) ([75c9e11](https://github.com/puppeteer/puppeteer/commit/75c9e117f1bf0d7a4de82c79201d70bf3cee2b6f)) +* remove `$x` and `waitForXpath` ([#11782](https://github.com/puppeteer/puppeteer/issues/11782)) ([53c9134](https://github.com/puppeteer/puppeteer/commit/53c91348094dc0bce59086c98986c5d06a949d08)) +* remove `error` const, change CustomError to PuppeteerError ([#11777](https://github.com/puppeteer/puppeteer/issues/11777)) ([b3bfdd2](https://github.com/puppeteer/puppeteer/commit/b3bfdd2024097be1974e28b3766419189b4a9fe0)) +* remove add/removeEventListener in favor of on/off ([#11792](https://github.com/puppeteer/puppeteer/issues/11792)) ([f160874](https://github.com/puppeteer/puppeteer/commit/f1608743c83e8ce7b56aec98ccdddacc91b86179)) +* remove devices in favor of KnownDevices ([#11787](https://github.com/puppeteer/puppeteer/issues/11787)) ([eb360e3](https://github.com/puppeteer/puppeteer/commit/eb360e3a762d9232a4972d4ec877b7d57a5b60c7)) +* remove duplicate type names ([#11803](https://github.com/puppeteer/puppeteer/issues/11803)) ([514e2d5](https://github.com/puppeteer/puppeteer/commit/514e2d5241dc3a9027c96d739cfc99efc5a02783)) +* remove InterceptResolutionStrategy ([#11788](https://github.com/puppeteer/puppeteer/issues/11788)) ([f18d447](https://github.com/puppeteer/puppeteer/commit/f18d44761cd1acc2e6b867e5eb2ebd753854e9ea)) +* remove networkConditions in favor of PredefinedNetworkConditions ([#11806](https://github.com/puppeteer/puppeteer/issues/11806)) ([7564dfa](https://github.com/puppeteer/puppeteer/commit/7564dfa9110e44b1f50f5fb1543c5c7d8529c182)) +* remove waitForTimeout ([#11780](https://github.com/puppeteer/puppeteer/issues/11780)) ([1900fa9](https://github.com/puppeteer/puppeteer/commit/1900fa94183e0a8654633a91f82b372ad068da71)) +* rename createIncognitoBrowserContext to createBrowserContext ([#11834](https://github.com/puppeteer/puppeteer/issues/11834)) ([46a3ef2](https://github.com/puppeteer/puppeteer/commit/46a3ef2681456d604e775f578fa447a094200610)) +* use ReadableStreams ([#11805](https://github.com/puppeteer/puppeteer/issues/11805)) ([84d9a94](https://github.com/puppeteer/puppeteer/commit/84d9a94d6228800e9f80914472ff2e5a4ee71b18)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.9.1 to 2.0.0 + +## [21.11.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.10.0...puppeteer-core-v21.11.0) (2024-02-02) + + +### Features + +* add outline to PDF generation ([#11779](https://github.com/puppeteer/puppeteer/issues/11779)) ([b99d478](https://github.com/puppeteer/puppeteer/commit/b99d478cd48adc261878836e04eac55ecc2890f2)) +* **bidi:** implement UserContexts ([#11784](https://github.com/puppeteer/puppeteer/issues/11784)) ([2930a70](https://github.com/puppeteer/puppeteer/commit/2930a70c884ce6835ec6bcff27b32f7d273c8af0)) + + +### Bug Fixes + +* use shareReplay for inflight requests ([#11810](https://github.com/puppeteer/puppeteer/issues/11810)) ([0f0813d](https://github.com/puppeteer/puppeteer/commit/0f0813db38aa0eb14d7514d725852d0cb66f4f0e)) + ## [21.10.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.9.0...puppeteer-core-v21.10.0) (2024-01-29) diff --git a/remote/test/puppeteer/packages/puppeteer-core/Herebyfile.mjs b/remote/test/puppeteer/packages/puppeteer-core/Herebyfile.mjs index 723fa2868a..972a080ba0 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/Herebyfile.mjs +++ b/remote/test/puppeteer/packages/puppeteer-core/Herebyfile.mjs @@ -1,10 +1,19 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ import {mkdir, readFile, readdir, writeFile} from 'fs/promises'; -import {join} from 'path/posix'; +import Module from 'node:module'; +import path from 'path'; +import posixPath from 'path/posix'; import esbuild from 'esbuild'; import {execa} from 'execa'; import {task} from 'hereby'; +const require = Module.createRequire(import.meta.url); + export const generateVersionTask = task({ name: 'generate:version', run: async () => { @@ -91,20 +100,52 @@ export const buildTask = task({ }); const builders = []; for (const format of formats) { - const folder = join('lib', format, 'third_party'); + const folder = posixPath.join('lib', format, 'third_party'); for (const name of packages) { - const path = join(folder, name, `${name}.js`); + const entrypoint = posixPath.join(folder, name, `${name}.js`); builders.push( await esbuild.build({ - entryPoints: [path], - outfile: path, + entryPoints: [entrypoint], + outfile: entrypoint, bundle: true, allowOverwrite: true, format, target: 'node16', minify: true, + legalComments: 'inline', }) ); + let license = ''; + switch (name) { + case 'rxjs': + license = await readFile( + path.join( + path.dirname(require.resolve('rxjs')), + '..', + '..', + 'LICENSE.txt' + ), + 'utf-8' + ); + break; + case 'mitt': + license = await readFile( + path.join(path.dirname(require.resolve('mitt')), '..', 'LICENSE'), + 'utf-8' + ); + break; + default: + throw new Error(`Add license handling for ${path}`); + } + const content = await readFile(entrypoint, 'utf-8'); + await writeFile( + entrypoint, + `/** +${license} +*/ +${content}`, + 'utf-8' + ); } } await Promise.all(builders); diff --git a/remote/test/puppeteer/packages/puppeteer-core/package.json b/remote/test/puppeteer/packages/puppeteer-core/package.json index 2f1943bd2f..1d4d564c4f 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/package.json +++ b/remote/test/puppeteer/packages/puppeteer-core/package.json @@ -1,6 +1,6 @@ { "name": "puppeteer-core", - "version": "21.10.0", + "version": "22.4.0", "description": "A high-level API to control headless Chrome over the DevTools Protocol", "keywords": [ "puppeteer", @@ -31,13 +31,13 @@ "url": "https://github.com/puppeteer/puppeteer/tree/main/packages/puppeteer-core" }, "engines": { - "node": ">=16.13.2" + "node": ">=18" }, "scripts": { "build:docs": "wireit", "build": "wireit", "check": "tsx tools/ensure-correct-devtools-protocol-package", - "clean": "../../tools/clean.js", + "clean": "../../tools/clean.mjs", "prepack": "wireit", "unit": "wireit" }, @@ -77,7 +77,8 @@ "files": [ "{src,third_party}/**", "../../versions.js", - "!src/generated" + "!src/generated", + "Herebyfile.mjs" ], "output": [ "lib/{cjs,esm}/**" @@ -118,11 +119,11 @@ "author": "The Chromium Authors", "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "1.9.1", - "chromium-bidi": "0.5.6", + "@puppeteer/browsers": "2.1.0", + "chromium-bidi": "0.5.12", "cross-fetch": "4.0.0", "debug": "4.3.4", - "devtools-protocol": "0.0.1232444", + "devtools-protocol": "0.0.1249869", "ws": "8.16.0" }, "devDependencies": { diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Browser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Browser.ts index e3b465c80e..6d7ea19d49 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/api/Browser.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Browser.ts @@ -9,7 +9,6 @@ import type {ChildProcess} from 'child_process'; import type {Protocol} from 'devtools-protocol'; import { - filterAsync, firstValueFrom, from, merge, @@ -17,7 +16,12 @@ import { } from '../../third_party/rxjs/rxjs.js'; import type {ProtocolType} from '../common/ConnectOptions.js'; import {EventEmitter, type EventType} from '../common/EventEmitter.js'; -import {debugError, fromEmitterEvent, timeout} from '../common/util.js'; +import { + debugError, + fromEmitterEvent, + filterAsync, + timeout, +} from '../common/util.js'; import {asyncDisposeSymbol, disposeSymbol} from '../util/disposable.js'; import type {BrowserContext} from './BrowserContext.js'; @@ -136,7 +140,7 @@ export const enum BrowserEvent { * Emitted when the URL of a target changes. Contains a {@link Target} * instance. * - * @remarks Note that this includes target changes in incognito browser + * @remarks Note that this includes target changes in all browser * contexts. */ TargetChanged = 'targetchanged', @@ -147,7 +151,7 @@ export const enum BrowserEvent { * * Contains a {@link Target} instance. * - * @remarks Note that this includes target creations in incognito browser + * @remarks Note that this includes target creations in all browser * contexts. */ TargetCreated = 'targetcreated', @@ -155,7 +159,7 @@ export const enum BrowserEvent { * Emitted when a target is destroyed, for example when a page is closed. * Contains a {@link Target} instance. * - * @remarks Note that this includes target destructions in incognito browser + * @remarks Note that this includes target destructions in all browser * contexts. */ TargetDestroyed = 'targetdestroyed', @@ -165,13 +169,6 @@ export const enum BrowserEvent { TargetDiscovered = 'targetdiscovered', } -export { - /** - * @deprecated Use {@link BrowserEvent}. - */ - BrowserEvent as BrowserEmittedEvents, -}; - /** * @public */ @@ -251,7 +248,7 @@ export abstract class Browser extends EventEmitter<BrowserEvents> { abstract process(): ChildProcess | null; /** - * Creates a new incognito {@link BrowserContext | browser context}. + * Creates a new {@link BrowserContext | browser context}. * * This won't share cookies/cache with other {@link BrowserContext | browser contexts}. * @@ -261,15 +258,15 @@ export abstract class Browser extends EventEmitter<BrowserEvents> { * import puppeteer from 'puppeteer'; * * const browser = await puppeteer.launch(); - * // Create a new incognito browser context. - * const context = await browser.createIncognitoBrowserContext(); + * // Create a new browser context. + * const context = await browser.createBrowserContext(); * // Create a new page in a pristine context. * const page = await context.newPage(); * // Do stuff * await page.goto('https://example.com'); * ``` */ - abstract createIncognitoBrowserContext( + abstract createBrowserContext( options?: BrowserContextOptions ): Promise<BrowserContext>; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/BrowserContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/BrowserContext.ts index 79335eb9ed..5e6a5d5d5c 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/api/BrowserContext.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/BrowserContext.ts @@ -4,8 +4,19 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { + firstValueFrom, + from, + merge, + raceWith, +} from '../../third_party/rxjs/rxjs.js'; import {EventEmitter, type EventType} from '../common/EventEmitter.js'; -import {debugError} from '../common/util.js'; +import { + debugError, + fromEmitterEvent, + filterAsync, + timeout, +} from '../common/util.js'; import {asyncDisposeSymbol, disposeSymbol} from '../util/disposable.js'; import type {Browser, Permission, WaitForTargetOptions} from './Browser.js'; @@ -38,13 +49,6 @@ export const enum BrowserContextEvent { TargetDestroyed = 'targetdestroyed', } -export { - /** - * @deprecated Use {@link BrowserContextEvent} - */ - BrowserContextEvent as BrowserContextEmittedEvents, -}; - /** * @public */ @@ -55,12 +59,13 @@ export interface BrowserContextEvents extends Record<EventType, unknown> { } /** - * {@link BrowserContext} represents individual sessions within a + * {@link BrowserContext} represents individual user contexts within a * {@link Browser | browser}. * * When a {@link Browser | browser} is launched, it has a single * {@link BrowserContext | browser context} by default. Others can be created - * using {@link Browser.createIncognitoBrowserContext}. + * using {@link Browser.createBrowserContext}. Each context has isolated storage + * (cookies/localStorage/etc.) * * {@link BrowserContext} {@link EventEmitter | emits} various events which are * documented in the {@link BrowserContextEvent} enum. @@ -69,11 +74,11 @@ export interface BrowserContextEvents extends Record<EventType, unknown> { * `window.open`, the popup will belong to the parent {@link Page.browserContext * | page's browser context}. * - * @example Creating an incognito {@link BrowserContext | browser context}: + * @example Creating a new {@link BrowserContext | browser context}: * * ```ts - * // Create a new incognito browser context - * const context = await browser.createIncognitoBrowserContext(); + * // Create a new browser context + * const context = await browser.createBrowserContext(); * // Create a new page inside context. * const page = await context.newPage(); * // ... do stuff with page ... @@ -114,10 +119,19 @@ export abstract class BrowserContext extends EventEmitter<BrowserContextEvents> * ); * ``` */ - abstract waitForTarget( + async waitForTarget( predicate: (x: Target) => boolean | Promise<boolean>, - options?: WaitForTargetOptions - ): Promise<Target>; + options: WaitForTargetOptions = {} + ): Promise<Target> { + const {timeout: ms = 30000} = options; + return await firstValueFrom( + merge( + fromEmitterEvent(this, BrowserContextEvent.TargetCreated), + fromEmitterEvent(this, BrowserContextEvent.TargetChanged), + from(this.targets()) + ).pipe(filterAsync(predicate), raceWith(timeout(ms))) + ); + } /** * Gets a list of all open {@link Page | pages} inside this @@ -131,8 +145,20 @@ export abstract class BrowserContext extends EventEmitter<BrowserContextEvents> /** * Whether this {@link BrowserContext | browser context} is incognito. * - * The {@link Browser.defaultBrowserContext | default browser context} is the - * only non-incognito browser context. + * In Chrome, the + * {@link Browser.defaultBrowserContext | default browser context} is the only + * non-incognito browser context. + * + * @deprecated In Chrome, the + * {@link Browser.defaultBrowserContext | default browser context} can also be + * "icognito" if configured via the arguments and in such cases this getter + * returns wrong results (see + * https://github.com/puppeteer/puppeteer/issues/8836). Also, the term + * "incognito" is not applicable to other browsers. To migrate, check the + * {@link Browser.defaultBrowserContext | default browser context} instead: in + * Chrome all non-default contexts are incognito, and the default context + * might be incognito if you provide the `--incognito` argument when launching + * the browser. */ abstract isIncognito(): boolean; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/CDPSession.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/CDPSession.ts index 8bdf96f954..3a1fdf1e24 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/api/CDPSession.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/CDPSession.ts @@ -1,3 +1,8 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ import type {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js'; import type {Connection} from '../cdp/Connection.js'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandle.ts index 43fec58e37..9b1326f998 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandle.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandle.ts @@ -17,15 +17,10 @@ import type { NodeFor, } from '../common/types.js'; import type {KeyInput} from '../common/USKeyboardLayout.js'; -import { - debugError, - isString, - withSourcePuppeteerURLIfNone, -} from '../common/util.js'; +import {isString, withSourcePuppeteerURLIfNone} from '../common/util.js'; import {assert} from '../util/assert.js'; import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js'; import {throwIfDisposed} from '../util/decorators.js'; -import {AsyncDisposableStack} from '../util/disposable.js'; import {_isElementHandle} from './ElementHandleSymbol.js'; import type { @@ -482,27 +477,6 @@ export abstract class ElementHandle< } /** - * @deprecated Use {@link ElementHandle.$$} with the `xpath` prefix. - * - * Example: `await elementHandle.$$('xpath/' + xpathExpression)` - * - * The method evaluates the XPath expression relative to the elementHandle. - * If `xpath` starts with `//` instead of `.//`, the dot will be appended - * automatically. - * - * If there are no such elements, the method will resolve to an empty array. - * @param expression - Expression to {@link https://developer.mozilla.org/en-US/docs/Web/API/Document/evaluate | evaluate} - */ - @throwIfDisposed() - @ElementHandle.bindIsolatedHandle - async $x(expression: string): Promise<Array<ElementHandle<Node>>> { - if (expression.startsWith('//')) { - expression = `.${expression}`; - } - return await this.$$(`xpath/${expression}`); - } - - /** * Wait for an element matching the given selector to appear in the current * element. * @@ -587,84 +561,6 @@ export abstract class ElementHandle< } /** - * @deprecated Use {@link ElementHandle.waitForSelector} with the `xpath` - * prefix. - * - * Example: `await elementHandle.waitForSelector('xpath/' + xpathExpression)` - * - * The method evaluates the XPath expression relative to the elementHandle. - * - * Wait for the `xpath` within the element. If at the moment of calling the - * method the `xpath` already exists, the method will return immediately. If - * the `xpath` doesn't appear after the `timeout` milliseconds of waiting, the - * function will throw. - * - * If `xpath` starts with `//` instead of `.//`, the dot will be appended - * automatically. - * - * @example - * This method works across navigation. - * - * ```ts - * import puppeteer from 'puppeteer'; - * (async () => { - * const browser = await puppeteer.launch(); - * const page = await browser.newPage(); - * let currentURL; - * page - * .waitForXPath('//img') - * .then(() => console.log('First URL with image: ' + currentURL)); - * for (currentURL of [ - * 'https://example.com', - * 'https://google.com', - * 'https://bbc.com', - * ]) { - * await page.goto(currentURL); - * } - * await browser.close(); - * })(); - * ``` - * - * @param xpath - A - * {@link https://developer.mozilla.org/en-US/docs/Web/XPath | xpath} of an - * element to wait for - * @param options - Optional waiting parameters - * @returns Promise which resolves when element specified by xpath string is - * added to DOM. Resolves to `null` if waiting for `hidden: true` and xpath is - * not found in DOM, otherwise resolves to `ElementHandle`. - * @remarks - * The optional Argument `options` have properties: - * - * - `visible`: A boolean to wait for element to be present in DOM and to be - * visible, i.e. to not have `display: none` or `visibility: hidden` CSS - * properties. Defaults to `false`. - * - * - `hidden`: A boolean wait for element to not be found in the DOM or to be - * hidden, i.e. have `display: none` or `visibility: hidden` CSS properties. - * Defaults to `false`. - * - * - `timeout`: A number which is maximum time to wait for in milliseconds. - * Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The - * default value can be changed by using the {@link Page.setDefaultTimeout} - * method. - */ - @throwIfDisposed() - @ElementHandle.bindIsolatedHandle - async waitForXPath( - xpath: string, - options: { - visible?: boolean; - hidden?: boolean; - timeout?: number; - } = {} - ): Promise<ElementHandle<Node> | null> { - if (xpath.startsWith('//')) { - xpath = `.${xpath}`; - } - return await this.waitForSelector(`xpath/${xpath}`, options); - } - - /** * Converts the current handle to the given element type. * * @example @@ -1346,30 +1242,6 @@ export abstract class ElementHandle< const page = this.frame.page(); - // If the element is larger than the viewport, `captureBeyondViewport` will - // _not_ affect element rendering, so we need to adjust the viewport to - // properly render the element. - const viewport = page.viewport() ?? { - width: clip.width, - height: clip.height, - }; - await using stack = new AsyncDisposableStack(); - if (clip.width > viewport.width || clip.height > viewport.height) { - await this.frame.page().setViewport({ - ...viewport, - width: Math.max(viewport.width, Math.ceil(clip.width)), - height: Math.max(viewport.height, Math.ceil(clip.height)), - }); - - stack.defer(async () => { - try { - await this.frame.page().setViewport(viewport); - } catch (error) { - debugError(error); - } - }); - } - // Only scroll the element into view if the user wants it. if (scrollIntoView) { await this.scrollIntoViewIfNeeded(); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Frame.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Frame.ts index 757ec872c6..19b5eb7fa0 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/api/Frame.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Frame.ts @@ -14,7 +14,6 @@ import type { WaitTimeoutOptions, } from '../api/Page.js'; import type {DeviceRequestPrompt} from '../cdp/DeviceRequestPrompt.js'; -import type {IsolatedWorldChart} from '../cdp/IsolatedWorld.js'; import type {PuppeteerLifeCycleEvent} from '../cdp/LifecycleWatcher.js'; import {EventEmitter, type EventType} from '../common/EventEmitter.js'; import {getQueryHandlerAndSelector} from '../common/GetQueryHandler.js'; @@ -38,8 +37,8 @@ import type {CDPSession} from './CDPSession.js'; import type {KeyboardTypeOptions} from './Input.js'; import { FunctionLocator, - type Locator, NodeLocator, + type Locator, } from './locators/locators.js'; import type {Realm} from './Realm.js'; @@ -273,11 +272,6 @@ export abstract class Frame extends EventEmitter<FrameEvents> { /** * @internal */ - worlds!: IsolatedWorldChart; - - /** - * @internal - */ _name?: string; /** @@ -339,12 +333,7 @@ export abstract class Frame extends EventEmitter<FrameEvents> { */ abstract goto( url: string, - options?: { - referer?: string; - referrerPolicy?: string; - timeout?: number; - waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; - } + options?: GoToOptions ): Promise<HTTPResponse | null>; /** @@ -425,12 +414,12 @@ export abstract class Frame extends EventEmitter<FrameEvents> { return null; } using list = await parentFrame.isolatedRealm().evaluateHandle(() => { - return document.querySelectorAll('iframe'); + return document.querySelectorAll('iframe,frame'); }); for await (using iframe of transposeIterableHandle(list)) { const frame = await iframe.contentFrame(); - if (frame._id === this._id) { - return iframe.move(); + if (frame?._id === this._id) { + return (iframe as HandleFor<HTMLIFrameElement>).move(); } } return null; @@ -624,23 +613,6 @@ export abstract class Frame extends EventEmitter<FrameEvents> { } /** - * @deprecated Use {@link Frame.$$} with the `xpath` prefix. - * - * Example: `await frame.$$('xpath/' + xpathExpression)` - * - * This method evaluates the given XPath expression and returns the results. - * If `xpath` starts with `//` instead of `.//`, the dot will be appended - * automatically. - * @param expression - the XPath expression to evaluate. - */ - @throwIfDetached - async $x(expression: string): Promise<Array<ElementHandle<Node>>> { - // eslint-disable-next-line rulesdir/use-using -- This is cached. - const document = await this.#document(); - return await document.$x(expression); - } - - /** * Waits for an element matching the given selector to appear in the frame. * * This method works across navigations. @@ -690,39 +662,6 @@ export abstract class Frame extends EventEmitter<FrameEvents> { } /** - * @deprecated Use {@link Frame.waitForSelector} with the `xpath` prefix. - * - * Example: `await frame.waitForSelector('xpath/' + xpathExpression)` - * - * The method evaluates the XPath expression relative to the Frame. - * If `xpath` starts with `//` instead of `.//`, the dot will be appended - * automatically. - * - * Wait for the `xpath` to appear in page. If at the moment of calling the - * method the `xpath` already exists, the method will return immediately. If - * the xpath doesn't appear after the `timeout` milliseconds of waiting, the - * function will throw. - * - * For a code example, see the example for {@link Frame.waitForSelector}. That - * function behaves identically other than taking a CSS selector rather than - * an XPath. - * - * @param xpath - the XPath expression to wait for. - * @param options - options to configure the visibility of the element and how - * long to wait before timing out. - */ - @throwIfDetached - async waitForXPath( - xpath: string, - options: WaitForSelectorOptions = {} - ): Promise<ElementHandle<Node> | null> { - if (xpath.startsWith('//')) { - xpath = `.${xpath}`; - } - return await this.waitForSelector(`xpath/${xpath}`, options); - } - - /** * @example * The `waitForFunction` can be used to observe viewport size change: * @@ -799,13 +738,7 @@ export abstract class Frame extends EventEmitter<FrameEvents> { * @param options - Options to configure how long before timing out and at * what point to consider the content setting successful. */ - abstract setContent( - html: string, - options?: { - timeout?: number; - waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; - } - ): Promise<void>; + abstract setContent(html: string, options?: WaitForOptions): Promise<void>; /** * @internal @@ -1152,32 +1085,6 @@ export abstract class Frame extends EventEmitter<FrameEvents> { } /** - * @deprecated Replace with `new Promise(r => setTimeout(r, milliseconds));`. - * - * Causes your script to wait for the given number of milliseconds. - * - * @remarks - * It's generally recommended to not wait for a number of seconds, but instead - * use {@link Frame.waitForSelector}, {@link Frame.waitForXPath} or - * {@link Frame.waitForFunction} to wait for exactly the conditions you want. - * - * @example - * - * Wait for 1 second: - * - * ```ts - * await frame.waitForTimeout(1000); - * ``` - * - * @param milliseconds - the number of milliseconds to wait. - */ - async waitForTimeout(milliseconds: number): Promise<void> { - return await new Promise(resolve => { - setTimeout(resolve, milliseconds); - }); - } - - /** * The frame's title. */ @throwIfDetached diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPRequest.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPRequest.ts index 3c952371ee..d72f088686 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPRequest.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPRequest.ts @@ -94,7 +94,8 @@ export abstract class HTTPRequest { /** * @internal */ - _requestId = ''; + abstract get id(): string; + /** * @internal */ @@ -395,13 +396,6 @@ export enum InterceptResolutionAction { /** * @public - * - * @deprecated please use {@link InterceptResolutionAction} instead. - */ -export type InterceptResolutionStrategy = InterceptResolutionAction; - -/** - * @public */ export type ErrorCode = | 'aborted' diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Page.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Page.ts index deb04628fd..b094d14b2f 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/api/Page.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Page.ts @@ -4,26 +4,25 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {Readable} from 'stream'; - import type {Protocol} from 'devtools-protocol'; import { concat, EMPTY, filter, - filterAsync, first, firstValueFrom, from, map, merge, mergeMap, + mergeScan, of, - race, raceWith, + ReplaySubject, startWith, switchMap, + take, takeUntil, timer, type Observable, @@ -36,6 +35,11 @@ import type {DeviceRequestPrompt} from '../cdp/DeviceRequestPrompt.js'; import type {Credentials, NetworkConditions} from '../cdp/NetworkManager.js'; import type {Tracing} from '../cdp/Tracing.js'; import type {ConsoleMessage} from '../common/ConsoleMessage.js'; +import type { + Cookie, + CookieParam, + DeleteCookiesRequest, +} from '../common/Cookie.js'; import type {Device} from '../common/Device.js'; import {TargetCloseError} from '../common/Errors.js'; import { @@ -58,6 +62,7 @@ import type { import { debugError, fromEmitterEvent, + filterAsync, importFSPromises, isString, NETWORK_IDLE_TIME, @@ -477,15 +482,6 @@ export const enum PageEvent { WorkerDestroyed = 'workerdestroyed', } -export { - /** - * All the events that a page instance may emit. - * - * @deprecated Use {@link PageEvent}. - */ - PageEvent as PageEmittedEvents, -}; - /** * Denotes the objects received by callback functions for page events. * @@ -516,13 +512,6 @@ export interface PageEvents extends Record<EventType, unknown> { [PageEvent.WorkerDestroyed]: WebWorker; } -export type { - /** - * @deprecated Use {@link PageEvents}. - */ - PageEvents as PageEventObject, -}; - /** * @public */ @@ -604,8 +593,7 @@ export abstract class Page extends EventEmitter<PageEvents> { #requestHandlers = new WeakMap<Handler<HTTPRequest>, Handler<HTTPRequest>>(); - #requestsInFlight = 0; - #inflight$: Observable<number>; + #inflight$ = new ReplaySubject<number>(1); /** * @internal @@ -613,39 +601,37 @@ export abstract class Page extends EventEmitter<PageEvents> { constructor() { super(); - this.#inflight$ = fromEmitterEvent(this, PageEvent.Request).pipe( - takeUntil(fromEmitterEvent(this, PageEvent.Close)), - mergeMap(request => { - return concat( - of(1), - race( - fromEmitterEvent(this, PageEvent.Response).pipe( - filter(response => { - return response.request()._requestId === request._requestId; - }) - ), - fromEmitterEvent(this, PageEvent.RequestFailed).pipe( - filter(failure => { - return failure._requestId === request._requestId; - }) - ), - fromEmitterEvent(this, PageEvent.RequestFinished).pipe( - filter(success => { - return success._requestId === request._requestId; + fromEmitterEvent(this, PageEvent.Request) + .pipe( + mergeMap(originalRequest => { + return concat( + of(1), + merge( + fromEmitterEvent(this, PageEvent.RequestFailed), + fromEmitterEvent(this, PageEvent.RequestFinished), + fromEmitterEvent(this, PageEvent.Response).pipe( + map(response => { + return response.request(); + }) + ) + ).pipe( + filter(request => { + return request.id === originalRequest.id; + }), + take(1), + map(() => { + return -1; }) ) - ).pipe( - map(() => { - return -1; - }) - ) - ); - }) - ); - - this.#inflight$.subscribe(count => { - this.#requestsInFlight += count; - }); + ); + }), + mergeScan((acc, addend) => { + return of(acc + addend); + }, 0), + takeUntil(fromEmitterEvent(this, PageEvent.Close)), + startWith(0) + ) + .subscribe(this.#inflight$); } /** @@ -771,6 +757,8 @@ export abstract class Page extends EventEmitter<PageEvents> { /** * A target this page was created from. + * + * @deprecated Use {@link Page.createCDPSession} directly. */ abstract target(): Target; @@ -1287,28 +1275,12 @@ export abstract class Page extends EventEmitter<PageEvents> { } /** - * The method evaluates the XPath expression relative to the page document as - * its context node. If there are no such elements, the method resolves to an - * empty array. - * - * @remarks - * Shortcut for {@link Frame.$x | Page.mainFrame().$x(expression) }. - * - * @param expression - Expression to evaluate - */ - async $x(expression: string): Promise<Array<ElementHandle<Node>>> { - return await this.mainFrame().$x(expression); - } - - /** * If no URLs are specified, this method returns cookies for the current page * URL. If URLs are specified, only cookies for those URLs are returned. */ - abstract cookies(...urls: string[]): Promise<Protocol.Network.Cookie[]>; + abstract cookies(...urls: string[]): Promise<Cookie[]>; - abstract deleteCookie( - ...cookies: Protocol.Network.DeleteCookiesRequest[] - ): Promise<void>; + abstract deleteCookie(...cookies: DeleteCookiesRequest[]): Promise<void>; /** * @example @@ -1317,7 +1289,7 @@ export abstract class Page extends EventEmitter<PageEvents> { * await page.setCookie(cookieObject1, cookieObject2); * ``` */ - abstract setCookie(...cookies: Protocol.Network.CookieParam[]): Promise<void>; + abstract setCookie(...cookies: CookieParam[]): Promise<void>; /** * Adds a `<script>` tag into the page with the desired URL or content. @@ -1776,13 +1748,11 @@ export abstract class Page extends EventEmitter<PageEvents> { } = options; return this.#inflight$.pipe( - startWith(this.#requestsInFlight), - switchMap(() => { - if (this.#requestsInFlight > concurrency) { + switchMap(inflight => { + if (inflight > concurrency) { return EMPTY; - } else { - return timer(idleTime); } + return timer(idleTime); }), map(() => {}), raceWith( @@ -2604,7 +2574,9 @@ export abstract class Page extends EventEmitter<PageEvents> { * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-print-color-adjust | `-webkit-print-color-adjust`} * property to force rendering of exact colors. */ - abstract createPDFStream(options?: PDFOptions): Promise<Readable>; + abstract createPDFStream( + options?: PDFOptions + ): Promise<ReadableStream<Uint8Array>>; /** * {@inheritDoc Page.createPDFStream} @@ -2785,31 +2757,6 @@ export abstract class Page extends EventEmitter<PageEvents> { } /** - * @deprecated Replace with `new Promise(r => setTimeout(r, milliseconds));`. - * - * Causes your script to wait for the given number of milliseconds. - * - * @remarks - * - * It's generally recommended to not wait for a number of seconds, but instead - * use {@link Frame.waitForSelector}, {@link Frame.waitForXPath} or - * {@link Frame.waitForFunction} to wait for exactly the conditions you want. - * - * @example - * - * Wait for 1 second: - * - * ```ts - * await page.waitForTimeout(1000); - * ``` - * - * @param milliseconds - the number of milliseconds to wait. - */ - waitForTimeout(milliseconds: number): Promise<void> { - return this.mainFrame().waitForTimeout(milliseconds); - } - - /** * Wait for the `selector` to appear in page. If at the moment of calling the * method the `selector` already exists, the method will return immediately. If * the `selector` doesn't appear after the `timeout` milliseconds of waiting, the @@ -2869,64 +2816,6 @@ export abstract class Page extends EventEmitter<PageEvents> { } /** - * Wait for the `xpath` to appear in page. If at the moment of calling the - * method the `xpath` already exists, the method will return immediately. If - * the `xpath` doesn't appear after the `timeout` milliseconds of waiting, the - * function will throw. - * - * @example - * This method works across navigation - * - * ```ts - * import puppeteer from 'puppeteer'; - * (async () => { - * const browser = await puppeteer.launch(); - * const page = await browser.newPage(); - * let currentURL; - * page - * .waitForXPath('//img') - * .then(() => console.log('First URL with image: ' + currentURL)); - * for (currentURL of [ - * 'https://example.com', - * 'https://google.com', - * 'https://bbc.com', - * ]) { - * await page.goto(currentURL); - * } - * await browser.close(); - * })(); - * ``` - * - * @param xpath - A - * {@link https://developer.mozilla.org/en-US/docs/Web/XPath | xpath} of an - * element to wait for - * @param options - Optional waiting parameters - * @returns Promise which resolves when element specified by xpath string is - * added to DOM. Resolves to `null` if waiting for `hidden: true` and xpath is - * not found in DOM, otherwise resolves to `ElementHandle`. - * @remarks - * The optional Argument `options` have properties: - * - * - `visible`: A boolean to wait for element to be present in DOM and to be - * visible, i.e. to not have `display: none` or `visibility: hidden` CSS - * properties. Defaults to `false`. - * - * - `hidden`: A boolean wait for element to not be found in the DOM or to be - * hidden, i.e. have `display: none` or `visibility: hidden` CSS properties. - * Defaults to `false`. - * - * - `timeout`: A number which is maximum time to wait for in milliseconds. - * Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The default - * value can be changed by using the {@link Page.setDefaultTimeout} method. - */ - waitForXPath( - xpath: string, - options?: WaitForSelectorOptions - ): Promise<ElementHandle<Node> | null> { - return this.mainFrame().waitForXPath(xpath, options); - } - - /** * Waits for the provided function, `pageFunction`, to return a truthy value when * evaluated in the page's context. * diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/WebWorker.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/WebWorker.ts index 4de287f146..b65452b650 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/api/WebWorker.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/WebWorker.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {UnsupportedOperation} from '../common/Errors.js'; import {EventEmitter, type EventType} from '../common/EventEmitter.js'; import {TimeoutSettings} from '../common/TimeoutSettings.js'; import type {EvaluateFunc, HandleFor} from '../common/types.js'; @@ -131,4 +132,8 @@ export abstract class WebWorker extends EventEmitter< func = withSourcePuppeteerURLIfNone(this.evaluateHandle.name, func); return await this.mainRealm().evaluateHandle(func, ...args); } + + async close(): Promise<void> { + throw new UnsupportedOperation('WebWorker.close() is not supported'); + } } diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/locators/locators.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/locators/locators.ts index 7bec11e38e..d88cc0a17d 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/api/locators/locators.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/locators/locators.ts @@ -109,24 +109,14 @@ export enum LocatorEvent { */ Action = 'action', } -export { - /** - * @deprecated Use {@link LocatorEvent}. - */ - LocatorEvent as LocatorEmittedEvents, -}; + /** * @public */ export interface LocatorEvents extends Record<EventType, unknown> { [LocatorEvent.Action]: undefined; } -export type { - /** - * @deprecated Use {@link LocatorEvents}. - */ - LocatorEvents as LocatorEventObject, -}; + /** * Locators describe a strategy of locating objects and performing an action on * them. If the action fails because the object is not ready for the action, the 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; + }; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Binding.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Binding.ts index 7a6a6f8582..7fe372788f 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Binding.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Binding.ts @@ -1,3 +1,8 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ import {JSHandle} from '../api/JSHandle.js'; import {debugError} from '../common/util.js'; import {DisposableStack} from '../util/disposable.js'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Browser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Browser.ts index 7698acd164..5c8a4c24da 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Browser.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Browser.ts @@ -18,7 +18,6 @@ import { type IsPageTargetCallback, type Permission, type TargetFilterCallback, - type WaitForTargetOptions, } from '../api/Browser.js'; import {BrowserContext, BrowserContextEvent} from '../api/BrowserContext.js'; import {CDPSessionEvent, type CDPSession} from '../api/CDPSession.js'; @@ -201,7 +200,7 @@ export class CdpBrowser extends BrowserBase { return this.#isPageTargetCallback; } - override async createIncognitoBrowserContext( + override async createBrowserContext( options: BrowserContextOptions = {} ): Promise<CdpBrowserContext> { const {proxyServer, proxyBypassList} = options; @@ -451,15 +450,6 @@ export class CdpBrowserContext extends BrowserContext { }); } - override waitForTarget( - predicate: (x: Target) => boolean | Promise<boolean>, - options: WaitForTargetOptions = {} - ): Promise<Target> { - return this.#browser.waitForTarget(target => { - return target.browserContext() === this && predicate(target); - }, options); - } - override async pages(): Promise<Page[]> { const pages = await Promise.all( this.targets() diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/EmulationManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/EmulationManager.ts index 8598967fe7..823b3b462e 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/EmulationManager.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/EmulationManager.ts @@ -267,13 +267,23 @@ export class EmulationManager { const hasTouch = viewport.hasTouch || false; await Promise.all([ - client.send('Emulation.setDeviceMetricsOverride', { - mobile, - width, - height, - deviceScaleFactor, - screenOrientation, - }), + client + .send('Emulation.setDeviceMetricsOverride', { + mobile, + width, + height, + deviceScaleFactor, + screenOrientation, + }) + .catch(err => { + if ( + err.message.includes('Target does not support metrics override') + ) { + debugError(err); + return; + } + throw err; + }), client.send('Emulation.setTouchEmulationEnabled', { enabled: hasTouch, }), diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Frame.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Frame.ts index 844120d7ff..edc7009b11 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Frame.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Frame.ts @@ -20,6 +20,7 @@ import type { DeviceRequestPromptManager, } from './DeviceRequestPrompt.js'; import type {FrameManager} from './FrameManager.js'; +import type {IsolatedWorldChart} from './IsolatedWorld.js'; import {IsolatedWorld} from './IsolatedWorld.js'; import {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js'; import { @@ -35,6 +36,7 @@ export class CdpFrame extends Frame { #url = ''; #detached = false; #client!: CDPSession; + worlds!: IsolatedWorldChart; _frameManager: FrameManager; override _id: string; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPRequest.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPRequest.ts index 029e77470b..1331513e19 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPRequest.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPRequest.ts @@ -28,6 +28,7 @@ import type {CdpHTTPResponse} from './HTTPResponse.js'; * @internal */ export class CdpHTTPRequest extends HTTPRequest { + override id: string; declare _redirectChain: CdpHTTPRequest[]; declare _response: CdpHTTPResponse | null; @@ -91,7 +92,7 @@ export class CdpHTTPRequest extends HTTPRequest { ) { super(); this.#client = client; - this._requestId = data.requestId; + this.id = data.requestId; this.#isNavigationRequest = data.requestId === data.loaderId && data.type === 'Document'; this._interceptionId = interceptionId; @@ -188,7 +189,7 @@ export class CdpHTTPRequest extends HTTPRequest { override async fetchPostData(): Promise<string | undefined> { try { const result = await this.#client.send('Network.getRequestPostData', { - requestId: this._requestId, + requestId: this.id, }); return result.postData; } catch (err) { diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPResponse.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPResponse.ts index 2b2264ffd4..eb92ab07e3 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPResponse.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPResponse.ts @@ -130,7 +130,7 @@ export class CdpHTTPResponse extends HTTPResponse { const response = await this.#client.send( 'Network.getResponseBody', { - requestId: this.#request._requestId, + requestId: this.#request.id, } ); return Buffer.from( diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Input.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Input.ts index 9bfafddcf3..0674ef4634 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Input.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Input.ts @@ -10,16 +10,16 @@ import type {CDPSession} from '../api/CDPSession.js'; import type {Point} from '../api/ElementHandle.js'; import { Keyboard, - type KeyDownOptions, - type KeyPressOptions, Mouse, MouseButton, + Touchscreen, + type KeyDownOptions, + type KeyPressOptions, + type KeyboardTypeOptions, type MouseClickOptions, type MouseMoveOptions, type MouseOptions, type MouseWheelOptions, - Touchscreen, - type KeyboardTypeOptions, } from '../api/Input.js'; import { _keyDefinitions, @@ -573,6 +573,7 @@ export class CdpTouchscreen extends Touchscreen { y: Math.round(y), radiusX: 0.5, radiusY: 0.5, + force: 0.5, }, ], modifiers: this.#keyboard._modifiers, @@ -588,6 +589,7 @@ export class CdpTouchscreen extends Touchscreen { y: Math.round(y), radiusX: 0.5, radiusY: 0.5, + force: 0.5, }, ], modifiers: this.#keyboard._modifiers, diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/LifecycleWatcher.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/LifecycleWatcher.ts index a4f5aaa468..fe71ca52fc 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/LifecycleWatcher.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/LifecycleWatcher.ts @@ -191,14 +191,14 @@ export class LifecycleWatcher { } #onRequestFailed(request: HTTPRequest): void { - if (this.#navigationRequest?._requestId !== request._requestId) { + if (this.#navigationRequest?.id !== request.id) { return; } this.#navigationResponseReceived?.resolve(); } #onResponse(response: HTTPResponse): void { - if (this.#navigationRequest?._requestId !== response.request()._requestId) { + if (this.#navigationRequest?.id !== response.request().id) { return; } this.#navigationResponseReceived?.resolve(); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.test.ts index c3e9a8f609..96f0d20963 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.test.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.test.ts @@ -128,6 +128,7 @@ describe('NetworkManager', () => { url: 'http://localhost:8907/redirect/1.html', status: 302, statusText: 'Found', + charset: 'utf-8', headers: { location: '/redirect/2.html', Date: 'Fri, 19 Nov 2021 09:53:58 GMT', @@ -217,6 +218,7 @@ describe('NetworkManager', () => { url: 'http://localhost:8907/redirect/2.html', status: 302, statusText: 'Found', + charset: 'utf-8', headers: { location: '/redirect/3.html', Date: 'Fri, 19 Nov 2021 09:53:58 GMT', @@ -321,6 +323,7 @@ describe('NetworkManager', () => { url: 'http://localhost:8907/redirect/3.html', status: 302, statusText: 'Found', + charset: 'utf-8', headers: { location: 'http://localhost:8907/empty.html', Date: 'Fri, 19 Nov 2021 09:53:58 GMT', @@ -433,6 +436,7 @@ describe('NetworkManager', () => { 'Keep-Alive': 'timeout=5', 'Content-Length': '0', }, + charset: 'utf-8', mimeType: 'text/html', connectionReused: true, connectionId: 322, @@ -613,6 +617,7 @@ describe('NetworkManager', () => { connection: 'keep-alive', 'content-length': '85862', }, + charset: 'utf-8', mimeType: 'text/plain', connectionReused: false, connectionId: 119, @@ -725,6 +730,7 @@ describe('NetworkManager', () => { url: 'http://10.1.0.39:42915/empty.html', status: 200, statusText: 'OK', + charset: 'utf-8', headers: { 'Cache-Control': 'no-cache, no-store', Connection: 'keep-alive', @@ -932,6 +938,7 @@ describe('NetworkManager', () => { url: 'http://127.0.0.1:54590/empty.html', status: 200, statusText: 'OK', + charset: 'utf-8', headers: { 'Cache-Control': 'no-cache, no-store', Connection: 'keep-alive', @@ -1036,6 +1043,7 @@ describe('NetworkManager', () => { url: 'http://localhost:56295/empty.html', status: 200, statusText: 'OK', + charset: 'utf-8', headers: { 'Cache-Control': 'no-cache, no-store', Connection: 'keep-alive', @@ -1221,6 +1229,7 @@ describe('NetworkManager', () => { url: 'http://localhost:3000/', status: 200, statusText: 'OK', + charset: 'utf-8', headers: { 'Cache-Control': 'max-age=5', Connection: 'keep-alive', @@ -1394,6 +1403,7 @@ describe('NetworkManager', () => { url: 'http://localhost:3000/redirect', status: 302, statusText: 'Found', + charset: 'utf-8', headers: { Connection: 'keep-alive', Date: 'Wed, 05 Apr 2023 12:39:13 GMT', @@ -1457,6 +1467,7 @@ describe('NetworkManager', () => { url: 'http://localhost:3000/', status: 200, statusText: 'OK', + charset: 'utf-8', headers: { 'Cache-Control': 'max-age=5', 'Content-Type': 'text/html; charset=utf-8', diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.ts index 8b24b9a748..4fd61116d2 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.ts @@ -36,11 +36,17 @@ export interface Credentials { * @public */ export interface NetworkConditions { - // Download speed (bytes/s) + /** + * Download speed (bytes/s) + */ download: number; - // Upload speed (bytes/s) + /** + * Upload speed (bytes/s) + */ upload: number; - // Latency (ms) + /** + * Latency (ms) + */ latency: number; } @@ -631,7 +637,7 @@ export class NetworkManager extends EventEmitter<NetworkManagerEvents> { } #forgetRequest(request: CdpHTTPRequest, events: boolean): void { - const requestId = request._requestId; + const requestId = request.id; const interceptionId = request._interceptionId; this.#networkEventManager.forgetRequest(requestId); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Page.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Page.ts index 491637f0ea..d5341cf3bb 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Page.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Page.ts @@ -4,8 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {Readable} from 'stream'; - import type {Protocol} from 'devtools-protocol'; import {firstValueFrom, from, raceWith} from '../../third_party/rxjs/rxjs.js'; @@ -32,6 +30,11 @@ import { ConsoleMessage, type ConsoleMessageType, } from '../common/ConsoleMessage.js'; +import type { + Cookie, + DeleteCookiesRequest, + CookieParam, +} from '../common/Cookie.js'; import {TargetCloseError} from '../common/Errors.js'; import {FileChooser} from '../common/FileChooser.js'; import {NetworkManagerEvent} from '../common/NetworkManagerEvents.js'; @@ -80,6 +83,15 @@ import { } from './utils.js'; import {CdpWebWorker} from './WebWorker.js'; +function convertConsoleMessageLevel(method: string): ConsoleMessageType { + switch (method) { + case 'warning': + return 'warn'; + default: + return method as ConsoleMessageType; + } +} + /** * @internal */ @@ -346,6 +358,8 @@ export class CdpPage extends Page { const worker = new CdpWebWorker( session, session._target().url(), + session._target()._targetId, + session._target().type(), this.#addConsoleMessage.bind(this), this.#handleException.bind(this) ); @@ -470,7 +484,12 @@ export class CdpPage extends Page { if (source !== 'worker') { this.emit( PageEvent.Console, - new ConsoleMessage(level, text, [], [{url, lineNumber}]) + new ConsoleMessage( + convertConsoleMessageLevel(level), + text, + [], + [{url, lineNumber}] + ) ); } } @@ -572,16 +591,14 @@ export class CdpPage extends Page { ) as HandleFor<Prototype[]>; } - override async cookies( - ...urls: string[] - ): Promise<Protocol.Network.Cookie[]> { + override async cookies(...urls: string[]): Promise<Cookie[]> { const originalCookies = ( await this.#primaryTargetClient.send('Network.getCookies', { urls: urls.length ? urls : [this.url()], }) ).cookies; - const unsupportedCookieAttributes = ['priority']; + const unsupportedCookieAttributes = ['sourcePort']; const filterUnsupportedAttributes = ( cookie: Protocol.Network.Cookie ): Protocol.Network.Cookie => { @@ -594,7 +611,7 @@ export class CdpPage extends Page { } override async deleteCookie( - ...cookies: Protocol.Network.DeleteCookiesRequest[] + ...cookies: DeleteCookiesRequest[] ): Promise<void> { const pageURL = this.url(); for (const cookie of cookies) { @@ -606,9 +623,7 @@ export class CdpPage extends Page { } } - override async setCookie( - ...cookies: Protocol.Network.CookieParam[] - ): Promise<void> { + override async setCookie(...cookies: CookieParam[]): Promise<void> { const pageURL = this.url(); const startsWithHTTP = pageURL.startsWith('http'); const items = cookies.map(cookie => { @@ -810,7 +825,11 @@ export class CdpPage extends Page { const values = event.args.map(arg => { return createCdpHandle(context._world, arg); }); - this.#addConsoleMessage(event.type, values, event.stackTrace); + this.#addConsoleMessage( + convertConsoleMessageLevel(event.type), + values, + event.stackTrace + ); } async #onBindingCalled( @@ -842,7 +861,7 @@ export class CdpPage extends Page { } #addConsoleMessage( - eventType: ConsoleMessageType, + eventType: string, args: JSHandle[], stackTrace?: Protocol.Runtime.StackTrace ): void { @@ -874,7 +893,7 @@ export class CdpPage extends Page { } } const message = new ConsoleMessage( - eventType, + convertConsoleMessageLevel(eventType), textTokens.join(' '), args, stackTraceLocations @@ -1086,7 +1105,9 @@ export class CdpPage extends Page { return data; } - override async createPDFStream(options: PDFOptions = {}): Promise<Readable> { + override async createPDFStream( + options: PDFOptions = {} + ): Promise<ReadableStream<Uint8Array>> { const {timeout: ms = this._timeoutSettings.timeout()} = options; const { landscape, @@ -1102,6 +1123,7 @@ export class CdpPage extends Page { preferCSSPageSize, omitBackground, tagged: generateTaggedPDF, + outline: generateDocumentOutline, } = parsePDFOptions(options); if (omitBackground) { @@ -1127,6 +1149,7 @@ export class CdpPage extends Page { pageRanges, preferCSSPageSize, generateTaggedPDF, + generateDocumentOutline, } ); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/PredefinedNetworkConditions.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/PredefinedNetworkConditions.ts index df035ae52b..2e30f900c3 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/PredefinedNetworkConditions.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/PredefinedNetworkConditions.ts @@ -40,10 +40,3 @@ export const PredefinedNetworkConditions = Object.freeze({ latency: 150 * 3.75, } as NetworkConditions, }); - -/** - * @deprecated Import {@link PredefinedNetworkConditions}. - * - * @public - */ -export const networkConditions = PredefinedNetworkConditions; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Target.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Target.ts index b3e9ea83ec..ab8b00475b 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Target.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Target.ts @@ -290,6 +290,8 @@ export class WorkerTarget extends CdpTarget { return new CdpWebWorker( client, this._getTargetInfo().url, + this._targetId, + this.type(), () => {} /* consoleAPICalled */, () => {} /* exceptionThrown */ ); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/WebWorker.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/WebWorker.ts index 552e8a6cf5..ed2407ba66 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/WebWorker.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/WebWorker.ts @@ -7,8 +7,8 @@ import type {Protocol} from 'devtools-protocol'; import type {CDPSession} from '../api/CDPSession.js'; import type {Realm} from '../api/Realm.js'; +import {TargetType} from '../api/Target.js'; import {WebWorker} from '../api/WebWorker.js'; -import type {ConsoleMessageType} from '../common/ConsoleMessage.js'; import {TimeoutSettings} from '../common/TimeoutSettings.js'; import {debugError} from '../common/util.js'; @@ -20,7 +20,7 @@ import {CdpJSHandle} from './JSHandle.js'; * @internal */ export type ConsoleAPICalledCallback = ( - eventType: ConsoleMessageType, + eventType: string, handles: CdpJSHandle[], trace?: Protocol.Runtime.StackTrace ) => void; @@ -38,15 +38,21 @@ export type ExceptionThrownCallback = ( export class CdpWebWorker extends WebWorker { #world: IsolatedWorld; #client: CDPSession; + readonly #id: string; + readonly #targetType: TargetType; constructor( client: CDPSession, url: string, + targetId: string, + targetType: TargetType, consoleAPICalled: ConsoleAPICalledCallback, exceptionThrown: ExceptionThrownCallback ) { super(url); + this.#id = targetId; this.#client = client; + this.#targetType = targetType; this.#world = new IsolatedWorld(this, new TimeoutSettings()); this.#client.once('Runtime.executionContextCreated', async event => { @@ -80,4 +86,25 @@ export class CdpWebWorker extends WebWorker { get client(): CDPSession { return this.#client; } + + override async close(): Promise<void> { + switch (this.#targetType) { + case TargetType.SERVICE_WORKER: + case TargetType.SHARED_WORKER: { + // For service and shared workers we need to close the target and detach to allow + // the worker to stop. + await this.client.connection()?.send('Target.closeTarget', { + targetId: this.#id, + }); + await this.client.connection()?.send('Target.detachFromTarget', { + sessionId: this.client.id(), + }); + break; + } + default: + await this.evaluate(() => { + self.close(); + }); + } + } } diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserConnector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserConnector.ts index 217e53bedd..4c8308da6e 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserConnector.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserConnector.ts @@ -14,7 +14,6 @@ import {isErrorLike} from '../util/ErrorLike.js'; import type {ConnectionTransport} from './ConnectionTransport.js'; import type {ConnectOptions} from './ConnectOptions.js'; import type {BrowserConnectOptions} from './ConnectOptions.js'; -import {getFetch} from './fetch.js'; const getWebSocketTransportClass = async () => { return isNode @@ -93,9 +92,8 @@ async function getConnectionTransport( async function getWSEndpoint(browserURL: string): Promise<string> { const endpointURL = new URL('/json/version', browserURL); - const fetch = await getFetch(); try { - const result = await fetch(endpointURL.toString(), { + const result = await globalThis.fetch(endpointURL.toString(), { method: 'GET', }); if (!result.ok) { diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Configuration.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Configuration.ts index c64d109a7c..fe71e57587 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/common/Configuration.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Configuration.ts @@ -32,7 +32,13 @@ export interface Configuration { * See {@link PuppeteerNode.launch | puppeteer.launch} on how executable path * is inferred. * - * @defaultValue A compatible-revision of the browser. + * Use a specific browser version (e.g., 119.0.6045.105). If you use an alias + * such `stable` or `canary` it will only work during the installation of + * Puppeteer and it will fail when launching the browser. + * + * @example 119.0.6045.105 + * @defaultValue The pinned browser version supported by the current Puppeteer + * version. */ browserRevision?: string; /** @@ -51,20 +57,12 @@ export interface Configuration { * @remarks * This must include the protocol and may even need a path prefix. * - * @defaultValue Either https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing or + * @defaultValue Either https://storage.googleapis.com/chrome-for-testing-public or * https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central, * depending on the product. */ downloadBaseUrl?: string; /** - * Specifies the path for the downloads folder. - * - * Can be overridden by `PUPPETEER_DOWNLOAD_PATH`. - * - * @defaultValue `<cacheDirectory>` - */ - downloadPath?: string; - /** * Specifies an executable path to be used in * {@link PuppeteerNode.launch | puppeteer.launch}. * diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/ConsoleMessage.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/ConsoleMessage.ts index 85d2db9f75..c2aad7679d 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/common/ConsoleMessage.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/ConsoleMessage.ts @@ -35,7 +35,7 @@ export type ConsoleMessageType = | 'debug' | 'info' | 'error' - | 'warning' + | 'warn' | 'dir' | 'dirxml' | 'table' diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Cookie.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Cookie.ts new file mode 100644 index 0000000000..c9f7283075 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Cookie.ts @@ -0,0 +1,186 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Represents the cookie's 'SameSite' status: + * https://tools.ietf.org/html/draft-west-first-party-cookies + * + * @public + */ +export type CookieSameSite = 'Strict' | 'Lax' | 'None'; + +/** + * Represents the cookie's 'Priority' status: + * https://tools.ietf.org/html/draft-west-cookie-priority-00 + * + * @public + */ +export type CookiePriority = 'Low' | 'Medium' | 'High'; + +/** + * Represents the source scheme of the origin that originally set the cookie. A value of + * "Unset" allows protocol clients to emulate legacy cookie scope for the scheme. + * This is a temporary ability and it will be removed in the future. + * + * @public + */ +export type CookieSourceScheme = 'Unset' | 'NonSecure' | 'Secure'; + +/** + * Represents a cookie object. + * + * @public + */ +export interface Cookie { + /** + * Cookie name. + */ + name: string; + /** + * Cookie value. + */ + value: string; + /** + * Cookie domain. + */ + domain: string; + /** + * Cookie path. + */ + path: string; + /** + * Cookie expiration date as the number of seconds since the UNIX epoch. Set to `-1` for + * session cookies + */ + expires: number; + /** + * Cookie size. + */ + size: number; + /** + * True if cookie is http-only. + */ + httpOnly: boolean; + /** + * True if cookie is secure. + */ + secure: boolean; + /** + * True in case of session cookie. + */ + session: boolean; + /** + * Cookie SameSite type. + */ + sameSite?: CookieSameSite; + /** + * Cookie Priority. Supported only in Chrome. + */ + priority?: CookiePriority; + /** + * True if cookie is SameParty. Supported only in Chrome. + */ + sameParty?: boolean; + /** + * Cookie source scheme type. Supported only in Chrome. + */ + sourceScheme?: CookieSourceScheme; + /** + * Cookie partition key. The site of the top-level URL the browser was visiting at the + * start of the request to the endpoint that set the cookie. Supported only in Chrome. + */ + partitionKey?: string; + /** + * True if cookie partition key is opaque. Supported only in Chrome. + */ + partitionKeyOpaque?: boolean; +} + +/** + * Cookie parameter object + * + * @public + */ +export interface CookieParam { + /** + * Cookie name. + */ + name: string; + /** + * Cookie value. + */ + value: string; + /** + * The request-URI to associate with the setting of the cookie. This value can affect + * the default domain, path, and source scheme values of the created cookie. + */ + url?: string; + /** + * Cookie domain. + */ + domain?: string; + /** + * Cookie path. + */ + path?: string; + /** + * True if cookie is secure. + */ + secure?: boolean; + /** + * True if cookie is http-only. + */ + httpOnly?: boolean; + /** + * Cookie SameSite type. + */ + sameSite?: CookieSameSite; + /** + * Cookie expiration date, session cookie if not set + */ + expires?: number; + /** + * Cookie Priority. Supported only in Chrome. + */ + priority?: CookiePriority; + /** + * True if cookie is SameParty. Supported only in Chrome. + */ + sameParty?: boolean; + /** + * Cookie source scheme type. Supported only in Chrome. + */ + sourceScheme?: CookieSourceScheme; + /** + * Cookie partition key. The site of the top-level URL the browser was visiting at the + * start of the request to the endpoint that set the cookie. If not set, the cookie will + * be set as not partitioned. + */ + partitionKey?: string; +} + +/** + * @public + */ +export interface DeleteCookiesRequest { + /** + * Name of the cookies to remove. + */ + name: string; + /** + * If specified, deletes all the cookies with the given name where domain and path match + * provided URL. Otherwise, deletes only cookies related to the current page's domain. + */ + url?: string; + /** + * If specified, deletes only cookies with the exact domain. + */ + domain?: string; + /** + * If specified, deletes only cookies with the exact path. + */ + path?: string; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Device.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Device.ts index dbf5c13c95..1f1a35dd0b 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/common/Device.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Device.ts @@ -1543,10 +1543,3 @@ for (const device of knownDevices) { * @public */ export const KnownDevices = Object.freeze(knownDevicesByName); - -/** - * @deprecated Import {@link KnownDevices} - * - * @public - */ -export const devices = KnownDevices; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Errors.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Errors.ts index 8225d64f07..4d0a43ea33 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/common/Errors.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Errors.ts @@ -5,11 +5,11 @@ */ /** - * @deprecated Do not use. + * The base class for all Puppeteer-specific errors * * @public */ -export class CustomError extends Error { +export class PuppeteerError extends Error { /** * @internal */ @@ -36,14 +36,14 @@ export class CustomError extends Error { * * @public */ -export class TimeoutError extends CustomError {} +export class TimeoutError extends PuppeteerError {} /** * ProtocolError is emitted whenever there is an error from the protocol. * * @public */ -export class ProtocolError extends CustomError { +export class ProtocolError extends PuppeteerError { #code?: number; #originalMessage = ''; @@ -76,49 +76,9 @@ export class ProtocolError extends CustomError { * * @public */ -export class UnsupportedOperation extends CustomError {} +export class UnsupportedOperation extends PuppeteerError {} /** * @internal */ export class TargetCloseError extends ProtocolError {} - -/** - * @deprecated Do not use. - * - * @public - */ -export interface PuppeteerErrors { - TimeoutError: typeof TimeoutError; - ProtocolError: typeof ProtocolError; -} - -/** - * @deprecated Import error classes directly. - * - * Puppeteer methods might throw errors if they are unable to fulfill a request. - * For example, `page.waitForSelector(selector[, options])` might fail if the - * selector doesn't match any nodes during the given timeframe. - * - * For certain types of errors Puppeteer uses specific error classes. These - * classes are available via `puppeteer.errors`. - * - * @example - * An example of handling a timeout error: - * - * ```ts - * try { - * await page.waitForSelector('.foo'); - * } catch (e) { - * if (e instanceof TimeoutError) { - * // Do something if this is a timeout. - * } - * } - * ``` - * - * @public - */ -export const errors: PuppeteerErrors = Object.freeze({ - TimeoutError, - ProtocolError, -}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.test.ts index cf05ef6700..f3875e99e8 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.test.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.test.ts @@ -19,7 +19,7 @@ describe('EventEmitter', () => { }); describe('on', () => { - const onTests = (methodName: 'on' | 'addListener'): void => { + const onTests = (methodName: 'on'): void => { it(`${methodName}: adds an event listener that is fired when the event is emitted`, () => { const listener = sinon.spy(); emitter[methodName]('foo', listener); @@ -43,12 +43,10 @@ describe('EventEmitter', () => { }); }; onTests('on'); - // we support addListener for legacy reasons - onTests('addListener'); }); describe('off', () => { - const offTests = (methodName: 'off' | 'removeListener'): void => { + const offTests = (methodName: 'off'): void => { it(`${methodName}: removes the listener so it is no longer called`, () => { const listener = sinon.spy(); emitter.on('foo', listener); @@ -67,8 +65,6 @@ describe('EventEmitter', () => { }); }; offTests('off'); - // we support removeListener for legacy reasons - offTests('removeListener'); }); describe('once', () => { diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.ts index 4a8bcb801f..0aace8b2eb 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.ts @@ -27,18 +27,6 @@ export interface CommonEventEmitter<Events extends Record<EventType, unknown>> { handler?: Handler<Events[Key]> ): this; emit<Key extends keyof Events>(type: Key, event: Events[Key]): boolean; - /* To maintain parity with the built in NodeJS event emitter which uses removeListener - * rather than `off`. - * If you're implementing new code you should use `off`. - */ - addListener<Key extends keyof Events>( - type: Key, - handler: Handler<Events[Key]> - ): this; - removeListener<Key extends keyof Events>( - type: Key, - handler: Handler<Events[Key]> - ): this; once<Key extends keyof Events>( type: Key, handler: Handler<Events[Key]> @@ -149,30 +137,6 @@ export class EventEmitter<Events extends Record<EventType, unknown>> } /** - * Remove an event listener. - * - * @deprecated please use {@link EventEmitter.off} instead. - */ - removeListener<Key extends keyof EventsWithWildcard<Events>>( - type: Key, - handler: Handler<EventsWithWildcard<Events>[Key]> - ): this { - return this.off(type, handler); - } - - /** - * Add an event listener. - * - * @deprecated please use {@link EventEmitter.on} instead. - */ - addListener<Key extends keyof EventsWithWildcard<Events>>( - type: Key, - handler: Handler<EventsWithWildcard<Events>[Key]> - ): this { - return this.on(type, handler); - } - - /** * Like `on` but the listener will only be fired once and then it will be removed. * @param type - the event you'd like to listen to * @param handler - the handler function to run when the event occurs diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/PDFOptions.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/PDFOptions.ts index 7cae9191a9..f87ec6817b 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/common/PDFOptions.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/PDFOptions.ts @@ -158,11 +158,23 @@ export interface PDFOptions { omitBackground?: boolean; /** * Generate tagged (accessible) PDF. - * @defaultValue `false` + * @defaultValue `true` * @experimental */ tagged?: boolean; /** + * Generate document outline. + * + * @remarks + * If this is enabled the PDF will also be tagged (accessible) + * Currently only works in old Headless (headless = 'shell') + * crbug/840455#c47 + * + * @defaultValue `false` + * @experimental + */ + outline?: boolean; + /** * Timeout in milliseconds. Pass `0` to disable timeout. * @defaultValue `30_000` */ diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/ScriptInjector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/ScriptInjector.ts index 0264c9175f..d505d6c5ff 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/common/ScriptInjector.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/ScriptInjector.ts @@ -1,3 +1,8 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ import {source as injectedSource} from '../generated/injected.js'; /** diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/common.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/common.ts index 6ef8925605..bf4274fcf1 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/common/common.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/common.ts @@ -10,12 +10,12 @@ export * from './Configuration.js'; export * from './ConnectionTransport.js'; export * from './ConnectOptions.js'; export * from './ConsoleMessage.js'; +export * from './Cookie.js'; export * from './CustomQueryHandler.js'; export * from './Debug.js'; export * from './Device.js'; export * from './Errors.js'; export * from './EventEmitter.js'; -export * from './fetch.js'; export * from './FileChooser.js'; export * from './GetQueryHandler.js'; export * from './HandleIterator.js'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/fetch.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/fetch.ts deleted file mode 100644 index 6c7a2b451c..0000000000 --- a/remote/test/puppeteer/packages/puppeteer-core/src/common/fetch.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * @license - * Copyright 2020 Google Inc. - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * Gets the global version if we're in the browser, else loads the node-fetch module. - * - * @internal - */ -export const getFetch = async (): Promise<typeof fetch> => { - return (globalThis as any).fetch || (await import('cross-fetch')).fetch; -}; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/util.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/util.ts index 2c8f76f664..f84453c612 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/common/util.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/util.ts @@ -5,13 +5,19 @@ */ import type FS from 'fs/promises'; -import type {Readable} from 'stream'; -import {map, NEVER, Observable, timer} from '../../third_party/rxjs/rxjs.js'; +import type {OperatorFunction} from '../../third_party/rxjs/rxjs.js'; +import { + filter, + from, + map, + mergeMap, + NEVER, + Observable, + timer, +} from '../../third_party/rxjs/rxjs.js'; import type {CDPSession} from '../api/CDPSession.js'; -import {isNode} from '../environment.js'; import {assert} from '../util/assert.js'; -import {isErrorLike} from '../util/ErrorLike.js'; import {debug} from './Debug.js'; import {TimeoutError} from './Errors.js'; @@ -209,29 +215,39 @@ export async function importFSPromises(): Promise<typeof FS> { * @internal */ export async function getReadableAsBuffer( - readable: Readable, + readable: ReadableStream<Uint8Array>, path?: string ): Promise<Buffer | null> { - const buffers = []; + const buffers: Uint8Array[] = []; + const reader = readable.getReader(); if (path) { const fs = await importFSPromises(); const fileHandle = await fs.open(path, 'w+'); try { - for await (const chunk of readable) { - buffers.push(chunk); - await fileHandle.writeFile(chunk); + while (true) { + const {done, value} = await reader.read(); + if (done) { + break; + } + buffers.push(value); + await fileHandle.writeFile(value); } } finally { await fileHandle.close(); } } else { - for await (const chunk of readable) { - buffers.push(chunk); + while (true) { + const {done, value} = await reader.read(); + if (done) { + break; + } + buffers.push(value); } } try { return Buffer.concat(buffers); } catch (error) { + debugError(error); return null; } } @@ -239,39 +255,34 @@ export async function getReadableAsBuffer( /** * @internal */ + +/** + * @internal + */ export async function getReadableFromProtocolStream( client: CDPSession, handle: string -): Promise<Readable> { - // TODO: Once Node 18 becomes the lowest supported version, we can migrate to - // ReadableStream. - if (!isNode) { - throw new Error('Cannot create a stream outside of Node.js environment.'); - } +): Promise<ReadableStream<Uint8Array>> { + return new ReadableStream({ + async pull(controller) { + function getUnit8Array(data: string, isBase64: boolean): Uint8Array { + if (isBase64) { + return Uint8Array.from(atob(data), m => { + return m.codePointAt(0)!; + }); + } + const encoder = new TextEncoder(); + return encoder.encode(data); + } - const {Readable} = await import('stream'); + const {data, base64Encoded, eof} = await client.send('IO.read', { + handle, + }); - let eof = false; - return new Readable({ - async read(size: number) { + controller.enqueue(getUnit8Array(data, base64Encoded ?? false)); if (eof) { - return; - } - - try { - const response = await client.send('IO.read', {handle, size}); - this.push(response.data, response.base64Encoded ? 'base64' : undefined); - if (response.eof) { - eof = true; - await client.send('IO.close', {handle}); - this.push(null); - } - } catch (error) { - if (isErrorLike(error)) { - this.destroy(error); - return; - } - throw error; + await client.send('IO.close', {handle}); + controller.close(); } }, }); @@ -349,7 +360,8 @@ export function parsePDFOptions( pageRanges: '', preferCSSPageSize: false, omitBackground: false, - tagged: false, + outline: false, + tagged: true, }; let width = 8.5; @@ -375,6 +387,11 @@ export function parsePDFOptions( convertPrintParameterToInches(options.margin?.right, lengthUnit) || 0, }; + // Quirk https://bugs.chromium.org/p/chromium/issues/detail?id=840455#c44 + if (options.outline) { + options.tagged = true; + } + return { ...defaults, ...options, @@ -445,3 +462,21 @@ export function fromEmitterEvent< }; }); } + +/** + * @internal + */ +export function filterAsync<T>( + predicate: (value: T) => boolean | PromiseLike<boolean> +): OperatorFunction<T, T> { + return mergeMap<T, Observable<T>>((value): Observable<T> => { + return from(Promise.resolve(predicate(value))).pipe( + filter(isMatch => { + return isMatch; + }), + map(() => { + return value; + }) + ); + }); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/util.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/util.ts index 34fe8f7748..cfc209ba57 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/injected/util.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/util.ts @@ -1,3 +1,8 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ const HIDDEN_VISIBILITY_VALUES = ['hidden', 'collapse']; /** diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.ts index 51d5a19983..0cec3de9ae 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.ts @@ -36,25 +36,6 @@ export class ChromeLauncher extends ProductLauncher { } override launch(options: PuppeteerNodeLaunchOptions = {}): Promise<Browser> { - const headless = options.headless ?? true; - if ( - headless === true && - this.puppeteer.configuration.logLevel === 'warn' && - !Boolean(process.env['PUPPETEER_DISABLE_HEADLESS_WARNING']) - ) { - console.warn( - [ - '\x1B[1m\x1B[43m\x1B[30m', - 'Puppeteer old Headless deprecation warning:\x1B[0m\x1B[33m', - ' In the near future `headless: true` will default to the new Headless mode', - ' for Chrome instead of the old Headless implementation. For more', - ' information, please see https://developer.chrome.com/articles/new-headless/.', - ' Consider opting in early by passing `headless: "new"` to `puppeteer.launch()`', - ' If you encounter any bugs, please report them to https://github.com/puppeteer/puppeteer/issues/new/choose.\x1B[0m\n', - ].join('\n ') - ); - } - if ( this.puppeteer.configuration.logLevel === 'warn' && process.platform === 'darwin' && @@ -231,6 +212,7 @@ export class ChromeLauncher extends ProductLauncher { '--disable-sync', '--enable-automation', '--export-tagged-pdf', + '--generate-pdf-document-outline', '--force-color-profile=srgb', '--metrics-recording-only', '--no-first-run', @@ -253,7 +235,7 @@ export class ChromeLauncher extends ProductLauncher { } if (headless) { chromeArguments.push( - headless === 'new' ? '--headless=new' : '--headless', + headless === 'shell' ? '--headless' : '--headless=new', '--hide-scrollbars', '--mute-audio' ); @@ -271,7 +253,7 @@ export class ChromeLauncher extends ProductLauncher { override executablePath( channel?: ChromeReleaseChannel, - headless?: boolean | 'new' + headless?: boolean | 'shell' ): string { if (channel) { return computeSystemExecutablePath({ diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.ts index eb4f375fc7..1af09192ec 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.ts @@ -43,12 +43,20 @@ export class FirefoxLauncher extends ProductLauncher { return { ...extraPrefsFirefox, ...(protocol === 'webDriverBiDi' - ? {} + ? { + // Only enable the WebDriver BiDi protocol + 'remote.active-protocols': 1, + } : { // Do not close the window when the last tab gets closed 'browser.tabs.closeWindowWithLastTab': false, + // Prevent various error message on the console + // jest-puppeteer asserts that no error message is emitted by the console + 'network.cookie.cookieBehavior': 0, // Temporarily force disable BFCache in parent (https://bit.ly/bug-1732263) 'fission.bfcacheInParent': false, + // Only enable the CDP protocol + 'remote.active-protocols': 2, }), // Force all web content to use a single content process. TODO: remove // this once Firefox supports mouse event dispatch from the main frame diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/LaunchOptions.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/LaunchOptions.ts index 28e0b595df..d7717e45c5 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/node/LaunchOptions.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/LaunchOptions.ts @@ -17,13 +17,18 @@ export interface BrowserLaunchArgumentOptions { * Whether to run the browser in headless mode. * * @remarks - * In the future `headless: true` will be equivalent to `headless: 'new'`. - * You can read more about the change {@link https://developer.chrome.com/articles/new-headless/ | here}. - * Consider opting in early by setting the value to `"new"`. + * + * - `true` launches the browser in the + * {@link https://developer.chrome.com/articles/new-headless/ | new headless} + * mode. + * + * - `'shell'` launches + * {@link https://developer.chrome.com/blog/chrome-headless-shell | shell} + * known as the old headless mode. * * @defaultValue `true` */ - headless?: boolean | 'new'; + headless?: boolean | 'shell'; /** * Path to a user data directory. * {@link https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/user_data_dir.md | see the Chromium docs} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/ProductLauncher.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/ProductLauncher.ts index ab3432cd3a..2da07e8f7c 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/node/ProductLauncher.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/ProductLauncher.ts @@ -393,7 +393,7 @@ export abstract class ProductLauncher { /** * @internal */ - protected resolveExecutablePath(headless?: boolean | 'new'): string { + protected resolveExecutablePath(headless?: boolean | 'shell'): string { let executablePath = this.puppeteer.configuration.executablePath; if (executablePath) { if (!existsSync(executablePath)) { @@ -404,10 +404,10 @@ export abstract class ProductLauncher { return executablePath; } - function productToBrowser(product?: Product, headless?: boolean | 'new') { + function productToBrowser(product?: Product, headless?: boolean | 'shell') { switch (product) { case 'chrome': - if (headless === true) { + if (headless === 'shell') { return InstalledBrowser.CHROMEHEADLESSSHELL; } return InstalledBrowser.CHROME; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/PuppeteerNode.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/PuppeteerNode.ts index e50e09acdb..726ee24cbb 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/node/PuppeteerNode.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/PuppeteerNode.ts @@ -223,7 +223,7 @@ export class PuppeteerNode extends Puppeteer { * @internal */ get defaultDownloadPath(): string | undefined { - return this.configuration.downloadPath ?? this.configuration.cacheDirectory; + return this.configuration.cacheDirectory; } /** @@ -283,8 +283,7 @@ export class PuppeteerNode extends Puppeteer { throw new Error('The current platform is not supported.'); } - const cacheDir = - this.configuration.downloadPath ?? this.configuration.cacheDirectory!; + const cacheDir = this.configuration.cacheDirectory!; const installedBrowsers = await getInstalledBrowsers({ cacheDir, }); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/revisions.ts b/remote/test/puppeteer/packages/puppeteer-core/src/revisions.ts index 37360204d8..c543cd9517 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/revisions.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/revisions.ts @@ -8,7 +8,7 @@ * @internal */ export const PUPPETEER_REVISIONS = Object.freeze({ - chrome: '121.0.6167.85', - 'chrome-headless-shell': '121.0.6167.85', + chrome: '122.0.6261.94', + 'chrome-headless-shell': '122.0.6261.94', firefox: 'latest', }); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/Deferred.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/Deferred.ts index 0dfb013bb3..6699ace36d 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/util/Deferred.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/Deferred.ts @@ -1,3 +1,8 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ import {TimeoutError} from '../common/Errors.js'; /** diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/Mutex.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/Mutex.ts index 9498bac306..f789837def 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/util/Mutex.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/Mutex.ts @@ -1,3 +1,8 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ import {Deferred} from './Deferred.js'; import {disposeSymbol} from './disposable.js'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.test.ts index 4cdaf15d5b..bc476b0153 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.test.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.test.ts @@ -9,7 +9,9 @@ import {describe, it} from 'node:test'; import expect from 'expect'; import sinon from 'sinon'; -import {invokeAtMostOnceForArguments} from './decorators.js'; +import {EventEmitter} from '../common/EventEmitter.js'; + +import {bubble, invokeAtMostOnceForArguments} from './decorators.js'; describe('decorators', function () { describe('invokeAtMostOnceForArguments', () => { @@ -76,4 +78,48 @@ describe('decorators', function () { }).toThrow(); }); }); + + describe('bubble', () => { + it('should work', () => { + class Test extends EventEmitter<any> { + @bubble() + accessor field = new EventEmitter(); + } + + const t = new Test(); + let a = false; + t.on('a', (value: boolean) => { + a = value; + }); + + t.field.emit('a', true); + expect(a).toBeTruthy(); + + // Set a new emitter. + t.field = new EventEmitter(); + a = false; + + t.field.emit('a', true); + expect(a).toBeTruthy(); + }); + + it('should not bubble down', () => { + class Test extends EventEmitter<any> { + @bubble() + accessor field = new EventEmitter<any>(); + } + + const t = new Test(); + let a = false; + t.field.on('a', (value: boolean) => { + a = value; + }); + + t.emit('a', true); + expect(a).toBeFalsy(); + + t.field.emit('a', true); + expect(a).toBeTruthy(); + }); + }); }); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.ts index af21c5fe29..c4dc3b6c0d 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type {EventType} from '../common/EventEmitter.js'; +import type {EventEmitter} from '../common/EventEmitter.js'; import type {Disposed, Moveable} from '../common/types.js'; import {asyncDisposeSymbol, disposeSymbol} from './disposable.js'; @@ -138,3 +140,67 @@ export function guarded<T extends object>( }; }; } + +const bubbleHandlers = new WeakMap<object, Map<any, any>>(); + +/** + * Event emitter fields marked with `bubble` will have their events bubble up + * the field owner. + */ +// The type is too complicated to type. +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export function bubble<T extends EventType[]>(events?: T) { + return <This extends EventEmitter<any>, Value extends EventEmitter<any>>( + {set, get}: ClassAccessorDecoratorTarget<This, Value>, + context: ClassAccessorDecoratorContext<This, Value> + ): ClassAccessorDecoratorResult<This, Value> => { + context.addInitializer(function () { + const handlers = bubbleHandlers.get(this) ?? new Map(); + if (handlers.has(events)) { + return; + } + + const handler = + events !== undefined + ? (type: EventType, event: unknown) => { + if (events.includes(type)) { + this.emit(type, event); + } + } + : (type: EventType, event: unknown) => { + this.emit(type, event); + }; + + handlers.set(events, handler); + bubbleHandlers.set(this, handlers); + }); + return { + set(emitter) { + const handler = bubbleHandlers.get(this)!.get(events)!; + + // In case we are re-setting. + const oldEmitter = get.call(this); + if (oldEmitter !== undefined) { + oldEmitter.off('*', handler); + } + + if (emitter === undefined) { + return; + } + emitter.on('*', handler); + set.call(this, emitter); + }, + // @ts-expect-error -- TypeScript incorrectly types init to require a + // return. + init(emitter) { + if (emitter === undefined) { + return; + } + const handler = bubbleHandlers.get(this)!.get(events)!; + + emitter.on('*', handler); + return emitter; + }, + }; + }; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/third_party/mitt/mitt.ts b/remote/test/puppeteer/packages/puppeteer-core/third_party/mitt/mitt.ts index c20aaa8342..1d522c780b 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/third_party/mitt/mitt.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/third_party/mitt/mitt.ts @@ -1,8 +1,3 @@ -/** - * @license - * Copyright 2022 Google Inc. - * SPDX-License-Identifier: Apache-2.0 - */ - +// esline-disable rulesdir/check-license export * from 'mitt'; export {default as default} from 'mitt'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/third_party/rxjs/rxjs.ts b/remote/test/puppeteer/packages/puppeteer-core/third_party/rxjs/rxjs.ts index b8b64788ae..6f4f844f5d 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/third_party/rxjs/rxjs.ts +++ b/remote/test/puppeteer/packages/puppeteer-core/third_party/rxjs/rxjs.ts @@ -1,13 +1,11 @@ -/** - * @license - * Copyright 2023 Google Inc. - * SPDX-License-Identifier: Apache-2.0 - */ +// esline-disable rulesdir/check-license export { bufferCount, catchError, + combineLatest, concat, concatMap, + debounceTime, defaultIfEmpty, defer, delay, @@ -16,6 +14,7 @@ export { first, firstValueFrom, forkJoin, + delayWhen, from, fromEvent, identity, @@ -24,6 +23,7 @@ export { map, merge, mergeMap, + mergeScan, NEVER, noop, Observable, @@ -31,9 +31,11 @@ export { pipe, race, raceWith, + ReplaySubject, retry, startWith, switchMap, + take, takeUntil, tap, throwIfEmpty, @@ -42,20 +44,3 @@ export { } from 'rxjs'; export type * from 'rxjs'; - -import {filter, from, map, mergeMap, type Observable} from 'rxjs'; - -export function filterAsync<T>( - predicate: (value: T) => boolean | PromiseLike<boolean> -) { - return mergeMap<T, Observable<T>>(value => { - return from(Promise.resolve(predicate(value))).pipe( - filter(isMatch => { - return isMatch; - }), - map(() => { - return value; - }) - ); - }); -} diff --git a/remote/test/puppeteer/packages/puppeteer-core/third_party/tsconfig.json b/remote/test/puppeteer/packages/puppeteer-core/third_party/tsconfig.json index 25c438c57d..cfe3a26f4c 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/third_party/tsconfig.json +++ b/remote/test/puppeteer/packages/puppeteer-core/third_party/tsconfig.json @@ -3,6 +3,6 @@ "compilerOptions": { "declarationMap": false, "outDir": "../lib/esm/third_party", - "sourceMap": false, - }, + "sourceMap": false + } } diff --git a/remote/test/puppeteer/packages/puppeteer-core/tsconfig.json b/remote/test/puppeteer/packages/puppeteer-core/tsconfig.json index b662532a01..a219f8b704 100644 --- a/remote/test/puppeteer/packages/puppeteer-core/tsconfig.json +++ b/remote/test/puppeteer/packages/puppeteer-core/tsconfig.json @@ -3,6 +3,6 @@ "files": [], "references": [ {"path": "src/tsconfig.esm.json"}, - {"path": "src/tsconfig.cjs.json"}, - ], + {"path": "src/tsconfig.cjs.json"} + ] } |