diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /remote/test/puppeteer/packages | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'remote/test/puppeteer/packages')
298 files changed, 53082 insertions, 0 deletions
diff --git a/remote/test/puppeteer/packages/browsers/.mocharc.cjs b/remote/test/puppeteer/packages/browsers/.mocharc.cjs new file mode 100644 index 0000000000..50110ff654 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/.mocharc.cjs @@ -0,0 +1,8 @@ +module.exports = { + logLevel: 'debug', + spec: 'test/build/**/*.spec.js', + require: ['./test/build/mocha-utils.js'], + exit: !!process.env.CI, + reporter: 'spec', + timeout: 10_000, +}; diff --git a/remote/test/puppeteer/packages/browsers/CHANGELOG.md b/remote/test/puppeteer/packages/browsers/CHANGELOG.md new file mode 100644 index 0000000000..abfb45bb6d --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/CHANGELOG.md @@ -0,0 +1,282 @@ +# Changelog + +## [1.9.1](https://github.com/puppeteer/puppeteer/compare/browsers-v1.9.0...browsers-v1.9.1) (2024-01-04) + + +### Bug Fixes + +* disable GFX sanity window for Firefox and enable WebDriver BiDi CI jobs for Windows ([#11578](https://github.com/puppeteer/puppeteer/issues/11578)) ([e41a265](https://github.com/puppeteer/puppeteer/commit/e41a2656d9e1f3f037b298457fbd6c6e08f5a371)) + +## [1.9.0](https://github.com/puppeteer/puppeteer/compare/browsers-v1.8.0...browsers-v1.9.0) (2023-12-05) + + +### Features + +* implement the Puppeteer CLI ([#11344](https://github.com/puppeteer/puppeteer/issues/11344)) ([53fb69b](https://github.com/puppeteer/puppeteer/commit/53fb69bf7f2bf06fa4fd7bb6d3cf21382386f6e7)) + + +### Bug Fixes + +* ng-schematics install Windows ([#11487](https://github.com/puppeteer/puppeteer/issues/11487)) ([02af748](https://github.com/puppeteer/puppeteer/commit/02af7482d9bf2163b90dfe623b0af18c513d5a3b)) +* remove CDP-specific preferences from defaults for Firefox ([#11477](https://github.com/puppeteer/puppeteer/issues/11477)) ([f8c9469](https://github.com/puppeteer/puppeteer/commit/f8c94699c7f5b15c7bb96f299c2c8217d74230cd)) + +## [1.8.0](https://github.com/puppeteer/puppeteer/compare/browsers-v1.7.1...browsers-v1.8.0) (2023-10-20) + + +### Features + +* enable tab targets ([#11099](https://github.com/puppeteer/puppeteer/issues/11099)) ([8324c16](https://github.com/puppeteer/puppeteer/commit/8324c1634883d97ed83f32a1e62acc9b5e64e0bd)) + +## [1.7.1](https://github.com/puppeteer/puppeteer/compare/browsers-v1.7.0...browsers-v1.7.1) (2023-09-13) + + +### Bug Fixes + +* use supported node range for types ([#10896](https://github.com/puppeteer/puppeteer/issues/10896)) ([2d851c1](https://github.com/puppeteer/puppeteer/commit/2d851c1398e5efcdabdb5304dc78e68cbd3fadd2)) + +## [1.7.0](https://github.com/puppeteer/puppeteer/compare/browsers-v1.6.0...browsers-v1.7.0) (2023-08-18) + + +### Features + +* support chrome-headless-shell ([#10739](https://github.com/puppeteer/puppeteer/issues/10739)) ([416843b](https://github.com/puppeteer/puppeteer/commit/416843ba68aaab7ae14bbc74c2ac705e877e91a7)) + +## [1.6.0](https://github.com/puppeteer/puppeteer/compare/browsers-v1.5.1...browsers-v1.6.0) (2023-08-10) + + +### Features + +* allow installing chrome/chromedriver by milestone and version prefix ([#10720](https://github.com/puppeteer/puppeteer/issues/10720)) ([bec2357](https://github.com/puppeteer/puppeteer/commit/bec2357aeedda42cfaf3096c6293c2f49ceb825e)) + +## [1.5.1](https://github.com/puppeteer/puppeteer/compare/browsers-v1.5.0...browsers-v1.5.1) (2023-08-08) + + +### Bug Fixes + +* add buildId to archive path ([#10699](https://github.com/puppeteer/puppeteer/issues/10699)) ([21461b0](https://github.com/puppeteer/puppeteer/commit/21461b02c65062f5ed240e8ea357e9b7f2d26b32)) + +## [1.5.0](https://github.com/puppeteer/puppeteer/compare/browsers-v1.4.6...browsers-v1.5.0) (2023-08-02) + + +### Features + +* add executablePath to InstalledBrowser ([#10594](https://github.com/puppeteer/puppeteer/issues/10594)) ([87522e7](https://github.com/puppeteer/puppeteer/commit/87522e778a6487111931458755e701f1c4b717d9)) + + +### Bug Fixes + +* clear pending TLS socket handle ([#10667](https://github.com/puppeteer/puppeteer/issues/10667)) ([87bd791](https://github.com/puppeteer/puppeteer/commit/87bd791ddc10c247bf154bbac2aa912327a4cf20)) +* remove typescript from peer dependencies ([#10593](https://github.com/puppeteer/puppeteer/issues/10593)) ([c60572a](https://github.com/puppeteer/puppeteer/commit/c60572a1ca36ea5946d287bd629ac31798d84cb0)) + +## [1.4.6](https://github.com/puppeteer/puppeteer/compare/browsers-v1.4.5...browsers-v1.4.6) (2023-07-20) + + +### Bug Fixes + +* restore proxy-agent ([#10569](https://github.com/puppeteer/puppeteer/issues/10569)) ([bf6304e](https://github.com/puppeteer/puppeteer/commit/bf6304e064eb52d39d7f993f1ea868da06f7f006)) + +## [1.4.5](https://github.com/puppeteer/puppeteer/compare/browsers-v1.4.4...browsers-v1.4.5) (2023-07-13) + + +### Bug Fixes + +* stop relying on vm2 (via proxy agent) ([#10548](https://github.com/puppeteer/puppeteer/issues/10548)) ([4070cd6](https://github.com/puppeteer/puppeteer/commit/4070cd68b6d01fb9a1643da2662ce0b6f53cf37d)) + +## [1.4.4](https://github.com/puppeteer/puppeteer/compare/browsers-v1.4.3...browsers-v1.4.4) (2023-07-11) + + +### Bug Fixes + +* correctly parse the default buildId ([#10535](https://github.com/puppeteer/puppeteer/issues/10535)) ([c308266](https://github.com/puppeteer/puppeteer/commit/c3082661113b4b55534f25da86e3b261d3952953)) +* remove Chromium channels ([#10536](https://github.com/puppeteer/puppeteer/issues/10536)) ([c0dc8ad](https://github.com/puppeteer/puppeteer/commit/c0dc8ad8a82446752e29f98d8eee617b9a67c942)) + +## [1.4.3](https://github.com/puppeteer/puppeteer/compare/browsers-v1.4.2...browsers-v1.4.3) (2023-06-29) + + +### Bug Fixes + +* negative timeout doesn't break launch ([#10480](https://github.com/puppeteer/puppeteer/issues/10480)) ([6a89a2a](https://github.com/puppeteer/puppeteer/commit/6a89a2aadcaf683fe57f1e0e13886f1fa937e194)) + +## [1.4.2](https://github.com/puppeteer/puppeteer/compare/browsers-v1.4.1...browsers-v1.4.2) (2023-06-20) + + +### Bug Fixes + +* include src into published package ([#10415](https://github.com/puppeteer/puppeteer/issues/10415)) ([d1ffad0](https://github.com/puppeteer/puppeteer/commit/d1ffad059ae66104842b92dc814d362c123b9646)) + +## [1.4.1](https://github.com/puppeteer/puppeteer/compare/browsers-v1.4.0...browsers-v1.4.1) (2023-05-31) + + +### Bug Fixes + +* pass on the auth from the download URL ([#10271](https://github.com/puppeteer/puppeteer/issues/10271)) ([3a1f4f0](https://github.com/puppeteer/puppeteer/commit/3a1f4f0f8f5fe4e20c4ed69f5485a827a841cf54)) + +## [1.4.0](https://github.com/puppeteer/puppeteer/compare/browsers-v1.3.0...browsers-v1.4.0) (2023-05-24) + + +### Features + +* use proxy-agent to support various proxies ([#10227](https://github.com/puppeteer/puppeteer/issues/10227)) ([2c0bd54](https://github.com/puppeteer/puppeteer/commit/2c0bd54d2e3b778818b9b4b32f436778f571b918)) + +## [1.3.0](https://github.com/puppeteer/puppeteer/compare/browsers-v1.2.0...browsers-v1.3.0) (2023-05-15) + + +### Features + +* add ability to uninstall a browser ([#10179](https://github.com/puppeteer/puppeteer/issues/10179)) ([d388a6e](https://github.com/puppeteer/puppeteer/commit/d388a6edfd164548b008cb0d8e9cb5c0d03cdcda)) + + +### Bug Fixes + +* update the command name ([#10178](https://github.com/puppeteer/puppeteer/issues/10178)) ([ccbb82d](https://github.com/puppeteer/puppeteer/commit/ccbb82d9cd5b77f8262c143a5663fc1f9938a8c4)) + +## [1.2.0](https://github.com/puppeteer/puppeteer/compare/browsers-v1.1.0...browsers-v1.2.0) (2023-05-11) + + +### Features + +* support Chrome channels for ChromeDriver ([#10158](https://github.com/puppeteer/puppeteer/issues/10158)) ([e313b05](https://github.com/puppeteer/puppeteer/commit/e313b054e658887e2c062ea55d8ee99f3f4f3789)) + +## [1.1.0](https://github.com/puppeteer/puppeteer/compare/browsers-v1.0.1...browsers-v1.1.0) (2023-05-08) + + +### Features + +* support stable/dev/beta/canary keywords for chrome and chromium ([#10140](https://github.com/puppeteer/puppeteer/issues/10140)) ([90ed263](https://github.com/puppeteer/puppeteer/commit/90ed263eafb0ca0420ea1918d7c1f326eaa58e20)) + +## [1.0.1](https://github.com/puppeteer/puppeteer/compare/browsers-v1.0.0...browsers-v1.0.1) (2023-05-05) + + +### Bug Fixes + +* rename PUPPETEER_DOWNLOAD_HOST to PUPPETEER_DOWNLOAD_BASE_URL ([#10130](https://github.com/puppeteer/puppeteer/issues/10130)) ([9758cae](https://github.com/puppeteer/puppeteer/commit/9758cae029f90908c4b5340561d9c51c26aa2f21)) + +## [1.0.0](https://github.com/puppeteer/puppeteer/compare/browsers-v0.5.0...browsers-v1.0.0) (2023-05-02) + + +### ⚠ BREAKING CHANGES + +* drop support for node14 ([#10019](https://github.com/puppeteer/puppeteer/issues/10019)) +* switch to Chrome for Testing instead of Chromium ([#10054](https://github.com/puppeteer/puppeteer/issues/10054)) + +### Features + +* drop support for node14 ([#10019](https://github.com/puppeteer/puppeteer/issues/10019)) ([7405d65](https://github.com/puppeteer/puppeteer/commit/7405d6585aa09b240fbab09aa360674d4442b3d9)) +* switch to Chrome for Testing instead of Chromium ([#10054](https://github.com/puppeteer/puppeteer/issues/10054)) ([df4d60c](https://github.com/puppeteer/puppeteer/commit/df4d60c187aa11c4ad783827242e9511f4ec2aab)) + + +### Bug Fixes + +* add Host header when used with http_proxy ([#10080](https://github.com/puppeteer/puppeteer/issues/10080)) ([edbfff7](https://github.com/puppeteer/puppeteer/commit/edbfff7b04baffc29c01c37c595d6b3355c0dea0)) + +## [0.5.0](https://github.com/puppeteer/puppeteer/compare/browsers-v0.4.1...browsers-v0.5.0) (2023-04-21) + + +### Features + +* **browser:** add a method to get installed browsers ([#10057](https://github.com/puppeteer/puppeteer/issues/10057)) ([e16e2a9](https://github.com/puppeteer/puppeteer/commit/e16e2a97284f5e7ab4073f375254572a6a89e800)) + +## [0.4.1](https://github.com/puppeteer/puppeteer/compare/browsers-v0.4.0...browsers-v0.4.1) (2023-04-13) + + +### Bug Fixes + +* report install errors properly ([#10016](https://github.com/puppeteer/puppeteer/issues/10016)) ([7381229](https://github.com/puppeteer/puppeteer/commit/7381229a164e598e7523862f2438cd0cd1cd796a)) + +## [0.4.0](https://github.com/puppeteer/puppeteer/compare/browsers-v0.3.3...browsers-v0.4.0) (2023-04-06) + + +### Features + +* **browsers:** support downloading chromedriver ([#9990](https://github.com/puppeteer/puppeteer/issues/9990)) ([ef0fb5d](https://github.com/puppeteer/puppeteer/commit/ef0fb5d87299c604af2387ac1c72be317c50316d)) + +## [0.3.3](https://github.com/puppeteer/puppeteer/compare/browsers-v0.3.2...browsers-v0.3.3) (2023-04-06) + + +### Bug Fixes + +* **browsers:** update package json ([#9968](https://github.com/puppeteer/puppeteer/issues/9968)) ([817288c](https://github.com/puppeteer/puppeteer/commit/817288cd901121ddc8a44226eda689bb784cee61)) +* **browsers:** various fixes and improvements ([#9966](https://github.com/puppeteer/puppeteer/issues/9966)) ([f1211cb](https://github.com/puppeteer/puppeteer/commit/f1211cbec091ec669de019aeb7fb4f011a81c1d7)) +* consider downloadHost as baseUrl ([#9973](https://github.com/puppeteer/puppeteer/issues/9973)) ([05a44af](https://github.com/puppeteer/puppeteer/commit/05a44afe5affcac9fe0f0a2e83f17807c99b2f0c)) + +## [0.3.2](https://github.com/puppeteer/puppeteer/compare/browsers-v0.3.1...browsers-v0.3.2) (2023-04-03) + + +### Bug Fixes + +* typo in the browsers package ([#9957](https://github.com/puppeteer/puppeteer/issues/9957)) ([c780384](https://github.com/puppeteer/puppeteer/commit/c7803844cf10b6edaa2da83134029b7acf5b45b2)) + +## [0.3.1](https://github.com/puppeteer/puppeteer/compare/browsers-v0.3.0...browsers-v0.3.1) (2023-03-29) + + +### Bug Fixes + +* bump @puppeteer/browsers ([#9938](https://github.com/puppeteer/puppeteer/issues/9938)) ([2a29d30](https://github.com/puppeteer/puppeteer/commit/2a29d30d1790b47c99f8d196b3844364d351acbd)) + +## [0.3.0](https://github.com/puppeteer/puppeteer/compare/browsers-v0.2.0...browsers-v0.3.0) (2023-03-27) + + +### Features + +* update Chrome browser binaries ([#9917](https://github.com/puppeteer/puppeteer/issues/9917)) ([fcb233c](https://github.com/puppeteer/puppeteer/commit/fcb233ce949f5f716aee39253e910104b04aa000)) + +## [0.2.0](https://github.com/puppeteer/puppeteer/compare/browsers-v0.1.1...browsers-v0.2.0) (2023-03-24) + + +### Features + +* implement a command to clear the cache ([#9868](https://github.com/puppeteer/puppeteer/issues/9868)) ([b8d38cb](https://github.com/puppeteer/puppeteer/commit/b8d38cb05f7eedf554ed46f2f7428b621197d1cc)) + +## [0.1.1](https://github.com/puppeteer/puppeteer/compare/browsers-v0.1.0...browsers-v0.1.1) (2023-03-14) + + +### Bug Fixes + +* export ChromeReleaseChannel ([#9851](https://github.com/puppeteer/puppeteer/issues/9851)) ([3e7a514](https://github.com/puppeteer/puppeteer/commit/3e7a514e556ddb4306aa3c15f24c512beaac65f4)) + +## [0.1.0](https://github.com/puppeteer/puppeteer/compare/browsers-v0.0.5...browsers-v0.1.0) (2023-03-14) + + +### Features + +* implement system channels for chrome in browsers ([#9844](https://github.com/puppeteer/puppeteer/issues/9844)) ([dec48a9](https://github.com/puppeteer/puppeteer/commit/dec48a95923e21a054c1d70d22c14001a0150293)) + + +### Bug Fixes + +* add browsers entry point ([#9846](https://github.com/puppeteer/puppeteer/issues/9846)) ([1a1e79d](https://github.com/puppeteer/puppeteer/commit/1a1e79d046ccad6fe843aa219501c17da08bc498)) + +## [0.0.5](https://github.com/puppeteer/puppeteer/compare/browsers-v0.0.4...browsers-v0.0.5) (2023-03-07) + + +### Bug Fixes + +* change the install output to include the executable path ([#9797](https://github.com/puppeteer/puppeteer/issues/9797)) ([8cca7bb](https://github.com/puppeteer/puppeteer/commit/8cca7bb7a2a1cdf62919d9c7eca62d6774e698db)) + +## [0.0.4](https://github.com/puppeteer/puppeteer/compare/browsers-v0.0.3...browsers-v0.0.4) (2023-03-06) + + +### Features + +* browsers: recognize chromium as a valid browser ([#9760](https://github.com/puppeteer/puppeteer/issues/9760)) ([04247a4](https://github.com/puppeteer/puppeteer/commit/04247a4e00b43683977bd8aa309d493eee663735)) + +## [0.0.3](https://github.com/puppeteer/puppeteer/compare/browsers-v0.0.2...browsers-v0.0.3) (2023-02-22) + + +### Bug Fixes + +* define options per command ([#9733](https://github.com/puppeteer/puppeteer/issues/9733)) ([8bae054](https://github.com/puppeteer/puppeteer/commit/8bae0545b7321d398dae3f522952dd981111587e)) + +## [0.0.2](https://github.com/puppeteer/puppeteer/compare/browsers-v0.0.1...browsers-v0.0.2) (2023-02-22) + + +### Bug Fixes + +* permissions for the browser CLI ([#9731](https://github.com/puppeteer/puppeteer/issues/9731)) ([e944931](https://github.com/puppeteer/puppeteer/commit/e944931de22726f35c5c83052892f8ab4667b035)) + +## 0.0.1 (2023-02-22) + + +### Features + +* initial release of browsers ([#9722](https://github.com/puppeteer/puppeteer/issues/9722)) ([#9727](https://github.com/puppeteer/puppeteer/issues/9727)) ([86a2d1d](https://github.com/puppeteer/puppeteer/commit/86a2d1dd3b2c024b886c6280e08a2d7dc8caabc5)) diff --git a/remote/test/puppeteer/packages/browsers/README.md b/remote/test/puppeteer/packages/browsers/README.md new file mode 100644 index 0000000000..f5342126c6 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/README.md @@ -0,0 +1,28 @@ +# @puppeteer/browsers + +Manage and launch browsers/drivers from a CLI or programmatically. + +## CLI + +Use `npx` to run the CLI: + +```bash +npx @puppeteer/browsers --help +``` + +CLI help will provide all documentation you need to use the CLI. + +```bash +npx @puppeteer/browsers --help # help for all commands +npx @puppeteer/browsers install --help # help for the install command +npx @puppeteer/browsers launch --help # help for the launch command +``` + +## Known limitations + +1. We support installing and running Firefox, Chrome and Chromium. The `latest`, `beta`, `dev`, `canary`, `stable` keywords are only supported for the install command. For the `launch` command you need to specify an exact build ID. The build ID is provided by the `install` command (see `npx @puppeteer/browsers install --help` for the format). +2. Launching the system browsers is only possible for Chrome/Chromium. + +## API + +The programmatic API allows installing and launching browsers from your code. See the `test` folder for examples on how to use the `install`, `canInstall`, `launch`, `computeExecutablePath`, `computeSystemExecutablePath` and other methods. diff --git a/remote/test/puppeteer/packages/browsers/api-extractor.docs.json b/remote/test/puppeteer/packages/browsers/api-extractor.docs.json new file mode 100644 index 0000000000..6a41a3b59c --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/api-extractor.docs.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "<projectFolder>/lib/esm/main.d.ts", + + "extends": "./api-extractor.json", + + "dtsRollup": { + "enabled": false + }, + + "docModel": { + "enabled": true, + "apiJsonFilePath": "<projectFolder>/../../docs/<unscopedPackageName>.api.json" + } +} diff --git a/remote/test/puppeteer/packages/browsers/api-extractor.json b/remote/test/puppeteer/packages/browsers/api-extractor.json new file mode 100644 index 0000000000..da1caae622 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/api-extractor.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "<projectFolder>/lib/esm/main.d.ts", + "bundledPackages": [], + + "apiReport": { + "enabled": false + }, + + "docModel": { + "enabled": false + }, + + "tsdocMetadata": { + "enabled": false + }, + + "messages": { + "compilerMessageReporting": { + "default": { + "logLevel": "warning" + } + }, + + "extractorMessageReporting": { + "ae-internal-missing-underscore": { + "logLevel": "none" + }, + "default": { + "logLevel": "warning" + } + }, + + "tsdocMessageReporting": { + "default": { + "logLevel": "warning" + } + } + } +} diff --git a/remote/test/puppeteer/packages/browsers/package.json b/remote/test/puppeteer/packages/browsers/package.json new file mode 100644 index 0000000000..45de79abb8 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/package.json @@ -0,0 +1,113 @@ +{ + "name": "@puppeteer/browsers", + "version": "1.9.1", + "description": "Download and launch browsers", + "scripts": { + "build:docs": "wireit", + "build": "wireit", + "build:test": "wireit", + "clean": "../../tools/clean.js", + "test": "wireit" + }, + "type": "commonjs", + "bin": "lib/cjs/main-cli.js", + "main": "./lib/cjs/main.js", + "exports": { + "import": "./lib/esm/main.js", + "require": "./lib/cjs/main.js" + }, + "wireit": { + "build": { + "command": "tsc -b && tsx ../../tools/chmod.ts 755 lib/cjs/main-cli.js lib/esm/main-cli.js", + "files": [ + "src/**/*.ts", + "tsconfig.json" + ], + "clean": "if-file-deleted", + "output": [ + "lib/**", + "!lib/esm/package.json" + ], + "dependencies": [ + "generate:package-json" + ] + }, + "generate:package-json": { + "command": "tsx ../../tools/generate_module_package_json.ts lib/esm/package.json", + "files": [ + "../../tools/generate_module_package_json.ts" + ], + "output": [ + "lib/esm/package.json" + ] + }, + "build:docs": { + "command": "api-extractor run --local --config \"./api-extractor.docs.json\"", + "files": [ + "api-extractor.docs.json", + "lib/esm/main.d.ts", + "tsconfig.json" + ], + "dependencies": [ + "build" + ] + }, + "build:test": { + "command": "tsc -b test/src/tsconfig.json", + "files": [ + "test/**/*.ts", + "test/src/tsconfig.json" + ], + "output": [ + "test/build/**" + ], + "dependencies": [ + "build", + "../testserver:build" + ] + }, + "test": { + "command": "node tools/downloadTestBrowsers.mjs && mocha", + "files": [ + ".mocharc.cjs" + ], + "dependencies": [ + "build:test" + ] + } + }, + "keywords": [ + "puppeteer", + "browsers" + ], + "repository": { + "type": "git", + "url": "https://github.com/puppeteer/puppeteer/tree/main/packages/browsers" + }, + "author": "The Chromium Authors", + "license": "Apache-2.0", + "engines": { + "node": ">=16.3.0" + }, + "files": [ + "lib", + "src", + "!*.tsbuildinfo" + ], + "dependencies": { + "debug": "4.3.4", + "extract-zip": "2.0.1", + "progress": "2.0.3", + "proxy-agent": "6.3.1", + "tar-fs": "3.0.4", + "unbzip2-stream": "1.4.3", + "yargs": "17.7.2" + }, + "devDependencies": { + "@types/debug": "4.1.12", + "@types/progress": "2.0.7", + "@types/tar-fs": "2.0.4", + "@types/unbzip2-stream": "1.4.3", + "@types/yargs": "17.0.32" + } +} diff --git a/remote/test/puppeteer/packages/browsers/src/CLI.ts b/remote/test/puppeteer/packages/browsers/src/CLI.ts new file mode 100644 index 0000000000..255f5545b4 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/CLI.ts @@ -0,0 +1,401 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {stdin as input, stdout as output} from 'process'; +import * as readline from 'readline'; + +import ProgressBar from 'progress'; +import type * as Yargs from 'yargs'; +import {hideBin} from 'yargs/helpers'; +import yargs from 'yargs/yargs'; + +import { + resolveBuildId, + type Browser, + BrowserPlatform, + type ChromeReleaseChannel, +} from './browser-data/browser-data.js'; +import {Cache} from './Cache.js'; +import {detectBrowserPlatform} from './detectPlatform.js'; +import {install} from './install.js'; +import { + computeExecutablePath, + computeSystemExecutablePath, + launch, +} from './launch.js'; + +interface InstallArgs { + browser: { + name: Browser; + buildId: string; + }; + path?: string; + platform?: BrowserPlatform; + baseUrl?: string; +} + +interface LaunchArgs { + browser: { + name: Browser; + buildId: string; + }; + path?: string; + platform?: BrowserPlatform; + detached: boolean; + system: boolean; +} + +interface ClearArgs { + path?: string; +} + +/** + * @public + */ +export class CLI { + #cachePath; + #rl?: readline.Interface; + #scriptName = ''; + #allowCachePathOverride = true; + #pinnedBrowsers?: Partial<{[key in Browser]: string}>; + #prefixCommand?: {cmd: string; description: string}; + + constructor( + opts?: + | string + | { + cachePath?: string; + scriptName?: string; + prefixCommand?: {cmd: string; description: string}; + allowCachePathOverride?: boolean; + pinnedBrowsers?: Partial<{[key in Browser]: string}>; + }, + rl?: readline.Interface + ) { + if (!opts) { + opts = {}; + } + if (typeof opts === 'string') { + opts = { + cachePath: opts, + }; + } + this.#cachePath = opts.cachePath ?? process.cwd(); + this.#rl = rl; + this.#scriptName = opts.scriptName ?? '@puppeteer/browsers'; + this.#allowCachePathOverride = opts.allowCachePathOverride ?? true; + this.#pinnedBrowsers = opts.pinnedBrowsers; + this.#prefixCommand = opts.prefixCommand; + } + + #defineBrowserParameter(yargs: Yargs.Argv<unknown>): void { + yargs.positional('browser', { + description: + 'Which browser to install <browser>[@<buildId|latest>]. `latest` will try to find the latest available build. `buildId` is a browser-specific identifier such as a version or a revision.', + type: 'string', + coerce: (opt): InstallArgs['browser'] => { + return { + name: this.#parseBrowser(opt), + buildId: this.#parseBuildId(opt), + }; + }, + }); + } + + #definePlatformParameter(yargs: Yargs.Argv<unknown>): void { + yargs.option('platform', { + type: 'string', + desc: 'Platform that the binary needs to be compatible with.', + choices: Object.values(BrowserPlatform), + defaultDescription: 'Auto-detected', + }); + } + + #definePathParameter(yargs: Yargs.Argv<unknown>, required = false): void { + if (!this.#allowCachePathOverride) { + return; + } + yargs.option('path', { + type: 'string', + desc: 'Path to the root folder for the browser downloads and installation. The installation folder structure is compatible with the cache structure used by Puppeteer.', + defaultDescription: 'Current working directory', + ...(required ? {} : {default: process.cwd()}), + }); + if (required) { + yargs.demandOption('path'); + } + } + + async run(argv: string[]): Promise<void> { + const yargsInstance = yargs(hideBin(argv)); + let target = yargsInstance.scriptName(this.#scriptName); + if (this.#prefixCommand) { + target = target.command( + this.#prefixCommand.cmd, + this.#prefixCommand.description, + yargs => { + return this.#build(yargs); + } + ); + } else { + target = this.#build(target); + } + await target + .demandCommand(1) + .help() + .wrap(Math.min(120, yargsInstance.terminalWidth())) + .parse(); + } + + #build(yargs: Yargs.Argv<unknown>): Yargs.Argv<unknown> { + const latestOrPinned = this.#pinnedBrowsers ? 'pinned' : 'latest'; + return yargs + .command( + 'install <browser>', + 'Download and install the specified browser. If successful, the command outputs the actual browser buildId that was installed and the absolute path to the browser executable (format: <browser>@<buildID> <path>).', + yargs => { + this.#defineBrowserParameter(yargs); + this.#definePlatformParameter(yargs); + this.#definePathParameter(yargs); + yargs.option('base-url', { + type: 'string', + desc: 'Base URL to download from', + }); + yargs.example( + '$0 install chrome', + `Install the ${latestOrPinned} available build of the Chrome browser.` + ); + yargs.example( + '$0 install chrome@latest', + 'Install the latest available build for the Chrome browser.' + ); + yargs.example( + '$0 install chrome@canary', + 'Install the latest available build for the Chrome Canary browser.' + ); + yargs.example( + '$0 install chrome@115', + 'Install the latest available build for Chrome 115.' + ); + yargs.example( + '$0 install chromedriver@canary', + 'Install the latest available build for ChromeDriver Canary.' + ); + yargs.example( + '$0 install chromedriver@115', + 'Install the latest available build for ChromeDriver 115.' + ); + yargs.example( + '$0 install chromedriver@115.0.5790', + 'Install the latest available patch (115.0.5790.X) build for ChromeDriver.' + ); + yargs.example( + '$0 install chrome-headless-shell', + 'Install the latest available chrome-headless-shell build.' + ); + yargs.example( + '$0 install chrome-headless-shell@beta', + 'Install the latest available chrome-headless-shell build corresponding to the Beta channel.' + ); + yargs.example( + '$0 install chrome-headless-shell@118', + 'Install the latest available chrome-headless-shell 118 build.' + ); + yargs.example( + '$0 install chromium@1083080', + 'Install the revision 1083080 of the Chromium browser.' + ); + yargs.example( + '$0 install firefox', + 'Install the latest available build of the Firefox browser.' + ); + yargs.example( + '$0 install firefox --platform mac', + 'Install the latest Mac (Intel) build of the Firefox browser.' + ); + if (this.#allowCachePathOverride) { + yargs.example( + '$0 install firefox --path /tmp/my-browser-cache', + 'Install to the specified cache directory.' + ); + } + }, + async argv => { + const args = argv as unknown as InstallArgs; + args.platform ??= detectBrowserPlatform(); + if (!args.platform) { + throw new Error(`Could not resolve the current platform`); + } + if (args.browser.buildId === 'pinned') { + const pinnedVersion = this.#pinnedBrowsers?.[args.browser.name]; + if (!pinnedVersion) { + throw new Error( + `No pinned version found for ${args.browser.name}` + ); + } + args.browser.buildId = pinnedVersion; + } + args.browser.buildId = await resolveBuildId( + args.browser.name, + args.platform, + args.browser.buildId + ); + await install({ + browser: args.browser.name, + buildId: args.browser.buildId, + platform: args.platform, + cacheDir: args.path ?? this.#cachePath, + downloadProgressCallback: makeProgressCallback( + args.browser.name, + args.browser.buildId + ), + baseUrl: args.baseUrl, + }); + console.log( + `${args.browser.name}@${ + args.browser.buildId + } ${computeExecutablePath({ + browser: args.browser.name, + buildId: args.browser.buildId, + cacheDir: args.path ?? this.#cachePath, + platform: args.platform, + })}` + ); + } + ) + .command( + 'launch <browser>', + 'Launch the specified browser', + yargs => { + this.#defineBrowserParameter(yargs); + this.#definePlatformParameter(yargs); + this.#definePathParameter(yargs); + yargs.option('detached', { + type: 'boolean', + desc: 'Detach the child process.', + default: false, + }); + yargs.option('system', { + type: 'boolean', + desc: 'Search for a browser installed on the system instead of the cache folder.', + default: false, + }); + yargs.example( + '$0 launch chrome@115.0.5790.170', + 'Launch Chrome 115.0.5790.170' + ); + yargs.example( + '$0 launch firefox@112.0a1', + 'Launch the Firefox browser identified by the milestone 112.0a1.' + ); + yargs.example( + '$0 launch chrome@115.0.5790.170 --detached', + 'Launch the browser but detach the sub-processes.' + ); + yargs.example( + '$0 launch chrome@canary --system', + 'Try to locate the Canary build of Chrome installed on the system and launch it.' + ); + }, + async argv => { + const args = argv as unknown as LaunchArgs; + const executablePath = args.system + ? computeSystemExecutablePath({ + browser: args.browser.name, + // TODO: throw an error if not a ChromeReleaseChannel is provided. + channel: args.browser.buildId as ChromeReleaseChannel, + platform: args.platform, + }) + : computeExecutablePath({ + browser: args.browser.name, + buildId: args.browser.buildId, + cacheDir: args.path ?? this.#cachePath, + platform: args.platform, + }); + launch({ + executablePath, + detached: args.detached, + }); + } + ) + .command( + 'clear', + this.#allowCachePathOverride + ? 'Removes all installed browsers from the specified cache directory' + : `Removes all installed browsers from ${this.#cachePath}`, + yargs => { + this.#definePathParameter(yargs, true); + }, + async argv => { + const args = argv as unknown as ClearArgs; + const cacheDir = args.path ?? this.#cachePath; + const rl = this.#rl ?? readline.createInterface({input, output}); + rl.question( + `Do you want to permanently and recursively delete the content of ${cacheDir} (yes/No)? `, + answer => { + rl.close(); + if (!['y', 'yes'].includes(answer.toLowerCase().trim())) { + console.log('Cancelled.'); + return; + } + const cache = new Cache(cacheDir); + cache.clear(); + console.log(`${cacheDir} cleared.`); + } + ); + } + ) + .demandCommand(1) + .help(); + } + + #parseBrowser(version: string): Browser { + return version.split('@').shift() as Browser; + } + + #parseBuildId(version: string): string { + const parts = version.split('@'); + return parts.length === 2 + ? parts[1]! + : this.#pinnedBrowsers + ? 'pinned' + : 'latest'; + } +} + +/** + * @public + */ +export function makeProgressCallback( + browser: Browser, + buildId: string +): (downloadedBytes: number, totalBytes: number) => void { + let progressBar: ProgressBar; + let lastDownloadedBytes = 0; + return (downloadedBytes: number, totalBytes: number) => { + if (!progressBar) { + progressBar = new ProgressBar( + `Downloading ${browser} r${buildId} - ${toMegabytes( + totalBytes + )} [:bar] :percent :etas `, + { + complete: '=', + incomplete: ' ', + width: 20, + total: totalBytes, + } + ); + } + const delta = downloadedBytes - lastDownloadedBytes; + lastDownloadedBytes = downloadedBytes; + progressBar.tick(delta); + }; +} + +function toMegabytes(bytes: number) { + const mb = bytes / 1000 / 1000; + return `${Math.round(mb * 10) / 10} MB`; +} diff --git a/remote/test/puppeteer/packages/browsers/src/Cache.ts b/remote/test/puppeteer/packages/browsers/src/Cache.ts new file mode 100644 index 0000000000..13b465835a --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/Cache.ts @@ -0,0 +1,211 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + Browser, + type BrowserPlatform, + executablePathByBrowser, +} from './browser-data/browser-data.js'; +import {detectBrowserPlatform} from './detectPlatform.js'; + +/** + * @public + */ +export class InstalledBrowser { + browser: Browser; + buildId: string; + platform: BrowserPlatform; + readonly executablePath: string; + + #cache: Cache; + + /** + * @internal + */ + constructor( + cache: Cache, + browser: Browser, + buildId: string, + platform: BrowserPlatform + ) { + this.#cache = cache; + this.browser = browser; + this.buildId = buildId; + this.platform = platform; + this.executablePath = cache.computeExecutablePath({ + browser, + buildId, + platform, + }); + } + + /** + * Path to the root of the installation folder. Use + * {@link computeExecutablePath} to get the path to the executable binary. + */ + get path(): string { + return this.#cache.installationDir( + this.browser, + this.platform, + this.buildId + ); + } +} + +/** + * @internal + */ +export interface ComputeExecutablePathOptions { + /** + * Determines which platform the browser will be suited for. + * + * @defaultValue **Auto-detected.** + */ + platform?: BrowserPlatform; + /** + * Determines which browser to launch. + */ + browser: Browser; + /** + * Determines which buildId to download. BuildId should uniquely identify + * binaries and they are used for caching. + */ + buildId: string; +} + +/** + * The cache used by Puppeteer relies on the following structure: + * + * - rootDir + * -- <browser1> | browserRoot(browser1) + * ---- <platform>-<buildId> | installationDir() + * ------ the browser-platform-buildId + * ------ specific structure. + * -- <browser2> | browserRoot(browser2) + * ---- <platform>-<buildId> | installationDir() + * ------ the browser-platform-buildId + * ------ specific structure. + * @internal + */ +export class Cache { + #rootDir: string; + + constructor(rootDir: string) { + this.#rootDir = rootDir; + } + + /** + * @internal + */ + get rootDir(): string { + return this.#rootDir; + } + + browserRoot(browser: Browser): string { + return path.join(this.#rootDir, browser); + } + + installationDir( + browser: Browser, + platform: BrowserPlatform, + buildId: string + ): string { + return path.join(this.browserRoot(browser), `${platform}-${buildId}`); + } + + clear(): void { + fs.rmSync(this.#rootDir, { + force: true, + recursive: true, + maxRetries: 10, + retryDelay: 500, + }); + } + + uninstall( + browser: Browser, + platform: BrowserPlatform, + buildId: string + ): void { + fs.rmSync(this.installationDir(browser, platform, buildId), { + force: true, + recursive: true, + maxRetries: 10, + retryDelay: 500, + }); + } + + getInstalledBrowsers(): InstalledBrowser[] { + if (!fs.existsSync(this.#rootDir)) { + return []; + } + const types = fs.readdirSync(this.#rootDir); + const browsers = types.filter((t): t is Browser => { + return (Object.values(Browser) as string[]).includes(t); + }); + return browsers.flatMap(browser => { + const files = fs.readdirSync(this.browserRoot(browser)); + return files + .map(file => { + const result = parseFolderPath( + path.join(this.browserRoot(browser), file) + ); + if (!result) { + return null; + } + return new InstalledBrowser( + this, + browser, + result.buildId, + result.platform as BrowserPlatform + ); + }) + .filter((item: InstalledBrowser | null): item is InstalledBrowser => { + return item !== null; + }); + }); + } + + computeExecutablePath(options: ComputeExecutablePathOptions): string { + options.platform ??= detectBrowserPlatform(); + if (!options.platform) { + throw new Error( + `Cannot download a binary for the provided platform: ${os.platform()} (${os.arch()})` + ); + } + const installationDir = this.installationDir( + options.browser, + options.platform, + options.buildId + ); + return path.join( + installationDir, + executablePathByBrowser[options.browser]( + options.platform, + options.buildId + ) + ); + } +} + +function parseFolderPath( + folderPath: string +): {platform: string; buildId: string} | undefined { + const name = path.basename(folderPath); + const splits = name.split('-'); + if (splits.length !== 2) { + return; + } + const [platform, buildId] = splits; + if (!buildId || !platform) { + return; + } + return {platform, buildId}; +} diff --git a/remote/test/puppeteer/packages/browsers/src/browser-data/browser-data.ts b/remote/test/puppeteer/packages/browsers/src/browser-data/browser-data.ts new file mode 100644 index 0000000000..67bb4990b2 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/browser-data/browser-data.ts @@ -0,0 +1,187 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as chromeHeadlessShell from './chrome-headless-shell.js'; +import * as chrome from './chrome.js'; +import * as chromedriver from './chromedriver.js'; +import * as chromium from './chromium.js'; +import * as firefox from './firefox.js'; +import { + Browser, + BrowserPlatform, + BrowserTag, + ChromeReleaseChannel, + type ProfileOptions, +} from './types.js'; + +export type {ProfileOptions}; + +export const downloadUrls = { + [Browser.CHROMEDRIVER]: chromedriver.resolveDownloadUrl, + [Browser.CHROMEHEADLESSSHELL]: chromeHeadlessShell.resolveDownloadUrl, + [Browser.CHROME]: chrome.resolveDownloadUrl, + [Browser.CHROMIUM]: chromium.resolveDownloadUrl, + [Browser.FIREFOX]: firefox.resolveDownloadUrl, +}; + +export const downloadPaths = { + [Browser.CHROMEDRIVER]: chromedriver.resolveDownloadPath, + [Browser.CHROMEHEADLESSSHELL]: chromeHeadlessShell.resolveDownloadPath, + [Browser.CHROME]: chrome.resolveDownloadPath, + [Browser.CHROMIUM]: chromium.resolveDownloadPath, + [Browser.FIREFOX]: firefox.resolveDownloadPath, +}; + +export const executablePathByBrowser = { + [Browser.CHROMEDRIVER]: chromedriver.relativeExecutablePath, + [Browser.CHROMEHEADLESSSHELL]: chromeHeadlessShell.relativeExecutablePath, + [Browser.CHROME]: chrome.relativeExecutablePath, + [Browser.CHROMIUM]: chromium.relativeExecutablePath, + [Browser.FIREFOX]: firefox.relativeExecutablePath, +}; + +export {Browser, BrowserPlatform, ChromeReleaseChannel}; + +/** + * @public + */ +export async function resolveBuildId( + browser: Browser, + platform: BrowserPlatform, + tag: string +): Promise<string> { + switch (browser) { + case Browser.FIREFOX: + switch (tag as BrowserTag) { + case BrowserTag.LATEST: + return await firefox.resolveBuildId('FIREFOX_NIGHTLY'); + case BrowserTag.BETA: + case BrowserTag.CANARY: + case BrowserTag.DEV: + case BrowserTag.STABLE: + throw new Error( + `${tag} is not supported for ${browser}. Use 'latest' instead.` + ); + } + case Browser.CHROME: { + switch (tag as BrowserTag) { + case BrowserTag.LATEST: + return await chrome.resolveBuildId(ChromeReleaseChannel.CANARY); + case BrowserTag.BETA: + return await chrome.resolveBuildId(ChromeReleaseChannel.BETA); + case BrowserTag.CANARY: + return await chrome.resolveBuildId(ChromeReleaseChannel.CANARY); + case BrowserTag.DEV: + return await chrome.resolveBuildId(ChromeReleaseChannel.DEV); + case BrowserTag.STABLE: + return await chrome.resolveBuildId(ChromeReleaseChannel.STABLE); + default: + const result = await chrome.resolveBuildId(tag); + if (result) { + return result; + } + } + return tag; + } + case Browser.CHROMEDRIVER: { + switch (tag) { + case BrowserTag.LATEST: + case BrowserTag.CANARY: + return await chromedriver.resolveBuildId(ChromeReleaseChannel.CANARY); + case BrowserTag.BETA: + return await chromedriver.resolveBuildId(ChromeReleaseChannel.BETA); + case BrowserTag.DEV: + return await chromedriver.resolveBuildId(ChromeReleaseChannel.DEV); + case BrowserTag.STABLE: + return await chromedriver.resolveBuildId(ChromeReleaseChannel.STABLE); + default: + const result = await chromedriver.resolveBuildId(tag); + if (result) { + return result; + } + } + return tag; + } + case Browser.CHROMEHEADLESSSHELL: { + switch (tag) { + case BrowserTag.LATEST: + case BrowserTag.CANARY: + return await chromeHeadlessShell.resolveBuildId( + ChromeReleaseChannel.CANARY + ); + case BrowserTag.BETA: + return await chromeHeadlessShell.resolveBuildId( + ChromeReleaseChannel.BETA + ); + case BrowserTag.DEV: + return await chromeHeadlessShell.resolveBuildId( + ChromeReleaseChannel.DEV + ); + case BrowserTag.STABLE: + return await chromeHeadlessShell.resolveBuildId( + ChromeReleaseChannel.STABLE + ); + default: + const result = await chromeHeadlessShell.resolveBuildId(tag); + if (result) { + return result; + } + } + return tag; + } + case Browser.CHROMIUM: + switch (tag as BrowserTag) { + case BrowserTag.LATEST: + return await chromium.resolveBuildId(platform); + case BrowserTag.BETA: + case BrowserTag.CANARY: + case BrowserTag.DEV: + case BrowserTag.STABLE: + throw new Error( + `${tag} is not supported for ${browser}. Use 'latest' instead.` + ); + } + } + // We assume the tag is the buildId if it didn't match any keywords. + return tag; +} + +/** + * @public + */ +export async function createProfile( + browser: Browser, + opts: ProfileOptions +): Promise<void> { + switch (browser) { + case Browser.FIREFOX: + return await firefox.createProfile(opts); + case Browser.CHROME: + case Browser.CHROMIUM: + throw new Error(`Profile creation is not support for ${browser} yet`); + } +} + +/** + * @public + */ +export function resolveSystemExecutablePath( + browser: Browser, + platform: BrowserPlatform, + channel: ChromeReleaseChannel +): string { + switch (browser) { + case Browser.CHROMEDRIVER: + case Browser.CHROMEHEADLESSSHELL: + case Browser.FIREFOX: + case Browser.CHROMIUM: + throw new Error( + `System browser detection is not supported for ${browser} yet.` + ); + case Browser.CHROME: + return chrome.resolveSystemExecutablePath(platform, channel); + } +} diff --git a/remote/test/puppeteer/packages/browsers/src/browser-data/chrome-headless-shell.ts b/remote/test/puppeteer/packages/browsers/src/browser-data/chrome-headless-shell.ts new file mode 100644 index 0000000000..b1c6178de8 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/browser-data/chrome-headless-shell.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import path from 'path'; + +import {BrowserPlatform} from './types.js'; + +function folder(platform: BrowserPlatform): string { + switch (platform) { + case BrowserPlatform.LINUX: + return 'linux64'; + case BrowserPlatform.MAC_ARM: + return 'mac-arm64'; + case BrowserPlatform.MAC: + return 'mac-x64'; + case BrowserPlatform.WIN32: + return 'win32'; + case BrowserPlatform.WIN64: + return 'win64'; + } +} + +export function resolveDownloadUrl( + platform: BrowserPlatform, + buildId: string, + baseUrl = 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing' +): string { + return `${baseUrl}/${resolveDownloadPath(platform, buildId).join('/')}`; +} + +export function resolveDownloadPath( + platform: BrowserPlatform, + buildId: string +): string[] { + return [ + buildId, + folder(platform), + `chrome-headless-shell-${folder(platform)}.zip`, + ]; +} + +export function relativeExecutablePath( + platform: BrowserPlatform, + _buildId: string +): string { + switch (platform) { + case BrowserPlatform.MAC: + case BrowserPlatform.MAC_ARM: + return path.join( + 'chrome-headless-shell-' + folder(platform), + 'chrome-headless-shell' + ); + case BrowserPlatform.LINUX: + return path.join( + 'chrome-headless-shell-linux64', + 'chrome-headless-shell' + ); + case BrowserPlatform.WIN32: + case BrowserPlatform.WIN64: + return path.join( + 'chrome-headless-shell-' + folder(platform), + 'chrome-headless-shell.exe' + ); + } +} + +export {resolveBuildId} from './chrome.js'; diff --git a/remote/test/puppeteer/packages/browsers/src/browser-data/chrome.ts b/remote/test/puppeteer/packages/browsers/src/browser-data/chrome.ts new file mode 100644 index 0000000000..c6329255c3 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/browser-data/chrome.ts @@ -0,0 +1,195 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'path'; + +import {getJSON} from '../httpUtil.js'; + +import {BrowserPlatform, ChromeReleaseChannel} from './types.js'; + +function folder(platform: BrowserPlatform): string { + switch (platform) { + case BrowserPlatform.LINUX: + return 'linux64'; + case BrowserPlatform.MAC_ARM: + return 'mac-arm64'; + case BrowserPlatform.MAC: + return 'mac-x64'; + case BrowserPlatform.WIN32: + return 'win32'; + case BrowserPlatform.WIN64: + return 'win64'; + } +} + +export function resolveDownloadUrl( + platform: BrowserPlatform, + buildId: string, + baseUrl = 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing' +): string { + return `${baseUrl}/${resolveDownloadPath(platform, buildId).join('/')}`; +} + +export function resolveDownloadPath( + platform: BrowserPlatform, + buildId: string +): string[] { + return [buildId, folder(platform), `chrome-${folder(platform)}.zip`]; +} + +export function relativeExecutablePath( + platform: BrowserPlatform, + _buildId: string +): string { + switch (platform) { + case BrowserPlatform.MAC: + case BrowserPlatform.MAC_ARM: + return path.join( + 'chrome-' + folder(platform), + 'Google Chrome for Testing.app', + 'Contents', + 'MacOS', + 'Google Chrome for Testing' + ); + case BrowserPlatform.LINUX: + return path.join('chrome-linux64', 'chrome'); + case BrowserPlatform.WIN32: + case BrowserPlatform.WIN64: + return path.join('chrome-' + folder(platform), 'chrome.exe'); + } +} + +export async function getLastKnownGoodReleaseForChannel( + channel: ChromeReleaseChannel +): Promise<{version: string; revision: string}> { + const data = (await getJSON( + new URL( + 'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json' + ) + )) as { + channels: Record<string, {version: string}>; + }; + + for (const channel of Object.keys(data.channels)) { + data.channels[channel.toLowerCase()] = data.channels[channel]!; + delete data.channels[channel]; + } + + return ( + data as { + channels: { + [channel in ChromeReleaseChannel]: {version: string; revision: string}; + }; + } + ).channels[channel]; +} + +export async function getLastKnownGoodReleaseForMilestone( + milestone: string +): Promise<{version: string; revision: string} | undefined> { + const data = (await getJSON( + new URL( + 'https://googlechromelabs.github.io/chrome-for-testing/latest-versions-per-milestone.json' + ) + )) as { + milestones: Record<string, {version: string; revision: string}>; + }; + return data.milestones[milestone] as + | {version: string; revision: string} + | undefined; +} + +export async function getLastKnownGoodReleaseForBuild( + /** + * @example `112.0.23`, + */ + buildPrefix: string +): Promise<{version: string; revision: string} | undefined> { + const data = (await getJSON( + new URL( + 'https://googlechromelabs.github.io/chrome-for-testing/latest-patch-versions-per-build.json' + ) + )) as { + builds: Record<string, {version: string; revision: string}>; + }; + return data.builds[buildPrefix] as + | {version: string; revision: string} + | undefined; +} + +export async function resolveBuildId( + channel: ChromeReleaseChannel +): Promise<string>; +export async function resolveBuildId( + channel: string +): Promise<string | undefined>; +export async function resolveBuildId( + channel: ChromeReleaseChannel | string +): Promise<string | undefined> { + if ( + Object.values(ChromeReleaseChannel).includes( + channel as ChromeReleaseChannel + ) + ) { + return ( + await getLastKnownGoodReleaseForChannel(channel as ChromeReleaseChannel) + ).version; + } + if (channel.match(/^\d+$/)) { + // Potentially a milestone. + return (await getLastKnownGoodReleaseForMilestone(channel))?.version; + } + if (channel.match(/^\d+\.\d+\.\d+$/)) { + // Potentially a build prefix without the patch version. + return (await getLastKnownGoodReleaseForBuild(channel))?.version; + } + return; +} + +export function resolveSystemExecutablePath( + platform: BrowserPlatform, + channel: ChromeReleaseChannel +): string { + switch (platform) { + case BrowserPlatform.WIN64: + case BrowserPlatform.WIN32: + switch (channel) { + case ChromeReleaseChannel.STABLE: + return `${process.env['PROGRAMFILES']}\\Google\\Chrome\\Application\\chrome.exe`; + case ChromeReleaseChannel.BETA: + return `${process.env['PROGRAMFILES']}\\Google\\Chrome Beta\\Application\\chrome.exe`; + case ChromeReleaseChannel.CANARY: + return `${process.env['PROGRAMFILES']}\\Google\\Chrome SxS\\Application\\chrome.exe`; + case ChromeReleaseChannel.DEV: + return `${process.env['PROGRAMFILES']}\\Google\\Chrome Dev\\Application\\chrome.exe`; + } + case BrowserPlatform.MAC_ARM: + case BrowserPlatform.MAC: + switch (channel) { + case ChromeReleaseChannel.STABLE: + return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + case ChromeReleaseChannel.BETA: + return '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta'; + case ChromeReleaseChannel.CANARY: + return '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary'; + case ChromeReleaseChannel.DEV: + return '/Applications/Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev'; + } + case BrowserPlatform.LINUX: + switch (channel) { + case ChromeReleaseChannel.STABLE: + return '/opt/google/chrome/chrome'; + case ChromeReleaseChannel.BETA: + return '/opt/google/chrome-beta/chrome'; + case ChromeReleaseChannel.DEV: + return '/opt/google/chrome-unstable/chrome'; + } + } + + throw new Error( + `Unable to detect browser executable path for '${channel}' on ${platform}.` + ); +} diff --git a/remote/test/puppeteer/packages/browsers/src/browser-data/chromedriver.ts b/remote/test/puppeteer/packages/browsers/src/browser-data/chromedriver.ts new file mode 100644 index 0000000000..290598d0d7 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/browser-data/chromedriver.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import path from 'path'; + +import {BrowserPlatform} from './types.js'; + +function folder(platform: BrowserPlatform): string { + switch (platform) { + case BrowserPlatform.LINUX: + return 'linux64'; + case BrowserPlatform.MAC_ARM: + return 'mac-arm64'; + case BrowserPlatform.MAC: + return 'mac-x64'; + case BrowserPlatform.WIN32: + return 'win32'; + case BrowserPlatform.WIN64: + return 'win64'; + } +} + +export function resolveDownloadUrl( + platform: BrowserPlatform, + buildId: string, + baseUrl = 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing' +): string { + return `${baseUrl}/${resolveDownloadPath(platform, buildId).join('/')}`; +} + +export function resolveDownloadPath( + platform: BrowserPlatform, + buildId: string +): string[] { + return [buildId, folder(platform), `chromedriver-${folder(platform)}.zip`]; +} + +export function relativeExecutablePath( + platform: BrowserPlatform, + _buildId: string +): string { + switch (platform) { + case BrowserPlatform.MAC: + case BrowserPlatform.MAC_ARM: + return path.join('chromedriver-' + folder(platform), 'chromedriver'); + case BrowserPlatform.LINUX: + return path.join('chromedriver-linux64', 'chromedriver'); + case BrowserPlatform.WIN32: + case BrowserPlatform.WIN64: + return path.join('chromedriver-' + folder(platform), 'chromedriver.exe'); + } +} + +export {resolveBuildId} from './chrome.js'; diff --git a/remote/test/puppeteer/packages/browsers/src/browser-data/chromium.ts b/remote/test/puppeteer/packages/browsers/src/browser-data/chromium.ts new file mode 100644 index 0000000000..09cfc987a8 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/browser-data/chromium.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'path'; + +import {getText} from '../httpUtil.js'; + +import {BrowserPlatform} from './types.js'; + +function archive(platform: BrowserPlatform, buildId: string): string { + switch (platform) { + case BrowserPlatform.LINUX: + return 'chrome-linux'; + case BrowserPlatform.MAC_ARM: + case BrowserPlatform.MAC: + return 'chrome-mac'; + case BrowserPlatform.WIN32: + case BrowserPlatform.WIN64: + // Windows archive name changed at r591479. + return parseInt(buildId, 10) > 591479 ? 'chrome-win' : 'chrome-win32'; + } +} + +function folder(platform: BrowserPlatform): string { + switch (platform) { + case BrowserPlatform.LINUX: + return 'Linux_x64'; + case BrowserPlatform.MAC_ARM: + return 'Mac_Arm'; + case BrowserPlatform.MAC: + return 'Mac'; + case BrowserPlatform.WIN32: + return 'Win'; + case BrowserPlatform.WIN64: + return 'Win_x64'; + } +} + +export function resolveDownloadUrl( + platform: BrowserPlatform, + buildId: string, + baseUrl = 'https://storage.googleapis.com/chromium-browser-snapshots' +): string { + return `${baseUrl}/${resolveDownloadPath(platform, buildId).join('/')}`; +} + +export function resolveDownloadPath( + platform: BrowserPlatform, + buildId: string +): string[] { + return [folder(platform), buildId, `${archive(platform, buildId)}.zip`]; +} + +export function relativeExecutablePath( + platform: BrowserPlatform, + _buildId: string +): string { + switch (platform) { + case BrowserPlatform.MAC: + case BrowserPlatform.MAC_ARM: + return path.join( + 'chrome-mac', + 'Chromium.app', + 'Contents', + 'MacOS', + 'Chromium' + ); + case BrowserPlatform.LINUX: + return path.join('chrome-linux', 'chrome'); + case BrowserPlatform.WIN32: + case BrowserPlatform.WIN64: + return path.join('chrome-win', 'chrome.exe'); + } +} +export async function resolveBuildId( + platform: BrowserPlatform +): Promise<string> { + return await getText( + new URL( + `https://storage.googleapis.com/chromium-browser-snapshots/${folder( + platform + )}/LAST_CHANGE` + ) + ); +} diff --git a/remote/test/puppeteer/packages/browsers/src/browser-data/firefox.ts b/remote/test/puppeteer/packages/browsers/src/browser-data/firefox.ts new file mode 100644 index 0000000000..ccc30fa1b5 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/browser-data/firefox.ts @@ -0,0 +1,330 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; +import path from 'path'; + +import {getJSON} from '../httpUtil.js'; + +import {BrowserPlatform, type ProfileOptions} from './types.js'; + +function archive(platform: BrowserPlatform, buildId: string): string { + switch (platform) { + case BrowserPlatform.LINUX: + return `firefox-${buildId}.en-US.${platform}-x86_64.tar.bz2`; + case BrowserPlatform.MAC_ARM: + case BrowserPlatform.MAC: + return `firefox-${buildId}.en-US.mac.dmg`; + case BrowserPlatform.WIN32: + case BrowserPlatform.WIN64: + return `firefox-${buildId}.en-US.${platform}.zip`; + } +} + +export function resolveDownloadUrl( + platform: BrowserPlatform, + buildId: string, + baseUrl = 'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central' +): string { + return `${baseUrl}/${resolveDownloadPath(platform, buildId).join('/')}`; +} + +export function resolveDownloadPath( + platform: BrowserPlatform, + buildId: string +): string[] { + return [archive(platform, buildId)]; +} + +export function relativeExecutablePath( + platform: BrowserPlatform, + _buildId: string +): string { + switch (platform) { + case BrowserPlatform.MAC_ARM: + case BrowserPlatform.MAC: + return path.join('Firefox Nightly.app', 'Contents', 'MacOS', 'firefox'); + case BrowserPlatform.LINUX: + return path.join('firefox', 'firefox'); + case BrowserPlatform.WIN32: + case BrowserPlatform.WIN64: + return path.join('firefox', 'firefox.exe'); + } +} + +export async function resolveBuildId( + channel: 'FIREFOX_NIGHTLY' = 'FIREFOX_NIGHTLY' +): Promise<string> { + const versions = (await getJSON( + new URL('https://product-details.mozilla.org/1.0/firefox_versions.json') + )) as Record<string, string>; + const version = versions[channel]; + if (!version) { + throw new Error(`Channel ${channel} is not found.`); + } + return version; +} + +export async function createProfile(options: ProfileOptions): Promise<void> { + if (!fs.existsSync(options.path)) { + await fs.promises.mkdir(options.path, { + recursive: true, + }); + } + await writePreferences({ + preferences: { + ...defaultProfilePreferences(options.preferences), + ...options.preferences, + }, + path: options.path, + }); +} + +function defaultProfilePreferences( + extraPrefs: Record<string, unknown> +): Record<string, unknown> { + const server = 'dummy.test'; + + const defaultPrefs = { + // Make sure Shield doesn't hit the network. + 'app.normandy.api_url': '', + // Disable Firefox old build background check + 'app.update.checkInstallTime': false, + // Disable automatically upgrading Firefox + 'app.update.disabledForTesting': true, + + // Increase the APZ content response timeout to 1 minute + 'apz.content_response_timeout': 60000, + + // Prevent various error message on the console + // jest-puppeteer asserts that no error message is emitted by the console + 'browser.contentblocking.features.standard': + '-tp,tpPrivate,cookieBehavior0,-cm,-fp', + + // Enable the dump function: which sends messages to the system + // console + // https://bugzilla.mozilla.org/show_bug.cgi?id=1543115 + 'browser.dom.window.dump.enabled': true, + // Disable topstories + 'browser.newtabpage.activity-stream.feeds.system.topstories': false, + // Always display a blank page + 'browser.newtabpage.enabled': false, + // Background thumbnails in particular cause grief: and disabling + // thumbnails in general cannot hurt + 'browser.pagethumbnails.capturing_disabled': true, + + // Disable safebrowsing components. + 'browser.safebrowsing.blockedURIs.enabled': false, + 'browser.safebrowsing.downloads.enabled': false, + 'browser.safebrowsing.malware.enabled': false, + 'browser.safebrowsing.phishing.enabled': false, + + // Disable updates to search engines. + 'browser.search.update': false, + // Do not restore the last open set of tabs if the browser has crashed + 'browser.sessionstore.resume_from_crash': false, + // Skip check for default browser on startup + 'browser.shell.checkDefaultBrowser': false, + + // Disable newtabpage + 'browser.startup.homepage': 'about:blank', + // Do not redirect user when a milstone upgrade of Firefox is detected + 'browser.startup.homepage_override.mstone': 'ignore', + // Start with a blank page about:blank + 'browser.startup.page': 0, + + // Do not allow background tabs to be zombified on Android: otherwise for + // tests that open additional tabs: the test harness tab itself might get + // unloaded + 'browser.tabs.disableBackgroundZombification': false, + // Do not warn when closing all other open tabs + 'browser.tabs.warnOnCloseOtherTabs': false, + // Do not warn when multiple tabs will be opened + 'browser.tabs.warnOnOpen': false, + + // Do not automatically offer translations, as tests do not expect this. + 'browser.translations.automaticallyPopup': false, + + // Disable the UI tour. + 'browser.uitour.enabled': false, + // Turn off search suggestions in the location bar so as not to trigger + // network connections. + 'browser.urlbar.suggest.searches': false, + // Disable first run splash page on Windows 10 + 'browser.usedOnWindows10.introURL': '', + // Do not warn on quitting Firefox + 'browser.warnOnQuit': false, + + // Defensively disable data reporting systems + 'datareporting.healthreport.documentServerURI': `http://${server}/dummy/healthreport/`, + 'datareporting.healthreport.logging.consoleEnabled': false, + 'datareporting.healthreport.service.enabled': false, + 'datareporting.healthreport.service.firstRun': false, + 'datareporting.healthreport.uploadEnabled': false, + + // Do not show datareporting policy notifications which can interfere with tests + 'datareporting.policy.dataSubmissionEnabled': false, + 'datareporting.policy.dataSubmissionPolicyBypassNotification': true, + + // DevTools JSONViewer sometimes fails to load dependencies with its require.js. + // This doesn't affect Puppeteer but spams console (Bug 1424372) + 'devtools.jsonview.enabled': false, + + // Disable popup-blocker + 'dom.disable_open_during_load': false, + + // Enable the support for File object creation in the content process + // Required for |Page.setFileInputFiles| protocol method. + 'dom.file.createInChild': true, + + // Disable the ProcessHangMonitor + 'dom.ipc.reportProcessHangs': false, + + // Disable slow script dialogues + 'dom.max_chrome_script_run_time': 0, + 'dom.max_script_run_time': 0, + + // Only load extensions from the application and user profile + // AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION + 'extensions.autoDisableScopes': 0, + 'extensions.enabledScopes': 5, + + // Disable metadata caching for installed add-ons by default + 'extensions.getAddons.cache.enabled': false, + + // Disable installing any distribution extensions or add-ons. + 'extensions.installDistroAddons': false, + + // Disabled screenshots extension + 'extensions.screenshots.disabled': true, + + // Turn off extension updates so they do not bother tests + 'extensions.update.enabled': false, + + // Turn off extension updates so they do not bother tests + 'extensions.update.notifyUser': false, + + // Make sure opening about:addons will not hit the network + 'extensions.webservice.discoverURL': `http://${server}/dummy/discoveryURL`, + + // Allow the application to have focus even it runs in the background + 'focusmanager.testmode': true, + + // Disable useragent updates + 'general.useragent.updates.enabled': false, + + // Always use network provider for geolocation tests so we bypass the + // macOS dialog raised by the corelocation provider + 'geo.provider.testing': true, + + // Do not scan Wifi + 'geo.wifi.scan': false, + + // No hang monitor + 'hangmonitor.timeout': 0, + + // Show chrome errors and warnings in the error console + 'javascript.options.showInConsole': true, + + // Disable download and usage of OpenH264: and Widevine plugins + 'media.gmp-manager.updateEnabled': false, + + // Disable the GFX sanity window + 'media.sanity-test.disabled': true, + + // Prevent various error message on the console + // jest-puppeteer asserts that no error message is emitted by the console + 'network.cookie.cookieBehavior': 0, + + // Disable experimental feature that is only available in Nightly + 'network.cookie.sameSite.laxByDefault': false, + + // Do not prompt for temporary redirects + 'network.http.prompt-temp-redirect': false, + + // Disable speculative connections so they are not reported as leaking + // when they are hanging around + 'network.http.speculative-parallel-limit': 0, + + // Do not automatically switch between offline and online + 'network.manage-offline-status': false, + + // Make sure SNTP requests do not hit the network + 'network.sntp.pools': server, + + // Disable Flash. + 'plugin.state.flash': 0, + + 'privacy.trackingprotection.enabled': false, + + // Can be removed once Firefox 89 is no longer supported + // https://bugzilla.mozilla.org/show_bug.cgi?id=1710839 + 'remote.enabled': true, + + // Don't do network connections for mitm priming + 'security.certerrors.mitm.priming.enabled': false, + + // Local documents have access to all other local documents, + // including directory listings + 'security.fileuri.strict_origin_policy': false, + + // Do not wait for the notification button security delay + 'security.notification_enable_delay': 0, + + // Ensure blocklist updates do not hit the network + 'services.settings.server': `http://${server}/dummy/blocklist/`, + + // Do not automatically fill sign-in forms with known usernames and + // passwords + 'signon.autofillForms': false, + + // Disable password capture, so that tests that include forms are not + // influenced by the presence of the persistent doorhanger notification + 'signon.rememberSignons': false, + + // Disable first-run welcome page + 'startup.homepage_welcome_url': 'about:blank', + + // Disable first-run welcome page + 'startup.homepage_welcome_url.additional': '', + + // Disable browser animations (tabs, fullscreen, sliding alerts) + 'toolkit.cosmeticAnimations.enabled': false, + + // Prevent starting into safe mode after application crashes + 'toolkit.startup.max_resumed_crashes': -1, + }; + + return Object.assign(defaultPrefs, extraPrefs); +} + +/** + * Populates the user.js file with custom preferences as needed to allow + * Firefox's CDP support to properly function. These preferences will be + * automatically copied over to prefs.js during startup of Firefox. To be + * able to restore the original values of preferences a backup of prefs.js + * will be created. + * + * @param prefs - List of preferences to add. + * @param profilePath - Firefox profile to write the preferences to. + */ +async function writePreferences(options: ProfileOptions): Promise<void> { + const lines = Object.entries(options.preferences).map(([key, value]) => { + return `user_pref(${JSON.stringify(key)}, ${JSON.stringify(value)});`; + }); + + await fs.promises.writeFile( + path.join(options.path, 'user.js'), + lines.join('\n') + ); + + // Create a backup of the preferences file if it already exitsts. + const prefsPath = path.join(options.path, 'prefs.js'); + if (fs.existsSync(prefsPath)) { + const prefsBackupPath = path.join(options.path, 'prefs.js.puppeteer'); + await fs.promises.copyFile(prefsPath, prefsBackupPath); + } +} diff --git a/remote/test/puppeteer/packages/browsers/src/browser-data/types.ts b/remote/test/puppeteer/packages/browsers/src/browser-data/types.ts new file mode 100644 index 0000000000..ac72661a2d --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/browser-data/types.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Supported browsers. + * + * @public + */ +export enum Browser { + CHROME = 'chrome', + CHROMEHEADLESSSHELL = 'chrome-headless-shell', + CHROMIUM = 'chromium', + FIREFOX = 'firefox', + CHROMEDRIVER = 'chromedriver', +} + +/** + * Platform names used to identify a OS platform x architecture combination in the way + * that is relevant for the browser download. + * + * @public + */ +export enum BrowserPlatform { + LINUX = 'linux', + MAC = 'mac', + MAC_ARM = 'mac_arm', + WIN32 = 'win32', + WIN64 = 'win64', +} + +/** + * @public + */ +export enum BrowserTag { + CANARY = 'canary', + BETA = 'beta', + DEV = 'dev', + STABLE = 'stable', + LATEST = 'latest', +} + +/** + * @public + */ +export interface ProfileOptions { + preferences: Record<string, unknown>; + path: string; +} + +/** + * @public + */ +export enum ChromeReleaseChannel { + STABLE = 'stable', + DEV = 'dev', + CANARY = 'canary', + BETA = 'beta', +} diff --git a/remote/test/puppeteer/packages/browsers/src/debug.ts b/remote/test/puppeteer/packages/browsers/src/debug.ts new file mode 100644 index 0000000000..491097f41d --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/debug.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import debug from 'debug'; + +export {debug}; diff --git a/remote/test/puppeteer/packages/browsers/src/detectPlatform.ts b/remote/test/puppeteer/packages/browsers/src/detectPlatform.ts new file mode 100644 index 0000000000..df644c38b7 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/detectPlatform.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import os from 'os'; + +import {BrowserPlatform} from './browser-data/browser-data.js'; + +/** + * @public + */ +export function detectBrowserPlatform(): BrowserPlatform | undefined { + const platform = os.platform(); + switch (platform) { + case 'darwin': + return os.arch() === 'arm64' + ? BrowserPlatform.MAC_ARM + : BrowserPlatform.MAC; + case 'linux': + return BrowserPlatform.LINUX; + case 'win32': + return os.arch() === 'x64' || + // Windows 11 for ARM supports x64 emulation + (os.arch() === 'arm64' && isWindows11(os.release())) + ? BrowserPlatform.WIN64 + : BrowserPlatform.WIN32; + default: + return undefined; + } +} + +/** + * Windows 11 is identified by the version 10.0.22000 or greater + * @internal + */ +function isWindows11(version: string): boolean { + const parts = version.split('.'); + if (parts.length > 2) { + const major = parseInt(parts[0] as string, 10); + const minor = parseInt(parts[1] as string, 10); + const patch = parseInt(parts[2] as string, 10); + return ( + major > 10 || + (major === 10 && minor > 0) || + (major === 10 && minor === 0 && patch >= 22000) + ); + } + return false; +} diff --git a/remote/test/puppeteer/packages/browsers/src/fileUtil.ts b/remote/test/puppeteer/packages/browsers/src/fileUtil.ts new file mode 100644 index 0000000000..50a6897853 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/fileUtil.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {exec as execChildProcess} from 'child_process'; +import {createReadStream} from 'fs'; +import {mkdir, readdir} from 'fs/promises'; +import * as path from 'path'; +import {promisify} from 'util'; + +import extractZip from 'extract-zip'; +import tar from 'tar-fs'; +import bzip from 'unbzip2-stream'; + +const exec = promisify(execChildProcess); + +/** + * @internal + */ +export async function unpackArchive( + archivePath: string, + folderPath: string +): Promise<void> { + if (archivePath.endsWith('.zip')) { + await extractZip(archivePath, {dir: folderPath}); + } else if (archivePath.endsWith('.tar.bz2')) { + await extractTar(archivePath, folderPath); + } else if (archivePath.endsWith('.dmg')) { + await mkdir(folderPath); + await installDMG(archivePath, folderPath); + } else { + throw new Error(`Unsupported archive format: ${archivePath}`); + } +} + +/** + * @internal + */ +function extractTar(tarPath: string, folderPath: string): Promise<void> { + return new Promise((fulfill, reject) => { + const tarStream = tar.extract(folderPath); + tarStream.on('error', reject); + tarStream.on('finish', fulfill); + const readStream = createReadStream(tarPath); + readStream.pipe(bzip()).pipe(tarStream); + }); +} + +/** + * @internal + */ +async function installDMG(dmgPath: string, folderPath: string): Promise<void> { + const {stdout} = await exec( + `hdiutil attach -nobrowse -noautoopen "${dmgPath}"` + ); + + const volumes = stdout.match(/\/Volumes\/(.*)/m); + if (!volumes) { + throw new Error(`Could not find volume path in ${stdout}`); + } + const mountPath = volumes[0]!; + + try { + const fileNames = await readdir(mountPath); + const appName = fileNames.find(item => { + return typeof item === 'string' && item.endsWith('.app'); + }); + if (!appName) { + throw new Error(`Cannot find app in ${mountPath}`); + } + const mountedPath = path.join(mountPath!, appName); + + await exec(`cp -R "${mountedPath}" "${folderPath}"`); + } finally { + await exec(`hdiutil detach "${mountPath}" -quiet`); + } +} diff --git a/remote/test/puppeteer/packages/browsers/src/httpUtil.ts b/remote/test/puppeteer/packages/browsers/src/httpUtil.ts new file mode 100644 index 0000000000..96f7fc9f36 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/httpUtil.ts @@ -0,0 +1,151 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {createWriteStream} from 'fs'; +import * as http from 'http'; +import * as https from 'https'; +import {URL, urlToHttpOptions} from 'url'; + +import {ProxyAgent} from 'proxy-agent'; + +export function headHttpRequest(url: URL): Promise<boolean> { + return new Promise(resolve => { + const request = httpRequest( + url, + 'HEAD', + response => { + // consume response data free node process + response.resume(); + resolve(response.statusCode === 200); + }, + false + ); + request.on('error', () => { + resolve(false); + }); + }); +} + +export function httpRequest( + url: URL, + method: string, + response: (x: http.IncomingMessage) => void, + keepAlive = true +): http.ClientRequest { + const options: http.RequestOptions = { + protocol: url.protocol, + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + method, + headers: keepAlive ? {Connection: 'keep-alive'} : undefined, + auth: urlToHttpOptions(url).auth, + agent: new ProxyAgent(), + }; + + const requestCallback = (res: http.IncomingMessage): void => { + if ( + res.statusCode && + res.statusCode >= 300 && + res.statusCode < 400 && + res.headers.location + ) { + httpRequest(new URL(res.headers.location), method, response); + } else { + response(res); + } + }; + const request = + options.protocol === 'https:' + ? https.request(options, requestCallback) + : http.request(options, requestCallback); + request.end(); + return request; +} + +/** + * @internal + */ +export function downloadFile( + url: URL, + destinationPath: string, + progressCallback?: (downloadedBytes: number, totalBytes: number) => void +): Promise<void> { + return new Promise<void>((resolve, reject) => { + let downloadedBytes = 0; + let totalBytes = 0; + + function onData(chunk: string): void { + downloadedBytes += chunk.length; + progressCallback!(downloadedBytes, totalBytes); + } + + const request = httpRequest(url, 'GET', response => { + if (response.statusCode !== 200) { + const error = new Error( + `Download failed: server returned code ${response.statusCode}. URL: ${url}` + ); + // consume response data to free up memory + response.resume(); + reject(error); + return; + } + const file = createWriteStream(destinationPath); + file.on('finish', () => { + return resolve(); + }); + file.on('error', error => { + return reject(error); + }); + response.pipe(file); + totalBytes = parseInt(response.headers['content-length']!, 10); + if (progressCallback) { + response.on('data', onData); + } + }); + request.on('error', error => { + return reject(error); + }); + }); +} + +export async function getJSON(url: URL): Promise<unknown> { + const text = await getText(url); + try { + return JSON.parse(text); + } catch { + throw new Error('Could not parse JSON from ' + url.toString()); + } +} + +export function getText(url: URL): Promise<string> { + return new Promise((resolve, reject) => { + const request = httpRequest( + url, + 'GET', + response => { + let data = ''; + if (response.statusCode && response.statusCode >= 400) { + return reject(new Error(`Got status code ${response.statusCode}`)); + } + response.on('data', chunk => { + data += chunk; + }); + response.on('end', () => { + try { + return resolve(String(data)); + } catch { + return reject(new Error('Chrome version not found')); + } + }); + }, + false + ); + request.on('error', err => { + reject(err); + }); + }); +} diff --git a/remote/test/puppeteer/packages/browsers/src/install.ts b/remote/test/puppeteer/packages/browsers/src/install.ts new file mode 100644 index 0000000000..375c75babc --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/install.ts @@ -0,0 +1,271 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import {existsSync} from 'fs'; +import {mkdir, unlink} from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import { + type Browser, + type BrowserPlatform, + downloadUrls, +} from './browser-data/browser-data.js'; +import {Cache, InstalledBrowser} from './Cache.js'; +import {debug} from './debug.js'; +import {detectBrowserPlatform} from './detectPlatform.js'; +import {unpackArchive} from './fileUtil.js'; +import {downloadFile, headHttpRequest} from './httpUtil.js'; + +const debugInstall = debug('puppeteer:browsers:install'); + +const times = new Map<string, [number, number]>(); +function debugTime(label: string) { + times.set(label, process.hrtime()); +} + +function debugTimeEnd(label: string) { + const end = process.hrtime(); + const start = times.get(label); + if (!start) { + return; + } + const duration = + end[0] * 1000 + end[1] / 1e6 - (start[0] * 1000 + start[1] / 1e6); // calculate duration in milliseconds + debugInstall(`Duration for ${label}: ${duration}ms`); +} + +/** + * @public + */ +export interface InstallOptions { + /** + * Determines the path to download browsers to. + */ + cacheDir: string; + /** + * Determines which platform the browser will be suited for. + * + * @defaultValue **Auto-detected.** + */ + platform?: BrowserPlatform; + /** + * Determines which browser to install. + */ + browser: Browser; + /** + * Determines which buildId to download. BuildId should uniquely identify + * binaries and they are used for caching. + */ + buildId: string; + /** + * Provides information about the progress of the download. + */ + downloadProgressCallback?: ( + downloadedBytes: number, + totalBytes: number + ) => void; + /** + * Determines the host that will be used for downloading. + * + * @defaultValue Either + * + * - https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing or + * - https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central + * + */ + baseUrl?: string; + /** + * Whether to unpack and install browser archives. + * + * @defaultValue `true` + */ + unpack?: boolean; +} + +/** + * @public + */ +export function install( + options: InstallOptions & {unpack?: true} +): Promise<InstalledBrowser>; +/** + * @public + */ +export function install( + options: InstallOptions & {unpack: false} +): Promise<string>; +export async function install( + options: InstallOptions +): Promise<InstalledBrowser | string> { + options.platform ??= detectBrowserPlatform(); + options.unpack ??= true; + if (!options.platform) { + throw new Error( + `Cannot download a binary for the provided platform: ${os.platform()} (${os.arch()})` + ); + } + const url = getDownloadUrl( + options.browser, + options.platform, + options.buildId, + options.baseUrl + ); + const fileName = url.toString().split('/').pop(); + assert(fileName, `A malformed download URL was found: ${url}.`); + const cache = new Cache(options.cacheDir); + const browserRoot = cache.browserRoot(options.browser); + const archivePath = path.join(browserRoot, `${options.buildId}-${fileName}`); + if (!existsSync(browserRoot)) { + await mkdir(browserRoot, {recursive: true}); + } + + if (!options.unpack) { + if (existsSync(archivePath)) { + return archivePath; + } + debugInstall(`Downloading binary from ${url}`); + debugTime('download'); + await downloadFile(url, archivePath, options.downloadProgressCallback); + debugTimeEnd('download'); + return archivePath; + } + + const outputPath = cache.installationDir( + options.browser, + options.platform, + options.buildId + ); + if (existsSync(outputPath)) { + return new InstalledBrowser( + cache, + options.browser, + options.buildId, + options.platform + ); + } + try { + debugInstall(`Downloading binary from ${url}`); + try { + debugTime('download'); + await downloadFile(url, archivePath, options.downloadProgressCallback); + } finally { + debugTimeEnd('download'); + } + + debugInstall(`Installing ${archivePath} to ${outputPath}`); + try { + debugTime('extract'); + await unpackArchive(archivePath, outputPath); + } finally { + debugTimeEnd('extract'); + } + } finally { + if (existsSync(archivePath)) { + await unlink(archivePath); + } + } + return new InstalledBrowser( + cache, + options.browser, + options.buildId, + options.platform + ); +} + +/** + * @public + */ +export interface UninstallOptions { + /** + * Determines the platform for the browser binary. + * + * @defaultValue **Auto-detected.** + */ + platform?: BrowserPlatform; + /** + * The path to the root of the cache directory. + */ + cacheDir: string; + /** + * Determines which browser to uninstall. + */ + browser: Browser; + /** + * The browser build to uninstall + */ + buildId: string; +} + +/** + * + * @public + */ +export async function uninstall(options: UninstallOptions): Promise<void> { + options.platform ??= detectBrowserPlatform(); + if (!options.platform) { + throw new Error( + `Cannot detect the browser platform for: ${os.platform()} (${os.arch()})` + ); + } + + new Cache(options.cacheDir).uninstall( + options.browser, + options.platform, + options.buildId + ); +} + +/** + * @public + */ +export interface GetInstalledBrowsersOptions { + /** + * The path to the root of the cache directory. + */ + cacheDir: string; +} + +/** + * Returns metadata about browsers installed in the cache directory. + * + * @public + */ +export async function getInstalledBrowsers( + options: GetInstalledBrowsersOptions +): Promise<InstalledBrowser[]> { + return new Cache(options.cacheDir).getInstalledBrowsers(); +} + +/** + * @public + */ +export async function canDownload(options: InstallOptions): Promise<boolean> { + options.platform ??= detectBrowserPlatform(); + if (!options.platform) { + throw new Error( + `Cannot download a binary for the provided platform: ${os.platform()} (${os.arch()})` + ); + } + return await headHttpRequest( + getDownloadUrl( + options.browser, + options.platform, + options.buildId, + options.baseUrl + ) + ); +} + +function getDownloadUrl( + browser: Browser, + platform: BrowserPlatform, + buildId: string, + baseUrl?: string +): URL { + return new URL(downloadUrls[browser](platform, buildId, baseUrl)); +} diff --git a/remote/test/puppeteer/packages/browsers/src/launch.ts b/remote/test/puppeteer/packages/browsers/src/launch.ts new file mode 100644 index 0000000000..dfb0fbf633 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/launch.ts @@ -0,0 +1,479 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import childProcess from 'child_process'; +import {accessSync} from 'fs'; +import os from 'os'; +import readline from 'readline'; + +import { + type Browser, + type BrowserPlatform, + resolveSystemExecutablePath, + type ChromeReleaseChannel, +} from './browser-data/browser-data.js'; +import {Cache} from './Cache.js'; +import {debug} from './debug.js'; +import {detectBrowserPlatform} from './detectPlatform.js'; + +const debugLaunch = debug('puppeteer:browsers:launcher'); + +/** + * @public + */ +export interface ComputeExecutablePathOptions { + /** + * Root path to the storage directory. + */ + cacheDir: string; + /** + * Determines which platform the browser will be suited for. + * + * @defaultValue **Auto-detected.** + */ + platform?: BrowserPlatform; + /** + * Determines which browser to launch. + */ + browser: Browser; + /** + * Determines which buildId to download. BuildId should uniquely identify + * binaries and they are used for caching. + */ + buildId: string; +} + +/** + * @public + */ +export function computeExecutablePath( + options: ComputeExecutablePathOptions +): string { + return new Cache(options.cacheDir).computeExecutablePath(options); +} + +/** + * @public + */ +export interface SystemOptions { + /** + * Determines which platform the browser will be suited for. + * + * @defaultValue **Auto-detected.** + */ + platform?: BrowserPlatform; + /** + * Determines which browser to launch. + */ + browser: Browser; + /** + * Release channel to look for on the system. + */ + channel: ChromeReleaseChannel; +} + +/** + * @public + */ +export function computeSystemExecutablePath(options: SystemOptions): string { + options.platform ??= detectBrowserPlatform(); + if (!options.platform) { + throw new Error( + `Cannot download a binary for the provided platform: ${os.platform()} (${os.arch()})` + ); + } + const path = resolveSystemExecutablePath( + options.browser, + options.platform, + options.channel + ); + try { + accessSync(path); + } catch (error) { + throw new Error( + `Could not find Google Chrome executable for channel '${options.channel}' at '${path}'.` + ); + } + return path; +} + +/** + * @public + */ +export interface LaunchOptions { + executablePath: string; + pipe?: boolean; + dumpio?: boolean; + args?: string[]; + env?: Record<string, string | undefined>; + handleSIGINT?: boolean; + handleSIGTERM?: boolean; + handleSIGHUP?: boolean; + detached?: boolean; + onExit?: () => Promise<void>; +} + +/** + * @public + */ +export function launch(opts: LaunchOptions): Process { + return new Process(opts); +} + +/** + * @public + */ +export const CDP_WEBSOCKET_ENDPOINT_REGEX = + /^DevTools listening on (ws:\/\/.*)$/; + +/** + * @public + */ +export const WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_REGEX = + /^WebDriver BiDi listening on (ws:\/\/.*)$/; + +/** + * @public + */ +export class Process { + #executablePath; + #args: string[]; + #browserProcess: childProcess.ChildProcess; + #exited = false; + // The browser process can be closed externally or from the driver process. We + // need to invoke the hooks only once though but we don't know how many times + // we will be invoked. + #hooksRan = false; + #onExitHook = async () => {}; + #browserProcessExiting: Promise<void>; + + constructor(opts: LaunchOptions) { + this.#executablePath = opts.executablePath; + this.#args = opts.args ?? []; + + opts.pipe ??= false; + opts.dumpio ??= false; + opts.handleSIGINT ??= true; + opts.handleSIGTERM ??= true; + opts.handleSIGHUP ??= true; + // On non-windows platforms, `detached: true` makes child process a + // leader of a new process group, making it possible to kill child + // process tree with `.kill(-pid)` command. @see + // https://nodejs.org/api/child_process.html#child_process_options_detached + opts.detached ??= process.platform !== 'win32'; + + const stdio = this.#configureStdio({ + pipe: opts.pipe, + dumpio: opts.dumpio, + }); + + const env = opts.env || {}; + + debugLaunch(`Launching ${this.#executablePath} ${this.#args.join(' ')}`, { + detached: opts.detached, + env: Object.keys(env).reduce<Record<string, string | undefined>>( + (res, key) => { + if (key.toLowerCase().startsWith('puppeteer_')) { + res[key] = env[key]; + } + return res; + }, + {} + ), + stdio, + }); + + this.#browserProcess = childProcess.spawn( + this.#executablePath, + this.#args, + { + detached: opts.detached, + env, + stdio, + } + ); + + debugLaunch(`Launched ${this.#browserProcess.pid}`); + if (opts.dumpio) { + this.#browserProcess.stderr?.pipe(process.stderr); + this.#browserProcess.stdout?.pipe(process.stdout); + } + process.on('exit', this.#onDriverProcessExit); + if (opts.handleSIGINT) { + process.on('SIGINT', this.#onDriverProcessSignal); + } + if (opts.handleSIGTERM) { + process.on('SIGTERM', this.#onDriverProcessSignal); + } + if (opts.handleSIGHUP) { + process.on('SIGHUP', this.#onDriverProcessSignal); + } + if (opts.onExit) { + this.#onExitHook = opts.onExit; + } + this.#browserProcessExiting = new Promise((resolve, reject) => { + this.#browserProcess.once('exit', async () => { + debugLaunch(`Browser process ${this.#browserProcess.pid} onExit`); + this.#clearListeners(); + this.#exited = true; + try { + await this.#runHooks(); + } catch (err) { + reject(err); + return; + } + resolve(); + }); + }); + } + + async #runHooks() { + if (this.#hooksRan) { + return; + } + this.#hooksRan = true; + await this.#onExitHook(); + } + + get nodeProcess(): childProcess.ChildProcess { + return this.#browserProcess; + } + + #configureStdio(opts: { + pipe: boolean; + dumpio: boolean; + }): Array<'ignore' | 'pipe'> { + if (opts.pipe) { + if (opts.dumpio) { + return ['ignore', 'pipe', 'pipe', 'pipe', 'pipe']; + } else { + return ['ignore', 'ignore', 'ignore', 'pipe', 'pipe']; + } + } else { + if (opts.dumpio) { + return ['pipe', 'pipe', 'pipe']; + } else { + return ['pipe', 'ignore', 'pipe']; + } + } + } + + #clearListeners(): void { + process.off('exit', this.#onDriverProcessExit); + process.off('SIGINT', this.#onDriverProcessSignal); + process.off('SIGTERM', this.#onDriverProcessSignal); + process.off('SIGHUP', this.#onDriverProcessSignal); + } + + #onDriverProcessExit = (_code: number) => { + this.kill(); + }; + + #onDriverProcessSignal = (signal: string): void => { + switch (signal) { + case 'SIGINT': + this.kill(); + process.exit(130); + case 'SIGTERM': + case 'SIGHUP': + void this.close(); + break; + } + }; + + async close(): Promise<void> { + await this.#runHooks(); + if (!this.#exited) { + this.kill(); + } + return await this.#browserProcessExiting; + } + + hasClosed(): Promise<void> { + return this.#browserProcessExiting; + } + + kill(): void { + debugLaunch(`Trying to kill ${this.#browserProcess.pid}`); + // If the process failed to launch (for example if the browser executable path + // is invalid), then the process does not get a pid assigned. A call to + // `proc.kill` would error, as the `pid` to-be-killed can not be found. + if ( + this.#browserProcess && + this.#browserProcess.pid && + pidExists(this.#browserProcess.pid) + ) { + try { + debugLaunch(`Browser process ${this.#browserProcess.pid} exists`); + if (process.platform === 'win32') { + try { + childProcess.execSync( + `taskkill /pid ${this.#browserProcess.pid} /T /F` + ); + } catch (error) { + debugLaunch( + `Killing ${this.#browserProcess.pid} using taskkill failed`, + error + ); + // taskkill can fail to kill the process e.g. due to missing permissions. + // Let's kill the process via Node API. This delays killing of all child + // processes of `this.proc` until the main Node.js process dies. + this.#browserProcess.kill(); + } + } else { + // on linux the process group can be killed with the group id prefixed with + // a minus sign. The process group id is the group leader's pid. + const processGroupId = -this.#browserProcess.pid; + + try { + process.kill(processGroupId, 'SIGKILL'); + } catch (error) { + debugLaunch( + `Killing ${this.#browserProcess.pid} using process.kill failed`, + error + ); + // Killing the process group can fail due e.g. to missing permissions. + // Let's kill the process via Node API. This delays killing of all child + // processes of `this.proc` until the main Node.js process dies. + this.#browserProcess.kill('SIGKILL'); + } + } + } catch (error) { + throw new Error( + `${PROCESS_ERROR_EXPLANATION}\nError cause: ${ + isErrorLike(error) ? error.stack : error + }` + ); + } + } + this.#clearListeners(); + } + + waitForLineOutput(regex: RegExp, timeout = 0): Promise<string> { + if (!this.#browserProcess.stderr) { + throw new Error('`browserProcess` does not have stderr.'); + } + const rl = readline.createInterface(this.#browserProcess.stderr); + let stderr = ''; + + return new Promise((resolve, reject) => { + rl.on('line', onLine); + rl.on('close', onClose); + this.#browserProcess.on('exit', onClose); + this.#browserProcess.on('error', onClose); + const timeoutId = + timeout > 0 ? setTimeout(onTimeout, timeout) : undefined; + + const cleanup = (): void => { + if (timeoutId) { + clearTimeout(timeoutId); + } + rl.off('line', onLine); + rl.off('close', onClose); + this.#browserProcess.off('exit', onClose); + this.#browserProcess.off('error', onClose); + }; + + function onClose(error?: Error): void { + cleanup(); + reject( + new Error( + [ + `Failed to launch the browser process!${ + error ? ' ' + error.message : '' + }`, + stderr, + '', + 'TROUBLESHOOTING: https://pptr.dev/troubleshooting', + '', + ].join('\n') + ) + ); + } + + function onTimeout(): void { + cleanup(); + reject( + new TimeoutError( + `Timed out after ${timeout} ms while waiting for the WS endpoint URL to appear in stdout!` + ) + ); + } + + function onLine(line: string): void { + stderr += line + '\n'; + const match = line.match(regex); + if (!match) { + return; + } + cleanup(); + // The RegExp matches, so this will obviously exist. + resolve(match[1]!); + } + }); + } +} + +const PROCESS_ERROR_EXPLANATION = `Puppeteer was unable to kill the process which ran the browser binary. +This means that, on future Puppeteer launches, Puppeteer might not be able to launch the browser. +Please check your open processes and ensure that the browser processes that Puppeteer launched have been killed. +If you think this is a bug, please report it on the Puppeteer issue tracker.`; + +/** + * @internal + */ +function pidExists(pid: number): boolean { + try { + return process.kill(pid, 0); + } catch (error) { + if (isErrnoException(error)) { + if (error.code && error.code === 'ESRCH') { + return false; + } + } + throw error; + } +} + +/** + * @internal + */ +export interface ErrorLike extends Error { + name: string; + message: string; +} + +/** + * @internal + */ +export function isErrorLike(obj: unknown): obj is ErrorLike { + return ( + typeof obj === 'object' && obj !== null && 'name' in obj && 'message' in obj + ); +} +/** + * @internal + */ +export function isErrnoException(obj: unknown): obj is NodeJS.ErrnoException { + return ( + isErrorLike(obj) && + ('errno' in obj || 'code' in obj || 'path' in obj || 'syscall' in obj) + ); +} + +/** + * @public + */ +export class TimeoutError extends Error { + /** + * @internal + */ + constructor(message?: string) { + super(message); + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } +} diff --git a/remote/test/puppeteer/packages/browsers/src/main-cli.ts b/remote/test/puppeteer/packages/browsers/src/main-cli.ts new file mode 100644 index 0000000000..9919a4dfb7 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/main-cli.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {CLI} from './CLI.js'; + +void new CLI().run(process.argv); diff --git a/remote/test/puppeteer/packages/browsers/src/main.ts b/remote/test/puppeteer/packages/browsers/src/main.ts new file mode 100644 index 0000000000..df93de530d --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/main.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export type { + LaunchOptions, + ComputeExecutablePathOptions as Options, + SystemOptions, +} from './launch.js'; +export { + launch, + computeExecutablePath, + computeSystemExecutablePath, + TimeoutError, + CDP_WEBSOCKET_ENDPOINT_REGEX, + WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_REGEX, + Process, +} from './launch.js'; +export type { + InstallOptions, + GetInstalledBrowsersOptions, + UninstallOptions, +} from './install.js'; +export { + install, + getInstalledBrowsers, + canDownload, + uninstall, +} from './install.js'; +export {detectBrowserPlatform} from './detectPlatform.js'; +export type {ProfileOptions} from './browser-data/browser-data.js'; +export { + resolveBuildId, + Browser, + BrowserPlatform, + ChromeReleaseChannel, + createProfile, +} from './browser-data/browser-data.js'; +export {CLI, makeProgressCallback} from './CLI.js'; +export {Cache, InstalledBrowser} from './Cache.js'; diff --git a/remote/test/puppeteer/packages/browsers/src/tsconfig.cjs.json b/remote/test/puppeteer/packages/browsers/src/tsconfig.cjs.json new file mode 100644 index 0000000000..acb1968862 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/tsconfig.cjs.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "../lib/cjs" + } +} diff --git a/remote/test/puppeteer/packages/browsers/src/tsconfig.esm.json b/remote/test/puppeteer/packages/browsers/src/tsconfig.esm.json new file mode 100644 index 0000000000..a824bc8cb8 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/src/tsconfig.esm.json @@ -0,0 +1,6 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../lib/esm" + } +} diff --git a/remote/test/puppeteer/packages/browsers/test/src/chrome-headless-shell/chrome-headless-shell-data.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/chrome-headless-shell/chrome-headless-shell-data.spec.ts new file mode 100644 index 0000000000..65008b5edb --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/chrome-headless-shell/chrome-headless-shell-data.spec.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import path from 'path'; + +import {BrowserPlatform} from '../../../lib/cjs/browser-data/browser-data.js'; +import { + resolveDownloadUrl, + relativeExecutablePath, + resolveBuildId, +} from '../../../lib/cjs/browser-data/chrome-headless-shell.js'; + +describe('chrome-headless-shell', () => { + it('should resolve download URLs', () => { + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.LINUX, '118.0.5950.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/118.0.5950.0/linux64/chrome-headless-shell-linux64.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.MAC, '118.0.5950.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/118.0.5950.0/mac-x64/chrome-headless-shell-mac-x64.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.MAC_ARM, '118.0.5950.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/118.0.5950.0/mac-arm64/chrome-headless-shell-mac-arm64.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.WIN32, '118.0.5950.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/118.0.5950.0/win32/chrome-headless-shell-win32.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.WIN64, '118.0.5950.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/118.0.5950.0/win64/chrome-headless-shell-win64.zip' + ); + }); + + // TODO: once no new releases happen for the milestone, we can use the exact match. + it('should resolve milestones', async () => { + assert((await resolveBuildId('118'))?.startsWith('118.0')); + }); + + it('should resolve build prefix', async () => { + assert.strictEqual(await resolveBuildId('118.0.5950'), '118.0.5950.0'); + }); + + it('should resolve executable paths', () => { + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.LINUX, '12372323'), + path.join('chrome-headless-shell-linux64', 'chrome-headless-shell') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.MAC, '12372323'), + path.join('chrome-headless-shell-mac-x64/', 'chrome-headless-shell') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.MAC_ARM, '12372323'), + path.join('chrome-headless-shell-mac-arm64', 'chrome-headless-shell') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.WIN32, '12372323'), + path.join('chrome-headless-shell-win32', 'chrome-headless-shell.exe') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.WIN64, '12372323'), + path.join('chrome-headless-shell-win64', 'chrome-headless-shell.exe') + ); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/chrome-headless-shell/cli.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/chrome-headless-shell/cli.spec.ts new file mode 100644 index 0000000000..445d0f700e --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/chrome-headless-shell/cli.spec.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import {CLI} from '../../../lib/cjs/CLI.js'; +import { + createMockedReadlineInterface, + setupTestServer, + getServerUrl, +} from '../utils.js'; +import {testChromeHeadlessShellBuildId} from '../versions.js'; + +describe('chrome-headless-shell CLI', function () { + this.timeout(90000); + + setupTestServer(); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'puppeteer-browsers-test')); + }); + + afterEach(async () => { + await new CLI(tmpDir, createMockedReadlineInterface('yes')).run([ + 'npx', + '@puppeteer/browsers', + 'clear', + `--path=${tmpDir}`, + `--base-url=${getServerUrl()}`, + ]); + }); + + it('should download chrome-headless-shell binaries', async () => { + await new CLI(tmpDir).run([ + 'npx', + '@puppeteer/browsers', + 'install', + `chrome-headless-shell@${testChromeHeadlessShellBuildId}`, + `--path=${tmpDir}`, + '--platform=linux', + `--base-url=${getServerUrl()}`, + ]); + assert.ok( + fs.existsSync( + path.join( + tmpDir, + 'chrome-headless-shell', + `linux-${testChromeHeadlessShellBuildId}`, + 'chrome-headless-shell-linux64', + 'chrome-headless-shell' + ) + ) + ); + + await new CLI(tmpDir, createMockedReadlineInterface('no')).run([ + 'npx', + '@puppeteer/browsers', + 'clear', + `--path=${tmpDir}`, + ]); + assert.ok( + fs.existsSync( + path.join( + tmpDir, + 'chrome-headless-shell', + `linux-${testChromeHeadlessShellBuildId}`, + 'chrome-headless-shell-linux64', + 'chrome-headless-shell' + ) + ) + ); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/chrome-headless-shell/install.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/chrome-headless-shell/install.spec.ts new file mode 100644 index 0000000000..88f9fae7fc --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/chrome-headless-shell/install.spec.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + install, + canDownload, + Browser, + BrowserPlatform, + Cache, +} from '../../../lib/cjs/main.js'; +import {getServerUrl, setupTestServer} from '../utils.js'; +import {testChromeDriverBuildId} from '../versions.js'; + +/** + * Tests in this spec use real download URLs and unpack live browser archives + * so it requires the network access. + */ +describe('ChromeDriver install', () => { + setupTestServer(); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'puppeteer-browsers-test')); + }); + + afterEach(() => { + new Cache(tmpDir).clear(); + }); + + it('should check if a buildId can be downloaded', async () => { + assert.ok( + await canDownload({ + cacheDir: tmpDir, + browser: Browser.CHROMEDRIVER, + platform: BrowserPlatform.LINUX, + buildId: testChromeDriverBuildId, + baseUrl: getServerUrl(), + }) + ); + }); + + it('should report if a buildId is not downloadable', async () => { + assert.strictEqual( + await canDownload({ + cacheDir: tmpDir, + browser: Browser.CHROMEDRIVER, + platform: BrowserPlatform.LINUX, + buildId: 'unknown', + baseUrl: getServerUrl(), + }), + false + ); + }); + + it('should download and unpack the binary', async function () { + this.timeout(60000); + const expectedOutputPath = path.join( + tmpDir, + 'chromedriver', + `${BrowserPlatform.LINUX}-${testChromeDriverBuildId}` + ); + assert.strictEqual(fs.existsSync(expectedOutputPath), false); + let browser = await install({ + cacheDir: tmpDir, + browser: Browser.CHROMEDRIVER, + platform: BrowserPlatform.LINUX, + buildId: testChromeDriverBuildId, + baseUrl: getServerUrl(), + }); + assert.strictEqual(browser.path, expectedOutputPath); + assert.ok(fs.existsSync(expectedOutputPath)); + // Second iteration should be no-op. + browser = await install({ + cacheDir: tmpDir, + browser: Browser.CHROMEDRIVER, + platform: BrowserPlatform.LINUX, + buildId: testChromeDriverBuildId, + baseUrl: getServerUrl(), + }); + assert.strictEqual(browser.path, expectedOutputPath); + assert.ok(fs.existsSync(expectedOutputPath)); + assert.ok(fs.existsSync(browser.executablePath)); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/chrome/chrome-data.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/chrome/chrome-data.spec.ts new file mode 100644 index 0000000000..510afa8454 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/chrome/chrome-data.spec.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import path from 'path'; + +import { + BrowserPlatform, + ChromeReleaseChannel, +} from '../../../lib/cjs/browser-data/browser-data.js'; +import { + resolveDownloadUrl, + relativeExecutablePath, + resolveSystemExecutablePath, + resolveBuildId, +} from '../../../lib/cjs/browser-data/chrome.js'; + +describe('Chrome', () => { + it('should resolve download URLs', () => { + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.LINUX, '113.0.5672.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/113.0.5672.0/linux64/chrome-linux64.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.MAC, '113.0.5672.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/113.0.5672.0/mac-x64/chrome-mac-x64.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.MAC_ARM, '113.0.5672.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/113.0.5672.0/mac-arm64/chrome-mac-arm64.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.WIN32, '113.0.5672.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/113.0.5672.0/win32/chrome-win32.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.WIN64, '113.0.5672.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/113.0.5672.0/win64/chrome-win64.zip' + ); + }); + + it('should resolve executable paths', () => { + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.LINUX, '12372323'), + path.join('chrome-linux64', 'chrome') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.MAC, '12372323'), + path.join( + 'chrome-mac-x64', + 'Google Chrome for Testing.app', + 'Contents', + 'MacOS', + 'Google Chrome for Testing' + ) + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.MAC_ARM, '12372323'), + path.join( + 'chrome-mac-arm64', + 'Google Chrome for Testing.app', + 'Contents', + 'MacOS', + 'Google Chrome for Testing' + ) + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.WIN32, '12372323'), + path.join('chrome-win32', 'chrome.exe') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.WIN64, '12372323'), + path.join('chrome-win64', 'chrome.exe') + ); + }); + + it('should resolve system executable path', () => { + process.env['PROGRAMFILES'] = 'C:\\ProgramFiles'; + try { + assert.strictEqual( + resolveSystemExecutablePath( + BrowserPlatform.WIN32, + ChromeReleaseChannel.DEV + ), + 'C:\\ProgramFiles\\Google\\Chrome Dev\\Application\\chrome.exe' + ); + } finally { + delete process.env['PROGRAMFILES']; + } + + assert.strictEqual( + resolveSystemExecutablePath( + BrowserPlatform.MAC, + ChromeReleaseChannel.BETA + ), + '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta' + ); + assert.throws(() => { + assert.strictEqual( + resolveSystemExecutablePath( + BrowserPlatform.LINUX, + ChromeReleaseChannel.CANARY + ), + path.join('chrome-linux', 'chrome') + ); + }, new Error(`Unable to detect browser executable path for 'canary' on linux.`)); + }); + + it('should resolve milestones', async () => { + assert.strictEqual(await resolveBuildId('115'), '115.0.5790.170'); + }); + + it('should resolve build prefix', async () => { + assert.strictEqual(await resolveBuildId('115.0.5790'), '115.0.5790.170'); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/chrome/cli.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/chrome/cli.spec.ts new file mode 100644 index 0000000000..bdda9d9aa9 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/chrome/cli.spec.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import {CLI} from '../../../lib/cjs/CLI.js'; +import { + createMockedReadlineInterface, + setupTestServer, + getServerUrl, +} from '../utils.js'; +import {testChromeBuildId} from '../versions.js'; + +describe('Chrome CLI', function () { + this.timeout(90000); + + setupTestServer(); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'puppeteer-browsers-test')); + }); + + afterEach(async () => { + await new CLI(tmpDir, createMockedReadlineInterface('yes')).run([ + 'npx', + '@puppeteer/browsers', + 'clear', + `--path=${tmpDir}`, + `--base-url=${getServerUrl()}`, + ]); + }); + + it('should download Chrome binaries', async () => { + await new CLI(tmpDir).run([ + 'npx', + '@puppeteer/browsers', + 'install', + `chrome@${testChromeBuildId}`, + `--path=${tmpDir}`, + '--platform=linux', + `--base-url=${getServerUrl()}`, + ]); + assert.ok( + fs.existsSync( + path.join( + tmpDir, + 'chrome', + `linux-${testChromeBuildId}`, + 'chrome-linux64', + 'chrome' + ) + ) + ); + + await new CLI(tmpDir, createMockedReadlineInterface('no')).run([ + 'npx', + '@puppeteer/browsers', + 'clear', + `--path=${tmpDir}`, + ]); + assert.ok( + fs.existsSync( + path.join( + tmpDir, + 'chrome', + `linux-${testChromeBuildId}`, + 'chrome-linux64', + 'chrome' + ) + ) + ); + }); + + // Skipped because the current latest is not published yet. + it.skip('should download latest Chrome binaries', async () => { + await new CLI(tmpDir).run([ + 'npx', + '@puppeteer/browsers', + 'install', + `chrome@latest`, + `--path=${tmpDir}`, + '--platform=linux', + `--base-url=${getServerUrl()}`, + ]); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/chrome/install.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/chrome/install.spec.ts new file mode 100644 index 0000000000..8103ff3612 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/chrome/install.spec.ts @@ -0,0 +1,233 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import http from 'http'; +import https from 'https'; +import os from 'os'; +import path from 'path'; + +import { + install, + canDownload, + Browser, + BrowserPlatform, + Cache, +} from '../../../lib/cjs/main.js'; +import {getServerUrl, setupTestServer} from '../utils.js'; +import {testChromeBuildId} from '../versions.js'; + +/** + * Tests in this spec use real download URLs and unpack live browser archives + * so it requires the network access. + */ +describe('Chrome install', () => { + setupTestServer(); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'puppeteer-browsers-test')); + }); + + afterEach(() => { + new Cache(tmpDir).clear(); + }); + + it('should check if a buildId can be downloaded', async () => { + assert.ok( + await canDownload({ + cacheDir: tmpDir, + browser: Browser.CHROME, + platform: BrowserPlatform.LINUX, + buildId: testChromeBuildId, + baseUrl: getServerUrl(), + }) + ); + }); + + it('should report if a buildId is not downloadable', async () => { + assert.strictEqual( + await canDownload({ + cacheDir: tmpDir, + browser: Browser.CHROME, + platform: BrowserPlatform.LINUX, + buildId: 'unknown', + baseUrl: getServerUrl(), + }), + false + ); + }); + + it('should download a buildId that is a zip archive', async function () { + this.timeout(60000); + const expectedOutputPath = path.join( + tmpDir, + 'chrome', + `${BrowserPlatform.LINUX}-${testChromeBuildId}` + ); + assert.strictEqual(fs.existsSync(expectedOutputPath), false); + let browser = await install({ + cacheDir: tmpDir, + browser: Browser.CHROME, + platform: BrowserPlatform.LINUX, + buildId: testChromeBuildId, + baseUrl: getServerUrl(), + }); + assert.strictEqual(browser.path, expectedOutputPath); + assert.ok(fs.existsSync(expectedOutputPath)); + // Second iteration should be no-op. + browser = await install({ + cacheDir: tmpDir, + browser: Browser.CHROME, + platform: BrowserPlatform.LINUX, + buildId: testChromeBuildId, + baseUrl: getServerUrl(), + }); + assert.strictEqual(browser.path, expectedOutputPath); + assert.ok(fs.existsSync(expectedOutputPath)); + // Should discover installed browsers. + const cache = new Cache(tmpDir); + const installed = cache.getInstalledBrowsers(); + assert.deepStrictEqual(browser, installed[0]); + assert.deepStrictEqual( + browser!.executablePath, + installed[0]?.executablePath + ); + }); + + it('throws on invalid URL', async function () { + const expectedOutputPath = path.join( + tmpDir, + 'chrome', + `${BrowserPlatform.LINUX}-${testChromeBuildId}` + ); + assert.strictEqual(fs.existsSync(expectedOutputPath), false); + + async function installThatThrows(): Promise<unknown> { + try { + await install({ + cacheDir: tmpDir, + browser: Browser.CHROME, + platform: BrowserPlatform.LINUX, + buildId: testChromeBuildId, + baseUrl: 'https://127.0.0.1', + }); + return undefined; + } catch (err) { + return err; + } + } + assert.ok(await installThatThrows()); + assert.strictEqual(fs.existsSync(expectedOutputPath), false); + }); + + describe('with proxy', () => { + const proxyUrl = new URL(`http://localhost:54321`); + let proxyServer: http.Server; + let proxiedRequestUrls: string[] = []; + let proxiedRequestHosts: string[] = []; + + beforeEach(() => { + proxiedRequestUrls = []; + proxiedRequestHosts = []; + proxyServer = http + .createServer( + ( + originalRequest: http.IncomingMessage, + originalResponse: http.ServerResponse + ) => { + const url = originalRequest.url as string; + const proxyRequest = ( + url.startsWith('http:') ? http : https + ).request( + url, + { + method: originalRequest.method, + rejectUnauthorized: false, + }, + proxyResponse => { + originalResponse.writeHead( + proxyResponse.statusCode as number, + proxyResponse.headers + ); + proxyResponse.pipe(originalResponse, {end: true}); + } + ); + originalRequest.pipe(proxyRequest, {end: true}); + proxiedRequestUrls.push(url); + proxiedRequestHosts.push(originalRequest.headers?.host || ''); + } + ) + .listen({ + port: proxyUrl.port, + hostname: proxyUrl.hostname, + }); + + process.env['HTTPS_PROXY'] = proxyUrl.toString(); + process.env['HTTP_PROXY'] = proxyUrl.toString(); + }); + + afterEach(async () => { + await new Promise((resolve, reject) => { + proxyServer.close(error => { + if (error) { + reject(error); + } else { + resolve(undefined); + } + }); + }); + delete process.env['HTTP_PROXY']; + delete process.env['HTTPS_PROXY']; + }); + + it('can send canDownload requests via a proxy', async () => { + assert.strictEqual( + await canDownload({ + cacheDir: tmpDir, + browser: Browser.CHROME, + platform: BrowserPlatform.LINUX, + buildId: testChromeBuildId, + baseUrl: getServerUrl(), + }), + true + ); + assert.deepStrictEqual(proxiedRequestUrls, [ + getServerUrl() + '/113.0.5672.0/linux64/chrome-linux64.zip', + ]); + assert.deepStrictEqual(proxiedRequestHosts, [ + getServerUrl().replace('http://', ''), + ]); + }); + + it('can download via a proxy', async function () { + this.timeout(120000); + const expectedOutputPath = path.join( + tmpDir, + 'chrome', + `${BrowserPlatform.LINUX}-${testChromeBuildId}` + ); + assert.strictEqual(fs.existsSync(expectedOutputPath), false); + const browser = await install({ + cacheDir: tmpDir, + browser: Browser.CHROME, + platform: BrowserPlatform.LINUX, + buildId: testChromeBuildId, + baseUrl: getServerUrl(), + }); + assert.strictEqual(browser.path, expectedOutputPath); + assert.ok(fs.existsSync(expectedOutputPath)); + assert.deepStrictEqual(proxiedRequestUrls, [ + getServerUrl() + '/113.0.5672.0/linux64/chrome-linux64.zip', + ]); + assert.deepStrictEqual(proxiedRequestHosts, [ + getServerUrl().replace('http://', ''), + ]); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/chrome/launch.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/chrome/launch.spec.ts new file mode 100644 index 0000000000..c420d9e0b6 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/chrome/launch.spec.ts @@ -0,0 +1,122 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + CDP_WEBSOCKET_ENDPOINT_REGEX, + computeExecutablePath, + launch, + install, + Browser, + BrowserPlatform, +} from '../../../lib/cjs/main.js'; +import {getServerUrl, setupTestServer, clearCache} from '../utils.js'; +import {testChromeBuildId} from '../versions.js'; + +describe('Chrome', () => { + it('should compute executable path for Chrome', () => { + assert.strictEqual( + computeExecutablePath({ + browser: Browser.CHROME, + platform: BrowserPlatform.LINUX, + buildId: '123', + cacheDir: '.cache', + }), + path.join('.cache', 'chrome', 'linux-123', 'chrome-linux64', 'chrome') + ); + }); + + describe('launcher', function () { + setupTestServer(); + + this.timeout(60000); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(async () => { + tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'puppeteer-browsers-test') + ); + await install({ + cacheDir: tmpDir, + browser: Browser.CHROME, + buildId: testChromeBuildId, + baseUrl: getServerUrl(), + }); + }); + + afterEach(() => { + clearCache(tmpDir); + }); + + function getArgs() { + return [ + '--allow-pre-commit-input', + '--disable-background-networking', + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-breakpad', + '--disable-client-side-phishing-detection', + '--disable-component-extensions-with-background-pages', + '--disable-component-update', + '--disable-default-apps', + '--disable-dev-shm-usage', + '--disable-extensions', + '--disable-features=Translate,BackForwardCache,AcceptCHFrame,MediaRouter,OptimizationHints,DialMediaRouteProvider', + '--disable-hang-monitor', + '--disable-ipc-flooding-protection', + '--disable-popup-blocking', + '--disable-prompt-on-repost', + '--disable-renderer-backgrounding', + '--disable-sync', + '--enable-automation', + '--enable-features=NetworkServiceInProcess2', + '--export-tagged-pdf', + '--force-color-profile=srgb', + '--headless=new', + '--metrics-recording-only', + '--no-first-run', + '--password-store=basic', + '--remote-debugging-port=9222', + '--use-mock-keychain', + `--user-data-dir=${path.join(tmpDir, 'profile')}`, + 'about:blank', + ]; + } + + it('should launch a Chrome browser', async () => { + const executablePath = computeExecutablePath({ + cacheDir: tmpDir, + browser: Browser.CHROME, + buildId: testChromeBuildId, + }); + const process = launch({ + executablePath, + args: getArgs(), + }); + await process.close(); + }); + + it('should allow parsing stderr output of the browser process', async () => { + const executablePath = computeExecutablePath({ + cacheDir: tmpDir, + browser: Browser.CHROME, + buildId: testChromeBuildId, + }); + const process = launch({ + executablePath, + args: getArgs(), + }); + const url = await process.waitForLineOutput(CDP_WEBSOCKET_ENDPOINT_REGEX); + await process.close(); + assert.ok(url.startsWith('ws://127.0.0.1:9222/devtools/browser')); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/chromedriver/chromedriver-data.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/chromedriver/chromedriver-data.spec.ts new file mode 100644 index 0000000000..62522d88f4 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/chromedriver/chromedriver-data.spec.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import path from 'path'; + +import {BrowserPlatform} from '../../../lib/cjs/browser-data/browser-data.js'; +import { + resolveDownloadUrl, + relativeExecutablePath, + resolveBuildId, +} from '../../../lib/cjs/browser-data/chromedriver.js'; + +describe('ChromeDriver', () => { + it('should resolve download URLs', () => { + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.LINUX, '115.0.5763.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/115.0.5763.0/linux64/chromedriver-linux64.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.MAC, '115.0.5763.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/115.0.5763.0/mac-x64/chromedriver-mac-x64.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.MAC_ARM, '115.0.5763.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/115.0.5763.0/mac-arm64/chromedriver-mac-arm64.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.WIN32, '115.0.5763.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/115.0.5763.0/win32/chromedriver-win32.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.WIN64, '115.0.5763.0'), + 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/115.0.5763.0/win64/chromedriver-win64.zip' + ); + }); + + it('should resolve milestones', async () => { + assert.strictEqual(await resolveBuildId('115'), '115.0.5790.170'); + }); + + it('should resolve build prefix', async () => { + assert.strictEqual(await resolveBuildId('115.0.5790'), '115.0.5790.170'); + }); + + it('should resolve executable paths', () => { + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.LINUX, '12372323'), + path.join('chromedriver-linux64', 'chromedriver') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.MAC, '12372323'), + path.join('chromedriver-mac-x64/', 'chromedriver') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.MAC_ARM, '12372323'), + path.join('chromedriver-mac-arm64', 'chromedriver') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.WIN32, '12372323'), + path.join('chromedriver-win32', 'chromedriver.exe') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.WIN64, '12372323'), + path.join('chromedriver-win64', 'chromedriver.exe') + ); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/chromedriver/cli.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/chromedriver/cli.spec.ts new file mode 100644 index 0000000000..d407062a88 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/chromedriver/cli.spec.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import {CLI} from '../../../lib/cjs/CLI.js'; +import { + createMockedReadlineInterface, + setupTestServer, + getServerUrl, +} from '../utils.js'; +import {testChromeDriverBuildId} from '../versions.js'; + +describe('ChromeDriver CLI', function () { + this.timeout(90000); + + setupTestServer(); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'puppeteer-browsers-test')); + }); + + afterEach(async () => { + await new CLI(tmpDir, createMockedReadlineInterface('yes')).run([ + 'npx', + '@puppeteer/browsers', + 'clear', + `--path=${tmpDir}`, + `--base-url=${getServerUrl()}`, + ]); + }); + + it('should download ChromeDriver binaries', async () => { + await new CLI(tmpDir).run([ + 'npx', + '@puppeteer/browsers', + 'install', + `chromedriver@${testChromeDriverBuildId}`, + `--path=${tmpDir}`, + '--platform=linux', + `--base-url=${getServerUrl()}`, + ]); + assert.ok( + fs.existsSync( + path.join( + tmpDir, + 'chromedriver', + `linux-${testChromeDriverBuildId}`, + 'chromedriver-linux64', + 'chromedriver' + ) + ) + ); + + await new CLI(tmpDir, createMockedReadlineInterface('no')).run([ + 'npx', + '@puppeteer/browsers', + 'clear', + `--path=${tmpDir}`, + ]); + assert.ok( + fs.existsSync( + path.join( + tmpDir, + 'chromedriver', + `linux-${testChromeDriverBuildId}`, + 'chromedriver-linux64', + 'chromedriver' + ) + ) + ); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/chromedriver/install.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/chromedriver/install.spec.ts new file mode 100644 index 0000000000..88f9fae7fc --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/chromedriver/install.spec.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + install, + canDownload, + Browser, + BrowserPlatform, + Cache, +} from '../../../lib/cjs/main.js'; +import {getServerUrl, setupTestServer} from '../utils.js'; +import {testChromeDriverBuildId} from '../versions.js'; + +/** + * Tests in this spec use real download URLs and unpack live browser archives + * so it requires the network access. + */ +describe('ChromeDriver install', () => { + setupTestServer(); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'puppeteer-browsers-test')); + }); + + afterEach(() => { + new Cache(tmpDir).clear(); + }); + + it('should check if a buildId can be downloaded', async () => { + assert.ok( + await canDownload({ + cacheDir: tmpDir, + browser: Browser.CHROMEDRIVER, + platform: BrowserPlatform.LINUX, + buildId: testChromeDriverBuildId, + baseUrl: getServerUrl(), + }) + ); + }); + + it('should report if a buildId is not downloadable', async () => { + assert.strictEqual( + await canDownload({ + cacheDir: tmpDir, + browser: Browser.CHROMEDRIVER, + platform: BrowserPlatform.LINUX, + buildId: 'unknown', + baseUrl: getServerUrl(), + }), + false + ); + }); + + it('should download and unpack the binary', async function () { + this.timeout(60000); + const expectedOutputPath = path.join( + tmpDir, + 'chromedriver', + `${BrowserPlatform.LINUX}-${testChromeDriverBuildId}` + ); + assert.strictEqual(fs.existsSync(expectedOutputPath), false); + let browser = await install({ + cacheDir: tmpDir, + browser: Browser.CHROMEDRIVER, + platform: BrowserPlatform.LINUX, + buildId: testChromeDriverBuildId, + baseUrl: getServerUrl(), + }); + assert.strictEqual(browser.path, expectedOutputPath); + assert.ok(fs.existsSync(expectedOutputPath)); + // Second iteration should be no-op. + browser = await install({ + cacheDir: tmpDir, + browser: Browser.CHROMEDRIVER, + platform: BrowserPlatform.LINUX, + buildId: testChromeDriverBuildId, + baseUrl: getServerUrl(), + }); + assert.strictEqual(browser.path, expectedOutputPath); + assert.ok(fs.existsSync(expectedOutputPath)); + assert.ok(fs.existsSync(browser.executablePath)); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/chromium/chromium-data.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/chromium/chromium-data.spec.ts new file mode 100644 index 0000000000..601efccc47 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/chromium/chromium-data.spec.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import path from 'path'; + +import {BrowserPlatform} from '../../../lib/cjs/browser-data/browser-data.js'; +import { + resolveDownloadUrl, + relativeExecutablePath, +} from '../../../lib/cjs/browser-data/chromium.js'; + +describe('Chromium', () => { + it('should resolve download URLs', () => { + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.LINUX, '1083080'), + 'https://storage.googleapis.com/chromium-browser-snapshots/Linux_x64/1083080/chrome-linux.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.MAC, '1083080'), + 'https://storage.googleapis.com/chromium-browser-snapshots/Mac/1083080/chrome-mac.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.MAC_ARM, '1083080'), + 'https://storage.googleapis.com/chromium-browser-snapshots/Mac_Arm/1083080/chrome-mac.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.WIN32, '1083080'), + 'https://storage.googleapis.com/chromium-browser-snapshots/Win/1083080/chrome-win.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.WIN64, '1083080'), + 'https://storage.googleapis.com/chromium-browser-snapshots/Win_x64/1083080/chrome-win.zip' + ); + }); + + it('should resolve executable paths', () => { + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.LINUX, '12372323'), + path.join('chrome-linux', 'chrome') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.MAC, '12372323'), + path.join('chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.MAC_ARM, '12372323'), + path.join('chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.WIN32, '12372323'), + path.join('chrome-win', 'chrome.exe') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.WIN64, '12372323'), + path.join('chrome-win', 'chrome.exe') + ); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/chromium/launch.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/chromium/launch.spec.ts new file mode 100644 index 0000000000..8cf7c8255b --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/chromium/launch.spec.ts @@ -0,0 +1,122 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + CDP_WEBSOCKET_ENDPOINT_REGEX, + computeExecutablePath, + launch, + install, + Browser, + BrowserPlatform, +} from '../../../lib/cjs/main.js'; +import {getServerUrl, setupTestServer, clearCache} from '../utils.js'; +import {testChromiumBuildId} from '../versions.js'; + +describe('Chromium', () => { + it('should compute executable path for Chromium', () => { + assert.strictEqual( + computeExecutablePath({ + browser: Browser.CHROMIUM, + platform: BrowserPlatform.LINUX, + buildId: '123', + cacheDir: '.cache', + }), + path.join('.cache', 'chromium', 'linux-123', 'chrome-linux', 'chrome') + ); + }); + + describe('launcher', function () { + setupTestServer(); + + this.timeout(120000); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(async () => { + tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'puppeteer-browsers-test') + ); + await install({ + cacheDir: tmpDir, + browser: Browser.CHROMIUM, + buildId: testChromiumBuildId, + baseUrl: getServerUrl(), + }); + }); + + afterEach(() => { + clearCache(tmpDir); + }); + + function getArgs() { + return [ + '--allow-pre-commit-input', + '--disable-background-networking', + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-breakpad', + '--disable-client-side-phishing-detection', + '--disable-component-extensions-with-background-pages', + '--disable-component-update', + '--disable-default-apps', + '--disable-dev-shm-usage', + '--disable-extensions', + '--disable-features=Translate,BackForwardCache,AcceptCHFrame,MediaRouter,OptimizationHints,DialMediaRouteProvider', + '--disable-hang-monitor', + '--disable-ipc-flooding-protection', + '--disable-popup-blocking', + '--disable-prompt-on-repost', + '--disable-renderer-backgrounding', + '--disable-sync', + '--enable-automation', + '--enable-features=NetworkServiceInProcess2', + '--export-tagged-pdf', + '--force-color-profile=srgb', + '--headless=new', + '--metrics-recording-only', + '--no-first-run', + '--password-store=basic', + '--remote-debugging-port=9222', + '--use-mock-keychain', + `--user-data-dir=${path.join(tmpDir, 'profile')}`, + 'about:blank', + ]; + } + + it('should launch a Chromium browser', async () => { + const executablePath = computeExecutablePath({ + cacheDir: tmpDir, + browser: Browser.CHROMIUM, + buildId: testChromiumBuildId, + }); + const process = launch({ + executablePath, + args: getArgs(), + }); + await process.close(); + }); + + it('should allow parsing stderr output of the browser process', async () => { + const executablePath = computeExecutablePath({ + cacheDir: tmpDir, + browser: Browser.CHROMIUM, + buildId: testChromiumBuildId, + }); + const process = launch({ + executablePath, + args: getArgs(), + }); + const url = await process.waitForLineOutput(CDP_WEBSOCKET_ENDPOINT_REGEX); + await process.close(); + assert.ok(url.startsWith('ws://127.0.0.1:9222/devtools/browser')); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/firefox/cli.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/firefox/cli.spec.ts new file mode 100644 index 0000000000..134b432641 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/firefox/cli.spec.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import sinon from 'sinon'; + +import {CLI} from '../../../lib/cjs/CLI.js'; +import * as httpUtil from '../../../lib/cjs/httpUtil.js'; +import { + createMockedReadlineInterface, + getServerUrl, + setupTestServer, +} from '../utils.js'; +import {testFirefoxBuildId} from '../versions.js'; + +describe('Firefox CLI', function () { + this.timeout(90000); + + setupTestServer(); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'puppeteer-browsers-test')); + }); + + afterEach(async () => { + await new CLI(tmpDir, createMockedReadlineInterface('yes')).run([ + 'npx', + '@puppeteer/browsers', + 'clear', + `--path=${tmpDir}`, + `--base-url=${getServerUrl()}`, + ]); + + sinon.restore(); + }); + + it('should download Firefox binaries', async () => { + await new CLI(tmpDir).run([ + 'npx', + '@puppeteer/browsers', + 'install', + `firefox@${testFirefoxBuildId}`, + `--path=${tmpDir}`, + '--platform=linux', + `--base-url=${getServerUrl()}`, + ]); + assert.ok( + fs.existsSync( + path.join(tmpDir, 'firefox', `linux-${testFirefoxBuildId}`, 'firefox') + ) + ); + }); + + it('should download latest Firefox binaries', async () => { + sinon + .stub(httpUtil, 'getJSON') + .returns(Promise.resolve({FIREFOX_NIGHTLY: testFirefoxBuildId})); + await new CLI(tmpDir).run([ + 'npx', + '@puppeteer/browsers', + 'install', + `firefox@latest`, + `--path=${tmpDir}`, + '--platform=linux', + `--base-url=${getServerUrl()}`, + ]); + + await new CLI(tmpDir).run([ + 'npx', + '@puppeteer/browsers', + 'install', + `firefox`, + `--path=${tmpDir}`, + '--platform=linux', + `--base-url=${getServerUrl()}`, + ]); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/firefox/firefox-data.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/firefox/firefox-data.spec.ts new file mode 100644 index 0000000000..d0bb056090 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/firefox/firefox-data.spec.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import {BrowserPlatform} from '../../../lib/cjs/browser-data/browser-data.js'; +import { + createProfile, + relativeExecutablePath, + resolveDownloadUrl, +} from '../../../lib/cjs/browser-data/firefox.js'; + +describe('Firefox', () => { + it('should resolve download URLs', () => { + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.LINUX, '111.0a1'), + 'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central/firefox-111.0a1.en-US.linux-x86_64.tar.bz2' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.MAC, '111.0a1'), + 'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central/firefox-111.0a1.en-US.mac.dmg' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.MAC_ARM, '111.0a1'), + 'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central/firefox-111.0a1.en-US.mac.dmg' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.WIN32, '111.0a1'), + 'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central/firefox-111.0a1.en-US.win32.zip' + ); + assert.strictEqual( + resolveDownloadUrl(BrowserPlatform.WIN64, '111.0a1'), + 'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central/firefox-111.0a1.en-US.win64.zip' + ); + }); + + it('should resolve executable paths', () => { + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.LINUX, '111.0a1'), + path.join('firefox', 'firefox') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.MAC, '111.0a1'), + path.join('Firefox Nightly.app', 'Contents', 'MacOS', 'firefox') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.MAC_ARM, '111.0a1'), + path.join('Firefox Nightly.app', 'Contents', 'MacOS', 'firefox') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.WIN32, '111.0a1'), + path.join('firefox', 'firefox.exe') + ); + assert.strictEqual( + relativeExecutablePath(BrowserPlatform.WIN64, '111.0a1'), + path.join('firefox', 'firefox.exe') + ); + }); + + describe('profile', () => { + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(() => { + tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'puppeteer-browsers-test') + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { + force: true, + recursive: true, + maxRetries: 5, + }); + }); + + it('should create a profile', async () => { + await createProfile({ + preferences: { + test: 1, + }, + path: tmpDir, + }); + const text = fs.readFileSync(path.join(tmpDir, 'user.js'), 'utf-8'); + assert.ok( + text.includes(`user_pref("toolkit.startup.max_resumed_crashes", -1);`) + ); // default preference. + assert.ok(text.includes(`user_pref("test", 1);`)); // custom preference. + }); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/firefox/install.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/firefox/install.spec.ts new file mode 100644 index 0000000000..1bada43729 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/firefox/install.spec.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import {install, Browser, BrowserPlatform} from '../../../lib/cjs/main.js'; +import {setupTestServer, getServerUrl, clearCache} from '../utils.js'; +import {testFirefoxBuildId} from '../versions.js'; + +/** + * Tests in this spec use real download URLs and unpack live browser archives + * so it requires the network access. + */ +describe('Firefox install', () => { + setupTestServer(); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'puppeteer-browsers-test')); + }); + + afterEach(() => { + clearCache(tmpDir); + }); + + it('should download a buildId that is a bzip2 archive', async function () { + this.timeout(90000); + const expectedOutputPath = path.join( + tmpDir, + 'firefox', + `${BrowserPlatform.LINUX}-${testFirefoxBuildId}` + ); + assert.strictEqual(fs.existsSync(expectedOutputPath), false); + const browser = await install({ + cacheDir: tmpDir, + browser: Browser.FIREFOX, + platform: BrowserPlatform.LINUX, + buildId: testFirefoxBuildId, + baseUrl: getServerUrl(), + }); + assert.strictEqual(browser.path, expectedOutputPath); + assert.ok(fs.existsSync(expectedOutputPath)); + }); + + // install relies on the `hdiutil` utility on MacOS. + // The utility is not available on other platforms. + (os.platform() === 'darwin' ? it : it.skip)( + 'should download a buildId that is a dmg archive', + async function () { + this.timeout(180000); + const expectedOutputPath = path.join( + tmpDir, + 'firefox', + `${BrowserPlatform.MAC}-${testFirefoxBuildId}` + ); + assert.strictEqual(fs.existsSync(expectedOutputPath), false); + const browser = await install({ + cacheDir: tmpDir, + browser: Browser.FIREFOX, + platform: BrowserPlatform.MAC, + buildId: testFirefoxBuildId, + baseUrl: getServerUrl(), + }); + assert.strictEqual(browser.path, expectedOutputPath); + assert.ok(fs.existsSync(expectedOutputPath)); + } + ); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/firefox/launch.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/firefox/launch.spec.ts new file mode 100644 index 0000000000..3c62c87448 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/firefox/launch.spec.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + computeExecutablePath, + launch, + install, + Browser, + BrowserPlatform, + createProfile, +} from '../../../lib/cjs/main.js'; +import {setupTestServer, getServerUrl, clearCache} from '../utils.js'; +import {testFirefoxBuildId} from '../versions.js'; + +describe('Firefox', () => { + it('should compute executable path for Firefox', () => { + assert.strictEqual( + computeExecutablePath({ + browser: Browser.FIREFOX, + platform: BrowserPlatform.LINUX, + buildId: '123', + cacheDir: '.cache', + }), + path.join('.cache', 'firefox', 'linux-123', 'firefox', 'firefox') + ); + }); + + describe('launcher', function () { + this.timeout(120000); + + setupTestServer(); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(async () => { + tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'puppeteer-browsers-test') + ); + await install({ + cacheDir: tmpDir, + browser: Browser.FIREFOX, + buildId: testFirefoxBuildId, + baseUrl: getServerUrl(), + }); + }); + + afterEach(() => { + clearCache(tmpDir); + }); + + it('should launch a Firefox browser', async () => { + const userDataDir = path.join(tmpDir, 'profile'); + function getArgs(): string[] { + const firefoxArguments = ['--no-remote']; + switch (os.platform()) { + case 'darwin': + firefoxArguments.push('--foreground'); + break; + case 'win32': + firefoxArguments.push('--wait-for-browser'); + break; + } + firefoxArguments.push('--profile', userDataDir); + firefoxArguments.push('--headless'); + firefoxArguments.push('about:blank'); + return firefoxArguments; + } + await createProfile(Browser.FIREFOX, { + path: userDataDir, + preferences: {}, + }); + const executablePath = computeExecutablePath({ + cacheDir: tmpDir, + browser: Browser.FIREFOX, + buildId: testFirefoxBuildId, + }); + const process = launch({ + executablePath, + args: getArgs(), + }); + await process.close(); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/mocha-utils.ts b/remote/test/puppeteer/packages/browsers/test/src/mocha-utils.ts new file mode 100644 index 0000000000..245a0048b2 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/mocha-utils.ts @@ -0,0 +1,8 @@ +import debug from 'debug'; + +export const mochaHooks = { + async beforeAll(): Promise<void> { + // Enable logging for Debug + debug.enable('puppeteer:*'); + }, +}; diff --git a/remote/test/puppeteer/packages/browsers/test/src/tsconfig.json b/remote/test/puppeteer/packages/browsers/test/src/tsconfig.json new file mode 100644 index 0000000000..03eae4a458 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "../build", + }, + "references": [{"path": "../../tsconfig.json"}], +} diff --git a/remote/test/puppeteer/packages/browsers/test/src/tsdoc.json b/remote/test/puppeteer/packages/browsers/test/src/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} diff --git a/remote/test/puppeteer/packages/browsers/test/src/uninstall.spec.ts b/remote/test/puppeteer/packages/browsers/test/src/uninstall.spec.ts new file mode 100644 index 0000000000..0ef8a20fde --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/uninstall.spec.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + install, + uninstall, + Browser, + BrowserPlatform, + Cache, +} from '../../lib/cjs/main.js'; + +import {getServerUrl, setupTestServer} from './utils.js'; +import {testChromeBuildId} from './versions.js'; + +describe('common', () => { + setupTestServer(); + + let tmpDir = '/tmp/puppeteer-browsers-test'; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'puppeteer-browsers-test')); + }); + + afterEach(() => { + new Cache(tmpDir).clear(); + }); + + it('should uninstall a browser', async function () { + this.timeout(60000); + const expectedOutputPath = path.join( + tmpDir, + 'chrome', + `${BrowserPlatform.LINUX}-${testChromeBuildId}` + ); + assert.strictEqual(fs.existsSync(expectedOutputPath), false); + const browser = await install({ + cacheDir: tmpDir, + browser: Browser.CHROME, + platform: BrowserPlatform.LINUX, + buildId: testChromeBuildId, + baseUrl: getServerUrl(), + }); + assert.strictEqual(browser.path, expectedOutputPath); + assert.ok(fs.existsSync(expectedOutputPath)); + + await uninstall({ + cacheDir: tmpDir, + browser: Browser.CHROME, + platform: BrowserPlatform.LINUX, + buildId: testChromeBuildId, + }); + assert.strictEqual(browser.path, expectedOutputPath); + assert.strictEqual(fs.existsSync(expectedOutputPath), false); + }); +}); diff --git a/remote/test/puppeteer/packages/browsers/test/src/utils.ts b/remote/test/puppeteer/packages/browsers/test/src/utils.ts new file mode 100644 index 0000000000..bae231423e --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/utils.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {execSync} from 'child_process'; +import os from 'os'; +import path from 'path'; +import * as readline from 'readline'; +import {Writable, Readable} from 'stream'; + +import {TestServer} from '@pptr/testserver'; + +import {isErrorLike} from '../../lib/cjs/launch.js'; +import {Cache} from '../../lib/cjs/main.js'; + +export function createMockedReadlineInterface( + input: string +): readline.Interface { + const readable = Readable.from([input]); + const writable = new Writable({ + write(_chunk, _encoding, callback) { + // Suppress the output to keep the test clean + callback(); + }, + }); + + return readline.createInterface({ + input: readable, + output: writable, + }); +} + +const startServer = async () => { + const assetsPath = path.join(__dirname, '..', '.cache', 'server'); + return await TestServer.create(assetsPath); +}; + +interface ServerState { + server: TestServer; +} + +const state: Partial<ServerState> = {}; + +export function setupTestServer(): void { + before(async () => { + state.server = await startServer(); + }); + + after(async () => { + await state.server!.stop(); + state.server = undefined; + }); +} + +export function getServerUrl(): string { + return `http://localhost:${state.server!.port}`; +} + +export function clearCache(tmpDir: string): void { + try { + new Cache(tmpDir).clear(); + } catch (err) { + if (os.platform() === 'win32') { + console.log(execSync('tasklist').toString('utf-8')); + // Sometimes on Windows the folder cannot be removed due to unknown reasons. + // We suppress the error to avoud flakiness. + if (isErrorLike(err) && err.message.includes('EBUSY')) { + return; + } + } + throw err; + } +} diff --git a/remote/test/puppeteer/packages/browsers/test/src/versions.ts b/remote/test/puppeteer/packages/browsers/test/src/versions.ts new file mode 100644 index 0000000000..3e13b8fc61 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/test/src/versions.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export const testChromeBuildId = '113.0.5672.0'; +export const testChromiumBuildId = '1083080'; +export const testFirefoxBuildId = '123.0a1'; +export const testChromeDriverBuildId = '115.0.5763.0'; +export const testChromeHeadlessShellBuildId = '118.0.5950.0'; diff --git a/remote/test/puppeteer/packages/browsers/tools/downloadTestBrowsers.mjs b/remote/test/puppeteer/packages/browsers/tools/downloadTestBrowsers.mjs new file mode 100644 index 0000000000..e9c4ec963a --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/tools/downloadTestBrowsers.mjs @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Downloads test browser binaries to test/.cache/server folder that + * mirrors the structure of the download server. + */ + +import {existsSync, mkdirSync, copyFileSync, rmSync} from 'fs'; +import {normalize, join, dirname} from 'path'; + +import {downloadPaths} from '../lib/esm/browser-data/browser-data.js'; +import * as versions from '../test/build/versions.js'; + +import {BrowserPlatform, install} from '@puppeteer/browsers'; + +function getBrowser(str) { + const regex = /test(.+)BuildId/; + const match = str.match(regex); + + if (match && match[1]) { + const lowercased = match[1].toLowerCase(); + if (lowercased === 'chromeheadlessshell') { + return 'chrome-headless-shell'; + } + return lowercased; + } else { + return null; + } +} + +const cacheDir = normalize(join('.', 'test', '.cache')); + +for (const version of Object.keys(versions)) { + const browser = getBrowser(version); + if (!browser) { + continue; + } + + const buildId = versions[version]; + + for (const platform of Object.values(BrowserPlatform)) { + const targetPath = join( + cacheDir, + 'server', + ...downloadPaths[browser](platform, buildId) + ); + + if (existsSync(targetPath)) { + continue; + } + + const archivePath = await install({ + browser, + buildId, + platform, + cacheDir: join(cacheDir, 'tmp'), + unpack: false, + }); + + mkdirSync(dirname(targetPath), { + recursive: true, + }); + copyFileSync(archivePath, targetPath); + } +} + +rmSync(join(cacheDir, 'tmp'), { + recursive: true, + force: true, + maxRetries: 10, +}); diff --git a/remote/test/puppeteer/packages/browsers/tools/updateVersions.mjs b/remote/test/puppeteer/packages/browsers/tools/updateVersions.mjs new file mode 100644 index 0000000000..9fb704baf5 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/tools/updateVersions.mjs @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; + +import actions from '@actions/core'; + +import {testFirefoxBuildId} from '../test/build/versions.js'; + +const filePath = './test/src/versions.ts'; + +const getVersion = async () => { + // https://stackoverflow.com/a/1732454/96656 + const response = await fetch( + 'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central/' + ); + const html = await response.text(); + const re = /firefox-(.*)\.en-US\.langpack\.xpi">/; + const match = re.exec(html)[1]; + return match; +}; + +const patch = (input, version) => { + const output = input.replace(/testFirefoxBuildId = '([^']+)';/, match => { + return `testFirefoxBuildId = '${version}';`; + }); + return output; +}; + +const version = await getVersion(); + +if (testFirefoxBuildId !== version) { + actions.setOutput( + 'commit', + `chore: update Firefox testing pin to ${version}` + ); + const contents = await fs.readFile(filePath, 'utf8'); + const patched = patch(contents, version); + fs.writeFile(filePath, patched); +} diff --git a/remote/test/puppeteer/packages/browsers/tsconfig.json b/remote/test/puppeteer/packages/browsers/tsconfig.json new file mode 100644 index 0000000000..b662532a01 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "references": [ + {"path": "src/tsconfig.esm.json"}, + {"path": "src/tsconfig.cjs.json"}, + ], +} diff --git a/remote/test/puppeteer/packages/browsers/tsdoc.json b/remote/test/puppeteer/packages/browsers/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/packages/browsers/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} diff --git a/remote/test/puppeteer/packages/ng-schematics/.eslintignore b/remote/test/puppeteer/packages/ng-schematics/.eslintignore new file mode 100644 index 0000000000..8424d7004d --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/.eslintignore @@ -0,0 +1,5 @@ +# Ignore File that will be copied to Angular +/files/ + +# Ignore sandbox enviroment +./sandbox/ diff --git a/remote/test/puppeteer/packages/ng-schematics/.gitignore b/remote/test/puppeteer/packages/ng-schematics/.gitignore new file mode 100644 index 0000000000..9dad45cdd5 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/.gitignore @@ -0,0 +1,3 @@ + +# Sandbox +sandbox/ diff --git a/remote/test/puppeteer/packages/ng-schematics/.mocharc.cjs b/remote/test/puppeteer/packages/ng-schematics/.mocharc.cjs new file mode 100644 index 0000000000..be9bc29919 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/.mocharc.cjs @@ -0,0 +1,6 @@ +module.exports = { + logLevel: 'debug', + spec: 'test/build/**/*.spec.js', + exit: !!process.env.CI, + reporter: process.env.CI ? 'spec' : 'dot', +}; diff --git a/remote/test/puppeteer/packages/ng-schematics/CHANGELOG.md b/remote/test/puppeteer/packages/ng-schematics/CHANGELOG.md new file mode 100644 index 0000000000..a483c4f2fb --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/CHANGELOG.md @@ -0,0 +1,110 @@ +# Changelog + +## [0.5.6](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.5.5...ng-schematics-v0.5.6) (2024-01-16) + + +### Bug Fixes + +* jest config issue on Windows ([3711f86](https://github.com/puppeteer/puppeteer/commit/3711f86dca4140da9e830bd7a46f4eca43cd5f4b)) + +## [0.5.5](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.5.4...ng-schematics-v0.5.5) (2023-12-19) + + +### Bug Fixes + +* update documentation for ng-schematics ([#11533](https://github.com/puppeteer/puppeteer/issues/11533)) ([744e894](https://github.com/puppeteer/puppeteer/commit/744e8944ac62b9d7284fa260c5c796fa1b83b5ef)) + +## [0.5.4](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.5.3...ng-schematics-v0.5.4) (2023-12-06) + + +### Bug Fixes + +* get port from created server ([#11495](https://github.com/puppeteer/puppeteer/issues/11495)) ([d2f4b9c](https://github.com/puppeteer/puppeteer/commit/d2f4b9ca53642ac9ccae9a22fd3138698990387b)) + +## [0.5.3](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.5.2...ng-schematics-v0.5.3) (2023-12-04) + + +### Bug Fixes + +* ng-schematics install Windows ([#11487](https://github.com/puppeteer/puppeteer/issues/11487)) ([02af748](https://github.com/puppeteer/puppeteer/commit/02af7482d9bf2163b90dfe623b0af18c513d5a3b)) + +## [0.5.2](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.5.1...ng-schematics-v0.5.2) (2023-11-16) + + +### Bug Fixes + +* run post-install hooks ([#11403](https://github.com/puppeteer/puppeteer/issues/11403)) ([3f6ca24](https://github.com/puppeteer/puppeteer/commit/3f6ca249ed898eee25015a6fd0ce7cf774ad31b2)) + +## [0.5.1](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.5.0...ng-schematics-v0.5.1) (2023-11-13) + + +### Bug Fixes + +* multi-app project extend root `tsconfig.json` ([#11374](https://github.com/puppeteer/puppeteer/issues/11374)) ([1b2d920](https://github.com/puppeteer/puppeteer/commit/1b2d920fe638f3aad704ab8f21d1e4f4099b6d44)) +* support Angular 17 new template ([#11375](https://github.com/puppeteer/puppeteer/issues/11375)) ([64f7bf0](https://github.com/puppeteer/puppeteer/commit/64f7bf0af442369a07352b11555ec3f612eb62b8)) + +## [0.5.0](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.4.0...ng-schematics-v0.5.0) (2023-08-22) + + +### Features + +* **ng-schematics:** reduce the user options and better defaults ([35dc2d8](https://github.com/puppeteer/puppeteer/commit/35dc2d884052b27a3f9c70b8646f95743be7b84d)) +* **ng-schematics:** release version 0.5.0 ([#10768](https://github.com/puppeteer/puppeteer/issues/10768)) ([42fdd0a](https://github.com/puppeteer/puppeteer/commit/42fdd0a733acb2a9af3878bfa8927252f68ed465)) + + +### Bug Fixes + +* **ng-schematics:** builder is responsible for resolving commands ([683e181](https://github.com/puppeteer/puppeteer/commit/683e18189c0aedad7deb9007055a1a38801bbf08)) +* **ng-schematics:** don't install for library projects ([1376b77](https://github.com/puppeteer/puppeteer/commit/1376b77a7ab2260c2fd236c3cf31abbd544193e8)) + +## [0.4.0](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.3.0...ng-schematics-v0.4.0) (2023-08-08) + + +### Features + +* support for multi projects repos ([#10665](https://github.com/puppeteer/puppeteer/issues/10665)) ([6bca1db](https://github.com/puppeteer/puppeteer/commit/6bca1db956c44358716d52f0b9f3c012ba0b482d)) + +## [0.3.0](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.3.0...ng-schematics-v0.3.0) (2023-08-03) + + +### Features + +* support for multi projects repos ([#10665](https://github.com/puppeteer/puppeteer/issues/10665)) ([6bca1db](https://github.com/puppeteer/puppeteer/commit/6bca1db956c44358716d52f0b9f3c012ba0b482d)) + +## [0.3.0](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.3.0...ng-schematics-v0.3.0) (2023-08-02) + + +### Features + +* support for multi projects repos ([#10665](https://github.com/puppeteer/puppeteer/issues/10665)) ([6bca1db](https://github.com/puppeteer/puppeteer/commit/6bca1db956c44358716d52f0b9f3c012ba0b482d)) + +## [0.3.0](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.2.0...ng-schematics-v0.3.0) (2023-06-29) + + +### Features + +* add Test command ([#10443](https://github.com/puppeteer/puppeteer/issues/10443)) ([2d8993b](https://github.com/puppeteer/puppeteer/commit/2d8993b45b0a0c5943907fe69f865e1064a23d3c)) + + +### Bug Fixes + +* `port` option to run dev and e2e side-by-side ([#10458](https://github.com/puppeteer/puppeteer/issues/10458)) ([a43b346](https://github.com/puppeteer/puppeteer/commit/a43b346bfc7f0071fcead1abb7d7b46dcf3c27f9)) +* use Node test reporter ([#10464](https://github.com/puppeteer/puppeteer/issues/10464)) ([f778b1e](https://github.com/puppeteer/puppeteer/commit/f778b1e2a70f3d507ab2012d2918f5ed241a8d21)) + +## [0.2.0](https://github.com/puppeteer/puppeteer/compare/ng-schematics-v0.1.0...ng-schematics-v0.2.0) (2023-05-02) + + +### ⚠ BREAKING CHANGES + +* drop support for node14 ([#10019](https://github.com/puppeteer/puppeteer/issues/10019)) + +### Features + +* drop support for node14 ([#10019](https://github.com/puppeteer/puppeteer/issues/10019)) ([7405d65](https://github.com/puppeteer/puppeteer/commit/7405d6585aa09b240fbab09aa360674d4442b3d9)) + +## 0.1.0 (2022-11-23) + + +### Features + +* **ng-schematics:** Release @puppeteer/ng-schematics ([#9244](https://github.com/puppeteer/puppeteer/issues/9244)) ([be33929](https://github.com/puppeteer/puppeteer/commit/be33929770e473992ad49029e6d038d36591e108)) diff --git a/remote/test/puppeteer/packages/ng-schematics/README.md b/remote/test/puppeteer/packages/ng-schematics/README.md new file mode 100644 index 0000000000..975f74a704 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/README.md @@ -0,0 +1,230 @@ +# Puppeteer Angular Schematic + +Adds Puppeteer-based e2e tests to your Angular project. + +## Getting started + +Run the command below in an Angular CLI app directory and follow the prompts. + +> Note this will add the schematic as a dependency to your project. + +```bash +ng add @puppeteer/ng-schematics +``` + +Or you can use the same command followed by the [options](#options) below. + +Currently, this schematic supports the following test runners: + +- [**Jasmine**](https://jasmine.github.io/) +- [**Jest**](https://jestjs.io/) +- [**Mocha**](https://mochajs.org/) +- [**Node Test Runner**](https://nodejs.org/api/test.html) + +With the schematics installed you can run E2E tests: + +```bash +ng e2e +``` + +### Options + +When adding schematics to your project you can to provide following options: + +| Option | Description | Value | Required | +| --------------- | ------------------------------------------------------ | ------------------------------------------ | -------- | +| `--test-runner` | The testing framework to install along side Puppeteer. | `"jasmine"`, `"jest"`, `"mocha"`, `"node"` | `true` | + +## Creating a single test file + +Puppeteer Angular Schematic exposes a method to create a single test file. + +```bash +ng generate @puppeteer/ng-schematics:e2e "<TestName>" +``` + +### Running test server and dev server at the same time + +By default the E2E test will run the app on the same port as `ng start`. +To avoid this you can specify the port the an the `angular.json` +Update either `e2e` or `puppeteer` (depending on the initial setup) to: + +```json +{ + "e2e": { + "builder": "@puppeteer/ng-schematics:puppeteer", + "options": { + "commands": [...], + "devServerTarget": "sandbox:serve", + "testRunner": "<TestRunner>", + "port": 8080 + }, + ... +} +``` + +Now update the E2E test file `utils.ts` baseUrl to: + +```ts +const baseUrl = 'http://localhost:8080'; +``` + +## Contributing + +Check out our [contributing guide](https://pptr.dev/contributing) to get an overview of what you need to develop in the Puppeteer repo. + +### Sandbox smoke tests + +To make integration easier smoke test can be run with a single command, that will create a fresh install of Angular (single application and a milti application projects). Then it will install the schematics inside them and run the initial e2e tests: + +```bash +node tools/smoke.mjs +``` + +### Unit Testing + +The schematics utilize `@angular-devkit/schematics/testing` for verifying correct file creation and `package.json` updates. To execute the test suit: + +```bash +npm run test +``` + +## Migrating from Protractor + +### Entry point + +Puppeteer has its own [`browser`](https://pptr.dev/api/puppeteer.browser) that exposes the browser process. +A more closes comparison for Protractor's `browser` would be Puppeteer's [`page`](https://pptr.dev/api/puppeteer.page). + +```ts +// Testing framework specific imports + +import {setupBrowserHooks, getBrowserState} from './utils'; + +describe('<Test Name>', function () { + setupBrowserHooks(); + it('is running', async function () { + const {page} = getBrowserState(); + // Query elements + await page + .locator('my-component') + // Click on the element once found + .click(); + }); +}); +``` + +### Getting element properties + +You can easily get any property of the element. + +```ts +// Testing framework specific imports + +import {setupBrowserHooks, getBrowserState} from './utils'; + +describe('<Test Name>', function () { + setupBrowserHooks(); + it('is running', async function () { + const {page} = getBrowserState(); + // Query elements + const elementText = await page + .locator('.my-component') + .map(button => button.innerText) + // Wait for element to show up + .wait(); + + // Assert via assertion library + }); +}); +``` + +### Query Selectors + +Puppeteer supports multiple types of selectors, namely, the CSS, ARIA, text, XPath and pierce selectors. +The following table shows Puppeteer's equivalents to [Protractor By](https://www.protractortest.org/#/api?view=ProtractorBy). + +> For improved reliability and reduced flakiness try our +> **Experimental** [Locators API](https://pptr.dev/guides/locators) + +| By | Protractor code | Puppeteer querySelector | +| ----------------- | --------------------------------------------- | ------------------------------------------------------------ | +| CSS (Single) | `$(by.css('<CSS>'))` | `page.$('<CSS>')` | +| CSS (Multiple) | `$$(by.css('<CSS>'))` | `page.$$('<CSS>')` | +| Id | `$(by.id('<ID>'))` | `page.$('#<ID>')` | +| CssContainingText | `$(by.cssContainingText('<CSS>', '<TEXT>'))` | `page.$('<CSS> ::-p-text(<TEXT>)')` ` | +| DeepCss | `$(by.deepCss('<CSS>'))` | `page.$(':scope >>> <CSS>')` | +| XPath | `$(by.xpath('<XPATH>'))` | `page.$('::-p-xpath(<XPATH>)')` | +| JS | `$(by.js('document.querySelector("<CSS>")'))` | `page.evaluateHandle(() => document.querySelector('<CSS>'))` | + +> For advanced use cases such as Protractor's `by.addLocator` you can check Puppeteer's [Custom selectors](https://pptr.dev/guides/query-selectors#custom-selectors). + +### Actions Selectors + +Puppeteer allows you to all necessary actions to allow test your application. + +```ts +// Click on the element. +element(locator).click(); +// Puppeteer equivalent +await page.locator(locator).click(); + +// Send keys to the element (usually an input). +element(locator).sendKeys('my text'); +// Puppeteer equivalent +await page.locator(locator).fill('my text'); + +// Clear the text in an element (usually an input). +element(locator).clear(); +// Puppeteer equivalent +await page.locator(locator).fill(''); + +// Get the value of an attribute, for example, get the value of an input. +element(locator).getAttribute('value'); +// Puppeteer equivalent +const element = await page.locator(locator).waitHandle(); +const value = await element.getProperty('value'); +``` + +### Example + +Sample Protractor test: + +```ts +describe('Protractor Demo', function () { + it('should add one and two', function () { + browser.get('http://juliemr.github.io/protractor-demo/'); + element(by.model('first')).sendKeys(1); + element(by.model('second')).sendKeys(2); + + element(by.id('gobutton')).click(); + + expect(element(by.binding('latest')).getText()).toEqual('3'); + }); +}); +``` + +Sample Puppeteer migration: + +```ts +import {setupBrowserHooks, getBrowserState} from './utils'; + +describe('Puppeteer Demo', function () { + setupBrowserHooks(); + it('should add one and two', function () { + const {page} = getBrowserState(); + await page.goto('http://juliemr.github.io/protractor-demo/'); + + await page.locator('.form-inline > input:nth-child(1)').fill('1'); + await page.locator('.form-inline > input:nth-child(2)').fill('2'); + await page.locator('#gobutton').fill('2'); + + const result = await page + .locator('.table tbody td:last-of-type') + .map(header => header.innerText) + .wait(); + + expect(result).toEqual('3'); + }); +}); +``` diff --git a/remote/test/puppeteer/packages/ng-schematics/package.json b/remote/test/puppeteer/packages/ng-schematics/package.json new file mode 100644 index 0000000000..29db1dcdc9 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/package.json @@ -0,0 +1,71 @@ +{ + "name": "@puppeteer/ng-schematics", + "version": "0.5.6", + "description": "Puppeteer Angular schematics", + "scripts": { + "build": "wireit", + "clean": "../../tools/clean.js", + "dev:test": "npm run test --watch", + "dev": "npm run build --watch", + "unit": "wireit" + }, + "wireit": { + "build": { + "command": "tsc -b && node tools/copySchemaFiles.mjs", + "clean": "if-file-deleted", + "files": [ + "tsconfig.json", + "tsconfig.test.json", + "src/**", + "test/src/**" + ], + "output": [ + "lib/**", + "test/build/**", + "*.tsbuildinfo" + ] + }, + "build:test": { + "command": "tsc -b test/tsconfig.json" + }, + "unit": { + "command": "node --test --test-reporter spec test/build", + "dependencies": [ + "build", + "build:test" + ] + } + }, + "keywords": [ + "angular", + "puppeteer", + "schematics" + ], + "repository": { + "type": "git", + "url": "https://github.com/puppeteer/puppeteer/tree/main/packages/ng-schematics" + }, + "author": "The Chromium Authors", + "license": "Apache-2.0", + "engines": { + "node": ">=16.13.2" + }, + "dependencies": { + "@angular-devkit/architect": "^0.1701.1", + "@angular-devkit/core": "^17.0.7", + "@angular-devkit/schematics": "^17.0.7" + }, + "devDependencies": { + "@schematics/angular": "^17.0.7", + "@angular/cli": "^17.0.7" + }, + "files": [ + "lib", + "!*.tsbuildinfo" + ], + "ng-add": { + "save": "devDependencies" + }, + "schematics": "./lib/schematics/collection.json", + "builders": "./lib/builders/builders.json" +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/builders/builders.json b/remote/test/puppeteer/packages/ng-schematics/src/builders/builders.json new file mode 100644 index 0000000000..41079f7731 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/builders/builders.json @@ -0,0 +1,10 @@ +{ + "$schema": "../../../../node_modules/@angular-devkit/architect/src/builders-schema.json", + "builders": { + "puppeteer": { + "implementation": "./puppeteer", + "schema": "./puppeteer/schema.json", + "description": "Run e2e test with Puppeteer" + } + } +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/index.ts b/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/index.ts new file mode 100644 index 0000000000..82a1e8e7da --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/index.ts @@ -0,0 +1,200 @@ +import {spawn} from 'child_process'; +import {normalize, join} from 'path'; + +import { + createBuilder, + type BuilderContext, + type BuilderOutput, + targetFromTargetString, + type BuilderRun, +} from '@angular-devkit/architect'; +import type {JsonObject} from '@angular-devkit/core'; + +import {TestRunner} from '../../schematics/utils/types.js'; + +import type {PuppeteerBuilderOptions} from './types.js'; + +const terminalStyles = { + cyan: '\u001b[36;1m', + green: '\u001b[32m', + red: '\u001b[31m', + bold: '\u001b[1m', + reverse: '\u001b[7m', + clear: '\u001b[0m', +}; + +export function getCommandForRunner(runner: TestRunner): [string, ...string[]] { + switch (runner) { + case TestRunner.Jasmine: + return [`jasmine`, '--config=./e2e/jasmine.json']; + case TestRunner.Jest: + return [`jest`, '-c', 'e2e/jest.config.js']; + case TestRunner.Mocha: + return [`mocha`, '--config=./e2e/.mocharc.js']; + case TestRunner.Node: + return ['node', '--test', '--test-reporter', 'spec', 'e2e/build/']; + } + + throw new Error(`Unknown test runner ${runner}!`); +} + +function getExecutable(command: string[]) { + const executable = command.shift()!; + const debugError = `Error running '${executable}' with arguments '${command.join( + ' ' + )}'.`; + + return { + executable, + args: command, + debugError, + error: 'Please look at the output above to determine the issue!', + }; +} + +function updateExecutablePath(command: string, root?: string) { + if (command === TestRunner.Node) { + return command; + } + + let path = 'node_modules/.bin/'; + if (root && root !== '') { + const nested = root + .split('/') + .map(() => { + return '../'; + }) + .join(''); + path = `${nested}${path}${command}`; + } else { + path = `./${path}${command}`; + } + + return normalize(path); +} + +async function executeCommand( + context: BuilderContext, + command: string[], + env: NodeJS.ProcessEnv = {} +) { + let project: JsonObject; + if (context.target) { + project = await context.getProjectMetadata(context.target.project); + command[0] = updateExecutablePath(command[0]!, String(project['root'])); + } + + await new Promise(async (resolve, reject) => { + context.logger.debug(`Trying to execute command - ${command.join(' ')}.`); + const {executable, args, debugError, error} = getExecutable(command); + let path = context.workspaceRoot; + if (context.target) { + path = join(path, (project['root'] as string | undefined) ?? ''); + } + + const child = spawn(executable, args, { + cwd: path, + stdio: 'inherit', + shell: true, + env: { + ...process.env, + ...env, + }, + }); + + child.on('error', message => { + context.logger.debug(debugError); + console.log(message); + reject(error); + }); + + child.on('exit', code => { + if (code === 0) { + resolve(true); + } else { + reject(error); + } + }); + }); +} + +function message( + message: string, + context: BuilderContext, + type: 'info' | 'success' | 'error' = 'info' +): void { + let style: string; + switch (type) { + case 'info': + style = terminalStyles.reverse + terminalStyles.cyan; + break; + case 'success': + style = terminalStyles.reverse + terminalStyles.green; + break; + case 'error': + style = terminalStyles.red; + break; + } + context.logger.info( + `${terminalStyles.bold}${style}${message}${terminalStyles.clear}` + ); +} + +async function startServer( + options: PuppeteerBuilderOptions, + context: BuilderContext +): Promise<BuilderRun> { + context.logger.debug('Trying to start server.'); + const target = targetFromTargetString(options.devServerTarget); + const defaultServerOptions = await context.getTargetOptions(target); + + const overrides = { + watch: false, + host: defaultServerOptions['host'], + port: options.port ?? defaultServerOptions['port'], + } as JsonObject; + + message(' Spawning test server ⚙️ ... \n', context); + const server = await context.scheduleTarget(target, overrides); + const result = await server.result; + if (!result.success) { + throw new Error('Failed to spawn server! Stopping tests...'); + } + + return server; +} + +async function executeE2ETest( + options: PuppeteerBuilderOptions, + context: BuilderContext +): Promise<BuilderOutput> { + let server: BuilderRun | null = null; + try { + message('\n Building tests 🛠️ ... \n', context); + await executeCommand(context, [`tsc`, '-p', 'e2e/tsconfig.json']); + + server = await startServer(options, context); + const result = await server.result; + + message('\n Running tests 🧪 ... \n', context); + const testRunnerCommand = getCommandForRunner(options.testRunner); + await executeCommand(context, testRunnerCommand, { + baseUrl: result['baseUrl'], + }); + + message('\n 🚀 Test ran successfully! 🚀 ', context, 'success'); + return {success: true}; + } catch (error) { + message('\n 🛑 Test failed! 🛑 ', context, 'error'); + if (error instanceof Error) { + return {success: false, error: error.message}; + } + return {success: false, error: error as string}; + } finally { + if (server) { + await server.stop(); + } + } +} + +export default createBuilder<PuppeteerBuilderOptions>(executeE2ETest); diff --git a/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/schema.json b/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/schema.json new file mode 100644 index 0000000000..2693d19cce --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/schema.json @@ -0,0 +1,26 @@ +{ + "title": "Puppeteer", + "description": "Options for Puppeteer Angular Schematics", + "type": "object", + "properties": { + "commands": { + "type": "array", + "items": { + "type": "array", + "item": { + "type": "string" + } + }, + "description": "Commands to execute in the repo. Commands prefixed with `./node_modules/bin` (Exception: 'node')." + }, + "devServerTarget": { + "type": "string", + "description": "Angular target that spawns the server." + }, + "port": { + "type": ["number", "null"], + "description": "Port to run the test server on." + } + }, + "additionalProperties": true +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/types.ts b/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/types.ts new file mode 100644 index 0000000000..6258a955c0 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/builders/puppeteer/types.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {JsonObject} from '@angular-devkit/core'; + +import type {TestRunner} from '../../schematics/utils/types.js'; + +export interface PuppeteerBuilderOptions extends JsonObject { + testRunner: TestRunner; + devServerTarget: string; + port: number | null; +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/collection.json b/remote/test/puppeteer/packages/ng-schematics/src/schematics/collection.json new file mode 100644 index 0000000000..00bede45e5 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/collection.json @@ -0,0 +1,20 @@ +{ + "$schema": "../../../../node_modules/@angular-devkit/schematics/collection-schema.json", + "schematics": { + "ng-add": { + "description": "Add Puppeteer to an Angular project", + "factory": "./ng-add/index#ngAdd", + "schema": "./ng-add/schema.json" + }, + "e2e": { + "description": "Create a single test file", + "factory": "./e2e/index#e2e", + "schema": "./e2e/schema.json" + }, + "config": { + "description": "Eject Puppeteer config file", + "factory": "./config/index#config", + "schema": "./config/schema.json" + } + } +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/files/.puppeteerrc.mjs b/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/files/.puppeteerrc.mjs new file mode 100644 index 0000000000..0da14a80d8 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/files/.puppeteerrc.mjs @@ -0,0 +1,4 @@ +/** + * @type {import("puppeteer").Configuration} + */ +export {}; diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/index.ts b/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/index.ts new file mode 100644 index 0000000000..b01d98e33e --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/index.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + chain, + type Rule, + type SchematicContext, + type Tree, +} from '@angular-devkit/schematics'; + +import {addFilesSingle} from '../utils/files.js'; +import {TestRunner, type AngularProject} from '../utils/types.js'; + +// You don't have to export the function as default. You can also have more than one rule +// factory per file. +export function config(): Rule { + return (tree: Tree, context: SchematicContext) => { + return chain([addPuppeteerConfig()])(tree, context); + }; +} + +function addPuppeteerConfig(): Rule { + return (_tree: Tree, context: SchematicContext) => { + context.logger.debug('Adding Puppeteer config file.'); + + return addFilesSingle('', {root: ''} as AngularProject, { + // No-op here to fill types + options: { + testRunner: TestRunner.Jasmine, + port: 4200, + }, + applyPath: './files', + relativeToWorkspacePath: `/`, + }); + }; +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/schema.json b/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/schema.json new file mode 100644 index 0000000000..8d45751bb1 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/config/schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "Puppeteer", + "title": "Puppeteer Config Schema", + "type": "object", + "properties": {}, + "required": [] +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/files/common/e2e/tests/__name@dasherize__.__ext@dasherize__.ts.template b/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/files/common/e2e/tests/__name@dasherize__.__ext@dasherize__.ts.template new file mode 100644 index 0000000000..ca90f258b8 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/files/common/e2e/tests/__name@dasherize__.__ext@dasherize__.ts.template @@ -0,0 +1,18 @@ +<% if(testRunner == 'node') { %> +import * as assert from 'assert'; +import {describe, it} from 'node:test'; +<% } %><% if(testRunner == 'mocha') { %> +import * as assert from 'assert'; +<% } %> +import {setupBrowserHooks, getBrowserState} from './utils'; + +describe('<%= classify(name) %>', function () { + <% if(route) { %> + setupBrowserHooks('<%= route %>'); + <% } else { %> + setupBrowserHooks(); + <% } %> + it('', async function () { + const {page} = getBrowserState(); + }); +}); diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/index.ts b/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/index.ts new file mode 100644 index 0000000000..cf1f634f94 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/index.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + chain, + type Rule, + type SchematicContext, + SchematicsException, + type Tree, +} from '@angular-devkit/schematics'; + +import {addCommonFiles} from '../utils/files.js'; +import {getApplicationProjects} from '../utils/json.js'; +import { + TestRunner, + type SchematicsSpec, + type AngularProject, + type PuppeteerSchematicsConfig, +} from '../utils/types.js'; + +// You don't have to export the function as default. You can also have more than one rule +// factory per file. +export function e2e(userArgs: Record<string, string>): Rule { + const options = parseUserTestArgs(userArgs); + + return (tree: Tree, context: SchematicContext) => { + return chain([addE2EFile(options)])(tree, context); + }; +} + +function parseUserTestArgs(userArgs: Record<string, string>): SchematicsSpec { + const options: Partial<SchematicsSpec> = { + ...userArgs, + }; + if ('p' in userArgs) { + options['project'] = userArgs['p']; + } + if ('n' in userArgs) { + options['name'] = userArgs['n']; + } + if ('r' in userArgs) { + options['route'] = userArgs['r']; + } + + if (options['route'] && options['route'].startsWith('/')) { + options['route'] = options['route'].substring(1); + } + + return options as SchematicsSpec; +} + +function findTestingOption< + Property extends keyof PuppeteerSchematicsConfig['options'], +>( + [name, project]: [string, AngularProject | undefined], + property: Property +): PuppeteerSchematicsConfig['options'][Property] { + if (!project) { + throw new Error(`Project "${name}" not found.`); + } + + const e2e = project.architect?.e2e; + const puppeteer = project.architect?.puppeteer; + const builder = '@puppeteer/ng-schematics:puppeteer'; + + if (e2e?.builder === builder) { + return e2e.options[property]; + } else if (puppeteer?.builder === builder) { + return puppeteer.options[property]; + } + + throw new Error(`Can't find property "${property}" for project "${name}".`); +} + +function addE2EFile(options: SchematicsSpec): Rule { + return async (tree: Tree, context: SchematicContext) => { + context.logger.debug('Adding Spec file.'); + + const projects = getApplicationProjects(tree); + const projectNames = Object.keys(projects) as [string, ...string[]]; + const foundProject: [string, AngularProject | undefined] | undefined = + projectNames.length === 1 + ? [projectNames[0], projects[projectNames[0]]] + : Object.entries(projects).find(([name, project]) => { + return options.project + ? options.project === name + : project.root === ''; + }); + if (!foundProject) { + throw new SchematicsException( + `Project not found! Please run "ng generate @puppeteer/ng-schematics:test <Test> <Project>"` + ); + } + + const testRunner = findTestingOption(foundProject, 'testRunner'); + const port = findTestingOption(foundProject, 'port'); + + context.logger.debug('Creating Spec file.'); + + return addCommonFiles( + {[foundProject[0]]: foundProject[1]} as Record<string, AngularProject>, + { + options: { + name: options.name, + route: options.route, + testRunner, + // Node test runner does not support glob patterns + // It looks for files `*.test.js` + ext: testRunner === TestRunner.Node ? 'test' : 'e2e', + port, + }, + } + ); + }; +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/schema.json b/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/schema.json new file mode 100644 index 0000000000..7752c9ceef --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/e2e/schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "Puppeteer", + "title": "Puppeteer E2E Schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "alias": "n", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "Name for spec to be created:" + }, + "project": { + "type": "string", + "$default": { + "$source": "argv", + "index": 1 + }, + "alias": "p" + }, + "route": { + "type": "string", + "$default": { + "$source": "argv", + "index": 1 + }, + "alias": "r" + } + }, + "required": [] +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/.gitignore.template b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/.gitignore.template new file mode 100644 index 0000000000..f038b2eb67 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/.gitignore.template @@ -0,0 +1,2 @@ +# Compiled e2e tests output +build/ diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tests/app.__ext@dasherize__.ts.template b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tests/app.__ext@dasherize__.ts.template new file mode 100644 index 0000000000..60637d0fa7 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tests/app.__ext@dasherize__.ts.template @@ -0,0 +1,20 @@ +<% if(testRunner == 'node') { %> +import * as assert from 'assert'; +import {describe, it} from 'node:test'; +<% } %><% if(testRunner == 'mocha') { %> +import * as assert from 'assert'; +<% } %> +import {setupBrowserHooks, getBrowserState} from './utils'; + +describe('App test', function () { + setupBrowserHooks(); + it('is running', async function () { + const {page} = getBrowserState(); + const element = await page.locator('::-p-text(<%= project %>)').wait(); +<% if(testRunner == 'jasmine' || testRunner == 'jest') { %> + expect(element).not.toBeNull(); +<% } %><% if(testRunner == 'mocha' || testRunner == 'node') { %> + assert.ok(element); +<% } %> + }); +}); diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tests/utils.ts.template b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tests/utils.ts.template new file mode 100644 index 0000000000..2136f99a3a --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tests/utils.ts.template @@ -0,0 +1,60 @@ +<% if(testRunner == 'node') { %> +import {before, beforeEach, after, afterEach} from 'node:test'; +<% } %> +import * as puppeteer from 'puppeteer'; + +const baseUrl = process.env['baseUrl'] ?? '<%= baseUrl %>'; +let browser: puppeteer.Browser; +let page: puppeteer.Page; + +export function setupBrowserHooks(path = ''): void { +<% if(testRunner == 'jasmine' || testRunner == 'jest') { %> + beforeAll(async () => { + browser = await puppeteer.launch({ + headless: 'new' + }); + }); +<% } %><% if(testRunner == 'mocha' || testRunner == 'node') { %> + before(async () => { + browser = await puppeteer.launch({ + headless: 'new' + }); + }); +<% } %> + + beforeEach(async () => { + page = await browser.newPage(); + await page.goto(`${baseUrl}${path}`); + }); + + afterEach(async () => { + await page?.close(); + }); + +<% if(testRunner == 'jasmine' || testRunner == 'jest') { %> + afterAll(async () => { + await browser?.close(); + }); +<% } %><% if(testRunner == 'mocha' || testRunner == 'node') { %> + after(async () => { + await browser?.close(); + }); +<% } %> +} + +export function getBrowserState(): { + browser: puppeteer.Browser; + page: puppeteer.Page; + baseUrl: string; +} { + if (!browser) { + throw new Error( + 'No browser state found! Ensure `setupBrowserHooks()` is called.' + ); + } + return { + browser, + page, + baseUrl, + }; +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tsconfig.json.template b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tsconfig.json.template new file mode 100644 index 0000000000..38501b89ef --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/common/e2e/tsconfig.json.template @@ -0,0 +1,10 @@ +{ + "extends": "<%= tsConfigPath %>", + "compilerOptions": { + "module": "CommonJS", + "rootDir": "tests/", + "outDir": "build/", + "types": ["<%= testRunner %>"] + }, + "include": ["tests/**/*.ts"] +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/jasmine/e2e/jasmine.json b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/jasmine/e2e/jasmine.json new file mode 100644 index 0000000000..ad5dc6fbce --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/jasmine/e2e/jasmine.json @@ -0,0 +1,10 @@ +{ + "spec_dir": "e2e", + "spec_files": ["**/*[eE]2[eE].js"], + "helpers": ["helpers/**/*.?(m)js"], + "env": { + "failSpecWithNoExpectations": true, + "stopSpecOnExpectationFailure": false, + "random": true + } +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/jest/e2e/jest.config.js b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/jest/e2e/jest.config.js new file mode 100644 index 0000000000..ee21c6737e --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/jest/e2e/jest.config.js @@ -0,0 +1,10 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +/** @type {import('jest').Config} */ +module.exports = { + testMatch: ['<rootDir>/build/**/*.e2e.js'], + testEnvironment: 'node', +}; diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/mocha/e2e/.mocharc.js b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/mocha/e2e/.mocharc.js new file mode 100644 index 0000000000..28c1839674 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/files/mocha/e2e/.mocharc.js @@ -0,0 +1,4 @@ +module.exports = { + spec: './e2e/build/**/*.e2e.js', + timeout: 5000, +}; diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/index.ts b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/index.ts new file mode 100644 index 0000000000..1f962e0cfc --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/index.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + chain, + type Rule, + type SchematicContext, + type Tree, +} from '@angular-devkit/schematics'; +import {NodePackageInstallTask} from '@angular-devkit/schematics/tasks'; +import {of} from 'rxjs'; +import {concatMap, map, scan} from 'rxjs/operators'; + +import { + addCommonFiles as addCommonFilesHelper, + addFrameworkFiles, + getNgCommandName, + hasE2ETester, +} from '../utils/files.js'; +import {getApplicationProjects} from '../utils/json.js'; +import { + addPackageJsonDependencies, + addPackageJsonScripts, + getDependenciesFromOptions, + getPackageLatestNpmVersion, + DependencyType, + type NodePackage, + updateAngularJsonScripts, +} from '../utils/packages.js'; +import {TestRunner, type SchematicsOptions} from '../utils/types.js'; + +const DEFAULT_PORT = 4200; + +// You don't have to export the function as default. You can also have more than one rule +// factory per file. +export function ngAdd(options: SchematicsOptions): Rule { + return (tree: Tree, context: SchematicContext) => { + return chain([ + addDependencies(options), + addCommonFiles(options), + addOtherFiles(options), + updateScripts(), + updateAngularConfig(options), + ])(tree, context); + }; +} + +function addDependencies(options: SchematicsOptions): Rule { + return (tree: Tree, context: SchematicContext) => { + context.logger.debug('Adding dependencies to "package.json"'); + const dependencies = getDependenciesFromOptions(options); + + return of(...dependencies).pipe( + concatMap((packageName: string) => { + return getPackageLatestNpmVersion(packageName); + }), + scan((array, nodePackage) => { + array.push(nodePackage); + return array; + }, [] as NodePackage[]), + map(packages => { + context.logger.debug('Updating dependencies...'); + addPackageJsonDependencies(tree, packages, DependencyType.Dev); + context.addTask( + new NodePackageInstallTask({ + // Trigger Post-Install hooks to download the browser + allowScripts: true, + }) + ); + + return tree; + }) + ); + }; +} + +function updateScripts(): Rule { + return (tree: Tree, context: SchematicContext): Tree => { + context.logger.debug('Updating "package.json" scripts'); + const projects = getApplicationProjects(tree); + const projectsKeys = Object.keys(projects); + + if (projectsKeys.length === 1) { + const name = getNgCommandName(projects); + const prefix = hasE2ETester(projects) ? `run ${projectsKeys[0]}:` : ''; + return addPackageJsonScripts(tree, [ + { + name, + script: `ng ${prefix}${name}`, + }, + ]); + } + return tree; + }; +} + +function addCommonFiles(options: SchematicsOptions): Rule { + return (tree: Tree, context: SchematicContext) => { + context.logger.debug('Adding Puppeteer base files.'); + const projects = getApplicationProjects(tree); + + return addCommonFilesHelper(projects, { + options: { + ...options, + port: DEFAULT_PORT, + ext: options.testRunner === TestRunner.Node ? 'test' : 'e2e', + }, + }); + }; +} + +function addOtherFiles(options: SchematicsOptions): Rule { + return (tree: Tree, context: SchematicContext) => { + context.logger.debug('Adding Puppeteer additional files.'); + const projects = getApplicationProjects(tree); + + return addFrameworkFiles(projects, { + options: { + ...options, + port: DEFAULT_PORT, + }, + }); + }; +} + +function updateAngularConfig(options: SchematicsOptions): Rule { + return (tree: Tree, context: SchematicContext): Tree => { + context.logger.debug('Updating "angular.json".'); + + return updateAngularJsonScripts(tree, options); + }; +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/schema.json b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/schema.json new file mode 100644 index 0000000000..0fa581f1a7 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/ng-add/schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "Puppeteer", + "title": "Puppeteer Install Schema", + "type": "object", + "properties": { + "testRunner": { + "type": "string", + "enum": ["jasmine", "jest", "mocha", "node"], + "default": "jasmine", + "alias": "t", + "x-prompt": { + "message": "Which test runners do you wish to use?", + "type": "list", + "items": [ + { + "value": "jasmine", + "label": "Use Jasmine [https://jasmine.github.io/]" + }, + { + "value": "jest", + "label": "Use Jest [https://jestjs.io/]" + }, + { + "value": "mocha", + "label": "Use Mocha [https://mochajs.org/]" + }, + { + "value": "node", + "label": "Use Node Test Runner [https://nodejs.org/api/test.html]" + } + ] + } + } + }, + "required": [] +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/files.ts b/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/files.ts new file mode 100644 index 0000000000..4d255062b4 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/files.ts @@ -0,0 +1,152 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {relative, resolve} from 'path'; + +import {getSystemPath, normalize, strings} from '@angular-devkit/core'; +import type {Rule} from '@angular-devkit/schematics'; +import { + apply, + applyTemplates, + chain, + mergeWith, + move, + url, +} from '@angular-devkit/schematics'; + +import type {AngularProject, TestRunner} from './types.js'; + +export interface FilesOptions { + options: { + testRunner: TestRunner; + port: number; + name?: string; + exportConfig?: boolean; + ext?: string; + route?: string; + }; + applyPath: string; + relativeToWorkspacePath: string; + movePath?: string; +} + +export function addFilesToProjects( + projects: Record<string, AngularProject>, + options: FilesOptions +): Rule { + return chain( + Object.keys(projects).map(name => { + return addFilesSingle(name, projects[name] as AngularProject, options); + }) + ); +} + +export function addFilesSingle( + name: string, + project: AngularProject, + {options, applyPath, movePath, relativeToWorkspacePath}: FilesOptions +): Rule { + const projectPath = resolve(getSystemPath(normalize(project.root))); + const workspacePath = resolve(getSystemPath(normalize(''))); + + const relativeToWorkspace = relative( + `${projectPath}${relativeToWorkspacePath}`, + workspacePath + ); + + const baseUrl = getProjectBaseUrl(project, options.port); + const tsConfigPath = getTsConfigPath(project); + + return mergeWith( + apply(url(applyPath), [ + move(movePath ? `${project.root}${movePath}` : project.root), + applyTemplates({ + ...options, + ...strings, + root: project.root ? `${project.root}/` : project.root, + baseUrl, + tsConfigPath, + project: name, + relativeToWorkspace, + }), + ]) + ); +} + +function getProjectBaseUrl(project: AngularProject, port: number): string { + let options = {protocol: 'http', port, host: 'localhost'}; + + if (project.architect?.serve?.options) { + const projectOptions = project.architect?.serve?.options; + const projectPort = port !== 4200 ? port : projectOptions?.port ?? port; + options = {...options, ...projectOptions, port: projectPort}; + options.protocol = projectOptions.ssl ? 'https' : 'http'; + } + + return `${options.protocol}://${options.host}:${options.port}/`; +} + +function getTsConfigPath(project: AngularProject): string { + const filename = 'tsconfig.json'; + + if (!project.root) { + return `../${filename}`; + } + + const nested = project.root + .split('/') + .map(() => { + return '../'; + }) + .join(''); + + // Prepend a single `../` as we put the test inside `e2e` folder + return `../${nested}${filename}`; +} + +export function addCommonFiles( + projects: Record<string, AngularProject>, + filesOptions: Omit<FilesOptions, 'applyPath' | 'relativeToWorkspacePath'> +): Rule { + const options: FilesOptions = { + ...filesOptions, + applyPath: './files/common', + relativeToWorkspacePath: `/`, + }; + + return addFilesToProjects(projects, options); +} + +export function addFrameworkFiles( + projects: Record<string, AngularProject>, + filesOptions: Omit<FilesOptions, 'applyPath' | 'relativeToWorkspacePath'> +): Rule { + const testRunner = filesOptions.options.testRunner; + const options: FilesOptions = { + ...filesOptions, + applyPath: `./files/${testRunner}`, + relativeToWorkspacePath: `/`, + }; + + return addFilesToProjects(projects, options); +} + +export function hasE2ETester( + projects: Record<string, AngularProject> +): boolean { + return Object.values(projects).some((project: AngularProject) => { + return Boolean(project.architect?.e2e); + }); +} + +export function getNgCommandName( + projects: Record<string, AngularProject> +): string { + if (!hasE2ETester(projects)) { + return 'e2e'; + } + return 'puppeteer'; +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/json.ts b/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/json.ts new file mode 100644 index 0000000000..1a38d638a7 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/json.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {SchematicsException, type Tree} from '@angular-devkit/schematics'; + +import type {AngularJson, AngularProject} from './types.js'; + +export function getJsonFileAsObject( + tree: Tree, + path: string +): Record<string, unknown> { + try { + const buffer = tree.read(path) as Buffer; + const content = buffer.toString(); + return JSON.parse(content); + } catch { + throw new SchematicsException(`Unable to retrieve file at ${path}.`); + } +} + +export function getObjectAsJson(object: Record<string, unknown>): string { + return JSON.stringify(object, null, 2); +} + +export function getAngularConfig(tree: Tree): AngularJson { + return getJsonFileAsObject(tree, './angular.json') as unknown as AngularJson; +} + +export function getApplicationProjects( + tree: Tree +): Record<string, AngularProject> { + const {projects} = getAngularConfig(tree); + + const applications: Record<string, AngularProject> = {}; + for (const key in projects) { + const project = projects[key]!; + if (project.projectType === 'application') { + applications[key] = project; + } + } + return applications; +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/packages.ts b/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/packages.ts new file mode 100644 index 0000000000..6ef8ef6002 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/packages.ts @@ -0,0 +1,189 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {get} from 'https'; + +import type {Tree} from '@angular-devkit/schematics'; + +import {getNgCommandName} from './files.js'; +import { + getAngularConfig, + getApplicationProjects, + getJsonFileAsObject, + getObjectAsJson, +} from './json.js'; +import {type SchematicsOptions, TestRunner} from './types.js'; +export interface NodePackage { + name: string; + version: string; +} +export interface NodeScripts { + name: string; + script: string; +} + +export enum DependencyType { + Default = 'dependencies', + Dev = 'devDependencies', + Peer = 'peerDependencies', + Optional = 'optionalDependencies', +} + +export function getPackageLatestNpmVersion(name: string): Promise<NodePackage> { + return new Promise(resolve => { + let version = 'latest'; + + return get(`https://registry.npmjs.org/${name}`, res => { + let data = ''; + + res.on('data', chunk => { + data += chunk; + }); + res.on('end', () => { + try { + const response = JSON.parse(data); + version = response?.['dist-tags']?.latest ?? version; + } catch { + } finally { + resolve({ + name, + version, + }); + } + }); + }).on('error', () => { + resolve({ + name, + version, + }); + }); + }); +} + +function updateJsonValues( + json: Record<string, any>, + target: string, + updates: Array<{name: string; value: any}>, + overwrite = false +) { + updates.forEach(({name, value}) => { + if (!json[target][name] || overwrite) { + json[target] = { + ...json[target], + [name]: value, + }; + } + }); +} + +export function addPackageJsonDependencies( + tree: Tree, + packages: NodePackage[], + type: DependencyType, + overwrite?: boolean, + fileLocation = './package.json' +): Tree { + const packageJson = getJsonFileAsObject(tree, fileLocation); + + updateJsonValues( + packageJson, + type, + packages.map(({name, version}) => { + return {name, value: version}; + }), + overwrite + ); + + tree.overwrite(fileLocation, getObjectAsJson(packageJson)); + + return tree; +} + +export function getDependenciesFromOptions( + options: SchematicsOptions +): string[] { + const dependencies = ['puppeteer']; + + switch (options.testRunner) { + case TestRunner.Jasmine: + dependencies.push('jasmine'); + break; + case TestRunner.Jest: + dependencies.push('jest', '@types/jest'); + break; + case TestRunner.Mocha: + dependencies.push('mocha', '@types/mocha'); + break; + case TestRunner.Node: + dependencies.push('@types/node'); + break; + } + + return dependencies; +} + +export function addPackageJsonScripts( + tree: Tree, + scripts: NodeScripts[], + overwrite?: boolean, + fileLocation = './package.json' +): Tree { + const packageJson = getJsonFileAsObject(tree, fileLocation); + + updateJsonValues( + packageJson, + 'scripts', + scripts.map(({name, script}) => { + return {name, value: script}; + }), + overwrite + ); + + tree.overwrite(fileLocation, getObjectAsJson(packageJson)); + + return tree; +} + +export function updateAngularJsonScripts( + tree: Tree, + options: SchematicsOptions, + overwrite = true +): Tree { + const angularJson = getAngularConfig(tree); + const projects = getApplicationProjects(tree); + const name = getNgCommandName(projects); + + Object.keys(projects).forEach(project => { + const e2eScript = [ + { + name, + value: { + builder: '@puppeteer/ng-schematics:puppeteer', + options: { + devServerTarget: `${project}:serve`, + testRunner: options.testRunner, + }, + configurations: { + production: { + devServerTarget: `${project}:serve:production`, + }, + }, + }, + }, + ]; + + updateJsonValues( + angularJson['projects'][project]!, + 'architect', + e2eScript, + overwrite + ); + }); + + tree.overwrite('./angular.json', getObjectAsJson(angularJson as any)); + + return tree; +} diff --git a/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/types.ts b/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/types.ts new file mode 100644 index 0000000000..7d66e0f0fa --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/src/schematics/utils/types.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum TestRunner { + Jasmine = 'jasmine', + Jest = 'jest', + Mocha = 'mocha', + Node = 'node', +} + +export interface SchematicsOptions { + testRunner: TestRunner; +} + +export interface PuppeteerSchematicsConfig { + builder: string; + options: { + port: number; + testRunner: TestRunner; + }; +} +export interface AngularProject { + projectType: 'application' | 'library'; + root: string; + architect: { + e2e?: PuppeteerSchematicsConfig; + puppeteer?: PuppeteerSchematicsConfig; + serve: { + options: { + ssl: string; + port: number; + }; + }; + }; +} +export interface AngularJson { + projects: Record<string, AngularProject>; +} + +export interface SchematicsSpec { + name: string; + project?: string; + route?: string; +} diff --git a/remote/test/puppeteer/packages/ng-schematics/test/src/config.test.ts b/remote/test/puppeteer/packages/ng-schematics/test/src/config.test.ts new file mode 100644 index 0000000000..e4ec03ed54 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/test/src/config.test.ts @@ -0,0 +1,30 @@ +import {describe, it} from 'node:test'; + +import expect from 'expect'; + +import { + buildTestingTree, + getMultiApplicationFile, + setupHttpHooks, +} from './utils.js'; + +void describe('@puppeteer/ng-schematics: config', () => { + setupHttpHooks(); + + void describe('Single Project', () => { + void it('should create default file', async () => { + const tree = await buildTestingTree('config', 'single'); + expect(tree.files).toContain('/.puppeteerrc.mjs'); + }); + }); + + void describe('Multi projects', () => { + void it('should create default file', async () => { + const tree = await buildTestingTree('config', 'multi'); + expect(tree.files).toContain('/.puppeteerrc.mjs'); + expect(tree.files).not.toContain( + getMultiApplicationFile('.puppeteerrc.mjs') + ); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/ng-schematics/test/src/e2e.test.ts b/remote/test/puppeteer/packages/ng-schematics/test/src/e2e.test.ts new file mode 100644 index 0000000000..8ae211cd59 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/test/src/e2e.test.ts @@ -0,0 +1,111 @@ +import {describe, it} from 'node:test'; + +import expect from 'expect'; + +import { + buildTestingTree, + getMultiApplicationFile, + setupHttpHooks, +} from './utils.js'; + +void describe('@puppeteer/ng-schematics: e2e', () => { + setupHttpHooks(); + + void describe('Single Project', () => { + void it('should create default file', async () => { + const tree = await buildTestingTree('e2e', 'single', { + name: 'myTest', + }); + expect(tree.files).toContain('/e2e/tests/my-test.e2e.ts'); + expect(tree.files).not.toContain('/e2e/tests/my-test.test.ts'); + }); + + void it('should create Node file', async () => { + const tree = await buildTestingTree('e2e', 'single', { + name: 'myTest', + testRunner: 'node', + }); + expect(tree.files).not.toContain('/e2e/tests/my-test.e2e.ts'); + expect(tree.files).toContain('/e2e/tests/my-test.test.ts'); + }); + + void it('should create file with route', async () => { + const route = 'home'; + const tree = await buildTestingTree('e2e', 'single', { + name: 'myTest', + route, + }); + expect(tree.files).toContain('/e2e/tests/my-test.e2e.ts'); + expect(tree.readContent('/e2e/tests/my-test.e2e.ts')).toContain( + `setupBrowserHooks('${route}');` + ); + }); + + void it('should create with route with starting slash', async () => { + const route = '/home'; + const tree = await buildTestingTree('e2e', 'single', { + name: 'myTest', + route, + }); + expect(tree.files).toContain('/e2e/tests/my-test.e2e.ts'); + expect(tree.readContent('/e2e/tests/my-test.e2e.ts')).toContain( + `setupBrowserHooks('home');` + ); + }); + }); + + void describe('Multi projects', () => { + void it('should create default file', async () => { + const tree = await buildTestingTree('e2e', 'multi', { + name: 'myTest', + }); + expect(tree.files).toContain( + getMultiApplicationFile('e2e/tests/my-test.e2e.ts') + ); + expect(tree.files).not.toContain( + getMultiApplicationFile('e2e/tests/my-test.test.ts') + ); + }); + + void it('should create Node file', async () => { + const tree = await buildTestingTree('e2e', 'multi', { + name: 'myTest', + testRunner: 'node', + }); + expect(tree.files).not.toContain( + getMultiApplicationFile('e2e/tests/my-test.e2e.ts') + ); + expect(tree.files).toContain( + getMultiApplicationFile('e2e/tests/my-test.test.ts') + ); + }); + + void it('should create file with route', async () => { + const route = 'home'; + const tree = await buildTestingTree('e2e', 'multi', { + name: 'myTest', + route, + }); + expect(tree.files).toContain( + getMultiApplicationFile('e2e/tests/my-test.e2e.ts') + ); + expect( + tree.readContent(getMultiApplicationFile('e2e/tests/my-test.e2e.ts')) + ).toContain(`setupBrowserHooks('${route}');`); + }); + + void it('should create with route with starting slash', async () => { + const route = '/home'; + const tree = await buildTestingTree('e2e', 'multi', { + name: 'myTest', + route, + }); + expect(tree.files).toContain( + getMultiApplicationFile('e2e/tests/my-test.e2e.ts') + ); + expect( + tree.readContent(getMultiApplicationFile('e2e/tests/my-test.e2e.ts')) + ).toContain(`setupBrowserHooks('home');`); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/ng-schematics/test/src/ng-add.test.ts b/remote/test/puppeteer/packages/ng-schematics/test/src/ng-add.test.ts new file mode 100644 index 0000000000..d912c5dc3d --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/test/src/ng-add.test.ts @@ -0,0 +1,260 @@ +import {describe, it} from 'node:test'; + +import expect from 'expect'; + +import { + MULTI_LIBRARY_OPTIONS, + buildTestingTree, + getAngularJsonScripts, + getMultiApplicationFile, + getMultiLibraryFile, + getPackageJson, + runSchematic, + setupHttpHooks, +} from './utils.js'; + +void describe('@puppeteer/ng-schematics: ng-add', () => { + setupHttpHooks(); + + void describe('Single Project', () => { + void it('should create base files and update to "package.json"', async () => { + const tree = await buildTestingTree('ng-add'); + const {devDependencies, scripts} = getPackageJson(tree); + const {builder, configurations} = getAngularJsonScripts(tree); + + expect(tree.files).toContain('/e2e/tsconfig.json'); + expect(tree.files).toContain('/e2e/tests/app.e2e.ts'); + expect(tree.files).toContain('/e2e/tests/utils.ts'); + expect(devDependencies).toContain('puppeteer'); + expect(scripts['e2e']).toBe('ng e2e'); + expect(builder).toBe('@puppeteer/ng-schematics:puppeteer'); + expect(configurations).toEqual({ + production: { + devServerTarget: 'sandbox:serve:production', + }, + }); + }); + void it('should update create proper "ng" command for non default tester', async () => { + let tree = await buildTestingTree('ng-add', 'single'); + // Re-run schematic to have e2e populated + tree = await runSchematic(tree, 'ng-add'); + const {scripts} = getPackageJson(tree); + const {builder} = getAngularJsonScripts(tree, false); + + expect(scripts['puppeteer']).toBe('ng run sandbox:puppeteer'); + expect(builder).toBe('@puppeteer/ng-schematics:puppeteer'); + }); + void it('should not create Puppeteer config', async () => { + const {files} = await buildTestingTree('ng-add', 'single'); + + expect(files).not.toContain('/.puppeteerrc.cjs'); + }); + void it('should create Jasmine files and update "package.json"', async () => { + const tree = await buildTestingTree('ng-add', 'single', { + testRunner: 'jasmine', + }); + const {devDependencies} = getPackageJson(tree); + const {options} = getAngularJsonScripts(tree); + + expect(tree.files).toContain('/e2e/jasmine.json'); + expect(devDependencies).toContain('jasmine'); + expect(options['testRunner']).toBe('jasmine'); + }); + void it('should create Jest files and update "package.json"', async () => { + const tree = await buildTestingTree('ng-add', 'single', { + testRunner: 'jest', + }); + const {devDependencies} = getPackageJson(tree); + const {options} = getAngularJsonScripts(tree); + + expect(tree.files).toContain('/e2e/jest.config.js'); + expect(devDependencies).toContain('jest'); + expect(devDependencies).toContain('@types/jest'); + expect(options['testRunner']).toBe('jest'); + }); + void it('should create Mocha files and update "package.json"', async () => { + const tree = await buildTestingTree('ng-add', 'single', { + testRunner: 'mocha', + }); + const {devDependencies} = getPackageJson(tree); + const {options} = getAngularJsonScripts(tree); + + expect(tree.files).toContain('/e2e/.mocharc.js'); + expect(devDependencies).toContain('mocha'); + expect(devDependencies).toContain('@types/mocha'); + expect(options['testRunner']).toBe('mocha'); + }); + void it('should create Node files', async () => { + const tree = await buildTestingTree('ng-add', 'single', { + testRunner: 'node', + }); + const {options} = getAngularJsonScripts(tree); + + expect(tree.files).toContain('/e2e/.gitignore'); + expect(tree.files).not.toContain('/e2e/tests/app.e2e.ts'); + expect(tree.files).toContain('/e2e/tests/app.test.ts'); + expect(options['testRunner']).toBe('node'); + }); + void it('should create TypeScript files', async () => { + const tree = await buildTestingTree('ng-add', 'single'); + const tsConfigPath = '/e2e/tsconfig.json'; + const tsConfig = tree.readJson(tsConfigPath); + + expect(tree.files).toContain(tsConfigPath); + expect(tsConfig).toMatchObject({ + extends: '../tsconfig.json', + compilerOptions: { + module: 'CommonJS', + }, + }); + }); + void it('should not create port value', async () => { + const tree = await buildTestingTree('ng-add'); + + const {options} = getAngularJsonScripts(tree); + expect(options['port']).toBeUndefined(); + }); + }); + + void describe('Multi projects Application', () => { + void it('should create base files and update to "package.json"', async () => { + const tree = await buildTestingTree('ng-add', 'multi'); + const {devDependencies, scripts} = getPackageJson(tree); + const {builder, configurations} = getAngularJsonScripts(tree); + + expect(tree.files).toContain( + getMultiApplicationFile('e2e/tsconfig.json') + ); + expect(tree.files).toContain( + getMultiApplicationFile('e2e/tests/app.e2e.ts') + ); + expect(tree.files).toContain( + getMultiApplicationFile('e2e/tests/utils.ts') + ); + expect(devDependencies).toContain('puppeteer'); + expect(scripts['e2e']).toBe('ng e2e'); + expect(builder).toBe('@puppeteer/ng-schematics:puppeteer'); + expect(configurations).toEqual({ + production: { + devServerTarget: 'sandbox:serve:production', + }, + }); + }); + void it('should update create proper "ng" command for non default tester', async () => { + let tree = await buildTestingTree('ng-add', 'multi'); + // Re-run schematic to have e2e populated + tree = await runSchematic(tree, 'ng-add'); + const {scripts} = getPackageJson(tree); + const {builder} = getAngularJsonScripts(tree, false); + + expect(scripts['puppeteer']).toBe('ng run sandbox:puppeteer'); + expect(builder).toBe('@puppeteer/ng-schematics:puppeteer'); + }); + void it('should not create Puppeteer config', async () => { + const {files} = await buildTestingTree('ng-add', 'multi'); + + expect(files).not.toContain(getMultiApplicationFile('.puppeteerrc.cjs')); + expect(files).not.toContain('/.puppeteerrc.cjs'); + }); + void it('should create Jasmine files and update "package.json"', async () => { + const tree = await buildTestingTree('ng-add', 'multi', { + testRunner: 'jasmine', + }); + const {devDependencies} = getPackageJson(tree); + const {options} = getAngularJsonScripts(tree); + + expect(tree.files).toContain(getMultiApplicationFile('e2e/jasmine.json')); + expect(devDependencies).toContain('jasmine'); + expect(options['testRunner']).toBe('jasmine'); + }); + void it('should create Jest files and update "package.json"', async () => { + const tree = await buildTestingTree('ng-add', 'multi', { + testRunner: 'jest', + }); + const {devDependencies} = getPackageJson(tree); + const {options} = getAngularJsonScripts(tree); + + expect(tree.files).toContain( + getMultiApplicationFile('e2e/jest.config.js') + ); + expect(devDependencies).toContain('jest'); + expect(devDependencies).toContain('@types/jest'); + expect(options['testRunner']).toBe('jest'); + }); + void it('should create Mocha files and update "package.json"', async () => { + const tree = await buildTestingTree('ng-add', 'multi', { + testRunner: 'mocha', + }); + const {devDependencies} = getPackageJson(tree); + const {options} = getAngularJsonScripts(tree); + + expect(tree.files).toContain(getMultiApplicationFile('e2e/.mocharc.js')); + expect(devDependencies).toContain('mocha'); + expect(devDependencies).toContain('@types/mocha'); + expect(options['testRunner']).toBe('mocha'); + }); + void it('should create Node files', async () => { + const tree = await buildTestingTree('ng-add', 'multi', { + testRunner: 'node', + }); + const {options} = getAngularJsonScripts(tree); + + expect(tree.files).toContain(getMultiApplicationFile('e2e/.gitignore')); + expect(tree.files).not.toContain( + getMultiApplicationFile('e2e/tests/app.e2e.ts') + ); + expect(tree.files).toContain( + getMultiApplicationFile('e2e/tests/app.test.ts') + ); + expect(options['testRunner']).toBe('node'); + }); + void it('should create TypeScript files', async () => { + const tree = await buildTestingTree('ng-add', 'multi'); + const tsConfigPath = getMultiApplicationFile('e2e/tsconfig.json'); + const tsConfig = tree.readJson(tsConfigPath); + + expect(tree.files).toContain(tsConfigPath); + expect(tsConfig).toMatchObject({ + extends: '../../../tsconfig.json', + compilerOptions: { + module: 'CommonJS', + }, + }); + }); + void it('should not create port value', async () => { + const tree = await buildTestingTree('ng-add'); + + const {options} = getAngularJsonScripts(tree); + expect(options['port']).toBeUndefined(); + }); + }); + + void describe('Multi projects Library', () => { + void it('should create base files and update to "package.json"', async () => { + const tree = await buildTestingTree('ng-add', 'multi'); + const config = getAngularJsonScripts( + tree, + true, + MULTI_LIBRARY_OPTIONS.name + ); + + expect(tree.files).not.toContain( + getMultiLibraryFile('e2e/tsconfig.json') + ); + expect(tree.files).not.toContain( + getMultiLibraryFile('e2e/tests/app.e2e.ts') + ); + expect(tree.files).not.toContain( + getMultiLibraryFile('e2e/tests/utils.ts') + ); + expect(config).toBeUndefined(); + }); + + void it('should not create Puppeteer config', async () => { + const {files} = await buildTestingTree('ng-add', 'multi'); + + expect(files).not.toContain(getMultiLibraryFile('.puppeteerrc.cjs')); + expect(files).not.toContain('/.puppeteerrc.cjs'); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/ng-schematics/test/src/utils.ts b/remote/test/puppeteer/packages/ng-schematics/test/src/utils.ts new file mode 100644 index 0000000000..503cbd5cec --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/test/src/utils.ts @@ -0,0 +1,147 @@ +import https from 'https'; +import {before, after} from 'node:test'; +import {join} from 'path'; + +import type {JsonObject} from '@angular-devkit/core'; +import { + SchematicTestRunner, + type UnitTestTree, +} from '@angular-devkit/schematics/testing'; +import sinon from 'sinon'; + +const WORKSPACE_OPTIONS = { + name: 'workspace', + newProjectRoot: 'projects', + version: '14.0.0', +}; + +const SINGLE_APPLICATION_OPTIONS = { + name: 'sandbox', + directory: '.', + createApplication: true, + version: '14.0.0', +}; + +const MULTI_APPLICATION_OPTIONS = { + name: SINGLE_APPLICATION_OPTIONS.name, +}; + +export const MULTI_LIBRARY_OPTIONS = { + name: 'components', +}; + +export function setupHttpHooks(): void { + // Stop outgoing Request for version fetching + before(() => { + const httpsGetStub = sinon.stub(https, 'get'); + httpsGetStub.returns({ + on: (_: string, callback: () => void) => { + callback(); + }, + } as any); + }); + + after(() => { + sinon.restore(); + }); +} + +export function getAngularJsonScripts( + tree: UnitTestTree, + isDefault = true, + name = SINGLE_APPLICATION_OPTIONS.name +): { + builder: string; + configurations: Record<string, any>; + options: Record<string, any>; +} { + const angularJson = tree.readJson('angular.json') as any; + const e2eScript = isDefault ? 'e2e' : 'puppeteer'; + return angularJson['projects']?.[name]?.['architect'][e2eScript]; +} + +export function getPackageJson(tree: UnitTestTree): { + scripts: Record<string, string>; + devDependencies: string[]; +} { + const packageJson = tree.readJson('package.json') as JsonObject; + return { + scripts: packageJson['scripts'] as any, + devDependencies: Object.keys( + packageJson['devDependencies'] as Record<string, string> + ), + }; +} + +export function getMultiApplicationFile(file: string): string { + return `/${WORKSPACE_OPTIONS.newProjectRoot}/${MULTI_APPLICATION_OPTIONS.name}/${file}`; +} +export function getMultiLibraryFile(file: string): string { + return `/${WORKSPACE_OPTIONS.newProjectRoot}/${MULTI_LIBRARY_OPTIONS.name}/${file}`; +} + +export async function buildTestingTree( + command: 'ng-add' | 'e2e' | 'config', + type: 'single' | 'multi' = 'single', + userOptions?: Record<string, unknown> +): Promise<UnitTestTree> { + const runner = new SchematicTestRunner( + 'schematics', + join(__dirname, '../../lib/schematics/collection.json') + ); + const options = { + testRunner: 'jasmine', + ...userOptions, + }; + let workingTree: UnitTestTree; + + // Build workspace + if (type === 'single') { + workingTree = await runner.runExternalSchematic( + '@schematics/angular', + 'ng-new', + SINGLE_APPLICATION_OPTIONS + ); + } else { + // Build workspace + workingTree = await runner.runExternalSchematic( + '@schematics/angular', + 'workspace', + WORKSPACE_OPTIONS + ); + // Build dummy application + workingTree = await runner.runExternalSchematic( + '@schematics/angular', + 'application', + MULTI_APPLICATION_OPTIONS, + workingTree + ); + // Build dummy library + workingTree = await runner.runExternalSchematic( + '@schematics/angular', + 'library', + MULTI_LIBRARY_OPTIONS, + workingTree + ); + } + + if (command !== 'ng-add') { + // We want to create update the proper files with `ng-add` + // First else the angular.json will have wrong data + workingTree = await runner.runSchematic('ng-add', options, workingTree); + } + + return await runner.runSchematic(command, options, workingTree); +} + +export async function runSchematic( + tree: UnitTestTree, + command: 'ng-add' | 'test', + options?: Record<string, any> +): Promise<UnitTestTree> { + const runner = new SchematicTestRunner( + 'schematics', + join(__dirname, '../../lib/schematics/collection.json') + ); + return await runner.runSchematic(command, options, tree); +} diff --git a/remote/test/puppeteer/packages/ng-schematics/test/tsconfig.json b/remote/test/puppeteer/packages/ng-schematics/test/tsconfig.json new file mode 100644 index 0000000000..3d45f9cc54 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/test/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "src/", + "outDir": "build/", + "types": ["node"], + }, + "include": ["src/**/*"], + "references": [{"path": "../tsconfig.json"}], +} diff --git a/remote/test/puppeteer/packages/ng-schematics/tools/copySchemaFiles.mjs b/remote/test/puppeteer/packages/ng-schematics/tools/copySchemaFiles.mjs new file mode 100644 index 0000000000..2bd88f229a --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/tools/copySchemaFiles.mjs @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs/promises'; +import path from 'path'; +import url from 'url'; + +const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); + +/** + * + * @param {String} directory + * @param {String[]} files + */ +async function findSchemaFiles(directory, files = []) { + const items = await fs.readdir(directory); + const promises = []; + // Match any listing that has no *.* format + // Ignore files folder + const regEx = /^.*\.[^\s]*$/; + + items.forEach(item => { + if (!item.match(regEx)) { + promises.push(findSchemaFiles(`${directory}/${item}`, files)); + } else if (item.endsWith('.json') || directory.includes('files')) { + files.push(`${directory}/${item}`); + } + }); + + await Promise.all(promises); + + return files; +} + +async function copySchemaFiles() { + const srcDir = path.join(__dirname, '..', 'src'); + const outputDir = path.join(__dirname, '..', 'lib'); + const files = await findSchemaFiles(srcDir); + + const moves = files.map(file => { + const to = file.replace(srcDir, outputDir); + + return {from: file, to}; + }); + + // Because fs.cp is Experimental (recursive support) + // We need to create directories first and copy the files + await Promise.all( + moves.map(({to}) => { + const dir = path.dirname(to); + return fs.mkdir(dir, {recursive: true}); + }) + ); + await Promise.all( + moves.map(({from, to}) => { + return fs.copyFile(from, to); + }) + ); +} + +copySchemaFiles(); diff --git a/remote/test/puppeteer/packages/ng-schematics/tools/projects.mjs b/remote/test/puppeteer/packages/ng-schematics/tools/projects.mjs new file mode 100644 index 0000000000..985200881e --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/tools/projects.mjs @@ -0,0 +1,159 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {spawn} from 'child_process'; +import {randomUUID} from 'crypto'; +import {readFile, writeFile} from 'fs/promises'; +import {join} from 'path'; +import {cwd} from 'process'; + +class AngularProject { + static ports = new Set(); + static randomPort() { + const min = 4000; + const max = 9876; + return Math.floor(Math.random() * (max - min + 1) + min); + } + static port() { + const port = AngularProject.randomPort(); + if (AngularProject.ports.has(port)) { + return AngularProject.port(); + } + return port; + } + + static #scripts = testRunner => { + return { + // Builds the ng-schematics before running them + 'build:schematics': 'npm run --prefix ../../ build', + // Deletes all files created by Puppeteer Ng-Schematics to avoid errors + 'delete:file': + 'rm -f .puppeteerrc.cjs && rm -f tsconfig.e2e.json && rm -R -f e2e/', + // Runs the Puppeteer Ng-Schematics against the sandbox + schematics: 'schematics ../../:ng-add --dry-run=false', + 'schematics:e2e': 'schematics ../../:e2e --dry-run=false', + 'schematics:config': 'schematics ../../:config --dry-run=false', + 'schematics:smoke': `schematics ../../:ng-add --dry-run=false --test-runner="${testRunner}" && ng e2e`, + }; + }; + /** Folder name */ + #name; + /** E2E test runner to use */ + #runner; + + constructor(runner, name) { + this.#runner = runner ?? 'node'; + this.#name = name ?? randomUUID(); + } + + get runner() { + return this.#runner; + } + + get name() { + return this.#name; + } + + async executeCommand(command, options) { + const [executable, ...args] = command.split(' '); + await new Promise((resolve, reject) => { + const createProcess = spawn(executable, args, { + shell: true, + ...options, + }); + + createProcess.stdout.on('data', data => { + data = data + .toString() + // Replace new lines with a prefix including the test runner + .replace(/(?:\r\n?|\n)(?=.*[\r\n])/g, `\n${this.#runner} - `); + console.log(`${this.#runner} - ${data}`); + }); + + createProcess.on('error', message => { + console.error(`Running ${command} exited with error:`, message); + reject(message); + }); + + createProcess.on('exit', code => { + if (code === 0) { + resolve(true); + } else { + reject(); + } + }); + }); + } + + async create() { + await this.createProject(); + await this.updatePackageJson(); + } + + async updatePackageJson() { + const packageJsonFile = join(cwd(), `/sandbox/${this.#name}/package.json`); + const packageJson = JSON.parse(await readFile(packageJsonFile)); + packageJson['scripts'] = { + ...packageJson['scripts'], + ...AngularProject.#scripts(this.#runner), + }; + await writeFile(packageJsonFile, JSON.stringify(packageJson, null, 2)); + } + + get commandOptions() { + return { + ...process.env, + cwd: join(cwd(), `/sandbox/${this.#name}/`), + }; + } + + async runNpmScripts(command) { + await this.executeCommand(`npm run ${command}`, this.commandOptions); + } + + async runSchematics() { + await this.runNpmScripts('schematics'); + } + + async runSchematicsE2E() { + await this.runNpmScripts('schematics:e2e'); + } + + async runSchematicsConfig() { + await this.runNpmScripts('schematics:config'); + } + + async runSmoke() { + await this.runNpmScripts( + `schematics:smoke -- --port=${AngularProject.port()}` + ); + } +} + +export class AngularProjectSingle extends AngularProject { + async createProject() { + await this.executeCommand( + `ng new ${this.name} --directory=sandbox/${this.name} --defaults --skip-git` + ); + } +} + +export class AngularProjectMulti extends AngularProject { + async createProject() { + await this.executeCommand( + `ng new ${this.name} --create-application=false --directory=sandbox/${this.name} --defaults --skip-git` + ); + + await this.executeCommand( + `ng generate application core --style=css --routing=true`, + this.commandOptions + ); + await this.executeCommand( + `ng generate application admin --style=css --routing=false`, + this.commandOptions + ); + } +} diff --git a/remote/test/puppeteer/packages/ng-schematics/tools/smoke.mjs b/remote/test/puppeteer/packages/ng-schematics/tools/smoke.mjs new file mode 100644 index 0000000000..8ae9907266 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/tools/smoke.mjs @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ok} from 'node:assert'; +import {execSync} from 'node:child_process'; +import {parseArgs} from 'node:util'; + +import {AngularProjectMulti, AngularProjectSingle} from './projects.mjs'; + +const {values: args} = parseArgs({ + options: { + testRunner: { + type: 'string', + short: 't', + default: undefined, + }, + name: { + type: 'string', + short: 'n', + default: undefined, + }, + }, +}); + +if (process.env.CI) { + // Need to install in CI + execSync('npm install -g @angular/cli@latest @angular-devkit/schematics-cli'); + const runners = ['node', 'jest', 'jasmine', 'mocha']; + const groups = []; + + for (const runner of runners) { + groups.push([ + new AngularProjectSingle(runner), + new AngularProjectMulti(runner), + ]); + } + + const angularProjects = await Promise.allSettled( + groups.flat().map(async project => { + return await project.create(); + }) + ); + ok( + angularProjects.every(project => { + return project.status === 'fulfilled'; + }), + 'Building of 1 or more projects failed!' + ); + + for await (const runnerGroup of groups) { + const smokeResults = await Promise.allSettled( + runnerGroup.map(async project => { + return await project.runSmoke(); + }) + ); + ok( + smokeResults.every(project => { + return project.status === 'fulfilled'; + }), + `Smoke test for ${runnerGroup[0].runner} failed!` + ); + } +} else { + const single = new AngularProjectSingle(args.testRunner, args.name); + const multi = new AngularProjectMulti(args.testRunner, args.name); + + await Promise.all([single.create(), multi.create()]); + await Promise.all([single.runSmoke(), multi.runSmoke()]); +} diff --git a/remote/test/puppeteer/packages/ng-schematics/tsconfig.json b/remote/test/puppeteer/packages/ng-schematics/tsconfig.json new file mode 100644 index 0000000000..40529c7d17 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "tsconfig", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmitOnError": true, + "rootDir": "src/", + "outDir": "lib/", + "skipDefaultLibCheck": true, + "skipLibCheck": true, + "sourceMap": true, + "types": ["node"], + }, + "include": ["src/**/*"], + "exclude": ["src/**/files/**/*"], +} diff --git a/remote/test/puppeteer/packages/ng-schematics/tsdoc.json b/remote/test/puppeteer/packages/ng-schematics/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/packages/ng-schematics/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/.gitignore b/remote/test/puppeteer/packages/puppeteer-core/.gitignore new file mode 100644 index 0000000000..42061c01a1 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/.gitignore @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000000..341d706fb4 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/CHANGELOG.md @@ -0,0 +1,1926 @@ +# Changelog + +All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.0.1 to 1.1.0 + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.4.4 to 1.4.5 + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.5.1 to 1.6.0 + +## [21.10.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.9.0...puppeteer-core-v21.10.0) (2024-01-29) + + +### Features + +* add experimental browser.debugInfo ([#11748](https://github.com/puppeteer/puppeteer/issues/11748)) ([f88e1da](https://github.com/puppeteer/puppeteer/commit/f88e1da6385bc72e9ffde8514c28e4a0ff9e396a)) +* download chrome-headless-shell by default and use it for the old headless mode ([#11754](https://github.com/puppeteer/puppeteer/issues/11754)) ([ce894a2](https://github.com/puppeteer/puppeteer/commit/ce894a2ffce4bc44bd11f12d1f0543e003a97e02)) + + +### Bug Fixes + +* set viewport for element screenshots ([#11772](https://github.com/puppeteer/puppeteer/issues/11772)) ([9cd6673](https://github.com/puppeteer/puppeteer/commit/9cd66731d148afff9c2f873c1383fbe367cc5fb2)) + +## [21.9.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.8.0...puppeteer-core-v21.9.0) (2024-01-24) + + +### Features + +* roll to Chrome 121.0.6167.85 (r1233107) ([#11743](https://github.com/puppeteer/puppeteer/issues/11743)) ([0eec94c](https://github.com/puppeteer/puppeteer/commit/0eec94cf57288528ecd0a084a71311b181864f7b)) + +## [21.8.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.7.0...puppeteer-core-v21.8.0) (2024-01-24) + + +### Features + +* roll to Chrome 120.0.6099.109 (r1217362) ([#11733](https://github.com/puppeteer/puppeteer/issues/11733)) ([415cfac](https://github.com/puppeteer/puppeteer/commit/415cfaca202126b64ff496e4318cae64c4f14e89)) + + +### Bug Fixes + +* expose function for Firefox BiDi ([#11660](https://github.com/puppeteer/puppeteer/issues/11660)) ([cf879b8](https://github.com/puppeteer/puppeteer/commit/cf879b82f6c10302fcafe186b315fe7807107c31)) +* wait for WebDriver BiDi browser to close gracefully ([#11636](https://github.com/puppeteer/puppeteer/issues/11636)) ([cc3aeeb](https://github.com/puppeteer/puppeteer/commit/cc3aeeb6eae4663198466755f23746ef821408ae)) + + +### Reverts + +* refactor: adopt `core/UserContext` on `BidiBrowserContext` ([#11721](https://github.com/puppeteer/puppeteer/issues/11721)) ([d17a9df](https://github.com/puppeteer/puppeteer/commit/d17a9df0278be34c206701d8dfc1fb62af3637b3)) + +## [21.7.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.6.1...puppeteer-core-v21.7.0) (2024-01-04) + + +### Features + +* allow converting other targets to pages ([#11604](https://github.com/puppeteer/puppeteer/issues/11604)) ([66aa770](https://github.com/puppeteer/puppeteer/commit/66aa77003880a1458e14b47a3ed87856fd3a1933)) +* support fetching request POST data ([#11598](https://github.com/puppeteer/puppeteer/issues/11598)) ([80143de](https://github.com/puppeteer/puppeteer/commit/80143def9606ec5f2018dde618c00784442c5c1d)) +* support timeouts per CDP command ([#11595](https://github.com/puppeteer/puppeteer/issues/11595)) ([c660d40](https://github.com/puppeteer/puppeteer/commit/c660d4001d610854399d7ecb551c4eb56a7f840a)) + + +### Bug Fixes + +* change viewportHeight in screencast ([#11583](https://github.com/puppeteer/puppeteer/issues/11583)) ([107b833](https://github.com/puppeteer/puppeteer/commit/107b8337e5eebc5e31a57663ba1345be81fb486e)) +* disable GFX sanity window for Firefox and enable WebDriver BiDi CI jobs for Windows ([#11578](https://github.com/puppeteer/puppeteer/issues/11578)) ([e41a265](https://github.com/puppeteer/puppeteer/commit/e41a2656d9e1f3f037b298457fbd6c6e08f5a371)) +* improve reliability of exposeFunction ([#11600](https://github.com/puppeteer/puppeteer/issues/11600)) ([b0c5392](https://github.com/puppeteer/puppeteer/commit/b0c5392cb36eed2ed4ae4864587885b6059f4cfb)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.9.0 to 1.9.1 + +## [21.6.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.6.0...puppeteer-core-v21.6.1) (2023-12-13) + + +### Bug Fixes + +* emulate if captureBeyondViewport is false ([#11525](https://github.com/puppeteer/puppeteer/issues/11525)) ([b6d1163](https://github.com/puppeteer/puppeteer/commit/b6d1163f7f33d80fd43fa4915789d3689ea2369f)) +* ensure fission.bfcacheInParent is disabled for cdp in Firefox ([#11522](https://github.com/puppeteer/puppeteer/issues/11522)) ([b4a6524](https://github.com/puppeteer/puppeteer/commit/b4a65245b0ad01b2b634473ebb4d8bb2d7e420f7)) + +## [21.6.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.5.2...puppeteer-core-v21.6.0) (2023-12-05) + + +### Features + +* BiDi implementation of `Puppeteer.connect` for Firefox ([#11451](https://github.com/puppeteer/puppeteer/issues/11451)) ([be081ba](https://github.com/puppeteer/puppeteer/commit/be081ba17a9bbac70c13cafa81f1038f0ecfda70)) +* experimental WebDriver BiDi support with Firefox ([#11412](https://github.com/puppeteer/puppeteer/issues/11412)) ([8aba033](https://github.com/puppeteer/puppeteer/commit/8aba033dde1a306e37f6033d6f6ff36387e1aac3)) +* implement the Puppeteer CLI ([#11344](https://github.com/puppeteer/puppeteer/issues/11344)) ([53fb69b](https://github.com/puppeteer/puppeteer/commit/53fb69bf7f2bf06fa4fd7bb6d3cf21382386f6e7)) + + +### Bug Fixes + +* end WebDriver BiDi session on disconnect ([#11470](https://github.com/puppeteer/puppeteer/issues/11470)) ([a66d029](https://github.com/puppeteer/puppeteer/commit/a66d0296077a82179a2182281a5040fd96d3843c)) +* remove CDP-specific preferences from defaults for Firefox ([#11477](https://github.com/puppeteer/puppeteer/issues/11477)) ([f8c9469](https://github.com/puppeteer/puppeteer/commit/f8c94699c7f5b15c7bb96f299c2c8217d74230cd)) +* warn about launch Chrome using Node x64 on arm64 Macs ([#11471](https://github.com/puppeteer/puppeteer/issues/11471)) ([957a829](https://github.com/puppeteer/puppeteer/commit/957a8293bb1444fd51fd5673002a7781e8127c9d)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.8.0 to 1.9.0 + +## [21.5.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.5.1...puppeteer-core-v21.5.2) (2023-11-15) + + +### Bug Fixes + +* add --disable-field-trial-config ([#11352](https://github.com/puppeteer/puppeteer/issues/11352)) ([cbc33be](https://github.com/puppeteer/puppeteer/commit/cbc33bea40b8801b8eeb3277fc15d04900715795)) +* add --disable-infobars ([#11377](https://github.com/puppeteer/puppeteer/issues/11377)) ([0a41f8d](https://github.com/puppeteer/puppeteer/commit/0a41f8d01e85ff732fdd2e50468bc746d7bc6475)) +* mitt types should not be exported ([#11371](https://github.com/puppeteer/puppeteer/issues/11371)) ([4bf2a09](https://github.com/puppeteer/puppeteer/commit/4bf2a09a13450c530b24288d65791fd5c4d4dce7)) + +## [21.5.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.5.0...puppeteer-core-v21.5.1) (2023-11-09) + + +### Bug Fixes + +* better debugging for WaitTask ([#11330](https://github.com/puppeteer/puppeteer/issues/11330)) ([d2480b0](https://github.com/puppeteer/puppeteer/commit/d2480b022d74b7071b515408a31c6e82448e3c9e)) + +## [21.5.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.4.1...puppeteer-core-v21.5.0) (2023-11-02) + + +### Features + +* roll to Chrome 119.0.6045.105 (r1204232) ([#11287](https://github.com/puppeteer/puppeteer/issues/11287)) ([325fa8b](https://github.com/puppeteer/puppeteer/commit/325fa8b1b16a9dafd5bb320e49984d24044fa3d7)) + + +### Bug Fixes + +* ignore unordered frames ([#11283](https://github.com/puppeteer/puppeteer/issues/11283)) ([ce4e485](https://github.com/puppeteer/puppeteer/commit/ce4e485d1b1e9d4e223890ee0fc2475a1ad71bc3)) +* Type for ElementHandle.screenshot ([#11274](https://github.com/puppeteer/puppeteer/issues/11274)) ([22aeff1](https://github.com/puppeteer/puppeteer/commit/22aeff1eac9d22048330a16aa3c41293133911e4)) + +## [21.4.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.4.0...puppeteer-core-v21.4.1) (2023-10-23) + + +### Bug Fixes + +* do not pass --{enable,disable}-features twice when user-provided ([#11230](https://github.com/puppeteer/puppeteer/issues/11230)) ([edec7d5](https://github.com/puppeteer/puppeteer/commit/edec7d53f8190381ade7db145ad7e7d6dba2ee13)) +* remove circular import in IsolatedWorld ([#11228](https://github.com/puppeteer/puppeteer/issues/11228)) ([3edce3a](https://github.com/puppeteer/puppeteer/commit/3edce3aee9521654d7a285f4068a5e60bfb52245)) +* remove import cycle ([#11227](https://github.com/puppeteer/puppeteer/issues/11227)) ([525f13c](https://github.com/puppeteer/puppeteer/commit/525f13cd18b39cc951a84aa51b2d852758e6f0d2)) +* remove import cycle in connection ([#11225](https://github.com/puppeteer/puppeteer/issues/11225)) ([60f1b78](https://github.com/puppeteer/puppeteer/commit/60f1b788a6304504f504b0be9f02cb768e2803f8)) +* remove import cycle in query handlers ([#11234](https://github.com/puppeteer/puppeteer/issues/11234)) ([954c75f](https://github.com/puppeteer/puppeteer/commit/954c75f9a9879e2e68935c17d7eb777b1f9f808a)) +* remove more import cycles ([#11231](https://github.com/puppeteer/puppeteer/issues/11231)) ([b9ce89e](https://github.com/puppeteer/puppeteer/commit/b9ce89e460702ad85314685c600a4e5267f4db9b)) +* typo in screencast error message ([#11213](https://github.com/puppeteer/puppeteer/issues/11213)) ([25b90b2](https://github.com/puppeteer/puppeteer/commit/25b90b2b542c4693150b67dc0c690b99f4ccfc95)) + +## [21.4.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.3.8...puppeteer-core-v21.4.0) (2023-10-20) + + +### Features + +* added tagged (accessible) PDFs option ([#11182](https://github.com/puppeteer/puppeteer/issues/11182)) ([0316863](https://github.com/puppeteer/puppeteer/commit/031686339136873c555a19ffb871f7140a2c39d9)) +* enable tab targets ([#11099](https://github.com/puppeteer/puppeteer/issues/11099)) ([8324c16](https://github.com/puppeteer/puppeteer/commit/8324c1634883d97ed83f32a1e62acc9b5e64e0bd)) +* implement screencasting ([#11084](https://github.com/puppeteer/puppeteer/issues/11084)) ([f060d46](https://github.com/puppeteer/puppeteer/commit/f060d467c00457e6be6878e0789d0df2ac4aae50)) +* merge user-provided --{disable,enable}-features in args ([#11152](https://github.com/puppeteer/puppeteer/issues/11152)) ([2b578e4](https://github.com/puppeteer/puppeteer/commit/2b578e4a096aa94d792cc2da2da41fee061a77b8)), closes [#11072](https://github.com/puppeteer/puppeteer/issues/11072) +* roll to Chrome 118.0.5993.70 (r1192594) ([#11123](https://github.com/puppeteer/puppeteer/issues/11123)) ([91d14c8](https://github.com/puppeteer/puppeteer/commit/91d14c8c86f5be48c8e0937fd209bea643d60b45)) + + +### Bug Fixes + +* `Page.waitForDevicePrompt` crash ([#11153](https://github.com/puppeteer/puppeteer/issues/11153)) ([257be15](https://github.com/puppeteer/puppeteer/commit/257be15d83a46038a65d47977d4d847c54506517)) +* add InlineTextBox as a non-element a11y role ([#11142](https://github.com/puppeteer/puppeteer/issues/11142)) ([8aa6cb3](https://github.com/puppeteer/puppeteer/commit/8aa6cb37d2443ff7fe2a1fd5d5adafdde4e9d165)) +* disable ProcessPerSiteUpToMainFrameThreshold in Chrome ([#11139](https://github.com/puppeteer/puppeteer/issues/11139)) ([9347aae](https://github.com/puppeteer/puppeteer/commit/9347aae12e996604cea871acc9d007cbf338542e)) +* make sure discovery happens before auto-attach ([#11100](https://github.com/puppeteer/puppeteer/issues/11100)) ([9ce204e](https://github.com/puppeteer/puppeteer/commit/9ce204e27ed091bde5aa5bc9f82da41c80534bde)) +* synchronize frame tree with the events processing ([#11112](https://github.com/puppeteer/puppeteer/issues/11112)) ([d63f0cf](https://github.com/puppeteer/puppeteer/commit/d63f0cfc61e8ba2233eee8b2f3b99d8619a0acaf)) +* update TextQuerySelector cache on subtree update ([#11200](https://github.com/puppeteer/puppeteer/issues/11200)) ([4206e76](https://github.com/puppeteer/puppeteer/commit/4206e76c3e4647ea6290f16127764d1a2f337dcf)) +* xpath queries should be atomic ([#11101](https://github.com/puppeteer/puppeteer/issues/11101)) ([6098bab](https://github.com/puppeteer/puppeteer/commit/6098bab2ba68276c85a974e17c9fe3bdac8c4c58)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.7.1 to 1.8.0 + +## [21.3.8](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.3.7...puppeteer-core-v21.3.8) (2023-10-06) + + +### Bug Fixes + +* avoid double subscription to frame manager in Page ([#11091](https://github.com/puppeteer/puppeteer/issues/11091)) ([5887649](https://github.com/puppeteer/puppeteer/commit/5887649891ea9cf1d7b3afbcf7196620ceb20ab2)) +* update file chooser events ([#11057](https://github.com/puppeteer/puppeteer/issues/11057)) ([317f820](https://github.com/puppeteer/puppeteer/commit/317f82055b2f4dd68db136a3d52c5712425fa339)) + +## [21.3.7](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.3.6...puppeteer-core-v21.3.7) (2023-10-05) + + +### Bug Fixes + +* roll to Chrome 117.0.5938.149 (r1181205) ([#11077](https://github.com/puppeteer/puppeteer/issues/11077)) ([0c0e516](https://github.com/puppeteer/puppeteer/commit/0c0e516d736665a27f7773f66a0f9c362daa73aa)) + +## [21.3.6](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.3.5...puppeteer-core-v21.3.6) (2023-09-28) + + +### Bug Fixes + +* remove the flag disabling bfcache ([#11047](https://github.com/puppeteer/puppeteer/issues/11047)) ([b0d7375](https://github.com/puppeteer/puppeteer/commit/b0d73755193e7c60deb70df120859b5db87e7817)) + +## [21.3.5](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.3.4...puppeteer-core-v21.3.5) (2023-09-26) + + +### Bug Fixes + +* set defaults in screenshot ([#11021](https://github.com/puppeteer/puppeteer/issues/11021)) ([ace1230](https://github.com/puppeteer/puppeteer/commit/ace1230e41aad6168dc85b9bc1f7c04d9dce5527)) + +## [21.3.4](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.3.3...puppeteer-core-v21.3.4) (2023-09-22) + + +### Bug Fixes + +* avoid structuredClone for Node 16 ([#11006](https://github.com/puppeteer/puppeteer/issues/11006)) ([25eca9a](https://github.com/puppeteer/puppeteer/commit/25eca9a747c122b3096b0f2d01b3323339d57dd9)) + +## [21.3.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.3.2...puppeteer-core-v21.3.3) (2023-09-22) + + +### Bug Fixes + +* do not export bidi and fix import from the entrypoint ([#10998](https://github.com/puppeteer/puppeteer/issues/10998)) ([88c78de](https://github.com/puppeteer/puppeteer/commit/88c78dea41eb7690d67343298c150194fe145763)) + +## [21.3.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.3.1...puppeteer-core-v21.3.2) (2023-09-22) + + +### Bug Fixes + +* handle missing detach events for restored bfcache targets ([#10967](https://github.com/puppeteer/puppeteer/issues/10967)) ([7bcdfcb](https://github.com/puppeteer/puppeteer/commit/7bcdfcb7e9e75feca0a8de692926ea25ca8fbed0)) +* roll to Chrome 117.0.5938.92 (r1181205) ([#10989](https://github.com/puppeteer/puppeteer/issues/10989)) ([d048cd9](https://github.com/puppeteer/puppeteer/commit/d048cd965f0707dd9b2a3276f02c563b69f6fac4)) + +## [21.3.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.3.0...puppeteer-core-v21.3.1) (2023-09-19) + + +### Bug Fixes + +* make `CDPSessionEvent.SessionAttached` public ([#10941](https://github.com/puppeteer/puppeteer/issues/10941)) ([cfed7b9](https://github.com/puppeteer/puppeteer/commit/cfed7b93ec23e92ec11632f1cd90f00dac754739)) + +## [21.3.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.2.1...puppeteer-core-v21.3.0) (2023-09-19) + + +### Features + +* implement `Browser.connected` ([#10927](https://github.com/puppeteer/puppeteer/issues/10927)) ([a4345a4](https://github.com/puppeteer/puppeteer/commit/a4345a477f58541f5d95da11ffee74abe24c12bf)) +* implement `BrowserContext.closed` ([#10928](https://github.com/puppeteer/puppeteer/issues/10928)) ([2292078](https://github.com/puppeteer/puppeteer/commit/2292078969fa46a27d5759989cd44a4d48beb310)) +* implement improved Drag n' Drop APIs ([#10651](https://github.com/puppeteer/puppeteer/issues/10651)) ([9342bac](https://github.com/puppeteer/puppeteer/commit/9342bac2639702090f39fc1e3a97d43a934f3f0b)) +* implement typed events ([#10889](https://github.com/puppeteer/puppeteer/issues/10889)) ([9b6f1de](https://github.com/puppeteer/puppeteer/commit/9b6f1de8b99445c661c5aebcf041fe90daf469b9)) +* roll to Chrome 117.0.5938.62 (r1181205) ([#10893](https://github.com/puppeteer/puppeteer/issues/10893)) ([4b8d20d](https://github.com/puppeteer/puppeteer/commit/4b8d20d0edeccaa3028e0c1c0b63c022cfabcee2)) + + +### Bug Fixes + +* fix line/column number in errors ([#10926](https://github.com/puppeteer/puppeteer/issues/10926)) ([a0e57f7](https://github.com/puppeteer/puppeteer/commit/a0e57f7eb230ba6a659c2d418da8d3f67add2d00)) +* handle frame manager init without unhandled rejection ([#10902](https://github.com/puppeteer/puppeteer/issues/10902)) ([ea14834](https://github.com/puppeteer/puppeteer/commit/ea14834fdf1c7c1afa45bdd1fb5339380f4631a2)) +* remove explicit resource management from types ([#10918](https://github.com/puppeteer/puppeteer/issues/10918)) ([a1b1bff](https://github.com/puppeteer/puppeteer/commit/a1b1bffb7258f1dec3b0a2e9ce068baf2cc3db19)) +* roll to Chrome 117.0.5938.88 (r1181205) ([#10920](https://github.com/puppeteer/puppeteer/issues/10920)) ([b7bcc9a](https://github.com/puppeteer/puppeteer/commit/b7bcc9a733a3ac376397a32c3f62eb68101bedf9)) + +## [21.2.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.2.0...puppeteer-core-v21.2.1) (2023-09-13) + + +### Bug Fixes + +* use supported node range for types ([#10896](https://github.com/puppeteer/puppeteer/issues/10896)) ([2d851c1](https://github.com/puppeteer/puppeteer/commit/2d851c1398e5efcdabdb5304dc78e68cbd3fadd2)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.7.0 to 1.7.1 + +## [21.2.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.1.1...puppeteer-core-v21.2.0) (2023-09-12) + + +### Features + +* expose DevTools as a target ([#10812](https://github.com/puppeteer/puppeteer/issues/10812)) ([a540085](https://github.com/puppeteer/puppeteer/commit/a540085176d92bd160a12ebc54606dbacd064979)) + + +### Bug Fixes + +* add --disable-search-engine-choice-screen to default arguments ([#10880](https://github.com/puppeteer/puppeteer/issues/10880)) ([d08ad5f](https://github.com/puppeteer/puppeteer/commit/d08ad5fbbe3be4349dd6132c209895f8436ae9e6)) +* apply viewport emulation to prerender targets ([#10804](https://github.com/puppeteer/puppeteer/issues/10804)) ([14f0ab7](https://github.com/puppeteer/puppeteer/commit/14f0ab7397053db5591823c716e142c684f25b44)) +* implement `throwIfDetached` ([#10826](https://github.com/puppeteer/puppeteer/issues/10826)) ([538bb73](https://github.com/puppeteer/puppeteer/commit/538bb73ea7e280cacf15fc1d2100251d8e17f906)) +* LifecycleWatcher sub frames handling ([#10841](https://github.com/puppeteer/puppeteer/issues/10841)) ([06c1588](https://github.com/puppeteer/puppeteer/commit/06c1588016e1ebef5ed8f079dc34507f6d781e07)) +* make network manager multi session ([#10793](https://github.com/puppeteer/puppeteer/issues/10793)) ([085936b](https://github.com/puppeteer/puppeteer/commit/085936bd7e17ed5a8085311f5b212c7b9ca96a0d)) +* make page.goBack work with bfcache in tab mode ([#10818](https://github.com/puppeteer/puppeteer/issues/10818)) ([22daf18](https://github.com/puppeteer/puppeteer/commit/22daf1861fc358acf4d84c360049736c22249f92)) +* only a single disable features flag is allowed ([#10887](https://github.com/puppeteer/puppeteer/issues/10887)) ([4852e22](https://github.com/puppeteer/puppeteer/commit/4852e222b771ed9b95596657f70e45c1d5b9790d)) +* trimCache should remove Firefox too ([#10872](https://github.com/puppeteer/puppeteer/issues/10872)) ([acdd7d3](https://github.com/puppeteer/puppeteer/commit/acdd7d3cd5529bc934edbb8479bdb950cc7d8a6a)) + +## [21.1.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.1.0...puppeteer-core-v21.1.1) (2023-08-28) + + +### Bug Fixes + +* **locators:** do not retry via catchError ([#10762](https://github.com/puppeteer/puppeteer/issues/10762)) ([8f9388f](https://github.com/puppeteer/puppeteer/commit/8f9388f2ce5220ad9b3c05fb3f3d9a86fac894dc)) + +## [21.1.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.0.3...puppeteer-core-v21.1.0) (2023-08-18) + + +### Features + +* roll to Chrome 116.0.5845.96 (r1160321) ([#10735](https://github.com/puppeteer/puppeteer/issues/10735)) ([e12b558](https://github.com/puppeteer/puppeteer/commit/e12b558f505aab13f38030a7b748261bdeadc48b)) + + +### Bug Fixes + +* locator.fill should work for textareas ([#10737](https://github.com/puppeteer/puppeteer/issues/10737)) ([fc08a7d](https://github.com/puppeteer/puppeteer/commit/fc08a7dd54226878300f3a4b52fb16aeb5cc93e8)) +* relative ordering of events and command responses should be ensured ([#10725](https://github.com/puppeteer/puppeteer/issues/10725)) ([81ecb60](https://github.com/puppeteer/puppeteer/commit/81ecb60190f89389abb6d8834158f38ff7317ec8)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.6.0 to 1.7.0 + +## [21.0.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.0.1...puppeteer-core-v21.0.2) (2023-08-08) + + +### Bug Fixes + +* destroy puppeteer utility on context destruction ([#10672](https://github.com/puppeteer/puppeteer/issues/10672)) ([8b8770c](https://github.com/puppeteer/puppeteer/commit/8b8770c004ba842496e0ca4845642fe82a211051)) +* roll to Chrome 115.0.5790.170 (r1148114) ([#10677](https://github.com/puppeteer/puppeteer/issues/10677)) ([e5af57e](https://github.com/puppeteer/puppeteer/commit/e5af57ebd0187c296bc44426c1b931f57442732e)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.5.0 to 1.5.1 + +## [21.0.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v21.0.0...puppeteer-core-v21.0.1) (2023-08-03) + + +### Bug Fixes + +* use handle frame instead of page ([#10676](https://github.com/puppeteer/puppeteer/issues/10676)) ([1b44b91](https://github.com/puppeteer/puppeteer/commit/1b44b911d3633df89bd6106aaf7accb49230934d)) + +## [21.0.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.9.0...puppeteer-core-v21.0.0) (2023-08-02) + + +### ⚠ BREAKING CHANGES + +* use Target for filters ([#10601](https://github.com/puppeteer/puppeteer/issues/10601)) + +### Features + +* add page.createCDPSession method ([#10515](https://github.com/puppeteer/puppeteer/issues/10515)) ([d0c5b8e](https://github.com/puppeteer/puppeteer/commit/d0c5b8e08905f3802705a1a90d7cc8fa04bc82db)) +* implement `Locator.prototype.filter` ([#10631](https://github.com/puppeteer/puppeteer/issues/10631)) ([e73d35d](https://github.com/puppeteer/puppeteer/commit/e73d35def0718468fe854ac2ef5f4a8beafb2fb3)) +* implement `Locator.prototype.map` ([#10630](https://github.com/puppeteer/puppeteer/issues/10630)) ([47eecf5](https://github.com/puppeteer/puppeteer/commit/47eecf5bb11daba0114ad04282beb01c85eb9405)) +* implement `Locator.prototype.wait` ([#10629](https://github.com/puppeteer/puppeteer/issues/10629)) ([5d34d42](https://github.com/puppeteer/puppeteer/commit/5d34d42d1536cbe7cf2ba1aa8670d909c4e6a6fc)) +* implement `Locator.prototype.waitHandle` ([#10650](https://github.com/puppeteer/puppeteer/issues/10650)) ([fdada74](https://github.com/puppeteer/puppeteer/commit/fdada74ba7265b3571ebdf60ae301b64d13a8226)) +* implement function locators ([#10632](https://github.com/puppeteer/puppeteer/issues/10632)) ([6ad92f7](https://github.com/puppeteer/puppeteer/commit/6ad92f7f84f477b22674f52f0a145a500c3aa152)) +* implement immutable locator operations ([#10638](https://github.com/puppeteer/puppeteer/issues/10638)) ([34be28d](https://github.com/puppeteer/puppeteer/commit/34be28db5d9971cf16d9741b0141357df3cbf74c)) + + +### Bug Fixes + +* remove typescript from peer dependencies ([#10593](https://github.com/puppeteer/puppeteer/issues/10593)) ([c60572a](https://github.com/puppeteer/puppeteer/commit/c60572a1ca36ea5946d287bd629ac31798d84cb0)) +* roll to Chrome 115.0.5790.102 (r1148114) ([#10608](https://github.com/puppeteer/puppeteer/issues/10608)) ([8649c53](https://github.com/puppeteer/puppeteer/commit/8649c53a706e5a09ae5e16849eb29a793cec5bec)) + + +### Code Refactoring + +* use Target for filters ([#10601](https://github.com/puppeteer/puppeteer/issues/10601)) ([44712d1](https://github.com/puppeteer/puppeteer/commit/44712d1e6efcb3fa49c27b1195d17c0c1c92a0ca)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.4.6 to 1.5.0 + +## [20.9.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.8.3...puppeteer-core-v20.9.0) (2023-07-20) + + +### Features + +* add autofill support ([#10565](https://github.com/puppeteer/puppeteer/issues/10565)) ([6c9306a](https://github.com/puppeteer/puppeteer/commit/6c9306a72e0f7195a4a6c300645f6089845c9abc)) +* roll to Chrome 115.0.5790.98 (r1148114) ([#10584](https://github.com/puppeteer/puppeteer/issues/10584)) ([830f926](https://github.com/puppeteer/puppeteer/commit/830f926d486675701720b5c147f597364f3e8f7b)) + + +### Bug Fixes + +* update the target to ES2022 ([#10574](https://github.com/puppeteer/puppeteer/issues/10574)) ([88439f9](https://github.com/puppeteer/puppeteer/commit/88439f913ed4159cdc8be573f2dbda0b1f615301)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.4.5 to 1.4.6 + +## [20.8.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.8.2...puppeteer-core-v20.8.3) (2023-07-18) + + +### Bug Fixes + +* **locators:** reject the race if there are only failures ([#10567](https://github.com/puppeteer/puppeteer/issues/10567)) ([e3dd596](https://github.com/puppeteer/puppeteer/commit/e3dd5968cae196b64d958c161fed3d1b39aed3f6)) +* prevent erroneous new main frame ([#10549](https://github.com/puppeteer/puppeteer/issues/10549)) ([cb46413](https://github.com/puppeteer/puppeteer/commit/cb46413d87f10970f4088b7d58e02a65c5ccd27e)) + +## [20.8.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.8.0...puppeteer-core-v20.8.1) (2023-07-11) + + +### Bug Fixes + +* remove test metadata files ([#10520](https://github.com/puppeteer/puppeteer/issues/10520)) ([cbf4f2a](https://github.com/puppeteer/puppeteer/commit/cbf4f2a66912f24849ae8c88fc1423851dcc4aa7)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.4.3 to 1.4.4 + +## [20.8.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.7.4...puppeteer-core-v20.8.0) (2023-07-06) + + +### Features + +* **screenshot:** enable optimizeForSpeed ([#10492](https://github.com/puppeteer/puppeteer/issues/10492)) ([87aaed4](https://github.com/puppeteer/puppeteer/commit/87aaed4807e5240dec7b25273e44c1ce5e884336)) + + +### Bug Fixes + +* add an internal page.locatorRace ([#10512](https://github.com/puppeteer/puppeteer/issues/10512)) ([56a97dd](https://github.com/puppeteer/puppeteer/commit/56a97dd2fb1cbf36e4f3344f7d22afd6e7ef2380)) + +## [20.7.4](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.7.3...puppeteer-core-v20.7.4) (2023-06-29) + + +### Bug Fixes + +* fix escaping algo for P selectors ([#10474](https://github.com/puppeteer/puppeteer/issues/10474)) ([84a956f](https://github.com/puppeteer/puppeteer/commit/84a956f56ba9ce74e9dd0f95ff40fdd14be87b1d)) +* fix the util import in Connection.ts ([#10450](https://github.com/puppeteer/puppeteer/issues/10450)) ([61f4525](https://github.com/puppeteer/puppeteer/commit/61f4525ae306810404af9083d2e7440403c02722)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.4.2 to 1.4.3 + +## [20.7.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.7.2...puppeteer-core-v20.7.3) (2023-06-20) + + +### Bug Fixes + +* add parenthesis to JS values in interpolateFunction ([#10426](https://github.com/puppeteer/puppeteer/issues/10426)) ([fbdcc0d](https://github.com/puppeteer/puppeteer/commit/fbdcc0d6469abe7115723347a9f161628074d41e)) +* added clipboard permission that was not exposed ([#10119](https://github.com/puppeteer/puppeteer/issues/10119)) ([c06e15f](https://github.com/puppeteer/puppeteer/commit/c06e15fb5bd7ec21db2d883ccf63ef8fe98c7f4d)) +* include src into published package ([#10415](https://github.com/puppeteer/puppeteer/issues/10415)) ([d1ffad0](https://github.com/puppeteer/puppeteer/commit/d1ffad059ae66104842b92dc814d362c123b9646)) +* WaitForNetworkIdle and Deferred.race ([#10411](https://github.com/puppeteer/puppeteer/issues/10411)) ([138cc5c](https://github.com/puppeteer/puppeteer/commit/138cc5c961da698bf7ca635c9947058df4b2ec72)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.4.1 to 1.4.2 + +## [20.7.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.7.1...puppeteer-core-v20.7.2) (2023-06-16) + + +### Bug Fixes + +* roll to Chrome 114.0.5735.133 (r1135570) ([#10384](https://github.com/puppeteer/puppeteer/issues/10384)) ([9311558](https://github.com/puppeteer/puppeteer/commit/93115587c94278e0a5309429d3f23a52ed24e22d)) + +## [20.7.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.7.0...puppeteer-core-v20.7.1) (2023-06-13) + + +### Bug Fixes + +* avoid importing puppeteer-core.js ([#10376](https://github.com/puppeteer/puppeteer/issues/10376)) ([3171c12](https://github.com/puppeteer/puppeteer/commit/3171c12a0c16b283e6b65b1ed3d801b089a6e28b)) + +## [20.7.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.6.0...puppeteer-core-v20.7.0) (2023-06-13) + + +### Features + +* add `reset` to mouse ([#10340](https://github.com/puppeteer/puppeteer/issues/10340)) ([35aedc0](https://github.com/puppeteer/puppeteer/commit/35aedc0dbbd80818e6f83ff9f0777dc3ea2588f0)) + + +### Bug Fixes + +* Locator.scroll in race ([#10363](https://github.com/puppeteer/puppeteer/issues/10363)) ([ba28724](https://github.com/puppeteer/puppeteer/commit/ba28724952b41ea653830a75efc4c73b234ea354)) +* mark CDPSessionOnMessageObject as internal ([#10373](https://github.com/puppeteer/puppeteer/issues/10373)) ([7cb6059](https://github.com/puppeteer/puppeteer/commit/7cb6059bcc36f8dc3739a8df9119c658146ac100)) +* specify the context id when adding bindings ([#10366](https://github.com/puppeteer/puppeteer/issues/10366)) ([c2d3488](https://github.com/puppeteer/puppeteer/commit/c2d3488ad8c0453312557ba28e6ade9c32464f17)) + +## [20.6.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.5.0...puppeteer-core-v20.6.0) (2023-06-09) + + +### Features + +* add `page.removeExposedFunction` ([#10297](https://github.com/puppeteer/puppeteer/issues/10297)) ([4d0dbbc](https://github.com/puppeteer/puppeteer/commit/4d0dbbc517f388a3fe984ec569bc1bad28d91494)) +* **chrome:** roll to Chrome 114.0.5735.45 (r1135570) ([#10302](https://github.com/puppeteer/puppeteer/issues/10302)) ([021402d](https://github.com/puppeteer/puppeteer/commit/021402d1363accabc05f75ea1004451a90e1dfca)) +* implement Locator.race ([#10337](https://github.com/puppeteer/puppeteer/issues/10337)) ([9c35e9a](https://github.com/puppeteer/puppeteer/commit/9c35e9ab1f92e99aab8dabcd17f687befd6aad81)) +* implement Locators ([#10305](https://github.com/puppeteer/puppeteer/issues/10305)) ([1f978f5](https://github.com/puppeteer/puppeteer/commit/1f978f5fc5f0580859ad423e952595979f50d5a9)) + + +### Bug Fixes + +* content() not showing comments outside html tag ([#10293](https://github.com/puppeteer/puppeteer/issues/10293)) ([9abd48a](https://github.com/puppeteer/puppeteer/commit/9abd48a062a4a30fb93d0b555f2fa03d3dc410f3)) +* ensure stack trace contains one line ([#10317](https://github.com/puppeteer/puppeteer/issues/10317)) ([bc0b04b](https://github.com/puppeteer/puppeteer/commit/bc0b04beef3244280e6569a233173d512adaa9d8)) +* roll to Chrome 114.0.5735.90 (r1135570) ([#10329](https://github.com/puppeteer/puppeteer/issues/10329)) ([60acefc](https://github.com/puppeteer/puppeteer/commit/60acefc1d6d719ed6c5053d6b9ad734306d08c4a)) +* send capabilities property in session.new command ([#10311](https://github.com/puppeteer/puppeteer/issues/10311)) ([e8d044c](https://github.com/puppeteer/puppeteer/commit/e8d044cb8dcb689cc066ffa18a1e3c9366f57902)) + +## [20.5.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.4.0...puppeteer-core-v20.5.0) (2023-05-31) + + +### Features + +* Page.removeScriptToEvaluateOnNewDocument ([#10250](https://github.com/puppeteer/puppeteer/issues/10250)) ([b5a124f](https://github.com/puppeteer/puppeteer/commit/b5a124ff738a03fa7eb5755b441af5b773447449)) + + +### Bug Fixes + +* bind trimCache to the instance ([#10270](https://github.com/puppeteer/puppeteer/issues/10270)) ([50e72a4](https://github.com/puppeteer/puppeteer/commit/50e72a4d1164af7d53e31b8b83117f695ede7ae4)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.4.0 to 1.4.1 + +## [20.4.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.3.0...puppeteer-core-v20.4.0) (2023-05-24) + + +### Features + +* Page.setBypassServiceWorker ([#10229](https://github.com/puppeteer/puppeteer/issues/10229)) ([81f73a5](https://github.com/puppeteer/puppeteer/commit/81f73a55f31892e55219ef9d37e235e988731fc1)) + + +### Bug Fixes + +* stacktraces should not throw errors ([#10231](https://github.com/puppeteer/puppeteer/issues/10231)) ([557ec24](https://github.com/puppeteer/puppeteer/commit/557ec24cfc084440197da67581bf9782f10eb346)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.3.0 to 1.4.0 + +## [20.3.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.2.1...puppeteer-core-v20.3.0) (2023-05-22) + + +### Features + +* add an ability to trim cache for Puppeteer ([#10199](https://github.com/puppeteer/puppeteer/issues/10199)) ([1ad32ec](https://github.com/puppeteer/puppeteer/commit/1ad32ec9948ca3e07e15548a562c8f3c633b3dc3)) + + +### Bug Fixes + +* ElementHandle dragAndDrop should fail when interception is disabled ([#10209](https://github.com/puppeteer/puppeteer/issues/10209)) ([bcf5fd8](https://github.com/puppeteer/puppeteer/commit/bcf5fd87aeeb822203c3388e8aa6dadaa0107690)) + +## [20.2.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.2.0...puppeteer-core-v20.2.1) (2023-05-15) + + +### Bug Fixes + +* use encode/decodeURIComponent ([#10183](https://github.com/puppeteer/puppeteer/issues/10183)) ([d0c68ff](https://github.com/puppeteer/puppeteer/commit/d0c68ff002df37907968d3b999a8273590ac7c97)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.2.0 to 1.3.0 + +## [20.2.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.1.2...puppeteer-core-v20.2.0) (2023-05-11) + + +### Features + +* implement detailed errors for evaluation ([#10114](https://github.com/puppeteer/puppeteer/issues/10114)) ([317fa73](https://github.com/puppeteer/puppeteer/commit/317fa732f920382f9b3f6dea4e31ed31b04e25da)) + + +### Bug Fixes + +* downloadPath should be used by the install script ([#10163](https://github.com/puppeteer/puppeteer/issues/10163)) ([4398f66](https://github.com/puppeteer/puppeteer/commit/4398f66f281f1ffe5be81b529fc4751edfaf761d)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.1.0 to 1.2.0 + +## [20.1.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.1.0...puppeteer-core-v20.1.1) (2023-05-05) + + +### Bug Fixes + +* rename PUPPETEER_DOWNLOAD_HOST to PUPPETEER_DOWNLOAD_BASE_URL ([#10130](https://github.com/puppeteer/puppeteer/issues/10130)) ([9758cae](https://github.com/puppeteer/puppeteer/commit/9758cae029f90908c4b5340561d9c51c26aa2f21)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 1.0.0 to 1.0.1 + +## [20.1.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v20.0.0...puppeteer-core-v20.1.0) (2023-05-03) + + +### Features + +* **chrome:** roll to Chrome 113.0.5672.63 (r1121455) ([#10116](https://github.com/puppeteer/puppeteer/issues/10116)) ([19f4334](https://github.com/puppeteer/puppeteer/commit/19f43348a884edfc3e73ab60e41a9757239df013)) + +## [20.0.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.11.1...puppeteer-core-v20.0.0) (2023-05-02) + + +### ⚠ BREAKING CHANGES + +* drop support for node14 ([#10019](https://github.com/puppeteer/puppeteer/issues/10019)) +* switch to Chrome for Testing instead of Chromium ([#10054](https://github.com/puppeteer/puppeteer/issues/10054)) + +### Features + +* add AbortSignal to waitForFunction ([#10078](https://github.com/puppeteer/puppeteer/issues/10078)) ([4dd4cb9](https://github.com/puppeteer/puppeteer/commit/4dd4cb929242a6b1a621fd461edd3167d40e1c4c)) +* drop support for node14 ([#10019](https://github.com/puppeteer/puppeteer/issues/10019)) ([7405d65](https://github.com/puppeteer/puppeteer/commit/7405d6585aa09b240fbab09aa360674d4442b3d9)) +* switch to Chrome for Testing instead of Chromium ([#10054](https://github.com/puppeteer/puppeteer/issues/10054)) ([df4d60c](https://github.com/puppeteer/puppeteer/commit/df4d60c187aa11c4ad783827242e9511f4ec2aab)) + + +### Bug Fixes + +* use AbortSignal.throwIfAborted ([#10105](https://github.com/puppeteer/puppeteer/issues/10105)) ([575f00a](https://github.com/puppeteer/puppeteer/commit/575f00a31d0278f7ff27096e770ff84399cd9993)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 0.5.0 to 1.0.0 + +## [19.11.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.11.0...puppeteer-core-v19.11.1) (2023-04-25) + + +### Bug Fixes + +* implement click `count` ([#10069](https://github.com/puppeteer/puppeteer/issues/10069)) ([8124a7d](https://github.com/puppeteer/puppeteer/commit/8124a7d5bfc1cfa8cb579271f78ce586efc62b8e)) +* implement flag for disabling headless warning ([#10073](https://github.com/puppeteer/puppeteer/issues/10073)) ([cfe9bbc](https://github.com/puppeteer/puppeteer/commit/cfe9bbc852d014b31c754950590b6b6c96573eeb)) + +## [19.11.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.10.1...puppeteer-core-v19.11.0) (2023-04-24) + + +### Features + +* add warn for `headless: true` ([#10039](https://github.com/puppeteer/puppeteer/issues/10039)) ([23d6a95](https://github.com/puppeteer/puppeteer/commit/23d6a95cf10c90f8aba2b12d7b02a73072e20382)) + + +### Bug Fixes + +* infer last pressed button in mouse move ([#10067](https://github.com/puppeteer/puppeteer/issues/10067)) ([a6eaac4](https://github.com/puppeteer/puppeteer/commit/a6eaac4c39d4b0ab3ab1a3c2f319a70fde393edb)) + +## [19.10.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.10.0...puppeteer-core-v19.10.1) (2023-04-21) + + +### Bug Fixes + +* move fs.js to the node folder ([#10055](https://github.com/puppeteer/puppeteer/issues/10055)) ([704624e](https://github.com/puppeteer/puppeteer/commit/704624eb2045a7e38ed14044d6863a2871e9d7e2)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 0.4.1 to 0.5.0 + +## [19.10.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.9.1...puppeteer-core-v19.10.0) (2023-04-20) + + +### Features + +* support AbortController in waitForSelector ([#10018](https://github.com/puppeteer/puppeteer/issues/10018)) ([9109b76](https://github.com/puppeteer/puppeteer/commit/9109b76276c9d86a2c521c72fc5b7189979279ca)) +* **webworker:** expose WebWorker.client ([#10042](https://github.com/puppeteer/puppeteer/issues/10042)) ([c125128](https://github.com/puppeteer/puppeteer/commit/c12512822a546e7bfdefd2c68f020aab2a308f4f)) + + +### Bug Fixes + +* continue requests without network instrumentation ([#10046](https://github.com/puppeteer/puppeteer/issues/10046)) ([8283823](https://github.com/puppeteer/puppeteer/commit/8283823cb860528a938e84cb5ba2b5f4cf980e83)) +* install bindings once ([#10049](https://github.com/puppeteer/puppeteer/issues/10049)) ([690aec1](https://github.com/puppeteer/puppeteer/commit/690aec1b5cb4e7e574abde9c533c6c0954e6f1aa)) + +## [19.9.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.9.0...puppeteer-core-v19.9.1) (2023-04-17) + + +### Bug Fixes + +* improve mouse actions ([#10021](https://github.com/puppeteer/puppeteer/issues/10021)) ([34db39e](https://github.com/puppeteer/puppeteer/commit/34db39e4474efee9d4579743026c3d6b6c8e494b)) + +## [19.9.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.8.5...puppeteer-core-v19.9.0) (2023-04-13) + + +### Features + +* add ElementHandle.isVisible and ElementHandle.isHidden ([#10007](https://github.com/puppeteer/puppeteer/issues/10007)) ([26c81b7](https://github.com/puppeteer/puppeteer/commit/26c81b7408a98cb9ef1aac9b57a038b699e6d518)) +* add ElementHandle.scrollIntoView ([#10005](https://github.com/puppeteer/puppeteer/issues/10005)) ([0d556a7](https://github.com/puppeteer/puppeteer/commit/0d556a71d6bcd5da501724ccbb4ce0be433768df)) + + +### Bug Fixes + +* make isIntersectingViewport work with SVG elements ([#10004](https://github.com/puppeteer/puppeteer/issues/10004)) ([656b562](https://github.com/puppeteer/puppeteer/commit/656b562c7488d4976a7a53264feef508c6b629dd)) + + +### Performance Improvements + +* amortize handle iterator ([#10002](https://github.com/puppeteer/puppeteer/issues/10002)) ([ab27f73](https://github.com/puppeteer/puppeteer/commit/ab27f738c9abb56f6083d02f7f45d2b8da9fc3f3)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 0.4.0 to 0.4.1 + +## [19.8.5](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.8.4...puppeteer-core-v19.8.5) (2023-04-06) + + +### Bug Fixes + +* add filter to setDiscoverTargets for Firefox ([#9693](https://github.com/puppeteer/puppeteer/issues/9693)) ([c09764e](https://github.com/puppeteer/puppeteer/commit/c09764e4c43d7a62096f430b598d63f2b688e860)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 0.3.3 to 0.4.0 + +## [19.8.4](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.8.3...puppeteer-core-v19.8.4) (2023-04-06) + + +### Bug Fixes + +* ignore extraInfo events if the response is served from cache ([#9983](https://github.com/puppeteer/puppeteer/issues/9983)) ([e7265c9](https://github.com/puppeteer/puppeteer/commit/e7265c9aa94e749de5745e5e98d45d4659f19d30)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 0.3.2 to 0.3.3 + +## [19.8.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.8.1...puppeteer-core-v19.8.3) (2023-04-03) + + +### Bug Fixes + +* use shadowRoot for tree walker ([#9950](https://github.com/puppeteer/puppeteer/issues/9950)) ([728547d](https://github.com/puppeteer/puppeteer/commit/728547d4608e8c601e209ede860493b1986da174)) + +## [19.8.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.8.0...puppeteer-core-v19.8.1) (2023-03-28) + + +### Bug Fixes + +* increase the default protocol timeout ([#9928](https://github.com/puppeteer/puppeteer/issues/9928)) ([4465f4b](https://github.com/puppeteer/puppeteer/commit/4465f4bd1900afc0b049ac863f4e372453a0c234)) + +## [19.8.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.7.5...puppeteer-core-v19.8.0) (2023-03-24) + + +### Features + +* add Page.waitForDevicePrompt ([#9299](https://github.com/puppeteer/puppeteer/issues/9299)) ([a5149d5](https://github.com/puppeteer/puppeteer/commit/a5149d52f54036a27a411bc070902b1eb3a7a629)) +* **chromium:** roll to Chromium 112.0.5614.0 (r1108766) ([#9841](https://github.com/puppeteer/puppeteer/issues/9841)) ([eddb1f6](https://github.com/puppeteer/puppeteer/commit/eddb1f6ec3958b79fea297123f7621eb7beaff04)) + + +### Bug Fixes + +* fallback to CSS ([#9876](https://github.com/puppeteer/puppeteer/issues/9876)) ([e6ec9c2](https://github.com/puppeteer/puppeteer/commit/e6ec9c295847fa0f1ec240952f0f2523bb13b7c8)) +* implement protocol-level timeouts ([#9877](https://github.com/puppeteer/puppeteer/issues/9877)) ([510b36c](https://github.com/puppeteer/puppeteer/commit/510b36c50001c95783b00dc8af42b5801ec57358)) +* viewport.deviceScaleFactor can be set to system default ([#9911](https://github.com/puppeteer/puppeteer/issues/9911)) ([022c909](https://github.com/puppeteer/puppeteer/commit/022c90932658d13ff4ae4aa51d26716f5dbe54ac)) +* waitForNavigation issue with aborted events ([#9883](https://github.com/puppeteer/puppeteer/issues/9883)) ([36c029b](https://github.com/puppeteer/puppeteer/commit/36c029b38d64a10590bfc74ecea255a58914b0d2)) + +## [19.7.5](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.7.4...puppeteer-core-v19.7.5) (2023-03-14) + + +### Bug Fixes + +* sort elements based on selector matching algorithm ([#9836](https://github.com/puppeteer/puppeteer/issues/9836)) ([9044609](https://github.com/puppeteer/puppeteer/commit/9044609be3ea78c650420533e7f6f40b83cedd99)) + + +### Performance Improvements + +* use `querySelector*` for pure CSS selectors ([#9835](https://github.com/puppeteer/puppeteer/issues/9835)) ([8aea8e0](https://github.com/puppeteer/puppeteer/commit/8aea8e047103b72c0238dde8e4777acf7897ddaa)) + +## [19.7.4](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.7.3...puppeteer-core-v19.7.4) (2023-03-10) + + +### Bug Fixes + +* call _detach on disconnect ([#9807](https://github.com/puppeteer/puppeteer/issues/9807)) ([bc1a04d](https://github.com/puppeteer/puppeteer/commit/bc1a04def8f699ad245c12ec69ac176e3e7e888d)) +* restore rimraf for puppeteer-core code ([#9815](https://github.com/puppeteer/puppeteer/issues/9815)) ([cefc4ea](https://github.com/puppeteer/puppeteer/commit/cefc4eab4750d2c1209eb36ca44f6963a4a6bf4c)) +* update troubleshooting guide links in errors ([#9821](https://github.com/puppeteer/puppeteer/issues/9821)) ([0165f06](https://github.com/puppeteer/puppeteer/commit/0165f06deef9e45862fd127a205ade5ad30ddaa3)) + +## [19.7.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.7.2...puppeteer-core-v19.7.3) (2023-03-06) + + +### Bug Fixes + +* update dependencies ([#9781](https://github.com/puppeteer/puppeteer/issues/9781)) ([364b23f](https://github.com/puppeteer/puppeteer/commit/364b23f8b5c7b04974f233c58e5ded9a8f912ff2)) + +## [19.7.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.7.1...puppeteer-core-v19.7.2) (2023-02-20) + + +### Bug Fixes + +* bump chromium-bidi to a version that does not declare mitt as a peer dependency ([#9701](https://github.com/puppeteer/puppeteer/issues/9701)) ([82916c1](https://github.com/puppeteer/puppeteer/commit/82916c102b2c399093ba9019e272207b5ce81849)) + +## [19.7.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.7.0...puppeteer-core-v19.7.1) (2023-02-15) + + +### Bug Fixes + +* fix circularity on JSHandle interface ([#9661](https://github.com/puppeteer/puppeteer/issues/9661)) ([eb13863](https://github.com/puppeteer/puppeteer/commit/eb138635d661d3cdaf2940959fece5aca482178a)) +* make chromium-bidi an opt peer dep ([#9667](https://github.com/puppeteer/puppeteer/issues/9667)) ([c6054ac](https://github.com/puppeteer/puppeteer/commit/c6054ac1a56c08ee7bf01321878699b7b4ab4e0b)) + +## [19.7.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.6.3...puppeteer-core-v19.7.0) (2023-02-13) + + +### Features + +* add touchstart, touchmove and touchend methods ([#9622](https://github.com/puppeteer/puppeteer/issues/9622)) ([c8bb11a](https://github.com/puppeteer/puppeteer/commit/c8bb11adfcf1537032730a91baa3c36a6e324926)) +* **chromium:** roll to Chromium 111.0.5556.0 (r1095492) ([#9656](https://github.com/puppeteer/puppeteer/issues/9656)) ([df59d01](https://github.com/puppeteer/puppeteer/commit/df59d010c20644da06eb4c4e28a11c4eea164aba)) + + +### Bug Fixes + +* `page.goto` error throwing on 40x/50x responses with an empty body ([#9523](https://github.com/puppeteer/puppeteer/issues/9523)) ([#9577](https://github.com/puppeteer/puppeteer/issues/9577)) ([ddb0cc1](https://github.com/puppeteer/puppeteer/commit/ddb0cc174d2a14c0948dcdaf9bae78620937c667)) + +## [19.6.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.6.2...puppeteer-core-v19.6.3) (2023-02-01) + + +### Bug Fixes + +* ignore not found contexts for console messages ([#9595](https://github.com/puppeteer/puppeteer/issues/9595)) ([390685b](https://github.com/puppeteer/puppeteer/commit/390685bbe52c22b686fc0e3119b4ac7b1073c581)) +* restore WaitTask terminate condition ([#9612](https://github.com/puppeteer/puppeteer/issues/9612)) ([e16cbc6](https://github.com/puppeteer/puppeteer/commit/e16cbc6626cffd40d0caa30801620e7293455006)) + +## [19.6.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.6.1...puppeteer-core-v19.6.2) (2023-01-27) + + +### Bug Fixes + +* atomically get Puppeteer utilities ([#9597](https://github.com/puppeteer/puppeteer/issues/9597)) ([050a7b0](https://github.com/puppeteer/puppeteer/commit/050a7b062415ebaf10bcb71c405143eacc4e5d4b)) + +## [19.6.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.6.0...puppeteer-core-v19.6.1) (2023-01-26) + + +### Bug Fixes + +* don't clean up previous browser versions ([#9568](https://github.com/puppeteer/puppeteer/issues/9568)) ([344bc2a](https://github.com/puppeteer/puppeteer/commit/344bc2af62e4068fe2cb8162d4b6c8242aac843b)), closes [#9533](https://github.com/puppeteer/puppeteer/issues/9533) +* mimic rejection for PuppeteerUtil on early call ([#9589](https://github.com/puppeteer/puppeteer/issues/9589)) ([1980de9](https://github.com/puppeteer/puppeteer/commit/1980de91a161523c7098a79919b20e6d8d2e5d81)) +* **revert:** use LazyArg for puppeteer utilities ([#9590](https://github.com/puppeteer/puppeteer/issues/9590)) ([6edd996](https://github.com/puppeteer/puppeteer/commit/6edd99676827de2c83f7a858e4f903b1c34e7d35)) +* use LazyArg for puppeteer utilities ([#9575](https://github.com/puppeteer/puppeteer/issues/9575)) ([496658f](https://github.com/puppeteer/puppeteer/commit/496658f02945b53096483f36cb3d64556cff045e)) + +## [19.6.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.5.2...puppeteer-core-v19.6.0) (2023-01-23) + + +### Features + +* **chromium:** roll to Chromium 110.0.5479.0 (r1083080) ([#9500](https://github.com/puppeteer/puppeteer/issues/9500)) ([06e816b](https://github.com/puppeteer/puppeteer/commit/06e816bbfa7b9ca84284929f654de7288c51169d)), closes [#9470](https://github.com/puppeteer/puppeteer/issues/9470) +* **page:** Adding support for referrerPolicy in `page.goto` ([#9561](https://github.com/puppeteer/puppeteer/issues/9561)) ([e3d69ec](https://github.com/puppeteer/puppeteer/commit/e3d69ec554beeac37bd206a21921d2fed3cb968c)) + + +### Bug Fixes + +* firefox revision resolution should not update chrome revision ([#9507](https://github.com/puppeteer/puppeteer/issues/9507)) ([f59bbf4](https://github.com/puppeteer/puppeteer/commit/f59bbf4014644dec6f395713e8403939aebe06ea)), closes [#9461](https://github.com/puppeteer/puppeteer/issues/9461) +* improve screenshot method types ([#9529](https://github.com/puppeteer/puppeteer/issues/9529)) ([6847f88](https://github.com/puppeteer/puppeteer/commit/6847f8835f28e97edba6fce76a4cbf85561482b9)) + +## [19.5.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.5.1...puppeteer-core-v19.5.2) (2023-01-11) + + +### Bug Fixes + +* make sure browser fetcher in launchers uses configuration ([#9493](https://github.com/puppeteer/puppeteer/issues/9493)) ([df55439](https://github.com/puppeteer/puppeteer/commit/df554397b51e97aea2765b325f9a887b50b9263a)), closes [#9470](https://github.com/puppeteer/puppeteer/issues/9470) + +## [19.5.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.5.0...puppeteer-core-v19.5.1) (2023-01-11) + + +### Bug Fixes + +* use puppeteer node for installation script ([#9489](https://github.com/puppeteer/puppeteer/issues/9489)) ([9bf90d9](https://github.com/puppeteer/puppeteer/commit/9bf90d9f4b5aeab06f8b433714712cad3259d36e)) + +## [19.5.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.4.1...puppeteer-core-v19.5.0) (2023-01-05) + + +### Features + +* add element validation ([#9352](https://github.com/puppeteer/puppeteer/issues/9352)) ([c7a063a](https://github.com/puppeteer/puppeteer/commit/c7a063a15274856184356e15f2ae4be41191d309)) + + +### Bug Fixes + +* **puppeteer-core:** target interceptor is not async ([#9430](https://github.com/puppeteer/puppeteer/issues/9430)) ([e3e9cc6](https://github.com/puppeteer/puppeteer/commit/e3e9cc622ac32f2067b6e74b5e8706c63169a157)) + +## [19.4.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.4.0...puppeteer-core-v19.4.1) (2022-12-16) + + +### Bug Fixes + +* improve a11y snapshot handling if the tree is not correct ([#9405](https://github.com/puppeteer/puppeteer/issues/9405)) ([02fe501](https://github.com/puppeteer/puppeteer/commit/02fe50194e60bd14c3a82539473a0313ab88c766)), closes [#9404](https://github.com/puppeteer/puppeteer/issues/9404) +* remove oopif expectations and fix oopif flakiness ([#9375](https://github.com/puppeteer/puppeteer/issues/9375)) ([810e0cd](https://github.com/puppeteer/puppeteer/commit/810e0cd74ecef353cfa43746c18bd5f580a3233d)) + +## [19.4.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.3.0...puppeteer-core-v19.4.0) (2022-12-07) + + +### Features + +* ability to send headers via ws connection to browser in node.js environment ([#9314](https://github.com/puppeteer/puppeteer/issues/9314)) ([937fffa](https://github.com/puppeteer/puppeteer/commit/937fffaedc340ea12d5f6636d3ba6598cb22e397)), closes [#7218](https://github.com/puppeteer/puppeteer/issues/7218) +* **chromium:** roll to Chromium 109.0.5412.0 (r1069273) ([#9364](https://github.com/puppeteer/puppeteer/issues/9364)) ([1875da6](https://github.com/puppeteer/puppeteer/commit/1875da61916df1fbcf98047858c01075bd9af189)), closes [#9233](https://github.com/puppeteer/puppeteer/issues/9233) +* **puppeteer-core:** keydown supports commands ([#9357](https://github.com/puppeteer/puppeteer/issues/9357)) ([b7ebc5d](https://github.com/puppeteer/puppeteer/commit/b7ebc5d9bb9b9940ffdf470e51d007f709587d40)) + + +### Bug Fixes + +* **puppeteer-core:** avoid type instantiation errors ([#9370](https://github.com/puppeteer/puppeteer/issues/9370)) ([17f31a9](https://github.com/puppeteer/puppeteer/commit/17f31a9ee408ca5a08fe6dbceb8915e710156bd3)), closes [#9369](https://github.com/puppeteer/puppeteer/issues/9369) + +## [19.3.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.2.2...puppeteer-core-v19.3.0) (2022-11-23) + + +### Features + +* **puppeteer-core:** Infer element type from complex selector ([#9253](https://github.com/puppeteer/puppeteer/issues/9253)) ([bef1061](https://github.com/puppeteer/puppeteer/commit/bef1061c064e5135d86a48fffd7278f3e7f4a29e)) +* **puppeteer-core:** update Chrome launcher flags ([#9239](https://github.com/puppeteer/puppeteer/issues/9239)) ([ae87bfc](https://github.com/puppeteer/puppeteer/commit/ae87bfc2b4361556e3660a1de2c6db348ce663ae)) + + +### Bug Fixes + +* remove boundary conditions for visibility ([#9249](https://github.com/puppeteer/puppeteer/issues/9249)) ([e003513](https://github.com/puppeteer/puppeteer/commit/e003513c0c049aad38e374a16dc96c3e54ab0de5)) + +## [19.2.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.2.1...puppeteer-core-v19.2.2) (2022-11-03) + + +### Bug Fixes + +* update missing product message ([#9207](https://github.com/puppeteer/puppeteer/issues/9207)) ([29f47e2](https://github.com/puppeteer/puppeteer/commit/29f47e2e150ff7bfd89e38a4ce4ca34eac7f2fdf)) + +## [19.2.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.2.0...puppeteer-core-v19.2.1) (2022-10-28) + + +### Bug Fixes + +* resolve navigation requests when request fails ([#9178](https://github.com/puppeteer/puppeteer/issues/9178)) ([c11297b](https://github.com/puppeteer/puppeteer/commit/c11297baa5124eb89f7686c3eb446d2ba1b7123a)), closes [#9175](https://github.com/puppeteer/puppeteer/issues/9175) + +## [19.2.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.1.1...puppeteer-core-v19.2.0) (2022-10-26) + + +### Features + +* **chromium:** roll to Chromium 108.0.5351.0 (r1056772) ([#9153](https://github.com/puppeteer/puppeteer/issues/9153)) ([e78a4e8](https://github.com/puppeteer/puppeteer/commit/e78a4e89c22bb1180e72d180c16b39673ff9125e)) + +## [19.1.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.1.0...puppeteer-core-v19.1.1) (2022-10-24) + + +### Bug Fixes + +* update documentation on configuring puppeteer ([#9150](https://github.com/puppeteer/puppeteer/issues/9150)) ([f07ad2c](https://github.com/puppeteer/puppeteer/commit/f07ad2c6616ecd2a959b0c1a65b167ba77611d61)) + +## [19.1.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v19.0.0...puppeteer-core-v19.1.0) (2022-10-21) + + +### Features + +* expose browser context id ([#9134](https://github.com/puppeteer/puppeteer/issues/9134)) ([122778a](https://github.com/puppeteer/puppeteer/commit/122778a1f8b60e0dcc6f0ffcb2097e95ae98f4a3)), closes [#9132](https://github.com/puppeteer/puppeteer/issues/9132) +* use configuration files ([#9140](https://github.com/puppeteer/puppeteer/issues/9140)) ([ec20174](https://github.com/puppeteer/puppeteer/commit/ec201744f077987b288e3dff52c0906fe700f6fb)), closes [#9128](https://github.com/puppeteer/puppeteer/issues/9128) + + +### Bug Fixes + +* update `BrowserFetcher` deprecation message ([#9141](https://github.com/puppeteer/puppeteer/issues/9141)) ([efcbc97](https://github.com/puppeteer/puppeteer/commit/efcbc97c60e4cfd49a9ed25a900f6133d06b290b)) + +## [19.0.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v18.2.1...puppeteer-core-v19.0.0) (2022-10-14) + + +### ⚠ BREAKING CHANGES + +* use `~/.cache/puppeteer` for browser downloads (#9095) +* deprecate `createBrowserFetcher` in favor of `BrowserFetcher` (#9079) +* refactor custom query handler API (#9078) +* remove `puppeteer.devices` in favor of `KnownDevices` (#9075) +* deprecate indirect network condition imports (#9074) +* deprecate indirect error imports (#9072) + +### Features + +* add ability to collect JS code coverage at the function level ([#9027](https://github.com/puppeteer/puppeteer/issues/9027)) ([a032583](https://github.com/puppeteer/puppeteer/commit/a032583b6c9b469bda699bca200b180206d61247)) +* deprecate `createBrowserFetcher` in favor of `BrowserFetcher` ([#9079](https://github.com/puppeteer/puppeteer/issues/9079)) ([7294dfe](https://github.com/puppeteer/puppeteer/commit/7294dfe9c6c3b224f95ba6d59b5ef33d379fd09a)), closes [#8999](https://github.com/puppeteer/puppeteer/issues/8999) +* use `~/.cache/puppeteer` for browser downloads ([#9095](https://github.com/puppeteer/puppeteer/issues/9095)) ([3df375b](https://github.com/puppeteer/puppeteer/commit/3df375baedad64b8773bb1e1e6f81b604ed18989)) + + +### Bug Fixes + +* deprecate indirect error imports ([#9072](https://github.com/puppeteer/puppeteer/issues/9072)) ([9f4f43a](https://github.com/puppeteer/puppeteer/commit/9f4f43a28b06787a1cf97efe904ccfe7237dffdd)) +* deprecate indirect network condition imports ([#9074](https://github.com/puppeteer/puppeteer/issues/9074)) ([41d0122](https://github.com/puppeteer/puppeteer/commit/41d0122b94f41b308536c48ced345dec8c272a49)) +* refactor custom query handler API ([#9078](https://github.com/puppeteer/puppeteer/issues/9078)) ([1847704](https://github.com/puppeteer/puppeteer/commit/1847704789e2888c755de8c739d567364b8ad645)) +* remove `puppeteer.devices` in favor of `KnownDevices` ([#9075](https://github.com/puppeteer/puppeteer/issues/9075)) ([87c08fd](https://github.com/puppeteer/puppeteer/commit/87c08fd86a79b63308ad8d46c5f7acd1927505f8)) +* remove viewport conditions in `waitForSelector` ([#9087](https://github.com/puppeteer/puppeteer/issues/9087)) ([acbc599](https://github.com/puppeteer/puppeteer/commit/acbc59999bf800eeac75c4045b75a32b4357c79e)) + +## [18.2.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v18.2.0...puppeteer-core-v18.2.1) (2022-10-06) + + +### Bug Fixes + +* add README to package during prepack ([#9057](https://github.com/puppeteer/puppeteer/issues/9057)) ([9374e23](https://github.com/puppeteer/puppeteer/commit/9374e23d3da5e40378461ed08db24649730a445a)) +* waitForRequest works with async predicate ([#9058](https://github.com/puppeteer/puppeteer/issues/9058)) ([8f6b2c9](https://github.com/puppeteer/puppeteer/commit/8f6b2c9b7c219d405c954bf7af082d3d29fd48ff)) + +## [18.2.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v18.1.0...puppeteer-core-v18.2.0) (2022-10-05) + + +### Features + +* separate puppeteer and puppeteer-core ([#9023](https://github.com/puppeteer/puppeteer/issues/9023)) ([f42336c](https://github.com/puppeteer/puppeteer/commit/f42336cf83982332829ca7e14ee48d8676e11545)) + + +## [18.1.0](https://github.com/puppeteer/puppeteer/compare/v18.0.5...v18.1.0) (2022-10-05) + +### Features + +* **chromium:** roll to Chromium 107.0.5296.0 (r1045629) ([#9039](https://github.com/puppeteer/puppeteer/issues/9039)) ([022fbde](https://github.com/puppeteer/puppeteer/commit/022fbde85e067e8c419cf42dd571f9a1187c343c)) + +## [18.0.5](https://github.com/puppeteer/puppeteer/compare/v18.0.4...v18.0.5) (2022-09-22) + + +### Bug Fixes + +* add missing npm config environment variable ([#8996](https://github.com/puppeteer/puppeteer/issues/8996)) ([7c1be20](https://github.com/puppeteer/puppeteer/commit/7c1be20aef46aaf5029732a580ec65aa8008aa9c)) + +## [18.0.4](https://github.com/puppeteer/puppeteer/compare/v18.0.3...v18.0.4) (2022-09-21) + + +### Bug Fixes + +* hardcode binding names ([#8993](https://github.com/puppeteer/puppeteer/issues/8993)) ([7e20554](https://github.com/puppeteer/puppeteer/commit/7e2055433e79ef20f6dcdf02f92e1d64564b7d33)) + +## [18.0.3](https://github.com/puppeteer/puppeteer/compare/v18.0.2...v18.0.3) (2022-09-20) + + +### Bug Fixes + +* change injected.ts imports ([#8987](https://github.com/puppeteer/puppeteer/issues/8987)) ([10a114d](https://github.com/puppeteer/puppeteer/commit/10a114d36f2add90860950f61b3f8b93258edb5c)) + +## [18.0.2](https://github.com/puppeteer/puppeteer/compare/v18.0.1...v18.0.2) (2022-09-19) + + +### Bug Fixes + +* mark internal objects ([#8984](https://github.com/puppeteer/puppeteer/issues/8984)) ([181a148](https://github.com/puppeteer/puppeteer/commit/181a148269fce1575f5e37056929ecdec0517586)) + +## [18.0.1](https://github.com/puppeteer/puppeteer/compare/v18.0.0...v18.0.1) (2022-09-19) + + +### Bug Fixes + +* internal lazy params ([#8982](https://github.com/puppeteer/puppeteer/issues/8982)) ([d504597](https://github.com/puppeteer/puppeteer/commit/d5045976a6dd321bbd265b84c2474ff1ad5d0b77)) + +## [18.0.0](https://github.com/puppeteer/puppeteer/compare/v17.1.3...v18.0.0) (2022-09-19) + + +### ⚠ BREAKING CHANGES + +* fix bounding box visibility conditions (#8954) + +### Features + +* add text query handler ([#8956](https://github.com/puppeteer/puppeteer/issues/8956)) ([633e7cf](https://github.com/puppeteer/puppeteer/commit/633e7cfdf99d42f420d0af381394bd1f6ac7bcd1)) + + +### Bug Fixes + +* fix bounding box visibility conditions ([#8954](https://github.com/puppeteer/puppeteer/issues/8954)) ([ac9929d](https://github.com/puppeteer/puppeteer/commit/ac9929d80f6f7d4905a39183ae235500e29b4f53)) +* suppress init errors if the target is closed ([#8947](https://github.com/puppeteer/puppeteer/issues/8947)) ([cfaaa5e](https://github.com/puppeteer/puppeteer/commit/cfaaa5e2c07e5f98baeb7de99e303aa840a351e8)) +* use win64 version of chromium when on arm64 windows ([#8927](https://github.com/puppeteer/puppeteer/issues/8927)) ([64843b8](https://github.com/puppeteer/puppeteer/commit/64843b88853210314677ab1b434729513ce615a7)) + +## [17.1.3](https://github.com/puppeteer/puppeteer/compare/v17.1.2...v17.1.3) (2022-09-08) + + +### Bug Fixes + +* FirefoxLauncher should not use BrowserFetcher in puppeteer-core ([#8920](https://github.com/puppeteer/puppeteer/issues/8920)) ([f2e8de7](https://github.com/puppeteer/puppeteer/commit/f2e8de777fc5d547778fdc6cac658add84ed4082)), closes [#8919](https://github.com/puppeteer/puppeteer/issues/8919) +* linux arm64 check on windows arm ([#8917](https://github.com/puppeteer/puppeteer/issues/8917)) ([f02b926](https://github.com/puppeteer/puppeteer/commit/f02b926245e28b5671087c051dbdbb3165696f08)), closes [#8915](https://github.com/puppeteer/puppeteer/issues/8915) + +## [17.1.2](https://github.com/puppeteer/puppeteer/compare/v17.1.1...v17.1.2) (2022-09-07) + + +### Bug Fixes + +* add missing code coverage ranges that span only a single character ([#8911](https://github.com/puppeteer/puppeteer/issues/8911)) ([0c577b9](https://github.com/puppeteer/puppeteer/commit/0c577b9bf8855dc0ccb6098cd43a25c528f6d7f5)) +* add Page.getDefaultTimeout getter ([#8903](https://github.com/puppeteer/puppeteer/issues/8903)) ([3240095](https://github.com/puppeteer/puppeteer/commit/32400954c50cbddc48468ad118c3f8a47653b9d3)), closes [#8901](https://github.com/puppeteer/puppeteer/issues/8901) +* don't detect project root for puppeteer-core ([#8907](https://github.com/puppeteer/puppeteer/issues/8907)) ([b4f5ea1](https://github.com/puppeteer/puppeteer/commit/b4f5ea1167a60c870194c70d22f5372ada5b7c4c)), closes [#8896](https://github.com/puppeteer/puppeteer/issues/8896) +* support scale for screenshot clips ([#8908](https://github.com/puppeteer/puppeteer/issues/8908)) ([260e428](https://github.com/puppeteer/puppeteer/commit/260e4282275ab1d05c86e5643e2a02c01f269a9c)), closes [#5329](https://github.com/puppeteer/puppeteer/issues/5329) +* work around a race in waitForFileChooser ([#8905](https://github.com/puppeteer/puppeteer/issues/8905)) ([053d960](https://github.com/puppeteer/puppeteer/commit/053d960fb593e514e7914d7da9af436afc39a12f)), closes [#6040](https://github.com/puppeteer/puppeteer/issues/6040) + +## [17.1.1](https://github.com/puppeteer/puppeteer/compare/v17.1.0...v17.1.1) (2022-09-05) + + +### Bug Fixes + +* restore deferred promise debugging ([#8895](https://github.com/puppeteer/puppeteer/issues/8895)) ([7b42250](https://github.com/puppeteer/puppeteer/commit/7b42250c7bb91ac873307acda493726ffc4c54a8)) + +## [17.1.0](https://github.com/puppeteer/puppeteer/compare/v17.0.0...v17.1.0) (2022-09-02) + + +### Features + +* **chromium:** roll to Chromium 106.0.5249.0 (r1036745) ([#8869](https://github.com/puppeteer/puppeteer/issues/8869)) ([6e9a47a](https://github.com/puppeteer/puppeteer/commit/6e9a47a6faa06d241dec0bcf7bcdf49370517008)) + + +### Bug Fixes + +* allow getting a frame from an elementhandle ([#8875](https://github.com/puppeteer/puppeteer/issues/8875)) ([3732757](https://github.com/puppeteer/puppeteer/commit/3732757450b4363041ccbacc3b236289a156abb0)) +* typos in documentation ([#8858](https://github.com/puppeteer/puppeteer/issues/8858)) ([8d95a9b](https://github.com/puppeteer/puppeteer/commit/8d95a9bc920b98820aa655ad4eb2d8fd9b2b893a)) +* use the timeout setting in waitForFileChooser ([#8856](https://github.com/puppeteer/puppeteer/issues/8856)) ([f477b46](https://github.com/puppeteer/puppeteer/commit/f477b46f212da9206102da695697760eea539f05)) + +## [17.0.0](https://github.com/puppeteer/puppeteer/compare/v16.2.0...v17.0.0) (2022-08-26) + + +### ⚠ BREAKING CHANGES + +* remove `root` from `WaitForSelectorOptions` (#8848) +* internalize execution context (#8844) + +### Bug Fixes + +* allow multiple navigations to happen in LifecycleWatcher ([#8826](https://github.com/puppeteer/puppeteer/issues/8826)) ([341b669](https://github.com/puppeteer/puppeteer/commit/341b669a5e45ecbb9ffb0f28c45b520660f27ad2)), closes [#8811](https://github.com/puppeteer/puppeteer/issues/8811) +* internalize execution context ([#8844](https://github.com/puppeteer/puppeteer/issues/8844)) ([2f33237](https://github.com/puppeteer/puppeteer/commit/2f33237d0443de77d58dca4454b0c9a1d2b57d03)) +* remove `root` from `WaitForSelectorOptions` ([#8848](https://github.com/puppeteer/puppeteer/issues/8848)) ([1155c8e](https://github.com/puppeteer/puppeteer/commit/1155c8eac85b176c3334cc3d98adfe7d943dfbe6)) +* remove deferred promise timeouts ([#8835](https://github.com/puppeteer/puppeteer/issues/8835)) ([202ffce](https://github.com/puppeteer/puppeteer/commit/202ffce0aa4f34dba35fbb8e7d740af16efee35f)), closes [#8832](https://github.com/puppeteer/puppeteer/issues/8832) + +## [16.2.0](https://github.com/puppeteer/puppeteer/compare/v16.1.1...v16.2.0) (2022-08-18) + + +### Features + +* add Khmer (Cambodian) language support ([#8809](https://github.com/puppeteer/puppeteer/issues/8809)) ([34f8737](https://github.com/puppeteer/puppeteer/commit/34f873721804d57a5faf3eab8ef50340c69ed180)) + + +### Bug Fixes + +* handle service workers in extensions ([#8807](https://github.com/puppeteer/puppeteer/issues/8807)) ([2a0eefb](https://github.com/puppeteer/puppeteer/commit/2a0eefb99f0ae00dacc9e768a253308c0d18a4c3)), closes [#8800](https://github.com/puppeteer/puppeteer/issues/8800) + +## [16.1.1](https://github.com/puppeteer/puppeteer/compare/v16.1.0...v16.1.1) (2022-08-16) + + +### Bug Fixes + +* custom sessions should not emit targetcreated events ([#8788](https://github.com/puppeteer/puppeteer/issues/8788)) ([3fad05d](https://github.com/puppeteer/puppeteer/commit/3fad05d333b79f41a7b58582c4ca493200bb5a79)), closes [#8787](https://github.com/puppeteer/puppeteer/issues/8787) +* deprecate `ExecutionContext` ([#8792](https://github.com/puppeteer/puppeteer/issues/8792)) ([b5da718](https://github.com/puppeteer/puppeteer/commit/b5da718e2e4a2004a36cf23cad555e1fc3b50333)) +* deprecate `root` in `WaitForSelectorOptions` ([#8795](https://github.com/puppeteer/puppeteer/issues/8795)) ([65a5ce8](https://github.com/puppeteer/puppeteer/commit/65a5ce8464c56fcc55e5ac3ed490f31311bbe32a)) +* deprecate `waitForTimeout` ([#8793](https://github.com/puppeteer/puppeteer/issues/8793)) ([8f612d5](https://github.com/puppeteer/puppeteer/commit/8f612d5ff855d48ae4b38bdaacf2a8fbda8e9ce8)) +* make sure there is a check for targets when timeout=0 ([#8765](https://github.com/puppeteer/puppeteer/issues/8765)) ([c23cdb7](https://github.com/puppeteer/puppeteer/commit/c23cdb73a7b113c1dd29f7e4a7a61326422c4080)), closes [#8763](https://github.com/puppeteer/puppeteer/issues/8763) +* resolve navigation flakiness ([#8768](https://github.com/puppeteer/puppeteer/issues/8768)) ([2580347](https://github.com/puppeteer/puppeteer/commit/2580347b50091d172b2a5591138a2e41ede072fe)), closes [#8644](https://github.com/puppeteer/puppeteer/issues/8644) +* specify Puppeteer version for Chromium 105.0.5173.0 ([#8766](https://github.com/puppeteer/puppeteer/issues/8766)) ([b5064b7](https://github.com/puppeteer/puppeteer/commit/b5064b7b8bd3bd9eb481b6807c65d9d06d23b9dd)) +* use targetFilter in puppeteer.launch ([#8774](https://github.com/puppeteer/puppeteer/issues/8774)) ([ee2540b](https://github.com/puppeteer/puppeteer/commit/ee2540baefeced44f6b336f2b979af5c3a4cb040)), closes [#8772](https://github.com/puppeteer/puppeteer/issues/8772) + +## [16.1.0](https://github.com/puppeteer/puppeteer/compare/v16.0.0...v16.1.0) (2022-08-06) + + +### Features + +* use an `xpath` query handler ([#8730](https://github.com/puppeteer/puppeteer/issues/8730)) ([5cf9b4d](https://github.com/puppeteer/puppeteer/commit/5cf9b4de8d50bd056db82bcaa23279b72c9313c5)) + + +### Bug Fixes + +* resolve target manager init if no existing targets detected ([#8748](https://github.com/puppeteer/puppeteer/issues/8748)) ([8cb5043](https://github.com/puppeteer/puppeteer/commit/8cb5043868f69cdff7f34f1cfe0c003ff09e281b)), closes [#8747](https://github.com/puppeteer/puppeteer/issues/8747) +* specify the target filter in setDiscoverTargets ([#8742](https://github.com/puppeteer/puppeteer/issues/8742)) ([49193cb](https://github.com/puppeteer/puppeteer/commit/49193cbf1c17f16f0ca59a9fd2ebf306f812f52b)) + +## [16.0.0](https://github.com/puppeteer/puppeteer/compare/v15.5.0...v16.0.0) (2022-08-02) + + +### ⚠ BREAKING CHANGES + +* With Chromium, Puppeteer will now attach to page/iframe targets immediately to allow reliable configuration of targets. + +### Features + +* add Dockerfile ([#8315](https://github.com/puppeteer/puppeteer/issues/8315)) ([936ed86](https://github.com/puppeteer/puppeteer/commit/936ed8607ec0c3798d2b22b590d0be0ad361a888)) +* detect Firefox in connect() automatically ([#8718](https://github.com/puppeteer/puppeteer/issues/8718)) ([2abd772](https://github.com/puppeteer/puppeteer/commit/2abd772c9c3d2b86deb71541eaac41aceef94356)) +* use CDP's auto-attach mechanism ([#8520](https://github.com/puppeteer/puppeteer/issues/8520)) ([2cbfdeb](https://github.com/puppeteer/puppeteer/commit/2cbfdeb0ca388a45cedfae865266230e1291bd29)) + + +### Bug Fixes + +* address flakiness in frame handling ([#8688](https://github.com/puppeteer/puppeteer/issues/8688)) ([6f81b23](https://github.com/puppeteer/puppeteer/commit/6f81b23728a511f7b89eaa2b8f850b22d6c4ab24)) +* disable AcceptCHFrame ([#8706](https://github.com/puppeteer/puppeteer/issues/8706)) ([96d9608](https://github.com/puppeteer/puppeteer/commit/96d9608d1de17877414a649a0737661894dd96c8)), closes [#8479](https://github.com/puppeteer/puppeteer/issues/8479) +* use loaderId to reduce test flakiness ([#8717](https://github.com/puppeteer/puppeteer/issues/8717)) ([d2f6db2](https://github.com/puppeteer/puppeteer/commit/d2f6db20735342bb3f419e85adbd51ed10470044)) + +## [15.5.0](https://github.com/puppeteer/puppeteer/compare/v15.4.2...v15.5.0) (2022-07-21) + + +### Features + +* **chromium:** roll to Chromium 105.0.5173.0 (r1022525) ([#8682](https://github.com/puppeteer/puppeteer/issues/8682)) ([f1b8ad3](https://github.com/puppeteer/puppeteer/commit/f1b8ad3269286800d31818ea4b6b3ee23f7437c3)) + +## [15.4.2](https://github.com/puppeteer/puppeteer/compare/v15.4.1...v15.4.2) (2022-07-21) + + +### Bug Fixes + +* taking a screenshot with null viewport should be possible ([#8680](https://github.com/puppeteer/puppeteer/issues/8680)) ([2abb9f0](https://github.com/puppeteer/puppeteer/commit/2abb9f0c144779d555ecbf337a759440d0282cba)), closes [#8673](https://github.com/puppeteer/puppeteer/issues/8673) + +## [15.4.1](https://github.com/puppeteer/puppeteer/compare/v15.4.0...v15.4.1) (2022-07-21) + + +### Bug Fixes + +* import URL ([#8670](https://github.com/puppeteer/puppeteer/issues/8670)) ([34ab5ca](https://github.com/puppeteer/puppeteer/commit/34ab5ca50353ffb6a6345a8984b724a6f42fb726)) + +## [15.4.0](https://github.com/puppeteer/puppeteer/compare/v15.3.2...v15.4.0) (2022-07-13) + + +### Features + +* expose the page getter on Frame ([#8657](https://github.com/puppeteer/puppeteer/issues/8657)) ([af08c5c](https://github.com/puppeteer/puppeteer/commit/af08c5c90380c853e8257a51298bfed4b0635779)) + + +### Bug Fixes + +* ignore *.tsbuildinfo ([#8662](https://github.com/puppeteer/puppeteer/issues/8662)) ([edcdf21](https://github.com/puppeteer/puppeteer/commit/edcdf217cefbf31aee5a2f571abac429dd81f3a0)) + +## [15.3.2](https://github.com/puppeteer/puppeteer/compare/v15.3.1...v15.3.2) (2022-07-08) + + +### Bug Fixes + +* cache dynamic imports ([#8652](https://github.com/puppeteer/puppeteer/issues/8652)) ([1de0383](https://github.com/puppeteer/puppeteer/commit/1de0383abf6be31cf06faede3e59b087a2958227)) +* expose a RemoteObject getter ([#8642](https://github.com/puppeteer/puppeteer/issues/8642)) ([d0c4291](https://github.com/puppeteer/puppeteer/commit/d0c42919956bd36ad7993a0fc1de86e886e39f62)), closes [#8639](https://github.com/puppeteer/puppeteer/issues/8639) +* **page:** fix page.#scrollIntoViewIfNeeded method ([#8631](https://github.com/puppeteer/puppeteer/issues/8631)) ([b47f066](https://github.com/puppeteer/puppeteer/commit/b47f066c2c068825e3b65cfe17b6923c77ad30b9)) + +## [15.3.1](https://github.com/puppeteer/puppeteer/compare/v15.3.0...v15.3.1) (2022-07-06) + + +### Bug Fixes + +* extends `ElementHandle` to `Node`s ([#8552](https://github.com/puppeteer/puppeteer/issues/8552)) ([5ff205d](https://github.com/puppeteer/puppeteer/commit/5ff205dc8b659eb8864b4b1862105d21dd334c8f)) + +## [15.3.0](https://github.com/puppeteer/puppeteer/compare/v15.2.0...v15.3.0) (2022-07-01) + + +### Features + +* add documentation ([#8593](https://github.com/puppeteer/puppeteer/issues/8593)) ([066f440](https://github.com/puppeteer/puppeteer/commit/066f440ba7bdc9aca9423d7205adf36f2858bd78)) + + +### Bug Fixes + +* remove unused imports ([#8613](https://github.com/puppeteer/puppeteer/issues/8613)) ([0cf4832](https://github.com/puppeteer/puppeteer/commit/0cf4832878731ffcfc84570315f326eb851d7629)) + +## [15.2.0](https://github.com/puppeteer/puppeteer/compare/v15.1.1...v15.2.0) (2022-06-29) + + +### Features + +* add fromSurface option to page.screenshot ([#8496](https://github.com/puppeteer/puppeteer/issues/8496)) ([79e1198](https://github.com/puppeteer/puppeteer/commit/79e11985ba44b72b1ad6b8cd861fe316f1945e64)) +* export public types only ([#8584](https://github.com/puppeteer/puppeteer/issues/8584)) ([7001322](https://github.com/puppeteer/puppeteer/commit/7001322cd1cf9f77ee2c370d50a6707e7aaad72d)) + + +### Bug Fixes + +* clean up tmp profile dirs when browser is closed ([#8580](https://github.com/puppeteer/puppeteer/issues/8580)) ([9787a1d](https://github.com/puppeteer/puppeteer/commit/9787a1d8df7768017b36d42327faab402695c4bb)) + +## [15.1.1](https://github.com/puppeteer/puppeteer/compare/v15.1.0...v15.1.1) (2022-06-25) + + +### Bug Fixes + +* export `ElementHandle` ([e0198a7](https://github.com/puppeteer/puppeteer/commit/e0198a79e06c8bb72dde554db0246a3db5fec4c2)) + +## [15.1.0](https://github.com/puppeteer/puppeteer/compare/v15.0.2...v15.1.0) (2022-06-24) + + +### Features + +* **chromium:** roll to Chromium 104.0.5109.0 (r1011831) ([#8569](https://github.com/puppeteer/puppeteer/issues/8569)) ([fb7d31e](https://github.com/puppeteer/puppeteer/commit/fb7d31e3698428560e1f654d33782d241192f48f)) + +## [15.0.2](https://github.com/puppeteer/puppeteer/compare/v15.0.1...v15.0.2) (2022-06-24) + + +### Bug Fixes + +* CSS coverage should work with empty stylesheets ([#8570](https://github.com/puppeteer/puppeteer/issues/8570)) ([383e855](https://github.com/puppeteer/puppeteer/commit/383e8558477fae7708734ab2160ef50f385e2983)), closes [#8535](https://github.com/puppeteer/puppeteer/issues/8535) + +## [15.0.1](https://github.com/puppeteer/puppeteer/compare/v15.0.0...v15.0.1) (2022-06-24) + + +### Bug Fixes + +* infer unioned handles ([#8562](https://github.com/puppeteer/puppeteer/issues/8562)) ([8100cbb](https://github.com/puppeteer/puppeteer/commit/8100cbb29569541541f61001983efb9a80d89890)) + +## [15.0.0](https://github.com/puppeteer/puppeteer/compare/v14.4.1...v15.0.0) (2022-06-23) + + +### ⚠ BREAKING CHANGES + +* type inference for evaluation types (#8547) + +### Features + +* add experimental `client` to `HTTPRequest` ([#8556](https://github.com/puppeteer/puppeteer/issues/8556)) ([ec79f3a](https://github.com/puppeteer/puppeteer/commit/ec79f3a58a44c9ea60a82f9cd2df4c8f19e82ab8)) +* type inference for evaluation types ([#8547](https://github.com/puppeteer/puppeteer/issues/8547)) ([26c3acb](https://github.com/puppeteer/puppeteer/commit/26c3acbb0795eb66f29479f442e156832f794f01)) + +## [14.4.1](https://github.com/puppeteer/puppeteer/compare/v14.4.0...v14.4.1) (2022-06-17) + + +### Bug Fixes + +* avoid `instanceof Object` check in `isErrorLike` ([#8527](https://github.com/puppeteer/puppeteer/issues/8527)) ([6cd5cd0](https://github.com/puppeteer/puppeteer/commit/6cd5cd043997699edca6e3458f90adc1118cf4a5)) +* export `devices`, `errors`, and more ([cba58a1](https://github.com/puppeteer/puppeteer/commit/cba58a12c4e2043f6a5acf7d4754e4a7b7f6e198)) + +## [14.4.0](https://github.com/puppeteer/puppeteer/compare/v14.3.0...v14.4.0) (2022-06-13) + + +### Features + +* export puppeteer methods ([#8493](https://github.com/puppeteer/puppeteer/issues/8493)) ([465a7c4](https://github.com/puppeteer/puppeteer/commit/465a7c405f01fcef99380ffa69d86042a1f5618f)) +* support node-like environments ([#8490](https://github.com/puppeteer/puppeteer/issues/8490)) ([f64ec20](https://github.com/puppeteer/puppeteer/commit/f64ec2051b9b2d12225abba6ffe9551da9751bf7)) + + +### Bug Fixes + +* parse empty options in \<select\> ([#8489](https://github.com/puppeteer/puppeteer/issues/8489)) ([b30f3f4](https://github.com/puppeteer/puppeteer/commit/b30f3f44cdabd9545c4661cd755b9d49e5c144cd)) +* use error-like ([#8504](https://github.com/puppeteer/puppeteer/issues/8504)) ([4d35990](https://github.com/puppeteer/puppeteer/commit/4d359906a44e4ddd5ec54a523cfd9076048d3433)) +* use OS-independent abs. path check ([#8505](https://github.com/puppeteer/puppeteer/issues/8505)) ([bfd4e68](https://github.com/puppeteer/puppeteer/commit/bfd4e68f25bec6e00fd5cbf261813f8297d362ee)) + +## [14.3.0](https://github.com/puppeteer/puppeteer/compare/v14.2.1...v14.3.0) (2022-06-07) + + +### Features + +* use absolute URL for EVALUATION_SCRIPT_URL ([#8481](https://github.com/puppeteer/puppeteer/issues/8481)) ([e142560](https://github.com/puppeteer/puppeteer/commit/e14256010d2d84d613cd3c6e7999b0705115d4bf)), closes [#8424](https://github.com/puppeteer/puppeteer/issues/8424) + + +### Bug Fixes + +* don't throw on bad access ([#8472](https://github.com/puppeteer/puppeteer/issues/8472)) ([e837866](https://github.com/puppeteer/puppeteer/commit/e8378666c671e5703aec4f52912de2aac94e1828)) +* Kill browser process when killing process group fails ([#8477](https://github.com/puppeteer/puppeteer/issues/8477)) ([7dc8e37](https://github.com/puppeteer/puppeteer/commit/7dc8e37a23d025bb2c31efb9c060c7f6e00179b4)) +* only lookup `localhost` for DNS lookups ([1b025b4](https://github.com/puppeteer/puppeteer/commit/1b025b4c8466fe64da0fa2050eaa02b7764770b1)) +* robustly check for launch executable ([#8468](https://github.com/puppeteer/puppeteer/issues/8468)) ([b54dc55](https://github.com/puppeteer/puppeteer/commit/b54dc55f7622ee2b75afd3bd9fe118dd2f144f40)) + +## [14.2.1](https://github.com/puppeteer/puppeteer/compare/v14.2.0...v14.2.1) (2022-06-02) + + +### Bug Fixes + +* use isPageTargetCallback in Browser::pages() ([#8460](https://github.com/puppeteer/puppeteer/issues/8460)) ([5c9050a](https://github.com/puppeteer/puppeteer/commit/5c9050aea0fe8d57114130fe38bd33ed2b4955d6)) + +## [14.2.0](https://github.com/puppeteer/puppeteer/compare/v14.1.2...v14.2.0) (2022-06-01) + + +### Features + +* **chromium:** roll to Chromium 103.0.5059.0 (r1002410) ([#8410](https://github.com/puppeteer/puppeteer/issues/8410)) ([54efc2c](https://github.com/puppeteer/puppeteer/commit/54efc2c949be1d6ef22f4d2630620e33d14d2597)) +* support node 18 ([#8447](https://github.com/puppeteer/puppeteer/issues/8447)) ([f2d8276](https://github.com/puppeteer/puppeteer/commit/f2d8276d6e745a7547b8ce54c3f50934bb70de0b)) +* use strict typescript ([#8401](https://github.com/puppeteer/puppeteer/issues/8401)) ([b4e751f](https://github.com/puppeteer/puppeteer/commit/b4e751f29cb6fd4c3cc41fe702de83721f0eb6dc)) + + +### Bug Fixes + +* multiple same request event listener ([#8404](https://github.com/puppeteer/puppeteer/issues/8404)) ([9211015](https://github.com/puppeteer/puppeteer/commit/92110151d9a33f26abc07bc805f4f2f3943697a0)) +* NodeNext incompatibility in package.json ([#8445](https://github.com/puppeteer/puppeteer/issues/8445)) ([c4898a7](https://github.com/puppeteer/puppeteer/commit/c4898a7a2e69681baac55366848da6688f0d8790)) +* process documentation during publishing ([#8433](https://github.com/puppeteer/puppeteer/issues/8433)) ([d111d19](https://github.com/puppeteer/puppeteer/commit/d111d19f788d88d984dcf4ad7542f59acd2f4c1e)) + +## [14.1.2](https://github.com/puppeteer/puppeteer/compare/v14.1.1...v14.1.2) (2022-05-30) + + +### Bug Fixes + +* do not use loaderId for lifecycle events ([#8395](https://github.com/puppeteer/puppeteer/issues/8395)) ([c96c915](https://github.com/puppeteer/puppeteer/commit/c96c915b535dcf414038677bd3d3ed6b980a4901)) +* fix release-please bot ([#8400](https://github.com/puppeteer/puppeteer/issues/8400)) ([5c235c7](https://github.com/puppeteer/puppeteer/commit/5c235c701fc55380f09d09ac2cf63f2c94b60e3d)) +* use strict TS in Input.ts ([#8392](https://github.com/puppeteer/puppeteer/issues/8392)) ([af92a24](https://github.com/puppeteer/puppeteer/commit/af92a24ba9fc8efea1ba41f96d87515cf760da65)) + +### [14.1.1](https://github.com/puppeteer/puppeteer/compare/v14.1.0...v14.1.1) (2022-05-19) + + +### Bug Fixes + +* kill browser process when 'taskkill' fails on Windows ([#8352](https://github.com/puppeteer/puppeteer/issues/8352)) ([dccfadb](https://github.com/puppeteer/puppeteer/commit/dccfadb90e8947cae3f33d7a209b6f5752f97b46)) +* only check loading iframe in lifecycling ([#8348](https://github.com/puppeteer/puppeteer/issues/8348)) ([7438030](https://github.com/puppeteer/puppeteer/commit/74380303ac6cc6e2d84948a10920d56e665ccebe)) +* recompile before funit and unit commands ([#8363](https://github.com/puppeteer/puppeteer/issues/8363)) ([8735b78](https://github.com/puppeteer/puppeteer/commit/8735b784ba7838c1002b521a7f9f23bb27263d03)), closes [#8362](https://github.com/puppeteer/puppeteer/issues/8362) + +## [14.1.0](https://github.com/puppeteer/puppeteer/compare/v14.0.0...v14.1.0) (2022-05-13) + + +### Features + +* add waitForXPath to ElementHandle ([#8329](https://github.com/puppeteer/puppeteer/issues/8329)) ([7eaadaf](https://github.com/puppeteer/puppeteer/commit/7eaadafe197279a7d1753e7274d2e24dfc11abdf)) +* allow handling other targets as pages internally ([#8336](https://github.com/puppeteer/puppeteer/issues/8336)) ([3b66a2c](https://github.com/puppeteer/puppeteer/commit/3b66a2c47ee36785a6a72c9afedd768fab3d040a)) + + +### Bug Fixes + +* disable AvoidUnnecessaryBeforeUnloadCheckSync to fix navigations ([#8330](https://github.com/puppeteer/puppeteer/issues/8330)) ([4854ad5](https://github.com/puppeteer/puppeteer/commit/4854ad5b15c9bdf93c06dcb758393e7cbacd7469)) +* If currentNode and root are the same, do not include them in the result ([#8332](https://github.com/puppeteer/puppeteer/issues/8332)) ([a61144d](https://github.com/puppeteer/puppeteer/commit/a61144d43780b5c32197427d7682b9b6c433f2bb)) + +## [14.0.0](https://github.com/puppeteer/puppeteer/compare/v13.7.0...v14.0.0) (2022-05-09) + + +### ⚠ BREAKING CHANGES + +* strict mode fixes for HTTPRequest/Response classes (#8297) +* Node 12 is no longer supported. + +### Features + +* add support for Apple Silicon chromium builds ([#7546](https://github.com/puppeteer/puppeteer/issues/7546)) ([baa017d](https://github.com/puppeteer/puppeteer/commit/baa017db92b1fecf2e3584d5b3161371ae60f55b)), closes [#6622](https://github.com/puppeteer/puppeteer/issues/6622) +* **chromium:** roll to Chromium 102.0.5002.0 (r991974) ([#8319](https://github.com/puppeteer/puppeteer/issues/8319)) ([be4c930](https://github.com/puppeteer/puppeteer/commit/be4c930c60164f681a966d0f8cb745f6c263fe2b)) +* support ES modules ([#8306](https://github.com/puppeteer/puppeteer/issues/8306)) ([6841bd6](https://github.com/puppeteer/puppeteer/commit/6841bd68d85e3b3952c5e7ce454ac4d23f84262d)) + + +### Bug Fixes + +* apparent typo SUPPORTER_PLATFORMS ([#8294](https://github.com/puppeteer/puppeteer/issues/8294)) ([e09287f](https://github.com/puppeteer/puppeteer/commit/e09287f4e9a1ff3c637dd165d65f221394970e2c)) +* make sure inner OOPIFs can be attached to ([#8304](https://github.com/puppeteer/puppeteer/issues/8304)) ([5539598](https://github.com/puppeteer/puppeteer/commit/553959884f4edb4deab760fa8ca38fc1c85c05c5)) +* strict mode fixes for HTTPRequest/Response classes ([#8297](https://github.com/puppeteer/puppeteer/issues/8297)) ([2804ae8](https://github.com/puppeteer/puppeteer/commit/2804ae8cdbc4c90bf942510bce656275a2d409e1)), closes [#6769](https://github.com/puppeteer/puppeteer/issues/6769) +* tests failing in headful ([#8273](https://github.com/puppeteer/puppeteer/issues/8273)) ([e841d7f](https://github.com/puppeteer/puppeteer/commit/e841d7f9f3f407c02dbc48e107b545b91db104e6)) + + +* drop Node 12 support ([#8299](https://github.com/puppeteer/puppeteer/issues/8299)) ([274bd6b](https://github.com/puppeteer/puppeteer/commit/274bd6b3b98c305ed014909d8053e4c54187971b)) + +## [13.7.0](https://github.com/puppeteer/puppeteer/compare/v13.6.0...v13.7.0) (2022-04-28) + + +### Features + +* add `back` and `forward` mouse buttons ([#8284](https://github.com/puppeteer/puppeteer/issues/8284)) ([7a51bff](https://github.com/puppeteer/puppeteer/commit/7a51bff47f6436fc29d0df7eb74f12f69102ca5b)) +* support chrome headless mode ([#8260](https://github.com/puppeteer/puppeteer/issues/8260)) ([1308d9a](https://github.com/puppeteer/puppeteer/commit/1308d9aa6a5920b20da02dca8db03c63e43c8b84)) + + +### Bug Fixes + +* doc typo ([#8263](https://github.com/puppeteer/puppeteer/issues/8263)) ([952a2ae](https://github.com/puppeteer/puppeteer/commit/952a2ae0bc4f059f8e8b4d1de809d0a486a74551)) +* use different test names for browser specific tests in launcher.spec.ts ([#8250](https://github.com/puppeteer/puppeteer/issues/8250)) ([c6cf1a9](https://github.com/puppeteer/puppeteer/commit/c6cf1a9f27621c8a619cfbdc9d0821541768ac94)) + +## [13.6.0](https://github.com/puppeteer/puppeteer/compare/v13.5.2...v13.6.0) (2022-04-19) + + +### Features + +* **chromium:** roll to Chromium 101.0.4950.0 (r982053) ([#8213](https://github.com/puppeteer/puppeteer/issues/8213)) ([ec74bd8](https://github.com/puppeteer/puppeteer/commit/ec74bd811d9b7fbaf600068e86f13a63d7b0bc6f)) +* respond multiple headers with same key ([#8183](https://github.com/puppeteer/puppeteer/issues/8183)) ([c1dcd85](https://github.com/puppeteer/puppeteer/commit/c1dcd857e3bc17769f02474a41bbedee01f471dc)) + + +### Bug Fixes + +* also kill Firefox when temporary profile is used ([#8233](https://github.com/puppeteer/puppeteer/issues/8233)) ([b6504d7](https://github.com/puppeteer/puppeteer/commit/b6504d7186336a2fc0b41c3878c843b7409ba5fb)) +* consider existing frames when waiting for a frame ([#8200](https://github.com/puppeteer/puppeteer/issues/8200)) ([0955225](https://github.com/puppeteer/puppeteer/commit/0955225b51421663288523a3dfb63103b51775b4)) +* disable bfcache in the launcher ([#8196](https://github.com/puppeteer/puppeteer/issues/8196)) ([9ac7318](https://github.com/puppeteer/puppeteer/commit/9ac7318506ac858b3465e9b4ede8ad75fbbcee11)), closes [#8182](https://github.com/puppeteer/puppeteer/issues/8182) +* enable page.spec event handler test for firefox ([#8214](https://github.com/puppeteer/puppeteer/issues/8214)) ([2b45027](https://github.com/puppeteer/puppeteer/commit/2b45027d256f85f21a0c824183696b237e00ad33)) +* forget queuedEventGroup when emitting response in responseReceivedExtraInfo ([#8234](https://github.com/puppeteer/puppeteer/issues/8234)) ([#8239](https://github.com/puppeteer/puppeteer/issues/8239)) ([91a8e73](https://github.com/puppeteer/puppeteer/commit/91a8e73b1196e4128b1e7c25e08080f2faaf3cf7)) +* forget request will be sent from the _requestWillBeSentMap list. ([#8226](https://github.com/puppeteer/puppeteer/issues/8226)) ([4b786c9](https://github.com/puppeteer/puppeteer/commit/4b786c904cbfe3f059322292f3b788b8a5ebd9bf)) +* ignore favicon requests in page.spec event handler tests ([#8208](https://github.com/puppeteer/puppeteer/issues/8208)) ([04e5c88](https://github.com/puppeteer/puppeteer/commit/04e5c889973432c6163a8539cdec23c0e8726bff)) +* **network.spec.ts:** typo in the word should ([#8223](https://github.com/puppeteer/puppeteer/issues/8223)) ([e93faad](https://github.com/puppeteer/puppeteer/commit/e93faadc21b7fcb1e03b69c451c28b769f9cde51)) + +### [13.5.2](https://github.com/puppeteer/puppeteer/compare/v13.5.1...v13.5.2) (2022-03-31) + + +### Bug Fixes + +* chromium downloading hung at 99% ([#8169](https://github.com/puppeteer/puppeteer/issues/8169)) ([8f13470](https://github.com/puppeteer/puppeteer/commit/8f13470af06045857f32496f03e77b14f3ecff98)) +* get extra headers from Fetch.requestPaused event ([#8162](https://github.com/puppeteer/puppeteer/issues/8162)) ([37ede68](https://github.com/puppeteer/puppeteer/commit/37ede6877017a8dc6c946a3dff4ec6d79c3ebc59)) + +### [13.5.1](https://github.com/puppeteer/puppeteer/compare/v13.5.0...v13.5.1) (2022-03-09) + + +### Bug Fixes + +* waitForNavigation in OOPIFs ([#8117](https://github.com/puppeteer/puppeteer/issues/8117)) ([34775e5](https://github.com/puppeteer/puppeteer/commit/34775e58316be49d8bc5a13209a1f570bc66b448)) + +## [13.5.0](https://github.com/puppeteer/puppeteer/compare/v13.4.1...v13.5.0) (2022-03-07) + + +### Features + +* **chromium:** roll to Chromium 100.0.4889.0 (r970485) ([#8108](https://github.com/puppeteer/puppeteer/issues/8108)) ([d12f427](https://github.com/puppeteer/puppeteer/commit/d12f42754f7013b5ec0a2198cf2d9cf945d3cb38)) + + +### Bug Fixes + +* Inherit browser-level proxy settings from incognito context ([#7770](https://github.com/puppeteer/puppeteer/issues/7770)) ([3feca32](https://github.com/puppeteer/puppeteer/commit/3feca325a9472ee36f7e866ebe375c7f083e0e36)) +* **page:** page.createIsolatedWorld error catching has been added ([#7848](https://github.com/puppeteer/puppeteer/issues/7848)) ([309e8b8](https://github.com/puppeteer/puppeteer/commit/309e8b80da0519327bc37b44a3ebb6f2e2d357a7)) +* **tests:** ensure all tests honour BINARY envvar ([#8092](https://github.com/puppeteer/puppeteer/issues/8092)) ([3b8b9ad](https://github.com/puppeteer/puppeteer/commit/3b8b9adde5d18892af96329b6f9303979f9c04f5)) + +### [13.4.1](https://github.com/puppeteer/puppeteer/compare/v13.4.0...v13.4.1) (2022-03-01) + + +### Bug Fixes + +* regression in --user-data-dir handling ([#8060](https://github.com/puppeteer/puppeteer/issues/8060)) ([85decdc](https://github.com/puppeteer/puppeteer/commit/85decdc28d7d2128e6d2946a72f4d99dd5dbb48a)) + +## [13.4.0](https://github.com/puppeteer/puppeteer/compare/v13.3.2...v13.4.0) (2022-02-22) + + +### Features + +* add support for async waitForTarget ([#7885](https://github.com/puppeteer/puppeteer/issues/7885)) ([dbf0639](https://github.com/puppeteer/puppeteer/commit/dbf0639822d0b2736993de52c0bfe1dbf4e58f25)) +* export `Frame._client` through getter ([#8041](https://github.com/puppeteer/puppeteer/issues/8041)) ([e9278fc](https://github.com/puppeteer/puppeteer/commit/e9278fcfcffe2558de63ce7542483445bcb6e74f)) +* **HTTPResponse:** expose timing information ([#8025](https://github.com/puppeteer/puppeteer/issues/8025)) ([30b3d49](https://github.com/puppeteer/puppeteer/commit/30b3d49b0de46d812b7485e708174a07c73dbdd0)) + + +### Bug Fixes + +* change kill to signal the whole process group to terminate ([#6859](https://github.com/puppeteer/puppeteer/issues/6859)) ([0eb9c78](https://github.com/puppeteer/puppeteer/commit/0eb9c7861717ebba7012c03e76b7a46063e4e5dd)) +* element screenshot issue in headful mode ([#8018](https://github.com/puppeteer/puppeteer/issues/8018)) ([5346e70](https://github.com/puppeteer/puppeteer/commit/5346e70ffc15b33c1949657cf1b465f1acc5d84d)), closes [#7999](https://github.com/puppeteer/puppeteer/issues/7999) +* ensure dom binding is not called after detach ([#8024](https://github.com/puppeteer/puppeteer/issues/8024)) ([5c308b0](https://github.com/puppeteer/puppeteer/commit/5c308b0704123736ddb085f97596c201ea18cf4a)), closes [#7814](https://github.com/puppeteer/puppeteer/issues/7814) +* use both __dirname and require.resolve to support different bundlers ([#8046](https://github.com/puppeteer/puppeteer/issues/8046)) ([e6a6295](https://github.com/puppeteer/puppeteer/commit/e6a6295d9a7480bb59ee58a2cc7785171fa0fa2c)), closes [#8044](https://github.com/puppeteer/puppeteer/issues/8044) + +### [13.3.2](https://github.com/puppeteer/puppeteer/compare/v13.3.1...v13.3.2) (2022-02-14) + + +### Bug Fixes + +* always use ENV executable path when present ([#7985](https://github.com/puppeteer/puppeteer/issues/7985)) ([6d6ea9b](https://github.com/puppeteer/puppeteer/commit/6d6ea9bf59daa3fb851b3da8baa27887e0aa2c28)) +* use require.resolve instead of __dirname ([#8003](https://github.com/puppeteer/puppeteer/issues/8003)) ([bbb186d](https://github.com/puppeteer/puppeteer/commit/bbb186d88cb99e4914299c983c822fa41a80f356)) + +### [13.3.1](https://github.com/puppeteer/puppeteer/compare/v13.3.0...v13.3.1) (2022-02-10) + + +### Bug Fixes + +* **puppeteer:** revert: esm modules ([#7986](https://github.com/puppeteer/puppeteer/issues/7986)) ([179eded](https://github.com/puppeteer/puppeteer/commit/179ededa1400c35c1f2edc015548e0f2a1bcee14)) + +## [13.3.0](https://github.com/puppeteer/puppeteer/compare/v13.2.0...v13.3.0) (2022-02-09) + + +### Features + +* **puppeteer:** export esm modules in package.json ([#7964](https://github.com/puppeteer/puppeteer/issues/7964)) ([523b487](https://github.com/puppeteer/puppeteer/commit/523b487e8802824cecff86d256b4f7dbc4c47c8a)) + +## [13.2.0](https://github.com/puppeteer/puppeteer/compare/v13.1.3...v13.2.0) (2022-02-07) + + +### Features + +* add more models to DeviceDescriptors ([#7904](https://github.com/puppeteer/puppeteer/issues/7904)) ([6a655cb](https://github.com/puppeteer/puppeteer/commit/6a655cb647e12eaf1055be0b298908d83bebac25)) +* **chromium:** roll to Chromium 99.0.4844.16 (r961656) ([#7960](https://github.com/puppeteer/puppeteer/issues/7960)) ([96c3f94](https://github.com/puppeteer/puppeteer/commit/96c3f943b2f6e26bd871ecfcce71b6a33e214ebf)) + + +### Bug Fixes + +* make projectRoot optional in Puppeteer and launchers ([#7967](https://github.com/puppeteer/puppeteer/issues/7967)) ([9afdc63](https://github.com/puppeteer/puppeteer/commit/9afdc6300b80f01091dc4cb42d4ebe952c7d60f0)) +* migrate more files to strict-mode TypeScript ([#7950](https://github.com/puppeteer/puppeteer/issues/7950)) ([aaac8d9](https://github.com/puppeteer/puppeteer/commit/aaac8d9c44327a2c503ffd6c97b7f21e8010c3e4)) +* typos in documentation ([#7968](https://github.com/puppeteer/puppeteer/issues/7968)) ([41ab4e9](https://github.com/puppeteer/puppeteer/commit/41ab4e9127df64baa6c43ecde2f7ddd702ba7b0c)) + +### [13.1.3](https://github.com/puppeteer/puppeteer/compare/v13.1.2...v13.1.3) (2022-01-31) + + +### Bug Fixes + +* issue with reading versions.js in doclint ([#7940](https://github.com/puppeteer/puppeteer/issues/7940)) ([06ba963](https://github.com/puppeteer/puppeteer/commit/06ba9632a4c63859244068d32c312817d90daf63)) +* make more files work in strict-mode TypeScript ([#7936](https://github.com/puppeteer/puppeteer/issues/7936)) ([0636513](https://github.com/puppeteer/puppeteer/commit/0636513e34046f4d40b5e88beb2b18b16dab80aa)) +* page.pdf producing an invalid pdf ([#7868](https://github.com/puppeteer/puppeteer/issues/7868)) ([afea509](https://github.com/puppeteer/puppeteer/commit/afea509544fb99bfffe5b0bebe6f3575c53802f0)), closes [#7757](https://github.com/puppeteer/puppeteer/issues/7757) + +### [13.1.2](https://github.com/puppeteer/puppeteer/compare/v13.1.1...v13.1.2) (2022-01-25) + + +### Bug Fixes + +* **package.json:** update node-fetch package ([#7924](https://github.com/puppeteer/puppeteer/issues/7924)) ([e4c48d3](https://github.com/puppeteer/puppeteer/commit/e4c48d3b8c2a812752094ed8163e4f2f32c4b6cb)) +* types in Browser.ts to be compatible with strict mode Typescript ([#7918](https://github.com/puppeteer/puppeteer/issues/7918)) ([a8ec0aa](https://github.com/puppeteer/puppeteer/commit/a8ec0aadc9c90d224d568d9e418d14261e6e85b1)), closes [#6769](https://github.com/puppeteer/puppeteer/issues/6769) +* types in Connection.ts to be compatible with strict mode Typescript ([#7919](https://github.com/puppeteer/puppeteer/issues/7919)) ([d80d602](https://github.com/puppeteer/puppeteer/commit/d80d6027ea8e1b7fcdaf045398629cf8e6512658)), closes [#6769](https://github.com/puppeteer/puppeteer/issues/6769) + +### [13.1.1](https://github.com/puppeteer/puppeteer/compare/v13.1.0...v13.1.1) (2022-01-18) + + +### Bug Fixes + +* use content box for OOPIF offset calculations ([#7911](https://github.com/puppeteer/puppeteer/issues/7911)) ([344feb5](https://github.com/puppeteer/puppeteer/commit/344feb53c28ce018a4c600d408468f6d9d741eee)) + +## [13.1.0](https://github.com/puppeteer/puppeteer/compare/v13.0.1...v13.1.0) (2022-01-17) + + +### Features + +* **chromium:** roll to Chromium 98.0.4758.0 (r950341) ([#7907](https://github.com/puppeteer/puppeteer/issues/7907)) ([a55c86f](https://github.com/puppeteer/puppeteer/commit/a55c86fac504b5e89ba23735fb3a1b1d54a4e1e5)) + + +### Bug Fixes + +* apply OOPIF offsets to bounding box and box model calls ([#7906](https://github.com/puppeteer/puppeteer/issues/7906)) ([a566263](https://github.com/puppeteer/puppeteer/commit/a566263ba28e58ff648bffbdb628606f75d5876f)) +* correctly compute clickable points for elements inside OOPIFs ([#7900](https://github.com/puppeteer/puppeteer/issues/7900)) ([486bbe0](https://github.com/puppeteer/puppeteer/commit/486bbe010d5ee5c446d9e8daf61a080232379c3f)), closes [#7849](https://github.com/puppeteer/puppeteer/issues/7849) +* error for pre-existing OOPIFs ([#7899](https://github.com/puppeteer/puppeteer/issues/7899)) ([d7937b8](https://github.com/puppeteer/puppeteer/commit/d7937b806d331bf16c2016aaf16e932b1334eac8)), closes [#7844](https://github.com/puppeteer/puppeteer/issues/7844) [#7896](https://github.com/puppeteer/puppeteer/issues/7896) + +### [13.0.1](https://github.com/puppeteer/puppeteer/compare/v13.0.0...v13.0.1) (2021-12-22) + + +### Bug Fixes + +* disable a test failing on Firefox ([#7846](https://github.com/puppeteer/puppeteer/issues/7846)) ([36207c5](https://github.com/puppeteer/puppeteer/commit/36207c5efe8ca21f4b3fc5b00212700326a701d2)) +* make sure ElementHandle.waitForSelector is evaluated in the right context ([#7843](https://github.com/puppeteer/puppeteer/issues/7843)) ([8d8e874](https://github.com/puppeteer/puppeteer/commit/8d8e874b072b17fc763f33d08e51c046b7435244)) +* predicate arguments for waitForFunction ([#7845](https://github.com/puppeteer/puppeteer/issues/7845)) ([1c44551](https://github.com/puppeteer/puppeteer/commit/1c44551f1b5bb19455b4a1eb7061715717ec880e)), closes [#7836](https://github.com/puppeteer/puppeteer/issues/7836) + +## [13.0.0](https://github.com/puppeteer/puppeteer/compare/v12.0.1...v13.0.0) (2021-12-10) + + +### ⚠ BREAKING CHANGES + +* typo in 'already-handled' constant of the request interception API (#7813) + +### Features + +* expose HTTPRequest intercept resolution state and clarify docs ([#7796](https://github.com/puppeteer/puppeteer/issues/7796)) ([dc23b75](https://github.com/puppeteer/puppeteer/commit/dc23b7535cb958c00d1eecfe85b4ee26e52e2e39)) +* implement Element.waitForSelector ([#7825](https://github.com/puppeteer/puppeteer/issues/7825)) ([c034294](https://github.com/puppeteer/puppeteer/commit/c03429444d05b39549489ad3da67d93b2be59f51)) + + +### Bug Fixes + +* handle multiple/duplicate Fetch.requestPaused events ([#7802](https://github.com/puppeteer/puppeteer/issues/7802)) ([636b086](https://github.com/puppeteer/puppeteer/commit/636b0863a169da132e333eb53b17eb2601daabe6)), closes [#7475](https://github.com/puppeteer/puppeteer/issues/7475) [#6696](https://github.com/puppeteer/puppeteer/issues/6696) [#7225](https://github.com/puppeteer/puppeteer/issues/7225) +* revert "feat(typescript): allow using puppeteer without dom lib" ([02c9af6](https://github.com/puppeteer/puppeteer/commit/02c9af62d64060a83f53368640f343ae2e30e38a)), closes [#6998](https://github.com/puppeteer/puppeteer/issues/6998) +* typo in 'already-handled' constant of the request interception API ([#7813](https://github.com/puppeteer/puppeteer/issues/7813)) ([8242422](https://github.com/puppeteer/puppeteer/commit/824242246de9e158aacb85f71350a79cb386ed92)), closes [#7745](https://github.com/puppeteer/puppeteer/issues/7745) [#7747](https://github.com/puppeteer/puppeteer/issues/7747) [#7780](https://github.com/puppeteer/puppeteer/issues/7780) + +### [12.0.1](https://github.com/puppeteer/puppeteer/compare/v12.0.0...v12.0.1) (2021-11-29) + + +### Bug Fixes + +* handle extraInfo events even if event.hasExtraInfo === false ([#7808](https://github.com/puppeteer/puppeteer/issues/7808)) ([6ee2feb](https://github.com/puppeteer/puppeteer/commit/6ee2feb1eafdd399f0af50cdc4517f21bcb55121)), closes [#7805](https://github.com/puppeteer/puppeteer/issues/7805) + +## [12.0.0](https://github.com/puppeteer/puppeteer/compare/v11.0.0...v12.0.0) (2021-11-26) + + +### ⚠ BREAKING CHANGES + +* **chromium:** roll to Chromium 97.0.4692.0 (r938248) + +### Features + +* **chromium:** roll to Chromium 97.0.4692.0 (r938248) ([ac162c5](https://github.com/puppeteer/puppeteer/commit/ac162c561ee43dd69eff38e1b354a41bb42c9eba)), closes [#7458](https://github.com/puppeteer/puppeteer/issues/7458) +* support for custom user data (profile) directory for Firefox ([#7684](https://github.com/puppeteer/puppeteer/issues/7684)) ([790c7a0](https://github.com/puppeteer/puppeteer/commit/790c7a0eb92291efebaa37e80c72f5cb5f46bbdb)) + + +### Bug Fixes + +* **ariaqueryhandler:** allow single quotes in aria attribute selector ([#7750](https://github.com/puppeteer/puppeteer/issues/7750)) ([b0319ec](https://github.com/puppeteer/puppeteer/commit/b0319ecc89f8ea3d31ab9aee5e1cd33d2a4e62be)), closes [#7721](https://github.com/puppeteer/puppeteer/issues/7721) +* clearer jsdoc for behavior of `headless` when `devtools` is true ([#7748](https://github.com/puppeteer/puppeteer/issues/7748)) ([9f9b4ed](https://github.com/puppeteer/puppeteer/commit/9f9b4ed72ab0bb43d002a0024122d6f5eab231aa)) +* null check for frame in FrameManager ([#7773](https://github.com/puppeteer/puppeteer/issues/7773)) ([23ee295](https://github.com/puppeteer/puppeteer/commit/23ee295f348d114617f2a86d0bb792936f413ac5)), closes [#7749](https://github.com/puppeteer/puppeteer/issues/7749) +* only kill the process when there is no browser instance available ([#7762](https://github.com/puppeteer/puppeteer/issues/7762)) ([51e6169](https://github.com/puppeteer/puppeteer/commit/51e61696c1c20cc09bd4fc068ae1dfa259c41745)), closes [#7668](https://github.com/puppeteer/puppeteer/issues/7668) +* parse statusText from the extraInfo event ([#7798](https://github.com/puppeteer/puppeteer/issues/7798)) ([a26b12b](https://github.com/puppeteer/puppeteer/commit/a26b12b7c775c36271cd4c98e39bbd59f4356320)), closes [#7458](https://github.com/puppeteer/puppeteer/issues/7458) +* try to remove the temporary user data directory after the process has been killed ([#7761](https://github.com/puppeteer/puppeteer/issues/7761)) ([fc94a28](https://github.com/puppeteer/puppeteer/commit/fc94a28778cfdb3cb8bcd882af3ebcdacf85c94e)) + +## [11.0.0](https://github.com/puppeteer/puppeteer/compare/v10.4.0...v11.0.0) (2021-11-02) + + +### ⚠ BREAKING CHANGES + +* **oop iframes:** integrate OOP iframes with the frame manager (#7556) + +### Features + +* improve error message for response.buffer() ([#7669](https://github.com/puppeteer/puppeteer/issues/7669)) ([03c9ecc](https://github.com/puppeteer/puppeteer/commit/03c9ecca400a02684cd60229550dbad1190a5b6e)) +* **oop iframes:** integrate OOP iframes with the frame manager ([#7556](https://github.com/puppeteer/puppeteer/issues/7556)) ([4d9dc8c](https://github.com/puppeteer/puppeteer/commit/4d9dc8c0e613f22d4cdf237e8bd0b0da3c588edb)), closes [#2548](https://github.com/puppeteer/puppeteer/issues/2548) +* add custom debugging port option ([#4993](https://github.com/puppeteer/puppeteer/issues/4993)) ([26145e9](https://github.com/puppeteer/puppeteer/commit/26145e9a24af7caed6ece61031f2cafa6abd505f)) +* add initiator to HTTPRequest ([#7614](https://github.com/puppeteer/puppeteer/issues/7614)) ([a271145](https://github.com/puppeteer/puppeteer/commit/a271145b0663ef9de1903dd0eb9fd5366465bed7)) +* allow to customize tmpdir ([#7243](https://github.com/puppeteer/puppeteer/issues/7243)) ([b1f6e86](https://github.com/puppeteer/puppeteer/commit/b1f6e8692b0bc7e8551b2a78169c830cd80a7acb)) +* handle unhandled promise rejections in tests ([#7722](https://github.com/puppeteer/puppeteer/issues/7722)) ([07febca](https://github.com/puppeteer/puppeteer/commit/07febca04b391893cfc872250e4391da142d4fe2)) + + +### Bug Fixes + +* add support for relative install paths to BrowserFetcher ([#7613](https://github.com/puppeteer/puppeteer/issues/7613)) ([eebf452](https://github.com/puppeteer/puppeteer/commit/eebf452d38b79bb2ea1a1ba84c3d2ea6f2f9f899)), closes [#7592](https://github.com/puppeteer/puppeteer/issues/7592) +* add webp to screenshot quality option allow list ([#7631](https://github.com/puppeteer/puppeteer/issues/7631)) ([b20c2bf](https://github.com/puppeteer/puppeteer/commit/b20c2bfa24cbdd4a1b9cefca2e0a9407e442baf5)) +* prevent Target closed errors on streams ([#7728](https://github.com/puppeteer/puppeteer/issues/7728)) ([5b792de](https://github.com/puppeteer/puppeteer/commit/5b792de7a97611441777d1ac99cb95516301d7dc)) +* request an animation frame to fix flaky clickablePoint test ([#7587](https://github.com/puppeteer/puppeteer/issues/7587)) ([7341d9f](https://github.com/puppeteer/puppeteer/commit/7341d9fadd1466a5b2f2bde8631f3b02cf9a7d8a)) +* setup husky properly ([#7727](https://github.com/puppeteer/puppeteer/issues/7727)) ([8b712e7](https://github.com/puppeteer/puppeteer/commit/8b712e7b642b58193437f26d4e104a9e412f388d)), closes [#7726](https://github.com/puppeteer/puppeteer/issues/7726) +* updated troubleshooting.md to meet latest dependencies changes ([#7656](https://github.com/puppeteer/puppeteer/issues/7656)) ([edb0197](https://github.com/puppeteer/puppeteer/commit/edb01972b9606d8b05b979a588eda0d622315981)) +* **launcher:** launcher.launch() should pass 'timeout' option [#5180](https://github.com/puppeteer/puppeteer/issues/5180) ([#7596](https://github.com/puppeteer/puppeteer/issues/7596)) ([113489d](https://github.com/puppeteer/puppeteer/commit/113489d3b58e2907374a4e6e5133bf46630695d1)) +* **page:** fallback to default in exposeFunction when using imported module ([#6365](https://github.com/puppeteer/puppeteer/issues/6365)) ([44c9ec6](https://github.com/puppeteer/puppeteer/commit/44c9ec67c57dccf3e186c86f14f3a8da9a8eb971)) +* **page:** fix page.off method for request event ([#7624](https://github.com/puppeteer/puppeteer/issues/7624)) ([d0cb943](https://github.com/puppeteer/puppeteer/commit/d0cb9436a302418086f6763e0e58ae3732a20b62)), closes [#7572](https://github.com/puppeteer/puppeteer/issues/7572) + +## [10.4.0](https://github.com/puppeteer/puppeteer/compare/v10.2.0...v10.4.0) (2021-09-21) + + +### Features + +* add webp to screenshot options ([#7565](https://github.com/puppeteer/puppeteer/issues/7565)) ([43a9268](https://github.com/puppeteer/puppeteer/commit/43a926832505a57922016907a264165676424557)) +* **page:** expose page.client() ([#7582](https://github.com/puppeteer/puppeteer/issues/7582)) ([99ca842](https://github.com/puppeteer/puppeteer/commit/99ca842124a1edef5e66426621885141a9feaca5)) +* **page:** mark page.client() as internal ([#7585](https://github.com/puppeteer/puppeteer/issues/7585)) ([8451951](https://github.com/puppeteer/puppeteer/commit/84519514831f304f9076ca235fe474f797616b2c)) +* add ability to specify offsets for JSHandle.click ([#7573](https://github.com/puppeteer/puppeteer/issues/7573)) ([2b5c001](https://github.com/puppeteer/puppeteer/commit/2b5c0019dc3744196c5858edeaa901dff9973ef5)) +* add durableStorage to allowed permissions ([#5295](https://github.com/puppeteer/puppeteer/issues/5295)) ([eda5171](https://github.com/puppeteer/puppeteer/commit/eda51712790b9260626dc53cfb58a72805c45582)) +* add id option to addScriptTag ([#5477](https://github.com/puppeteer/puppeteer/issues/5477)) ([300be5d](https://github.com/puppeteer/puppeteer/commit/300be5d167b6e7e532e725fdb86966081a5d0093)) +* add more Android models to DeviceDescriptors ([#7210](https://github.com/puppeteer/puppeteer/issues/7210)) ([b5020dc](https://github.com/puppeteer/puppeteer/commit/b5020dc04121b265c77662237dfb177d6de06053)), closes [/github.com/aerokube/moon-deploy/blob/master/moon-local.yaml#L199](https://github.com/puppeteer//github.com/aerokube/moon-deploy/blob/master/moon-local.yaml/issues/L199) +* add proxy and bypass list parameters to createIncognitoBrowserContext ([#7516](https://github.com/puppeteer/puppeteer/issues/7516)) ([8e45a1c](https://github.com/puppeteer/puppeteer/commit/8e45a1c882207cc36e87be2a917b661eb841c4bf)), closes [#678](https://github.com/puppeteer/puppeteer/issues/678) +* add threshold to Page.isIntersectingViewport ([#6497](https://github.com/puppeteer/puppeteer/issues/6497)) ([54c4318](https://github.com/puppeteer/puppeteer/commit/54c43180161c3c512e4698e7f2e85ce3c6f0ab50)) +* add unit test support for bisect ([#7553](https://github.com/puppeteer/puppeteer/issues/7553)) ([a0b1f6b](https://github.com/puppeteer/puppeteer/commit/a0b1f6b401abae2fbc5a8987061644adfaa7b482)) +* add User-Agent with Puppeteer version to WebSocket request ([#5614](https://github.com/puppeteer/puppeteer/issues/5614)) ([6a2bf0a](https://github.com/puppeteer/puppeteer/commit/6a2bf0aabaa4df72c7838f5a6cd742e8f9c72be6)) +* extend husky checks ([#7574](https://github.com/puppeteer/puppeteer/issues/7574)) ([7316086](https://github.com/puppeteer/puppeteer/commit/73160869417275200be19bd37372b6218dbc5f63)) +* **api:** implement `Page.waitForNetworkIdle()` ([#5140](https://github.com/puppeteer/puppeteer/issues/5140)) ([3c6029c](https://github.com/puppeteer/puppeteer/commit/3c6029c702291ca7ef637b66e78d72e03156fe58)) +* **coverage:** option for raw V8 script coverage ([#6454](https://github.com/puppeteer/puppeteer/issues/6454)) ([cb4470a](https://github.com/puppeteer/puppeteer/commit/cb4470a6d9b0a7f73836458bb3d5779eb85ac5f2)) +* support timeout for page.pdf() call ([#7508](https://github.com/puppeteer/puppeteer/issues/7508)) ([f90af66](https://github.com/puppeteer/puppeteer/commit/f90af6639d801e764bdb479b9543b7f8f2b926df)) +* **typescript:** allow using puppeteer without dom lib ([#6998](https://github.com/puppeteer/puppeteer/issues/6998)) ([723052d](https://github.com/puppeteer/puppeteer/commit/723052d5bb3c3d1d3908508467512bea4d8fdc80)), closes [#6989](https://github.com/puppeteer/puppeteer/issues/6989) + + +### Bug Fixes + +* **docs:** deploy includes website documentation ([#7469](https://github.com/puppeteer/puppeteer/issues/7469)) ([6fde41c](https://github.com/puppeteer/puppeteer/commit/6fde41c6b6657986df1bbce3f2e0f7aa499f2be4)) +* **docs:** names in version 9.1.1 ([#7517](https://github.com/puppeteer/puppeteer/issues/7517)) ([44b22bb](https://github.com/puppeteer/puppeteer/commit/44b22bbc2629e3c75c1494b299a66790b371fb0a)) +* **frame:** fix Frame.waitFor's XPath pattern detection ([#5184](https://github.com/puppeteer/puppeteer/issues/5184)) ([caa2b73](https://github.com/puppeteer/puppeteer/commit/caa2b732fe58f32ec03f2a9fa8568f20188203c5)) +* **install:** respect environment proxy config when downloading Firef… ([#6577](https://github.com/puppeteer/puppeteer/issues/6577)) ([9399c97](https://github.com/puppeteer/puppeteer/commit/9399c9786fba4e45e1c5485ddbb197d2d4f1735f)), closes [#6573](https://github.com/puppeteer/puppeteer/issues/6573) +* added names in V9.1.1 ([#7547](https://github.com/puppeteer/puppeteer/issues/7547)) ([d132b8b](https://github.com/puppeteer/puppeteer/commit/d132b8b041696e6d5b9a99d0be1acf1cf943efef)) +* **test:** tweak waitForNetworkIdle delay in test between downloads ([#7564](https://github.com/puppeteer/puppeteer/issues/7564)) ([a21b737](https://github.com/puppeteer/puppeteer/commit/a21b7376e7feaf23066d67948d52480516f42496)) +* **types:** allow evaluate functions to take a readonly array as an argument ([#7072](https://github.com/puppeteer/puppeteer/issues/7072)) ([491614c](https://github.com/puppeteer/puppeteer/commit/491614c7f8cfa50b902d0275064e611c2a48c3b2)) +* update firefox prefs documentation link ([#7539](https://github.com/puppeteer/puppeteer/issues/7539)) ([2aec355](https://github.com/puppeteer/puppeteer/commit/2aec35553bc6e0305f40837bb3665ddbd02aa889)) +* use non-deprecated tracing categories api ([#7413](https://github.com/puppeteer/puppeteer/issues/7413)) ([040a0e5](https://github.com/puppeteer/puppeteer/commit/040a0e561b4f623f7929130b90be129f94ebb642)) + +## [10.2.0](https://github.com/puppeteer/puppeteer/compare/v10.1.0...v10.2.0) (2021-08-04) + + +### Features + +* **api:** make `page.isDragInterceptionEnabled` a method ([#7419](https://github.com/puppeteer/puppeteer/issues/7419)) ([dd470c7](https://github.com/puppeteer/puppeteer/commit/dd470c7a226a8422a938a7b0fffa58ffc6b78512)), closes [#7150](https://github.com/puppeteer/puppeteer/issues/7150) +* **chromium:** roll to Chromium 93.0.4577.0 (r901912) ([#7387](https://github.com/puppeteer/puppeteer/issues/7387)) ([e10faad](https://github.com/puppeteer/puppeteer/commit/e10faad4f239b1120491bb54fcba0216acd3a646)) +* add channel parameter for puppeteer.launch ([#7389](https://github.com/puppeteer/puppeteer/issues/7389)) ([d70f60e](https://github.com/puppeteer/puppeteer/commit/d70f60e0619b8659d191fa492e3db4bc221ae982)) +* add cooperative request intercepts ([#6735](https://github.com/puppeteer/puppeteer/issues/6735)) ([b5e6474](https://github.com/puppeteer/puppeteer/commit/b5e6474374ae6a88fc73cdb1a9906764c2ac5d70)) +* add support for useragentdata ([#7378](https://github.com/puppeteer/puppeteer/issues/7378)) ([7200b1a](https://github.com/puppeteer/puppeteer/commit/7200b1a6fb9dfdfb65d50f0000339333e71b1b2a)) + + +### Bug Fixes + +* **browser-runner:** reject promise on error ([#7338](https://github.com/puppeteer/puppeteer/issues/7338)) ([5eb20e2](https://github.com/puppeteer/puppeteer/commit/5eb20e29a21ea0e0368fa8937ef38f7c7693ab34)) +* add script to remove html comments from docs markdown ([#7394](https://github.com/puppeteer/puppeteer/issues/7394)) ([ea3df80](https://github.com/puppeteer/puppeteer/commit/ea3df80ed136a03d7698d2319106af5df8d48b58)) + +## [10.1.0](https://github.com/puppeteer/puppeteer/compare/v10.0.0...v10.1.0) (2021-06-29) + + +### Features + +* add a streaming version for page.pdf ([e3699e2](https://github.com/puppeteer/puppeteer/commit/e3699e248bc9c1f7a6ead9a07d68ae8b65905443)) +* add drag-and-drop support ([#7150](https://github.com/puppeteer/puppeteer/issues/7150)) ([a91b8ac](https://github.com/puppeteer/puppeteer/commit/a91b8aca3728b2c2e310e9446897d729bf983377)) +* add page.emulateCPUThrottling ([#7343](https://github.com/puppeteer/puppeteer/issues/7343)) ([4ce4110](https://github.com/puppeteer/puppeteer/commit/4ce41106288938b9d366c550e7a424812920683d)) + + +### Bug Fixes + +* remove redundant await while fetching target ([#7351](https://github.com/puppeteer/puppeteer/issues/7351)) ([083b297](https://github.com/puppeteer/puppeteer/commit/083b297a6741c6b1dd23867f441130655fac8f7d)) + +## [10.0.0](https://github.com/puppeteer/puppeteer/compare/v9.1.1...v10.0.0) (2021-05-31) + + +### ⚠ BREAKING CHANGES + +* Node.js 10 is no longer supported. + +### Features + +* **chromium:** roll to Chromium 92.0.4512.0 (r884014) ([#7288](https://github.com/puppeteer/puppeteer/issues/7288)) ([f863f4b](https://github.com/puppeteer/puppeteer/commit/f863f4bfe015e57ea1f9fbb322f1cedee468b857)) +* **requestinterception:** remove cacheSafe flag ([#7217](https://github.com/puppeteer/puppeteer/issues/7217)) ([d01aa6c](https://github.com/puppeteer/puppeteer/commit/d01aa6c84a1e41f15ffed3a8d36ad26a404a7187)) +* expose other sessions from connection ([#6863](https://github.com/puppeteer/puppeteer/issues/6863)) ([cb285a2](https://github.com/puppeteer/puppeteer/commit/cb285a237921259eac99ade1d8b5550e068a55eb)) +* **launcher:** add new launcher option `waitForInitialPage` ([#7105](https://github.com/puppeteer/puppeteer/issues/7105)) ([2605309](https://github.com/puppeteer/puppeteer/commit/2605309f74b43da160cda4d214016e4422bf7676)), closes [#3630](https://github.com/puppeteer/puppeteer/issues/3630) + + +### Bug Fixes + +* added comments for browsercontext, startCSSCoverage, and startJSCoverage. ([#7264](https://github.com/puppeteer/puppeteer/issues/7264)) ([b750397](https://github.com/puppeteer/puppeteer/commit/b75039746ac6bddf1411538242b5e70b0f2e6e8a)) +* modified comment for method product, platform and newPage ([#7262](https://github.com/puppeteer/puppeteer/issues/7262)) ([159d283](https://github.com/puppeteer/puppeteer/commit/159d2835450697dabea6f9adf6e67d158b5b8ae3)) +* **requestinterception:** fix font loading issue ([#7060](https://github.com/puppeteer/puppeteer/issues/7060)) ([c9978d2](https://github.com/puppeteer/puppeteer/commit/c9978d20d5584c9fd2dc902e4b4ac86ed8ea5d6e)), closes [/github.com/puppeteer/puppeteer/pull/6996#issuecomment-811546501](https://github.com/puppeteer//github.com/puppeteer/puppeteer/pull/6996/issues/issuecomment-811546501) [/github.com/puppeteer/puppeteer/pull/6996#issuecomment-813797393](https://github.com/puppeteer//github.com/puppeteer/puppeteer/pull/6996/issues/issuecomment-813797393) [#7038](https://github.com/puppeteer/puppeteer/issues/7038) + + +* drop support for Node.js 10 ([#7200](https://github.com/puppeteer/puppeteer/issues/7200)) ([97c9fe2](https://github.com/puppeteer/puppeteer/commit/97c9fe2520723d45a5a86da06b888ae888d400be)), closes [#6753](https://github.com/puppeteer/puppeteer/issues/6753) + +### [9.1.1](https://github.com/puppeteer/puppeteer/compare/v9.1.0...v9.1.1) (2021-05-05) + + +### Bug Fixes + +* make targetFilter synchronous ([#7203](https://github.com/puppeteer/puppeteer/issues/7203)) ([bcc85a0](https://github.com/puppeteer/puppeteer/commit/bcc85a0969077d122e5d8d2fb5c1061999a8ae48)) + +## [9.1.0](https://github.com/puppeteer/puppeteer/compare/v9.0.0...v9.1.0) (2021-05-03) + + +### Features + +* add option to filter targets ([#7192](https://github.com/puppeteer/puppeteer/issues/7192)) ([ec3fc2e](https://github.com/puppeteer/puppeteer/commit/ec3fc2e035bb5ca14a576180fff612e1ecf6bad7)) + + +### Bug Fixes + +* change rm -rf to rimraf ([#7168](https://github.com/puppeteer/puppeteer/issues/7168)) ([ad6b736](https://github.com/puppeteer/puppeteer/commit/ad6b736039436fcc5c0a262e5b575aa041427be3)) + +## [9.0.0](https://github.com/puppeteer/puppeteer/compare/v8.0.0...v9.0.0) (2021-04-21) + + +### ⚠ BREAKING CHANGES + +* **filechooser:** FileChooser.cancel() is now synchronous. + +### Features + +* **chromium:** roll to Chromium 91.0.4469.0 (r869685) ([#7110](https://github.com/puppeteer/puppeteer/issues/7110)) ([715e7a8](https://github.com/puppeteer/puppeteer/commit/715e7a8d62901d1c7ec602425c2fce8d8148b742)) +* **launcher:** fix installation error on Apple M1 chips ([#7099](https://github.com/puppeteer/puppeteer/issues/7099)) ([c239d9e](https://github.com/puppeteer/puppeteer/commit/c239d9edc72d85697b4875c98fff3ec592848082)), closes [#6622](https://github.com/puppeteer/puppeteer/issues/6622) +* **network:** request interception and caching compatibility ([#6996](https://github.com/puppeteer/puppeteer/issues/6996)) ([8695759](https://github.com/puppeteer/puppeteer/commit/8695759a223bc1bd31baecb00dc28721216e4c6f)) +* **page:** emit the event after removing the Worker ([#7080](https://github.com/puppeteer/puppeteer/issues/7080)) ([e34a6d5](https://github.com/puppeteer/puppeteer/commit/e34a6d53183c3e1f63a375ba6a26bee0dcfcf542)) +* **types:** improve type of predicate function ([#6997](https://github.com/puppeteer/puppeteer/issues/6997)) ([943477c](https://github.com/puppeteer/puppeteer/commit/943477cc1eb4b129870142873b3554737d5ef252)), closes [/github.com/DefinitelyTyped/DefinitelyTyped/blob/c43191a8f7a7d2a47bbff0bc3a7d95ecc64d2269/types/puppeteer/index.d.ts#L1883-L1885](https://github.com/puppeteer//github.com/DefinitelyTyped/DefinitelyTyped/blob/c43191a8f7a7d2a47bbff0bc3a7d95ecc64d2269/types/puppeteer/index.d.ts/issues/L1883-L1885) +* accept captureBeyondViewport as optional screenshot param ([#7063](https://github.com/puppeteer/puppeteer/issues/7063)) ([0e092d2](https://github.com/puppeteer/puppeteer/commit/0e092d2ea0ec18ad7f07ad3507deb80f96086e7a)) +* **page:** add omitBackground option for page.pdf method ([#6981](https://github.com/puppeteer/puppeteer/issues/6981)) ([dc8ab6d](https://github.com/puppeteer/puppeteer/commit/dc8ab6d8ca1661f8e56d329e6d9c49c891e8b975)) + + +### Bug Fixes + +* **aria:** fix parsing of ARIA selectors ([#7037](https://github.com/puppeteer/puppeteer/issues/7037)) ([4426135](https://github.com/puppeteer/puppeteer/commit/4426135692ae3ee7ed2841569dd9375e7ca8286c)) +* **page:** fix mouse.click method ([#7097](https://github.com/puppeteer/puppeteer/issues/7097)) ([ba7c367](https://github.com/puppeteer/puppeteer/commit/ba7c367de33ace7753fd9d8b8cc894b2c14ab6c2)), closes [#6462](https://github.com/puppeteer/puppeteer/issues/6462) [#3347](https://github.com/puppeteer/puppeteer/issues/3347) +* make `$` and `$$` selectors generic ([#6883](https://github.com/puppeteer/puppeteer/issues/6883)) ([b349c91](https://github.com/puppeteer/puppeteer/commit/b349c91e7df76630b7411d6645e649945c4609bd)) +* type page event listeners correctly ([#6891](https://github.com/puppeteer/puppeteer/issues/6891)) ([866d34e](https://github.com/puppeteer/puppeteer/commit/866d34ee1122e89eab00743246676845bb065968)) +* **typescript:** allow defaultViewport to be 'null' ([#6942](https://github.com/puppeteer/puppeteer/issues/6942)) ([e31e68d](https://github.com/puppeteer/puppeteer/commit/e31e68dfa12dd50482b700472bc98876b9031829)), closes [#6885](https://github.com/puppeteer/puppeteer/issues/6885) +* make screenshots work in puppeteer-web ([#6936](https://github.com/puppeteer/puppeteer/issues/6936)) ([5f24f60](https://github.com/puppeteer/puppeteer/commit/5f24f608194fd4252da7b288461427cabc9dabb3)) +* **filechooser:** cancel is sync ([#6937](https://github.com/puppeteer/puppeteer/issues/6937)) ([2ba61e0](https://github.com/puppeteer/puppeteer/commit/2ba61e04e923edaac09c92315212552f2d4ce676)) +* **network:** don't disable cache for auth challenge ([#6962](https://github.com/puppeteer/puppeteer/issues/6962)) ([1c2479a](https://github.com/puppeteer/puppeteer/commit/1c2479a6cd4bd09a577175ffd31c40ca6f4279b8)) + +## [8.0.0](https://github.com/puppeteer/puppeteer/compare/v7.1.0...v8.0.0) (2021-02-26) + + +### ⚠ BREAKING CHANGES + +* renamed type `ChromeArgOptions` to `BrowserLaunchArgumentOptions` +* renamed type `BrowserOptions` to `BrowserConnectOptions` + +### Features + +* **chromium:** roll Chromium to r856583 ([#6927](https://github.com/puppeteer/puppeteer/issues/6927)) ([0c688bd](https://github.com/puppeteer/puppeteer/commit/0c688bd75ef1d1fc3afd14cbe8966757ecda68fb)) + + +### Bug Fixes + +* explicit HTTPRequest.resourceType type defs ([#6882](https://github.com/puppeteer/puppeteer/issues/6882)) ([ff26c62](https://github.com/puppeteer/puppeteer/commit/ff26c62647b60cd0d8d7ea66ee998adaadc3fcc2)), closes [#6854](https://github.com/puppeteer/puppeteer/issues/6854) +* expose `Viewport` type ([#6881](https://github.com/puppeteer/puppeteer/issues/6881)) ([be7c229](https://github.com/puppeteer/puppeteer/commit/be7c22933c1dcf5eee797d61463171bd0ef44582)) +* improve TS types for launching browsers ([#6888](https://github.com/puppeteer/puppeteer/issues/6888)) ([98c8145](https://github.com/puppeteer/puppeteer/commit/98c81458c27f378eb66c38e1620e79e2ffde418e)) +* move CI npm config out of .npmrc ([#6901](https://github.com/puppeteer/puppeteer/issues/6901)) ([f7de60b](https://github.com/puppeteer/puppeteer/commit/f7de60be22d9bc6433ada7bfefeaa7f6f6f62047)) + +## [7.1.0](https://github.com/puppeteer/puppeteer/compare/v7.0.4...v7.1.0) (2021-02-12) + + +### Features + +* **page:** add color-gamut support to Page.emulateMediaFeatures ([#6857](https://github.com/puppeteer/puppeteer/issues/6857)) ([ad59357](https://github.com/puppeteer/puppeteer/commit/ad5935738d869cfce386a0d28b4bc6131457f962)), closes [#6761](https://github.com/puppeteer/puppeteer/issues/6761) + + +### Bug Fixes + +* add favicon test asset ([#6868](https://github.com/puppeteer/puppeteer/issues/6868)) ([a63f53c](https://github.com/puppeteer/puppeteer/commit/a63f53c9380545550503f5539494c72c607e19ac)) +* expose `ScreenshotOptions` type in type defs ([#6869](https://github.com/puppeteer/puppeteer/issues/6869)) ([63d48b2](https://github.com/puppeteer/puppeteer/commit/63d48b2ecba317b6c0a3acad87a7a3671c769dbc)), closes [#6866](https://github.com/puppeteer/puppeteer/issues/6866) +* expose puppeteer.Permission type ([#6856](https://github.com/puppeteer/puppeteer/issues/6856)) ([a5e174f](https://github.com/puppeteer/puppeteer/commit/a5e174f696eb192c541db64a603ea5cdf385a643)) +* jsonValue() type is generic ([#6865](https://github.com/puppeteer/puppeteer/issues/6865)) ([bdaba78](https://github.com/puppeteer/puppeteer/commit/bdaba7829da366aabbc81885d84bb2401ab3eaff)) +* wider compat TS types and CI checks to ensure correct type defs ([#6855](https://github.com/puppeteer/puppeteer/issues/6855)) ([6a0eb78](https://github.com/puppeteer/puppeteer/commit/6a0eb7841fd82493903b0b9fa153d2de181350eb)) + +### [7.0.4](https://github.com/puppeteer/puppeteer/compare/v7.0.3...v7.0.4) (2021-02-09) + + +### Bug Fixes + +* make publish bot run full build, not just tsc ([#6848](https://github.com/puppeteer/puppeteer/issues/6848)) ([f718b14](https://github.com/puppeteer/puppeteer/commit/f718b14b64df8be492d344ddd35e40961ff750c5)) + +### [7.0.3](https://github.com/puppeteer/puppeteer/compare/v7.0.2...v7.0.3) (2021-02-09) + + +### Bug Fixes + +* include lib/types.d.ts in files list ([#6844](https://github.com/puppeteer/puppeteer/issues/6844)) ([e34f317](https://github.com/puppeteer/puppeteer/commit/e34f317b37533256a063c1238609b488d263b998)) + +### [7.0.2](https://github.com/puppeteer/puppeteer/compare/v7.0.1...v7.0.2) (2021-02-09) + + +### Bug Fixes + +* much better TypeScript definitions ([#6837](https://github.com/puppeteer/puppeteer/issues/6837)) ([f1b46ab](https://github.com/puppeteer/puppeteer/commit/f1b46ab5faa262f893c17923579d0cf52268a764)) +* **domworld:** reset bindings when context changes ([#6766](https://github.com/puppeteer/puppeteer/issues/6766)) ([#6836](https://github.com/puppeteer/puppeteer/issues/6836)) ([4e8d074](https://github.com/puppeteer/puppeteer/commit/4e8d074c2f8384a2f283f5edf9ef69c40bd8464f)) +* **launcher:** output correct error message for browser ([#6815](https://github.com/puppeteer/puppeteer/issues/6815)) ([6c61874](https://github.com/puppeteer/puppeteer/commit/6c618747979c3a08f2727e9e22fe45cade8c926a)) + +### [7.0.1](https://github.com/puppeteer/puppeteer/compare/v7.0.0...v7.0.1) (2021-02-04) + + +### Bug Fixes + +* **typescript:** ship .d.ts file in npm package ([#6811](https://github.com/puppeteer/puppeteer/issues/6811)) ([a7e3c2e](https://github.com/puppeteer/puppeteer/commit/a7e3c2e09e9163eee2f15221aafa4400e6a75f91)) + +## [7.0.0](https://github.com/puppeteer/puppeteer/compare/v6.0.0...v7.0.0) (2021-02-03) + + +### ⚠ BREAKING CHANGES + +* - `page.screenshot` makes a screenshot with the clip dimensions, not cutting it by the ViewPort size. +* **chromium:** - `page.screenshot` cuts screenshot content by the ViewPort size, not ViewPort position. + +### Features + +* use `captureBeyondViewport` in `Page.captureScreenshot` ([#6805](https://github.com/puppeteer/puppeteer/issues/6805)) ([401d84e](https://github.com/puppeteer/puppeteer/commit/401d84e4a3508f9ca5c24dbfcad2a71571b1b8eb)) +* **chromium:** roll Chromium to r848005 ([#6801](https://github.com/puppeteer/puppeteer/issues/6801)) ([890d5c2](https://github.com/puppeteer/puppeteer/commit/890d5c2e57cdee7d73915a878bda86b72e26b608)) + +## [6.0.0](https://github.com/puppeteer/puppeteer/compare/v5.5.0...v6.0.0) (2021-02-02) + + +### ⚠ BREAKING CHANGES + +* **chromium:** The built-in `aria/` selector query handler doesn’t return ignored elements anymore. + +### Features + +* **chromium:** roll Chromium to r843427 ([#6797](https://github.com/puppeteer/puppeteer/issues/6797)) ([8f9fbdb](https://github.com/puppeteer/puppeteer/commit/8f9fbdbae68254600a9c73ab05f36146c975dba6)), closes [#6758](https://github.com/puppeteer/puppeteer/issues/6758) +* add page.emulateNetworkConditions ([#6759](https://github.com/puppeteer/puppeteer/issues/6759)) ([5ea76e9](https://github.com/puppeteer/puppeteer/commit/5ea76e9333c42ab5a751ca01aa5676a662f6c063)) +* **types:** expose typedefs to consumers ([#6745](https://github.com/puppeteer/puppeteer/issues/6745)) ([ebd087a](https://github.com/puppeteer/puppeteer/commit/ebd087a31661a1b701650d0be3e123cc5a813bd8)) +* add iPhone 11 models to DeviceDescriptors ([#6467](https://github.com/puppeteer/puppeteer/issues/6467)) ([50b810d](https://github.com/puppeteer/puppeteer/commit/50b810dab7fae5950ba086295462788f91ff1e6f)) +* support fetching and launching on Apple M1 ([9a8479a](https://github.com/puppeteer/puppeteer/commit/9a8479a52a7d8b51690b0732b2a10816cd1b8aef)), closes [#6495](https://github.com/puppeteer/puppeteer/issues/6495) [#6634](https://github.com/puppeteer/puppeteer/issues/6634) [#6641](https://github.com/puppeteer/puppeteer/issues/6641) [#6614](https://github.com/puppeteer/puppeteer/issues/6614) +* support promise as return value for page.waitForResponse predicate ([#6624](https://github.com/puppeteer/puppeteer/issues/6624)) ([b57f3fc](https://github.com/puppeteer/puppeteer/commit/b57f3fcd5393c68f51d82e670b004f5b116dcbc3)) + + +### Bug Fixes + +* **domworld:** fix waitfor bindings ([#6766](https://github.com/puppeteer/puppeteer/issues/6766)) ([#6775](https://github.com/puppeteer/puppeteer/issues/6775)) ([cac540b](https://github.com/puppeteer/puppeteer/commit/cac540be3ab8799a1d77b0951b16bc22ea1c2adb)) +* **launcher:** rename TranslateUI to Translate to match Chrome ([#6692](https://github.com/puppeteer/puppeteer/issues/6692)) ([d901696](https://github.com/puppeteer/puppeteer/commit/d901696e0d8901bcb23cf676a5e5ac562f821a0d)) +* do not use old utility world ([#6528](https://github.com/puppeteer/puppeteer/issues/6528)) ([fb85911](https://github.com/puppeteer/puppeteer/commit/fb859115c0e2829bae1d1b32edbf642988e2ef76)), closes [#6527](https://github.com/puppeteer/puppeteer/issues/6527) +* update to https-proxy-agent@^5.0.0 to fix `ERR_INVALID_PROTOCOL` ([#6555](https://github.com/puppeteer/puppeteer/issues/6555)) ([3bf5a55](https://github.com/puppeteer/puppeteer/commit/3bf5a552890ee80cc4326b1e430424b0fdad4363)) + +## [5.5.0](https://github.com/puppeteer/puppeteer/compare/v5.4.1...v5.5.0) (2020-11-16) + + +### Features + +* **chromium:** roll Chromium to r818858 ([#6526](https://github.com/puppeteer/puppeteer/issues/6526)) ([b549256](https://github.com/puppeteer/puppeteer/commit/b54925695200cad32f470f8eb407259606447a85)) + + +### Bug Fixes + +* **common:** fix generic type of `_isClosedPromise` ([#6579](https://github.com/puppeteer/puppeteer/issues/6579)) ([122f074](https://github.com/puppeteer/puppeteer/commit/122f074f92f47a7b9aa08091851e51a07632d23b)) +* **domworld:** fix missing binding for waittasks ([#6562](https://github.com/puppeteer/puppeteer/issues/6562)) ([67da1cf](https://github.com/puppeteer/puppeteer/commit/67da1cf866703f5f581c9cce4923697ac38129ef)) diff --git a/remote/test/puppeteer/packages/puppeteer-core/Herebyfile.mjs b/remote/test/puppeteer/packages/puppeteer-core/Herebyfile.mjs new file mode 100644 index 0000000000..723fa2868a --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/Herebyfile.mjs @@ -0,0 +1,112 @@ +import {mkdir, readFile, readdir, writeFile} from 'fs/promises'; +import {join} from 'path/posix'; + +import esbuild from 'esbuild'; +import {execa} from 'execa'; +import {task} from 'hereby'; + +export const generateVersionTask = task({ + name: 'generate:version', + run: async () => { + const {version} = JSON.parse(await readFile('package.json', 'utf8')); + await mkdir('src/generated', {recursive: true}); + await writeFile( + 'src/generated/version.ts', + (await readFile('src/templates/version.ts.tmpl', 'utf8')).replace( + 'PACKAGE_VERSION', + version + ) + ); + if (process.env['PUBLISH']) { + await writeFile( + '../../versions.js', + ( + await readFile('../../versions.js', { + encoding: 'utf-8', + }) + ).replace("'NEXT'", `'v${version}'`) + ); + } + }, +}); + +export const generateInjectedTask = task({ + name: 'generate:injected', + run: async () => { + const { + outputFiles: [{text}], + } = await esbuild.build({ + entryPoints: ['src/injected/injected.ts'], + bundle: true, + format: 'cjs', + target: ['chrome117', 'firefox118'], + minify: true, + write: false, + }); + const template = await readFile('src/templates/injected.ts.tmpl', 'utf8'); + await mkdir('src/generated', {recursive: true}); + await writeFile( + 'src/generated/injected.ts', + template.replace('SOURCE_CODE', JSON.stringify(text)) + ); + }, +}); + +export const generatePackageJsonTask = task({ + name: 'generate:package-json', + run: async () => { + await mkdir('lib/esm', {recursive: true}); + await writeFile('lib/esm/package.json', JSON.stringify({type: 'module'})); + }, +}); + +export const generateTask = task({ + name: 'generate', + dependencies: [ + generateVersionTask, + generateInjectedTask, + generatePackageJsonTask, + ], +}); + +export const buildTscTask = task({ + name: 'build:tsc', + dependencies: [generateTask], + run: async () => { + await execa('tsc', ['-b']); + }, +}); + +export const buildTask = task({ + name: 'build', + dependencies: [buildTscTask], + run: async () => { + const formats = ['esm', 'cjs']; + const packages = (await readdir('third_party', {withFileTypes: true})) + .filter(dirent => { + return dirent.isDirectory(); + }) + .map(({name}) => { + return name; + }); + const builders = []; + for (const format of formats) { + const folder = join('lib', format, 'third_party'); + for (const name of packages) { + const path = join(folder, name, `${name}.js`); + builders.push( + await esbuild.build({ + entryPoints: [path], + outfile: path, + bundle: true, + allowOverwrite: true, + format, + target: 'node16', + minify: true, + }) + ); + } + } + await Promise.all(builders); + }, +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/api-extractor.docs.json b/remote/test/puppeteer/packages/puppeteer-core/api-extractor.docs.json new file mode 100644 index 0000000000..b0bcacbb34 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/api-extractor.docs.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "<projectFolder>/lib/esm/puppeteer/puppeteer-core.d.ts", + + "extends": "./api-extractor.json", + + "dtsRollup": { + "enabled": false + }, + + "docModel": { + "enabled": true, + "apiJsonFilePath": "<projectFolder>/../../docs/<unscopedPackageName>.api.json" + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/api-extractor.json b/remote/test/puppeteer/packages/puppeteer-core/api-extractor.json new file mode 100644 index 0000000000..7b9032de29 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/api-extractor.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "<projectFolder>/lib/esm/puppeteer/puppeteer-core.d.ts", + "bundledPackages": [], + + "apiReport": { + "enabled": false + }, + + "docModel": { + "enabled": false + }, + + "dtsRollup": { + "enabled": true, + "untrimmedFilePath": "", + "alphaTrimmedFilePath": "lib/types.d.ts" + }, + + "tsdocMetadata": { + "enabled": false + }, + + "messages": { + "compilerMessageReporting": { + "default": { + "logLevel": "warning" + } + }, + + "extractorMessageReporting": { + "ae-internal-missing-underscore": { + "logLevel": "none" + }, + "default": { + "logLevel": "warning" + } + }, + + "tsdocMessageReporting": { + "default": { + "logLevel": "warning" + } + } + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/package.json b/remote/test/puppeteer/packages/puppeteer-core/package.json new file mode 100644 index 0000000000..2f1943bd2f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/package.json @@ -0,0 +1,136 @@ +{ + "name": "puppeteer-core", + "version": "21.10.0", + "description": "A high-level API to control headless Chrome over the DevTools Protocol", + "keywords": [ + "puppeteer", + "chrome", + "headless", + "automation" + ], + "type": "commonjs", + "main": "./lib/cjs/puppeteer/puppeteer-core.js", + "types": "./lib/types.d.ts", + "exports": { + ".": { + "types": "./lib/types.d.ts", + "import": "./lib/esm/puppeteer/puppeteer-core.js", + "require": "./lib/cjs/puppeteer/puppeteer-core.js" + }, + "./internal/*": { + "import": "./lib/esm/puppeteer/*", + "require": "./lib/cjs/puppeteer/*" + }, + "./*": { + "import": "./*", + "require": "./*" + } + }, + "repository": { + "type": "git", + "url": "https://github.com/puppeteer/puppeteer/tree/main/packages/puppeteer-core" + }, + "engines": { + "node": ">=16.13.2" + }, + "scripts": { + "build:docs": "wireit", + "build": "wireit", + "check": "tsx tools/ensure-correct-devtools-protocol-package", + "clean": "../../tools/clean.js", + "prepack": "wireit", + "unit": "wireit" + }, + "wireit": { + "prepack": { + "command": "tsx ../../tools/cp.ts ../../README.md README.md", + "files": [ + "../../README.md" + ], + "output": [ + "README.md" + ] + }, + "build": { + "dependencies": [ + "build:tsc", + "build:types" + ] + }, + "build:docs": { + "command": "api-extractor run --local --config \"./api-extractor.docs.json\"", + "files": [ + "api-extractor.docs.json", + "lib/esm/puppeteer/puppeteer-core.d.ts", + "tsconfig.json" + ], + "dependencies": [ + "build:tsc" + ] + }, + "build:tsc": { + "command": "hereby build", + "clean": "if-file-deleted", + "dependencies": [ + "../browsers:build" + ], + "files": [ + "{src,third_party}/**", + "../../versions.js", + "!src/generated" + ], + "output": [ + "lib/{cjs,esm}/**" + ] + }, + "build:types": { + "command": "api-extractor run --local && eslint --cache-location .eslintcache --cache --ext=ts --no-ignore --no-eslintrc -c=../../.eslintrc.types.cjs --fix lib/types.d.ts", + "files": [ + "../../.eslintrc.types.cjs", + "api-extractor.json", + "lib/esm/puppeteer/types.d.ts", + "tsconfig.json" + ], + "output": [ + "lib/types.d.ts" + ], + "dependencies": [ + "build:tsc" + ] + }, + "unit": { + "command": "node --test --test-reporter spec lib/cjs", + "dependencies": [ + "build" + ] + } + }, + "files": [ + "lib", + "src", + "!*.test.ts", + "!*.test.js", + "!*.test.d.ts", + "!*.test.js.map", + "!*.test.d.ts.map", + "!*.tsbuildinfo" + ], + "author": "The Chromium Authors", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "1.9.1", + "chromium-bidi": "0.5.6", + "cross-fetch": "4.0.0", + "debug": "4.3.4", + "devtools-protocol": "0.0.1232444", + "ws": "8.16.0" + }, + "devDependencies": { + "@types/debug": "4.1.12", + "@types/node": "18.17.15", + "@types/ws": "8.5.10", + "mitt": "3.0.1", + "parsel-js": "1.1.2", + "rxjs": "7.8.1" + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Browser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Browser.ts new file mode 100644 index 0000000000..e3b465c80e --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Browser.ts @@ -0,0 +1,454 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ChildProcess} from 'child_process'; + +import type {Protocol} from 'devtools-protocol'; + +import { + filterAsync, + firstValueFrom, + from, + merge, + raceWith, +} 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 {asyncDisposeSymbol, disposeSymbol} from '../util/disposable.js'; + +import type {BrowserContext} from './BrowserContext.js'; +import type {Page} from './Page.js'; +import type {Target} from './Target.js'; +/** + * @public + */ +export interface BrowserContextOptions { + /** + * Proxy server with optional port to use for all requests. + * Username and password can be set in `Page.authenticate`. + */ + proxyServer?: string; + /** + * Bypass the proxy for the given list of hosts. + */ + proxyBypassList?: string[]; +} + +/** + * @internal + */ +export type BrowserCloseCallback = () => Promise<void> | void; + +/** + * @public + */ +export type TargetFilterCallback = (target: Target) => boolean; + +/** + * @internal + */ +export type IsPageTargetCallback = (target: Target) => boolean; + +/** + * @internal + */ +export const WEB_PERMISSION_TO_PROTOCOL_PERMISSION = new Map< + Permission, + Protocol.Browser.PermissionType +>([ + ['geolocation', 'geolocation'], + ['midi', 'midi'], + ['notifications', 'notifications'], + // TODO: push isn't a valid type? + // ['push', 'push'], + ['camera', 'videoCapture'], + ['microphone', 'audioCapture'], + ['background-sync', 'backgroundSync'], + ['ambient-light-sensor', 'sensors'], + ['accelerometer', 'sensors'], + ['gyroscope', 'sensors'], + ['magnetometer', 'sensors'], + ['accessibility-events', 'accessibilityEvents'], + ['clipboard-read', 'clipboardReadWrite'], + ['clipboard-write', 'clipboardReadWrite'], + ['clipboard-sanitized-write', 'clipboardSanitizedWrite'], + ['payment-handler', 'paymentHandler'], + ['persistent-storage', 'durableStorage'], + ['idle-detection', 'idleDetection'], + // chrome-specific permissions we have. + ['midi-sysex', 'midiSysex'], +]); + +/** + * @public + */ +export type Permission = + | 'geolocation' + | 'midi' + | 'notifications' + | 'camera' + | 'microphone' + | 'background-sync' + | 'ambient-light-sensor' + | 'accelerometer' + | 'gyroscope' + | 'magnetometer' + | 'accessibility-events' + | 'clipboard-read' + | 'clipboard-write' + | 'clipboard-sanitized-write' + | 'payment-handler' + | 'persistent-storage' + | 'idle-detection' + | 'midi-sysex'; + +/** + * @public + */ +export interface WaitForTargetOptions { + /** + * Maximum wait time in milliseconds. Pass `0` to disable the timeout. + * + * @defaultValue `30_000` + */ + timeout?: number; +} + +/** + * All the events a {@link Browser | browser instance} may emit. + * + * @public + */ +export const enum BrowserEvent { + /** + * Emitted when Puppeteer gets disconnected from the browser instance. This + * might happen because either: + * + * - The browser closes/crashes or + * - {@link Browser.disconnect} was called. + */ + Disconnected = 'disconnected', + /** + * Emitted when the URL of a target changes. Contains a {@link Target} + * instance. + * + * @remarks Note that this includes target changes in incognito browser + * contexts. + */ + TargetChanged = 'targetchanged', + /** + * Emitted when a target is created, for example when a new page is opened by + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/open | window.open} + * or by {@link Browser.newPage | browser.newPage} + * + * Contains a {@link Target} instance. + * + * @remarks Note that this includes target creations in incognito browser + * contexts. + */ + TargetCreated = 'targetcreated', + /** + * 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 + * contexts. + */ + TargetDestroyed = 'targetdestroyed', + /** + * @internal + */ + TargetDiscovered = 'targetdiscovered', +} + +export { + /** + * @deprecated Use {@link BrowserEvent}. + */ + BrowserEvent as BrowserEmittedEvents, +}; + +/** + * @public + */ +export interface BrowserEvents extends Record<EventType, unknown> { + [BrowserEvent.Disconnected]: undefined; + [BrowserEvent.TargetCreated]: Target; + [BrowserEvent.TargetDestroyed]: Target; + [BrowserEvent.TargetChanged]: Target; + /** + * @internal + */ + [BrowserEvent.TargetDiscovered]: Protocol.Target.TargetInfo; +} + +/** + * @public + * @experimental + */ +export interface DebugInfo { + pendingProtocolErrors: Error[]; +} + +/** + * {@link Browser} represents a browser instance that is either: + * + * - connected to via {@link Puppeteer.connect} or + * - launched by {@link PuppeteerNode.launch}. + * + * {@link Browser} {@link EventEmitter | emits} various events which are + * documented in the {@link BrowserEvent} enum. + * + * @example Using a {@link Browser} to create a {@link Page}: + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://example.com'); + * await browser.close(); + * ``` + * + * @example Disconnecting from and reconnecting to a {@link Browser}: + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * const browser = await puppeteer.launch(); + * // Store the endpoint to be able to reconnect to the browser. + * const browserWSEndpoint = browser.wsEndpoint(); + * // Disconnect puppeteer from the browser. + * await browser.disconnect(); + * + * // Use the endpoint to reestablish a connection + * const browser2 = await puppeteer.connect({browserWSEndpoint}); + * // Close the browser. + * await browser2.close(); + * ``` + * + * @public + */ +export abstract class Browser extends EventEmitter<BrowserEvents> { + /** + * @internal + */ + constructor() { + super(); + } + + /** + * Gets the associated + * {@link https://nodejs.org/api/child_process.html#class-childprocess | ChildProcess}. + * + * @returns `null` if this instance was connected to via + * {@link Puppeteer.connect}. + */ + abstract process(): ChildProcess | null; + + /** + * Creates a new incognito {@link BrowserContext | browser context}. + * + * This won't share cookies/cache with other {@link BrowserContext | browser contexts}. + * + * @example + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * const browser = await puppeteer.launch(); + * // Create a new incognito browser context. + * const context = await browser.createIncognitoBrowserContext(); + * // Create a new page in a pristine context. + * const page = await context.newPage(); + * // Do stuff + * await page.goto('https://example.com'); + * ``` + */ + abstract createIncognitoBrowserContext( + options?: BrowserContextOptions + ): Promise<BrowserContext>; + + /** + * Gets a list of open {@link BrowserContext | browser contexts}. + * + * In a newly-created {@link Browser | browser}, this will return a single + * instance of {@link BrowserContext}. + */ + abstract browserContexts(): BrowserContext[]; + + /** + * Gets the default {@link BrowserContext | browser context}. + * + * @remarks The default {@link BrowserContext | browser context} cannot be + * closed. + */ + abstract defaultBrowserContext(): BrowserContext; + + /** + * Gets the WebSocket URL to connect to this {@link Browser | browser}. + * + * This is usually used with {@link Puppeteer.connect}. + * + * You can find the debugger URL (`webSocketDebuggerUrl`) from + * `http://HOST:PORT/json/version`. + * + * See {@link + * https://chromedevtools.github.io/devtools-protocol/#how-do-i-access-the-browser-target + * | browser endpoint} for more information. + * + * @remarks The format is always `ws://HOST:PORT/devtools/browser/<id>`. + */ + abstract wsEndpoint(): string; + + /** + * Creates a new {@link Page | page} in the + * {@link Browser.defaultBrowserContext | default browser context}. + */ + abstract newPage(): Promise<Page>; + + /** + * Gets all active {@link Target | targets}. + * + * In case of multiple {@link BrowserContext | browser contexts}, this returns + * all {@link Target | targets} in all + * {@link BrowserContext | browser contexts}. + */ + abstract targets(): Target[]; + + /** + * Gets the {@link Target | target} associated with the + * {@link Browser.defaultBrowserContext | default browser context}). + */ + abstract target(): Target; + + /** + * Waits until a {@link Target | target} matching the given `predicate` + * appears and returns it. + * + * This will look all open {@link BrowserContext | browser contexts}. + * + * @example Finding a target for a page opened via `window.open`: + * + * ```ts + * await page.evaluate(() => window.open('https://www.example.com/')); + * const newWindowTarget = await browser.waitForTarget( + * target => target.url() === 'https://www.example.com/' + * ); + * ``` + */ + async waitForTarget( + predicate: (x: Target) => boolean | Promise<boolean>, + options: WaitForTargetOptions = {} + ): Promise<Target> { + const {timeout: ms = 30000} = options; + return await firstValueFrom( + merge( + fromEmitterEvent(this, BrowserEvent.TargetCreated), + fromEmitterEvent(this, BrowserEvent.TargetChanged), + from(this.targets()) + ).pipe(filterAsync(predicate), raceWith(timeout(ms))) + ); + } + + /** + * Gets a list of all open {@link Page | pages} inside this {@link Browser}. + * + * If there ar multiple {@link BrowserContext | browser contexts}, this + * returns all {@link Page | pages} in all + * {@link BrowserContext | browser contexts}. + * + * @remarks Non-visible {@link Page | pages}, such as `"background_page"`, + * will not be listed here. You can find them using {@link Target.page}. + */ + async pages(): Promise<Page[]> { + const contextPages = await Promise.all( + this.browserContexts().map(context => { + return context.pages(); + }) + ); + // Flatten array. + return contextPages.reduce((acc, x) => { + return acc.concat(x); + }, []); + } + + /** + * Gets a string representing this {@link Browser | browser's} name and + * version. + * + * For headless browser, this is similar to `"HeadlessChrome/61.0.3153.0"`. For + * non-headless or new-headless, this is similar to `"Chrome/61.0.3153.0"`. For + * Firefox, it is similar to `"Firefox/116.0a1"`. + * + * The format of {@link Browser.version} might change with future releases of + * browsers. + */ + abstract version(): Promise<string>; + + /** + * Gets this {@link Browser | browser's} original user agent. + * + * {@link Page | Pages} can override the user agent with + * {@link Page.setUserAgent}. + * + */ + abstract userAgent(): Promise<string>; + + /** + * Closes this {@link Browser | browser} and all associated + * {@link Page | pages}. + */ + abstract close(): Promise<void>; + + /** + * Disconnects Puppeteer from this {@link Browser | browser}, but leaves the + * process running. + */ + abstract disconnect(): Promise<void>; + + /** + * Whether Puppeteer is connected to this {@link Browser | browser}. + * + * @deprecated Use {@link Browser | Browser.connected}. + */ + isConnected(): boolean { + return this.connected; + } + + /** + * Whether Puppeteer is connected to this {@link Browser | browser}. + */ + abstract get connected(): boolean; + + /** @internal */ + [disposeSymbol](): void { + return void this.close().catch(debugError); + } + + /** @internal */ + [asyncDisposeSymbol](): Promise<void> { + return this.close(); + } + + /** + * @internal + */ + abstract get protocol(): ProtocolType; + + /** + * Get debug information from Puppeteer. + * + * @remarks + * + * Currently, includes pending protocol calls. In the future, we might add more info. + * + * @public + * @experimental + */ + abstract get debugInfo(): DebugInfo; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/BrowserContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/BrowserContext.ts new file mode 100644 index 0000000000..79335eb9ed --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/BrowserContext.ts @@ -0,0 +1,224 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {EventEmitter, type EventType} from '../common/EventEmitter.js'; +import {debugError} from '../common/util.js'; +import {asyncDisposeSymbol, disposeSymbol} from '../util/disposable.js'; + +import type {Browser, Permission, WaitForTargetOptions} from './Browser.js'; +import type {Page} from './Page.js'; +import type {Target} from './Target.js'; + +/** + * @public + */ +export const enum BrowserContextEvent { + /** + * Emitted when the url of a target inside the browser context changes. + * Contains a {@link Target} instance. + */ + TargetChanged = 'targetchanged', + + /** + * Emitted when a target is created within the browser context, for example + * when a new page is opened by + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/open | window.open} + * or by {@link BrowserContext.newPage | browserContext.newPage} + * + * Contains a {@link Target} instance. + */ + TargetCreated = 'targetcreated', + /** + * Emitted when a target is destroyed within the browser context, for example + * when a page is closed. Contains a {@link Target} instance. + */ + TargetDestroyed = 'targetdestroyed', +} + +export { + /** + * @deprecated Use {@link BrowserContextEvent} + */ + BrowserContextEvent as BrowserContextEmittedEvents, +}; + +/** + * @public + */ +export interface BrowserContextEvents extends Record<EventType, unknown> { + [BrowserContextEvent.TargetChanged]: Target; + [BrowserContextEvent.TargetCreated]: Target; + [BrowserContextEvent.TargetDestroyed]: Target; +} + +/** + * {@link BrowserContext} represents individual sessions 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}. + * + * {@link BrowserContext} {@link EventEmitter | emits} various events which are + * documented in the {@link BrowserContextEvent} enum. + * + * If a {@link Page | page} opens another {@link Page | page}, e.g. using + * `window.open`, the popup will belong to the parent {@link Page.browserContext + * | page's browser context}. + * + * @example Creating an incognito {@link BrowserContext | browser context}: + * + * ```ts + * // Create a new incognito browser context + * const context = await browser.createIncognitoBrowserContext(); + * // Create a new page inside context. + * const page = await context.newPage(); + * // ... do stuff with page ... + * await page.goto('https://example.com'); + * // Dispose context once it's no longer needed. + * await context.close(); + * ``` + * + * @public + */ + +export abstract class BrowserContext extends EventEmitter<BrowserContextEvents> { + /** + * @internal + */ + constructor() { + super(); + } + + /** + * Gets all active {@link Target | targets} inside this + * {@link BrowserContext | browser context}. + */ + abstract targets(): Target[]; + + /** + * Waits until a {@link Target | target} matching the given `predicate` + * appears and returns it. + * + * This will look all open {@link BrowserContext | browser contexts}. + * + * @example Finding a target for a page opened via `window.open`: + * + * ```ts + * await page.evaluate(() => window.open('https://www.example.com/')); + * const newWindowTarget = await browserContext.waitForTarget( + * target => target.url() === 'https://www.example.com/' + * ); + * ``` + */ + abstract waitForTarget( + predicate: (x: Target) => boolean | Promise<boolean>, + options?: WaitForTargetOptions + ): Promise<Target>; + + /** + * Gets a list of all open {@link Page | pages} inside this + * {@link BrowserContext | browser context}. + * + * @remarks Non-visible {@link Page | pages}, such as `"background_page"`, + * will not be listed here. You can find them using {@link Target.page}. + */ + abstract pages(): Promise<Page[]>; + + /** + * Whether this {@link BrowserContext | browser context} is incognito. + * + * The {@link Browser.defaultBrowserContext | default browser context} is the + * only non-incognito browser context. + */ + abstract isIncognito(): boolean; + + /** + * Grants this {@link BrowserContext | browser context} the given + * `permissions` within the given `origin`. + * + * @example Overriding permissions in the + * {@link Browser.defaultBrowserContext | default browser context}: + * + * ```ts + * const context = browser.defaultBrowserContext(); + * await context.overridePermissions('https://html5demos.com', [ + * 'geolocation', + * ]); + * ``` + * + * @param origin - The origin to grant permissions to, e.g. + * "https://example.com". + * @param permissions - An array of permissions to grant. All permissions that + * are not listed here will be automatically denied. + */ + abstract overridePermissions( + origin: string, + permissions: Permission[] + ): Promise<void>; + + /** + * Clears all permission overrides for this + * {@link BrowserContext | browser context}. + * + * @example Clearing overridden permissions in the + * {@link Browser.defaultBrowserContext | default browser context}: + * + * ```ts + * const context = browser.defaultBrowserContext(); + * context.overridePermissions('https://example.com', ['clipboard-read']); + * // do stuff .. + * context.clearPermissionOverrides(); + * ``` + */ + abstract clearPermissionOverrides(): Promise<void>; + + /** + * Creates a new {@link Page | page} in this + * {@link BrowserContext | browser context}. + */ + abstract newPage(): Promise<Page>; + + /** + * Gets the {@link Browser | browser} associated with this + * {@link BrowserContext | browser context}. + */ + abstract browser(): Browser; + + /** + * Closes this {@link BrowserContext | browser context} and all associated + * {@link Page | pages}. + * + * @remarks The + * {@link Browser.defaultBrowserContext | default browser context} cannot be + * closed. + */ + abstract close(): Promise<void>; + + /** + * Whether this {@link BrowserContext | browser context} is closed. + */ + get closed(): boolean { + return !this.browser().browserContexts().includes(this); + } + + /** + * Identifier for this {@link BrowserContext | browser context}. + */ + get id(): string | undefined { + return undefined; + } + + /** @internal */ + [disposeSymbol](): void { + return void this.close().catch(debugError); + } + + /** @internal */ + [asyncDisposeSymbol](): Promise<void> { + return this.close(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/CDPSession.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/CDPSession.ts new file mode 100644 index 0000000000..8bdf96f954 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/CDPSession.ts @@ -0,0 +1,121 @@ +import type {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js'; + +import type {Connection} from '../cdp/Connection.js'; +import {EventEmitter, type EventType} from '../common/EventEmitter.js'; + +/** + * @public + */ +export type CDPEvents = { + [Property in keyof ProtocolMapping.Events]: ProtocolMapping.Events[Property][0]; +}; + +/** + * Events that the CDPSession class emits. + * + * @public + */ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace CDPSessionEvent { + /** @internal */ + export const Disconnected = Symbol('CDPSession.Disconnected'); + /** @internal */ + export const Swapped = Symbol('CDPSession.Swapped'); + /** + * Emitted when the session is ready to be configured during the auto-attach + * process. Right after the event is handled, the session will be resumed. + * + * @internal + */ + export const Ready = Symbol('CDPSession.Ready'); + export const SessionAttached = 'sessionattached' as const; + export const SessionDetached = 'sessiondetached' as const; +} + +/** + * @public + */ +export interface CDPSessionEvents + extends CDPEvents, + Record<EventType, unknown> { + /** @internal */ + [CDPSessionEvent.Disconnected]: undefined; + /** @internal */ + [CDPSessionEvent.Swapped]: CDPSession; + /** @internal */ + [CDPSessionEvent.Ready]: CDPSession; + [CDPSessionEvent.SessionAttached]: CDPSession; + [CDPSessionEvent.SessionDetached]: CDPSession; +} + +/** + * @public + */ +export interface CommandOptions { + timeout: number; +} + +/** + * The `CDPSession` instances are used to talk raw Chrome Devtools Protocol. + * + * @remarks + * + * Protocol methods can be called with {@link CDPSession.send} method and protocol + * events can be subscribed to with `CDPSession.on` method. + * + * Useful links: {@link https://chromedevtools.github.io/devtools-protocol/ | DevTools Protocol Viewer} + * and {@link https://github.com/aslushnikov/getting-started-with-cdp/blob/HEAD/README.md | Getting Started with DevTools Protocol}. + * + * @example + * + * ```ts + * const client = await page.target().createCDPSession(); + * await client.send('Animation.enable'); + * client.on('Animation.animationCreated', () => + * console.log('Animation created!') + * ); + * const response = await client.send('Animation.getPlaybackRate'); + * console.log('playback rate is ' + response.playbackRate); + * await client.send('Animation.setPlaybackRate', { + * playbackRate: response.playbackRate / 2, + * }); + * ``` + * + * @public + */ +export abstract class CDPSession extends EventEmitter<CDPSessionEvents> { + /** + * @internal + */ + constructor() { + super(); + } + + abstract connection(): Connection | undefined; + + /** + * Parent session in terms of CDP's auto-attach mechanism. + * + * @internal + */ + parentSession(): CDPSession | undefined { + return undefined; + } + + abstract send<T extends keyof ProtocolMapping.Commands>( + method: T, + params?: ProtocolMapping.Commands[T]['paramsType'][0], + options?: CommandOptions + ): Promise<ProtocolMapping.Commands[T]['returnType']>; + + /** + * Detaches the cdpSession from the target. Once detached, the cdpSession object + * won't emit any events and can't be used to send messages. + */ + abstract detach(): Promise<void>; + + /** + * Returns the session's id. + */ + abstract id(): string; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Dialog.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Dialog.ts new file mode 100644 index 0000000000..352337f30f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Dialog.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import {assert} from '../util/assert.js'; + +/** + * Dialog instances are dispatched by the {@link Page} via the `dialog` event. + * + * @remarks + * + * @example + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * page.on('dialog', async dialog => { + * console.log(dialog.message()); + * await dialog.dismiss(); + * await browser.close(); + * }); + * page.evaluate(() => alert('1')); + * })(); + * ``` + * + * @public + */ +export abstract class Dialog { + #type: Protocol.Page.DialogType; + #message: string; + #defaultValue: string; + #handled = false; + + /** + * @internal + */ + constructor( + type: Protocol.Page.DialogType, + message: string, + defaultValue = '' + ) { + this.#type = type; + this.#message = message; + this.#defaultValue = defaultValue; + } + + /** + * The type of the dialog. + */ + type(): Protocol.Page.DialogType { + return this.#type; + } + + /** + * The message displayed in the dialog. + */ + message(): string { + return this.#message; + } + + /** + * The default value of the prompt, or an empty string if the dialog + * is not a `prompt`. + */ + defaultValue(): string { + return this.#defaultValue; + } + + /** + * @internal + */ + protected abstract handle(options: { + accept: boolean; + text?: string; + }): Promise<void>; + + /** + * A promise that resolves when the dialog has been accepted. + * + * @param promptText - optional text that will be entered in the dialog + * prompt. Has no effect if the dialog's type is not `prompt`. + * + */ + async accept(promptText?: string): Promise<void> { + assert(!this.#handled, 'Cannot accept dialog which is already handled!'); + this.#handled = true; + await this.handle({ + accept: true, + text: promptText, + }); + } + + /** + * A promise which will resolve once the dialog has been dismissed + */ + async dismiss(): Promise<void> { + assert(!this.#handled, 'Cannot dismiss dialog which is already handled!'); + this.#handled = true; + await this.handle({ + accept: false, + }); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandle.ts new file mode 100644 index 0000000000..43fec58e37 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandle.ts @@ -0,0 +1,1580 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {Frame} from '../api/Frame.js'; +import {getQueryHandlerAndSelector} from '../common/GetQueryHandler.js'; +import {LazyArg} from '../common/LazyArg.js'; +import type { + ElementFor, + EvaluateFuncWith, + HandleFor, + HandleOr, + NodeFor, +} from '../common/types.js'; +import type {KeyInput} from '../common/USKeyboardLayout.js'; +import { + debugError, + 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 { + KeyboardTypeOptions, + KeyPressOptions, + MouseClickOptions, +} from './Input.js'; +import {JSHandle} from './JSHandle.js'; +import type {ScreenshotOptions, WaitForSelectorOptions} from './Page.js'; + +/** + * @public + */ +export type Quad = [Point, Point, Point, Point]; + +/** + * @public + */ +export interface BoxModel { + content: Quad; + padding: Quad; + border: Quad; + margin: Quad; + width: number; + height: number; +} + +/** + * @public + */ +export interface BoundingBox extends Point { + /** + * the width of the element in pixels. + */ + width: number; + /** + * the height of the element in pixels. + */ + height: number; +} + +/** + * @public + */ +export interface Offset { + /** + * x-offset for the clickable point relative to the top-left corner of the border box. + */ + x: number; + /** + * y-offset for the clickable point relative to the top-left corner of the border box. + */ + y: number; +} + +/** + * @public + */ +export interface ClickOptions extends MouseClickOptions { + /** + * Offset for the clickable point relative to the top-left corner of the border box. + */ + offset?: Offset; +} + +/** + * @public + */ +export interface Point { + x: number; + y: number; +} + +/** + * @public + */ +export interface ElementScreenshotOptions extends ScreenshotOptions { + /** + * @defaultValue `true` + */ + scrollIntoView?: boolean; +} + +/** + * ElementHandle represents an in-page DOM element. + * + * @remarks + * ElementHandles can be created with the {@link Page.$} method. + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://example.com'); + * const hrefElement = await page.$('a'); + * await hrefElement.click(); + * // ... + * })(); + * ``` + * + * ElementHandle prevents the DOM element from being garbage-collected unless the + * handle is {@link JSHandle.dispose | disposed}. ElementHandles are auto-disposed + * when their origin frame gets navigated. + * + * ElementHandle instances can be used as arguments in {@link Page.$eval} and + * {@link Page.evaluate} methods. + * + * If you're using TypeScript, ElementHandle takes a generic argument that + * denotes the type of element the handle is holding within. For example, if you + * have a handle to a `<select>` element, you can type it as + * `ElementHandle<HTMLSelectElement>` and you get some nicer type checks. + * + * @public + */ +export abstract class ElementHandle< + ElementType extends Node = Element, +> extends JSHandle<ElementType> { + /** + * @internal + */ + declare [_isElementHandle]: boolean; + + /** + * A given method will have it's `this` replaced with an isolated version of + * `this` when decorated with this decorator. + * + * All changes of isolated `this` are reflected on the actual `this`. + * + * @internal + */ + static bindIsolatedHandle<This extends ElementHandle<Node>>( + target: (this: This, ...args: any[]) => Promise<any>, + _: unknown + ): typeof target { + return async function (...args) { + // If the handle is already isolated, then we don't need to adopt it + // again. + if (this.realm === this.frame.isolatedRealm()) { + return await target.call(this, ...args); + } + using adoptedThis = await this.frame.isolatedRealm().adoptHandle(this); + const result = await target.call(adoptedThis, ...args); + // If the function returns `adoptedThis`, then we return `this`. + if (result === adoptedThis) { + return this; + } + // If the function returns a handle, transfer it into the current realm. + if (result instanceof JSHandle) { + return await this.realm.transferHandle(result); + } + // If the function returns an array of handlers, transfer them into the + // current realm. + if (Array.isArray(result)) { + await Promise.all( + result.map(async (item, index, result) => { + if (item instanceof JSHandle) { + result[index] = await this.realm.transferHandle(item); + } + }) + ); + } + if (result instanceof Map) { + await Promise.all( + [...result.entries()].map(async ([key, value]) => { + if (value instanceof JSHandle) { + result.set(key, await this.realm.transferHandle(value)); + } + }) + ); + } + return result; + }; + } + + /** + * @internal + */ + protected readonly handle; + + /** + * @internal + */ + constructor(handle: JSHandle<ElementType>) { + super(); + this.handle = handle; + this[_isElementHandle] = true; + } + + /** + * @internal + */ + override get id(): string | undefined { + return this.handle.id; + } + + /** + * @internal + */ + override get disposed(): boolean { + return this.handle.disposed; + } + + /** + * @internal + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + override async getProperty<K extends keyof ElementType>( + propertyName: HandleOr<K> + ): Promise<HandleFor<ElementType[K]>> { + return await this.handle.getProperty(propertyName); + } + + /** + * @internal + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + override async getProperties(): Promise<Map<string, JSHandle>> { + return await this.handle.getProperties(); + } + + /** + * @internal + */ + override async evaluate< + Params extends unknown[], + Func extends EvaluateFuncWith<ElementType, Params> = EvaluateFuncWith< + ElementType, + Params + >, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluate.name, + pageFunction + ); + return await this.handle.evaluate(pageFunction, ...args); + } + + /** + * @internal + */ + override async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFuncWith<ElementType, Params> = EvaluateFuncWith< + ElementType, + Params + >, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluateHandle.name, + pageFunction + ); + return await this.handle.evaluateHandle(pageFunction, ...args); + } + + /** + * @internal + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + override async jsonValue(): Promise<ElementType> { + return await this.handle.jsonValue(); + } + + /** + * @internal + */ + override toString(): string { + return this.handle.toString(); + } + + /** + * @internal + */ + override remoteObject(): Protocol.Runtime.RemoteObject { + return this.handle.remoteObject(); + } + + /** + * @internal + */ + override dispose(): Promise<void> { + return this.handle.dispose(); + } + + /** + * @internal + */ + override asElement(): ElementHandle<ElementType> { + return this; + } + + /** + * Frame corresponding to the current handle. + */ + abstract get frame(): Frame; + + /** + * Queries the current element for an element matching the given selector. + * + * @param selector - The selector to query for. + * @returns A {@link ElementHandle | element handle} to the first element + * matching the given selector. Otherwise, `null`. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async $<Selector extends string>( + selector: Selector + ): Promise<ElementHandle<NodeFor<Selector>> | null> { + const {updatedSelector, QueryHandler} = + getQueryHandlerAndSelector(selector); + return (await QueryHandler.queryOne( + this, + updatedSelector + )) as ElementHandle<NodeFor<Selector>> | null; + } + + /** + * Queries the current element for all elements matching the given selector. + * + * @param selector - The selector to query for. + * @returns An array of {@link ElementHandle | element handles} that point to + * elements matching the given selector. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async $$<Selector extends string>( + selector: Selector + ): Promise<Array<ElementHandle<NodeFor<Selector>>>> { + const {updatedSelector, QueryHandler} = + getQueryHandlerAndSelector(selector); + return await (AsyncIterableUtil.collect( + QueryHandler.queryAll(this, updatedSelector) + ) as Promise<Array<ElementHandle<NodeFor<Selector>>>>); + } + + /** + * Runs the given function on the first element matching the given selector in + * the current element. + * + * If the given function returns a promise, then this method will wait till + * the promise resolves. + * + * @example + * + * ```ts + * const tweetHandle = await page.$('.tweet'); + * expect(await tweetHandle.$eval('.like', node => node.innerText)).toBe( + * '100' + * ); + * expect(await tweetHandle.$eval('.retweets', node => node.innerText)).toBe( + * '10' + * ); + * ``` + * + * @param selector - The selector to query for. + * @param pageFunction - The function to be evaluated in this element's page's + * context. The first element matching the selector will be passed in as the + * first argument. + * @param args - Additional arguments to pass to `pageFunction`. + * @returns A promise to the result of the function. + */ + async $eval< + Selector extends string, + Params extends unknown[], + Func extends EvaluateFuncWith<NodeFor<Selector>, Params> = EvaluateFuncWith< + NodeFor<Selector>, + Params + >, + >( + selector: Selector, + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + pageFunction = withSourcePuppeteerURLIfNone(this.$eval.name, pageFunction); + using elementHandle = await this.$(selector); + if (!elementHandle) { + throw new Error( + `Error: failed to find element matching selector "${selector}"` + ); + } + return await elementHandle.evaluate(pageFunction, ...args); + } + + /** + * Runs the given function on an array of elements matching the given selector + * in the current element. + * + * If the given function returns a promise, then this method will wait till + * the promise resolves. + * + * @example + * HTML: + * + * ```html + * <div class="feed"> + * <div class="tweet">Hello!</div> + * <div class="tweet">Hi!</div> + * </div> + * ``` + * + * JavaScript: + * + * ```ts + * const feedHandle = await page.$('.feed'); + * expect( + * await feedHandle.$$eval('.tweet', nodes => nodes.map(n => n.innerText)) + * ).toEqual(['Hello!', 'Hi!']); + * ``` + * + * @param selector - The selector to query for. + * @param pageFunction - The function to be evaluated in the element's page's + * context. An array of elements matching the given selector will be passed to + * the function as its first argument. + * @param args - Additional arguments to pass to `pageFunction`. + * @returns A promise to the result of the function. + */ + async $$eval< + Selector extends string, + Params extends unknown[], + Func extends EvaluateFuncWith< + Array<NodeFor<Selector>>, + Params + > = EvaluateFuncWith<Array<NodeFor<Selector>>, Params>, + >( + selector: Selector, + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + pageFunction = withSourcePuppeteerURLIfNone(this.$$eval.name, pageFunction); + const results = await this.$$(selector); + using elements = await this.evaluateHandle( + (_, ...elements) => { + return elements; + }, + ...results + ); + const [result] = await Promise.all([ + elements.evaluate(pageFunction, ...args), + ...results.map(results => { + return results.dispose(); + }), + ]); + return result; + } + + /** + * @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. + * + * Unlike {@link Frame.waitForSelector}, this method does not work across + * navigations or if the element is detached from DOM. + * + * @example + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * let currentURL; + * page + * .mainFrame() + * .waitForSelector('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 selector - The selector to query and wait for. + * @param options - Options for customizing waiting behavior. + * @returns An element matching the given selector. + * @throws Throws if an element matching the given selector doesn't appear. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async waitForSelector<Selector extends string>( + selector: Selector, + options: WaitForSelectorOptions = {} + ): Promise<ElementHandle<NodeFor<Selector>> | null> { + const {updatedSelector, QueryHandler} = + getQueryHandlerAndSelector(selector); + return (await QueryHandler.waitFor( + this, + updatedSelector, + options + )) as ElementHandle<NodeFor<Selector>> | null; + } + + async #checkVisibility(visibility: boolean): Promise<boolean> { + return await this.evaluate( + async (element, PuppeteerUtil, visibility) => { + return Boolean(PuppeteerUtil.checkVisibility(element, visibility)); + }, + LazyArg.create(context => { + return context.puppeteerUtil; + }), + visibility + ); + } + + /** + * Checks if an element is visible using the same mechanism as + * {@link ElementHandle.waitForSelector}. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async isVisible(): Promise<boolean> { + return await this.#checkVisibility(true); + } + + /** + * Checks if an element is hidden using the same mechanism as + * {@link ElementHandle.waitForSelector}. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async isHidden(): Promise<boolean> { + return await this.#checkVisibility(false); + } + + /** + * @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 + * + * ```ts + * const element: ElementHandle<Element> = await page.$( + * '.class-name-of-anchor' + * ); + * // DO NOT DISPOSE `element`, this will be always be the same handle. + * const anchor: ElementHandle<HTMLAnchorElement> = + * await element.toElement('a'); + * ``` + * + * @param tagName - The tag name of the desired element type. + * @throws An error if the handle does not match. **The handle will not be + * automatically disposed.** + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async toElement< + K extends keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap, + >(tagName: K): Promise<HandleFor<ElementFor<K>>> { + const isMatchingTagName = await this.evaluate((node, tagName) => { + return node.nodeName === tagName.toUpperCase(); + }, tagName); + if (!isMatchingTagName) { + throw new Error(`Element is not a(n) \`${tagName}\` element`); + } + return this as unknown as HandleFor<ElementFor<K>>; + } + + /** + * Resolves the frame associated with the element, if any. Always exists for + * HTMLIFrameElements. + */ + abstract contentFrame(this: ElementHandle<HTMLIFrameElement>): Promise<Frame>; + abstract contentFrame(): Promise<Frame | null>; + + /** + * Returns the middle point within an element unless a specific offset is provided. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async clickablePoint(offset?: Offset): Promise<Point> { + const box = await this.#clickableBox(); + if (!box) { + throw new Error('Node is either not clickable or not an Element'); + } + if (offset !== undefined) { + return { + x: box.x + offset.x, + y: box.y + offset.y, + }; + } + return { + x: box.x + box.width / 2, + y: box.y + box.height / 2, + }; + } + + /** + * This method scrolls element into view if needed, and then + * uses {@link Page} to hover over the center of the element. + * If the element is detached from DOM, the method throws an error. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async hover(this: ElementHandle<Element>): Promise<void> { + await this.scrollIntoViewIfNeeded(); + const {x, y} = await this.clickablePoint(); + await this.frame.page().mouse.move(x, y); + } + + /** + * This method scrolls element into view if needed, and then + * uses {@link Page | Page.mouse} to click in the center of the element. + * If the element is detached from DOM, the method throws an error. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async click( + this: ElementHandle<Element>, + options: Readonly<ClickOptions> = {} + ): Promise<void> { + await this.scrollIntoViewIfNeeded(); + const {x, y} = await this.clickablePoint(options.offset); + await this.frame.page().mouse.click(x, y, options); + } + + /** + * Drags an element over the given element or point. + * + * @returns DEPRECATED. When drag interception is enabled, the drag payload is + * returned. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async drag( + this: ElementHandle<Element>, + target: Point | ElementHandle<Element> + ): Promise<Protocol.Input.DragData | void> { + await this.scrollIntoViewIfNeeded(); + const page = this.frame.page(); + if (page.isDragInterceptionEnabled()) { + const source = await this.clickablePoint(); + if (target instanceof ElementHandle) { + target = await target.clickablePoint(); + } + return await page.mouse.drag(source, target); + } + try { + if (!page._isDragging) { + page._isDragging = true; + await this.hover(); + await page.mouse.down(); + } + if (target instanceof ElementHandle) { + await target.hover(); + } else { + await page.mouse.move(target.x, target.y); + } + } catch (error) { + page._isDragging = false; + throw error; + } + } + + /** + * @deprecated Do not use. `dragenter` will automatically be performed during dragging. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async dragEnter( + this: ElementHandle<Element>, + data: Protocol.Input.DragData = {items: [], dragOperationsMask: 1} + ): Promise<void> { + const page = this.frame.page(); + await this.scrollIntoViewIfNeeded(); + const target = await this.clickablePoint(); + await page.mouse.dragEnter(target, data); + } + + /** + * @deprecated Do not use. `dragover` will automatically be performed during dragging. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async dragOver( + this: ElementHandle<Element>, + data: Protocol.Input.DragData = {items: [], dragOperationsMask: 1} + ): Promise<void> { + const page = this.frame.page(); + await this.scrollIntoViewIfNeeded(); + const target = await this.clickablePoint(); + await page.mouse.dragOver(target, data); + } + + /** + * Drops the given element onto the current one. + */ + async drop( + this: ElementHandle<Element>, + element: ElementHandle<Element> + ): Promise<void>; + + /** + * @deprecated No longer supported. + */ + async drop( + this: ElementHandle<Element>, + data?: Protocol.Input.DragData + ): Promise<void>; + + /** + * @internal + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async drop( + this: ElementHandle<Element>, + dataOrElement: ElementHandle<Element> | Protocol.Input.DragData = { + items: [], + dragOperationsMask: 1, + } + ): Promise<void> { + const page = this.frame.page(); + if ('items' in dataOrElement) { + await this.scrollIntoViewIfNeeded(); + const destination = await this.clickablePoint(); + await page.mouse.drop(destination, dataOrElement); + } else { + // Note if the rest errors, we still want dragging off because the errors + // is most likely something implying the mouse is no longer dragging. + await dataOrElement.drag(this); + page._isDragging = false; + await page.mouse.up(); + } + } + + /** + * @deprecated Use `ElementHandle.drop` instead. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async dragAndDrop( + this: ElementHandle<Element>, + target: ElementHandle<Node>, + options?: {delay: number} + ): Promise<void> { + const page = this.frame.page(); + assert( + page.isDragInterceptionEnabled(), + 'Drag Interception is not enabled!' + ); + await this.scrollIntoViewIfNeeded(); + const startPoint = await this.clickablePoint(); + const targetPoint = await target.clickablePoint(); + await page.mouse.dragAndDrop(startPoint, targetPoint, options); + } + + /** + * Triggers a `change` and `input` event once all the provided options have been + * selected. If there's no `<select>` element matching `selector`, the method + * throws an error. + * + * @example + * + * ```ts + * handle.select('blue'); // single selection + * handle.select('red', 'green', 'blue'); // multiple selections + * ``` + * + * @param values - Values of options to select. If the `<select>` has the + * `multiple` attribute, all values are considered, otherwise only the first + * one is taken into account. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async select(...values: string[]): Promise<string[]> { + for (const value of values) { + assert( + isString(value), + 'Values must be strings. Found value "' + + value + + '" of type "' + + typeof value + + '"' + ); + } + + return await this.evaluate((element, vals): string[] => { + const values = new Set(vals); + if (!(element instanceof HTMLSelectElement)) { + throw new Error('Element is not a <select> element.'); + } + + const selectedValues = new Set<string>(); + if (!element.multiple) { + for (const option of element.options) { + option.selected = false; + } + for (const option of element.options) { + if (values.has(option.value)) { + option.selected = true; + selectedValues.add(option.value); + break; + } + } + } else { + for (const option of element.options) { + option.selected = values.has(option.value); + if (option.selected) { + selectedValues.add(option.value); + } + } + } + element.dispatchEvent(new Event('input', {bubbles: true})); + element.dispatchEvent(new Event('change', {bubbles: true})); + return [...selectedValues.values()]; + }, values); + } + + /** + * Sets the value of an + * {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input | input element} + * to the given file paths. + * + * @remarks This will not validate whether the file paths exists. Also, if a + * path is relative, then it is resolved against the + * {@link https://nodejs.org/api/process.html#process_process_cwd | current working directory}. + * For locals script connecting to remote chrome environments, paths must be + * absolute. + */ + abstract uploadFile( + this: ElementHandle<HTMLInputElement>, + ...paths: string[] + ): Promise<void>; + + /** + * This method scrolls element into view if needed, and then uses + * {@link Touchscreen.tap} to tap in the center of the element. + * If the element is detached from DOM, the method throws an error. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async tap(this: ElementHandle<Element>): Promise<void> { + await this.scrollIntoViewIfNeeded(); + const {x, y} = await this.clickablePoint(); + await this.frame.page().touchscreen.tap(x, y); + } + + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async touchStart(this: ElementHandle<Element>): Promise<void> { + await this.scrollIntoViewIfNeeded(); + const {x, y} = await this.clickablePoint(); + await this.frame.page().touchscreen.touchStart(x, y); + } + + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async touchMove(this: ElementHandle<Element>): Promise<void> { + await this.scrollIntoViewIfNeeded(); + const {x, y} = await this.clickablePoint(); + await this.frame.page().touchscreen.touchMove(x, y); + } + + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async touchEnd(this: ElementHandle<Element>): Promise<void> { + await this.scrollIntoViewIfNeeded(); + await this.frame.page().touchscreen.touchEnd(); + } + + /** + * Calls {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus | focus} on the element. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async focus(): Promise<void> { + await this.evaluate(element => { + if (!(element instanceof HTMLElement)) { + throw new Error('Cannot focus non-HTMLElement'); + } + return element.focus(); + }); + } + + /** + * Focuses the element, and then sends a `keydown`, `keypress`/`input`, and + * `keyup` event for each character in the text. + * + * To press a special key, like `Control` or `ArrowDown`, + * use {@link ElementHandle.press}. + * + * @example + * + * ```ts + * await elementHandle.type('Hello'); // Types instantly + * await elementHandle.type('World', {delay: 100}); // Types slower, like a user + * ``` + * + * @example + * An example of typing into a text field and then submitting the form: + * + * ```ts + * const elementHandle = await page.$('input'); + * await elementHandle.type('some text'); + * await elementHandle.press('Enter'); + * ``` + * + * @param options - Delay in milliseconds. Defaults to 0. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async type( + text: string, + options?: Readonly<KeyboardTypeOptions> + ): Promise<void> { + await this.focus(); + await this.frame.page().keyboard.type(text, options); + } + + /** + * Focuses the element, and then uses {@link Keyboard.down} and {@link Keyboard.up}. + * + * @remarks + * If `key` is a single character and no modifier keys besides `Shift` + * are being held down, a `keypress`/`input` event will also be generated. + * The `text` option can be specified to force an input event to be generated. + * + * **NOTE** Modifier keys DO affect `elementHandle.press`. Holding down `Shift` + * will type the text in upper case. + * + * @param key - Name of key to press, such as `ArrowLeft`. + * See {@link KeyInput} for a list of all key names. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async press( + key: KeyInput, + options?: Readonly<KeyPressOptions> + ): Promise<void> { + await this.focus(); + await this.frame.page().keyboard.press(key, options); + } + + async #clickableBox(): Promise<BoundingBox | null> { + const boxes = await this.evaluate(element => { + if (!(element instanceof Element)) { + return null; + } + return [...element.getClientRects()].map(rect => { + return {x: rect.x, y: rect.y, width: rect.width, height: rect.height}; + }); + }); + if (!boxes?.length) { + return null; + } + await this.#intersectBoundingBoxesWithFrame(boxes); + let frame = this.frame; + let parentFrame: Frame | null | undefined; + while ((parentFrame = frame?.parentFrame())) { + using handle = await frame.frameElement(); + if (!handle) { + throw new Error('Unsupported frame type'); + } + const parentBox = await handle.evaluate(element => { + // Element is not visible. + if (element.getClientRects().length === 0) { + return null; + } + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return { + left: + rect.left + + parseInt(style.paddingLeft, 10) + + parseInt(style.borderLeftWidth, 10), + top: + rect.top + + parseInt(style.paddingTop, 10) + + parseInt(style.borderTopWidth, 10), + }; + }); + if (!parentBox) { + return null; + } + for (const box of boxes) { + box.x += parentBox.left; + box.y += parentBox.top; + } + await handle.#intersectBoundingBoxesWithFrame(boxes); + frame = parentFrame; + } + const box = boxes.find(box => { + return box.width >= 1 && box.height >= 1; + }); + if (!box) { + return null; + } + return { + x: box.x, + y: box.y, + height: box.height, + width: box.width, + }; + } + + async #intersectBoundingBoxesWithFrame(boxes: BoundingBox[]) { + const {documentWidth, documentHeight} = await this.frame + .isolatedRealm() + .evaluate(() => { + return { + documentWidth: document.documentElement.clientWidth, + documentHeight: document.documentElement.clientHeight, + }; + }); + for (const box of boxes) { + intersectBoundingBox(box, documentWidth, documentHeight); + } + } + + /** + * This method returns the bounding box of the element (relative to the main frame), + * or `null` if the element is {@link https://drafts.csswg.org/css-display-4/#box-generation | not part of the layout} + * (example: `display: none`). + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async boundingBox(): Promise<BoundingBox | null> { + const box = await this.evaluate(element => { + if (!(element instanceof Element)) { + return null; + } + // Element is not visible. + if (element.getClientRects().length === 0) { + return null; + } + const rect = element.getBoundingClientRect(); + return {x: rect.x, y: rect.y, width: rect.width, height: rect.height}; + }); + if (!box) { + return null; + } + const offset = await this.#getTopLeftCornerOfFrame(); + if (!offset) { + return null; + } + return { + x: box.x + offset.x, + y: box.y + offset.y, + height: box.height, + width: box.width, + }; + } + + /** + * This method returns boxes of the element, + * or `null` if the element is {@link https://drafts.csswg.org/css-display-4/#box-generation | not part of the layout} + * (example: `display: none`). + * + * @remarks + * + * Boxes are represented as an array of points; + * Each Point is an object `{x, y}`. Box points are sorted clock-wise. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async boxModel(): Promise<BoxModel | null> { + const model = await this.evaluate(element => { + if (!(element instanceof Element)) { + return null; + } + // Element is not visible. + if (element.getClientRects().length === 0) { + return null; + } + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + const offsets = { + padding: { + left: parseInt(style.paddingLeft, 10), + top: parseInt(style.paddingTop, 10), + right: parseInt(style.paddingRight, 10), + bottom: parseInt(style.paddingBottom, 10), + }, + margin: { + left: -parseInt(style.marginLeft, 10), + top: -parseInt(style.marginTop, 10), + right: -parseInt(style.marginRight, 10), + bottom: -parseInt(style.marginBottom, 10), + }, + border: { + left: parseInt(style.borderLeft, 10), + top: parseInt(style.borderTop, 10), + right: parseInt(style.borderRight, 10), + bottom: parseInt(style.borderBottom, 10), + }, + }; + const border: Quad = [ + {x: rect.left, y: rect.top}, + {x: rect.left + rect.width, y: rect.top}, + {x: rect.left + rect.width, y: rect.top + rect.bottom}, + {x: rect.left, y: rect.top + rect.bottom}, + ]; + const padding = transformQuadWithOffsets(border, offsets.border); + const content = transformQuadWithOffsets(padding, offsets.padding); + const margin = transformQuadWithOffsets(border, offsets.margin); + return { + content, + padding, + border, + margin, + width: rect.width, + height: rect.height, + }; + + function transformQuadWithOffsets( + quad: Quad, + offsets: {top: number; left: number; right: number; bottom: number} + ): Quad { + return [ + { + x: quad[0].x + offsets.left, + y: quad[0].y + offsets.top, + }, + { + x: quad[1].x - offsets.right, + y: quad[1].y + offsets.top, + }, + { + x: quad[2].x - offsets.right, + y: quad[2].y - offsets.bottom, + }, + { + x: quad[3].x + offsets.left, + y: quad[3].y - offsets.bottom, + }, + ]; + } + }); + if (!model) { + return null; + } + const offset = await this.#getTopLeftCornerOfFrame(); + if (!offset) { + return null; + } + for (const attribute of [ + 'content', + 'padding', + 'border', + 'margin', + ] as const) { + for (const point of model[attribute]) { + point.x += offset.x; + point.y += offset.y; + } + } + return model; + } + + async #getTopLeftCornerOfFrame() { + const point = {x: 0, y: 0}; + let frame = this.frame; + let parentFrame: Frame | null | undefined; + while ((parentFrame = frame?.parentFrame())) { + using handle = await frame.frameElement(); + if (!handle) { + throw new Error('Unsupported frame type'); + } + const parentBox = await handle.evaluate(element => { + // Element is not visible. + if (element.getClientRects().length === 0) { + return null; + } + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return { + left: + rect.left + + parseInt(style.paddingLeft, 10) + + parseInt(style.borderLeftWidth, 10), + top: + rect.top + + parseInt(style.paddingTop, 10) + + parseInt(style.borderTopWidth, 10), + }; + }); + if (!parentBox) { + return null; + } + point.x += parentBox.left; + point.y += parentBox.top; + frame = parentFrame; + } + return point; + } + + /** + * This method scrolls element into view if needed, and then uses + * {@link Page.(screenshot:2) } to take a screenshot of the element. + * If the element is detached from DOM, the method throws an error. + */ + async screenshot( + options: Readonly<ScreenshotOptions> & {encoding: 'base64'} + ): Promise<string>; + async screenshot(options?: Readonly<ScreenshotOptions>): Promise<Buffer>; + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async screenshot( + this: ElementHandle<Element>, + options: Readonly<ElementScreenshotOptions> = {} + ): Promise<string | Buffer> { + const {scrollIntoView = true} = options; + + let clip = await this.#nonEmptyVisibleBoundingBox(); + + 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(); + + // We measure again just in case. + clip = await this.#nonEmptyVisibleBoundingBox(); + } + + const [pageLeft, pageTop] = await this.evaluate(() => { + if (!window.visualViewport) { + throw new Error('window.visualViewport is not supported.'); + } + return [ + window.visualViewport.pageLeft, + window.visualViewport.pageTop, + ] as const; + }); + clip.x += pageLeft; + clip.y += pageTop; + + return await page.screenshot({...options, clip}); + } + + async #nonEmptyVisibleBoundingBox() { + const box = await this.boundingBox(); + assert(box, 'Node is either not visible or not an HTMLElement'); + assert(box.width !== 0, 'Node has 0 width.'); + assert(box.height !== 0, 'Node has 0 height.'); + return box; + } + + /** + * @internal + */ + protected async assertConnectedElement(): Promise<void> { + const error = await this.evaluate(async element => { + if (!element.isConnected) { + return 'Node is detached from document'; + } + if (element.nodeType !== Node.ELEMENT_NODE) { + return 'Node is not of type HTMLElement'; + } + return; + }); + + if (error) { + throw new Error(error); + } + } + + /** + * @internal + */ + protected async scrollIntoViewIfNeeded( + this: ElementHandle<Element> + ): Promise<void> { + if ( + await this.isIntersectingViewport({ + threshold: 1, + }) + ) { + return; + } + await this.scrollIntoView(); + } + + /** + * Resolves to true if the element is visible in the current viewport. If an + * element is an SVG, we check if the svg owner element is in the viewport + * instead. See https://crbug.com/963246. + * + * @param options - Threshold for the intersection between 0 (no intersection) and 1 + * (full intersection). Defaults to 1. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async isIntersectingViewport( + this: ElementHandle<Element>, + options: { + threshold?: number; + } = {} + ): Promise<boolean> { + await this.assertConnectedElement(); + // eslint-disable-next-line rulesdir/use-using -- Returns `this`. + const handle = await this.#asSVGElementHandle(); + using target = handle && (await handle.#getOwnerSVGElement()); + return await ((target ?? this) as ElementHandle<Element>).evaluate( + async (element, threshold) => { + const visibleRatio = await new Promise<number>(resolve => { + const observer = new IntersectionObserver(entries => { + resolve(entries[0]!.intersectionRatio); + observer.disconnect(); + }); + observer.observe(element); + }); + return threshold === 1 ? visibleRatio === 1 : visibleRatio > threshold; + }, + options.threshold ?? 0 + ); + } + + /** + * Scrolls the element into view using either the automation protocol client + * or by calling element.scrollIntoView. + */ + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + async scrollIntoView(this: ElementHandle<Element>): Promise<void> { + await this.assertConnectedElement(); + await this.evaluate(async (element): Promise<void> => { + element.scrollIntoView({ + block: 'center', + inline: 'center', + behavior: 'instant', + }); + }); + } + + /** + * Returns true if an element is an SVGElement (included svg, path, rect + * etc.). + */ + async #asSVGElementHandle( + this: ElementHandle<Element> + ): Promise<ElementHandle<SVGElement> | null> { + if ( + await this.evaluate(element => { + return element instanceof SVGElement; + }) + ) { + return this as ElementHandle<SVGElement>; + } else { + return null; + } + } + + async #getOwnerSVGElement( + this: ElementHandle<SVGElement> + ): Promise<ElementHandle<SVGSVGElement>> { + // SVGSVGElement.ownerSVGElement === null. + return await this.evaluateHandle(element => { + if (element instanceof SVGSVGElement) { + return element; + } + return element.ownerSVGElement!; + }); + } + + /** + * If the element is a form input, you can use {@link ElementHandle.autofill} + * to test if the form is compatible with the browser's autofill + * implementation. Throws an error if the form cannot be autofilled. + * + * @remarks + * + * Currently, Puppeteer supports auto-filling credit card information only and + * in Chrome in the new headless and headful modes only. + * + * ```ts + * // Select an input on the credit card form. + * const name = await page.waitForSelector('form #name'); + * // Trigger autofill with the desired data. + * await name.autofill({ + * creditCard: { + * number: '4444444444444444', + * name: 'John Smith', + * expiryMonth: '01', + * expiryYear: '2030', + * cvc: '123', + * }, + * }); + * ``` + */ + abstract autofill(data: AutofillData): Promise<void>; +} + +/** + * @public + */ +export interface AutofillData { + creditCard: { + // See https://chromedevtools.github.io/devtools-protocol/tot/Autofill/#type-CreditCard. + number: string; + name: string; + expiryMonth: string; + expiryYear: string; + cvc: string; + }; +} + +function intersectBoundingBox( + box: BoundingBox, + width: number, + height: number +): void { + box.width = Math.max( + box.x >= 0 + ? Math.min(width - box.x, box.width) + : Math.min(width, box.width + box.x), + 0 + ); + box.height = Math.max( + box.y >= 0 + ? Math.min(height - box.y, box.height) + : Math.min(height, box.height + box.y), + 0 + ); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandleSymbol.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandleSymbol.ts new file mode 100644 index 0000000000..6e5087b773 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandleSymbol.ts @@ -0,0 +1,10 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @internal + */ +export const _isElementHandle = Symbol('_isElementHandle'); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Environment.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Environment.ts new file mode 100644 index 0000000000..c5a8d73d00 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Environment.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {CDPSession} from './CDPSession.js'; +import type {Realm} from './Realm.js'; + +/** + * @internal + */ +export interface Environment { + get client(): CDPSession; + mainRealm(): Realm; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Frame.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Frame.ts new file mode 100644 index 0000000000..757ec872c6 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Frame.ts @@ -0,0 +1,1218 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type Protocol from 'devtools-protocol'; + +import type {ClickOptions, ElementHandle} from '../api/ElementHandle.js'; +import type {HTTPResponse} from '../api/HTTPResponse.js'; +import type { + Page, + WaitForSelectorOptions, + 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'; +import {transposeIterableHandle} from '../common/HandleIterator.js'; +import {LazyArg} from '../common/LazyArg.js'; +import type { + Awaitable, + EvaluateFunc, + EvaluateFuncWith, + HandleFor, + NodeFor, +} from '../common/types.js'; +import { + importFSPromises, + withSourcePuppeteerURLIfNone, +} from '../common/util.js'; +import {assert} from '../util/assert.js'; +import {throwIfDisposed} from '../util/decorators.js'; + +import type {CDPSession} from './CDPSession.js'; +import type {KeyboardTypeOptions} from './Input.js'; +import { + FunctionLocator, + type Locator, + NodeLocator, +} from './locators/locators.js'; +import type {Realm} from './Realm.js'; + +/** + * @public + */ +export interface WaitForOptions { + /** + * Maximum wait time in milliseconds. Pass 0 to disable the timeout. + * + * The default value can be changed by using the + * {@link Page.setDefaultTimeout} or {@link Page.setDefaultNavigationTimeout} + * methods. + * + * @defaultValue `30000` + */ + timeout?: number; + /** + * When to consider waiting succeeds. Given an array of event strings, waiting + * is considered to be successful after all events have been fired. + * + * @defaultValue `'load'` + */ + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; +} + +/** + * @public + */ +export interface GoToOptions extends WaitForOptions { + /** + * If provided, it will take preference over the referer header value set by + * {@link Page.setExtraHTTPHeaders | page.setExtraHTTPHeaders()}. + */ + referer?: string; + /** + * If provided, it will take preference over the referer-policy header value + * set by {@link Page.setExtraHTTPHeaders | page.setExtraHTTPHeaders()}. + */ + referrerPolicy?: string; +} + +/** + * @public + */ +export interface FrameWaitForFunctionOptions { + /** + * An interval at which the `pageFunction` is executed, defaults to `raf`. If + * `polling` is a number, then it is treated as an interval in milliseconds at + * which the function would be executed. If `polling` is a string, then it can + * be one of the following values: + * + * - `raf` - to constantly execute `pageFunction` in `requestAnimationFrame` + * callback. This is the tightest polling mode which is suitable to observe + * styling changes. + * + * - `mutation` - to execute `pageFunction` on every DOM mutation. + */ + polling?: 'raf' | 'mutation' | number; + /** + * Maximum time to wait in milliseconds. Defaults to `30000` (30 seconds). + * Pass `0` to disable the timeout. Puppeteer's default timeout can be changed + * using {@link Page.setDefaultTimeout}. + */ + timeout?: number; + /** + * A signal object that allows you to cancel a waitForFunction call. + */ + signal?: AbortSignal; +} + +/** + * @public + */ +export interface FrameAddScriptTagOptions { + /** + * URL of the script to be added. + */ + url?: string; + /** + * Path to a JavaScript file to be injected into the frame. + * + * @remarks + * If `path` is a relative path, it is resolved relative to the current + * working directory (`process.cwd()` in Node.js). + */ + path?: string; + /** + * JavaScript to be injected into the frame. + */ + content?: string; + /** + * Sets the `type` of the script. Use `module` in order to load an ES2015 module. + */ + type?: string; + /** + * Sets the `id` of the script. + */ + id?: string; +} + +/** + * @public + */ +export interface FrameAddStyleTagOptions { + /** + * the URL of the CSS file to be added. + */ + url?: string; + /** + * The path to a CSS file to be injected into the frame. + * @remarks + * If `path` is a relative path, it is resolved relative to the current + * working directory (`process.cwd()` in Node.js). + */ + path?: string; + /** + * Raw CSS content to be injected into the frame. + */ + content?: string; +} + +/** + * @public + */ +export interface FrameEvents extends Record<EventType, unknown> { + /** @internal */ + [FrameEvent.FrameNavigated]: Protocol.Page.NavigationType; + /** @internal */ + [FrameEvent.FrameSwapped]: undefined; + /** @internal */ + [FrameEvent.LifecycleEvent]: undefined; + /** @internal */ + [FrameEvent.FrameNavigatedWithinDocument]: undefined; + /** @internal */ + [FrameEvent.FrameDetached]: Frame; + /** @internal */ + [FrameEvent.FrameSwappedByActivation]: undefined; +} + +/** + * We use symbols to prevent external parties listening to these events. + * They are internal to Puppeteer. + * + * @internal + */ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace FrameEvent { + export const FrameNavigated = Symbol('Frame.FrameNavigated'); + export const FrameSwapped = Symbol('Frame.FrameSwapped'); + export const LifecycleEvent = Symbol('Frame.LifecycleEvent'); + export const FrameNavigatedWithinDocument = Symbol( + 'Frame.FrameNavigatedWithinDocument' + ); + export const FrameDetached = Symbol('Frame.FrameDetached'); + export const FrameSwappedByActivation = Symbol( + 'Frame.FrameSwappedByActivation' + ); +} + +/** + * @internal + */ +export const throwIfDetached = throwIfDisposed<Frame>(frame => { + return `Attempted to use detached Frame '${frame._id}'.`; +}); + +/** + * Represents a DOM frame. + * + * To understand frames, you can think of frames as `<iframe>` elements. Just + * like iframes, frames can be nested, and when JavaScript is executed in a + * frame, the JavaScript does not effect frames inside the ambient frame the + * JavaScript executes in. + * + * @example + * At any point in time, {@link Page | pages} expose their current frame + * tree via the {@link Page.mainFrame} and {@link Frame.childFrames} methods. + * + * @example + * An example of dumping frame tree: + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://www.google.com/chrome/browser/canary.html'); + * dumpFrameTree(page.mainFrame(), ''); + * await browser.close(); + * + * function dumpFrameTree(frame, indent) { + * console.log(indent + frame.url()); + * for (const child of frame.childFrames()) { + * dumpFrameTree(child, indent + ' '); + * } + * } + * })(); + * ``` + * + * @example + * An example of getting text from an iframe element: + * + * ```ts + * const frame = page.frames().find(frame => frame.name() === 'myframe'); + * const text = await frame.$eval('.selector', element => element.textContent); + * console.log(text); + * ``` + * + * @remarks + * Frame lifecycles are controlled by three events that are all dispatched on + * the parent {@link Frame.page | page}: + * + * - {@link PageEvent.FrameAttached} + * - {@link PageEvent.FrameNavigated} + * - {@link PageEvent.FrameDetached} + * + * @public + */ +export abstract class Frame extends EventEmitter<FrameEvents> { + /** + * @internal + */ + _id!: string; + /** + * @internal + */ + _parentId?: string; + + /** + * @internal + */ + worlds!: IsolatedWorldChart; + + /** + * @internal + */ + _name?: string; + + /** + * @internal + */ + _hasStartedLoading = false; + + /** + * @internal + */ + constructor() { + super(); + } + + /** + * The page associated with the frame. + */ + abstract page(): Page; + + /** + * Is `true` if the frame is an out-of-process (OOP) frame. Otherwise, + * `false`. + */ + abstract isOOPFrame(): boolean; + + /** + * Navigates the frame to the given `url`. + * + * @remarks + * Navigation to `about:blank` or navigation to the same URL with a different + * hash will succeed and return `null`. + * + * :::warning + * + * Headless mode doesn't support navigation to a PDF document. See the {@link + * https://bugs.chromium.org/p/chromium/issues/detail?id=761295 | upstream + * issue}. + * + * ::: + * + * @param url - URL to navigate the frame to. The URL should include scheme, + * e.g. `https://` + * @param options - Options to configure waiting behavior. + * @returns A promise which resolves to the main resource response. In case of + * multiple redirects, the navigation will resolve with the response of the + * last redirect. + * @throws If: + * + * - there's an SSL error (e.g. in case of self-signed certificates). + * - target URL is invalid. + * - the timeout is exceeded during navigation. + * - the remote server does not respond or is unreachable. + * - the main resource failed to load. + * + * This method will not throw an error when any valid HTTP status code is + * returned by the remote server, including 404 "Not Found" and 500 "Internal + * Server Error". The status code for such responses can be retrieved by + * calling {@link HTTPResponse.status}. + */ + abstract goto( + url: string, + options?: { + referer?: string; + referrerPolicy?: string; + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; + } + ): Promise<HTTPResponse | null>; + + /** + * Waits for the frame to navigate. It is useful for when you run code which + * will indirectly cause the frame to navigate. + * + * Usage of the + * {@link https://developer.mozilla.org/en-US/docs/Web/API/History_API | History API} + * to change the URL is considered a navigation. + * + * @example + * + * ```ts + * const [response] = await Promise.all([ + * // The navigation promise resolves after navigation has finished + * frame.waitForNavigation(), + * // Clicking the link will indirectly cause a navigation + * frame.click('a.my-link'), + * ]); + * ``` + * + * @param options - Options to configure waiting behavior. + * @returns A promise which resolves to the main resource response. + */ + abstract waitForNavigation( + options?: WaitForOptions + ): Promise<HTTPResponse | null>; + + /** + * @internal + */ + abstract get client(): CDPSession; + + /** + * @internal + */ + abstract mainRealm(): Realm; + + /** + * @internal + */ + abstract isolatedRealm(): Realm; + + #_document: Promise<ElementHandle<Document>> | undefined; + + /** + * @internal + */ + #document(): Promise<ElementHandle<Document>> { + if (!this.#_document) { + this.#_document = this.isolatedRealm() + .evaluateHandle(() => { + return document; + }) + .then(handle => { + return this.mainRealm().transferHandle(handle); + }); + } + return this.#_document; + } + + /** + * Used to clear the document handle that has been destroyed. + * + * @internal + */ + clearDocumentHandle(): void { + this.#_document = undefined; + } + + /** + * @internal + */ + @throwIfDetached + async frameElement(): Promise<HandleFor<HTMLIFrameElement> | null> { + const parentFrame = this.parentFrame(); + if (!parentFrame) { + return null; + } + using list = await parentFrame.isolatedRealm().evaluateHandle(() => { + return document.querySelectorAll('iframe'); + }); + for await (using iframe of transposeIterableHandle(list)) { + const frame = await iframe.contentFrame(); + if (frame._id === this._id) { + return iframe.move(); + } + } + return null; + } + + /** + * Behaves identically to {@link Page.evaluateHandle} except it's run within + * the context of this frame. + * + * @see {@link Page.evaluateHandle} for details. + */ + @throwIfDetached + 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.mainRealm().evaluateHandle(pageFunction, ...args); + } + + /** + * Behaves identically to {@link Page.evaluate} except it's run within the + * the context of this frame. + * + * @see {@link Page.evaluate} for details. + */ + @throwIfDetached + 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.mainRealm().evaluate(pageFunction, ...args); + } + + /** + * Creates a locator for the provided selector. See {@link Locator} for + * details and supported actions. + * + * @remarks + * Locators API is experimental and we will not follow semver for breaking + * change in the Locators API. + */ + locator<Selector extends string>( + selector: Selector + ): Locator<NodeFor<Selector>>; + + /** + * Creates a locator for the provided function. See {@link Locator} for + * details and supported actions. + * + * @remarks + * Locators API is experimental and we will not follow semver for breaking + * change in the Locators API. + */ + locator<Ret>(func: () => Awaitable<Ret>): Locator<Ret>; + + /** + * @internal + */ + @throwIfDetached + locator<Selector extends string, Ret>( + selectorOrFunc: Selector | (() => Awaitable<Ret>) + ): Locator<NodeFor<Selector>> | Locator<Ret> { + if (typeof selectorOrFunc === 'string') { + return NodeLocator.create(this, selectorOrFunc); + } else { + return FunctionLocator.create(this, selectorOrFunc); + } + } + /** + * Queries the frame for an element matching the given selector. + * + * @param selector - The selector to query for. + * @returns A {@link ElementHandle | element handle} to the first element + * matching the given selector. Otherwise, `null`. + */ + @throwIfDetached + async $<Selector extends string>( + selector: Selector + ): Promise<ElementHandle<NodeFor<Selector>> | null> { + // eslint-disable-next-line rulesdir/use-using -- This is cached. + const document = await this.#document(); + return await document.$(selector); + } + + /** + * Queries the frame for all elements matching the given selector. + * + * @param selector - The selector to query for. + * @returns An array of {@link ElementHandle | element handles} that point to + * elements matching the given selector. + */ + @throwIfDetached + async $$<Selector extends string>( + selector: Selector + ): Promise<Array<ElementHandle<NodeFor<Selector>>>> { + // eslint-disable-next-line rulesdir/use-using -- This is cached. + const document = await this.#document(); + return await document.$$(selector); + } + + /** + * Runs the given function on the first element matching the given selector in + * the frame. + * + * If the given function returns a promise, then this method will wait till + * the promise resolves. + * + * @example + * + * ```ts + * const searchValue = await frame.$eval('#search', el => el.value); + * ``` + * + * @param selector - The selector to query for. + * @param pageFunction - The function to be evaluated in the frame's context. + * The first element matching the selector will be passed to the function as + * its first argument. + * @param args - Additional arguments to pass to `pageFunction`. + * @returns A promise to the result of the function. + */ + @throwIfDetached + async $eval< + Selector extends string, + Params extends unknown[], + Func extends EvaluateFuncWith<NodeFor<Selector>, Params> = EvaluateFuncWith< + NodeFor<Selector>, + Params + >, + >( + selector: Selector, + pageFunction: string | Func, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + pageFunction = withSourcePuppeteerURLIfNone(this.$eval.name, pageFunction); + // eslint-disable-next-line rulesdir/use-using -- This is cached. + const document = await this.#document(); + return await document.$eval(selector, pageFunction, ...args); + } + + /** + * Runs the given function on an array of elements matching the given selector + * in the frame. + * + * If the given function returns a promise, then this method will wait till + * the promise resolves. + * + * @example + * + * ```ts + * const divsCounts = await frame.$$eval('div', divs => divs.length); + * ``` + * + * @param selector - The selector to query for. + * @param pageFunction - The function to be evaluated in the frame's context. + * An array of elements matching the given selector will be passed to the + * function as its first argument. + * @param args - Additional arguments to pass to `pageFunction`. + * @returns A promise to the result of the function. + */ + @throwIfDetached + async $$eval< + Selector extends string, + Params extends unknown[], + Func extends EvaluateFuncWith< + Array<NodeFor<Selector>>, + Params + > = EvaluateFuncWith<Array<NodeFor<Selector>>, Params>, + >( + selector: Selector, + pageFunction: string | Func, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + pageFunction = withSourcePuppeteerURLIfNone(this.$$eval.name, pageFunction); + // eslint-disable-next-line rulesdir/use-using -- This is cached. + const document = await this.#document(); + return await document.$$eval(selector, pageFunction, ...args); + } + + /** + * @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. + * + * @example + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * let currentURL; + * page + * .mainFrame() + * .waitForSelector('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 selector - The selector to query and wait for. + * @param options - Options for customizing waiting behavior. + * @returns An element matching the given selector. + * @throws Throws if an element matching the given selector doesn't appear. + */ + @throwIfDetached + async waitForSelector<Selector extends string>( + selector: Selector, + options: WaitForSelectorOptions = {} + ): Promise<ElementHandle<NodeFor<Selector>> | null> { + const {updatedSelector, QueryHandler} = + getQueryHandlerAndSelector(selector); + return (await QueryHandler.waitFor( + this, + updatedSelector, + options + )) as ElementHandle<NodeFor<Selector>> | null; + } + + /** + * @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: + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * (async () => { + * . const browser = await puppeteer.launch(); + * . const page = await browser.newPage(); + * . const watchDog = page.mainFrame().waitForFunction('window.innerWidth < 100'); + * . page.setViewport({width: 50, height: 50}); + * . await watchDog; + * . await browser.close(); + * })(); + * ``` + * + * To pass arguments from Node.js to the predicate of `page.waitForFunction` function: + * + * ```ts + * const selector = '.foo'; + * await frame.waitForFunction( + * selector => !!document.querySelector(selector), + * {}, // empty options object + * selector + * ); + * ``` + * + * @param pageFunction - the function to evaluate in the frame context. + * @param options - options to configure the polling method and timeout. + * @param args - arguments to pass to the `pageFunction`. + * @returns the promise which resolve when the `pageFunction` returns a truthy value. + */ + @throwIfDetached + async waitForFunction< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + pageFunction: Func | string, + options: FrameWaitForFunctionOptions = {}, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + return await (this.mainRealm().waitForFunction( + pageFunction, + options, + ...args + ) as Promise<HandleFor<Awaited<ReturnType<Func>>>>); + } + /** + * The full HTML contents of the frame, including the DOCTYPE. + */ + @throwIfDetached + async content(): Promise<string> { + return await this.evaluate(() => { + let content = ''; + for (const node of document.childNodes) { + switch (node) { + case document.documentElement: + content += document.documentElement.outerHTML; + break; + default: + content += new XMLSerializer().serializeToString(node); + break; + } + } + + return content; + }); + } + + /** + * Set the content of the frame. + * + * @param html - HTML markup to assign to the page. + * @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>; + + /** + * @internal + */ + async setFrameContent(content: string): Promise<void> { + return await this.evaluate(html => { + document.open(); + document.write(html); + document.close(); + }, content); + } + + /** + * The frame's `name` attribute as specified in the tag. + * + * @remarks + * If the name is empty, it returns the `id` attribute instead. + * + * @remarks + * This value is calculated once when the frame is created, and will not + * update if the attribute is changed later. + */ + name(): string { + return this._name || ''; + } + + /** + * The frame's URL. + */ + abstract url(): string; + + /** + * The parent frame, if any. Detached and main frames return `null`. + */ + abstract parentFrame(): Frame | null; + + /** + * An array of child frames. + */ + abstract childFrames(): Frame[]; + + /** + * @returns `true` if the frame has detached. `false` otherwise. + */ + abstract get detached(): boolean; + + /** + * Is`true` if the frame has been detached. Otherwise, `false`. + * + * @deprecated Use the `detached` getter. + */ + isDetached(): boolean { + return this.detached; + } + + /** + * @internal + */ + get disposed(): boolean { + return this.detached; + } + + /** + * Adds a `<script>` tag into the page with the desired url or content. + * + * @param options - Options for the script. + * @returns An {@link ElementHandle | element handle} to the injected + * `<script>` element. + */ + @throwIfDetached + async addScriptTag( + options: FrameAddScriptTagOptions + ): Promise<ElementHandle<HTMLScriptElement>> { + let {content = '', type} = options; + const {path} = options; + if (+!!options.url + +!!path + +!!content !== 1) { + throw new Error( + 'Exactly one of `url`, `path`, or `content` must be specified.' + ); + } + + if (path) { + const fs = await importFSPromises(); + content = await fs.readFile(path, 'utf8'); + content += `//# sourceURL=${path.replace(/\n/g, '')}`; + } + + type = type ?? 'text/javascript'; + + return await this.mainRealm().transferHandle( + await this.isolatedRealm().evaluateHandle( + async ({Deferred}, {url, id, type, content}) => { + const deferred = Deferred.create<void>(); + const script = document.createElement('script'); + script.type = type; + script.text = content; + if (url) { + script.src = url; + script.addEventListener( + 'load', + () => { + return deferred.resolve(); + }, + {once: true} + ); + script.addEventListener( + 'error', + event => { + deferred.reject( + new Error(event.message ?? 'Could not load script') + ); + }, + {once: true} + ); + } else { + deferred.resolve(); + } + if (id) { + script.id = id; + } + document.head.appendChild(script); + await deferred.valueOrThrow(); + return script; + }, + LazyArg.create(context => { + return context.puppeteerUtil; + }), + {...options, type, content} + ) + ); + } + + /** + * Adds a `HTMLStyleElement` into the frame with the desired URL + * + * @returns An {@link ElementHandle | element handle} to the loaded `<style>` + * element. + */ + async addStyleTag( + options: Omit<FrameAddStyleTagOptions, 'url'> + ): Promise<ElementHandle<HTMLStyleElement>>; + + /** + * Adds a `HTMLLinkElement` into the frame with the desired URL + * + * @returns An {@link ElementHandle | element handle} to the loaded `<link>` + * element. + */ + async addStyleTag( + options: FrameAddStyleTagOptions + ): Promise<ElementHandle<HTMLLinkElement>>; + + /** + * @internal + */ + @throwIfDetached + async addStyleTag( + options: FrameAddStyleTagOptions + ): Promise<ElementHandle<HTMLStyleElement | HTMLLinkElement>> { + let {content = ''} = options; + const {path} = options; + if (+!!options.url + +!!path + +!!content !== 1) { + throw new Error( + 'Exactly one of `url`, `path`, or `content` must be specified.' + ); + } + + if (path) { + const fs = await importFSPromises(); + + content = await fs.readFile(path, 'utf8'); + content += '/*# sourceURL=' + path.replace(/\n/g, '') + '*/'; + options.content = content; + } + + return await this.mainRealm().transferHandle( + await this.isolatedRealm().evaluateHandle( + async ({Deferred}, {url, content}) => { + const deferred = Deferred.create<void>(); + let element: HTMLStyleElement | HTMLLinkElement; + if (!url) { + element = document.createElement('style'); + element.appendChild(document.createTextNode(content!)); + } else { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = url; + element = link; + } + element.addEventListener( + 'load', + () => { + deferred.resolve(); + }, + {once: true} + ); + element.addEventListener( + 'error', + event => { + deferred.reject( + new Error( + (event as ErrorEvent).message ?? 'Could not load style' + ) + ); + }, + {once: true} + ); + document.head.appendChild(element); + await deferred.valueOrThrow(); + return element; + }, + LazyArg.create(context => { + return context.puppeteerUtil; + }), + options + ) + ); + } + + /** + * Clicks the first element found that matches `selector`. + * + * @remarks + * If `click()` triggers a navigation event and there's a separate + * `page.waitForNavigation()` promise to be resolved, you may end up with a + * race condition that yields unexpected results. The correct pattern for + * click and wait for navigation is the following: + * + * ```ts + * const [response] = await Promise.all([ + * page.waitForNavigation(waitOptions), + * frame.click(selector, clickOptions), + * ]); + * ``` + * + * @param selector - The selector to query for. + */ + @throwIfDetached + async click( + selector: string, + options: Readonly<ClickOptions> = {} + ): Promise<void> { + using handle = await this.$(selector); + assert(handle, `No element found for selector: ${selector}`); + await handle.click(options); + await handle.dispose(); + } + + /** + * Focuses the first element that matches the `selector`. + * + * @param selector - The selector to query for. + * @throws Throws if there's no element matching `selector`. + */ + @throwIfDetached + async focus(selector: string): Promise<void> { + using handle = await this.$(selector); + assert(handle, `No element found for selector: ${selector}`); + await handle.focus(); + } + + /** + * Hovers the pointer over the center of the first element that matches the + * `selector`. + * + * @param selector - The selector to query for. + * @throws Throws if there's no element matching `selector`. + */ + @throwIfDetached + async hover(selector: string): Promise<void> { + using handle = await this.$(selector); + assert(handle, `No element found for selector: ${selector}`); + await handle.hover(); + } + + /** + * Selects a set of value on the first `<select>` element that matches the + * `selector`. + * + * @example + * + * ```ts + * frame.select('select#colors', 'blue'); // single selection + * frame.select('select#colors', 'red', 'green', 'blue'); // multiple selections + * ``` + * + * @param selector - The selector to query for. + * @param values - The array of values to select. If the `<select>` has the + * `multiple` attribute, all values are considered, otherwise only the first + * one is taken into account. + * @returns the list of values that were successfully selected. + * @throws Throws if there's no `<select>` matching `selector`. + */ + @throwIfDetached + async select(selector: string, ...values: string[]): Promise<string[]> { + using handle = await this.$(selector); + assert(handle, `No element found for selector: ${selector}`); + return await handle.select(...values); + } + + /** + * Taps the first element that matches the `selector`. + * + * @param selector - The selector to query for. + * @throws Throws if there's no element matching `selector`. + */ + @throwIfDetached + async tap(selector: string): Promise<void> { + using handle = await this.$(selector); + assert(handle, `No element found for selector: ${selector}`); + await handle.tap(); + } + + /** + * Sends a `keydown`, `keypress`/`input`, and `keyup` event for each character + * in the text. + * + * @remarks + * To press a special key, like `Control` or `ArrowDown`, use + * {@link Keyboard.press}. + * + * @example + * + * ```ts + * await frame.type('#mytextarea', 'Hello'); // Types instantly + * await frame.type('#mytextarea', 'World', {delay: 100}); // Types slower, like a user + * ``` + * + * @param selector - the selector for the element to type into. If there are + * multiple the first will be used. + * @param text - text to type into the element + * @param options - takes one option, `delay`, which sets the time to wait + * between key presses in milliseconds. Defaults to `0`. + */ + @throwIfDetached + async type( + selector: string, + text: string, + options?: Readonly<KeyboardTypeOptions> + ): Promise<void> { + using handle = await this.$(selector); + assert(handle, `No element found for selector: ${selector}`); + await handle.type(text, options); + } + + /** + * @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 + async title(): Promise<string> { + return await this.isolatedRealm().evaluate(() => { + return document.title; + }); + } + + /** + * This method is typically coupled with an action that triggers a device + * request from an api such as WebBluetooth. + * + * :::caution + * + * This must be called before the device request is made. It will not return a + * currently active device prompt. + * + * ::: + * + * @example + * + * ```ts + * const [devicePrompt] = Promise.all([ + * frame.waitForDevicePrompt(), + * frame.click('#connect-bluetooth'), + * ]); + * await devicePrompt.select( + * await devicePrompt.waitForDevice(({name}) => name.includes('My Device')) + * ); + * ``` + * + * @internal + */ + abstract waitForDevicePrompt( + options?: WaitTimeoutOptions + ): Promise<DeviceRequestPrompt>; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPRequest.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPRequest.ts new file mode 100644 index 0000000000..3c952371ee --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPRequest.ts @@ -0,0 +1,521 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from './CDPSession.js'; +import type {Frame} from './Frame.js'; +import type {HTTPResponse} from './HTTPResponse.js'; + +/** + * @public + */ +export interface ContinueRequestOverrides { + /** + * If set, the request URL will change. This is not a redirect. + */ + url?: string; + method?: string; + postData?: string; + headers?: Record<string, string>; +} + +/** + * @public + */ +export interface InterceptResolutionState { + action: InterceptResolutionAction; + priority?: number; +} + +/** + * Required response data to fulfill a request with. + * + * @public + */ +export interface ResponseForRequest { + status: number; + /** + * Optional response headers. All values are converted to strings. + */ + headers: Record<string, unknown>; + contentType: string; + body: string | Buffer; +} + +/** + * Resource types for HTTPRequests as perceived by the rendering engine. + * + * @public + */ +export type ResourceType = Lowercase<Protocol.Network.ResourceType>; + +/** + * The default cooperative request interception resolution priority + * + * @public + */ +export const DEFAULT_INTERCEPT_RESOLUTION_PRIORITY = 0; + +/** + * Represents an HTTP request sent by a page. + * @remarks + * + * Whenever the page sends a request, such as for a network resource, the + * following events are emitted by Puppeteer's `page`: + * + * - `request`: emitted when the request is issued by the page. + * - `requestfinished` - emitted when the response body is downloaded and the + * request is complete. + * + * If request fails at some point, then instead of `requestfinished` event the + * `requestfailed` event is emitted. + * + * All of these events provide an instance of `HTTPRequest` representing the + * request that occurred: + * + * ``` + * page.on('request', request => ...) + * ``` + * + * NOTE: HTTP Error responses, such as 404 or 503, are still successful + * responses from HTTP standpoint, so request will complete with + * `requestfinished` event. + * + * If request gets a 'redirect' response, the request is successfully finished + * with the `requestfinished` event, and a new request is issued to a + * redirected url. + * + * @public + */ +export abstract class HTTPRequest { + /** + * @internal + */ + _requestId = ''; + /** + * @internal + */ + _interceptionId: string | undefined; + /** + * @internal + */ + _failureText: string | null = null; + /** + * @internal + */ + _response: HTTPResponse | null = null; + /** + * @internal + */ + _fromMemoryCache = false; + /** + * @internal + */ + _redirectChain: HTTPRequest[] = []; + + /** + * Warning! Using this client can break Puppeteer. Use with caution. + * + * @experimental + */ + abstract get client(): CDPSession; + + /** + * @internal + */ + constructor() {} + + /** + * The URL of the request + */ + abstract url(): string; + + /** + * The `ContinueRequestOverrides` that will be used + * if the interception is allowed to continue (ie, `abort()` and + * `respond()` aren't called). + */ + abstract continueRequestOverrides(): ContinueRequestOverrides; + + /** + * The `ResponseForRequest` that gets used if the + * interception is allowed to respond (ie, `abort()` is not called). + */ + abstract responseForRequest(): Partial<ResponseForRequest> | null; + + /** + * The most recent reason for aborting the request + */ + abstract abortErrorReason(): Protocol.Network.ErrorReason | null; + + /** + * An InterceptResolutionState object describing the current resolution + * action and priority. + * + * InterceptResolutionState contains: + * action: InterceptResolutionAction + * priority?: number + * + * InterceptResolutionAction is one of: `abort`, `respond`, `continue`, + * `disabled`, `none`, or `already-handled`. + */ + abstract interceptResolutionState(): InterceptResolutionState; + + /** + * Is `true` if the intercept resolution has already been handled, + * `false` otherwise. + */ + abstract isInterceptResolutionHandled(): boolean; + + /** + * Adds an async request handler to the processing queue. + * Deferred handlers are not guaranteed to execute in any particular order, + * but they are guaranteed to resolve before the request interception + * is finalized. + */ + abstract enqueueInterceptAction( + pendingHandler: () => void | PromiseLike<unknown> + ): void; + + /** + * Awaits pending interception handlers and then decides how to fulfill + * the request interception. + */ + abstract finalizeInterceptions(): Promise<void>; + + /** + * Contains the request's resource type as it was perceived by the rendering + * engine. + */ + abstract resourceType(): ResourceType; + + /** + * The method used (`GET`, `POST`, etc.) + */ + abstract method(): string; + + /** + * The request's post body, if any. + */ + abstract postData(): string | undefined; + + /** + * True when the request has POST data. Note that {@link HTTPRequest.postData} + * might still be undefined when this flag is true when the data is too long + * or not readily available in the decoded form. In that case, use + * {@link HTTPRequest.fetchPostData}. + */ + abstract hasPostData(): boolean; + + /** + * Fetches the POST data for the request from the browser. + */ + abstract fetchPostData(): Promise<string | undefined>; + + /** + * An object with HTTP headers associated with the request. All + * header names are lower-case. + */ + abstract headers(): Record<string, string>; + + /** + * A matching `HTTPResponse` object, or null if the response has not + * been received yet. + */ + abstract response(): HTTPResponse | null; + + /** + * The frame that initiated the request, or null if navigating to + * error pages. + */ + abstract frame(): Frame | null; + + /** + * True if the request is the driver of the current frame's navigation. + */ + abstract isNavigationRequest(): boolean; + + /** + * The initiator of the request. + */ + abstract initiator(): Protocol.Network.Initiator | undefined; + + /** + * A `redirectChain` is a chain of requests initiated to fetch a resource. + * @remarks + * + * `redirectChain` is shared between all the requests of the same chain. + * + * For example, if the website `http://example.com` has a single redirect to + * `https://example.com`, then the chain will contain one request: + * + * ```ts + * const response = await page.goto('http://example.com'); + * const chain = response.request().redirectChain(); + * console.log(chain.length); // 1 + * console.log(chain[0].url()); // 'http://example.com' + * ``` + * + * If the website `https://google.com` has no redirects, then the chain will be empty: + * + * ```ts + * const response = await page.goto('https://google.com'); + * const chain = response.request().redirectChain(); + * console.log(chain.length); // 0 + * ``` + * + * @returns the chain of requests - if a server responds with at least a + * single redirect, this chain will contain all requests that were redirected. + */ + abstract redirectChain(): HTTPRequest[]; + + /** + * Access information about the request's failure. + * + * @remarks + * + * @example + * + * Example of logging all failed requests: + * + * ```ts + * page.on('requestfailed', request => { + * console.log(request.url() + ' ' + request.failure().errorText); + * }); + * ``` + * + * @returns `null` unless the request failed. If the request fails this can + * return an object with `errorText` containing a human-readable error + * message, e.g. `net::ERR_FAILED`. It is not guaranteed that there will be + * failure text if the request fails. + */ + abstract failure(): {errorText: string} | null; + + /** + * Continues request with optional request overrides. + * + * @example + * + * ```ts + * await page.setRequestInterception(true); + * page.on('request', request => { + * // Override headers + * const headers = Object.assign({}, request.headers(), { + * foo: 'bar', // set "foo" header + * origin: undefined, // remove "origin" header + * }); + * request.continue({headers}); + * }); + * ``` + * + * @param overrides - optional overrides to apply to the request. + * @param priority - If provided, intercept is resolved using cooperative + * handling rules. Otherwise, intercept is resolved immediately. + * + * @remarks + * + * To use this, request interception should be enabled with + * {@link Page.setRequestInterception}. + * + * Exception is immediately thrown if the request interception is not enabled. + */ + abstract continue( + overrides?: ContinueRequestOverrides, + priority?: number + ): Promise<void>; + + /** + * Fulfills a request with the given response. + * + * @example + * An example of fulfilling all requests with 404 responses: + * + * ```ts + * await page.setRequestInterception(true); + * page.on('request', request => { + * request.respond({ + * status: 404, + * contentType: 'text/plain', + * body: 'Not Found!', + * }); + * }); + * ``` + * + * NOTE: Mocking responses for dataURL requests is not supported. + * Calling `request.respond` for a dataURL request is a noop. + * + * @param response - the response to fulfill the request with. + * @param priority - If provided, intercept is resolved using + * cooperative handling rules. Otherwise, intercept is resolved + * immediately. + * + * @remarks + * + * To use this, request + * interception should be enabled with {@link Page.setRequestInterception}. + * + * Exception is immediately thrown if the request interception is not enabled. + */ + abstract respond( + response: Partial<ResponseForRequest>, + priority?: number + ): Promise<void>; + + /** + * Aborts a request. + * + * @param errorCode - optional error code to provide. + * @param priority - If provided, intercept is resolved using + * cooperative handling rules. Otherwise, intercept is resolved + * immediately. + * + * @remarks + * + * To use this, request interception should be enabled with + * {@link Page.setRequestInterception}. If it is not enabled, this method will + * throw an exception immediately. + */ + abstract abort(errorCode?: ErrorCode, priority?: number): Promise<void>; +} + +/** + * @public + */ +export enum InterceptResolutionAction { + Abort = 'abort', + Respond = 'respond', + Continue = 'continue', + Disabled = 'disabled', + None = 'none', + AlreadyHandled = 'already-handled', +} + +/** + * @public + * + * @deprecated please use {@link InterceptResolutionAction} instead. + */ +export type InterceptResolutionStrategy = InterceptResolutionAction; + +/** + * @public + */ +export type ErrorCode = + | 'aborted' + | 'accessdenied' + | 'addressunreachable' + | 'blockedbyclient' + | 'blockedbyresponse' + | 'connectionaborted' + | 'connectionclosed' + | 'connectionfailed' + | 'connectionrefused' + | 'connectionreset' + | 'internetdisconnected' + | 'namenotresolved' + | 'timedout' + | 'failed'; + +/** + * @public + */ +export type ActionResult = 'continue' | 'abort' | 'respond'; + +/** + * @internal + */ +export function headersArray( + headers: Record<string, string | string[]> +): Array<{name: string; value: string}> { + const result = []; + for (const name in headers) { + const value = headers[name]; + + if (!Object.is(value, undefined)) { + const values = Array.isArray(value) ? value : [value]; + + result.push( + ...values.map(value => { + return {name, value: value + ''}; + }) + ); + } + } + return result; +} + +/** + * @internal + * + * @remarks + * List taken from {@link https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml} + * with extra 306 and 418 codes. + */ +export const STATUS_TEXTS: Record<string, string> = { + '100': 'Continue', + '101': 'Switching Protocols', + '102': 'Processing', + '103': 'Early Hints', + '200': 'OK', + '201': 'Created', + '202': 'Accepted', + '203': 'Non-Authoritative Information', + '204': 'No Content', + '205': 'Reset Content', + '206': 'Partial Content', + '207': 'Multi-Status', + '208': 'Already Reported', + '226': 'IM Used', + '300': 'Multiple Choices', + '301': 'Moved Permanently', + '302': 'Found', + '303': 'See Other', + '304': 'Not Modified', + '305': 'Use Proxy', + '306': 'Switch Proxy', + '307': 'Temporary Redirect', + '308': 'Permanent Redirect', + '400': 'Bad Request', + '401': 'Unauthorized', + '402': 'Payment Required', + '403': 'Forbidden', + '404': 'Not Found', + '405': 'Method Not Allowed', + '406': 'Not Acceptable', + '407': 'Proxy Authentication Required', + '408': 'Request Timeout', + '409': 'Conflict', + '410': 'Gone', + '411': 'Length Required', + '412': 'Precondition Failed', + '413': 'Payload Too Large', + '414': 'URI Too Long', + '415': 'Unsupported Media Type', + '416': 'Range Not Satisfiable', + '417': 'Expectation Failed', + '418': "I'm a teapot", + '421': 'Misdirected Request', + '422': 'Unprocessable Entity', + '423': 'Locked', + '424': 'Failed Dependency', + '425': 'Too Early', + '426': 'Upgrade Required', + '428': 'Precondition Required', + '429': 'Too Many Requests', + '431': 'Request Header Fields Too Large', + '451': 'Unavailable For Legal Reasons', + '500': 'Internal Server Error', + '501': 'Not Implemented', + '502': 'Bad Gateway', + '503': 'Service Unavailable', + '504': 'Gateway Timeout', + '505': 'HTTP Version Not Supported', + '506': 'Variant Also Negotiates', + '507': 'Insufficient Storage', + '508': 'Loop Detected', + '510': 'Not Extended', + '511': 'Network Authentication Required', +} as const; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPResponse.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPResponse.ts new file mode 100644 index 0000000000..906479eb43 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPResponse.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type Protocol from 'devtools-protocol'; + +import type {SecurityDetails} from '../common/SecurityDetails.js'; + +import type {Frame} from './Frame.js'; +import type {HTTPRequest} from './HTTPRequest.js'; + +/** + * @public + */ +export interface RemoteAddress { + ip?: string; + port?: number; +} + +/** + * The HTTPResponse class represents responses which are received by the + * {@link Page} class. + * + * @public + */ +export abstract class HTTPResponse { + /** + * @internal + */ + constructor() {} + + /** + * The IP address and port number used to connect to the remote + * server. + */ + abstract remoteAddress(): RemoteAddress; + + /** + * The URL of the response. + */ + abstract url(): string; + + /** + * True if the response was successful (status in the range 200-299). + */ + ok(): boolean { + // TODO: document === 0 case? + const status = this.status(); + return status === 0 || (status >= 200 && status <= 299); + } + + /** + * The status code of the response (e.g., 200 for a success). + */ + abstract status(): number; + + /** + * The status text of the response (e.g. usually an "OK" for a + * success). + */ + abstract statusText(): string; + + /** + * An object with HTTP headers associated with the response. All + * header names are lower-case. + */ + abstract headers(): Record<string, string>; + + /** + * {@link SecurityDetails} if the response was received over the + * secure connection, or `null` otherwise. + */ + abstract securityDetails(): SecurityDetails | null; + + /** + * Timing information related to the response. + */ + abstract timing(): Protocol.Network.ResourceTiming | null; + + /** + * Promise which resolves to a buffer with response body. + */ + abstract buffer(): Promise<Buffer>; + + /** + * Promise which resolves to a text representation of response body. + */ + async text(): Promise<string> { + const content = await this.buffer(); + return content.toString('utf8'); + } + + /** + * Promise which resolves to a JSON representation of response body. + * + * @remarks + * + * This method will throw if the response body is not parsable via + * `JSON.parse`. + */ + async json(): Promise<any> { + const content = await this.text(); + return JSON.parse(content); + } + + /** + * A matching {@link HTTPRequest} object. + */ + abstract request(): HTTPRequest; + + /** + * True if the response was served from either the browser's disk + * cache or memory cache. + */ + abstract fromCache(): boolean; + + /** + * True if the response was served by a service worker. + */ + abstract fromServiceWorker(): boolean; + + /** + * A {@link Frame} that initiated this response, or `null` if + * navigating to error pages. + */ + abstract frame(): Frame | null; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Input.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Input.ts new file mode 100644 index 0000000000..6b41ca8fe1 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Input.ts @@ -0,0 +1,517 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {KeyInput} from '../common/USKeyboardLayout.js'; + +import type {Point} from './ElementHandle.js'; + +/** + * @public + */ +export interface KeyDownOptions { + /** + * @deprecated Do not use. This is automatically handled. + */ + text?: string; + /** + * @deprecated Do not use. This is automatically handled. + */ + commands?: string[]; +} + +/** + * @public + */ +export interface KeyboardTypeOptions { + delay?: number; +} + +/** + * @public + */ +export type KeyPressOptions = KeyDownOptions & KeyboardTypeOptions; + +/** + * Keyboard provides an api for managing a virtual keyboard. + * The high level api is {@link Keyboard."type"}, + * which takes raw characters and generates proper keydown, keypress/input, + * and keyup events on your page. + * + * @remarks + * For finer control, you can use {@link Keyboard.down}, + * {@link Keyboard.up}, and {@link Keyboard.sendCharacter} + * to manually fire events as if they were generated from a real keyboard. + * + * On macOS, keyboard shortcuts like `⌘ A` -\> Select All do not work. + * See {@link https://github.com/puppeteer/puppeteer/issues/1313 | #1313}. + * + * @example + * An example of holding down `Shift` in order to select and delete some text: + * + * ```ts + * await page.keyboard.type('Hello World!'); + * await page.keyboard.press('ArrowLeft'); + * + * await page.keyboard.down('Shift'); + * for (let i = 0; i < ' World'.length; i++) + * await page.keyboard.press('ArrowLeft'); + * await page.keyboard.up('Shift'); + * + * await page.keyboard.press('Backspace'); + * // Result text will end up saying 'Hello!' + * ``` + * + * @example + * An example of pressing `A` + * + * ```ts + * await page.keyboard.down('Shift'); + * await page.keyboard.press('KeyA'); + * await page.keyboard.up('Shift'); + * ``` + * + * @public + */ +export abstract class Keyboard { + /** + * @internal + */ + constructor() {} + + /** + * Dispatches a `keydown` event. + * + * @remarks + * If `key` is a single character and no modifier keys besides `Shift` + * are being held down, a `keypress`/`input` event will also generated. + * The `text` option can be specified to force an input event to be generated. + * If `key` is a modifier key, `Shift`, `Meta`, `Control`, or `Alt`, + * subsequent key presses will be sent with that modifier active. + * To release the modifier key, use {@link Keyboard.up}. + * + * After the key is pressed once, subsequent calls to + * {@link Keyboard.down} will have + * {@link https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/repeat | repeat} + * set to true. To release the key, use {@link Keyboard.up}. + * + * Modifier keys DO influence {@link Keyboard.down}. + * Holding down `Shift` will type the text in upper case. + * + * @param key - Name of key to press, such as `ArrowLeft`. + * See {@link KeyInput} for a list of all key names. + * + * @param options - An object of options. Accepts text which, if specified, + * generates an input event with this text. Accepts commands which, if specified, + * is the commands of keyboard shortcuts, + * see {@link https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/editing/commands/editor_command_names.h | Chromium Source Code} for valid command names. + */ + abstract down( + key: KeyInput, + options?: Readonly<KeyDownOptions> + ): Promise<void>; + + /** + * Dispatches a `keyup` event. + * + * @param key - Name of key to release, such as `ArrowLeft`. + * See {@link KeyInput | KeyInput} + * for a list of all key names. + */ + abstract up(key: KeyInput): Promise<void>; + + /** + * Dispatches a `keypress` and `input` event. + * This does not send a `keydown` or `keyup` event. + * + * @remarks + * Modifier keys DO NOT effect {@link Keyboard.sendCharacter | Keyboard.sendCharacter}. + * Holding down `Shift` will not type the text in upper case. + * + * @example + * + * ```ts + * page.keyboard.sendCharacter('嗨'); + * ``` + * + * @param char - Character to send into the page. + */ + abstract sendCharacter(char: string): Promise<void>; + + /** + * Sends a `keydown`, `keypress`/`input`, + * and `keyup` event for each character in the text. + * + * @remarks + * To press a special key, like `Control` or `ArrowDown`, + * use {@link Keyboard.press}. + * + * Modifier keys DO NOT effect `keyboard.type`. + * Holding down `Shift` will not type the text in upper case. + * + * @example + * + * ```ts + * await page.keyboard.type('Hello'); // Types instantly + * await page.keyboard.type('World', {delay: 100}); // Types slower, like a user + * ``` + * + * @param text - A text to type into a focused element. + * @param options - An object of options. Accepts delay which, + * if specified, is the time to wait between `keydown` and `keyup` in milliseconds. + * Defaults to 0. + */ + abstract type( + text: string, + options?: Readonly<KeyboardTypeOptions> + ): Promise<void>; + + /** + * Shortcut for {@link Keyboard.down} + * and {@link Keyboard.up}. + * + * @remarks + * If `key` is a single character and no modifier keys besides `Shift` + * are being held down, a `keypress`/`input` event will also generated. + * The `text` option can be specified to force an input event to be generated. + * + * Modifier keys DO effect {@link Keyboard.press}. + * Holding down `Shift` will type the text in upper case. + * + * @param key - Name of key to press, such as `ArrowLeft`. + * See {@link KeyInput} for a list of all key names. + * + * @param options - An object of options. Accepts text which, if specified, + * generates an input event with this text. Accepts delay which, + * if specified, is the time to wait between `keydown` and `keyup` in milliseconds. + * Defaults to 0. Accepts commands which, if specified, + * is the commands of keyboard shortcuts, + * see {@link https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/editing/commands/editor_command_names.h | Chromium Source Code} for valid command names. + */ + abstract press( + key: KeyInput, + options?: Readonly<KeyPressOptions> + ): Promise<void>; +} + +/** + * @public + */ +export interface MouseOptions { + /** + * Determines which button will be pressed. + * + * @defaultValue `'left'` + */ + button?: MouseButton; + /** + * Determines the click count for the mouse event. This does not perform + * multiple clicks. + * + * @deprecated Use {@link MouseClickOptions.count}. + * @defaultValue `1` + */ + clickCount?: number; +} + +/** + * @public + */ +export interface MouseClickOptions extends MouseOptions { + /** + * Time (in ms) to delay the mouse release after the mouse press. + */ + delay?: number; + /** + * Number of clicks to perform. + * + * @defaultValue `1` + */ + count?: number; +} + +/** + * @public + */ +export interface MouseWheelOptions { + deltaX?: number; + deltaY?: number; +} + +/** + * @public + */ +export interface MouseMoveOptions { + /** + * Determines the number of movements to make from the current mouse position + * to the new one. + * + * @defaultValue `1` + */ + steps?: number; +} + +/** + * Enum of valid mouse buttons. + * + * @public + */ +export const MouseButton = Object.freeze({ + Left: 'left', + Right: 'right', + Middle: 'middle', + Back: 'back', + Forward: 'forward', +}) satisfies Record<string, Protocol.Input.MouseButton>; + +/** + * @public + */ +export type MouseButton = (typeof MouseButton)[keyof typeof MouseButton]; + +/** + * The Mouse class operates in main-frame CSS pixels + * relative to the top-left corner of the viewport. + * @remarks + * Every `page` object has its own Mouse, accessible with [`page.mouse`](#pagemouse). + * + * @example + * + * ```ts + * // Using ‘page.mouse’ to trace a 100x100 square. + * await page.mouse.move(0, 0); + * await page.mouse.down(); + * await page.mouse.move(0, 100); + * await page.mouse.move(100, 100); + * await page.mouse.move(100, 0); + * await page.mouse.move(0, 0); + * await page.mouse.up(); + * ``` + * + * **Note**: The mouse events trigger synthetic `MouseEvent`s. + * This means that it does not fully replicate the functionality of what a normal user + * would be able to do with their mouse. + * + * For example, dragging and selecting text is not possible using `page.mouse`. + * Instead, you can use the {@link https://developer.mozilla.org/en-US/docs/Web/API/DocumentOrShadowRoot/getSelection | `DocumentOrShadowRoot.getSelection()`} functionality implemented in the platform. + * + * @example + * For example, if you want to select all content between nodes: + * + * ```ts + * await page.evaluate( + * (from, to) => { + * const selection = from.getRootNode().getSelection(); + * const range = document.createRange(); + * range.setStartBefore(from); + * range.setEndAfter(to); + * selection.removeAllRanges(); + * selection.addRange(range); + * }, + * fromJSHandle, + * toJSHandle + * ); + * ``` + * + * If you then would want to copy-paste your selection, you can use the clipboard api: + * + * ```ts + * // The clipboard api does not allow you to copy, unless the tab is focused. + * await page.bringToFront(); + * await page.evaluate(() => { + * // Copy the selected content to the clipboard + * document.execCommand('copy'); + * // Obtain the content of the clipboard as a string + * return navigator.clipboard.readText(); + * }); + * ``` + * + * **Note**: If you want access to the clipboard API, + * you have to give it permission to do so: + * + * ```ts + * await browser + * .defaultBrowserContext() + * .overridePermissions('<your origin>', [ + * 'clipboard-read', + * 'clipboard-write', + * ]); + * ``` + * + * @public + */ +export abstract class Mouse { + /** + * @internal + */ + constructor() {} + + /** + * Resets the mouse to the default state: No buttons pressed; position at + * (0,0). + */ + abstract reset(): Promise<void>; + + /** + * Moves the mouse to the given coordinate. + * + * @param x - Horizontal position of the mouse. + * @param y - Vertical position of the mouse. + * @param options - Options to configure behavior. + */ + abstract move( + x: number, + y: number, + options?: Readonly<MouseMoveOptions> + ): Promise<void>; + + /** + * Presses the mouse. + * + * @param options - Options to configure behavior. + */ + abstract down(options?: Readonly<MouseOptions>): Promise<void>; + + /** + * Releases the mouse. + * + * @param options - Options to configure behavior. + */ + abstract up(options?: Readonly<MouseOptions>): Promise<void>; + + /** + * Shortcut for `mouse.move`, `mouse.down` and `mouse.up`. + * + * @param x - Horizontal position of the mouse. + * @param y - Vertical position of the mouse. + * @param options - Options to configure behavior. + */ + abstract click( + x: number, + y: number, + options?: Readonly<MouseClickOptions> + ): Promise<void>; + + /** + * Dispatches a `mousewheel` event. + * @param options - Optional: `MouseWheelOptions`. + * + * @example + * An example of zooming into an element: + * + * ```ts + * await page.goto( + * 'https://mdn.mozillademos.org/en-US/docs/Web/API/Element/wheel_event$samples/Scaling_an_element_via_the_wheel?revision=1587366' + * ); + * + * const elem = await page.$('div'); + * const boundingBox = await elem.boundingBox(); + * await page.mouse.move( + * boundingBox.x + boundingBox.width / 2, + * boundingBox.y + boundingBox.height / 2 + * ); + * + * await page.mouse.wheel({deltaY: -100}); + * ``` + */ + abstract wheel(options?: Readonly<MouseWheelOptions>): Promise<void>; + + /** + * Dispatches a `drag` event. + * @param start - starting point for drag + * @param target - point to drag to + */ + abstract drag(start: Point, target: Point): Promise<Protocol.Input.DragData>; + + /** + * Dispatches a `dragenter` event. + * @param target - point for emitting `dragenter` event + * @param data - drag data containing items and operations mask + */ + abstract dragEnter( + target: Point, + data: Protocol.Input.DragData + ): Promise<void>; + + /** + * Dispatches a `dragover` event. + * @param target - point for emitting `dragover` event + * @param data - drag data containing items and operations mask + */ + abstract dragOver( + target: Point, + data: Protocol.Input.DragData + ): Promise<void>; + + /** + * Performs a dragenter, dragover, and drop in sequence. + * @param target - point to drop on + * @param data - drag data containing items and operations mask + */ + abstract drop(target: Point, data: Protocol.Input.DragData): Promise<void>; + + /** + * Performs a drag, dragenter, dragover, and drop in sequence. + * @param start - point to drag from + * @param target - point to drop on + * @param options - An object of options. Accepts delay which, + * if specified, is the time to wait between `dragover` and `drop` in milliseconds. + * Defaults to 0. + */ + abstract dragAndDrop( + start: Point, + target: Point, + options?: {delay?: number} + ): Promise<void>; +} + +/** + * The Touchscreen class exposes touchscreen events. + * @public + */ +export abstract class Touchscreen { + /** + * @internal + */ + constructor() {} + + /** + * Dispatches a `touchstart` and `touchend` event. + * @param x - Horizontal position of the tap. + * @param y - Vertical position of the tap. + */ + async tap(x: number, y: number): Promise<void> { + await this.touchStart(x, y); + await this.touchEnd(); + } + + /** + * Dispatches a `touchstart` event. + * @param x - Horizontal position of the tap. + * @param y - Vertical position of the tap. + */ + abstract touchStart(x: number, y: number): Promise<void>; + + /** + * Dispatches a `touchMove` event. + * @param x - Horizontal position of the move. + * @param y - Vertical position of the move. + * + * @remarks + * + * Not every `touchMove` call results in a `touchmove` event being emitted, + * depending on the browser's optimizations. For example, Chrome + * {@link https://developer.chrome.com/blog/a-more-compatible-smoother-touch/#chromes-new-model-the-throttled-async-touchmove-model | throttles} + * touch move events. + */ + abstract touchMove(x: number, y: number): Promise<void>; + + /** + * Dispatches a `touchend` event. + */ + abstract touchEnd(): Promise<void>; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/JSHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/JSHandle.ts new file mode 100644 index 0000000000..52ca7fe8f8 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/JSHandle.ts @@ -0,0 +1,212 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type Protocol from 'devtools-protocol'; + +import type {EvaluateFuncWith, HandleFor, HandleOr} from '../common/types.js'; +import {debugError, withSourcePuppeteerURLIfNone} from '../common/util.js'; +import {moveable, throwIfDisposed} from '../util/decorators.js'; +import {disposeSymbol, asyncDisposeSymbol} from '../util/disposable.js'; + +import type {ElementHandle} from './ElementHandle.js'; +import type {Realm} from './Realm.js'; + +/** + * Represents a reference to a JavaScript object. Instances can be created using + * {@link Page.evaluateHandle}. + * + * Handles prevent the referenced JavaScript object from being garbage-collected + * unless the handle is purposely {@link JSHandle.dispose | disposed}. JSHandles + * are auto-disposed when their associated frame is navigated away or the parent + * context gets destroyed. + * + * Handles can be used as arguments for any evaluation function such as + * {@link Page.$eval}, {@link Page.evaluate}, and {@link Page.evaluateHandle}. + * They are resolved to their referenced object. + * + * @example + * + * ```ts + * const windowHandle = await page.evaluateHandle(() => window); + * ``` + * + * @public + */ +@moveable +export abstract class JSHandle<T = unknown> { + declare move: () => this; + + /** + * Used for nominally typing {@link JSHandle}. + */ + declare _?: T; + + /** + * @internal + */ + constructor() {} + + /** + * @internal + */ + abstract get realm(): Realm; + + /** + * @internal + */ + abstract get disposed(): boolean; + + /** + * Evaluates the given function with the current handle as its first argument. + */ + async evaluate< + Params extends unknown[], + Func extends EvaluateFuncWith<T, Params> = EvaluateFuncWith<T, Params>, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluate.name, + pageFunction + ); + return await this.realm.evaluate(pageFunction, this, ...args); + } + + /** + * Evaluates the given function with the current handle as its first argument. + * + */ + async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFuncWith<T, Params> = EvaluateFuncWith<T, Params>, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluateHandle.name, + pageFunction + ); + return await this.realm.evaluateHandle(pageFunction, this, ...args); + } + + /** + * Fetches a single property from the referenced object. + */ + getProperty<K extends keyof T>( + propertyName: HandleOr<K> + ): Promise<HandleFor<T[K]>>; + getProperty(propertyName: string): Promise<JSHandle<unknown>>; + + /** + * @internal + */ + @throwIfDisposed() + async getProperty<K extends keyof T>( + propertyName: HandleOr<K> + ): Promise<HandleFor<T[K]>> { + return await this.evaluateHandle((object, propertyName) => { + return object[propertyName as K]; + }, propertyName); + } + + /** + * Gets a map of handles representing the properties of the current handle. + * + * @example + * + * ```ts + * const listHandle = await page.evaluateHandle(() => document.body.children); + * const properties = await listHandle.getProperties(); + * const children = []; + * for (const property of properties.values()) { + * const element = property.asElement(); + * if (element) { + * children.push(element); + * } + * } + * children; // holds elementHandles to all children of document.body + * ``` + */ + @throwIfDisposed() + async getProperties(): Promise<Map<string, JSHandle>> { + const propertyNames = await this.evaluate(object => { + const enumerableProperties = []; + const descriptors = Object.getOwnPropertyDescriptors(object); + for (const propertyName in descriptors) { + if (descriptors[propertyName]?.enumerable) { + enumerableProperties.push(propertyName); + } + } + return enumerableProperties; + }); + const map = new Map<string, JSHandle>(); + const results = await Promise.all( + propertyNames.map(key => { + return this.getProperty(key); + }) + ); + for (const [key, value] of Object.entries(propertyNames)) { + using handle = results[key as any]; + if (handle) { + map.set(value, handle.move()); + } + } + return map; + } + + /** + * A vanilla object representing the serializable portions of the + * referenced object. + * @throws Throws if the object cannot be serialized due to circularity. + * + * @remarks + * If the object has a `toJSON` function, it **will not** be called. + */ + abstract jsonValue(): Promise<T>; + + /** + * Either `null` or the handle itself if the handle is an + * instance of {@link ElementHandle}. + */ + abstract asElement(): ElementHandle<Node> | null; + + /** + * Releases the object referenced by the handle for garbage collection. + */ + abstract dispose(): Promise<void>; + + /** + * Returns a string representation of the JSHandle. + * + * @remarks + * Useful during debugging. + */ + abstract toString(): string; + + /** + * @internal + */ + abstract get id(): string | undefined; + + /** + * Provides access to the + * {@link https://chromedevtools.github.io/devtools-protocol/tot/Runtime/#type-RemoteObject | Protocol.Runtime.RemoteObject} + * backing this handle. + */ + abstract remoteObject(): Protocol.Runtime.RemoteObject; + + /** @internal */ + [disposeSymbol](): void { + return void this.dispose().catch(debugError); + } + + /** @internal */ + [asyncDisposeSymbol](): Promise<void> { + return this.dispose(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Page.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Page.ts new file mode 100644 index 0000000000..deb04628fd --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Page.ts @@ -0,0 +1,3090 @@ +/** + * @license + * Copyright 2017 Google Inc. + * 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, + of, + race, + raceWith, + startWith, + switchMap, + takeUntil, + timer, + type Observable, +} from '../../third_party/rxjs/rxjs.js'; +import type {HTTPRequest} from '../api/HTTPRequest.js'; +import type {HTTPResponse} from '../api/HTTPResponse.js'; +import type {Accessibility} from '../cdp/Accessibility.js'; +import type {Coverage} from '../cdp/Coverage.js'; +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 {Device} from '../common/Device.js'; +import {TargetCloseError} from '../common/Errors.js'; +import { + EventEmitter, + type EventsWithWildcard, + type EventType, + type Handler, +} from '../common/EventEmitter.js'; +import type {FileChooser} from '../common/FileChooser.js'; +import type {PDFOptions} from '../common/PDFOptions.js'; +import {TimeoutSettings} from '../common/TimeoutSettings.js'; +import type { + Awaitable, + AwaitablePredicate, + EvaluateFunc, + EvaluateFuncWith, + HandleFor, + NodeFor, +} from '../common/types.js'; +import { + debugError, + fromEmitterEvent, + importFSPromises, + isString, + NETWORK_IDLE_TIME, + timeout, + withSourcePuppeteerURLIfNone, +} from '../common/util.js'; +import type {Viewport} from '../common/Viewport.js'; +import type {ScreenRecorder} from '../node/ScreenRecorder.js'; +import {guarded} from '../util/decorators.js'; +import { + AsyncDisposableStack, + asyncDisposeSymbol, + DisposableStack, + disposeSymbol, +} from '../util/disposable.js'; + +import type {Browser} from './Browser.js'; +import type {BrowserContext} from './BrowserContext.js'; +import type {CDPSession} from './CDPSession.js'; +import type {Dialog} from './Dialog.js'; +import type { + BoundingBox, + ClickOptions, + ElementHandle, +} from './ElementHandle.js'; +import type { + Frame, + FrameAddScriptTagOptions, + FrameAddStyleTagOptions, + FrameWaitForFunctionOptions, + GoToOptions, + WaitForOptions, +} from './Frame.js'; +import type { + Keyboard, + KeyboardTypeOptions, + Mouse, + Touchscreen, +} from './Input.js'; +import type {JSHandle} from './JSHandle.js'; +import { + FunctionLocator, + Locator, + NodeLocator, + type AwaitedLocator, +} from './locators/locators.js'; +import type {Target} from './Target.js'; +import type {WebWorker} from './WebWorker.js'; + +/** + * @public + */ +export interface Metrics { + Timestamp?: number; + Documents?: number; + Frames?: number; + JSEventListeners?: number; + Nodes?: number; + LayoutCount?: number; + RecalcStyleCount?: number; + LayoutDuration?: number; + RecalcStyleDuration?: number; + ScriptDuration?: number; + TaskDuration?: number; + JSHeapUsedSize?: number; + JSHeapTotalSize?: number; +} + +/** + * @public + */ +export interface WaitForNetworkIdleOptions extends WaitTimeoutOptions { + /** + * Time (in milliseconds) the network should be idle. + * + * @defaultValue `500` + */ + idleTime?: number; + /** + * Maximum number concurrent of network connections to be considered inactive. + * + * @defaultValue `0` + */ + concurrency?: number; +} + +/** + * @public + */ +export interface WaitTimeoutOptions { + /** + * Maximum wait time in milliseconds. Pass 0 to disable the timeout. + * + * The default value can be changed by using the + * {@link Page.setDefaultTimeout} method. + * + * @defaultValue `30000` + */ + timeout?: number; +} + +/** + * @public + */ +export interface WaitForSelectorOptions { + /** + * Wait for the selected element to be present in DOM and to be visible, i.e. + * to not have `display: none` or `visibility: hidden` CSS properties. + * + * @defaultValue `false` + */ + visible?: boolean; + /** + * Wait for the selected element to not be found in the DOM or to be hidden, + * i.e. have `display: none` or `visibility: hidden` CSS properties. + * + * @defaultValue `false` + */ + hidden?: boolean; + /** + * Maximum time to wait in milliseconds. Pass `0` to disable timeout. + * + * The default value can be changed by using {@link Page.setDefaultTimeout} + * + * @defaultValue `30_000` (30 seconds) + */ + timeout?: number; + /** + * A signal object that allows you to cancel a waitForSelector call. + */ + signal?: AbortSignal; +} + +/** + * @public + */ +export interface GeolocationOptions { + /** + * Latitude between `-90` and `90`. + */ + longitude: number; + /** + * Longitude between `-180` and `180`. + */ + latitude: number; + /** + * Optional non-negative accuracy value. + */ + accuracy?: number; +} + +/** + * @public + */ +export interface MediaFeature { + name: string; + value: string; +} + +/** + * @public + */ +export interface ScreenshotClip extends BoundingBox { + /** + * @defaultValue `1` + */ + scale?: number; +} + +/** + * @public + */ +export interface ScreenshotOptions { + /** + * @defaultValue `false` + */ + optimizeForSpeed?: boolean; + /** + * @defaultValue `'png'` + */ + type?: 'png' | 'jpeg' | 'webp'; + /** + * Quality of the image, between 0-100. Not applicable to `png` images. + */ + quality?: number; + /** + * Capture the screenshot from the surface, rather than the view. + * + * @defaultValue `true` + */ + fromSurface?: boolean; + /** + * When `true`, takes a screenshot of the full page. + * + * @defaultValue `false` + */ + fullPage?: boolean; + /** + * Hides default white background and allows capturing screenshots with transparency. + * + * @defaultValue `false` + */ + omitBackground?: boolean; + /** + * The file path to save the image to. The screenshot type will be inferred + * from file extension. If path is a relative path, then it is resolved + * relative to current working directory. If no path is provided, the image + * won't be saved to the disk. + */ + path?: string; + /** + * Specifies the region of the page to clip. + */ + clip?: ScreenshotClip; + /** + * Encoding of the image. + * + * @defaultValue `'binary'` + */ + encoding?: 'base64' | 'binary'; + /** + * Capture the screenshot beyond the viewport. + * + * @defaultValue `false` if there is no `clip`. `true` otherwise. + */ + captureBeyondViewport?: boolean; +} + +/** + * @public + * @experimental + */ +export interface ScreencastOptions { + /** + * File path to save the screencast to. + */ + path?: `${string}.webm`; + /** + * Specifies the region of the viewport to crop. + */ + crop?: BoundingBox; + /** + * Scales the output video. + * + * For example, `0.5` will shrink the width and height of the output video by + * half. `2` will double the width and height of the output video. + * + * @defaultValue `1` + */ + scale?: number; + /** + * Specifies the speed to record at. + * + * For example, `0.5` will slowdown the output video by 50%. `2` will double the + * speed of the output video. + * + * @defaultValue `1` + */ + speed?: number; + /** + * Path to the [ffmpeg](https://ffmpeg.org/). + * + * Required if `ffmpeg` is not in your PATH. + */ + ffmpegPath?: string; +} + +/** + * All the events that a page instance may emit. + * + * @public + */ +export const enum PageEvent { + /** + * Emitted when the page closes. + */ + Close = 'close', + /** + * Emitted when JavaScript within the page calls one of console API methods, + * e.g. `console.log` or `console.dir`. Also emitted if the page throws an + * error or a warning. + * + * @remarks + * A `console` event provides a {@link ConsoleMessage} representing the + * console message that was logged. + * + * @example + * An example of handling `console` event: + * + * ```ts + * page.on('console', msg => { + * for (let i = 0; i < msg.args().length; ++i) + * console.log(`${i}: ${msg.args()[i]}`); + * }); + * page.evaluate(() => console.log('hello', 5, {foo: 'bar'})); + * ``` + */ + Console = 'console', + /** + * Emitted when a JavaScript dialog appears, such as `alert`, `prompt`, + * `confirm` or `beforeunload`. Puppeteer can respond to the dialog via + * {@link Dialog.accept} or {@link Dialog.dismiss}. + */ + Dialog = 'dialog', + /** + * Emitted when the JavaScript + * {@link https://developer.mozilla.org/en-US/docs/Web/Events/DOMContentLoaded | DOMContentLoaded } + * event is dispatched. + */ + DOMContentLoaded = 'domcontentloaded', + /** + * Emitted when the page crashes. Will contain an `Error`. + */ + Error = 'error', + /** Emitted when a frame is attached. Will contain a {@link Frame}. */ + FrameAttached = 'frameattached', + /** Emitted when a frame is detached. Will contain a {@link Frame}. */ + FrameDetached = 'framedetached', + /** + * Emitted when a frame is navigated to a new URL. Will contain a + * {@link Frame}. + */ + FrameNavigated = 'framenavigated', + /** + * Emitted when the JavaScript + * {@link https://developer.mozilla.org/en-US/docs/Web/Events/load | load} + * event is dispatched. + */ + Load = 'load', + /** + * Emitted when the JavaScript code makes a call to `console.timeStamp`. For + * the list of metrics see {@link Page.metrics | page.metrics}. + * + * @remarks + * Contains an object with two properties: + * + * - `title`: the title passed to `console.timeStamp` + * - `metrics`: object containing metrics as key/value pairs. The values will + * be `number`s. + */ + Metrics = 'metrics', + /** + * Emitted when an uncaught exception happens within the page. Contains an + * `Error`. + */ + PageError = 'pageerror', + /** + * Emitted when the page opens a new tab or window. + * + * Contains a {@link Page} corresponding to the popup window. + * + * @example + * + * ```ts + * const [popup] = await Promise.all([ + * new Promise(resolve => page.once('popup', resolve)), + * page.click('a[target=_blank]'), + * ]); + * ``` + * + * ```ts + * const [popup] = await Promise.all([ + * new Promise(resolve => page.once('popup', resolve)), + * page.evaluate(() => window.open('https://example.com')), + * ]); + * ``` + */ + Popup = 'popup', + /** + * Emitted when a page issues a request and contains a {@link HTTPRequest}. + * + * @remarks + * The object is readonly. See {@link Page.setRequestInterception} for + * intercepting and mutating requests. + */ + Request = 'request', + /** + * Emitted when a request ended up loading from cache. Contains a + * {@link HTTPRequest}. + * + * @remarks + * For certain requests, might contain undefined. + * {@link https://crbug.com/750469} + */ + RequestServedFromCache = 'requestservedfromcache', + /** + * Emitted when a request fails, for example by timing out. + * + * Contains a {@link HTTPRequest}. + * + * @remarks + * HTTP Error responses, such as 404 or 503, are still successful responses + * from HTTP standpoint, so request will complete with `requestfinished` event + * and not with `requestfailed`. + */ + RequestFailed = 'requestfailed', + /** + * Emitted when a request finishes successfully. Contains a + * {@link HTTPRequest}. + */ + RequestFinished = 'requestfinished', + /** + * Emitted when a response is received. Contains a {@link HTTPResponse}. + */ + Response = 'response', + /** + * Emitted when a dedicated + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API | WebWorker} + * is spawned by the page. + */ + WorkerCreated = 'workercreated', + /** + * Emitted when a dedicated + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API | WebWorker} + * is destroyed by the page. + */ + 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. + * + * See {@link PageEvent} for more detail on the events and when they are + * emitted. + * + * @public + */ +export interface PageEvents extends Record<EventType, unknown> { + [PageEvent.Close]: undefined; + [PageEvent.Console]: ConsoleMessage; + [PageEvent.Dialog]: Dialog; + [PageEvent.DOMContentLoaded]: undefined; + [PageEvent.Error]: Error; + [PageEvent.FrameAttached]: Frame; + [PageEvent.FrameDetached]: Frame; + [PageEvent.FrameNavigated]: Frame; + [PageEvent.Load]: undefined; + [PageEvent.Metrics]: {title: string; metrics: Metrics}; + [PageEvent.PageError]: Error; + [PageEvent.Popup]: Page | null; + [PageEvent.Request]: HTTPRequest; + [PageEvent.Response]: HTTPResponse; + [PageEvent.RequestFailed]: HTTPRequest; + [PageEvent.RequestFinished]: HTTPRequest; + [PageEvent.RequestServedFromCache]: HTTPRequest; + [PageEvent.WorkerCreated]: WebWorker; + [PageEvent.WorkerDestroyed]: WebWorker; +} + +export type { + /** + * @deprecated Use {@link PageEvents}. + */ + PageEvents as PageEventObject, +}; + +/** + * @public + */ +export interface NewDocumentScriptEvaluation { + identifier: string; +} + +/** + * @internal + */ +export function setDefaultScreenshotOptions(options: ScreenshotOptions): void { + options.optimizeForSpeed ??= false; + options.type ??= 'png'; + options.fromSurface ??= true; + options.fullPage ??= false; + options.omitBackground ??= false; + options.encoding ??= 'binary'; + options.captureBeyondViewport ??= true; +} + +/** + * Page provides methods to interact with a single tab or + * {@link https://developer.chrome.com/extensions/background_pages | extension background page} + * in the browser. + * + * :::note + * + * One Browser instance might have multiple Page instances. + * + * ::: + * + * @example + * This example creates a page, navigates it to a URL, and then saves a screenshot: + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://example.com'); + * await page.screenshot({path: 'screenshot.png'}); + * await browser.close(); + * })(); + * ``` + * + * The Page class extends from Puppeteer's {@link EventEmitter} class and will + * emit various events which are documented in the {@link PageEvent} enum. + * + * @example + * This example logs a message for a single page `load` event: + * + * ```ts + * page.once('load', () => console.log('Page loaded!')); + * ``` + * + * To unsubscribe from events use the {@link EventEmitter.off} method: + * + * ```ts + * function logRequest(interceptedRequest) { + * console.log('A request was made:', interceptedRequest.url()); + * } + * page.on('request', logRequest); + * // Sometime later... + * page.off('request', logRequest); + * ``` + * + * @public + */ +export abstract class Page extends EventEmitter<PageEvents> { + /** + * @internal + */ + _isDragging = false; + /** + * @internal + */ + _timeoutSettings = new TimeoutSettings(); + + #requestHandlers = new WeakMap<Handler<HTTPRequest>, Handler<HTTPRequest>>(); + + #requestsInFlight = 0; + #inflight$: Observable<number>; + + /** + * @internal + */ + 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; + }) + ) + ).pipe( + map(() => { + return -1; + }) + ) + ); + }) + ); + + this.#inflight$.subscribe(count => { + this.#requestsInFlight += count; + }); + } + + /** + * `true` if the service worker are being bypassed, `false` otherwise. + */ + abstract isServiceWorkerBypassed(): boolean; + + /** + * `true` if drag events are being intercepted, `false` otherwise. + * + * @deprecated We no longer support intercepting drag payloads. Use the new + * drag APIs found on {@link ElementHandle} to drag (or just use the + * {@link Page | Page.mouse}). + */ + abstract isDragInterceptionEnabled(): boolean; + + /** + * `true` if the page has JavaScript enabled, `false` otherwise. + */ + abstract isJavaScriptEnabled(): boolean; + + /** + * Listen to page events. + * + * @remarks + * This method exists to define event typings and handle proper wireup of + * cooperative request interception. Actual event listening and dispatching is + * delegated to {@link EventEmitter}. + * + * @internal + */ + override on<K extends keyof EventsWithWildcard<PageEvents>>( + type: K, + handler: (event: EventsWithWildcard<PageEvents>[K]) => void + ): this { + if (type !== PageEvent.Request) { + return super.on(type, handler); + } + let wrapper = this.#requestHandlers.get( + handler as (event: PageEvents[PageEvent.Request]) => void + ); + if (wrapper === undefined) { + wrapper = (event: HTTPRequest) => { + event.enqueueInterceptAction(() => { + return handler(event as EventsWithWildcard<PageEvents>[K]); + }); + }; + this.#requestHandlers.set( + handler as (event: PageEvents[PageEvent.Request]) => void, + wrapper + ); + } + return super.on( + type, + wrapper as (event: EventsWithWildcard<PageEvents>[K]) => void + ); + } + + /** + * @internal + */ + override off<K extends keyof EventsWithWildcard<PageEvents>>( + type: K, + handler: (event: EventsWithWildcard<PageEvents>[K]) => void + ): this { + if (type === PageEvent.Request) { + handler = + (this.#requestHandlers.get( + handler as ( + event: EventsWithWildcard<PageEvents>[PageEvent.Request] + ) => void + ) as (event: EventsWithWildcard<PageEvents>[K]) => void) || handler; + } + return super.off(type, handler); + } + + /** + * This method is typically coupled with an action that triggers file + * choosing. + * + * :::caution + * + * This must be called before the file chooser is launched. It will not return + * a currently active file chooser. + * + * ::: + * + * @remarks + * In the "headful" browser, this method results in the native file picker + * dialog `not showing up` for the user. + * + * @example + * The following example clicks a button that issues a file chooser + * and then responds with `/tmp/myfile.pdf` as if a user has selected this file. + * + * ```ts + * const [fileChooser] = await Promise.all([ + * page.waitForFileChooser(), + * page.click('#upload-file-button'), + * // some button that triggers file selection + * ]); + * await fileChooser.accept(['/tmp/myfile.pdf']); + * ``` + */ + abstract waitForFileChooser( + options?: WaitTimeoutOptions + ): Promise<FileChooser>; + + /** + * Sets the page's geolocation. + * + * @remarks + * Consider using {@link BrowserContext.overridePermissions} to grant + * permissions for the page to read its geolocation. + * + * @example + * + * ```ts + * await page.setGeolocation({latitude: 59.95, longitude: 30.31667}); + * ``` + */ + abstract setGeolocation(options: GeolocationOptions): Promise<void>; + + /** + * A target this page was created from. + */ + abstract target(): Target; + + /** + * Get the browser the page belongs to. + */ + abstract browser(): Browser; + + /** + * Get the browser context that the page belongs to. + */ + abstract browserContext(): BrowserContext; + + /** + * The page's main frame. + * + * @remarks + * Page is guaranteed to have a main frame which persists during navigations. + */ + abstract mainFrame(): Frame; + + /** + * Creates a Chrome Devtools Protocol session attached to the page. + */ + abstract createCDPSession(): Promise<CDPSession>; + + /** + * {@inheritDoc Keyboard} + */ + abstract get keyboard(): Keyboard; + + /** + * {@inheritDoc Touchscreen} + */ + abstract get touchscreen(): Touchscreen; + + /** + * {@inheritDoc Coverage} + */ + abstract get coverage(): Coverage; + + /** + * {@inheritDoc Tracing} + */ + abstract get tracing(): Tracing; + + /** + * {@inheritDoc Accessibility} + */ + abstract get accessibility(): Accessibility; + + /** + * An array of all frames attached to the page. + */ + abstract frames(): Frame[]; + + /** + * All of the dedicated {@link + * https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API | + * WebWorkers} associated with the page. + * + * @remarks + * This does not contain ServiceWorkers + */ + abstract workers(): WebWorker[]; + + /** + * Activating request interception enables {@link HTTPRequest.abort}, + * {@link HTTPRequest.continue} and {@link HTTPRequest.respond} methods. This + * provides the capability to modify network requests that are made by a page. + * + * Once request interception is enabled, every request will stall unless it's + * continued, responded or aborted; or completed using the browser cache. + * + * See the + * {@link https://pptr.dev/next/guides/request-interception|Request interception guide} + * for more details. + * + * @example + * An example of a naïve request interceptor that aborts all image requests: + * + * ```ts + * import puppeteer from 'puppeteer'; + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.setRequestInterception(true); + * page.on('request', interceptedRequest => { + * if ( + * interceptedRequest.url().endsWith('.png') || + * interceptedRequest.url().endsWith('.jpg') + * ) + * interceptedRequest.abort(); + * else interceptedRequest.continue(); + * }); + * await page.goto('https://example.com'); + * await browser.close(); + * })(); + * ``` + * + * @param value - Whether to enable request interception. + */ + abstract setRequestInterception(value: boolean): Promise<void>; + + /** + * Toggles ignoring of service worker for each request. + * + * @param bypass - Whether to bypass service worker and load from network. + */ + abstract setBypassServiceWorker(bypass: boolean): Promise<void>; + + /** + * @param enabled - Whether to enable drag interception. + * + * @deprecated We no longer support intercepting drag payloads. Use the new + * drag APIs found on {@link ElementHandle} to drag (or just use the + * {@link Page | Page.mouse}). + */ + abstract setDragInterception(enabled: boolean): Promise<void>; + + /** + * Sets the network connection to offline. + * + * It does not change the parameters used in {@link Page.emulateNetworkConditions} + * + * @param enabled - When `true`, enables offline mode for the page. + */ + abstract setOfflineMode(enabled: boolean): Promise<void>; + + /** + * This does not affect WebSockets and WebRTC PeerConnections (see + * https://crbug.com/563644). To set the page offline, you can use + * {@link Page.setOfflineMode}. + * + * A list of predefined network conditions can be used by importing + * {@link PredefinedNetworkConditions}. + * + * @example + * + * ```ts + * import {PredefinedNetworkConditions} from 'puppeteer'; + * const slow3G = PredefinedNetworkConditions['Slow 3G']; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.emulateNetworkConditions(slow3G); + * await page.goto('https://www.google.com'); + * // other actions... + * await browser.close(); + * })(); + * ``` + * + * @param networkConditions - Passing `null` disables network condition + * emulation. + */ + abstract emulateNetworkConditions( + networkConditions: NetworkConditions | null + ): Promise<void>; + + /** + * This setting will change the default maximum navigation time for the + * following methods and related shortcuts: + * + * - {@link Page.goBack | page.goBack(options)} + * + * - {@link Page.goForward | page.goForward(options)} + * + * - {@link Page.goto | page.goto(url,options)} + * + * - {@link Page.reload | page.reload(options)} + * + * - {@link Page.setContent | page.setContent(html,options)} + * + * - {@link Page.waitForNavigation | page.waitForNavigation(options)} + * @param timeout - Maximum navigation time in milliseconds. + */ + abstract setDefaultNavigationTimeout(timeout: number): void; + + /** + * @param timeout - Maximum time in milliseconds. + */ + abstract setDefaultTimeout(timeout: number): void; + + /** + * Maximum time in milliseconds. + */ + abstract getDefaultTimeout(): number; + + /** + * Creates a locator for the provided selector. See {@link Locator} for + * details and supported actions. + * + * @remarks + * Locators API is experimental and we will not follow semver for breaking + * change in the Locators API. + */ + locator<Selector extends string>( + selector: Selector + ): Locator<NodeFor<Selector>>; + + /** + * Creates a locator for the provided function. See {@link Locator} for + * details and supported actions. + * + * @remarks + * Locators API is experimental and we will not follow semver for breaking + * change in the Locators API. + */ + locator<Ret>(func: () => Awaitable<Ret>): Locator<Ret>; + locator<Selector extends string, Ret>( + selectorOrFunc: Selector | (() => Awaitable<Ret>) + ): Locator<NodeFor<Selector>> | Locator<Ret> { + if (typeof selectorOrFunc === 'string') { + return NodeLocator.create(this, selectorOrFunc); + } else { + return FunctionLocator.create(this, selectorOrFunc); + } + } + + /** + * A shortcut for {@link Locator.race} that does not require static imports. + * + * @internal + */ + locatorRace<Locators extends readonly unknown[] | []>( + locators: Locators + ): Locator<AwaitedLocator<Locators[number]>> { + return Locator.race(locators); + } + + /** + * Runs `document.querySelector` within the page. If no element matches the + * selector, the return value resolves to `null`. + * + * @param selector - A `selector` to query page for + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector} + * to query page for. + */ + async $<Selector extends string>( + selector: Selector + ): Promise<ElementHandle<NodeFor<Selector>> | null> { + return await this.mainFrame().$(selector); + } + + /** + * The method runs `document.querySelectorAll` within the page. If no elements + * match the selector, the return value resolves to `[]`. + * + * @param selector - A `selector` to query page for + * + * @remarks + * + * Shortcut for {@link Frame.$$ | Page.mainFrame().$$(selector) }. + */ + async $$<Selector extends string>( + selector: Selector + ): Promise<Array<ElementHandle<NodeFor<Selector>>>> { + return await this.mainFrame().$$(selector); + } + + /** + * @remarks + * + * The only difference between {@link Page.evaluate | page.evaluate} and + * `page.evaluateHandle` is that `evaluateHandle` will return the value + * wrapped in an in-page object. + * + * If the function passed to `page.evaluateHandle` returns a Promise, the + * function will wait for the promise to resolve and return its value. + * + * You can pass a string instead of a function (although functions are + * recommended as they are easier to debug and use with TypeScript): + * + * @example + * + * ```ts + * const aHandle = await page.evaluateHandle('document'); + * ``` + * + * @example + * {@link JSHandle} instances can be passed as arguments to the `pageFunction`: + * + * ```ts + * const aHandle = await page.evaluateHandle(() => document.body); + * const resultHandle = await page.evaluateHandle( + * body => body.innerHTML, + * aHandle + * ); + * console.log(await resultHandle.jsonValue()); + * await resultHandle.dispose(); + * ``` + * + * Most of the time this function returns a {@link JSHandle}, + * but if `pageFunction` returns a reference to an element, + * you instead get an {@link ElementHandle} back: + * + * @example + * + * ```ts + * const button = await page.evaluateHandle(() => + * document.querySelector('button') + * ); + * // can call `click` because `button` is an `ElementHandle` + * await button.click(); + * ``` + * + * The TypeScript definitions assume that `evaluateHandle` returns + * a `JSHandle`, but if you know it's going to return an + * `ElementHandle`, pass it as the generic argument: + * + * ```ts + * const button = await page.evaluateHandle<ElementHandle>(...); + * ``` + * + * @param pageFunction - a function that is run within the page + * @param args - arguments to be passed to the pageFunction + */ + 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.mainFrame().evaluateHandle(pageFunction, ...args); + } + + /** + * This method iterates the JavaScript heap and finds all objects with the + * given prototype. + * + * @example + * + * ```ts + * // Create a Map object + * await page.evaluate(() => (window.map = new Map())); + * // Get a handle to the Map object prototype + * const mapPrototype = await page.evaluateHandle(() => Map.prototype); + * // Query all map instances into an array + * const mapInstances = await page.queryObjects(mapPrototype); + * // Count amount of map objects in heap + * const count = await page.evaluate(maps => maps.length, mapInstances); + * await mapInstances.dispose(); + * await mapPrototype.dispose(); + * ``` + * + * @param prototypeHandle - a handle to the object prototype. + * @returns Promise which resolves to a handle to an array of objects with + * this prototype. + */ + abstract queryObjects<Prototype>( + prototypeHandle: JSHandle<Prototype> + ): Promise<JSHandle<Prototype[]>>; + + /** + * This method runs `document.querySelector` within the page and passes the + * result as the first argument to the `pageFunction`. + * + * @remarks + * + * If no element is found matching `selector`, the method will throw an error. + * + * If `pageFunction` returns a promise `$eval` will wait for the promise to + * resolve and then return its value. + * + * @example + * + * ```ts + * const searchValue = await page.$eval('#search', el => el.value); + * const preloadHref = await page.$eval('link[rel=preload]', el => el.href); + * const html = await page.$eval('.main-container', el => el.outerHTML); + * ``` + * + * If you are using TypeScript, you may have to provide an explicit type to the + * first argument of the `pageFunction`. + * By default it is typed as `Element`, but you may need to provide a more + * specific sub-type: + * + * @example + * + * ```ts + * // if you don't provide HTMLInputElement here, TS will error + * // as `value` is not on `Element` + * const searchValue = await page.$eval( + * '#search', + * (el: HTMLInputElement) => el.value + * ); + * ``` + * + * The compiler should be able to infer the return type + * from the `pageFunction` you provide. If it is unable to, you can use the generic + * type to tell the compiler what return type you expect from `$eval`: + * + * @example + * + * ```ts + * // The compiler can infer the return type in this case, but if it can't + * // or if you want to be more explicit, provide it as the generic type. + * const searchValue = await page.$eval<string>( + * '#search', + * (el: HTMLInputElement) => el.value + * ); + * ``` + * + * @param selector - the + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector} + * to query for + * @param pageFunction - the function to be evaluated in the page context. + * Will be passed the result of `document.querySelector(selector)` as its + * first argument. + * @param args - any additional arguments to pass through to `pageFunction`. + * + * @returns The result of calling `pageFunction`. If it returns an element it + * is wrapped in an {@link ElementHandle}, else the raw value itself is + * returned. + */ + async $eval< + Selector extends string, + Params extends unknown[], + Func extends EvaluateFuncWith<NodeFor<Selector>, Params> = EvaluateFuncWith< + NodeFor<Selector>, + Params + >, + >( + selector: Selector, + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + pageFunction = withSourcePuppeteerURLIfNone(this.$eval.name, pageFunction); + return await this.mainFrame().$eval(selector, pageFunction, ...args); + } + + /** + * This method runs `Array.from(document.querySelectorAll(selector))` within + * the page and passes the result as the first argument to the `pageFunction`. + * + * @remarks + * If `pageFunction` returns a promise `$$eval` will wait for the promise to + * resolve and then return its value. + * + * @example + * + * ```ts + * // get the amount of divs on the page + * const divCount = await page.$$eval('div', divs => divs.length); + * + * // get the text content of all the `.options` elements: + * const options = await page.$$eval('div > span.options', options => { + * return options.map(option => option.textContent); + * }); + * ``` + * + * If you are using TypeScript, you may have to provide an explicit type to the + * first argument of the `pageFunction`. + * By default it is typed as `Element[]`, but you may need to provide a more + * specific sub-type: + * + * @example + * + * ```ts + * // if you don't provide HTMLInputElement here, TS will error + * // as `value` is not on `Element` + * await page.$$eval('input', (elements: HTMLInputElement[]) => { + * return elements.map(e => e.value); + * }); + * ``` + * + * The compiler should be able to infer the return type + * from the `pageFunction` you provide. If it is unable to, you can use the generic + * type to tell the compiler what return type you expect from `$$eval`: + * + * @example + * + * ```ts + * // The compiler can infer the return type in this case, but if it can't + * // or if you want to be more explicit, provide it as the generic type. + * const allInputValues = await page.$$eval<string[]>( + * 'input', + * (elements: HTMLInputElement[]) => elements.map(e => e.textContent) + * ); + * ``` + * + * @param selector - the + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector} + * to query for + * @param pageFunction - the function to be evaluated in the page context. + * Will be passed the result of + * `Array.from(document.querySelectorAll(selector))` as its first argument. + * @param args - any additional arguments to pass through to `pageFunction`. + * + * @returns The result of calling `pageFunction`. If it returns an element it + * is wrapped in an {@link ElementHandle}, else the raw value itself is + * returned. + */ + async $$eval< + Selector extends string, + Params extends unknown[], + Func extends EvaluateFuncWith< + Array<NodeFor<Selector>>, + Params + > = EvaluateFuncWith<Array<NodeFor<Selector>>, Params>, + >( + selector: Selector, + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + pageFunction = withSourcePuppeteerURLIfNone(this.$$eval.name, pageFunction); + return await this.mainFrame().$$eval(selector, pageFunction, ...args); + } + + /** + * 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 deleteCookie( + ...cookies: Protocol.Network.DeleteCookiesRequest[] + ): Promise<void>; + + /** + * @example + * + * ```ts + * await page.setCookie(cookieObject1, cookieObject2); + * ``` + */ + abstract setCookie(...cookies: Protocol.Network.CookieParam[]): Promise<void>; + + /** + * Adds a `<script>` tag into the page with the desired URL or content. + * + * @remarks + * Shortcut for + * {@link Frame.addScriptTag | page.mainFrame().addScriptTag(options)}. + * + * @param options - Options for the script. + * @returns An {@link ElementHandle | element handle} to the injected + * `<script>` element. + */ + async addScriptTag( + options: FrameAddScriptTagOptions + ): Promise<ElementHandle<HTMLScriptElement>> { + return await this.mainFrame().addScriptTag(options); + } + + /** + * Adds a `<link rel="stylesheet">` tag into the page with the desired URL or + * a `<style type="text/css">` tag with the content. + * + * Shortcut for + * {@link Frame.(addStyleTag:2) | page.mainFrame().addStyleTag(options)}. + * + * @returns An {@link ElementHandle | element handle} to the injected `<link>` + * or `<style>` element. + */ + async addStyleTag( + options: Omit<FrameAddStyleTagOptions, 'url'> + ): Promise<ElementHandle<HTMLStyleElement>>; + async addStyleTag( + options: FrameAddStyleTagOptions + ): Promise<ElementHandle<HTMLLinkElement>>; + async addStyleTag( + options: FrameAddStyleTagOptions + ): Promise<ElementHandle<HTMLStyleElement | HTMLLinkElement>> { + return await this.mainFrame().addStyleTag(options); + } + + /** + * The method adds a function called `name` on the page's `window` object. + * When called, the function executes `puppeteerFunction` in node.js and + * returns a `Promise` which resolves to the return value of + * `puppeteerFunction`. + * + * If the puppeteerFunction returns a `Promise`, it will be awaited. + * + * :::note + * + * Functions installed via `page.exposeFunction` survive navigations. + * + * :::note + * + * @example + * An example of adding an `md5` function into the page: + * + * ```ts + * import puppeteer from 'puppeteer'; + * import crypto from 'crypto'; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * page.on('console', msg => console.log(msg.text())); + * await page.exposeFunction('md5', text => + * crypto.createHash('md5').update(text).digest('hex') + * ); + * await page.evaluate(async () => { + * // use window.md5 to compute hashes + * const myString = 'PUPPETEER'; + * const myHash = await window.md5(myString); + * console.log(`md5 of ${myString} is ${myHash}`); + * }); + * await browser.close(); + * })(); + * ``` + * + * @example + * An example of adding a `window.readfile` function into the page: + * + * ```ts + * import puppeteer from 'puppeteer'; + * import fs from 'fs'; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * page.on('console', msg => console.log(msg.text())); + * await page.exposeFunction('readfile', async filePath => { + * return new Promise((resolve, reject) => { + * fs.readFile(filePath, 'utf8', (err, text) => { + * if (err) reject(err); + * else resolve(text); + * }); + * }); + * }); + * await page.evaluate(async () => { + * // use window.readfile to read contents of a file + * const content = await window.readfile('/etc/hosts'); + * console.log(content); + * }); + * await browser.close(); + * })(); + * ``` + * + * @param name - Name of the function on the window object + * @param pptrFunction - Callback function which will be called in Puppeteer's + * context. + */ + abstract exposeFunction( + name: string, + pptrFunction: Function | {default: Function} + ): Promise<void>; + + /** + * The method removes a previously added function via ${@link Page.exposeFunction} + * called `name` from the page's `window` object. + */ + abstract removeExposedFunction(name: string): Promise<void>; + + /** + * Provide credentials for `HTTP authentication`. + * + * @remarks + * To disable authentication, pass `null`. + */ + abstract authenticate(credentials: Credentials): Promise<void>; + + /** + * The extra HTTP headers will be sent with every request the page initiates. + * + * :::tip + * + * All HTTP header names are lowercased. (HTTP headers are + * case-insensitive, so this shouldn’t impact your server code.) + * + * ::: + * + * :::note + * + * page.setExtraHTTPHeaders does not guarantee the order of headers in + * the outgoing requests. + * + * ::: + * + * @param headers - An object containing additional HTTP headers to be sent + * with every request. All header values must be strings. + */ + abstract setExtraHTTPHeaders(headers: Record<string, string>): Promise<void>; + + /** + * @param userAgent - Specific user agent to use in this page + * @param userAgentData - Specific user agent client hint data to use in this + * page + * @returns Promise which resolves when the user agent is set. + */ + abstract setUserAgent( + userAgent: string, + userAgentMetadata?: Protocol.Emulation.UserAgentMetadata + ): Promise<void>; + + /** + * Object containing metrics as key/value pairs. + * + * @returns + * + * - `Timestamp` : The timestamp when the metrics sample was taken. + * + * - `Documents` : Number of documents in the page. + * + * - `Frames` : Number of frames in the page. + * + * - `JSEventListeners` : Number of events in the page. + * + * - `Nodes` : Number of DOM nodes in the page. + * + * - `LayoutCount` : Total number of full or partial page layout. + * + * - `RecalcStyleCount` : Total number of page style recalculations. + * + * - `LayoutDuration` : Combined durations of all page layouts. + * + * - `RecalcStyleDuration` : Combined duration of all page style + * recalculations. + * + * - `ScriptDuration` : Combined duration of JavaScript execution. + * + * - `TaskDuration` : Combined duration of all tasks performed by the browser. + * + * - `JSHeapUsedSize` : Used JavaScript heap size. + * + * - `JSHeapTotalSize` : Total JavaScript heap size. + * + * @remarks + * All timestamps are in monotonic time: monotonically increasing time + * in seconds since an arbitrary point in the past. + */ + abstract metrics(): Promise<Metrics>; + + /** + * The page's URL. + * + * @remarks + * + * Shortcut for {@link Frame.url | page.mainFrame().url()}. + */ + url(): string { + return this.mainFrame().url(); + } + + /** + * The full HTML contents of the page, including the DOCTYPE. + */ + async content(): Promise<string> { + return await this.mainFrame().content(); + } + + /** + * Set the content of the page. + * + * @param html - HTML markup to assign to the page. + * @param options - Parameters that has some properties. + * + * @remarks + * + * The parameter `options` might have the following options. + * + * - `timeout` : Maximum time in milliseconds for resources to load, defaults + * to 30 seconds, pass `0` to disable timeout. The default value can be + * changed by using the {@link Page.setDefaultNavigationTimeout} or + * {@link Page.setDefaultTimeout} methods. + * + * - `waitUntil`: When to consider setting markup succeeded, defaults to + * `load`. Given an array of event strings, setting content is considered + * to be successful after all events have been fired. Events can be + * either:<br/> + * - `load` : consider setting content to be finished when the `load` event + * is fired.<br/> + * - `domcontentloaded` : consider setting content to be finished when the + * `DOMContentLoaded` event is fired.<br/> + * - `networkidle0` : consider setting content to be finished when there are + * no more than 0 network connections for at least `500` ms.<br/> + * - `networkidle2` : consider setting content to be finished when there are + * no more than 2 network connections for at least `500` ms. + */ + async setContent(html: string, options?: WaitForOptions): Promise<void> { + await this.mainFrame().setContent(html, options); + } + + /** + * Navigates the page to the given `url`. + * + * @remarks + * + * Navigation to `about:blank` or navigation to the same URL with a different + * hash will succeed and return `null`. + * + * :::warning + * + * Headless mode doesn't support navigation to a PDF document. See the {@link + * https://bugs.chromium.org/p/chromium/issues/detail?id=761295 | upstream + * issue}. + * + * ::: + * + * Shortcut for {@link Frame.goto | page.mainFrame().goto(url, options)}. + * + * @param url - URL to navigate page to. The URL should include scheme, e.g. + * `https://` + * @param options - Options to configure waiting behavior. + * @returns A promise which resolves to the main resource response. In case of + * multiple redirects, the navigation will resolve with the response of the + * last redirect. + * @throws If: + * + * - there's an SSL error (e.g. in case of self-signed certificates). + * - target URL is invalid. + * - the timeout is exceeded during navigation. + * - the remote server does not respond or is unreachable. + * - the main resource failed to load. + * + * This method will not throw an error when any valid HTTP status code is + * returned by the remote server, including 404 "Not Found" and 500 "Internal + * Server Error". The status code for such responses can be retrieved by + * calling {@link HTTPResponse.status}. + */ + async goto(url: string, options?: GoToOptions): Promise<HTTPResponse | null> { + return await this.mainFrame().goto(url, options); + } + + /** + * Reloads the page. + * + * @param options - Options to configure waiting behavior. + * @returns A promise which resolves to the main resource response. In case of + * multiple redirects, the navigation will resolve with the response of the + * last redirect. + */ + abstract reload(options?: WaitForOptions): Promise<HTTPResponse | null>; + + /** + * Waits for the page to navigate to a new URL or to reload. It is useful when + * you run code that will indirectly cause the page to navigate. + * + * @example + * + * ```ts + * const [response] = await Promise.all([ + * page.waitForNavigation(), // The promise resolves after navigation has finished + * page.click('a.my-link'), // Clicking the link will indirectly cause a navigation + * ]); + * ``` + * + * @remarks + * + * Usage of the + * {@link https://developer.mozilla.org/en-US/docs/Web/API/History_API | History API} + * to change the URL is considered a navigation. + * + * @param options - Navigation parameters which might have the following + * properties: + * @returns A `Promise` which resolves to the main resource response. + * + * - In case of multiple redirects, the navigation will resolve with the + * response of the last redirect. + * - In case of navigation to a different anchor or navigation due to History + * API usage, the navigation will resolve with `null`. + */ + async waitForNavigation( + options: WaitForOptions = {} + ): Promise<HTTPResponse | null> { + return await this.mainFrame().waitForNavigation(options); + } + + /** + * @param urlOrPredicate - A URL or predicate to wait for + * @param options - Optional waiting parameters + * @returns Promise which resolves to the matched request + * @example + * + * ```ts + * const firstRequest = await page.waitForRequest( + * 'https://example.com/resource' + * ); + * const finalRequest = await page.waitForRequest( + * request => request.url() === 'https://example.com' + * ); + * return finalRequest.response()?.ok(); + * ``` + * + * @remarks + * Optional Waiting Parameters have: + * + * - `timeout`: Maximum wait time in milliseconds, defaults to `30` seconds, pass + * `0` to disable the timeout. The default value can be changed by using the + * {@link Page.setDefaultTimeout} method. + */ + waitForRequest( + urlOrPredicate: string | AwaitablePredicate<HTTPRequest>, + options: WaitTimeoutOptions = {} + ): Promise<HTTPRequest> { + const {timeout: ms = this._timeoutSettings.timeout()} = options; + if (typeof urlOrPredicate === 'string') { + const url = urlOrPredicate; + urlOrPredicate = (request: HTTPRequest) => { + return request.url() === url; + }; + } + const observable$ = fromEmitterEvent(this, PageEvent.Request).pipe( + filterAsync(urlOrPredicate), + raceWith( + timeout(ms), + fromEmitterEvent(this, PageEvent.Close).pipe( + map(() => { + throw new TargetCloseError('Page closed!'); + }) + ) + ) + ); + return firstValueFrom(observable$); + } + + /** + * @param urlOrPredicate - A URL or predicate to wait for. + * @param options - Optional waiting parameters + * @returns Promise which resolves to the matched response. + * @example + * + * ```ts + * const firstResponse = await page.waitForResponse( + * 'https://example.com/resource' + * ); + * const finalResponse = await page.waitForResponse( + * response => + * response.url() === 'https://example.com' && response.status() === 200 + * ); + * const finalResponse = await page.waitForResponse(async response => { + * return (await response.text()).includes('<html>'); + * }); + * return finalResponse.ok(); + * ``` + * + * @remarks + * Optional Parameter have: + * + * - `timeout`: Maximum wait time in milliseconds, defaults to `30` seconds, + * pass `0` to disable the timeout. The default value can be changed by using + * the {@link Page.setDefaultTimeout} method. + */ + waitForResponse( + urlOrPredicate: string | AwaitablePredicate<HTTPResponse>, + options: WaitTimeoutOptions = {} + ): Promise<HTTPResponse> { + const {timeout: ms = this._timeoutSettings.timeout()} = options; + if (typeof urlOrPredicate === 'string') { + const url = urlOrPredicate; + urlOrPredicate = (response: HTTPResponse) => { + return response.url() === url; + }; + } + const observable$ = fromEmitterEvent(this, PageEvent.Response).pipe( + filterAsync(urlOrPredicate), + raceWith( + timeout(ms), + fromEmitterEvent(this, PageEvent.Close).pipe( + map(() => { + throw new TargetCloseError('Page closed!'); + }) + ) + ) + ); + return firstValueFrom(observable$); + } + + /** + * Waits for the network to be idle. + * + * @param options - Options to configure waiting behavior. + * @returns A promise which resolves once the network is idle. + */ + waitForNetworkIdle(options: WaitForNetworkIdleOptions = {}): Promise<void> { + return firstValueFrom(this.waitForNetworkIdle$(options)); + } + + /** + * @internal + */ + waitForNetworkIdle$( + options: WaitForNetworkIdleOptions = {} + ): Observable<void> { + const { + timeout: ms = this._timeoutSettings.timeout(), + idleTime = NETWORK_IDLE_TIME, + concurrency = 0, + } = options; + + return this.#inflight$.pipe( + startWith(this.#requestsInFlight), + switchMap(() => { + if (this.#requestsInFlight > concurrency) { + return EMPTY; + } else { + return timer(idleTime); + } + }), + map(() => {}), + raceWith( + timeout(ms), + fromEmitterEvent(this, PageEvent.Close).pipe( + map(() => { + throw new TargetCloseError('Page closed!'); + }) + ) + ) + ); + } + + /** + * Waits for a frame matching the given conditions to appear. + * + * @example + * + * ```ts + * const frame = await page.waitForFrame(async frame => { + * return frame.name() === 'Test'; + * }); + * ``` + */ + async waitForFrame( + urlOrPredicate: string | ((frame: Frame) => Awaitable<boolean>), + options: WaitTimeoutOptions = {} + ): Promise<Frame> { + const {timeout: ms = this.getDefaultTimeout()} = options; + + if (isString(urlOrPredicate)) { + urlOrPredicate = (frame: Frame) => { + return urlOrPredicate === frame.url(); + }; + } + + return await firstValueFrom( + merge( + fromEmitterEvent(this, PageEvent.FrameAttached), + fromEmitterEvent(this, PageEvent.FrameNavigated), + from(this.frames()) + ).pipe( + filterAsync(urlOrPredicate), + first(), + raceWith( + timeout(ms), + fromEmitterEvent(this, PageEvent.Close).pipe( + map(() => { + throw new TargetCloseError('Page closed.'); + }) + ) + ) + ) + ); + } + + /** + * This method navigate to the previous page in history. + * @param options - Navigation parameters + * @returns Promise which resolves to the main resource response. In case of + * multiple redirects, the navigation will resolve with the response of the + * last redirect. If can not go back, resolves to `null`. + * @remarks + * The argument `options` might have the following properties: + * + * - `timeout` : Maximum navigation time in milliseconds, defaults to 30 + * seconds, pass 0 to disable timeout. The default value can be changed by + * using the {@link Page.setDefaultNavigationTimeout} or + * {@link Page.setDefaultTimeout} methods. + * + * - `waitUntil` : When to consider navigation succeeded, defaults to `load`. + * Given an array of event strings, navigation is considered to be + * successful after all events have been fired. Events can be either:<br/> + * - `load` : consider navigation to be finished when the load event is + * fired.<br/> + * - `domcontentloaded` : consider navigation to be finished when the + * DOMContentLoaded event is fired.<br/> + * - `networkidle0` : consider navigation to be finished when there are no + * more than 0 network connections for at least `500` ms.<br/> + * - `networkidle2` : consider navigation to be finished when there are no + * more than 2 network connections for at least `500` ms. + */ + abstract goBack(options?: WaitForOptions): Promise<HTTPResponse | null>; + + /** + * This method navigate to the next page in history. + * @param options - Navigation Parameter + * @returns Promise which resolves to the main resource response. In case of + * multiple redirects, the navigation will resolve with the response of the + * last redirect. If can not go forward, resolves to `null`. + * @remarks + * The argument `options` might have the following properties: + * + * - `timeout` : Maximum navigation time in milliseconds, defaults to 30 + * seconds, pass 0 to disable timeout. The default value can be changed by + * using the {@link Page.setDefaultNavigationTimeout} or + * {@link Page.setDefaultTimeout} methods. + * + * - `waitUntil`: When to consider navigation succeeded, defaults to `load`. + * Given an array of event strings, navigation is considered to be + * successful after all events have been fired. Events can be either:<br/> + * - `load` : consider navigation to be finished when the load event is + * fired.<br/> + * - `domcontentloaded` : consider navigation to be finished when the + * DOMContentLoaded event is fired.<br/> + * - `networkidle0` : consider navigation to be finished when there are no + * more than 0 network connections for at least `500` ms.<br/> + * - `networkidle2` : consider navigation to be finished when there are no + * more than 2 network connections for at least `500` ms. + */ + abstract goForward(options?: WaitForOptions): Promise<HTTPResponse | null>; + + /** + * Brings page to front (activates tab). + */ + abstract bringToFront(): Promise<void>; + + /** + * Emulates a given device's metrics and user agent. + * + * To aid emulation, Puppeteer provides a list of known devices that can be + * via {@link KnownDevices}. + * + * @remarks + * This method is a shortcut for calling two methods: + * {@link Page.setUserAgent} and {@link Page.setViewport}. + * + * This method will resize the page. A lot of websites don't expect phones to + * change size, so you should emulate before navigating to the page. + * + * @example + * + * ```ts + * import {KnownDevices} from 'puppeteer'; + * const iPhone = KnownDevices['iPhone 6']; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.emulate(iPhone); + * await page.goto('https://www.google.com'); + * // other actions... + * await browser.close(); + * })(); + * ``` + */ + async emulate(device: Device): Promise<void> { + await Promise.all([ + this.setUserAgent(device.userAgent), + this.setViewport(device.viewport), + ]); + } + + /** + * @param enabled - Whether or not to enable JavaScript on the page. + * @remarks + * NOTE: changing this value won't affect scripts that have already been run. + * It will take full effect on the next navigation. + */ + abstract setJavaScriptEnabled(enabled: boolean): Promise<void>; + + /** + * Toggles bypassing page's Content-Security-Policy. + * @param enabled - sets bypassing of page's Content-Security-Policy. + * @remarks + * NOTE: CSP bypassing happens at the moment of CSP initialization rather than + * evaluation. Usually, this means that `page.setBypassCSP` should be called + * before navigating to the domain. + */ + abstract setBypassCSP(enabled: boolean): Promise<void>; + + /** + * @param type - Changes the CSS media type of the page. The only allowed + * values are `screen`, `print` and `null`. Passing `null` disables CSS media + * emulation. + * @example + * + * ```ts + * await page.evaluate(() => matchMedia('screen').matches); + * // → true + * await page.evaluate(() => matchMedia('print').matches); + * // → false + * + * await page.emulateMediaType('print'); + * await page.evaluate(() => matchMedia('screen').matches); + * // → false + * await page.evaluate(() => matchMedia('print').matches); + * // → true + * + * await page.emulateMediaType(null); + * await page.evaluate(() => matchMedia('screen').matches); + * // → true + * await page.evaluate(() => matchMedia('print').matches); + * // → false + * ``` + */ + abstract emulateMediaType(type?: string): Promise<void>; + + /** + * Enables CPU throttling to emulate slow CPUs. + * @param factor - slowdown factor (1 is no throttle, 2 is 2x slowdown, etc). + */ + abstract emulateCPUThrottling(factor: number | null): Promise<void>; + + /** + * @param features - `<?Array<Object>>` Given an array of media feature + * objects, emulates CSS media features on the page. Each media feature object + * must have the following properties: + * @example + * + * ```ts + * await page.emulateMediaFeatures([ + * {name: 'prefers-color-scheme', value: 'dark'}, + * ]); + * await page.evaluate( + * () => matchMedia('(prefers-color-scheme: dark)').matches + * ); + * // → true + * await page.evaluate( + * () => matchMedia('(prefers-color-scheme: light)').matches + * ); + * // → false + * + * await page.emulateMediaFeatures([ + * {name: 'prefers-reduced-motion', value: 'reduce'}, + * ]); + * await page.evaluate( + * () => matchMedia('(prefers-reduced-motion: reduce)').matches + * ); + * // → true + * await page.evaluate( + * () => matchMedia('(prefers-reduced-motion: no-preference)').matches + * ); + * // → false + * + * await page.emulateMediaFeatures([ + * {name: 'prefers-color-scheme', value: 'dark'}, + * {name: 'prefers-reduced-motion', value: 'reduce'}, + * ]); + * await page.evaluate( + * () => matchMedia('(prefers-color-scheme: dark)').matches + * ); + * // → true + * await page.evaluate( + * () => matchMedia('(prefers-color-scheme: light)').matches + * ); + * // → false + * await page.evaluate( + * () => matchMedia('(prefers-reduced-motion: reduce)').matches + * ); + * // → true + * await page.evaluate( + * () => matchMedia('(prefers-reduced-motion: no-preference)').matches + * ); + * // → false + * + * await page.emulateMediaFeatures([{name: 'color-gamut', value: 'p3'}]); + * await page.evaluate(() => matchMedia('(color-gamut: srgb)').matches); + * // → true + * await page.evaluate(() => matchMedia('(color-gamut: p3)').matches); + * // → true + * await page.evaluate(() => matchMedia('(color-gamut: rec2020)').matches); + * // → false + * ``` + */ + abstract emulateMediaFeatures(features?: MediaFeature[]): Promise<void>; + + /** + * @param timezoneId - Changes the timezone of the page. See + * {@link https://source.chromium.org/chromium/chromium/deps/icu.git/+/faee8bc70570192d82d2978a71e2a615788597d1:source/data/misc/metaZones.txt | ICU’s metaZones.txt} + * for a list of supported timezone IDs. Passing + * `null` disables timezone emulation. + */ + abstract emulateTimezone(timezoneId?: string): Promise<void>; + + /** + * Emulates the idle state. + * If no arguments set, clears idle state emulation. + * + * @example + * + * ```ts + * // set idle emulation + * await page.emulateIdleState({isUserActive: true, isScreenUnlocked: false}); + * + * // do some checks here + * ... + * + * // clear idle emulation + * await page.emulateIdleState(); + * ``` + * + * @param overrides - Mock idle state. If not set, clears idle overrides + */ + abstract emulateIdleState(overrides?: { + isUserActive: boolean; + isScreenUnlocked: boolean; + }): Promise<void>; + + /** + * Simulates the given vision deficiency on the page. + * + * @example + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://v8.dev/blog/10-years'); + * + * await page.emulateVisionDeficiency('achromatopsia'); + * await page.screenshot({path: 'achromatopsia.png'}); + * + * await page.emulateVisionDeficiency('deuteranopia'); + * await page.screenshot({path: 'deuteranopia.png'}); + * + * await page.emulateVisionDeficiency('blurredVision'); + * await page.screenshot({path: 'blurred-vision.png'}); + * + * await browser.close(); + * })(); + * ``` + * + * @param type - the type of deficiency to simulate, or `'none'` to reset. + */ + abstract emulateVisionDeficiency( + type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'] + ): Promise<void>; + + /** + * `page.setViewport` will resize the page. A lot of websites don't expect + * phones to change size, so you should set the viewport before navigating to + * the page. + * + * In the case of multiple pages in a single browser, each page can have its + * own viewport size. + * @example + * + * ```ts + * const page = await browser.newPage(); + * await page.setViewport({ + * width: 640, + * height: 480, + * deviceScaleFactor: 1, + * }); + * await page.goto('https://example.com'); + * ``` + * + * @param viewport - + * @remarks + * NOTE: in certain cases, setting viewport will reload the page in order to + * set the isMobile or hasTouch properties. + */ + abstract setViewport(viewport: Viewport): Promise<void>; + + /** + * Returns the current page viewport settings without checking the actual page + * viewport. + * + * This is either the viewport set with the previous {@link Page.setViewport} + * call or the default viewport set via + * {@link BrowserConnectOptions | BrowserConnectOptions.defaultViewport}. + */ + abstract viewport(): Viewport | null; + + /** + * Evaluates a function in the page's context and returns the result. + * + * If the function passed to `page.evaluate` returns a Promise, the + * function will wait for the promise to resolve and return its value. + * + * @example + * + * ```ts + * const result = await frame.evaluate(() => { + * return Promise.resolve(8 * 7); + * }); + * console.log(result); // prints "56" + * ``` + * + * You can pass a string instead of a function (although functions are + * recommended as they are easier to debug and use with TypeScript): + * + * @example + * + * ```ts + * const aHandle = await page.evaluate('1 + 2'); + * ``` + * + * To get the best TypeScript experience, you should pass in as the + * generic the type of `pageFunction`: + * + * ```ts + * const aHandle = await page.evaluate(() => 2); + * ``` + * + * @example + * + * {@link ElementHandle} instances (including {@link JSHandle}s) can be passed + * as arguments to the `pageFunction`: + * + * ```ts + * const bodyHandle = await page.$('body'); + * const html = await page.evaluate(body => body.innerHTML, bodyHandle); + * await bodyHandle.dispose(); + * ``` + * + * @param pageFunction - a function that is run within the page + * @param args - arguments to be passed to the pageFunction + * + * @returns the return value of `pageFunction`. + */ + 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.mainFrame().evaluate(pageFunction, ...args); + } + + /** + * Adds a function which would be invoked in one of the following scenarios: + * + * - whenever the page is navigated + * + * - whenever the child frame is attached or navigated. In this case, the + * function is invoked in the context of the newly attached frame. + * + * The function is invoked after the document was created but before any of + * its scripts were run. This is useful to amend the JavaScript environment, + * e.g. to seed `Math.random`. + * @param pageFunction - Function to be evaluated in browser context + * @param args - Arguments to pass to `pageFunction` + * @example + * An example of overriding the navigator.languages property before the page loads: + * + * ```ts + * // preload.js + * + * // overwrite the `languages` property to use a custom getter + * Object.defineProperty(navigator, 'languages', { + * get: function () { + * return ['en-US', 'en', 'bn']; + * }, + * }); + * + * // In your puppeteer script, assuming the preload.js file is + * // in same folder of our script. + * const preloadFile = fs.readFileSync('./preload.js', 'utf8'); + * await page.evaluateOnNewDocument(preloadFile); + * ``` + */ + abstract evaluateOnNewDocument< + Params extends unknown[], + Func extends (...args: Params) => unknown = (...args: Params) => unknown, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<NewDocumentScriptEvaluation>; + + /** + * Removes script that injected into page by Page.evaluateOnNewDocument. + * + * @param identifier - script identifier + */ + abstract removeScriptToEvaluateOnNewDocument( + identifier: string + ): Promise<void>; + + /** + * Toggles ignoring cache for each request based on the enabled state. By + * default, caching is enabled. + * @param enabled - sets the `enabled` state of cache + * @defaultValue `true` + */ + abstract setCacheEnabled(enabled?: boolean): Promise<void>; + + /** + * @internal + */ + async _maybeWriteBufferToFile( + path: string | undefined, + buffer: Buffer + ): Promise<void> { + if (!path) { + return; + } + + const fs = await importFSPromises(); + + await fs.writeFile(path, buffer); + } + + /** + * Captures a screencast of this {@link Page | page}. + * + * @example + * Recording a {@link Page | page}: + * + * ``` + * import puppeteer from 'puppeteer'; + * + * // Launch a browser + * const browser = await puppeteer.launch(); + * + * // Create a new page + * const page = await browser.newPage(); + * + * // Go to your site. + * await page.goto("https://www.example.com"); + * + * // Start recording. + * const recorder = await page.screencast({path: 'recording.webm'}); + * + * // Do something. + * + * // Stop recording. + * await recorder.stop(); + * + * browser.close(); + * ``` + * + * @param options - Configures screencast behavior. + * + * @experimental + * + * @remarks + * + * All recordings will be {@link https://www.webmproject.org/ | WebM} format using + * the {@link https://www.webmproject.org/vp9/ | VP9} video codec. The FPS is 30. + * + * You must have {@link https://ffmpeg.org/ | ffmpeg} installed on your system. + */ + async screencast( + options: Readonly<ScreencastOptions> = {} + ): Promise<ScreenRecorder> { + const [{ScreenRecorder}, [width, height, devicePixelRatio]] = + await Promise.all([ + import('../node/ScreenRecorder.js'), + this.#getNativePixelDimensions(), + ]); + + let crop: BoundingBox | undefined; + if (options.crop) { + const { + x, + y, + width: cropWidth, + height: cropHeight, + } = roundRectangle(normalizeRectangle(options.crop)); + if (x < 0 || y < 0) { + throw new Error( + `\`crop.x\` and \`crop.y\` must be greater than or equal to 0.` + ); + } + if (cropWidth <= 0 || cropHeight <= 0) { + throw new Error( + `\`crop.height\` and \`crop.width\` must be greater than or equal to 0.` + ); + } + + const viewportWidth = width / devicePixelRatio; + const viewportHeight = height / devicePixelRatio; + if (x + cropWidth > viewportWidth) { + throw new Error( + `\`crop.width\` cannot be larger than the viewport width (${viewportWidth}).` + ); + } + if (y + cropHeight > viewportHeight) { + throw new Error( + `\`crop.height\` cannot be larger than the viewport height (${viewportHeight}).` + ); + } + + crop = { + x: x * devicePixelRatio, + y: y * devicePixelRatio, + width: cropWidth * devicePixelRatio, + height: cropHeight * devicePixelRatio, + }; + } + if (options.speed !== undefined && options.speed <= 0) { + throw new Error(`\`speed\` must be greater than 0.`); + } + if (options.scale !== undefined && options.scale <= 0) { + throw new Error(`\`scale\` must be greater than 0.`); + } + + const recorder = new ScreenRecorder(this, width, height, { + ...options, + path: options.ffmpegPath, + crop, + }); + try { + await this._startScreencast(); + } catch (error) { + void recorder.stop(); + throw error; + } + if (options.path) { + const {createWriteStream} = await import('fs'); + const stream = createWriteStream(options.path, 'binary'); + recorder.pipe(stream); + } + return recorder; + } + + #screencastSessionCount = 0; + #startScreencastPromise: Promise<void> | undefined; + + /** + * @internal + */ + async _startScreencast(): Promise<void> { + ++this.#screencastSessionCount; + if (!this.#startScreencastPromise) { + this.#startScreencastPromise = this.mainFrame() + .client.send('Page.startScreencast', {format: 'png'}) + .then(() => { + // Wait for the first frame. + return new Promise(resolve => { + return this.mainFrame().client.once('Page.screencastFrame', () => { + return resolve(); + }); + }); + }); + } + await this.#startScreencastPromise; + } + + /** + * @internal + */ + async _stopScreencast(): Promise<void> { + --this.#screencastSessionCount; + if (!this.#startScreencastPromise) { + return; + } + this.#startScreencastPromise = undefined; + if (this.#screencastSessionCount === 0) { + await this.mainFrame().client.send('Page.stopScreencast'); + } + } + + /** + * Gets the native, non-emulated dimensions of the viewport. + */ + async #getNativePixelDimensions(): Promise< + readonly [width: number, height: number, devicePixelRatio: number] + > { + const viewport = this.viewport(); + using stack = new DisposableStack(); + if (viewport && viewport.deviceScaleFactor !== 0) { + await this.setViewport({...viewport, deviceScaleFactor: 0}); + stack.defer(() => { + void this.setViewport(viewport).catch(debugError); + }); + } + return await this.mainFrame() + .isolatedRealm() + .evaluate(() => { + return [ + window.visualViewport!.width * window.devicePixelRatio, + window.visualViewport!.height * window.devicePixelRatio, + window.devicePixelRatio, + ] as const; + }); + } + + /** + * Captures a screenshot of this {@link Page | page}. + * + * @param options - Configures screenshot behavior. + */ + async screenshot( + options: Readonly<ScreenshotOptions> & {encoding: 'base64'} + ): Promise<string>; + async screenshot(options?: Readonly<ScreenshotOptions>): Promise<Buffer>; + @guarded(function () { + return this.browser(); + }) + async screenshot( + userOptions: Readonly<ScreenshotOptions> = {} + ): Promise<Buffer | string> { + await this.bringToFront(); + + // TODO: use structuredClone after Node 16 support is dropped. + const options = { + ...userOptions, + clip: userOptions.clip + ? { + ...userOptions.clip, + } + : undefined, + }; + if (options.type === undefined && options.path !== undefined) { + const filePath = options.path; + // Note we cannot use Node.js here due to browser compatability. + const extension = filePath + .slice(filePath.lastIndexOf('.') + 1) + .toLowerCase(); + switch (extension) { + case 'png': + options.type = 'png'; + break; + case 'jpeg': + case 'jpg': + options.type = 'jpeg'; + break; + case 'webp': + options.type = 'webp'; + break; + } + } + if (options.quality !== undefined) { + if (options.quality < 0 && options.quality > 100) { + throw new Error( + `Expected 'quality' (${options.quality}) to be between 0 and 100, inclusive.` + ); + } + if ( + options.type === undefined || + !['jpeg', 'webp'].includes(options.type) + ) { + throw new Error( + `${options.type ?? 'png'} screenshots do not support 'quality'.` + ); + } + } + if (options.clip) { + if (options.clip.width <= 0) { + throw new Error("'width' in 'clip' must be positive."); + } + if (options.clip.height <= 0) { + throw new Error("'height' in 'clip' must be positive."); + } + } + + setDefaultScreenshotOptions(options); + + await using stack = new AsyncDisposableStack(); + if (options.clip) { + if (options.fullPage) { + throw new Error("'clip' and 'fullPage' are mutually exclusive"); + } + + options.clip = roundRectangle(normalizeRectangle(options.clip)); + } else { + if (options.fullPage) { + // If `captureBeyondViewport` is `false`, then we set the viewport to + // capture the full page. Note this may be affected by on-page CSS and + // JavaScript. + if (!options.captureBeyondViewport) { + const scrollDimensions = await this.mainFrame() + .isolatedRealm() + .evaluate(() => { + const element = document.documentElement; + return { + width: element.scrollWidth, + height: element.scrollHeight, + }; + }); + const viewport = this.viewport(); + await this.setViewport({ + ...viewport, + ...scrollDimensions, + }); + stack.defer(async () => { + if (viewport) { + await this.setViewport(viewport).catch(debugError); + } else { + await this.setViewport({ + width: 0, + height: 0, + }).catch(debugError); + } + }); + } + } else { + options.captureBeyondViewport = false; + } + } + + const data = await this._screenshot(options); + if (options.encoding === 'base64') { + return data; + } + const buffer = Buffer.from(data, 'base64'); + await this._maybeWriteBufferToFile(options.path, buffer); + return buffer; + } + + /** + * @internal + */ + abstract _screenshot(options: Readonly<ScreenshotOptions>): Promise<string>; + + /** + * Generates a PDF of the page with the `print` CSS media type. + * + * @param options - options for generating the PDF. + * + * @remarks + * + * To generate a PDF with the `screen` media type, call + * {@link Page.emulateMediaType | `page.emulateMediaType('screen')`} before + * calling `page.pdf()`. + * + * By default, `page.pdf()` generates a pdf with modified colors for printing. + * Use the + * {@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>; + + /** + * {@inheritDoc Page.createPDFStream} + */ + abstract pdf(options?: PDFOptions): Promise<Buffer>; + + /** + * The page's title + * + * @remarks + * + * Shortcut for {@link Frame.title | page.mainFrame().title()}. + */ + async title(): Promise<string> { + return await this.mainFrame().title(); + } + + abstract close(options?: {runBeforeUnload?: boolean}): Promise<void>; + + /** + * Indicates that the page has been closed. + * @returns + */ + abstract isClosed(): boolean; + + /** + * {@inheritDoc Mouse} + */ + abstract get mouse(): Mouse; + + /** + * This method fetches an element with `selector`, scrolls it into view if + * needed, and then uses {@link Page | Page.mouse} to click in the center of the + * element. If there's no element matching `selector`, the method throws an + * error. + * + * @remarks + * + * Bear in mind that if `click()` triggers a navigation event and + * there's a separate `page.waitForNavigation()` promise to be resolved, you + * may end up with a race condition that yields unexpected results. The + * correct pattern for click and wait for navigation is the following: + * + * ```ts + * const [response] = await Promise.all([ + * page.waitForNavigation(waitOptions), + * page.click(selector, clickOptions), + * ]); + * ``` + * + * Shortcut for {@link Frame.click | page.mainFrame().click(selector[, options]) }. + * @param selector - A `selector` to search for element to click. If there are + * multiple elements satisfying the `selector`, the first will be clicked + * @param options - `Object` + * @returns Promise which resolves when the element matching `selector` is + * successfully clicked. The Promise will be rejected if there is no element + * matching `selector`. + */ + click(selector: string, options?: Readonly<ClickOptions>): Promise<void> { + return this.mainFrame().click(selector, options); + } + + /** + * This method fetches an element with `selector` and focuses it. If there's no + * element matching `selector`, the method throws an error. + * @param selector - A + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector } + * of an element to focus. If there are multiple elements satisfying the + * selector, the first will be focused. + * @returns Promise which resolves when the element matching selector is + * successfully focused. The promise will be rejected if there is no element + * matching selector. + * + * @remarks + * + * Shortcut for {@link Frame.focus | page.mainFrame().focus(selector)}. + */ + focus(selector: string): Promise<void> { + return this.mainFrame().focus(selector); + } + + /** + * This method fetches an element with `selector`, scrolls it into view if + * needed, and then uses {@link Page | Page.mouse} + * to hover over the center of the element. + * If there's no element matching `selector`, the method throws an error. + * @param selector - A + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector} + * to search for element to hover. If there are multiple elements satisfying + * the selector, the first will be hovered. + * @returns Promise which resolves when the element matching `selector` is + * successfully hovered. Promise gets rejected if there's no element matching + * `selector`. + * + * @remarks + * + * Shortcut for {@link Page.hover | page.mainFrame().hover(selector)}. + */ + hover(selector: string): Promise<void> { + return this.mainFrame().hover(selector); + } + + /** + * Triggers a `change` and `input` event once all the provided options have been + * selected. If there's no `<select>` element matching `selector`, the method + * throws an error. + * + * @example + * + * ```ts + * page.select('select#colors', 'blue'); // single selection + * page.select('select#colors', 'red', 'green', 'blue'); // multiple selections + * ``` + * + * @param selector - A + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | Selector} + * to query the page for + * @param values - Values of options to select. If the `<select>` has the + * `multiple` attribute, all values are considered, otherwise only the first one + * is taken into account. + * @returns + * + * @remarks + * + * Shortcut for {@link Frame.select | page.mainFrame().select()} + */ + select(selector: string, ...values: string[]): Promise<string[]> { + return this.mainFrame().select(selector, ...values); + } + + /** + * This method fetches an element with `selector`, scrolls it into view if + * needed, and then uses {@link Page | Page.touchscreen} + * to tap in the center of the element. + * If there's no element matching `selector`, the method throws an error. + * @param selector - A + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | Selector} + * to search for element to tap. If there are multiple elements satisfying the + * selector, the first will be tapped. + * + * @remarks + * + * Shortcut for {@link Frame.tap | page.mainFrame().tap(selector)}. + */ + tap(selector: string): Promise<void> { + return this.mainFrame().tap(selector); + } + + /** + * Sends a `keydown`, `keypress/input`, and `keyup` event for each character + * in the text. + * + * To press a special key, like `Control` or `ArrowDown`, use {@link Keyboard.press}. + * @example + * + * ```ts + * await page.type('#mytextarea', 'Hello'); + * // Types instantly + * await page.type('#mytextarea', 'World', {delay: 100}); + * // Types slower, like a user + * ``` + * + * @param selector - A + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector} + * of an element to type into. If there are multiple elements satisfying the + * selector, the first will be used. + * @param text - A text to type into a focused element. + * @param options - have property `delay` which is the Time to wait between + * key presses in milliseconds. Defaults to `0`. + * @returns + */ + type( + selector: string, + text: string, + options?: Readonly<KeyboardTypeOptions> + ): Promise<void> { + return this.mainFrame().type(selector, text, options); + } + + /** + * @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 + * function will throw. + * + * @example + * This method works across navigations: + * + * ```ts + * import puppeteer from 'puppeteer'; + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * let currentURL; + * page + * .waitForSelector('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 selector - A + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector} + * of an element to wait for + * @param options - Optional waiting parameters + * @returns Promise which resolves when element specified by selector string + * is added to DOM. Resolves to `null` if waiting for hidden: `true` and + * selector is not found in DOM. + * + * @remarks + * The optional Parameter in Arguments `options` are: + * + * - `visible`: A boolean 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`: 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`: 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. + */ + async waitForSelector<Selector extends string>( + selector: Selector, + options: WaitForSelectorOptions = {} + ): Promise<ElementHandle<NodeFor<Selector>> | null> { + return await this.mainFrame().waitForSelector(selector, options); + } + + /** + * 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. + * + * @example + * {@link Page.waitForFunction} can be used to observe a viewport size change: + * + * ```ts + * import puppeteer from 'puppeteer'; + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * const watchDog = page.waitForFunction('window.innerWidth < 100'); + * await page.setViewport({width: 50, height: 50}); + * await watchDog; + * await browser.close(); + * })(); + * ``` + * + * @example + * Arguments can be passed from Node.js to `pageFunction`: + * + * ```ts + * const selector = '.foo'; + * await page.waitForFunction( + * selector => !!document.querySelector(selector), + * {}, + * selector + * ); + * ``` + * + * @example + * The provided `pageFunction` can be asynchronous: + * + * ```ts + * const username = 'github-username'; + * await page.waitForFunction( + * async username => { + * const githubResponse = await fetch( + * `https://api.github.com/users/${username}` + * ); + * const githubUser = await githubResponse.json(); + * // show the avatar + * const img = document.createElement('img'); + * img.src = githubUser.avatar_url; + * // wait 3 seconds + * await new Promise((resolve, reject) => setTimeout(resolve, 3000)); + * img.remove(); + * }, + * {}, + * username + * ); + * ``` + * + * @param pageFunction - Function to be evaluated in browser context until it returns a + * truthy value. + * @param options - Options for configuring waiting behavior. + */ + waitForFunction< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + pageFunction: Func | string, + options?: FrameWaitForFunctionOptions, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + return this.mainFrame().waitForFunction(pageFunction, options, ...args); + } + + /** + * This method is typically coupled with an action that triggers a device + * request from an api such as WebBluetooth. + * + * :::caution + * + * This must be called before the device request is made. It will not return a + * currently active device prompt. + * + * ::: + * + * @example + * + * ```ts + * const [devicePrompt] = Promise.all([ + * page.waitForDevicePrompt(), + * page.click('#connect-bluetooth'), + * ]); + * await devicePrompt.select( + * await devicePrompt.waitForDevice(({name}) => name.includes('My Device')) + * ); + * ``` + */ + abstract waitForDevicePrompt( + options?: WaitTimeoutOptions + ): Promise<DeviceRequestPrompt>; + + /** @internal */ + [disposeSymbol](): void { + return void this.close().catch(debugError); + } + + /** @internal */ + [asyncDisposeSymbol](): Promise<void> { + return this.close(); + } +} + +/** + * @internal + */ +export const supportedMetrics = new Set<string>([ + 'Timestamp', + 'Documents', + 'Frames', + 'JSEventListeners', + 'Nodes', + 'LayoutCount', + 'RecalcStyleCount', + 'LayoutDuration', + 'RecalcStyleDuration', + 'ScriptDuration', + 'TaskDuration', + 'JSHeapUsedSize', + 'JSHeapTotalSize', +]); + +/** @see https://w3c.github.io/webdriver-bidi/#normalize-rect */ +function normalizeRectangle<BoundingBoxType extends BoundingBox>( + clip: Readonly<BoundingBoxType> +): BoundingBoxType { + return { + ...clip, + ...(clip.width < 0 + ? { + x: clip.x + clip.width, + width: -clip.width, + } + : { + x: clip.x, + width: clip.width, + }), + ...(clip.height < 0 + ? { + y: clip.y + clip.height, + height: -clip.height, + } + : { + y: clip.y, + height: clip.height, + }), + }; +} + +function roundRectangle<BoundingBoxType extends BoundingBox>( + clip: Readonly<BoundingBoxType> +): BoundingBoxType { + const x = Math.round(clip.x); + const y = Math.round(clip.y); + const width = Math.round(clip.width + clip.x - x); + const height = Math.round(clip.height + clip.y - y); + return {...clip, x, y, width, height}; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Realm.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Realm.ts new file mode 100644 index 0000000000..eee1f2c1dd --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Realm.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {TimeoutSettings} from '../common/TimeoutSettings.js'; +import type { + EvaluateFunc, + HandleFor, + InnerLazyParams, +} from '../common/types.js'; +import {TaskManager, WaitTask} from '../common/WaitTask.js'; +import {disposeSymbol} from '../util/disposable.js'; + +import type {ElementHandle} from './ElementHandle.js'; +import type {Environment} from './Environment.js'; +import type {JSHandle} from './JSHandle.js'; + +/** + * @internal + */ +export abstract class Realm implements Disposable { + protected readonly timeoutSettings: TimeoutSettings; + readonly taskManager = new TaskManager(); + + constructor(timeoutSettings: TimeoutSettings) { + this.timeoutSettings = timeoutSettings; + } + + abstract get environment(): Environment; + + abstract adoptHandle<T extends JSHandle<Node>>(handle: T): Promise<T>; + abstract transferHandle<T extends JSHandle<Node>>(handle: T): Promise<T>; + abstract evaluateHandle< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>>; + abstract evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>>; + + async waitForFunction< + Params extends unknown[], + Func extends EvaluateFunc<InnerLazyParams<Params>> = EvaluateFunc< + InnerLazyParams<Params> + >, + >( + pageFunction: Func | string, + options: { + polling?: 'raf' | 'mutation' | number; + timeout?: number; + root?: ElementHandle<Node>; + signal?: AbortSignal; + } = {}, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + const { + polling = 'raf', + timeout = this.timeoutSettings.timeout(), + root, + signal, + } = options; + if (typeof polling === 'number' && polling < 0) { + throw new Error('Cannot poll with non-positive interval'); + } + const waitTask = new WaitTask( + this, + { + polling, + root, + timeout, + signal, + }, + pageFunction as unknown as + | ((...args: unknown[]) => Promise<Awaited<ReturnType<Func>>>) + | string, + ...args + ); + return await waitTask.result; + } + + abstract adoptBackendNode(backendNodeId?: number): Promise<JSHandle<Node>>; + + get disposed(): boolean { + return this.#disposed; + } + + #disposed = false; + /** @internal */ + [disposeSymbol](): void { + this.#disposed = true; + this.taskManager.terminateAll( + new Error('waitForFunction failed: frame got detached.') + ); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Target.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Target.ts new file mode 100644 index 0000000000..f91b91df12 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Target.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Browser} from './Browser.js'; +import type {BrowserContext} from './BrowserContext.js'; +import type {CDPSession} from './CDPSession.js'; +import type {Page} from './Page.js'; +import type {WebWorker} from './WebWorker.js'; + +/** + * @public + */ +export enum TargetType { + PAGE = 'page', + BACKGROUND_PAGE = 'background_page', + SERVICE_WORKER = 'service_worker', + SHARED_WORKER = 'shared_worker', + BROWSER = 'browser', + WEBVIEW = 'webview', + OTHER = 'other', + /** + * @internal + */ + TAB = 'tab', +} + +/** + * Target represents a + * {@link https://chromedevtools.github.io/devtools-protocol/tot/Target/ | CDP target}. + * In CDP a target is something that can be debugged such a frame, a page or a + * worker. + * @public + */ +export abstract class Target { + /** + * @internal + */ + protected constructor() {} + + /** + * If the target is not of type `"service_worker"` or `"shared_worker"`, returns `null`. + */ + async worker(): Promise<WebWorker | null> { + return null; + } + + /** + * If the target is not of type `"page"`, `"webview"` or `"background_page"`, + * returns `null`. + */ + async page(): Promise<Page | null> { + return null; + } + + /** + * Forcefully creates a page for a target of any type. It is useful if you + * want to handle a CDP target of type `other` as a page. If you deal with a + * regular page target, use {@link Target.page}. + */ + abstract asPage(): Promise<Page>; + + abstract url(): string; + + /** + * Creates a Chrome Devtools Protocol session attached to the target. + */ + abstract createCDPSession(): Promise<CDPSession>; + + /** + * Identifies what kind of target this is. + * + * @remarks + * + * See {@link https://developer.chrome.com/extensions/background_pages | docs} for more info about background pages. + */ + abstract type(): TargetType; + + /** + * Get the browser the target belongs to. + */ + abstract browser(): Browser; + + /** + * Get the browser context the target belongs to. + */ + abstract browserContext(): BrowserContext; + + /** + * Get the target that opened this target. Top-level targets return `null`. + */ + abstract opener(): Target | undefined; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/WebWorker.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/WebWorker.ts new file mode 100644 index 0000000000..4de287f146 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/WebWorker.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {EventEmitter, type EventType} from '../common/EventEmitter.js'; +import {TimeoutSettings} from '../common/TimeoutSettings.js'; +import type {EvaluateFunc, HandleFor} from '../common/types.js'; +import {withSourcePuppeteerURLIfNone} from '../common/util.js'; + +import type {CDPSession} from './CDPSession.js'; +import type {Realm} from './Realm.js'; + +/** + * This class represents a + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API | WebWorker}. + * + * @remarks + * The events `workercreated` and `workerdestroyed` are emitted on the page + * object to signal the worker lifecycle. + * + * @example + * + * ```ts + * page.on('workercreated', worker => + * console.log('Worker created: ' + worker.url()) + * ); + * page.on('workerdestroyed', worker => + * console.log('Worker destroyed: ' + worker.url()) + * ); + * + * console.log('Current workers:'); + * for (const worker of page.workers()) { + * console.log(' ' + worker.url()); + * } + * ``` + * + * @public + */ +export abstract class WebWorker extends EventEmitter< + Record<EventType, unknown> +> { + /** + * @internal + */ + readonly timeoutSettings = new TimeoutSettings(); + + readonly #url: string; + + /** + * @internal + */ + constructor(url: string) { + super(); + + this.#url = url; + } + + /** + * @internal + */ + abstract mainRealm(): Realm; + + /** + * The URL of this web worker. + */ + url(): string { + return this.#url; + } + + /** + * The CDP session client the WebWorker belongs to. + */ + abstract get client(): CDPSession; + + /** + * Evaluates a given function in the {@link WebWorker | worker}. + * + * @remarks If the given function returns a promise, + * {@link WebWorker.evaluate | evaluate} will wait for the promise to resolve. + * + * As a rule of thumb, if the return value of the given function is more + * complicated than a JSON object (e.g. most classes), then + * {@link WebWorker.evaluate | evaluate} will _likely_ return some truncated + * value (or `{}`). This is because we are not returning the actual return + * value, but a deserialized version as a result of transferring the return + * value through a protocol to Puppeteer. + * + * In general, you should use + * {@link WebWorker.evaluateHandle | evaluateHandle} if + * {@link WebWorker.evaluate | evaluate} cannot serialize the return value + * properly or you need a mutable {@link JSHandle | handle} to the return + * object. + * + * @param func - Function to be evaluated. + * @param args - Arguments to pass into `func`. + * @returns The result of `func`. + */ + async evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >(func: Func | string, ...args: Params): Promise<Awaited<ReturnType<Func>>> { + func = withSourcePuppeteerURLIfNone(this.evaluate.name, func); + return await this.mainRealm().evaluate(func, ...args); + } + + /** + * Evaluates a given function in the {@link WebWorker | worker}. + * + * @remarks If the given function returns a promise, + * {@link WebWorker.evaluate | evaluate} will wait for the promise to resolve. + * + * In general, you should use + * {@link WebWorker.evaluateHandle | evaluateHandle} if + * {@link WebWorker.evaluate | evaluate} cannot serialize the return value + * properly or you need a mutable {@link JSHandle | handle} to the return + * object. + * + * @param func - Function to be evaluated. + * @param args - Arguments to pass into `func`. + * @returns A {@link JSHandle | handle} to the return value of `func`. + */ + async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + func: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + func = withSourcePuppeteerURLIfNone(this.evaluateHandle.name, func); + return await this.mainRealm().evaluateHandle(func, ...args); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/api.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/api.ts new file mode 100644 index 0000000000..d2bf832a6d --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/api.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './Browser.js'; +export * from './BrowserContext.js'; +export * from './CDPSession.js'; +export * from './Dialog.js'; +export * from './ElementHandle.js'; +export * from './Environment.js'; +export * from './Frame.js'; +export * from './HTTPRequest.js'; +export * from './HTTPResponse.js'; +export * from './Input.js'; +export * from './JSHandle.js'; +export * from './Page.js'; +export * from './Realm.js'; +export * from './Target.js'; +export * from './WebWorker.js'; +export * from './locators/locators.js'; 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 new file mode 100644 index 0000000000..7bec11e38e --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/locators/locators.ts @@ -0,0 +1,1088 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type { + Observable, + OperatorFunction, +} from '../../../third_party/rxjs/rxjs.js'; +import { + EMPTY, + catchError, + defaultIfEmpty, + defer, + filter, + first, + firstValueFrom, + from, + fromEvent, + identity, + ignoreElements, + map, + merge, + mergeMap, + noop, + pipe, + race, + raceWith, + retry, + tap, + throwIfEmpty, +} from '../../../third_party/rxjs/rxjs.js'; +import type {EventType} from '../../common/EventEmitter.js'; +import {EventEmitter} from '../../common/EventEmitter.js'; +import type {Awaitable, HandleFor, NodeFor} from '../../common/types.js'; +import {debugError, timeout} from '../../common/util.js'; +import type { + BoundingBox, + ClickOptions, + ElementHandle, +} from '../ElementHandle.js'; +import type {Frame} from '../Frame.js'; +import type {Page} from '../Page.js'; + +/** + * @public + */ +export type VisibilityOption = 'hidden' | 'visible' | null; +/** + * @public + */ +export interface LocatorOptions { + /** + * Whether to wait for the element to be `visible` or `hidden`. `null` to + * disable visibility checks. + */ + visibility: VisibilityOption; + /** + * Total timeout for the entire locator operation. + * + * Pass `0` to disable timeout. + * + * @defaultValue `Page.getDefaultTimeout()` + */ + timeout: number; + /** + * Whether to scroll the element into viewport if not in the viewprot already. + * @defaultValue `true` + */ + ensureElementIsInTheViewport: boolean; + /** + * Whether to wait for input elements to become enabled before the action. + * Applicable to `click` and `fill` actions. + * @defaultValue `true` + */ + waitForEnabled: boolean; + /** + * Whether to wait for the element's bounding box to be same between two + * animation frames. + * @defaultValue `true` + */ + waitForStableBoundingBox: boolean; +} +/** + * @public + */ +export interface ActionOptions { + signal?: AbortSignal; +} +/** + * @public + */ +export type LocatorClickOptions = ClickOptions & ActionOptions; +/** + * @public + */ +export interface LocatorScrollOptions extends ActionOptions { + scrollTop?: number; + scrollLeft?: number; +} +/** + * All the events that a locator instance may emit. + * + * @public + */ +export enum LocatorEvent { + /** + * Emitted every time before the locator performs an action on the located element(s). + */ + 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 + * whole operation is retried. Various preconditions for a successful action are + * checked automatically. + * + * @public + */ +export abstract class Locator<T> extends EventEmitter<LocatorEvents> { + /** + * Creates a race between multiple locators but ensures that only a single one + * acts. + * + * @public + */ + static race<Locators extends readonly unknown[] | []>( + locators: Locators + ): Locator<AwaitedLocator<Locators[number]>> { + return RaceLocator.create(locators); + } + + /** + * Used for nominally typing {@link Locator}. + */ + declare _?: T; + + /** + * @internal + */ + protected visibility: VisibilityOption = null; + /** + * @internal + */ + protected _timeout = 30000; + #ensureElementIsInTheViewport = true; + #waitForEnabled = true; + #waitForStableBoundingBox = true; + + /** + * @internal + */ + protected operators = { + conditions: ( + conditions: Array<Action<T, never>>, + signal?: AbortSignal + ): OperatorFunction<HandleFor<T>, HandleFor<T>> => { + return mergeMap((handle: HandleFor<T>) => { + return merge( + ...conditions.map(condition => { + return condition(handle, signal); + }) + ).pipe(defaultIfEmpty(handle)); + }); + }, + retryAndRaceWithSignalAndTimer: <T>( + signal?: AbortSignal + ): OperatorFunction<T, T> => { + const candidates = []; + if (signal) { + candidates.push( + fromEvent(signal, 'abort').pipe( + map(() => { + throw signal.reason; + }) + ) + ); + } + candidates.push(timeout(this._timeout)); + return pipe( + retry({delay: RETRY_DELAY}), + raceWith<T, never[]>(...candidates) + ); + }, + }; + + // Determines when the locator will timeout for actions. + get timeout(): number { + return this._timeout; + } + + setTimeout(timeout: number): Locator<T> { + const locator = this._clone(); + locator._timeout = timeout; + return locator; + } + + setVisibility<NodeType extends Node>( + this: Locator<NodeType>, + visibility: VisibilityOption + ): Locator<NodeType> { + const locator = this._clone(); + locator.visibility = visibility; + return locator; + } + + setWaitForEnabled<NodeType extends Node>( + this: Locator<NodeType>, + value: boolean + ): Locator<NodeType> { + const locator = this._clone(); + locator.#waitForEnabled = value; + return locator; + } + + setEnsureElementIsInTheViewport<ElementType extends Element>( + this: Locator<ElementType>, + value: boolean + ): Locator<ElementType> { + const locator = this._clone(); + locator.#ensureElementIsInTheViewport = value; + return locator; + } + + setWaitForStableBoundingBox<ElementType extends Element>( + this: Locator<ElementType>, + value: boolean + ): Locator<ElementType> { + const locator = this._clone(); + locator.#waitForStableBoundingBox = value; + return locator; + } + + /** + * @internal + */ + copyOptions<T>(locator: Locator<T>): this { + this._timeout = locator._timeout; + this.visibility = locator.visibility; + this.#waitForEnabled = locator.#waitForEnabled; + this.#ensureElementIsInTheViewport = locator.#ensureElementIsInTheViewport; + this.#waitForStableBoundingBox = locator.#waitForStableBoundingBox; + return this; + } + + /** + * If the element has a "disabled" property, wait for the element to be + * enabled. + */ + #waitForEnabledIfNeeded = <ElementType extends Node>( + handle: HandleFor<ElementType>, + signal?: AbortSignal + ): Observable<never> => { + if (!this.#waitForEnabled) { + return EMPTY; + } + return from( + handle.frame.waitForFunction( + element => { + if (!(element instanceof HTMLElement)) { + return true; + } + const isNativeFormControl = [ + 'BUTTON', + 'INPUT', + 'SELECT', + 'TEXTAREA', + 'OPTION', + 'OPTGROUP', + ].includes(element.nodeName); + return !isNativeFormControl || !element.hasAttribute('disabled'); + }, + { + timeout: this._timeout, + signal, + }, + handle + ) + ).pipe(ignoreElements()); + }; + + /** + * Compares the bounding box of the element for two consecutive animation + * frames and waits till they are the same. + */ + #waitForStableBoundingBoxIfNeeded = <ElementType extends Element>( + handle: HandleFor<ElementType> + ): Observable<never> => { + if (!this.#waitForStableBoundingBox) { + return EMPTY; + } + return defer(() => { + // Note we don't use waitForFunction because that relies on RAF. + return from( + handle.evaluate(element => { + return new Promise<[BoundingBox, BoundingBox]>(resolve => { + window.requestAnimationFrame(() => { + const rect1 = element.getBoundingClientRect(); + window.requestAnimationFrame(() => { + const rect2 = element.getBoundingClientRect(); + resolve([ + { + x: rect1.x, + y: rect1.y, + width: rect1.width, + height: rect1.height, + }, + { + x: rect2.x, + y: rect2.y, + width: rect2.width, + height: rect2.height, + }, + ]); + }); + }); + }); + }) + ); + }).pipe( + first(([rect1, rect2]) => { + return ( + rect1.x === rect2.x && + rect1.y === rect2.y && + rect1.width === rect2.width && + rect1.height === rect2.height + ); + }), + retry({delay: RETRY_DELAY}), + ignoreElements() + ); + }; + + /** + * Checks if the element is in the viewport and auto-scrolls it if it is not. + */ + #ensureElementIsInTheViewportIfNeeded = <ElementType extends Element>( + handle: HandleFor<ElementType> + ): Observable<never> => { + if (!this.#ensureElementIsInTheViewport) { + return EMPTY; + } + return from(handle.isIntersectingViewport({threshold: 0})).pipe( + filter(isIntersectingViewport => { + return !isIntersectingViewport; + }), + mergeMap(() => { + return from(handle.scrollIntoView()); + }), + mergeMap(() => { + return defer(() => { + return from(handle.isIntersectingViewport({threshold: 0})); + }).pipe(first(identity), retry({delay: RETRY_DELAY}), ignoreElements()); + }) + ); + }; + + #click<ElementType extends Element>( + this: Locator<ElementType>, + options?: Readonly<LocatorClickOptions> + ): Observable<void> { + const signal = options?.signal; + return this._wait(options).pipe( + this.operators.conditions( + [ + this.#ensureElementIsInTheViewportIfNeeded, + this.#waitForStableBoundingBoxIfNeeded, + this.#waitForEnabledIfNeeded, + ], + signal + ), + tap(() => { + return this.emit(LocatorEvent.Action, undefined); + }), + mergeMap(handle => { + return from(handle.click(options)).pipe( + catchError(err => { + void handle.dispose().catch(debugError); + throw err; + }) + ); + }), + this.operators.retryAndRaceWithSignalAndTimer(signal) + ); + } + + #fill<ElementType extends Element>( + this: Locator<ElementType>, + value: string, + options?: Readonly<ActionOptions> + ): Observable<void> { + const signal = options?.signal; + return this._wait(options).pipe( + this.operators.conditions( + [ + this.#ensureElementIsInTheViewportIfNeeded, + this.#waitForStableBoundingBoxIfNeeded, + this.#waitForEnabledIfNeeded, + ], + signal + ), + tap(() => { + return this.emit(LocatorEvent.Action, undefined); + }), + mergeMap(handle => { + return from( + (handle as unknown as ElementHandle<HTMLElement>).evaluate(el => { + if (el instanceof HTMLSelectElement) { + return 'select'; + } + if (el instanceof HTMLTextAreaElement) { + return 'typeable-input'; + } + if (el instanceof HTMLInputElement) { + if ( + new Set([ + 'textarea', + 'text', + 'url', + 'tel', + 'search', + 'password', + 'number', + 'email', + ]).has(el.type) + ) { + return 'typeable-input'; + } else { + return 'other-input'; + } + } + + if (el.isContentEditable) { + return 'contenteditable'; + } + + return 'unknown'; + }) + ) + .pipe( + mergeMap(inputType => { + switch (inputType) { + case 'select': + return from(handle.select(value).then(noop)); + case 'contenteditable': + case 'typeable-input': + return from( + ( + handle as unknown as ElementHandle<HTMLInputElement> + ).evaluate((input, newValue) => { + const currentValue = input.isContentEditable + ? input.innerText + : input.value; + + // Clear the input if the current value does not match the filled + // out value. + if ( + newValue.length <= currentValue.length || + !newValue.startsWith(input.value) + ) { + if (input.isContentEditable) { + input.innerText = ''; + } else { + input.value = ''; + } + return newValue; + } + const originalValue = input.isContentEditable + ? input.innerText + : input.value; + + // If the value is partially filled out, only type the rest. Move + // cursor to the end of the common prefix. + if (input.isContentEditable) { + input.innerText = ''; + input.innerText = originalValue; + } else { + input.value = ''; + input.value = originalValue; + } + return newValue.substring(originalValue.length); + }, value) + ).pipe( + mergeMap(textToType => { + return from(handle.type(textToType)); + }) + ); + case 'other-input': + return from(handle.focus()).pipe( + mergeMap(() => { + return from( + handle.evaluate((input, value) => { + (input as HTMLInputElement).value = value; + input.dispatchEvent( + new Event('input', {bubbles: true}) + ); + input.dispatchEvent( + new Event('change', {bubbles: true}) + ); + }, value) + ); + }) + ); + case 'unknown': + throw new Error(`Element cannot be filled out.`); + } + }) + ) + .pipe( + catchError(err => { + void handle.dispose().catch(debugError); + throw err; + }) + ); + }), + this.operators.retryAndRaceWithSignalAndTimer(signal) + ); + } + + #hover<ElementType extends Element>( + this: Locator<ElementType>, + options?: Readonly<ActionOptions> + ): Observable<void> { + const signal = options?.signal; + return this._wait(options).pipe( + this.operators.conditions( + [ + this.#ensureElementIsInTheViewportIfNeeded, + this.#waitForStableBoundingBoxIfNeeded, + ], + signal + ), + tap(() => { + return this.emit(LocatorEvent.Action, undefined); + }), + mergeMap(handle => { + return from(handle.hover()).pipe( + catchError(err => { + void handle.dispose().catch(debugError); + throw err; + }) + ); + }), + this.operators.retryAndRaceWithSignalAndTimer(signal) + ); + } + + #scroll<ElementType extends Element>( + this: Locator<ElementType>, + options?: Readonly<LocatorScrollOptions> + ): Observable<void> { + const signal = options?.signal; + return this._wait(options).pipe( + this.operators.conditions( + [ + this.#ensureElementIsInTheViewportIfNeeded, + this.#waitForStableBoundingBoxIfNeeded, + ], + signal + ), + tap(() => { + return this.emit(LocatorEvent.Action, undefined); + }), + mergeMap(handle => { + return from( + handle.evaluate( + (el, scrollTop, scrollLeft) => { + if (scrollTop !== undefined) { + el.scrollTop = scrollTop; + } + if (scrollLeft !== undefined) { + el.scrollLeft = scrollLeft; + } + }, + options?.scrollTop, + options?.scrollLeft + ) + ).pipe( + catchError(err => { + void handle.dispose().catch(debugError); + throw err; + }) + ); + }), + this.operators.retryAndRaceWithSignalAndTimer(signal) + ); + } + + /** + * @internal + */ + abstract _clone(): Locator<T>; + + /** + * @internal + */ + abstract _wait(options?: Readonly<ActionOptions>): Observable<HandleFor<T>>; + + /** + * Clones the locator. + */ + clone(): Locator<T> { + return this._clone(); + } + + /** + * Waits for the locator to get a handle from the page. + * + * @public + */ + async waitHandle(options?: Readonly<ActionOptions>): Promise<HandleFor<T>> { + return await firstValueFrom( + this._wait(options).pipe( + this.operators.retryAndRaceWithSignalAndTimer(options?.signal) + ) + ); + } + + /** + * Waits for the locator to get the serialized value from the page. + * + * Note this requires the value to be JSON-serializable. + * + * @public + */ + async wait(options?: Readonly<ActionOptions>): Promise<T> { + using handle = await this.waitHandle(options); + return await handle.jsonValue(); + } + + /** + * Maps the locator using the provided mapper. + * + * @public + */ + map<To>(mapper: Mapper<T, To>): Locator<To> { + return new MappedLocator(this._clone(), handle => { + // SAFETY: TypeScript cannot deduce the type. + return (handle as any).evaluateHandle(mapper); + }); + } + + /** + * Creates an expectation that is evaluated against located values. + * + * If the expectations do not match, then the locator will retry. + * + * @public + */ + filter<S extends T>(predicate: Predicate<T, S>): Locator<S> { + return new FilteredLocator(this._clone(), async (handle, signal) => { + await (handle as ElementHandle<Node>).frame.waitForFunction( + predicate, + {signal, timeout: this._timeout}, + handle + ); + return true; + }); + } + + /** + * Creates an expectation that is evaluated against located handles. + * + * If the expectations do not match, then the locator will retry. + * + * @internal + */ + filterHandle<S extends T>( + predicate: Predicate<HandleFor<T>, HandleFor<S>> + ): Locator<S> { + return new FilteredLocator(this._clone(), predicate); + } + + /** + * Maps the locator using the provided mapper. + * + * @internal + */ + mapHandle<To>(mapper: HandleMapper<T, To>): Locator<To> { + return new MappedLocator(this._clone(), mapper); + } + + click<ElementType extends Element>( + this: Locator<ElementType>, + options?: Readonly<LocatorClickOptions> + ): Promise<void> { + return firstValueFrom(this.#click(options)); + } + + /** + * Fills out the input identified by the locator using the provided value. The + * type of the input is determined at runtime and the appropriate fill-out + * method is chosen based on the type. contenteditable, selector, inputs are + * supported. + */ + fill<ElementType extends Element>( + this: Locator<ElementType>, + value: string, + options?: Readonly<ActionOptions> + ): Promise<void> { + return firstValueFrom(this.#fill(value, options)); + } + + hover<ElementType extends Element>( + this: Locator<ElementType>, + options?: Readonly<ActionOptions> + ): Promise<void> { + return firstValueFrom(this.#hover(options)); + } + + scroll<ElementType extends Element>( + this: Locator<ElementType>, + options?: Readonly<LocatorScrollOptions> + ): Promise<void> { + return firstValueFrom(this.#scroll(options)); + } +} + +/** + * @internal + */ +export class FunctionLocator<T> extends Locator<T> { + static create<Ret>( + pageOrFrame: Page | Frame, + func: () => Awaitable<Ret> + ): Locator<Ret> { + return new FunctionLocator<Ret>(pageOrFrame, func).setTimeout( + 'getDefaultTimeout' in pageOrFrame + ? pageOrFrame.getDefaultTimeout() + : pageOrFrame.page().getDefaultTimeout() + ); + } + + #pageOrFrame: Page | Frame; + #func: () => Awaitable<T>; + + private constructor(pageOrFrame: Page | Frame, func: () => Awaitable<T>) { + super(); + + this.#pageOrFrame = pageOrFrame; + this.#func = func; + } + + override _clone(): FunctionLocator<T> { + return new FunctionLocator(this.#pageOrFrame, this.#func); + } + + _wait(options?: Readonly<ActionOptions>): Observable<HandleFor<T>> { + const signal = options?.signal; + return defer(() => { + return from( + this.#pageOrFrame.waitForFunction(this.#func, { + timeout: this.timeout, + signal, + }) + ); + }).pipe(throwIfEmpty()); + } +} + +/** + * @public + */ +export type Predicate<From, To extends From = From> = + | ((value: From) => value is To) + | ((value: From) => Awaitable<boolean>); +/** + * @internal + */ +export type HandlePredicate<From, To extends From = From> = + | ((value: HandleFor<From>, signal?: AbortSignal) => value is HandleFor<To>) + | ((value: HandleFor<From>, signal?: AbortSignal) => Awaitable<boolean>); + +/** + * @internal + */ +export abstract class DelegatedLocator<T, U> extends Locator<U> { + #delegate: Locator<T>; + + constructor(delegate: Locator<T>) { + super(); + + this.#delegate = delegate; + this.copyOptions(this.#delegate); + } + + protected get delegate(): Locator<T> { + return this.#delegate; + } + + override setTimeout(timeout: number): DelegatedLocator<T, U> { + const locator = super.setTimeout(timeout) as DelegatedLocator<T, U>; + locator.#delegate = this.#delegate.setTimeout(timeout); + return locator; + } + + override setVisibility<ValueType extends Node, NodeType extends Node>( + this: DelegatedLocator<ValueType, NodeType>, + visibility: VisibilityOption + ): DelegatedLocator<ValueType, NodeType> { + const locator = super.setVisibility<NodeType>( + visibility + ) as DelegatedLocator<ValueType, NodeType>; + locator.#delegate = locator.#delegate.setVisibility<ValueType>(visibility); + return locator; + } + + override setWaitForEnabled<ValueType extends Node, NodeType extends Node>( + this: DelegatedLocator<ValueType, NodeType>, + value: boolean + ): DelegatedLocator<ValueType, NodeType> { + const locator = super.setWaitForEnabled<NodeType>( + value + ) as DelegatedLocator<ValueType, NodeType>; + locator.#delegate = this.#delegate.setWaitForEnabled(value); + return locator; + } + + override setEnsureElementIsInTheViewport< + ValueType extends Element, + ElementType extends Element, + >( + this: DelegatedLocator<ValueType, ElementType>, + value: boolean + ): DelegatedLocator<ValueType, ElementType> { + const locator = super.setEnsureElementIsInTheViewport<ElementType>( + value + ) as DelegatedLocator<ValueType, ElementType>; + locator.#delegate = this.#delegate.setEnsureElementIsInTheViewport(value); + return locator; + } + + override setWaitForStableBoundingBox< + ValueType extends Element, + ElementType extends Element, + >( + this: DelegatedLocator<ValueType, ElementType>, + value: boolean + ): DelegatedLocator<ValueType, ElementType> { + const locator = super.setWaitForStableBoundingBox<ElementType>( + value + ) as DelegatedLocator<ValueType, ElementType>; + locator.#delegate = this.#delegate.setWaitForStableBoundingBox(value); + return locator; + } + + abstract override _clone(): DelegatedLocator<T, U>; + abstract override _wait(): Observable<HandleFor<U>>; +} + +/** + * @internal + */ +export class FilteredLocator<From, To extends From> extends DelegatedLocator< + From, + To +> { + #predicate: HandlePredicate<From, To>; + + constructor(base: Locator<From>, predicate: HandlePredicate<From, To>) { + super(base); + this.#predicate = predicate; + } + + override _clone(): FilteredLocator<From, To> { + return new FilteredLocator( + this.delegate.clone(), + this.#predicate + ).copyOptions(this); + } + + override _wait(options?: Readonly<ActionOptions>): Observable<HandleFor<To>> { + return this.delegate._wait(options).pipe( + mergeMap(handle => { + return from( + Promise.resolve(this.#predicate(handle, options?.signal)) + ).pipe( + filter(value => { + return value; + }), + map(() => { + // SAFETY: It passed the predicate, so this is correct. + return handle as HandleFor<To>; + }) + ); + }), + throwIfEmpty() + ); + } +} + +/** + * @public + */ +export type Mapper<From, To> = (value: From) => Awaitable<To>; +/** + * @internal + */ +export type HandleMapper<From, To> = ( + value: HandleFor<From>, + signal?: AbortSignal +) => Awaitable<HandleFor<To>>; +/** + * @internal + */ +export class MappedLocator<From, To> extends DelegatedLocator<From, To> { + #mapper: HandleMapper<From, To>; + + constructor(base: Locator<From>, mapper: HandleMapper<From, To>) { + super(base); + this.#mapper = mapper; + } + + override _clone(): MappedLocator<From, To> { + return new MappedLocator(this.delegate.clone(), this.#mapper).copyOptions( + this + ); + } + + override _wait(options?: Readonly<ActionOptions>): Observable<HandleFor<To>> { + return this.delegate._wait(options).pipe( + mergeMap(handle => { + return from(Promise.resolve(this.#mapper(handle, options?.signal))); + }) + ); + } +} + +/** + * @internal + */ +export type Action<T, U> = ( + element: HandleFor<T>, + signal?: AbortSignal +) => Observable<U>; +/** + * @internal + */ +export class NodeLocator<T extends Node> extends Locator<T> { + static create<Selector extends string>( + pageOrFrame: Page | Frame, + selector: Selector + ): Locator<NodeFor<Selector>> { + return new NodeLocator<NodeFor<Selector>>(pageOrFrame, selector).setTimeout( + 'getDefaultTimeout' in pageOrFrame + ? pageOrFrame.getDefaultTimeout() + : pageOrFrame.page().getDefaultTimeout() + ); + } + + #pageOrFrame: Page | Frame; + #selector: string; + + private constructor(pageOrFrame: Page | Frame, selector: string) { + super(); + + this.#pageOrFrame = pageOrFrame; + this.#selector = selector; + } + + /** + * Waits for the element to become visible or hidden. visibility === 'visible' + * means that the element has a computed style, the visibility property other + * than 'hidden' or 'collapse' and non-empty bounding box. visibility === + * 'hidden' means the opposite of that. + */ + #waitForVisibilityIfNeeded = (handle: HandleFor<T>): Observable<never> => { + if (!this.visibility) { + return EMPTY; + } + + return (() => { + switch (this.visibility) { + case 'hidden': + return defer(() => { + return from(handle.isHidden()); + }); + case 'visible': + return defer(() => { + return from(handle.isVisible()); + }); + } + })().pipe(first(identity), retry({delay: RETRY_DELAY}), ignoreElements()); + }; + + override _clone(): NodeLocator<T> { + return new NodeLocator<T>(this.#pageOrFrame, this.#selector).copyOptions( + this + ); + } + + override _wait(options?: Readonly<ActionOptions>): Observable<HandleFor<T>> { + const signal = options?.signal; + return defer(() => { + return from( + this.#pageOrFrame.waitForSelector(this.#selector, { + visible: false, + timeout: this._timeout, + signal, + }) as Promise<HandleFor<T> | null> + ); + }).pipe( + filter((value): value is NonNullable<typeof value> => { + return value !== null; + }), + throwIfEmpty(), + this.operators.conditions([this.#waitForVisibilityIfNeeded], signal) + ); + } +} + +/** + * @public + */ +export type AwaitedLocator<T> = T extends Locator<infer S> ? S : never; +function checkLocatorArray<T extends readonly unknown[] | []>( + locators: T +): ReadonlyArray<Locator<AwaitedLocator<T[number]>>> { + for (const locator of locators) { + if (!(locator instanceof Locator)) { + throw new Error('Unknown locator for race candidate'); + } + } + return locators as ReadonlyArray<Locator<AwaitedLocator<T[number]>>>; +} +/** + * @internal + */ +export class RaceLocator<T> extends Locator<T> { + static create<T extends readonly unknown[]>( + locators: T + ): Locator<AwaitedLocator<T[number]>> { + const array = checkLocatorArray(locators); + return new RaceLocator(array); + } + + #locators: ReadonlyArray<Locator<T>>; + + constructor(locators: ReadonlyArray<Locator<T>>) { + super(); + this.#locators = locators; + } + + override _clone(): RaceLocator<T> { + return new RaceLocator<T>( + this.#locators.map(locator => { + return locator.clone(); + }) + ).copyOptions(this); + } + + override _wait(options?: Readonly<ActionOptions>): Observable<HandleFor<T>> { + return race( + ...this.#locators.map(locator => { + return locator._wait(options); + }) + ); + } +} + +/** + * For observables coming from promises, a delay is needed, otherwise RxJS will + * never yield in a permanent failure for a promise. + * + * We also don't want RxJS to do promise operations to often, so we bump the + * delay up to 100ms. + * + * @internal + */ +export const RETRY_DELAY = 100; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BidiOverCdp.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BidiOverCdp.ts new file mode 100644 index 0000000000..ace35a52b0 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BidiOverCdp.ts @@ -0,0 +1,209 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as BidiMapper from 'chromium-bidi/lib/cjs/bidiMapper/BidiMapper.js'; +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; +import type {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js'; + +import type {CDPEvents, CDPSession} from '../api/CDPSession.js'; +import type {Connection as CdpConnection} from '../cdp/Connection.js'; +import {debug} from '../common/Debug.js'; +import {TargetCloseError} from '../common/Errors.js'; +import type {Handler} from '../common/EventEmitter.js'; + +import {BidiConnection} from './Connection.js'; + +const bidiServerLogger = (prefix: string, ...args: unknown[]): void => { + debug(`bidi:${prefix}`)(args); +}; + +/** + * @internal + */ +export async function connectBidiOverCdp( + cdp: CdpConnection, + // TODO: replace with `BidiMapper.MapperOptions`, once it's exported in + // https://github.com/puppeteer/puppeteer/pull/11415. + options: {acceptInsecureCerts: boolean} +): Promise<BidiConnection> { + const transportBiDi = new NoOpTransport(); + const cdpConnectionAdapter = new CdpConnectionAdapter(cdp); + const pptrTransport = { + send(message: string): void { + // Forwards a BiDi command sent by Puppeteer to the input of the BidiServer. + transportBiDi.emitMessage(JSON.parse(message)); + }, + close(): void { + bidiServer.close(); + cdpConnectionAdapter.close(); + cdp.dispose(); + }, + onmessage(_message: string): void { + // The method is overridden by the Connection. + }, + }; + transportBiDi.on('bidiResponse', (message: object) => { + // Forwards a BiDi event sent by BidiServer to Puppeteer. + pptrTransport.onmessage(JSON.stringify(message)); + }); + const pptrBiDiConnection = new BidiConnection(cdp.url(), pptrTransport); + const bidiServer = await BidiMapper.BidiServer.createAndStart( + transportBiDi, + cdpConnectionAdapter, + // TODO: most likely need a little bit of refactoring + cdpConnectionAdapter.browserClient(), + '', + options, + undefined, + bidiServerLogger + ); + return pptrBiDiConnection; +} + +/** + * Manages CDPSessions for BidiServer. + * @internal + */ +class CdpConnectionAdapter { + #cdp: CdpConnection; + #adapters = new Map<CDPSession, CDPClientAdapter<CDPSession>>(); + #browserCdpConnection: CDPClientAdapter<CdpConnection>; + + constructor(cdp: CdpConnection) { + this.#cdp = cdp; + this.#browserCdpConnection = new CDPClientAdapter(cdp); + } + + browserClient(): CDPClientAdapter<CdpConnection> { + return this.#browserCdpConnection; + } + + getCdpClient(id: string) { + const session = this.#cdp.session(id); + if (!session) { + throw new Error(`Unknown CDP session with id ${id}`); + } + if (!this.#adapters.has(session)) { + const adapter = new CDPClientAdapter( + session, + id, + this.#browserCdpConnection + ); + this.#adapters.set(session, adapter); + return adapter; + } + return this.#adapters.get(session)!; + } + + close() { + this.#browserCdpConnection.close(); + for (const adapter of this.#adapters.values()) { + adapter.close(); + } + } +} + +/** + * Wrapper on top of CDPSession/CDPConnection to satisfy CDP interface that + * BidiServer needs. + * + * @internal + */ +class CDPClientAdapter<T extends CDPSession | CdpConnection> + extends BidiMapper.EventEmitter<CDPEvents> + implements BidiMapper.CdpClient +{ + #closed = false; + #client: T; + sessionId: string | undefined = undefined; + #browserClient?: BidiMapper.CdpClient; + + constructor( + client: T, + sessionId?: string, + browserClient?: BidiMapper.CdpClient + ) { + super(); + this.#client = client; + this.sessionId = sessionId; + this.#browserClient = browserClient; + this.#client.on('*', this.#forwardMessage as Handler<any>); + } + + browserClient(): BidiMapper.CdpClient { + return this.#browserClient!; + } + + #forwardMessage = <T extends keyof CDPEvents>( + method: T, + event: CDPEvents[T] + ) => { + this.emit(method, event); + }; + + async sendCommand<T extends keyof ProtocolMapping.Commands>( + method: T, + ...params: ProtocolMapping.Commands[T]['paramsType'] + ): Promise<ProtocolMapping.Commands[T]['returnType']> { + if (this.#closed) { + return; + } + try { + return await this.#client.send(method, ...params); + } catch (err) { + if (this.#closed) { + return; + } + throw err; + } + } + + close() { + this.#client.off('*', this.#forwardMessage as Handler<any>); + this.#closed = true; + } + + isCloseError(error: unknown): boolean { + return error instanceof TargetCloseError; + } +} + +/** + * This transport is given to the BiDi server instance and allows Puppeteer + * to send and receive commands to the BiDiServer. + * @internal + */ +class NoOpTransport + extends BidiMapper.EventEmitter<{ + bidiResponse: Bidi.ChromiumBidi.Message; + }> + implements BidiMapper.BidiTransport +{ + #onMessage: (message: Bidi.ChromiumBidi.Command) => Promise<void> | void = + async (_m: Bidi.ChromiumBidi.Command): Promise<void> => { + return; + }; + + emitMessage(message: Bidi.ChromiumBidi.Command) { + void this.#onMessage(message); + } + + setOnMessage( + onMessage: (message: Bidi.ChromiumBidi.Command) => Promise<void> | void + ): void { + this.#onMessage = onMessage; + } + + async sendMessage(message: Bidi.ChromiumBidi.Message): Promise<void> { + this.emit('bidiResponse', message); + } + + close() { + this.#onMessage = async (_m: Bidi.ChromiumBidi.Command): Promise<void> => { + return; + }; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Browser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Browser.ts new file mode 100644 index 0000000000..42979790c9 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Browser.ts @@ -0,0 +1,317 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ChildProcess} from 'child_process'; + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import { + Browser, + BrowserEvent, + type BrowserCloseCallback, + type BrowserContextOptions, + type DebugInfo, +} from '../api/Browser.js'; +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 {debugError} from '../common/util.js'; +import type {Viewport} from '../common/Viewport.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'; + +/** + * @internal + */ +export interface BidiBrowserOptions { + process?: ChildProcess; + closeCallback?: BrowserCloseCallback; + connection: BidiConnection; + defaultViewport: Viewport | null; + ignoreHTTPSErrors?: boolean; +} + +/** + * @internal + */ +export class BidiBrowser extends Browser { + readonly protocol = 'webDriverBiDi'; + + // TODO: Update generator to include fully module + static readonly subscribeModules: string[] = [ + 'browsingContext', + 'network', + 'log', + 'script', + ]; + static readonly subscribeCdpEvents: Bidi.Cdp.EventNames[] = [ + // Coverage + 'cdp.Debugger.scriptParsed', + 'cdp.CSS.styleSheetAdded', + 'cdp.Runtime.executionContextsCleared', + // Tracing + 'cdp.Tracing.tracingComplete', + // TODO: subscribe to all CDP events in the future. + 'cdp.Network.requestWillBeSent', + 'cdp.Debugger.scriptParsed', + 'cdp.Page.screencastFrame', + ]; + + static async create(opts: BidiBrowserOptions): Promise<BidiBrowser> { + const session = await Session.from(opts.connection, { + alwaysMatch: { + acceptInsecureCerts: opts.ignoreHTTPSErrors, + webSocketUrl: true, + }, + }); + + await session.subscribe( + session.capabilities.browserName.toLocaleLowerCase().includes('firefox') + ? BidiBrowser.subscribeModules + : [...BidiBrowser.subscribeModules, ...BidiBrowser.subscribeCdpEvents] + ); + + const browser = new BidiBrowser(session.browser, opts); + browser.#initialize(); + await browser.#getTree(); + return browser; + } + + #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)], + ]); + + private constructor(browserCore: BrowserCore, opts: BidiBrowserOptions) { + super(); + this.#process = opts.process; + this.#closeCallback = opts.closeCallback; + this.#browserCore = browserCore; + this.#defaultViewport = opts.defaultViewport; + this.#browserTarget = new BiDiBrowserTarget(this); + this.#createBrowserContext(this.#browserCore.defaultUserContext); + } + + #initialize() { + this.#browserCore.once('disconnected', () => { + this.emit(BrowserEvent.Disconnected, undefined); + }); + 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() { + return this.#browserCore.session.capabilities.browserName; + } + get #browserVersion() { + return this.#browserCore.session.capabilities.browserVersion; + } + + override userAgent(): never { + throw new UnsupportedOperation(); + } + + #createBrowserContext(userContext: UserContext) { + const browserContext = new BidiBrowserContext(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 + ); + 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); + } + } + + get connection(): BidiConnection { + // SAFETY: We only have one implementation. + return this.#browserCore.session.connection as BidiConnection; + } + + override wsEndpoint(): string { + return this.connection.url; + } + + override async close(): Promise<void> { + for (const [eventName, handler] of this.#connectionEventHandlers) { + this.connection.off(eventName, handler); + } + if (this.connection.closed) { + return; + } + + try { + await this.#browserCore.close(); + await this.#closeCallback?.call(null); + } catch (error) { + // Fail silently. + debugError(error); + } finally { + this.connection.dispose(); + } + } + + override get connected(): boolean { + return !this.#browserCore.disposed; + } + + override process(): ChildProcess | null { + return this.#process ?? null; + } + + override async createIncognitoBrowserContext( + _options?: BrowserContextOptions + ): Promise<BidiBrowserContext> { + const userContext = await this.#browserCore.createUserContext(); + return this.#createBrowserContext(userContext); + } + + override async version(): Promise<string> { + return `${this.#browserName}/${this.#browserVersion}`; + } + + override browserContexts(): BidiBrowserContext[] { + return [...this.#browserCore.userContexts].map(context => { + return this.#browserContexts.get(context)!; + }); + } + + override defaultBrowserContext(): BidiBrowserContext { + return this.#browserContexts.get(this.#browserCore.defaultUserContext)!; + } + + override newPage(): Promise<Page> { + return this.defaultBrowserContext().newPage(); + } + + 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; + } + + override target(): Target { + return this.#browserTarget; + } + + override async disconnect(): Promise<void> { + try { + await this.#browserCore.session.end(); + } catch (error) { + // Fail silently. + debugError(error); + } finally { + this.connection.dispose(); + } + } + + override get debugInfo(): DebugInfo { + return { + pendingProtocolErrors: this.connection.getPendingProtocolErrors(), + }; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserConnector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserConnector.ts new file mode 100644 index 0000000000..f616e90561 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserConnector.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {BrowserCloseCallback} from '../api/Browser.js'; +import {Connection} from '../cdp/Connection.js'; +import type {ConnectionTransport} from '../common/ConnectionTransport.js'; +import type { + BrowserConnectOptions, + ConnectOptions, +} from '../common/ConnectOptions.js'; +import {ProtocolError, UnsupportedOperation} from '../common/Errors.js'; +import {debugError, DEFAULT_VIEWPORT} from '../common/util.js'; + +import type {BidiBrowser} from './Browser.js'; +import type {BidiConnection} from './Connection.js'; + +/** + * Users should never call this directly; it's called when calling `puppeteer.connect` + * with `protocol: 'webDriverBiDi'`. This method attaches Puppeteer to an existing browser + * instance. First it tries to connect to the browser using pure BiDi. If the protocol is + * not supported, connects to the browser using BiDi over CDP. + * + * @internal + */ +export async function _connectToBiDiBrowser( + connectionTransport: ConnectionTransport, + url: string, + options: BrowserConnectOptions & ConnectOptions +): Promise<BidiBrowser> { + const {ignoreHTTPSErrors = false, defaultViewport = DEFAULT_VIEWPORT} = + options; + + const {bidiConnection, closeCallback} = await getBiDiConnection( + connectionTransport, + url, + options + ); + const BiDi = await import(/* webpackIgnore: true */ './bidi.js'); + const bidiBrowser = await BiDi.BidiBrowser.create({ + connection: bidiConnection, + closeCallback, + process: undefined, + defaultViewport: defaultViewport, + ignoreHTTPSErrors: ignoreHTTPSErrors, + }); + return bidiBrowser; +} + +/** + * Returns a BiDiConnection established to the endpoint specified by the options and a + * callback closing the browser. Callback depends on whether the connection is pure BiDi + * or BiDi over CDP. + * The method tries to connect to the browser using pure BiDi protocol, and falls back + * to BiDi over CDP. + */ +async function getBiDiConnection( + connectionTransport: ConnectionTransport, + url: string, + options: BrowserConnectOptions +): Promise<{ + bidiConnection: BidiConnection; + closeCallback: BrowserCloseCallback; +}> { + const BiDi = await import(/* webpackIgnore: true */ './bidi.js'); + const {ignoreHTTPSErrors = false, slowMo = 0, protocolTimeout} = options; + + // Try pure BiDi first. + const pureBidiConnection = new BiDi.BidiConnection( + url, + connectionTransport, + slowMo, + protocolTimeout + ); + try { + const result = await pureBidiConnection.send('session.status', {}); + if ('type' in result && result.type === 'success') { + // The `browserWSEndpoint` points to an endpoint supporting pure WebDriver BiDi. + return { + bidiConnection: pureBidiConnection, + closeCallback: async () => { + await pureBidiConnection.send('browser.close', {}).catch(debugError); + }, + }; + } + } catch (e) { + if (!(e instanceof ProtocolError)) { + // Unexpected exception not related to BiDi / CDP. Rethrow. + throw e; + } + } + // Unbind the connection to avoid memory leaks. + pureBidiConnection.unbind(); + + // Fall back to CDP over BiDi reusing the WS connection. + const cdpConnection = new Connection( + url, + connectionTransport, + slowMo, + protocolTimeout + ); + + const version = await cdpConnection.send('Browser.getVersion'); + if (version.product.toLowerCase().includes('firefox')) { + throw new UnsupportedOperation( + 'Firefox is not supported in BiDi over CDP mode.' + ); + } + + // TODO: use other options too. + const bidiOverCdpConnection = await BiDi.connectBidiOverCdp(cdpConnection, { + acceptInsecureCerts: ignoreHTTPSErrors, + }); + return { + bidiConnection: bidiOverCdpConnection, + closeCallback: async () => { + // In case of BiDi over CDP, we need to close browser via CDP. + await cdpConnection.send('Browser.close').catch(debugError); + }, + }; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserContext.ts new file mode 100644 index 0000000000..feb5e9951d --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserContext.ts @@ -0,0 +1,145 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +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 {Target} from '../api/Target.js'; +import {UnsupportedOperation} from '../common/Errors.js'; +import {debugError} from '../common/util.js'; +import type {Viewport} from '../common/Viewport.js'; + +import type {BidiBrowser} from './Browser.js'; +import type {BidiConnection} from './Connection.js'; +import {UserContext} from './core/UserContext.js'; +import type {BidiPage} from './Page.js'; + +/** + * @internal + */ +export interface BidiBrowserContextOptions { + defaultViewport: Viewport | null; +} + +/** + * @internal + */ +export class BidiBrowserContext extends BrowserContext { + #browser: BidiBrowser; + #connection: BidiConnection; + #defaultViewport: Viewport | null; + #userContext: UserContext; + + constructor( + browser: BidiBrowser, + userContext: UserContext, + options: BidiBrowserContextOptions + ) { + super(); + this.#browser = browser; + this.#userContext = userContext; + this.#connection = this.#browser.connection; + this.#defaultViewport = options.defaultViewport; + } + + override targets(): Target[] { + return this.#browser.targets().filter(target => { + return target.browserContext() === this; + }); + } + + override waitForTarget( + predicate: (x: Target) => boolean | Promise<boolean>, + options: WaitForTargetOptions = {} + ): Promise<Target> { + return this.#browser.waitForTarget(target => { + return target.browserContext() === this && predicate(target); + }, options); + } + + get connection(): BidiConnection { + return this.#connection; + } + + override async newPage(): Promise<Page> { + const {result} = await this.#connection.send('browsingContext.create', { + type: Bidi.BrowsingContext.CreateType.Tab, + }); + 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); + + const page = await target.page(); + if (!page) { + throw new Error('Page is not found'); + } + if (this.#defaultViewport) { + try { + await page.setViewport(this.#defaultViewport); + } catch { + // No support for setViewport in Firefox. + } + } + + return page; + } + + override async close(): Promise<void> { + if (!this.isIncognito()) { + 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(); + } catch (error) { + debugError(error); + } + } + + override browser(): BidiBrowser { + return this.#browser; + } + + 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; + }); + } + + override isIncognito(): boolean { + return this.#userContext.id !== UserContext.DEFAULT; + } + + override overridePermissions(): never { + throw new UnsupportedOperation(); + } + + override clearPermissionOverrides(): never { + throw new UnsupportedOperation(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowsingContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowsingContext.ts new file mode 100644 index 0000000000..0804628c06 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowsingContext.ts @@ -0,0 +1,187 @@ +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/Connection.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.test.ts new file mode 100644 index 0000000000..9f37e38661 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.test.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, it} from 'node:test'; + +import expect from 'expect'; + +import type {ConnectionTransport} from '../common/ConnectionTransport.js'; + +import {BidiConnection} from './Connection.js'; + +describe('WebDriver BiDi Connection', () => { + class TestConnectionTransport implements ConnectionTransport { + sent: string[] = []; + closed = false; + + send(message: string) { + this.sent.push(message); + } + + close(): void { + this.closed = true; + } + } + + it('should work', async () => { + const transport = new TestConnectionTransport(); + const connection = new BidiConnection('ws://127.0.0.1', transport); + const responsePromise = connection.send('session.new', { + capabilities: {}, + }); + expect(transport.sent).toEqual([ + `{"id":1,"method":"session.new","params":{"capabilities":{}}}`, + ]); + const id = JSON.parse(transport.sent[0]!).id; + const rawResponse = { + id, + type: 'success', + result: {ready: false, message: 'already connected'}, + }; + (transport as ConnectionTransport).onmessage?.(JSON.stringify(rawResponse)); + const response = await responsePromise; + expect(response).toEqual(rawResponse); + connection.dispose(); + expect(transport.closed).toBeTruthy(); + }); +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.ts new file mode 100644 index 0000000000..bce952ba39 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.ts @@ -0,0 +1,256 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {CallbackRegistry} from '../common/CallbackRegistry.js'; +import type {ConnectionTransport} from '../common/ConnectionTransport.js'; +import {debug} from '../common/Debug.js'; +import type {EventsWithWildcard} from '../common/EventEmitter.js'; +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 type { + BidiEvents, + Commands as BidiCommands, + Connection, +} from './core/Connection.js'; + +const debugProtocolSend = debug('puppeteer:webDriverBiDi:SEND ►'); +const debugProtocolReceive = debug('puppeteer:webDriverBiDi:RECV ◀'); + +/** + * @internal + */ +export interface Commands extends BidiCommands { + 'cdp.sendCommand': { + params: Bidi.Cdp.SendCommandParameters; + returnType: Bidi.Cdp.SendCommandResult; + }; + 'cdp.getSession': { + params: Bidi.Cdp.GetSessionParameters; + returnType: Bidi.Cdp.GetSessionResult; + }; +} + +/** + * @internal + */ +export class BidiConnection + extends EventEmitter<BidiEvents> + implements Connection +{ + #url: string; + #transport: ConnectionTransport; + #delay: number; + #timeout? = 0; + #closed = false; + #callbacks = new CallbackRegistry(); + #browsingContexts = new Map<string, BrowsingContext>(); + #emitters: Array<EventEmitter<any>> = []; + + constructor( + url: string, + transport: ConnectionTransport, + delay = 0, + timeout?: number + ) { + super(); + this.#url = url; + this.#delay = delay; + this.#timeout = timeout ?? 180_000; + + this.#transport = transport; + this.#transport.onmessage = this.onMessage.bind(this); + this.#transport.onclose = this.unbind.bind(this); + } + + get closed(): boolean { + return this.#closed; + } + + get url(): string { + return this.#url; + } + + pipeTo<Events extends BidiEvents>(emitter: EventEmitter<Events>): void { + this.#emitters.push(emitter); + } + + override emit<Key extends keyof EventsWithWildcard<BidiEvents>>( + type: Key, + event: EventsWithWildcard<BidiEvents>[Key] + ): boolean { + for (const emitter of this.#emitters) { + emitter.emit(type, event); + } + return super.emit(type, event); + } + + send<T extends keyof Commands>( + method: T, + params: Commands[T]['params'] + ): Promise<{result: Commands[T]['returnType']}> { + assert(!this.#closed, 'Protocol error: Connection closed.'); + + return this.#callbacks.create(method, this.#timeout, id => { + const stringifiedMessage = JSON.stringify({ + id, + method, + params, + } as Bidi.Command); + debugProtocolSend(stringifiedMessage); + this.#transport.send(stringifiedMessage); + }) as Promise<{result: Commands[T]['returnType']}>; + } + + /** + * @internal + */ + protected async onMessage(message: string): Promise<void> { + if (this.#delay) { + await new Promise(f => { + return setTimeout(f, this.#delay); + }); + } + debugProtocolReceive(message); + const object: Bidi.ChromiumBidi.Message = JSON.parse(message); + if ('type' in object) { + switch (object.type) { + case 'success': + this.#callbacks.resolve(object.id, object); + return; + case 'error': + if (object.id === null) { + break; + } + this.#callbacks.reject( + object.id, + createProtocolError(object), + object.message + ); + return; + case 'event': + if (isCdpEvent(object)) { + cdpSessions + .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, + object.params as BidiEvents[keyof BidiEvents] + ); + return; + } + } + // Even if the response in not in BiDi protocol format but `id` is provided, reject + // the callback. This can happen if the endpoint supports CDP instead of BiDi. + if ('id' in object) { + this.#callbacks.reject( + (object as {id: number}).id, + `Protocol Error. Message is not in BiDi protocol format: '${message}'`, + object.message + ); + } + 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. + * @internal + */ + unbind(): void { + if (this.#closed) { + return; + } + this.#closed = true; + // Both may still be invoked and produce errors + this.#transport.onmessage = () => {}; + this.#transport.onclose = () => {}; + + this.#browsingContexts.clear(); + this.#callbacks.clear(); + } + + /** + * Unbinds the connection and closes the transport. + */ + dispose(): void { + this.unbind(); + this.#transport.close(); + } + + getPendingProtocolErrors(): Error[] { + return this.#callbacks.getPendingProtocolErrors(); + } +} + +/** + * @internal + */ +function createProtocolError(object: Bidi.ErrorResponse): string { + let message = `${object.error} ${object.message}`; + if (object.stacktrace) { + message += ` ${object.stacktrace}`; + } + return message; +} + +function isCdpEvent(event: Bidi.ChromiumBidi.Event): event is Bidi.Cdp.Event { + return event.method.startsWith('cdp.'); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Deserializer.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Deserializer.ts new file mode 100644 index 0000000000..14b87d403b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Deserializer.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +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 deserializeLocalValue(result: Bidi.Script.RemoteValue): unknown { + switch (result.type) { + case 'array': + return result.value?.map(value => { + return BidiDeserializer.deserializeLocalValue(value); + }); + case 'set': + return result.value?.reduce((acc: Set<unknown>, value) => { + return acc.add(BidiDeserializer.deserializeLocalValue(value)); + }, new Set()); + case 'object': + return result.value?.reduce((acc: Record<any, unknown>, tuple) => { + const {key, value} = BidiDeserializer.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); + return acc.set(key, value); + }, new Map()); + case 'promise': + return {}; + case 'regexp': + return new RegExp(result.value.pattern, result.value.flags); + case 'date': + return new Date(result.value); + case 'undefined': + return undefined; + case 'null': + return null; + case 'number': + return BidiDeserializer.deserializeNumber(result.value); + case 'bigint': + return BigInt(result.value); + case 'boolean': + return Boolean(result.value); + case 'string': + return result.value; + } + + debugError(`Deserialization of type ${result.type} not supported.`); + return undefined; + } + + 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); + + 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 new file mode 100644 index 0000000000..ce22223461 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Dialog.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2017 Google Inc. + * 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'; + +/** + * @internal + */ +export class BidiDialog extends Dialog { + #context: BrowsingContext; + + /** + * @internal + */ + constructor( + context: BrowsingContext, + type: Bidi.BrowsingContext.UserPromptOpenedParameters['type'], + message: string, + defaultValue?: string + ) { + super(type, message, defaultValue); + this.#context = context; + } + + /** + * @internal + */ + override async handle(options: { + accept: boolean; + text?: string; + }): Promise<void> { + await this.#context.connection.send('browsingContext.handleUserPrompt', { + context: this.#context.id, + 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 new file mode 100644 index 0000000000..fd886e8c26 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ElementHandle.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +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 {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'; + +/** + * @internal + */ +export class BidiElementHandle< + ElementType extends Node = Element, +> extends ElementHandle<ElementType> { + declare handle: BidiJSHandle<ElementType>; + + constructor(sandbox: Sandbox, remoteValue: Bidi.Script.RemoteValue) { + super(new BidiJSHandle(sandbox, remoteValue)); + } + + override get realm(): Sandbox { + return this.handle.realm; + } + + 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(); + } + + @throwIfDisposed() + override async autofill(data: AutofillData): Promise<void> { + const client = this.frame.client; + const nodeInfo = await client.send('DOM.describeNode', { + objectId: this.handle.id, + }); + const fieldId = nodeInfo.node.backendNodeId; + const frameId = this.frame._id; + await client.send('Autofill.trigger', { + fieldId, + frameId, + card: data.creditCard, + }); + } + + override async contentFrame( + this: BidiElementHandle<HTMLIFrameElement> + ): Promise<BidiFrame>; + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + override async contentFrame(): Promise<BidiFrame | null> { + using handle = (await this.evaluateHandle(element => { + if (element instanceof HTMLIFrameElement) { + return element.contentWindow; + } + return; + })) as BidiJSHandle; + const value = handle.remoteValue(); + if (value.type === 'window') { + return this.frame.page().frame(value.value.context); + } + return null; + } + + override uploadFile(this: ElementHandle<HTMLInputElement>): never { + throw new UnsupportedOperation(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/EmulationManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/EmulationManager.ts new file mode 100644 index 0000000000..de95695785 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/EmulationManager.ts @@ -0,0 +1,35 @@ +/** + * @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 new file mode 100644 index 0000000000..62c6b5e37e --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ExposedFunction.ts @@ -0,0 +1,295 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.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 {interpolateFunction, stringifyFunction} from '../util/Function.js'; + +import type {BidiConnection} from './Connection.js'; +import {BidiDeserializer} from './Deserializer.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] +) => void; + +interface RemotePromiseCallbacks { + resolve: Deferred<Bidi.Script.RemoteValue>; + reject: Deferred<Bidi.Script.RemoteValue>; +} + +/** + * @internal + */ +export class ExposeableFunction<Args extends unknown[], Ret> { + readonly #frame; + + readonly name; + readonly #apply; + + readonly #channels; + readonly #callerInfos = new Map< + string, + Map<number, RemotePromiseCallbacks> + >(); + + #preloadScriptId?: Bidi.Script.PreloadScript; + + constructor( + frame: BidiFrame, + name: string, + apply: (...args: Args) => Awaitable<Ret> + ) { + this.#frame = frame; + this.name = name; + this.#apply = apply; + + 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`, + }; + } + + async expose(): Promise<void> { + const connection = this.#connection; + const channelArguments = this.#channelArguments; + + // TODO(jrandolf): Implement cleanup with removePreloadScript. + connection.on( + Bidi.ChromiumBidi.Script.EventNames.Message, + this.#handleArgumentsMessage + ); + connection.on( + Bidi.ChromiumBidi.Script.EventNames.Message, + this.#handleResolveMessage + ); + connection.on( + Bidi.ChromiumBidi.Script.EventNames.Message, + this.#handleRejectMessage + ); + + const functionDeclaration = stringifyFunction( + interpolateFunction( + ( + sendArgs: SendArgsChannel<Args>, + sendResolve: SendResolveChannel<Ret>, + sendReject: SendRejectChannel + ) => { + let id = 0; + 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; + } + ); + }, + }); + }, + {name: JSON.stringify(this.name)} + ) + ); + + const {result} = await connection.send('script.addPreloadScript', { + functionDeclaration, + arguments: channelArguments, + contexts: [this.#frame.page().mainFrame()._id], + }); + this.#preloadScriptId = result.script; + + 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, + }); + }) + ); + } + + #handleArgumentsMessage = async (params: Bidi.Script.MessageParameters) => { + if (params.channel !== this.#channels.args) { + return; + } + const connection = this.#connection; + const {callbacks, remoteValue} = this.#getCallbacksAndRemoteValue(params); + const args = remoteValue.value?.[1]; + assert(args); + 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, + }, + }); + } 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); + } + ), + 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, + }, + }); + } 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, + }, + }); + } + } 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; + } + 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); + } + + const callerId = callerIdRemote.value; + let callbacks = bindingMap.get(callerId); + if (!callbacks) { + callbacks = { + resolve: new Deferred(), + reject: new Deferred(), + }; + bindingMap.set(callerId, callbacks); + } + return {callbacks, remoteValue: data}; + } + + [Symbol.dispose](): void { + void this[Symbol.asyncDispose]().catch(debugError); + } + + async [Symbol.asyncDispose](): Promise<void> { + if (this.#preloadScriptId) { + await this.#connection.send('script.removePreloadScript', { + script: this.#preloadScriptId, + }); + } + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts new file mode 100644 index 0000000000..1638c2cbdf --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts @@ -0,0 +1,313 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import { + first, + firstValueFrom, + forkJoin, + from, + map, + merge, + raceWith, + zip, +} from '../../third_party/rxjs/rxjs.js'; +import type {CDPSession} from '../api/CDPSession.js'; +import type {ElementHandle} from '../api/ElementHandle.js'; +import { + Frame, + throwIfDetached, + type GoToOptions, + type WaitForOptions, +} from '../api/Frame.js'; +import type {WaitForSelectorOptions} from '../api/Page.js'; +import {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 {ExposeableFunction} from './ExposedFunction.js'; +import type {BidiHTTPResponse} from './HTTPResponse.js'; +import { + getBiDiLifecycleEvent, + getBiDiReadinessState, + rewriteNavigationError, +} from './lifecycle.js'; +import type {BidiPage} from './Page.js'; +import { + MAIN_SANDBOX, + PUPPETEER_SANDBOX, + Sandbox, + type SandboxChart, +} from './Sandbox.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 + ) { + 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 + ), + }; + } + + override get client(): CDPSession { + return this.context().cdpSession; + } + + override mainRealm(): Sandbox { + return this.sandboxes[MAIN_SANDBOX]; + } + + override isolatedRealm(): Sandbox { + return this.sandboxes[PUPPETEER_SANDBOX]; + } + + override page(): BidiPage { + return this.#page; + } + + override isOOPFrame(): never { + throw new UnsupportedOperation(); + } + + override url(): string { + return this.#context.url; + } + + override parentFrame(): BidiFrame | null { + return this.#page.frame(this._parentId ?? ''); + } + + override childFrames(): BidiFrame[] { + return this.#page.childFrames(this.#context.id); + } + + @throwIfDetached + override async goto( + 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, + }) + ), + ...(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) + ); + + const result = await firstValueFrom(result$); + return this.#page.getNavigationResponse(result.navigation); + } + + @throwIfDetached + override async setContent( + 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; + }) + ), + ...(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 + ) + ).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 { + throw new UnsupportedOperation(); + } + + 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](); + } + + #exposedFunctions = new Map<string, ExposeableFunction<never[], unknown>>(); + async exposeFunction<Args extends unknown[], Ret>( + name: string, + apply: (...args: Args) => Awaitable<Ret> + ): Promise<void> { + if (this.#exposedFunctions.has(name)) { + throw new Error( + `Failed to add page binding with name ${name}: globalThis['${name}'] already exists!` + ); + } + const exposeable = new ExposeableFunction(this, name, apply); + this.#exposedFunctions.set(name, exposeable); + try { + await exposeable.expose(); + } catch (error) { + this.#exposedFunctions.delete(name); + throw error; + } + } + + override waitForSelector<Selector extends string>( + selector: Selector, + options?: WaitForSelectorOptions + ): Promise<ElementHandle<NodeFor<Selector>> | null> { + if (selector.startsWith('aria')) { + throw new UnsupportedOperation( + 'ARIA selector is not supported for BiDi!' + ); + } + + return super.waitForSelector(selector, options); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPRequest.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPRequest.ts new file mode 100644 index 0000000000..57cb801b8c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPRequest.ts @@ -0,0 +1,163 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import type {Frame} from '../api/Frame.js'; +import type { + ContinueRequestOverrides, + ResponseForRequest, +} from '../api/HTTPRequest.js'; +import {HTTPRequest, type ResourceType} from '../api/HTTPRequest.js'; +import {UnsupportedOperation} from '../common/Errors.js'; + +import type {BidiHTTPResponse} from './HTTPResponse.js'; + +/** + * @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[] = [] + ) { + super(); + + 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.#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; + } + } + } + + override get client(): never { + throw new UnsupportedOperation(); + } + + override url(): string { + return this.#url; + } + + override resourceType(): ResourceType { + return this.#resourceType; + } + + override method(): string { + return this.#method; + } + + override postData(): string | undefined { + return this.#postData; + } + + override hasPostData(): boolean { + return this.#postData !== undefined; + } + + override async fetchPostData(): Promise<string | undefined> { + return this.#postData; + } + + override headers(): Record<string, string> { + return this.#headers; + } + + override response(): BidiHTTPResponse | null { + return this._response; + } + + override isNavigationRequest(): boolean { + return Boolean(this._navigationId); + } + + override initiator(): Bidi.Network.Initiator { + return this.#initiator; + } + + override redirectChain(): BidiHTTPRequest[] { + return this._redirectChain.slice(); + } + + override enqueueInterceptAction( + pendingHandler: () => void | PromiseLike<unknown> + ): void { + // Execute the handler when interception is not supported + void pendingHandler(); + } + + override frame(): Frame | null { + return this.#frame; + } + + override continueRequestOverrides(): never { + throw new UnsupportedOperation(); + } + + override continue(_overrides: ContinueRequestOverrides = {}): never { + throw new UnsupportedOperation(); + } + + override responseForRequest(): never { + throw new UnsupportedOperation(); + } + + override abortErrorReason(): never { + throw new UnsupportedOperation(); + } + + override interceptResolutionState(): never { + throw new UnsupportedOperation(); + } + + override isInterceptResolutionHandled(): never { + throw new UnsupportedOperation(); + } + + override finalizeInterceptions(): never { + throw new UnsupportedOperation(); + } + + override abort(): never { + throw new UnsupportedOperation(); + } + + override respond( + _response: Partial<ResponseForRequest>, + _priority?: number + ): 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 new file mode 100644 index 0000000000..ce28820a65 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPResponse.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +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 {UnsupportedOperation} from '../common/Errors.js'; + +import type {BidiHTTPRequest} from './HTTPRequest.js'; + +/** + * @internal + */ +export class BidiHTTPResponse extends HTTPResponse { + #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 + ) { + super(); + 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; + } + } + } + + override remoteAddress(): RemoteAddress { + return this.#remoteAddress; + } + + override url(): string { + return this.#url; + } + + override status(): number { + return this.#status; + } + + override statusText(): string { + return this.#statusText; + } + + override headers(): Record<string, string> { + return this.#headers; + } + + override request(): BidiHTTPRequest { + return this.#request; + } + + override fromCache(): boolean { + return this.#fromCache; + } + + override timing(): Protocol.Network.ResourceTiming | null { + return this.#timings as any; + } + + override frame(): Frame | null { + return this.#request.frame(); + } + + override fromServiceWorker(): boolean { + return false; + } + + override securityDetails(): never { + throw new UnsupportedOperation(); + } + + override buffer(): never { + throw new UnsupportedOperation(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Input.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Input.ts new file mode 100644 index 0000000000..5406556d64 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Input.ts @@ -0,0 +1,732 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import type {Point} from '../api/ElementHandle.js'; +import { + Keyboard, + Mouse, + MouseButton, + Touchscreen, + type KeyDownOptions, + type KeyPressOptions, + type KeyboardTypeOptions, + type MouseClickOptions, + type MouseMoveOptions, + type MouseOptions, + type MouseWheelOptions, +} from '../api/Input.js'; +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 { + Mouse = '__puppeteer_mouse', + Keyboard = '__puppeteer_keyboard', + Wheel = '__puppeteer_wheel', + Finger = '__puppeteer_finger', +} + +enum SourceActionsType { + None = 'none', + Key = 'key', + Pointer = 'pointer', + Wheel = 'wheel', +} + +enum ActionType { + Pause = 'pause', + KeyDown = 'keyDown', + KeyUp = 'keyUp', + PointerUp = 'pointerUp', + PointerDown = 'pointerDown', + PointerMove = 'pointerMove', + Scroll = 'scroll', +} + +const getBidiKeyValue = (key: KeyInput) => { + switch (key) { + case '\r': + case '\n': + key = 'Enter'; + break; + } + // Measures the number of code points rather than UTF-16 code units. + if ([...key].length === 1) { + return key; + } + switch (key) { + case 'Cancel': + return '\uE001'; + case 'Help': + return '\uE002'; + case 'Backspace': + return '\uE003'; + case 'Tab': + return '\uE004'; + case 'Clear': + return '\uE005'; + case 'Enter': + return '\uE007'; + case 'Shift': + case 'ShiftLeft': + return '\uE008'; + case 'Control': + case 'ControlLeft': + return '\uE009'; + case 'Alt': + case 'AltLeft': + return '\uE00A'; + case 'Pause': + return '\uE00B'; + case 'Escape': + return '\uE00C'; + case 'PageUp': + return '\uE00E'; + case 'PageDown': + return '\uE00F'; + case 'End': + return '\uE010'; + case 'Home': + return '\uE011'; + case 'ArrowLeft': + return '\uE012'; + case 'ArrowUp': + return '\uE013'; + case 'ArrowRight': + return '\uE014'; + case 'ArrowDown': + return '\uE015'; + case 'Insert': + return '\uE016'; + case 'Delete': + return '\uE017'; + case 'NumpadEqual': + return '\uE019'; + case 'Numpad0': + return '\uE01A'; + case 'Numpad1': + return '\uE01B'; + case 'Numpad2': + return '\uE01C'; + case 'Numpad3': + return '\uE01D'; + case 'Numpad4': + return '\uE01E'; + case 'Numpad5': + return '\uE01F'; + case 'Numpad6': + return '\uE020'; + case 'Numpad7': + return '\uE021'; + case 'Numpad8': + return '\uE022'; + case 'Numpad9': + return '\uE023'; + case 'NumpadMultiply': + return '\uE024'; + case 'NumpadAdd': + return '\uE025'; + case 'NumpadSubtract': + return '\uE027'; + case 'NumpadDecimal': + return '\uE028'; + case 'NumpadDivide': + return '\uE029'; + case 'F1': + return '\uE031'; + case 'F2': + return '\uE032'; + case 'F3': + return '\uE033'; + case 'F4': + return '\uE034'; + case 'F5': + return '\uE035'; + case 'F6': + return '\uE036'; + case 'F7': + return '\uE037'; + case 'F8': + return '\uE038'; + case 'F9': + return '\uE039'; + case 'F10': + return '\uE03A'; + case 'F11': + return '\uE03B'; + case 'F12': + return '\uE03C'; + case 'Meta': + case 'MetaLeft': + return '\uE03D'; + case 'ShiftRight': + return '\uE050'; + case 'ControlRight': + return '\uE051'; + case 'AltRight': + return '\uE052'; + case 'MetaRight': + return '\uE053'; + case 'Digit0': + return '0'; + case 'Digit1': + return '1'; + case 'Digit2': + return '2'; + case 'Digit3': + return '3'; + case 'Digit4': + return '4'; + case 'Digit5': + return '5'; + case 'Digit6': + return '6'; + case 'Digit7': + return '7'; + case 'Digit8': + return '8'; + case 'Digit9': + return '9'; + case 'KeyA': + return 'a'; + case 'KeyB': + return 'b'; + case 'KeyC': + return 'c'; + case 'KeyD': + return 'd'; + case 'KeyE': + return 'e'; + case 'KeyF': + return 'f'; + case 'KeyG': + return 'g'; + case 'KeyH': + return 'h'; + case 'KeyI': + return 'i'; + case 'KeyJ': + return 'j'; + case 'KeyK': + return 'k'; + case 'KeyL': + return 'l'; + case 'KeyM': + return 'm'; + case 'KeyN': + return 'n'; + case 'KeyO': + return 'o'; + case 'KeyP': + return 'p'; + case 'KeyQ': + return 'q'; + case 'KeyR': + return 'r'; + case 'KeyS': + return 's'; + case 'KeyT': + return 't'; + case 'KeyU': + return 'u'; + case 'KeyV': + return 'v'; + case 'KeyW': + return 'w'; + case 'KeyX': + return 'x'; + case 'KeyY': + return 'y'; + case 'KeyZ': + return 'z'; + case 'Semicolon': + return ';'; + case 'Equal': + return '='; + case 'Comma': + return ','; + case 'Minus': + return '-'; + case 'Period': + return '.'; + case 'Slash': + return '/'; + case 'Backquote': + return '`'; + case 'BracketLeft': + return '['; + case 'Backslash': + return '\\'; + case 'BracketRight': + return ']'; + case 'Quote': + return '"'; + default: + throw new Error(`Unknown key: "${key}"`); + } +}; + +/** + * @internal + */ +export class BidiKeyboard extends Keyboard { + #page: BidiPage; + + constructor(page: BidiPage) { + super(); + this.#page = page; + } + + override async down( + 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), + }, + ], + }, + ], + }); + } + + 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), + }, + ], + }, + ], + }); + } + + override async press( + key: KeyInput, + options: Readonly<KeyPressOptions> = {} + ): Promise<void> { + const {delay = 0} = options; + const actions: Bidi.Input.KeySourceAction[] = [ + { + type: ActionType.KeyDown, + value: getBidiKeyValue(key), + }, + ]; + if (delay > 0) { + actions.push({ + type: ActionType.Pause, + duration: delay, + }); + } + actions.push({ + 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, + }, + ], + }); + } + + override async type( + text: string, + options: Readonly<KeyboardTypeOptions> = {} + ): Promise<void> { + const {delay = 0} = options; + // This spread separates the characters into code points rather than UTF-16 + // code units. + const values = ([...text] as KeyInput[]).map(getBidiKeyValue); + const actions: Bidi.Input.KeySourceAction[] = []; + if (delay <= 0) { + for (const value of values) { + actions.push( + { + type: ActionType.KeyDown, + value, + }, + { + type: ActionType.KeyUp, + value, + } + ); + } + } else { + for (const value of values) { + actions.push( + { + type: ActionType.KeyDown, + value, + }, + { + type: ActionType.Pause, + duration: delay, + }, + { + type: ActionType.KeyUp, + value, + } + ); + } + } + await this.#page.connection.send('input.performActions', { + context: this.#page.mainFrame()._id, + actions: [ + { + type: SourceActionsType.Key, + id: InputId.Keyboard, + actions, + }, + ], + }); + } + + override async sendCharacter(char: string): Promise<void> { + // Measures the number of code points rather than UTF-16 code units. + if ([...char].length > 1) { + throw new Error('Cannot send more than 1 character.'); + } + const frame = await this.#page.focusedFrame(); + await frame.isolatedRealm().evaluate(async char => { + document.execCommand('insertText', false, char); + }, char); + } +} + +/** + * @internal + */ +export interface BidiMouseClickOptions extends MouseClickOptions { + origin?: Bidi.Input.Origin; +} + +/** + * @internal + */ +export interface BidiMouseMoveOptions extends MouseMoveOptions { + origin?: Bidi.Input.Origin; +} + +/** + * @internal + */ +export interface BidiTouchMoveOptions { + origin?: Bidi.Input.Origin; +} + +const getBidiButton = (button: MouseButton) => { + switch (button) { + case MouseButton.Left: + return 0; + case MouseButton.Middle: + return 1; + case MouseButton.Right: + return 2; + case MouseButton.Back: + return 3; + case MouseButton.Forward: + return 4; + } +}; + +/** + * @internal + */ +export class BidiMouse extends Mouse { + #context: BrowsingContext; + #lastMovePoint: Point = {x: 0, y: 0}; + + constructor(context: BrowsingContext) { + super(); + this.#context = context; + } + + override async reset(): Promise<void> { + this.#lastMovePoint = {x: 0, y: 0}; + await this.#context.connection.send('input.releaseActions', { + context: this.#context.id, + }); + } + + override async move( + x: number, + y: number, + options: Readonly<BidiMouseMoveOptions> = {} + ): Promise<void> { + const from = this.#lastMovePoint; + const to = { + x: Math.round(x), + y: Math.round(y), + }; + const actions: Bidi.Input.PointerSourceAction[] = []; + const steps = options.steps ?? 0; + for (let i = 0; i < steps; ++i) { + actions.push({ + type: ActionType.PointerMove, + x: from.x + (to.x - from.x) * (i / steps), + y: from.y + (to.y - from.y) * (i / steps), + origin: options.origin, + }); + } + actions.push({ + type: ActionType.PointerMove, + ...to, + origin: options.origin, + }); + // 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, + }, + ], + }); + } + + 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), + }, + ], + }, + ], + }); + } + + 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), + }, + ], + }, + ], + }); + } + + override async click( + x: number, + y: number, + options: Readonly<BidiMouseClickOptions> = {} + ): Promise<void> { + const actions: Bidi.Input.PointerSourceAction[] = [ + { + type: ActionType.PointerMove, + x: Math.round(x), + y: Math.round(y), + origin: options.origin, + }, + ]; + const pointerDownAction = { + type: ActionType.PointerDown, + button: getBidiButton(options.button ?? MouseButton.Left), + } as const; + const pointerUpAction = { + type: ActionType.PointerUp, + button: pointerDownAction.button, + } as const; + for (let i = 1; i < (options.count ?? 1); ++i) { + actions.push(pointerDownAction, pointerUpAction); + } + actions.push(pointerDownAction); + if (options.delay) { + actions.push({ + type: ActionType.Pause, + duration: options.delay, + }); + } + actions.push(pointerUpAction); + await this.#context.connection.send('input.performActions', { + context: this.#context.id, + actions: [ + { + 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, + }, + ], + }, + ], + }); + } + + override drag(): never { + throw new UnsupportedOperation(); + } + + override dragOver(): never { + throw new UnsupportedOperation(); + } + + override dragEnter(): never { + throw new UnsupportedOperation(); + } + + override drop(): never { + throw new UnsupportedOperation(); + } + + override dragAndDrop(): never { + throw new UnsupportedOperation(); + } +} + +/** + * @internal + */ +export class BidiTouchscreen extends Touchscreen { + #context: BrowsingContext; + + constructor(context: BrowsingContext) { + super(); + this.#context = context; + } + + override async touchStart( + x: number, + 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, + }, + ], + }, + ], + }); + } + + override async touchMove( + x: number, + 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, + }, + ], + }, + ], + }); + } + + 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, + }, + ], + }, + ], + }); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/JSHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/JSHandle.ts new file mode 100644 index 0000000000..7104601553 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/JSHandle.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import type {ElementHandle} from '../api/ElementHandle.js'; +import {JSHandle} from '../api/JSHandle.js'; +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; + readonly #remoteValue: Bidi.Script.RemoteValue; + + constructor(sandbox: Sandbox, remoteValue: Bidi.Script.RemoteValue) { + super(); + this.#sandbox = sandbox; + this.#remoteValue = remoteValue; + } + + context(): BidiRealm { + return this.realm.environment.context(); + } + + override get realm(): Sandbox { + return this.#sandbox; + } + + override get disposed(): boolean { + return this.#disposed; + } + + override async jsonValue(): Promise<T> { + return await this.evaluate(value => { + return value; + }); + } + + override asElement(): ElementHandle<Node> | null { + return null; + } + + override async dispose(): Promise<void> { + if (this.#disposed) { + return; + } + this.#disposed = true; + if ('handle' in this.#remoteValue) { + await releaseReference( + this.context(), + this.#remoteValue as Bidi.Script.RemoteReference + ); + } + } + + get isPrimitiveValue(): boolean { + switch (this.#remoteValue.type) { + case 'string': + case 'number': + case 'bigint': + case 'boolean': + case 'undefined': + case 'null': + return true; + + default: + return false; + } + } + + override toString(): string { + if (this.isPrimitiveValue) { + return 'JSHandle:' + BidiDeserializer.deserialize(this.#remoteValue); + } + + return 'JSHandle@' + this.#remoteValue.type; + } + + override get id(): string | undefined { + return 'handle' in this.#remoteValue ? this.#remoteValue.handle : undefined; + } + + remoteValue(): Bidi.Script.RemoteValue { + return this.#remoteValue; + } + + override remoteObject(): never { + throw new UnsupportedOperation('Not available in WebDriver BiDi'); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/NetworkManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/NetworkManager.ts new file mode 100644 index 0000000000..2caaf0ad50 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/NetworkManager.ts @@ -0,0 +1,155 @@ +/** + * @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 new file mode 100644 index 0000000000..053d23b63a --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Page.ts @@ -0,0 +1,913 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Readable} from 'stream'; + +import type * 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 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 { + 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 {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 {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 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 {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 {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'; + +/** + * @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, + browserContext: BidiBrowserContext, + target: BiDiPageTarget + ) { + super(); + this.#browsingContext = browsingContext; + this.#browserContext = browserContext; + this.#target = target; + this.#connection = browsingContext.connection; + + for (const [event, subscriber] of this.#browsingContextEvents) { + this.#browsingContext.on(event, subscriber); + } + + this.#networkManager = new BidiNetworkManager(this.#connection, this); + + for (const [event, subscriber] of this.#subscribedEvents) { + this.#connection.on(event, subscriber); + } + + for (const [event, subscriber] of this.#networkManagerEvents) { + // TODO: remove any + this.#networkManager.on(event, subscriber as any); + } + + const frame = new BidiFrame( + this, + this.#browsingContext, + this._timeoutSettings, + this.#browsingContext.parent + ); + this.#frameTree.addFrame(frame); + this.emit(PageEvent.FrameAttached, frame); + + // 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); + } + + /** + * @internal + */ + get connection(): BidiConnection { + return this.#connection; + } + + override async setUserAgent( + userAgent: string, + userAgentMetadata?: Protocol.Emulation.UserAgentMetadata | undefined + ): Promise<void> { + // TODO: handle CDP-specific cases such as mprach. + await this._client().send('Network.setUserAgentOverride', { + userAgent: userAgent, + userAgentMetadata: userAgentMetadata, + }); + } + + override async setBypassCSP(enabled: boolean): Promise<void> { + // TODO: handle CDP-specific cases such as mprach. + await this._client().send('Page.setBypassCSP', {enabled}); + } + + override async queryObjects<Prototype>( + prototypeHandle: BidiJSHandle<Prototype> + ): Promise<BidiJSHandle<Prototype[]>> { + assert(!prototypeHandle.disposed, 'Prototype JSHandle is disposed!'); + assert( + 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(), { + 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(); + } + + override browserContext(): BidiBrowserContext { + return this.#browserContext; + } + + override mainFrame(): BidiFrame { + const mainFrame = this.#frameTree.getMainFrame(); + assert(mainFrame, 'Requesting main frame too early!'); + return mainFrame; + } + + /** + * @internal + */ + async focusedFrame(): Promise<BidiFrame> { + using frame = await this.mainFrame() + .isolatedRealm() + .evaluateHandle(() => { + let frame: HTMLIFrameElement | undefined; + let win: Window | null = window; + while (win?.document.activeElement instanceof HTMLIFrameElement) { + frame = win.document.activeElement; + win = frame.contentWindow; + } + return frame; + }); + if (!(frame instanceof BidiElementHandle)) { + return this.mainFrame(); + } + return await frame.contentFrame(); + } + + 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 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); + } + + override isClosed(): boolean { + return this.#closedDeferred.finished(); + } + + override async close(options?: {runBeforeUnload?: boolean}): Promise<void> { + if (this.#closedDeferred.finished()) { + 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 result = await firstValueFrom(result$); + return this.getNavigationResponse(result.navigation); + } + + override setDefaultNavigationTimeout(timeout: number): void { + this._timeoutSettings.setDefaultNavigationTimeout(timeout); + } + + override setDefaultTimeout(timeout: number): void { + this._timeoutSettings.setDefaultTimeout(timeout); + } + + override getDefaultTimeout(): number { + return this._timeoutSettings.timeout(); + } + + override isJavaScriptEnabled(): boolean { + return this.#cdpEmulationManager.javascriptEnabled; + } + + override async setGeolocation(options: GeolocationOptions): Promise<void> { + return await this.#cdpEmulationManager.setGeolocation(options); + } + + override async setJavaScriptEnabled(enabled: boolean): Promise<void> { + return await this.#cdpEmulationManager.setJavaScriptEnabled(enabled); + } + + override async emulateMediaType(type?: string): Promise<void> { + return await this.#cdpEmulationManager.emulateMediaType(type); + } + + override async emulateCPUThrottling(factor: number | null): Promise<void> { + return await this.#cdpEmulationManager.emulateCPUThrottling(factor); + } + + override async emulateMediaFeatures( + features?: MediaFeature[] + ): Promise<void> { + return await this.#cdpEmulationManager.emulateMediaFeatures(features); + } + + override async emulateTimezone(timezoneId?: string): Promise<void> { + return await this.#cdpEmulationManager.emulateTimezone(timezoneId); + } + + override async emulateIdleState(overrides?: { + isUserActive: boolean; + isScreenUnlocked: boolean; + }): Promise<void> { + return await this.#cdpEmulationManager.emulateIdleState(overrides); + } + + override async emulateVisionDeficiency( + type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'] + ): Promise<void> { + return await this.#cdpEmulationManager.emulateVisionDeficiency(type); + } + + override async setViewport(viewport: Viewport): Promise<void> { + if (!this.#browsingContext.supportsCdp()) { + await this.#emulationManager.emulateViewport(viewport); + this.#viewport = viewport; + return; + } + const needsReload = + await this.#cdpEmulationManager.emulateViewport(viewport); + this.#viewport = viewport; + if (needsReload) { + await this.reload(); + } + } + + override viewport(): Viewport | null { + return this.#viewport; + } + + override async pdf(options: PDFOptions = {}): Promise<Buffer> { + const {timeout: ms = this._timeoutSettings.timeout(), path = undefined} = + options; + const { + printBackground: background, + margin, + landscape, + width, + height, + pageRanges: ranges, + scale, + preferCSSPageSize, + } = parsePDFOptions(options, 'cm'); + const pageRanges = ranges ? ranges.split(', ') : []; + const {result} = await firstValueFrom( + from( + this.#connection.send('browsingContext.print', { + context: this.mainFrame()._id, + background, + margin, + orientation: landscape ? 'landscape' : 'portrait', + page: { + width, + height, + }, + pageRanges, + scale, + shrinkToFit: !preferCSSPageSize, + }) + ).pipe(raceWith(timeout(ms))) + ); + + const buffer = Buffer.from(result.data, 'base64'); + + await this._maybeWriteBufferToFile(path, buffer); + + return buffer; + } + + override async createPDFStream( + options?: PDFOptions | undefined + ): Promise<Readable> { + 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; + } + } + + override async _screenshot( + options: Readonly<ScreenshotOptions> + ): Promise<string> { + const {clip, type, captureBeyondViewport, quality} = options; + if (options.omitBackground !== undefined && options.omitBackground) { + throw new UnsupportedOperation(`BiDi does not support 'omitBackground'.`); + } + if (options.optimizeForSpeed !== undefined && options.optimizeForSpeed) { + throw new UnsupportedOperation( + `BiDi does not support 'optimizeForSpeed'.` + ); + } + if (options.fromSurface !== undefined && !options.fromSurface) { + throw new UnsupportedOperation(`BiDi does not support 'fromSurface'.`); + } + if (clip !== undefined && clip.scale !== undefined && clip.scale !== 1) { + throw new UnsupportedOperation( + `BiDi does not support 'scale' in 'clip'.` + ); + } + + let box: BoundingBox | undefined; + if (clip) { + if (captureBeyondViewport) { + box = clip; + } else { + // The clip is always with respect to the document coordinates, so we + // need to convert this to viewport coordinates when we aren't capturing + // beyond the viewport. + const [pageLeft, pageTop] = await this.evaluate(() => { + if (!window.visualViewport) { + throw new Error('window.visualViewport is not supported.'); + } + return [ + window.visualViewport.pageLeft, + window.visualViewport.pageTop, + ] as const; + }); + box = { + ...clip, + x: clip.x - pageLeft, + y: clip.y - pageTop, + }; + } + } + + const { + result: {data}, + } = await this.#connection.send('browsingContext.captureScreenshot', { + context: this.mainFrame()._id, + origin: captureBeyondViewport ? 'document' : 'viewport', + format: { + type: `image/${type}`, + ...(quality !== undefined ? {quality: quality / 100} : {}), + }, + ...(box ? {clip: {type: 'box', ...box}} : {}), + }); + return data; + } + + 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); + } + + override async bringToFront(): Promise<void> { + await this.#connection.send('browsingContext.activate', { + context: this.mainFrame()._id, + }); + } + + override async evaluateOnNewDocument< + Params extends unknown[], + Func extends (...args: Params) => unknown = (...args: Params) => unknown, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<NewDocumentScriptEvaluation> { + const expression = evaluationExpression(pageFunction, ...args); + const {result} = await this.#connection.send('script.addPreloadScript', { + functionDeclaration: expression, + contexts: [this.mainFrame()._id], + }); + + return {identifier: result.script}; + } + + override async removeScriptToEvaluateOnNewDocument( + id: string + ): Promise<void> { + await this.#connection.send('script.removePreloadScript', { + script: id, + }); + } + + override async exposeFunction<Args extends unknown[], Ret>( + name: string, + pptrFunction: + | ((...args: Args) => Awaitable<Ret>) + | {default: (...args: Args) => Awaitable<Ret>} + ): Promise<void> { + return await this.mainFrame().exposeFunction( + name, + 'default' in pptrFunction ? pptrFunction.default : pptrFunction + ); + } + + override isDragInterceptionEnabled(): boolean { + return false; + } + + override async setCacheEnabled(enabled?: boolean): Promise<void> { + // TODO: handle CDP-specific cases such as mprach. + await this._client().send('Network.setCacheDisabled', { + cacheDisabled: !enabled, + }); + } + + override isServiceWorkerBypassed(): never { + throw new UnsupportedOperation(); + } + + override target(): BiDiPageTarget { + return this.#target; + } + + override waitForFileChooser(): never { + throw new UnsupportedOperation(); + } + + override workers(): never { + throw new UnsupportedOperation(); + } + + override setRequestInterception(): never { + throw new UnsupportedOperation(); + } + + override setDragInterception(): never { + throw new UnsupportedOperation(); + } + + override setBypassServiceWorker(): never { + throw new UnsupportedOperation(); + } + + override setOfflineMode(): never { + throw new UnsupportedOperation(); + } + + override emulateNetworkConditions(): never { + throw new UnsupportedOperation(); + } + + override cookies(): never { + throw new UnsupportedOperation(); + } + + override setCookie(): never { + throw new UnsupportedOperation(); + } + + override deleteCookie(): never { + throw new UnsupportedOperation(); + } + + override removeExposedFunction(): never { + // TODO: Quick win? + throw new UnsupportedOperation(); + } + + override authenticate(): never { + throw new UnsupportedOperation(); + } + + override setExtraHTTPHeaders(): never { + throw new UnsupportedOperation(); + } + + override metrics(): never { + throw new UnsupportedOperation(); + } + + override async goBack( + options: WaitForOptions = {} + ): Promise<HTTPResponse | null> { + return await this.#go(-1, options); + } + + override async goForward( + options: WaitForOptions = {} + ): Promise<HTTPResponse | null> { + return await this.#go(+1, options); + } + + async #go( + delta: number, + options: WaitForOptions + ): Promise<HTTPResponse | null> { + try { + const result = await Promise.all([ + this.waitForNavigation(options), + this.#connection.send('browsingContext.traverseHistory', { + delta, + context: this.mainFrame()._id, + }), + ]); + return result[0]; + } catch (err) { + // TODO: waitForNavigation should be cancelled if an error happens. + if (isErrorLike(err)) { + if (err.message.includes('no such history entry')) { + return null; + } + } + throw err; + } + } + + override waitForDevicePrompt(): never { + throw new UnsupportedOperation(); + } +} + +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; +} + +function evaluationExpression(fun: Function | string, ...args: unknown[]) { + return `() => {${evaluationString(fun, ...args)}}`; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Realm.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Realm.ts new file mode 100644 index 0000000000..84f13bc703 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Realm.ts @@ -0,0 +1,228 @@ +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter, type EventType} from '../common/EventEmitter.js'; +import {scriptInjector} from '../common/ScriptInjector.js'; +import type {EvaluateFunc, HandleFor} from '../common/types.js'; +import { + PuppeteerURL, + SOURCE_URL_REGEX, + getSourcePuppeteerURLIfAvailable, + getSourceUrlComment, + isString, +} from '../common/util.js'; +import type PuppeteerUtil from '../injected/injected.js'; +import {disposeSymbol} from '../util/disposable.js'; +import {stringifyFunction} from '../util/Function.js'; + +import type {BidiConnection} from './Connection.js'; +import {BidiDeserializer} from './Deserializer.js'; +import {BidiElementHandle} from './ElementHandle.js'; +import {BidiJSHandle} from './JSHandle.js'; +import type {Sandbox} from './Sandbox.js'; +import {BidiSerializer} from './Serializer.js'; +import {createEvaluationError} from './util.js'; + +/** + * @internal + */ +export class BidiRealm extends EventEmitter<Record<EventType, any>> { + readonly connection: BidiConnection; + + #id!: string; + #sandbox!: Sandbox; + + constructor(connection: BidiConnection) { + super(); + this.connection = connection; + } + + 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. + 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 + ); + } + + protected internalPuppeteerUtil?: Promise<BidiJSHandle<PuppeteerUtil>>; + get puppeteerUtil(): Promise<BidiJSHandle<PuppeteerUtil>> { + const promise = Promise.resolve() as Promise<unknown>; + scriptInjector.inject(script => { + if (this.internalPuppeteerUtil) { + void this.internalPuppeteerUtil.then(handle => { + void handle.dispose(); + }); + } + this.internalPuppeteerUtil = promise.then(() => { + return this.evaluateHandle(script) as Promise< + BidiJSHandle<PuppeteerUtil> + >; + }); + }, !this.internalPuppeteerUtil); + return this.internalPuppeteerUtil as Promise<BidiJSHandle<PuppeteerUtil>>; + } + + async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + return await this.#evaluate(false, pageFunction, ...args); + } + + async evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + return await this.#evaluate(true, pageFunction, ...args); + } + + async #evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + returnByValue: true, + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>>; + async #evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + returnByValue: false, + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>>; + async #evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + returnByValue: boolean, + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>> | Awaited<ReturnType<Func>>> { + const sourceUrlComment = getSourceUrlComment( + getSourcePuppeteerURLIfAvailable(pageFunction)?.toString() ?? + PuppeteerURL.INTERNAL_URL + ); + + const sandbox = this.#sandbox; + + let responsePromise; + const resultOwnership = returnByValue + ? Bidi.Script.ResultOwnership.None + : Bidi.Script.ResultOwnership.Root; + const serializationOptions: Bidi.Script.SerializationOptions = returnByValue + ? {} + : { + maxObjectDepth: 0, + maxDomDepth: 0, + }; + if (isString(pageFunction)) { + const expression = SOURCE_URL_REGEX.test(pageFunction) + ? pageFunction + : `${pageFunction}\n${sourceUrlComment}\n`; + + responsePromise = this.connection.send('script.evaluate', { + expression, + target: this.target, + resultOwnership, + awaitPromise: true, + userActivation: true, + serializationOptions, + }); + } else { + let functionDeclaration = stringifyFunction(pageFunction); + functionDeclaration = SOURCE_URL_REGEX.test(functionDeclaration) + ? functionDeclaration + : `${functionDeclaration}\n${sourceUrlComment}\n`; + responsePromise = this.connection.send('script.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, + }); + } + + const {result} = await responsePromise; + + if ('type' in result && result.type === 'exception') { + throw createEvaluationError(result.exceptionDetails); + } + + return returnByValue + ? BidiDeserializer.deserialize(result.result) + : createBidiHandle(sandbox, result.result); + } + + [disposeSymbol](): void { + this.connection.off( + Bidi.ChromiumBidi.Script.EventNames.RealmCreated, + this.handleRealmCreated + ); + this.connection.off( + Bidi.ChromiumBidi.Script.EventNames.RealmDestroyed, + this.handleRealmDestroyed + ); + } +} + +/** + * @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); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Sandbox.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Sandbox.ts new file mode 100644 index 0000000000..4411b3dbcd --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Sandbox.ts @@ -0,0 +1,123 @@ +/** + * @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 new file mode 100644 index 0000000000..c147ec9281 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Serializer.ts @@ -0,0 +1,164 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +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 + */ +class UnserializableError extends Error {} + +/** + * @internal + */ +export class BidiSerializer { + static serializeNumber(arg: number): Bidi.Script.LocalValue { + let value: Bidi.Script.SpecialNumber | number; + if (Object.is(arg, -0)) { + value = '-0'; + } else if (Object.is(arg, Infinity)) { + value = 'Infinity'; + } else if (Object.is(arg, -Infinity)) { + value = '-Infinity'; + } else if (Object.is(arg, NaN)) { + value = 'NaN'; + } else { + value = arg; + } + return { + type: 'number', + value, + }; + } + + 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 { + type: 'array', + value: parsedArray, + }; + } else if (isPlainObject(arg)) { + try { + JSON.stringify(arg); + } catch (error) { + if ( + error instanceof TypeError && + error.message.startsWith('Converting circular structure to JSON') + ) { + error.message += ' Recursive objects are not allowed.'; + } + throw error; + } + + const parsedObject: Bidi.Script.MappingLocalValue = []; + for (const key in arg) { + parsedObject.push([ + BidiSerializer.serializeRemoteValue(key), + BidiSerializer.serializeRemoteValue(arg[key]), + ]); + } + + return { + type: 'object', + value: parsedObject, + }; + } else if (isRegExp(arg)) { + return { + type: 'regexp', + value: { + pattern: arg.source, + flags: arg.flags, + }, + }; + } else if (isDate(arg)) { + return { + type: 'date', + value: arg.toISOString(), + }; + } + + throw new UnserializableError( + '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 new file mode 100644 index 0000000000..fb01c34638 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Target.ts @@ -0,0 +1,151 @@ +/** + * @license + * Copyright 2023 Google Inc. + * 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 {BidiBrowser} from './Browser.js'; +import type {BidiBrowserContext} from './BrowserContext.js'; +import {type BrowsingContext, CdpSessionWrapper} from './BrowsingContext.js'; +import {BidiPage} from './Page.js'; + +/** + * @internal + */ +export abstract class BidiTarget extends Target { + protected _browserContext: BidiBrowserContext; + + constructor(browserContext: BidiBrowserContext) { + super(); + this._browserContext = browserContext; + } + + _setBrowserContext(browserContext: BidiBrowserContext): void { + this._browserContext = browserContext; + } + + override asPage(): Promise<Page> { + throw new UnsupportedOperation(); + } + + override browser(): BidiBrowser { + return this._browserContext.browser(); + } + + override browserContext(): BidiBrowserContext { + return this._browserContext; + } + + override opener(): never { + throw new UnsupportedOperation(); + } + + override createCDPSession(): Promise<CDPSession> { + throw new UnsupportedOperation(); + } +} + +/** + * @internal + */ +export class BiDiBrowserTarget extends Target { + #browser: BidiBrowser; + + constructor(browser: BidiBrowser) { + super(); + this.#browser = browser; + } + + override url(): string { + return ''; + } + + override type(): TargetType { + return TargetType.BROWSER; + } + + override asPage(): Promise<Page> { + throw new UnsupportedOperation(); + } + + override browser(): BidiBrowser { + return this.#browser; + } + + override browserContext(): BidiBrowserContext { + return this.#browser.defaultBrowserContext(); + } + + override opener(): never { + throw new UnsupportedOperation(); + } + + override createCDPSession(): Promise<CDPSession> { + throw new UnsupportedOperation(); + } +} + +/** + * @internal + */ +export class BiDiBrowsingContextTarget extends BidiTarget { + protected _browsingContext: BrowsingContext; + + constructor( + browserContext: BidiBrowserContext, + browsingContext: BrowsingContext + ) { + super(browserContext); + + this._browsingContext = browsingContext; + } + + override url(): string { + return this._browsingContext.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 type(): TargetType { + return TargetType.PAGE; + } +} + +/** + * @internal + */ +export class BiDiPageTarget extends BiDiBrowsingContextTarget { + #page: BidiPage; + + constructor( + browserContext: BidiBrowserContext, + browsingContext: BrowsingContext + ) { + super(browserContext, browsingContext); + + this.#page = new BidiPage(browsingContext, browserContext, this); + } + + override async page(): Promise<BidiPage> { + return this.#page; + } + + override _setBrowserContext(browserContext: BidiBrowserContext): void { + super._setBrowserContext(browserContext); + this.#page._setBrowserContext(browserContext); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/bidi.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/bidi.ts new file mode 100644 index 0000000000..373d6d999c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/bidi.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +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'; +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 new file mode 100644 index 0000000000..7c4a8ed01c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Browser.ts @@ -0,0 +1,225 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js'; +import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; +import type {SharedWorkerRealm} from './Realm.js'; +import type {Session} from './Session.js'; +import {UserContext} from './UserContext.js'; + +/** + * @internal + */ +export type AddPreloadScriptOptions = Omit< + Bidi.Script.AddPreloadScriptParameters, + 'functionDeclaration' | 'contexts' +> & { + contexts?: [BrowsingContext, ...BrowsingContext[]]; +}; + +/** + * @internal + */ +export class Browser extends EventEmitter<{ + /** Emitted before the browser closes. */ + closed: { + /** The reason for closing the browser. */ + reason: string; + }; + /** Emitted after the browser disconnects. */ + disconnected: { + /** The reason for disconnecting the browser. */ + reason: string; + }; + /** Emitted when a shared worker is created. */ + sharedworker: { + /** The realm of the shared worker. */ + realm: SharedWorkerRealm; + }; +}> { + static async from(session: Session): Promise<Browser> { + const browser = new Browser(session); + await browser.#initialize(); + return browser; + } + + // keep-sorted start + #closed = false; + #reason: string | undefined; + readonly #disposables = new DisposableStack(); + readonly #userContexts = new Map<string, UserContext>(); + readonly session: Session; + // keep-sorted end + + private constructor(session: Session) { + super(); + // keep-sorted start + this.session = session; + // keep-sorted end + + this.#userContexts.set( + UserContext.DEFAULT, + UserContext.create(this, UserContext.DEFAULT) + ); + } + + async #initialize() { + const sessionEmitter = this.#disposables.use( + new EventEmitter(this.session) + ); + sessionEmitter.once('ended', ({reason}) => { + this.dispose(reason); + }); + + sessionEmitter.on('script.realmCreated', info => { + if (info.type === 'shared-worker') { + // TODO: Create a SharedWorkerRealm. + } + }); + + await this.#syncBrowsingContexts(); + } + + async #syncBrowsingContexts() { + // In case contexts are created or destroyed during `getTree`, we use this + // set to detect them. + const contextIds = new Set<string>(); + let contexts: Bidi.BrowsingContext.Info[]; + + { + using sessionEmitter = new EventEmitter(this.session); + sessionEmitter.on('browsingContext.contextCreated', info => { + contextIds.add(info.context); + }); + sessionEmitter.on('browsingContext.contextDestroyed', info => { + contextIds.delete(info.context); + }); + const {result} = await this.session.send('browsingContext.getTree', {}); + contexts = result.contexts; + } + + // Simulating events so contexts are created naturally. + for (const info of contexts) { + if (contextIds.has(info.context)) { + this.session.emit('browsingContext.contextCreated', info); + } + if (info.children) { + contexts.push(...info.children); + } + } + } + + // keep-sorted start block=yes + get closed(): boolean { + return this.#closed; + } + get defaultUserContext(): UserContext { + // SAFETY: A UserContext is always created for the default context. + return this.#userContexts.get(UserContext.DEFAULT)!; + } + get disconnected(): boolean { + return this.#reason !== undefined; + } + get disposed(): boolean { + return this.disconnected; + } + get userContexts(): Iterable<UserContext> { + return this.#userContexts.values(); + } + // keep-sorted end + + @inertIfDisposed + dispose(reason?: string, closed = false): void { + this.#closed = closed; + this.#reason = reason; + this[disposeSymbol](); + } + + @throwIfDisposed<Browser>(browser => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return browser.#reason!; + }) + async close(): Promise<void> { + try { + await this.session.send('browser.close', {}); + } finally { + this.dispose('Browser already closed.', true); + } + } + + @throwIfDisposed<Browser>(browser => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return browser.#reason!; + }) + async addPreloadScript( + functionDeclaration: string, + options: AddPreloadScriptOptions = {} + ): Promise<string> { + const { + result: {script}, + } = await this.session.send('script.addPreloadScript', { + functionDeclaration, + ...options, + contexts: options.contexts?.map(context => { + return context.id; + }) as [string, ...string[]], + }); + return script; + } + + @throwIfDisposed<Browser>(browser => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return browser.#reason!; + }) + async removePreloadScript(script: string): Promise<void> { + await this.session.send('script.removePreloadScript', { + script, + }); + } + + static userContextId = 0; + @throwIfDisposed<Browser>(browser => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return browser.#reason!; + }) + async createUserContext(): Promise<UserContext> { + // TODO: implement incognito context https://github.com/w3c/webdriver-bidi/issues/289. + // TODO: Call `createUserContext` once available. + // Generating a monotonically increasing context id. + const context = `${++Browser.userContextId}`; + + const userContext = UserContext.create(this, context); + this.#userContexts.set(userContext.id, userContext); + + const userContextEmitter = this.#disposables.use( + new EventEmitter(userContext) + ); + userContextEmitter.once('closed', () => { + userContextEmitter.removeAllListeners(); + + this.#userContexts.delete(context); + }); + + return userContext; + } + + [disposeSymbol](): void { + this.#reason ??= + 'Browser was disconnected, probably because the session ended.'; + if (this.closed) { + this.emit('closed', {reason: this.#reason}); + } + this.emit('disconnected', {reason: this.#reason}); + + this.#disposables.dispose(); + super[disposeSymbol](); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/BrowsingContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/BrowsingContext.ts new file mode 100644 index 0000000000..9bec2a506c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/BrowsingContext.ts @@ -0,0 +1,475 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js'; +import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; + +import type {AddPreloadScriptOptions} from './Browser.js'; +import {Navigation} from './Navigation.js'; +import {WindowRealm} from './Realm.js'; +import {Request} from './Request.js'; +import type {UserContext} from './UserContext.js'; +import {UserPrompt} from './UserPrompt.js'; + +/** + * @internal + */ +export type CaptureScreenshotOptions = Omit< + Bidi.BrowsingContext.CaptureScreenshotParameters, + 'context' +>; + +/** + * @internal + */ +export type ReloadOptions = Omit< + Bidi.BrowsingContext.ReloadParameters, + 'context' +>; + +/** + * @internal + */ +export type PrintOptions = Omit< + Bidi.BrowsingContext.PrintParameters, + 'context' +>; + +/** + * @internal + */ +export type HandleUserPromptOptions = Omit< + Bidi.BrowsingContext.HandleUserPromptParameters, + 'context' +>; + +/** + * @internal + */ +export type SetViewportOptions = Omit< + Bidi.BrowsingContext.SetViewportParameters, + 'context' +>; + +/** + * @internal + */ +export class BrowsingContext extends EventEmitter<{ + /** Emitted when this context is closed. */ + closed: { + /** The reason the browsing context was closed */ + reason: string; + }; + /** Emitted when a child browsing context is created. */ + browsingcontext: { + /** The newly created child browsing context. */ + browsingContext: BrowsingContext; + }; + /** Emitted whenever a navigation occurs. */ + navigation: { + /** The navigation that occurred. */ + navigation: Navigation; + }; + /** Emitted whenever a request is made. */ + request: { + /** The request that was made. */ + request: Request; + }; + /** Emitted whenever a log entry is added. */ + log: { + /** Entry added to the log. */ + entry: Bidi.Log.Entry; + }; + /** Emitted whenever a prompt is opened. */ + userprompt: { + /** The prompt that was opened. */ + userPrompt: UserPrompt; + }; + /** Emitted whenever the frame emits `DOMContentLoaded` */ + DOMContentLoaded: void; + /** Emitted whenever the frame emits `load` */ + load: void; +}> { + static from( + userContext: UserContext, + parent: BrowsingContext | undefined, + id: string, + url: string + ): BrowsingContext { + const browsingContext = new BrowsingContext(userContext, parent, id, url); + browsingContext.#initialize(); + return browsingContext; + } + + // keep-sorted start + #navigation: Navigation | undefined; + #reason?: string; + #url: string; + readonly #children = new Map<string, BrowsingContext>(); + readonly #disposables = new DisposableStack(); + readonly #realms = new Map<string, WindowRealm>(); + readonly #requests = new Map<string, Request>(); + readonly defaultRealm: WindowRealm; + readonly id: string; + readonly parent: BrowsingContext | undefined; + readonly userContext: UserContext; + // keep-sorted end + + private constructor( + context: UserContext, + parent: BrowsingContext | undefined, + id: string, + url: string + ) { + super(); + // keep-sorted start + this.#url = url; + this.id = id; + this.parent = parent; + this.userContext = context; + // keep-sorted end + + this.defaultRealm = WindowRealm.from(this); + } + + #initialize() { + const userContextEmitter = this.#disposables.use( + new EventEmitter(this.userContext) + ); + userContextEmitter.once('closed', ({reason}) => { + this.dispose(`Browsing context already closed: ${reason}`); + }); + + const sessionEmitter = this.#disposables.use( + new EventEmitter(this.#session) + ); + sessionEmitter.on('browsingContext.contextCreated', info => { + if (info.parent !== this.id) { + return; + } + + const browsingContext = BrowsingContext.from( + this.userContext, + this, + info.context, + info.url + ); + this.#children.set(info.context, browsingContext); + + const browsingContextEmitter = this.#disposables.use( + new EventEmitter(browsingContext) + ); + browsingContextEmitter.once('closed', () => { + browsingContextEmitter.removeAllListeners(); + + this.#children.delete(browsingContext.id); + }); + + this.emit('browsingcontext', {browsingContext}); + }); + sessionEmitter.on('browsingContext.contextDestroyed', info => { + if (info.context !== this.id) { + return; + } + this.dispose('Browsing context already closed.'); + }); + + sessionEmitter.on('browsingContext.domContentLoaded', info => { + if (info.context !== this.id) { + return; + } + this.#url = info.url; + this.emit('DOMContentLoaded', undefined); + }); + + sessionEmitter.on('browsingContext.load', info => { + if (info.context !== this.id) { + return; + } + this.#url = info.url; + this.emit('load', undefined); + }); + + sessionEmitter.on('browsingContext.navigationStarted', info => { + if (info.context !== this.id) { + return; + } + this.#url = info.url; + + this.#requests.clear(); + + // Note the navigation ID is null for this event. + this.#navigation = Navigation.from(this); + + const navigationEmitter = this.#disposables.use( + new EventEmitter(this.#navigation) + ); + for (const eventName of ['fragment', 'failed', 'aborted'] as const) { + navigationEmitter.once(eventName, ({url}) => { + navigationEmitter[disposeSymbol](); + + this.#url = url; + }); + } + + this.emit('navigation', {navigation: this.#navigation}); + }); + sessionEmitter.on('network.beforeRequestSent', event => { + if (event.context !== this.id) { + return; + } + if (this.#requests.has(event.request.request)) { + return; + } + + const request = Request.from(this, event); + this.#requests.set(request.id, request); + this.emit('request', {request}); + }); + + sessionEmitter.on('log.entryAdded', entry => { + if (entry.source.context !== this.id) { + return; + } + + this.emit('log', {entry}); + }); + + sessionEmitter.on('browsingContext.userPromptOpened', info => { + if (info.context !== this.id) { + return; + } + + const userPrompt = UserPrompt.from(this, info); + this.emit('userprompt', {userPrompt}); + }); + } + + // keep-sorted start block=yes + get #session() { + return this.userContext.browser.session; + } + get children(): Iterable<BrowsingContext> { + return this.#children.values(); + } + get closed(): boolean { + return this.#reason !== undefined; + } + get disposed(): boolean { + return this.closed; + } + get realms(): Iterable<WindowRealm> { + return this.#realms.values(); + } + get top(): BrowsingContext { + let context = this as BrowsingContext; + for (let {parent} = context; parent; {parent} = context) { + context = parent; + } + return context; + } + get url(): string { + return this.#url; + } + // keep-sorted end + + @inertIfDisposed + private dispose(reason?: string): void { + this.#reason = reason; + this[disposeSymbol](); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async activate(): Promise<void> { + await this.#session.send('browsingContext.activate', { + context: this.id, + }); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async captureScreenshot( + options: CaptureScreenshotOptions = {} + ): Promise<string> { + const { + result: {data}, + } = await this.#session.send('browsingContext.captureScreenshot', { + context: this.id, + ...options, + }); + return data; + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async close(promptUnload?: boolean): Promise<void> { + await Promise.all( + [...this.#children.values()].map(async child => { + await child.close(promptUnload); + }) + ); + await this.#session.send('browsingContext.close', { + context: this.id, + promptUnload, + }); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async traverseHistory(delta: number): Promise<void> { + await this.#session.send('browsingContext.traverseHistory', { + context: this.id, + delta, + }); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async navigate( + url: string, + wait?: Bidi.BrowsingContext.ReadinessState + ): Promise<Navigation> { + await this.#session.send('browsingContext.navigate', { + context: this.id, + url, + wait, + }); + return await new Promise(resolve => { + this.once('navigation', ({navigation}) => { + resolve(navigation); + }); + }); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async reload(options: ReloadOptions = {}): Promise<Navigation> { + await this.#session.send('browsingContext.reload', { + context: this.id, + ...options, + }); + return await new Promise(resolve => { + this.once('navigation', ({navigation}) => { + resolve(navigation); + }); + }); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async print(options: PrintOptions = {}): Promise<string> { + const { + result: {data}, + } = await this.#session.send('browsingContext.print', { + context: this.id, + ...options, + }); + return data; + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async handleUserPrompt(options: HandleUserPromptOptions = {}): Promise<void> { + await this.#session.send('browsingContext.handleUserPrompt', { + context: this.id, + ...options, + }); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async setViewport(options: SetViewportOptions = {}): Promise<void> { + await this.#session.send('browsingContext.setViewport', { + context: this.id, + ...options, + }); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async performActions(actions: Bidi.Input.SourceActions[]): Promise<void> { + await this.#session.send('input.performActions', { + context: this.id, + actions, + }); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async releaseActions(): Promise<void> { + await this.#session.send('input.releaseActions', { + context: this.id, + }); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + createWindowRealm(sandbox: string): WindowRealm { + return WindowRealm.from(this, sandbox); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async addPreloadScript( + functionDeclaration: string, + options: AddPreloadScriptOptions = {} + ): Promise<string> { + return await this.userContext.browser.addPreloadScript( + functionDeclaration, + { + ...options, + contexts: [this, ...(options.contexts ?? [])], + } + ); + } + + @throwIfDisposed<BrowsingContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async removePreloadScript(script: string): Promise<void> { + await this.userContext.browser.removePreloadScript(script); + } + + [disposeSymbol](): void { + this.#reason ??= + 'Browsing context already closed, probably because the user context closed.'; + this.emit('closed', {reason: this.#reason}); + + this.#disposables.dispose(); + super[disposeSymbol](); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Connection.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Connection.ts new file mode 100644 index 0000000000..b9de14372b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Connection.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import type {EventEmitter} from '../../common/EventEmitter.js'; + +/** + * @internal + */ +export interface Commands { + 'script.evaluate': { + params: Bidi.Script.EvaluateParameters; + returnType: Bidi.Script.EvaluateResult; + }; + 'script.callFunction': { + params: Bidi.Script.CallFunctionParameters; + returnType: Bidi.Script.EvaluateResult; + }; + 'script.disown': { + params: Bidi.Script.DisownParameters; + returnType: Bidi.EmptyResult; + }; + 'script.addPreloadScript': { + params: Bidi.Script.AddPreloadScriptParameters; + returnType: Bidi.Script.AddPreloadScriptResult; + }; + 'script.removePreloadScript': { + params: Bidi.Script.RemovePreloadScriptParameters; + returnType: Bidi.EmptyResult; + }; + + 'browser.close': { + params: Bidi.EmptyParams; + returnType: Bidi.EmptyResult; + }; + + 'browsingContext.activate': { + params: Bidi.BrowsingContext.ActivateParameters; + returnType: Bidi.EmptyResult; + }; + 'browsingContext.create': { + params: Bidi.BrowsingContext.CreateParameters; + returnType: Bidi.BrowsingContext.CreateResult; + }; + 'browsingContext.close': { + params: Bidi.BrowsingContext.CloseParameters; + returnType: Bidi.EmptyResult; + }; + 'browsingContext.getTree': { + params: Bidi.BrowsingContext.GetTreeParameters; + returnType: Bidi.BrowsingContext.GetTreeResult; + }; + 'browsingContext.navigate': { + params: Bidi.BrowsingContext.NavigateParameters; + returnType: Bidi.BrowsingContext.NavigateResult; + }; + 'browsingContext.reload': { + params: Bidi.BrowsingContext.ReloadParameters; + returnType: Bidi.BrowsingContext.NavigateResult; + }; + 'browsingContext.print': { + params: Bidi.BrowsingContext.PrintParameters; + returnType: Bidi.BrowsingContext.PrintResult; + }; + 'browsingContext.captureScreenshot': { + params: Bidi.BrowsingContext.CaptureScreenshotParameters; + returnType: Bidi.BrowsingContext.CaptureScreenshotResult; + }; + 'browsingContext.handleUserPrompt': { + params: Bidi.BrowsingContext.HandleUserPromptParameters; + returnType: Bidi.EmptyResult; + }; + 'browsingContext.setViewport': { + params: Bidi.BrowsingContext.SetViewportParameters; + returnType: Bidi.EmptyResult; + }; + 'browsingContext.traverseHistory': { + params: Bidi.BrowsingContext.TraverseHistoryParameters; + returnType: Bidi.EmptyResult; + }; + + 'input.performActions': { + params: Bidi.Input.PerformActionsParameters; + returnType: Bidi.EmptyResult; + }; + 'input.releaseActions': { + params: Bidi.Input.ReleaseActionsParameters; + returnType: Bidi.EmptyResult; + }; + + 'session.end': { + params: Bidi.EmptyParams; + returnType: Bidi.EmptyResult; + }; + 'session.new': { + params: Bidi.Session.NewParameters; + returnType: Bidi.Session.NewResult; + }; + 'session.status': { + params: object; + returnType: Bidi.Session.StatusResult; + }; + 'session.subscribe': { + params: Bidi.Session.SubscriptionRequest; + returnType: Bidi.EmptyResult; + }; + 'session.unsubscribe': { + params: Bidi.Session.SubscriptionRequest; + returnType: Bidi.EmptyResult; + }; +} + +/** + * @internal + */ +export type BidiEvents = { + [K in Bidi.ChromiumBidi.Event['method']]: Extract< + Bidi.ChromiumBidi.Event, + {method: K} + >['params']; +}; + +/** + * @internal + */ +export interface Connection<Events extends BidiEvents = BidiEvents> + extends EventEmitter<Events> { + send<T extends keyof Commands>( + method: T, + params: Commands[T]['params'] + ): Promise<{result: Commands[T]['returnType']}>; + + // This will pipe events into the provided emitter. + pipeTo<Events extends BidiEvents>(emitter: EventEmitter<Events>): void; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Navigation.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Navigation.ts new file mode 100644 index 0000000000..a7efbfeb2c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Navigation.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {inertIfDisposed} from '../../util/decorators.js'; +import {Deferred} from '../../util/Deferred.js'; +import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; +import type {Request} from './Request.js'; + +/** + * @internal + */ +export interface NavigationInfo { + url: string; + timestamp: Date; +} + +/** + * @internal + */ +export class Navigation extends EventEmitter<{ + /** Emitted when navigation has a request associated with it. */ + request: Request; + /** Emitted when fragment navigation occurred. */ + fragment: NavigationInfo; + /** Emitted when navigation failed. */ + failed: NavigationInfo; + /** Emitted when navigation was aborted. */ + aborted: NavigationInfo; +}> { + static from(context: BrowsingContext): Navigation { + const navigation = new Navigation(context); + navigation.#initialize(); + return navigation; + } + + // keep-sorted start + #request: Request | undefined; + readonly #browsingContext: BrowsingContext; + readonly #disposables = new DisposableStack(); + readonly #id = new Deferred<string>(); + // keep-sorted end + + private constructor(context: BrowsingContext) { + super(); + // keep-sorted start + this.#browsingContext = context; + // keep-sorted end + } + + #initialize() { + const browsingContextEmitter = this.#disposables.use( + new EventEmitter(this.#browsingContext) + ); + browsingContextEmitter.once('closed', () => { + this.emit('failed', { + url: this.#browsingContext.url, + timestamp: new Date(), + }); + this.dispose(); + }); + + this.#browsingContext.on('request', ({request}) => { + if (request.navigation === this.#id.value()) { + this.#request = request; + this.emit('request', request); + } + }); + + const sessionEmitter = this.#disposables.use( + new EventEmitter(this.#session) + ); + // To get the navigation ID if any. + for (const eventName of [ + 'browsingContext.domContentLoaded', + 'browsingContext.load', + ] as const) { + sessionEmitter.on(eventName, info => { + if (info.context !== this.#browsingContext.id) { + return; + } + if (!info.navigation) { + return; + } + if (!this.#id.resolved()) { + this.#id.resolve(info.navigation); + } + }); + } + + for (const [eventName, event] of [ + ['browsingContext.fragmentNavigated', 'fragment'], + ['browsingContext.navigationFailed', 'failed'], + ['browsingContext.navigationAborted', 'aborted'], + ] as const) { + sessionEmitter.on(eventName, info => { + if (info.context !== this.#browsingContext.id) { + return; + } + if (!info.navigation) { + return; + } + if (!this.#id.resolved()) { + this.#id.resolve(info.navigation); + } + if (this.#id.value() !== info.navigation) { + return; + } + this.emit(event, { + url: info.url, + timestamp: new Date(info.timestamp), + }); + this.dispose(); + }); + } + } + + // keep-sorted start block=yes + get #session() { + return this.#browsingContext.userContext.browser.session; + } + get disposed(): boolean { + return this.#disposables.disposed; + } + get request(): Request | undefined { + return this.#request; + } + // keep-sorted end + + @inertIfDisposed + private dispose(): void { + this[disposeSymbol](); + } + + [disposeSymbol](): void { + this.#disposables.dispose(); + super[disposeSymbol](); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Realm.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Realm.ts new file mode 100644 index 0000000000..d9bbbede50 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Realm.ts @@ -0,0 +1,351 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js'; +import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; +import type {Session} from './Session.js'; + +/** + * @internal + */ +export type CallFunctionOptions = Omit< + Bidi.Script.CallFunctionParameters, + 'functionDeclaration' | 'awaitPromise' | 'target' +>; + +/** + * @internal + */ +export type EvaluateOptions = Omit< + Bidi.Script.EvaluateParameters, + 'expression' | 'awaitPromise' | 'target' +>; + +/** + * @internal + */ +export abstract class Realm extends EventEmitter<{ + /** Emitted when the realm is destroyed. */ + destroyed: {reason: string}; + /** Emitted when a dedicated worker is created in the realm. */ + worker: DedicatedWorkerRealm; + /** Emitted when a shared worker is created in the realm. */ + sharedworker: SharedWorkerRealm; +}> { + // keep-sorted start + #reason?: string; + protected readonly disposables = new DisposableStack(); + readonly id: string; + readonly origin: string; + // keep-sorted end + + protected constructor(id: string, origin: string) { + super(); + // keep-sorted start + this.id = id; + this.origin = origin; + // keep-sorted end + } + + protected initialize(): void { + const sessionEmitter = this.disposables.use(new EventEmitter(this.session)); + sessionEmitter.on('script.realmDestroyed', info => { + if (info.realm !== this.id) { + return; + } + this.dispose('Realm already destroyed.'); + }); + } + + // keep-sorted start block=yes + get disposed(): boolean { + return this.#reason !== undefined; + } + protected abstract get session(): Session; + protected get target(): Bidi.Script.Target { + return {realm: this.id}; + } + // keep-sorted end + + @inertIfDisposed + protected dispose(reason?: string): void { + this.#reason = reason; + this[disposeSymbol](); + } + + @throwIfDisposed<Realm>(realm => { + // SAFETY: Disposal implies this exists. + return realm.#reason!; + }) + async disown(handles: string[]): Promise<void> { + await this.session.send('script.disown', { + target: this.target, + handles, + }); + } + + @throwIfDisposed<Realm>(realm => { + // SAFETY: Disposal implies this exists. + return realm.#reason!; + }) + async callFunction( + functionDeclaration: string, + awaitPromise: boolean, + options: CallFunctionOptions = {} + ): Promise<Bidi.Script.EvaluateResult> { + const {result} = await this.session.send('script.callFunction', { + functionDeclaration, + awaitPromise, + target: this.target, + ...options, + }); + return result; + } + + @throwIfDisposed<Realm>(realm => { + // SAFETY: Disposal implies this exists. + return realm.#reason!; + }) + async evaluate( + expression: string, + awaitPromise: boolean, + options: EvaluateOptions = {} + ): Promise<Bidi.Script.EvaluateResult> { + const {result} = await this.session.send('script.evaluate', { + expression, + awaitPromise, + target: this.target, + ...options, + }); + return result; + } + + [disposeSymbol](): void { + this.#reason ??= + 'Realm already destroyed, probably because all associated browsing contexts closed.'; + this.emit('destroyed', {reason: this.#reason}); + + this.disposables.dispose(); + super[disposeSymbol](); + } +} + +/** + * @internal + */ +export class WindowRealm extends Realm { + static from(context: BrowsingContext, sandbox?: string): WindowRealm { + const realm = new WindowRealm(context, sandbox); + realm.initialize(); + return realm; + } + + // keep-sorted start + readonly browsingContext: BrowsingContext; + readonly sandbox?: string; + // keep-sorted end + + readonly #workers: { + dedicated: Map<string, DedicatedWorkerRealm>; + shared: Map<string, SharedWorkerRealm>; + } = { + dedicated: new Map(), + shared: new Map(), + }; + + private constructor(context: BrowsingContext, sandbox?: string) { + super('', ''); + // keep-sorted start + this.browsingContext = context; + this.sandbox = sandbox; + // keep-sorted end + } + + override initialize(): void { + super.initialize(); + + const sessionEmitter = this.disposables.use(new EventEmitter(this.session)); + sessionEmitter.on('script.realmCreated', info => { + if (info.type !== 'window') { + return; + } + (this as any).id = info.realm; + (this as any).origin = info.origin; + }); + sessionEmitter.on('script.realmCreated', info => { + if (info.type !== 'dedicated-worker') { + return; + } + if (!info.owners.includes(this.id)) { + return; + } + + const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin); + this.#workers.dedicated.set(realm.id, realm); + + const realmEmitter = this.disposables.use(new EventEmitter(realm)); + realmEmitter.once('destroyed', () => { + realmEmitter.removeAllListeners(); + this.#workers.dedicated.delete(realm.id); + }); + + this.emit('worker', realm); + }); + + this.browsingContext.userContext.browser.on('sharedworker', ({realm}) => { + if (!realm.owners.has(this)) { + return; + } + + this.#workers.shared.set(realm.id, realm); + + const realmEmitter = this.disposables.use(new EventEmitter(realm)); + realmEmitter.once('destroyed', () => { + realmEmitter.removeAllListeners(); + this.#workers.shared.delete(realm.id); + }); + + this.emit('sharedworker', realm); + }); + } + + override get session(): Session { + return this.browsingContext.userContext.browser.session; + } + + override get target(): Bidi.Script.Target { + return {context: this.browsingContext.id, sandbox: this.sandbox}; + } +} + +/** + * @internal + */ +export type DedicatedWorkerOwnerRealm = + | DedicatedWorkerRealm + | SharedWorkerRealm + | WindowRealm; + +/** + * @internal + */ +export class DedicatedWorkerRealm extends Realm { + static from( + owner: DedicatedWorkerOwnerRealm, + id: string, + origin: string + ): DedicatedWorkerRealm { + const realm = new DedicatedWorkerRealm(owner, id, origin); + realm.initialize(); + return realm; + } + + // keep-sorted start + readonly #workers = new Map<string, DedicatedWorkerRealm>(); + readonly owners: Set<DedicatedWorkerOwnerRealm>; + // keep-sorted end + + private constructor( + owner: DedicatedWorkerOwnerRealm, + id: string, + origin: string + ) { + super(id, origin); + this.owners = new Set([owner]); + } + + override initialize(): void { + super.initialize(); + + const sessionEmitter = this.disposables.use(new EventEmitter(this.session)); + sessionEmitter.on('script.realmCreated', info => { + if (info.type !== 'dedicated-worker') { + return; + } + if (!info.owners.includes(this.id)) { + return; + } + + const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin); + this.#workers.set(realm.id, realm); + + const realmEmitter = this.disposables.use(new EventEmitter(realm)); + realmEmitter.once('destroyed', () => { + this.#workers.delete(realm.id); + }); + + this.emit('worker', realm); + }); + } + + override get session(): Session { + // SAFETY: At least one owner will exist. + return this.owners.values().next().value.session; + } +} + +/** + * @internal + */ +export class SharedWorkerRealm extends Realm { + static from( + owners: [WindowRealm, ...WindowRealm[]], + id: string, + origin: string + ): SharedWorkerRealm { + const realm = new SharedWorkerRealm(owners, id, origin); + realm.initialize(); + return realm; + } + + // keep-sorted start + readonly #workers = new Map<string, DedicatedWorkerRealm>(); + readonly owners: Set<WindowRealm>; + // keep-sorted end + + private constructor( + owners: [WindowRealm, ...WindowRealm[]], + id: string, + origin: string + ) { + super(id, origin); + this.owners = new Set(owners); + } + + override initialize(): void { + super.initialize(); + + const sessionEmitter = this.disposables.use(new EventEmitter(this.session)); + sessionEmitter.on('script.realmCreated', info => { + if (info.type !== 'dedicated-worker') { + return; + } + if (!info.owners.includes(this.id)) { + return; + } + + const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin); + this.#workers.set(realm.id, realm); + + const realmEmitter = this.disposables.use(new EventEmitter(realm)); + realmEmitter.once('destroyed', () => { + this.#workers.delete(realm.id); + }); + + this.emit('worker', realm); + }); + } + + override get session(): Session { + // SAFETY: At least one owner will exist. + return this.owners.values().next().value.session; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Request.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Request.ts new file mode 100644 index 0000000000..2a445f7d87 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Request.ts @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {inertIfDisposed} from '../../util/decorators.js'; +import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; + +/** + * @internal + */ +export class Request extends EventEmitter<{ + /** Emitted when the request is redirected. */ + redirect: Request; + /** Emitted when the request succeeds. */ + success: Bidi.Network.ResponseData; + /** Emitted when the request fails. */ + error: string; +}> { + static from( + browsingContext: BrowsingContext, + event: Bidi.Network.BeforeRequestSentParameters + ): Request { + const request = new Request(browsingContext, event); + request.#initialize(); + return request; + } + + // keep-sorted start + #error?: string; + #redirect?: Request; + #response?: Bidi.Network.ResponseData; + readonly #browsingContext: BrowsingContext; + readonly #disposables = new DisposableStack(); + readonly #event: Bidi.Network.BeforeRequestSentParameters; + // keep-sorted end + + private constructor( + browsingContext: BrowsingContext, + event: Bidi.Network.BeforeRequestSentParameters + ) { + super(); + // keep-sorted start + this.#browsingContext = browsingContext; + this.#event = event; + // keep-sorted end + } + + #initialize() { + const browsingContextEmitter = this.#disposables.use( + new EventEmitter(this.#browsingContext) + ); + browsingContextEmitter.once('closed', ({reason}) => { + this.#error = reason; + this.emit('error', this.#error); + this.dispose(); + }); + + const sessionEmitter = this.#disposables.use( + new EventEmitter(this.#session) + ); + sessionEmitter.on('network.beforeRequestSent', event => { + if (event.context !== this.#browsingContext.id) { + return; + } + if (event.request.request !== this.id) { + return; + } + this.#redirect = Request.from(this.#browsingContext, event); + this.emit('redirect', this.#redirect); + this.dispose(); + }); + sessionEmitter.on('network.fetchError', event => { + if (event.context !== this.#browsingContext.id) { + return; + } + if (event.request.request !== this.id) { + return; + } + this.#error = event.errorText; + this.emit('error', this.#error); + this.dispose(); + }); + sessionEmitter.on('network.responseCompleted', event => { + if (event.context !== this.#browsingContext.id) { + return; + } + if (event.request.request !== this.id) { + return; + } + this.#response = event.response; + this.emit('success', this.#response); + this.dispose(); + }); + } + + // keep-sorted start block=yes + get #session() { + return this.#browsingContext.userContext.browser.session; + } + get disposed(): boolean { + return this.#disposables.disposed; + } + get error(): string | undefined { + return this.#error; + } + get headers(): Bidi.Network.Header[] { + return this.#event.request.headers; + } + get id(): string { + return this.#event.request.request; + } + get initiator(): Bidi.Network.Initiator { + return this.#event.initiator; + } + get method(): string { + return this.#event.request.method; + } + get navigation(): string | undefined { + return this.#event.navigation ?? undefined; + } + get redirect(): Request | undefined { + return this.redirect; + } + get response(): Bidi.Network.ResponseData | undefined { + return this.#response; + } + get url(): string { + return this.#event.request.url; + } + // keep-sorted end + + @inertIfDisposed + private dispose(): void { + this[disposeSymbol](); + } + + [disposeSymbol](): void { + this.#disposables.dispose(); + super[disposeSymbol](); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Session.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Session.ts new file mode 100644 index 0000000000..b6e28061f1 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Session.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {debugError} from '../../common/util.js'; +import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js'; +import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; + +import {Browser} from './Browser.js'; +import type {BidiEvents, Commands, Connection} from './Connection.js'; + +// TODO: Once Chrome supports session.status properly, uncomment this block. +// const MAX_RETRIES = 5; + +/** + * @internal + */ +export class Session + extends EventEmitter<BidiEvents & {ended: {reason: string}}> + implements Connection<BidiEvents & {ended: {reason: string}}> +{ + static async from( + connection: Connection, + capabilities: Bidi.Session.CapabilitiesRequest + ): Promise<Session> { + // Wait until the session is ready. + // + // TODO: Once Chrome supports session.status properly, uncomment this block + // and remove `getBiDiConnection` in BrowserConnector. + + // let status = {message: '', ready: false}; + // for (let i = 0; i < MAX_RETRIES; ++i) { + // status = (await connection.send('session.status', {})).result; + // if (status.ready) { + // break; + // } + // // Backoff a little bit each time. + // await new Promise(resolve => { + // return setTimeout(resolve, (1 << i) * 100); + // }); + // } + // if (!status.ready) { + // throw new Error(status.message); + // } + + let result; + try { + result = ( + await connection.send('session.new', { + capabilities, + }) + ).result; + } catch (err) { + // Chrome does not support session.new. + debugError(err); + result = { + sessionId: '', + capabilities: { + acceptInsecureCerts: false, + browserName: '', + browserVersion: '', + platformName: '', + setWindowRect: false, + webSocketUrl: '', + }, + }; + } + + const session = new Session(connection, result); + await session.#initialize(); + return session; + } + + // keep-sorted start + #reason: string | undefined; + readonly #disposables = new DisposableStack(); + readonly #info: Bidi.Session.NewResult; + readonly browser!: Browser; + readonly connection: Connection; + // keep-sorted end + + private constructor(connection: Connection, info: Bidi.Session.NewResult) { + super(); + // keep-sorted start + this.#info = info; + this.connection = connection; + // keep-sorted end + } + + async #initialize(): Promise<void> { + this.connection.pipeTo(this); + + // SAFETY: We use `any` to allow assignment of the readonly property. + (this as any).browser = await Browser.from(this); + + const browserEmitter = this.#disposables.use(this.browser); + browserEmitter.once('closed', ({reason}) => { + this.dispose(reason); + }); + } + + // keep-sorted start block=yes + get capabilities(): Bidi.Session.NewResult['capabilities'] { + return this.#info.capabilities; + } + get disposed(): boolean { + return this.ended; + } + get ended(): boolean { + return this.#reason !== undefined; + } + get id(): string { + return this.#info.sessionId; + } + // keep-sorted end + + @inertIfDisposed + private dispose(reason?: string): void { + this.#reason = reason; + this[disposeSymbol](); + } + + pipeTo<Events extends BidiEvents>(emitter: EventEmitter<Events>): void { + this.connection.pipeTo(emitter); + } + + /** + * Currently, there is a 1:1 relationship between the session and the + * session. In the future, we might support multiple sessions and in that + * case we always needs to make sure that the session for the right session + * object is used, so we implement this method here, although it's not defined + * in the spec. + */ + @throwIfDisposed<Session>(session => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return session.#reason!; + }) + async send<T extends keyof Commands>( + method: T, + params: Commands[T]['params'] + ): Promise<{result: Commands[T]['returnType']}> { + return await this.connection.send(method, params); + } + + @throwIfDisposed<Session>(session => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return session.#reason!; + }) + async subscribe(events: string[]): Promise<void> { + await this.send('session.subscribe', { + events, + }); + } + + @throwIfDisposed<Session>(session => { + // SAFETY: By definition of `disposed`, `#reason` is defined. + return session.#reason!; + }) + async end(): Promise<void> { + try { + await this.send('session.end', {}); + } finally { + this.dispose(`Session already ended.`); + } + } + + [disposeSymbol](): void { + this.#reason ??= + 'Session already destroyed, probably because the connection broke.'; + this.emit('ended', {reason: this.#reason}); + + this.#disposables.dispose(); + super[disposeSymbol](); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserContext.ts new file mode 100644 index 0000000000..01ee5c7649 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserContext.ts @@ -0,0 +1,178 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {assert} from '../../util/assert.js'; +import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js'; +import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; + +import type {Browser} from './Browser.js'; +import {BrowsingContext} from './BrowsingContext.js'; + +/** + * @internal + */ +export type CreateBrowsingContextOptions = Omit< + Bidi.BrowsingContext.CreateParameters, + 'type' | 'referenceContext' +> & { + referenceContext?: BrowsingContext; +}; + +/** + * @internal + */ +export class UserContext extends EventEmitter<{ + /** + * Emitted when a new browsing context is created. + */ + browsingcontext: { + /** The new browsing context. */ + browsingContext: BrowsingContext; + }; + /** + * Emitted when the user context is closed. + */ + closed: { + /** The reason the user context was closed. */ + reason: string; + }; +}> { + static DEFAULT = 'default'; + + static create(browser: Browser, id: string): UserContext { + const context = new UserContext(browser, id); + context.#initialize(); + return context; + } + + // keep-sorted start + #reason?: string; + // Note these are only top-level contexts. + readonly #browsingContexts = new Map<string, BrowsingContext>(); + readonly #disposables = new DisposableStack(); + readonly #id: string; + readonly browser: Browser; + // keep-sorted end + + private constructor(browser: Browser, id: string) { + super(); + // keep-sorted start + this.#id = id; + this.browser = browser; + // keep-sorted end + } + + #initialize() { + const browserEmitter = this.#disposables.use( + new EventEmitter(this.browser) + ); + browserEmitter.once('closed', ({reason}) => { + this.dispose(`User context already closed: ${reason}`); + }); + + const sessionEmitter = this.#disposables.use( + new EventEmitter(this.#session) + ); + sessionEmitter.on('browsingContext.contextCreated', info => { + if (info.parent) { + return; + } + + const browsingContext = BrowsingContext.from( + this, + undefined, + info.context, + info.url + ); + this.#browsingContexts.set(browsingContext.id, browsingContext); + + const browsingContextEmitter = this.#disposables.use( + new EventEmitter(browsingContext) + ); + browsingContextEmitter.on('closed', () => { + browsingContextEmitter.removeAllListeners(); + + this.#browsingContexts.delete(browsingContext.id); + }); + + this.emit('browsingcontext', {browsingContext}); + }); + } + + // keep-sorted start block=yes + get #session() { + return this.browser.session; + } + get browsingContexts(): Iterable<BrowsingContext> { + return this.#browsingContexts.values(); + } + get closed(): boolean { + return this.#reason !== undefined; + } + get disposed(): boolean { + return this.closed; + } + get id(): string { + return this.#id; + } + // keep-sorted end + + @inertIfDisposed + private dispose(reason?: string): void { + this.#reason = reason; + this[disposeSymbol](); + } + + @throwIfDisposed<UserContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async createBrowsingContext( + type: Bidi.BrowsingContext.CreateType, + options: CreateBrowsingContextOptions = {} + ): Promise<BrowsingContext> { + const { + result: {context: contextId}, + } = await this.#session.send('browsingContext.create', { + type, + ...options, + referenceContext: options.referenceContext?.id, + }); + + const browsingContext = this.#browsingContexts.get(contextId); + assert( + browsingContext, + 'The WebDriver BiDi implementation is failing to create a browsing context correctly.' + ); + + // We use an array to avoid the promise from being awaited. + return browsingContext; + } + + @throwIfDisposed<UserContext>(context => { + // SAFETY: Disposal implies this exists. + return context.#reason!; + }) + async remove(): Promise<void> { + try { + // TODO: Call `removeUserContext` once available. + } finally { + this.dispose('User context already closed.'); + } + } + + [disposeSymbol](): void { + this.#reason ??= + 'User context already closed, probably because the browser disconnected/closed.'; + this.emit('closed', {reason: this.#reason}); + + this.#disposables.dispose(); + super[disposeSymbol](); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserPrompt.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserPrompt.ts new file mode 100644 index 0000000000..073233bed0 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserPrompt.ts @@ -0,0 +1,137 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {EventEmitter} from '../../common/EventEmitter.js'; +import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js'; +import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; + +import type {BrowsingContext} from './BrowsingContext.js'; + +/** + * @internal + */ +export type HandleOptions = Omit< + Bidi.BrowsingContext.HandleUserPromptParameters, + 'context' +>; + +/** + * @internal + */ +export type UserPromptResult = Omit< + Bidi.BrowsingContext.UserPromptClosedParameters, + 'context' +>; + +/** + * @internal + */ +export class UserPrompt extends EventEmitter<{ + /** Emitted when the user prompt is handled. */ + handled: UserPromptResult; + /** Emitted when the user prompt is closed. */ + closed: { + /** The reason the user prompt was closed. */ + reason: string; + }; +}> { + static from( + browsingContext: BrowsingContext, + info: Bidi.BrowsingContext.UserPromptOpenedParameters + ): UserPrompt { + const userPrompt = new UserPrompt(browsingContext, info); + userPrompt.#initialize(); + return userPrompt; + } + + // keep-sorted start + #reason?: string; + #result?: UserPromptResult; + readonly #disposables = new DisposableStack(); + readonly browsingContext: BrowsingContext; + readonly info: Bidi.BrowsingContext.UserPromptOpenedParameters; + // keep-sorted end + + private constructor( + context: BrowsingContext, + info: Bidi.BrowsingContext.UserPromptOpenedParameters + ) { + super(); + // keep-sorted start + this.browsingContext = context; + this.info = info; + // keep-sorted end + } + + #initialize() { + const browserContextEmitter = this.#disposables.use( + new EventEmitter(this.browsingContext) + ); + browserContextEmitter.once('closed', ({reason}) => { + this.dispose(`User prompt already closed: ${reason}`); + }); + + const sessionEmitter = this.#disposables.use( + new EventEmitter(this.#session) + ); + sessionEmitter.on('browsingContext.userPromptClosed', parameters => { + if (parameters.context !== this.browsingContext.id) { + return; + } + this.#result = parameters; + this.emit('handled', parameters); + this.dispose('User prompt already handled.'); + }); + } + + // keep-sorted start block=yes + get #session() { + return this.browsingContext.userContext.browser.session; + } + get closed(): boolean { + return this.#reason !== undefined; + } + get disposed(): boolean { + return this.closed; + } + get handled(): boolean { + return this.#result !== undefined; + } + get result(): UserPromptResult | undefined { + return this.#result; + } + // keep-sorted end + + @inertIfDisposed + private dispose(reason?: string): void { + this.#reason = reason; + this[disposeSymbol](); + } + + @throwIfDisposed<UserPrompt>(prompt => { + // SAFETY: Disposal implies this exists. + return prompt.#reason!; + }) + async handle(options: HandleOptions = {}): Promise<UserPromptResult> { + await this.#session.send('browsingContext.handleUserPrompt', { + ...options, + context: this.info.context, + }); + // SAFETY: `handled` is triggered before the above promise resolved. + return this.#result!; + } + + [disposeSymbol](): void { + this.#reason ??= + 'User prompt already closed, probably because the associated browsing context was destroyed.'; + this.emit('closed', {reason: this.#reason}); + + this.#disposables.dispose(); + super[disposeSymbol](); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/core.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/core.ts new file mode 100644 index 0000000000..203281614b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/core.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './Browser.js'; +export * from './BrowsingContext.js'; +export * from './Connection.js'; +export * from './Navigation.js'; +export * from './Realm.js'; +export * from './Request.js'; +export * from './Session.js'; +export * from './UserContext.js'; +export * from './UserPrompt.js'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/lifecycle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/lifecycle.ts new file mode 100644 index 0000000000..73b86cba9c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/lifecycle.ts @@ -0,0 +1,119 @@ +/** + * @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 new file mode 100644 index 0000000000..41e88e26c2 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/util.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {PuppeteerURL, debugError} 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 + */ +export function createEvaluationError( + details: Bidi.Script.ExceptionDetails +): unknown { + if (details.exception.type !== 'error') { + return BidiDeserializer.deserialize(details.exception); + } + const [name = '', ...parts] = details.text.split(': '); + const message = parts.join(': '); + const error = new Error(message); + error.name = name; + + // The first line is this function which we ignore. + const stackLines = []; + if (details.stackTrace && stackLines.length < Error.stackTraceLimit) { + for (const frame of details.stackTrace.callFrames.reverse()) { + if ( + PuppeteerURL.isPuppeteerURL(frame.url) && + frame.url !== PuppeteerURL.INTERNAL_URL + ) { + const url = PuppeteerURL.parse(frame.url); + stackLines.unshift( + ` at ${frame.functionName || url.functionName} (${ + url.functionName + } at ${url.siteString}, <anonymous>:${frame.lineNumber}:${ + frame.columnNumber + })` + ); + } else { + stackLines.push( + ` at ${frame.functionName || '<anonymous>'} (${frame.url}:${ + frame.lineNumber + }:${frame.columnNumber})` + ); + } + if (stackLines.length >= Error.stackTraceLimit) { + break; + } + } + } + + error.stack = [details.text, ...stackLines].join('\n'); + return error; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Accessibility.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Accessibility.ts new file mode 100644 index 0000000000..d0279e3dda --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Accessibility.ts @@ -0,0 +1,579 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import type {ElementHandle} from '../api/ElementHandle.js'; + +/** + * Represents a Node and the properties of it that are relevant to Accessibility. + * @public + */ +export interface SerializedAXNode { + /** + * The {@link https://www.w3.org/TR/wai-aria/#usage_intro | role} of the node. + */ + role: string; + /** + * A human readable name for the node. + */ + name?: string; + /** + * The current value of the node. + */ + value?: string | number; + /** + * An additional human readable description of the node. + */ + description?: string; + /** + * Any keyboard shortcuts associated with this node. + */ + keyshortcuts?: string; + /** + * A human readable alternative to the role. + */ + roledescription?: string; + /** + * A description of the current value. + */ + valuetext?: string; + disabled?: boolean; + expanded?: boolean; + focused?: boolean; + modal?: boolean; + multiline?: boolean; + /** + * Whether more than one child can be selected. + */ + multiselectable?: boolean; + readonly?: boolean; + required?: boolean; + selected?: boolean; + /** + * Whether the checkbox is checked, or in a + * {@link https://www.w3.org/TR/wai-aria-practices/examples/checkbox/checkbox-2/checkbox-2.html | mixed state}. + */ + checked?: boolean | 'mixed'; + /** + * Whether the node is checked or in a mixed state. + */ + pressed?: boolean | 'mixed'; + /** + * The level of a heading. + */ + level?: number; + valuemin?: number; + valuemax?: number; + autocomplete?: string; + haspopup?: string; + /** + * Whether and in what way this node's value is invalid. + */ + invalid?: string; + orientation?: string; + /** + * Children of this node, if there are any. + */ + children?: SerializedAXNode[]; +} + +/** + * @public + */ +export interface SnapshotOptions { + /** + * Prune uninteresting nodes from the tree. + * @defaultValue `true` + */ + interestingOnly?: boolean; + /** + * Root node to get the accessibility tree for + * @defaultValue The root node of the entire page. + */ + root?: ElementHandle<Node>; +} + +/** + * The Accessibility class provides methods for inspecting the browser's + * accessibility tree. The accessibility tree is used by assistive technology + * such as {@link https://en.wikipedia.org/wiki/Screen_reader | screen readers} or + * {@link https://en.wikipedia.org/wiki/Switch_access | switches}. + * + * @remarks + * + * Accessibility is a very platform-specific thing. On different platforms, + * there are different screen readers that might have wildly different output. + * + * Blink - Chrome's rendering engine - has a concept of "accessibility tree", + * which is then translated into different platform-specific APIs. Accessibility + * namespace gives users access to the Blink Accessibility Tree. + * + * Most of the accessibility tree gets filtered out when converting from Blink + * AX Tree to Platform-specific AX-Tree or by assistive technologies themselves. + * By default, Puppeteer tries to approximate this filtering, exposing only + * the "interesting" nodes of the tree. + * + * @public + */ +export class Accessibility { + #client: CDPSession; + + /** + * @internal + */ + constructor(client: CDPSession) { + this.#client = client; + } + + /** + * @internal + */ + updateClient(client: CDPSession): void { + this.#client = client; + } + + /** + * Captures the current state of the accessibility tree. + * The returned object represents the root accessible node of the page. + * + * @remarks + * + * **NOTE** The Chrome accessibility tree contains nodes that go unused on + * most platforms and by most screen readers. Puppeteer will discard them as + * well for an easier to process tree, unless `interestingOnly` is set to + * `false`. + * + * @example + * An example of dumping the entire accessibility tree: + * + * ```ts + * const snapshot = await page.accessibility.snapshot(); + * console.log(snapshot); + * ``` + * + * @example + * An example of logging the focused node's name: + * + * ```ts + * const snapshot = await page.accessibility.snapshot(); + * const node = findFocusedNode(snapshot); + * console.log(node && node.name); + * + * function findFocusedNode(node) { + * if (node.focused) return node; + * for (const child of node.children || []) { + * const foundNode = findFocusedNode(child); + * return foundNode; + * } + * return null; + * } + * ``` + * + * @returns An AXNode object representing the snapshot. + */ + public async snapshot( + options: SnapshotOptions = {} + ): Promise<SerializedAXNode | null> { + const {interestingOnly = true, root = null} = options; + const {nodes} = await this.#client.send('Accessibility.getFullAXTree'); + let backendNodeId: number | undefined; + if (root) { + const {node} = await this.#client.send('DOM.describeNode', { + objectId: root.id, + }); + backendNodeId = node.backendNodeId; + } + const defaultRoot = AXNode.createTree(nodes); + let needle: AXNode | null = defaultRoot; + if (backendNodeId) { + needle = defaultRoot.find(node => { + return node.payload.backendDOMNodeId === backendNodeId; + }); + if (!needle) { + return null; + } + } + if (!interestingOnly) { + return this.serializeTree(needle)[0] ?? null; + } + + const interestingNodes = new Set<AXNode>(); + this.collectInterestingNodes(interestingNodes, defaultRoot, false); + if (!interestingNodes.has(needle)) { + return null; + } + return this.serializeTree(needle, interestingNodes)[0] ?? null; + } + + private serializeTree( + node: AXNode, + interestingNodes?: Set<AXNode> + ): SerializedAXNode[] { + const children: SerializedAXNode[] = []; + for (const child of node.children) { + children.push(...this.serializeTree(child, interestingNodes)); + } + + if (interestingNodes && !interestingNodes.has(node)) { + return children; + } + + const serializedNode = node.serialize(); + if (children.length) { + serializedNode.children = children; + } + return [serializedNode]; + } + + private collectInterestingNodes( + collection: Set<AXNode>, + node: AXNode, + insideControl: boolean + ): void { + if (node.isInteresting(insideControl)) { + collection.add(node); + } + if (node.isLeafNode()) { + return; + } + insideControl = insideControl || node.isControl(); + for (const child of node.children) { + this.collectInterestingNodes(collection, child, insideControl); + } + } +} + +class AXNode { + public payload: Protocol.Accessibility.AXNode; + public children: AXNode[] = []; + + #richlyEditable = false; + #editable = false; + #focusable = false; + #hidden = false; + #name: string; + #role: string; + #ignored: boolean; + #cachedHasFocusableChild?: boolean; + + constructor(payload: Protocol.Accessibility.AXNode) { + this.payload = payload; + this.#name = this.payload.name ? this.payload.name.value : ''; + this.#role = this.payload.role ? this.payload.role.value : 'Unknown'; + this.#ignored = this.payload.ignored; + + for (const property of this.payload.properties || []) { + if (property.name === 'editable') { + this.#richlyEditable = property.value.value === 'richtext'; + this.#editable = true; + } + if (property.name === 'focusable') { + this.#focusable = property.value.value; + } + if (property.name === 'hidden') { + this.#hidden = property.value.value; + } + } + } + + #isPlainTextField(): boolean { + if (this.#richlyEditable) { + return false; + } + if (this.#editable) { + return true; + } + return this.#role === 'textbox' || this.#role === 'searchbox'; + } + + #isTextOnlyObject(): boolean { + const role = this.#role; + return ( + role === 'LineBreak' || + role === 'text' || + role === 'InlineTextBox' || + role === 'StaticText' + ); + } + + #hasFocusableChild(): boolean { + if (this.#cachedHasFocusableChild === undefined) { + this.#cachedHasFocusableChild = false; + for (const child of this.children) { + if (child.#focusable || child.#hasFocusableChild()) { + this.#cachedHasFocusableChild = true; + break; + } + } + } + return this.#cachedHasFocusableChild; + } + + public find(predicate: (x: AXNode) => boolean): AXNode | null { + if (predicate(this)) { + return this; + } + for (const child of this.children) { + const result = child.find(predicate); + if (result) { + return result; + } + } + return null; + } + + public isLeafNode(): boolean { + if (!this.children.length) { + return true; + } + + // These types of objects may have children that we use as internal + // implementation details, but we want to expose them as leaves to platform + // accessibility APIs because screen readers might be confused if they find + // any children. + if (this.#isPlainTextField() || this.#isTextOnlyObject()) { + return true; + } + + // Roles whose children are only presentational according to the ARIA and + // HTML5 Specs should be hidden from screen readers. + // (Note that whilst ARIA buttons can have only presentational children, HTML5 + // buttons are allowed to have content.) + switch (this.#role) { + case 'doc-cover': + case 'graphics-symbol': + case 'img': + case 'image': + case 'Meter': + case 'scrollbar': + case 'slider': + case 'separator': + case 'progressbar': + return true; + default: + break; + } + + // Here and below: Android heuristics + if (this.#hasFocusableChild()) { + return false; + } + if (this.#focusable && this.#name) { + return true; + } + if (this.#role === 'heading' && this.#name) { + return true; + } + return false; + } + + public isControl(): boolean { + switch (this.#role) { + case 'button': + case 'checkbox': + case 'ColorWell': + case 'combobox': + case 'DisclosureTriangle': + case 'listbox': + case 'menu': + case 'menubar': + case 'menuitem': + case 'menuitemcheckbox': + case 'menuitemradio': + case 'radio': + case 'scrollbar': + case 'searchbox': + case 'slider': + case 'spinbutton': + case 'switch': + case 'tab': + case 'textbox': + case 'tree': + case 'treeitem': + return true; + default: + return false; + } + } + + public isInteresting(insideControl: boolean): boolean { + const role = this.#role; + if (role === 'Ignored' || this.#hidden || this.#ignored) { + return false; + } + + if (this.#focusable || this.#richlyEditable) { + return true; + } + + // If it's not focusable but has a control role, then it's interesting. + if (this.isControl()) { + return true; + } + + // A non focusable child of a control is not interesting + if (insideControl) { + return false; + } + + return this.isLeafNode() && !!this.#name; + } + + public serialize(): SerializedAXNode { + const properties = new Map<string, number | string | boolean>(); + for (const property of this.payload.properties || []) { + properties.set(property.name.toLowerCase(), property.value.value); + } + if (this.payload.name) { + properties.set('name', this.payload.name.value); + } + if (this.payload.value) { + properties.set('value', this.payload.value.value); + } + if (this.payload.description) { + properties.set('description', this.payload.description.value); + } + + const node: SerializedAXNode = { + role: this.#role, + }; + + type UserStringProperty = + | 'name' + | 'value' + | 'description' + | 'keyshortcuts' + | 'roledescription' + | 'valuetext'; + + const userStringProperties: UserStringProperty[] = [ + 'name', + 'value', + 'description', + 'keyshortcuts', + 'roledescription', + 'valuetext', + ]; + const getUserStringPropertyValue = (key: UserStringProperty): string => { + return properties.get(key) as string; + }; + + for (const userStringProperty of userStringProperties) { + if (!properties.has(userStringProperty)) { + continue; + } + + node[userStringProperty] = getUserStringPropertyValue(userStringProperty); + } + + type BooleanProperty = + | 'disabled' + | 'expanded' + | 'focused' + | 'modal' + | 'multiline' + | 'multiselectable' + | 'readonly' + | 'required' + | 'selected'; + const booleanProperties: BooleanProperty[] = [ + 'disabled', + 'expanded', + 'focused', + 'modal', + 'multiline', + 'multiselectable', + 'readonly', + 'required', + 'selected', + ]; + const getBooleanPropertyValue = (key: BooleanProperty): boolean => { + return properties.get(key) as boolean; + }; + + for (const booleanProperty of booleanProperties) { + // RootWebArea's treat focus differently than other nodes. They report whether + // their frame has focus, not whether focus is specifically on the root + // node. + if (booleanProperty === 'focused' && this.#role === 'RootWebArea') { + continue; + } + const value = getBooleanPropertyValue(booleanProperty); + if (!value) { + continue; + } + node[booleanProperty] = getBooleanPropertyValue(booleanProperty); + } + + type TristateProperty = 'checked' | 'pressed'; + const tristateProperties: TristateProperty[] = ['checked', 'pressed']; + for (const tristateProperty of tristateProperties) { + if (!properties.has(tristateProperty)) { + continue; + } + const value = properties.get(tristateProperty); + node[tristateProperty] = + value === 'mixed' ? 'mixed' : value === 'true' ? true : false; + } + + type NumbericalProperty = 'level' | 'valuemax' | 'valuemin'; + const numericalProperties: NumbericalProperty[] = [ + 'level', + 'valuemax', + 'valuemin', + ]; + const getNumericalPropertyValue = (key: NumbericalProperty): number => { + return properties.get(key) as number; + }; + for (const numericalProperty of numericalProperties) { + if (!properties.has(numericalProperty)) { + continue; + } + node[numericalProperty] = getNumericalPropertyValue(numericalProperty); + } + + type TokenProperty = + | 'autocomplete' + | 'haspopup' + | 'invalid' + | 'orientation'; + const tokenProperties: TokenProperty[] = [ + 'autocomplete', + 'haspopup', + 'invalid', + 'orientation', + ]; + const getTokenPropertyValue = (key: TokenProperty): string => { + return properties.get(key) as string; + }; + for (const tokenProperty of tokenProperties) { + const value = getTokenPropertyValue(tokenProperty); + if (!value || value === 'false') { + continue; + } + node[tokenProperty] = getTokenPropertyValue(tokenProperty); + } + return node; + } + + public static createTree(payloads: Protocol.Accessibility.AXNode[]): AXNode { + const nodeById = new Map<string, AXNode>(); + for (const payload of payloads) { + nodeById.set(payload.nodeId, new AXNode(payload)); + } + for (const node of nodeById.values()) { + for (const childId of node.payload.childIds || []) { + const child = nodeById.get(childId); + if (child) { + node.children.push(child); + } + } + } + return nodeById.values().next().value; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/AriaQueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/AriaQueryHandler.ts new file mode 100644 index 0000000000..2286723758 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/AriaQueryHandler.ts @@ -0,0 +1,120 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import type {ElementHandle} from '../api/ElementHandle.js'; +import {QueryHandler, type QuerySelector} from '../common/QueryHandler.js'; +import type {AwaitableIterable} from '../common/types.js'; +import {assert} from '../util/assert.js'; +import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js'; + +const NON_ELEMENT_NODE_ROLES = new Set(['StaticText', 'InlineTextBox']); + +const queryAXTree = async ( + client: CDPSession, + element: ElementHandle<Node>, + accessibleName?: string, + role?: string +): Promise<Protocol.Accessibility.AXNode[]> => { + const {nodes} = await client.send('Accessibility.queryAXTree', { + objectId: element.id, + accessibleName, + role, + }); + return nodes.filter((node: Protocol.Accessibility.AXNode) => { + return !node.role || !NON_ELEMENT_NODE_ROLES.has(node.role.value); + }); +}; + +interface ARIASelector { + name?: string; + role?: string; +} + +const isKnownAttribute = ( + attribute: string +): attribute is keyof ARIASelector => { + return ['name', 'role'].includes(attribute); +}; + +const normalizeValue = (value: string): string => { + return value.replace(/ +/g, ' ').trim(); +}; + +/** + * The selectors consist of an accessible name to query for and optionally + * further aria attributes on the form `[<attribute>=<value>]`. + * Currently, we only support the `name` and `role` attribute. + * The following examples showcase how the syntax works wrt. querying: + * + * - 'title[role="heading"]' queries for elements with name 'title' and role 'heading'. + * - '[role="image"]' queries for elements with role 'image' and any name. + * - 'label' queries for elements with name 'label' and any role. + * - '[name=""][role="button"]' queries for elements with no name and role 'button'. + */ +const ATTRIBUTE_REGEXP = + /\[\s*(?<attribute>\w+)\s*=\s*(?<quote>"|')(?<value>\\.|.*?(?=\k<quote>))\k<quote>\s*\]/g; +const parseARIASelector = (selector: string): ARIASelector => { + const queryOptions: ARIASelector = {}; + const defaultName = selector.replace( + ATTRIBUTE_REGEXP, + (_, attribute, __, value) => { + attribute = attribute.trim(); + assert( + isKnownAttribute(attribute), + `Unknown aria attribute "${attribute}" in selector` + ); + queryOptions[attribute] = normalizeValue(value); + return ''; + } + ); + if (defaultName && !queryOptions.name) { + queryOptions.name = normalizeValue(defaultName); + } + return queryOptions; +}; + +/** + * @internal + */ +export class ARIAQueryHandler extends QueryHandler { + static override querySelector: QuerySelector = async ( + node, + selector, + {ariaQuerySelector} + ) => { + return await ariaQuerySelector(node, selector); + }; + + static override async *queryAll( + element: ElementHandle<Node>, + selector: string + ): AwaitableIterable<ElementHandle<Node>> { + const {name, role} = parseARIASelector(selector); + const results = await queryAXTree( + element.realm.environment.client, + element, + name, + role + ); + yield* AsyncIterableUtil.map(results, node => { + return element.realm.adoptBackendNode(node.backendDOMNodeId) as Promise< + ElementHandle<Node> + >; + }); + } + + static override queryOne = async ( + element: ElementHandle<Node>, + selector: string + ): Promise<ElementHandle<Node> | null> => { + return ( + (await AsyncIterableUtil.first(this.queryAll(element, selector))) ?? null + ); + }; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Binding.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Binding.ts new file mode 100644 index 0000000000..7a6a6f8582 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Binding.ts @@ -0,0 +1,118 @@ +import {JSHandle} from '../api/JSHandle.js'; +import {debugError} from '../common/util.js'; +import {DisposableStack} from '../util/disposable.js'; +import {isErrorLike} from '../util/ErrorLike.js'; + +import type {ExecutionContext} from './ExecutionContext.js'; + +/** + * @internal + */ +export class Binding { + #name: string; + #fn: (...args: unknown[]) => unknown; + constructor(name: string, fn: (...args: unknown[]) => unknown) { + this.#name = name; + this.#fn = fn; + } + + get name(): string { + return this.#name; + } + + /** + * @param context - Context to run the binding in; the context should have + * the binding added to it beforehand. + * @param id - ID of the call. This should come from the CDP + * `onBindingCalled` response. + * @param args - Plain arguments from CDP. + */ + async run( + context: ExecutionContext, + id: number, + args: unknown[], + isTrivial: boolean + ): Promise<void> { + const stack = new DisposableStack(); + try { + if (!isTrivial) { + // Getting non-trivial arguments. + using handles = await context.evaluateHandle( + (name, seq) => { + // @ts-expect-error Code is evaluated in a different context. + return globalThis[name].args.get(seq); + }, + this.#name, + id + ); + const properties = await handles.getProperties(); + for (const [index, handle] of properties) { + // This is not straight-forward since some arguments can stringify, but + // aren't plain objects so add subtypes when the use-case arises. + if (index in args) { + switch (handle.remoteObject().subtype) { + case 'node': + args[+index] = handle; + break; + default: + stack.use(handle); + } + } else { + stack.use(handle); + } + } + } + + await context.evaluate( + (name, seq, result) => { + // @ts-expect-error Code is evaluated in a different context. + const callbacks = globalThis[name].callbacks; + callbacks.get(seq).resolve(result); + callbacks.delete(seq); + }, + this.#name, + id, + await this.#fn(...args) + ); + + for (const arg of args) { + if (arg instanceof JSHandle) { + stack.use(arg); + } + } + } catch (error) { + if (isErrorLike(error)) { + await context + .evaluate( + (name, seq, message, stack) => { + const error = new Error(message); + error.stack = stack; + // @ts-expect-error Code is evaluated in a different context. + const callbacks = globalThis[name].callbacks; + callbacks.get(seq).reject(error); + callbacks.delete(seq); + }, + this.#name, + id, + error.message, + error.stack + ) + .catch(debugError); + } else { + await context + .evaluate( + (name, seq, error) => { + // @ts-expect-error Code is evaluated in a different context. + const callbacks = globalThis[name].callbacks; + callbacks.get(seq).reject(error); + callbacks.delete(seq); + }, + this.#name, + id, + error + ) + .catch(debugError); + } + } + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Browser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Browser.ts new file mode 100644 index 0000000000..7698acd164 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Browser.ts @@ -0,0 +1,523 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ChildProcess} from 'child_process'; + +import type {Protocol} from 'devtools-protocol'; + +import type {DebugInfo} from '../api/Browser.js'; +import { + Browser as BrowserBase, + BrowserEvent, + WEB_PERMISSION_TO_PROTOCOL_PERMISSION, + type BrowserCloseCallback, + type BrowserContextOptions, + 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'; +import type {Page} from '../api/Page.js'; +import type {Target} from '../api/Target.js'; +import type {Viewport} from '../common/Viewport.js'; +import {assert} from '../util/assert.js'; + +import {ChromeTargetManager} from './ChromeTargetManager.js'; +import type {Connection} from './Connection.js'; +import {FirefoxTargetManager} from './FirefoxTargetManager.js'; +import { + DevToolsTarget, + InitializationStatus, + OtherTarget, + PageTarget, + WorkerTarget, + type CdpTarget, +} from './Target.js'; +import {TargetManagerEvent, type TargetManager} from './TargetManager.js'; + +/** + * @internal + */ +export class CdpBrowser extends BrowserBase { + readonly protocol = 'cdp'; + + static async _create( + product: 'firefox' | 'chrome' | undefined, + connection: Connection, + contextIds: string[], + ignoreHTTPSErrors: boolean, + defaultViewport?: Viewport | null, + process?: ChildProcess, + closeCallback?: BrowserCloseCallback, + targetFilterCallback?: TargetFilterCallback, + isPageTargetCallback?: IsPageTargetCallback, + waitForInitiallyDiscoveredTargets = true + ): Promise<CdpBrowser> { + const browser = new CdpBrowser( + product, + connection, + contextIds, + ignoreHTTPSErrors, + defaultViewport, + process, + closeCallback, + targetFilterCallback, + isPageTargetCallback, + waitForInitiallyDiscoveredTargets + ); + await browser._attach(); + return browser; + } + #ignoreHTTPSErrors: boolean; + #defaultViewport?: Viewport | null; + #process?: ChildProcess; + #connection: Connection; + #closeCallback: BrowserCloseCallback; + #targetFilterCallback: TargetFilterCallback; + #isPageTargetCallback!: IsPageTargetCallback; + #defaultContext: CdpBrowserContext; + #contexts = new Map<string, CdpBrowserContext>(); + #targetManager: TargetManager; + + constructor( + product: 'chrome' | 'firefox' | undefined, + connection: Connection, + contextIds: string[], + ignoreHTTPSErrors: boolean, + defaultViewport?: Viewport | null, + process?: ChildProcess, + closeCallback?: BrowserCloseCallback, + targetFilterCallback?: TargetFilterCallback, + isPageTargetCallback?: IsPageTargetCallback, + waitForInitiallyDiscoveredTargets = true + ) { + super(); + product = product || 'chrome'; + this.#ignoreHTTPSErrors = ignoreHTTPSErrors; + this.#defaultViewport = defaultViewport; + this.#process = process; + this.#connection = connection; + this.#closeCallback = closeCallback || function (): void {}; + this.#targetFilterCallback = + targetFilterCallback || + ((): boolean => { + return true; + }); + this.#setIsPageTargetCallback(isPageTargetCallback); + if (product === 'firefox') { + this.#targetManager = new FirefoxTargetManager( + connection, + this.#createTarget, + this.#targetFilterCallback + ); + } else { + this.#targetManager = new ChromeTargetManager( + connection, + this.#createTarget, + this.#targetFilterCallback, + waitForInitiallyDiscoveredTargets + ); + } + this.#defaultContext = new CdpBrowserContext(this.#connection, this); + for (const contextId of contextIds) { + this.#contexts.set( + contextId, + new CdpBrowserContext(this.#connection, this, contextId) + ); + } + } + + #emitDisconnected = () => { + this.emit(BrowserEvent.Disconnected, undefined); + }; + + async _attach(): Promise<void> { + this.#connection.on(CDPSessionEvent.Disconnected, this.#emitDisconnected); + this.#targetManager.on( + TargetManagerEvent.TargetAvailable, + this.#onAttachedToTarget + ); + this.#targetManager.on( + TargetManagerEvent.TargetGone, + this.#onDetachedFromTarget + ); + this.#targetManager.on( + TargetManagerEvent.TargetChanged, + this.#onTargetChanged + ); + this.#targetManager.on( + TargetManagerEvent.TargetDiscovered, + this.#onTargetDiscovered + ); + await this.#targetManager.initialize(); + } + + _detach(): void { + this.#connection.off(CDPSessionEvent.Disconnected, this.#emitDisconnected); + this.#targetManager.off( + TargetManagerEvent.TargetAvailable, + this.#onAttachedToTarget + ); + this.#targetManager.off( + TargetManagerEvent.TargetGone, + this.#onDetachedFromTarget + ); + this.#targetManager.off( + TargetManagerEvent.TargetChanged, + this.#onTargetChanged + ); + this.#targetManager.off( + TargetManagerEvent.TargetDiscovered, + this.#onTargetDiscovered + ); + } + + override process(): ChildProcess | null { + return this.#process ?? null; + } + + _targetManager(): TargetManager { + return this.#targetManager; + } + + #setIsPageTargetCallback(isPageTargetCallback?: IsPageTargetCallback): void { + this.#isPageTargetCallback = + isPageTargetCallback || + ((target: Target): boolean => { + return ( + target.type() === 'page' || + target.type() === 'background_page' || + target.type() === 'webview' + ); + }); + } + + _getIsPageTargetCallback(): IsPageTargetCallback | undefined { + return this.#isPageTargetCallback; + } + + override async createIncognitoBrowserContext( + options: BrowserContextOptions = {} + ): Promise<CdpBrowserContext> { + const {proxyServer, proxyBypassList} = options; + + const {browserContextId} = await this.#connection.send( + 'Target.createBrowserContext', + { + proxyServer, + proxyBypassList: proxyBypassList && proxyBypassList.join(','), + } + ); + const context = new CdpBrowserContext( + this.#connection, + this, + browserContextId + ); + this.#contexts.set(browserContextId, context); + return context; + } + + override browserContexts(): CdpBrowserContext[] { + return [this.#defaultContext, ...Array.from(this.#contexts.values())]; + } + + override defaultBrowserContext(): CdpBrowserContext { + return this.#defaultContext; + } + + async _disposeContext(contextId?: string): Promise<void> { + if (!contextId) { + return; + } + await this.#connection.send('Target.disposeBrowserContext', { + browserContextId: contextId, + }); + this.#contexts.delete(contextId); + } + + #createTarget = ( + targetInfo: Protocol.Target.TargetInfo, + session?: CDPSession + ) => { + const {browserContextId} = targetInfo; + const context = + browserContextId && this.#contexts.has(browserContextId) + ? this.#contexts.get(browserContextId) + : this.#defaultContext; + + if (!context) { + throw new Error('Missing browser context'); + } + + const createSession = (isAutoAttachEmulated: boolean) => { + return this.#connection._createSession(targetInfo, isAutoAttachEmulated); + }; + const otherTarget = new OtherTarget( + targetInfo, + session, + context, + this.#targetManager, + createSession + ); + if (targetInfo.url?.startsWith('devtools://')) { + return new DevToolsTarget( + targetInfo, + session, + context, + this.#targetManager, + createSession, + this.#ignoreHTTPSErrors, + this.#defaultViewport ?? null + ); + } + if (this.#isPageTargetCallback(otherTarget)) { + return new PageTarget( + targetInfo, + session, + context, + this.#targetManager, + createSession, + this.#ignoreHTTPSErrors, + this.#defaultViewport ?? null + ); + } + if ( + targetInfo.type === 'service_worker' || + targetInfo.type === 'shared_worker' + ) { + return new WorkerTarget( + targetInfo, + session, + context, + this.#targetManager, + createSession + ); + } + return otherTarget; + }; + + #onAttachedToTarget = async (target: CdpTarget) => { + if ( + target._isTargetExposed() && + (await target._initializedDeferred.valueOrThrow()) === + InitializationStatus.SUCCESS + ) { + this.emit(BrowserEvent.TargetCreated, target); + target.browserContext().emit(BrowserContextEvent.TargetCreated, target); + } + }; + + #onDetachedFromTarget = async (target: CdpTarget): Promise<void> => { + target._initializedDeferred.resolve(InitializationStatus.ABORTED); + target._isClosedDeferred.resolve(); + if ( + target._isTargetExposed() && + (await target._initializedDeferred.valueOrThrow()) === + InitializationStatus.SUCCESS + ) { + this.emit(BrowserEvent.TargetDestroyed, target); + target.browserContext().emit(BrowserContextEvent.TargetDestroyed, target); + } + }; + + #onTargetChanged = ({target}: {target: CdpTarget}): void => { + this.emit(BrowserEvent.TargetChanged, target); + target.browserContext().emit(BrowserContextEvent.TargetChanged, target); + }; + + #onTargetDiscovered = (targetInfo: Protocol.Target.TargetInfo): void => { + this.emit(BrowserEvent.TargetDiscovered, targetInfo); + }; + + override wsEndpoint(): string { + return this.#connection.url(); + } + + override async newPage(): Promise<Page> { + return await this.#defaultContext.newPage(); + } + + async _createPageInContext(contextId?: string): Promise<Page> { + const {targetId} = await this.#connection.send('Target.createTarget', { + url: 'about:blank', + browserContextId: contextId || undefined, + }); + const target = (await this.waitForTarget(t => { + return (t as CdpTarget)._targetId === targetId; + })) as CdpTarget; + if (!target) { + throw new Error(`Missing target for page (id = ${targetId})`); + } + const initialized = + (await target._initializedDeferred.valueOrThrow()) === + InitializationStatus.SUCCESS; + if (!initialized) { + throw new Error(`Failed to create target for page (id = ${targetId})`); + } + const page = await target.page(); + if (!page) { + throw new Error( + `Failed to create a page for context (id = ${contextId})` + ); + } + return page; + } + + override targets(): CdpTarget[] { + return Array.from( + this.#targetManager.getAvailableTargets().values() + ).filter(target => { + return ( + target._isTargetExposed() && + target._initializedDeferred.value() === InitializationStatus.SUCCESS + ); + }); + } + + override target(): CdpTarget { + const browserTarget = this.targets().find(target => { + return target.type() === 'browser'; + }); + if (!browserTarget) { + throw new Error('Browser target is not found'); + } + return browserTarget; + } + + override async version(): Promise<string> { + const version = await this.#getVersion(); + return version.product; + } + + override async userAgent(): Promise<string> { + const version = await this.#getVersion(); + return version.userAgent; + } + + override async close(): Promise<void> { + await this.#closeCallback.call(null); + await this.disconnect(); + } + + override disconnect(): Promise<void> { + this.#targetManager.dispose(); + this.#connection.dispose(); + this._detach(); + return Promise.resolve(); + } + + override get connected(): boolean { + return !this.#connection._closed; + } + + #getVersion(): Promise<Protocol.Browser.GetVersionResponse> { + return this.#connection.send('Browser.getVersion'); + } + + override get debugInfo(): DebugInfo { + return { + pendingProtocolErrors: this.#connection.getPendingProtocolErrors(), + }; + } +} + +/** + * @internal + */ +export class CdpBrowserContext extends BrowserContext { + #connection: Connection; + #browser: CdpBrowser; + #id?: string; + + constructor(connection: Connection, browser: CdpBrowser, contextId?: string) { + super(); + this.#connection = connection; + this.#browser = browser; + this.#id = contextId; + } + + override get id(): string | undefined { + return this.#id; + } + + override targets(): CdpTarget[] { + return this.#browser.targets().filter(target => { + return target.browserContext() === this; + }); + } + + 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() + .filter(target => { + return ( + target.type() === 'page' || + (target.type() === 'other' && + this.#browser._getIsPageTargetCallback()?.(target)) + ); + }) + .map(target => { + return target.page(); + }) + ); + return pages.filter((page): page is Page => { + return !!page; + }); + } + + override isIncognito(): boolean { + return !!this.#id; + } + + override async overridePermissions( + origin: string, + permissions: Permission[] + ): Promise<void> { + const protocolPermissions = permissions.map(permission => { + const protocolPermission = + WEB_PERMISSION_TO_PROTOCOL_PERMISSION.get(permission); + if (!protocolPermission) { + throw new Error('Unknown permission: ' + permission); + } + return protocolPermission; + }); + await this.#connection.send('Browser.grantPermissions', { + origin, + browserContextId: this.#id || undefined, + permissions: protocolPermissions, + }); + } + + override async clearPermissionOverrides(): Promise<void> { + await this.#connection.send('Browser.resetPermissions', { + browserContextId: this.#id || undefined, + }); + } + + override newPage(): Promise<Page> { + return this.#browser._createPageInContext(this.#id); + } + + override browser(): CdpBrowser { + return this.#browser; + } + + override async close(): Promise<void> { + assert(this.#id, 'Non-incognito profiles cannot be closed!'); + await this.#browser._disposeContext(this.#id); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/BrowserConnector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/BrowserConnector.ts new file mode 100644 index 0000000000..ef4aebe747 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/BrowserConnector.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ConnectionTransport} from '../common/ConnectionTransport.js'; +import type { + BrowserConnectOptions, + ConnectOptions, +} from '../common/ConnectOptions.js'; +import {debugError, DEFAULT_VIEWPORT} from '../common/util.js'; + +import {CdpBrowser} from './Browser.js'; +import {Connection} from './Connection.js'; + +/** + * Users should never call this directly; it's called when calling + * `puppeteer.connect` with `protocol: 'cdp'`. + * + * @internal + */ +export async function _connectToCdpBrowser( + connectionTransport: ConnectionTransport, + url: string, + options: BrowserConnectOptions & ConnectOptions +): Promise<CdpBrowser> { + const { + ignoreHTTPSErrors = false, + defaultViewport = DEFAULT_VIEWPORT, + targetFilter, + _isPageTarget: isPageTarget, + slowMo = 0, + protocolTimeout, + } = options; + + const connection = new Connection( + url, + connectionTransport, + slowMo, + protocolTimeout + ); + + const version = await connection.send('Browser.getVersion'); + const product = version.product.toLowerCase().includes('firefox') + ? 'firefox' + : 'chrome'; + + const {browserContextIds} = await connection.send( + 'Target.getBrowserContexts' + ); + const browser = await CdpBrowser._create( + product || 'chrome', + connection, + browserContextIds, + ignoreHTTPSErrors, + defaultViewport, + undefined, + () => { + return connection.send('Browser.close').catch(debugError); + }, + targetFilter, + isPageTarget + ); + return browser; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/CDPSession.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/CDPSession.ts new file mode 100644 index 0000000000..fe5faa5647 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/CDPSession.ts @@ -0,0 +1,167 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js'; + +import { + type CDPEvents, + CDPSession, + CDPSessionEvent, + type CommandOptions, +} from '../api/CDPSession.js'; +import {CallbackRegistry} from '../common/CallbackRegistry.js'; +import {TargetCloseError} from '../common/Errors.js'; +import {assert} from '../util/assert.js'; +import {createProtocolErrorMessage} from '../util/ErrorLike.js'; + +import type {Connection} from './Connection.js'; +import type {CdpTarget} from './Target.js'; + +/** + * @internal + */ + +export class CdpCDPSession extends CDPSession { + #sessionId: string; + #targetType: string; + #callbacks = new CallbackRegistry(); + #connection?: Connection; + #parentSessionId?: string; + #target?: CdpTarget; + + /** + * @internal + */ + constructor( + connection: Connection, + targetType: string, + sessionId: string, + parentSessionId: string | undefined + ) { + super(); + this.#connection = connection; + this.#targetType = targetType; + this.#sessionId = sessionId; + this.#parentSessionId = parentSessionId; + } + + /** + * Sets the {@link CdpTarget} associated with the session instance. + * + * @internal + */ + _setTarget(target: CdpTarget): void { + this.#target = target; + } + + /** + * Gets the {@link CdpTarget} associated with the session instance. + * + * @internal + */ + _target(): CdpTarget { + assert(this.#target, 'Target must exist'); + return this.#target; + } + + override connection(): Connection | undefined { + return this.#connection; + } + + override parentSession(): CDPSession | undefined { + if (!this.#parentSessionId) { + // To make it work in Firefox that does not have parent (tab) sessions. + return this; + } + const parent = this.#connection?.session(this.#parentSessionId); + return parent ?? undefined; + } + + override send<T extends keyof ProtocolMapping.Commands>( + method: T, + params?: ProtocolMapping.Commands[T]['paramsType'][0], + options?: CommandOptions + ): Promise<ProtocolMapping.Commands[T]['returnType']> { + if (!this.#connection) { + return Promise.reject( + new TargetCloseError( + `Protocol error (${method}): Session closed. Most likely the ${this.#targetType} has been closed.` + ) + ); + } + return this.#connection._rawSend( + this.#callbacks, + method, + params, + this.#sessionId, + options + ); + } + + /** + * @internal + */ + _onMessage(object: { + id?: number; + method: keyof CDPEvents; + params: CDPEvents[keyof CDPEvents]; + error: {message: string; data: any; code: number}; + result?: any; + }): void { + if (object.id) { + if (object.error) { + this.#callbacks.reject( + object.id, + createProtocolErrorMessage(object), + object.error.message + ); + } else { + this.#callbacks.resolve(object.id, object.result); + } + } else { + assert(!object.id); + this.emit(object.method, object.params); + } + } + + /** + * Detaches the cdpSession from the target. Once detached, the cdpSession object + * won't emit any events and can't be used to send messages. + */ + override async detach(): Promise<void> { + if (!this.#connection) { + throw new Error( + `Session already detached. Most likely the ${this.#targetType} has been closed.` + ); + } + await this.#connection.send('Target.detachFromTarget', { + sessionId: this.#sessionId, + }); + } + + /** + * @internal + */ + _onClosed(): void { + this.#callbacks.clear(); + this.#connection = undefined; + this.emit(CDPSessionEvent.Disconnected, undefined); + } + + /** + * Returns the session's id. + */ + override id(): string { + return this.#sessionId; + } + + /** + * @internal + */ + getPendingProtocolErrors(): Error[] { + return this.#callbacks.getPendingProtocolErrors(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ChromeTargetManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ChromeTargetManager.ts new file mode 100644 index 0000000000..e87d71fff9 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ChromeTargetManager.ts @@ -0,0 +1,417 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {TargetFilterCallback} from '../api/Browser.js'; +import {CDPSession, CDPSessionEvent} from '../api/CDPSession.js'; +import {EventEmitter} from '../common/EventEmitter.js'; +import {debugError} from '../common/util.js'; +import {assert} from '../util/assert.js'; +import {Deferred} from '../util/Deferred.js'; + +import type {CdpCDPSession} from './CDPSession.js'; +import type {Connection} from './Connection.js'; +import {CdpTarget, InitializationStatus} from './Target.js'; +import { + type TargetFactory, + type TargetManager, + TargetManagerEvent, + type TargetManagerEvents, +} from './TargetManager.js'; + +function isPageTargetBecomingPrimary( + target: CdpTarget, + newTargetInfo: Protocol.Target.TargetInfo +): boolean { + return Boolean(target._subtype()) && !newTargetInfo.subtype; +} + +/** + * ChromeTargetManager uses the CDP's auto-attach mechanism to intercept + * new targets and allow the rest of Puppeteer to configure listeners while + * the target is paused. + * + * @internal + */ +export class ChromeTargetManager + extends EventEmitter<TargetManagerEvents> + implements TargetManager +{ + #connection: Connection; + /** + * Keeps track of the following events: 'Target.targetCreated', + * 'Target.targetDestroyed', 'Target.targetInfoChanged'. + * + * A target becomes discovered when 'Target.targetCreated' is received. + * A target is removed from this map once 'Target.targetDestroyed' is + * received. + * + * `targetFilterCallback` has no effect on this map. + */ + #discoveredTargetsByTargetId = new Map<string, Protocol.Target.TargetInfo>(); + /** + * A target is added to this map once ChromeTargetManager has created + * a Target and attached at least once to it. + */ + #attachedTargetsByTargetId = new Map<string, CdpTarget>(); + /** + * Tracks which sessions attach to which target. + */ + #attachedTargetsBySessionId = new Map<string, CdpTarget>(); + /** + * If a target was filtered out by `targetFilterCallback`, we still receive + * events about it from CDP, but we don't forward them to the rest of Puppeteer. + */ + #ignoredTargets = new Set<string>(); + #targetFilterCallback: TargetFilterCallback | undefined; + #targetFactory: TargetFactory; + + #attachedToTargetListenersBySession = new WeakMap< + CDPSession | Connection, + (event: Protocol.Target.AttachedToTargetEvent) => void + >(); + #detachedFromTargetListenersBySession = new WeakMap< + CDPSession | Connection, + (event: Protocol.Target.DetachedFromTargetEvent) => void + >(); + + #initializeDeferred = Deferred.create<void>(); + #targetsIdsForInit = new Set<string>(); + #waitForInitiallyDiscoveredTargets = true; + + #discoveryFilter: Protocol.Target.FilterEntry[] = [{}]; + + constructor( + connection: Connection, + targetFactory: TargetFactory, + targetFilterCallback?: TargetFilterCallback, + waitForInitiallyDiscoveredTargets = true + ) { + super(); + this.#connection = connection; + this.#targetFilterCallback = targetFilterCallback; + this.#targetFactory = targetFactory; + this.#waitForInitiallyDiscoveredTargets = waitForInitiallyDiscoveredTargets; + + this.#connection.on('Target.targetCreated', this.#onTargetCreated); + this.#connection.on('Target.targetDestroyed', this.#onTargetDestroyed); + this.#connection.on('Target.targetInfoChanged', this.#onTargetInfoChanged); + this.#connection.on( + CDPSessionEvent.SessionDetached, + this.#onSessionDetached + ); + this.#setupAttachmentListeners(this.#connection); + } + + #storeExistingTargetsForInit = () => { + if (!this.#waitForInitiallyDiscoveredTargets) { + return; + } + for (const [ + targetId, + targetInfo, + ] of this.#discoveredTargetsByTargetId.entries()) { + const targetForFilter = new CdpTarget( + targetInfo, + undefined, + undefined, + this, + undefined + ); + if ( + (!this.#targetFilterCallback || + this.#targetFilterCallback(targetForFilter)) && + targetInfo.type !== 'browser' + ) { + this.#targetsIdsForInit.add(targetId); + } + } + }; + + async initialize(): Promise<void> { + await this.#connection.send('Target.setDiscoverTargets', { + discover: true, + filter: this.#discoveryFilter, + }); + + this.#storeExistingTargetsForInit(); + + await this.#connection.send('Target.setAutoAttach', { + waitForDebuggerOnStart: true, + flatten: true, + autoAttach: true, + filter: [ + { + type: 'page', + exclude: true, + }, + ...this.#discoveryFilter, + ], + }); + this.#finishInitializationIfReady(); + await this.#initializeDeferred.valueOrThrow(); + } + + dispose(): void { + this.#connection.off('Target.targetCreated', this.#onTargetCreated); + this.#connection.off('Target.targetDestroyed', this.#onTargetDestroyed); + this.#connection.off('Target.targetInfoChanged', this.#onTargetInfoChanged); + this.#connection.off( + CDPSessionEvent.SessionDetached, + this.#onSessionDetached + ); + + this.#removeAttachmentListeners(this.#connection); + } + + getAvailableTargets(): ReadonlyMap<string, CdpTarget> { + return this.#attachedTargetsByTargetId; + } + + #setupAttachmentListeners(session: CDPSession | Connection): void { + const listener = (event: Protocol.Target.AttachedToTargetEvent) => { + void this.#onAttachedToTarget(session, event); + }; + assert(!this.#attachedToTargetListenersBySession.has(session)); + this.#attachedToTargetListenersBySession.set(session, listener); + session.on('Target.attachedToTarget', listener); + + const detachedListener = ( + event: Protocol.Target.DetachedFromTargetEvent + ) => { + return this.#onDetachedFromTarget(session, event); + }; + assert(!this.#detachedFromTargetListenersBySession.has(session)); + this.#detachedFromTargetListenersBySession.set(session, detachedListener); + session.on('Target.detachedFromTarget', detachedListener); + } + + #removeAttachmentListeners(session: CDPSession | Connection): void { + const listener = this.#attachedToTargetListenersBySession.get(session); + if (listener) { + session.off('Target.attachedToTarget', listener); + this.#attachedToTargetListenersBySession.delete(session); + } + + if (this.#detachedFromTargetListenersBySession.has(session)) { + session.off( + 'Target.detachedFromTarget', + this.#detachedFromTargetListenersBySession.get(session)! + ); + this.#detachedFromTargetListenersBySession.delete(session); + } + } + + #onSessionDetached = (session: CDPSession) => { + this.#removeAttachmentListeners(session); + }; + + #onTargetCreated = async (event: Protocol.Target.TargetCreatedEvent) => { + this.#discoveredTargetsByTargetId.set( + event.targetInfo.targetId, + event.targetInfo + ); + + this.emit(TargetManagerEvent.TargetDiscovered, event.targetInfo); + + // The connection is already attached to the browser target implicitly, + // therefore, no new CDPSession is created and we have special handling + // here. + if (event.targetInfo.type === 'browser' && event.targetInfo.attached) { + if (this.#attachedTargetsByTargetId.has(event.targetInfo.targetId)) { + return; + } + const target = this.#targetFactory(event.targetInfo, undefined); + target._initialize(); + this.#attachedTargetsByTargetId.set(event.targetInfo.targetId, target); + } + }; + + #onTargetDestroyed = (event: Protocol.Target.TargetDestroyedEvent) => { + const targetInfo = this.#discoveredTargetsByTargetId.get(event.targetId); + this.#discoveredTargetsByTargetId.delete(event.targetId); + this.#finishInitializationIfReady(event.targetId); + if ( + targetInfo?.type === 'service_worker' && + this.#attachedTargetsByTargetId.has(event.targetId) + ) { + // Special case for service workers: report TargetGone event when + // the worker is destroyed. + const target = this.#attachedTargetsByTargetId.get(event.targetId); + if (target) { + this.emit(TargetManagerEvent.TargetGone, target); + this.#attachedTargetsByTargetId.delete(event.targetId); + } + } + }; + + #onTargetInfoChanged = (event: Protocol.Target.TargetInfoChangedEvent) => { + this.#discoveredTargetsByTargetId.set( + event.targetInfo.targetId, + event.targetInfo + ); + + if ( + this.#ignoredTargets.has(event.targetInfo.targetId) || + !this.#attachedTargetsByTargetId.has(event.targetInfo.targetId) || + !event.targetInfo.attached + ) { + return; + } + + const target = this.#attachedTargetsByTargetId.get( + event.targetInfo.targetId + ); + if (!target) { + return; + } + const previousURL = target.url(); + const wasInitialized = + target._initializedDeferred.value() === InitializationStatus.SUCCESS; + + if (isPageTargetBecomingPrimary(target, event.targetInfo)) { + const session = target?._session(); + assert( + session, + 'Target that is being activated is missing a CDPSession.' + ); + session.parentSession()?.emit(CDPSessionEvent.Swapped, session); + } + + target._targetInfoChanged(event.targetInfo); + + if (wasInitialized && previousURL !== target.url()) { + this.emit(TargetManagerEvent.TargetChanged, { + target, + wasInitialized, + previousURL, + }); + } + }; + + #onAttachedToTarget = async ( + parentSession: Connection | CDPSession, + event: Protocol.Target.AttachedToTargetEvent + ) => { + const targetInfo = event.targetInfo; + const session = this.#connection.session(event.sessionId); + if (!session) { + throw new Error(`Session ${event.sessionId} was not created.`); + } + + const silentDetach = async () => { + await session.send('Runtime.runIfWaitingForDebugger').catch(debugError); + // We don't use `session.detach()` because that dispatches all commands on + // the connection instead of the parent session. + await parentSession + .send('Target.detachFromTarget', { + sessionId: session.id(), + }) + .catch(debugError); + }; + + if (!this.#connection.isAutoAttached(targetInfo.targetId)) { + return; + } + + // Special case for service workers: being attached to service workers will + // prevent them from ever being destroyed. Therefore, we silently detach + // from service workers unless the connection was manually created via + // `page.worker()`. To determine this, we use + // `this.#connection.isAutoAttached(targetInfo.targetId)`. In the future, we + // should determine if a target is auto-attached or not with the help of + // CDP. + if (targetInfo.type === 'service_worker') { + this.#finishInitializationIfReady(targetInfo.targetId); + await silentDetach(); + if (this.#attachedTargetsByTargetId.has(targetInfo.targetId)) { + return; + } + const target = this.#targetFactory(targetInfo); + target._initialize(); + this.#attachedTargetsByTargetId.set(targetInfo.targetId, target); + this.emit(TargetManagerEvent.TargetAvailable, target); + return; + } + + const isExistingTarget = this.#attachedTargetsByTargetId.has( + targetInfo.targetId + ); + + const target = isExistingTarget + ? this.#attachedTargetsByTargetId.get(targetInfo.targetId)! + : this.#targetFactory( + targetInfo, + session, + parentSession instanceof CDPSession ? parentSession : undefined + ); + + if (this.#targetFilterCallback && !this.#targetFilterCallback(target)) { + this.#ignoredTargets.add(targetInfo.targetId); + this.#finishInitializationIfReady(targetInfo.targetId); + await silentDetach(); + return; + } + + this.#setupAttachmentListeners(session); + + if (isExistingTarget) { + (session as CdpCDPSession)._setTarget(target); + this.#attachedTargetsBySessionId.set( + session.id(), + this.#attachedTargetsByTargetId.get(targetInfo.targetId)! + ); + } else { + target._initialize(); + this.#attachedTargetsByTargetId.set(targetInfo.targetId, target); + this.#attachedTargetsBySessionId.set(session.id(), target); + } + + parentSession.emit(CDPSessionEvent.Ready, session); + + this.#targetsIdsForInit.delete(target._targetId); + if (!isExistingTarget) { + this.emit(TargetManagerEvent.TargetAvailable, target); + } + this.#finishInitializationIfReady(); + + // TODO: the browser might be shutting down here. What do we do with the + // error? + await Promise.all([ + session.send('Target.setAutoAttach', { + waitForDebuggerOnStart: true, + flatten: true, + autoAttach: true, + filter: this.#discoveryFilter, + }), + session.send('Runtime.runIfWaitingForDebugger'), + ]).catch(debugError); + }; + + #finishInitializationIfReady(targetId?: string): void { + targetId !== undefined && this.#targetsIdsForInit.delete(targetId); + if (this.#targetsIdsForInit.size === 0) { + this.#initializeDeferred.resolve(); + } + } + + #onDetachedFromTarget = ( + _parentSession: Connection | CDPSession, + event: Protocol.Target.DetachedFromTargetEvent + ) => { + const target = this.#attachedTargetsBySessionId.get(event.sessionId); + + this.#attachedTargetsBySessionId.delete(event.sessionId); + + if (!target) { + return; + } + + this.#attachedTargetsByTargetId.delete(target._targetId); + this.emit(TargetManagerEvent.TargetGone, target); + }; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Connection.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Connection.ts new file mode 100644 index 0000000000..3c565341b3 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Connection.ts @@ -0,0 +1,273 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; +import type {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js'; + +import type {CommandOptions} from '../api/CDPSession.js'; +import { + CDPSessionEvent, + type CDPSession, + type CDPSessionEvents, +} from '../api/CDPSession.js'; +import {CallbackRegistry} from '../common/CallbackRegistry.js'; +import type {ConnectionTransport} from '../common/ConnectionTransport.js'; +import {debug} from '../common/Debug.js'; +import {TargetCloseError} from '../common/Errors.js'; +import {EventEmitter} from '../common/EventEmitter.js'; +import {createProtocolErrorMessage} from '../util/ErrorLike.js'; + +import {CdpCDPSession} from './CDPSession.js'; + +const debugProtocolSend = debug('puppeteer:protocol:SEND ►'); +const debugProtocolReceive = debug('puppeteer:protocol:RECV ◀'); + +/** + * @public + */ +export type {ConnectionTransport, ProtocolMapping}; + +/** + * @public + */ +export class Connection extends EventEmitter<CDPSessionEvents> { + #url: string; + #transport: ConnectionTransport; + #delay: number; + #timeout: number; + #sessions = new Map<string, CdpCDPSession>(); + #closed = false; + #manuallyAttached = new Set<string>(); + #callbacks = new CallbackRegistry(); + + constructor( + url: string, + transport: ConnectionTransport, + delay = 0, + timeout?: number + ) { + super(); + this.#url = url; + this.#delay = delay; + this.#timeout = timeout ?? 180_000; + + this.#transport = transport; + this.#transport.onmessage = this.onMessage.bind(this); + this.#transport.onclose = this.#onClose.bind(this); + } + + static fromSession(session: CDPSession): Connection | undefined { + return session.connection(); + } + + get timeout(): number { + return this.#timeout; + } + + /** + * @internal + */ + get _closed(): boolean { + return this.#closed; + } + + /** + * @internal + */ + get _sessions(): Map<string, CDPSession> { + return this.#sessions; + } + + /** + * @param sessionId - The session id + * @returns The current CDP session if it exists + */ + session(sessionId: string): CDPSession | null { + return this.#sessions.get(sessionId) || null; + } + + url(): string { + return this.#url; + } + + send<T extends keyof ProtocolMapping.Commands>( + method: T, + params?: ProtocolMapping.Commands[T]['paramsType'][0], + options?: CommandOptions + ): Promise<ProtocolMapping.Commands[T]['returnType']> { + // There is only ever 1 param arg passed, but the Protocol defines it as an + // array of 0 or 1 items See this comment: + // https://github.com/ChromeDevTools/devtools-protocol/pull/113#issuecomment-412603285 + // which explains why the protocol defines the params this way for better + // type-inference. + // So now we check if there are any params or not and deal with them accordingly. + return this._rawSend(this.#callbacks, method, params, undefined, options); + } + + /** + * @internal + */ + _rawSend<T extends keyof ProtocolMapping.Commands>( + callbacks: CallbackRegistry, + method: T, + params: ProtocolMapping.Commands[T]['paramsType'][0], + sessionId?: string, + options?: CommandOptions + ): Promise<ProtocolMapping.Commands[T]['returnType']> { + return callbacks.create(method, options?.timeout ?? this.#timeout, id => { + const stringifiedMessage = JSON.stringify({ + method, + params, + id, + sessionId, + }); + debugProtocolSend(stringifiedMessage); + this.#transport.send(stringifiedMessage); + }) as Promise<ProtocolMapping.Commands[T]['returnType']>; + } + + /** + * @internal + */ + async closeBrowser(): Promise<void> { + await this.send('Browser.close'); + } + + /** + * @internal + */ + protected async onMessage(message: string): Promise<void> { + if (this.#delay) { + await new Promise(r => { + return setTimeout(r, this.#delay); + }); + } + debugProtocolReceive(message); + const object = JSON.parse(message); + if (object.method === 'Target.attachedToTarget') { + const sessionId = object.params.sessionId; + const session = new CdpCDPSession( + this, + object.params.targetInfo.type, + sessionId, + object.sessionId + ); + this.#sessions.set(sessionId, session); + this.emit(CDPSessionEvent.SessionAttached, session); + const parentSession = this.#sessions.get(object.sessionId); + if (parentSession) { + parentSession.emit(CDPSessionEvent.SessionAttached, session); + } + } else if (object.method === 'Target.detachedFromTarget') { + const session = this.#sessions.get(object.params.sessionId); + if (session) { + session._onClosed(); + this.#sessions.delete(object.params.sessionId); + this.emit(CDPSessionEvent.SessionDetached, session); + const parentSession = this.#sessions.get(object.sessionId); + if (parentSession) { + parentSession.emit(CDPSessionEvent.SessionDetached, session); + } + } + } + if (object.sessionId) { + const session = this.#sessions.get(object.sessionId); + if (session) { + session._onMessage(object); + } + } else if (object.id) { + if (object.error) { + this.#callbacks.reject( + object.id, + createProtocolErrorMessage(object), + object.error.message + ); + } else { + this.#callbacks.resolve(object.id, object.result); + } + } else { + this.emit(object.method, object.params); + } + } + + #onClose(): void { + if (this.#closed) { + return; + } + this.#closed = true; + this.#transport.onmessage = undefined; + this.#transport.onclose = undefined; + this.#callbacks.clear(); + for (const session of this.#sessions.values()) { + session._onClosed(); + } + this.#sessions.clear(); + this.emit(CDPSessionEvent.Disconnected, undefined); + } + + dispose(): void { + this.#onClose(); + this.#transport.close(); + } + + /** + * @internal + */ + isAutoAttached(targetId: string): boolean { + return !this.#manuallyAttached.has(targetId); + } + + /** + * @internal + */ + async _createSession( + targetInfo: Protocol.Target.TargetInfo, + isAutoAttachEmulated = true + ): Promise<CDPSession> { + if (!isAutoAttachEmulated) { + this.#manuallyAttached.add(targetInfo.targetId); + } + const {sessionId} = await this.send('Target.attachToTarget', { + targetId: targetInfo.targetId, + flatten: true, + }); + this.#manuallyAttached.delete(targetInfo.targetId); + const session = this.#sessions.get(sessionId); + if (!session) { + throw new Error('CDPSession creation failed.'); + } + return session; + } + + /** + * @param targetInfo - The target info + * @returns The CDP session that is created + */ + async createSession( + targetInfo: Protocol.Target.TargetInfo + ): Promise<CDPSession> { + return await this._createSession(targetInfo, false); + } + + /** + * @internal + */ + getPendingProtocolErrors(): Error[] { + const result: Error[] = []; + result.push(...this.#callbacks.getPendingProtocolErrors()); + for (const session of this.#sessions.values()) { + result.push(...session.getPendingProtocolErrors()); + } + return result; + } +} + +/** + * @internal + */ +export function isTargetClosedError(error: Error): boolean { + return error instanceof TargetCloseError; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Coverage.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Coverage.ts new file mode 100644 index 0000000000..db995fb45b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Coverage.ts @@ -0,0 +1,513 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import {EventSubscription} from '../common/EventEmitter.js'; +import {debugError, PuppeteerURL} from '../common/util.js'; +import {assert} from '../util/assert.js'; +import {DisposableStack} from '../util/disposable.js'; + +/** + * The CoverageEntry class represents one entry of the coverage report. + * @public + */ +export interface CoverageEntry { + /** + * The URL of the style sheet or script. + */ + url: string; + /** + * The content of the style sheet or script. + */ + text: string; + /** + * The covered range as start and end positions. + */ + ranges: Array<{start: number; end: number}>; +} + +/** + * The CoverageEntry class for JavaScript + * @public + */ +export interface JSCoverageEntry extends CoverageEntry { + /** + * Raw V8 script coverage entry. + */ + rawScriptCoverage?: Protocol.Profiler.ScriptCoverage; +} + +/** + * Set of configurable options for JS coverage. + * @public + */ +export interface JSCoverageOptions { + /** + * Whether to reset coverage on every navigation. + */ + resetOnNavigation?: boolean; + /** + * Whether anonymous scripts generated by the page should be reported. + */ + reportAnonymousScripts?: boolean; + /** + * Whether the result includes raw V8 script coverage entries. + */ + includeRawScriptCoverage?: boolean; + /** + * Whether to collect coverage information at the block level. + * If true, coverage will be collected at the block level (this is the default). + * If false, coverage will be collected at the function level. + */ + useBlockCoverage?: boolean; +} + +/** + * Set of configurable options for CSS coverage. + * @public + */ +export interface CSSCoverageOptions { + /** + * Whether to reset coverage on every navigation. + */ + resetOnNavigation?: boolean; +} + +/** + * The Coverage class provides methods to gather information about parts of + * JavaScript and CSS that were used by the page. + * + * @remarks + * To output coverage in a form consumable by {@link https://github.com/istanbuljs | Istanbul}, + * see {@link https://github.com/istanbuljs/puppeteer-to-istanbul | puppeteer-to-istanbul}. + * + * @example + * An example of using JavaScript and CSS coverage to get percentage of initially + * executed code: + * + * ```ts + * // Enable both JavaScript and CSS coverage + * await Promise.all([ + * page.coverage.startJSCoverage(), + * page.coverage.startCSSCoverage(), + * ]); + * // Navigate to page + * await page.goto('https://example.com'); + * // Disable both JavaScript and CSS coverage + * const [jsCoverage, cssCoverage] = await Promise.all([ + * page.coverage.stopJSCoverage(), + * page.coverage.stopCSSCoverage(), + * ]); + * let totalBytes = 0; + * let usedBytes = 0; + * const coverage = [...jsCoverage, ...cssCoverage]; + * for (const entry of coverage) { + * totalBytes += entry.text.length; + * for (const range of entry.ranges) usedBytes += range.end - range.start - 1; + * } + * console.log(`Bytes used: ${(usedBytes / totalBytes) * 100}%`); + * ``` + * + * @public + */ +export class Coverage { + #jsCoverage: JSCoverage; + #cssCoverage: CSSCoverage; + + constructor(client: CDPSession) { + this.#jsCoverage = new JSCoverage(client); + this.#cssCoverage = new CSSCoverage(client); + } + + /** + * @internal + */ + updateClient(client: CDPSession): void { + this.#jsCoverage.updateClient(client); + this.#cssCoverage.updateClient(client); + } + + /** + * @param options - Set of configurable options for coverage defaults to + * `resetOnNavigation : true, reportAnonymousScripts : false,` + * `includeRawScriptCoverage : false, useBlockCoverage : true` + * @returns Promise that resolves when coverage is started. + * + * @remarks + * Anonymous scripts are ones that don't have an associated url. These are + * scripts that are dynamically created on the page using `eval` or + * `new Function`. If `reportAnonymousScripts` is set to `true`, anonymous + * scripts URL will start with `debugger://VM` (unless a magic //# sourceURL + * comment is present, in which case that will the be URL). + */ + async startJSCoverage(options: JSCoverageOptions = {}): Promise<void> { + return await this.#jsCoverage.start(options); + } + + /** + * Promise that resolves to the array of coverage reports for + * all scripts. + * + * @remarks + * JavaScript Coverage doesn't include anonymous scripts by default. + * However, scripts with sourceURLs are reported. + */ + async stopJSCoverage(): Promise<JSCoverageEntry[]> { + return await this.#jsCoverage.stop(); + } + + /** + * @param options - Set of configurable options for coverage, defaults to + * `resetOnNavigation : true` + * @returns Promise that resolves when coverage is started. + */ + async startCSSCoverage(options: CSSCoverageOptions = {}): Promise<void> { + return await this.#cssCoverage.start(options); + } + + /** + * Promise that resolves to the array of coverage reports + * for all stylesheets. + * + * @remarks + * CSS Coverage doesn't include dynamically injected style tags + * without sourceURLs. + */ + async stopCSSCoverage(): Promise<CoverageEntry[]> { + return await this.#cssCoverage.stop(); + } +} + +/** + * @public + */ +export class JSCoverage { + #client: CDPSession; + #enabled = false; + #scriptURLs = new Map<string, string>(); + #scriptSources = new Map<string, string>(); + #subscriptions?: DisposableStack; + #resetOnNavigation = false; + #reportAnonymousScripts = false; + #includeRawScriptCoverage = false; + + constructor(client: CDPSession) { + this.#client = client; + } + + /** + * @internal + */ + updateClient(client: CDPSession): void { + this.#client = client; + } + + async start( + options: { + resetOnNavigation?: boolean; + reportAnonymousScripts?: boolean; + includeRawScriptCoverage?: boolean; + useBlockCoverage?: boolean; + } = {} + ): Promise<void> { + assert(!this.#enabled, 'JSCoverage is already enabled'); + const { + resetOnNavigation = true, + reportAnonymousScripts = false, + includeRawScriptCoverage = false, + useBlockCoverage = true, + } = options; + this.#resetOnNavigation = resetOnNavigation; + this.#reportAnonymousScripts = reportAnonymousScripts; + this.#includeRawScriptCoverage = includeRawScriptCoverage; + this.#enabled = true; + this.#scriptURLs.clear(); + this.#scriptSources.clear(); + this.#subscriptions = new DisposableStack(); + this.#subscriptions.use( + new EventSubscription( + this.#client, + 'Debugger.scriptParsed', + this.#onScriptParsed.bind(this) + ) + ); + this.#subscriptions.use( + new EventSubscription( + this.#client, + 'Runtime.executionContextsCleared', + this.#onExecutionContextsCleared.bind(this) + ) + ); + await Promise.all([ + this.#client.send('Profiler.enable'), + this.#client.send('Profiler.startPreciseCoverage', { + callCount: this.#includeRawScriptCoverage, + detailed: useBlockCoverage, + }), + this.#client.send('Debugger.enable'), + this.#client.send('Debugger.setSkipAllPauses', {skip: true}), + ]); + } + + #onExecutionContextsCleared(): void { + if (!this.#resetOnNavigation) { + return; + } + this.#scriptURLs.clear(); + this.#scriptSources.clear(); + } + + async #onScriptParsed( + event: Protocol.Debugger.ScriptParsedEvent + ): Promise<void> { + // Ignore puppeteer-injected scripts + if (PuppeteerURL.isPuppeteerURL(event.url)) { + return; + } + // Ignore other anonymous scripts unless the reportAnonymousScripts option is true. + if (!event.url && !this.#reportAnonymousScripts) { + return; + } + try { + const response = await this.#client.send('Debugger.getScriptSource', { + scriptId: event.scriptId, + }); + this.#scriptURLs.set(event.scriptId, event.url); + this.#scriptSources.set(event.scriptId, response.scriptSource); + } catch (error) { + // This might happen if the page has already navigated away. + debugError(error); + } + } + + async stop(): Promise<JSCoverageEntry[]> { + assert(this.#enabled, 'JSCoverage is not enabled'); + this.#enabled = false; + + const result = await Promise.all([ + this.#client.send('Profiler.takePreciseCoverage'), + this.#client.send('Profiler.stopPreciseCoverage'), + this.#client.send('Profiler.disable'), + this.#client.send('Debugger.disable'), + ]); + + this.#subscriptions?.dispose(); + + const coverage = []; + const profileResponse = result[0]; + + for (const entry of profileResponse.result) { + let url = this.#scriptURLs.get(entry.scriptId); + if (!url && this.#reportAnonymousScripts) { + url = 'debugger://VM' + entry.scriptId; + } + const text = this.#scriptSources.get(entry.scriptId); + if (text === undefined || url === undefined) { + continue; + } + const flattenRanges = []; + for (const func of entry.functions) { + flattenRanges.push(...func.ranges); + } + const ranges = convertToDisjointRanges(flattenRanges); + if (!this.#includeRawScriptCoverage) { + coverage.push({url, ranges, text}); + } else { + coverage.push({url, ranges, text, rawScriptCoverage: entry}); + } + } + return coverage; + } +} + +/** + * @public + */ +export class CSSCoverage { + #client: CDPSession; + #enabled = false; + #stylesheetURLs = new Map<string, string>(); + #stylesheetSources = new Map<string, string>(); + #eventListeners?: DisposableStack; + #resetOnNavigation = false; + + constructor(client: CDPSession) { + this.#client = client; + } + + /** + * @internal + */ + updateClient(client: CDPSession): void { + this.#client = client; + } + + async start(options: {resetOnNavigation?: boolean} = {}): Promise<void> { + assert(!this.#enabled, 'CSSCoverage is already enabled'); + const {resetOnNavigation = true} = options; + this.#resetOnNavigation = resetOnNavigation; + this.#enabled = true; + this.#stylesheetURLs.clear(); + this.#stylesheetSources.clear(); + this.#eventListeners = new DisposableStack(); + this.#eventListeners.use( + new EventSubscription( + this.#client, + 'CSS.styleSheetAdded', + this.#onStyleSheet.bind(this) + ) + ); + this.#eventListeners.use( + new EventSubscription( + this.#client, + 'Runtime.executionContextsCleared', + this.#onExecutionContextsCleared.bind(this) + ) + ); + await Promise.all([ + this.#client.send('DOM.enable'), + this.#client.send('CSS.enable'), + this.#client.send('CSS.startRuleUsageTracking'), + ]); + } + + #onExecutionContextsCleared(): void { + if (!this.#resetOnNavigation) { + return; + } + this.#stylesheetURLs.clear(); + this.#stylesheetSources.clear(); + } + + async #onStyleSheet(event: Protocol.CSS.StyleSheetAddedEvent): Promise<void> { + const header = event.header; + // Ignore anonymous scripts + if (!header.sourceURL) { + return; + } + try { + const response = await this.#client.send('CSS.getStyleSheetText', { + styleSheetId: header.styleSheetId, + }); + this.#stylesheetURLs.set(header.styleSheetId, header.sourceURL); + this.#stylesheetSources.set(header.styleSheetId, response.text); + } catch (error) { + // This might happen if the page has already navigated away. + debugError(error); + } + } + + async stop(): Promise<CoverageEntry[]> { + assert(this.#enabled, 'CSSCoverage is not enabled'); + this.#enabled = false; + const ruleTrackingResponse = await this.#client.send( + 'CSS.stopRuleUsageTracking' + ); + await Promise.all([ + this.#client.send('CSS.disable'), + this.#client.send('DOM.disable'), + ]); + this.#eventListeners?.dispose(); + + // aggregate by styleSheetId + const styleSheetIdToCoverage = new Map(); + for (const entry of ruleTrackingResponse.ruleUsage) { + let ranges = styleSheetIdToCoverage.get(entry.styleSheetId); + if (!ranges) { + ranges = []; + styleSheetIdToCoverage.set(entry.styleSheetId, ranges); + } + ranges.push({ + startOffset: entry.startOffset, + endOffset: entry.endOffset, + count: entry.used ? 1 : 0, + }); + } + + const coverage: CoverageEntry[] = []; + for (const styleSheetId of this.#stylesheetURLs.keys()) { + const url = this.#stylesheetURLs.get(styleSheetId); + assert( + typeof url !== 'undefined', + `Stylesheet URL is undefined (styleSheetId=${styleSheetId})` + ); + const text = this.#stylesheetSources.get(styleSheetId); + assert( + typeof text !== 'undefined', + `Stylesheet text is undefined (styleSheetId=${styleSheetId})` + ); + const ranges = convertToDisjointRanges( + styleSheetIdToCoverage.get(styleSheetId) || [] + ); + coverage.push({url, ranges, text}); + } + + return coverage; + } +} + +function convertToDisjointRanges( + nestedRanges: Array<{startOffset: number; endOffset: number; count: number}> +): Array<{start: number; end: number}> { + const points = []; + for (const range of nestedRanges) { + points.push({offset: range.startOffset, type: 0, range}); + points.push({offset: range.endOffset, type: 1, range}); + } + // Sort points to form a valid parenthesis sequence. + points.sort((a, b) => { + // Sort with increasing offsets. + if (a.offset !== b.offset) { + return a.offset - b.offset; + } + // All "end" points should go before "start" points. + if (a.type !== b.type) { + return b.type - a.type; + } + const aLength = a.range.endOffset - a.range.startOffset; + const bLength = b.range.endOffset - b.range.startOffset; + // For two "start" points, the one with longer range goes first. + if (a.type === 0) { + return bLength - aLength; + } + // For two "end" points, the one with shorter range goes first. + return aLength - bLength; + }); + + const hitCountStack = []; + const results: Array<{ + start: number; + end: number; + }> = []; + let lastOffset = 0; + // Run scanning line to intersect all ranges. + for (const point of points) { + if ( + hitCountStack.length && + lastOffset < point.offset && + hitCountStack[hitCountStack.length - 1]! > 0 + ) { + const lastResult = results[results.length - 1]; + if (lastResult && lastResult.end === lastOffset) { + lastResult.end = point.offset; + } else { + results.push({start: lastOffset, end: point.offset}); + } + } + lastOffset = point.offset; + if (point.type === 0) { + hitCountStack.push(point.range.count); + } else { + hitCountStack.pop(); + } + } + // Filter out empty ranges. + return results.filter(range => { + return range.end - range.start > 0; + }); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/DeviceRequestPrompt.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/DeviceRequestPrompt.test.ts new file mode 100644 index 0000000000..7d75e97eaf --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/DeviceRequestPrompt.test.ts @@ -0,0 +1,471 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, it} from 'node:test'; + +import expect from 'expect'; + +import type {CDPSessionEvents} from '../api/CDPSession.js'; +import {TimeoutError} from '../common/Errors.js'; +import {EventEmitter} from '../common/EventEmitter.js'; +import {TimeoutSettings} from '../common/TimeoutSettings.js'; + +import { + DeviceRequestPrompt, + DeviceRequestPromptDevice, + DeviceRequestPromptManager, +} from './DeviceRequestPrompt.js'; + +class MockCDPSession extends EventEmitter<CDPSessionEvents> { + async send(): Promise<any> {} + connection() { + return undefined; + } + async detach() {} + id() { + return '1'; + } + parentSession() { + return undefined; + } +} + +describe('DeviceRequestPrompt', function () { + describe('waitForDevicePrompt', function () { + it('should return prompt', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const manager = new DeviceRequestPromptManager(client, timeoutSettings); + + const [prompt] = await Promise.all([ + manager.waitForDevicePrompt(), + (() => { + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [], + }); + })(), + ]); + expect(prompt).toBeTruthy(); + }); + + it('should respect timeout', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const manager = new DeviceRequestPromptManager(client, timeoutSettings); + + await expect( + manager.waitForDevicePrompt({timeout: 1}) + ).rejects.toBeInstanceOf(TimeoutError); + }); + + it('should respect default timeout when there is no custom timeout', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const manager = new DeviceRequestPromptManager(client, timeoutSettings); + + timeoutSettings.setDefaultTimeout(1); + await expect(manager.waitForDevicePrompt()).rejects.toBeInstanceOf( + TimeoutError + ); + }); + + it('should prioritize exact timeout over default timeout', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const manager = new DeviceRequestPromptManager(client, timeoutSettings); + + timeoutSettings.setDefaultTimeout(0); + await expect( + manager.waitForDevicePrompt({timeout: 1}) + ).rejects.toBeInstanceOf(TimeoutError); + }); + + it('should work with no timeout', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const manager = new DeviceRequestPromptManager(client, timeoutSettings); + + const [prompt] = await Promise.all([ + manager.waitForDevicePrompt({timeout: 0}), + (async () => { + await new Promise(resolve => { + setTimeout(resolve, 50); + }); + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [], + }); + })(), + ]); + expect(prompt).toBeTruthy(); + }); + + it('should return the same prompt when there are many watchdogs simultaneously', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const manager = new DeviceRequestPromptManager(client, timeoutSettings); + + const [prompt1, prompt2] = await Promise.all([ + manager.waitForDevicePrompt(), + manager.waitForDevicePrompt(), + (() => { + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [], + }); + })(), + ]); + expect(prompt1 === prompt2).toBeTruthy(); + }); + + it('should listen and shortcut when there are no watchdogs', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const manager = new DeviceRequestPromptManager(client, timeoutSettings); + + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [], + }); + + expect(manager).toBeTruthy(); + }); + }); + + describe('DeviceRequestPrompt.devices', function () { + it('lists devices as they arrive', function () { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + expect(prompt.devices).toHaveLength(0); + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [{id: '00000000', name: 'Device 0'}], + }); + expect(prompt.devices).toHaveLength(1); + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + expect(prompt.devices).toHaveLength(2); + expect(prompt.devices[0]).toBeInstanceOf(DeviceRequestPromptDevice); + expect(prompt.devices[1]).toBeInstanceOf(DeviceRequestPromptDevice); + }); + + it('does not list devices from events of another prompt', function () { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + expect(prompt.devices).toHaveLength(0); + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '88888888888888888888888888888888', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + expect(prompt.devices).toHaveLength(0); + }); + }); + + describe('DeviceRequestPrompt.waitForDevice', function () { + it('should return first matching device', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + const [device] = await Promise.all([ + prompt.waitForDevice(({name}) => { + return name.includes('1'); + }), + (() => { + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [{id: '00000000', name: 'Device 0'}], + }); + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + })(), + ]); + expect(device).toBeInstanceOf(DeviceRequestPromptDevice); + }); + + it('should return first matching device from already known devices', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + + const device = await prompt.waitForDevice(({name}) => { + return name.includes('1'); + }); + expect(device).toBeInstanceOf(DeviceRequestPromptDevice); + }); + + it('should return device in the devices list', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + const [device] = await Promise.all([ + prompt.waitForDevice(({name}) => { + return name.includes('1'); + }), + (() => { + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + })(), + ]); + expect(prompt.devices).toContain(device); + }); + + it('should respect timeout', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + await expect( + prompt.waitForDevice( + ({name}) => { + return name.includes('Device'); + }, + {timeout: 1} + ) + ).rejects.toBeInstanceOf(TimeoutError); + }); + + it('should respect default timeout when there is no custom timeout', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + timeoutSettings.setDefaultTimeout(1); + await expect( + prompt.waitForDevice( + ({name}) => { + return name.includes('Device'); + }, + {timeout: 1} + ) + ).rejects.toBeInstanceOf(TimeoutError); + }); + + it('should prioritize exact timeout over default timeout', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + timeoutSettings.setDefaultTimeout(0); + await expect( + prompt.waitForDevice( + ({name}) => { + return name.includes('Device'); + }, + {timeout: 1} + ) + ).rejects.toBeInstanceOf(TimeoutError); + }); + + it('should work with no timeout', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + const [device] = await Promise.all([ + prompt.waitForDevice( + ({name}) => { + return name.includes('1'); + }, + {timeout: 0} + ), + (() => { + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [{id: '00000000', name: 'Device 0'}], + }); + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + })(), + ]); + expect(device).toBeInstanceOf(DeviceRequestPromptDevice); + }); + + it('should return same device from multiple watchdogs', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + const [device1, device2] = await Promise.all([ + prompt.waitForDevice(({name}) => { + return name.includes('1'); + }), + prompt.waitForDevice(({name}) => { + return name.includes('1'); + }), + (() => { + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [{id: '00000000', name: 'Device 0'}], + }); + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + })(), + ]); + expect(device1 === device2).toBeTruthy(); + }); + }); + + describe('DeviceRequestPrompt.select', function () { + it('should succeed with listed device', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + const [device] = await Promise.all([ + prompt.waitForDevice(({name}) => { + return name.includes('1'); + }), + (() => { + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + })(), + ]); + await prompt.select(device); + }); + + it('should error for device not listed in devices', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + await expect( + prompt.select(new DeviceRequestPromptDevice('11111111', 'Device 1')) + ).rejects.toThrowError('Cannot select unknown device!'); + }); + + it('should fail when selecting prompt twice', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + const [device] = await Promise.all([ + prompt.waitForDevice(({name}) => { + return name.includes('1'); + }), + (() => { + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + })(), + ]); + await prompt.select(device); + await expect(prompt.select(device)).rejects.toThrowError( + 'Cannot select DeviceRequestPrompt which is already handled!' + ); + }); + }); + + describe('DeviceRequestPrompt.cancel', function () { + it('should succeed on first call', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + await prompt.cancel(); + }); + + it('should fail when canceling prompt twice', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + await prompt.cancel(); + await expect(prompt.cancel()).rejects.toThrowError( + 'Cannot cancel DeviceRequestPrompt which is already handled!' + ); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/DeviceRequestPrompt.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/DeviceRequestPrompt.ts new file mode 100644 index 0000000000..f5bd73bf72 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/DeviceRequestPrompt.ts @@ -0,0 +1,280 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type Protocol from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import type {WaitTimeoutOptions} from '../api/Page.js'; +import type {TimeoutSettings} from '../common/TimeoutSettings.js'; +import {assert} from '../util/assert.js'; +import {Deferred} from '../util/Deferred.js'; + +/** + * Device in a request prompt. + * + * @public + */ +export class DeviceRequestPromptDevice { + /** + * Device id during a prompt. + */ + id: string; + + /** + * Device name as it appears in a prompt. + */ + name: string; + + /** + * @internal + */ + constructor(id: string, name: string) { + this.id = id; + this.name = name; + } +} + +/** + * Device request prompts let you respond to the page requesting for a device + * through an API like WebBluetooth. + * + * @remarks + * `DeviceRequestPrompt` instances are returned via the + * {@link Page.waitForDevicePrompt} method. + * + * @example + * + * ```ts + * const [deviceRequest] = Promise.all([ + * page.waitForDevicePrompt(), + * page.click('#connect-bluetooth'), + * ]); + * await devicePrompt.select( + * await devicePrompt.waitForDevice(({name}) => name.includes('My Device')) + * ); + * ``` + * + * @public + */ +export class DeviceRequestPrompt { + #client: CDPSession | null; + #timeoutSettings: TimeoutSettings; + #id: string; + #handled = false; + #updateDevicesHandle = this.#updateDevices.bind(this); + #waitForDevicePromises = new Set<{ + filter: (device: DeviceRequestPromptDevice) => boolean; + promise: Deferred<DeviceRequestPromptDevice>; + }>(); + + /** + * Current list of selectable devices. + */ + devices: DeviceRequestPromptDevice[] = []; + + /** + * @internal + */ + constructor( + client: CDPSession, + timeoutSettings: TimeoutSettings, + firstEvent: Protocol.DeviceAccess.DeviceRequestPromptedEvent + ) { + this.#client = client; + this.#timeoutSettings = timeoutSettings; + this.#id = firstEvent.id; + + this.#client.on( + 'DeviceAccess.deviceRequestPrompted', + this.#updateDevicesHandle + ); + this.#client.on('Target.detachedFromTarget', () => { + this.#client = null; + }); + + this.#updateDevices(firstEvent); + } + + #updateDevices(event: Protocol.DeviceAccess.DeviceRequestPromptedEvent) { + if (event.id !== this.#id) { + return; + } + + for (const rawDevice of event.devices) { + if ( + this.devices.some(device => { + return device.id === rawDevice.id; + }) + ) { + continue; + } + + const newDevice = new DeviceRequestPromptDevice( + rawDevice.id, + rawDevice.name + ); + this.devices.push(newDevice); + + for (const waitForDevicePromise of this.#waitForDevicePromises) { + if (waitForDevicePromise.filter(newDevice)) { + waitForDevicePromise.promise.resolve(newDevice); + } + } + } + } + + /** + * Resolve to the first device in the prompt matching a filter. + */ + async waitForDevice( + filter: (device: DeviceRequestPromptDevice) => boolean, + options: WaitTimeoutOptions = {} + ): Promise<DeviceRequestPromptDevice> { + for (const device of this.devices) { + if (filter(device)) { + return device; + } + } + + const {timeout = this.#timeoutSettings.timeout()} = options; + const deferred = Deferred.create<DeviceRequestPromptDevice>({ + message: `Waiting for \`DeviceRequestPromptDevice\` failed: ${timeout}ms exceeded`, + timeout, + }); + const handle = {filter, promise: deferred}; + this.#waitForDevicePromises.add(handle); + try { + return await deferred.valueOrThrow(); + } finally { + this.#waitForDevicePromises.delete(handle); + } + } + + /** + * Select a device in the prompt's list. + */ + async select(device: DeviceRequestPromptDevice): Promise<void> { + assert( + this.#client !== null, + 'Cannot select device through detached session!' + ); + assert(this.devices.includes(device), 'Cannot select unknown device!'); + assert( + !this.#handled, + 'Cannot select DeviceRequestPrompt which is already handled!' + ); + this.#client.off( + 'DeviceAccess.deviceRequestPrompted', + this.#updateDevicesHandle + ); + this.#handled = true; + return await this.#client.send('DeviceAccess.selectPrompt', { + id: this.#id, + deviceId: device.id, + }); + } + + /** + * Cancel the prompt. + */ + async cancel(): Promise<void> { + assert( + this.#client !== null, + 'Cannot cancel prompt through detached session!' + ); + assert( + !this.#handled, + 'Cannot cancel DeviceRequestPrompt which is already handled!' + ); + this.#client.off( + 'DeviceAccess.deviceRequestPrompted', + this.#updateDevicesHandle + ); + this.#handled = true; + return await this.#client.send('DeviceAccess.cancelPrompt', {id: this.#id}); + } +} + +/** + * @internal + */ +export class DeviceRequestPromptManager { + #client: CDPSession | null; + #timeoutSettings: TimeoutSettings; + #deviceRequestPrompDeferreds = new Set<Deferred<DeviceRequestPrompt>>(); + + /** + * @internal + */ + constructor(client: CDPSession, timeoutSettings: TimeoutSettings) { + this.#client = client; + this.#timeoutSettings = timeoutSettings; + + this.#client.on('DeviceAccess.deviceRequestPrompted', event => { + this.#onDeviceRequestPrompted(event); + }); + this.#client.on('Target.detachedFromTarget', () => { + this.#client = null; + }); + } + + /** + * Wait for device prompt created by an action like calling WebBluetooth's + * requestDevice. + */ + async waitForDevicePrompt( + options: WaitTimeoutOptions = {} + ): Promise<DeviceRequestPrompt> { + assert( + this.#client !== null, + 'Cannot wait for device prompt through detached session!' + ); + const needsEnable = this.#deviceRequestPrompDeferreds.size === 0; + let enablePromise: Promise<void> | undefined; + if (needsEnable) { + enablePromise = this.#client.send('DeviceAccess.enable'); + } + + const {timeout = this.#timeoutSettings.timeout()} = options; + const deferred = Deferred.create<DeviceRequestPrompt>({ + message: `Waiting for \`DeviceRequestPrompt\` failed: ${timeout}ms exceeded`, + timeout, + }); + this.#deviceRequestPrompDeferreds.add(deferred); + + try { + const [result] = await Promise.all([ + deferred.valueOrThrow(), + enablePromise, + ]); + return result; + } finally { + this.#deviceRequestPrompDeferreds.delete(deferred); + } + } + + /** + * @internal + */ + #onDeviceRequestPrompted( + event: Protocol.DeviceAccess.DeviceRequestPromptedEvent + ) { + if (!this.#deviceRequestPrompDeferreds.size) { + return; + } + + assert(this.#client !== null); + const devicePrompt = new DeviceRequestPrompt( + this.#client, + this.#timeoutSettings, + event + ); + for (const promise of this.#deviceRequestPrompDeferreds) { + promise.resolve(devicePrompt); + } + this.#deviceRequestPrompDeferreds.clear(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Dialog.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Dialog.ts new file mode 100644 index 0000000000..fe8fffbcad --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Dialog.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import {Dialog} from '../api/Dialog.js'; + +/** + * @internal + */ +export class CdpDialog extends Dialog { + #client: CDPSession; + + constructor( + client: CDPSession, + type: Protocol.Page.DialogType, + message: string, + defaultValue = '' + ) { + super(type, message, defaultValue); + this.#client = client; + } + + override async handle(options: { + accept: boolean; + text?: string; + }): Promise<void> { + await this.#client.send('Page.handleJavaScriptDialog', { + accept: options.accept, + promptText: options.text, + }); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ElementHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ElementHandle.ts new file mode 100644 index 0000000000..a47d546a87 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ElementHandle.ts @@ -0,0 +1,172 @@ +/** + * @license + * Copyright 2019 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type Path from 'path'; + +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import {ElementHandle, type AutofillData} from '../api/ElementHandle.js'; +import {debugError} from '../common/util.js'; +import {assert} from '../util/assert.js'; +import {throwIfDisposed} from '../util/decorators.js'; + +import type {CdpFrame} from './Frame.js'; +import type {FrameManager} from './FrameManager.js'; +import type {IsolatedWorld} from './IsolatedWorld.js'; +import {CdpJSHandle} from './JSHandle.js'; + +/** + * The CdpElementHandle extends ElementHandle now to keep compatibility + * with `instanceof` because of that we need to have methods for + * CdpJSHandle to in this implementation as well. + * + * @internal + */ +export class CdpElementHandle< + ElementType extends Node = Element, +> extends ElementHandle<ElementType> { + protected declare readonly handle: CdpJSHandle<ElementType>; + + constructor( + world: IsolatedWorld, + remoteObject: Protocol.Runtime.RemoteObject + ) { + super(new CdpJSHandle(world, remoteObject)); + } + + override get realm(): IsolatedWorld { + return this.handle.realm; + } + + get client(): CDPSession { + return this.handle.client; + } + + override remoteObject(): Protocol.Runtime.RemoteObject { + return this.handle.remoteObject(); + } + + get #frameManager(): FrameManager { + return this.frame._frameManager; + } + + override get frame(): CdpFrame { + return this.realm.environment as CdpFrame; + } + + override async contentFrame( + this: ElementHandle<HTMLIFrameElement> + ): Promise<CdpFrame>; + + @throwIfDisposed() + override async contentFrame(): Promise<CdpFrame | null> { + const nodeInfo = await this.client.send('DOM.describeNode', { + objectId: this.id, + }); + if (typeof nodeInfo.node.frameId !== 'string') { + return null; + } + return this.#frameManager.frame(nodeInfo.node.frameId); + } + + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + override async scrollIntoView( + this: CdpElementHandle<Element> + ): Promise<void> { + await this.assertConnectedElement(); + try { + await this.client.send('DOM.scrollIntoViewIfNeeded', { + objectId: this.id, + }); + } catch (error) { + debugError(error); + // Fallback to Element.scrollIntoView if DOM.scrollIntoViewIfNeeded is not supported + await super.scrollIntoView(); + } + } + + @throwIfDisposed() + @ElementHandle.bindIsolatedHandle + override async uploadFile( + this: CdpElementHandle<HTMLInputElement>, + ...filePaths: string[] + ): Promise<void> { + const isMultiple = await this.evaluate(element => { + return element.multiple; + }); + assert( + filePaths.length <= 1 || isMultiple, + 'Multiple file uploads only work with <input type=file multiple>' + ); + + // Locate all files and confirm that they exist. + let path: typeof 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; + } + const files = filePaths.map(filePath => { + if (path.win32.isAbsolute(filePath) || path.posix.isAbsolute(filePath)) { + return filePath; + } else { + return path.resolve(filePath); + } + }); + + /** + * The zero-length array is a special case, it seems that + * DOM.setFileInputFiles does not actually update the files in that case, so + * the solution is to eval the element value to a new FileList directly. + */ + if (files.length === 0) { + // XXX: These events should converted to trusted events. Perhaps do this + // in `DOM.setFileInputFiles`? + await this.evaluate(element => { + element.files = new DataTransfer().files; + + // Dispatch events for this case because it should behave akin to a user action. + element.dispatchEvent( + new Event('input', {bubbles: true, composed: true}) + ); + element.dispatchEvent(new Event('change', {bubbles: true})); + }); + return; + } + + const { + node: {backendNodeId}, + } = await this.client.send('DOM.describeNode', { + objectId: this.id, + }); + await this.client.send('DOM.setFileInputFiles', { + objectId: this.id, + files, + backendNodeId, + }); + } + + @throwIfDisposed() + override async autofill(data: AutofillData): Promise<void> { + const nodeInfo = await this.client.send('DOM.describeNode', { + objectId: this.handle.id, + }); + const fieldId = nodeInfo.node.backendNodeId; + const frameId = this.frame._id; + await this.client.send('Autofill.trigger', { + fieldId, + frameId, + card: data.creditCard, + }); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/EmulationManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/EmulationManager.ts new file mode 100644 index 0000000000..8598967fe7 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/EmulationManager.ts @@ -0,0 +1,554 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type {Protocol} from 'devtools-protocol'; + +import {type CDPSession, CDPSessionEvent} from '../api/CDPSession.js'; +import type {GeolocationOptions, MediaFeature} from '../api/Page.js'; +import {debugError} from '../common/util.js'; +import type {Viewport} from '../common/Viewport.js'; +import {assert} from '../util/assert.js'; +import {invokeAtMostOnceForArguments} from '../util/decorators.js'; +import {isErrorLike} from '../util/ErrorLike.js'; + +interface ViewportState { + viewport?: Viewport; + active: boolean; +} + +interface IdleOverridesState { + overrides?: { + isUserActive: boolean; + isScreenUnlocked: boolean; + }; + active: boolean; +} + +interface TimezoneState { + timezoneId?: string; + active: boolean; +} + +interface VisionDeficiencyState { + visionDeficiency?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type']; + active: boolean; +} + +interface CpuThrottlingState { + factor?: number; + active: boolean; +} + +interface MediaFeaturesState { + mediaFeatures?: MediaFeature[]; + active: boolean; +} + +interface MediaTypeState { + type?: string; + active: boolean; +} + +interface GeoLocationState { + geoLocation?: GeolocationOptions; + active: boolean; +} + +interface DefaultBackgroundColorState { + color?: Protocol.DOM.RGBA; + active: boolean; +} + +interface JavascriptEnabledState { + javaScriptEnabled: boolean; + active: boolean; +} + +/** + * @internal + */ +export interface ClientProvider { + clients(): CDPSession[]; + registerState(state: EmulatedState<any>): void; +} + +/** + * @internal + */ +export class EmulatedState<T extends {active: boolean}> { + #state: T; + #clientProvider: ClientProvider; + #updater: (client: CDPSession, state: T) => Promise<void>; + + constructor( + initialState: T, + clientProvider: ClientProvider, + updater: (client: CDPSession, state: T) => Promise<void> + ) { + this.#state = initialState; + this.#clientProvider = clientProvider; + this.#updater = updater; + this.#clientProvider.registerState(this); + } + + async setState(state: T): Promise<void> { + this.#state = state; + await this.sync(); + } + + get state(): T { + return this.#state; + } + + async sync(): Promise<void> { + await Promise.all( + this.#clientProvider.clients().map(client => { + return this.#updater(client, this.#state); + }) + ); + } +} + +/** + * @internal + */ +export class EmulationManager { + #client: CDPSession; + + #emulatingMobile = false; + #hasTouch = false; + + #states: Array<EmulatedState<any>> = []; + + #viewportState = new EmulatedState<ViewportState>( + { + active: false, + }, + this, + this.#applyViewport + ); + #idleOverridesState = new EmulatedState<IdleOverridesState>( + { + active: false, + }, + this, + this.#emulateIdleState + ); + #timezoneState = new EmulatedState<TimezoneState>( + { + active: false, + }, + this, + this.#emulateTimezone + ); + #visionDeficiencyState = new EmulatedState<VisionDeficiencyState>( + { + active: false, + }, + this, + this.#emulateVisionDeficiency + ); + #cpuThrottlingState = new EmulatedState<CpuThrottlingState>( + { + active: false, + }, + this, + this.#emulateCpuThrottling + ); + #mediaFeaturesState = new EmulatedState<MediaFeaturesState>( + { + active: false, + }, + this, + this.#emulateMediaFeatures + ); + #mediaTypeState = new EmulatedState<MediaTypeState>( + { + active: false, + }, + this, + this.#emulateMediaType + ); + #geoLocationState = new EmulatedState<GeoLocationState>( + { + active: false, + }, + this, + this.#setGeolocation + ); + #defaultBackgroundColorState = new EmulatedState<DefaultBackgroundColorState>( + { + active: false, + }, + this, + this.#setDefaultBackgroundColor + ); + #javascriptEnabledState = new EmulatedState<JavascriptEnabledState>( + { + javaScriptEnabled: true, + active: false, + }, + this, + this.#setJavaScriptEnabled + ); + + #secondaryClients = new Set<CDPSession>(); + + constructor(client: CDPSession) { + this.#client = client; + } + + updateClient(client: CDPSession): void { + this.#client = client; + this.#secondaryClients.delete(client); + } + + registerState(state: EmulatedState<any>): void { + this.#states.push(state); + } + + clients(): CDPSession[] { + return [this.#client, ...Array.from(this.#secondaryClients)]; + } + + async registerSpeculativeSession(client: CDPSession): Promise<void> { + this.#secondaryClients.add(client); + client.once(CDPSessionEvent.Disconnected, () => { + this.#secondaryClients.delete(client); + }); + // We don't await here because we want to register all state changes before + // the target is unpaused. + void Promise.all( + this.#states.map(s => { + return s.sync().catch(debugError); + }) + ); + } + + get javascriptEnabled(): boolean { + return this.#javascriptEnabledState.state.javaScriptEnabled; + } + + async emulateViewport(viewport: Viewport): Promise<boolean> { + await this.#viewportState.setState({ + viewport, + active: true, + }); + + const mobile = viewport.isMobile || false; + const hasTouch = viewport.hasTouch || false; + const reloadNeeded = + this.#emulatingMobile !== mobile || this.#hasTouch !== hasTouch; + this.#emulatingMobile = mobile; + this.#hasTouch = hasTouch; + + return reloadNeeded; + } + + @invokeAtMostOnceForArguments + async #applyViewport( + client: CDPSession, + viewportState: ViewportState + ): Promise<void> { + if (!viewportState.viewport) { + return; + } + const {viewport} = viewportState; + const mobile = viewport.isMobile || false; + const width = viewport.width; + const height = viewport.height; + const deviceScaleFactor = viewport.deviceScaleFactor ?? 1; + const screenOrientation: Protocol.Emulation.ScreenOrientation = + viewport.isLandscape + ? {angle: 90, type: 'landscapePrimary'} + : {angle: 0, type: 'portraitPrimary'}; + const hasTouch = viewport.hasTouch || false; + + await Promise.all([ + client.send('Emulation.setDeviceMetricsOverride', { + mobile, + width, + height, + deviceScaleFactor, + screenOrientation, + }), + client.send('Emulation.setTouchEmulationEnabled', { + enabled: hasTouch, + }), + ]); + } + + async emulateIdleState(overrides?: { + isUserActive: boolean; + isScreenUnlocked: boolean; + }): Promise<void> { + await this.#idleOverridesState.setState({ + active: true, + overrides, + }); + } + + @invokeAtMostOnceForArguments + async #emulateIdleState( + client: CDPSession, + idleStateState: IdleOverridesState + ): Promise<void> { + if (!idleStateState.active) { + return; + } + if (idleStateState.overrides) { + await client.send('Emulation.setIdleOverride', { + isUserActive: idleStateState.overrides.isUserActive, + isScreenUnlocked: idleStateState.overrides.isScreenUnlocked, + }); + } else { + await client.send('Emulation.clearIdleOverride'); + } + } + + @invokeAtMostOnceForArguments + async #emulateTimezone( + client: CDPSession, + timezoneState: TimezoneState + ): Promise<void> { + if (!timezoneState.active) { + return; + } + try { + await client.send('Emulation.setTimezoneOverride', { + timezoneId: timezoneState.timezoneId || '', + }); + } catch (error) { + if (isErrorLike(error) && error.message.includes('Invalid timezone')) { + throw new Error(`Invalid timezone ID: ${timezoneState.timezoneId}`); + } + throw error; + } + } + + async emulateTimezone(timezoneId?: string): Promise<void> { + await this.#timezoneState.setState({ + timezoneId, + active: true, + }); + } + + @invokeAtMostOnceForArguments + async #emulateVisionDeficiency( + client: CDPSession, + visionDeficiency: VisionDeficiencyState + ): Promise<void> { + if (!visionDeficiency.active) { + return; + } + await client.send('Emulation.setEmulatedVisionDeficiency', { + type: visionDeficiency.visionDeficiency || 'none', + }); + } + + async emulateVisionDeficiency( + type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'] + ): Promise<void> { + const visionDeficiencies = new Set< + Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'] + >([ + 'none', + 'achromatopsia', + 'blurredVision', + 'deuteranopia', + 'protanopia', + 'tritanopia', + ]); + assert( + !type || visionDeficiencies.has(type), + `Unsupported vision deficiency: ${type}` + ); + await this.#visionDeficiencyState.setState({ + active: true, + visionDeficiency: type, + }); + } + + @invokeAtMostOnceForArguments + async #emulateCpuThrottling( + client: CDPSession, + state: CpuThrottlingState + ): Promise<void> { + if (!state.active) { + return; + } + await client.send('Emulation.setCPUThrottlingRate', { + rate: state.factor ?? 1, + }); + } + + async emulateCPUThrottling(factor: number | null): Promise<void> { + assert( + factor === null || factor >= 1, + 'Throttling rate should be greater or equal to 1' + ); + await this.#cpuThrottlingState.setState({ + active: true, + factor: factor ?? undefined, + }); + } + + @invokeAtMostOnceForArguments + async #emulateMediaFeatures( + client: CDPSession, + state: MediaFeaturesState + ): Promise<void> { + if (!state.active) { + return; + } + await client.send('Emulation.setEmulatedMedia', { + features: state.mediaFeatures, + }); + } + + async emulateMediaFeatures(features?: MediaFeature[]): Promise<void> { + if (Array.isArray(features)) { + for (const mediaFeature of features) { + const name = mediaFeature.name; + assert( + /^(?:prefers-(?:color-scheme|reduced-motion)|color-gamut)$/.test( + name + ), + 'Unsupported media feature: ' + name + ); + } + } + await this.#mediaFeaturesState.setState({ + active: true, + mediaFeatures: features, + }); + } + + @invokeAtMostOnceForArguments + async #emulateMediaType( + client: CDPSession, + state: MediaTypeState + ): Promise<void> { + if (!state.active) { + return; + } + await client.send('Emulation.setEmulatedMedia', { + media: state.type || '', + }); + } + + async emulateMediaType(type?: string): Promise<void> { + assert( + type === 'screen' || + type === 'print' || + (type ?? undefined) === undefined, + 'Unsupported media type: ' + type + ); + await this.#mediaTypeState.setState({ + type, + active: true, + }); + } + + @invokeAtMostOnceForArguments + async #setGeolocation( + client: CDPSession, + state: GeoLocationState + ): Promise<void> { + if (!state.active) { + return; + } + await client.send( + 'Emulation.setGeolocationOverride', + state.geoLocation + ? { + longitude: state.geoLocation.longitude, + latitude: state.geoLocation.latitude, + accuracy: state.geoLocation.accuracy, + } + : undefined + ); + } + + async setGeolocation(options: GeolocationOptions): Promise<void> { + const {longitude, latitude, accuracy = 0} = options; + if (longitude < -180 || longitude > 180) { + throw new Error( + `Invalid longitude "${longitude}": precondition -180 <= LONGITUDE <= 180 failed.` + ); + } + if (latitude < -90 || latitude > 90) { + throw new Error( + `Invalid latitude "${latitude}": precondition -90 <= LATITUDE <= 90 failed.` + ); + } + if (accuracy < 0) { + throw new Error( + `Invalid accuracy "${accuracy}": precondition 0 <= ACCURACY failed.` + ); + } + await this.#geoLocationState.setState({ + active: true, + geoLocation: { + longitude, + latitude, + accuracy, + }, + }); + } + + @invokeAtMostOnceForArguments + async #setDefaultBackgroundColor( + client: CDPSession, + state: DefaultBackgroundColorState + ): Promise<void> { + if (!state.active) { + return; + } + await client.send('Emulation.setDefaultBackgroundColorOverride', { + color: state.color, + }); + } + + /** + * Resets default white background + */ + async resetDefaultBackgroundColor(): Promise<void> { + await this.#defaultBackgroundColorState.setState({ + active: true, + color: undefined, + }); + } + + /** + * Hides default white background + */ + async setTransparentBackgroundColor(): Promise<void> { + await this.#defaultBackgroundColorState.setState({ + active: true, + color: {r: 0, g: 0, b: 0, a: 0}, + }); + } + + @invokeAtMostOnceForArguments + async #setJavaScriptEnabled( + client: CDPSession, + state: JavascriptEnabledState + ): Promise<void> { + if (!state.active) { + return; + } + await client.send('Emulation.setScriptExecutionDisabled', { + value: !state.javaScriptEnabled, + }); + } + + async setJavaScriptEnabled(enabled: boolean): Promise<void> { + await this.#javascriptEnabledState.setState({ + active: true, + javaScriptEnabled: enabled, + }); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ExecutionContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ExecutionContext.ts new file mode 100644 index 0000000000..6efdf8ac76 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ExecutionContext.ts @@ -0,0 +1,392 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import type {ElementHandle} from '../api/ElementHandle.js'; +import type {JSHandle} from '../api/JSHandle.js'; +import {LazyArg} from '../common/LazyArg.js'; +import {scriptInjector} from '../common/ScriptInjector.js'; +import type {EvaluateFunc, HandleFor} from '../common/types.js'; +import { + PuppeteerURL, + SOURCE_URL_REGEX, + getSourcePuppeteerURLIfAvailable, + getSourceUrlComment, + isString, +} from '../common/util.js'; +import type PuppeteerUtil from '../injected/injected.js'; +import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js'; +import {stringifyFunction} from '../util/Function.js'; + +import {ARIAQueryHandler} from './AriaQueryHandler.js'; +import {Binding} from './Binding.js'; +import {CdpElementHandle} from './ElementHandle.js'; +import type {IsolatedWorld} from './IsolatedWorld.js'; +import {CdpJSHandle} from './JSHandle.js'; +import {createEvaluationError, valueFromRemoteObject} from './utils.js'; + +/** + * @internal + */ +export class ExecutionContext { + _client: CDPSession; + _world: IsolatedWorld; + _contextId: number; + _contextName?: string; + + constructor( + client: CDPSession, + contextPayload: Protocol.Runtime.ExecutionContextDescription, + world: IsolatedWorld + ) { + this._client = client; + this._world = world; + this._contextId = contextPayload.id; + if (contextPayload.name) { + this._contextName = contextPayload.name; + } + } + + #bindingsInstalled = false; + #puppeteerUtil?: Promise<JSHandle<PuppeteerUtil>>; + get puppeteerUtil(): Promise<JSHandle<PuppeteerUtil>> { + let promise = Promise.resolve() as Promise<unknown>; + if (!this.#bindingsInstalled) { + promise = Promise.all([ + this.#installGlobalBinding( + new Binding( + '__ariaQuerySelector', + ARIAQueryHandler.queryOne as (...args: unknown[]) => unknown + ) + ), + this.#installGlobalBinding( + new Binding('__ariaQuerySelectorAll', (async ( + element: ElementHandle<Node>, + selector: string + ): Promise<JSHandle<Node[]>> => { + const results = ARIAQueryHandler.queryAll(element, selector); + return await element.realm.evaluateHandle( + (...elements) => { + return elements; + }, + ...(await AsyncIterableUtil.collect(results)) + ); + }) as (...args: unknown[]) => unknown) + ), + ]); + this.#bindingsInstalled = true; + } + scriptInjector.inject(script => { + if (this.#puppeteerUtil) { + void this.#puppeteerUtil.then(handle => { + void handle.dispose(); + }); + } + this.#puppeteerUtil = promise.then(() => { + return this.evaluateHandle(script) as Promise<JSHandle<PuppeteerUtil>>; + }); + }, !this.#puppeteerUtil); + return this.#puppeteerUtil as Promise<JSHandle<PuppeteerUtil>>; + } + + async #installGlobalBinding(binding: Binding) { + try { + if (this._world) { + this._world._bindings.set(binding.name, binding); + await this._world._addBindingToContext(this, binding.name); + } + } catch { + // If the binding cannot be added, then either the browser doesn't support + // bindings (e.g. Firefox) or the context is broken. Either breakage is + // okay, so we ignore the error. + } + } + + /** + * Evaluates the given function. + * + * @example + * + * ```ts + * const executionContext = await page.mainFrame().executionContext(); + * const result = await executionContext.evaluate(() => Promise.resolve(8 * 7))* ; + * console.log(result); // prints "56" + * ``` + * + * @example + * A string can also be passed in instead of a function: + * + * ```ts + * console.log(await executionContext.evaluate('1 + 2')); // prints "3" + * ``` + * + * @example + * Handles can also be passed as `args`. They resolve to their referenced object: + * + * ```ts + * const oneHandle = await executionContext.evaluateHandle(() => 1); + * const twoHandle = await executionContext.evaluateHandle(() => 2); + * const result = await executionContext.evaluate( + * (a, b) => a + b, + * oneHandle, + * twoHandle + * ); + * await oneHandle.dispose(); + * await twoHandle.dispose(); + * console.log(result); // prints '3'. + * ``` + * + * @param pageFunction - The function to evaluate. + * @param args - Additional arguments to pass into the function. + * @returns The result of evaluating the function. If the result is an object, + * a vanilla object containing the serializable properties of the result is + * returned. + */ + async evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + return await this.#evaluate(true, pageFunction, ...args); + } + + /** + * Evaluates the given function. + * + * Unlike {@link ExecutionContext.evaluate | evaluate}, this method returns a + * handle to the result of the function. + * + * This method may be better suited if the object cannot be serialized (e.g. + * `Map`) and requires further manipulation. + * + * @example + * + * ```ts + * const context = await page.mainFrame().executionContext(); + * const handle: JSHandle<typeof globalThis> = await context.evaluateHandle( + * () => Promise.resolve(self) + * ); + * ``` + * + * @example + * A string can also be passed in instead of a function. + * + * ```ts + * const handle: JSHandle<number> = await context.evaluateHandle('1 + 2'); + * ``` + * + * @example + * Handles can also be passed as `args`. They resolve to their referenced object: + * + * ```ts + * const bodyHandle: ElementHandle<HTMLBodyElement> = + * await context.evaluateHandle(() => { + * return document.body; + * }); + * const stringHandle: JSHandle<string> = await context.evaluateHandle( + * body => body.innerHTML, + * body + * ); + * console.log(await stringHandle.jsonValue()); // prints body's innerHTML + * // Always dispose your garbage! :) + * await bodyHandle.dispose(); + * await stringHandle.dispose(); + * ``` + * + * @param pageFunction - The function to evaluate. + * @param args - Additional arguments to pass into the function. + * @returns A {@link JSHandle | handle} to the result of evaluating the + * function. If the result is a `Node`, then this will return an + * {@link ElementHandle | element handle}. + */ + async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + return await this.#evaluate(false, pageFunction, ...args); + } + + async #evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + returnByValue: true, + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>>; + async #evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + returnByValue: false, + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>>; + async #evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, + >( + returnByValue: boolean, + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>> | Awaited<ReturnType<Func>>> { + const sourceUrlComment = getSourceUrlComment( + getSourcePuppeteerURLIfAvailable(pageFunction)?.toString() ?? + PuppeteerURL.INTERNAL_URL + ); + + if (isString(pageFunction)) { + const contextId = this._contextId; + const expression = pageFunction; + const expressionWithSourceUrl = SOURCE_URL_REGEX.test(expression) + ? expression + : `${expression}\n${sourceUrlComment}\n`; + + const {exceptionDetails, result: remoteObject} = await this._client + .send('Runtime.evaluate', { + expression: expressionWithSourceUrl, + contextId, + returnByValue, + awaitPromise: true, + userGesture: true, + }) + .catch(rewriteError); + + if (exceptionDetails) { + throw createEvaluationError(exceptionDetails); + } + + return returnByValue + ? valueFromRemoteObject(remoteObject) + : createCdpHandle(this._world, remoteObject); + } + + const functionDeclaration = stringifyFunction(pageFunction); + const functionDeclarationWithSourceUrl = SOURCE_URL_REGEX.test( + functionDeclaration + ) + ? functionDeclaration + : `${functionDeclaration}\n${sourceUrlComment}\n`; + let callFunctionOnPromise; + try { + callFunctionOnPromise = this._client.send('Runtime.callFunctionOn', { + functionDeclaration: functionDeclarationWithSourceUrl, + executionContextId: this._contextId, + arguments: args.length + ? await Promise.all(args.map(convertArgument.bind(this))) + : [], + returnByValue, + awaitPromise: true, + userGesture: true, + }); + } catch (error) { + if ( + error instanceof TypeError && + error.message.startsWith('Converting circular structure to JSON') + ) { + error.message += ' Recursive objects are not allowed.'; + } + throw error; + } + const {exceptionDetails, result: remoteObject} = + await callFunctionOnPromise.catch(rewriteError); + if (exceptionDetails) { + throw createEvaluationError(exceptionDetails); + } + return returnByValue + ? valueFromRemoteObject(remoteObject) + : createCdpHandle(this._world, remoteObject); + + async function convertArgument( + this: ExecutionContext, + arg: unknown + ): Promise<Protocol.Runtime.CallArgument> { + if (arg instanceof LazyArg) { + arg = await arg.get(this); + } + if (typeof arg === 'bigint') { + // eslint-disable-line valid-typeof + return {unserializableValue: `${arg.toString()}n`}; + } + if (Object.is(arg, -0)) { + return {unserializableValue: '-0'}; + } + if (Object.is(arg, Infinity)) { + return {unserializableValue: 'Infinity'}; + } + if (Object.is(arg, -Infinity)) { + return {unserializableValue: '-Infinity'}; + } + if (Object.is(arg, NaN)) { + return {unserializableValue: 'NaN'}; + } + const objectHandle = + arg && (arg instanceof CdpJSHandle || arg instanceof CdpElementHandle) + ? arg + : null; + if (objectHandle) { + if (objectHandle.realm !== this._world) { + throw new Error( + 'JSHandles can be evaluated only in the context they were created!' + ); + } + if (objectHandle.disposed) { + throw new Error('JSHandle is disposed!'); + } + if (objectHandle.remoteObject().unserializableValue) { + return { + unserializableValue: + objectHandle.remoteObject().unserializableValue, + }; + } + if (!objectHandle.remoteObject().objectId) { + return {value: objectHandle.remoteObject().value}; + } + return {objectId: objectHandle.remoteObject().objectId}; + } + return {value: arg}; + } + } +} + +const rewriteError = (error: Error): Protocol.Runtime.EvaluateResponse => { + if (error.message.includes('Object reference chain is too long')) { + return {result: {type: 'undefined'}}; + } + if (error.message.includes("Object couldn't be returned by value")) { + return {result: {type: 'undefined'}}; + } + + if ( + error.message.endsWith('Cannot find context with specified id') || + error.message.endsWith('Inspected target navigated or closed') + ) { + throw new Error( + 'Execution context was destroyed, most likely because of a navigation.' + ); + } + throw error; +}; + +/** + * @internal + */ +export function createCdpHandle( + realm: IsolatedWorld, + remoteObject: Protocol.Runtime.RemoteObject +): JSHandle | ElementHandle<Node> { + if (remoteObject.subtype === 'node') { + return new CdpElementHandle(realm, remoteObject); + } + return new CdpJSHandle(realm, remoteObject); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FirefoxTargetManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FirefoxTargetManager.ts new file mode 100644 index 0000000000..0ef09a0093 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FirefoxTargetManager.ts @@ -0,0 +1,210 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {TargetFilterCallback} from '../api/Browser.js'; +import {type CDPSession, CDPSessionEvent} from '../api/CDPSession.js'; +import {EventEmitter} from '../common/EventEmitter.js'; +import {assert} from '../util/assert.js'; +import {Deferred} from '../util/Deferred.js'; + +import type {CdpCDPSession} from './CDPSession.js'; +import type {Connection} from './Connection.js'; +import type {CdpTarget} from './Target.js'; +import { + type TargetFactory, + TargetManagerEvent, + type TargetManager, + type TargetManagerEvents, +} from './TargetManager.js'; + +/** + * FirefoxTargetManager implements target management using + * `Target.setDiscoverTargets` without using auto-attach. It, therefore, creates + * targets that lazily establish their CDP sessions. + * + * Although the approach is potentially flaky, there is no other way for Firefox + * because Firefox's CDP implementation does not support auto-attach. + * + * Firefox does not support targetInfoChanged and detachedFromTarget events: + * + * - https://bugzilla.mozilla.org/show_bug.cgi?id=1610855 + * - https://bugzilla.mozilla.org/show_bug.cgi?id=1636979 + * @internal + */ +export class FirefoxTargetManager + extends EventEmitter<TargetManagerEvents> + implements TargetManager +{ + #connection: Connection; + /** + * Keeps track of the following events: 'Target.targetCreated', + * 'Target.targetDestroyed'. + * + * A target becomes discovered when 'Target.targetCreated' is received. + * A target is removed from this map once 'Target.targetDestroyed' is + * received. + * + * `targetFilterCallback` has no effect on this map. + */ + #discoveredTargetsByTargetId = new Map<string, Protocol.Target.TargetInfo>(); + /** + * Keeps track of targets that were created via 'Target.targetCreated' + * and which one are not filtered out by `targetFilterCallback`. + * + * The target is removed from here once it's been destroyed. + */ + #availableTargetsByTargetId = new Map<string, CdpTarget>(); + /** + * Tracks which sessions attach to which target. + */ + #availableTargetsBySessionId = new Map<string, CdpTarget>(); + #targetFilterCallback: TargetFilterCallback | undefined; + #targetFactory: TargetFactory; + + #attachedToTargetListenersBySession = new WeakMap< + CDPSession | Connection, + (event: Protocol.Target.AttachedToTargetEvent) => Promise<void> + >(); + + #initializeDeferred = Deferred.create<void>(); + #targetsIdsForInit = new Set<string>(); + + constructor( + connection: Connection, + targetFactory: TargetFactory, + targetFilterCallback?: TargetFilterCallback + ) { + super(); + this.#connection = connection; + this.#targetFilterCallback = targetFilterCallback; + this.#targetFactory = targetFactory; + + this.#connection.on('Target.targetCreated', this.#onTargetCreated); + this.#connection.on('Target.targetDestroyed', this.#onTargetDestroyed); + this.#connection.on( + CDPSessionEvent.SessionDetached, + this.#onSessionDetached + ); + this.setupAttachmentListeners(this.#connection); + } + + setupAttachmentListeners(session: CDPSession | Connection): void { + const listener = (event: Protocol.Target.AttachedToTargetEvent) => { + return this.#onAttachedToTarget(session, event); + }; + assert(!this.#attachedToTargetListenersBySession.has(session)); + this.#attachedToTargetListenersBySession.set(session, listener); + session.on('Target.attachedToTarget', listener); + } + + #onSessionDetached = (session: CDPSession) => { + this.removeSessionListeners(session); + this.#availableTargetsBySessionId.delete(session.id()); + }; + + removeSessionListeners(session: CDPSession): void { + if (this.#attachedToTargetListenersBySession.has(session)) { + session.off( + 'Target.attachedToTarget', + this.#attachedToTargetListenersBySession.get(session)! + ); + this.#attachedToTargetListenersBySession.delete(session); + } + } + + getAvailableTargets(): ReadonlyMap<string, CdpTarget> { + return this.#availableTargetsByTargetId; + } + + dispose(): void { + this.#connection.off('Target.targetCreated', this.#onTargetCreated); + this.#connection.off('Target.targetDestroyed', this.#onTargetDestroyed); + } + + async initialize(): Promise<void> { + await this.#connection.send('Target.setDiscoverTargets', { + discover: true, + filter: [{}], + }); + this.#targetsIdsForInit = new Set(this.#discoveredTargetsByTargetId.keys()); + await this.#initializeDeferred.valueOrThrow(); + } + + #onTargetCreated = async ( + event: Protocol.Target.TargetCreatedEvent + ): Promise<void> => { + if (this.#discoveredTargetsByTargetId.has(event.targetInfo.targetId)) { + return; + } + + this.#discoveredTargetsByTargetId.set( + event.targetInfo.targetId, + event.targetInfo + ); + + if (event.targetInfo.type === 'browser' && event.targetInfo.attached) { + const target = this.#targetFactory(event.targetInfo, undefined); + target._initialize(); + this.#availableTargetsByTargetId.set(event.targetInfo.targetId, target); + this.#finishInitializationIfReady(target._targetId); + return; + } + + const target = this.#targetFactory(event.targetInfo, undefined); + if (this.#targetFilterCallback && !this.#targetFilterCallback(target)) { + this.#finishInitializationIfReady(event.targetInfo.targetId); + return; + } + target._initialize(); + this.#availableTargetsByTargetId.set(event.targetInfo.targetId, target); + this.emit(TargetManagerEvent.TargetAvailable, target); + this.#finishInitializationIfReady(target._targetId); + }; + + #onTargetDestroyed = (event: Protocol.Target.TargetDestroyedEvent): void => { + this.#discoveredTargetsByTargetId.delete(event.targetId); + this.#finishInitializationIfReady(event.targetId); + const target = this.#availableTargetsByTargetId.get(event.targetId); + if (target) { + this.emit(TargetManagerEvent.TargetGone, target); + this.#availableTargetsByTargetId.delete(event.targetId); + } + }; + + #onAttachedToTarget = async ( + parentSession: Connection | CDPSession, + event: Protocol.Target.AttachedToTargetEvent + ) => { + const targetInfo = event.targetInfo; + const session = this.#connection.session(event.sessionId); + if (!session) { + throw new Error(`Session ${event.sessionId} was not created.`); + } + + const target = this.#availableTargetsByTargetId.get(targetInfo.targetId); + + assert(target, `Target ${targetInfo.targetId} is missing`); + + (session as CdpCDPSession)._setTarget(target); + this.setupAttachmentListeners(session); + + this.#availableTargetsBySessionId.set( + session.id(), + this.#availableTargetsByTargetId.get(targetInfo.targetId)! + ); + + parentSession.emit(CDPSessionEvent.Ready, session); + }; + + #finishInitializationIfReady(targetId: string): void { + this.#targetsIdsForInit.delete(targetId); + if (this.#targetsIdsForInit.size === 0) { + this.#initializeDeferred.resolve(); + } + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Frame.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Frame.ts new file mode 100644 index 0000000000..844120d7ff --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Frame.ts @@ -0,0 +1,351 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import {Frame, FrameEvent, throwIfDetached} from '../api/Frame.js'; +import type {HTTPResponse} from '../api/HTTPResponse.js'; +import type {WaitTimeoutOptions} from '../api/Page.js'; +import {UnsupportedOperation} from '../common/Errors.js'; +import {Deferred} from '../util/Deferred.js'; +import {disposeSymbol} from '../util/disposable.js'; +import {isErrorLike} from '../util/ErrorLike.js'; + +import type { + DeviceRequestPrompt, + DeviceRequestPromptManager, +} from './DeviceRequestPrompt.js'; +import type {FrameManager} from './FrameManager.js'; +import {IsolatedWorld} from './IsolatedWorld.js'; +import {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js'; +import { + LifecycleWatcher, + type PuppeteerLifeCycleEvent, +} from './LifecycleWatcher.js'; +import type {CdpPage} from './Page.js'; + +/** + * @internal + */ +export class CdpFrame extends Frame { + #url = ''; + #detached = false; + #client!: CDPSession; + + _frameManager: FrameManager; + override _id: string; + _loaderId = ''; + _lifecycleEvents = new Set<string>(); + override _parentId?: string; + + constructor( + frameManager: FrameManager, + frameId: string, + parentFrameId: string | undefined, + client: CDPSession + ) { + super(); + this._frameManager = frameManager; + this.#url = ''; + this._id = frameId; + this._parentId = parentFrameId; + this.#detached = false; + + this._loaderId = ''; + + this.updateClient(client); + + this.on(FrameEvent.FrameSwappedByActivation, () => { + // Emulate loading process for swapped frames. + this._onLoadingStarted(); + this._onLoadingStopped(); + }); + } + + /** + * This is used internally in DevTools. + * + * @internal + */ + _client(): CDPSession { + return this.#client; + } + + /** + * Updates the frame ID with the new ID. This happens when the main frame is + * replaced by a different frame. + */ + updateId(id: string): void { + this._id = id; + } + + updateClient(client: CDPSession, keepWorlds = false): void { + this.#client = client; + if (!keepWorlds) { + // Clear the current contexts on previous world instances. + if (this.worlds) { + this.worlds[MAIN_WORLD].clearContext(); + this.worlds[PUPPETEER_WORLD].clearContext(); + } + this.worlds = { + [MAIN_WORLD]: new IsolatedWorld( + this, + this._frameManager.timeoutSettings + ), + [PUPPETEER_WORLD]: new IsolatedWorld( + this, + this._frameManager.timeoutSettings + ), + }; + } else { + this.worlds[MAIN_WORLD].frameUpdated(); + this.worlds[PUPPETEER_WORLD].frameUpdated(); + } + } + + override page(): CdpPage { + return this._frameManager.page(); + } + + override isOOPFrame(): boolean { + return this.#client !== this._frameManager.client; + } + + @throwIfDetached + override async goto( + url: string, + options: { + referer?: string; + referrerPolicy?: string; + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; + } = {} + ): Promise<HTTPResponse | null> { + const { + referer = this._frameManager.networkManager.extraHTTPHeaders()['referer'], + referrerPolicy = this._frameManager.networkManager.extraHTTPHeaders()[ + 'referer-policy' + ], + waitUntil = ['load'], + timeout = this._frameManager.timeoutSettings.navigationTimeout(), + } = options; + + let ensureNewDocumentNavigation = false; + const watcher = new LifecycleWatcher( + this._frameManager.networkManager, + this, + waitUntil, + timeout + ); + let error = await Deferred.race([ + navigate( + this.#client, + url, + referer, + referrerPolicy as Protocol.Page.ReferrerPolicy, + this._id + ), + watcher.terminationPromise(), + ]); + if (!error) { + error = await Deferred.race([ + watcher.terminationPromise(), + ensureNewDocumentNavigation + ? watcher.newDocumentNavigationPromise() + : watcher.sameDocumentNavigationPromise(), + ]); + } + + try { + if (error) { + throw error; + } + return await watcher.navigationResponse(); + } finally { + watcher.dispose(); + } + + async function navigate( + client: CDPSession, + url: string, + referrer: string | undefined, + referrerPolicy: Protocol.Page.ReferrerPolicy | undefined, + frameId: string + ): Promise<Error | null> { + try { + const response = await client.send('Page.navigate', { + url, + referrer, + frameId, + referrerPolicy, + }); + ensureNewDocumentNavigation = !!response.loaderId; + if (response.errorText === 'net::ERR_HTTP_RESPONSE_CODE_FAILURE') { + return null; + } + return response.errorText + ? new Error(`${response.errorText} at ${url}`) + : null; + } catch (error) { + if (isErrorLike(error)) { + return error; + } + throw error; + } + } + } + + @throwIfDetached + override async waitForNavigation( + options: { + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; + } = {} + ): Promise<HTTPResponse | null> { + const { + waitUntil = ['load'], + timeout = this._frameManager.timeoutSettings.navigationTimeout(), + } = options; + const watcher = new LifecycleWatcher( + this._frameManager.networkManager, + this, + waitUntil, + timeout + ); + const error = await Deferred.race([ + watcher.terminationPromise(), + watcher.sameDocumentNavigationPromise(), + watcher.newDocumentNavigationPromise(), + ]); + try { + if (error) { + throw error; + } + return await watcher.navigationResponse(); + } finally { + watcher.dispose(); + } + } + + override get client(): CDPSession { + return this.#client; + } + + override mainRealm(): IsolatedWorld { + return this.worlds[MAIN_WORLD]; + } + + override isolatedRealm(): IsolatedWorld { + return this.worlds[PUPPETEER_WORLD]; + } + + @throwIfDetached + override async setContent( + html: string, + options: { + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; + } = {} + ): Promise<void> { + const { + waitUntil = ['load'], + timeout = this._frameManager.timeoutSettings.navigationTimeout(), + } = options; + + // We rely upon the fact that document.open() will reset frame lifecycle with "init" + // lifecycle event. @see https://crrev.com/608658 + await this.setFrameContent(html); + + const watcher = new LifecycleWatcher( + this._frameManager.networkManager, + this, + waitUntil, + timeout + ); + const error = await Deferred.race<void | Error | undefined>([ + watcher.terminationPromise(), + watcher.lifecyclePromise(), + ]); + watcher.dispose(); + if (error) { + throw error; + } + } + + override url(): string { + return this.#url; + } + + override parentFrame(): CdpFrame | null { + return this._frameManager._frameTree.parentFrame(this._id) || null; + } + + override childFrames(): CdpFrame[] { + return this._frameManager._frameTree.childFrames(this._id); + } + + #deviceRequestPromptManager(): DeviceRequestPromptManager { + const rootFrame = this.page().mainFrame(); + if (this.isOOPFrame() || rootFrame === null) { + return this._frameManager._deviceRequestPromptManager(this.#client); + } else { + return rootFrame._frameManager._deviceRequestPromptManager(this.#client); + } + } + + @throwIfDetached + override async waitForDevicePrompt( + options: WaitTimeoutOptions = {} + ): Promise<DeviceRequestPrompt> { + return await this.#deviceRequestPromptManager().waitForDevicePrompt( + options + ); + } + + _navigated(framePayload: Protocol.Page.Frame): void { + this._name = framePayload.name; + this.#url = `${framePayload.url}${framePayload.urlFragment || ''}`; + } + + _navigatedWithinDocument(url: string): void { + this.#url = url; + } + + _onLifecycleEvent(loaderId: string, name: string): void { + if (name === 'init') { + this._loaderId = loaderId; + this._lifecycleEvents.clear(); + } + this._lifecycleEvents.add(name); + } + + _onLoadingStopped(): void { + this._lifecycleEvents.add('DOMContentLoaded'); + this._lifecycleEvents.add('load'); + } + + _onLoadingStarted(): void { + this._hasStartedLoading = true; + } + + override get detached(): boolean { + return this.#detached; + } + + [disposeSymbol](): void { + if (this.#detached) { + return; + } + this.#detached = true; + this.worlds[MAIN_WORLD][disposeSymbol](); + this.worlds[PUPPETEER_WORLD][disposeSymbol](); + } + + exposeFunction(): never { + throw new UnsupportedOperation(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameManager.ts new file mode 100644 index 0000000000..48ed9ac2f5 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameManager.ts @@ -0,0 +1,551 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import {type CDPSession, CDPSessionEvent} from '../api/CDPSession.js'; +import {FrameEvent} from '../api/Frame.js'; +import {EventEmitter} from '../common/EventEmitter.js'; +import type {TimeoutSettings} from '../common/TimeoutSettings.js'; +import {debugError, PuppeteerURL, UTILITY_WORLD_NAME} from '../common/util.js'; +import {assert} from '../util/assert.js'; +import {Deferred} from '../util/Deferred.js'; +import {disposeSymbol} from '../util/disposable.js'; +import {isErrorLike} from '../util/ErrorLike.js'; + +import {CdpCDPSession} from './CDPSession.js'; +import {isTargetClosedError} from './Connection.js'; +import {DeviceRequestPromptManager} from './DeviceRequestPrompt.js'; +import {ExecutionContext} from './ExecutionContext.js'; +import {CdpFrame} from './Frame.js'; +import type {FrameManagerEvents} from './FrameManagerEvents.js'; +import {FrameManagerEvent} from './FrameManagerEvents.js'; +import {FrameTree} from './FrameTree.js'; +import type {IsolatedWorld} from './IsolatedWorld.js'; +import {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js'; +import {NetworkManager} from './NetworkManager.js'; +import type {CdpPage} from './Page.js'; +import type {CdpTarget} from './Target.js'; + +const TIME_FOR_WAITING_FOR_SWAP = 100; // ms. + +/** + * A frame manager manages the frames for a given {@link Page | page}. + * + * @internal + */ +export class FrameManager extends EventEmitter<FrameManagerEvents> { + #page: CdpPage; + #networkManager: NetworkManager; + #timeoutSettings: TimeoutSettings; + #contextIdToContext = new Map<string, ExecutionContext>(); + #isolatedWorlds = new Set<string>(); + #client: CDPSession; + + _frameTree = new FrameTree<CdpFrame>(); + + /** + * Set of frame IDs stored to indicate if a frame has received a + * frameNavigated event so that frame tree responses could be ignored as the + * frameNavigated event usually contains the latest information. + */ + #frameNavigatedReceived = new Set<string>(); + + #deviceRequestPromptManagerMap = new WeakMap< + CDPSession, + DeviceRequestPromptManager + >(); + + #frameTreeHandled?: Deferred<void>; + + get timeoutSettings(): TimeoutSettings { + return this.#timeoutSettings; + } + + get networkManager(): NetworkManager { + return this.#networkManager; + } + + get client(): CDPSession { + return this.#client; + } + + constructor( + client: CDPSession, + page: CdpPage, + ignoreHTTPSErrors: boolean, + timeoutSettings: TimeoutSettings + ) { + super(); + this.#client = client; + this.#page = page; + this.#networkManager = new NetworkManager(ignoreHTTPSErrors, this); + this.#timeoutSettings = timeoutSettings; + this.setupEventListeners(this.#client); + client.once(CDPSessionEvent.Disconnected, () => { + this.#onClientDisconnect().catch(debugError); + }); + } + + /** + * Called when the frame's client is disconnected. We don't know if the + * disconnect means that the frame is removed or if it will be replaced by a + * new frame. Therefore, we wait for a swap event. + */ + async #onClientDisconnect() { + const mainFrame = this._frameTree.getMainFrame(); + if (!mainFrame) { + return; + } + for (const child of mainFrame.childFrames()) { + this.#removeFramesRecursively(child); + } + const swapped = Deferred.create<void>({ + timeout: TIME_FOR_WAITING_FOR_SWAP, + message: 'Frame was not swapped', + }); + mainFrame.once(FrameEvent.FrameSwappedByActivation, () => { + swapped.resolve(); + }); + try { + await swapped.valueOrThrow(); + } catch (err) { + this.#removeFramesRecursively(mainFrame); + } + } + + /** + * When the main frame is replaced by another main frame, + * we maintain the main frame object identity while updating + * its frame tree and ID. + */ + async swapFrameTree(client: CDPSession): Promise<void> { + this.#onExecutionContextsCleared(this.#client); + + this.#client = client; + assert( + this.#client instanceof CdpCDPSession, + 'CDPSession is not an instance of CDPSessionImpl.' + ); + const frame = this._frameTree.getMainFrame(); + if (frame) { + this.#frameNavigatedReceived.add(this.#client._target()._targetId); + this._frameTree.removeFrame(frame); + frame.updateId(this.#client._target()._targetId); + frame.mainRealm().clearContext(); + frame.isolatedRealm().clearContext(); + this._frameTree.addFrame(frame); + frame.updateClient(client, true); + } + this.setupEventListeners(client); + client.once(CDPSessionEvent.Disconnected, () => { + this.#onClientDisconnect().catch(debugError); + }); + await this.initialize(client); + await this.#networkManager.addClient(client); + if (frame) { + frame.emit(FrameEvent.FrameSwappedByActivation, undefined); + } + } + + async registerSpeculativeSession(client: CdpCDPSession): Promise<void> { + await this.#networkManager.addClient(client); + } + + private setupEventListeners(session: CDPSession) { + session.on('Page.frameAttached', async event => { + await this.#frameTreeHandled?.valueOrThrow(); + this.#onFrameAttached(session, event.frameId, event.parentFrameId); + }); + session.on('Page.frameNavigated', async event => { + this.#frameNavigatedReceived.add(event.frame.id); + await this.#frameTreeHandled?.valueOrThrow(); + void this.#onFrameNavigated(event.frame, event.type); + }); + session.on('Page.navigatedWithinDocument', async event => { + await this.#frameTreeHandled?.valueOrThrow(); + this.#onFrameNavigatedWithinDocument(event.frameId, event.url); + }); + session.on( + 'Page.frameDetached', + async (event: Protocol.Page.FrameDetachedEvent) => { + await this.#frameTreeHandled?.valueOrThrow(); + this.#onFrameDetached( + event.frameId, + event.reason as Protocol.Page.FrameDetachedEventReason + ); + } + ); + session.on('Page.frameStartedLoading', async event => { + await this.#frameTreeHandled?.valueOrThrow(); + this.#onFrameStartedLoading(event.frameId); + }); + session.on('Page.frameStoppedLoading', async event => { + await this.#frameTreeHandled?.valueOrThrow(); + this.#onFrameStoppedLoading(event.frameId); + }); + session.on('Runtime.executionContextCreated', async event => { + await this.#frameTreeHandled?.valueOrThrow(); + this.#onExecutionContextCreated(event.context, session); + }); + session.on('Runtime.executionContextDestroyed', async event => { + await this.#frameTreeHandled?.valueOrThrow(); + this.#onExecutionContextDestroyed(event.executionContextId, session); + }); + session.on('Runtime.executionContextsCleared', async () => { + await this.#frameTreeHandled?.valueOrThrow(); + this.#onExecutionContextsCleared(session); + }); + session.on('Page.lifecycleEvent', async event => { + await this.#frameTreeHandled?.valueOrThrow(); + this.#onLifecycleEvent(event); + }); + } + + async initialize(client: CDPSession): Promise<void> { + try { + this.#frameTreeHandled?.resolve(); + this.#frameTreeHandled = Deferred.create(); + // We need to schedule all these commands while the target is paused, + // therefore, it needs to happen synchroniously. At the same time we + // should not start processing execution context and frame events before + // we received the initial information about the frame tree. + await Promise.all([ + this.#networkManager.addClient(client), + client.send('Page.enable'), + client.send('Page.getFrameTree').then(({frameTree}) => { + this.#handleFrameTree(client, frameTree); + this.#frameTreeHandled?.resolve(); + }), + client.send('Page.setLifecycleEventsEnabled', {enabled: true}), + client.send('Runtime.enable').then(() => { + return this.#createIsolatedWorld(client, UTILITY_WORLD_NAME); + }), + ]); + } catch (error) { + this.#frameTreeHandled?.resolve(); + // The target might have been closed before the initialization finished. + if (isErrorLike(error) && isTargetClosedError(error)) { + return; + } + + throw error; + } + } + + executionContextById( + contextId: number, + session: CDPSession = this.#client + ): ExecutionContext { + const context = this.getExecutionContextById(contextId, session); + assert(context, 'INTERNAL ERROR: missing context with id = ' + contextId); + return context; + } + + getExecutionContextById( + contextId: number, + session: CDPSession = this.#client + ): ExecutionContext | undefined { + return this.#contextIdToContext.get(`${session.id()}:${contextId}`); + } + + page(): CdpPage { + return this.#page; + } + + mainFrame(): CdpFrame { + const mainFrame = this._frameTree.getMainFrame(); + assert(mainFrame, 'Requesting main frame too early!'); + return mainFrame; + } + + frames(): CdpFrame[] { + return Array.from(this._frameTree.frames()); + } + + frame(frameId: string): CdpFrame | null { + return this._frameTree.getById(frameId) || null; + } + + onAttachedToTarget(target: CdpTarget): void { + if (target._getTargetInfo().type !== 'iframe') { + return; + } + + const frame = this.frame(target._getTargetInfo().targetId); + if (frame) { + frame.updateClient(target._session()!); + } + this.setupEventListeners(target._session()!); + void this.initialize(target._session()!); + } + + _deviceRequestPromptManager(client: CDPSession): DeviceRequestPromptManager { + let manager = this.#deviceRequestPromptManagerMap.get(client); + if (manager === undefined) { + manager = new DeviceRequestPromptManager(client, this.#timeoutSettings); + this.#deviceRequestPromptManagerMap.set(client, manager); + } + return manager; + } + + #onLifecycleEvent(event: Protocol.Page.LifecycleEventEvent): void { + const frame = this.frame(event.frameId); + if (!frame) { + return; + } + frame._onLifecycleEvent(event.loaderId, event.name); + this.emit(FrameManagerEvent.LifecycleEvent, frame); + frame.emit(FrameEvent.LifecycleEvent, undefined); + } + + #onFrameStartedLoading(frameId: string): void { + const frame = this.frame(frameId); + if (!frame) { + return; + } + frame._onLoadingStarted(); + } + + #onFrameStoppedLoading(frameId: string): void { + const frame = this.frame(frameId); + if (!frame) { + return; + } + frame._onLoadingStopped(); + this.emit(FrameManagerEvent.LifecycleEvent, frame); + frame.emit(FrameEvent.LifecycleEvent, undefined); + } + + #handleFrameTree( + session: CDPSession, + frameTree: Protocol.Page.FrameTree + ): void { + if (frameTree.frame.parentId) { + this.#onFrameAttached( + session, + frameTree.frame.id, + frameTree.frame.parentId + ); + } + if (!this.#frameNavigatedReceived.has(frameTree.frame.id)) { + void this.#onFrameNavigated(frameTree.frame, 'Navigation'); + } else { + this.#frameNavigatedReceived.delete(frameTree.frame.id); + } + + if (!frameTree.childFrames) { + return; + } + + for (const child of frameTree.childFrames) { + this.#handleFrameTree(session, child); + } + } + + #onFrameAttached( + session: CDPSession, + frameId: string, + parentFrameId: string + ): void { + let frame = this.frame(frameId); + if (frame) { + if (session && frame.isOOPFrame()) { + // If an OOP iframes becomes a normal iframe again + // it is first attached to the parent page before + // the target is removed. + frame.updateClient(session); + } + return; + } + + frame = new CdpFrame(this, frameId, parentFrameId, session); + this._frameTree.addFrame(frame); + this.emit(FrameManagerEvent.FrameAttached, frame); + } + + async #onFrameNavigated( + framePayload: Protocol.Page.Frame, + navigationType: Protocol.Page.NavigationType + ): Promise<void> { + const frameId = framePayload.id; + const isMainFrame = !framePayload.parentId; + + let frame = this._frameTree.getById(frameId); + + // Detach all child frames first. + if (frame) { + for (const child of frame.childFrames()) { + this.#removeFramesRecursively(child); + } + } + + // Update or create main frame. + if (isMainFrame) { + if (frame) { + // Update frame id to retain frame identity on cross-process navigation. + this._frameTree.removeFrame(frame); + frame._id = frameId; + } else { + // Initial main frame navigation. + frame = new CdpFrame(this, frameId, undefined, this.#client); + } + this._frameTree.addFrame(frame); + } + + frame = await this._frameTree.waitForFrame(frameId); + frame._navigated(framePayload); + this.emit(FrameManagerEvent.FrameNavigated, frame); + frame.emit(FrameEvent.FrameNavigated, navigationType); + } + + async #createIsolatedWorld(session: CDPSession, name: string): Promise<void> { + const key = `${session.id()}:${name}`; + + if (this.#isolatedWorlds.has(key)) { + return; + } + + await session.send('Page.addScriptToEvaluateOnNewDocument', { + source: `//# sourceURL=${PuppeteerURL.INTERNAL_URL}`, + worldName: name, + }); + + await Promise.all( + this.frames() + .filter(frame => { + return frame.client === session; + }) + .map(frame => { + // Frames might be removed before we send this, so we don't want to + // throw an error. + return session + .send('Page.createIsolatedWorld', { + frameId: frame._id, + worldName: name, + grantUniveralAccess: true, + }) + .catch(debugError); + }) + ); + + this.#isolatedWorlds.add(key); + } + + #onFrameNavigatedWithinDocument(frameId: string, url: string): void { + const frame = this.frame(frameId); + if (!frame) { + return; + } + frame._navigatedWithinDocument(url); + this.emit(FrameManagerEvent.FrameNavigatedWithinDocument, frame); + frame.emit(FrameEvent.FrameNavigatedWithinDocument, undefined); + this.emit(FrameManagerEvent.FrameNavigated, frame); + frame.emit(FrameEvent.FrameNavigated, 'Navigation'); + } + + #onFrameDetached( + frameId: string, + reason: Protocol.Page.FrameDetachedEventReason + ): void { + const frame = this.frame(frameId); + if (!frame) { + return; + } + switch (reason) { + case 'remove': + // Only remove the frame if the reason for the detached event is + // an actual removement of the frame. + // For frames that become OOP iframes, the reason would be 'swap'. + this.#removeFramesRecursively(frame); + break; + case 'swap': + this.emit(FrameManagerEvent.FrameSwapped, frame); + frame.emit(FrameEvent.FrameSwapped, undefined); + break; + } + } + + #onExecutionContextCreated( + contextPayload: Protocol.Runtime.ExecutionContextDescription, + session: CDPSession + ): void { + const auxData = contextPayload.auxData as {frameId?: string} | undefined; + const frameId = auxData && auxData.frameId; + const frame = typeof frameId === 'string' ? this.frame(frameId) : undefined; + let world: IsolatedWorld | undefined; + if (frame) { + // Only care about execution contexts created for the current session. + if (frame.client !== session) { + return; + } + if (contextPayload.auxData && contextPayload.auxData['isDefault']) { + world = frame.worlds[MAIN_WORLD]; + } else if ( + contextPayload.name === UTILITY_WORLD_NAME && + !frame.worlds[PUPPETEER_WORLD].hasContext() + ) { + // In case of multiple sessions to the same target, there's a race between + // connections so we might end up creating multiple isolated worlds. + // We can use either. + world = frame.worlds[PUPPETEER_WORLD]; + } + } + // If there is no world, the context is not meant to be handled by us. + if (!world) { + return; + } + const context = new ExecutionContext( + frame?.client || this.#client, + contextPayload, + world + ); + if (world) { + world.setContext(context); + } + const key = `${session.id()}:${contextPayload.id}`; + this.#contextIdToContext.set(key, context); + } + + #onExecutionContextDestroyed( + executionContextId: number, + session: CDPSession + ): void { + const key = `${session.id()}:${executionContextId}`; + const context = this.#contextIdToContext.get(key); + if (!context) { + return; + } + this.#contextIdToContext.delete(key); + if (context._world) { + context._world.clearContext(); + } + } + + #onExecutionContextsCleared(session: CDPSession): void { + for (const [key, context] of this.#contextIdToContext.entries()) { + // Make sure to only clear execution contexts that belong + // to the current session. + if (context._client !== session) { + continue; + } + if (context._world) { + context._world.clearContext(); + } + this.#contextIdToContext.delete(key); + } + } + + #removeFramesRecursively(frame: CdpFrame): void { + for (const child of frame.childFrames()) { + this.#removeFramesRecursively(child); + } + frame[disposeSymbol](); + this._frameTree.removeFrame(frame); + this.emit(FrameManagerEvent.FrameDetached, frame); + frame.emit(FrameEvent.FrameDetached, frame); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameManagerEvents.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameManagerEvents.ts new file mode 100644 index 0000000000..645dd86d71 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameManagerEvents.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {EventType} from '../common/EventEmitter.js'; + +import type {CdpFrame} from './Frame.js'; + +/** + * We use symbols to prevent external parties listening to these events. + * They are internal to Puppeteer. + * + * @internal + */ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace FrameManagerEvent { + export const FrameAttached = Symbol('FrameManager.FrameAttached'); + export const FrameNavigated = Symbol('FrameManager.FrameNavigated'); + export const FrameDetached = Symbol('FrameManager.FrameDetached'); + export const FrameSwapped = Symbol('FrameManager.FrameSwapped'); + export const LifecycleEvent = Symbol('FrameManager.LifecycleEvent'); + export const FrameNavigatedWithinDocument = Symbol( + 'FrameManager.FrameNavigatedWithinDocument' + ); +} + +/** + * @internal + */ +export interface FrameManagerEvents extends Record<EventType, unknown> { + [FrameManagerEvent.FrameAttached]: CdpFrame; + [FrameManagerEvent.FrameNavigated]: CdpFrame; + [FrameManagerEvent.FrameDetached]: CdpFrame; + [FrameManagerEvent.FrameSwapped]: CdpFrame; + [FrameManagerEvent.LifecycleEvent]: CdpFrame; + [FrameManagerEvent.FrameNavigatedWithinDocument]: CdpFrame; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameTree.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameTree.ts new file mode 100644 index 0000000000..7ee1b86b5f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameTree.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Frame} from '../api/Frame.js'; +import {Deferred} from '../util/Deferred.js'; + +/** + * Keeps track of the page frame tree and it's is managed by + * {@link FrameManager}. FrameTree uses frame IDs to reference frame and it + * means that referenced frames might not be in the tree anymore. Thus, the tree + * structure is eventually consistent. + * @internal + */ +export class FrameTree<FrameType extends Frame> { + #frames = new Map<string, FrameType>(); + // frameID -> parentFrameID + #parentIds = new Map<string, string>(); + // frameID -> childFrameIDs + #childIds = new Map<string, Set<string>>(); + #mainFrame?: FrameType; + #waitRequests = new Map<string, Set<Deferred<FrameType>>>(); + + getMainFrame(): FrameType | undefined { + return this.#mainFrame; + } + + getById(frameId: string): FrameType | undefined { + return this.#frames.get(frameId); + } + + /** + * Returns a promise that is resolved once the frame with + * the given ID is added to the tree. + */ + waitForFrame(frameId: string): Promise<FrameType> { + const frame = this.getById(frameId); + if (frame) { + return Promise.resolve(frame); + } + const deferred = Deferred.create<FrameType>(); + const callbacks = + this.#waitRequests.get(frameId) || new Set<Deferred<FrameType>>(); + callbacks.add(deferred); + return deferred.valueOrThrow(); + } + + frames(): FrameType[] { + return Array.from(this.#frames.values()); + } + + addFrame(frame: FrameType): void { + this.#frames.set(frame._id, frame); + if (frame._parentId) { + this.#parentIds.set(frame._id, frame._parentId); + if (!this.#childIds.has(frame._parentId)) { + this.#childIds.set(frame._parentId, new Set()); + } + this.#childIds.get(frame._parentId)!.add(frame._id); + } else if (!this.#mainFrame) { + this.#mainFrame = frame; + } + this.#waitRequests.get(frame._id)?.forEach(request => { + return request.resolve(frame); + }); + } + + removeFrame(frame: FrameType): void { + this.#frames.delete(frame._id); + this.#parentIds.delete(frame._id); + if (frame._parentId) { + this.#childIds.get(frame._parentId)?.delete(frame._id); + } else { + this.#mainFrame = undefined; + } + } + + childFrames(frameId: string): FrameType[] { + const childIds = this.#childIds.get(frameId); + if (!childIds) { + return []; + } + return Array.from(childIds) + .map(id => { + return this.getById(id); + }) + .filter((frame): frame is FrameType => { + return frame !== undefined; + }); + } + + parentFrame(frameId: string): FrameType | undefined { + const parentId = this.#parentIds.get(frameId); + return parentId ? this.getById(parentId) : undefined; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPRequest.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPRequest.ts new file mode 100644 index 0000000000..029e77470b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPRequest.ts @@ -0,0 +1,449 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import type {Frame} from '../api/Frame.js'; +import { + type ContinueRequestOverrides, + type ErrorCode, + headersArray, + HTTPRequest, + InterceptResolutionAction, + type InterceptResolutionState, + type ResourceType, + type ResponseForRequest, + STATUS_TEXTS, +} from '../api/HTTPRequest.js'; +import type {ProtocolError} from '../common/Errors.js'; +import {debugError, isString} from '../common/util.js'; +import {assert} from '../util/assert.js'; + +import type {CdpHTTPResponse} from './HTTPResponse.js'; + +/** + * @internal + */ +export class CdpHTTPRequest extends HTTPRequest { + declare _redirectChain: CdpHTTPRequest[]; + declare _response: CdpHTTPResponse | null; + + #client: CDPSession; + #isNavigationRequest: boolean; + #allowInterception: boolean; + #interceptionHandled = false; + #url: string; + #resourceType: ResourceType; + + #method: string; + #hasPostData = false; + #postData?: string; + #headers: Record<string, string> = {}; + #frame: Frame | null; + #continueRequestOverrides: ContinueRequestOverrides; + #responseForRequest: Partial<ResponseForRequest> | null = null; + #abortErrorReason: Protocol.Network.ErrorReason | null = null; + #interceptResolutionState: InterceptResolutionState = { + action: InterceptResolutionAction.None, + }; + #interceptHandlers: Array<() => void | PromiseLike<any>>; + #initiator?: Protocol.Network.Initiator; + + override get client(): CDPSession { + return this.#client; + } + + constructor( + client: CDPSession, + frame: Frame | null, + interceptionId: string | undefined, + allowInterception: boolean, + data: { + /** + * Request identifier. + */ + requestId: Protocol.Network.RequestId; + /** + * Loader identifier. Empty string if the request is fetched from worker. + */ + loaderId?: Protocol.Network.LoaderId; + /** + * URL of the document this request is loaded for. + */ + documentURL?: string; + /** + * Request data. + */ + request: Protocol.Network.Request; + /** + * Request initiator. + */ + initiator?: Protocol.Network.Initiator; + /** + * Type of this resource. + */ + type?: Protocol.Network.ResourceType; + }, + redirectChain: CdpHTTPRequest[] + ) { + super(); + this.#client = client; + this._requestId = data.requestId; + this.#isNavigationRequest = + data.requestId === data.loaderId && data.type === 'Document'; + this._interceptionId = interceptionId; + this.#allowInterception = allowInterception; + this.#url = data.request.url; + this.#resourceType = (data.type || 'other').toLowerCase() as ResourceType; + this.#method = data.request.method; + this.#postData = data.request.postData; + this.#hasPostData = data.request.hasPostData ?? false; + this.#frame = frame; + this._redirectChain = redirectChain; + this.#continueRequestOverrides = {}; + this.#interceptHandlers = []; + this.#initiator = data.initiator; + + for (const [key, value] of Object.entries(data.request.headers)) { + this.#headers[key.toLowerCase()] = value; + } + } + + override url(): string { + return this.#url; + } + + override continueRequestOverrides(): ContinueRequestOverrides { + assert(this.#allowInterception, 'Request Interception is not enabled!'); + return this.#continueRequestOverrides; + } + + override responseForRequest(): Partial<ResponseForRequest> | null { + assert(this.#allowInterception, 'Request Interception is not enabled!'); + return this.#responseForRequest; + } + + override abortErrorReason(): Protocol.Network.ErrorReason | null { + assert(this.#allowInterception, 'Request Interception is not enabled!'); + return this.#abortErrorReason; + } + + override interceptResolutionState(): InterceptResolutionState { + if (!this.#allowInterception) { + return {action: InterceptResolutionAction.Disabled}; + } + if (this.#interceptionHandled) { + return {action: InterceptResolutionAction.AlreadyHandled}; + } + return {...this.#interceptResolutionState}; + } + + override isInterceptResolutionHandled(): boolean { + return this.#interceptionHandled; + } + + enqueueInterceptAction( + pendingHandler: () => void | PromiseLike<unknown> + ): void { + this.#interceptHandlers.push(pendingHandler); + } + + override async finalizeInterceptions(): Promise<void> { + await this.#interceptHandlers.reduce((promiseChain, interceptAction) => { + return promiseChain.then(interceptAction); + }, Promise.resolve()); + const {action} = this.interceptResolutionState(); + switch (action) { + case 'abort': + return await this.#abort(this.#abortErrorReason); + case 'respond': + if (this.#responseForRequest === null) { + throw new Error('Response is missing for the interception'); + } + return await this.#respond(this.#responseForRequest); + case 'continue': + return await this.#continue(this.#continueRequestOverrides); + } + } + + override resourceType(): ResourceType { + return this.#resourceType; + } + + override method(): string { + return this.#method; + } + + override postData(): string | undefined { + return this.#postData; + } + + override hasPostData(): boolean { + return this.#hasPostData; + } + + override async fetchPostData(): Promise<string | undefined> { + try { + const result = await this.#client.send('Network.getRequestPostData', { + requestId: this._requestId, + }); + return result.postData; + } catch (err) { + debugError(err); + return; + } + } + + override headers(): Record<string, string> { + return this.#headers; + } + + override response(): CdpHTTPResponse | null { + return this._response; + } + + override frame(): Frame | null { + return this.#frame; + } + + override isNavigationRequest(): boolean { + return this.#isNavigationRequest; + } + + override initiator(): Protocol.Network.Initiator | undefined { + return this.#initiator; + } + + override redirectChain(): CdpHTTPRequest[] { + return this._redirectChain.slice(); + } + + override failure(): {errorText: string} | null { + if (!this._failureText) { + return null; + } + return { + errorText: this._failureText, + }; + } + + override async continue( + overrides: ContinueRequestOverrides = {}, + priority?: number + ): Promise<void> { + // Request interception is not supported for data: urls. + if (this.#url.startsWith('data:')) { + return; + } + assert(this.#allowInterception, 'Request Interception is not enabled!'); + assert(!this.#interceptionHandled, 'Request is already handled!'); + if (priority === undefined) { + return await this.#continue(overrides); + } + this.#continueRequestOverrides = overrides; + if ( + this.#interceptResolutionState.priority === undefined || + priority > this.#interceptResolutionState.priority + ) { + this.#interceptResolutionState = { + action: InterceptResolutionAction.Continue, + priority, + }; + return; + } + if (priority === this.#interceptResolutionState.priority) { + if ( + this.#interceptResolutionState.action === 'abort' || + this.#interceptResolutionState.action === 'respond' + ) { + return; + } + this.#interceptResolutionState.action = + InterceptResolutionAction.Continue; + } + return; + } + + async #continue(overrides: ContinueRequestOverrides = {}): Promise<void> { + const {url, method, postData, headers} = overrides; + this.#interceptionHandled = true; + + const postDataBinaryBase64 = postData + ? Buffer.from(postData).toString('base64') + : undefined; + + if (this._interceptionId === undefined) { + throw new Error( + 'HTTPRequest is missing _interceptionId needed for Fetch.continueRequest' + ); + } + await this.#client + .send('Fetch.continueRequest', { + requestId: this._interceptionId, + url, + method, + postData: postDataBinaryBase64, + headers: headers ? headersArray(headers) : undefined, + }) + .catch(error => { + this.#interceptionHandled = false; + return handleError(error); + }); + } + + override async respond( + response: Partial<ResponseForRequest>, + priority?: number + ): Promise<void> { + // Mocking responses for dataURL requests is not currently supported. + if (this.#url.startsWith('data:')) { + return; + } + assert(this.#allowInterception, 'Request Interception is not enabled!'); + assert(!this.#interceptionHandled, 'Request is already handled!'); + if (priority === undefined) { + return await this.#respond(response); + } + this.#responseForRequest = response; + if ( + this.#interceptResolutionState.priority === undefined || + priority > this.#interceptResolutionState.priority + ) { + this.#interceptResolutionState = { + action: InterceptResolutionAction.Respond, + priority, + }; + return; + } + if (priority === this.#interceptResolutionState.priority) { + if (this.#interceptResolutionState.action === 'abort') { + return; + } + this.#interceptResolutionState.action = InterceptResolutionAction.Respond; + } + } + + async #respond(response: Partial<ResponseForRequest>): Promise<void> { + this.#interceptionHandled = true; + + const responseBody: Buffer | null = + response.body && isString(response.body) + ? Buffer.from(response.body) + : (response.body as Buffer) || null; + + const responseHeaders: Record<string, string | string[]> = {}; + if (response.headers) { + for (const header of Object.keys(response.headers)) { + const value = response.headers[header]; + + responseHeaders[header.toLowerCase()] = Array.isArray(value) + ? value.map(item => { + return String(item); + }) + : String(value); + } + } + if (response.contentType) { + responseHeaders['content-type'] = response.contentType; + } + if (responseBody && !('content-length' in responseHeaders)) { + responseHeaders['content-length'] = String( + Buffer.byteLength(responseBody) + ); + } + + const status = response.status || 200; + if (this._interceptionId === undefined) { + throw new Error( + 'HTTPRequest is missing _interceptionId needed for Fetch.fulfillRequest' + ); + } + await this.#client + .send('Fetch.fulfillRequest', { + requestId: this._interceptionId, + responseCode: status, + responsePhrase: STATUS_TEXTS[status], + responseHeaders: headersArray(responseHeaders), + body: responseBody ? responseBody.toString('base64') : undefined, + }) + .catch(error => { + this.#interceptionHandled = false; + return handleError(error); + }); + } + + override async abort( + errorCode: ErrorCode = 'failed', + priority?: number + ): Promise<void> { + // Request interception is not supported for data: urls. + if (this.#url.startsWith('data:')) { + return; + } + const errorReason = errorReasons[errorCode]; + assert(errorReason, 'Unknown error code: ' + errorCode); + assert(this.#allowInterception, 'Request Interception is not enabled!'); + assert(!this.#interceptionHandled, 'Request is already handled!'); + if (priority === undefined) { + return await this.#abort(errorReason); + } + this.#abortErrorReason = errorReason; + if ( + this.#interceptResolutionState.priority === undefined || + priority >= this.#interceptResolutionState.priority + ) { + this.#interceptResolutionState = { + action: InterceptResolutionAction.Abort, + priority, + }; + return; + } + } + + async #abort( + errorReason: Protocol.Network.ErrorReason | null + ): Promise<void> { + this.#interceptionHandled = true; + if (this._interceptionId === undefined) { + throw new Error( + 'HTTPRequest is missing _interceptionId needed for Fetch.failRequest' + ); + } + await this.#client + .send('Fetch.failRequest', { + requestId: this._interceptionId, + errorReason: errorReason || 'Failed', + }) + .catch(handleError); + } +} + +const errorReasons: Record<ErrorCode, Protocol.Network.ErrorReason> = { + aborted: 'Aborted', + accessdenied: 'AccessDenied', + addressunreachable: 'AddressUnreachable', + blockedbyclient: 'BlockedByClient', + blockedbyresponse: 'BlockedByResponse', + connectionaborted: 'ConnectionAborted', + connectionclosed: 'ConnectionClosed', + connectionfailed: 'ConnectionFailed', + connectionrefused: 'ConnectionRefused', + connectionreset: 'ConnectionReset', + internetdisconnected: 'InternetDisconnected', + namenotresolved: 'NameNotResolved', + timedout: 'TimedOut', + failed: 'Failed', +} as const; + +async function handleError(error: ProtocolError) { + if (['Invalid header'].includes(error.originalMessage)) { + throw error; + } + // In certain cases, protocol will return error if the request was + // already canceled or the page was closed. We should tolerate these + // errors. + debugError(error); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPResponse.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPResponse.ts new file mode 100644 index 0000000000..2b2264ffd4 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPResponse.ts @@ -0,0 +1,173 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import type {Frame} from '../api/Frame.js'; +import {HTTPResponse, type RemoteAddress} from '../api/HTTPResponse.js'; +import {ProtocolError} from '../common/Errors.js'; +import {SecurityDetails} from '../common/SecurityDetails.js'; +import {Deferred} from '../util/Deferred.js'; + +import type {CdpHTTPRequest} from './HTTPRequest.js'; + +/** + * @internal + */ +export class CdpHTTPResponse extends HTTPResponse { + #client: CDPSession; + #request: CdpHTTPRequest; + #contentPromise: Promise<Buffer> | null = null; + #bodyLoadedDeferred = Deferred.create<void, Error>(); + #remoteAddress: RemoteAddress; + #status: number; + #statusText: string; + #url: string; + #fromDiskCache: boolean; + #fromServiceWorker: boolean; + #headers: Record<string, string> = {}; + #securityDetails: SecurityDetails | null; + #timing: Protocol.Network.ResourceTiming | null; + + constructor( + client: CDPSession, + request: CdpHTTPRequest, + responsePayload: Protocol.Network.Response, + extraInfo: Protocol.Network.ResponseReceivedExtraInfoEvent | null + ) { + super(); + this.#client = client; + this.#request = request; + + this.#remoteAddress = { + ip: responsePayload.remoteIPAddress, + port: responsePayload.remotePort, + }; + this.#statusText = + this.#parseStatusTextFromExtraInfo(extraInfo) || + responsePayload.statusText; + this.#url = request.url(); + this.#fromDiskCache = !!responsePayload.fromDiskCache; + this.#fromServiceWorker = !!responsePayload.fromServiceWorker; + + this.#status = extraInfo ? extraInfo.statusCode : responsePayload.status; + const headers = extraInfo ? extraInfo.headers : responsePayload.headers; + for (const [key, value] of Object.entries(headers)) { + this.#headers[key.toLowerCase()] = value; + } + + this.#securityDetails = responsePayload.securityDetails + ? new SecurityDetails(responsePayload.securityDetails) + : null; + this.#timing = responsePayload.timing || null; + } + + #parseStatusTextFromExtraInfo( + extraInfo: Protocol.Network.ResponseReceivedExtraInfoEvent | null + ): string | undefined { + if (!extraInfo || !extraInfo.headersText) { + return; + } + const firstLine = extraInfo.headersText.split('\r', 1)[0]; + if (!firstLine) { + return; + } + const match = firstLine.match(/[^ ]* [^ ]* (.*)/); + if (!match) { + return; + } + const statusText = match[1]; + if (!statusText) { + return; + } + return statusText; + } + + _resolveBody(err?: Error): void { + if (err) { + return this.#bodyLoadedDeferred.reject(err); + } + return this.#bodyLoadedDeferred.resolve(); + } + + override remoteAddress(): RemoteAddress { + return this.#remoteAddress; + } + + override url(): string { + return this.#url; + } + + override status(): number { + return this.#status; + } + + override statusText(): string { + return this.#statusText; + } + + override headers(): Record<string, string> { + return this.#headers; + } + + override securityDetails(): SecurityDetails | null { + return this.#securityDetails; + } + + override timing(): Protocol.Network.ResourceTiming | null { + return this.#timing; + } + + override buffer(): Promise<Buffer> { + if (!this.#contentPromise) { + this.#contentPromise = this.#bodyLoadedDeferred + .valueOrThrow() + .then(async () => { + try { + const response = await this.#client.send( + 'Network.getResponseBody', + { + requestId: this.#request._requestId, + } + ); + return Buffer.from( + response.body, + response.base64Encoded ? 'base64' : 'utf8' + ); + } catch (error) { + if ( + error instanceof ProtocolError && + error.originalMessage === + 'No resource with given identifier found' + ) { + throw new ProtocolError( + 'Could not load body for this request. This might happen if the request is a preflight request.' + ); + } + + throw error; + } + }); + } + return this.#contentPromise; + } + + override request(): CdpHTTPRequest { + return this.#request; + } + + override fromCache(): boolean { + return this.#fromDiskCache || this.#request._fromMemoryCache; + } + + override fromServiceWorker(): boolean { + return this.#fromServiceWorker; + } + + override frame(): Frame | null { + return this.#request.frame(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Input.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Input.ts new file mode 100644 index 0000000000..9bfafddcf3 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Input.ts @@ -0,0 +1,604 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import type {Point} from '../api/ElementHandle.js'; +import { + Keyboard, + type KeyDownOptions, + type KeyPressOptions, + Mouse, + MouseButton, + type MouseClickOptions, + type MouseMoveOptions, + type MouseOptions, + type MouseWheelOptions, + Touchscreen, + type KeyboardTypeOptions, +} from '../api/Input.js'; +import { + _keyDefinitions, + type KeyDefinition, + type KeyInput, +} from '../common/USKeyboardLayout.js'; +import {assert} from '../util/assert.js'; + +type KeyDescription = Required< + Pick<KeyDefinition, 'keyCode' | 'key' | 'text' | 'code' | 'location'> +>; + +/** + * @internal + */ +export class CdpKeyboard extends Keyboard { + #client: CDPSession; + #pressedKeys = new Set<string>(); + + _modifiers = 0; + + constructor(client: CDPSession) { + super(); + this.#client = client; + } + + updateClient(client: CDPSession): void { + this.#client = client; + } + + override async down( + key: KeyInput, + options: Readonly<KeyDownOptions> = { + text: undefined, + commands: [], + } + ): Promise<void> { + const description = this.#keyDescriptionForString(key); + + const autoRepeat = this.#pressedKeys.has(description.code); + this.#pressedKeys.add(description.code); + this._modifiers |= this.#modifierBit(description.key); + + const text = options.text === undefined ? description.text : options.text; + await this.#client.send('Input.dispatchKeyEvent', { + type: text ? 'keyDown' : 'rawKeyDown', + modifiers: this._modifiers, + windowsVirtualKeyCode: description.keyCode, + code: description.code, + key: description.key, + text: text, + unmodifiedText: text, + autoRepeat, + location: description.location, + isKeypad: description.location === 3, + commands: options.commands, + }); + } + + #modifierBit(key: string): number { + if (key === 'Alt') { + return 1; + } + if (key === 'Control') { + return 2; + } + if (key === 'Meta') { + return 4; + } + if (key === 'Shift') { + return 8; + } + return 0; + } + + #keyDescriptionForString(keyString: KeyInput): KeyDescription { + const shift = this._modifiers & 8; + const description = { + key: '', + keyCode: 0, + code: '', + text: '', + location: 0, + }; + + const definition = _keyDefinitions[keyString]; + assert(definition, `Unknown key: "${keyString}"`); + + if (definition.key) { + description.key = definition.key; + } + if (shift && definition.shiftKey) { + description.key = definition.shiftKey; + } + + if (definition.keyCode) { + description.keyCode = definition.keyCode; + } + if (shift && definition.shiftKeyCode) { + description.keyCode = definition.shiftKeyCode; + } + + if (definition.code) { + description.code = definition.code; + } + + if (definition.location) { + description.location = definition.location; + } + + if (description.key.length === 1) { + description.text = description.key; + } + + if (definition.text) { + description.text = definition.text; + } + if (shift && definition.shiftText) { + description.text = definition.shiftText; + } + + // if any modifiers besides shift are pressed, no text should be sent + if (this._modifiers & ~8) { + description.text = ''; + } + + return description; + } + + override async up(key: KeyInput): Promise<void> { + const description = this.#keyDescriptionForString(key); + + this._modifiers &= ~this.#modifierBit(description.key); + this.#pressedKeys.delete(description.code); + await this.#client.send('Input.dispatchKeyEvent', { + type: 'keyUp', + modifiers: this._modifiers, + key: description.key, + windowsVirtualKeyCode: description.keyCode, + code: description.code, + location: description.location, + }); + } + + override async sendCharacter(char: string): Promise<void> { + await this.#client.send('Input.insertText', {text: char}); + } + + private charIsKey(char: string): char is KeyInput { + return !!_keyDefinitions[char as KeyInput]; + } + + override async type( + text: string, + options: Readonly<KeyboardTypeOptions> = {} + ): Promise<void> { + const delay = options.delay || undefined; + for (const char of text) { + if (this.charIsKey(char)) { + await this.press(char, {delay}); + } else { + if (delay) { + await new Promise(f => { + return setTimeout(f, delay); + }); + } + await this.sendCharacter(char); + } + } + } + + override async press( + key: KeyInput, + options: Readonly<KeyPressOptions> = {} + ): Promise<void> { + const {delay = null} = options; + await this.down(key, options); + if (delay) { + await new Promise(f => { + return setTimeout(f, options.delay); + }); + } + await this.up(key); + } +} + +/** + * This must follow {@link Protocol.Input.DispatchMouseEventRequest.buttons}. + */ +const enum MouseButtonFlag { + None = 0, + Left = 1, + Right = 1 << 1, + Middle = 1 << 2, + Back = 1 << 3, + Forward = 1 << 4, +} + +const getFlag = (button: MouseButton): MouseButtonFlag => { + switch (button) { + case MouseButton.Left: + return MouseButtonFlag.Left; + case MouseButton.Right: + return MouseButtonFlag.Right; + case MouseButton.Middle: + return MouseButtonFlag.Middle; + case MouseButton.Back: + return MouseButtonFlag.Back; + case MouseButton.Forward: + return MouseButtonFlag.Forward; + } +}; + +/** + * This should match + * https://source.chromium.org/chromium/chromium/src/+/refs/heads/main:content/browser/renderer_host/input/web_input_event_builders_mac.mm;drc=a61b95c63b0b75c1cfe872d9c8cdf927c226046e;bpv=1;bpt=1;l=221. + */ +const getButtonFromPressedButtons = ( + buttons: number +): Protocol.Input.MouseButton => { + if (buttons & MouseButtonFlag.Left) { + return MouseButton.Left; + } else if (buttons & MouseButtonFlag.Right) { + return MouseButton.Right; + } else if (buttons & MouseButtonFlag.Middle) { + return MouseButton.Middle; + } else if (buttons & MouseButtonFlag.Back) { + return MouseButton.Back; + } else if (buttons & MouseButtonFlag.Forward) { + return MouseButton.Forward; + } + return 'none'; +}; + +interface MouseState { + /** + * The current position of the mouse. + */ + position: Point; + /** + * The buttons that are currently being pressed. + */ + buttons: number; +} + +/** + * @internal + */ +export class CdpMouse extends Mouse { + #client: CDPSession; + #keyboard: CdpKeyboard; + + constructor(client: CDPSession, keyboard: CdpKeyboard) { + super(); + this.#client = client; + this.#keyboard = keyboard; + } + + updateClient(client: CDPSession): void { + this.#client = client; + } + + #_state: Readonly<MouseState> = { + position: {x: 0, y: 0}, + buttons: MouseButtonFlag.None, + }; + get #state(): MouseState { + return Object.assign({...this.#_state}, ...this.#transactions); + } + + // Transactions can run in parallel, so we store each of thme in this array. + #transactions: Array<Partial<MouseState>> = []; + #createTransaction(): { + update: (updates: Partial<MouseState>) => void; + commit: () => void; + rollback: () => void; + } { + const transaction: Partial<MouseState> = {}; + this.#transactions.push(transaction); + const popTransaction = () => { + this.#transactions.splice(this.#transactions.indexOf(transaction), 1); + }; + return { + update: (updates: Partial<MouseState>) => { + Object.assign(transaction, updates); + }, + commit: () => { + this.#_state = {...this.#_state, ...transaction}; + popTransaction(); + }, + rollback: popTransaction, + }; + } + + /** + * This is a shortcut for a typical update, commit/rollback lifecycle based on + * the error of the action. + */ + async #withTransaction( + action: (update: (updates: Partial<MouseState>) => void) => Promise<unknown> + ): Promise<void> { + const {update, commit, rollback} = this.#createTransaction(); + try { + await action(update); + commit(); + } catch (error) { + rollback(); + throw error; + } + } + + override async reset(): Promise<void> { + const actions = []; + for (const [flag, button] of [ + [MouseButtonFlag.Left, MouseButton.Left], + [MouseButtonFlag.Middle, MouseButton.Middle], + [MouseButtonFlag.Right, MouseButton.Right], + [MouseButtonFlag.Forward, MouseButton.Forward], + [MouseButtonFlag.Back, MouseButton.Back], + ] as const) { + if (this.#state.buttons & flag) { + actions.push(this.up({button: button})); + } + } + if (this.#state.position.x !== 0 || this.#state.position.y !== 0) { + actions.push(this.move(0, 0)); + } + await Promise.all(actions); + } + + override async move( + x: number, + y: number, + options: Readonly<MouseMoveOptions> = {} + ): Promise<void> { + const {steps = 1} = options; + const from = this.#state.position; + const to = {x, y}; + for (let i = 1; i <= steps; i++) { + await this.#withTransaction(updateState => { + updateState({ + position: { + x: from.x + (to.x - from.x) * (i / steps), + y: from.y + (to.y - from.y) * (i / steps), + }, + }); + const {buttons, position} = this.#state; + return this.#client.send('Input.dispatchMouseEvent', { + type: 'mouseMoved', + modifiers: this.#keyboard._modifiers, + buttons, + button: getButtonFromPressedButtons(buttons), + ...position, + }); + }); + } + } + + override async down(options: Readonly<MouseOptions> = {}): Promise<void> { + const {button = MouseButton.Left, clickCount = 1} = options; + const flag = getFlag(button); + if (!flag) { + throw new Error(`Unsupported mouse button: ${button}`); + } + if (this.#state.buttons & flag) { + throw new Error(`'${button}' is already pressed.`); + } + await this.#withTransaction(updateState => { + updateState({ + buttons: this.#state.buttons | flag, + }); + const {buttons, position} = this.#state; + return this.#client.send('Input.dispatchMouseEvent', { + type: 'mousePressed', + modifiers: this.#keyboard._modifiers, + clickCount, + buttons, + button, + ...position, + }); + }); + } + + override async up(options: Readonly<MouseOptions> = {}): Promise<void> { + const {button = MouseButton.Left, clickCount = 1} = options; + const flag = getFlag(button); + if (!flag) { + throw new Error(`Unsupported mouse button: ${button}`); + } + if (!(this.#state.buttons & flag)) { + throw new Error(`'${button}' is not pressed.`); + } + await this.#withTransaction(updateState => { + updateState({ + buttons: this.#state.buttons & ~flag, + }); + const {buttons, position} = this.#state; + return this.#client.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', + modifiers: this.#keyboard._modifiers, + clickCount, + buttons, + button, + ...position, + }); + }); + } + + override async click( + x: number, + y: number, + options: Readonly<MouseClickOptions> = {} + ): Promise<void> { + const {delay, count = 1, clickCount = count} = options; + if (count < 1) { + throw new Error('Click must occur a positive number of times.'); + } + const actions: Array<Promise<void>> = [this.move(x, y)]; + if (clickCount === count) { + for (let i = 1; i < count; ++i) { + actions.push( + this.down({...options, clickCount: i}), + this.up({...options, clickCount: i}) + ); + } + } + actions.push(this.down({...options, clickCount})); + if (typeof delay === 'number') { + await Promise.all(actions); + actions.length = 0; + await new Promise(resolve => { + setTimeout(resolve, delay); + }); + } + actions.push(this.up({...options, clickCount})); + await Promise.all(actions); + } + + override async wheel( + options: Readonly<MouseWheelOptions> = {} + ): Promise<void> { + const {deltaX = 0, deltaY = 0} = options; + const {position, buttons} = this.#state; + await this.#client.send('Input.dispatchMouseEvent', { + type: 'mouseWheel', + pointerType: 'mouse', + modifiers: this.#keyboard._modifiers, + deltaY, + deltaX, + buttons, + ...position, + }); + } + + override async drag( + start: Point, + target: Point + ): Promise<Protocol.Input.DragData> { + const promise = new Promise<Protocol.Input.DragData>(resolve => { + this.#client.once('Input.dragIntercepted', event => { + return resolve(event.data); + }); + }); + await this.move(start.x, start.y); + await this.down(); + await this.move(target.x, target.y); + return await promise; + } + + override async dragEnter( + target: Point, + data: Protocol.Input.DragData + ): Promise<void> { + await this.#client.send('Input.dispatchDragEvent', { + type: 'dragEnter', + x: target.x, + y: target.y, + modifiers: this.#keyboard._modifiers, + data, + }); + } + + override async dragOver( + target: Point, + data: Protocol.Input.DragData + ): Promise<void> { + await this.#client.send('Input.dispatchDragEvent', { + type: 'dragOver', + x: target.x, + y: target.y, + modifiers: this.#keyboard._modifiers, + data, + }); + } + + override async drop( + target: Point, + data: Protocol.Input.DragData + ): Promise<void> { + await this.#client.send('Input.dispatchDragEvent', { + type: 'drop', + x: target.x, + y: target.y, + modifiers: this.#keyboard._modifiers, + data, + }); + } + + override async dragAndDrop( + start: Point, + target: Point, + options: {delay?: number} = {} + ): Promise<void> { + const {delay = null} = options; + const data = await this.drag(start, target); + await this.dragEnter(target, data); + await this.dragOver(target, data); + if (delay) { + await new Promise(resolve => { + return setTimeout(resolve, delay); + }); + } + await this.drop(target, data); + await this.up(); + } +} + +/** + * @internal + */ +export class CdpTouchscreen extends Touchscreen { + #client: CDPSession; + #keyboard: CdpKeyboard; + + constructor(client: CDPSession, keyboard: CdpKeyboard) { + super(); + this.#client = client; + this.#keyboard = keyboard; + } + + updateClient(client: CDPSession): void { + this.#client = client; + } + + override async touchStart(x: number, y: number): Promise<void> { + await this.#client.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [ + { + x: Math.round(x), + y: Math.round(y), + radiusX: 0.5, + radiusY: 0.5, + }, + ], + modifiers: this.#keyboard._modifiers, + }); + } + + override async touchMove(x: number, y: number): Promise<void> { + await this.#client.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [ + { + x: Math.round(x), + y: Math.round(y), + radiusX: 0.5, + radiusY: 0.5, + }, + ], + modifiers: this.#keyboard._modifiers, + }); + } + + override async touchEnd(): Promise<void> { + await this.#client.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: [], + modifiers: this.#keyboard._modifiers, + }); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/IsolatedWorld.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/IsolatedWorld.ts new file mode 100644 index 0000000000..5846ef3652 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/IsolatedWorld.ts @@ -0,0 +1,273 @@ +/** + * @license + * Copyright 2019 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import type {JSHandle} from '../api/JSHandle.js'; +import {Realm} from '../api/Realm.js'; +import type {TimeoutSettings} from '../common/TimeoutSettings.js'; +import type {BindingPayload, EvaluateFunc, HandleFor} from '../common/types.js'; +import {debugError, withSourcePuppeteerURLIfNone} from '../common/util.js'; +import {Deferred} from '../util/Deferred.js'; +import {disposeSymbol} from '../util/disposable.js'; +import {Mutex} from '../util/Mutex.js'; + +import type {Binding} from './Binding.js'; +import {ExecutionContext, createCdpHandle} from './ExecutionContext.js'; +import type {CdpFrame} from './Frame.js'; +import type {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js'; +import {addPageBinding} from './utils.js'; +import type {CdpWebWorker} from './WebWorker.js'; + +/** + * @internal + */ +export interface PageBinding { + name: string; + pptrFunction: Function; +} + +/** + * @internal + */ +export interface IsolatedWorldChart { + [key: string]: IsolatedWorld; + [MAIN_WORLD]: IsolatedWorld; + [PUPPETEER_WORLD]: IsolatedWorld; +} + +/** + * @internal + */ +export class IsolatedWorld extends Realm { + #context = Deferred.create<ExecutionContext>(); + + // Set of bindings that have been registered in the current context. + #contextBindings = new Set<string>(); + + // Contains mapping from functions that should be bound to Puppeteer functions. + #bindings = new Map<string, Binding>(); + + get _bindings(): Map<string, Binding> { + return this.#bindings; + } + + readonly #frameOrWorker: CdpFrame | CdpWebWorker; + + constructor( + frameOrWorker: CdpFrame | CdpWebWorker, + timeoutSettings: TimeoutSettings + ) { + super(timeoutSettings); + this.#frameOrWorker = frameOrWorker; + this.frameUpdated(); + } + + get environment(): CdpFrame | CdpWebWorker { + return this.#frameOrWorker; + } + + frameUpdated(): void { + this.client.on('Runtime.bindingCalled', this.#onBindingCalled); + } + + get client(): CDPSession { + return this.#frameOrWorker.client; + } + + clearContext(): void { + // The message has to match the CDP message expected by the WaitTask class. + this.#context?.reject(new Error('Execution context was destroyed')); + this.#context = Deferred.create(); + if ('clearDocumentHandle' in this.#frameOrWorker) { + this.#frameOrWorker.clearDocumentHandle(); + } + } + + setContext(context: ExecutionContext): void { + this.#contextBindings.clear(); + this.#context.resolve(context); + void this.taskManager.rerunAll(); + } + + hasContext(): boolean { + return this.#context.resolved(); + } + + #executionContext(): Promise<ExecutionContext> { + if (this.disposed) { + throw new Error( + `Execution context is not available in detached frame "${this.environment.url()}" (are you trying to evaluate?)` + ); + } + if (this.#context === null) { + throw new Error(`Execution content promise is missing`); + } + return this.#context.valueOrThrow(); + } + + 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 + ); + const context = await this.#executionContext(); + return await context.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 + ); + let context = this.#context.value(); + if (!context || !(context instanceof ExecutionContext)) { + context = await this.#executionContext(); + } + return await context.evaluate(pageFunction, ...args); + } + + // If multiple waitFor are set up asynchronously, we need to wait for the + // first one to set up the binding in the page before running the others. + #mutex = new Mutex(); + async _addBindingToContext( + context: ExecutionContext, + name: string + ): Promise<void> { + if (this.#contextBindings.has(name)) { + return; + } + + using _ = await this.#mutex.acquire(); + try { + await context._client.send( + 'Runtime.addBinding', + context._contextName + ? { + name, + executionContextName: context._contextName, + } + : { + name, + executionContextId: context._contextId, + } + ); + + await context.evaluate(addPageBinding, 'internal', name); + + this.#contextBindings.add(name); + } catch (error) { + // We could have tried to evaluate in a context which was already + // destroyed. This happens, for example, if the page is navigated while + // we are trying to add the binding + if (error instanceof Error) { + // Destroyed context. + if (error.message.includes('Execution context was destroyed')) { + return; + } + // Missing context. + if (error.message.includes('Cannot find context with specified id')) { + return; + } + } + + debugError(error); + } + } + + #onBindingCalled = async ( + event: Protocol.Runtime.BindingCalledEvent + ): Promise<void> => { + let payload: BindingPayload; + try { + payload = JSON.parse(event.payload); + } catch { + // The binding was either called by something in the page or it was + // called before our wrapper was initialized. + return; + } + const {type, name, seq, args, isTrivial} = payload; + if (type !== 'internal') { + return; + } + if (!this.#contextBindings.has(name)) { + return; + } + + try { + const context = await this.#context.valueOrThrow(); + if (event.executionContextId !== context._contextId) { + return; + } + + const binding = this._bindings.get(name); + await binding?.run(context, seq, args, isTrivial); + } catch (err) { + debugError(err); + } + }; + + override async adoptBackendNode( + backendNodeId?: Protocol.DOM.BackendNodeId + ): Promise<JSHandle<Node>> { + const executionContext = await this.#executionContext(); + const {object} = await this.client.send('DOM.resolveNode', { + backendNodeId: backendNodeId, + executionContextId: executionContext._contextId, + }); + return createCdpHandle(this, object) as JSHandle<Node>; + } + + async adoptHandle<T extends JSHandle<Node>>(handle: T): Promise<T> { + if (handle.realm === this) { + // If the context has already adopted this handle, clone it so downstream + // disposal doesn't become an issue. + return (await handle.evaluateHandle(value => { + return value; + })) as unknown as T; + } + const nodeInfo = await this.client.send('DOM.describeNode', { + objectId: handle.id, + }); + return (await this.adoptBackendNode(nodeInfo.node.backendNodeId)) as T; + } + + async transferHandle<T extends JSHandle<Node>>(handle: T): Promise<T> { + if (handle.realm === this) { + return handle; + } + // Implies it's a primitive value, probably. + if (handle.remoteObject().objectId === undefined) { + return handle; + } + const info = await this.client.send('DOM.describeNode', { + objectId: handle.remoteObject().objectId, + }); + const newHandle = (await this.adoptBackendNode( + info.node.backendNodeId + )) as T; + await handle.dispose(); + return newHandle; + } + + [disposeSymbol](): void { + super[disposeSymbol](); + this.client.off('Runtime.bindingCalled', this.#onBindingCalled); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/IsolatedWorlds.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/IsolatedWorlds.ts new file mode 100644 index 0000000000..ddb6c2381d --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/IsolatedWorlds.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * A unique key for {@link IsolatedWorldChart} to denote the default world. + * Execution contexts are automatically created in the default world. + * + * @internal + */ +export const MAIN_WORLD = Symbol('mainWorld'); +/** + * A unique key for {@link IsolatedWorldChart} to denote the puppeteer world. + * This world contains all puppeteer-internal bindings/code. + * + * @internal + */ +export const PUPPETEER_WORLD = Symbol('puppeteerWorld'); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/JSHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/JSHandle.ts new file mode 100644 index 0000000000..bba5f96b5d --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/JSHandle.ts @@ -0,0 +1,109 @@ +/** + * @license + * Copyright 2019 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import {JSHandle} from '../api/JSHandle.js'; +import {debugError} from '../common/util.js'; + +import type {CdpElementHandle} from './ElementHandle.js'; +import type {IsolatedWorld} from './IsolatedWorld.js'; +import {valueFromRemoteObject} from './utils.js'; + +/** + * @internal + */ +export class CdpJSHandle<T = unknown> extends JSHandle<T> { + #disposed = false; + readonly #remoteObject: Protocol.Runtime.RemoteObject; + readonly #world: IsolatedWorld; + + constructor( + world: IsolatedWorld, + remoteObject: Protocol.Runtime.RemoteObject + ) { + super(); + this.#world = world; + this.#remoteObject = remoteObject; + } + + override get disposed(): boolean { + return this.#disposed; + } + + override get realm(): IsolatedWorld { + return this.#world; + } + + get client(): CDPSession { + return this.realm.environment.client; + } + + override async jsonValue(): Promise<T> { + if (!this.#remoteObject.objectId) { + return valueFromRemoteObject(this.#remoteObject); + } + const value = await this.evaluate(object => { + return object; + }); + if (value === undefined) { + throw new Error('Could not serialize referenced object'); + } + return value; + } + + /** + * Either `null` or the handle itself if the handle is an + * instance of {@link ElementHandle}. + */ + override asElement(): CdpElementHandle<Node> | null { + return null; + } + + override async dispose(): Promise<void> { + if (this.#disposed) { + return; + } + this.#disposed = true; + await releaseObject(this.client, this.#remoteObject); + } + + override toString(): string { + if (!this.#remoteObject.objectId) { + return 'JSHandle:' + valueFromRemoteObject(this.#remoteObject); + } + const type = this.#remoteObject.subtype || this.#remoteObject.type; + return 'JSHandle@' + type; + } + + override get id(): string | undefined { + return this.#remoteObject.objectId; + } + + override remoteObject(): Protocol.Runtime.RemoteObject { + return this.#remoteObject; + } +} + +/** + * @internal + */ +export async function releaseObject( + client: CDPSession, + remoteObject: Protocol.Runtime.RemoteObject +): Promise<void> { + if (!remoteObject.objectId) { + return; + } + await client + .send('Runtime.releaseObject', {objectId: remoteObject.objectId}) + .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); + }); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/LifecycleWatcher.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/LifecycleWatcher.ts new file mode 100644 index 0000000000..a4f5aaa468 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/LifecycleWatcher.ts @@ -0,0 +1,298 @@ +/** + * @license + * Copyright 2019 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type Protocol from 'devtools-protocol'; + +import {type Frame, FrameEvent} from '../api/Frame.js'; +import type {HTTPRequest} from '../api/HTTPRequest.js'; +import type {HTTPResponse} from '../api/HTTPResponse.js'; +import type {TimeoutError} from '../common/Errors.js'; +import {EventSubscription} from '../common/EventEmitter.js'; +import {NetworkManagerEvent} from '../common/NetworkManagerEvents.js'; +import {assert} from '../util/assert.js'; +import {Deferred} from '../util/Deferred.js'; +import {DisposableStack} from '../util/disposable.js'; + +import type {CdpFrame} from './Frame.js'; +import {FrameManagerEvent} from './FrameManagerEvents.js'; +import type {NetworkManager} from './NetworkManager.js'; + +/** + * @public + */ +export type PuppeteerLifeCycleEvent = + /** + * Waits for the 'load' event. + */ + | 'load' + /** + * Waits for the 'DOMContentLoaded' event. + */ + | 'domcontentloaded' + /** + * Waits till there are no more than 0 network connections for at least `500` + * ms. + */ + | 'networkidle0' + /** + * Waits till there are no more than 2 network connections for at least `500` + * ms. + */ + | 'networkidle2'; + +/** + * @public + */ +export type ProtocolLifeCycleEvent = + | 'load' + | 'DOMContentLoaded' + | 'networkIdle' + | 'networkAlmostIdle'; + +const puppeteerToProtocolLifecycle = new Map< + PuppeteerLifeCycleEvent, + ProtocolLifeCycleEvent +>([ + ['load', 'load'], + ['domcontentloaded', 'DOMContentLoaded'], + ['networkidle0', 'networkIdle'], + ['networkidle2', 'networkAlmostIdle'], +]); + +/** + * @internal + */ +export class LifecycleWatcher { + #expectedLifecycle: ProtocolLifeCycleEvent[]; + #frame: CdpFrame; + #timeout: number; + #navigationRequest: HTTPRequest | null = null; + #subscriptions = new DisposableStack(); + #initialLoaderId: string; + + #terminationDeferred: Deferred<Error>; + #sameDocumentNavigationDeferred = Deferred.create<undefined>(); + #lifecycleDeferred = Deferred.create<void>(); + #newDocumentNavigationDeferred = Deferred.create<undefined>(); + + #hasSameDocumentNavigation?: boolean; + #swapped?: boolean; + + #navigationResponseReceived?: Deferred<void>; + + constructor( + networkManager: NetworkManager, + frame: CdpFrame, + waitUntil: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[], + timeout: number + ) { + if (Array.isArray(waitUntil)) { + waitUntil = waitUntil.slice(); + } else if (typeof waitUntil === 'string') { + waitUntil = [waitUntil]; + } + this.#initialLoaderId = frame._loaderId; + this.#expectedLifecycle = waitUntil.map(value => { + const protocolEvent = puppeteerToProtocolLifecycle.get(value); + assert(protocolEvent, 'Unknown value for options.waitUntil: ' + value); + return protocolEvent as ProtocolLifeCycleEvent; + }); + + this.#frame = frame; + this.#timeout = timeout; + this.#subscriptions.use( + // Revert if TODO #1 is done + new EventSubscription( + frame._frameManager, + FrameManagerEvent.LifecycleEvent, + this.#checkLifecycleComplete.bind(this) + ) + ); + this.#subscriptions.use( + new EventSubscription( + frame, + FrameEvent.FrameNavigatedWithinDocument, + this.#navigatedWithinDocument.bind(this) + ) + ); + this.#subscriptions.use( + new EventSubscription( + frame, + FrameEvent.FrameNavigated, + this.#navigated.bind(this) + ) + ); + this.#subscriptions.use( + new EventSubscription( + frame, + FrameEvent.FrameSwapped, + this.#frameSwapped.bind(this) + ) + ); + this.#subscriptions.use( + new EventSubscription( + frame, + FrameEvent.FrameSwappedByActivation, + this.#frameSwapped.bind(this) + ) + ); + this.#subscriptions.use( + new EventSubscription( + frame, + FrameEvent.FrameDetached, + this.#onFrameDetached.bind(this) + ) + ); + this.#subscriptions.use( + new EventSubscription( + networkManager, + NetworkManagerEvent.Request, + this.#onRequest.bind(this) + ) + ); + this.#subscriptions.use( + new EventSubscription( + networkManager, + NetworkManagerEvent.Response, + this.#onResponse.bind(this) + ) + ); + this.#subscriptions.use( + new EventSubscription( + networkManager, + NetworkManagerEvent.RequestFailed, + this.#onRequestFailed.bind(this) + ) + ); + this.#terminationDeferred = Deferred.create<Error>({ + timeout: this.#timeout, + message: `Navigation timeout of ${this.#timeout} ms exceeded`, + }); + + this.#checkLifecycleComplete(); + } + + #onRequest(request: HTTPRequest): void { + if (request.frame() !== this.#frame || !request.isNavigationRequest()) { + return; + } + this.#navigationRequest = request; + // Resolve previous navigation response in case there are multiple + // navigation requests reported by the backend. This generally should not + // happen by it looks like it's possible. + this.#navigationResponseReceived?.resolve(); + this.#navigationResponseReceived = Deferred.create(); + if (request.response() !== null) { + this.#navigationResponseReceived?.resolve(); + } + } + + #onRequestFailed(request: HTTPRequest): void { + if (this.#navigationRequest?._requestId !== request._requestId) { + return; + } + this.#navigationResponseReceived?.resolve(); + } + + #onResponse(response: HTTPResponse): void { + if (this.#navigationRequest?._requestId !== response.request()._requestId) { + return; + } + this.#navigationResponseReceived?.resolve(); + } + + #onFrameDetached(frame: Frame): void { + if (this.#frame === frame) { + this.#terminationDeferred.resolve( + new Error('Navigating frame was detached') + ); + return; + } + this.#checkLifecycleComplete(); + } + + async navigationResponse(): Promise<HTTPResponse | null> { + // Continue with a possibly null response. + await this.#navigationResponseReceived?.valueOrThrow(); + return this.#navigationRequest ? this.#navigationRequest.response() : null; + } + + sameDocumentNavigationPromise(): Promise<Error | undefined> { + return this.#sameDocumentNavigationDeferred.valueOrThrow(); + } + + newDocumentNavigationPromise(): Promise<Error | undefined> { + return this.#newDocumentNavigationDeferred.valueOrThrow(); + } + + lifecyclePromise(): Promise<void> { + return this.#lifecycleDeferred.valueOrThrow(); + } + + terminationPromise(): Promise<Error | TimeoutError | undefined> { + return this.#terminationDeferred.valueOrThrow(); + } + + #navigatedWithinDocument(): void { + this.#hasSameDocumentNavigation = true; + this.#checkLifecycleComplete(); + } + + #navigated(navigationType: Protocol.Page.NavigationType): void { + if (navigationType === 'BackForwardCacheRestore') { + return this.#frameSwapped(); + } + this.#checkLifecycleComplete(); + } + + #frameSwapped(): void { + this.#swapped = true; + this.#checkLifecycleComplete(); + } + + #checkLifecycleComplete(): void { + // We expect navigation to commit. + if (!checkLifecycle(this.#frame, this.#expectedLifecycle)) { + return; + } + this.#lifecycleDeferred.resolve(); + if (this.#hasSameDocumentNavigation) { + this.#sameDocumentNavigationDeferred.resolve(undefined); + } + if (this.#swapped || this.#frame._loaderId !== this.#initialLoaderId) { + this.#newDocumentNavigationDeferred.resolve(undefined); + } + + function checkLifecycle( + frame: CdpFrame, + expectedLifecycle: ProtocolLifeCycleEvent[] + ): boolean { + for (const event of expectedLifecycle) { + if (!frame._lifecycleEvents.has(event)) { + return false; + } + } + // TODO(#1): Its possible we don't need this check + // CDP provided the correct order for Loading Events + // And NetworkIdle is a global state + // Consider removing + for (const child of frame.childFrames()) { + if ( + child._hasStartedLoading && + !checkLifecycle(child, expectedLifecycle) + ) { + return false; + } + } + return true; + } + } + + dispose(): void { + this.#subscriptions.dispose(); + this.#terminationDeferred.resolve(new Error('LifecycleWatcher disposed')); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkEventManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkEventManager.ts new file mode 100644 index 0000000000..2aadd21d25 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkEventManager.ts @@ -0,0 +1,217 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {CdpHTTPRequest} from './HTTPRequest.js'; + +/** + * @internal + */ +export interface QueuedEventGroup { + responseReceivedEvent: Protocol.Network.ResponseReceivedEvent; + loadingFinishedEvent?: Protocol.Network.LoadingFinishedEvent; + loadingFailedEvent?: Protocol.Network.LoadingFailedEvent; +} + +/** + * @internal + */ +export type FetchRequestId = string; + +/** + * @internal + */ +export interface RedirectInfo { + event: Protocol.Network.RequestWillBeSentEvent; + fetchRequestId?: FetchRequestId; +} +type RedirectInfoList = RedirectInfo[]; + +/** + * @internal + */ +export type NetworkRequestId = string; + +/** + * Helper class to track network events by request ID + * + * @internal + */ +export class NetworkEventManager { + /** + * There are four possible orders of events: + * A. `_onRequestWillBeSent` + * B. `_onRequestWillBeSent`, `_onRequestPaused` + * C. `_onRequestPaused`, `_onRequestWillBeSent` + * D. `_onRequestPaused`, `_onRequestWillBeSent`, `_onRequestPaused`, + * `_onRequestWillBeSent`, `_onRequestPaused`, `_onRequestPaused` + * (see crbug.com/1196004) + * + * For `_onRequest` we need the event from `_onRequestWillBeSent` and + * optionally the `interceptionId` from `_onRequestPaused`. + * + * If request interception is disabled, call `_onRequest` once per call to + * `_onRequestWillBeSent`. + * If request interception is enabled, call `_onRequest` once per call to + * `_onRequestPaused` (once per `interceptionId`). + * + * Events are stored to allow for subsequent events to call `_onRequest`. + * + * Note that (chains of) redirect requests have the same `requestId` (!) as + * the original request. We have to anticipate series of events like these: + * A. `_onRequestWillBeSent`, + * `_onRequestWillBeSent`, ... + * B. `_onRequestWillBeSent`, `_onRequestPaused`, + * `_onRequestWillBeSent`, `_onRequestPaused`, ... + * C. `_onRequestWillBeSent`, `_onRequestPaused`, + * `_onRequestPaused`, `_onRequestWillBeSent`, ... + * D. `_onRequestPaused`, `_onRequestWillBeSent`, + * `_onRequestPaused`, `_onRequestWillBeSent`, `_onRequestPaused`, + * `_onRequestWillBeSent`, `_onRequestPaused`, `_onRequestPaused`, ... + * (see crbug.com/1196004) + */ + #requestWillBeSentMap = new Map< + NetworkRequestId, + Protocol.Network.RequestWillBeSentEvent + >(); + #requestPausedMap = new Map< + NetworkRequestId, + Protocol.Fetch.RequestPausedEvent + >(); + #httpRequestsMap = new Map<NetworkRequestId, CdpHTTPRequest>(); + + /* + * The below maps are used to reconcile Network.responseReceivedExtraInfo + * events with their corresponding request. Each response and redirect + * response gets an ExtraInfo event, and we don't know which will come first. + * This means that we have to store a Response or an ExtraInfo for each + * response, and emit the event when we get both of them. In addition, to + * handle redirects, we have to make them Arrays to represent the chain of + * events. + */ + #responseReceivedExtraInfoMap = new Map< + NetworkRequestId, + Protocol.Network.ResponseReceivedExtraInfoEvent[] + >(); + #queuedRedirectInfoMap = new Map<NetworkRequestId, RedirectInfoList>(); + #queuedEventGroupMap = new Map<NetworkRequestId, QueuedEventGroup>(); + + forget(networkRequestId: NetworkRequestId): void { + this.#requestWillBeSentMap.delete(networkRequestId); + this.#requestPausedMap.delete(networkRequestId); + this.#queuedEventGroupMap.delete(networkRequestId); + this.#queuedRedirectInfoMap.delete(networkRequestId); + this.#responseReceivedExtraInfoMap.delete(networkRequestId); + } + + responseExtraInfo( + networkRequestId: NetworkRequestId + ): Protocol.Network.ResponseReceivedExtraInfoEvent[] { + if (!this.#responseReceivedExtraInfoMap.has(networkRequestId)) { + this.#responseReceivedExtraInfoMap.set(networkRequestId, []); + } + return this.#responseReceivedExtraInfoMap.get( + networkRequestId + ) as Protocol.Network.ResponseReceivedExtraInfoEvent[]; + } + + private queuedRedirectInfo(fetchRequestId: FetchRequestId): RedirectInfoList { + if (!this.#queuedRedirectInfoMap.has(fetchRequestId)) { + this.#queuedRedirectInfoMap.set(fetchRequestId, []); + } + return this.#queuedRedirectInfoMap.get(fetchRequestId) as RedirectInfoList; + } + + queueRedirectInfo( + fetchRequestId: FetchRequestId, + redirectInfo: RedirectInfo + ): void { + this.queuedRedirectInfo(fetchRequestId).push(redirectInfo); + } + + takeQueuedRedirectInfo( + fetchRequestId: FetchRequestId + ): RedirectInfo | undefined { + return this.queuedRedirectInfo(fetchRequestId).shift(); + } + + inFlightRequestsCount(): number { + let inFlightRequestCounter = 0; + for (const request of this.#httpRequestsMap.values()) { + if (!request.response()) { + inFlightRequestCounter++; + } + } + return inFlightRequestCounter; + } + + storeRequestWillBeSent( + networkRequestId: NetworkRequestId, + event: Protocol.Network.RequestWillBeSentEvent + ): void { + this.#requestWillBeSentMap.set(networkRequestId, event); + } + + getRequestWillBeSent( + networkRequestId: NetworkRequestId + ): Protocol.Network.RequestWillBeSentEvent | undefined { + return this.#requestWillBeSentMap.get(networkRequestId); + } + + forgetRequestWillBeSent(networkRequestId: NetworkRequestId): void { + this.#requestWillBeSentMap.delete(networkRequestId); + } + + getRequestPaused( + networkRequestId: NetworkRequestId + ): Protocol.Fetch.RequestPausedEvent | undefined { + return this.#requestPausedMap.get(networkRequestId); + } + + forgetRequestPaused(networkRequestId: NetworkRequestId): void { + this.#requestPausedMap.delete(networkRequestId); + } + + storeRequestPaused( + networkRequestId: NetworkRequestId, + event: Protocol.Fetch.RequestPausedEvent + ): void { + this.#requestPausedMap.set(networkRequestId, event); + } + + getRequest(networkRequestId: NetworkRequestId): CdpHTTPRequest | undefined { + return this.#httpRequestsMap.get(networkRequestId); + } + + storeRequest( + networkRequestId: NetworkRequestId, + request: CdpHTTPRequest + ): void { + this.#httpRequestsMap.set(networkRequestId, request); + } + + forgetRequest(networkRequestId: NetworkRequestId): void { + this.#httpRequestsMap.delete(networkRequestId); + } + + getQueuedEventGroup( + networkRequestId: NetworkRequestId + ): QueuedEventGroup | undefined { + return this.#queuedEventGroupMap.get(networkRequestId); + } + + queueEventGroup( + networkRequestId: NetworkRequestId, + event: QueuedEventGroup + ): void { + this.#queuedEventGroupMap.set(networkRequestId, event); + } + + forgetQueuedEventGroup(networkRequestId: NetworkRequestId): void { + this.#queuedEventGroupMap.delete(networkRequestId); + } +} 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 new file mode 100644 index 0000000000..c3e9a8f609 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.test.ts @@ -0,0 +1,1531 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, it} from 'node:test'; + +import expect from 'expect'; + +import type {CDPSessionEvents} from '../api/CDPSession.js'; +import type {HTTPRequest} from '../api/HTTPRequest.js'; +import type {HTTPResponse} from '../api/HTTPResponse.js'; +import {EventEmitter} from '../common/EventEmitter.js'; +import {NetworkManagerEvent} from '../common/NetworkManagerEvents.js'; + +import type {CdpFrame} from './Frame.js'; +import {NetworkManager} from './NetworkManager.js'; + +// TODO: develop a helper to generate fake network events for attributes that +// are not relevant for the network manager to make tests shorter. + +class MockCDPSession extends EventEmitter<CDPSessionEvents> { + async send(): Promise<any> {} + connection() { + return undefined; + } + async detach() {} + id() { + return '1'; + } + parentSession() { + return undefined; + } +} + +describe('NetworkManager', () => { + it('should process extra info on multiple redirects', async () => { + const mockCDPSession = new MockCDPSession(); + const manager = new NetworkManager(true, { + frame(): CdpFrame | null { + return null; + }, + }); + await manager.addClient(mockCDPSession); + mockCDPSession.emit('Network.requestWillBeSent', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + loaderId: '7760711DEFCFA23132D98ABA6B4E175C', + documentURL: 'http://localhost:8907/redirect/1.html', + request: { + url: 'http://localhost:8907/redirect/1.html', + method: 'GET', + headers: { + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/97.0.4691.0 Safari/537.36', + }, + mixedContentType: 'none', + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + isSameSite: true, + }, + timestamp: 2111.55635, + wallTime: 1637315638.473634, + initiator: {type: 'other'}, + redirectHasExtraInfo: false, + type: 'Document', + frameId: '099A5216AF03AAFEC988F214B024DF08', + hasUserGesture: false, + }); + + mockCDPSession.emit('Network.requestWillBeSentExtraInfo', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + associatedCookies: [], + headers: { + Host: 'localhost:8907', + Connection: 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/97.0.4691.0 Safari/537.36', + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', + 'Sec-Fetch-Site': 'none', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-User': '?1', + 'Sec-Fetch-Dest': 'document', + 'Accept-Encoding': 'gzip, deflate, br', + }, + connectTiming: {requestTime: 2111.557593}, + }); + mockCDPSession.emit('Network.responseReceivedExtraInfo', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + blockedCookies: [], + headers: { + location: '/redirect/2.html', + Date: 'Fri, 19 Nov 2021 09:53:58 GMT', + Connection: 'keep-alive', + 'Keep-Alive': 'timeout=5', + 'Transfer-Encoding': 'chunked', + }, + resourceIPAddressSpace: 'Local', + statusCode: 302, + headersText: + 'HTTP/1.1 302 Found\r\nlocation: /redirect/2.html\r\nDate: Fri, 19 Nov 2021 09:53:58 GMT\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\nTransfer-Encoding: chunked\r\n\r\n', + }); + mockCDPSession.emit('Network.requestWillBeSent', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + loaderId: '7760711DEFCFA23132D98ABA6B4E175C', + documentURL: 'http://localhost:8907/redirect/2.html', + request: { + url: 'http://localhost:8907/redirect/2.html', + method: 'GET', + headers: { + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/97.0.4691.0 Safari/537.36', + }, + mixedContentType: 'none', + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + isSameSite: true, + }, + timestamp: 2111.559124, + wallTime: 1637315638.47642, + initiator: {type: 'other'}, + redirectHasExtraInfo: true, + redirectResponse: { + url: 'http://localhost:8907/redirect/1.html', + status: 302, + statusText: 'Found', + headers: { + location: '/redirect/2.html', + Date: 'Fri, 19 Nov 2021 09:53:58 GMT', + Connection: 'keep-alive', + 'Keep-Alive': 'timeout=5', + 'Transfer-Encoding': 'chunked', + }, + mimeType: '', + connectionReused: false, + connectionId: 322, + remoteIPAddress: '[::1]', + remotePort: 8907, + fromDiskCache: false, + fromServiceWorker: false, + fromPrefetchCache: false, + encodedDataLength: 162, + timing: { + receiveHeadersStart: 0, + requestTime: 2111.557593, + proxyStart: -1, + proxyEnd: -1, + dnsStart: 0.241, + dnsEnd: 0.251, + connectStart: 0.251, + connectEnd: 0.47, + sslStart: -1, + sslEnd: -1, + workerStart: -1, + workerReady: -1, + workerFetchStart: -1, + workerRespondWithSettled: -1, + sendStart: 0.537, + sendEnd: 0.611, + pushStart: 0, + pushEnd: 0, + receiveHeadersEnd: 0.939, + }, + responseTime: 1.637315638475744e12, + protocol: 'http/1.1', + securityState: 'secure', + }, + type: 'Document', + frameId: '099A5216AF03AAFEC988F214B024DF08', + hasUserGesture: false, + }); + mockCDPSession.emit('Network.requestWillBeSentExtraInfo', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + associatedCookies: [], + headers: { + Host: 'localhost:8907', + Connection: 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/97.0.4691.0 Safari/537.36', + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', + 'Sec-Fetch-Site': 'none', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-User': '?1', + 'Sec-Fetch-Dest': 'document', + 'Accept-Encoding': 'gzip, deflate, br', + }, + connectTiming: {requestTime: 2111.559346}, + }); + mockCDPSession.emit('Network.requestWillBeSent', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + loaderId: '7760711DEFCFA23132D98ABA6B4E175C', + documentURL: 'http://localhost:8907/redirect/3.html', + request: { + url: 'http://localhost:8907/redirect/3.html', + method: 'GET', + headers: { + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/97.0.4691.0 Safari/537.36', + }, + mixedContentType: 'none', + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + isSameSite: true, + }, + timestamp: 2111.560249, + wallTime: 1637315638.477543, + initiator: {type: 'other'}, + redirectHasExtraInfo: true, + redirectResponse: { + url: 'http://localhost:8907/redirect/2.html', + status: 302, + statusText: 'Found', + headers: { + location: '/redirect/3.html', + Date: 'Fri, 19 Nov 2021 09:53:58 GMT', + Connection: 'keep-alive', + 'Keep-Alive': 'timeout=5', + 'Transfer-Encoding': 'chunked', + }, + mimeType: '', + connectionReused: true, + connectionId: 322, + remoteIPAddress: '[::1]', + remotePort: 8907, + fromDiskCache: false, + fromServiceWorker: false, + fromPrefetchCache: false, + encodedDataLength: 162, + timing: { + receiveHeadersStart: 0, + requestTime: 2111.559346, + proxyStart: -1, + proxyEnd: -1, + dnsStart: -1, + dnsEnd: -1, + connectStart: -1, + connectEnd: -1, + sslStart: -1, + sslEnd: -1, + workerStart: -1, + workerReady: -1, + workerFetchStart: -1, + workerRespondWithSettled: -1, + sendStart: 0.15, + sendEnd: 0.196, + pushStart: 0, + pushEnd: 0, + receiveHeadersEnd: 0.507, + }, + responseTime: 1.637315638477063e12, + protocol: 'http/1.1', + securityState: 'secure', + }, + type: 'Document', + frameId: '099A5216AF03AAFEC988F214B024DF08', + hasUserGesture: false, + }); + mockCDPSession.emit('Network.responseReceivedExtraInfo', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + blockedCookies: [], + headers: { + location: '/redirect/3.html', + Date: 'Fri, 19 Nov 2021 09:53:58 GMT', + Connection: 'keep-alive', + 'Keep-Alive': 'timeout=5', + 'Transfer-Encoding': 'chunked', + }, + resourceIPAddressSpace: 'Local', + statusCode: 302, + headersText: + 'HTTP/1.1 302 Found\r\nlocation: /redirect/3.html\r\nDate: Fri, 19 Nov 2021 09:53:58 GMT\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\nTransfer-Encoding: chunked\r\n\r\n', + }); + mockCDPSession.emit('Network.requestWillBeSentExtraInfo', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + associatedCookies: [], + headers: { + Host: 'localhost:8907', + Connection: 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/97.0.4691.0 Safari/537.36', + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', + 'Sec-Fetch-Site': 'none', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-User': '?1', + 'Sec-Fetch-Dest': 'document', + 'Accept-Encoding': 'gzip, deflate, br', + }, + connectTiming: {requestTime: 2111.560482}, + }); + mockCDPSession.emit('Network.requestWillBeSent', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + loaderId: '7760711DEFCFA23132D98ABA6B4E175C', + documentURL: 'http://localhost:8907/empty.html', + request: { + url: 'http://localhost:8907/empty.html', + method: 'GET', + headers: { + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/97.0.4691.0 Safari/537.36', + }, + mixedContentType: 'none', + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + isSameSite: true, + }, + timestamp: 2111.561542, + wallTime: 1637315638.478837, + initiator: {type: 'other'}, + redirectHasExtraInfo: true, + redirectResponse: { + url: 'http://localhost:8907/redirect/3.html', + status: 302, + statusText: 'Found', + headers: { + location: 'http://localhost:8907/empty.html', + Date: 'Fri, 19 Nov 2021 09:53:58 GMT', + Connection: 'keep-alive', + 'Keep-Alive': 'timeout=5', + 'Transfer-Encoding': 'chunked', + }, + mimeType: '', + connectionReused: true, + connectionId: 322, + remoteIPAddress: '[::1]', + remotePort: 8907, + fromDiskCache: false, + fromServiceWorker: false, + fromPrefetchCache: false, + encodedDataLength: 178, + timing: { + receiveHeadersStart: 0, + requestTime: 2111.560482, + proxyStart: -1, + proxyEnd: -1, + dnsStart: -1, + dnsEnd: -1, + connectStart: -1, + connectEnd: -1, + sslStart: -1, + sslEnd: -1, + workerStart: -1, + workerReady: -1, + workerFetchStart: -1, + workerRespondWithSettled: -1, + sendStart: 0.149, + sendEnd: 0.198, + pushStart: 0, + pushEnd: 0, + receiveHeadersEnd: 0.478, + }, + responseTime: 1.637315638478184e12, + protocol: 'http/1.1', + securityState: 'secure', + }, + type: 'Document', + frameId: '099A5216AF03AAFEC988F214B024DF08', + hasUserGesture: false, + }); + mockCDPSession.emit('Network.responseReceivedExtraInfo', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + blockedCookies: [], + headers: { + location: 'http://localhost:8907/empty.html', + Date: 'Fri, 19 Nov 2021 09:53:58 GMT', + Connection: 'keep-alive', + 'Keep-Alive': 'timeout=5', + 'Transfer-Encoding': 'chunked', + }, + resourceIPAddressSpace: 'Local', + statusCode: 302, + headersText: + 'HTTP/1.1 302 Found\r\nlocation: http://localhost:8907/empty.html\r\nDate: Fri, 19 Nov 2021 09:53:58 GMT\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\nTransfer-Encoding: chunked\r\n\r\n', + }); + mockCDPSession.emit('Network.requestWillBeSentExtraInfo', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + associatedCookies: [], + headers: { + Host: 'localhost:8907', + Connection: 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/97.0.4691.0 Safari/537.36', + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', + 'Sec-Fetch-Site': 'none', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-User': '?1', + 'Sec-Fetch-Dest': 'document', + 'Accept-Encoding': 'gzip, deflate, br', + }, + connectTiming: {requestTime: 2111.561759}, + }); + mockCDPSession.emit('Network.responseReceivedExtraInfo', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + blockedCookies: [], + headers: { + 'Cache-Control': 'no-cache, no-store', + 'Content-Type': 'text/html; charset=utf-8', + Date: 'Fri, 19 Nov 2021 09:53:58 GMT', + Connection: 'keep-alive', + 'Keep-Alive': 'timeout=5', + 'Content-Length': '0', + }, + resourceIPAddressSpace: 'Local', + statusCode: 200, + headersText: + 'HTTP/1.1 200 OK\r\nCache-Control: no-cache, no-store\r\nContent-Type: text/html; charset=utf-8\r\nDate: Fri, 19 Nov 2021 09:53:58 GMT\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\nContent-Length: 0\r\n\r\n', + }); + mockCDPSession.emit('Network.responseReceived', { + requestId: '7760711DEFCFA23132D98ABA6B4E175C', + loaderId: '7760711DEFCFA23132D98ABA6B4E175C', + timestamp: 2111.563565, + type: 'Document', + response: { + url: 'http://localhost:8907/empty.html', + status: 200, + statusText: 'OK', + headers: { + 'Cache-Control': 'no-cache, no-store', + 'Content-Type': 'text/html; charset=utf-8', + Date: 'Fri, 19 Nov 2021 09:53:58 GMT', + Connection: 'keep-alive', + 'Keep-Alive': 'timeout=5', + 'Content-Length': '0', + }, + mimeType: 'text/html', + connectionReused: true, + connectionId: 322, + remoteIPAddress: '[::1]', + remotePort: 8907, + fromDiskCache: false, + fromServiceWorker: false, + fromPrefetchCache: false, + encodedDataLength: 197, + timing: { + receiveHeadersStart: 0, + requestTime: 2111.561759, + proxyStart: -1, + proxyEnd: -1, + dnsStart: -1, + dnsEnd: -1, + connectStart: -1, + connectEnd: -1, + sslStart: -1, + sslEnd: -1, + workerStart: -1, + workerReady: -1, + workerFetchStart: -1, + workerRespondWithSettled: -1, + sendStart: 0.148, + sendEnd: 0.19, + pushStart: 0, + pushEnd: 0, + receiveHeadersEnd: 0.925, + }, + responseTime: 1.637315638479928e12, + protocol: 'http/1.1', + securityState: 'secure', + }, + hasExtraInfo: true, + frameId: '099A5216AF03AAFEC988F214B024DF08', + }); + }); + it(`should handle "double pause" (crbug.com/1196004) Fetch.requestPaused events for the same Network.requestWillBeSent event`, async () => { + const mockCDPSession = new MockCDPSession(); + const manager = new NetworkManager(true, { + frame(): CdpFrame | null { + return null; + }, + }); + await manager.addClient(mockCDPSession); + await manager.setRequestInterception(true); + + const requests: HTTPRequest[] = []; + manager.on(NetworkManagerEvent.Request, async (request: HTTPRequest) => { + requests.push(request); + await request.continue(); + }); + + /** + * This sequence was taken from an actual CDP session produced by the following + * test script: + * + * ```ts + * const browser = await puppeteer.launch({headless: false}); + * const page = await browser.newPage(); + * await page.setCacheEnabled(false); + * + * await page.setRequestInterception(true); + * page.on('request', interceptedRequest => { + * interceptedRequest.continue(); + * }); + * + * await page.goto('https://www.google.com'); + * await browser.close(); + * ``` + */ + mockCDPSession.emit('Network.requestWillBeSent', { + requestId: '11ACE9783588040D644B905E8B55285B', + loaderId: '11ACE9783588040D644B905E8B55285B', + documentURL: 'https://www.google.com/', + request: { + url: 'https://www.google.com/', + method: 'GET', + headers: {}, + mixedContentType: 'none', + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + isSameSite: true, + }, + timestamp: 224604.980827, + wallTime: 1637955746.786191, + initiator: {type: 'other'}, + redirectHasExtraInfo: false, + type: 'Document', + frameId: '84AC261A351B86932B775B76D1DD79F8', + hasUserGesture: false, + }); + mockCDPSession.emit('Fetch.requestPaused', { + requestId: 'interception-job-1.0', + request: { + url: 'https://www.google.com/', + method: 'GET', + headers: {}, + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + }, + frameId: '84AC261A351B86932B775B76D1DD79F8', + resourceType: 'Document', + networkId: '11ACE9783588040D644B905E8B55285B', + }); + mockCDPSession.emit('Fetch.requestPaused', { + requestId: 'interception-job-2.0', + request: { + url: 'https://www.google.com/', + method: 'GET', + headers: {}, + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + }, + frameId: '84AC261A351B86932B775B76D1DD79F8', + resourceType: 'Document', + networkId: '11ACE9783588040D644B905E8B55285B', + }); + + expect(requests).toHaveLength(2); + }); + it(`should handle Network.responseReceivedExtraInfo event after Network.responseReceived event (github.com/puppeteer/puppeteer/issues/8234)`, async () => { + const mockCDPSession = new MockCDPSession(); + const manager = new NetworkManager(true, { + frame(): CdpFrame | null { + return null; + }, + }); + await manager.addClient(mockCDPSession); + + const requests: HTTPRequest[] = []; + manager.on(NetworkManagerEvent.RequestFinished, (request: HTTPRequest) => { + requests.push(request); + }); + + mockCDPSession.emit('Network.requestWillBeSent', { + requestId: '1360.2', + loaderId: '9E86B0282CC98B77FB0ABD49156DDFDD', + documentURL: 'http://this.is.the.start.page.com/', + request: { + url: 'http://this.is.a.test.com:1080/test.js', + method: 'GET', + headers: { + 'Accept-Language': 'en-US,en;q=0.9', + Referer: 'http://this.is.the.start.page.com/', + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.0 Safari/537.36', + }, + mixedContentType: 'none', + initialPriority: 'High', + referrerPolicy: 'strict-origin-when-cross-origin', + isSameSite: false, + }, + timestamp: 10959.020087, + wallTime: 1649712607.861365, + initiator: { + type: 'parser', + url: 'http://this.is.the.start.page.com/', + lineNumber: 9, + columnNumber: 80, + }, + redirectHasExtraInfo: false, + type: 'Script', + frameId: '60E6C35E7E519F28E646056820095498', + hasUserGesture: false, + }); + mockCDPSession.emit('Network.responseReceived', { + requestId: '1360.2', + loaderId: '9E86B0282CC98B77FB0ABD49156DDFDD', + timestamp: 10959.042529, + type: 'Script', + response: { + url: 'http://this.is.a.test.com:1080', + status: 200, + statusText: 'OK', + headers: { + connection: 'keep-alive', + 'content-length': '85862', + }, + mimeType: 'text/plain', + connectionReused: false, + connectionId: 119, + remoteIPAddress: '127.0.0.1', + remotePort: 1080, + fromDiskCache: false, + fromServiceWorker: false, + fromPrefetchCache: false, + encodedDataLength: 66, + timing: { + receiveHeadersStart: 0, + requestTime: 10959.023904, + proxyStart: -1, + proxyEnd: -1, + dnsStart: 0.328, + dnsEnd: 2.183, + connectStart: 2.183, + connectEnd: 2.798, + sslStart: -1, + sslEnd: -1, + workerStart: -1, + workerReady: -1, + workerFetchStart: -1, + workerRespondWithSettled: -1, + sendStart: 2.982, + sendEnd: 3.757, + pushStart: 0, + pushEnd: 0, + receiveHeadersEnd: 16.373, + }, + responseTime: 1649712607880.971, + protocol: 'http/1.1', + securityState: 'insecure', + }, + hasExtraInfo: true, + frameId: '60E6C35E7E519F28E646056820095498', + }); + mockCDPSession.emit('Network.responseReceivedExtraInfo', { + requestId: '1360.2', + blockedCookies: [], + headers: { + connection: 'keep-alive', + 'content-length': '85862', + }, + resourceIPAddressSpace: 'Private', + statusCode: 200, + headersText: + 'HTTP/1.1 200 OK\r\nconnection: keep-alive\r\ncontent-length: 85862\r\n\r\n', + }); + mockCDPSession.emit('Network.loadingFinished', { + requestId: '1360.2', + timestamp: 10959.060708, + encodedDataLength: 85928, + }); + + expect(requests).toHaveLength(1); + }); + + it(`should resolve the response once the late responseReceivedExtraInfo event arrives`, async () => { + const mockCDPSession = new MockCDPSession(); + const manager = new NetworkManager(true, { + frame(): CdpFrame | null { + return null; + }, + }); + await manager.addClient(mockCDPSession); + + const finishedRequests: HTTPRequest[] = []; + const pendingRequests: HTTPRequest[] = []; + manager.on(NetworkManagerEvent.RequestFinished, (request: HTTPRequest) => { + finishedRequests.push(request); + }); + + manager.on(NetworkManagerEvent.Request, (request: HTTPRequest) => { + pendingRequests.push(request); + }); + + mockCDPSession.emit('Network.requestWillBeSent', { + requestId: 'LOADERID', + loaderId: 'LOADERID', + documentURL: 'http://10.1.0.39:42915/empty.html', + request: { + url: 'http://10.1.0.39:42915/empty.html', + method: 'GET', + headers: { + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36', + }, + mixedContentType: 'none', + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + isSameSite: true, + }, + timestamp: 671.229856, + wallTime: 1660121157.913774, + initiator: {type: 'other'}, + redirectHasExtraInfo: false, + type: 'Document', + frameId: 'FRAMEID', + hasUserGesture: false, + }); + + mockCDPSession.emit('Network.responseReceived', { + requestId: 'LOADERID', + loaderId: 'LOADERID', + timestamp: 671.236025, + type: 'Document', + response: { + url: 'http://10.1.0.39:42915/empty.html', + status: 200, + statusText: 'OK', + headers: { + 'Cache-Control': 'no-cache, no-store', + Connection: 'keep-alive', + 'Content-Length': '0', + 'Content-Type': 'text/html; charset=utf-8', + Date: 'Wed, 10 Aug 2022 08:45:57 GMT', + 'Keep-Alive': 'timeout=5', + }, + mimeType: 'text/html', + connectionReused: true, + connectionId: 18, + remoteIPAddress: '10.1.0.39', + remotePort: 42915, + fromDiskCache: false, + fromServiceWorker: false, + fromPrefetchCache: false, + encodedDataLength: 197, + timing: { + receiveHeadersStart: 0, + requestTime: 671.232585, + proxyStart: -1, + proxyEnd: -1, + dnsStart: -1, + dnsEnd: -1, + connectStart: -1, + connectEnd: -1, + sslStart: -1, + sslEnd: -1, + workerStart: -1, + workerReady: -1, + workerFetchStart: -1, + workerRespondWithSettled: -1, + sendStart: 0.308, + sendEnd: 0.364, + pushStart: 0, + pushEnd: 0, + receiveHeadersEnd: 1.554, + }, + responseTime: 1.660121157917951e12, + protocol: 'http/1.1', + securityState: 'insecure', + }, + hasExtraInfo: true, + frameId: 'FRAMEID', + }); + + mockCDPSession.emit('Network.requestWillBeSentExtraInfo', { + requestId: 'LOADERID', + associatedCookies: [], + headers: { + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', + 'Accept-Encoding': 'gzip, deflate', + 'Accept-Language': 'en-US,en;q=0.9', + Connection: 'keep-alive', + Host: '10.1.0.39:42915', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36', + }, + connectTiming: {requestTime: 671.232585}, + }); + + mockCDPSession.emit('Network.loadingFinished', { + requestId: 'LOADERID', + timestamp: 671.234448, + encodedDataLength: 197, + }); + + expect(pendingRequests).toHaveLength(1); + expect(finishedRequests).toHaveLength(0); + expect(pendingRequests[0]!.response()).toEqual(null); + + // The extra info might arrive late. + mockCDPSession.emit('Network.responseReceivedExtraInfo', { + requestId: 'LOADERID', + blockedCookies: [], + headers: { + 'Cache-Control': 'no-cache, no-store', + Connection: 'keep-alive', + 'Content-Length': '0', + 'Content-Type': 'text/html; charset=utf-8', + Date: 'Wed, 10 Aug 2022 09:04:39 GMT', + 'Keep-Alive': 'timeout=5', + }, + resourceIPAddressSpace: 'Private', + statusCode: 200, + headersText: + 'HTTP/1.1 200 OK\\r\\nCache-Control: no-cache, no-store\\r\\nContent-Type: text/html; charset=utf-8\\r\\nDate: Wed, 10 Aug 2022 09:04:39 GMT\\r\\nConnection: keep-alive\\r\\nKeep-Alive: timeout=5\\r\\nContent-Length: 0\\r\\n\\r\\n', + }); + + expect(pendingRequests).toHaveLength(1); + expect(finishedRequests).toHaveLength(1); + expect(pendingRequests[0]!.response()).not.toEqual(null); + }); + + it(`should send responses for iframe that don't receive loadingFinished event`, async () => { + const mockCDPSession = new MockCDPSession(); + const manager = new NetworkManager(true, { + frame(): CdpFrame | null { + return null; + }, + }); + await manager.addClient(mockCDPSession); + + const responses: HTTPResponse[] = []; + const requests: HTTPRequest[] = []; + manager.on(NetworkManagerEvent.Response, (response: HTTPResponse) => { + responses.push(response); + }); + + manager.on(NetworkManagerEvent.Request, (request: HTTPRequest) => { + requests.push(request); + }); + + mockCDPSession.emit('Network.requestWillBeSent', { + requestId: '94051D839ACF29E53A3D1273FB20B4C4', + loaderId: '94051D839ACF29E53A3D1273FB20B4C4', + documentURL: 'http://127.0.0.1:54590/empty.html', + request: { + url: 'http://127.0.0.1:54590/empty.html', + method: 'GET', + headers: { + Referer: 'http://localhost:54590/', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/105.0.5173.0 Safari/537.36', + }, + mixedContentType: 'none', + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + isSameSite: false, + }, + timestamp: 504903.99901, + wallTime: 1660125092.026021, + initiator: { + type: 'script', + stack: { + callFrames: [ + { + functionName: 'navigateFrame', + scriptId: '8', + url: 'pptr://__puppeteer_evaluation_script__', + lineNumber: 2, + columnNumber: 18, + }, + ], + }, + }, + redirectHasExtraInfo: false, + type: 'Document', + frameId: '07D18B8630A8161C72B6079B74123D60', + hasUserGesture: true, + }); + + mockCDPSession.emit('Network.requestWillBeSentExtraInfo', { + requestId: '94051D839ACF29E53A3D1273FB20B4C4', + associatedCookies: [], + headers: { + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', + 'Accept-Encoding': 'gzip, deflate, br', + Connection: 'keep-alive', + Host: '127.0.0.1:54590', + Referer: 'http://localhost:54590/', + 'Sec-Fetch-Dest': 'iframe', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'cross-site', + 'Sec-Fetch-User': '?1', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/105.0.5173.0 Safari/537.36', + }, + connectTiming: {requestTime: 504904.000422}, + clientSecurityState: { + initiatorIsSecureContext: true, + initiatorIPAddressSpace: 'Local', + privateNetworkRequestPolicy: 'Allow', + }, + }); + + mockCDPSession.emit('Network.responseReceivedExtraInfo', { + requestId: '94051D839ACF29E53A3D1273FB20B4C4', + blockedCookies: [], + headers: { + 'Cache-Control': 'no-cache, no-store', + Connection: 'keep-alive', + 'Content-Length': '0', + 'Content-Type': 'text/html; charset=utf-8', + Date: 'Wed, 10 Aug 2022 09:51:32 GMT', + 'Keep-Alive': 'timeout=5', + }, + resourceIPAddressSpace: 'Local', + statusCode: 200, + headersText: + 'HTTP/1.1 200 OK\r\nCache-Control: no-cache, no-store\r\nContent-Type: text/html; charset=utf-8\r\nDate: Wed, 10 Aug 2022 09:51:32 GMT\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\nContent-Length: 0\r\n\r\n', + }); + + mockCDPSession.emit('Network.responseReceived', { + requestId: '94051D839ACF29E53A3D1273FB20B4C4', + loaderId: '94051D839ACF29E53A3D1273FB20B4C4', + timestamp: 504904.00338, + type: 'Document', + response: { + url: 'http://127.0.0.1:54590/empty.html', + status: 200, + statusText: 'OK', + headers: { + 'Cache-Control': 'no-cache, no-store', + Connection: 'keep-alive', + 'Content-Length': '0', + 'Content-Type': 'text/html; charset=utf-8', + Date: 'Wed, 10 Aug 2022 09:51:32 GMT', + 'Keep-Alive': 'timeout=5', + }, + mimeType: 'text/html', + connectionReused: true, + connectionId: 13, + remoteIPAddress: '127.0.0.1', + remotePort: 54590, + fromDiskCache: false, + fromServiceWorker: false, + fromPrefetchCache: false, + encodedDataLength: 197, + timing: { + receiveHeadersStart: 0, + requestTime: 504904.000422, + proxyStart: -1, + proxyEnd: -1, + dnsStart: -1, + dnsEnd: -1, + connectStart: -1, + connectEnd: -1, + sslStart: -1, + sslEnd: -1, + workerStart: -1, + workerReady: -1, + workerFetchStart: -1, + workerRespondWithSettled: -1, + sendStart: 0.338, + sendEnd: 0.413, + pushStart: 0, + pushEnd: 0, + receiveHeadersEnd: 1.877, + }, + responseTime: 1.660125092029241e12, + protocol: 'http/1.1', + securityState: 'secure', + }, + hasExtraInfo: true, + frameId: '07D18B8630A8161C72B6079B74123D60', + }); + + expect(requests).toHaveLength(1); + expect(responses).toHaveLength(1); + expect(requests[0]!.response()).not.toEqual(null); + }); + + it(`should send responses for iframe that don't receive loadingFinished event`, async () => { + const mockCDPSession = new MockCDPSession(); + const manager = new NetworkManager(true, { + frame(): CdpFrame | null { + return null; + }, + }); + await manager.addClient(mockCDPSession); + + const responses: HTTPResponse[] = []; + const requests: HTTPRequest[] = []; + manager.on(NetworkManagerEvent.Response, (response: HTTPResponse) => { + responses.push(response); + }); + + manager.on(NetworkManagerEvent.Request, (request: HTTPRequest) => { + requests.push(request); + }); + + mockCDPSession.emit('Network.requestWillBeSent', { + requestId: 'E18BEB94B486CA8771F9AFA2030FEA37', + loaderId: 'E18BEB94B486CA8771F9AFA2030FEA37', + documentURL: 'http://localhost:56295/empty.html', + request: { + url: 'http://localhost:56295/empty.html', + method: 'GET', + headers: { + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/105.0.5173.0 Safari/537.36', + }, + mixedContentType: 'none', + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + isSameSite: true, + }, + timestamp: 510294.105656, + wallTime: 1660130482.230591, + initiator: {type: 'other'}, + redirectHasExtraInfo: false, + type: 'Document', + frameId: 'F9C89A517341F1EFFE63310141630189', + hasUserGesture: false, + }); + mockCDPSession.emit('Network.responseReceived', { + requestId: 'E18BEB94B486CA8771F9AFA2030FEA37', + loaderId: 'E18BEB94B486CA8771F9AFA2030FEA37', + timestamp: 510294.119816, + type: 'Document', + response: { + url: 'http://localhost:56295/empty.html', + status: 200, + statusText: 'OK', + headers: { + 'Cache-Control': 'no-cache, no-store', + Connection: 'keep-alive', + 'Content-Length': '0', + 'Content-Type': 'text/html; charset=utf-8', + Date: 'Wed, 10 Aug 2022 11:21:22 GMT', + 'Keep-Alive': 'timeout=5', + }, + mimeType: 'text/html', + connectionReused: true, + connectionId: 13, + remoteIPAddress: '[::1]', + remotePort: 56295, + fromDiskCache: false, + fromServiceWorker: false, + fromPrefetchCache: false, + encodedDataLength: 197, + timing: { + receiveHeadersStart: 0, + requestTime: 510294.106734, + proxyStart: -1, + proxyEnd: -1, + dnsStart: -1, + dnsEnd: -1, + connectStart: -1, + connectEnd: -1, + sslStart: -1, + sslEnd: -1, + workerStart: -1, + workerReady: -1, + workerFetchStart: -1, + workerRespondWithSettled: -1, + sendStart: 2.195, + sendEnd: 2.29, + pushStart: 0, + pushEnd: 0, + receiveHeadersEnd: 6.493, + }, + responseTime: 1.660130482238109e12, + protocol: 'http/1.1', + securityState: 'secure', + }, + hasExtraInfo: true, + frameId: 'F9C89A517341F1EFFE63310141630189', + }); + mockCDPSession.emit('Network.requestWillBeSentExtraInfo', { + requestId: 'E18BEB94B486CA8771F9AFA2030FEA37', + associatedCookies: [], + headers: { + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', + 'Accept-Encoding': 'gzip, deflate, br', + Connection: 'keep-alive', + Host: 'localhost:56295', + 'Sec-Fetch-Dest': 'document', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'none', + 'Sec-Fetch-User': '?1', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/105.0.5173.0 Safari/537.36', + }, + connectTiming: {requestTime: 510294.106734}, + }); + mockCDPSession.emit('Network.loadingFinished', { + requestId: 'E18BEB94B486CA8771F9AFA2030FEA37', + timestamp: 510294.113383, + encodedDataLength: 197, + }); + mockCDPSession.emit('Network.responseReceivedExtraInfo', { + requestId: 'E18BEB94B486CA8771F9AFA2030FEA37', + blockedCookies: [], + headers: { + 'Cache-Control': 'no-cache, no-store', + Connection: 'keep-alive', + 'Content-Length': '0', + 'Content-Type': 'text/html; charset=utf-8', + Date: 'Wed, 10 Aug 2022 11:21:22 GMT', + 'Keep-Alive': 'timeout=5', + }, + resourceIPAddressSpace: 'Local', + statusCode: 200, + headersText: + 'HTTP/1.1 200 OK\r\nCache-Control: no-cache, no-store\r\nContent-Type: text/html; charset=utf-8\r\nDate: Wed, 10 Aug 2022 11:21:22 GMT\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\nContent-Length: 0\r\n\r\n', + }); + + expect(requests).toHaveLength(1); + expect(responses).toHaveLength(1); + expect(requests[0]!.response()).not.toEqual(null); + }); + + it(`should handle cached redirects`, async () => { + const mockCDPSession = new MockCDPSession(); + const manager = new NetworkManager(true, { + frame(): CdpFrame | null { + return null; + }, + }); + await manager.addClient(mockCDPSession); + + const responses: HTTPResponse[] = []; + const requests: HTTPRequest[] = []; + manager.on(NetworkManagerEvent.Response, (response: HTTPResponse) => { + responses.push(response); + }); + + manager.on(NetworkManagerEvent.Request, (request: HTTPRequest) => { + requests.push(request); + }); + + mockCDPSession.emit('Network.requestWillBeSent', { + requestId: '6D76C8ACAECE880C722FA515AD380015', + loaderId: '6D76C8ACAECE880C722FA515AD380015', + documentURL: 'http://localhost:3000/', + request: { + url: 'http://localhost:3000/', + method: 'GET', + headers: { + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36', + }, + mixedContentType: 'none', + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + isSameSite: true, + }, + timestamp: 31949.95878, + wallTime: 1680698353.570949, + initiator: {type: 'other'}, + redirectHasExtraInfo: false, + type: 'Document', + frameId: '4A6E05B1781795F1B586C1F8F8B2CBE4', + hasUserGesture: false, + }); + mockCDPSession.emit('Network.requestWillBeSentExtraInfo', { + requestId: '6D76C8ACAECE880C722FA515AD380015', + associatedCookies: [], + headers: { + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', + 'Accept-Encoding': 'gzip, deflate, br', + 'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8', + Connection: 'keep-alive', + Host: 'localhost:3000', + 'Sec-Fetch-Dest': 'document', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'none', + 'Sec-Fetch-User': '?1', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36', + 'sec-ch-ua-mobile': '?0', + }, + connectTiming: {requestTime: 31949.959838}, + siteHasCookieInOtherPartition: false, + }); + mockCDPSession.emit('Network.responseReceivedExtraInfo', { + requestId: '6D76C8ACAECE880C722FA515AD380015', + blockedCookies: [], + headers: { + 'Cache-Control': 'max-age=5', + Connection: 'keep-alive', + 'Content-Type': 'text/html; charset=utf-8', + Date: 'Wed, 05 Apr 2023 12:39:13 GMT', + 'Keep-Alive': 'timeout=5', + 'Transfer-Encoding': 'chunked', + }, + resourceIPAddressSpace: 'Local', + statusCode: 200, + headersText: + 'HTTP/1.1 200 OK\\r\\nContent-Type: text/html; charset=utf-8\\r\\nCache-Control: max-age=5\\r\\nDate: Wed, 05 Apr 2023 12:39:13 GMT\\r\\nConnection: keep-alive\\r\\nKeep-Alive: timeout=5\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n', + cookiePartitionKey: 'http://localhost', + cookiePartitionKeyOpaque: false, + }); + + mockCDPSession.emit('Network.responseReceived', { + requestId: '6D76C8ACAECE880C722FA515AD380015', + loaderId: '6D76C8ACAECE880C722FA515AD380015', + timestamp: 31949.965149, + type: 'Document', + response: { + url: 'http://localhost:3000/', + status: 200, + statusText: 'OK', + headers: { + 'Cache-Control': 'max-age=5', + Connection: 'keep-alive', + 'Content-Type': 'text/html; charset=utf-8', + Date: 'Wed, 05 Apr 2023 12:39:13 GMT', + 'Keep-Alive': 'timeout=5', + 'Transfer-Encoding': 'chunked', + }, + mimeType: 'text/html', + connectionReused: true, + connectionId: 34, + remoteIPAddress: '127.0.0.1', + remotePort: 3000, + fromDiskCache: false, + fromServiceWorker: false, + fromPrefetchCache: false, + encodedDataLength: 197, + timing: { + receiveHeadersStart: 0, + requestTime: 31949.959838, + proxyStart: -1, + proxyEnd: -1, + dnsStart: -1, + dnsEnd: -1, + connectStart: -1, + connectEnd: -1, + sslStart: -1, + sslEnd: -1, + workerStart: -1, + workerReady: -1, + workerFetchStart: -1, + workerRespondWithSettled: -1, + sendStart: 0.613, + sendEnd: 0.665, + pushStart: 0, + pushEnd: 0, + receiveHeadersEnd: 3.619, + }, + responseTime: 1.680698353573552e12, + protocol: 'http/1.1', + alternateProtocolUsage: 'unspecifiedReason', + securityState: 'secure', + }, + hasExtraInfo: true, + frameId: '4A6E05B1781795F1B586C1F8F8B2CBE4', + }); + mockCDPSession.emit('Network.loadingFinished', { + requestId: '6D76C8ACAECE880C722FA515AD380015', + timestamp: 31949.963861, + encodedDataLength: 847, + }); + + mockCDPSession.emit('Network.requestWillBeSent', { + requestId: '4C2CC44FB6A6CAC5BE2780BCC9313105', + loaderId: '4C2CC44FB6A6CAC5BE2780BCC9313105', + documentURL: 'http://localhost:3000/redirect', + request: { + url: 'http://localhost:3000/redirect', + method: 'GET', + headers: { + Referer: 'http://localhost:3000/', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36', + 'sec-ch-ua-mobile': '?0', + }, + mixedContentType: 'none', + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + isSameSite: true, + }, + timestamp: 31949.982895, + wallTime: 1680698353.595079, + initiator: { + type: 'script', + stack: { + callFrames: [ + { + functionName: '', + scriptId: '5', + url: 'http://localhost:3000/', + lineNumber: 8, + columnNumber: 32, + }, + ], + }, + }, + redirectHasExtraInfo: false, + type: 'Document', + frameId: '4A6E05B1781795F1B586C1F8F8B2CBE4', + hasUserGesture: false, + }); + + mockCDPSession.emit('Network.requestWillBeSentExtraInfo', { + requestId: '4C2CC44FB6A6CAC5BE2780BCC9313105', + associatedCookies: [], + headers: { + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', + 'Accept-Encoding': 'gzip, deflate, br', + 'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8', + Connection: 'keep-alive', + Host: 'localhost:3000', + Referer: 'http://localhost:3000/', + 'Sec-Fetch-Dest': 'document', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'same-origin', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36', + 'sec-ch-ua-mobile': '?0', + }, + connectTiming: {requestTime: 31949.983605}, + siteHasCookieInOtherPartition: false, + }); + mockCDPSession.emit('Network.responseReceivedExtraInfo', { + requestId: '4C2CC44FB6A6CAC5BE2780BCC9313105', + blockedCookies: [], + headers: { + Connection: 'keep-alive', + Date: 'Wed, 05 Apr 2023 12:39:13 GMT', + 'Keep-Alive': 'timeout=5', + Location: 'http://localhost:3000/#from-redirect', + 'Transfer-Encoding': 'chunked', + }, + resourceIPAddressSpace: 'Local', + statusCode: 302, + headersText: + 'HTTP/1.1 302 Found\\r\\nLocation: http://localhost:3000/#from-redirect\\r\\nDate: Wed, 05 Apr 2023 12:39:13 GMT\\r\\nConnection: keep-alive\\r\\nKeep-Alive: timeout=5\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n', + cookiePartitionKey: 'http://localhost', + cookiePartitionKeyOpaque: false, + }); + mockCDPSession.emit('Network.requestWillBeSent', { + requestId: '4C2CC44FB6A6CAC5BE2780BCC9313105', + loaderId: '4C2CC44FB6A6CAC5BE2780BCC9313105', + documentURL: 'http://localhost:3000/', + request: { + url: 'http://localhost:3000/', + urlFragment: '#from-redirect', + method: 'GET', + headers: { + Referer: 'http://localhost:3000/', + 'Upgrade-Insecure-Requests': '1', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36', + 'sec-ch-ua-mobile': '?0', + }, + mixedContentType: 'none', + initialPriority: 'VeryHigh', + referrerPolicy: 'strict-origin-when-cross-origin', + isSameSite: true, + }, + timestamp: 31949.988506, + wallTime: 1680698353.60069, + initiator: { + type: 'script', + stack: { + callFrames: [ + { + functionName: '', + scriptId: '5', + url: 'http://localhost:3000/', + lineNumber: 8, + columnNumber: 32, + }, + ], + }, + }, + redirectHasExtraInfo: true, + redirectResponse: { + url: 'http://localhost:3000/redirect', + status: 302, + statusText: 'Found', + headers: { + Connection: 'keep-alive', + Date: 'Wed, 05 Apr 2023 12:39:13 GMT', + 'Keep-Alive': 'timeout=5', + Location: 'http://localhost:3000/#from-redirect', + 'Transfer-Encoding': 'chunked', + }, + mimeType: '', + connectionReused: true, + connectionId: 34, + remoteIPAddress: '127.0.0.1', + remotePort: 3000, + fromDiskCache: false, + fromServiceWorker: false, + fromPrefetchCache: false, + encodedDataLength: 182, + timing: { + receiveHeadersStart: 0, + requestTime: 31949.983605, + proxyStart: -1, + proxyEnd: -1, + dnsStart: -1, + dnsEnd: -1, + connectStart: -1, + connectEnd: -1, + sslStart: -1, + sslEnd: -1, + workerStart: -1, + workerReady: -1, + workerFetchStart: -1, + workerRespondWithSettled: -1, + sendStart: 0.364, + sendEnd: 0.401, + pushStart: 0, + pushEnd: 0, + receiveHeadersEnd: 4.085, + }, + responseTime: 1.680698353596548e12, + protocol: 'http/1.1', + alternateProtocolUsage: 'unspecifiedReason', + securityState: 'secure', + }, + type: 'Document', + frameId: '4A6E05B1781795F1B586C1F8F8B2CBE4', + hasUserGesture: false, + }); + mockCDPSession.emit('Network.requestWillBeSentExtraInfo', { + requestId: '4C2CC44FB6A6CAC5BE2780BCC9313105', + associatedCookies: [], + headers: {}, + connectTiming: {requestTime: 31949.988855}, + siteHasCookieInOtherPartition: false, + }); + + mockCDPSession.emit('Network.responseReceived', { + requestId: '4C2CC44FB6A6CAC5BE2780BCC9313105', + loaderId: '4C2CC44FB6A6CAC5BE2780BCC9313105', + timestamp: 31949.991319, + type: 'Document', + response: { + url: 'http://localhost:3000/', + status: 200, + statusText: 'OK', + headers: { + 'Cache-Control': 'max-age=5', + 'Content-Type': 'text/html; charset=utf-8', + Date: 'Wed, 05 Apr 2023 12:39:13 GMT', + }, + mimeType: 'text/html', + connectionReused: false, + connectionId: 0, + remoteIPAddress: '127.0.0.1', + remotePort: 3000, + fromDiskCache: true, + fromServiceWorker: false, + fromPrefetchCache: false, + encodedDataLength: 0, + timing: { + receiveHeadersStart: 0, + requestTime: 31949.988855, + proxyStart: -1, + proxyEnd: -1, + dnsStart: -1, + dnsEnd: -1, + connectStart: -1, + connectEnd: -1, + sslStart: -1, + sslEnd: -1, + workerStart: -1, + workerReady: -1, + workerFetchStart: -1, + workerRespondWithSettled: -1, + sendStart: 0.069, + sendEnd: 0.069, + pushStart: 0, + pushEnd: 0, + receiveHeadersEnd: 0.321, + }, + responseTime: 1.680698353573552e12, + protocol: 'http/1.1', + alternateProtocolUsage: 'unspecifiedReason', + securityState: 'secure', + }, + hasExtraInfo: true, + frameId: '4A6E05B1781795F1B586C1F8F8B2CBE4', + }); + mockCDPSession.emit('Network.responseReceivedExtraInfo', { + requestId: '4C2CC44FB6A6CAC5BE2780BCC9313105', + blockedCookies: [], + headers: { + Connection: 'keep-alive', + Date: 'Wed, 05 Apr 2023 12:39:13 GMT', + 'Keep-Alive': 'timeout=5', + Location: 'http://localhost:3000/#from-redirect', + 'Transfer-Encoding': 'chunked', + }, + resourceIPAddressSpace: 'Local', + statusCode: 302, + headersText: + 'HTTP/1.1 302 Found\\r\\nLocation: http://localhost:3000/#from-redirect\\r\\nDate: Wed, 05 Apr 2023 12:39:13 GMT\\r\\nConnection: keep-alive\\r\\nKeep-Alive: timeout=5\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n', + cookiePartitionKey: 'http://localhost', + cookiePartitionKeyOpaque: false, + }); + mockCDPSession.emit('Network.loadingFinished', { + requestId: '4C2CC44FB6A6CAC5BE2780BCC9313105', + timestamp: 31949.989412, + encodedDataLength: 0, + }); + expect( + responses.map(r => { + return r.status(); + }) + ).toEqual([200, 302, 200]); + }); +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.ts new file mode 100644 index 0000000000..8b24b9a748 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.ts @@ -0,0 +1,710 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import {CDPSessionEvent, type CDPSession} from '../api/CDPSession.js'; +import type {Frame} from '../api/Frame.js'; +import {EventEmitter, EventSubscription} from '../common/EventEmitter.js'; +import { + NetworkManagerEvent, + type NetworkManagerEvents, +} from '../common/NetworkManagerEvents.js'; +import {debugError, isString} from '../common/util.js'; +import {assert} from '../util/assert.js'; +import {DisposableStack} from '../util/disposable.js'; + +import {CdpHTTPRequest} from './HTTPRequest.js'; +import {CdpHTTPResponse} from './HTTPResponse.js'; +import { + NetworkEventManager, + type FetchRequestId, +} from './NetworkEventManager.js'; + +/** + * @public + */ +export interface Credentials { + username: string; + password: string; +} + +/** + * @public + */ +export interface NetworkConditions { + // Download speed (bytes/s) + download: number; + // Upload speed (bytes/s) + upload: number; + // Latency (ms) + latency: number; +} + +/** + * @public + */ +export interface InternalNetworkConditions extends NetworkConditions { + offline: boolean; +} + +/** + * @internal + */ +export interface FrameProvider { + frame(id: string): Frame | null; +} + +/** + * @internal + */ +export class NetworkManager extends EventEmitter<NetworkManagerEvents> { + #ignoreHTTPSErrors: boolean; + #frameManager: FrameProvider; + #networkEventManager = new NetworkEventManager(); + #extraHTTPHeaders?: Record<string, string>; + #credentials?: Credentials; + #attemptedAuthentications = new Set<string>(); + #userRequestInterceptionEnabled = false; + #protocolRequestInterceptionEnabled = false; + #userCacheDisabled?: boolean; + #emulatedNetworkConditions?: InternalNetworkConditions; + #userAgent?: string; + #userAgentMetadata?: Protocol.Emulation.UserAgentMetadata; + + readonly #handlers = [ + ['Fetch.requestPaused', this.#onRequestPaused], + ['Fetch.authRequired', this.#onAuthRequired], + ['Network.requestWillBeSent', this.#onRequestWillBeSent], + ['Network.requestServedFromCache', this.#onRequestServedFromCache], + ['Network.responseReceived', this.#onResponseReceived], + ['Network.loadingFinished', this.#onLoadingFinished], + ['Network.loadingFailed', this.#onLoadingFailed], + ['Network.responseReceivedExtraInfo', this.#onResponseReceivedExtraInfo], + [CDPSessionEvent.Disconnected, this.#removeClient], + ] as const; + + #clients = new Map<CDPSession, DisposableStack>(); + + constructor(ignoreHTTPSErrors: boolean, frameManager: FrameProvider) { + super(); + this.#ignoreHTTPSErrors = ignoreHTTPSErrors; + this.#frameManager = frameManager; + } + + async addClient(client: CDPSession): Promise<void> { + if (this.#clients.has(client)) { + return; + } + const subscriptions = new DisposableStack(); + this.#clients.set(client, subscriptions); + for (const [event, handler] of this.#handlers) { + subscriptions.use( + // TODO: Remove any here. + new EventSubscription(client, event, (arg: any) => { + return handler.bind(this)(client, arg); + }) + ); + } + await Promise.all([ + this.#ignoreHTTPSErrors + ? client.send('Security.setIgnoreCertificateErrors', { + ignore: true, + }) + : null, + client.send('Network.enable'), + this.#applyExtraHTTPHeaders(client), + this.#applyNetworkConditions(client), + this.#applyProtocolCacheDisabled(client), + this.#applyProtocolRequestInterception(client), + this.#applyUserAgent(client), + ]); + } + + async #removeClient(client: CDPSession) { + this.#clients.get(client)?.dispose(); + this.#clients.delete(client); + } + + async authenticate(credentials?: Credentials): Promise<void> { + this.#credentials = credentials; + const enabled = this.#userRequestInterceptionEnabled || !!this.#credentials; + if (enabled === this.#protocolRequestInterceptionEnabled) { + return; + } + this.#protocolRequestInterceptionEnabled = enabled; + await this.#applyToAllClients( + this.#applyProtocolRequestInterception.bind(this) + ); + } + + async setExtraHTTPHeaders( + extraHTTPHeaders: Record<string, string> + ): Promise<void> { + this.#extraHTTPHeaders = {}; + for (const key of Object.keys(extraHTTPHeaders)) { + const value = extraHTTPHeaders[key]; + assert( + isString(value), + `Expected value of header "${key}" to be String, but "${typeof value}" is found.` + ); + this.#extraHTTPHeaders[key.toLowerCase()] = value; + } + + await this.#applyToAllClients(this.#applyExtraHTTPHeaders.bind(this)); + } + + async #applyExtraHTTPHeaders(client: CDPSession) { + if (this.#extraHTTPHeaders === undefined) { + return; + } + await client.send('Network.setExtraHTTPHeaders', { + headers: this.#extraHTTPHeaders, + }); + } + + extraHTTPHeaders(): Record<string, string> { + return Object.assign({}, this.#extraHTTPHeaders); + } + + inFlightRequestsCount(): number { + return this.#networkEventManager.inFlightRequestsCount(); + } + + async setOfflineMode(value: boolean): Promise<void> { + if (!this.#emulatedNetworkConditions) { + this.#emulatedNetworkConditions = { + offline: false, + upload: -1, + download: -1, + latency: 0, + }; + } + this.#emulatedNetworkConditions.offline = value; + await this.#applyToAllClients(this.#applyNetworkConditions.bind(this)); + } + + async emulateNetworkConditions( + networkConditions: NetworkConditions | null + ): Promise<void> { + if (!this.#emulatedNetworkConditions) { + this.#emulatedNetworkConditions = { + offline: false, + upload: -1, + download: -1, + latency: 0, + }; + } + this.#emulatedNetworkConditions.upload = networkConditions + ? networkConditions.upload + : -1; + this.#emulatedNetworkConditions.download = networkConditions + ? networkConditions.download + : -1; + this.#emulatedNetworkConditions.latency = networkConditions + ? networkConditions.latency + : 0; + + await this.#applyToAllClients(this.#applyNetworkConditions.bind(this)); + } + + async #applyToAllClients(fn: (client: CDPSession) => Promise<unknown>) { + await Promise.all( + Array.from(this.#clients.keys()).map(client => { + return fn(client); + }) + ); + } + + async #applyNetworkConditions(client: CDPSession): Promise<void> { + if (this.#emulatedNetworkConditions === undefined) { + return; + } + await client.send('Network.emulateNetworkConditions', { + offline: this.#emulatedNetworkConditions.offline, + latency: this.#emulatedNetworkConditions.latency, + uploadThroughput: this.#emulatedNetworkConditions.upload, + downloadThroughput: this.#emulatedNetworkConditions.download, + }); + } + + async setUserAgent( + userAgent: string, + userAgentMetadata?: Protocol.Emulation.UserAgentMetadata + ): Promise<void> { + this.#userAgent = userAgent; + this.#userAgentMetadata = userAgentMetadata; + await this.#applyToAllClients(this.#applyUserAgent.bind(this)); + } + + async #applyUserAgent(client: CDPSession) { + if (this.#userAgent === undefined) { + return; + } + await client.send('Network.setUserAgentOverride', { + userAgent: this.#userAgent, + userAgentMetadata: this.#userAgentMetadata, + }); + } + + async setCacheEnabled(enabled: boolean): Promise<void> { + this.#userCacheDisabled = !enabled; + await this.#applyToAllClients(this.#applyProtocolCacheDisabled.bind(this)); + } + + async setRequestInterception(value: boolean): Promise<void> { + this.#userRequestInterceptionEnabled = value; + const enabled = this.#userRequestInterceptionEnabled || !!this.#credentials; + if (enabled === this.#protocolRequestInterceptionEnabled) { + return; + } + this.#protocolRequestInterceptionEnabled = enabled; + await this.#applyToAllClients( + this.#applyProtocolRequestInterception.bind(this) + ); + } + + async #applyProtocolRequestInterception(client: CDPSession): Promise<void> { + if (this.#userCacheDisabled === undefined) { + this.#userCacheDisabled = false; + } + if (this.#protocolRequestInterceptionEnabled) { + await Promise.all([ + this.#applyProtocolCacheDisabled(client), + client.send('Fetch.enable', { + handleAuthRequests: true, + patterns: [{urlPattern: '*'}], + }), + ]); + } else { + await Promise.all([ + this.#applyProtocolCacheDisabled(client), + client.send('Fetch.disable'), + ]); + } + } + + async #applyProtocolCacheDisabled(client: CDPSession): Promise<void> { + if (this.#userCacheDisabled === undefined) { + return; + } + await client.send('Network.setCacheDisabled', { + cacheDisabled: this.#userCacheDisabled, + }); + } + + #onRequestWillBeSent( + client: CDPSession, + event: Protocol.Network.RequestWillBeSentEvent + ): void { + // Request interception doesn't happen for data URLs with Network Service. + if ( + this.#userRequestInterceptionEnabled && + !event.request.url.startsWith('data:') + ) { + const {requestId: networkRequestId} = event; + + this.#networkEventManager.storeRequestWillBeSent(networkRequestId, event); + + /** + * CDP may have sent a Fetch.requestPaused event already. Check for it. + */ + const requestPausedEvent = + this.#networkEventManager.getRequestPaused(networkRequestId); + if (requestPausedEvent) { + const {requestId: fetchRequestId} = requestPausedEvent; + this.#patchRequestEventHeaders(event, requestPausedEvent); + this.#onRequest(client, event, fetchRequestId); + this.#networkEventManager.forgetRequestPaused(networkRequestId); + } + + return; + } + this.#onRequest(client, event, undefined); + } + + #onAuthRequired( + client: CDPSession, + event: Protocol.Fetch.AuthRequiredEvent + ): void { + let response: Protocol.Fetch.AuthChallengeResponse['response'] = 'Default'; + if (this.#attemptedAuthentications.has(event.requestId)) { + response = 'CancelAuth'; + } else if (this.#credentials) { + response = 'ProvideCredentials'; + this.#attemptedAuthentications.add(event.requestId); + } + const {username, password} = this.#credentials || { + username: undefined, + password: undefined, + }; + client + .send('Fetch.continueWithAuth', { + requestId: event.requestId, + authChallengeResponse: {response, username, password}, + }) + .catch(debugError); + } + + /** + * CDP may send a Fetch.requestPaused without or before a + * Network.requestWillBeSent + * + * CDP may send multiple Fetch.requestPaused + * for the same Network.requestWillBeSent. + */ + #onRequestPaused( + client: CDPSession, + event: Protocol.Fetch.RequestPausedEvent + ): void { + if ( + !this.#userRequestInterceptionEnabled && + this.#protocolRequestInterceptionEnabled + ) { + client + .send('Fetch.continueRequest', { + requestId: event.requestId, + }) + .catch(debugError); + } + + const {networkId: networkRequestId, requestId: fetchRequestId} = event; + + if (!networkRequestId) { + this.#onRequestWithoutNetworkInstrumentation(client, event); + return; + } + + const requestWillBeSentEvent = (() => { + const requestWillBeSentEvent = + this.#networkEventManager.getRequestWillBeSent(networkRequestId); + + // redirect requests have the same `requestId`, + if ( + requestWillBeSentEvent && + (requestWillBeSentEvent.request.url !== event.request.url || + requestWillBeSentEvent.request.method !== event.request.method) + ) { + this.#networkEventManager.forgetRequestWillBeSent(networkRequestId); + return; + } + return requestWillBeSentEvent; + })(); + + if (requestWillBeSentEvent) { + this.#patchRequestEventHeaders(requestWillBeSentEvent, event); + this.#onRequest(client, requestWillBeSentEvent, fetchRequestId); + } else { + this.#networkEventManager.storeRequestPaused(networkRequestId, event); + } + } + + #patchRequestEventHeaders( + requestWillBeSentEvent: Protocol.Network.RequestWillBeSentEvent, + requestPausedEvent: Protocol.Fetch.RequestPausedEvent + ): void { + requestWillBeSentEvent.request.headers = { + ...requestWillBeSentEvent.request.headers, + // includes extra headers, like: Accept, Origin + ...requestPausedEvent.request.headers, + }; + } + + #onRequestWithoutNetworkInstrumentation( + client: CDPSession, + event: Protocol.Fetch.RequestPausedEvent + ): void { + // If an event has no networkId it should not have any network events. We + // still want to dispatch it for the interception by the user. + const frame = event.frameId + ? this.#frameManager.frame(event.frameId) + : null; + + const request = new CdpHTTPRequest( + client, + frame, + event.requestId, + this.#userRequestInterceptionEnabled, + event, + [] + ); + this.emit(NetworkManagerEvent.Request, request); + void request.finalizeInterceptions(); + } + + #onRequest( + client: CDPSession, + event: Protocol.Network.RequestWillBeSentEvent, + fetchRequestId?: FetchRequestId + ): void { + let redirectChain: CdpHTTPRequest[] = []; + if (event.redirectResponse) { + // We want to emit a response and requestfinished for the + // redirectResponse, but we can't do so unless we have a + // responseExtraInfo ready to pair it up with. If we don't have any + // responseExtraInfos saved in our queue, they we have to wait until + // the next one to emit response and requestfinished, *and* we should + // also wait to emit this Request too because it should come after the + // response/requestfinished. + let redirectResponseExtraInfo = null; + if (event.redirectHasExtraInfo) { + redirectResponseExtraInfo = this.#networkEventManager + .responseExtraInfo(event.requestId) + .shift(); + if (!redirectResponseExtraInfo) { + this.#networkEventManager.queueRedirectInfo(event.requestId, { + event, + fetchRequestId, + }); + return; + } + } + + const request = this.#networkEventManager.getRequest(event.requestId); + // If we connect late to the target, we could have missed the + // requestWillBeSent event. + if (request) { + this.#handleRequestRedirect( + client, + request, + event.redirectResponse, + redirectResponseExtraInfo + ); + redirectChain = request._redirectChain; + } + } + const frame = event.frameId + ? this.#frameManager.frame(event.frameId) + : null; + + const request = new CdpHTTPRequest( + client, + frame, + fetchRequestId, + this.#userRequestInterceptionEnabled, + event, + redirectChain + ); + this.#networkEventManager.storeRequest(event.requestId, request); + this.emit(NetworkManagerEvent.Request, request); + void request.finalizeInterceptions(); + } + + #onRequestServedFromCache( + _client: CDPSession, + event: Protocol.Network.RequestServedFromCacheEvent + ): void { + const request = this.#networkEventManager.getRequest(event.requestId); + if (request) { + request._fromMemoryCache = true; + } + this.emit(NetworkManagerEvent.RequestServedFromCache, request); + } + + #handleRequestRedirect( + client: CDPSession, + request: CdpHTTPRequest, + responsePayload: Protocol.Network.Response, + extraInfo: Protocol.Network.ResponseReceivedExtraInfoEvent | null + ): void { + const response = new CdpHTTPResponse( + client, + request, + responsePayload, + extraInfo + ); + request._response = response; + request._redirectChain.push(request); + response._resolveBody( + new Error('Response body is unavailable for redirect responses') + ); + this.#forgetRequest(request, false); + this.emit(NetworkManagerEvent.Response, response); + this.emit(NetworkManagerEvent.RequestFinished, request); + } + + #emitResponseEvent( + client: CDPSession, + responseReceived: Protocol.Network.ResponseReceivedEvent, + extraInfo: Protocol.Network.ResponseReceivedExtraInfoEvent | null + ): void { + const request = this.#networkEventManager.getRequest( + responseReceived.requestId + ); + // FileUpload sends a response without a matching request. + if (!request) { + return; + } + + const extraInfos = this.#networkEventManager.responseExtraInfo( + responseReceived.requestId + ); + if (extraInfos.length) { + debugError( + new Error( + 'Unexpected extraInfo events for request ' + + responseReceived.requestId + ) + ); + } + + // Chromium sends wrong extraInfo events for responses served from cache. + // See https://github.com/puppeteer/puppeteer/issues/9965 and + // https://crbug.com/1340398. + if (responseReceived.response.fromDiskCache) { + extraInfo = null; + } + + const response = new CdpHTTPResponse( + client, + request, + responseReceived.response, + extraInfo + ); + request._response = response; + this.emit(NetworkManagerEvent.Response, response); + } + + #onResponseReceived( + client: CDPSession, + event: Protocol.Network.ResponseReceivedEvent + ): void { + const request = this.#networkEventManager.getRequest(event.requestId); + let extraInfo = null; + if (request && !request._fromMemoryCache && event.hasExtraInfo) { + extraInfo = this.#networkEventManager + .responseExtraInfo(event.requestId) + .shift(); + if (!extraInfo) { + // Wait until we get the corresponding ExtraInfo event. + this.#networkEventManager.queueEventGroup(event.requestId, { + responseReceivedEvent: event, + }); + return; + } + } + this.#emitResponseEvent(client, event, extraInfo); + } + + #onResponseReceivedExtraInfo( + client: CDPSession, + event: Protocol.Network.ResponseReceivedExtraInfoEvent + ): void { + // We may have skipped a redirect response/request pair due to waiting for + // this ExtraInfo event. If so, continue that work now that we have the + // request. + const redirectInfo = this.#networkEventManager.takeQueuedRedirectInfo( + event.requestId + ); + if (redirectInfo) { + this.#networkEventManager.responseExtraInfo(event.requestId).push(event); + this.#onRequest(client, redirectInfo.event, redirectInfo.fetchRequestId); + return; + } + + // We may have skipped response and loading events because we didn't have + // this ExtraInfo event yet. If so, emit those events now. + const queuedEvents = this.#networkEventManager.getQueuedEventGroup( + event.requestId + ); + if (queuedEvents) { + this.#networkEventManager.forgetQueuedEventGroup(event.requestId); + this.#emitResponseEvent( + client, + queuedEvents.responseReceivedEvent, + event + ); + if (queuedEvents.loadingFinishedEvent) { + this.#emitLoadingFinished(queuedEvents.loadingFinishedEvent); + } + if (queuedEvents.loadingFailedEvent) { + this.#emitLoadingFailed(queuedEvents.loadingFailedEvent); + } + return; + } + + // Wait until we get another event that can use this ExtraInfo event. + this.#networkEventManager.responseExtraInfo(event.requestId).push(event); + } + + #forgetRequest(request: CdpHTTPRequest, events: boolean): void { + const requestId = request._requestId; + const interceptionId = request._interceptionId; + + this.#networkEventManager.forgetRequest(requestId); + interceptionId !== undefined && + this.#attemptedAuthentications.delete(interceptionId); + + if (events) { + this.#networkEventManager.forget(requestId); + } + } + + #onLoadingFinished( + _client: CDPSession, + event: Protocol.Network.LoadingFinishedEvent + ): void { + // If the response event for this request is still waiting on a + // corresponding ExtraInfo event, then wait to emit this event too. + const queuedEvents = this.#networkEventManager.getQueuedEventGroup( + event.requestId + ); + if (queuedEvents) { + queuedEvents.loadingFinishedEvent = event; + } else { + this.#emitLoadingFinished(event); + } + } + + #emitLoadingFinished(event: Protocol.Network.LoadingFinishedEvent): void { + const request = this.#networkEventManager.getRequest(event.requestId); + // For certain requestIds we never receive requestWillBeSent event. + // @see https://crbug.com/750469 + if (!request) { + return; + } + + // Under certain conditions we never get the Network.responseReceived + // event from protocol. @see https://crbug.com/883475 + if (request.response()) { + request.response()?._resolveBody(); + } + this.#forgetRequest(request, true); + this.emit(NetworkManagerEvent.RequestFinished, request); + } + + #onLoadingFailed( + _client: CDPSession, + event: Protocol.Network.LoadingFailedEvent + ): void { + // If the response event for this request is still waiting on a + // corresponding ExtraInfo event, then wait to emit this event too. + const queuedEvents = this.#networkEventManager.getQueuedEventGroup( + event.requestId + ); + if (queuedEvents) { + queuedEvents.loadingFailedEvent = event; + } else { + this.#emitLoadingFailed(event); + } + } + + #emitLoadingFailed(event: Protocol.Network.LoadingFailedEvent): void { + const request = this.#networkEventManager.getRequest(event.requestId); + // For certain requestIds we never receive requestWillBeSent event. + // @see https://crbug.com/750469 + if (!request) { + return; + } + request._failureText = event.errorText; + const response = request.response(); + if (response) { + response._resolveBody(); + } + this.#forgetRequest(request, true); + this.emit(NetworkManagerEvent.RequestFailed, request); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Page.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Page.ts new file mode 100644 index 0000000000..491637f0ea --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Page.ts @@ -0,0 +1,1249 @@ +/** + * @license + * Copyright 2017 Google Inc. + * 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'; +import type {Browser} from '../api/Browser.js'; +import type {BrowserContext} from '../api/BrowserContext.js'; +import {CDPSessionEvent, type CDPSession} from '../api/CDPSession.js'; +import type {ElementHandle} from '../api/ElementHandle.js'; +import type {Frame, WaitForOptions} from '../api/Frame.js'; +import type {HTTPRequest} from '../api/HTTPRequest.js'; +import type {HTTPResponse} from '../api/HTTPResponse.js'; +import type {JSHandle} from '../api/JSHandle.js'; +import { + Page, + PageEvent, + type GeolocationOptions, + type MediaFeature, + type Metrics, + type NewDocumentScriptEvaluation, + type ScreenshotClip, + type ScreenshotOptions, + type WaitTimeoutOptions, +} from '../api/Page.js'; +import { + ConsoleMessage, + type ConsoleMessageType, +} from '../common/ConsoleMessage.js'; +import {TargetCloseError} from '../common/Errors.js'; +import {FileChooser} from '../common/FileChooser.js'; +import {NetworkManagerEvent} from '../common/NetworkManagerEvents.js'; +import type {PDFOptions} from '../common/PDFOptions.js'; +import type {BindingPayload, HandleFor} from '../common/types.js'; +import { + debugError, + evaluationString, + getReadableAsBuffer, + getReadableFromProtocolStream, + parsePDFOptions, + timeout, + validateDialogType, +} from '../common/util.js'; +import type {Viewport} from '../common/Viewport.js'; +import {assert} from '../util/assert.js'; +import {Deferred} from '../util/Deferred.js'; +import {AsyncDisposableStack} from '../util/disposable.js'; +import {isErrorLike} from '../util/ErrorLike.js'; + +import {Accessibility} from './Accessibility.js'; +import {Binding} from './Binding.js'; +import {CdpCDPSession} from './CDPSession.js'; +import {isTargetClosedError} from './Connection.js'; +import {Coverage} from './Coverage.js'; +import type {DeviceRequestPrompt} from './DeviceRequestPrompt.js'; +import {CdpDialog} from './Dialog.js'; +import {EmulationManager} from './EmulationManager.js'; +import {createCdpHandle} from './ExecutionContext.js'; +import {FirefoxTargetManager} from './FirefoxTargetManager.js'; +import type {CdpFrame} from './Frame.js'; +import {FrameManager} from './FrameManager.js'; +import {FrameManagerEvent} from './FrameManagerEvents.js'; +import {CdpKeyboard, CdpMouse, CdpTouchscreen} from './Input.js'; +import {MAIN_WORLD} from './IsolatedWorlds.js'; +import {releaseObject} from './JSHandle.js'; +import type {Credentials, NetworkConditions} from './NetworkManager.js'; +import type {CdpTarget} from './Target.js'; +import type {TargetManager} from './TargetManager.js'; +import {TargetManagerEvent} from './TargetManager.js'; +import {Tracing} from './Tracing.js'; +import { + createClientError, + pageBindingInitString, + valueFromRemoteObject, +} from './utils.js'; +import {CdpWebWorker} from './WebWorker.js'; + +/** + * @internal + */ +export class CdpPage extends Page { + static async _create( + client: CDPSession, + target: CdpTarget, + ignoreHTTPSErrors: boolean, + defaultViewport: Viewport | null + ): Promise<CdpPage> { + const page = new CdpPage(client, target, ignoreHTTPSErrors); + await page.#initialize(); + if (defaultViewport) { + try { + await page.setViewport(defaultViewport); + } catch (err) { + if (isErrorLike(err) && isTargetClosedError(err)) { + debugError(err); + } else { + throw err; + } + } + } + return page; + } + + #closed = false; + readonly #targetManager: TargetManager; + + #primaryTargetClient: CDPSession; + #primaryTarget: CdpTarget; + #tabTargetClient: CDPSession; + #tabTarget: CdpTarget; + #keyboard: CdpKeyboard; + #mouse: CdpMouse; + #touchscreen: CdpTouchscreen; + #accessibility: Accessibility; + #frameManager: FrameManager; + #emulationManager: EmulationManager; + #tracing: Tracing; + #bindings = new Map<string, Binding>(); + #exposedFunctions = new Map<string, string>(); + #coverage: Coverage; + #viewport: Viewport | null; + #workers = new Map<string, CdpWebWorker>(); + #fileChooserDeferreds = new Set<Deferred<FileChooser>>(); + #sessionCloseDeferred = Deferred.create<never, TargetCloseError>(); + #serviceWorkerBypassed = false; + #userDragInterceptionEnabled = false; + + readonly #frameManagerHandlers = [ + [ + FrameManagerEvent.FrameAttached, + (frame: CdpFrame) => { + this.emit(PageEvent.FrameAttached, frame); + }, + ], + [ + FrameManagerEvent.FrameDetached, + (frame: CdpFrame) => { + this.emit(PageEvent.FrameDetached, frame); + }, + ], + [ + FrameManagerEvent.FrameNavigated, + (frame: CdpFrame) => { + this.emit(PageEvent.FrameNavigated, frame); + }, + ], + ] as const; + + readonly #networkManagerHandlers = [ + [ + NetworkManagerEvent.Request, + (request: HTTPRequest) => { + this.emit(PageEvent.Request, request); + }, + ], + [ + NetworkManagerEvent.RequestServedFromCache, + (request: HTTPRequest) => { + this.emit(PageEvent.RequestServedFromCache, request); + }, + ], + [ + NetworkManagerEvent.Response, + (response: HTTPResponse) => { + this.emit(PageEvent.Response, response); + }, + ], + [ + NetworkManagerEvent.RequestFailed, + (request: HTTPRequest) => { + this.emit(PageEvent.RequestFailed, request); + }, + ], + [ + NetworkManagerEvent.RequestFinished, + (request: HTTPRequest) => { + this.emit(PageEvent.RequestFinished, request); + }, + ], + ] as const; + + readonly #sessionHandlers = [ + [ + CDPSessionEvent.Disconnected, + () => { + this.#sessionCloseDeferred.reject( + new TargetCloseError('Target closed') + ); + }, + ], + [ + 'Page.domContentEventFired', + () => { + return this.emit(PageEvent.DOMContentLoaded, undefined); + }, + ], + [ + 'Page.loadEventFired', + () => { + return this.emit(PageEvent.Load, undefined); + }, + ], + ['Runtime.consoleAPICalled', this.#onConsoleAPI.bind(this)], + ['Runtime.bindingCalled', this.#onBindingCalled.bind(this)], + ['Page.javascriptDialogOpening', this.#onDialog.bind(this)], + ['Runtime.exceptionThrown', this.#handleException.bind(this)], + ['Inspector.targetCrashed', this.#onTargetCrashed.bind(this)], + ['Performance.metrics', this.#emitMetrics.bind(this)], + ['Log.entryAdded', this.#onLogEntryAdded.bind(this)], + ['Page.fileChooserOpened', this.#onFileChooser.bind(this)], + ] as const; + + constructor( + client: CDPSession, + target: CdpTarget, + ignoreHTTPSErrors: boolean + ) { + super(); + this.#primaryTargetClient = client; + this.#tabTargetClient = client.parentSession()!; + assert(this.#tabTargetClient, 'Tab target session is not defined.'); + this.#tabTarget = (this.#tabTargetClient as CdpCDPSession)._target(); + assert(this.#tabTarget, 'Tab target is not defined.'); + this.#primaryTarget = target; + this.#targetManager = target._targetManager(); + this.#keyboard = new CdpKeyboard(client); + this.#mouse = new CdpMouse(client, this.#keyboard); + this.#touchscreen = new CdpTouchscreen(client, this.#keyboard); + this.#accessibility = new Accessibility(client); + this.#frameManager = new FrameManager( + client, + this, + ignoreHTTPSErrors, + this._timeoutSettings + ); + this.#emulationManager = new EmulationManager(client); + this.#tracing = new Tracing(client); + this.#coverage = new Coverage(client); + this.#viewport = null; + + for (const [eventName, handler] of this.#frameManagerHandlers) { + this.#frameManager.on(eventName, handler); + } + + for (const [eventName, handler] of this.#networkManagerHandlers) { + // TODO: Remove any. + this.#frameManager.networkManager.on(eventName, handler as any); + } + + this.#tabTargetClient.on( + CDPSessionEvent.Swapped, + this.#onActivation.bind(this) + ); + + this.#tabTargetClient.on( + CDPSessionEvent.Ready, + this.#onSecondaryTarget.bind(this) + ); + + this.#targetManager.on( + TargetManagerEvent.TargetGone, + this.#onDetachedFromTarget + ); + + this.#tabTarget._isClosedDeferred + .valueOrThrow() + .then(() => { + this.#targetManager.off( + TargetManagerEvent.TargetGone, + this.#onDetachedFromTarget + ); + + this.emit(PageEvent.Close, undefined); + this.#closed = true; + }) + .catch(debugError); + + this.#setupPrimaryTargetListeners(); + } + + async #onActivation(newSession: CDPSession): Promise<void> { + this.#primaryTargetClient = newSession; + assert( + this.#primaryTargetClient instanceof CdpCDPSession, + 'CDPSession is not instance of CDPSessionImpl' + ); + this.#primaryTarget = this.#primaryTargetClient._target(); + assert(this.#primaryTarget, 'Missing target on swap'); + this.#keyboard.updateClient(newSession); + this.#mouse.updateClient(newSession); + this.#touchscreen.updateClient(newSession); + this.#accessibility.updateClient(newSession); + this.#emulationManager.updateClient(newSession); + this.#tracing.updateClient(newSession); + this.#coverage.updateClient(newSession); + await this.#frameManager.swapFrameTree(newSession); + this.#setupPrimaryTargetListeners(); + } + + async #onSecondaryTarget(session: CDPSession): Promise<void> { + assert(session instanceof CdpCDPSession); + if (session._target()._subtype() !== 'prerender') { + return; + } + this.#frameManager.registerSpeculativeSession(session).catch(debugError); + this.#emulationManager + .registerSpeculativeSession(session) + .catch(debugError); + } + + /** + * Sets up listeners for the primary target. The primary target can change + * during a navigation to a prerended page. + */ + #setupPrimaryTargetListeners() { + this.#primaryTargetClient.on( + CDPSessionEvent.Ready, + this.#onAttachedToTarget + ); + + for (const [eventName, handler] of this.#sessionHandlers) { + // TODO: Remove any. + this.#primaryTargetClient.on(eventName, handler as any); + } + } + + #onDetachedFromTarget = (target: CdpTarget) => { + const sessionId = target._session()?.id(); + const worker = this.#workers.get(sessionId!); + if (!worker) { + return; + } + this.#workers.delete(sessionId!); + this.emit(PageEvent.WorkerDestroyed, worker); + }; + + #onAttachedToTarget = (session: CDPSession) => { + assert(session instanceof CdpCDPSession); + this.#frameManager.onAttachedToTarget(session._target()); + if (session._target()._getTargetInfo().type === 'worker') { + const worker = new CdpWebWorker( + session, + session._target().url(), + this.#addConsoleMessage.bind(this), + this.#handleException.bind(this) + ); + this.#workers.set(session.id(), worker); + this.emit(PageEvent.WorkerCreated, worker); + } + session.on(CDPSessionEvent.Ready, this.#onAttachedToTarget); + }; + + async #initialize(): Promise<void> { + try { + await Promise.all([ + this.#frameManager.initialize(this.#primaryTargetClient), + this.#primaryTargetClient.send('Performance.enable'), + this.#primaryTargetClient.send('Log.enable'), + ]); + } catch (err) { + if (isErrorLike(err) && isTargetClosedError(err)) { + debugError(err); + } else { + throw err; + } + } + } + + async #onFileChooser( + event: Protocol.Page.FileChooserOpenedEvent + ): Promise<void> { + if (!this.#fileChooserDeferreds.size) { + return; + } + + const frame = this.#frameManager.frame(event.frameId); + assert(frame, 'This should never happen.'); + + // This is guaranteed to be an HTMLInputElement handle by the event. + using handle = (await frame.worlds[MAIN_WORLD].adoptBackendNode( + event.backendNodeId + )) as ElementHandle<HTMLInputElement>; + + const fileChooser = new FileChooser(handle.move(), event); + for (const promise of this.#fileChooserDeferreds) { + promise.resolve(fileChooser); + } + this.#fileChooserDeferreds.clear(); + } + + _client(): CDPSession { + return this.#primaryTargetClient; + } + + override isServiceWorkerBypassed(): boolean { + return this.#serviceWorkerBypassed; + } + + override isDragInterceptionEnabled(): boolean { + return this.#userDragInterceptionEnabled; + } + + override isJavaScriptEnabled(): boolean { + return this.#emulationManager.javascriptEnabled; + } + + override async waitForFileChooser( + options: WaitTimeoutOptions = {} + ): Promise<FileChooser> { + const needsEnable = this.#fileChooserDeferreds.size === 0; + const {timeout = this._timeoutSettings.timeout()} = options; + const deferred = Deferred.create<FileChooser>({ + message: `Waiting for \`FileChooser\` failed: ${timeout}ms exceeded`, + timeout, + }); + this.#fileChooserDeferreds.add(deferred); + let enablePromise: Promise<void> | undefined; + if (needsEnable) { + enablePromise = this.#primaryTargetClient.send( + 'Page.setInterceptFileChooserDialog', + { + enabled: true, + } + ); + } + try { + const [result] = await Promise.all([ + deferred.valueOrThrow(), + enablePromise, + ]); + return result; + } catch (error) { + this.#fileChooserDeferreds.delete(deferred); + throw error; + } + } + + override async setGeolocation(options: GeolocationOptions): Promise<void> { + return await this.#emulationManager.setGeolocation(options); + } + + override target(): CdpTarget { + return this.#primaryTarget; + } + + override browser(): Browser { + return this.#primaryTarget.browser(); + } + + override browserContext(): BrowserContext { + return this.#primaryTarget.browserContext(); + } + + #onTargetCrashed(): void { + this.emit(PageEvent.Error, new Error('Page crashed!')); + } + + #onLogEntryAdded(event: Protocol.Log.EntryAddedEvent): void { + const {level, text, args, source, url, lineNumber} = event.entry; + if (args) { + args.map(arg => { + void releaseObject(this.#primaryTargetClient, arg); + }); + } + if (source !== 'worker') { + this.emit( + PageEvent.Console, + new ConsoleMessage(level, text, [], [{url, lineNumber}]) + ); + } + } + + override mainFrame(): CdpFrame { + return this.#frameManager.mainFrame(); + } + + override get keyboard(): CdpKeyboard { + return this.#keyboard; + } + + override get touchscreen(): CdpTouchscreen { + return this.#touchscreen; + } + + override get coverage(): Coverage { + return this.#coverage; + } + + override get tracing(): Tracing { + return this.#tracing; + } + + override get accessibility(): Accessibility { + return this.#accessibility; + } + + override frames(): Frame[] { + return this.#frameManager.frames(); + } + + override workers(): CdpWebWorker[] { + return Array.from(this.#workers.values()); + } + + override async setRequestInterception(value: boolean): Promise<void> { + return await this.#frameManager.networkManager.setRequestInterception( + value + ); + } + + override async setBypassServiceWorker(bypass: boolean): Promise<void> { + this.#serviceWorkerBypassed = bypass; + return await this.#primaryTargetClient.send( + 'Network.setBypassServiceWorker', + {bypass} + ); + } + + override async setDragInterception(enabled: boolean): Promise<void> { + this.#userDragInterceptionEnabled = enabled; + return await this.#primaryTargetClient.send('Input.setInterceptDrags', { + enabled, + }); + } + + override async setOfflineMode(enabled: boolean): Promise<void> { + return await this.#frameManager.networkManager.setOfflineMode(enabled); + } + + override async emulateNetworkConditions( + networkConditions: NetworkConditions | null + ): Promise<void> { + return await this.#frameManager.networkManager.emulateNetworkConditions( + networkConditions + ); + } + + override setDefaultNavigationTimeout(timeout: number): void { + this._timeoutSettings.setDefaultNavigationTimeout(timeout); + } + + override setDefaultTimeout(timeout: number): void { + this._timeoutSettings.setDefaultTimeout(timeout); + } + + override getDefaultTimeout(): number { + return this._timeoutSettings.timeout(); + } + + override async queryObjects<Prototype>( + prototypeHandle: JSHandle<Prototype> + ): Promise<JSHandle<Prototype[]>> { + assert(!prototypeHandle.disposed, 'Prototype JSHandle is disposed!'); + assert( + prototypeHandle.id, + 'Prototype JSHandle must not be referencing primitive value' + ); + const response = await this.mainFrame().client.send( + 'Runtime.queryObjects', + { + prototypeObjectId: prototypeHandle.id, + } + ); + return createCdpHandle( + this.mainFrame().mainRealm(), + response.objects + ) as HandleFor<Prototype[]>; + } + + override async cookies( + ...urls: string[] + ): Promise<Protocol.Network.Cookie[]> { + const originalCookies = ( + await this.#primaryTargetClient.send('Network.getCookies', { + urls: urls.length ? urls : [this.url()], + }) + ).cookies; + + const unsupportedCookieAttributes = ['priority']; + const filterUnsupportedAttributes = ( + cookie: Protocol.Network.Cookie + ): Protocol.Network.Cookie => { + for (const attr of unsupportedCookieAttributes) { + delete (cookie as unknown as Record<string, unknown>)[attr]; + } + return cookie; + }; + return originalCookies.map(filterUnsupportedAttributes); + } + + override async deleteCookie( + ...cookies: Protocol.Network.DeleteCookiesRequest[] + ): Promise<void> { + const pageURL = this.url(); + for (const cookie of cookies) { + const item = Object.assign({}, cookie); + if (!cookie.url && pageURL.startsWith('http')) { + item.url = pageURL; + } + await this.#primaryTargetClient.send('Network.deleteCookies', item); + } + } + + override async setCookie( + ...cookies: Protocol.Network.CookieParam[] + ): Promise<void> { + const pageURL = this.url(); + const startsWithHTTP = pageURL.startsWith('http'); + const items = cookies.map(cookie => { + const item = Object.assign({}, cookie); + if (!item.url && startsWithHTTP) { + item.url = pageURL; + } + assert( + item.url !== 'about:blank', + `Blank page can not have cookie "${item.name}"` + ); + assert( + !String.prototype.startsWith.call(item.url || '', 'data:'), + `Data URL page can not have cookie "${item.name}"` + ); + return item; + }); + await this.deleteCookie(...items); + if (items.length) { + await this.#primaryTargetClient.send('Network.setCookies', { + cookies: items, + }); + } + } + + override async exposeFunction( + name: string, + pptrFunction: Function | {default: Function} + ): Promise<void> { + if (this.#bindings.has(name)) { + throw new Error( + `Failed to add page binding with name ${name}: window['${name}'] already exists!` + ); + } + + let binding: Binding; + switch (typeof pptrFunction) { + case 'function': + binding = new Binding( + name, + pptrFunction as (...args: unknown[]) => unknown + ); + break; + default: + binding = new Binding( + name, + pptrFunction.default as (...args: unknown[]) => unknown + ); + break; + } + + this.#bindings.set(name, binding); + + const expression = pageBindingInitString('exposedFun', name); + await this.#primaryTargetClient.send('Runtime.addBinding', {name}); + // TODO: investigate this as it appears to only apply to the main frame and + // local subframes instead of the entire frame tree (including future + // frame). + const {identifier} = await this.#primaryTargetClient.send( + 'Page.addScriptToEvaluateOnNewDocument', + { + source: expression, + } + ); + + this.#exposedFunctions.set(name, identifier); + + await Promise.all( + this.frames().map(frame => { + // If a frame has not started loading, it might never start. Rely on + // addScriptToEvaluateOnNewDocument in that case. + if (frame !== this.mainFrame() && !frame._hasStartedLoading) { + return; + } + return frame.evaluate(expression).catch(debugError); + }) + ); + } + + override async removeExposedFunction(name: string): Promise<void> { + const exposedFun = this.#exposedFunctions.get(name); + if (!exposedFun) { + throw new Error( + `Failed to remove page binding with name ${name}: window['${name}'] does not exists!` + ); + } + + await this.#primaryTargetClient.send('Runtime.removeBinding', {name}); + await this.removeScriptToEvaluateOnNewDocument(exposedFun); + + await Promise.all( + this.frames().map(frame => { + // If a frame has not started loading, it might never start. Rely on + // addScriptToEvaluateOnNewDocument in that case. + if (frame !== this.mainFrame() && !frame._hasStartedLoading) { + return; + } + return frame + .evaluate(name => { + // Removes the dangling Puppeteer binding wrapper. + // @ts-expect-error: In a different context. + globalThis[name] = undefined; + }, name) + .catch(debugError); + }) + ); + + this.#exposedFunctions.delete(name); + this.#bindings.delete(name); + } + + override async authenticate(credentials: Credentials): Promise<void> { + return await this.#frameManager.networkManager.authenticate(credentials); + } + + override async setExtraHTTPHeaders( + headers: Record<string, string> + ): Promise<void> { + return await this.#frameManager.networkManager.setExtraHTTPHeaders(headers); + } + + override async setUserAgent( + userAgent: string, + userAgentMetadata?: Protocol.Emulation.UserAgentMetadata + ): Promise<void> { + return await this.#frameManager.networkManager.setUserAgent( + userAgent, + userAgentMetadata + ); + } + + override async metrics(): Promise<Metrics> { + const response = await this.#primaryTargetClient.send( + 'Performance.getMetrics' + ); + return this.#buildMetricsObject(response.metrics); + } + + #emitMetrics(event: Protocol.Performance.MetricsEvent): void { + this.emit(PageEvent.Metrics, { + title: event.title, + metrics: this.#buildMetricsObject(event.metrics), + }); + } + + #buildMetricsObject(metrics?: Protocol.Performance.Metric[]): Metrics { + const result: Record< + Protocol.Performance.Metric['name'], + Protocol.Performance.Metric['value'] + > = {}; + for (const metric of metrics || []) { + if (supportedMetrics.has(metric.name)) { + result[metric.name] = metric.value; + } + } + return result; + } + + #handleException(exception: Protocol.Runtime.ExceptionThrownEvent): void { + this.emit( + PageEvent.PageError, + createClientError(exception.exceptionDetails) + ); + } + + async #onConsoleAPI( + event: Protocol.Runtime.ConsoleAPICalledEvent + ): Promise<void> { + if (event.executionContextId === 0) { + // DevTools protocol stores the last 1000 console messages. These + // messages are always reported even for removed execution contexts. In + // this case, they are marked with executionContextId = 0 and are + // reported upon enabling Runtime agent. + // + // Ignore these messages since: + // - there's no execution context we can use to operate with message + // arguments + // - these messages are reported before Puppeteer clients can subscribe + // to the 'console' + // page event. + // + // @see https://github.com/puppeteer/puppeteer/issues/3865 + return; + } + const context = this.#frameManager.getExecutionContextById( + event.executionContextId, + this.#primaryTargetClient + ); + if (!context) { + debugError( + new Error( + `ExecutionContext not found for a console message: ${JSON.stringify( + event + )}` + ) + ); + return; + } + const values = event.args.map(arg => { + return createCdpHandle(context._world, arg); + }); + this.#addConsoleMessage(event.type, values, event.stackTrace); + } + + async #onBindingCalled( + event: Protocol.Runtime.BindingCalledEvent + ): Promise<void> { + let payload: BindingPayload; + try { + payload = JSON.parse(event.payload); + } catch { + // The binding was either called by something in the page or it was + // called before our wrapper was initialized. + return; + } + const {type, name, seq, args, isTrivial} = payload; + if (type !== 'exposedFun') { + return; + } + + const context = this.#frameManager.executionContextById( + event.executionContextId, + this.#primaryTargetClient + ); + if (!context) { + return; + } + + const binding = this.#bindings.get(name); + await binding?.run(context, seq, args, isTrivial); + } + + #addConsoleMessage( + eventType: ConsoleMessageType, + args: JSHandle[], + stackTrace?: Protocol.Runtime.StackTrace + ): void { + if (!this.listenerCount(PageEvent.Console)) { + args.forEach(arg => { + return arg.dispose(); + }); + return; + } + const textTokens = []; + // eslint-disable-next-line max-len -- The comment is long. + // eslint-disable-next-line rulesdir/use-using -- These are not owned by this function. + for (const arg of args) { + const remoteObject = arg.remoteObject(); + if (remoteObject.objectId) { + textTokens.push(arg.toString()); + } else { + textTokens.push(valueFromRemoteObject(remoteObject)); + } + } + const stackTraceLocations = []; + if (stackTrace) { + for (const callFrame of stackTrace.callFrames) { + stackTraceLocations.push({ + url: callFrame.url, + lineNumber: callFrame.lineNumber, + columnNumber: callFrame.columnNumber, + }); + } + } + const message = new ConsoleMessage( + eventType, + textTokens.join(' '), + args, + stackTraceLocations + ); + this.emit(PageEvent.Console, message); + } + + #onDialog(event: Protocol.Page.JavascriptDialogOpeningEvent): void { + const type = validateDialogType(event.type); + const dialog = new CdpDialog( + this.#primaryTargetClient, + type, + event.message, + event.defaultPrompt + ); + this.emit(PageEvent.Dialog, dialog); + } + + override async reload( + options?: WaitForOptions + ): Promise<HTTPResponse | null> { + const [result] = await Promise.all([ + this.waitForNavigation(options), + this.#primaryTargetClient.send('Page.reload'), + ]); + + return result; + } + + override async createCDPSession(): Promise<CDPSession> { + return await this.target().createCDPSession(); + } + + override async goBack( + options: WaitForOptions = {} + ): Promise<HTTPResponse | null> { + return await this.#go(-1, options); + } + + override async goForward( + options: WaitForOptions = {} + ): Promise<HTTPResponse | null> { + return await this.#go(+1, options); + } + + async #go( + delta: number, + options: WaitForOptions + ): Promise<HTTPResponse | null> { + const history = await this.#primaryTargetClient.send( + 'Page.getNavigationHistory' + ); + const entry = history.entries[history.currentIndex + delta]; + if (!entry) { + return null; + } + const result = await Promise.all([ + this.waitForNavigation(options), + this.#primaryTargetClient.send('Page.navigateToHistoryEntry', { + entryId: entry.id, + }), + ]); + return result[0]; + } + + override async bringToFront(): Promise<void> { + await this.#primaryTargetClient.send('Page.bringToFront'); + } + + override async setJavaScriptEnabled(enabled: boolean): Promise<void> { + return await this.#emulationManager.setJavaScriptEnabled(enabled); + } + + override async setBypassCSP(enabled: boolean): Promise<void> { + await this.#primaryTargetClient.send('Page.setBypassCSP', {enabled}); + } + + override async emulateMediaType(type?: string): Promise<void> { + return await this.#emulationManager.emulateMediaType(type); + } + + override async emulateCPUThrottling(factor: number | null): Promise<void> { + return await this.#emulationManager.emulateCPUThrottling(factor); + } + + override async emulateMediaFeatures( + features?: MediaFeature[] + ): Promise<void> { + return await this.#emulationManager.emulateMediaFeatures(features); + } + + override async emulateTimezone(timezoneId?: string): Promise<void> { + return await this.#emulationManager.emulateTimezone(timezoneId); + } + + override async emulateIdleState(overrides?: { + isUserActive: boolean; + isScreenUnlocked: boolean; + }): Promise<void> { + return await this.#emulationManager.emulateIdleState(overrides); + } + + override async emulateVisionDeficiency( + type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'] + ): Promise<void> { + return await this.#emulationManager.emulateVisionDeficiency(type); + } + + override async setViewport(viewport: Viewport): Promise<void> { + const needsReload = await this.#emulationManager.emulateViewport(viewport); + this.#viewport = viewport; + if (needsReload) { + await this.reload(); + } + } + + override viewport(): Viewport | null { + return this.#viewport; + } + + override async evaluateOnNewDocument< + Params extends unknown[], + Func extends (...args: Params) => unknown = (...args: Params) => unknown, + >( + pageFunction: Func | string, + ...args: Params + ): Promise<NewDocumentScriptEvaluation> { + const source = evaluationString(pageFunction, ...args); + const {identifier} = await this.#primaryTargetClient.send( + 'Page.addScriptToEvaluateOnNewDocument', + { + source, + } + ); + + return {identifier}; + } + + override async removeScriptToEvaluateOnNewDocument( + identifier: string + ): Promise<void> { + await this.#primaryTargetClient.send( + 'Page.removeScriptToEvaluateOnNewDocument', + { + identifier, + } + ); + } + + override async setCacheEnabled(enabled = true): Promise<void> { + await this.#frameManager.networkManager.setCacheEnabled(enabled); + } + + override async _screenshot( + options: Readonly<ScreenshotOptions> + ): Promise<string> { + const { + fromSurface, + omitBackground, + optimizeForSpeed, + quality, + clip: userClip, + type, + captureBeyondViewport, + } = options; + + const isFirefox = + this.target()._targetManager() instanceof FirefoxTargetManager; + + await using stack = new AsyncDisposableStack(); + // Firefox omits background by default; it's not configurable. + if (!isFirefox && omitBackground && (type === 'png' || type === 'webp')) { + await this.#emulationManager.setTransparentBackgroundColor(); + stack.defer(async () => { + await this.#emulationManager + .resetDefaultBackgroundColor() + .catch(debugError); + }); + } + + let clip = userClip; + if (clip && !captureBeyondViewport) { + const viewport = await this.mainFrame() + .isolatedRealm() + .evaluate(() => { + const { + height, + pageLeft: x, + pageTop: y, + width, + } = window.visualViewport!; + return {x, y, height, width}; + }); + clip = getIntersectionRect(clip, viewport); + } + + // We need to do these spreads because Firefox doesn't allow unknown options. + const {data} = await this.#primaryTargetClient.send( + 'Page.captureScreenshot', + { + format: type, + ...(optimizeForSpeed ? {optimizeForSpeed} : {}), + ...(quality !== undefined ? {quality: Math.round(quality)} : {}), + ...(clip ? {clip: {...clip, scale: clip.scale ?? 1}} : {}), + ...(!fromSurface ? {fromSurface} : {}), + captureBeyondViewport, + } + ); + return data; + } + + override async createPDFStream(options: PDFOptions = {}): Promise<Readable> { + const {timeout: ms = this._timeoutSettings.timeout()} = options; + const { + landscape, + displayHeaderFooter, + headerTemplate, + footerTemplate, + printBackground, + scale, + width: paperWidth, + height: paperHeight, + margin, + pageRanges, + preferCSSPageSize, + omitBackground, + tagged: generateTaggedPDF, + } = parsePDFOptions(options); + + if (omitBackground) { + await this.#emulationManager.setTransparentBackgroundColor(); + } + + const printCommandPromise = this.#primaryTargetClient.send( + 'Page.printToPDF', + { + transferMode: 'ReturnAsStream', + landscape, + displayHeaderFooter, + headerTemplate, + footerTemplate, + printBackground, + scale, + paperWidth, + paperHeight, + marginTop: margin.top, + marginBottom: margin.bottom, + marginLeft: margin.left, + marginRight: margin.right, + pageRanges, + preferCSSPageSize, + generateTaggedPDF, + } + ); + + const result = await firstValueFrom( + from(printCommandPromise).pipe(raceWith(timeout(ms))) + ); + + if (omitBackground) { + await this.#emulationManager.resetDefaultBackgroundColor(); + } + + assert(result.stream, '`stream` is missing from `Page.printToPDF'); + return await getReadableFromProtocolStream( + this.#primaryTargetClient, + result.stream + ); + } + + override async pdf(options: PDFOptions = {}): Promise<Buffer> { + const {path = undefined} = options; + const readable = await this.createPDFStream(options); + const buffer = await getReadableAsBuffer(readable, path); + assert(buffer, 'Could not create buffer'); + return buffer; + } + + override async close( + options: {runBeforeUnload?: boolean} = {runBeforeUnload: undefined} + ): Promise<void> { + const connection = this.#primaryTargetClient.connection(); + assert( + connection, + 'Protocol error: Connection closed. Most likely the page has been closed.' + ); + const runBeforeUnload = !!options.runBeforeUnload; + if (runBeforeUnload) { + await this.#primaryTargetClient.send('Page.close'); + } else { + await connection.send('Target.closeTarget', { + targetId: this.#primaryTarget._targetId, + }); + await this.#tabTarget._isClosedDeferred.valueOrThrow(); + } + } + + override isClosed(): boolean { + return this.#closed; + } + + override get mouse(): CdpMouse { + return this.#mouse; + } + + /** + * This method is typically coupled with an action that triggers a device + * request from an api such as WebBluetooth. + * + * :::caution + * + * This must be called before the device request is made. It will not return a + * currently active device prompt. + * + * ::: + * + * @example + * + * ```ts + * const [devicePrompt] = Promise.all([ + * page.waitForDevicePrompt(), + * page.click('#connect-bluetooth'), + * ]); + * await devicePrompt.select( + * await devicePrompt.waitForDevice(({name}) => name.includes('My Device')) + * ); + * ``` + */ + override async waitForDevicePrompt( + options: WaitTimeoutOptions = {} + ): Promise<DeviceRequestPrompt> { + return await this.mainFrame().waitForDevicePrompt(options); + } +} + +const supportedMetrics = new Set<string>([ + 'Timestamp', + 'Documents', + 'Frames', + 'JSEventListeners', + 'Nodes', + 'LayoutCount', + 'RecalcStyleCount', + 'LayoutDuration', + 'RecalcStyleDuration', + 'ScriptDuration', + 'TaskDuration', + 'JSHeapUsedSize', + 'JSHeapTotalSize', +]); + +/** @see https://w3c.github.io/webdriver-bidi/#rectangle-intersection */ +function getIntersectionRect( + clip: Readonly<ScreenshotClip>, + viewport: Readonly<Protocol.DOM.Rect> +): ScreenshotClip { + // Note these will already be normalized. + const x = Math.max(clip.x, viewport.x); + const y = Math.max(clip.y, viewport.y); + return { + x, + y, + width: Math.max( + Math.min(clip.x + clip.width, viewport.x + viewport.width) - x, + 0 + ), + height: Math.max( + Math.min(clip.y + clip.height, viewport.y + viewport.height) - y, + 0 + ), + }; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/PredefinedNetworkConditions.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/PredefinedNetworkConditions.ts new file mode 100644 index 0000000000..df035ae52b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/PredefinedNetworkConditions.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2021 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {NetworkConditions} from './NetworkManager.js'; + +/** + * A list of network conditions to be used with + * {@link Page.emulateNetworkConditions}. + * + * @example + * + * ```ts + * import {PredefinedNetworkConditions} from 'puppeteer'; + * const slow3G = PredefinedNetworkConditions['Slow 3G']; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.emulateNetworkConditions(slow3G); + * await page.goto('https://www.google.com'); + * // other actions... + * await browser.close(); + * })(); + * ``` + * + * @public + */ +export const PredefinedNetworkConditions = Object.freeze({ + 'Slow 3G': { + download: ((500 * 1000) / 8) * 0.8, + upload: ((500 * 1000) / 8) * 0.8, + latency: 400 * 5, + } as NetworkConditions, + 'Fast 3G': { + download: ((1.6 * 1000 * 1000) / 8) * 0.9, + upload: ((750 * 1000) / 8) * 0.9, + 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 new file mode 100644 index 0000000000..b3e9ea83ec --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Target.ts @@ -0,0 +1,305 @@ +/** + * @license + * Copyright 2019 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {Browser} from '../api/Browser.js'; +import type {BrowserContext} from '../api/BrowserContext.js'; +import type {CDPSession} from '../api/CDPSession.js'; +import {PageEvent, type Page} from '../api/Page.js'; +import {Target, TargetType} from '../api/Target.js'; +import {debugError} from '../common/util.js'; +import type {Viewport} from '../common/Viewport.js'; +import {Deferred} from '../util/Deferred.js'; + +import {CdpCDPSession} from './CDPSession.js'; +import {CdpPage} from './Page.js'; +import type {TargetManager} from './TargetManager.js'; +import {CdpWebWorker} from './WebWorker.js'; + +/** + * @internal + */ +export enum InitializationStatus { + SUCCESS = 'success', + ABORTED = 'aborted', +} + +/** + * @internal + */ +export class CdpTarget extends Target { + #browserContext?: BrowserContext; + #session?: CDPSession; + #targetInfo: Protocol.Target.TargetInfo; + #targetManager?: TargetManager; + #sessionFactory: + | ((isAutoAttachEmulated: boolean) => Promise<CDPSession>) + | undefined; + + _initializedDeferred = Deferred.create<InitializationStatus>(); + _isClosedDeferred = Deferred.create<void>(); + _targetId: string; + + /** + * To initialize the target for use, call initialize. + * + * @internal + */ + constructor( + targetInfo: Protocol.Target.TargetInfo, + session: CDPSession | undefined, + browserContext: BrowserContext | undefined, + targetManager: TargetManager | undefined, + sessionFactory: + | ((isAutoAttachEmulated: boolean) => Promise<CDPSession>) + | undefined + ) { + super(); + this.#session = session; + this.#targetManager = targetManager; + this.#targetInfo = targetInfo; + this.#browserContext = browserContext; + this._targetId = targetInfo.targetId; + this.#sessionFactory = sessionFactory; + if (this.#session && this.#session instanceof CdpCDPSession) { + this.#session._setTarget(this); + } + } + + override async asPage(): Promise<Page> { + const session = this._session(); + if (!session) { + return await this.createCDPSession().then(client => { + return CdpPage._create(client, this, false, null); + }); + } + return await CdpPage._create(session, this, false, null); + } + + _subtype(): string | undefined { + return this.#targetInfo.subtype; + } + + _session(): CDPSession | undefined { + return this.#session; + } + + protected _sessionFactory(): ( + isAutoAttachEmulated: boolean + ) => Promise<CDPSession> { + if (!this.#sessionFactory) { + throw new Error('sessionFactory is not initialized'); + } + return this.#sessionFactory; + } + + override createCDPSession(): Promise<CDPSession> { + if (!this.#sessionFactory) { + throw new Error('sessionFactory is not initialized'); + } + return this.#sessionFactory(false).then(session => { + (session as CdpCDPSession)._setTarget(this); + return session; + }); + } + + override url(): string { + return this.#targetInfo.url; + } + + override type(): TargetType { + const type = this.#targetInfo.type; + switch (type) { + case 'page': + return TargetType.PAGE; + case 'background_page': + return TargetType.BACKGROUND_PAGE; + case 'service_worker': + return TargetType.SERVICE_WORKER; + case 'shared_worker': + return TargetType.SHARED_WORKER; + case 'browser': + return TargetType.BROWSER; + case 'webview': + return TargetType.WEBVIEW; + case 'tab': + return TargetType.TAB; + default: + return TargetType.OTHER; + } + } + + _targetManager(): TargetManager { + if (!this.#targetManager) { + throw new Error('targetManager is not initialized'); + } + return this.#targetManager; + } + + _getTargetInfo(): Protocol.Target.TargetInfo { + return this.#targetInfo; + } + + override browser(): Browser { + if (!this.#browserContext) { + throw new Error('browserContext is not initialized'); + } + return this.#browserContext.browser(); + } + + override browserContext(): BrowserContext { + if (!this.#browserContext) { + throw new Error('browserContext is not initialized'); + } + return this.#browserContext; + } + + override opener(): Target | undefined { + const {openerId} = this.#targetInfo; + if (!openerId) { + return; + } + return this.browser() + .targets() + .find(target => { + return (target as CdpTarget)._targetId === openerId; + }); + } + + _targetInfoChanged(targetInfo: Protocol.Target.TargetInfo): void { + this.#targetInfo = targetInfo; + this._checkIfInitialized(); + } + + _initialize(): void { + this._initializedDeferred.resolve(InitializationStatus.SUCCESS); + } + + _isTargetExposed(): boolean { + return this.type() !== TargetType.TAB && !this._subtype(); + } + + protected _checkIfInitialized(): void { + if (!this._initializedDeferred.resolved()) { + this._initializedDeferred.resolve(InitializationStatus.SUCCESS); + } + } +} + +/** + * @internal + */ +export class PageTarget extends CdpTarget { + #defaultViewport?: Viewport; + protected pagePromise?: Promise<Page>; + #ignoreHTTPSErrors: boolean; + + constructor( + targetInfo: Protocol.Target.TargetInfo, + session: CDPSession | undefined, + browserContext: BrowserContext, + targetManager: TargetManager, + sessionFactory: (isAutoAttachEmulated: boolean) => Promise<CDPSession>, + ignoreHTTPSErrors: boolean, + defaultViewport: Viewport | null + ) { + super(targetInfo, session, browserContext, targetManager, sessionFactory); + this.#ignoreHTTPSErrors = ignoreHTTPSErrors; + this.#defaultViewport = defaultViewport ?? undefined; + } + + override _initialize(): void { + this._initializedDeferred + .valueOrThrow() + .then(async result => { + if (result === InitializationStatus.ABORTED) { + return; + } + const opener = this.opener(); + if (!(opener instanceof PageTarget)) { + return; + } + if (!opener || !opener.pagePromise || this.type() !== 'page') { + return true; + } + const openerPage = await opener.pagePromise; + if (!openerPage.listenerCount(PageEvent.Popup)) { + return true; + } + const popupPage = await this.page(); + openerPage.emit(PageEvent.Popup, popupPage); + return true; + }) + .catch(debugError); + this._checkIfInitialized(); + } + + override async page(): Promise<Page | null> { + if (!this.pagePromise) { + const session = this._session(); + this.pagePromise = ( + session + ? Promise.resolve(session) + : this._sessionFactory()(/* isAutoAttachEmulated=*/ false) + ).then(client => { + return CdpPage._create( + client, + this, + this.#ignoreHTTPSErrors, + this.#defaultViewport ?? null + ); + }); + } + return (await this.pagePromise) ?? null; + } + + override _checkIfInitialized(): void { + if (this._initializedDeferred.resolved()) { + return; + } + if (this._getTargetInfo().url !== '') { + this._initializedDeferred.resolve(InitializationStatus.SUCCESS); + } + } +} + +/** + * @internal + */ +export class DevToolsTarget extends PageTarget {} + +/** + * @internal + */ +export class WorkerTarget extends CdpTarget { + #workerPromise?: Promise<CdpWebWorker>; + + override async worker(): Promise<CdpWebWorker | null> { + if (!this.#workerPromise) { + const session = this._session(); + // TODO(einbinder): Make workers send their console logs. + this.#workerPromise = ( + session + ? Promise.resolve(session) + : this._sessionFactory()(/* isAutoAttachEmulated=*/ false) + ).then(client => { + return new CdpWebWorker( + client, + this._getTargetInfo().url, + () => {} /* consoleAPICalled */, + () => {} /* exceptionThrown */ + ); + }); + } + return await this.#workerPromise; + } +} + +/** + * @internal + */ +export class OtherTarget extends CdpTarget {} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/TargetManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/TargetManager.ts new file mode 100644 index 0000000000..248f63539d --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/TargetManager.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import type {EventEmitter, EventType} from '../common/EventEmitter.js'; + +import type {CdpTarget} from './Target.js'; + +/** + * @internal + */ +export type TargetFactory = ( + targetInfo: Protocol.Target.TargetInfo, + session?: CDPSession, + parentSession?: CDPSession +) => CdpTarget; + +/** + * @internal + */ +export const enum TargetManagerEvent { + TargetDiscovered = 'targetDiscovered', + TargetAvailable = 'targetAvailable', + TargetGone = 'targetGone', + /** + * Emitted after a target has been initialized and whenever its URL changes. + */ + TargetChanged = 'targetChanged', +} + +/** + * @internal + */ +export interface TargetManagerEvents extends Record<EventType, unknown> { + [TargetManagerEvent.TargetAvailable]: CdpTarget; + [TargetManagerEvent.TargetDiscovered]: Protocol.Target.TargetInfo; + [TargetManagerEvent.TargetGone]: CdpTarget; + [TargetManagerEvent.TargetChanged]: { + target: CdpTarget; + wasInitialized: true; + previousURL: string; + }; +} + +/** + * TargetManager encapsulates all interactions with CDP targets and is + * responsible for coordinating the configuration of targets with the rest of + * Puppeteer. Code outside of this class should not subscribe `Target.*` events + * and only use the TargetManager events. + * + * There are two implementations: one for Chrome that uses CDP's auto-attach + * mechanism and one for Firefox because Firefox does not support auto-attach. + * + * @internal + */ +export interface TargetManager extends EventEmitter<TargetManagerEvents> { + getAvailableTargets(): ReadonlyMap<string, CdpTarget>; + initialize(): Promise<void>; + dispose(): void; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Tracing.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Tracing.ts new file mode 100644 index 0000000000..22eae9a5d4 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Tracing.ts @@ -0,0 +1,140 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type {CDPSession} from '../api/CDPSession.js'; +import { + getReadableAsBuffer, + getReadableFromProtocolStream, +} from '../common/util.js'; +import {assert} from '../util/assert.js'; +import {Deferred} from '../util/Deferred.js'; +import {isErrorLike} from '../util/ErrorLike.js'; + +/** + * @public + */ +export interface TracingOptions { + path?: string; + screenshots?: boolean; + categories?: string[]; +} + +/** + * The Tracing class exposes the tracing audit interface. + * @remarks + * You can use `tracing.start` and `tracing.stop` to create a trace file + * which can be opened in Chrome DevTools or {@link https://chromedevtools.github.io/timeline-viewer/ | timeline viewer}. + * + * @example + * + * ```ts + * await page.tracing.start({path: 'trace.json'}); + * await page.goto('https://www.google.com'); + * await page.tracing.stop(); + * ``` + * + * @public + */ +export class Tracing { + #client: CDPSession; + #recording = false; + #path?: string; + + /** + * @internal + */ + constructor(client: CDPSession) { + this.#client = client; + } + + /** + * @internal + */ + updateClient(client: CDPSession): void { + this.#client = client; + } + + /** + * Starts a trace for the current page. + * @remarks + * Only one trace can be active at a time per browser. + * + * @param options - Optional `TracingOptions`. + */ + async start(options: TracingOptions = {}): Promise<void> { + assert( + !this.#recording, + 'Cannot start recording trace while already recording trace.' + ); + + const defaultCategories = [ + '-*', + 'devtools.timeline', + 'v8.execute', + 'disabled-by-default-devtools.timeline', + 'disabled-by-default-devtools.timeline.frame', + 'toplevel', + 'blink.console', + 'blink.user_timing', + 'latencyInfo', + 'disabled-by-default-devtools.timeline.stack', + 'disabled-by-default-v8.cpu_profiler', + ]; + const {path, screenshots = false, categories = defaultCategories} = options; + + if (screenshots) { + categories.push('disabled-by-default-devtools.screenshot'); + } + + const excludedCategories = categories + .filter(cat => { + return cat.startsWith('-'); + }) + .map(cat => { + return cat.slice(1); + }); + const includedCategories = categories.filter(cat => { + return !cat.startsWith('-'); + }); + + this.#path = path; + this.#recording = true; + await this.#client.send('Tracing.start', { + transferMode: 'ReturnAsStream', + traceConfig: { + excludedCategories, + includedCategories, + }, + }); + } + + /** + * Stops a trace started with the `start` method. + * @returns Promise which resolves to buffer with trace data. + */ + async stop(): Promise<Buffer | undefined> { + const contentDeferred = Deferred.create<Buffer | undefined>(); + this.#client.once('Tracing.tracingComplete', async event => { + try { + assert(event.stream, 'Missing "stream"'); + const readable = await getReadableFromProtocolStream( + this.#client, + event.stream + ); + const buffer = await getReadableAsBuffer(readable, this.#path); + contentDeferred.resolve(buffer ?? undefined); + } catch (error) { + if (isErrorLike(error)) { + contentDeferred.reject(error); + } else { + contentDeferred.reject(new Error(`Unknown error: ${error}`)); + } + } + }); + await this.#client.send('Tracing.end'); + this.#recording = false; + return await contentDeferred.valueOrThrow(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/WebWorker.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/WebWorker.ts new file mode 100644 index 0000000000..552e8a6cf5 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/WebWorker.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type {Protocol} from 'devtools-protocol'; + +import type {CDPSession} from '../api/CDPSession.js'; +import type {Realm} from '../api/Realm.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'; + +import {ExecutionContext} from './ExecutionContext.js'; +import {IsolatedWorld} from './IsolatedWorld.js'; +import {CdpJSHandle} from './JSHandle.js'; + +/** + * @internal + */ +export type ConsoleAPICalledCallback = ( + eventType: ConsoleMessageType, + handles: CdpJSHandle[], + trace?: Protocol.Runtime.StackTrace +) => void; + +/** + * @internal + */ +export type ExceptionThrownCallback = ( + event: Protocol.Runtime.ExceptionThrownEvent +) => void; + +/** + * @internal + */ +export class CdpWebWorker extends WebWorker { + #world: IsolatedWorld; + #client: CDPSession; + + constructor( + client: CDPSession, + url: string, + consoleAPICalled: ConsoleAPICalledCallback, + exceptionThrown: ExceptionThrownCallback + ) { + super(url); + this.#client = client; + this.#world = new IsolatedWorld(this, new TimeoutSettings()); + + this.#client.once('Runtime.executionContextCreated', async event => { + this.#world.setContext( + new ExecutionContext(client, event.context, this.#world) + ); + }); + this.#client.on('Runtime.consoleAPICalled', async event => { + try { + return consoleAPICalled( + event.type, + event.args.map((object: Protocol.Runtime.RemoteObject) => { + return new CdpJSHandle(this.#world, object); + }), + event.stackTrace + ); + } catch (err) { + debugError(err); + } + }); + this.#client.on('Runtime.exceptionThrown', exceptionThrown); + + // This might fail if the target is closed before we receive all execution contexts. + this.#client.send('Runtime.enable').catch(debugError); + } + + mainRealm(): Realm { + return this.#world; + } + + get client(): CDPSession { + return this.#client; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/cdp.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/cdp.ts new file mode 100644 index 0000000000..1533d63f35 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/cdp.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './Accessibility.js'; +export * from './AriaQueryHandler.js'; +export * from './Binding.js'; +export * from './Browser.js'; +export * from './BrowserConnector.js'; +export * from './cdp.js'; +export * from './CDPSession.js'; +export * from './ChromeTargetManager.js'; +export * from './Connection.js'; +export * from './Coverage.js'; +export * from './DeviceRequestPrompt.js'; +export * from './Dialog.js'; +export * from './ElementHandle.js'; +export * from './EmulationManager.js'; +export * from './ExecutionContext.js'; +export * from './FirefoxTargetManager.js'; +export * from './Frame.js'; +export * from './FrameManager.js'; +export * from './FrameManagerEvents.js'; +export * from './FrameTree.js'; +export * from './HTTPRequest.js'; +export * from './HTTPResponse.js'; +export * from './Input.js'; +export * from './IsolatedWorld.js'; +export * from './IsolatedWorlds.js'; +export * from './JSHandle.js'; +export * from './LifecycleWatcher.js'; +export * from './NetworkEventManager.js'; +export * from './NetworkManager.js'; +export * from './Page.js'; +export * from './PredefinedNetworkConditions.js'; +export * from './Target.js'; +export * from './TargetManager.js'; +export * from './Tracing.js'; +export * from './utils.js'; +export * from './WebWorker.js'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/utils.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/utils.ts new file mode 100644 index 0000000000..989a3cd6a3 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/utils.ts @@ -0,0 +1,232 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import {PuppeteerURL, evaluationString} from '../common/util.js'; +import {assert} from '../util/assert.js'; + +/** + * @internal + */ +export function createEvaluationError( + details: Protocol.Runtime.ExceptionDetails +): unknown { + let name: string; + let message: string; + if (!details.exception) { + name = 'Error'; + message = details.text; + } else if ( + (details.exception.type !== 'object' || + details.exception.subtype !== 'error') && + !details.exception.objectId + ) { + return valueFromRemoteObject(details.exception); + } else { + const detail = getErrorDetails(details); + name = detail.name; + message = detail.message; + } + const messageHeight = message.split('\n').length; + const error = new Error(message); + error.name = name; + const stackLines = error.stack!.split('\n'); + const messageLines = stackLines.splice(0, messageHeight); + + // The first line is this function which we ignore. + stackLines.shift(); + if (details.stackTrace && stackLines.length < Error.stackTraceLimit) { + for (const frame of details.stackTrace.callFrames.reverse()) { + if ( + PuppeteerURL.isPuppeteerURL(frame.url) && + frame.url !== PuppeteerURL.INTERNAL_URL + ) { + const url = PuppeteerURL.parse(frame.url); + stackLines.unshift( + ` at ${frame.functionName || url.functionName} (${ + url.functionName + } at ${url.siteString}, <anonymous>:${frame.lineNumber}:${ + frame.columnNumber + })` + ); + } else { + stackLines.push( + ` at ${frame.functionName || '<anonymous>'} (${frame.url}:${ + frame.lineNumber + }:${frame.columnNumber})` + ); + } + if (stackLines.length >= Error.stackTraceLimit) { + break; + } + } + } + + error.stack = [...messageLines, ...stackLines].join('\n'); + return error; +} + +const getErrorDetails = (details: Protocol.Runtime.ExceptionDetails) => { + let name = ''; + let message: string; + const lines = details.exception?.description?.split('\n at ') ?? []; + const size = Math.min( + details.stackTrace?.callFrames.length ?? 0, + lines.length - 1 + ); + lines.splice(-size, size); + if (details.exception?.className) { + name = details.exception.className; + } + message = lines.join('\n'); + if (name && message.startsWith(`${name}: `)) { + message = message.slice(name.length + 2); + } + return {message, name}; +}; + +/** + * @internal + */ +export function createClientError( + details: Protocol.Runtime.ExceptionDetails +): Error { + let name: string; + let message: string; + if (!details.exception) { + name = 'Error'; + message = details.text; + } else if ( + (details.exception.type !== 'object' || + details.exception.subtype !== 'error') && + !details.exception.objectId + ) { + return valueFromRemoteObject(details.exception); + } else { + const detail = getErrorDetails(details); + name = detail.name; + message = detail.message; + } + const error = new Error(message); + error.name = name; + + const messageHeight = error.message.split('\n').length; + const messageLines = error.stack!.split('\n').splice(0, messageHeight); + + const stackLines = []; + if (details.stackTrace) { + for (const frame of details.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'); + return error; +} + +/** + * @internal + */ +export function valueFromRemoteObject( + remoteObject: Protocol.Runtime.RemoteObject +): any { + assert(!remoteObject.objectId, 'Cannot extract value when objectId is given'); + if (remoteObject.unserializableValue) { + if (remoteObject.type === 'bigint') { + return BigInt(remoteObject.unserializableValue.replace('n', '')); + } + switch (remoteObject.unserializableValue) { + case '-0': + return -0; + case 'NaN': + return NaN; + case 'Infinity': + return Infinity; + case '-Infinity': + return -Infinity; + default: + throw new Error( + 'Unsupported unserializable value: ' + + remoteObject.unserializableValue + ); + } + } + return remoteObject.value; +} + +/** + * @internal + */ +export function addPageBinding(type: string, name: string): void { + // This is the CDP binding. + // @ts-expect-error: In a different context. + const callCdp = globalThis[name]; + + // Depending on the frame loading state either Runtime.evaluate or + // Page.addScriptToEvaluateOnNewDocument might succeed. Let's check that we + // don't re-wrap Puppeteer's binding. + if (callCdp[Symbol.toStringTag] === 'PuppeteerBinding') { + return; + } + + // We replace the CDP binding with a Puppeteer binding. + Object.assign(globalThis, { + [name](...args: unknown[]): Promise<unknown> { + // This is the Puppeteer binding. + // @ts-expect-error: In a different context. + const callPuppeteer = globalThis[name]; + callPuppeteer.args ??= new Map(); + callPuppeteer.callbacks ??= new Map(); + + const seq = (callPuppeteer.lastSeq ?? 0) + 1; + callPuppeteer.lastSeq = seq; + callPuppeteer.args.set(seq, args); + + callCdp( + JSON.stringify({ + type, + name, + seq, + args, + isTrivial: !args.some(value => { + return value instanceof Node; + }), + }) + ); + + return new Promise((resolve, reject) => { + callPuppeteer.callbacks.set(seq, { + resolve(value: unknown) { + callPuppeteer.args.delete(seq); + resolve(value); + }, + reject(value?: unknown) { + callPuppeteer.args.delete(seq); + reject(value); + }, + }); + }); + }, + }); + // @ts-expect-error: In a different context. + globalThis[name][Symbol.toStringTag] = 'PuppeteerBinding'; +} + +/** + * @internal + */ +export function pageBindingInitString(type: string, name: string): string { + return evaluationString(addPageBinding, type, name); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserConnector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserConnector.ts new file mode 100644 index 0000000000..217e53bedd --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserConnector.ts @@ -0,0 +1,114 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Browser} from '../api/Browser.js'; +import {_connectToBiDiBrowser} from '../bidi/BrowserConnector.js'; +import {_connectToCdpBrowser} from '../cdp/BrowserConnector.js'; +import {isNode} from '../environment.js'; +import {assert} from '../util/assert.js'; +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 + ? (await import('../node/NodeWebSocketTransport.js')).NodeWebSocketTransport + : (await import('../common/BrowserWebSocketTransport.js')) + .BrowserWebSocketTransport; +}; + +/** + * Users should never call this directly; it's called when calling + * `puppeteer.connect`. This method attaches Puppeteer to an existing browser instance. + * + * @internal + */ +export async function _connectToBrowser( + options: ConnectOptions +): Promise<Browser> { + const {connectionTransport, endpointUrl} = + await getConnectionTransport(options); + + if (options.protocol === 'webDriverBiDi') { + const bidiBrowser = await _connectToBiDiBrowser( + connectionTransport, + endpointUrl, + options + ); + return bidiBrowser; + } else { + const cdpBrowser = await _connectToCdpBrowser( + connectionTransport, + endpointUrl, + options + ); + return cdpBrowser; + } +} + +/** + * Establishes a websocket connection by given options and returns both transport and + * endpoint url the transport is connected to. + */ +async function getConnectionTransport( + options: BrowserConnectOptions & ConnectOptions +): Promise<{connectionTransport: ConnectionTransport; endpointUrl: string}> { + const {browserWSEndpoint, browserURL, transport, headers = {}} = options; + + assert( + Number(!!browserWSEndpoint) + Number(!!browserURL) + Number(!!transport) === + 1, + 'Exactly one of browserWSEndpoint, browserURL or transport must be passed to puppeteer.connect' + ); + + if (transport) { + return {connectionTransport: transport, endpointUrl: ''}; + } else if (browserWSEndpoint) { + const WebSocketClass = await getWebSocketTransportClass(); + const connectionTransport: ConnectionTransport = + await WebSocketClass.create(browserWSEndpoint, headers); + return { + connectionTransport: connectionTransport, + endpointUrl: browserWSEndpoint, + }; + } else if (browserURL) { + const connectionURL = await getWSEndpoint(browserURL); + const WebSocketClass = await getWebSocketTransportClass(); + const connectionTransport: ConnectionTransport = + await WebSocketClass.create(connectionURL); + return { + connectionTransport: connectionTransport, + endpointUrl: connectionURL, + }; + } + throw new Error('Invalid connection options'); +} + +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(), { + method: 'GET', + }); + if (!result.ok) { + throw new Error(`HTTP ${result.statusText}`); + } + const data = await result.json(); + return data.webSocketDebuggerUrl; + } catch (error) { + if (isErrorLike(error)) { + error.message = + `Failed to fetch browser webSocket URL from ${endpointURL}: ` + + error.message; + } + throw error; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserWebSocketTransport.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserWebSocketTransport.ts new file mode 100644 index 0000000000..cc0f81cb06 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserWebSocketTransport.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type {ConnectionTransport} from './ConnectionTransport.js'; + +/** + * @internal + */ +export class BrowserWebSocketTransport implements ConnectionTransport { + static create(url: string): Promise<BrowserWebSocketTransport> { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + + ws.addEventListener('open', () => { + return resolve(new BrowserWebSocketTransport(ws)); + }); + ws.addEventListener('error', reject); + }); + } + + #ws: WebSocket; + onmessage?: (message: string) => void; + onclose?: () => void; + + constructor(ws: WebSocket) { + this.#ws = ws; + this.#ws.addEventListener('message', event => { + if (this.onmessage) { + this.onmessage.call(null, event.data); + } + }); + this.#ws.addEventListener('close', () => { + if (this.onclose) { + this.onclose.call(null); + } + }); + // Silently ignore all errors - we don't know what to do with them. + this.#ws.addEventListener('error', () => {}); + } + + send(message: string): void { + this.#ws.send(message); + } + + close(): void { + this.#ws.close(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/CallbackRegistry.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/CallbackRegistry.ts new file mode 100644 index 0000000000..ea9f3d5abb --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/CallbackRegistry.ts @@ -0,0 +1,177 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Deferred} from '../util/Deferred.js'; +import {rewriteError} from '../util/ErrorLike.js'; + +import {ProtocolError, TargetCloseError} from './Errors.js'; +import {debugError} from './util.js'; + +/** + * Manages callbacks and their IDs for the protocol request/response communication. + * + * @internal + */ +export class CallbackRegistry { + #callbacks = new Map<number, Callback>(); + #idGenerator = createIncrementalIdGenerator(); + + create( + label: string, + timeout: number | undefined, + request: (id: number) => void + ): Promise<unknown> { + const callback = new Callback(this.#idGenerator(), label, timeout); + this.#callbacks.set(callback.id, callback); + try { + request(callback.id); + } catch (error) { + // We still throw sync errors synchronously and clean up the scheduled + // callback. + callback.promise + .valueOrThrow() + .catch(debugError) + .finally(() => { + this.#callbacks.delete(callback.id); + }); + callback.reject(error as Error); + throw error; + } + // Must only have sync code up until here. + return callback.promise.valueOrThrow().finally(() => { + this.#callbacks.delete(callback.id); + }); + } + + reject(id: number, message: string, originalMessage?: string): void { + const callback = this.#callbacks.get(id); + if (!callback) { + return; + } + this._reject(callback, message, originalMessage); + } + + _reject( + callback: Callback, + errorMessage: string | ProtocolError, + originalMessage?: string + ): void { + let error: ProtocolError; + let message: string; + if (errorMessage instanceof ProtocolError) { + error = errorMessage; + error.cause = callback.error; + message = errorMessage.message; + } else { + error = callback.error; + message = errorMessage; + } + + callback.reject( + rewriteError( + error, + `Protocol error (${callback.label}): ${message}`, + originalMessage + ) + ); + } + + resolve(id: number, value: unknown): void { + const callback = this.#callbacks.get(id); + if (!callback) { + return; + } + callback.resolve(value); + } + + clear(): void { + for (const callback of this.#callbacks.values()) { + // TODO: probably we can accept error messages as params. + this._reject(callback, new TargetCloseError('Target closed')); + } + this.#callbacks.clear(); + } + + /** + * @internal + */ + getPendingProtocolErrors(): Error[] { + const result: Error[] = []; + for (const callback of this.#callbacks.values()) { + result.push( + new Error(`${callback.label} timed out. Trace: ${callback.error.stack}`) + ); + } + return result; + } +} +/** + * @internal + */ + +export class Callback { + #id: number; + #error = new ProtocolError(); + #deferred = Deferred.create<unknown>(); + #timer?: ReturnType<typeof setTimeout>; + #label: string; + + constructor(id: number, label: string, timeout?: number) { + this.#id = id; + this.#label = label; + if (timeout) { + this.#timer = setTimeout(() => { + this.#deferred.reject( + rewriteError( + this.#error, + `${label} timed out. Increase the 'protocolTimeout' setting in launch/connect calls for a higher timeout if needed.` + ) + ); + }, timeout); + } + } + + resolve(value: unknown): void { + clearTimeout(this.#timer); + this.#deferred.resolve(value); + } + + reject(error: Error): void { + clearTimeout(this.#timer); + this.#deferred.reject(error); + } + + get id(): number { + return this.#id; + } + + get promise(): Deferred<unknown> { + return this.#deferred; + } + + get error(): ProtocolError { + return this.#error; + } + + get label(): string { + return this.#label; + } +} + +/** + * @internal + */ +export function createIncrementalIdGenerator(): GetIdFn { + let id = 0; + return (): number => { + return ++id; + }; +} + +/** + * @internal + */ +export type GetIdFn = () => number; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Configuration.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Configuration.ts new file mode 100644 index 0000000000..c64d109a7c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Configuration.ts @@ -0,0 +1,120 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Product} from './Product.js'; + +/** + * Defines experiment options for Puppeteer. + * + * See individual properties for more information. + * + * @public + */ +export type ExperimentsConfiguration = Record<string, never>; + +/** + * Defines options to configure Puppeteer's behavior during installation and + * runtime. + * + * See individual properties for more information. + * + * @public + */ +export interface Configuration { + /** + * Specifies a certain version of the browser you'd like Puppeteer to use. + * + * Can be overridden by `PUPPETEER_BROWSER_REVISION`. + * + * See {@link PuppeteerNode.launch | puppeteer.launch} on how executable path + * is inferred. + * + * @defaultValue A compatible-revision of the browser. + */ + browserRevision?: string; + /** + * Defines the directory to be used by Puppeteer for caching. + * + * Can be overridden by `PUPPETEER_CACHE_DIR`. + * + * @defaultValue `path.join(os.homedir(), '.cache', 'puppeteer')` + */ + cacheDirectory?: string; + /** + * Specifies the URL prefix that is used to download the browser. + * + * Can be overridden by `PUPPETEER_DOWNLOAD_BASE_URL`. + * + * @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 + * 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}. + * + * Can be overridden by `PUPPETEER_EXECUTABLE_PATH`. + * + * @defaultValue **Auto-computed.** + */ + executablePath?: string; + /** + * Specifies which browser you'd like Puppeteer to use. + * + * Can be overridden by `PUPPETEER_PRODUCT`. + * + * @defaultValue `chrome` + */ + defaultProduct?: Product; + /** + * Defines the directory to be used by Puppeteer for creating temporary files. + * + * Can be overridden by `PUPPETEER_TMP_DIR`. + * + * @defaultValue `os.tmpdir()` + */ + temporaryDirectory?: string; + /** + * Tells Puppeteer to not download during installation. + * + * Can be overridden by `PUPPETEER_SKIP_DOWNLOAD`. + */ + skipDownload?: boolean; + /** + * Tells Puppeteer to not Chrome download during installation. + * + * Can be overridden by `PUPPETEER_SKIP_CHROME_DOWNLOAD`. + */ + skipChromeDownload?: boolean; + /** + * Tells Puppeteer to not chrome-headless-shell download during installation. + * + * Can be overridden by `PUPPETEER_SKIP_CHROME_HEADLESSS_HELL_DOWNLOAD`. + */ + skipChromeHeadlessShellDownload?: boolean; + /** + * Tells Puppeteer to log at the given level. + * + * @defaultValue `warn` + */ + logLevel?: 'silent' | 'error' | 'warn'; + /** + * Defines experimental options for Puppeteer. + */ + experiments?: ExperimentsConfiguration; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/ConnectOptions.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/ConnectOptions.ts new file mode 100644 index 0000000000..ce46585162 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/ConnectOptions.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + IsPageTargetCallback, + TargetFilterCallback, +} from '../api/Browser.js'; + +import type {ConnectionTransport} from './ConnectionTransport.js'; +import type {Viewport} from './Viewport.js'; + +/** + * @public + */ +export type ProtocolType = 'cdp' | 'webDriverBiDi'; + +/** + * Generic browser options that can be passed when launching any browser or when + * connecting to an existing browser instance. + * @public + */ +export interface BrowserConnectOptions { + /** + * Whether to ignore HTTPS errors during navigation. + * @defaultValue `false` + */ + ignoreHTTPSErrors?: boolean; + /** + * Sets the viewport for each page. + * + * @defaultValue '\{width: 800, height: 600\}' + */ + defaultViewport?: Viewport | null; + /** + * Slows down Puppeteer operations by the specified amount of milliseconds to + * aid debugging. + */ + slowMo?: number; + /** + * Callback to decide if Puppeteer should connect to a given target or not. + */ + targetFilter?: TargetFilterCallback; + /** + * @internal + */ + _isPageTarget?: IsPageTargetCallback; + + /** + * @defaultValue 'cdp' + * @public + */ + protocol?: ProtocolType; + /** + * Timeout setting for individual protocol (CDP) calls. + * + * @defaultValue `180_000` + */ + protocolTimeout?: number; +} + +/** + * @public + */ +export interface ConnectOptions extends BrowserConnectOptions { + browserWSEndpoint?: string; + browserURL?: string; + transport?: ConnectionTransport; + /** + * Headers to use for the web socket connection. + * @remarks + * Only works in the Node.js environment. + */ + headers?: Record<string, string>; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/ConnectionTransport.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/ConnectionTransport.ts new file mode 100644 index 0000000000..ff36a2557a --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/ConnectionTransport.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @public + */ +export interface ConnectionTransport { + send(message: string): void; + close(): void; + onmessage?: (message: string) => void; + onclose?: () => void; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/ConsoleMessage.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/ConsoleMessage.ts new file mode 100644 index 0000000000..85d2db9f75 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/ConsoleMessage.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {JSHandle} from '../api/JSHandle.js'; + +/** + * @public + */ +export interface ConsoleMessageLocation { + /** + * URL of the resource if known or `undefined` otherwise. + */ + url?: string; + + /** + * 0-based line number in the resource if known or `undefined` otherwise. + */ + lineNumber?: number; + + /** + * 0-based column number in the resource if known or `undefined` otherwise. + */ + columnNumber?: number; +} + +/** + * The supported types for console messages. + * @public + */ +export type ConsoleMessageType = + | 'log' + | 'debug' + | 'info' + | 'error' + | 'warning' + | 'dir' + | 'dirxml' + | 'table' + | 'trace' + | 'clear' + | 'startGroup' + | 'startGroupCollapsed' + | 'endGroup' + | 'assert' + | 'profile' + | 'profileEnd' + | 'count' + | 'timeEnd' + | 'verbose'; + +/** + * ConsoleMessage objects are dispatched by page via the 'console' event. + * @public + */ +export class ConsoleMessage { + #type: ConsoleMessageType; + #text: string; + #args: JSHandle[]; + #stackTraceLocations: ConsoleMessageLocation[]; + + /** + * @public + */ + constructor( + type: ConsoleMessageType, + text: string, + args: JSHandle[], + stackTraceLocations: ConsoleMessageLocation[] + ) { + this.#type = type; + this.#text = text; + this.#args = args; + this.#stackTraceLocations = stackTraceLocations; + } + + /** + * The type of the console message. + */ + type(): ConsoleMessageType { + return this.#type; + } + + /** + * The text of the console message. + */ + text(): string { + return this.#text; + } + + /** + * An array of arguments passed to the console. + */ + args(): JSHandle[] { + return this.#args; + } + + /** + * The location of the console message. + */ + location(): ConsoleMessageLocation { + return this.#stackTraceLocations[0] ?? {}; + } + + /** + * The array of locations on the stack of the console message. + */ + stackTrace(): ConsoleMessageLocation[] { + return this.#stackTraceLocations; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/CustomQueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/CustomQueryHandler.ts new file mode 100644 index 0000000000..33e5f889c1 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/CustomQueryHandler.ts @@ -0,0 +1,207 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type PuppeteerUtil from '../injected/injected.js'; +import {assert} from '../util/assert.js'; +import {interpolateFunction, stringifyFunction} from '../util/Function.js'; + +import { + QueryHandler, + type QuerySelector, + type QuerySelectorAll, +} from './QueryHandler.js'; +import {scriptInjector} from './ScriptInjector.js'; + +/** + * @public + */ +export interface CustomQueryHandler { + /** + * Searches for a {@link https://developer.mozilla.org/en-US/docs/Web/API/Node | Node} matching the given `selector` from {@link https://developer.mozilla.org/en-US/docs/Web/API/Node | node}. + */ + queryOne?: (node: Node, selector: string) => Node | null; + /** + * Searches for some {@link https://developer.mozilla.org/en-US/docs/Web/API/Node | Nodes} matching the given `selector` from {@link https://developer.mozilla.org/en-US/docs/Web/API/Node | node}. + */ + queryAll?: (node: Node, selector: string) => Iterable<Node>; +} + +/** + * The registry of {@link CustomQueryHandler | custom query handlers}. + * + * @example + * + * ```ts + * Puppeteer.customQueryHandlers.register('lit', { … }); + * const aHandle = await page.$('lit/…'); + * ``` + * + * @internal + */ +export class CustomQueryHandlerRegistry { + #handlers = new Map< + string, + [registerScript: string, Handler: typeof QueryHandler] + >(); + + get(name: string): typeof QueryHandler | undefined { + const handler = this.#handlers.get(name); + return handler ? handler[1] : undefined; + } + + /** + * Registers a {@link CustomQueryHandler | custom query handler}. + * + * @remarks + * After registration, the handler can be used everywhere where a selector is + * expected by prepending the selection string with `<name>/`. The name is + * only allowed to consist of lower- and upper case latin letters. + * + * @example + * + * ```ts + * Puppeteer.customQueryHandlers.register('lit', { … }); + * const aHandle = await page.$('lit/…'); + * ``` + * + * @param name - Name to register under. + * @param queryHandler - {@link CustomQueryHandler | Custom query handler} to + * register. + */ + register(name: string, handler: CustomQueryHandler): void { + assert( + !this.#handlers.has(name), + `Cannot register over existing handler: ${name}` + ); + assert( + /^[a-zA-Z]+$/.test(name), + `Custom query handler names may only contain [a-zA-Z]` + ); + assert( + handler.queryAll || handler.queryOne, + `At least one query method must be implemented.` + ); + + const Handler = class extends QueryHandler { + static override querySelectorAll: QuerySelectorAll = interpolateFunction( + (node, selector, PuppeteerUtil) => { + return PuppeteerUtil.customQuerySelectors + .get(PLACEHOLDER('name'))! + .querySelectorAll(node, selector); + }, + {name: JSON.stringify(name)} + ); + static override querySelector: QuerySelector = interpolateFunction( + (node, selector, PuppeteerUtil) => { + return PuppeteerUtil.customQuerySelectors + .get(PLACEHOLDER('name'))! + .querySelector(node, selector); + }, + {name: JSON.stringify(name)} + ); + }; + const registerScript = interpolateFunction( + (PuppeteerUtil: PuppeteerUtil) => { + PuppeteerUtil.customQuerySelectors.register(PLACEHOLDER('name'), { + queryAll: PLACEHOLDER('queryAll'), + queryOne: PLACEHOLDER('queryOne'), + }); + }, + { + name: JSON.stringify(name), + queryAll: handler.queryAll + ? stringifyFunction(handler.queryAll) + : String(undefined), + queryOne: handler.queryOne + ? stringifyFunction(handler.queryOne) + : String(undefined), + } + ).toString(); + + this.#handlers.set(name, [registerScript, Handler]); + scriptInjector.append(registerScript); + } + + /** + * Unregisters the {@link CustomQueryHandler | custom query handler} for the + * given name. + * + * @throws `Error` if there is no handler under the given name. + */ + unregister(name: string): void { + const handler = this.#handlers.get(name); + if (!handler) { + throw new Error(`Cannot unregister unknown handler: ${name}`); + } + scriptInjector.pop(handler[0]); + this.#handlers.delete(name); + } + + /** + * Gets the names of all {@link CustomQueryHandler | custom query handlers}. + */ + names(): string[] { + return [...this.#handlers.keys()]; + } + + /** + * Unregisters all custom query handlers. + */ + clear(): void { + for (const [registerScript] of this.#handlers) { + scriptInjector.pop(registerScript); + } + this.#handlers.clear(); + } +} + +/** + * @internal + */ +export const customQueryHandlers = new CustomQueryHandlerRegistry(); + +/** + * @deprecated Import {@link Puppeteer} and use the static method + * {@link Puppeteer.registerCustomQueryHandler} + * + * @public + */ +export function registerCustomQueryHandler( + name: string, + handler: CustomQueryHandler +): void { + customQueryHandlers.register(name, handler); +} + +/** + * @deprecated Import {@link Puppeteer} and use the static method + * {@link Puppeteer.unregisterCustomQueryHandler} + * + * @public + */ +export function unregisterCustomQueryHandler(name: string): void { + customQueryHandlers.unregister(name); +} + +/** + * @deprecated Import {@link Puppeteer} and use the static method + * {@link Puppeteer.customQueryHandlerNames} + * + * @public + */ +export function customQueryHandlerNames(): string[] { + return customQueryHandlers.names(); +} + +/** + * @deprecated Import {@link Puppeteer} and use the static method + * {@link Puppeteer.clearCustomQueryHandlers} + * + * @public + */ +export function clearCustomQueryHandlers(): void { + customQueryHandlers.clear(); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Debug.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Debug.ts new file mode 100644 index 0000000000..06ac9f58f9 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Debug.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type Debug from 'debug'; + +import {isNode} from '../environment.js'; + +declare global { + // eslint-disable-next-line no-var + var __PUPPETEER_DEBUG: string; +} + +/** + * @internal + */ +let debugModule: typeof Debug | null = null; +/** + * @internal + */ +export async function importDebug(): Promise<typeof Debug> { + if (!debugModule) { + debugModule = (await import('debug')).default; + } + return debugModule; +} + +/** + * A debug function that can be used in any environment. + * + * @remarks + * If used in Node, it falls back to the + * {@link https://www.npmjs.com/package/debug | debug module}. In the browser it + * uses `console.log`. + * + * In Node, use the `DEBUG` environment variable to control logging: + * + * ``` + * DEBUG=* // logs all channels + * DEBUG=foo // logs the `foo` channel + * DEBUG=foo* // logs any channels starting with `foo` + * ``` + * + * In the browser, set `window.__PUPPETEER_DEBUG` to a string: + * + * ``` + * window.__PUPPETEER_DEBUG='*'; // logs all channels + * window.__PUPPETEER_DEBUG='foo'; // logs the `foo` channel + * window.__PUPPETEER_DEBUG='foo*'; // logs any channels starting with `foo` + * ``` + * + * @example + * + * ``` + * const log = debug('Page'); + * + * log('new page created') + * // logs "Page: new page created" + * ``` + * + * @param prefix - this will be prefixed to each log. + * @returns a function that can be called to log to that debug channel. + * + * @internal + */ +export const debug = (prefix: string): ((...args: unknown[]) => void) => { + if (isNode) { + return async (...logArgs: unknown[]) => { + if (captureLogs) { + capturedLogs.push(prefix + logArgs); + } + (await importDebug())(prefix)(logArgs); + }; + } + + return (...logArgs: unknown[]): void => { + const debugLevel = (globalThis as any).__PUPPETEER_DEBUG; + if (!debugLevel) { + return; + } + + const everythingShouldBeLogged = debugLevel === '*'; + + const prefixMatchesDebugLevel = + everythingShouldBeLogged || + /** + * If the debug level is `foo*`, that means we match any prefix that + * starts with `foo`. If the level is `foo`, we match only the prefix + * `foo`. + */ + (debugLevel.endsWith('*') + ? prefix.startsWith(debugLevel) + : prefix === debugLevel); + + if (!prefixMatchesDebugLevel) { + return; + } + + // eslint-disable-next-line no-console + console.log(`${prefix}:`, ...logArgs); + }; +}; + +/** + * @internal + */ +let capturedLogs: string[] = []; +/** + * @internal + */ +let captureLogs = false; + +/** + * @internal + */ +export function setLogCapture(value: boolean): void { + capturedLogs = []; + captureLogs = value; +} + +/** + * @internal + */ +export function getCapturedLogs(): string[] { + return capturedLogs; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Device.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Device.ts new file mode 100644 index 0000000000..dbf5c13c95 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Device.ts @@ -0,0 +1,1552 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Viewport} from './Viewport.js'; + +/** + * @public + */ +export interface Device { + userAgent: string; + viewport: Viewport; +} + +const knownDevices = [ + { + name: 'Blackberry PlayBook', + userAgent: + 'Mozilla/5.0 (PlayBook; U; RIM Tablet OS 2.1.0; en-US) AppleWebKit/536.2+ (KHTML like Gecko) Version/7.2.1.0 Safari/536.2+', + viewport: { + width: 600, + height: 1024, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Blackberry PlayBook landscape', + userAgent: + 'Mozilla/5.0 (PlayBook; U; RIM Tablet OS 2.1.0; en-US) AppleWebKit/536.2+ (KHTML like Gecko) Version/7.2.1.0 Safari/536.2+', + viewport: { + width: 1024, + height: 600, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'BlackBerry Z30', + userAgent: + 'Mozilla/5.0 (BB10; Touch) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.0.9.2372 Mobile Safari/537.10+', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'BlackBerry Z30 landscape', + userAgent: + 'Mozilla/5.0 (BB10; Touch) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.0.9.2372 Mobile Safari/537.10+', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Galaxy Note 3', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Galaxy Note 3 landscape', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Galaxy Note II', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.1; en-us; GT-N7100 Build/JRO03C) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Galaxy Note II landscape', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.1; en-us; GT-N7100 Build/JRO03C) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Galaxy S III', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.0; en-us; GT-I9300 Build/IMM76D) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Galaxy S III landscape', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.0; en-us; GT-I9300 Build/IMM76D) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Galaxy S5', + userAgent: + 'Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Galaxy S5 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Galaxy S8', + userAgent: + 'Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.84 Mobile Safari/537.36', + viewport: { + width: 360, + height: 740, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Galaxy S8 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.84 Mobile Safari/537.36', + viewport: { + width: 740, + height: 360, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Galaxy S9+', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.111 Mobile Safari/537.36', + viewport: { + width: 320, + height: 658, + deviceScaleFactor: 4.5, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Galaxy S9+ landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.111 Mobile Safari/537.36', + viewport: { + width: 658, + height: 320, + deviceScaleFactor: 4.5, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Galaxy Tab S4', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.80 Safari/537.36', + viewport: { + width: 712, + height: 1138, + deviceScaleFactor: 2.25, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Galaxy Tab S4 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.80 Safari/537.36', + viewport: { + width: 1138, + height: 712, + deviceScaleFactor: 2.25, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPad', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + viewport: { + width: 768, + height: 1024, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPad landscape', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + viewport: { + width: 1024, + height: 768, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPad (gen 6)', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 768, + height: 1024, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPad (gen 6) landscape', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 1024, + height: 768, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPad (gen 7)', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 810, + height: 1080, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPad (gen 7) landscape', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 1080, + height: 810, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPad Mini', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + viewport: { + width: 768, + height: 1024, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPad Mini landscape', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + viewport: { + width: 1024, + height: 768, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPad Pro', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + viewport: { + width: 1024, + height: 1366, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPad Pro landscape', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + viewport: { + width: 1366, + height: 1024, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPad Pro 11', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 834, + height: 1194, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPad Pro 11 landscape', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 1194, + height: 834, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 4', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 7_1_2 like Mac OS X) AppleWebKit/537.51.2 (KHTML, like Gecko) Version/7.0 Mobile/11D257 Safari/9537.53', + viewport: { + width: 320, + height: 480, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 4 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 7_1_2 like Mac OS X) AppleWebKit/537.51.2 (KHTML, like Gecko) Version/7.0 Mobile/11D257 Safari/9537.53', + viewport: { + width: 480, + height: 320, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 5', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1', + viewport: { + width: 320, + height: 568, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 5 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1', + viewport: { + width: 568, + height: 320, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 6', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 375, + height: 667, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 6 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 667, + height: 375, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 6 Plus', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 414, + height: 736, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 6 Plus landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 736, + height: 414, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 7', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 375, + height: 667, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 7 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 667, + height: 375, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 7 Plus', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 414, + height: 736, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 7 Plus landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 736, + height: 414, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 8', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 375, + height: 667, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 8 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 667, + height: 375, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 8 Plus', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 414, + height: 736, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 8 Plus landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 736, + height: 414, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone SE', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1', + viewport: { + width: 320, + height: 568, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone SE landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1', + viewport: { + width: 568, + height: 320, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone X', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 375, + height: 812, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone X landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 812, + height: 375, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone XR', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1', + viewport: { + width: 414, + height: 896, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone XR landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1', + viewport: { + width: 896, + height: 414, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 11', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1', + viewport: { + width: 414, + height: 828, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 11 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1', + viewport: { + width: 828, + height: 414, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 11 Pro', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1', + viewport: { + width: 375, + height: 812, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 11 Pro landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1', + viewport: { + width: 812, + height: 375, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 11 Pro Max', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1', + viewport: { + width: 414, + height: 896, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 11 Pro Max landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1', + viewport: { + width: 896, + height: 414, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 12', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 390, + height: 844, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 12 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 844, + height: 390, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 12 Pro', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 390, + height: 844, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 12 Pro landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 844, + height: 390, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 12 Pro Max', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 428, + height: 926, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 12 Pro Max landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 926, + height: 428, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 12 Mini', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 375, + height: 812, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 12 Mini landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 812, + height: 375, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 13', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 390, + height: 844, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 13 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 844, + height: 390, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 13 Pro', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 390, + height: 844, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 13 Pro landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 844, + height: 390, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 13 Pro Max', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 428, + height: 926, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 13 Pro Max landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 926, + height: 428, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 13 Mini', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 375, + height: 812, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 13 Mini landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', + viewport: { + width: 812, + height: 375, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'JioPhone 2', + userAgent: + 'Mozilla/5.0 (Mobile; LYF/F300B/LYF-F300B-001-01-15-130718-i;Android; rv:48.0) Gecko/48.0 Firefox/48.0 KAIOS/2.5', + viewport: { + width: 240, + height: 320, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'JioPhone 2 landscape', + userAgent: + 'Mozilla/5.0 (Mobile; LYF/F300B/LYF-F300B-001-01-15-130718-i;Android; rv:48.0) Gecko/48.0 Firefox/48.0 KAIOS/2.5', + viewport: { + width: 320, + height: 240, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Kindle Fire HDX', + userAgent: + 'Mozilla/5.0 (Linux; U; en-us; KFAPWI Build/JDQ39) AppleWebKit/535.19 (KHTML, like Gecko) Silk/3.13 Safari/535.19 Silk-Accelerated=true', + viewport: { + width: 800, + height: 1280, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Kindle Fire HDX landscape', + userAgent: + 'Mozilla/5.0 (Linux; U; en-us; KFAPWI Build/JDQ39) AppleWebKit/535.19 (KHTML, like Gecko) Silk/3.13 Safari/535.19 Silk-Accelerated=true', + viewport: { + width: 1280, + height: 800, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'LG Optimus L70', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 384, + height: 640, + deviceScaleFactor: 1.25, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'LG Optimus L70 landscape', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 640, + height: 384, + deviceScaleFactor: 1.25, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Microsoft Lumia 550', + userAgent: + 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Microsoft Lumia 950', + userAgent: + 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 4, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Microsoft Lumia 950 landscape', + userAgent: + 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 4, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 10', + userAgent: + 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36', + viewport: { + width: 800, + height: 1280, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 10 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36', + viewport: { + width: 1280, + height: 800, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 4', + userAgent: + 'Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 384, + height: 640, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 4 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 640, + height: 384, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 5', + userAgent: + 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 5 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 5X', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 412, + height: 732, + deviceScaleFactor: 2.625, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 5X landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 732, + height: 412, + deviceScaleFactor: 2.625, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 6', + userAgent: + 'Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 412, + height: 732, + deviceScaleFactor: 3.5, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 6 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 732, + height: 412, + deviceScaleFactor: 3.5, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 6P', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 412, + height: 732, + deviceScaleFactor: 3.5, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 6P landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 732, + height: 412, + deviceScaleFactor: 3.5, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 7', + userAgent: + 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36', + viewport: { + width: 600, + height: 960, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 7 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36', + viewport: { + width: 960, + height: 600, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nokia Lumia 520', + userAgent: + 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 520)', + viewport: { + width: 320, + height: 533, + deviceScaleFactor: 1.5, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nokia Lumia 520 landscape', + userAgent: + 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 520)', + viewport: { + width: 533, + height: 320, + deviceScaleFactor: 1.5, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nokia N9', + userAgent: + 'Mozilla/5.0 (MeeGo; NokiaN9) AppleWebKit/534.13 (KHTML, like Gecko) NokiaBrowser/8.5.0 Mobile Safari/534.13', + viewport: { + width: 480, + height: 854, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nokia N9 landscape', + userAgent: + 'Mozilla/5.0 (MeeGo; NokiaN9) AppleWebKit/534.13 (KHTML, like Gecko) NokiaBrowser/8.5.0 Mobile Safari/534.13', + viewport: { + width: 854, + height: 480, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Pixel 2', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 411, + height: 731, + deviceScaleFactor: 2.625, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Pixel 2 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 731, + height: 411, + deviceScaleFactor: 2.625, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Pixel 2 XL', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 411, + height: 823, + deviceScaleFactor: 3.5, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Pixel 2 XL landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 823, + height: 411, + deviceScaleFactor: 3.5, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Pixel 3', + userAgent: + 'Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.158 Mobile Safari/537.36', + viewport: { + width: 393, + height: 786, + deviceScaleFactor: 2.75, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Pixel 3 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.158 Mobile Safari/537.36', + viewport: { + width: 786, + height: 393, + deviceScaleFactor: 2.75, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Pixel 4', + userAgent: + 'Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Mobile Safari/537.36', + viewport: { + width: 353, + height: 745, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Pixel 4 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Mobile Safari/537.36', + viewport: { + width: 745, + height: 353, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Pixel 4a (5G)', + userAgent: + 'Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36', + viewport: { + width: 353, + height: 745, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Pixel 4a (5G) landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36', + viewport: { + width: 745, + height: 353, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Pixel 5', + userAgent: + 'Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36', + viewport: { + width: 393, + height: 851, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Pixel 5 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36', + viewport: { + width: 851, + height: 393, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Moto G4', + userAgent: + 'Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Moto G4 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, +] as const; + +const knownDevicesByName = {} as Record< + (typeof knownDevices)[number]['name'], + Device +>; + +for (const device of knownDevices) { + knownDevicesByName[device.name] = device; +} + +/** + * A list of devices to be used with {@link Page.emulate}. + * + * @example + * + * ```ts + * import {KnownDevices} from 'puppeteer'; + * const iPhone = KnownDevices['iPhone 6']; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.emulate(iPhone); + * await page.goto('https://www.google.com'); + * // other actions... + * await browser.close(); + * })(); + * ``` + * + * @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 new file mode 100644 index 0000000000..8225d64f07 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Errors.ts @@ -0,0 +1,124 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @deprecated Do not use. + * + * @public + */ +export class CustomError extends Error { + /** + * @internal + */ + constructor(message?: string) { + super(message); + this.name = this.constructor.name; + } + + /** + * @internal + */ + get [Symbol.toStringTag](): string { + return this.constructor.name; + } +} + +/** + * TimeoutError is emitted whenever certain operations are terminated due to + * timeout. + * + * @remarks + * Example operations are {@link Page.waitForSelector | page.waitForSelector} or + * {@link PuppeteerNode.launch | puppeteer.launch}. + * + * @public + */ +export class TimeoutError extends CustomError {} + +/** + * ProtocolError is emitted whenever there is an error from the protocol. + * + * @public + */ +export class ProtocolError extends CustomError { + #code?: number; + #originalMessage = ''; + + set code(code: number | undefined) { + this.#code = code; + } + /** + * @readonly + * @public + */ + get code(): number | undefined { + return this.#code; + } + + set originalMessage(originalMessage: string) { + this.#originalMessage = originalMessage; + } + /** + * @readonly + * @public + */ + get originalMessage(): string { + return this.#originalMessage; + } +} + +/** + * Puppeteer will throw this error if a method is not + * supported by the currently used protocol + * + * @public + */ +export class UnsupportedOperation extends CustomError {} + +/** + * @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 new file mode 100644 index 0000000000..cf05ef6700 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.test.ts @@ -0,0 +1,185 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, it, beforeEach} from 'node:test'; + +import expect from 'expect'; +import sinon from 'sinon'; + +import {EventEmitter} from './EventEmitter.js'; + +describe('EventEmitter', () => { + let emitter: EventEmitter<Record<string, unknown>>; + + beforeEach(() => { + emitter = new EventEmitter(); + }); + + describe('on', () => { + const onTests = (methodName: 'on' | 'addListener'): void => { + it(`${methodName}: adds an event listener that is fired when the event is emitted`, () => { + const listener = sinon.spy(); + emitter[methodName]('foo', listener); + emitter.emit('foo', undefined); + expect(listener.callCount).toEqual(1); + }); + + it(`${methodName} sends the event data to the handler`, () => { + const listener = sinon.spy(); + const data = {}; + emitter[methodName]('foo', listener); + emitter.emit('foo', data); + expect(listener.callCount).toEqual(1); + expect(listener.firstCall.args[0]).toBe(data); + }); + + it(`${methodName}: supports chaining`, () => { + const listener = sinon.spy(); + const returnValue = emitter[methodName]('foo', listener); + expect(returnValue).toBe(emitter); + }); + }; + onTests('on'); + // we support addListener for legacy reasons + onTests('addListener'); + }); + + describe('off', () => { + const offTests = (methodName: 'off' | 'removeListener'): void => { + it(`${methodName}: removes the listener so it is no longer called`, () => { + const listener = sinon.spy(); + emitter.on('foo', listener); + emitter.emit('foo', undefined); + expect(listener.callCount).toEqual(1); + emitter.off('foo', listener); + emitter.emit('foo', undefined); + expect(listener.callCount).toEqual(1); + }); + + it(`${methodName}: supports chaining`, () => { + const listener = sinon.spy(); + emitter.on('foo', listener); + const returnValue = emitter.off('foo', listener); + expect(returnValue).toBe(emitter); + }); + }; + offTests('off'); + // we support removeListener for legacy reasons + offTests('removeListener'); + }); + + describe('once', () => { + it('only calls the listener once and then removes it', () => { + const listener = sinon.spy(); + emitter.once('foo', listener); + emitter.emit('foo', undefined); + expect(listener.callCount).toEqual(1); + emitter.emit('foo', undefined); + expect(listener.callCount).toEqual(1); + }); + + it('supports chaining', () => { + const listener = sinon.spy(); + const returnValue = emitter.once('foo', listener); + expect(returnValue).toBe(emitter); + }); + }); + + describe('emit', () => { + it('calls all the listeners for an event', () => { + const listener1 = sinon.spy(); + const listener2 = sinon.spy(); + const listener3 = sinon.spy(); + emitter.on('foo', listener1).on('foo', listener2).on('bar', listener3); + + emitter.emit('foo', undefined); + + expect(listener1.callCount).toEqual(1); + expect(listener2.callCount).toEqual(1); + expect(listener3.callCount).toEqual(0); + }); + + it('passes data through to the listener', () => { + const listener = sinon.spy(); + emitter.on('foo', listener); + const data = {}; + + emitter.emit('foo', data); + expect(listener.callCount).toEqual(1); + expect(listener.firstCall.args[0]).toBe(data); + }); + + it('returns true if the event has listeners', () => { + const listener = sinon.spy(); + emitter.on('foo', listener); + expect(emitter.emit('foo', undefined)).toBe(true); + }); + + it('returns false if the event has listeners', () => { + const listener = sinon.spy(); + emitter.on('foo', listener); + expect(emitter.emit('notFoo', undefined)).toBe(false); + }); + }); + + describe('listenerCount', () => { + it('returns the number of listeners for the given event', () => { + emitter.on('foo', () => {}); + emitter.on('foo', () => {}); + emitter.on('bar', () => {}); + expect(emitter.listenerCount('foo')).toEqual(2); + expect(emitter.listenerCount('bar')).toEqual(1); + expect(emitter.listenerCount('noListeners')).toEqual(0); + }); + }); + + describe('removeAllListeners', () => { + it('removes every listener from all events by default', () => { + emitter.on('foo', () => {}).on('bar', () => {}); + + emitter.removeAllListeners(); + expect(emitter.emit('foo', undefined)).toBe(false); + expect(emitter.emit('bar', undefined)).toBe(false); + }); + + it('returns the emitter for chaining', () => { + expect(emitter.removeAllListeners()).toBe(emitter); + }); + + it('can filter to remove only listeners for a given event name', () => { + emitter + .on('foo', () => {}) + .on('bar', () => {}) + .on('bar', () => {}); + + emitter.removeAllListeners('bar'); + expect(emitter.emit('foo', undefined)).toBe(true); + expect(emitter.emit('bar', undefined)).toBe(false); + }); + }); + + describe('dispose', () => { + it('should dispose higher order emitters properly', () => { + let values = ''; + emitter.on('foo', () => { + values += '1'; + }); + const higherOrderEmitter = new EventEmitter(emitter); + + higherOrderEmitter.on('foo', () => { + values += '2'; + }); + higherOrderEmitter.emit('foo', undefined); + + expect(values).toMatch('12'); + + higherOrderEmitter.off('foo'); + higherOrderEmitter.emit('foo', undefined); + + expect(values).toMatch('121'); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.ts new file mode 100644 index 0000000000..4a8bcb801f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.ts @@ -0,0 +1,253 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import mitt, {type Emitter} from '../../third_party/mitt/mitt.js'; +import {disposeSymbol} from '../util/disposable.js'; + +/** + * @public + */ +export type EventType = string | symbol; + +/** + * @public + */ +export type Handler<T = unknown> = (event: T) => void; + +/** + * @public + */ +export interface CommonEventEmitter<Events extends Record<EventType, unknown>> { + on<Key extends keyof Events>(type: Key, handler: Handler<Events[Key]>): this; + off<Key extends keyof Events>( + type: Key, + 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]> + ): this; + listenerCount(event: keyof Events): number; + + removeAllListeners(event?: keyof Events): this; +} + +/** + * @public + */ +export type EventsWithWildcard<Events extends Record<EventType, unknown>> = + Events & { + '*': Events[keyof Events]; + }; + +/** + * The EventEmitter class that many Puppeteer classes extend. + * + * @remarks + * + * This allows you to listen to events that Puppeteer classes fire and act + * accordingly. Therefore you'll mostly use {@link EventEmitter.on | on} and + * {@link EventEmitter.off | off} to bind + * and unbind to event listeners. + * + * @public + */ +export class EventEmitter<Events extends Record<EventType, unknown>> + implements CommonEventEmitter<EventsWithWildcard<Events>> +{ + #emitter: Emitter<EventsWithWildcard<Events>> | EventEmitter<Events>; + #handlers = new Map<keyof Events | '*', Array<Handler<any>>>(); + + /** + * If you pass an emitter, the returned emitter will wrap the passed emitter. + * + * @internal + */ + constructor( + emitter: Emitter<EventsWithWildcard<Events>> | EventEmitter<Events> = mitt( + new Map() + ) + ) { + this.#emitter = emitter; + } + + /** + * Bind an event listener to fire when an event occurs. + * @param type - the event type you'd like to listen to. Can be a string or symbol. + * @param handler - the function to be called when the event occurs. + * @returns `this` to enable you to chain method calls. + */ + on<Key extends keyof EventsWithWildcard<Events>>( + type: Key, + handler: Handler<EventsWithWildcard<Events>[Key]> + ): this { + const handlers = this.#handlers.get(type); + if (handlers === undefined) { + this.#handlers.set(type, [handler]); + } else { + handlers.push(handler); + } + + this.#emitter.on(type, handler); + return this; + } + + /** + * Remove an event listener from firing. + * @param type - the event type you'd like to stop listening to. + * @param handler - the function that should be removed. + * @returns `this` to enable you to chain method calls. + */ + off<Key extends keyof EventsWithWildcard<Events>>( + type: Key, + handler?: Handler<EventsWithWildcard<Events>[Key]> + ): this { + const handlers = this.#handlers.get(type) ?? []; + if (handler === undefined) { + for (const handler of handlers) { + this.#emitter.off(type, handler); + } + this.#handlers.delete(type); + return this; + } + const index = handlers.lastIndexOf(handler); + if (index > -1) { + this.#emitter.off(type, ...handlers.splice(index, 1)); + } + return this; + } + + /** + * Emit an event and call any associated listeners. + * + * @param type - the event you'd like to emit + * @param eventData - any data you'd like to emit with the event + * @returns `true` if there are any listeners, `false` if there are not. + */ + emit<Key extends keyof EventsWithWildcard<Events>>( + type: Key, + event: EventsWithWildcard<Events>[Key] + ): boolean { + this.#emitter.emit(type, event); + return this.listenerCount(type) > 0; + } + + /** + * 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 + * @returns `this` to enable you to chain method calls. + */ + once<Key extends keyof EventsWithWildcard<Events>>( + type: Key, + handler: Handler<EventsWithWildcard<Events>[Key]> + ): this { + const onceHandler: Handler<EventsWithWildcard<Events>[Key]> = eventData => { + handler(eventData); + this.off(type, onceHandler); + }; + + return this.on(type, onceHandler); + } + + /** + * Gets the number of listeners for a given event. + * + * @param type - the event to get the listener count for + * @returns the number of listeners bound to the given event + */ + listenerCount(type: keyof EventsWithWildcard<Events>): number { + return this.#handlers.get(type)?.length || 0; + } + + /** + * Removes all listeners. If given an event argument, it will remove only + * listeners for that event. + * + * @param type - the event to remove listeners for. + * @returns `this` to enable you to chain method calls. + */ + removeAllListeners(type?: keyof EventsWithWildcard<Events>): this { + if (type !== undefined) { + return this.off(type); + } + this[disposeSymbol](); + return this; + } + + /** + * @internal + */ + [disposeSymbol](): void { + for (const [type, handlers] of this.#handlers) { + for (const handler of handlers) { + this.#emitter.off(type, handler); + } + } + this.#handlers.clear(); + } +} + +/** + * @internal + */ +export class EventSubscription< + Target extends CommonEventEmitter<Record<Type, Event>>, + Type extends EventType = EventType, + Event = unknown, +> { + #target: Target; + #type: Type; + #handler: Handler<Event>; + + constructor(target: Target, type: Type, handler: Handler<Event>) { + this.#target = target; + this.#type = type; + this.#handler = handler; + this.#target.on(this.#type, this.#handler); + } + + [disposeSymbol](): void { + this.#target.off(this.#type, this.#handler); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/FileChooser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/FileChooser.ts new file mode 100644 index 0000000000..2e4fd14fa7 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/FileChooser.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +import type {ElementHandle} from '../api/ElementHandle.js'; +import {assert} from '../util/assert.js'; + +/** + * File choosers let you react to the page requesting for a file. + * + * @remarks + * `FileChooser` instances are returned via the {@link Page.waitForFileChooser} method. + * + * In browsers, only one file chooser can be opened at a time. + * All file choosers must be accepted or canceled. Not doing so will prevent + * subsequent file choosers from appearing. + * + * @example + * + * ```ts + * const [fileChooser] = await Promise.all([ + * page.waitForFileChooser(), + * page.click('#upload-file-button'), // some button that triggers file selection + * ]); + * await fileChooser.accept(['/tmp/myfile.pdf']); + * ``` + * + * @public + */ +export class FileChooser { + #element: ElementHandle<HTMLInputElement>; + #multiple: boolean; + #handled = false; + + /** + * @internal + */ + constructor( + element: ElementHandle<HTMLInputElement>, + event: Protocol.Page.FileChooserOpenedEvent + ) { + this.#element = element; + this.#multiple = event.mode !== 'selectSingle'; + } + + /** + * Whether file chooser allow for + * {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#attr-multiple | multiple} + * file selection. + */ + isMultiple(): boolean { + return this.#multiple; + } + + /** + * Accept the file chooser request with the given file paths. + * + * @remarks This will not validate whether the file paths exists. Also, if a + * path is relative, then it is resolved against the + * {@link https://nodejs.org/api/process.html#process_process_cwd | current working directory}. + * For locals script connecting to remote chrome environments, paths must be + * absolute. + */ + async accept(paths: string[]): Promise<void> { + assert( + !this.#handled, + 'Cannot accept FileChooser which is already handled!' + ); + this.#handled = true; + await this.#element.uploadFile(...paths); + } + + /** + * Closes the file chooser without selecting any files. + */ + async cancel(): Promise<void> { + assert( + !this.#handled, + 'Cannot cancel FileChooser which is already handled!' + ); + this.#handled = true; + // XXX: These events should converted to trusted events. Perhaps do this + // in `DOM.setFileInputFiles`? + await this.#element.evaluate(element => { + element.dispatchEvent(new Event('cancel', {bubbles: true})); + }); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/GetQueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/GetQueryHandler.ts new file mode 100644 index 0000000000..1d8bb01414 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/GetQueryHandler.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ARIAQueryHandler} from '../cdp/AriaQueryHandler.js'; + +import {customQueryHandlers} from './CustomQueryHandler.js'; +import {PierceQueryHandler} from './PierceQueryHandler.js'; +import {PQueryHandler} from './PQueryHandler.js'; +import type {QueryHandler} from './QueryHandler.js'; +import {TextQueryHandler} from './TextQueryHandler.js'; +import {XPathQueryHandler} from './XPathQueryHandler.js'; + +const BUILTIN_QUERY_HANDLERS = { + aria: ARIAQueryHandler, + pierce: PierceQueryHandler, + xpath: XPathQueryHandler, + text: TextQueryHandler, +} as const; + +const QUERY_SEPARATORS = ['=', '/']; + +/** + * @internal + */ +export function getQueryHandlerAndSelector(selector: string): { + updatedSelector: string; + QueryHandler: typeof QueryHandler; +} { + for (const handlerMap of [ + customQueryHandlers.names().map(name => { + return [name, customQueryHandlers.get(name)!] as const; + }), + Object.entries(BUILTIN_QUERY_HANDLERS), + ]) { + for (const [name, QueryHandler] of handlerMap) { + for (const separator of QUERY_SEPARATORS) { + const prefix = `${name}${separator}`; + if (selector.startsWith(prefix)) { + selector = selector.slice(prefix.length); + return {updatedSelector: selector, QueryHandler}; + } + } + } + } + return {updatedSelector: selector, QueryHandler: PQueryHandler}; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/HandleIterator.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/HandleIterator.ts new file mode 100644 index 0000000000..c88003ed71 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/HandleIterator.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {JSHandle} from '../api/JSHandle.js'; +import {DisposableStack, disposeSymbol} from '../util/disposable.js'; + +import type {AwaitableIterable, HandleFor} from './types.js'; + +const DEFAULT_BATCH_SIZE = 20; + +/** + * This will transpose an iterator JSHandle into a fast, Puppeteer-side iterator + * of JSHandles. + * + * @param size - The number of elements to transpose. This should be something + * reasonable. + */ +async function* fastTransposeIteratorHandle<T>( + iterator: JSHandle<AwaitableIterator<T>>, + size: number +) { + using array = await iterator.evaluateHandle(async (iterator, size) => { + const results = []; + while (results.length < size) { + const result = await iterator.next(); + if (result.done) { + break; + } + results.push(result.value); + } + return results; + }, size); + const properties = (await array.getProperties()) as Map<string, HandleFor<T>>; + const handles = properties.values(); + using stack = new DisposableStack(); + stack.defer(() => { + for (using handle of handles) { + handle[disposeSymbol](); + } + }); + yield* handles; + return properties.size === 0; +} + +/** + * This will transpose an iterator JSHandle in batches based on the default size + * of {@link fastTransposeIteratorHandle}. + */ + +async function* transposeIteratorHandle<T>( + iterator: JSHandle<AwaitableIterator<T>> +) { + let size = DEFAULT_BATCH_SIZE; + while (!(yield* fastTransposeIteratorHandle(iterator, size))) { + size <<= 1; + } +} + +type AwaitableIterator<T> = Iterator<T> | AsyncIterator<T>; + +/** + * @internal + */ +export async function* transposeIterableHandle<T>( + handle: JSHandle<AwaitableIterable<T>> +): AsyncIterableIterator<HandleFor<T>> { + using generatorHandle = await handle.evaluateHandle(iterable => { + return (async function* () { + yield* iterable; + })(); + }); + yield* transposeIteratorHandle(generatorHandle); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/LazyArg.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/LazyArg.ts new file mode 100644 index 0000000000..ed30281dd8 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/LazyArg.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {JSHandle} from '../api/JSHandle.js'; +import type PuppeteerUtil from '../injected/injected.js'; + +/** + * @internal + */ +export interface PuppeteerUtilWrapper { + puppeteerUtil: Promise<JSHandle<PuppeteerUtil>>; +} + +/** + * @internal + */ +export class LazyArg<T, Context = PuppeteerUtilWrapper> { + static create = <T>( + get: (context: PuppeteerUtilWrapper) => Promise<T> | T + ): T => { + // We don't want to introduce LazyArg to the type system, otherwise we would + // have to make it public. + return new LazyArg(get) as unknown as T; + }; + + #get: (context: Context) => Promise<T> | T; + private constructor(get: (context: Context) => Promise<T> | T) { + this.#get = get; + } + + async get(context: Context): Promise<T> { + return await this.#get(context); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/NetworkManagerEvents.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/NetworkManagerEvents.ts new file mode 100644 index 0000000000..eae26252d1 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/NetworkManagerEvents.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {HTTPRequest} from '../api/HTTPRequest.js'; +import type {HTTPResponse} from '../api/HTTPResponse.js'; + +import type {EventType} from './EventEmitter.js'; + +/** + * We use symbols to prevent any external parties listening to these events. + * They are internal to Puppeteer. + * + * @internal + */ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace NetworkManagerEvent { + export const Request = Symbol('NetworkManager.Request'); + export const RequestServedFromCache = Symbol( + 'NetworkManager.RequestServedFromCache' + ); + export const Response = Symbol('NetworkManager.Response'); + export const RequestFailed = Symbol('NetworkManager.RequestFailed'); + export const RequestFinished = Symbol('NetworkManager.RequestFinished'); +} + +/** + * @internal + */ +export interface NetworkManagerEvents extends Record<EventType, unknown> { + [NetworkManagerEvent.Request]: HTTPRequest; + [NetworkManagerEvent.RequestServedFromCache]: HTTPRequest | undefined; + [NetworkManagerEvent.Response]: HTTPResponse; + [NetworkManagerEvent.RequestFailed]: HTTPRequest; + [NetworkManagerEvent.RequestFinished]: HTTPRequest; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/PDFOptions.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/PDFOptions.ts new file mode 100644 index 0000000000..7cae9191a9 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/PDFOptions.ts @@ -0,0 +1,217 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @public + */ +export interface PDFMargin { + top?: string | number; + bottom?: string | number; + left?: string | number; + right?: string | number; +} + +/** + * @public + */ +export type LowerCasePaperFormat = + | 'letter' + | 'legal' + | 'tabloid' + | 'ledger' + | 'a0' + | 'a1' + | 'a2' + | 'a3' + | 'a4' + | 'a5' + | 'a6'; + +/** + * All the valid paper format types when printing a PDF. + * + * @remarks + * + * The sizes of each format are as follows: + * + * - `Letter`: 8.5in x 11in + * + * - `Legal`: 8.5in x 14in + * + * - `Tabloid`: 11in x 17in + * + * - `Ledger`: 17in x 11in + * + * - `A0`: 33.1in x 46.8in + * + * - `A1`: 23.4in x 33.1in + * + * - `A2`: 16.54in x 23.4in + * + * - `A3`: 11.7in x 16.54in + * + * - `A4`: 8.27in x 11.7in + * + * - `A5`: 5.83in x 8.27in + * + * - `A6`: 4.13in x 5.83in + * + * @public + */ +export type PaperFormat = + | Uppercase<LowerCasePaperFormat> + | Capitalize<LowerCasePaperFormat> + | LowerCasePaperFormat; + +/** + * Valid options to configure PDF generation via {@link Page.pdf}. + * @public + */ +export interface PDFOptions { + /** + * Scales the rendering of the web page. Amount must be between `0.1` and `2`. + * @defaultValue `1` + */ + scale?: number; + /** + * Whether to show the header and footer. + * @defaultValue `false` + */ + displayHeaderFooter?: boolean; + /** + * HTML template for the print header. Should be valid HTML with the following + * classes used to inject values into them: + * + * - `date` formatted print date + * + * - `title` document title + * + * - `url` document location + * + * - `pageNumber` current page number + * + * - `totalPages` total pages in the document + */ + headerTemplate?: string; + /** + * HTML template for the print footer. Has the same constraints and support + * for special classes as {@link PDFOptions | PDFOptions.headerTemplate}. + */ + footerTemplate?: string; + /** + * Set to `true` to print background graphics. + * @defaultValue `false` + */ + printBackground?: boolean; + /** + * Whether to print in landscape orientation. + * @defaultValue `false` + */ + landscape?: boolean; + /** + * Paper ranges to print, e.g. `1-5, 8, 11-13`. + * @defaultValue The empty string, which means all pages are printed. + */ + pageRanges?: string; + /** + * @remarks + * If set, this takes priority over the `width` and `height` options. + * @defaultValue `letter`. + */ + format?: PaperFormat; + /** + * Sets the width of paper. You can pass in a number or a string with a unit. + */ + width?: string | number; + /** + * Sets the height of paper. You can pass in a number or a string with a unit. + */ + height?: string | number; + /** + * Give any CSS `@page` size declared in the page priority over what is + * declared in the `width` or `height` or `format` option. + * @defaultValue `false`, which will scale the content to fit the paper size. + */ + preferCSSPageSize?: boolean; + /** + * Set the PDF margins. + * @defaultValue `undefined` no margins are set. + */ + margin?: PDFMargin; + /** + * The path to save the file to. + * + * @remarks + * + * If the path is relative, it's resolved relative to the current working directory. + * + * @defaultValue `undefined`, which means the PDF will not be written to disk. + */ + path?: string; + /** + * Hides default white background and allows generating pdfs with transparency. + * @defaultValue `false` + */ + omitBackground?: boolean; + /** + * Generate tagged (accessible) PDF. + * @defaultValue `false` + * @experimental + */ + tagged?: boolean; + /** + * Timeout in milliseconds. Pass `0` to disable timeout. + * @defaultValue `30_000` + */ + timeout?: number; +} + +/** + * @internal + */ +export interface PaperFormatDimensions { + width: number; + height: number; +} + +/** + * @internal + */ +export interface ParsedPDFOptionsInterface { + width: number; + height: number; + margin: { + top: number; + bottom: number; + left: number; + right: number; + }; +} + +/** + * @internal + */ +export type ParsedPDFOptions = Required< + Omit<PDFOptions, 'path' | 'format' | 'timeout'> & ParsedPDFOptionsInterface +>; + +/** + * @internal + */ +export const paperFormats: Record<LowerCasePaperFormat, PaperFormatDimensions> = + { + letter: {width: 8.5, height: 11}, + legal: {width: 8.5, height: 14}, + tabloid: {width: 11, height: 17}, + ledger: {width: 17, height: 11}, + a0: {width: 33.1, height: 46.8}, + a1: {width: 23.4, height: 33.1}, + a2: {width: 16.54, height: 23.4}, + a3: {width: 11.7, height: 16.54}, + a4: {width: 8.27, height: 11.7}, + a5: {width: 5.83, height: 8.27}, + a6: {width: 4.13, height: 5.83}, + } as const; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/PQueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/PQueryHandler.ts new file mode 100644 index 0000000000..db9b832d77 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/PQueryHandler.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + QueryHandler, + type QuerySelector, + type QuerySelectorAll, +} from './QueryHandler.js'; + +/** + * @internal + */ +export class PQueryHandler extends QueryHandler { + static override querySelectorAll: QuerySelectorAll = ( + element, + selector, + {pQuerySelectorAll} + ) => { + return pQuerySelectorAll(element, selector); + }; + static override querySelector: QuerySelector = ( + element, + selector, + {pQuerySelector} + ) => { + return pQuerySelector(element, selector); + }; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/PierceQueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/PierceQueryHandler.ts new file mode 100644 index 0000000000..36ddbe7f3e --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/PierceQueryHandler.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type PuppeteerUtil from '../injected/injected.js'; + +import {QueryHandler} from './QueryHandler.js'; + +/** + * @internal + */ +export class PierceQueryHandler extends QueryHandler { + static override querySelector = ( + element: Node, + selector: string, + {pierceQuerySelector}: PuppeteerUtil + ): Node | null => { + return pierceQuerySelector(element, selector); + }; + static override querySelectorAll = ( + element: Node, + selector: string, + {pierceQuerySelectorAll}: PuppeteerUtil + ): Iterable<Node> => { + return pierceQuerySelectorAll(element, selector); + }; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Product.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Product.ts new file mode 100644 index 0000000000..dcd75aceb6 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Product.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Supported products. + * @public + */ +export type Product = 'chrome' | 'firefox'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Puppeteer.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Puppeteer.ts new file mode 100644 index 0000000000..844a3622bd --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Puppeteer.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Browser} from '../api/Browser.js'; + +import {_connectToBrowser} from './BrowserConnector.js'; +import type {ConnectOptions} from './ConnectOptions.js'; +import { + type CustomQueryHandler, + customQueryHandlers, +} from './CustomQueryHandler.js'; + +/** + * Settings that are common to the Puppeteer class, regardless of environment. + * + * @internal + */ +export interface CommonPuppeteerSettings { + isPuppeteerCore: boolean; +} + +/** + * The main Puppeteer class. + * + * IMPORTANT: if you are using Puppeteer in a Node environment, you will get an + * instance of {@link PuppeteerNode} when you import or require `puppeteer`. + * That class extends `Puppeteer`, so has all the methods documented below as + * well as all that are defined on {@link PuppeteerNode}. + * + * @public + */ +export class Puppeteer { + /** + * Operations for {@link CustomQueryHandler | custom query handlers}. See + * {@link CustomQueryHandlerRegistry}. + * + * @internal + */ + static customQueryHandlers = customQueryHandlers; + + /** + * Registers a {@link CustomQueryHandler | custom query handler}. + * + * @remarks + * After registration, the handler can be used everywhere where a selector is + * expected by prepending the selection string with `<name>/`. The name is only + * allowed to consist of lower- and upper case latin letters. + * + * @example + * + * ``` + * puppeteer.registerCustomQueryHandler('text', { … }); + * const aHandle = await page.$('text/…'); + * ``` + * + * @param name - The name that the custom query handler will be registered + * under. + * @param queryHandler - The {@link CustomQueryHandler | custom query handler} + * to register. + * + * @public + */ + static registerCustomQueryHandler( + name: string, + queryHandler: CustomQueryHandler + ): void { + return this.customQueryHandlers.register(name, queryHandler); + } + + /** + * Unregisters a custom query handler for a given name. + */ + static unregisterCustomQueryHandler(name: string): void { + return this.customQueryHandlers.unregister(name); + } + + /** + * Gets the names of all custom query handlers. + */ + static customQueryHandlerNames(): string[] { + return this.customQueryHandlers.names(); + } + + /** + * Unregisters all custom query handlers. + */ + static clearCustomQueryHandlers(): void { + return this.customQueryHandlers.clear(); + } + + /** + * @internal + */ + _isPuppeteerCore: boolean; + /** + * @internal + */ + protected _changedProduct = false; + + /** + * @internal + */ + constructor(settings: CommonPuppeteerSettings) { + this._isPuppeteerCore = settings.isPuppeteerCore; + + this.connect = this.connect.bind(this); + } + + /** + * This method attaches Puppeteer to an existing browser instance. + * + * @remarks + * + * @param options - Set of configurable options to set on the browser. + * @returns Promise which resolves to browser instance. + */ + connect(options: ConnectOptions): Promise<Browser> { + return _connectToBrowser(options); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/QueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/QueryHandler.ts new file mode 100644 index 0000000000..1655c7dba2 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/QueryHandler.ts @@ -0,0 +1,205 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ElementHandle} from '../api/ElementHandle.js'; +import {_isElementHandle} from '../api/ElementHandleSymbol.js'; +import type {Frame} from '../api/Frame.js'; +import type {WaitForSelectorOptions} from '../api/Page.js'; +import type PuppeteerUtil from '../injected/injected.js'; +import {isErrorLike} from '../util/ErrorLike.js'; +import {interpolateFunction, stringifyFunction} from '../util/Function.js'; + +import {transposeIterableHandle} from './HandleIterator.js'; +import {LazyArg} from './LazyArg.js'; +import type {Awaitable, AwaitableIterable} from './types.js'; + +/** + * @internal + */ +export type QuerySelectorAll = ( + node: Node, + selector: string, + PuppeteerUtil: PuppeteerUtil +) => AwaitableIterable<Node>; + +/** + * @internal + */ +export type QuerySelector = ( + node: Node, + selector: string, + PuppeteerUtil: PuppeteerUtil +) => Awaitable<Node | null>; + +/** + * @internal + */ +export class QueryHandler { + // Either one of these may be implemented, but at least one must be. + static querySelectorAll?: QuerySelectorAll; + static querySelector?: QuerySelector; + + static get _querySelector(): QuerySelector { + if (this.querySelector) { + return this.querySelector; + } + if (!this.querySelectorAll) { + throw new Error('Cannot create default `querySelector`.'); + } + + return (this.querySelector = interpolateFunction( + async (node, selector, PuppeteerUtil) => { + const querySelectorAll: QuerySelectorAll = + PLACEHOLDER('querySelectorAll'); + const results = querySelectorAll(node, selector, PuppeteerUtil); + for await (const result of results) { + return result; + } + return null; + }, + { + querySelectorAll: stringifyFunction(this.querySelectorAll), + } + )); + } + + static get _querySelectorAll(): QuerySelectorAll { + if (this.querySelectorAll) { + return this.querySelectorAll; + } + if (!this.querySelector) { + throw new Error('Cannot create default `querySelectorAll`.'); + } + + return (this.querySelectorAll = interpolateFunction( + async function* (node, selector, PuppeteerUtil) { + const querySelector: QuerySelector = PLACEHOLDER('querySelector'); + const result = await querySelector(node, selector, PuppeteerUtil); + if (result) { + yield result; + } + }, + { + querySelector: stringifyFunction(this.querySelector), + } + )); + } + + /** + * Queries for multiple nodes given a selector and {@link ElementHandle}. + * + * Akin to {@link https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelectorAll | Document.querySelectorAll()}. + */ + static async *queryAll( + element: ElementHandle<Node>, + selector: string + ): AwaitableIterable<ElementHandle<Node>> { + using handle = await element.evaluateHandle( + this._querySelectorAll, + selector, + LazyArg.create(context => { + return context.puppeteerUtil; + }) + ); + yield* transposeIterableHandle(handle); + } + + /** + * Queries for a single node given a selector and {@link ElementHandle}. + * + * Akin to {@link https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector}. + */ + static async queryOne( + element: ElementHandle<Node>, + selector: string + ): Promise<ElementHandle<Node> | null> { + using result = await element.evaluateHandle( + this._querySelector, + selector, + LazyArg.create(context => { + return context.puppeteerUtil; + }) + ); + if (!(_isElementHandle in result)) { + return null; + } + return result.move(); + } + + /** + * Waits until a single node appears for a given selector and + * {@link ElementHandle}. + * + * This will always query the handle in the Puppeteer world and migrate the + * result to the main world. + */ + static async waitFor( + elementOrFrame: ElementHandle<Node> | Frame, + selector: string, + options: WaitForSelectorOptions + ): Promise<ElementHandle<Node> | null> { + let frame!: Frame; + using element = await (async () => { + if (!(_isElementHandle in elementOrFrame)) { + frame = elementOrFrame; + return; + } + frame = elementOrFrame.frame; + return await frame.isolatedRealm().adoptHandle(elementOrFrame); + })(); + + const {visible = false, hidden = false, timeout, signal} = options; + + try { + signal?.throwIfAborted(); + + using handle = await frame.isolatedRealm().waitForFunction( + async (PuppeteerUtil, query, selector, root, visible) => { + const querySelector = PuppeteerUtil.createFunction( + query + ) as QuerySelector; + const node = await querySelector( + root ?? document, + selector, + PuppeteerUtil + ); + return PuppeteerUtil.checkVisibility(node, visible); + }, + { + polling: visible || hidden ? 'raf' : 'mutation', + root: element, + timeout, + signal, + }, + LazyArg.create(context => { + return context.puppeteerUtil; + }), + stringifyFunction(this._querySelector), + selector, + element, + visible ? true : hidden ? false : undefined + ); + + if (signal?.aborted) { + throw signal.reason; + } + + if (!(_isElementHandle in handle)) { + return null; + } + return await frame.mainRealm().transferHandle(handle); + } catch (error) { + if (!isErrorLike(error)) { + throw error; + } + if (error.name === 'AbortError') { + throw error; + } + error.message = `Waiting for selector \`${selector}\` failed: ${error.message}`; + throw error; + } + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/ScriptInjector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/ScriptInjector.ts new file mode 100644 index 0000000000..0264c9175f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/ScriptInjector.ts @@ -0,0 +1,52 @@ +import {source as injectedSource} from '../generated/injected.js'; + +/** + * @internal + */ +export class ScriptInjector { + #updated = false; + #amendments = new Set<string>(); + + // Appends a statement of the form `(PuppeteerUtil) => {...}`. + append(statement: string): void { + this.#update(() => { + this.#amendments.add(statement); + }); + } + + pop(statement: string): void { + this.#update(() => { + this.#amendments.delete(statement); + }); + } + + inject(inject: (script: string) => void, force = false): void { + if (this.#updated || force) { + inject(this.#get()); + } + this.#updated = false; + } + + #update(callback: () => void): void { + callback(); + this.#updated = true; + } + + #get(): string { + return `(() => { + const module = {}; + ${injectedSource} + ${[...this.#amendments] + .map(statement => { + return `(${statement})(module.exports.default);`; + }) + .join('')} + return module.exports.default; + })()`; + } +} + +/** + * @internal + */ +export const scriptInjector = new ScriptInjector(); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/SecurityDetails.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/SecurityDetails.ts new file mode 100644 index 0000000000..188eeea9ad --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/SecurityDetails.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Protocol} from 'devtools-protocol'; + +/** + * The SecurityDetails class represents the security details of a + * response that was received over a secure connection. + * + * @public + */ +export class SecurityDetails { + #subjectName: string; + #issuer: string; + #validFrom: number; + #validTo: number; + #protocol: string; + #sanList: string[]; + + /** + * @internal + */ + constructor(securityPayload: Protocol.Network.SecurityDetails) { + this.#subjectName = securityPayload.subjectName; + this.#issuer = securityPayload.issuer; + this.#validFrom = securityPayload.validFrom; + this.#validTo = securityPayload.validTo; + this.#protocol = securityPayload.protocol; + this.#sanList = securityPayload.sanList; + } + + /** + * The name of the issuer of the certificate. + */ + issuer(): string { + return this.#issuer; + } + + /** + * {@link https://en.wikipedia.org/wiki/Unix_time | Unix timestamp} + * marking the start of the certificate's validity. + */ + validFrom(): number { + return this.#validFrom; + } + + /** + * {@link https://en.wikipedia.org/wiki/Unix_time | Unix timestamp} + * marking the end of the certificate's validity. + */ + validTo(): number { + return this.#validTo; + } + + /** + * The security protocol being used, e.g. "TLS 1.2". + */ + protocol(): string { + return this.#protocol; + } + + /** + * The name of the subject to which the certificate was issued. + */ + subjectName(): string { + return this.#subjectName; + } + + /** + * The list of {@link https://en.wikipedia.org/wiki/Subject_Alternative_Name | subject alternative names (SANs)} of the certificate. + */ + subjectAlternativeNames(): string[] { + return this.#sanList; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/TaskQueue.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/TaskQueue.ts new file mode 100644 index 0000000000..3ad1409c1b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/TaskQueue.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @internal + */ +export class TaskQueue { + #chain: Promise<void>; + + constructor() { + this.#chain = Promise.resolve(); + } + + postTask<T>(task: () => Promise<T>): Promise<T> { + const result = this.#chain.then(task); + this.#chain = result.then( + () => { + return undefined; + }, + () => { + return undefined; + } + ); + return result; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/TextQueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/TextQueryHandler.ts new file mode 100644 index 0000000000..450ed06957 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/TextQueryHandler.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {QueryHandler, type QuerySelectorAll} from './QueryHandler.js'; + +/** + * @internal + */ +export class TextQueryHandler extends QueryHandler { + static override querySelectorAll: QuerySelectorAll = ( + element, + selector, + {textQuerySelectorAll} + ) => { + return textQuerySelectorAll(element, selector); + }; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/TimeoutSettings.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/TimeoutSettings.ts new file mode 100644 index 0000000000..7789d89b75 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/TimeoutSettings.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2019 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +const DEFAULT_TIMEOUT = 30000; + +/** + * @internal + */ +export class TimeoutSettings { + #defaultTimeout: number | null; + #defaultNavigationTimeout: number | null; + + constructor() { + this.#defaultTimeout = null; + this.#defaultNavigationTimeout = null; + } + + setDefaultTimeout(timeout: number): void { + this.#defaultTimeout = timeout; + } + + setDefaultNavigationTimeout(timeout: number): void { + this.#defaultNavigationTimeout = timeout; + } + + navigationTimeout(): number { + if (this.#defaultNavigationTimeout !== null) { + return this.#defaultNavigationTimeout; + } + if (this.#defaultTimeout !== null) { + return this.#defaultTimeout; + } + return DEFAULT_TIMEOUT; + } + + timeout(): number { + if (this.#defaultTimeout !== null) { + return this.#defaultTimeout; + } + return DEFAULT_TIMEOUT; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/USKeyboardLayout.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/USKeyboardLayout.ts new file mode 100644 index 0000000000..0a6d2f2e18 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/USKeyboardLayout.ts @@ -0,0 +1,671 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @internal + */ +export interface KeyDefinition { + keyCode?: number; + shiftKeyCode?: number; + key?: string; + shiftKey?: string; + code?: string; + text?: string; + shiftText?: string; + location?: number; +} + +/** + * All the valid keys that can be passed to functions that take user input, such + * as {@link Keyboard.press | keyboard.press } + * + * @public + */ +export type KeyInput = + | '0' + | '1' + | '2' + | '3' + | '4' + | '5' + | '6' + | '7' + | '8' + | '9' + | 'Power' + | 'Eject' + | 'Abort' + | 'Help' + | 'Backspace' + | 'Tab' + | 'Numpad5' + | 'NumpadEnter' + | 'Enter' + | '\r' + | '\n' + | 'ShiftLeft' + | 'ShiftRight' + | 'ControlLeft' + | 'ControlRight' + | 'AltLeft' + | 'AltRight' + | 'Pause' + | 'CapsLock' + | 'Escape' + | 'Convert' + | 'NonConvert' + | 'Space' + | 'Numpad9' + | 'PageUp' + | 'Numpad3' + | 'PageDown' + | 'End' + | 'Numpad1' + | 'Home' + | 'Numpad7' + | 'ArrowLeft' + | 'Numpad4' + | 'Numpad8' + | 'ArrowUp' + | 'ArrowRight' + | 'Numpad6' + | 'Numpad2' + | 'ArrowDown' + | 'Select' + | 'Open' + | 'PrintScreen' + | 'Insert' + | 'Numpad0' + | 'Delete' + | 'NumpadDecimal' + | 'Digit0' + | 'Digit1' + | 'Digit2' + | 'Digit3' + | 'Digit4' + | 'Digit5' + | 'Digit6' + | 'Digit7' + | 'Digit8' + | 'Digit9' + | 'KeyA' + | 'KeyB' + | 'KeyC' + | 'KeyD' + | 'KeyE' + | 'KeyF' + | 'KeyG' + | 'KeyH' + | 'KeyI' + | 'KeyJ' + | 'KeyK' + | 'KeyL' + | 'KeyM' + | 'KeyN' + | 'KeyO' + | 'KeyP' + | 'KeyQ' + | 'KeyR' + | 'KeyS' + | 'KeyT' + | 'KeyU' + | 'KeyV' + | 'KeyW' + | 'KeyX' + | 'KeyY' + | 'KeyZ' + | 'MetaLeft' + | 'MetaRight' + | 'ContextMenu' + | 'NumpadMultiply' + | 'NumpadAdd' + | 'NumpadSubtract' + | 'NumpadDivide' + | 'F1' + | 'F2' + | 'F3' + | 'F4' + | 'F5' + | 'F6' + | 'F7' + | 'F8' + | 'F9' + | 'F10' + | 'F11' + | 'F12' + | 'F13' + | 'F14' + | 'F15' + | 'F16' + | 'F17' + | 'F18' + | 'F19' + | 'F20' + | 'F21' + | 'F22' + | 'F23' + | 'F24' + | 'NumLock' + | 'ScrollLock' + | 'AudioVolumeMute' + | 'AudioVolumeDown' + | 'AudioVolumeUp' + | 'MediaTrackNext' + | 'MediaTrackPrevious' + | 'MediaStop' + | 'MediaPlayPause' + | 'Semicolon' + | 'Equal' + | 'NumpadEqual' + | 'Comma' + | 'Minus' + | 'Period' + | 'Slash' + | 'Backquote' + | 'BracketLeft' + | 'Backslash' + | 'BracketRight' + | 'Quote' + | 'AltGraph' + | 'Props' + | 'Cancel' + | 'Clear' + | 'Shift' + | 'Control' + | 'Alt' + | 'Accept' + | 'ModeChange' + | ' ' + | 'Print' + | 'Execute' + | '\u0000' + | 'a' + | 'b' + | 'c' + | 'd' + | 'e' + | 'f' + | 'g' + | 'h' + | 'i' + | 'j' + | 'k' + | 'l' + | 'm' + | 'n' + | 'o' + | 'p' + | 'q' + | 'r' + | 's' + | 't' + | 'u' + | 'v' + | 'w' + | 'x' + | 'y' + | 'z' + | 'Meta' + | '*' + | '+' + | '-' + | '/' + | ';' + | '=' + | ',' + | '.' + | '`' + | '[' + | '\\' + | ']' + | "'" + | 'Attn' + | 'CrSel' + | 'ExSel' + | 'EraseEof' + | 'Play' + | 'ZoomOut' + | ')' + | '!' + | '@' + | '#' + | '$' + | '%' + | '^' + | '&' + | '(' + | 'A' + | 'B' + | 'C' + | 'D' + | 'E' + | 'F' + | 'G' + | 'H' + | 'I' + | 'J' + | 'K' + | 'L' + | 'M' + | 'N' + | 'O' + | 'P' + | 'Q' + | 'R' + | 'S' + | 'T' + | 'U' + | 'V' + | 'W' + | 'X' + | 'Y' + | 'Z' + | ':' + | '<' + | '_' + | '>' + | '?' + | '~' + | '{' + | '|' + | '}' + | '"' + | 'SoftLeft' + | 'SoftRight' + | 'Camera' + | 'Call' + | 'EndCall' + | 'VolumeDown' + | 'VolumeUp'; + +/** + * @internal + */ +export const _keyDefinitions: Readonly<Record<KeyInput, KeyDefinition>> = { + '0': {keyCode: 48, key: '0', code: 'Digit0'}, + '1': {keyCode: 49, key: '1', code: 'Digit1'}, + '2': {keyCode: 50, key: '2', code: 'Digit2'}, + '3': {keyCode: 51, key: '3', code: 'Digit3'}, + '4': {keyCode: 52, key: '4', code: 'Digit4'}, + '5': {keyCode: 53, key: '5', code: 'Digit5'}, + '6': {keyCode: 54, key: '6', code: 'Digit6'}, + '7': {keyCode: 55, key: '7', code: 'Digit7'}, + '8': {keyCode: 56, key: '8', code: 'Digit8'}, + '9': {keyCode: 57, key: '9', code: 'Digit9'}, + Power: {key: 'Power', code: 'Power'}, + Eject: {key: 'Eject', code: 'Eject'}, + Abort: {keyCode: 3, code: 'Abort', key: 'Cancel'}, + Help: {keyCode: 6, code: 'Help', key: 'Help'}, + Backspace: {keyCode: 8, code: 'Backspace', key: 'Backspace'}, + Tab: {keyCode: 9, code: 'Tab', key: 'Tab'}, + Numpad5: { + keyCode: 12, + shiftKeyCode: 101, + key: 'Clear', + code: 'Numpad5', + shiftKey: '5', + location: 3, + }, + NumpadEnter: { + keyCode: 13, + code: 'NumpadEnter', + key: 'Enter', + text: '\r', + location: 3, + }, + Enter: {keyCode: 13, code: 'Enter', key: 'Enter', text: '\r'}, + '\r': {keyCode: 13, code: 'Enter', key: 'Enter', text: '\r'}, + '\n': {keyCode: 13, code: 'Enter', key: 'Enter', text: '\r'}, + ShiftLeft: {keyCode: 16, code: 'ShiftLeft', key: 'Shift', location: 1}, + ShiftRight: {keyCode: 16, code: 'ShiftRight', key: 'Shift', location: 2}, + ControlLeft: { + keyCode: 17, + code: 'ControlLeft', + key: 'Control', + location: 1, + }, + ControlRight: { + keyCode: 17, + code: 'ControlRight', + key: 'Control', + location: 2, + }, + AltLeft: {keyCode: 18, code: 'AltLeft', key: 'Alt', location: 1}, + AltRight: {keyCode: 18, code: 'AltRight', key: 'Alt', location: 2}, + Pause: {keyCode: 19, code: 'Pause', key: 'Pause'}, + CapsLock: {keyCode: 20, code: 'CapsLock', key: 'CapsLock'}, + Escape: {keyCode: 27, code: 'Escape', key: 'Escape'}, + Convert: {keyCode: 28, code: 'Convert', key: 'Convert'}, + NonConvert: {keyCode: 29, code: 'NonConvert', key: 'NonConvert'}, + Space: {keyCode: 32, code: 'Space', key: ' '}, + Numpad9: { + keyCode: 33, + shiftKeyCode: 105, + key: 'PageUp', + code: 'Numpad9', + shiftKey: '9', + location: 3, + }, + PageUp: {keyCode: 33, code: 'PageUp', key: 'PageUp'}, + Numpad3: { + keyCode: 34, + shiftKeyCode: 99, + key: 'PageDown', + code: 'Numpad3', + shiftKey: '3', + location: 3, + }, + PageDown: {keyCode: 34, code: 'PageDown', key: 'PageDown'}, + End: {keyCode: 35, code: 'End', key: 'End'}, + Numpad1: { + keyCode: 35, + shiftKeyCode: 97, + key: 'End', + code: 'Numpad1', + shiftKey: '1', + location: 3, + }, + Home: {keyCode: 36, code: 'Home', key: 'Home'}, + Numpad7: { + keyCode: 36, + shiftKeyCode: 103, + key: 'Home', + code: 'Numpad7', + shiftKey: '7', + location: 3, + }, + ArrowLeft: {keyCode: 37, code: 'ArrowLeft', key: 'ArrowLeft'}, + Numpad4: { + keyCode: 37, + shiftKeyCode: 100, + key: 'ArrowLeft', + code: 'Numpad4', + shiftKey: '4', + location: 3, + }, + Numpad8: { + keyCode: 38, + shiftKeyCode: 104, + key: 'ArrowUp', + code: 'Numpad8', + shiftKey: '8', + location: 3, + }, + ArrowUp: {keyCode: 38, code: 'ArrowUp', key: 'ArrowUp'}, + ArrowRight: {keyCode: 39, code: 'ArrowRight', key: 'ArrowRight'}, + Numpad6: { + keyCode: 39, + shiftKeyCode: 102, + key: 'ArrowRight', + code: 'Numpad6', + shiftKey: '6', + location: 3, + }, + Numpad2: { + keyCode: 40, + shiftKeyCode: 98, + key: 'ArrowDown', + code: 'Numpad2', + shiftKey: '2', + location: 3, + }, + ArrowDown: {keyCode: 40, code: 'ArrowDown', key: 'ArrowDown'}, + Select: {keyCode: 41, code: 'Select', key: 'Select'}, + Open: {keyCode: 43, code: 'Open', key: 'Execute'}, + PrintScreen: {keyCode: 44, code: 'PrintScreen', key: 'PrintScreen'}, + Insert: {keyCode: 45, code: 'Insert', key: 'Insert'}, + Numpad0: { + keyCode: 45, + shiftKeyCode: 96, + key: 'Insert', + code: 'Numpad0', + shiftKey: '0', + location: 3, + }, + Delete: {keyCode: 46, code: 'Delete', key: 'Delete'}, + NumpadDecimal: { + keyCode: 46, + shiftKeyCode: 110, + code: 'NumpadDecimal', + key: '\u0000', + shiftKey: '.', + location: 3, + }, + Digit0: {keyCode: 48, code: 'Digit0', shiftKey: ')', key: '0'}, + Digit1: {keyCode: 49, code: 'Digit1', shiftKey: '!', key: '1'}, + Digit2: {keyCode: 50, code: 'Digit2', shiftKey: '@', key: '2'}, + Digit3: {keyCode: 51, code: 'Digit3', shiftKey: '#', key: '3'}, + Digit4: {keyCode: 52, code: 'Digit4', shiftKey: '$', key: '4'}, + Digit5: {keyCode: 53, code: 'Digit5', shiftKey: '%', key: '5'}, + Digit6: {keyCode: 54, code: 'Digit6', shiftKey: '^', key: '6'}, + Digit7: {keyCode: 55, code: 'Digit7', shiftKey: '&', key: '7'}, + Digit8: {keyCode: 56, code: 'Digit8', shiftKey: '*', key: '8'}, + Digit9: {keyCode: 57, code: 'Digit9', shiftKey: '(', key: '9'}, + KeyA: {keyCode: 65, code: 'KeyA', shiftKey: 'A', key: 'a'}, + KeyB: {keyCode: 66, code: 'KeyB', shiftKey: 'B', key: 'b'}, + KeyC: {keyCode: 67, code: 'KeyC', shiftKey: 'C', key: 'c'}, + KeyD: {keyCode: 68, code: 'KeyD', shiftKey: 'D', key: 'd'}, + KeyE: {keyCode: 69, code: 'KeyE', shiftKey: 'E', key: 'e'}, + KeyF: {keyCode: 70, code: 'KeyF', shiftKey: 'F', key: 'f'}, + KeyG: {keyCode: 71, code: 'KeyG', shiftKey: 'G', key: 'g'}, + KeyH: {keyCode: 72, code: 'KeyH', shiftKey: 'H', key: 'h'}, + KeyI: {keyCode: 73, code: 'KeyI', shiftKey: 'I', key: 'i'}, + KeyJ: {keyCode: 74, code: 'KeyJ', shiftKey: 'J', key: 'j'}, + KeyK: {keyCode: 75, code: 'KeyK', shiftKey: 'K', key: 'k'}, + KeyL: {keyCode: 76, code: 'KeyL', shiftKey: 'L', key: 'l'}, + KeyM: {keyCode: 77, code: 'KeyM', shiftKey: 'M', key: 'm'}, + KeyN: {keyCode: 78, code: 'KeyN', shiftKey: 'N', key: 'n'}, + KeyO: {keyCode: 79, code: 'KeyO', shiftKey: 'O', key: 'o'}, + KeyP: {keyCode: 80, code: 'KeyP', shiftKey: 'P', key: 'p'}, + KeyQ: {keyCode: 81, code: 'KeyQ', shiftKey: 'Q', key: 'q'}, + KeyR: {keyCode: 82, code: 'KeyR', shiftKey: 'R', key: 'r'}, + KeyS: {keyCode: 83, code: 'KeyS', shiftKey: 'S', key: 's'}, + KeyT: {keyCode: 84, code: 'KeyT', shiftKey: 'T', key: 't'}, + KeyU: {keyCode: 85, code: 'KeyU', shiftKey: 'U', key: 'u'}, + KeyV: {keyCode: 86, code: 'KeyV', shiftKey: 'V', key: 'v'}, + KeyW: {keyCode: 87, code: 'KeyW', shiftKey: 'W', key: 'w'}, + KeyX: {keyCode: 88, code: 'KeyX', shiftKey: 'X', key: 'x'}, + KeyY: {keyCode: 89, code: 'KeyY', shiftKey: 'Y', key: 'y'}, + KeyZ: {keyCode: 90, code: 'KeyZ', shiftKey: 'Z', key: 'z'}, + MetaLeft: {keyCode: 91, code: 'MetaLeft', key: 'Meta', location: 1}, + MetaRight: {keyCode: 92, code: 'MetaRight', key: 'Meta', location: 2}, + ContextMenu: {keyCode: 93, code: 'ContextMenu', key: 'ContextMenu'}, + NumpadMultiply: { + keyCode: 106, + code: 'NumpadMultiply', + key: '*', + location: 3, + }, + NumpadAdd: {keyCode: 107, code: 'NumpadAdd', key: '+', location: 3}, + NumpadSubtract: { + keyCode: 109, + code: 'NumpadSubtract', + key: '-', + location: 3, + }, + NumpadDivide: {keyCode: 111, code: 'NumpadDivide', key: '/', location: 3}, + F1: {keyCode: 112, code: 'F1', key: 'F1'}, + F2: {keyCode: 113, code: 'F2', key: 'F2'}, + F3: {keyCode: 114, code: 'F3', key: 'F3'}, + F4: {keyCode: 115, code: 'F4', key: 'F4'}, + F5: {keyCode: 116, code: 'F5', key: 'F5'}, + F6: {keyCode: 117, code: 'F6', key: 'F6'}, + F7: {keyCode: 118, code: 'F7', key: 'F7'}, + F8: {keyCode: 119, code: 'F8', key: 'F8'}, + F9: {keyCode: 120, code: 'F9', key: 'F9'}, + F10: {keyCode: 121, code: 'F10', key: 'F10'}, + F11: {keyCode: 122, code: 'F11', key: 'F11'}, + F12: {keyCode: 123, code: 'F12', key: 'F12'}, + F13: {keyCode: 124, code: 'F13', key: 'F13'}, + F14: {keyCode: 125, code: 'F14', key: 'F14'}, + F15: {keyCode: 126, code: 'F15', key: 'F15'}, + F16: {keyCode: 127, code: 'F16', key: 'F16'}, + F17: {keyCode: 128, code: 'F17', key: 'F17'}, + F18: {keyCode: 129, code: 'F18', key: 'F18'}, + F19: {keyCode: 130, code: 'F19', key: 'F19'}, + F20: {keyCode: 131, code: 'F20', key: 'F20'}, + F21: {keyCode: 132, code: 'F21', key: 'F21'}, + F22: {keyCode: 133, code: 'F22', key: 'F22'}, + F23: {keyCode: 134, code: 'F23', key: 'F23'}, + F24: {keyCode: 135, code: 'F24', key: 'F24'}, + NumLock: {keyCode: 144, code: 'NumLock', key: 'NumLock'}, + ScrollLock: {keyCode: 145, code: 'ScrollLock', key: 'ScrollLock'}, + AudioVolumeMute: { + keyCode: 173, + code: 'AudioVolumeMute', + key: 'AudioVolumeMute', + }, + AudioVolumeDown: { + keyCode: 174, + code: 'AudioVolumeDown', + key: 'AudioVolumeDown', + }, + AudioVolumeUp: {keyCode: 175, code: 'AudioVolumeUp', key: 'AudioVolumeUp'}, + MediaTrackNext: { + keyCode: 176, + code: 'MediaTrackNext', + key: 'MediaTrackNext', + }, + MediaTrackPrevious: { + keyCode: 177, + code: 'MediaTrackPrevious', + key: 'MediaTrackPrevious', + }, + MediaStop: {keyCode: 178, code: 'MediaStop', key: 'MediaStop'}, + MediaPlayPause: { + keyCode: 179, + code: 'MediaPlayPause', + key: 'MediaPlayPause', + }, + Semicolon: {keyCode: 186, code: 'Semicolon', shiftKey: ':', key: ';'}, + Equal: {keyCode: 187, code: 'Equal', shiftKey: '+', key: '='}, + NumpadEqual: {keyCode: 187, code: 'NumpadEqual', key: '=', location: 3}, + Comma: {keyCode: 188, code: 'Comma', shiftKey: '<', key: ','}, + Minus: {keyCode: 189, code: 'Minus', shiftKey: '_', key: '-'}, + Period: {keyCode: 190, code: 'Period', shiftKey: '>', key: '.'}, + Slash: {keyCode: 191, code: 'Slash', shiftKey: '?', key: '/'}, + Backquote: {keyCode: 192, code: 'Backquote', shiftKey: '~', key: '`'}, + BracketLeft: {keyCode: 219, code: 'BracketLeft', shiftKey: '{', key: '['}, + Backslash: {keyCode: 220, code: 'Backslash', shiftKey: '|', key: '\\'}, + BracketRight: {keyCode: 221, code: 'BracketRight', shiftKey: '}', key: ']'}, + Quote: {keyCode: 222, code: 'Quote', shiftKey: '"', key: "'"}, + AltGraph: {keyCode: 225, code: 'AltGraph', key: 'AltGraph'}, + Props: {keyCode: 247, code: 'Props', key: 'CrSel'}, + Cancel: {keyCode: 3, key: 'Cancel', code: 'Abort'}, + Clear: {keyCode: 12, key: 'Clear', code: 'Numpad5', location: 3}, + Shift: {keyCode: 16, key: 'Shift', code: 'ShiftLeft', location: 1}, + Control: {keyCode: 17, key: 'Control', code: 'ControlLeft', location: 1}, + Alt: {keyCode: 18, key: 'Alt', code: 'AltLeft', location: 1}, + Accept: {keyCode: 30, key: 'Accept'}, + ModeChange: {keyCode: 31, key: 'ModeChange'}, + ' ': {keyCode: 32, key: ' ', code: 'Space'}, + Print: {keyCode: 42, key: 'Print'}, + Execute: {keyCode: 43, key: 'Execute', code: 'Open'}, + '\u0000': {keyCode: 46, key: '\u0000', code: 'NumpadDecimal', location: 3}, + a: {keyCode: 65, key: 'a', code: 'KeyA'}, + b: {keyCode: 66, key: 'b', code: 'KeyB'}, + c: {keyCode: 67, key: 'c', code: 'KeyC'}, + d: {keyCode: 68, key: 'd', code: 'KeyD'}, + e: {keyCode: 69, key: 'e', code: 'KeyE'}, + f: {keyCode: 70, key: 'f', code: 'KeyF'}, + g: {keyCode: 71, key: 'g', code: 'KeyG'}, + h: {keyCode: 72, key: 'h', code: 'KeyH'}, + i: {keyCode: 73, key: 'i', code: 'KeyI'}, + j: {keyCode: 74, key: 'j', code: 'KeyJ'}, + k: {keyCode: 75, key: 'k', code: 'KeyK'}, + l: {keyCode: 76, key: 'l', code: 'KeyL'}, + m: {keyCode: 77, key: 'm', code: 'KeyM'}, + n: {keyCode: 78, key: 'n', code: 'KeyN'}, + o: {keyCode: 79, key: 'o', code: 'KeyO'}, + p: {keyCode: 80, key: 'p', code: 'KeyP'}, + q: {keyCode: 81, key: 'q', code: 'KeyQ'}, + r: {keyCode: 82, key: 'r', code: 'KeyR'}, + s: {keyCode: 83, key: 's', code: 'KeyS'}, + t: {keyCode: 84, key: 't', code: 'KeyT'}, + u: {keyCode: 85, key: 'u', code: 'KeyU'}, + v: {keyCode: 86, key: 'v', code: 'KeyV'}, + w: {keyCode: 87, key: 'w', code: 'KeyW'}, + x: {keyCode: 88, key: 'x', code: 'KeyX'}, + y: {keyCode: 89, key: 'y', code: 'KeyY'}, + z: {keyCode: 90, key: 'z', code: 'KeyZ'}, + Meta: {keyCode: 91, key: 'Meta', code: 'MetaLeft', location: 1}, + '*': {keyCode: 106, key: '*', code: 'NumpadMultiply', location: 3}, + '+': {keyCode: 107, key: '+', code: 'NumpadAdd', location: 3}, + '-': {keyCode: 109, key: '-', code: 'NumpadSubtract', location: 3}, + '/': {keyCode: 111, key: '/', code: 'NumpadDivide', location: 3}, + ';': {keyCode: 186, key: ';', code: 'Semicolon'}, + '=': {keyCode: 187, key: '=', code: 'Equal'}, + ',': {keyCode: 188, key: ',', code: 'Comma'}, + '.': {keyCode: 190, key: '.', code: 'Period'}, + '`': {keyCode: 192, key: '`', code: 'Backquote'}, + '[': {keyCode: 219, key: '[', code: 'BracketLeft'}, + '\\': {keyCode: 220, key: '\\', code: 'Backslash'}, + ']': {keyCode: 221, key: ']', code: 'BracketRight'}, + "'": {keyCode: 222, key: "'", code: 'Quote'}, + Attn: {keyCode: 246, key: 'Attn'}, + CrSel: {keyCode: 247, key: 'CrSel', code: 'Props'}, + ExSel: {keyCode: 248, key: 'ExSel'}, + EraseEof: {keyCode: 249, key: 'EraseEof'}, + Play: {keyCode: 250, key: 'Play'}, + ZoomOut: {keyCode: 251, key: 'ZoomOut'}, + ')': {keyCode: 48, key: ')', code: 'Digit0'}, + '!': {keyCode: 49, key: '!', code: 'Digit1'}, + '@': {keyCode: 50, key: '@', code: 'Digit2'}, + '#': {keyCode: 51, key: '#', code: 'Digit3'}, + $: {keyCode: 52, key: '$', code: 'Digit4'}, + '%': {keyCode: 53, key: '%', code: 'Digit5'}, + '^': {keyCode: 54, key: '^', code: 'Digit6'}, + '&': {keyCode: 55, key: '&', code: 'Digit7'}, + '(': {keyCode: 57, key: '(', code: 'Digit9'}, + A: {keyCode: 65, key: 'A', code: 'KeyA'}, + B: {keyCode: 66, key: 'B', code: 'KeyB'}, + C: {keyCode: 67, key: 'C', code: 'KeyC'}, + D: {keyCode: 68, key: 'D', code: 'KeyD'}, + E: {keyCode: 69, key: 'E', code: 'KeyE'}, + F: {keyCode: 70, key: 'F', code: 'KeyF'}, + G: {keyCode: 71, key: 'G', code: 'KeyG'}, + H: {keyCode: 72, key: 'H', code: 'KeyH'}, + I: {keyCode: 73, key: 'I', code: 'KeyI'}, + J: {keyCode: 74, key: 'J', code: 'KeyJ'}, + K: {keyCode: 75, key: 'K', code: 'KeyK'}, + L: {keyCode: 76, key: 'L', code: 'KeyL'}, + M: {keyCode: 77, key: 'M', code: 'KeyM'}, + N: {keyCode: 78, key: 'N', code: 'KeyN'}, + O: {keyCode: 79, key: 'O', code: 'KeyO'}, + P: {keyCode: 80, key: 'P', code: 'KeyP'}, + Q: {keyCode: 81, key: 'Q', code: 'KeyQ'}, + R: {keyCode: 82, key: 'R', code: 'KeyR'}, + S: {keyCode: 83, key: 'S', code: 'KeyS'}, + T: {keyCode: 84, key: 'T', code: 'KeyT'}, + U: {keyCode: 85, key: 'U', code: 'KeyU'}, + V: {keyCode: 86, key: 'V', code: 'KeyV'}, + W: {keyCode: 87, key: 'W', code: 'KeyW'}, + X: {keyCode: 88, key: 'X', code: 'KeyX'}, + Y: {keyCode: 89, key: 'Y', code: 'KeyY'}, + Z: {keyCode: 90, key: 'Z', code: 'KeyZ'}, + ':': {keyCode: 186, key: ':', code: 'Semicolon'}, + '<': {keyCode: 188, key: '<', code: 'Comma'}, + _: {keyCode: 189, key: '_', code: 'Minus'}, + '>': {keyCode: 190, key: '>', code: 'Period'}, + '?': {keyCode: 191, key: '?', code: 'Slash'}, + '~': {keyCode: 192, key: '~', code: 'Backquote'}, + '{': {keyCode: 219, key: '{', code: 'BracketLeft'}, + '|': {keyCode: 220, key: '|', code: 'Backslash'}, + '}': {keyCode: 221, key: '}', code: 'BracketRight'}, + '"': {keyCode: 222, key: '"', code: 'Quote'}, + SoftLeft: {key: 'SoftLeft', code: 'SoftLeft', location: 4}, + SoftRight: {key: 'SoftRight', code: 'SoftRight', location: 4}, + Camera: {keyCode: 44, key: 'Camera', code: 'Camera', location: 4}, + Call: {key: 'Call', code: 'Call', location: 4}, + EndCall: {keyCode: 95, key: 'EndCall', code: 'EndCall', location: 4}, + VolumeDown: { + keyCode: 182, + key: 'VolumeDown', + code: 'VolumeDown', + location: 4, + }, + VolumeUp: {keyCode: 183, key: 'VolumeUp', code: 'VolumeUp', location: 4}, +}; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Viewport.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Viewport.ts new file mode 100644 index 0000000000..46a937a88f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Viewport.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @public + */ +export interface Viewport { + /** + * The page width in CSS pixels. + * + * @remarks + * Setting this value to `0` will reset this value to the system default. + */ + width: number; + /** + * The page height in CSS pixels. + * + * @remarks + * Setting this value to `0` will reset this value to the system default. + */ + height: number; + /** + * Specify device scale factor. + * See {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio | devicePixelRatio} for more info. + * + * @remarks + * Setting this value to `0` will reset this value to the system default. + * + * @defaultValue `1` + */ + deviceScaleFactor?: number; + /** + * Whether the `meta viewport` tag is taken into account. + * @defaultValue `false` + */ + isMobile?: boolean; + /** + * Specifies if the viewport is in landscape mode. + * @defaultValue `false` + */ + isLandscape?: boolean; + /** + * Specify if the viewport supports touch events. + * @defaultValue `false` + */ + hasTouch?: boolean; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/WaitTask.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/WaitTask.ts new file mode 100644 index 0000000000..d0c1e2a038 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/WaitTask.ts @@ -0,0 +1,275 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ElementHandle} from '../api/ElementHandle.js'; +import type {JSHandle} from '../api/JSHandle.js'; +import type {Realm} from '../api/Realm.js'; +import type {Poller} from '../injected/Poller.js'; +import {Deferred} from '../util/Deferred.js'; +import {isErrorLike} from '../util/ErrorLike.js'; +import {stringifyFunction} from '../util/Function.js'; + +import {TimeoutError} from './Errors.js'; +import {LazyArg} from './LazyArg.js'; +import type {HandleFor} from './types.js'; + +/** + * @internal + */ +export interface WaitTaskOptions { + polling: 'raf' | 'mutation' | number; + root?: ElementHandle<Node>; + timeout: number; + signal?: AbortSignal; +} + +/** + * @internal + */ +export class WaitTask<T = unknown> { + #world: Realm; + #polling: 'raf' | 'mutation' | number; + #root?: ElementHandle<Node>; + + #fn: string; + #args: unknown[]; + + #timeout?: NodeJS.Timeout; + #timeoutError?: TimeoutError; + + #result = Deferred.create<HandleFor<T>>(); + + #poller?: JSHandle<Poller<T>>; + #signal?: AbortSignal; + #reruns: AbortController[] = []; + + constructor( + world: Realm, + options: WaitTaskOptions, + fn: ((...args: unknown[]) => Promise<T>) | string, + ...args: unknown[] + ) { + this.#world = world; + this.#polling = options.polling; + this.#root = options.root; + this.#signal = options.signal; + this.#signal?.addEventListener( + 'abort', + () => { + void this.terminate(this.#signal?.reason); + }, + { + once: true, + } + ); + + switch (typeof fn) { + case 'string': + this.#fn = `() => {return (${fn});}`; + break; + default: + this.#fn = stringifyFunction(fn); + break; + } + this.#args = args; + + this.#world.taskManager.add(this); + + if (options.timeout) { + this.#timeoutError = new TimeoutError( + `Waiting failed: ${options.timeout}ms exceeded` + ); + this.#timeout = setTimeout(() => { + void this.terminate(this.#timeoutError); + }, options.timeout); + } + + void this.rerun(); + } + + get result(): Promise<HandleFor<T>> { + return this.#result.valueOrThrow(); + } + + async rerun(): Promise<void> { + for (const prev of this.#reruns) { + prev.abort(); + } + this.#reruns.length = 0; + const controller = new AbortController(); + this.#reruns.push(controller); + try { + switch (this.#polling) { + case 'raf': + this.#poller = await this.#world.evaluateHandle( + ({RAFPoller, createFunction}, fn, ...args) => { + const fun = createFunction(fn); + return new RAFPoller(() => { + return fun(...args) as Promise<T>; + }); + }, + LazyArg.create(context => { + return context.puppeteerUtil; + }), + this.#fn, + ...this.#args + ); + break; + case 'mutation': + this.#poller = await this.#world.evaluateHandle( + ({MutationPoller, createFunction}, root, fn, ...args) => { + const fun = createFunction(fn); + return new MutationPoller(() => { + return fun(...args) as Promise<T>; + }, root || document); + }, + LazyArg.create(context => { + return context.puppeteerUtil; + }), + this.#root, + this.#fn, + ...this.#args + ); + break; + default: + this.#poller = await this.#world.evaluateHandle( + ({IntervalPoller, createFunction}, ms, fn, ...args) => { + const fun = createFunction(fn); + return new IntervalPoller(() => { + return fun(...args) as Promise<T>; + }, ms); + }, + LazyArg.create(context => { + return context.puppeteerUtil; + }), + this.#polling, + this.#fn, + ...this.#args + ); + break; + } + + await this.#poller.evaluate(poller => { + void poller.start(); + }); + + const result = await this.#poller.evaluateHandle(poller => { + return poller.result(); + }); + this.#result.resolve(result); + + await this.terminate(); + } catch (error) { + if (controller.signal.aborted) { + return; + } + const badError = this.getBadError(error); + if (badError) { + await this.terminate(badError); + } + } + } + + async terminate(error?: Error): Promise<void> { + this.#world.taskManager.delete(this); + + clearTimeout(this.#timeout); + + if (error && !this.#result.finished()) { + this.#result.reject(error); + } + + if (this.#poller) { + try { + await this.#poller.evaluateHandle(async poller => { + await poller.stop(); + }); + if (this.#poller) { + await this.#poller.dispose(); + this.#poller = undefined; + } + } catch { + // Ignore errors since they most likely come from low-level cleanup. + } + } + } + + /** + * Not all errors lead to termination. They usually imply we need to rerun the task. + */ + getBadError(error: unknown): Error | undefined { + if (isErrorLike(error)) { + // When frame is detached the task should have been terminated by the IsolatedWorld. + // This can fail if we were adding this task while the frame was detached, + // so we terminate here instead. + if ( + error.message.includes( + 'Execution context is not available in detached frame' + ) + ) { + return new Error('Waiting failed: Frame detached'); + } + + // When the page is navigated, the promise is rejected. + // We will try again in the new execution context. + if (error.message.includes('Execution context was destroyed')) { + return; + } + + // We could have tried to evaluate in a context which was already + // destroyed. + if (error.message.includes('Cannot find context with specified id')) { + return; + } + + // Errors coming from WebDriver BiDi. TODO: Adjust messages after + // https://github.com/w3c/webdriver-bidi/issues/540 is resolved. + if ( + error.message.includes( + "AbortError: Actor 'MessageHandlerFrame' destroyed" + ) + ) { + return; + } + + return error; + } + + return new Error('WaitTask failed with an error', { + cause: error, + }); + } +} + +/** + * @internal + */ +export class TaskManager { + #tasks: Set<WaitTask> = new Set<WaitTask>(); + + add(task: WaitTask<any>): void { + this.#tasks.add(task); + } + + delete(task: WaitTask<any>): void { + this.#tasks.delete(task); + } + + terminateAll(error?: Error): void { + for (const task of this.#tasks) { + void task.terminate(error); + } + this.#tasks.clear(); + } + + async rerunAll(): Promise<void> { + await Promise.all( + [...this.#tasks].map(task => { + return task.rerun(); + }) + ); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/XPathQueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/XPathQueryHandler.ts new file mode 100644 index 0000000000..b6e3a67bad --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/XPathQueryHandler.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + QueryHandler, + type QuerySelectorAll, + type QuerySelector, +} from './QueryHandler.js'; + +/** + * @internal + */ +export class XPathQueryHandler extends QueryHandler { + static override querySelectorAll: QuerySelectorAll = ( + element, + selector, + {xpathQuerySelectorAll} + ) => { + return xpathQuerySelectorAll(element, selector); + }; + + static override querySelector: QuerySelector = ( + element: Node, + selector: string, + {xpathQuerySelectorAll} + ) => { + for (const result of xpathQuerySelectorAll(element, selector, 1)) { + return result; + } + return null; + }; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/common.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/common.ts new file mode 100644 index 0000000000..6ef8925605 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/common.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './BrowserWebSocketTransport.js'; +export * from './CallbackRegistry.js'; +export * from './Configuration.js'; +export * from './ConnectionTransport.js'; +export * from './ConnectOptions.js'; +export * from './ConsoleMessage.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'; +export * from './LazyArg.js'; +export * from './NetworkManagerEvents.js'; +export * from './PDFOptions.js'; +export * from './PierceQueryHandler.js'; +export * from './PQueryHandler.js'; +export * from './Product.js'; +export * from './Puppeteer.js'; +export * from './QueryHandler.js'; +export * from './ScriptInjector.js'; +export * from './SecurityDetails.js'; +export * from './TaskQueue.js'; +export * from './TextQueryHandler.js'; +export * from './TimeoutSettings.js'; +export * from './types.js'; +export * from './USKeyboardLayout.js'; +export * from './util.js'; +export * from './Viewport.js'; +export * from './WaitTask.js'; +export * from './XPathQueryHandler.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 new file mode 100644 index 0000000000..6c7a2b451c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/fetch.ts @@ -0,0 +1,14 @@ +/** + * @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/types.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/types.ts new file mode 100644 index 0000000000..3f2cf5d4f3 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/types.ts @@ -0,0 +1,225 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ElementHandle} from '../api/ElementHandle.js'; +import type {JSHandle} from '../api/JSHandle.js'; + +import type {LazyArg} from './LazyArg.js'; + +/** + * @public + */ +export type AwaitablePredicate<T> = (value: T) => Awaitable<boolean>; + +/** + * @public + */ +export interface Moveable { + /** + * Moves the resource when 'using'. + */ + move(): this; +} + +/** + * @internal + */ +export interface Disposed { + get disposed(): boolean; +} + +/** + * @internal + */ +export interface BindingPayload { + type: string; + name: string; + seq: number; + args: unknown[]; + /** + * Determines whether the arguments of the payload are trivial. + */ + isTrivial: boolean; +} + +/** + * @internal + */ +export type AwaitableIterator<T> = Iterator<T> | AsyncIterator<T>; + +/** + * @public + */ +export type AwaitableIterable<T> = Iterable<T> | AsyncIterable<T>; + +/** + * @public + */ +export type Awaitable<T> = T | PromiseLike<T>; + +/** + * @public + */ +export type HandleFor<T> = T extends Node ? ElementHandle<T> : JSHandle<T>; + +/** + * @public + */ +export type HandleOr<T> = HandleFor<T> | JSHandle<T> | T; + +/** + * @public + */ +export type FlattenHandle<T> = T extends HandleOr<infer U> ? U : never; + +/** + * @internal + */ +export type FlattenLazyArg<T> = T extends LazyArg<infer U> ? U : T; + +/** + * @internal + */ +export type InnerLazyParams<T extends unknown[]> = { + [K in keyof T]: FlattenLazyArg<T[K]>; +}; + +/** + * @public + */ +export type InnerParams<T extends unknown[]> = { + [K in keyof T]: FlattenHandle<T[K]>; +}; + +/** + * @public + */ +export type ElementFor< + TagName extends keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap, +> = TagName extends keyof HTMLElementTagNameMap + ? HTMLElementTagNameMap[TagName] + : TagName extends keyof SVGElementTagNameMap + ? SVGElementTagNameMap[TagName] + : never; + +/** + * @public + */ +export type EvaluateFunc<T extends unknown[]> = ( + ...params: InnerParams<T> +) => Awaitable<unknown>; + +/** + * @public + */ +export type EvaluateFuncWith<V, T extends unknown[]> = ( + ...params: [V, ...InnerParams<T>] +) => Awaitable<unknown>; + +/** + * @public + */ +export type NodeFor<ComplexSelector extends string> = + TypeSelectorOfComplexSelector<ComplexSelector> extends infer TypeSelector + ? TypeSelector extends + | keyof HTMLElementTagNameMap + | keyof SVGElementTagNameMap + ? ElementFor<TypeSelector> + : Element + : never; + +type TypeSelectorOfComplexSelector<ComplexSelector extends string> = + CompoundSelectorsOfComplexSelector<ComplexSelector> extends infer CompoundSelectors + ? CompoundSelectors extends NonEmptyReadonlyArray<string> + ? Last<CompoundSelectors> extends infer LastCompoundSelector + ? LastCompoundSelector extends string + ? TypeSelectorOfCompoundSelector<LastCompoundSelector> + : never + : never + : unknown + : never; + +type TypeSelectorOfCompoundSelector<CompoundSelector extends string> = + SplitWithDelemiters< + CompoundSelector, + BeginSubclassSelectorTokens + > extends infer CompoundSelectorTokens + ? CompoundSelectorTokens extends [infer TypeSelector, ...any[]] + ? TypeSelector extends '' + ? unknown + : TypeSelector + : never + : never; + +type Last<Arr extends NonEmptyReadonlyArray<unknown>> = Arr extends [ + infer Head, + ...infer Tail, +] + ? Tail extends NonEmptyReadonlyArray<unknown> + ? Last<Tail> + : Head + : never; + +type NonEmptyReadonlyArray<T> = [T, ...(readonly T[])]; + +type CompoundSelectorsOfComplexSelector<ComplexSelector extends string> = + SplitWithDelemiters< + ComplexSelector, + CombinatorTokens + > extends infer IntermediateTokens + ? IntermediateTokens extends readonly string[] + ? Drop<IntermediateTokens, ''> + : never + : never; + +type SplitWithDelemiters< + Input extends string, + Delemiters extends readonly string[], +> = Delemiters extends [infer FirstDelemiter, ...infer RestDelemiters] + ? FirstDelemiter extends string + ? RestDelemiters extends readonly string[] + ? FlatmapSplitWithDelemiters<Split<Input, FirstDelemiter>, RestDelemiters> + : never + : never + : [Input]; + +type BeginSubclassSelectorTokens = ['.', '#', '[', ':']; + +type CombinatorTokens = [' ', '>', '+', '~', '|', '|']; + +type Drop< + Arr extends readonly unknown[], + Remove, + Acc extends unknown[] = [], +> = Arr extends [infer Head, ...infer Tail] + ? Head extends Remove + ? Drop<Tail, Remove> + : Drop<Tail, Remove, [...Acc, Head]> + : Acc; + +type FlatmapSplitWithDelemiters< + Inputs extends readonly string[], + Delemiters extends readonly string[], + Acc extends string[] = [], +> = Inputs extends [infer FirstInput, ...infer RestInputs] + ? FirstInput extends string + ? RestInputs extends readonly string[] + ? FlatmapSplitWithDelemiters< + RestInputs, + Delemiters, + [...Acc, ...SplitWithDelemiters<FirstInput, Delemiters>] + > + : Acc + : Acc + : Acc; + +type Split< + Input extends string, + Delimiter extends string, + Acc extends string[] = [], +> = Input extends `${infer Prefix}${Delimiter}${infer Suffix}` + ? Split<Suffix, Delimiter, [...Acc, Prefix]> + : [...Acc, Input]; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/util.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/util.ts new file mode 100644 index 0000000000..2c8f76f664 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/util.ts @@ -0,0 +1,447 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type FS from 'fs/promises'; +import type {Readable} from 'stream'; + +import {map, 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'; +import type {EventEmitter, EventType} from './EventEmitter.js'; +import type { + LowerCasePaperFormat, + ParsedPDFOptions, + PDFOptions, +} from './PDFOptions.js'; +import {paperFormats} from './PDFOptions.js'; + +/** + * @internal + */ +export const debugError = debug('puppeteer:error'); + +/** + * @internal + */ +export const DEFAULT_VIEWPORT = Object.freeze({width: 800, height: 600}); + +/** + * @internal + */ +const SOURCE_URL = Symbol('Source URL for Puppeteer evaluation scripts'); + +/** + * @internal + */ +export class PuppeteerURL { + static INTERNAL_URL = 'pptr:internal'; + + static fromCallSite( + functionName: string, + site: NodeJS.CallSite + ): PuppeteerURL { + const url = new PuppeteerURL(); + url.#functionName = functionName; + url.#siteString = site.toString(); + return url; + } + + static parse = (url: string): PuppeteerURL => { + url = url.slice('pptr:'.length); + const [functionName = '', siteString = ''] = url.split(';'); + const puppeteerUrl = new PuppeteerURL(); + puppeteerUrl.#functionName = functionName; + puppeteerUrl.#siteString = decodeURIComponent(siteString); + return puppeteerUrl; + }; + + static isPuppeteerURL = (url: string): boolean => { + return url.startsWith('pptr:'); + }; + + #functionName!: string; + #siteString!: string; + + get functionName(): string { + return this.#functionName; + } + + get siteString(): string { + return this.#siteString; + } + + toString(): string { + return `pptr:${[ + this.#functionName, + encodeURIComponent(this.#siteString), + ].join(';')}`; + } +} + +/** + * @internal + */ +export const withSourcePuppeteerURLIfNone = <T extends NonNullable<unknown>>( + functionName: string, + object: T +): T => { + if (Object.prototype.hasOwnProperty.call(object, SOURCE_URL)) { + return object; + } + const original = Error.prepareStackTrace; + Error.prepareStackTrace = (_, stack) => { + // First element is the function. + // Second element is the caller of this function. + // Third element is the caller of the caller of this function + // which is precisely what we want. + return stack[2]; + }; + const site = new Error().stack as unknown as NodeJS.CallSite; + Error.prepareStackTrace = original; + return Object.assign(object, { + [SOURCE_URL]: PuppeteerURL.fromCallSite(functionName, site), + }); +}; + +/** + * @internal + */ +export const getSourcePuppeteerURLIfAvailable = < + T extends NonNullable<unknown>, +>( + object: T +): PuppeteerURL | undefined => { + if (Object.prototype.hasOwnProperty.call(object, SOURCE_URL)) { + return object[SOURCE_URL as keyof T] as PuppeteerURL; + } + return undefined; +}; + +/** + * @internal + */ +export const isString = (obj: unknown): obj is string => { + return typeof obj === 'string' || obj instanceof String; +}; + +/** + * @internal + */ +export const isNumber = (obj: unknown): obj is number => { + return typeof obj === 'number' || obj instanceof Number; +}; + +/** + * @internal + */ +export const isPlainObject = (obj: unknown): obj is Record<any, unknown> => { + return typeof obj === 'object' && obj?.constructor === Object; +}; + +/** + * @internal + */ +export const isRegExp = (obj: unknown): obj is RegExp => { + return typeof obj === 'object' && obj?.constructor === RegExp; +}; + +/** + * @internal + */ +export const isDate = (obj: unknown): obj is Date => { + return typeof obj === 'object' && obj?.constructor === Date; +}; + +/** + * @internal + */ +export function evaluationString( + fun: Function | string, + ...args: unknown[] +): string { + if (isString(fun)) { + assert(args.length === 0, 'Cannot evaluate a string with arguments'); + return fun; + } + + function serializeArgument(arg: unknown): string { + if (Object.is(arg, undefined)) { + return 'undefined'; + } + return JSON.stringify(arg); + } + + return `(${fun})(${args.map(serializeArgument).join(',')})`; +} + +/** + * @internal + */ +let fs: typeof FS | null = null; +/** + * @internal + */ +export async function importFSPromises(): Promise<typeof FS> { + if (!fs) { + try { + fs = await import('fs/promises'); + } catch (error) { + if (error instanceof TypeError) { + throw new Error( + 'Cannot write to a path outside of a Node-like environment.' + ); + } + throw error; + } + } + return fs; +} + +/** + * @internal + */ +export async function getReadableAsBuffer( + readable: Readable, + path?: string +): Promise<Buffer | null> { + const buffers = []; + 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); + } + } finally { + await fileHandle.close(); + } + } else { + for await (const chunk of readable) { + buffers.push(chunk); + } + } + try { + return Buffer.concat(buffers); + } catch (error) { + return null; + } +} + +/** + * @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.'); + } + + const {Readable} = await import('stream'); + + let eof = false; + return new Readable({ + async read(size: number) { + 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; + } + }, + }); +} + +/** + * @internal + */ +export function validateDialogType( + type: string +): 'alert' | 'confirm' | 'prompt' | 'beforeunload' { + let dialogType = null; + const validDialogTypes = new Set([ + 'alert', + 'confirm', + 'prompt', + 'beforeunload', + ]); + + if (validDialogTypes.has(type)) { + dialogType = type; + } + assert(dialogType, `Unknown javascript dialog type: ${type}`); + return dialogType as 'alert' | 'confirm' | 'prompt' | 'beforeunload'; +} + +/** + * @internal + */ +export function timeout(ms: number): Observable<never> { + return ms === 0 + ? NEVER + : timer(ms).pipe( + map(() => { + throw new TimeoutError(`Timed out after waiting ${ms}ms`); + }) + ); +} + +/** + * @internal + */ +export const UTILITY_WORLD_NAME = '__puppeteer_utility_world__'; + +/** + * @internal + */ +export const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m; +/** + * @internal + */ +export function getSourceUrlComment(url: string): string { + return `//# sourceURL=${url}`; +} + +/** + * @internal + */ +export const NETWORK_IDLE_TIME = 500; + +/** + * @internal + */ +export function parsePDFOptions( + options: PDFOptions = {}, + lengthUnit: 'in' | 'cm' = 'in' +): ParsedPDFOptions { + const defaults: Omit<ParsedPDFOptions, 'width' | 'height' | 'margin'> = { + scale: 1, + displayHeaderFooter: false, + headerTemplate: '', + footerTemplate: '', + printBackground: false, + landscape: false, + pageRanges: '', + preferCSSPageSize: false, + omitBackground: false, + tagged: false, + }; + + let width = 8.5; + let height = 11; + if (options.format) { + const format = + paperFormats[options.format.toLowerCase() as LowerCasePaperFormat]; + assert(format, 'Unknown paper format: ' + options.format); + width = format.width; + height = format.height; + } else { + width = convertPrintParameterToInches(options.width, lengthUnit) ?? width; + height = + convertPrintParameterToInches(options.height, lengthUnit) ?? height; + } + + const margin = { + top: convertPrintParameterToInches(options.margin?.top, lengthUnit) || 0, + left: convertPrintParameterToInches(options.margin?.left, lengthUnit) || 0, + bottom: + convertPrintParameterToInches(options.margin?.bottom, lengthUnit) || 0, + right: + convertPrintParameterToInches(options.margin?.right, lengthUnit) || 0, + }; + + return { + ...defaults, + ...options, + width, + height, + margin, + }; +} + +/** + * @internal + */ +export const unitToPixels = { + px: 1, + in: 96, + cm: 37.8, + mm: 3.78, +}; + +function convertPrintParameterToInches( + parameter?: string | number, + lengthUnit: 'in' | 'cm' = 'in' +): number | undefined { + if (typeof parameter === 'undefined') { + return undefined; + } + let pixels; + if (isNumber(parameter)) { + // Treat numbers as pixel values to be aligned with phantom's paperSize. + pixels = parameter; + } else if (isString(parameter)) { + const text = parameter; + let unit = text.substring(text.length - 2).toLowerCase(); + let valueText = ''; + if (unit in unitToPixels) { + valueText = text.substring(0, text.length - 2); + } else { + // In case of unknown unit try to parse the whole parameter as number of pixels. + // This is consistent with phantom's paperSize behavior. + unit = 'px'; + valueText = text; + } + const value = Number(valueText); + assert(!isNaN(value), 'Failed to parse parameter value: ' + text); + pixels = value * unitToPixels[unit as keyof typeof unitToPixels]; + } else { + throw new Error( + 'page.pdf() Cannot handle parameter type: ' + typeof parameter + ); + } + return pixels / unitToPixels[lengthUnit]; +} + +/** + * @internal + */ +export function fromEmitterEvent< + Events extends Record<EventType, unknown>, + Event extends keyof Events, +>(emitter: EventEmitter<Events>, eventName: Event): Observable<Events[Event]> { + return new Observable(subscriber => { + const listener = (event: Events[Event]) => { + subscriber.next(event); + }; + emitter.on(eventName, listener); + return () => { + emitter.off(eventName, listener); + }; + }); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/environment.ts b/remote/test/puppeteer/packages/puppeteer-core/src/environment.ts new file mode 100644 index 0000000000..bf7227243d --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/environment.ts @@ -0,0 +1,10 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @internal + */ +export const isNode = !!(typeof process !== 'undefined' && process.version); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/ARIAQuerySelector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/ARIAQuerySelector.ts new file mode 100644 index 0000000000..972b6a6c64 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/ARIAQuerySelector.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +declare global { + interface Window { + /** + * @internal + */ + __ariaQuerySelector(root: Node, selector: string): Promise<Node | null>; + /** + * @internal + */ + __ariaQuerySelectorAll(root: Node, selector: string): Promise<Node[]>; + } +} + +export const ariaQuerySelector = ( + root: Node, + selector: string +): Promise<Node | null> => { + return window.__ariaQuerySelector(root, selector); +}; +export const ariaQuerySelectorAll = async function* ( + root: Node, + selector: string +): AsyncIterable<Node> { + yield* await window.__ariaQuerySelectorAll(root, selector); +}; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/CustomQuerySelector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/CustomQuerySelector.ts new file mode 100644 index 0000000000..ccd041deea --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/CustomQuerySelector.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {CustomQueryHandler} from '../common/CustomQueryHandler.js'; +import type {Awaitable, AwaitableIterable} from '../common/types.js'; + +export interface CustomQuerySelector { + querySelector(root: Node, selector: string): Awaitable<Node | null>; + querySelectorAll(root: Node, selector: string): AwaitableIterable<Node>; +} + +/** + * This class mimics the injected {@link CustomQuerySelectorRegistry}. + */ +class CustomQuerySelectorRegistry { + #selectors = new Map<string, CustomQuerySelector>(); + + register(name: string, handler: CustomQueryHandler): void { + if (!handler.queryOne && handler.queryAll) { + const querySelectorAll = handler.queryAll; + handler.queryOne = (node, selector) => { + for (const result of querySelectorAll(node, selector)) { + return result; + } + return null; + }; + } else if (handler.queryOne && !handler.queryAll) { + const querySelector = handler.queryOne; + handler.queryAll = (node, selector) => { + const result = querySelector(node, selector); + return result ? [result] : []; + }; + } else if (!handler.queryOne || !handler.queryAll) { + throw new Error('At least one query method must be defined.'); + } + + this.#selectors.set(name, { + querySelector: handler.queryOne, + querySelectorAll: handler.queryAll!, + }); + } + + unregister(name: string): void { + this.#selectors.delete(name); + } + + get(name: string): CustomQuerySelector | undefined { + return this.#selectors.get(name); + } + + clear() { + this.#selectors.clear(); + } +} + +export const customQuerySelectors = new CustomQuerySelectorRegistry(); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/PQuerySelector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PQuerySelector.ts new file mode 100644 index 0000000000..11499c072f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PQuerySelector.ts @@ -0,0 +1,298 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {AwaitableIterable} from '../common/types.js'; +import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js'; + +import {ariaQuerySelectorAll} from './ARIAQuerySelector.js'; +import {customQuerySelectors} from './CustomQuerySelector.js'; +import { + type ComplexPSelector, + type ComplexPSelectorList, + type CompoundPSelector, + type CSSSelector, + parsePSelectors, + PCombinator, + type PPseudoSelector, +} from './PSelectorParser.js'; +import {textQuerySelectorAll} from './TextQuerySelector.js'; +import {pierce, pierceAll} from './util.js'; +import {xpathQuerySelectorAll} from './XPathQuerySelector.js'; + +const IDENT_TOKEN_START = /[-\w\P{ASCII}*]/; + +interface QueryableNode extends Node { + querySelectorAll: typeof Document.prototype.querySelectorAll; +} + +const isQueryableNode = (node: Node): node is QueryableNode => { + return 'querySelectorAll' in node; +}; + +class SelectorError extends Error { + constructor(selector: string, message: string) { + super(`${selector} is not a valid selector: ${message}`); + } +} + +class PQueryEngine { + #input: string; + + #complexSelector: ComplexPSelector; + #compoundSelector: CompoundPSelector = []; + #selector: CSSSelector | PPseudoSelector | undefined = undefined; + + elements: AwaitableIterable<Node>; + + constructor(element: Node, input: string, complexSelector: ComplexPSelector) { + this.elements = [element]; + this.#input = input; + this.#complexSelector = complexSelector; + this.#next(); + } + + async run(): Promise<void> { + if (typeof this.#selector === 'string') { + switch (this.#selector.trimStart()) { + case ':scope': + // `:scope` has some special behavior depending on the node. It always + // represents the current node within a compound selector, but by + // itself, it depends on the node. For example, Document is + // represented by `<html>`, but any HTMLElement is not represented by + // itself (i.e. `null`). This can be troublesome if our combinators + // are used right after so we treat this selector specially. + this.#next(); + break; + } + } + + for (; this.#selector !== undefined; this.#next()) { + const selector = this.#selector; + const input = this.#input; + if (typeof selector === 'string') { + // The regular expression tests if the selector is a type/universal + // selector. Any other case means we want to apply the selector onto + // the element itself (e.g. `element.class`, `element>div`, + // `element:hover`, etc.). + if (selector[0] && IDENT_TOKEN_START.test(selector[0])) { + this.elements = AsyncIterableUtil.flatMap( + this.elements, + async function* (element) { + if (isQueryableNode(element)) { + yield* element.querySelectorAll(selector); + } + } + ); + } else { + this.elements = AsyncIterableUtil.flatMap( + this.elements, + async function* (element) { + if (!element.parentElement) { + if (!isQueryableNode(element)) { + return; + } + yield* element.querySelectorAll(selector); + return; + } + + let index = 0; + for (const child of element.parentElement.children) { + ++index; + if (child === element) { + break; + } + } + yield* element.parentElement.querySelectorAll( + `:scope>:nth-child(${index})${selector}` + ); + } + ); + } + } else { + this.elements = AsyncIterableUtil.flatMap( + this.elements, + async function* (element) { + switch (selector.name) { + case 'text': + yield* textQuerySelectorAll(element, selector.value); + break; + case 'xpath': + yield* xpathQuerySelectorAll(element, selector.value); + break; + case 'aria': + yield* ariaQuerySelectorAll(element, selector.value); + break; + default: + const querySelector = customQuerySelectors.get(selector.name); + if (!querySelector) { + throw new SelectorError( + input, + `Unknown selector type: ${selector.name}` + ); + } + yield* querySelector.querySelectorAll(element, selector.value); + } + } + ); + } + } + } + + #next() { + if (this.#compoundSelector.length !== 0) { + this.#selector = this.#compoundSelector.shift(); + return; + } + if (this.#complexSelector.length === 0) { + this.#selector = undefined; + return; + } + const selector = this.#complexSelector.shift(); + switch (selector) { + case PCombinator.Child: { + this.elements = AsyncIterableUtil.flatMap(this.elements, pierce); + this.#next(); + break; + } + case PCombinator.Descendent: { + this.elements = AsyncIterableUtil.flatMap(this.elements, pierceAll); + this.#next(); + break; + } + default: + this.#compoundSelector = selector as CompoundPSelector; + this.#next(); + break; + } + } +} + +class DepthCalculator { + #cache = new WeakMap<Node, number[]>(); + + calculate(node: Node | null, depth: number[] = []): number[] { + if (node === null) { + return depth; + } + if (node instanceof ShadowRoot) { + node = node.host; + } + + const cachedDepth = this.#cache.get(node); + if (cachedDepth) { + return [...cachedDepth, ...depth]; + } + + let index = 0; + for ( + let prevSibling = node.previousSibling; + prevSibling; + prevSibling = prevSibling.previousSibling + ) { + ++index; + } + + const value = this.calculate(node.parentNode, [index]); + this.#cache.set(node, value); + return [...value, ...depth]; + } +} + +const compareDepths = (a: number[], b: number[]): -1 | 0 | 1 => { + if (a.length + b.length === 0) { + return 0; + } + const [i = -1, ...otherA] = a; + const [j = -1, ...otherB] = b; + if (i === j) { + return compareDepths(otherA, otherB); + } + return i < j ? -1 : 1; +}; + +const domSort = async function* (elements: AwaitableIterable<Node>) { + const results = new Set<Node>(); + for await (const element of elements) { + results.add(element); + } + const calculator = new DepthCalculator(); + yield* [...results.values()] + .map(result => { + return [result, calculator.calculate(result)] as const; + }) + .sort(([, a], [, b]) => { + return compareDepths(a, b); + }) + .map(([result]) => { + return result; + }); +}; + +/** + * Queries the given node for all nodes matching the given text selector. + * + * @internal + */ +export const pQuerySelectorAll = function ( + root: Node, + selector: string +): AwaitableIterable<Node> { + let selectors: ComplexPSelectorList; + let isPureCSS: boolean; + try { + [selectors, isPureCSS] = parsePSelectors(selector); + } catch (error) { + return (root as unknown as QueryableNode).querySelectorAll(selector); + } + + if (isPureCSS) { + return (root as unknown as QueryableNode).querySelectorAll(selector); + } + // If there are any empty elements, then this implies the selector has + // contiguous combinators (e.g. `>>> >>>>`) or starts/ends with one which we + // treat as illegal, similar to existing behavior. + if ( + selectors.some(parts => { + let i = 0; + return parts.some(parts => { + if (typeof parts === 'string') { + ++i; + } else { + i = 0; + } + return i > 1; + }); + }) + ) { + throw new SelectorError( + selector, + 'Multiple deep combinators found in sequence.' + ); + } + + return domSort( + AsyncIterableUtil.flatMap(selectors, selectorParts => { + const query = new PQueryEngine(root, selector, selectorParts); + void query.run(); + return query.elements; + }) + ); +}; + +/** + * Queries the given node for all nodes matching the given text selector. + * + * @internal + */ +export const pQuerySelector = async function ( + root: Node, + selector: string +): Promise<Node | null> { + for await (const element of pQuerySelectorAll(root, selector)) { + return element; + } + return null; +}; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/PSelectorParser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PSelectorParser.ts new file mode 100644 index 0000000000..8044562348 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PSelectorParser.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {type Token, tokenize, TOKENS, stringify} from 'parsel-js'; + +export type CSSSelector = string; +export interface PPseudoSelector { + name: string; + value: string; +} +export const enum PCombinator { + Descendent = '>>>', + Child = '>>>>', +} +export type CompoundPSelector = Array<CSSSelector | PPseudoSelector>; +export type ComplexPSelector = Array<CompoundPSelector | PCombinator>; +export type ComplexPSelectorList = ComplexPSelector[]; + +TOKENS['combinator'] = /\s*(>>>>?|[\s>+~])\s*/g; + +const ESCAPE_REGEXP = /\\[\s\S]/g; +const unquote = (text: string): string => { + if (text.length <= 1) { + return text; + } + if ((text[0] === '"' || text[0] === "'") && text.endsWith(text[0])) { + text = text.slice(1, -1); + } + return text.replace(ESCAPE_REGEXP, match => { + return match[1] as string; + }); +}; + +export function parsePSelectors( + selector: string +): [selector: ComplexPSelectorList, isPureCSS: boolean] { + let isPureCSS = true; + const tokens = tokenize(selector); + if (tokens.length === 0) { + return [[], isPureCSS]; + } + let compoundSelector: CompoundPSelector = []; + let complexSelector: ComplexPSelector = [compoundSelector]; + const selectors: ComplexPSelectorList = [complexSelector]; + const storage: Token[] = []; + for (const token of tokens) { + switch (token.type) { + case 'combinator': + switch (token.content) { + case PCombinator.Descendent: + isPureCSS = false; + if (storage.length) { + compoundSelector.push(stringify(storage)); + storage.splice(0); + } + compoundSelector = []; + complexSelector.push(PCombinator.Descendent); + complexSelector.push(compoundSelector); + continue; + case PCombinator.Child: + isPureCSS = false; + if (storage.length) { + compoundSelector.push(stringify(storage)); + storage.splice(0); + } + compoundSelector = []; + complexSelector.push(PCombinator.Child); + complexSelector.push(compoundSelector); + continue; + } + break; + case 'pseudo-element': + if (!token.name.startsWith('-p-')) { + break; + } + isPureCSS = false; + if (storage.length) { + compoundSelector.push(stringify(storage)); + storage.splice(0); + } + compoundSelector.push({ + name: token.name.slice(3), + value: unquote(token.argument ?? ''), + }); + continue; + case 'comma': + if (storage.length) { + compoundSelector.push(stringify(storage)); + storage.splice(0); + } + compoundSelector = []; + complexSelector = [compoundSelector]; + selectors.push(complexSelector); + continue; + } + storage.push(token); + } + if (storage.length) { + compoundSelector.push(stringify(storage)); + } + return [selectors, isPureCSS]; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/PierceQuerySelector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PierceQuerySelector.ts new file mode 100644 index 0000000000..c224ee8324 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PierceQuerySelector.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @internal + */ +export const pierceQuerySelector = ( + root: Node, + selector: string +): Element | null => { + let found: Node | null = null; + const search = (root: Node) => { + const iter = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + do { + const currentNode = iter.currentNode as Element; + if (currentNode.shadowRoot) { + search(currentNode.shadowRoot); + } + if (currentNode instanceof ShadowRoot) { + continue; + } + if (currentNode !== root && !found && currentNode.matches(selector)) { + found = currentNode; + } + } while (!found && iter.nextNode()); + }; + if (root instanceof Document) { + root = root.documentElement; + } + search(root); + return found; +}; + +/** + * @internal + */ +export const pierceQuerySelectorAll = ( + element: Node, + selector: string +): Element[] => { + const result: Element[] = []; + const collect = (root: Node) => { + const iter = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + do { + const currentNode = iter.currentNode as Element; + if (currentNode.shadowRoot) { + collect(currentNode.shadowRoot); + } + if (currentNode instanceof ShadowRoot) { + continue; + } + if (currentNode !== root && currentNode.matches(selector)) { + result.push(currentNode); + } + } while (iter.nextNode()); + }; + if (element instanceof Document) { + element = element.documentElement; + } + collect(element); + return result; +}; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/Poller.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/Poller.ts new file mode 100644 index 0000000000..68b9f1812b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/Poller.ts @@ -0,0 +1,168 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {assert} from '../util/assert.js'; +import {Deferred} from '../util/Deferred.js'; + +/** + * @internal + */ +export interface Poller<T> { + start(): Promise<void>; + stop(): Promise<void>; + result(): Promise<T>; +} + +/** + * @internal + */ +export class MutationPoller<T> implements Poller<T> { + #fn: () => Promise<T>; + + #root: Node; + + #observer?: MutationObserver; + #deferred?: Deferred<T>; + constructor(fn: () => Promise<T>, root: Node) { + this.#fn = fn; + this.#root = root; + } + + async start(): Promise<void> { + const deferred = (this.#deferred = Deferred.create<T>()); + const result = await this.#fn(); + if (result) { + deferred.resolve(result); + return; + } + + this.#observer = new MutationObserver(async () => { + const result = await this.#fn(); + if (!result) { + return; + } + deferred.resolve(result); + await this.stop(); + }); + this.#observer.observe(this.#root, { + childList: true, + subtree: true, + attributes: true, + }); + } + + async stop(): Promise<void> { + assert(this.#deferred, 'Polling never started.'); + if (!this.#deferred.finished()) { + this.#deferred.reject(new Error('Polling stopped')); + } + if (this.#observer) { + this.#observer.disconnect(); + this.#observer = undefined; + } + } + + result(): Promise<T> { + assert(this.#deferred, 'Polling never started.'); + return this.#deferred.valueOrThrow(); + } +} + +/** + * @internal + */ +export class RAFPoller<T> implements Poller<T> { + #fn: () => Promise<T>; + #deferred?: Deferred<T>; + constructor(fn: () => Promise<T>) { + this.#fn = fn; + } + + async start(): Promise<void> { + const deferred = (this.#deferred = Deferred.create<T>()); + const result = await this.#fn(); + if (result) { + deferred.resolve(result); + return; + } + + const poll = async () => { + if (deferred.finished()) { + return; + } + const result = await this.#fn(); + if (!result) { + window.requestAnimationFrame(poll); + return; + } + deferred.resolve(result); + await this.stop(); + }; + window.requestAnimationFrame(poll); + } + + async stop(): Promise<void> { + assert(this.#deferred, 'Polling never started.'); + if (!this.#deferred.finished()) { + this.#deferred.reject(new Error('Polling stopped')); + } + } + + result(): Promise<T> { + assert(this.#deferred, 'Polling never started.'); + return this.#deferred.valueOrThrow(); + } +} + +/** + * @internal + */ + +export class IntervalPoller<T> implements Poller<T> { + #fn: () => Promise<T>; + #ms: number; + + #interval?: NodeJS.Timeout; + #deferred?: Deferred<T>; + constructor(fn: () => Promise<T>, ms: number) { + this.#fn = fn; + this.#ms = ms; + } + + async start(): Promise<void> { + const deferred = (this.#deferred = Deferred.create<T>()); + const result = await this.#fn(); + if (result) { + deferred.resolve(result); + return; + } + + this.#interval = setInterval(async () => { + const result = await this.#fn(); + if (!result) { + return; + } + deferred.resolve(result); + await this.stop(); + }, this.#ms); + } + + async stop(): Promise<void> { + assert(this.#deferred, 'Polling never started.'); + if (!this.#deferred.finished()) { + this.#deferred.reject(new Error('Polling stopped')); + } + if (this.#interval) { + clearInterval(this.#interval); + this.#interval = undefined; + } + } + + result(): Promise<T> { + assert(this.#deferred, 'Polling never started.'); + return this.#deferred.valueOrThrow(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextContent.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextContent.ts new file mode 100644 index 0000000000..ffe8980d5e --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextContent.ts @@ -0,0 +1,146 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +interface NonTrivialValueNode extends Node { + value: string; +} + +const TRIVIAL_VALUE_INPUT_TYPES = new Set(['checkbox', 'image', 'radio']); + +/** + * Determines if the node has a non-trivial value property. + * + * @internal + */ +const isNonTrivialValueNode = (node: Node): node is NonTrivialValueNode => { + if (node instanceof HTMLSelectElement) { + return true; + } + if (node instanceof HTMLTextAreaElement) { + return true; + } + if ( + node instanceof HTMLInputElement && + !TRIVIAL_VALUE_INPUT_TYPES.has(node.type) + ) { + return true; + } + return false; +}; + +const UNSUITABLE_NODE_NAMES = new Set(['SCRIPT', 'STYLE']); + +/** + * Determines whether a given node is suitable for text matching. + * + * @internal + */ +export const isSuitableNodeForTextMatching = (node: Node): boolean => { + return ( + !UNSUITABLE_NODE_NAMES.has(node.nodeName) && !document.head?.contains(node) + ); +}; + +/** + * @internal + */ +export interface TextContent { + // Contains the full text of the node. + full: string; + // Contains the text immediately beneath the node. + immediate: string[]; +} + +/** + * Maps {@link Node}s to their computed {@link TextContent}. + */ +const textContentCache = new WeakMap<Node, TextContent>(); +const eraseFromCache = (node: Node | null) => { + while (node) { + textContentCache.delete(node); + if (node instanceof ShadowRoot) { + node = node.host; + } else { + node = node.parentNode; + } + } +}; + +/** + * Erases the cache when the tree has mutated text. + */ +const observedNodes = new WeakSet<Node>(); +const textChangeObserver = new MutationObserver(mutations => { + for (const mutation of mutations) { + eraseFromCache(mutation.target); + } +}); + +/** + * Builds the text content of a node using some custom logic. + * + * @remarks + * The primary reason this function exists is due to {@link ShadowRoot}s not having + * text content. + * + * @internal + */ +export const createTextContent = (root: Node): TextContent => { + let value = textContentCache.get(root); + if (value) { + return value; + } + value = {full: '', immediate: []}; + if (!isSuitableNodeForTextMatching(root)) { + return value; + } + + let currentImmediate = ''; + if (isNonTrivialValueNode(root)) { + value.full = root.value; + value.immediate.push(root.value); + + root.addEventListener( + 'input', + event => { + eraseFromCache(event.target as HTMLInputElement); + }, + {once: true, capture: true} + ); + } else { + for (let child = root.firstChild; child; child = child.nextSibling) { + if (child.nodeType === Node.TEXT_NODE) { + value.full += child.nodeValue ?? ''; + currentImmediate += child.nodeValue ?? ''; + continue; + } + if (currentImmediate) { + value.immediate.push(currentImmediate); + } + currentImmediate = ''; + if (child.nodeType === Node.ELEMENT_NODE) { + value.full += createTextContent(child).full; + } + } + if (currentImmediate) { + value.immediate.push(currentImmediate); + } + if (root instanceof Element && root.shadowRoot) { + value.full += createTextContent(root.shadowRoot).full; + } + + if (!observedNodes.has(root)) { + textChangeObserver.observe(root, { + childList: true, + characterData: true, + subtree: true, + }); + observedNodes.add(root); + } + } + textContentCache.set(root, value); + return value; +}; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextQuerySelector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextQuerySelector.ts new file mode 100644 index 0000000000..debc423ccf --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextQuerySelector.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + createTextContent, + isSuitableNodeForTextMatching, +} from './TextContent.js'; + +/** + * Queries the given node for all nodes matching the given text selector. + * + * @internal + */ +export const textQuerySelectorAll = function* ( + root: Node, + selector: string +): Generator<Element> { + let yielded = false; + for (const node of root.childNodes) { + if (node instanceof Element && isSuitableNodeForTextMatching(node)) { + let matches: Generator<Element, boolean>; + if (!node.shadowRoot) { + matches = textQuerySelectorAll(node, selector); + } else { + matches = textQuerySelectorAll(node.shadowRoot, selector); + } + for (const match of matches) { + yield match; + yielded = true; + } + } + } + if (yielded) { + return; + } + + if (root instanceof Element && isSuitableNodeForTextMatching(root)) { + const textContent = createTextContent(root); + if (textContent.full.includes(selector)) { + yield root; + } + } +}; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/XPathQuerySelector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/XPathQuerySelector.ts new file mode 100644 index 0000000000..039bfa5e54 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/XPathQuerySelector.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @internal + */ +export const xpathQuerySelectorAll = function* ( + root: Node, + selector: string, + maxResults = -1 +): Iterable<Node> { + const doc = root.ownerDocument || document; + const iterator = doc.evaluate( + selector, + root, + null, + XPathResult.ORDERED_NODE_ITERATOR_TYPE + ); + const items = []; + let item; + + // Read all results upfront to avoid + // https://stackoverflow.com/questions/48235278/xpath-error-the-document-has-mutated-since-the-result-was-returned. + while ((item = iterator.iterateNext())) { + items.push(item); + if (maxResults && items.length === maxResults) { + break; + } + } + + for (let i = 0; i < items.length; i++) { + item = items[i]; + yield item as Node; + delete items[i]; + } +}; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/injected.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/injected.ts new file mode 100644 index 0000000000..e81d274290 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/injected.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Deferred} from '../util/Deferred.js'; +import {createFunction} from '../util/Function.js'; + +import * as ARIAQuerySelector from './ARIAQuerySelector.js'; +import * as CustomQuerySelectors from './CustomQuerySelector.js'; +import * as PierceQuerySelector from './PierceQuerySelector.js'; +import {IntervalPoller, MutationPoller, RAFPoller} from './Poller.js'; +import * as PQuerySelector from './PQuerySelector.js'; +import { + createTextContent, + isSuitableNodeForTextMatching, +} from './TextContent.js'; +import * as TextQuerySelector from './TextQuerySelector.js'; +import * as util from './util.js'; +import * as XPathQuerySelector from './XPathQuerySelector.js'; + +/** + * @internal + */ +const PuppeteerUtil = Object.freeze({ + ...ARIAQuerySelector, + ...CustomQuerySelectors, + ...PierceQuerySelector, + ...PQuerySelector, + ...TextQuerySelector, + ...util, + ...XPathQuerySelector, + Deferred, + createFunction, + createTextContent, + IntervalPoller, + isSuitableNodeForTextMatching, + MutationPoller, + RAFPoller, +}); + +/** + * @internal + */ +type PuppeteerUtil = typeof PuppeteerUtil; + +/** + * @internal + */ +export default PuppeteerUtil; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/util.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/util.ts new file mode 100644 index 0000000000..34fe8f7748 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/util.ts @@ -0,0 +1,67 @@ +const HIDDEN_VISIBILITY_VALUES = ['hidden', 'collapse']; + +/** + * @internal + */ +export const checkVisibility = ( + node: Node | null, + visible?: boolean +): Node | boolean => { + if (!node) { + return visible === false; + } + if (visible === undefined) { + return node; + } + const element = ( + node.nodeType === Node.TEXT_NODE ? node.parentElement : node + ) as Element; + + const style = window.getComputedStyle(element); + const isVisible = + style && + !HIDDEN_VISIBILITY_VALUES.includes(style.visibility) && + !isBoundingBoxEmpty(element); + return visible === isVisible ? node : false; +}; + +function isBoundingBoxEmpty(element: Element): boolean { + const rect = element.getBoundingClientRect(); + return rect.width === 0 || rect.height === 0; +} + +const hasShadowRoot = (node: Node): node is Node & {shadowRoot: ShadowRoot} => { + return 'shadowRoot' in node && node.shadowRoot instanceof ShadowRoot; +}; + +/** + * @internal + */ +export function* pierce(root: Node): IterableIterator<Node | ShadowRoot> { + if (hasShadowRoot(root)) { + yield root.shadowRoot; + } else { + yield root; + } +} + +/** + * @internal + */ +export function* pierceAll(root: Node): IterableIterator<Node | ShadowRoot> { + root = pierce(root).next().value; + yield root; + const walkers = [document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)]; + for (const walker of walkers) { + let node: Element | null; + while ((node = walker.nextNode() as Element | null)) { + if (!node.shadowRoot) { + continue; + } + yield node.shadowRoot; + walkers.push( + document.createTreeWalker(node.shadowRoot, NodeFilter.SHOW_ELEMENT) + ); + } + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.test.ts new file mode 100644 index 0000000000..9abd3697f7 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.test.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import {describe, it} from 'node:test'; + +import expect from 'expect'; + +import {getFeatures, removeMatchingFlags} from './ChromeLauncher.js'; + +describe('getFeatures', () => { + it('returns an empty array when no options are provided', () => { + const result = getFeatures('--foo'); + expect(result).toEqual([]); + }); + + it('returns an empty array when no options match the flag', () => { + const result = getFeatures('--foo', ['--bar', '--baz']); + expect(result).toEqual([]); + }); + + it('returns an array of values when options match the flag', () => { + const result = getFeatures('--foo', ['--foo=bar', '--foo=baz']); + expect(result).toEqual(['bar', 'baz']); + }); + + it('does not handle whitespace', () => { + const result = getFeatures('--foo', ['--foo bar', '--foo baz ']); + expect(result).toEqual([]); + }); + + it('handles equals sign around the flag and value', () => { + const result = getFeatures('--foo', ['--foo=bar', '--foo=baz ']); + expect(result).toEqual(['bar', 'baz']); + }); +}); + +describe('removeMatchingFlags', () => { + it('empty', () => { + const a: string[] = []; + expect(removeMatchingFlags(a, '--foo')).toEqual([]); + }); + + it('with one match', () => { + const a: string[] = ['--foo=1', '--bar=baz']; + expect(removeMatchingFlags(a, '--foo')).toEqual(['--bar=baz']); + }); + + it('with multiple matches', () => { + const a: string[] = ['--foo=1', '--foo=2', '--bar=baz']; + expect(removeMatchingFlags(a, '--foo')).toEqual(['--bar=baz']); + }); + + it('with no matches', () => { + const a: string[] = ['--foo=1', '--bar=baz']; + expect(removeMatchingFlags(a, '--baz')).toEqual(['--foo=1', '--bar=baz']); + }); +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.ts new file mode 100644 index 0000000000..51d5a19983 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.ts @@ -0,0 +1,344 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {mkdtemp} from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import { + computeSystemExecutablePath, + Browser as SupportedBrowsers, + ChromeReleaseChannel as BrowsersChromeReleaseChannel, +} from '@puppeteer/browsers'; + +import type {Browser} from '../api/Browser.js'; +import {debugError} from '../common/util.js'; +import {assert} from '../util/assert.js'; + +import type { + BrowserLaunchArgumentOptions, + ChromeReleaseChannel, + PuppeteerNodeLaunchOptions, +} from './LaunchOptions.js'; +import {ProductLauncher, type ResolvedLaunchArgs} from './ProductLauncher.js'; +import type {PuppeteerNode} from './PuppeteerNode.js'; +import {rm} from './util/fs.js'; + +/** + * @internal + */ +export class ChromeLauncher extends ProductLauncher { + constructor(puppeteer: PuppeteerNode) { + super(puppeteer, 'chrome'); + } + + 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' && + process.arch === 'x64' + ) { + const cpus = os.cpus(); + if (cpus[0]?.model.includes('Apple')) { + console.warn( + [ + '\x1B[1m\x1B[43m\x1B[30m', + 'Degraded performance warning:\x1B[0m\x1B[33m', + 'Launching Chrome on Mac Silicon (arm64) from an x64 Node installation results in', + 'Rosetta translating the Chrome binary, even if Chrome is already arm64. This would', + 'result in huge performance issues. To resolve this, you must run Puppeteer with', + 'a version of Node built for arm64.', + ].join('\n ') + ); + } + } + + return super.launch(options); + } + + /** + * @internal + */ + override async computeLaunchArguments( + options: PuppeteerNodeLaunchOptions = {} + ): Promise<ResolvedLaunchArgs> { + const { + ignoreDefaultArgs = false, + args = [], + pipe = false, + debuggingPort, + channel, + executablePath, + } = options; + + const chromeArguments = []; + if (!ignoreDefaultArgs) { + chromeArguments.push(...this.defaultArgs(options)); + } else if (Array.isArray(ignoreDefaultArgs)) { + chromeArguments.push( + ...this.defaultArgs(options).filter(arg => { + return !ignoreDefaultArgs.includes(arg); + }) + ); + } else { + chromeArguments.push(...args); + } + + if ( + !chromeArguments.some(argument => { + return argument.startsWith('--remote-debugging-'); + }) + ) { + if (pipe) { + assert( + !debuggingPort, + 'Browser should be launched with either pipe or debugging port - not both.' + ); + chromeArguments.push('--remote-debugging-pipe'); + } else { + chromeArguments.push(`--remote-debugging-port=${debuggingPort || 0}`); + } + } + + let isTempUserDataDir = false; + + // Check for the user data dir argument, which will always be set even + // with a custom directory specified via the userDataDir option. + let userDataDirIndex = chromeArguments.findIndex(arg => { + return arg.startsWith('--user-data-dir'); + }); + if (userDataDirIndex < 0) { + isTempUserDataDir = true; + chromeArguments.push( + `--user-data-dir=${await mkdtemp(this.getProfilePath())}` + ); + userDataDirIndex = chromeArguments.length - 1; + } + + const userDataDir = chromeArguments[userDataDirIndex]!.split('=', 2)[1]; + assert(typeof userDataDir === 'string', '`--user-data-dir` is malformed'); + + let chromeExecutable = executablePath; + if (!chromeExecutable) { + assert( + channel || !this.puppeteer._isPuppeteerCore, + `An \`executablePath\` or \`channel\` must be specified for \`puppeteer-core\`` + ); + chromeExecutable = this.executablePath(channel, options.headless ?? true); + } + + return { + executablePath: chromeExecutable, + args: chromeArguments, + isTempUserDataDir, + userDataDir, + }; + } + + /** + * @internal + */ + override async cleanUserDataDir( + path: string, + opts: {isTemp: boolean} + ): Promise<void> { + if (opts.isTemp) { + try { + await rm(path); + } catch (error) { + debugError(error); + throw error; + } + } + } + + override defaultArgs(options: BrowserLaunchArgumentOptions = {}): string[] { + // See https://github.com/GoogleChrome/chrome-launcher/blob/main/docs/chrome-flags-for-tools.md + + const userDisabledFeatures = getFeatures( + '--disable-features', + options.args + ); + if (options.args && userDisabledFeatures.length > 0) { + removeMatchingFlags(options.args, '--disable-features'); + } + + // Merge default disabled features with user-provided ones, if any. + const disabledFeatures = [ + 'Translate', + // AcceptCHFrame disabled because of crbug.com/1348106. + 'AcceptCHFrame', + 'MediaRouter', + 'OptimizationHints', + // https://crbug.com/1492053 + 'ProcessPerSiteUpToMainFrameThreshold', + ...userDisabledFeatures, + ]; + + const userEnabledFeatures = getFeatures('--enable-features', options.args); + if (options.args && userEnabledFeatures.length > 0) { + removeMatchingFlags(options.args, '--enable-features'); + } + + // Merge default enabled features with user-provided ones, if any. + const enabledFeatures = [ + 'NetworkServiceInProcess2', + ...userEnabledFeatures, + ]; + + const chromeArguments = [ + '--allow-pre-commit-input', + '--disable-background-networking', + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-breakpad', + '--disable-client-side-phishing-detection', + '--disable-component-extensions-with-background-pages', + '--disable-component-update', + '--disable-default-apps', + '--disable-dev-shm-usage', + '--disable-extensions', + '--disable-field-trial-config', // https://source.chromium.org/chromium/chromium/src/+/main:testing/variations/README.md + '--disable-hang-monitor', + '--disable-infobars', + '--disable-ipc-flooding-protection', + '--disable-popup-blocking', + '--disable-prompt-on-repost', + '--disable-renderer-backgrounding', + '--disable-search-engine-choice-screen', + '--disable-sync', + '--enable-automation', + '--export-tagged-pdf', + '--force-color-profile=srgb', + '--metrics-recording-only', + '--no-first-run', + '--password-store=basic', + '--use-mock-keychain', + `--disable-features=${disabledFeatures.join(',')}`, + `--enable-features=${enabledFeatures.join(',')}`, + ]; + const { + devtools = false, + headless = !devtools, + args = [], + userDataDir, + } = options; + if (userDataDir) { + chromeArguments.push(`--user-data-dir=${path.resolve(userDataDir)}`); + } + if (devtools) { + chromeArguments.push('--auto-open-devtools-for-tabs'); + } + if (headless) { + chromeArguments.push( + headless === 'new' ? '--headless=new' : '--headless', + '--hide-scrollbars', + '--mute-audio' + ); + } + if ( + args.every(arg => { + return arg.startsWith('-'); + }) + ) { + chromeArguments.push('about:blank'); + } + chromeArguments.push(...args); + return chromeArguments; + } + + override executablePath( + channel?: ChromeReleaseChannel, + headless?: boolean | 'new' + ): string { + if (channel) { + return computeSystemExecutablePath({ + browser: SupportedBrowsers.CHROME, + channel: convertPuppeteerChannelToBrowsersChannel(channel), + }); + } else { + return this.resolveExecutablePath(headless); + } + } +} + +function convertPuppeteerChannelToBrowsersChannel( + channel: ChromeReleaseChannel +): BrowsersChromeReleaseChannel { + switch (channel) { + case 'chrome': + return BrowsersChromeReleaseChannel.STABLE; + case 'chrome-dev': + return BrowsersChromeReleaseChannel.DEV; + case 'chrome-beta': + return BrowsersChromeReleaseChannel.BETA; + case 'chrome-canary': + return BrowsersChromeReleaseChannel.CANARY; + } +} + +/** + * Extracts all features from the given command-line flag + * (e.g. `--enable-features`, `--enable-features=`). + * + * Example input: + * ["--enable-features=NetworkService,NetworkServiceInProcess", "--enable-features=Foo"] + * + * Example output: + * ["NetworkService", "NetworkServiceInProcess", "Foo"] + * + * @internal + */ +export function getFeatures(flag: string, options: string[] = []): string[] { + return options + .filter(s => { + return s.startsWith(flag.endsWith('=') ? flag : `${flag}=`); + }) + .map(s => { + return s.split(new RegExp(`${flag}=\\s*`))[1]?.trim(); + }) + .filter(s => { + return s; + }) as string[]; +} + +/** + * Removes all elements in-place from the given string array + * that match the given command-line flag. + * + * @internal + */ +export function removeMatchingFlags(array: string[], flag: string): string[] { + const regex = new RegExp(`^${flag}=.*`); + let i = 0; + while (i < array.length) { + if (regex.test(array[i]!)) { + array.splice(i, 1); + } else { + i++; + } + } + return array; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.test.ts new file mode 100644 index 0000000000..b0b1f81249 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.test.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, it} from 'node:test'; + +import expect from 'expect'; + +import {FirefoxLauncher} from './FirefoxLauncher.js'; + +describe('FirefoxLauncher', function () { + describe('getPreferences', function () { + it('should return preferences for CDP', async () => { + const prefs: Record<string, unknown> = FirefoxLauncher.getPreferences( + { + test: 1, + }, + undefined + ); + expect(prefs['test']).toBe(1); + expect(prefs['fission.bfcacheInParent']).toBe(false); + expect(prefs['fission.webContentIsolationStrategy']).toBe(0); + expect(prefs).toEqual( + FirefoxLauncher.getPreferences( + { + test: 1, + }, + 'cdp' + ) + ); + }); + + it('should return preferences for WebDriver BiDi', async () => { + const prefs: Record<string, unknown> = FirefoxLauncher.getPreferences( + { + test: 1, + }, + 'webDriverBiDi' + ); + expect(prefs['test']).toBe(1); + expect(prefs['fission.bfcacheInParent']).toBe(undefined); + expect(prefs['fission.webContentIsolationStrategy']).toBe(0); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.ts new file mode 100644 index 0000000000..eb4f375fc7 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.ts @@ -0,0 +1,242 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; +import {rename, unlink, mkdtemp} from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import { + Browser as SupportedBrowsers, + createProfile, + Cache, + detectBrowserPlatform, + Browser, +} from '@puppeteer/browsers'; + +import {debugError} from '../common/util.js'; +import {assert} from '../util/assert.js'; + +import type { + BrowserLaunchArgumentOptions, + PuppeteerNodeLaunchOptions, +} from './LaunchOptions.js'; +import {ProductLauncher, type ResolvedLaunchArgs} from './ProductLauncher.js'; +import type {PuppeteerNode} from './PuppeteerNode.js'; +import {rm} from './util/fs.js'; + +/** + * @internal + */ +export class FirefoxLauncher extends ProductLauncher { + constructor(puppeteer: PuppeteerNode) { + super(puppeteer, 'firefox'); + } + + static getPreferences( + extraPrefsFirefox?: Record<string, unknown>, + protocol?: 'cdp' | 'webDriverBiDi' + ): Record<string, unknown> { + return { + ...extraPrefsFirefox, + ...(protocol === 'webDriverBiDi' + ? {} + : { + // Do not close the window when the last tab gets closed + 'browser.tabs.closeWindowWithLastTab': false, + // Temporarily force disable BFCache in parent (https://bit.ly/bug-1732263) + 'fission.bfcacheInParent': false, + }), + // Force all web content to use a single content process. TODO: remove + // this once Firefox supports mouse event dispatch from the main frame + // context. Once this happens, webContentIsolationStrategy should only + // be set for CDP. See + // https://bugzilla.mozilla.org/show_bug.cgi?id=1773393 + 'fission.webContentIsolationStrategy': 0, + }; + } + + /** + * @internal + */ + override async computeLaunchArguments( + options: PuppeteerNodeLaunchOptions = {} + ): Promise<ResolvedLaunchArgs> { + const { + ignoreDefaultArgs = false, + args = [], + executablePath, + pipe = false, + extraPrefsFirefox = {}, + debuggingPort = null, + } = options; + + const firefoxArguments = []; + if (!ignoreDefaultArgs) { + firefoxArguments.push(...this.defaultArgs(options)); + } else if (Array.isArray(ignoreDefaultArgs)) { + firefoxArguments.push( + ...this.defaultArgs(options).filter(arg => { + return !ignoreDefaultArgs.includes(arg); + }) + ); + } else { + firefoxArguments.push(...args); + } + + if ( + !firefoxArguments.some(argument => { + return argument.startsWith('--remote-debugging-'); + }) + ) { + if (pipe) { + assert( + debuggingPort === null, + 'Browser should be launched with either pipe or debugging port - not both.' + ); + } + firefoxArguments.push(`--remote-debugging-port=${debuggingPort || 0}`); + } + + let userDataDir: string | undefined; + let isTempUserDataDir = true; + + // Check for the profile argument, which will always be set even + // with a custom directory specified via the userDataDir option. + const profileArgIndex = firefoxArguments.findIndex(arg => { + return ['-profile', '--profile'].includes(arg); + }); + + if (profileArgIndex !== -1) { + userDataDir = firefoxArguments[profileArgIndex + 1]; + if (!userDataDir || !fs.existsSync(userDataDir)) { + throw new Error(`Firefox profile not found at '${userDataDir}'`); + } + + // When using a custom Firefox profile it needs to be populated + // with required preferences. + isTempUserDataDir = false; + } else { + userDataDir = await mkdtemp(this.getProfilePath()); + firefoxArguments.push('--profile'); + firefoxArguments.push(userDataDir); + } + + await createProfile(SupportedBrowsers.FIREFOX, { + path: userDataDir, + preferences: FirefoxLauncher.getPreferences( + extraPrefsFirefox, + options.protocol + ), + }); + + let firefoxExecutable: string; + if (this.puppeteer._isPuppeteerCore || executablePath) { + assert( + executablePath, + `An \`executablePath\` must be specified for \`puppeteer-core\`` + ); + firefoxExecutable = executablePath; + } else { + firefoxExecutable = this.executablePath(); + } + + return { + isTempUserDataDir, + userDataDir, + args: firefoxArguments, + executablePath: firefoxExecutable, + }; + } + + /** + * @internal + */ + override async cleanUserDataDir( + userDataDir: string, + opts: {isTemp: boolean} + ): Promise<void> { + if (opts.isTemp) { + try { + await rm(userDataDir); + } catch (error) { + debugError(error); + throw error; + } + } else { + try { + // When an existing user profile has been used remove the user + // preferences file and restore possibly backuped preferences. + await unlink(path.join(userDataDir, 'user.js')); + + const prefsBackupPath = path.join(userDataDir, 'prefs.js.puppeteer'); + if (fs.existsSync(prefsBackupPath)) { + const prefsPath = path.join(userDataDir, 'prefs.js'); + await unlink(prefsPath); + await rename(prefsBackupPath, prefsPath); + } + } catch (error) { + debugError(error); + } + } + } + + override executablePath(): string { + // replace 'latest' placeholder with actual downloaded revision + if (this.puppeteer.browserRevision === 'latest') { + const cache = new Cache(this.puppeteer.defaultDownloadPath!); + const installedFirefox = cache.getInstalledBrowsers().find(browser => { + return ( + browser.platform === detectBrowserPlatform() && + browser.browser === Browser.FIREFOX + ); + }); + if (installedFirefox) { + this.actualBrowserRevision = installedFirefox.buildId; + } + } + return this.resolveExecutablePath(); + } + + override defaultArgs(options: BrowserLaunchArgumentOptions = {}): string[] { + const { + devtools = false, + headless = !devtools, + args = [], + userDataDir = null, + } = options; + + const firefoxArguments = ['--no-remote']; + + switch (os.platform()) { + case 'darwin': + firefoxArguments.push('--foreground'); + break; + case 'win32': + firefoxArguments.push('--wait-for-browser'); + break; + } + if (userDataDir) { + firefoxArguments.push('--profile'); + firefoxArguments.push(userDataDir); + } + if (headless) { + firefoxArguments.push('--headless'); + } + if (devtools) { + firefoxArguments.push('--devtools'); + } + if ( + args.every(arg => { + return arg.startsWith('-'); + }) + ) { + firefoxArguments.push('about:blank'); + } + firefoxArguments.push(...args); + return firefoxArguments; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/LaunchOptions.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/LaunchOptions.ts new file mode 100644 index 0000000000..28e0b595df --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/LaunchOptions.ts @@ -0,0 +1,140 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {BrowserConnectOptions} from '../common/ConnectOptions.js'; +import type {Product} from '../common/Product.js'; + +/** + * Launcher options that only apply to Chrome. + * + * @public + */ +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"`. + * + * @defaultValue `true` + */ + headless?: boolean | 'new'; + /** + * 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} + * for more info. + */ + userDataDir?: string; + /** + * Whether to auto-open a DevTools panel for each tab. If this is set to + * `true`, then `headless` will be forced to `false`. + * @defaultValue `false` + */ + devtools?: boolean; + /** + * Specify the debugging port number to use + */ + debuggingPort?: number; + /** + * Additional command line arguments to pass to the browser instance. + */ + args?: string[]; +} +/** + * @public + */ +export type ChromeReleaseChannel = + | 'chrome' + | 'chrome-beta' + | 'chrome-canary' + | 'chrome-dev'; + +/** + * Generic launch options that can be passed when launching any browser. + * @public + */ +export interface LaunchOptions { + /** + * Chrome Release Channel + */ + channel?: ChromeReleaseChannel; + /** + * Path to a browser executable to use instead of the bundled Chromium. Note + * that Puppeteer is only guaranteed to work with the bundled Chromium, so use + * this setting at your own risk. + */ + executablePath?: string; + /** + * If `true`, do not use `puppeteer.defaultArgs()` when creating a browser. If + * an array is provided, these args will be filtered out. Use this with care - + * you probably want the default arguments Puppeteer uses. + * @defaultValue `false` + */ + ignoreDefaultArgs?: boolean | string[]; + /** + * Close the browser process on `Ctrl+C`. + * @defaultValue `true` + */ + handleSIGINT?: boolean; + /** + * Close the browser process on `SIGTERM`. + * @defaultValue `true` + */ + handleSIGTERM?: boolean; + /** + * Close the browser process on `SIGHUP`. + * @defaultValue `true` + */ + handleSIGHUP?: boolean; + /** + * Maximum time in milliseconds to wait for the browser to start. + * Pass `0` to disable the timeout. + * @defaultValue `30_000` (30 seconds). + */ + timeout?: number; + /** + * If true, pipes the browser process stdout and stderr to `process.stdout` + * and `process.stderr`. + * @defaultValue `false` + */ + dumpio?: boolean; + /** + * Specify environment variables that will be visible to the browser. + * @defaultValue The contents of `process.env`. + */ + env?: Record<string, string | undefined>; + /** + * Connect to a browser over a pipe instead of a WebSocket. + * @defaultValue `false` + */ + pipe?: boolean; + /** + * Which browser to launch. + * @defaultValue `chrome` + */ + product?: Product; + /** + * {@link https://searchfox.org/mozilla-release/source/modules/libpref/init/all.js | Additional preferences } that can be passed when launching with Firefox. + */ + extraPrefsFirefox?: Record<string, unknown>; + /** + * Whether to wait for the initial page to be ready. + * Useful when a user explicitly disables that (e.g. `--no-startup-window` for Chrome). + * @defaultValue `true` + */ + waitForInitialPage?: boolean; +} + +/** + * Utility type exposed to enable users to define options that can be passed to + * `puppeteer.launch` without having to list the set of all types. + * @public + */ +export type PuppeteerNodeLaunchOptions = BrowserLaunchArgumentOptions & + LaunchOptions & + BrowserConnectOptions; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/NodeWebSocketTransport.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/NodeWebSocketTransport.ts new file mode 100644 index 0000000000..f4ac592e4f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/NodeWebSocketTransport.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import NodeWebSocket from 'ws'; + +import type {ConnectionTransport} from '../common/ConnectionTransport.js'; +import {packageVersion} from '../generated/version.js'; + +/** + * @internal + */ +export class NodeWebSocketTransport implements ConnectionTransport { + static create( + url: string, + headers?: Record<string, string> + ): Promise<NodeWebSocketTransport> { + return new Promise((resolve, reject) => { + const ws = new NodeWebSocket(url, [], { + followRedirects: true, + perMessageDeflate: false, + maxPayload: 256 * 1024 * 1024, // 256Mb + headers: { + 'User-Agent': `Puppeteer ${packageVersion}`, + ...headers, + }, + }); + + ws.addEventListener('open', () => { + return resolve(new NodeWebSocketTransport(ws)); + }); + ws.addEventListener('error', reject); + }); + } + + #ws: NodeWebSocket; + onmessage?: (message: NodeWebSocket.Data) => void; + onclose?: () => void; + + constructor(ws: NodeWebSocket) { + this.#ws = ws; + this.#ws.addEventListener('message', event => { + if (this.onmessage) { + this.onmessage.call(null, event.data); + } + }); + this.#ws.addEventListener('close', () => { + if (this.onclose) { + this.onclose.call(null); + } + }); + // Silently ignore all errors - we don't know what to do with them. + this.#ws.addEventListener('error', () => {}); + } + + send(message: string): void { + this.#ws.send(message); + } + + close(): void { + this.#ws.close(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/PipeTransport.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/PipeTransport.ts new file mode 100644 index 0000000000..616f164d82 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/PipeTransport.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type {ConnectionTransport} from '../common/ConnectionTransport.js'; +import {EventSubscription} from '../common/EventEmitter.js'; +import {debugError} from '../common/util.js'; +import {assert} from '../util/assert.js'; +import {DisposableStack} from '../util/disposable.js'; + +/** + * @internal + */ +export class PipeTransport implements ConnectionTransport { + #pipeWrite: NodeJS.WritableStream; + #subscriptions = new DisposableStack(); + + #isClosed = false; + #pendingMessage = ''; + + onclose?: () => void; + onmessage?: (value: string) => void; + + constructor( + pipeWrite: NodeJS.WritableStream, + pipeRead: NodeJS.ReadableStream + ) { + this.#pipeWrite = pipeWrite; + this.#subscriptions.use( + new EventSubscription(pipeRead, 'data', (buffer: Buffer) => { + return this.#dispatch(buffer); + }) + ); + this.#subscriptions.use( + new EventSubscription(pipeRead, 'close', () => { + if (this.onclose) { + this.onclose.call(null); + } + }) + ); + this.#subscriptions.use( + new EventSubscription(pipeRead, 'error', debugError) + ); + this.#subscriptions.use( + new EventSubscription(pipeWrite, 'error', debugError) + ); + } + + send(message: string): void { + assert(!this.#isClosed, '`PipeTransport` is closed.'); + + this.#pipeWrite.write(message); + this.#pipeWrite.write('\0'); + } + + #dispatch(buffer: Buffer): void { + assert(!this.#isClosed, '`PipeTransport` is closed.'); + + let end = buffer.indexOf('\0'); + if (end === -1) { + this.#pendingMessage += buffer.toString(); + return; + } + const message = this.#pendingMessage + buffer.toString(undefined, 0, end); + if (this.onmessage) { + this.onmessage.call(null, message); + } + + let start = end + 1; + end = buffer.indexOf('\0', start); + while (end !== -1) { + if (this.onmessage) { + this.onmessage.call(null, buffer.toString(undefined, start, end)); + } + start = end + 1; + end = buffer.indexOf('\0', start); + } + this.#pendingMessage = buffer.toString(undefined, start); + } + + close(): void { + this.#isClosed = true; + this.#subscriptions.dispose(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/ProductLauncher.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/ProductLauncher.ts new file mode 100644 index 0000000000..ab3432cd3a --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/ProductLauncher.ts @@ -0,0 +1,451 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import {existsSync} from 'fs'; +import {tmpdir} from 'os'; +import {join} from 'path'; + +import { + Browser as InstalledBrowser, + CDP_WEBSOCKET_ENDPOINT_REGEX, + launch, + TimeoutError as BrowsersTimeoutError, + WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_REGEX, + computeExecutablePath, +} from '@puppeteer/browsers'; + +import { + firstValueFrom, + from, + map, + race, + timer, +} from '../../third_party/rxjs/rxjs.js'; +import type {Browser, BrowserCloseCallback} from '../api/Browser.js'; +import {CdpBrowser} from '../cdp/Browser.js'; +import {Connection} from '../cdp/Connection.js'; +import {TimeoutError} from '../common/Errors.js'; +import type {Product} from '../common/Product.js'; +import {debugError, DEFAULT_VIEWPORT} from '../common/util.js'; +import type {Viewport} from '../common/Viewport.js'; + +import type { + BrowserLaunchArgumentOptions, + ChromeReleaseChannel, + PuppeteerNodeLaunchOptions, +} from './LaunchOptions.js'; +import {NodeWebSocketTransport as WebSocketTransport} from './NodeWebSocketTransport.js'; +import {PipeTransport} from './PipeTransport.js'; +import type {PuppeteerNode} from './PuppeteerNode.js'; + +/** + * @internal + */ +export interface ResolvedLaunchArgs { + isTempUserDataDir: boolean; + userDataDir: string; + executablePath: string; + args: string[]; +} + +/** + * Describes a launcher - a class that is able to create and launch a browser instance. + * + * @public + */ +export abstract class ProductLauncher { + #product: Product; + + /** + * @internal + */ + puppeteer: PuppeteerNode; + + /** + * @internal + */ + protected actualBrowserRevision?: string; + + /** + * @internal + */ + constructor(puppeteer: PuppeteerNode, product: Product) { + this.puppeteer = puppeteer; + this.#product = product; + } + + get product(): Product { + return this.#product; + } + + async launch(options: PuppeteerNodeLaunchOptions = {}): Promise<Browser> { + const { + dumpio = false, + env = process.env, + handleSIGINT = true, + handleSIGTERM = true, + handleSIGHUP = true, + ignoreHTTPSErrors = false, + defaultViewport = DEFAULT_VIEWPORT, + slowMo = 0, + timeout = 30000, + waitForInitialPage = true, + protocolTimeout, + protocol, + } = options; + + const launchArgs = await this.computeLaunchArguments(options); + + const usePipe = launchArgs.args.includes('--remote-debugging-pipe'); + + const onProcessExit = async () => { + await this.cleanUserDataDir(launchArgs.userDataDir, { + isTemp: launchArgs.isTempUserDataDir, + }); + }; + + const browserProcess = launch({ + executablePath: launchArgs.executablePath, + args: launchArgs.args, + handleSIGHUP, + handleSIGTERM, + handleSIGINT, + dumpio, + env, + pipe: usePipe, + onExit: onProcessExit, + }); + + let browser: Browser; + let cdpConnection: Connection; + let closing = false; + + const browserCloseCallback: BrowserCloseCallback = async () => { + if (closing) { + return; + } + closing = true; + await this.closeBrowser(browserProcess, cdpConnection); + }; + + try { + if (this.#product === 'firefox' && protocol === 'webDriverBiDi') { + browser = await this.createBiDiBrowser( + browserProcess, + browserCloseCallback, + { + timeout, + protocolTimeout, + slowMo, + defaultViewport, + ignoreHTTPSErrors, + } + ); + } else { + if (usePipe) { + cdpConnection = await this.createCdpPipeConnection(browserProcess, { + timeout, + protocolTimeout, + slowMo, + }); + } else { + cdpConnection = await this.createCdpSocketConnection(browserProcess, { + timeout, + protocolTimeout, + slowMo, + }); + } + if (protocol === 'webDriverBiDi') { + browser = await this.createBiDiOverCdpBrowser( + browserProcess, + cdpConnection, + browserCloseCallback, + { + timeout, + protocolTimeout, + slowMo, + defaultViewport, + ignoreHTTPSErrors, + } + ); + } else { + browser = await CdpBrowser._create( + this.product, + cdpConnection, + [], + ignoreHTTPSErrors, + defaultViewport, + browserProcess.nodeProcess, + browserCloseCallback, + options.targetFilter + ); + } + } + } catch (error) { + void browserCloseCallback(); + if (error instanceof BrowsersTimeoutError) { + throw new TimeoutError(error.message); + } + throw error; + } + + if (waitForInitialPage && protocol !== 'webDriverBiDi') { + await this.waitForPageTarget(browser, timeout); + } + + return browser; + } + + abstract executablePath(channel?: ChromeReleaseChannel): string; + + abstract defaultArgs(object: BrowserLaunchArgumentOptions): string[]; + + /** + * Set only for Firefox, after the launcher resolves the `latest` revision to + * the actual revision. + * @internal + */ + getActualBrowserRevision(): string | undefined { + return this.actualBrowserRevision; + } + + /** + * @internal + */ + protected abstract computeLaunchArguments( + options: PuppeteerNodeLaunchOptions + ): Promise<ResolvedLaunchArgs>; + + /** + * @internal + */ + protected abstract cleanUserDataDir( + path: string, + opts: {isTemp: boolean} + ): Promise<void>; + + /** + * @internal + */ + protected async closeBrowser( + browserProcess: ReturnType<typeof launch>, + cdpConnection?: Connection + ): Promise<void> { + if (cdpConnection) { + // Attempt to close the browser gracefully + try { + await cdpConnection.closeBrowser(); + await browserProcess.hasClosed(); + } catch (error) { + debugError(error); + await browserProcess.close(); + } + } else { + // Wait for a possible graceful shutdown. + await firstValueFrom( + race( + from(browserProcess.hasClosed()), + timer(5000).pipe( + map(() => { + return from(browserProcess.close()); + }) + ) + ) + ); + } + } + + /** + * @internal + */ + protected async waitForPageTarget( + browser: Browser, + timeout: number + ): Promise<void> { + try { + await browser.waitForTarget( + t => { + return t.type() === 'page'; + }, + {timeout} + ); + } catch (error) { + await browser.close(); + throw error; + } + } + + /** + * @internal + */ + protected async createCdpSocketConnection( + browserProcess: ReturnType<typeof launch>, + opts: {timeout: number; protocolTimeout: number | undefined; slowMo: number} + ): Promise<Connection> { + const browserWSEndpoint = await browserProcess.waitForLineOutput( + CDP_WEBSOCKET_ENDPOINT_REGEX, + opts.timeout + ); + const transport = await WebSocketTransport.create(browserWSEndpoint); + return new Connection( + browserWSEndpoint, + transport, + opts.slowMo, + opts.protocolTimeout + ); + } + + /** + * @internal + */ + protected async createCdpPipeConnection( + browserProcess: ReturnType<typeof launch>, + opts: {timeout: number; protocolTimeout: number | undefined; slowMo: number} + ): Promise<Connection> { + // stdio was assigned during start(), and the 'pipe' option there adds the + // 4th and 5th items to stdio array + const {3: pipeWrite, 4: pipeRead} = browserProcess.nodeProcess.stdio; + const transport = new PipeTransport( + pipeWrite as NodeJS.WritableStream, + pipeRead as NodeJS.ReadableStream + ); + return new Connection('', transport, opts.slowMo, opts.protocolTimeout); + } + + /** + * @internal + */ + protected async createBiDiOverCdpBrowser( + browserProcess: ReturnType<typeof launch>, + connection: Connection, + closeCallback: BrowserCloseCallback, + opts: { + timeout: number; + protocolTimeout: number | undefined; + slowMo: number; + defaultViewport: Viewport | null; + ignoreHTTPSErrors?: boolean; + } + ): Promise<Browser> { + // TODO: use other options too. + const BiDi = await import(/* webpackIgnore: true */ '../bidi/bidi.js'); + const bidiConnection = await BiDi.connectBidiOverCdp(connection, { + acceptInsecureCerts: opts.ignoreHTTPSErrors ?? false, + }); + return await BiDi.BidiBrowser.create({ + connection: bidiConnection, + closeCallback, + process: browserProcess.nodeProcess, + defaultViewport: opts.defaultViewport, + ignoreHTTPSErrors: opts.ignoreHTTPSErrors, + }); + } + + /** + * @internal + */ + protected async createBiDiBrowser( + browserProcess: ReturnType<typeof launch>, + closeCallback: BrowserCloseCallback, + opts: { + timeout: number; + protocolTimeout: number | undefined; + slowMo: number; + defaultViewport: Viewport | null; + ignoreHTTPSErrors?: boolean; + } + ): Promise<Browser> { + const browserWSEndpoint = + (await browserProcess.waitForLineOutput( + WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_REGEX, + opts.timeout + )) + '/session'; + const transport = await WebSocketTransport.create(browserWSEndpoint); + const BiDi = await import(/* webpackIgnore: true */ '../bidi/bidi.js'); + const bidiConnection = new BiDi.BidiConnection( + browserWSEndpoint, + transport, + opts.slowMo, + opts.protocolTimeout + ); + // TODO: use other options too. + return await BiDi.BidiBrowser.create({ + connection: bidiConnection, + closeCallback, + process: browserProcess.nodeProcess, + defaultViewport: opts.defaultViewport, + ignoreHTTPSErrors: opts.ignoreHTTPSErrors, + }); + } + + /** + * @internal + */ + protected getProfilePath(): string { + return join( + this.puppeteer.configuration.temporaryDirectory ?? tmpdir(), + `puppeteer_dev_${this.product}_profile-` + ); + } + + /** + * @internal + */ + protected resolveExecutablePath(headless?: boolean | 'new'): string { + let executablePath = this.puppeteer.configuration.executablePath; + if (executablePath) { + if (!existsSync(executablePath)) { + throw new Error( + `Tried to find the browser at the configured path (${executablePath}), but no executable was found.` + ); + } + return executablePath; + } + + function productToBrowser(product?: Product, headless?: boolean | 'new') { + switch (product) { + case 'chrome': + if (headless === true) { + return InstalledBrowser.CHROMEHEADLESSSHELL; + } + return InstalledBrowser.CHROME; + case 'firefox': + return InstalledBrowser.FIREFOX; + } + return InstalledBrowser.CHROME; + } + + executablePath = computeExecutablePath({ + cacheDir: this.puppeteer.defaultDownloadPath!, + browser: productToBrowser(this.product, headless), + buildId: this.puppeteer.browserRevision, + }); + + if (!existsSync(executablePath)) { + if (this.puppeteer.configuration.browserRevision) { + throw new Error( + `Tried to find the browser at the configured path (${executablePath}) for revision ${this.puppeteer.browserRevision}, but no executable was found.` + ); + } + switch (this.product) { + case 'chrome': + throw new Error( + `Could not find Chrome (ver. ${this.puppeteer.browserRevision}). This can occur if either\n` + + ' 1. you did not perform an installation before running the script (e.g. `npx puppeteer browsers install chrome`) or\n' + + ` 2. your cache path is incorrectly configured (which is: ${this.puppeteer.configuration.cacheDirectory}).\n` + + 'For (2), check out our guide on configuring puppeteer at https://pptr.dev/guides/configuration.' + ); + case 'firefox': + throw new Error( + `Could not find Firefox (rev. ${this.puppeteer.browserRevision}). This can occur if either\n` + + ' 1. you did not perform an installation for Firefox before running the script (e.g. `npx puppeteer browsers install firefox`) or\n' + + ` 2. your cache path is incorrectly configured (which is: ${this.puppeteer.configuration.cacheDirectory}).\n` + + 'For (2), check out our guide on configuring puppeteer at https://pptr.dev/guides/configuration.' + ); + } + } + return executablePath; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/PuppeteerNode.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/PuppeteerNode.ts new file mode 100644 index 0000000000..e50e09acdb --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/PuppeteerNode.ts @@ -0,0 +1,356 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Browser as SupportedBrowser, + resolveBuildId, + detectBrowserPlatform, + getInstalledBrowsers, + uninstall, +} from '@puppeteer/browsers'; + +import type {Browser} from '../api/Browser.js'; +import type {Configuration} from '../common/Configuration.js'; +import type { + ConnectOptions, + BrowserConnectOptions, +} from '../common/ConnectOptions.js'; +import type {Product} from '../common/Product.js'; +import {type CommonPuppeteerSettings, Puppeteer} from '../common/Puppeteer.js'; +import {PUPPETEER_REVISIONS} from '../revisions.js'; + +import {ChromeLauncher} from './ChromeLauncher.js'; +import {FirefoxLauncher} from './FirefoxLauncher.js'; +import type { + BrowserLaunchArgumentOptions, + ChromeReleaseChannel, + LaunchOptions, +} from './LaunchOptions.js'; +import type {ProductLauncher} from './ProductLauncher.js'; + +/** + * @public + */ +export interface PuppeteerLaunchOptions + extends LaunchOptions, + BrowserLaunchArgumentOptions, + BrowserConnectOptions { + product?: Product; + extraPrefsFirefox?: Record<string, unknown>; +} + +/** + * Extends the main {@link Puppeteer} class with Node specific behaviour for + * fetching and downloading browsers. + * + * If you're using Puppeteer in a Node environment, this is the class you'll get + * when you run `require('puppeteer')` (or the equivalent ES `import`). + * + * @remarks + * The most common method to use is {@link PuppeteerNode.launch | launch}, which + * is used to launch and connect to a new browser instance. + * + * See {@link Puppeteer | the main Puppeteer class} for methods common to all + * environments, such as {@link Puppeteer.connect}. + * + * @example + * The following is a typical example of using Puppeteer to drive automation: + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://www.google.com'); + * // other actions... + * await browser.close(); + * })(); + * ``` + * + * Once you have created a `page` you have access to a large API to interact + * with the page, navigate, or find certain elements in that page. + * The {@link Page | `page` documentation} lists all the available methods. + * + * @public + */ +export class PuppeteerNode extends Puppeteer { + #_launcher?: ProductLauncher; + #lastLaunchedProduct?: Product; + + /** + * @internal + */ + defaultBrowserRevision: string; + + /** + * @internal + */ + configuration: Configuration = {}; + + /** + * @internal + */ + constructor( + settings: { + configuration?: Configuration; + } & CommonPuppeteerSettings + ) { + const {configuration, ...commonSettings} = settings; + super(commonSettings); + if (configuration) { + this.configuration = configuration; + } + switch (this.configuration.defaultProduct) { + case 'firefox': + this.defaultBrowserRevision = PUPPETEER_REVISIONS.firefox; + break; + default: + this.configuration.defaultProduct = 'chrome'; + this.defaultBrowserRevision = PUPPETEER_REVISIONS.chrome; + break; + } + + this.connect = this.connect.bind(this); + this.launch = this.launch.bind(this); + this.executablePath = this.executablePath.bind(this); + this.defaultArgs = this.defaultArgs.bind(this); + this.trimCache = this.trimCache.bind(this); + } + + /** + * This method attaches Puppeteer to an existing browser instance. + * + * @param options - Set of configurable options to set on the browser. + * @returns Promise which resolves to browser instance. + */ + override connect(options: ConnectOptions): Promise<Browser> { + return super.connect(options); + } + + /** + * Launches a browser instance with given arguments and options when + * specified. + * + * When using with `puppeteer-core`, + * {@link LaunchOptions | options.executablePath} or + * {@link LaunchOptions | options.channel} must be provided. + * + * @example + * You can use {@link LaunchOptions | options.ignoreDefaultArgs} + * to filter out `--mute-audio` from default arguments: + * + * ```ts + * const browser = await puppeteer.launch({ + * ignoreDefaultArgs: ['--mute-audio'], + * }); + * ``` + * + * @remarks + * Puppeteer can also be used to control the Chrome browser, but it works best + * with the version of Chrome for Testing downloaded by default. + * There is no guarantee it will work with any other version. If Google Chrome + * (rather than Chrome for Testing) is preferred, a + * {@link https://www.google.com/chrome/browser/canary.html | Chrome Canary} + * or + * {@link https://www.chromium.org/getting-involved/dev-channel | Dev Channel} + * build is suggested. See + * {@link https://www.howtogeek.com/202825/what%E2%80%99s-the-difference-between-chromium-and-chrome/ | this article} + * for a description of the differences between Chromium and Chrome. + * {@link https://chromium.googlesource.com/chromium/src/+/lkgr/docs/chromium_browser_vs_google_chrome.md | This article} + * describes some differences for Linux users. See + * {@link https://developer.chrome.com/blog/chrome-for-testing/ | this doc} for the description + * of Chrome for Testing. + * + * @param options - Options to configure launching behavior. + */ + launch(options: PuppeteerLaunchOptions = {}): Promise<Browser> { + const {product = this.defaultProduct} = options; + this.#lastLaunchedProduct = product; + return this.#launcher.launch(options); + } + + /** + * @internal + */ + get #launcher(): ProductLauncher { + if ( + this.#_launcher && + this.#_launcher.product === this.lastLaunchedProduct + ) { + return this.#_launcher; + } + switch (this.lastLaunchedProduct) { + case 'chrome': + this.defaultBrowserRevision = PUPPETEER_REVISIONS.chrome; + this.#_launcher = new ChromeLauncher(this); + break; + case 'firefox': + this.defaultBrowserRevision = PUPPETEER_REVISIONS.firefox; + this.#_launcher = new FirefoxLauncher(this); + break; + default: + throw new Error(`Unknown product: ${this.#lastLaunchedProduct}`); + } + return this.#_launcher; + } + + /** + * The default executable path. + */ + executablePath(channel?: ChromeReleaseChannel): string { + return this.#launcher.executablePath(channel); + } + + /** + * @internal + */ + get browserRevision(): string { + return ( + this.#_launcher?.getActualBrowserRevision() ?? + this.configuration.browserRevision ?? + this.defaultBrowserRevision! + ); + } + + /** + * The default download path for puppeteer. For puppeteer-core, this + * code should never be called as it is never defined. + * + * @internal + */ + get defaultDownloadPath(): string | undefined { + return this.configuration.downloadPath ?? this.configuration.cacheDirectory; + } + + /** + * The name of the browser that was last launched. + */ + get lastLaunchedProduct(): Product { + return this.#lastLaunchedProduct ?? this.defaultProduct; + } + + /** + * The name of the browser that will be launched by default. For + * `puppeteer`, this is influenced by your configuration. Otherwise, it's + * `chrome`. + */ + get defaultProduct(): Product { + return this.configuration.defaultProduct ?? 'chrome'; + } + + /** + * @deprecated Do not use as this field as it does not take into account + * multiple browsers of different types. Use + * {@link PuppeteerNode.defaultProduct | defaultProduct} or + * {@link PuppeteerNode.lastLaunchedProduct | lastLaunchedProduct}. + * + * @returns The name of the browser that is under automation. + */ + get product(): string { + return this.#launcher.product; + } + + /** + * @param options - Set of configurable options to set on the browser. + * + * @returns The default flags that Chromium will be launched with. + */ + defaultArgs(options: BrowserLaunchArgumentOptions = {}): string[] { + return this.#launcher.defaultArgs(options); + } + + /** + * Removes all non-current Firefox and Chrome binaries in the cache directory + * identified by the provided Puppeteer configuration. The current browser + * version is determined by resolving PUPPETEER_REVISIONS from Puppeteer + * unless `configuration.browserRevision` is provided. + * + * @remarks + * + * Note that the method does not check if any other Puppeteer versions + * installed on the host that use the same cache directory require the + * non-current binaries. + * + * @public + */ + async trimCache(): Promise<void> { + const platform = detectBrowserPlatform(); + if (!platform) { + throw new Error('The current platform is not supported.'); + } + + const cacheDir = + this.configuration.downloadPath ?? this.configuration.cacheDirectory!; + const installedBrowsers = await getInstalledBrowsers({ + cacheDir, + }); + + const product = this.configuration.defaultProduct!; + + const puppeteerBrowsers: Array<{ + product: Product; + browser: SupportedBrowser; + currentBuildId: string; + }> = [ + { + product: 'chrome', + browser: SupportedBrowser.CHROME, + currentBuildId: '', + }, + { + product: 'firefox', + browser: SupportedBrowser.FIREFOX, + currentBuildId: '', + }, + ]; + + // Resolve current buildIds. + for (const item of puppeteerBrowsers) { + item.currentBuildId = await resolveBuildId( + item.browser, + platform, + (product === item.product + ? this.configuration.browserRevision + : null) || PUPPETEER_REVISIONS[item.product] + ); + } + + const currentBrowserBuilds = new Set( + puppeteerBrowsers.map(browser => { + return `${browser.browser}_${browser.currentBuildId}`; + }) + ); + + const currentBrowsers = new Set( + puppeteerBrowsers.map(browser => { + return browser.browser; + }) + ); + + for (const installedBrowser of installedBrowsers) { + // Don't uninstall browsers that are not managed by Puppeteer yet. + if (!currentBrowsers.has(installedBrowser.browser)) { + continue; + } + // Keep the browser build used by the current Puppeteer installation. + if ( + currentBrowserBuilds.has( + `${installedBrowser.browser}_${installedBrowser.buildId}` + ) + ) { + continue; + } + + await uninstall({ + browser: installedBrowser.browser, + platform, + cacheDir, + buildId: installedBrowser.buildId, + }); + } + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/ScreenRecorder.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/ScreenRecorder.ts new file mode 100644 index 0000000000..effb2d63ba --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/ScreenRecorder.ts @@ -0,0 +1,255 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ChildProcessWithoutNullStreams} from 'child_process'; +import {spawn, spawnSync} from 'child_process'; +import {PassThrough} from 'stream'; + +import debug from 'debug'; + +import type {OperatorFunction} from '../../third_party/rxjs/rxjs.js'; +import { + bufferCount, + concatMap, + filter, + from, + fromEvent, + lastValueFrom, + map, + takeUntil, + tap, +} from '../../third_party/rxjs/rxjs.js'; +import {CDPSessionEvent} from '../api/CDPSession.js'; +import type {BoundingBox} from '../api/ElementHandle.js'; +import type {Page} from '../api/Page.js'; +import {debugError, fromEmitterEvent} from '../common/util.js'; +import {guarded} from '../util/decorators.js'; +import {asyncDisposeSymbol} from '../util/disposable.js'; + +const CRF_VALUE = 30; +const DEFAULT_FPS = 30; + +const debugFfmpeg = debug('puppeteer:ffmpeg'); + +/** + * @internal + */ +export interface ScreenRecorderOptions { + speed?: number; + crop?: BoundingBox; + format?: 'gif' | 'webm'; + scale?: number; + path?: string; +} + +/** + * @public + */ +export class ScreenRecorder extends PassThrough { + #page: Page; + + #process: ChildProcessWithoutNullStreams; + + #controller = new AbortController(); + #lastFrame: Promise<readonly [Buffer, number]>; + + /** + * @internal + */ + constructor( + page: Page, + width: number, + height: number, + {speed, scale, crop, format, path}: ScreenRecorderOptions = {} + ) { + super({allowHalfOpen: false}); + + path ??= 'ffmpeg'; + + // Tests if `ffmpeg` exists. + const {error} = spawnSync(path); + if (error) { + throw error; + } + + this.#process = spawn( + path, + // See https://trac.ffmpeg.org/wiki/Encode/VP9 for more information on flags. + [ + ['-loglevel', 'error'], + // Reduces general buffering. + ['-avioflags', 'direct'], + // Reduces initial buffering while analyzing input fps and other stats. + [ + '-fpsprobesize', + '0', + '-probesize', + '32', + '-analyzeduration', + '0', + '-fflags', + 'nobuffer', + ], + // Forces input to be read from standard input, and forces png input + // image format. + ['-f', 'image2pipe', '-c:v', 'png', '-i', 'pipe:0'], + // Overwrite output and no audio. + ['-y', '-an'], + // This drastically reduces stalling when cpu is overbooked. By default + // VP9 tries to use all available threads? + ['-threads', '1'], + // Specifies the frame rate we are giving ffmpeg. + ['-framerate', `${DEFAULT_FPS}`], + // Specifies the encoding and format we are using. + this.#getFormatArgs(format ?? 'webm'), + // Disable bitrate. + ['-b:v', '0'], + // Filters to ensure the images are piped correctly. + [ + '-vf', + `${ + speed ? `setpts=${1 / speed}*PTS,` : '' + }crop='min(${width},iw):min(${height},ih):0:0',pad=${width}:${height}:0:0${ + crop ? `,crop=${crop.width}:${crop.height}:${crop.x}:${crop.y}` : '' + }${scale ? `,scale=iw*${scale}:-1` : ''}`, + ], + 'pipe:1', + ].flat(), + {stdio: ['pipe', 'pipe', 'pipe']} + ); + this.#process.stdout.pipe(this); + this.#process.stderr.on('data', (data: Buffer) => { + debugFfmpeg(data.toString('utf8')); + }); + + this.#page = page; + + const {client} = this.#page.mainFrame(); + client.once(CDPSessionEvent.Disconnected, () => { + void this.stop().catch(debugError); + }); + + this.#lastFrame = lastValueFrom( + fromEmitterEvent(client, 'Page.screencastFrame').pipe( + tap(event => { + void client.send('Page.screencastFrameAck', { + sessionId: event.sessionId, + }); + }), + filter(event => { + return event.metadata.timestamp !== undefined; + }), + map(event => { + return { + buffer: Buffer.from(event.data, 'base64'), + timestamp: event.metadata.timestamp!, + }; + }), + bufferCount(2, 1) as OperatorFunction< + {buffer: Buffer; timestamp: number}, + [ + {buffer: Buffer; timestamp: number}, + {buffer: Buffer; timestamp: number}, + ] + >, + concatMap(([{timestamp: previousTimestamp, buffer}, {timestamp}]) => { + return from( + Array<Buffer>( + Math.round( + DEFAULT_FPS * Math.max(timestamp - previousTimestamp, 0) + ) + ).fill(buffer) + ); + }), + map(buffer => { + void this.#writeFrame(buffer); + return [buffer, performance.now()] as const; + }), + takeUntil(fromEvent(this.#controller.signal, 'abort')) + ), + {defaultValue: [Buffer.from([]), performance.now()] as const} + ); + } + + #getFormatArgs(format: 'webm' | 'gif') { + switch (format) { + case 'webm': + return [ + // Sets the codec to use. + ['-c:v', 'vp9'], + // Sets the format + ['-f', 'webm'], + // Sets the quality. Lower the better. + ['-crf', `${CRF_VALUE}`], + // Sets the quality and how efficient the compression will be. + ['-deadline', 'realtime', '-cpu-used', '8'], + ].flat(); + case 'gif': + return [ + // Sets the frame rate and uses a custom palette generated from the + // input. + [ + '-vf', + 'fps=5,split[s0][s1];[s0]palettegen=stats_mode=diff[p];[s1][p]paletteuse', + ], + // Sets the format + ['-f', 'gif'], + ].flat(); + } + } + + @guarded() + async #writeFrame(buffer: Buffer) { + const error = await new Promise<Error | null | undefined>(resolve => { + this.#process.stdin.write(buffer, resolve); + }); + if (error) { + console.log(`ffmpeg failed to write: ${error.message}.`); + } + } + + /** + * Stops the recorder. + * + * @public + */ + @guarded() + async stop(): Promise<void> { + if (this.#controller.signal.aborted) { + return; + } + // Stopping the screencast will flush the frames. + await this.#page._stopScreencast().catch(debugError); + + this.#controller.abort(); + + // Repeat the last frame for the remaining frames. + const [buffer, timestamp] = await this.#lastFrame; + await Promise.all( + Array<Buffer>( + Math.max( + 1, + Math.round((DEFAULT_FPS * (performance.now() - timestamp)) / 1000) + ) + ) + .fill(buffer) + .map(this.#writeFrame.bind(this)) + ); + + // Close stdin to notify FFmpeg we are done. + this.#process.stdin.end(); + await new Promise(resolve => { + this.#process.once('close', resolve); + }); + } + + /** + * @internal + */ + async [asyncDisposeSymbol](): Promise<void> { + await this.stop(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/node.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/node.ts new file mode 100644 index 0000000000..373449ec0f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/node.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './ChromeLauncher.js'; +export * from './FirefoxLauncher.js'; +export * from './LaunchOptions.js'; +export * from './PipeTransport.js'; +export * from './ProductLauncher.js'; +export * from './PuppeteerNode.js'; +export * from './ScreenRecorder.js'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/util/fs.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/util/fs.ts new file mode 100644 index 0000000000..d18c76d6dc --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/util/fs.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; + +const rmOptions = { + force: true, + recursive: true, + maxRetries: 5, +}; + +/** + * @internal + */ +export async function rm(path: string): Promise<void> { + await fs.promises.rm(path, rmOptions); +} + +/** + * @internal + */ +export function rmSync(path: string): void { + fs.rmSync(path, rmOptions); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/puppeteer-core.ts b/remote/test/puppeteer/packages/puppeteer-core/src/puppeteer-core.ts new file mode 100644 index 0000000000..d19162b4a3 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/puppeteer-core.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export type {Protocol} from 'devtools-protocol'; + +export * from './api/api.js'; +export * from './cdp/cdp.js'; +export * from './common/common.js'; +export * from './node/node.js'; +export * from './revisions.js'; +export * from './util/util.js'; + +/** + * @deprecated Use the query handler API defined on {@link Puppeteer} + */ +export * from './common/CustomQueryHandler.js'; + +import {PuppeteerNode} from './node/PuppeteerNode.js'; + +/** + * @public + */ +const puppeteer = new PuppeteerNode({ + isPuppeteerCore: true, +}); + +export const { + /** + * @public + */ + connect, + /** + * @public + */ + defaultArgs, + /** + * @public + */ + executablePath, + /** + * @public + */ + launch, +} = puppeteer; + +export default puppeteer; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/revisions.ts b/remote/test/puppeteer/packages/puppeteer-core/src/revisions.ts new file mode 100644 index 0000000000..37360204d8 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/revisions.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @internal + */ +export const PUPPETEER_REVISIONS = Object.freeze({ + chrome: '121.0.6167.85', + 'chrome-headless-shell': '121.0.6167.85', + firefox: 'latest', +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/templates/injected.ts.tmpl b/remote/test/puppeteer/packages/puppeteer-core/src/templates/injected.ts.tmpl new file mode 100644 index 0000000000..aa799e9fdb --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/templates/injected.ts.tmpl @@ -0,0 +1,8 @@ +/** + * JavaScript code that provides the puppeteer utilities. See the + * [README](https://github.com/puppeteer/puppeteer/blob/main/src/injected/README.md) + * for injection for more information. + * + * @internal + */ +export const source = SOURCE_CODE; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/templates/version.ts.tmpl b/remote/test/puppeteer/packages/puppeteer-core/src/templates/version.ts.tmpl new file mode 100644 index 0000000000..73b984d2ff --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/templates/version.ts.tmpl @@ -0,0 +1,4 @@ +/** + * @internal + */ +export const packageVersion = 'PACKAGE_VERSION'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/tsconfig.cjs.json b/remote/test/puppeteer/packages/puppeteer-core/src/tsconfig.cjs.json new file mode 100644 index 0000000000..897b1a03df --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node", + "outDir": "../lib/cjs/puppeteer" + }, + "references": [{"path": "../third_party/tsconfig.cjs.json"}] +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/tsconfig.esm.json b/remote/test/puppeteer/packages/puppeteer-core/src/tsconfig.esm.json new file mode 100644 index 0000000000..2cd2ab579f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/tsconfig.esm.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../lib/esm/puppeteer" + }, + "references": [{"path": "../third_party/tsconfig.json"}] +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/AsyncIterableUtil.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/AsyncIterableUtil.ts new file mode 100644 index 0000000000..4d96d0cdf4 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/AsyncIterableUtil.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type {AwaitableIterable} from '../common/types.js'; + +/** + * @internal + */ +export class AsyncIterableUtil { + static async *map<T, U>( + iterable: AwaitableIterable<T>, + map: (item: T) => Promise<U> + ): AsyncIterable<U> { + for await (const value of iterable) { + yield await map(value); + } + } + + static async *flatMap<T, U>( + iterable: AwaitableIterable<T>, + map: (item: T) => AwaitableIterable<U> + ): AsyncIterable<U> { + for await (const value of iterable) { + yield* map(value); + } + } + + static async collect<T>(iterable: AwaitableIterable<T>): Promise<T[]> { + const result = []; + for await (const value of iterable) { + result.push(value); + } + return result; + } + + static async first<T>( + iterable: AwaitableIterable<T> + ): Promise<T | undefined> { + for await (const value of iterable) { + return value; + } + return; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/Deferred.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/Deferred.test.ts new file mode 100644 index 0000000000..b989e3a888 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/Deferred.test.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, it} from 'node:test'; + +import expect from 'expect'; +import sinon from 'sinon'; + +import {Deferred} from './Deferred.js'; + +describe('DeferredPromise', function () { + it('should catch errors', async () => { + // Async function before try/catch. + async function task() { + await new Promise(resolve => { + return setTimeout(resolve, 50); + }); + } + // Async function that fails. + function fails(): Deferred<void> { + const deferred = Deferred.create<void>(); + setTimeout(() => { + deferred.reject(new Error('test')); + }, 25); + return deferred; + } + + const expectedToFail = fails(); + await task(); + let caught = false; + try { + await expectedToFail.valueOrThrow(); + } catch (err) { + expect((err as Error).message).toEqual('test'); + caught = true; + } + expect(caught).toBeTruthy(); + }); + + it('Deferred.race should cancel timeout', async function () { + const clock = sinon.useFakeTimers(); + + try { + const deferred = Deferred.create<void>(); + const deferredTimeout = Deferred.create<void>({ + message: 'Race did not stop timer', + timeout: 100, + }); + + clock.tick(50); + + await Promise.all([ + Deferred.race([deferred, deferredTimeout]), + deferred.resolve(), + ]); + + clock.tick(150); + + expect(deferredTimeout.value()).toBeInstanceOf(Error); + expect(deferredTimeout.value()?.message).toContain('Timeout cleared'); + } finally { + clock.restore(); + } + }); +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/Deferred.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/Deferred.ts new file mode 100644 index 0000000000..0dfb013bb3 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/Deferred.ts @@ -0,0 +1,122 @@ +import {TimeoutError} from '../common/Errors.js'; + +/** + * @internal + */ +export interface DeferredOptions { + message: string; + timeout: number; +} + +/** + * Creates and returns a deferred object along with the resolve/reject functions. + * + * If the deferred has not been resolved/rejected within the `timeout` period, + * the deferred gets resolves with a timeout error. `timeout` has to be greater than 0 or + * it is ignored. + * + * @internal + */ +export class Deferred<T, V extends Error = Error> { + static create<R, X extends Error = Error>( + opts?: DeferredOptions + ): Deferred<R, X> { + return new Deferred<R, X>(opts); + } + + static async race<R>( + awaitables: Array<Promise<R> | Deferred<R>> + ): Promise<R> { + const deferredWithTimeout = new Set<Deferred<R>>(); + try { + const promises = awaitables.map(value => { + if (value instanceof Deferred) { + if (value.#timeoutId) { + deferredWithTimeout.add(value); + } + + return value.valueOrThrow(); + } + + return value; + }); + // eslint-disable-next-line no-restricted-syntax + return await Promise.race(promises); + } finally { + for (const deferred of deferredWithTimeout) { + // We need to stop the timeout else + // Node.JS will keep running the event loop till the + // timer executes + deferred.reject(new Error('Timeout cleared')); + } + } + } + + #isResolved = false; + #isRejected = false; + #value: T | V | TimeoutError | undefined; + // SAFETY: This is ensured by #taskPromise. + #resolve!: (value: void) => void; + #taskPromise = new Promise<void>(resolve => { + this.#resolve = resolve; + }); + #timeoutId: ReturnType<typeof setTimeout> | undefined; + #timeoutError: TimeoutError | undefined; + + constructor(opts?: DeferredOptions) { + if (opts && opts.timeout > 0) { + this.#timeoutError = new TimeoutError(opts.message); + this.#timeoutId = setTimeout(() => { + this.reject(this.#timeoutError!); + }, opts.timeout); + } + } + + #finish(value: T | V | TimeoutError) { + clearTimeout(this.#timeoutId); + this.#value = value; + this.#resolve(); + } + + resolve(value: T): void { + if (this.#isRejected || this.#isResolved) { + return; + } + this.#isResolved = true; + this.#finish(value); + } + + reject(error: V | TimeoutError): void { + if (this.#isRejected || this.#isResolved) { + return; + } + this.#isRejected = true; + this.#finish(error); + } + + resolved(): boolean { + return this.#isResolved; + } + + finished(): boolean { + return this.#isResolved || this.#isRejected; + } + + value(): T | V | TimeoutError | undefined { + return this.#value; + } + + #promise: Promise<T> | undefined; + valueOrThrow(): Promise<T> { + if (!this.#promise) { + this.#promise = (async () => { + await this.#taskPromise; + if (this.#isRejected) { + throw this.#value; + } + return this.#value as T; + })(); + } + return this.#promise; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/ErrorLike.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/ErrorLike.ts new file mode 100644 index 0000000000..d4ab3044ab --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/ErrorLike.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ProtocolError} from '../common/Errors.js'; + +/** + * @internal + */ +export interface ErrorLike extends Error { + name: string; + message: string; +} + +/** + * @internal + */ +export function isErrorLike(obj: unknown): obj is ErrorLike { + return ( + typeof obj === 'object' && obj !== null && 'name' in obj && 'message' in obj + ); +} + +/** + * @internal + */ +export function isErrnoException(obj: unknown): obj is NodeJS.ErrnoException { + return ( + isErrorLike(obj) && + ('errno' in obj || 'code' in obj || 'path' in obj || 'syscall' in obj) + ); +} + +/** + * @internal + */ +export function rewriteError( + error: ProtocolError, + message: string, + originalMessage?: string +): Error { + error.message = message; + error.originalMessage = originalMessage ?? error.originalMessage; + return error; +} + +/** + * @internal + */ +export function createProtocolErrorMessage(object: { + error: {message: string; data: any; code: number}; +}): string { + let message = object.error.message; + // TODO: remove the type checks when we stop connecting to BiDi with a CDP + // client. + if ( + object.error && + typeof object.error === 'object' && + 'data' in object.error + ) { + message += ` ${object.error.data}`; + } + return message; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/Function.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/Function.test.ts new file mode 100644 index 0000000000..c6da4cdf27 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/Function.test.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, it} from 'node:test'; + +import expect from 'expect'; + +import {interpolateFunction} from './Function.js'; + +describe('Function', function () { + describe('interpolateFunction', function () { + it('should work', async () => { + const test = interpolateFunction( + () => { + const test = PLACEHOLDER('test') as () => number; + return test(); + }, + {test: `() => 5`} + ); + expect(test()).toBe(5); + }); + it('should work inlined', async () => { + const test = interpolateFunction( + () => { + // Note the parenthesis will be removed by the typescript compiler. + return (PLACEHOLDER('test') as () => number)(); + }, + {test: `() => 5`} + ); + expect(test()).toBe(5); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/Function.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/Function.ts new file mode 100644 index 0000000000..41db98830b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/Function.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +const createdFunctions = new Map<string, (...args: unknown[]) => unknown>(); + +/** + * Creates a function from a string. + * + * @internal + */ +export const createFunction = ( + functionValue: string +): ((...args: unknown[]) => unknown) => { + let fn = createdFunctions.get(functionValue); + if (fn) { + return fn; + } + fn = new Function(`return ${functionValue}`)() as ( + ...args: unknown[] + ) => unknown; + createdFunctions.set(functionValue, fn); + return fn; +}; + +/** + * @internal + */ +export function stringifyFunction(fn: (...args: never) => unknown): string { + let value = fn.toString(); + try { + new Function(`(${value})`); + } catch { + // This means we might have a function shorthand (e.g. `test(){}`). Let's + // try prefixing. + let prefix = 'function '; + if (value.startsWith('async ')) { + prefix = `async ${prefix}`; + value = value.substring('async '.length); + } + value = `${prefix}${value}`; + try { + new Function(`(${value})`); + } catch { + // We tried hard to serialize, but there's a weird beast here. + throw new Error('Passed function cannot be serialized!'); + } + } + return value; +} + +/** + * Replaces `PLACEHOLDER`s with the given replacements. + * + * All replacements must be valid JS code. + * + * @example + * + * ```ts + * interpolateFunction(() => PLACEHOLDER('test'), {test: 'void 0'}); + * // Equivalent to () => void 0 + * ``` + * + * @internal + */ +export const interpolateFunction = <T extends (...args: never[]) => unknown>( + fn: T, + replacements: Record<string, string> +): T => { + let value = stringifyFunction(fn); + for (const [name, jsValue] of Object.entries(replacements)) { + value = value.replace( + new RegExp(`PLACEHOLDER\\(\\s*(?:'${name}'|"${name}")\\s*\\)`, 'g'), + // Wrapping this ensures tersers that accidently inline PLACEHOLDER calls + // are still valid. Without, we may get calls like ()=>{...}() which is + // not valid. + `(${jsValue})` + ); + } + return createFunction(value) as unknown as T; +}; + +declare global { + /** + * Used for interpolation with {@link interpolateFunction}. + * + * @internal + */ + function PLACEHOLDER<T>(name: string): T; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/Mutex.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/Mutex.ts new file mode 100644 index 0000000000..9498bac306 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/Mutex.ts @@ -0,0 +1,41 @@ +import {Deferred} from './Deferred.js'; +import {disposeSymbol} from './disposable.js'; + +/** + * @internal + */ +export class Mutex { + static Guard = class Guard { + #mutex: Mutex; + constructor(mutex: Mutex) { + this.#mutex = mutex; + } + [disposeSymbol](): void { + return this.#mutex.release(); + } + }; + + #locked = false; + #acquirers: Array<() => void> = []; + + // This is FIFO. + async acquire(): Promise<InstanceType<typeof Mutex.Guard>> { + if (!this.#locked) { + this.#locked = true; + return new Mutex.Guard(this); + } + const deferred = Deferred.create<void>(); + this.#acquirers.push(deferred.resolve.bind(deferred)); + await deferred.valueOrThrow(); + return new Mutex.Guard(this); + } + + release(): void { + const resolve = this.#acquirers.shift(); + if (!resolve) { + this.#locked = false; + return; + } + resolve(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/assert.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/assert.ts new file mode 100644 index 0000000000..7800b3be40 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/assert.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Asserts that the given value is truthy. + * @param value - some conditional statement + * @param message - the error message to throw if the value is not truthy. + * + * @internal + */ +export const assert: (value: unknown, message?: string) => asserts value = ( + value, + message +) => { + if (!value) { + throw new Error(message); + } +}; 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 new file mode 100644 index 0000000000..4cdaf15d5b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.test.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, it} from 'node:test'; + +import expect from 'expect'; +import sinon from 'sinon'; + +import {invokeAtMostOnceForArguments} from './decorators.js'; + +describe('decorators', function () { + describe('invokeAtMostOnceForArguments', () => { + it('should delegate calls', () => { + const spy = sinon.spy(); + class Test { + @invokeAtMostOnceForArguments + test(obj1: object, obj2: object) { + spy(obj1, obj2); + } + } + const t = new Test(); + expect(spy.callCount).toBe(0); + const obj1 = {}; + const obj2 = {}; + t.test(obj1, obj2); + expect(spy.callCount).toBe(1); + }); + + it('should prevent repeated calls', () => { + const spy = sinon.spy(); + class Test { + @invokeAtMostOnceForArguments + test(obj1: object, obj2: object) { + spy(obj1, obj2); + } + } + const t = new Test(); + expect(spy.callCount).toBe(0); + const obj1 = {}; + const obj2 = {}; + t.test(obj1, obj2); + expect(spy.callCount).toBe(1); + expect(spy.lastCall.calledWith(obj1, obj2)).toBeTruthy(); + t.test(obj1, obj2); + expect(spy.callCount).toBe(1); + expect(spy.lastCall.calledWith(obj1, obj2)).toBeTruthy(); + const obj3 = {}; + t.test(obj1, obj3); + expect(spy.callCount).toBe(2); + expect(spy.lastCall.calledWith(obj1, obj3)).toBeTruthy(); + }); + + it('should throw an error for dynamic argumetns', () => { + class Test { + @invokeAtMostOnceForArguments + test(..._args: unknown[]) {} + } + const t = new Test(); + t.test({}); + expect(() => { + t.test({}, {}); + }).toThrow(); + }); + + it('should throw an error for non object arguments', () => { + class Test { + @invokeAtMostOnceForArguments + test(..._args: unknown[]) {} + } + const t = new Test(); + expect(() => { + t.test(1); + }).toThrow(); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.ts new file mode 100644 index 0000000000..af21c5fe29 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.ts @@ -0,0 +1,140 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Disposed, Moveable} from '../common/types.js'; + +import {asyncDisposeSymbol, disposeSymbol} from './disposable.js'; +import {Mutex} from './Mutex.js'; + +const instances = new WeakSet<object>(); + +export function moveable< + Class extends abstract new (...args: never[]) => Moveable, +>(Class: Class, _: ClassDecoratorContext<Class>): Class { + let hasDispose = false; + if (Class.prototype[disposeSymbol]) { + const dispose = Class.prototype[disposeSymbol]; + Class.prototype[disposeSymbol] = function (this: InstanceType<Class>) { + if (instances.has(this)) { + instances.delete(this); + return; + } + return dispose.call(this); + }; + hasDispose = true; + } + if (Class.prototype[asyncDisposeSymbol]) { + const asyncDispose = Class.prototype[asyncDisposeSymbol]; + Class.prototype[asyncDisposeSymbol] = function (this: InstanceType<Class>) { + if (instances.has(this)) { + instances.delete(this); + return; + } + return asyncDispose.call(this); + }; + hasDispose = true; + } + if (hasDispose) { + Class.prototype.move = function ( + this: InstanceType<Class> + ): InstanceType<Class> { + instances.add(this); + return this; + }; + } + return Class; +} + +export function throwIfDisposed<This extends Disposed>( + message: (value: This) => string = value => { + return `Attempted to use disposed ${value.constructor.name}.`; + } +) { + return (target: (this: This, ...args: any[]) => any, _: unknown) => { + return function (this: This, ...args: any[]): any { + if (this.disposed) { + throw new Error(message(this)); + } + return target.call(this, ...args); + }; + }; +} + +export function inertIfDisposed<This extends Disposed>( + target: (this: This, ...args: any[]) => any, + _: unknown +) { + return function (this: This, ...args: any[]): any { + if (this.disposed) { + return; + } + return target.call(this, ...args); + }; +} + +/** + * The decorator only invokes the target if the target has not been invoked with + * the same arguments before. The decorated method throws an error if it's + * invoked with a different number of elements: if you decorate a method, it + * should have the same number of arguments + * + * @internal + */ +export function invokeAtMostOnceForArguments( + target: (this: unknown, ...args: any[]) => any, + _: unknown +): typeof target { + const cache = new WeakMap(); + let cacheDepth = -1; + return function (this: unknown, ...args: unknown[]) { + if (cacheDepth === -1) { + cacheDepth = args.length; + } + if (cacheDepth !== args.length) { + throw new Error( + 'Memoized method was called with the wrong number of arguments' + ); + } + let freshArguments = false; + let cacheIterator = cache; + for (const arg of args) { + if (cacheIterator.has(arg as object)) { + cacheIterator = cacheIterator.get(arg as object)!; + } else { + freshArguments = true; + cacheIterator.set(arg as object, new WeakMap()); + cacheIterator = cacheIterator.get(arg as object)!; + } + } + if (!freshArguments) { + return; + } + return target.call(this, ...args); + }; +} + +export function guarded<T extends object>( + getKey = function (this: T): object { + return this; + } +) { + return ( + target: (this: T, ...args: any[]) => Promise<any>, + _: ClassMethodDecoratorContext<T> + ): typeof target => { + const mutexes = new WeakMap<object, Mutex>(); + return async function (...args) { + const key = getKey.call(this); + let mutex = mutexes.get(key); + if (!mutex) { + mutex = new Mutex(); + mutexes.set(key, mutex); + } + await using _ = await mutex.acquire(); + return await target.call(this, ...args); + }; + }; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/disposable.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/disposable.ts new file mode 100644 index 0000000000..a1848f3860 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/disposable.ts @@ -0,0 +1,275 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +declare global { + interface SymbolConstructor { + /** + * A method that is used to release resources held by an object. Called by + * the semantics of the `using` statement. + */ + readonly dispose: unique symbol; + + /** + * A method that is used to asynchronously release resources held by an + * object. Called by the semantics of the `await using` statement. + */ + readonly asyncDispose: unique symbol; + } + + interface Disposable { + [Symbol.dispose](): void; + } + + interface AsyncDisposable { + [Symbol.asyncDispose](): PromiseLike<void>; + } +} + +(Symbol as any).dispose ??= Symbol('dispose'); +(Symbol as any).asyncDispose ??= Symbol('asyncDispose'); + +/** + * @internal + */ +export const disposeSymbol: typeof Symbol.dispose = Symbol.dispose; + +/** + * @internal + */ +export const asyncDisposeSymbol: typeof Symbol.asyncDispose = + Symbol.asyncDispose; + +/** + * @internal + */ +export class DisposableStack { + #disposed = false; + #stack: Disposable[] = []; + + /** + * Returns a value indicating whether this stack has been disposed. + */ + get disposed(): boolean { + return this.#disposed; + } + + /** + * Disposes each resource in the stack in the reverse order that they were added. + */ + dispose(): void { + if (this.#disposed) { + return; + } + this.#disposed = true; + for (const resource of this.#stack.reverse()) { + resource[disposeSymbol](); + } + } + + /** + * Adds a disposable resource to the stack, returning the resource. + * + * @param value - The resource to add. `null` and `undefined` will not be added, + * but will be returned. + * @returns The provided `value`. + */ + use<T extends Disposable | null | undefined>(value: T): T { + if (value) { + this.#stack.push(value); + } + return value; + } + + /** + * Adds a value and associated disposal callback as a resource to the stack. + * + * @param value - The value to add. + * @param onDispose - The callback to use in place of a `[disposeSymbol]()` + * method. Will be invoked with `value` as the first parameter. + * @returns The provided `value`. + */ + adopt<T>(value: T, onDispose: (value: T) => void): T { + this.#stack.push({ + [disposeSymbol]() { + onDispose(value); + }, + }); + return value; + } + + /** + * Adds a callback to be invoked when the stack is disposed. + */ + defer(onDispose: () => void): void { + this.#stack.push({ + [disposeSymbol]() { + onDispose(); + }, + }); + } + + /** + * Move all resources out of this stack and into a new `DisposableStack`, and + * marks this stack as disposed. + * + * @example + * + * ```ts + * class C { + * #res1: Disposable; + * #res2: Disposable; + * #disposables: DisposableStack; + * constructor() { + * // stack will be disposed when exiting constructor for any reason + * using stack = new DisposableStack(); + * + * // get first resource + * this.#res1 = stack.use(getResource1()); + * + * // get second resource. If this fails, both `stack` and `#res1` will be disposed. + * this.#res2 = stack.use(getResource2()); + * + * // all operations succeeded, move resources out of `stack` so that + * // they aren't disposed when constructor exits + * this.#disposables = stack.move(); + * } + * + * [disposeSymbol]() { + * this.#disposables.dispose(); + * } + * } + * ``` + */ + move(): DisposableStack { + if (this.#disposed) { + throw new ReferenceError('a disposed stack can not use anything new'); // step 3 + } + const stack = new DisposableStack(); // step 4-5 + stack.#stack = this.#stack; + this.#disposed = true; + return stack; + } + + [disposeSymbol] = this.dispose; + + readonly [Symbol.toStringTag] = 'DisposableStack'; +} + +/** + * @internal + */ +export class AsyncDisposableStack { + #disposed = false; + #stack: AsyncDisposable[] = []; + + /** + * Returns a value indicating whether this stack has been disposed. + */ + get disposed(): boolean { + return this.#disposed; + } + + /** + * Disposes each resource in the stack in the reverse order that they were added. + */ + async dispose(): Promise<void> { + if (this.#disposed) { + return; + } + this.#disposed = true; + for (const resource of this.#stack.reverse()) { + await resource[asyncDisposeSymbol](); + } + } + + /** + * Adds a disposable resource to the stack, returning the resource. + * + * @param value - The resource to add. `null` and `undefined` will not be added, + * but will be returned. + * @returns The provided `value`. + */ + use<T extends AsyncDisposable | null | undefined>(value: T): T { + if (value) { + this.#stack.push(value); + } + return value; + } + + /** + * Adds a value and associated disposal callback as a resource to the stack. + * + * @param value - The value to add. + * @param onDispose - The callback to use in place of a `[disposeSymbol]()` + * method. Will be invoked with `value` as the first parameter. + * @returns The provided `value`. + */ + adopt<T>(value: T, onDispose: (value: T) => Promise<void>): T { + this.#stack.push({ + [asyncDisposeSymbol]() { + return onDispose(value); + }, + }); + return value; + } + + /** + * Adds a callback to be invoked when the stack is disposed. + */ + defer(onDispose: () => Promise<void>): void { + this.#stack.push({ + [asyncDisposeSymbol]() { + return onDispose(); + }, + }); + } + + /** + * Move all resources out of this stack and into a new `DisposableStack`, and + * marks this stack as disposed. + * + * @example + * + * ```ts + * class C { + * #res1: Disposable; + * #res2: Disposable; + * #disposables: DisposableStack; + * constructor() { + * // stack will be disposed when exiting constructor for any reason + * using stack = new DisposableStack(); + * + * // get first resource + * this.#res1 = stack.use(getResource1()); + * + * // get second resource. If this fails, both `stack` and `#res1` will be disposed. + * this.#res2 = stack.use(getResource2()); + * + * // all operations succeeded, move resources out of `stack` so that + * // they aren't disposed when constructor exits + * this.#disposables = stack.move(); + * } + * + * [disposeSymbol]() { + * this.#disposables.dispose(); + * } + * } + * ``` + */ + move(): AsyncDisposableStack { + if (this.#disposed) { + throw new ReferenceError('a disposed stack can not use anything new'); // step 3 + } + const stack = new AsyncDisposableStack(); // step 4-5 + stack.#stack = this.#stack; + this.#disposed = true; + return stack; + } + + [asyncDisposeSymbol] = this.dispose; + + readonly [Symbol.toStringTag] = 'AsyncDisposableStack'; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/util.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/util.ts new file mode 100644 index 0000000000..f55610da9e --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/util.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './assert.js'; +export * from './Deferred.js'; +export * from './ErrorLike.js'; +export * from './AsyncIterableUtil.js'; +export * from './disposable.js'; 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 new file mode 100644 index 0000000000..c20aaa8342 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/third_party/mitt/mitt.ts @@ -0,0 +1,8 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +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 new file mode 100644 index 0000000000..b8b64788ae --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/third_party/rxjs/rxjs.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +export { + bufferCount, + catchError, + concat, + concatMap, + defaultIfEmpty, + defer, + delay, + EMPTY, + filter, + first, + firstValueFrom, + forkJoin, + from, + fromEvent, + identity, + ignoreElements, + lastValueFrom, + map, + merge, + mergeMap, + NEVER, + noop, + Observable, + of, + pipe, + race, + raceWith, + retry, + startWith, + switchMap, + takeUntil, + tap, + throwIfEmpty, + timer, + zip, +} 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.cjs.json b/remote/test/puppeteer/packages/puppeteer-core/third_party/tsconfig.cjs.json new file mode 100644 index 0000000000..a796932cd8 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/third_party/tsconfig.cjs.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "../lib/cjs/third_party", + "declarationMap": false, + "sourceMap": false + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/third_party/tsconfig.json b/remote/test/puppeteer/packages/puppeteer-core/third_party/tsconfig.json new file mode 100644 index 0000000000..25c438c57d --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/third_party/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "declarationMap": false, + "outDir": "../lib/esm/third_party", + "sourceMap": false, + }, +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/tools/ensure-correct-devtools-protocol-package.ts b/remote/test/puppeteer/packages/puppeteer-core/tools/ensure-correct-devtools-protocol-package.ts new file mode 100644 index 0000000000..ca230716b3 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/tools/ensure-correct-devtools-protocol-package.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * This script ensures that the pinned version of devtools-protocol in + * package.json is the right version for the current revision of Chrome that + * Puppeteer ships with. + * + * The devtools-protocol package publisher runs every hour and checks if there + * are protocol changes. If there are, it will be versioned with the revision + * number of the commit that last changed the .pdl files. + * + * Chrome branches/releases are figured out at a later point in time, so it's + * not true that each Chrome revision will have an exact matching revision + * version of devtools-protocol. To ensure we're using a devtools-protocol that + * is aligned with our revision, we want to find the largest package number + * that's \<= the revision that Puppeteer is using. + * + * This script uses npm's `view` function to list all versions in a range and + * find the one closest to our Chrome revision. + */ + +import {execSync} from 'child_process'; + +import packageJson from '../package.json' assert {type: 'json'}; +import {PUPPETEER_REVISIONS} from '../src/revisions.js'; + +async function main() { + const currentProtocolPackageInstalledVersion = + packageJson.dependencies['devtools-protocol']; + + /** + * Ensure that the devtools-protocol version is pinned. + */ + if (/^[^0-9]/.test(currentProtocolPackageInstalledVersion)) { + console.log( + `ERROR: devtools-protocol package is not pinned to a specific version.\n` + ); + process.exit(1); + } + + const chromeVersion = PUPPETEER_REVISIONS.chrome; + // find the right revision for our Chrome version. + const req = await fetch( + `https://googlechromelabs.github.io/chrome-for-testing/known-good-versions.json` + ); + const releases = await req.json(); + const chromeRevision = releases.versions.find(release => { + return release.version === chromeVersion; + }).revision; + console.log(`Revisions for ${chromeVersion}: ${chromeRevision}`); + + const command = `npm view "devtools-protocol@<=0.0.${chromeRevision}" version | tail -1`; + + console.log( + 'Checking npm for devtools-protocol revisions:\n', + `'${command}'`, + '\n' + ); + + const output = execSync(command, { + encoding: 'utf8', + }); + + const bestRevisionFromNpm = output.split(' ')[1]!.replace(/'|\n/g, ''); + + if (currentProtocolPackageInstalledVersion !== bestRevisionFromNpm) { + console.log(`ERROR: bad devtools-protocol revision detected: + + Current Puppeteer Chrome revision: ${chromeRevision} + Current devtools-protocol version in package.json: ${currentProtocolPackageInstalledVersion} + Expected devtools-protocol version: ${bestRevisionFromNpm}`); + + process.exit(1); + } + + console.log( + `Correct devtools-protocol version found (${bestRevisionFromNpm}).` + ); + process.exit(0); +} + +void main(); diff --git a/remote/test/puppeteer/packages/puppeteer-core/tsconfig.json b/remote/test/puppeteer/packages/puppeteer-core/tsconfig.json new file mode 100644 index 0000000000..b662532a01 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "references": [ + {"path": "src/tsconfig.esm.json"}, + {"path": "src/tsconfig.cjs.json"}, + ], +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/tsdoc.json b/remote/test/puppeteer/packages/puppeteer-core/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} diff --git a/remote/test/puppeteer/packages/puppeteer/.gitignore b/remote/test/puppeteer/packages/puppeteer/.gitignore new file mode 100644 index 0000000000..42061c01a1 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/.gitignore @@ -0,0 +1 @@ +README.md
\ No newline at end of file diff --git a/remote/test/puppeteer/packages/puppeteer/CHANGELOG.md b/remote/test/puppeteer/packages/puppeteer/CHANGELOG.md new file mode 100644 index 0000000000..c3d834c5f5 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/CHANGELOG.md @@ -0,0 +1,2096 @@ +# Changelog + +All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @puppeteer/browsers bumped from 0.3.0 to 0.3.1 + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.1.1 to 20.1.2 + * @puppeteer/browsers bumped from 1.0.1 to 1.1.0 + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.8.1 to 20.8.2 + * @puppeteer/browsers bumped from 1.4.4 to 1.4.5 + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.0.2 to 21.0.3 + * @puppeteer/browsers bumped from 1.5.1 to 1.6.0 + +## [21.10.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.9.0...puppeteer-v21.10.0) (2024-01-29) + + +### Features + +* download chrome-headless-shell by default and use it for the old headless mode ([#11754](https://github.com/puppeteer/puppeteer/issues/11754)) ([ce894a2](https://github.com/puppeteer/puppeteer/commit/ce894a2ffce4bc44bd11f12d1f0543e003a97e02)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.9.0 to 21.10.0 + +## [21.9.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.8.0...puppeteer-v21.9.0) (2024-01-24) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.8.0 to 21.9.0 + +## [21.8.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.7.0...puppeteer-v21.8.0) (2024-01-24) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.7.0 to 21.8.0 + +## [21.7.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.6.1...puppeteer-v21.7.0) (2024-01-04) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.6.1 to 21.7.0 + * @puppeteer/browsers bumped from 1.9.0 to 1.9.1 + +## [21.6.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.6.0...puppeteer-v21.6.1) (2023-12-13) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.6.0 to 21.6.1 + +## [21.6.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.5.2...puppeteer-v21.6.0) (2023-12-05) + + +### Features + +* implement the Puppeteer CLI ([#11344](https://github.com/puppeteer/puppeteer/issues/11344)) ([53fb69b](https://github.com/puppeteer/puppeteer/commit/53fb69bf7f2bf06fa4fd7bb6d3cf21382386f6e7)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.5.2 to 21.6.0 + * @puppeteer/browsers bumped from 1.8.0 to 1.9.0 + +## [21.5.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.5.1...puppeteer-v21.5.2) (2023-11-15) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.5.1 to 21.5.2 + +## [21.5.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.5.0...puppeteer-v21.5.1) (2023-11-09) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.5.0 to 21.5.1 + +## [21.5.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.4.1...puppeteer-v21.5.0) (2023-11-02) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.4.1 to 21.5.0 + +## [21.4.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.4.0...puppeteer-v21.4.1) (2023-10-23) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.4.0 to 21.4.1 + +## [21.4.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.3.8...puppeteer-v21.4.0) (2023-10-20) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.3.8 to 21.4.0 + * @puppeteer/browsers bumped from 1.7.1 to 1.8.0 + +## [21.3.8](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.3.7...puppeteer-v21.3.8) (2023-10-06) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.3.7 to 21.3.8 + +## [21.3.7](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.3.6...puppeteer-v21.3.7) (2023-10-05) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.3.6 to 21.3.7 + +## [21.3.6](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.3.5...puppeteer-v21.3.6) (2023-09-28) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.3.5 to 21.3.6 + +## [21.3.5](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.3.4...puppeteer-v21.3.5) (2023-09-26) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.3.4 to 21.3.5 + +## [21.3.4](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.3.3...puppeteer-v21.3.4) (2023-09-22) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.3.3 to 21.3.4 + +## [21.3.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.3.2...puppeteer-v21.3.3) (2023-09-22) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.3.2 to 21.3.3 + +## [21.3.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.3.1...puppeteer-v21.3.2) (2023-09-22) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.3.1 to 21.3.2 + +## [21.3.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.3.0...puppeteer-v21.3.1) (2023-09-19) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.3.0 to 21.3.1 + +## [21.3.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.2.1...puppeteer-v21.3.0) (2023-09-19) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.2.1 to 21.3.0 + +## [21.2.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.2.0...puppeteer-v21.2.1) (2023-09-13) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.2.0 to 21.2.1 + * @puppeteer/browsers bumped from 1.7.0 to 1.7.1 + +## [21.2.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.1.1...puppeteer-v21.2.0) (2023-09-12) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.1.1 to 21.2.0 + +## [21.1.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.1.0...puppeteer-v21.1.1) (2023-08-28) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.1.0 to 21.1.1 + +## [21.1.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.0.3...puppeteer-v21.1.0) (2023-08-18) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.0.3 to 21.1.0 + * @puppeteer/browsers bumped from 1.6.0 to 1.7.0 + +## [21.0.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.0.1...puppeteer-v21.0.2) (2023-08-08) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.0.1 to 21.0.2 + * @puppeteer/browsers bumped from 1.5.0 to 1.5.1 + +## [21.0.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v21.0.0...puppeteer-v21.0.1) (2023-08-03) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 21.0.0 to 21.0.1 + +## [21.0.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.9.0...puppeteer-v21.0.0) (2023-08-02) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.9.0 to 21.0.0 + * @puppeteer/browsers bumped from 1.4.6 to 1.5.0 + +## [20.9.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.8.3...puppeteer-v20.9.0) (2023-07-20) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.8.3 to 20.9.0 + * @puppeteer/browsers bumped from 1.4.5 to 1.4.6 + +## [20.8.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.8.2...puppeteer-v20.8.3) (2023-07-18) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.8.2 to 20.8.3 + +## [20.8.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.8.0...puppeteer-v20.8.1) (2023-07-11) + + +### Bug Fixes + +* remove test metadata files ([#10520](https://github.com/puppeteer/puppeteer/issues/10520)) ([cbf4f2a](https://github.com/puppeteer/puppeteer/commit/cbf4f2a66912f24849ae8c88fc1423851dcc4aa7)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.8.0 to 20.8.1 + * @puppeteer/browsers bumped from 1.4.3 to 1.4.4 + +## [20.8.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.7.4...puppeteer-v20.8.0) (2023-07-06) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.7.4 to 20.8.0 + +## [20.7.4](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.7.3...puppeteer-v20.7.4) (2023-06-29) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.7.3 to 20.7.4 + * @puppeteer/browsers bumped from 1.4.2 to 1.4.3 + +## [20.7.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.7.2...puppeteer-v20.7.3) (2023-06-20) + + +### Bug Fixes + +* include src into published package ([#10415](https://github.com/puppeteer/puppeteer/issues/10415)) ([d1ffad0](https://github.com/puppeteer/puppeteer/commit/d1ffad059ae66104842b92dc814d362c123b9646)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.7.2 to 20.7.3 + * @puppeteer/browsers bumped from 1.4.1 to 1.4.2 + +## [20.7.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.7.1...puppeteer-v20.7.2) (2023-06-16) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.7.1 to 20.7.2 + +## [20.7.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.7.0...puppeteer-v20.7.1) (2023-06-13) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.7.0 to 20.7.1 + +## [20.7.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.6.0...puppeteer-v20.7.0) (2023-06-13) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.6.0 to 20.7.0 + +## [20.6.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.5.0...puppeteer-v20.6.0) (2023-06-09) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.5.0 to 20.6.0 + +## [20.5.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.4.0...puppeteer-v20.5.0) (2023-05-31) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.4.0 to 20.5.0 + * @puppeteer/browsers bumped from 1.4.0 to 1.4.1 + +## [20.4.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.3.0...puppeteer-v20.4.0) (2023-05-24) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.3.0 to 20.4.0 + * @puppeteer/browsers bumped from 1.3.0 to 1.4.0 + +## [20.3.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.2.1...puppeteer-v20.3.0) (2023-05-22) + + +### Features + +* add an ability to trim cache for Puppeteer ([#10199](https://github.com/puppeteer/puppeteer/issues/10199)) ([1ad32ec](https://github.com/puppeteer/puppeteer/commit/1ad32ec9948ca3e07e15548a562c8f3c633b3dc3)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.2.1 to 20.3.0 + +## [20.2.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.2.0...puppeteer-v20.2.1) (2023-05-15) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.2.0 to 20.2.1 + * @puppeteer/browsers bumped from 1.2.0 to 1.3.0 + +## [20.2.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.1.2...puppeteer-v20.2.0) (2023-05-11) + + +### Bug Fixes + +* downloadPath should be used by the install script ([#10163](https://github.com/puppeteer/puppeteer/issues/10163)) ([4398f66](https://github.com/puppeteer/puppeteer/commit/4398f66f281f1ffe5be81b529fc4751edfaf761d)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.1.2 to 20.2.0 + * @puppeteer/browsers bumped from 1.1.0 to 1.2.0 + +## [20.1.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.1.0...puppeteer-v20.1.1) (2023-05-05) + + +### Bug Fixes + +* rename PUPPETEER_DOWNLOAD_HOST to PUPPETEER_DOWNLOAD_BASE_URL ([#10130](https://github.com/puppeteer/puppeteer/issues/10130)) ([9758cae](https://github.com/puppeteer/puppeteer/commit/9758cae029f90908c4b5340561d9c51c26aa2f21)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.1.0 to 20.1.1 + * @puppeteer/browsers bumped from 1.0.0 to 1.0.1 + +## [20.1.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v20.0.0...puppeteer-v20.1.0) (2023-05-03) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 20.0.0 to 20.1.0 + +## [20.0.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.11.1...puppeteer-v20.0.0) (2023-05-02) + + +### ⚠ BREAKING CHANGES + +* switch to Chrome for Testing instead of Chromium ([#10054](https://github.com/puppeteer/puppeteer/issues/10054)) + +### Features + +* switch to Chrome for Testing instead of Chromium ([#10054](https://github.com/puppeteer/puppeteer/issues/10054)) ([df4d60c](https://github.com/puppeteer/puppeteer/commit/df4d60c187aa11c4ad783827242e9511f4ec2aab)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.11.1 to 20.0.0 + * @puppeteer/browsers bumped from 0.5.0 to 1.0.0 + +## [19.11.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.11.0...puppeteer-v19.11.1) (2023-04-25) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.11.0 to 19.11.1 + +## [19.11.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.10.1...puppeteer-v19.11.0) (2023-04-24) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.10.1 to 19.11.0 + +## [19.10.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.10.0...puppeteer-v19.10.1) (2023-04-21) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.10.0 to 19.10.1 + * @puppeteer/browsers bumped from 0.4.1 to 0.5.0 + +## [19.10.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.9.1...puppeteer-v19.10.0) (2023-04-20) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.9.1 to 19.10.0 + +## [19.9.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.9.0...puppeteer-v19.9.1) (2023-04-17) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.9.0 to 19.9.1 + +## [19.9.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.8.5...puppeteer-v19.9.0) (2023-04-13) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.8.5 to 19.9.0 + * @puppeteer/browsers bumped from 0.4.0 to 0.4.1 + +## [19.8.5](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.8.4...puppeteer-v19.8.5) (2023-04-06) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.8.4 to 19.8.5 + * @puppeteer/browsers bumped from 0.3.3 to 0.4.0 + +## [19.8.4](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.8.3...puppeteer-v19.8.4) (2023-04-06) + + +### Bug Fixes + +* consider downloadHost as baseUrl ([#9973](https://github.com/puppeteer/puppeteer/issues/9973)) ([05a44af](https://github.com/puppeteer/puppeteer/commit/05a44afe5affcac9fe0f0a2e83f17807c99b2f0c)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.8.3 to 19.8.4 + * @puppeteer/browsers bumped from 0.3.2 to 0.3.3 + +## [19.8.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.8.2...puppeteer-v19.8.3) (2023-04-03) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.8.1 to 19.8.3 + * @puppeteer/browsers bumped from 0.3.1 to 0.3.2 + +## [19.8.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.8.0...puppeteer-v19.8.1) (2023-03-28) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.8.0 to 19.8.1 + +## [19.8.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.7.5...puppeteer-v19.8.0) (2023-03-24) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.7.5 to 19.8.0 + +## [19.7.5](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.7.4...puppeteer-v19.7.5) (2023-03-14) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.7.4 to 19.7.5 + +## [19.7.4](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.7.3...puppeteer-v19.7.4) (2023-03-10) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.7.3 to 19.7.4 + +## [19.7.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.7.2...puppeteer-v19.7.3) (2023-03-06) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.7.2 to 19.7.3 + +## [19.7.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.7.1...puppeteer-v19.7.2) (2023-02-20) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.7.1 to 19.7.2 + +## [19.7.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.7.0...puppeteer-v19.7.1) (2023-02-15) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.7.0 to 19.7.1 + +## [19.7.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.6.3...puppeteer-v19.7.0) (2023-02-13) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.6.3 to 19.7.0 + +## [19.6.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.6.2...puppeteer-v19.6.3) (2023-02-01) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.6.2 to 19.6.3 + +## [19.6.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.6.1...puppeteer-v19.6.2) (2023-01-27) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.6.1 to 19.6.2 + +## [19.6.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.6.0...puppeteer-v19.6.1) (2023-01-26) + + +### Bug Fixes + +* don't clean up previous browser versions ([#9568](https://github.com/puppeteer/puppeteer/issues/9568)) ([344bc2a](https://github.com/puppeteer/puppeteer/commit/344bc2af62e4068fe2cb8162d4b6c8242aac843b)), closes [#9533](https://github.com/puppeteer/puppeteer/issues/9533) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.6.0 to 19.6.1 + +## [19.6.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.5.2...puppeteer-v19.6.0) (2023-01-23) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.5.2 to 19.6.0 + +## [19.5.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.5.1...puppeteer-v19.5.2) (2023-01-11) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.5.1 to 19.5.2 + +## [19.5.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.5.0...puppeteer-v19.5.1) (2023-01-11) + + +### Bug Fixes + +* use puppeteer node for installation script ([#9489](https://github.com/puppeteer/puppeteer/issues/9489)) ([9bf90d9](https://github.com/puppeteer/puppeteer/commit/9bf90d9f4b5aeab06f8b433714712cad3259d36e)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.5.0 to 19.5.1 + +## [19.5.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.4.1...puppeteer-v19.5.0) (2023-01-05) + + +### Features + +* Default to not downloading if explicit browser path is set ([#9440](https://github.com/puppeteer/puppeteer/issues/9440)) ([d2536d7](https://github.com/puppeteer/puppeteer/commit/d2536d7cf5fa731250bbfd0d18959cacc8afffac)), closes [#9419](https://github.com/puppeteer/puppeteer/issues/9419) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.4.1 to 19.5.0 + +## [19.4.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.4.0...puppeteer-v19.4.1) (2022-12-16) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.4.0 to 19.4.1 + +## [19.4.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.3.0...puppeteer-v19.4.0) (2022-12-07) + + +### Features + +* **chromium:** roll to Chromium 109.0.5412.0 (r1069273) ([#9364](https://github.com/puppeteer/puppeteer/issues/9364)) ([1875da6](https://github.com/puppeteer/puppeteer/commit/1875da61916df1fbcf98047858c01075bd9af189)), closes [#9233](https://github.com/puppeteer/puppeteer/issues/9233) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.3.0 to 19.4.0 + +## [19.3.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v19.2.2...puppeteer-v19.3.0) (2022-11-23) + + +### Miscellaneous Chores + +* **puppeteer:** Synchronize puppeteer versions + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.2.2 to 19.3.0 + +## [19.2.2](https://github.com/puppeteer/puppeteer/compare/v19.2.1...v19.2.2) (2022-11-03) + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.2.1 to ^19.2.2 + +## [19.2.1](https://github.com/puppeteer/puppeteer/compare/v19.2.0...v19.2.1) (2022-10-28) + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.2.0 to ^19.2.1 + +## [19.2.0](https://github.com/puppeteer/puppeteer/compare/v19.1.2...v19.2.0) (2022-10-26) + + +### Features + +* **chromium:** roll to Chromium 108.0.5351.0 (r1056772) ([#9153](https://github.com/puppeteer/puppeteer/issues/9153)) ([e78a4e8](https://github.com/puppeteer/puppeteer/commit/e78a4e89c22bb1180e72d180c16b39673ff9125e)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.1.1 to ^19.2.0 + +## [19.1.2](https://github.com/puppeteer/puppeteer/compare/v19.1.1...v19.1.2) (2022-10-25) + + +### Bug Fixes + +* skip browser download ([#9160](https://github.com/puppeteer/puppeteer/issues/9160)) ([2245d7d](https://github.com/puppeteer/puppeteer/commit/2245d7d6ed0630ee1ad985dcbd48354772924750)) + +## [19.1.1](https://github.com/puppeteer/puppeteer/compare/v19.1.0...v19.1.1) (2022-10-21) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.1.0 to ^19.1.1 + +## [19.1.0](https://github.com/puppeteer/puppeteer/compare/v19.0.0...v19.1.0) (2022-10-21) + + +### Features + +* use configuration files ([#9140](https://github.com/puppeteer/puppeteer/issues/9140)) ([ec20174](https://github.com/puppeteer/puppeteer/commit/ec201744f077987b288e3dff52c0906fe700f6fb)), closes [#9128](https://github.com/puppeteer/puppeteer/issues/9128) + + +### Bug Fixes + +* update `BrowserFetcher` deprecation message ([#9141](https://github.com/puppeteer/puppeteer/issues/9141)) ([efcbc97](https://github.com/puppeteer/puppeteer/commit/efcbc97c60e4cfd49a9ed25a900f6133d06b290b)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 19.0.0 to ^19.1.0 + +## [19.0.0](https://github.com/puppeteer/puppeteer/compare/v18.2.1...v19.0.0) (2022-10-14) + + +### ⚠ BREAKING CHANGES + +* use `~/.cache/puppeteer` for browser downloads (#9095) +* deprecate `createBrowserFetcher` in favor of `BrowserFetcher` (#9079) +* refactor custom query handler API (#9078) +* remove `puppeteer.devices` in favor of `KnownDevices` (#9075) +* deprecate indirect network condition imports (#9074) + +### Features + +* deprecate `createBrowserFetcher` in favor of `BrowserFetcher` ([#9079](https://github.com/puppeteer/puppeteer/issues/9079)) ([7294dfe](https://github.com/puppeteer/puppeteer/commit/7294dfe9c6c3b224f95ba6d59b5ef33d379fd09a)), closes [#8999](https://github.com/puppeteer/puppeteer/issues/8999) +* use `~/.cache/puppeteer` for browser downloads ([#9095](https://github.com/puppeteer/puppeteer/issues/9095)) ([3df375b](https://github.com/puppeteer/puppeteer/commit/3df375baedad64b8773bb1e1e6f81b604ed18989)) + + +### Bug Fixes + +* deprecate indirect network condition imports ([#9074](https://github.com/puppeteer/puppeteer/issues/9074)) ([41d0122](https://github.com/puppeteer/puppeteer/commit/41d0122b94f41b308536c48ced345dec8c272a49)) +* refactor custom query handler API ([#9078](https://github.com/puppeteer/puppeteer/issues/9078)) ([1847704](https://github.com/puppeteer/puppeteer/commit/1847704789e2888c755de8c739d567364b8ad645)) +* remove `puppeteer.devices` in favor of `KnownDevices` ([#9075](https://github.com/puppeteer/puppeteer/issues/9075)) ([87c08fd](https://github.com/puppeteer/puppeteer/commit/87c08fd86a79b63308ad8d46c5f7acd1927505f8)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 18.2.1 to ^19.0.0 + +## [18.2.1](https://github.com/puppeteer/puppeteer/compare/v18.2.0...v18.2.1) (2022-10-06) + + +### Bug Fixes + +* add README to package during prepack ([#9057](https://github.com/puppeteer/puppeteer/issues/9057)) ([9374e23](https://github.com/puppeteer/puppeteer/commit/9374e23d3da5e40378461ed08db24649730a445a)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 18.2.0 to ^18.2.1 + +## [18.2.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v18.1.0...puppeteer-v18.2.0) (2022-10-05) + + +### Features + +* separate puppeteer and puppeteer-core ([#9023](https://github.com/puppeteer/puppeteer/issues/9023)) ([f42336c](https://github.com/puppeteer/puppeteer/commit/f42336cf83982332829ca7e14ee48d8676e11545)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * puppeteer-core bumped from 18.1.0 to ^18.2.0 + +## [18.1.0](https://github.com/puppeteer/puppeteer/compare/v18.0.5...v18.1.0) (2022-10-05) + + +### Features + +* **chromium:** roll to Chromium 107.0.5296.0 (r1045629) ([#9039](https://github.com/puppeteer/puppeteer/issues/9039)) ([022fbde](https://github.com/puppeteer/puppeteer/commit/022fbde85e067e8c419cf42dd571f9a1187c343c)) + +## [18.0.5](https://github.com/puppeteer/puppeteer/compare/v18.0.4...v18.0.5) (2022-09-22) + + +### Bug Fixes + +* add missing npm config environment variable ([#8996](https://github.com/puppeteer/puppeteer/issues/8996)) ([7c1be20](https://github.com/puppeteer/puppeteer/commit/7c1be20aef46aaf5029732a580ec65aa8008aa9c)) + +## [18.0.4](https://github.com/puppeteer/puppeteer/compare/v18.0.3...v18.0.4) (2022-09-21) + + +### Bug Fixes + +* hardcode binding names ([#8993](https://github.com/puppeteer/puppeteer/issues/8993)) ([7e20554](https://github.com/puppeteer/puppeteer/commit/7e2055433e79ef20f6dcdf02f92e1d64564b7d33)) + +## [18.0.3](https://github.com/puppeteer/puppeteer/compare/v18.0.2...v18.0.3) (2022-09-20) + + +### Bug Fixes + +* change injected.ts imports ([#8987](https://github.com/puppeteer/puppeteer/issues/8987)) ([10a114d](https://github.com/puppeteer/puppeteer/commit/10a114d36f2add90860950f61b3f8b93258edb5c)) + +## [18.0.2](https://github.com/puppeteer/puppeteer/compare/v18.0.1...v18.0.2) (2022-09-19) + + +### Bug Fixes + +* mark internal objects ([#8984](https://github.com/puppeteer/puppeteer/issues/8984)) ([181a148](https://github.com/puppeteer/puppeteer/commit/181a148269fce1575f5e37056929ecdec0517586)) + +## [18.0.1](https://github.com/puppeteer/puppeteer/compare/v18.0.0...v18.0.1) (2022-09-19) + + +### Bug Fixes + +* internal lazy params ([#8982](https://github.com/puppeteer/puppeteer/issues/8982)) ([d504597](https://github.com/puppeteer/puppeteer/commit/d5045976a6dd321bbd265b84c2474ff1ad5d0b77)) + +## [18.0.0](https://github.com/puppeteer/puppeteer/compare/v17.1.3...v18.0.0) (2022-09-19) + + +### ⚠ BREAKING CHANGES + +* fix bounding box visibility conditions (#8954) + +### Features + +* add text query handler ([#8956](https://github.com/puppeteer/puppeteer/issues/8956)) ([633e7cf](https://github.com/puppeteer/puppeteer/commit/633e7cfdf99d42f420d0af381394bd1f6ac7bcd1)) + + +### Bug Fixes + +* fix bounding box visibility conditions ([#8954](https://github.com/puppeteer/puppeteer/issues/8954)) ([ac9929d](https://github.com/puppeteer/puppeteer/commit/ac9929d80f6f7d4905a39183ae235500e29b4f53)) +* suppress init errors if the target is closed ([#8947](https://github.com/puppeteer/puppeteer/issues/8947)) ([cfaaa5e](https://github.com/puppeteer/puppeteer/commit/cfaaa5e2c07e5f98baeb7de99e303aa840a351e8)) +* use win64 version of chromium when on arm64 windows ([#8927](https://github.com/puppeteer/puppeteer/issues/8927)) ([64843b8](https://github.com/puppeteer/puppeteer/commit/64843b88853210314677ab1b434729513ce615a7)) + +## [17.1.3](https://github.com/puppeteer/puppeteer/compare/v17.1.2...v17.1.3) (2022-09-08) + + +### Bug Fixes + +* FirefoxLauncher should not use BrowserFetcher in puppeteer-core ([#8920](https://github.com/puppeteer/puppeteer/issues/8920)) ([f2e8de7](https://github.com/puppeteer/puppeteer/commit/f2e8de777fc5d547778fdc6cac658add84ed4082)), closes [#8919](https://github.com/puppeteer/puppeteer/issues/8919) +* linux arm64 check on windows arm ([#8917](https://github.com/puppeteer/puppeteer/issues/8917)) ([f02b926](https://github.com/puppeteer/puppeteer/commit/f02b926245e28b5671087c051dbdbb3165696f08)), closes [#8915](https://github.com/puppeteer/puppeteer/issues/8915) + +## [17.1.2](https://github.com/puppeteer/puppeteer/compare/v17.1.1...v17.1.2) (2022-09-07) + + +### Bug Fixes + +* add missing code coverage ranges that span only a single character ([#8911](https://github.com/puppeteer/puppeteer/issues/8911)) ([0c577b9](https://github.com/puppeteer/puppeteer/commit/0c577b9bf8855dc0ccb6098cd43a25c528f6d7f5)) +* add Page.getDefaultTimeout getter ([#8903](https://github.com/puppeteer/puppeteer/issues/8903)) ([3240095](https://github.com/puppeteer/puppeteer/commit/32400954c50cbddc48468ad118c3f8a47653b9d3)), closes [#8901](https://github.com/puppeteer/puppeteer/issues/8901) +* don't detect project root for puppeteer-core ([#8907](https://github.com/puppeteer/puppeteer/issues/8907)) ([b4f5ea1](https://github.com/puppeteer/puppeteer/commit/b4f5ea1167a60c870194c70d22f5372ada5b7c4c)), closes [#8896](https://github.com/puppeteer/puppeteer/issues/8896) +* support scale for screenshot clips ([#8908](https://github.com/puppeteer/puppeteer/issues/8908)) ([260e428](https://github.com/puppeteer/puppeteer/commit/260e4282275ab1d05c86e5643e2a02c01f269a9c)), closes [#5329](https://github.com/puppeteer/puppeteer/issues/5329) +* work around a race in waitForFileChooser ([#8905](https://github.com/puppeteer/puppeteer/issues/8905)) ([053d960](https://github.com/puppeteer/puppeteer/commit/053d960fb593e514e7914d7da9af436afc39a12f)), closes [#6040](https://github.com/puppeteer/puppeteer/issues/6040) + +## [17.1.1](https://github.com/puppeteer/puppeteer/compare/v17.1.0...v17.1.1) (2022-09-05) + + +### Bug Fixes + +* restore deferred promise debugging ([#8895](https://github.com/puppeteer/puppeteer/issues/8895)) ([7b42250](https://github.com/puppeteer/puppeteer/commit/7b42250c7bb91ac873307acda493726ffc4c54a8)) + +## [17.1.0](https://github.com/puppeteer/puppeteer/compare/v17.0.0...v17.1.0) (2022-09-02) + + +### Features + +* **chromium:** roll to Chromium 106.0.5249.0 (r1036745) ([#8869](https://github.com/puppeteer/puppeteer/issues/8869)) ([6e9a47a](https://github.com/puppeteer/puppeteer/commit/6e9a47a6faa06d241dec0bcf7bcdf49370517008)) + + +### Bug Fixes + +* allow getting a frame from an elementhandle ([#8875](https://github.com/puppeteer/puppeteer/issues/8875)) ([3732757](https://github.com/puppeteer/puppeteer/commit/3732757450b4363041ccbacc3b236289a156abb0)) +* typos in documentation ([#8858](https://github.com/puppeteer/puppeteer/issues/8858)) ([8d95a9b](https://github.com/puppeteer/puppeteer/commit/8d95a9bc920b98820aa655ad4eb2d8fd9b2b893a)) +* use the timeout setting in waitForFileChooser ([#8856](https://github.com/puppeteer/puppeteer/issues/8856)) ([f477b46](https://github.com/puppeteer/puppeteer/commit/f477b46f212da9206102da695697760eea539f05)) + +## [17.0.0](https://github.com/puppeteer/puppeteer/compare/v16.2.0...v17.0.0) (2022-08-26) + + +### ⚠ BREAKING CHANGES + +* remove `root` from `WaitForSelectorOptions` (#8848) +* internalize execution context (#8844) + +### Bug Fixes + +* allow multiple navigations to happen in LifecycleWatcher ([#8826](https://github.com/puppeteer/puppeteer/issues/8826)) ([341b669](https://github.com/puppeteer/puppeteer/commit/341b669a5e45ecbb9ffb0f28c45b520660f27ad2)), closes [#8811](https://github.com/puppeteer/puppeteer/issues/8811) +* internalize execution context ([#8844](https://github.com/puppeteer/puppeteer/issues/8844)) ([2f33237](https://github.com/puppeteer/puppeteer/commit/2f33237d0443de77d58dca4454b0c9a1d2b57d03)) +* remove `root` from `WaitForSelectorOptions` ([#8848](https://github.com/puppeteer/puppeteer/issues/8848)) ([1155c8e](https://github.com/puppeteer/puppeteer/commit/1155c8eac85b176c3334cc3d98adfe7d943dfbe6)) +* remove deferred promise timeouts ([#8835](https://github.com/puppeteer/puppeteer/issues/8835)) ([202ffce](https://github.com/puppeteer/puppeteer/commit/202ffce0aa4f34dba35fbb8e7d740af16efee35f)), closes [#8832](https://github.com/puppeteer/puppeteer/issues/8832) + +## [16.2.0](https://github.com/puppeteer/puppeteer/compare/v16.1.1...v16.2.0) (2022-08-18) + + +### Features + +* add Khmer (Cambodian) language support ([#8809](https://github.com/puppeteer/puppeteer/issues/8809)) ([34f8737](https://github.com/puppeteer/puppeteer/commit/34f873721804d57a5faf3eab8ef50340c69ed180)) + + +### Bug Fixes + +* handle service workers in extensions ([#8807](https://github.com/puppeteer/puppeteer/issues/8807)) ([2a0eefb](https://github.com/puppeteer/puppeteer/commit/2a0eefb99f0ae00dacc9e768a253308c0d18a4c3)), closes [#8800](https://github.com/puppeteer/puppeteer/issues/8800) + +## [16.1.1](https://github.com/puppeteer/puppeteer/compare/v16.1.0...v16.1.1) (2022-08-16) + + +### Bug Fixes + +* custom sessions should not emit targetcreated events ([#8788](https://github.com/puppeteer/puppeteer/issues/8788)) ([3fad05d](https://github.com/puppeteer/puppeteer/commit/3fad05d333b79f41a7b58582c4ca493200bb5a79)), closes [#8787](https://github.com/puppeteer/puppeteer/issues/8787) +* deprecate `ExecutionContext` ([#8792](https://github.com/puppeteer/puppeteer/issues/8792)) ([b5da718](https://github.com/puppeteer/puppeteer/commit/b5da718e2e4a2004a36cf23cad555e1fc3b50333)) +* deprecate `root` in `WaitForSelectorOptions` ([#8795](https://github.com/puppeteer/puppeteer/issues/8795)) ([65a5ce8](https://github.com/puppeteer/puppeteer/commit/65a5ce8464c56fcc55e5ac3ed490f31311bbe32a)) +* deprecate `waitForTimeout` ([#8793](https://github.com/puppeteer/puppeteer/issues/8793)) ([8f612d5](https://github.com/puppeteer/puppeteer/commit/8f612d5ff855d48ae4b38bdaacf2a8fbda8e9ce8)) +* make sure there is a check for targets when timeout=0 ([#8765](https://github.com/puppeteer/puppeteer/issues/8765)) ([c23cdb7](https://github.com/puppeteer/puppeteer/commit/c23cdb73a7b113c1dd29f7e4a7a61326422c4080)), closes [#8763](https://github.com/puppeteer/puppeteer/issues/8763) +* resolve navigation flakiness ([#8768](https://github.com/puppeteer/puppeteer/issues/8768)) ([2580347](https://github.com/puppeteer/puppeteer/commit/2580347b50091d172b2a5591138a2e41ede072fe)), closes [#8644](https://github.com/puppeteer/puppeteer/issues/8644) +* specify Puppeteer version for Chromium 105.0.5173.0 ([#8766](https://github.com/puppeteer/puppeteer/issues/8766)) ([b5064b7](https://github.com/puppeteer/puppeteer/commit/b5064b7b8bd3bd9eb481b6807c65d9d06d23b9dd)) +* use targetFilter in puppeteer.launch ([#8774](https://github.com/puppeteer/puppeteer/issues/8774)) ([ee2540b](https://github.com/puppeteer/puppeteer/commit/ee2540baefeced44f6b336f2b979af5c3a4cb040)), closes [#8772](https://github.com/puppeteer/puppeteer/issues/8772) + +## [16.1.0](https://github.com/puppeteer/puppeteer/compare/v16.0.0...v16.1.0) (2022-08-06) + + +### Features + +* use an `xpath` query handler ([#8730](https://github.com/puppeteer/puppeteer/issues/8730)) ([5cf9b4d](https://github.com/puppeteer/puppeteer/commit/5cf9b4de8d50bd056db82bcaa23279b72c9313c5)) + + +### Bug Fixes + +* resolve target manager init if no existing targets detected ([#8748](https://github.com/puppeteer/puppeteer/issues/8748)) ([8cb5043](https://github.com/puppeteer/puppeteer/commit/8cb5043868f69cdff7f34f1cfe0c003ff09e281b)), closes [#8747](https://github.com/puppeteer/puppeteer/issues/8747) +* specify the target filter in setDiscoverTargets ([#8742](https://github.com/puppeteer/puppeteer/issues/8742)) ([49193cb](https://github.com/puppeteer/puppeteer/commit/49193cbf1c17f16f0ca59a9fd2ebf306f812f52b)) + +## [16.0.0](https://github.com/puppeteer/puppeteer/compare/v15.5.0...v16.0.0) (2022-08-02) + + +### ⚠ BREAKING CHANGES + +* With Chromium, Puppeteer will now attach to page/iframe targets immediately to allow reliable configuration of targets. + +### Features + +* add Dockerfile ([#8315](https://github.com/puppeteer/puppeteer/issues/8315)) ([936ed86](https://github.com/puppeteer/puppeteer/commit/936ed8607ec0c3798d2b22b590d0be0ad361a888)) +* detect Firefox in connect() automatically ([#8718](https://github.com/puppeteer/puppeteer/issues/8718)) ([2abd772](https://github.com/puppeteer/puppeteer/commit/2abd772c9c3d2b86deb71541eaac41aceef94356)) +* use CDP's auto-attach mechanism ([#8520](https://github.com/puppeteer/puppeteer/issues/8520)) ([2cbfdeb](https://github.com/puppeteer/puppeteer/commit/2cbfdeb0ca388a45cedfae865266230e1291bd29)) + + +### Bug Fixes + +* address flakiness in frame handling ([#8688](https://github.com/puppeteer/puppeteer/issues/8688)) ([6f81b23](https://github.com/puppeteer/puppeteer/commit/6f81b23728a511f7b89eaa2b8f850b22d6c4ab24)) +* disable AcceptCHFrame ([#8706](https://github.com/puppeteer/puppeteer/issues/8706)) ([96d9608](https://github.com/puppeteer/puppeteer/commit/96d9608d1de17877414a649a0737661894dd96c8)), closes [#8479](https://github.com/puppeteer/puppeteer/issues/8479) +* use loaderId to reduce test flakiness ([#8717](https://github.com/puppeteer/puppeteer/issues/8717)) ([d2f6db2](https://github.com/puppeteer/puppeteer/commit/d2f6db20735342bb3f419e85adbd51ed10470044)) + +## [15.5.0](https://github.com/puppeteer/puppeteer/compare/v15.4.2...v15.5.0) (2022-07-21) + + +### Features + +* **chromium:** roll to Chromium 105.0.5173.0 (r1022525) ([#8682](https://github.com/puppeteer/puppeteer/issues/8682)) ([f1b8ad3](https://github.com/puppeteer/puppeteer/commit/f1b8ad3269286800d31818ea4b6b3ee23f7437c3)) + +## [15.4.2](https://github.com/puppeteer/puppeteer/compare/v15.4.1...v15.4.2) (2022-07-21) + + +### Bug Fixes + +* taking a screenshot with null viewport should be possible ([#8680](https://github.com/puppeteer/puppeteer/issues/8680)) ([2abb9f0](https://github.com/puppeteer/puppeteer/commit/2abb9f0c144779d555ecbf337a759440d0282cba)), closes [#8673](https://github.com/puppeteer/puppeteer/issues/8673) + +## [15.4.1](https://github.com/puppeteer/puppeteer/compare/v15.4.0...v15.4.1) (2022-07-21) + + +### Bug Fixes + +* import URL ([#8670](https://github.com/puppeteer/puppeteer/issues/8670)) ([34ab5ca](https://github.com/puppeteer/puppeteer/commit/34ab5ca50353ffb6a6345a8984b724a6f42fb726)) + +## [15.4.0](https://github.com/puppeteer/puppeteer/compare/v15.3.2...v15.4.0) (2022-07-13) + + +### Features + +* expose the page getter on Frame ([#8657](https://github.com/puppeteer/puppeteer/issues/8657)) ([af08c5c](https://github.com/puppeteer/puppeteer/commit/af08c5c90380c853e8257a51298bfed4b0635779)) + + +### Bug Fixes + +* ignore *.tsbuildinfo ([#8662](https://github.com/puppeteer/puppeteer/issues/8662)) ([edcdf21](https://github.com/puppeteer/puppeteer/commit/edcdf217cefbf31aee5a2f571abac429dd81f3a0)) + +## [15.3.2](https://github.com/puppeteer/puppeteer/compare/v15.3.1...v15.3.2) (2022-07-08) + + +### Bug Fixes + +* cache dynamic imports ([#8652](https://github.com/puppeteer/puppeteer/issues/8652)) ([1de0383](https://github.com/puppeteer/puppeteer/commit/1de0383abf6be31cf06faede3e59b087a2958227)) +* expose a RemoteObject getter ([#8642](https://github.com/puppeteer/puppeteer/issues/8642)) ([d0c4291](https://github.com/puppeteer/puppeteer/commit/d0c42919956bd36ad7993a0fc1de86e886e39f62)), closes [#8639](https://github.com/puppeteer/puppeteer/issues/8639) +* **page:** fix page.#scrollIntoViewIfNeeded method ([#8631](https://github.com/puppeteer/puppeteer/issues/8631)) ([b47f066](https://github.com/puppeteer/puppeteer/commit/b47f066c2c068825e3b65cfe17b6923c77ad30b9)) + +## [15.3.1](https://github.com/puppeteer/puppeteer/compare/v15.3.0...v15.3.1) (2022-07-06) + + +### Bug Fixes + +* extends `ElementHandle` to `Node`s ([#8552](https://github.com/puppeteer/puppeteer/issues/8552)) ([5ff205d](https://github.com/puppeteer/puppeteer/commit/5ff205dc8b659eb8864b4b1862105d21dd334c8f)) + +## [15.3.0](https://github.com/puppeteer/puppeteer/compare/v15.2.0...v15.3.0) (2022-07-01) + + +### Features + +* add documentation ([#8593](https://github.com/puppeteer/puppeteer/issues/8593)) ([066f440](https://github.com/puppeteer/puppeteer/commit/066f440ba7bdc9aca9423d7205adf36f2858bd78)) + + +### Bug Fixes + +* remove unused imports ([#8613](https://github.com/puppeteer/puppeteer/issues/8613)) ([0cf4832](https://github.com/puppeteer/puppeteer/commit/0cf4832878731ffcfc84570315f326eb851d7629)) + +## [15.2.0](https://github.com/puppeteer/puppeteer/compare/v15.1.1...v15.2.0) (2022-06-29) + + +### Features + +* add fromSurface option to page.screenshot ([#8496](https://github.com/puppeteer/puppeteer/issues/8496)) ([79e1198](https://github.com/puppeteer/puppeteer/commit/79e11985ba44b72b1ad6b8cd861fe316f1945e64)) +* export public types only ([#8584](https://github.com/puppeteer/puppeteer/issues/8584)) ([7001322](https://github.com/puppeteer/puppeteer/commit/7001322cd1cf9f77ee2c370d50a6707e7aaad72d)) + + +### Bug Fixes + +* clean up tmp profile dirs when browser is closed ([#8580](https://github.com/puppeteer/puppeteer/issues/8580)) ([9787a1d](https://github.com/puppeteer/puppeteer/commit/9787a1d8df7768017b36d42327faab402695c4bb)) + +## [15.1.1](https://github.com/puppeteer/puppeteer/compare/v15.1.0...v15.1.1) (2022-06-25) + + +### Bug Fixes + +* export `ElementHandle` ([e0198a7](https://github.com/puppeteer/puppeteer/commit/e0198a79e06c8bb72dde554db0246a3db5fec4c2)) + +## [15.1.0](https://github.com/puppeteer/puppeteer/compare/v15.0.2...v15.1.0) (2022-06-24) + + +### Features + +* **chromium:** roll to Chromium 104.0.5109.0 (r1011831) ([#8569](https://github.com/puppeteer/puppeteer/issues/8569)) ([fb7d31e](https://github.com/puppeteer/puppeteer/commit/fb7d31e3698428560e1f654d33782d241192f48f)) + +## [15.0.2](https://github.com/puppeteer/puppeteer/compare/v15.0.1...v15.0.2) (2022-06-24) + + +### Bug Fixes + +* CSS coverage should work with empty stylesheets ([#8570](https://github.com/puppeteer/puppeteer/issues/8570)) ([383e855](https://github.com/puppeteer/puppeteer/commit/383e8558477fae7708734ab2160ef50f385e2983)), closes [#8535](https://github.com/puppeteer/puppeteer/issues/8535) + +## [15.0.1](https://github.com/puppeteer/puppeteer/compare/v15.0.0...v15.0.1) (2022-06-24) + + +### Bug Fixes + +* infer unioned handles ([#8562](https://github.com/puppeteer/puppeteer/issues/8562)) ([8100cbb](https://github.com/puppeteer/puppeteer/commit/8100cbb29569541541f61001983efb9a80d89890)) + +## [15.0.0](https://github.com/puppeteer/puppeteer/compare/v14.4.1...v15.0.0) (2022-06-23) + + +### ⚠ BREAKING CHANGES + +* type inference for evaluation types (#8547) + +### Features + +* add experimental `client` to `HTTPRequest` ([#8556](https://github.com/puppeteer/puppeteer/issues/8556)) ([ec79f3a](https://github.com/puppeteer/puppeteer/commit/ec79f3a58a44c9ea60a82f9cd2df4c8f19e82ab8)) +* type inference for evaluation types ([#8547](https://github.com/puppeteer/puppeteer/issues/8547)) ([26c3acb](https://github.com/puppeteer/puppeteer/commit/26c3acbb0795eb66f29479f442e156832f794f01)) + +## [14.4.1](https://github.com/puppeteer/puppeteer/compare/v14.4.0...v14.4.1) (2022-06-17) + + +### Bug Fixes + +* avoid `instanceof Object` check in `isErrorLike` ([#8527](https://github.com/puppeteer/puppeteer/issues/8527)) ([6cd5cd0](https://github.com/puppeteer/puppeteer/commit/6cd5cd043997699edca6e3458f90adc1118cf4a5)) +* export `devices`, `errors`, and more ([cba58a1](https://github.com/puppeteer/puppeteer/commit/cba58a12c4e2043f6a5acf7d4754e4a7b7f6e198)) + +## [14.4.0](https://github.com/puppeteer/puppeteer/compare/v14.3.0...v14.4.0) (2022-06-13) + + +### Features + +* export puppeteer methods ([#8493](https://github.com/puppeteer/puppeteer/issues/8493)) ([465a7c4](https://github.com/puppeteer/puppeteer/commit/465a7c405f01fcef99380ffa69d86042a1f5618f)) +* support node-like environments ([#8490](https://github.com/puppeteer/puppeteer/issues/8490)) ([f64ec20](https://github.com/puppeteer/puppeteer/commit/f64ec2051b9b2d12225abba6ffe9551da9751bf7)) + + +### Bug Fixes + +* parse empty options in \<select\> ([#8489](https://github.com/puppeteer/puppeteer/issues/8489)) ([b30f3f4](https://github.com/puppeteer/puppeteer/commit/b30f3f44cdabd9545c4661cd755b9d49e5c144cd)) +* use error-like ([#8504](https://github.com/puppeteer/puppeteer/issues/8504)) ([4d35990](https://github.com/puppeteer/puppeteer/commit/4d359906a44e4ddd5ec54a523cfd9076048d3433)) +* use OS-independent abs. path check ([#8505](https://github.com/puppeteer/puppeteer/issues/8505)) ([bfd4e68](https://github.com/puppeteer/puppeteer/commit/bfd4e68f25bec6e00fd5cbf261813f8297d362ee)) + +## [14.3.0](https://github.com/puppeteer/puppeteer/compare/v14.2.1...v14.3.0) (2022-06-07) + + +### Features + +* use absolute URL for EVALUATION_SCRIPT_URL ([#8481](https://github.com/puppeteer/puppeteer/issues/8481)) ([e142560](https://github.com/puppeteer/puppeteer/commit/e14256010d2d84d613cd3c6e7999b0705115d4bf)), closes [#8424](https://github.com/puppeteer/puppeteer/issues/8424) + + +### Bug Fixes + +* don't throw on bad access ([#8472](https://github.com/puppeteer/puppeteer/issues/8472)) ([e837866](https://github.com/puppeteer/puppeteer/commit/e8378666c671e5703aec4f52912de2aac94e1828)) +* Kill browser process when killing process group fails ([#8477](https://github.com/puppeteer/puppeteer/issues/8477)) ([7dc8e37](https://github.com/puppeteer/puppeteer/commit/7dc8e37a23d025bb2c31efb9c060c7f6e00179b4)) +* only lookup `localhost` for DNS lookups ([1b025b4](https://github.com/puppeteer/puppeteer/commit/1b025b4c8466fe64da0fa2050eaa02b7764770b1)) +* robustly check for launch executable ([#8468](https://github.com/puppeteer/puppeteer/issues/8468)) ([b54dc55](https://github.com/puppeteer/puppeteer/commit/b54dc55f7622ee2b75afd3bd9fe118dd2f144f40)) + +## [14.2.1](https://github.com/puppeteer/puppeteer/compare/v14.2.0...v14.2.1) (2022-06-02) + + +### Bug Fixes + +* use isPageTargetCallback in Browser::pages() ([#8460](https://github.com/puppeteer/puppeteer/issues/8460)) ([5c9050a](https://github.com/puppeteer/puppeteer/commit/5c9050aea0fe8d57114130fe38bd33ed2b4955d6)) + +## [14.2.0](https://github.com/puppeteer/puppeteer/compare/v14.1.2...v14.2.0) (2022-06-01) + + +### Features + +* **chromium:** roll to Chromium 103.0.5059.0 (r1002410) ([#8410](https://github.com/puppeteer/puppeteer/issues/8410)) ([54efc2c](https://github.com/puppeteer/puppeteer/commit/54efc2c949be1d6ef22f4d2630620e33d14d2597)) +* support node 18 ([#8447](https://github.com/puppeteer/puppeteer/issues/8447)) ([f2d8276](https://github.com/puppeteer/puppeteer/commit/f2d8276d6e745a7547b8ce54c3f50934bb70de0b)) +* use strict typescript ([#8401](https://github.com/puppeteer/puppeteer/issues/8401)) ([b4e751f](https://github.com/puppeteer/puppeteer/commit/b4e751f29cb6fd4c3cc41fe702de83721f0eb6dc)) + + +### Bug Fixes + +* multiple same request event listener ([#8404](https://github.com/puppeteer/puppeteer/issues/8404)) ([9211015](https://github.com/puppeteer/puppeteer/commit/92110151d9a33f26abc07bc805f4f2f3943697a0)) +* NodeNext incompatibility in package.json ([#8445](https://github.com/puppeteer/puppeteer/issues/8445)) ([c4898a7](https://github.com/puppeteer/puppeteer/commit/c4898a7a2e69681baac55366848da6688f0d8790)) +* process documentation during publishing ([#8433](https://github.com/puppeteer/puppeteer/issues/8433)) ([d111d19](https://github.com/puppeteer/puppeteer/commit/d111d19f788d88d984dcf4ad7542f59acd2f4c1e)) + +## [14.1.2](https://github.com/puppeteer/puppeteer/compare/v14.1.1...v14.1.2) (2022-05-30) + + +### Bug Fixes + +* do not use loaderId for lifecycle events ([#8395](https://github.com/puppeteer/puppeteer/issues/8395)) ([c96c915](https://github.com/puppeteer/puppeteer/commit/c96c915b535dcf414038677bd3d3ed6b980a4901)) +* fix release-please bot ([#8400](https://github.com/puppeteer/puppeteer/issues/8400)) ([5c235c7](https://github.com/puppeteer/puppeteer/commit/5c235c701fc55380f09d09ac2cf63f2c94b60e3d)) +* use strict TS in Input.ts ([#8392](https://github.com/puppeteer/puppeteer/issues/8392)) ([af92a24](https://github.com/puppeteer/puppeteer/commit/af92a24ba9fc8efea1ba41f96d87515cf760da65)) + +### [14.1.1](https://github.com/puppeteer/puppeteer/compare/v14.1.0...v14.1.1) (2022-05-19) + + +### Bug Fixes + +* kill browser process when 'taskkill' fails on Windows ([#8352](https://github.com/puppeteer/puppeteer/issues/8352)) ([dccfadb](https://github.com/puppeteer/puppeteer/commit/dccfadb90e8947cae3f33d7a209b6f5752f97b46)) +* only check loading iframe in lifecycling ([#8348](https://github.com/puppeteer/puppeteer/issues/8348)) ([7438030](https://github.com/puppeteer/puppeteer/commit/74380303ac6cc6e2d84948a10920d56e665ccebe)) +* recompile before funit and unit commands ([#8363](https://github.com/puppeteer/puppeteer/issues/8363)) ([8735b78](https://github.com/puppeteer/puppeteer/commit/8735b784ba7838c1002b521a7f9f23bb27263d03)), closes [#8362](https://github.com/puppeteer/puppeteer/issues/8362) + +## [14.1.0](https://github.com/puppeteer/puppeteer/compare/v14.0.0...v14.1.0) (2022-05-13) + + +### Features + +* add waitForXPath to ElementHandle ([#8329](https://github.com/puppeteer/puppeteer/issues/8329)) ([7eaadaf](https://github.com/puppeteer/puppeteer/commit/7eaadafe197279a7d1753e7274d2e24dfc11abdf)) +* allow handling other targets as pages internally ([#8336](https://github.com/puppeteer/puppeteer/issues/8336)) ([3b66a2c](https://github.com/puppeteer/puppeteer/commit/3b66a2c47ee36785a6a72c9afedd768fab3d040a)) + + +### Bug Fixes + +* disable AvoidUnnecessaryBeforeUnloadCheckSync to fix navigations ([#8330](https://github.com/puppeteer/puppeteer/issues/8330)) ([4854ad5](https://github.com/puppeteer/puppeteer/commit/4854ad5b15c9bdf93c06dcb758393e7cbacd7469)) +* If currentNode and root are the same, do not include them in the result ([#8332](https://github.com/puppeteer/puppeteer/issues/8332)) ([a61144d](https://github.com/puppeteer/puppeteer/commit/a61144d43780b5c32197427d7682b9b6c433f2bb)) + +## [14.0.0](https://github.com/puppeteer/puppeteer/compare/v13.7.0...v14.0.0) (2022-05-09) + + +### ⚠ BREAKING CHANGES + +* strict mode fixes for HTTPRequest/Response classes (#8297) +* Node 12 is no longer supported. + +### Features + +* add support for Apple Silicon chromium builds ([#7546](https://github.com/puppeteer/puppeteer/issues/7546)) ([baa017d](https://github.com/puppeteer/puppeteer/commit/baa017db92b1fecf2e3584d5b3161371ae60f55b)), closes [#6622](https://github.com/puppeteer/puppeteer/issues/6622) +* **chromium:** roll to Chromium 102.0.5002.0 (r991974) ([#8319](https://github.com/puppeteer/puppeteer/issues/8319)) ([be4c930](https://github.com/puppeteer/puppeteer/commit/be4c930c60164f681a966d0f8cb745f6c263fe2b)) +* support ES modules ([#8306](https://github.com/puppeteer/puppeteer/issues/8306)) ([6841bd6](https://github.com/puppeteer/puppeteer/commit/6841bd68d85e3b3952c5e7ce454ac4d23f84262d)) + + +### Bug Fixes + +* apparent typo SUPPORTER_PLATFORMS ([#8294](https://github.com/puppeteer/puppeteer/issues/8294)) ([e09287f](https://github.com/puppeteer/puppeteer/commit/e09287f4e9a1ff3c637dd165d65f221394970e2c)) +* make sure inner OOPIFs can be attached to ([#8304](https://github.com/puppeteer/puppeteer/issues/8304)) ([5539598](https://github.com/puppeteer/puppeteer/commit/553959884f4edb4deab760fa8ca38fc1c85c05c5)) +* strict mode fixes for HTTPRequest/Response classes ([#8297](https://github.com/puppeteer/puppeteer/issues/8297)) ([2804ae8](https://github.com/puppeteer/puppeteer/commit/2804ae8cdbc4c90bf942510bce656275a2d409e1)), closes [#6769](https://github.com/puppeteer/puppeteer/issues/6769) +* tests failing in headful ([#8273](https://github.com/puppeteer/puppeteer/issues/8273)) ([e841d7f](https://github.com/puppeteer/puppeteer/commit/e841d7f9f3f407c02dbc48e107b545b91db104e6)) + + +* drop Node 12 support ([#8299](https://github.com/puppeteer/puppeteer/issues/8299)) ([274bd6b](https://github.com/puppeteer/puppeteer/commit/274bd6b3b98c305ed014909d8053e4c54187971b)) + +## [13.7.0](https://github.com/puppeteer/puppeteer/compare/v13.6.0...v13.7.0) (2022-04-28) + + +### Features + +* add `back` and `forward` mouse buttons ([#8284](https://github.com/puppeteer/puppeteer/issues/8284)) ([7a51bff](https://github.com/puppeteer/puppeteer/commit/7a51bff47f6436fc29d0df7eb74f12f69102ca5b)) +* support chrome headless mode ([#8260](https://github.com/puppeteer/puppeteer/issues/8260)) ([1308d9a](https://github.com/puppeteer/puppeteer/commit/1308d9aa6a5920b20da02dca8db03c63e43c8b84)) + + +### Bug Fixes + +* doc typo ([#8263](https://github.com/puppeteer/puppeteer/issues/8263)) ([952a2ae](https://github.com/puppeteer/puppeteer/commit/952a2ae0bc4f059f8e8b4d1de809d0a486a74551)) +* use different test names for browser specific tests in launcher.spec.ts ([#8250](https://github.com/puppeteer/puppeteer/issues/8250)) ([c6cf1a9](https://github.com/puppeteer/puppeteer/commit/c6cf1a9f27621c8a619cfbdc9d0821541768ac94)) + +## [13.6.0](https://github.com/puppeteer/puppeteer/compare/v13.5.2...v13.6.0) (2022-04-19) + + +### Features + +* **chromium:** roll to Chromium 101.0.4950.0 (r982053) ([#8213](https://github.com/puppeteer/puppeteer/issues/8213)) ([ec74bd8](https://github.com/puppeteer/puppeteer/commit/ec74bd811d9b7fbaf600068e86f13a63d7b0bc6f)) +* respond multiple headers with same key ([#8183](https://github.com/puppeteer/puppeteer/issues/8183)) ([c1dcd85](https://github.com/puppeteer/puppeteer/commit/c1dcd857e3bc17769f02474a41bbedee01f471dc)) + + +### Bug Fixes + +* also kill Firefox when temporary profile is used ([#8233](https://github.com/puppeteer/puppeteer/issues/8233)) ([b6504d7](https://github.com/puppeteer/puppeteer/commit/b6504d7186336a2fc0b41c3878c843b7409ba5fb)) +* consider existing frames when waiting for a frame ([#8200](https://github.com/puppeteer/puppeteer/issues/8200)) ([0955225](https://github.com/puppeteer/puppeteer/commit/0955225b51421663288523a3dfb63103b51775b4)) +* disable bfcache in the launcher ([#8196](https://github.com/puppeteer/puppeteer/issues/8196)) ([9ac7318](https://github.com/puppeteer/puppeteer/commit/9ac7318506ac858b3465e9b4ede8ad75fbbcee11)), closes [#8182](https://github.com/puppeteer/puppeteer/issues/8182) +* enable page.spec event handler test for firefox ([#8214](https://github.com/puppeteer/puppeteer/issues/8214)) ([2b45027](https://github.com/puppeteer/puppeteer/commit/2b45027d256f85f21a0c824183696b237e00ad33)) +* forget queuedEventGroup when emitting response in responseReceivedExtraInfo ([#8234](https://github.com/puppeteer/puppeteer/issues/8234)) ([#8239](https://github.com/puppeteer/puppeteer/issues/8239)) ([91a8e73](https://github.com/puppeteer/puppeteer/commit/91a8e73b1196e4128b1e7c25e08080f2faaf3cf7)) +* forget request will be sent from the _requestWillBeSentMap list. ([#8226](https://github.com/puppeteer/puppeteer/issues/8226)) ([4b786c9](https://github.com/puppeteer/puppeteer/commit/4b786c904cbfe3f059322292f3b788b8a5ebd9bf)) +* ignore favicon requests in page.spec event handler tests ([#8208](https://github.com/puppeteer/puppeteer/issues/8208)) ([04e5c88](https://github.com/puppeteer/puppeteer/commit/04e5c889973432c6163a8539cdec23c0e8726bff)) +* **network.spec.ts:** typo in the word should ([#8223](https://github.com/puppeteer/puppeteer/issues/8223)) ([e93faad](https://github.com/puppeteer/puppeteer/commit/e93faadc21b7fcb1e03b69c451c28b769f9cde51)) + +### [13.5.2](https://github.com/puppeteer/puppeteer/compare/v13.5.1...v13.5.2) (2022-03-31) + + +### Bug Fixes + +* chromium downloading hung at 99% ([#8169](https://github.com/puppeteer/puppeteer/issues/8169)) ([8f13470](https://github.com/puppeteer/puppeteer/commit/8f13470af06045857f32496f03e77b14f3ecff98)) +* get extra headers from Fetch.requestPaused event ([#8162](https://github.com/puppeteer/puppeteer/issues/8162)) ([37ede68](https://github.com/puppeteer/puppeteer/commit/37ede6877017a8dc6c946a3dff4ec6d79c3ebc59)) + +### [13.5.1](https://github.com/puppeteer/puppeteer/compare/v13.5.0...v13.5.1) (2022-03-09) + + +### Bug Fixes + +* waitForNavigation in OOPIFs ([#8117](https://github.com/puppeteer/puppeteer/issues/8117)) ([34775e5](https://github.com/puppeteer/puppeteer/commit/34775e58316be49d8bc5a13209a1f570bc66b448)) + +## [13.5.0](https://github.com/puppeteer/puppeteer/compare/v13.4.1...v13.5.0) (2022-03-07) + + +### Features + +* **chromium:** roll to Chromium 100.0.4889.0 (r970485) ([#8108](https://github.com/puppeteer/puppeteer/issues/8108)) ([d12f427](https://github.com/puppeteer/puppeteer/commit/d12f42754f7013b5ec0a2198cf2d9cf945d3cb38)) + + +### Bug Fixes + +* Inherit browser-level proxy settings from incognito context ([#7770](https://github.com/puppeteer/puppeteer/issues/7770)) ([3feca32](https://github.com/puppeteer/puppeteer/commit/3feca325a9472ee36f7e866ebe375c7f083e0e36)) +* **page:** page.createIsolatedWorld error catching has been added ([#7848](https://github.com/puppeteer/puppeteer/issues/7848)) ([309e8b8](https://github.com/puppeteer/puppeteer/commit/309e8b80da0519327bc37b44a3ebb6f2e2d357a7)) +* **tests:** ensure all tests honour BINARY envvar ([#8092](https://github.com/puppeteer/puppeteer/issues/8092)) ([3b8b9ad](https://github.com/puppeteer/puppeteer/commit/3b8b9adde5d18892af96329b6f9303979f9c04f5)) + +### [13.4.1](https://github.com/puppeteer/puppeteer/compare/v13.4.0...v13.4.1) (2022-03-01) + + +### Bug Fixes + +* regression in --user-data-dir handling ([#8060](https://github.com/puppeteer/puppeteer/issues/8060)) ([85decdc](https://github.com/puppeteer/puppeteer/commit/85decdc28d7d2128e6d2946a72f4d99dd5dbb48a)) + +## [13.4.0](https://github.com/puppeteer/puppeteer/compare/v13.3.2...v13.4.0) (2022-02-22) + + +### Features + +* add support for async waitForTarget ([#7885](https://github.com/puppeteer/puppeteer/issues/7885)) ([dbf0639](https://github.com/puppeteer/puppeteer/commit/dbf0639822d0b2736993de52c0bfe1dbf4e58f25)) +* export `Frame._client` through getter ([#8041](https://github.com/puppeteer/puppeteer/issues/8041)) ([e9278fc](https://github.com/puppeteer/puppeteer/commit/e9278fcfcffe2558de63ce7542483445bcb6e74f)) +* **HTTPResponse:** expose timing information ([#8025](https://github.com/puppeteer/puppeteer/issues/8025)) ([30b3d49](https://github.com/puppeteer/puppeteer/commit/30b3d49b0de46d812b7485e708174a07c73dbdd0)) + + +### Bug Fixes + +* change kill to signal the whole process group to terminate ([#6859](https://github.com/puppeteer/puppeteer/issues/6859)) ([0eb9c78](https://github.com/puppeteer/puppeteer/commit/0eb9c7861717ebba7012c03e76b7a46063e4e5dd)) +* element screenshot issue in headful mode ([#8018](https://github.com/puppeteer/puppeteer/issues/8018)) ([5346e70](https://github.com/puppeteer/puppeteer/commit/5346e70ffc15b33c1949657cf1b465f1acc5d84d)), closes [#7999](https://github.com/puppeteer/puppeteer/issues/7999) +* ensure dom binding is not called after detach ([#8024](https://github.com/puppeteer/puppeteer/issues/8024)) ([5c308b0](https://github.com/puppeteer/puppeteer/commit/5c308b0704123736ddb085f97596c201ea18cf4a)), closes [#7814](https://github.com/puppeteer/puppeteer/issues/7814) +* use both __dirname and require.resolve to support different bundlers ([#8046](https://github.com/puppeteer/puppeteer/issues/8046)) ([e6a6295](https://github.com/puppeteer/puppeteer/commit/e6a6295d9a7480bb59ee58a2cc7785171fa0fa2c)), closes [#8044](https://github.com/puppeteer/puppeteer/issues/8044) + +### [13.3.2](https://github.com/puppeteer/puppeteer/compare/v13.3.1...v13.3.2) (2022-02-14) + + +### Bug Fixes + +* always use ENV executable path when present ([#7985](https://github.com/puppeteer/puppeteer/issues/7985)) ([6d6ea9b](https://github.com/puppeteer/puppeteer/commit/6d6ea9bf59daa3fb851b3da8baa27887e0aa2c28)) +* use require.resolve instead of __dirname ([#8003](https://github.com/puppeteer/puppeteer/issues/8003)) ([bbb186d](https://github.com/puppeteer/puppeteer/commit/bbb186d88cb99e4914299c983c822fa41a80f356)) + +### [13.3.1](https://github.com/puppeteer/puppeteer/compare/v13.3.0...v13.3.1) (2022-02-10) + + +### Bug Fixes + +* **puppeteer:** revert: esm modules ([#7986](https://github.com/puppeteer/puppeteer/issues/7986)) ([179eded](https://github.com/puppeteer/puppeteer/commit/179ededa1400c35c1f2edc015548e0f2a1bcee14)) + +## [13.3.0](https://github.com/puppeteer/puppeteer/compare/v13.2.0...v13.3.0) (2022-02-09) + + +### Features + +* **puppeteer:** export esm modules in package.json ([#7964](https://github.com/puppeteer/puppeteer/issues/7964)) ([523b487](https://github.com/puppeteer/puppeteer/commit/523b487e8802824cecff86d256b4f7dbc4c47c8a)) + +## [13.2.0](https://github.com/puppeteer/puppeteer/compare/v13.1.3...v13.2.0) (2022-02-07) + + +### Features + +* add more models to DeviceDescriptors ([#7904](https://github.com/puppeteer/puppeteer/issues/7904)) ([6a655cb](https://github.com/puppeteer/puppeteer/commit/6a655cb647e12eaf1055be0b298908d83bebac25)) +* **chromium:** roll to Chromium 99.0.4844.16 (r961656) ([#7960](https://github.com/puppeteer/puppeteer/issues/7960)) ([96c3f94](https://github.com/puppeteer/puppeteer/commit/96c3f943b2f6e26bd871ecfcce71b6a33e214ebf)) + + +### Bug Fixes + +* make projectRoot optional in Puppeteer and launchers ([#7967](https://github.com/puppeteer/puppeteer/issues/7967)) ([9afdc63](https://github.com/puppeteer/puppeteer/commit/9afdc6300b80f01091dc4cb42d4ebe952c7d60f0)) +* migrate more files to strict-mode TypeScript ([#7950](https://github.com/puppeteer/puppeteer/issues/7950)) ([aaac8d9](https://github.com/puppeteer/puppeteer/commit/aaac8d9c44327a2c503ffd6c97b7f21e8010c3e4)) +* typos in documentation ([#7968](https://github.com/puppeteer/puppeteer/issues/7968)) ([41ab4e9](https://github.com/puppeteer/puppeteer/commit/41ab4e9127df64baa6c43ecde2f7ddd702ba7b0c)) + +### [13.1.3](https://github.com/puppeteer/puppeteer/compare/v13.1.2...v13.1.3) (2022-01-31) + + +### Bug Fixes + +* issue with reading versions.js in doclint ([#7940](https://github.com/puppeteer/puppeteer/issues/7940)) ([06ba963](https://github.com/puppeteer/puppeteer/commit/06ba9632a4c63859244068d32c312817d90daf63)) +* make more files work in strict-mode TypeScript ([#7936](https://github.com/puppeteer/puppeteer/issues/7936)) ([0636513](https://github.com/puppeteer/puppeteer/commit/0636513e34046f4d40b5e88beb2b18b16dab80aa)) +* page.pdf producing an invalid pdf ([#7868](https://github.com/puppeteer/puppeteer/issues/7868)) ([afea509](https://github.com/puppeteer/puppeteer/commit/afea509544fb99bfffe5b0bebe6f3575c53802f0)), closes [#7757](https://github.com/puppeteer/puppeteer/issues/7757) + +### [13.1.2](https://github.com/puppeteer/puppeteer/compare/v13.1.1...v13.1.2) (2022-01-25) + + +### Bug Fixes + +* **package.json:** update node-fetch package ([#7924](https://github.com/puppeteer/puppeteer/issues/7924)) ([e4c48d3](https://github.com/puppeteer/puppeteer/commit/e4c48d3b8c2a812752094ed8163e4f2f32c4b6cb)) +* types in Browser.ts to be compatible with strict mode Typescript ([#7918](https://github.com/puppeteer/puppeteer/issues/7918)) ([a8ec0aa](https://github.com/puppeteer/puppeteer/commit/a8ec0aadc9c90d224d568d9e418d14261e6e85b1)), closes [#6769](https://github.com/puppeteer/puppeteer/issues/6769) +* types in Connection.ts to be compatible with strict mode Typescript ([#7919](https://github.com/puppeteer/puppeteer/issues/7919)) ([d80d602](https://github.com/puppeteer/puppeteer/commit/d80d6027ea8e1b7fcdaf045398629cf8e6512658)), closes [#6769](https://github.com/puppeteer/puppeteer/issues/6769) + +### [13.1.1](https://github.com/puppeteer/puppeteer/compare/v13.1.0...v13.1.1) (2022-01-18) + + +### Bug Fixes + +* use content box for OOPIF offset calculations ([#7911](https://github.com/puppeteer/puppeteer/issues/7911)) ([344feb5](https://github.com/puppeteer/puppeteer/commit/344feb53c28ce018a4c600d408468f6d9d741eee)) + +## [13.1.0](https://github.com/puppeteer/puppeteer/compare/v13.0.1...v13.1.0) (2022-01-17) + + +### Features + +* **chromium:** roll to Chromium 98.0.4758.0 (r950341) ([#7907](https://github.com/puppeteer/puppeteer/issues/7907)) ([a55c86f](https://github.com/puppeteer/puppeteer/commit/a55c86fac504b5e89ba23735fb3a1b1d54a4e1e5)) + + +### Bug Fixes + +* apply OOPIF offsets to bounding box and box model calls ([#7906](https://github.com/puppeteer/puppeteer/issues/7906)) ([a566263](https://github.com/puppeteer/puppeteer/commit/a566263ba28e58ff648bffbdb628606f75d5876f)) +* correctly compute clickable points for elements inside OOPIFs ([#7900](https://github.com/puppeteer/puppeteer/issues/7900)) ([486bbe0](https://github.com/puppeteer/puppeteer/commit/486bbe010d5ee5c446d9e8daf61a080232379c3f)), closes [#7849](https://github.com/puppeteer/puppeteer/issues/7849) +* error for pre-existing OOPIFs ([#7899](https://github.com/puppeteer/puppeteer/issues/7899)) ([d7937b8](https://github.com/puppeteer/puppeteer/commit/d7937b806d331bf16c2016aaf16e932b1334eac8)), closes [#7844](https://github.com/puppeteer/puppeteer/issues/7844) [#7896](https://github.com/puppeteer/puppeteer/issues/7896) + +### [13.0.1](https://github.com/puppeteer/puppeteer/compare/v13.0.0...v13.0.1) (2021-12-22) + + +### Bug Fixes + +* disable a test failing on Firefox ([#7846](https://github.com/puppeteer/puppeteer/issues/7846)) ([36207c5](https://github.com/puppeteer/puppeteer/commit/36207c5efe8ca21f4b3fc5b00212700326a701d2)) +* make sure ElementHandle.waitForSelector is evaluated in the right context ([#7843](https://github.com/puppeteer/puppeteer/issues/7843)) ([8d8e874](https://github.com/puppeteer/puppeteer/commit/8d8e874b072b17fc763f33d08e51c046b7435244)) +* predicate arguments for waitForFunction ([#7845](https://github.com/puppeteer/puppeteer/issues/7845)) ([1c44551](https://github.com/puppeteer/puppeteer/commit/1c44551f1b5bb19455b4a1eb7061715717ec880e)), closes [#7836](https://github.com/puppeteer/puppeteer/issues/7836) + +## [13.0.0](https://github.com/puppeteer/puppeteer/compare/v12.0.1...v13.0.0) (2021-12-10) + + +### ⚠ BREAKING CHANGES + +* typo in 'already-handled' constant of the request interception API (#7813) + +### Features + +* expose HTTPRequest intercept resolution state and clarify docs ([#7796](https://github.com/puppeteer/puppeteer/issues/7796)) ([dc23b75](https://github.com/puppeteer/puppeteer/commit/dc23b7535cb958c00d1eecfe85b4ee26e52e2e39)) +* implement Element.waitForSelector ([#7825](https://github.com/puppeteer/puppeteer/issues/7825)) ([c034294](https://github.com/puppeteer/puppeteer/commit/c03429444d05b39549489ad3da67d93b2be59f51)) + + +### Bug Fixes + +* handle multiple/duplicate Fetch.requestPaused events ([#7802](https://github.com/puppeteer/puppeteer/issues/7802)) ([636b086](https://github.com/puppeteer/puppeteer/commit/636b0863a169da132e333eb53b17eb2601daabe6)), closes [#7475](https://github.com/puppeteer/puppeteer/issues/7475) [#6696](https://github.com/puppeteer/puppeteer/issues/6696) [#7225](https://github.com/puppeteer/puppeteer/issues/7225) +* revert "feat(typescript): allow using puppeteer without dom lib" ([02c9af6](https://github.com/puppeteer/puppeteer/commit/02c9af62d64060a83f53368640f343ae2e30e38a)), closes [#6998](https://github.com/puppeteer/puppeteer/issues/6998) +* typo in 'already-handled' constant of the request interception API ([#7813](https://github.com/puppeteer/puppeteer/issues/7813)) ([8242422](https://github.com/puppeteer/puppeteer/commit/824242246de9e158aacb85f71350a79cb386ed92)), closes [#7745](https://github.com/puppeteer/puppeteer/issues/7745) [#7747](https://github.com/puppeteer/puppeteer/issues/7747) [#7780](https://github.com/puppeteer/puppeteer/issues/7780) + +### [12.0.1](https://github.com/puppeteer/puppeteer/compare/v12.0.0...v12.0.1) (2021-11-29) + + +### Bug Fixes + +* handle extraInfo events even if event.hasExtraInfo === false ([#7808](https://github.com/puppeteer/puppeteer/issues/7808)) ([6ee2feb](https://github.com/puppeteer/puppeteer/commit/6ee2feb1eafdd399f0af50cdc4517f21bcb55121)), closes [#7805](https://github.com/puppeteer/puppeteer/issues/7805) + +## [12.0.0](https://github.com/puppeteer/puppeteer/compare/v11.0.0...v12.0.0) (2021-11-26) + + +### ⚠ BREAKING CHANGES + +* **chromium:** roll to Chromium 97.0.4692.0 (r938248) + +### Features + +* **chromium:** roll to Chromium 97.0.4692.0 (r938248) ([ac162c5](https://github.com/puppeteer/puppeteer/commit/ac162c561ee43dd69eff38e1b354a41bb42c9eba)), closes [#7458](https://github.com/puppeteer/puppeteer/issues/7458) +* support for custom user data (profile) directory for Firefox ([#7684](https://github.com/puppeteer/puppeteer/issues/7684)) ([790c7a0](https://github.com/puppeteer/puppeteer/commit/790c7a0eb92291efebaa37e80c72f5cb5f46bbdb)) + + +### Bug Fixes + +* **ariaqueryhandler:** allow single quotes in aria attribute selector ([#7750](https://github.com/puppeteer/puppeteer/issues/7750)) ([b0319ec](https://github.com/puppeteer/puppeteer/commit/b0319ecc89f8ea3d31ab9aee5e1cd33d2a4e62be)), closes [#7721](https://github.com/puppeteer/puppeteer/issues/7721) +* clearer jsdoc for behavior of `headless` when `devtools` is true ([#7748](https://github.com/puppeteer/puppeteer/issues/7748)) ([9f9b4ed](https://github.com/puppeteer/puppeteer/commit/9f9b4ed72ab0bb43d002a0024122d6f5eab231aa)) +* null check for frame in FrameManager ([#7773](https://github.com/puppeteer/puppeteer/issues/7773)) ([23ee295](https://github.com/puppeteer/puppeteer/commit/23ee295f348d114617f2a86d0bb792936f413ac5)), closes [#7749](https://github.com/puppeteer/puppeteer/issues/7749) +* only kill the process when there is no browser instance available ([#7762](https://github.com/puppeteer/puppeteer/issues/7762)) ([51e6169](https://github.com/puppeteer/puppeteer/commit/51e61696c1c20cc09bd4fc068ae1dfa259c41745)), closes [#7668](https://github.com/puppeteer/puppeteer/issues/7668) +* parse statusText from the extraInfo event ([#7798](https://github.com/puppeteer/puppeteer/issues/7798)) ([a26b12b](https://github.com/puppeteer/puppeteer/commit/a26b12b7c775c36271cd4c98e39bbd59f4356320)), closes [#7458](https://github.com/puppeteer/puppeteer/issues/7458) +* try to remove the temporary user data directory after the process has been killed ([#7761](https://github.com/puppeteer/puppeteer/issues/7761)) ([fc94a28](https://github.com/puppeteer/puppeteer/commit/fc94a28778cfdb3cb8bcd882af3ebcdacf85c94e)) + +## [11.0.0](https://github.com/puppeteer/puppeteer/compare/v10.4.0...v11.0.0) (2021-11-02) + + +### ⚠ BREAKING CHANGES + +* **oop iframes:** integrate OOP iframes with the frame manager (#7556) + +### Features + +* improve error message for response.buffer() ([#7669](https://github.com/puppeteer/puppeteer/issues/7669)) ([03c9ecc](https://github.com/puppeteer/puppeteer/commit/03c9ecca400a02684cd60229550dbad1190a5b6e)) +* **oop iframes:** integrate OOP iframes with the frame manager ([#7556](https://github.com/puppeteer/puppeteer/issues/7556)) ([4d9dc8c](https://github.com/puppeteer/puppeteer/commit/4d9dc8c0e613f22d4cdf237e8bd0b0da3c588edb)), closes [#2548](https://github.com/puppeteer/puppeteer/issues/2548) +* add custom debugging port option ([#4993](https://github.com/puppeteer/puppeteer/issues/4993)) ([26145e9](https://github.com/puppeteer/puppeteer/commit/26145e9a24af7caed6ece61031f2cafa6abd505f)) +* add initiator to HTTPRequest ([#7614](https://github.com/puppeteer/puppeteer/issues/7614)) ([a271145](https://github.com/puppeteer/puppeteer/commit/a271145b0663ef9de1903dd0eb9fd5366465bed7)) +* allow to customize tmpdir ([#7243](https://github.com/puppeteer/puppeteer/issues/7243)) ([b1f6e86](https://github.com/puppeteer/puppeteer/commit/b1f6e8692b0bc7e8551b2a78169c830cd80a7acb)) +* handle unhandled promise rejections in tests ([#7722](https://github.com/puppeteer/puppeteer/issues/7722)) ([07febca](https://github.com/puppeteer/puppeteer/commit/07febca04b391893cfc872250e4391da142d4fe2)) + + +### Bug Fixes + +* add support for relative install paths to BrowserFetcher ([#7613](https://github.com/puppeteer/puppeteer/issues/7613)) ([eebf452](https://github.com/puppeteer/puppeteer/commit/eebf452d38b79bb2ea1a1ba84c3d2ea6f2f9f899)), closes [#7592](https://github.com/puppeteer/puppeteer/issues/7592) +* add webp to screenshot quality option allow list ([#7631](https://github.com/puppeteer/puppeteer/issues/7631)) ([b20c2bf](https://github.com/puppeteer/puppeteer/commit/b20c2bfa24cbdd4a1b9cefca2e0a9407e442baf5)) +* prevent Target closed errors on streams ([#7728](https://github.com/puppeteer/puppeteer/issues/7728)) ([5b792de](https://github.com/puppeteer/puppeteer/commit/5b792de7a97611441777d1ac99cb95516301d7dc)) +* request an animation frame to fix flaky clickablePoint test ([#7587](https://github.com/puppeteer/puppeteer/issues/7587)) ([7341d9f](https://github.com/puppeteer/puppeteer/commit/7341d9fadd1466a5b2f2bde8631f3b02cf9a7d8a)) +* setup husky properly ([#7727](https://github.com/puppeteer/puppeteer/issues/7727)) ([8b712e7](https://github.com/puppeteer/puppeteer/commit/8b712e7b642b58193437f26d4e104a9e412f388d)), closes [#7726](https://github.com/puppeteer/puppeteer/issues/7726) +* updated troubleshooting.md to meet latest dependencies changes ([#7656](https://github.com/puppeteer/puppeteer/issues/7656)) ([edb0197](https://github.com/puppeteer/puppeteer/commit/edb01972b9606d8b05b979a588eda0d622315981)) +* **launcher:** launcher.launch() should pass 'timeout' option [#5180](https://github.com/puppeteer/puppeteer/issues/5180) ([#7596](https://github.com/puppeteer/puppeteer/issues/7596)) ([113489d](https://github.com/puppeteer/puppeteer/commit/113489d3b58e2907374a4e6e5133bf46630695d1)) +* **page:** fallback to default in exposeFunction when using imported module ([#6365](https://github.com/puppeteer/puppeteer/issues/6365)) ([44c9ec6](https://github.com/puppeteer/puppeteer/commit/44c9ec67c57dccf3e186c86f14f3a8da9a8eb971)) +* **page:** fix page.off method for request event ([#7624](https://github.com/puppeteer/puppeteer/issues/7624)) ([d0cb943](https://github.com/puppeteer/puppeteer/commit/d0cb9436a302418086f6763e0e58ae3732a20b62)), closes [#7572](https://github.com/puppeteer/puppeteer/issues/7572) + +## [10.4.0](https://github.com/puppeteer/puppeteer/compare/v10.2.0...v10.4.0) (2021-09-21) + + +### Features + +* add webp to screenshot options ([#7565](https://github.com/puppeteer/puppeteer/issues/7565)) ([43a9268](https://github.com/puppeteer/puppeteer/commit/43a926832505a57922016907a264165676424557)) +* **page:** expose page.client() ([#7582](https://github.com/puppeteer/puppeteer/issues/7582)) ([99ca842](https://github.com/puppeteer/puppeteer/commit/99ca842124a1edef5e66426621885141a9feaca5)) +* **page:** mark page.client() as internal ([#7585](https://github.com/puppeteer/puppeteer/issues/7585)) ([8451951](https://github.com/puppeteer/puppeteer/commit/84519514831f304f9076ca235fe474f797616b2c)) +* add ability to specify offsets for JSHandle.click ([#7573](https://github.com/puppeteer/puppeteer/issues/7573)) ([2b5c001](https://github.com/puppeteer/puppeteer/commit/2b5c0019dc3744196c5858edeaa901dff9973ef5)) +* add durableStorage to allowed permissions ([#5295](https://github.com/puppeteer/puppeteer/issues/5295)) ([eda5171](https://github.com/puppeteer/puppeteer/commit/eda51712790b9260626dc53cfb58a72805c45582)) +* add id option to addScriptTag ([#5477](https://github.com/puppeteer/puppeteer/issues/5477)) ([300be5d](https://github.com/puppeteer/puppeteer/commit/300be5d167b6e7e532e725fdb86966081a5d0093)) +* add more Android models to DeviceDescriptors ([#7210](https://github.com/puppeteer/puppeteer/issues/7210)) ([b5020dc](https://github.com/puppeteer/puppeteer/commit/b5020dc04121b265c77662237dfb177d6de06053)), closes [/github.com/aerokube/moon-deploy/blob/master/moon-local.yaml#L199](https://github.com/puppeteer//github.com/aerokube/moon-deploy/blob/master/moon-local.yaml/issues/L199) +* add proxy and bypass list parameters to createIncognitoBrowserContext ([#7516](https://github.com/puppeteer/puppeteer/issues/7516)) ([8e45a1c](https://github.com/puppeteer/puppeteer/commit/8e45a1c882207cc36e87be2a917b661eb841c4bf)), closes [#678](https://github.com/puppeteer/puppeteer/issues/678) +* add threshold to Page.isIntersectingViewport ([#6497](https://github.com/puppeteer/puppeteer/issues/6497)) ([54c4318](https://github.com/puppeteer/puppeteer/commit/54c43180161c3c512e4698e7f2e85ce3c6f0ab50)) +* add unit test support for bisect ([#7553](https://github.com/puppeteer/puppeteer/issues/7553)) ([a0b1f6b](https://github.com/puppeteer/puppeteer/commit/a0b1f6b401abae2fbc5a8987061644adfaa7b482)) +* add User-Agent with Puppeteer version to WebSocket request ([#5614](https://github.com/puppeteer/puppeteer/issues/5614)) ([6a2bf0a](https://github.com/puppeteer/puppeteer/commit/6a2bf0aabaa4df72c7838f5a6cd742e8f9c72be6)) +* extend husky checks ([#7574](https://github.com/puppeteer/puppeteer/issues/7574)) ([7316086](https://github.com/puppeteer/puppeteer/commit/73160869417275200be19bd37372b6218dbc5f63)) +* **api:** implement `Page.waitForNetworkIdle()` ([#5140](https://github.com/puppeteer/puppeteer/issues/5140)) ([3c6029c](https://github.com/puppeteer/puppeteer/commit/3c6029c702291ca7ef637b66e78d72e03156fe58)) +* **coverage:** option for raw V8 script coverage ([#6454](https://github.com/puppeteer/puppeteer/issues/6454)) ([cb4470a](https://github.com/puppeteer/puppeteer/commit/cb4470a6d9b0a7f73836458bb3d5779eb85ac5f2)) +* support timeout for page.pdf() call ([#7508](https://github.com/puppeteer/puppeteer/issues/7508)) ([f90af66](https://github.com/puppeteer/puppeteer/commit/f90af6639d801e764bdb479b9543b7f8f2b926df)) +* **typescript:** allow using puppeteer without dom lib ([#6998](https://github.com/puppeteer/puppeteer/issues/6998)) ([723052d](https://github.com/puppeteer/puppeteer/commit/723052d5bb3c3d1d3908508467512bea4d8fdc80)), closes [#6989](https://github.com/puppeteer/puppeteer/issues/6989) + + +### Bug Fixes + +* **docs:** deploy includes website documentation ([#7469](https://github.com/puppeteer/puppeteer/issues/7469)) ([6fde41c](https://github.com/puppeteer/puppeteer/commit/6fde41c6b6657986df1bbce3f2e0f7aa499f2be4)) +* **docs:** names in version 9.1.1 ([#7517](https://github.com/puppeteer/puppeteer/issues/7517)) ([44b22bb](https://github.com/puppeteer/puppeteer/commit/44b22bbc2629e3c75c1494b299a66790b371fb0a)) +* **frame:** fix Frame.waitFor's XPath pattern detection ([#5184](https://github.com/puppeteer/puppeteer/issues/5184)) ([caa2b73](https://github.com/puppeteer/puppeteer/commit/caa2b732fe58f32ec03f2a9fa8568f20188203c5)) +* **install:** respect environment proxy config when downloading Firef… ([#6577](https://github.com/puppeteer/puppeteer/issues/6577)) ([9399c97](https://github.com/puppeteer/puppeteer/commit/9399c9786fba4e45e1c5485ddbb197d2d4f1735f)), closes [#6573](https://github.com/puppeteer/puppeteer/issues/6573) +* added names in V9.1.1 ([#7547](https://github.com/puppeteer/puppeteer/issues/7547)) ([d132b8b](https://github.com/puppeteer/puppeteer/commit/d132b8b041696e6d5b9a99d0be1acf1cf943efef)) +* **test:** tweak waitForNetworkIdle delay in test between downloads ([#7564](https://github.com/puppeteer/puppeteer/issues/7564)) ([a21b737](https://github.com/puppeteer/puppeteer/commit/a21b7376e7feaf23066d67948d52480516f42496)) +* **types:** allow evaluate functions to take a readonly array as an argument ([#7072](https://github.com/puppeteer/puppeteer/issues/7072)) ([491614c](https://github.com/puppeteer/puppeteer/commit/491614c7f8cfa50b902d0275064e611c2a48c3b2)) +* update firefox prefs documentation link ([#7539](https://github.com/puppeteer/puppeteer/issues/7539)) ([2aec355](https://github.com/puppeteer/puppeteer/commit/2aec35553bc6e0305f40837bb3665ddbd02aa889)) +* use non-deprecated tracing categories api ([#7413](https://github.com/puppeteer/puppeteer/issues/7413)) ([040a0e5](https://github.com/puppeteer/puppeteer/commit/040a0e561b4f623f7929130b90be129f94ebb642)) + +## [10.2.0](https://github.com/puppeteer/puppeteer/compare/v10.1.0...v10.2.0) (2021-08-04) + + +### Features + +* **api:** make `page.isDragInterceptionEnabled` a method ([#7419](https://github.com/puppeteer/puppeteer/issues/7419)) ([dd470c7](https://github.com/puppeteer/puppeteer/commit/dd470c7a226a8422a938a7b0fffa58ffc6b78512)), closes [#7150](https://github.com/puppeteer/puppeteer/issues/7150) +* **chromium:** roll to Chromium 93.0.4577.0 (r901912) ([#7387](https://github.com/puppeteer/puppeteer/issues/7387)) ([e10faad](https://github.com/puppeteer/puppeteer/commit/e10faad4f239b1120491bb54fcba0216acd3a646)) +* add channel parameter for puppeteer.launch ([#7389](https://github.com/puppeteer/puppeteer/issues/7389)) ([d70f60e](https://github.com/puppeteer/puppeteer/commit/d70f60e0619b8659d191fa492e3db4bc221ae982)) +* add cooperative request intercepts ([#6735](https://github.com/puppeteer/puppeteer/issues/6735)) ([b5e6474](https://github.com/puppeteer/puppeteer/commit/b5e6474374ae6a88fc73cdb1a9906764c2ac5d70)) +* add support for useragentdata ([#7378](https://github.com/puppeteer/puppeteer/issues/7378)) ([7200b1a](https://github.com/puppeteer/puppeteer/commit/7200b1a6fb9dfdfb65d50f0000339333e71b1b2a)) + + +### Bug Fixes + +* **browser-runner:** reject promise on error ([#7338](https://github.com/puppeteer/puppeteer/issues/7338)) ([5eb20e2](https://github.com/puppeteer/puppeteer/commit/5eb20e29a21ea0e0368fa8937ef38f7c7693ab34)) +* add script to remove html comments from docs markdown ([#7394](https://github.com/puppeteer/puppeteer/issues/7394)) ([ea3df80](https://github.com/puppeteer/puppeteer/commit/ea3df80ed136a03d7698d2319106af5df8d48b58)) + +## [10.1.0](https://github.com/puppeteer/puppeteer/compare/v10.0.0...v10.1.0) (2021-06-29) + + +### Features + +* add a streaming version for page.pdf ([e3699e2](https://github.com/puppeteer/puppeteer/commit/e3699e248bc9c1f7a6ead9a07d68ae8b65905443)) +* add drag-and-drop support ([#7150](https://github.com/puppeteer/puppeteer/issues/7150)) ([a91b8ac](https://github.com/puppeteer/puppeteer/commit/a91b8aca3728b2c2e310e9446897d729bf983377)) +* add page.emulateCPUThrottling ([#7343](https://github.com/puppeteer/puppeteer/issues/7343)) ([4ce4110](https://github.com/puppeteer/puppeteer/commit/4ce41106288938b9d366c550e7a424812920683d)) + + +### Bug Fixes + +* remove redundant await while fetching target ([#7351](https://github.com/puppeteer/puppeteer/issues/7351)) ([083b297](https://github.com/puppeteer/puppeteer/commit/083b297a6741c6b1dd23867f441130655fac8f7d)) + +## [10.0.0](https://github.com/puppeteer/puppeteer/compare/v9.1.1...v10.0.0) (2021-05-31) + + +### ⚠ BREAKING CHANGES + +* Node.js 10 is no longer supported. + +### Features + +* **chromium:** roll to Chromium 92.0.4512.0 (r884014) ([#7288](https://github.com/puppeteer/puppeteer/issues/7288)) ([f863f4b](https://github.com/puppeteer/puppeteer/commit/f863f4bfe015e57ea1f9fbb322f1cedee468b857)) +* **requestinterception:** remove cacheSafe flag ([#7217](https://github.com/puppeteer/puppeteer/issues/7217)) ([d01aa6c](https://github.com/puppeteer/puppeteer/commit/d01aa6c84a1e41f15ffed3a8d36ad26a404a7187)) +* expose other sessions from connection ([#6863](https://github.com/puppeteer/puppeteer/issues/6863)) ([cb285a2](https://github.com/puppeteer/puppeteer/commit/cb285a237921259eac99ade1d8b5550e068a55eb)) +* **launcher:** add new launcher option `waitForInitialPage` ([#7105](https://github.com/puppeteer/puppeteer/issues/7105)) ([2605309](https://github.com/puppeteer/puppeteer/commit/2605309f74b43da160cda4d214016e4422bf7676)), closes [#3630](https://github.com/puppeteer/puppeteer/issues/3630) + + +### Bug Fixes + +* added comments for browsercontext, startCSSCoverage, and startJSCoverage. ([#7264](https://github.com/puppeteer/puppeteer/issues/7264)) ([b750397](https://github.com/puppeteer/puppeteer/commit/b75039746ac6bddf1411538242b5e70b0f2e6e8a)) +* modified comment for method product, platform and newPage ([#7262](https://github.com/puppeteer/puppeteer/issues/7262)) ([159d283](https://github.com/puppeteer/puppeteer/commit/159d2835450697dabea6f9adf6e67d158b5b8ae3)) +* **requestinterception:** fix font loading issue ([#7060](https://github.com/puppeteer/puppeteer/issues/7060)) ([c9978d2](https://github.com/puppeteer/puppeteer/commit/c9978d20d5584c9fd2dc902e4b4ac86ed8ea5d6e)), closes [/github.com/puppeteer/puppeteer/pull/6996#issuecomment-811546501](https://github.com/puppeteer//github.com/puppeteer/puppeteer/pull/6996/issues/issuecomment-811546501) [/github.com/puppeteer/puppeteer/pull/6996#issuecomment-813797393](https://github.com/puppeteer//github.com/puppeteer/puppeteer/pull/6996/issues/issuecomment-813797393) [#7038](https://github.com/puppeteer/puppeteer/issues/7038) + + +* drop support for Node.js 10 ([#7200](https://github.com/puppeteer/puppeteer/issues/7200)) ([97c9fe2](https://github.com/puppeteer/puppeteer/commit/97c9fe2520723d45a5a86da06b888ae888d400be)), closes [#6753](https://github.com/puppeteer/puppeteer/issues/6753) + +### [9.1.1](https://github.com/puppeteer/puppeteer/compare/v9.1.0...v9.1.1) (2021-05-05) + + +### Bug Fixes + +* make targetFilter synchronous ([#7203](https://github.com/puppeteer/puppeteer/issues/7203)) ([bcc85a0](https://github.com/puppeteer/puppeteer/commit/bcc85a0969077d122e5d8d2fb5c1061999a8ae48)) + +## [9.1.0](https://github.com/puppeteer/puppeteer/compare/v9.0.0...v9.1.0) (2021-05-03) + + +### Features + +* add option to filter targets ([#7192](https://github.com/puppeteer/puppeteer/issues/7192)) ([ec3fc2e](https://github.com/puppeteer/puppeteer/commit/ec3fc2e035bb5ca14a576180fff612e1ecf6bad7)) + + +### Bug Fixes + +* change rm -rf to rimraf ([#7168](https://github.com/puppeteer/puppeteer/issues/7168)) ([ad6b736](https://github.com/puppeteer/puppeteer/commit/ad6b736039436fcc5c0a262e5b575aa041427be3)) + +## [9.0.0](https://github.com/puppeteer/puppeteer/compare/v8.0.0...v9.0.0) (2021-04-21) + + +### ⚠ BREAKING CHANGES + +* **filechooser:** FileChooser.cancel() is now synchronous. + +### Features + +* **chromium:** roll to Chromium 91.0.4469.0 (r869685) ([#7110](https://github.com/puppeteer/puppeteer/issues/7110)) ([715e7a8](https://github.com/puppeteer/puppeteer/commit/715e7a8d62901d1c7ec602425c2fce8d8148b742)) +* **launcher:** fix installation error on Apple M1 chips ([#7099](https://github.com/puppeteer/puppeteer/issues/7099)) ([c239d9e](https://github.com/puppeteer/puppeteer/commit/c239d9edc72d85697b4875c98fff3ec592848082)), closes [#6622](https://github.com/puppeteer/puppeteer/issues/6622) +* **network:** request interception and caching compatibility ([#6996](https://github.com/puppeteer/puppeteer/issues/6996)) ([8695759](https://github.com/puppeteer/puppeteer/commit/8695759a223bc1bd31baecb00dc28721216e4c6f)) +* **page:** emit the event after removing the Worker ([#7080](https://github.com/puppeteer/puppeteer/issues/7080)) ([e34a6d5](https://github.com/puppeteer/puppeteer/commit/e34a6d53183c3e1f63a375ba6a26bee0dcfcf542)) +* **types:** improve type of predicate function ([#6997](https://github.com/puppeteer/puppeteer/issues/6997)) ([943477c](https://github.com/puppeteer/puppeteer/commit/943477cc1eb4b129870142873b3554737d5ef252)), closes [/github.com/DefinitelyTyped/DefinitelyTyped/blob/c43191a8f7a7d2a47bbff0bc3a7d95ecc64d2269/types/puppeteer/index.d.ts#L1883-L1885](https://github.com/puppeteer//github.com/DefinitelyTyped/DefinitelyTyped/blob/c43191a8f7a7d2a47bbff0bc3a7d95ecc64d2269/types/puppeteer/index.d.ts/issues/L1883-L1885) +* accept captureBeyondViewport as optional screenshot param ([#7063](https://github.com/puppeteer/puppeteer/issues/7063)) ([0e092d2](https://github.com/puppeteer/puppeteer/commit/0e092d2ea0ec18ad7f07ad3507deb80f96086e7a)) +* **page:** add omitBackground option for page.pdf method ([#6981](https://github.com/puppeteer/puppeteer/issues/6981)) ([dc8ab6d](https://github.com/puppeteer/puppeteer/commit/dc8ab6d8ca1661f8e56d329e6d9c49c891e8b975)) + + +### Bug Fixes + +* **aria:** fix parsing of ARIA selectors ([#7037](https://github.com/puppeteer/puppeteer/issues/7037)) ([4426135](https://github.com/puppeteer/puppeteer/commit/4426135692ae3ee7ed2841569dd9375e7ca8286c)) +* **page:** fix mouse.click method ([#7097](https://github.com/puppeteer/puppeteer/issues/7097)) ([ba7c367](https://github.com/puppeteer/puppeteer/commit/ba7c367de33ace7753fd9d8b8cc894b2c14ab6c2)), closes [#6462](https://github.com/puppeteer/puppeteer/issues/6462) [#3347](https://github.com/puppeteer/puppeteer/issues/3347) +* make `$` and `$$` selectors generic ([#6883](https://github.com/puppeteer/puppeteer/issues/6883)) ([b349c91](https://github.com/puppeteer/puppeteer/commit/b349c91e7df76630b7411d6645e649945c4609bd)) +* type page event listeners correctly ([#6891](https://github.com/puppeteer/puppeteer/issues/6891)) ([866d34e](https://github.com/puppeteer/puppeteer/commit/866d34ee1122e89eab00743246676845bb065968)) +* **typescript:** allow defaultViewport to be 'null' ([#6942](https://github.com/puppeteer/puppeteer/issues/6942)) ([e31e68d](https://github.com/puppeteer/puppeteer/commit/e31e68dfa12dd50482b700472bc98876b9031829)), closes [#6885](https://github.com/puppeteer/puppeteer/issues/6885) +* make screenshots work in puppeteer-web ([#6936](https://github.com/puppeteer/puppeteer/issues/6936)) ([5f24f60](https://github.com/puppeteer/puppeteer/commit/5f24f608194fd4252da7b288461427cabc9dabb3)) +* **filechooser:** cancel is sync ([#6937](https://github.com/puppeteer/puppeteer/issues/6937)) ([2ba61e0](https://github.com/puppeteer/puppeteer/commit/2ba61e04e923edaac09c92315212552f2d4ce676)) +* **network:** don't disable cache for auth challenge ([#6962](https://github.com/puppeteer/puppeteer/issues/6962)) ([1c2479a](https://github.com/puppeteer/puppeteer/commit/1c2479a6cd4bd09a577175ffd31c40ca6f4279b8)) + +## [8.0.0](https://github.com/puppeteer/puppeteer/compare/v7.1.0...v8.0.0) (2021-02-26) + + +### ⚠ BREAKING CHANGES + +* renamed type `ChromeArgOptions` to `BrowserLaunchArgumentOptions` +* renamed type `BrowserOptions` to `BrowserConnectOptions` + +### Features + +* **chromium:** roll Chromium to r856583 ([#6927](https://github.com/puppeteer/puppeteer/issues/6927)) ([0c688bd](https://github.com/puppeteer/puppeteer/commit/0c688bd75ef1d1fc3afd14cbe8966757ecda68fb)) + + +### Bug Fixes + +* explicit HTTPRequest.resourceType type defs ([#6882](https://github.com/puppeteer/puppeteer/issues/6882)) ([ff26c62](https://github.com/puppeteer/puppeteer/commit/ff26c62647b60cd0d8d7ea66ee998adaadc3fcc2)), closes [#6854](https://github.com/puppeteer/puppeteer/issues/6854) +* expose `Viewport` type ([#6881](https://github.com/puppeteer/puppeteer/issues/6881)) ([be7c229](https://github.com/puppeteer/puppeteer/commit/be7c22933c1dcf5eee797d61463171bd0ef44582)) +* improve TS types for launching browsers ([#6888](https://github.com/puppeteer/puppeteer/issues/6888)) ([98c8145](https://github.com/puppeteer/puppeteer/commit/98c81458c27f378eb66c38e1620e79e2ffde418e)) +* move CI npm config out of .npmrc ([#6901](https://github.com/puppeteer/puppeteer/issues/6901)) ([f7de60b](https://github.com/puppeteer/puppeteer/commit/f7de60be22d9bc6433ada7bfefeaa7f6f6f62047)) + +## [7.1.0](https://github.com/puppeteer/puppeteer/compare/v7.0.4...v7.1.0) (2021-02-12) + + +### Features + +* **page:** add color-gamut support to Page.emulateMediaFeatures ([#6857](https://github.com/puppeteer/puppeteer/issues/6857)) ([ad59357](https://github.com/puppeteer/puppeteer/commit/ad5935738d869cfce386a0d28b4bc6131457f962)), closes [#6761](https://github.com/puppeteer/puppeteer/issues/6761) + + +### Bug Fixes + +* add favicon test asset ([#6868](https://github.com/puppeteer/puppeteer/issues/6868)) ([a63f53c](https://github.com/puppeteer/puppeteer/commit/a63f53c9380545550503f5539494c72c607e19ac)) +* expose `ScreenshotOptions` type in type defs ([#6869](https://github.com/puppeteer/puppeteer/issues/6869)) ([63d48b2](https://github.com/puppeteer/puppeteer/commit/63d48b2ecba317b6c0a3acad87a7a3671c769dbc)), closes [#6866](https://github.com/puppeteer/puppeteer/issues/6866) +* expose puppeteer.Permission type ([#6856](https://github.com/puppeteer/puppeteer/issues/6856)) ([a5e174f](https://github.com/puppeteer/puppeteer/commit/a5e174f696eb192c541db64a603ea5cdf385a643)) +* jsonValue() type is generic ([#6865](https://github.com/puppeteer/puppeteer/issues/6865)) ([bdaba78](https://github.com/puppeteer/puppeteer/commit/bdaba7829da366aabbc81885d84bb2401ab3eaff)) +* wider compat TS types and CI checks to ensure correct type defs ([#6855](https://github.com/puppeteer/puppeteer/issues/6855)) ([6a0eb78](https://github.com/puppeteer/puppeteer/commit/6a0eb7841fd82493903b0b9fa153d2de181350eb)) + +### [7.0.4](https://github.com/puppeteer/puppeteer/compare/v7.0.3...v7.0.4) (2021-02-09) + + +### Bug Fixes + +* make publish bot run full build, not just tsc ([#6848](https://github.com/puppeteer/puppeteer/issues/6848)) ([f718b14](https://github.com/puppeteer/puppeteer/commit/f718b14b64df8be492d344ddd35e40961ff750c5)) + +### [7.0.3](https://github.com/puppeteer/puppeteer/compare/v7.0.2...v7.0.3) (2021-02-09) + + +### Bug Fixes + +* include lib/types.d.ts in files list ([#6844](https://github.com/puppeteer/puppeteer/issues/6844)) ([e34f317](https://github.com/puppeteer/puppeteer/commit/e34f317b37533256a063c1238609b488d263b998)) + +### [7.0.2](https://github.com/puppeteer/puppeteer/compare/v7.0.1...v7.0.2) (2021-02-09) + + +### Bug Fixes + +* much better TypeScript definitions ([#6837](https://github.com/puppeteer/puppeteer/issues/6837)) ([f1b46ab](https://github.com/puppeteer/puppeteer/commit/f1b46ab5faa262f893c17923579d0cf52268a764)) +* **domworld:** reset bindings when context changes ([#6766](https://github.com/puppeteer/puppeteer/issues/6766)) ([#6836](https://github.com/puppeteer/puppeteer/issues/6836)) ([4e8d074](https://github.com/puppeteer/puppeteer/commit/4e8d074c2f8384a2f283f5edf9ef69c40bd8464f)) +* **launcher:** output correct error message for browser ([#6815](https://github.com/puppeteer/puppeteer/issues/6815)) ([6c61874](https://github.com/puppeteer/puppeteer/commit/6c618747979c3a08f2727e9e22fe45cade8c926a)) + +### [7.0.1](https://github.com/puppeteer/puppeteer/compare/v7.0.0...v7.0.1) (2021-02-04) + + +### Bug Fixes + +* **typescript:** ship .d.ts file in npm package ([#6811](https://github.com/puppeteer/puppeteer/issues/6811)) ([a7e3c2e](https://github.com/puppeteer/puppeteer/commit/a7e3c2e09e9163eee2f15221aafa4400e6a75f91)) + +## [7.0.0](https://github.com/puppeteer/puppeteer/compare/v6.0.0...v7.0.0) (2021-02-03) + + +### ⚠ BREAKING CHANGES + +* - `page.screenshot` makes a screenshot with the clip dimensions, not cutting it by the ViewPort size. +* **chromium:** - `page.screenshot` cuts screenshot content by the ViewPort size, not ViewPort position. + +### Features + +* use `captureBeyondViewport` in `Page.captureScreenshot` ([#6805](https://github.com/puppeteer/puppeteer/issues/6805)) ([401d84e](https://github.com/puppeteer/puppeteer/commit/401d84e4a3508f9ca5c24dbfcad2a71571b1b8eb)) +* **chromium:** roll Chromium to r848005 ([#6801](https://github.com/puppeteer/puppeteer/issues/6801)) ([890d5c2](https://github.com/puppeteer/puppeteer/commit/890d5c2e57cdee7d73915a878bda86b72e26b608)) + +## [6.0.0](https://github.com/puppeteer/puppeteer/compare/v5.5.0...v6.0.0) (2021-02-02) + + +### ⚠ BREAKING CHANGES + +* **chromium:** The built-in `aria/` selector query handler doesn’t return ignored elements anymore. + +### Features + +* **chromium:** roll Chromium to r843427 ([#6797](https://github.com/puppeteer/puppeteer/issues/6797)) ([8f9fbdb](https://github.com/puppeteer/puppeteer/commit/8f9fbdbae68254600a9c73ab05f36146c975dba6)), closes [#6758](https://github.com/puppeteer/puppeteer/issues/6758) +* add page.emulateNetworkConditions ([#6759](https://github.com/puppeteer/puppeteer/issues/6759)) ([5ea76e9](https://github.com/puppeteer/puppeteer/commit/5ea76e9333c42ab5a751ca01aa5676a662f6c063)) +* **types:** expose typedefs to consumers ([#6745](https://github.com/puppeteer/puppeteer/issues/6745)) ([ebd087a](https://github.com/puppeteer/puppeteer/commit/ebd087a31661a1b701650d0be3e123cc5a813bd8)) +* add iPhone 11 models to DeviceDescriptors ([#6467](https://github.com/puppeteer/puppeteer/issues/6467)) ([50b810d](https://github.com/puppeteer/puppeteer/commit/50b810dab7fae5950ba086295462788f91ff1e6f)) +* support fetching and launching on Apple M1 ([9a8479a](https://github.com/puppeteer/puppeteer/commit/9a8479a52a7d8b51690b0732b2a10816cd1b8aef)), closes [#6495](https://github.com/puppeteer/puppeteer/issues/6495) [#6634](https://github.com/puppeteer/puppeteer/issues/6634) [#6641](https://github.com/puppeteer/puppeteer/issues/6641) [#6614](https://github.com/puppeteer/puppeteer/issues/6614) +* support promise as return value for page.waitForResponse predicate ([#6624](https://github.com/puppeteer/puppeteer/issues/6624)) ([b57f3fc](https://github.com/puppeteer/puppeteer/commit/b57f3fcd5393c68f51d82e670b004f5b116dcbc3)) + + +### Bug Fixes + +* **domworld:** fix waitfor bindings ([#6766](https://github.com/puppeteer/puppeteer/issues/6766)) ([#6775](https://github.com/puppeteer/puppeteer/issues/6775)) ([cac540b](https://github.com/puppeteer/puppeteer/commit/cac540be3ab8799a1d77b0951b16bc22ea1c2adb)) +* **launcher:** rename TranslateUI to Translate to match Chrome ([#6692](https://github.com/puppeteer/puppeteer/issues/6692)) ([d901696](https://github.com/puppeteer/puppeteer/commit/d901696e0d8901bcb23cf676a5e5ac562f821a0d)) +* do not use old utility world ([#6528](https://github.com/puppeteer/puppeteer/issues/6528)) ([fb85911](https://github.com/puppeteer/puppeteer/commit/fb859115c0e2829bae1d1b32edbf642988e2ef76)), closes [#6527](https://github.com/puppeteer/puppeteer/issues/6527) +* update to https-proxy-agent@^5.0.0 to fix `ERR_INVALID_PROTOCOL` ([#6555](https://github.com/puppeteer/puppeteer/issues/6555)) ([3bf5a55](https://github.com/puppeteer/puppeteer/commit/3bf5a552890ee80cc4326b1e430424b0fdad4363)) + +## [5.5.0](https://github.com/puppeteer/puppeteer/compare/v5.4.1...v5.5.0) (2020-11-16) + + +### Features + +* **chromium:** roll Chromium to r818858 ([#6526](https://github.com/puppeteer/puppeteer/issues/6526)) ([b549256](https://github.com/puppeteer/puppeteer/commit/b54925695200cad32f470f8eb407259606447a85)) + + +### Bug Fixes + +* **common:** fix generic type of `_isClosedPromise` ([#6579](https://github.com/puppeteer/puppeteer/issues/6579)) ([122f074](https://github.com/puppeteer/puppeteer/commit/122f074f92f47a7b9aa08091851e51a07632d23b)) +* **domworld:** fix missing binding for waittasks ([#6562](https://github.com/puppeteer/puppeteer/issues/6562)) ([67da1cf](https://github.com/puppeteer/puppeteer/commit/67da1cf866703f5f581c9cce4923697ac38129ef)) diff --git a/remote/test/puppeteer/packages/puppeteer/api-extractor.docs.json b/remote/test/puppeteer/packages/puppeteer/api-extractor.docs.json new file mode 100644 index 0000000000..88fcdbfd38 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/api-extractor.docs.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "<projectFolder>/lib/esm/puppeteer/puppeteer.d.ts", + + "extends": "./api-extractor.json", + + "dtsRollup": { + "enabled": false + }, + + "docModel": { + "enabled": true, + "apiJsonFilePath": "<projectFolder>/../../docs/<unscopedPackageName>.api.json" + } +} diff --git a/remote/test/puppeteer/packages/puppeteer/api-extractor.json b/remote/test/puppeteer/packages/puppeteer/api-extractor.json new file mode 100644 index 0000000000..486b3929e7 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/api-extractor.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "<projectFolder>/lib/esm/puppeteer/puppeteer.d.ts", + "bundledPackages": ["puppeteer-core"], + + "apiReport": { + "enabled": false + }, + + "docModel": { + "enabled": false + }, + + "dtsRollup": { + "enabled": true, + "untrimmedFilePath": "", + "alphaTrimmedFilePath": "lib/types.d.ts" + }, + + "tsdocMetadata": { + "enabled": false + }, + + "messages": { + "compilerMessageReporting": { + "default": { + "logLevel": "warning" + } + }, + + "extractorMessageReporting": { + "ae-wrong-input-file-type": { + "logLevel": "none" + }, + "ae-internal-missing-underscore": { + "logLevel": "none" + }, + "default": { + "logLevel": "warning" + } + }, + + "tsdocMessageReporting": { + "default": { + "logLevel": "warning" + } + } + } +} diff --git a/remote/test/puppeteer/packages/puppeteer/install.mjs b/remote/test/puppeteer/packages/puppeteer/install.mjs new file mode 100755 index 0000000000..2724e129d9 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/install.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * This file is part of public API. + * + * By default, the `puppeteer` package runs this script during the installation + * process unless one of the env flags is provided. + * `puppeteer-core` package doesn't include this step at all. However, it's + * still possible to install a supported browser using this script when + * necessary. + */ + +async function importInstaller() { + try { + return await import('puppeteer/internal/node/install.js'); + } catch { + console.warn( + 'Skipping browser installation because the Puppeteer build is not available. Run `npm install` again after you have re-built Puppeteer.' + ); + process.exit(0); + } +} + +try { + const {downloadBrowser} = await importInstaller(); + downloadBrowser(); +} catch (error) { + console.warn('Browser download failed', error); +} diff --git a/remote/test/puppeteer/packages/puppeteer/package.json b/remote/test/puppeteer/packages/puppeteer/package.json new file mode 100644 index 0000000000..0419e4b459 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/package.json @@ -0,0 +1,133 @@ +{ + "name": "puppeteer", + "version": "21.10.0", + "description": "A high-level API to control headless Chrome over the DevTools Protocol", + "keywords": [ + "puppeteer", + "chrome", + "headless", + "automation" + ], + "type": "commonjs", + "bin": "./lib/esm/puppeteer/node/cli.js", + "main": "./lib/cjs/puppeteer/puppeteer.js", + "types": "./lib/types.d.ts", + "exports": { + ".": { + "types": "./lib/types.d.ts", + "import": "./lib/esm/puppeteer/puppeteer.js", + "require": "./lib/cjs/puppeteer/puppeteer.js" + }, + "./internal/*": { + "import": "./lib/esm/puppeteer/*", + "require": "./lib/cjs/puppeteer/*" + }, + "./*": { + "import": "./*", + "require": "./*" + } + }, + "repository": { + "type": "git", + "url": "https://github.com/puppeteer/puppeteer/tree/main/packages/puppeteer" + }, + "engines": { + "node": ">=16.13.2" + }, + "scripts": { + "build:docs": "wireit", + "build": "wireit", + "clean": "../../tools/clean.js", + "postinstall": "node install.mjs", + "prepack": "wireit" + }, + "wireit": { + "prepack": { + "command": "tsx ../../tools/cp.ts ../../README.md README.md", + "files": [ + "../../README.md" + ], + "output": [ + "README.md" + ] + }, + "build": { + "dependencies": [ + "build:tsc", + "build:types" + ] + }, + "generate:package-json": { + "command": "tsx ../../tools/generate_module_package_json.ts lib/esm/package.json", + "files": [ + "../../tools/generate_module_package_json.ts" + ], + "output": [ + "lib/esm/package.json" + ] + }, + "build:docs": { + "command": "api-extractor run --local --config \"./api-extractor.docs.json\"", + "files": [ + "api-extractor.docs.json", + "lib/esm/puppeteer/puppeteer-core.d.ts", + "tsconfig.json" + ], + "dependencies": [ + "build:tsc" + ] + }, + "build:tsc": { + "command": "tsc -b && tsx ../../tools/chmod.ts 755 lib/cjs/puppeteer/node/cli.js lib/esm/puppeteer/node/cli.js", + "clean": "if-file-deleted", + "dependencies": [ + "../puppeteer-core:build", + "../browsers:build", + "generate:package-json" + ], + "files": [ + "src/**" + ], + "output": [ + "lib/{cjs,esm}/**", + "!lib/esm/package.json" + ] + }, + "build:types": { + "command": "api-extractor run --local && eslint --cache-location .eslintcache --cache --ext=ts --no-ignore --no-eslintrc -c=../../.eslintrc.types.cjs --fix lib/types.d.ts", + "files": [ + "../../.eslintrc.types.cjs", + "api-extractor.json", + "lib/esm/puppeteer/types.d.ts", + "tsconfig.json" + ], + "output": [ + "lib/types.d.ts" + ], + "dependencies": [ + "build:tsc" + ] + } + }, + "files": [ + "lib", + "src", + "install.mjs", + "!*.test.ts", + "!*.test.js", + "!*.test.d.ts", + "!*.test.js.map", + "!*.test.d.ts.map", + "!*.tsbuildinfo" + ], + "author": "The Chromium Authors", + "license": "Apache-2.0", + "dependencies": { + "cosmiconfig": "9.0.0", + "puppeteer-core": "21.10.0", + "@puppeteer/browsers": "1.9.1" + }, + "devDependencies": { + "@types/node": "18.17.15" + } +} diff --git a/remote/test/puppeteer/packages/puppeteer/src/getConfiguration.ts b/remote/test/puppeteer/packages/puppeteer/src/getConfiguration.ts new file mode 100644 index 0000000000..28cf026eb7 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/src/getConfiguration.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {homedir} from 'os'; +import {join} from 'path'; + +import {cosmiconfigSync} from 'cosmiconfig'; +import type {Configuration, Product} from 'puppeteer-core'; + +/** + * @internal + */ +function isSupportedProduct(product: unknown): product is Product { + switch (product) { + case 'chrome': + case 'firefox': + return true; + default: + return false; + } +} + +/** + * @internal + */ +export const getConfiguration = (): Configuration => { + const result = cosmiconfigSync('puppeteer', { + searchStrategy: 'global', + }).search(); + const configuration: Configuration = result ? result.config : {}; + + configuration.logLevel = (process.env['PUPPETEER_LOGLEVEL'] ?? + process.env['npm_config_LOGLEVEL'] ?? + process.env['npm_package_config_LOGLEVEL'] ?? + configuration.logLevel ?? + 'warn') as 'silent' | 'error' | 'warn'; + + // Merging environment variables. + configuration.defaultProduct = (process.env['PUPPETEER_PRODUCT'] ?? + process.env['npm_config_puppeteer_product'] ?? + process.env['npm_package_config_puppeteer_product'] ?? + configuration.defaultProduct ?? + 'chrome') as Product; + + configuration.executablePath = + process.env['PUPPETEER_EXECUTABLE_PATH'] ?? + process.env['npm_config_puppeteer_executable_path'] ?? + process.env['npm_package_config_puppeteer_executable_path'] ?? + configuration.executablePath; + + // Default to skipDownload if executablePath is set + if (configuration.executablePath) { + configuration.skipDownload = true; + } + + // Set skipDownload explicitly or from default + configuration.skipDownload = Boolean( + process.env['PUPPETEER_SKIP_DOWNLOAD'] ?? + process.env['npm_config_puppeteer_skip_download'] ?? + process.env['npm_package_config_puppeteer_skip_download'] ?? + configuration.skipDownload + ); + + // Set skipChromeDownload explicitly or from default + configuration.skipChromeDownload = Boolean( + process.env['PUPPETEER_SKIP_CHROME_DOWNLOAD'] ?? + process.env['npm_config_puppeteer_skip_chrome_download'] ?? + process.env['npm_package_config_puppeteer_skip_chrome_download'] ?? + configuration.skipChromeDownload + ); + + // Set skipChromeDownload explicitly or from default + configuration.skipChromeHeadlessShellDownload = Boolean( + process.env['PUPPETEER_SKIP_CHROME_HEADLESS_SHELL_DOWNLOAD'] ?? + process.env['npm_config_puppeteer_skip_chrome_headless_shell_download'] ?? + process.env[ + 'npm_package_config_puppeteer_skip_chrome_headless_shell_download' + ] ?? + configuration.skipChromeHeadlessShellDownload + ); + + // Prepare variables used in browser downloading + if (!configuration.skipDownload) { + configuration.browserRevision = + process.env['PUPPETEER_BROWSER_REVISION'] ?? + process.env['npm_config_puppeteer_browser_revision'] ?? + process.env['npm_package_config_puppeteer_browser_revision'] ?? + configuration.browserRevision; + + const downloadHost = + process.env['PUPPETEER_DOWNLOAD_HOST'] ?? + process.env['npm_config_puppeteer_download_host'] ?? + process.env['npm_package_config_puppeteer_download_host']; + + if (downloadHost && configuration.logLevel === 'warn') { + console.warn( + `PUPPETEER_DOWNLOAD_HOST is deprecated. Use PUPPETEER_DOWNLOAD_BASE_URL instead.` + ); + } + + configuration.downloadBaseUrl = + process.env['PUPPETEER_DOWNLOAD_BASE_URL'] ?? + process.env['npm_config_puppeteer_download_base_url'] ?? + process.env['npm_package_config_puppeteer_download_base_url'] ?? + configuration.downloadBaseUrl ?? + downloadHost; + + configuration.downloadPath = + process.env['PUPPETEER_DOWNLOAD_PATH'] ?? + process.env['npm_config_puppeteer_download_path'] ?? + process.env['npm_package_config_puppeteer_download_path'] ?? + configuration.downloadPath; + } + + configuration.cacheDirectory = + process.env['PUPPETEER_CACHE_DIR'] ?? + process.env['npm_config_puppeteer_cache_dir'] ?? + process.env['npm_package_config_puppeteer_cache_dir'] ?? + configuration.cacheDirectory ?? + join(homedir(), '.cache', 'puppeteer'); + configuration.temporaryDirectory = + process.env['PUPPETEER_TMP_DIR'] ?? + process.env['npm_config_puppeteer_tmp_dir'] ?? + process.env['npm_package_config_puppeteer_tmp_dir'] ?? + configuration.temporaryDirectory; + + configuration.experiments ??= {}; + + // Validate configuration. + if (!isSupportedProduct(configuration.defaultProduct)) { + throw new Error(`Unsupported product ${configuration.defaultProduct}`); + } + + return configuration; +}; diff --git a/remote/test/puppeteer/packages/puppeteer/src/node/cli.ts b/remote/test/puppeteer/packages/puppeteer/src/node/cli.ts new file mode 100644 index 0000000000..9a25c59327 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/src/node/cli.ts @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {CLI, Browser} from '@puppeteer/browsers'; +import {PUPPETEER_REVISIONS} from 'puppeteer-core/internal/revisions.js'; + +import puppeteer from '../puppeteer.js'; + +// TODO: deprecate downloadPath in favour of cacheDirectory. +const cacheDir = + puppeteer.configuration.downloadPath ?? + puppeteer.configuration.cacheDirectory!; + +void new CLI({ + cachePath: cacheDir, + scriptName: 'puppeteer', + prefixCommand: { + cmd: 'browsers', + description: 'Manage browsers of this Puppeteer installation', + }, + allowCachePathOverride: false, + pinnedBrowsers: { + [Browser.CHROME]: PUPPETEER_REVISIONS.chrome, + [Browser.FIREFOX]: PUPPETEER_REVISIONS.firefox, + [Browser.CHROMEHEADLESSSHELL]: PUPPETEER_REVISIONS['chrome-headless-shell'], + }, +}).run(process.argv); diff --git a/remote/test/puppeteer/packages/puppeteer/src/node/install.ts b/remote/test/puppeteer/packages/puppeteer/src/node/install.ts new file mode 100644 index 0000000000..76bad868b8 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/src/node/install.ts @@ -0,0 +1,184 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + install, + Browser, + resolveBuildId, + makeProgressCallback, + detectBrowserPlatform, +} from '@puppeteer/browsers'; +import type {Product} from 'puppeteer-core'; +import {PUPPETEER_REVISIONS} from 'puppeteer-core/internal/revisions.js'; + +import {getConfiguration} from '../getConfiguration.js'; + +/** + * @internal + */ +const supportedProducts = { + chrome: 'Chrome', + firefox: 'Firefox Nightly', +} as const; + +/** + * @internal + */ +export async function downloadBrowser(): Promise<void> { + overrideProxy(); + + const configuration = getConfiguration(); + if (configuration.skipDownload) { + logPolitely('**INFO** Skipping browser download as instructed.'); + return; + } + + const downloadBaseUrl = configuration.downloadBaseUrl; + + const platform = detectBrowserPlatform(); + if (!platform) { + throw new Error('The current platform is not supported.'); + } + + const product = configuration.defaultProduct!; + const browser = productToBrowser(product); + + const unresolvedBuildId = + configuration.browserRevision || PUPPETEER_REVISIONS[product] || 'latest'; + const unresolvedShellBuildId = + configuration.browserRevision || + PUPPETEER_REVISIONS['chrome-headless-shell'] || + 'latest'; + + // TODO: deprecate downloadPath in favour of cacheDirectory. + const cacheDir = configuration.downloadPath ?? configuration.cacheDirectory!; + + try { + const installationJobs = []; + + if (configuration.skipChromeDownload) { + logPolitely('**INFO** Skipping Chrome download as instructed.'); + } else { + const buildId = await resolveBuildId( + browser, + platform, + unresolvedBuildId + ); + installationJobs.push( + install({ + browser, + cacheDir, + platform, + buildId, + downloadProgressCallback: makeProgressCallback(browser, buildId), + baseUrl: downloadBaseUrl, + }) + .then(result => { + logPolitely( + `${supportedProducts[product]} (${result.buildId}) downloaded to ${result.path}` + ); + }) + .catch(error => { + throw new Error( + `ERROR: Failed to set up ${supportedProducts[product]} v${buildId}! Set "PUPPETEER_SKIP_DOWNLOAD" env variable to skip download.`, + { + cause: error, + } + ); + }) + ); + } + + if (browser === Browser.CHROME) { + if (configuration.skipChromeHeadlessShellDownload) { + logPolitely('**INFO** Skipping Chrome download as instructed.'); + } else { + const shellBuildId = await resolveBuildId( + browser, + platform, + unresolvedShellBuildId + ); + + installationJobs.push( + install({ + browser: Browser.CHROMEHEADLESSSHELL, + cacheDir, + platform, + buildId: shellBuildId, + downloadProgressCallback: makeProgressCallback( + browser, + shellBuildId + ), + baseUrl: downloadBaseUrl, + }) + .then(result => { + logPolitely( + `${Browser.CHROMEHEADLESSSHELL} (${result.buildId}) downloaded to ${result.path}` + ); + }) + .catch(error => { + throw new Error( + `ERROR: Failed to set up ${Browser.CHROMEHEADLESSSHELL} v${shellBuildId}! Set "PUPPETEER_SKIP_DOWNLOAD" env variable to skip download.`, + { + cause: error, + } + ); + }) + ); + } + } + + await Promise.all(installationJobs); + } catch (error) { + console.error(error); + process.exit(1); + } +} + +function productToBrowser(product?: Product) { + switch (product) { + case 'chrome': + return Browser.CHROME; + case 'firefox': + return Browser.FIREFOX; + } + return Browser.CHROME; +} + +/** + * @internal + */ +function logPolitely(toBeLogged: unknown): void { + const logLevel = process.env['npm_config_loglevel'] || ''; + const logLevelDisplay = ['silent', 'error', 'warn'].indexOf(logLevel) > -1; + + // eslint-disable-next-line no-console + if (!logLevelDisplay) { + console.log(toBeLogged); + } +} + +/** + * @internal + */ +function overrideProxy() { + // Override current environment proxy settings with npm configuration, if any. + const NPM_HTTPS_PROXY = + process.env['npm_config_https_proxy'] || process.env['npm_config_proxy']; + const NPM_HTTP_PROXY = + process.env['npm_config_http_proxy'] || process.env['npm_config_proxy']; + const NPM_NO_PROXY = process.env['npm_config_no_proxy']; + + if (NPM_HTTPS_PROXY) { + process.env['HTTPS_PROXY'] = NPM_HTTPS_PROXY; + } + if (NPM_HTTP_PROXY) { + process.env['HTTP_PROXY'] = NPM_HTTP_PROXY; + } + if (NPM_NO_PROXY) { + process.env['NO_PROXY'] = NPM_NO_PROXY; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer/src/puppeteer.ts b/remote/test/puppeteer/packages/puppeteer/src/puppeteer.ts new file mode 100644 index 0000000000..4f4321bc6c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/src/puppeteer.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export type {Protocol} from 'puppeteer-core'; + +export * from 'puppeteer-core/internal/puppeteer-core.js'; + +import {PuppeteerNode} from 'puppeteer-core/internal/node/PuppeteerNode.js'; + +import {getConfiguration} from './getConfiguration.js'; + +const configuration = getConfiguration(); + +/** + * @public + */ +const puppeteer = new PuppeteerNode({ + isPuppeteerCore: false, + configuration, +}); + +export const { + /** + * @public + */ + connect, + /** + * @public + */ + defaultArgs, + /** + * @public + */ + executablePath, + /** + * @public + */ + launch, + /** + * @public + */ + trimCache, +} = puppeteer; + +export default puppeteer; diff --git a/remote/test/puppeteer/packages/puppeteer/src/tsconfig.cjs.json b/remote/test/puppeteer/packages/puppeteer/src/tsconfig.cjs.json new file mode 100644 index 0000000000..0cb78dca7f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/src/tsconfig.cjs.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "../lib/cjs/puppeteer" + } +} diff --git a/remote/test/puppeteer/packages/puppeteer/src/tsconfig.esm.json b/remote/test/puppeteer/packages/puppeteer/src/tsconfig.esm.json new file mode 100644 index 0000000000..a848929f4f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/src/tsconfig.esm.json @@ -0,0 +1,6 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../lib/esm/puppeteer" + } +} diff --git a/remote/test/puppeteer/packages/puppeteer/tsconfig.json b/remote/test/puppeteer/packages/puppeteer/tsconfig.json new file mode 100644 index 0000000000..11314a80e3 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "compilerOptions": { + // API extractor doesn't work well with NodeNext module resolution, so we + // just stick with ol'fashion path resolution. + "baseUrl": ".", + "paths": { + "puppeteer-core/internal/*": ["../puppeteer-core/lib/esm/puppeteer/*"], + }, + }, + "references": [ + {"path": "src/tsconfig.esm.json"}, + {"path": "src/tsconfig.cjs.json"}, + ], +} diff --git a/remote/test/puppeteer/packages/puppeteer/tsdoc.json b/remote/test/puppeteer/packages/puppeteer/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} diff --git a/remote/test/puppeteer/packages/testserver/CHANGELOG.md b/remote/test/puppeteer/packages/testserver/CHANGELOG.md new file mode 100644 index 0000000000..bb971ef46d --- /dev/null +++ b/remote/test/puppeteer/packages/testserver/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## [0.6.0](https://github.com/puppeteer/puppeteer/compare/testserver-v0.5.0...testserver-v0.6.0) (2022-10-05) + + +### Features + +* separate puppeteer and puppeteer-core ([#9023](https://github.com/puppeteer/puppeteer/issues/9023)) ([f42336c](https://github.com/puppeteer/puppeteer/commit/f42336cf83982332829ca7e14ee48d8676e11545)) diff --git a/remote/test/puppeteer/packages/testserver/LICENSE b/remote/test/puppeteer/packages/testserver/LICENSE new file mode 100644 index 0000000000..afdfe50e72 --- /dev/null +++ b/remote/test/puppeteer/packages/testserver/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2017 Google Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/remote/test/puppeteer/packages/testserver/README.md b/remote/test/puppeteer/packages/testserver/README.md new file mode 100644 index 0000000000..d22b2da449 --- /dev/null +++ b/remote/test/puppeteer/packages/testserver/README.md @@ -0,0 +1,18 @@ +# TestServer + +This test server is used internally by Puppeteer to test Puppeteer itself. + +### Example + +```ts +const {TestServer} = require('@pptr/testserver'); + +(async(() => { + const httpServer = await TestServer.create(__dirname, 8000), + const httpsServer = await TestServer.createHTTPS(__dirname, 8001) + httpServer.setRoute('/hello', (req, res) => { + res.end('Hello, world!'); + }); + console.log('HTTP and HTTPS servers are running!'); +})(); +``` diff --git a/remote/test/puppeteer/packages/testserver/cert.pem b/remote/test/puppeteer/packages/testserver/cert.pem new file mode 100644 index 0000000000..fd3838535a --- /dev/null +++ b/remote/test/puppeteer/packages/testserver/cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDWDCCAkCgAwIBAgIUM8Tmw+D1j+eVz9x9So4zRVqFsKowDQYJKoZIhvcNAQEL +BQAwGjEYMBYGA1UEAwwPcHVwcGV0ZWVyLXRlc3RzMB4XDTIwMDUxMzA4MDQyOVoX +DTMwMDUxMTA4MDQyOVowGjEYMBYGA1UEAwwPcHVwcGV0ZWVyLXRlc3RzMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApWbbhgc6CnWywd8xGETT1mfLi3wi +KIbpAUHghLF4sj0jXz8vLh/4oicpQ12d6bsz+IAi7qrdXNh11P5nEej6/Gx4fWzB +gGdrJFGPqsvXuhYdzZAmy6xOaWcLIJeQ543bXv3YeST7EGRXJBc/ocTo2jIGTGjq +hksFaid910VQlX3KGOLTDMUCk00TeEYBTTUx47PWoIsxVqbl2RzVXRSWL5hlPWlW +29/BQtBGmsXxZyWtqqHudiUulGBSr4LcPyicZLI8nqCqD0ioS0TEmGh61nRBuwBa +xmLCvPmpt0+sDuOU+1bme3w8juvTVToBIFxGB86rADd3ys+8NeZzXqi+bQIDAQAB +o4GVMIGSMB0GA1UdDgQWBBT/m3vdkZpQyVQFdYrKHVoAHXDFODAfBgNVHSMEGDAW +gBT/m3vdkZpQyVQFdYrKHVoAHXDFODAPBgNVHRMBAf8EBTADAQH/MD8GA1UdEQQ4 +MDaCGHd3dy5wdXBwZXRlZXItdGVzdHMudGVzdIIad3d3LnB1cHBldGVlci10ZXN0 +cy0xLnRlc3QwDQYJKoZIhvcNAQELBQADggEBAI1qp5ZppV1R3e8XxzwwkFDPFN8W +Pe3AoqhAKyJnJl1NUn9q3sroEeSQRhODWUHCd7lENzhsT+3mzonNNkN9B/hq0rpK +KHHczXILDqdyuxH3LxQ1VHGE8VN2NbdkfobtzAsA3woiJxOuGeusXJnKB4kJQeIP +V+BMEZWeaSDC2PREkG7GOezmE1/WDUCYaorPw2whdCA5wJvTW3zXpJjYhfsld+5z +KuErx4OCxRJij73/BD9SpLxDEY1cdl819F1IvxsRGhmTIaSly2hQLrhOgo1jgZtV +FGCa6DSlXnQGLaV+N+ssR0lkCksNrNBVDfA1bP5bT/4VCcwUWwm9TUeF0Qo= +-----END CERTIFICATE----- diff --git a/remote/test/puppeteer/packages/testserver/key.pem b/remote/test/puppeteer/packages/testserver/key.pem new file mode 100644 index 0000000000..cbc3acb229 --- /dev/null +++ b/remote/test/puppeteer/packages/testserver/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQClZtuGBzoKdbLB +3zEYRNPWZ8uLfCIohukBQeCEsXiyPSNfPy8uH/iiJylDXZ3puzP4gCLuqt1c2HXU +/mcR6Pr8bHh9bMGAZ2skUY+qy9e6Fh3NkCbLrE5pZwsgl5Dnjdte/dh5JPsQZFck +Fz+hxOjaMgZMaOqGSwVqJ33XRVCVfcoY4tMMxQKTTRN4RgFNNTHjs9agizFWpuXZ +HNVdFJYvmGU9aVbb38FC0EaaxfFnJa2qoe52JS6UYFKvgtw/KJxksjyeoKoPSKhL +RMSYaHrWdEG7AFrGYsK8+am3T6wO45T7VuZ7fDyO69NVOgEgXEYHzqsAN3fKz7w1 +5nNeqL5tAgMBAAECggEAKPveo0xBHnxhidZzBM9xKixX7D0a/a3IKI6ZQmfzPz8U +97HhT+2OHyfS+qVEzribPRULEtZ1uV7Ne7R5958iKc/63yFGpTl6++nVzn1p++sl +AV2Zr1gHqehlgnLr7eRhmh0OOZ5nM32ZdhDorH3tMLu6gc5xZktKkS4t6Vx8hj3a +Docx+rbawp8GRd0p7I6vzIE3bsDab8hC+RTRO63q2G0BqgKwV9ZNtJxQgcDJ5L8N +6gtM2z5nKXAIOCbCQYa1PsrDh3IRA/ZNxEeA9G3YQjwlZYCWmdRRplgDraYxcTBO +oQGjaLwICNdcprMacPD6cCSgrI+PadzyMsAuk9SgpQKBgQDO9PT4gK40Pm+Damxv ++tWYBFmvn3vasmyolc1zVDltsxQbQTjKhVpTLXTTGmrIhDXEIIV9I4rg164WptQs +6Brp2EwYR7ZJIrjvXs/9i2QTW1ZXvhdiWpB3s+RXD5VHGovHUadcI6wOgw2Cl+Jk +zXjSIgyXKM99N1MAonuR7DyzTwKBgQDMmPX+9vWZMpS/gc6JLQiPPoGszE6tYjXg +W3LpRUNqmO0/bDDjslbebDgrGAmhlkJlxzH6gz96VmGm1evEGPEet3euy8S9zuM3 +LCgEM9Ulqa3JbInwtKupmKv76Im+XWLLSxAXbfiel1zFRRwxI99A3ad0QRZ6Bov5 +3cHJBwvzgwKBgAU5HW2gIcVjxgC1EOOKmxVpFrJd/gw48JEYpsTAXWqtWFaPwNUr +pGnw/b/OLN++pnS6tWPBH+Ioz1X3A+fWO8enE9SRCsKxw6UW6XzmpbHvXjB8ta5f +xsGeoqan2AahXuG659RlehQrro2bM7WDkgcLoPG3r/TjDo83ipLWOXn1AoGAKWiL +4R56dpcWI+xRsNG8ecFc3Ww8QDswTEg16aBrFJf+7GcpPexKSJn+hDpJOLsAlTjL +lLgbkNcKzIlfPkEOC/l175quJvxIYFI/hxo2eXjuA2ZERMNMOvb7V/CocC7WX+7B +Qvyu5OodjI+ANTHdbXNvAMhrlCbfDaMkJVuXv6ECgYBzvY4aYmVoFsr+72/EfLls +Dz9pi55tUUWc61w6ovd+iliawvXeGi4wibtTH4iGj/C2sJIaMmOD99NQ7Oi/x89D +oMgSUemkoFL8FGsZGyZ7szqxyON1jP42Bm2MQrW5kIf7Y4yaIGhoak5JNxn2JUyV +gupVbY1mQ1GTPByxHeLh1w== +-----END PRIVATE KEY----- diff --git a/remote/test/puppeteer/packages/testserver/package.json b/remote/test/puppeteer/packages/testserver/package.json new file mode 100644 index 0000000000..3a9ecf9c65 --- /dev/null +++ b/remote/test/puppeteer/packages/testserver/package.json @@ -0,0 +1,36 @@ +{ + "name": "@pptr/testserver", + "version": "0.6.0", + "description": "testing server", + "main": "lib/index.js", + "scripts": { + "build": "wireit", + "clean": "../../tools/clean.js" + }, + "wireit": { + "build": { + "command": "tsc -b", + "clean": "if-file-deleted", + "files": [ + "src/**" + ], + "output": [ + "lib/**", + "tsconfig.tsbuildinfo" + ] + } + }, + "repository": { + "type": "git", + "url": "https://github.com/puppeteer/puppeteer/tree/main/packages/testserver" + }, + "author": "The Chromium Authors", + "license": "Apache-2.0", + "dependencies": { + "mime": "3.0.0", + "ws": "8.16.0" + }, + "devDependencies": { + "@types/mime": "3.0.4" + } +} diff --git a/remote/test/puppeteer/packages/testserver/src/index.ts b/remote/test/puppeteer/packages/testserver/src/index.ts new file mode 100644 index 0000000000..2618fd4d0d --- /dev/null +++ b/remote/test/puppeteer/packages/testserver/src/index.ts @@ -0,0 +1,311 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert'; +import {readFile, readFileSync} from 'fs'; +import { + createServer as createHttpServer, + type IncomingMessage, + type RequestListener, + type Server as HttpServer, + type ServerResponse, +} from 'http'; +import { + createServer as createHttpsServer, + type Server as HttpsServer, + type ServerOptions as HttpsServerOptions, +} from 'https'; +import type {AddressInfo} from 'net'; +import {join} from 'path'; +import type {Duplex} from 'stream'; +import {gzip} from 'zlib'; + +import {getType as getMimeType} from 'mime'; +import {Server as WebSocketServer, type WebSocket} from 'ws'; + +interface Subscriber { + resolve: (msg: IncomingMessage) => void; + reject: (err?: Error) => void; + promise: Promise<IncomingMessage>; +} + +type TestIncomingMessage = IncomingMessage & {postBody?: Promise<string>}; + +export class TestServer { + PORT!: number; + PREFIX!: string; + CROSS_PROCESS_PREFIX!: string; + EMPTY_PAGE!: string; + + #dirPath: string; + #server: HttpsServer | HttpServer; + #wsServer: WebSocketServer; + + #startTime = new Date(); + #cachedPathPrefix?: string; + + #connections = new Set<Duplex>(); + #routes = new Map< + string, + (msg: IncomingMessage, res: ServerResponse) => void + >(); + #auths = new Map<string, {username: string; password: string}>(); + #csp = new Map<string, string>(); + #gzipRoutes = new Set<string>(); + #requestSubscribers = new Map<string, Subscriber>(); + #requests = new Set<ServerResponse>(); + + static async create(dirPath: string): Promise<TestServer> { + let res!: (value: unknown) => void; + const promise = new Promise(resolve => { + res = resolve; + }); + const server = new TestServer(dirPath); + server.#server.once('listening', res); + server.#server.listen(0); + await promise; + return server; + } + + static async createHTTPS(dirPath: string): Promise<TestServer> { + let res!: (value: unknown) => void; + const promise = new Promise(resolve => { + res = resolve; + }); + const server = new TestServer(dirPath, { + key: readFileSync(join(__dirname, '..', 'key.pem')), + cert: readFileSync(join(__dirname, '..', 'cert.pem')), + passphrase: 'aaaa', + }); + server.#server.once('listening', res); + server.#server.listen(0); + await promise; + return server; + } + + constructor(dirPath: string, sslOptions?: HttpsServerOptions) { + this.#dirPath = dirPath; + + if (sslOptions) { + this.#server = createHttpsServer(sslOptions, this.#onRequest); + } else { + this.#server = createHttpServer(this.#onRequest); + } + this.#server.on('connection', this.#onServerConnection); + // Disable this as sometimes the socket will timeout + // We rely on the fact that we will close the server at the end + this.#server.keepAliveTimeout = 0; + this.#wsServer = new WebSocketServer({server: this.#server}); + this.#wsServer.on('connection', this.#onWebSocketConnection); + } + + #onServerConnection = (connection: Duplex): void => { + this.#connections.add(connection); + // ECONNRESET is a legit error given + // that tab closing simply kills process. + connection.on('error', error => { + if ((error as NodeJS.ErrnoException).code !== 'ECONNRESET') { + throw error; + } + }); + connection.once('close', () => { + return this.#connections.delete(connection); + }); + }; + + get port(): number { + return (this.#server.address() as AddressInfo).port; + } + + enableHTTPCache(pathPrefix: string): void { + this.#cachedPathPrefix = pathPrefix; + } + + setAuth(path: string, username: string, password: string): void { + this.#auths.set(path, {username, password}); + } + + enableGzip(path: string): void { + this.#gzipRoutes.add(path); + } + + setCSP(path: string, csp: string): void { + this.#csp.set(path, csp); + } + + async stop(): Promise<void> { + this.reset(); + for (const socket of this.#connections) { + socket.destroy(); + } + this.#connections.clear(); + await new Promise(x => { + return this.#server.close(x); + }); + } + + setRoute( + path: string, + handler: (req: IncomingMessage, res: ServerResponse) => void + ): void { + this.#routes.set(path, handler); + } + + setRedirect(from: string, to: string): void { + this.setRoute(from, (_, res) => { + res.writeHead(302, {location: to}); + res.end(); + }); + } + + waitForRequest(path: string): Promise<TestIncomingMessage> { + const subscriber = this.#requestSubscribers.get(path); + if (subscriber) { + return subscriber.promise; + } + let resolve!: (value: IncomingMessage) => void; + let reject!: (reason?: Error) => void; + const promise = new Promise<IncomingMessage>((res, rej) => { + resolve = res; + reject = rej; + }); + this.#requestSubscribers.set(path, {resolve, reject, promise}); + return promise; + } + + reset(): void { + this.#routes.clear(); + this.#auths.clear(); + this.#csp.clear(); + this.#gzipRoutes.clear(); + const error = new Error('Static Server has been reset'); + for (const subscriber of this.#requestSubscribers.values()) { + subscriber.reject.call(undefined, error); + } + this.#requestSubscribers.clear(); + for (const request of this.#requests.values()) { + if (!request.writableEnded) { + request.end(); + } + } + this.#requests.clear(); + } + + #onRequest: RequestListener = ( + request: TestIncomingMessage, + response + ): void => { + this.#requests.add(response); + + request.on('error', (error: {code: string}) => { + if (error.code === 'ECONNRESET') { + response.end(); + } else { + throw error; + } + }); + request.postBody = new Promise(resolve => { + let body = ''; + request.on('data', (chunk: string) => { + return (body += chunk); + }); + request.on('end', () => { + return resolve(body); + }); + }); + assert(request.url); + const url = new URL(request.url, `https://${request.headers.host}`); + const path = url.pathname + url.search; + const auth = this.#auths.get(path); + if (auth) { + const credentials = Buffer.from( + (request.headers.authorization || '').split(' ')[1] || '', + 'base64' + ).toString(); + if (credentials !== `${auth.username}:${auth.password}`) { + response.writeHead(401, { + 'WWW-Authenticate': 'Basic realm="Secure Area"', + }); + response.end('HTTP Error 401 Unauthorized: Access is denied'); + return; + } + } + const subscriber = this.#requestSubscribers.get(path); + if (subscriber) { + subscriber.resolve.call(undefined, request); + this.#requestSubscribers.delete(path); + } + const handler = this.#routes.get(path); + if (handler) { + handler.call(undefined, request, response); + } else { + this.serveFile(request, response, path); + } + }; + + serveFile( + request: IncomingMessage, + response: ServerResponse, + pathName: string + ): void { + if (pathName === '/') { + pathName = '/index.html'; + } + const filePath = join(this.#dirPath, pathName.substring(1)); + + if (this.#cachedPathPrefix && filePath.startsWith(this.#cachedPathPrefix)) { + if (request.headers['if-modified-since']) { + response.statusCode = 304; // not modified + response.end(); + return; + } + response.setHeader('Cache-Control', 'public, max-age=31536000'); + response.setHeader('Last-Modified', this.#startTime.toISOString()); + } else { + response.setHeader('Cache-Control', 'no-cache, no-store'); + } + const csp = this.#csp.get(pathName); + if (csp) { + response.setHeader('Content-Security-Policy', csp); + } + + readFile(filePath, (err, data) => { + // This can happen if the request is not awaited but started + // in the test and get clean via `reset()` + if (response.writableEnded) { + return; + } + + if (err) { + response.statusCode = 404; + response.end(`File not found: ${filePath}`); + return; + } + const mimeType = getMimeType(filePath); + if (mimeType) { + const isTextEncoding = /^text\/|^application\/(javascript|json)/.test( + mimeType + ); + const contentType = isTextEncoding + ? `${mimeType}; charset=utf-8` + : mimeType; + response.setHeader('Content-Type', contentType); + } + if (this.#gzipRoutes.has(pathName)) { + response.setHeader('Content-Encoding', 'gzip'); + gzip(data, (_, result) => { + response.end(result); + }); + } else { + response.end(data); + } + }); + } + + #onWebSocketConnection = (socket: WebSocket): void => { + socket.send('opened'); + }; +} diff --git a/remote/test/puppeteer/packages/testserver/tsconfig.json b/remote/test/puppeteer/packages/testserver/tsconfig.json new file mode 100644 index 0000000000..08e6681481 --- /dev/null +++ b/remote/test/puppeteer/packages/testserver/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "allowJs": true, + "composite": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "lib", + "rootDir": "src", + }, + "include": ["src"], +} diff --git a/remote/test/puppeteer/packages/testserver/tsdoc.json b/remote/test/puppeteer/packages/testserver/tsdoc.json new file mode 100644 index 0000000000..f5b91f4af6 --- /dev/null +++ b/remote/test/puppeteer/packages/testserver/tsdoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@license", + "syntaxKind": "modifier", + "allowMultiple": false + } + ], + "supportForTags": { + "@license": true + } +} |