Merge branch 'main' into feat-extend-custom-asymmetric-matchers

This commit is contained in:
Mathias Leppich 2024-09-25 14:29:43 +02:00
commit 74b30438c0
68 changed files with 1325 additions and 579 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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]>

View file

@ -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

View file

@ -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:

View file

@ -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
View file

@ -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"
}
},

View file

@ -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",

View file

@ -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)

View 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');
}
}

View file

@ -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);
}

View file

@ -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;

View file

@ -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);
}

View file

@ -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;

View file

@ -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;

View file

@ -5,7 +5,7 @@
../isomorphic/**
../registry/**
../../common/
../../generated/recorderSource.ts
../../generated/pollingRecorderSource.ts
../../protocol/
../../utils/**
../../utilsBundle.ts

View file

@ -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';
@ -54,9 +54,11 @@ export class ContextRecorder extends EventEmitter {
private _throttledOutputFile: ThrottledFile | null = null;
private _orderedLanguages: LanguageGenerator[] = [];
private _listeners: RegisteredListener[] = [];
private _codegenMode: 'actions' | 'trace-events';
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;
@ -145,10 +147,12 @@ export class ContextRecorder extends EventEmitter {
setEnabled(enabled: boolean) {
this._collection.setEnabled(enabled);
if (enabled)
this._context.tracing.startChunk({ name: 'trace', title: 'trace' }).catch(() => {});
else
this._context.tracing.stopChunk({ mode: 'discard' }).catch(() => {});
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() {

View file

@ -43,6 +43,7 @@ export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp
constructor(transport: RecorderTransport, tracePage: Page, traceServer: HttpServer, wsEndpointForTest: string | undefined) {
super();
this._transport = transport;
this._transport.eventSink.resolve(this);
this._tracePage = tracePage;
this._traceServer = traceServer;
this.wsEndpointForTest = wsEndpointForTest;
@ -94,6 +95,7 @@ async function openApp(trace: string, options?: TraceViewerServerOptions & { hea
class RecorderTransport implements Transport {
private _connected = new ManualPromise<void>();
readonly eventSink = new ManualPromise<EventEmitter>();
constructor() {
}
@ -103,6 +105,8 @@ class RecorderTransport implements Transport {
}
async dispatch(method: string, params: any): Promise<any> {
const eventSink = await this.eventSink;
eventSink.emit('event', { event: method, params });
}
onclose() {

View file

@ -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 || [])

View file

@ -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',

View file

@ -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>;

View file

@ -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';

View file

@ -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 });
Object.defineProperty(wrappedMatchers[key], 'qualifiedName', { value: key });
extendedMatchers[name] = wrappedMatchers[key];

View file

@ -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);

View file

@ -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,8 +84,9 @@ 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),
@ -145,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 };
@ -239,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);
@ -295,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,
@ -307,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,
};
@ -424,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'));
@ -442,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));

View file

@ -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;

View file

@ -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));
}

View file

@ -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)}

View file

@ -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)

View file

@ -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}

View file

@ -14,52 +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]);
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);
@ -67,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 {
@ -79,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: '',
@ -90,37 +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}
hideTimeline={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;
@ -166,5 +392,9 @@ class Connection {
this._options.setSources(sources);
window.playwrightSourcesEchoForTest = sources;
}
if (method === 'setMode') {
const { mode } = params as { mode: Mode };
this._options.setMode(mode);
}
}
}

View file

@ -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 {

View file

@ -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>';

View file

@ -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,

View file

@ -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') || '/',
@ -187,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 => {
@ -296,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',
@ -319,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

View file

@ -25,7 +25,7 @@ import type { ConsoleEntry } from './consoleTab';
import { ConsoleTab, useConsoleTabModel } from './consoleTab';
import type * as modelUtil 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';
@ -60,8 +60,7 @@ export const Workbench: React.FunctionComponent<{
}> = ({ 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');
@ -77,6 +76,14 @@ export const Workbench: React.FunctionComponent<{
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(() => {
@ -331,7 +338,7 @@ export const Workbench: React.FunctionComponent<{
orientation='horizontal'
sidebarIsFirst
settingName='actionListSidebar'
main={<SnapshotTab
main={<SnapshotTabsView
action={activeAction}
model={model}
sdkLanguage={sdkLanguage}

View 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;
}

View 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: []
};
}

View file

@ -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>

View file

@ -24,6 +24,10 @@
padding-right: 4px;
}
.toolbar.toolbar-sidebar-background {
background-color: var(--vscode-sideBar-background);
}
.toolbar:after {
content: '';
display: block;

View file

@ -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>;
};

View file

@ -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');

View file

@ -0,0 +1,5 @@
console.log("Service worker script loaded");
chrome.runtime.onInstalled.addListener(() => {
console.log("Extension installed");
});

View file

@ -0,0 +1 @@
console.log("Test console log from a third-party execution context");

View 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"]
}
]
}

View file

@ -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,

View file

@ -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();

View file

@ -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 }) {

View file

@ -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;

View file

@ -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();
@ -426,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 }) => {
@ -549,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();`);
});
});

View file

@ -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();

View file

@ -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);

View file

@ -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');

View file

@ -1408,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();

View file

@ -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');
});

View file

@ -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();
}
});

View file

@ -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

View file

@ -1097,3 +1097,37 @@ test('expect.extend should be immutable', async ({ runInlineTest }) => {
'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',
]);
});

View file

@ -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));
},

View file

@ -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%)');
});

View file

@ -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);
}
}

View file

@ -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;

View file

@ -17,7 +17,6 @@
// @ts-check
const path = require('path');
const Documentation = require('./documentation');
const { parseApi } = require('./api_parser');
const PROJECT_DIR = path.join(__dirname, '..', '..');
@ -38,14 +37,14 @@ const PROJECT_DIR = path.join(__dirname, '..', '..');
}
/**
* @param {Documentation} documentation
* @param {import('./documentation').Documentation} documentation
*/
function serialize(documentation) {
return documentation.classesArray.map(serializeClass);
}
/**
* @param {Documentation.Class} clazz
* @param {import('./documentation').Class} clazz
*/
function serializeClass(clazz) {
const result = { name: clazz.name, spec: clazz.spec };
@ -65,7 +64,7 @@ function serializeClass(clazz) {
}
/**
* @param {Documentation.Member} member
* @param {import('./documentation').Member} member
*/
function serializeMember(member) {
const result = /** @type {any} */ ({ ...member });
@ -76,14 +75,20 @@ function serializeMember(member) {
return result;
}
/**
* @param {import('./documentation').Member} arg
*/
function serializeProperty(arg) {
const result = { ...arg, parent: undefined };
sanitize(result);
if (arg.type)
result.type = serializeType(arg.type, arg.name === 'options');
result.type = serializeType(arg.type);
return result;
}
/**
* @param {object} result
*/
function sanitize(result) {
delete result.args;
delete result.argsArray;
@ -92,14 +97,13 @@ function sanitize(result) {
}
/**
* @param {Documentation.Type} type
* @param {boolean} sortProperties
* @param {import('./documentation').Type} type
*/
function serializeType(type, sortProperties = false) {
function serializeType(type) {
/** @type {any} */
const result = { ...type };
if (type.properties)
result.properties = (sortProperties ? type.sortedProperties() : type.properties).map(serializeProperty);
result.properties = type.properties.map(serializeProperty);
if (type.union)
result.union = type.union.map(type => serializeType(type));
if (type.templates)

View file

@ -45,7 +45,7 @@ const injectedScripts = [
true,
],
[
path.join(ROOT, 'packages', 'playwright-core', 'src', 'server', 'injected', 'recorder', 'recorder.ts'),
path.join(ROOT, 'packages', 'playwright-core', 'src', 'server', 'injected', 'recorder', 'pollingRecorder.ts'),
path.join(ROOT, 'packages', 'playwright-core', 'lib', 'server', 'injected', 'packed'),
path.join(ROOT, 'packages', 'playwright-core', 'src', 'generated'),
true,

View file

@ -91,7 +91,7 @@ class TypesGenerator {
if (!docClass)
return '';
handledClasses.add(className);
return this.writeComment(docClass.comment) + '\n';
return this.writeComment(docClass.comment, '') + '\n';
}, (className, methodName, overloadIndex) => {
if (className === 'SuiteFunction' && methodName === '__call') {
const cls = this.documentation.classes.get('Test');
@ -218,7 +218,7 @@ class TypesGenerator {
classToString(classDesc) {
const parts = [];
if (classDesc.comment) {
parts.push(this.writeComment(classDesc.comment))
parts.push(this.writeComment(classDesc.comment, ''))
}
const shouldExport = !this.doNotExportClassNames.has(classDesc.name);
parts.push(`${shouldExport ? 'export ' : ''}interface ${classDesc.name} ${classDesc.extends ? `extends ${classDesc.extends} ` : ''}{`);
@ -258,7 +258,7 @@ class TypesGenerator {
const descriptions = [];
for (let [eventName, value] of classDesc.events) {
eventName = eventName.toLowerCase();
const type = this.stringifyComplexType(value && value.type, 'out', ' ', classDesc.name, eventName, 'payload');
const type = this.stringifyComplexType(value && value.type, 'out', ' ', [classDesc.name, eventName, 'payload']);
const argName = this.argNameForType(type);
const params = argName ? `${argName}: ${type}` : '';
descriptions.push({
@ -311,8 +311,8 @@ class TypesGenerator {
return parts.join('\n');
}
const jsdoc = this.memberJSDOC(member, indent);
const args = this.argsFromMember(member, indent, classDesc.name);
let type = this.stringifyComplexType(member.type, 'out', indent, classDesc.name, member.alias);
const args = this.argsFromMember(member, indent, [classDesc.name]);
let type = this.stringifyComplexType(member.type, 'out', indent, [classDesc.name, member.alias]);
if (member.async)
type = `Promise<${type}>`;
// do this late, because we still want object definitions for overridden types
@ -351,7 +351,12 @@ class TypesGenerator {
return this.documentation.classes.get(classDesc.extends);
}
writeComment(comment, indent = '') {
/**
* @param {string} comment
* @param {string} indent
* @returns {string}
*/
writeComment(comment, indent) {
const parts = [];
const out = [];
const pushLine = (line) => {
@ -387,26 +392,30 @@ class TypesGenerator {
/**
* @param {docs.Type|null} type
* @param {'in' | 'out'} direction
* @param {string} indent
* @param {string[]} namespace
* @returns {string}
*/
stringifyComplexType(type, direction, indent, ...namespace) {
stringifyComplexType(type, direction, indent, namespace) {
if (!type)
return 'void';
return this.stringifySimpleType(type, direction, indent, ...namespace);
return this.stringifySimpleType(type, direction, indent, namespace);
}
/**
* @param {docs.Member[]} properties
* @param {string} name
* @param {string=} indent
* @param {string} indent
* @returns {string}
*/
stringifyObjectType(properties, name, indent = '') {
stringifyObjectType(properties, name, indent) {
const parts = [];
parts.push(`{`);
parts.push(properties.map(member => {
const comment = this.memberJSDOC(member, indent + ' ');
const args = this.argsFromMember(member, indent + ' ', name);
const type = this.stringifyComplexType(member.type, 'out', indent + ' ', name, member.name);
const args = this.argsFromMember(member, indent + ' ', [name]);
const type = this.stringifyComplexType(member.type, 'out', indent + ' ', [name, member.name]);
return `${comment}${this.nameForProperty(member)}${args}: ${type};`;
}).join('\n\n'));
parts.push(indent + '}');
@ -416,14 +425,16 @@ class TypesGenerator {
/**
* @param {docs.Type | null | undefined} type
* @param {'in' | 'out'} direction
* @returns{string}
* @param {string} indent
* @param {string[]} namespace
* @returns {string}
*/
stringifySimpleType(type, direction, indent = '', ...namespace) {
stringifySimpleType(type, direction, indent, namespace) {
if (!type)
return 'void';
if (type.name === 'Object' && type.templates) {
const keyType = this.stringifySimpleType(type.templates[0], direction, indent, ...namespace);
const valueType = this.stringifySimpleType(type.templates[1], direction, indent, ...namespace);
const keyType = this.stringifySimpleType(type.templates[0], direction, indent, namespace);
const valueType = this.stringifySimpleType(type.templates[1], direction, indent, namespace);
return `{ [key: ${keyType}]: ${valueType}; }`;
}
let out = type.name;
@ -434,7 +445,7 @@ class TypesGenerator {
if (type.name === 'Object' && type.properties && type.properties.length) {
const name = namespace.map(n => n[0].toUpperCase() + n.substring(1)).join('');
const shouldExport = exported[name];
const properties = namespace[namespace.length - 1] === 'options' ? type.sortedProperties() : type.properties;
const properties = type.properties;
if (!properties)
throw new Error(`Object type must have properties`);
if (!this.objectDefinitions.some(o => o.name === name))
@ -448,10 +459,10 @@ class TypesGenerator {
if (type.args) {
const stringArgs = type.args.map(a => ({
type: this.stringifySimpleType(a, direction, indent, ...namespace),
type: this.stringifySimpleType(a, direction, indent, namespace),
name: a.name.toLowerCase()
}));
out = `((${stringArgs.map(({ name, type }) => `${name}: ${type}`).join(', ')}) => ${this.stringifySimpleType(type.returnType, 'out', indent, ...namespace)})`;
out = `((${stringArgs.map(({ name, type }) => `${name}: ${type}`).join(', ')}) => ${this.stringifySimpleType(type.returnType, 'out', indent, namespace)})`;
} else if (type.name === 'function') {
out = 'Function';
}
@ -460,19 +471,22 @@ class TypesGenerator {
if (out === 'Any')
return 'any';
if (type.templates)
out += '<' + type.templates.map(t => this.stringifySimpleType(t, direction, indent, ...namespace)).join(', ') + '>';
out += '<' + type.templates.map(t => this.stringifySimpleType(t, direction, indent, namespace)).join(', ') + '>';
if (type.union)
out = type.union.map(t => this.stringifySimpleType(t, direction, indent, ...namespace)).join('|');
out = type.union.map(t => this.stringifySimpleType(t, direction, indent, namespace)).join('|');
return out.trim();
}
/**
* @param {docs.Member} member
* @param {string} indent
* @param {string[]} namespace
* @returns {string}
*/
argsFromMember(member, indent, ...namespace) {
argsFromMember(member, indent, namespace) {
if (member.kind === 'property')
return '';
return '(' + member.argsArray.map(arg => `${this.nameForProperty(arg)}: ${this.stringifyComplexType(arg.type, 'in', indent, ...namespace, member.alias, arg.alias)}`).join(', ') + ')';
return '(' + member.argsArray.map(arg => `${this.nameForProperty(arg)}: ${this.stringifyComplexType(arg.type, 'in', indent, [...namespace, member.alias, arg.alias])}`).join(', ') + ')';
}
/**

View file

@ -84,6 +84,8 @@ gzip "${REPORT_NAME}"
AZ_STORAGE_ACCOUNT="folioflakinessdashboard"
echo "Uploading ${REPORT_NAME}.gz"
az storage blob upload --auth-mode login --account-name "${AZ_STORAGE_ACCOUNT}" -c uploads -f "${REPORT_NAME}.gz" -n "${REPORT_NAME}.gz"
UTC_DATE=$(cat <<EOF | node