Merge branch 'main' into fix-hover-trial

This commit is contained in:
Mathias Leppich 2024-09-25 14:31:33 +02:00
commit 920e0854ea
104 changed files with 5748 additions and 920 deletions

View file

@ -26,7 +26,7 @@ jobs:
strategy:
fail-fast: false
matrix:
channel: [bidi-chromium, bidi-firefox-beta]
channel: [bidi-chromium, bidi-firefox-nightly]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@ -38,8 +38,8 @@ jobs:
- run: npm run build
- run: npx playwright install --with-deps chromium
if: matrix.channel == 'bidi-chromium'
- run: npx -y @puppeteer/browsers install firefox@beta
if: matrix.channel == 'bidi-firefox-beta'
- run: npx -y @puppeteer/browsers install firefox@nightly
if: matrix.channel == 'bidi-firefox-nightly'
- name: Run tests
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run biditest -- --project=${{ matrix.channel }}*
env:

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

@ -397,7 +397,7 @@ async function launchContext(options: Options, extraOptions: LaunchOptions): Pro
process.stdout.write('\n-------------8<-------------\n');
const autoExitCondition = process.env.PWTEST_CLI_AUTO_EXIT_WHEN;
if (autoExitCondition && text.includes(autoExitCondition))
Promise.all(context.pages().map(async p => p.close()));
closeBrowser();
};
// Make sure we exit abnormally when browser crashes.
const logs: string[] = [];
@ -504,7 +504,7 @@ async function launchContext(options: Options, extraOptions: LaunchOptions): Pro
if (hasPage)
return;
// Avoid the error when the last page is closed because the browser has been closed.
closeBrowser().catch(e => null);
closeBrowser().catch(() => {});
});
});
process.on('SIGINT', async () => {
@ -560,7 +560,7 @@ async function open(options: Options, url: string | undefined, language: string)
async function codegen(options: Options & { target: string, output?: string, testIdAttribute?: string }, url: string | undefined) {
const { target: language, output: outputFile, testIdAttribute: testIdAttributeName } = options;
const tracesDir = path.join(os.tmpdir(), `recorder-trace-${Date.now()}`);
const tracesDir = path.join(os.tmpdir(), `playwright-recorder-trace-${Date.now()}`);
const { context, launchOptions, contextOptions } = await launchContext(options, {
headless: !!process.env.PWTEST_CLI_HEADLESS,
executablePath: process.env.PWTEST_CLI_EXECUTABLE_PATH,
@ -574,6 +574,7 @@ async function codegen(options: Options & { target: string, output?: string, tes
device: options.device,
saveStorage: options.saveStorage,
mode: 'recording',
codegenMode: process.env.PW_RECORDER_IS_TRACE_VIEWER ? 'trace-events' : 'actions',
testIdAttributeName,
outputFile: outputFile ? path.resolve(outputFile) : undefined,
});

View file

@ -220,7 +220,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
}
// If the page is closed or unrouteAll() was called without waiting and interception disabled,
// the method will throw an error - silence it.
await route._innerContinue(true).catch(() => {});
await route._innerContinue(true /* isFallback */).catch(() => {});
}
async _onWebSocketRoute(webSocketRoute: network.WebSocketRoute) {
@ -492,17 +492,8 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
await this._closedPromise;
}
async _enableRecorder(params: {
language: string,
launchOptions?: LaunchOptions,
contextOptions?: BrowserContextOptions,
device?: string,
saveStorage?: string,
mode?: 'recording' | 'inspecting',
testIdAttributeName?: string,
outputFile?: string,
}) {
await this._channel.recorderSupplementEnable(params);
async _enableRecorder(params: channels.BrowserContextEnableRecorderParams) {
await this._channel.enableRecorder(params);
}
}

View file

@ -40,6 +40,7 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
_logger: Logger | undefined;
readonly _instrumentation: ClientInstrumentation;
private _eventToSubscriptionMapping: Map<string, string> = new Map();
private _isInternalType = false;
_wasCollected: boolean = false;
constructor(parent: ChannelOwner | Connection, type: string, guid: string, initializer: channels.InitializerTraits<T>) {
@ -61,6 +62,10 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
this._initializer = initializer;
}
protected markAsInternalType() {
this._isInternalType = true;
}
_setEventToSubscriptionMapping(mapping: Map<string, string>) {
this._eventToSubscriptionMapping = mapping;
}
@ -173,7 +178,7 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
let apiName: string | undefined = stackTrace.apiName;
const frames: channels.StackFrame[] = stackTrace.frames;
isInternal = isInternal || this._type === 'LocalUtils';
isInternal = isInternal || this._isInternalType;
if (isInternal)
apiName = undefined;

View file

@ -33,6 +33,7 @@ export class LocalUtils extends ChannelOwner<channels.LocalUtilsChannel> {
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.LocalUtilsInitializer) {
super(parent, type, guid, initializer);
this.markAsInternalType();
this.devices = {};
for (const { name, descriptor } of initializer.deviceDescriptors)
this.devices[name] = descriptor;

View file

@ -299,6 +299,7 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.RouteInitializer) {
super(parent, type, guid, initializer);
this.markAsInternalType();
}
request(): Request {
@ -325,7 +326,7 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
async abort(errorCode?: string) {
await this._handleRoute(async () => {
await this._raceWithTargetClose(this._channel.abort({ requestUrl: this.request()._initializer.url, errorCode }));
await this._raceWithTargetClose(this._channel.abort({ errorCode }));
});
}
@ -409,7 +410,6 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
headers['content-length'] = String(length);
await this._raceWithTargetClose(this._channel.fulfill({
requestUrl: this.request()._initializer.url,
status: statusOption || 200,
headers: headersObjectToArray(headers),
body,
@ -421,7 +421,7 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
async continue(options: FallbackOverrides = {}) {
await this._handleRoute(async () => {
this.request()._applyFallbackOverrides(options);
await this._innerContinue();
await this._innerContinue(false /* isFallback */);
});
}
@ -436,18 +436,15 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
chain.resolve(done);
}
async _innerContinue(internal = false) {
async _innerContinue(isFallback: boolean) {
const options = this.request()._fallbackOverridesForContinue();
return await this._wrapApiCall(async () => {
await this._raceWithTargetClose(this._channel.continue({
requestUrl: this.request()._initializer.url,
url: options.url,
method: options.method,
headers: options.headers ? headersObjectToArray(options.headers) : undefined,
postData: options.postDataBuffer,
isFallback: internal,
}));
}, !!internal);
return await this._raceWithTargetClose(this._channel.continue({
url: options.url,
method: options.method,
headers: options.headers ? headersObjectToArray(options.headers) : undefined,
postData: options.postDataBuffer,
isFallback,
}));
}
}

View file

@ -31,20 +31,18 @@ export class Tracing extends ChannelOwner<channels.TracingChannel> implements ap
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.TracingInitializer) {
super(parent, type, guid, initializer);
this.markAsInternalType();
}
async start(options: { name?: string, title?: string, snapshots?: boolean, screenshots?: boolean, sources?: boolean, _live?: boolean } = {}) {
this._includeSources = !!options.sources;
const traceName = await this._wrapApiCall(async () => {
await this._channel.tracingStart({
name: options.name,
snapshots: options.snapshots,
screenshots: options.screenshots,
live: options._live,
});
const response = await this._channel.tracingStartChunk({ name: options.name, title: options.title });
return response.traceName;
}, true);
await this._channel.tracingStart({
name: options.name,
snapshots: options.snapshots,
screenshots: options.screenshots,
live: options._live,
});
const { traceName } = await this._channel.tracingStartChunk({ name: options.name, title: options.title });
await this._startCollectingStacks(traceName);
}
@ -63,16 +61,12 @@ export class Tracing extends ChannelOwner<channels.TracingChannel> implements ap
}
async stopChunk(options: { path?: string } = {}) {
await this._wrapApiCall(async () => {
await this._doStopChunk(options.path);
}, true);
await this._doStopChunk(options.path);
}
async stop(options: { path?: string } = {}) {
await this._wrapApiCall(async () => {
await this._doStopChunk(options.path);
await this._channel.tracingStop();
}, true);
await this._doStopChunk(options.path);
await this._channel.tracingStop();
}
private async _doStopChunk(filePath: string | undefined) {

View file

@ -965,9 +965,10 @@ scheme.BrowserContextStorageStateResult = tObject({
});
scheme.BrowserContextPauseParams = tOptional(tObject({}));
scheme.BrowserContextPauseResult = tOptional(tObject({}));
scheme.BrowserContextRecorderSupplementEnableParams = tObject({
scheme.BrowserContextEnableRecorderParams = tObject({
language: tOptional(tString),
mode: tOptional(tEnum(['inspecting', 'recording'])),
codegenMode: tOptional(tEnum(['actions', 'trace-events'])),
pauseOnNextStatement: tOptional(tBoolean),
testIdAttributeName: tOptional(tString),
launchOptions: tOptional(tAny),
@ -977,7 +978,7 @@ scheme.BrowserContextRecorderSupplementEnableParams = tObject({
outputFile: tOptional(tString),
omitCallTracking: tOptional(tBoolean),
});
scheme.BrowserContextRecorderSupplementEnableResult = tOptional(tObject({}));
scheme.BrowserContextEnableRecorderResult = tOptional(tObject({}));
scheme.BrowserContextNewCDPSessionParams = tObject({
page: tOptional(tChannel(['Page'])),
frame: tOptional(tChannel(['Frame'])),
@ -2115,7 +2116,6 @@ scheme.RouteRedirectNavigationRequestParams = tObject({
scheme.RouteRedirectNavigationRequestResult = tOptional(tObject({}));
scheme.RouteAbortParams = tObject({
errorCode: tOptional(tString),
requestUrl: tString,
});
scheme.RouteAbortResult = tOptional(tObject({}));
scheme.RouteContinueParams = tObject({
@ -2123,7 +2123,6 @@ scheme.RouteContinueParams = tObject({
method: tOptional(tString),
headers: tOptional(tArray(tType('NameValue'))),
postData: tOptional(tBinary),
requestUrl: tString,
isFallback: tBoolean,
});
scheme.RouteContinueResult = tOptional(tObject({}));
@ -2133,7 +2132,6 @@ scheme.RouteFulfillParams = tObject({
body: tOptional(tString),
isBase64: tOptional(tBoolean),
fetchResponseUid: tOptional(tString),
requestUrl: tString,
});
scheme.RouteFulfillResult = tOptional(tObject({}));
scheme.WebSocketRouteInitializer = tObject({

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

@ -131,15 +131,15 @@ export abstract class BrowserContext extends SdkObject {
// When PWDEBUG=1, show inspector for each context.
if (debugMode() === 'inspector')
await Recorder.show(this, RecorderApp.factory(this), { pauseOnNextStatement: true });
await Recorder.show('actions', this, RecorderApp.factory(this), { pauseOnNextStatement: true });
// When paused, show inspector.
if (this._debugger.isPaused())
Recorder.showInspector(this, RecorderApp.factory(this));
Recorder.showInspectorNoReply(this, RecorderApp.factory(this));
this._debugger.on(Debugger.Events.PausedStateChanged, () => {
if (this._debugger.isPaused())
Recorder.showInspector(this, RecorderApp.factory(this));
Recorder.showInspectorNoReply(this, RecorderApp.factory(this));
});
if (debugMode() === 'console')
@ -525,7 +525,7 @@ export abstract class BrowserContext extends SdkObject {
const internalMetadata = serverSideCallMetadata();
const page = await this.newPage(internalMetadata);
await page._setServerRequestInterceptor(handler => {
handler.fulfill({ body: '<html></html>', requestUrl: handler.request().url() }).catch(() => {});
handler.fulfill({ body: '<html></html>' }).catch(() => {});
return true;
});
for (const origin of originsToSave) {
@ -559,7 +559,7 @@ export abstract class BrowserContext extends SdkObject {
isServerSide: false,
});
await page._setServerRequestInterceptor(handler => {
handler.fulfill({ body: '<html></html>', requestUrl: handler.request().url() }).catch(() => {});
handler.fulfill({ body: '<html></html>' }).catch(() => {});
return true;
});
@ -594,7 +594,7 @@ export abstract class BrowserContext extends SdkObject {
const internalMetadata = serverSideCallMetadata();
const page = await this.newPage(internalMetadata);
await page._setServerRequestInterceptor(handler => {
handler.fulfill({ body: '<html></html>', requestUrl: handler.request().url() }).catch(() => {});
handler.fulfill({ body: '<html></html>' }).catch(() => {});
return true;
});
for (const originState of state.origins) {

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

@ -197,7 +197,7 @@ export class DebugController extends SdkObject {
const contexts = new Set<BrowserContext>();
for (const page of this._playwright.allPages())
contexts.add(page.context());
const result = await Promise.all([...contexts].map(c => Recorder.show(c, () => Promise.resolve(new InspectingRecorderApp(this)), { omitCallTracking: true })));
const result = await Promise.all([...contexts].map(c => Recorder.showInspector(c, { omitCallTracking: true }, () => Promise.resolve(new InspectingRecorderApp(this)))));
return result.filter(Boolean) as Recorder[];
}

View file

@ -39,6 +39,7 @@ export class Dialog extends SdkObject {
this._onHandle = onHandle;
this._defaultValue = defaultValue || '';
this._page._frameManager.dialogDidOpen(this);
this.instrumentation.onDialog(this);
}
page() {

View file

@ -41,7 +41,6 @@ import { serializeError } from '../errors';
import { ElementHandleDispatcher } from './elementHandlerDispatcher';
import { RecorderInTraceViewer } from '../recorder/recorderInTraceViewer';
import { RecorderApp } from '../recorder/recorderApp';
import type { IRecorderAppFactory } from '../recorder/recorderFrontend';
import { WebSocketRouteDispatcher } from './webSocketRouteDispatcher';
export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channels.BrowserContextChannel, DispatcherScope> implements channels.BrowserContextChannel {
@ -301,21 +300,18 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
await this._context.close(params);
}
async recorderSupplementEnable(params: channels.BrowserContextRecorderSupplementEnableParams): Promise<void> {
let factory: IRecorderAppFactory;
if (process.env.PW_RECORDER_IS_TRACE_VIEWER) {
factory = RecorderInTraceViewer.factory(this._context);
async enableRecorder(params: channels.BrowserContextEnableRecorderParams): Promise<void> {
if (params.codegenMode === 'trace-events') {
await this._context.tracing.start({
name: 'trace',
snapshots: true,
screenshots: false,
screenshots: true,
live: true,
});
await this._context.tracing.startChunk({ name: 'trace', title: 'trace' });
await Recorder.show('trace-events', this._context, RecorderInTraceViewer.factory(this._context), params);
} else {
factory = RecorderApp.factory(this._context);
await Recorder.show('actions', this._context, RecorderApp.factory(this._context), params);
}
await Recorder.show(this._context, factory, params);
}
async pause(params: channels.BrowserContextPauseParams, metadata: CallMetadata) {

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

@ -35,16 +35,25 @@ export class Download {
this._suggestedFilename = suggestedFilename;
page._browserContext._downloads.add(this);
if (suggestedFilename !== undefined)
this._page.emit(Page.Events.Download, this);
this._fireDownloadEvent();
}
page(): Page {
return this._page;
}
_filenameSuggested(suggestedFilename: string) {
assert(this._suggestedFilename === undefined);
this._suggestedFilename = suggestedFilename;
this._page.emit(Page.Events.Download, this);
this._fireDownloadEvent();
}
suggestedFilename(): string {
return this._suggestedFilename!;
}
private _fireDownloadEvent() {
this._page.instrumentation.onDownload(this._page, this);
this._page.emit(Page.Events.Download, this);
}
}

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

@ -35,6 +35,8 @@ export type Attribution = {
};
import type { CallMetadata } from '@protocol/callMetadata';
import type { Dialog } from './dialog';
import type { Download } from './download';
export type { CallMetadata } from '@protocol/callMetadata';
export class SdkObject extends EventEmitter {
@ -62,6 +64,8 @@ export interface Instrumentation {
onPageClose(page: Page): void;
onBrowserOpen(browser: Browser): void;
onBrowserClose(browser: Browser): void;
onDialog(dialog: Dialog): void;
onDownload(page: Page, download: Download): void;
}
export interface InstrumentationListener {
@ -73,6 +77,8 @@ export interface InstrumentationListener {
onPageClose?(page: Page): void;
onBrowserOpen?(browser: Browser): void;
onBrowserClose?(browser: Browser): void;
onDialog?(dialog: Dialog): void;
onDownload?(page: Page, download: Download): void;
}
export function createInstrumentation(): Instrumentation {

View file

@ -45,32 +45,35 @@ export class Recorder implements InstrumentationListener, IRecorder {
private _omitCallTracking = false;
private _currentLanguage: Language;
static showInspector(context: BrowserContext, recorderAppFactory: IRecorderAppFactory) {
const params: channels.BrowserContextRecorderSupplementEnableParams = {};
static async showInspector(context: BrowserContext, params: channels.BrowserContextEnableRecorderParams, recorderAppFactory: IRecorderAppFactory) {
if (isUnderTest())
params.language = process.env.TEST_INSPECTOR_LANGUAGE;
Recorder.show(context, recorderAppFactory, params).catch(() => {});
return await Recorder.show('actions', context, recorderAppFactory, params);
}
static show(context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<Recorder> {
static showInspectorNoReply(context: BrowserContext, recorderAppFactory: IRecorderAppFactory) {
Recorder.showInspector(context, {}, recorderAppFactory).catch(() => {});
}
static show(codegenMode: 'actions' | 'trace-events', context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextEnableRecorderParams): Promise<Recorder> {
let recorderPromise = (context as any)[recorderSymbol] as Promise<Recorder>;
if (!recorderPromise) {
recorderPromise = Recorder._create(context, recorderAppFactory, params);
recorderPromise = Recorder._create(codegenMode, context, recorderAppFactory, params);
(context as any)[recorderSymbol] = recorderPromise;
}
return recorderPromise;
}
private static async _create(context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<Recorder> {
const recorder = new Recorder(context, params);
private static async _create(codegenMode: 'actions' | 'trace-events', context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextEnableRecorderParams = {}): Promise<Recorder> {
const recorder = new Recorder(codegenMode, context, params);
const recorderApp = await recorderAppFactory(recorder);
await recorder._install(recorderApp);
return recorder;
}
constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) {
constructor(codegenMode: 'actions' | 'trace-events', context: BrowserContext, params: channels.BrowserContextEnableRecorderParams) {
this._mode = params.mode || 'none';
this._contextRecorder = new ContextRecorder(context, params, {});
this._contextRecorder = new ContextRecorder(codegenMode, context, params, {});
this._context = context;
this._omitCallTracking = !!params.omitCallTracking;
this._debugger = context.debugger();

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';
@ -48,15 +48,17 @@ export class ContextRecorder extends EventEmitter {
private _lastDialogOrdinal = -1;
private _lastDownloadOrdinal = -1;
private _context: BrowserContext;
private _params: channels.BrowserContextRecorderSupplementEnableParams;
private _params: channels.BrowserContextEnableRecorderParams;
private _delegate: ContextRecorderDelegate;
private _recorderSources: Source[];
private _throttledOutputFile: ThrottledFile | null = null;
private _orderedLanguages: LanguageGenerator[] = [];
private _listeners: RegisteredListener[] = [];
private _codegenMode: 'actions' | 'trace-events';
constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams, delegate: ContextRecorderDelegate) {
constructor(codegenMode: 'actions' | 'trace-events', context: BrowserContext, params: channels.BrowserContextEnableRecorderParams, delegate: ContextRecorderDelegate) {
super();
this._codegenMode = codegenMode;
this._context = context;
this._params = params;
this._delegate = delegate;
@ -73,8 +75,8 @@ export class ContextRecorder extends EventEmitter {
saveStorage: params.saveStorage,
};
const collection = new RecorderCollection(context, this._pageAliases, params.mode === 'recording');
collection.on('change', (actions: ActionInContext[]) => {
this._collection = new RecorderCollection(codegenMode, context, this._pageAliases);
this._collection.on('change', (actions: ActionInContext[]) => {
this._recorderSources = [];
for (const languageGenerator of this._orderedLanguages) {
const { header, footer, actionTexts, text } = generateCode(actions, languageGenerator, languageGeneratorOptions);
@ -103,7 +105,7 @@ export class ContextRecorder extends EventEmitter {
this._listeners.push(eventsHelper.addEventListener(process, 'exit', () => {
this._throttledOutputFile?.flush();
}));
this._collection = collection;
this.setEnabled(true);
}
setOutput(codegenId: string, outputFile?: string) {
@ -145,6 +147,12 @@ export class ContextRecorder extends EventEmitter {
setEnabled(enabled: boolean) {
this._collection.setEnabled(enabled);
if (this._codegenMode === 'trace-events') {
if (enabled)
this._context.tracing.startChunk({ name: 'trace', title: 'trace' }).catch(() => {});
else
this._context.tracing.stopChunk({ mode: 'discard' }).catch(() => {});
}
}
dispose() {

View file

@ -81,7 +81,6 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
const file = require.resolve('../../vite/recorder/' + uri);
fs.promises.readFile(file).then(buffer => {
route.fulfill({
requestUrl: route.request().url(),
status: 200,
headers: [
{ name: 'Content-Type', value: mime.getType(path.extname(file)) || 'application/octet-stream' }
@ -162,8 +161,10 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
}).toString(), { isFunction: true }, sources).catch(() => {});
// Testing harness for runCLI mode.
if (process.env.PWTEST_CLI_IS_UNDER_TEST && sources.length)
(process as any)._didSetSourcesForTest(sources[0].text);
if (process.env.PWTEST_CLI_IS_UNDER_TEST && sources.length) {
if ((process as any)._didSetSourcesForTest(sources[0].text))
this.close();
}
}
async setSelector(selector: string, userGesture?: boolean): Promise<void> {

View file

@ -29,17 +29,16 @@ import type { BrowserContext } from '../browserContext';
export class RecorderCollection extends EventEmitter {
private _actions: ActionInContext[] = [];
private _enabled: boolean;
private _enabled = false;
private _pageAliases: Map<Page, string>;
private _context: BrowserContext;
constructor(context: BrowserContext, pageAliases: Map<Page, string>, enabled: boolean) {
constructor(codegenMode: 'actions' | 'trace-events', context: BrowserContext, pageAliases: Map<Page, string>) {
super();
this._context = context;
this._enabled = enabled;
this._pageAliases = pageAliases;
if (process.env.PW_RECORDER_IS_TRACE_VIEWER) {
if (codegenMode === 'trace-events') {
this._context.tracing.onMemoryEvents(events => {
this._actions = traceEventsToAction(events);
this._fireChange();

View file

@ -21,77 +21,101 @@ import type { IRecorder, IRecorderApp, IRecorderAppFactory } from './recorderFro
import { installRootRedirect, openTraceViewerApp, startTraceViewerServer } from '../trace/viewer/traceViewer';
import type { TraceViewerServerOptions } from '../trace/viewer/traceViewer';
import type { BrowserContext } from '../browserContext';
import { gracefullyProcessExitDoNotHang } from '../../utils/processLauncher';
import type { Transport } from '../../utils/httpServer';
import type { HttpServer, Transport } from '../../utils/httpServer';
import type { Page } from '../page';
import { ManualPromise } from '../../utils/manualPromise';
export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp {
readonly wsEndpointForTest: string | undefined;
private _recorder: IRecorder;
private _transport: Transport;
private _transport: RecorderTransport;
private _tracePage: Page;
private _traceServer: HttpServer;
static factory(context: BrowserContext): IRecorderAppFactory {
return async (recorder: IRecorder) => {
const transport = new RecorderTransport();
const trace = path.join(context._browser.options.tracesDir, 'trace');
const wsEndpointForTest = await openApp(trace, { transport, headless: !context._browser.options.headful });
return new RecorderInTraceViewer(context, recorder, transport, wsEndpointForTest);
const { wsEndpointForTest, tracePage, traceServer } = await openApp(trace, { transport, headless: !context._browser.options.headful });
return new RecorderInTraceViewer(transport, tracePage, traceServer, wsEndpointForTest);
};
}
constructor(context: BrowserContext, recorder: IRecorder, transport: Transport, wsEndpointForTest: string | undefined) {
constructor(transport: RecorderTransport, tracePage: Page, traceServer: HttpServer, wsEndpointForTest: string | undefined) {
super();
this._recorder = recorder;
this._transport = transport;
this._transport.eventSink.resolve(this);
this._tracePage = tracePage;
this._traceServer = traceServer;
this.wsEndpointForTest = wsEndpointForTest;
this._tracePage.once('close', () => {
this.close();
});
}
async close(): Promise<void> {
this._transport.sendEvent?.('close', {});
await this._tracePage.context().close({ reason: 'Recorder window closed' });
await this._traceServer.stop();
}
async setPaused(paused: boolean): Promise<void> {
this._transport.sendEvent?.('setPaused', { paused });
this._transport.deliverEvent('setPaused', { paused });
}
async setMode(mode: Mode): Promise<void> {
this._transport.sendEvent?.('setMode', { mode });
this._transport.deliverEvent('setMode', { mode });
}
async setFile(file: string): Promise<void> {
this._transport.sendEvent?.('setFileIfNeeded', { file });
this._transport.deliverEvent('setFileIfNeeded', { file });
}
async setSelector(selector: string, userGesture?: boolean): Promise<void> {
this._transport.sendEvent?.('setSelector', { selector, userGesture });
this._transport.deliverEvent('setSelector', { selector, userGesture });
}
async updateCallLogs(callLogs: CallLog[]): Promise<void> {
this._transport.sendEvent?.('updateCallLogs', { callLogs });
this._transport.deliverEvent('updateCallLogs', { callLogs });
}
async setSources(sources: Source[]): Promise<void> {
this._transport.sendEvent?.('setSources', { sources });
this._transport.deliverEvent('setSources', { sources });
if (process.env.PWTEST_CLI_IS_UNDER_TEST && sources.length) {
if ((process as any)._didSetSourcesForTest(sources[0].text))
this.close();
}
}
}
async function openApp(trace: string, options?: TraceViewerServerOptions & { headless?: boolean }): Promise<string | undefined> {
const server = await startTraceViewerServer(options);
await installRootRedirect(server, [trace], { ...options, webApp: 'recorder.html' });
const page = await openTraceViewerApp(server.urlPrefix('precise'), 'chromium', options);
page.on('close', () => gracefullyProcessExitDoNotHang(0));
return page.context()._browser.options.wsEndpoint;
async function openApp(trace: string, options?: TraceViewerServerOptions & { headless?: boolean }): Promise<{ wsEndpointForTest: string | undefined, tracePage: Page, traceServer: HttpServer }> {
const traceServer = await startTraceViewerServer(options);
await installRootRedirect(traceServer, [trace], { ...options, webApp: 'recorder.html' });
const page = await openTraceViewerApp(traceServer.urlPrefix('precise'), 'chromium', options);
return { wsEndpointForTest: page.context()._browser.options.wsEndpoint, tracePage: page, traceServer };
}
class RecorderTransport implements Transport {
private _connected = new ManualPromise<void>();
readonly eventSink = new ManualPromise<EventEmitter>();
constructor() {
}
async dispatch(method: string, params: any) {
onconnect() {
this._connected.resolve();
}
async dispatch(method: string, params: any): Promise<any> {
const eventSink = await this.eventSink;
eventSink.emit('event', { event: method, params });
}
onclose() {
}
deliverEvent(method: string, params: any) {
this._connected.then(() => this.sendEvent?.(method, params));
}
sendEvent?: (method: string, params: any) => void;
close?: () => void;
}

View file

@ -25,7 +25,7 @@ import type * as trace from '@trace/trace';
import { fromKeyboardModifiers, toKeyboardModifiers } from '../codegen/language';
import { serializeExpectedTextValues } from '../../utils/expectUtils';
import { createGuid, monotonicTime } from '../../utils';
import { serializeValue } from '../../protocol/serializers';
import { parseSerializedValue, serializeValue } from '../../protocol/serializers';
import type { SmartKeyboardModifier } from '../types';
export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus): CallLog {
@ -158,7 +158,7 @@ export function traceParamsForAction(actionInContext: ActionInContext): { method
const params: channels.FrameExpectParams = {
selector: action.selector,
expression: 'to.be.checked',
isNot: action.checked,
isNot: !action.checked,
};
return { method: 'expect', params };
}
@ -166,7 +166,7 @@ export function traceParamsForAction(actionInContext: ActionInContext): { method
const params: channels.FrameExpectParams = {
selector,
expression: 'to.have.text',
expectedText: serializeExpectedTextValues([action.text], { matchSubstring: true, normalizeWhiteSpace: true }),
expectedText: serializeExpectedTextValues([action.text], { matchSubstring: action.substring, normalizeWhiteSpace: true }),
isNot: false,
};
return { method: 'expect', params };
@ -193,12 +193,12 @@ export function traceParamsForAction(actionInContext: ActionInContext): { method
export function callMetadataForAction(pageAliases: Map<Page, string>, actionInContext: ActionInContext): { callMetadata: CallMetadata, mainFrame: Frame } {
const mainFrame = mainFrameForAction(pageAliases, actionInContext);
const { action } = actionInContext;
const { method, params } = traceParamsForAction(actionInContext);
const callMetadata: CallMetadata = {
id: `call@${createGuid()}`,
stepId: `recorder@${createGuid()}`,
apiName: 'frame.' + action.name,
apiName: 'page.' + method,
objectId: mainFrame.guid,
pageId: mainFrame._page.guid,
frameId: mainFrame.guid,
@ -215,38 +215,70 @@ export function callMetadataForAction(pageAliases: Map<Page, string>, actionInCo
export function traceEventsToAction(events: trace.TraceEvent[]): ActionInContext[] {
const result: ActionInContext[] = [];
const pageAliases = new Map<string, string>();
let lastDownloadOrdinal = 0;
let lastDialogOrdinal = 0;
const addSignal = (signal: actions.Signal) => {
const lastAction = result[result.length - 1];
if (!lastAction)
return;
lastAction.action.signals.push(signal);
};
for (const event of events) {
if (event.type === 'event' && event.class === 'BrowserContext' && event.method === 'page') {
const pageAlias = 'page' + pageAliases.size;
pageAliases.set(event.params.pageId, pageAlias);
const lastAction = result[result.length - 1];
lastAction.action.signals.push({
name: 'popup',
popupAlias: pageAlias,
});
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'openPage',
url: '',
signals: [],
},
timestamp: event.time,
});
continue;
}
if (event.type === 'event' && event.class === 'BrowserContext') {
const { method, params } = event;
if (method === 'page') {
const pageAlias = 'page' + (pageAliases.size || '');
pageAliases.set(params.pageId, pageAlias);
addSignal({
name: 'popup',
popupAlias: pageAlias,
});
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'openPage',
url: '',
signals: [],
},
timestamp: event.time,
});
continue;
}
if (event.type === 'event' && event.class === 'BrowserContext' && event.method === 'pageClosed') {
const pageAlias = pageAliases.get(event.params.pageId) || 'page';
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'closePage',
signals: [],
},
timestamp: event.time,
});
if (method === 'pageClosed') {
const pageAlias = pageAliases.get(event.params.pageId) || 'page';
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'closePage',
signals: [],
},
timestamp: event.time,
});
continue;
}
if (method === 'download') {
const downloadAlias = lastDownloadOrdinal ? String(lastDownloadOrdinal) : '';
++lastDownloadOrdinal;
addSignal({
name: 'download',
downloadAlias,
});
continue;
}
if (method === 'dialog') {
const dialogAlias = lastDialogOrdinal ? String(lastDialogOrdinal) : '';
++lastDialogOrdinal;
addSignal({
name: 'dialog',
dialogAlias,
});
continue;
}
continue;
}
@ -389,6 +421,67 @@ export function traceEventsToAction(events: trace.TraceEvent[]): ActionInContext
});
continue;
}
if (method === 'expect') {
const params = untypedParams as channels.FrameExpectParams;
if (params.expression === 'to.have.text') {
const entry = params.expectedText?.[0];
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'assertText',
selector: params.selector,
signals: [],
text: entry?.string!,
substring: !!entry?.matchSubstring,
},
timestamp: event.startTime
});
continue;
}
if (params.expression === 'to.have.value') {
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'assertValue',
selector: params.selector,
signals: [],
value: parseSerializedValue(params.expectedValue!.value, params.expectedValue!.handles),
},
timestamp: event.startTime
});
continue;
}
if (params.expression === 'to.be.checked') {
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'assertChecked',
selector: params.selector,
signals: [],
checked: !params.isNot,
},
timestamp: event.startTime
});
continue;
}
if (params.expression === 'to.be.visible') {
result.push({
frame: { pageAlias, framePath: [] },
action: {
name: 'assertVisible',
selector: params.selector,
signals: [],
},
timestamp: event.startTime
});
continue;
}
continue;
}
}
return result;

View file

@ -38,6 +38,8 @@ import { Snapshotter } from './snapshotter';
import type { ConsoleMessage } from '../../console';
import { Dispatcher } from '../../dispatchers/dispatcher';
import { serializeError } from '../../errors';
import type { Dialog } from '../../dialog';
import type { Download } from '../../download';
const version: trace.VERSION = 7;
@ -454,6 +456,28 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
this._appendTraceEvent(event);
}
onDialog(dialog: Dialog) {
const event: trace.EventTraceEvent = {
type: 'event',
time: monotonicTime(),
class: 'BrowserContext',
method: 'dialog',
params: { pageId: dialog.page().guid, type: dialog.type(), message: dialog.message(), defaultValue: dialog.defaultValue() },
};
this._appendTraceEvent(event);
}
onDownload(page: Page, download: Download) {
const event: trace.EventTraceEvent = {
type: 'event',
time: monotonicTime(),
class: 'BrowserContext',
method: 'download',
params: { pageId: page.guid, url: download.url, suggestedFilename: download.suggestedFilename() },
};
this._appendTraceEvent(event);
}
onPageOpen(page: Page) {
const event: trace.EventTraceEvent = {
type: 'event',

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 || [])
@ -223,6 +220,9 @@ class StdinServer implements Transport {
process.stdin.on('close', () => gracefullyProcessExitDoNotHang(0));
}
onconnect() {
}
async dispatch(method: string, params: any) {
if (method === 'initialize') {
if (this._traceUrl)

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

@ -27,8 +27,9 @@ export type ServerRouteHandler = (request: http.IncomingMessage, response: http.
export type Transport = {
sendEvent?: (method: string, params: any) => void;
dispatch: (method: string, params: any) => Promise<any>;
close?: () => void;
onconnect: () => void;
dispatch: (method: string, params: any) => Promise<any>;
onclose: () => void;
};
@ -82,6 +83,7 @@ export class HttpServer {
this._wsGuid = guid || createGuid();
const wss = new wsServer({ server: this._server, path: '/' + this._wsGuid });
wss.on('connection', ws => {
transport.onconnect();
transport.sendEvent = (method, params) => ws.send(JSON.stringify({ method, params }));
transport.close = () => ws.close();
ws.on('message', async message => {

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 });
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,9 +84,11 @@ export class TestServerDispatcher implements TestServerInterface {
private _closeOnDisconnect = false;
private _populateDependenciesOnList = false;
constructor(configLocation: ConfigLocation) {
constructor(configLocation: ConfigLocation, configCLIOverrides: ConfigCLIOverrides) {
this._configLocation = configLocation;
this._configCLIOverrides = configCLIOverrides;
this.transport = {
onconnect: () => {},
dispatch: (method, params) => (this as any)[method](params),
onclose: () => {
if (this._closeOnDisconnect)
@ -144,11 +149,8 @@ export class TestServerDispatcher implements TestServerInterface {
async runGlobalSetup(params: Parameters<TestServerInterface['runGlobalSetup']>[0]): ReturnType<TestServerInterface['runGlobalSetup']> {
await this.runGlobalTeardown();
const overrides: ConfigCLIOverrides = {
outputDir: params.outputDir,
};
const { reporter, report } = await this._collectingInternalReporter(new ListReporter());
const config = await this._loadConfigOrReportError(reporter, overrides);
const config = await this._loadConfigOrReportError(reporter, this._configCLIOverrides);
if (!config)
return { status: 'failed', report };
@ -238,9 +240,9 @@ export class TestServerDispatcher implements TestServerInterface {
config?: FullConfigInternal,
}> {
const overrides: ConfigCLIOverrides = {
...this._configCLIOverrides,
repeatEach: 1,
retries: 0,
outputDir: params.outputDir,
};
const { reporter, report } = await this._collectingInternalReporter();
const config = await this._loadConfigOrReportError(reporter, overrides);
@ -294,6 +296,7 @@ export class TestServerDispatcher implements TestServerInterface {
private async _innerRunTests(params: Parameters<TestServerInterface['runTests']>[0]): ReturnType<TestServerInterface['runTests']> {
await this.stopTests();
const overrides: ConfigCLIOverrides = {
...this._configCLIOverrides,
repeatEach: 1,
retries: 0,
preserveOutputDir: true,
@ -306,7 +309,6 @@ export class TestServerDispatcher implements TestServerInterface {
_optionContextReuseMode: params.reuseContext ? 'when-possible' : undefined,
_optionConnectOptions: params.connectWsEndpoint ? { wsEndpoint: params.connectWsEndpoint } : undefined,
},
outputDir: params.outputDir,
updateSnapshots: params.updateSnapshots,
workers: params.workers,
};
@ -423,9 +425,9 @@ export class TestServerDispatcher implements TestServerInterface {
}
}
export async function runUIMode(configFile: string | undefined, options: TraceViewerServerOptions & TraceViewerRedirectOptions): Promise<reporterTypes.FullResult['status'] | 'restarted'> {
export async function runUIMode(configFile: string | undefined, configCLIOverrides: ConfigCLIOverrides, options: TraceViewerServerOptions & TraceViewerRedirectOptions): Promise<reporterTypes.FullResult['status'] | 'restarted'> {
const configLocation = resolveConfigLocation(configFile);
return await innerRunTestServer(configLocation, options, async (server: HttpServer, cancelPromise: ManualPromise<void>) => {
return await innerRunTestServer(configLocation, configCLIOverrides, options, async (server: HttpServer, cancelPromise: ManualPromise<void>) => {
await installRootRedirect(server, [], { ...options, webApp: 'uiMode.html' });
if (options.host !== undefined || options.port !== undefined) {
await openTraceInBrowser(server.urlPrefix('human-readable'));
@ -441,18 +443,18 @@ export async function runUIMode(configFile: string | undefined, options: TraceVi
});
}
export async function runTestServer(configFile: string | undefined, options: { host?: string, port?: number }): Promise<reporterTypes.FullResult['status'] | 'restarted'> {
export async function runTestServer(configFile: string | undefined, configCLIOverrides: ConfigCLIOverrides, options: { host?: string, port?: number }): Promise<reporterTypes.FullResult['status'] | 'restarted'> {
const configLocation = resolveConfigLocation(configFile);
return await innerRunTestServer(configLocation, options, async server => {
return await innerRunTestServer(configLocation, configCLIOverrides, options, async server => {
// eslint-disable-next-line no-console
console.log('Listening on ' + server.urlPrefix('precise').replace('http:', 'ws:') + '/' + server.wsGuid());
});
}
async function innerRunTestServer(configLocation: ConfigLocation, options: { host?: string, port?: number }, openUI: (server: HttpServer, cancelPromise: ManualPromise<void>, configLocation: ConfigLocation) => Promise<void>): Promise<reporterTypes.FullResult['status'] | 'restarted'> {
async function innerRunTestServer(configLocation: ConfigLocation, configCLIOverrides: ConfigCLIOverrides, options: { host?: string, port?: number }, openUI: (server: HttpServer, cancelPromise: ManualPromise<void>, configLocation: ConfigLocation) => Promise<void>): Promise<reporterTypes.FullResult['status'] | 'restarted'> {
if (restartWithExperimentalTsEsm(undefined, true))
return 'restarted';
const testServer = new TestServer(configLocation);
const testServer = new TestServer(configLocation, configCLIOverrides);
const cancelPromise = new ManualPromise<void>();
const sigintWatcher = new SigIntWatcher();
process.stdin.on('close', () => gracefullyProcessExitDoNotHang(0));

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

@ -1526,7 +1526,7 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, EventT
setOffline(params: BrowserContextSetOfflineParams, metadata?: CallMetadata): Promise<BrowserContextSetOfflineResult>;
storageState(params?: BrowserContextStorageStateParams, metadata?: CallMetadata): Promise<BrowserContextStorageStateResult>;
pause(params?: BrowserContextPauseParams, metadata?: CallMetadata): Promise<BrowserContextPauseResult>;
recorderSupplementEnable(params: BrowserContextRecorderSupplementEnableParams, metadata?: CallMetadata): Promise<BrowserContextRecorderSupplementEnableResult>;
enableRecorder(params: BrowserContextEnableRecorderParams, metadata?: CallMetadata): Promise<BrowserContextEnableRecorderResult>;
newCDPSession(params: BrowserContextNewCDPSessionParams, metadata?: CallMetadata): Promise<BrowserContextNewCDPSessionResult>;
harStart(params: BrowserContextHarStartParams, metadata?: CallMetadata): Promise<BrowserContextHarStartResult>;
harExport(params: BrowserContextHarExportParams, metadata?: CallMetadata): Promise<BrowserContextHarExportResult>;
@ -1766,9 +1766,10 @@ export type BrowserContextStorageStateResult = {
export type BrowserContextPauseParams = {};
export type BrowserContextPauseOptions = {};
export type BrowserContextPauseResult = void;
export type BrowserContextRecorderSupplementEnableParams = {
export type BrowserContextEnableRecorderParams = {
language?: string,
mode?: 'inspecting' | 'recording',
codegenMode?: 'actions' | 'trace-events',
pauseOnNextStatement?: boolean,
testIdAttributeName?: string,
launchOptions?: any,
@ -1778,9 +1779,10 @@ export type BrowserContextRecorderSupplementEnableParams = {
outputFile?: string,
omitCallTracking?: boolean,
};
export type BrowserContextRecorderSupplementEnableOptions = {
export type BrowserContextEnableRecorderOptions = {
language?: string,
mode?: 'inspecting' | 'recording',
codegenMode?: 'actions' | 'trace-events',
pauseOnNextStatement?: boolean,
testIdAttributeName?: string,
launchOptions?: any,
@ -1790,7 +1792,7 @@ export type BrowserContextRecorderSupplementEnableOptions = {
outputFile?: string,
omitCallTracking?: boolean,
};
export type BrowserContextRecorderSupplementEnableResult = void;
export type BrowserContextEnableRecorderResult = void;
export type BrowserContextNewCDPSessionParams = {
page?: PageChannel,
frame?: FrameChannel,
@ -3769,7 +3771,6 @@ export type RouteRedirectNavigationRequestOptions = {
export type RouteRedirectNavigationRequestResult = void;
export type RouteAbortParams = {
errorCode?: string,
requestUrl: string,
};
export type RouteAbortOptions = {
errorCode?: string,
@ -3780,7 +3781,6 @@ export type RouteContinueParams = {
method?: string,
headers?: NameValue[],
postData?: Binary,
requestUrl: string,
isFallback: boolean,
};
export type RouteContinueOptions = {
@ -3796,7 +3796,6 @@ export type RouteFulfillParams = {
body?: string,
isBase64?: boolean,
fetchResponseUid?: string,
requestUrl: string,
};
export type RouteFulfillOptions = {
status?: number,

View file

@ -1187,7 +1187,7 @@ BrowserContext:
pause:
experimental: True
recorderSupplementEnable:
enableRecorder:
experimental: True
parameters:
language: string?
@ -1196,6 +1196,11 @@ BrowserContext:
literals:
- inspecting
- recording
codegenMode:
type: enum?
literals:
- actions
- trace-events
pauseOnNextStatement: boolean?
testIdAttributeName: string?
launchOptions: json?
@ -2941,7 +2946,6 @@ Route:
abort:
parameters:
errorCode: string?
requestUrl: string
continue:
parameters:
@ -2951,7 +2955,6 @@ Route:
type: array?
items: NameValue
postData: binary?
requestUrl: string
isFallback: boolean
fulfill:
@ -2964,7 +2967,6 @@ Route:
body: string?
isBase64: boolean?
fetchResponseUid: string?
requestUrl: string
WebSocketRoute:

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

@ -47,12 +47,14 @@ async function loadTrace(traceUrl: string, traceFileName: string | null, clientI
}
set.add(traceUrl);
const isRecorderMode = traceUrl.includes('/playwright-recorder-trace-');
const traceModel = new TraceModel();
try {
// Allow 10% to hop from sw to page.
const [fetchProgress, unzipProgress] = splitProgress(progress, [0.5, 0.4, 0.1]);
const backend = traceUrl.endsWith('json') ? new FetchTraceModelBackend(traceUrl) : new ZipTraceModelBackend(traceUrl, fetchProgress);
await traceModel.load(backend, unzipProgress);
await traceModel.load(backend, isRecorderMode, unzipProgress);
} catch (error: any) {
// eslint-disable-next-line no-console
console.error(error);

View file

@ -15,7 +15,7 @@
*/
import { parseClientSideCallMetadata } from '../../../packages/playwright-core/src/utils/isomorphic/traceUtils';
import type { ContextEntry } from './entries';
import type { ActionEntry, ContextEntry } from './entries';
import { createEmptyContext } from './entries';
import { SnapshotStorage } from './snapshotStorage';
import { TraceModernizer } from './traceModernizer';
@ -38,7 +38,7 @@ export class TraceModel {
constructor() {
}
async load(backend: TraceModelBackend, unzipProgress: (done: number, total: number) => void) {
async load(backend: TraceModelBackend, isRecorderMode: boolean, unzipProgress: (done: number, total: number) => void) {
this._backend = backend;
const ordinals: string[] = [];
@ -72,7 +72,8 @@ export class TraceModel {
modernizer.appendTrace(network);
unzipProgress(++done, total);
contextEntry.actions = modernizer.actions().sort((a1, a2) => a1.startTime - a2.startTime);
const actions = modernizer.actions().sort((a1, a2) => a1.startTime - a2.startTime);
contextEntry.actions = isRecorderMode ? collapseActionsForRecorder(actions) : actions;
if (!backend.isLive()) {
// Terminate actions w/o after event gracefully.
@ -133,3 +134,19 @@ function stripEncodingFromContentType(contentType: string) {
return charset[1];
return contentType;
}
function collapseActionsForRecorder(actions: ActionEntry[]): ActionEntry[] {
const result: ActionEntry[] = [];
for (const action of actions) {
const lastAction = result[result.length - 1];
const isSameAction = lastAction && lastAction.method === action.method && lastAction.pageId === action.pageId;
const isSameSelector = lastAction && 'selector' in lastAction.params && 'selector' in action.params && action.params.selector === lastAction.params.selector;
const shouldMerge = isSameAction && (action.method === 'goto' || (action.method === 'fill' && isSameSelector));
if (!shouldMerge) {
result.push(action);
continue;
}
result[result.length - 1] = action;
}
return result;
}

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

@ -36,6 +36,8 @@
.call-section {
padding-left: 6px;
padding-top: 2px;
margin-top: 2px;
font-weight: bold;
text-transform: uppercase;
font-size: 10px;
@ -53,9 +55,8 @@
align-items: center;
text-overflow: ellipsis;
overflow: hidden;
line-height: 18px;
line-height: 20px;
white-space: nowrap;
max-height: 18px;
}
.call-line:not(:hover) .toolbar-button.copy {
@ -64,7 +65,8 @@
.call-line .toolbar-button.copy {
margin-left: 5px;
transform: scale(0.8);
margin-top: -2px;
margin-bottom: -2px;
}
.call-value {

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,54 +14,52 @@
limitations under the License.
*/
import * as React from 'react';
import './recorderView.css';
import { MultiTraceModel } from './modelUtil';
import type { SourceLocation } from './modelUtil';
import { Workbench } from './workbench';
import type { Language } from '@isomorphic/locatorGenerators';
import type { Mode, Source } from '@recorder/recorderTypes';
import { SplitView } from '@web/components/splitView';
import type { TabbedPaneTabModel } from '@web/components/tabbedPane';
import { TabbedPane } from '@web/components/tabbedPane';
import { sha1, useSetting } from '@web/uiUtils';
import * as React from 'react';
import type { ContextEntry } from '../entries';
import type { Boundaries } from '../geometry';
import { ActionList } from './actionList';
import { ConsoleTab, useConsoleTabModel } from './consoleTab';
import { InspectorTab } from './inspectorTab';
import type * as modelUtil from './modelUtil';
import type { SourceLocation } from './modelUtil';
import { MultiTraceModel } from './modelUtil';
import { NetworkTab, useNetworkTabModel } from './networkTab';
import './recorderView.css';
import { collectSnapshots, extendSnapshot, SnapshotView } from './snapshotTab';
import { SourceTab } from './sourceTab';
import { Toolbar } from '@web/components/toolbar';
import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton';
import { toggleTheme } from '@web/theme';
import { SourceChooser } from '@web/components/sourceChooser';
const searchParams = new URLSearchParams(window.location.search);
const guid = searchParams.get('ws');
const trace = searchParams.get('trace') + '.json';
const traceLocation = searchParams.get('trace') + '.json';
export const RecorderView: React.FunctionComponent = () => {
const [connection, setConnection] = React.useState<Connection | null>(null);
const [sources, setSources] = React.useState<Source[]>([]);
const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean, sha1: string } | undefined>();
const [mode, setMode] = React.useState<Mode>('none');
const [counter, setCounter] = React.useState(0);
const pollTimer = React.useRef<NodeJS.Timeout | null>(null);
React.useEffect(() => {
const wsURL = new URL(`../${guid}`, window.location.toString());
wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:');
const webSocket = new WebSocket(wsURL.toString());
setConnection(new Connection(webSocket, { setSources }));
setConnection(new Connection(webSocket, { setMode, setSources }));
return () => {
webSocket.close();
};
}, []);
React.useEffect(() => {
if (!connection)
return;
connection.setMode('recording');
}, [connection]);
window.playwrightSourcesEchoForTest = sources;
return <div className='vbox workbench-loader'>
<TraceView
traceLocation={trace}
sources={sources} />
</div>;
};
export const TraceView: React.FC<{
traceLocation: string,
sources: Source[],
}> = ({ traceLocation, sources }) => {
const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>();
const [counter, setCounter] = React.useState(0);
const pollTimer = React.useRef<NodeJS.Timeout | null>(null);
React.useEffect(() => {
if (pollTimer.current)
clearTimeout(pollTimer.current);
@ -69,8 +67,9 @@ export const TraceView: React.FC<{
// Start polling running test.
pollTimer.current = setTimeout(async () => {
try {
const model = await loadSingleTraceFile(traceLocation);
setModel({ model, isLive: true });
const result = await loadSingleTraceFile(traceLocation);
if (result.sha1 !== model?.sha1)
setModel({ ...result, isLive: true });
} catch {
setModel(undefined);
} finally {
@ -81,10 +80,94 @@ export const TraceView: React.FC<{
if (pollTimer.current)
clearTimeout(pollTimer.current);
};
}, [counter, traceLocation]);
}, [counter, model]);
return <div className='vbox workbench-loader'>
<Workbench
key='workbench'
mode={mode}
setMode={mode => connection?.setMode(mode)}
model={model?.model}
sources={sources}
/>
</div>;
};
async function loadSingleTraceFile(url: string): Promise<{ model: MultiTraceModel, sha1: string }> {
const params = new URLSearchParams();
params.set('trace', url);
const response = await fetch(`contexts?${params.toString()}`);
const contextEntries = await response.json() as ContextEntry[];
const tokens: string[] = [];
for (const entry of contextEntries) {
entry.actions.forEach(a => tokens.push(a.type + '@' + a.startTime + '-' + a.endTime));
entry.events.forEach(e => tokens.push(e.type + '@' + e.time));
}
return { model: new MultiTraceModel(contextEntries), sha1: await sha1(tokens.join('|')) };
}
export const Workbench: React.FunctionComponent<{
mode: Mode,
setMode: (mode: Mode) => void,
model?: modelUtil.MultiTraceModel,
sources: Source[],
}> = ({ mode, setMode, model, sources }) => {
const [fileId, setFileId] = React.useState<string | undefined>();
const [selectedCallId, setSelectedCallId] = React.useState<string | undefined>(undefined);
const [selectedPropertiesTab, setSelectedPropertiesTab] = useSetting<string>('recorderPropertiesTab', 'source');
const [isInspecting, setIsInspectingState] = React.useState(false);
const [highlightedLocator, setHighlightedLocator] = React.useState<string>('');
const [selectedTime, setSelectedTime] = React.useState<Boundaries | undefined>();
const sourceModel = React.useRef(new Map<string, modelUtil.SourceModel>());
const setSelectedAction = React.useCallback((action: modelUtil.ActionTraceEventInContext | undefined) => {
setSelectedCallId(action?.callId);
}, []);
const selectedAction = React.useMemo(() => {
return model?.actions.find(a => a.callId === selectedCallId);
}, [model, selectedCallId]);
const onActionSelected = React.useCallback((action: modelUtil.ActionTraceEventInContext) => {
setSelectedAction(action);
}, [setSelectedAction]);
const selectPropertiesTab = React.useCallback((tab: string) => {
setSelectedPropertiesTab(tab);
if (tab !== 'inspector')
setIsInspectingState(false);
}, [setSelectedPropertiesTab]);
const setIsInspecting = React.useCallback((value: boolean) => {
if (!isInspecting && value)
selectPropertiesTab('inspector');
setIsInspectingState(value);
}, [setIsInspectingState, selectPropertiesTab, isInspecting]);
const locatorPicked = React.useCallback((locator: string) => {
setHighlightedLocator(locator);
selectPropertiesTab('inspector');
}, [selectPropertiesTab]);
const consoleModel = useConsoleTabModel(model, selectedTime);
const networkModel = useNetworkTabModel(model, selectedTime);
const sdkLanguage = model?.sdkLanguage || 'javascript';
const inspectorTab: TabbedPaneTabModel = {
id: 'inspector',
title: 'Locator',
render: () => <InspectorTab
sdkLanguage={sdkLanguage}
setIsInspecting={setIsInspecting}
highlightedLocator={highlightedLocator}
setHighlightedLocator={setHighlightedLocator} />,
};
const source = React.useMemo(() => sources.find(s => s.id === fileId) || sources[0], [sources, fileId]);
const fallbackLocation = React.useMemo(() => {
if (!sources.length)
if (!source)
return undefined;
const fallbackLocation: SourceLocation = {
file: '',
@ -92,36 +175,178 @@ export const TraceView: React.FC<{
column: 0,
source: {
errors: [],
content: sources[0].text
content: source.text
}
};
return fallbackLocation;
}, [sources]);
}, [source]);
return <Workbench
key='workbench'
model={model?.model}
showSourcesFirst={true}
fallbackLocation={fallbackLocation}
const sourceTab: TabbedPaneTabModel = {
id: 'source',
title: 'Source',
render: () => <SourceTab
sources={sourceModel.current}
stackFrameLocation={'right'}
fallbackLocation={fallbackLocation}
/>
};
const consoleTab: TabbedPaneTabModel = {
id: 'console',
title: 'Console',
count: consoleModel.entries.length,
render: () => <ConsoleTab
consoleModel={consoleModel}
boundaries={boundaries}
selectedTime={selectedTime}
onAccepted={m => setSelectedTime({ minimum: m.timestamp, maximum: m.timestamp })}
/>
};
const networkTab: TabbedPaneTabModel = {
id: 'network',
title: 'Network',
count: networkModel.resources.length,
render: () => <NetworkTab boundaries={boundaries} networkModel={networkModel} />
};
const tabs: TabbedPaneTabModel[] = [
sourceTab,
inspectorTab,
consoleTab,
networkTab,
];
const { boundaries } = React.useMemo(() => {
const boundaries = { minimum: model?.startTime || 0, maximum: model?.endTime || 30000 };
if (boundaries.minimum > boundaries.maximum) {
boundaries.minimum = 0;
boundaries.maximum = 30000;
}
// Leave some nice free space on the right hand side.
boundaries.maximum += (boundaries.maximum - boundaries.minimum) / 20;
return { boundaries };
}, [model]);
const actionList = <ActionList
sdkLanguage={sdkLanguage}
actions={model?.actions || []}
selectedAction={model ? selectedAction : undefined}
selectedTime={selectedTime}
setSelectedTime={setSelectedTime}
onSelected={onActionSelected}
revealConsole={() => selectPropertiesTab('console')}
isLive={true}
/>;
const actionsTab: TabbedPaneTabModel = {
id: 'actions',
title: 'Actions',
component: actionList,
};
const toolbar = <Toolbar sidebarBackground>
<div style={{ width: 4 }}></div>
<ToolbarButton icon='circle-large-filled' title='Record' toggled={mode === 'recording'} onClick={() => {
setMode(mode === 'recording' ? 'standby' : 'recording');
}}>Record</ToolbarButton>
<ToolbarSeparator />
<ToolbarButton icon='inspect' title='Pick locator' toggled={isInspecting} onClick={() => {
setIsInspecting(!isInspecting);
}} />
<ToolbarButton icon='eye' title='Assert visibility' onClick={() => {
}} />
<ToolbarButton icon='whole-word' title='Assert text' onClick={() => {
}} />
<ToolbarButton icon='symbol-constant' title='Assert value' onClick={() => {
}} />
<ToolbarSeparator />
<ToolbarButton icon='files' title='Copy' onClick={() => {
}} />
<div style={{ flex: 'auto' }}></div>
<div>Target:</div>
<SourceChooser fileId={fileId} sources={sources} setFileId={fileId => {
setFileId(fileId);
}} />
<ToolbarButton icon='clear-all' title='Clear' onClick={() => {
}}></ToolbarButton>
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}></ToolbarButton>
</Toolbar>;
const sidebarTabbedPane = <TabbedPane tabs={[actionsTab]} />;
const propertiesTabbedPane = <TabbedPane
tabs={tabs}
selectedTab={selectedPropertiesTab}
setSelectedTab={selectPropertiesTab}
/>;
const snapshotView = <SnapshotContainer
sdkLanguage={sdkLanguage}
action={selectedAction}
testIdAttributeName={model?.testIdAttributeName || 'data-testid'}
isInspecting={isInspecting}
setIsInspecting={setIsInspecting}
highlightedLocator={highlightedLocator}
locatorPicked={locatorPicked} />;
return <div className='vbox workbench'>
<SplitView
sidebarSize={250}
orientation={'horizontal'}
settingName='recorderActionListSidebar'
sidebarIsFirst
main={<SplitView
sidebarSize={250}
orientation='vertical'
settingName='recorderPropertiesSidebar'
main={<div className='vbox'>
{toolbar}
{snapshotView}
</div>}
sidebar={propertiesTabbedPane}
/>}
sidebar={sidebarTabbedPane}
/>
</div>;
};
async function loadSingleTraceFile(url: string): Promise<MultiTraceModel> {
const params = new URLSearchParams();
params.set('trace', url);
const response = await fetch(`contexts?${params.toString()}`);
const contextEntries = await response.json() as ContextEntry[];
return new MultiTraceModel(contextEntries);
}
const SnapshotContainer: React.FunctionComponent<{
sdkLanguage: Language,
action: modelUtil.ActionTraceEventInContext | undefined,
testIdAttributeName?: string,
isInspecting: boolean,
highlightedLocator: string,
setIsInspecting: (value: boolean) => void,
locatorPicked: (locator: string) => void,
}> = ({ sdkLanguage, action, testIdAttributeName, isInspecting, setIsInspecting, highlightedLocator, locatorPicked }) => {
const snapshot = React.useMemo(() => {
const snapshot = collectSnapshots(action);
return snapshot.action || snapshot.after || snapshot.before;
}, [action]);
const snapshotUrls = React.useMemo(() => {
return snapshot ? extendSnapshot(snapshot) : undefined;
}, [snapshot]);
return <SnapshotView
sdkLanguage={sdkLanguage}
testIdAttributeName={testIdAttributeName || 'data-testid'}
isInspecting={isInspecting}
setIsInspecting={setIsInspecting}
highlightedLocator={highlightedLocator}
setHighlightedLocator={locatorPicked}
snapshotUrls={snapshotUrls} />;
};
type ConnectionOptions = {
setSources: (sources: Source[]) => void;
setMode: (mode: Mode) => void;
};
class Connection {
private _lastId = 0;
private _webSocket: WebSocket;
private _callbacks = new Map<number, { resolve: (arg: any) => void, reject: (arg: Error) => void }>();
private _options: { setSources: (sources: Source[]) => void; };
private _options: ConnectionOptions;
constructor(webSocket: WebSocket, options: { setSources: (sources: Source[]) => void }) {
constructor(webSocket: WebSocket, options: ConnectionOptions) {
this._webSocket = webSocket;
this._callbacks = new Map();
this._options = options;
@ -165,6 +390,11 @@ class Connection {
if (method === 'setSources') {
const { sources } = params as { sources: Source[] };
this._options.setSources(sources);
window.playwrightSourcesEchoForTest = sources;
}
if (method === 'setMode') {
const { mode } = params as { mode: Mode };
this._options.setMode(mode);
}
}
}

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') || '/',
@ -105,7 +104,6 @@ export const UIModeView: React.FC<{}> = ({
const [singleWorker, setSingleWorker] = React.useState(queryParams.workers === '1');
const [showBrowser, setShowBrowser] = React.useState(queryParams.headed);
const [updateSnapshots, setUpdateSnapshots] = React.useState(queryParams.updateSnapshots === 'all');
const [showRouteActions, setShowRouteActions] = useSetting('show-route-actions', true);
const [darkMode, setDarkMode] = useDarkModeSetting();
const [showScreenshot, setShowScreenshot] = useSetting('screenshot-instead-of-snapshot', false);
@ -188,14 +186,12 @@ export const UIModeView: React.FC<{}> = ({
interceptStdio: true,
watchTestDirs: true
});
const { status, report } = await testServerConnection.runGlobalSetup({
outputDir: queryParams.outputDir,
});
const { status, report } = await testServerConnection.runGlobalSetup({});
teleSuiteUpdater.processGlobalReport(report);
if (status !== 'passed')
return;
const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert, outputDir: queryParams.outputDir });
const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert });
teleSuiteUpdater.processListReport(result.report);
testServerConnection.onReport(params => {
@ -297,7 +293,6 @@ export const UIModeView: React.FC<{}> = ({
workers: singleWorker ? '1' : (queryParams.workers === '1' ? undefined : queryParams.workers),
timeout: queryParams.timeout,
headed: showBrowser,
outputDir: queryParams.outputDir,
updateSnapshots: updateSnapshots ? 'all' : queryParams.updateSnapshots,
reporters: queryParams.reporters,
trace: 'on',
@ -320,7 +315,7 @@ export const UIModeView: React.FC<{}> = ({
commandQueue.current = commandQueue.current.then(async () => {
setIsLoading(true);
try {
const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert, outputDir: queryParams.outputDir });
const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert });
teleSuiteUpdater.processListReport(result.report);
} catch (e) {
// eslint-disable-next-line no-console
@ -526,7 +521,6 @@ export const UIModeView: React.FC<{}> = ({
</Toolbar>
{settingsVisible && <SettingsView settings={[
{ value: darkMode, set: setDarkMode, title: 'Dark mode' },
{ value: showRouteActions, set: setShowRouteActions, title: 'Show route actions' },
{ value: showScreenshot, set: setShowScreenshot, title: 'Show screenshot instead of snapshot' },
]} />}
</div>

View file

@ -24,9 +24,8 @@ import type { ErrorDescription } from './errorsTab';
import type { ConsoleEntry } from './consoleTab';
import { ConsoleTab, useConsoleTabModel } from './consoleTab';
import type * as modelUtil from './modelUtil';
import { isRouteAction } from './modelUtil';
import { NetworkTab, useNetworkTabModel } from './networkTab';
import { SnapshotTab } from './snapshotTab';
import { SnapshotTabsView } from './snapshotTab';
import { SourceTab } from './sourceTab';
import { TabbedPane } from '@web/components/tabbedPane';
import type { TabbedPaneTabModel } from '@web/components/tabbedPane';
@ -50,6 +49,7 @@ export const Workbench: React.FunctionComponent<{
rootDir?: string,
fallbackLocation?: modelUtil.SourceLocation,
isLive?: boolean,
hideTimeline?: boolean,
status?: UITestStatus,
annotations?: { type: string; description?: string; }[];
inert?: boolean,
@ -57,11 +57,10 @@ export const Workbench: React.FunctionComponent<{
onOpenExternally?: (location: modelUtil.SourceLocation) => void,
revealSource?: boolean,
showSettings?: boolean,
}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => {
}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => {
const [selectedCallId, setSelectedCallId] = React.useState<string | undefined>(undefined);
const [revealedError, setRevealedError] = React.useState<ErrorDescription | undefined>(undefined);
const [highlightedAction, setHighlightedAction] = React.useState<modelUtil.ActionTraceEventInContext | undefined>();
const [highlightedCallId, setHighlightedCallId] = React.useState<string | undefined>();
const [highlightedEntry, setHighlightedEntry] = React.useState<Entry | undefined>();
const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState<ConsoleEntry | undefined>();
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions');
@ -70,18 +69,21 @@ export const Workbench: React.FunctionComponent<{
const [highlightedLocator, setHighlightedLocator] = React.useState<string>('');
const [selectedTime, setSelectedTime] = React.useState<Boundaries | undefined>();
const [sidebarLocation, setSidebarLocation] = useSetting<'bottom' | 'right'>('propertiesSidebarLocation', 'bottom');
const [showRouteActions, setShowRouteActions] = useSetting('show-route-actions', true);
const [showScreenshot, setShowScreenshot] = useSetting('screenshot-instead-of-snapshot', false);
const filteredActions = React.useMemo(() => {
return (model?.actions || []).filter(action => showRouteActions || !isRouteAction(action));
}, [model, showRouteActions]);
const setSelectedAction = React.useCallback((action: modelUtil.ActionTraceEventInContext | undefined) => {
setSelectedCallId(action?.callId);
setRevealedError(undefined);
}, []);
const highlightedAction = React.useMemo(() => {
return model?.actions.find(a => a.callId === highlightedCallId);
}, [model, highlightedCallId]);
const setHighlightedAction = React.useCallback((highlightedAction: modelUtil.ActionTraceEventInContext | undefined) => {
setHighlightedCallId(highlightedAction?.callId);
}, []);
const sources = React.useMemo(() => model?.sources || new Map<string, modelUtil.SourceModel>(), [model]);
React.useEffect(() => {
@ -291,7 +293,7 @@ export const Workbench: React.FunctionComponent<{
</div>}
<ActionList
sdkLanguage={sdkLanguage}
actions={filteredActions}
actions={model?.actions || []}
selectedAction={model ? selectedAction : undefined}
selectedTime={selectedTime}
setSelectedTime={setSelectedTime}
@ -311,13 +313,12 @@ export const Workbench: React.FunctionComponent<{
id: 'settings',
title: 'Settings',
component: <SettingsView settings={[
{ value: showRouteActions, set: setShowRouteActions, title: 'Show route actions' },
{ value: showScreenshot, set: setShowScreenshot, title: 'Show screenshot instead of snapshot' }
]}/>,
};
return <div className='vbox workbench' {...(inert ? { inert: 'true' } : {})}>
<Timeline
{!hideTimeline && <Timeline
model={model}
consoleEntries={consoleModel.entries}
boundaries={boundaries}
@ -328,7 +329,7 @@ export const Workbench: React.FunctionComponent<{
sdkLanguage={sdkLanguage}
selectedTime={selectedTime}
setSelectedTime={setSelectedTime}
/>
/>}
<SplitView
sidebarSize={250}
orientation={sidebarLocation === 'bottom' ? 'vertical' : 'horizontal'} settingName='propertiesSidebar'
@ -337,7 +338,7 @@ export const Workbench: React.FunctionComponent<{
orientation='horizontal'
sidebarIsFirst
settingName='actionListSidebar'
main={<SnapshotTab
main={<SnapshotTabsView
action={activeAction}
model={model}
sdkLanguage={sdkLanguage}

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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

@ -21,6 +21,7 @@ import * as playwrightLibrary from 'playwright-core';
export type TestModeWorkerOptions = {
mode: TestModeName;
codegenMode: 'trace-events' | 'actions';
};
export type TestModeTestFixtures = {
@ -48,6 +49,7 @@ export const testModeTest = test.extend<TestModeTestFixtures, TestModeWorkerOpti
await run(playwright);
await testMode.teardown();
}, { scope: 'worker' }],
codegenMode: ['actions', { scope: 'worker', option: true }],
toImplInWorkerScope: [async ({ playwright }, use) => {
await use((playwright as any)._toImpl);

View file

@ -71,10 +71,12 @@ class TraceViewerPage {
return await this.page.waitForSelector(`.list-view-entry:has-text("${action}") .action-icons`);
}
@step
async selectAction(title: string, ordinal: number = 0) {
await this.page.locator(`.action-title:has-text("${title}")`).nth(ordinal).click();
}
@step
async selectSnapshot(name: string) {
await this.page.click(`.snapshot-tab .tabbed-pane-tab-label:has-text("${name}")`);
}

View file

@ -160,7 +160,7 @@ export async function parseTraceRaw(file: string): Promise<{ events: any[], reso
export async function parseTrace(file: string): Promise<{ resources: Map<string, Buffer>, events: (EventTraceEvent | ConsoleMessageTraceEvent)[], actions: ActionTraceEvent[], apiNames: string[], traceModel: TraceModel, model: MultiTraceModel, actionTree: string[], errors: string[] }> {
const backend = new TraceBackend(file);
const traceModel = new TraceModel();
await traceModel.load(backend, () => {});
await traceModel.load(backend, false, () => {});
const model = new MultiTraceModel(traceModel.contextEntries);
const { rootItem } = buildActionTree(model.actions);
const actionTree: string[] = [];

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();
@ -337,7 +338,8 @@ await page.GetByRole(AriaRole.Button, new() { Name = "click me" }).ClickAsync();
}
});
test('should record open in a new tab with url', async ({ openRecorder, browserName }) => {
test('should record open in a new tab with url', async ({ openRecorder, browserName, codegenMode }) => {
test.skip(codegenMode === 'trace-events');
const { page, recorder } = await openRecorder();
await recorder.setContentAndWait(`<a href="about:blank?foo">link</a>`);
@ -425,7 +427,7 @@ await page1.GotoAsync("about:blank?foo");`);
page.click('button'),
recorder.waitForOutput('JavaScript', '.click(')
]);
expect(messages).toEqual(['mousedown', 'mouseup', 'click']);
await expect.poll(() => messages).toEqual(['mousedown', 'mouseup', 'click']);
});
test('should update hover model on action', async ({ openRecorder }) => {
@ -490,7 +492,8 @@ await page1.GotoAsync("about:blank?foo");`);
await recorder.waitForOutput('JavaScript', `await page.goto('${server.PREFIX}/page2.html');`);
});
test('should --save-trace', async ({ runCLI }, testInfo) => {
test('should --save-trace', async ({ runCLI, codegenMode }, testInfo) => {
test.skip(codegenMode === 'trace-events');
const traceFileName = testInfo.outputPath('trace.zip');
const cli = runCLI([`--save-trace=${traceFileName}`], {
autoExitWhen: ' ',
@ -499,7 +502,8 @@ await page1.GotoAsync("about:blank?foo");`);
expect(fs.existsSync(traceFileName)).toBeTruthy();
});
test('should save assets via SIGINT', async ({ runCLI, platform }, testInfo) => {
test('should save assets via SIGINT', async ({ runCLI, platform, codegenMode }, testInfo) => {
test.skip(codegenMode === 'trace-events');
test.skip(platform === 'win32', 'SIGINT not supported on Windows');
const traceFileName = testInfo.outputPath('trace.zip');
@ -546,18 +550,17 @@ await page.Locator("#textarea").FillAsync(\"Hello'\\"\`\\nWorld\");`);
expect(message.text()).toBe('Hello\'\"\`\nWorld');
});
});
test('should --test-id-attribute', async ({ openRecorder }) => {
const { page, recorder } = await openRecorder({ testIdAttributeName: 'my-test-id' });
await recorder.setContentAndWait(`<div my-test-id="foo">Hello</div>`);
await page.click('[my-test-id=foo]');
const sources = await recorder.waitForOutput('JavaScript', `page.getByTestId`);
expect.soft(sources.get('JavaScript')!.text).toContain(`await page.getByTestId('foo').click()`);
expect.soft(sources.get('Java')!.text).toContain(`page.getByTestId("foo").click()`);
expect.soft(sources.get('Python')!.text).toContain(`page.get_by_test_id("foo").click()`);
expect.soft(sources.get('Python Async')!.text).toContain(`await page.get_by_test_id("foo").click()`);
expect.soft(sources.get('C#')!.text).toContain(`await page.GetByTestId("foo").ClickAsync();`);
test('should --test-id-attribute', async ({ openRecorder }) => {
const { page, recorder } = await openRecorder({ testIdAttributeName: 'my-test-id' });
await recorder.setContentAndWait(`<div my-test-id="foo">Hello</div>`);
await page.click('[my-test-id=foo]');
const sources = await recorder.waitForOutput('JavaScript', `page.getByTestId`);
expect.soft(sources.get('JavaScript')!.text).toContain(`await page.getByTestId('foo').click()`);
expect.soft(sources.get('Java')!.text).toContain(`page.getByTestId("foo").click()`);
expect.soft(sources.get('Python')!.text).toContain(`page.get_by_test_id("foo").click()`);
expect.soft(sources.get('Python Async')!.text).toContain(`await page.get_by_test_id("foo").click()`);
expect.soft(sources.get('C#')!.text).toContain(`await page.GetByTestId("foo").ClickAsync();`);
});
});

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

@ -67,17 +67,30 @@ export const test = contextTest.extend<CLITestArgs>({
});
},
runCLI: async ({ childProcess, browserName, channel, headless, mode, launchOptions }, run, testInfo) => {
runCLI: async ({ childProcess, browserName, channel, headless, mode, launchOptions, codegenMode }, run, testInfo) => {
testInfo.skip(mode.startsWith('service'));
await run((cliArgs, { autoExitWhen } = {}) => {
return new CLIMock(childProcess, browserName, channel, headless, cliArgs, launchOptions.executablePath, autoExitWhen);
return new CLIMock(childProcess, {
browserName,
channel,
headless,
args: cliArgs,
executablePath: launchOptions.executablePath,
autoExitWhen,
codegenMode
});
});
},
openRecorder: async ({ context, recorderPageGetter }, run) => {
openRecorder: async ({ context, recorderPageGetter, codegenMode }, run) => {
await run(async (options?: { testIdAttributeName?: string }) => {
await (context as any)._enableRecorder({ language: 'javascript', mode: 'recording', ...options });
await (context as any)._enableRecorder({
language: 'javascript',
mode: 'recording',
codegenMode,
...options
});
const page = await context.newPage();
return { page, recorder: new Recorder(page, await recorderPageGetter()) };
});
@ -205,23 +218,24 @@ class Recorder {
class CLIMock {
process: TestChildProcess;
constructor(childProcess: CommonFixtures['childProcess'], browserName: string, channel: string | undefined, headless: boolean | undefined, args: string[], executablePath: string | undefined, autoExitWhen: string | undefined) {
constructor(childProcess: CommonFixtures['childProcess'], options: { browserName: string, channel: string | undefined, headless: boolean | undefined, args: string[], executablePath: string | undefined, autoExitWhen: string | undefined, codegenMode?: 'trace-events' | 'actions'}) {
const nodeArgs = [
'node',
path.join(__dirname, '..', '..', '..', 'packages', 'playwright-core', 'cli.js'),
'codegen',
...args,
`--browser=${browserName}`,
...options.args,
`--browser=${options.browserName}`,
];
if (channel)
nodeArgs.push(`--channel=${channel}`);
if (options.channel)
nodeArgs.push(`--channel=${options.channel}`);
this.process = childProcess({
command: nodeArgs,
env: {
PWTEST_CLI_AUTO_EXIT_WHEN: autoExitWhen,
PW_RECORDER_IS_TRACE_VIEWER: options.codegenMode === 'trace-events' ? '1' : undefined,
PWTEST_CLI_AUTO_EXIT_WHEN: options.autoExitWhen,
PWTEST_CLI_IS_UNDER_TEST: '1',
PWTEST_CLI_HEADLESS: headless ? '1' : undefined,
PWTEST_CLI_EXECUTABLE_PATH: executablePath,
PWTEST_CLI_HEADLESS: options.headless ? '1' : undefined,
PWTEST_CLI_EXECUTABLE_PATH: options.executablePath,
DEBUG: (process.env.DEBUG ?? '') + ',pw:browser*',
},
});

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

@ -107,43 +107,63 @@ for (const browserName of browserNames) {
console.error(`Using executable at ${executablePath}`);
const devtools = process.env.DEVTOOLS === '1';
const testIgnore: RegExp[] = browserNames.filter(b => b !== browserName).map(b => new RegExp(b));
for (const folder of ['library', 'page']) {
config.projects.push({
name: `${browserName}-${folder}`,
testDir: path.join(testDir, folder),
testIgnore,
snapshotPathTemplate: `{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-${browserName}{ext}`,
use: {
mode,
browserName,
headless: !headed,
channel,
video: video ? 'on' : undefined,
launchOptions: {
executablePath,
devtools
},
trace: trace ? 'on' : undefined,
const projectTemplate: typeof config.projects[0] = {
testIgnore,
snapshotPathTemplate: `{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-${browserName}{ext}`,
use: {
mode,
browserName,
headless: !headed,
channel,
video: video ? 'on' : undefined,
launchOptions: {
executablePath,
devtools
},
metadata: {
platform: process.platform,
docker: !!process.env.INSIDE_DOCKER,
headless: (() => {
if (process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW)
return 'headless-new';
if (headed)
return 'headed';
return 'headless';
})(),
browserName,
channel,
mode,
video: !!video,
trace: !!trace,
clock: process.env.PW_CLOCK ? 'clock-' + process.env.PW_CLOCK : undefined,
},
});
}
trace: trace ? 'on' : undefined,
},
metadata: {
platform: process.platform,
docker: !!process.env.INSIDE_DOCKER,
headless: (() => {
if (process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW)
return 'headless-new';
if (headed)
return 'headed';
return 'headless';
})(),
browserName,
channel,
mode,
video: !!video,
trace: !!trace,
clock: process.env.PW_CLOCK ? 'clock-' + process.env.PW_CLOCK : undefined,
}
};
config.projects.push({
name: `${browserName}-library`,
testDir: path.join(testDir, 'library'),
...projectTemplate,
});
config.projects.push({
name: `${browserName}-page`,
testDir: path.join(testDir, 'page'),
...projectTemplate,
});
config.projects.push({
name: `${browserName}-codegen-mode-trace`,
testDir: path.join(testDir, 'library'),
testMatch: '**/cli-codegen-*.spec.ts',
...projectTemplate,
use: {
...projectTemplate.use,
codegenMode: 'trace-events',
}
});
}
export default config;

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

@ -130,7 +130,9 @@ test('should complain about newer version of trace in old viewer', async ({ show
test('should properly synchronize local and remote time', async ({ showTraceViewer, asset }, testInfo) => {
const traceViewer = await showTraceViewer([asset('trace-remote-time-diff.zip')]);
// The total duration should be sub 10s, rather than 16h.
await expect(traceViewer.page.locator('.timeline-time').last()).toHaveText('8.5s');
await expect.poll(async () =>
parseInt(await traceViewer.page.locator('.timeline-time').last().innerText(), 10)
).toBeLessThan(10);
});
test('should contain action info', async ({ showTraceViewer }) => {
@ -964,28 +966,6 @@ test('should open two trace files of the same test', async ({ context, page, req
]);
});
test('should include requestUrl in route.fulfill', async ({ page, runAndTrace, browserName }) => {
await page.route('**/*', route => {
void route.fulfill({
status: 200,
headers: {
'content-type': 'text/html'
},
body: 'Hello there!'
});
});
const traceViewer = await runAndTrace(async () => {
await page.goto('http://test.com');
});
// Render snapshot, check expectations.
await traceViewer.selectAction('route.fulfill');
await traceViewer.page.locator('.tabbed-pane-tab-label', { hasText: 'Call' }).click();
const callLine = traceViewer.page.locator('.call-line');
await expect(callLine.getByText('status')).toContainText('200');
await expect(callLine.getByText('requestUrl')).toContainText('http://test.com');
});
test('should not crash with broken locator', async ({ page, runAndTrace, server }) => {
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/21832' });
const traceViewer = await runAndTrace(async () => {
@ -999,37 +979,6 @@ test('should not crash with broken locator', async ({ page, runAndTrace, server
await expect(header).toBeVisible();
});
test('should include requestUrl in route.continue', async ({ page, runAndTrace, server }) => {
await page.route('**/*', route => {
void route.continue({ url: server.EMPTY_PAGE });
});
const traceViewer = await runAndTrace(async () => {
await page.goto('http://test.com');
});
// Render snapshot, check expectations.
await traceViewer.selectAction('route.continue');
await traceViewer.page.locator('.tabbed-pane-tab-label', { hasText: 'Call' }).click();
const callLine = traceViewer.page.locator('.call-line');
await expect(callLine.getByText('requestUrl')).toContainText('http://test.com');
await expect(callLine.getByText(/^url:.*/)).toContainText(server.EMPTY_PAGE);
});
test('should include requestUrl in route.abort', async ({ page, runAndTrace, server }) => {
await page.route('**/*', route => {
void route.abort();
});
const traceViewer = await runAndTrace(async () => {
await page.goto('http://test.com').catch(() => {});
});
// Render snapshot, check expectations.
await traceViewer.selectAction('route.abort');
await traceViewer.page.locator('.tabbed-pane-tab-label', { hasText: 'Call' }).click();
const callLine = traceViewer.page.locator('.call-line');
await expect(callLine.getByText('requestUrl')).toContainText('http://test.com');
});
test('should serve overridden request', async ({ page, runAndTrace, server }) => {
server.setRoute('/custom.css', (req, res) => {
res.writeHead(200, {
@ -1416,7 +1365,7 @@ test('should show correct request start time', {
expect(parseMillis(start)).toBeLessThan(1000);
});
test('should allow hiding route actions', {
test('should not record route actions', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30970' },
}, async ({ page, runAndTrace, server }) => {
const traceViewer = await runAndTrace(async () => {
@ -1426,28 +1375,9 @@ test('should allow hiding route actions', {
await page.goto(server.EMPTY_PAGE);
});
// Routes are visible by default.
await expect(traceViewer.actionTitles).toHaveText([
/page.route/,
/page.goto.*empty.html/,
/route.fulfill/,
]);
await traceViewer.page.getByText('Settings').click();
await expect(traceViewer.page.getByRole('checkbox', { name: 'Show route actions' })).toBeChecked();
await traceViewer.page.getByRole('checkbox', { name: 'Show route actions' }).uncheck();
await traceViewer.page.getByText('Actions', { exact: true }).click();
await expect(traceViewer.actionTitles).toHaveText([
/page.goto.*empty.html/,
]);
await traceViewer.page.getByText('Settings').click();
await traceViewer.page.getByRole('checkbox', { name: 'Show route actions' }).check();
await traceViewer.page.getByText('Actions', { exact: true }).click();
await expect(traceViewer.actionTitles).toHaveText([
/page.route/,
/page.goto.*empty.html/,
/route.fulfill/,
]);
});
@ -1478,7 +1408,7 @@ test('should allow showing screenshots instead of snapshots', async ({ runAndTra
await page.waitForTimeout(1000); // ensure we could take a screenshot
});
const screenshot = traceViewer.page.getByAltText(`Screenshot of page.goto > Action`);
const screenshot = traceViewer.page.getByAltText(`Screenshot of page.goto`);
const snapshot = (await traceViewer.snapshotFrame('page.goto')).owner();
await expect(snapshot).toBeVisible();
await expect(screenshot).not.toBeVisible();

View file

@ -66,10 +66,9 @@ test('should collect trace with resources, but no js', async ({ context, page, s
test('should use the correct apiName for event driven callbacks', async ({ context, page, server }, testInfo) => {
await context.tracing.start();
// route.* calls should not be included in the trace
await page.route('**/empty.html', route => route.continue());
// page.goto -> page.route should be included in the trace since its handled.
await page.goto(server.PREFIX + '/empty.html');
// page.route -> internalContinue should not be included in the trace since it was handled by Playwright internally.
await page.goto(server.PREFIX + '/grid.html');
// The default internal dialog handler should not provide an action.
@ -87,7 +86,6 @@ test('should use the correct apiName for event driven callbacks', async ({ conte
expect(actions).toEqual([
'page.route',
'page.goto',
'route.continue',
'page.goto',
'page.evaluate',
'page.reload',

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

@ -117,6 +117,71 @@ it('clicking checkbox should activate it', async ({ page, browserName, headless,
expect(nodeName).toBe('INPUT');
});
it('tab should cycle between single input and browser', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/32339' }
}, async ({ page, browserName, headless }) => {
it.fixme(browserName === 'chromium' && (!headless || !!process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW),
'Chromium in headful mode keeps input focused.');
it.fixme(browserName !== 'chromium');
await page.setContent(`<label for="input1">input1</label>
<input id="input1">
<script>
{
window.focusEvents = [];
const input = document.getElementById('input1');
input.addEventListener('blur', () => focusEvents.push('blur'));
input.addEventListener('focus', () => focusEvents.push('focus'));
}
</script>`);
expect(await page.evaluate(() => document.activeElement.tagName)).toBe('BODY');
await page.keyboard.press('Tab');
expect(await page.evaluate(() => document.activeElement.id)).toBe('input1');
expect(await page.evaluate(() => (window as any).focusEvents)).toEqual(['focus']);
await page.keyboard.press('Tab');
expect(await page.evaluate(() => document.activeElement.tagName)).toBe('BODY');
expect(await page.evaluate(() => (window as any).focusEvents)).toEqual(['focus', 'blur']);
await page.keyboard.press('Tab');
expect(await page.evaluate(() => document.activeElement.id)).toBe('input1');
expect(await page.evaluate(() => (window as any).focusEvents)).toEqual(['focus', 'blur', 'focus']);
});
it('tab should cycle between document elements and browser', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/32339' }
}, async ({ page, browserName, headless }) => {
it.fixme(browserName === 'chromium' && (!headless || !!process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW),
'Chromium in headful mode keeps last input focused.');
it.fixme(browserName !== 'chromium');
await page.setContent(`
<input id="input1">
<input id="input2">
<script>
window.focusEvents = [];
{
const input = document.getElementById('input1');
input.addEventListener('blur', () => focusEvents.push('blur1'));
input.addEventListener('focus', () => focusEvents.push('focus1'));
}
{
const input = document.getElementById('input2');
input.addEventListener('blur', () => focusEvents.push('blur2'));
input.addEventListener('focus', () => focusEvents.push('focus2'));
}
</script>`);
expect(await page.evaluate(() => document.activeElement.tagName)).toBe('BODY');
await page.keyboard.press('Tab');
expect(await page.evaluate(() => document.activeElement.id)).toBe('input1');
expect(await page.evaluate(() => (window as any).focusEvents)).toEqual(['focus1']);
await page.keyboard.press('Tab');
expect(await page.evaluate(() => document.activeElement.id)).toBe('input2');
expect(await page.evaluate(() => (window as any).focusEvents)).toEqual(['focus1', 'blur1', 'focus2']);
await page.keyboard.press('Tab');
expect(await page.evaluate(() => document.activeElement.tagName)).toBe('BODY');
expect(await page.evaluate(() => (window as any).focusEvents)).toEqual(['focus1', 'blur1', 'focus2', 'blur2']);
await page.keyboard.press('Tab');
expect(await page.evaluate(() => document.activeElement.id)).toBe('input1');
expect(await page.evaluate(() => (window as any).focusEvents)).toEqual(['focus1', 'blur1', 'focus2', 'blur2', 'focus1']);
});
it('keeps focus on element when attempting to focus a non-focusable element', async ({ page }) => {
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/14254' });

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

@ -1068,4 +1068,38 @@ test('expect.extend should be immutable', async ({ runInlineTest }) => {
'foo',
'bar',
]);
});
});
test('expect.extend should fall back to legacy behavior', async ({ runInlineTest }) => {
const result = await runInlineTest({
'expect-test.spec.ts': `
import { test, expect } from '@playwright/test';
expect.extend({
toFoo() {
console.log('%%foo');
return { pass: true };
}
});
expect.extend({
toFoo() {
console.log('%%foo2');
return { pass: true };
}
});
expect.extend({
toBar() {
console.log('%%bar');
return { pass: true };
}
});
test('logs', () => {
expect().toFoo();
expect().toBar();
});
`
});
expect(result.outputLines).toEqual([
'foo2',
'bar',
]);
});

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

@ -1216,7 +1216,6 @@ test('should not nest top level expect into unfinished api calls ', {
' browserContext.newPage',
'page.route',
'page.goto',
'route.fetch',
'expect.toBeVisible',
'page.unrouteAll',
'After Hooks',

View file

@ -546,37 +546,6 @@ fixture | fixture: browser
`);
});
test('should not nest page.continue inside page.goto steps', async ({ runInlineTest }) => {
const result = await runInlineTest({
'reporter.ts': stepIndentReporter,
'playwright.config.ts': `module.exports = { reporter: './reporter', };`,
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('pass', async ({ page }) => {
await page.route('**/*', route => route.fulfill('<html></html>'));
await page.goto('http://localhost:1234');
});
`
}, { reporter: '' });
expect(result.exitCode).toBe(0);
expect(result.output).toBe(`
hook |Before Hooks
fixture | fixture: browser
pw:api | browserType.launch
fixture | fixture: context
pw:api | browser.newContext
fixture | fixture: page
pw:api | browserContext.newPage
pw:api |page.route @ a.test.ts:4
pw:api |page.goto(http://localhost:1234) @ a.test.ts:5
pw:api |route.fulfill @ a.test.ts:4
hook |After Hooks
fixture | fixture: page
fixture | fixture: context
`);
});
test('should not propagate errors from within toPass', async ({ runInlineTest }) => {
const result = await runInlineTest({
'reporter.ts': stepIndentReporter,

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;

Some files were not shown because too many files have changed in this diff Show more