From ede43ca2d3a643f85ad7b4deadf2a324f87a2a24 Mon Sep 17 00:00:00 2001 From: jrandolf <101637635+jrandolf@users.noreply.github.com> Date: Tue, 25 Jul 2023 12:43:07 +0200 Subject: [PATCH] chore: use RxJS for locator implementation (#10607) Using RxJS greatly simplifies the control flow for locators and comes with automatic cleanup on failure. It greatly simplifies the `signal` logic and the retry logic. --- docs/api/index.md | 2 +- docs/api/puppeteer.awaitedlocator.md | 13 + docs/api/puppeteer.locator.click.md | 2 +- docs/api/puppeteer.locator.fill.md | 2 +- docs/api/puppeteer.locator.hover.md | 2 +- docs/api/puppeteer.locator.md | 30 +- docs/api/puppeteer.locator.race.md | 6 +- docs/api/puppeteer.locator.scroll.md | 2 +- ...locator.setensureelementisintheviewport.md | 14 +- docs/api/puppeteer.locator.settimeout.md | 2 +- docs/api/puppeteer.locator.setvisibility.md | 8 +- .../puppeteer.locator.setwaitforenabled.md | 14 +- ...eer.locator.setwaitforstableboundingbox.md | 14 +- docs/api/puppeteer.unionlocatorof.md | 13 - package-lock.json | 409 +++++++------ package.json | 2 +- packages/puppeteer-core/package.json | 1 + .../rollup.third_party.config.mjs | 4 +- packages/puppeteer-core/src/api/Page.ts | 8 +- .../src/api/locators/ExpectedLocator.ts | 145 +++-- .../src/api/locators/Locator.ts | 576 ++++++++++++++++-- .../src/api/locators/NodeLocator.ts | 518 ++-------------- .../src/api/locators/RaceLocator.ts | 199 ++---- .../src/api/locators/locators.ts | 16 + .../puppeteer-core/third_party/rxjs/rxjs.ts | 41 ++ test/src/locator.spec.ts | 11 +- 26 files changed, 1083 insertions(+), 971 deletions(-) create mode 100644 docs/api/puppeteer.awaitedlocator.md delete mode 100644 docs/api/puppeteer.unionlocatorof.md create mode 100644 packages/puppeteer-core/third_party/rxjs/rxjs.ts diff --git a/docs/api/index.md b/docs/api/index.md index a30c402e488..0700b872cee 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -153,6 +153,7 @@ sidebar_label: API | [ActionResult](./puppeteer.actionresult.md) | | | [Awaitable](./puppeteer.awaitable.md) | | | [AwaitableIterable](./puppeteer.awaitableiterable.md) | | +| [AwaitedLocator](./puppeteer.awaitedlocator.md) | | | [ChromeReleaseChannel](./puppeteer.chromereleasechannel.md) | | | [ConsoleMessageType](./puppeteer.consolemessagetype.md) | The supported types for console messages. | | [ElementFor](./puppeteer.elementfor.md) | | @@ -182,5 +183,4 @@ sidebar_label: API | [PuppeteerNodeLaunchOptions](./puppeteer.puppeteernodelaunchoptions.md) | 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. | | [ResourceType](./puppeteer.resourcetype.md) | Resource types for HTTPRequests as perceived by the rendering engine. | | [TargetFilterCallback](./puppeteer.targetfiltercallback.md) | | -| [UnionLocatorOf](./puppeteer.unionlocatorof.md) | | | [VisibilityOption](./puppeteer.visibilityoption.md) | | diff --git a/docs/api/puppeteer.awaitedlocator.md b/docs/api/puppeteer.awaitedlocator.md new file mode 100644 index 00000000000..fd2f4a18e09 --- /dev/null +++ b/docs/api/puppeteer.awaitedlocator.md @@ -0,0 +1,13 @@ +--- +sidebar_label: AwaitedLocator +--- + +# AwaitedLocator type + +#### Signature: + +```typescript +export type AwaitedLocator = T extends Locator ? S : never; +``` + +**References:** [Locator](./puppeteer.locator.md) diff --git a/docs/api/puppeteer.locator.click.md b/docs/api/puppeteer.locator.click.md index 0b0cce00774..35a903680ac 100644 --- a/docs/api/puppeteer.locator.click.md +++ b/docs/api/puppeteer.locator.click.md @@ -8,7 +8,7 @@ sidebar_label: Locator.click ```typescript class Locator { - abstract click( + click( this: Locator, options?: Readonly ): Promise; diff --git a/docs/api/puppeteer.locator.fill.md b/docs/api/puppeteer.locator.fill.md index 3f622bbd684..784020b293c 100644 --- a/docs/api/puppeteer.locator.fill.md +++ b/docs/api/puppeteer.locator.fill.md @@ -10,7 +10,7 @@ Fills out the input identified by the locator using the provided value. The type ```typescript class Locator { - abstract fill( + fill( this: Locator, value: string, options?: Readonly diff --git a/docs/api/puppeteer.locator.hover.md b/docs/api/puppeteer.locator.hover.md index b1ece20715c..583193117ed 100644 --- a/docs/api/puppeteer.locator.hover.md +++ b/docs/api/puppeteer.locator.hover.md @@ -8,7 +8,7 @@ sidebar_label: Locator.hover ```typescript class Locator { - abstract hover( + hover( this: Locator, options?: Readonly ): Promise; diff --git a/docs/api/puppeteer.locator.md b/docs/api/puppeteer.locator.md index 9adeefa5bdf..96574f90ba9 100644 --- a/docs/api/puppeteer.locator.md +++ b/docs/api/puppeteer.locator.md @@ -22,18 +22,18 @@ export declare abstract class Locator extends EventEmitter ## Methods -| Method | Modifiers | Description | -| ------------------------------------------------------------------------------------------------ | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [click(this, options)](./puppeteer.locator.click.md) | | | -| [fill(this, value, options)](./puppeteer.locator.fill.md) | | Fills out the input identified by the locator using the provided value. The type of the input is determined at runtime and the appropriate fill-out method is chosen based on the type. contenteditable, selector, inputs are supported. | -| [hover(this, options)](./puppeteer.locator.hover.md) | | | -| [off(eventName, handler)](./puppeteer.locator.off.md) | | | -| [on(eventName, handler)](./puppeteer.locator.on.md) | | | -| [once(eventName, handler)](./puppeteer.locator.once.md) | | | -| [race(locators)](./puppeteer.locator.race.md) | static | Creates a race between multiple locators but ensures that only a single one acts. | -| [scroll(this, options)](./puppeteer.locator.scroll.md) | | | -| [setEnsureElementIsInTheViewport(value)](./puppeteer.locator.setensureelementisintheviewport.md) | | | -| [setTimeout(timeout)](./puppeteer.locator.settimeout.md) | | | -| [setVisibility(visibility)](./puppeteer.locator.setvisibility.md) | | | -| [setWaitForEnabled(value)](./puppeteer.locator.setwaitforenabled.md) | | | -| [setWaitForStableBoundingBox(value)](./puppeteer.locator.setwaitforstableboundingbox.md) | | | +| Method | Modifiers | Description | +| ------------------------------------------------------------------------------------------------------ | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [click(this, options)](./puppeteer.locator.click.md) | | | +| [fill(this, value, options)](./puppeteer.locator.fill.md) | | Fills out the input identified by the locator using the provided value. The type of the input is determined at runtime and the appropriate fill-out method is chosen based on the type. contenteditable, selector, inputs are supported. | +| [hover(this, options)](./puppeteer.locator.hover.md) | | | +| [off(eventName, handler)](./puppeteer.locator.off.md) | | | +| [on(eventName, handler)](./puppeteer.locator.on.md) | | | +| [once(eventName, handler)](./puppeteer.locator.once.md) | | | +| [race(locators)](./puppeteer.locator.race.md) | static | Creates a race between multiple locators but ensures that only a single one acts. | +| [scroll(this, options)](./puppeteer.locator.scroll.md) | | | +| [setEnsureElementIsInTheViewport(this, value)](./puppeteer.locator.setensureelementisintheviewport.md) | | | +| [setTimeout(timeout)](./puppeteer.locator.settimeout.md) | | | +| [setVisibility(this, visibility)](./puppeteer.locator.setvisibility.md) | | | +| [setWaitForEnabled(this, value)](./puppeteer.locator.setwaitforenabled.md) | | | +| [setWaitForStableBoundingBox(this, value)](./puppeteer.locator.setwaitforstableboundingbox.md) | | | diff --git a/docs/api/puppeteer.locator.race.md b/docs/api/puppeteer.locator.race.md index 642df259b9c..8e285164aee 100644 --- a/docs/api/puppeteer.locator.race.md +++ b/docs/api/puppeteer.locator.race.md @@ -10,9 +10,9 @@ Creates a race between multiple locators but ensures that only a single one acts ```typescript class Locator { - static race>>( + static race( locators: Locators - ): Locator>; + ): Locator>; } ``` @@ -24,4 +24,4 @@ class Locator { **Returns:** -[Locator](./puppeteer.locator.md)<[UnionLocatorOf](./puppeteer.unionlocatorof.md)<Locators>> +[Locator](./puppeteer.locator.md)<[AwaitedLocator](./puppeteer.awaitedlocator.md)<Locators\[number\]>> diff --git a/docs/api/puppeteer.locator.scroll.md b/docs/api/puppeteer.locator.scroll.md index ca58be382cf..d7f4ababe35 100644 --- a/docs/api/puppeteer.locator.scroll.md +++ b/docs/api/puppeteer.locator.scroll.md @@ -8,7 +8,7 @@ sidebar_label: Locator.scroll ```typescript class Locator { - abstract scroll( + scroll( this: Locator, options?: Readonly ): Promise; diff --git a/docs/api/puppeteer.locator.setensureelementisintheviewport.md b/docs/api/puppeteer.locator.setensureelementisintheviewport.md index 37de38bdcb5..65d3179ed64 100644 --- a/docs/api/puppeteer.locator.setensureelementisintheviewport.md +++ b/docs/api/puppeteer.locator.setensureelementisintheviewport.md @@ -8,16 +8,20 @@ sidebar_label: Locator.setEnsureElementIsInTheViewport ```typescript class Locator { - abstract setEnsureElementIsInTheViewport(value: boolean): this; + setEnsureElementIsInTheViewport( + this: Locator, + value: boolean + ): Locator; } ``` ## Parameters -| Parameter | Type | Description | -| --------- | ------- | ----------- | -| value | boolean | | +| Parameter | Type | Description | +| --------- | ---------------------------------------------------- | ----------- | +| this | [Locator](./puppeteer.locator.md)<ElementType> | | +| value | boolean | | **Returns:** -this +[Locator](./puppeteer.locator.md)<ElementType> diff --git a/docs/api/puppeteer.locator.settimeout.md b/docs/api/puppeteer.locator.settimeout.md index da7508c93df..33099941c66 100644 --- a/docs/api/puppeteer.locator.settimeout.md +++ b/docs/api/puppeteer.locator.settimeout.md @@ -8,7 +8,7 @@ sidebar_label: Locator.setTimeout ```typescript class Locator { - abstract setTimeout(timeout: number): this; + setTimeout(timeout: number): this; } ``` diff --git a/docs/api/puppeteer.locator.setvisibility.md b/docs/api/puppeteer.locator.setvisibility.md index 1cab9ec8a28..0ee6dc6fb29 100644 --- a/docs/api/puppeteer.locator.setvisibility.md +++ b/docs/api/puppeteer.locator.setvisibility.md @@ -8,7 +8,10 @@ sidebar_label: Locator.setVisibility ```typescript class Locator { - abstract setVisibility(visibility: VisibilityOption): this; + setVisibility( + this: Locator, + visibility: VisibilityOption + ): Locator; } ``` @@ -16,8 +19,9 @@ class Locator { | Parameter | Type | Description | | ---------- | --------------------------------------------------- | ----------- | +| this | [Locator](./puppeteer.locator.md)<NodeType> | | | visibility | [VisibilityOption](./puppeteer.visibilityoption.md) | | **Returns:** -this +[Locator](./puppeteer.locator.md)<NodeType> diff --git a/docs/api/puppeteer.locator.setwaitforenabled.md b/docs/api/puppeteer.locator.setwaitforenabled.md index 3b097f4023c..5b23898fb61 100644 --- a/docs/api/puppeteer.locator.setwaitforenabled.md +++ b/docs/api/puppeteer.locator.setwaitforenabled.md @@ -8,16 +8,20 @@ sidebar_label: Locator.setWaitForEnabled ```typescript class Locator { - abstract setWaitForEnabled(value: boolean): this; + setWaitForEnabled( + this: Locator, + value: boolean + ): Locator; } ``` ## Parameters -| Parameter | Type | Description | -| --------- | ------- | ----------- | -| value | boolean | | +| Parameter | Type | Description | +| --------- | ------------------------------------------------- | ----------- | +| this | [Locator](./puppeteer.locator.md)<NodeType> | | +| value | boolean | | **Returns:** -this +[Locator](./puppeteer.locator.md)<NodeType> diff --git a/docs/api/puppeteer.locator.setwaitforstableboundingbox.md b/docs/api/puppeteer.locator.setwaitforstableboundingbox.md index f76323a1799..fd0141aaf3a 100644 --- a/docs/api/puppeteer.locator.setwaitforstableboundingbox.md +++ b/docs/api/puppeteer.locator.setwaitforstableboundingbox.md @@ -8,16 +8,20 @@ sidebar_label: Locator.setWaitForStableBoundingBox ```typescript class Locator { - abstract setWaitForStableBoundingBox(value: boolean): this; + setWaitForStableBoundingBox( + this: Locator, + value: boolean + ): Locator; } ``` ## Parameters -| Parameter | Type | Description | -| --------- | ------- | ----------- | -| value | boolean | | +| Parameter | Type | Description | +| --------- | ---------------------------------------------------- | ----------- | +| this | [Locator](./puppeteer.locator.md)<ElementType> | | +| value | boolean | | **Returns:** -this +[Locator](./puppeteer.locator.md)<ElementType> diff --git a/docs/api/puppeteer.unionlocatorof.md b/docs/api/puppeteer.unionlocatorof.md deleted file mode 100644 index ea442953b4d..00000000000 --- a/docs/api/puppeteer.unionlocatorof.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -sidebar_label: UnionLocatorOf ---- - -# UnionLocatorOf type - -#### Signature: - -```typescript -export type UnionLocatorOf = T extends Array> ? S : never; -``` - -**References:** [Locator](./puppeteer.locator.md) diff --git a/package-lock.json b/package-lock.json index 5bf156669bf..c05de4ba9f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,8 +18,8 @@ "@microsoft/api-extractor-model": "7.27.4", "@pptr/testserver": "file:packages/testserver", "@prettier/sync": "0.2.1", - "@rollup/plugin-commonjs": "25.0.2", "@rollup/plugin-node-resolve": "15.1.0", + "@rollup/plugin-terser": "0.4.3", "@types/debug": "4.1.8", "@types/diff": "5.0.3", "@types/mime": "3.0.1", @@ -1528,6 +1528,20 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.0", "dev": true, @@ -1536,10 +1550,39 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", + "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.14", "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.18", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz", + "integrity": "sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "3.1.0", + "@jridgewell/sourcemap-codec": "1.4.14" + } + }, "node_modules/@microsoft/api-documenter": { "version": "7.22.27", "resolved": "https://registry.npmjs.org/@microsoft/api-documenter/-/api-documenter-7.22.27.tgz", @@ -1935,76 +1978,6 @@ "resolved": "packages/ng-schematics", "link": true }, - "node_modules/@rollup/plugin-commonjs": { - "version": "25.0.2", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.2.tgz", - "integrity": "sha512-NGTwaJxIO0klMs+WSFFtBP7b9TdTJ3K76HZkewT8/+yHzMiUGVQgaPtLQxNVYIgT5F7lxkEyVID+yS3K7bhCow==", - "dev": true, - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "commondir": "^1.0.1", - "estree-walker": "^2.0.2", - "glob": "^8.0.3", - "is-reference": "1.2.1", - "magic-string": "^0.27.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.68.0||^3.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-commonjs/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@rollup/plugin-commonjs/node_modules/estree-walker": { - "version": "2.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/plugin-commonjs/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@rollup/plugin-commonjs/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@rollup/plugin-node-resolve": { "version": "15.1.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.1.0.tgz", @@ -2035,6 +2008,37 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/plugin-terser": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.3.tgz", + "integrity": "sha512-EF0oejTMtkyhrkwCdg0HJ0IpkcaVg1MMSf2olHb2Jp+1mnLM04OhjpJWGma4HobiDTF0WCyViWuvadyE9ch2XA==", + "dev": true, + "dependencies": { + "serialize-javascript": "^6.0.1", + "smob": "^1.0.0", + "terser": "^5.17.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.x || ^3.x" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-terser/node_modules/serialize-javascript": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", + "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, "node_modules/@rollup/pluginutils": { "version": "5.0.2", "dev": true, @@ -3529,11 +3533,6 @@ "node": "^12.20.0 || >=14" } }, - "node_modules/commondir": { - "version": "1.0.1", - "dev": true, - "license": "MIT" - }, "node_modules/commonmark": { "version": "0.30.0", "dev": true, @@ -6293,14 +6292,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-reference": { - "version": "1.2.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, "node_modules/is-regex": { "version": "1.1.4", "dev": true, @@ -6885,17 +6876,6 @@ "node": ">=10" } }, - "node_modules/magic-string": { - "version": "0.27.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.13" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/make-dir": { "version": "3.1.0", "dev": true, @@ -9344,6 +9324,12 @@ "npm": ">= 3.0.0" } }, + "node_modules/smob": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.4.0.tgz", + "integrity": "sha512-MqR3fVulhjWuRNSMydnTlweu38UhQ0HXM4buStD/S3mc/BzX3CuM9OmhyQpmtYCvoYdl5ris6TI0ZqH355Ymqg==", + "dev": true + }, "node_modules/socks": { "version": "2.7.1", "license": "MIT", @@ -9761,6 +9747,30 @@ "node": ">=8" } }, + "node_modules/terser": { + "version": "5.19.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.19.2.tgz", + "integrity": "sha512-qC5+dmecKJA4cpYxRa5aVkKehYsQKc+AHeKl0Oe62aYjBL8ZA33tTljktDHJSaxxMnbI5ZYw+o/S2DxxLu8OfA==", + "dev": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, "node_modules/test-exclude": { "version": "6.0.0", "dev": true, @@ -10148,15 +10158,6 @@ "node": ">=10.12.0" } }, - "node_modules/v8-to-istanbul/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.16", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "3.1.0", - "@jridgewell/sourcemap-codec": "1.4.14" - } - }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "dev": true, @@ -10570,12 +10571,28 @@ }, "devDependencies": { "mitt": "3.0.0", - "parsel-js": "1.1.0" + "parsel-js": "1.1.0", + "rxjs": "7.8.1" }, "engines": { "node": ">=16.3.0" } }, + "packages/puppeteer-core/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "packages/puppeteer-core/node_modules/tslib": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz", + "integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA==", + "dev": true + }, "packages/testserver": { "name": "@pptr/testserver", "version": "0.6.0", @@ -11463,13 +11480,50 @@ "chalk": "^4.0.0" } }, + "@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, + "requires": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, "@jridgewell/resolve-uri": { "version": "3.1.0", "dev": true }, + "@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true + }, + "@jridgewell/source-map": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", + "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, "@jridgewell/sourcemap-codec": { "version": "1.4.14" }, + "@jridgewell/trace-mapping": { + "version": "0.3.18", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz", + "integrity": "sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "3.1.0", + "@jridgewell/sourcemap-codec": "1.4.14" + } + }, "@microsoft/api-documenter": { "version": "7.22.27", "resolved": "https://registry.npmjs.org/@microsoft/api-documenter/-/api-documenter-7.22.27.tgz", @@ -11823,57 +11877,6 @@ } } }, - "@rollup/plugin-commonjs": { - "version": "25.0.2", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.2.tgz", - "integrity": "sha512-NGTwaJxIO0klMs+WSFFtBP7b9TdTJ3K76HZkewT8/+yHzMiUGVQgaPtLQxNVYIgT5F7lxkEyVID+yS3K7bhCow==", - "dev": true, - "requires": { - "@rollup/pluginutils": "^5.0.1", - "commondir": "^1.0.1", - "estree-walker": "^2.0.2", - "glob": "^8.0.3", - "is-reference": "1.2.1", - "magic-string": "^0.27.0" - }, - "dependencies": { - "brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0" - } - }, - "estree-walker": { - "version": "2.0.2", - "dev": true - }, - "glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - } - }, - "minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "requires": { - "brace-expansion": "^2.0.1" - } - } - } - }, "@rollup/plugin-node-resolve": { "version": "15.1.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.1.0.tgz", @@ -11894,6 +11897,28 @@ } } }, + "@rollup/plugin-terser": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.3.tgz", + "integrity": "sha512-EF0oejTMtkyhrkwCdg0HJ0IpkcaVg1MMSf2olHb2Jp+1mnLM04OhjpJWGma4HobiDTF0WCyViWuvadyE9ch2XA==", + "dev": true, + "requires": { + "serialize-javascript": "^6.0.1", + "smob": "^1.0.0", + "terser": "^5.17.4" + }, + "dependencies": { + "serialize-javascript": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", + "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + } + } + }, "@rollup/pluginutils": { "version": "5.0.2", "dev": true, @@ -12938,10 +12963,6 @@ "dev": true, "optional": true }, - "commondir": { - "version": "1.0.1", - "dev": true - }, "commonmark": { "version": "0.30.0", "dev": true, @@ -14726,13 +14747,6 @@ "version": "1.1.0", "dev": true }, - "is-reference": { - "version": "1.2.1", - "dev": true, - "requires": { - "@types/estree": "*" - } - }, "is-regex": { "version": "1.1.4", "dev": true, @@ -15152,13 +15166,6 @@ "yallist": "^4.0.0" } }, - "magic-string": { - "version": "0.27.0", - "dev": true, - "requires": { - "@jridgewell/sourcemap-codec": "^1.4.13" - } - }, "make-dir": { "version": "3.1.0", "dev": true, @@ -16337,7 +16344,25 @@ "devtools-protocol": "0.0.1147663", "mitt": "3.0.0", "parsel-js": "1.1.0", + "rxjs": "7.8.1", "ws": "8.13.0" + }, + "dependencies": { + "rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "requires": { + "tslib": "^2.1.0" + } + }, + "tslib": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz", + "integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA==", + "dev": true + } } }, "queue-microtask": { @@ -16845,6 +16870,12 @@ "smart-buffer": { "version": "4.2.0" }, + "smob": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.4.0.tgz", + "integrity": "sha512-MqR3fVulhjWuRNSMydnTlweu38UhQ0HXM4buStD/S3mc/BzX3CuM9OmhyQpmtYCvoYdl5ris6TI0ZqH355Ymqg==", + "dev": true + }, "socks": { "version": "2.7.1", "requires": { @@ -17155,6 +17186,26 @@ "streamx": "^2.15.0" } }, + "terser": { + "version": "5.19.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.19.2.tgz", + "integrity": "sha512-qC5+dmecKJA4cpYxRa5aVkKehYsQKc+AHeKl0Oe62aYjBL8ZA33tTljktDHJSaxxMnbI5ZYw+o/S2DxxLu8OfA==", + "dev": true, + "requires": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "dependencies": { + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + } + } + }, "test-exclude": { "version": "6.0.0", "dev": true, @@ -17420,16 +17471,6 @@ "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^1.6.0" - }, - "dependencies": { - "@jridgewell/trace-mapping": { - "version": "0.3.16", - "dev": true, - "requires": { - "@jridgewell/resolve-uri": "3.1.0", - "@jridgewell/sourcemap-codec": "1.4.14" - } - } } }, "validate-npm-package-license": { diff --git a/package.json b/package.json index 29385ceb23b..7fd74e8d0df 100644 --- a/package.json +++ b/package.json @@ -110,8 +110,8 @@ "@microsoft/api-extractor-model": "7.27.4", "@pptr/testserver": "file:packages/testserver", "@prettier/sync": "0.2.1", - "@rollup/plugin-commonjs": "25.0.2", "@rollup/plugin-node-resolve": "15.1.0", + "@rollup/plugin-terser": "0.4.3", "@types/debug": "4.1.8", "@types/diff": "5.0.3", "@types/mime": "3.0.1", diff --git a/packages/puppeteer-core/package.json b/packages/puppeteer-core/package.json index a978ce2fb5b..c39bfaa1304 100644 --- a/packages/puppeteer-core/package.json +++ b/packages/puppeteer-core/package.json @@ -152,6 +152,7 @@ "@puppeteer/browsers": "1.4.6" }, "devDependencies": { + "rxjs": "7.8.1", "mitt": "3.0.0", "parsel-js": "1.1.0" } diff --git a/packages/puppeteer-core/rollup.third_party.config.mjs b/packages/puppeteer-core/rollup.third_party.config.mjs index 36b83c079be..b9ae06b58d3 100644 --- a/packages/puppeteer-core/rollup.third_party.config.mjs +++ b/packages/puppeteer-core/rollup.third_party.config.mjs @@ -13,8 +13,8 @@ * 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 terser from '@rollup/plugin-terser'; import {globSync} from 'glob'; export default ['cjs', 'esm'].flatMap(outputType => { @@ -28,7 +28,7 @@ export default ['cjs', 'esm'].flatMap(outputType => { file, format: outputType, }, - plugins: [commonjs(), nodeResolve()], + plugins: [terser(), nodeResolve()], }); } return configs; diff --git a/packages/puppeteer-core/src/api/Page.ts b/packages/puppeteer-core/src/api/Page.ts index 2b0fadeb164..9629210a359 100644 --- a/packages/puppeteer-core/src/api/Page.ts +++ b/packages/puppeteer-core/src/api/Page.ts @@ -73,7 +73,7 @@ import type { } from './Frame.js'; import {Keyboard, KeyboardTypeOptions, Mouse, Touchscreen} from './Input.js'; import type {JSHandle} from './JSHandle.js'; -import {Locator, NodeLocator, UnionLocatorOf} from './locators/locators.js'; +import {AwaitedLocator, Locator, NodeLocator} from './locators/locators.js'; import type {Target} from './Target.js'; /** @@ -850,10 +850,10 @@ export class Page extends EventEmitter { * * @internal */ - locatorRace>>( + locatorRace( locators: Locators - ): Locator> { - return Locator.race(locators as Array>>); + ): Locator> { + return Locator.race(locators); } /** diff --git a/packages/puppeteer-core/src/api/locators/ExpectedLocator.ts b/packages/puppeteer-core/src/api/locators/ExpectedLocator.ts index b11571a0482..b0a8bc6b122 100644 --- a/packages/puppeteer-core/src/api/locators/ExpectedLocator.ts +++ b/packages/puppeteer-core/src/api/locators/ExpectedLocator.ts @@ -1,16 +1,30 @@ -import {Awaitable} from '../../common/common.js'; -import {ElementHandle} from '../ElementHandle.js'; +/** + * 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 { - ActionOptions, - LOCATOR_CONTEXTS, - Locator, - LocatorClickOptions, - LocatorContext, - LocatorScrollOptions, - VisibilityOption, - type ActionCondition, -} from './locators.js'; + Observable, + from, + map, + mergeMap, + throwIfEmpty, +} from '../../../third_party/rxjs/rxjs.js'; +import {Awaitable, HandleFor} from '../../common/common.js'; +import {ElementHandle} from '../ElementHandle.js'; + +import {ActionOptions, Locator, VisibilityOption} from './locators.js'; /** * @public @@ -31,76 +45,75 @@ export class ExpectedLocator extends Locator { this.#base = base; this.#predicate = predicate; + + this.copyOptions(this.#base); } - override setVisibility(visibility: VisibilityOption): this { - this.#base.setVisibility(visibility); - return this; - } override setTimeout(timeout: number): this { + super.setTimeout(timeout); this.#base.setTimeout(timeout); return this; } - override setEnsureElementIsInTheViewport(value: boolean): this { - this.#base.setEnsureElementIsInTheViewport(value); + + override setVisibility( + this: ExpectedLocator, + visibility: VisibilityOption + ): Locator { + super.setVisibility(visibility); + this.#base.setVisibility(visibility); return this; } - override setWaitForEnabled(value: boolean): this { + + override setWaitForEnabled( + this: ExpectedLocator, + value: boolean + ): Locator { + super.setWaitForEnabled(value); this.#base.setWaitForEnabled(value); return this; } - override setWaitForStableBoundingBox(value: boolean): this { + + override setEnsureElementIsInTheViewport< + FromElement extends Element, + ToElement extends FromElement, + >( + this: ExpectedLocator, + value: boolean + ): Locator { + super.setEnsureElementIsInTheViewport(value); + this.#base.setEnsureElementIsInTheViewport(value); + return this; + } + + override setWaitForStableBoundingBox< + FromElement extends Element, + ToElement extends FromElement, + >( + this: ExpectedLocator, + value: boolean + ): Locator { + super.setWaitForStableBoundingBox(value); this.#base.setWaitForStableBoundingBox(value); return this; } - #condition: ActionCondition = async (handle, signal) => { - // TODO(jrandolf): We should remove this once JSHandle has waitForFunction. - await (handle as ElementHandle).frame.waitForFunction( - this.#predicate, - {signal}, - handle + override _wait(options?: Readonly): Observable> { + return this.#base._wait(options).pipe( + mergeMap(handle => { + return from( + (handle as ElementHandle).frame.waitForFunction( + this.#predicate, + {signal: options?.signal, timeout: this.timeout}, + handle + ) + ).pipe( + map(() => { + // SAFETY: It passed the predicate, so this is correct. + return handle as HandleFor; + }) + ); + }), + throwIfEmpty() ); - }; - - #insertFilterCondition< - FromElement extends Node, - ToElement extends FromElement, - >(this: ExpectedLocator): void { - const context = (LOCATOR_CONTEXTS.get(this.#base) ?? - {}) as LocatorContext; - context.conditions ??= new Set(); - context.conditions.add(this.#condition); - LOCATOR_CONTEXTS.set(this.#base, context); - } - - override click( - this: ExpectedLocator, - options?: Readonly - ): Promise { - this.#insertFilterCondition(); - return this.#base.click(options); - } - override fill( - this: ExpectedLocator, - value: string, - options?: Readonly - ): Promise { - this.#insertFilterCondition(); - return this.#base.fill(value, options); - } - override hover( - this: ExpectedLocator, - options?: Readonly - ): Promise { - this.#insertFilterCondition(); - return this.#base.hover(options); - } - override scroll( - this: ExpectedLocator, - options?: Readonly - ): Promise { - this.#insertFilterCondition(); - return this.#base.scroll(options); } } diff --git a/packages/puppeteer-core/src/api/locators/Locator.ts b/packages/puppeteer-core/src/api/locators/Locator.ts index 3d8e99f0c61..94d268e086f 100644 --- a/packages/puppeteer-core/src/api/locators/Locator.ts +++ b/packages/puppeteer-core/src/api/locators/Locator.ts @@ -1,28 +1,67 @@ -import {EventEmitter} from '../../common/EventEmitter.js'; -import {ClickOptions} from '../ElementHandle.js'; +/** + * 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 { + EMPTY, + Observable, + OperatorFunction, + catchError, + defaultIfEmpty, + defer, + filter, + first, + firstValueFrom, + from, + fromEvent, + identity, + ignoreElements, + map, + merge, + mergeMap, + noop, + pipe, + raceWith, + retry, + tap, + timer, +} from '../../../third_party/rxjs/rxjs.js'; +import {TimeoutError} from '../../common/Errors.js'; +import {EventEmitter} from '../../common/EventEmitter.js'; +import {HandleFor} from '../../common/types.js'; +import {debugError} from '../../common/util.js'; +import {BoundingBox, ClickOptions, ElementHandle} from '../ElementHandle.js'; + +import { + Action, + AwaitedLocator, ExpectedLocator, Predicate, RaceLocator, - UnionLocatorOf, - type ActionCondition, } from './locators.js'; /** + * For observables coming from promises, a delay is needed, otherwise RxJS will + * never yield in a permanent failure for a promise. + * + * We also don't want RxJS to do promise operations to often, so we bump the + * delay up to 100ms. + * * @internal */ -export interface LocatorContext { - conditions?: Set>; -} - -/** - * @internal - */ -export const LOCATOR_CONTEXTS = new WeakMap< - Locator, - LocatorContext ->(); +export const RETRY_DELAY = 100; /** * @public @@ -113,33 +152,81 @@ export interface LocatorEventObject { * @public */ export abstract class Locator extends EventEmitter { + /** + * Creates a race between multiple locators but ensures that only a single one + * acts. + * + * @public + */ + static race( + locators: Locators + ): Locator> { + return RaceLocator.create(locators); + } + /** * Used for nominally typing {@link Locator}. */ declare _?: T; /** - * Creates a race between multiple locators but ensures that only a single one - * acts. - */ - static race>>( - locators: Locators - ): Locator> { - return new RaceLocator( - locators as Array>> - ); - } - - /** - * Creates an expectation that is evaluated against located values. - * - * If the expectations do not match, then the locator will retry. - * * @internal */ - expect(predicate: Predicate): Locator { - return new ExpectedLocator(this, predicate); - } + protected visibility: VisibilityOption = null; + /** + * @internal + */ + protected timeout = 30_000; + #ensureElementIsInTheViewport = true; + #waitForEnabled = true; + #waitForStableBoundingBox = true; + + /** + * @internal + */ + protected operators = { + conditions: ( + conditions: Array>, + signal?: AbortSignal + ): OperatorFunction, HandleFor> => { + return mergeMap((handle: HandleFor) => { + return merge( + ...conditions.map(condition => { + return condition(handle, signal); + }) + ).pipe(defaultIfEmpty(handle)); + }); + }, + retryAndRaceWithSignalAndTimer: ( + signal?: AbortSignal + ): OperatorFunction => { + const candidates = []; + if (signal) { + candidates.push( + fromEvent(signal, 'abort').pipe( + map(() => { + throw signal.reason; + }) + ) + ); + } + if (this.timeout > 0) { + candidates.push( + timer(this.timeout).pipe( + map(() => { + throw new TimeoutError( + `Timed out after waiting ${this.timeout}ms` + ); + }) + ) + ); + } + return pipe( + retry({delay: RETRY_DELAY}), + raceWith(...candidates) + ); + }, + }; override on( eventName: K, @@ -162,20 +249,409 @@ export abstract class Locator extends EventEmitter { return super.off(eventName, handler); } - abstract setVisibility(visibility: VisibilityOption): this; + setTimeout(timeout: number): this { + this.timeout = timeout; + return this; + } - abstract setTimeout(timeout: number): this; + setVisibility( + this: Locator, + visibility: VisibilityOption + ): Locator { + this.visibility = visibility; + return this; + } - abstract setEnsureElementIsInTheViewport(value: boolean): this; + setWaitForEnabled( + this: Locator, + value: boolean + ): Locator { + this.#waitForEnabled = value; + return this; + } - abstract setWaitForEnabled(value: boolean): this; + setEnsureElementIsInTheViewport( + this: Locator, + value: boolean + ): Locator { + this.#ensureElementIsInTheViewport = value; + return this; + } - abstract setWaitForStableBoundingBox(value: boolean): this; + setWaitForStableBoundingBox( + this: Locator, + value: boolean + ): Locator { + this.#waitForStableBoundingBox = value; + return this; + } - abstract click( + /** + * @internal + */ + copyOptions(locator: Locator): this { + this.timeout = locator.timeout; + this.visibility = locator.visibility; + this.#waitForEnabled = locator.#waitForEnabled; + this.#ensureElementIsInTheViewport = locator.#ensureElementIsInTheViewport; + this.#waitForStableBoundingBox = locator.#waitForStableBoundingBox; + return this; + } + + /** + * If the element has a "disabled" property, wait for the element to be + * enabled. + */ + #waitForEnabledIfNeeded = ( + handle: HandleFor, + signal?: AbortSignal + ): Observable => { + if (!this.#waitForEnabled) { + return EMPTY; + } + return from( + handle.frame.page().waitForFunction( + element => { + if ('disabled' in element && typeof element.disabled === 'boolean') { + return !element.disabled; + } + return true; + }, + { + timeout: this.timeout, + signal, + }, + handle + ) + ).pipe(ignoreElements()); + }; + + /** + * Compares the bounding box of the element for two consecutive animation + * frames and waits till they are the same. + */ + #waitForStableBoundingBoxIfNeeded = ( + handle: HandleFor + ): Observable => { + if (!this.#waitForStableBoundingBox) { + return EMPTY; + } + return defer(() => { + // Note we don't use waitForFunction because that relies on RAF. + return from( + handle.evaluate(element => { + return new Promise<[BoundingBox, BoundingBox]>(resolve => { + window.requestAnimationFrame(() => { + const rect1 = element.getBoundingClientRect(); + window.requestAnimationFrame(() => { + const rect2 = element.getBoundingClientRect(); + resolve([ + { + x: rect1.x, + y: rect1.y, + width: rect1.width, + height: rect1.height, + }, + { + x: rect2.x, + y: rect2.y, + width: rect2.width, + height: rect2.height, + }, + ]); + }); + }); + }); + }) + ); + }).pipe( + first(([rect1, rect2]) => { + return ( + rect1.x === rect2.x && + rect1.y === rect2.y && + rect1.width === rect2.width && + rect1.height === rect2.height + ); + }), + retry({delay: RETRY_DELAY}), + ignoreElements() + ); + }; + + /** + * Checks if the element is in the viewport and auto-scrolls it if it is not. + */ + #ensureElementIsInTheViewportIfNeeded = ( + handle: HandleFor + ): Observable => { + if (!this.#ensureElementIsInTheViewport) { + return EMPTY; + } + return from(handle.isIntersectingViewport({threshold: 0})).pipe( + filter(isIntersectingViewport => { + return !isIntersectingViewport; + }), + mergeMap(() => { + return from(handle.scrollIntoView()); + }), + mergeMap(() => { + return defer(() => { + return from(handle.isIntersectingViewport({threshold: 0})); + }).pipe(first(identity), retry({delay: RETRY_DELAY}), ignoreElements()); + }) + ); + }; + + #click( this: Locator, options?: Readonly - ): Promise; + ): Observable { + const signal = options?.signal; + return this._wait(options).pipe( + this.operators.conditions( + [ + this.#ensureElementIsInTheViewportIfNeeded, + this.#waitForStableBoundingBoxIfNeeded, + this.#waitForEnabledIfNeeded, + ], + signal + ), + tap(() => { + return this.emit(LocatorEmittedEvents.Action); + }), + mergeMap(handle => { + return from(handle.click(options)).pipe( + catchError((_, caught) => { + void handle.dispose().catch(debugError); + return caught; + }) + ); + }), + this.operators.retryAndRaceWithSignalAndTimer(signal) + ); + } + + #fill( + this: Locator, + value: string, + options?: Readonly + ): Observable { + const signal = options?.signal; + return this._wait(options).pipe( + this.operators.conditions( + [ + this.#ensureElementIsInTheViewportIfNeeded, + this.#waitForStableBoundingBoxIfNeeded, + this.#waitForEnabledIfNeeded, + ], + signal + ), + tap(() => { + return this.emit(LocatorEmittedEvents.Action); + }), + mergeMap(handle => { + return from( + (handle as unknown as ElementHandle).evaluate(el => { + if (el instanceof HTMLSelectElement) { + return 'select'; + } + if (el instanceof HTMLInputElement) { + if ( + new Set([ + 'textarea', + 'text', + 'url', + 'tel', + 'search', + 'password', + 'number', + 'email', + ]).has(el.type) + ) { + return 'typeable-input'; + } else { + return 'other-input'; + } + } + + if (el.isContentEditable) { + return 'contenteditable'; + } + + return 'unknown'; + }) + ) + .pipe( + mergeMap(inputType => { + switch (inputType) { + case 'select': + return from(handle.select(value).then(noop)); + case 'contenteditable': + case 'typeable-input': + return from( + ( + handle as unknown as ElementHandle + ).evaluate((input, newValue) => { + const currentValue = input.isContentEditable + ? input.innerText + : input.value; + + // Clear the input if the current value does not match the filled + // out value. + if ( + newValue.length <= currentValue.length || + !newValue.startsWith(input.value) + ) { + if (input.isContentEditable) { + input.innerText = ''; + } else { + input.value = ''; + } + return newValue; + } + const originalValue = input.isContentEditable + ? input.innerText + : input.value; + + // If the value is partially filled out, only type the rest. Move + // cursor to the end of the common prefix. + if (input.isContentEditable) { + input.innerText = ''; + input.innerText = originalValue; + } else { + input.value = ''; + input.value = originalValue; + } + return newValue.substring(originalValue.length); + }, value) + ).pipe( + mergeMap(textToType => { + return from(handle.type(textToType)); + }) + ); + case 'other-input': + return from(handle.focus()).pipe( + mergeMap(() => { + return from( + handle.evaluate((input, value) => { + (input as HTMLInputElement).value = value; + input.dispatchEvent( + new Event('input', {bubbles: true}) + ); + input.dispatchEvent( + new Event('change', {bubbles: true}) + ); + }, value) + ); + }) + ); + case 'unknown': + throw new Error(`Element cannot be filled out.`); + } + }) + ) + .pipe( + catchError((_, caught) => { + void handle.dispose().catch(debugError); + return caught; + }) + ); + }), + this.operators.retryAndRaceWithSignalAndTimer(signal) + ); + } + + #hover( + this: Locator, + options?: Readonly + ): Observable { + const signal = options?.signal; + return this._wait(options).pipe( + this.operators.conditions( + [ + this.#ensureElementIsInTheViewportIfNeeded, + this.#waitForStableBoundingBoxIfNeeded, + ], + signal + ), + tap(() => { + return this.emit(LocatorEmittedEvents.Action); + }), + mergeMap(handle => { + return from(handle.hover()).pipe( + catchError((_, caught) => { + void handle.dispose().catch(debugError); + return caught; + }) + ); + }), + this.operators.retryAndRaceWithSignalAndTimer(signal) + ); + } + + #scroll( + this: Locator, + options?: Readonly + ): Observable { + const signal = options?.signal; + return this._wait(options).pipe( + this.operators.conditions( + [ + this.#ensureElementIsInTheViewportIfNeeded, + this.#waitForStableBoundingBoxIfNeeded, + ], + signal + ), + tap(() => { + return this.emit(LocatorEmittedEvents.Action); + }), + mergeMap(handle => { + return from( + handle.evaluate( + (el, scrollTop, scrollLeft) => { + if (scrollTop !== undefined) { + el.scrollTop = scrollTop; + } + if (scrollLeft !== undefined) { + el.scrollLeft = scrollLeft; + } + }, + options?.scrollTop, + options?.scrollLeft + ) + ).pipe( + catchError((_, caught) => { + void handle.dispose().catch(debugError); + return caught; + }) + ); + }), + this.operators.retryAndRaceWithSignalAndTimer(signal) + ); + } + + /** + * @internal + */ + abstract _wait(options?: Readonly): Observable>; + + /** + * Creates an expectation that is evaluated against located values. + * + * If the expectations do not match, then the locator will retry. + * + * @internal + */ + expect(predicate: Predicate): Locator { + return new ExpectedLocator(this, predicate); + } + + click( + this: Locator, + options?: Readonly + ): Promise { + return firstValueFrom(this.#click(options)); + } /** * Fills out the input identified by the locator using the provided value. The @@ -183,19 +659,25 @@ export abstract class Locator extends EventEmitter { * method is chosen based on the type. contenteditable, selector, inputs are * supported. */ - abstract fill( + fill( this: Locator, value: string, options?: Readonly - ): Promise; + ): Promise { + return firstValueFrom(this.#fill(value, options)); + } - abstract hover( + hover( this: Locator, options?: Readonly - ): Promise; + ): Promise { + return firstValueFrom(this.#hover(options)); + } - abstract scroll( + scroll( this: Locator, options?: Readonly - ): Promise; + ): Promise { + return firstValueFrom(this.#scroll(options)); + } } diff --git a/packages/puppeteer-core/src/api/locators/NodeLocator.ts b/packages/puppeteer-core/src/api/locators/NodeLocator.ts index 4534411a1ff..c70ada133fa 100644 --- a/packages/puppeteer-core/src/api/locators/NodeLocator.ts +++ b/packages/puppeteer-core/src/api/locators/NodeLocator.ts @@ -1,45 +1,49 @@ -import {TimeoutError} from '../../common/Errors.js'; -import {Awaitable, HandleFor, NodeFor} from '../../common/types.js'; -import {debugError} from '../../common/util.js'; -import {isErrorLike} from '../../util/ErrorLike.js'; -import {BoundingBox, ElementHandle} from '../ElementHandle.js'; -import type {Frame} from '../Frame.js'; -import type {Page} from '../Page.js'; +/** + * 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 { - ActionOptions, - LOCATOR_CONTEXTS, - Locator, - LocatorClickOptions, - LocatorEmittedEvents, - LocatorScrollOptions, - VisibilityOption, -} from './locators.js'; + EMPTY, + Observable, + defer, + filter, + first, + from, + identity, + ignoreElements, + retry, + throwIfEmpty, +} from '../../../third_party/rxjs/rxjs.js'; +import {HandleFor, NodeFor} from '../../common/types.js'; +import {Frame} from '../Frame.js'; +import {Page} from '../Page.js'; -/** - * Timeout for individual operations inside the locator. On errors the - * operation is retried as long as {@link Locator.setTimeout} is not - * exceeded. This timeout should be generally much lower as locating an - * element means multiple asynchronious operations. - */ -const CONDITION_TIMEOUT = 1000; -const WAIT_FOR_FUNCTION_DELAY = 100; +import {ActionOptions, Locator, RETRY_DELAY} from './locators.js'; /** * @internal */ -export type ActionCondition = ( +export type Action = ( element: HandleFor, - signal: AbortSignal -) => Promise; + signal?: AbortSignal +) => Observable; /** * @internal */ export class NodeLocator extends Locator { - /** - * @internal - */ static create( pageOrFrame: Page | Frame, selector: Selector @@ -53,447 +57,55 @@ export class NodeLocator extends Locator { #pageOrFrame: Page | Frame; #selector: string; - #visibility: VisibilityOption = 'visible'; - #timeout = 30000; - #ensureElementIsInTheViewport = true; - #waitForEnabled = true; - #waitForStableBoundingBox = true; - constructor(pageOrFrame: Page | Frame, selector: string) { + private constructor(pageOrFrame: Page | Frame, selector: string) { super(); + this.#pageOrFrame = pageOrFrame; this.#selector = selector; } - setVisibility(visibility: VisibilityOption): this { - this.#visibility = visibility; - return this; - } - - setTimeout(timeout: number): this { - this.#timeout = timeout; - return this; - } - - setEnsureElementIsInTheViewport(value: boolean): this { - this.#ensureElementIsInTheViewport = value; - return this; - } - - setWaitForEnabled(value: boolean): this { - this.#waitForEnabled = value; - return this; - } - - setWaitForStableBoundingBox(value: boolean): this { - this.#waitForStableBoundingBox = value; - return this; - } - - /** - * Retries the `fn` until a truthy result is returned. - */ - async #waitForFunction( - fn: (signal: AbortSignal) => Awaitable, - signal?: AbortSignal, - timeout = CONDITION_TIMEOUT - ): Promise { - let isActive = true; - let controller: AbortController; - // If the loop times out, we abort only the last iteration's controller. - const timeoutId = timeout - ? setTimeout(() => { - isActive = false; - controller?.abort(); - }, timeout) - : 0; - // If the user's signal aborts, we abort the last iteration and the loop. - signal?.addEventListener( - 'abort', - () => { - controller?.abort(); - isActive = false; - clearTimeout(timeoutId); - }, - {once: true} - ); - while (isActive) { - controller = new AbortController(); - try { - const result = await fn(controller.signal); - clearTimeout(timeoutId); - return result; - } catch (err) { - if (isErrorLike(err)) { - debugError(err); - // Retry on all timeouts. - if (err instanceof TimeoutError) { - continue; - } - // Abort error are ignored as they only affect one iteration. - if (err.name === 'AbortError') { - continue; - } - } - throw err; - } finally { - // We abort any operations that might have been started by `fn`, because - // the iteration is now over. - controller.abort(); - } - await new Promise(resolve => { - return setTimeout(resolve, WAIT_FOR_FUNCTION_DELAY); - }); - } - signal?.throwIfAborted(); - throw new TimeoutError( - `waitForFunction timed out. The timeout is ${timeout}ms.` - ); - } - - /** - * Checks if the element is in the viewport and auto-scrolls it if it is not. - */ - #ensureElementIsInTheViewportIfNeeded = async ( - element: HandleFor, - signal?: AbortSignal - ): Promise => { - if (!this.#ensureElementIsInTheViewport) { - return; - } - // Side-effect: this also checks if it is connected. - const isIntersectingViewport = await element.isIntersectingViewport({ - threshold: 0, - }); - signal?.throwIfAborted(); - if (!isIntersectingViewport) { - await element.scrollIntoView(); - signal?.throwIfAborted(); - await this.#waitForFunction(async () => { - return await element.isIntersectingViewport({ - threshold: 0, - }); - }, signal); - signal?.throwIfAborted(); - } - }; - /** * Waits for the element to become visible or hidden. visibility === 'visible' * means that the element has a computed style, the visibility property other * than 'hidden' or 'collapse' and non-empty bounding box. visibility === * 'hidden' means the opposite of that. */ - #waitForVisibilityIfNeeded = async ( - element: HandleFor, - signal?: AbortSignal - ): Promise => { - if (this.#visibility === null) { - return; + #waitForVisibilityIfNeeded = (handle: HandleFor): Observable => { + if (!this.visibility) { + return EMPTY; } - if (this.#visibility === 'hidden') { - await this.#waitForFunction(async () => { - return element.isHidden(); - }, signal); - } - await this.#waitForFunction(async () => { - return element.isVisible(); - }, signal); - }; - /** - * If the element has a "disabled" property, wait for the element to be - * enabled. - */ - #waitForEnabledIfNeeded = async ( - element: HandleFor, - signal?: AbortSignal - ): Promise => { - if (!this.#waitForEnabled) { - return; - } - await this.#pageOrFrame.waitForFunction( - el => { - if ('disabled' in el && typeof el.disabled === 'boolean') { - return !el.disabled; - } - return true; - }, - { - timeout: CONDITION_TIMEOUT, - signal, - }, - element - ); - }; - - /** - * Compares the bounding box of the element for two consecutive animation - * frames and waits till they are the same. - */ - #waitForStableBoundingBoxIfNeeded = async ( - element: HandleFor, - signal?: AbortSignal - ): Promise => { - if (!this.#waitForStableBoundingBox) { - return; - } - function getClientRect() { - return element.evaluate(el => { - return new Promise<[BoundingBox, BoundingBox]>(resolve => { - window.requestAnimationFrame(() => { - const rect1 = el.getBoundingClientRect(); - window.requestAnimationFrame(() => { - const rect2 = el.getBoundingClientRect(); - resolve([ - { - x: rect1.x, - y: rect1.y, - width: rect1.width, - height: rect1.height, - }, - { - x: rect2.x, - y: rect2.y, - width: rect2.width, - height: rect2.height, - }, - ]); - }); + return (() => { + switch (this.visibility) { + case 'hidden': + return defer(() => { + return from(handle.isHidden()); }); - }); - }); - } - await this.#waitForFunction(async () => { - const [rect1, rect2] = await getClientRect(); - return ( - rect1.x === rect2.x && - rect1.y === rect2.y && - rect1.width === rect2.width && - rect1.height === rect2.height - ); - }, signal); + case 'visible': + return defer(() => { + return from(handle.isVisible()); + }); + } + })().pipe(first(identity), retry({delay: RETRY_DELAY}), ignoreElements()); }; - #run( - action: (el: HandleFor) => Promise, - signal?: AbortSignal, - conditions: Array> = [] - ): Promise { - const globalConditions = [ - ...(LOCATOR_CONTEXTS.get(this)?.conditions?.values() ?? []), - ] as Array>; - const allConditions = conditions.concat(globalConditions); - return this.#waitForFunction( - async signal => { - // 1. Select the element without visibility checks. - const element = (await this.#pageOrFrame.waitForSelector( - this.#selector, - { - visible: false, - timeout: this.#timeout, - signal, - } - )) as HandleFor | null; - // Retry if no element is found. - if (!element) { - throw new Error('No element found'); - } - signal?.throwIfAborted(); - // 2. Perform action specific checks. - await Promise.all( - allConditions.map(check => { - return check(element, signal); - }) - ); - signal?.throwIfAborted(); - // 3. Perform the action - this.emit(LocatorEmittedEvents.Action); - try { - return await action(element); - } catch (error) { - void element.dispose().catch(debugError); - throw error; - } - }, - signal, - this.#timeout - ); - } - - async click( - this: NodeLocator, - options?: Readonly - ): Promise { - await this.#run( - async element => { - await element.click(options); - void element.dispose().catch(debugError); - }, - options?.signal, - [ - this.#ensureElementIsInTheViewportIfNeeded, - this.#waitForVisibilityIfNeeded, - this.#waitForEnabledIfNeeded, - this.#waitForStableBoundingBoxIfNeeded, - ] - ); - } - - /** - * Fills out the input identified by the locator using the provided value. The - * type of the input is determined at runtime and the appropriate fill-out - * method is chosen based on the type. contenteditable, selector, inputs are - * supported. - */ - async fill( - this: NodeLocator, - value: string, - options?: Readonly - ): Promise { - await this.#run( - async element => { - const input = element as unknown as ElementHandle; - const inputType = await input.evaluate(el => { - if (el instanceof HTMLSelectElement) { - return 'select'; - } - if (el instanceof HTMLInputElement) { - if ( - new Set([ - 'textarea', - 'text', - 'url', - 'tel', - 'search', - 'password', - 'number', - 'email', - ]).has(el.type) - ) { - return 'typeable-input'; - } else { - return 'other-input'; - } - } - - if (el.isContentEditable) { - return 'contenteditable'; - } - - return 'unknown'; - }); - - switch (inputType) { - case 'select': - await input.select(value); - break; - case 'contenteditable': - case 'typeable-input': - const textToType = await ( - input as ElementHandle - ).evaluate((input, newValue) => { - const currentValue = input.isContentEditable - ? input.innerText - : input.value; - - // Clear the input if the current value does not match the filled - // out value. - if ( - newValue.length <= currentValue.length || - !newValue.startsWith(input.value) - ) { - if (input.isContentEditable) { - input.innerText = ''; - } else { - input.value = ''; - } - return newValue; - } - const originalValue = input.isContentEditable - ? input.innerText - : input.value; - - // If the value is partially filled out, only type the rest. Move - // cursor to the end of the common prefix. - if (input.isContentEditable) { - input.innerText = ''; - input.innerText = originalValue; - } else { - input.value = ''; - input.value = originalValue; - } - return newValue.substring(originalValue.length); - }, value); - await input.type(textToType); - break; - case 'other-input': - await input.focus(); - await input.evaluate((input, value) => { - (input as HTMLInputElement).value = value; - input.dispatchEvent(new Event('input', {bubbles: true})); - input.dispatchEvent(new Event('change', {bubbles: true})); - }, value); - break; - case 'unknown': - throw new Error(`Element cannot be filled out.`); - } - void element.dispose().catch(debugError); - }, - options?.signal, - [ - this.#ensureElementIsInTheViewportIfNeeded, - this.#waitForVisibilityIfNeeded, - this.#waitForEnabledIfNeeded, - this.#waitForStableBoundingBoxIfNeeded, - ] - ); - } - - async hover( - this: NodeLocator, - options?: Readonly - ): Promise { - await this.#run( - async element => { - await element.hover(); - void element.dispose().catch(debugError); - }, - options?.signal, - [ - this.#ensureElementIsInTheViewportIfNeeded, - this.#waitForVisibilityIfNeeded, - this.#waitForStableBoundingBoxIfNeeded, - ] - ); - } - - async scroll( - this: NodeLocator, - options?: Readonly - ): Promise { - await this.#run( - async element => { - await element.evaluate( - (el, scrollTop, scrollLeft) => { - if (scrollTop !== undefined) { - el.scrollTop = scrollTop; - } - if (scrollLeft !== undefined) { - el.scrollLeft = scrollLeft; - } - }, - options?.scrollTop, - options?.scrollLeft - ); - void element.dispose().catch(debugError); - }, - options?.signal, - [ - this.#ensureElementIsInTheViewportIfNeeded, - this.#waitForVisibilityIfNeeded, - this.#waitForStableBoundingBoxIfNeeded, - ] + _wait(options?: Readonly): Observable> { + const signal = options?.signal; + return defer(() => { + return from( + this.#pageOrFrame.waitForSelector(this.#selector, { + visible: false, + timeout: this.timeout, + signal, + }) as Promise | null> + ); + }).pipe( + filter((value): value is NonNullable => { + return value !== null; + }), + throwIfEmpty(), + this.operators.conditions([this.#waitForVisibilityIfNeeded], signal) ); } } diff --git a/packages/puppeteer-core/src/api/locators/RaceLocator.ts b/packages/puppeteer-core/src/api/locators/RaceLocator.ts index 6fd5aeca51c..e5890d42f0f 100644 --- a/packages/puppeteer-core/src/api/locators/RaceLocator.ts +++ b/packages/puppeteer-core/src/api/locators/RaceLocator.ts @@ -1,174 +1,63 @@ -import {isErrorLike} from '../../util/ErrorLike.js'; +/** + * 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 { - Locator, - VisibilityOption, - LocatorEmittedEvents, - LocatorClickOptions, - ActionOptions, - LocatorScrollOptions, -} from './locators.js'; +import {Observable, race} from '../../../third_party/rxjs/rxjs.js'; +import {HandleFor} from '../../puppeteer-core.js'; + +import {ActionOptions, Locator} from './locators.js'; /** * @public */ -export type UnionLocatorOf = T extends Array> ? S : never; +export type AwaitedLocator = T extends Locator ? S : never; + +function checkLocatorArray( + locators: T +): ReadonlyArray>> { + for (const locator of locators) { + if (!(locator instanceof Locator)) { + throw new Error('Unknown locator for race candidate'); + } + } + return locators as ReadonlyArray>>; +} /** * @internal */ export class RaceLocator extends Locator { - #locators: Array>; + static create( + locators: T + ): Locator> { + const array = checkLocatorArray(locators); + return new RaceLocator(array); + } - constructor(locators: Array>) { + #locators: ReadonlyArray>; + + constructor(locators: ReadonlyArray>) { super(); this.#locators = locators; } - override setVisibility(visibility: VisibilityOption): this { - for (const locator of this.#locators) { - locator.setVisibility(visibility); - } - return this; - } - - override setTimeout(timeout: number): this { - for (const locator of this.#locators) { - locator.setTimeout(timeout); - } - return this; - } - - override setEnsureElementIsInTheViewport(value: boolean): this { - for (const locator of this.#locators) { - locator.setEnsureElementIsInTheViewport(value); - } - return this; - } - - override setWaitForEnabled(value: boolean): this { - for (const locator of this.#locators) { - locator.setWaitForEnabled(value); - } - return this; - } - - override setWaitForStableBoundingBox(value: boolean): this { - for (const locator of this.#locators) { - locator.setWaitForStableBoundingBox(value); - } - return this; - } - - async #run( - action: (locator: Locator, signal: AbortSignal) => Promise, - signal?: AbortSignal - ) { - const abortControllers = new WeakMap, AbortController>(); - - // Abort all locators if the user-provided signal aborts. - signal?.addEventListener('abort', () => { - for (const locator of this.#locators) { - abortControllers.get(locator)?.abort(); - } - }); - - const handleLocatorAction = (locator: Locator): (() => void) => { - return () => { - // When one locator is ready to act, we will abort other locators. - for (const other of this.#locators) { - if (other !== locator) { - abortControllers.get(other)?.abort(); - } - } - this.emit(LocatorEmittedEvents.Action); - }; - }; - - const createAbortController = (locator: Locator): AbortController => { - const abortController = new AbortController(); - abortControllers.set(locator, abortController); - return abortController; - }; - - const results = await Promise.allSettled( - this.#locators.map(locator => { - return action( - locator.on(LocatorEmittedEvents.Action, handleLocatorAction(locator)), - createAbortController(locator).signal - ); + override _wait(options?: Readonly): Observable> { + return race( + ...this.#locators.map(locator => { + return locator._wait(options); }) ); - - signal?.throwIfAborted(); - - const rejected = results.filter( - (result): result is PromiseRejectedResult => { - return result.status === 'rejected'; - } - ); - - // If some locators are fulfilled, do not throw. - if (rejected.length !== results.length) { - return; - } - - for (const result of rejected) { - const reason = result.reason; - // AbortError is be an expected result of a race. - if (isErrorLike(reason) && reason.name === 'AbortError') { - continue; - } - throw reason; - } - } - - async click( - this: RaceLocator, - options?: Readonly - ): Promise { - return await this.#run( - (locator, signal) => { - return locator.click({...options, signal}); - }, - options?.signal - ); - } - - async fill( - this: RaceLocator, - value: string, - options?: Readonly - ): Promise { - return await this.#run( - (locator, signal) => { - return locator.fill(value, {...options, signal}); - }, - options?.signal - ); - } - - async hover( - this: RaceLocator, - options?: Readonly - ): Promise { - return await this.#run( - (locator, signal) => { - return locator.hover({...options, signal}); - }, - options?.signal - ); - } - - async scroll( - this: RaceLocator, - options?: Readonly - ): Promise { - return await this.#run( - (locator, signal) => { - return locator.scroll({...options, signal}); - }, - options?.signal - ); } } diff --git a/packages/puppeteer-core/src/api/locators/locators.ts b/packages/puppeteer-core/src/api/locators/locators.ts index fe44cc4cd68..c717af9914c 100644 --- a/packages/puppeteer-core/src/api/locators/locators.ts +++ b/packages/puppeteer-core/src/api/locators/locators.ts @@ -1,3 +1,19 @@ +/** + * 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. + */ + export * from './Locator.js'; export * from './NodeLocator.js'; export * from './ExpectedLocator.js'; diff --git a/packages/puppeteer-core/third_party/rxjs/rxjs.ts b/packages/puppeteer-core/third_party/rxjs/rxjs.ts new file mode 100644 index 00000000000..5a2f27bf488 --- /dev/null +++ b/packages/puppeteer-core/third_party/rxjs/rxjs.ts @@ -0,0 +1,41 @@ +/** + * 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. + */ +export { + catchError, + defaultIfEmpty, + filter, + first, + ignoreElements, + map, + mergeMap, + raceWith, + retry, + tap, + throwIfEmpty, + firstValueFrom, + defer, + EMPTY, + from, + fromEvent, + merge, + race, + timer, + OperatorFunction, + identity, + noop, + pipe, + Observable, +} from 'rxjs'; diff --git a/test/src/locator.spec.ts b/test/src/locator.spec.ts index 89bb437a0f2..ee3c027419f 100644 --- a/test/src/locator.spec.ts +++ b/test/src/locator.spec.ts @@ -236,7 +236,7 @@ describe('Locator', function () { const result = page.locator('button').click(); clock.tick(5100); await expect(result).rejects.toEqual( - new TimeoutError('waitForFunction timed out. The timeout is 5000ms.') + new TimeoutError('Timed out after waiting 5000ms') ); } finally { clock.restore(); @@ -257,7 +257,7 @@ describe('Locator', function () { const result = page.locator('button').click(); clock.tick(5100); await expect(result).rejects.toEqual( - new TimeoutError('waitForFunction timed out. The timeout is 5000ms.') + new TimeoutError('Timed out after waiting 5000ms') ); } finally { clock.restore(); @@ -513,15 +513,16 @@ describe('Locator', function () { }); try { const {page} = await getTestState(); - page.setDefaultTimeout(5000); await page.setContent(``); const result = Locator.race([ page.locator('not-found'), page.locator('not-found'), - ]).click(); + ]) + .setTimeout(5000) + .click(); clock.tick(5100); await expect(result).rejects.toEqual( - new TimeoutError('waitForFunction timed out. The timeout is 5000ms.') + new TimeoutError('Timed out after waiting 5000ms') ); } finally { clock.restore();