diff --git a/docs/src/api/class-frame.md b/docs/src/api/class-frame.md index f3f308622f..bf3666b9be 100644 --- a/docs/src/api/class-frame.md +++ b/docs/src/api/class-frame.md @@ -280,7 +280,7 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Frame.click.timeout = %%-input-timeout-js-%% * since: v1.8 -### option: Frame.click.trial = %%-input-trial-%% +### option: Frame.click.trial = %%-input-trial-with-modifiers-%% * since: v1.11 ## async method: Frame.content @@ -341,7 +341,7 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Frame.dblclick.timeout = %%-input-timeout-js-%% * since: v1.8 -### option: Frame.dblclick.trial = %%-input-trial-%% +### option: Frame.dblclick.trial = %%-input-trial-with-modifiers-%% * since: v1.11 ## async method: Frame.dispatchEvent @@ -1153,7 +1153,7 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Frame.hover.timeout = %%-input-timeout-js-%% * since: v1.8 -### option: Frame.hover.trial = %%-input-trial-%% +### option: Frame.hover.trial = %%-input-trial-with-modifiers-%% * since: v1.11 ### option: Frame.hover.noWaitAfter = %%-input-no-wait-after-removed-%% @@ -1703,7 +1703,7 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Frame.tap.timeout = %%-input-timeout-js-%% * since: v1.8 -### option: Frame.tap.trial = %%-input-trial-%% +### option: Frame.tap.trial = %%-input-trial-with-modifiers-%% * since: v1.11 ## async method: Frame.textContent diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index 88658b5494..86de53841c 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -433,7 +433,7 @@ await page.Locator("canvas").ClickAsync(new() { ### option: Locator.click.timeout = %%-input-timeout-js-%% * since: v1.14 -### option: Locator.click.trial = %%-input-trial-%% +### option: Locator.click.trial = %%-input-trial-with-modifiers-%% * since: v1.14 ## async method: Locator.count @@ -516,7 +516,7 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Locator.dblclick.timeout = %%-input-timeout-js-%% * since: v1.14 -### option: Locator.dblclick.trial = %%-input-trial-%% +### option: Locator.dblclick.trial = %%-input-trial-with-modifiers-%% * since: v1.14 ## async method: Locator.dispatchEvent @@ -1266,7 +1266,7 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Locator.hover.timeout = %%-input-timeout-js-%% * since: v1.14 -### option: Locator.hover.trial = %%-input-trial-%% +### option: Locator.hover.trial = %%-input-trial-with-modifiers-%% * since: v1.14 ### option: Locator.hover.noWaitAfter = %%-input-no-wait-after-removed-%% @@ -2331,7 +2331,7 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Locator.tap.timeout = %%-input-timeout-js-%% * since: v1.14 -### option: Locator.tap.trial = %%-input-trial-%% +### option: Locator.tap.trial = %%-input-trial-with-modifiers-%% * since: v1.14 ## async method: Locator.textContent diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index c0890d04ff..b437b9313e 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -812,7 +812,7 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Page.click.timeout = %%-input-timeout-js-%% * since: v1.8 -### option: Page.click.trial = %%-input-trial-%% +### option: Page.click.trial = %%-input-trial-with-modifiers-%% * since: v1.11 ## async method: Page.close @@ -915,7 +915,7 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Page.dblclick.timeout = %%-input-timeout-js-%% * since: v1.8 -### option: Page.dblclick.trial = %%-input-trial-%% +### option: Page.dblclick.trial = %%-input-trial-with-modifiers-%% * since: v1.11 ## async method: Page.dispatchEvent @@ -2437,7 +2437,7 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Page.hover.timeout = %%-input-timeout-js-%% * since: v1.8 -### option: Page.hover.trial = %%-input-trial-%% +### option: Page.hover.trial = %%-input-trial-with-modifiers-%% * since: v1.11 ### option: Page.hover.noWaitAfter = %%-input-no-wait-after-removed-%% @@ -4099,7 +4099,7 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Page.tap.timeout = %%-input-timeout-js-%% * since: v1.8 -### option: Page.tap.trial = %%-input-trial-%% +### option: Page.tap.trial = %%-input-trial-with-modifiers-%% * since: v1.11 ## async method: Page.textContent diff --git a/docs/src/api/params.md b/docs/src/api/params.md index de930ee97e..4a0e3af657 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -136,6 +136,11 @@ defaults to 1. See [UIEvent.detail]. When set, this method only performs the [actionability](../actionability.md) checks and skips the action. Defaults to `false`. Useful to wait until the element is ready for the action without performing it. +## input-trial-with-modifiers +- `trial` <[boolean]> + +When set, this method only performs the [actionability](../actionability.md) checks and skips the action. Defaults to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys are pressed. + ## input-source-position - `sourcePosition` <[Object]> - `x` <[float]> diff --git a/docs/src/ci.md b/docs/src/ci.md index 745ecc9d50..f4c11c9984 100644 --- a/docs/src/ci.md +++ b/docs/src/ci.md @@ -85,7 +85,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 18 + node-version: lts/* - name: Install dependencies run: npm ci - name: Install Playwright Browsers @@ -214,7 +214,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 18 + node-version: lts/* - name: Install dependencies run: npm ci - name: Run your tests @@ -319,7 +319,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 18 + node-version: lts/* - name: Install dependencies run: npm ci - name: Install Playwright @@ -434,7 +434,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-node@v4 with: - node-version: 18 + node-version: lts/* - name: Install dependencies run: npm ci - name: Install Playwright Browsers diff --git a/docs/src/network.md b/docs/src/network.md index 152231556e..4d6f229678 100644 --- a/docs/src/network.md +++ b/docs/src/network.md @@ -700,6 +700,27 @@ await Page.RouteAsync("**/title.html", async route => }); ``` +## Glob URL patterns + +Playwright uses simplified glob patterns for URL matching in network interception methods like [`method: Page.route`] or [`method: Page.waitForResponse`]. These patterns support basic wildcards: + +1. Asterisks: + - A single `*` matches any characters except `/` + - A double `**` matches any characters including `/` +1. Question mark `?` matches any single character except `/` +1. Curly braces `{}` can be used to match a list of options separated by commas `,` + +Examples: +- `https://example.com/*.js` matches `https://example.com/file.js` but not `https://example.com/path/file.js` +- `**/*.js` matches both `https://example.com/file.js` and `https://example.com/path/file.js` +- `**/*.{png,jpg,jpeg}` matches all image requests + +Important notes: + +- The glob pattern must match the entire URL, not just a part of it. +- When using globs for URL matching, consider the full URL structure, including the protocol and path separators. +- For more complex matching requirements, consider using [RegExp] instead of glob patterns. + ## WebSockets Playwright supports [WebSockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) inspection out of the box. Every time a WebSocket is created, the [`event: Page.webSocket`] event is fired. This event contains the [WebSocket] instance for further web socket frames inspection: diff --git a/docs/src/test-sharding-js.md b/docs/src/test-sharding-js.md index d0312db811..263733e78d 100644 --- a/docs/src/test-sharding-js.md +++ b/docs/src/test-sharding-js.md @@ -85,7 +85,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 18 + node-version: lts/* - name: Install dependencies run: npm ci - name: Install Playwright browsers @@ -118,7 +118,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 18 + node-version: lts/* - name: Install dependencies run: npm ci diff --git a/package-lock.json b/package-lock.json index d113e1c42e..fded01102a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1517,9 +1517,9 @@ "link": true }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.3.tgz", - "integrity": "sha512-MmKSfaB9GX+zXl6E8z4koOr/xU63AMVleLEa64v7R0QF/ZloMs5vcD1sHgM64GXXS1csaJutG+ddtzcueI/BLg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", + "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", "cpu": [ "arm" ], @@ -1529,9 +1529,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.3.tgz", - "integrity": "sha512-zrt8ecH07PE3sB4jPOggweBjJMzI1JG5xI2DIsUbkA+7K+Gkjys6eV7i9pOenNSDJH3eOr/jLb/PzqtmdwDq5g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", + "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", "cpu": [ "arm64" ], @@ -1541,9 +1541,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.3.tgz", - "integrity": "sha512-P0UxIOrKNBFTQaXTxOH4RxuEBVCgEA5UTNV6Yz7z9QHnUJ7eLX9reOd/NYMO3+XZO2cco19mXTxDMXxit4R/eQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", + "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", "cpu": [ "arm64" ], @@ -1553,9 +1553,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.3.tgz", - "integrity": "sha512-L1M0vKGO5ASKntqtsFEjTq/fD91vAqnzeaF6sfNAy55aD+Hi2pBI5DKwCO+UNDQHWsDViJLqshxOahXyLSh3EA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", + "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", "cpu": [ "x64" ], @@ -1565,9 +1565,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.3.tgz", - "integrity": "sha512-btVgIsCjuYFKUjopPoWiDqmoUXQDiW2A4C3Mtmp5vACm7/GnyuprqIDPNczeyR5W8rTXEbkmrJux7cJmD99D2g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", + "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", "cpu": [ "arm" ], @@ -1577,9 +1577,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.3.tgz", - "integrity": "sha512-zmjbSphplZlau6ZTkxd3+NMtE4UKVy7U4aVFMmHcgO5CUbw17ZP6QCgyxhzGaU/wFFdTfiojjbLG3/0p9HhAqA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", + "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", "cpu": [ "arm" ], @@ -1589,9 +1589,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.3.tgz", - "integrity": "sha512-nSZfcZtAnQPRZmUkUQwZq2OjQciR6tEoJaZVFvLHsj0MF6QhNMg0fQ6mUOsiCUpTqxTx0/O6gX0V/nYc7LrgPw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", + "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", "cpu": [ "arm64" ], @@ -1601,9 +1601,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.3.tgz", - "integrity": "sha512-MnvSPGO8KJXIMGlQDYfvYS3IosFN2rKsvxRpPO2l2cum+Z3exiExLwVU+GExL96pn8IP+GdH8Tz70EpBhO0sIQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", + "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", "cpu": [ "arm64" ], @@ -1613,9 +1613,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.3.tgz", - "integrity": "sha512-+W+p/9QNDr2vE2AXU0qIy0qQE75E8RTwTwgqS2G5CRQ11vzq0tbnfBd6brWhS9bCRjAjepJe2fvvkvS3dno+iw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", + "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", "cpu": [ "ppc64" ], @@ -1625,9 +1625,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.3.tgz", - "integrity": "sha512-yXH6K6KfqGXaxHrtr+Uoy+JpNlUlI46BKVyonGiaD74ravdnF9BUNC+vV+SIuB96hUMGShhKV693rF9QDfO6nQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", + "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", "cpu": [ "riscv64" ], @@ -1637,9 +1637,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.3.tgz", - "integrity": "sha512-R8cwY9wcnApN/KDYWTH4gV/ypvy9yZUHlbJvfaiXSB48JO3KpwSpjOGqO4jnGkLDSk1hgjYkTbTt6Q7uvPf8eg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", + "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", "cpu": [ "s390x" ], @@ -1649,9 +1649,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.3.tgz", - "integrity": "sha512-kZPbX/NOPh0vhS5sI+dR8L1bU2cSO9FgxwM8r7wHzGydzfSjLRCFAT87GR5U9scj2rhzN3JPYVC7NoBbl4FZ0g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", + "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", "cpu": [ "x64" ], @@ -1661,9 +1661,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.3.tgz", - "integrity": "sha512-S0Yq+xA1VEH66uiMNhijsWAafffydd2X5b77eLHfRmfLsRSpbiAWiRHV6DEpz6aOToPsgid7TI9rGd6zB1rhbg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", + "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", "cpu": [ "x64" ], @@ -1673,9 +1673,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.3.tgz", - "integrity": "sha512-9isNzeL34yquCPyerog+IMCNxKR8XYmGd0tHSV+OVx0TmE0aJOo9uw4fZfUuk2qxobP5sug6vNdZR6u7Mw7Q+Q==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", + "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", "cpu": [ "arm64" ], @@ -1685,9 +1685,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.3.tgz", - "integrity": "sha512-nMIdKnfZfzn1Vsk+RuOvl43ONTZXoAPUUxgcU0tXooqg4YrAqzfKzVenqqk2g5efWh46/D28cKFrOzDSW28gTA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", + "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", "cpu": [ "ia32" ], @@ -1697,9 +1697,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.3.tgz", - "integrity": "sha512-fOvu7PCQjAj4eWDEuD8Xz5gpzFqXzGlxHZozHP4b9Jxv9APtdxL6STqztDzMLuRXEc4UpXGGhx029Xgm91QBeA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", + "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", "cpu": [ "x64" ], @@ -5417,9 +5417,9 @@ "dev": true }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "dependencies": { "braces": "^3.0.3", @@ -6313,9 +6313,9 @@ } }, "node_modules/rollup": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.3.tgz", - "integrity": "sha512-7sqRtBNnEbcBtMeRVc6VRsJMmpI+JU1z9VTvW8D4gXIYQFz0aLcsE6rRkyghZkLfEgUZgVvOG7A5CVz/VW5GIA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", + "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", "dependencies": { "@types/estree": "1.0.5" }, @@ -6327,22 +6327,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.21.3", - "@rollup/rollup-android-arm64": "4.21.3", - "@rollup/rollup-darwin-arm64": "4.21.3", - "@rollup/rollup-darwin-x64": "4.21.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.21.3", - "@rollup/rollup-linux-arm-musleabihf": "4.21.3", - "@rollup/rollup-linux-arm64-gnu": "4.21.3", - "@rollup/rollup-linux-arm64-musl": "4.21.3", - "@rollup/rollup-linux-powerpc64le-gnu": "4.21.3", - "@rollup/rollup-linux-riscv64-gnu": "4.21.3", - "@rollup/rollup-linux-s390x-gnu": "4.21.3", - "@rollup/rollup-linux-x64-gnu": "4.21.3", - "@rollup/rollup-linux-x64-musl": "4.21.3", - "@rollup/rollup-win32-arm64-msvc": "4.21.3", - "@rollup/rollup-win32-ia32-msvc": "4.21.3", - "@rollup/rollup-win32-x64-msvc": "4.21.3", + "@rollup/rollup-android-arm-eabi": "4.22.4", + "@rollup/rollup-android-arm64": "4.22.4", + "@rollup/rollup-darwin-arm64": "4.22.4", + "@rollup/rollup-darwin-x64": "4.22.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", + "@rollup/rollup-linux-arm-musleabihf": "4.22.4", + "@rollup/rollup-linux-arm64-gnu": "4.22.4", + "@rollup/rollup-linux-arm64-musl": "4.22.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", + "@rollup/rollup-linux-riscv64-gnu": "4.22.4", + "@rollup/rollup-linux-s390x-gnu": "4.22.4", + "@rollup/rollup-linux-x64-gnu": "4.22.4", + "@rollup/rollup-linux-x64-musl": "4.22.4", + "@rollup/rollup-win32-arm64-msvc": "4.22.4", + "@rollup/rollup-win32-ia32-msvc": "4.22.4", + "@rollup/rollup-win32-x64-msvc": "4.22.4", "fsevents": "~2.3.2" } }, diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 5fd2927700..d8605f9ed9 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -9,9 +9,9 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1261", + "revision": "1263", "installByDefault": false, - "browserVersion": "131.0.6726.0" + "browserVersion": "131.0.6736.0" }, { "name": "firefox", @@ -27,7 +27,7 @@ }, { "name": "webkit", - "revision": "2080", + "revision": "2082", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", diff --git a/packages/playwright-core/src/server/bidi/bidiPage.ts b/packages/playwright-core/src/server/bidi/bidiPage.ts index 180e8a651e..a50217975a 100644 --- a/packages/playwright-core/src/server/bidi/bidiPage.ts +++ b/packages/playwright-core/src/server/bidi/bidiPage.ts @@ -22,6 +22,7 @@ import * as dom from '../dom'; import * as dialog from '../dialog'; import type * as frames from '../frames'; import { Page } from '../page'; +import type * as channels from '@protocol/channels'; import type { InitScript, PageDelegate } from '../page'; import type { Progress } from '../progress'; import type * as types from '../types'; @@ -32,6 +33,7 @@ import * as bidi from './third_party/bidiProtocol'; import { BidiExecutionContext } from './bidiExecutionContext'; import { BidiNetworkManager } from './bidiNetworkManager'; import { BrowserContext } from '../browserContext'; +import { BidiPDF } from './bidiPdf'; const UTILITY_WORLD_NAME = '__playwright_utility_world__'; const kPlaywrightBindingChannel = 'playwrightChannel'; @@ -48,6 +50,7 @@ export class BidiPage implements PageDelegate { private _sessionListeners: RegisteredListener[] = []; readonly _browserContext: BidiBrowserContext; readonly _networkManager: BidiNetworkManager; + private readonly _pdf: BidiPDF; _initializedPage: Page | null = null; private _initScriptIds: string[] = []; @@ -61,6 +64,7 @@ export class BidiPage implements PageDelegate { this._page = new Page(this, browserContext); this._browserContext = browserContext; this._networkManager = new BidiNetworkManager(this._session, this._page, this._onNavigationResponseStarted.bind(this)); + this._pdf = new BidiPDF(this._session); this._page.on(Page.Events.FrameDetached, (frame: frames.Frame) => this._removeContextsForFrame(frame, false)); this._sessionListeners = [ eventsHelper.addEventListener(bidiSession, 'script.realmCreated', this._onRealmCreated.bind(this)), @@ -279,6 +283,9 @@ export class BidiPage implements PageDelegate { } async bringToFront(): Promise { + await this._session.send('browsingContext.activate', { + context: this._session.sessionId, + }); } private async _updateViewport(): Promise { @@ -555,6 +562,10 @@ export class BidiPage implements PageDelegate { async resetForReuse(): Promise { } + async pdf(options: channels.PagePdfParams): Promise { + return this._pdf.generate(options); + } + async getFrameElement(frame: frames.Frame): Promise { const parent = frame.parentFrame(); if (!parent) diff --git a/packages/playwright-core/src/server/bidi/bidiPdf.ts b/packages/playwright-core/src/server/bidi/bidiPdf.ts new file mode 100644 index 0000000000..89fefb5260 --- /dev/null +++ b/packages/playwright-core/src/server/bidi/bidiPdf.ts @@ -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 { + 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'); + } +} diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index fbdc9db91a..bba14ff00e 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -694,16 +694,15 @@ class FrameSession { if (!frame || this._eventBelongsToStaleFrame(frame._id)) return; const delegate = new CRExecutionContext(this._client, contextPayload); - let worldName: types.World; + let worldName: types.World|null = null; if (contextPayload.auxData && !!contextPayload.auxData.isDefault) worldName = 'main'; else if (contextPayload.name === UTILITY_WORLD_NAME) worldName = 'utility'; - else - return; const context = new dom.FrameExecutionContext(delegate, frame, worldName); (context as any)[contextDelegateSymbol] = delegate; - frame._contextCreated(worldName, context); + if (worldName) + frame._contextCreated(worldName, context); this._contextIdToContext.set(contextPayload.id, context); } diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index 55105bd50c..05a8b4fda2 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -50,9 +50,9 @@ export function isNonRecoverableDOMError(error: Error) { export class FrameExecutionContext extends js.ExecutionContext { readonly frame: frames.Frame; private _injectedScriptPromise?: Promise; - readonly world: types.World; + readonly world: types.World | null; - constructor(delegate: js.ExecutionContextDelegate, frame: frames.Frame, world: types.World) { + constructor(delegate: js.ExecutionContextDelegate, frame: frames.Frame, world: types.World|null) { super(frame, delegate, world || 'content-script'); this.frame = frame; this.world = world; diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index 03a27954dd..9dfffc1eb3 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -163,16 +163,15 @@ export class FFPage implements PageDelegate { if (!frame) return; const delegate = new FFExecutionContext(this._session, executionContextId); - let worldName: types.World; + let worldName: types.World|null = null; if (auxData.name === UTILITY_WORLD_NAME) worldName = 'utility'; else if (!auxData.name) worldName = 'main'; - else - return; const context = new dom.FrameExecutionContext(delegate, frame, worldName); (context as any)[contextDelegateSymbol] = delegate; - frame._contextCreated(worldName, context); + if (worldName) + frame._contextCreated(worldName, context); this._contextIdToContext.set(executionContextId, context); } diff --git a/packages/playwright-core/src/server/injected/recorder/pollingRecorder.ts b/packages/playwright-core/src/server/injected/recorder/pollingRecorder.ts new file mode 100644 index 0000000000..57627f3723 --- /dev/null +++ b/packages/playwright-core/src/server/injected/recorder/pollingRecorder.ts @@ -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; + __pw_recorderRecordAction(action: actions.Action): Promise; + __pw_recorderState(): Promise; + __pw_recorderSetSelector(selector: string): Promise; + __pw_recorderSetMode(mode: Mode): Promise; + __pw_recorderSetOverlayState(state: OverlayState): Promise; + __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 { + await this._embedder.__pw_recorderRecordAction(action); + } + + async setSelector(selector: string): Promise { + await this._embedder.__pw_recorderSetSelector(selector); + } + + async setMode(mode: Mode): Promise { + await this._embedder.__pw_recorderSetMode(mode); + } + + async setOverlayState(state: OverlayState): Promise { + await this._embedder.__pw_recorderSetOverlayState(state); + } +} + +export default PollingRecorder; diff --git a/packages/playwright-core/src/server/injected/recorder/recorder.ts b/packages/playwright-core/src/server/injected/recorder/recorder.ts index 76d5791b64..757b413704 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -21,9 +21,8 @@ import type { Mode, OverlayState, UIState } from '@recorder/recorderTypes'; import type { ElementText } from '../selectorUtils'; import type { Highlight, HighlightOptions } from '../highlight'; import clipPaths from './clipPaths'; -import type { SimpleDomNode } from '../simpleDom'; -interface RecorderDelegate { +export interface RecorderDelegate { performAction?(action: actions.PerformOnRecordAction): Promise; recordAction?(action: actions.Action): Promise; setSelector?(selector: string): Promise; @@ -206,7 +205,7 @@ class InspectTool implements RecorderTool { class RecordActionTool implements RecorderTool { private _recorder: Recorder; - private _performingAction = false; + private _performingAction: actions.PerformOnRecordAction | null = null; private _hoveredModel: HighlightModel | null = null; private _hoveredElement: HTMLElement | null = null; private _activeModel: HighlightModel | null = null; @@ -509,9 +508,15 @@ class RecordActionTool implements RecorderTool { private _actionInProgress(event: Event): boolean { // If Playwright is performing action for us, bail. - if (this._performingAction) + const isKeyEvent = event instanceof KeyboardEvent; + if (this._performingAction?.name === 'press' && isKeyEvent && event.key === this._performingAction.key) return true; - // Consume as the first thing. + + const isMouseOrPointerEvent = event instanceof MouseEvent || event instanceof PointerEvent; + if (isMouseOrPointerEvent && (this._performingAction?.name === 'click' || this._performingAction?.name === 'check' || this._performingAction?.name === 'uncheck')) + return true; + + // Consume event if action is not being executed. consumeEvent(event); return false; } @@ -535,9 +540,9 @@ class RecordActionTool implements RecorderTool { this._hoveredModel = null; this._activeModel = null; this._recorder.updateHighlight(null, false); - this._performingAction = true; + this._performingAction = action; void this._recorder.performAction(action).then(() => { - this._performingAction = false; + this._performingAction = null; // If that was a keyboard action, it similarly requires new selectors for active model. this._onFocus(false); @@ -1457,73 +1462,3 @@ function createSvgElement(doc: Document, { tagName, attrs, children }: SvgJson): return elem; } - -interface Embedder { - __pw_recorderPerformAction(action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode): Promise; - __pw_recorderRecordAction(action: actions.Action, simpleDomNode?: SimpleDomNode): Promise; - __pw_recorderState(): Promise; - __pw_recorderSetSelector(selector: string): Promise; - __pw_recorderSetMode(mode: Mode): Promise; - __pw_recorderSetOverlayState(state: OverlayState): Promise; - __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 { - await this._embedder.__pw_recorderRecordAction(action, simpleDomNode); - } - - async setSelector(selector: string): Promise { - await this._embedder.__pw_recorderSetSelector(selector); - } - - async setMode(mode: Mode): Promise { - await this._embedder.__pw_recorderSetMode(mode); - } - - async setOverlayState(state: OverlayState): Promise { - await this._embedder.__pw_recorderSetOverlayState(state); - } -} - -export default PollingRecorder; diff --git a/packages/playwright-core/src/server/recorder/DEPS.list b/packages/playwright-core/src/server/recorder/DEPS.list index f3bbfc23bf..85ae7c9152 100644 --- a/packages/playwright-core/src/server/recorder/DEPS.list +++ b/packages/playwright-core/src/server/recorder/DEPS.list @@ -5,7 +5,7 @@ ../isomorphic/** ../registry/** ../../common/ -../../generated/recorderSource.ts +../../generated/pollingRecorderSource.ts ../../protocol/ ../../utils/** ../../utilsBundle.ts diff --git a/packages/playwright-core/src/server/recorder/contextRecorder.ts b/packages/playwright-core/src/server/recorder/contextRecorder.ts index dc38866167..a02ea9f5b4 100644 --- a/packages/playwright-core/src/server/recorder/contextRecorder.ts +++ b/packages/playwright-core/src/server/recorder/contextRecorder.ts @@ -17,7 +17,7 @@ import type * as channels from '@protocol/channels'; import type { Source } from '@recorder/recorderTypes'; import { EventEmitter } from 'events'; -import * as recorderSource from '../../generated/recorderSource'; +import * as recorderSource from '../../generated/pollingRecorderSource'; import { eventsHelper, monotonicTime, quoteCSSAttributeValue, type RegisteredListener } from '../../utils'; import { raceAgainstDeadline } from '../../utils/timeoutRunner'; import { BrowserContext } from '../browserContext'; @@ -54,9 +54,11 @@ export class ContextRecorder extends EventEmitter { private _throttledOutputFile: ThrottledFile | null = null; private _orderedLanguages: LanguageGenerator[] = []; private _listeners: RegisteredListener[] = []; + private _codegenMode: 'actions' | 'trace-events'; constructor(codegenMode: 'actions' | 'trace-events', context: BrowserContext, params: channels.BrowserContextEnableRecorderParams, delegate: ContextRecorderDelegate) { super(); + this._codegenMode = codegenMode; this._context = context; this._params = params; this._delegate = delegate; @@ -145,10 +147,12 @@ export class ContextRecorder extends EventEmitter { setEnabled(enabled: boolean) { this._collection.setEnabled(enabled); - if (enabled) - this._context.tracing.startChunk({ name: 'trace', title: 'trace' }).catch(() => {}); - else - this._context.tracing.stopChunk({ mode: 'discard' }).catch(() => {}); + if (this._codegenMode === 'trace-events') { + if (enabled) + this._context.tracing.startChunk({ name: 'trace', title: 'trace' }).catch(() => {}); + else + this._context.tracing.stopChunk({ mode: 'discard' }).catch(() => {}); + } } dispose() { diff --git a/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts b/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts index 8da0896497..4c84547ade 100644 --- a/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts +++ b/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts @@ -43,6 +43,7 @@ export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp constructor(transport: RecorderTransport, tracePage: Page, traceServer: HttpServer, wsEndpointForTest: string | undefined) { super(); this._transport = transport; + this._transport.eventSink.resolve(this); this._tracePage = tracePage; this._traceServer = traceServer; this.wsEndpointForTest = wsEndpointForTest; @@ -94,6 +95,7 @@ async function openApp(trace: string, options?: TraceViewerServerOptions & { hea class RecorderTransport implements Transport { private _connected = new ManualPromise(); + readonly eventSink = new ManualPromise(); constructor() { } @@ -103,6 +105,8 @@ class RecorderTransport implements Transport { } async dispatch(method: string, params: any): Promise { + const eventSink = await this.eventSink; + eventSink.emit('event', { event: method, params }); } onclose() { diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index ee2ccac593..6d45e7c6f5 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -46,7 +46,6 @@ export type TraceViewerRedirectOptions = { reporter?: string[]; webApp?: string; isServer?: boolean; - outputDir?: string; updateSnapshots?: 'all' | 'none' | 'missing'; }; @@ -133,8 +132,6 @@ export async function installRootRedirect(server: HttpServer, traceUrls: string[ params.append('timeout', String(options.timeout)); if (options.headed) params.append('headed', ''); - if (options.outputDir) - params.append('outputDir', options.outputDir); if (options.updateSnapshots) params.append('updateSnapshots', options.updateSnapshots); for (const reporter of options.reporter || []) diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index 2f579b619b..320df04ce2 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -502,16 +502,15 @@ export class WKPage implements PageDelegate { if (!frame) return; const delegate = new WKExecutionContext(this._session, contextPayload.id); - let worldName: types.World; + let worldName: types.World|null = null; if (contextPayload.type === 'normal') worldName = 'main'; else if (contextPayload.type === 'user' && contextPayload.name === UTILITY_WORLD_NAME) worldName = 'utility'; - else - return; const context = new dom.FrameExecutionContext(delegate, frame, worldName); (context as any)[contextDelegateSymbol] = delegate; - frame._contextCreated(worldName, context); + if (worldName) + frame._contextCreated(worldName, context); this._contextIdToContext.set(contextPayload.id, context); } @@ -1116,7 +1115,7 @@ export class WKPage implements PageDelegate { const response = request.createResponse(event.response); this._page._frameManager.requestReceivedResponse(response); - if (response.status() === 204) { + if (response.status() === 204 && request.request.isNavigationRequest()) { this._onLoadingFailed(session, { requestId: event.requestId, errorText: 'Aborted: 204 No Content', diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index d6d4e7f39b..e0c640db6f 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -2120,7 +2120,9 @@ export interface Page { /** * When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults - * to `false`. Useful to wait until the element is ready for the action without performing it. + * to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard + * `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys + * are pressed. */ trial?: boolean; }): Promise; @@ -2233,7 +2235,9 @@ export interface Page { /** * When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults - * to `false`. Useful to wait until the element is ready for the action without performing it. + * to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard + * `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys + * are pressed. */ trial?: boolean; }): Promise; @@ -3125,7 +3129,9 @@ export interface Page { /** * When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults - * to `false`. Useful to wait until the element is ready for the action without performing it. + * to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard + * `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys + * are pressed. */ trial?: boolean; }): Promise; @@ -4266,7 +4272,9 @@ export interface Page { /** * When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults - * to `false`. Useful to wait until the element is ready for the action without performing it. + * to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard + * `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys + * are pressed. */ trial?: boolean; }): Promise; @@ -5845,7 +5853,9 @@ export interface Frame { /** * When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults - * to `false`. Useful to wait until the element is ready for the action without performing it. + * to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard + * `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys + * are pressed. */ trial?: boolean; }): Promise; @@ -5931,7 +5941,9 @@ export interface Frame { /** * When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults - * to `false`. Useful to wait until the element is ready for the action without performing it. + * to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard + * `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys + * are pressed. */ trial?: boolean; }): Promise; @@ -6621,7 +6633,9 @@ export interface Frame { /** * When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults - * to `false`. Useful to wait until the element is ready for the action without performing it. + * to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard + * `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys + * are pressed. */ trial?: boolean; }): Promise; @@ -7308,7 +7322,9 @@ export interface Frame { /** * When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults - * to `false`. Useful to wait until the element is ready for the action without performing it. + * to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard + * `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys + * are pressed. */ trial?: boolean; }): Promise; @@ -12045,7 +12061,9 @@ export interface Locator { /** * When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults - * to `false`. Useful to wait until the element is ready for the action without performing it. + * to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard + * `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys + * are pressed. */ trial?: boolean; }): Promise; @@ -12155,7 +12173,9 @@ export interface Locator { /** * When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults - * to `false`. Useful to wait until the element is ready for the action without performing it. + * to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard + * `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys + * are pressed. */ trial?: boolean; }): Promise; @@ -12825,7 +12845,9 @@ export interface Locator { /** * When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults - * to `false`. Useful to wait until the element is ready for the action without performing it. + * to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard + * `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys + * are pressed. */ trial?: boolean; }): Promise; @@ -13626,7 +13648,9 @@ export interface Locator { /** * When set, this method only performs the [actionability](https://playwright.dev/docs/actionability) checks and skips the action. Defaults - * to `false`. Useful to wait until the element is ready for the action without performing it. + * to `false`. Useful to wait until the element is ready for the action without performing it. Note that keyboard + * `modifiers` will be pressed regardless of `trial` to allow testing elements which are only visible when those keys + * are pressed. */ trial?: boolean; }): Promise; diff --git a/packages/playwright/src/isomorphic/testServerInterface.ts b/packages/playwright/src/isomorphic/testServerInterface.ts index 22cb9e35ef..ce1c1888d5 100644 --- a/packages/playwright/src/isomorphic/testServerInterface.ts +++ b/packages/playwright/src/isomorphic/testServerInterface.ts @@ -45,7 +45,7 @@ export interface TestServerInterface { installBrowsers(params: {}): Promise; - runGlobalSetup(params: { outputDir?: string }): Promise<{ + runGlobalSetup(params: {}): Promise<{ report: ReportEntry[], status: reporterTypes.FullResult['status'] }>; @@ -82,7 +82,6 @@ export interface TestServerInterface { locations?: string[]; grep?: string; grepInvert?: string; - outputDir?: string; }): Promise<{ report: ReportEntry[], status: reporterTypes.FullResult['status'] @@ -96,7 +95,6 @@ export interface TestServerInterface { headed?: boolean; workers?: number | string; timeout?: number, - outputDir?: string; updateSnapshots?: 'all' | 'none' | 'missing'; reporters?: string[], trace?: 'on' | 'off'; diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index 582ca950a5..b391b53295 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -140,8 +140,7 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re const wrappedMatchers: any = {}; const extendedMatchers: any = { ...customMatchers }; for (const [name, matcher] of Object.entries(matchers)) { - const key = qualifiedMatcherName(qualifier, name); - wrappedMatchers[key] = function(...args: any[]) { + wrappedMatchers[name] = function(...args: any[]) { const { isNot, promise, utils } = this; const newThis: ExpectMatcherState = { isNot, @@ -152,6 +151,8 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re (newThis as any).equals = throwUnsupportedExpectMatcherError; return (matcher as any).call(newThis, ...args); }; + const key = qualifiedMatcherName(qualifier, name); + wrappedMatchers[key] = wrappedMatchers[name]; Object.defineProperty(wrappedMatchers[key], 'name', { value: name }); Object.defineProperty(wrappedMatchers[key], 'qualifiedName', { value: key }); extendedMatchers[name] = wrappedMatchers[key]; diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index 775ef27065..136606b26c 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -161,7 +161,7 @@ async function runTests(args: string[], opts: { [key: string]: any }) { if (opts.onlyChanged) throw new Error(`--only-changed is not supported in UI mode. If you'd like that to change, see https://github.com/microsoft/playwright/issues/15075 for more details.`); - const status = await testServer.runUIMode(opts.config, { + const status = await testServer.runUIMode(opts.config, cliOverrides, { host: opts.uiHost, port: opts.uiPort ? +opts.uiPort : undefined, args, @@ -172,7 +172,6 @@ async function runTests(args: string[], opts: { [key: string]: any }) { reporter: Array.isArray(opts.reporter) ? opts.reporter : opts.reporter ? [opts.reporter] : undefined, workers: cliOverrides.workers, timeout: cliOverrides.timeout, - outputDir: cliOverrides.outputDir, updateSnapshots: cliOverrides.updateSnapshots, }); await stopProfiling('runner'); @@ -227,7 +226,7 @@ async function runTests(args: string[], opts: { [key: string]: any }) { async function runTestServer(opts: { [key: string]: any }) { const host = opts.host || 'localhost'; const port = opts.port ? +opts.port : 0; - const status = await testServer.runTestServer(opts.config, { host, port }); + const status = await testServer.runTestServer(opts.config, { }, { host, port }); if (status === 'restarted') return; const exitCode = status === 'interrupted' ? 130 : (status === 'passed' ? 0 : 1); diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index d4433b2d85..ee3cefaac4 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -44,14 +44,16 @@ const originalStderrWrite = process.stderr.write; class TestServer { private _configLocation: ConfigLocation; + private _configCLIOverrides: ConfigCLIOverrides; private _dispatcher: TestServerDispatcher | undefined; - constructor(configLocation: ConfigLocation) { + constructor(configLocation: ConfigLocation, configCLIOverrides: ConfigCLIOverrides) { this._configLocation = configLocation; + this._configCLIOverrides = configCLIOverrides; } async start(options: { host?: string, port?: number }): Promise { - this._dispatcher = new TestServerDispatcher(this._configLocation); + this._dispatcher = new TestServerDispatcher(this._configLocation, this._configCLIOverrides); return await startTraceViewerServer({ ...options, transport: this._dispatcher.transport }); } @@ -63,6 +65,7 @@ class TestServer { export class TestServerDispatcher implements TestServerInterface { private _configLocation: ConfigLocation; + private _configCLIOverrides: ConfigCLIOverrides; private _watcher: Watcher; private _watchedProjectDirs = new Set(); @@ -81,8 +84,9 @@ export class TestServerDispatcher implements TestServerInterface { private _closeOnDisconnect = false; private _populateDependenciesOnList = false; - constructor(configLocation: ConfigLocation) { + constructor(configLocation: ConfigLocation, configCLIOverrides: ConfigCLIOverrides) { this._configLocation = configLocation; + this._configCLIOverrides = configCLIOverrides; this.transport = { onconnect: () => {}, dispatch: (method, params) => (this as any)[method](params), @@ -145,11 +149,8 @@ export class TestServerDispatcher implements TestServerInterface { async runGlobalSetup(params: Parameters[0]): ReturnType { await this.runGlobalTeardown(); - const overrides: ConfigCLIOverrides = { - outputDir: params.outputDir, - }; const { reporter, report } = await this._collectingInternalReporter(new ListReporter()); - const config = await this._loadConfigOrReportError(reporter, overrides); + const config = await this._loadConfigOrReportError(reporter, this._configCLIOverrides); if (!config) return { status: 'failed', report }; @@ -239,9 +240,9 @@ export class TestServerDispatcher implements TestServerInterface { config?: FullConfigInternal, }> { const overrides: ConfigCLIOverrides = { + ...this._configCLIOverrides, repeatEach: 1, retries: 0, - outputDir: params.outputDir, }; const { reporter, report } = await this._collectingInternalReporter(); const config = await this._loadConfigOrReportError(reporter, overrides); @@ -295,6 +296,7 @@ export class TestServerDispatcher implements TestServerInterface { private async _innerRunTests(params: Parameters[0]): ReturnType { await this.stopTests(); const overrides: ConfigCLIOverrides = { + ...this._configCLIOverrides, repeatEach: 1, retries: 0, preserveOutputDir: true, @@ -307,7 +309,6 @@ export class TestServerDispatcher implements TestServerInterface { _optionContextReuseMode: params.reuseContext ? 'when-possible' : undefined, _optionConnectOptions: params.connectWsEndpoint ? { wsEndpoint: params.connectWsEndpoint } : undefined, }, - outputDir: params.outputDir, updateSnapshots: params.updateSnapshots, workers: params.workers, }; @@ -424,9 +425,9 @@ export class TestServerDispatcher implements TestServerInterface { } } -export async function runUIMode(configFile: string | undefined, options: TraceViewerServerOptions & TraceViewerRedirectOptions): Promise { +export async function runUIMode(configFile: string | undefined, configCLIOverrides: ConfigCLIOverrides, options: TraceViewerServerOptions & TraceViewerRedirectOptions): Promise { const configLocation = resolveConfigLocation(configFile); - return await innerRunTestServer(configLocation, options, async (server: HttpServer, cancelPromise: ManualPromise) => { + return await innerRunTestServer(configLocation, configCLIOverrides, options, async (server: HttpServer, cancelPromise: ManualPromise) => { await installRootRedirect(server, [], { ...options, webApp: 'uiMode.html' }); if (options.host !== undefined || options.port !== undefined) { await openTraceInBrowser(server.urlPrefix('human-readable')); @@ -442,18 +443,18 @@ export async function runUIMode(configFile: string | undefined, options: TraceVi }); } -export async function runTestServer(configFile: string | undefined, options: { host?: string, port?: number }): Promise { +export async function runTestServer(configFile: string | undefined, configCLIOverrides: ConfigCLIOverrides, options: { host?: string, port?: number }): Promise { const configLocation = resolveConfigLocation(configFile); - return await innerRunTestServer(configLocation, options, async server => { + return await innerRunTestServer(configLocation, configCLIOverrides, options, async server => { // eslint-disable-next-line no-console console.log('Listening on ' + server.urlPrefix('precise').replace('http:', 'ws:') + '/' + server.wsGuid()); }); } -async function innerRunTestServer(configLocation: ConfigLocation, options: { host?: string, port?: number }, openUI: (server: HttpServer, cancelPromise: ManualPromise, configLocation: ConfigLocation) => Promise): Promise { +async function innerRunTestServer(configLocation: ConfigLocation, configCLIOverrides: ConfigCLIOverrides, options: { host?: string, port?: number }, openUI: (server: HttpServer, cancelPromise: ManualPromise, configLocation: ConfigLocation) => Promise): Promise { if (restartWithExperimentalTsEsm(undefined, true)) return 'restarted'; - const testServer = new TestServer(configLocation); + const testServer = new TestServer(configLocation, configCLIOverrides); const cancelPromise = new ManualPromise(); const sigintWatcher = new SigIntWatcher(); process.stdin.on('close', () => gracefullyProcessExitDoNotHang(0)); diff --git a/packages/playwright/src/runner/watchMode.ts b/packages/playwright/src/runner/watchMode.ts index a47ca0fe32..bdbe2d5c2d 100644 --- a/packages/playwright/src/runner/watchMode.ts +++ b/packages/playwright/src/runner/watchMode.ts @@ -75,7 +75,7 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp const options: WatchModeOptions = { ...initialOptions }; let bufferMode = false; - const testServerDispatcher = new TestServerDispatcher(configLocation); + const testServerDispatcher = new TestServerDispatcher(configLocation, {}); const transport = new InMemoryTransport( async data => { const { id, method, params } = JSON.parse(data); @@ -144,11 +144,13 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp else printPrompt(); + const waitForCommand = readCommand(); const command = await Promise.race([ onDirtyTests, - readCommand(), + waitForCommand.result, ]); - + if (command === 'changed') + waitForCommand.cancel(); if (bufferMode && command === 'changed') continue; @@ -282,7 +284,7 @@ async function runTests(watchOptions: WatchModeOptions, testServerConnection: Te }); } -function readCommand(): ManualPromise { +function readCommand(): { result: Promise, cancel: () => void } { const result = new ManualPromise(); const rl = readline.createInterface({ input: process.stdin, escapeCodeTimeout: 50 }); readline.emitKeypressEvents(process.stdin, rl); @@ -334,13 +336,14 @@ Change settings }; process.stdin.on('keypress', handler); - void result.finally(() => { + const cancel = () => { process.stdin.off('keypress', handler); rl.close(); if (process.stdin.isTTY) process.stdin.setRawMode(false); - }); - return result; + }; + void result.finally(cancel); + return { result, cancel }; } let showBrowserServer: PlaywrightServer | undefined; diff --git a/packages/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx index 31bad2b70b..9d5c0feeba 100644 --- a/packages/recorder/src/recorder.tsx +++ b/packages/recorder/src/recorder.tsx @@ -19,6 +19,7 @@ import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; import { SplitView } from '@web/components/splitView'; import { TabbedPane } from '@web/components/tabbedPane'; import { Toolbar } from '@web/components/toolbar'; +import { emptySource, SourceChooser } from '@web/components/sourceChooser'; import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton'; import * as React from 'react'; import { CallLogView } from './callLog'; @@ -54,15 +55,7 @@ export const Recorder: React.FC = ({ if (source) return source; } - const source: Source = { - id: 'default', - isRecorded: false, - text: '', - language: 'javascript', - label: '', - highlight: [] - }; - return source; + return emptySource(); }, [sources, fileId]); const [locator, setLocator] = React.useState(''); @@ -152,10 +145,10 @@ export const Recorder: React.FC = ({ }}>
Target:
- + { + setFileId(fileId); + window.dispatch({ event: 'fileChanged', params: { file: fileId } }); + }} /> { window.dispatch({ event: 'clear' }); }}> @@ -184,22 +177,3 @@ export const Recorder: React.FC = ({ /> ; }; - -function renderSourceOptions(sources: Source[]): React.ReactNode { - const transformTitle = (title: string): string => title.replace(/.*[/\\]([^/\\]+)/, '$1'); - const renderOption = (source: Source): React.ReactNode => ( - - ); - - const hasGroup = sources.some(s => s.group); - if (hasGroup) { - const groups = new Set(sources.map(s => s.group)); - return [...groups].filter(Boolean).map(group => ( - - {sources.filter(s => s.group === group).map(source => renderOption(source))} - - )); - } - - return sources.map(source => renderOption(source)); -} diff --git a/packages/trace-viewer/src/ui/actionList.tsx b/packages/trace-viewer/src/ui/actionList.tsx index 3935447e7d..55f4f4028a 100644 --- a/packages/trace-viewer/src/ui/actionList.tsx +++ b/packages/trace-viewer/src/ui/actionList.tsx @@ -32,9 +32,9 @@ export interface ActionListProps { selectedTime: Boundaries | undefined, setSelectedTime: (time: Boundaries | undefined) => void, sdkLanguage: Language | undefined; - onSelected: (action: ActionTraceEventInContext) => void, - onHighlighted: (action: ActionTraceEventInContext | undefined) => void, - revealConsole: () => void, + onSelected?: (action: ActionTraceEventInContext) => void, + onHighlighted?: (action: ActionTraceEventInContext | undefined) => void, + revealConsole?: () => void, isLive?: boolean, } @@ -67,8 +67,8 @@ export const ActionList: React.FC = ({ treeState={treeState} setTreeState={setTreeState} selectedItem={selectedItem} - onSelected={item => onSelected(item.action!)} - onHighlighted={item => onHighlighted(item?.action)} + onSelected={item => onSelected?.(item.action!)} + onHighlighted={item => onHighlighted?.(item?.action)} onAccepted={item => setSelectedTime({ minimum: item.action!.startTime, maximum: item.action!.endTime })} isError={item => !!item.action?.error?.message} isVisible={item => !selectedTime || (item.action!.startTime <= selectedTime.maximum && item.action!.endTime >= selectedTime.minimum)} diff --git a/packages/trace-viewer/src/ui/consoleTab.tsx b/packages/trace-viewer/src/ui/consoleTab.tsx index b2947f5011..3ae847ef50 100644 --- a/packages/trace-viewer/src/ui/consoleTab.tsx +++ b/packages/trace-viewer/src/ui/consoleTab.tsx @@ -107,7 +107,7 @@ export const ConsoleTab: React.FunctionComponent<{ boundaries: Boundaries, consoleModel: ConsoleTabModel, selectedTime: Boundaries | undefined, - onEntryHovered: (entry: ConsoleEntry | undefined) => void, + onEntryHovered?: (entry: ConsoleEntry | undefined) => void, onAccepted: (entry: ConsoleEntry) => void, }> = ({ consoleModel, boundaries, onEntryHovered, onAccepted }) => { if (!consoleModel.entries.length) diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index 207dd33547..62b139be0d 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -65,7 +65,7 @@ export function useNetworkTabModel(model: MultiTraceModel | undefined, selectedT export const NetworkTab: React.FunctionComponent<{ boundaries: Boundaries, networkModel: NetworkTabModel, - onEntryHovered: (entry: Entry | undefined) => void, + onEntryHovered?: (entry: Entry | undefined) => void, }> = ({ boundaries, networkModel, onEntryHovered }) => { const [sorting, setSorting] = React.useState(undefined); const [selectedEntry, setSelectedEntry] = React.useState(undefined); @@ -95,7 +95,7 @@ export const NetworkTab: React.FunctionComponent<{ items={renderedEntries} selectedItem={selectedEntry} onSelected={item => setSelectedEntry(item)} - onHighlighted={item => onEntryHovered(item?.resource)} + onHighlighted={item => onEntryHovered?.(item?.resource)} columns={visibleColumns(!!selectedEntry, renderedEntries)} columnTitle={columnTitle} columnWidths={columnWidths} diff --git a/packages/trace-viewer/src/ui/recorderView.tsx b/packages/trace-viewer/src/ui/recorderView.tsx index 945ac86fc0..159536d925 100644 --- a/packages/trace-viewer/src/ui/recorderView.tsx +++ b/packages/trace-viewer/src/ui/recorderView.tsx @@ -14,52 +14,52 @@ limitations under the License. */ -import * as React from 'react'; -import './recorderView.css'; -import { MultiTraceModel } from './modelUtil'; -import type { SourceLocation } from './modelUtil'; -import { Workbench } from './workbench'; +import type { Language } from '@isomorphic/locatorGenerators'; import type { Mode, Source } from '@recorder/recorderTypes'; +import { SplitView } from '@web/components/splitView'; +import type { TabbedPaneTabModel } from '@web/components/tabbedPane'; +import { TabbedPane } from '@web/components/tabbedPane'; +import { sha1, useSetting } from '@web/uiUtils'; +import * as React from 'react'; import type { ContextEntry } from '../entries'; +import type { Boundaries } from '../geometry'; +import { ActionList } from './actionList'; +import { ConsoleTab, useConsoleTabModel } from './consoleTab'; +import { InspectorTab } from './inspectorTab'; +import type * as modelUtil from './modelUtil'; +import type { SourceLocation } from './modelUtil'; +import { MultiTraceModel } from './modelUtil'; +import { NetworkTab, useNetworkTabModel } from './networkTab'; +import './recorderView.css'; +import { collectSnapshots, extendSnapshot, SnapshotView } from './snapshotTab'; +import { SourceTab } from './sourceTab'; +import { Toolbar } from '@web/components/toolbar'; +import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton'; +import { toggleTheme } from '@web/theme'; +import { SourceChooser } from '@web/components/sourceChooser'; const searchParams = new URLSearchParams(window.location.search); const guid = searchParams.get('ws'); -const trace = searchParams.get('trace') + '.json'; +const traceLocation = searchParams.get('trace') + '.json'; export const RecorderView: React.FunctionComponent = () => { const [connection, setConnection] = React.useState(null); const [sources, setSources] = React.useState([]); + const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean, sha1: string } | undefined>(); + const [mode, setMode] = React.useState('none'); + const [counter, setCounter] = React.useState(0); + const pollTimer = React.useRef(null); + React.useEffect(() => { const wsURL = new URL(`../${guid}`, window.location.toString()); wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:'); const webSocket = new WebSocket(wsURL.toString()); - setConnection(new Connection(webSocket, { setSources })); + setConnection(new Connection(webSocket, { setMode, setSources })); return () => { webSocket.close(); }; }, []); - React.useEffect(() => { - if (!connection) - return; - connection.setMode('recording'); - }, [connection]); - - return
- -
; -}; - -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(null); - React.useEffect(() => { if (pollTimer.current) clearTimeout(pollTimer.current); @@ -67,8 +67,9 @@ export const TraceView: React.FC<{ // Start polling running test. pollTimer.current = setTimeout(async () => { try { - const model = await loadSingleTraceFile(traceLocation); - setModel({ model, isLive: true }); + const result = await loadSingleTraceFile(traceLocation); + if (result.sha1 !== model?.sha1) + setModel({ ...result, isLive: true }); } catch { setModel(undefined); } finally { @@ -79,10 +80,94 @@ export const TraceView: React.FC<{ if (pollTimer.current) clearTimeout(pollTimer.current); }; - }, [counter, traceLocation]); + }, [counter, model]); + + return
+ connection?.setMode(mode)} + model={model?.model} + sources={sources} + /> +
; +}; + +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(); + const [selectedCallId, setSelectedCallId] = React.useState(undefined); + const [selectedPropertiesTab, setSelectedPropertiesTab] = useSetting('recorderPropertiesTab', 'source'); + const [isInspecting, setIsInspectingState] = React.useState(false); + const [highlightedLocator, setHighlightedLocator] = React.useState(''); + const [selectedTime, setSelectedTime] = React.useState(); + const sourceModel = React.useRef(new Map()); + + 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: () => , + }; + + const source = React.useMemo(() => sources.find(s => s.id === fileId) || sources[0], [sources, fileId]); const fallbackLocation = React.useMemo(() => { - if (!sources.length) + if (!source) return undefined; const fallbackLocation: SourceLocation = { file: '', @@ -90,37 +175,178 @@ export const TraceView: React.FC<{ column: 0, source: { errors: [], - content: sources[0].text + content: source.text } }; return fallbackLocation; - }, [sources]); + }, [source]); - return + }; + const consoleTab: TabbedPaneTabModel = { + id: 'console', + title: 'Console', + count: consoleModel.entries.length, + render: () => setSelectedTime({ minimum: m.timestamp, maximum: m.timestamp })} + /> + }; + const networkTab: TabbedPaneTabModel = { + id: 'network', + title: 'Network', + count: networkModel.resources.length, + render: () => + }; + + 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 = selectPropertiesTab('console')} isLive={true} - hideTimeline={true} />; + + const actionsTab: TabbedPaneTabModel = { + id: 'actions', + title: 'Actions', + component: actionList, + }; + + const toolbar = +
+ { + setMode(mode === 'recording' ? 'standby' : 'recording'); + }}>Record + + { + setIsInspecting(!isInspecting); + }} /> + { + }} /> + { + }} /> + { + }} /> + + { + }} /> +
+
Target:
+ { + setFileId(fileId); + }} /> + { + }}> + toggleTheme()}> +
; + + const sidebarTabbedPane = ; + + const propertiesTabbedPane = ; + + const snapshotView = ; + + return
+ + {toolbar} + {snapshotView} +
} + sidebar={propertiesTabbedPane} + />} + sidebar={sidebarTabbedPane} + /> + ; }; -async function loadSingleTraceFile(url: string): Promise { - const params = new URLSearchParams(); - params.set('trace', url); - const response = await fetch(`contexts?${params.toString()}`); - const contextEntries = await response.json() as ContextEntry[]; - return new MultiTraceModel(contextEntries); -} +const SnapshotContainer: React.FunctionComponent<{ + sdkLanguage: Language, + action: modelUtil.ActionTraceEventInContext | undefined, + testIdAttributeName?: string, + isInspecting: boolean, + highlightedLocator: string, + setIsInspecting: (value: boolean) => void, + locatorPicked: (locator: string) => void, +}> = ({ sdkLanguage, action, testIdAttributeName, isInspecting, setIsInspecting, highlightedLocator, locatorPicked }) => { + const snapshot = React.useMemo(() => { + const snapshot = collectSnapshots(action); + return snapshot.action || snapshot.after || snapshot.before; + }, [action]); + const snapshotUrls = React.useMemo(() => { + return snapshot ? extendSnapshot(snapshot) : undefined; + }, [snapshot]); + return ; +}; + +type ConnectionOptions = { + setSources: (sources: Source[]) => void; + setMode: (mode: Mode) => void; +}; class Connection { private _lastId = 0; private _webSocket: WebSocket; private _callbacks = new Map void, reject: (arg: Error) => void }>(); - private _options: { setSources: (sources: Source[]) => void; }; + private _options: ConnectionOptions; - constructor(webSocket: WebSocket, options: { setSources: (sources: Source[]) => void }) { + constructor(webSocket: WebSocket, options: ConnectionOptions) { this._webSocket = webSocket; this._callbacks = new Map(); this._options = options; @@ -166,5 +392,9 @@ class Connection { this._options.setSources(sources); window.playwrightSourcesEchoForTest = sources; } + if (method === 'setMode') { + const { mode } = params as { mode: Mode }; + this._options.setMode(mode); + } } } diff --git a/packages/trace-viewer/src/ui/snapshotTab.css b/packages/trace-viewer/src/ui/snapshotTab.css index 2677cfe53a..926685dc81 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.css +++ b/packages/trace-viewer/src/ui/snapshotTab.css @@ -15,12 +15,8 @@ */ .snapshot-tab { - display: flex; - flex: auto; - flex-direction: column; align-items: stretch; outline: none; - --browser-frame-header-height: 40px; overflow: hidden; } @@ -73,6 +69,7 @@ margin: 1px; padding: 10px; position: relative; + --browser-frame-header-height: 40px; } .snapshot-container { diff --git a/packages/trace-viewer/src/ui/snapshotTab.tsx b/packages/trace-viewer/src/ui/snapshotTab.tsx index 9dafa10b96..b845c60cd5 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.tsx +++ b/packages/trace-viewer/src/ui/snapshotTab.tsx @@ -40,7 +40,7 @@ function findClosest(items: T[], metric: (v: T) => number, target: number) { }); } -export const SnapshotTab: React.FunctionComponent<{ +export const SnapshotTabsView: React.FunctionComponent<{ action: ActionTraceEvent | undefined, model?: MultiTraceModel, sdkLanguage: Language, @@ -50,63 +50,69 @@ export const SnapshotTab: React.FunctionComponent<{ highlightedLocator: string, setHighlightedLocator: (locator: string) => void, openPage?: (url: string, target?: string) => Window | any, -}> = ({ action, model, sdkLanguage, testIdAttributeName, isInspecting, setIsInspecting, highlightedLocator, setHighlightedLocator, openPage }) => { - const [measure, ref] = useMeasure(); +}> = ({ action, sdkLanguage, testIdAttributeName, isInspecting, setIsInspecting, highlightedLocator, setHighlightedLocator, openPage }) => { const [snapshotTab, setSnapshotTab] = React.useState<'action'|'before'|'after'>('action'); const [showScreenshotInsteadOfSnapshot] = useSetting('screenshot-instead-of-snapshot', false); - type Snapshot = { action: ActionTraceEvent, snapshotName: string, point?: { x: number, y: number }, hasInputTarget?: boolean }; - const { snapshots } = React.useMemo(() => { - if (!action) - return { snapshots: {} }; - - // if the action has no beforeSnapshot, use the last available afterSnapshot. - let beforeSnapshot: Snapshot | undefined = action.beforeSnapshot ? { action, snapshotName: action.beforeSnapshot } : undefined; - let a = action; - while (!beforeSnapshot && a) { - a = prevInList(a); - beforeSnapshot = a?.afterSnapshot ? { action: a, snapshotName: a?.afterSnapshot } : undefined; - } - const afterSnapshot: Snapshot | undefined = action.afterSnapshot ? { action, snapshotName: action.afterSnapshot } : beforeSnapshot; - const actionSnapshot: Snapshot | undefined = action.inputSnapshot ? { action, snapshotName: action.inputSnapshot, hasInputTarget: true } : afterSnapshot; - if (actionSnapshot) - actionSnapshot.point = action.point; - return { snapshots: { action: actionSnapshot, before: beforeSnapshot, after: afterSnapshot } }; + const snapshots = React.useMemo(() => { + return collectSnapshots(action); }, [action]); - - const { snapshotInfoUrl, snapshotUrl, popoutUrl, point } = React.useMemo(() => { + const snapshotUrls = React.useMemo(() => { const snapshot = snapshots[snapshotTab]; - if (!snapshot) - return { snapshotUrl: kBlankSnapshotUrl }; - - const params = new URLSearchParams(); - params.set('trace', context(snapshot.action).traceUrl); - params.set('name', snapshot.snapshotName); - if (snapshot.point) { - params.set('pointX', String(snapshot.point.x)); - params.set('pointY', String(snapshot.point.y)); - if (snapshot.hasInputTarget) - params.set('hasInputTarget', '1'); - } - const snapshotUrl = new URL(`snapshot/${snapshot.action.pageId}?${params.toString()}`, window.location.href).toString(); - const snapshotInfoUrl = new URL(`snapshotInfo/${snapshot.action.pageId}?${params.toString()}`, window.location.href).toString(); - - const popoutParams = new URLSearchParams(); - popoutParams.set('r', snapshotUrl); - popoutParams.set('trace', context(snapshot.action).traceUrl); - if (snapshot.point) { - popoutParams.set('pointX', String(snapshot.point.x)); - popoutParams.set('pointY', String(snapshot.point.y)); - if (snapshot.hasInputTarget) - params.set('hasInputTarget', '1'); - } - const popoutUrl = new URL(`snapshot.html?${popoutParams.toString()}`, window.location.href).toString(); - return { snapshots, snapshotInfoUrl, snapshotUrl, popoutUrl, point: snapshot.point }; + return snapshot ? extendSnapshot(snapshot) : undefined; }, [snapshots, snapshotTab]); + return
+ + setIsInspecting(!isInspecting)} disabled={showScreenshotInsteadOfSnapshot} /> + {['action', 'before', 'after'].map(tab => { + return setSnapshotTab(tab as 'action' | 'before' | 'after')} + >; + })} +
+ { + 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); + }); + }} /> +
+ {!showScreenshotInsteadOfSnapshot && } + {showScreenshotInsteadOfSnapshot && } +
; +}; + +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(null); const iframeRef1 = React.useRef(null); - const [snapshotInfo, setSnapshotInfo] = React.useState<{ viewport: typeof kDefaultViewport, url: string, timestamp?: number, wallTime?: undefined }>({ viewport: kDefaultViewport, url: '' }); + const [snapshotInfo, setSnapshotInfo] = React.useState({ viewport: kDefaultViewport, url: '' }); const loadingRef = React.useRef({ iteration: 0, visibleIframe: 0 }); React.useEffect(() => { @@ -115,17 +121,7 @@ export const SnapshotTab: React.FunctionComponent<{ const newVisibleIframe = 1 - loadingRef.current.visibleIframe; loadingRef.current.iteration = thisIteration; - const newSnapshotInfo = { url: '', viewport: kDefaultViewport, timestamp: undefined, wallTime: undefined }; - if (snapshotInfoUrl) { - const response = await fetch(snapshotInfoUrl); - const info = await response.json(); - if (!info.error) { - newSnapshotInfo.url = info.url; - newSnapshotInfo.viewport = info.viewport; - newSnapshotInfo.timestamp = info.timestamp; - newSnapshotInfo.wallTime = info.wallTime; - } - } + const newSnapshotInfo = await fetchSnapshotInfo(snapshotUrls?.snapshotInfoUrl); // Interrupted by another load - bail out. if (loadingRef.current.iteration !== thisIteration) @@ -140,6 +136,7 @@ export const SnapshotTab: React.FunctionComponent<{ iframe.addEventListener('error', loadedCallback); // Try preventing history entry from being created. + const snapshotUrl = snapshotUrls?.snapshotUrl || kBlankSnapshotUrl; if (iframe.contentWindow) iframe.contentWindow.location.replace(snapshotUrl); else @@ -159,33 +156,10 @@ export const SnapshotTab: React.FunctionComponent<{ loadingRef.current.visibleIframe = newVisibleIframe; setSnapshotInfo(newSnapshotInfo); })(); - }, [snapshotUrl, snapshotInfoUrl]); - - const windowHeaderHeight = 40; - const snapshotContainerSize = { - width: snapshotInfo.viewport.width, - height: snapshotInfo.viewport.height + windowHeaderHeight, - }; - const scale = Math.min(measure.width / snapshotContainerSize.width, measure.height / snapshotContainerSize.height, 1); - const translate = { - x: (measure.width - snapshotContainerSize.width) / 2, - y: (measure.height - snapshotContainerSize.height) / 2, - }; - - const page = action ? pageForAction(action) : undefined; - const screencastFrame = React.useMemo( - () => { - if (snapshotInfo.wallTime && page?.screencastFrames[0]?.frameSwapWallTime) - return findClosest(page.screencastFrames, frame => frame.frameSwapWallTime!, snapshotInfo.wallTime); - - if (snapshotInfo.timestamp && page?.screencastFrames) - return findClosest(page.screencastFrames, frame => frame.timestamp, snapshotInfo.timestamp); - }, - [page?.screencastFrames, snapshotInfo.timestamp, snapshotInfo.wallTime] - ); + }, [snapshotUrls]); return
{ if (event.key === 'Escape') { @@ -210,46 +184,72 @@ export const SnapshotTab: React.FunctionComponent<{ setHighlightedLocator={setHighlightedLocator} iframe={iframeRef1.current} iteration={loadingRef.current.iteration} /> - - setIsInspecting(!isInspecting)} disabled={showScreenshotInsteadOfSnapshot} /> - {['action', 'before', 'after'].map(tab => { - return setSnapshotTab(tab as 'action' | 'before' | 'after')} - >; - })} -
- { - 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); - }); - }}> -
-
-
- - {(showScreenshotInsteadOfSnapshot && screencastFrame) && ( - <> - {point && } - {`Screenshot ${renderTitle(snapshotTab)}`} src={`sha1/${screencastFrame.sha1}`} width={screencastFrame.width} height={screencastFrame.height} /> - - )} -
- - -
+ +
+ +
+
+
; +}; + +export const ScreenshotView: React.FunctionComponent<{ + action: ActionTraceEvent | undefined, + snapshotUrls: SnapshotUrls | undefined, + snapshot: Snapshot | undefined, +}> = ({ action, snapshotUrls, snapshot }) => { + const [snapshotInfo, setSnapshotInfo] = React.useState({ 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 + {screencastFrame && ( + <> + {point && } + {`Screenshot + + )} + ; +}; + +const SnapshotWrapper: React.FunctionComponent> = ({ snapshotInfo, children }) => { + const [measure, ref] = useMeasure(); + + 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
+
+ + {children}
; }; @@ -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,'; diff --git a/packages/trace-viewer/src/ui/sourceTab.tsx b/packages/trace-viewer/src/ui/sourceTab.tsx index ce54b34d53..d130499207 100644 --- a/packages/trace-viewer/src/ui/sourceTab.tsx +++ b/packages/trace-viewer/src/ui/sourceTab.tsx @@ -28,7 +28,7 @@ import { ToolbarButton } from '@web/components/toolbarButton'; import { Toolbar } from '@web/components/toolbar'; export const SourceTab: React.FunctionComponent<{ - stack: StackFrame[] | undefined, + stack?: StackFrame[], stackFrameLocation: 'bottom' | 'right', sources: Map, rootDir?: string, diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index ba74e60092..5c6ab7969b 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -58,7 +58,6 @@ const queryParams = { workers: searchParams.get('workers') || undefined, timeout: searchParams.has('timeout') ? +searchParams.get('timeout')! : undefined, headed: searchParams.has('headed'), - outputDir: searchParams.get('outputDir') || undefined, updateSnapshots: (searchParams.get('updateSnapshots') as 'all' | 'none' | 'missing' | undefined) || undefined, reporters: searchParams.has('reporter') ? searchParams.getAll('reporter') : undefined, pathSeparator: searchParams.get('pathSeparator') || '/', @@ -187,14 +186,12 @@ export const UIModeView: React.FC<{}> = ({ interceptStdio: true, watchTestDirs: true }); - const { status, report } = await testServerConnection.runGlobalSetup({ - outputDir: queryParams.outputDir, - }); + const { status, report } = await testServerConnection.runGlobalSetup({}); teleSuiteUpdater.processGlobalReport(report); if (status !== 'passed') return; - const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert, outputDir: queryParams.outputDir }); + const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert }); teleSuiteUpdater.processListReport(result.report); testServerConnection.onReport(params => { @@ -296,7 +293,6 @@ export const UIModeView: React.FC<{}> = ({ workers: singleWorker ? '1' : (queryParams.workers === '1' ? undefined : queryParams.workers), timeout: queryParams.timeout, headed: showBrowser, - outputDir: queryParams.outputDir, updateSnapshots: updateSnapshots ? 'all' : queryParams.updateSnapshots, reporters: queryParams.reporters, trace: 'on', @@ -319,7 +315,7 @@ export const UIModeView: React.FC<{}> = ({ commandQueue.current = commandQueue.current.then(async () => { setIsLoading(true); try { - const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert, outputDir: queryParams.outputDir }); + const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert }); teleSuiteUpdater.processListReport(result.report); } catch (e) { // eslint-disable-next-line no-console diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index 95c18d8d0a..2905bb052f 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -25,7 +25,7 @@ import type { ConsoleEntry } from './consoleTab'; import { ConsoleTab, useConsoleTabModel } from './consoleTab'; import type * as modelUtil from './modelUtil'; import { NetworkTab, useNetworkTabModel } from './networkTab'; -import { SnapshotTab } from './snapshotTab'; +import { SnapshotTabsView } from './snapshotTab'; import { SourceTab } from './sourceTab'; import { TabbedPane } from '@web/components/tabbedPane'; import type { TabbedPaneTabModel } from '@web/components/tabbedPane'; @@ -60,8 +60,7 @@ export const Workbench: React.FunctionComponent<{ }> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => { const [selectedCallId, setSelectedCallId] = React.useState(undefined); const [revealedError, setRevealedError] = React.useState(undefined); - - const [highlightedAction, setHighlightedAction] = React.useState(); + const [highlightedCallId, setHighlightedCallId] = React.useState(); const [highlightedEntry, setHighlightedEntry] = React.useState(); const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState(); const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState('actions'); @@ -77,6 +76,14 @@ export const Workbench: React.FunctionComponent<{ setRevealedError(undefined); }, []); + const highlightedAction = React.useMemo(() => { + return model?.actions.find(a => a.callId === highlightedCallId); + }, [model, highlightedCallId]); + + const setHighlightedAction = React.useCallback((highlightedAction: modelUtil.ActionTraceEventInContext | undefined) => { + setHighlightedCallId(highlightedAction?.callId); + }, []); + const sources = React.useMemo(() => model?.sources || new Map(), [model]); React.useEffect(() => { @@ -331,7 +338,7 @@ export const Workbench: React.FunctionComponent<{ orientation='horizontal' sidebarIsFirst settingName='actionListSidebar' - main={ void, +}> = ({ sources, fileId, setFileId }) => { + return ; +}; + +function renderSourceOptions(sources: Source[]): React.ReactNode { + const transformTitle = (title: string): string => title.replace(/.*[/\\]([^/\\]+)/, '$1'); + const renderOption = (source: Source): React.ReactNode => ( + + ); + + const hasGroup = sources.some(s => s.group); + if (hasGroup) { + const groups = new Set(sources.map(s => s.group)); + return [...groups].filter(Boolean).map(group => ( + + {sources.filter(s => s.group === group).map(source => renderOption(source))} + + )); + } + + return sources.map(source => renderOption(source)); +} + +export function emptySource(): Source { + return { + id: 'default', + isRecorded: false, + text: '', + language: 'javascript', + label: '', + highlight: [] + }; +} \ No newline at end of file diff --git a/packages/web/src/components/tabbedPane.tsx b/packages/web/src/components/tabbedPane.tsx index b9872294df..5df94ec4c3 100644 --- a/packages/web/src/components/tabbedPane.tsx +++ b/packages/web/src/components/tabbedPane.tsx @@ -32,11 +32,13 @@ export const TabbedPane: React.FunctionComponent<{ tabs: TabbedPaneTabModel[], leftToolbar?: React.ReactElement[], rightToolbar?: React.ReactElement[], - selectedTab: string, - setSelectedTab: (tab: string) => void, + selectedTab?: string, + setSelectedTab?: (tab: string) => void, dataTestId?: string, mode?: 'default' | 'select', }> = ({ tabs, selectedTab, setSelectedTab, leftToolbar, rightToolbar, dataTestId, mode }) => { + if (!selectedTab) + selectedTab = tabs[0].id; if (!mode) mode = 'default'; return
@@ -60,7 +62,7 @@ export const TabbedPane: React.FunctionComponent<{
} {mode === 'select' &&