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: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
channel: [bidi-chromium, bidi-firefox-beta] channel: [bidi-chromium, bidi-firefox-nightly]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
@ -38,8 +38,8 @@ jobs:
- run: npm run build - run: npm run build
- run: npx playwright install --with-deps chromium - run: npx playwright install --with-deps chromium
if: matrix.channel == 'bidi-chromium' if: matrix.channel == 'bidi-chromium'
- run: npx -y @puppeteer/browsers install firefox@beta - run: npx -y @puppeteer/browsers install firefox@nightly
if: matrix.channel == 'bidi-firefox-beta' if: matrix.channel == 'bidi-firefox-nightly'
- name: Run tests - name: Run tests
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run biditest -- --project=${{ matrix.channel }}* run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run biditest -- --project=${{ matrix.channel }}*
env: 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-%% ### option: Frame.click.timeout = %%-input-timeout-js-%%
* since: v1.8 * since: v1.8
### option: Frame.click.trial = %%-input-trial-%% ### option: Frame.click.trial = %%-input-trial-with-modifiers-%%
* since: v1.11 * since: v1.11
## async method: Frame.content ## 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-%% ### option: Frame.dblclick.timeout = %%-input-timeout-js-%%
* since: v1.8 * since: v1.8
### option: Frame.dblclick.trial = %%-input-trial-%% ### option: Frame.dblclick.trial = %%-input-trial-with-modifiers-%%
* since: v1.11 * since: v1.11
## async method: Frame.dispatchEvent ## 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-%% ### option: Frame.hover.timeout = %%-input-timeout-js-%%
* since: v1.8 * since: v1.8
### option: Frame.hover.trial = %%-input-trial-%% ### option: Frame.hover.trial = %%-input-trial-with-modifiers-%%
* since: v1.11 * since: v1.11
### option: Frame.hover.noWaitAfter = %%-input-no-wait-after-removed-%% ### 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-%% ### option: Frame.tap.timeout = %%-input-timeout-js-%%
* since: v1.8 * since: v1.8
### option: Frame.tap.trial = %%-input-trial-%% ### option: Frame.tap.trial = %%-input-trial-with-modifiers-%%
* since: v1.11 * since: v1.11
## async method: Frame.textContent ## async method: Frame.textContent

View file

@ -433,7 +433,7 @@ await page.Locator("canvas").ClickAsync(new() {
### option: Locator.click.timeout = %%-input-timeout-js-%% ### option: Locator.click.timeout = %%-input-timeout-js-%%
* since: v1.14 * since: v1.14
### option: Locator.click.trial = %%-input-trial-%% ### option: Locator.click.trial = %%-input-trial-with-modifiers-%%
* since: v1.14 * since: v1.14
## async method: Locator.count ## 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-%% ### option: Locator.dblclick.timeout = %%-input-timeout-js-%%
* since: v1.14 * since: v1.14
### option: Locator.dblclick.trial = %%-input-trial-%% ### option: Locator.dblclick.trial = %%-input-trial-with-modifiers-%%
* since: v1.14 * since: v1.14
## async method: Locator.dispatchEvent ## 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-%% ### option: Locator.hover.timeout = %%-input-timeout-js-%%
* since: v1.14 * since: v1.14
### option: Locator.hover.trial = %%-input-trial-%% ### option: Locator.hover.trial = %%-input-trial-with-modifiers-%%
* since: v1.14 * since: v1.14
### option: Locator.hover.noWaitAfter = %%-input-no-wait-after-removed-%% ### 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-%% ### option: Locator.tap.timeout = %%-input-timeout-js-%%
* since: v1.14 * since: v1.14
### option: Locator.tap.trial = %%-input-trial-%% ### option: Locator.tap.trial = %%-input-trial-with-modifiers-%%
* since: v1.14 * since: v1.14
## async method: Locator.textContent ## 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-%% ### option: Page.click.timeout = %%-input-timeout-js-%%
* since: v1.8 * since: v1.8
### option: Page.click.trial = %%-input-trial-%% ### option: Page.click.trial = %%-input-trial-with-modifiers-%%
* since: v1.11 * since: v1.11
## async method: Page.close ## 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-%% ### option: Page.dblclick.timeout = %%-input-timeout-js-%%
* since: v1.8 * since: v1.8
### option: Page.dblclick.trial = %%-input-trial-%% ### option: Page.dblclick.trial = %%-input-trial-with-modifiers-%%
* since: v1.11 * since: v1.11
## async method: Page.dispatchEvent ## 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-%% ### option: Page.hover.timeout = %%-input-timeout-js-%%
* since: v1.8 * since: v1.8
### option: Page.hover.trial = %%-input-trial-%% ### option: Page.hover.trial = %%-input-trial-with-modifiers-%%
* since: v1.11 * since: v1.11
### option: Page.hover.noWaitAfter = %%-input-no-wait-after-removed-%% ### 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-%% ### option: Page.tap.timeout = %%-input-timeout-js-%%
* since: v1.8 * since: v1.8
### option: Page.tap.trial = %%-input-trial-%% ### option: Page.tap.trial = %%-input-trial-with-modifiers-%%
* since: v1.11 * since: v1.11
## async method: Page.textContent ## 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. 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 ## input-source-position
- `sourcePosition` <[Object]> - `sourcePosition` <[Object]>
- `x` <[float]> - `x` <[float]>

View file

@ -85,7 +85,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: lts/*
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Install Playwright Browsers - name: Install Playwright Browsers
@ -214,7 +214,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: lts/*
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Run your tests - name: Run your tests
@ -319,7 +319,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: lts/*
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Install Playwright - name: Install Playwright
@ -434,7 +434,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: lts/*
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Install Playwright Browsers - 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 ## 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: 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/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: lts/*
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Install Playwright browsers - name: Install Playwright browsers
@ -118,7 +118,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: lts/*
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci

140
package-lock.json generated
View file

@ -1517,9 +1517,9 @@
"link": true "link": true
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz",
"integrity": "sha512-MmKSfaB9GX+zXl6E8z4koOr/xU63AMVleLEa64v7R0QF/ZloMs5vcD1sHgM64GXXS1csaJutG+ddtzcueI/BLg==", "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1529,9 +1529,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz",
"integrity": "sha512-zrt8ecH07PE3sB4jPOggweBjJMzI1JG5xI2DIsUbkA+7K+Gkjys6eV7i9pOenNSDJH3eOr/jLb/PzqtmdwDq5g==", "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1541,9 +1541,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz",
"integrity": "sha512-P0UxIOrKNBFTQaXTxOH4RxuEBVCgEA5UTNV6Yz7z9QHnUJ7eLX9reOd/NYMO3+XZO2cco19mXTxDMXxit4R/eQ==", "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1553,9 +1553,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz",
"integrity": "sha512-L1M0vKGO5ASKntqtsFEjTq/fD91vAqnzeaF6sfNAy55aD+Hi2pBI5DKwCO+UNDQHWsDViJLqshxOahXyLSh3EA==", "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1565,9 +1565,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz",
"integrity": "sha512-btVgIsCjuYFKUjopPoWiDqmoUXQDiW2A4C3Mtmp5vACm7/GnyuprqIDPNczeyR5W8rTXEbkmrJux7cJmD99D2g==", "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1577,9 +1577,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz",
"integrity": "sha512-zmjbSphplZlau6ZTkxd3+NMtE4UKVy7U4aVFMmHcgO5CUbw17ZP6QCgyxhzGaU/wFFdTfiojjbLG3/0p9HhAqA==", "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1589,9 +1589,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz",
"integrity": "sha512-nSZfcZtAnQPRZmUkUQwZq2OjQciR6tEoJaZVFvLHsj0MF6QhNMg0fQ6mUOsiCUpTqxTx0/O6gX0V/nYc7LrgPw==", "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1601,9 +1601,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz",
"integrity": "sha512-MnvSPGO8KJXIMGlQDYfvYS3IosFN2rKsvxRpPO2l2cum+Z3exiExLwVU+GExL96pn8IP+GdH8Tz70EpBhO0sIQ==", "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1613,9 +1613,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": { "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz",
"integrity": "sha512-+W+p/9QNDr2vE2AXU0qIy0qQE75E8RTwTwgqS2G5CRQ11vzq0tbnfBd6brWhS9bCRjAjepJe2fvvkvS3dno+iw==", "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -1625,9 +1625,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz",
"integrity": "sha512-yXH6K6KfqGXaxHrtr+Uoy+JpNlUlI46BKVyonGiaD74ravdnF9BUNC+vV+SIuB96hUMGShhKV693rF9QDfO6nQ==", "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -1637,9 +1637,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz",
"integrity": "sha512-R8cwY9wcnApN/KDYWTH4gV/ypvy9yZUHlbJvfaiXSB48JO3KpwSpjOGqO4jnGkLDSk1hgjYkTbTt6Q7uvPf8eg==", "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -1649,9 +1649,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz",
"integrity": "sha512-kZPbX/NOPh0vhS5sI+dR8L1bU2cSO9FgxwM8r7wHzGydzfSjLRCFAT87GR5U9scj2rhzN3JPYVC7NoBbl4FZ0g==", "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1661,9 +1661,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz",
"integrity": "sha512-S0Yq+xA1VEH66uiMNhijsWAafffydd2X5b77eLHfRmfLsRSpbiAWiRHV6DEpz6aOToPsgid7TI9rGd6zB1rhbg==", "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1673,9 +1673,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz",
"integrity": "sha512-9isNzeL34yquCPyerog+IMCNxKR8XYmGd0tHSV+OVx0TmE0aJOo9uw4fZfUuk2qxobP5sug6vNdZR6u7Mw7Q+Q==", "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1685,9 +1685,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz",
"integrity": "sha512-nMIdKnfZfzn1Vsk+RuOvl43ONTZXoAPUUxgcU0tXooqg4YrAqzfKzVenqqk2g5efWh46/D28cKFrOzDSW28gTA==", "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -1697,9 +1697,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz",
"integrity": "sha512-fOvu7PCQjAj4eWDEuD8Xz5gpzFqXzGlxHZozHP4b9Jxv9APtdxL6STqztDzMLuRXEc4UpXGGhx029Xgm91QBeA==", "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -5417,9 +5417,9 @@
"dev": true "dev": true
}, },
"node_modules/micromatch": { "node_modules/micromatch": {
"version": "4.0.7", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"braces": "^3.0.3", "braces": "^3.0.3",
@ -6313,9 +6313,9 @@
} }
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.21.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.3.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz",
"integrity": "sha512-7sqRtBNnEbcBtMeRVc6VRsJMmpI+JU1z9VTvW8D4gXIYQFz0aLcsE6rRkyghZkLfEgUZgVvOG7A5CVz/VW5GIA==", "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==",
"dependencies": { "dependencies": {
"@types/estree": "1.0.5" "@types/estree": "1.0.5"
}, },
@ -6327,22 +6327,22 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.21.3", "@rollup/rollup-android-arm-eabi": "4.22.4",
"@rollup/rollup-android-arm64": "4.21.3", "@rollup/rollup-android-arm64": "4.22.4",
"@rollup/rollup-darwin-arm64": "4.21.3", "@rollup/rollup-darwin-arm64": "4.22.4",
"@rollup/rollup-darwin-x64": "4.21.3", "@rollup/rollup-darwin-x64": "4.22.4",
"@rollup/rollup-linux-arm-gnueabihf": "4.21.3", "@rollup/rollup-linux-arm-gnueabihf": "4.22.4",
"@rollup/rollup-linux-arm-musleabihf": "4.21.3", "@rollup/rollup-linux-arm-musleabihf": "4.22.4",
"@rollup/rollup-linux-arm64-gnu": "4.21.3", "@rollup/rollup-linux-arm64-gnu": "4.22.4",
"@rollup/rollup-linux-arm64-musl": "4.21.3", "@rollup/rollup-linux-arm64-musl": "4.22.4",
"@rollup/rollup-linux-powerpc64le-gnu": "4.21.3", "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4",
"@rollup/rollup-linux-riscv64-gnu": "4.21.3", "@rollup/rollup-linux-riscv64-gnu": "4.22.4",
"@rollup/rollup-linux-s390x-gnu": "4.21.3", "@rollup/rollup-linux-s390x-gnu": "4.22.4",
"@rollup/rollup-linux-x64-gnu": "4.21.3", "@rollup/rollup-linux-x64-gnu": "4.22.4",
"@rollup/rollup-linux-x64-musl": "4.21.3", "@rollup/rollup-linux-x64-musl": "4.22.4",
"@rollup/rollup-win32-arm64-msvc": "4.21.3", "@rollup/rollup-win32-arm64-msvc": "4.22.4",
"@rollup/rollup-win32-ia32-msvc": "4.21.3", "@rollup/rollup-win32-ia32-msvc": "4.22.4",
"@rollup/rollup-win32-x64-msvc": "4.21.3", "@rollup/rollup-win32-x64-msvc": "4.22.4",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },

View file

@ -9,9 +9,9 @@
}, },
{ {
"name": "chromium-tip-of-tree", "name": "chromium-tip-of-tree",
"revision": "1261", "revision": "1263",
"installByDefault": false, "installByDefault": false,
"browserVersion": "131.0.6726.0" "browserVersion": "131.0.6736.0"
}, },
{ {
"name": "firefox", "name": "firefox",
@ -27,7 +27,7 @@
}, },
{ {
"name": "webkit", "name": "webkit",
"revision": "2080", "revision": "2082",
"installByDefault": true, "installByDefault": true,
"revisionOverrides": { "revisionOverrides": {
"mac10.14": "1446", "mac10.14": "1446",

View file

@ -397,7 +397,7 @@ async function launchContext(options: Options, extraOptions: LaunchOptions): Pro
process.stdout.write('\n-------------8<-------------\n'); process.stdout.write('\n-------------8<-------------\n');
const autoExitCondition = process.env.PWTEST_CLI_AUTO_EXIT_WHEN; const autoExitCondition = process.env.PWTEST_CLI_AUTO_EXIT_WHEN;
if (autoExitCondition && text.includes(autoExitCondition)) if (autoExitCondition && text.includes(autoExitCondition))
Promise.all(context.pages().map(async p => p.close())); closeBrowser();
}; };
// Make sure we exit abnormally when browser crashes. // Make sure we exit abnormally when browser crashes.
const logs: string[] = []; const logs: string[] = [];
@ -504,7 +504,7 @@ async function launchContext(options: Options, extraOptions: LaunchOptions): Pro
if (hasPage) if (hasPage)
return; return;
// Avoid the error when the last page is closed because the browser has been closed. // 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 () => { 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) { async function codegen(options: Options & { target: string, output?: string, testIdAttribute?: string }, url: string | undefined) {
const { target: language, output: outputFile, testIdAttribute: testIdAttributeName } = options; 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, { const { context, launchOptions, contextOptions } = await launchContext(options, {
headless: !!process.env.PWTEST_CLI_HEADLESS, headless: !!process.env.PWTEST_CLI_HEADLESS,
executablePath: process.env.PWTEST_CLI_EXECUTABLE_PATH, executablePath: process.env.PWTEST_CLI_EXECUTABLE_PATH,
@ -574,6 +574,7 @@ async function codegen(options: Options & { target: string, output?: string, tes
device: options.device, device: options.device,
saveStorage: options.saveStorage, saveStorage: options.saveStorage,
mode: 'recording', mode: 'recording',
codegenMode: process.env.PW_RECORDER_IS_TRACE_VIEWER ? 'trace-events' : 'actions',
testIdAttributeName, testIdAttributeName,
outputFile: outputFile ? path.resolve(outputFile) : undefined, 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, // If the page is closed or unrouteAll() was called without waiting and interception disabled,
// the method will throw an error - silence it. // the method will throw an error - silence it.
await route._innerContinue(true).catch(() => {}); await route._innerContinue(true /* isFallback */).catch(() => {});
} }
async _onWebSocketRoute(webSocketRoute: network.WebSocketRoute) { async _onWebSocketRoute(webSocketRoute: network.WebSocketRoute) {
@ -492,17 +492,8 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
await this._closedPromise; await this._closedPromise;
} }
async _enableRecorder(params: { async _enableRecorder(params: channels.BrowserContextEnableRecorderParams) {
language: string, await this._channel.enableRecorder(params);
launchOptions?: LaunchOptions,
contextOptions?: BrowserContextOptions,
device?: string,
saveStorage?: string,
mode?: 'recording' | 'inspecting',
testIdAttributeName?: string,
outputFile?: string,
}) {
await this._channel.recorderSupplementEnable(params);
} }
} }

View file

@ -40,6 +40,7 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
_logger: Logger | undefined; _logger: Logger | undefined;
readonly _instrumentation: ClientInstrumentation; readonly _instrumentation: ClientInstrumentation;
private _eventToSubscriptionMapping: Map<string, string> = new Map(); private _eventToSubscriptionMapping: Map<string, string> = new Map();
private _isInternalType = false;
_wasCollected: boolean = false; _wasCollected: boolean = false;
constructor(parent: ChannelOwner | Connection, type: string, guid: string, initializer: channels.InitializerTraits<T>) { 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; this._initializer = initializer;
} }
protected markAsInternalType() {
this._isInternalType = true;
}
_setEventToSubscriptionMapping(mapping: Map<string, string>) { _setEventToSubscriptionMapping(mapping: Map<string, string>) {
this._eventToSubscriptionMapping = mapping; this._eventToSubscriptionMapping = mapping;
} }
@ -173,7 +178,7 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
let apiName: string | undefined = stackTrace.apiName; let apiName: string | undefined = stackTrace.apiName;
const frames: channels.StackFrame[] = stackTrace.frames; const frames: channels.StackFrame[] = stackTrace.frames;
isInternal = isInternal || this._type === 'LocalUtils'; isInternal = isInternal || this._isInternalType;
if (isInternal) if (isInternal)
apiName = undefined; 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) { constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.LocalUtilsInitializer) {
super(parent, type, guid, initializer); super(parent, type, guid, initializer);
this.markAsInternalType();
this.devices = {}; this.devices = {};
for (const { name, descriptor } of initializer.deviceDescriptors) for (const { name, descriptor } of initializer.deviceDescriptors)
this.devices[name] = descriptor; 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) { constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.RouteInitializer) {
super(parent, type, guid, initializer); super(parent, type, guid, initializer);
this.markAsInternalType();
} }
request(): Request { request(): Request {
@ -325,7 +326,7 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
async abort(errorCode?: string) { async abort(errorCode?: string) {
await this._handleRoute(async () => { 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); headers['content-length'] = String(length);
await this._raceWithTargetClose(this._channel.fulfill({ await this._raceWithTargetClose(this._channel.fulfill({
requestUrl: this.request()._initializer.url,
status: statusOption || 200, status: statusOption || 200,
headers: headersObjectToArray(headers), headers: headersObjectToArray(headers),
body, body,
@ -421,7 +421,7 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
async continue(options: FallbackOverrides = {}) { async continue(options: FallbackOverrides = {}) {
await this._handleRoute(async () => { await this._handleRoute(async () => {
this.request()._applyFallbackOverrides(options); 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); chain.resolve(done);
} }
async _innerContinue(internal = false) { async _innerContinue(isFallback: boolean) {
const options = this.request()._fallbackOverridesForContinue(); const options = this.request()._fallbackOverridesForContinue();
return await this._wrapApiCall(async () => { return await this._raceWithTargetClose(this._channel.continue({
await this._raceWithTargetClose(this._channel.continue({ url: options.url,
requestUrl: this.request()._initializer.url, method: options.method,
url: options.url, headers: options.headers ? headersObjectToArray(options.headers) : undefined,
method: options.method, postData: options.postDataBuffer,
headers: options.headers ? headersObjectToArray(options.headers) : undefined, isFallback,
postData: options.postDataBuffer, }));
isFallback: internal,
}));
}, !!internal);
} }
} }

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

View file

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

View file

@ -22,6 +22,7 @@ import * as dom from '../dom';
import * as dialog from '../dialog'; import * as dialog from '../dialog';
import type * as frames from '../frames'; import type * as frames from '../frames';
import { Page } from '../page'; import { Page } from '../page';
import type * as channels from '@protocol/channels';
import type { InitScript, PageDelegate } from '../page'; import type { InitScript, PageDelegate } from '../page';
import type { Progress } from '../progress'; import type { Progress } from '../progress';
import type * as types from '../types'; import type * as types from '../types';
@ -32,6 +33,7 @@ import * as bidi from './third_party/bidiProtocol';
import { BidiExecutionContext } from './bidiExecutionContext'; import { BidiExecutionContext } from './bidiExecutionContext';
import { BidiNetworkManager } from './bidiNetworkManager'; import { BidiNetworkManager } from './bidiNetworkManager';
import { BrowserContext } from '../browserContext'; import { BrowserContext } from '../browserContext';
import { BidiPDF } from './bidiPdf';
const UTILITY_WORLD_NAME = '__playwright_utility_world__'; const UTILITY_WORLD_NAME = '__playwright_utility_world__';
const kPlaywrightBindingChannel = 'playwrightChannel'; const kPlaywrightBindingChannel = 'playwrightChannel';
@ -48,6 +50,7 @@ export class BidiPage implements PageDelegate {
private _sessionListeners: RegisteredListener[] = []; private _sessionListeners: RegisteredListener[] = [];
readonly _browserContext: BidiBrowserContext; readonly _browserContext: BidiBrowserContext;
readonly _networkManager: BidiNetworkManager; readonly _networkManager: BidiNetworkManager;
private readonly _pdf: BidiPDF;
_initializedPage: Page | null = null; _initializedPage: Page | null = null;
private _initScriptIds: string[] = []; private _initScriptIds: string[] = [];
@ -61,6 +64,7 @@ export class BidiPage implements PageDelegate {
this._page = new Page(this, browserContext); this._page = new Page(this, browserContext);
this._browserContext = browserContext; this._browserContext = browserContext;
this._networkManager = new BidiNetworkManager(this._session, this._page, this._onNavigationResponseStarted.bind(this)); 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._page.on(Page.Events.FrameDetached, (frame: frames.Frame) => this._removeContextsForFrame(frame, false));
this._sessionListeners = [ this._sessionListeners = [
eventsHelper.addEventListener(bidiSession, 'script.realmCreated', this._onRealmCreated.bind(this)), eventsHelper.addEventListener(bidiSession, 'script.realmCreated', this._onRealmCreated.bind(this)),
@ -279,6 +283,9 @@ export class BidiPage implements PageDelegate {
} }
async bringToFront(): Promise<void> { async bringToFront(): Promise<void> {
await this._session.send('browsingContext.activate', {
context: this._session.sessionId,
});
} }
private async _updateViewport(): Promise<void> { private async _updateViewport(): Promise<void> {
@ -555,6 +562,10 @@ export class BidiPage implements PageDelegate {
async resetForReuse(): Promise<void> { async resetForReuse(): Promise<void> {
} }
async pdf(options: channels.PagePdfParams): Promise<Buffer> {
return this._pdf.generate(options);
}
async getFrameElement(frame: frames.Frame): Promise<dom.ElementHandle> { async getFrameElement(frame: frames.Frame): Promise<dom.ElementHandle> {
const parent = frame.parentFrame(); const parent = frame.parentFrame();
if (!parent) 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. // When PWDEBUG=1, show inspector for each context.
if (debugMode() === 'inspector') 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. // When paused, show inspector.
if (this._debugger.isPaused()) if (this._debugger.isPaused())
Recorder.showInspector(this, RecorderApp.factory(this)); Recorder.showInspectorNoReply(this, RecorderApp.factory(this));
this._debugger.on(Debugger.Events.PausedStateChanged, () => { this._debugger.on(Debugger.Events.PausedStateChanged, () => {
if (this._debugger.isPaused()) if (this._debugger.isPaused())
Recorder.showInspector(this, RecorderApp.factory(this)); Recorder.showInspectorNoReply(this, RecorderApp.factory(this));
}); });
if (debugMode() === 'console') if (debugMode() === 'console')
@ -525,7 +525,7 @@ export abstract class BrowserContext extends SdkObject {
const internalMetadata = serverSideCallMetadata(); const internalMetadata = serverSideCallMetadata();
const page = await this.newPage(internalMetadata); const page = await this.newPage(internalMetadata);
await page._setServerRequestInterceptor(handler => { await page._setServerRequestInterceptor(handler => {
handler.fulfill({ body: '<html></html>', requestUrl: handler.request().url() }).catch(() => {}); handler.fulfill({ body: '<html></html>' }).catch(() => {});
return true; return true;
}); });
for (const origin of originsToSave) { for (const origin of originsToSave) {
@ -559,7 +559,7 @@ export abstract class BrowserContext extends SdkObject {
isServerSide: false, isServerSide: false,
}); });
await page._setServerRequestInterceptor(handler => { await page._setServerRequestInterceptor(handler => {
handler.fulfill({ body: '<html></html>', requestUrl: handler.request().url() }).catch(() => {}); handler.fulfill({ body: '<html></html>' }).catch(() => {});
return true; return true;
}); });
@ -594,7 +594,7 @@ export abstract class BrowserContext extends SdkObject {
const internalMetadata = serverSideCallMetadata(); const internalMetadata = serverSideCallMetadata();
const page = await this.newPage(internalMetadata); const page = await this.newPage(internalMetadata);
await page._setServerRequestInterceptor(handler => { await page._setServerRequestInterceptor(handler => {
handler.fulfill({ body: '<html></html>', requestUrl: handler.request().url() }).catch(() => {}); handler.fulfill({ body: '<html></html>' }).catch(() => {});
return true; return true;
}); });
for (const originState of state.origins) { for (const originState of state.origins) {

View file

@ -694,16 +694,15 @@ class FrameSession {
if (!frame || this._eventBelongsToStaleFrame(frame._id)) if (!frame || this._eventBelongsToStaleFrame(frame._id))
return; return;
const delegate = new CRExecutionContext(this._client, contextPayload); const delegate = new CRExecutionContext(this._client, contextPayload);
let worldName: types.World; let worldName: types.World|null = null;
if (contextPayload.auxData && !!contextPayload.auxData.isDefault) if (contextPayload.auxData && !!contextPayload.auxData.isDefault)
worldName = 'main'; worldName = 'main';
else if (contextPayload.name === UTILITY_WORLD_NAME) else if (contextPayload.name === UTILITY_WORLD_NAME)
worldName = 'utility'; worldName = 'utility';
else
return;
const context = new dom.FrameExecutionContext(delegate, frame, worldName); const context = new dom.FrameExecutionContext(delegate, frame, worldName);
(context as any)[contextDelegateSymbol] = delegate; (context as any)[contextDelegateSymbol] = delegate;
frame._contextCreated(worldName, context); if (worldName)
frame._contextCreated(worldName, context);
this._contextIdToContext.set(contextPayload.id, context); this._contextIdToContext.set(contextPayload.id, context);
} }

View file

@ -197,7 +197,7 @@ export class DebugController extends SdkObject {
const contexts = new Set<BrowserContext>(); const contexts = new Set<BrowserContext>();
for (const page of this._playwright.allPages()) for (const page of this._playwright.allPages())
contexts.add(page.context()); 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[]; return result.filter(Boolean) as Recorder[];
} }

View file

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

View file

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

View file

@ -50,9 +50,9 @@ export function isNonRecoverableDOMError(error: Error) {
export class FrameExecutionContext extends js.ExecutionContext { export class FrameExecutionContext extends js.ExecutionContext {
readonly frame: frames.Frame; readonly frame: frames.Frame;
private _injectedScriptPromise?: Promise<js.JSHandle>; 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'); super(frame, delegate, world || 'content-script');
this.frame = frame; this.frame = frame;
this.world = world; this.world = world;

View file

@ -35,16 +35,25 @@ export class Download {
this._suggestedFilename = suggestedFilename; this._suggestedFilename = suggestedFilename;
page._browserContext._downloads.add(this); page._browserContext._downloads.add(this);
if (suggestedFilename !== undefined) if (suggestedFilename !== undefined)
this._page.emit(Page.Events.Download, this); this._fireDownloadEvent();
}
page(): Page {
return this._page;
} }
_filenameSuggested(suggestedFilename: string) { _filenameSuggested(suggestedFilename: string) {
assert(this._suggestedFilename === undefined); assert(this._suggestedFilename === undefined);
this._suggestedFilename = suggestedFilename; this._suggestedFilename = suggestedFilename;
this._page.emit(Page.Events.Download, this); this._fireDownloadEvent();
} }
suggestedFilename(): string { suggestedFilename(): string {
return this._suggestedFilename!; 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) if (!frame)
return; return;
const delegate = new FFExecutionContext(this._session, executionContextId); const delegate = new FFExecutionContext(this._session, executionContextId);
let worldName: types.World; let worldName: types.World|null = null;
if (auxData.name === UTILITY_WORLD_NAME) if (auxData.name === UTILITY_WORLD_NAME)
worldName = 'utility'; worldName = 'utility';
else if (!auxData.name) else if (!auxData.name)
worldName = 'main'; worldName = 'main';
else
return;
const context = new dom.FrameExecutionContext(delegate, frame, worldName); const context = new dom.FrameExecutionContext(delegate, frame, worldName);
(context as any)[contextDelegateSymbol] = delegate; (context as any)[contextDelegateSymbol] = delegate;
frame._contextCreated(worldName, context); if (worldName)
frame._contextCreated(worldName, context);
this._contextIdToContext.set(executionContextId, 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 { ElementText } from '../selectorUtils';
import type { Highlight, HighlightOptions } from '../highlight'; import type { Highlight, HighlightOptions } from '../highlight';
import clipPaths from './clipPaths'; import clipPaths from './clipPaths';
import type { SimpleDomNode } from '../simpleDom';
interface RecorderDelegate { export interface RecorderDelegate {
performAction?(action: actions.PerformOnRecordAction): Promise<void>; performAction?(action: actions.PerformOnRecordAction): Promise<void>;
recordAction?(action: actions.Action): Promise<void>; recordAction?(action: actions.Action): Promise<void>;
setSelector?(selector: string): Promise<void>; setSelector?(selector: string): Promise<void>;
@ -206,7 +205,7 @@ class InspectTool implements RecorderTool {
class RecordActionTool implements RecorderTool { class RecordActionTool implements RecorderTool {
private _recorder: Recorder; private _recorder: Recorder;
private _performingAction = false; private _performingAction: actions.PerformOnRecordAction | null = null;
private _hoveredModel: HighlightModel | null = null; private _hoveredModel: HighlightModel | null = null;
private _hoveredElement: HTMLElement | null = null; private _hoveredElement: HTMLElement | null = null;
private _activeModel: HighlightModel | null = null; private _activeModel: HighlightModel | null = null;
@ -509,9 +508,15 @@ class RecordActionTool implements RecorderTool {
private _actionInProgress(event: Event): boolean { private _actionInProgress(event: Event): boolean {
// If Playwright is performing action for us, bail. // 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; 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); consumeEvent(event);
return false; return false;
} }
@ -535,9 +540,9 @@ class RecordActionTool implements RecorderTool {
this._hoveredModel = null; this._hoveredModel = null;
this._activeModel = null; this._activeModel = null;
this._recorder.updateHighlight(null, false); this._recorder.updateHighlight(null, false);
this._performingAction = true; this._performingAction = action;
void this._recorder.performAction(action).then(() => { 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. // If that was a keyboard action, it similarly requires new selectors for active model.
this._onFocus(false); this._onFocus(false);
@ -1457,73 +1462,3 @@ function createSvgElement(doc: Document, { tagName, attrs, children }: SvgJson):
return elem; 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 { CallMetadata } from '@protocol/callMetadata';
import type { Dialog } from './dialog';
import type { Download } from './download';
export type { CallMetadata } from '@protocol/callMetadata'; export type { CallMetadata } from '@protocol/callMetadata';
export class SdkObject extends EventEmitter { export class SdkObject extends EventEmitter {
@ -62,6 +64,8 @@ export interface Instrumentation {
onPageClose(page: Page): void; onPageClose(page: Page): void;
onBrowserOpen(browser: Browser): void; onBrowserOpen(browser: Browser): void;
onBrowserClose(browser: Browser): void; onBrowserClose(browser: Browser): void;
onDialog(dialog: Dialog): void;
onDownload(page: Page, download: Download): void;
} }
export interface InstrumentationListener { export interface InstrumentationListener {
@ -73,6 +77,8 @@ export interface InstrumentationListener {
onPageClose?(page: Page): void; onPageClose?(page: Page): void;
onBrowserOpen?(browser: Browser): void; onBrowserOpen?(browser: Browser): void;
onBrowserClose?(browser: Browser): void; onBrowserClose?(browser: Browser): void;
onDialog?(dialog: Dialog): void;
onDownload?(page: Page, download: Download): void;
} }
export function createInstrumentation(): Instrumentation { export function createInstrumentation(): Instrumentation {

View file

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

View file

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

View file

@ -17,7 +17,7 @@
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
import type { Source } from '@recorder/recorderTypes'; import type { Source } from '@recorder/recorderTypes';
import { EventEmitter } from 'events'; 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 { eventsHelper, monotonicTime, quoteCSSAttributeValue, type RegisteredListener } from '../../utils';
import { raceAgainstDeadline } from '../../utils/timeoutRunner'; import { raceAgainstDeadline } from '../../utils/timeoutRunner';
import { BrowserContext } from '../browserContext'; import { BrowserContext } from '../browserContext';
@ -48,15 +48,17 @@ export class ContextRecorder extends EventEmitter {
private _lastDialogOrdinal = -1; private _lastDialogOrdinal = -1;
private _lastDownloadOrdinal = -1; private _lastDownloadOrdinal = -1;
private _context: BrowserContext; private _context: BrowserContext;
private _params: channels.BrowserContextRecorderSupplementEnableParams; private _params: channels.BrowserContextEnableRecorderParams;
private _delegate: ContextRecorderDelegate; private _delegate: ContextRecorderDelegate;
private _recorderSources: Source[]; private _recorderSources: Source[];
private _throttledOutputFile: ThrottledFile | null = null; private _throttledOutputFile: ThrottledFile | null = null;
private _orderedLanguages: LanguageGenerator[] = []; private _orderedLanguages: LanguageGenerator[] = [];
private _listeners: RegisteredListener[] = []; 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(); super();
this._codegenMode = codegenMode;
this._context = context; this._context = context;
this._params = params; this._params = params;
this._delegate = delegate; this._delegate = delegate;
@ -73,8 +75,8 @@ export class ContextRecorder extends EventEmitter {
saveStorage: params.saveStorage, saveStorage: params.saveStorage,
}; };
const collection = new RecorderCollection(context, this._pageAliases, params.mode === 'recording'); this._collection = new RecorderCollection(codegenMode, context, this._pageAliases);
collection.on('change', (actions: ActionInContext[]) => { this._collection.on('change', (actions: ActionInContext[]) => {
this._recorderSources = []; this._recorderSources = [];
for (const languageGenerator of this._orderedLanguages) { for (const languageGenerator of this._orderedLanguages) {
const { header, footer, actionTexts, text } = generateCode(actions, languageGenerator, languageGeneratorOptions); 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._listeners.push(eventsHelper.addEventListener(process, 'exit', () => {
this._throttledOutputFile?.flush(); this._throttledOutputFile?.flush();
})); }));
this._collection = collection; this.setEnabled(true);
} }
setOutput(codegenId: string, outputFile?: string) { setOutput(codegenId: string, outputFile?: string) {
@ -145,6 +147,12 @@ export class ContextRecorder extends EventEmitter {
setEnabled(enabled: boolean) { setEnabled(enabled: boolean) {
this._collection.setEnabled(enabled); 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() { dispose() {

View file

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

View file

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

View file

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

View file

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

View file

@ -38,6 +38,8 @@ import { Snapshotter } from './snapshotter';
import type { ConsoleMessage } from '../../console'; import type { ConsoleMessage } from '../../console';
import { Dispatcher } from '../../dispatchers/dispatcher'; import { Dispatcher } from '../../dispatchers/dispatcher';
import { serializeError } from '../../errors'; import { serializeError } from '../../errors';
import type { Dialog } from '../../dialog';
import type { Download } from '../../download';
const version: trace.VERSION = 7; const version: trace.VERSION = 7;
@ -454,6 +456,28 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
this._appendTraceEvent(event); 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) { onPageOpen(page: Page) {
const event: trace.EventTraceEvent = { const event: trace.EventTraceEvent = {
type: 'event', type: 'event',

View file

@ -46,7 +46,6 @@ export type TraceViewerRedirectOptions = {
reporter?: string[]; reporter?: string[];
webApp?: string; webApp?: string;
isServer?: boolean; isServer?: boolean;
outputDir?: string;
updateSnapshots?: 'all' | 'none' | 'missing'; updateSnapshots?: 'all' | 'none' | 'missing';
}; };
@ -133,8 +132,6 @@ export async function installRootRedirect(server: HttpServer, traceUrls: string[
params.append('timeout', String(options.timeout)); params.append('timeout', String(options.timeout));
if (options.headed) if (options.headed)
params.append('headed', ''); params.append('headed', '');
if (options.outputDir)
params.append('outputDir', options.outputDir);
if (options.updateSnapshots) if (options.updateSnapshots)
params.append('updateSnapshots', options.updateSnapshots); params.append('updateSnapshots', options.updateSnapshots);
for (const reporter of options.reporter || []) for (const reporter of options.reporter || [])
@ -223,6 +220,9 @@ class StdinServer implements Transport {
process.stdin.on('close', () => gracefullyProcessExitDoNotHang(0)); process.stdin.on('close', () => gracefullyProcessExitDoNotHang(0));
} }
onconnect() {
}
async dispatch(method: string, params: any) { async dispatch(method: string, params: any) {
if (method === 'initialize') { if (method === 'initialize') {
if (this._traceUrl) if (this._traceUrl)

View file

@ -502,16 +502,15 @@ export class WKPage implements PageDelegate {
if (!frame) if (!frame)
return; return;
const delegate = new WKExecutionContext(this._session, contextPayload.id); const delegate = new WKExecutionContext(this._session, contextPayload.id);
let worldName: types.World; let worldName: types.World|null = null;
if (contextPayload.type === 'normal') if (contextPayload.type === 'normal')
worldName = 'main'; worldName = 'main';
else if (contextPayload.type === 'user' && contextPayload.name === UTILITY_WORLD_NAME) else if (contextPayload.type === 'user' && contextPayload.name === UTILITY_WORLD_NAME)
worldName = 'utility'; worldName = 'utility';
else
return;
const context = new dom.FrameExecutionContext(delegate, frame, worldName); const context = new dom.FrameExecutionContext(delegate, frame, worldName);
(context as any)[contextDelegateSymbol] = delegate; (context as any)[contextDelegateSymbol] = delegate;
frame._contextCreated(worldName, context); if (worldName)
frame._contextCreated(worldName, context);
this._contextIdToContext.set(contextPayload.id, context); this._contextIdToContext.set(contextPayload.id, context);
} }
@ -1116,7 +1115,7 @@ export class WKPage implements PageDelegate {
const response = request.createResponse(event.response); const response = request.createResponse(event.response);
this._page._frameManager.requestReceivedResponse(response); this._page._frameManager.requestReceivedResponse(response);
if (response.status() === 204) { if (response.status() === 204 && request.request.isNavigationRequest()) {
this._onLoadingFailed(session, { this._onLoadingFailed(session, {
requestId: event.requestId, requestId: event.requestId,
errorText: 'Aborted: 204 No Content', errorText: 'Aborted: 204 No Content',

View file

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

View file

@ -45,7 +45,7 @@ export interface TestServerInterface {
installBrowsers(params: {}): Promise<void>; installBrowsers(params: {}): Promise<void>;
runGlobalSetup(params: { outputDir?: string }): Promise<{ runGlobalSetup(params: {}): Promise<{
report: ReportEntry[], report: ReportEntry[],
status: reporterTypes.FullResult['status'] status: reporterTypes.FullResult['status']
}>; }>;
@ -82,7 +82,6 @@ export interface TestServerInterface {
locations?: string[]; locations?: string[];
grep?: string; grep?: string;
grepInvert?: string; grepInvert?: string;
outputDir?: string;
}): Promise<{ }): Promise<{
report: ReportEntry[], report: ReportEntry[],
status: reporterTypes.FullResult['status'] status: reporterTypes.FullResult['status']
@ -96,7 +95,6 @@ export interface TestServerInterface {
headed?: boolean; headed?: boolean;
workers?: number | string; workers?: number | string;
timeout?: number, timeout?: number,
outputDir?: string;
updateSnapshots?: 'all' | 'none' | 'missing'; updateSnapshots?: 'all' | 'none' | 'missing';
reporters?: string[], reporters?: string[],
trace?: 'on' | 'off'; trace?: 'on' | 'off';

View file

@ -140,8 +140,7 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re
const wrappedMatchers: any = {}; const wrappedMatchers: any = {};
const extendedMatchers: any = { ...customMatchers }; const extendedMatchers: any = { ...customMatchers };
for (const [name, matcher] of Object.entries(matchers)) { for (const [name, matcher] of Object.entries(matchers)) {
const key = qualifiedMatcherName(qualifier, name); wrappedMatchers[name] = function(...args: any[]) {
wrappedMatchers[key] = function(...args: any[]) {
const { isNot, promise, utils } = this; const { isNot, promise, utils } = this;
const newThis: ExpectMatcherState = { const newThis: ExpectMatcherState = {
isNot, isNot,
@ -152,6 +151,8 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re
(newThis as any).equals = throwUnsupportedExpectMatcherError; (newThis as any).equals = throwUnsupportedExpectMatcherError;
return (matcher as any).call(newThis, ...args); return (matcher as any).call(newThis, ...args);
}; };
const key = qualifiedMatcherName(qualifier, name);
wrappedMatchers[key] = wrappedMatchers[name];
Object.defineProperty(wrappedMatchers[key], 'name', { value: name }); Object.defineProperty(wrappedMatchers[key], 'name', { value: name });
extendedMatchers[name] = wrappedMatchers[key]; extendedMatchers[name] = wrappedMatchers[key];
} }

View file

@ -161,7 +161,7 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
if (opts.onlyChanged) 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.`); 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, host: opts.uiHost,
port: opts.uiPort ? +opts.uiPort : undefined, port: opts.uiPort ? +opts.uiPort : undefined,
args, 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, reporter: Array.isArray(opts.reporter) ? opts.reporter : opts.reporter ? [opts.reporter] : undefined,
workers: cliOverrides.workers, workers: cliOverrides.workers,
timeout: cliOverrides.timeout, timeout: cliOverrides.timeout,
outputDir: cliOverrides.outputDir,
updateSnapshots: cliOverrides.updateSnapshots, updateSnapshots: cliOverrides.updateSnapshots,
}); });
await stopProfiling('runner'); await stopProfiling('runner');
@ -227,7 +226,7 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
async function runTestServer(opts: { [key: string]: any }) { async function runTestServer(opts: { [key: string]: any }) {
const host = opts.host || 'localhost'; const host = opts.host || 'localhost';
const port = opts.port ? +opts.port : 0; 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') if (status === 'restarted')
return; return;
const exitCode = status === 'interrupted' ? 130 : (status === 'passed' ? 0 : 1); const exitCode = status === 'interrupted' ? 130 : (status === 'passed' ? 0 : 1);

View file

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

View file

@ -75,7 +75,7 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp
const options: WatchModeOptions = { ...initialOptions }; const options: WatchModeOptions = { ...initialOptions };
let bufferMode = false; let bufferMode = false;
const testServerDispatcher = new TestServerDispatcher(configLocation); const testServerDispatcher = new TestServerDispatcher(configLocation, {});
const transport = new InMemoryTransport( const transport = new InMemoryTransport(
async data => { async data => {
const { id, method, params } = JSON.parse(data); const { id, method, params } = JSON.parse(data);
@ -144,11 +144,13 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp
else else
printPrompt(); printPrompt();
const waitForCommand = readCommand();
const command = await Promise.race([ const command = await Promise.race([
onDirtyTests, onDirtyTests,
readCommand(), waitForCommand.result,
]); ]);
if (command === 'changed')
waitForCommand.cancel();
if (bufferMode && command === 'changed') if (bufferMode && command === 'changed')
continue; 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 result = new ManualPromise<Command>();
const rl = readline.createInterface({ input: process.stdin, escapeCodeTimeout: 50 }); const rl = readline.createInterface({ input: process.stdin, escapeCodeTimeout: 50 });
readline.emitKeypressEvents(process.stdin, rl); readline.emitKeypressEvents(process.stdin, rl);
@ -334,13 +336,14 @@ Change settings
}; };
process.stdin.on('keypress', handler); process.stdin.on('keypress', handler);
void result.finally(() => { const cancel = () => {
process.stdin.off('keypress', handler); process.stdin.off('keypress', handler);
rl.close(); rl.close();
if (process.stdin.isTTY) if (process.stdin.isTTY)
process.stdin.setRawMode(false); process.stdin.setRawMode(false);
}); };
return result; void result.finally(cancel);
return { result, cancel };
} }
let showBrowserServer: PlaywrightServer | undefined; let showBrowserServer: PlaywrightServer | undefined;

View file

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

View file

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

View file

@ -19,6 +19,7 @@ import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
import { SplitView } from '@web/components/splitView'; import { SplitView } from '@web/components/splitView';
import { TabbedPane } from '@web/components/tabbedPane'; import { TabbedPane } from '@web/components/tabbedPane';
import { Toolbar } from '@web/components/toolbar'; import { Toolbar } from '@web/components/toolbar';
import { emptySource, SourceChooser } from '@web/components/sourceChooser';
import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton'; import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton';
import * as React from 'react'; import * as React from 'react';
import { CallLogView } from './callLog'; import { CallLogView } from './callLog';
@ -54,15 +55,7 @@ export const Recorder: React.FC<RecorderProps> = ({
if (source) if (source)
return source; return source;
} }
const source: Source = { return emptySource();
id: 'default',
isRecorded: false,
text: '',
language: 'javascript',
label: '',
highlight: []
};
return source;
}, [sources, fileId]); }, [sources, fileId]);
const [locator, setLocator] = React.useState(''); const [locator, setLocator] = React.useState('');
@ -152,10 +145,10 @@ export const Recorder: React.FC<RecorderProps> = ({
}}></ToolbarButton> }}></ToolbarButton>
<div style={{ flex: 'auto' }}></div> <div style={{ flex: 'auto' }}></div>
<div>Target:</div> <div>Target:</div>
<select className='recorder-chooser' hidden={!sources.length} value={fileId} onChange={event => { <SourceChooser fileId={fileId} sources={sources} setFileId={fileId => {
setFileId(event.target.selectedOptions[0].value); setFileId(fileId);
window.dispatch({ event: 'fileChanged', params: { file: event.target.selectedOptions[0].value } }); window.dispatch({ event: 'fileChanged', params: { file: fileId } });
}}>{renderSourceOptions(sources)}</select> }} />
<ToolbarButton icon='clear-all' title='Clear' disabled={!source || !source.text} onClick={() => { <ToolbarButton icon='clear-all' title='Clear' disabled={!source || !source.text} onClick={() => {
window.dispatch({ event: 'clear' }); window.dispatch({ event: 'clear' });
}}></ToolbarButton> }}></ToolbarButton>
@ -184,22 +177,3 @@ export const Recorder: React.FC<RecorderProps> = ({
/> />
</div>; </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); set.add(traceUrl);
const isRecorderMode = traceUrl.includes('/playwright-recorder-trace-');
const traceModel = new TraceModel(); const traceModel = new TraceModel();
try { try {
// Allow 10% to hop from sw to page. // Allow 10% to hop from sw to page.
const [fetchProgress, unzipProgress] = splitProgress(progress, [0.5, 0.4, 0.1]); const [fetchProgress, unzipProgress] = splitProgress(progress, [0.5, 0.4, 0.1]);
const backend = traceUrl.endsWith('json') ? new FetchTraceModelBackend(traceUrl) : new ZipTraceModelBackend(traceUrl, fetchProgress); 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) { } catch (error: any) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(error); console.error(error);

View file

@ -15,7 +15,7 @@
*/ */
import { parseClientSideCallMetadata } from '../../../packages/playwright-core/src/utils/isomorphic/traceUtils'; 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 { createEmptyContext } from './entries';
import { SnapshotStorage } from './snapshotStorage'; import { SnapshotStorage } from './snapshotStorage';
import { TraceModernizer } from './traceModernizer'; import { TraceModernizer } from './traceModernizer';
@ -38,7 +38,7 @@ export class TraceModel {
constructor() { 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; this._backend = backend;
const ordinals: string[] = []; const ordinals: string[] = [];
@ -72,7 +72,8 @@ export class TraceModel {
modernizer.appendTrace(network); modernizer.appendTrace(network);
unzipProgress(++done, total); 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()) { if (!backend.isLive()) {
// Terminate actions w/o after event gracefully. // Terminate actions w/o after event gracefully.
@ -133,3 +134,19 @@ function stripEncodingFromContentType(contentType: string) {
return charset[1]; return charset[1];
return contentType; 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, selectedTime: Boundaries | undefined,
setSelectedTime: (time: Boundaries | undefined) => void, setSelectedTime: (time: Boundaries | undefined) => void,
sdkLanguage: Language | undefined; sdkLanguage: Language | undefined;
onSelected: (action: ActionTraceEventInContext) => void, onSelected?: (action: ActionTraceEventInContext) => void,
onHighlighted: (action: ActionTraceEventInContext | undefined) => void, onHighlighted?: (action: ActionTraceEventInContext | undefined) => void,
revealConsole: () => void, revealConsole?: () => void,
isLive?: boolean, isLive?: boolean,
} }
@ -67,8 +67,8 @@ export const ActionList: React.FC<ActionListProps> = ({
treeState={treeState} treeState={treeState}
setTreeState={setTreeState} setTreeState={setTreeState}
selectedItem={selectedItem} selectedItem={selectedItem}
onSelected={item => onSelected(item.action!)} onSelected={item => onSelected?.(item.action!)}
onHighlighted={item => onHighlighted(item?.action)} onHighlighted={item => onHighlighted?.(item?.action)}
onAccepted={item => setSelectedTime({ minimum: item.action!.startTime, maximum: item.action!.endTime })} onAccepted={item => setSelectedTime({ minimum: item.action!.startTime, maximum: item.action!.endTime })}
isError={item => !!item.action?.error?.message} isError={item => !!item.action?.error?.message}
isVisible={item => !selectedTime || (item.action!.startTime <= selectedTime.maximum && item.action!.endTime >= selectedTime.minimum)} isVisible={item => !selectedTime || (item.action!.startTime <= selectedTime.maximum && item.action!.endTime >= selectedTime.minimum)}

View file

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

View file

@ -107,7 +107,7 @@ export const ConsoleTab: React.FunctionComponent<{
boundaries: Boundaries, boundaries: Boundaries,
consoleModel: ConsoleTabModel, consoleModel: ConsoleTabModel,
selectedTime: Boundaries | undefined, selectedTime: Boundaries | undefined,
onEntryHovered: (entry: ConsoleEntry | undefined) => void, onEntryHovered?: (entry: ConsoleEntry | undefined) => void,
onAccepted: (entry: ConsoleEntry) => void, onAccepted: (entry: ConsoleEntry) => void,
}> = ({ consoleModel, boundaries, onEntryHovered, onAccepted }) => { }> = ({ consoleModel, boundaries, onEntryHovered, onAccepted }) => {
if (!consoleModel.entries.length) if (!consoleModel.entries.length)

View file

@ -65,7 +65,7 @@ export function useNetworkTabModel(model: MultiTraceModel | undefined, selectedT
export const NetworkTab: React.FunctionComponent<{ export const NetworkTab: React.FunctionComponent<{
boundaries: Boundaries, boundaries: Boundaries,
networkModel: NetworkTabModel, networkModel: NetworkTabModel,
onEntryHovered: (entry: Entry | undefined) => void, onEntryHovered?: (entry: Entry | undefined) => void,
}> = ({ boundaries, networkModel, onEntryHovered }) => { }> = ({ boundaries, networkModel, onEntryHovered }) => {
const [sorting, setSorting] = React.useState<Sorting | undefined>(undefined); const [sorting, setSorting] = React.useState<Sorting | undefined>(undefined);
const [selectedEntry, setSelectedEntry] = React.useState<RenderedEntry | undefined>(undefined); const [selectedEntry, setSelectedEntry] = React.useState<RenderedEntry | undefined>(undefined);
@ -95,7 +95,7 @@ export const NetworkTab: React.FunctionComponent<{
items={renderedEntries} items={renderedEntries}
selectedItem={selectedEntry} selectedItem={selectedEntry}
onSelected={item => setSelectedEntry(item)} onSelected={item => setSelectedEntry(item)}
onHighlighted={item => onEntryHovered(item?.resource)} onHighlighted={item => onEntryHovered?.(item?.resource)}
columns={visibleColumns(!!selectedEntry, renderedEntries)} columns={visibleColumns(!!selectedEntry, renderedEntries)}
columnTitle={columnTitle} columnTitle={columnTitle}
columnWidths={columnWidths} columnWidths={columnWidths}

View file

@ -14,54 +14,52 @@
limitations under the License. limitations under the License.
*/ */
import * as React from 'react'; import type { Language } from '@isomorphic/locatorGenerators';
import './recorderView.css';
import { MultiTraceModel } from './modelUtil';
import type { SourceLocation } from './modelUtil';
import { Workbench } from './workbench';
import type { Mode, Source } from '@recorder/recorderTypes'; 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 { 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 searchParams = new URLSearchParams(window.location.search);
const guid = searchParams.get('ws'); const guid = searchParams.get('ws');
const trace = searchParams.get('trace') + '.json'; const traceLocation = searchParams.get('trace') + '.json';
export const RecorderView: React.FunctionComponent = () => { export const RecorderView: React.FunctionComponent = () => {
const [connection, setConnection] = React.useState<Connection | null>(null); const [connection, setConnection] = React.useState<Connection | null>(null);
const [sources, setSources] = React.useState<Source[]>([]); 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(() => { React.useEffect(() => {
const wsURL = new URL(`../${guid}`, window.location.toString()); const wsURL = new URL(`../${guid}`, window.location.toString());
wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:'); wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:');
const webSocket = new WebSocket(wsURL.toString()); const webSocket = new WebSocket(wsURL.toString());
setConnection(new Connection(webSocket, { setSources })); setConnection(new Connection(webSocket, { setMode, setSources }));
return () => { return () => {
webSocket.close(); 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(() => { React.useEffect(() => {
if (pollTimer.current) if (pollTimer.current)
clearTimeout(pollTimer.current); clearTimeout(pollTimer.current);
@ -69,8 +67,9 @@ export const TraceView: React.FC<{
// Start polling running test. // Start polling running test.
pollTimer.current = setTimeout(async () => { pollTimer.current = setTimeout(async () => {
try { try {
const model = await loadSingleTraceFile(traceLocation); const result = await loadSingleTraceFile(traceLocation);
setModel({ model, isLive: true }); if (result.sha1 !== model?.sha1)
setModel({ ...result, isLive: true });
} catch { } catch {
setModel(undefined); setModel(undefined);
} finally { } finally {
@ -81,10 +80,94 @@ export const TraceView: React.FC<{
if (pollTimer.current) if (pollTimer.current)
clearTimeout(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(() => { const fallbackLocation = React.useMemo(() => {
if (!sources.length) if (!source)
return undefined; return undefined;
const fallbackLocation: SourceLocation = { const fallbackLocation: SourceLocation = {
file: '', file: '',
@ -92,36 +175,178 @@ export const TraceView: React.FC<{
column: 0, column: 0,
source: { source: {
errors: [], errors: [],
content: sources[0].text content: source.text
} }
}; };
return fallbackLocation; return fallbackLocation;
}, [sources]); }, [source]);
return <Workbench const sourceTab: TabbedPaneTabModel = {
key='workbench' id: 'source',
model={model?.model} title: 'Source',
showSourcesFirst={true} render: () => <SourceTab
fallbackLocation={fallbackLocation} 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} 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 SnapshotContainer: React.FunctionComponent<{
const params = new URLSearchParams(); sdkLanguage: Language,
params.set('trace', url); action: modelUtil.ActionTraceEventInContext | undefined,
const response = await fetch(`contexts?${params.toString()}`); testIdAttributeName?: string,
const contextEntries = await response.json() as ContextEntry[]; isInspecting: boolean,
return new MultiTraceModel(contextEntries); 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 { class Connection {
private _lastId = 0; private _lastId = 0;
private _webSocket: WebSocket; private _webSocket: WebSocket;
private _callbacks = new Map<number, { resolve: (arg: any) => void, reject: (arg: Error) => void }>(); 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._webSocket = webSocket;
this._callbacks = new Map(); this._callbacks = new Map();
this._options = options; this._options = options;
@ -165,6 +390,11 @@ class Connection {
if (method === 'setSources') { if (method === 'setSources') {
const { sources } = params as { sources: Source[] }; const { sources } = params as { sources: Source[] };
this._options.setSources(sources); 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 { .snapshot-tab {
display: flex;
flex: auto;
flex-direction: column;
align-items: stretch; align-items: stretch;
outline: none; outline: none;
--browser-frame-header-height: 40px;
overflow: hidden; overflow: hidden;
} }
@ -73,6 +69,7 @@
margin: 1px; margin: 1px;
padding: 10px; padding: 10px;
position: relative; position: relative;
--browser-frame-header-height: 40px;
} }
.snapshot-container { .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, action: ActionTraceEvent | undefined,
model?: MultiTraceModel, model?: MultiTraceModel,
sdkLanguage: Language, sdkLanguage: Language,
@ -50,63 +50,69 @@ export const SnapshotTab: React.FunctionComponent<{
highlightedLocator: string, highlightedLocator: string,
setHighlightedLocator: (locator: string) => void, setHighlightedLocator: (locator: string) => void,
openPage?: (url: string, target?: string) => Window | any, openPage?: (url: string, target?: string) => Window | any,
}> = ({ action, model, sdkLanguage, testIdAttributeName, isInspecting, setIsInspecting, highlightedLocator, setHighlightedLocator, openPage }) => { }> = ({ action, sdkLanguage, testIdAttributeName, isInspecting, setIsInspecting, highlightedLocator, setHighlightedLocator, openPage }) => {
const [measure, ref] = useMeasure<HTMLDivElement>();
const [snapshotTab, setSnapshotTab] = React.useState<'action'|'before'|'after'>('action'); const [snapshotTab, setSnapshotTab] = React.useState<'action'|'before'|'after'>('action');
const [showScreenshotInsteadOfSnapshot] = useSetting('screenshot-instead-of-snapshot', false); 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(() => {
const { snapshots } = React.useMemo(() => { return collectSnapshots(action);
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 } };
}, [action]); }, [action]);
const snapshotUrls = React.useMemo(() => {
const { snapshotInfoUrl, snapshotUrl, popoutUrl, point } = React.useMemo(() => {
const snapshot = snapshots[snapshotTab]; const snapshot = snapshots[snapshotTab];
if (!snapshot) return snapshot ? extendSnapshot(snapshot) : undefined;
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 };
}, [snapshots, snapshotTab]); }, [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 iframeRef0 = React.useRef<HTMLIFrameElement>(null);
const iframeRef1 = 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 }); const loadingRef = React.useRef({ iteration: 0, visibleIframe: 0 });
React.useEffect(() => { React.useEffect(() => {
@ -115,17 +121,7 @@ export const SnapshotTab: React.FunctionComponent<{
const newVisibleIframe = 1 - loadingRef.current.visibleIframe; const newVisibleIframe = 1 - loadingRef.current.visibleIframe;
loadingRef.current.iteration = thisIteration; loadingRef.current.iteration = thisIteration;
const newSnapshotInfo = { url: '', viewport: kDefaultViewport, timestamp: undefined, wallTime: undefined }; const newSnapshotInfo = await fetchSnapshotInfo(snapshotUrls?.snapshotInfoUrl);
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;
}
}
// Interrupted by another load - bail out. // Interrupted by another load - bail out.
if (loadingRef.current.iteration !== thisIteration) if (loadingRef.current.iteration !== thisIteration)
@ -140,6 +136,7 @@ export const SnapshotTab: React.FunctionComponent<{
iframe.addEventListener('error', loadedCallback); iframe.addEventListener('error', loadedCallback);
// Try preventing history entry from being created. // Try preventing history entry from being created.
const snapshotUrl = snapshotUrls?.snapshotUrl || kBlankSnapshotUrl;
if (iframe.contentWindow) if (iframe.contentWindow)
iframe.contentWindow.location.replace(snapshotUrl); iframe.contentWindow.location.replace(snapshotUrl);
else else
@ -159,33 +156,10 @@ export const SnapshotTab: React.FunctionComponent<{
loadingRef.current.visibleIframe = newVisibleIframe; loadingRef.current.visibleIframe = newVisibleIframe;
setSnapshotInfo(newSnapshotInfo); setSnapshotInfo(newSnapshotInfo);
})(); })();
}, [snapshotUrl, snapshotInfoUrl]); }, [snapshotUrls]);
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]
);
return <div return <div
className='snapshot-tab' className='vbox'
tabIndex={0} tabIndex={0}
onKeyDown={event => { onKeyDown={event => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
@ -210,46 +184,72 @@ export const SnapshotTab: React.FunctionComponent<{
setHighlightedLocator={setHighlightedLocator} setHighlightedLocator={setHighlightedLocator}
iframe={iframeRef1.current} iframe={iframeRef1.current}
iteration={loadingRef.current.iteration} /> iteration={loadingRef.current.iteration} />
<Toolbar> <SnapshotWrapper snapshotInfo={snapshotInfo}>
<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} /> <div className='snapshot-switcher'>
{['action', 'before', 'after'].map(tab => { <iframe ref={iframeRef0} name='snapshot' title='DOM Snapshot' className={clsx(loadingRef.current.visibleIframe === 0 && 'snapshot-visible')}></iframe>
return <TabbedPaneTab <iframe ref={iframeRef1} name='snapshot' title='DOM Snapshot' className={clsx(loadingRef.current.visibleIframe === 1 && 'snapshot-visible')}></iframe>
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>
</div> </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>
</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>'; 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'; import { Toolbar } from '@web/components/toolbar';
export const SourceTab: React.FunctionComponent<{ export const SourceTab: React.FunctionComponent<{
stack: StackFrame[] | undefined, stack?: StackFrame[],
stackFrameLocation: 'bottom' | 'right', stackFrameLocation: 'bottom' | 'right',
sources: Map<string, SourceModel>, sources: Map<string, SourceModel>,
rootDir?: string, rootDir?: string,

View file

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

View file

@ -24,9 +24,8 @@ import type { ErrorDescription } from './errorsTab';
import type { ConsoleEntry } from './consoleTab'; import type { ConsoleEntry } from './consoleTab';
import { ConsoleTab, useConsoleTabModel } from './consoleTab'; import { ConsoleTab, useConsoleTabModel } from './consoleTab';
import type * as modelUtil from './modelUtil'; import type * as modelUtil from './modelUtil';
import { isRouteAction } from './modelUtil';
import { NetworkTab, useNetworkTabModel } from './networkTab'; import { NetworkTab, useNetworkTabModel } from './networkTab';
import { SnapshotTab } from './snapshotTab'; import { SnapshotTabsView } from './snapshotTab';
import { SourceTab } from './sourceTab'; import { SourceTab } from './sourceTab';
import { TabbedPane } from '@web/components/tabbedPane'; import { TabbedPane } from '@web/components/tabbedPane';
import type { TabbedPaneTabModel } from '@web/components/tabbedPane'; import type { TabbedPaneTabModel } from '@web/components/tabbedPane';
@ -50,6 +49,7 @@ export const Workbench: React.FunctionComponent<{
rootDir?: string, rootDir?: string,
fallbackLocation?: modelUtil.SourceLocation, fallbackLocation?: modelUtil.SourceLocation,
isLive?: boolean, isLive?: boolean,
hideTimeline?: boolean,
status?: UITestStatus, status?: UITestStatus,
annotations?: { type: string; description?: string; }[]; annotations?: { type: string; description?: string; }[];
inert?: boolean, inert?: boolean,
@ -57,11 +57,10 @@ export const Workbench: React.FunctionComponent<{
onOpenExternally?: (location: modelUtil.SourceLocation) => void, onOpenExternally?: (location: modelUtil.SourceLocation) => void,
revealSource?: boolean, revealSource?: boolean,
showSettings?: 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 [selectedCallId, setSelectedCallId] = React.useState<string | undefined>(undefined);
const [revealedError, setRevealedError] = React.useState<ErrorDescription | undefined>(undefined); const [revealedError, setRevealedError] = React.useState<ErrorDescription | undefined>(undefined);
const [highlightedCallId, setHighlightedCallId] = React.useState<string | undefined>();
const [highlightedAction, setHighlightedAction] = React.useState<modelUtil.ActionTraceEventInContext | undefined>();
const [highlightedEntry, setHighlightedEntry] = React.useState<Entry | undefined>(); const [highlightedEntry, setHighlightedEntry] = React.useState<Entry | undefined>();
const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState<ConsoleEntry | undefined>(); const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState<ConsoleEntry | undefined>();
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions'); const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions');
@ -70,18 +69,21 @@ export const Workbench: React.FunctionComponent<{
const [highlightedLocator, setHighlightedLocator] = React.useState<string>(''); const [highlightedLocator, setHighlightedLocator] = React.useState<string>('');
const [selectedTime, setSelectedTime] = React.useState<Boundaries | undefined>(); const [selectedTime, setSelectedTime] = React.useState<Boundaries | undefined>();
const [sidebarLocation, setSidebarLocation] = useSetting<'bottom' | 'right'>('propertiesSidebarLocation', 'bottom'); 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 [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) => { const setSelectedAction = React.useCallback((action: modelUtil.ActionTraceEventInContext | undefined) => {
setSelectedCallId(action?.callId); setSelectedCallId(action?.callId);
setRevealedError(undefined); 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]); const sources = React.useMemo(() => model?.sources || new Map<string, modelUtil.SourceModel>(), [model]);
React.useEffect(() => { React.useEffect(() => {
@ -291,7 +293,7 @@ export const Workbench: React.FunctionComponent<{
</div>} </div>}
<ActionList <ActionList
sdkLanguage={sdkLanguage} sdkLanguage={sdkLanguage}
actions={filteredActions} actions={model?.actions || []}
selectedAction={model ? selectedAction : undefined} selectedAction={model ? selectedAction : undefined}
selectedTime={selectedTime} selectedTime={selectedTime}
setSelectedTime={setSelectedTime} setSelectedTime={setSelectedTime}
@ -311,13 +313,12 @@ export const Workbench: React.FunctionComponent<{
id: 'settings', id: 'settings',
title: 'Settings', title: 'Settings',
component: <SettingsView settings={[ component: <SettingsView settings={[
{ value: showRouteActions, set: setShowRouteActions, title: 'Show route actions' },
{ value: showScreenshot, set: setShowScreenshot, title: 'Show screenshot instead of snapshot' } { value: showScreenshot, set: setShowScreenshot, title: 'Show screenshot instead of snapshot' }
]}/>, ]}/>,
}; };
return <div className='vbox workbench' {...(inert ? { inert: 'true' } : {})}> return <div className='vbox workbench' {...(inert ? { inert: 'true' } : {})}>
<Timeline {!hideTimeline && <Timeline
model={model} model={model}
consoleEntries={consoleModel.entries} consoleEntries={consoleModel.entries}
boundaries={boundaries} boundaries={boundaries}
@ -328,7 +329,7 @@ export const Workbench: React.FunctionComponent<{
sdkLanguage={sdkLanguage} sdkLanguage={sdkLanguage}
selectedTime={selectedTime} selectedTime={selectedTime}
setSelectedTime={setSelectedTime} setSelectedTime={setSelectedTime}
/> />}
<SplitView <SplitView
sidebarSize={250} sidebarSize={250}
orientation={sidebarLocation === 'bottom' ? 'vertical' : 'horizontal'} settingName='propertiesSidebar' orientation={sidebarLocation === 'bottom' ? 'vertical' : 'horizontal'} settingName='propertiesSidebar'
@ -337,7 +338,7 @@ export const Workbench: React.FunctionComponent<{
orientation='horizontal' orientation='horizontal'
sidebarIsFirst sidebarIsFirst
settingName='actionListSidebar' settingName='actionListSidebar'
main={<SnapshotTab main={<SnapshotTabsView
action={activeAction} action={activeAction}
model={model} model={model}
sdkLanguage={sdkLanguage} 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[], tabs: TabbedPaneTabModel[],
leftToolbar?: React.ReactElement[], leftToolbar?: React.ReactElement[],
rightToolbar?: React.ReactElement[], rightToolbar?: React.ReactElement[],
selectedTab: string, selectedTab?: string,
setSelectedTab: (tab: string) => void, setSelectedTab?: (tab: string) => void,
dataTestId?: string, dataTestId?: string,
mode?: 'default' | 'select', mode?: 'default' | 'select',
}> = ({ tabs, selectedTab, setSelectedTab, leftToolbar, rightToolbar, dataTestId, mode }) => { }> = ({ tabs, selectedTab, setSelectedTab, leftToolbar, rightToolbar, dataTestId, mode }) => {
if (!selectedTab)
selectedTab = tabs[0].id;
if (!mode) if (!mode)
mode = 'default'; mode = 'default';
return <div className='tabbed-pane' data-testid={dataTestId}> return <div className='tabbed-pane' data-testid={dataTestId}>
@ -60,7 +62,7 @@ export const TabbedPane: React.FunctionComponent<{
</div>} </div>}
{mode === 'select' && <div style={{ flex: 'auto', display: 'flex', height: '100%', overflow: 'hidden' }}> {mode === 'select' && <div style={{ flex: 'auto', display: 'flex', height: '100%', overflow: 'hidden' }}>
<select style={{ width: '100%', background: 'none', cursor: 'pointer' }} onChange={e => { <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 => { {tabs.map(tab => {
let suffix = ''; let suffix = '';
@ -95,10 +97,10 @@ export const TabbedPaneTab: React.FunctionComponent<{
count?: number, count?: number,
errorCount?: number, errorCount?: number,
selected?: boolean, selected?: boolean,
onSelect: (id: string) => void onSelect?: (id: string) => void
}> = ({ id, title, count, errorCount, selected, onSelect }) => { }> = ({ id, title, count, errorCount, selected, onSelect }) => {
return <div className={clsx('tabbed-pane-tab', selected && 'selected')} return <div className={clsx('tabbed-pane-tab', selected && 'selected')}
onClick={() => onSelect(id)} onClick={() => onSelect?.(id)}
title={title} title={title}
key={id}> key={id}>
<div className='tabbed-pane-tab-label'>{title}</div> <div className='tabbed-pane-tab-label'>{title}</div>

View file

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

View file

@ -21,6 +21,7 @@ import * as React from 'react';
type ToolbarProps = { type ToolbarProps = {
noShadow?: boolean; noShadow?: boolean;
noMinHeight?: boolean; noMinHeight?: boolean;
sidebarBackground?: boolean;
className?: string; className?: string;
onClick?: (e: React.MouseEvent) => void; onClick?: (e: React.MouseEvent) => void;
}; };
@ -30,7 +31,8 @@ export const Toolbar: React.FC<React.PropsWithChildren<ToolbarProps>> = ({
children, children,
noMinHeight, noMinHeight,
className, className,
sidebarBackground,
onClick, 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(' '); 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'; 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'); 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, maxFailures: 0,
timeout: 15 * 1000, timeout: 15 * 1000,
globalTimeout: 30 * 60 * 1000, globalTimeout: 60 * 60 * 1000,
workers: process.env.CI ? 2 : undefined, workers: process.env.CI ? 2 : undefined,
fullyParallel: !process.env.CI, fullyParallel: !process.env.CI,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,

View file

@ -21,6 +21,7 @@ import * as playwrightLibrary from 'playwright-core';
export type TestModeWorkerOptions = { export type TestModeWorkerOptions = {
mode: TestModeName; mode: TestModeName;
codegenMode: 'trace-events' | 'actions';
}; };
export type TestModeTestFixtures = { export type TestModeTestFixtures = {
@ -48,6 +49,7 @@ export const testModeTest = test.extend<TestModeTestFixtures, TestModeWorkerOpti
await run(playwright); await run(playwright);
await testMode.teardown(); await testMode.teardown();
}, { scope: 'worker' }], }, { scope: 'worker' }],
codegenMode: ['actions', { scope: 'worker', option: true }],
toImplInWorkerScope: [async ({ playwright }, use) => { toImplInWorkerScope: [async ({ playwright }, use) => {
await use((playwright as any)._toImpl); 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`); return await this.page.waitForSelector(`.list-view-entry:has-text("${action}") .action-icons`);
} }
@step
async selectAction(title: string, ordinal: number = 0) { async selectAction(title: string, ordinal: number = 0) {
await this.page.locator(`.action-title:has-text("${title}")`).nth(ordinal).click(); await this.page.locator(`.action-title:has-text("${title}")`).nth(ordinal).click();
} }
@step
async selectSnapshot(name: string) { async selectSnapshot(name: string) {
await this.page.click(`.snapshot-tab .tabbed-pane-tab-label:has-text("${name}")`); 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[] }> { 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 backend = new TraceBackend(file);
const traceModel = new TraceModel(); const traceModel = new TraceModel();
await traceModel.load(backend, () => {}); await traceModel.load(backend, false, () => {});
const model = new MultiTraceModel(traceModel.contextEntries); const model = new MultiTraceModel(traceModel.contextEntries);
const { rootItem } = buildActionTree(model.actions); const { rootItem } = buildActionTree(model.actions);
const actionTree: string[] = []; const actionTree: string[] = [];

View file

@ -146,6 +146,27 @@ it('should support request/response events when using backgroundPage()', async (
await context.close(); 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 }) => { it('should not create pages automatically', async ({ browserType }) => {
const browser = await browserType.launch(); const browser = await browserType.launch();
const browserSession = await browser.newBrowserCDPSession(); 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); await assertOOPIFCount(browser, 1);
const handle1 = await page.frames()[1].$('.box:nth-of-type(13)'); 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([ await Promise.all([
page.frames()[1].waitForNavigation(), page.frames()[1].waitForNavigation(),
@ -277,7 +277,7 @@ it('ElementHandle.boundingBox() should work', async function({ page, browser, se
]); ]);
await assertOOPIFCount(browser, 0); await assertOOPIFCount(browser, 0);
const handle2 = await page.frames()[1].$('.box:nth-of-type(13)'); 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 }) { it('should click', async function({ page, browser, server }) {

View file

@ -19,6 +19,7 @@ import type { ConsoleMessage } from 'playwright';
test.describe('cli codegen', () => { test.describe('cli codegen', () => {
test.skip(({ mode }) => mode !== 'default'); test.skip(({ mode }) => mode !== 'default');
test.skip(({ trace, codegenMode }) => trace === 'on' && codegenMode === 'trace-events');
test('should click', async ({ openRecorder }) => { test('should click', async ({ openRecorder }) => {
const { page, recorder } = await openRecorder(); const { page, recorder } = await openRecorder();
@ -412,7 +413,7 @@ await page.GetByRole(AriaRole.Textbox).PressAsync("Shift+Enter");`);
expect(messages[0].text()).toBe('press'); 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(); const { page, recorder } = await openRecorder();
await recorder.setContentAndWait(` await recorder.setContentAndWait(`
@ -428,6 +429,9 @@ await page.GetByRole(AriaRole.Textbox).PressAsync("Shift+Enter");`);
await page.keyboard.press('Tab'); await page.keyboard.press('Tab');
await recorder.waitForOutput('JavaScript', 'Tab'); await recorder.waitForOutput('JavaScript', 'Tab');
await page.keyboard.type('barfoo321'); 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'); await recorder.waitForOutput('JavaScript', 'barfoo321');
const text = recorder.sources().get('JavaScript')!.text; const text = recorder.sources().get('JavaScript')!.text;

View file

@ -20,6 +20,7 @@ import fs from 'fs';
test.describe('cli codegen', () => { test.describe('cli codegen', () => {
test.skip(({ mode }) => mode !== 'default'); test.skip(({ mode }) => mode !== 'default');
test.skip(({ trace, codegenMode }) => trace === 'on' && codegenMode === 'trace-events');
test('should contain open page', async ({ openRecorder }) => { test('should contain open page', async ({ openRecorder }) => {
const { recorder } = await 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(); const { page, recorder } = await openRecorder();
await recorder.setContentAndWait(`<a href="about:blank?foo">link</a>`); await recorder.setContentAndWait(`<a href="about:blank?foo">link</a>`);
@ -425,7 +427,7 @@ await page1.GotoAsync("about:blank?foo");`);
page.click('button'), page.click('button'),
recorder.waitForOutput('JavaScript', '.click(') 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 }) => { 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');`); 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 traceFileName = testInfo.outputPath('trace.zip');
const cli = runCLI([`--save-trace=${traceFileName}`], { const cli = runCLI([`--save-trace=${traceFileName}`], {
autoExitWhen: ' ', autoExitWhen: ' ',
@ -499,7 +502,8 @@ await page1.GotoAsync("about:blank?foo");`);
expect(fs.existsSync(traceFileName)).toBeTruthy(); 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'); test.skip(platform === 'win32', 'SIGINT not supported on Windows');
const traceFileName = testInfo.outputPath('trace.zip'); const traceFileName = testInfo.outputPath('trace.zip');
@ -546,18 +550,17 @@ await page.Locator("#textarea").FillAsync(\"Hello'\\"\`\\nWorld\");`);
expect(message.text()).toBe('Hello\'\"\`\nWorld'); expect(message.text()).toBe('Hello\'\"\`\nWorld');
}); });
}); test('should --test-id-attribute', async ({ openRecorder }) => {
const { page, recorder } = await openRecorder({ testIdAttributeName: 'my-test-id' });
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]');
await recorder.setContentAndWait(`<div my-test-id="foo">Hello</div>`); const sources = await recorder.waitForOutput('JavaScript', `page.getByTestId`);
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('JavaScript')!.text).toContain(`await page.getByTestId('foo').click()`); expect.soft(sources.get('Python')!.text).toContain(`page.get_by_test_id("foo").click()`);
expect.soft(sources.get('Java')!.text).toContain(`page.getByTestId("foo").click()`); expect.soft(sources.get('Python Async')!.text).toContain(`await page.get_by_test_id("foo").click()`);
expect.soft(sources.get('Python')!.text).toContain(`page.get_by_test_id("foo").click()`); expect.soft(sources.get('C#')!.text).toContain(`await page.GetByTestId("foo").ClickAsync();`);
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.describe('cli codegen', () => {
test.skip(({ mode }) => mode !== 'default'); test.skip(({ mode }) => mode !== 'default');
test.skip(({ trace, codegenMode }) => trace === 'on' && codegenMode === 'trace-events');
test('should click locator.first', async ({ openRecorder }) => { test('should click locator.first', async ({ openRecorder }) => {
const { page, recorder } = await 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')); testInfo.skip(mode.startsWith('service'));
await run((cliArgs, { autoExitWhen } = {}) => { 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 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(); const page = await context.newPage();
return { page, recorder: new Recorder(page, await recorderPageGetter()) }; return { page, recorder: new Recorder(page, await recorderPageGetter()) };
}); });
@ -205,23 +218,24 @@ class Recorder {
class CLIMock { class CLIMock {
process: TestChildProcess; 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 = [ const nodeArgs = [
'node', 'node',
path.join(__dirname, '..', '..', '..', 'packages', 'playwright-core', 'cli.js'), path.join(__dirname, '..', '..', '..', 'packages', 'playwright-core', 'cli.js'),
'codegen', 'codegen',
...args, ...options.args,
`--browser=${browserName}`, `--browser=${options.browserName}`,
]; ];
if (channel) if (options.channel)
nodeArgs.push(`--channel=${channel}`); nodeArgs.push(`--channel=${options.channel}`);
this.process = childProcess({ this.process = childProcess({
command: nodeArgs, command: nodeArgs,
env: { 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_IS_UNDER_TEST: '1',
PWTEST_CLI_HEADLESS: headless ? '1' : undefined, PWTEST_CLI_HEADLESS: options.headless ? '1' : undefined,
PWTEST_CLI_EXECUTABLE_PATH: executablePath, PWTEST_CLI_EXECUTABLE_PATH: options.executablePath,
DEBUG: (process.env.DEBUG ?? '') + ',pw:browser*', 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');
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({ const context = await browser.newContext({
deviceScaleFactor: 2 deviceScaleFactor: 2
}); });
@ -93,9 +93,9 @@ it('Safari Desktop', async ({ browser, browserName, platform, server, headless,
expect(actual).toEqual(expected); 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');
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 iPhone = playwright.devices['iPhone 12'];
const context = await browser.newContext(iPhone); const context = await browser.newContext(iPhone);
const { actual, expected } = await checkFeatures('mobile-safari-18', context, server); 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}`); console.error(`Using executable at ${executablePath}`);
const devtools = process.env.DEVTOOLS === '1'; const devtools = process.env.DEVTOOLS === '1';
const testIgnore: RegExp[] = browserNames.filter(b => b !== browserName).map(b => new RegExp(b)); const testIgnore: RegExp[] = browserNames.filter(b => b !== browserName).map(b => new RegExp(b));
for (const folder of ['library', 'page']) {
config.projects.push({ const projectTemplate: typeof config.projects[0] = {
name: `${browserName}-${folder}`, testIgnore,
testDir: path.join(testDir, folder), snapshotPathTemplate: `{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-${browserName}{ext}`,
testIgnore, use: {
snapshotPathTemplate: `{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-${browserName}{ext}`, mode,
use: { browserName,
mode, headless: !headed,
browserName, channel,
headless: !headed, video: video ? 'on' : undefined,
channel, launchOptions: {
video: video ? 'on' : undefined, executablePath,
launchOptions: { devtools
executablePath,
devtools
},
trace: trace ? 'on' : undefined,
}, },
metadata: { trace: trace ? 'on' : undefined,
platform: process.platform, },
docker: !!process.env.INSIDE_DOCKER, metadata: {
headless: (() => { platform: process.platform,
if (process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW) docker: !!process.env.INSIDE_DOCKER,
return 'headless-new'; headless: (() => {
if (headed) if (process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW)
return 'headed'; return 'headless-new';
return 'headless'; if (headed)
})(), return 'headed';
browserName, return 'headless';
channel, })(),
mode, browserName,
video: !!video, channel,
trace: !!trace, mode,
clock: process.env.PW_CLOCK ? 'clock-' + process.env.PW_CLOCK : undefined, 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; 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 === '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' && 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(); const upgradePromise = server.waitForUpgrade();
await setupWS(page, server.PORT, 'blob'); 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) => { test('should properly synchronize local and remote time', async ({ showTraceViewer, asset }, testInfo) => {
const traceViewer = await showTraceViewer([asset('trace-remote-time-diff.zip')]); const traceViewer = await showTraceViewer([asset('trace-remote-time-diff.zip')]);
// The total duration should be sub 10s, rather than 16h. // 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 }) => { 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('should not crash with broken locator', async ({ page, runAndTrace, server }) => {
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/21832' }); test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/21832' });
const traceViewer = await runAndTrace(async () => { const traceViewer = await runAndTrace(async () => {
@ -999,37 +979,6 @@ test('should not crash with broken locator', async ({ page, runAndTrace, server
await expect(header).toBeVisible(); 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 }) => { test('should serve overridden request', async ({ page, runAndTrace, server }) => {
server.setRoute('/custom.css', (req, res) => { server.setRoute('/custom.css', (req, res) => {
res.writeHead(200, { res.writeHead(200, {
@ -1416,7 +1365,7 @@ test('should show correct request start time', {
expect(parseMillis(start)).toBeLessThan(1000); 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' }, annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30970' },
}, async ({ page, runAndTrace, server }) => { }, async ({ page, runAndTrace, server }) => {
const traceViewer = await runAndTrace(async () => { const traceViewer = await runAndTrace(async () => {
@ -1426,28 +1375,9 @@ test('should allow hiding route actions', {
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
}); });
// Routes are visible by default.
await expect(traceViewer.actionTitles).toHaveText([ await expect(traceViewer.actionTitles).toHaveText([
/page.route/, /page.route/,
/page.goto.*empty.html/, /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 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(); const snapshot = (await traceViewer.snapshotFrame('page.goto')).owner();
await expect(snapshot).toBeVisible(); await expect(snapshot).toBeVisible();
await expect(screenshot).not.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) => { test('should use the correct apiName for event driven callbacks', async ({ context, page, server }, testInfo) => {
await context.tracing.start(); await context.tracing.start();
// route.* calls should not be included in the trace
await page.route('**/empty.html', route => route.continue()); 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'); 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'); await page.goto(server.PREFIX + '/grid.html');
// The default internal dialog handler should not provide an action. // 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([ expect(actions).toEqual([
'page.route', 'page.route',
'page.goto', 'page.goto',
'route.continue',
'page.goto', 'page.goto',
'page.evaluate', 'page.evaluate',
'page.reload', '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.isNavigationRequest()).toBe(false);
expect(request.resourceType()).toBe('xhr'); 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'); 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('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' }); 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(); expect(leakedJSHandles()).toBeFalsy();
if (browserName === 'chromium') { if (browserName === 'chromium') {
const counts = await objectCounts(toImpl(page), 'HTMLButtonElement'); await expect(async () => {
expect(counts.main + counts.utility).toBeGreaterThanOrEqual(2); const counts = await objectCounts(toImpl(page), 'HTMLButtonElement');
expect(counts.main + counts.utility).toBeLessThan(25); 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(); expect(leakedJSHandles()).toBeFalsy();
if (browserName === 'chromium') { if (browserName === 'chromium') {
const counts = await objectCounts(toImpl(page), 'HTMLInputElement'); await expect(async () => {
expect(counts.main + counts.utility).toBeGreaterThanOrEqual(2); const counts = await objectCounts(toImpl(page), 'HTMLInputElement');
expect(counts.main + counts.utility).toBeLessThan(25); 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(); expect(leakedJSHandles()).toBeFalsy();
if (browserName === 'chromium') { if (browserName === 'chromium') {
const counts = await objectCounts(toImpl(page), 'HTMLButtonElement'); await expect(async () => {
expect(counts.main + counts.utility).toBeGreaterThanOrEqual(2); const counts = await objectCounts(toImpl(page), 'HTMLButtonElement');
expect(counts.main + counts.utility).toBeLessThan(25); 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(); expect(leakedJSHandles()).toBeFalsy();
if (browserName === 'chromium') { if (browserName === 'chromium') {
const counts = await objectCounts(toImpl(page), 'HTMLButtonElement'); await expect(async () => {
expect(counts.main + counts.utility).toBeGreaterThanOrEqual(2); const counts = await objectCounts(toImpl(page), 'HTMLButtonElement');
expect(counts.main + counts.utility).toBeLessThan(25); 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'); 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'); it.fixme(isElectron && isMac, 'Fails on the bots');
await page.setViewportSize({ width: 500, height: 500 }); await page.setViewportSize({ width: 500, height: 500 });
await page.goto(server.PREFIX + '/screenshots/canvas.html'); await page.goto(server.PREFIX + '/screenshots/canvas.html');
const screenshot = await page.screenshot(); 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 }) => { 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('should work for webgl', async ({ page, server, browserName, platform }) => {
it.fixme(browserName === 'firefox'); 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.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.setViewportSize({ width: 640, height: 480 });
await page.goto(server.PREFIX + '/screenshots/webgl.html'); await page.goto(server.PREFIX + '/screenshots/webgl.html');

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -1069,3 +1069,37 @@ test('expect.extend should be immutable', async ({ runInlineTest }) => {
'bar', '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(`init --initial-branch=main`);
git(`config --local user.name "Robert Botman"`); git(`config --local user.name "Robert Botman"`);
git(`config --local user.email "botty@mcbotface.com"`); git(`config --local user.email "botty@mcbotface.com"`);
git(`config --local core.autocrlf false`);
await use((command: string) => git(command)); 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', ' browserContext.newPage',
'page.route', 'page.route',
'page.goto', 'page.goto',
'route.fetch',
'expect.toBeVisible', 'expect.toBeVisible',
'page.unrouteAll', 'page.unrouteAll',
'After Hooks', '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 }) => { test('should not propagate errors from within toPass', async ({ runInlineTest }) => {
const result = await runInlineTest({ const result = await runInlineTest({
'reporter.ts': stepIndentReporter, '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%)'); 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); method.argsArray.push(options);
} }
p.required = false; 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.alias = match[1];
this.overloadIndex = (+match[2]) - 1; this.overloadIndex = (+match[2]) - 1;
} }
/**
* Param is true and option false
* @type {Boolean | null}
*/
this.paramOrOption = null;
} }
index() { index() {
@ -384,10 +379,8 @@ class Member {
for (const arg of this.argsArray) { for (const arg of this.argsArray) {
this.args.set(arg.name, arg); this.args.set(arg.name, arg);
arg.enclosingMethod = this; arg.enclosingMethod = this;
if (arg.name === 'options') { if (arg.name === 'options')
// @ts-ignore arg.type?.properties?.sort((p1, p2) => p1.name.localeCompare(p2.name));
arg.type.properties.sort((p1, p2) => p1.name.localeCompare(p2.name));
}
indexArg(arg); indexArg(arg);
} }
} }
@ -410,11 +403,9 @@ class Member {
continue; continue;
const overriddenArg = (arg.langs.overrides && arg.langs.overrides[lang]) || arg; const overriddenArg = (arg.langs.overrides && arg.langs.overrides[lang]) || arg;
overriddenArg.filterForLanguage(lang, options); overriddenArg.filterForLanguage(lang, options);
// @ts-ignore if (overriddenArg.name === 'options' && !overriddenArg.type?.properties?.length)
if (overriddenArg.name === 'options' && !overriddenArg.type.properties.length)
continue; continue;
// @ts-ignore overriddenArg.type?.filterForLanguage(lang, options);
overriddenArg.type.filterForLanguage(lang, options);
argsArray.push(overriddenArg); argsArray.push(overriddenArg);
} }
this.argsArray = argsArray; 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); 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.alias = this.alias;
result.async = this.async; result.async = this.async;
result.paramOrOption = this.paramOrOption;
return result; return result;
} }
@ -526,8 +516,7 @@ class Type {
if (!inUnion && (parsedType.union || parsedType.unionName)) { if (!inUnion && (parsedType.union || parsedType.unionName)) {
const type = new Type(parsedType.unionName || ''); const type = new Type(parsedType.unionName || '');
type.union = []; type.union = [];
// @ts-ignore for (let /** @type {ParsedType | null} */ t = parsedType; t; t = t.union) {
for (let t = parsedType; t; t = t.union) {
const nestedUnion = !!t.unionName && t !== parsedType; const nestedUnion = !!t.unionName && t !== parsedType;
type.union.push(Type.fromParsedType(t, !nestedUnion)); type.union.push(Type.fromParsedType(t, !nestedUnion));
if (nestedUnion) if (nestedUnion)
@ -539,7 +528,6 @@ class Type {
if (parsedType.args || parsedType.retType) { if (parsedType.args || parsedType.retType) {
const type = new Type('function'); const type = new Type('function');
type.args = []; type.args = [];
// @ts-ignore
for (let t = parsedType.args; t; t = t.next) for (let t = parsedType.args; t; t = t.next)
type.args.push(Type.fromParsedType(t)); type.args.push(Type.fromParsedType(t));
type.returnType = parsedType.retType ? Type.fromParsedType(parsedType.retType) : undefined; type.returnType = parsedType.retType ? Type.fromParsedType(parsedType.retType) : undefined;
@ -549,8 +537,7 @@ class Type {
if (parsedType.template) { if (parsedType.template) {
const type = new Type(parsedType.name); const type = new Type(parsedType.name);
type.templates = []; type.templates = [];
// @ts-ignore for (let /** @type {ParsedType | null} */ t = parsedType.template; t; t = t.next)
for (let t = parsedType.template; t; t = t.next)
type.templates.push(Type.fromParsedType(t)); type.templates.push(Type.fromParsedType(t));
return type; return type;
} }
@ -613,17 +600,6 @@ class Type {
return []; 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 {string} lang
* @param {LanguageOptions=} options * @param {LanguageOptions=} options
@ -768,11 +744,10 @@ function patchLinksInText(classOrMember, text, classesMap, membersMap, linkRende
let alias = p2; let alias = p2;
if (classOrMember) { if (classOrMember) {
// param/option reference can only be in method or same method parameter comments. // param/option reference can only be in method or same method parameter comments.
// @ts-ignore const method = /** @type {Member} */(classOrMember).enclosingMethod;
const method = classOrMember.enclosingMethod; const param = method?.argsArray.find(a => a.name === p2);
const param = method.argsArray.find(a => a.name === p2);
if (!param) 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; alias = param.alias;
} }
return linkRenderer({ param: alias, href }) || match; return linkRenderer({ param: alias, href }) || match;

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