diff options
Diffstat (limited to '')
131 files changed, 30380 insertions, 0 deletions
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..41457fd4d4 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/CHANGELOG.md @@ -0,0 +1,1322 @@ +# 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. + +## [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/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..27306ad882 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/package.json @@ -0,0 +1,159 @@ +{ + "name": "puppeteer-core", + "version": "20.1.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.0.0" + }, + "scripts": { + "build:docs": "wireit", + "build:tsc": "wireit", + "build:types": "wireit", + "build": "wireit", + "check": "tsx tools/ensure-correct-devtools-protocol-package", + "clean": "tsc -b --clean && rm -rf lib src/generated", + "generate:package-json": "wireit", + "generate:sources": "wireit", + "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:sources": { + "command": "tsx tools/generate_sources.ts", + "clean": "if-file-deleted", + "files": [ + "../../versions.js", + "src/{injected,templates}/**", + "tools/generate_sources.ts" + ], + "output": [ + "src/generated/*.ts" + ] + }, + "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 && rollup --config rollup.third_party.config.mjs", + "clean": "if-file-deleted", + "dependencies": [ + "generate:package-json", + "generate:sources", + "../browsers:build" + ], + "files": [ + "{compat,src,third_party}/**", + "rollup.third_party.config.mjs" + ], + "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", + "!*.tsbuildinfo" + ], + "author": "The Chromium Authors", + "license": "Apache-2.0", + "dependencies": { + "chromium-bidi": "0.4.7", + "cross-fetch": "3.1.5", + "debug": "4.3.4", + "devtools-protocol": "0.0.1120988", + "extract-zip": "2.0.1", + "https-proxy-agent": "5.0.1", + "proxy-from-env": "1.1.0", + "tar-fs": "2.1.1", + "unbzip2-stream": "1.4.3", + "ws": "8.13.0", + "@puppeteer/browsers": "1.0.0" + }, + "peerDependencies": { + "typescript": ">= 4.7.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, + "devDependencies": { + "mitt": "3.0.0", + "parsel-js": "1.1.0" + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/rollup.third_party.config.mjs b/remote/test/puppeteer/packages/puppeteer-core/rollup.third_party.config.mjs new file mode 100644 index 0000000000..8b40906cbf --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/rollup.third_party.config.mjs @@ -0,0 +1,35 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ +import commonjs from '@rollup/plugin-commonjs'; +import {nodeResolve} from '@rollup/plugin-node-resolve'; +import glob from 'glob'; + +export default ['cjs', 'esm'].flatMap(outputType => { + const configs = []; + // Note we don't use path.join here. We cannot since `glob` does not support + // the backslash path separator. + for (const file of glob.sync(`lib/${outputType}/third_party/**/*.js`)) { + configs.push({ + input: file, + output: { + file, + format: outputType, + }, + plugins: [commonjs(), nodeResolve()], + }); + } + return configs; +}); 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..5a08f6ec17 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Browser.ts @@ -0,0 +1,473 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * 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. + */ + +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import {ChildProcess} from 'child_process'; + +import {Protocol} from 'devtools-protocol'; + +import {EventEmitter} from '../common/EventEmitter.js'; +import type {Target} from '../common/Target.js'; // TODO: move to ./api + +import type {BrowserContext} from './BrowserContext.js'; +import type {Page} from './Page.js'; // TODO: move to ./api + +/** + * BrowserContext options. + * + * @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: Protocol.Target.TargetInfo +) => boolean; + +/** + * @internal + */ +export type IsPageTargetCallback = ( + target: Protocol.Target.TargetInfo +) => 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'], + ['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' + | '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 BrowserEmittedEvents { + /** + * Emitted when Puppeteer gets disconnected from the browser instance. This + * might happen because of one of the following: + * + * - browser is closed or crashed + * + * - The {@link Browser.disconnect | browser.disconnect } method 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', +} + +/** + * A Browser is created when Puppeteer connects to a browser instance, either through + * {@link PuppeteerNode.launch} or {@link Puppeteer.connect}. + * + * @remarks + * + * The Browser class extends from Puppeteer's {@link EventEmitter} class and will + * emit various events which are documented in the {@link BrowserEmittedEvents} enum. + * + * @example + * An example of using a {@link Browser} to create a {@link Page}: + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://example.com'); + * await browser.close(); + * })(); + * ``` + * + * @example + * An example of disconnecting from and reconnecting to a {@link Browser}: + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * (async () => { + * 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. + * browser.disconnect(); + * + * // Use the endpoint to reestablish a connection + * const browser2 = await puppeteer.connect({browserWSEndpoint}); + * // Close the browser. + * await browser2.close(); + * })(); + * ``` + * + * @public + */ +export class Browser extends EventEmitter { + /** + * @internal + */ + constructor() { + super(); + } + + /** + * @internal + */ + _attach(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * @internal + */ + _detach(): void { + throw new Error('Not implemented'); + } + + /** + * @internal + */ + get _targets(): Map<string, Target> { + throw new Error('Not implemented'); + } + + /** + * The spawned browser process. Returns `null` if the browser instance was created with + * {@link Puppeteer.connect}. + */ + process(): ChildProcess | null { + throw new Error('Not implemented'); + } + + /** + * @internal + */ + _getIsPageTargetCallback(): IsPageTargetCallback | undefined { + throw new Error('Not implemented'); + } + + /** + * Creates a new incognito browser context. This won't share cookies/cache with other + * browser contexts. + * + * @example + * + * ```ts + * (async () => { + * 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'); + * })(); + * ``` + */ + createIncognitoBrowserContext( + options?: BrowserContextOptions + ): Promise<BrowserContext>; + createIncognitoBrowserContext(): Promise<BrowserContext> { + throw new Error('Not implemented'); + } + + /** + * Returns an array of all open browser contexts. In a newly created browser, this will + * return a single instance of {@link BrowserContext}. + */ + browserContexts(): BrowserContext[] { + throw new Error('Not implemented'); + } + + /** + * Returns the default browser context. The default browser context cannot be closed. + */ + defaultBrowserContext(): BrowserContext { + throw new Error('Not implemented'); + } + + /** + * @internal + */ + _disposeContext(contextId?: string): Promise<void>; + _disposeContext(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * The browser websocket endpoint which can be used as an argument to + * {@link Puppeteer.connect}. + * + * @returns The Browser websocket url. + * + * @remarks + * + * The format is `ws://${host}:${port}/devtools/browser/<id>`. + * + * You can find the `webSocketDebuggerUrl` from `http://${host}:${port}/json/version`. + * Learn more about the + * {@link https://chromedevtools.github.io/devtools-protocol | devtools protocol} and + * the {@link + * https://chromedevtools.github.io/devtools-protocol/#how-do-i-access-the-browser-target + * | browser endpoint}. + */ + wsEndpoint(): string { + throw new Error('Not implemented'); + } + + /** + * Promise which resolves to a new {@link Page} object. The Page is created in + * a default browser context. + */ + newPage(): Promise<Page> { + throw new Error('Not implemented'); + } + + /** + * @internal + */ + _createPageInContext(contextId?: string): Promise<Page>; + _createPageInContext(): Promise<Page> { + throw new Error('Not implemented'); + } + + /** + * All active targets inside the Browser. In case of multiple browser contexts, returns + * an array with all the targets in all browser contexts. + */ + targets(): Target[] { + throw new Error('Not implemented'); + } + + /** + * The target associated with the browser. + */ + target(): Target { + throw new Error('Not implemented'); + } + + /** + * Searches for a target in all browser contexts. + * + * @param predicate - A function to be run for every target. + * @returns The first target found that matches the `predicate` function. + * + * @example + * + * An example of 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/' + * ); + * ``` + */ + waitForTarget( + predicate: (x: Target) => boolean | Promise<boolean>, + options?: WaitForTargetOptions + ): Promise<Target>; + waitForTarget(): Promise<Target> { + throw new Error('Not implemented'); + } + + /** + * An array of all open pages inside the Browser. + * + * @remarks + * + * In case of multiple browser contexts, returns an array with all the pages in all + * browser contexts. Non-visible pages, such as `"background_page"`, will not be listed + * here. You can find them using {@link Target.page}. + */ + pages(): Promise<Page[]> { + throw new Error('Not implemented'); + } + + /** + * A string representing the browser name and version. + * + * @remarks + * + * For headless browser, this is similar to `HeadlessChrome/61.0.3153.0`. For + * non-headless, this is similar to `Chrome/61.0.3153.0`. + * + * The format of browser.version() might change with future releases of browsers. + */ + version(): Promise<string> { + throw new Error('Not implemented'); + } + + /** + * The browser's original user agent. Pages can override the browser user agent with + * {@link Page.setUserAgent}. + */ + userAgent(): Promise<string> { + throw new Error('Not implemented'); + } + + /** + * Closes the browser and all of its pages (if any were opened). The + * {@link Browser} object itself is considered to be disposed and cannot be + * used anymore. + */ + close(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * Disconnects Puppeteer from the browser, but leaves the browser process running. + * After calling `disconnect`, the {@link Browser} object is considered disposed and + * cannot be used anymore. + */ + disconnect(): void { + throw new Error('Not implemented'); + } + + /** + * Indicates that the browser is connected. + */ + isConnected(): boolean { + throw new Error('Not implemented'); + } +} +/** + * @public + */ +export const enum BrowserContextEmittedEvents { + /** + * 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', +} 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..77fb9b1987 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/BrowserContext.ts @@ -0,0 +1,186 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * 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. + */ + +import {EventEmitter} from '../common/EventEmitter.js'; +import {Target} from '../common/Target.js'; + +import type {Permission, Browser} from './Browser.js'; +import {Page} from './Page.js'; + +/** + * BrowserContexts provide a way to operate multiple independent browser + * sessions. When a browser is launched, it has a single BrowserContext used by + * default. The method {@link Browser.newPage | Browser.newPage} creates a page + * in the default browser context. + * + * @remarks + * + * The Browser class extends from Puppeteer's {@link EventEmitter} class and + * will emit various events which are documented in the + * {@link BrowserContextEmittedEvents} enum. + * + * If a page opens another page, e.g. with a `window.open` call, the popup will + * belong to the parent page's browser context. + * + * Puppeteer allows creation of "incognito" browser contexts with + * {@link Browser.createIncognitoBrowserContext | Browser.createIncognitoBrowserContext} + * method. "Incognito" browser contexts don't write any browsing data to disk. + * + * @example + * + * ```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 class BrowserContext extends EventEmitter { + /** + * @internal + */ + constructor() { + super(); + } + + /** + * An array of all active targets inside the browser context. + */ + targets(): Target[] { + throw new Error('Not implemented'); + } + + /** + * This searches for a target in this specific browser context. + * + * @example + * An example of 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/' + * ); + * ``` + * + * @param predicate - A function to be run for every target + * @param options - An object of options. Accepts a timeout, + * which is the maximum wait time in milliseconds. + * Pass `0` to disable the timeout. Defaults to 30 seconds. + * @returns Promise which resolves to the first target found + * that matches the `predicate` function. + */ + waitForTarget( + predicate: (x: Target) => boolean | Promise<boolean>, + options?: {timeout?: number} + ): Promise<Target>; + waitForTarget(): Promise<Target> { + throw new Error('Not implemented'); + } + + /** + * An array of all pages inside the browser context. + * + * @returns Promise which resolves to an array of all open pages. + * Non visible pages, such as `"background_page"`, will not be listed here. + * You can find them using {@link Target.page | the target page}. + */ + pages(): Promise<Page[]> { + throw new Error('Not implemented'); + } + + /** + * Returns whether BrowserContext is incognito. + * The default browser context is the only non-incognito browser context. + * + * @remarks + * The default browser context cannot be closed. + */ + isIncognito(): boolean { + throw new Error('Not implemented'); + } + + /** + * @example + * + * ```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. + */ + overridePermissions(origin: string, permissions: Permission[]): Promise<void>; + overridePermissions(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * Clears all permission overrides for the browser context. + * + * @example + * + * ```ts + * const context = browser.defaultBrowserContext(); + * context.overridePermissions('https://example.com', ['clipboard-read']); + * // do stuff .. + * context.clearPermissionOverrides(); + * ``` + */ + clearPermissionOverrides(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * Creates a new page in the browser context. + */ + newPage(): Promise<Page> { + throw new Error('Not implemented'); + } + + /** + * The browser this browser context belongs to. + */ + browser(): Browser { + throw new Error('Not implemented'); + } + + /** + * Closes the browser context. All the targets that belong to the browser context + * will be closed. + * + * @remarks + * Only incognito browser contexts can be closed. + */ + close(): Promise<void> { + throw new Error('Not implemented'); + } + + get id(): string | undefined { + return undefined; + } +} 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..09c409736e --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandle.ts @@ -0,0 +1,917 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +import {Protocol} from 'devtools-protocol'; + +import {CDPSession} from '../common/Connection.js'; +import {ExecutionContext} from '../common/ExecutionContext.js'; +import {Frame} from '../common/Frame.js'; +import {MouseClickOptions} from '../common/Input.js'; +import {WaitForSelectorOptions} from '../common/IsolatedWorld.js'; +import { + ElementFor, + EvaluateFuncWith, + HandleFor, + HandleOr, + NodeFor, +} from '../common/types.js'; +import {KeyInput} from '../common/USKeyboardLayout.js'; + +import {JSHandle} from './JSHandle.js'; +import {ScreenshotOptions} from './Page.js'; + +/** + * @public + */ +export interface BoxModel { + content: Point[]; + padding: Point[]; + border: Point[]; + margin: Point[]; + 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 PressOptions { + /** + * Time to wait between `keydown` and `keyup` in milliseconds. Defaults to 0. + */ + delay?: number; + /** + * If specified, generates an input event with this text. + */ + text?: string; +} + +/** + * @public + */ +export interface Point { + x: number; + y: number; +} + +/** + * 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 class ElementHandle< + ElementType extends Node = Element +> extends JSHandle<ElementType> { + /** + * @internal + */ + protected handle; + + /** + * @internal + */ + constructor(handle: JSHandle<ElementType>) { + super(); + this.handle = handle; + } + + /** + * @internal + */ + override get id(): string | undefined { + return this.handle.id; + } + + /** + * @internal + */ + override get disposed(): boolean { + return this.handle.disposed; + } + + /** + * @internal + */ + override async getProperty<K extends keyof ElementType>( + propertyName: HandleOr<K> + ): Promise<HandleFor<ElementType[K]>>; + /** + * @internal + */ + override async getProperty(propertyName: string): Promise<JSHandle<unknown>>; + override async getProperty<K extends keyof ElementType>( + propertyName: HandleOr<K> + ): Promise<HandleFor<ElementType[K]>> { + return this.handle.getProperty(propertyName); + } + + /** + * @internal + */ + override async getProperties(): Promise<Map<string, JSHandle>> { + return 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>>> { + return this.handle.evaluate(pageFunction, ...args); + } + + /** + * @internal + */ + override evaluateHandle< + Params extends unknown[], + Func extends EvaluateFuncWith<ElementType, Params> = EvaluateFuncWith< + ElementType, + Params + > + >( + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + return this.handle.evaluateHandle(pageFunction, ...args); + } + + /** + * @internal + */ + override async jsonValue(): Promise<ElementType> { + return this.handle.jsonValue(); + } + + /** + * @internal + */ + override toString(): string { + return this.handle.toString(); + } + + /** + * @internal + */ + override async dispose(): Promise<void> { + return await this.handle.dispose(); + } + + override asElement(): ElementHandle<ElementType> { + return this; + } + + /** + * @internal + */ + override executionContext(): ExecutionContext { + throw new Error('Not implemented'); + } + + /** + * @internal + */ + override get client(): CDPSession { + throw new Error('Not implemented'); + } + + get frame(): Frame { + throw new Error('Not implemented'); + } + + /** + * 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`. + */ + async $<Selector extends string>( + selector: Selector + ): Promise<ElementHandle<NodeFor<Selector>> | null>; + async $<Selector extends string>(): Promise<ElementHandle< + NodeFor<Selector> + > | null> { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + async $$<Selector extends string>( + selector: Selector + ): Promise<Array<ElementHandle<NodeFor<Selector>>>>; + async $$<Selector extends string>(): Promise< + Array<ElementHandle<NodeFor<Selector>>> + > { + throw new Error('Not implemented'); + } + + /** + * 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>>>; + async $eval(): Promise<unknown> { + throw new Error('Not implemented'); + } + + /** + * 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: + * + * ```js + * 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>>>; + async $$eval(): Promise<unknown> { + throw new Error('Not implemented'); + } + + /** + * @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} + */ + async $x(expression: string): Promise<Array<ElementHandle<Node>>>; + async $x(): Promise<Array<ElementHandle<Node>>> { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + async waitForSelector<Selector extends string>( + selector: Selector, + options?: WaitForSelectorOptions + ): Promise<ElementHandle<NodeFor<Selector>> | null>; + async waitForSelector<Selector extends string>(): Promise<ElementHandle< + NodeFor<Selector> + > | null> { + throw new Error('Not implemented'); + } + + /** + * Checks if an element is visible using the same mechanism as + * {@link ElementHandle.waitForSelector}. + */ + async isVisible(): Promise<boolean> { + throw new Error('Not implemented.'); + } + + /** + * Checks if an element is hidden using the same mechanism as + * {@link ElementHandle.waitForSelector}. + */ + async isHidden(): Promise<boolean> { + throw new Error('Not implemented.'); + } + + /** + * @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. + */ + async waitForXPath( + xpath: string, + options?: { + visible?: boolean; + hidden?: boolean; + timeout?: number; + } + ): Promise<ElementHandle<Node> | null>; + async waitForXPath(): Promise<ElementHandle<Node> | null> { + throw new Error('Not implemented'); + } + + /** + * 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.** + */ + async toElement< + K extends keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap + >(tagName: K): Promise<HandleFor<ElementFor<K>>>; + async toElement< + K extends keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap + >(): Promise<HandleFor<ElementFor<K>>> { + throw new Error('Not implemented'); + } + + /** + * Resolves to the content frame for element handles referencing + * iframe nodes, or null otherwise + */ + async contentFrame(): Promise<Frame | null> { + throw new Error('Not implemented'); + } + + /** + * Returns the middle point within an element unless a specific offset is provided. + */ + async clickablePoint(offset?: Offset): Promise<Point>; + async clickablePoint(): Promise<Point> { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + async hover(this: ElementHandle<Element>): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + async click( + this: ElementHandle<Element>, + options?: ClickOptions + ): Promise<void>; + async click(this: ElementHandle<Element>): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * This method creates and captures a dragevent from the element. + */ + async drag( + this: ElementHandle<Element>, + target: Point + ): Promise<Protocol.Input.DragData>; + async drag(this: ElementHandle<Element>): Promise<Protocol.Input.DragData> { + throw new Error('Not implemented'); + } + + /** + * This method creates a `dragenter` event on the element. + */ + async dragEnter( + this: ElementHandle<Element>, + data?: Protocol.Input.DragData + ): Promise<void>; + async dragEnter(this: ElementHandle<Element>): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * This method creates a `dragover` event on the element. + */ + async dragOver( + this: ElementHandle<Element>, + data?: Protocol.Input.DragData + ): Promise<void>; + async dragOver(this: ElementHandle<Element>): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * This method triggers a drop on the element. + */ + async drop( + this: ElementHandle<Element>, + data?: Protocol.Input.DragData + ): Promise<void>; + async drop(this: ElementHandle<Element>): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * This method triggers a dragenter, dragover, and drop on the element. + */ + async dragAndDrop( + this: ElementHandle<Element>, + target: ElementHandle<Node>, + options?: {delay: number} + ): Promise<void>; + async dragAndDrop(this: ElementHandle<Element>): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + async select(...values: string[]): Promise<string[]>; + async select(): Promise<string[]> { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + async uploadFile( + this: ElementHandle<HTMLInputElement>, + ...paths: string[] + ): Promise<void>; + async uploadFile(this: ElementHandle<HTMLInputElement>): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + async tap(this: ElementHandle<Element>): Promise<void> { + throw new Error('Not implemented'); + } + + async touchStart(this: ElementHandle<Element>): Promise<void> { + throw new Error('Not implemented'); + } + + async touchMove(this: ElementHandle<Element>): Promise<void> { + throw new Error('Not implemented'); + } + + async touchEnd(this: ElementHandle<Element>): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * Calls {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus | focus} on the element. + */ + async focus(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + async type(text: string, options?: {delay: number}): Promise<void>; + async type(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + async press(key: KeyInput, options?: PressOptions): Promise<void>; + async press(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * This method returns the bounding box of the element (relative to the main frame), + * or `null` if the element is not visible. + */ + async boundingBox(): Promise<BoundingBox | null> { + throw new Error('Not implemented'); + } + + /** + * This method returns boxes of the element, or `null` if the element is not visible. + * + * @remarks + * + * Boxes are represented as an array of points; + * Each Point is an object `{x, y}`. Box points are sorted clock-wise. + */ + async boxModel(): Promise<BoxModel | null> { + throw new Error('Not implemented'); + } + + /** + * This method scrolls element into view if needed, and then uses + * {@link Page.(screenshot:3) } to take a screenshot of the element. + * If the element is detached from DOM, the method throws an error. + */ + async screenshot( + this: ElementHandle<Element>, + options?: ScreenshotOptions + ): Promise<string | Buffer>; + async screenshot(this: ElementHandle<Element>): Promise<string | Buffer> { + throw new Error('Not implemented'); + } + + /** + * @internal + */ + protected async assertConnectedElement(): Promise<void> { + const error = await this.evaluate( + async (element): Promise<string | undefined> => { + 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); + } + } + + /** + * 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. + */ + async isIntersectingViewport( + this: ElementHandle<Element>, + options?: { + threshold?: number; + } + ): Promise<boolean> { + await this.assertConnectedElement(); + + const {threshold = 0} = options ?? {}; + const svgHandle = await this.#asSVGElementHandle(this); + const intersectionTarget: ElementHandle<Element> = svgHandle + ? await this.#getOwnerSVGElement(svgHandle) + : this; + + try { + return await intersectionTarget.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; + }, threshold); + } finally { + if (intersectionTarget !== this) { + await intersectionTarget.dispose(); + } + } + } + + /** + * Scrolls the element into view using either the automation protocol client + * or by calling element.scrollIntoView. + */ + async scrollIntoView(this: ElementHandle<Element>): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * Returns true if an element is an SVGElement (included svg, path, rect + * etc.). + */ + async #asSVGElementHandle( + handle: ElementHandle<Element> + ): Promise<ElementHandle<SVGElement> | null> { + if ( + await handle.evaluate(element => { + return element instanceof SVGElement; + }) + ) { + return handle as ElementHandle<SVGElement>; + } else { + return null; + } + } + + async #getOwnerSVGElement( + handle: ElementHandle<SVGElement> + ): Promise<ElementHandle<SVGSVGElement>> { + // SVGSVGElement.ownerSVGElement === null. + return await handle.evaluateHandle(element => { + if (element instanceof SVGSVGElement) { + return element; + } + return element.ownerSVGElement!; + }); + } +} 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..460077568e --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPRequest.ts @@ -0,0 +1,567 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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. + */ +import {Protocol} from 'devtools-protocol'; + +import {CDPSession} from '../common/Connection.js'; +import {Frame} from '../common/Frame.js'; + +import {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 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 + */ + get client(): CDPSession { + throw new Error('Not implemented'); + } + + /** + * @internal + */ + constructor() {} + + /** + * The URL of the request + */ + url(): string { + throw new Error('Not implemented'); + } + + /** + * The `ContinueRequestOverrides` that will be used + * if the interception is allowed to continue (ie, `abort()` and + * `respond()` aren't called). + */ + continueRequestOverrides(): ContinueRequestOverrides { + throw new Error('Not implemented'); + } + + /** + * The `ResponseForRequest` that gets used if the + * interception is allowed to respond (ie, `abort()` is not called). + */ + responseForRequest(): Partial<ResponseForRequest> | null { + throw new Error('Not implemented'); + } + + /** + * The most recent reason for aborting the request + */ + abortErrorReason(): Protocol.Network.ErrorReason | null { + throw new Error('Not implemented'); + } + + /** + * 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`. + */ + interceptResolutionState(): InterceptResolutionState { + throw new Error('Not implemented'); + } + + /** + * Is `true` if the intercept resolution has already been handled, + * `false` otherwise. + */ + isInterceptResolutionHandled(): boolean { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + enqueueInterceptAction( + pendingHandler: () => void | PromiseLike<unknown> + ): void; + enqueueInterceptAction(): void { + throw new Error('Not implemented'); + } + + /** + * Awaits pending interception handlers and then decides how to fulfill + * the request interception. + */ + async finalizeInterceptions(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * Contains the request's resource type as it was perceived by the rendering + * engine. + */ + resourceType(): ResourceType { + throw new Error('Not implemented'); + } + + /** + * The method used (`GET`, `POST`, etc.) + */ + method(): string { + throw new Error('Not implemented'); + } + + /** + * The request's post body, if any. + */ + postData(): string | undefined { + throw new Error('Not implemented'); + } + + /** + * An object with HTTP headers associated with the request. All + * header names are lower-case. + */ + headers(): Record<string, string> { + throw new Error('Not implemented'); + } + + /** + * A matching `HTTPResponse` object, or null if the response has not + * been received yet. + */ + response(): HTTPResponse | null { + throw new Error('Not implemented'); + } + + /** + * The frame that initiated the request, or null if navigating to + * error pages. + */ + frame(): Frame | null { + throw new Error('Not implemented'); + } + + /** + * True if the request is the driver of the current frame's navigation. + */ + isNavigationRequest(): boolean { + throw new Error('Not implemented'); + } + + /** + * The initiator of the request. + */ + initiator(): Protocol.Network.Initiator | undefined { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + redirectChain(): HTTPRequest[] { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + failure(): {errorText: string} | null { + throw new Error('Not implemented'); + } + + /** + * Continues request with optional request overrides. + * + * @remarks + * + * To use this, request + * interception should be enabled with {@link Page.setRequestInterception}. + * + * Exception is immediately thrown if the request interception is not enabled. + * + * @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. + */ + async continue( + overrides?: ContinueRequestOverrides, + priority?: number + ): Promise<void>; + async continue(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * Fulfills a request with the given response. + * + * @remarks + * + * To use this, request + * interception should be enabled with {@link Page.setRequestInterception}. + * + * Exception is immediately thrown if the request interception is not enabled. + * + * @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. + */ + async respond( + response: Partial<ResponseForRequest>, + priority?: number + ): Promise<void>; + async respond(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * Aborts a request. + * + * @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. + * + * @param errorCode - optional error code to provide. + * @param priority - If provided, intercept is resolved using + * cooperative handling rules. Otherwise, intercept is resolved + * immediately. + */ + async abort(errorCode?: ErrorCode, priority?: number): Promise<void>; + async abort(): Promise<void> { + throw new Error('Not implemented'); + } +} + +/** + * @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: {[key: string]: string | undefined} = { + '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..ddc56279a4 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPResponse.ts @@ -0,0 +1,168 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +import Protocol from 'devtools-protocol'; + +import {Frame} from '../common/Frame.js'; +import {SecurityDetails} from '../common/SecurityDetails.js'; + +import {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 class HTTPResponse { + /** + * @internal + */ + constructor() {} + + /** + * @internal + */ + _resolveBody(_err: Error | null): void { + throw new Error('Not implemented'); + } + + /** + * The IP address and port number used to connect to the remote + * server. + */ + remoteAddress(): RemoteAddress { + throw new Error('Not implemented'); + } + + /** + * The URL of the response. + */ + url(): string { + throw new Error('Not implemented'); + } + + /** + * True if the response was successful (status in the range 200-299). + */ + ok(): boolean { + throw new Error('Not implemented'); + } + + /** + * The status code of the response (e.g., 200 for a success). + */ + status(): number { + throw new Error('Not implemented'); + } + + /** + * The status text of the response (e.g. usually an "OK" for a + * success). + */ + statusText(): string { + throw new Error('Not implemented'); + } + + /** + * An object with HTTP headers associated with the response. All + * header names are lower-case. + */ + headers(): Record<string, string> { + throw new Error('Not implemented'); + } + + /** + * {@link SecurityDetails} if the response was received over the + * secure connection, or `null` otherwise. + */ + securityDetails(): SecurityDetails | null { + throw new Error('Not implemented'); + } + + /** + * Timing information related to the response. + */ + timing(): Protocol.Network.ResourceTiming | null { + throw new Error('Not implemented'); + } + + /** + * Promise which resolves to a buffer with response body. + */ + buffer(): Promise<Buffer> { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + request(): HTTPRequest { + throw new Error('Not implemented'); + } + + /** + * True if the response was served from either the browser's disk + * cache or memory cache. + */ + fromCache(): boolean { + throw new Error('Not implemented'); + } + + /** + * True if the response was served by a service worker. + */ + fromServiceWorker(): boolean { + throw new Error('Not implemented'); + } + + /** + * A {@link Frame} that initiated this response, or `null` if + * navigating to error pages. + */ + frame(): Frame | null { + throw new Error('Not implemented'); + } +} 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..8720fc0ad7 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/JSHandle.ts @@ -0,0 +1,197 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +import Protocol from 'devtools-protocol'; + +import {CDPSession} from '../common/Connection.js'; +import {ExecutionContext} from '../common/ExecutionContext.js'; +import {EvaluateFuncWith, HandleFor, HandleOr} from '../common/types.js'; + +import {ElementHandle} from './ElementHandle.js'; + +declare const __JSHandleSymbol: unique symbol; + +/** + * 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 + */ +export class JSHandle<T = unknown> { + /** + * Used for nominally typing {@link JSHandle}. + */ + [__JSHandleSymbol]?: T; + + /** + * @internal + */ + constructor() {} + + /** + * @internal + */ + get disposed(): boolean { + throw new Error('Not implemented'); + } + + /** + * @internal + */ + executionContext(): ExecutionContext { + throw new Error('Not implemented'); + } + + /** + * @internal + */ + get client(): CDPSession { + throw new Error('Not implemented'); + } + + /** + * 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>>>; + async evaluate(): Promise<unknown> { + throw new Error('Not implemented'); + } + + /** + * 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>>>>; + async evaluateHandle(): Promise<HandleFor<unknown>> { + throw new Error('Not implemented'); + } + + /** + * Fetches a single property from the referenced object. + */ + async getProperty<K extends keyof T>( + propertyName: HandleOr<K> + ): Promise<HandleFor<T[K]>>; + async getProperty(propertyName: string): Promise<JSHandle<unknown>>; + async getProperty<K extends keyof T>( + propertyName: HandleOr<K> + ): Promise<HandleFor<T[K]>>; + async getProperty<K extends keyof T>(): Promise<HandleFor<T[K]>> { + throw new Error('Not implemented'); + } + + /** + * 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 + * ``` + */ + async getProperties(): Promise<Map<string, JSHandle>> { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + async jsonValue(): Promise<T> { + throw new Error('Not implemented'); + } + + /** + * Either `null` or the handle itself if the handle is an + * instance of {@link ElementHandle}. + */ + asElement(): ElementHandle<Node> | null { + throw new Error('Not implemented'); + } + + /** + * Releases the object referenced by the handle for garbage collection. + */ + async dispose(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * Returns a string representation of the JSHandle. + * + * @remarks + * Useful during debugging. + */ + toString(): string { + throw new Error('Not implemented'); + } + + /** + * @internal + */ + get id(): string | undefined { + throw new Error('Not implemented'); + } + + /** + * Provides access to the + * {@link https://chromedevtools.github.io/devtools-protocol/tot/Runtime/#type-RemoteObject | Protocol.Runtime.RemoteObject} + * backing this handle. + */ + remoteObject(): Protocol.Runtime.RemoteObject { + throw new Error('Not implemented'); + } +} 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..10fcbfccda --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Page.ts @@ -0,0 +1,2748 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * 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. + */ + +import type {Readable} from 'stream'; + +import {Protocol} from 'devtools-protocol'; + +import type {HTTPRequest} from '../api/HTTPRequest.js'; +import type {HTTPResponse} from '../api/HTTPResponse.js'; +import type {Accessibility} from '../common/Accessibility.js'; +import type {ConsoleMessage} from '../common/ConsoleMessage.js'; +import type {Coverage} from '../common/Coverage.js'; +import {Device} from '../common/Device.js'; +import {DeviceRequestPrompt} from '../common/DeviceRequestPrompt.js'; +import type {Dialog} from '../common/Dialog.js'; +import {EventEmitter, Handler} from '../common/EventEmitter.js'; +import type {FileChooser} from '../common/FileChooser.js'; +import type { + Frame, + FrameAddScriptTagOptions, + FrameAddStyleTagOptions, + FrameWaitForFunctionOptions, +} from '../common/Frame.js'; +import type {Keyboard, Mouse, Touchscreen} from '../common/Input.js'; +import type {WaitForSelectorOptions} from '../common/IsolatedWorld.js'; +import type {PuppeteerLifeCycleEvent} from '../common/LifecycleWatcher.js'; +import type {Credentials, NetworkConditions} from '../common/NetworkManager.js'; +import { + LowerCasePaperFormat, + paperFormats, + ParsedPDFOptions, + PDFOptions, +} from '../common/PDFOptions.js'; +import type {Viewport} from '../common/PuppeteerViewport.js'; +import type {Target} from '../common/Target.js'; +import type {Tracing} from '../common/Tracing.js'; +import type { + EvaluateFunc, + EvaluateFuncWith, + HandleFor, + NodeFor, +} from '../common/types.js'; +import {importFSPromises, isNumber, isString} from '../common/util.js'; +import type {WebWorker} from '../common/WebWorker.js'; +import {assert} from '../util/assert.js'; + +import type {Browser} from './Browser.js'; +import type {BrowserContext} from './BrowserContext.js'; +import type {ClickOptions, ElementHandle} from './ElementHandle.js'; +import type {JSHandle} from './JSHandle.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 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 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; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; +} + +/** + * @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 { + x: number; + y: number; + width: number; + height: number; + /** + * @defaultValue `1` + */ + scale?: number; +} + +/** + * @public + */ +export interface ScreenshotOptions { + /** + * @defaultValue `png` + */ + type?: 'png' | 'jpeg' | 'webp'; + /** + * 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; + /** + * When `true`, takes a screenshot of the full page. + * @defaultValue `false` + */ + fullPage?: boolean; + /** + * An object which specifies the clipping region of the page. + */ + clip?: ScreenshotClip; + /** + * Quality of the image, between 0-100. Not applicable to `png` images. + */ + quality?: number; + /** + * Hides default white background and allows capturing screenshots with transparency. + * @defaultValue `false` + */ + omitBackground?: boolean; + /** + * Encoding of the image. + * @defaultValue `binary` + */ + encoding?: 'base64' | 'binary'; + /** + * Capture the screenshot beyond the viewport. + * @defaultValue `true` + */ + captureBeyondViewport?: boolean; + /** + * Capture the screenshot from the surface, rather than the view. + * @defaultValue `true` + */ + fromSurface?: boolean; +} + +/** + * All the events that a page instance may emit. + * + * @public + */ +export const enum PageEmittedEvents { + /** + * 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', +} + +/** + * Denotes the objects received by callback functions for page events. + * + * See {@link PageEmittedEvents} for more detail on the events and when they are + * emitted. + * + * @public + */ +export interface PageEventObject { + close: never; + console: ConsoleMessage; + dialog: Dialog; + domcontentloaded: never; + error: Error; + frameattached: Frame; + framedetached: Frame; + framenavigated: Frame; + load: never; + metrics: {title: string; metrics: Metrics}; + pageerror: Error; + popup: Page; + request: HTTPRequest; + response: HTTPResponse; + requestfailed: HTTPRequest; + requestfinished: HTTPRequest; + requestservedfromcache: HTTPRequest; + workercreated: WebWorker; + workerdestroyed: WebWorker; +} + +/** + * 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 PageEmittedEvents} 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 Page.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 class Page extends EventEmitter { + #handlerMap = new WeakMap<Handler<any>, Handler<any>>(); + + /** + * @internal + */ + constructor() { + super(); + } + + /** + * `true` if drag events are being intercepted, `false` otherwise. + */ + isDragInterceptionEnabled(): boolean { + throw new Error('Not implemented'); + } + + /** + * `true` if the page has JavaScript enabled, `false` otherwise. + */ + isJavaScriptEnabled(): boolean { + throw new Error('Not implemented'); + } + + /** + * Listen to page events. + * + * :::note + * + * 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}. + * + * ::: + */ + override on<K extends keyof PageEventObject>( + eventName: K, + handler: (event: PageEventObject[K]) => void + ): EventEmitter { + if (eventName === 'request') { + const wrap = + this.#handlerMap.get(handler) || + ((event: HTTPRequest) => { + event.enqueueInterceptAction(() => { + return handler(event as PageEventObject[K]); + }); + }); + + this.#handlerMap.set(handler, wrap); + + return super.on(eventName, wrap); + } + return super.on(eventName, handler); + } + + override once<K extends keyof PageEventObject>( + eventName: K, + handler: (event: PageEventObject[K]) => void + ): EventEmitter { + // Note: this method only exists to define the types; we delegate the impl + // to EventEmitter. + return super.once(eventName, handler); + } + + override off<K extends keyof PageEventObject>( + eventName: K, + handler: (event: PageEventObject[K]) => void + ): EventEmitter { + if (eventName === 'request') { + handler = this.#handlerMap.get(handler) || handler; + } + + return super.off(eventName, 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']); + * ``` + */ + waitForFileChooser(options?: WaitTimeoutOptions): Promise<FileChooser>; + waitForFileChooser(): Promise<FileChooser> { + throw new Error('Not implemented'); + } + + /** + * 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}); + * ``` + */ + async setGeolocation(options: GeolocationOptions): Promise<void>; + async setGeolocation(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * A target this page was created from. + */ + target(): Target { + throw new Error('Not implemented'); + } + + /** + * Get the browser the page belongs to. + */ + browser(): Browser { + throw new Error('Not implemented'); + } + + /** + * Get the browser context that the page belongs to. + */ + browserContext(): BrowserContext { + throw new Error('Not implemented'); + } + + /** + * The page's main frame. + * + * @remarks + * Page is guaranteed to have a main frame which persists during navigations. + */ + mainFrame(): Frame { + throw new Error('Not implemented'); + } + + /** + * {@inheritDoc Keyboard} + */ + get keyboard(): Keyboard { + throw new Error('Not implemented'); + } + + /** + * {@inheritDoc Touchscreen} + */ + get touchscreen(): Touchscreen { + throw new Error('Not implemented'); + } + + /** + * {@inheritDoc Coverage} + */ + get coverage(): Coverage { + throw new Error('Not implemented'); + } + + /** + * {@inheritDoc Tracing} + */ + get tracing(): Tracing { + throw new Error('Not implemented'); + } + + /** + * {@inheritDoc Accessibility} + */ + get accessibility(): Accessibility { + throw new Error('Not implemented'); + } + + /** + * An array of all frames attached to the page. + */ + frames(): Frame[] { + throw new Error('Not implemented'); + } + + /** + * 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 + */ + workers(): WebWorker[] { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + async setRequestInterception(value: boolean): Promise<void>; + async setRequestInterception(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * @param enabled - Whether to enable drag interception. + * + * @remarks + * Activating drag interception enables the `Input.drag`, + * methods This provides the capability to capture drag events emitted + * on the page, which can then be used to simulate drag-and-drop. + */ + async setDragInterception(enabled: boolean): Promise<void>; + async setDragInterception(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + setOfflineMode(enabled: boolean): Promise<void>; + setOfflineMode(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + emulateNetworkConditions( + networkConditions: NetworkConditions | null + ): Promise<void>; + emulateNetworkConditions(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + setDefaultNavigationTimeout(timeout: number): void; + setDefaultNavigationTimeout(): void { + throw new Error('Not implemented'); + } + + /** + * @param timeout - Maximum time in milliseconds. + */ + setDefaultTimeout(timeout: number): void; + setDefaultTimeout(): void { + throw new Error('Not implemented'); + } + + /** + * Maximum time in milliseconds. + */ + getDefaultTimeout(): number { + throw new Error('Not implemented'); + } + + /** + * 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>; + async $<Selector extends string>(): Promise<ElementHandle< + NodeFor<Selector> + > | null> { + throw new Error('Not implemented'); + } + + /** + * The method runs `document.querySelectorAll` within the page. If no elements + * match the selector, the return value resolves to `[]`. + * @remarks + * Shortcut for {@link Frame.$$ | Page.mainFrame().$$(selector) }. + * @param selector - A `selector` to query page for + */ + async $$<Selector extends string>( + selector: Selector + ): Promise<Array<ElementHandle<NodeFor<Selector>>>>; + async $$<Selector extends string>(): Promise< + Array<ElementHandle<NodeFor<Selector>>> + > { + throw new Error('Not implemented'); + } + + /** + * @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>>>>; + async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params> + >(): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + async queryObjects<Prototype>( + prototypeHandle: JSHandle<Prototype> + ): Promise<JSHandle<Prototype[]>>; + async queryObjects<Prototype>(): Promise<JSHandle<Prototype[]>> { + throw new Error('Not implemented'); + } + + /** + * 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>>>; + async $eval(): Promise<unknown> { + throw new Error('Not implemented'); + } + + /** + * 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>>>; + async $$eval(): Promise<unknown> { + throw new Error('Not implemented'); + } + + /** + * 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>>>; + async $x(): Promise<Array<ElementHandle<Node>>> { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + async cookies(...urls: string[]): Promise<Protocol.Network.Cookie[]>; + async cookies(): Promise<Protocol.Network.Cookie[]> { + throw new Error('Not implemented'); + } + + async deleteCookie( + ...cookies: Protocol.Network.DeleteCookiesRequest[] + ): Promise<void>; + async deleteCookie(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * @example + * + * ```ts + * await page.setCookie(cookieObject1, cookieObject2); + * ``` + */ + async setCookie(...cookies: Protocol.Network.CookieParam[]): Promise<void>; + async setCookie(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * 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>>; + async addScriptTag(): Promise<ElementHandle<HTMLScriptElement>> { + throw new Error('Not implemented'); + } + + /** + * 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>>; + async addStyleTag(): Promise< + ElementHandle<HTMLStyleElement | HTMLLinkElement> + > { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + async exposeFunction( + name: string, + pptrFunction: Function | {default: Function} + ): Promise<void>; + async exposeFunction(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * Provide credentials for `HTTP authentication`. + * + * @remarks + * To disable authentication, pass `null`. + */ + async authenticate(credentials: Credentials): Promise<void>; + async authenticate(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + async setExtraHTTPHeaders(headers: Record<string, string>): Promise<void>; + async setExtraHTTPHeaders(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * @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. + */ + async setUserAgent( + userAgent: string, + userAgentMetadata?: Protocol.Emulation.UserAgentMetadata + ): Promise<void>; + async setUserAgent(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + async metrics(): Promise<Metrics> { + throw new Error('Not implemented'); + } + + /** + * The page's URL. + * @remarks Shortcut for + * {@link Frame.url | page.mainFrame().url()}. + */ + url(): string { + throw new Error('Not implemented'); + } + + /** + * The full HTML contents of the page, including the DOCTYPE. + */ + async content(): Promise<string> { + throw new Error('Not implemented'); + } + + /** + * 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>; + async setContent(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * @param url - URL to navigate page to. The URL should include scheme, e.g. + * `https://` + * @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. + * @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. + * + * - `referer` : Referer header value. If provided it will take preference + * over the referer header value set by + * {@link Page.setExtraHTTPHeaders |page.setExtraHTTPHeaders()}.<br/> + * - `referrerPolicy` : ReferrerPolicy. If provided it will take preference + * over the referer-policy header value set by + * {@link Page.setExtraHTTPHeaders |page.setExtraHTTPHeaders()}. + * + * `page.goto` will throw an error 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. + * + * `page.goto` 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 response.status(). + * + * NOTE: `page.goto` either throws an error or returns a main resource + * response. The only exceptions are navigation to about:blank or navigation + * to the same URL with a different hash, which would succeed and return null. + * + * NOTE: 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)}. + */ + async goto( + url: string, + options?: WaitForOptions & {referer?: string; referrerPolicy?: string} + ): Promise<HTTPResponse | null>; + async goto(): Promise<HTTPResponse | null> { + throw new Error('Not implemented'); + } + + /** + * @param options - Navigation parameters which might have the following + * properties: + * @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. + * @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. + */ + async reload(options?: WaitForOptions): Promise<HTTPResponse | null>; + async reload(): Promise<HTTPResponse | null> { + throw new Error('Not implemented'); + } + + /** + * 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>; + async waitForNavigation(): Promise<HTTPResponse | null> { + throw new Error('Not implemented'); + } + + /** + * @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. + */ + async waitForRequest( + urlOrPredicate: string | ((req: HTTPRequest) => boolean | Promise<boolean>), + options?: {timeout?: number} + ): Promise<HTTPRequest>; + async waitForRequest(): Promise<HTTPRequest> { + throw new Error('Not implemented'); + } + + /** + * @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. + */ + async waitForResponse( + urlOrPredicate: + | string + | ((res: HTTPResponse) => boolean | Promise<boolean>), + options?: {timeout?: number} + ): Promise<HTTPResponse>; + async waitForResponse(): Promise<HTTPResponse> { + throw new Error('Not implemented'); + } + + /** + * @param options - Optional waiting parameters + * @returns Promise which resolves when network is idle + */ + async waitForNetworkIdle(options?: { + idleTime?: number; + timeout?: number; + }): Promise<void>; + async waitForNetworkIdle(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * @param urlOrPredicate - A URL or predicate to wait for. + * @param options - Optional waiting parameters + * @returns Promise which resolves to the matched frame. + * @example + * + * ```ts + * const frame = await page.waitForFrame(async frame => { + * return frame.name() === 'Test'; + * }); + * ``` + * + * @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. + */ + async waitForFrame( + urlOrPredicate: string | ((frame: Frame) => boolean | Promise<boolean>), + options?: {timeout?: number} + ): Promise<Frame>; + async waitForFrame(): Promise<Frame> { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + async goBack(options?: WaitForOptions): Promise<HTTPResponse | null>; + async goBack(): Promise<HTTPResponse | null> { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + async goForward(options?: WaitForOptions): Promise<HTTPResponse | null>; + async goForward(): Promise<HTTPResponse | null> { + throw new Error('Not implemented'); + } + + /** + * Brings page to front (activates tab). + */ + async bringToFront(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * 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}. + * + * @remarks + * 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. + */ + async setJavaScriptEnabled(enabled: boolean): Promise<void>; + async setJavaScriptEnabled(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + async setBypassCSP(enabled: boolean): Promise<void>; + async setBypassCSP(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * @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 + * ``` + */ + async emulateMediaType(type?: string): Promise<void>; + async emulateMediaType(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * Enables CPU throttling to emulate slow CPUs. + * @param factor - slowdown factor (1 is no throttle, 2 is 2x slowdown, etc). + */ + async emulateCPUThrottling(factor: number | null): Promise<void>; + async emulateCPUThrottling(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * @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 + * ``` + */ + async emulateMediaFeatures(features?: MediaFeature[]): Promise<void>; + async emulateMediaFeatures(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * @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. + */ + async emulateTimezone(timezoneId?: string): Promise<void>; + async emulateTimezone(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * 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 + */ + async emulateIdleState(overrides?: { + isUserActive: boolean; + isScreenUnlocked: boolean; + }): Promise<void>; + async emulateIdleState(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + async emulateVisionDeficiency( + type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'] + ): Promise<void>; + async emulateVisionDeficiency(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * `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 + * Argument viewport have following properties: + * + * - `width`: page width in pixels. required + * + * - `height`: page height in pixels. required + * + * - `deviceScaleFactor`: Specify device scale factor (can be thought of as + * DPR). Defaults to `1`. + * + * - `isMobile`: Whether the meta viewport tag is taken into account. Defaults + * to `false`. + * + * - `hasTouch`: Specifies if viewport supports touch events. Defaults to `false` + * + * - `isLandScape`: Specifies if viewport is in landscape mode. Defaults to false. + * + * NOTE: in certain cases, setting viewport will reload the page in order to + * set the isMobile or hasTouch properties. + */ + async setViewport(viewport: Viewport): Promise<void>; + async setViewport(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * Current page viewport settings. + * + * @returns + * + * - `width`: page's width in pixels + * + * - `height`: page's height in pixels + * + * - `deviceScaleFactor`: Specify device scale factor (can be though of as + * dpr). Defaults to `1`. + * + * - `isMobile`: Whether the meta viewport tag is taken into account. Defaults + * to `false`. + * + * - `hasTouch`: Specifies if viewport supports touch events. Defaults to + * `false`. + * + * - `isLandScape`: Specifies if viewport is in landscape mode. Defaults to + * `false`. + */ + viewport(): Viewport | null { + throw new Error('Not implemented'); + } + + /** + * Evaluates a function in the page's context and returns the result. + * + * If the function passed to `page.evaluateHandle` 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>>>; + async evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params> + >(): Promise<Awaited<ReturnType<Func>>> { + throw new Error('Not implemented'); + } + + /** + * 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); + * ``` + */ + async evaluateOnNewDocument< + Params extends unknown[], + Func extends (...args: Params) => unknown = (...args: Params) => unknown + >(pageFunction: Func | string, ...args: Params): Promise<void>; + async evaluateOnNewDocument(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * 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` + */ + async setCacheEnabled(enabled?: boolean): Promise<void>; + async setCacheEnabled(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * @internal + */ + async _maybeWriteBufferToFile( + path: string | undefined, + buffer: Buffer + ): Promise<void> { + if (!path) { + return; + } + + const fs = await importFSPromises(); + + await fs.writeFile(path, buffer); + } + + /** + * Captures screenshot of the current page. + * + * @remarks + * Options object which might have the following properties: + * + * - `path` : 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 + * {@link https://nodejs.org/api/process.html#process_process_cwd + * | current working directory}. + * If no path is provided, the image won't be saved to the disk. + * + * - `type` : Specify screenshot type, can be either `jpeg` or `png`. + * Defaults to 'png'. + * + * - `quality` : The quality of the image, between 0-100. Not + * applicable to `png` images. + * + * - `fullPage` : When true, takes a screenshot of the full + * scrollable page. Defaults to `false`. + * + * - `clip` : An object which specifies clipping region of the page. + * Should have the following fields:<br/> + * - `x` : x-coordinate of top-left corner of clip area.<br/> + * - `y` : y-coordinate of top-left corner of clip area.<br/> + * - `width` : width of clipping area.<br/> + * - `height` : height of clipping area. + * + * - `omitBackground` : Hides default white background and allows + * capturing screenshots with transparency. Defaults to `false`. + * + * - `encoding` : The encoding of the image, can be either base64 or + * binary. Defaults to `binary`. + * + * - `captureBeyondViewport` : When true, captures screenshot + * {@link https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-captureScreenshot + * | beyond the viewport}. When false, falls back to old behaviour, + * and cuts the screenshot by the viewport size. Defaults to `true`. + * + * - `fromSurface` : When true, captures screenshot + * {@link https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-captureScreenshot + * | from the surface rather than the view}. When false, works only in + * headful mode and ignores page viewport (but not browser window's + * bounds). Defaults to `true`. + * + * @returns Promise which resolves to buffer or a base64 string (depending on + * the value of `encoding`) with captured screenshot. + */ + screenshot( + options: ScreenshotOptions & {encoding: 'base64'} + ): Promise<string>; + screenshot( + options?: ScreenshotOptions & {encoding?: 'binary'} + ): Promise<Buffer>; + async screenshot(options?: ScreenshotOptions): Promise<Buffer | string>; + async screenshot(): Promise<Buffer | string> { + throw new Error('Not implemented'); + } + + /** + * @internal + */ + _getPDFOptions( + options: PDFOptions = {}, + lengthUnit: 'in' | 'cm' = 'in' + ): ParsedPDFOptions { + const defaults = { + scale: 1, + displayHeaderFooter: false, + headerTemplate: '', + footerTemplate: '', + printBackground: false, + landscape: false, + pageRanges: '', + preferCSSPageSize: false, + omitBackground: false, + timeout: 30000, + }; + + 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, + }; + + const output = { + ...defaults, + ...options, + width, + height, + margin, + }; + + return output; + } + + /** + * Generates a PDF of the page with the `print` CSS media type. + * @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. + * + * @param options - options for generating the PDF. + */ + async createPDFStream(options?: PDFOptions): Promise<Readable>; + async createPDFStream(): Promise<Readable> { + throw new Error('Not implemented'); + } + + /** + * {@inheritDoc Page.createPDFStream} + */ + async pdf(options?: PDFOptions): Promise<Buffer>; + async pdf(): Promise<Buffer> { + throw new Error('Not implemented'); + } + + /** + * The page's title + * + * @remarks + * Shortcut for {@link Frame.title | page.mainFrame().title()}. + */ + async title(): Promise<string> { + throw new Error('Not implemented'); + } + + async close(options?: {runBeforeUnload?: boolean}): Promise<void>; + async close(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * Indicates that the page has been closed. + * @returns + */ + isClosed(): boolean { + throw new Error('Not implemented'); + } + + /** + * {@inheritDoc Mouse} + */ + get mouse(): Mouse { + throw new Error('Not implemented'); + } + + /** + * 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>; + click(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * 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>; + focus(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * 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>; + hover(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * 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[]>; + select(): Promise<string[]> { + throw new Error('Not implemented'); + } + + /** + * 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. + * @returns + * @remarks + * Shortcut for {@link Frame.tap | page.mainFrame().tap(selector)}. + */ + tap(selector: string): Promise<void>; + tap(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * 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 + * @remarks + */ + type( + selector: string, + text: string, + options?: {delay: number} + ): Promise<void>; + type(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * @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>; + waitForTimeout(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * 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>; + async waitForSelector<Selector extends string>(): Promise<ElementHandle< + NodeFor<Selector> + > | null> { + throw new Error('Not implemented'); + } + + /** + * 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>; + waitForXPath(): Promise<ElementHandle<Node> | null> { + throw new Error('Not implemented'); + } + + /** + * Waits for a function to finish evaluating in the page's context. + * + * @example + * The {@link Page.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.waitForFunction('window.innerWidth < 100'); + * await page.setViewport({width: 50, height: 50}); + * await watchDog; + * await browser.close(); + * })(); + * ``` + * + * @example + * To pass arguments from node.js to the predicate of + * {@link Page.waitForFunction} function: + * + * ```ts + * const selector = '.foo'; + * await page.waitForFunction( + * selector => !!document.querySelector(selector), + * {}, + * selector + * ); + * ``` + * + * @example + * The predicate of {@link Page.waitForFunction} can be asynchronous too: + * + * ```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 + * @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>>>>; + waitForFunction< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params> + >(): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + throw new Error('Not implemented'); + } + + /** + * 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')) + * ); + * ``` + */ + waitForDevicePrompt( + options?: WaitTimeoutOptions + ): Promise<DeviceRequestPrompt>; + waitForDevicePrompt(): Promise<DeviceRequestPrompt> { + throw new Error('Not implemented'); + } +} + +/** + * @internal + */ +export const supportedMetrics = new Set<string>([ + 'Timestamp', + 'Documents', + 'Frames', + 'JSEventListeners', + 'Nodes', + 'LayoutCount', + 'RecalcStyleCount', + 'LayoutDuration', + 'RecalcStyleDuration', + 'ScriptDuration', + 'TaskDuration', + 'JSHeapUsedSize', + 'JSHeapTotalSize', +]); + +/** + * @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]; +} 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..704c8d127f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/api.ts @@ -0,0 +1,23 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +export * from './Browser.js'; +export * from './BrowserContext.js'; +export * from './Page.js'; +export * from './JSHandle.js'; +export * from './ElementHandle.js'; +export * from './HTTPResponse.js'; +export * from './HTTPRequest.js'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Accessibility.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Accessibility.ts new file mode 100644 index 0000000000..1429ecf607 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Accessibility.ts @@ -0,0 +1,577 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * 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. + */ + +import {Protocol} from 'devtools-protocol'; + +import {ElementHandle} from '../api/ElementHandle.js'; + +import {CDPSession} from './Connection.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; + } + + /** + * 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'; + } + + #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 '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/common/AriaQueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/AriaQueryHandler.ts new file mode 100644 index 0000000000..ac322370ba --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/AriaQueryHandler.ts @@ -0,0 +1,124 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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. + */ + +import {Protocol} from 'devtools-protocol'; + +import {ElementHandle} from '../api/ElementHandle.js'; +import {assert} from '../util/assert.js'; +import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js'; + +import {CDPSession} from './Connection.js'; +import {QueryHandler, QuerySelector} from './QueryHandler.js'; +import {AwaitableIterable} from './types.js'; + +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 || node.role.value !== 'StaticText'; + }); +}; + +type ARIASelector = {name?: string; role?: string}; + +const KNOWN_ATTRIBUTES = Object.freeze(['name', 'role']); +const isKnownAttribute = ( + attribute: string +): attribute is keyof ARIASelector => { + return KNOWN_ATTRIBUTES.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="img"]' queries for elements with role 'img' 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 ariaQuerySelector(node, selector); + }; + + static override async *queryAll( + element: ElementHandle<Node>, + selector: string + ): AwaitableIterable<ElementHandle<Node>> { + const context = element.executionContext(); + const {name, role} = parseARIASelector(selector); + const results = await queryAXTree(context._client, element, name, role); + const world = context._world!; + yield* AsyncIterableUtil.map(results, node => { + return world.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/common/Binding.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Binding.ts new file mode 100644 index 0000000000..01268bbefa --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Binding.ts @@ -0,0 +1,123 @@ +import {JSHandle} from '../api/JSHandle.js'; +import {isErrorLike} from '../util/ErrorLike.js'; + +import {ExecutionContext} from './ExecutionContext.js'; +import {debugError} from './util.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 garbage = []; + try { + if (!isTrivial) { + // Getting non-trivial arguments. + const 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 + ); + try { + 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: + garbage.push(handle.dispose()); + } + } else { + garbage.push(handle.dispose()); + } + } + } finally { + await handles.dispose(); + } + } + + 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) { + garbage.push(arg.dispose()); + } + } + } 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); + } + } finally { + await Promise.all(garbage); + } + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Browser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Browser.ts new file mode 100644 index 0000000000..6a2b46f2e2 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Browser.ts @@ -0,0 +1,737 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * 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. + */ + +import {ChildProcess} from 'child_process'; + +import {Protocol} from 'devtools-protocol'; + +import { + Browser as BrowserBase, + BrowserCloseCallback, + TargetFilterCallback, + IsPageTargetCallback, + BrowserEmittedEvents, + BrowserContextEmittedEvents, + BrowserContextOptions, + WEB_PERMISSION_TO_PROTOCOL_PERMISSION, + WaitForTargetOptions, + Permission, +} from '../api/Browser.js'; +import {BrowserContext} from '../api/BrowserContext.js'; +import {Page} from '../api/Page.js'; +import {assert} from '../util/assert.js'; +import {createDeferredPromise} from '../util/DeferredPromise.js'; + +import {ChromeTargetManager} from './ChromeTargetManager.js'; +import {CDPSession, Connection, ConnectionEmittedEvents} from './Connection.js'; +import {FirefoxTargetManager} from './FirefoxTargetManager.js'; +import {Viewport} from './PuppeteerViewport.js'; +import {Target} from './Target.js'; +import {TargetManager, TargetManagerEmittedEvents} from './TargetManager.js'; +import {TaskQueue} from './TaskQueue.js'; +import {waitWithTimeout} from './util.js'; + +/** + * @internal + */ +export class CDPBrowser extends BrowserBase { + /** + * @internal + */ + static async _create( + product: 'firefox' | 'chrome' | undefined, + connection: Connection, + contextIds: string[], + ignoreHTTPSErrors: boolean, + defaultViewport?: Viewport | null, + process?: ChildProcess, + closeCallback?: BrowserCloseCallback, + targetFilterCallback?: TargetFilterCallback, + isPageTargetCallback?: IsPageTargetCallback + ): Promise<CDPBrowser> { + const browser = new CDPBrowser( + product, + connection, + contextIds, + ignoreHTTPSErrors, + defaultViewport, + process, + closeCallback, + targetFilterCallback, + isPageTargetCallback + ); + await browser._attach(); + return browser; + } + #ignoreHTTPSErrors: boolean; + #defaultViewport?: Viewport | null; + #process?: ChildProcess; + #connection: Connection; + #closeCallback: BrowserCloseCallback; + #targetFilterCallback: TargetFilterCallback; + #isPageTargetCallback!: IsPageTargetCallback; + #defaultContext: CDPBrowserContext; + #contexts: Map<string, CDPBrowserContext>; + #screenshotTaskQueue: TaskQueue; + #targetManager: TargetManager; + + /** + * @internal + */ + override get _targets(): Map<string, Target> { + return this.#targetManager.getAvailableTargets(); + } + + /** + * @internal + */ + constructor( + product: 'chrome' | 'firefox' | undefined, + connection: Connection, + contextIds: string[], + ignoreHTTPSErrors: boolean, + defaultViewport?: Viewport | null, + process?: ChildProcess, + closeCallback?: BrowserCloseCallback, + targetFilterCallback?: TargetFilterCallback, + isPageTargetCallback?: IsPageTargetCallback + ) { + super(); + product = product || 'chrome'; + this.#ignoreHTTPSErrors = ignoreHTTPSErrors; + this.#defaultViewport = defaultViewport; + this.#process = process; + this.#screenshotTaskQueue = new TaskQueue(); + 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 + ); + } + this.#defaultContext = new CDPBrowserContext(this.#connection, this); + this.#contexts = new Map(); + for (const contextId of contextIds) { + this.#contexts.set( + contextId, + new CDPBrowserContext(this.#connection, this, contextId) + ); + } + } + + #emitDisconnected = () => { + this.emit(BrowserEmittedEvents.Disconnected); + }; + + /** + * @internal + */ + override async _attach(): Promise<void> { + this.#connection.on( + ConnectionEmittedEvents.Disconnected, + this.#emitDisconnected + ); + this.#targetManager.on( + TargetManagerEmittedEvents.TargetAvailable, + this.#onAttachedToTarget + ); + this.#targetManager.on( + TargetManagerEmittedEvents.TargetGone, + this.#onDetachedFromTarget + ); + this.#targetManager.on( + TargetManagerEmittedEvents.TargetChanged, + this.#onTargetChanged + ); + this.#targetManager.on( + TargetManagerEmittedEvents.TargetDiscovered, + this.#onTargetDiscovered + ); + await this.#targetManager.initialize(); + } + + /** + * @internal + */ + override _detach(): void { + this.#connection.off( + ConnectionEmittedEvents.Disconnected, + this.#emitDisconnected + ); + this.#targetManager.off( + TargetManagerEmittedEvents.TargetAvailable, + this.#onAttachedToTarget + ); + this.#targetManager.off( + TargetManagerEmittedEvents.TargetGone, + this.#onDetachedFromTarget + ); + this.#targetManager.off( + TargetManagerEmittedEvents.TargetChanged, + this.#onTargetChanged + ); + this.#targetManager.off( + TargetManagerEmittedEvents.TargetDiscovered, + this.#onTargetDiscovered + ); + } + + /** + * The spawned browser process. Returns `null` if the browser instance was created with + * {@link Puppeteer.connect}. + */ + override process(): ChildProcess | null { + return this.#process ?? null; + } + + /** + * @internal + */ + _targetManager(): TargetManager { + return this.#targetManager; + } + + #setIsPageTargetCallback(isPageTargetCallback?: IsPageTargetCallback): void { + this.#isPageTargetCallback = + isPageTargetCallback || + ((target: Protocol.Target.TargetInfo): boolean => { + return ( + target.type === 'page' || + target.type === 'background_page' || + target.type === 'webview' + ); + }); + } + + /** + * @internal + */ + override _getIsPageTargetCallback(): IsPageTargetCallback | undefined { + return this.#isPageTargetCallback; + } + + /** + * Creates a new incognito browser context. This won't share cookies/cache with other + * browser contexts. + * + * @example + * + * ```ts + * (async () => { + * 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'); + * })(); + * ``` + */ + 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; + } + + /** + * Returns an array of all open browser contexts. In a newly created browser, this will + * return a single instance of {@link BrowserContext}. + */ + override browserContexts(): CDPBrowserContext[] { + return [this.#defaultContext, ...Array.from(this.#contexts.values())]; + } + + /** + * Returns the default browser context. The default browser context cannot be closed. + */ + override defaultBrowserContext(): CDPBrowserContext { + return this.#defaultContext; + } + + /** + * @internal + */ + override 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'); + } + + return new Target( + targetInfo, + session, + context, + this.#targetManager, + (isAutoAttachEmulated: boolean) => { + return this.#connection._createSession( + targetInfo, + isAutoAttachEmulated + ); + }, + this.#ignoreHTTPSErrors, + this.#defaultViewport ?? null, + this.#screenshotTaskQueue, + this.#isPageTargetCallback + ); + }; + + #onAttachedToTarget = async (target: Target) => { + if (await target._initializedPromise) { + this.emit(BrowserEmittedEvents.TargetCreated, target); + target + .browserContext() + .emit(BrowserContextEmittedEvents.TargetCreated, target); + } + }; + + #onDetachedFromTarget = async (target: Target): Promise<void> => { + target._initializedCallback(false); + target._closedCallback(); + if (await target._initializedPromise) { + this.emit(BrowserEmittedEvents.TargetDestroyed, target); + target + .browserContext() + .emit(BrowserContextEmittedEvents.TargetDestroyed, target); + } + }; + + #onTargetChanged = ({ + target, + targetInfo, + }: { + target: Target; + targetInfo: Protocol.Target.TargetInfo; + }): void => { + const previousURL = target.url(); + const wasInitialized = target._isInitialized; + target._targetInfoChanged(targetInfo); + if (wasInitialized && previousURL !== target.url()) { + this.emit(BrowserEmittedEvents.TargetChanged, target); + target + .browserContext() + .emit(BrowserContextEmittedEvents.TargetChanged, target); + } + }; + + #onTargetDiscovered = (targetInfo: Protocol.Target.TargetInfo): void => { + this.emit('targetdiscovered', targetInfo); + }; + + /** + * The browser websocket endpoint which can be used as an argument to + * {@link Puppeteer.connect}. + * + * @returns The Browser websocket url. + * + * @remarks + * + * The format is `ws://${host}:${port}/devtools/browser/<id>`. + * + * You can find the `webSocketDebuggerUrl` from `http://${host}:${port}/json/version`. + * Learn more about the + * {@link https://chromedevtools.github.io/devtools-protocol | devtools protocol} and + * the {@link + * https://chromedevtools.github.io/devtools-protocol/#how-do-i-access-the-browser-target + * | browser endpoint}. + */ + override wsEndpoint(): string { + return this.#connection.url(); + } + + /** + * Promise which resolves to a new {@link Page} object. The Page is created in + * a default browser context. + */ + override async newPage(): Promise<Page> { + return this.#defaultContext.newPage(); + } + + /** + * @internal + */ + override async _createPageInContext(contextId?: string): Promise<Page> { + const {targetId} = await this.#connection.send('Target.createTarget', { + url: 'about:blank', + browserContextId: contextId || undefined, + }); + const target = this.#targetManager.getAvailableTargets().get(targetId); + if (!target) { + throw new Error(`Missing target for page (id = ${targetId})`); + } + const initialized = await target._initializedPromise; + 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; + } + + /** + * All active targets inside the Browser. In case of multiple browser contexts, returns + * an array with all the targets in all browser contexts. + */ + override targets(): Target[] { + return Array.from( + this.#targetManager.getAvailableTargets().values() + ).filter(target => { + return target._isInitialized; + }); + } + + /** + * The target associated with the browser. + */ + override target(): Target { + const browserTarget = this.targets().find(target => { + return target.type() === 'browser'; + }); + if (!browserTarget) { + throw new Error('Browser target is not found'); + } + return browserTarget; + } + + /** + * Searches for a target in all browser contexts. + * + * @param predicate - A function to be run for every target. + * @returns The first target found that matches the `predicate` function. + * + * @example + * + * An example of 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/' + * ); + * ``` + */ + override async waitForTarget( + predicate: (x: Target) => boolean | Promise<boolean>, + options: WaitForTargetOptions = {} + ): Promise<Target> { + const {timeout = 30000} = options; + const targetPromise = createDeferredPromise<Target | PromiseLike<Target>>(); + + this.on(BrowserEmittedEvents.TargetCreated, check); + this.on(BrowserEmittedEvents.TargetChanged, check); + try { + this.targets().forEach(check); + if (!timeout) { + return await targetPromise; + } + return await waitWithTimeout(targetPromise, 'target', timeout); + } finally { + this.off(BrowserEmittedEvents.TargetCreated, check); + this.off(BrowserEmittedEvents.TargetChanged, check); + } + + async function check(target: Target): Promise<void> { + if ((await predicate(target)) && !targetPromise.resolved()) { + targetPromise.resolve(target); + } + } + } + + /** + * An array of all open pages inside the Browser. + * + * @remarks + * + * In case of multiple browser contexts, returns an array with all the pages in all + * browser contexts. Non-visible pages, such as `"background_page"`, will not be listed + * here. You can find them using {@link Target.page}. + */ + override 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); + }, []); + } + + override async version(): Promise<string> { + const version = await this.#getVersion(); + return version.product; + } + + /** + * The browser's original user agent. Pages can override the browser user agent with + * {@link Page.setUserAgent}. + */ + override async userAgent(): Promise<string> { + const version = await this.#getVersion(); + return version.userAgent; + } + + override async close(): Promise<void> { + await this.#closeCallback.call(null); + this.disconnect(); + } + + override disconnect(): void { + this.#targetManager.dispose(); + this.#connection.dispose(); + this._detach(); + } + + /** + * Indicates that the browser is connected. + */ + override isConnected(): boolean { + return !this.#connection._closed; + } + + #getVersion(): Promise<Protocol.Browser.GetVersionResponse> { + return this.#connection.send('Browser.getVersion'); + } +} + +/** + * @internal + */ +export class CDPBrowserContext extends BrowserContext { + #connection: Connection; + #browser: CDPBrowser; + #id?: string; + + /** + * @internal + */ + 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; + } + + /** + * An array of all active targets inside the browser context. + */ + override targets(): Target[] { + return this.#browser.targets().filter(target => { + return target.browserContext() === this; + }); + } + + /** + * This searches for a target in this specific browser context. + * + * @example + * An example of 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/' + * ); + * ``` + * + * @param predicate - A function to be run for every target + * @param options - An object of options. Accepts a timeout, + * which is the maximum wait time in milliseconds. + * Pass `0` to disable the timeout. Defaults to 30 seconds. + * @returns Promise which resolves to the first target found + * that matches the `predicate` function. + */ + override waitForTarget( + predicate: (x: Target) => boolean | Promise<boolean>, + options: {timeout?: number} = {} + ): Promise<Target> { + return this.#browser.waitForTarget(target => { + return target.browserContext() === this && predicate(target); + }, options); + } + + /** + * An array of all pages inside the browser context. + * + * @returns Promise which resolves to an array of all open pages. + * Non visible pages, such as `"background_page"`, will not be listed here. + * You can find them using {@link Target.page | the target page}. + */ + 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._getTargetInfo() + )) + ); + }) + .map(target => { + return target.page(); + }) + ); + return pages.filter((page): page is Page => { + return !!page; + }); + } + + /** + * Returns whether BrowserContext is incognito. + * The default browser context is the only non-incognito browser context. + * + * @remarks + * The default browser context cannot be closed. + */ + override isIncognito(): boolean { + return !!this.#id; + } + + /** + * @example + * + * ```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. + */ + 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, + }); + } + + /** + * Clears all permission overrides for the browser context. + * + * @example + * + * ```ts + * const context = browser.defaultBrowserContext(); + * context.overridePermissions('https://example.com', ['clipboard-read']); + * // do stuff .. + * context.clearPermissionOverrides(); + * ``` + */ + override async clearPermissionOverrides(): Promise<void> { + await this.#connection.send('Browser.resetPermissions', { + browserContextId: this.#id || undefined, + }); + } + + /** + * Creates a new page in the browser context. + */ + override newPage(): Promise<Page> { + return this.#browser._createPageInContext(this.#id); + } + + /** + * The browser this browser context belongs to. + */ + override browser(): CDPBrowser { + return this.#browser; + } + + /** + * Closes the browser context. All the targets that belong to the browser context + * will be closed. + * + * @remarks + * Only incognito browser contexts can be closed. + */ + 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/common/BrowserConnector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserConnector.ts new file mode 100644 index 0000000000..c9916a8570 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserConnector.ts @@ -0,0 +1,176 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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. + */ + +import {IsPageTargetCallback, TargetFilterCallback} from '../api/Browser.js'; +import {isNode} from '../environment.js'; +import {assert} from '../util/assert.js'; +import {isErrorLike} from '../util/ErrorLike.js'; + +import {CDPBrowser} from './Browser.js'; +import {Connection} from './Connection.js'; +import {ConnectionTransport} from './ConnectionTransport.js'; +import {getFetch} from './fetch.js'; +import type {ConnectOptions} from './Puppeteer.js'; +import {Viewport} from './PuppeteerViewport.js'; +import {debugError} from './util.js'; +/** + * 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. + */ + 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' + * @internal + */ + protocol?: 'cdp' | 'webDriverBiDi'; + /** + * Timeout setting for individual protocol (CDP) calls. + * + * @defaultValue `180_000` + */ + protocolTimeout?: number; +} + +const getWebSocketTransportClass = async () => { + return isNode + ? (await import('./NodeWebSocketTransport.js')).NodeWebSocketTransport + : (await import('./BrowserWebSocketTransport.js')) + .BrowserWebSocketTransport; +}; + +/** + * Users should never call this directly; it's called when calling + * `puppeteer.connect`. + * + * @internal + */ +export async function _connectToCDPBrowser( + options: BrowserConnectOptions & ConnectOptions +): Promise<CDPBrowser> { + const { + browserWSEndpoint, + browserURL, + ignoreHTTPSErrors = false, + defaultViewport = {width: 800, height: 600}, + transport, + headers = {}, + slowMo = 0, + targetFilter, + _isPageTarget: isPageTarget, + protocolTimeout, + } = options; + + assert( + Number(!!browserWSEndpoint) + Number(!!browserURL) + Number(!!transport) === + 1, + 'Exactly one of browserWSEndpoint, browserURL or transport must be passed to puppeteer.connect' + ); + + let connection!: Connection; + if (transport) { + connection = new Connection('', transport, slowMo, protocolTimeout); + } else if (browserWSEndpoint) { + const WebSocketClass = await getWebSocketTransportClass(); + const connectionTransport: ConnectionTransport = + await WebSocketClass.create(browserWSEndpoint, headers); + connection = new Connection( + browserWSEndpoint, + connectionTransport, + slowMo, + protocolTimeout + ); + } else if (browserURL) { + const connectionURL = await getWSEndpoint(browserURL); + const WebSocketClass = await getWebSocketTransportClass(); + const connectionTransport: ConnectionTransport = + await WebSocketClass.create(connectionURL); + connection = new Connection( + connectionURL, + 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; +} + +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..5fe32e6526 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserWebSocketTransport.ts @@ -0,0 +1,60 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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. + */ +import {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/ChromeTargetManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/ChromeTargetManager.ts new file mode 100644 index 0000000000..58c8353aad --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/ChromeTargetManager.ts @@ -0,0 +1,401 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +import {Protocol} from 'devtools-protocol'; + +import {TargetFilterCallback} from '../api/Browser.js'; +import {assert} from '../util/assert.js'; +import {createDeferredPromise} from '../util/DeferredPromise.js'; + +import {CDPSession, Connection} from './Connection.js'; +import {EventEmitter} from './EventEmitter.js'; +import {Target} from './Target.js'; +import { + TargetInterceptor, + TargetFactory, + TargetManager, + TargetManagerEmittedEvents, +} from './TargetManager.js'; +import {debugError} from './util.js'; + +/** + * 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 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: Map<string, Protocol.Target.TargetInfo> = + new Map(); + /** + * A target is added to this map once ChromeTargetManager has created + * a Target and attached at least once to it. + */ + #attachedTargetsByTargetId: Map<string, Target> = new Map(); + /** + * Tracks which sessions attach to which target. + */ + #attachedTargetsBySessionId: Map<string, Target> = new Map(); + /** + * 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; + + #targetInterceptors: WeakMap<CDPSession | Connection, TargetInterceptor[]> = + new WeakMap(); + + #attachedToTargetListenersBySession: WeakMap< + CDPSession | Connection, + (event: Protocol.Target.AttachedToTargetEvent) => Promise<void> + > = new WeakMap(); + #detachedFromTargetListenersBySession: WeakMap< + CDPSession | Connection, + (event: Protocol.Target.DetachedFromTargetEvent) => void + > = new WeakMap(); + + #initializePromise = createDeferredPromise<void>(); + #targetsIdsForInit: Set<string> = new Set(); + + 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('Target.targetInfoChanged', this.#onTargetInfoChanged); + this.#connection.on('sessiondetached', this.#onSessionDetached); + this.#setupAttachmentListeners(this.#connection); + + this.#connection + .send('Target.setDiscoverTargets', { + discover: true, + filter: [{type: 'tab', exclude: true}, {}], + }) + .then(this.#storeExistingTargetsForInit) + .catch(debugError); + } + + #storeExistingTargetsForInit = () => { + for (const [ + targetId, + targetInfo, + ] of this.#discoveredTargetsByTargetId.entries()) { + if ( + (!this.#targetFilterCallback || + this.#targetFilterCallback(targetInfo)) && + targetInfo.type !== 'browser' + ) { + this.#targetsIdsForInit.add(targetId); + } + } + }; + + async initialize(): Promise<void> { + await this.#connection.send('Target.setAutoAttach', { + waitForDebuggerOnStart: true, + flatten: true, + autoAttach: true, + }); + this.#finishInitializationIfReady(); + await this.#initializePromise; + } + + 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('sessiondetached', this.#onSessionDetached); + + this.#removeAttachmentListeners(this.#connection); + } + + getAvailableTargets(): Map<string, Target> { + return this.#attachedTargetsByTargetId; + } + + addTargetInterceptor( + session: CDPSession | Connection, + interceptor: TargetInterceptor + ): void { + const interceptors = this.#targetInterceptors.get(session) || []; + interceptors.push(interceptor); + this.#targetInterceptors.set(session, interceptors); + } + + removeTargetInterceptor( + client: CDPSession | Connection, + interceptor: TargetInterceptor + ): void { + const interceptors = this.#targetInterceptors.get(client) || []; + this.#targetInterceptors.set( + client, + interceptors.filter(currentInterceptor => { + return currentInterceptor !== interceptor; + }) + ); + } + + #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); + + 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 { + if (this.#attachedToTargetListenersBySession.has(session)) { + session.off( + 'Target.attachedToTarget', + this.#attachedToTargetListenersBySession.get(session)! + ); + 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); + this.#targetInterceptors.delete(session); + }; + + #onTargetCreated = async (event: Protocol.Target.TargetCreatedEvent) => { + this.#discoveredTargetsByTargetId.set( + event.targetInfo.targetId, + event.targetInfo + ); + + this.emit(TargetManagerEmittedEvents.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); + 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); + this.emit(TargetManagerEmittedEvents.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 + ); + this.emit(TargetManagerEmittedEvents.TargetChanged, { + target: target!, + targetInfo: event.targetInfo, + }); + }; + + #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.#connection.isAutoAttached(targetInfo.targetId) + ) { + this.#finishInitializationIfReady(targetInfo.targetId); + await silentDetach(); + if (this.#attachedTargetsByTargetId.has(targetInfo.targetId)) { + return; + } + const target = this.#targetFactory(targetInfo); + this.#attachedTargetsByTargetId.set(targetInfo.targetId, target); + this.emit(TargetManagerEmittedEvents.TargetAvailable, target); + return; + } + + if (this.#targetFilterCallback && !this.#targetFilterCallback(targetInfo)) { + this.#ignoredTargets.add(targetInfo.targetId); + this.#finishInitializationIfReady(targetInfo.targetId); + await silentDetach(); + return; + } + + const existingTarget = this.#attachedTargetsByTargetId.has( + targetInfo.targetId + ); + + const target = existingTarget + ? this.#attachedTargetsByTargetId.get(targetInfo.targetId)! + : this.#targetFactory(targetInfo, session); + + this.#setupAttachmentListeners(session); + + if (existingTarget) { + this.#attachedTargetsBySessionId.set( + session.id(), + this.#attachedTargetsByTargetId.get(targetInfo.targetId)! + ); + } else { + this.#attachedTargetsByTargetId.set(targetInfo.targetId, target); + this.#attachedTargetsBySessionId.set(session.id(), target); + } + + for (const interceptor of this.#targetInterceptors.get(parentSession) || + []) { + if (!(parentSession instanceof Connection)) { + // Sanity check: if parent session is not a connection, it should be + // present in #attachedTargetsBySessionId. + assert(this.#attachedTargetsBySessionId.has(parentSession.id())); + } + interceptor( + target, + parentSession instanceof Connection + ? null + : this.#attachedTargetsBySessionId.get(parentSession.id())! + ); + } + + this.#targetsIdsForInit.delete(target._targetId); + if (!existingTarget) { + this.emit(TargetManagerEmittedEvents.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, + }), + session.send('Runtime.runIfWaitingForDebugger'), + ]).catch(debugError); + }; + + #finishInitializationIfReady(targetId?: string): void { + targetId !== undefined && this.#targetsIdsForInit.delete(targetId); + if (this.#targetsIdsForInit.size === 0) { + this.#initializePromise.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(TargetManagerEmittedEvents.TargetGone, target); + }; +} 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..6db94c3452 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Configuration.ts @@ -0,0 +1,121 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +import {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_HOST`. + * + * @remarks + * This must include the protocol and may even need a path prefix. + * + * @defaultValue Either https://storage.googleapis.com or + * https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central, + * depending on the product. + */ + downloadHost?: string; + /** + * Specifies the path for the downloads folder. + * + * Can be overridden by `PUPPETEER_DOWNLOAD_PATH`. + * + * @defaultValue `<cache>/<product>` where `<cache>` is Puppeteer's cache + * directory and `<product>` is the name of the browser. + */ + 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 log at the given level. + * + * At the moment, any option silences logging. + * + * @defaultValue `undefined` + */ + logLevel?: 'silent' | 'error' | 'warn'; + /** + * Defines experimental options for Puppeteer. + */ + experiments?: ExperimentsConfiguration; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Connection.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Connection.ts new file mode 100644 index 0000000000..8b75848347 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Connection.ts @@ -0,0 +1,615 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * 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. + */ + +import {Protocol} from 'devtools-protocol'; +import {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js'; + +import {assert} from '../util/assert.js'; +import {createDeferredPromise} from '../util/util.js'; + +import {ConnectionTransport} from './ConnectionTransport.js'; +import {debug} from './Debug.js'; +import {ProtocolError} from './Errors.js'; +import {EventEmitter} from './EventEmitter.js'; + +const debugProtocolSend = debug('puppeteer:protocol:SEND ►'); +const debugProtocolReceive = debug('puppeteer:protocol:RECV ◀'); + +/** + * @public + */ +export {ConnectionTransport, ProtocolMapping}; + +/** + * Internal events that the Connection class emits. + * + * @internal + */ +export const ConnectionEmittedEvents = { + Disconnected: Symbol('Connection.Disconnected'), +} as const; + +/** + * @internal + */ +type GetIdFn = () => number; + +/** + * @internal + */ +function createIncrementalIdGenerator(): GetIdFn { + let id = 0; + return (): number => { + return ++id; + }; +} + +/** + * @internal + */ +class Callback { + #id: number; + #error = new ProtocolError(); + #promise = createDeferredPromise<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.#promise.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.#promise.resolve(value); + } + + reject(error: Error): void { + clearTimeout(this.#timer); + this.#promise.reject(error); + } + + get id(): number { + return this.#id; + } + + get promise(): Promise<unknown> { + return this.#promise; + } + + get error(): ProtocolError { + return this.#error; + } + + get label(): string { + return this.#label; + } +} + +/** + * Manages callbacks and their IDs for the protocol request/response communication. + * + * @internal + */ +export class CallbackRegistry { + #callbacks: Map<number, Callback> = new Map(); + #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.catch(() => { + this.#callbacks.delete(callback.id); + }); + callback.reject(error as Error); + throw error; + } + // Must only have sync code up until here. + return callback.promise.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, message: string, originalMessage?: string): void { + callback.reject( + rewriteError( + callback.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, 'Target closed'); + } + this.#callbacks.clear(); + } +} + +/** + * @public + */ +export class Connection extends EventEmitter { + #url: string; + #transport: ConnectionTransport; + #delay: number; + #timeout: number; + #sessions: Map<string, CDPSessionImpl> = new Map(); + #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, + ...paramArgs: ProtocolMapping.Commands[T]['paramsType'] + ): 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. + const params = paramArgs.length ? paramArgs[0] : undefined; + return this._rawSend(this.#callbacks, method, params); + } + + /** + * @internal + */ + _rawSend<T extends keyof ProtocolMapping.Commands>( + callbacks: CallbackRegistry, + method: T, + params: ProtocolMapping.Commands[T]['paramsType'][0], + sessionId?: string + ): Promise<ProtocolMapping.Commands[T]['returnType']> { + return callbacks.create(method, 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(f => { + return setTimeout(f, this.#delay); + }); + } + debugProtocolReceive(message); + const object = JSON.parse(message); + if (object.method === 'Target.attachedToTarget') { + const sessionId = object.params.sessionId; + const session = new CDPSessionImpl( + this, + object.params.targetInfo.type, + sessionId + ); + this.#sessions.set(sessionId, session); + this.emit('sessionattached', session); + const parentSession = this.#sessions.get(object.sessionId); + if (parentSession) { + parentSession.emit('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('sessiondetached', session); + const parentSession = this.#sessions.get(object.sessionId); + if (parentSession) { + parentSession.emit('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(ConnectionEmittedEvents.Disconnected); + } + + 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); + } +} + +/** + * @public + */ +export interface CDPSessionOnMessageObject { + id?: number; + method: string; + params: Record<string, unknown>; + error: {message: string; data: any; code: number}; + result?: any; +} + +/** + * Internal events that the CDPSession class emits. + * + * @internal + */ +export const CDPSessionEmittedEvents = { + Disconnected: Symbol('CDPSession.Disconnected'), +} as const; + +/** + * 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 class CDPSession extends EventEmitter { + /** + * @internal + */ + constructor() { + super(); + } + + connection(): Connection | undefined { + throw new Error('Not implemented'); + } + + send<T extends keyof ProtocolMapping.Commands>( + method: T, + ...paramArgs: ProtocolMapping.Commands[T]['paramsType'] + ): Promise<ProtocolMapping.Commands[T]['returnType']>; + send<T extends keyof ProtocolMapping.Commands>(): Promise< + ProtocolMapping.Commands[T]['returnType'] + > { + throw new Error('Not implemented'); + } + + /** + * Detaches the cdpSession from the target. Once detached, the cdpSession object + * won't emit any events and can't be used to send messages. + */ + async detach(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * Returns the session's id. + */ + id(): string { + throw new Error('Not implemented'); + } +} + +/** + * @internal + */ +export class CDPSessionImpl extends CDPSession { + #sessionId: string; + #targetType: string; + #callbacks = new CallbackRegistry(); + #connection?: Connection; + + /** + * @internal + */ + constructor(connection: Connection, targetType: string, sessionId: string) { + super(); + this.#connection = connection; + this.#targetType = targetType; + this.#sessionId = sessionId; + } + + override connection(): Connection | undefined { + return this.#connection; + } + + override send<T extends keyof ProtocolMapping.Commands>( + method: T, + ...paramArgs: ProtocolMapping.Commands[T]['paramsType'] + ): Promise<ProtocolMapping.Commands[T]['returnType']> { + if (!this.#connection) { + return Promise.reject( + new Error( + `Protocol error (${method}): Session closed. Most likely the ${ + this.#targetType + } has been closed.` + ) + ); + } + // See the comment in Connection#send explaining why we do this. + const params = paramArgs.length ? paramArgs[0] : undefined; + return this.#connection._rawSend( + this.#callbacks, + method, + params, + this.#sessionId + ); + } + + /** + * @internal + */ + _onMessage(object: CDPSessionOnMessageObject): 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(CDPSessionEmittedEvents.Disconnected); + } + + /** + * Returns the session's id. + */ + override id(): string { + return this.#sessionId; + } +} + +function createProtocolErrorMessage(object: { + error: {message: string; data: any; code: number}; +}): string { + let message = `${object.error.message}`; + if ('data' in object.error) { + message += ` ${object.error.data}`; + } + return message; +} + +function rewriteError( + error: ProtocolError, + message: string, + originalMessage?: string +): Error { + error.message = message; + error.originalMessage = originalMessage ?? error.originalMessage; + return error; +} + +/** + * @internal + */ +export function isTargetClosedError(err: Error): boolean { + return ( + err.message.includes('Target closed') || + err.message.includes('Session closed') + ); +} 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..753379fd56 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/ConnectionTransport.ts @@ -0,0 +1,25 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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. + */ + +/** + * @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..9e911c8149 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/ConsoleMessage.ts @@ -0,0 +1,123 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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. + */ + +import {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/Coverage.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Coverage.ts new file mode 100644 index 0000000000..b19d79e95e --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Coverage.ts @@ -0,0 +1,501 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * 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. + */ + +import {Protocol} from 'devtools-protocol'; + +import {assert} from '../util/assert.js'; + +import {CDPSession} from './Connection.js'; +import {EVALUATION_SCRIPT_URL} from './ExecutionContext.js'; +import {addEventListener, debugError, PuppeteerEventListener} from './util.js'; +import {removeEventListeners} from './util.js'; + +/** + * @internal + */ +export {PuppeteerEventListener}; + +/** + * 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); + } + + /** + * @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>(); + #eventListeners: PuppeteerEventListener[] = []; + #resetOnNavigation = false; + #reportAnonymousScripts = false; + #includeRawScriptCoverage = false; + + constructor(client: CDPSession) { + 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.#eventListeners = [ + addEventListener( + this.#client, + 'Debugger.scriptParsed', + this.#onScriptParsed.bind(this) + ), + addEventListener( + 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 (event.url === EVALUATION_SCRIPT_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'), + ]); + + removeEventListeners(this.#eventListeners); + + 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: PuppeteerEventListener[] = []; + #resetOnNavigation = false; + + constructor(client: CDPSession) { + 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 = [ + addEventListener( + this.#client, + 'CSS.styleSheetAdded', + this.#onStyleSheet.bind(this) + ), + addEventListener( + 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'), + ]); + removeEventListeners(this.#eventListeners); + + // 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/common/CustomQueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/CustomQueryHandler.ts new file mode 100644 index 0000000000..89ae0ca0a2 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/CustomQueryHandler.ts @@ -0,0 +1,227 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +import type PuppeteerUtil from '../injected/injected.js'; +import {assert} from '../util/assert.js'; +import {interpolateFunction, stringifyFunction} from '../util/Function.js'; + +import {QueryHandler, QuerySelector, 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] + >(); + + /** + * @internal + */ + 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. + * + * @internal + */ + register(name: string, handler: CustomQueryHandler): void { + if (this.#handlers.has(name)) { + throw new Error(`Cannot register over existing handler: ${name}`); + } + 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. + * + * @internal + */ + 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}. + * + * @internal + */ + names(): string[] { + return [...this.#handlers.keys()]; + } + + /** + * Unregisters all custom query handlers. + * + * @internal + */ + 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..ae9ddab90d --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Debug.ts @@ -0,0 +1,136 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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. + */ + +import {isNode} from '../environment.js'; + +declare global { + // eslint-disable-next-line no-var + var __PUPPETEER_DEBUG: string; +} + +/** + * @internal + */ +let debugModule: typeof import('debug') | null = null; +/** + * @internal + */ +export async function importDebug(): Promise<typeof import('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..984418a10e --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Device.ts @@ -0,0 +1,1562 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * 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. + */ + +import {Viewport} from './PuppeteerViewport.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/DeviceRequestPrompt.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/DeviceRequestPrompt.ts new file mode 100644 index 0000000000..aa3b1264c1 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/DeviceRequestPrompt.ts @@ -0,0 +1,293 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +import Protocol from 'devtools-protocol'; + +import {WaitTimeoutOptions} from '../api/Page.js'; +import {assert} from '../util/assert.js'; +import { + createDeferredPromise, + DeferredPromise, +} from '../util/DeferredPromise.js'; + +import {CDPSession} from './Connection.js'; +import {TimeoutSettings} from './TimeoutSettings.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: DeferredPromise<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 promise = createDeferredPromise<DeviceRequestPromptDevice>({ + message: `Waiting for \`DeviceRequestPromptDevice\` failed: ${timeout}ms exceeded`, + timeout, + }); + const handle = {filter, promise}; + this.#waitForDevicePromises.add(handle); + try { + return await promise; + } 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 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 this.#client.send('DeviceAccess.cancelPrompt', {id: this.#id}); + } +} + +/** + * @internal + */ +export class DeviceRequestPromptManager { + #client: CDPSession | null; + #timeoutSettings: TimeoutSettings; + #deviceRequestPromptPromises = new Set< + DeferredPromise<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.#deviceRequestPromptPromises.size === 0; + let enablePromise: Promise<void> | undefined; + if (needsEnable) { + enablePromise = this.#client.send('DeviceAccess.enable'); + } + + const {timeout = this.#timeoutSettings.timeout()} = options; + const promise = createDeferredPromise<DeviceRequestPrompt>({ + message: `Waiting for \`DeviceRequestPrompt\` failed: ${timeout}ms exceeded`, + timeout, + }); + this.#deviceRequestPromptPromises.add(promise); + + try { + const [result] = await Promise.all([promise, enablePromise]); + return result; + } finally { + this.#deviceRequestPromptPromises.delete(promise); + } + } + + /** + * @internal + */ + #onDeviceRequestPrompted( + event: Protocol.DeviceAccess.DeviceRequestPromptedEvent + ) { + if (!this.#deviceRequestPromptPromises.size) { + return; + } + + assert(this.#client !== null); + const devicePrompt = new DeviceRequestPrompt( + this.#client, + this.#timeoutSettings, + event + ); + for (const promise of this.#deviceRequestPromptPromises) { + promise.resolve(devicePrompt); + } + this.#deviceRequestPromptPromises.clear(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Dialog.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Dialog.ts new file mode 100644 index 0000000000..5ccc5e1bac --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Dialog.ts @@ -0,0 +1,117 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * 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. + */ + +import {Protocol} from 'devtools-protocol'; + +import {assert} from '../util/assert.js'; + +import {CDPSession} from './Connection.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 class Dialog { + #client: CDPSession; + #type: Protocol.Page.DialogType; + #message: string; + #defaultValue: string; + #handled = false; + + /** + * @internal + */ + constructor( + client: CDPSession, + type: Protocol.Page.DialogType, + message: string, + defaultValue = '' + ) { + this.#client = client; + 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; + } + + /** + * 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.#client.send('Page.handleJavaScriptDialog', { + accept: true, + promptText: 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.#client.send('Page.handleJavaScriptDialog', { + accept: false, + }); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/ElementHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/ElementHandle.ts new file mode 100644 index 0000000000..351d7057bf --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/ElementHandle.ts @@ -0,0 +1,777 @@ +/** + * Copyright 2019 Google Inc. All rights reserved. + * + * 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. + */ + +import {Protocol} from 'devtools-protocol'; + +import { + BoundingBox, + BoxModel, + ClickOptions, + ElementHandle, + Offset, + Point, + PressOptions, +} from '../api/ElementHandle.js'; +import {Page, ScreenshotOptions} from '../api/Page.js'; +import {assert} from '../util/assert.js'; +import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js'; + +import {CDPSession} from './Connection.js'; +import {ExecutionContext} from './ExecutionContext.js'; +import {Frame} from './Frame.js'; +import {FrameManager} from './FrameManager.js'; +import {getQueryHandlerAndSelector} from './GetQueryHandler.js'; +import {WaitForSelectorOptions} from './IsolatedWorld.js'; +import {PUPPETEER_WORLD} from './IsolatedWorlds.js'; +import {CDPJSHandle} from './JSHandle.js'; +import {LazyArg} from './LazyArg.js'; +import {CDPPage} from './Page.js'; +import {ElementFor, EvaluateFuncWith, HandleFor, NodeFor} from './types.js'; +import {KeyInput} from './USKeyboardLayout.js'; +import {debugError, isString} from './util.js'; + +const applyOffsetsToQuad = ( + quad: Point[], + offsetX: number, + offsetY: number +) => { + return quad.map(part => { + return {x: part.x + offsetX, y: part.y + offsetY}; + }); +}; + +/** + * 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> { + #frame: Frame; + declare handle: CDPJSHandle<ElementType>; + + constructor( + context: ExecutionContext, + remoteObject: Protocol.Runtime.RemoteObject, + frame: Frame + ) { + super(new CDPJSHandle(context, remoteObject)); + this.#frame = frame; + } + + /** + * @internal + */ + override executionContext(): ExecutionContext { + return this.handle.executionContext(); + } + + /** + * @internal + */ + override get client(): CDPSession { + return this.handle.client; + } + + override remoteObject(): Protocol.Runtime.RemoteObject { + return this.handle.remoteObject(); + } + + get #frameManager(): FrameManager { + return this.#frame._frameManager; + } + + get #page(): Page { + return this.#frame.page(); + } + + override get frame(): Frame { + return this.#frame; + } + + override async $<Selector extends string>( + selector: Selector + ): Promise<CDPElementHandle<NodeFor<Selector>> | null> { + const {updatedSelector, QueryHandler} = + getQueryHandlerAndSelector(selector); + return (await QueryHandler.queryOne( + this, + updatedSelector + )) as CDPElementHandle<NodeFor<Selector>> | null; + } + + override async $$<Selector extends string>( + selector: Selector + ): Promise<Array<CDPElementHandle<NodeFor<Selector>>>> { + const {updatedSelector, QueryHandler} = + getQueryHandlerAndSelector(selector); + return AsyncIterableUtil.collect( + QueryHandler.queryAll(this, updatedSelector) + ) as Promise<Array<CDPElementHandle<NodeFor<Selector>>>>; + } + + override 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>>> { + const elementHandle = await this.$(selector); + if (!elementHandle) { + throw new Error( + `Error: failed to find element matching selector "${selector}"` + ); + } + const result = await elementHandle.evaluate(pageFunction, ...args); + await elementHandle.dispose(); + return result; + } + + override 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>>> { + const results = await this.$$(selector); + const elements = await this.evaluateHandle((_, ...elements) => { + return elements; + }, ...results); + const [result] = await Promise.all([ + elements.evaluate(pageFunction, ...args), + ...results.map(results => { + return results.dispose(); + }), + ]); + await elements.dispose(); + return result; + } + + override async $x( + expression: string + ): Promise<Array<CDPElementHandle<Node>>> { + if (expression.startsWith('//')) { + expression = `.${expression}`; + } + return this.$$(`xpath/${expression}`); + } + + override async waitForSelector<Selector extends string>( + selector: Selector, + options: WaitForSelectorOptions = {} + ): Promise<CDPElementHandle<NodeFor<Selector>> | null> { + const {updatedSelector, QueryHandler} = + getQueryHandlerAndSelector(selector); + return (await QueryHandler.waitFor( + this, + updatedSelector, + options + )) as CDPElementHandle<NodeFor<Selector>> | null; + } + + override async waitForXPath( + xpath: string, + options: { + visible?: boolean; + hidden?: boolean; + timeout?: number; + } = {} + ): Promise<CDPElementHandle<Node> | null> { + if (xpath.startsWith('//')) { + xpath = `.${xpath}`; + } + return this.waitForSelector(`xpath/${xpath}`, options); + } + + async #checkVisibility(visibility: boolean): Promise<boolean> { + const element = await this.frame.worlds[PUPPETEER_WORLD].adoptHandle(this); + try { + return await this.frame.worlds[PUPPETEER_WORLD].evaluate( + async (PuppeteerUtil, element, visibility) => { + return Boolean(PuppeteerUtil.checkVisibility(element, visibility)); + }, + LazyArg.create(context => { + return context.puppeteerUtil; + }), + element, + visibility + ); + } finally { + await element.dispose(); + } + } + + override async isVisible(): Promise<boolean> { + return this.#checkVisibility(true); + } + + override async isHidden(): Promise<boolean> { + return this.#checkVisibility(false); + } + + override 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>>; + } + + override async contentFrame(): Promise<Frame | null> { + const nodeInfo = await this.client.send('DOM.describeNode', { + objectId: this.remoteObject().objectId, + }); + if (typeof nodeInfo.node.frameId !== 'string') { + return null; + } + return this.#frameManager.frame(nodeInfo.node.frameId); + } + + override async scrollIntoView( + this: CDPElementHandle<Element> + ): Promise<void> { + await this.assertConnectedElement(); + + try { + await this.client.send('DOM.scrollIntoViewIfNeeded', { + objectId: this.remoteObject().objectId, + }); + } catch (error) { + debugError(error); + // Fallback to Element.scrollIntoView if DOM.scrollIntoViewIfNeeded is not supported + await this.evaluate(async (element): Promise<void> => { + element.scrollIntoView({ + block: 'center', + inline: 'center', + // @ts-expect-error Chrome still supports behavior: instant but + // it's not in the spec so TS shouts We don't want to make this + // breaking change in Puppeteer yet so we'll ignore the line. + behavior: 'instant', + }); + }); + } + } + + async #scrollIntoViewIfNeeded( + this: CDPElementHandle<Element> + ): Promise<void> { + if ( + await this.isIntersectingViewport({ + threshold: 1, + }) + ) { + return; + } + await this.scrollIntoView(); + } + + async #getOOPIFOffsets( + frame: Frame + ): Promise<{offsetX: number; offsetY: number}> { + let offsetX = 0; + let offsetY = 0; + let currentFrame: Frame | null = frame; + while (currentFrame && currentFrame.parentFrame()) { + const parent = currentFrame.parentFrame(); + if (!currentFrame.isOOPFrame() || !parent) { + currentFrame = parent; + continue; + } + const {backendNodeId} = await parent._client().send('DOM.getFrameOwner', { + frameId: currentFrame._id, + }); + const result = await parent._client().send('DOM.getBoxModel', { + backendNodeId: backendNodeId, + }); + if (!result) { + break; + } + const contentBoxQuad = result.model.content; + const topLeftCorner = this.#fromProtocolQuad(contentBoxQuad)[0]; + offsetX += topLeftCorner!.x; + offsetY += topLeftCorner!.y; + currentFrame = parent; + } + return {offsetX, offsetY}; + } + + override async clickablePoint(offset?: Offset): Promise<Point> { + const [result, layoutMetrics] = await Promise.all([ + this.client + .send('DOM.getContentQuads', { + objectId: this.remoteObject().objectId, + }) + .catch(debugError), + (this.#page as CDPPage)._client().send('Page.getLayoutMetrics'), + ]); + if (!result || !result.quads.length) { + throw new Error('Node is either not clickable or not an HTMLElement'); + } + // Filter out quads that have too small area to click into. + // Fallback to `layoutViewport` in case of using Firefox. + const {clientWidth, clientHeight} = + layoutMetrics.cssLayoutViewport || layoutMetrics.layoutViewport; + const {offsetX, offsetY} = await this.#getOOPIFOffsets(this.#frame); + const quads = result.quads + .map(quad => { + return this.#fromProtocolQuad(quad); + }) + .map(quad => { + return applyOffsetsToQuad(quad, offsetX, offsetY); + }) + .map(quad => { + return this.#intersectQuadWithViewport(quad, clientWidth, clientHeight); + }) + .filter(quad => { + return computeQuadArea(quad) > 1; + }); + if (!quads.length) { + throw new Error('Node is either not clickable or not an HTMLElement'); + } + const quad = quads[0]!; + if (offset) { + // Return the point of the first quad identified by offset. + let minX = Number.MAX_SAFE_INTEGER; + let minY = Number.MAX_SAFE_INTEGER; + for (const point of quad) { + if (point.x < minX) { + minX = point.x; + } + if (point.y < minY) { + minY = point.y; + } + } + if ( + minX !== Number.MAX_SAFE_INTEGER && + minY !== Number.MAX_SAFE_INTEGER + ) { + return { + x: minX + offset.x, + y: minY + offset.y, + }; + } + } + // Return the middle point of the first quad. + let x = 0; + let y = 0; + for (const point of quad) { + x += point.x; + y += point.y; + } + return { + x: x / 4, + y: y / 4, + }; + } + + #getBoxModel(): Promise<void | Protocol.DOM.GetBoxModelResponse> { + const params: Protocol.DOM.GetBoxModelRequest = { + objectId: this.id, + }; + return this.client.send('DOM.getBoxModel', params).catch(error => { + return debugError(error); + }); + } + + #fromProtocolQuad(quad: number[]): Point[] { + return [ + {x: quad[0]!, y: quad[1]!}, + {x: quad[2]!, y: quad[3]!}, + {x: quad[4]!, y: quad[5]!}, + {x: quad[6]!, y: quad[7]!}, + ]; + } + + #intersectQuadWithViewport( + quad: Point[], + width: number, + height: number + ): Point[] { + return quad.map(point => { + return { + x: Math.min(Math.max(point.x, 0), width), + y: Math.min(Math.max(point.y, 0), height), + }; + }); + } + + /** + * This method scrolls element into view if needed, and then + * uses {@link Page.mouse} to hover over the center of the element. + * If the element is detached from DOM, the method throws an error. + */ + override async hover(this: CDPElementHandle<Element>): Promise<void> { + await this.#scrollIntoViewIfNeeded(); + const {x, y} = await this.clickablePoint(); + await this.#page.mouse.move(x, y); + } + + /** + * This method scrolls element into view if needed, and then + * uses {@link Page.mouse} to click in the center of the element. + * If the element is detached from DOM, the method throws an error. + */ + override async click( + this: CDPElementHandle<Element>, + options: Readonly<ClickOptions> = {} + ): Promise<void> { + await this.#scrollIntoViewIfNeeded(); + const {x, y} = await this.clickablePoint(options.offset); + await this.#page.mouse.click(x, y, options); + } + + /** + * This method creates and captures a dragevent from the element. + */ + override async drag( + this: CDPElementHandle<Element>, + target: Point + ): Promise<Protocol.Input.DragData> { + assert( + this.#page.isDragInterceptionEnabled(), + 'Drag Interception is not enabled!' + ); + await this.#scrollIntoViewIfNeeded(); + const start = await this.clickablePoint(); + return await this.#page.mouse.drag(start, target); + } + + override async dragEnter( + this: CDPElementHandle<Element>, + data: Protocol.Input.DragData = {items: [], dragOperationsMask: 1} + ): Promise<void> { + await this.#scrollIntoViewIfNeeded(); + const target = await this.clickablePoint(); + await this.#page.mouse.dragEnter(target, data); + } + + override async dragOver( + this: CDPElementHandle<Element>, + data: Protocol.Input.DragData = {items: [], dragOperationsMask: 1} + ): Promise<void> { + await this.#scrollIntoViewIfNeeded(); + const target = await this.clickablePoint(); + await this.#page.mouse.dragOver(target, data); + } + + override async drop( + this: CDPElementHandle<Element>, + data: Protocol.Input.DragData = {items: [], dragOperationsMask: 1} + ): Promise<void> { + await this.#scrollIntoViewIfNeeded(); + const destination = await this.clickablePoint(); + await this.#page.mouse.drop(destination, data); + } + + override async dragAndDrop( + this: CDPElementHandle<Element>, + target: CDPElementHandle<Node>, + options?: {delay: number} + ): Promise<void> { + await this.#scrollIntoViewIfNeeded(); + const startPoint = await this.clickablePoint(); + const targetPoint = await target.clickablePoint(); + await this.#page.mouse.dragAndDrop(startPoint, targetPoint, options); + } + + override 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 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); + } + + 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 import('path'); + try { + path = await import('path'); + } catch (error) { + if (error instanceof TypeError) { + throw new Error( + `JSHandle#uploadFile can only be used in Node-like environments.` + ); + } + throw error; + } + const files = filePaths.map(filePath => { + if (path.win32.isAbsolute(filePath) || path.posix.isAbsolute(filePath)) { + return filePath; + } else { + return path.resolve(filePath); + } + }); + const {objectId} = this.remoteObject(); + const {node} = await this.client.send('DOM.describeNode', { + objectId, + }); + const {backendNodeId} = node; + + /* 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) { + 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})); + element.dispatchEvent(new Event('change', {bubbles: true})); + }); + } else { + await this.client.send('DOM.setFileInputFiles', { + objectId, + files, + backendNodeId, + }); + } + } + + override async tap(this: CDPElementHandle<Element>): Promise<void> { + await this.#scrollIntoViewIfNeeded(); + const {x, y} = await this.clickablePoint(); + await this.#page.touchscreen.touchStart(x, y); + await this.#page.touchscreen.touchEnd(); + } + + override async touchStart(this: CDPElementHandle<Element>): Promise<void> { + await this.#scrollIntoViewIfNeeded(); + const {x, y} = await this.clickablePoint(); + await this.#page.touchscreen.touchStart(x, y); + } + + override async touchMove(this: CDPElementHandle<Element>): Promise<void> { + await this.#scrollIntoViewIfNeeded(); + const {x, y} = await this.clickablePoint(); + await this.#page.touchscreen.touchMove(x, y); + } + + override async touchEnd(this: CDPElementHandle<Element>): Promise<void> { + await this.#scrollIntoViewIfNeeded(); + await this.#page.touchscreen.touchEnd(); + } + + override async focus(): Promise<void> { + await this.evaluate(element => { + if (!(element instanceof HTMLElement)) { + throw new Error('Cannot focus non-HTMLElement'); + } + return element.focus(); + }); + } + + override async type(text: string, options?: {delay: number}): Promise<void> { + await this.focus(); + await this.#page.keyboard.type(text, options); + } + + override async press(key: KeyInput, options?: PressOptions): Promise<void> { + await this.focus(); + await this.#page.keyboard.press(key, options); + } + + override async boundingBox(): Promise<BoundingBox | null> { + const result = await this.#getBoxModel(); + + if (!result) { + return null; + } + + const {offsetX, offsetY} = await this.#getOOPIFOffsets(this.#frame); + const quad = result.model.border; + const x = Math.min(quad[0]!, quad[2]!, quad[4]!, quad[6]!); + const y = Math.min(quad[1]!, quad[3]!, quad[5]!, quad[7]!); + const width = Math.max(quad[0]!, quad[2]!, quad[4]!, quad[6]!) - x; + const height = Math.max(quad[1]!, quad[3]!, quad[5]!, quad[7]!) - y; + + return {x: x + offsetX, y: y + offsetY, width, height}; + } + + override async boxModel(): Promise<BoxModel | null> { + const result = await this.#getBoxModel(); + + if (!result) { + return null; + } + + const {offsetX, offsetY} = await this.#getOOPIFOffsets(this.#frame); + + const {content, padding, border, margin, width, height} = result.model; + return { + content: applyOffsetsToQuad( + this.#fromProtocolQuad(content), + offsetX, + offsetY + ), + padding: applyOffsetsToQuad( + this.#fromProtocolQuad(padding), + offsetX, + offsetY + ), + border: applyOffsetsToQuad( + this.#fromProtocolQuad(border), + offsetX, + offsetY + ), + margin: applyOffsetsToQuad( + this.#fromProtocolQuad(margin), + offsetX, + offsetY + ), + width, + height, + }; + } + + override async screenshot( + this: CDPElementHandle<Element>, + options: ScreenshotOptions = {} + ): Promise<string | Buffer> { + let needsViewportReset = false; + + let boundingBox = await this.boundingBox(); + assert(boundingBox, 'Node is either not visible or not an HTMLElement'); + + const viewport = this.#page.viewport(); + + if ( + viewport && + (boundingBox.width > viewport.width || + boundingBox.height > viewport.height) + ) { + const newViewport = { + width: Math.max(viewport.width, Math.ceil(boundingBox.width)), + height: Math.max(viewport.height, Math.ceil(boundingBox.height)), + }; + await this.#page.setViewport(Object.assign({}, viewport, newViewport)); + + needsViewportReset = true; + } + + await this.#scrollIntoViewIfNeeded(); + + boundingBox = await this.boundingBox(); + assert(boundingBox, 'Node is either not visible or not an HTMLElement'); + assert(boundingBox.width !== 0, 'Node has 0 width.'); + assert(boundingBox.height !== 0, 'Node has 0 height.'); + + const layoutMetrics = await this.client.send('Page.getLayoutMetrics'); + // Fallback to `layoutViewport` in case of using Firefox. + const {pageX, pageY} = + layoutMetrics.cssVisualViewport || layoutMetrics.layoutViewport; + + const clip = Object.assign({}, boundingBox); + clip.x += pageX; + clip.y += pageY; + + const imageData = await this.#page.screenshot( + Object.assign( + {}, + { + clip, + }, + options + ) + ); + + if (needsViewportReset && viewport) { + await this.#page.setViewport(viewport); + } + + return imageData; + } +} + +function computeQuadArea(quad: Point[]): number { + /* Compute sum of all directed areas of adjacent triangles + https://en.wikipedia.org/wiki/Polygon#Simple_polygons + */ + let area = 0; + for (let i = 0; i < quad.length; ++i) { + const p1 = quad[i]!; + const p2 = quad[(i + 1) % quad.length]!; + area += (p1.x * p2.y - p2.x * p1.y) / 2; + } + return Math.abs(area); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/EmulationManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/EmulationManager.ts new file mode 100644 index 0000000000..27aa57581c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/EmulationManager.ts @@ -0,0 +1,63 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * 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. + */ +import {Protocol} from 'devtools-protocol'; + +import {CDPSession} from './Connection.js'; +import {Viewport} from './PuppeteerViewport.js'; + +/** + * @internal + */ +export class EmulationManager { + #client: CDPSession; + #emulatingMobile = false; + #hasTouch = false; + + constructor(client: CDPSession) { + this.#client = client; + } + + async emulateViewport(viewport: Viewport): Promise<boolean> { + 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([ + this.#client.send('Emulation.setDeviceMetricsOverride', { + mobile, + width, + height, + deviceScaleFactor, + screenOrientation, + }), + this.#client.send('Emulation.setTouchEmulationEnabled', { + enabled: hasTouch, + }), + ]); + + const reloadNeeded = + this.#emulatingMobile !== mobile || this.#hasTouch !== hasTouch; + this.#emulatingMobile = mobile; + this.#hasTouch = hasTouch; + return reloadNeeded; + } +} 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..4d067c89ae --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Errors.ts @@ -0,0 +1,115 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * 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. + */ + +/** + * @deprecated Do not use. + * + * @public + */ +export class CustomError extends Error { + /** + * @internal + */ + constructor(message?: string) { + super(message); + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } +} + +/** + * 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; + } +} + +/** + * @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.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.ts new file mode 100644 index 0000000000..41001a1592 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.ts @@ -0,0 +1,165 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +import mitt, {Emitter, EventHandlerMap} from '../../third_party/mitt/index.js'; + +/** + * @public + */ +export type EventType = string | symbol; +/** + * @public + */ +export type Handler<T = unknown> = (event: T) => void; + +/** + * @public + */ +export interface CommonEventEmitter { + on(event: EventType, handler: Handler): CommonEventEmitter; + off(event: EventType, handler: Handler): CommonEventEmitter; + /* 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(event: EventType, handler: Handler): CommonEventEmitter; + removeListener(event: EventType, handler: Handler): CommonEventEmitter; + emit(event: EventType, eventData?: unknown): boolean; + once(event: EventType, handler: Handler): CommonEventEmitter; + listenerCount(event: string): number; + + removeAllListeners(event?: EventType): CommonEventEmitter; +} + +/** + * 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 implements CommonEventEmitter { + private emitter: Emitter<Record<string | symbol, any>>; + private eventsMap: EventHandlerMap<Record<string | symbol, any>> = new Map(); + + /** + * @internal + */ + constructor() { + this.emitter = mitt(this.eventsMap); + } + + /** + * Bind an event listener to fire when an event occurs. + * @param event - 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(event: EventType, handler: Handler<any>): EventEmitter { + this.emitter.on(event, handler); + return this; + } + + /** + * Remove an event listener from firing. + * @param event - 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(event: EventType, handler: Handler<any>): EventEmitter { + this.emitter.off(event, handler); + return this; + } + + /** + * Remove an event listener. + * @deprecated please use {@link EventEmitter.off} instead. + */ + removeListener(event: EventType, handler: Handler<any>): EventEmitter { + this.off(event, handler); + return this; + } + + /** + * Add an event listener. + * @deprecated please use {@link EventEmitter.on} instead. + */ + addListener(event: EventType, handler: Handler<any>): EventEmitter { + this.on(event, handler); + return this; + } + + /** + * Emit an event and call any associated listeners. + * + * @param event - 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(event: EventType, eventData?: unknown): boolean { + this.emitter.emit(event, eventData); + return this.eventListenersCount(event) > 0; + } + + /** + * Like `on` but the listener will only be fired once and then it will be removed. + * @param event - 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(event: EventType, handler: Handler<any>): EventEmitter { + const onceHandler: Handler<any> = eventData => { + handler(eventData); + this.off(event, onceHandler); + }; + + return this.on(event, onceHandler); + } + + /** + * Gets the number of listeners for a given event. + * + * @param event - the event to get the listener count for + * @returns the number of listeners bound to the given event + */ + listenerCount(event: EventType): number { + return this.eventListenersCount(event); + } + + /** + * Removes all listeners. If given an event argument, it will remove only + * listeners for that event. + * @param event - the event to remove listeners for. + * @returns `this` to enable you to chain method calls. + */ + removeAllListeners(event?: EventType): EventEmitter { + if (event) { + this.eventsMap.delete(event); + } else { + this.eventsMap.clear(); + } + return this; + } + + private eventListenersCount(event: EventType): number { + return this.eventsMap.get(event)?.length || 0; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/ExecutionContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/ExecutionContext.ts new file mode 100644 index 0000000000..e00c45b2dd --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/ExecutionContext.ts @@ -0,0 +1,402 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * 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. + */ + +import {Protocol} from 'devtools-protocol'; + +import type {ElementHandle} from '../api/ElementHandle.js'; +import {JSHandle} from '../api/JSHandle.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 {CDPSession} from './Connection.js'; +import {CDPElementHandle} from './ElementHandle.js'; +import {IsolatedWorld} from './IsolatedWorld.js'; +import {CDPJSHandle} from './JSHandle.js'; +import {LazyArg} from './LazyArg.js'; +import {scriptInjector} from './ScriptInjector.js'; +import {EvaluateFunc, HandleFor} from './types.js'; +import { + createJSHandle, + getExceptionMessage, + isString, + valueFromRemoteObject, +} from './util.js'; + +/** + * @public + */ +export const EVALUATION_SCRIPT_URL = 'pptr://__puppeteer_evaluation_script__'; +const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m; + +/** + * Represents a context for JavaScript execution. + * + * @example + * A {@link Page} can have several execution contexts: + * + * - Each {@link Frame} of a {@link Page | page} has a "default" execution + * context that is always created after frame is attached to DOM. This context + * is returned by the {@link Frame.executionContext} method. + * - Each {@link https://developer.chrome.com/extensions | Chrome extensions} + * creates additional execution contexts to isolate their code. + * + * @remarks + * By definition, each context is isolated from one another, however they are + * all able to manipulate non-JavaScript resources (such as DOM). + * + * @remarks + * Besides pages, execution contexts can be found in + * {@link WebWorker | workers}. + * + * @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 element.executionContext().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 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 suffix = `//# sourceURL=${EVALUATION_SCRIPT_URL}`; + + if (isString(pageFunction)) { + const contextId = this._contextId; + const expression = pageFunction; + const expressionWithSourceUrl = SOURCE_URL_REGEX.test(expression) + ? expression + : expression + '\n' + suffix; + + const {exceptionDetails, result: remoteObject} = await this._client + .send('Runtime.evaluate', { + expression: expressionWithSourceUrl, + contextId, + returnByValue, + awaitPromise: true, + userGesture: true, + }) + .catch(rewriteError); + + if (exceptionDetails) { + throw new Error( + 'Evaluation failed: ' + getExceptionMessage(exceptionDetails) + ); + } + + return returnByValue + ? valueFromRemoteObject(remoteObject) + : createJSHandle(this, remoteObject); + } + + let callFunctionOnPromise; + try { + callFunctionOnPromise = this._client.send('Runtime.callFunctionOn', { + functionDeclaration: `${stringifyFunction(pageFunction)}\n${suffix}\n`, + executionContextId: this._contextId, + arguments: 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 new Error( + 'Evaluation failed: ' + getExceptionMessage(exceptionDetails) + ); + } + return returnByValue + ? valueFromRemoteObject(remoteObject) + : createJSHandle(this, 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.executionContext() !== this) { + 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; +}; 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..96d6e6eb28 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/FileChooser.ts @@ -0,0 +1,97 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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. + */ + +import {Protocol} from 'devtools-protocol'; + +import {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. + */ + cancel(): void { + assert( + !this.#handled, + 'Cannot cancel FileChooser which is already handled!' + ); + this.#handled = true; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/FirefoxTargetManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/FirefoxTargetManager.ts new file mode 100644 index 0000000000..745c37d950 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/FirefoxTargetManager.ts @@ -0,0 +1,259 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +import {Protocol} from 'devtools-protocol'; + +import {TargetFilterCallback} from '../api/Browser.js'; +import {assert} from '../util/assert.js'; +import {createDeferredPromise} from '../util/DeferredPromise.js'; + +import {CDPSession, Connection} from './Connection.js'; +import {EventEmitter} from './EventEmitter.js'; +import {Target} from './Target.js'; +import { + TargetFactory, + TargetInterceptor, + TargetManagerEmittedEvents, + TargetManager, +} 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 + 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: Map<string, Protocol.Target.TargetInfo> = + new Map(); + /** + * 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: Map<string, Target> = new Map(); + /** + * Tracks which sessions attach to which target. + */ + #availableTargetsBySessionId: Map<string, Target> = new Map(); + /** + * 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; + + #targetInterceptors: WeakMap<CDPSession | Connection, TargetInterceptor[]> = + new WeakMap(); + + #attachedToTargetListenersBySession: WeakMap< + CDPSession | Connection, + (event: Protocol.Target.AttachedToTargetEvent) => Promise<void> + > = new WeakMap(); + + #initializePromise = createDeferredPromise<void>(); + #targetsIdsForInit: Set<string> = new Set(); + + 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('sessiondetached', this.#onSessionDetached); + this.setupAttachmentListeners(this.#connection); + } + + addTargetInterceptor( + client: CDPSession | Connection, + interceptor: TargetInterceptor + ): void { + const interceptors = this.#targetInterceptors.get(client) || []; + interceptors.push(interceptor); + this.#targetInterceptors.set(client, interceptors); + } + + removeTargetInterceptor( + client: CDPSession | Connection, + interceptor: TargetInterceptor + ): void { + const interceptors = this.#targetInterceptors.get(client) || []; + this.#targetInterceptors.set( + client, + interceptors.filter(currentInterceptor => { + return currentInterceptor !== interceptor; + }) + ); + } + + 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.#targetInterceptors.delete(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(): Map<string, Target> { + 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.#initializePromise; + } + + #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); + this.#availableTargetsByTargetId.set(event.targetInfo.targetId, target); + this.#finishInitializationIfReady(target._targetId); + return; + } + + if ( + this.#targetFilterCallback && + !this.#targetFilterCallback(event.targetInfo) + ) { + this.#ignoredTargets.add(event.targetInfo.targetId); + this.#finishInitializationIfReady(event.targetInfo.targetId); + return; + } + + const target = this.#targetFactory(event.targetInfo, undefined); + this.#availableTargetsByTargetId.set(event.targetInfo.targetId, target); + this.emit(TargetManagerEmittedEvents.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(TargetManagerEmittedEvents.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`); + + this.setupAttachmentListeners(session); + + this.#availableTargetsBySessionId.set( + session.id(), + this.#availableTargetsByTargetId.get(targetInfo.targetId)! + ); + + for (const hook of this.#targetInterceptors.get(parentSession) || []) { + if (!(parentSession instanceof Connection)) { + assert(this.#availableTargetsBySessionId.has(parentSession.id())); + } + await hook( + target, + parentSession instanceof Connection + ? null + : this.#availableTargetsBySessionId.get(parentSession.id())! + ); + } + }; + + #finishInitializationIfReady(targetId: string): void { + this.#targetsIdsForInit.delete(targetId); + if (this.#targetsIdsForInit.size === 0) { + this.#initializePromise.resolve(); + } + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Frame.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Frame.ts new file mode 100644 index 0000000000..b605e60637 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Frame.ts @@ -0,0 +1,1160 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * 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. + */ + +import {Protocol} from 'devtools-protocol'; + +import {type ClickOptions, ElementHandle} from '../api/ElementHandle.js'; +import {HTTPResponse} from '../api/HTTPResponse.js'; +import {Page, WaitTimeoutOptions} from '../api/Page.js'; +import {assert} from '../util/assert.js'; +import {isErrorLike} from '../util/ErrorLike.js'; + +import {CDPSession} from './Connection.js'; +import { + DeviceRequestPrompt, + DeviceRequestPromptManager, +} from './DeviceRequestPrompt.js'; +import {ExecutionContext} from './ExecutionContext.js'; +import {FrameManager} from './FrameManager.js'; +import {getQueryHandlerAndSelector} from './GetQueryHandler.js'; +import { + IsolatedWorld, + IsolatedWorldChart, + WaitForSelectorOptions, +} from './IsolatedWorld.js'; +import {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js'; +import {LazyArg} from './LazyArg.js'; +import {LifecycleWatcher, PuppeteerLifeCycleEvent} from './LifecycleWatcher.js'; +import {EvaluateFunc, EvaluateFuncWith, HandleFor, NodeFor} from './types.js'; +import {importFSPromises} from './util.js'; + +/** + * @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; +} + +/** + * 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 PageEmittedEvents.FrameAttached} + * - {@link PageEmittedEvents.FrameNavigated} + * - {@link PageEmittedEvents.FrameDetached} + * + * @public + */ +export class Frame { + #url = ''; + #detached = false; + #client!: CDPSession; + + /** + * @internal + */ + worlds!: IsolatedWorldChart; + /** + * @internal + */ + _frameManager: FrameManager; + /** + * @internal + */ + _id: string; + /** + * @internal + */ + _loaderId = ''; + /** + * @internal + */ + _name?: string; + /** + * @internal + */ + _hasStartedLoading = false; + /** + * @internal + */ + _lifecycleEvents = new Set<string>(); + /** + * @internal + */ + _parentId?: string; + + /** + * @internal + */ + constructor( + frameManager: FrameManager, + frameId: string, + parentFrameId: string | undefined, + client: CDPSession + ) { + this._frameManager = frameManager; + this.#url = ''; + this._id = frameId; + this._parentId = parentFrameId; + this.#detached = false; + + this._loaderId = ''; + + this.updateClient(client); + } + + /** + * @internal + */ + updateClient(client: CDPSession): void { + this.#client = client; + this.worlds = { + [MAIN_WORLD]: new IsolatedWorld(this), + [PUPPETEER_WORLD]: new IsolatedWorld(this), + }; + } + + /** + * The page associated with the frame. + */ + page(): Page { + return this._frameManager.page(); + } + + /** + * Is `true` if the frame is an out-of-process (OOP) frame. Otherwise, + * `false`. + */ + isOOPFrame(): boolean { + return this.#client !== this._frameManager.client; + } + + /** + * Navigates a 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 - the URL to navigate the frame to. This should include the + * scheme, e.g. `https://`. + * @param options - navigation options. `waitUntil` is useful to define when + * the navigation should be considered successful - see the docs for + * {@link PuppeteerLifeCycleEvent} for more details. + * + * @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 This method will throw an error 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: { + 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, + this, + waitUntil, + timeout + ); + let error = await Promise.race([ + navigate( + this.#client, + url, + referer, + referrerPolicy as Protocol.Page.ReferrerPolicy, + this._id + ), + watcher.timeoutOrTerminationPromise(), + ]); + if (!error) { + error = await Promise.race([ + watcher.timeoutOrTerminationPromise(), + 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; + } + } + } + + /** + * 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 when the navigation is consided + * finished. + * @returns a promise that resolves when the frame navigates to a new URL. + */ + 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, + this, + waitUntil, + timeout + ); + const error = await Promise.race([ + watcher.timeoutOrTerminationPromise(), + watcher.sameDocumentNavigationPromise(), + watcher.newDocumentNavigationPromise(), + ]); + try { + if (error) { + throw error; + } + return await watcher.navigationResponse(); + } finally { + watcher.dispose(); + } + } + + /** + * @internal + */ + _client(): CDPSession { + return this.#client; + } + + /** + * @internal + */ + executionContext(): Promise<ExecutionContext> { + return this.worlds[MAIN_WORLD].executionContext(); + } + + /** + * Behaves identically to {@link Page.evaluateHandle} except it's run within + * the context of this frame. + * + * @see {@link Page.evaluateHandle} for details. + */ + async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params> + >( + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + return this.worlds[MAIN_WORLD].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. + */ + async evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params> + >( + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + return this.worlds[MAIN_WORLD].evaluate(pageFunction, ...args); + } + + /** + * 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`. + */ + async $<Selector extends string>( + selector: Selector + ): Promise<ElementHandle<NodeFor<Selector>> | null> { + return this.worlds[MAIN_WORLD].$(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. + */ + async $$<Selector extends string>( + selector: Selector + ): Promise<Array<ElementHandle<NodeFor<Selector>>>> { + return this.worlds[MAIN_WORLD].$$(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. + */ + 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>>> { + return this.worlds[MAIN_WORLD].$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 + * + * ```js + * 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. + */ + 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>>> { + return this.worlds[MAIN_WORLD].$$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. + */ + async $x(expression: string): Promise<Array<ElementHandle<Node>>> { + return this.worlds[MAIN_WORLD].$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. + */ + 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. + */ + async waitForXPath( + xpath: string, + options: WaitForSelectorOptions = {} + ): Promise<ElementHandle<Node> | null> { + if (xpath.startsWith('//')) { + xpath = `.${xpath}`; + } + return 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. + */ + waitForFunction< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params> + >( + pageFunction: Func | string, + options: FrameWaitForFunctionOptions = {}, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + return this.worlds[MAIN_WORLD].waitForFunction( + pageFunction, + options, + ...args + ) as Promise<HandleFor<Awaited<ReturnType<Func>>>>; + } + + /** + * The full HTML contents of the frame, including the DOCTYPE. + */ + async content(): Promise<string> { + return this.worlds[PUPPETEER_WORLD].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. + */ + async setContent( + html: string, + options: { + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; + } = {} + ): Promise<void> { + return this.worlds[PUPPETEER_WORLD].setContent(html, options); + } + + /** + * 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. + */ + url(): string { + return this.#url; + } + + /** + * The parent frame, if any. Detached and main frames return `null`. + */ + parentFrame(): Frame | null { + return this._frameManager._frameTree.parentFrame(this._id) || null; + } + + /** + * An array of child frames. + */ + childFrames(): Frame[] { + return this._frameManager._frameTree.childFrames(this._id); + } + + /** + * Is`true` if the frame has been detached. Otherwise, `false`. + */ + isDetached(): 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. + */ + 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 this.worlds[MAIN_WORLD].transferHandle( + await this.worlds[PUPPETEER_WORLD].evaluateHandle( + async ({createDeferredPromise}, {url, id, type, content}) => { + const promise = createDeferredPromise<void>(); + const script = document.createElement('script'); + script.type = type; + script.text = content; + if (url) { + script.src = url; + script.addEventListener( + 'load', + () => { + return promise.resolve(); + }, + {once: true} + ); + script.addEventListener( + 'error', + event => { + promise.reject( + new Error(event.message ?? 'Could not load script') + ); + }, + {once: true} + ); + } else { + promise.resolve(); + } + if (id) { + script.id = id; + } + document.head.appendChild(script); + await promise; + return script; + }, + LazyArg.create(context => { + return context.puppeteerUtil; + }), + {...options, type, content} + ) + ); + } + + /** + * Adds a `<link rel="stylesheet">` tag into the page with the desired URL or + * a `<style type="text/css">` tag with the content. + * + * @returns An {@link ElementHandle | element handle} to the loaded `<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>> { + 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 this.worlds[MAIN_WORLD].transferHandle( + await this.worlds[PUPPETEER_WORLD].evaluateHandle( + async ({createDeferredPromise}, {url, content}) => { + const promise = createDeferredPromise<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', + () => { + promise.resolve(); + }, + {once: true} + ); + element.addEventListener( + 'error', + event => { + promise.reject( + new Error( + (event as ErrorEvent).message ?? 'Could not load style' + ) + ); + }, + {once: true} + ); + document.head.appendChild(element); + await promise; + 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. + */ + async click( + selector: string, + options: Readonly<ClickOptions> = {} + ): Promise<void> { + return this.worlds[PUPPETEER_WORLD].click(selector, options); + } + + /** + * Focuses the first element that matches the `selector`. + * + * @param selector - The selector to query for. + * @throws Throws if there's no element matching `selector`. + */ + async focus(selector: string): Promise<void> { + return this.worlds[PUPPETEER_WORLD].focus(selector); + } + + /** + * 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`. + */ + async hover(selector: string): Promise<void> { + return this.worlds[PUPPETEER_WORLD].hover(selector); + } + + /** + * 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`. + */ + select(selector: string, ...values: string[]): Promise<string[]> { + return this.worlds[PUPPETEER_WORLD].select(selector, ...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`. + */ + async tap(selector: string): Promise<void> { + return this.worlds[PUPPETEER_WORLD].tap(selector); + } + + /** + * 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`. + */ + async type( + selector: string, + text: string, + options?: {delay: number} + ): Promise<void> { + return this.worlds[PUPPETEER_WORLD].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 frame.waitForTimeout(1000); + * ``` + * + * @param milliseconds - the number of milliseconds to wait. + */ + waitForTimeout(milliseconds: number): Promise<void> { + return new Promise(resolve => { + setTimeout(resolve, milliseconds); + }); + } + + /** + * The frame's title. + */ + async title(): Promise<string> { + return this.worlds[PUPPETEER_WORLD].title(); + } + + /** + * @internal + */ + _deviceRequestPromptManager(): DeviceRequestPromptManager { + if (this.isOOPFrame()) { + return this._frameManager._deviceRequestPromptManager(this.#client); + } + const parentFrame = this.parentFrame(); + assert(parentFrame !== null); + return parentFrame._deviceRequestPromptManager(); + } + + /** + * 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')) + * ); + * ``` + */ + waitForDevicePrompt( + options: WaitTimeoutOptions = {} + ): Promise<DeviceRequestPrompt> { + return this._deviceRequestPromptManager().waitForDevicePrompt(options); + } + + /** + * @internal + */ + _navigated(framePayload: Protocol.Page.Frame): void { + this._name = framePayload.name; + this.#url = `${framePayload.url}${framePayload.urlFragment || ''}`; + } + + /** + * @internal + */ + _navigatedWithinDocument(url: string): void { + this.#url = url; + } + + /** + * @internal + */ + _onLifecycleEvent(loaderId: string, name: string): void { + if (name === 'init') { + this._loaderId = loaderId; + this._lifecycleEvents.clear(); + } + this._lifecycleEvents.add(name); + } + + /** + * @internal + */ + _onLoadingStopped(): void { + this._lifecycleEvents.add('DOMContentLoaded'); + this._lifecycleEvents.add('load'); + } + + /** + * @internal + */ + _onLoadingStarted(): void { + this._hasStartedLoading = true; + } + + /** + * @internal + */ + _detach(): void { + this.#detached = true; + this.worlds[MAIN_WORLD]._detach(); + this.worlds[PUPPETEER_WORLD]._detach(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/FrameManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/FrameManager.ts new file mode 100644 index 0000000000..148fe34095 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/FrameManager.ts @@ -0,0 +1,478 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * 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. + */ + +import {Protocol} from 'devtools-protocol'; + +import {Page} from '../api/Page.js'; +import {assert} from '../util/assert.js'; +import {isErrorLike} from '../util/ErrorLike.js'; + +import {CDPSession, isTargetClosedError} from './Connection.js'; +import {DeviceRequestPromptManager} from './DeviceRequestPrompt.js'; +import {EventEmitter} from './EventEmitter.js'; +import {EVALUATION_SCRIPT_URL, ExecutionContext} from './ExecutionContext.js'; +import {Frame} from './Frame.js'; +import {FrameTree} from './FrameTree.js'; +import {IsolatedWorld} from './IsolatedWorld.js'; +import {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js'; +import {NetworkManager} from './NetworkManager.js'; +import {Target} from './Target.js'; +import {TimeoutSettings} from './TimeoutSettings.js'; +import {debugError} from './util.js'; + +const UTILITY_WORLD_NAME = '__puppeteer_utility_world__'; + +/** + * We use symbols to prevent external parties listening to these events. + * They are internal to Puppeteer. + * + * @internal + */ +export const FrameManagerEmittedEvents = { + FrameAttached: Symbol('FrameManager.FrameAttached'), + FrameNavigated: Symbol('FrameManager.FrameNavigated'), + FrameDetached: Symbol('FrameManager.FrameDetached'), + FrameSwapped: Symbol('FrameManager.FrameSwapped'), + LifecycleEvent: Symbol('FrameManager.LifecycleEvent'), + FrameNavigatedWithinDocument: Symbol( + 'FrameManager.FrameNavigatedWithinDocument' + ), + ExecutionContextCreated: Symbol('FrameManager.ExecutionContextCreated'), + ExecutionContextDestroyed: Symbol('FrameManager.ExecutionContextDestroyed'), +}; + +/** + * A frame manager manages the frames for a given {@link Page | page}. + * + * @internal + */ +export class FrameManager extends EventEmitter { + #page: Page; + #networkManager: NetworkManager; + #timeoutSettings: TimeoutSettings; + #contextIdToContext = new Map<string, ExecutionContext>(); + #isolatedWorlds = new Set<string>(); + #client: CDPSession; + /** + * @internal + */ + _frameTree = new FrameTree(); + + /** + * 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 + >(); + + get timeoutSettings(): TimeoutSettings { + return this.#timeoutSettings; + } + + get networkManager(): NetworkManager { + return this.#networkManager; + } + + get client(): CDPSession { + return this.#client; + } + + constructor( + client: CDPSession, + page: Page, + ignoreHTTPSErrors: boolean, + timeoutSettings: TimeoutSettings + ) { + super(); + this.#client = client; + this.#page = page; + this.#networkManager = new NetworkManager(client, ignoreHTTPSErrors, this); + this.#timeoutSettings = timeoutSettings; + this.setupEventListeners(this.#client); + } + + private setupEventListeners(session: CDPSession) { + session.on('Page.frameAttached', event => { + this.#onFrameAttached(session, event.frameId, event.parentFrameId); + }); + session.on('Page.frameNavigated', event => { + this.#frameNavigatedReceived.add(event.frame.id); + void this.#onFrameNavigated(event.frame); + }); + session.on('Page.navigatedWithinDocument', event => { + this.#onFrameNavigatedWithinDocument(event.frameId, event.url); + }); + session.on( + 'Page.frameDetached', + (event: Protocol.Page.FrameDetachedEvent) => { + this.#onFrameDetached( + event.frameId, + event.reason as Protocol.Page.FrameDetachedEventReason + ); + } + ); + session.on('Page.frameStartedLoading', event => { + this.#onFrameStartedLoading(event.frameId); + }); + session.on('Page.frameStoppedLoading', event => { + this.#onFrameStoppedLoading(event.frameId); + }); + session.on('Runtime.executionContextCreated', event => { + this.#onExecutionContextCreated(event.context, session); + }); + session.on('Runtime.executionContextDestroyed', event => { + this.#onExecutionContextDestroyed(event.executionContextId, session); + }); + session.on('Runtime.executionContextsCleared', () => { + this.#onExecutionContextsCleared(session); + }); + session.on('Page.lifecycleEvent', event => { + this.#onLifecycleEvent(event); + }); + } + + async initialize(client: CDPSession = this.#client): Promise<void> { + try { + const result = await Promise.all([ + client.send('Page.enable'), + client.send('Page.getFrameTree'), + ]); + + const {frameTree} = result[1]; + this.#handleFrameTree(client, frameTree); + await Promise.all([ + client.send('Page.setLifecycleEventsEnabled', {enabled: true}), + client.send('Runtime.enable').then(() => { + return this.#createIsolatedWorld(client, UTILITY_WORLD_NAME); + }), + // TODO: Network manager is not aware of OOP iframes yet. + client === this.#client + ? this.#networkManager.initialize() + : Promise.resolve(), + ]); + } catch (error) { + // 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(): Page { + return this.#page; + } + + mainFrame(): Frame { + const mainFrame = this._frameTree.getMainFrame(); + assert(mainFrame, 'Requesting main frame too early!'); + return mainFrame; + } + + frames(): Frame[] { + return Array.from(this._frameTree.frames()); + } + + frame(frameId: string): Frame | null { + return this._frameTree.getById(frameId) || null; + } + + onAttachedToTarget(target: Target): 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()); + } + + /** + * @internal + */ + _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(FrameManagerEmittedEvents.LifecycleEvent, frame); + } + + #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(FrameManagerEmittedEvents.LifecycleEvent, frame); + } + + #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); + } 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 Frame(this, frameId, parentFrameId, session); + this._frameTree.addFrame(frame); + this.emit(FrameManagerEmittedEvents.FrameAttached, frame); + } + + async #onFrameNavigated(framePayload: Protocol.Page.Frame): 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 Frame(this, frameId, undefined, this.#client); + } + this._frameTree.addFrame(frame); + } + + frame = await this._frameTree.waitForFrame(frameId); + frame._navigated(framePayload); + this.emit(FrameManagerEmittedEvents.FrameNavigated, frame); + } + + 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=${EVALUATION_SCRIPT_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(FrameManagerEmittedEvents.FrameNavigatedWithinDocument, frame); + this.emit(FrameManagerEmittedEvents.FrameNavigated, frame); + } + + #onFrameDetached( + frameId: string, + reason: Protocol.Page.FrameDetachedEventReason + ): void { + const frame = this.frame(frameId); + if (reason === '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'. + if (frame) { + this.#removeFramesRecursively(frame); + } + } else if (reason === 'swap') { + this.emit(FrameManagerEmittedEvents.FrameSwapped, frame); + } + } + + #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]; + } + } + 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: Frame): void { + for (const child of frame.childFrames()) { + this.#removeFramesRecursively(child); + } + frame._detach(); + this._frameTree.removeFrame(frame); + this.emit(FrameManagerEmittedEvents.FrameDetached, frame); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/FrameTree.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/FrameTree.ts new file mode 100644 index 0000000000..94ee3a45e5 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/FrameTree.ts @@ -0,0 +1,112 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +import { + createDeferredPromise, + DeferredPromise, +} from '../util/DeferredPromise.js'; + +import type {Frame} from './Frame.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 { + #frames = new Map<string, Frame>(); + // frameID -> parentFrameID + #parentIds = new Map<string, string>(); + // frameID -> childFrameIDs + #childIds = new Map<string, Set<string>>(); + #mainFrame?: Frame; + #waitRequests = new Map<string, Set<DeferredPromise<Frame>>>(); + + getMainFrame(): Frame | undefined { + return this.#mainFrame; + } + + getById(frameId: string): Frame | 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<Frame> { + const frame = this.getById(frameId); + if (frame) { + return Promise.resolve(frame); + } + const deferred = createDeferredPromise<Frame>(); + const callbacks = + this.#waitRequests.get(frameId) || new Set<DeferredPromise<Frame>>(); + callbacks.add(deferred); + return deferred; + } + + frames(): Frame[] { + return Array.from(this.#frames.values()); + } + + addFrame(frame: Frame): 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 { + this.#mainFrame = frame; + } + this.#waitRequests.get(frame._id)?.forEach(request => { + return request.resolve(frame); + }); + } + + removeFrame(frame: Frame): 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): Frame[] { + const childIds = this.#childIds.get(frameId); + if (!childIds) { + return []; + } + return Array.from(childIds) + .map(id => { + return this.getById(id); + }) + .filter((frame): frame is Frame => { + return frame !== undefined; + }); + } + + parentFrame(frameId: string): Frame | undefined { + const parentId = this.#parentIds.get(frameId); + return parentId ? this.getById(parentId) : undefined; + } +} 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..405f09e2c5 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/GetQueryHandler.ts @@ -0,0 +1,70 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +import {ARIAQueryHandler} from './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'; + +export const BUILTIN_QUERY_HANDLERS = Object.freeze({ + aria: ARIAQueryHandler, + pierce: PierceQueryHandler, + xpath: XPathQueryHandler, + text: TextQueryHandler, +}); + +const QUERY_SEPARATORS = ['=', '/']; + +/** + * @internal + */ +export function getQueryHandlerByName( + name: string +): typeof QueryHandler | undefined { + if (name in BUILTIN_QUERY_HANDLERS) { + return BUILTIN_QUERY_HANDLERS[name as 'aria']; + } + return customQueryHandlers.get(name); +} + +/** + * @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/HTTPRequest.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/HTTPRequest.ts new file mode 100644 index 0000000000..3016df7054 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/HTTPRequest.ts @@ -0,0 +1,445 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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. + */ +import {Protocol} from 'devtools-protocol'; + +import { + ContinueRequestOverrides, + ErrorCode, + headersArray, + HTTPRequest as BaseHTTPRequest, + InterceptResolutionAction, + InterceptResolutionState, + ResourceType, + ResponseForRequest, + STATUS_TEXTS, +} from '../api/HTTPRequest.js'; +import {HTTPResponse} from '../api/HTTPResponse.js'; +import {assert} from '../util/assert.js'; + +import {CDPSession} from './Connection.js'; +import {ProtocolError} from './Errors.js'; +import {Frame} from './Frame.js'; +import {debugError, isString} from './util.js'; + +/** + * @internal + */ +export class HTTPRequest extends BaseHTTPRequest { + override _requestId: string; + override _interceptionId: string | undefined; + override _failureText: string | null = null; + override _response: HTTPResponse | null = null; + override _fromMemoryCache = false; + override _redirectChain: HTTPRequest[]; + + #client: CDPSession; + #isNavigationRequest: boolean; + #allowInterception: boolean; + #interceptionHandled = false; + #url: string; + #resourceType: ResourceType; + + #method: string; + #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: HTTPRequest[] + ) { + 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.#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; + } + + override 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 this.#abort(this.#abortErrorReason); + case 'respond': + if (this.#responseForRequest === null) { + throw new Error('Response is missing for the interception'); + } + return this.#respond(this.#responseForRequest); + case 'continue': + return this.#continue(this.#continueRequestOverrides); + } + } + + override resourceType(): ResourceType { + return this.#resourceType; + } + + override method(): string { + return this.#method; + } + + override postData(): string | undefined { + return this.#postData; + } + + override headers(): Record<string, string> { + return this.#headers; + } + + override response(): HTTPResponse | 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(): HTTPRequest[] { + 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 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 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 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/common/HTTPResponse.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/HTTPResponse.ts new file mode 100644 index 0000000000..a43aa17195 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/HTTPResponse.ts @@ -0,0 +1,188 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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. + */ +import {Protocol} from 'devtools-protocol'; + +import { + HTTPResponse as BaseHTTPResponse, + RemoteAddress, +} from '../api/HTTPResponse.js'; +import {createDeferredPromise} from '../util/DeferredPromise.js'; + +import {CDPSession} from './Connection.js'; +import {ProtocolError} from './Errors.js'; +import {Frame} from './Frame.js'; +import {HTTPRequest} from './HTTPRequest.js'; +import {SecurityDetails} from './SecurityDetails.js'; + +/** + * @internal + */ +export class HTTPResponse extends BaseHTTPResponse { + #client: CDPSession; + #request: HTTPRequest; + #contentPromise: Promise<Buffer> | null = null; + #bodyLoadedPromise = createDeferredPromise<Error | void>(); + #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: HTTPRequest, + 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.#parseStatusTextFromExtrInfo(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; + } + + #parseStatusTextFromExtrInfo( + 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; + } + + override _resolveBody(err: Error | null): void { + if (err) { + return this.#bodyLoadedPromise.resolve(err); + } + return this.#bodyLoadedPromise.resolve(); + } + + override remoteAddress(): RemoteAddress { + return this.#remoteAddress; + } + + override url(): string { + return this.#url; + } + + override ok(): boolean { + // TODO: document === 0 case? + return this.#status === 0 || (this.#status >= 200 && this.#status <= 299); + } + + 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.#bodyLoadedPromise.then(async error => { + if (error) { + throw error; + } + 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(): HTTPRequest { + 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/common/HandleIterator.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/HandleIterator.ts new file mode 100644 index 0000000000..d5df382f88 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/HandleIterator.ts @@ -0,0 +1,84 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +import {JSHandle} from '../api/JSHandle.js'; + +import {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 +) { + const 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>>; + await array.dispose(); + yield* properties.values(); + 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; + try { + while (!(yield* fastTransposeIteratorHandle(iterator, size))) { + size <<= 1; + } + } finally { + await iterator.dispose(); + } +} + +type AwaitableIterator<T> = Iterator<T> | AsyncIterator<T>; + +/** + * @internal + */ +export async function* transposeIterableHandle<T>( + handle: JSHandle<AwaitableIterable<T>> +): AsyncIterableIterator<HandleFor<T>> { + yield* transposeIteratorHandle( + await handle.evaluateHandle(iterable => { + return (async function* () { + yield* iterable; + })(); + }) + ); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Input.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Input.ts new file mode 100644 index 0000000000..4af29bd520 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Input.ts @@ -0,0 +1,920 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * 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. + */ + +import {Protocol} from 'devtools-protocol'; + +import {Point} from '../api/ElementHandle.js'; +import {assert} from '../util/assert.js'; + +import {CDPSession} from './Connection.js'; +import {_keyDefinitions, KeyDefinition, KeyInput} from './USKeyboardLayout.js'; + +type KeyDescription = Required< + Pick<KeyDefinition, 'keyCode' | 'key' | 'text' | 'code' | 'location'> +>; + +/** + * 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 class Keyboard { + #client: CDPSession; + #pressedKeys = new Set<string>(); + + /** + * @internal + */ + _modifiers = 0; + + /** + * @internal + */ + constructor(client: CDPSession) { + this.#client = client; + } + + /** + * 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. + */ + async down( + key: KeyInput, + options: {text?: string; commands?: string[]} = { + 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; + } + + /** + * 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. + */ + 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, + }); + } + + /** + * 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. + */ + 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]; + } + + /** + * 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. + */ + async type(text: string, options: {delay?: number} = {}): 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); + } + } + } + + /** + * 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. + */ + async press( + key: KeyInput, + options: {delay?: number; text?: string; commands?: string[]} = {} + ): 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); + } +} + +/** + * @public + */ +export interface MouseOptions { + /** + * Determines which button will be pressed. + * + * @defaultValue `'left'` + */ + button?: MouseButton; + /** + * @deprecated Use {@link MouseClickOptions.count}. + * + * Determines the click count for the mouse event. This does not perform + * multiple clicks. + * + * @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]; + +/** + * 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; +} + +/** + * 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 class Mouse { + #client: CDPSession; + #keyboard: Keyboard; + + /** + * @internal + */ + constructor(client: CDPSession, keyboard: Keyboard) { + this.#client = client; + this.#keyboard = keyboard; + } + + #_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; + } + } + + /** + * 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. + */ + async move( + x: number, + y: number, + options: 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, + }); + }); + } + } + + /** + * Presses the mouse. + * + * @param options - Options to configure behavior. + */ + async down(options: 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, + }); + }); + } + + /** + * Releases the mouse. + * + * @param options - Options to configure behavior. + */ + async up(options: 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, + }); + }); + } + + /** + * 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. + */ + 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); + } + + /** + * 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}); + * ``` + */ + async wheel(options: 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, + }); + } + + /** + * Dispatches a `drag` event. + * @param start - starting point for drag + * @param target - point to drag to + */ + 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 promise; + } + + /** + * Dispatches a `dragenter` event. + * @param target - point for emitting `dragenter` event + * @param data - drag data containing items and operations mask + */ + 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, + }); + } + + /** + * Dispatches a `dragover` event. + * @param target - point for emitting `dragover` event + * @param data - drag data containing items and operations mask + */ + 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, + }); + } + + /** + * Performs a dragenter, dragover, and drop in sequence. + * @param target - point to drop on + * @param data - drag data containing items and operations mask + */ + 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, + }); + } + + /** + * 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. + */ + 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(); + } +} + +/** + * The Touchscreen class exposes touchscreen events. + * @public + */ +export class Touchscreen { + #client: CDPSession; + #keyboard: Keyboard; + + /** + * @internal + */ + constructor(client: CDPSession, keyboard: Keyboard) { + this.#client = client; + this.#keyboard = keyboard; + } + + /** + * 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. + */ + async touchStart(x: number, y: number): Promise<void> { + const touchPoints = [{x: Math.round(x), y: Math.round(y)}]; + await this.#client.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints, + modifiers: this.#keyboard._modifiers, + }); + } + /** + * Dispatches a `touchMove` event. + * @param x - Horizontal position of the move. + * @param y - Vertical position of the move. + */ + async touchMove(x: number, y: number): Promise<void> { + const movePoints = [{x: Math.round(x), y: Math.round(y)}]; + await this.#client.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: movePoints, + modifiers: this.#keyboard._modifiers, + }); + } + /** + * Dispatches a `touchend` event. + */ + 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/common/IsolatedWorld.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/IsolatedWorld.ts new file mode 100644 index 0000000000..82272ae32a --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/IsolatedWorld.ts @@ -0,0 +1,537 @@ +/** + * Copyright 2019 Google Inc. All rights reserved. + * + * 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. + */ + +import {Protocol} from 'devtools-protocol'; + +import type {ClickOptions, ElementHandle} from '../api/ElementHandle.js'; +import {JSHandle} from '../api/JSHandle.js'; +import {assert} from '../util/assert.js'; +import {createDeferredPromise} from '../util/DeferredPromise.js'; + +import {Binding} from './Binding.js'; +import {CDPSession} from './Connection.js'; +import {ExecutionContext} from './ExecutionContext.js'; +import {Frame} from './Frame.js'; +import {FrameManager} from './FrameManager.js'; +import {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js'; +import {LifecycleWatcher, PuppeteerLifeCycleEvent} from './LifecycleWatcher.js'; +import {TimeoutSettings} from './TimeoutSettings.js'; +import { + BindingPayload, + EvaluateFunc, + EvaluateFuncWith, + HandleFor, + InnerLazyParams, + NodeFor, +} from './types.js'; +import { + addPageBinding, + createJSHandle, + debugError, + setPageContent, +} from './util.js'; +import {TaskManager, WaitTask} from './WaitTask.js'; + +/** + * @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; +} + +/** + * @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 { + #frame: Frame; + #document?: ElementHandle<Document>; + #context = createDeferredPromise<ExecutionContext>(); + #detached = false; + + // 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>(); + #taskManager = new TaskManager(); + + get taskManager(): TaskManager { + return this.#taskManager; + } + + get _bindings(): Map<string, Binding> { + return this.#bindings; + } + + constructor(frame: Frame) { + // Keep own reference to client because it might differ from the FrameManager's + // client for OOP iframes. + this.#frame = frame; + this.#client.on('Runtime.bindingCalled', this.#onBindingCalled); + } + + get #client(): CDPSession { + return this.#frame._client(); + } + + get #frameManager(): FrameManager { + return this.#frame._frameManager; + } + + get #timeoutSettings(): TimeoutSettings { + return this.#frameManager.timeoutSettings; + } + + frame(): Frame { + return this.#frame; + } + + clearContext(): void { + this.#document = undefined; + this.#context = createDeferredPromise(); + } + + setContext(context: ExecutionContext): void { + this.#contextBindings.clear(); + this.#context.resolve(context); + void this.#taskManager.rerunAll(); + } + + hasContext(): boolean { + return this.#context.resolved(); + } + + _detach(): void { + this.#detached = true; + this.#client.off('Runtime.bindingCalled', this.#onBindingCalled); + this.#taskManager.terminateAll( + new Error('waitForFunction failed: frame got detached.') + ); + } + + executionContext(): Promise<ExecutionContext> { + if (this.#detached) { + throw new Error( + `Execution context is not available in detached frame "${this.#frame.url()}" (are you trying to evaluate?)` + ); + } + if (this.#context === null) { + throw new Error(`Execution content promise is missing`); + } + return this.#context; + } + + async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params> + >( + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + const context = await this.executionContext(); + return context.evaluateHandle(pageFunction, ...args); + } + + async evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params> + >( + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + const context = await this.executionContext(); + return context.evaluate(pageFunction, ...args); + } + + async $<Selector extends string>( + selector: Selector + ): Promise<ElementHandle<NodeFor<Selector>> | null> { + const document = await this.document(); + return document.$(selector); + } + + async $$<Selector extends string>( + selector: Selector + ): Promise<Array<ElementHandle<NodeFor<Selector>>>> { + const document = await this.document(); + return document.$$(selector); + } + + async document(): Promise<ElementHandle<Document>> { + if (this.#document) { + return this.#document; + } + const context = await this.executionContext(); + this.#document = await context.evaluateHandle(() => { + return document; + }); + return this.#document; + } + + async $x(expression: string): Promise<Array<ElementHandle<Node>>> { + const document = await this.document(); + return document.$x(expression); + } + + 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>>> { + const document = await this.document(); + return document.$eval(selector, pageFunction, ...args); + } + + 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>>> { + const document = await this.document(); + return document.$$eval(selector, pageFunction, ...args); + } + + async content(): Promise<string> { + return await this.evaluate(() => { + let retVal = ''; + if (document.doctype) { + retVal = new XMLSerializer().serializeToString(document.doctype); + } + if (document.documentElement) { + retVal += document.documentElement.outerHTML; + } + return retVal; + }); + } + + async setContent( + html: string, + options: { + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; + } = {} + ): Promise<void> { + const { + waitUntil = ['load'], + timeout = this.#timeoutSettings.navigationTimeout(), + } = options; + + await setPageContent(this, html); + + const watcher = new LifecycleWatcher( + this.#frameManager, + this.#frame, + waitUntil, + timeout + ); + const error = await Promise.race([ + watcher.timeoutOrTerminationPromise(), + watcher.lifecyclePromise(), + ]); + watcher.dispose(); + if (error) { + throw error; + } + } + + async click( + selector: string, + options: Readonly<ClickOptions> = {} + ): Promise<void> { + const handle = await this.$(selector); + assert(handle, `No element found for selector: ${selector}`); + await handle.click(options); + await handle.dispose(); + } + + async focus(selector: string): Promise<void> { + const handle = await this.$(selector); + assert(handle, `No element found for selector: ${selector}`); + await handle.focus(); + await handle.dispose(); + } + + async hover(selector: string): Promise<void> { + const handle = await this.$(selector); + assert(handle, `No element found for selector: ${selector}`); + await handle.hover(); + await handle.dispose(); + } + + async select(selector: string, ...values: string[]): Promise<string[]> { + const handle = await this.$(selector); + assert(handle, `No element found for selector: ${selector}`); + const result = await handle.select(...values); + await handle.dispose(); + return result; + } + + async tap(selector: string): Promise<void> { + const handle = await this.$(selector); + assert(handle, `No element found for selector: ${selector}`); + await handle.tap(); + await handle.dispose(); + } + + async type( + selector: string, + text: string, + options?: {delay: number} + ): Promise<void> { + const handle = await this.$(selector); + assert(handle, `No element found for selector: ${selector}`); + await handle.type(text, options); + await handle.dispose(); + } + + // 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; + } + + await this.#mutex.acquire(); + try { + await context._client.send('Runtime.addBinding', { + name, + executionContextName: context._contextName, + }); + + 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); + } finally { + this.#mutex.release(); + } + } + + #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; + } + + const context = await this.#context; + if (event.executionContextId !== context._contextId) { + return; + } + + const binding = this._bindings.get(name); + await binding?.run(context, seq, args, isTrivial); + }; + + 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 waitTask.result; + } + + async title(): Promise<string> { + return this.evaluate(() => { + return document.title; + }); + } + + 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 createJSHandle(executionContext, object) as JSHandle<Node>; + } + + async adoptHandle<T extends JSHandle<Node>>(handle: T): Promise<T> { + const context = await this.executionContext(); + assert( + handle.executionContext() !== context, + 'Cannot adopt handle that already belongs to this execution context' + ); + 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> { + const context = await this.executionContext(); + if (handle.executionContext() === context) { + 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; + } +} + +class Mutex { + #locked = false; + #acquirers: Array<() => void> = []; + + // This is FIFO. + acquire(): Promise<void> { + if (!this.#locked) { + this.#locked = true; + return Promise.resolve(); + } + let resolve!: () => void; + const promise = new Promise<void>(res => { + resolve = res; + }); + this.#acquirers.push(resolve); + return promise; + } + + release(): void { + const resolve = this.#acquirers.shift(); + if (!resolve) { + this.#locked = false; + return; + } + resolve(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/IsolatedWorlds.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/IsolatedWorlds.ts new file mode 100644 index 0000000000..bf6ee30b11 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/IsolatedWorlds.ts @@ -0,0 +1,30 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +/** + * 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/common/JSHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/JSHandle.ts new file mode 100644 index 0000000000..e755e9344c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/JSHandle.ts @@ -0,0 +1,168 @@ +/** + * Copyright 2019 Google Inc. All rights reserved. + * + * 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. + */ + +import {Protocol} from 'devtools-protocol'; + +import {JSHandle} from '../api/JSHandle.js'; +import {assert} from '../util/assert.js'; + +import {CDPSession} from './Connection.js'; +import type {CDPElementHandle} from './ElementHandle.js'; +import {ExecutionContext} from './ExecutionContext.js'; +import {EvaluateFuncWith, HandleFor, HandleOr} from './types.js'; +import {createJSHandle, releaseObject, valueFromRemoteObject} from './util.js'; + +declare const __JSHandleSymbol: unique symbol; + +/** + * @internal + */ +export class CDPJSHandle<T = unknown> extends JSHandle<T> { + /** + * Used for nominally typing {@link JSHandle}. + */ + [__JSHandleSymbol]?: T; + + #disposed = false; + #context: ExecutionContext; + #remoteObject: Protocol.Runtime.RemoteObject; + + override get disposed(): boolean { + return this.#disposed; + } + + constructor( + context: ExecutionContext, + remoteObject: Protocol.Runtime.RemoteObject + ) { + super(); + this.#context = context; + this.#remoteObject = remoteObject; + } + + override executionContext(): ExecutionContext { + return this.#context; + } + + override get client(): CDPSession { + return this.#context._client; + } + + /** + * @see {@link ExecutionContext.evaluate} for more details. + */ + override async evaluate< + Params extends unknown[], + Func extends EvaluateFuncWith<T, Params> = EvaluateFuncWith<T, Params> + >( + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + return await this.executionContext().evaluate(pageFunction, this, ...args); + } + + /** + * @see {@link ExecutionContext.evaluateHandle} for more details. + */ + override async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFuncWith<T, Params> = EvaluateFuncWith<T, Params> + >( + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + return await this.executionContext().evaluateHandle( + pageFunction, + this, + ...args + ); + } + + override async getProperty<K extends keyof T>( + propertyName: HandleOr<K> + ): Promise<HandleFor<T[K]>>; + override async getProperty(propertyName: string): Promise<JSHandle<unknown>>; + override async getProperty<K extends keyof T>( + propertyName: HandleOr<K> + ): Promise<HandleFor<T[K]>> { + return this.evaluateHandle((object, propertyName) => { + return object[propertyName as K]; + }, propertyName); + } + + override async getProperties(): Promise<Map<string, JSHandle>> { + assert(this.#remoteObject.objectId); + // We use Runtime.getProperties rather than iterative building because the + // iterative approach might create a distorted snapshot. + const response = await this.client.send('Runtime.getProperties', { + objectId: this.#remoteObject.objectId, + ownProperties: true, + }); + const result = new Map<string, JSHandle>(); + for (const property of response.result) { + if (!property.enumerable || !property.value) { + continue; + } + result.set(property.name, createJSHandle(this.#context, property.value)); + } + return result; + } + + 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; + } +} 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..7ba7dd707e --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/LazyArg.ts @@ -0,0 +1,39 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +import {ExecutionContext} from './ExecutionContext.js'; + +/** + * @internal + */ +export class LazyArg<T> { + static create = <T>( + get: (context: ExecutionContext) => 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: ExecutionContext) => Promise<T> | T; + private constructor(get: (context: ExecutionContext) => Promise<T> | T) { + this.#get = get; + } + + async get(context: ExecutionContext): Promise<T> { + return this.#get(context); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/LifecycleWatcher.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/LifecycleWatcher.ts new file mode 100644 index 0000000000..06899eb0e9 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/LifecycleWatcher.ts @@ -0,0 +1,304 @@ +/** + * Copyright 2019 Google Inc. All rights reserved. + * + * 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. + */ + +import {HTTPResponse} from '../api/HTTPResponse.js'; +import {assert} from '../util/assert.js'; +import { + DeferredPromise, + createDeferredPromise, +} from '../util/DeferredPromise.js'; + +import {CDPSessionEmittedEvents} from './Connection.js'; +import {TimeoutError} from './Errors.js'; +import {Frame} from './Frame.js'; +import {FrameManager, FrameManagerEmittedEvents} from './FrameManager.js'; +import {HTTPRequest} from './HTTPRequest.js'; +import {NetworkManagerEmittedEvents} from './NetworkManager.js'; +import { + addEventListener, + PuppeteerEventListener, + removeEventListeners, +} from './util.js'; +/** + * @public + */ +export type PuppeteerLifeCycleEvent = + | 'load' + | 'domcontentloaded' + | 'networkidle0' + | 'networkidle2'; + +/** + * @public + */ +export type ProtocolLifeCycleEvent = + | 'load' + | 'DOMContentLoaded' + | 'networkIdle' + | 'networkAlmostIdle'; + +const puppeteerToProtocolLifecycle = new Map< + PuppeteerLifeCycleEvent, + ProtocolLifeCycleEvent +>([ + ['load', 'load'], + ['domcontentloaded', 'DOMContentLoaded'], + ['networkidle0', 'networkIdle'], + ['networkidle2', 'networkAlmostIdle'], +]); + +const noop = (): void => {}; + +/** + * @internal + */ +export class LifecycleWatcher { + #expectedLifecycle: ProtocolLifeCycleEvent[]; + #frameManager: FrameManager; + #frame: Frame; + #timeout: number; + #navigationRequest: HTTPRequest | null = null; + #eventListeners: PuppeteerEventListener[]; + #initialLoaderId: string; + + #sameDocumentNavigationPromise = createDeferredPromise<Error | undefined>(); + #lifecyclePromise = createDeferredPromise<void>(); + #newDocumentNavigationPromise = createDeferredPromise<Error | undefined>(); + #terminationPromise = createDeferredPromise<Error | undefined>(); + + #timeoutPromise: Promise<TimeoutError | undefined>; + + #maximumTimer?: NodeJS.Timeout; + #hasSameDocumentNavigation?: boolean; + #swapped?: boolean; + + #navigationResponseReceived?: DeferredPromise<void>; + + constructor( + frameManager: FrameManager, + frame: Frame, + 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.#frameManager = frameManager; + this.#frame = frame; + this.#timeout = timeout; + this.#eventListeners = [ + addEventListener( + frameManager.client, + CDPSessionEmittedEvents.Disconnected, + this.#terminate.bind( + this, + new Error('Navigation failed because browser has disconnected!') + ) + ), + addEventListener( + this.#frameManager, + FrameManagerEmittedEvents.LifecycleEvent, + this.#checkLifecycleComplete.bind(this) + ), + addEventListener( + this.#frameManager, + FrameManagerEmittedEvents.FrameNavigatedWithinDocument, + this.#navigatedWithinDocument.bind(this) + ), + addEventListener( + this.#frameManager, + FrameManagerEmittedEvents.FrameNavigated, + this.#navigated.bind(this) + ), + addEventListener( + this.#frameManager, + FrameManagerEmittedEvents.FrameSwapped, + this.#frameSwapped.bind(this) + ), + addEventListener( + this.#frameManager, + FrameManagerEmittedEvents.FrameDetached, + this.#onFrameDetached.bind(this) + ), + addEventListener( + this.#frameManager.networkManager, + NetworkManagerEmittedEvents.Request, + this.#onRequest.bind(this) + ), + addEventListener( + this.#frameManager.networkManager, + NetworkManagerEmittedEvents.Response, + this.#onResponse.bind(this) + ), + addEventListener( + this.#frameManager.networkManager, + NetworkManagerEmittedEvents.RequestFailed, + this.#onRequestFailed.bind(this) + ), + ]; + + this.#timeoutPromise = this.#createTimeoutPromise(); + 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 = createDeferredPromise(); + 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.#terminationPromise.resolve( + new Error('Navigating frame was detached') + ); + return; + } + this.#checkLifecycleComplete(); + } + + async navigationResponse(): Promise<HTTPResponse | null> { + // Continue with a possibly null response. + await this.#navigationResponseReceived?.catch(() => {}); + return this.#navigationRequest ? this.#navigationRequest.response() : null; + } + + #terminate(error: Error): void { + this.#terminationPromise.resolve(error); + } + + sameDocumentNavigationPromise(): Promise<Error | undefined> { + return this.#sameDocumentNavigationPromise; + } + + newDocumentNavigationPromise(): Promise<Error | undefined> { + return this.#newDocumentNavigationPromise; + } + + lifecyclePromise(): Promise<void> { + return this.#lifecyclePromise; + } + + timeoutOrTerminationPromise(): Promise<Error | TimeoutError | undefined> { + return Promise.race([this.#timeoutPromise, this.#terminationPromise]); + } + + async #createTimeoutPromise(): Promise<TimeoutError | undefined> { + if (!this.#timeout) { + return new Promise(noop); + } + const errorMessage = + 'Navigation timeout of ' + this.#timeout + ' ms exceeded'; + await new Promise(fulfill => { + return (this.#maximumTimer = setTimeout(fulfill, this.#timeout)); + }); + return new TimeoutError(errorMessage); + } + + #navigatedWithinDocument(frame: Frame): void { + if (frame !== this.#frame) { + return; + } + this.#hasSameDocumentNavigation = true; + this.#checkLifecycleComplete(); + } + + #navigated(frame: Frame): void { + if (frame !== this.#frame) { + return; + } + this.#checkLifecycleComplete(); + } + + #frameSwapped(frame: Frame): void { + if (frame !== this.#frame) { + return; + } + this.#swapped = true; + this.#checkLifecycleComplete(); + } + + #checkLifecycleComplete(): void { + // We expect navigation to commit. + if (!checkLifecycle(this.#frame, this.#expectedLifecycle)) { + return; + } + this.#lifecyclePromise.resolve(); + if (this.#hasSameDocumentNavigation) { + this.#sameDocumentNavigationPromise.resolve(undefined); + } + if (this.#swapped || this.#frame._loaderId !== this.#initialLoaderId) { + this.#newDocumentNavigationPromise.resolve(undefined); + } + + function checkLifecycle( + frame: Frame, + expectedLifecycle: ProtocolLifeCycleEvent[] + ): boolean { + for (const event of expectedLifecycle) { + if (!frame._lifecycleEvents.has(event)) { + return false; + } + } + for (const child of frame.childFrames()) { + if ( + child._hasStartedLoading && + !checkLifecycle(child, expectedLifecycle) + ) { + return false; + } + } + return true; + } + } + + dispose(): void { + removeEventListeners(this.#eventListeners); + this.#maximumTimer !== undefined && clearTimeout(this.#maximumTimer); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/NetworkEventManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/NetworkEventManager.ts new file mode 100644 index 0000000000..da550a0068 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/NetworkEventManager.ts @@ -0,0 +1,220 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +import {Protocol} from 'devtools-protocol'; + +import {HTTPRequest} from './HTTPRequest.js'; + +/** + * @internal + */ +export type QueuedEventGroup = { + responseReceivedEvent: Protocol.Network.ResponseReceivedEvent; + loadingFinishedEvent?: Protocol.Network.LoadingFinishedEvent; + loadingFailedEvent?: Protocol.Network.LoadingFailedEvent; +}; + +/** + * @internal + */ +export type FetchRequestId = string; + +/** + * @internal + */ +export type 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, HTTPRequest>(); + + /* + * 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(); + } + + numRequestsInProgress(): number { + return [...this.#httpRequestsMap].filter(([, request]) => { + return !request.response(); + }).length; + } + + 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): HTTPRequest | undefined { + return this.#httpRequestsMap.get(networkRequestId); + } + + storeRequest(networkRequestId: NetworkRequestId, request: HTTPRequest): 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/common/NetworkManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/NetworkManager.ts new file mode 100644 index 0000000000..d9a46be580 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/NetworkManager.ts @@ -0,0 +1,652 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * 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. + */ + +import {Protocol} from 'devtools-protocol'; + +import {assert} from '../util/assert.js'; +import {createDebuggableDeferredPromise} from '../util/DebuggableDeferredPromise.js'; +import {DeferredPromise} from '../util/DeferredPromise.js'; + +import {CDPSession} from './Connection.js'; +import {EventEmitter} from './EventEmitter.js'; +import {FrameManager} from './FrameManager.js'; +import {HTTPRequest} from './HTTPRequest.js'; +import {HTTPResponse} from './HTTPResponse.js'; +import {FetchRequestId, NetworkEventManager} from './NetworkEventManager.js'; +import {debugError, isString} from './util.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; +} + +/** + * We use symbols to prevent any external parties listening to these events. + * They are internal to Puppeteer. + * + * @internal + */ +export const NetworkManagerEmittedEvents = { + Request: Symbol('NetworkManager.Request'), + RequestServedFromCache: Symbol('NetworkManager.RequestServedFromCache'), + Response: Symbol('NetworkManager.Response'), + RequestFailed: Symbol('NetworkManager.RequestFailed'), + RequestFinished: Symbol('NetworkManager.RequestFinished'), +} as const; + +/** + * @internal + */ +export class NetworkManager extends EventEmitter { + #client: CDPSession; + #ignoreHTTPSErrors: boolean; + #frameManager: Pick<FrameManager, 'frame'>; + #networkEventManager = new NetworkEventManager(); + #extraHTTPHeaders: Record<string, string> = {}; + #credentials?: Credentials; + #attemptedAuthentications = new Set<string>(); + #userRequestInterceptionEnabled = false; + #protocolRequestInterceptionEnabled = false; + #userCacheDisabled = false; + #emulatedNetworkConditions: InternalNetworkConditions = { + offline: false, + upload: -1, + download: -1, + latency: 0, + }; + #deferredInitPromise?: DeferredPromise<void>; + + constructor( + client: CDPSession, + ignoreHTTPSErrors: boolean, + frameManager: Pick<FrameManager, 'frame'> + ) { + super(); + this.#client = client; + this.#ignoreHTTPSErrors = ignoreHTTPSErrors; + this.#frameManager = frameManager; + + this.#client.on('Fetch.requestPaused', this.#onRequestPaused.bind(this)); + this.#client.on('Fetch.authRequired', this.#onAuthRequired.bind(this)); + this.#client.on( + 'Network.requestWillBeSent', + this.#onRequestWillBeSent.bind(this) + ); + this.#client.on( + 'Network.requestServedFromCache', + this.#onRequestServedFromCache.bind(this) + ); + this.#client.on( + 'Network.responseReceived', + this.#onResponseReceived.bind(this) + ); + this.#client.on( + 'Network.loadingFinished', + this.#onLoadingFinished.bind(this) + ); + this.#client.on('Network.loadingFailed', this.#onLoadingFailed.bind(this)); + this.#client.on( + 'Network.responseReceivedExtraInfo', + this.#onResponseReceivedExtraInfo.bind(this) + ); + } + + /** + * Initialize calls should avoid async dependencies between CDP calls as those + * might not resolve until after the target is resumed causing a deadlock. + */ + initialize(): Promise<void> { + if (this.#deferredInitPromise) { + return this.#deferredInitPromise; + } + this.#deferredInitPromise = createDebuggableDeferredPromise( + 'NetworkManager initialization timed out' + ); + const init = Promise.all([ + this.#ignoreHTTPSErrors + ? this.#client.send('Security.setIgnoreCertificateErrors', { + ignore: true, + }) + : null, + this.#client.send('Network.enable'), + ]); + const deferredInitPromise = this.#deferredInitPromise; + init + .then(() => { + deferredInitPromise.resolve(); + }) + .catch(err => { + deferredInitPromise.reject(err); + }); + return this.#deferredInitPromise; + } + + async authenticate(credentials?: Credentials): Promise<void> { + this.#credentials = credentials; + await this.#updateProtocolRequestInterception(); + } + + 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.#client.send('Network.setExtraHTTPHeaders', { + headers: this.#extraHTTPHeaders, + }); + } + + extraHTTPHeaders(): Record<string, string> { + return Object.assign({}, this.#extraHTTPHeaders); + } + + numRequestsInProgress(): number { + return this.#networkEventManager.numRequestsInProgress(); + } + + async setOfflineMode(value: boolean): Promise<void> { + this.#emulatedNetworkConditions.offline = value; + await this.#updateNetworkConditions(); + } + + async emulateNetworkConditions( + networkConditions: NetworkConditions | null + ): Promise<void> { + this.#emulatedNetworkConditions.upload = networkConditions + ? networkConditions.upload + : -1; + this.#emulatedNetworkConditions.download = networkConditions + ? networkConditions.download + : -1; + this.#emulatedNetworkConditions.latency = networkConditions + ? networkConditions.latency + : 0; + + await this.#updateNetworkConditions(); + } + + async #updateNetworkConditions(): Promise<void> { + await this.#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> { + await this.#client.send('Network.setUserAgentOverride', { + userAgent: userAgent, + userAgentMetadata: userAgentMetadata, + }); + } + + async setCacheEnabled(enabled: boolean): Promise<void> { + this.#userCacheDisabled = !enabled; + await this.#updateProtocolCacheDisabled(); + } + + async setRequestInterception(value: boolean): Promise<void> { + this.#userRequestInterceptionEnabled = value; + await this.#updateProtocolRequestInterception(); + } + + async #updateProtocolRequestInterception(): Promise<void> { + const enabled = this.#userRequestInterceptionEnabled || !!this.#credentials; + if (enabled === this.#protocolRequestInterceptionEnabled) { + return; + } + this.#protocolRequestInterceptionEnabled = enabled; + if (enabled) { + await Promise.all([ + this.#updateProtocolCacheDisabled(), + this.#client.send('Fetch.enable', { + handleAuthRequests: true, + patterns: [{urlPattern: '*'}], + }), + ]); + } else { + await Promise.all([ + this.#updateProtocolCacheDisabled(), + this.#client.send('Fetch.disable'), + ]); + } + } + + #cacheDisabled(): boolean { + return this.#userCacheDisabled; + } + + async #updateProtocolCacheDisabled(): Promise<void> { + await this.#client.send('Network.setCacheDisabled', { + cacheDisabled: this.#cacheDisabled(), + }); + } + + #onRequestWillBeSent(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(event, fetchRequestId); + this.#networkEventManager.forgetRequestPaused(networkRequestId); + } + + return; + } + this.#onRequest(event, undefined); + } + + #onAuthRequired(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, + }; + this.#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(event: Protocol.Fetch.RequestPausedEvent): void { + if ( + !this.#userRequestInterceptionEnabled && + this.#protocolRequestInterceptionEnabled + ) { + this.#client + .send('Fetch.continueRequest', { + requestId: event.requestId, + }) + .catch(debugError); + } + + const {networkId: networkRequestId, requestId: fetchRequestId} = event; + + if (!networkRequestId) { + this.#onRequestWithoutNetworkInstrumentation(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(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( + 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 HTTPRequest( + this.#client, + frame, + event.requestId, + this.#userRequestInterceptionEnabled, + event, + [] + ); + this.emit(NetworkManagerEmittedEvents.Request, request); + void request.finalizeInterceptions(); + } + + #onRequest( + event: Protocol.Network.RequestWillBeSentEvent, + fetchRequestId?: FetchRequestId + ): void { + let redirectChain: HTTPRequest[] = []; + 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( + request, + event.redirectResponse, + redirectResponseExtraInfo + ); + redirectChain = request._redirectChain; + } + } + const frame = event.frameId + ? this.#frameManager.frame(event.frameId) + : null; + + const request = new HTTPRequest( + this.#client, + frame, + fetchRequestId, + this.#userRequestInterceptionEnabled, + event, + redirectChain + ); + this.#networkEventManager.storeRequest(event.requestId, request); + this.emit(NetworkManagerEmittedEvents.Request, request); + void request.finalizeInterceptions(); + } + + #onRequestServedFromCache( + event: Protocol.Network.RequestServedFromCacheEvent + ): void { + const request = this.#networkEventManager.getRequest(event.requestId); + if (request) { + request._fromMemoryCache = true; + } + this.emit(NetworkManagerEmittedEvents.RequestServedFromCache, request); + } + + #handleRequestRedirect( + request: HTTPRequest, + responsePayload: Protocol.Network.Response, + extraInfo: Protocol.Network.ResponseReceivedExtraInfoEvent | null + ): void { + const response = new HTTPResponse( + this.#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(NetworkManagerEmittedEvents.Response, response); + this.emit(NetworkManagerEmittedEvents.RequestFinished, request); + } + + #emitResponseEvent( + 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 HTTPResponse( + this.#client, + request, + responseReceived.response, + extraInfo + ); + request._response = response; + this.emit(NetworkManagerEmittedEvents.Response, response); + } + + #onResponseReceived(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(event, extraInfo); + } + + #onResponseReceivedExtraInfo( + 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(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(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: HTTPRequest, 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(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(null); + } + this.#forgetRequest(request, true); + this.emit(NetworkManagerEmittedEvents.RequestFinished, request); + } + + #onLoadingFailed(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(null); + } + this.#forgetRequest(request, true); + this.emit(NetworkManagerEmittedEvents.RequestFailed, request); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/NodeWebSocketTransport.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/NodeWebSocketTransport.ts new file mode 100644 index 0000000000..2a864df651 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/NodeWebSocketTransport.ts @@ -0,0 +1,74 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * 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. + */ +import NodeWebSocket from 'ws'; + +import {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/common/PDFOptions.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/PDFOptions.ts new file mode 100644 index 0000000000..7b9eed7872 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/PDFOptions.ts @@ -0,0 +1,221 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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. + */ + +/** + * @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; + /** + * 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'> & 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..4044c4ba4d --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/PQueryHandler.ts @@ -0,0 +1,37 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +import {QueryHandler, QuerySelector, 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/Page.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Page.ts new file mode 100644 index 0000000000..543065597e --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Page.ts @@ -0,0 +1,1656 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * 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. + */ + +import type {Readable} from 'stream'; + +import {Protocol} from 'devtools-protocol'; + +import type {Browser} from '../api/Browser.js'; +import type {BrowserContext} from '../api/BrowserContext.js'; +import {ClickOptions, ElementHandle} from '../api/ElementHandle.js'; +import {HTTPRequest} from '../api/HTTPRequest.js'; +import {HTTPResponse} from '../api/HTTPResponse.js'; +import {JSHandle} from '../api/JSHandle.js'; +import { + GeolocationOptions, + MediaFeature, + Metrics, + Page, + PageEmittedEvents, + ScreenshotClip, + ScreenshotOptions, + WaitForOptions, + WaitTimeoutOptions, +} from '../api/Page.js'; +import {assert} from '../util/assert.js'; +import { + createDeferredPromise, + DeferredPromise, +} from '../util/DeferredPromise.js'; +import {isErrorLike} from '../util/ErrorLike.js'; + +import {Accessibility} from './Accessibility.js'; +import {Binding} from './Binding.js'; +import { + CDPSession, + CDPSessionEmittedEvents, + isTargetClosedError, +} from './Connection.js'; +import {ConsoleMessage, ConsoleMessageType} from './ConsoleMessage.js'; +import {Coverage} from './Coverage.js'; +import {DeviceRequestPrompt} from './DeviceRequestPrompt.js'; +import {Dialog} from './Dialog.js'; +import {EmulationManager} from './EmulationManager.js'; +import {FileChooser} from './FileChooser.js'; +import { + Frame, + FrameAddScriptTagOptions, + FrameAddStyleTagOptions, + FrameWaitForFunctionOptions, +} from './Frame.js'; +import {FrameManager, FrameManagerEmittedEvents} from './FrameManager.js'; +import {Keyboard, Mouse, Touchscreen} from './Input.js'; +import {WaitForSelectorOptions} from './IsolatedWorld.js'; +import {MAIN_WORLD} from './IsolatedWorlds.js'; +import { + Credentials, + NetworkConditions, + NetworkManagerEmittedEvents, +} from './NetworkManager.js'; +import {PDFOptions} from './PDFOptions.js'; +import {Viewport} from './PuppeteerViewport.js'; +import {Target} from './Target.js'; +import {TargetManagerEmittedEvents} from './TargetManager.js'; +import {TaskQueue} from './TaskQueue.js'; +import {TimeoutSettings} from './TimeoutSettings.js'; +import {Tracing} from './Tracing.js'; +import { + BindingPayload, + EvaluateFunc, + EvaluateFuncWith, + HandleFor, + NodeFor, +} from './types.js'; +import { + createJSHandle, + debugError, + evaluationString, + getExceptionMessage, + getReadableAsBuffer, + getReadableFromProtocolStream, + isString, + pageBindingInitString, + releaseObject, + valueFromRemoteObject, + waitForEvent, + waitWithTimeout, +} from './util.js'; +import {WebWorker} from './WebWorker.js'; + +/** + * @internal + */ +export class CDPPage extends Page { + /** + * @internal + */ + static async _create( + client: CDPSession, + target: Target, + ignoreHTTPSErrors: boolean, + defaultViewport: Viewport | null, + screenshotTaskQueue: TaskQueue + ): Promise<CDPPage> { + const page = new CDPPage( + client, + target, + ignoreHTTPSErrors, + screenshotTaskQueue + ); + 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; + #client: CDPSession; + #target: Target; + #keyboard: Keyboard; + #mouse: Mouse; + #timeoutSettings = new TimeoutSettings(); + #touchscreen: Touchscreen; + #accessibility: Accessibility; + #frameManager: FrameManager; + #emulationManager: EmulationManager; + #tracing: Tracing; + #bindings = new Map<string, Binding>(); + #coverage: Coverage; + #javascriptEnabled = true; + #viewport: Viewport | null; + #screenshotTaskQueue: TaskQueue; + #workers = new Map<string, WebWorker>(); + #fileChooserPromises = new Set<DeferredPromise<FileChooser>>(); + + #disconnectPromise?: Promise<Error>; + #userDragInterceptionEnabled = false; + + /** + * @internal + */ + constructor( + client: CDPSession, + target: Target, + ignoreHTTPSErrors: boolean, + screenshotTaskQueue: TaskQueue + ) { + super(); + this.#client = client; + this.#target = target; + this.#keyboard = new Keyboard(client); + this.#mouse = new Mouse(client, this.#keyboard); + this.#touchscreen = new Touchscreen(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.#screenshotTaskQueue = screenshotTaskQueue; + this.#viewport = null; + + this.#target + ._targetManager() + .addTargetInterceptor(this.#client, this.#onAttachedToTarget); + + this.#target + ._targetManager() + .on(TargetManagerEmittedEvents.TargetGone, this.#onDetachedFromTarget); + + this.#frameManager.on(FrameManagerEmittedEvents.FrameAttached, event => { + return this.emit(PageEmittedEvents.FrameAttached, event); + }); + this.#frameManager.on(FrameManagerEmittedEvents.FrameDetached, event => { + return this.emit(PageEmittedEvents.FrameDetached, event); + }); + this.#frameManager.on(FrameManagerEmittedEvents.FrameNavigated, event => { + return this.emit(PageEmittedEvents.FrameNavigated, event); + }); + + const networkManager = this.#frameManager.networkManager; + networkManager.on(NetworkManagerEmittedEvents.Request, event => { + return this.emit(PageEmittedEvents.Request, event); + }); + networkManager.on( + NetworkManagerEmittedEvents.RequestServedFromCache, + event => { + return this.emit(PageEmittedEvents.RequestServedFromCache, event); + } + ); + networkManager.on(NetworkManagerEmittedEvents.Response, event => { + return this.emit(PageEmittedEvents.Response, event); + }); + networkManager.on(NetworkManagerEmittedEvents.RequestFailed, event => { + return this.emit(PageEmittedEvents.RequestFailed, event); + }); + networkManager.on(NetworkManagerEmittedEvents.RequestFinished, event => { + return this.emit(PageEmittedEvents.RequestFinished, event); + }); + + client.on('Page.domContentEventFired', () => { + return this.emit(PageEmittedEvents.DOMContentLoaded); + }); + client.on('Page.loadEventFired', () => { + return this.emit(PageEmittedEvents.Load); + }); + client.on('Runtime.consoleAPICalled', event => { + return this.#onConsoleAPI(event); + }); + client.on('Runtime.bindingCalled', event => { + return this.#onBindingCalled(event); + }); + client.on('Page.javascriptDialogOpening', event => { + return this.#onDialog(event); + }); + client.on('Runtime.exceptionThrown', exception => { + return this.#handleException(exception.exceptionDetails); + }); + client.on('Inspector.targetCrashed', () => { + return this.#onTargetCrashed(); + }); + client.on('Performance.metrics', event => { + return this.#emitMetrics(event); + }); + client.on('Log.entryAdded', event => { + return this.#onLogEntryAdded(event); + }); + client.on('Page.fileChooserOpened', event => { + return this.#onFileChooser(event); + }); + void this.#target._isClosedPromise.then(() => { + this.#target + ._targetManager() + .removeTargetInterceptor(this.#client, this.#onAttachedToTarget); + + this.#target + ._targetManager() + .off(TargetManagerEmittedEvents.TargetGone, this.#onDetachedFromTarget); + this.emit(PageEmittedEvents.Close); + this.#closed = true; + }); + } + + #onDetachedFromTarget = (target: Target) => { + const sessionId = target._session()?.id(); + const worker = this.#workers.get(sessionId!); + if (!worker) { + return; + } + this.#workers.delete(sessionId!); + this.emit(PageEmittedEvents.WorkerDestroyed, worker); + }; + + #onAttachedToTarget = (createdTarget: Target) => { + this.#frameManager.onAttachedToTarget(createdTarget); + if (createdTarget._getTargetInfo().type === 'worker') { + const session = createdTarget._session(); + assert(session); + const worker = new WebWorker( + session, + createdTarget.url(), + this.#addConsoleMessage.bind(this), + this.#handleException.bind(this) + ); + this.#workers.set(session.id(), worker); + this.emit(PageEmittedEvents.WorkerCreated, worker); + } + if (createdTarget._session()) { + this.#target + ._targetManager() + .addTargetInterceptor( + createdTarget._session()!, + this.#onAttachedToTarget + ); + } + }; + + async #initialize(): Promise<void> { + try { + await Promise.all([ + this.#frameManager.initialize(), + this.#client.send('Performance.enable'), + this.#client.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.#fileChooserPromises.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. + const handle = (await frame.worlds[MAIN_WORLD].adoptBackendNode( + event.backendNodeId + )) as ElementHandle<HTMLInputElement>; + + const fileChooser = new FileChooser(handle, event); + for (const promise of this.#fileChooserPromises) { + promise.resolve(fileChooser); + } + this.#fileChooserPromises.clear(); + } + + /** + * @internal + */ + _client(): CDPSession { + return this.#client; + } + + override isDragInterceptionEnabled(): boolean { + return this.#userDragInterceptionEnabled; + } + + override isJavaScriptEnabled(): boolean { + return this.#javascriptEnabled; + } + + override waitForFileChooser( + options: WaitTimeoutOptions = {} + ): Promise<FileChooser> { + const needsEnable = this.#fileChooserPromises.size === 0; + const {timeout = this.#timeoutSettings.timeout()} = options; + const promise = createDeferredPromise<FileChooser>({ + message: `Waiting for \`FileChooser\` failed: ${timeout}ms exceeded`, + timeout, + }); + this.#fileChooserPromises.add(promise); + let enablePromise: Promise<void> | undefined; + if (needsEnable) { + enablePromise = this.#client.send('Page.setInterceptFileChooserDialog', { + enabled: true, + }); + } + return Promise.all([promise, enablePromise]) + .then(([result]) => { + return result; + }) + .catch(error => { + this.#fileChooserPromises.delete(promise); + throw error; + }); + } + + override 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.#client.send('Emulation.setGeolocationOverride', { + longitude, + latitude, + accuracy, + }); + } + + override target(): Target { + return this.#target; + } + + override browser(): Browser { + return this.#target.browser(); + } + + override browserContext(): BrowserContext { + return this.#target.browserContext(); + } + + #onTargetCrashed(): void { + this.emit('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 => { + return releaseObject(this.#client, arg); + }); + } + if (source !== 'worker') { + this.emit( + PageEmittedEvents.Console, + new ConsoleMessage(level, text, [], [{url, lineNumber}]) + ); + } + } + + override mainFrame(): Frame { + return this.#frameManager.mainFrame(); + } + + override get keyboard(): Keyboard { + return this.#keyboard; + } + + override get touchscreen(): Touchscreen { + 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(): WebWorker[] { + return Array.from(this.#workers.values()); + } + + override async setRequestInterception(value: boolean): Promise<void> { + return this.#frameManager.networkManager.setRequestInterception(value); + } + + override async setDragInterception(enabled: boolean): Promise<void> { + this.#userDragInterceptionEnabled = enabled; + return this.#client.send('Input.setInterceptDrags', {enabled}); + } + + override setOfflineMode(enabled: boolean): Promise<void> { + return this.#frameManager.networkManager.setOfflineMode(enabled); + } + + override emulateNetworkConditions( + networkConditions: NetworkConditions | null + ): Promise<void> { + return 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 $<Selector extends string>( + selector: Selector + ): Promise<ElementHandle<NodeFor<Selector>> | null> { + return this.mainFrame().$(selector); + } + + override async $$<Selector extends string>( + selector: Selector + ): Promise<Array<ElementHandle<NodeFor<Selector>>>> { + return this.mainFrame().$$(selector); + } + + override async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params> + >( + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + const context = await this.mainFrame().executionContext(); + return context.evaluateHandle(pageFunction, ...args); + } + + override async queryObjects<Prototype>( + prototypeHandle: JSHandle<Prototype> + ): Promise<JSHandle<Prototype[]>> { + const context = await this.mainFrame().executionContext(); + assert(!prototypeHandle.disposed, 'Prototype JSHandle is disposed!'); + assert( + prototypeHandle.id, + 'Prototype JSHandle must not be referencing primitive value' + ); + const response = await context._client.send('Runtime.queryObjects', { + prototypeObjectId: prototypeHandle.id, + }); + return createJSHandle(context, response.objects) as HandleFor<Prototype[]>; + } + + override 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>>> { + return this.mainFrame().$eval(selector, pageFunction, ...args); + } + + override 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>>> { + return this.mainFrame().$$eval(selector, pageFunction, ...args); + } + + override async $x(expression: string): Promise<Array<ElementHandle<Node>>> { + return this.mainFrame().$x(expression); + } + + override async cookies( + ...urls: string[] + ): Promise<Protocol.Network.Cookie[]> { + const originalCookies = ( + await this.#client.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.#client.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.#client.send('Network.setCookies', {cookies: items}); + } + } + + override async addScriptTag( + options: FrameAddScriptTagOptions + ): Promise<ElementHandle<HTMLScriptElement>> { + return this.mainFrame().addScriptTag(options); + } + + override async addStyleTag( + options: Omit<FrameAddStyleTagOptions, 'url'> + ): Promise<ElementHandle<HTMLStyleElement>>; + override async addStyleTag( + options: FrameAddStyleTagOptions + ): Promise<ElementHandle<HTMLLinkElement>>; + override async addStyleTag( + options: FrameAddStyleTagOptions + ): Promise<ElementHandle<HTMLStyleElement | HTMLLinkElement>> { + return this.mainFrame().addStyleTag(options); + } + + 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.#client.send('Runtime.addBinding', {name: name}); + await this.#client.send('Page.addScriptToEvaluateOnNewDocument', { + source: expression, + }); + await Promise.all( + this.frames().map(frame => { + return frame.evaluate(expression).catch(debugError); + }) + ); + } + + override async authenticate(credentials: Credentials): Promise<void> { + return this.#frameManager.networkManager.authenticate(credentials); + } + + override async setExtraHTTPHeaders( + headers: Record<string, string> + ): Promise<void> { + return this.#frameManager.networkManager.setExtraHTTPHeaders(headers); + } + + override async setUserAgent( + userAgent: string, + userAgentMetadata?: Protocol.Emulation.UserAgentMetadata + ): Promise<void> { + return this.#frameManager.networkManager.setUserAgent( + userAgent, + userAgentMetadata + ); + } + + override async metrics(): Promise<Metrics> { + const response = await this.#client.send('Performance.getMetrics'); + return this.#buildMetricsObject(response.metrics); + } + + #emitMetrics(event: Protocol.Performance.MetricsEvent): void { + this.emit(PageEmittedEvents.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(exceptionDetails: Protocol.Runtime.ExceptionDetails): void { + const message = getExceptionMessage(exceptionDetails); + const err = new Error(message); + err.stack = ''; // Don't report clientside error with a node stack attached + this.emit(PageEmittedEvents.PageError, err); + } + + 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.#client + ); + if (!context) { + debugError( + new Error( + `ExecutionContext not found for a console message: ${JSON.stringify( + event + )}` + ) + ); + return; + } + const values = event.args.map(arg => { + return createJSHandle(context, 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.#client + ); + 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(PageEmittedEvents.Console)) { + args.forEach(arg => { + return arg.dispose(); + }); + return; + } + const textTokens = []; + 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(PageEmittedEvents.Console, message); + } + + #onDialog(event: Protocol.Page.JavascriptDialogOpeningEvent): void { + let dialogType = null; + const validDialogTypes = new Set<Protocol.Page.DialogType>([ + 'alert', + 'confirm', + 'prompt', + 'beforeunload', + ]); + + if (validDialogTypes.has(event.type)) { + dialogType = event.type as Protocol.Page.DialogType; + } + assert(dialogType, 'Unknown javascript dialog type: ' + event.type); + + const dialog = new Dialog( + this.#client, + dialogType, + event.message, + event.defaultPrompt + ); + this.emit(PageEmittedEvents.Dialog, dialog); + } + + /** + * Resets default white background + */ + async #resetDefaultBackgroundColor() { + await this.#client.send('Emulation.setDefaultBackgroundColorOverride'); + } + + /** + * Hides default white background + */ + async #setTransparentBackgroundColor(): Promise<void> { + await this.#client.send('Emulation.setDefaultBackgroundColorOverride', { + color: {r: 0, g: 0, b: 0, a: 0}, + }); + } + + override url(): string { + return this.mainFrame().url(); + } + + override async content(): Promise<string> { + return await this.#frameManager.mainFrame().content(); + } + + override async setContent( + html: string, + options: WaitForOptions = {} + ): Promise<void> { + await this.#frameManager.mainFrame().setContent(html, options); + } + + override async goto( + url: string, + options: WaitForOptions & {referer?: string; referrerPolicy?: string} = {} + ): Promise<HTTPResponse | null> { + return await this.#frameManager.mainFrame().goto(url, options); + } + + override async reload( + options?: WaitForOptions + ): Promise<HTTPResponse | null> { + const result = await Promise.all([ + this.waitForNavigation(options), + this.#client.send('Page.reload'), + ]); + + return result[0]; + } + + override async waitForNavigation( + options: WaitForOptions = {} + ): Promise<HTTPResponse | null> { + return await this.#frameManager.mainFrame().waitForNavigation(options); + } + + #sessionClosePromise(): Promise<Error> { + if (!this.#disconnectPromise) { + this.#disconnectPromise = new Promise(fulfill => { + return this.#client.once(CDPSessionEmittedEvents.Disconnected, () => { + return fulfill(new Error('Target closed')); + }); + }); + } + return this.#disconnectPromise; + } + + override async waitForRequest( + urlOrPredicate: string | ((req: HTTPRequest) => boolean | Promise<boolean>), + options: {timeout?: number} = {} + ): Promise<HTTPRequest> { + const {timeout = this.#timeoutSettings.timeout()} = options; + return waitForEvent( + this.#frameManager.networkManager, + NetworkManagerEmittedEvents.Request, + async request => { + if (isString(urlOrPredicate)) { + return urlOrPredicate === request.url(); + } + if (typeof urlOrPredicate === 'function') { + return !!(await urlOrPredicate(request)); + } + return false; + }, + timeout, + this.#sessionClosePromise() + ); + } + + override async waitForResponse( + urlOrPredicate: + | string + | ((res: HTTPResponse) => boolean | Promise<boolean>), + options: {timeout?: number} = {} + ): Promise<HTTPResponse> { + const {timeout = this.#timeoutSettings.timeout()} = options; + return waitForEvent( + this.#frameManager.networkManager, + NetworkManagerEmittedEvents.Response, + async response => { + if (isString(urlOrPredicate)) { + return urlOrPredicate === response.url(); + } + if (typeof urlOrPredicate === 'function') { + return !!(await urlOrPredicate(response)); + } + return false; + }, + timeout, + this.#sessionClosePromise() + ); + } + + override async waitForNetworkIdle( + options: {idleTime?: number; timeout?: number} = {} + ): Promise<void> { + const {idleTime = 500, timeout = this.#timeoutSettings.timeout()} = options; + + const networkManager = this.#frameManager.networkManager; + + const idlePromise = createDeferredPromise<void>(); + + let abortRejectCallback: (error: Error) => void; + const abortPromise = new Promise<Error>((_, reject) => { + abortRejectCallback = reject; + }); + + let idleTimer: NodeJS.Timeout; + const cleanup = () => { + idleTimer && clearTimeout(idleTimer); + abortRejectCallback(new Error('abort')); + }; + + const evaluate = () => { + idleTimer && clearTimeout(idleTimer); + if (networkManager.numRequestsInProgress() === 0) { + idleTimer = setTimeout(idlePromise.resolve, idleTime); + } + }; + + evaluate(); + + const eventHandler = () => { + evaluate(); + return false; + }; + + const listenToEvent = (event: symbol) => { + return waitForEvent( + networkManager, + event, + eventHandler, + timeout, + abortPromise + ); + }; + + const eventPromises = [ + listenToEvent(NetworkManagerEmittedEvents.Request), + listenToEvent(NetworkManagerEmittedEvents.Response), + listenToEvent(NetworkManagerEmittedEvents.RequestFailed), + ]; + + await Promise.race([ + idlePromise, + ...eventPromises, + this.#sessionClosePromise(), + ]).then( + r => { + cleanup(); + return r; + }, + error => { + cleanup(); + throw error; + } + ); + } + + override async waitForFrame( + urlOrPredicate: string | ((frame: Frame) => boolean | Promise<boolean>), + options: {timeout?: number} = {} + ): Promise<Frame> { + const {timeout = this.#timeoutSettings.timeout()} = options; + + let predicate: (frame: Frame) => Promise<boolean>; + if (isString(urlOrPredicate)) { + predicate = (frame: Frame) => { + return Promise.resolve(urlOrPredicate === frame.url()); + }; + } else { + predicate = (frame: Frame) => { + const value = urlOrPredicate(frame); + if (typeof value === 'boolean') { + return Promise.resolve(value); + } + return value; + }; + } + + const eventRace: Promise<Frame> = Promise.race([ + waitForEvent( + this.#frameManager, + FrameManagerEmittedEvents.FrameAttached, + predicate, + timeout, + this.#sessionClosePromise() + ), + waitForEvent( + this.#frameManager, + FrameManagerEmittedEvents.FrameNavigated, + predicate, + timeout, + this.#sessionClosePromise() + ), + ...this.frames().map(async frame => { + if (await predicate(frame)) { + return frame; + } + return await eventRace; + }), + ]); + + return eventRace; + } + + override async goBack( + options: WaitForOptions = {} + ): Promise<HTTPResponse | null> { + return this.#go(-1, options); + } + + override async goForward( + options: WaitForOptions = {} + ): Promise<HTTPResponse | null> { + return this.#go(+1, options); + } + + async #go( + delta: number, + options: WaitForOptions + ): Promise<HTTPResponse | null> { + const history = await this.#client.send('Page.getNavigationHistory'); + const entry = history.entries[history.currentIndex + delta]; + if (!entry) { + return null; + } + const result = await Promise.all([ + this.waitForNavigation(options), + this.#client.send('Page.navigateToHistoryEntry', {entryId: entry.id}), + ]); + return result[0]; + } + + override async bringToFront(): Promise<void> { + await this.#client.send('Page.bringToFront'); + } + + override async setJavaScriptEnabled(enabled: boolean): Promise<void> { + if (this.#javascriptEnabled === enabled) { + return; + } + this.#javascriptEnabled = enabled; + await this.#client.send('Emulation.setScriptExecutionDisabled', { + value: !enabled, + }); + } + + override async setBypassCSP(enabled: boolean): Promise<void> { + await this.#client.send('Page.setBypassCSP', {enabled}); + } + + override async emulateMediaType(type?: string): Promise<void> { + assert( + type === 'screen' || + type === 'print' || + (type ?? undefined) === undefined, + 'Unsupported media type: ' + type + ); + await this.#client.send('Emulation.setEmulatedMedia', { + media: type || '', + }); + } + + override async emulateCPUThrottling(factor: number | null): Promise<void> { + assert( + factor === null || factor >= 1, + 'Throttling rate should be greater or equal to 1' + ); + await this.#client.send('Emulation.setCPUThrottlingRate', { + rate: factor !== null ? factor : 1, + }); + } + + override async emulateMediaFeatures( + features?: MediaFeature[] + ): Promise<void> { + if (!features) { + await this.#client.send('Emulation.setEmulatedMedia', {}); + } + 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.#client.send('Emulation.setEmulatedMedia', { + features: features, + }); + } + } + + override async emulateTimezone(timezoneId?: string): Promise<void> { + try { + await this.#client.send('Emulation.setTimezoneOverride', { + timezoneId: timezoneId || '', + }); + } catch (error) { + if (isErrorLike(error) && error.message.includes('Invalid timezone')) { + throw new Error(`Invalid timezone ID: ${timezoneId}`); + } + throw error; + } + } + + override async emulateIdleState(overrides?: { + isUserActive: boolean; + isScreenUnlocked: boolean; + }): Promise<void> { + if (overrides) { + await this.#client.send('Emulation.setIdleOverride', { + isUserActive: overrides.isUserActive, + isScreenUnlocked: overrides.isScreenUnlocked, + }); + } else { + await this.#client.send('Emulation.clearIdleOverride'); + } + } + + override async emulateVisionDeficiency( + type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'] + ): Promise<void> { + const visionDeficiencies = new Set< + Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'] + >([ + 'none', + 'achromatopsia', + 'blurredVision', + 'deuteranopia', + 'protanopia', + 'tritanopia', + ]); + try { + assert( + !type || visionDeficiencies.has(type), + `Unsupported vision deficiency: ${type}` + ); + await this.#client.send('Emulation.setEmulatedVisionDeficiency', { + type: type || 'none', + }); + } catch (error) { + throw error; + } + } + + 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 evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params> + >( + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + return this.#frameManager.mainFrame().evaluate(pageFunction, ...args); + } + + override async evaluateOnNewDocument< + Params extends unknown[], + Func extends (...args: Params) => unknown = (...args: Params) => unknown + >(pageFunction: Func | string, ...args: Params): Promise<void> { + const source = evaluationString(pageFunction, ...args); + await this.#client.send('Page.addScriptToEvaluateOnNewDocument', { + source, + }); + } + + override async setCacheEnabled(enabled = true): Promise<void> { + await this.#frameManager.networkManager.setCacheEnabled(enabled); + } + + override screenshot( + options: ScreenshotOptions & {encoding: 'base64'} + ): Promise<string>; + override screenshot( + options?: ScreenshotOptions & {encoding?: 'binary'} + ): Promise<Buffer>; + override async screenshot( + options: ScreenshotOptions = {} + ): Promise<Buffer | string> { + let screenshotType = Protocol.Page.CaptureScreenshotRequestFormat.Png; + // options.type takes precedence over inferring the type from options.path + // because it may be a 0-length file with no extension created beforehand + // (i.e. as a temp file). + if (options.type) { + screenshotType = + options.type as Protocol.Page.CaptureScreenshotRequestFormat; + } else if (options.path) { + const filePath = options.path; + const extension = filePath + .slice(filePath.lastIndexOf('.') + 1) + .toLowerCase(); + switch (extension) { + case 'png': + screenshotType = Protocol.Page.CaptureScreenshotRequestFormat.Png; + break; + case 'jpeg': + case 'jpg': + screenshotType = Protocol.Page.CaptureScreenshotRequestFormat.Jpeg; + break; + case 'webp': + screenshotType = Protocol.Page.CaptureScreenshotRequestFormat.Webp; + break; + default: + throw new Error( + `Unsupported screenshot type for extension \`.${extension}\`` + ); + } + } + + if (options.quality) { + assert( + screenshotType === Protocol.Page.CaptureScreenshotRequestFormat.Jpeg || + screenshotType === Protocol.Page.CaptureScreenshotRequestFormat.Webp, + 'options.quality is unsupported for the ' + + screenshotType + + ' screenshots' + ); + assert( + typeof options.quality === 'number', + 'Expected options.quality to be a number but found ' + + typeof options.quality + ); + assert( + Number.isInteger(options.quality), + 'Expected options.quality to be an integer' + ); + assert( + options.quality >= 0 && options.quality <= 100, + 'Expected options.quality to be between 0 and 100 (inclusive), got ' + + options.quality + ); + } + assert( + !options.clip || !options.fullPage, + 'options.clip and options.fullPage are exclusive' + ); + if (options.clip) { + assert( + typeof options.clip.x === 'number', + 'Expected options.clip.x to be a number but found ' + + typeof options.clip.x + ); + assert( + typeof options.clip.y === 'number', + 'Expected options.clip.y to be a number but found ' + + typeof options.clip.y + ); + assert( + typeof options.clip.width === 'number', + 'Expected options.clip.width to be a number but found ' + + typeof options.clip.width + ); + assert( + typeof options.clip.height === 'number', + 'Expected options.clip.height to be a number but found ' + + typeof options.clip.height + ); + assert( + options.clip.width !== 0, + 'Expected options.clip.width not to be 0.' + ); + assert( + options.clip.height !== 0, + 'Expected options.clip.height not to be 0.' + ); + } + return this.#screenshotTaskQueue.postTask(() => { + return this.#screenshotTask(screenshotType, options); + }); + } + + async #screenshotTask( + format: Protocol.Page.CaptureScreenshotRequestFormat, + options: ScreenshotOptions = {} + ): Promise<Buffer | string> { + await this.#client.send('Target.activateTarget', { + targetId: this.#target._targetId, + }); + let clip = options.clip ? processClip(options.clip) : undefined; + let captureBeyondViewport = options.captureBeyondViewport ?? true; + const fromSurface = options.fromSurface; + + if (options.fullPage) { + // Overwrite clip for full page. + clip = undefined; + + if (!captureBeyondViewport) { + const metrics = await this.#client.send('Page.getLayoutMetrics'); + // Fallback to `contentSize` in case of using Firefox. + const {width, height} = metrics.cssContentSize || metrics.contentSize; + const { + isMobile = false, + deviceScaleFactor = 1, + isLandscape = false, + } = this.#viewport || {}; + const screenOrientation: Protocol.Emulation.ScreenOrientation = + isLandscape + ? {angle: 90, type: 'landscapePrimary'} + : {angle: 0, type: 'portraitPrimary'}; + await this.#client.send('Emulation.setDeviceMetricsOverride', { + mobile: isMobile, + width, + height, + deviceScaleFactor, + screenOrientation, + }); + } + } else if (!clip) { + captureBeyondViewport = false; + } + + const shouldSetDefaultBackground = + options.omitBackground && (format === 'png' || format === 'webp'); + if (shouldSetDefaultBackground) { + await this.#setTransparentBackgroundColor(); + } + + const result = await this.#client.send('Page.captureScreenshot', { + format, + quality: options.quality, + clip: clip && { + ...clip, + scale: clip.scale ?? 1, + }, + captureBeyondViewport, + fromSurface, + }); + if (shouldSetDefaultBackground) { + await this.#resetDefaultBackgroundColor(); + } + + if (options.fullPage && this.#viewport) { + await this.setViewport(this.#viewport); + } + + if (options.encoding === 'base64') { + return result.data; + } + + const buffer = Buffer.from(result.data, 'base64'); + await this._maybeWriteBufferToFile(options.path, buffer); + + return buffer; + + function processClip(clip: ScreenshotClip): ScreenshotClip { + 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 {x, y, width, height, scale: clip.scale}; + } + } + + override async createPDFStream(options: PDFOptions = {}): Promise<Readable> { + const { + landscape, + displayHeaderFooter, + headerTemplate, + footerTemplate, + printBackground, + scale, + width: paperWidth, + height: paperHeight, + margin, + pageRanges, + preferCSSPageSize, + omitBackground, + timeout, + } = this._getPDFOptions(options); + + if (omitBackground) { + await this.#setTransparentBackgroundColor(); + } + + const printCommandPromise = this.#client.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, + }); + + const result = await waitWithTimeout( + printCommandPromise, + 'Page.printToPDF', + timeout + ); + + if (omitBackground) { + await this.#resetDefaultBackgroundColor(); + } + + assert(result.stream, '`stream` is missing from `Page.printToPDF'); + return getReadableFromProtocolStream(this.#client, 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 title(): Promise<string> { + return this.mainFrame().title(); + } + + override async close( + options: {runBeforeUnload?: boolean} = {runBeforeUnload: undefined} + ): Promise<void> { + const connection = this.#client.connection(); + assert( + connection, + 'Protocol error: Connection closed. Most likely the page has been closed.' + ); + const runBeforeUnload = !!options.runBeforeUnload; + if (runBeforeUnload) { + await this.#client.send('Page.close'); + } else { + await connection.send('Target.closeTarget', { + targetId: this.#target._targetId, + }); + await this.#target._isClosedPromise; + } + } + + override isClosed(): boolean { + return this.#closed; + } + + override get mouse(): Mouse { + return this.#mouse; + } + + override click( + selector: string, + options: Readonly<ClickOptions> = {} + ): Promise<void> { + return this.mainFrame().click(selector, options); + } + + override focus(selector: string): Promise<void> { + return this.mainFrame().focus(selector); + } + + override hover(selector: string): Promise<void> { + return this.mainFrame().hover(selector); + } + + override select(selector: string, ...values: string[]): Promise<string[]> { + return this.mainFrame().select(selector, ...values); + } + + override tap(selector: string): Promise<void> { + return this.mainFrame().tap(selector); + } + + override type( + selector: string, + text: string, + options?: {delay: number} + ): Promise<void> { + return this.mainFrame().type(selector, text, options); + } + + override waitForTimeout(milliseconds: number): Promise<void> { + return this.mainFrame().waitForTimeout(milliseconds); + } + + override async waitForSelector<Selector extends string>( + selector: Selector, + options: WaitForSelectorOptions = {} + ): Promise<ElementHandle<NodeFor<Selector>> | null> { + return await this.mainFrame().waitForSelector(selector, options); + } + + override waitForXPath( + xpath: string, + options: WaitForSelectorOptions = {} + ): Promise<ElementHandle<Node> | null> { + return this.mainFrame().waitForXPath(xpath, options); + } + + override 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')) + * ); + * ``` + */ + override waitForDevicePrompt( + options: WaitTimeoutOptions = {} + ): Promise<DeviceRequestPrompt> { + return this.mainFrame().waitForDevicePrompt(options); + } +} + +const supportedMetrics = new Set<string>([ + 'Timestamp', + 'Documents', + 'Frames', + 'JSEventListeners', + 'Nodes', + 'LayoutCount', + 'RecalcStyleCount', + 'LayoutDuration', + 'RecalcStyleDuration', + 'ScriptDuration', + 'TaskDuration', + 'JSHeapUsedSize', + 'JSHeapTotalSize', +]); 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..941f762c82 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/PierceQueryHandler.ts @@ -0,0 +1,39 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +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/PredefinedNetworkConditions.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/PredefinedNetworkConditions.ts new file mode 100644 index 0000000000..69ee56ed68 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/PredefinedNetworkConditions.ts @@ -0,0 +1,59 @@ +/** + * Copyright 2021 Google Inc. All rights reserved. + * + * 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. + */ + +import {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/common/Product.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Product.ts new file mode 100644 index 0000000000..58a62fad3e --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Product.ts @@ -0,0 +1,21 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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. + */ + +/** + * 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..068ec173f0 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Puppeteer.ts @@ -0,0 +1,147 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * 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. + */ + +import {Browser} from '../api/Browser.js'; + +import { + BrowserConnectOptions, + _connectToCDPBrowser, +} from './BrowserConnector.js'; +import {ConnectionTransport} from './ConnectionTransport.js'; +import {CustomQueryHandler, customQueryHandlers} from './CustomQueryHandler.js'; + +/** + * Settings that are common to the Puppeteer class, regardless of environment. + * + * @internal + */ +export interface CommonPuppeteerSettings { + isPuppeteerCore: boolean; +} +/** + * @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>; +} + +/** + * 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 _connectToCDPBrowser(options); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/PuppeteerViewport.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/PuppeteerViewport.ts new file mode 100644 index 0000000000..953b327c01 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/PuppeteerViewport.ts @@ -0,0 +1,55 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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. + */ +/** + * + * Sets the viewport of the page. + * @public + */ +export interface Viewport { + /** + * The page width in pixels. + */ + width: number; + /** + * The page height in pixels. + */ + 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 set the deviceScaleFactor 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/QueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/QueryHandler.ts new file mode 100644 index 0000000000..975bee4530 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/QueryHandler.ts @@ -0,0 +1,226 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +import {ElementHandle} from '../api/ElementHandle.js'; +import type PuppeteerUtil from '../injected/injected.js'; +import {assert} from '../util/assert.js'; +import {isErrorLike} from '../util/ErrorLike.js'; +import {interpolateFunction, stringifyFunction} from '../util/Function.js'; + +import type {Frame} from './Frame.js'; +import {transposeIterableHandle} from './HandleIterator.js'; +import type {WaitForSelectorOptions} from './IsolatedWorld.js'; +import {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.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>> { + const world = element.executionContext()._world; + assert(world); + const 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> { + const world = element.executionContext()._world; + assert(world); + const result = await element.evaluateHandle( + this._querySelector, + selector, + LazyArg.create(context => { + return context.puppeteerUtil; + }) + ); + if (!(result instanceof ElementHandle)) { + await result.dispose(); + return null; + } + return result; + } + + /** + * 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; + let element: ElementHandle<Node> | undefined; + if (!(elementOrFrame instanceof ElementHandle)) { + frame = elementOrFrame; + } else { + frame = elementOrFrame.frame; + element = await frame.worlds[PUPPETEER_WORLD].adoptHandle(elementOrFrame); + } + + const {visible = false, hidden = false, timeout, signal} = options; + + try { + signal?.throwIfAborted(); + + const handle = await frame.worlds[PUPPETEER_WORLD].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) { + await handle.dispose(); + throw signal.reason; + } + + if (!(handle instanceof ElementHandle)) { + await handle.dispose(); + return null; + } + return frame.worlds[MAIN_WORLD].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; + } finally { + if (element) { + await element.dispose(); + } + } + } +} 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..cb0c039530 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/ScriptInjector.ts @@ -0,0 +1,49 @@ +import {source as injectedSource} from '../generated/injected.js'; + +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) { + 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..4dbb71046e --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/SecurityDetails.ts @@ -0,0 +1,88 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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. + */ + +import {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/Target.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Target.ts new file mode 100644 index 0000000000..fd9b5f9f27 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Target.ts @@ -0,0 +1,285 @@ +/** + * Copyright 2019 Google Inc. All rights reserved. + * + * 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. + */ + +import {Protocol} from 'devtools-protocol'; + +import type {Browser, IsPageTargetCallback} from '../api/Browser.js'; +import type {BrowserContext} from '../api/BrowserContext.js'; +import {Page, PageEmittedEvents} from '../api/Page.js'; + +import {CDPSession} from './Connection.js'; +import {CDPPage} from './Page.js'; +import {Viewport} from './PuppeteerViewport.js'; +import {TargetManager} from './TargetManager.js'; +import {TaskQueue} from './TaskQueue.js'; +import {WebWorker} from './WebWorker.js'; + +/** + * 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 class Target { + #browserContext: BrowserContext; + #session?: CDPSession; + #targetInfo: Protocol.Target.TargetInfo; + #sessionFactory: (isAutoAttachEmulated: boolean) => Promise<CDPSession>; + #ignoreHTTPSErrors: boolean; + #defaultViewport?: Viewport; + #pagePromise?: Promise<Page>; + #workerPromise?: Promise<WebWorker>; + #screenshotTaskQueue: TaskQueue; + + /** + * @internal + */ + _initializedPromise: Promise<boolean>; + /** + * @internal + */ + _initializedCallback!: (x: boolean) => void; + /** + * @internal + */ + _isClosedPromise: Promise<void>; + /** + * @internal + */ + _closedCallback!: () => void; + /** + * @internal + */ + _isInitialized: boolean; + /** + * @internal + */ + _targetId: string; + /** + * @internal + */ + _isPageTargetCallback: IsPageTargetCallback; + + #targetManager: TargetManager; + + /** + * @internal + */ + constructor( + targetInfo: Protocol.Target.TargetInfo, + session: CDPSession | undefined, + browserContext: BrowserContext, + targetManager: TargetManager, + sessionFactory: (isAutoAttachEmulated: boolean) => Promise<CDPSession>, + ignoreHTTPSErrors: boolean, + defaultViewport: Viewport | null, + screenshotTaskQueue: TaskQueue, + isPageTargetCallback: IsPageTargetCallback + ) { + this.#session = session; + this.#targetManager = targetManager; + this.#targetInfo = targetInfo; + this.#browserContext = browserContext; + this._targetId = targetInfo.targetId; + this.#sessionFactory = sessionFactory; + this.#ignoreHTTPSErrors = ignoreHTTPSErrors; + this.#defaultViewport = defaultViewport ?? undefined; + this.#screenshotTaskQueue = screenshotTaskQueue; + this._isPageTargetCallback = isPageTargetCallback; + this._initializedPromise = new Promise<boolean>(fulfill => { + return (this._initializedCallback = fulfill); + }).then(async success => { + if (!success) { + return false; + } + const opener = this.opener(); + if (!opener || !opener.#pagePromise || this.type() !== 'page') { + return true; + } + const openerPage = await opener.#pagePromise; + if (!openerPage.listenerCount(PageEmittedEvents.Popup)) { + return true; + } + const popupPage = await this.page(); + openerPage.emit(PageEmittedEvents.Popup, popupPage); + return true; + }); + this._isClosedPromise = new Promise<void>(fulfill => { + return (this._closedCallback = fulfill); + }); + this._isInitialized = + !this._isPageTargetCallback(this.#targetInfo) || + this.#targetInfo.url !== ''; + if (this._isInitialized) { + this._initializedCallback(true); + } + } + + /** + * @internal + */ + _session(): CDPSession | undefined { + return this.#session; + } + + /** + * Creates a Chrome Devtools Protocol session attached to the target. + */ + createCDPSession(): Promise<CDPSession> { + return this.#sessionFactory(false); + } + + /** + * @internal + */ + _targetManager(): TargetManager { + return this.#targetManager; + } + + /** + * @internal + */ + _getTargetInfo(): Protocol.Target.TargetInfo { + return this.#targetInfo; + } + + /** + * If the target is not of type `"page"` or `"background_page"`, returns `null`. + */ + async page(): Promise<Page | null> { + if (this._isPageTargetCallback(this.#targetInfo) && !this.#pagePromise) { + this.#pagePromise = ( + this.#session + ? Promise.resolve(this.#session) + : this.#sessionFactory(true) + ).then(client => { + return CDPPage._create( + client, + this, + this.#ignoreHTTPSErrors, + this.#defaultViewport ?? null, + this.#screenshotTaskQueue + ); + }); + } + return (await this.#pagePromise) ?? null; + } + + /** + * If the target is not of type `"service_worker"` or `"shared_worker"`, returns `null`. + */ + async worker(): Promise<WebWorker | null> { + if ( + this.#targetInfo.type !== 'service_worker' && + this.#targetInfo.type !== 'shared_worker' + ) { + return null; + } + if (!this.#workerPromise) { + // TODO(einbinder): Make workers send their console logs. + this.#workerPromise = ( + this.#session + ? Promise.resolve(this.#session) + : this.#sessionFactory(false) + ).then(client => { + return new WebWorker( + client, + this.#targetInfo.url, + () => {} /* consoleAPICalled */, + () => {} /* exceptionThrown */ + ); + }); + } + return this.#workerPromise; + } + + url(): string { + return this.#targetInfo.url; + } + + /** + * Identifies what kind of target this is. + * + * @remarks + * + * See {@link https://developer.chrome.com/extensions/background_pages | docs} for more info about background pages. + */ + type(): + | 'page' + | 'background_page' + | 'service_worker' + | 'shared_worker' + | 'other' + | 'browser' + | 'webview' { + const type = this.#targetInfo.type; + if ( + type === 'page' || + type === 'background_page' || + type === 'service_worker' || + type === 'shared_worker' || + type === 'browser' || + type === 'webview' + ) { + return type; + } + return 'other'; + } + + /** + * Get the browser the target belongs to. + */ + browser(): Browser { + return this.#browserContext.browser(); + } + + /** + * Get the browser context the target belongs to. + */ + browserContext(): BrowserContext { + return this.#browserContext; + } + + /** + * Get the target that opened this target. Top-level targets return `null`. + */ + opener(): Target | undefined { + const {openerId} = this.#targetInfo; + if (!openerId) { + return; + } + return this.browser()._targets.get(openerId); + } + + /** + * @internal + */ + _targetInfoChanged(targetInfo: Protocol.Target.TargetInfo): void { + this.#targetInfo = targetInfo; + + if ( + !this._isInitialized && + (!this._isPageTargetCallback(this.#targetInfo) || + this.#targetInfo.url !== '') + ) { + this._isInitialized = true; + this._initializedCallback(true); + return; + } + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/TargetManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/TargetManager.ts new file mode 100644 index 0000000000..4f69b72ba9 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/TargetManager.ts @@ -0,0 +1,72 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +import {Protocol} from 'devtools-protocol'; + +import {CDPSession} from './Connection.js'; +import {EventEmitter} from './EventEmitter.js'; +import {Target} from './Target.js'; + +/** + * @internal + */ +export type TargetFactory = ( + targetInfo: Protocol.Target.TargetInfo, + session?: CDPSession +) => Target; + +/** + * @internal + */ +export type TargetInterceptor = ( + createdTarget: Target, + parentTarget: Target | null +) => void; + +/** + * 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 { + getAvailableTargets(): Map<string, Target>; + initialize(): Promise<void>; + dispose(): void; + addTargetInterceptor( + session: CDPSession, + interceptor: TargetInterceptor + ): void; + removeTargetInterceptor( + session: CDPSession, + interceptor: TargetInterceptor + ): void; +} + +/** + * @internal + */ +export const enum TargetManagerEmittedEvents { + TargetDiscovered = 'targetDiscovered', + TargetAvailable = 'targetAvailable', + TargetGone = 'targetGone', + TargetChanged = 'targetChanged', +} 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..97cfe7c769 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/TaskQueue.ts @@ -0,0 +1,39 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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. + */ + +/** + * @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..02ecdddca8 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/TextQueryHandler.ts @@ -0,0 +1,30 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +import {QueryHandler, 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..97acc70147 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/TimeoutSettings.ts @@ -0,0 +1,55 @@ +/** + * Copyright 2019 Google Inc. All rights reserved. + * + * 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. + */ + +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/Tracing.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Tracing.ts new file mode 100644 index 0000000000..e76f3a15d4 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Tracing.ts @@ -0,0 +1,144 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * 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. + */ +import {assert} from '../util/assert.js'; +import {isErrorLike} from '../util/ErrorLike.js'; + +import {CDPSession} from './Connection.js'; +import {getReadableAsBuffer, getReadableFromProtocolStream} from './util.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; + } + + /** + * 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> { + let resolve: (value: Buffer | undefined) => void; + let reject: (err: Error) => void; + const contentPromise = new Promise<Buffer | undefined>((x, y) => { + resolve = x; + reject = y; + }); + this.#client.once('Tracing.tracingComplete', async event => { + try { + const readable = await getReadableFromProtocolStream( + this.#client, + event.stream + ); + const buffer = await getReadableAsBuffer(readable, this.#path); + resolve(buffer ?? undefined); + } catch (error) { + if (isErrorLike(error)) { + reject(error); + } else { + reject(new Error(`Unknown error: ${error}`)); + } + } + }); + await this.#client.send('Tracing.end'); + this.#recording = false; + return contentPromise; + } +} 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..f6a042e5ce --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/USKeyboardLayout.ts @@ -0,0 +1,681 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * 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. + */ + +/** + * @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/WaitTask.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/WaitTask.ts new file mode 100644 index 0000000000..30155d4a50 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/WaitTask.ts @@ -0,0 +1,260 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +import {ElementHandle} from '../api/ElementHandle.js'; +import {JSHandle} from '../api/JSHandle.js'; +import type {Poller} from '../injected/Poller.js'; +import {createDeferredPromise} from '../util/DeferredPromise.js'; +import {stringifyFunction} from '../util/Function.js'; + +import {TimeoutError} from './Errors.js'; +import {IsolatedWorld} from './IsolatedWorld.js'; +import {LazyArg} from './LazyArg.js'; +import {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: IsolatedWorld; + #polling: 'raf' | 'mutation' | number; + #root?: ElementHandle<Node>; + + #fn: string; + #args: unknown[]; + + #timeout?: NodeJS.Timeout; + + #result = createDeferredPromise<HandleFor<T>>(); + + #poller?: JSHandle<Poller<T>>; + #signal?: AbortSignal; + + constructor( + world: IsolatedWorld, + 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.#timeout = setTimeout(() => { + void this.terminate( + new TimeoutError(`Waiting failed: ${options.timeout}ms exceeded`) + ); + }, options.timeout); + } + + void this.rerun(); + } + + get result(): Promise<HandleFor<T>> { + return this.#result; + } + + async rerun(): Promise<void> { + 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) { + const badError = this.getBadError(error); + if (badError) { + await this.terminate(badError); + } + } + } + + async terminate(error?: unknown): Promise<void> { + this.#world.taskManager.delete(this); + + if (this.#timeout) { + 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): unknown { + if (error instanceof 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; + } + } + + return 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/WebWorker.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/WebWorker.ts new file mode 100644 index 0000000000..fface119ad --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/WebWorker.ts @@ -0,0 +1,179 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * 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. + */ +import {Protocol} from 'devtools-protocol'; + +import {createDeferredPromise} from '../util/DeferredPromise.js'; + +import {CDPSession} from './Connection.js'; +import {ConsoleMessageType} from './ConsoleMessage.js'; +import {EventEmitter} from './EventEmitter.js'; +import {ExecutionContext} from './ExecutionContext.js'; +import {CDPJSHandle} from './JSHandle.js'; +import {EvaluateFunc, HandleFor} from './types.js'; +import {debugError} from './util.js'; + +/** + * @internal + */ +export type ConsoleAPICalledCallback = ( + eventType: ConsoleMessageType, + handles: CDPJSHandle[], + trace: Protocol.Runtime.StackTrace +) => void; + +/** + * @internal + */ +export type ExceptionThrownCallback = ( + details: Protocol.Runtime.ExceptionDetails +) => void; + +/** + * 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 class WebWorker extends EventEmitter { + #executionContext = createDeferredPromise<ExecutionContext>(); + + #client: CDPSession; + #url: string; + + /** + * @internal + */ + constructor( + client: CDPSession, + url: string, + consoleAPICalled: ConsoleAPICalledCallback, + exceptionThrown: ExceptionThrownCallback + ) { + super(); + this.#client = client; + this.#url = url; + + this.#client.once('Runtime.executionContextCreated', async event => { + const context = new ExecutionContext(client, event.context); + this.#executionContext.resolve(context); + }); + this.#client.on('Runtime.consoleAPICalled', async event => { + const context = await this.#executionContext; + return consoleAPICalled( + event.type, + event.args.map((object: Protocol.Runtime.RemoteObject) => { + return new CDPJSHandle(context, object); + }), + event.stackTrace + ); + }); + this.#client.on('Runtime.exceptionThrown', exception => { + return exceptionThrown(exception.exceptionDetails); + }); + + // This might fail if the target is closed before we receive all execution contexts. + this.#client.send('Runtime.enable').catch(debugError); + } + + /** + * @internal + */ + async executionContext(): Promise<ExecutionContext> { + return this.#executionContext; + } + + /** + * The URL of this web worker. + */ + url(): string { + return this.#url; + } + + /** + * The CDP session client the WebWorker belongs to. + */ + get client(): CDPSession { + return this.#client; + } + + /** + * If the function passed to the `worker.evaluate` returns a Promise, then + * `worker.evaluate` would wait for the promise to resolve and return its + * value. If the function passed to the `worker.evaluate` returns a + * non-serializable value, then `worker.evaluate` resolves to `undefined`. + * DevTools Protocol also supports transferring some additional values that + * are not serializable by `JSON`: `-0`, `NaN`, `Infinity`, `-Infinity`, and + * bigint literals. + * Shortcut for `await worker.executionContext()).evaluate(pageFunction, ...args)`. + * + * @param pageFunction - Function to be evaluated in the worker context. + * @param args - Arguments to pass to `pageFunction`. + * @returns Promise which resolves to 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>>> { + const context = await this.#executionContext; + return context.evaluate(pageFunction, ...args); + } + + /** + * The only difference between `worker.evaluate` and `worker.evaluateHandle` + * is that `worker.evaluateHandle` returns in-page object (JSHandle). If the + * function passed to the `worker.evaluateHandle` returns a `Promise`, then + * `worker.evaluateHandle` would wait for the promise to resolve and return + * its value. Shortcut for + * `await worker.executionContext()).evaluateHandle(pageFunction, ...args)` + * + * @param pageFunction - Function to be evaluated in the page context. + * @param args - Arguments to pass to `pageFunction`. + * @returns Promise which resolves to the return value of `pageFunction`. + */ + async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params> + >( + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + const context = await this.#executionContext; + return context.evaluateHandle(pageFunction, ...args); + } +} 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..34f824d542 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/XPathQueryHandler.ts @@ -0,0 +1,30 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +import {QueryHandler, QuerySelectorAll} from './QueryHandler.js'; + +/** + * @internal + */ +export class XPathQueryHandler extends QueryHandler { + static override querySelectorAll: QuerySelectorAll = ( + element, + selector, + {xpathQuerySelectorAll} + ) => { + return xpathQuerySelectorAll(element, selector); + }; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/BidiOverCDP.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/BidiOverCDP.ts new file mode 100644 index 0000000000..1f965c56ab --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/BidiOverCDP.ts @@ -0,0 +1,190 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +import * as BidiMapper from 'chromium-bidi/lib/cjs/bidiMapper/bidiMapper.js'; +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; +import type {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js'; + +import {CDPSession, Connection as CDPPPtrConnection} from '../Connection.js'; +import {Handler} from '../EventEmitter.js'; + +import {Connection as BidiPPtrConnection} from './Connection.js'; + +type CdpEvents = { + [Property in keyof ProtocolMapping.Events]: ProtocolMapping.Events[Property][0]; +}; + +/** + * @internal + */ +export async function connectBidiOverCDP( + cdp: CDPPPtrConnection +): Promise<BidiPPtrConnection> { + 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(); + }, + 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 BidiPPtrConnection(pptrTransport); + const bidiServer = await BidiMapper.BidiServer.createAndStart( + transportBiDi, + cdpConnectionAdapter, + '' + ); + return pptrBiDiConnection; +} + +/** + * Manages CDPSessions for BidiServer. + * @internal + */ +class CDPConnectionAdapter { + #cdp: CDPPPtrConnection; + #adapters = new Map<CDPSession, CDPClientAdapter<CDPSession>>(); + #browser: CDPClientAdapter<CDPPPtrConnection>; + + constructor(cdp: CDPPPtrConnection) { + this.#cdp = cdp; + this.#browser = new CDPClientAdapter(cdp); + } + + browserClient(): CDPClientAdapter<CDPPPtrConnection> { + return this.#browser; + } + + 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); + this.#adapters.set(session, adapter); + return adapter; + } + return this.#adapters.get(session)!; + } + + close() { + this.#browser.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 Pick<CDPPPtrConnection, 'send' | 'on' | 'off'>> + extends BidiMapper.EventEmitter<CdpEvents> + implements BidiMapper.CdpClient +{ + #closed = false; + #client: T; + + constructor(client: T) { + super(); + this.#client = client; + this.#client.on('*', this.#forwardMessage as Handler<any>); + } + + #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; + } +} + +/** + * 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<any> + implements BidiMapper.BidiTransport +{ + #onMessage: ( + message: Bidi.Message.RawCommandRequest + ) => Promise<void> | void = async ( + _m: Bidi.Message.RawCommandRequest + ): Promise<void> => { + return; + }; + + emitMessage(message: Bidi.Message.RawCommandRequest) { + void this.#onMessage(message); + } + + setOnMessage( + onMessage: (message: Bidi.Message.RawCommandRequest) => Promise<void> | void + ): void { + this.#onMessage = onMessage; + } + + async sendMessage(message: Bidi.Message.OutgoingMessage): Promise<void> { + this.emit('bidiResponse', message); + } + + close() { + this.#onMessage = async ( + _m: Bidi.Message.RawCommandRequest + ): Promise<void> => { + return; + }; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/Browser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/Browser.ts new file mode 100644 index 0000000000..9741ce7129 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/Browser.ts @@ -0,0 +1,89 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +import {ChildProcess} from 'child_process'; + +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import { + Browser as BrowserBase, + BrowserCloseCallback, + BrowserContextOptions, +} from '../../api/Browser.js'; +import {BrowserContext as BrowserContextBase} from '../../api/BrowserContext.js'; +import {Viewport} from '../PuppeteerViewport.js'; + +import {BrowserContext} from './BrowserContext.js'; +import {Connection} from './Connection.js'; + +/** + * @internal + */ +export class Browser extends BrowserBase { + static async create(opts: Options): Promise<Browser> { + // TODO: await until the connection is established. + try { + await opts.connection.send('session.new', {}); + } catch {} + await opts.connection.send('session.subscribe', { + events: [ + 'browsingContext.contextCreated', + ] as Bidi.Session.SubscribeParametersEvent[], + }); + return new Browser(opts); + } + + #process?: ChildProcess; + #closeCallback?: BrowserCloseCallback; + #connection: Connection; + #defaultViewport: Viewport | null; + + constructor(opts: Options) { + super(); + this.#process = opts.process; + this.#closeCallback = opts.closeCallback; + this.#connection = opts.connection; + this.#defaultViewport = opts.defaultViewport; + } + + override async close(): Promise<void> { + this.#connection.dispose(); + await this.#closeCallback?.call(null); + } + + override isConnected(): boolean { + return !this.#connection.closed; + } + + override process(): ChildProcess | null { + return this.#process ?? null; + } + + override async createIncognitoBrowserContext( + _options?: BrowserContextOptions + ): Promise<BrowserContextBase> { + return new BrowserContext(this.#connection, { + defaultViewport: this.#defaultViewport, + }); + } +} + +interface Options { + process?: ChildProcess; + closeCallback?: BrowserCloseCallback; + connection: Connection; + defaultViewport: Viewport | null; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/BrowserContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/BrowserContext.ts new file mode 100644 index 0000000000..92950b87b0 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/BrowserContext.ts @@ -0,0 +1,59 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +import {BrowserContext as BrowserContextBase} from '../../api/BrowserContext.js'; +import {Page as PageBase} from '../../api/Page.js'; +import {Viewport} from '../PuppeteerViewport.js'; + +import {Connection} from './Connection.js'; +import {Context} from './Context.js'; +import {Page} from './Page.js'; + +interface BrowserContextOptions { + defaultViewport: Viewport | null; +} + +/** + * @internal + */ +export class BrowserContext extends BrowserContextBase { + #connection: Connection; + #defaultViewport: Viewport | null; + + constructor(connection: Connection, options: BrowserContextOptions) { + super(); + this.#connection = connection; + this.#defaultViewport = options.defaultViewport; + } + + override async newPage(): Promise<PageBase> { + const {result} = await this.#connection.send('browsingContext.create', { + type: 'tab', + }); + const context = this.#connection.context(result.context) as Context; + const page = new Page(context); + if (this.#defaultViewport) { + try { + await page.setViewport(this.#defaultViewport); + } catch { + // No support for setViewport in Firefox. + } + } + return page; + } + + override async close(): Promise<void> {} +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/Connection.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/Connection.ts new file mode 100644 index 0000000000..5f26ee00fb --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/Connection.ts @@ -0,0 +1,215 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * 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. + */ + +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {CallbackRegistry} from '../Connection.js'; +import {ConnectionTransport} from '../ConnectionTransport.js'; +import {debug} from '../Debug.js'; +import {EventEmitter} from '../EventEmitter.js'; + +import {Context} from './Context.js'; + +const debugProtocolSend = debug('puppeteer:webDriverBiDi:SEND ►'); +const debugProtocolReceive = debug('puppeteer:webDriverBiDi:RECV ◀'); + +/** + * @internal + */ +interface Commands { + 'script.evaluate': { + params: Bidi.Script.EvaluateParameters; + returnType: Bidi.Script.EvaluateResult; + }; + 'script.callFunction': { + params: Bidi.Script.CallFunctionParameters; + returnType: Bidi.Script.CallFunctionResult; + }; + 'script.disown': { + params: Bidi.Script.DisownParameters; + returnType: Bidi.Script.DisownResult; + }; + + 'browsingContext.create': { + params: Bidi.BrowsingContext.CreateParameters; + returnType: Bidi.BrowsingContext.CreateResult; + }; + 'browsingContext.close': { + params: Bidi.BrowsingContext.CloseParameters; + returnType: Bidi.BrowsingContext.CloseResult; + }; + 'browsingContext.navigate': { + params: Bidi.BrowsingContext.NavigateParameters; + returnType: Bidi.BrowsingContext.NavigateResult; + }; + 'browsingContext.print': { + params: Bidi.BrowsingContext.PrintParameters; + returnType: Bidi.BrowsingContext.PrintResult; + }; + 'browsingContext.captureScreenshot': { + params: Bidi.BrowsingContext.CaptureScreenshotParameters; + returnType: Bidi.BrowsingContext.CaptureScreenshotResult; + }; + + 'session.new': { + params: {capabilities?: Record<any, unknown>}; // TODO: Update Types in chromium bidi + returnType: {sessionId: string}; + }; + 'session.status': { + params: object; + returnType: Bidi.Session.StatusResult; + }; + 'session.subscribe': { + params: Bidi.Session.SubscribeParameters; + returnType: Bidi.Session.SubscribeResult; + }; + 'session.unsubscribe': { + params: Bidi.Session.SubscribeParameters; + returnType: Bidi.Session.UnsubscribeResult; + }; + 'cdp.sendCommand': { + params: Bidi.CDP.SendCommandParams; + returnType: Bidi.CDP.SendCommandResult; + }; + 'cdp.getSession': { + params: Bidi.CDP.GetSessionParams; + returnType: Bidi.CDP.GetSessionResult; + }; +} + +/** + * @internal + */ +export class Connection extends EventEmitter { + #transport: ConnectionTransport; + #delay: number; + #timeout? = 0; + #closed = false; + #callbacks = new CallbackRegistry(); + #contexts: Map<string, Context> = new Map(); + + constructor(transport: ConnectionTransport, delay = 0, timeout?: number) { + super(); + 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); + } + + get closed(): boolean { + return this.#closed; + } + + context(contextId: string): Context | null { + return this.#contexts.get(contextId) || null; + } + + send<T extends keyof Commands>( + method: T, + params: Commands[T]['params'] + ): Promise<Commands[T]['returnType']> { + return this.#callbacks.create(method, this.#timeout, id => { + const stringifiedMessage = JSON.stringify({ + id, + method, + params, + } as Bidi.Message.CommandRequest); + debugProtocolSend(stringifiedMessage); + this.#transport.send(stringifiedMessage); + }) as Promise<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 = JSON.parse(message) as + | Bidi.Message.CommandResponse + | Bidi.Message.EventMessage; + + if ('id' in object) { + if ('error' in object) { + this.#callbacks.reject( + object.id, + createProtocolError(object), + object.message + ); + } else { + this.#callbacks.resolve(object.id, object); + } + } else { + this.#handleSpecialEvents(object); + this.#maybeEmitOnContext(object); + this.emit(object.method, object.params); + } + } + + #maybeEmitOnContext(event: Bidi.Message.EventMessage) { + let context: Context | undefined; + // Context specific events + if ('context' in event.params && event.params.context) { + context = this.#contexts.get(event.params.context); + // `log.entryAdded` specific context + } else if ('source' in event.params && event.params.source.context) { + context = this.#contexts.get(event.params.source.context); + } + context?.emit(event.method, event.params); + } + + #handleSpecialEvents(event: Bidi.Message.EventMessage) { + switch (event.method) { + case 'browsingContext.contextCreated': + this.#contexts.set( + event.params.context, + new Context(this, event.params) + ); + } + } + + #onClose(): void { + if (this.#closed) { + return; + } + this.#closed = true; + this.#transport.onmessage = undefined; + this.#transport.onclose = undefined; + this.#callbacks.clear(); + } + + dispose(): void { + this.#onClose(); + this.#transport.close(); + } +} + +/** + * @internal + */ +function createProtocolError(object: Bidi.Message.ErrorResult): string { + let message = `${object.error} ${object.message}`; + if (object.stacktrace) { + message += ` ${object.stacktrace}`; + } + return message; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/Context.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/Context.ts new file mode 100644 index 0000000000..4d3711d6aa --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/Context.ts @@ -0,0 +1,282 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {HTTPResponse} from '../../api/HTTPResponse.js'; +import {WaitForOptions} from '../../api/Page.js'; +import {assert} from '../../util/assert.js'; +import {stringifyFunction} from '../../util/Function.js'; +import {ProtocolMapping} from '../Connection.js'; +import {ProtocolError, TimeoutError} from '../Errors.js'; +import {EventEmitter} from '../EventEmitter.js'; +import {PuppeteerLifeCycleEvent} from '../LifecycleWatcher.js'; +import {TimeoutSettings} from '../TimeoutSettings.js'; +import {EvaluateFunc, HandleFor} from '../types.js'; +import {isString, setPageContent, waitWithTimeout} from '../util.js'; + +import {Connection} from './Connection.js'; +import {ElementHandle} from './ElementHandle.js'; +import {JSHandle} from './JSHandle.js'; +import {BidiSerializer} from './Serializer.js'; + +/** + * @internal + */ +const lifeCycleToReadinessState = new Map< + PuppeteerLifeCycleEvent, + Bidi.BrowsingContext.ReadinessState +>([ + ['load', 'complete'], + ['domcontentloaded', 'interactive'], +]); + +/** + * @internal + */ +const lifeCycleToSubscribedEvent = new Map<PuppeteerLifeCycleEvent, string>([ + ['load', 'browsingContext.load'], + ['domcontentloaded', 'browsingContext.domContentLoaded'], +]); + +/** + * @internal + */ +export class Context extends EventEmitter { + #connection: Connection; + #url: string; + _contextId: string; + _timeoutSettings = new TimeoutSettings(); + + constructor(connection: Connection, result: Bidi.BrowsingContext.Info) { + super(); + this.#connection = connection; + this._contextId = result.context; + this.#url = result.url; + } + + get connection(): Connection { + return this.#connection; + } + + get id(): string { + return this._contextId; + } + + async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params> + >( + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + return 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 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>>> { + let responsePromise; + const resultOwnership = returnByValue ? 'none' : 'root'; + if (isString(pageFunction)) { + responsePromise = this.#connection.send('script.evaluate', { + expression: pageFunction, + target: {context: this._contextId}, + resultOwnership, + awaitPromise: true, + }); + } else { + responsePromise = this.#connection.send('script.callFunction', { + functionDeclaration: stringifyFunction(pageFunction), + arguments: await Promise.all( + args.map(arg => { + return BidiSerializer.serialize(arg, this); + }) + ), + target: {context: this._contextId}, + resultOwnership, + awaitPromise: true, + }); + } + + const {result} = await responsePromise; + + if ('type' in result && result.type === 'exception') { + throw new Error(result.exceptionDetails.text); + } + + return returnByValue + ? BidiSerializer.deserialize(result.result) + : getBidiHandle(this, result.result); + } + + async goto( + url: string, + options: WaitForOptions & { + referer?: string | undefined; + referrerPolicy?: string | undefined; + } = {} + ): Promise<HTTPResponse | null> { + const { + waitUntil = 'load', + timeout = this._timeoutSettings.navigationTimeout(), + } = options; + + const readinessState = lifeCycleToReadinessState.get( + getWaitUntilSingle(waitUntil) + ) as Bidi.BrowsingContext.ReadinessState; + + try { + const response = await waitWithTimeout( + this.connection.send('browsingContext.navigate', { + url: url, + context: this.id, + wait: readinessState, + }), + 'Navigation', + timeout + ); + this.#url = response.result.url; + + return null; + } catch (error) { + if (error instanceof ProtocolError) { + error.message += ` at ${url}`; + } else if (error instanceof TimeoutError) { + error.message = 'Navigation timeout of ' + timeout + ' ms exceeded'; + } + throw error; + } + } + + url(): string { + return this.#url; + } + + async setContent( + html: string, + options: WaitForOptions | undefined = {} + ): Promise<void> { + const { + waitUntil = 'load', + timeout = this._timeoutSettings.navigationTimeout(), + } = options; + + const waitUntilCommand = lifeCycleToSubscribedEvent.get( + getWaitUntilSingle(waitUntil) + ) as string; + + await Promise.all([ + setPageContent(this, html), + waitWithTimeout( + new Promise<void>(resolve => { + this.once(waitUntilCommand, () => { + resolve(); + }); + }), + waitUntilCommand, + timeout + ), + ]); + } + + async sendCDPCommand( + method: keyof ProtocolMapping.Commands, + params: object = {} + ): Promise<unknown> { + const session = await this.#connection.send('cdp.getSession', { + context: this._contextId, + }); + // TODO: remove any once chromium-bidi types are updated. + const sessionId = (session.result as any).cdpSession; + return await this.#connection.send('cdp.sendCommand', { + cdpMethod: method, + cdpParams: params, + cdpSession: sessionId, + }); + } +} + +/** + * @internal + */ +function getWaitUntilSingle( + event: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[] +): Extract<PuppeteerLifeCycleEvent, 'load' | 'domcontentloaded'> { + if (Array.isArray(event) && event.length > 1) { + throw new Error('BiDi support only single `waitUntil` argument'); + } + const waitUntilSingle = Array.isArray(event) + ? (event.find(lifecycle => { + return lifecycle === 'domcontentloaded' || lifecycle === 'load'; + }) as PuppeteerLifeCycleEvent) + : event; + + if ( + waitUntilSingle === 'networkidle0' || + waitUntilSingle === 'networkidle2' + ) { + throw new Error(`BiDi does not support 'waitUntil' ${waitUntilSingle}`); + } + + assert(waitUntilSingle, `Invalid waitUntil option ${waitUntilSingle}`); + + return waitUntilSingle; +} + +/** + * @internal + */ +export function getBidiHandle( + context: Context, + result: Bidi.CommonDataTypes.RemoteValue +): JSHandle | ElementHandle<Node> { + if (result.type === 'node' || result.type === 'window') { + return new ElementHandle(context, result); + } + return new JSHandle(context, result); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/ElementHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/ElementHandle.ts new file mode 100644 index 0000000000..21e69e3e9b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/ElementHandle.ts @@ -0,0 +1,52 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {ElementHandle as BaseElementHandle} from '../../api/ElementHandle.js'; + +import {Connection} from './Connection.js'; +import {Context} from './Context.js'; +import {JSHandle} from './JSHandle.js'; + +/** + * @internal + */ +export class ElementHandle< + ElementType extends Node = Element +> extends BaseElementHandle<ElementType> { + declare handle: JSHandle<ElementType>; + + constructor(context: Context, remoteValue: Bidi.CommonDataTypes.RemoteValue) { + super(new JSHandle(context, remoteValue)); + } + + context(): Context { + return this.handle.context(); + } + + get connection(): Connection { + return this.handle.connection; + } + + get isPrimitiveValue(): boolean { + return this.handle.isPrimitiveValue; + } + + remoteValue(): Bidi.CommonDataTypes.RemoteValue { + return this.handle.remoteValue(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/JSHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/JSHandle.ts new file mode 100644 index 0000000000..2cd2876622 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/JSHandle.ts @@ -0,0 +1,159 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {ElementHandle} from '../../api/ElementHandle.js'; +import {JSHandle as BaseJSHandle} from '../../api/JSHandle.js'; +import {EvaluateFuncWith, HandleFor, HandleOr} from '../../common/types.js'; + +import {Connection} from './Connection.js'; +import {Context} from './Context.js'; +import {BidiSerializer} from './Serializer.js'; +import {releaseReference} from './utils.js'; + +export class JSHandle<T = unknown> extends BaseJSHandle<T> { + #disposed = false; + #context; + #remoteValue; + + constructor(context: Context, remoteValue: Bidi.CommonDataTypes.RemoteValue) { + super(); + this.#context = context; + this.#remoteValue = remoteValue; + } + + context(): Context { + return this.#context; + } + + get connection(): Connection { + return this.#context.connection; + } + + override get disposed(): boolean { + return this.#disposed; + } + + override async evaluate< + Params extends unknown[], + Func extends EvaluateFuncWith<T, Params> = EvaluateFuncWith<T, Params> + >( + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + return await this.context().evaluate(pageFunction, this, ...args); + } + + override async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFuncWith<T, Params> = EvaluateFuncWith<T, Params> + >( + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + return await this.context().evaluateHandle(pageFunction, this, ...args); + } + + override async getProperty<K extends keyof T>( + propertyName: HandleOr<K> + ): Promise<HandleFor<T[K]>>; + override async getProperty(propertyName: string): Promise<HandleFor<unknown>>; + override 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); + } + + override async getProperties(): Promise<Map<string, BaseJSHandle>> { + // TODO(lightning00blade): Either include return of depth Handles in RemoteValue + // or new BiDi command that returns array of remote value + const keys = await this.evaluate(object => { + return Object.getOwnPropertyNames(object); + }); + const map: Map<string, BaseJSHandle> = new Map(); + const results = await Promise.all( + keys.map(key => { + return this.getProperty(key); + }) + ); + + for (const [key, value] of Object.entries(keys)) { + const handle = results[key as any]; + if (handle) { + map.set(value, handle); + } + } + + return map; + } + + override async jsonValue(): Promise<T> { + const value = BidiSerializer.deserialize(this.#remoteValue); + + if (this.#remoteValue.type !== 'undefined' && value === undefined) { + throw new Error('Could not serialize referenced object'); + } + 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); + } + } + + 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:' + BidiSerializer.deserialize(this.#remoteValue); + } + + return 'JSHandle@' + this.#remoteValue.type; + } + + override get id(): string | undefined { + return 'handle' in this.#remoteValue ? this.#remoteValue.handle : undefined; + } + + remoteValue(): Bidi.CommonDataTypes.RemoteValue { + return this.#remoteValue; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/Page.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/Page.ts new file mode 100644 index 0000000000..524f5ed122 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/Page.ts @@ -0,0 +1,345 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +import type {Readable} from 'stream'; + +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {HTTPResponse} from '../../api/HTTPResponse.js'; +import { + Page as PageBase, + PageEmittedEvents, + ScreenshotOptions, + WaitForOptions, +} from '../../api/Page.js'; +import {isErrorLike} from '../../util/ErrorLike.js'; +import {ConsoleMessage, ConsoleMessageLocation} from '../ConsoleMessage.js'; +import {Handler} from '../EventEmitter.js'; +import {PDFOptions} from '../PDFOptions.js'; +import {Viewport} from '../PuppeteerViewport.js'; +import {EvaluateFunc, HandleFor} from '../types.js'; +import {debugError, waitWithTimeout} from '../util.js'; + +import {Context, getBidiHandle} from './Context.js'; +import {BidiSerializer} from './Serializer.js'; + +/** + * @internal + */ +export class Page extends PageBase { + #context: Context; + #subscribedEvents = new Map<string, Handler<any>>([ + ['log.entryAdded', this.#onLogEntryAdded.bind(this)], + ['browsingContext.load', this.#onLoad.bind(this)], + ['browsingContext.domContentLoaded', this.#onDOMLoad.bind(this)], + ]) as Map<Bidi.Session.SubscribeParametersEvent, Handler>; + #viewport: Viewport | null = null; + + constructor(context: Context) { + super(); + this.#context = context; + + this.#context.connection + .send('session.subscribe', { + events: [ + ...this.#subscribedEvents.keys(), + ] as Bidi.Session.SubscribeParameters['events'], + contexts: [this.#context.id], + }) + .catch(error => { + if (isErrorLike(error) && !error.message.includes('Target closed')) { + throw error; + } + }); + + for (const [event, subscriber] of this.#subscribedEvents) { + this.#context.on(event, subscriber); + } + } + + #onLogEntryAdded(event: Bidi.Log.LogEntry): void { + if (isConsoleLogEntry(event)) { + const args = event.args.map(arg => { + return getBidiHandle(this.#context, arg); + }); + + const text = args + .reduce((value, arg) => { + const parsedValue = arg.isPrimitiveValue + ? BidiSerializer.deserialize(arg.remoteValue()) + : arg.toString(); + return `${value} ${parsedValue}`; + }, '') + .slice(1); + + this.emit( + PageEmittedEvents.Console, + new ConsoleMessage( + event.method as any, + text, + args, + getStackTraceLocations(event.stackTrace) + ) + ); + } else if (isJavaScriptLogEntry(event)) { + let message = event.text ?? ''; + + if (event.stackTrace) { + for (const callFrame of event.stackTrace.callFrames) { + const location = + callFrame.url + + ':' + + callFrame.lineNumber + + ':' + + callFrame.columnNumber; + const functionName = callFrame.functionName || '<anonymous>'; + message += `\n at ${functionName} (${location})`; + } + } + + const error = new Error(message); + error.stack = ''; // Don't capture Puppeteer stacktrace. + + this.emit(PageEmittedEvents.PageError, error); + } else { + debugError( + `Unhandled LogEntry with type "${event.type}", text "${event.text}" and level "${event.level}"` + ); + } + } + + #onLoad(_event: Bidi.BrowsingContext.NavigationInfo): void { + this.emit(PageEmittedEvents.Load); + } + + #onDOMLoad(_event: Bidi.BrowsingContext.NavigationInfo): void { + this.emit(PageEmittedEvents.DOMContentLoaded); + } + + override async close(): Promise<void> { + await this.#context.connection.send('session.unsubscribe', { + events: [...this.#subscribedEvents.keys()], + contexts: [this.#context.id], + }); + + await this.#context.connection.send('browsingContext.close', { + context: this.#context.id, + }); + + for (const [event, subscriber] of this.#subscribedEvents) { + this.#context.off(event, subscriber); + } + } + + override async evaluateHandle< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params> + >( + pageFunction: Func | string, + ...args: Params + ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { + return this.#context.evaluateHandle(pageFunction, ...args); + } + + override async evaluate< + Params extends unknown[], + Func extends EvaluateFunc<Params> = EvaluateFunc<Params> + >( + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + return this.#context.evaluate(pageFunction, ...args); + } + + override async goto( + url: string, + options?: WaitForOptions & { + referer?: string | undefined; + referrerPolicy?: string | undefined; + } + ): Promise<HTTPResponse | null> { + return this.#context.goto(url, options); + } + + override url(): string { + return this.#context.url(); + } + + override setDefaultNavigationTimeout(timeout: number): void { + this.#context._timeoutSettings.setDefaultNavigationTimeout(timeout); + } + + override setDefaultTimeout(timeout: number): void { + this.#context._timeoutSettings.setDefaultTimeout(timeout); + } + + override async setContent( + html: string, + options: WaitForOptions = {} + ): Promise<void> { + await this.#context.setContent(html, options); + } + + override async content(): Promise<string> { + return await this.evaluate(() => { + let retVal = ''; + if (document.doctype) { + retVal = new XMLSerializer().serializeToString(document.doctype); + } + if (document.documentElement) { + retVal += document.documentElement.outerHTML; + } + return retVal; + }); + } + + override async setViewport(viewport: Viewport): Promise<void> { + // TODO: use BiDi commands when available. + const mobile = false; + const width = viewport.width; + const height = viewport.height; + const deviceScaleFactor = 1; + const screenOrientation = {angle: 0, type: 'portraitPrimary'}; + + await this.#context.sendCDPCommand('Emulation.setDeviceMetricsOverride', { + mobile, + width, + height, + deviceScaleFactor, + screenOrientation, + }); + + this.#viewport = viewport; + } + + override viewport(): Viewport | null { + return this.#viewport; + } + + override async pdf(options: PDFOptions = {}): Promise<Buffer> { + const {path = undefined} = options; + const { + printBackground: background, + margin, + landscape, + width, + height, + pageRanges, + scale, + preferCSSPageSize, + timeout, + } = this._getPDFOptions(options, 'cm'); + const {result} = await waitWithTimeout( + this.#context.connection.send('browsingContext.print', { + context: this.#context._contextId, + background, + margin, + orientation: landscape ? 'landscape' : 'portrait', + page: { + width, + height, + }, + pageRanges: pageRanges.split(', '), + scale, + shrinkToFit: !preferCSSPageSize, + }), + 'browsingContext.print', + timeout + ); + + 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 screenshot( + options: ScreenshotOptions & {encoding: 'base64'} + ): Promise<string>; + override screenshot( + options?: ScreenshotOptions & {encoding?: 'binary'} + ): never; + override async screenshot( + options: ScreenshotOptions = {} + ): Promise<Buffer | string> { + const {path = undefined, encoding, ...args} = options; + if (Object.keys(args).length >= 1) { + throw new Error('BiDi only supports "encoding" and "path" options'); + } + + const {result} = await this.#context.connection.send( + 'browsingContext.captureScreenshot', + { + context: this.#context._contextId, + } + ); + + if (encoding === 'base64') { + return result.data; + } + + const buffer = Buffer.from(result.data, 'base64'); + await this._maybeWriteBufferToFile(path, buffer); + + return buffer; + } +} + +function isConsoleLogEntry( + event: Bidi.Log.LogEntry +): event is Bidi.Log.ConsoleLogEntry { + return event.type === 'console'; +} + +function isJavaScriptLogEntry( + event: Bidi.Log.LogEntry +): event is Bidi.Log.JavascriptLogEntry { + return event.type === 'javascript'; +} + +function getStackTraceLocations( + stackTrace?: Bidi.Script.StackTrace +): ConsoleMessageLocation[] { + const stackTraceLocations: ConsoleMessageLocation[] = []; + if (stackTrace) { + for (const callFrame of stackTrace.callFrames) { + stackTraceLocations.push({ + url: callFrame.url, + lineNumber: callFrame.lineNumber, + columnNumber: callFrame.columnNumber, + }); + } + } + return stackTraceLocations; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/Serializer.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/Serializer.ts new file mode 100644 index 0000000000..f28b0e7318 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/Serializer.ts @@ -0,0 +1,273 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {debugError, isDate, isPlainObject, isRegExp} from '../util.js'; + +import {Context} from './Context.js'; +import {ElementHandle} from './ElementHandle.js'; +import {JSHandle} from './JSHandle.js'; + +/** + * @internal + */ +class UnserializableError extends Error {} + +/** + * @internal + */ +export class BidiSerializer { + static serializeNumber(arg: number): Bidi.CommonDataTypes.LocalOrRemoteValue { + let value: Bidi.CommonDataTypes.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.CommonDataTypes.LocalOrRemoteValue { + if (arg === null) { + return { + type: 'null', + }; + } else if (Array.isArray(arg)) { + const parsedArray = arg.map(subArg => { + return BidiSerializer.serializeRemoveValue(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.CommonDataTypes.MappingLocalValue = []; + for (const key in arg) { + parsedObject.push([ + BidiSerializer.serializeRemoveValue(key), + BidiSerializer.serializeRemoveValue(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 serializeRemoveValue( + arg: unknown + ): Bidi.CommonDataTypes.LocalOrRemoteValue { + 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 serialize( + arg: unknown, + context: Context + ): Bidi.CommonDataTypes.LocalOrRemoteValue { + // TODO: See use case of LazyArgs + const objectHandle = + arg && (arg instanceof JSHandle || arg instanceof ElementHandle) + ? arg + : null; + if (objectHandle) { + if (objectHandle.context() !== 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(); + } + + return BidiSerializer.serializeRemoveValue(arg); + } + + static deserializeNumber( + value: Bidi.CommonDataTypes.SpecialNumber | number + ): number { + switch (value) { + case '-0': + return -0; + case 'NaN': + return NaN; + case 'Infinity': + case '+Infinity': + return Infinity; + case '-Infinity': + return -Infinity; + default: + return value; + } + } + + static deserializeLocalValue( + result: Bidi.CommonDataTypes.RemoteValue + ): unknown { + switch (result.type) { + case 'array': + // TODO: Check expected output when value is undefined + return result.value?.map(value => { + return BidiSerializer.deserializeLocalValue(value); + }); + case 'set': + // TODO: Check expected output when value is undefined + return result.value.reduce((acc: Set<unknown>, value) => { + return acc.add(BidiSerializer.deserializeLocalValue(value)); + }, new Set()); + case 'object': + if (result.value) { + return result.value.reduce((acc: Record<any, unknown>, tuple) => { + const {key, value} = BidiSerializer.deserializeTuple(tuple); + acc[key as any] = value; + return acc; + }, {}); + } + break; + case 'map': + return result.value.reduce((acc: Map<unknown, unknown>, tuple) => { + const {key, value} = BidiSerializer.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 BidiSerializer.deserializeNumber(result.value); + case 'bigint': + return BigInt(result.value); + case 'boolean': + return Boolean(result.value); + case 'string': + return result.value; + } + + throw new UnserializableError( + `Deserialization of type ${result.type} not supported.` + ); + } + + static deserializeTuple([serializedKey, serializedValue]: [ + Bidi.CommonDataTypes.RemoteValue | string, + Bidi.CommonDataTypes.RemoteValue + ]): {key: unknown; value: unknown} { + const key = + typeof serializedKey === 'string' + ? serializedKey + : BidiSerializer.deserializeLocalValue(serializedKey); + const value = BidiSerializer.deserializeLocalValue(serializedValue); + + return {key, value}; + } + + static deserialize(result: Bidi.CommonDataTypes.RemoteValue): any { + if (!result) { + debugError('Service did not produce a result.'); + return undefined; + } + + try { + return BidiSerializer.deserializeLocalValue(result); + } catch (error) { + if (error instanceof UnserializableError) { + debugError(error.message); + return undefined; + } + throw error; + } + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/bidi.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/bidi.ts new file mode 100644 index 0000000000..c980168aaa --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/bidi.ts @@ -0,0 +1,21 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +export * from './Browser.js'; +export * from './BrowserContext.js'; +export * from './Page.js'; +export * from './Connection.js'; +export * from './BidiOverCDP.js'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/utils.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/utils.ts new file mode 100644 index 0000000000..ad4a590c5a --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/bidi/utils.ts @@ -0,0 +1,47 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; + +import {debug} from '../Debug.js'; + +import {Context} from './Context.js'; + +/** + * @internal + */ +export const debugError = debug('puppeteer:error'); +/** + * @internal + */ +export async function releaseReference( + client: Context, + remoteReference: Bidi.CommonDataTypes.RemoteReference +): Promise<void> { + if (!remoteReference.handle) { + return; + } + await client.connection + .send('script.disown', { + target: {context: client._contextId}, + handles: [remoteReference.handle], + }) + .catch((error: any) => { + // 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/common/common.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/common.ts new file mode 100644 index 0000000000..ef4f2de99b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/common.ts @@ -0,0 +1,70 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +export * from './Accessibility.js'; +export * from './AriaQueryHandler.js'; +export * from './Browser.js'; +export * from './BrowserConnector.js'; +export * from './BrowserWebSocketTransport.js'; +export * from './ChromeTargetManager.js'; +export * from './Configuration.js'; +export * from './Connection.js'; +export * from './ConnectionTransport.js'; +export * from './ConsoleMessage.js'; +export * from './Coverage.js'; +export * from './CustomQueryHandler.js'; +export * from './Debug.js'; +export * from './Device.js'; +export * from './DeviceRequestPrompt.js'; +export * from './Dialog.js'; +export * from './ElementHandle.js'; +export * from './EmulationManager.js'; +export * from './Errors.js'; +export * from './EventEmitter.js'; +export * from './ExecutionContext.js'; +export * from './fetch.js'; +export * from './FileChooser.js'; +export * from './FirefoxTargetManager.js'; +export * from './Frame.js'; +export * from './FrameManager.js'; +export * from './FrameTree.js'; +export * from './Input.js'; +export * from './IsolatedWorld.js'; +export * from './IsolatedWorlds.js'; +export * from './JSHandle.js'; +export * from './LazyArg.js'; +export * from './LifecycleWatcher.js'; +export * from './NetworkEventManager.js'; +export * from './NetworkManager.js'; +export * from './NodeWebSocketTransport.js'; +export * from './Page.js'; +export * from './PDFOptions.js'; +export * from './PredefinedNetworkConditions.js'; +export * from './Product.js'; +export * from './Puppeteer.js'; +export * from './PuppeteerViewport.js'; +export * from './SecurityDetails.js'; +export * from './Target.js'; +export * from './TargetManager.js'; +export * from './TaskQueue.js'; +export * from './TimeoutSettings.js'; +export * from './Tracing.js'; +export * from './types.js'; +export * from './USKeyboardLayout.js'; +export * from './util.js'; +export * from './WaitTask.js'; +export * from './WebWorker.js'; +export * from './QueryHandler.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..5f13a35288 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/fetch.ts @@ -0,0 +1,24 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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. + */ + +/** + * 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..ac05a58ac9 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/types.ts @@ -0,0 +1,213 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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. + */ + +import type {ElementHandle} from '../api/ElementHandle.js'; +import type {JSHandle} from '../api/JSHandle.js'; + +import type {LazyArg} from './LazyArg.js'; + +/** + * @internal + */ +export type 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..d9b66934de --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/util.ts @@ -0,0 +1,472 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * 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. + */ + +import type {Readable} from 'stream'; + +import type {Protocol} from 'devtools-protocol'; + +import type {ElementHandle} from '../api/ElementHandle.js'; +import type {JSHandle} from '../api/JSHandle.js'; +import {Page} from '../api/Page.js'; +import {isNode} from '../environment.js'; +import {assert} from '../util/assert.js'; +import {isErrorLike} from '../util/ErrorLike.js'; + +import type {CDPSession} from './Connection.js'; +import {debug} from './Debug.js'; +import {CDPElementHandle} from './ElementHandle.js'; +import {TimeoutError} from './Errors.js'; +import type {CommonEventEmitter} from './EventEmitter.js'; +import type {ExecutionContext} from './ExecutionContext.js'; +import {CDPJSHandle} from './JSHandle.js'; + +/** + * @internal + */ +export const debugError = debug('puppeteer:error'); + +/** + * @internal + */ +export function getExceptionMessage( + exceptionDetails: Protocol.Runtime.ExceptionDetails +): string { + if (exceptionDetails.exception) { + return ( + exceptionDetails.exception.description || exceptionDetails.exception.value + ); + } + let message = exceptionDetails.text; + if (exceptionDetails.stackTrace) { + for (const callframe of exceptionDetails.stackTrace.callFrames) { + const location = + callframe.url + + ':' + + callframe.lineNumber + + ':' + + callframe.columnNumber; + const functionName = callframe.functionName || '<anonymous>'; + message += `\n at ${functionName} (${location})`; + } + } + return message; +} + +/** + * @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 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); + }); +} + +/** + * @internal + */ +export interface PuppeteerEventListener { + emitter: CommonEventEmitter; + eventName: string | symbol; + handler: (...args: any[]) => void; +} + +/** + * @internal + */ +export function addEventListener( + emitter: CommonEventEmitter, + eventName: string | symbol, + handler: (...args: any[]) => void +): PuppeteerEventListener { + emitter.on(eventName, handler); + return {emitter, eventName, handler}; +} + +/** + * @internal + */ +export function removeEventListeners( + listeners: Array<{ + emitter: CommonEventEmitter; + eventName: string | symbol; + handler: (...args: any[]) => void; + }> +): void { + for (const listener of listeners) { + listener.emitter.removeListener(listener.eventName, listener.handler); + } + listeners.length = 0; +} + +/** + * @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 async function waitForEvent<T>( + emitter: CommonEventEmitter, + eventName: string | symbol, + predicate: (event: T) => Promise<boolean> | boolean, + timeout: number, + abortPromise: Promise<Error> +): Promise<T> { + let eventTimeout: NodeJS.Timeout; + let resolveCallback: (value: T | PromiseLike<T>) => void; + let rejectCallback: (value: Error) => void; + const promise = new Promise<T>((resolve, reject) => { + resolveCallback = resolve; + rejectCallback = reject; + }); + const listener = addEventListener(emitter, eventName, async event => { + if (!(await predicate(event))) { + return; + } + resolveCallback(event); + }); + if (timeout) { + eventTimeout = setTimeout(() => { + rejectCallback( + new TimeoutError('Timeout exceeded while waiting for event') + ); + }, timeout); + } + function cleanup(): void { + removeEventListeners([listener]); + clearTimeout(eventTimeout); + } + const result = await Promise.race([promise, abortPromise]).then( + r => { + cleanup(); + return r; + }, + error => { + cleanup(); + throw error; + } + ); + if (isErrorLike(result)) { + throw result; + } + + return result; +} + +/** + * @internal + */ +export function createJSHandle( + context: ExecutionContext, + remoteObject: Protocol.Runtime.RemoteObject +): JSHandle | ElementHandle<Node> { + if (remoteObject.subtype === 'node' && context._world) { + return new CDPElementHandle(context, remoteObject, context._world.frame()); + } + return new CDPJSHandle(context, remoteObject); +} + +/** + * @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 + */ +export function addPageBinding(type: string, name: string): void { + // This is the CDP binding. + // @ts-expect-error: In a different context. + const callCDP = globalThis[name]; + + // 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); + }, + }); + }); + }, + }); +} + +/** + * @internal + */ +export function pageBindingInitString(type: string, name: string): string { + return evaluationString(addPageBinding, type, name); +} + +/** + * @internal + */ +export async function waitWithTimeout<T>( + promise: Promise<T>, + taskName: string, + timeout: number +): Promise<T> { + let reject: (reason?: Error) => void; + const timeoutError = new TimeoutError( + `waiting for ${taskName} failed: timeout ${timeout}ms exceeded` + ); + const timeoutPromise = new Promise<never>((_, rej) => { + return (reject = rej); + }); + let timeoutTimer = null; + if (timeout) { + timeoutTimer = setTimeout(() => { + return reject(timeoutError); + }, timeout); + } + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + if (timeoutTimer) { + clearTimeout(timeoutTimer); + } + } +} + +/** + * @internal + */ +let fs: typeof import('fs/promises') | null = null; +/** + * @internal + */ +export async function importFSPromises(): Promise< + typeof import('fs/promises') +> { + 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 async function setPageContent( + page: Pick<Page, 'evaluate'>, + content: string +): Promise<void> { + // We rely upon the fact that document.open() will reset frame lifecycle with "init" + // lifecycle event. @see https://crrev.com/608658 + return page.evaluate(html => { + document.open(); + document.write(html); + document.close(); + }, content); +} 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..80258c67fe --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/environment.ts @@ -0,0 +1,29 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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. + */ + +/** + * @internal + */ +export const isNode = !!(typeof process !== 'undefined' && process.version); + +/** + * @internal + */ +export const DEFERRED_PROMISE_DEBUG_TIMEOUT = + typeof process !== 'undefined' && + typeof process.env['PUPPETEER_DEFERRED_PROMISE_DEBUG_TIMEOUT'] !== 'undefined' + ? Number(process.env['PUPPETEER_DEFERRED_PROMISE_DEBUG_TIMEOUT']) + : -1; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/generated/injected.ts b/remote/test/puppeteer/packages/puppeteer-core/src/generated/injected.ts new file mode 100644 index 0000000000..1a3afff23b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/generated/injected.ts @@ -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 = "\"use strict\";var C=Object.defineProperty;var ne=Object.getOwnPropertyDescriptor;var oe=Object.getOwnPropertyNames;var se=Object.prototype.hasOwnProperty;var u=(e,t)=>{for(var n in t)C(e,n,{get:t[n],enumerable:!0})},ie=(e,t,n,r)=>{if(t&&typeof t==\"object\"||typeof t==\"function\")for(let o of oe(t))!se.call(e,o)&&o!==n&&C(e,o,{get:()=>t[o],enumerable:!(r=ne(t,o))||r.enumerable});return e};var le=e=>ie(C({},\"__esModule\",{value:!0}),e);var Oe={};u(Oe,{default:()=>Re});module.exports=le(Oe);var P=class extends Error{constructor(t){super(t),this.name=this.constructor.name,Error.captureStackTrace(this,this.constructor)}},S=class extends P{},I=class extends P{#e;#r=\"\";set code(t){this.#e=t}get code(){return this.#e}set originalMessage(t){this.#r=t}get originalMessage(){return this.#r}},De=Object.freeze({TimeoutError:S,ProtocolError:I});function p(e){let t=!1,n=!1,r,o,i=new Promise((l,a)=>{r=l,o=a}),s=e&&e.timeout>0?setTimeout(()=>{n=!0,o(new S(e.message))},e.timeout):void 0;return Object.assign(i,{resolved:()=>t,finished:()=>t||n,resolve:l=>{s&&clearTimeout(s),t=!0,r(l)},reject:l=>{clearTimeout(s),n=!0,o(l)}})}var G=new Map,X=e=>{let t=G.get(e);return t||(t=new Function(`return ${e}`)(),G.set(e,t),t)};var R={};u(R,{ariaQuerySelector:()=>ae,ariaQuerySelectorAll:()=>k});var ae=(e,t)=>window.__ariaQuerySelector(e,t),k=async function*(e,t){yield*await window.__ariaQuerySelectorAll(e,t)};var D={};u(D,{customQuerySelectors:()=>_});var O=class{#e=new Map;register(t,n){if(!n.queryOne&&n.queryAll){let r=n.queryAll;n.queryOne=(o,i)=>{for(let s of r(o,i))return s;return null}}else if(n.queryOne&&!n.queryAll){let r=n.queryOne;n.queryAll=(o,i)=>{let s=r(o,i);return s?[s]:[]}}else if(!n.queryOne||!n.queryAll)throw new Error(\"At least one query method must be defined.\");this.#e.set(t,{querySelector:n.queryOne,querySelectorAll:n.queryAll})}unregister(t){this.#e.delete(t)}get(t){return this.#e.get(t)}clear(){this.#e.clear()}},_=new O;var M={};u(M,{pierceQuerySelector:()=>ce,pierceQuerySelectorAll:()=>ue});var ce=(e,t)=>{let n=null,r=o=>{let i=document.createTreeWalker(o,NodeFilter.SHOW_ELEMENT);do{let s=i.currentNode;s.shadowRoot&&r(s.shadowRoot),!(s instanceof ShadowRoot)&&s!==o&&!n&&s.matches(t)&&(n=s)}while(!n&&i.nextNode())};return e instanceof Document&&(e=e.documentElement),r(e),n},ue=(e,t)=>{let n=[],r=o=>{let i=document.createTreeWalker(o,NodeFilter.SHOW_ELEMENT);do{let s=i.currentNode;s.shadowRoot&&r(s.shadowRoot),!(s instanceof ShadowRoot)&&s!==o&&s.matches(t)&&n.push(s)}while(i.nextNode())};return e instanceof Document&&(e=e.documentElement),r(e),n};var m=(e,t)=>{if(!e)throw new Error(t)};var T=class{#e;#r;#n;#t;constructor(t,n){this.#e=t,this.#r=n}async start(){let t=this.#t=p(),n=await this.#e();if(n){t.resolve(n);return}this.#n=new MutationObserver(async()=>{let r=await this.#e();r&&(t.resolve(r),await this.stop())}),this.#n.observe(this.#r,{childList:!0,subtree:!0,attributes:!0})}async stop(){m(this.#t,\"Polling never started.\"),this.#t.finished()||this.#t.reject(new Error(\"Polling stopped\")),this.#n&&(this.#n.disconnect(),this.#n=void 0)}result(){return m(this.#t,\"Polling never started.\"),this.#t}},x=class{#e;#r;constructor(t){this.#e=t}async start(){let t=this.#r=p(),n=await this.#e();if(n){t.resolve(n);return}let r=async()=>{if(t.finished())return;let o=await this.#e();if(!o){window.requestAnimationFrame(r);return}t.resolve(o),await this.stop()};window.requestAnimationFrame(r)}async stop(){m(this.#r,\"Polling never started.\"),this.#r.finished()||this.#r.reject(new Error(\"Polling stopped\"))}result(){return m(this.#r,\"Polling never started.\"),this.#r}},E=class{#e;#r;#n;#t;constructor(t,n){this.#e=t,this.#r=n}async start(){let t=this.#t=p(),n=await this.#e();if(n){t.resolve(n);return}this.#n=setInterval(async()=>{let r=await this.#e();r&&(t.resolve(r),await this.stop())},this.#r)}async stop(){m(this.#t,\"Polling never started.\"),this.#t.finished()||this.#t.reject(new Error(\"Polling stopped\")),this.#n&&(clearInterval(this.#n),this.#n=void 0)}result(){return m(this.#t,\"Polling never started.\"),this.#t}};var H={};u(H,{pQuerySelector:()=>Ie,pQuerySelectorAll:()=>re});var c=class{static async*map(t,n){for await(let r of t)yield await n(r)}static async*flatMap(t,n){for await(let r of t)yield*n(r)}static async collect(t){let n=[];for await(let r of t)n.push(r);return n}static async first(t){for await(let n of t)return n}};var h={attribute:/\\[\\s*(?:(?<namespace>\\*|[-\\w\\P{ASCII}]*)\\|)?(?<name>[-\\w\\P{ASCII}]+)\\s*(?:(?<operator>\\W?=)\\s*(?<value>.+?)\\s*(\\s(?<caseSensitive>[iIsS]))?\\s*)?\\]/gu,id:/#(?<name>[-\\w\\P{ASCII}]+)/gu,class:/\\.(?<name>[-\\w\\P{ASCII}]+)/gu,comma:/\\s*,\\s*/g,combinator:/\\s*[\\s>+~]\\s*/g,\"pseudo-element\":/::(?<name>[-\\w\\P{ASCII}]+)(?:\\((?<argument>¶+)\\))?/gu,\"pseudo-class\":/:(?<name>[-\\w\\P{ASCII}]+)(?:\\((?<argument>¶+)\\))?/gu,universal:/(?:(?<namespace>\\*|[-\\w\\P{ASCII}]*)\\|)?\\*/gu,type:/(?:(?<namespace>\\*|[-\\w\\P{ASCII}]*)\\|)?(?<name>[-\\w\\P{ASCII}]+)/gu},fe=new Set([\"combinator\",\"comma\"]);var me=e=>{switch(e){case\"pseudo-element\":case\"pseudo-class\":return new RegExp(h[e].source.replace(\"(?<argument>\\xB6+)\",\"(?<argument>.+)\"),\"gu\");default:return h[e]}};function de(e,t){let n=0,r=\"\";for(;t<e.length;t++){let o=e[t];switch(o){case\"(\":++n;break;case\")\":--n;break}if(r+=o,n===0)return r}return r}function pe(e,t=h){if(!e)return[];let n=[e];for(let[o,i]of Object.entries(t))for(let s=0;s<n.length;s++){let l=n[s];if(typeof l!=\"string\")continue;i.lastIndex=0;let a=i.exec(l);if(!a)continue;let d=a.index-1,f=[],V=a[0],B=l.slice(0,d+1);B&&f.push(B),f.push({...a.groups,type:o,content:V});let z=l.slice(d+V.length+1);z&&f.push(z),n.splice(s,1,...f)}let r=0;for(let o of n)switch(typeof o){case\"string\":throw new Error(`Unexpected sequence ${o} found at index ${r}`);case\"object\":r+=o.content.length,o.pos=[r-o.content.length,r],fe.has(o.type)&&(o.content=o.content.trim()||\" \");break}return n}var he=/(['\"])([^\\\\\\n]+?)\\1/g,ge=/\\\\./g;function K(e,t=h){if(e=e.trim(),e===\"\")return[];let n=[];e=e.replace(ge,(i,s)=>(n.push({value:i,offset:s}),\"\\uE000\".repeat(i.length))),e=e.replace(he,(i,s,l,a)=>(n.push({value:i,offset:a}),`${s}${\"\\uE001\".repeat(l.length)}${s}`));{let i=0,s;for(;(s=e.indexOf(\"(\",i))>-1;){let l=de(e,s);n.push({value:l,offset:s}),e=`${e.substring(0,s)}(${\"\\xB6\".repeat(l.length-2)})${e.substring(s+l.length)}`,i=s+l.length}}let r=pe(e,t),o=new Set;for(let i of n.reverse())for(let s of r){let{offset:l,value:a}=i;if(!(s.pos[0]<=l&&l+a.length<=s.pos[1]))continue;let{content:d}=s,f=l-s.pos[0];s.content=d.slice(0,f)+a+d.slice(f+a.length),s.content!==d&&o.add(s)}for(let i of o){let s=me(i.type);if(!s)throw new Error(`Unknown token type: ${i.type}`);s.lastIndex=0;let l=s.exec(i.content);if(!l)throw new Error(`Unable to parse content for ${i.type}: ${i.content}`);Object.assign(i,l.groups)}return r}function*N(e,t){switch(e.type){case\"list\":for(let n of e.list)yield*N(n,e);break;case\"complex\":yield*N(e.left,e),yield*N(e.right,e);break;case\"compound\":yield*e.list.map(n=>[n,e]);break;default:yield[e,t]}}function g(e){let t;return Array.isArray(e)?t=e:t=[...N(e)].map(([n])=>n),t.map(n=>n.content).join(\"\")}h.combinator=/\\s*(>>>>?|[\\s>+~])\\s*/g;var ye=/\\\\[\\s\\S]/g,we=e=>{if(e.length>1){for(let t of['\"',\"'\"])if(!(!e.startsWith(t)||!e.endsWith(t)))return e.slice(t.length,-t.length).replace(ye,n=>n.slice(1))}return e};function Y(e){let t=!0,n=K(e);if(n.length===0)return[[],t];let r=[],o=[r],i=[o],s=[];for(let l of n){switch(l.type){case\"combinator\":switch(l.content){case\">>>\":t=!1,s.length&&(r.push(g(s)),s.splice(0)),r=[],o.push(\">>>\"),o.push(r);continue;case\">>>>\":t=!1,s.length&&(r.push(g(s)),s.splice(0)),r=[],o.push(\">>>>\"),o.push(r);continue}break;case\"pseudo-element\":if(!l.name.startsWith(\"-p-\"))break;t=!1,s.length&&(r.push(g(s)),s.splice(0)),r.push({name:l.name.slice(3),value:we(l.argument??\"\")});continue;case\"comma\":s.length&&(r.push(g(s)),s.splice(0)),r=[],o=[r],i.push(o);continue}s.push(l)}return s.length&&r.push(g(s)),[i,t]}var Q={};u(Q,{textQuerySelectorAll:()=>b});var Se=new Set([\"checkbox\",\"image\",\"radio\"]),be=e=>e instanceof HTMLSelectElement||e instanceof HTMLTextAreaElement||e instanceof HTMLInputElement&&!Se.has(e.type),Pe=new Set([\"SCRIPT\",\"STYLE\"]),w=e=>!Pe.has(e.nodeName)&&!document.head?.contains(e),q=new WeakMap,Z=e=>{for(;e;)q.delete(e),e instanceof ShadowRoot?e=e.host:e=e.parentNode},J=new WeakSet,Te=new MutationObserver(e=>{for(let t of e)Z(t.target)}),y=e=>{let t=q.get(e);if(t||(t={full:\"\",immediate:[]},!w(e)))return t;let n=\"\";if(be(e))t.full=e.value,t.immediate.push(e.value),e.addEventListener(\"input\",r=>{Z(r.target)},{once:!0,capture:!0});else{for(let r=e.firstChild;r;r=r.nextSibling){if(r.nodeType===Node.TEXT_NODE){t.full+=r.nodeValue??\"\",n+=r.nodeValue??\"\";continue}n&&t.immediate.push(n),n=\"\",r.nodeType===Node.ELEMENT_NODE&&(t.full+=y(r).full)}n&&t.immediate.push(n),e instanceof Element&&e.shadowRoot&&(t.full+=y(e.shadowRoot).full),J.has(e)||(Te.observe(e,{childList:!0,characterData:!0}),J.add(e))}return q.set(e,t),t};var b=function*(e,t){let n=!1;for(let r of e.childNodes)if(r instanceof Element&&w(r)){let o;r.shadowRoot?o=b(r.shadowRoot,t):o=b(r,t);for(let i of o)yield i,n=!0}n||e instanceof Element&&w(e)&&y(e).full.includes(t)&&(yield e)};var $={};u($,{checkVisibility:()=>Ee,pierce:()=>A,pierceAll:()=>L});var xe=[\"hidden\",\"collapse\"],Ee=(e,t)=>{if(!e)return t===!1;if(t===void 0)return e;let n=e.nodeType===Node.TEXT_NODE?e.parentElement:e,r=window.getComputedStyle(n),o=r&&!xe.includes(r.visibility)&&!Ne(n);return t===o?e:!1};function Ne(e){let t=e.getBoundingClientRect();return t.width===0||t.height===0}var Ae=e=>\"shadowRoot\"in e&&e.shadowRoot instanceof ShadowRoot;function*A(e){Ae(e)?yield e.shadowRoot:yield e}function*L(e){e=A(e).next().value,yield e;let t=[document.createTreeWalker(e,NodeFilter.SHOW_ELEMENT)];for(let n of t){let r;for(;r=n.nextNode();)r.shadowRoot&&(yield r.shadowRoot,t.push(document.createTreeWalker(r.shadowRoot,NodeFilter.SHOW_ELEMENT)))}}var U={};u(U,{xpathQuerySelectorAll:()=>j});var j=function*(e,t){let r=(e.ownerDocument||document).evaluate(t,e,null,XPathResult.ORDERED_NODE_ITERATOR_TYPE),o;for(;o=r.iterateNext();)yield o};var ve=/[-\\w\\P{ASCII}*]/,ee=e=>\"querySelectorAll\"in e,v=class extends Error{constructor(t,n){super(`${t} is not a valid selector: ${n}`)}},F=class{#e;#r;#n=[];#t=void 0;elements;constructor(t,n,r){this.elements=[t],this.#e=n,this.#r=r,this.#o()}async run(){if(typeof this.#t==\"string\")switch(this.#t.trimStart()){case\":scope\":this.#o();break}for(;this.#t!==void 0;this.#o()){let t=this.#t,n=this.#e;typeof t==\"string\"?t[0]&&ve.test(t[0])?this.elements=c.flatMap(this.elements,async function*(r){ee(r)&&(yield*r.querySelectorAll(t))}):this.elements=c.flatMap(this.elements,async function*(r){if(!r.parentElement){if(!ee(r))return;yield*r.querySelectorAll(t);return}let o=0;for(let i of r.parentElement.children)if(++o,i===r)break;yield*r.parentElement.querySelectorAll(`:scope>:nth-child(${o})${t}`)}):this.elements=c.flatMap(this.elements,async function*(r){switch(t.name){case\"text\":yield*b(r,t.value);break;case\"xpath\":yield*j(r,t.value);break;case\"aria\":yield*k(r,t.value);break;default:let o=_.get(t.name);if(!o)throw new v(n,`Unknown selector type: ${t.name}`);yield*o.querySelectorAll(r,t.value)}})}}#o(){if(this.#n.length!==0){this.#t=this.#n.shift();return}if(this.#r.length===0){this.#t=void 0;return}let t=this.#r.shift();switch(t){case\">>>>\":{this.elements=c.flatMap(this.elements,A),this.#o();break}case\">>>\":{this.elements=c.flatMap(this.elements,L),this.#o();break}default:this.#n=t,this.#o();break}}},W=class{#e=new WeakMap;calculate(t,n=[]){if(t===null)return n;t instanceof ShadowRoot&&(t=t.host);let r=this.#e.get(t);if(r)return[...r,...n];let o=0;for(let s=t.previousSibling;s;s=s.previousSibling)++o;let i=this.calculate(t.parentNode,[o]);return this.#e.set(t,i),[...i,...n]}},te=(e,t)=>{if(e.length+t.length===0)return 0;let[n=-1,...r]=e,[o=-1,...i]=t;return n===o?te(r,i):n<o?-1:1},Ce=async function*(e){let t=new Set;for await(let r of e)t.add(r);let n=new W;yield*[...t.values()].map(r=>[r,n.calculate(r)]).sort(([,r],[,o])=>te(r,o)).map(([r])=>r)},re=function(e,t){let n,r;try{[n,r]=Y(t)}catch{return e.querySelectorAll(t)}if(r)return e.querySelectorAll(t);if(n.some(o=>{let i=0;return o.some(s=>(typeof s==\"string\"?++i:i=0,i>1))}))throw new v(t,\"Multiple deep combinators found in sequence.\");return Ce(c.flatMap(n,o=>{let i=new F(e,t,o);return i.run(),i.elements}))},Ie=async function(e,t){for await(let n of re(e,t))return n;return null};var ke=Object.freeze({...R,...D,...M,...H,...Q,...$,...U,createDeferredPromise:p,createFunction:X,createTextContent:y,IntervalPoller:E,isSuitableNodeForTextMatching:w,MutationPoller:T,RAFPoller:x}),Re=ke;\n"; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/generated/version.ts b/remote/test/puppeteer/packages/puppeteer-core/src/generated/version.ts new file mode 100644 index 0000000000..a2c4aa3ba7 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/generated/version.ts @@ -0,0 +1,4 @@ +/** + * @internal + */ +export const packageVersion = '20.1.0'; 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..90168e2dd2 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/ARIAQuerySelector.ts @@ -0,0 +1,41 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +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..41bb1a8408 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/CustomQuerySelector.ts @@ -0,0 +1,69 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +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..f345442f5a --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PQuerySelector.ts @@ -0,0 +1,308 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +import type {AwaitableIterable} from '../common/types.js'; +import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js'; + +import {ariaQuerySelectorAll} from './ARIAQuerySelector.js'; +import {customQuerySelectors} from './CustomQuerySelector.js'; +import { + ComplexPSelector, + ComplexPSelectorList, + CompoundPSelector, + CSSSelector, + parsePSelectors, + PCombinator, + 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..19bb9e3000 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PSelectorParser.ts @@ -0,0 +1,119 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +import {Token, tokenize, TOKENS, stringify} from 'parsel-js'; + +export type CSSSelector = string; +export type 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) { + for (const char of ['"', "'"]) { + if (!text.startsWith(char) || !text.endsWith(char)) { + continue; + } + return text + .slice(char.length, -char.length) + .replace(ESCAPE_REGEXP, match => { + return match.slice(1); + }); + } + } + return text; +}; + +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..d72d8913bb --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PierceQuerySelector.ts @@ -0,0 +1,73 @@ +// Copyright 2022 Google Inc. All rights reserved. +// +// 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. + +/** + * @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..d7f4eb398b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/Poller.ts @@ -0,0 +1,181 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +import {assert} from '../util/assert.js'; +import { + createDeferredPromise, + DeferredPromise, +} from '../util/DeferredPromise.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; + #promise?: DeferredPromise<T>; + constructor(fn: () => Promise<T>, root: Node) { + this.#fn = fn; + this.#root = root; + } + + async start(): Promise<void> { + const promise = (this.#promise = createDeferredPromise<T>()); + const result = await this.#fn(); + if (result) { + promise.resolve(result); + return; + } + + this.#observer = new MutationObserver(async () => { + const result = await this.#fn(); + if (!result) { + return; + } + promise.resolve(result); + await this.stop(); + }); + this.#observer.observe(this.#root, { + childList: true, + subtree: true, + attributes: true, + }); + } + + async stop(): Promise<void> { + assert(this.#promise, 'Polling never started.'); + if (!this.#promise.finished()) { + this.#promise.reject(new Error('Polling stopped')); + } + if (this.#observer) { + this.#observer.disconnect(); + this.#observer = undefined; + } + } + + result(): Promise<T> { + assert(this.#promise, 'Polling never started.'); + return this.#promise; + } +} + +/** + * @internal + */ +export class RAFPoller<T> implements Poller<T> { + #fn: () => Promise<T>; + #promise?: DeferredPromise<T>; + constructor(fn: () => Promise<T>) { + this.#fn = fn; + } + + async start(): Promise<void> { + const promise = (this.#promise = createDeferredPromise<T>()); + const result = await this.#fn(); + if (result) { + promise.resolve(result); + return; + } + + const poll = async () => { + if (promise.finished()) { + return; + } + const result = await this.#fn(); + if (!result) { + window.requestAnimationFrame(poll); + return; + } + promise.resolve(result); + await this.stop(); + }; + window.requestAnimationFrame(poll); + } + + async stop(): Promise<void> { + assert(this.#promise, 'Polling never started.'); + if (!this.#promise.finished()) { + this.#promise.reject(new Error('Polling stopped')); + } + } + + result(): Promise<T> { + assert(this.#promise, 'Polling never started.'); + return this.#promise; + } +} + +/** + * @internal + */ + +export class IntervalPoller<T> implements Poller<T> { + #fn: () => Promise<T>; + #ms: number; + + #interval?: NodeJS.Timer; + #promise?: DeferredPromise<T>; + constructor(fn: () => Promise<T>, ms: number) { + this.#fn = fn; + this.#ms = ms; + } + + async start(): Promise<void> { + const promise = (this.#promise = createDeferredPromise<T>()); + const result = await this.#fn(); + if (result) { + promise.resolve(result); + return; + } + + this.#interval = setInterval(async () => { + const result = await this.#fn(); + if (!result) { + return; + } + promise.resolve(result); + await this.stop(); + }, this.#ms); + } + + async stop(): Promise<void> { + assert(this.#promise, 'Polling never started.'); + if (!this.#promise.finished()) { + this.#promise.reject(new Error('Polling stopped')); + } + if (this.#interval) { + clearInterval(this.#interval); + this.#interval = undefined; + } + } + + result(): Promise<T> { + assert(this.#promise, 'Polling never started.'); + return this.#promise; + } +} 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..8c09bbc6d5 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextContent.ts @@ -0,0 +1,155 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +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 type 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, + }); + 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..eebd59f675 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextQuerySelector.ts @@ -0,0 +1,56 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +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..787e3afaec --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/XPathQuerySelector.ts @@ -0,0 +1,35 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +/** + * @internal + */ +export const xpathQuerySelectorAll = function* ( + root: Node, + selector: string +): Iterable<Node> { + const doc = root.ownerDocument || document; + const iterator = doc.evaluate( + selector, + root, + null, + XPathResult.ORDERED_NODE_ITERATOR_TYPE + ); + let item; + while ((item = iterator.iterateNext())) { + yield item; + } +}; 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..0f6aac78ac --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/injected.ts @@ -0,0 +1,61 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +import {createDeferredPromise} from '../util/DeferredPromise.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, + createDeferredPromise, + 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.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.ts new file mode 100644 index 0000000000..9594ed33db --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.ts @@ -0,0 +1,257 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +import {mkdtemp} from 'fs/promises'; +import path from 'path'; + +import { + computeSystemExecutablePath, + Browser as SupportedBrowsers, + ChromeReleaseChannel as BrowsersChromeReleaseChannel, +} from '@puppeteer/browsers'; + +import {debugError} from '../common/util.js'; +import {Browser} from '../puppeteer-core.js'; +import {assert} from '../util/assert.js'; + +import { + BrowserLaunchArgumentOptions, + ChromeReleaseChannel, + PuppeteerNodeLaunchOptions, +} from './LaunchOptions.js'; +import {ProductLauncher, ResolvedLaunchArgs} from './ProductLauncher.js'; +import {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 || + 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 feature `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 ') + ); + } + + 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); + } + + 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 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', + // AcceptCHFrame disabled because of crbug.com/1348106. + // DIPS is disabled because of crbug.com/1439578. TODO: enable after M115. + '--disable-features=Translate,BackForwardCache,AcceptCHFrame,MediaRouter,OptimizationHints,DIPS', + '--disable-hang-monitor', + '--disable-ipc-flooding-protection', + '--disable-popup-blocking', + '--disable-prompt-on-repost', + '--disable-renderer-backgrounding', + '--disable-sync', + '--enable-automation', + // TODO(sadym): remove '--enable-blink-features=IdleDetection' once + // IdleDetection is turned on by default. + '--enable-blink-features=IdleDetection', + '--enable-features=NetworkServiceInProcess2', + '--export-tagged-pdf', + '--force-color-profile=srgb', + '--metrics-recording-only', + '--no-first-run', + '--password-store=basic', + '--use-mock-keychain', + ]; + 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): string { + if (channel) { + return computeSystemExecutablePath({ + browser: SupportedBrowsers.CHROME, + channel: convertPuppeteerChannelToBrowsersChannel(channel), + }); + } else { + return this.resolveExecutablePath(); + } + } +} + +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; + } +} 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..004d78bd7f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.ts @@ -0,0 +1,225 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +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 { + BrowserLaunchArgumentOptions, + PuppeteerNodeLaunchOptions, +} from './LaunchOptions.js'; +import {ProductLauncher, ResolvedLaunchArgs} from './ProductLauncher.js'; +import {PuppeteerNode} from './PuppeteerNode.js'; +import {rm} from './util/fs.js'; + +/** + * @internal + */ +export class FirefoxLauncher extends ProductLauncher { + constructor(puppeteer: PuppeteerNode) { + super(puppeteer, 'firefox'); + } + /** + * @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: extraPrefsFirefox, + }); + + 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..b7f97ad9c0 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/LaunchOptions.ts @@ -0,0 +1,150 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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. + */ + +import {BrowserConnectOptions} from '../common/BrowserConnector.js'; +import {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/PipeTransport.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/PipeTransport.ts new file mode 100644 index 0000000000..830825e6f7 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/PipeTransport.ts @@ -0,0 +1,93 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * 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. + */ +import {ConnectionTransport} from '../common/ConnectionTransport.js'; +import { + addEventListener, + debugError, + PuppeteerEventListener, + removeEventListeners, +} from '../common/util.js'; +import {assert} from '../util/assert.js'; + +/** + * @internal + */ +export class PipeTransport implements ConnectionTransport { + #pipeWrite: NodeJS.WritableStream; + #eventListeners: PuppeteerEventListener[]; + + #isClosed = false; + #pendingMessage = ''; + + onclose?: () => void; + onmessage?: (value: string) => void; + + constructor( + pipeWrite: NodeJS.WritableStream, + pipeRead: NodeJS.ReadableStream + ) { + this.#pipeWrite = pipeWrite; + this.#eventListeners = [ + addEventListener(pipeRead, 'data', buffer => { + return this.#dispatch(buffer); + }), + addEventListener(pipeRead, 'close', () => { + if (this.onclose) { + this.onclose.call(null); + } + }), + addEventListener(pipeRead, 'error', debugError), + addEventListener(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; + removeEventListeners(this.#eventListeners); + } +} 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..9b92772fab --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/ProductLauncher.ts @@ -0,0 +1,448 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * 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. + */ +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 {Browser, BrowserCloseCallback} from '../api/Browser.js'; +import {CDPBrowser} from '../common/Browser.js'; +import {Connection} from '../common/Connection.js'; +import {TimeoutError} from '../common/Errors.js'; +import {NodeWebSocketTransport as WebSocketTransport} from '../common/NodeWebSocketTransport.js'; +import {Product} from '../common/Product.js'; +import {Viewport} from '../common/PuppeteerViewport.js'; +import {debugError} from '../common/util.js'; + +import { + BrowserLaunchArgumentOptions, + ChromeReleaseChannel, + PuppeteerNodeLaunchOptions, +} from './LaunchOptions.js'; +import {PipeTransport} from './PipeTransport.js'; +import {PuppeteerNode} from './PuppeteerNode.js'; + +/** + * @internal + */ +export type 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 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 = {width: 800, height: 600}, + slowMo = 0, + timeout = 30000, + waitForInitialPage = true, + protocol, + protocolTimeout, + } = 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 connection: Connection; + let closing = false; + + const browserCloseCallback = async () => { + if (closing) { + return; + } + closing = true; + await this.closeBrowser(browserProcess, connection); + }; + + try { + if (this.#product === 'firefox' && protocol === 'webDriverBiDi') { + browser = await this.createBiDiBrowser( + browserProcess, + browserCloseCallback, + { + timeout, + protocolTimeout, + slowMo, + defaultViewport, + } + ); + } else { + if (usePipe) { + connection = await this.createCDPPipeConnection(browserProcess, { + timeout, + protocolTimeout, + slowMo, + }); + } else { + connection = await this.createCDPSocketConnection(browserProcess, { + timeout, + protocolTimeout, + slowMo, + }); + } + if (protocol === 'webDriverBiDi') { + browser = await this.createBiDiOverCDPBrowser( + browserProcess, + connection, + browserCloseCallback, + { + timeout, + protocolTimeout, + slowMo, + defaultViewport, + } + ); + } else { + browser = await CDPBrowser._create( + this.product, + connection, + [], + 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; + } + + executablePath(channel?: ChromeReleaseChannel): string; + executablePath(): string { + throw new Error('Not implemented'); + } + + defaultArgs(object: BrowserLaunchArgumentOptions): string[]; + defaultArgs(): string[] { + throw new Error('Not implemented'); + } + + /** + * Set only for Firefox, after the launcher resolves the `latest` revision to + * the actual revision. + * @internal + */ + getActualBrowserRevision(): string | undefined { + return this.actualBrowserRevision; + } + + /** + * @internal + */ + protected async computeLaunchArguments( + options: PuppeteerNodeLaunchOptions + ): Promise<ResolvedLaunchArgs>; + protected async computeLaunchArguments(): Promise<ResolvedLaunchArgs> { + throw new Error('Not implemented'); + } + + /** + * @internal + */ + protected async cleanUserDataDir( + path: string, + opts: {isTemp: boolean} + ): Promise<void>; + protected async cleanUserDataDir(): Promise<void> { + throw new Error('Not implemented'); + } + + /** + * @internal + */ + protected async closeBrowser( + browserProcess: ReturnType<typeof launch>, + connection?: Connection + ): Promise<void> { + if (connection) { + // Attempt to close the browser gracefully + try { + await connection.closeBrowser(); + await browserProcess.hasClosed(); + } catch (error) { + debugError(error); + await browserProcess.close(); + } + } else { + await 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; + } + ): Promise<Browser> { + // TODO: use other options too. + const BiDi = await import( + /* webpackIgnore: true */ '../common/bidi/bidi.js' + ); + const bidiConnection = await BiDi.connectBidiOverCDP(connection); + return await BiDi.Browser.create({ + connection: bidiConnection, + closeCallback, + process: browserProcess.nodeProcess, + defaultViewport: opts.defaultViewport, + }); + } + + /** + * @internal + */ + protected async createBiDiBrowser( + browserProcess: ReturnType<typeof launch>, + closeCallback: BrowserCloseCallback, + opts: { + timeout: number; + protocolTimeout: number | undefined; + slowMo: number; + defaultViewport: Viewport | null; + } + ): 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 */ '../common/bidi/bidi.js' + ); + const bidiConnection = new BiDi.Connection( + transport, + opts.slowMo, + opts.protocolTimeout + ); + // TODO: use other options too. + return await BiDi.Browser.create({ + connection: bidiConnection, + closeCallback, + process: browserProcess.nodeProcess, + defaultViewport: opts.defaultViewport, + }); + } + + /** + * @internal + */ + protected getProfilePath(): string { + return join( + this.puppeteer.configuration.temporaryDirectory ?? tmpdir(), + `puppeteer_dev_${this.product}_profile-` + ); + } + + /** + * @internal + */ + protected resolveExecutablePath(): 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) { + switch (product) { + case 'chrome': + return InstalledBrowser.CHROME; + case 'firefox': + return InstalledBrowser.FIREFOX; + } + return InstalledBrowser.CHROME; + } + + executablePath = computeExecutablePath({ + cacheDir: this.puppeteer.defaultDownloadPath!, + browser: productToBrowser(this.product), + 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. `npm install`) 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. `PUPPETEER_PRODUCT=firefox npm install`) 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..c6667eb28f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/PuppeteerNode.ts @@ -0,0 +1,267 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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. + */ + +import {Browser} from '../api/Browser.js'; +import {BrowserConnectOptions} from '../common/BrowserConnector.js'; +import {Configuration} from '../common/Configuration.js'; +import {Product} from '../common/Product.js'; +import { + CommonPuppeteerSettings, + ConnectOptions, + Puppeteer, +} from '../common/Puppeteer.js'; +import {PUPPETEER_REVISIONS} from '../revisions.js'; + +import {ChromeLauncher} from './ChromeLauncher.js'; +import {FirefoxLauncher} from './FirefoxLauncher.js'; +import { + BrowserLaunchArgumentOptions, + ChromeReleaseChannel, + LaunchOptions, +} from './LaunchOptions.js'; +import {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 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://goo.gle/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); + } +} 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..da815faf16 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/node.ts @@ -0,0 +1,22 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +export * from './ChromeLauncher.js'; +export * from './FirefoxLauncher.js'; +export * from './LaunchOptions.js'; +export * from './PipeTransport.js'; +export * from './ProductLauncher.js'; +export * from './PuppeteerNode.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..ae0419a91d --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/util/fs.ts @@ -0,0 +1,37 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +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..08cb8092a5 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/puppeteer-core.ts @@ -0,0 +1,58 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * 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. + */ + +export {Protocol} from 'devtools-protocol'; + +export * from './api/api.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..dd30692b6a --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/revisions.ts @@ -0,0 +1,23 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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. + */ + +/** + * @internal + */ +export const PUPPETEER_REVISIONS = Object.freeze({ + chrome: '113.0.5672.63', + 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..0fabc5470c --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/tsconfig.cjs.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "CommonJS", + "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..5b06b3ab30 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/AsyncIterableUtil.ts @@ -0,0 +1,56 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ +import {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/DebuggableDeferredPromise.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/DebuggableDeferredPromise.ts new file mode 100644 index 0000000000..0632fd5e88 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/DebuggableDeferredPromise.ts @@ -0,0 +1,21 @@ +import {DEFERRED_PROMISE_DEBUG_TIMEOUT} from '../environment.js'; + +import {DeferredPromise, createDeferredPromise} from './DeferredPromise.js'; + +/** + * Creates and returns a deferred promise using DEFERRED_PROMISE_DEBUG_TIMEOUT + * if it's specified or a normal deferred promise otherwise. + * + * @internal + */ +export function createDebuggableDeferredPromise<T>( + message: string +): DeferredPromise<T> { + if (DEFERRED_PROMISE_DEBUG_TIMEOUT > 0) { + return createDeferredPromise({ + message, + timeout: DEFERRED_PROMISE_DEBUG_TIMEOUT, + }); + } + return createDeferredPromise(); +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/DeferredPromise.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/DeferredPromise.ts new file mode 100644 index 0000000000..8c7945e359 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/DeferredPromise.ts @@ -0,0 +1,68 @@ +import {TimeoutError} from '../common/Errors.js'; + +/** + * @internal + */ +export interface DeferredPromise<T> extends Promise<T> { + finished: () => boolean; + resolved: () => boolean; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +} + +/** + * @internal + */ +export interface DeferredPromiseOptions { + message: string; + timeout: number; +} + +/** + * Creates and returns a promise along with the resolve/reject functions. + * + * If the promise has not been resolved/rejected within the `timeout` period, + * the promise gets rejected with a timeout error. `timeout` has to be greater than 0 or + * it is ignored. + * + * @internal + */ +export function createDeferredPromise<T>( + opts?: DeferredPromiseOptions +): DeferredPromise<T> { + let isResolved = false; + let isRejected = false; + let resolver: (value: T) => void; + let rejector: (reason?: unknown) => void; + const taskPromise = new Promise<T>((resolve, reject) => { + resolver = resolve; + rejector = reject; + }); + const timeoutId = + opts && opts.timeout > 0 + ? setTimeout(() => { + isRejected = true; + rejector(new TimeoutError(opts.message)); + }, opts.timeout) + : undefined; + return Object.assign(taskPromise, { + resolved: () => { + return isResolved; + }, + finished: () => { + return isResolved || isRejected; + }, + resolve: (value: T) => { + if (timeoutId) { + clearTimeout(timeoutId); + } + isResolved = true; + resolver(value); + }, + reject: (err?: unknown) => { + clearTimeout(timeoutId); + isRejected = true; + rejector(err); + }, + }); +} 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..e5659ce3e3 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/ErrorLike.ts @@ -0,0 +1,27 @@ +/** + * @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) + ); +} 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..cdf09ba195 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/Function.ts @@ -0,0 +1,98 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ +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'), + 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/assert.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/assert.ts new file mode 100644 index 0000000000..bd8b10e731 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/assert.ts @@ -0,0 +1,31 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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. + */ + +/** + * 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/util.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/util.ts new file mode 100644 index 0000000000..d316075794 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/util.ts @@ -0,0 +1,21 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +export * from './assert.js'; +export * from './DebuggableDeferredPromise.js'; +export * from './DeferredPromise.js'; +export * from './ErrorLike.js'; +export * from './AsyncIterableUtil.js'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/third_party/mitt/index.ts b/remote/test/puppeteer/packages/puppeteer-core/third_party/mitt/index.ts new file mode 100644 index 0000000000..b4833b79e4 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/third_party/mitt/index.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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. + */ + +export * from 'mitt'; +export {default as default} from 'mitt'; 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..a169b93816 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/third_party/tsconfig.cjs.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "declarationMap": false, + "outDir": "../lib/cjs/third_party", + "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..cfe3a26f4c --- /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..5c0d74cbcd --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/tools/ensure-correct-devtools-protocol-package.ts @@ -0,0 +1,97 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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. + */ + +/** + * 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. + */ + +// eslint-disable-next-line import/extensions +import {execSync} from 'child_process'; + +import packageJson from '../package.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://chromiumdash.appspot.com/fetch_releases?channel=stable` + ); + const stableReleases = await req.json(); + const chromeRevision = stableReleases.find(release => { + return release.version === chromeVersion; + }).chromium_main_branch_position; + 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/tools/generate_sources.ts b/remote/test/puppeteer/packages/puppeteer-core/tools/generate_sources.ts new file mode 100644 index 0000000000..70922c6aad --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/tools/generate_sources.ts @@ -0,0 +1,88 @@ +#!/usr/bin/env node +import {mkdir, mkdtemp, readFile, rm, writeFile} from 'fs/promises'; +import path, {join, resolve} from 'path'; +import {chdir} from 'process'; + +import esbuild from 'esbuild'; + +import {job} from '../../../tools/internal/job.js'; + +const packageRoot = resolve(join(__dirname, '..')); +chdir(packageRoot); + +(async () => { + await job('', async ({outputs}) => { + await Promise.all( + outputs.map(outputs => { + return mkdir(outputs, {recursive: true}); + }) + ); + }) + .outputs(['src/generated']) + .build(); + + const versionJob = job('', async ({inputs, outputs}) => { + const version = JSON.parse(await readFile(inputs[0]!, 'utf8')).version; + await writeFile( + outputs[0]!, + (await readFile(inputs[1]!, 'utf8')).replace('PACKAGE_VERSION', version) + ); + }) + .inputs(['package.json', 'src/templates/version.ts.tmpl']) + .outputs(['src/generated/version.ts']) + .build(); + + const injectedJob = job('', async ({name, inputs, outputs}) => { + const input = inputs.find(input => { + return input.endsWith('injected.ts'); + })!; + const template = await readFile( + inputs.find(input => { + return input.includes('injected.ts.tmpl'); + })!, + 'utf8' + ); + const tmp = await mkdtemp(name); + await esbuild.build({ + entryPoints: [input], + bundle: true, + outdir: tmp, + format: 'cjs', + platform: 'browser', + target: 'ES2022', + minify: true, + }); + const baseName = path.basename(input); + const content = await readFile( + path.join(tmp, baseName.replace('.ts', '.js')), + 'utf-8' + ); + const scriptContent = template.replace( + 'SOURCE_CODE', + JSON.stringify(content) + ); + await writeFile(outputs[0]!, scriptContent); + await rm(tmp, {recursive: true, force: true}); + }) + .inputs(['src/templates/injected.ts.tmpl', 'src/injected/**/*.ts']) + .outputs(['src/generated/injected.ts']) + .build(); + + await Promise.all([versionJob, injectedJob]); + + if (process.env['PUBLISH']) { + await job('', async ({inputs}) => { + const version = JSON.parse(await readFile(inputs[0]!, 'utf8')).version; + await writeFile( + inputs[1]!, + ( + await readFile(inputs[1]!, { + encoding: 'utf-8', + }) + ).replace("'NEXT'", `'v${version}'`) + ); + }) + .inputs(['package.json', '../../versions.js']) + .build(); + } +})(); 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..a219f8b704 --- /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"} + ] +} |