Merge branch 'main' into fix-hover-trial
This commit is contained in:
commit
920e0854ea
6
.github/workflows/tests_bidi.yml
vendored
6
.github/workflows/tests_bidi.yml
vendored
|
|
@ -26,7 +26,7 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
channel: [bidi-chromium, bidi-firefox-beta]
|
||||
channel: [bidi-chromium, bidi-firefox-nightly]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
|
|
@ -38,8 +38,8 @@ jobs:
|
|||
- run: npm run build
|
||||
- run: npx playwright install --with-deps chromium
|
||||
if: matrix.channel == 'bidi-chromium'
|
||||
- run: npx -y @puppeteer/browsers install firefox@beta
|
||||
if: matrix.channel == 'bidi-firefox-beta'
|
||||
- run: npx -y @puppeteer/browsers install firefox@nightly
|
||||
if: matrix.channel == 'bidi-firefox-nightly'
|
||||
- name: Run tests
|
||||
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run biditest -- --project=${{ matrix.channel }}*
|
||||
env:
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@ When all steps combined have not finished during the specified [`option: timeout
|
|||
### option: Frame.click.timeout = %%-input-timeout-js-%%
|
||||
* since: v1.8
|
||||
|
||||
### option: Frame.click.trial = %%-input-trial-%%
|
||||
### option: Frame.click.trial = %%-input-trial-with-modifiers-%%
|
||||
* since: v1.11
|
||||
|
||||
## async method: Frame.content
|
||||
|
|
@ -341,7 +341,7 @@ When all steps combined have not finished during the specified [`option: timeout
|
|||
### option: Frame.dblclick.timeout = %%-input-timeout-js-%%
|
||||
* since: v1.8
|
||||
|
||||
### option: Frame.dblclick.trial = %%-input-trial-%%
|
||||
### option: Frame.dblclick.trial = %%-input-trial-with-modifiers-%%
|
||||
* since: v1.11
|
||||
|
||||
## async method: Frame.dispatchEvent
|
||||
|
|
@ -1153,7 +1153,7 @@ When all steps combined have not finished during the specified [`option: timeout
|
|||
### option: Frame.hover.timeout = %%-input-timeout-js-%%
|
||||
* since: v1.8
|
||||
|
||||
### option: Frame.hover.trial = %%-input-trial-%%
|
||||
### option: Frame.hover.trial = %%-input-trial-with-modifiers-%%
|
||||
* since: v1.11
|
||||
|
||||
### option: Frame.hover.noWaitAfter = %%-input-no-wait-after-removed-%%
|
||||
|
|
@ -1703,7 +1703,7 @@ When all steps combined have not finished during the specified [`option: timeout
|
|||
### option: Frame.tap.timeout = %%-input-timeout-js-%%
|
||||
* since: v1.8
|
||||
|
||||
### option: Frame.tap.trial = %%-input-trial-%%
|
||||
### option: Frame.tap.trial = %%-input-trial-with-modifiers-%%
|
||||
* since: v1.11
|
||||
|
||||
## async method: Frame.textContent
|
||||
|
|
|
|||
|
|
@ -433,7 +433,7 @@ await page.Locator("canvas").ClickAsync(new() {
|
|||
### option: Locator.click.timeout = %%-input-timeout-js-%%
|
||||
* since: v1.14
|
||||
|
||||
### option: Locator.click.trial = %%-input-trial-%%
|
||||
### option: Locator.click.trial = %%-input-trial-with-modifiers-%%
|
||||
* since: v1.14
|
||||
|
||||
## async method: Locator.count
|
||||
|
|
@ -516,7 +516,7 @@ When all steps combined have not finished during the specified [`option: timeout
|
|||
### option: Locator.dblclick.timeout = %%-input-timeout-js-%%
|
||||
* since: v1.14
|
||||
|
||||
### option: Locator.dblclick.trial = %%-input-trial-%%
|
||||
### option: Locator.dblclick.trial = %%-input-trial-with-modifiers-%%
|
||||
* since: v1.14
|
||||
|
||||
## async method: Locator.dispatchEvent
|
||||
|
|
@ -1266,7 +1266,7 @@ When all steps combined have not finished during the specified [`option: timeout
|
|||
### option: Locator.hover.timeout = %%-input-timeout-js-%%
|
||||
* since: v1.14
|
||||
|
||||
### option: Locator.hover.trial = %%-input-trial-%%
|
||||
### option: Locator.hover.trial = %%-input-trial-with-modifiers-%%
|
||||
* since: v1.14
|
||||
|
||||
### option: Locator.hover.noWaitAfter = %%-input-no-wait-after-removed-%%
|
||||
|
|
@ -2331,7 +2331,7 @@ When all steps combined have not finished during the specified [`option: timeout
|
|||
### option: Locator.tap.timeout = %%-input-timeout-js-%%
|
||||
* since: v1.14
|
||||
|
||||
### option: Locator.tap.trial = %%-input-trial-%%
|
||||
### option: Locator.tap.trial = %%-input-trial-with-modifiers-%%
|
||||
* since: v1.14
|
||||
|
||||
## async method: Locator.textContent
|
||||
|
|
|
|||
|
|
@ -812,7 +812,7 @@ When all steps combined have not finished during the specified [`option: timeout
|
|||
### option: Page.click.timeout = %%-input-timeout-js-%%
|
||||
* since: v1.8
|
||||
|
||||
### option: Page.click.trial = %%-input-trial-%%
|
||||
### option: Page.click.trial = %%-input-trial-with-modifiers-%%
|
||||
* since: v1.11
|
||||
|
||||
## async method: Page.close
|
||||
|
|
@ -915,7 +915,7 @@ When all steps combined have not finished during the specified [`option: timeout
|
|||
### option: Page.dblclick.timeout = %%-input-timeout-js-%%
|
||||
* since: v1.8
|
||||
|
||||
### option: Page.dblclick.trial = %%-input-trial-%%
|
||||
### option: Page.dblclick.trial = %%-input-trial-with-modifiers-%%
|
||||
* since: v1.11
|
||||
|
||||
## async method: Page.dispatchEvent
|
||||
|
|
@ -2437,7 +2437,7 @@ When all steps combined have not finished during the specified [`option: timeout
|
|||
### option: Page.hover.timeout = %%-input-timeout-js-%%
|
||||
* since: v1.8
|
||||
|
||||
### option: Page.hover.trial = %%-input-trial-%%
|
||||
### option: Page.hover.trial = %%-input-trial-with-modifiers-%%
|
||||
* since: v1.11
|
||||
|
||||
### option: Page.hover.noWaitAfter = %%-input-no-wait-after-removed-%%
|
||||
|
|
@ -4099,7 +4099,7 @@ When all steps combined have not finished during the specified [`option: timeout
|
|||
### option: Page.tap.timeout = %%-input-timeout-js-%%
|
||||
* since: v1.8
|
||||
|
||||
### option: Page.tap.trial = %%-input-trial-%%
|
||||
### option: Page.tap.trial = %%-input-trial-with-modifiers-%%
|
||||
* since: v1.11
|
||||
|
||||
## async method: Page.textContent
|
||||
|
|
|
|||
|
|
@ -136,6 +136,11 @@ defaults to 1. See [UIEvent.detail].
|
|||
|
||||
When set, this method only performs the [actionability](../actionability.md) checks and skips the action. Defaults to `false`. Useful to wait until the element is ready for the action without performing it.
|
||||
|
||||
## input-trial-with-modifiers
|
||||
- `trial` <[boolean]>
|
||||
|
||||
When set, this method only performs the [actionability](../actionability.md) checks and skips the action. Defaults to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys are pressed.
|
||||
|
||||
## input-source-position
|
||||
- `sourcePosition` <[Object]>
|
||||
- `x` <[float]>
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: lts/*
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Install Playwright Browsers
|
||||
|
|
@ -214,7 +214,7 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: lts/*
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Run your tests
|
||||
|
|
@ -319,7 +319,7 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: lts/*
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Install Playwright
|
||||
|
|
@ -434,7 +434,7 @@ jobs:
|
|||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: lts/*
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Install Playwright Browsers
|
||||
|
|
|
|||
|
|
@ -700,6 +700,27 @@ await Page.RouteAsync("**/title.html", async route =>
|
|||
});
|
||||
```
|
||||
|
||||
## Glob URL patterns
|
||||
|
||||
Playwright uses simplified glob patterns for URL matching in network interception methods like [`method: Page.route`] or [`method: Page.waitForResponse`]. These patterns support basic wildcards:
|
||||
|
||||
1. Asterisks:
|
||||
- A single `*` matches any characters except `/`
|
||||
- A double `**` matches any characters including `/`
|
||||
1. Question mark `?` matches any single character except `/`
|
||||
1. Curly braces `{}` can be used to match a list of options separated by commas `,`
|
||||
|
||||
Examples:
|
||||
- `https://example.com/*.js` matches `https://example.com/file.js` but not `https://example.com/path/file.js`
|
||||
- `**/*.js` matches both `https://example.com/file.js` and `https://example.com/path/file.js`
|
||||
- `**/*.{png,jpg,jpeg}` matches all image requests
|
||||
|
||||
Important notes:
|
||||
|
||||
- The glob pattern must match the entire URL, not just a part of it.
|
||||
- When using globs for URL matching, consider the full URL structure, including the protocol and path separators.
|
||||
- For more complex matching requirements, consider using [RegExp] instead of glob patterns.
|
||||
|
||||
## WebSockets
|
||||
|
||||
Playwright supports [WebSockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) inspection out of the box. Every time a WebSocket is created, the [`event: Page.webSocket`] event is fired. This event contains the [WebSocket] instance for further web socket frames inspection:
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: lts/*
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Install Playwright browsers
|
||||
|
|
@ -118,7 +118,7 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: lts/*
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
|
|
|
|||
140
package-lock.json
generated
140
package-lock.json
generated
|
|
@ -1517,9 +1517,9 @@
|
|||
"link": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.3.tgz",
|
||||
"integrity": "sha512-MmKSfaB9GX+zXl6E8z4koOr/xU63AMVleLEa64v7R0QF/ZloMs5vcD1sHgM64GXXS1csaJutG+ddtzcueI/BLg==",
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz",
|
||||
"integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -1529,9 +1529,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.3.tgz",
|
||||
"integrity": "sha512-zrt8ecH07PE3sB4jPOggweBjJMzI1JG5xI2DIsUbkA+7K+Gkjys6eV7i9pOenNSDJH3eOr/jLb/PzqtmdwDq5g==",
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz",
|
||||
"integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1541,9 +1541,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.3.tgz",
|
||||
"integrity": "sha512-P0UxIOrKNBFTQaXTxOH4RxuEBVCgEA5UTNV6Yz7z9QHnUJ7eLX9reOd/NYMO3+XZO2cco19mXTxDMXxit4R/eQ==",
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz",
|
||||
"integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1553,9 +1553,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.3.tgz",
|
||||
"integrity": "sha512-L1M0vKGO5ASKntqtsFEjTq/fD91vAqnzeaF6sfNAy55aD+Hi2pBI5DKwCO+UNDQHWsDViJLqshxOahXyLSh3EA==",
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz",
|
||||
"integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1565,9 +1565,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.3.tgz",
|
||||
"integrity": "sha512-btVgIsCjuYFKUjopPoWiDqmoUXQDiW2A4C3Mtmp5vACm7/GnyuprqIDPNczeyR5W8rTXEbkmrJux7cJmD99D2g==",
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz",
|
||||
"integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -1577,9 +1577,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.3.tgz",
|
||||
"integrity": "sha512-zmjbSphplZlau6ZTkxd3+NMtE4UKVy7U4aVFMmHcgO5CUbw17ZP6QCgyxhzGaU/wFFdTfiojjbLG3/0p9HhAqA==",
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz",
|
||||
"integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -1589,9 +1589,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.3.tgz",
|
||||
"integrity": "sha512-nSZfcZtAnQPRZmUkUQwZq2OjQciR6tEoJaZVFvLHsj0MF6QhNMg0fQ6mUOsiCUpTqxTx0/O6gX0V/nYc7LrgPw==",
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz",
|
||||
"integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1601,9 +1601,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.3.tgz",
|
||||
"integrity": "sha512-MnvSPGO8KJXIMGlQDYfvYS3IosFN2rKsvxRpPO2l2cum+Z3exiExLwVU+GExL96pn8IP+GdH8Tz70EpBhO0sIQ==",
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz",
|
||||
"integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1613,9 +1613,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
|
||||
"version": "4.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.3.tgz",
|
||||
"integrity": "sha512-+W+p/9QNDr2vE2AXU0qIy0qQE75E8RTwTwgqS2G5CRQ11vzq0tbnfBd6brWhS9bCRjAjepJe2fvvkvS3dno+iw==",
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz",
|
||||
"integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -1625,9 +1625,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.3.tgz",
|
||||
"integrity": "sha512-yXH6K6KfqGXaxHrtr+Uoy+JpNlUlI46BKVyonGiaD74ravdnF9BUNC+vV+SIuB96hUMGShhKV693rF9QDfO6nQ==",
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz",
|
||||
"integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -1637,9 +1637,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.3.tgz",
|
||||
"integrity": "sha512-R8cwY9wcnApN/KDYWTH4gV/ypvy9yZUHlbJvfaiXSB48JO3KpwSpjOGqO4jnGkLDSk1hgjYkTbTt6Q7uvPf8eg==",
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz",
|
||||
"integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
|
|
@ -1649,9 +1649,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.3.tgz",
|
||||
"integrity": "sha512-kZPbX/NOPh0vhS5sI+dR8L1bU2cSO9FgxwM8r7wHzGydzfSjLRCFAT87GR5U9scj2rhzN3JPYVC7NoBbl4FZ0g==",
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz",
|
||||
"integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1661,9 +1661,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.3.tgz",
|
||||
"integrity": "sha512-S0Yq+xA1VEH66uiMNhijsWAafffydd2X5b77eLHfRmfLsRSpbiAWiRHV6DEpz6aOToPsgid7TI9rGd6zB1rhbg==",
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz",
|
||||
"integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1673,9 +1673,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.3.tgz",
|
||||
"integrity": "sha512-9isNzeL34yquCPyerog+IMCNxKR8XYmGd0tHSV+OVx0TmE0aJOo9uw4fZfUuk2qxobP5sug6vNdZR6u7Mw7Q+Q==",
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz",
|
||||
"integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1685,9 +1685,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.3.tgz",
|
||||
"integrity": "sha512-nMIdKnfZfzn1Vsk+RuOvl43ONTZXoAPUUxgcU0tXooqg4YrAqzfKzVenqqk2g5efWh46/D28cKFrOzDSW28gTA==",
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz",
|
||||
"integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
|
@ -1697,9 +1697,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.3.tgz",
|
||||
"integrity": "sha512-fOvu7PCQjAj4eWDEuD8Xz5gpzFqXzGlxHZozHP4b9Jxv9APtdxL6STqztDzMLuRXEc4UpXGGhx029Xgm91QBeA==",
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz",
|
||||
"integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -5417,9 +5417,9 @@
|
|||
"dev": true
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz",
|
||||
"integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==",
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
|
|
@ -6313,9 +6313,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.21.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.3.tgz",
|
||||
"integrity": "sha512-7sqRtBNnEbcBtMeRVc6VRsJMmpI+JU1z9VTvW8D4gXIYQFz0aLcsE6rRkyghZkLfEgUZgVvOG7A5CVz/VW5GIA==",
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz",
|
||||
"integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.5"
|
||||
},
|
||||
|
|
@ -6327,22 +6327,22 @@
|
|||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.21.3",
|
||||
"@rollup/rollup-android-arm64": "4.21.3",
|
||||
"@rollup/rollup-darwin-arm64": "4.21.3",
|
||||
"@rollup/rollup-darwin-x64": "4.21.3",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.21.3",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.21.3",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.21.3",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.21.3",
|
||||
"@rollup/rollup-linux-powerpc64le-gnu": "4.21.3",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.21.3",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.21.3",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.21.3",
|
||||
"@rollup/rollup-linux-x64-musl": "4.21.3",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.21.3",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.21.3",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.21.3",
|
||||
"@rollup/rollup-android-arm-eabi": "4.22.4",
|
||||
"@rollup/rollup-android-arm64": "4.22.4",
|
||||
"@rollup/rollup-darwin-arm64": "4.22.4",
|
||||
"@rollup/rollup-darwin-x64": "4.22.4",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.22.4",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.22.4",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.22.4",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.22.4",
|
||||
"@rollup/rollup-linux-powerpc64le-gnu": "4.22.4",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.22.4",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.22.4",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.22.4",
|
||||
"@rollup/rollup-linux-x64-musl": "4.22.4",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.22.4",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.22.4",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.22.4",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@
|
|||
},
|
||||
{
|
||||
"name": "chromium-tip-of-tree",
|
||||
"revision": "1261",
|
||||
"revision": "1263",
|
||||
"installByDefault": false,
|
||||
"browserVersion": "131.0.6726.0"
|
||||
"browserVersion": "131.0.6736.0"
|
||||
},
|
||||
{
|
||||
"name": "firefox",
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
},
|
||||
{
|
||||
"name": "webkit",
|
||||
"revision": "2080",
|
||||
"revision": "2082",
|
||||
"installByDefault": true,
|
||||
"revisionOverrides": {
|
||||
"mac10.14": "1446",
|
||||
|
|
|
|||
|
|
@ -397,7 +397,7 @@ async function launchContext(options: Options, extraOptions: LaunchOptions): Pro
|
|||
process.stdout.write('\n-------------8<-------------\n');
|
||||
const autoExitCondition = process.env.PWTEST_CLI_AUTO_EXIT_WHEN;
|
||||
if (autoExitCondition && text.includes(autoExitCondition))
|
||||
Promise.all(context.pages().map(async p => p.close()));
|
||||
closeBrowser();
|
||||
};
|
||||
// Make sure we exit abnormally when browser crashes.
|
||||
const logs: string[] = [];
|
||||
|
|
@ -504,7 +504,7 @@ async function launchContext(options: Options, extraOptions: LaunchOptions): Pro
|
|||
if (hasPage)
|
||||
return;
|
||||
// Avoid the error when the last page is closed because the browser has been closed.
|
||||
closeBrowser().catch(e => null);
|
||||
closeBrowser().catch(() => {});
|
||||
});
|
||||
});
|
||||
process.on('SIGINT', async () => {
|
||||
|
|
@ -560,7 +560,7 @@ async function open(options: Options, url: string | undefined, language: string)
|
|||
|
||||
async function codegen(options: Options & { target: string, output?: string, testIdAttribute?: string }, url: string | undefined) {
|
||||
const { target: language, output: outputFile, testIdAttribute: testIdAttributeName } = options;
|
||||
const tracesDir = path.join(os.tmpdir(), `recorder-trace-${Date.now()}`);
|
||||
const tracesDir = path.join(os.tmpdir(), `playwright-recorder-trace-${Date.now()}`);
|
||||
const { context, launchOptions, contextOptions } = await launchContext(options, {
|
||||
headless: !!process.env.PWTEST_CLI_HEADLESS,
|
||||
executablePath: process.env.PWTEST_CLI_EXECUTABLE_PATH,
|
||||
|
|
@ -574,6 +574,7 @@ async function codegen(options: Options & { target: string, output?: string, tes
|
|||
device: options.device,
|
||||
saveStorage: options.saveStorage,
|
||||
mode: 'recording',
|
||||
codegenMode: process.env.PW_RECORDER_IS_TRACE_VIEWER ? 'trace-events' : 'actions',
|
||||
testIdAttributeName,
|
||||
outputFile: outputFile ? path.resolve(outputFile) : undefined,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -220,7 +220,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
|
|||
}
|
||||
// If the page is closed or unrouteAll() was called without waiting and interception disabled,
|
||||
// the method will throw an error - silence it.
|
||||
await route._innerContinue(true).catch(() => {});
|
||||
await route._innerContinue(true /* isFallback */).catch(() => {});
|
||||
}
|
||||
|
||||
async _onWebSocketRoute(webSocketRoute: network.WebSocketRoute) {
|
||||
|
|
@ -492,17 +492,8 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
|
|||
await this._closedPromise;
|
||||
}
|
||||
|
||||
async _enableRecorder(params: {
|
||||
language: string,
|
||||
launchOptions?: LaunchOptions,
|
||||
contextOptions?: BrowserContextOptions,
|
||||
device?: string,
|
||||
saveStorage?: string,
|
||||
mode?: 'recording' | 'inspecting',
|
||||
testIdAttributeName?: string,
|
||||
outputFile?: string,
|
||||
}) {
|
||||
await this._channel.recorderSupplementEnable(params);
|
||||
async _enableRecorder(params: channels.BrowserContextEnableRecorderParams) {
|
||||
await this._channel.enableRecorder(params);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
|
|||
_logger: Logger | undefined;
|
||||
readonly _instrumentation: ClientInstrumentation;
|
||||
private _eventToSubscriptionMapping: Map<string, string> = new Map();
|
||||
private _isInternalType = false;
|
||||
_wasCollected: boolean = false;
|
||||
|
||||
constructor(parent: ChannelOwner | Connection, type: string, guid: string, initializer: channels.InitializerTraits<T>) {
|
||||
|
|
@ -61,6 +62,10 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
|
|||
this._initializer = initializer;
|
||||
}
|
||||
|
||||
protected markAsInternalType() {
|
||||
this._isInternalType = true;
|
||||
}
|
||||
|
||||
_setEventToSubscriptionMapping(mapping: Map<string, string>) {
|
||||
this._eventToSubscriptionMapping = mapping;
|
||||
}
|
||||
|
|
@ -173,7 +178,7 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
|
|||
let apiName: string | undefined = stackTrace.apiName;
|
||||
const frames: channels.StackFrame[] = stackTrace.frames;
|
||||
|
||||
isInternal = isInternal || this._type === 'LocalUtils';
|
||||
isInternal = isInternal || this._isInternalType;
|
||||
if (isInternal)
|
||||
apiName = undefined;
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export class LocalUtils extends ChannelOwner<channels.LocalUtilsChannel> {
|
|||
|
||||
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.LocalUtilsInitializer) {
|
||||
super(parent, type, guid, initializer);
|
||||
this.markAsInternalType();
|
||||
this.devices = {};
|
||||
for (const { name, descriptor } of initializer.deviceDescriptors)
|
||||
this.devices[name] = descriptor;
|
||||
|
|
|
|||
|
|
@ -299,6 +299,7 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
|
|||
|
||||
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.RouteInitializer) {
|
||||
super(parent, type, guid, initializer);
|
||||
this.markAsInternalType();
|
||||
}
|
||||
|
||||
request(): Request {
|
||||
|
|
@ -325,7 +326,7 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
|
|||
|
||||
async abort(errorCode?: string) {
|
||||
await this._handleRoute(async () => {
|
||||
await this._raceWithTargetClose(this._channel.abort({ requestUrl: this.request()._initializer.url, errorCode }));
|
||||
await this._raceWithTargetClose(this._channel.abort({ errorCode }));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -409,7 +410,6 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
|
|||
headers['content-length'] = String(length);
|
||||
|
||||
await this._raceWithTargetClose(this._channel.fulfill({
|
||||
requestUrl: this.request()._initializer.url,
|
||||
status: statusOption || 200,
|
||||
headers: headersObjectToArray(headers),
|
||||
body,
|
||||
|
|
@ -421,7 +421,7 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
|
|||
async continue(options: FallbackOverrides = {}) {
|
||||
await this._handleRoute(async () => {
|
||||
this.request()._applyFallbackOverrides(options);
|
||||
await this._innerContinue();
|
||||
await this._innerContinue(false /* isFallback */);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -436,18 +436,15 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
|
|||
chain.resolve(done);
|
||||
}
|
||||
|
||||
async _innerContinue(internal = false) {
|
||||
async _innerContinue(isFallback: boolean) {
|
||||
const options = this.request()._fallbackOverridesForContinue();
|
||||
return await this._wrapApiCall(async () => {
|
||||
await this._raceWithTargetClose(this._channel.continue({
|
||||
requestUrl: this.request()._initializer.url,
|
||||
url: options.url,
|
||||
method: options.method,
|
||||
headers: options.headers ? headersObjectToArray(options.headers) : undefined,
|
||||
postData: options.postDataBuffer,
|
||||
isFallback: internal,
|
||||
}));
|
||||
}, !!internal);
|
||||
return await this._raceWithTargetClose(this._channel.continue({
|
||||
url: options.url,
|
||||
method: options.method,
|
||||
headers: options.headers ? headersObjectToArray(options.headers) : undefined,
|
||||
postData: options.postDataBuffer,
|
||||
isFallback,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,20 +31,18 @@ export class Tracing extends ChannelOwner<channels.TracingChannel> implements ap
|
|||
|
||||
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.TracingInitializer) {
|
||||
super(parent, type, guid, initializer);
|
||||
this.markAsInternalType();
|
||||
}
|
||||
|
||||
async start(options: { name?: string, title?: string, snapshots?: boolean, screenshots?: boolean, sources?: boolean, _live?: boolean } = {}) {
|
||||
this._includeSources = !!options.sources;
|
||||
const traceName = await this._wrapApiCall(async () => {
|
||||
await this._channel.tracingStart({
|
||||
name: options.name,
|
||||
snapshots: options.snapshots,
|
||||
screenshots: options.screenshots,
|
||||
live: options._live,
|
||||
});
|
||||
const response = await this._channel.tracingStartChunk({ name: options.name, title: options.title });
|
||||
return response.traceName;
|
||||
}, true);
|
||||
await this._channel.tracingStart({
|
||||
name: options.name,
|
||||
snapshots: options.snapshots,
|
||||
screenshots: options.screenshots,
|
||||
live: options._live,
|
||||
});
|
||||
const { traceName } = await this._channel.tracingStartChunk({ name: options.name, title: options.title });
|
||||
await this._startCollectingStacks(traceName);
|
||||
}
|
||||
|
||||
|
|
@ -63,16 +61,12 @@ export class Tracing extends ChannelOwner<channels.TracingChannel> implements ap
|
|||
}
|
||||
|
||||
async stopChunk(options: { path?: string } = {}) {
|
||||
await this._wrapApiCall(async () => {
|
||||
await this._doStopChunk(options.path);
|
||||
}, true);
|
||||
await this._doStopChunk(options.path);
|
||||
}
|
||||
|
||||
async stop(options: { path?: string } = {}) {
|
||||
await this._wrapApiCall(async () => {
|
||||
await this._doStopChunk(options.path);
|
||||
await this._channel.tracingStop();
|
||||
}, true);
|
||||
await this._doStopChunk(options.path);
|
||||
await this._channel.tracingStop();
|
||||
}
|
||||
|
||||
private async _doStopChunk(filePath: string | undefined) {
|
||||
|
|
|
|||
|
|
@ -965,9 +965,10 @@ scheme.BrowserContextStorageStateResult = tObject({
|
|||
});
|
||||
scheme.BrowserContextPauseParams = tOptional(tObject({}));
|
||||
scheme.BrowserContextPauseResult = tOptional(tObject({}));
|
||||
scheme.BrowserContextRecorderSupplementEnableParams = tObject({
|
||||
scheme.BrowserContextEnableRecorderParams = tObject({
|
||||
language: tOptional(tString),
|
||||
mode: tOptional(tEnum(['inspecting', 'recording'])),
|
||||
codegenMode: tOptional(tEnum(['actions', 'trace-events'])),
|
||||
pauseOnNextStatement: tOptional(tBoolean),
|
||||
testIdAttributeName: tOptional(tString),
|
||||
launchOptions: tOptional(tAny),
|
||||
|
|
@ -977,7 +978,7 @@ scheme.BrowserContextRecorderSupplementEnableParams = tObject({
|
|||
outputFile: tOptional(tString),
|
||||
omitCallTracking: tOptional(tBoolean),
|
||||
});
|
||||
scheme.BrowserContextRecorderSupplementEnableResult = tOptional(tObject({}));
|
||||
scheme.BrowserContextEnableRecorderResult = tOptional(tObject({}));
|
||||
scheme.BrowserContextNewCDPSessionParams = tObject({
|
||||
page: tOptional(tChannel(['Page'])),
|
||||
frame: tOptional(tChannel(['Frame'])),
|
||||
|
|
@ -2115,7 +2116,6 @@ scheme.RouteRedirectNavigationRequestParams = tObject({
|
|||
scheme.RouteRedirectNavigationRequestResult = tOptional(tObject({}));
|
||||
scheme.RouteAbortParams = tObject({
|
||||
errorCode: tOptional(tString),
|
||||
requestUrl: tString,
|
||||
});
|
||||
scheme.RouteAbortResult = tOptional(tObject({}));
|
||||
scheme.RouteContinueParams = tObject({
|
||||
|
|
@ -2123,7 +2123,6 @@ scheme.RouteContinueParams = tObject({
|
|||
method: tOptional(tString),
|
||||
headers: tOptional(tArray(tType('NameValue'))),
|
||||
postData: tOptional(tBinary),
|
||||
requestUrl: tString,
|
||||
isFallback: tBoolean,
|
||||
});
|
||||
scheme.RouteContinueResult = tOptional(tObject({}));
|
||||
|
|
@ -2133,7 +2132,6 @@ scheme.RouteFulfillParams = tObject({
|
|||
body: tOptional(tString),
|
||||
isBase64: tOptional(tBoolean),
|
||||
fetchResponseUid: tOptional(tString),
|
||||
requestUrl: tString,
|
||||
});
|
||||
scheme.RouteFulfillResult = tOptional(tObject({}));
|
||||
scheme.WebSocketRouteInitializer = tObject({
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import * as dom from '../dom';
|
|||
import * as dialog from '../dialog';
|
||||
import type * as frames from '../frames';
|
||||
import { Page } from '../page';
|
||||
import type * as channels from '@protocol/channels';
|
||||
import type { InitScript, PageDelegate } from '../page';
|
||||
import type { Progress } from '../progress';
|
||||
import type * as types from '../types';
|
||||
|
|
@ -32,6 +33,7 @@ import * as bidi from './third_party/bidiProtocol';
|
|||
import { BidiExecutionContext } from './bidiExecutionContext';
|
||||
import { BidiNetworkManager } from './bidiNetworkManager';
|
||||
import { BrowserContext } from '../browserContext';
|
||||
import { BidiPDF } from './bidiPdf';
|
||||
|
||||
const UTILITY_WORLD_NAME = '__playwright_utility_world__';
|
||||
const kPlaywrightBindingChannel = 'playwrightChannel';
|
||||
|
|
@ -48,6 +50,7 @@ export class BidiPage implements PageDelegate {
|
|||
private _sessionListeners: RegisteredListener[] = [];
|
||||
readonly _browserContext: BidiBrowserContext;
|
||||
readonly _networkManager: BidiNetworkManager;
|
||||
private readonly _pdf: BidiPDF;
|
||||
_initializedPage: Page | null = null;
|
||||
private _initScriptIds: string[] = [];
|
||||
|
||||
|
|
@ -61,6 +64,7 @@ export class BidiPage implements PageDelegate {
|
|||
this._page = new Page(this, browserContext);
|
||||
this._browserContext = browserContext;
|
||||
this._networkManager = new BidiNetworkManager(this._session, this._page, this._onNavigationResponseStarted.bind(this));
|
||||
this._pdf = new BidiPDF(this._session);
|
||||
this._page.on(Page.Events.FrameDetached, (frame: frames.Frame) => this._removeContextsForFrame(frame, false));
|
||||
this._sessionListeners = [
|
||||
eventsHelper.addEventListener(bidiSession, 'script.realmCreated', this._onRealmCreated.bind(this)),
|
||||
|
|
@ -279,6 +283,9 @@ export class BidiPage implements PageDelegate {
|
|||
}
|
||||
|
||||
async bringToFront(): Promise<void> {
|
||||
await this._session.send('browsingContext.activate', {
|
||||
context: this._session.sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
private async _updateViewport(): Promise<void> {
|
||||
|
|
@ -555,6 +562,10 @@ export class BidiPage implements PageDelegate {
|
|||
async resetForReuse(): Promise<void> {
|
||||
}
|
||||
|
||||
async pdf(options: channels.PagePdfParams): Promise<Buffer> {
|
||||
return this._pdf.generate(options);
|
||||
}
|
||||
|
||||
async getFrameElement(frame: frames.Frame): Promise<dom.ElementHandle> {
|
||||
const parent = frame.parentFrame();
|
||||
if (!parent)
|
||||
|
|
|
|||
109
packages/playwright-core/src/server/bidi/bidiPdf.ts
Normal file
109
packages/playwright-core/src/server/bidi/bidiPdf.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* 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 '../../utils';
|
||||
import type * as channels from '@protocol/channels';
|
||||
import type { BidiSession } from './bidiConnection';
|
||||
|
||||
const PagePaperFormats: { [key: string]: { width: number, height: number }} = {
|
||||
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 },
|
||||
};
|
||||
|
||||
const unitToPixels: { [key: string]: number } = {
|
||||
'px': 1,
|
||||
'in': 96,
|
||||
'cm': 37.8,
|
||||
'mm': 3.78
|
||||
};
|
||||
|
||||
function convertPrintParameterToInches(text: string | undefined): number | undefined {
|
||||
if (text === undefined)
|
||||
return undefined;
|
||||
let unit = text.substring(text.length - 2).toLowerCase();
|
||||
let valueText = '';
|
||||
if (unitToPixels.hasOwnProperty(unit)) {
|
||||
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);
|
||||
const pixels = value * unitToPixels[unit];
|
||||
return pixels / 96;
|
||||
}
|
||||
|
||||
export class BidiPDF {
|
||||
private _session: BidiSession;
|
||||
|
||||
constructor(session: BidiSession) {
|
||||
this._session = session;
|
||||
}
|
||||
|
||||
async generate(options: channels.PagePdfParams): Promise<Buffer> {
|
||||
const {
|
||||
scale = 1,
|
||||
printBackground = false,
|
||||
landscape = false,
|
||||
pageRanges = '',
|
||||
margin = {},
|
||||
} = options;
|
||||
|
||||
let paperWidth = 8.5;
|
||||
let paperHeight = 11;
|
||||
if (options.format) {
|
||||
const format = PagePaperFormats[options.format.toLowerCase()];
|
||||
assert(format, 'Unknown paper format: ' + options.format);
|
||||
paperWidth = format.width;
|
||||
paperHeight = format.height;
|
||||
} else {
|
||||
paperWidth = convertPrintParameterToInches(options.width) || paperWidth;
|
||||
paperHeight = convertPrintParameterToInches(options.height) || paperHeight;
|
||||
}
|
||||
|
||||
const { data } = await this._session.send('browsingContext.print', {
|
||||
context: this._session.sessionId,
|
||||
background: printBackground,
|
||||
margin: {
|
||||
bottom: convertPrintParameterToInches(margin.bottom) || 0,
|
||||
left: convertPrintParameterToInches(margin.left) || 0,
|
||||
right: convertPrintParameterToInches(margin.right) || 0,
|
||||
top: convertPrintParameterToInches(margin.top) || 0
|
||||
},
|
||||
orientation: landscape ? 'landscape' : 'portrait',
|
||||
page: {
|
||||
width: paperWidth,
|
||||
height: paperHeight
|
||||
},
|
||||
pageRanges: pageRanges ? pageRanges.split(',').map(r => r.trim()) : undefined,
|
||||
scale,
|
||||
});
|
||||
return Buffer.from(data, 'base64');
|
||||
}
|
||||
}
|
||||
|
|
@ -131,15 +131,15 @@ export abstract class BrowserContext extends SdkObject {
|
|||
|
||||
// When PWDEBUG=1, show inspector for each context.
|
||||
if (debugMode() === 'inspector')
|
||||
await Recorder.show(this, RecorderApp.factory(this), { pauseOnNextStatement: true });
|
||||
await Recorder.show('actions', this, RecorderApp.factory(this), { pauseOnNextStatement: true });
|
||||
|
||||
// When paused, show inspector.
|
||||
if (this._debugger.isPaused())
|
||||
Recorder.showInspector(this, RecorderApp.factory(this));
|
||||
Recorder.showInspectorNoReply(this, RecorderApp.factory(this));
|
||||
|
||||
this._debugger.on(Debugger.Events.PausedStateChanged, () => {
|
||||
if (this._debugger.isPaused())
|
||||
Recorder.showInspector(this, RecorderApp.factory(this));
|
||||
Recorder.showInspectorNoReply(this, RecorderApp.factory(this));
|
||||
});
|
||||
|
||||
if (debugMode() === 'console')
|
||||
|
|
@ -525,7 +525,7 @@ export abstract class BrowserContext extends SdkObject {
|
|||
const internalMetadata = serverSideCallMetadata();
|
||||
const page = await this.newPage(internalMetadata);
|
||||
await page._setServerRequestInterceptor(handler => {
|
||||
handler.fulfill({ body: '<html></html>', requestUrl: handler.request().url() }).catch(() => {});
|
||||
handler.fulfill({ body: '<html></html>' }).catch(() => {});
|
||||
return true;
|
||||
});
|
||||
for (const origin of originsToSave) {
|
||||
|
|
@ -559,7 +559,7 @@ export abstract class BrowserContext extends SdkObject {
|
|||
isServerSide: false,
|
||||
});
|
||||
await page._setServerRequestInterceptor(handler => {
|
||||
handler.fulfill({ body: '<html></html>', requestUrl: handler.request().url() }).catch(() => {});
|
||||
handler.fulfill({ body: '<html></html>' }).catch(() => {});
|
||||
return true;
|
||||
});
|
||||
|
||||
|
|
@ -594,7 +594,7 @@ export abstract class BrowserContext extends SdkObject {
|
|||
const internalMetadata = serverSideCallMetadata();
|
||||
const page = await this.newPage(internalMetadata);
|
||||
await page._setServerRequestInterceptor(handler => {
|
||||
handler.fulfill({ body: '<html></html>', requestUrl: handler.request().url() }).catch(() => {});
|
||||
handler.fulfill({ body: '<html></html>' }).catch(() => {});
|
||||
return true;
|
||||
});
|
||||
for (const originState of state.origins) {
|
||||
|
|
|
|||
|
|
@ -694,16 +694,15 @@ class FrameSession {
|
|||
if (!frame || this._eventBelongsToStaleFrame(frame._id))
|
||||
return;
|
||||
const delegate = new CRExecutionContext(this._client, contextPayload);
|
||||
let worldName: types.World;
|
||||
let worldName: types.World|null = null;
|
||||
if (contextPayload.auxData && !!contextPayload.auxData.isDefault)
|
||||
worldName = 'main';
|
||||
else if (contextPayload.name === UTILITY_WORLD_NAME)
|
||||
worldName = 'utility';
|
||||
else
|
||||
return;
|
||||
const context = new dom.FrameExecutionContext(delegate, frame, worldName);
|
||||
(context as any)[contextDelegateSymbol] = delegate;
|
||||
frame._contextCreated(worldName, context);
|
||||
if (worldName)
|
||||
frame._contextCreated(worldName, context);
|
||||
this._contextIdToContext.set(contextPayload.id, context);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ export class DebugController extends SdkObject {
|
|||
const contexts = new Set<BrowserContext>();
|
||||
for (const page of this._playwright.allPages())
|
||||
contexts.add(page.context());
|
||||
const result = await Promise.all([...contexts].map(c => Recorder.show(c, () => Promise.resolve(new InspectingRecorderApp(this)), { omitCallTracking: true })));
|
||||
const result = await Promise.all([...contexts].map(c => Recorder.showInspector(c, { omitCallTracking: true }, () => Promise.resolve(new InspectingRecorderApp(this)))));
|
||||
return result.filter(Boolean) as Recorder[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export class Dialog extends SdkObject {
|
|||
this._onHandle = onHandle;
|
||||
this._defaultValue = defaultValue || '';
|
||||
this._page._frameManager.dialogDidOpen(this);
|
||||
this.instrumentation.onDialog(this);
|
||||
}
|
||||
|
||||
page() {
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ import { serializeError } from '../errors';
|
|||
import { ElementHandleDispatcher } from './elementHandlerDispatcher';
|
||||
import { RecorderInTraceViewer } from '../recorder/recorderInTraceViewer';
|
||||
import { RecorderApp } from '../recorder/recorderApp';
|
||||
import type { IRecorderAppFactory } from '../recorder/recorderFrontend';
|
||||
import { WebSocketRouteDispatcher } from './webSocketRouteDispatcher';
|
||||
|
||||
export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channels.BrowserContextChannel, DispatcherScope> implements channels.BrowserContextChannel {
|
||||
|
|
@ -301,21 +300,18 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
|
|||
await this._context.close(params);
|
||||
}
|
||||
|
||||
async recorderSupplementEnable(params: channels.BrowserContextRecorderSupplementEnableParams): Promise<void> {
|
||||
let factory: IRecorderAppFactory;
|
||||
if (process.env.PW_RECORDER_IS_TRACE_VIEWER) {
|
||||
factory = RecorderInTraceViewer.factory(this._context);
|
||||
async enableRecorder(params: channels.BrowserContextEnableRecorderParams): Promise<void> {
|
||||
if (params.codegenMode === 'trace-events') {
|
||||
await this._context.tracing.start({
|
||||
name: 'trace',
|
||||
snapshots: true,
|
||||
screenshots: false,
|
||||
screenshots: true,
|
||||
live: true,
|
||||
});
|
||||
await this._context.tracing.startChunk({ name: 'trace', title: 'trace' });
|
||||
await Recorder.show('trace-events', this._context, RecorderInTraceViewer.factory(this._context), params);
|
||||
} else {
|
||||
factory = RecorderApp.factory(this._context);
|
||||
await Recorder.show('actions', this._context, RecorderApp.factory(this._context), params);
|
||||
}
|
||||
await Recorder.show(this._context, factory, params);
|
||||
}
|
||||
|
||||
async pause(params: channels.BrowserContextPauseParams, metadata: CallMetadata) {
|
||||
|
|
|
|||
|
|
@ -50,9 +50,9 @@ export function isNonRecoverableDOMError(error: Error) {
|
|||
export class FrameExecutionContext extends js.ExecutionContext {
|
||||
readonly frame: frames.Frame;
|
||||
private _injectedScriptPromise?: Promise<js.JSHandle>;
|
||||
readonly world: types.World;
|
||||
readonly world: types.World | null;
|
||||
|
||||
constructor(delegate: js.ExecutionContextDelegate, frame: frames.Frame, world: types.World) {
|
||||
constructor(delegate: js.ExecutionContextDelegate, frame: frames.Frame, world: types.World|null) {
|
||||
super(frame, delegate, world || 'content-script');
|
||||
this.frame = frame;
|
||||
this.world = world;
|
||||
|
|
|
|||
|
|
@ -35,16 +35,25 @@ export class Download {
|
|||
this._suggestedFilename = suggestedFilename;
|
||||
page._browserContext._downloads.add(this);
|
||||
if (suggestedFilename !== undefined)
|
||||
this._page.emit(Page.Events.Download, this);
|
||||
this._fireDownloadEvent();
|
||||
}
|
||||
|
||||
page(): Page {
|
||||
return this._page;
|
||||
}
|
||||
|
||||
_filenameSuggested(suggestedFilename: string) {
|
||||
assert(this._suggestedFilename === undefined);
|
||||
this._suggestedFilename = suggestedFilename;
|
||||
this._page.emit(Page.Events.Download, this);
|
||||
this._fireDownloadEvent();
|
||||
}
|
||||
|
||||
suggestedFilename(): string {
|
||||
return this._suggestedFilename!;
|
||||
}
|
||||
|
||||
private _fireDownloadEvent() {
|
||||
this._page.instrumentation.onDownload(this._page, this);
|
||||
this._page.emit(Page.Events.Download, this);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -163,16 +163,15 @@ export class FFPage implements PageDelegate {
|
|||
if (!frame)
|
||||
return;
|
||||
const delegate = new FFExecutionContext(this._session, executionContextId);
|
||||
let worldName: types.World;
|
||||
let worldName: types.World|null = null;
|
||||
if (auxData.name === UTILITY_WORLD_NAME)
|
||||
worldName = 'utility';
|
||||
else if (!auxData.name)
|
||||
worldName = 'main';
|
||||
else
|
||||
return;
|
||||
const context = new dom.FrameExecutionContext(delegate, frame, worldName);
|
||||
(context as any)[contextDelegateSymbol] = delegate;
|
||||
frame._contextCreated(worldName, context);
|
||||
if (worldName)
|
||||
frame._contextCreated(worldName, context);
|
||||
this._contextIdToContext.set(executionContextId, context);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* 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 { Mode, OverlayState, UIState } from '@recorder/recorderTypes';
|
||||
import type * as actions from '../../recorder/recorderActions';
|
||||
import type { InjectedScript } from '../injectedScript';
|
||||
import { Recorder } from './recorder';
|
||||
import type { RecorderDelegate } from './recorder';
|
||||
|
||||
interface Embedder {
|
||||
__pw_recorderPerformAction(action: actions.PerformOnRecordAction): Promise<void>;
|
||||
__pw_recorderRecordAction(action: actions.Action): Promise<void>;
|
||||
__pw_recorderState(): Promise<UIState>;
|
||||
__pw_recorderSetSelector(selector: string): Promise<void>;
|
||||
__pw_recorderSetMode(mode: Mode): Promise<void>;
|
||||
__pw_recorderSetOverlayState(state: OverlayState): Promise<void>;
|
||||
__pw_refreshOverlay(): void;
|
||||
}
|
||||
|
||||
export class PollingRecorder implements RecorderDelegate {
|
||||
private _recorder: Recorder;
|
||||
private _embedder: Embedder;
|
||||
private _pollRecorderModeTimer: number | undefined;
|
||||
|
||||
constructor(injectedScript: InjectedScript) {
|
||||
this._recorder = new Recorder(injectedScript);
|
||||
this._embedder = injectedScript.window as any;
|
||||
|
||||
injectedScript.onGlobalListenersRemoved.add(() => this._recorder.installListeners());
|
||||
|
||||
const refreshOverlay = () => {
|
||||
this._pollRecorderMode().catch(e => console.log(e)); // eslint-disable-line no-console
|
||||
};
|
||||
this._embedder.__pw_refreshOverlay = refreshOverlay;
|
||||
refreshOverlay();
|
||||
}
|
||||
|
||||
private async _pollRecorderMode() {
|
||||
const pollPeriod = 1000;
|
||||
if (this._pollRecorderModeTimer)
|
||||
clearTimeout(this._pollRecorderModeTimer);
|
||||
const state = await this._embedder.__pw_recorderState().catch(() => {});
|
||||
if (!state) {
|
||||
this._pollRecorderModeTimer = this._recorder.injectedScript.builtinSetTimeout(() => this._pollRecorderMode(), pollPeriod);
|
||||
return;
|
||||
}
|
||||
const win = this._recorder.document.defaultView!;
|
||||
if (win.top !== win) {
|
||||
// Only show action point in the main frame, since it is relative to the page's viewport.
|
||||
// Otherwise we'll see multiple action points at different locations.
|
||||
state.actionPoint = undefined;
|
||||
}
|
||||
this._recorder.setUIState(state, this);
|
||||
this._pollRecorderModeTimer = this._recorder.injectedScript.builtinSetTimeout(() => this._pollRecorderMode(), pollPeriod);
|
||||
}
|
||||
|
||||
async performAction(action: actions.PerformOnRecordAction) {
|
||||
await this._embedder.__pw_recorderPerformAction(action);
|
||||
}
|
||||
|
||||
async recordAction(action: actions.Action): Promise<void> {
|
||||
await this._embedder.__pw_recorderRecordAction(action);
|
||||
}
|
||||
|
||||
async setSelector(selector: string): Promise<void> {
|
||||
await this._embedder.__pw_recorderSetSelector(selector);
|
||||
}
|
||||
|
||||
async setMode(mode: Mode): Promise<void> {
|
||||
await this._embedder.__pw_recorderSetMode(mode);
|
||||
}
|
||||
|
||||
async setOverlayState(state: OverlayState): Promise<void> {
|
||||
await this._embedder.__pw_recorderSetOverlayState(state);
|
||||
}
|
||||
}
|
||||
|
||||
export default PollingRecorder;
|
||||
|
|
@ -21,9 +21,8 @@ import type { Mode, OverlayState, UIState } from '@recorder/recorderTypes';
|
|||
import type { ElementText } from '../selectorUtils';
|
||||
import type { Highlight, HighlightOptions } from '../highlight';
|
||||
import clipPaths from './clipPaths';
|
||||
import type { SimpleDomNode } from '../simpleDom';
|
||||
|
||||
interface RecorderDelegate {
|
||||
export interface RecorderDelegate {
|
||||
performAction?(action: actions.PerformOnRecordAction): Promise<void>;
|
||||
recordAction?(action: actions.Action): Promise<void>;
|
||||
setSelector?(selector: string): Promise<void>;
|
||||
|
|
@ -206,7 +205,7 @@ class InspectTool implements RecorderTool {
|
|||
|
||||
class RecordActionTool implements RecorderTool {
|
||||
private _recorder: Recorder;
|
||||
private _performingAction = false;
|
||||
private _performingAction: actions.PerformOnRecordAction | null = null;
|
||||
private _hoveredModel: HighlightModel | null = null;
|
||||
private _hoveredElement: HTMLElement | null = null;
|
||||
private _activeModel: HighlightModel | null = null;
|
||||
|
|
@ -509,9 +508,15 @@ class RecordActionTool implements RecorderTool {
|
|||
|
||||
private _actionInProgress(event: Event): boolean {
|
||||
// If Playwright is performing action for us, bail.
|
||||
if (this._performingAction)
|
||||
const isKeyEvent = event instanceof KeyboardEvent;
|
||||
if (this._performingAction?.name === 'press' && isKeyEvent && event.key === this._performingAction.key)
|
||||
return true;
|
||||
// Consume as the first thing.
|
||||
|
||||
const isMouseOrPointerEvent = event instanceof MouseEvent || event instanceof PointerEvent;
|
||||
if (isMouseOrPointerEvent && (this._performingAction?.name === 'click' || this._performingAction?.name === 'check' || this._performingAction?.name === 'uncheck'))
|
||||
return true;
|
||||
|
||||
// Consume event if action is not being executed.
|
||||
consumeEvent(event);
|
||||
return false;
|
||||
}
|
||||
|
|
@ -535,9 +540,9 @@ class RecordActionTool implements RecorderTool {
|
|||
this._hoveredModel = null;
|
||||
this._activeModel = null;
|
||||
this._recorder.updateHighlight(null, false);
|
||||
this._performingAction = true;
|
||||
this._performingAction = action;
|
||||
void this._recorder.performAction(action).then(() => {
|
||||
this._performingAction = false;
|
||||
this._performingAction = null;
|
||||
|
||||
// If that was a keyboard action, it similarly requires new selectors for active model.
|
||||
this._onFocus(false);
|
||||
|
|
@ -1457,73 +1462,3 @@ function createSvgElement(doc: Document, { tagName, attrs, children }: SvgJson):
|
|||
|
||||
return elem;
|
||||
}
|
||||
|
||||
interface Embedder {
|
||||
__pw_recorderPerformAction(action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode): Promise<void>;
|
||||
__pw_recorderRecordAction(action: actions.Action, simpleDomNode?: SimpleDomNode): Promise<void>;
|
||||
__pw_recorderState(): Promise<UIState>;
|
||||
__pw_recorderSetSelector(selector: string): Promise<void>;
|
||||
__pw_recorderSetMode(mode: Mode): Promise<void>;
|
||||
__pw_recorderSetOverlayState(state: OverlayState): Promise<void>;
|
||||
__pw_refreshOverlay(): void;
|
||||
}
|
||||
|
||||
export class PollingRecorder implements RecorderDelegate {
|
||||
private _recorder: Recorder;
|
||||
private _embedder: Embedder;
|
||||
private _pollRecorderModeTimer: number | undefined;
|
||||
|
||||
constructor(injectedScript: InjectedScript) {
|
||||
this._recorder = new Recorder(injectedScript);
|
||||
this._embedder = injectedScript.window as any;
|
||||
|
||||
injectedScript.onGlobalListenersRemoved.add(() => this._recorder.installListeners());
|
||||
|
||||
const refreshOverlay = () => {
|
||||
this._pollRecorderMode().catch(e => console.log(e)); // eslint-disable-line no-console
|
||||
};
|
||||
this._embedder.__pw_refreshOverlay = refreshOverlay;
|
||||
refreshOverlay();
|
||||
}
|
||||
|
||||
private async _pollRecorderMode() {
|
||||
const pollPeriod = 1000;
|
||||
if (this._pollRecorderModeTimer)
|
||||
clearTimeout(this._pollRecorderModeTimer);
|
||||
const state = await this._embedder.__pw_recorderState().catch(() => {});
|
||||
if (!state) {
|
||||
this._pollRecorderModeTimer = this._recorder.injectedScript.builtinSetTimeout(() => this._pollRecorderMode(), pollPeriod);
|
||||
return;
|
||||
}
|
||||
const win = this._recorder.document.defaultView!;
|
||||
if (win.top !== win) {
|
||||
// Only show action point in the main frame, since it is relative to the page's viewport.
|
||||
// Otherwise we'll see multiple action points at different locations.
|
||||
state.actionPoint = undefined;
|
||||
}
|
||||
this._recorder.setUIState(state, this);
|
||||
this._pollRecorderModeTimer = this._recorder.injectedScript.builtinSetTimeout(() => this._pollRecorderMode(), pollPeriod);
|
||||
}
|
||||
|
||||
async performAction(action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode) {
|
||||
await this._embedder.__pw_recorderPerformAction(action, simpleDomNode);
|
||||
}
|
||||
|
||||
async recordAction(action: actions.Action, simpleDomNode?: SimpleDomNode): Promise<void> {
|
||||
await this._embedder.__pw_recorderRecordAction(action, simpleDomNode);
|
||||
}
|
||||
|
||||
async setSelector(selector: string): Promise<void> {
|
||||
await this._embedder.__pw_recorderSetSelector(selector);
|
||||
}
|
||||
|
||||
async setMode(mode: Mode): Promise<void> {
|
||||
await this._embedder.__pw_recorderSetMode(mode);
|
||||
}
|
||||
|
||||
async setOverlayState(state: OverlayState): Promise<void> {
|
||||
await this._embedder.__pw_recorderSetOverlayState(state);
|
||||
}
|
||||
}
|
||||
|
||||
export default PollingRecorder;
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ export type Attribution = {
|
|||
};
|
||||
|
||||
import type { CallMetadata } from '@protocol/callMetadata';
|
||||
import type { Dialog } from './dialog';
|
||||
import type { Download } from './download';
|
||||
export type { CallMetadata } from '@protocol/callMetadata';
|
||||
|
||||
export class SdkObject extends EventEmitter {
|
||||
|
|
@ -62,6 +64,8 @@ export interface Instrumentation {
|
|||
onPageClose(page: Page): void;
|
||||
onBrowserOpen(browser: Browser): void;
|
||||
onBrowserClose(browser: Browser): void;
|
||||
onDialog(dialog: Dialog): void;
|
||||
onDownload(page: Page, download: Download): void;
|
||||
}
|
||||
|
||||
export interface InstrumentationListener {
|
||||
|
|
@ -73,6 +77,8 @@ export interface InstrumentationListener {
|
|||
onPageClose?(page: Page): void;
|
||||
onBrowserOpen?(browser: Browser): void;
|
||||
onBrowserClose?(browser: Browser): void;
|
||||
onDialog?(dialog: Dialog): void;
|
||||
onDownload?(page: Page, download: Download): void;
|
||||
}
|
||||
|
||||
export function createInstrumentation(): Instrumentation {
|
||||
|
|
|
|||
|
|
@ -45,32 +45,35 @@ export class Recorder implements InstrumentationListener, IRecorder {
|
|||
private _omitCallTracking = false;
|
||||
private _currentLanguage: Language;
|
||||
|
||||
static showInspector(context: BrowserContext, recorderAppFactory: IRecorderAppFactory) {
|
||||
const params: channels.BrowserContextRecorderSupplementEnableParams = {};
|
||||
static async showInspector(context: BrowserContext, params: channels.BrowserContextEnableRecorderParams, recorderAppFactory: IRecorderAppFactory) {
|
||||
if (isUnderTest())
|
||||
params.language = process.env.TEST_INSPECTOR_LANGUAGE;
|
||||
Recorder.show(context, recorderAppFactory, params).catch(() => {});
|
||||
return await Recorder.show('actions', context, recorderAppFactory, params);
|
||||
}
|
||||
|
||||
static show(context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<Recorder> {
|
||||
static showInspectorNoReply(context: BrowserContext, recorderAppFactory: IRecorderAppFactory) {
|
||||
Recorder.showInspector(context, {}, recorderAppFactory).catch(() => {});
|
||||
}
|
||||
|
||||
static show(codegenMode: 'actions' | 'trace-events', context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextEnableRecorderParams): Promise<Recorder> {
|
||||
let recorderPromise = (context as any)[recorderSymbol] as Promise<Recorder>;
|
||||
if (!recorderPromise) {
|
||||
recorderPromise = Recorder._create(context, recorderAppFactory, params);
|
||||
recorderPromise = Recorder._create(codegenMode, context, recorderAppFactory, params);
|
||||
(context as any)[recorderSymbol] = recorderPromise;
|
||||
}
|
||||
return recorderPromise;
|
||||
}
|
||||
|
||||
private static async _create(context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<Recorder> {
|
||||
const recorder = new Recorder(context, params);
|
||||
private static async _create(codegenMode: 'actions' | 'trace-events', context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextEnableRecorderParams = {}): Promise<Recorder> {
|
||||
const recorder = new Recorder(codegenMode, context, params);
|
||||
const recorderApp = await recorderAppFactory(recorder);
|
||||
await recorder._install(recorderApp);
|
||||
return recorder;
|
||||
}
|
||||
|
||||
constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) {
|
||||
constructor(codegenMode: 'actions' | 'trace-events', context: BrowserContext, params: channels.BrowserContextEnableRecorderParams) {
|
||||
this._mode = params.mode || 'none';
|
||||
this._contextRecorder = new ContextRecorder(context, params, {});
|
||||
this._contextRecorder = new ContextRecorder(codegenMode, context, params, {});
|
||||
this._context = context;
|
||||
this._omitCallTracking = !!params.omitCallTracking;
|
||||
this._debugger = context.debugger();
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
../isomorphic/**
|
||||
../registry/**
|
||||
../../common/
|
||||
../../generated/recorderSource.ts
|
||||
../../generated/pollingRecorderSource.ts
|
||||
../../protocol/
|
||||
../../utils/**
|
||||
../../utilsBundle.ts
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
import type * as channels from '@protocol/channels';
|
||||
import type { Source } from '@recorder/recorderTypes';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as recorderSource from '../../generated/recorderSource';
|
||||
import * as recorderSource from '../../generated/pollingRecorderSource';
|
||||
import { eventsHelper, monotonicTime, quoteCSSAttributeValue, type RegisteredListener } from '../../utils';
|
||||
import { raceAgainstDeadline } from '../../utils/timeoutRunner';
|
||||
import { BrowserContext } from '../browserContext';
|
||||
|
|
@ -48,15 +48,17 @@ export class ContextRecorder extends EventEmitter {
|
|||
private _lastDialogOrdinal = -1;
|
||||
private _lastDownloadOrdinal = -1;
|
||||
private _context: BrowserContext;
|
||||
private _params: channels.BrowserContextRecorderSupplementEnableParams;
|
||||
private _params: channels.BrowserContextEnableRecorderParams;
|
||||
private _delegate: ContextRecorderDelegate;
|
||||
private _recorderSources: Source[];
|
||||
private _throttledOutputFile: ThrottledFile | null = null;
|
||||
private _orderedLanguages: LanguageGenerator[] = [];
|
||||
private _listeners: RegisteredListener[] = [];
|
||||
private _codegenMode: 'actions' | 'trace-events';
|
||||
|
||||
constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams, delegate: ContextRecorderDelegate) {
|
||||
constructor(codegenMode: 'actions' | 'trace-events', context: BrowserContext, params: channels.BrowserContextEnableRecorderParams, delegate: ContextRecorderDelegate) {
|
||||
super();
|
||||
this._codegenMode = codegenMode;
|
||||
this._context = context;
|
||||
this._params = params;
|
||||
this._delegate = delegate;
|
||||
|
|
@ -73,8 +75,8 @@ export class ContextRecorder extends EventEmitter {
|
|||
saveStorage: params.saveStorage,
|
||||
};
|
||||
|
||||
const collection = new RecorderCollection(context, this._pageAliases, params.mode === 'recording');
|
||||
collection.on('change', (actions: ActionInContext[]) => {
|
||||
this._collection = new RecorderCollection(codegenMode, context, this._pageAliases);
|
||||
this._collection.on('change', (actions: ActionInContext[]) => {
|
||||
this._recorderSources = [];
|
||||
for (const languageGenerator of this._orderedLanguages) {
|
||||
const { header, footer, actionTexts, text } = generateCode(actions, languageGenerator, languageGeneratorOptions);
|
||||
|
|
@ -103,7 +105,7 @@ export class ContextRecorder extends EventEmitter {
|
|||
this._listeners.push(eventsHelper.addEventListener(process, 'exit', () => {
|
||||
this._throttledOutputFile?.flush();
|
||||
}));
|
||||
this._collection = collection;
|
||||
this.setEnabled(true);
|
||||
}
|
||||
|
||||
setOutput(codegenId: string, outputFile?: string) {
|
||||
|
|
@ -145,6 +147,12 @@ export class ContextRecorder extends EventEmitter {
|
|||
|
||||
setEnabled(enabled: boolean) {
|
||||
this._collection.setEnabled(enabled);
|
||||
if (this._codegenMode === 'trace-events') {
|
||||
if (enabled)
|
||||
this._context.tracing.startChunk({ name: 'trace', title: 'trace' }).catch(() => {});
|
||||
else
|
||||
this._context.tracing.stopChunk({ mode: 'discard' }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
|
|
|
|||
|
|
@ -81,7 +81,6 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
|
|||
const file = require.resolve('../../vite/recorder/' + uri);
|
||||
fs.promises.readFile(file).then(buffer => {
|
||||
route.fulfill({
|
||||
requestUrl: route.request().url(),
|
||||
status: 200,
|
||||
headers: [
|
||||
{ name: 'Content-Type', value: mime.getType(path.extname(file)) || 'application/octet-stream' }
|
||||
|
|
@ -162,8 +161,10 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
|
|||
}).toString(), { isFunction: true }, sources).catch(() => {});
|
||||
|
||||
// Testing harness for runCLI mode.
|
||||
if (process.env.PWTEST_CLI_IS_UNDER_TEST && sources.length)
|
||||
(process as any)._didSetSourcesForTest(sources[0].text);
|
||||
if (process.env.PWTEST_CLI_IS_UNDER_TEST && sources.length) {
|
||||
if ((process as any)._didSetSourcesForTest(sources[0].text))
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
async setSelector(selector: string, userGesture?: boolean): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -29,17 +29,16 @@ import type { BrowserContext } from '../browserContext';
|
|||
|
||||
export class RecorderCollection extends EventEmitter {
|
||||
private _actions: ActionInContext[] = [];
|
||||
private _enabled: boolean;
|
||||
private _enabled = false;
|
||||
private _pageAliases: Map<Page, string>;
|
||||
private _context: BrowserContext;
|
||||
|
||||
constructor(context: BrowserContext, pageAliases: Map<Page, string>, enabled: boolean) {
|
||||
constructor(codegenMode: 'actions' | 'trace-events', context: BrowserContext, pageAliases: Map<Page, string>) {
|
||||
super();
|
||||
this._context = context;
|
||||
this._enabled = enabled;
|
||||
this._pageAliases = pageAliases;
|
||||
|
||||
if (process.env.PW_RECORDER_IS_TRACE_VIEWER) {
|
||||
if (codegenMode === 'trace-events') {
|
||||
this._context.tracing.onMemoryEvents(events => {
|
||||
this._actions = traceEventsToAction(events);
|
||||
this._fireChange();
|
||||
|
|
|
|||
|
|
@ -21,77 +21,101 @@ import type { IRecorder, IRecorderApp, IRecorderAppFactory } from './recorderFro
|
|||
import { installRootRedirect, openTraceViewerApp, startTraceViewerServer } from '../trace/viewer/traceViewer';
|
||||
import type { TraceViewerServerOptions } from '../trace/viewer/traceViewer';
|
||||
import type { BrowserContext } from '../browserContext';
|
||||
import { gracefullyProcessExitDoNotHang } from '../../utils/processLauncher';
|
||||
import type { Transport } from '../../utils/httpServer';
|
||||
import type { HttpServer, Transport } from '../../utils/httpServer';
|
||||
import type { Page } from '../page';
|
||||
import { ManualPromise } from '../../utils/manualPromise';
|
||||
|
||||
export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp {
|
||||
readonly wsEndpointForTest: string | undefined;
|
||||
private _recorder: IRecorder;
|
||||
private _transport: Transport;
|
||||
private _transport: RecorderTransport;
|
||||
private _tracePage: Page;
|
||||
private _traceServer: HttpServer;
|
||||
|
||||
static factory(context: BrowserContext): IRecorderAppFactory {
|
||||
return async (recorder: IRecorder) => {
|
||||
const transport = new RecorderTransport();
|
||||
const trace = path.join(context._browser.options.tracesDir, 'trace');
|
||||
const wsEndpointForTest = await openApp(trace, { transport, headless: !context._browser.options.headful });
|
||||
return new RecorderInTraceViewer(context, recorder, transport, wsEndpointForTest);
|
||||
const { wsEndpointForTest, tracePage, traceServer } = await openApp(trace, { transport, headless: !context._browser.options.headful });
|
||||
return new RecorderInTraceViewer(transport, tracePage, traceServer, wsEndpointForTest);
|
||||
};
|
||||
}
|
||||
|
||||
constructor(context: BrowserContext, recorder: IRecorder, transport: Transport, wsEndpointForTest: string | undefined) {
|
||||
constructor(transport: RecorderTransport, tracePage: Page, traceServer: HttpServer, wsEndpointForTest: string | undefined) {
|
||||
super();
|
||||
this._recorder = recorder;
|
||||
this._transport = transport;
|
||||
this._transport.eventSink.resolve(this);
|
||||
this._tracePage = tracePage;
|
||||
this._traceServer = traceServer;
|
||||
this.wsEndpointForTest = wsEndpointForTest;
|
||||
this._tracePage.once('close', () => {
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this._transport.sendEvent?.('close', {});
|
||||
await this._tracePage.context().close({ reason: 'Recorder window closed' });
|
||||
await this._traceServer.stop();
|
||||
}
|
||||
|
||||
async setPaused(paused: boolean): Promise<void> {
|
||||
this._transport.sendEvent?.('setPaused', { paused });
|
||||
this._transport.deliverEvent('setPaused', { paused });
|
||||
}
|
||||
|
||||
async setMode(mode: Mode): Promise<void> {
|
||||
this._transport.sendEvent?.('setMode', { mode });
|
||||
this._transport.deliverEvent('setMode', { mode });
|
||||
}
|
||||
|
||||
async setFile(file: string): Promise<void> {
|
||||
this._transport.sendEvent?.('setFileIfNeeded', { file });
|
||||
this._transport.deliverEvent('setFileIfNeeded', { file });
|
||||
}
|
||||
|
||||
async setSelector(selector: string, userGesture?: boolean): Promise<void> {
|
||||
this._transport.sendEvent?.('setSelector', { selector, userGesture });
|
||||
this._transport.deliverEvent('setSelector', { selector, userGesture });
|
||||
}
|
||||
|
||||
async updateCallLogs(callLogs: CallLog[]): Promise<void> {
|
||||
this._transport.sendEvent?.('updateCallLogs', { callLogs });
|
||||
this._transport.deliverEvent('updateCallLogs', { callLogs });
|
||||
}
|
||||
|
||||
async setSources(sources: Source[]): Promise<void> {
|
||||
this._transport.sendEvent?.('setSources', { sources });
|
||||
this._transport.deliverEvent('setSources', { sources });
|
||||
if (process.env.PWTEST_CLI_IS_UNDER_TEST && sources.length) {
|
||||
if ((process as any)._didSetSourcesForTest(sources[0].text))
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function openApp(trace: string, options?: TraceViewerServerOptions & { headless?: boolean }): Promise<string | undefined> {
|
||||
const server = await startTraceViewerServer(options);
|
||||
await installRootRedirect(server, [trace], { ...options, webApp: 'recorder.html' });
|
||||
const page = await openTraceViewerApp(server.urlPrefix('precise'), 'chromium', options);
|
||||
page.on('close', () => gracefullyProcessExitDoNotHang(0));
|
||||
return page.context()._browser.options.wsEndpoint;
|
||||
async function openApp(trace: string, options?: TraceViewerServerOptions & { headless?: boolean }): Promise<{ wsEndpointForTest: string | undefined, tracePage: Page, traceServer: HttpServer }> {
|
||||
const traceServer = await startTraceViewerServer(options);
|
||||
await installRootRedirect(traceServer, [trace], { ...options, webApp: 'recorder.html' });
|
||||
const page = await openTraceViewerApp(traceServer.urlPrefix('precise'), 'chromium', options);
|
||||
return { wsEndpointForTest: page.context()._browser.options.wsEndpoint, tracePage: page, traceServer };
|
||||
}
|
||||
|
||||
class RecorderTransport implements Transport {
|
||||
private _connected = new ManualPromise<void>();
|
||||
readonly eventSink = new ManualPromise<EventEmitter>();
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
async dispatch(method: string, params: any) {
|
||||
onconnect() {
|
||||
this._connected.resolve();
|
||||
}
|
||||
|
||||
async dispatch(method: string, params: any): Promise<any> {
|
||||
const eventSink = await this.eventSink;
|
||||
eventSink.emit('event', { event: method, params });
|
||||
}
|
||||
|
||||
onclose() {
|
||||
}
|
||||
|
||||
deliverEvent(method: string, params: any) {
|
||||
this._connected.then(() => this.sendEvent?.(method, params));
|
||||
}
|
||||
|
||||
sendEvent?: (method: string, params: any) => void;
|
||||
close?: () => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import type * as trace from '@trace/trace';
|
|||
import { fromKeyboardModifiers, toKeyboardModifiers } from '../codegen/language';
|
||||
import { serializeExpectedTextValues } from '../../utils/expectUtils';
|
||||
import { createGuid, monotonicTime } from '../../utils';
|
||||
import { serializeValue } from '../../protocol/serializers';
|
||||
import { parseSerializedValue, serializeValue } from '../../protocol/serializers';
|
||||
import type { SmartKeyboardModifier } from '../types';
|
||||
|
||||
export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus): CallLog {
|
||||
|
|
@ -158,7 +158,7 @@ export function traceParamsForAction(actionInContext: ActionInContext): { method
|
|||
const params: channels.FrameExpectParams = {
|
||||
selector: action.selector,
|
||||
expression: 'to.be.checked',
|
||||
isNot: action.checked,
|
||||
isNot: !action.checked,
|
||||
};
|
||||
return { method: 'expect', params };
|
||||
}
|
||||
|
|
@ -166,7 +166,7 @@ export function traceParamsForAction(actionInContext: ActionInContext): { method
|
|||
const params: channels.FrameExpectParams = {
|
||||
selector,
|
||||
expression: 'to.have.text',
|
||||
expectedText: serializeExpectedTextValues([action.text], { matchSubstring: true, normalizeWhiteSpace: true }),
|
||||
expectedText: serializeExpectedTextValues([action.text], { matchSubstring: action.substring, normalizeWhiteSpace: true }),
|
||||
isNot: false,
|
||||
};
|
||||
return { method: 'expect', params };
|
||||
|
|
@ -193,12 +193,12 @@ export function traceParamsForAction(actionInContext: ActionInContext): { method
|
|||
|
||||
export function callMetadataForAction(pageAliases: Map<Page, string>, actionInContext: ActionInContext): { callMetadata: CallMetadata, mainFrame: Frame } {
|
||||
const mainFrame = mainFrameForAction(pageAliases, actionInContext);
|
||||
const { action } = actionInContext;
|
||||
const { method, params } = traceParamsForAction(actionInContext);
|
||||
|
||||
const callMetadata: CallMetadata = {
|
||||
id: `call@${createGuid()}`,
|
||||
stepId: `recorder@${createGuid()}`,
|
||||
apiName: 'frame.' + action.name,
|
||||
apiName: 'page.' + method,
|
||||
objectId: mainFrame.guid,
|
||||
pageId: mainFrame._page.guid,
|
||||
frameId: mainFrame.guid,
|
||||
|
|
@ -215,38 +215,70 @@ export function callMetadataForAction(pageAliases: Map<Page, string>, actionInCo
|
|||
export function traceEventsToAction(events: trace.TraceEvent[]): ActionInContext[] {
|
||||
const result: ActionInContext[] = [];
|
||||
const pageAliases = new Map<string, string>();
|
||||
let lastDownloadOrdinal = 0;
|
||||
let lastDialogOrdinal = 0;
|
||||
|
||||
const addSignal = (signal: actions.Signal) => {
|
||||
const lastAction = result[result.length - 1];
|
||||
if (!lastAction)
|
||||
return;
|
||||
lastAction.action.signals.push(signal);
|
||||
};
|
||||
|
||||
for (const event of events) {
|
||||
if (event.type === 'event' && event.class === 'BrowserContext' && event.method === 'page') {
|
||||
const pageAlias = 'page' + pageAliases.size;
|
||||
pageAliases.set(event.params.pageId, pageAlias);
|
||||
const lastAction = result[result.length - 1];
|
||||
lastAction.action.signals.push({
|
||||
name: 'popup',
|
||||
popupAlias: pageAlias,
|
||||
});
|
||||
result.push({
|
||||
frame: { pageAlias, framePath: [] },
|
||||
action: {
|
||||
name: 'openPage',
|
||||
url: '',
|
||||
signals: [],
|
||||
},
|
||||
timestamp: event.time,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (event.type === 'event' && event.class === 'BrowserContext') {
|
||||
const { method, params } = event;
|
||||
if (method === 'page') {
|
||||
const pageAlias = 'page' + (pageAliases.size || '');
|
||||
pageAliases.set(params.pageId, pageAlias);
|
||||
addSignal({
|
||||
name: 'popup',
|
||||
popupAlias: pageAlias,
|
||||
});
|
||||
result.push({
|
||||
frame: { pageAlias, framePath: [] },
|
||||
action: {
|
||||
name: 'openPage',
|
||||
url: '',
|
||||
signals: [],
|
||||
},
|
||||
timestamp: event.time,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === 'event' && event.class === 'BrowserContext' && event.method === 'pageClosed') {
|
||||
const pageAlias = pageAliases.get(event.params.pageId) || 'page';
|
||||
result.push({
|
||||
frame: { pageAlias, framePath: [] },
|
||||
action: {
|
||||
name: 'closePage',
|
||||
signals: [],
|
||||
},
|
||||
timestamp: event.time,
|
||||
});
|
||||
if (method === 'pageClosed') {
|
||||
const pageAlias = pageAliases.get(event.params.pageId) || 'page';
|
||||
result.push({
|
||||
frame: { pageAlias, framePath: [] },
|
||||
action: {
|
||||
name: 'closePage',
|
||||
signals: [],
|
||||
},
|
||||
timestamp: event.time,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (method === 'download') {
|
||||
const downloadAlias = lastDownloadOrdinal ? String(lastDownloadOrdinal) : '';
|
||||
++lastDownloadOrdinal;
|
||||
addSignal({
|
||||
name: 'download',
|
||||
downloadAlias,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (method === 'dialog') {
|
||||
const dialogAlias = lastDialogOrdinal ? String(lastDialogOrdinal) : '';
|
||||
++lastDialogOrdinal;
|
||||
addSignal({
|
||||
name: 'dialog',
|
||||
dialogAlias,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -389,6 +421,67 @@ export function traceEventsToAction(events: trace.TraceEvent[]): ActionInContext
|
|||
});
|
||||
continue;
|
||||
}
|
||||
if (method === 'expect') {
|
||||
const params = untypedParams as channels.FrameExpectParams;
|
||||
if (params.expression === 'to.have.text') {
|
||||
const entry = params.expectedText?.[0];
|
||||
result.push({
|
||||
frame: { pageAlias, framePath: [] },
|
||||
action: {
|
||||
name: 'assertText',
|
||||
selector: params.selector,
|
||||
signals: [],
|
||||
text: entry?.string!,
|
||||
substring: !!entry?.matchSubstring,
|
||||
},
|
||||
timestamp: event.startTime
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (params.expression === 'to.have.value') {
|
||||
result.push({
|
||||
frame: { pageAlias, framePath: [] },
|
||||
action: {
|
||||
name: 'assertValue',
|
||||
selector: params.selector,
|
||||
signals: [],
|
||||
value: parseSerializedValue(params.expectedValue!.value, params.expectedValue!.handles),
|
||||
},
|
||||
timestamp: event.startTime
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (params.expression === 'to.be.checked') {
|
||||
result.push({
|
||||
frame: { pageAlias, framePath: [] },
|
||||
action: {
|
||||
name: 'assertChecked',
|
||||
selector: params.selector,
|
||||
signals: [],
|
||||
checked: !params.isNot,
|
||||
},
|
||||
timestamp: event.startTime
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (params.expression === 'to.be.visible') {
|
||||
result.push({
|
||||
frame: { pageAlias, framePath: [] },
|
||||
action: {
|
||||
name: 'assertVisible',
|
||||
selector: params.selector,
|
||||
signals: [],
|
||||
},
|
||||
timestamp: event.startTime
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ import { Snapshotter } from './snapshotter';
|
|||
import type { ConsoleMessage } from '../../console';
|
||||
import { Dispatcher } from '../../dispatchers/dispatcher';
|
||||
import { serializeError } from '../../errors';
|
||||
import type { Dialog } from '../../dialog';
|
||||
import type { Download } from '../../download';
|
||||
|
||||
const version: trace.VERSION = 7;
|
||||
|
||||
|
|
@ -454,6 +456,28 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
|
|||
this._appendTraceEvent(event);
|
||||
}
|
||||
|
||||
onDialog(dialog: Dialog) {
|
||||
const event: trace.EventTraceEvent = {
|
||||
type: 'event',
|
||||
time: monotonicTime(),
|
||||
class: 'BrowserContext',
|
||||
method: 'dialog',
|
||||
params: { pageId: dialog.page().guid, type: dialog.type(), message: dialog.message(), defaultValue: dialog.defaultValue() },
|
||||
};
|
||||
this._appendTraceEvent(event);
|
||||
}
|
||||
|
||||
onDownload(page: Page, download: Download) {
|
||||
const event: trace.EventTraceEvent = {
|
||||
type: 'event',
|
||||
time: monotonicTime(),
|
||||
class: 'BrowserContext',
|
||||
method: 'download',
|
||||
params: { pageId: page.guid, url: download.url, suggestedFilename: download.suggestedFilename() },
|
||||
};
|
||||
this._appendTraceEvent(event);
|
||||
}
|
||||
|
||||
onPageOpen(page: Page) {
|
||||
const event: trace.EventTraceEvent = {
|
||||
type: 'event',
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@ export type TraceViewerRedirectOptions = {
|
|||
reporter?: string[];
|
||||
webApp?: string;
|
||||
isServer?: boolean;
|
||||
outputDir?: string;
|
||||
updateSnapshots?: 'all' | 'none' | 'missing';
|
||||
};
|
||||
|
||||
|
|
@ -133,8 +132,6 @@ export async function installRootRedirect(server: HttpServer, traceUrls: string[
|
|||
params.append('timeout', String(options.timeout));
|
||||
if (options.headed)
|
||||
params.append('headed', '');
|
||||
if (options.outputDir)
|
||||
params.append('outputDir', options.outputDir);
|
||||
if (options.updateSnapshots)
|
||||
params.append('updateSnapshots', options.updateSnapshots);
|
||||
for (const reporter of options.reporter || [])
|
||||
|
|
@ -223,6 +220,9 @@ class StdinServer implements Transport {
|
|||
process.stdin.on('close', () => gracefullyProcessExitDoNotHang(0));
|
||||
}
|
||||
|
||||
onconnect() {
|
||||
}
|
||||
|
||||
async dispatch(method: string, params: any) {
|
||||
if (method === 'initialize') {
|
||||
if (this._traceUrl)
|
||||
|
|
|
|||
|
|
@ -502,16 +502,15 @@ export class WKPage implements PageDelegate {
|
|||
if (!frame)
|
||||
return;
|
||||
const delegate = new WKExecutionContext(this._session, contextPayload.id);
|
||||
let worldName: types.World;
|
||||
let worldName: types.World|null = null;
|
||||
if (contextPayload.type === 'normal')
|
||||
worldName = 'main';
|
||||
else if (contextPayload.type === 'user' && contextPayload.name === UTILITY_WORLD_NAME)
|
||||
worldName = 'utility';
|
||||
else
|
||||
return;
|
||||
const context = new dom.FrameExecutionContext(delegate, frame, worldName);
|
||||
(context as any)[contextDelegateSymbol] = delegate;
|
||||
frame._contextCreated(worldName, context);
|
||||
if (worldName)
|
||||
frame._contextCreated(worldName, context);
|
||||
this._contextIdToContext.set(contextPayload.id, context);
|
||||
}
|
||||
|
||||
|
|
@ -1116,7 +1115,7 @@ export class WKPage implements PageDelegate {
|
|||
const response = request.createResponse(event.response);
|
||||
this._page._frameManager.requestReceivedResponse(response);
|
||||
|
||||
if (response.status() === 204) {
|
||||
if (response.status() === 204 && request.request.isNavigationRequest()) {
|
||||
this._onLoadingFailed(session, {
|
||||
requestId: event.requestId,
|
||||
errorText: 'Aborted: 204 No Content',
|
||||
|
|
|
|||
|
|
@ -27,8 +27,9 @@ export type ServerRouteHandler = (request: http.IncomingMessage, response: http.
|
|||
|
||||
export type Transport = {
|
||||
sendEvent?: (method: string, params: any) => void;
|
||||
dispatch: (method: string, params: any) => Promise<any>;
|
||||
close?: () => void;
|
||||
onconnect: () => void;
|
||||
dispatch: (method: string, params: any) => Promise<any>;
|
||||
onclose: () => void;
|
||||
};
|
||||
|
||||
|
|
@ -82,6 +83,7 @@ export class HttpServer {
|
|||
this._wsGuid = guid || createGuid();
|
||||
const wss = new wsServer({ server: this._server, path: '/' + this._wsGuid });
|
||||
wss.on('connection', ws => {
|
||||
transport.onconnect();
|
||||
transport.sendEvent = (method, params) => ws.send(JSON.stringify({ method, params }));
|
||||
transport.close = () => ws.close();
|
||||
ws.on('message', async message => {
|
||||
|
|
|
|||
48
packages/playwright-core/types/types.d.ts
vendored
48
packages/playwright-core/types/types.d.ts
vendored
|
|
@ -2120,7 +2120,9 @@ export interface Page {
|
|||
|
||||
/**
|
||||
* When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults
|
||||
* to `false`. Useful to wait until the element is ready for the action without performing it.
|
||||
* to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard
|
||||
* `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys
|
||||
* are pressed.
|
||||
*/
|
||||
trial?: boolean;
|
||||
}): Promise<void>;
|
||||
|
|
@ -2233,7 +2235,9 @@ export interface Page {
|
|||
|
||||
/**
|
||||
* When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults
|
||||
* to `false`. Useful to wait until the element is ready for the action without performing it.
|
||||
* to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard
|
||||
* `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys
|
||||
* are pressed.
|
||||
*/
|
||||
trial?: boolean;
|
||||
}): Promise<void>;
|
||||
|
|
@ -3125,7 +3129,9 @@ export interface Page {
|
|||
|
||||
/**
|
||||
* When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults
|
||||
* to `false`. Useful to wait until the element is ready for the action without performing it.
|
||||
* to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard
|
||||
* `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys
|
||||
* are pressed.
|
||||
*/
|
||||
trial?: boolean;
|
||||
}): Promise<void>;
|
||||
|
|
@ -4266,7 +4272,9 @@ export interface Page {
|
|||
|
||||
/**
|
||||
* When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults
|
||||
* to `false`. Useful to wait until the element is ready for the action without performing it.
|
||||
* to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard
|
||||
* `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys
|
||||
* are pressed.
|
||||
*/
|
||||
trial?: boolean;
|
||||
}): Promise<void>;
|
||||
|
|
@ -5845,7 +5853,9 @@ export interface Frame {
|
|||
|
||||
/**
|
||||
* When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults
|
||||
* to `false`. Useful to wait until the element is ready for the action without performing it.
|
||||
* to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard
|
||||
* `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys
|
||||
* are pressed.
|
||||
*/
|
||||
trial?: boolean;
|
||||
}): Promise<void>;
|
||||
|
|
@ -5931,7 +5941,9 @@ export interface Frame {
|
|||
|
||||
/**
|
||||
* When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults
|
||||
* to `false`. Useful to wait until the element is ready for the action without performing it.
|
||||
* to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard
|
||||
* `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys
|
||||
* are pressed.
|
||||
*/
|
||||
trial?: boolean;
|
||||
}): Promise<void>;
|
||||
|
|
@ -6621,7 +6633,9 @@ export interface Frame {
|
|||
|
||||
/**
|
||||
* When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults
|
||||
* to `false`. Useful to wait until the element is ready for the action without performing it.
|
||||
* to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard
|
||||
* `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys
|
||||
* are pressed.
|
||||
*/
|
||||
trial?: boolean;
|
||||
}): Promise<void>;
|
||||
|
|
@ -7308,7 +7322,9 @@ export interface Frame {
|
|||
|
||||
/**
|
||||
* When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults
|
||||
* to `false`. Useful to wait until the element is ready for the action without performing it.
|
||||
* to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard
|
||||
* `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys
|
||||
* are pressed.
|
||||
*/
|
||||
trial?: boolean;
|
||||
}): Promise<void>;
|
||||
|
|
@ -12045,7 +12061,9 @@ export interface Locator {
|
|||
|
||||
/**
|
||||
* When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults
|
||||
* to `false`. Useful to wait until the element is ready for the action without performing it.
|
||||
* to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard
|
||||
* `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys
|
||||
* are pressed.
|
||||
*/
|
||||
trial?: boolean;
|
||||
}): Promise<void>;
|
||||
|
|
@ -12155,7 +12173,9 @@ export interface Locator {
|
|||
|
||||
/**
|
||||
* When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults
|
||||
* to `false`. Useful to wait until the element is ready for the action without performing it.
|
||||
* to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard
|
||||
* `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys
|
||||
* are pressed.
|
||||
*/
|
||||
trial?: boolean;
|
||||
}): Promise<void>;
|
||||
|
|
@ -12825,7 +12845,9 @@ export interface Locator {
|
|||
|
||||
/**
|
||||
* When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults
|
||||
* to `false`. Useful to wait until the element is ready for the action without performing it.
|
||||
* to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard
|
||||
* `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys
|
||||
* are pressed.
|
||||
*/
|
||||
trial?: boolean;
|
||||
}): Promise<void>;
|
||||
|
|
@ -13626,7 +13648,9 @@ export interface Locator {
|
|||
|
||||
/**
|
||||
* When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults
|
||||
* to `false`. Useful to wait until the element is ready for the action without performing it.
|
||||
* to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard
|
||||
* `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys
|
||||
* are pressed.
|
||||
*/
|
||||
trial?: boolean;
|
||||
}): Promise<void>;
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export interface TestServerInterface {
|
|||
|
||||
installBrowsers(params: {}): Promise<void>;
|
||||
|
||||
runGlobalSetup(params: { outputDir?: string }): Promise<{
|
||||
runGlobalSetup(params: {}): Promise<{
|
||||
report: ReportEntry[],
|
||||
status: reporterTypes.FullResult['status']
|
||||
}>;
|
||||
|
|
@ -82,7 +82,6 @@ export interface TestServerInterface {
|
|||
locations?: string[];
|
||||
grep?: string;
|
||||
grepInvert?: string;
|
||||
outputDir?: string;
|
||||
}): Promise<{
|
||||
report: ReportEntry[],
|
||||
status: reporterTypes.FullResult['status']
|
||||
|
|
@ -96,7 +95,6 @@ export interface TestServerInterface {
|
|||
headed?: boolean;
|
||||
workers?: number | string;
|
||||
timeout?: number,
|
||||
outputDir?: string;
|
||||
updateSnapshots?: 'all' | 'none' | 'missing';
|
||||
reporters?: string[],
|
||||
trace?: 'on' | 'off';
|
||||
|
|
|
|||
|
|
@ -140,8 +140,7 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re
|
|||
const wrappedMatchers: any = {};
|
||||
const extendedMatchers: any = { ...customMatchers };
|
||||
for (const [name, matcher] of Object.entries(matchers)) {
|
||||
const key = qualifiedMatcherName(qualifier, name);
|
||||
wrappedMatchers[key] = function(...args: any[]) {
|
||||
wrappedMatchers[name] = function(...args: any[]) {
|
||||
const { isNot, promise, utils } = this;
|
||||
const newThis: ExpectMatcherState = {
|
||||
isNot,
|
||||
|
|
@ -152,6 +151,8 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re
|
|||
(newThis as any).equals = throwUnsupportedExpectMatcherError;
|
||||
return (matcher as any).call(newThis, ...args);
|
||||
};
|
||||
const key = qualifiedMatcherName(qualifier, name);
|
||||
wrappedMatchers[key] = wrappedMatchers[name];
|
||||
Object.defineProperty(wrappedMatchers[key], 'name', { value: name });
|
||||
extendedMatchers[name] = wrappedMatchers[key];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
|
|||
if (opts.onlyChanged)
|
||||
throw new Error(`--only-changed is not supported in UI mode. If you'd like that to change, see https://github.com/microsoft/playwright/issues/15075 for more details.`);
|
||||
|
||||
const status = await testServer.runUIMode(opts.config, {
|
||||
const status = await testServer.runUIMode(opts.config, cliOverrides, {
|
||||
host: opts.uiHost,
|
||||
port: opts.uiPort ? +opts.uiPort : undefined,
|
||||
args,
|
||||
|
|
@ -172,7 +172,6 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
|
|||
reporter: Array.isArray(opts.reporter) ? opts.reporter : opts.reporter ? [opts.reporter] : undefined,
|
||||
workers: cliOverrides.workers,
|
||||
timeout: cliOverrides.timeout,
|
||||
outputDir: cliOverrides.outputDir,
|
||||
updateSnapshots: cliOverrides.updateSnapshots,
|
||||
});
|
||||
await stopProfiling('runner');
|
||||
|
|
@ -227,7 +226,7 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
|
|||
async function runTestServer(opts: { [key: string]: any }) {
|
||||
const host = opts.host || 'localhost';
|
||||
const port = opts.port ? +opts.port : 0;
|
||||
const status = await testServer.runTestServer(opts.config, { host, port });
|
||||
const status = await testServer.runTestServer(opts.config, { }, { host, port });
|
||||
if (status === 'restarted')
|
||||
return;
|
||||
const exitCode = status === 'interrupted' ? 130 : (status === 'passed' ? 0 : 1);
|
||||
|
|
|
|||
|
|
@ -44,14 +44,16 @@ const originalStderrWrite = process.stderr.write;
|
|||
|
||||
class TestServer {
|
||||
private _configLocation: ConfigLocation;
|
||||
private _configCLIOverrides: ConfigCLIOverrides;
|
||||
private _dispatcher: TestServerDispatcher | undefined;
|
||||
|
||||
constructor(configLocation: ConfigLocation) {
|
||||
constructor(configLocation: ConfigLocation, configCLIOverrides: ConfigCLIOverrides) {
|
||||
this._configLocation = configLocation;
|
||||
this._configCLIOverrides = configCLIOverrides;
|
||||
}
|
||||
|
||||
async start(options: { host?: string, port?: number }): Promise<HttpServer> {
|
||||
this._dispatcher = new TestServerDispatcher(this._configLocation);
|
||||
this._dispatcher = new TestServerDispatcher(this._configLocation, this._configCLIOverrides);
|
||||
return await startTraceViewerServer({ ...options, transport: this._dispatcher.transport });
|
||||
}
|
||||
|
||||
|
|
@ -63,6 +65,7 @@ class TestServer {
|
|||
|
||||
export class TestServerDispatcher implements TestServerInterface {
|
||||
private _configLocation: ConfigLocation;
|
||||
private _configCLIOverrides: ConfigCLIOverrides;
|
||||
|
||||
private _watcher: Watcher;
|
||||
private _watchedProjectDirs = new Set<string>();
|
||||
|
|
@ -81,9 +84,11 @@ export class TestServerDispatcher implements TestServerInterface {
|
|||
private _closeOnDisconnect = false;
|
||||
private _populateDependenciesOnList = false;
|
||||
|
||||
constructor(configLocation: ConfigLocation) {
|
||||
constructor(configLocation: ConfigLocation, configCLIOverrides: ConfigCLIOverrides) {
|
||||
this._configLocation = configLocation;
|
||||
this._configCLIOverrides = configCLIOverrides;
|
||||
this.transport = {
|
||||
onconnect: () => {},
|
||||
dispatch: (method, params) => (this as any)[method](params),
|
||||
onclose: () => {
|
||||
if (this._closeOnDisconnect)
|
||||
|
|
@ -144,11 +149,8 @@ export class TestServerDispatcher implements TestServerInterface {
|
|||
async runGlobalSetup(params: Parameters<TestServerInterface['runGlobalSetup']>[0]): ReturnType<TestServerInterface['runGlobalSetup']> {
|
||||
await this.runGlobalTeardown();
|
||||
|
||||
const overrides: ConfigCLIOverrides = {
|
||||
outputDir: params.outputDir,
|
||||
};
|
||||
const { reporter, report } = await this._collectingInternalReporter(new ListReporter());
|
||||
const config = await this._loadConfigOrReportError(reporter, overrides);
|
||||
const config = await this._loadConfigOrReportError(reporter, this._configCLIOverrides);
|
||||
if (!config)
|
||||
return { status: 'failed', report };
|
||||
|
||||
|
|
@ -238,9 +240,9 @@ export class TestServerDispatcher implements TestServerInterface {
|
|||
config?: FullConfigInternal,
|
||||
}> {
|
||||
const overrides: ConfigCLIOverrides = {
|
||||
...this._configCLIOverrides,
|
||||
repeatEach: 1,
|
||||
retries: 0,
|
||||
outputDir: params.outputDir,
|
||||
};
|
||||
const { reporter, report } = await this._collectingInternalReporter();
|
||||
const config = await this._loadConfigOrReportError(reporter, overrides);
|
||||
|
|
@ -294,6 +296,7 @@ export class TestServerDispatcher implements TestServerInterface {
|
|||
private async _innerRunTests(params: Parameters<TestServerInterface['runTests']>[0]): ReturnType<TestServerInterface['runTests']> {
|
||||
await this.stopTests();
|
||||
const overrides: ConfigCLIOverrides = {
|
||||
...this._configCLIOverrides,
|
||||
repeatEach: 1,
|
||||
retries: 0,
|
||||
preserveOutputDir: true,
|
||||
|
|
@ -306,7 +309,6 @@ export class TestServerDispatcher implements TestServerInterface {
|
|||
_optionContextReuseMode: params.reuseContext ? 'when-possible' : undefined,
|
||||
_optionConnectOptions: params.connectWsEndpoint ? { wsEndpoint: params.connectWsEndpoint } : undefined,
|
||||
},
|
||||
outputDir: params.outputDir,
|
||||
updateSnapshots: params.updateSnapshots,
|
||||
workers: params.workers,
|
||||
};
|
||||
|
|
@ -423,9 +425,9 @@ export class TestServerDispatcher implements TestServerInterface {
|
|||
}
|
||||
}
|
||||
|
||||
export async function runUIMode(configFile: string | undefined, options: TraceViewerServerOptions & TraceViewerRedirectOptions): Promise<reporterTypes.FullResult['status'] | 'restarted'> {
|
||||
export async function runUIMode(configFile: string | undefined, configCLIOverrides: ConfigCLIOverrides, options: TraceViewerServerOptions & TraceViewerRedirectOptions): Promise<reporterTypes.FullResult['status'] | 'restarted'> {
|
||||
const configLocation = resolveConfigLocation(configFile);
|
||||
return await innerRunTestServer(configLocation, options, async (server: HttpServer, cancelPromise: ManualPromise<void>) => {
|
||||
return await innerRunTestServer(configLocation, configCLIOverrides, options, async (server: HttpServer, cancelPromise: ManualPromise<void>) => {
|
||||
await installRootRedirect(server, [], { ...options, webApp: 'uiMode.html' });
|
||||
if (options.host !== undefined || options.port !== undefined) {
|
||||
await openTraceInBrowser(server.urlPrefix('human-readable'));
|
||||
|
|
@ -441,18 +443,18 @@ export async function runUIMode(configFile: string | undefined, options: TraceVi
|
|||
});
|
||||
}
|
||||
|
||||
export async function runTestServer(configFile: string | undefined, options: { host?: string, port?: number }): Promise<reporterTypes.FullResult['status'] | 'restarted'> {
|
||||
export async function runTestServer(configFile: string | undefined, configCLIOverrides: ConfigCLIOverrides, options: { host?: string, port?: number }): Promise<reporterTypes.FullResult['status'] | 'restarted'> {
|
||||
const configLocation = resolveConfigLocation(configFile);
|
||||
return await innerRunTestServer(configLocation, options, async server => {
|
||||
return await innerRunTestServer(configLocation, configCLIOverrides, options, async server => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Listening on ' + server.urlPrefix('precise').replace('http:', 'ws:') + '/' + server.wsGuid());
|
||||
});
|
||||
}
|
||||
|
||||
async function innerRunTestServer(configLocation: ConfigLocation, options: { host?: string, port?: number }, openUI: (server: HttpServer, cancelPromise: ManualPromise<void>, configLocation: ConfigLocation) => Promise<void>): Promise<reporterTypes.FullResult['status'] | 'restarted'> {
|
||||
async function innerRunTestServer(configLocation: ConfigLocation, configCLIOverrides: ConfigCLIOverrides, options: { host?: string, port?: number }, openUI: (server: HttpServer, cancelPromise: ManualPromise<void>, configLocation: ConfigLocation) => Promise<void>): Promise<reporterTypes.FullResult['status'] | 'restarted'> {
|
||||
if (restartWithExperimentalTsEsm(undefined, true))
|
||||
return 'restarted';
|
||||
const testServer = new TestServer(configLocation);
|
||||
const testServer = new TestServer(configLocation, configCLIOverrides);
|
||||
const cancelPromise = new ManualPromise<void>();
|
||||
const sigintWatcher = new SigIntWatcher();
|
||||
process.stdin.on('close', () => gracefullyProcessExitDoNotHang(0));
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp
|
|||
const options: WatchModeOptions = { ...initialOptions };
|
||||
let bufferMode = false;
|
||||
|
||||
const testServerDispatcher = new TestServerDispatcher(configLocation);
|
||||
const testServerDispatcher = new TestServerDispatcher(configLocation, {});
|
||||
const transport = new InMemoryTransport(
|
||||
async data => {
|
||||
const { id, method, params } = JSON.parse(data);
|
||||
|
|
@ -144,11 +144,13 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp
|
|||
else
|
||||
printPrompt();
|
||||
|
||||
const waitForCommand = readCommand();
|
||||
const command = await Promise.race([
|
||||
onDirtyTests,
|
||||
readCommand(),
|
||||
waitForCommand.result,
|
||||
]);
|
||||
|
||||
if (command === 'changed')
|
||||
waitForCommand.cancel();
|
||||
if (bufferMode && command === 'changed')
|
||||
continue;
|
||||
|
||||
|
|
@ -282,7 +284,7 @@ async function runTests(watchOptions: WatchModeOptions, testServerConnection: Te
|
|||
});
|
||||
}
|
||||
|
||||
function readCommand(): ManualPromise<Command> {
|
||||
function readCommand(): { result: Promise<Command>, cancel: () => void } {
|
||||
const result = new ManualPromise<Command>();
|
||||
const rl = readline.createInterface({ input: process.stdin, escapeCodeTimeout: 50 });
|
||||
readline.emitKeypressEvents(process.stdin, rl);
|
||||
|
|
@ -334,13 +336,14 @@ Change settings
|
|||
};
|
||||
|
||||
process.stdin.on('keypress', handler);
|
||||
void result.finally(() => {
|
||||
const cancel = () => {
|
||||
process.stdin.off('keypress', handler);
|
||||
rl.close();
|
||||
if (process.stdin.isTTY)
|
||||
process.stdin.setRawMode(false);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
void result.finally(cancel);
|
||||
return { result, cancel };
|
||||
}
|
||||
|
||||
let showBrowserServer: PlaywrightServer | undefined;
|
||||
|
|
|
|||
|
|
@ -1526,7 +1526,7 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, EventT
|
|||
setOffline(params: BrowserContextSetOfflineParams, metadata?: CallMetadata): Promise<BrowserContextSetOfflineResult>;
|
||||
storageState(params?: BrowserContextStorageStateParams, metadata?: CallMetadata): Promise<BrowserContextStorageStateResult>;
|
||||
pause(params?: BrowserContextPauseParams, metadata?: CallMetadata): Promise<BrowserContextPauseResult>;
|
||||
recorderSupplementEnable(params: BrowserContextRecorderSupplementEnableParams, metadata?: CallMetadata): Promise<BrowserContextRecorderSupplementEnableResult>;
|
||||
enableRecorder(params: BrowserContextEnableRecorderParams, metadata?: CallMetadata): Promise<BrowserContextEnableRecorderResult>;
|
||||
newCDPSession(params: BrowserContextNewCDPSessionParams, metadata?: CallMetadata): Promise<BrowserContextNewCDPSessionResult>;
|
||||
harStart(params: BrowserContextHarStartParams, metadata?: CallMetadata): Promise<BrowserContextHarStartResult>;
|
||||
harExport(params: BrowserContextHarExportParams, metadata?: CallMetadata): Promise<BrowserContextHarExportResult>;
|
||||
|
|
@ -1766,9 +1766,10 @@ export type BrowserContextStorageStateResult = {
|
|||
export type BrowserContextPauseParams = {};
|
||||
export type BrowserContextPauseOptions = {};
|
||||
export type BrowserContextPauseResult = void;
|
||||
export type BrowserContextRecorderSupplementEnableParams = {
|
||||
export type BrowserContextEnableRecorderParams = {
|
||||
language?: string,
|
||||
mode?: 'inspecting' | 'recording',
|
||||
codegenMode?: 'actions' | 'trace-events',
|
||||
pauseOnNextStatement?: boolean,
|
||||
testIdAttributeName?: string,
|
||||
launchOptions?: any,
|
||||
|
|
@ -1778,9 +1779,10 @@ export type BrowserContextRecorderSupplementEnableParams = {
|
|||
outputFile?: string,
|
||||
omitCallTracking?: boolean,
|
||||
};
|
||||
export type BrowserContextRecorderSupplementEnableOptions = {
|
||||
export type BrowserContextEnableRecorderOptions = {
|
||||
language?: string,
|
||||
mode?: 'inspecting' | 'recording',
|
||||
codegenMode?: 'actions' | 'trace-events',
|
||||
pauseOnNextStatement?: boolean,
|
||||
testIdAttributeName?: string,
|
||||
launchOptions?: any,
|
||||
|
|
@ -1790,7 +1792,7 @@ export type BrowserContextRecorderSupplementEnableOptions = {
|
|||
outputFile?: string,
|
||||
omitCallTracking?: boolean,
|
||||
};
|
||||
export type BrowserContextRecorderSupplementEnableResult = void;
|
||||
export type BrowserContextEnableRecorderResult = void;
|
||||
export type BrowserContextNewCDPSessionParams = {
|
||||
page?: PageChannel,
|
||||
frame?: FrameChannel,
|
||||
|
|
@ -3769,7 +3771,6 @@ export type RouteRedirectNavigationRequestOptions = {
|
|||
export type RouteRedirectNavigationRequestResult = void;
|
||||
export type RouteAbortParams = {
|
||||
errorCode?: string,
|
||||
requestUrl: string,
|
||||
};
|
||||
export type RouteAbortOptions = {
|
||||
errorCode?: string,
|
||||
|
|
@ -3780,7 +3781,6 @@ export type RouteContinueParams = {
|
|||
method?: string,
|
||||
headers?: NameValue[],
|
||||
postData?: Binary,
|
||||
requestUrl: string,
|
||||
isFallback: boolean,
|
||||
};
|
||||
export type RouteContinueOptions = {
|
||||
|
|
@ -3796,7 +3796,6 @@ export type RouteFulfillParams = {
|
|||
body?: string,
|
||||
isBase64?: boolean,
|
||||
fetchResponseUid?: string,
|
||||
requestUrl: string,
|
||||
};
|
||||
export type RouteFulfillOptions = {
|
||||
status?: number,
|
||||
|
|
|
|||
|
|
@ -1187,7 +1187,7 @@ BrowserContext:
|
|||
pause:
|
||||
experimental: True
|
||||
|
||||
recorderSupplementEnable:
|
||||
enableRecorder:
|
||||
experimental: True
|
||||
parameters:
|
||||
language: string?
|
||||
|
|
@ -1196,6 +1196,11 @@ BrowserContext:
|
|||
literals:
|
||||
- inspecting
|
||||
- recording
|
||||
codegenMode:
|
||||
type: enum?
|
||||
literals:
|
||||
- actions
|
||||
- trace-events
|
||||
pauseOnNextStatement: boolean?
|
||||
testIdAttributeName: string?
|
||||
launchOptions: json?
|
||||
|
|
@ -2941,7 +2946,6 @@ Route:
|
|||
abort:
|
||||
parameters:
|
||||
errorCode: string?
|
||||
requestUrl: string
|
||||
|
||||
continue:
|
||||
parameters:
|
||||
|
|
@ -2951,7 +2955,6 @@ Route:
|
|||
type: array?
|
||||
items: NameValue
|
||||
postData: binary?
|
||||
requestUrl: string
|
||||
isFallback: boolean
|
||||
|
||||
fulfill:
|
||||
|
|
@ -2964,7 +2967,6 @@ Route:
|
|||
body: string?
|
||||
isBase64: boolean?
|
||||
fetchResponseUid: string?
|
||||
requestUrl: string
|
||||
|
||||
|
||||
WebSocketRoute:
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
|
|||
import { SplitView } from '@web/components/splitView';
|
||||
import { TabbedPane } from '@web/components/tabbedPane';
|
||||
import { Toolbar } from '@web/components/toolbar';
|
||||
import { emptySource, SourceChooser } from '@web/components/sourceChooser';
|
||||
import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton';
|
||||
import * as React from 'react';
|
||||
import { CallLogView } from './callLog';
|
||||
|
|
@ -54,15 +55,7 @@ export const Recorder: React.FC<RecorderProps> = ({
|
|||
if (source)
|
||||
return source;
|
||||
}
|
||||
const source: Source = {
|
||||
id: 'default',
|
||||
isRecorded: false,
|
||||
text: '',
|
||||
language: 'javascript',
|
||||
label: '',
|
||||
highlight: []
|
||||
};
|
||||
return source;
|
||||
return emptySource();
|
||||
}, [sources, fileId]);
|
||||
|
||||
const [locator, setLocator] = React.useState('');
|
||||
|
|
@ -152,10 +145,10 @@ export const Recorder: React.FC<RecorderProps> = ({
|
|||
}}></ToolbarButton>
|
||||
<div style={{ flex: 'auto' }}></div>
|
||||
<div>Target:</div>
|
||||
<select className='recorder-chooser' hidden={!sources.length} value={fileId} onChange={event => {
|
||||
setFileId(event.target.selectedOptions[0].value);
|
||||
window.dispatch({ event: 'fileChanged', params: { file: event.target.selectedOptions[0].value } });
|
||||
}}>{renderSourceOptions(sources)}</select>
|
||||
<SourceChooser fileId={fileId} sources={sources} setFileId={fileId => {
|
||||
setFileId(fileId);
|
||||
window.dispatch({ event: 'fileChanged', params: { file: fileId } });
|
||||
}} />
|
||||
<ToolbarButton icon='clear-all' title='Clear' disabled={!source || !source.text} onClick={() => {
|
||||
window.dispatch({ event: 'clear' });
|
||||
}}></ToolbarButton>
|
||||
|
|
@ -184,22 +177,3 @@ export const Recorder: React.FC<RecorderProps> = ({
|
|||
/>
|
||||
</div>;
|
||||
};
|
||||
|
||||
function renderSourceOptions(sources: Source[]): React.ReactNode {
|
||||
const transformTitle = (title: string): string => title.replace(/.*[/\\]([^/\\]+)/, '$1');
|
||||
const renderOption = (source: Source): React.ReactNode => (
|
||||
<option key={source.id} value={source.id}>{transformTitle(source.label)}</option>
|
||||
);
|
||||
|
||||
const hasGroup = sources.some(s => s.group);
|
||||
if (hasGroup) {
|
||||
const groups = new Set(sources.map(s => s.group));
|
||||
return [...groups].filter(Boolean).map(group => (
|
||||
<optgroup label={group} key={group}>
|
||||
{sources.filter(s => s.group === group).map(source => renderOption(source))}
|
||||
</optgroup>
|
||||
));
|
||||
}
|
||||
|
||||
return sources.map(source => renderOption(source));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,12 +47,14 @@ async function loadTrace(traceUrl: string, traceFileName: string | null, clientI
|
|||
}
|
||||
set.add(traceUrl);
|
||||
|
||||
const isRecorderMode = traceUrl.includes('/playwright-recorder-trace-');
|
||||
|
||||
const traceModel = new TraceModel();
|
||||
try {
|
||||
// Allow 10% to hop from sw to page.
|
||||
const [fetchProgress, unzipProgress] = splitProgress(progress, [0.5, 0.4, 0.1]);
|
||||
const backend = traceUrl.endsWith('json') ? new FetchTraceModelBackend(traceUrl) : new ZipTraceModelBackend(traceUrl, fetchProgress);
|
||||
await traceModel.load(backend, unzipProgress);
|
||||
await traceModel.load(backend, isRecorderMode, unzipProgress);
|
||||
} catch (error: any) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
*/
|
||||
|
||||
import { parseClientSideCallMetadata } from '../../../packages/playwright-core/src/utils/isomorphic/traceUtils';
|
||||
import type { ContextEntry } from './entries';
|
||||
import type { ActionEntry, ContextEntry } from './entries';
|
||||
import { createEmptyContext } from './entries';
|
||||
import { SnapshotStorage } from './snapshotStorage';
|
||||
import { TraceModernizer } from './traceModernizer';
|
||||
|
|
@ -38,7 +38,7 @@ export class TraceModel {
|
|||
constructor() {
|
||||
}
|
||||
|
||||
async load(backend: TraceModelBackend, unzipProgress: (done: number, total: number) => void) {
|
||||
async load(backend: TraceModelBackend, isRecorderMode: boolean, unzipProgress: (done: number, total: number) => void) {
|
||||
this._backend = backend;
|
||||
|
||||
const ordinals: string[] = [];
|
||||
|
|
@ -72,7 +72,8 @@ export class TraceModel {
|
|||
modernizer.appendTrace(network);
|
||||
unzipProgress(++done, total);
|
||||
|
||||
contextEntry.actions = modernizer.actions().sort((a1, a2) => a1.startTime - a2.startTime);
|
||||
const actions = modernizer.actions().sort((a1, a2) => a1.startTime - a2.startTime);
|
||||
contextEntry.actions = isRecorderMode ? collapseActionsForRecorder(actions) : actions;
|
||||
|
||||
if (!backend.isLive()) {
|
||||
// Terminate actions w/o after event gracefully.
|
||||
|
|
@ -133,3 +134,19 @@ function stripEncodingFromContentType(contentType: string) {
|
|||
return charset[1];
|
||||
return contentType;
|
||||
}
|
||||
|
||||
function collapseActionsForRecorder(actions: ActionEntry[]): ActionEntry[] {
|
||||
const result: ActionEntry[] = [];
|
||||
for (const action of actions) {
|
||||
const lastAction = result[result.length - 1];
|
||||
const isSameAction = lastAction && lastAction.method === action.method && lastAction.pageId === action.pageId;
|
||||
const isSameSelector = lastAction && 'selector' in lastAction.params && 'selector' in action.params && action.params.selector === lastAction.params.selector;
|
||||
const shouldMerge = isSameAction && (action.method === 'goto' || (action.method === 'fill' && isSameSelector));
|
||||
if (!shouldMerge) {
|
||||
result.push(action);
|
||||
continue;
|
||||
}
|
||||
result[result.length - 1] = action;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,9 +32,9 @@ export interface ActionListProps {
|
|||
selectedTime: Boundaries | undefined,
|
||||
setSelectedTime: (time: Boundaries | undefined) => void,
|
||||
sdkLanguage: Language | undefined;
|
||||
onSelected: (action: ActionTraceEventInContext) => void,
|
||||
onHighlighted: (action: ActionTraceEventInContext | undefined) => void,
|
||||
revealConsole: () => void,
|
||||
onSelected?: (action: ActionTraceEventInContext) => void,
|
||||
onHighlighted?: (action: ActionTraceEventInContext | undefined) => void,
|
||||
revealConsole?: () => void,
|
||||
isLive?: boolean,
|
||||
}
|
||||
|
||||
|
|
@ -67,8 +67,8 @@ export const ActionList: React.FC<ActionListProps> = ({
|
|||
treeState={treeState}
|
||||
setTreeState={setTreeState}
|
||||
selectedItem={selectedItem}
|
||||
onSelected={item => onSelected(item.action!)}
|
||||
onHighlighted={item => onHighlighted(item?.action)}
|
||||
onSelected={item => onSelected?.(item.action!)}
|
||||
onHighlighted={item => onHighlighted?.(item?.action)}
|
||||
onAccepted={item => setSelectedTime({ minimum: item.action!.startTime, maximum: item.action!.endTime })}
|
||||
isError={item => !!item.action?.error?.message}
|
||||
isVisible={item => !selectedTime || (item.action!.startTime <= selectedTime.maximum && item.action!.endTime >= selectedTime.minimum)}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@
|
|||
|
||||
.call-section {
|
||||
padding-left: 6px;
|
||||
padding-top: 2px;
|
||||
margin-top: 2px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
|
|
@ -53,9 +55,8 @@
|
|||
align-items: center;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
line-height: 18px;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
max-height: 18px;
|
||||
}
|
||||
|
||||
.call-line:not(:hover) .toolbar-button.copy {
|
||||
|
|
@ -64,7 +65,8 @@
|
|||
|
||||
.call-line .toolbar-button.copy {
|
||||
margin-left: 5px;
|
||||
transform: scale(0.8);
|
||||
margin-top: -2px;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
.call-value {
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ export const ConsoleTab: React.FunctionComponent<{
|
|||
boundaries: Boundaries,
|
||||
consoleModel: ConsoleTabModel,
|
||||
selectedTime: Boundaries | undefined,
|
||||
onEntryHovered: (entry: ConsoleEntry | undefined) => void,
|
||||
onEntryHovered?: (entry: ConsoleEntry | undefined) => void,
|
||||
onAccepted: (entry: ConsoleEntry) => void,
|
||||
}> = ({ consoleModel, boundaries, onEntryHovered, onAccepted }) => {
|
||||
if (!consoleModel.entries.length)
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export function useNetworkTabModel(model: MultiTraceModel | undefined, selectedT
|
|||
export const NetworkTab: React.FunctionComponent<{
|
||||
boundaries: Boundaries,
|
||||
networkModel: NetworkTabModel,
|
||||
onEntryHovered: (entry: Entry | undefined) => void,
|
||||
onEntryHovered?: (entry: Entry | undefined) => void,
|
||||
}> = ({ boundaries, networkModel, onEntryHovered }) => {
|
||||
const [sorting, setSorting] = React.useState<Sorting | undefined>(undefined);
|
||||
const [selectedEntry, setSelectedEntry] = React.useState<RenderedEntry | undefined>(undefined);
|
||||
|
|
@ -95,7 +95,7 @@ export const NetworkTab: React.FunctionComponent<{
|
|||
items={renderedEntries}
|
||||
selectedItem={selectedEntry}
|
||||
onSelected={item => setSelectedEntry(item)}
|
||||
onHighlighted={item => onEntryHovered(item?.resource)}
|
||||
onHighlighted={item => onEntryHovered?.(item?.resource)}
|
||||
columns={visibleColumns(!!selectedEntry, renderedEntries)}
|
||||
columnTitle={columnTitle}
|
||||
columnWidths={columnWidths}
|
||||
|
|
|
|||
|
|
@ -14,54 +14,52 @@
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import './recorderView.css';
|
||||
import { MultiTraceModel } from './modelUtil';
|
||||
import type { SourceLocation } from './modelUtil';
|
||||
import { Workbench } from './workbench';
|
||||
import type { Language } from '@isomorphic/locatorGenerators';
|
||||
import type { Mode, Source } from '@recorder/recorderTypes';
|
||||
import { SplitView } from '@web/components/splitView';
|
||||
import type { TabbedPaneTabModel } from '@web/components/tabbedPane';
|
||||
import { TabbedPane } from '@web/components/tabbedPane';
|
||||
import { sha1, useSetting } from '@web/uiUtils';
|
||||
import * as React from 'react';
|
||||
import type { ContextEntry } from '../entries';
|
||||
import type { Boundaries } from '../geometry';
|
||||
import { ActionList } from './actionList';
|
||||
import { ConsoleTab, useConsoleTabModel } from './consoleTab';
|
||||
import { InspectorTab } from './inspectorTab';
|
||||
import type * as modelUtil from './modelUtil';
|
||||
import type { SourceLocation } from './modelUtil';
|
||||
import { MultiTraceModel } from './modelUtil';
|
||||
import { NetworkTab, useNetworkTabModel } from './networkTab';
|
||||
import './recorderView.css';
|
||||
import { collectSnapshots, extendSnapshot, SnapshotView } from './snapshotTab';
|
||||
import { SourceTab } from './sourceTab';
|
||||
import { Toolbar } from '@web/components/toolbar';
|
||||
import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton';
|
||||
import { toggleTheme } from '@web/theme';
|
||||
import { SourceChooser } from '@web/components/sourceChooser';
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const guid = searchParams.get('ws');
|
||||
const trace = searchParams.get('trace') + '.json';
|
||||
const traceLocation = searchParams.get('trace') + '.json';
|
||||
|
||||
export const RecorderView: React.FunctionComponent = () => {
|
||||
const [connection, setConnection] = React.useState<Connection | null>(null);
|
||||
const [sources, setSources] = React.useState<Source[]>([]);
|
||||
const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean, sha1: string } | undefined>();
|
||||
const [mode, setMode] = React.useState<Mode>('none');
|
||||
const [counter, setCounter] = React.useState(0);
|
||||
const pollTimer = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const wsURL = new URL(`../${guid}`, window.location.toString());
|
||||
wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:');
|
||||
const webSocket = new WebSocket(wsURL.toString());
|
||||
setConnection(new Connection(webSocket, { setSources }));
|
||||
setConnection(new Connection(webSocket, { setMode, setSources }));
|
||||
return () => {
|
||||
webSocket.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!connection)
|
||||
return;
|
||||
connection.setMode('recording');
|
||||
}, [connection]);
|
||||
|
||||
window.playwrightSourcesEchoForTest = sources;
|
||||
|
||||
return <div className='vbox workbench-loader'>
|
||||
<TraceView
|
||||
traceLocation={trace}
|
||||
sources={sources} />
|
||||
</div>;
|
||||
};
|
||||
|
||||
export const TraceView: React.FC<{
|
||||
traceLocation: string,
|
||||
sources: Source[],
|
||||
}> = ({ traceLocation, sources }) => {
|
||||
const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>();
|
||||
const [counter, setCounter] = React.useState(0);
|
||||
const pollTimer = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (pollTimer.current)
|
||||
clearTimeout(pollTimer.current);
|
||||
|
|
@ -69,8 +67,9 @@ export const TraceView: React.FC<{
|
|||
// Start polling running test.
|
||||
pollTimer.current = setTimeout(async () => {
|
||||
try {
|
||||
const model = await loadSingleTraceFile(traceLocation);
|
||||
setModel({ model, isLive: true });
|
||||
const result = await loadSingleTraceFile(traceLocation);
|
||||
if (result.sha1 !== model?.sha1)
|
||||
setModel({ ...result, isLive: true });
|
||||
} catch {
|
||||
setModel(undefined);
|
||||
} finally {
|
||||
|
|
@ -81,10 +80,94 @@ export const TraceView: React.FC<{
|
|||
if (pollTimer.current)
|
||||
clearTimeout(pollTimer.current);
|
||||
};
|
||||
}, [counter, traceLocation]);
|
||||
}, [counter, model]);
|
||||
|
||||
return <div className='vbox workbench-loader'>
|
||||
<Workbench
|
||||
key='workbench'
|
||||
mode={mode}
|
||||
setMode={mode => connection?.setMode(mode)}
|
||||
model={model?.model}
|
||||
sources={sources}
|
||||
/>
|
||||
</div>;
|
||||
};
|
||||
|
||||
async function loadSingleTraceFile(url: string): Promise<{ model: MultiTraceModel, sha1: string }> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('trace', url);
|
||||
const response = await fetch(`contexts?${params.toString()}`);
|
||||
const contextEntries = await response.json() as ContextEntry[];
|
||||
|
||||
const tokens: string[] = [];
|
||||
for (const entry of contextEntries) {
|
||||
entry.actions.forEach(a => tokens.push(a.type + '@' + a.startTime + '-' + a.endTime));
|
||||
entry.events.forEach(e => tokens.push(e.type + '@' + e.time));
|
||||
}
|
||||
return { model: new MultiTraceModel(contextEntries), sha1: await sha1(tokens.join('|')) };
|
||||
}
|
||||
|
||||
export const Workbench: React.FunctionComponent<{
|
||||
mode: Mode,
|
||||
setMode: (mode: Mode) => void,
|
||||
model?: modelUtil.MultiTraceModel,
|
||||
sources: Source[],
|
||||
}> = ({ mode, setMode, model, sources }) => {
|
||||
const [fileId, setFileId] = React.useState<string | undefined>();
|
||||
const [selectedCallId, setSelectedCallId] = React.useState<string | undefined>(undefined);
|
||||
const [selectedPropertiesTab, setSelectedPropertiesTab] = useSetting<string>('recorderPropertiesTab', 'source');
|
||||
const [isInspecting, setIsInspectingState] = React.useState(false);
|
||||
const [highlightedLocator, setHighlightedLocator] = React.useState<string>('');
|
||||
const [selectedTime, setSelectedTime] = React.useState<Boundaries | undefined>();
|
||||
const sourceModel = React.useRef(new Map<string, modelUtil.SourceModel>());
|
||||
|
||||
const setSelectedAction = React.useCallback((action: modelUtil.ActionTraceEventInContext | undefined) => {
|
||||
setSelectedCallId(action?.callId);
|
||||
}, []);
|
||||
|
||||
const selectedAction = React.useMemo(() => {
|
||||
return model?.actions.find(a => a.callId === selectedCallId);
|
||||
}, [model, selectedCallId]);
|
||||
|
||||
const onActionSelected = React.useCallback((action: modelUtil.ActionTraceEventInContext) => {
|
||||
setSelectedAction(action);
|
||||
}, [setSelectedAction]);
|
||||
|
||||
const selectPropertiesTab = React.useCallback((tab: string) => {
|
||||
setSelectedPropertiesTab(tab);
|
||||
if (tab !== 'inspector')
|
||||
setIsInspectingState(false);
|
||||
}, [setSelectedPropertiesTab]);
|
||||
|
||||
const setIsInspecting = React.useCallback((value: boolean) => {
|
||||
if (!isInspecting && value)
|
||||
selectPropertiesTab('inspector');
|
||||
setIsInspectingState(value);
|
||||
}, [setIsInspectingState, selectPropertiesTab, isInspecting]);
|
||||
|
||||
const locatorPicked = React.useCallback((locator: string) => {
|
||||
setHighlightedLocator(locator);
|
||||
selectPropertiesTab('inspector');
|
||||
}, [selectPropertiesTab]);
|
||||
|
||||
const consoleModel = useConsoleTabModel(model, selectedTime);
|
||||
const networkModel = useNetworkTabModel(model, selectedTime);
|
||||
const sdkLanguage = model?.sdkLanguage || 'javascript';
|
||||
|
||||
const inspectorTab: TabbedPaneTabModel = {
|
||||
id: 'inspector',
|
||||
title: 'Locator',
|
||||
render: () => <InspectorTab
|
||||
sdkLanguage={sdkLanguage}
|
||||
setIsInspecting={setIsInspecting}
|
||||
highlightedLocator={highlightedLocator}
|
||||
setHighlightedLocator={setHighlightedLocator} />,
|
||||
};
|
||||
|
||||
const source = React.useMemo(() => sources.find(s => s.id === fileId) || sources[0], [sources, fileId]);
|
||||
|
||||
const fallbackLocation = React.useMemo(() => {
|
||||
if (!sources.length)
|
||||
if (!source)
|
||||
return undefined;
|
||||
const fallbackLocation: SourceLocation = {
|
||||
file: '',
|
||||
|
|
@ -92,36 +175,178 @@ export const TraceView: React.FC<{
|
|||
column: 0,
|
||||
source: {
|
||||
errors: [],
|
||||
content: sources[0].text
|
||||
content: source.text
|
||||
}
|
||||
};
|
||||
return fallbackLocation;
|
||||
}, [sources]);
|
||||
}, [source]);
|
||||
|
||||
return <Workbench
|
||||
key='workbench'
|
||||
model={model?.model}
|
||||
showSourcesFirst={true}
|
||||
fallbackLocation={fallbackLocation}
|
||||
const sourceTab: TabbedPaneTabModel = {
|
||||
id: 'source',
|
||||
title: 'Source',
|
||||
render: () => <SourceTab
|
||||
sources={sourceModel.current}
|
||||
stackFrameLocation={'right'}
|
||||
fallbackLocation={fallbackLocation}
|
||||
/>
|
||||
};
|
||||
const consoleTab: TabbedPaneTabModel = {
|
||||
id: 'console',
|
||||
title: 'Console',
|
||||
count: consoleModel.entries.length,
|
||||
render: () => <ConsoleTab
|
||||
consoleModel={consoleModel}
|
||||
boundaries={boundaries}
|
||||
selectedTime={selectedTime}
|
||||
onAccepted={m => setSelectedTime({ minimum: m.timestamp, maximum: m.timestamp })}
|
||||
/>
|
||||
};
|
||||
const networkTab: TabbedPaneTabModel = {
|
||||
id: 'network',
|
||||
title: 'Network',
|
||||
count: networkModel.resources.length,
|
||||
render: () => <NetworkTab boundaries={boundaries} networkModel={networkModel} />
|
||||
};
|
||||
|
||||
const tabs: TabbedPaneTabModel[] = [
|
||||
sourceTab,
|
||||
inspectorTab,
|
||||
consoleTab,
|
||||
networkTab,
|
||||
];
|
||||
|
||||
const { boundaries } = React.useMemo(() => {
|
||||
const boundaries = { minimum: model?.startTime || 0, maximum: model?.endTime || 30000 };
|
||||
if (boundaries.minimum > boundaries.maximum) {
|
||||
boundaries.minimum = 0;
|
||||
boundaries.maximum = 30000;
|
||||
}
|
||||
// Leave some nice free space on the right hand side.
|
||||
boundaries.maximum += (boundaries.maximum - boundaries.minimum) / 20;
|
||||
return { boundaries };
|
||||
}, [model]);
|
||||
|
||||
const actionList = <ActionList
|
||||
sdkLanguage={sdkLanguage}
|
||||
actions={model?.actions || []}
|
||||
selectedAction={model ? selectedAction : undefined}
|
||||
selectedTime={selectedTime}
|
||||
setSelectedTime={setSelectedTime}
|
||||
onSelected={onActionSelected}
|
||||
revealConsole={() => selectPropertiesTab('console')}
|
||||
isLive={true}
|
||||
/>;
|
||||
|
||||
const actionsTab: TabbedPaneTabModel = {
|
||||
id: 'actions',
|
||||
title: 'Actions',
|
||||
component: actionList,
|
||||
};
|
||||
|
||||
const toolbar = <Toolbar sidebarBackground>
|
||||
<div style={{ width: 4 }}></div>
|
||||
<ToolbarButton icon='circle-large-filled' title='Record' toggled={mode === 'recording'} onClick={() => {
|
||||
setMode(mode === 'recording' ? 'standby' : 'recording');
|
||||
}}>Record</ToolbarButton>
|
||||
<ToolbarSeparator />
|
||||
<ToolbarButton icon='inspect' title='Pick locator' toggled={isInspecting} onClick={() => {
|
||||
setIsInspecting(!isInspecting);
|
||||
}} />
|
||||
<ToolbarButton icon='eye' title='Assert visibility' onClick={() => {
|
||||
}} />
|
||||
<ToolbarButton icon='whole-word' title='Assert text' onClick={() => {
|
||||
}} />
|
||||
<ToolbarButton icon='symbol-constant' title='Assert value' onClick={() => {
|
||||
}} />
|
||||
<ToolbarSeparator />
|
||||
<ToolbarButton icon='files' title='Copy' onClick={() => {
|
||||
}} />
|
||||
<div style={{ flex: 'auto' }}></div>
|
||||
<div>Target:</div>
|
||||
<SourceChooser fileId={fileId} sources={sources} setFileId={fileId => {
|
||||
setFileId(fileId);
|
||||
}} />
|
||||
<ToolbarButton icon='clear-all' title='Clear' onClick={() => {
|
||||
}}></ToolbarButton>
|
||||
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}></ToolbarButton>
|
||||
</Toolbar>;
|
||||
|
||||
const sidebarTabbedPane = <TabbedPane tabs={[actionsTab]} />;
|
||||
|
||||
const propertiesTabbedPane = <TabbedPane
|
||||
tabs={tabs}
|
||||
selectedTab={selectedPropertiesTab}
|
||||
setSelectedTab={selectPropertiesTab}
|
||||
/>;
|
||||
|
||||
const snapshotView = <SnapshotContainer
|
||||
sdkLanguage={sdkLanguage}
|
||||
action={selectedAction}
|
||||
testIdAttributeName={model?.testIdAttributeName || 'data-testid'}
|
||||
isInspecting={isInspecting}
|
||||
setIsInspecting={setIsInspecting}
|
||||
highlightedLocator={highlightedLocator}
|
||||
locatorPicked={locatorPicked} />;
|
||||
|
||||
return <div className='vbox workbench'>
|
||||
<SplitView
|
||||
sidebarSize={250}
|
||||
orientation={'horizontal'}
|
||||
settingName='recorderActionListSidebar'
|
||||
sidebarIsFirst
|
||||
main={<SplitView
|
||||
sidebarSize={250}
|
||||
orientation='vertical'
|
||||
settingName='recorderPropertiesSidebar'
|
||||
main={<div className='vbox'>
|
||||
{toolbar}
|
||||
{snapshotView}
|
||||
</div>}
|
||||
sidebar={propertiesTabbedPane}
|
||||
/>}
|
||||
sidebar={sidebarTabbedPane}
|
||||
/>
|
||||
</div>;
|
||||
};
|
||||
|
||||
async function loadSingleTraceFile(url: string): Promise<MultiTraceModel> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('trace', url);
|
||||
const response = await fetch(`contexts?${params.toString()}`);
|
||||
const contextEntries = await response.json() as ContextEntry[];
|
||||
return new MultiTraceModel(contextEntries);
|
||||
}
|
||||
const SnapshotContainer: React.FunctionComponent<{
|
||||
sdkLanguage: Language,
|
||||
action: modelUtil.ActionTraceEventInContext | undefined,
|
||||
testIdAttributeName?: string,
|
||||
isInspecting: boolean,
|
||||
highlightedLocator: string,
|
||||
setIsInspecting: (value: boolean) => void,
|
||||
locatorPicked: (locator: string) => void,
|
||||
}> = ({ sdkLanguage, action, testIdAttributeName, isInspecting, setIsInspecting, highlightedLocator, locatorPicked }) => {
|
||||
const snapshot = React.useMemo(() => {
|
||||
const snapshot = collectSnapshots(action);
|
||||
return snapshot.action || snapshot.after || snapshot.before;
|
||||
}, [action]);
|
||||
const snapshotUrls = React.useMemo(() => {
|
||||
return snapshot ? extendSnapshot(snapshot) : undefined;
|
||||
}, [snapshot]);
|
||||
return <SnapshotView
|
||||
sdkLanguage={sdkLanguage}
|
||||
testIdAttributeName={testIdAttributeName || 'data-testid'}
|
||||
isInspecting={isInspecting}
|
||||
setIsInspecting={setIsInspecting}
|
||||
highlightedLocator={highlightedLocator}
|
||||
setHighlightedLocator={locatorPicked}
|
||||
snapshotUrls={snapshotUrls} />;
|
||||
};
|
||||
|
||||
type ConnectionOptions = {
|
||||
setSources: (sources: Source[]) => void;
|
||||
setMode: (mode: Mode) => void;
|
||||
};
|
||||
|
||||
class Connection {
|
||||
private _lastId = 0;
|
||||
private _webSocket: WebSocket;
|
||||
private _callbacks = new Map<number, { resolve: (arg: any) => void, reject: (arg: Error) => void }>();
|
||||
private _options: { setSources: (sources: Source[]) => void; };
|
||||
private _options: ConnectionOptions;
|
||||
|
||||
constructor(webSocket: WebSocket, options: { setSources: (sources: Source[]) => void }) {
|
||||
constructor(webSocket: WebSocket, options: ConnectionOptions) {
|
||||
this._webSocket = webSocket;
|
||||
this._callbacks = new Map();
|
||||
this._options = options;
|
||||
|
|
@ -165,6 +390,11 @@ class Connection {
|
|||
if (method === 'setSources') {
|
||||
const { sources } = params as { sources: Source[] };
|
||||
this._options.setSources(sources);
|
||||
window.playwrightSourcesEchoForTest = sources;
|
||||
}
|
||||
if (method === 'setMode') {
|
||||
const { mode } = params as { mode: Mode };
|
||||
this._options.setMode(mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,12 +15,8 @@
|
|||
*/
|
||||
|
||||
.snapshot-tab {
|
||||
display: flex;
|
||||
flex: auto;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
outline: none;
|
||||
--browser-frame-header-height: 40px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
@ -73,6 +69,7 @@
|
|||
margin: 1px;
|
||||
padding: 10px;
|
||||
position: relative;
|
||||
--browser-frame-header-height: 40px;
|
||||
}
|
||||
|
||||
.snapshot-container {
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ function findClosest<T>(items: T[], metric: (v: T) => number, target: number) {
|
|||
});
|
||||
}
|
||||
|
||||
export const SnapshotTab: React.FunctionComponent<{
|
||||
export const SnapshotTabsView: React.FunctionComponent<{
|
||||
action: ActionTraceEvent | undefined,
|
||||
model?: MultiTraceModel,
|
||||
sdkLanguage: Language,
|
||||
|
|
@ -50,63 +50,69 @@ export const SnapshotTab: React.FunctionComponent<{
|
|||
highlightedLocator: string,
|
||||
setHighlightedLocator: (locator: string) => void,
|
||||
openPage?: (url: string, target?: string) => Window | any,
|
||||
}> = ({ action, model, sdkLanguage, testIdAttributeName, isInspecting, setIsInspecting, highlightedLocator, setHighlightedLocator, openPage }) => {
|
||||
const [measure, ref] = useMeasure<HTMLDivElement>();
|
||||
}> = ({ action, sdkLanguage, testIdAttributeName, isInspecting, setIsInspecting, highlightedLocator, setHighlightedLocator, openPage }) => {
|
||||
const [snapshotTab, setSnapshotTab] = React.useState<'action'|'before'|'after'>('action');
|
||||
const [showScreenshotInsteadOfSnapshot] = useSetting('screenshot-instead-of-snapshot', false);
|
||||
|
||||
type Snapshot = { action: ActionTraceEvent, snapshotName: string, point?: { x: number, y: number }, hasInputTarget?: boolean };
|
||||
const { snapshots } = React.useMemo(() => {
|
||||
if (!action)
|
||||
return { snapshots: {} };
|
||||
|
||||
// if the action has no beforeSnapshot, use the last available afterSnapshot.
|
||||
let beforeSnapshot: Snapshot | undefined = action.beforeSnapshot ? { action, snapshotName: action.beforeSnapshot } : undefined;
|
||||
let a = action;
|
||||
while (!beforeSnapshot && a) {
|
||||
a = prevInList(a);
|
||||
beforeSnapshot = a?.afterSnapshot ? { action: a, snapshotName: a?.afterSnapshot } : undefined;
|
||||
}
|
||||
const afterSnapshot: Snapshot | undefined = action.afterSnapshot ? { action, snapshotName: action.afterSnapshot } : beforeSnapshot;
|
||||
const actionSnapshot: Snapshot | undefined = action.inputSnapshot ? { action, snapshotName: action.inputSnapshot, hasInputTarget: true } : afterSnapshot;
|
||||
if (actionSnapshot)
|
||||
actionSnapshot.point = action.point;
|
||||
return { snapshots: { action: actionSnapshot, before: beforeSnapshot, after: afterSnapshot } };
|
||||
const snapshots = React.useMemo(() => {
|
||||
return collectSnapshots(action);
|
||||
}, [action]);
|
||||
|
||||
const { snapshotInfoUrl, snapshotUrl, popoutUrl, point } = React.useMemo(() => {
|
||||
const snapshotUrls = React.useMemo(() => {
|
||||
const snapshot = snapshots[snapshotTab];
|
||||
if (!snapshot)
|
||||
return { snapshotUrl: kBlankSnapshotUrl };
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('trace', context(snapshot.action).traceUrl);
|
||||
params.set('name', snapshot.snapshotName);
|
||||
if (snapshot.point) {
|
||||
params.set('pointX', String(snapshot.point.x));
|
||||
params.set('pointY', String(snapshot.point.y));
|
||||
if (snapshot.hasInputTarget)
|
||||
params.set('hasInputTarget', '1');
|
||||
}
|
||||
const snapshotUrl = new URL(`snapshot/${snapshot.action.pageId}?${params.toString()}`, window.location.href).toString();
|
||||
const snapshotInfoUrl = new URL(`snapshotInfo/${snapshot.action.pageId}?${params.toString()}`, window.location.href).toString();
|
||||
|
||||
const popoutParams = new URLSearchParams();
|
||||
popoutParams.set('r', snapshotUrl);
|
||||
popoutParams.set('trace', context(snapshot.action).traceUrl);
|
||||
if (snapshot.point) {
|
||||
popoutParams.set('pointX', String(snapshot.point.x));
|
||||
popoutParams.set('pointY', String(snapshot.point.y));
|
||||
if (snapshot.hasInputTarget)
|
||||
params.set('hasInputTarget', '1');
|
||||
}
|
||||
const popoutUrl = new URL(`snapshot.html?${popoutParams.toString()}`, window.location.href).toString();
|
||||
return { snapshots, snapshotInfoUrl, snapshotUrl, popoutUrl, point: snapshot.point };
|
||||
return snapshot ? extendSnapshot(snapshot) : undefined;
|
||||
}, [snapshots, snapshotTab]);
|
||||
|
||||
return <div className='snapshot-tab vbox'>
|
||||
<Toolbar>
|
||||
<ToolbarButton className='pick-locator' title={showScreenshotInsteadOfSnapshot ? 'Disable "screenshots instead of snapshots" to pick a locator' : 'Pick locator'} icon='target' toggled={isInspecting} onClick={() => setIsInspecting(!isInspecting)} disabled={showScreenshotInsteadOfSnapshot} />
|
||||
{['action', 'before', 'after'].map(tab => {
|
||||
return <TabbedPaneTab
|
||||
key={tab}
|
||||
id={tab}
|
||||
title={renderTitle(tab)}
|
||||
selected={snapshotTab === tab}
|
||||
onSelect={() => setSnapshotTab(tab as 'action' | 'before' | 'after')}
|
||||
></TabbedPaneTab>;
|
||||
})}
|
||||
<div style={{ flex: 'auto' }}></div>
|
||||
<ToolbarButton icon='link-external' title={showScreenshotInsteadOfSnapshot ? 'Not available when showing screenshot' : 'Open snapshot in a new tab'} disabled={!snapshotUrls?.popoutUrl || showScreenshotInsteadOfSnapshot} onClick={() => {
|
||||
if (!openPage)
|
||||
openPage = window.open;
|
||||
const win = openPage(snapshotUrls?.popoutUrl || '', '_blank');
|
||||
win?.addEventListener('DOMContentLoaded', () => {
|
||||
const injectedScript = new InjectedScript(win as any, false, sdkLanguage, testIdAttributeName, 1, 'chromium', []);
|
||||
new ConsoleAPI(injectedScript);
|
||||
});
|
||||
}} />
|
||||
</Toolbar>
|
||||
{!showScreenshotInsteadOfSnapshot && <SnapshotView
|
||||
snapshotUrls={snapshotUrls}
|
||||
sdkLanguage={sdkLanguage}
|
||||
testIdAttributeName={testIdAttributeName}
|
||||
isInspecting={isInspecting}
|
||||
setIsInspecting={setIsInspecting}
|
||||
highlightedLocator={highlightedLocator}
|
||||
setHighlightedLocator={setHighlightedLocator}
|
||||
/>}
|
||||
{showScreenshotInsteadOfSnapshot && <ScreenshotView
|
||||
action={action}
|
||||
snapshotUrls={snapshotUrls}
|
||||
snapshot={snapshots[snapshotTab]} />}
|
||||
</div>;
|
||||
};
|
||||
|
||||
export const SnapshotView: React.FunctionComponent<{
|
||||
snapshotUrls: SnapshotUrls | undefined,
|
||||
sdkLanguage: Language,
|
||||
testIdAttributeName: string,
|
||||
isInspecting: boolean,
|
||||
setIsInspecting: (isInspecting: boolean) => void,
|
||||
highlightedLocator: string,
|
||||
setHighlightedLocator: (locator: string) => void,
|
||||
}> = ({ snapshotUrls, sdkLanguage, testIdAttributeName, isInspecting, setIsInspecting, highlightedLocator, setHighlightedLocator }) => {
|
||||
const iframeRef0 = React.useRef<HTMLIFrameElement>(null);
|
||||
const iframeRef1 = React.useRef<HTMLIFrameElement>(null);
|
||||
const [snapshotInfo, setSnapshotInfo] = React.useState<{ viewport: typeof kDefaultViewport, url: string, timestamp?: number, wallTime?: undefined }>({ viewport: kDefaultViewport, url: '' });
|
||||
const [snapshotInfo, setSnapshotInfo] = React.useState<SnapshotInfo>({ viewport: kDefaultViewport, url: '' });
|
||||
const loadingRef = React.useRef({ iteration: 0, visibleIframe: 0 });
|
||||
|
||||
React.useEffect(() => {
|
||||
|
|
@ -115,17 +121,7 @@ export const SnapshotTab: React.FunctionComponent<{
|
|||
const newVisibleIframe = 1 - loadingRef.current.visibleIframe;
|
||||
loadingRef.current.iteration = thisIteration;
|
||||
|
||||
const newSnapshotInfo = { url: '', viewport: kDefaultViewport, timestamp: undefined, wallTime: undefined };
|
||||
if (snapshotInfoUrl) {
|
||||
const response = await fetch(snapshotInfoUrl);
|
||||
const info = await response.json();
|
||||
if (!info.error) {
|
||||
newSnapshotInfo.url = info.url;
|
||||
newSnapshotInfo.viewport = info.viewport;
|
||||
newSnapshotInfo.timestamp = info.timestamp;
|
||||
newSnapshotInfo.wallTime = info.wallTime;
|
||||
}
|
||||
}
|
||||
const newSnapshotInfo = await fetchSnapshotInfo(snapshotUrls?.snapshotInfoUrl);
|
||||
|
||||
// Interrupted by another load - bail out.
|
||||
if (loadingRef.current.iteration !== thisIteration)
|
||||
|
|
@ -140,6 +136,7 @@ export const SnapshotTab: React.FunctionComponent<{
|
|||
iframe.addEventListener('error', loadedCallback);
|
||||
|
||||
// Try preventing history entry from being created.
|
||||
const snapshotUrl = snapshotUrls?.snapshotUrl || kBlankSnapshotUrl;
|
||||
if (iframe.contentWindow)
|
||||
iframe.contentWindow.location.replace(snapshotUrl);
|
||||
else
|
||||
|
|
@ -159,33 +156,10 @@ export const SnapshotTab: React.FunctionComponent<{
|
|||
loadingRef.current.visibleIframe = newVisibleIframe;
|
||||
setSnapshotInfo(newSnapshotInfo);
|
||||
})();
|
||||
}, [snapshotUrl, snapshotInfoUrl]);
|
||||
|
||||
const windowHeaderHeight = 40;
|
||||
const snapshotContainerSize = {
|
||||
width: snapshotInfo.viewport.width,
|
||||
height: snapshotInfo.viewport.height + windowHeaderHeight,
|
||||
};
|
||||
const scale = Math.min(measure.width / snapshotContainerSize.width, measure.height / snapshotContainerSize.height, 1);
|
||||
const translate = {
|
||||
x: (measure.width - snapshotContainerSize.width) / 2,
|
||||
y: (measure.height - snapshotContainerSize.height) / 2,
|
||||
};
|
||||
|
||||
const page = action ? pageForAction(action) : undefined;
|
||||
const screencastFrame = React.useMemo(
|
||||
() => {
|
||||
if (snapshotInfo.wallTime && page?.screencastFrames[0]?.frameSwapWallTime)
|
||||
return findClosest(page.screencastFrames, frame => frame.frameSwapWallTime!, snapshotInfo.wallTime);
|
||||
|
||||
if (snapshotInfo.timestamp && page?.screencastFrames)
|
||||
return findClosest(page.screencastFrames, frame => frame.timestamp, snapshotInfo.timestamp);
|
||||
},
|
||||
[page?.screencastFrames, snapshotInfo.timestamp, snapshotInfo.wallTime]
|
||||
);
|
||||
}, [snapshotUrls]);
|
||||
|
||||
return <div
|
||||
className='snapshot-tab'
|
||||
className='vbox'
|
||||
tabIndex={0}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Escape') {
|
||||
|
|
@ -210,46 +184,72 @@ export const SnapshotTab: React.FunctionComponent<{
|
|||
setHighlightedLocator={setHighlightedLocator}
|
||||
iframe={iframeRef1.current}
|
||||
iteration={loadingRef.current.iteration} />
|
||||
<Toolbar>
|
||||
<ToolbarButton className='pick-locator' title={showScreenshotInsteadOfSnapshot ? 'Disable "screenshots instead of snapshots" to pick a locator' : 'Pick locator'} icon='target' toggled={isInspecting} onClick={() => setIsInspecting(!isInspecting)} disabled={showScreenshotInsteadOfSnapshot} />
|
||||
{['action', 'before', 'after'].map(tab => {
|
||||
return <TabbedPaneTab
|
||||
key={tab}
|
||||
id={tab}
|
||||
title={renderTitle(tab)}
|
||||
selected={snapshotTab === tab}
|
||||
onSelect={() => setSnapshotTab(tab as 'action' | 'before' | 'after')}
|
||||
></TabbedPaneTab>;
|
||||
})}
|
||||
<div style={{ flex: 'auto' }}></div>
|
||||
<ToolbarButton icon='link-external' title={showScreenshotInsteadOfSnapshot ? 'Not available when showing screenshot' : 'Open snapshot in a new tab'} disabled={!popoutUrl || showScreenshotInsteadOfSnapshot} onClick={() => {
|
||||
if (!openPage)
|
||||
openPage = window.open;
|
||||
const win = openPage(popoutUrl || '', '_blank');
|
||||
win?.addEventListener('DOMContentLoaded', () => {
|
||||
const injectedScript = new InjectedScript(win as any, false, sdkLanguage, testIdAttributeName, 1, 'chromium', []);
|
||||
new ConsoleAPI(injectedScript);
|
||||
});
|
||||
}}></ToolbarButton>
|
||||
</Toolbar>
|
||||
<div ref={ref} className='snapshot-wrapper'>
|
||||
<div className='snapshot-container' style={{
|
||||
width: snapshotContainerSize.width + 'px',
|
||||
height: snapshotContainerSize.height + 'px',
|
||||
transform: `translate(${translate.x}px, ${translate.y}px) scale(${scale})`,
|
||||
}}>
|
||||
<BrowserFrame url={snapshotInfo.url} />
|
||||
{(showScreenshotInsteadOfSnapshot && screencastFrame) && (
|
||||
<>
|
||||
{point && <ClickPointer point={point} />}
|
||||
<img alt={`Screenshot of ${action?.apiName} > ${renderTitle(snapshotTab)}`} src={`sha1/${screencastFrame.sha1}`} width={screencastFrame.width} height={screencastFrame.height} />
|
||||
</>
|
||||
)}
|
||||
<div className='snapshot-switcher' style={showScreenshotInsteadOfSnapshot ? { display: 'none' } : undefined}>
|
||||
<iframe ref={iframeRef0} name='snapshot' title='DOM Snapshot' className={clsx(loadingRef.current.visibleIframe === 0 && 'snapshot-visible')}></iframe>
|
||||
<iframe ref={iframeRef1} name='snapshot' title='DOM Snapshot' className={clsx(loadingRef.current.visibleIframe === 1 && 'snapshot-visible')}></iframe>
|
||||
</div>
|
||||
<SnapshotWrapper snapshotInfo={snapshotInfo}>
|
||||
<div className='snapshot-switcher'>
|
||||
<iframe ref={iframeRef0} name='snapshot' title='DOM Snapshot' className={clsx(loadingRef.current.visibleIframe === 0 && 'snapshot-visible')}></iframe>
|
||||
<iframe ref={iframeRef1} name='snapshot' title='DOM Snapshot' className={clsx(loadingRef.current.visibleIframe === 1 && 'snapshot-visible')}></iframe>
|
||||
</div>
|
||||
</SnapshotWrapper>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export const ScreenshotView: React.FunctionComponent<{
|
||||
action: ActionTraceEvent | undefined,
|
||||
snapshotUrls: SnapshotUrls | undefined,
|
||||
snapshot: Snapshot | undefined,
|
||||
}> = ({ action, snapshotUrls, snapshot }) => {
|
||||
const [snapshotInfo, setSnapshotInfo] = React.useState<SnapshotInfo>({ viewport: kDefaultViewport, url: '' });
|
||||
React.useEffect(() => {
|
||||
fetchSnapshotInfo(snapshotUrls?.snapshotInfoUrl).then(setSnapshotInfo);
|
||||
}, [snapshotUrls?.snapshotInfoUrl]);
|
||||
|
||||
const page = action ? pageForAction(action) : undefined;
|
||||
const screencastFrame = React.useMemo(() => {
|
||||
if (snapshotInfo.wallTime && page?.screencastFrames[0]?.frameSwapWallTime)
|
||||
return findClosest(page.screencastFrames, frame => frame.frameSwapWallTime!, snapshotInfo.wallTime);
|
||||
|
||||
if (snapshotInfo.timestamp && page?.screencastFrames)
|
||||
return findClosest(page.screencastFrames, frame => frame.timestamp, snapshotInfo.timestamp);
|
||||
},
|
||||
[page?.screencastFrames, snapshotInfo.timestamp, snapshotInfo.wallTime]);
|
||||
|
||||
const point = snapshot?.point;
|
||||
|
||||
return <SnapshotWrapper snapshotInfo={snapshotInfo}>
|
||||
{screencastFrame && (
|
||||
<>
|
||||
{point && <ClickPointer point={point} />}
|
||||
<img alt={`Screenshot of ${action?.apiName}`} src={`sha1/${screencastFrame.sha1}`} width={screencastFrame.width} height={screencastFrame.height} />
|
||||
</>
|
||||
)}
|
||||
</SnapshotWrapper>;
|
||||
};
|
||||
|
||||
const SnapshotWrapper: React.FunctionComponent<React.PropsWithChildren<{
|
||||
snapshotInfo: SnapshotInfo,
|
||||
}>> = ({ snapshotInfo, children }) => {
|
||||
const [measure, ref] = useMeasure<HTMLDivElement>();
|
||||
|
||||
const windowHeaderHeight = 40;
|
||||
const snapshotContainerSize = {
|
||||
width: snapshotInfo.viewport.width,
|
||||
height: snapshotInfo.viewport.height + windowHeaderHeight,
|
||||
};
|
||||
|
||||
const scale = Math.min(measure.width / snapshotContainerSize.width, measure.height / snapshotContainerSize.height, 1);
|
||||
const translate = {
|
||||
x: (measure.width - snapshotContainerSize.width) / 2,
|
||||
y: (measure.height - snapshotContainerSize.height) / 2,
|
||||
};
|
||||
|
||||
return <div ref={ref} className='snapshot-wrapper'>
|
||||
<div className='snapshot-container' style={{
|
||||
width: snapshotContainerSize.width + 'px',
|
||||
height: snapshotContainerSize.height + 'px',
|
||||
transform: `translate(${translate.x}px, ${translate.y}px) scale(${scale})`,
|
||||
}}>
|
||||
<BrowserFrame url={snapshotInfo.url} />
|
||||
{children}
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
|
@ -325,5 +325,90 @@ function createRecorders(recorders: { recorder: Recorder, frameSelector: string
|
|||
}
|
||||
}
|
||||
|
||||
const kDefaultViewport = { width: 1280, height: 720 };
|
||||
export type Snapshot = {
|
||||
action: ActionTraceEvent;
|
||||
snapshotName: string;
|
||||
point?: { x: number, y: number };
|
||||
hasInputTarget?: boolean;
|
||||
};
|
||||
|
||||
export type SnapshotInfo = {
|
||||
url: string;
|
||||
viewport: { width: number, height: number };
|
||||
timestamp?: number;
|
||||
wallTime?: undefined;
|
||||
};
|
||||
|
||||
export type Snapshots = {
|
||||
action?: Snapshot;
|
||||
before?: Snapshot;
|
||||
after?: Snapshot;
|
||||
};
|
||||
|
||||
export type SnapshotUrls = {
|
||||
snapshotInfoUrl: string;
|
||||
snapshotUrl: string;
|
||||
popoutUrl: string;
|
||||
};
|
||||
|
||||
export function collectSnapshots(action: ActionTraceEvent | undefined): Snapshots {
|
||||
if (!action)
|
||||
return {};
|
||||
|
||||
// if the action has no beforeSnapshot, use the last available afterSnapshot.
|
||||
let beforeSnapshot: Snapshot | undefined = action.beforeSnapshot ? { action, snapshotName: action.beforeSnapshot } : undefined;
|
||||
let a = action;
|
||||
while (!beforeSnapshot && a) {
|
||||
a = prevInList(a);
|
||||
beforeSnapshot = a?.afterSnapshot ? { action: a, snapshotName: a?.afterSnapshot } : undefined;
|
||||
}
|
||||
const afterSnapshot: Snapshot | undefined = action.afterSnapshot ? { action, snapshotName: action.afterSnapshot } : beforeSnapshot;
|
||||
const actionSnapshot: Snapshot | undefined = action.inputSnapshot ? { action, snapshotName: action.inputSnapshot, hasInputTarget: true } : afterSnapshot;
|
||||
if (actionSnapshot)
|
||||
actionSnapshot.point = action.point;
|
||||
return { action: actionSnapshot, before: beforeSnapshot, after: afterSnapshot };
|
||||
}
|
||||
|
||||
export function extendSnapshot(snapshot: Snapshot): SnapshotUrls {
|
||||
const params = new URLSearchParams();
|
||||
params.set('trace', context(snapshot.action).traceUrl);
|
||||
params.set('name', snapshot.snapshotName);
|
||||
if (snapshot.point) {
|
||||
params.set('pointX', String(snapshot.point.x));
|
||||
params.set('pointY', String(snapshot.point.y));
|
||||
if (snapshot.hasInputTarget)
|
||||
params.set('hasInputTarget', '1');
|
||||
}
|
||||
const snapshotUrl = new URL(`snapshot/${snapshot.action.pageId}?${params.toString()}`, window.location.href).toString();
|
||||
const snapshotInfoUrl = new URL(`snapshotInfo/${snapshot.action.pageId}?${params.toString()}`, window.location.href).toString();
|
||||
|
||||
const popoutParams = new URLSearchParams();
|
||||
popoutParams.set('r', snapshotUrl);
|
||||
popoutParams.set('trace', context(snapshot.action).traceUrl);
|
||||
if (snapshot.point) {
|
||||
popoutParams.set('pointX', String(snapshot.point.x));
|
||||
popoutParams.set('pointY', String(snapshot.point.y));
|
||||
if (snapshot.hasInputTarget)
|
||||
params.set('hasInputTarget', '1');
|
||||
}
|
||||
const popoutUrl = new URL(`snapshot.html?${popoutParams.toString()}`, window.location.href).toString();
|
||||
return { snapshotInfoUrl, snapshotUrl, popoutUrl };
|
||||
}
|
||||
|
||||
export async function fetchSnapshotInfo(snapshotInfoUrl: string | undefined) {
|
||||
const result = { url: '', viewport: kDefaultViewport, timestamp: undefined, wallTime: undefined };
|
||||
if (snapshotInfoUrl) {
|
||||
const response = await fetch(snapshotInfoUrl);
|
||||
const info = await response.json();
|
||||
if (!info.error) {
|
||||
result.url = info.url;
|
||||
result.viewport = info.viewport;
|
||||
result.timestamp = info.timestamp;
|
||||
result.wallTime = info.wallTime;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const kDefaultViewport = { width: 1280, height: 720 };
|
||||
const kBlankSnapshotUrl = 'data:text/html,<body style="background: #ddd"></body>';
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { ToolbarButton } from '@web/components/toolbarButton';
|
|||
import { Toolbar } from '@web/components/toolbar';
|
||||
|
||||
export const SourceTab: React.FunctionComponent<{
|
||||
stack: StackFrame[] | undefined,
|
||||
stack?: StackFrame[],
|
||||
stackFrameLocation: 'bottom' | 'right',
|
||||
sources: Map<string, SourceModel>,
|
||||
rootDir?: string,
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ const queryParams = {
|
|||
workers: searchParams.get('workers') || undefined,
|
||||
timeout: searchParams.has('timeout') ? +searchParams.get('timeout')! : undefined,
|
||||
headed: searchParams.has('headed'),
|
||||
outputDir: searchParams.get('outputDir') || undefined,
|
||||
updateSnapshots: (searchParams.get('updateSnapshots') as 'all' | 'none' | 'missing' | undefined) || undefined,
|
||||
reporters: searchParams.has('reporter') ? searchParams.getAll('reporter') : undefined,
|
||||
pathSeparator: searchParams.get('pathSeparator') || '/',
|
||||
|
|
@ -105,7 +104,6 @@ export const UIModeView: React.FC<{}> = ({
|
|||
const [singleWorker, setSingleWorker] = React.useState(queryParams.workers === '1');
|
||||
const [showBrowser, setShowBrowser] = React.useState(queryParams.headed);
|
||||
const [updateSnapshots, setUpdateSnapshots] = React.useState(queryParams.updateSnapshots === 'all');
|
||||
const [showRouteActions, setShowRouteActions] = useSetting('show-route-actions', true);
|
||||
const [darkMode, setDarkMode] = useDarkModeSetting();
|
||||
const [showScreenshot, setShowScreenshot] = useSetting('screenshot-instead-of-snapshot', false);
|
||||
|
||||
|
|
@ -188,14 +186,12 @@ export const UIModeView: React.FC<{}> = ({
|
|||
interceptStdio: true,
|
||||
watchTestDirs: true
|
||||
});
|
||||
const { status, report } = await testServerConnection.runGlobalSetup({
|
||||
outputDir: queryParams.outputDir,
|
||||
});
|
||||
const { status, report } = await testServerConnection.runGlobalSetup({});
|
||||
teleSuiteUpdater.processGlobalReport(report);
|
||||
if (status !== 'passed')
|
||||
return;
|
||||
|
||||
const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert, outputDir: queryParams.outputDir });
|
||||
const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert });
|
||||
teleSuiteUpdater.processListReport(result.report);
|
||||
|
||||
testServerConnection.onReport(params => {
|
||||
|
|
@ -297,7 +293,6 @@ export const UIModeView: React.FC<{}> = ({
|
|||
workers: singleWorker ? '1' : (queryParams.workers === '1' ? undefined : queryParams.workers),
|
||||
timeout: queryParams.timeout,
|
||||
headed: showBrowser,
|
||||
outputDir: queryParams.outputDir,
|
||||
updateSnapshots: updateSnapshots ? 'all' : queryParams.updateSnapshots,
|
||||
reporters: queryParams.reporters,
|
||||
trace: 'on',
|
||||
|
|
@ -320,7 +315,7 @@ export const UIModeView: React.FC<{}> = ({
|
|||
commandQueue.current = commandQueue.current.then(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert, outputDir: queryParams.outputDir });
|
||||
const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert });
|
||||
teleSuiteUpdater.processListReport(result.report);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
|
|
@ -526,7 +521,6 @@ export const UIModeView: React.FC<{}> = ({
|
|||
</Toolbar>
|
||||
{settingsVisible && <SettingsView settings={[
|
||||
{ value: darkMode, set: setDarkMode, title: 'Dark mode' },
|
||||
{ value: showRouteActions, set: setShowRouteActions, title: 'Show route actions' },
|
||||
{ value: showScreenshot, set: setShowScreenshot, title: 'Show screenshot instead of snapshot' },
|
||||
]} />}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24,9 +24,8 @@ import type { ErrorDescription } from './errorsTab';
|
|||
import type { ConsoleEntry } from './consoleTab';
|
||||
import { ConsoleTab, useConsoleTabModel } from './consoleTab';
|
||||
import type * as modelUtil from './modelUtil';
|
||||
import { isRouteAction } from './modelUtil';
|
||||
import { NetworkTab, useNetworkTabModel } from './networkTab';
|
||||
import { SnapshotTab } from './snapshotTab';
|
||||
import { SnapshotTabsView } from './snapshotTab';
|
||||
import { SourceTab } from './sourceTab';
|
||||
import { TabbedPane } from '@web/components/tabbedPane';
|
||||
import type { TabbedPaneTabModel } from '@web/components/tabbedPane';
|
||||
|
|
@ -50,6 +49,7 @@ export const Workbench: React.FunctionComponent<{
|
|||
rootDir?: string,
|
||||
fallbackLocation?: modelUtil.SourceLocation,
|
||||
isLive?: boolean,
|
||||
hideTimeline?: boolean,
|
||||
status?: UITestStatus,
|
||||
annotations?: { type: string; description?: string; }[];
|
||||
inert?: boolean,
|
||||
|
|
@ -57,11 +57,10 @@ export const Workbench: React.FunctionComponent<{
|
|||
onOpenExternally?: (location: modelUtil.SourceLocation) => void,
|
||||
revealSource?: boolean,
|
||||
showSettings?: boolean,
|
||||
}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => {
|
||||
}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => {
|
||||
const [selectedCallId, setSelectedCallId] = React.useState<string | undefined>(undefined);
|
||||
const [revealedError, setRevealedError] = React.useState<ErrorDescription | undefined>(undefined);
|
||||
|
||||
const [highlightedAction, setHighlightedAction] = React.useState<modelUtil.ActionTraceEventInContext | undefined>();
|
||||
const [highlightedCallId, setHighlightedCallId] = React.useState<string | undefined>();
|
||||
const [highlightedEntry, setHighlightedEntry] = React.useState<Entry | undefined>();
|
||||
const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState<ConsoleEntry | undefined>();
|
||||
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions');
|
||||
|
|
@ -70,18 +69,21 @@ export const Workbench: React.FunctionComponent<{
|
|||
const [highlightedLocator, setHighlightedLocator] = React.useState<string>('');
|
||||
const [selectedTime, setSelectedTime] = React.useState<Boundaries | undefined>();
|
||||
const [sidebarLocation, setSidebarLocation] = useSetting<'bottom' | 'right'>('propertiesSidebarLocation', 'bottom');
|
||||
const [showRouteActions, setShowRouteActions] = useSetting('show-route-actions', true);
|
||||
const [showScreenshot, setShowScreenshot] = useSetting('screenshot-instead-of-snapshot', false);
|
||||
|
||||
const filteredActions = React.useMemo(() => {
|
||||
return (model?.actions || []).filter(action => showRouteActions || !isRouteAction(action));
|
||||
}, [model, showRouteActions]);
|
||||
|
||||
const setSelectedAction = React.useCallback((action: modelUtil.ActionTraceEventInContext | undefined) => {
|
||||
setSelectedCallId(action?.callId);
|
||||
setRevealedError(undefined);
|
||||
}, []);
|
||||
|
||||
const highlightedAction = React.useMemo(() => {
|
||||
return model?.actions.find(a => a.callId === highlightedCallId);
|
||||
}, [model, highlightedCallId]);
|
||||
|
||||
const setHighlightedAction = React.useCallback((highlightedAction: modelUtil.ActionTraceEventInContext | undefined) => {
|
||||
setHighlightedCallId(highlightedAction?.callId);
|
||||
}, []);
|
||||
|
||||
const sources = React.useMemo(() => model?.sources || new Map<string, modelUtil.SourceModel>(), [model]);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
|
@ -291,7 +293,7 @@ export const Workbench: React.FunctionComponent<{
|
|||
</div>}
|
||||
<ActionList
|
||||
sdkLanguage={sdkLanguage}
|
||||
actions={filteredActions}
|
||||
actions={model?.actions || []}
|
||||
selectedAction={model ? selectedAction : undefined}
|
||||
selectedTime={selectedTime}
|
||||
setSelectedTime={setSelectedTime}
|
||||
|
|
@ -311,13 +313,12 @@ export const Workbench: React.FunctionComponent<{
|
|||
id: 'settings',
|
||||
title: 'Settings',
|
||||
component: <SettingsView settings={[
|
||||
{ value: showRouteActions, set: setShowRouteActions, title: 'Show route actions' },
|
||||
{ value: showScreenshot, set: setShowScreenshot, title: 'Show screenshot instead of snapshot' }
|
||||
]}/>,
|
||||
};
|
||||
|
||||
return <div className='vbox workbench' {...(inert ? { inert: 'true' } : {})}>
|
||||
<Timeline
|
||||
{!hideTimeline && <Timeline
|
||||
model={model}
|
||||
consoleEntries={consoleModel.entries}
|
||||
boundaries={boundaries}
|
||||
|
|
@ -328,7 +329,7 @@ export const Workbench: React.FunctionComponent<{
|
|||
sdkLanguage={sdkLanguage}
|
||||
selectedTime={selectedTime}
|
||||
setSelectedTime={setSelectedTime}
|
||||
/>
|
||||
/>}
|
||||
<SplitView
|
||||
sidebarSize={250}
|
||||
orientation={sidebarLocation === 'bottom' ? 'vertical' : 'horizontal'} settingName='propertiesSidebar'
|
||||
|
|
@ -337,7 +338,7 @@ export const Workbench: React.FunctionComponent<{
|
|||
orientation='horizontal'
|
||||
sidebarIsFirst
|
||||
settingName='actionListSidebar'
|
||||
main={<SnapshotTab
|
||||
main={<SnapshotTabsView
|
||||
action={activeAction}
|
||||
model={model}
|
||||
sdkLanguage={sdkLanguage}
|
||||
|
|
|
|||
20
packages/web/src/components/sourceChooser.css
Normal file
20
packages/web/src/components/sourceChooser.css
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
Copyright (c) Microsoft Corporation.
|
||||
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.
|
||||
*/
|
||||
|
||||
.source-chooser {
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
color: var(--vscode-sideBarTitle-foreground);
|
||||
min-width: 100px;
|
||||
}
|
||||
58
packages/web/src/components/sourceChooser.tsx
Normal file
58
packages/web/src/components/sourceChooser.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* 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 React from 'react';
|
||||
import type { Source } from '@recorder/recorderTypes';
|
||||
|
||||
export const SourceChooser: React.FC<{
|
||||
sources: Source[],
|
||||
fileId: string | undefined,
|
||||
setFileId: (fileId: string) => void,
|
||||
}> = ({ sources, fileId, setFileId }) => {
|
||||
return <select className='source-chooser' hidden={!sources.length} value={fileId} onChange={event => {
|
||||
setFileId(event.target.selectedOptions[0].value);
|
||||
}}>{renderSourceOptions(sources)}</select>;
|
||||
};
|
||||
|
||||
function renderSourceOptions(sources: Source[]): React.ReactNode {
|
||||
const transformTitle = (title: string): string => title.replace(/.*[/\\]([^/\\]+)/, '$1');
|
||||
const renderOption = (source: Source): React.ReactNode => (
|
||||
<option key={source.id} value={source.id}>{transformTitle(source.label)}</option>
|
||||
);
|
||||
|
||||
const hasGroup = sources.some(s => s.group);
|
||||
if (hasGroup) {
|
||||
const groups = new Set(sources.map(s => s.group));
|
||||
return [...groups].filter(Boolean).map(group => (
|
||||
<optgroup label={group} key={group}>
|
||||
{sources.filter(s => s.group === group).map(source => renderOption(source))}
|
||||
</optgroup>
|
||||
));
|
||||
}
|
||||
|
||||
return sources.map(source => renderOption(source));
|
||||
}
|
||||
|
||||
export function emptySource(): Source {
|
||||
return {
|
||||
id: 'default',
|
||||
isRecorded: false,
|
||||
text: '',
|
||||
language: 'javascript',
|
||||
label: '',
|
||||
highlight: []
|
||||
};
|
||||
}
|
||||
|
|
@ -32,11 +32,13 @@ export const TabbedPane: React.FunctionComponent<{
|
|||
tabs: TabbedPaneTabModel[],
|
||||
leftToolbar?: React.ReactElement[],
|
||||
rightToolbar?: React.ReactElement[],
|
||||
selectedTab: string,
|
||||
setSelectedTab: (tab: string) => void,
|
||||
selectedTab?: string,
|
||||
setSelectedTab?: (tab: string) => void,
|
||||
dataTestId?: string,
|
||||
mode?: 'default' | 'select',
|
||||
}> = ({ tabs, selectedTab, setSelectedTab, leftToolbar, rightToolbar, dataTestId, mode }) => {
|
||||
if (!selectedTab)
|
||||
selectedTab = tabs[0].id;
|
||||
if (!mode)
|
||||
mode = 'default';
|
||||
return <div className='tabbed-pane' data-testid={dataTestId}>
|
||||
|
|
@ -60,7 +62,7 @@ export const TabbedPane: React.FunctionComponent<{
|
|||
</div>}
|
||||
{mode === 'select' && <div style={{ flex: 'auto', display: 'flex', height: '100%', overflow: 'hidden' }}>
|
||||
<select style={{ width: '100%', background: 'none', cursor: 'pointer' }} onChange={e => {
|
||||
setSelectedTab(tabs[e.currentTarget.selectedIndex].id);
|
||||
setSelectedTab?.(tabs[e.currentTarget.selectedIndex].id);
|
||||
}}>
|
||||
{tabs.map(tab => {
|
||||
let suffix = '';
|
||||
|
|
@ -95,10 +97,10 @@ export const TabbedPaneTab: React.FunctionComponent<{
|
|||
count?: number,
|
||||
errorCount?: number,
|
||||
selected?: boolean,
|
||||
onSelect: (id: string) => void
|
||||
onSelect?: (id: string) => void
|
||||
}> = ({ id, title, count, errorCount, selected, onSelect }) => {
|
||||
return <div className={clsx('tabbed-pane-tab', selected && 'selected')}
|
||||
onClick={() => onSelect(id)}
|
||||
onClick={() => onSelect?.(id)}
|
||||
title={title}
|
||||
key={id}>
|
||||
<div className='tabbed-pane-tab-label'>{title}</div>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@
|
|||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.toolbar.toolbar-sidebar-background {
|
||||
background-color: var(--vscode-sideBar-background);
|
||||
}
|
||||
|
||||
.toolbar:after {
|
||||
content: '';
|
||||
display: block;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import * as React from 'react';
|
|||
type ToolbarProps = {
|
||||
noShadow?: boolean;
|
||||
noMinHeight?: boolean;
|
||||
sidebarBackground?: boolean;
|
||||
className?: string;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
};
|
||||
|
|
@ -30,7 +31,8 @@ export const Toolbar: React.FC<React.PropsWithChildren<ToolbarProps>> = ({
|
|||
children,
|
||||
noMinHeight,
|
||||
className,
|
||||
sidebarBackground,
|
||||
onClick,
|
||||
}) => {
|
||||
return <div className={clsx('toolbar', noShadow && 'no-shadow', noMinHeight && 'no-min-height', className)} onClick={onClick}>{children}</div>;
|
||||
return <div className={clsx('toolbar', noShadow && 'no-shadow', noMinHeight && 'no-min-height', className, sidebarBackground && 'toolbar-sidebar-background')} onClick={onClick}>{children}</div>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -203,5 +203,10 @@ export function clsx(...classes: (string | undefined | false)[]) {
|
|||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export async function sha1(str: string): Promise<string> {
|
||||
const buffer = new TextEncoder().encode(str);
|
||||
return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-1', buffer))).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
const kControlCodesRe = '\\u0000-\\u0020\\u007f-\\u009f';
|
||||
export const kWebLinkRe = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|www\\.)[^\\s' + kControlCodesRe + '"]{2,}[^\\s' + kControlCodesRe + '"\')}\\],:;.!?]', 'ug');
|
||||
|
|
|
|||
5
tests/assets/extension-with-logging/background.js
Normal file
5
tests/assets/extension-with-logging/background.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
console.log("Service worker script loaded");
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
console.log("Extension installed");
|
||||
});
|
||||
1
tests/assets/extension-with-logging/content.js
Normal file
1
tests/assets/extension-with-logging/content.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
console.log("Test console log from a third-party execution context");
|
||||
17
tests/assets/extension-with-logging/manifest.json
Normal file
17
tests/assets/extension-with-logging/manifest.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Console Log Extension",
|
||||
"version": "1.0",
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"permissions": [
|
||||
"tabs"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content.js"]
|
||||
}
|
||||
]
|
||||
}
|
||||
1949
tests/bidi/expectations/bidi-firefox-nightly-library.txt
Normal file
1949
tests/bidi/expectations/bidi-firefox-nightly-library.txt
Normal file
File diff suppressed because it is too large
Load diff
1968
tests/bidi/expectations/bidi-firefox-nightly-page.txt
Normal file
1968
tests/bidi/expectations/bidi-firefox-nightly-page.txt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -50,7 +50,7 @@ const config: Config<PlaywrightWorkerOptions & PlaywrightTestOptions & TestModeW
|
|||
},
|
||||
maxFailures: 0,
|
||||
timeout: 15 * 1000,
|
||||
globalTimeout: 30 * 60 * 1000,
|
||||
globalTimeout: 60 * 60 * 1000,
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
fullyParallel: !process.env.CI,
|
||||
forbidOnly: !!process.env.CI,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import * as playwrightLibrary from 'playwright-core';
|
|||
|
||||
export type TestModeWorkerOptions = {
|
||||
mode: TestModeName;
|
||||
codegenMode: 'trace-events' | 'actions';
|
||||
};
|
||||
|
||||
export type TestModeTestFixtures = {
|
||||
|
|
@ -48,6 +49,7 @@ export const testModeTest = test.extend<TestModeTestFixtures, TestModeWorkerOpti
|
|||
await run(playwright);
|
||||
await testMode.teardown();
|
||||
}, { scope: 'worker' }],
|
||||
codegenMode: ['actions', { scope: 'worker', option: true }],
|
||||
|
||||
toImplInWorkerScope: [async ({ playwright }, use) => {
|
||||
await use((playwright as any)._toImpl);
|
||||
|
|
|
|||
|
|
@ -71,10 +71,12 @@ class TraceViewerPage {
|
|||
return await this.page.waitForSelector(`.list-view-entry:has-text("${action}") .action-icons`);
|
||||
}
|
||||
|
||||
@step
|
||||
async selectAction(title: string, ordinal: number = 0) {
|
||||
await this.page.locator(`.action-title:has-text("${title}")`).nth(ordinal).click();
|
||||
}
|
||||
|
||||
@step
|
||||
async selectSnapshot(name: string) {
|
||||
await this.page.click(`.snapshot-tab .tabbed-pane-tab-label:has-text("${name}")`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ export async function parseTraceRaw(file: string): Promise<{ events: any[], reso
|
|||
export async function parseTrace(file: string): Promise<{ resources: Map<string, Buffer>, events: (EventTraceEvent | ConsoleMessageTraceEvent)[], actions: ActionTraceEvent[], apiNames: string[], traceModel: TraceModel, model: MultiTraceModel, actionTree: string[], errors: string[] }> {
|
||||
const backend = new TraceBackend(file);
|
||||
const traceModel = new TraceModel();
|
||||
await traceModel.load(backend, () => {});
|
||||
await traceModel.load(backend, false, () => {});
|
||||
const model = new MultiTraceModel(traceModel.contextEntries);
|
||||
const { rootItem } = buildActionTree(model.actions);
|
||||
const actionTree: string[] = [];
|
||||
|
|
|
|||
|
|
@ -146,6 +146,27 @@ it('should support request/response events when using backgroundPage()', async (
|
|||
await context.close();
|
||||
});
|
||||
|
||||
it('should report console messages from content script', {
|
||||
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/32762' }
|
||||
}, async ({ browserType, createUserDataDir, asset, server }) => {
|
||||
const userDataDir = await createUserDataDir();
|
||||
const extensionPath = asset('extension-with-logging');
|
||||
const extensionOptions = {
|
||||
headless: false,
|
||||
args: [
|
||||
`--disable-extensions-except=${extensionPath}`,
|
||||
`--load-extension=${extensionPath}`,
|
||||
],
|
||||
};
|
||||
const context = await browserType.launchPersistentContext(userDataDir, extensionOptions);
|
||||
const page = await context.newPage();
|
||||
const consolePromise = page.waitForEvent('console', e => e.text().includes('Test console log from a third-party execution context'));
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
const message = await consolePromise;
|
||||
expect(message.text()).toContain('Test console log from a third-party execution context');
|
||||
await context.close();
|
||||
});
|
||||
|
||||
it('should not create pages automatically', async ({ browserType }) => {
|
||||
const browser = await browserType.launch();
|
||||
const browserSession = await browser.newBrowserCDPSession();
|
||||
|
|
|
|||
|
|
@ -269,7 +269,7 @@ it('ElementHandle.boundingBox() should work', async function({ page, browser, se
|
|||
|
||||
await assertOOPIFCount(browser, 1);
|
||||
const handle1 = await page.frames()[1].$('.box:nth-of-type(13)');
|
||||
expect(await handle1!.boundingBox()).toEqual({ x: 100 + 42, y: 50 + 17, width: 50, height: 50 });
|
||||
await expect.poll(() => handle1!.boundingBox()).toEqual({ x: 100 + 42, y: 50 + 17, width: 50, height: 50 });
|
||||
|
||||
await Promise.all([
|
||||
page.frames()[1].waitForNavigation(),
|
||||
|
|
@ -277,7 +277,7 @@ it('ElementHandle.boundingBox() should work', async function({ page, browser, se
|
|||
]);
|
||||
await assertOOPIFCount(browser, 0);
|
||||
const handle2 = await page.frames()[1].$('.box:nth-of-type(13)');
|
||||
expect(await handle2!.boundingBox()).toEqual({ x: 100 + 42, y: 50 + 17, width: 50, height: 50 });
|
||||
await expect.poll(() => handle2!.boundingBox()).toEqual({ x: 100 + 42, y: 50 + 17, width: 50, height: 50 });
|
||||
});
|
||||
|
||||
it('should click', async function({ page, browser, server }) {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type { ConsoleMessage } from 'playwright';
|
|||
|
||||
test.describe('cli codegen', () => {
|
||||
test.skip(({ mode }) => mode !== 'default');
|
||||
test.skip(({ trace, codegenMode }) => trace === 'on' && codegenMode === 'trace-events');
|
||||
|
||||
test('should click', async ({ openRecorder }) => {
|
||||
const { page, recorder } = await openRecorder();
|
||||
|
|
@ -412,7 +413,7 @@ await page.GetByRole(AriaRole.Textbox).PressAsync("Shift+Enter");`);
|
|||
expect(messages[0].text()).toBe('press');
|
||||
});
|
||||
|
||||
test('should update selected element after pressing Tab', async ({ openRecorder }) => {
|
||||
test('should update selected element after pressing Tab', async ({ openRecorder, browserName, codegenMode }) => {
|
||||
const { page, recorder } = await openRecorder();
|
||||
|
||||
await recorder.setContentAndWait(`
|
||||
|
|
@ -428,6 +429,9 @@ await page.GetByRole(AriaRole.Textbox).PressAsync("Shift+Enter");`);
|
|||
await page.keyboard.press('Tab');
|
||||
await recorder.waitForOutput('JavaScript', 'Tab');
|
||||
await page.keyboard.type('barfoo321');
|
||||
// I can't explain it atm, first character is being consumed for no apparent reason.
|
||||
if (browserName === 'webkit' && codegenMode === 'trace-events')
|
||||
await page.waitForTimeout(1000);
|
||||
await recorder.waitForOutput('JavaScript', 'barfoo321');
|
||||
|
||||
const text = recorder.sources().get('JavaScript')!.text;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import fs from 'fs';
|
|||
|
||||
test.describe('cli codegen', () => {
|
||||
test.skip(({ mode }) => mode !== 'default');
|
||||
test.skip(({ trace, codegenMode }) => trace === 'on' && codegenMode === 'trace-events');
|
||||
|
||||
test('should contain open page', async ({ openRecorder }) => {
|
||||
const { recorder } = await openRecorder();
|
||||
|
|
@ -337,7 +338,8 @@ await page.GetByRole(AriaRole.Button, new() { Name = "click me" }).ClickAsync();
|
|||
}
|
||||
});
|
||||
|
||||
test('should record open in a new tab with url', async ({ openRecorder, browserName }) => {
|
||||
test('should record open in a new tab with url', async ({ openRecorder, browserName, codegenMode }) => {
|
||||
test.skip(codegenMode === 'trace-events');
|
||||
const { page, recorder } = await openRecorder();
|
||||
await recorder.setContentAndWait(`<a href="about:blank?foo">link</a>`);
|
||||
|
||||
|
|
@ -425,7 +427,7 @@ await page1.GotoAsync("about:blank?foo");`);
|
|||
page.click('button'),
|
||||
recorder.waitForOutput('JavaScript', '.click(')
|
||||
]);
|
||||
expect(messages).toEqual(['mousedown', 'mouseup', 'click']);
|
||||
await expect.poll(() => messages).toEqual(['mousedown', 'mouseup', 'click']);
|
||||
});
|
||||
|
||||
test('should update hover model on action', async ({ openRecorder }) => {
|
||||
|
|
@ -490,7 +492,8 @@ await page1.GotoAsync("about:blank?foo");`);
|
|||
await recorder.waitForOutput('JavaScript', `await page.goto('${server.PREFIX}/page2.html');`);
|
||||
});
|
||||
|
||||
test('should --save-trace', async ({ runCLI }, testInfo) => {
|
||||
test('should --save-trace', async ({ runCLI, codegenMode }, testInfo) => {
|
||||
test.skip(codegenMode === 'trace-events');
|
||||
const traceFileName = testInfo.outputPath('trace.zip');
|
||||
const cli = runCLI([`--save-trace=${traceFileName}`], {
|
||||
autoExitWhen: ' ',
|
||||
|
|
@ -499,7 +502,8 @@ await page1.GotoAsync("about:blank?foo");`);
|
|||
expect(fs.existsSync(traceFileName)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should save assets via SIGINT', async ({ runCLI, platform }, testInfo) => {
|
||||
test('should save assets via SIGINT', async ({ runCLI, platform, codegenMode }, testInfo) => {
|
||||
test.skip(codegenMode === 'trace-events');
|
||||
test.skip(platform === 'win32', 'SIGINT not supported on Windows');
|
||||
|
||||
const traceFileName = testInfo.outputPath('trace.zip');
|
||||
|
|
@ -546,18 +550,17 @@ await page.Locator("#textarea").FillAsync(\"Hello'\\"\`\\nWorld\");`);
|
|||
expect(message.text()).toBe('Hello\'\"\`\nWorld');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
test('should --test-id-attribute', async ({ openRecorder }) => {
|
||||
const { page, recorder } = await openRecorder({ testIdAttributeName: 'my-test-id' });
|
||||
|
||||
await recorder.setContentAndWait(`<div my-test-id="foo">Hello</div>`);
|
||||
await page.click('[my-test-id=foo]');
|
||||
const sources = await recorder.waitForOutput('JavaScript', `page.getByTestId`);
|
||||
|
||||
expect.soft(sources.get('JavaScript')!.text).toContain(`await page.getByTestId('foo').click()`);
|
||||
expect.soft(sources.get('Java')!.text).toContain(`page.getByTestId("foo").click()`);
|
||||
expect.soft(sources.get('Python')!.text).toContain(`page.get_by_test_id("foo").click()`);
|
||||
expect.soft(sources.get('Python Async')!.text).toContain(`await page.get_by_test_id("foo").click()`);
|
||||
expect.soft(sources.get('C#')!.text).toContain(`await page.GetByTestId("foo").ClickAsync();`);
|
||||
test('should --test-id-attribute', async ({ openRecorder }) => {
|
||||
const { page, recorder } = await openRecorder({ testIdAttributeName: 'my-test-id' });
|
||||
|
||||
await recorder.setContentAndWait(`<div my-test-id="foo">Hello</div>`);
|
||||
await page.click('[my-test-id=foo]');
|
||||
const sources = await recorder.waitForOutput('JavaScript', `page.getByTestId`);
|
||||
|
||||
expect.soft(sources.get('JavaScript')!.text).toContain(`await page.getByTestId('foo').click()`);
|
||||
expect.soft(sources.get('Java')!.text).toContain(`page.getByTestId("foo").click()`);
|
||||
expect.soft(sources.get('Python')!.text).toContain(`page.get_by_test_id("foo").click()`);
|
||||
expect.soft(sources.get('Python Async')!.text).toContain(`await page.get_by_test_id("foo").click()`);
|
||||
expect.soft(sources.get('C#')!.text).toContain(`await page.GetByTestId("foo").ClickAsync();`);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { test, expect } from './inspectorTest';
|
|||
|
||||
test.describe('cli codegen', () => {
|
||||
test.skip(({ mode }) => mode !== 'default');
|
||||
test.skip(({ trace, codegenMode }) => trace === 'on' && codegenMode === 'trace-events');
|
||||
|
||||
test('should click locator.first', async ({ openRecorder }) => {
|
||||
const { page, recorder } = await openRecorder();
|
||||
|
|
|
|||
|
|
@ -67,17 +67,30 @@ export const test = contextTest.extend<CLITestArgs>({
|
|||
});
|
||||
},
|
||||
|
||||
runCLI: async ({ childProcess, browserName, channel, headless, mode, launchOptions }, run, testInfo) => {
|
||||
runCLI: async ({ childProcess, browserName, channel, headless, mode, launchOptions, codegenMode }, run, testInfo) => {
|
||||
testInfo.skip(mode.startsWith('service'));
|
||||
|
||||
await run((cliArgs, { autoExitWhen } = {}) => {
|
||||
return new CLIMock(childProcess, browserName, channel, headless, cliArgs, launchOptions.executablePath, autoExitWhen);
|
||||
return new CLIMock(childProcess, {
|
||||
browserName,
|
||||
channel,
|
||||
headless,
|
||||
args: cliArgs,
|
||||
executablePath: launchOptions.executablePath,
|
||||
autoExitWhen,
|
||||
codegenMode
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
openRecorder: async ({ context, recorderPageGetter }, run) => {
|
||||
openRecorder: async ({ context, recorderPageGetter, codegenMode }, run) => {
|
||||
await run(async (options?: { testIdAttributeName?: string }) => {
|
||||
await (context as any)._enableRecorder({ language: 'javascript', mode: 'recording', ...options });
|
||||
await (context as any)._enableRecorder({
|
||||
language: 'javascript',
|
||||
mode: 'recording',
|
||||
codegenMode,
|
||||
...options
|
||||
});
|
||||
const page = await context.newPage();
|
||||
return { page, recorder: new Recorder(page, await recorderPageGetter()) };
|
||||
});
|
||||
|
|
@ -205,23 +218,24 @@ class Recorder {
|
|||
class CLIMock {
|
||||
process: TestChildProcess;
|
||||
|
||||
constructor(childProcess: CommonFixtures['childProcess'], browserName: string, channel: string | undefined, headless: boolean | undefined, args: string[], executablePath: string | undefined, autoExitWhen: string | undefined) {
|
||||
constructor(childProcess: CommonFixtures['childProcess'], options: { browserName: string, channel: string | undefined, headless: boolean | undefined, args: string[], executablePath: string | undefined, autoExitWhen: string | undefined, codegenMode?: 'trace-events' | 'actions'}) {
|
||||
const nodeArgs = [
|
||||
'node',
|
||||
path.join(__dirname, '..', '..', '..', 'packages', 'playwright-core', 'cli.js'),
|
||||
'codegen',
|
||||
...args,
|
||||
`--browser=${browserName}`,
|
||||
...options.args,
|
||||
`--browser=${options.browserName}`,
|
||||
];
|
||||
if (channel)
|
||||
nodeArgs.push(`--channel=${channel}`);
|
||||
if (options.channel)
|
||||
nodeArgs.push(`--channel=${options.channel}`);
|
||||
this.process = childProcess({
|
||||
command: nodeArgs,
|
||||
env: {
|
||||
PWTEST_CLI_AUTO_EXIT_WHEN: autoExitWhen,
|
||||
PW_RECORDER_IS_TRACE_VIEWER: options.codegenMode === 'trace-events' ? '1' : undefined,
|
||||
PWTEST_CLI_AUTO_EXIT_WHEN: options.autoExitWhen,
|
||||
PWTEST_CLI_IS_UNDER_TEST: '1',
|
||||
PWTEST_CLI_HEADLESS: headless ? '1' : undefined,
|
||||
PWTEST_CLI_EXECUTABLE_PATH: executablePath,
|
||||
PWTEST_CLI_HEADLESS: options.headless ? '1' : undefined,
|
||||
PWTEST_CLI_EXECUTABLE_PATH: options.executablePath,
|
||||
DEBUG: (process.env.DEBUG ?? '') + ',pw:browser*',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,9 +30,9 @@ async function checkFeatures(name: string, context: any, server: any) {
|
|||
}
|
||||
}
|
||||
|
||||
it('Safari Desktop', async ({ browser, browserName, platform, server, headless, isMac }) => {
|
||||
it('Safari Desktop', async ({ browser, browserName, platform, server, headless }) => {
|
||||
it.skip(browserName !== 'webkit');
|
||||
it.skip(browserName === 'webkit' && platform === 'darwin' && parseInt(os.release(), 10) === 22, 'Modernizr uses WebGL which is not available in macOS-13 - https://bugs.webkit.org/show_bug.cgi?id=278277');
|
||||
it.skip(browserName === 'webkit' && platform === 'darwin' && os.arch() === 'x64', 'Modernizr uses WebGL which is not available on Intel macOS - https://bugs.webkit.org/show_bug.cgi?id=278277');
|
||||
const context = await browser.newContext({
|
||||
deviceScaleFactor: 2
|
||||
});
|
||||
|
|
@ -93,9 +93,9 @@ it('Safari Desktop', async ({ browser, browserName, platform, server, headless,
|
|||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it('Mobile Safari', async ({ playwright, browser, browserName, platform, isMac, server, headless }) => {
|
||||
it('Mobile Safari', async ({ playwright, browser, browserName, platform, server, headless }) => {
|
||||
it.skip(browserName !== 'webkit');
|
||||
it.skip(browserName === 'webkit' && platform === 'darwin' && parseInt(os.release(), 10) === 22, 'Modernizr uses WebGL which is not available in macOS-13 - https://bugs.webkit.org/show_bug.cgi?id=278277');
|
||||
it.skip(browserName === 'webkit' && platform === 'darwin' && os.arch() === 'x64', 'Modernizr uses WebGL which is not available on Intel macOS - https://bugs.webkit.org/show_bug.cgi?id=278277');
|
||||
const iPhone = playwright.devices['iPhone 12'];
|
||||
const context = await browser.newContext(iPhone);
|
||||
const { actual, expected } = await checkFeatures('mobile-safari-18', context, server);
|
||||
|
|
|
|||
|
|
@ -107,43 +107,63 @@ for (const browserName of browserNames) {
|
|||
console.error(`Using executable at ${executablePath}`);
|
||||
const devtools = process.env.DEVTOOLS === '1';
|
||||
const testIgnore: RegExp[] = browserNames.filter(b => b !== browserName).map(b => new RegExp(b));
|
||||
for (const folder of ['library', 'page']) {
|
||||
config.projects.push({
|
||||
name: `${browserName}-${folder}`,
|
||||
testDir: path.join(testDir, folder),
|
||||
testIgnore,
|
||||
snapshotPathTemplate: `{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-${browserName}{ext}`,
|
||||
use: {
|
||||
mode,
|
||||
browserName,
|
||||
headless: !headed,
|
||||
channel,
|
||||
video: video ? 'on' : undefined,
|
||||
launchOptions: {
|
||||
executablePath,
|
||||
devtools
|
||||
},
|
||||
trace: trace ? 'on' : undefined,
|
||||
|
||||
const projectTemplate: typeof config.projects[0] = {
|
||||
testIgnore,
|
||||
snapshotPathTemplate: `{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-${browserName}{ext}`,
|
||||
use: {
|
||||
mode,
|
||||
browserName,
|
||||
headless: !headed,
|
||||
channel,
|
||||
video: video ? 'on' : undefined,
|
||||
launchOptions: {
|
||||
executablePath,
|
||||
devtools
|
||||
},
|
||||
metadata: {
|
||||
platform: process.platform,
|
||||
docker: !!process.env.INSIDE_DOCKER,
|
||||
headless: (() => {
|
||||
if (process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW)
|
||||
return 'headless-new';
|
||||
if (headed)
|
||||
return 'headed';
|
||||
return 'headless';
|
||||
})(),
|
||||
browserName,
|
||||
channel,
|
||||
mode,
|
||||
video: !!video,
|
||||
trace: !!trace,
|
||||
clock: process.env.PW_CLOCK ? 'clock-' + process.env.PW_CLOCK : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
trace: trace ? 'on' : undefined,
|
||||
},
|
||||
metadata: {
|
||||
platform: process.platform,
|
||||
docker: !!process.env.INSIDE_DOCKER,
|
||||
headless: (() => {
|
||||
if (process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW)
|
||||
return 'headless-new';
|
||||
if (headed)
|
||||
return 'headed';
|
||||
return 'headless';
|
||||
})(),
|
||||
browserName,
|
||||
channel,
|
||||
mode,
|
||||
video: !!video,
|
||||
trace: !!trace,
|
||||
clock: process.env.PW_CLOCK ? 'clock-' + process.env.PW_CLOCK : undefined,
|
||||
}
|
||||
};
|
||||
|
||||
config.projects.push({
|
||||
name: `${browserName}-library`,
|
||||
testDir: path.join(testDir, 'library'),
|
||||
...projectTemplate,
|
||||
});
|
||||
|
||||
config.projects.push({
|
||||
name: `${browserName}-page`,
|
||||
testDir: path.join(testDir, 'page'),
|
||||
...projectTemplate,
|
||||
});
|
||||
|
||||
config.projects.push({
|
||||
name: `${browserName}-codegen-mode-trace`,
|
||||
testDir: path.join(testDir, 'library'),
|
||||
testMatch: '**/cli-codegen-*.spec.ts',
|
||||
...projectTemplate,
|
||||
use: {
|
||||
...projectTemplate.use,
|
||||
codegenMode: 'trace-events',
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default config;
|
||||
|
|
|
|||
|
|
@ -144,9 +144,10 @@ for (const mock of ['no-mock', 'no-match', 'pass-through']) {
|
|||
]);
|
||||
});
|
||||
|
||||
test('should work with error after successful open', async ({ page, server, browserName, isLinux }) => {
|
||||
test('should work with error after successful open', async ({ page, server, browserName, isLinux, isWindows }) => {
|
||||
test.skip(browserName === 'firefox', 'Firefox does not close the websocket upon a bad frame');
|
||||
test.skip(browserName === 'webkit' && isLinux, 'WebKit linux does not close the websocket upon a bad frame');
|
||||
test.skip(browserName === 'webkit' && isWindows, 'WebKit Windows does not close the websocket upon a bad frame');
|
||||
|
||||
const upgradePromise = server.waitForUpgrade();
|
||||
await setupWS(page, server.PORT, 'blob');
|
||||
|
|
|
|||
|
|
@ -130,7 +130,9 @@ test('should complain about newer version of trace in old viewer', async ({ show
|
|||
test('should properly synchronize local and remote time', async ({ showTraceViewer, asset }, testInfo) => {
|
||||
const traceViewer = await showTraceViewer([asset('trace-remote-time-diff.zip')]);
|
||||
// The total duration should be sub 10s, rather than 16h.
|
||||
await expect(traceViewer.page.locator('.timeline-time').last()).toHaveText('8.5s');
|
||||
await expect.poll(async () =>
|
||||
parseInt(await traceViewer.page.locator('.timeline-time').last().innerText(), 10)
|
||||
).toBeLessThan(10);
|
||||
});
|
||||
|
||||
test('should contain action info', async ({ showTraceViewer }) => {
|
||||
|
|
@ -964,28 +966,6 @@ test('should open two trace files of the same test', async ({ context, page, req
|
|||
]);
|
||||
});
|
||||
|
||||
test('should include requestUrl in route.fulfill', async ({ page, runAndTrace, browserName }) => {
|
||||
await page.route('**/*', route => {
|
||||
void route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'text/html'
|
||||
},
|
||||
body: 'Hello there!'
|
||||
});
|
||||
});
|
||||
const traceViewer = await runAndTrace(async () => {
|
||||
await page.goto('http://test.com');
|
||||
});
|
||||
|
||||
// Render snapshot, check expectations.
|
||||
await traceViewer.selectAction('route.fulfill');
|
||||
await traceViewer.page.locator('.tabbed-pane-tab-label', { hasText: 'Call' }).click();
|
||||
const callLine = traceViewer.page.locator('.call-line');
|
||||
await expect(callLine.getByText('status')).toContainText('200');
|
||||
await expect(callLine.getByText('requestUrl')).toContainText('http://test.com');
|
||||
});
|
||||
|
||||
test('should not crash with broken locator', async ({ page, runAndTrace, server }) => {
|
||||
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/21832' });
|
||||
const traceViewer = await runAndTrace(async () => {
|
||||
|
|
@ -999,37 +979,6 @@ test('should not crash with broken locator', async ({ page, runAndTrace, server
|
|||
await expect(header).toBeVisible();
|
||||
});
|
||||
|
||||
test('should include requestUrl in route.continue', async ({ page, runAndTrace, server }) => {
|
||||
await page.route('**/*', route => {
|
||||
void route.continue({ url: server.EMPTY_PAGE });
|
||||
});
|
||||
const traceViewer = await runAndTrace(async () => {
|
||||
await page.goto('http://test.com');
|
||||
});
|
||||
|
||||
// Render snapshot, check expectations.
|
||||
await traceViewer.selectAction('route.continue');
|
||||
await traceViewer.page.locator('.tabbed-pane-tab-label', { hasText: 'Call' }).click();
|
||||
const callLine = traceViewer.page.locator('.call-line');
|
||||
await expect(callLine.getByText('requestUrl')).toContainText('http://test.com');
|
||||
await expect(callLine.getByText(/^url:.*/)).toContainText(server.EMPTY_PAGE);
|
||||
});
|
||||
|
||||
test('should include requestUrl in route.abort', async ({ page, runAndTrace, server }) => {
|
||||
await page.route('**/*', route => {
|
||||
void route.abort();
|
||||
});
|
||||
const traceViewer = await runAndTrace(async () => {
|
||||
await page.goto('http://test.com').catch(() => {});
|
||||
});
|
||||
|
||||
// Render snapshot, check expectations.
|
||||
await traceViewer.selectAction('route.abort');
|
||||
await traceViewer.page.locator('.tabbed-pane-tab-label', { hasText: 'Call' }).click();
|
||||
const callLine = traceViewer.page.locator('.call-line');
|
||||
await expect(callLine.getByText('requestUrl')).toContainText('http://test.com');
|
||||
});
|
||||
|
||||
test('should serve overridden request', async ({ page, runAndTrace, server }) => {
|
||||
server.setRoute('/custom.css', (req, res) => {
|
||||
res.writeHead(200, {
|
||||
|
|
@ -1416,7 +1365,7 @@ test('should show correct request start time', {
|
|||
expect(parseMillis(start)).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
test('should allow hiding route actions', {
|
||||
test('should not record route actions', {
|
||||
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30970' },
|
||||
}, async ({ page, runAndTrace, server }) => {
|
||||
const traceViewer = await runAndTrace(async () => {
|
||||
|
|
@ -1426,28 +1375,9 @@ test('should allow hiding route actions', {
|
|||
await page.goto(server.EMPTY_PAGE);
|
||||
});
|
||||
|
||||
// Routes are visible by default.
|
||||
await expect(traceViewer.actionTitles).toHaveText([
|
||||
/page.route/,
|
||||
/page.goto.*empty.html/,
|
||||
/route.fulfill/,
|
||||
]);
|
||||
|
||||
await traceViewer.page.getByText('Settings').click();
|
||||
await expect(traceViewer.page.getByRole('checkbox', { name: 'Show route actions' })).toBeChecked();
|
||||
await traceViewer.page.getByRole('checkbox', { name: 'Show route actions' }).uncheck();
|
||||
await traceViewer.page.getByText('Actions', { exact: true }).click();
|
||||
await expect(traceViewer.actionTitles).toHaveText([
|
||||
/page.goto.*empty.html/,
|
||||
]);
|
||||
|
||||
await traceViewer.page.getByText('Settings').click();
|
||||
await traceViewer.page.getByRole('checkbox', { name: 'Show route actions' }).check();
|
||||
await traceViewer.page.getByText('Actions', { exact: true }).click();
|
||||
await expect(traceViewer.actionTitles).toHaveText([
|
||||
/page.route/,
|
||||
/page.goto.*empty.html/,
|
||||
/route.fulfill/,
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -1478,7 +1408,7 @@ test('should allow showing screenshots instead of snapshots', async ({ runAndTra
|
|||
await page.waitForTimeout(1000); // ensure we could take a screenshot
|
||||
});
|
||||
|
||||
const screenshot = traceViewer.page.getByAltText(`Screenshot of page.goto > Action`);
|
||||
const screenshot = traceViewer.page.getByAltText(`Screenshot of page.goto`);
|
||||
const snapshot = (await traceViewer.snapshotFrame('page.goto')).owner();
|
||||
await expect(snapshot).toBeVisible();
|
||||
await expect(screenshot).not.toBeVisible();
|
||||
|
|
|
|||
|
|
@ -66,10 +66,9 @@ test('should collect trace with resources, but no js', async ({ context, page, s
|
|||
|
||||
test('should use the correct apiName for event driven callbacks', async ({ context, page, server }, testInfo) => {
|
||||
await context.tracing.start();
|
||||
// route.* calls should not be included in the trace
|
||||
await page.route('**/empty.html', route => route.continue());
|
||||
// page.goto -> page.route should be included in the trace since its handled.
|
||||
await page.goto(server.PREFIX + '/empty.html');
|
||||
// page.route -> internalContinue should not be included in the trace since it was handled by Playwright internally.
|
||||
await page.goto(server.PREFIX + '/grid.html');
|
||||
|
||||
// The default internal dialog handler should not provide an action.
|
||||
|
|
@ -87,7 +86,6 @@ test('should use the correct apiName for event driven callbacks', async ({ conte
|
|||
expect(actions).toEqual([
|
||||
'page.route',
|
||||
'page.goto',
|
||||
'route.continue',
|
||||
'page.goto',
|
||||
'page.evaluate',
|
||||
'page.reload',
|
||||
|
|
|
|||
|
|
@ -241,3 +241,20 @@ it('main resource xhr should have type xhr', async ({ page, server }) => {
|
|||
expect(request.isNavigationRequest()).toBe(false);
|
||||
expect(request.resourceType()).toBe('xhr');
|
||||
});
|
||||
|
||||
it('should finish 204 request', {
|
||||
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/32752' }
|
||||
}, async ({ page, server, browserName }) => {
|
||||
it.fixme(browserName === 'chromium');
|
||||
server.setRoute('/204', (req, res) => {
|
||||
res.writeHead(204, { 'Content-type': 'text/plain' });
|
||||
res.end();
|
||||
});
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
const reqPromise = Promise.race([
|
||||
page.waitForEvent('requestfailed', r => r.url().endsWith('/204')).then(() => 'requestfailed'),
|
||||
page.waitForEvent('requestfinished', r => r.url().endsWith('/204')).then(() => 'requestfinished'),
|
||||
]);
|
||||
page.evaluate(async url => { await fetch(url); }, server.PREFIX + '/204').catch(() => {});
|
||||
expect(await reqPromise).toBe('requestfinished');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -117,6 +117,71 @@ it('clicking checkbox should activate it', async ({ page, browserName, headless,
|
|||
expect(nodeName).toBe('INPUT');
|
||||
});
|
||||
|
||||
it('tab should cycle between single input and browser', {
|
||||
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/32339' }
|
||||
}, async ({ page, browserName, headless }) => {
|
||||
it.fixme(browserName === 'chromium' && (!headless || !!process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW),
|
||||
'Chromium in headful mode keeps input focused.');
|
||||
it.fixme(browserName !== 'chromium');
|
||||
await page.setContent(`<label for="input1">input1</label>
|
||||
<input id="input1">
|
||||
<script>
|
||||
{
|
||||
window.focusEvents = [];
|
||||
const input = document.getElementById('input1');
|
||||
input.addEventListener('blur', () => focusEvents.push('blur'));
|
||||
input.addEventListener('focus', () => focusEvents.push('focus'));
|
||||
}
|
||||
</script>`);
|
||||
expect(await page.evaluate(() => document.activeElement.tagName)).toBe('BODY');
|
||||
await page.keyboard.press('Tab');
|
||||
expect(await page.evaluate(() => document.activeElement.id)).toBe('input1');
|
||||
expect(await page.evaluate(() => (window as any).focusEvents)).toEqual(['focus']);
|
||||
await page.keyboard.press('Tab');
|
||||
expect(await page.evaluate(() => document.activeElement.tagName)).toBe('BODY');
|
||||
expect(await page.evaluate(() => (window as any).focusEvents)).toEqual(['focus', 'blur']);
|
||||
await page.keyboard.press('Tab');
|
||||
expect(await page.evaluate(() => document.activeElement.id)).toBe('input1');
|
||||
expect(await page.evaluate(() => (window as any).focusEvents)).toEqual(['focus', 'blur', 'focus']);
|
||||
});
|
||||
|
||||
it('tab should cycle between document elements and browser', {
|
||||
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/32339' }
|
||||
}, async ({ page, browserName, headless }) => {
|
||||
it.fixme(browserName === 'chromium' && (!headless || !!process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW),
|
||||
'Chromium in headful mode keeps last input focused.');
|
||||
it.fixme(browserName !== 'chromium');
|
||||
await page.setContent(`
|
||||
<input id="input1">
|
||||
<input id="input2">
|
||||
<script>
|
||||
window.focusEvents = [];
|
||||
{
|
||||
const input = document.getElementById('input1');
|
||||
input.addEventListener('blur', () => focusEvents.push('blur1'));
|
||||
input.addEventListener('focus', () => focusEvents.push('focus1'));
|
||||
}
|
||||
{
|
||||
const input = document.getElementById('input2');
|
||||
input.addEventListener('blur', () => focusEvents.push('blur2'));
|
||||
input.addEventListener('focus', () => focusEvents.push('focus2'));
|
||||
}
|
||||
</script>`);
|
||||
expect(await page.evaluate(() => document.activeElement.tagName)).toBe('BODY');
|
||||
await page.keyboard.press('Tab');
|
||||
expect(await page.evaluate(() => document.activeElement.id)).toBe('input1');
|
||||
expect(await page.evaluate(() => (window as any).focusEvents)).toEqual(['focus1']);
|
||||
await page.keyboard.press('Tab');
|
||||
expect(await page.evaluate(() => document.activeElement.id)).toBe('input2');
|
||||
expect(await page.evaluate(() => (window as any).focusEvents)).toEqual(['focus1', 'blur1', 'focus2']);
|
||||
await page.keyboard.press('Tab');
|
||||
expect(await page.evaluate(() => document.activeElement.tagName)).toBe('BODY');
|
||||
expect(await page.evaluate(() => (window as any).focusEvents)).toEqual(['focus1', 'blur1', 'focus2', 'blur2']);
|
||||
await page.keyboard.press('Tab');
|
||||
expect(await page.evaluate(() => document.activeElement.id)).toBe('input1');
|
||||
expect(await page.evaluate(() => (window as any).focusEvents)).toEqual(['focus1', 'blur1', 'focus2', 'blur2', 'focus1']);
|
||||
});
|
||||
|
||||
it('keeps focus on element when attempting to focus a non-focusable element', async ({ page }) => {
|
||||
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/14254' });
|
||||
|
||||
|
|
|
|||
|
|
@ -83,9 +83,11 @@ test('click should not leak', async ({ page, browserName, toImpl }) => {
|
|||
expect(leakedJSHandles()).toBeFalsy();
|
||||
|
||||
if (browserName === 'chromium') {
|
||||
const counts = await objectCounts(toImpl(page), 'HTMLButtonElement');
|
||||
expect(counts.main + counts.utility).toBeGreaterThanOrEqual(2);
|
||||
expect(counts.main + counts.utility).toBeLessThan(25);
|
||||
await expect(async () => {
|
||||
const counts = await objectCounts(toImpl(page), 'HTMLButtonElement');
|
||||
expect(counts.main + counts.utility).toBeGreaterThanOrEqual(2);
|
||||
expect(counts.main + counts.utility).toBeLessThan(25);
|
||||
}).toPass();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -114,9 +116,11 @@ test('fill should not leak', async ({ page, mode, browserName, toImpl }) => {
|
|||
expect(leakedJSHandles()).toBeFalsy();
|
||||
|
||||
if (browserName === 'chromium') {
|
||||
const counts = await objectCounts(toImpl(page), 'HTMLInputElement');
|
||||
expect(counts.main + counts.utility).toBeGreaterThanOrEqual(2);
|
||||
expect(counts.main + counts.utility).toBeLessThan(25);
|
||||
await expect(async () => {
|
||||
const counts = await objectCounts(toImpl(page), 'HTMLInputElement');
|
||||
expect(counts.main + counts.utility).toBeGreaterThanOrEqual(2);
|
||||
expect(counts.main + counts.utility).toBeLessThan(25);
|
||||
}).toPass();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -144,9 +148,11 @@ test('expect should not leak', async ({ page, mode, browserName, toImpl }) => {
|
|||
expect(leakedJSHandles()).toBeFalsy();
|
||||
|
||||
if (browserName === 'chromium') {
|
||||
const counts = await objectCounts(toImpl(page), 'HTMLButtonElement');
|
||||
expect(counts.main + counts.utility).toBeGreaterThanOrEqual(2);
|
||||
expect(counts.main + counts.utility).toBeLessThan(25);
|
||||
await expect(async () => {
|
||||
const counts = await objectCounts(toImpl(page), 'HTMLButtonElement');
|
||||
expect(counts.main + counts.utility).toBeGreaterThanOrEqual(2);
|
||||
expect(counts.main + counts.utility).toBeLessThan(25);
|
||||
}).toPass();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -174,8 +180,10 @@ test('waitFor should not leak', async ({ page, mode, browserName, toImpl }) => {
|
|||
expect(leakedJSHandles()).toBeFalsy();
|
||||
|
||||
if (browserName === 'chromium') {
|
||||
const counts = await objectCounts(toImpl(page), 'HTMLButtonElement');
|
||||
expect(counts.main + counts.utility).toBeGreaterThanOrEqual(2);
|
||||
expect(counts.main + counts.utility).toBeLessThan(25);
|
||||
await expect(async () => {
|
||||
const counts = await objectCounts(toImpl(page), 'HTMLButtonElement');
|
||||
expect(counts.main + counts.utility).toBeGreaterThanOrEqual(2);
|
||||
expect(counts.main + counts.utility).toBeLessThan(25);
|
||||
}).toPass();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -280,12 +280,15 @@ it.describe('page screenshot', () => {
|
|||
expect(screenshot).toMatchSnapshot('screenshot-clip-odd-size.png');
|
||||
});
|
||||
|
||||
it('should work for canvas', async ({ page, server, isElectron, isMac }) => {
|
||||
it('should work for canvas', async ({ page, server, isElectron, isMac, browserName, headless }) => {
|
||||
it.fixme(isElectron && isMac, 'Fails on the bots');
|
||||
await page.setViewportSize({ width: 500, height: 500 });
|
||||
await page.goto(server.PREFIX + '/screenshots/canvas.html');
|
||||
const screenshot = await page.screenshot();
|
||||
expect(screenshot).toMatchSnapshot('screenshot-canvas.png');
|
||||
if (!headless && browserName === 'chromium' && isMac && os.arch() === 'arm64' && /* macOS 14+ */ parseInt(os.release(), 10) >= 23)
|
||||
expect(screenshot).toMatchSnapshot('screenshot-canvas-with-accurate-corners.png');
|
||||
else
|
||||
expect(screenshot).toMatchSnapshot('screenshot-canvas.png');
|
||||
});
|
||||
|
||||
it('should capture canvas changes', async ({ page, isElectron, browserName, isMac, isWebView2 }) => {
|
||||
|
|
@ -323,7 +326,7 @@ it.describe('page screenshot', () => {
|
|||
it('should work for webgl', async ({ page, server, browserName, platform }) => {
|
||||
it.fixme(browserName === 'firefox');
|
||||
it.fixme(browserName === 'chromium' && platform === 'darwin' && os.arch() === 'arm64', 'SwiftShader is not available on macOS-arm64 - https://github.com/microsoft/playwright/issues/28216');
|
||||
it.skip(browserName === 'webkit' && platform === 'darwin' && parseInt(os.release(), 10) === 22, 'WebGL is not available in macOS-13 - https://bugs.webkit.org/show_bug.cgi?id=278277');
|
||||
it.skip(browserName === 'webkit' && platform === 'darwin' && os.arch() === 'x64', 'Modernizr uses WebGL which is not available on Intel macOS - https://bugs.webkit.org/show_bug.cgi?id=278277');
|
||||
|
||||
await page.setViewportSize({ width: 640, height: 480 });
|
||||
await page.goto(server.PREFIX + '/screenshots/webgl.html');
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
|
|
@ -1068,4 +1068,38 @@ test('expect.extend should be immutable', async ({ runInlineTest }) => {
|
|||
'foo',
|
||||
'bar',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test('expect.extend should fall back to legacy behavior', async ({ runInlineTest }) => {
|
||||
const result = await runInlineTest({
|
||||
'expect-test.spec.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
expect.extend({
|
||||
toFoo() {
|
||||
console.log('%%foo');
|
||||
return { pass: true };
|
||||
}
|
||||
});
|
||||
expect.extend({
|
||||
toFoo() {
|
||||
console.log('%%foo2');
|
||||
return { pass: true };
|
||||
}
|
||||
});
|
||||
expect.extend({
|
||||
toBar() {
|
||||
console.log('%%bar');
|
||||
return { pass: true };
|
||||
}
|
||||
});
|
||||
test('logs', () => {
|
||||
expect().toFoo();
|
||||
expect().toBar();
|
||||
});
|
||||
`
|
||||
});
|
||||
expect(result.outputLines).toEqual([
|
||||
'foo2',
|
||||
'bar',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const test = baseTest.extend<{ git(command: string): void }>({
|
|||
git(`init --initial-branch=main`);
|
||||
git(`config --local user.name "Robert Botman"`);
|
||||
git(`config --local user.email "botty@mcbotface.com"`);
|
||||
git(`config --local core.autocrlf false`);
|
||||
|
||||
await use((command: string) => git(command));
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1216,7 +1216,6 @@ test('should not nest top level expect into unfinished api calls ', {
|
|||
' browserContext.newPage',
|
||||
'page.route',
|
||||
'page.goto',
|
||||
'route.fetch',
|
||||
'expect.toBeVisible',
|
||||
'page.unrouteAll',
|
||||
'After Hooks',
|
||||
|
|
|
|||
|
|
@ -546,37 +546,6 @@ fixture | fixture: browser
|
|||
`);
|
||||
});
|
||||
|
||||
test('should not nest page.continue inside page.goto steps', async ({ runInlineTest }) => {
|
||||
const result = await runInlineTest({
|
||||
'reporter.ts': stepIndentReporter,
|
||||
'playwright.config.ts': `module.exports = { reporter: './reporter', };`,
|
||||
'a.test.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('pass', async ({ page }) => {
|
||||
await page.route('**/*', route => route.fulfill('<html></html>'));
|
||||
await page.goto('http://localhost:1234');
|
||||
});
|
||||
`
|
||||
}, { reporter: '' });
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output).toBe(`
|
||||
hook |Before Hooks
|
||||
fixture | fixture: browser
|
||||
pw:api | browserType.launch
|
||||
fixture | fixture: context
|
||||
pw:api | browser.newContext
|
||||
fixture | fixture: page
|
||||
pw:api | browserContext.newPage
|
||||
pw:api |page.route @ a.test.ts:4
|
||||
pw:api |page.goto(http://localhost:1234) @ a.test.ts:5
|
||||
pw:api |route.fulfill @ a.test.ts:4
|
||||
hook |After Hooks
|
||||
fixture | fixture: page
|
||||
fixture | fixture: context
|
||||
`);
|
||||
});
|
||||
|
||||
test('should not propagate errors from within toPass', async ({ runInlineTest }) => {
|
||||
const result = await runInlineTest({
|
||||
'reporter.ts': stepIndentReporter,
|
||||
|
|
|
|||
|
|
@ -444,3 +444,62 @@ test('should show proper total when using deps', async ({ runUITest }) => {
|
|||
`);
|
||||
await expect(page.getByTestId('status-line')).toHaveText('2/2 passed (100%)');
|
||||
});
|
||||
|
||||
test('should respect --tsconfig option', {
|
||||
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/32797' }
|
||||
}, async ({ runUITest }) => {
|
||||
const { page } = await runUITest({
|
||||
'playwright.config.ts': `
|
||||
import { foo } from '~/foo';
|
||||
export default {
|
||||
testDir: './tests' + foo,
|
||||
};
|
||||
`,
|
||||
'tsconfig.json': `{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./does-not-exist/*"],
|
||||
},
|
||||
},
|
||||
}`,
|
||||
'tsconfig.special.json': `{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./mapped-from-root/*"],
|
||||
},
|
||||
},
|
||||
}`,
|
||||
'mapped-from-root/foo.ts': `
|
||||
export const foo = 42;
|
||||
`,
|
||||
'tests42/tsconfig.json': `{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["../should-be-ignored/*"],
|
||||
},
|
||||
},
|
||||
}`,
|
||||
'tests42/a.test.ts': `
|
||||
import { foo } from '~/foo';
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('test', ({}) => {
|
||||
expect(foo).toBe(42);
|
||||
});
|
||||
`,
|
||||
'should-be-ignored/foo.ts': `
|
||||
export const foo = 43;
|
||||
`,
|
||||
}, undefined, { additionalArgs: ['--tsconfig=tsconfig.special.json'] });
|
||||
|
||||
await page.getByTitle('Run all').click();
|
||||
|
||||
await expect.poll(dumpTestTree(page)).toBe(`
|
||||
▼ ✅ a.test.ts
|
||||
✅ test
|
||||
`);
|
||||
|
||||
await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)');
|
||||
});
|
||||
|
|
@ -192,8 +192,7 @@ class ApiParser {
|
|||
method.argsArray.push(options);
|
||||
}
|
||||
p.required = false;
|
||||
// @ts-ignore
|
||||
options.type.properties.push(p);
|
||||
options.type?.properties?.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -363,11 +363,6 @@ class Member {
|
|||
this.alias = match[1];
|
||||
this.overloadIndex = (+match[2]) - 1;
|
||||
}
|
||||
/**
|
||||
* Param is true and option false
|
||||
* @type {Boolean | null}
|
||||
*/
|
||||
this.paramOrOption = null;
|
||||
}
|
||||
|
||||
index() {
|
||||
|
|
@ -384,10 +379,8 @@ class Member {
|
|||
for (const arg of this.argsArray) {
|
||||
this.args.set(arg.name, arg);
|
||||
arg.enclosingMethod = this;
|
||||
if (arg.name === 'options') {
|
||||
// @ts-ignore
|
||||
arg.type.properties.sort((p1, p2) => p1.name.localeCompare(p2.name));
|
||||
}
|
||||
if (arg.name === 'options')
|
||||
arg.type?.properties?.sort((p1, p2) => p1.name.localeCompare(p2.name));
|
||||
indexArg(arg);
|
||||
}
|
||||
}
|
||||
|
|
@ -410,11 +403,9 @@ class Member {
|
|||
continue;
|
||||
const overriddenArg = (arg.langs.overrides && arg.langs.overrides[lang]) || arg;
|
||||
overriddenArg.filterForLanguage(lang, options);
|
||||
// @ts-ignore
|
||||
if (overriddenArg.name === 'options' && !overriddenArg.type.properties.length)
|
||||
if (overriddenArg.name === 'options' && !overriddenArg.type?.properties?.length)
|
||||
continue;
|
||||
// @ts-ignore
|
||||
overriddenArg.type.filterForLanguage(lang, options);
|
||||
overriddenArg.type?.filterForLanguage(lang, options);
|
||||
argsArray.push(overriddenArg);
|
||||
}
|
||||
this.argsArray = argsArray;
|
||||
|
|
@ -433,7 +424,6 @@ class Member {
|
|||
const result = new Member(this.kind, { langs: this.langs, since: this.since, deprecated: this.deprecated, discouraged: this.discouraged }, this.name, this.type?.clone(), this.argsArray.map(arg => arg.clone()), this.spec, this.required);
|
||||
result.alias = this.alias;
|
||||
result.async = this.async;
|
||||
result.paramOrOption = this.paramOrOption;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -526,8 +516,7 @@ class Type {
|
|||
if (!inUnion && (parsedType.union || parsedType.unionName)) {
|
||||
const type = new Type(parsedType.unionName || '');
|
||||
type.union = [];
|
||||
// @ts-ignore
|
||||
for (let t = parsedType; t; t = t.union) {
|
||||
for (let /** @type {ParsedType | null} */ t = parsedType; t; t = t.union) {
|
||||
const nestedUnion = !!t.unionName && t !== parsedType;
|
||||
type.union.push(Type.fromParsedType(t, !nestedUnion));
|
||||
if (nestedUnion)
|
||||
|
|
@ -539,7 +528,6 @@ class Type {
|
|||
if (parsedType.args || parsedType.retType) {
|
||||
const type = new Type('function');
|
||||
type.args = [];
|
||||
// @ts-ignore
|
||||
for (let t = parsedType.args; t; t = t.next)
|
||||
type.args.push(Type.fromParsedType(t));
|
||||
type.returnType = parsedType.retType ? Type.fromParsedType(parsedType.retType) : undefined;
|
||||
|
|
@ -549,8 +537,7 @@ class Type {
|
|||
if (parsedType.template) {
|
||||
const type = new Type(parsedType.name);
|
||||
type.templates = [];
|
||||
// @ts-ignore
|
||||
for (let t = parsedType.template; t; t = t.next)
|
||||
for (let /** @type {ParsedType | null} */ t = parsedType.template; t; t = t.next)
|
||||
type.templates.push(Type.fromParsedType(t));
|
||||
return type;
|
||||
}
|
||||
|
|
@ -613,17 +600,6 @@ class Type {
|
|||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Member[] | undefined}
|
||||
*/
|
||||
sortedProperties() {
|
||||
if (!this.properties)
|
||||
return this.properties;
|
||||
const sortedProperties = [...this.properties];
|
||||
sortedProperties.sort((p1, p2) => p1.name.localeCompare(p2.name));
|
||||
return sortedProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} lang
|
||||
* @param {LanguageOptions=} options
|
||||
|
|
@ -768,11 +744,10 @@ function patchLinksInText(classOrMember, text, classesMap, membersMap, linkRende
|
|||
let alias = p2;
|
||||
if (classOrMember) {
|
||||
// param/option reference can only be in method or same method parameter comments.
|
||||
// @ts-ignore
|
||||
const method = classOrMember.enclosingMethod;
|
||||
const param = method.argsArray.find(a => a.name === p2);
|
||||
const method = /** @type {Member} */(classOrMember).enclosingMethod;
|
||||
const param = method?.argsArray.find(a => a.name === p2);
|
||||
if (!param)
|
||||
throw new Error(`Referenced parameter ${match} not found in the parent method ${method.name} `);
|
||||
throw new Error(`Referenced parameter ${match} not found in the parent method ${method?.name} `);
|
||||
alias = param.alias;
|
||||
}
|
||||
return linkRenderer({ param: alias, href }) || match;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue