From a838e74c619c2cc303b57bf118b51311b5ffcb8f Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 12 Sep 2024 10:50:05 +0200 Subject: [PATCH 01/41] devops: move macos runners to public infra (#32586) --- .github/workflows/tests_secondary.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests_secondary.yml b/.github/workflows/tests_secondary.yml index a6b473b72b..d23398b2a9 100644 --- a/.github/workflows/tests_secondary.yml +++ b/.github/workflows/tests_secondary.yml @@ -50,7 +50,9 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-12, macos-13, macos-14] + # Intel: macos-12, macos-13, macos-14-large + # Arm64: macos-13-xlarge, macos-14 + os: [macos-12, macos-13, macos-13-xlarge, macos-14-large, macos-14] browser: [chromium, firefox, webkit] runs-on: ${{ matrix.os }} steps: From dc5bbbf295d790922b0b6e6c2a5eb4ab559da059 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 12 Sep 2024 11:27:47 +0200 Subject: [PATCH 02/41] devops: remove macos-12 bots (#32587) We stopped supporting macos-12 in 1.45, see the release notes and https://github.com/microsoft/playwright/pull/31283. --- .github/workflows/tests_secondary.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests_secondary.yml b/.github/workflows/tests_secondary.yml index d23398b2a9..8d4bbd1c78 100644 --- a/.github/workflows/tests_secondary.yml +++ b/.github/workflows/tests_secondary.yml @@ -50,9 +50,9 @@ jobs: strategy: fail-fast: false matrix: - # Intel: macos-12, macos-13, macos-14-large + # Intel: macos-13, macos-14-large # Arm64: macos-13-xlarge, macos-14 - os: [macos-12, macos-13, macos-13-xlarge, macos-14-large, macos-14] + os: [macos-13, macos-13-xlarge, macos-14-large, macos-14] browser: [chromium, firefox, webkit] runs-on: ${{ matrix.os }} steps: @@ -237,7 +237,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-20.04, macos-12, windows-latest] + os: [ubuntu-20.04, macos-13, windows-latest] steps: - uses: actions/checkout@v4 - uses: ./.github/actions/run-test From 6a0009f9ed51face85136c82a3d17861f5659163 Mon Sep 17 00:00:00 2001 From: Teng Yang <15653996+morenyang@users.noreply.github.com> Date: Thu, 12 Sep 2024 18:26:51 +0800 Subject: [PATCH 03/41] fix(trace-viewer): fix ui issue on network request details (#32553) --- packages/trace-viewer/src/ui/networkResourceDetails.css | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.css b/packages/trace-viewer/src/ui/networkResourceDetails.css index a2d3f5a86f..59989b89dd 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.css +++ b/packages/trace-viewer/src/ui/networkResourceDetails.css @@ -16,6 +16,7 @@ .network-request-details-tab { width: 100%; + height: 100%; user-select: text; line-height: 24px; margin-left: 10px; From 491678ada628ebdaaa2351029dd89a2a0f8eb4bc Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 12 Sep 2024 12:51:30 +0200 Subject: [PATCH 04/41] docs: release note fixes for 1.47 (#32589) --- docs/src/release-notes-csharp.md | 5 +++-- docs/src/release-notes-java.md | 5 +++-- docs/src/release-notes-python.md | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/src/release-notes-csharp.md b/docs/src/release-notes-csharp.md index 6c357ba3b5..b6a249b0eb 100644 --- a/docs/src/release-notes-csharp.md +++ b/docs/src/release-notes-csharp.md @@ -18,8 +18,9 @@ The Network tab in the trace viewer has several nice improvements: ### Miscellaneous -- The `mcr.microsoft.com/playwright-dotnet:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble. - To use the 22.04 jammy-based image, please use `mcr.microsoft.com/playwright-dotnet:v1.47.0-jammy` instead. +- The `mcr.microsoft.com/playwright/dotnet:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble. + To use the 22.04 jammy-based image, please use `mcr.microsoft.com/playwright/dotnet:v1.47.0-jammy` instead. +- The `:latest`/`:focal`/`:jammy` tag for Playwright Docker images is no longer being published. Pin to a specific version for better stability and reproducibility. - TLS client certificates can now be passed from memory by passing [`option: cert`] and [`option: key`] as byte arrays instead of file paths. - [`option: noWaitAfter`] in [`method: Locator.selectOption`] was deprecated. - We've seen reports of WebGL in Webkit misbehaving on GitHub Actions `macos-13`. We recommend upgrading GitHub Actions to `macos-14`. diff --git a/docs/src/release-notes-java.md b/docs/src/release-notes-java.md index 2b9789e8bc..89a49f7451 100644 --- a/docs/src/release-notes-java.md +++ b/docs/src/release-notes-java.md @@ -18,8 +18,9 @@ The Network tab in the trace viewer has several nice improvements: ### Miscellaneous -- The `mcr.microsoft.com/playwright-java:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble. - To use the 22.02 jammy-based image, please use `mcr.microsoft.com/playwright-java:v1.47.0-jammy` instead. +- The `mcr.microsoft.com/playwright/java:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble. + To use the 22.02 jammy-based image, please use `mcr.microsoft.com/playwright/java:v1.47.0-jammy` instead. +- The `:latest`/`:focal`/`:jammy` tag for Playwright Docker images is no longer being published. Pin to a specific version for better stability and reproducibility. - TLS client certificates can now be passed from memory by passing [`option: cert`] and [`option: key`] as byte arrays instead of file paths. - [`option: noWaitAfter`] in [`method: Locator.selectOption`] was deprecated. - We've seen reports of WebGL in Webkit misbehaving on GitHub Actions `macos-13`. We recommend upgrading GitHub Actions to `macos-14`. diff --git a/docs/src/release-notes-python.md b/docs/src/release-notes-python.md index 985c19d388..7defa1c0d1 100644 --- a/docs/src/release-notes-python.md +++ b/docs/src/release-notes-python.md @@ -18,8 +18,9 @@ The Network tab in the trace viewer has several nice improvements: ### Miscellaneous -- The `mcr.microsoft.com/playwright-python:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble. - To use the 22.04 jammy-based image, please use `mcr.microsoft.com/playwright-python:v1.47.0-jammy` instead. +- The `mcr.microsoft.com/playwright/python:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble. + To use the 22.04 jammy-based image, please use `mcr.microsoft.com/playwright/python:v1.47.0-jammy` instead. +- The `:latest`/`:focal`/`:jammy` tag for Playwright Docker images is no longer being published. Pin to a specific version for better stability and reproducibility. - TLS client certificates can now be passed from memory by passing [`option: cert`] and [`option: key`] as bytes instead of file paths. - [`option: noWaitAfter`] in [`method: Locator.selectOption`] was deprecated. - We've seen reports of WebGL in Webkit misbehaving on GitHub Actions `macos-13`. We recommend upgrading GitHub Actions to `macos-14`. From b1b33efebb61a07670c870faaf58914d057d3a89 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Thu, 12 Sep 2024 04:46:15 -0700 Subject: [PATCH 05/41] feat(chromium): roll to r1135 (#32591) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- README.md | 4 +- packages/playwright-core/browsers.json | 4 +- .../src/server/deviceDescriptorsSource.json | 96 +++++++++---------- 3 files changed, 52 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 48c0014914..d0d8e53dd0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🎭 Playwright -[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-129.0.6668.29-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-130.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.0-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord) +[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-129.0.6668.42-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-130.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.0-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord) ## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright) @@ -8,7 +8,7 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 129.0.6668.29 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Chromium 129.0.6668.42 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 18.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox 130.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 3a1834117c..ee20e556aa 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -3,9 +3,9 @@ "browsers": [ { "name": "chromium", - "revision": "1134", + "revision": "1135", "installByDefault": true, - "browserVersion": "129.0.6668.29" + "browserVersion": "129.0.6668.42" }, { "name": "chromium-tip-of-tree", diff --git a/packages/playwright-core/src/server/deviceDescriptorsSource.json b/packages/playwright-core/src/server/deviceDescriptorsSource.json index 384f6f2377..efb2801f2c 100644 --- a/packages/playwright-core/src/server/deviceDescriptorsSource.json +++ b/packages/playwright-core/src/server/deviceDescriptorsSource.json @@ -110,7 +110,7 @@ "defaultBrowserType": "webkit" }, "Galaxy S5": { - "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -121,7 +121,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -132,7 +132,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S8": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 360, "height": 740 @@ -143,7 +143,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S8 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 740, "height": 360 @@ -154,7 +154,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S9+": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 320, "height": 658 @@ -165,7 +165,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S9+ landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 658, "height": 320 @@ -176,7 +176,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S4": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36", "viewport": { "width": 712, "height": 1138 @@ -187,7 +187,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36", "viewport": { "width": 1138, "height": 712 @@ -1098,7 +1098,7 @@ "defaultBrowserType": "webkit" }, "LG Optimus L70": { - "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1109,7 +1109,7 @@ "defaultBrowserType": "chromium" }, "LG Optimus L70 landscape": { - "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1120,7 +1120,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 550": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1131,7 +1131,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 550 landscape": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1142,7 +1142,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 950": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1153,7 +1153,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 950 landscape": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1164,7 +1164,7 @@ "defaultBrowserType": "chromium" }, "Nexus 10": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36", "viewport": { "width": 800, "height": 1280 @@ -1175,7 +1175,7 @@ "defaultBrowserType": "chromium" }, "Nexus 10 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36", "viewport": { "width": 1280, "height": 800 @@ -1186,7 +1186,7 @@ "defaultBrowserType": "chromium" }, "Nexus 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1197,7 +1197,7 @@ "defaultBrowserType": "chromium" }, "Nexus 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1208,7 +1208,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1219,7 +1219,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1230,7 +1230,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5X": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1241,7 +1241,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5X landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1252,7 +1252,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1263,7 +1263,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1274,7 +1274,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6P": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1285,7 +1285,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6P landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1296,7 +1296,7 @@ "defaultBrowserType": "chromium" }, "Nexus 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36", "viewport": { "width": 600, "height": 960 @@ -1307,7 +1307,7 @@ "defaultBrowserType": "chromium" }, "Nexus 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36", "viewport": { "width": 960, "height": 600 @@ -1362,7 +1362,7 @@ "defaultBrowserType": "webkit" }, "Pixel 2": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 411, "height": 731 @@ -1373,7 +1373,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 731, "height": 411 @@ -1384,7 +1384,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 XL": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 411, "height": 823 @@ -1395,7 +1395,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 XL landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 823, "height": 411 @@ -1406,7 +1406,7 @@ "defaultBrowserType": "chromium" }, "Pixel 3": { - "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 393, "height": 786 @@ -1417,7 +1417,7 @@ "defaultBrowserType": "chromium" }, "Pixel 3 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 786, "height": 393 @@ -1428,7 +1428,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 353, "height": 745 @@ -1439,7 +1439,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 745, "height": 353 @@ -1450,7 +1450,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G)": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "screen": { "width": 412, "height": 892 @@ -1465,7 +1465,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G) landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "screen": { "height": 892, "width": 412 @@ -1480,7 +1480,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "screen": { "width": 393, "height": 851 @@ -1495,7 +1495,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "screen": { "width": 851, "height": 393 @@ -1510,7 +1510,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "screen": { "width": 412, "height": 915 @@ -1525,7 +1525,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "screen": { "width": 915, "height": 412 @@ -1540,7 +1540,7 @@ "defaultBrowserType": "chromium" }, "Moto G4": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1551,7 +1551,7 @@ "defaultBrowserType": "chromium" }, "Moto G4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1562,7 +1562,7 @@ "defaultBrowserType": "chromium" }, "Desktop Chrome HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36", "screen": { "width": 1792, "height": 1120 @@ -1577,7 +1577,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36 Edg/129.0.6668.29", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36 Edg/129.0.6668.42", "screen": { "width": 1792, "height": 1120 @@ -1622,7 +1622,7 @@ "defaultBrowserType": "webkit" }, "Desktop Chrome": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36", "screen": { "width": 1920, "height": 1080 @@ -1637,7 +1637,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Safari/537.36 Edg/129.0.6668.29", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36 Edg/129.0.6668.42", "screen": { "width": 1920, "height": 1080 From 470b1b4922a665d2b4230dacde16be6593b5e80c Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Thu, 12 Sep 2024 04:46:33 -0700 Subject: [PATCH 06/41] feat(chromium-tip-of-tree): roll to r1259 (#32588) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index ee20e556aa..5a232cf37e 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -9,9 +9,9 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1256", + "revision": "1259", "installByDefault": false, - "browserVersion": "130.0.6695.0" + "browserVersion": "130.0.6713.0" }, { "name": "firefox", From c9f3eb158e16e409b69f718b8fb6c0bea60a187b Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 12 Sep 2024 19:40:40 +0200 Subject: [PATCH 07/41] feat(ui): highlight output toggle button if terminal contains error (#32392) Closes https://github.com/microsoft/playwright/issues/32368 Screenshot 2024-08-30 at 13 22 39 --- packages/trace-viewer/src/ui/uiModeView.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 2c4b61fdad..a97716bdc4 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -72,6 +72,7 @@ export const UIModeView: React.FC<{}> = ({ }) => { const [filterText, setFilterText] = React.useState(''); const [isShowingOutput, setIsShowingOutput] = React.useState(false); + const [outputContainsError, setOutputContainsError] = React.useState(false); const [statusFilters, setStatusFilters] = React.useState>(new Map([ ['passed', false], ['failed', false], @@ -134,6 +135,9 @@ export const UIModeView: React.FC<{}> = ({ } else { xtermDataSource.write(params.text!); } + + if (params.type === 'stderr') + setOutputContainsError(true); }), testServerConnection.onClose(() => setIsDisconnected(true)) ]; @@ -168,6 +172,7 @@ export const UIModeView: React.FC<{}> = ({ }, onError: error => { xtermDataSource.write((error.stack || error.value || '') + '\n'); + setOutputContainsError(true); }, pathSeparator: queryParams.pathSeparator, }); @@ -426,7 +431,7 @@ export const UIModeView: React.FC<{}> = ({
Output
- xtermDataSource.clear()}> + { xtermDataSource.clear(); setOutputContainsError(false); }}>
setIsShowingOutput(false)}>
@@ -447,7 +452,10 @@ export const UIModeView: React.FC<{}> = ({ Playwright logo
Playwright
reloadTests()} disabled={isRunningTest || isLoading}> - { setIsShowingOutput(!isShowingOutput); }} /> +
+ { setIsShowingOutput(!isShowingOutput); }} /> + {outputContainsError &&
} +
{!hasBrowsers && } Date: Thu, 12 Sep 2024 19:56:38 +0200 Subject: [PATCH 08/41] feat(test runner): make `expect.extend` immutable (#32366) Changes `expect.extend` behaviour so that it doesn't mutate the global instance and behaves closer to what users expect. This is formally a breaking change, and I had to remove a test that asserts the breaking behaviour. TODO: - [x] decide wether this is a separate method or a flag for `expect.extend` - [x] figure out if we need to change docs --- packages/playwright/src/matchers/expect.ts | 65 ++++++++++++---- tests/playwright-test/expect.spec.ts | 86 ++++++++++++---------- tests/playwright-test/test-step.spec.ts | 9 ++- 3 files changed, 102 insertions(+), 58 deletions(-) diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index ea796bfc72..9c426fd86e 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -16,6 +16,7 @@ import { captureRawStack, + createGuid, isString, pollAgainstDeadline } from 'playwright-core/lib/utils'; import type { ExpectZone } from 'playwright-core/lib/utils'; @@ -104,11 +105,17 @@ export const printReceivedStringContainExpectedResult = ( type ExpectMessage = string | { message?: string }; -function createMatchers(actual: unknown, info: ExpectMetaInfo): any { - return new Proxy(expectLibrary(actual), new ExpectMetaInfoProxyHandler(info)); +function createMatchers(actual: unknown, info: ExpectMetaInfo, prefix: string[]): any { + return new Proxy(expectLibrary(actual), new ExpectMetaInfoProxyHandler(info, prefix)); } -function createExpect(info: ExpectMetaInfo) { +const getCustomMatchersSymbol = Symbol('get custom matchers'); + +function qualifiedMatcherName(qualifier: string[], matcherName: string) { + return qualifier.join(':') + '$' + matcherName; +} + +function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Record) { const expectInstance: Expect<{}> = new Proxy(expectLibrary, { apply: function(target: any, thisArg: any, argumentsList: [unknown, ExpectMessage?]) { const [actual, messageOrOptions] = argumentsList; @@ -119,18 +126,22 @@ function createExpect(info: ExpectMetaInfo) { throw new Error('`expect.poll()` accepts only function as a first argument'); newInfo.generator = actual as any; } - return createMatchers(actual, newInfo); + return createMatchers(actual, newInfo, prefix); }, - get: function(target: any, property: string) { + get: function(target: any, property: string | typeof getCustomMatchersSymbol) { if (property === 'configure') return configure; if (property === 'extend') { return (matchers: any) => { + const qualifier = [...prefix, createGuid()]; + const wrappedMatchers: any = {}; + const extendedMatchers: any = { ...customMatchers }; for (const [name, matcher] of Object.entries(matchers)) { - wrappedMatchers[name] = function(...args: any[]) { + const key = qualifiedMatcherName(qualifier, name); + wrappedMatchers[key] = function(...args: any[]) { const { isNot, promise, utils } = this; const newThis: ExpectMatcherState = { isNot, @@ -141,9 +152,12 @@ function createExpect(info: ExpectMetaInfo) { (newThis as any).equals = throwUnsupportedExpectMatcherError; return (matcher as any).call(newThis, ...args); }; + Object.defineProperty(wrappedMatchers[key], 'name', { value: name }); + extendedMatchers[name] = wrappedMatchers[key]; } expectLibrary.extend(wrappedMatchers); - return expectInstance; + + return createExpect(info, qualifier, extendedMatchers); }; } @@ -153,6 +167,9 @@ function createExpect(info: ExpectMetaInfo) { }; } + if (property === getCustomMatchersSymbol) + return customMatchers; + if (property === 'poll') { return (actual: unknown, messageOrOptions?: ExpectMessage & { timeout?: number, intervals?: number[] }) => { const poll = isString(messageOrOptions) ? {} : messageOrOptions || {}; @@ -178,7 +195,7 @@ function createExpect(info: ExpectMetaInfo) { newInfo.pollIntervals = configuration._poll.intervals; } } - return createExpect(newInfo); + return createExpect(newInfo, prefix, customMatchers); }; return expectInstance; @@ -241,15 +258,28 @@ type ExpectMetaInfo = { class ExpectMetaInfoProxyHandler implements ProxyHandler { private _info: ExpectMetaInfo; + private _prefix: string[]; - constructor(info: ExpectMetaInfo) { + constructor(info: ExpectMetaInfo, prefix: string[]) { this._info = { ...info }; + this._prefix = prefix; } get(target: Object, matcherName: string | symbol, receiver: any): any { let matcher = Reflect.get(target, matcherName, receiver); if (typeof matcherName !== 'string') return matcher; + + let resolvedMatcherName = matcherName; + for (let i = this._prefix.length; i > 0; i--) { + const qualifiedName = qualifiedMatcherName(this._prefix.slice(0, i), matcherName); + if (Reflect.has(target, qualifiedName)) { + matcher = Reflect.get(target, qualifiedName, receiver); + resolvedMatcherName = qualifiedName; + break; + } + } + if (matcher === undefined) throw new Error(`expect: Property '${matcherName}' not found.`); if (typeof matcher !== 'function') { @@ -260,7 +290,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { if (this._info.isPoll) { if ((customAsyncMatchers as any)[matcherName] || matcherName === 'resolves' || matcherName === 'rejects') throw new Error(`\`expect.poll()\` does not support "${matcherName}" matcher.`); - matcher = (...args: any[]) => pollMatcher(matcherName, !!this._info.isNot, this._info.pollIntervals, this._info.pollTimeout ?? currentExpectTimeout(), this._info.generator!, ...args); + matcher = (...args: any[]) => pollMatcher(resolvedMatcherName, !!this._info.isNot, this._info.pollIntervals, this._info.pollTimeout ?? currentExpectTimeout(), this._info.generator!, ...args); } return (...args: any[]) => { const testInfo = currentTestInfo(); @@ -320,7 +350,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { } } -async function pollMatcher(matcherName: any, isNot: boolean, pollIntervals: number[] | undefined, timeout: number, generator: () => any, ...args: any[]) { +async function pollMatcher(qualifiedMatcherName: any, isNot: boolean, pollIntervals: number[] | undefined, timeout: number, generator: () => any, ...args: any[]) { const testInfo = currentTestInfo(); const { deadline, timeoutMessage } = testInfo ? testInfo._deadlineForMatcher(timeout) : TestInfoImpl._defaultDeadlineForMatcher(timeout); @@ -333,7 +363,7 @@ async function pollMatcher(matcherName: any, isNot: boolean, pollIntervals: numb if (isNot) expectInstance = expectInstance.not; try { - expectInstance[matcherName].call(expectInstance, ...args); + expectInstance[qualifiedMatcherName].call(expectInstance, ...args); return { continuePolling: false, result: undefined }; } catch (error) { return { continuePolling: true, result: error }; @@ -375,8 +405,15 @@ function computeArgsSuffix(matcherName: string, args: any[]) { return value ? `(${value})` : ''; } -export const expect: Expect<{}> = createExpect({}).extend(customMatchers); +export const expect: Expect<{}> = createExpect({}, [], {}).extend(customMatchers); export function mergeExpects(...expects: any[]) { - return expect; + let merged = expect; + for (const e of expects) { + const internals = e[getCustomMatchersSymbol]; + if (!internals) // non-playwright expects mutate the global expect, so we don't need to do anything special + continue; + merged = merged.extend(internals); + } + return merged; } diff --git a/tests/playwright-test/expect.spec.ts b/tests/playwright-test/expect.spec.ts index 63928e86fb..a541ad9c96 100644 --- a/tests/playwright-test/expect.spec.ts +++ b/tests/playwright-test/expect.spec.ts @@ -18,41 +18,6 @@ import path from 'path'; import { test, expect, parseTestRunnerOutput, stripAnsi } from './playwright-test-fixtures'; const { spawnAsync } = require('../../packages/playwright-core/lib/utils'); -test('should be able to call expect.extend in config', async ({ runInlineTest }) => { - const result = await runInlineTest({ - 'helper.ts': ` - import { test as base, expect } from '@playwright/test'; - expect.extend({ - toBeWithinRange(received, floor, ceiling) { - const pass = received >= floor && received <= ceiling; - if (pass) { - return { - message: () => - 'passed', - pass: true, - }; - } else { - return { - message: () => 'failed', - pass: false, - }; - } - }, - }); - export const test = base; - `, - 'expect-test.spec.ts': ` - import { test } from './helper'; - test('numeric ranges', () => { - test.expect(100).toBeWithinRange(90, 110); - test.expect(101).not.toBeWithinRange(0, 100); - }); - ` - }); - expect(result.exitCode).toBe(0); - expect(result.passed).toBe(1); -}); - test('should not expand huge arrays', async ({ runInlineTest }) => { const result = await runInlineTest({ 'expect-test.spec.ts': ` @@ -1043,8 +1008,8 @@ test('should expose timeout to custom matchers', async ({ runInlineTest, runTSC test('should throw error when using .equals()', async ({ runInlineTest }) => { const result = await runInlineTest({ 'helper.ts': ` - import { test as base, expect } from '@playwright/test'; - expect.extend({ + import { test as base, expect as baseExpect } from '@playwright/test'; + export const expect = baseExpect.extend({ toBeWithinRange(received, floor, ceiling) { this.equals(1, 2); }, @@ -1052,10 +1017,10 @@ test('should throw error when using .equals()', async ({ runInlineTest }) => { export const test = base; `, 'expect-test.spec.ts': ` - import { test } from './helper'; + import { test, expect } from './helper'; test('numeric ranges', () => { - test.expect(() => { - test.expect(100).toBeWithinRange(90, 110); + expect(() => { + expect(100).toBeWithinRange(90, 110); }).toThrowError('It looks like you are using custom expect matchers that are not compatible with Playwright. See https://aka.ms/playwright/expect-compatibility'); }); ` @@ -1063,3 +1028,44 @@ test('should throw error when using .equals()', async ({ runInlineTest }) => { expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); }); + +test('expect.extend should be immutable', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'expect-test.spec.ts': ` + import { test, expect } from '@playwright/test'; + const expectFoo = expect.extend({ + toFoo() { + console.log('%%foo'); + return { pass: true }; + } + }); + const expectFoo2 = expect.extend({ + toFoo() { + console.log('%%foo2'); + return { pass: true }; + } + }); + const expectBar = expectFoo.extend({ + toBar() { + console.log('%%bar'); + return { pass: true }; + } + }); + test('logs', () => { + expect(expectFoo).not.toBe(expectFoo2); + expect(expectFoo).not.toBe(expectBar); + + expectFoo().toFoo(); + expectFoo2().toFoo(); + expectBar().toFoo(); + expectBar().toBar(); + }); + ` + }); + expect(result.outputLines).toEqual([ + 'foo', + 'foo2', + 'foo', + 'bar', + ]); +}); \ No newline at end of file diff --git a/tests/playwright-test/test-step.spec.ts b/tests/playwright-test/test-step.spec.ts index f7538de3e9..96539a4c88 100644 --- a/tests/playwright-test/test-step.spec.ts +++ b/tests/playwright-test/test-step.spec.ts @@ -311,7 +311,9 @@ test('should report custom expect steps', async ({ runInlineTest }) => { }; `, 'a.test.ts': ` - expect.extend({ + import { test, expect as baseExpect } from '@playwright/test'; + + const expect = baseExpect.extend({ toBeWithinRange(received, floor, ceiling) { const pass = received >= floor && received <= ceiling; if (pass) { @@ -338,7 +340,6 @@ test('should report custom expect steps', async ({ runInlineTest }) => { }, }); - import { test, expect } from '@playwright/test'; test('fail', async ({}) => { expect(15).toBeWithinRange(10, 20); await expect(1).toBeFailingAsync(22); @@ -349,8 +350,8 @@ test('should report custom expect steps', async ({ runInlineTest }) => { expect(result.exitCode).toBe(1); expect(result.output).toBe(` hook |Before Hooks -expect |expect.toBeWithinRange @ a.test.ts:31 -expect |expect.toBeFailingAsync @ a.test.ts:32 +expect |expect.toBeWithinRange @ a.test.ts:32 +expect |expect.toBeFailingAsync @ a.test.ts:33 expect |↪ error: Error: It fails! hook |After Hooks hook |Worker Cleanup From 8e82f53ceb943eb6550e89801fc6cee779ca3965 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 12 Sep 2024 20:31:22 +0200 Subject: [PATCH 09/41] test: unskip various proxy tests (#32595) Unskipping of various tests and revert the rest of https://github.com/microsoft/playwright/pull/6350 since the upstream bug has been fixed. --- tests/library/browsercontext-proxy.spec.ts | 4 ---- tests/library/client-certificates.spec.ts | 9 --------- tests/library/proxy.spec.ts | 2 -- 3 files changed, 15 deletions(-) diff --git a/tests/library/browsercontext-proxy.spec.ts b/tests/library/browsercontext-proxy.spec.ts index 466e866e04..460c242998 100644 --- a/tests/library/browsercontext-proxy.spec.ts +++ b/tests/library/browsercontext-proxy.spec.ts @@ -25,10 +25,6 @@ it.beforeEach(({ server }) => { }); it('should work when passing the proxy only on the context level', async ({ browserName, platform, browserType, server, proxyServer }) => { - // Currently an upstream bug in the network stack of Chromium which leads that - // the wrong proxy gets used in the BrowserContext. - it.fixme(browserName === 'chromium' && platform === 'win32'); - proxyServer.forwardTo(server.PORT); let browser; try { diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index 75ca2468f7..682df0b00f 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -76,15 +76,6 @@ const test = base.extend({ }, }); -test.use({ - launchOptions: async ({ launchOptions }, use) => { - await use({ - ...launchOptions, - proxy: { server: 'per-context' } - }); - } -}); - const kDummyFileName = __filename; const kValidationSubTests: [BrowserContextOptions, string][] = [ [{ clientCertificates: [{ origin: 'test' }] }, 'None of cert, key, passphrase or pfx is specified'], diff --git a/tests/library/proxy.spec.ts b/tests/library/proxy.spec.ts index 344763bb1d..947f3b8788 100644 --- a/tests/library/proxy.spec.ts +++ b/tests/library/proxy.spec.ts @@ -145,7 +145,6 @@ it('should authenticate', async ({ browserType, server }) => { }); it('should work with authenticate followed by redirect', async ({ browserName, browserType, server }) => { - it.fixme(browserName === 'firefox', 'https://github.com/microsoft/playwright/issues/10095'); function hasAuth(req, res) { const auth = req.headers['proxy-authorization']; if (!auth) { @@ -324,7 +323,6 @@ async function setupSocksForwardingServer(port: number, forwardPort: number) { } it('should use SOCKS proxy for websocket requests', async ({ browserName, platform, browserType, server }, testInfo) => { - it.fixme(browserName === 'webkit' && platform !== 'linux'); const { proxyServerAddr, closeProxyServer } = await setupSocksForwardingServer(testInfo.workerIndex + 2048 + 2, server.PORT); const browser = await browserType.launch({ proxy: { From 5e086be36bbb1ba71c2ae07e2c66e2cf48af50c3 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 12 Sep 2024 20:32:34 +0200 Subject: [PATCH 10/41] chore: freeze ffmpeg on macOS-12 (#32596) --- packages/playwright-core/browsers.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 5a232cf37e..23974d6523 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -42,7 +42,11 @@ { "name": "ffmpeg", "revision": "1010", - "installByDefault": true + "installByDefault": true, + "revisionOverrides": { + "mac12": "1010", + "mac12-arm64": "1010" + } }, { "name": "android", From d051495c7a95e2fdbd5c4b740efeca540310b5e3 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 12 Sep 2024 11:40:44 -0700 Subject: [PATCH 11/41] chore: perform double click while recording (#32576) --- .../src/server/codegen/csharp.ts | 4 +- .../src/server/codegen/java.ts | 4 +- .../src/server/codegen/javascript.ts | 4 +- .../src/server/codegen/language.ts | 3 +- .../src/server/codegen/python.ts | 4 +- .../src/server/injected/recorder/recorder.ts | 61 +++++++++++++++++++ .../src/server/recorder/recorderCollection.ts | 9 --- .../src/server/recorder/recorderRunner.ts | 18 +++++- tests/library/inspector/cli-codegen-1.spec.ts | 40 ++++++++++++ tests/library/inspector/inspectorTest.ts | 7 +++ 10 files changed, 135 insertions(+), 19 deletions(-) diff --git a/packages/playwright-core/src/server/codegen/csharp.ts b/packages/playwright-core/src/server/codegen/csharp.ts index f11435a0c2..2244a372fc 100644 --- a/packages/playwright-core/src/server/codegen/csharp.ts +++ b/packages/playwright-core/src/server/codegen/csharp.ts @@ -16,7 +16,7 @@ import type { BrowserContextOptions } from '../../../types/types'; import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; -import { sanitizeDeviceOptions, toClickOptions, toKeyboardModifiers, toSignalMap } from './language'; +import { sanitizeDeviceOptions, toClickOptionsForSourceCode, toKeyboardModifiers, toSignalMap } from './language'; import { escapeWithQuotes, asLocator } from '../../utils'; import { deviceDescriptors } from '../deviceDescriptors'; @@ -112,7 +112,7 @@ export class CSharpLanguageGenerator implements LanguageGenerator { let method = 'Click'; if (action.clickCount === 2) method = 'DblClick'; - const options = toClickOptions(action); + const options = toClickOptionsForSourceCode(action); if (!Object.entries(options).length) return `await ${subject}.${this._asLocator(action.selector)}.${method}Async();`; const optionsString = formatObject(options, ' ', 'Locator' + method + 'Options'); diff --git a/packages/playwright-core/src/server/codegen/java.ts b/packages/playwright-core/src/server/codegen/java.ts index 47c6fa3619..3a640d36e2 100644 --- a/packages/playwright-core/src/server/codegen/java.ts +++ b/packages/playwright-core/src/server/codegen/java.ts @@ -17,7 +17,7 @@ import type { BrowserContextOptions } from '../../../types/types'; import type * as types from '../types'; import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; -import { toClickOptions, toKeyboardModifiers, toSignalMap } from './language'; +import { toClickOptionsForSourceCode, toKeyboardModifiers, toSignalMap } from './language'; import { deviceDescriptors } from '../deviceDescriptors'; import { JavaScriptFormatter } from './javascript'; import { escapeWithQuotes, asLocator } from '../../utils'; @@ -101,7 +101,7 @@ export class JavaLanguageGenerator implements LanguageGenerator { let method = 'click'; if (action.clickCount === 2) method = 'dblclick'; - const options = toClickOptions(action); + const options = toClickOptionsForSourceCode(action); const optionsText = formatClickOptions(options); return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.${method}(${optionsText});`; } diff --git a/packages/playwright-core/src/server/codegen/javascript.ts b/packages/playwright-core/src/server/codegen/javascript.ts index 1c1ba3f1cb..c3ed05d4d4 100644 --- a/packages/playwright-core/src/server/codegen/javascript.ts +++ b/packages/playwright-core/src/server/codegen/javascript.ts @@ -16,7 +16,7 @@ import type { BrowserContextOptions } from '../../../types/types'; import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; -import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptions } from './language'; +import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptionsForSourceCode } from './language'; import { deviceDescriptors } from '../deviceDescriptors'; import { escapeWithQuotes, asLocator } from '../../utils'; @@ -85,7 +85,7 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { let method = 'click'; if (action.clickCount === 2) method = 'dblclick'; - const options = toClickOptions(action); + const options = toClickOptionsForSourceCode(action); const optionsString = formatOptions(options, false); return `await ${subject}.${this._asLocator(action.selector)}.${method}(${optionsString});`; } diff --git a/packages/playwright-core/src/server/codegen/language.ts b/packages/playwright-core/src/server/codegen/language.ts index 72cfb9083d..3e0c8f71e5 100644 --- a/packages/playwright-core/src/server/codegen/language.ts +++ b/packages/playwright-core/src/server/codegen/language.ts @@ -69,13 +69,14 @@ export function toKeyboardModifiers(modifiers: number): types.SmartKeyboardModif return result; } -export function toClickOptions(action: actions.ClickAction): types.MouseClickOptions { +export function toClickOptionsForSourceCode(action: actions.ClickAction): types.MouseClickOptions { const modifiers = toKeyboardModifiers(action.modifiers); const options: types.MouseClickOptions = {}; if (action.button !== 'left') options.button = action.button; if (modifiers.length) options.modifiers = modifiers; + // Do not render clickCount === 2 for dblclick. if (action.clickCount > 2) options.clickCount = action.clickCount; if (action.position) diff --git a/packages/playwright-core/src/server/codegen/python.ts b/packages/playwright-core/src/server/codegen/python.ts index 6ed101bcf0..6c2b60dc70 100644 --- a/packages/playwright-core/src/server/codegen/python.ts +++ b/packages/playwright-core/src/server/codegen/python.ts @@ -16,7 +16,7 @@ import type { BrowserContextOptions } from '../../../types/types'; import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; -import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptions } from './language'; +import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptionsForSourceCode } from './language'; import { escapeWithQuotes, toSnakeCase, asLocator } from '../../utils'; import { deviceDescriptors } from '../deviceDescriptors'; @@ -94,7 +94,7 @@ export class PythonLanguageGenerator implements LanguageGenerator { let method = 'click'; if (action.clickCount === 2) method = 'dblclick'; - const options = toClickOptions(action); + const options = toClickOptionsForSourceCode(action); const optionsString = formatOptions(options, false); return `${subject}.${this._asLocator(action.selector)}.${method}(${optionsString})`; } diff --git a/packages/playwright-core/src/server/injected/recorder/recorder.ts b/packages/playwright-core/src/server/injected/recorder/recorder.ts index 8cbf11964f..48639fefc8 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -36,6 +36,7 @@ interface RecorderTool { cursor(): string; cleanup?(): void; onClick?(event: MouseEvent): void; + onDblClick?(event: MouseEvent): void; onContextMenu?(event: MouseEvent): void; onDragStart?(event: DragEvent): void; onInput?(event: Event): void; @@ -210,6 +211,7 @@ class RecordActionTool implements RecorderTool { private _hoveredElement: HTMLElement | null = null; private _activeModel: HighlightModel | null = null; private _expectProgrammaticKeyUp = false; + private _pendingClickAction: { action: actions.ClickAction, timeout: NodeJS.Timeout } | undefined; constructor(recorder: Recorder) { this._recorder = recorder; @@ -252,6 +254,38 @@ class RecordActionTool implements RecorderTool { return; } + this._cancelPendingClickAction(); + + // Stall click in case we are observing double-click. + if (event.detail === 1) { + this._pendingClickAction = { + action: { + name: 'click', + selector: this._hoveredModel!.selector, + position: positionForEvent(event), + signals: [], + button: buttonForEvent(event), + modifiers: modifiersForEvent(event), + clickCount: event.detail + }, + timeout: setTimeout(() => this._commitPendingClickAction(), 200) + }; + } + } + + onDblClick(event: MouseEvent) { + if (isRangeInput(this._hoveredElement)) + return; + if (this._shouldIgnoreMouseEvent(event)) + return; + // Only allow double click dispatch while action is in progress. + if (this._actionInProgress(event)) + return; + if (this._consumedDueToNoModel(event, this._hoveredModel)) + return; + + this._cancelPendingClickAction(); + this._performAction({ name: 'click', selector: this._hoveredModel!.selector, @@ -263,6 +297,18 @@ class RecordActionTool implements RecorderTool { }); } + private _commitPendingClickAction() { + if (this._pendingClickAction) + this._performAction(this._pendingClickAction.action); + this._cancelPendingClickAction(); + } + + private _cancelPendingClickAction() { + if (this._pendingClickAction) + clearTimeout(this._pendingClickAction.timeout); + this._pendingClickAction = undefined; + } + onContextMenu(event: MouseEvent) { // the 'contextmenu' event is triggered by a right-click or equivalent action, // and it prevents the click event from firing for that action, so we always @@ -915,6 +961,10 @@ class Overlay { } return false; } + + onDblClick(event: MouseEvent) { + return false; + } } export class Recorder { @@ -970,6 +1020,7 @@ export class Recorder { this._listeners = [ addEventListener(this.document, 'click', event => this._onClick(event as MouseEvent), true), addEventListener(this.document, 'auxclick', event => this._onClick(event as MouseEvent), true), + addEventListener(this.document, 'dblclick', event => this._onDblClick(event as MouseEvent), true), addEventListener(this.document, 'contextmenu', event => this._onContextMenu(event as MouseEvent), true), addEventListener(this.document, 'dragstart', event => this._onDragStart(event as DragEvent), true), addEventListener(this.document, 'input', event => this._onInput(event), true), @@ -1043,6 +1094,16 @@ export class Recorder { this._currentTool.onClick?.(event); } + private _onDblClick(event: MouseEvent) { + if (!event.isTrusted) + return; + if (this.overlay?.onDblClick(event)) + return; + if (this._ignoreOverlayEvent(event)) + return; + this._currentTool.onDblClick?.(event); + } + private _onContextMenu(event: MouseEvent) { if (!event.isTrusted) return; diff --git a/packages/playwright-core/src/server/recorder/recorderCollection.ts b/packages/playwright-core/src/server/recorder/recorderCollection.ts index 29da778ffb..a98660af38 100644 --- a/packages/playwright-core/src/server/recorder/recorderCollection.ts +++ b/packages/playwright-core/src/server/recorder/recorderCollection.ts @@ -78,10 +78,6 @@ export class RecorderCollection extends EventEmitter { if (action.selector === lastAction.selector) eraseLastAction = true; } - if (lastAction && action.name === 'click' && lastAction.name === 'click') { - if (action.selector === lastAction.selector && action.clickCount > lastAction.clickCount) - eraseLastAction = true; - } if (lastAction && action.name === 'navigate' && lastAction.name === 'navigate') { if (action.url === lastAction.url) { // Already at a target URL. @@ -89,11 +85,6 @@ export class RecorderCollection extends EventEmitter { return; } } - // Check and uncheck erase click. - if (lastAction && (action.name === 'check' || action.name === 'uncheck') && lastAction.name === 'click') { - if (action.selector === lastAction.selector) - eraseLastAction = true; - } } this._lastAction = actionInContext; diff --git a/packages/playwright-core/src/server/recorder/recorderRunner.ts b/packages/playwright-core/src/server/recorder/recorderRunner.ts index b6bdfd1a72..d27d18d3e7 100644 --- a/packages/playwright-core/src/server/recorder/recorderRunner.ts +++ b/packages/playwright-core/src/server/recorder/recorderRunner.ts @@ -15,11 +15,13 @@ */ import { createGuid, monotonicTime, serializeExpectedTextValues } from '../../utils'; -import { toClickOptions, toKeyboardModifiers } from '../codegen/language'; +import { toKeyboardModifiers } from '../codegen/language'; import type { ActionInContext } from '../codegen/types'; import type { Frame } from '../frames'; import type { CallMetadata } from '../instrumentation'; import type { Page } from '../page'; +import type * as actions from './recorderActions'; +import type * as types from '../types'; import { buildFullSelector } from './recorderUtils'; async function innerPerformAction(mainFrame: Frame, action: string, params: any, cb: (callMetadata: CallMetadata) => Promise): Promise { @@ -126,3 +128,17 @@ export async function performAction(pageAliases: Map, actionInCont } throw new Error('Internal error: unexpected action ' + (action as any).name); } + +export function toClickOptions(action: actions.ClickAction): types.MouseClickOptions { + const modifiers = toKeyboardModifiers(action.modifiers); + const options: types.MouseClickOptions = {}; + if (action.button !== 'left') + options.button = action.button; + if (modifiers.length) + options.modifiers = modifiers; + if (action.clickCount > 1) + options.clickCount = action.clickCount; + if (action.position) + options.position = action.position; + return options; +} diff --git a/tests/library/inspector/cli-codegen-1.spec.ts b/tests/library/inspector/cli-codegen-1.spec.ts index a58a8a38b9..4320aac5e8 100644 --- a/tests/library/inspector/cli-codegen-1.spec.ts +++ b/tests/library/inspector/cli-codegen-1.spec.ts @@ -52,6 +52,46 @@ await page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).ClickAsync();`) expect(message.text()).toBe('click'); }); + test('should double click', async ({ page, openRecorder }) => { + const recorder = await openRecorder(); + + await recorder.setContentAndWait(``); + + const locator = await recorder.hoverOverElement('button'); + expect(locator).toBe(`getByRole('button', { name: 'Submit' })`); + + const messages: string[] = []; + page.on('console', message => { + if (message.text().includes('click')) + messages.push(message.text()); + }); + const [, sources] = await Promise.all([ + page.waitForEvent('console', msg => msg.type() !== 'error' && msg.text() === 'dblclick 2'), + recorder.waitForOutput('JavaScript', 'dblclick'), + recorder.trustedDblclick(), + ]); + + expect.soft(sources.get('JavaScript')!.text).toContain(` + await page.getByRole('button', { name: 'Submit' }).dblclick();`); + + expect.soft(sources.get('Python')!.text).toContain(` + page.get_by_role("button", name="Submit").dblclick()`); + + expect.soft(sources.get('Python Async')!.text).toContain(` + await page.get_by_role("button", name="Submit").dblclick()`); + + expect.soft(sources.get('Java')!.text).toContain(` + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Submit")).dblclick()`); + + expect.soft(sources.get('C#')!.text).toContain(` +await page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).DblClickAsync();`); + + expect(messages).toEqual([ + 'click 1', + 'click 2', + 'dblclick 2', + ]); + }); test('should ignore programmatic events', async ({ page, openRecorder }) => { const recorder = await openRecorder(); diff --git a/tests/library/inspector/inspectorTest.ts b/tests/library/inspector/inspectorTest.ts index 6ebbc1fdd1..02acdeb6fd 100644 --- a/tests/library/inspector/inspectorTest.ts +++ b/tests/library/inspector/inspectorTest.ts @@ -191,6 +191,13 @@ class Recorder { await this.page.mouse.up(options); } + async trustedDblclick() { + await this.page.mouse.down(); + await this.page.mouse.up(); + await this.page.mouse.down({ clickCount: 2 }); + await this.page.mouse.up(); + } + async focusElement(selector: string): Promise { return this.waitForHighlight(() => this.page.focus(selector)); } From de08e729aeede3df1448fc0e6a7b531dde33b6eb Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 12 Sep 2024 12:42:28 -0700 Subject: [PATCH 12/41] chore: move recorder trace to action collector (#32597) --- .../src/server/recorder/contextRecorder.ts | 23 ++-- .../src/server/recorder/recorderCollection.ts | 63 ++++++--- .../src/server/recorder/recorderRunner.ts | 121 +++++++++--------- .../src/server/recorder/recorderUtils.ts | 57 +++++++++ 4 files changed, 176 insertions(+), 88 deletions(-) diff --git a/packages/playwright-core/src/server/recorder/contextRecorder.ts b/packages/playwright-core/src/server/recorder/contextRecorder.ts index 17d2c2c130..88aeacc368 100644 --- a/packages/playwright-core/src/server/recorder/contextRecorder.ts +++ b/packages/playwright-core/src/server/recorder/contextRecorder.ts @@ -75,7 +75,7 @@ export class ContextRecorder extends EventEmitter { saveStorage: params.saveStorage, }; - const collection = new RecorderCollection(params.mode === 'recording'); + const collection = new RecorderCollection(this._pageAliases, params.mode === 'recording'); collection.on('change', () => { this._recorderSources = []; for (const languageGenerator of this._orderedLanguages) { @@ -163,7 +163,7 @@ export class ContextRecorder extends EventEmitter { // First page is called page, others are called popup1, popup2, etc. const frame = page.mainFrame(); page.on('close', () => { - this._collection.addAction({ + this._collection.addRecordedAction({ frame: this._describeMainFrame(page), committed: true, action: { @@ -185,7 +185,7 @@ export class ContextRecorder extends EventEmitter { if (page.opener()) { this._onPopup(page.opener()!, page); } else { - this._collection.addAction({ + this._collection.addRecordedAction({ frame: this._describeMainFrame(page), committed: true, action: { @@ -236,14 +236,15 @@ export class ContextRecorder extends EventEmitter { await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext); - this._collection.willPerformAction(actionInContext); - const success = await performAction(this._pageAliases, actionInContext); - if (success) { - this._collection.didPerformAction(actionInContext); + const callMetadata = await this._collection.willPerformAction(actionInContext); + if (!callMetadata) + return; + const error = await performAction(callMetadata, this._pageAliases, actionInContext).then(() => undefined).catch((e: Error) => e); + await this._collection.didPerformAction(callMetadata, actionInContext, error); + if (error) + actionInContext.committed = true; + else this._setCommittedAfterTimeout(actionInContext); - } else { - this._collection.performedActionFailed(actionInContext); - } } private async _recordAction(frame: Frame, action: actions.Action) { @@ -260,7 +261,7 @@ export class ContextRecorder extends EventEmitter { await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext); this._setCommittedAfterTimeout(actionInContext); - this._collection.addAction(actionInContext); + this._collection.addRecordedAction(actionInContext); } private _setCommittedAfterTimeout(actionInContext: ActionInContext) { diff --git a/packages/playwright-core/src/server/recorder/recorderCollection.ts b/packages/playwright-core/src/server/recorder/recorderCollection.ts index a98660af38..ab44410e2f 100644 --- a/packages/playwright-core/src/server/recorder/recorderCollection.ts +++ b/packages/playwright-core/src/server/recorder/recorderCollection.ts @@ -16,18 +16,25 @@ import { EventEmitter } from 'events'; import type { Frame } from '../frames'; +import type { Page } from '../page'; import type { Signal } from './recorderActions'; import type { ActionInContext } from '../codegen/types'; +import type { CallMetadata } from '@protocol/callMetadata'; +import { createGuid } from '../../utils/crypto'; +import { monotonicTime } from '../../utils/time'; +import { mainFrameForAction, traceParamsForAction } from './recorderUtils'; export class RecorderCollection extends EventEmitter { private _currentAction: ActionInContext | null = null; private _lastAction: ActionInContext | null = null; private _actions: ActionInContext[] = []; private _enabled: boolean; + private _pageAliases: Map; - constructor(enabled: boolean) { + constructor(pageAliases: Map, enabled: boolean) { super(); this._enabled = enabled; + this._pageAliases = pageAliases; this.restart(); } @@ -46,29 +53,55 @@ export class RecorderCollection extends EventEmitter { this._enabled = enabled; } - addAction(action: ActionInContext) { + async willPerformAction(actionInContext: ActionInContext): Promise { if (!this._enabled) - return; - this.willPerformAction(action); - this.didPerformAction(action); + return null; + const mainFrame = mainFrameForAction(this._pageAliases, actionInContext); + + const { action } = actionInContext; + const callMetadata: CallMetadata = { + id: `call@${createGuid()}`, + apiName: 'frame.' + action.name, + objectId: mainFrame.guid, + pageId: mainFrame._page.guid, + frameId: mainFrame.guid, + startTime: monotonicTime(), + endTime: 0, + type: 'Frame', + method: action.name, + params: traceParamsForAction(actionInContext), + log: [], + }; + await mainFrame.instrumentation.onBeforeCall(mainFrame, callMetadata); + this._currentAction = actionInContext; + return callMetadata; } - willPerformAction(action: ActionInContext) { + async didPerformAction(callMetadata: CallMetadata, actionInContext: ActionInContext, error?: Error) { if (!this._enabled) return; - this._currentAction = action; - } - performedActionFailed(action: ActionInContext) { - if (!this._enabled) - return; - if (this._currentAction === action) + if (error) { + // Do not clear current action on delayed error. + if (this._currentAction === actionInContext) + this._currentAction = null; + } else { this._currentAction = null; + this._actions.push(actionInContext); + } + + this._lastAction = actionInContext; + const mainFrame = mainFrameForAction(this._pageAliases, actionInContext); + callMetadata.endTime = monotonicTime(); + await mainFrame.instrumentation.onAfterCall(mainFrame, callMetadata); + + this.emit('change'); } - didPerformAction(actionInContext: ActionInContext) { + addRecordedAction(actionInContext: ActionInContext) { if (!this._enabled) return; + this._currentAction = null; const action = actionInContext.action; let eraseLastAction = false; if (this._lastAction && this._lastAction.frame.pageAlias === actionInContext.frame.pageAlias) { @@ -81,14 +114,12 @@ export class RecorderCollection extends EventEmitter { if (lastAction && action.name === 'navigate' && lastAction.name === 'navigate') { if (action.url === lastAction.url) { // Already at a target URL. - this._currentAction = null; return; } } } this._lastAction = actionInContext; - this._currentAction = null; if (eraseLastAction) this._actions.pop(); this._actions.push(actionInContext); @@ -125,7 +156,7 @@ export class RecorderCollection extends EventEmitter { } if (signal.name === 'navigation' && frame._page.mainFrame() === frame) { - this.addAction({ + this.addRecordedAction({ frame: { pageAlias, framePath: [], diff --git a/packages/playwright-core/src/server/recorder/recorderRunner.ts b/packages/playwright-core/src/server/recorder/recorderRunner.ts index d27d18d3e7..f5358d6097 100644 --- a/packages/playwright-core/src/server/recorder/recorderRunner.ts +++ b/packages/playwright-core/src/server/recorder/recorderRunner.ts @@ -14,118 +14,117 @@ * limitations under the License. */ -import { createGuid, monotonicTime, serializeExpectedTextValues } from '../../utils'; +import { serializeExpectedTextValues } from '../../utils'; import { toKeyboardModifiers } from '../codegen/language'; import type { ActionInContext } from '../codegen/types'; -import type { Frame } from '../frames'; import type { CallMetadata } from '../instrumentation'; import type { Page } from '../page'; import type * as actions from './recorderActions'; import type * as types from '../types'; -import { buildFullSelector } from './recorderUtils'; +import { buildFullSelector, mainFrameForAction } from './recorderUtils'; -async function innerPerformAction(mainFrame: Frame, action: string, params: any, cb: (callMetadata: CallMetadata) => Promise): Promise { - const callMetadata: CallMetadata = { - id: `call@${createGuid()}`, - apiName: 'frame.' + action, - objectId: mainFrame.guid, - pageId: mainFrame._page.guid, - frameId: mainFrame.guid, - startTime: monotonicTime(), - endTime: 0, - type: 'Frame', - method: action, - params, - log: [], - }; - - try { - await mainFrame.instrumentation.onBeforeCall(mainFrame, callMetadata); - await cb(callMetadata); - } catch (e) { - callMetadata.endTime = monotonicTime(); - await mainFrame.instrumentation.onAfterCall(mainFrame, callMetadata); - return false; - } - - callMetadata.endTime = monotonicTime(); - await mainFrame.instrumentation.onAfterCall(mainFrame, callMetadata); - return true; -} - -export async function performAction(pageAliases: Map, actionInContext: ActionInContext): Promise { - const pageAlias = actionInContext.frame.pageAlias; - const page = [...pageAliases.entries()].find(([, alias]) => pageAlias === alias)?.[0]; - if (!page) - throw new Error('Internal error: page not found'); - const mainFrame = page.mainFrame(); +export async function performAction(callMetadata: CallMetadata, pageAliases: Map, actionInContext: ActionInContext) { + const mainFrame = mainFrameForAction(pageAliases, actionInContext); const { action } = actionInContext; + const kActionTimeout = 5000; - if (action.name === 'navigate') - return await innerPerformAction(mainFrame, 'goto', { url: action.url }, callMetadata => mainFrame.goto(callMetadata, action.url, { timeout: kActionTimeout })); + if (action.name === 'navigate') { + await mainFrame.goto(callMetadata, action.url, { timeout: kActionTimeout }); + return; + } + if (action.name === 'openPage') throw Error('Not reached'); - if (action.name === 'closePage') - return await innerPerformAction(mainFrame, 'close', {}, callMetadata => mainFrame._page.close(callMetadata)); + + if (action.name === 'closePage') { + await mainFrame._page.close(callMetadata); + return; + } const selector = buildFullSelector(actionInContext.frame.framePath, action.selector); if (action.name === 'click') { const options = toClickOptions(action); - return await innerPerformAction(mainFrame, 'click', { selector }, callMetadata => mainFrame.click(callMetadata, selector, { ...options, timeout: kActionTimeout, strict: true })); + await mainFrame.click(callMetadata, selector, { ...options, timeout: kActionTimeout, strict: true }); + return; } + if (action.name === 'press') { const modifiers = toKeyboardModifiers(action.modifiers); const shortcut = [...modifiers, action.key].join('+'); - return await innerPerformAction(mainFrame, 'press', { selector, key: shortcut }, callMetadata => mainFrame.press(callMetadata, selector, shortcut, { timeout: kActionTimeout, strict: true })); + await mainFrame.press(callMetadata, selector, shortcut, { timeout: kActionTimeout, strict: true }); + return; } - if (action.name === 'fill') - return await innerPerformAction(mainFrame, 'fill', { selector, text: action.text }, callMetadata => mainFrame.fill(callMetadata, selector, action.text, { timeout: kActionTimeout, strict: true })); - if (action.name === 'setInputFiles') - return await innerPerformAction(mainFrame, 'setInputFiles', { selector, files: action.files }, callMetadata => mainFrame.setInputFiles(callMetadata, selector, { selector, payloads: [], timeout: kActionTimeout, strict: true })); - if (action.name === 'check') - return await innerPerformAction(mainFrame, 'check', { selector }, callMetadata => mainFrame.check(callMetadata, selector, { timeout: kActionTimeout, strict: true })); - if (action.name === 'uncheck') - return await innerPerformAction(mainFrame, 'uncheck', { selector }, callMetadata => mainFrame.uncheck(callMetadata, selector, { timeout: kActionTimeout, strict: true })); + + if (action.name === 'fill') { + await mainFrame.fill(callMetadata, selector, action.text, { timeout: kActionTimeout, strict: true }); + return; + } + + if (action.name === 'setInputFiles') { + await mainFrame.setInputFiles(callMetadata, selector, { selector, payloads: [], timeout: kActionTimeout, strict: true }); + return; + } + + if (action.name === 'check') { + await mainFrame.check(callMetadata, selector, { timeout: kActionTimeout, strict: true }); + return; + } + + if (action.name === 'uncheck') { + await mainFrame.uncheck(callMetadata, selector, { timeout: kActionTimeout, strict: true }); + return; + } + if (action.name === 'select') { const values = action.options.map(value => ({ value })); - return await innerPerformAction(mainFrame, 'selectOption', { selector, values }, callMetadata => mainFrame.selectOption(callMetadata, selector, [], values, { timeout: kActionTimeout, strict: true })); + await mainFrame.selectOption(callMetadata, selector, [], values, { timeout: kActionTimeout, strict: true }); + return; } + if (action.name === 'assertChecked') { - return await innerPerformAction(mainFrame, 'expect', { selector }, callMetadata => mainFrame.expect(callMetadata, selector, { + await mainFrame.expect(callMetadata, selector, { selector, expression: 'to.be.checked', isNot: !action.checked, timeout: kActionTimeout, - })); + }); + return; } + if (action.name === 'assertText') { - return await innerPerformAction(mainFrame, 'expect', { selector }, callMetadata => mainFrame.expect(callMetadata, selector, { + await mainFrame.expect(callMetadata, selector, { selector, expression: 'to.have.text', expectedText: serializeExpectedTextValues([action.text], { matchSubstring: true, normalizeWhiteSpace: true }), isNot: false, timeout: kActionTimeout, - })); + }); + return; } + if (action.name === 'assertValue') { - return await innerPerformAction(mainFrame, 'expect', { selector }, callMetadata => mainFrame.expect(callMetadata, selector, { + await mainFrame.expect(callMetadata, selector, { selector, expression: 'to.have.value', expectedValue: action.value, isNot: false, timeout: kActionTimeout, - })); + }); + return; } + if (action.name === 'assertVisible') { - return await innerPerformAction(mainFrame, 'expect', { selector }, callMetadata => mainFrame.expect(callMetadata, selector, { + await mainFrame.expect(callMetadata, selector, { selector, expression: 'to.be.visible', isNot: false, timeout: kActionTimeout, - })); + }); + return; } + throw new Error('Internal error: unexpected action ' + (action as any).name); } diff --git a/packages/playwright-core/src/server/recorder/recorderUtils.ts b/packages/playwright-core/src/server/recorder/recorderUtils.ts index b4949115d2..234fc79a0f 100644 --- a/packages/playwright-core/src/server/recorder/recorderUtils.ts +++ b/packages/playwright-core/src/server/recorder/recorderUtils.ts @@ -20,6 +20,8 @@ import type { Page } from '../page'; import type { ActionInContext } from '../codegen/types'; import type { Frame } from '../frames'; import type * as actions from './recorderActions'; +import { toKeyboardModifiers } from '../codegen/language'; +import { serializeExpectedTextValues } from '../../utils/expectUtils'; export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus): CallLog { let title = metadata.apiName || metadata.method; @@ -72,3 +74,58 @@ export async function frameForAction(pageAliases: Map, actionInCon throw new Error('Internal error: frame not found'); return result.frame; } + +export function traceParamsForAction(actionInContext: ActionInContext) { + const { action } = actionInContext; + + switch (action.name) { + case 'navigate': return { url: action.url }; + case 'openPage': return {}; + case 'closePage': return {}; + } + + const selector = buildFullSelector(actionInContext.frame.framePath, action.selector); + switch (action.name) { + case 'click': return { selector, clickCount: action.clickCount }; + case 'press': { + const modifiers = toKeyboardModifiers(action.modifiers); + const shortcut = [...modifiers, action.key].join('+'); + return { selector, key: shortcut }; + } + case 'fill': return { selector, text: action.text }; + case 'setInputFiles': return { selector, files: action.files }; + case 'check': return { selector }; + case 'uncheck': return { selector }; + case 'select': return { selector, values: action.options.map(value => ({ value })) }; + case 'assertChecked': { + return { + selector, + expression: 'to.be.checked', + isNot: !action.checked, + }; + } + case 'assertText': { + return { + selector, + expression: 'to.have.text', + expectedText: serializeExpectedTextValues([action.text], { matchSubstring: true, normalizeWhiteSpace: true }), + isNot: false, + }; + } + case 'assertValue': { + return { + selector, + expression: 'to.have.value', + expectedValue: action.value, + isNot: false, + }; + } + case 'assertVisible': { + return { + selector, + expression: 'to.be.visible', + isNot: false, + }; + } + } +} From 7e3348eb0e85252054d92b27e25f18f017c17916 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 12 Sep 2024 13:39:44 -0700 Subject: [PATCH 13/41] chore: recorder is trace viewer experiment (#32598) --- packages/playwright-core/src/cli/program.ts | 23 ++- .../dispatchers/browserContextDispatcher.ts | 4 +- .../playwright-core/src/server/recorder.ts | 12 +- .../src/server/recorder/DEPS.list | 3 + .../src/server/recorder/contextRecorder.ts | 2 +- .../src/server/recorder/recorderApp.ts | 20 +-- .../src/server/recorder/recorderFrontend.ts | 35 ++++ .../server/recorder/recorderInTraceViewer.ts | 94 ++++++++++ packages/trace-viewer/recorder.html | 28 +++ packages/trace-viewer/src/recorder.tsx | 41 +++++ packages/trace-viewer/src/ui/recorderView.css | 15 ++ packages/trace-viewer/src/ui/recorderView.tsx | 168 ++++++++++++++++++ packages/trace-viewer/src/ui/sourceTab.tsx | 6 +- packages/trace-viewer/vite.config.ts | 1 + 14 files changed, 419 insertions(+), 33 deletions(-) create mode 100644 packages/playwright-core/src/server/recorder/recorderFrontend.ts create mode 100644 packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts create mode 100644 packages/trace-viewer/recorder.html create mode 100644 packages/trace-viewer/src/recorder.tsx create mode 100644 packages/trace-viewer/src/ui/recorderView.css create mode 100644 packages/trace-viewer/src/ui/recorderView.tsx diff --git a/packages/playwright-core/src/cli/program.ts b/packages/playwright-core/src/cli/program.ts index 9c68271e80..d8fa8230c6 100644 --- a/packages/playwright-core/src/cli/program.ts +++ b/packages/playwright-core/src/cli/program.ts @@ -348,10 +348,10 @@ type CaptureOptions = { fullPage: boolean; }; -async function launchContext(options: Options, headless: boolean, executablePath?: string): Promise<{ browser: Browser, browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, context: BrowserContext }> { +async function launchContext(options: Options, extraOptions: LaunchOptions): Promise<{ browser: Browser, browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, context: BrowserContext }> { validateOptions(options); const browserType = lookupBrowserType(options); - const launchOptions: LaunchOptions = { headless, executablePath }; + const launchOptions: LaunchOptions = extraOptions; if (options.channel) launchOptions.channel = options.channel as any; launchOptions.handleSIGINT = false; @@ -363,7 +363,7 @@ async function launchContext(options: Options, headless: boolean, executablePath // In headful mode, use host device scale factor for things to look nice. // In headless, keep things the way it works in Playwright by default. // Assume high-dpi on MacOS. TODO: this is not perfect. - if (!headless) + if (!extraOptions.headless) contextOptions.deviceScaleFactor = os.platform() === 'darwin' ? 2 : 1; // Work around the WebKit GTK scrolling issue. @@ -547,7 +547,7 @@ async function openPage(context: BrowserContext, url: string | undefined): Promi } async function open(options: Options, url: string | undefined, language: string) { - const { context, launchOptions, contextOptions } = await launchContext(options, !!process.env.PWTEST_CLI_HEADLESS, process.env.PWTEST_CLI_EXECUTABLE_PATH); + const { context, launchOptions, contextOptions } = await launchContext(options, { headless: !!process.env.PWTEST_CLI_HEADLESS, executablePath: process.env.PWTEST_CLI_EXECUTABLE_PATH }); await context._enableRecorder({ language, launchOptions, @@ -560,8 +560,17 @@ async function open(options: Options, url: string | undefined, language: string) async function codegen(options: Options & { target: string, output?: string, testIdAttribute?: string }, url: string | undefined) { const { target: language, output: outputFile, testIdAttribute: testIdAttributeName } = options; - const { context, launchOptions, contextOptions } = await launchContext(options, !!process.env.PWTEST_CLI_HEADLESS, process.env.PWTEST_CLI_EXECUTABLE_PATH); + const tracesDir = path.join(os.tmpdir(), `recorder-trace-${Date.now()}`); + const { context, launchOptions, contextOptions } = await launchContext(options, { + headless: !!process.env.PWTEST_CLI_HEADLESS, + executablePath: process.env.PWTEST_CLI_EXECUTABLE_PATH, + tracesDir, + }); dotenv.config({ path: 'playwright.env' }); + if (process.env.PW_RECORDER_IS_TRACE_VIEWER) { + await fs.promises.mkdir(tracesDir, { recursive: true }); + await context.tracing.start({ name: 'trace', _live: true }); + } await context._enableRecorder({ language, launchOptions, @@ -587,7 +596,7 @@ async function waitForPage(page: Page, captureOptions: CaptureOptions) { } async function screenshot(options: Options, captureOptions: CaptureOptions, url: string, path: string) { - const { context } = await launchContext(options, true); + const { context } = await launchContext(options, { headless: true }); console.log('Navigating to ' + url); const page = await openPage(context, url); await waitForPage(page, captureOptions); @@ -600,7 +609,7 @@ async function screenshot(options: Options, captureOptions: CaptureOptions, url: async function pdf(options: Options, captureOptions: CaptureOptions, url: string, path: string) { if (options.browser !== 'chromium') throw new Error('PDF creation is only working with Chromium'); - const { context } = await launchContext({ ...options, browser: 'chromium' }, true); + const { context } = await launchContext({ ...options, browser: 'chromium' }, { headless: true }); console.log('Navigating to ' + url); const page = await openPage(context, url); await waitForPage(page, captureOptions); diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index c70d8e825a..5c8fa550a7 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -39,6 +39,7 @@ import type { Dialog } from '../dialog'; import type { ConsoleMessage } from '../console'; import { serializeError } from '../errors'; import { ElementHandleDispatcher } from './elementHandlerDispatcher'; +import { RecorderInTraceViewer } from '../recorder/recorderInTraceViewer'; import { RecorderApp } from '../recorder/recorderApp'; export class BrowserContextDispatcher extends Dispatcher implements channels.BrowserContextChannel { @@ -292,7 +293,8 @@ export class BrowserContextDispatcher extends Dispatcher { - await Recorder.show(this._context, RecorderApp.factory(this._context), params); + const factory = process.env.PW_RECORDER_IS_TRACE_VIEWER ? RecorderInTraceViewer.factory(this._context) : RecorderApp.factory(this._context); + await Recorder.show(this._context, factory, params); } async pause(params: channels.BrowserContextPauseParams, metadata: CallMetadata) { diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 97316c2f9e..5e197f871f 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -26,14 +26,12 @@ import { type Language } from './codegen/types'; import { Debugger } from './debugger'; import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation'; import { ContextRecorder, generateFrameSelector } from './recorder/contextRecorder'; -import { type IRecorderApp } from './recorder/recorderApp'; +import type { IRecorderAppFactory, IRecorderApp, IRecorder } from './recorder/recorderFrontend'; import { buildFullSelector, metadataToCallLog } from './recorder/recorderUtils'; const recorderSymbol = Symbol('recorderSymbol'); -export type RecorderAppFactory = (recorder: Recorder) => Promise; - -export class Recorder implements InstrumentationListener { +export class Recorder implements InstrumentationListener, IRecorder { private _context: BrowserContext; private _mode: Mode; private _highlightedSelector = ''; @@ -47,14 +45,14 @@ export class Recorder implements InstrumentationListener { private _omitCallTracking = false; private _currentLanguage: Language; - static showInspector(context: BrowserContext, recorderAppFactory: RecorderAppFactory) { + static showInspector(context: BrowserContext, recorderAppFactory: IRecorderAppFactory) { const params: channels.BrowserContextRecorderSupplementEnableParams = {}; if (isUnderTest()) params.language = process.env.TEST_INSPECTOR_LANGUAGE; Recorder.show(context, recorderAppFactory, params).catch(() => {}); } - static show(context: BrowserContext, recorderAppFactory: RecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { + static show(context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { let recorderPromise = (context as any)[recorderSymbol] as Promise; if (!recorderPromise) { recorderPromise = Recorder._create(context, recorderAppFactory, params); @@ -63,7 +61,7 @@ export class Recorder implements InstrumentationListener { return recorderPromise; } - private static async _create(context: BrowserContext, recorderAppFactory: RecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { + private static async _create(context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { const recorder = new Recorder(context, params); const recorderApp = await recorderAppFactory(recorder); await recorder._install(recorderApp); diff --git a/packages/playwright-core/src/server/recorder/DEPS.list b/packages/playwright-core/src/server/recorder/DEPS.list index 22ec3dfc2f..f3bbfc23bf 100644 --- a/packages/playwright-core/src/server/recorder/DEPS.list +++ b/packages/playwright-core/src/server/recorder/DEPS.list @@ -10,3 +10,6 @@ ../../utils/** ../../utilsBundle.ts ../../zipBundle.ts + +[recorderInTraceViewer.ts] +../trace/viewer/traceViewer.ts diff --git a/packages/playwright-core/src/server/recorder/contextRecorder.ts b/packages/playwright-core/src/server/recorder/contextRecorder.ts index 88aeacc368..9b4efb9e65 100644 --- a/packages/playwright-core/src/server/recorder/contextRecorder.ts +++ b/packages/playwright-core/src/server/recorder/contextRecorder.ts @@ -69,7 +69,7 @@ export class ContextRecorder extends EventEmitter { // Make a copy of options to modify them later. const languageGeneratorOptions: LanguageGeneratorOptions = { browserName: context._browser.options.name, - launchOptions: { headless: false, ...params.launchOptions }, + launchOptions: { headless: false, ...params.launchOptions, tracesDir: undefined }, contextOptions: { ...params.contextOptions }, deviceName: params.device, saveStorage: params.saveStorage, diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index 0faf191ea5..8044fadf41 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -24,9 +24,9 @@ import type { CallLog, EventData, Mode, Source } from '@recorder/recorderTypes'; import { isUnderTest } from '../../utils'; import { mime } from '../../utilsBundle'; import { syncLocalStorageWithSettings } from '../launchApp'; -import type { Recorder, RecorderAppFactory } from '../recorder'; import type { BrowserContext } from '../browserContext'; import { launchApp } from '../launchApp'; +import type { IRecorder, IRecorderApp, IRecorderAppFactory } from './recorderFrontend'; declare global { interface Window { @@ -42,16 +42,6 @@ declare global { } } -export interface IRecorderApp extends EventEmitter { - close(): Promise; - setPaused(paused: boolean): Promise; - setMode(mode: Mode): Promise; - setFileIfNeeded(file: string): Promise; - setSelector(selector: string, userGesture?: boolean): Promise; - updateCallLogs(callLogs: CallLog[]): Promise; - setSources(sources: Source[]): Promise; -} - export class EmptyRecorderApp extends EventEmitter implements IRecorderApp { async close(): Promise {} async setPaused(paused: boolean): Promise {} @@ -65,9 +55,9 @@ export class EmptyRecorderApp extends EventEmitter implements IRecorderApp { export class RecorderApp extends EventEmitter implements IRecorderApp { private _page: Page; readonly wsEndpoint: string | undefined; - private _recorder: Recorder; + private _recorder: IRecorder; - constructor(recorder: Recorder, page: Page, wsEndpoint: string | undefined) { + constructor(recorder: IRecorder, page: Page, wsEndpoint: string | undefined) { super(); this.setMaxListeners(0); this._recorder = recorder; @@ -113,7 +103,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { await mainFrame.goto(serverSideCallMetadata(), 'https://playwright/index.html'); } - static factory(context: BrowserContext): RecorderAppFactory { + static factory(context: BrowserContext): IRecorderAppFactory { return async recorder => { if (process.env.PW_CODEGEN_NO_INSPECTOR) return new EmptyRecorderApp(); @@ -121,7 +111,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { }; } - private static async _open(recorder: Recorder, inspectedContext: BrowserContext): Promise { + private static async _open(recorder: IRecorder, inspectedContext: BrowserContext): Promise { const sdkLanguage = inspectedContext.attribution.playwright.options.sdkLanguage; const headed = !!inspectedContext._browser.options.headful; const recorderPlaywright = (require('../playwright').createPlaywright as typeof import('../playwright').createPlaywright)({ sdkLanguage: 'javascript', isInternalPlaywright: true }); diff --git a/packages/playwright-core/src/server/recorder/recorderFrontend.ts b/packages/playwright-core/src/server/recorder/recorderFrontend.ts new file mode 100644 index 0000000000..161aa71eca --- /dev/null +++ b/packages/playwright-core/src/server/recorder/recorderFrontend.ts @@ -0,0 +1,35 @@ +/** + * 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 { CallLog, Mode, Source } from '@recorder/recorderTypes'; +import type { EventEmitter } from 'events'; + +export interface IRecorder { + setMode(mode: Mode): void; + mode(): Mode; +} + +export interface IRecorderApp extends EventEmitter { + close(): Promise; + setPaused(paused: boolean): Promise; + setMode(mode: Mode): Promise; + setFileIfNeeded(file: string): Promise; + setSelector(selector: string, userGesture?: boolean): Promise; + updateCallLogs(callLogs: CallLog[]): Promise; + setSources(sources: Source[]): Promise; +} + +export type IRecorderAppFactory = (recorder: IRecorder) => Promise; diff --git a/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts b/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts new file mode 100644 index 0000000000..f7613ffc54 --- /dev/null +++ b/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts @@ -0,0 +1,94 @@ +/** + * 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 path from 'path'; +import type { CallLog, Mode, Source } from '@recorder/recorderTypes'; +import { EventEmitter } from 'events'; +import type { IRecorder, IRecorderApp, IRecorderAppFactory } from './recorderFrontend'; +import { installRootRedirect, openTraceViewerApp, startTraceViewerServer } from '../trace/viewer/traceViewer'; +import type { TraceViewerServerOptions } from '../trace/viewer/traceViewer'; +import type { BrowserContext } from '../browserContext'; +import { gracefullyProcessExitDoNotHang } from '../../utils/processLauncher'; +import type { Transport } from '../../utils/httpServer'; + +export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp { + private _recorder: IRecorder; + private _transport: Transport; + + static factory(context: BrowserContext): IRecorderAppFactory { + return async (recorder: IRecorder) => { + const transport = new RecorderTransport(); + const trace = path.join(context._browser.options.tracesDir, 'trace'); + await openApp(trace, { transport }); + return new RecorderInTraceViewer(context, recorder, transport); + }; + } + + constructor(context: BrowserContext, recorder: IRecorder, transport: Transport) { + super(); + this._recorder = recorder; + this._transport = transport; + } + + async close(): Promise { + this._transport.sendEvent?.('close', {}); + } + + async setPaused(paused: boolean): Promise { + this._transport.sendEvent?.('setPaused', { paused }); + } + + async setMode(mode: Mode): Promise { + this._transport.sendEvent?.('setMode', { mode }); + } + + async setFileIfNeeded(file: string): Promise { + this._transport.sendEvent?.('setFileIfNeeded', { file }); + } + + async setSelector(selector: string, userGesture?: boolean): Promise { + this._transport.sendEvent?.('setSelector', { selector, userGesture }); + } + + async updateCallLogs(callLogs: CallLog[]): Promise { + this._transport.sendEvent?.('updateCallLogs', { callLogs }); + } + + async setSources(sources: Source[]): Promise { + this._transport.sendEvent?.('setSources', { sources }); + } +} + +async function openApp(trace: string, options?: TraceViewerServerOptions & { headless?: boolean }) { + const server = await startTraceViewerServer(options); + await installRootRedirect(server, [trace], { ...options, webApp: 'recorder.html' }); + const page = await openTraceViewerApp(server.urlPrefix('precise'), 'chromium', options); + page.on('close', () => gracefullyProcessExitDoNotHang(0)); +} + +class RecorderTransport implements Transport { + constructor() { + } + + async dispatch(method: string, params: any) { + } + + onclose() { + } + + sendEvent?: (method: string, params: any) => void; + close?: () => void; +} diff --git a/packages/trace-viewer/recorder.html b/packages/trace-viewer/recorder.html new file mode 100644 index 0000000000..c33d6586e5 --- /dev/null +++ b/packages/trace-viewer/recorder.html @@ -0,0 +1,28 @@ + + + + + + + + Playwright Recorder + + +
+ + + diff --git a/packages/trace-viewer/src/recorder.tsx b/packages/trace-viewer/src/recorder.tsx new file mode 100644 index 0000000000..4de705d4fc --- /dev/null +++ b/packages/trace-viewer/src/recorder.tsx @@ -0,0 +1,41 @@ +/** + * 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 '@web/common.css'; +import { applyTheme } from '@web/theme'; +import '@web/third_party/vscode/codicon.css'; +import * as ReactDOM from 'react-dom/client'; +import { RecorderView } from './ui/recorderView'; + +(async () => { + applyTheme(); + + if (window.location.protocol !== 'file:') { + if (!navigator.serviceWorker) + throw new Error(`Service workers are not supported.\nMake sure to serve the Recorder (${window.location}) via HTTPS or localhost.`); + navigator.serviceWorker.register('sw.bundle.js'); + if (!navigator.serviceWorker.controller) { + await new Promise(f => { + navigator.serviceWorker.oncontrollerchange = () => f(); + }); + } + + // Keep SW running. + setInterval(function() { fetch('ping'); }, 10000); + } + + ReactDOM.createRoot(document.querySelector('#root')!).render(); +})(); diff --git a/packages/trace-viewer/src/ui/recorderView.css b/packages/trace-viewer/src/ui/recorderView.css new file mode 100644 index 0000000000..ad03e78e7d --- /dev/null +++ b/packages/trace-viewer/src/ui/recorderView.css @@ -0,0 +1,15 @@ +/* + 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. +*/ diff --git a/packages/trace-viewer/src/ui/recorderView.tsx b/packages/trace-viewer/src/ui/recorderView.tsx new file mode 100644 index 0000000000..940fd146a9 --- /dev/null +++ b/packages/trace-viewer/src/ui/recorderView.tsx @@ -0,0 +1,168 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import * as React from 'react'; +import './recorderView.css'; +import { MultiTraceModel } from './modelUtil'; +import type { SourceLocation } from './modelUtil'; +import { Workbench } from './workbench'; +import type { Mode, Source } from '@recorder/recorderTypes'; +import type { ContextEntry } from '../entries'; + +const searchParams = new URLSearchParams(window.location.search); +const guid = searchParams.get('ws'); +const trace = searchParams.get('trace') + '.json'; + +export const RecorderView: React.FunctionComponent = () => { + const [connection, setConnection] = React.useState(null); + const [sources, setSources] = React.useState([]); + 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 })); + 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); + + // Start polling running test. + pollTimer.current = setTimeout(async () => { + try { + const model = await loadSingleTraceFile(traceLocation); + setModel({ model, isLive: true }); + } catch { + setModel(undefined); + } finally { + setCounter(counter + 1); + } + }, 500); + return () => { + if (pollTimer.current) + clearTimeout(pollTimer.current); + }; + }, [counter, traceLocation]); + + const fallbackLocation = React.useMemo(() => { + if (!sources.length) + return undefined; + const fallbackLocation: SourceLocation = { + file: '', + line: 0, + column: 0, + source: { + errors: [], + content: sources[0].text + } + }; + return fallbackLocation; + }, [sources]); + + return ; +}; + +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); +} + +class Connection { + private _lastId = 0; + private _webSocket: WebSocket; + private _callbacks = new Map void, reject: (arg: Error) => void }>(); + private _options: { setSources: (sources: Source[]) => void; }; + + constructor(webSocket: WebSocket, options: { setSources: (sources: Source[]) => void }) { + this._webSocket = webSocket; + this._callbacks = new Map(); + this._options = options; + + this._webSocket.addEventListener('message', event => { + const message = JSON.parse(event.data); + const { id, result, error, method, params } = message; + if (id) { + const callback = this._callbacks.get(id); + if (!callback) + return; + this._callbacks.delete(id); + if (error) + callback.reject(new Error(error)); + else + callback.resolve(result); + } else { + this._dispatchEvent(method, params); + } + }); + } + + setMode(mode: Mode) { + this._sendMessageNoReply('setMode', { mode }); + } + + private async _sendMessage(method: string, params?: any): Promise { + const id = ++this._lastId; + const message = { id, method, params }; + this._webSocket.send(JSON.stringify(message)); + return new Promise((resolve, reject) => { + this._callbacks.set(id, { resolve, reject }); + }); + } + + private _sendMessageNoReply(method: string, params?: any) { + this._sendMessage(method, params).catch(() => { }); + } + + private _dispatchEvent(method: string, params?: any) { + if (method === 'setSources') { + const { sources } = params as { sources: Source[] }; + this._options.setSources(sources); + } + } +} diff --git a/packages/trace-viewer/src/ui/sourceTab.tsx b/packages/trace-viewer/src/ui/sourceTab.tsx index cf704a8438..ce54b34d53 100644 --- a/packages/trace-viewer/src/ui/sourceTab.tsx +++ b/packages/trace-viewer/src/ui/sourceTab.tsx @@ -55,7 +55,7 @@ export const SourceTab: React.FunctionComponent<{ let source = sources.get(file); // Fallback location can fall outside the sources model. if (!source) { - source = { errors: fallbackLocation?.source?.errors || [], content: undefined }; + source = { errors: fallbackLocation?.source?.errors || [], content: fallbackLocation?.source?.content }; sources.set(file, source); } @@ -66,7 +66,9 @@ export const SourceTab: React.FunctionComponent<{ highlight.push({ line: targetLine, type: 'running' }); // After the source update, but before the test run, don't trust the cache. - if (source.content === undefined || shouldUseFallback) { + if (fallbackLocation?.source?.content !== undefined) { + source.content = fallbackLocation.source.content; + } else if (source.content === undefined || shouldUseFallback) { const sha1 = await calculateSha1(file); try { let response = await fetch(`sha1/src@${sha1}.txt`); diff --git a/packages/trace-viewer/vite.config.ts b/packages/trace-viewer/vite.config.ts index 13310ca0f7..0e2e9cb642 100644 --- a/packages/trace-viewer/vite.config.ts +++ b/packages/trace-viewer/vite.config.ts @@ -45,6 +45,7 @@ export default defineConfig({ index: path.resolve(__dirname, 'index.html'), uiMode: path.resolve(__dirname, 'uiMode.html'), embedded: path.resolve(__dirname, 'embedded.html'), + recorder: path.resolve(__dirname, 'recorder.html'), snapshot: path.resolve(__dirname, 'snapshot.html'), }, output: { From cd4dabef8bd0c80cf91c93fd170be1aaf87cc7a9 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 12 Sep 2024 14:38:23 -0700 Subject: [PATCH 14/41] chore: remove stray codegen signal handling (#32599) --- .../src/server/recorder/recorderCollection.ts | 61 ++++++------------- 1 file changed, 18 insertions(+), 43 deletions(-) diff --git a/packages/playwright-core/src/server/recorder/recorderCollection.ts b/packages/playwright-core/src/server/recorder/recorderCollection.ts index ab44410e2f..fbfbf8f26e 100644 --- a/packages/playwright-core/src/server/recorder/recorderCollection.ts +++ b/packages/playwright-core/src/server/recorder/recorderCollection.ts @@ -25,7 +25,6 @@ import { monotonicTime } from '../../utils/time'; import { mainFrameForAction, traceParamsForAction } from './recorderUtils'; export class RecorderCollection extends EventEmitter { - private _currentAction: ActionInContext | null = null; private _lastAction: ActionInContext | null = null; private _actions: ActionInContext[] = []; private _enabled: boolean; @@ -39,7 +38,6 @@ export class RecorderCollection extends EventEmitter { } restart() { - this._currentAction = null; this._lastAction = null; this._actions = []; this.emit('change'); @@ -56,8 +54,14 @@ export class RecorderCollection extends EventEmitter { async willPerformAction(actionInContext: ActionInContext): Promise { if (!this._enabled) return null; - const mainFrame = mainFrameForAction(this._pageAliases, actionInContext); + const { callMetadata, mainFrame } = this._callMetadataForAction(actionInContext); + await mainFrame.instrumentation.onBeforeCall(mainFrame, callMetadata); + this._lastAction = actionInContext; + return callMetadata; + } + private _callMetadataForAction(actionInContext: ActionInContext): { callMetadata: CallMetadata, mainFrame: Frame } { + const mainFrame = mainFrameForAction(this._pageAliases, actionInContext); const { action } = actionInContext; const callMetadata: CallMetadata = { id: `call@${createGuid()}`, @@ -72,25 +76,16 @@ export class RecorderCollection extends EventEmitter { params: traceParamsForAction(actionInContext), log: [], }; - await mainFrame.instrumentation.onBeforeCall(mainFrame, callMetadata); - this._currentAction = actionInContext; - return callMetadata; + return { callMetadata, mainFrame }; } async didPerformAction(callMetadata: CallMetadata, actionInContext: ActionInContext, error?: Error) { if (!this._enabled) return; - if (error) { - // Do not clear current action on delayed error. - if (this._currentAction === actionInContext) - this._currentAction = null; - } else { - this._currentAction = null; + if (!error) this._actions.push(actionInContext); - } - this._lastAction = actionInContext; const mainFrame = mainFrameForAction(this._pageAliases, actionInContext); callMetadata.endTime = monotonicTime(); await mainFrame.instrumentation.onAfterCall(mainFrame, callMetadata); @@ -101,27 +96,18 @@ export class RecorderCollection extends EventEmitter { addRecordedAction(actionInContext: ActionInContext) { if (!this._enabled) return; - this._currentAction = null; const action = actionInContext.action; - let eraseLastAction = false; - if (this._lastAction && this._lastAction.frame.pageAlias === actionInContext.frame.pageAlias) { - const lastAction = this._lastAction.action; - // We augment last action based on the type. - if (this._lastAction && action.name === 'fill' && lastAction.name === 'fill') { - if (action.selector === lastAction.selector) - eraseLastAction = true; - } - if (lastAction && action.name === 'navigate' && lastAction.name === 'navigate') { - if (action.url === lastAction.url) { - // Already at a target URL. - return; - } - } + + const lastAction = this._lastAction && this._lastAction.frame.pageAlias === actionInContext.frame.pageAlias ? this._lastAction.action : undefined; + if (lastAction && action.name === 'navigate' && lastAction.name === 'navigate' && action.url === lastAction.url) { + // Already at a target URL. + return; } - this._lastAction = actionInContext; - if (eraseLastAction) + if (lastAction && action.name === 'fill' && lastAction.name === 'fill' && action.selector === lastAction.selector) this._actions.pop(); + + this._lastAction = actionInContext; this._actions.push(actionInContext); this.emit('change'); } @@ -138,18 +124,7 @@ export class RecorderCollection extends EventEmitter { if (!this._enabled) return; - // Signal either arrives while action is being performed or shortly after. - if (this._currentAction) { - this._currentAction.action.signals.push(signal); - return; - } - - if (this._lastAction && (!this._lastAction.committed || signal.name !== 'navigation')) { - const signals = this._lastAction.action.signals; - if (signal.name === 'navigation' && signals.length && signals[signals.length - 1].name === 'download') - return; - if (signal.name === 'download' && signals.length && signals[signals.length - 1].name === 'navigation') - signals.length = signals.length - 1; + if (this._lastAction && !this._lastAction.committed) { this._lastAction.action.signals.push(signal); this.emit('change'); return; From 48c7fb6b0624a771bfcc7a8536ec4d18d780f455 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Fri, 13 Sep 2024 13:21:02 +0200 Subject: [PATCH 15/41] feat(library): accept `FormData` in `fetch` (#32602) Closes https://github.com/microsoft/playwright/issues/26520 by accepting `FormData`, which became stable in Node.js in v21. --- docs/src/api/class-apirequestcontext.md | 35 ++++++++++++++++---- docs/src/api/params.md | 12 +++++-- packages/playwright-core/src/client/fetch.ts | 15 +++++++-- packages/playwright-core/types/types.d.ts | 14 ++++---- tests/library/browsercontext-fetch.spec.ts | 16 +++++++++ 5 files changed, 73 insertions(+), 19 deletions(-) diff --git a/docs/src/api/class-apirequestcontext.md b/docs/src/api/class-apirequestcontext.md index 1d3e728235..f56087d1bd 100644 --- a/docs/src/api/class-apirequestcontext.md +++ b/docs/src/api/class-apirequestcontext.md @@ -159,7 +159,10 @@ context cookies from the response. The method will automatically follow redirect ### option: APIRequestContext.delete.data = %%-js-python-csharp-fetch-option-data-%% * since: v1.17 -### option: APIRequestContext.delete.form = %%-js-python-fetch-option-form-%% +### option: APIRequestContext.delete.form = %%-js-fetch-option-form-%% +* since: v1.17 + +### option: APIRequestContext.delete.form = %%-python-fetch-option-form-%% * since: v1.17 ### option: APIRequestContext.delete.form = %%-csharp-fetch-option-form-%% @@ -332,7 +335,10 @@ If set changes the fetch method (e.g. [PUT](https://developer.mozilla.org/en-US/ ### option: APIRequestContext.fetch.data = %%-js-python-csharp-fetch-option-data-%% * since: v1.16 -### option: APIRequestContext.fetch.form = %%-js-python-fetch-option-form-%% +### option: APIRequestContext.fetch.form = %%-js-fetch-option-form-%% +* since: v1.16 + +### option: APIRequestContext.fetch.form = %%-python-fetch-option-form-%% * since: v1.16 ### option: APIRequestContext.fetch.form = %%-csharp-fetch-option-form-%% @@ -442,7 +448,10 @@ await request.GetAsync("https://example.com/api/getText", new() { Params = query ### option: APIRequestContext.get.data = %%-js-python-csharp-fetch-option-data-%% * since: v1.26 -### option: APIRequestContext.get.form = %%-js-python-fetch-option-form-%% +### option: APIRequestContext.get.form = %%-js-fetch-option-form-%% +* since: v1.26 + +### option: APIRequestContext.get.form = %%-python-fetch-option-form-%% * since: v1.26 ### option: APIRequestContext.get.form = %%-csharp-fetch-option-form-%% @@ -504,7 +513,10 @@ context cookies from the response. The method will automatically follow redirect ### option: APIRequestContext.head.data = %%-js-python-csharp-fetch-option-data-%% * since: v1.26 -### option: APIRequestContext.head.form = %%-js-python-fetch-option-form-%% +### option: APIRequestContext.head.form = %%-python-fetch-option-form-%% +* since: v1.26 + +### option: APIRequestContext.head.form = %%-js-fetch-option-form-%% * since: v1.26 ### option: APIRequestContext.head.form = %%-csharp-fetch-option-form-%% @@ -566,7 +578,10 @@ context cookies from the response. The method will automatically follow redirect ### option: APIRequestContext.patch.data = %%-js-python-csharp-fetch-option-data-%% * since: v1.16 -### option: APIRequestContext.patch.form = %%-js-python-fetch-option-form-%% +### option: APIRequestContext.patch.form = %%-js-fetch-option-form-%% +* since: v1.16 + +### option: APIRequestContext.patch.form = %%-python-fetch-option-form-%% * since: v1.16 ### option: APIRequestContext.patch.form = %%-csharp-fetch-option-form-%% @@ -749,7 +764,10 @@ await request.PostAsync("https://example.com/api/uploadScript", new() { Multipar ### option: APIRequestContext.post.data = %%-js-python-csharp-fetch-option-data-%% * since: v1.16 -### option: APIRequestContext.post.form = %%-js-python-fetch-option-form-%% +### option: APIRequestContext.post.form = %%-js-fetch-option-form-%% +* since: v1.16 + +### option: APIRequestContext.post.form = %%-python-fetch-option-form-%% * since: v1.16 ### option: APIRequestContext.post.form = %%-csharp-fetch-option-form-%% @@ -811,7 +829,10 @@ context cookies from the response. The method will automatically follow redirect ### option: APIRequestContext.put.data = %%-js-python-csharp-fetch-option-data-%% * since: v1.16 -### option: APIRequestContext.put.form = %%-js-python-fetch-option-form-%% +### option: APIRequestContext.put.form = %%-python-fetch-option-form-%% +* since: v1.16 + +### option: APIRequestContext.put.form = %%-js-fetch-option-form-%% * since: v1.16 ### option: APIRequestContext.put.form = %%-csharp-fetch-option-form-%% diff --git a/docs/src/api/params.md b/docs/src/api/params.md index cbec1a5e25..608855ab2a 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -405,8 +405,16 @@ Request timeout in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to d Whether to throw on response codes other than 2xx and 3xx. By default response object is returned for all status codes. -## js-python-fetch-option-form -* langs: js, python +## js-fetch-option-form +* langs: js +- `form` <[Object]<[string], [string]|[float]|[boolean]>|[FormData]> + +Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as +this request body. If this parameter is specified `content-type` header will be set to `application/x-www-form-urlencoded` +unless explicitly provided. + +## python-fetch-option-form +* langs: python - `form` <[Object]<[string], [string]|[float]|[boolean]>> Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as diff --git a/packages/playwright-core/src/client/fetch.ts b/packages/playwright-core/src/client/fetch.ts index 87c31579b5..58928532ac 100644 --- a/packages/playwright-core/src/client/fetch.ts +++ b/packages/playwright-core/src/client/fetch.ts @@ -36,8 +36,8 @@ export type FetchOptions = { method?: string, headers?: Headers, data?: string | Buffer | Serializable, - form?: { [key: string]: string|number|boolean; }; - multipart?: { [key: string]: string|number|boolean|fs.ReadStream|FilePayload; }; + form?: { [key: string]: string|number|boolean; } | FormData; + multipart?: { [key: string]: string|number|boolean|fs.ReadStream|FilePayload; } | FormData; timeout?: number, failOnStatusCode?: boolean, ignoreHTTPSErrors?: boolean, @@ -202,7 +202,16 @@ export class APIRequestContext extends ChannelOwner Date: Fri, 13 Sep 2024 06:13:11 -0700 Subject: [PATCH 16/41] feat(webkit): roll to r2075 (#32610) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 23974d6523..cf83b5413f 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -27,7 +27,7 @@ }, { "name": "webkit", - "revision": "2073", + "revision": "2075", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", From 9e99c86f003d4d8a701540489ba61d44ea8569ee Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 13 Sep 2024 16:13:23 +0200 Subject: [PATCH 17/41] chore: unhide merge-reports command (#32605) --- packages/playwright/src/program.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index 1bf2fb42b2..dd8d181676 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -133,7 +133,7 @@ Examples: } function addMergeReportsCommand(program: Command) { - const command = program.command('merge-reports [dir]', { hidden: true }); + const command = program.command('merge-reports [dir]'); command.description('merge multiple blob reports (for sharded tests) into a single report'); command.action(async (dir, options) => { try { From 9bb1c86f93a2b369796edf1ce5ccf8f091f9697f Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Fri, 13 Sep 2024 17:24:38 +0200 Subject: [PATCH 18/41] feat(test runner): don't run tests on --watch start (#32583) Closes https://github.com/microsoft/playwright/issues/32580. --- .../src/isomorphic/testServerInterface.ts | 1 + packages/playwright/src/runner/testServer.ts | 4 +- packages/playwright/src/runner/watchMode.ts | 11 +-- tests/playwright-test/watch.spec.ts | 76 +++++++++---------- 4 files changed, 44 insertions(+), 48 deletions(-) diff --git a/packages/playwright/src/isomorphic/testServerInterface.ts b/packages/playwright/src/isomorphic/testServerInterface.ts index 28f82688dc..22cb9e35ef 100644 --- a/packages/playwright/src/isomorphic/testServerInterface.ts +++ b/packages/playwright/src/isomorphic/testServerInterface.ts @@ -28,6 +28,7 @@ export interface TestServerInterface { closeOnDisconnect?: boolean, interceptStdio?: boolean, watchTestDirs?: boolean, + populateDependenciesOnList?: boolean, }): Promise; ping(params: {}): Promise; diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index f34a7314f1..5d67385dc5 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -79,6 +79,7 @@ export class TestServerDispatcher implements TestServerInterface { private _serializer = require.resolve('./uiModeReporter'); private _watchTestDirs = false; private _closeOnDisconnect = false; + private _populateDependenciesOnList = false; constructor(configLocation: ConfigLocation) { this._configLocation = configLocation; @@ -113,6 +114,7 @@ export class TestServerDispatcher implements TestServerInterface { this._closeOnDisconnect = !!params.closeOnDisconnect; await this._setInterceptStdio(!!params.interceptStdio); this._watchTestDirs = !!params.watchTestDirs; + this._populateDependenciesOnList = !!params.populateDependenciesOnList; } async ping() {} @@ -252,7 +254,7 @@ export class TestServerDispatcher implements TestServerInterface { config.cliListOnly = true; const status = await runTasks(new TestRun(config, reporter), [ - createLoadTask('out-of-process', { failOnLoadErrors: false, filterOnly: false }), + createLoadTask('out-of-process', { failOnLoadErrors: false, filterOnly: false, populateDependencies: this._populateDependenciesOnList }), createReportBeginTask(), ]); return { config, report, reporter, status }; diff --git a/packages/playwright/src/runner/watchMode.ts b/packages/playwright/src/runner/watchMode.ts index ba2c5a34e7..603f066601 100644 --- a/packages/playwright/src/runner/watchMode.ts +++ b/packages/playwright/src/runner/watchMode.ts @@ -122,7 +122,7 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp }); testServerConnection.onReport(report => teleSuiteUpdater.processTestReportEvent(report)); - await testServerConnection.initialize({ interceptStdio: false, watchTestDirs: true }); + await testServerConnection.initialize({ interceptStdio: false, watchTestDirs: true, populateDependenciesOnList: true }); await testServerConnection.runGlobalSetup({}); const { report } = await testServerConnection.listTests({}); @@ -133,9 +133,6 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp let lastRun: { type: 'changed' | 'regular' | 'failed', failedTestIds?: string[], dirtyTestIds?: string[] } = { type: 'regular' }; let result: FullResult['status'] = 'passed'; - // Enter the watch loop. - await runTests(options, testServerConnection); - while (true) { printPrompt(); const readCommandPromise = readCommand(); @@ -330,7 +327,7 @@ Change settings let showBrowserServer: PlaywrightServer | undefined; let connectWsEndpoint: string | undefined = undefined; -let seq = 0; +let seq = 1; function printConfiguration(options: WatchModeOptions, title?: string) { const packageManagerCommand = getPackageManagerExecCommand(); @@ -344,9 +341,7 @@ function printConfiguration(options: WatchModeOptions, title?: string) { tokens.push(...options.files.map(a => colors.bold(a))); if (title) tokens.push(colors.dim(`(${title})`)); - if (seq) - tokens.push(colors.dim(`#${seq}`)); - ++seq; + tokens.push(colors.dim(`#${seq++}`)); const lines: string[] = []; const sep = separator(); lines.push('\x1Bc' + sep); diff --git a/tests/playwright-test/watch.spec.ts b/tests/playwright-test/watch.spec.ts index f4d1fd72ce..ec05e5bb68 100644 --- a/tests/playwright-test/watch.spec.ts +++ b/tests/playwright-test/watch.spec.ts @@ -174,15 +174,16 @@ test('should print dependencies in mixed CJS/ESM mode 2', async ({ runInlineTest }); }); -test('should perform initial run', async ({ runWatchTest }) => { +test('should not perform initial run', async ({ runWatchTest }) => { const testProcess = await runWatchTest({ 'a.test.ts': ` import { test, expect } from '@playwright/test'; test('passes', () => {}); `, }); - await testProcess.waitForOutput('a.test.ts:3:11 › passes'); await testProcess.waitForOutput('Waiting for file changes.'); + + expect(testProcess.output).not.toContain('a.test.ts'); }); test('should quit on Q', async ({ runWatchTest }) => { @@ -206,7 +207,6 @@ test('should run tests on Enter', async ({ runWatchTest }) => { test('passes', () => {}); `, }); - await testProcess.waitForOutput('a.test.ts:3:11 › passes'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); testProcess.write('\r\n'); @@ -222,7 +222,6 @@ test('should run tests on R', async ({ runWatchTest }) => { test('passes', () => {}); `, }); - await testProcess.waitForOutput('a.test.ts:3:11 › passes'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); testProcess.write('r'); @@ -246,6 +245,10 @@ test('should run failed tests on F', async ({ runWatchTest }) => { test('fails', () => { expect(1).toBe(2); }); `, }); + await testProcess.waitForOutput('Waiting for file changes.'); + testProcess.write('\r\n'); + + await testProcess.waitForOutput('npx playwright test #1'); await testProcess.waitForOutput('a.test.ts:3:11 › passes'); await testProcess.waitForOutput('b.test.ts:3:11 › passes'); await testProcess.waitForOutput('c.test.ts:3:11 › fails'); @@ -253,7 +256,7 @@ test('should run failed tests on F', async ({ runWatchTest }) => { await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); testProcess.write('f'); - await testProcess.waitForOutput('npx playwright test (running failed tests) #1'); + await testProcess.waitForOutput('npx playwright test (running failed tests) #2'); await testProcess.waitForOutput('c.test.ts:3:11 › fails'); expect(testProcess.output).not.toContain('a.test.ts:3:11'); }); @@ -269,8 +272,6 @@ test('should respect file filter P', async ({ runWatchTest }) => { test('passes', () => {}); `, }); - await testProcess.waitForOutput('a.test.ts:3:11 › passes'); - await testProcess.waitForOutput('b.test.ts:3:11 › passes'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); testProcess.write('p'); @@ -294,6 +295,11 @@ test('should respect project filter C', async ({ runWatchTest, writeFiles }) => `, }; const testProcess = await runWatchTest(files, { project: 'foo' }); + await testProcess.waitForOutput('Waiting for file changes.'); + testProcess.clearOutput(); + testProcess.write('\r\n'); + + await testProcess.waitForOutput('npx playwright test --project foo #1'); await testProcess.waitForOutput('[foo] › a.test.ts:3:11 › passes'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); @@ -303,7 +309,7 @@ test('should respect project filter C', async ({ runWatchTest, writeFiles }) => await testProcess.waitForOutput('bar'); testProcess.write(' '); testProcess.write('\r\n'); - await testProcess.waitForOutput('npx playwright test --project foo #1'); + await testProcess.waitForOutput('npx playwright test --project foo #2'); await testProcess.waitForOutput('[foo] › a.test.ts:3:11 › passes'); expect(testProcess.output).not.toContain('[bar] › a.test.ts:3:11 › passes'); @@ -329,8 +335,6 @@ test('should respect file filter P and split files', async ({ runWatchTest }) => test('passes', () => {}); `, }); - await testProcess.waitForOutput('a.test.ts:3:11 › passes'); - await testProcess.waitForOutput('b.test.ts:3:11 › passes'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); testProcess.write('p'); @@ -353,8 +357,6 @@ test('should respect title filter T', async ({ runWatchTest }) => { test('title 2', () => {}); `, }); - await testProcess.waitForOutput('a.test.ts:3:11 › title 1'); - await testProcess.waitForOutput('b.test.ts:3:11 › title 2'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); testProcess.write('t'); @@ -381,6 +383,11 @@ test('should re-run failed tests on F > R', async ({ runWatchTest }) => { test('fails', () => { expect(1).toBe(2); }); `, }); + await testProcess.waitForOutput('Waiting for file changes.'); + testProcess.clearOutput(); + testProcess.write('\r\n'); + + await testProcess.waitForOutput('npx playwright test #1'); await testProcess.waitForOutput('a.test.ts:3:11 › passes'); await testProcess.waitForOutput('b.test.ts:3:11 › passes'); await testProcess.waitForOutput('c.test.ts:3:11 › fails'); @@ -388,12 +395,12 @@ test('should re-run failed tests on F > R', async ({ runWatchTest }) => { await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); testProcess.write('f'); - await testProcess.waitForOutput('npx playwright test (running failed tests) #1'); + await testProcess.waitForOutput('npx playwright test (running failed tests) #2'); await testProcess.waitForOutput('c.test.ts:3:11 › fails'); expect(testProcess.output).not.toContain('a.test.ts:3:11'); testProcess.clearOutput(); testProcess.write('r'); - await testProcess.waitForOutput('npx playwright test (re-running tests) #2'); + await testProcess.waitForOutput('npx playwright test (re-running tests) #3'); await testProcess.waitForOutput('c.test.ts:3:11 › fails'); expect(testProcess.output).not.toContain('a.test.ts:3:11'); }); @@ -413,10 +420,6 @@ test('should run on changed files', async ({ runWatchTest, writeFiles }) => { test('fails', () => { expect(1).toBe(2); }); `, }); - await testProcess.waitForOutput('a.test.ts:3:11 › passes'); - await testProcess.waitForOutput('b.test.ts:3:11 › passes'); - await testProcess.waitForOutput('c.test.ts:3:11 › fails'); - await testProcess.waitForOutput('Error: expect(received).toBe(expected)'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); await writeFiles({ @@ -457,9 +460,6 @@ test('should run on changed deps', async ({ runWatchTest, writeFiles }) => { console.log('old helper'); `, }); - await testProcess.waitForOutput('a.test.ts:3:11 › passes'); - await testProcess.waitForOutput('b.test.ts:4:11 › passes'); - await testProcess.waitForOutput('old helper'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); await writeFiles({ @@ -490,9 +490,6 @@ test('should run on changed deps in ESM', async ({ runWatchTest, writeFiles }) = console.log('old helper'); `, }); - await testProcess.waitForOutput('a.test.ts:3:7 › passes'); - await testProcess.waitForOutput('b.test.ts:4:7 › passes'); - await testProcess.waitForOutput('old helper'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); await writeFiles({ @@ -521,10 +518,6 @@ test('should re-run changed files on R', async ({ runWatchTest, writeFiles }) => test('fails', () => { expect(1).toBe(2); }); `, }); - await testProcess.waitForOutput('a.test.ts:3:11 › passes'); - await testProcess.waitForOutput('b.test.ts:3:11 › passes'); - await testProcess.waitForOutput('c.test.ts:3:11 › fails'); - await testProcess.waitForOutput('Error: expect(received).toBe(expected)'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); await writeFiles({ @@ -556,8 +549,6 @@ test('should not trigger on changes to non-tests', async ({ runWatchTest, writeF test('passes', () => {}); `, }); - await testProcess.waitForOutput('a.test.ts:3:11 › passes'); - await testProcess.waitForOutput('b.test.ts:3:11 › passes'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); @@ -582,6 +573,10 @@ test('should only watch selected projects', async ({ runWatchTest, writeFiles }) test('passes', () => {}); `, }, undefined, undefined, { additionalArgs: ['--project=foo'] }); + await testProcess.waitForOutput('Waiting for file changes.'); + testProcess.clearOutput(); + testProcess.write('\r\n'); + await testProcess.waitForOutput('npx playwright test --project foo'); await testProcess.waitForOutput('[foo] › a.test.ts:3:11 › passes'); expect(testProcess.output).not.toContain('[bar]'); @@ -612,6 +607,10 @@ test('should watch filtered files', async ({ runWatchTest, writeFiles }) => { test('passes', () => {}); `, }, undefined, undefined, { additionalArgs: ['a.test.ts'] }); + await testProcess.waitForOutput('Waiting for file changes.'); + testProcess.clearOutput(); + testProcess.write('\r\n'); + await testProcess.waitForOutput('npx playwright test a.test.ts'); await testProcess.waitForOutput('a.test.ts:3:11 › passes'); expect(testProcess.output).not.toContain('b.test'); @@ -640,6 +639,10 @@ test('should not watch unfiltered files', async ({ runWatchTest, writeFiles }) = test('passes', () => {}); `, }, undefined, undefined, { additionalArgs: ['a.test.ts'] }); + await testProcess.waitForOutput('Waiting for file changes.'); + testProcess.clearOutput(); + testProcess.write('\r\n'); + await testProcess.waitForOutput('npx playwright test a.test.ts'); await testProcess.waitForOutput('a.test.ts:3:11 › passes'); expect(testProcess.output).not.toContain('b.test'); @@ -684,10 +687,7 @@ test('should run CT on changed deps', async ({ runWatchTest, writeFiles }) => { }); `, }); - await testProcess.waitForOutput('button.spec.tsx:4:11 › pass'); - await testProcess.waitForOutput('link.spec.tsx:3:11 › pass'); await testProcess.waitForOutput('Waiting for file changes.'); - testProcess.clearOutput(); await writeFiles({ 'src/button.tsx': ` export const Button = () => ; @@ -732,10 +732,7 @@ test('should run CT on indirect deps change', async ({ runWatchTest, writeFiles }); `, }); - await testProcess.waitForOutput('button.spec.tsx:4:11 › pass'); - await testProcess.waitForOutput('link.spec.tsx:3:11 › pass'); await testProcess.waitForOutput('Waiting for file changes.'); - testProcess.clearOutput(); await writeFiles({ 'src/button.css': ` button { color: blue; } @@ -776,10 +773,7 @@ test('should run CT on indirect deps change ESM mode', async ({ runWatchTest, wr }); `, }); - await testProcess.waitForOutput('button.spec.tsx:4:7 › pass'); - await testProcess.waitForOutput('link.spec.tsx:3:7 › pass'); await testProcess.waitForOutput('Waiting for file changes.'); - testProcess.clearOutput(); await writeFiles({ 'src/button.css': ` button { color: blue; } @@ -809,6 +803,10 @@ test('should run global teardown before exiting', async ({ runWatchTest }) => { }); `, }); + await testProcess.waitForOutput('Waiting for file changes.'); + testProcess.clearOutput(); + testProcess.write('\r\n'); + await testProcess.waitForOutput('a.test.ts:3:11 › passes'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.write('\x1B'); From 79cba7d70455132f0069a8cf1ef099f356b2eb41 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 13 Sep 2024 17:34:34 +0200 Subject: [PATCH 19/41] chore: introduce option overrides on context/browser (#32606) --- .../src/server/android/android.ts | 3 ++- .../src/server/bidi/bidiBrowser.ts | 4 ++-- .../src/server/bidi/bidiChromium.ts | 3 ++- .../playwright-core/src/server/browser.ts | 10 ++++++--- .../src/server/browserContext.ts | 15 ++++++------- .../playwright-core/src/server/browserType.ts | 21 +++++++++---------- .../src/server/chromium/chromium.ts | 6 +++--- .../src/server/chromium/crBrowser.ts | 13 ++++++------ .../src/server/chromium/crPage.ts | 4 ++-- .../src/server/electron/electron.ts | 3 ++- packages/playwright-core/src/server/fetch.ts | 2 +- .../src/server/firefox/ffBrowser.ts | 16 +++++++------- .../socksClientCertificatesInterceptor.ts | 16 +++++++------- packages/playwright-core/src/server/types.ts | 10 ++++++++- .../src/server/webkit/webkit.ts | 3 ++- .../src/server/webkit/wkBrowser.ts | 15 ++++++------- tests/library/client-certificates.spec.ts | 1 + 17 files changed, 81 insertions(+), 64 deletions(-) diff --git a/packages/playwright-core/src/server/android/android.ts b/packages/playwright-core/src/server/android/android.ts index 0b4cb331b0..1af083916c 100644 --- a/packages/playwright-core/src/server/android/android.ts +++ b/packages/playwright-core/src/server/android/android.ts @@ -29,6 +29,7 @@ import { validateBrowserContextOptions } from '../browserContext'; import { ProgressController } from '../progress'; import { CRBrowser } from '../chromium/crBrowser'; import { helper } from '../helper'; +import type * as types from '../types'; import { PipeTransport } from '../../protocol/transport'; import { RecentLogsCollector } from '../../utils/debugLogger'; import { gracefullyCloseSet } from '../../utils/processLauncher'; @@ -309,7 +310,7 @@ export class AndroidDevice extends SdkObject { return await this._connectToBrowser(socketName); } - private async _connectToBrowser(socketName: string, options: channels.BrowserNewContextParams = {}): Promise { + private async _connectToBrowser(socketName: string, options: types.BrowserContextOptions = {}): Promise { const socket = await this._waitForLocalAbstract(socketName); const androidBrowser = new AndroidBrowser(this, socket); await androidBrowser._init(); diff --git a/packages/playwright-core/src/server/bidi/bidiBrowser.ts b/packages/playwright-core/src/server/bidi/bidiBrowser.ts index 0c658a82b4..cc98e2ff3a 100644 --- a/packages/playwright-core/src/server/bidi/bidiBrowser.ts +++ b/packages/playwright-core/src/server/bidi/bidiBrowser.ts @@ -111,7 +111,7 @@ export class BidiBrowser extends Browser { this._didClose(); } - async doCreateNewContext(options: channels.BrowserNewContextParams): Promise { + async doCreateNewContext(options: types.BrowserContextOptions): Promise { const { userContext } = await this._browserSession.send('browser.createUserContext', {}); const context = new BidiBrowserContext(this, userContext, options); await context._initialize(); @@ -190,7 +190,7 @@ export class BidiBrowser extends Browser { export class BidiBrowserContext extends BrowserContext { declare readonly _browser: BidiBrowser; - constructor(browser: BidiBrowser, browserContextId: string | undefined, options: channels.BrowserNewContextParams) { + constructor(browser: BidiBrowser, browserContextId: string | undefined, options: types.BrowserContextOptions) { super(browser, options, browserContextId); this._authenticateProxyViaHeader(); } diff --git a/packages/playwright-core/src/server/bidi/bidiChromium.ts b/packages/playwright-core/src/server/bidi/bidiChromium.ts index e94dabf072..32751bd51a 100644 --- a/packages/playwright-core/src/server/bidi/bidiChromium.ts +++ b/packages/playwright-core/src/server/bidi/bidiChromium.ts @@ -91,7 +91,7 @@ export class BidiChromium extends BrowserType { } private _innerDefaultArgs(options: types.LaunchOptions): string[] { - const { args = [], proxy } = options; + const { args = [] } = options; const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir')); if (userDataDirArg) throw this._createUserDataDirArgMisuseError('--user-data-dir'); @@ -125,6 +125,7 @@ export class BidiChromium extends BrowserType { } if (options.chromiumSandbox !== true) chromeArguments.push('--no-sandbox'); + const proxy = options.proxyOverride || options.proxy; if (proxy) { const proxyURL = new URL(proxy.server); const isSocks = proxyURL.protocol === 'socks5:'; diff --git a/packages/playwright-core/src/server/browser.ts b/packages/playwright-core/src/server/browser.ts index 663b9be377..04a23e7eac 100644 --- a/packages/playwright-core/src/server/browser.ts +++ b/packages/playwright-core/src/server/browser.ts @@ -41,7 +41,7 @@ export type BrowserOptions = { downloadsPath: string, tracesDir: string, headful?: boolean, - persistent?: channels.BrowserNewContextParams, // Undefined means no persistent context. + persistent?: types.BrowserContextOptions, // Undefined means no persistent context. browserProcess: BrowserProcess, customExecutablePath?: string; proxy?: ProxySettings, @@ -74,15 +74,19 @@ export abstract class Browser extends SdkObject { this.instrumentation.onBrowserOpen(this); } - abstract doCreateNewContext(options: channels.BrowserNewContextParams): Promise; + abstract doCreateNewContext(options: types.BrowserContextOptions): Promise; abstract contexts(): BrowserContext[]; abstract isConnected(): boolean; abstract version(): string; abstract userAgent(): string; - async newContext(metadata: CallMetadata, options: channels.BrowserNewContextParams): Promise { + async newContext(metadata: CallMetadata, options: types.BrowserContextOptions): Promise { validateBrowserContextOptions(options, this.options); const clientCertificatesProxy = await createClientCertificatesProxyIfNeeded(options, this.options); + if (clientCertificatesProxy) { + options.proxyOverride = await clientCertificatesProxy.listen(); + options.internalIgnoreHTTPSErrors = true; + } let context; try { context = await this.doCreateNewContext(options); diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 8ddbe68f89..e1166f3e97 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -68,7 +68,7 @@ export abstract class BrowserContext extends SdkObject { readonly _timeoutSettings = new TimeoutSettings(); readonly _pageBindings = new Map(); readonly _activeProgressControllers = new Set(); - readonly _options: channels.BrowserNewContextParams; + readonly _options: types.BrowserContextOptions; _requestInterceptor?: network.RouteHandler; private _isPersistentContext: boolean; private _closedStatus: 'open' | 'closing' | 'closed' = 'open'; @@ -93,7 +93,7 @@ export abstract class BrowserContext extends SdkObject { readonly clock: Clock; _clientCertificatesProxy: ClientCertificatesProxy | undefined; - constructor(browser: Browser, options: channels.BrowserNewContextParams, browserContextId: string | undefined) { + constructor(browser: Browser, options: types.BrowserContextOptions, browserContextId: string | undefined) { super(browser, 'browser-context'); this.attribution.context = this; this._browser = browser; @@ -659,19 +659,16 @@ export function assertBrowserContextIsNotOwned(context: BrowserContext) { } } -export async function createClientCertificatesProxyIfNeeded(options: channels.BrowserNewContextOptions, browserOptions?: BrowserOptions) { +export async function createClientCertificatesProxyIfNeeded(options: types.BrowserContextOptions, browserOptions?: BrowserOptions) { if (!options.clientCertificates?.length) return; if ((options.proxy?.server && options.proxy?.server !== 'per-context') || (browserOptions?.proxy?.server && browserOptions?.proxy?.server !== 'http://per-context')) throw new Error('Cannot specify both proxy and clientCertificates'); verifyClientCertificates(options.clientCertificates); - const clientCertificatesProxy = new ClientCertificatesProxy(options); - options.proxy = { server: await clientCertificatesProxy.listen() }; - options.ignoreHTTPSErrors = true; - return clientCertificatesProxy; + return new ClientCertificatesProxy(options); } -export function validateBrowserContextOptions(options: channels.BrowserNewContextParams, browserOptions: BrowserOptions) { +export function validateBrowserContextOptions(options: types.BrowserContextOptions, browserOptions: BrowserOptions) { if (options.noDefaultViewport && options.deviceScaleFactor !== undefined) throw new Error(`"deviceScaleFactor" option is not supported with null "viewport"`); if (options.noDefaultViewport && !!options.isMobile) @@ -720,7 +717,7 @@ export function verifyGeolocation(geolocation?: types.Geolocation) { throw new Error(`geolocation.accuracy: precondition 0 <= ACCURACY failed.`); } -export function verifyClientCertificates(clientCertificates?: channels.BrowserNewContextParams['clientCertificates']) { +export function verifyClientCertificates(clientCertificates?: types.BrowserContextOptions['clientCertificates']) { if (!clientCertificates) return; for (const cert of clientCertificates) { diff --git a/packages/playwright-core/src/server/browserType.ts b/packages/playwright-core/src/server/browserType.ts index d0c3174a59..286ec27c29 100644 --- a/packages/playwright-core/src/server/browserType.ts +++ b/packages/playwright-core/src/server/browserType.ts @@ -92,27 +92,26 @@ export abstract class BrowserType extends SdkObject { return browser; } - async launchPersistentContext(metadata: CallMetadata, userDataDir: string, options: channels.BrowserTypeLaunchPersistentContextOptions & { useWebSocket?: boolean }): Promise { - options = this._validateLaunchOptions(options); + async launchPersistentContext(metadata: CallMetadata, userDataDir: string, persistentContextOptions: channels.BrowserTypeLaunchPersistentContextOptions & { useWebSocket?: boolean }): Promise { + const launchOptions = this._validateLaunchOptions(persistentContextOptions); if (this._useBidi) - options.useWebSocket = true; + launchOptions.useWebSocket = true; const controller = new ProgressController(metadata, this); - const persistent: channels.BrowserNewContextParams = { ...options }; controller.setLogName('browser'); const browser = await controller.run(async progress => { // Note: Any initial TLS requests will fail since we rely on the Page/Frames initialize which sets ignoreHTTPSErrors. - const clientCertificatesProxy = await createClientCertificatesProxyIfNeeded(persistent); + const clientCertificatesProxy = await createClientCertificatesProxyIfNeeded(persistentContextOptions); if (clientCertificatesProxy) - options.proxy = persistent.proxy; + launchOptions.proxyOverride = await clientCertificatesProxy?.listen(); progress.cleanupWhenAborted(() => clientCertificatesProxy?.close()); - const browser = await this._innerLaunchWithRetries(progress, options, persistent, helper.debugProtocolLogger(), userDataDir).catch(e => { throw this._rewriteStartupLog(e); }); + const browser = await this._innerLaunchWithRetries(progress, launchOptions, persistentContextOptions, helper.debugProtocolLogger(), userDataDir).catch(e => { throw this._rewriteStartupLog(e); }); browser._defaultContext!._clientCertificatesProxy = clientCertificatesProxy; return browser; - }, TimeoutSettings.launchTimeout(options)); + }, TimeoutSettings.launchTimeout(launchOptions)); return browser._defaultContext!; } - async _innerLaunchWithRetries(progress: Progress, options: types.LaunchOptions, persistent: channels.BrowserNewContextParams | undefined, protocolLogger: types.ProtocolLogger, userDataDir?: string): Promise { + async _innerLaunchWithRetries(progress: Progress, options: types.LaunchOptions, persistent: types.BrowserContextOptions | undefined, protocolLogger: types.ProtocolLogger, userDataDir?: string): Promise { try { return await this._innerLaunch(progress, options, persistent, protocolLogger, userDataDir); } catch (error) { @@ -126,7 +125,7 @@ export abstract class BrowserType extends SdkObject { } } - async _innerLaunch(progress: Progress, options: types.LaunchOptions, persistent: channels.BrowserNewContextParams | undefined, protocolLogger: types.ProtocolLogger, maybeUserDataDir?: string): Promise { + async _innerLaunch(progress: Progress, options: types.LaunchOptions, persistent: types.BrowserContextOptions | undefined, protocolLogger: types.ProtocolLogger, maybeUserDataDir?: string): Promise { options.proxy = options.proxy ? normalizeProxySettings(options.proxy) : undefined; const browserLogsCollector = new RecentLogsCollector(); const { browserProcess, userDataDir, artifactsDir, transport } = await this._launchProcess(progress, options, !!persistent, browserLogsCollector, maybeUserDataDir); @@ -289,7 +288,7 @@ export abstract class BrowserType extends SdkObject { throw new Error('Connecting to SELENIUM_REMOTE_URL is only supported by Chromium'); } - private _validateLaunchOptions(options: Options): Options { + private _validateLaunchOptions(options: types.LaunchOptions): types.LaunchOptions { const { devtools = false } = options; let { headless = !devtools, downloadsPath, proxy } = options; if (debugMode()) diff --git a/packages/playwright-core/src/server/chromium/chromium.ts b/packages/playwright-core/src/server/chromium/chromium.ts index d84ae3952e..30bd2871b8 100644 --- a/packages/playwright-core/src/server/chromium/chromium.ts +++ b/packages/playwright-core/src/server/chromium/chromium.ts @@ -31,7 +31,6 @@ import { CRDevTools } from './crDevTools'; import type { BrowserOptions, BrowserProcess } from '../browser'; import { Browser } from '../browser'; import type * as types from '../types'; -import type * as channels from '@protocol/channels'; import type { HTTPRequestParams } from '../../utils/network'; import { fetchData } from '../../utils/network'; import { getUserAgent } from '../../utils/userAgent'; @@ -98,7 +97,7 @@ export class Chromium extends BrowserType { await cleanedUp; }; const browserProcess: BrowserProcess = { close: doClose, kill: doClose }; - const persistent: channels.BrowserNewContextParams = { noDefaultViewport: true }; + const persistent: types.BrowserContextOptions = { noDefaultViewport: true }; const browserOptions: BrowserOptions = { slowMo: options.slowMo, name: 'chromium', @@ -287,7 +286,7 @@ export class Chromium extends BrowserType { } private _innerDefaultArgs(options: types.LaunchOptions): string[] { - const { args = [], proxy } = options; + const { args = [] } = options; const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir')); if (userDataDirArg) throw this._createUserDataDirArgMisuseError('--user-data-dir'); @@ -321,6 +320,7 @@ export class Chromium extends BrowserType { } if (options.chromiumSandbox !== true) chromeArguments.push('--no-sandbox'); + const proxy = options.proxyOverride || options.proxy; if (proxy) { const proxyURL = new URL(proxy.server); const isSocks = proxyURL.protocol === 'socks5:'; diff --git a/packages/playwright-core/src/server/chromium/crBrowser.ts b/packages/playwright-core/src/server/chromium/crBrowser.ts index 42b916c186..e0409c1b16 100644 --- a/packages/playwright-core/src/server/chromium/crBrowser.ts +++ b/packages/playwright-core/src/server/chromium/crBrowser.ts @@ -100,18 +100,19 @@ export class CRBrowser extends Browser { this._session.on('Browser.downloadProgress', this._onDownloadProgress.bind(this)); } - async doCreateNewContext(options: channels.BrowserNewContextParams): Promise { + async doCreateNewContext(options: types.BrowserContextOptions): Promise { + const proxy = options.proxyOverride || options.proxy; let proxyBypassList = undefined; - if (options.proxy) { + if (proxy) { if (process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK) - proxyBypassList = options.proxy.bypass; + proxyBypassList = proxy.bypass; else - proxyBypassList = '<-loopback>' + (options.proxy.bypass ? `,${options.proxy.bypass}` : ''); + proxyBypassList = '<-loopback>' + (proxy.bypass ? `,${proxy.bypass}` : ''); } const { browserContextId } = await this._session.send('Target.createBrowserContext', { disposeOnDetach: true, - proxyServer: options.proxy ? options.proxy.server : undefined, + proxyServer: proxy ? proxy.server : undefined, proxyBypassList, }); const context = new CRBrowserContext(this, browserContextId, options); @@ -340,7 +341,7 @@ export class CRBrowserContext extends BrowserContext { declare readonly _browser: CRBrowser; - constructor(browser: CRBrowser, browserContextId: string | undefined, options: channels.BrowserNewContextParams) { + constructor(browser: CRBrowser, browserContextId: string | undefined, options: types.BrowserContextOptions) { super(browser, options, browserContextId); this._authenticateProxyViaCredentials(); } diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index 002dfa09be..5a7fb5e4af 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -543,7 +543,7 @@ class FrameSession { const options = this._crPage._browserContext._options; if (options.bypassCSP) promises.push(this._client.send('Page.setBypassCSP', { enabled: true })); - if (options.ignoreHTTPSErrors) + if (options.ignoreHTTPSErrors || options.internalIgnoreHTTPSErrors) promises.push(this._client.send('Security.setIgnoreCertificateErrors', { ignore: true })); if (this._isMainFrame()) promises.push(this._updateViewport()); @@ -1213,7 +1213,7 @@ async function emulateTimezone(session: CRSession, timezoneId: string) { const contextDelegateSymbol = Symbol('delegate'); // Chromium reference: https://source.chromium.org/chromium/chromium/src/+/main:components/embedder_support/user_agent_utils.cc;l=434;drc=70a6711e08e9f9e0d8e4c48e9ba5cab62eb010c2 -function calculateUserAgentMetadata(options: channels.BrowserNewContextParams) { +function calculateUserAgentMetadata(options: types.BrowserContextOptions) { const ua = options.userAgent; if (!ua) return undefined; diff --git a/packages/playwright-core/src/server/electron/electron.ts b/packages/playwright-core/src/server/electron/electron.ts index b8f361b48a..1606c407d5 100644 --- a/packages/playwright-core/src/server/electron/electron.ts +++ b/packages/playwright-core/src/server/electron/electron.ts @@ -36,6 +36,7 @@ import type { BrowserWindow } from 'electron'; import type { Progress } from '../progress'; import { ProgressController } from '../progress'; import { helper } from '../helper'; +import type * as types from '../types'; import { eventsHelper } from '../../utils/eventsHelper'; import type { BrowserOptions, BrowserProcess } from '../browser'; import type { Playwright } from '../playwright'; @@ -265,7 +266,7 @@ export class Electron extends SdkObject { close: gracefullyClose, kill }; - const contextOptions: channels.BrowserNewContextParams = { + const contextOptions: types.BrowserContextOptions = { ...options, noDefaultViewport: true, }; diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 7a6102a50d..5d00dc05a6 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -50,7 +50,7 @@ type FetchRequestOptions = { timeoutSettings: TimeoutSettings; ignoreHTTPSErrors?: boolean; baseURL?: string; - clientCertificates?: channels.BrowserNewContextOptions['clientCertificates']; + clientCertificates?: types.BrowserContextOptions['clientCertificates']; }; type HeadersObject = Readonly<{ [name: string]: string }>; diff --git a/packages/playwright-core/src/server/firefox/ffBrowser.ts b/packages/playwright-core/src/server/firefox/ffBrowser.ts index 94b90bbcea..b26a2850ee 100644 --- a/packages/playwright-core/src/server/firefox/ffBrowser.ts +++ b/packages/playwright-core/src/server/firefox/ffBrowser.ts @@ -58,8 +58,9 @@ export class FFBrowser extends Browser { browser._defaultContext = new FFBrowserContext(browser, undefined, options.persistent); promises.push((browser._defaultContext as FFBrowserContext)._initialize()); } - if (options.proxy) - promises.push(browser.session.send('Browser.setBrowserProxy', toJugglerProxyOptions(options.proxy))); + const proxy = options.originalLaunchOptions.proxyOverride || options.proxy; + if (proxy) + promises.push(browser.session.send('Browser.setBrowserProxy', toJugglerProxyOptions(proxy))); await Promise.all(promises); return browser; } @@ -88,7 +89,7 @@ export class FFBrowser extends Browser { return !this._connection._closed; } - async doCreateNewContext(options: channels.BrowserNewContextParams): Promise { + async doCreateNewContext(options: types.BrowserContextOptions): Promise { if (options.isMobile) throw new Error('options.isMobile is not supported in Firefox'); const { browserContextId } = await this.session.send('Browser.createBrowserContext', { removeOnDetach: true }); @@ -172,7 +173,7 @@ export class FFBrowser extends Browser { export class FFBrowserContext extends BrowserContext { declare readonly _browser: FFBrowser; - constructor(browser: FFBrowser, browserContextId: string | undefined, options: channels.BrowserNewContextParams) { + constructor(browser: FFBrowser, browserContextId: string | undefined, options: types.BrowserContextOptions) { super(browser, options, browserContextId); } @@ -205,7 +206,7 @@ export class FFBrowserContext extends BrowserContext { promises.push(this._browser.session.send('Browser.setUserAgentOverride', { browserContextId, userAgent: this._options.userAgent })); if (this._options.bypassCSP) promises.push(this._browser.session.send('Browser.setBypassCSP', { browserContextId, bypassCSP: true })); - if (this._options.ignoreHTTPSErrors) + if (this._options.ignoreHTTPSErrors || this._options.internalIgnoreHTTPSErrors) promises.push(this._browser.session.send('Browser.setIgnoreHTTPSErrors', { browserContextId, ignoreHTTPSErrors: true })); if (this._options.javaScriptEnabled === false) promises.push(this._browser.session.send('Browser.setJavaScriptDisabled', { browserContextId, javaScriptDisabled: true })); @@ -251,10 +252,11 @@ export class FFBrowserContext extends BrowserContext { }); })); } - if (this._options.proxy) { + const proxy = this._options.proxyOverride || this._options.proxy; + if (proxy) { promises.push(this._browser.session.send('Browser.setContextProxy', { browserContextId: this._browserContextId, - ...toJugglerProxyOptions(this._options.proxy) + ...toJugglerProxyOptions(proxy) })); } diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index 2dd900bf89..6d4b334dba 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -23,7 +23,7 @@ import { createSocket, createTLSSocket } from '../utils/happy-eyeballs'; import { escapeHTML, generateSelfSignedCertificate, ManualPromise, rewriteErrorMessage } from '../utils'; import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../common/socksProxy'; import { SocksProxy } from '../common/socksProxy'; -import type * as channels from '@protocol/channels'; +import type * as types from './types'; import { debugLogger } from '../utils/debugLogger'; let dummyServerTlsOptions: tls.TlsOptions | undefined = undefined; @@ -235,7 +235,7 @@ export class ClientCertificatesProxy { alpnCache: ALPNCache; constructor( - contextOptions: Pick + contextOptions: Pick ) { this.alpnCache = new ALPNCache(); this.ignoreHTTPSErrors = contextOptions.ignoreHTTPSErrors; @@ -261,9 +261,9 @@ export class ClientCertificatesProxy { loadDummyServerCertsIfNeeded(); } - _initSecureContexts(clientCertificates: channels.BrowserNewContextOptions['clientCertificates']) { + _initSecureContexts(clientCertificates: types.BrowserContextOptions['clientCertificates']) { // Step 1. Group certificates by origin. - const origin2certs = new Map(); + const origin2certs = new Map(); for (const cert of clientCertificates || []) { const origin = normalizeOrigin(cert.origin); const certs = origin2certs.get(origin) || []; @@ -282,9 +282,9 @@ export class ClientCertificatesProxy { } } - public async listen(): Promise { + public async listen() { const port = await this._socksProxy.listen(0, '127.0.0.1'); - return `socks5://127.0.0.1:${port}`; + return { server: `socks5://127.0.0.1:${port}` }; } public async close() { @@ -301,7 +301,7 @@ function normalizeOrigin(origin: string): string { } function convertClientCertificatesToTLSOptions( - clientCertificates: channels.BrowserNewContextOptions['clientCertificates'] + clientCertificates: types.BrowserContextOptions['clientCertificates'] ): Pick | undefined { if (!clientCertificates || !clientCertificates.length) return; @@ -322,7 +322,7 @@ function convertClientCertificatesToTLSOptions( } export function getMatchingTLSOptionsForOrigin( - clientCertificates: channels.BrowserNewContextOptions['clientCertificates'], + clientCertificates: types.BrowserContextOptions['clientCertificates'], origin: string ): Pick | undefined { const matchingCerts = clientCertificates?.filter(c => diff --git a/packages/playwright-core/src/server/types.ts b/packages/playwright-core/src/server/types.ts index 226216c397..b58ea5af83 100644 --- a/packages/playwright-core/src/server/types.ts +++ b/packages/playwright-core/src/server/types.ts @@ -150,7 +150,15 @@ export type NormalizedContinueOverrides = { export type EmulatedSize = { viewport: Size, screen: Size }; -export type LaunchOptions = channels.BrowserTypeLaunchOptions & { useWebSocket?: boolean }; +export type LaunchOptions = channels.BrowserTypeLaunchOptions & { + useWebSocket?: boolean, + proxyOverride?: ProxySettings, +}; + +export type BrowserContextOptions = channels.BrowserNewContextOptions & { + proxyOverride?: ProxySettings; + internalIgnoreHTTPSErrors?: boolean; +}; export type ProtocolLogger = (direction: 'send' | 'receive', message: object) => void; diff --git a/packages/playwright-core/src/server/webkit/webkit.ts b/packages/playwright-core/src/server/webkit/webkit.ts index b25b62a421..9a11f6c56d 100644 --- a/packages/playwright-core/src/server/webkit/webkit.ts +++ b/packages/playwright-core/src/server/webkit/webkit.ts @@ -53,7 +53,7 @@ export class WebKit extends BrowserType { } override defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] { - const { args = [], proxy, headless } = options; + const { args = [], headless } = options; const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir')); if (userDataDirArg) throw this._createUserDataDirArgMisuseError('--user-data-dir'); @@ -68,6 +68,7 @@ export class WebKit extends BrowserType { webkitArguments.push(`--user-data-dir=${userDataDir}`); else webkitArguments.push(`--no-startup-window`); + const proxy = options.proxyOverride || options.proxy; if (proxy) { if (process.platform === 'darwin') { webkitArguments.push(`--proxy=${proxy.server}`); diff --git a/packages/playwright-core/src/server/webkit/wkBrowser.ts b/packages/playwright-core/src/server/webkit/wkBrowser.ts index 92231edd75..c9bda10ddd 100644 --- a/packages/playwright-core/src/server/webkit/wkBrowser.ts +++ b/packages/playwright-core/src/server/webkit/wkBrowser.ts @@ -81,12 +81,13 @@ export class WKBrowser extends Browser { this._didClose(); } - async doCreateNewContext(options: channels.BrowserNewContextParams): Promise { - const createOptions = options.proxy ? { - // Enable socks5 hostname resolution on Windows. Workaround can be removed once fixed upstream. + async doCreateNewContext(options: types.BrowserContextOptions): Promise { + const proxy = options.proxyOverride || options.proxy; + const createOptions = proxy ? { + // Enable socks5 hostname resolution on Windows. // See https://github.com/microsoft/playwright/issues/20451 - proxyServer: process.platform === 'win32' ? options.proxy.server.replace(/^socks5:\/\//, 'socks5h://') : options.proxy.server, - proxyBypassList: options.proxy.bypass + proxyServer: process.platform === 'win32' ? proxy.server.replace(/^socks5:\/\//, 'socks5h://') : proxy.server, + proxyBypassList: proxy.bypass } : undefined; const { browserContextId } = await this._browserSession.send('Playwright.createContext', createOptions); options.userAgent = options.userAgent || DEFAULT_USER_AGENT; @@ -206,7 +207,7 @@ export class WKBrowser extends Browser { export class WKBrowserContext extends BrowserContext { declare readonly _browser: WKBrowser; - constructor(browser: WKBrowser, browserContextId: string | undefined, options: channels.BrowserNewContextParams) { + constructor(browser: WKBrowser, browserContextId: string | undefined, options: types.BrowserContextOptions) { super(browser, options, browserContextId); this._validateEmulatedViewport(options.viewport); this._authenticateProxyViaHeader(); @@ -221,7 +222,7 @@ export class WKBrowserContext extends BrowserContext { downloadPath: this._browser.options.downloadsPath, browserContextId })); - if (this._options.ignoreHTTPSErrors) + if (this._options.ignoreHTTPSErrors || this._options.internalIgnoreHTTPSErrors) promises.push(this._browser._browserSession.send('Playwright.setIgnoreCertificateErrors', { browserContextId, ignore: true })); if (this._options.locale) promises.push(this._browser._browserSession.send('Playwright.setLanguages', { browserContextId, languages: [this._options.locale] })); diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index 682df0b00f..899b9819be 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -546,6 +546,7 @@ test.describe('browser', () => { keyPath: asset('client-certificates/client/trusted/key.pem'), }; const page = await browser.newPage({ + ignoreHTTPSErrors: true, clientCertificates: [{ origin: new URL(serverURL).origin, ...baseOptions, From b82100adf5490bbbef5a55b0fa9658947a47bd55 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Fri, 13 Sep 2024 09:12:35 -0700 Subject: [PATCH 20/41] feat(firefox-beta): roll to r1464 (#32615) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index cf83b5413f..6e8da091b4 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -21,7 +21,7 @@ }, { "name": "firefox-beta", - "revision": "1463", + "revision": "1464", "installByDefault": false, "browserVersion": "131.0b2" }, From 5b28d2a84c0fa5232ab029ff1e6c7c73c10f9e42 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Fri, 13 Sep 2024 09:12:43 -0700 Subject: [PATCH 21/41] feat(firefox): roll to r1464 (#32614) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 2 +- packages/playwright-core/src/server/firefox/protocol.d.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 6e8da091b4..de51a1643c 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -15,7 +15,7 @@ }, { "name": "firefox", - "revision": "1463", + "revision": "1464", "installByDefault": true, "browserVersion": "130.0" }, diff --git a/packages/playwright-core/src/server/firefox/protocol.d.ts b/packages/playwright-core/src/server/firefox/protocol.d.ts index 1da6d70122..f4a44d9d4d 100644 --- a/packages/playwright-core/src/server/firefox/protocol.d.ts +++ b/packages/playwright-core/src/server/firefox/protocol.d.ts @@ -315,6 +315,11 @@ export module Protocol { }; export type cancelDownloadReturnValue = void; } + export module Heap { + export type collectGarbageParameters = { + }; + export type collectGarbageReturnValue = void; + } export module Page { export type DOMPoint = { x: number; @@ -1124,6 +1129,7 @@ export module Protocol { "Browser.setForcedColors": Browser.setForcedColorsParameters; "Browser.setVideoRecordingOptions": Browser.setVideoRecordingOptionsParameters; "Browser.cancelDownload": Browser.cancelDownloadParameters; + "Heap.collectGarbage": Heap.collectGarbageParameters; "Page.close": Page.closeParameters; "Page.setFileInputFiles": Page.setFileInputFilesParameters; "Page.addBinding": Page.addBindingParameters; @@ -1204,6 +1210,7 @@ export module Protocol { "Browser.setForcedColors": Browser.setForcedColorsReturnValue; "Browser.setVideoRecordingOptions": Browser.setVideoRecordingOptionsReturnValue; "Browser.cancelDownload": Browser.cancelDownloadReturnValue; + "Heap.collectGarbage": Heap.collectGarbageReturnValue; "Page.close": Page.closeReturnValue; "Page.setFileInputFiles": Page.setFileInputFilesReturnValue; "Page.addBinding": Page.addBindingReturnValue; From f2a974b0451db21295a1dde2f8670483d8a5697b Mon Sep 17 00:00:00 2001 From: Matthew Jee Date: Fri, 13 Sep 2024 14:09:36 -0700 Subject: [PATCH 22/41] feat(api): add method to force garbage collection (#32383) --- .../firefox/juggler/protocol/PageHandler.js | 7 +++++ .../firefox/juggler/protocol/Protocol.js | 13 ++++++++- docs/src/api/class-page.md | 5 ++++ packages/playwright-core/src/client/page.ts | 4 +++ .../playwright-core/src/protocol/validator.ts | 2 ++ .../src/server/bidi/bidiPage.ts | 4 +++ .../src/server/chromium/crPage.ts | 4 +++ .../src/server/dispatchers/pageDispatcher.ts | 4 +++ .../src/server/firefox/ffPage.ts | 4 +++ packages/playwright-core/src/server/page.ts | 5 ++++ .../src/server/webkit/wkPage.ts | 4 +++ packages/playwright-core/types/types.d.ts | 5 ++++ packages/protocol/src/channels.ts | 4 +++ packages/protocol/src/protocol.yml | 2 ++ tests/page/page-force-gc.spec.ts | 27 +++++++++++++++++++ 15 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 tests/page/page-force-gc.spec.ts diff --git a/browser_patches/firefox/juggler/protocol/PageHandler.js b/browser_patches/firefox/juggler/protocol/PageHandler.js index 8fa9a06361..bab151b392 100644 --- a/browser_patches/firefox/juggler/protocol/PageHandler.js +++ b/browser_patches/firefox/juggler/protocol/PageHandler.js @@ -256,6 +256,13 @@ class PageHandler { return await this._contentPage.send('disposeObject', options); } + async ['Heap.collectGarbage']() { + Services.obs.notifyObservers(null, "child-gc-request"); + Cu.forceGC(); + Services.obs.notifyObservers(null, "child-cc-request"); + Cu.forceCC(); + } + async ['Network.getResponseBody']({requestId}) { return this._pageNetwork.getResponseBody(requestId); } diff --git a/browser_patches/firefox/juggler/protocol/Protocol.js b/browser_patches/firefox/juggler/protocol/Protocol.js index 6c9b700f05..2b7ad56d6a 100644 --- a/browser_patches/firefox/juggler/protocol/Protocol.js +++ b/browser_patches/firefox/juggler/protocol/Protocol.js @@ -487,6 +487,17 @@ const Browser = { }, }; +const Heap = { + targets: ['page'], + types: {}, + events: {}, + methods: { + 'collectGarbage': { + params: {}, + }, + }, +}; + const Network = { targets: ['page'], types: networkTypes, @@ -1002,7 +1013,7 @@ const Accessibility = { } this.protocol = { - domains: {Browser, Page, Runtime, Network, Accessibility}, + domains: {Browser, Heap, Page, Runtime, Network, Accessibility}, }; this.checkScheme = checkScheme; this.EXPORTED_SYMBOLS = ['protocol', 'checkScheme']; diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index e1aa908041..d36a96ee72 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -2333,6 +2333,11 @@ last redirect. If cannot go forward, returns `null`. Navigate to the next page in history. +## async method: Page.forceGarbageCollection +* since: v1.47 + +Force the browser to perform garbage collection. + ### option: Page.goForward.waitUntil = %%-navigation-wait-until-%% * since: v1.8 diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index a10286fa9a..0bbe78f4c8 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -468,6 +468,10 @@ export class Page extends ChannelOwner implements api.Page return Response.fromNullable((await this._channel.goForward({ ...options, waitUntil })).response); } + async forceGarbageCollection() { + await this._channel.forceGarbageCollection(); + } + async emulateMedia(options: { media?: 'screen' | 'print' | null, colorScheme?: 'dark' | 'light' | 'no-preference' | null, reducedMotion?: 'reduce' | 'no-preference' | null, forcedColors?: 'active' | 'none' | null } = {}) { await this._channel.emulateMedia({ media: options.media === null ? 'no-override' : options.media, diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index b67edcbca8..abea7f8fce 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1122,6 +1122,8 @@ scheme.PageGoForwardParams = tObject({ scheme.PageGoForwardResult = tObject({ response: tOptional(tChannel(['Response'])), }); +scheme.PageForceGarbageCollectionParams = tOptional(tObject({})); +scheme.PageForceGarbageCollectionResult = tOptional(tObject({})); scheme.PageRegisterLocatorHandlerParams = tObject({ selector: tString, noWaitAfter: tOptional(tBoolean), diff --git a/packages/playwright-core/src/server/bidi/bidiPage.ts b/packages/playwright-core/src/server/bidi/bidiPage.ts index f06924d70f..c2d499bd67 100644 --- a/packages/playwright-core/src/server/bidi/bidiPage.ts +++ b/packages/playwright-core/src/server/bidi/bidiPage.ts @@ -323,6 +323,10 @@ export class BidiPage implements PageDelegate { throw new Error('Method not implemented.'); } + async forceGarbageCollection(): Promise { + throw new Error('Method not implemented.'); + } + async addInitScript(initScript: InitScript): Promise { const { script } = await this._session.send('script.addPreloadScript', { // TODO: remove function call from the source. diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index 5a7fb5e4af..fbdc9db91a 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -247,6 +247,10 @@ export class CRPage implements PageDelegate { return this._go(+1); } + async forceGarbageCollection(): Promise { + await this._mainFrameSession._client.send('HeapProfiler.collectGarbage'); + } + async addInitScript(initScript: InitScript, world: types.World = 'main'): Promise { await this._forAllFrameSessions(frame => frame._evaluateOnNewDocument(initScript, world)); } diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index 3101cd051d..a97ddbf1f0 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -137,6 +137,10 @@ export class PageDispatcher extends Dispatcher { + await this._page.forceGarbageCollection(); + } + async registerLocatorHandler(params: channels.PageRegisterLocatorHandlerParams, metadata: CallMetadata): Promise { const uid = this._page.registerLocatorHandler(params.selector, params.noWaitAfter); return { uid }; diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index d1066876eb..03a27954dd 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -400,6 +400,10 @@ export class FFPage implements PageDelegate { return success; } + async forceGarbageCollection(): Promise { + await this._session.send('Heap.collectGarbage'); + } + async addInitScript(initScript: InitScript, worldName?: string): Promise { this._initScripts.push({ initScript, worldName }); await this._session.send('Page.setInitScripts', { scripts: this._initScripts.map(s => ({ script: s.initScript.source, worldName: s.worldName })) }); diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index f2e59aa56f..aeaeb0af88 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -54,6 +54,7 @@ export interface PageDelegate { reload(): Promise; goBack(): Promise; goForward(): Promise; + forceGarbageCollection(): Promise; addInitScript(initScript: InitScript): Promise; removeNonInternalInitScripts(): Promise; closePage(runBeforeUnload: boolean): Promise; @@ -430,6 +431,10 @@ export class Page extends SdkObject { }), this._timeoutSettings.navigationTimeout(options)); } + forceGarbageCollection(): Promise { + return this._delegate.forceGarbageCollection(); + } + registerLocatorHandler(selector: string, noWaitAfter: boolean | undefined) { const uid = ++this._lastLocatorHandlerUid; this._locatorHandlers.set(uid, { selector, noWaitAfter }); diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index c3954b4882..2f579b619b 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -768,6 +768,10 @@ export class WKPage implements PageDelegate { }); } + async forceGarbageCollection(): Promise { + await this._session.send('Heap.gc'); + } + async addInitScript(initScript: InitScript): Promise { await this._updateBootstrapScript(); } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 1c3b7f50b2..80d99a732b 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -2554,6 +2554,11 @@ export interface Page { timeout?: number; }): Promise; + /** + * Force the browser to perform garbage collection. + */ + forceGarbageCollection(): Promise; + /** * Returns frame matching the specified criteria. Either `name` or `url` must be specified. * diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index 143f1ad0e0..689f0275b1 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -1933,6 +1933,7 @@ export interface PageChannel extends PageEventTarget, EventTargetChannel { exposeBinding(params: PageExposeBindingParams, metadata?: CallMetadata): Promise; goBack(params: PageGoBackParams, metadata?: CallMetadata): Promise; goForward(params: PageGoForwardParams, metadata?: CallMetadata): Promise; + forceGarbageCollection(params?: PageForceGarbageCollectionParams, metadata?: CallMetadata): Promise; registerLocatorHandler(params: PageRegisterLocatorHandlerParams, metadata?: CallMetadata): Promise; resolveLocatorHandlerNoReply(params: PageResolveLocatorHandlerNoReplyParams, metadata?: CallMetadata): Promise; unregisterLocatorHandler(params: PageUnregisterLocatorHandlerParams, metadata?: CallMetadata): Promise; @@ -2070,6 +2071,9 @@ export type PageGoForwardOptions = { export type PageGoForwardResult = { response?: ResponseChannel, }; +export type PageForceGarbageCollectionParams = {}; +export type PageForceGarbageCollectionOptions = {}; +export type PageForceGarbageCollectionResult = void; export type PageRegisterLocatorHandlerParams = { selector: string, noWaitAfter?: boolean, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 4f064ffa08..ce206ab569 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1430,6 +1430,8 @@ Page: slowMo: true snapshot: true + forceGarbageCollection: + registerLocatorHandler: parameters: selector: string diff --git a/tests/page/page-force-gc.spec.ts b/tests/page/page-force-gc.spec.ts new file mode 100644 index 0000000000..038d471eba --- /dev/null +++ b/tests/page/page-force-gc.spec.ts @@ -0,0 +1,27 @@ +/** + * Copyright 2024 Adobe Inc. All rights reserved. + * + * 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 { test, expect } from './pageTest'; + +test('should work', async ({ page }) => { + await page.evaluate(() => { + globalThis.objectToDestroy = {}; + globalThis.weakRef = new WeakRef(globalThis.objectToDestroy); + }); + await page.evaluate(() => globalThis.objectToDestroy = null); + await page.forceGarbageCollection(); + expect(await page.evaluate(() => globalThis.weakRef.deref())).toBe(undefined); +}); From 34876e929108fb13476b00e0e038ca020db909fa Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 13 Sep 2024 18:29:35 -0700 Subject: [PATCH 23/41] chore: cookies in intercepted bidi requests (#32623) --- .../src/server/bidi/bidiNetworkManager.ts | 43 +++++++-- .../playwright-core/src/server/cookieStore.ts | 92 +++++++++++++++++++ packages/playwright-core/src/server/fetch.ts | 81 +--------------- tests/config/browserTest.ts | 8 +- tests/page/pageTestApi.ts | 2 +- 5 files changed, 139 insertions(+), 87 deletions(-) diff --git a/packages/playwright-core/src/server/bidi/bidiNetworkManager.ts b/packages/playwright-core/src/server/bidi/bidiNetworkManager.ts index 00846b124a..b7c314bd10 100644 --- a/packages/playwright-core/src/server/bidi/bidiNetworkManager.ts +++ b/packages/playwright-core/src/server/bidi/bidiNetworkManager.ts @@ -22,6 +22,7 @@ import type * as frames from '../frames'; import type * as types from '../types'; import * as bidi from './third_party/bidiProtocol'; import type { BidiSession } from './bidiConnection'; +import { parseRawCookie } from '../cookieStore'; export class BidiNetworkManager { @@ -68,7 +69,7 @@ export class BidiNetworkManager { if (redirectedFrom) { this._session.sendMayFail('network.continueRequest', { request: param.request.request, - headers: redirectedFrom._originalRequestRoute?._alreadyContinuedHeaders, + ...(redirectedFrom._originalRequestRoute?._alreadyContinuedHeaders || {}), }); } else { route = new BidiRouteImpl(this._session, param.request.request); @@ -245,7 +246,7 @@ class BidiRouteImpl implements network.RouteDelegate { private _requestId: bidi.Network.Request; private _session: BidiSession; private _request!: network.Request; - _alreadyContinuedHeaders: bidi.Network.Header[] | undefined; + _alreadyContinuedHeaders: types.HeadersArray | undefined; constructor(session: BidiSession, requestId: bidi.Network.Request) { this._session = session; @@ -266,13 +267,12 @@ class BidiRouteImpl implements network.RouteDelegate { return header; }); } - this._alreadyContinuedHeaders = toBidiHeaders(headers); + this._alreadyContinuedHeaders = headers; await this._session.sendMayFail('network.continueRequest', { request: this._requestId, url: overrides.url, method: overrides.method, - // TODO: cookies! - headers: this._alreadyContinuedHeaders, + ...toBidiRequestHeaders(this._alreadyContinuedHeaders), body: overrides.postData ? { type: 'base64', value: Buffer.from(overrides.postData).toString('base64') } : undefined, }); } @@ -283,7 +283,7 @@ class BidiRouteImpl implements network.RouteDelegate { request: this._requestId, statusCode: response.status, reasonPhrase: network.statusText(response.status), - headers: toBidiHeaders(response.headers), + ...toBidiResponseHeaders(response.headers), body: { type: 'base64', value: base64body }, }); } @@ -302,6 +302,27 @@ function fromBidiHeaders(bidiHeaders: bidi.Network.Header[]): types.HeadersArray return result; } +function toBidiRequestHeaders(allHeaders: types.HeadersArray): { cookies: bidi.Network.CookieHeader[], headers: bidi.Network.Header[] } { + const bidiHeaders = toBidiHeaders(allHeaders); + const cookies = bidiHeaders.filter(h => h.name.toLowerCase() === 'cookie'); + const headers = bidiHeaders.filter(h => h.name.toLowerCase() !== 'cookie'); + return { cookies, headers }; +} + +function toBidiResponseHeaders(headers: types.HeadersArray): { cookies: bidi.Network.SetCookieHeader[], headers: bidi.Network.Header[] } { + const setCookieHeaders = headers.filter(h => h.name.toLowerCase() === 'set-cookie'); + const otherHeaders = headers.filter(h => h.name.toLowerCase() !== 'set-cookie'); + const rawCookies = setCookieHeaders.map(h => parseRawCookie(h.value)); + const cookies: bidi.Network.SetCookieHeader[] = rawCookies.filter(Boolean).map(c => { + return { + ...c!, + value: { type: 'string', value: c!.value }, + sameSite: toBidiSameSite(c!.sameSite), + }; + }); + return { cookies, headers: toBidiHeaders(otherHeaders) }; +} + function toBidiHeaders(headers: types.HeadersArray): bidi.Network.Header[] { return headers.map(({ name, value }) => ({ name, value: { type: 'string', value } })); } @@ -314,3 +335,13 @@ export function bidiBytesValueToString(value: bidi.Network.BytesValue): string { return 'unknown value type: ' + (value as any).type; } + +function toBidiSameSite(sameSite?: 'Strict' | 'Lax' | 'None'): bidi.Network.SameSite | undefined { + if (!sameSite) + return undefined; + if (sameSite === 'Strict') + return bidi.Network.SameSite.Strict; + if (sameSite === 'Lax') + return bidi.Network.SameSite.Lax; + return bidi.Network.SameSite.None; +} diff --git a/packages/playwright-core/src/server/cookieStore.ts b/packages/playwright-core/src/server/cookieStore.ts index fbf3f718f0..d1842660c7 100644 --- a/packages/playwright-core/src/server/cookieStore.ts +++ b/packages/playwright-core/src/server/cookieStore.ts @@ -15,6 +15,7 @@ */ import type * as channels from '@protocol/channels'; +import { kMaxCookieExpiresDateInSeconds } from './network'; class Cookie { private _raw: channels.NetworkCookie; @@ -115,6 +116,97 @@ export class CookieStore { } } +type RawCookie = { + name: string, + value: string, + domain?: string, + path?: string, + expires?: number, + httpOnly?: boolean, + secure?: boolean, + sameSite?: 'Strict' | 'Lax' | 'None', +}; + +export function parseRawCookie(header: string): RawCookie | null { + const pairs = header.split(';').filter(s => s.trim().length > 0).map(p => { + let key = ''; + let value = ''; + const separatorPos = p.indexOf('='); + if (separatorPos === -1) { + // If only a key is specified, the value is left undefined. + key = p.trim(); + } else { + // Otherwise we assume that the key is the element before the first `=` + key = p.slice(0, separatorPos).trim(); + // And the value is the rest of the string. + value = p.slice(separatorPos + 1).trim(); + } + return [key, value]; + }); + if (!pairs.length) + return null; + const [name, value] = pairs[0]; + const cookie: RawCookie = { + name, + value, + }; + for (let i = 1; i < pairs.length; i++) { + const [name, value] = pairs[i]; + switch (name.toLowerCase()) { + case 'expires': + const expiresMs = (+new Date(value)); + // https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.1 + if (isFinite(expiresMs)) { + if (expiresMs <= 0) + cookie.expires = 0; + else + cookie.expires = Math.min(expiresMs / 1000, kMaxCookieExpiresDateInSeconds); + } + break; + case 'max-age': + const maxAgeSec = parseInt(value, 10); + if (isFinite(maxAgeSec)) { + // From https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2 + // If delta-seconds is less than or equal to zero (0), let expiry-time + // be the earliest representable date and time. + if (maxAgeSec <= 0) + cookie.expires = 0; + else + cookie.expires = Math.min(Date.now() / 1000 + maxAgeSec, kMaxCookieExpiresDateInSeconds); + } + break; + case 'domain': + cookie.domain = value.toLocaleLowerCase() || ''; + if (cookie.domain && !cookie.domain.startsWith('.') && cookie.domain.includes('.')) + cookie.domain = '.' + cookie.domain; + break; + case 'path': + cookie.path = value || ''; + break; + case 'secure': + cookie.secure = true; + break; + case 'httponly': + cookie.httpOnly = true; + break; + case 'samesite': + switch (value.toLowerCase()) { + case 'none': + cookie.sameSite = 'None'; + break; + case 'lax': + cookie.sameSite = 'Lax'; + break; + case 'strict': + cookie.sameSite = 'Strict'; + break; + } + break; + } + } + return cookie; +} + export function domainMatches(value: string, domain: string): boolean { if (value === domain) return true; diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 5d00dc05a6..01c6c397ab 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -28,7 +28,7 @@ import { getUserAgent } from '../utils/userAgent'; import { assert, createGuid, monotonicTime } from '../utils'; import { HttpsProxyAgent, SocksProxyAgent } from '../utilsBundle'; import { BrowserContext, verifyClientCertificates } from './browserContext'; -import { CookieStore, domainMatches } from './cookieStore'; +import { CookieStore, domainMatches, parseRawCookie } from './cookieStore'; import { MultipartFormData } from './formData'; import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from '../utils/happy-eyeballs'; import type { CallMetadata } from './instrumentation'; @@ -39,7 +39,6 @@ import { ProgressController } from './progress'; import { Tracing } from './trace/recorder/tracing'; import type * as types from './types'; import type { HeadersArray, ProxySettings } from './types'; -import { kMaxCookieExpiresDateInSeconds } from './network'; import { getMatchingTLSOptionsForOrigin, rewriteOpenSSLErrorIfNeeded } from './socksClientCertificatesInterceptor'; type FetchRequestOptions = { @@ -640,27 +639,10 @@ function toHeadersArray(rawHeaders: string[]): types.HeadersArray { const redirectStatus = [301, 302, 303, 307, 308]; function parseCookie(header: string): channels.NetworkCookie | null { - const pairs = header.split(';').filter(s => s.trim().length > 0).map(p => { - let key = ''; - let value = ''; - const separatorPos = p.indexOf('='); - if (separatorPos === -1) { - // If only a key is specified, the value is left undefined. - key = p.trim(); - } else { - // Otherwise we assume that the key is the element before the first `=` - key = p.slice(0, separatorPos).trim(); - // And the value is the rest of the string. - value = p.slice(separatorPos + 1).trim(); - } - return [key, value]; - }); - if (!pairs.length) + const raw = parseRawCookie(header); + if (!raw) return null; - const [name, value] = pairs[0]; const cookie: channels.NetworkCookie = { - name, - value, domain: '', path: '', expires: -1, @@ -668,62 +650,9 @@ function parseCookie(header: string): channels.NetworkCookie | null { secure: false, // From https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite // The cookie-sending behavior if SameSite is not specified is SameSite=Lax. - sameSite: 'Lax' + sameSite: 'Lax', + ...raw }; - for (let i = 1; i < pairs.length; i++) { - const [name, value] = pairs[i]; - switch (name.toLowerCase()) { - case 'expires': - const expiresMs = (+new Date(value)); - // https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.1 - if (isFinite(expiresMs)) { - if (expiresMs <= 0) - cookie.expires = 0; - else - cookie.expires = Math.min(expiresMs / 1000, kMaxCookieExpiresDateInSeconds); - } - break; - case 'max-age': - const maxAgeSec = parseInt(value, 10); - if (isFinite(maxAgeSec)) { - // From https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2 - // If delta-seconds is less than or equal to zero (0), let expiry-time - // be the earliest representable date and time. - if (maxAgeSec <= 0) - cookie.expires = 0; - else - cookie.expires = Math.min(Date.now() / 1000 + maxAgeSec, kMaxCookieExpiresDateInSeconds); - } - break; - case 'domain': - cookie.domain = value.toLocaleLowerCase() || ''; - if (cookie.domain && !cookie.domain.startsWith('.') && cookie.domain.includes('.')) - cookie.domain = '.' + cookie.domain; - break; - case 'path': - cookie.path = value || ''; - break; - case 'secure': - cookie.secure = true; - break; - case 'httponly': - cookie.httpOnly = true; - break; - case 'samesite': - switch (value.toLowerCase()) { - case 'none': - cookie.sameSite = 'None'; - break; - case 'lax': - cookie.sameSite = 'Lax'; - break; - case 'strict': - cookie.sameSite = 'Strict'; - break; - } - break; - } - } return cookie; } diff --git a/tests/config/browserTest.ts b/tests/config/browserTest.ts index 196f3604db..44aaca9d26 100644 --- a/tests/config/browserTest.ts +++ b/tests/config/browserTest.ts @@ -62,21 +62,21 @@ const test = baseTest.extend await run(playwright[browserName]); }, { scope: 'worker' }], - allowsThirdParty: [async ({ browserName, browserMajorVersion, channel }, run) => { + allowsThirdParty: [async ({ browserName }, run) => { if (browserName === 'firefox') await run(true); else await run(false); }, { scope: 'worker' }], - defaultSameSiteCookieValue: [async ({ browserName, browserMajorVersion, channel, isLinux }, run) => { - if (browserName === 'chromium') + defaultSameSiteCookieValue: [async ({ browserName, isLinux }, run) => { + if (browserName === 'chromium' || browserName as any === '_bidiChromium') await run('Lax'); else if (browserName === 'webkit' && isLinux) await run('Lax'); else if (browserName === 'webkit' && !isLinux) await run('None'); - else if (browserName === 'firefox') + else if (browserName === 'firefox' || browserName as any === '_bidiFirefox') await run('None'); else throw new Error('unknown browser - ' + browserName); diff --git a/tests/page/pageTestApi.ts b/tests/page/pageTestApi.ts index cf497e76c0..1ccfd608e9 100644 --- a/tests/page/pageTestApi.ts +++ b/tests/page/pageTestApi.ts @@ -29,7 +29,7 @@ export type PageWorkerFixtures = { screenshot: ScreenshotMode | { mode: ScreenshotMode } & Pick; trace: 'off' | 'on' | 'retain-on-failure' | 'on-first-retry' | 'retain-on-first-failure' | 'on-all-retries' | /** deprecated */ 'retry-with-trace'; video: VideoMode | { mode: VideoMode, size: ViewportSize }; - browserName: 'chromium' | 'firefox' | 'webkit'; + browserName: 'chromium' | 'firefox' | 'webkit' | '_bidiFirefox' | '_bidiChromium'; browserVersion: string; browserMajorVersion: number; electronMajorVersion: number; From aeb4d182f749f8185797b66f28bff6828d35b867 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Sat, 14 Sep 2024 10:17:07 +0200 Subject: [PATCH 24/41] feat(tracing): add .pwtrace to trace file extension (#32581) Closes https://github.com/microsoft/playwright/issues/32226 I've updated every mention of `.trace.zip` except for the release notes. --- docs/src/api/class-tracing.md | 40 +++++----- docs/src/test-global-setup-teardown-js.md | 4 +- docs/src/trace-viewer-intro-java-python.md | 14 ++-- docs/src/trace-viewer-intro-js.md | 2 +- docs/src/trace-viewer.md | 32 ++++---- packages/playwright-core/src/cli/program.ts | 2 +- .../src/server/trace/recorder/tracing.ts | 2 +- packages/playwright-core/types/types.d.ts | 8 +- packages/playwright/src/worker/testTracing.ts | 4 +- tests/config/traceViewerFixtures.ts | 2 +- .../playwright-electron-should-work.spec.ts | 6 +- tests/library/browsercontext-reuse.spec.ts | 2 +- .../library/chromium/connect-over-cdp.spec.ts | 2 +- tests/library/inspector/cli-codegen-2.spec.ts | 4 +- tests/library/trace-viewer.spec.ts | 14 ++-- tests/library/tracing.spec.ts | 56 ++++++------- tests/library/video.spec.ts | 2 +- .../playwright.artifacts.spec.ts | 70 ++++++++-------- .../playwright-test/playwright.reuse.spec.ts | 8 +- .../playwright-test/playwright.trace.spec.ts | 80 +++++++++---------- .../reporter-attachment.spec.ts | 6 +- 21 files changed, 180 insertions(+), 180 deletions(-) diff --git a/docs/src/api/class-tracing.md b/docs/src/api/class-tracing.md index 6e7541e4cb..f35e9e53ff 100644 --- a/docs/src/api/class-tracing.md +++ b/docs/src/api/class-tracing.md @@ -11,7 +11,7 @@ const context = await browser.newContext(); await context.tracing.start({ screenshots: true, snapshots: true }); const page = await context.newPage(); await page.goto('https://playwright.dev'); -await context.tracing.stop({ path: 'trace.zip' }); +await context.tracing.stop({ path: 'trace.pwtrace.zip' }); ``` ```java @@ -23,7 +23,7 @@ context.tracing().start(new Tracing.StartOptions() Page page = context.newPage(); page.navigate("https://playwright.dev"); context.tracing().stop(new Tracing.StopOptions() - .setPath(Paths.get("trace.zip"))); + .setPath(Paths.get("trace.pwtrace.zip"))); ``` ```python async @@ -32,7 +32,7 @@ context = await browser.new_context() await context.tracing.start(screenshots=True, snapshots=True) page = await context.new_page() await page.goto("https://playwright.dev") -await context.tracing.stop(path = "trace.zip") +await context.tracing.stop(path = "trace.pwtrace.zip") ``` ```python sync @@ -41,7 +41,7 @@ context = browser.new_context() context.tracing.start(screenshots=True, snapshots=True) page = context.new_page() page.goto("https://playwright.dev") -context.tracing.stop(path = "trace.zip") +context.tracing.stop(path = "trace.pwtrace.zip") ``` ```csharp @@ -57,7 +57,7 @@ var page = await context.NewPageAsync(); await page.GotoAsync("https://playwright.dev"); await context.Tracing.StopAsync(new() { - Path = "trace.zip" + Path = "trace.pwtrace.zip" }); ``` @@ -72,7 +72,7 @@ Start tracing. await context.tracing.start({ screenshots: true, snapshots: true }); const page = await context.newPage(); await page.goto('https://playwright.dev'); -await context.tracing.stop({ path: 'trace.zip' }); +await context.tracing.stop({ path: 'trace.pwtrace.zip' }); ``` ```java @@ -82,21 +82,21 @@ context.tracing().start(new Tracing.StartOptions() Page page = context.newPage(); page.navigate("https://playwright.dev"); context.tracing().stop(new Tracing.StopOptions() - .setPath(Paths.get("trace.zip"))); + .setPath(Paths.get("trace.pwtrace.zip"))); ``` ```python async await context.tracing.start(screenshots=True, snapshots=True) page = await context.new_page() await page.goto("https://playwright.dev") -await context.tracing.stop(path = "trace.zip") +await context.tracing.stop(path = "trace.pwtrace.zip") ``` ```python sync context.tracing.start(screenshots=True, snapshots=True) page = context.new_page() page.goto("https://playwright.dev") -context.tracing.stop(path = "trace.zip") +context.tracing.stop(path = "trace.pwtrace.zip") ``` ```csharp @@ -112,7 +112,7 @@ var page = await context.NewPageAsync(); await page.GotoAsync("https://playwright.dev"); await context.Tracing.StopAsync(new() { - Path = "trace.zip" + Path = "trace.pwtrace.zip" }); ``` @@ -177,12 +177,12 @@ await page.goto('https://playwright.dev'); await context.tracing.startChunk(); await page.getByText('Get Started').click(); // Everything between startChunk and stopChunk will be recorded in the trace. -await context.tracing.stopChunk({ path: 'trace1.zip' }); +await context.tracing.stopChunk({ path: 'trace1.pwtrace.zip' }); await context.tracing.startChunk(); await page.goto('http://example.com'); // Save a second trace file with different actions. -await context.tracing.stopChunk({ path: 'trace2.zip' }); +await context.tracing.stopChunk({ path: 'trace2.pwtrace.zip' }); ``` ```java @@ -196,13 +196,13 @@ context.tracing().startChunk(); page.getByText("Get Started").click(); // Everything between startChunk and stopChunk will be recorded in the trace. context.tracing().stopChunk(new Tracing.StopChunkOptions() - .setPath(Paths.get("trace1.zip"))); + .setPath(Paths.get("trace1.pwtrace.zip"))); context.tracing().startChunk(); page.navigate("http://example.com"); // Save a second trace file with different actions. context.tracing().stopChunk(new Tracing.StopChunkOptions() - .setPath(Paths.get("trace2.zip"))); + .setPath(Paths.get("trace2.pwtrace.zip"))); ``` ```python async @@ -213,12 +213,12 @@ await page.goto("https://playwright.dev") await context.tracing.start_chunk() await page.get_by_text("Get Started").click() # Everything between start_chunk and stop_chunk will be recorded in the trace. -await context.tracing.stop_chunk(path = "trace1.zip") +await context.tracing.stop_chunk(path = "trace1.pwtrace.zip") await context.tracing.start_chunk() await page.goto("http://example.com") # Save a second trace file with different actions. -await context.tracing.stop_chunk(path = "trace2.zip") +await context.tracing.stop_chunk(path = "trace2.pwtrace.zip") ``` ```python sync @@ -229,12 +229,12 @@ page.goto("https://playwright.dev") context.tracing.start_chunk() page.get_by_text("Get Started").click() # Everything between start_chunk and stop_chunk will be recorded in the trace. -context.tracing.stop_chunk(path = "trace1.zip") +context.tracing.stop_chunk(path = "trace1.pwtrace.zip") context.tracing.start_chunk() page.goto("http://example.com") # Save a second trace file with different actions. -context.tracing.stop_chunk(path = "trace2.zip") +context.tracing.stop_chunk(path = "trace2.pwtrace.zip") ``` ```csharp @@ -254,7 +254,7 @@ await page.GetByText("Get Started").ClickAsync(); // Everything between StartChunkAsync and StopChunkAsync will be recorded in the trace. await context.Tracing.StopChunkAsync(new() { - Path = "trace1.zip" + Path = "trace1.pwtrace.zip" }); await context.Tracing.StartChunkAsync(); @@ -262,7 +262,7 @@ await page.GotoAsync("http://example.com"); // Save a second trace file with different actions. await context.Tracing.StopChunkAsync(new() { - Path = "trace2.zip" + Path = "trace2.pwtrace.zip" }); ``` diff --git a/docs/src/test-global-setup-teardown-js.md b/docs/src/test-global-setup-teardown-js.md index 883bdf25d6..0bb05cd78d 100644 --- a/docs/src/test-global-setup-teardown-js.md +++ b/docs/src/test-global-setup-teardown-js.md @@ -238,12 +238,12 @@ async function globalSetup(config: FullConfig) { await page.getByText('Sign in').click(); await context.storageState({ path: storageState as string }); await context.tracing.stop({ - path: './test-results/setup-trace.zip', + path: './test-results/setup-trace.pwtrace.zip', }); await browser.close(); } catch (error) { await context.tracing.stop({ - path: './test-results/failed-setup-trace.zip', + path: './test-results/failed-setup-trace.pwtrace.zip', }); await browser.close(); throw error; diff --git a/docs/src/trace-viewer-intro-java-python.md b/docs/src/trace-viewer-intro-java-python.md index 79a415e54a..db4f096b9e 100644 --- a/docs/src/trace-viewer-intro-java-python.md +++ b/docs/src/trace-viewer-intro-java-python.md @@ -25,7 +25,7 @@ Options for tracing are: - `off`: Do not record trace. (default) - `retain-on-failure`: Record trace for each test, but remove all traces from successful test runs. -This will record the trace and place it into the file named `trace.zip` in your `test-results` directory. +This will record the trace and place it into the file named `trace.pwtrace.zip` in your `test-results` directory.
If you are not using Pytest, click here to learn how to record traces. @@ -41,7 +41,7 @@ page = await context.new_page() await page.goto("https://playwright.dev") # Stop tracing and export it into a zip archive. -await context.tracing.stop(path = "trace.zip") +await context.tracing.stop(path = "trace.pwtrace.zip") ``` ```python sync @@ -55,7 +55,7 @@ page = context.new_page() page.goto("https://playwright.dev") # Stop tracing and export it into a zip archive. -context.tracing.stop(path = "trace.zip") +context.tracing.stop(path = "trace.pwtrace.zip") ```
@@ -80,22 +80,22 @@ page.navigate("https://playwright.dev"); // Stop tracing and export it into a zip archive. context.tracing().stop(new Tracing.StopOptions() - .setPath(Paths.get("trace.zip"))); + .setPath(Paths.get("trace.pwtrace.zip"))); ``` -This will record the trace and place it into the file named `trace.zip`. +This will record the trace and place it into the file named `trace.pwtrace.zip`. ## Opening the trace You can open the saved trace using the Playwright CLI or in your browser on [`trace.playwright.dev`](https://trace.playwright.dev). Make sure to add the full path to where your trace's zip file is located. Once opened you can click on each action or use the timeline to see the state of the page before and after each action. You can also inspect the log, source and network during each step of the test. The trace viewer creates a DOM snapshot so you can fully interact with it, open devtools etc. ```bash java -mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace trace.zip" +mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace trace.pwtrace.zip" ``` ```bash python -playwright show-trace trace.zip +playwright show-trace trace.pwtrace.zip ``` ###### diff --git a/docs/src/trace-viewer-intro-js.md b/docs/src/trace-viewer-intro-js.md index a950689468..b7ac93dd9c 100644 --- a/docs/src/trace-viewer-intro-js.md +++ b/docs/src/trace-viewer-intro-js.md @@ -22,7 +22,7 @@ Playwright Trace Viewer is a GUI tool that lets you explore recorded Playwright ## Recording a Trace -By default the [playwright.config](./trace-viewer.md#recording-a-trace-on-ci) file will contain the configuration needed to create a `trace.zip` file for each test. Traces are setup to run `on-first-retry` meaning they will be run on the first retry of a failed test. Also `retries` are set to 2 when running on CI and 0 locally. This means the traces will be recorded on the first retry of a failed test but not on the first run and not on the second retry. +By default the [playwright.config](./trace-viewer.md#recording-a-trace-on-ci) file will contain the configuration needed to create a `trace.pwtrace.zip` file for each test. Traces are setup to run `on-first-retry` meaning they will be run on the first retry of a failed test. Also `retries` are set to 2 when running on CI and 0 locally. This means the traces will be recorded on the first retry of a failed test but not on the first run and not on the second retry. ```js title="playwright.config.ts" import { defineConfig } from '@playwright/test'; diff --git a/docs/src/trace-viewer.md b/docs/src/trace-viewer.md index 207646c1d7..ad26278930 100644 --- a/docs/src/trace-viewer.md +++ b/docs/src/trace-viewer.md @@ -132,7 +132,7 @@ npx playwright show-report * langs: js Traces should be run on continuous integration on the first retry of a failed test -by setting the `trace: 'on-first-retry'` option in the test configuration file. This will produce a `trace.zip` file for each test that was retried. +by setting the `trace: 'on-first-retry'` option in the test configuration file. This will produce a `trace.pwtrace.zip` file for each test that was retried. ```js tab=js-test title="playwright.config.ts" import { defineConfig } from '@playwright/test'; @@ -155,7 +155,7 @@ const page = await context.newPage(); await page.goto('https://playwright.dev'); // Stop tracing and export it into a zip archive. -await context.tracing.stop({ path: 'trace.zip' }); +await context.tracing.stop({ path: 'trace.pwtrace.zip' }); ``` Available options to record a trace: @@ -185,7 +185,7 @@ Options for tracing are: - `off`: Do not record trace. (default) - `retain-on-failure`: Record trace for each test, but remove all traces from successful test runs. -This will record the trace and place it into the file named `trace.zip` in your `test-results` directory. +This will record the trace and place it into the file named `trace.pwtrace.zip` in your `test-results` directory.
If you are not using Pytest, click here to learn how to record traces. @@ -201,7 +201,7 @@ page = await context.new_page() await page.goto("https://playwright.dev") # Stop tracing and export it into a zip archive. -await context.tracing.stop(path = "trace.zip") +await context.tracing.stop(path = "trace.pwtrace.zip") ``` ```python sync @@ -215,7 +215,7 @@ page = context.new_page() page.goto("https://playwright.dev") # Stop tracing and export it into a zip archive. -context.tracing.stop(path = "trace.zip") +context.tracing.stop(path = "trace.pwtrace.zip") ```
@@ -240,10 +240,10 @@ page.navigate("https://playwright.dev"); // Stop tracing and export it into a zip archive. context.tracing().stop(new Tracing.StopOptions() - .setPath(Paths.get("trace.zip"))); + .setPath(Paths.get("trace.pwtracezip"))); ``` -This will record the trace and place it into the file named `trace.zip`. +This will record the trace and place it into the file named `trace.pwtrace.zip`. ## Recording a trace * langs: csharp @@ -466,22 +466,22 @@ public class ExampleTest : PageTest ## Opening the trace -You can open the saved trace using the Playwright CLI or in your browser on [`trace.playwright.dev`](https://trace.playwright.dev). Make sure to add the full path to where your `trace.zip` file is located. +You can open the saved trace using the Playwright CLI or in your browser on [`trace.playwright.dev`](https://trace.playwright.dev). Make sure to add the full path to where your `trace.pwtrace.zip` file is located. ```bash js -npx playwright show-trace path/to/trace.zip +npx playwright show-trace path/to/trace.pwtrace.zip ``` ```bash java -mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace trace.zip" +mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace trace.pwtrace.zip" ``` ```bash python -playwright show-trace trace.zip +playwright show-trace trace.pwtrace.zip ``` ```bash csharp -pwsh bin/Debug/netX/playwright.ps1 show-trace trace.zip +pwsh bin/Debug/netX/playwright.ps1 show-trace trace.pwtrace.zip ``` ## Using [trace.playwright.dev](https://trace.playwright.dev) @@ -496,19 +496,19 @@ pwsh bin/Debug/netX/playwright.ps1 show-trace trace.zip You can open remote traces using its URL. They could be generated on a CI run which makes it easy to view the remote trace without having to manually download the file. ```bash js -npx playwright show-trace https://example.com/trace.zip +npx playwright show-trace https://example.com/trace.pwtrace.zip ``` ```bash java -mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace https://example.com/trace.zip" +mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace https://example.com/trace.pwtrace.zip" ``` ```bash python -playwright show-trace https://example.com/trace.zip +playwright show-trace https://example.com/trace.pwtrace.zip ``` ```bash csharp -pwsh bin/Debug/netX/playwright.ps1 show-trace https://example.com/trace.zip +pwsh bin/Debug/netX/playwright.ps1 show-trace https://example.com/trace.pwtrace.zip ``` diff --git a/packages/playwright-core/src/cli/program.ts b/packages/playwright-core/src/cli/program.ts index d8fa8230c6..6a36210f8e 100644 --- a/packages/playwright-core/src/cli/program.ts +++ b/packages/playwright-core/src/cli/program.ts @@ -318,7 +318,7 @@ program }).addHelpText('afterAll', ` Examples: - $ show-trace https://example.com/trace.zip`); + $ show-trace https://example.com/trace.pwtrace.zip`); type Options = { browser: string; diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index b09bbe3134..13f15b6222 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -310,7 +310,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps this._fs.copyFile(this._state.networkFile, newNetworkFile); - const zipFileName = this._state.traceFile + '.zip'; + const zipFileName = this._state.traceFile + '.pwtrace.zip'; if (params.mode === 'archive') this._fs.zip(entries, zipFileName); diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 80d99a732b..e88e1b473b 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -19930,7 +19930,7 @@ export interface Touchscreen { * await context.tracing.start({ screenshots: true, snapshots: true }); * const page = await context.newPage(); * await page.goto('https://playwright.dev'); - * await context.tracing.stop({ path: 'trace.zip' }); + * await context.tracing.stop({ path: 'trace.pwtrace.zip' }); * ``` * */ @@ -19944,7 +19944,7 @@ export interface Tracing { * await context.tracing.start({ screenshots: true, snapshots: true }); * const page = await context.newPage(); * await page.goto('https://playwright.dev'); - * await context.tracing.stop({ path: 'trace.zip' }); + * await context.tracing.stop({ path: 'trace.pwtrace.zip' }); * ``` * * @param options @@ -19999,12 +19999,12 @@ export interface Tracing { * await context.tracing.startChunk(); * await page.getByText('Get Started').click(); * // Everything between startChunk and stopChunk will be recorded in the trace. - * await context.tracing.stopChunk({ path: 'trace1.zip' }); + * await context.tracing.stopChunk({ path: 'trace1.pwtrace.zip' }); * * await context.tracing.startChunk(); * await page.goto('http://example.com'); * // Save a second trace file with different actions. - * await context.tracing.stopChunk({ path: 'trace2.zip' }); + * await context.tracing.stopChunk({ path: 'trace2.pwtrace.zip' }); * ``` * * @param options diff --git a/packages/playwright/src/worker/testTracing.ts b/packages/playwright/src/worker/testTracing.ts index fed7fdde7e..1b58c5ebf6 100644 --- a/packages/playwright/src/worker/testTracing.ts +++ b/packages/playwright/src/worker/testTracing.ts @@ -131,7 +131,7 @@ export class TestTracing { } generateNextTraceRecordingPath() { - const file = path.join(this._artifactsDir, createGuid() + '.zip'); + const file = path.join(this._artifactsDir, createGuid() + '.pwtrace.zip'); this._temporaryTraceFiles.push(file); return file; } @@ -214,7 +214,7 @@ export class TestTracing { }); }); - const tracePath = this._testInfo.outputPath('trace.zip'); + const tracePath = this._testInfo.outputPath('trace.pwtrace.zip'); await mergeTraceFiles(tracePath, this._temporaryTraceFiles); this._testInfo.attachments.push({ name: 'trace', path: tracePath, contentType: 'application/zip' }); } diff --git a/tests/config/traceViewerFixtures.ts b/tests/config/traceViewerFixtures.ts index 3b79a55e98..936c8ea998 100644 --- a/tests/config/traceViewerFixtures.ts +++ b/tests/config/traceViewerFixtures.ts @@ -128,7 +128,7 @@ export const traceViewerFixtures: Fixtures { await use(async (body: () => Promise, optsOverrides = {}) => { - const traceFile = testInfo.outputPath('trace.zip'); + const traceFile = testInfo.outputPath('trace.pwtrace.zip'); await context.tracing.start({ snapshots: true, screenshots: true, sources: true, ...optsOverrides }); await body(); await context.tracing.stop({ path: traceFile }); diff --git a/tests/installation/playwright-electron-should-work.spec.ts b/tests/installation/playwright-electron-should-work.spec.ts index 5c6ced0948..32b529224f 100755 --- a/tests/installation/playwright-electron-should-work.spec.ts +++ b/tests/installation/playwright-electron-should-work.spec.ts @@ -58,7 +58,7 @@ test('should work when wrapped inside @playwright/test and trace is enabled', as await expect(window).toHaveTitle(/Playwright/); await expect(window.getByRole('heading')).toHaveText('Playwright'); - const path = test.info().outputPath('electron-trace.zip'); + const path = test.info().outputPath('electron-trace.pwtrace.zip'); if (trace) { await window.context().tracing.stop({ path }); test.info().attachments.push({ name: 'trace', path, contentType: 'application/zip' }); @@ -73,9 +73,9 @@ test('should work when wrapped inside @playwright/test and trace is enabled', as }); const traces = [ // our actual trace. - path.join(tmpWorkspace, 'test-results', 'electron-with-tracing-should-work', 'electron-trace.zip'), + path.join(tmpWorkspace, 'test-results', 'electron-with-tracing-should-work', 'electron-trace.pwtrace.zip'), // contains the expect() calls - path.join(tmpWorkspace, 'test-results', 'electron-with-tracing-should-work', 'trace.zip'), + path.join(tmpWorkspace, 'test-results', 'electron-with-tracing-should-work', 'trace.pwtrace.zip'), ]; for (const trace of traces) expect(fs.existsSync(trace)).toBe(true); diff --git a/tests/library/browsercontext-reuse.spec.ts b/tests/library/browsercontext-reuse.spec.ts index 14590a3ae3..4f7c3e611c 100644 --- a/tests/library/browsercontext-reuse.spec.ts +++ b/tests/library/browsercontext-reuse.spec.ts @@ -248,7 +248,7 @@ test('should reset tracing', async ({ reusedContext, trace }, testInfo) => { page = context.pages()[0]; await page.evaluate('2 + 2'); - const error = await context.tracing.stopChunk({ path: testInfo.outputPath('trace.zip') }).catch(e => e); + const error = await context.tracing.stopChunk({ path: testInfo.outputPath('trace.pwtrace.zip') }).catch(e => e); expect(error.message).toContain('Must start tracing before stopping'); }); diff --git a/tests/library/chromium/connect-over-cdp.spec.ts b/tests/library/chromium/connect-over-cdp.spec.ts index a473539e8e..55070f3ec8 100644 --- a/tests/library/chromium/connect-over-cdp.spec.ts +++ b/tests/library/chromium/connect-over-cdp.spec.ts @@ -489,7 +489,7 @@ test('should allow tracing over cdp session', async ({ browserType, trace }, tes await context.tracing.start({ screenshots: true, snapshots: true }); const page = await context.newPage(); await page.evaluate(() => 2 + 2); - const traceZip = testInfo.outputPath('trace.zip'); + const traceZip = testInfo.outputPath('trace.pwtrace.zip'); await context.tracing.stop({ path: traceZip }); await cdpBrowser.close(); expect(fs.existsSync(traceZip)).toBe(true); diff --git a/tests/library/inspector/cli-codegen-2.spec.ts b/tests/library/inspector/cli-codegen-2.spec.ts index ef67cd8b93..610d87fc05 100644 --- a/tests/library/inspector/cli-codegen-2.spec.ts +++ b/tests/library/inspector/cli-codegen-2.spec.ts @@ -491,7 +491,7 @@ await page1.GotoAsync("about:blank?foo");`); }); test('should --save-trace', async ({ runCLI }, testInfo) => { - const traceFileName = testInfo.outputPath('trace.zip'); + const traceFileName = testInfo.outputPath('trace.pwtrace.zip'); const cli = runCLI([`--save-trace=${traceFileName}`], { autoExitWhen: ' ', }); @@ -502,7 +502,7 @@ await page1.GotoAsync("about:blank?foo");`); test('should save assets via SIGINT', async ({ runCLI, platform }, testInfo) => { test.skip(platform === 'win32', 'SIGINT not supported on Windows'); - const traceFileName = testInfo.outputPath('trace.zip'); + const traceFileName = testInfo.outputPath('trace.pwtrace.zip'); const storageFileName = testInfo.outputPath('auth.json'); const harFileName = testInfo.outputPath('har.har'); const cli = runCLI([`--save-trace=${traceFileName}`, `--save-storage=${storageFileName}`, `--save-har=${harFileName}`]); diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index 2b00949b19..0c6ef86f25 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -74,7 +74,7 @@ test.beforeAll(async function recordTrace({ browser, browserName, browserType, s runBeforeCloseBrowserContext: async () => { await page.hover('body'); await page.close(); - traceFile = path.join(workerInfo.project.outputDir, String(workerInfo.workerIndex), browserName, 'trace.zip'); + traceFile = path.join(workerInfo.project.outputDir, String(workerInfo.workerIndex), browserName, 'trace.pwtrace.zip'); await context.tracing.stop({ path: traceFile }); } }; @@ -698,7 +698,7 @@ test('should handle file URIs', async ({ page, runAndTrace, browserName }) => { }); test('should preserve currentSrc', async ({ browser, server, showTraceViewer }) => { - const traceFile = test.info().outputPath('trace.zip'); + const traceFile = test.info().outputPath('trace.pwtrace.zip'); const page = await browser.newPage({ deviceScaleFactor: 3 }); await page.context().tracing.start({ snapshots: true, screenshots: true, sources: true }); await page.setViewportSize({ width: 300, height: 300 }); @@ -1294,7 +1294,7 @@ test('should highlight locator in iframe while typing', async ({ page, runAndTra }); test('should preserve noscript when javascript is disabled', async ({ browser, server, showTraceViewer }) => { - const traceFile = test.info().outputPath('trace.zip'); + const traceFile = test.info().outputPath('trace.pwtrace.zip'); const page = await browser.newPage({ javaScriptEnabled: false }); await page.context().tracing.start({ snapshots: true, screenshots: true, sources: true }); await page.goto(server.EMPTY_PAGE); @@ -1311,8 +1311,8 @@ test('should preserve noscript when javascript is disabled', async ({ browser, s await expect(frame.getByText('javascript is disabled!')).toBeVisible(); }); -test('should remove noscript by default', async ({ browser, server, showTraceViewer, browserType }) => { - const traceFile = test.info().outputPath('trace.zip'); +test('should remove noscript by default', async ({ browser, server, showTraceViewer }) => { + const traceFile = test.info().outputPath('trace.pwtrace.zip'); const page = await browser.newPage({ javaScriptEnabled: undefined }); await page.context().tracing.start({ snapshots: true, screenshots: true, sources: true }); await page.goto(server.EMPTY_PAGE); @@ -1329,8 +1329,8 @@ test('should remove noscript by default', async ({ browser, server, showTraceVie await expect(frame.getByText('Enable JavaScript to run this app.')).toBeHidden(); }); -test('should remove noscript when javaScriptEnabled is set to true', async ({ browser, server, showTraceViewer, browserType }) => { - const traceFile = test.info().outputPath('trace.zip'); +test('should remove noscript when javaScriptEnabled is set to true', async ({ browser, server, showTraceViewer }) => { + const traceFile = test.info().outputPath('trace.pwtrace.zip'); const page = await browser.newPage({ javaScriptEnabled: true }); await page.context().tracing.start({ snapshots: true, screenshots: true, sources: true }); await page.goto(server.EMPTY_PAGE); diff --git a/tests/library/tracing.spec.ts b/tests/library/tracing.spec.ts index c82db377aa..770ea0362c 100644 --- a/tests/library/tracing.spec.ts +++ b/tests/library/tracing.spec.ts @@ -37,9 +37,9 @@ test('should collect trace with resources, but no js', async ({ context, page, s await page.locator('input[type="file"]').setInputFiles(asset('file-to-upload.txt')); await page.waitForTimeout(2000); // Give it some time to produce screenshots. await page.close(); - await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); + await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') }); - const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace.zip')); + const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip')); expect(events[0].type).toBe('context-options'); expect(actions).toEqual([ 'page.goto', @@ -81,8 +81,8 @@ test('should use the correct apiName for event driven callbacks', async ({ conte }); await page.evaluate(() => alert('yo')); - await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); - const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace.zip')); + await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') }); + const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip')); expect(events[0].type).toBe('context-options'); expect(actions).toEqual([ 'page.route', @@ -102,9 +102,9 @@ test('should not collect snapshots by default', async ({ context, page, server } await page.setContent(''); await page.click('"Click"'); await page.close(); - await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); + await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') }); - const { events } = await parseTraceRaw(testInfo.outputPath('trace.zip')); + const { events } = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip')); expect(events.some(e => e.type === 'frame-snapshot')).toBeFalsy(); expect(events.some(e => e.type === 'resource-snapshot')).toBeFalsy(); }); @@ -113,8 +113,8 @@ test('should not include buffers in the trace', async ({ context, page, server } await context.tracing.start({ snapshots: true }); await page.goto(server.PREFIX + '/empty.html'); await page.screenshot(); - await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); - const { actionObjects } = await parseTraceRaw(testInfo.outputPath('trace.zip')); + await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') }); + const { actionObjects } = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip')); const screenshotEvent = actionObjects.find(a => a.apiName === 'page.screenshot'); expect(screenshotEvent.beforeSnapshot).toBeTruthy(); expect(screenshotEvent.afterSnapshot).toBeTruthy(); @@ -129,9 +129,9 @@ test('should exclude internal pages', async ({ browserName, context, page, serve await context.tracing.start(); await context.storageState(); await page.close(); - await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); + await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') }); - const trace = await parseTraceRaw(testInfo.outputPath('trace.zip')); + const trace = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip')); const pageIds = new Set(); trace.events.forEach(e => { const pageId = e.pageId; @@ -144,8 +144,8 @@ test('should exclude internal pages', async ({ browserName, context, page, serve test('should include context API requests', async ({ browserName, context, page, server }, testInfo) => { await context.tracing.start({ snapshots: true }); await page.request.post(server.PREFIX + '/simple.json', { data: { foo: 'bar' } }); - await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); - const { events } = await parseTraceRaw(testInfo.outputPath('trace.zip')); + await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') }); + const { events } = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip')); const postEvent = events.find(e => e.apiName === 'apiRequestContext.post'); expect(postEvent).toBeTruthy(); const harEntry = events.find(e => e.type === 'resource-snapshot'); @@ -428,9 +428,9 @@ for (const params of [ await page.setContent(''); await page.evaluate(() => new Promise(window.builtinRequestAnimationFrame)); } - await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); + await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') }); - const { events, resources } = await parseTraceRaw(testInfo.outputPath('trace.zip')); + const { events, resources } = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip')); const frames = events.filter(e => e.type === 'screencast-frame'); // Check all frame sizes. @@ -460,10 +460,10 @@ test('should include interrupted actions', async ({ context, page, server }, tes await page.goto(server.EMPTY_PAGE); await page.setContent(''); page.click('"ClickNoButton"').catch(() => {}); - await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); + await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') }); await context.close(); - const { events } = await parseTraceRaw(testInfo.outputPath('trace.zip')); + const { events } = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip')); const clickEvent = events.find(e => e.apiName === 'page.click'); expect(clickEvent).toBeTruthy(); }); @@ -475,7 +475,7 @@ test('should throw when starting with different options', async ({ context }) => }); test('should throw when stopping without start', async ({ context }, testInfo) => { - const error = await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }).catch(e => e); + const error = await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') }).catch(e => e); expect(error.message).toContain('Must start tracing before stopping'); }); @@ -492,7 +492,7 @@ test('should work with multiple chunks', async ({ context, page, server }, testI await page.click('"Click"'); page.click('"ClickNoButton"', { timeout: 0 }).catch(() => {}); await page.evaluate(() => {}); - await context.tracing.stopChunk({ path: testInfo.outputPath('trace.zip') }); + await context.tracing.stopChunk({ path: testInfo.outputPath('trace.pwtrace.zip') }); await context.tracing.startChunk(); await page.hover('"Click"'); @@ -502,7 +502,7 @@ test('should work with multiple chunks', async ({ context, page, server }, testI await page.click('"Click"'); await context.tracing.stopChunk(); // Should stop without a path. - const trace1 = await parseTraceRaw(testInfo.outputPath('trace.zip')); + const trace1 = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip')); expect(trace1.events[0].type).toBe('context-options'); expect(trace1.actions).toEqual([ 'page.setContent', @@ -533,7 +533,7 @@ test('should export trace concurrently to second navigation', async ({ context, await page.waitForTimeout(timeout); await Promise.all([ promise, - context.tracing.stop({ path: testInfo.outputPath('trace.zip') }), + context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') }), ]); } }); @@ -561,9 +561,9 @@ test('should ignore iframes in head', async ({ context, page, server }, testInfo await context.tracing.start({ screenshots: true, snapshots: true }); await page.click('button'); - await context.tracing.stopChunk({ path: testInfo.outputPath('trace.zip') }); + await context.tracing.stopChunk({ path: testInfo.outputPath('trace.pwtrace.zip') }); - const trace = await parseTraceRaw(testInfo.outputPath('trace.zip')); + const trace = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip')); expect(trace.actions).toEqual([ 'page.click', ]); @@ -581,7 +581,7 @@ test('should hide internal stack frames', async ({ context, page }, testInfo) => await page.setContent(`
Click me
`); await page.click('div'); await evalPromise; - const tracePath = testInfo.outputPath('trace.zip'); + const tracePath = testInfo.outputPath('trace.pwtrace.zip'); await context.tracing.stop({ path: tracePath }); const trace = await parseTraceRaw(tracePath); @@ -602,7 +602,7 @@ test('should hide internal stack frames in expect', async ({ context, page }, te await page.click('div'); await expect(page.locator('div')).toBeVisible(); await expectPromise; - const tracePath = testInfo.outputPath('trace.zip'); + const tracePath = testInfo.outputPath('trace.pwtrace.zip'); await context.tracing.stop({ path: tracePath }); const trace = await parseTraceRaw(tracePath); @@ -616,7 +616,7 @@ test('should record global request trace', async ({ request, context, server }, await (request as any)._tracing.start({ snapshots: true }); const url = server.PREFIX + '/simple.json'; await request.get(url); - const tracePath = testInfo.outputPath('trace.zip'); + const tracePath = testInfo.outputPath('trace.pwtrace.zip'); await (request as any)._tracing.stop({ path: tracePath }); const trace = await parseTraceRaw(tracePath); @@ -649,7 +649,7 @@ test('should store global request traces separately', async ({ request, server, request.get(url), request2.post(url) ]); - const tracePath = testInfo.outputPath('trace.zip'); + const tracePath = testInfo.outputPath('trace.pwtrace.zip'); const trace2Path = testInfo.outputPath('trace2.zip'); await Promise.all([ (request as any)._tracing.stop({ path: tracePath }), @@ -682,7 +682,7 @@ test('should store postData for global request', async ({ request, server }, tes await request.post(url, { data: 'test' }); - const tracePath = testInfo.outputPath('trace.zip'); + const tracePath = testInfo.outputPath('trace.pwtrace.zip'); await (request as any)._tracing.stop({ path: tracePath }); const trace = await parseTraceRaw(tracePath); @@ -755,7 +755,7 @@ test('should flush console events on tracing stop', async ({ context, page }, te }); }); await promise; - const tracePath = testInfo.outputPath('trace.zip'); + const tracePath = testInfo.outputPath('trace.pwtrace.zip'); await context.tracing.stop({ path: tracePath }); const trace = await parseTraceRaw(tracePath); const events = trace.events.filter(e => e.type === 'console'); diff --git a/tests/library/video.spec.ts b/tests/library/video.spec.ts index 39dcaecbc6..1413ba1ed5 100644 --- a/tests/library/video.spec.ts +++ b/tests/library/video.spec.ts @@ -799,7 +799,7 @@ it.describe('screencast', () => { it.fixme(!headless || !!process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW, 'different trace screencast image size on all browsers'); const size = { width: 500, height: 400 }; - const traceFile = testInfo.outputPath('trace.zip'); + const traceFile = testInfo.outputPath('trace.pwtrace.zip'); const context = await browser.newContext({ recordVideo: { diff --git a/tests/playwright-test/playwright.artifacts.spec.ts b/tests/playwright-test/playwright.artifacts.spec.ts index b666aa4b70..cc86c6c584 100644 --- a/tests/playwright-test/playwright.artifacts.spec.ts +++ b/tests/playwright-test/playwright.artifacts.spec.ts @@ -235,25 +235,25 @@ test('should work with trace: on', async ({ runInlineTest }, testInfo) => { expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ '.last-run.json', 'artifacts-failing', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-own-context-failing', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-own-context-passing', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-passing', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-persistent-failing', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-persistent-passing', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-shared-shared-failing', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-shared-shared-passing', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-two-contexts', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-two-contexts-failing', - ' trace.zip', + ' trace.pwtrace.zip', ]); }); @@ -271,15 +271,15 @@ test('should work with trace: retain-on-failure', async ({ runInlineTest }, test expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ '.last-run.json', 'artifacts-failing', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-own-context-failing', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-persistent-failing', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-shared-shared-failing', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-two-contexts-failing', - ' trace.zip', + ' trace.pwtrace.zip', ]); }); @@ -297,15 +297,15 @@ test('should work with trace: on-first-retry', async ({ runInlineTest }, testInf expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ '.last-run.json', 'artifacts-failing-retry1', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-own-context-failing-retry1', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-persistent-failing-retry1', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-shared-shared-failing-retry1', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-two-contexts-failing-retry1', - ' trace.zip', + ' trace.pwtrace.zip', ]); }); @@ -323,25 +323,25 @@ test('should work with trace: on-all-retries', async ({ runInlineTest }, testInf expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ '.last-run.json', 'artifacts-failing-retry1', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-failing-retry2', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-own-context-failing-retry1', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-own-context-failing-retry2', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-persistent-failing-retry1', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-persistent-failing-retry2', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-shared-shared-failing-retry1', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-shared-shared-failing-retry2', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-two-contexts-failing-retry1', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-two-contexts-failing-retry2', - ' trace.zip', + ' trace.pwtrace.zip', ]); }); @@ -359,15 +359,15 @@ test('should work with trace: retain-on-first-failure', async ({ runInlineTest } expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ '.last-run.json', 'artifacts-failing', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-own-context-failing', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-persistent-failing', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-shared-shared-failing', - ' trace.zip', + ' trace.pwtrace.zip', 'artifacts-two-contexts-failing', - ' trace.zip', + ' trace.pwtrace.zip', ]); }); diff --git a/tests/playwright-test/playwright.reuse.spec.ts b/tests/playwright-test/playwright.reuse.spec.ts index 0620195f65..76286679b9 100644 --- a/tests/playwright-test/playwright.reuse.spec.ts +++ b/tests/playwright-test/playwright.reuse.spec.ts @@ -114,7 +114,7 @@ test('should reuse context with trace if mode=when-possible', async ({ runInline expect(result.exitCode).toBe(0); expect(result.passed).toBe(2); - const trace1 = await parseTrace(testInfo.outputPath('test-results', 'reuse-one', 'trace.zip')); + const trace1 = await parseTrace(testInfo.outputPath('test-results', 'reuse-one', 'trace.pwtrace.zip')); expect(trace1.actionTree).toEqual([ 'Before Hooks', ' fixture: browser', @@ -131,7 +131,7 @@ test('should reuse context with trace if mode=when-possible', async ({ runInline expect(trace1.traceModel.storage().snapshotsForTest().length).toBeGreaterThan(0); expect(fs.existsSync(testInfo.outputPath('test-results', 'reuse-one', 'trace-1.zip'))).toBe(false); - const trace2 = await parseTrace(testInfo.outputPath('test-results', 'reuse-two', 'trace.zip')); + const trace2 = await parseTrace(testInfo.outputPath('test-results', 'reuse-two', 'trace.pwtrace.zip')); expect(trace2.actionTree).toEqual([ 'Before Hooks', ' fixture: context', @@ -533,6 +533,6 @@ test('should survive serial mode with tracing and reuse', async ({ runInlineTest expect(result.exitCode).toBe(0); expect(result.passed).toBe(2); - expect(fs.existsSync(testInfo.outputPath('test-results', 'reuse-one', 'trace.zip'))).toBe(true); - expect(fs.existsSync(testInfo.outputPath('test-results', 'reuse-two', 'trace.zip'))).toBe(true); + expect(fs.existsSync(testInfo.outputPath('test-results', 'reuse-one', 'trace.pwtrace.zip'))).toBe(true); + expect(fs.existsSync(testInfo.outputPath('test-results', 'reuse-two', 'trace.pwtrace.zip'))).toBe(true); }); diff --git a/tests/playwright-test/playwright.trace.spec.ts b/tests/playwright-test/playwright.trace.spec.ts index 5ca08925b4..a526eea44d 100644 --- a/tests/playwright-test/playwright.trace.spec.ts +++ b/tests/playwright-test/playwright.trace.spec.ts @@ -53,7 +53,7 @@ test('should stop tracing with trace: on-first-retry, when not retrying', async expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); expect(result.flaky).toBe(1); - expect(fs.existsSync(testInfo.outputPath('test-results', 'a-shared-flaky-retry1', 'trace.zip'))).toBeTruthy(); + expect(fs.existsSync(testInfo.outputPath('test-results', 'a-shared-flaky-retry1', 'trace.pwtrace.zip'))).toBeTruthy(); }); test('should record api trace', async ({ runInlineTest, server }, testInfo) => { @@ -86,7 +86,7 @@ test('should record api trace', async ({ runInlineTest, server }, testInfo) => { expect(result.passed).toBe(2); expect(result.failed).toBe(1); // One trace file for request context and one for each APIRequestContext - const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip')); + const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.pwtrace.zip')); expect(trace1.actionTree).toEqual([ 'Before Hooks', ' fixture: request', @@ -105,14 +105,14 @@ test('should record api trace', async ({ runInlineTest, server }, testInfo) => { ' fixture: request', ' apiRequestContext.dispose', ]); - const trace2 = await parseTrace(testInfo.outputPath('test-results', 'a-api-pass', 'trace.zip')); + const trace2 = await parseTrace(testInfo.outputPath('test-results', 'a-api-pass', 'trace.pwtrace.zip')); expect(trace2.actionTree).toEqual([ 'Before Hooks', 'apiRequest.newContext', 'apiRequestContext.get', 'After Hooks', ]); - const trace3 = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.zip')); + const trace3 = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.pwtrace.zip')); expect(trace3.actionTree).toEqual([ 'Before Hooks', ' fixture: request', @@ -204,7 +204,7 @@ test('should not mixup network files between contexts', async ({ runInlineTest, }, { workers: 1, timeout: 15000 }); expect(result.exitCode).toEqual(0); expect(result.passed).toBe(1); - expect(fs.existsSync(testInfo.outputPath('test-results', 'a-example', 'trace.zip'))).toBe(true); + expect(fs.existsSync(testInfo.outputPath('test-results', 'a-example', 'trace.pwtrace.zip'))).toBe(true); }); test('should save sources when requested', async ({ runInlineTest }, testInfo) => { @@ -224,7 +224,7 @@ test('should save sources when requested', async ({ runInlineTest }, testInfo) = `, }, { workers: 1 }); expect(result.exitCode).toEqual(0); - const { resources } = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip')); + const { resources } = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.pwtrace.zip')); expect([...resources.keys()].filter(name => name.startsWith('resources/src@'))).toHaveLength(1); }); @@ -248,7 +248,7 @@ test('should not save sources when not requested', async ({ runInlineTest }, tes `, }, { workers: 1 }); expect(result.exitCode).toEqual(0); - const { resources } = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip')); + const { resources } = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.pwtrace.zip')); expect([...resources.keys()].filter(name => name.startsWith('resources/src@'))).toHaveLength(0); }); @@ -283,8 +283,8 @@ test('should work in serial mode', async ({ runInlineTest }, testInfo) => { expect(result.exitCode).toBe(1); expect(result.passed).toBe(1); expect(result.failed).toBe(1); - expect(fs.existsSync(testInfo.outputPath('test-results', 'a-serial-passes', 'trace.zip'))).toBeFalsy(); - expect(fs.existsSync(testInfo.outputPath('test-results', 'a-serial-fails', 'trace.zip'))).toBeTruthy(); + expect(fs.existsSync(testInfo.outputPath('test-results', 'a-serial-passes', 'trace.pwtrace.zip'))).toBeFalsy(); + expect(fs.existsSync(testInfo.outputPath('test-results', 'a-serial-fails', 'trace.pwtrace.zip'))).toBeTruthy(); }); test('should not override trace file in afterAll', async ({ runInlineTest, server }, testInfo) => { @@ -313,7 +313,7 @@ test('should not override trace file in afterAll', async ({ runInlineTest, serve expect(result.exitCode).toBe(1); expect(result.passed).toBe(1); expect(result.failed).toBe(1); - const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip')); + const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-test-1', 'trace.pwtrace.zip')); expect(trace1.actionTree).toEqual([ 'Before Hooks', @@ -338,7 +338,7 @@ test('should not override trace file in afterAll', async ({ runInlineTest, serve ]); expect(trace1.errors).toEqual([`'oh no!'`]); - const error = await parseTrace(testInfo.outputPath('test-results', 'a-test-2', 'trace.zip')).catch(e => e); + const error = await parseTrace(testInfo.outputPath('test-results', 'a-test-2', 'trace.pwtrace.zip')).catch(e => e); expect(error).toBeTruthy(); }); @@ -366,8 +366,8 @@ test('should retain traces for interrupted tests', async ({ runInlineTest }, tes expect(result.exitCode).toBe(1); expect(result.failed).toBe(1); expect(result.interrupted).toBe(1); - expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip'))).toBeTruthy(); - expect(fs.existsSync(testInfo.outputPath('test-results', 'b-test-2', 'trace.zip'))).toBeTruthy(); + expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.pwtrace.zip'))).toBeTruthy(); + expect(fs.existsSync(testInfo.outputPath('test-results', 'b-test-2', 'trace.pwtrace.zip'))).toBeTruthy(); }); test('should respect --trace', async ({ runInlineTest }, testInfo) => { @@ -382,7 +382,7 @@ test('should respect --trace', async ({ runInlineTest }, testInfo) => { expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); - expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip'))).toBeTruthy(); + expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.pwtrace.zip'))).toBeTruthy(); }); test('should respect PW_TEST_DISABLE_TRACING', async ({ runInlineTest }, testInfo) => { @@ -400,7 +400,7 @@ test('should respect PW_TEST_DISABLE_TRACING', async ({ runInlineTest }, testInf expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); - expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip'))).toBe(false); + expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.pwtrace.zip'))).toBe(false); }); for (const mode of ['off', 'retain-on-failure', 'on-first-retry', 'on-all-retries', 'retain-on-first-failure']) { @@ -465,7 +465,7 @@ test(`trace:retain-on-failure should create trace if context is closed before fa }); `, }, { trace: 'retain-on-failure' }); - const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.zip'); + const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.pwtrace.zip'); const trace = await parseTrace(tracePath); expect(trace.apiNames).toContain('page.goto'); expect(result.failed).toBe(1); @@ -487,7 +487,7 @@ test(`trace:retain-on-failure should create trace if context is closed before fa }); `, }, { trace: 'retain-on-failure' }); - const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.zip'); + const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.pwtrace.zip'); const trace = await parseTrace(tracePath); expect(trace.apiNames).toContain('page.goto'); expect(result.failed).toBe(1); @@ -507,7 +507,7 @@ test(`trace:retain-on-failure should create trace if request context is disposed }); `, }, { trace: 'retain-on-failure' }); - const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.zip'); + const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.pwtrace.zip'); const trace = await parseTrace(tracePath); expect(trace.apiNames).toContain('apiRequestContext.get'); expect(result.failed).toBe(1); @@ -529,7 +529,7 @@ test('should include attachments by default', async ({ runInlineTest, server }, expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); - const trace = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip')); + const trace = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.pwtrace.zip')); expect(trace.apiNames).toEqual([ 'Before Hooks', `attach "foo"`, @@ -559,7 +559,7 @@ test('should opt out of attachments', async ({ runInlineTest, server }, testInfo expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); - const trace = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip')); + const trace = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.pwtrace.zip')); expect(trace.apiNames).toEqual([ 'Before Hooks', `attach "foo"`, @@ -592,7 +592,7 @@ test('should record with custom page fixture', async ({ runInlineTest }, testInf expect(result.exitCode).toBe(1); expect(result.failed).toBe(1); expect(result.output).toContain('failure!'); - const trace = await parseTraceRaw(testInfo.outputPath('test-results', 'a-fails', 'trace.zip')); + const trace = await parseTraceRaw(testInfo.outputPath('test-results', 'a-fails', 'trace.pwtrace.zip')); expect(trace.events).toContainEqual(expect.objectContaining({ type: 'frame-snapshot', })); @@ -617,7 +617,7 @@ test('should expand expect.toPass', async ({ runInlineTest }, testInfo) => { expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); - const trace = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip')); + const trace = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.pwtrace.zip')); expect(trace.actionTree).toEqual([ 'Before Hooks', ' fixture: browser', @@ -656,7 +656,7 @@ test('should show non-expect error in trace', async ({ runInlineTest }, testInfo expect(result.exitCode).toBe(1); expect(result.failed).toBe(1); - const trace = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.zip')); + const trace = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.pwtrace.zip')); expect(trace.actionTree).toEqual([ 'Before Hooks', ' fixture: browser', @@ -692,7 +692,7 @@ test('should show error from beforeAll in trace', async ({ runInlineTest }, test expect(result.exitCode).toBe(1); expect(result.failed).toBe(1); - const trace = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.zip')); + const trace = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.pwtrace.zip')); expect(trace.errors).toEqual(['Error: Oh my!']); }); @@ -730,7 +730,7 @@ test('should not throw when attachment is missing', async ({ runInlineTest }, te expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); - const trace = await parseTrace(testInfo.outputPath('test-results', 'a-passes', 'trace.zip')); + const trace = await parseTrace(testInfo.outputPath('test-results', 'a-passes', 'trace.pwtrace.zip')); expect(trace.actionTree).toContain('attach "screenshot"'); }); @@ -754,7 +754,7 @@ test('should not throw when screenshot on failure fails', async ({ runInlineTest expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); - const trace = await parseTrace(testInfo.outputPath('test-results', 'a-has-pdf-page', 'trace.zip')); + const trace = await parseTrace(testInfo.outputPath('test-results', 'a-has-pdf-page', 'trace.pwtrace.zip')); const attachedScreenshots = trace.actionTree.filter(s => s.trim() === `attach "screenshot"`); // One screenshot for the page, no screenshot for pdf page since it should have failed. expect(attachedScreenshots.length).toBe(1); @@ -778,7 +778,7 @@ test('should use custom expect message in trace', async ({ runInlineTest }, test expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); - const trace = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.zip')); + const trace = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.pwtrace.zip')); expect(trace.actionTree).toEqual([ 'Before Hooks', ' fixture: browser', @@ -837,7 +837,7 @@ test('should not throw when merging traces multiple times', async ({ runInlineTe expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); - expect(fs.existsSync(testInfo.outputPath('test-results', 'a-foo', 'trace.zip'))).toBe(true); + expect(fs.existsSync(testInfo.outputPath('test-results', 'a-foo', 'trace.pwtrace.zip'))).toBe(true); }); test('should record nested steps, even after timeout', async ({ runInlineTest }, testInfo) => { @@ -928,7 +928,7 @@ test('should record nested steps, even after timeout', async ({ runInlineTest }, expect(result.exitCode).toBe(1); expect(result.failed).toBe(1); - const trace = await parseTrace(testInfo.outputPath('test-results', 'a-example', 'trace.zip')); + const trace = await parseTrace(testInfo.outputPath('test-results', 'a-example', 'trace.pwtrace.zip')); expect(trace.actionTree).toEqual([ 'Before Hooks', ' beforeAll hook', @@ -1022,14 +1022,14 @@ test('should attribute worker fixture teardown to the right test', async ({ runI expect(result.exitCode).toBe(1); expect(result.passed).toBe(1); expect(result.failed).toBe(1); - const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-one', 'trace.zip')); + const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-one', 'trace.pwtrace.zip')); expect(trace1.actionTree).toEqual([ 'Before Hooks', ' fixture: foo', ' step in foo setup', 'After Hooks', ]); - const trace2 = await parseTrace(testInfo.outputPath('test-results', 'a-two', 'trace.zip')); + const trace2 = await parseTrace(testInfo.outputPath('test-results', 'a-two', 'trace.pwtrace.zip')); expect(trace2.actionTree).toEqual([ 'Before Hooks', 'After Hooks', @@ -1050,11 +1050,11 @@ test('trace:retain-on-first-failure should create trace but only on first failur `, }, { trace: 'retain-on-first-failure', retries: 1 }); - const retryTracePath = test.info().outputPath('test-results', 'a-fail-retry1', 'trace.zip'); + const retryTracePath = test.info().outputPath('test-results', 'a-fail-retry1', 'trace.pwtrace.zip'); const retryTraceExists = fs.existsSync(retryTracePath); expect(retryTraceExists).toBe(false); - const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip'); + const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.pwtrace.zip'); const trace = await parseTrace(tracePath); expect(trace.apiNames).toContain('page.goto'); expect(result.failed).toBe(1); @@ -1071,7 +1071,7 @@ test('trace:retain-on-first-failure should create trace if context is closed bef }); `, }, { trace: 'retain-on-first-failure' }); - const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip'); + const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.pwtrace.zip'); const trace = await parseTrace(tracePath); expect(trace.apiNames).toContain('page.goto'); expect(result.failed).toBe(1); @@ -1090,7 +1090,7 @@ test('trace:retain-on-first-failure should create trace if context is closed bef }); `, }, { trace: 'retain-on-first-failure' }); - const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip'); + const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.pwtrace.zip'); const trace = await parseTrace(tracePath); expect(trace.apiNames).toContain('page.goto'); expect(result.failed).toBe(1); @@ -1107,7 +1107,7 @@ test('trace:retain-on-first-failure should create trace if request context is di }); `, }, { trace: 'retain-on-first-failure' }); - const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip'); + const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.pwtrace.zip'); const trace = await parseTrace(tracePath); expect(trace.apiNames).toContain('apiRequestContext.get'); expect(result.failed).toBe(1); @@ -1132,7 +1132,7 @@ test('should not corrupt actions when no library trace is present', async ({ run expect(result.exitCode).toBe(1); expect(result.failed).toBe(1); - const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip'); + const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.pwtrace.zip'); const trace = await parseTrace(tracePath); expect(trace.actionTree).toEqual([ 'Before Hooks', @@ -1162,7 +1162,7 @@ test('should record trace for manually created context in a failed test', async expect(result.exitCode).toBe(1); expect(result.failed).toBe(1); - const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip'); + const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.pwtrace.zip'); const trace = await parseTrace(tracePath); expect(trace.actionTree).toEqual([ 'Before Hooks', @@ -1204,7 +1204,7 @@ test('should not nest top level expect into unfinished api calls ', { expect(result.exitCode).toBe(0); expect(result.failed).toBe(0); - const tracePath = test.info().outputPath('test-results', 'a-pass', 'trace.zip'); + const tracePath = test.info().outputPath('test-results', 'a-pass', 'trace.pwtrace.zip'); const trace = await parseTrace(tracePath); expect(trace.actionTree).toEqual([ 'Before Hooks', @@ -1246,7 +1246,7 @@ test('should record trace after fixture teardown timeout', { expect(result.exitCode).toBe(1); expect(result.failed).toBe(1); - const tracePath = test.info().outputPath('test-results', 'a-fails', 'trace.zip'); + const tracePath = test.info().outputPath('test-results', 'a-fails', 'trace.pwtrace.zip'); const trace = await parseTrace(tracePath); expect(trace.actionTree).toEqual([ 'Before Hooks', diff --git a/tests/playwright-test/reporter-attachment.spec.ts b/tests/playwright-test/reporter-attachment.spec.ts index 50d6d1ee0c..99345a9107 100644 --- a/tests/playwright-test/reporter-attachment.spec.ts +++ b/tests/playwright-test/reporter-attachment.spec.ts @@ -66,7 +66,7 @@ test('render trace attachment', async ({ runInlineTest }) => { test('one', async ({}, testInfo) => { testInfo.attachments.push({ name: 'trace', - path: testInfo.outputPath('my dir with space', 'trace.zip'), + path: testInfo.outputPath('my dir with space', 'trace.pwtrace.zip'), contentType: 'application/zip' }); expect(1).toBe(0); @@ -75,8 +75,8 @@ test('render trace attachment', async ({ runInlineTest }) => { }, { reporter: 'line' }); const text = result.output.replace(/\\/g, '/'); expect(text).toContain(' attachment #1: trace (application/zip) ─────────────────────────────────────────────────────────'); - expect(text).toContain(' test-results/a-one/my dir with space/trace.zip'); - expect(text).toContain('npx playwright show-trace "test-results/a-one/my dir with space/trace.zip"'); + expect(text).toContain(' test-results/a-one/my dir with space/trace.pwtrace.zip'); + expect(text).toContain('npx playwright show-trace "test-results/a-one/my dir with space/trace.pwtrace.zip"'); expect(text).toContain(' ────────────────────────────────────────────────────────────────────────────────────────────────'); expect(result.exitCode).toBe(1); }); From c24ad36f8639283d583cd38ba74f65a9905e8737 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 16 Sep 2024 07:39:36 +0200 Subject: [PATCH 25/41] docs(docker): fix Docker container permissions (#32621) --- docs/src/ci.md | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/docs/src/ci.md b/docs/src/ci.md index 97ea4ad8d2..745ecc9d50 100644 --- a/docs/src/ci.md +++ b/docs/src/ci.md @@ -209,6 +209,7 @@ jobs: runs-on: ubuntu-latest container: image: mcr.microsoft.com/playwright:v%%VERSION%%-jammy + options: --user 1001 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -218,8 +219,6 @@ jobs: run: npm ci - name: Run your tests run: npx playwright test - env: - HOME: /root ``` ```yml python title=".github/workflows/playwright.yml" @@ -235,6 +234,7 @@ jobs: runs-on: ubuntu-latest container: image: mcr.microsoft.com/playwright/python:v%%VERSION%%-jammy + options: --user 1001 steps: - uses: actions/checkout@v4 - name: Set up Python @@ -248,8 +248,6 @@ jobs: pip install -e . - name: Run your tests run: pytest - env: - HOME: /root ``` ```yml java title=".github/workflows/playwright.yml" @@ -265,6 +263,7 @@ jobs: runs-on: ubuntu-latest container: image: mcr.microsoft.com/playwright/java:v%%VERSION%%-jammy + options: --user 1001 steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v3 @@ -275,8 +274,6 @@ jobs: run: mvn -B install -D skipTests --no-transfer-progress - name: Run tests run: mvn test - env: - HOME: /root ``` ```yml csharp title=".github/workflows/playwright.yml" @@ -292,6 +289,7 @@ jobs: runs-on: ubuntu-latest container: image: mcr.microsoft.com/playwright/dotnet:v%%VERSION%%-jammy + options: --user 1001 steps: - uses: actions/checkout@v4 - name: Setup dotnet @@ -301,8 +299,6 @@ jobs: - run: dotnet build - name: Run your tests run: dotnet test - env: - HOME: /root ``` #### On deployment From 268357238ac27e03ea5309ab23e70ba9261e68f1 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 16 Sep 2024 00:10:06 -0700 Subject: [PATCH 26/41] fix(expect): respect custom message in expect.poll (#32603) Fixes #32582. --- packages/playwright/src/matchers/expect.ts | 48 +++++++++++++--------- tests/playwright-test/expect-poll.spec.ts | 18 +++++++- tests/playwright-test/test-step.spec.ts | 6 +++ 3 files changed, 51 insertions(+), 21 deletions(-) diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index 9c426fd86e..3a254ae7bf 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -121,10 +121,10 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re const [actual, messageOrOptions] = argumentsList; const message = isString(messageOrOptions) ? messageOrOptions : messageOrOptions?.message || info.message; const newInfo = { ...info, message }; - if (newInfo.isPoll) { + if (newInfo.poll) { if (typeof actual !== 'function') throw new Error('`expect.poll()` accepts only function as a first argument'); - newInfo.generator = actual as any; + newInfo.poll.generator = actual as any; } return createMatchers(actual, newInfo, prefix); }, @@ -189,10 +189,10 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re if ('soft' in configuration) newInfo.isSoft = configuration.soft; if ('_poll' in configuration) { - newInfo.isPoll = !!configuration._poll; + newInfo.poll = configuration._poll ? { ...info.poll, generator: () => {} } : undefined; if (typeof configuration._poll === 'object') { - newInfo.pollTimeout = configuration._poll.timeout; - newInfo.pollIntervals = configuration._poll.intervals; + newInfo.poll!.timeout = configuration._poll.timeout ?? newInfo.poll!.timeout; + newInfo.poll!.intervals = configuration._poll.intervals ?? newInfo.poll!.intervals; } } return createExpect(newInfo, prefix, customMatchers); @@ -249,11 +249,12 @@ type ExpectMetaInfo = { message?: string; isNot?: boolean; isSoft?: boolean; - isPoll?: boolean; + poll?: { + timeout?: number; + intervals?: number[]; + generator: Generator; + }; timeout?: number; - pollTimeout?: number; - pollIntervals?: number[]; - generator?: Generator; }; class ExpectMetaInfoProxyHandler implements ProxyHandler { @@ -287,10 +288,10 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { this._info.isNot = !this._info.isNot; return new Proxy(matcher, this); } - if (this._info.isPoll) { + if (this._info.poll) { if ((customAsyncMatchers as any)[matcherName] || matcherName === 'resolves' || matcherName === 'rejects') throw new Error(`\`expect.poll()\` does not support "${matcherName}" matcher.`); - matcher = (...args: any[]) => pollMatcher(resolvedMatcherName, !!this._info.isNot, this._info.pollIntervals, this._info.pollTimeout ?? currentExpectTimeout(), this._info.generator!, ...args); + matcher = (...args: any[]) => pollMatcher(resolvedMatcherName, this._info, this._prefix, ...args); } return (...args: any[]) => { const testInfo = currentTestInfo(); @@ -302,7 +303,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { const customMessage = this._info.message || ''; const argsSuffix = computeArgsSuffix(matcherName, args); - const defaultTitle = `expect${this._info.isPoll ? '.poll' : ''}${this._info.isSoft ? '.soft' : ''}${this._info.isNot ? '.not' : ''}.${matcherName}${argsSuffix}`; + const defaultTitle = `expect${this._info.poll ? '.poll' : ''}${this._info.isSoft ? '.soft' : ''}${this._info.isNot ? '.not' : ''}.${matcherName}${argsSuffix}`; const title = customMessage || defaultTitle; // This looks like it is unnecessary, but it isn't - we need to filter @@ -336,7 +337,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { const callback = () => matcher.call(target, ...args); // toPass and poll matchers can contain other steps, expects and API calls, // so they behave like a retriable step. - const result = (matcherName === 'toPass' || this._info.isPoll) ? + const result = (matcherName === 'toPass' || this._info.poll) ? zones.run('stepZone', step, callback) : zones.run('expectZone', { title, stepId: step.stepId }, callback); if (result instanceof Promise) @@ -350,25 +351,32 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { } } -async function pollMatcher(qualifiedMatcherName: any, isNot: boolean, pollIntervals: number[] | undefined, timeout: number, generator: () => any, ...args: any[]) { +async function pollMatcher(qualifiedMatcherName: string, info: ExpectMetaInfo, prefix: string[], ...args: any[]) { const testInfo = currentTestInfo(); + const poll = info.poll!; + const timeout = poll.timeout ?? currentExpectTimeout(); const { deadline, timeoutMessage } = testInfo ? testInfo._deadlineForMatcher(timeout) : TestInfoImpl._defaultDeadlineForMatcher(timeout); const result = await pollAgainstDeadline(async () => { if (testInfo && currentTestInfo() !== testInfo) return { continuePolling: false, result: undefined }; - const value = await generator(); - let expectInstance = expectLibrary(value) as any; - if (isNot) - expectInstance = expectInstance.not; + const innerInfo: ExpectMetaInfo = { + ...info, + isSoft: false, // soft is outside of poll, not inside + poll: undefined, + }; + const value = await poll.generator(); try { - expectInstance[qualifiedMatcherName].call(expectInstance, ...args); + let matchers = createMatchers(value, innerInfo, prefix); + if (info.isNot) + matchers = matchers.not; + matchers[qualifiedMatcherName](...args); return { continuePolling: false, result: undefined }; } catch (error) { return { continuePolling: true, result: error }; } - }, deadline, pollIntervals ?? [100, 250, 500, 1000]); + }, deadline, poll.intervals ?? [100, 250, 500, 1000]); if (result.timedOut) { const message = result.result ? [ diff --git a/tests/playwright-test/expect-poll.spec.ts b/tests/playwright-test/expect-poll.spec.ts index 344fdccdee..208ac4ccce 100644 --- a/tests/playwright-test/expect-poll.spec.ts +++ b/tests/playwright-test/expect-poll.spec.ts @@ -262,4 +262,20 @@ test('should propagate string exception from async arrow function', { annotation }); expect(result.output).toContain('some error'); -}); \ No newline at end of file +}); + +test('should show custom message', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/32582' } +}, async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('should fail', async () => { + await expect.poll(() => 1, { message: 'custom message', timeout: 500 }).toBe(2); + }); + `, + }); + expect(result.output).toContain('Error: custom message'); + expect(result.output).toContain('Expected: 2'); + expect(result.output).toContain('Received: 1'); +}); diff --git a/tests/playwright-test/test-step.spec.ts b/tests/playwright-test/test-step.spec.ts index 96539a4c88..d14bccd98b 100644 --- a/tests/playwright-test/test-step.spec.ts +++ b/tests/playwright-test/test-step.spec.ts @@ -987,9 +987,12 @@ expect |expect.poll.toHaveLength @ a.test.ts:14 pw:api | page.goto(about:blank) @ a.test.ts:7 test.step | inner step attempt: 0 @ a.test.ts:8 expect | expect.toBe @ a.test.ts:10 +expect | expect.toHaveLength @ a.test.ts:6 +expect | ↪ error: Error: expect(received).toHaveLength(expected) pw:api | page.goto(about:blank) @ a.test.ts:7 test.step | inner step attempt: 1 @ a.test.ts:8 expect | expect.toBe @ a.test.ts:10 +expect | expect.toHaveLength @ a.test.ts:6 hook |After Hooks fixture | fixture: page fixture | fixture: context @@ -1036,9 +1039,12 @@ expect |expect.poll.toBe @ a.test.ts:13 expect | expect.toHaveText @ a.test.ts:7 test.step | iteration 1 @ a.test.ts:9 expect | expect.toBeVisible @ a.test.ts:10 +expect | expect.toBe @ a.test.ts:6 +expect | ↪ error: Error: expect(received).toBe(expected) // Object.is equality expect | expect.toHaveText @ a.test.ts:7 test.step | iteration 2 @ a.test.ts:9 expect | expect.toBeVisible @ a.test.ts:10 +expect | expect.toBe @ a.test.ts:6 hook |After Hooks fixture | fixture: page fixture | fixture: context From 762e954599e1e45caebc21921d26c58b715176a9 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 16 Sep 2024 15:43:42 +0200 Subject: [PATCH 27/41] devops: update GitHub Actions (#32634) Fixes: image For future reference: `pip install github-actions-cli && github-actions-cli update-actions --update` --- .github/workflows/infra.yml | 4 ++-- .github/workflows/tests_others.yml | 2 +- .github/workflows/tests_service.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/infra.yml b/.github/workflows/infra.yml index 3580ecc97a..f33c8535f0 100644 --- a/.github/workflows/infra.yml +++ b/.github/workflows/infra.yml @@ -44,10 +44,10 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 18 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: '3.11' - - uses: actions/setup-dotnet@v3 + - uses: actions/setup-dotnet@v4 with: dotnet-version: 8.0.x - run: npm ci diff --git a/.github/workflows/tests_others.yml b/.github/workflows/tests_others.yml index 783f3fe2ff..434c5aa9d3 100644 --- a/.github/workflows/tests_others.yml +++ b/.github/workflows/tests_others.yml @@ -66,7 +66,7 @@ jobs: contents: read # This is required for actions/checkout to succeed steps: - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v3 + - uses: actions/setup-dotnet@v4 with: dotnet-version: '8.0.x' - run: dotnet build diff --git a/.github/workflows/tests_service.yml b/.github/workflows/tests_service.yml index 2788b0cf08..2d68740006 100644 --- a/.github/workflows/tests_service.yml +++ b/.github/workflows/tests_service.yml @@ -34,7 +34,7 @@ jobs: PLAYWRIGHT_SERVICE_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}-${{ github.sha }} - name: Upload blob report to GitHub if: ${{ !cancelled() }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: all-blob-reports path: blob-report From 71c43693acf35a9f49c9282ecae4c61557a7e160 Mon Sep 17 00:00:00 2001 From: Anthony Roberts Date: Tue, 17 Sep 2024 00:57:11 +1000 Subject: [PATCH 28/41] feat(reporter): add copy button for annotations (#31790) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a copy-to-clipboard button for each annotation so that text can be copied easily. This re-uses the existing `CopyToClipboard` component and adds a `small` variant that can be used inline. The icon size and colour have been chosen to avoid being overwhelming when used inline. Related to #30141 I opted not to introduce the hover behaviour from #30749 as it's less discoverable, but can understand why that might be favourable. Certainly open to suggestions 😄 Screenshot 2024-07-22 at 3 23 53 PM --- .../html-reporter/src/copyToClipboard.css | 18 +++++++++++- .../html-reporter/src/copyToClipboard.tsx | 29 ++++++++++++++++--- .../html-reporter/src/testCaseView.spec.tsx | 13 +++++++++ packages/html-reporter/src/testCaseView.tsx | 3 +- 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/packages/html-reporter/src/copyToClipboard.css b/packages/html-reporter/src/copyToClipboard.css index 5790b626c0..818100fff7 100644 --- a/packages/html-reporter/src/copyToClipboard.css +++ b/packages/html-reporter/src/copyToClipboard.css @@ -20,15 +20,31 @@ width: 24px; border: none; outline: none; - color: var(--color-fg-default); + color: var(--color-fg-muted); background: transparent; padding: 4px; cursor: pointer; display: inline-flex; align-items: center; + justify-content: center; border-radius: 4px; } +.copy-icon svg { + margin: 0; +} + .copy-icon:not(:disabled):hover { background-color: var(--color-border-default); } + +.copy-button-container { + visibility: hidden; + display: inline-flex; + margin-left: 8px; + vertical-align: bottom; +} + +.copy-value-container:hover .copy-button-container { + visibility: visible; +} diff --git a/packages/html-reporter/src/copyToClipboard.tsx b/packages/html-reporter/src/copyToClipboard.tsx index a24015a671..17b1dfbf95 100644 --- a/packages/html-reporter/src/copyToClipboard.tsx +++ b/packages/html-reporter/src/copyToClipboard.tsx @@ -18,9 +18,14 @@ import * as React from 'react'; import * as icons from './icons'; import './copyToClipboard.css'; -export const CopyToClipboard: React.FunctionComponent<{ - value: string, -}> = ({ value }) => { +type CopyToClipboardProps = { + value: string; +}; + +/** + * A copy to clipboard button. + */ +export const CopyToClipboard: React.FunctionComponent = ({ value }) => { type IconType = 'copy' | 'check' | 'cross'; const [icon, setIcon] = React.useState('copy'); const handleCopy = React.useCallback(() => { @@ -34,5 +39,21 @@ export const CopyToClipboard: React.FunctionComponent<{ }); }, [value]); const iconElement = icon === 'check' ? icons.check() : icon === 'cross' ? icons.cross() : icons.copy(); - return ; + return ; +}; + +type CopyToClipboardContainerProps = CopyToClipboardProps & { + children: React.ReactNode +}; + +/** + * Container for displaying a copy to clipboard button alongside children. + */ +export const CopyToClipboardContainer: React.FunctionComponent = ({ children, value }) => { + return + {children} + + + + ; }; diff --git a/packages/html-reporter/src/testCaseView.spec.tsx b/packages/html-reporter/src/testCaseView.spec.tsx index afe06cebcb..72552f5184 100644 --- a/packages/html-reporter/src/testCaseView.spec.tsx +++ b/packages/html-reporter/src/testCaseView.spec.tsx @@ -76,6 +76,19 @@ test('should render test case', async ({ mount }) => { await expect(component.getByText('My test')).toBeVisible(); }); +test('should render copy buttons for annotations', async ({ mount, page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + + const component = await mount(); + await expect(component.getByText('Annotation text', { exact: false }).first()).toBeVisible(); + component.getByText('Annotation text', { exact: false }).first().hover(); + await expect(component.getByLabel('Copy to clipboard').first()).toBeVisible(); + await component.getByLabel('Copy to clipboard').first().click(); + const handle = await page.evaluateHandle(() => navigator.clipboard.readText()); + const clipboardContent = await handle.jsonValue(); + expect(clipboardContent).toBe('Annotation text'); +}); + const annotationLinkRenderingTestCase: TestCase = { testId: 'testid', title: 'My test', diff --git a/packages/html-reporter/src/testCaseView.tsx b/packages/html-reporter/src/testCaseView.tsx index 5bed3c8309..1fe4f42ed3 100644 --- a/packages/html-reporter/src/testCaseView.tsx +++ b/packages/html-reporter/src/testCaseView.tsx @@ -26,6 +26,7 @@ import { TestResultView } from './testResultView'; import { linkifyText } from '@web/renderUtils'; import { hashStringToInt, msToString } from './utils'; import { clsx } from '@web/uiUtils'; +import { CopyToClipboardContainer } from './copyToClipboard'; export const TestCaseView: React.FC<{ projectNames: string[], @@ -73,7 +74,7 @@ function TestCaseAnnotationView({ annotation: { type, description } }: { annotat return (
{type} - {description && : {linkifyText(description)}} + {description && : {linkifyText(description)}}
); } From feac957475a5743c38512f429b2bee58120710a8 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Mon, 16 Sep 2024 08:18:01 -0700 Subject: [PATCH 29/41] feat(webkit): roll to r2077 (#32636) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index de51a1643c..dc91235659 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -27,7 +27,7 @@ }, { "name": "webkit", - "revision": "2075", + "revision": "2077", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", From b335b00a8622db4d2599741f2f013c83af8cb926 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 16 Sep 2024 17:30:14 +0200 Subject: [PATCH 30/41] docs: add reference to locator strictness if or resolves to multiple elements (#32633) --- docs/src/api/class-locator.md | 4 +++- packages/playwright-core/types/types.d.ts | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index 4df0035098..88658b5494 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -1654,7 +1654,9 @@ var banana = await page.GetByRole(AriaRole.Listitem).Nth(2); - alias-python: or_ - returns: <[Locator]> -Creates a locator that matches either of the two locators. +Creates a locator matching all elements that match one or both of the two locators. + +Note that when both locators match something, the resulting locator will have multiple matches and violate [locator strictness](../locators.md#strictness) guidelines. **Usage** diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index e88e1b473b..67bb51b192 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -13060,7 +13060,10 @@ export interface Locator { nth(index: number): Locator; /** - * Creates a locator that matches either of the two locators. + * Creates a locator matching all elements that match one or both of the two locators. + * + * Note that when both locators match something, the resulting locator will have multiple matches and violate + * [locator strictness](https://playwright.dev/docs/locators#strictness) guidelines. * * **Usage** * From 21d162c945e81661278b08f508c0af9fcc08e78c Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 16 Sep 2024 17:57:33 +0200 Subject: [PATCH 31/41] feat(client-certificates): add support for proxies (#32611) Fixes https://github.com/microsoft/playwright/issues/32370 --- docs/src/api/params.md | 4 - .../playwright-core/src/server/browser.ts | 9 +- .../src/server/browserContext.ts | 11 +- .../playwright-core/src/server/browserType.ts | 17 +-- packages/playwright-core/src/server/fetch.ts | 38 +++---- .../socksClientCertificatesInterceptor.ts | 14 ++- packages/playwright-core/types/types.d.ts | 8 -- packages/playwright/types/test.d.ts | 2 - tests/config/proxy.ts | 51 ++++++++- tests/library/client-certificates.spec.ts | 101 ++++++++++++++++-- tests/library/proxy.spec.ts | 44 ++------ 11 files changed, 196 insertions(+), 103 deletions(-) diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 608855ab2a..de930ee97e 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -558,10 +558,6 @@ TLS Client Authentication allows the server to request a client certificate and An array of client certificates to be used. Each certificate object must have either both `certPath` and `keyPath`, a single `pfxPath`, or their corresponding direct value equivalents (`cert` and `key`, or `pfx`). Optionally, `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided with an exact match to the request origin that the certificate is valid for. -:::note -Using Client Certificates in combination with Proxy Servers is not supported. -::: - :::note When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it work by replacing `localhost` with `local.playwright`. ::: diff --git a/packages/playwright-core/src/server/browser.ts b/packages/playwright-core/src/server/browser.ts index 04a23e7eac..252443bc44 100644 --- a/packages/playwright-core/src/server/browser.ts +++ b/packages/playwright-core/src/server/browser.ts @@ -16,7 +16,7 @@ import type * as types from './types'; import type * as channels from '@protocol/channels'; -import { BrowserContext, createClientCertificatesProxyIfNeeded, validateBrowserContextOptions } from './browserContext'; +import { BrowserContext, validateBrowserContextOptions } from './browserContext'; import { Page } from './page'; import { Download } from './download'; import type { ProxySettings } from './types'; @@ -25,6 +25,7 @@ import type { RecentLogsCollector } from '../utils/debugLogger'; import type { CallMetadata } from './instrumentation'; import { SdkObject } from './instrumentation'; import { Artifact } from './artifact'; +import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; export interface BrowserProcess { onclose?: ((exitCode: number | null, signal: string | null) => void); @@ -82,8 +83,10 @@ export abstract class Browser extends SdkObject { async newContext(metadata: CallMetadata, options: types.BrowserContextOptions): Promise { validateBrowserContextOptions(options, this.options); - const clientCertificatesProxy = await createClientCertificatesProxyIfNeeded(options, this.options); - if (clientCertificatesProxy) { + let clientCertificatesProxy: ClientCertificatesProxy | undefined; + if (options.clientCertificates?.length) { + clientCertificatesProxy = new ClientCertificatesProxy(options); + options = { ...options }; options.proxyOverride = await clientCertificatesProxy.listen(); options.internalIgnoreHTTPSErrors = true; } diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index e1166f3e97..499356ca49 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -42,7 +42,7 @@ import * as consoleApiSource from '../generated/consoleApiSource'; import { BrowserContextAPIRequestContext } from './fetch'; import type { Artifact } from './artifact'; import { Clock } from './clock'; -import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; +import type { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; import { RecorderApp } from './recorder/recorderApp'; export abstract class BrowserContext extends SdkObject { @@ -659,15 +659,6 @@ export function assertBrowserContextIsNotOwned(context: BrowserContext) { } } -export async function createClientCertificatesProxyIfNeeded(options: types.BrowserContextOptions, browserOptions?: BrowserOptions) { - if (!options.clientCertificates?.length) - return; - if ((options.proxy?.server && options.proxy?.server !== 'per-context') || (browserOptions?.proxy?.server && browserOptions?.proxy?.server !== 'http://per-context')) - throw new Error('Cannot specify both proxy and clientCertificates'); - verifyClientCertificates(options.clientCertificates); - return new ClientCertificatesProxy(options); -} - export function validateBrowserContextOptions(options: types.BrowserContextOptions, browserOptions: BrowserOptions) { if (options.noDefaultViewport && options.deviceScaleFactor !== undefined) throw new Error(`"deviceScaleFactor" option is not supported with null "viewport"`); diff --git a/packages/playwright-core/src/server/browserType.ts b/packages/playwright-core/src/server/browserType.ts index 286ec27c29..abc15a20f4 100644 --- a/packages/playwright-core/src/server/browserType.ts +++ b/packages/playwright-core/src/server/browserType.ts @@ -18,7 +18,7 @@ import fs from 'fs'; import * as os from 'os'; import path from 'path'; import type { BrowserContext } from './browserContext'; -import { createClientCertificatesProxyIfNeeded, normalizeProxySettings, validateBrowserContextOptions } from './browserContext'; +import { normalizeProxySettings, validateBrowserContextOptions } from './browserContext'; import type { BrowserName } from './registry'; import { registry } from './registry'; import type { ConnectionTransport } from './transport'; @@ -39,6 +39,7 @@ import { RecentLogsCollector } from '../utils/debugLogger'; import type { CallMetadata } from './instrumentation'; import { SdkObject } from './instrumentation'; import { type ProtocolError, isProtocolError } from './protocolError'; +import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; export const kNoXServerRunningError = 'Looks like you launched a headed browser without having a XServer running.\n' + 'Set either \'headless: true\' or use \'xvfb-run \' before running Playwright.\n\n<3 Playwright Team'; @@ -92,19 +93,23 @@ export abstract class BrowserType extends SdkObject { return browser; } - async launchPersistentContext(metadata: CallMetadata, userDataDir: string, persistentContextOptions: channels.BrowserTypeLaunchPersistentContextOptions & { useWebSocket?: boolean }): Promise { - const launchOptions = this._validateLaunchOptions(persistentContextOptions); + async launchPersistentContext(metadata: CallMetadata, userDataDir: string, options: channels.BrowserTypeLaunchPersistentContextOptions & { useWebSocket?: boolean, internalIgnoreHTTPSErrors?: boolean }): Promise { + const launchOptions = this._validateLaunchOptions(options); if (this._useBidi) launchOptions.useWebSocket = true; const controller = new ProgressController(metadata, this); controller.setLogName('browser'); const browser = await controller.run(async progress => { // Note: Any initial TLS requests will fail since we rely on the Page/Frames initialize which sets ignoreHTTPSErrors. - const clientCertificatesProxy = await createClientCertificatesProxyIfNeeded(persistentContextOptions); - if (clientCertificatesProxy) + let clientCertificatesProxy: ClientCertificatesProxy | undefined; + if (options.clientCertificates?.length) { + clientCertificatesProxy = new ClientCertificatesProxy(options); launchOptions.proxyOverride = await clientCertificatesProxy?.listen(); + options = { ...options }; + options.internalIgnoreHTTPSErrors = true; + } progress.cleanupWhenAborted(() => clientCertificatesProxy?.close()); - const browser = await this._innerLaunchWithRetries(progress, launchOptions, persistentContextOptions, helper.debugProtocolLogger(), userDataDir).catch(e => { throw this._rewriteStartupLog(e); }); + const browser = await this._innerLaunchWithRetries(progress, launchOptions, options, helper.debugProtocolLogger(), userDataDir).catch(e => { throw this._rewriteStartupLog(e); }); browser._defaultContext!._clientCertificatesProxy = clientCertificatesProxy; return browser; }, TimeoutSettings.launchTimeout(launchOptions)); diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 01c6c397ab..fc4e2c027d 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -168,23 +168,11 @@ export abstract class APIRequestContext extends SdkObject { const method = params.method?.toUpperCase() || 'GET'; const proxy = defaults.proxy; let agent; - // When `clientCertificates` is present, we set the `proxy` property to our own socks proxy - // for the browser to use. However, we don't need it here, because we already respect - // `clientCertificates` when fetching from Node.js. - if (proxy && !defaults.clientCertificates?.length && proxy.server !== 'per-context' && !shouldBypassProxy(requestUrl, proxy.bypass)) { - const proxyOpts = url.parse(proxy.server); - if (proxyOpts.protocol?.startsWith('socks')) { - agent = new SocksProxyAgent({ - host: proxyOpts.hostname, - port: proxyOpts.port || undefined, - }); - } else { - if (proxy.username) - proxyOpts.auth = `${proxy.username}:${proxy.password || ''}`; - // TODO: We should use HttpProxyAgent conditional on proxyOpts.protocol instead of always using CONNECT method. - agent = new HttpsProxyAgent(proxyOpts); - } - } + // We skip 'per-context' in order to not break existing users. 'per-context' was previously used to + // workaround an upstream Chromium bug. Can be removed in the future. + if (proxy && proxy.server !== 'per-context' && !shouldBypassProxy(requestUrl, proxy.bypass)) + agent = createProxyAgent(proxy); + const timeout = defaults.timeoutSettings.timeout(params); const deadline = timeout && (monotonicTime() + timeout); @@ -577,8 +565,6 @@ export class GlobalAPIRequestContext extends APIRequestContext { if (!/^\w+:\/\//.test(url)) url = 'http://' + url; proxy.server = url; - if (options.clientCertificates) - throw new Error('Cannot specify both proxy and clientCertificates'); } if (options.storageState) { this._origins = options.storageState.origins; @@ -629,6 +615,20 @@ export class GlobalAPIRequestContext extends APIRequestContext { } } +export function createProxyAgent(proxy: types.ProxySettings) { + const proxyOpts = url.parse(proxy.server); + if (proxyOpts.protocol?.startsWith('socks')) { + return new SocksProxyAgent({ + host: proxyOpts.hostname, + port: proxyOpts.port || undefined, + }); + } + if (proxy.username) + proxyOpts.auth = `${proxy.username}:${proxy.password || ''}`; + // TODO: We should use HttpProxyAgent conditional on proxyOpts.protocol instead of always using CONNECT method. + return new HttpsProxyAgent(proxyOpts); +} + function toHeadersArray(rawHeaders: string[]): types.HeadersArray { const result: types.HeadersArray = []; for (let i = 0; i < rawHeaders.length; i += 2) diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index 6d4b334dba..08aaa4f25a 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -25,6 +25,9 @@ import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketReque import { SocksProxy } from '../common/socksProxy'; import type * as types from './types'; import { debugLogger } from '../utils/debugLogger'; +import { createProxyAgent } from './fetch'; +import { EventEmitter } from 'events'; +import { verifyClientCertificates } from './browserContext'; let dummyServerTlsOptions: tls.TlsOptions | undefined = undefined; function loadDummyServerCertsIfNeeded() { @@ -94,7 +97,11 @@ class SocksProxyConnection { } async connect() { - this.target = await createSocket(rewriteToLocalhostIfNeeded(this.host), this.port); + if (this.socksProxy.proxyAgentFromOptions) + this.target = await this.socksProxy.proxyAgentFromOptions.callback(new EventEmitter() as any, { host: rewriteToLocalhostIfNeeded(this.host), port: this.port, secureEndpoint: false }); + else + this.target = await createSocket(rewriteToLocalhostIfNeeded(this.host), this.port); + this.target.once('close', this._targetCloseEventListener); this.target.once('error', error => this.socksProxy._socksProxy.sendSocketError({ uid: this.uid, error: error.message })); if (this._closed) { @@ -233,12 +240,15 @@ export class ClientCertificatesProxy { ignoreHTTPSErrors: boolean | undefined; secureContextMap: Map = new Map(); alpnCache: ALPNCache; + proxyAgentFromOptions: ReturnType | undefined; constructor( - contextOptions: Pick + contextOptions: Pick ) { + verifyClientCertificates(contextOptions.clientCertificates); this.alpnCache = new ALPNCache(); this.ignoreHTTPSErrors = contextOptions.ignoreHTTPSErrors; + this.proxyAgentFromOptions = contextOptions.proxy ? createProxyAgent(contextOptions.proxy) : undefined; this._initSecureContexts(contextOptions.clientCertificates); this._socksProxy = new SocksProxy(); this._socksProxy.setPattern('*'); diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 67bb51b192..f770fc70dc 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -9202,8 +9202,6 @@ export interface Browser { * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * with an exact match to the request origin that the certificate is valid for. * - * **NOTE** Using Client Certificates in combination with Proxy Servers is not supported. - * * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * work by replacing `localhost` with `local.playwright`. */ @@ -13924,8 +13922,6 @@ export interface BrowserType { * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * with an exact match to the request origin that the certificate is valid for. * - * **NOTE** Using Client Certificates in combination with Proxy Servers is not supported. - * * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * work by replacing `localhost` with `local.playwright`. */ @@ -16350,8 +16346,6 @@ export interface APIRequest { * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * with an exact match to the request origin that the certificate is valid for. * - * **NOTE** Using Client Certificates in combination with Proxy Servers is not supported. - * * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * work by replacing `localhost` with `local.playwright`. */ @@ -20712,8 +20706,6 @@ export interface BrowserContextOptions { * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * with an exact match to the request origin that the certificate is valid for. * - * **NOTE** Using Client Certificates in combination with Proxy Servers is not supported. - * * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * work by replacing `localhost` with `local.playwright`. */ diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 73cc415a87..e17a43843c 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -5211,8 +5211,6 @@ export interface PlaywrightTestOptions { * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * with an exact match to the request origin that the certificate is valid for. * - * **NOTE** Using Client Certificates in combination with Proxy Servers is not supported. - * * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * work by replacing `localhost` with `local.playwright`. * diff --git a/tests/config/proxy.ts b/tests/config/proxy.ts index 08546c9283..dc2d51b3ec 100644 --- a/tests/config/proxy.ts +++ b/tests/config/proxy.ts @@ -15,9 +15,11 @@ */ import type { IncomingMessage } from 'http'; -import type { Socket } from 'net'; import type { ProxyServer } from '../third_party/proxy'; import { createProxy } from '../third_party/proxy'; +import net from 'net'; +import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../../packages/playwright-core/src/common/socksProxy'; +import { SocksProxy } from '../../packages/playwright-core/lib/common/socksProxy'; export class TestProxy { readonly PORT: number; @@ -27,7 +29,7 @@ export class TestProxy { requestUrls: string[] = []; private readonly _server: ProxyServer; - private readonly _sockets = new Set(); + private readonly _sockets = new Set(); private _handlers: { event: string, handler: (...args: any[]) => void }[] = []; static async create(port: number): Promise { @@ -90,7 +92,7 @@ export class TestProxy { this._server.prependListener(event, handler); } - private _onSocket(socket: Socket) { + private _onSocket(socket: net.Socket) { this._sockets.add(socket); // ECONNRESET and HPE_INVALID_EOF_STATE are legit errors given // that tab closing aborts outgoing connections to the server. @@ -100,5 +102,46 @@ export class TestProxy { }); socket.once('close', () => this._sockets.delete(socket)); } - +} + +export async function setupSocksForwardingServer({ + port, forwardPort, allowedTargetPort +}: { + port: number, forwardPort: number, allowedTargetPort: number +}) { + const connectHosts = []; + const connections = new Map(); + const socksProxy = new SocksProxy(); + socksProxy.setPattern('*'); + socksProxy.addListener(SocksProxy.Events.SocksRequested, async (payload: SocksSocketRequestedPayload) => { + if (!['127.0.0.1', 'fake-localhost-127-0-0-1.nip.io', 'localhost'].includes(payload.host) || payload.port !== allowedTargetPort) { + socksProxy.sendSocketError({ uid: payload.uid, error: 'ECONNREFUSED' }); + return; + } + const target = new net.Socket(); + target.on('error', error => socksProxy.sendSocketError({ uid: payload.uid, error: error.toString() })); + target.on('end', () => socksProxy.sendSocketEnd({ uid: payload.uid })); + target.on('data', data => socksProxy.sendSocketData({ uid: payload.uid, data })); + target.setKeepAlive(false); + target.connect(forwardPort, '127.0.0.1'); + target.on('connect', () => { + connections.set(payload.uid, target); + if (!connectHosts.includes(`${payload.host}:${payload.port}`)) + connectHosts.push(`${payload.host}:${payload.port}`); + socksProxy.socketConnected({ uid: payload.uid, host: target.localAddress, port: target.localPort }); + }); + }); + socksProxy.addListener(SocksProxy.Events.SocksData, async (payload: SocksSocketDataPayload) => { + connections.get(payload.uid)?.write(payload.data); + }); + socksProxy.addListener(SocksProxy.Events.SocksClosed, (payload: SocksSocketClosedPayload) => { + connections.get(payload.uid)?.destroy(); + connections.delete(payload.uid); + }); + await socksProxy.listen(port, 'localhost'); + return { + closeProxyServer: () => socksProxy.close(), + proxyServerAddr: `socks5://localhost:${port}`, + connectHosts, + }; } diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index 899b9819be..156d80adfb 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -23,6 +23,7 @@ import type http from 'http'; import { expect, playwrightTest as base } from '../config/browserTest'; import type net from 'net'; import type { BrowserContextOptions } from 'packages/playwright-test'; +import { setupSocksForwardingServer } from '../config/proxy'; const { createHttpsServer, createHttp2Server } = require('../../packages/playwright-core/lib/utils'); type TestOptions = { @@ -88,14 +89,6 @@ const kValidationSubTests: [BrowserContextOptions, string][] = [ passphrase: kDummyFileName, }] }, 'pfx is specified together with cert, key or passphrase'], - [{ - proxy: { server: 'http://localhost:8080' }, - clientCertificates: [{ - origin: 'test', - certPath: kDummyFileName, - keyPath: kDummyFileName, - }] - }, 'Cannot specify both proxy and clientCertificates'], ]; test.describe('fetch', () => { @@ -180,6 +173,54 @@ test.describe('fetch', () => { await request.dispose(); }); + test('pass with trusted client certificates and when a http proxy is used', async ({ playwright, startCCServer, proxyServer, asset }) => { + const serverURL = await startCCServer(); + proxyServer.forwardTo(parseInt(new URL(serverURL).port, 10), { allowConnectRequests: true }); + const request = await playwright.request.newContext({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin, + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), + }], + proxy: { server: `localhost:${proxyServer.PORT}` } + }); + expect(proxyServer.connectHosts).toEqual([]); + const response = await request.get(serverURL); + expect(proxyServer.connectHosts).toEqual([new URL(serverURL).host]); + expect(response.url()).toBe(serverURL); + expect(response.status()).toBe(200); + expect(await response.text()).toContain('Hello Alice, your certificate was issued by localhost!'); + await request.dispose(); + }); + + test('pass with trusted client certificates and when a socks proxy is used', async ({ playwright, startCCServer, asset }) => { + const serverURL = await startCCServer({ host: '127.0.0.1' }); + const serverPort = parseInt(new URL(serverURL).port, 10); + const { proxyServerAddr, closeProxyServer, connectHosts } = await setupSocksForwardingServer({ + port: test.info().workerIndex + 2048 + 2, + forwardPort: serverPort, + allowedTargetPort: serverPort, + }); + const request = await playwright.request.newContext({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin, + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), + }], + proxy: { server: proxyServerAddr } + }); + expect(connectHosts).toEqual([]); + const response = await request.get(serverURL); + expect(connectHosts).toEqual([new URL(serverURL).host]); + expect(response.url()).toBe(serverURL); + expect(response.status()).toBe(200); + expect(await response.text()).toContain('Hello Alice, your certificate was issued by localhost!'); + await request.dispose(); + await closeProxyServer(); + }); + test('should throw a http error if the pfx passphrase is incorect', async ({ playwright, startCCServer, asset, browserName }) => { const serverURL = await startCCServer(); const request = await playwright.request.newContext({ @@ -311,6 +352,50 @@ test.describe('browser', () => { await page.close(); }); + test('should pass with matching certificates and when a http proxy is used', async ({ browser, startCCServer, asset, browserName, proxyServer }) => { + const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); + proxyServer.forwardTo(parseInt(new URL(serverURL).port, 10), { allowConnectRequests: true }); + const page = await browser.newPage({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin, + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), + }], + proxy: { server: `localhost:${proxyServer.PORT}` } + }); + expect(proxyServer.connectHosts).toEqual([]); + await page.goto(serverURL); + expect([...new Set(proxyServer.connectHosts)]).toEqual([`localhost:${new URL(serverURL).port}`]); + await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!'); + await page.close(); + }); + + test('should pass with matching certificates and when a socks proxy is used', async ({ browser, startCCServer, asset, browserName }) => { + const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin', host: '127.0.0.1' }); + const serverPort = parseInt(new URL(serverURL).port, 10); + const { proxyServerAddr, closeProxyServer, connectHosts } = await setupSocksForwardingServer({ + port: test.info().workerIndex + 2048 + 2, + forwardPort: serverPort, + allowedTargetPort: serverPort, + }); + const page = await browser.newPage({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin, + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), + }], + proxy: { server: proxyServerAddr } + }); + expect(connectHosts).toEqual([]); + await page.goto(serverURL); + expect(connectHosts).toEqual([`localhost:${serverPort}`]); + await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!'); + await page.close(); + await closeProxyServer(); + }); + test('should not hang on tls errors during TLS 1.2 handshake', async ({ browser, asset, platform, browserName }) => { for (const tlsVersion of ['TLSv1.3', 'TLSv1.2'] as const) { await test.step(`TLS version: ${tlsVersion}`, async () => { diff --git a/tests/library/proxy.spec.ts b/tests/library/proxy.spec.ts index 947f3b8788..b2ddcadeba 100644 --- a/tests/library/proxy.spec.ts +++ b/tests/library/proxy.spec.ts @@ -14,10 +14,9 @@ * limitations under the License. */ +import { setupSocksForwardingServer } from '../config/proxy'; import { playwrightTest as it, expect } from '../config/browserTest'; import net from 'net'; -import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../../packages/playwright-core/src/common/socksProxy'; -import { SocksProxy } from '../../packages/playwright-core/lib/common/socksProxy'; it.skip(({ mode }) => mode.startsWith('service')); @@ -288,42 +287,13 @@ it('should use proxy with emulated user agent', async ({ browserType }) => { expect(requestText).toContain('MyUserAgent'); }); -async function setupSocksForwardingServer(port: number, forwardPort: number) { - const connections = new Map(); - const socksProxy = new SocksProxy(); - socksProxy.setPattern('*'); - socksProxy.addListener(SocksProxy.Events.SocksRequested, async (payload: SocksSocketRequestedPayload) => { - if (!['127.0.0.1', 'fake-localhost-127-0-0-1.nip.io'].includes(payload.host) || payload.port !== 1337) { - socksProxy.sendSocketError({ uid: payload.uid, error: 'ECONNREFUSED' }); - return; - } - const target = new net.Socket(); - target.on('error', error => socksProxy.sendSocketError({ uid: payload.uid, error: error.toString() })); - target.on('end', () => socksProxy.sendSocketEnd({ uid: payload.uid })); - target.on('data', data => socksProxy.sendSocketData({ uid: payload.uid, data })); - target.setKeepAlive(false); - target.connect(forwardPort, '127.0.0.1'); - target.on('connect', () => { - connections.set(payload.uid, target); - socksProxy.socketConnected({ uid: payload.uid, host: target.localAddress, port: target.localPort }); - }); - }); - socksProxy.addListener(SocksProxy.Events.SocksData, async (payload: SocksSocketDataPayload) => { - connections.get(payload.uid)?.write(payload.data); - }); - socksProxy.addListener(SocksProxy.Events.SocksClosed, (payload: SocksSocketClosedPayload) => { - connections.get(payload.uid)?.destroy(); - connections.delete(payload.uid); - }); - await socksProxy.listen(port, 'localhost'); - return { - closeProxyServer: () => socksProxy.close(), - proxyServerAddr: `socks5://localhost:${port}`, - }; -} -it('should use SOCKS proxy for websocket requests', async ({ browserName, platform, browserType, server }, testInfo) => { - const { proxyServerAddr, closeProxyServer } = await setupSocksForwardingServer(testInfo.workerIndex + 2048 + 2, server.PORT); +it('should use SOCKS proxy for websocket requests', async ({ browserType, server }) => { + const { proxyServerAddr, closeProxyServer } = await setupSocksForwardingServer({ + port: it.info().workerIndex + 2048 + 2, + forwardPort: server.PORT, + allowedTargetPort: 1337, + }); const browser = await browserType.launch({ proxy: { server: proxyServerAddr, From ce06a81aa6563cb63c8a5077b91fef753ed30ea3 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 16 Sep 2024 12:54:20 -0700 Subject: [PATCH 32/41] feat: make `npx playwright clear-cache` public (#32638) --- packages/playwright/src/program.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index dd8d181676..775ef27065 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -69,7 +69,7 @@ function addListFilesCommand(program: Command) { } function addClearCacheCommand(program: Command) { - const command = program.command('clear-cache', { hidden: true }); + const command = program.command('clear-cache'); command.description('clears build and test caches'); command.option('-c, --config ', `Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}"`); command.action(async opts => { From 92c6408b94c419ab101579bd1ef4403957d3d738 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 16 Sep 2024 13:47:13 -0700 Subject: [PATCH 33/41] fix(recorder): address the react race condition (#32628) --- .../playwright-core/src/server/recorder.ts | 5 +-- .../src/server/recorder/contextRecorder.ts | 5 +-- .../src/server/recorder/recorderApp.ts | 8 ++-- .../src/server/recorder/recorderFrontend.ts | 2 +- .../server/recorder/recorderInTraceViewer.ts | 2 +- packages/recorder/src/recorder.tsx | 40 ++++++++----------- packages/recorder/src/recorderTypes.ts | 2 +- 7 files changed, 27 insertions(+), 37 deletions(-) diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 5e197f871f..ddaa035811 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -132,10 +132,9 @@ export class Recorder implements InstrumentationListener, IRecorder { this._context.instrumentation.removeListener(this); this._recorderApp?.close().catch(() => {}); }); - this._contextRecorder.on(ContextRecorder.Events.Change, (data: { sources: Source[], primaryFileName: string }) => { + this._contextRecorder.on(ContextRecorder.Events.Change, (data: { sources: Source[] }) => { this._recorderSources = data.sources; this._pushAllSources(); - this._recorderApp?.setFileIfNeeded(data.primaryFileName); }); await this._context.exposeBinding('__pw_recorderState', false, source => { @@ -294,7 +293,7 @@ export class Recorder implements InstrumentationListener, IRecorder { } this._pushAllSources(); if (fileToSelect) - this._recorderApp?.setFileIfNeeded(fileToSelect); + this._recorderApp?.setFile(fileToSelect); } private _pushAllSources() { diff --git a/packages/playwright-core/src/server/recorder/contextRecorder.ts b/packages/playwright-core/src/server/recorder/contextRecorder.ts index 9b4efb9e65..6642f13a62 100644 --- a/packages/playwright-core/src/server/recorder/contextRecorder.ts +++ b/packages/playwright-core/src/server/recorder/contextRecorder.ts @@ -97,10 +97,7 @@ export class ContextRecorder extends EventEmitter { if (languageGenerator === this._orderedLanguages[0]) this._throttledOutputFile?.setContent(source.text); } - this.emit(ContextRecorder.Events.Change, { - sources: this._recorderSources, - primaryFileName: this._orderedLanguages[0].id - }); + this.emit(ContextRecorder.Events.Change, { sources: this._recorderSources }); }); context.on(BrowserContext.Events.BeforeClose, () => { this._throttledOutputFile?.flush(); diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index 8044fadf41..67d8b6e8dc 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -30,7 +30,7 @@ import type { IRecorder, IRecorderApp, IRecorderAppFactory } from './recorderFro declare global { interface Window { - playwrightSetFileIfNeeded: (file: string) => void; + playwrightSetFile: (file: string) => void; playwrightSetMode: (mode: Mode) => void; playwrightSetPaused: (paused: boolean) => void; playwrightSetSources: (sources: Source[]) => void; @@ -46,7 +46,7 @@ export class EmptyRecorderApp extends EventEmitter implements IRecorderApp { async close(): Promise {} async setPaused(paused: boolean): Promise {} async setMode(mode: Mode): Promise {} - async setFileIfNeeded(file: string): Promise {} + async setFile(file: string): Promise {} async setSelector(selector: string, userGesture?: boolean): Promise {} async updateCallLogs(callLogs: CallLog[]): Promise {} async setSources(sources: Source[]): Promise {} @@ -144,9 +144,9 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { }).toString(), { isFunction: true }, mode).catch(() => {}); } - async setFileIfNeeded(file: string): Promise { + async setFile(file: string): Promise { await this._page.mainFrame().evaluateExpression(((file: string) => { - window.playwrightSetFileIfNeeded(file); + window.playwrightSetFile(file); }).toString(), { isFunction: true }, file).catch(() => {}); } diff --git a/packages/playwright-core/src/server/recorder/recorderFrontend.ts b/packages/playwright-core/src/server/recorder/recorderFrontend.ts index 161aa71eca..162c9f9964 100644 --- a/packages/playwright-core/src/server/recorder/recorderFrontend.ts +++ b/packages/playwright-core/src/server/recorder/recorderFrontend.ts @@ -26,7 +26,7 @@ export interface IRecorderApp extends EventEmitter { close(): Promise; setPaused(paused: boolean): Promise; setMode(mode: Mode): Promise; - setFileIfNeeded(file: string): Promise; + setFile(file: string): Promise; setSelector(selector: string, userGesture?: boolean): Promise; updateCallLogs(callLogs: CallLog[]): Promise; setSources(sources: Source[]): Promise; diff --git a/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts b/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts index f7613ffc54..a9fd766141 100644 --- a/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts +++ b/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts @@ -55,7 +55,7 @@ export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp this._transport.sendEvent?.('setMode', { mode }); } - async setFileIfNeeded(file: string): Promise { + async setFile(file: string): Promise { this._transport.sendEvent?.('setFileIfNeeded', { file }); } diff --git a/packages/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx index 486aeae701..31bad2b70b 100644 --- a/packages/recorder/src/recorder.tsx +++ b/packages/recorder/src/recorder.tsx @@ -27,14 +27,6 @@ import { asLocator } from '@isomorphic/locatorGenerators'; import { toggleTheme } from '@web/theme'; import { copy } from '@web/uiUtils'; -declare global { - interface Window { - playwrightSetFileIfNeeded: (file: string) => void; - playwrightSetSelector: (selector: string, focus?: boolean) => void; - dispatch(data: any): Promise; - } -} - export interface RecorderProps { sources: Source[], paused: boolean, @@ -56,14 +48,22 @@ export const Recorder: React.FC = ({ setFileId(sources[0].id); }, [fileId, sources]); - const source: Source = sources.find(s => s.id === fileId) || { - id: 'default', - isRecorded: false, - text: '', - language: 'javascript', - label: '', - highlight: [] - }; + const source = React.useMemo(() => { + if (fileId) { + const source = sources.find(s => s.id === fileId); + if (source) + return source; + } + const source: Source = { + id: 'default', + isRecorded: false, + text: '', + language: 'javascript', + label: '', + highlight: [] + }; + return source; + }, [sources, fileId]); const [locator, setLocator] = React.useState(''); window.playwrightSetSelector = (selector: string, focus?: boolean) => { @@ -73,13 +73,7 @@ export const Recorder: React.FC = ({ setLocator(asLocator(language, selector)); }; - window.playwrightSetFileIfNeeded = (value: string) => { - const newSource = sources.find(s => s.id === value); - // Do not forcefully switch between two recorded sources, because - // user did explicitly choose one. - if (newSource && !newSource.isRecorded || !source.isRecorded) - setFileId(value); - }; + window.playwrightSetFile = setFileId; const messagesEndRef = React.useRef(null); React.useLayoutEffect(() => { diff --git a/packages/recorder/src/recorderTypes.ts b/packages/recorder/src/recorderTypes.ts index c56984ad6d..a5791e2306 100644 --- a/packages/recorder/src/recorderTypes.ts +++ b/packages/recorder/src/recorderTypes.ts @@ -96,7 +96,7 @@ declare global { playwrightSetSources: (sources: Source[]) => void; playwrightSetOverlayVisible: (visible: boolean) => void; playwrightUpdateLogs: (callLogs: CallLog[]) => void; - playwrightSetFileIfNeeded: (file: string) => void; + playwrightSetFile: (file: string) => void; playwrightSetSelector: (selector: string, focus?: boolean) => void; playwrightSourcesEchoForTest: Source[]; dispatch(data: any): Promise; From 6dbde62a6b7a326fc70869b693de7bc3885b5883 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 16 Sep 2024 14:39:36 -0700 Subject: [PATCH 34/41] chore: simplify signal handling while recording (#32624) --- .../src/server/codegen/language.ts | 17 +++ .../src/server/codegen/types.ts | 2 +- .../src/server/recorder/contextRecorder.ts | 59 ++------ .../src/server/recorder/recorderCollection.ts | 133 +++++++----------- .../src/server/recorder/recorderUtils.ts | 22 ++- tests/library/inspector/cli-codegen-1.spec.ts | 30 +++- 6 files changed, 129 insertions(+), 134 deletions(-) diff --git a/packages/playwright-core/src/server/codegen/language.ts b/packages/playwright-core/src/server/codegen/language.ts index 3e0c8f71e5..4b1ba99b6f 100644 --- a/packages/playwright-core/src/server/codegen/language.ts +++ b/packages/playwright-core/src/server/codegen/language.ts @@ -20,6 +20,7 @@ import type * as types from '../types'; import type { ActionInContext, LanguageGenerator, LanguageGeneratorOptions } from './types'; export function generateCode(actions: ActionInContext[], languageGenerator: LanguageGenerator, options: LanguageGeneratorOptions) { + actions = collapseActions(actions); const header = languageGenerator.generateHeader(options); const footer = languageGenerator.generateFooter(options.saveStorage); const actionTexts = actions.map(a => languageGenerator.generateAction(a)).filter(Boolean); @@ -83,3 +84,19 @@ export function toClickOptionsForSourceCode(action: actions.ClickAction): types. options.position = action.position; return options; } + +function collapseActions(actions: ActionInContext[]): ActionInContext[] { + const result: ActionInContext[] = []; + for (const action of actions) { + const lastAction = result[result.length - 1]; + const isSameAction = lastAction && lastAction.action.name === action.action.name && lastAction.frame.pageAlias === action.frame.pageAlias && lastAction.frame.framePath.join('|') === action.frame.framePath.join('|'); + const isSameSelector = lastAction && 'selector' in lastAction.action && 'selector' in action.action && action.action.selector === lastAction.action.selector; + const shouldMerge = isSameAction && (action.action.name === 'navigate' || (action.action.name === 'fill' && isSameSelector)); + if (!shouldMerge) { + result.push(action); + continue; + } + result[result.length - 1] = action; + } + return result; +} diff --git a/packages/playwright-core/src/server/codegen/types.ts b/packages/playwright-core/src/server/codegen/types.ts index 96f2aa85d1..48c1141a3e 100644 --- a/packages/playwright-core/src/server/codegen/types.ts +++ b/packages/playwright-core/src/server/codegen/types.ts @@ -36,7 +36,7 @@ export type ActionInContext = { frame: FrameDescription; description?: string; action: actions.Action; - committed?: boolean; + timestamp: number; }; export interface LanguageGenerator { diff --git a/packages/playwright-core/src/server/recorder/contextRecorder.ts b/packages/playwright-core/src/server/recorder/contextRecorder.ts index 6642f13a62..71a1d3ec75 100644 --- a/packages/playwright-core/src/server/recorder/contextRecorder.ts +++ b/packages/playwright-core/src/server/recorder/contextRecorder.ts @@ -18,7 +18,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 { eventsHelper, isUnderTest, monotonicTime, quoteCSSAttributeValue, type RegisteredListener } from '../../utils'; +import { eventsHelper, monotonicTime, quoteCSSAttributeValue, type RegisteredListener } from '../../utils'; import { raceAgainstDeadline } from '../../utils/timeoutRunner'; import { BrowserContext } from '../browserContext'; import type { ActionInContext, FrameDescription, LanguageGeneratorOptions, Language, LanguageGenerator } from '../codegen/types'; @@ -27,7 +27,6 @@ import type { Dialog } from '../dialog'; import { Frame } from '../frames'; import { Page } from '../page'; import type * as actions from './recorderActions'; -import { performAction } from './recorderRunner'; import { ThrottledFile } from './throttledFile'; import { RecorderCollection } from './recorderCollection'; import { generateCode } from '../codegen/language'; @@ -48,7 +47,6 @@ export class ContextRecorder extends EventEmitter { private _lastPopupOrdinal = 0; private _lastDialogOrdinal = -1; private _lastDownloadOrdinal = -1; - private _timers = new Set(); private _context: BrowserContext; private _params: channels.BrowserContextRecorderSupplementEnableParams; private _delegate: ContextRecorderDelegate; @@ -150,9 +148,6 @@ export class ContextRecorder extends EventEmitter { } dispose() { - for (const timer of this._timers) - clearTimeout(timer); - this._timers.clear(); eventsHelper.removeEventListeners(this._listeners); } @@ -162,11 +157,11 @@ export class ContextRecorder extends EventEmitter { page.on('close', () => { this._collection.addRecordedAction({ frame: this._describeMainFrame(page), - committed: true, action: { name: 'closePage', signals: [], - } + }, + timestamp: monotonicTime() }); this._pageAliases.delete(page); }); @@ -184,12 +179,12 @@ export class ContextRecorder extends EventEmitter { } else { this._collection.addRecordedAction({ frame: this._describeMainFrame(page), - committed: true, action: { name: 'openPage', url: page.mainFrame().url(), signals: [], - } + }, + timestamp: monotonicTime() }); } } @@ -220,54 +215,24 @@ export class ContextRecorder extends EventEmitter { return this._params.testIdAttributeName || this._context.selectors().testIdAttributeName() || 'data-testid'; } - private async _performAction(frame: Frame, action: actions.PerformOnRecordAction) { - // Commit last action so that no further signals are added to it. - this._collection.commitLastAction(); - + private async _createActionInContext(frame: Frame, action: actions.Action): Promise { const frameDescription = await this._describeFrame(frame); const actionInContext: ActionInContext = { frame: frameDescription, action, description: undefined, + timestamp: monotonicTime() }; - await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext); + return actionInContext; + } - const callMetadata = await this._collection.willPerformAction(actionInContext); - if (!callMetadata) - return; - const error = await performAction(callMetadata, this._pageAliases, actionInContext).then(() => undefined).catch((e: Error) => e); - await this._collection.didPerformAction(callMetadata, actionInContext, error); - if (error) - actionInContext.committed = true; - else - this._setCommittedAfterTimeout(actionInContext); + private async _performAction(frame: Frame, action: actions.PerformOnRecordAction) { + await this._collection.performAction(await this._createActionInContext(frame, action)); } private async _recordAction(frame: Frame, action: actions.Action) { - // Commit last action so that no further signals are added to it. - this._collection.commitLastAction(); - - const frameDescription = await this._describeFrame(frame); - const actionInContext: ActionInContext = { - frame: frameDescription, - action, - description: undefined, - }; - - await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext); - - this._setCommittedAfterTimeout(actionInContext); - this._collection.addRecordedAction(actionInContext); - } - - private _setCommittedAfterTimeout(actionInContext: ActionInContext) { - const timer = setTimeout(() => { - // Commit the action after 5 seconds so that no further signals are added to it. - actionInContext.committed = true; - this._timers.delete(timer); - }, isUnderTest() ? 500 : 5000); - this._timers.add(timer); + this._collection.addRecordedAction(await this._createActionInContext(frame, action)); } private _onFrameNavigated(frame: Frame, page: Page) { diff --git a/packages/playwright-core/src/server/recorder/recorderCollection.ts b/packages/playwright-core/src/server/recorder/recorderCollection.ts index fbfbf8f26e..e9c2b31427 100644 --- a/packages/playwright-core/src/server/recorder/recorderCollection.ts +++ b/packages/playwright-core/src/server/recorder/recorderCollection.ts @@ -19,13 +19,14 @@ import type { Frame } from '../frames'; import type { Page } from '../page'; import type { Signal } from './recorderActions'; import type { ActionInContext } from '../codegen/types'; -import type { CallMetadata } from '@protocol/callMetadata'; -import { createGuid } from '../../utils/crypto'; import { monotonicTime } from '../../utils/time'; -import { mainFrameForAction, traceParamsForAction } from './recorderUtils'; +import { callMetadataForAction } from './recorderUtils'; +import { serializeError } from '../errors'; +import { performAction } from './recorderRunner'; +import type { CallMetadata } from '@protocol/callMetadata'; +import { isUnderTest } from '../../utils/debug'; export class RecorderCollection extends EventEmitter { - private _lastAction: ActionInContext | null = null; private _actions: ActionInContext[] = []; private _enabled: boolean; private _pageAliases: Map; @@ -38,7 +39,6 @@ export class RecorderCollection extends EventEmitter { } restart() { - this._lastAction = null; this._actions = []; this.emit('change'); } @@ -51,98 +51,73 @@ export class RecorderCollection extends EventEmitter { this._enabled = enabled; } - async willPerformAction(actionInContext: ActionInContext): Promise { - if (!this._enabled) - return null; - const { callMetadata, mainFrame } = this._callMetadataForAction(actionInContext); - await mainFrame.instrumentation.onBeforeCall(mainFrame, callMetadata); - this._lastAction = actionInContext; - return callMetadata; - } - - private _callMetadataForAction(actionInContext: ActionInContext): { callMetadata: CallMetadata, mainFrame: Frame } { - const mainFrame = mainFrameForAction(this._pageAliases, actionInContext); - const { action } = actionInContext; - const callMetadata: CallMetadata = { - id: `call@${createGuid()}`, - apiName: 'frame.' + action.name, - objectId: mainFrame.guid, - pageId: mainFrame._page.guid, - frameId: mainFrame.guid, - startTime: monotonicTime(), - endTime: 0, - type: 'Frame', - method: action.name, - params: traceParamsForAction(actionInContext), - log: [], - }; - return { callMetadata, mainFrame }; - } - - async didPerformAction(callMetadata: CallMetadata, actionInContext: ActionInContext, error?: Error) { - if (!this._enabled) - return; - - if (!error) - this._actions.push(actionInContext); - - const mainFrame = mainFrameForAction(this._pageAliases, actionInContext); - callMetadata.endTime = monotonicTime(); - await mainFrame.instrumentation.onAfterCall(mainFrame, callMetadata); - - this.emit('change'); + async performAction(actionInContext: ActionInContext) { + await this._addAction(actionInContext, async callMetadata => { + await performAction(callMetadata, this._pageAliases, actionInContext); + }); } addRecordedAction(actionInContext: ActionInContext) { - if (!this._enabled) - return; - const action = actionInContext.action; - - const lastAction = this._lastAction && this._lastAction.frame.pageAlias === actionInContext.frame.pageAlias ? this._lastAction.action : undefined; - if (lastAction && action.name === 'navigate' && lastAction.name === 'navigate' && action.url === lastAction.url) { - // Already at a target URL. + if (['openPage', 'closePage'].includes(actionInContext.action.name)) { + this._actions.push(actionInContext); + this.emit('change'); return; } - - if (lastAction && action.name === 'fill' && lastAction.name === 'fill' && action.selector === lastAction.selector) - this._actions.pop(); - - this._lastAction = actionInContext; - this._actions.push(actionInContext); - this.emit('change'); + this._addAction(actionInContext).catch(() => {}); } - commitLastAction() { + private async _addAction(actionInContext: ActionInContext, callback?: (callMetadata: CallMetadata) => Promise) { if (!this._enabled) return; - const action = this._lastAction; - if (action) - action.committed = true; + + const { callMetadata, mainFrame } = callMetadataForAction(this._pageAliases, actionInContext); + await mainFrame.instrumentation.onBeforeCall(mainFrame, callMetadata); + this._actions.push(actionInContext); + this.emit('change'); + const error = await callback?.(callMetadata).catch((e: Error) => e); + callMetadata.endTime = monotonicTime(); + callMetadata.error = error ? serializeError(error) : undefined; + await mainFrame.instrumentation.onAfterCall(mainFrame, callMetadata); } signal(pageAlias: string, frame: Frame, signal: Signal) { if (!this._enabled) return; - if (this._lastAction && !this._lastAction.committed) { - this._lastAction.action.signals.push(signal); - this.emit('change'); + if (signal.name === 'navigation' && frame._page.mainFrame() === frame) { + const timestamp = monotonicTime(); + const lastAction = this._actions[this._actions.length - 1]; + const signalThreshold = isUnderTest() ? 500 : 5000; + + let generateGoto = false; + if (!lastAction) + generateGoto = true; + else if (lastAction.action.name !== 'click' && lastAction.action.name !== 'press') + generateGoto = true; + else if (timestamp - lastAction.timestamp > signalThreshold) + generateGoto = true; + + if (generateGoto) { + this.addRecordedAction({ + frame: { + pageAlias, + framePath: [], + }, + action: { + name: 'navigate', + url: frame.url(), + signals: [], + }, + timestamp + }); + } return; } - if (signal.name === 'navigation' && frame._page.mainFrame() === frame) { - this.addRecordedAction({ - frame: { - pageAlias, - framePath: [], - }, - committed: true, - action: { - name: 'navigate', - url: frame.url(), - signals: [], - }, - }); + if (this._actions.length) { + this._actions[this._actions.length - 1].action.signals.push(signal); + this.emit('change'); + return; } } } diff --git a/packages/playwright-core/src/server/recorder/recorderUtils.ts b/packages/playwright-core/src/server/recorder/recorderUtils.ts index 234fc79a0f..ac6c970489 100644 --- a/packages/playwright-core/src/server/recorder/recorderUtils.ts +++ b/packages/playwright-core/src/server/recorder/recorderUtils.ts @@ -22,6 +22,7 @@ import type { Frame } from '../frames'; import type * as actions from './recorderActions'; import { toKeyboardModifiers } from '../codegen/language'; import { serializeExpectedTextValues } from '../../utils/expectUtils'; +import { createGuid, monotonicTime } from '../../utils'; export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus): CallLog { let title = metadata.apiName || metadata.method; @@ -59,7 +60,7 @@ export function mainFrameForAction(pageAliases: Map, actionInConte const pageAlias = actionInContext.frame.pageAlias; const page = [...pageAliases.entries()].find(([, alias]) => pageAlias === alias)?.[0]; if (!page) - throw new Error('Internal error: page not found'); + throw new Error(`Internal error: page ${pageAlias} not found in [${[...pageAliases.values()]}]`); return page.mainFrame(); } @@ -129,3 +130,22 @@ export function traceParamsForAction(actionInContext: ActionInContext) { } } } + +export function callMetadataForAction(pageAliases: Map, actionInContext: ActionInContext): { callMetadata: CallMetadata, mainFrame: Frame } { + const mainFrame = mainFrameForAction(pageAliases, actionInContext); + const { action } = actionInContext; + const callMetadata: CallMetadata = { + id: `call@${createGuid()}`, + apiName: 'frame.' + action.name, + objectId: mainFrame.guid, + pageId: mainFrame._page.guid, + frameId: mainFrame.guid, + startTime: monotonicTime(), + endTime: 0, + type: 'Frame', + method: action.name, + params: traceParamsForAction(actionInContext), + log: [], + }; + return { callMetadata, mainFrame }; +} diff --git a/tests/library/inspector/cli-codegen-1.spec.ts b/tests/library/inspector/cli-codegen-1.spec.ts index 4320aac5e8..c8046ef9b4 100644 --- a/tests/library/inspector/cli-codegen-1.spec.ts +++ b/tests/library/inspector/cli-codegen-1.spec.ts @@ -706,7 +706,7 @@ var page1 = await page.RunAndWaitForPopupAsync(async () => expect(popup.url()).toBe('about:blank'); }); - test('should assert navigation', async ({ page, openRecorder }) => { + test('should attribute navigation to click', async ({ page, openRecorder }) => { const recorder = await openRecorder(); await recorder.setContentAndWait(`link`); @@ -720,24 +720,42 @@ var page1 = await page.RunAndWaitForPopupAsync(async () => ]); expect.soft(sources.get('JavaScript')!.text).toContain(` - await page.getByText('link').click();`); + await page.goto('about:blank'); + await page.getByText('link').click(); + + // --------------------- + await context.close();`); expect.soft(sources.get('Playwright Test')!.text).toContain(` - await page.getByText('link').click();`); + await page.goto('about:blank'); + await page.getByText('link').click(); +});`); expect.soft(sources.get('Java')!.text).toContain(` - page.getByText("link").click();`); + page.navigate(\"about:blank\"); + page.getByText(\"link\").click(); + }`); expect.soft(sources.get('Python')!.text).toContain(` - page.get_by_text("link").click()`); + page.goto("about:blank") + page.get_by_text("link").click() + + # --------------------- + context.close()`); expect.soft(sources.get('Python Async')!.text).toContain(` - await page.get_by_text("link").click()`); + await page.goto("about:blank") + await page.get_by_text("link").click() + + # --------------------- + await context.close()`); expect.soft(sources.get('Pytest')!.text).toContain(` + page.goto("about:blank") page.get_by_text("link").click()`); expect.soft(sources.get('C#')!.text).toContain(` +await page.GotoAsync("about:blank"); await page.GetByText("link").ClickAsync();`); expect(page.url()).toContain('about:blank#foo'); From 2a347b5494e7794a06770634bb2986929c32c727 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 16 Sep 2024 17:31:26 -0700 Subject: [PATCH 35/41] chore: support launchPersistentContext with bidi (#32641) --- .../src/server/bidi/bidiBrowser.ts | 15 ++++++++++++--- .../src/server/bidi/bidiFirefox.ts | 6 ------ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/playwright-core/src/server/bidi/bidiBrowser.ts b/packages/playwright-core/src/server/bidi/bidiBrowser.ts index cc98e2ff3a..9861fc80cf 100644 --- a/packages/playwright-core/src/server/bidi/bidiBrowser.ts +++ b/packages/playwright-core/src/server/bidi/bidiBrowser.ts @@ -94,6 +94,14 @@ export class BidiBrowser extends Browser { 'script', ], }); + + if (options.persistent) { + browser._defaultContext = new BidiBrowserContext(browser, undefined, options.persistent); + await (browser._defaultContext as BidiBrowserContext)._initialize(); + // Create default page as we cannot get access to the existing one. + const pageDelegate = await browser._defaultContext.newPageDelegate(); + await pageDelegate.pageOrError(); + } return browser; } @@ -294,10 +302,11 @@ export class BidiBrowserContext extends BrowserContext { } async doClose(reason: string | undefined) { - // TODO: implement for persistent context - if (!this._browserContextId) + if (!this._browserContextId) { + // Closing persistent context should close the browser. + await this._browser.close({ reason }); return; - + } await this._browser._browserSession.send('browser.removeUserContext', { userContext: this._browserContextId }); diff --git a/packages/playwright-core/src/server/bidi/bidiFirefox.ts b/packages/playwright-core/src/server/bidi/bidiFirefox.ts index 3a2da48c25..3fb7c15b90 100644 --- a/packages/playwright-core/src/server/bidi/bidiFirefox.ts +++ b/packages/playwright-core/src/server/bidi/bidiFirefox.ts @@ -76,12 +76,6 @@ export class BidiFirefox extends BrowserType { firefoxArguments.push('--foreground'); firefoxArguments.push(`--profile`, userDataDir); firefoxArguments.push(...args); - // TODO: make ephemeral context work without this argument. - firefoxArguments.push('about:blank'); - // if (isPersistent) - // firefoxArguments.push('about:blank'); - // else - // firefoxArguments.push('-silent'); return firefoxArguments; } From 3bff7b6ab1ce5100b59b8c07d0dc342cf87d6e24 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 16 Sep 2024 17:33:52 -0700 Subject: [PATCH 36/41] chore: preserve selected trace action in live trace (#32630) --- packages/trace-viewer/src/ui/errorsTab.tsx | 2 +- packages/trace-viewer/src/ui/modelUtil.ts | 4 -- .../trace-viewer/src/ui/uiModeTraceView.tsx | 11 +--- packages/trace-viewer/src/ui/workbench.tsx | 59 +++++++++++-------- 4 files changed, 36 insertions(+), 40 deletions(-) diff --git a/packages/trace-viewer/src/ui/errorsTab.tsx b/packages/trace-viewer/src/ui/errorsTab.tsx index 71b6059c29..acf5bf838e 100644 --- a/packages/trace-viewer/src/ui/errorsTab.tsx +++ b/packages/trace-viewer/src/ui/errorsTab.tsx @@ -22,7 +22,7 @@ import { renderAction } from './actionList'; import type { Language } from '@isomorphic/locatorGenerators'; import type { StackFrame } from '@protocol/channels'; -type ErrorDescription = { +export type ErrorDescription = { action?: modelUtil.ActionTraceEventInContext; stack?: StackFrame[]; }; diff --git a/packages/trace-viewer/src/ui/modelUtil.ts b/packages/trace-viewer/src/ui/modelUtil.ts index 0c22d954f4..a544d4dc3f 100644 --- a/packages/trace-viewer/src/ui/modelUtil.ts +++ b/packages/trace-viewer/src/ui/modelUtil.ts @@ -342,10 +342,6 @@ export function buildActionTree(actions: ActionTraceEventInContext[]): { rootIte return { rootItem, itemMap }; } -export function idForAction(action: ActionTraceEvent) { - return `${action.pageId || 'none'}:${action.callId}`; -} - export function context(action: ActionTraceEvent | trace.EventTraceEvent | ResourceSnapshot): ContextEntry { return (action as any)[contextSymbol]; } diff --git a/packages/trace-viewer/src/ui/uiModeTraceView.tsx b/packages/trace-viewer/src/ui/uiModeTraceView.tsx index 48fd59e3ca..c0763fee3d 100644 --- a/packages/trace-viewer/src/ui/uiModeTraceView.tsx +++ b/packages/trace-viewer/src/ui/uiModeTraceView.tsx @@ -16,14 +16,13 @@ import { artifactsFolderName } from '@testIsomorphic/folders'; import type { TreeItem } from '@testIsomorphic/testTree'; -import type { ActionTraceEvent } from '@trace/trace'; import '@web/common.css'; import '@web/third_party/vscode/codicon.css'; import type * as reporterTypes from 'playwright/types/testReporter'; import React from 'react'; import type { ContextEntry } from '../entries'; import type { SourceLocation } from './modelUtil'; -import { idForAction, MultiTraceModel } from './modelUtil'; +import { MultiTraceModel } from './modelUtil'; import { Workbench } from './workbench'; export const TraceView: React.FC<{ @@ -42,12 +41,6 @@ export const TraceView: React.FC<{ return { outputDir }; }, [item]); - // Preserve user selection upon live-reloading trace model by persisting the action id. - // This avoids auto-selection of the last action every time we reload the model. - const [selectedActionId, setSelectedActionId] = React.useState(); - const onSelectionChanged = React.useCallback((action: ActionTraceEvent) => setSelectedActionId(idForAction(action)), [setSelectedActionId]); - const initialSelection = selectedActionId ? model?.model.actions.find(a => idForAction(a) === selectedActionId) : undefined; - React.useEffect(() => { if (pollTimer.current) clearTimeout(pollTimer.current); @@ -98,8 +91,6 @@ export const TraceView: React.FC<{ model={model?.model} showSourcesFirst={true} rootDir={rootDir} - initialSelection={initialSelection} - onSelectionChanged={onSelectionChanged} fallbackLocation={item.testFile} isLive={model?.isLive} status={item.treeItem?.status} diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index 3565eb3850..e1ce2298ae 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -20,11 +20,11 @@ import { ActionList } from './actionList'; import { CallTab } from './callTab'; import { LogTab } from './logTab'; import { ErrorsTab, useErrorsTabModel } from './errorsTab'; +import type { ErrorDescription } from './errorsTab'; import type { ConsoleEntry } from './consoleTab'; import { ConsoleTab, useConsoleTabModel } from './consoleTab'; import type * as modelUtil from './modelUtil'; import { isRouteAction } from './modelUtil'; -import type { StackFrame } from '@protocol/channels'; import { NetworkTab, useNetworkTabModel } from './networkTab'; import { SnapshotTab } from './snapshotTab'; import { SourceTab } from './sourceTab'; @@ -49,8 +49,6 @@ export const Workbench: React.FunctionComponent<{ showSourcesFirst?: boolean, rootDir?: string, fallbackLocation?: modelUtil.SourceLocation, - initialSelection?: modelUtil.ActionTraceEventInContext, - onSelectionChanged?: (action: modelUtil.ActionTraceEventInContext) => void, isLive?: boolean, status?: UITestStatus, annotations?: { type: string; description?: string; }[]; @@ -59,9 +57,10 @@ export const Workbench: React.FunctionComponent<{ onOpenExternally?: (location: modelUtil.SourceLocation) => void, revealSource?: boolean, showSettings?: boolean, -}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => { - const [selectedAction, setSelectedActionImpl] = React.useState(undefined); - const [revealedStack, setRevealedStack] = React.useState(undefined); +}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, 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 [highlightedEntry, setHighlightedEntry] = React.useState(); const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState(); @@ -69,38 +68,39 @@ export const Workbench: React.FunctionComponent<{ const [selectedPropertiesTab, setSelectedPropertiesTab] = useSetting('propertiesTab', showSourcesFirst ? 'source' : 'call'); const [isInspecting, setIsInspectingState] = React.useState(false); const [highlightedLocator, setHighlightedLocator] = React.useState(''); - const activeAction = model ? highlightedAction || selectedAction : undefined; const [selectedTime, setSelectedTime] = React.useState(); const [sidebarLocation, setSidebarLocation] = useSetting<'bottom' | 'right'>('propertiesSidebarLocation', 'bottom'); const [showRouteActions, setShowRouteActions] = useSetting('show-route-actions', true); const [showScreenshot, setShowScreenshot] = useSetting('screenshot-instead-of-snapshot', false); - const filteredActions = React.useMemo(() => { return (model?.actions || []).filter(action => showRouteActions || !isRouteAction(action)); }, [model, showRouteActions]); const setSelectedAction = React.useCallback((action: modelUtil.ActionTraceEventInContext | undefined) => { - setSelectedActionImpl(action); - setRevealedStack(action?.stack); - }, [setSelectedActionImpl, setRevealedStack]); + setSelectedCallId(action?.callId); + setRevealedError(undefined); + }, []); const sources = React.useMemo(() => model?.sources || new Map(), [model]); React.useEffect(() => { setSelectedTime(undefined); - setRevealedStack(undefined); + setRevealedError(undefined); }, [model]); - React.useEffect(() => { - if (selectedAction && model?.actions.includes(selectedAction)) - return; + const selectedAction = React.useMemo(() => { + if (selectedCallId) { + const action = model?.actions.find(a => a.callId === selectedCallId); + if (action) + return action; + } + const failedAction = model?.failedAction(); - if (initialSelection && model?.actions.includes(initialSelection)) { - setSelectedAction(initialSelection); - } else if (failedAction) { - setSelectedAction(failedAction); - } else if (model?.actions.length) { + if (failedAction) + return failedAction; + + if (model?.actions.length) { // Select the last non-after hooks item. let index = model.actions.length - 1; for (let i = 0; i < model.actions.length; ++i) { @@ -109,15 +109,24 @@ export const Workbench: React.FunctionComponent<{ break; } } - setSelectedAction(model.actions[index]); + return model.actions[index]; } - }, [model, selectedAction, setSelectedAction, initialSelection]); + }, [model, selectedCallId]); + + const revealedStack = React.useMemo(() => { + if (revealedError) + return revealedError.stack; + return selectedAction?.stack; + }, [selectedAction, revealedError]); + + const activeAction = React.useMemo(() => { + return highlightedAction || selectedAction; + }, [selectedAction, highlightedAction]); const onActionSelected = React.useCallback((action: modelUtil.ActionTraceEventInContext) => { setSelectedAction(action); setHighlightedAction(undefined); - onSelectionChanged?.(action); - }, [setSelectedAction, onSelectionChanged, setHighlightedAction]); + }, [setSelectedAction, setHighlightedAction]); const selectPropertiesTab = React.useCallback((tab: string) => { setSelectedPropertiesTab(tab); @@ -177,7 +186,7 @@ export const Workbench: React.FunctionComponent<{ if (error.action) setSelectedAction(error.action); else - setRevealedStack(error.stack); + setRevealedError(error); selectPropertiesTab('source'); }} /> }; From 47713e8a66ebae6958260780be0d04b277e71fe9 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 16 Sep 2024 22:47:54 -0700 Subject: [PATCH 37/41] chore: make recorder tests pass in frozen mode (#32645) --- .../playwright-core/src/server/injected/recorder/recorder.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/src/server/injected/recorder/recorder.ts b/packages/playwright-core/src/server/injected/recorder/recorder.ts index 48639fefc8..3ad40d53e2 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -211,7 +211,7 @@ class RecordActionTool implements RecorderTool { private _hoveredElement: HTMLElement | null = null; private _activeModel: HighlightModel | null = null; private _expectProgrammaticKeyUp = false; - private _pendingClickAction: { action: actions.ClickAction, timeout: NodeJS.Timeout } | undefined; + private _pendingClickAction: { action: actions.ClickAction, timeout: number } | undefined; constructor(recorder: Recorder) { this._recorder = recorder; @@ -268,7 +268,7 @@ class RecordActionTool implements RecorderTool { modifiers: modifiersForEvent(event), clickCount: event.detail }, - timeout: setTimeout(() => this._commitPendingClickAction(), 200) + timeout: this._recorder.injectedScript.builtinSetTimeout(() => this._commitPendingClickAction(), 200) }; } } From b0f15b320fddec3e62ec1254111c6875ec484114 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 17 Sep 2024 08:37:49 +0200 Subject: [PATCH 38/41] fix(recorder): reattach toolbar if it was unmounted by framework hydration (#32637) Closes https://github.com/microsoft/playwright/issues/32632. A side effect of Remix's hydration implementation is that it throws away the entire DOM. This is broadly discussed in https://github.com/remix-run/remix/issues/4822 - there might be a fix in coming React versions, but who knows. Besides breaking browser extensions, this also deletes our toolbar! This PR fixes it by periodically checking in on `x-pw-glass`, and remounting it if it was unmounted. Hacky but effective! --- .../src/server/injected/highlight.ts | 3 ++- .../src/server/injected/recorder/recorder.ts | 7 +++++++ tests/library/inspector/cli-codegen-3.spec.ts | 17 +++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/src/server/injected/highlight.ts b/packages/playwright-core/src/server/injected/highlight.ts index c85216e106..c89df54a6f 100644 --- a/packages/playwright-core/src/server/injected/highlight.ts +++ b/packages/playwright-core/src/server/injected/highlight.ts @@ -90,7 +90,8 @@ export class Highlight { } install() { - this._injectedScript.document.documentElement.appendChild(this._glassPaneElement); + if (!this._injectedScript.document.documentElement.contains(this._glassPaneElement)) + this._injectedScript.document.documentElement.appendChild(this._glassPaneElement); } setLanguage(language: Language) { diff --git a/packages/playwright-core/src/server/injected/recorder/recorder.ts b/packages/playwright-core/src/server/injected/recorder/recorder.ts index 3ad40d53e2..dfbd4608f0 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -1036,7 +1036,14 @@ export class Recorder { addEventListener(this.document, 'focus', event => this._onFocus(event), true), addEventListener(this.document, 'scroll', event => this._onScroll(event), true), ]; + this.highlight.install(); + // some frameworks erase the DOM on hydration, this ensures it's reattached + const recreationInterval = setInterval(() => { + this.highlight.install(); + }, 500); + this._listeners.push(() => clearInterval(recreationInterval)); + this.overlay?.install(); this.document.adoptedStyleSheets.push(this._stylesheet); } diff --git a/tests/library/inspector/cli-codegen-3.spec.ts b/tests/library/inspector/cli-codegen-3.spec.ts index 6828576456..b2f0c39988 100644 --- a/tests/library/inspector/cli-codegen-3.spec.ts +++ b/tests/library/inspector/cli-codegen-3.spec.ts @@ -739,4 +739,21 @@ await page.GetByLabel("Coun\\"try").ClickAsync();`); expect.soft(sources1.get('Java')!.text).toContain(`assertThat(page.getByRole(AriaRole.TEXTBOX)).isVisible()`); expect.soft(sources1.get('C#')!.text).toContain(`await Expect(page.GetByRole(AriaRole.Textbox)).ToBeVisibleAsync()`); }); + + test('should keep toolbar visible even if webpage erases content in hydration', async ({ openRecorder }) => { + const recorder = await openRecorder(); + + const hydrate = () => { + setTimeout(() => { + document.documentElement.innerHTML = '

Post-Hydration Content

'; + }, 500); + }; + await recorder.setContentAndWait(` +

Pre-Hydration Content

+ + `); + + await expect(recorder.page.getByText('Post-Hydration Content')).toBeVisible(); + await expect(recorder.page.locator('x-pw-glass')).toBeVisible(); + }); }); From ec2ae1ed2d37803b0816d558b90d981c5ef7f24b Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 17 Sep 2024 08:45:44 +0200 Subject: [PATCH 39/41] feat(watch mode): buffer mode (#32631) Closes https://github.com/microsoft/playwright/issues/32578. Adds a buffer mode that can be toggled by pressing b. When engaged, changed test files are collected and shown on screen. The test run is then kicked off by pressing Enter. This changes the signal to start a test run from Cmd+s to a Enter press in the test terminal. It should help users with auto-save and make it easier to run on long-running tests. It feels very similar to running `npx playwright test`, but without having to write a filter. https://github.com/user-attachments/assets/71e16139-9427-4e90-b523-8d218f09ed9d --- packages/playwright/src/runner/watchMode.ts | 64 ++++++++++++++++----- tests/playwright-test/watch.spec.ts | 51 ++++++++++++++++ 2 files changed, 100 insertions(+), 15 deletions(-) diff --git a/packages/playwright/src/runner/watchMode.ts b/packages/playwright/src/runner/watchMode.ts index 603f066601..a47ca0fe32 100644 --- a/packages/playwright/src/runner/watchMode.ts +++ b/packages/playwright/src/runner/watchMode.ts @@ -73,6 +73,7 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp return 'restarted'; const options: WatchModeOptions = { ...initialOptions }; + let bufferMode = false; const testServerDispatcher = new TestServerDispatcher(configLocation); const transport = new InMemoryTransport( @@ -94,8 +95,9 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp const teleSuiteUpdater = new TeleSuiteUpdater({ pathSeparator: path.sep, onUpdate() { } }); + const dirtyTestFiles = new Set(); const dirtyTestIds = new Set(); - let onDirtyTests = new ManualPromise(); + let onDirtyTests = new ManualPromise<'changed'>(); let queue = Promise.resolve(); const changedFiles = new Set(); @@ -110,14 +112,17 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp teleSuiteUpdater.processListReport(report); for (const test of teleSuiteUpdater.rootSuite!.allTests()) { - if (changedFiles.has(test.location.file)) + if (changedFiles.has(test.location.file)) { + dirtyTestFiles.add(test.location.file); dirtyTestIds.add(test.id); + } } - changedFiles.clear(); - if (dirtyTestIds.size > 0) - onDirtyTests.resolve?.(); + if (dirtyTestIds.size > 0) { + onDirtyTests.resolve('changed'); + onDirtyTests = new ManualPromise(); + } }); }); testServerConnection.onReport(report => teleSuiteUpdater.processTestReportEvent(report)); @@ -134,21 +139,27 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp let result: FullResult['status'] = 'passed'; while (true) { - printPrompt(); - const readCommandPromise = readCommand(); - await Promise.race([ + if (bufferMode) + printBufferPrompt(dirtyTestFiles, teleSuiteUpdater.config!.rootDir); + else + printPrompt(); + + const command = await Promise.race([ onDirtyTests, - readCommandPromise, + readCommand(), ]); - if (!readCommandPromise.isDone()) - readCommandPromise.resolve('changed'); - const command = await readCommandPromise; + if (bufferMode && command === 'changed') + continue; + + const shouldRunChangedFiles = bufferMode ? command === 'run' : command === 'changed'; + if (shouldRunChangedFiles) { + if (dirtyTestIds.size === 0) + continue; - if (command === 'changed') { - onDirtyTests = new ManualPromise(); const testIds = [...dirtyTestIds]; dirtyTestIds.clear(); + dirtyTestFiles.clear(); await runTests(options, testServerConnection, { testIds, title: 'files changed' }); lastRun = { type: 'changed', dirtyTestIds: testIds }; continue; @@ -234,6 +245,11 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp continue; } + if (command === 'toggle-buffer-mode') { + bufferMode = !bufferMode; + continue; + } + if (command === 'exit') break; @@ -300,6 +316,7 @@ Change settings ${colors.bold('p')} ${colors.dim('set file filter')} ${colors.bold('t')} ${colors.dim('set title filter')} ${colors.bold('s')} ${colors.dim('toggle show & reuse the browser')} + ${colors.bold('b')} ${colors.dim('toggle buffer mode')} `); return; } @@ -312,6 +329,7 @@ Change settings case 't': result.resolve('grep'); break; case 'f': result.resolve('failed'); break; case 's': result.resolve('toggle-show-browser'); break; + case 'b': result.resolve('toggle-buffer-mode'); break; } }; @@ -350,6 +368,22 @@ function printConfiguration(options: WatchModeOptions, title?: string) { process.stdout.write(lines.join('\n')); } +function printBufferPrompt(dirtyTestFiles: Set, rootDir: string) { + const sep = separator(); + process.stdout.write('\x1Bc'); + process.stdout.write(`${sep}\n`); + + if (dirtyTestFiles.size === 0) { + process.stdout.write(`${colors.dim('Waiting for file changes. Press')} ${colors.bold('q')} ${colors.dim('to quit or')} ${colors.bold('h')} ${colors.dim('for more options.')}\n\n`); + return; + } + + process.stdout.write(`${colors.dim(`${dirtyTestFiles.size} test ${dirtyTestFiles.size === 1 ? 'file' : 'files'} changed:`)}\n\n`); + for (const file of dirtyTestFiles) + process.stdout.write(` · ${path.relative(rootDir, file)}\n`); + process.stdout.write(`\n${colors.dim(`Press`)} ${colors.bold('enter')} ${colors.dim('to run')}, ${colors.bold('q')} ${colors.dim('to quit or')} ${colors.bold('h')} ${colors.dim('for more options.')}\n\n`); +} + function printPrompt() { const sep = separator(); process.stdout.write(` @@ -371,4 +405,4 @@ async function toggleShowBrowser() { } } -type Command = 'run' | 'failed' | 'repeat' | 'changed' | 'project' | 'file' | 'grep' | 'exit' | 'interrupted' | 'toggle-show-browser'; +type Command = 'run' | 'failed' | 'repeat' | 'changed' | 'project' | 'file' | 'grep' | 'exit' | 'interrupted' | 'toggle-show-browser' | 'toggle-buffer-mode'; diff --git a/tests/playwright-test/watch.spec.ts b/tests/playwright-test/watch.spec.ts index ec05e5bb68..b56fb470ea 100644 --- a/tests/playwright-test/watch.spec.ts +++ b/tests/playwright-test/watch.spec.ts @@ -313,12 +313,17 @@ test('should respect project filter C', async ({ runWatchTest, writeFiles }) => await testProcess.waitForOutput('[foo] › a.test.ts:3:11 › passes'); expect(testProcess.output).not.toContain('[bar] › a.test.ts:3:11 › passes'); + await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); await writeFiles(files); // file change triggers listTests with project filter await testProcess.waitForOutput('[foo] › a.test.ts:3:11 › passes'); + testProcess.clearOutput(); + await testProcess.waitForOutput('Waiting for file changes.'); + testProcess.write('c'); + testProcess.clearOutput(); await testProcess.waitForOutput('Select projects'); await testProcess.waitForOutput('foo'); await testProcess.waitForOutput('bar'); // second selection should still show all @@ -812,3 +817,49 @@ test('should run global teardown before exiting', async ({ runWatchTest }) => { testProcess.write('\x1B'); await testProcess.waitForOutput('running teardown'); }); + +test('buffer mode', async ({ runWatchTest, writeFiles }) => { + const testProcess = await runWatchTest({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('passes', () => {}); + `, + 'b.test.ts': ` + import { test, expect } from '@playwright/test'; + test('passes in b', () => {}); + `, + }); + + testProcess.clearOutput(); + testProcess.write('b'); + await testProcess.waitForOutput('Waiting for file changes. Press q to quit'); + + + testProcess.clearOutput(); + await writeFiles({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('passes again', () => {}); + `, + }); + + await testProcess.waitForOutput('1 test file changed:'); + await testProcess.waitForOutput('a.test.ts'); + + testProcess.clearOutput(); + await writeFiles({ + 'b.test.ts': ` + import { test, expect } from '@playwright/test'; + test('passes in b again', () => {}); + `, + }); + await testProcess.waitForOutput('2 test files changed:'); + await testProcess.waitForOutput('a.test.ts'); + await testProcess.waitForOutput('b.test.ts'); + + testProcess.clearOutput(); + testProcess.write('\r\n'); + + await testProcess.waitForOutput('a.test.ts:3:11 › passes'); + await testProcess.waitForOutput('b.test.ts:3:11 › passes'); +}); \ No newline at end of file From b23edf5137c02fb81d8a66ef719034dabd0828c8 Mon Sep 17 00:00:00 2001 From: Ana Margarida Silva Date: Tue, 17 Sep 2024 09:36:43 +0100 Subject: [PATCH 40/41] fix(docs): remove todo in ci intro docs (#32643) --- docs/src/ci-intro.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/src/ci-intro.md b/docs/src/ci-intro.md index 492130088a..9e0ee0eb4f 100644 --- a/docs/src/ci-intro.md +++ b/docs/src/ci-intro.md @@ -25,7 +25,7 @@ Playwright tests can be ran on any CI provider. In this section we will cover ru #### You will learn * langs: python, java, csharp - + - [How to set up GitHub Actions](/ci-intro.md#setting-up-github-actions) - [How to view test logs](/ci-intro.md#viewing-test-logs) - [How to view the trace](/ci-intro.md#viewing-the-trace) @@ -322,5 +322,5 @@ This step will not work for pull requests created from a forked repository becau - [Learn how to perform Actions](./input.md) - [Learn how to write Assertions](./test-assertions.md) - [Learn more about the Trace Viewer](/trace-viewer.md) -- [Learn more ways of running tests on GitHub Actions](/ci.md) -- [Learn more about running tests on other CI providers](/ci.md#github-actions) // TODO: is this link correct? \ No newline at end of file +- [Learn more ways of running tests on GitHub Actions](/ci.md#github-actions) +- [Learn more about running tests on other CI providers](/ci.md) From f6219e6e793054a89e89868f2277f194586086ea Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 17 Sep 2024 15:32:30 +0200 Subject: [PATCH 41/41] Revert "feat(tracing): add .pwtrace to trace file extension" (#32648) Reverts microsoft/playwright#32581 Relates https://github.com/microsoft/playwright/issues/32226#issuecomment-2351164727 --- docs/src/api/class-tracing.md | 40 +++++----- docs/src/test-global-setup-teardown-js.md | 4 +- docs/src/trace-viewer-intro-java-python.md | 14 ++-- docs/src/trace-viewer-intro-js.md | 2 +- docs/src/trace-viewer.md | 32 ++++---- packages/playwright-core/src/cli/program.ts | 2 +- .../src/server/trace/recorder/tracing.ts | 2 +- packages/playwright-core/types/types.d.ts | 8 +- packages/playwright/src/worker/testTracing.ts | 4 +- tests/config/traceViewerFixtures.ts | 2 +- .../playwright-electron-should-work.spec.ts | 6 +- tests/library/browsercontext-reuse.spec.ts | 2 +- .../library/chromium/connect-over-cdp.spec.ts | 2 +- tests/library/inspector/cli-codegen-2.spec.ts | 4 +- tests/library/trace-viewer.spec.ts | 14 ++-- tests/library/tracing.spec.ts | 56 ++++++------- tests/library/video.spec.ts | 2 +- .../playwright.artifacts.spec.ts | 70 ++++++++-------- .../playwright-test/playwright.reuse.spec.ts | 8 +- .../playwright-test/playwright.trace.spec.ts | 80 +++++++++---------- .../reporter-attachment.spec.ts | 6 +- 21 files changed, 180 insertions(+), 180 deletions(-) diff --git a/docs/src/api/class-tracing.md b/docs/src/api/class-tracing.md index f35e9e53ff..6e7541e4cb 100644 --- a/docs/src/api/class-tracing.md +++ b/docs/src/api/class-tracing.md @@ -11,7 +11,7 @@ const context = await browser.newContext(); await context.tracing.start({ screenshots: true, snapshots: true }); const page = await context.newPage(); await page.goto('https://playwright.dev'); -await context.tracing.stop({ path: 'trace.pwtrace.zip' }); +await context.tracing.stop({ path: 'trace.zip' }); ``` ```java @@ -23,7 +23,7 @@ context.tracing().start(new Tracing.StartOptions() Page page = context.newPage(); page.navigate("https://playwright.dev"); context.tracing().stop(new Tracing.StopOptions() - .setPath(Paths.get("trace.pwtrace.zip"))); + .setPath(Paths.get("trace.zip"))); ``` ```python async @@ -32,7 +32,7 @@ context = await browser.new_context() await context.tracing.start(screenshots=True, snapshots=True) page = await context.new_page() await page.goto("https://playwright.dev") -await context.tracing.stop(path = "trace.pwtrace.zip") +await context.tracing.stop(path = "trace.zip") ``` ```python sync @@ -41,7 +41,7 @@ context = browser.new_context() context.tracing.start(screenshots=True, snapshots=True) page = context.new_page() page.goto("https://playwright.dev") -context.tracing.stop(path = "trace.pwtrace.zip") +context.tracing.stop(path = "trace.zip") ``` ```csharp @@ -57,7 +57,7 @@ var page = await context.NewPageAsync(); await page.GotoAsync("https://playwright.dev"); await context.Tracing.StopAsync(new() { - Path = "trace.pwtrace.zip" + Path = "trace.zip" }); ``` @@ -72,7 +72,7 @@ Start tracing. await context.tracing.start({ screenshots: true, snapshots: true }); const page = await context.newPage(); await page.goto('https://playwright.dev'); -await context.tracing.stop({ path: 'trace.pwtrace.zip' }); +await context.tracing.stop({ path: 'trace.zip' }); ``` ```java @@ -82,21 +82,21 @@ context.tracing().start(new Tracing.StartOptions() Page page = context.newPage(); page.navigate("https://playwright.dev"); context.tracing().stop(new Tracing.StopOptions() - .setPath(Paths.get("trace.pwtrace.zip"))); + .setPath(Paths.get("trace.zip"))); ``` ```python async await context.tracing.start(screenshots=True, snapshots=True) page = await context.new_page() await page.goto("https://playwright.dev") -await context.tracing.stop(path = "trace.pwtrace.zip") +await context.tracing.stop(path = "trace.zip") ``` ```python sync context.tracing.start(screenshots=True, snapshots=True) page = context.new_page() page.goto("https://playwright.dev") -context.tracing.stop(path = "trace.pwtrace.zip") +context.tracing.stop(path = "trace.zip") ``` ```csharp @@ -112,7 +112,7 @@ var page = await context.NewPageAsync(); await page.GotoAsync("https://playwright.dev"); await context.Tracing.StopAsync(new() { - Path = "trace.pwtrace.zip" + Path = "trace.zip" }); ``` @@ -177,12 +177,12 @@ await page.goto('https://playwright.dev'); await context.tracing.startChunk(); await page.getByText('Get Started').click(); // Everything between startChunk and stopChunk will be recorded in the trace. -await context.tracing.stopChunk({ path: 'trace1.pwtrace.zip' }); +await context.tracing.stopChunk({ path: 'trace1.zip' }); await context.tracing.startChunk(); await page.goto('http://example.com'); // Save a second trace file with different actions. -await context.tracing.stopChunk({ path: 'trace2.pwtrace.zip' }); +await context.tracing.stopChunk({ path: 'trace2.zip' }); ``` ```java @@ -196,13 +196,13 @@ context.tracing().startChunk(); page.getByText("Get Started").click(); // Everything between startChunk and stopChunk will be recorded in the trace. context.tracing().stopChunk(new Tracing.StopChunkOptions() - .setPath(Paths.get("trace1.pwtrace.zip"))); + .setPath(Paths.get("trace1.zip"))); context.tracing().startChunk(); page.navigate("http://example.com"); // Save a second trace file with different actions. context.tracing().stopChunk(new Tracing.StopChunkOptions() - .setPath(Paths.get("trace2.pwtrace.zip"))); + .setPath(Paths.get("trace2.zip"))); ``` ```python async @@ -213,12 +213,12 @@ await page.goto("https://playwright.dev") await context.tracing.start_chunk() await page.get_by_text("Get Started").click() # Everything between start_chunk and stop_chunk will be recorded in the trace. -await context.tracing.stop_chunk(path = "trace1.pwtrace.zip") +await context.tracing.stop_chunk(path = "trace1.zip") await context.tracing.start_chunk() await page.goto("http://example.com") # Save a second trace file with different actions. -await context.tracing.stop_chunk(path = "trace2.pwtrace.zip") +await context.tracing.stop_chunk(path = "trace2.zip") ``` ```python sync @@ -229,12 +229,12 @@ page.goto("https://playwright.dev") context.tracing.start_chunk() page.get_by_text("Get Started").click() # Everything between start_chunk and stop_chunk will be recorded in the trace. -context.tracing.stop_chunk(path = "trace1.pwtrace.zip") +context.tracing.stop_chunk(path = "trace1.zip") context.tracing.start_chunk() page.goto("http://example.com") # Save a second trace file with different actions. -context.tracing.stop_chunk(path = "trace2.pwtrace.zip") +context.tracing.stop_chunk(path = "trace2.zip") ``` ```csharp @@ -254,7 +254,7 @@ await page.GetByText("Get Started").ClickAsync(); // Everything between StartChunkAsync and StopChunkAsync will be recorded in the trace. await context.Tracing.StopChunkAsync(new() { - Path = "trace1.pwtrace.zip" + Path = "trace1.zip" }); await context.Tracing.StartChunkAsync(); @@ -262,7 +262,7 @@ await page.GotoAsync("http://example.com"); // Save a second trace file with different actions. await context.Tracing.StopChunkAsync(new() { - Path = "trace2.pwtrace.zip" + Path = "trace2.zip" }); ``` diff --git a/docs/src/test-global-setup-teardown-js.md b/docs/src/test-global-setup-teardown-js.md index 0bb05cd78d..883bdf25d6 100644 --- a/docs/src/test-global-setup-teardown-js.md +++ b/docs/src/test-global-setup-teardown-js.md @@ -238,12 +238,12 @@ async function globalSetup(config: FullConfig) { await page.getByText('Sign in').click(); await context.storageState({ path: storageState as string }); await context.tracing.stop({ - path: './test-results/setup-trace.pwtrace.zip', + path: './test-results/setup-trace.zip', }); await browser.close(); } catch (error) { await context.tracing.stop({ - path: './test-results/failed-setup-trace.pwtrace.zip', + path: './test-results/failed-setup-trace.zip', }); await browser.close(); throw error; diff --git a/docs/src/trace-viewer-intro-java-python.md b/docs/src/trace-viewer-intro-java-python.md index db4f096b9e..79a415e54a 100644 --- a/docs/src/trace-viewer-intro-java-python.md +++ b/docs/src/trace-viewer-intro-java-python.md @@ -25,7 +25,7 @@ Options for tracing are: - `off`: Do not record trace. (default) - `retain-on-failure`: Record trace for each test, but remove all traces from successful test runs. -This will record the trace and place it into the file named `trace.pwtrace.zip` in your `test-results` directory. +This will record the trace and place it into the file named `trace.zip` in your `test-results` directory.
If you are not using Pytest, click here to learn how to record traces. @@ -41,7 +41,7 @@ page = await context.new_page() await page.goto("https://playwright.dev") # Stop tracing and export it into a zip archive. -await context.tracing.stop(path = "trace.pwtrace.zip") +await context.tracing.stop(path = "trace.zip") ``` ```python sync @@ -55,7 +55,7 @@ page = context.new_page() page.goto("https://playwright.dev") # Stop tracing and export it into a zip archive. -context.tracing.stop(path = "trace.pwtrace.zip") +context.tracing.stop(path = "trace.zip") ```
@@ -80,22 +80,22 @@ page.navigate("https://playwright.dev"); // Stop tracing and export it into a zip archive. context.tracing().stop(new Tracing.StopOptions() - .setPath(Paths.get("trace.pwtrace.zip"))); + .setPath(Paths.get("trace.zip"))); ``` -This will record the trace and place it into the file named `trace.pwtrace.zip`. +This will record the trace and place it into the file named `trace.zip`. ## Opening the trace You can open the saved trace using the Playwright CLI or in your browser on [`trace.playwright.dev`](https://trace.playwright.dev). Make sure to add the full path to where your trace's zip file is located. Once opened you can click on each action or use the timeline to see the state of the page before and after each action. You can also inspect the log, source and network during each step of the test. The trace viewer creates a DOM snapshot so you can fully interact with it, open devtools etc. ```bash java -mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace trace.pwtrace.zip" +mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace trace.zip" ``` ```bash python -playwright show-trace trace.pwtrace.zip +playwright show-trace trace.zip ``` ###### diff --git a/docs/src/trace-viewer-intro-js.md b/docs/src/trace-viewer-intro-js.md index b7ac93dd9c..a950689468 100644 --- a/docs/src/trace-viewer-intro-js.md +++ b/docs/src/trace-viewer-intro-js.md @@ -22,7 +22,7 @@ Playwright Trace Viewer is a GUI tool that lets you explore recorded Playwright ## Recording a Trace -By default the [playwright.config](./trace-viewer.md#recording-a-trace-on-ci) file will contain the configuration needed to create a `trace.pwtrace.zip` file for each test. Traces are setup to run `on-first-retry` meaning they will be run on the first retry of a failed test. Also `retries` are set to 2 when running on CI and 0 locally. This means the traces will be recorded on the first retry of a failed test but not on the first run and not on the second retry. +By default the [playwright.config](./trace-viewer.md#recording-a-trace-on-ci) file will contain the configuration needed to create a `trace.zip` file for each test. Traces are setup to run `on-first-retry` meaning they will be run on the first retry of a failed test. Also `retries` are set to 2 when running on CI and 0 locally. This means the traces will be recorded on the first retry of a failed test but not on the first run and not on the second retry. ```js title="playwright.config.ts" import { defineConfig } from '@playwright/test'; diff --git a/docs/src/trace-viewer.md b/docs/src/trace-viewer.md index ad26278930..207646c1d7 100644 --- a/docs/src/trace-viewer.md +++ b/docs/src/trace-viewer.md @@ -132,7 +132,7 @@ npx playwright show-report * langs: js Traces should be run on continuous integration on the first retry of a failed test -by setting the `trace: 'on-first-retry'` option in the test configuration file. This will produce a `trace.pwtrace.zip` file for each test that was retried. +by setting the `trace: 'on-first-retry'` option in the test configuration file. This will produce a `trace.zip` file for each test that was retried. ```js tab=js-test title="playwright.config.ts" import { defineConfig } from '@playwright/test'; @@ -155,7 +155,7 @@ const page = await context.newPage(); await page.goto('https://playwright.dev'); // Stop tracing and export it into a zip archive. -await context.tracing.stop({ path: 'trace.pwtrace.zip' }); +await context.tracing.stop({ path: 'trace.zip' }); ``` Available options to record a trace: @@ -185,7 +185,7 @@ Options for tracing are: - `off`: Do not record trace. (default) - `retain-on-failure`: Record trace for each test, but remove all traces from successful test runs. -This will record the trace and place it into the file named `trace.pwtrace.zip` in your `test-results` directory. +This will record the trace and place it into the file named `trace.zip` in your `test-results` directory.
If you are not using Pytest, click here to learn how to record traces. @@ -201,7 +201,7 @@ page = await context.new_page() await page.goto("https://playwright.dev") # Stop tracing and export it into a zip archive. -await context.tracing.stop(path = "trace.pwtrace.zip") +await context.tracing.stop(path = "trace.zip") ``` ```python sync @@ -215,7 +215,7 @@ page = context.new_page() page.goto("https://playwright.dev") # Stop tracing and export it into a zip archive. -context.tracing.stop(path = "trace.pwtrace.zip") +context.tracing.stop(path = "trace.zip") ```
@@ -240,10 +240,10 @@ page.navigate("https://playwright.dev"); // Stop tracing and export it into a zip archive. context.tracing().stop(new Tracing.StopOptions() - .setPath(Paths.get("trace.pwtracezip"))); + .setPath(Paths.get("trace.zip"))); ``` -This will record the trace and place it into the file named `trace.pwtrace.zip`. +This will record the trace and place it into the file named `trace.zip`. ## Recording a trace * langs: csharp @@ -466,22 +466,22 @@ public class ExampleTest : PageTest ## Opening the trace -You can open the saved trace using the Playwright CLI or in your browser on [`trace.playwright.dev`](https://trace.playwright.dev). Make sure to add the full path to where your `trace.pwtrace.zip` file is located. +You can open the saved trace using the Playwright CLI or in your browser on [`trace.playwright.dev`](https://trace.playwright.dev). Make sure to add the full path to where your `trace.zip` file is located. ```bash js -npx playwright show-trace path/to/trace.pwtrace.zip +npx playwright show-trace path/to/trace.zip ``` ```bash java -mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace trace.pwtrace.zip" +mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace trace.zip" ``` ```bash python -playwright show-trace trace.pwtrace.zip +playwright show-trace trace.zip ``` ```bash csharp -pwsh bin/Debug/netX/playwright.ps1 show-trace trace.pwtrace.zip +pwsh bin/Debug/netX/playwright.ps1 show-trace trace.zip ``` ## Using [trace.playwright.dev](https://trace.playwright.dev) @@ -496,19 +496,19 @@ pwsh bin/Debug/netX/playwright.ps1 show-trace trace.pwtrace.zip You can open remote traces using its URL. They could be generated on a CI run which makes it easy to view the remote trace without having to manually download the file. ```bash js -npx playwright show-trace https://example.com/trace.pwtrace.zip +npx playwright show-trace https://example.com/trace.zip ``` ```bash java -mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace https://example.com/trace.pwtrace.zip" +mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace https://example.com/trace.zip" ``` ```bash python -playwright show-trace https://example.com/trace.pwtrace.zip +playwright show-trace https://example.com/trace.zip ``` ```bash csharp -pwsh bin/Debug/netX/playwright.ps1 show-trace https://example.com/trace.pwtrace.zip +pwsh bin/Debug/netX/playwright.ps1 show-trace https://example.com/trace.zip ``` diff --git a/packages/playwright-core/src/cli/program.ts b/packages/playwright-core/src/cli/program.ts index 6a36210f8e..d8fa8230c6 100644 --- a/packages/playwright-core/src/cli/program.ts +++ b/packages/playwright-core/src/cli/program.ts @@ -318,7 +318,7 @@ program }).addHelpText('afterAll', ` Examples: - $ show-trace https://example.com/trace.pwtrace.zip`); + $ show-trace https://example.com/trace.zip`); type Options = { browser: string; diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index 13f15b6222..b09bbe3134 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -310,7 +310,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps this._fs.copyFile(this._state.networkFile, newNetworkFile); - const zipFileName = this._state.traceFile + '.pwtrace.zip'; + const zipFileName = this._state.traceFile + '.zip'; if (params.mode === 'archive') this._fs.zip(entries, zipFileName); diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index f770fc70dc..ad102f9271 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -19927,7 +19927,7 @@ export interface Touchscreen { * await context.tracing.start({ screenshots: true, snapshots: true }); * const page = await context.newPage(); * await page.goto('https://playwright.dev'); - * await context.tracing.stop({ path: 'trace.pwtrace.zip' }); + * await context.tracing.stop({ path: 'trace.zip' }); * ``` * */ @@ -19941,7 +19941,7 @@ export interface Tracing { * await context.tracing.start({ screenshots: true, snapshots: true }); * const page = await context.newPage(); * await page.goto('https://playwright.dev'); - * await context.tracing.stop({ path: 'trace.pwtrace.zip' }); + * await context.tracing.stop({ path: 'trace.zip' }); * ``` * * @param options @@ -19996,12 +19996,12 @@ export interface Tracing { * await context.tracing.startChunk(); * await page.getByText('Get Started').click(); * // Everything between startChunk and stopChunk will be recorded in the trace. - * await context.tracing.stopChunk({ path: 'trace1.pwtrace.zip' }); + * await context.tracing.stopChunk({ path: 'trace1.zip' }); * * await context.tracing.startChunk(); * await page.goto('http://example.com'); * // Save a second trace file with different actions. - * await context.tracing.stopChunk({ path: 'trace2.pwtrace.zip' }); + * await context.tracing.stopChunk({ path: 'trace2.zip' }); * ``` * * @param options diff --git a/packages/playwright/src/worker/testTracing.ts b/packages/playwright/src/worker/testTracing.ts index 1b58c5ebf6..fed7fdde7e 100644 --- a/packages/playwright/src/worker/testTracing.ts +++ b/packages/playwright/src/worker/testTracing.ts @@ -131,7 +131,7 @@ export class TestTracing { } generateNextTraceRecordingPath() { - const file = path.join(this._artifactsDir, createGuid() + '.pwtrace.zip'); + const file = path.join(this._artifactsDir, createGuid() + '.zip'); this._temporaryTraceFiles.push(file); return file; } @@ -214,7 +214,7 @@ export class TestTracing { }); }); - const tracePath = this._testInfo.outputPath('trace.pwtrace.zip'); + const tracePath = this._testInfo.outputPath('trace.zip'); await mergeTraceFiles(tracePath, this._temporaryTraceFiles); this._testInfo.attachments.push({ name: 'trace', path: tracePath, contentType: 'application/zip' }); } diff --git a/tests/config/traceViewerFixtures.ts b/tests/config/traceViewerFixtures.ts index 936c8ea998..3b79a55e98 100644 --- a/tests/config/traceViewerFixtures.ts +++ b/tests/config/traceViewerFixtures.ts @@ -128,7 +128,7 @@ export const traceViewerFixtures: Fixtures { await use(async (body: () => Promise, optsOverrides = {}) => { - const traceFile = testInfo.outputPath('trace.pwtrace.zip'); + const traceFile = testInfo.outputPath('trace.zip'); await context.tracing.start({ snapshots: true, screenshots: true, sources: true, ...optsOverrides }); await body(); await context.tracing.stop({ path: traceFile }); diff --git a/tests/installation/playwright-electron-should-work.spec.ts b/tests/installation/playwright-electron-should-work.spec.ts index 32b529224f..5c6ced0948 100755 --- a/tests/installation/playwright-electron-should-work.spec.ts +++ b/tests/installation/playwright-electron-should-work.spec.ts @@ -58,7 +58,7 @@ test('should work when wrapped inside @playwright/test and trace is enabled', as await expect(window).toHaveTitle(/Playwright/); await expect(window.getByRole('heading')).toHaveText('Playwright'); - const path = test.info().outputPath('electron-trace.pwtrace.zip'); + const path = test.info().outputPath('electron-trace.zip'); if (trace) { await window.context().tracing.stop({ path }); test.info().attachments.push({ name: 'trace', path, contentType: 'application/zip' }); @@ -73,9 +73,9 @@ test('should work when wrapped inside @playwright/test and trace is enabled', as }); const traces = [ // our actual trace. - path.join(tmpWorkspace, 'test-results', 'electron-with-tracing-should-work', 'electron-trace.pwtrace.zip'), + path.join(tmpWorkspace, 'test-results', 'electron-with-tracing-should-work', 'electron-trace.zip'), // contains the expect() calls - path.join(tmpWorkspace, 'test-results', 'electron-with-tracing-should-work', 'trace.pwtrace.zip'), + path.join(tmpWorkspace, 'test-results', 'electron-with-tracing-should-work', 'trace.zip'), ]; for (const trace of traces) expect(fs.existsSync(trace)).toBe(true); diff --git a/tests/library/browsercontext-reuse.spec.ts b/tests/library/browsercontext-reuse.spec.ts index 4f7c3e611c..14590a3ae3 100644 --- a/tests/library/browsercontext-reuse.spec.ts +++ b/tests/library/browsercontext-reuse.spec.ts @@ -248,7 +248,7 @@ test('should reset tracing', async ({ reusedContext, trace }, testInfo) => { page = context.pages()[0]; await page.evaluate('2 + 2'); - const error = await context.tracing.stopChunk({ path: testInfo.outputPath('trace.pwtrace.zip') }).catch(e => e); + const error = await context.tracing.stopChunk({ path: testInfo.outputPath('trace.zip') }).catch(e => e); expect(error.message).toContain('Must start tracing before stopping'); }); diff --git a/tests/library/chromium/connect-over-cdp.spec.ts b/tests/library/chromium/connect-over-cdp.spec.ts index 55070f3ec8..a473539e8e 100644 --- a/tests/library/chromium/connect-over-cdp.spec.ts +++ b/tests/library/chromium/connect-over-cdp.spec.ts @@ -489,7 +489,7 @@ test('should allow tracing over cdp session', async ({ browserType, trace }, tes await context.tracing.start({ screenshots: true, snapshots: true }); const page = await context.newPage(); await page.evaluate(() => 2 + 2); - const traceZip = testInfo.outputPath('trace.pwtrace.zip'); + const traceZip = testInfo.outputPath('trace.zip'); await context.tracing.stop({ path: traceZip }); await cdpBrowser.close(); expect(fs.existsSync(traceZip)).toBe(true); diff --git a/tests/library/inspector/cli-codegen-2.spec.ts b/tests/library/inspector/cli-codegen-2.spec.ts index 610d87fc05..ef67cd8b93 100644 --- a/tests/library/inspector/cli-codegen-2.spec.ts +++ b/tests/library/inspector/cli-codegen-2.spec.ts @@ -491,7 +491,7 @@ await page1.GotoAsync("about:blank?foo");`); }); test('should --save-trace', async ({ runCLI }, testInfo) => { - const traceFileName = testInfo.outputPath('trace.pwtrace.zip'); + const traceFileName = testInfo.outputPath('trace.zip'); const cli = runCLI([`--save-trace=${traceFileName}`], { autoExitWhen: ' ', }); @@ -502,7 +502,7 @@ await page1.GotoAsync("about:blank?foo");`); test('should save assets via SIGINT', async ({ runCLI, platform }, testInfo) => { test.skip(platform === 'win32', 'SIGINT not supported on Windows'); - const traceFileName = testInfo.outputPath('trace.pwtrace.zip'); + const traceFileName = testInfo.outputPath('trace.zip'); const storageFileName = testInfo.outputPath('auth.json'); const harFileName = testInfo.outputPath('har.har'); const cli = runCLI([`--save-trace=${traceFileName}`, `--save-storage=${storageFileName}`, `--save-har=${harFileName}`]); diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index 0c6ef86f25..2b00949b19 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -74,7 +74,7 @@ test.beforeAll(async function recordTrace({ browser, browserName, browserType, s runBeforeCloseBrowserContext: async () => { await page.hover('body'); await page.close(); - traceFile = path.join(workerInfo.project.outputDir, String(workerInfo.workerIndex), browserName, 'trace.pwtrace.zip'); + traceFile = path.join(workerInfo.project.outputDir, String(workerInfo.workerIndex), browserName, 'trace.zip'); await context.tracing.stop({ path: traceFile }); } }; @@ -698,7 +698,7 @@ test('should handle file URIs', async ({ page, runAndTrace, browserName }) => { }); test('should preserve currentSrc', async ({ browser, server, showTraceViewer }) => { - const traceFile = test.info().outputPath('trace.pwtrace.zip'); + const traceFile = test.info().outputPath('trace.zip'); const page = await browser.newPage({ deviceScaleFactor: 3 }); await page.context().tracing.start({ snapshots: true, screenshots: true, sources: true }); await page.setViewportSize({ width: 300, height: 300 }); @@ -1294,7 +1294,7 @@ test('should highlight locator in iframe while typing', async ({ page, runAndTra }); test('should preserve noscript when javascript is disabled', async ({ browser, server, showTraceViewer }) => { - const traceFile = test.info().outputPath('trace.pwtrace.zip'); + const traceFile = test.info().outputPath('trace.zip'); const page = await browser.newPage({ javaScriptEnabled: false }); await page.context().tracing.start({ snapshots: true, screenshots: true, sources: true }); await page.goto(server.EMPTY_PAGE); @@ -1311,8 +1311,8 @@ test('should preserve noscript when javascript is disabled', async ({ browser, s await expect(frame.getByText('javascript is disabled!')).toBeVisible(); }); -test('should remove noscript by default', async ({ browser, server, showTraceViewer }) => { - const traceFile = test.info().outputPath('trace.pwtrace.zip'); +test('should remove noscript by default', async ({ browser, server, showTraceViewer, browserType }) => { + const traceFile = test.info().outputPath('trace.zip'); const page = await browser.newPage({ javaScriptEnabled: undefined }); await page.context().tracing.start({ snapshots: true, screenshots: true, sources: true }); await page.goto(server.EMPTY_PAGE); @@ -1329,8 +1329,8 @@ test('should remove noscript by default', async ({ browser, server, showTraceVie await expect(frame.getByText('Enable JavaScript to run this app.')).toBeHidden(); }); -test('should remove noscript when javaScriptEnabled is set to true', async ({ browser, server, showTraceViewer }) => { - const traceFile = test.info().outputPath('trace.pwtrace.zip'); +test('should remove noscript when javaScriptEnabled is set to true', async ({ browser, server, showTraceViewer, browserType }) => { + const traceFile = test.info().outputPath('trace.zip'); const page = await browser.newPage({ javaScriptEnabled: true }); await page.context().tracing.start({ snapshots: true, screenshots: true, sources: true }); await page.goto(server.EMPTY_PAGE); diff --git a/tests/library/tracing.spec.ts b/tests/library/tracing.spec.ts index 770ea0362c..c82db377aa 100644 --- a/tests/library/tracing.spec.ts +++ b/tests/library/tracing.spec.ts @@ -37,9 +37,9 @@ test('should collect trace with resources, but no js', async ({ context, page, s await page.locator('input[type="file"]').setInputFiles(asset('file-to-upload.txt')); await page.waitForTimeout(2000); // Give it some time to produce screenshots. await page.close(); - await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') }); + await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); - const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip')); + const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace.zip')); expect(events[0].type).toBe('context-options'); expect(actions).toEqual([ 'page.goto', @@ -81,8 +81,8 @@ test('should use the correct apiName for event driven callbacks', async ({ conte }); await page.evaluate(() => alert('yo')); - await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') }); - const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip')); + await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); + const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace.zip')); expect(events[0].type).toBe('context-options'); expect(actions).toEqual([ 'page.route', @@ -102,9 +102,9 @@ test('should not collect snapshots by default', async ({ context, page, server } await page.setContent(''); await page.click('"Click"'); await page.close(); - await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') }); + await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); - const { events } = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip')); + const { events } = await parseTraceRaw(testInfo.outputPath('trace.zip')); expect(events.some(e => e.type === 'frame-snapshot')).toBeFalsy(); expect(events.some(e => e.type === 'resource-snapshot')).toBeFalsy(); }); @@ -113,8 +113,8 @@ test('should not include buffers in the trace', async ({ context, page, server } await context.tracing.start({ snapshots: true }); await page.goto(server.PREFIX + '/empty.html'); await page.screenshot(); - await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') }); - const { actionObjects } = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip')); + await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); + const { actionObjects } = await parseTraceRaw(testInfo.outputPath('trace.zip')); const screenshotEvent = actionObjects.find(a => a.apiName === 'page.screenshot'); expect(screenshotEvent.beforeSnapshot).toBeTruthy(); expect(screenshotEvent.afterSnapshot).toBeTruthy(); @@ -129,9 +129,9 @@ test('should exclude internal pages', async ({ browserName, context, page, serve await context.tracing.start(); await context.storageState(); await page.close(); - await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') }); + await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); - const trace = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip')); + const trace = await parseTraceRaw(testInfo.outputPath('trace.zip')); const pageIds = new Set(); trace.events.forEach(e => { const pageId = e.pageId; @@ -144,8 +144,8 @@ test('should exclude internal pages', async ({ browserName, context, page, serve test('should include context API requests', async ({ browserName, context, page, server }, testInfo) => { await context.tracing.start({ snapshots: true }); await page.request.post(server.PREFIX + '/simple.json', { data: { foo: 'bar' } }); - await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') }); - const { events } = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip')); + await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); + const { events } = await parseTraceRaw(testInfo.outputPath('trace.zip')); const postEvent = events.find(e => e.apiName === 'apiRequestContext.post'); expect(postEvent).toBeTruthy(); const harEntry = events.find(e => e.type === 'resource-snapshot'); @@ -428,9 +428,9 @@ for (const params of [ await page.setContent(''); await page.evaluate(() => new Promise(window.builtinRequestAnimationFrame)); } - await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') }); + await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); - const { events, resources } = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip')); + const { events, resources } = await parseTraceRaw(testInfo.outputPath('trace.zip')); const frames = events.filter(e => e.type === 'screencast-frame'); // Check all frame sizes. @@ -460,10 +460,10 @@ test('should include interrupted actions', async ({ context, page, server }, tes await page.goto(server.EMPTY_PAGE); await page.setContent(''); page.click('"ClickNoButton"').catch(() => {}); - await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') }); + await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); await context.close(); - const { events } = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip')); + const { events } = await parseTraceRaw(testInfo.outputPath('trace.zip')); const clickEvent = events.find(e => e.apiName === 'page.click'); expect(clickEvent).toBeTruthy(); }); @@ -475,7 +475,7 @@ test('should throw when starting with different options', async ({ context }) => }); test('should throw when stopping without start', async ({ context }, testInfo) => { - const error = await context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') }).catch(e => e); + const error = await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }).catch(e => e); expect(error.message).toContain('Must start tracing before stopping'); }); @@ -492,7 +492,7 @@ test('should work with multiple chunks', async ({ context, page, server }, testI await page.click('"Click"'); page.click('"ClickNoButton"', { timeout: 0 }).catch(() => {}); await page.evaluate(() => {}); - await context.tracing.stopChunk({ path: testInfo.outputPath('trace.pwtrace.zip') }); + await context.tracing.stopChunk({ path: testInfo.outputPath('trace.zip') }); await context.tracing.startChunk(); await page.hover('"Click"'); @@ -502,7 +502,7 @@ test('should work with multiple chunks', async ({ context, page, server }, testI await page.click('"Click"'); await context.tracing.stopChunk(); // Should stop without a path. - const trace1 = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip')); + const trace1 = await parseTraceRaw(testInfo.outputPath('trace.zip')); expect(trace1.events[0].type).toBe('context-options'); expect(trace1.actions).toEqual([ 'page.setContent', @@ -533,7 +533,7 @@ test('should export trace concurrently to second navigation', async ({ context, await page.waitForTimeout(timeout); await Promise.all([ promise, - context.tracing.stop({ path: testInfo.outputPath('trace.pwtrace.zip') }), + context.tracing.stop({ path: testInfo.outputPath('trace.zip') }), ]); } }); @@ -561,9 +561,9 @@ test('should ignore iframes in head', async ({ context, page, server }, testInfo await context.tracing.start({ screenshots: true, snapshots: true }); await page.click('button'); - await context.tracing.stopChunk({ path: testInfo.outputPath('trace.pwtrace.zip') }); + await context.tracing.stopChunk({ path: testInfo.outputPath('trace.zip') }); - const trace = await parseTraceRaw(testInfo.outputPath('trace.pwtrace.zip')); + const trace = await parseTraceRaw(testInfo.outputPath('trace.zip')); expect(trace.actions).toEqual([ 'page.click', ]); @@ -581,7 +581,7 @@ test('should hide internal stack frames', async ({ context, page }, testInfo) => await page.setContent(`
Click me
`); await page.click('div'); await evalPromise; - const tracePath = testInfo.outputPath('trace.pwtrace.zip'); + const tracePath = testInfo.outputPath('trace.zip'); await context.tracing.stop({ path: tracePath }); const trace = await parseTraceRaw(tracePath); @@ -602,7 +602,7 @@ test('should hide internal stack frames in expect', async ({ context, page }, te await page.click('div'); await expect(page.locator('div')).toBeVisible(); await expectPromise; - const tracePath = testInfo.outputPath('trace.pwtrace.zip'); + const tracePath = testInfo.outputPath('trace.zip'); await context.tracing.stop({ path: tracePath }); const trace = await parseTraceRaw(tracePath); @@ -616,7 +616,7 @@ test('should record global request trace', async ({ request, context, server }, await (request as any)._tracing.start({ snapshots: true }); const url = server.PREFIX + '/simple.json'; await request.get(url); - const tracePath = testInfo.outputPath('trace.pwtrace.zip'); + const tracePath = testInfo.outputPath('trace.zip'); await (request as any)._tracing.stop({ path: tracePath }); const trace = await parseTraceRaw(tracePath); @@ -649,7 +649,7 @@ test('should store global request traces separately', async ({ request, server, request.get(url), request2.post(url) ]); - const tracePath = testInfo.outputPath('trace.pwtrace.zip'); + const tracePath = testInfo.outputPath('trace.zip'); const trace2Path = testInfo.outputPath('trace2.zip'); await Promise.all([ (request as any)._tracing.stop({ path: tracePath }), @@ -682,7 +682,7 @@ test('should store postData for global request', async ({ request, server }, tes await request.post(url, { data: 'test' }); - const tracePath = testInfo.outputPath('trace.pwtrace.zip'); + const tracePath = testInfo.outputPath('trace.zip'); await (request as any)._tracing.stop({ path: tracePath }); const trace = await parseTraceRaw(tracePath); @@ -755,7 +755,7 @@ test('should flush console events on tracing stop', async ({ context, page }, te }); }); await promise; - const tracePath = testInfo.outputPath('trace.pwtrace.zip'); + const tracePath = testInfo.outputPath('trace.zip'); await context.tracing.stop({ path: tracePath }); const trace = await parseTraceRaw(tracePath); const events = trace.events.filter(e => e.type === 'console'); diff --git a/tests/library/video.spec.ts b/tests/library/video.spec.ts index 1413ba1ed5..39dcaecbc6 100644 --- a/tests/library/video.spec.ts +++ b/tests/library/video.spec.ts @@ -799,7 +799,7 @@ it.describe('screencast', () => { it.fixme(!headless || !!process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW, 'different trace screencast image size on all browsers'); const size = { width: 500, height: 400 }; - const traceFile = testInfo.outputPath('trace.pwtrace.zip'); + const traceFile = testInfo.outputPath('trace.zip'); const context = await browser.newContext({ recordVideo: { diff --git a/tests/playwright-test/playwright.artifacts.spec.ts b/tests/playwright-test/playwright.artifacts.spec.ts index cc86c6c584..b666aa4b70 100644 --- a/tests/playwright-test/playwright.artifacts.spec.ts +++ b/tests/playwright-test/playwright.artifacts.spec.ts @@ -235,25 +235,25 @@ test('should work with trace: on', async ({ runInlineTest }, testInfo) => { expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ '.last-run.json', 'artifacts-failing', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-own-context-failing', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-own-context-passing', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-passing', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-persistent-failing', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-persistent-passing', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-shared-shared-failing', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-shared-shared-passing', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-two-contexts', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-two-contexts-failing', - ' trace.pwtrace.zip', + ' trace.zip', ]); }); @@ -271,15 +271,15 @@ test('should work with trace: retain-on-failure', async ({ runInlineTest }, test expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ '.last-run.json', 'artifacts-failing', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-own-context-failing', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-persistent-failing', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-shared-shared-failing', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-two-contexts-failing', - ' trace.pwtrace.zip', + ' trace.zip', ]); }); @@ -297,15 +297,15 @@ test('should work with trace: on-first-retry', async ({ runInlineTest }, testInf expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ '.last-run.json', 'artifacts-failing-retry1', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-own-context-failing-retry1', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-persistent-failing-retry1', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-shared-shared-failing-retry1', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-two-contexts-failing-retry1', - ' trace.pwtrace.zip', + ' trace.zip', ]); }); @@ -323,25 +323,25 @@ test('should work with trace: on-all-retries', async ({ runInlineTest }, testInf expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ '.last-run.json', 'artifacts-failing-retry1', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-failing-retry2', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-own-context-failing-retry1', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-own-context-failing-retry2', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-persistent-failing-retry1', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-persistent-failing-retry2', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-shared-shared-failing-retry1', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-shared-shared-failing-retry2', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-two-contexts-failing-retry1', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-two-contexts-failing-retry2', - ' trace.pwtrace.zip', + ' trace.zip', ]); }); @@ -359,15 +359,15 @@ test('should work with trace: retain-on-first-failure', async ({ runInlineTest } expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ '.last-run.json', 'artifacts-failing', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-own-context-failing', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-persistent-failing', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-shared-shared-failing', - ' trace.pwtrace.zip', + ' trace.zip', 'artifacts-two-contexts-failing', - ' trace.pwtrace.zip', + ' trace.zip', ]); }); diff --git a/tests/playwright-test/playwright.reuse.spec.ts b/tests/playwright-test/playwright.reuse.spec.ts index 76286679b9..0620195f65 100644 --- a/tests/playwright-test/playwright.reuse.spec.ts +++ b/tests/playwright-test/playwright.reuse.spec.ts @@ -114,7 +114,7 @@ test('should reuse context with trace if mode=when-possible', async ({ runInline expect(result.exitCode).toBe(0); expect(result.passed).toBe(2); - const trace1 = await parseTrace(testInfo.outputPath('test-results', 'reuse-one', 'trace.pwtrace.zip')); + const trace1 = await parseTrace(testInfo.outputPath('test-results', 'reuse-one', 'trace.zip')); expect(trace1.actionTree).toEqual([ 'Before Hooks', ' fixture: browser', @@ -131,7 +131,7 @@ test('should reuse context with trace if mode=when-possible', async ({ runInline expect(trace1.traceModel.storage().snapshotsForTest().length).toBeGreaterThan(0); expect(fs.existsSync(testInfo.outputPath('test-results', 'reuse-one', 'trace-1.zip'))).toBe(false); - const trace2 = await parseTrace(testInfo.outputPath('test-results', 'reuse-two', 'trace.pwtrace.zip')); + const trace2 = await parseTrace(testInfo.outputPath('test-results', 'reuse-two', 'trace.zip')); expect(trace2.actionTree).toEqual([ 'Before Hooks', ' fixture: context', @@ -533,6 +533,6 @@ test('should survive serial mode with tracing and reuse', async ({ runInlineTest expect(result.exitCode).toBe(0); expect(result.passed).toBe(2); - expect(fs.existsSync(testInfo.outputPath('test-results', 'reuse-one', 'trace.pwtrace.zip'))).toBe(true); - expect(fs.existsSync(testInfo.outputPath('test-results', 'reuse-two', 'trace.pwtrace.zip'))).toBe(true); + expect(fs.existsSync(testInfo.outputPath('test-results', 'reuse-one', 'trace.zip'))).toBe(true); + expect(fs.existsSync(testInfo.outputPath('test-results', 'reuse-two', 'trace.zip'))).toBe(true); }); diff --git a/tests/playwright-test/playwright.trace.spec.ts b/tests/playwright-test/playwright.trace.spec.ts index a526eea44d..5ca08925b4 100644 --- a/tests/playwright-test/playwright.trace.spec.ts +++ b/tests/playwright-test/playwright.trace.spec.ts @@ -53,7 +53,7 @@ test('should stop tracing with trace: on-first-retry, when not retrying', async expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); expect(result.flaky).toBe(1); - expect(fs.existsSync(testInfo.outputPath('test-results', 'a-shared-flaky-retry1', 'trace.pwtrace.zip'))).toBeTruthy(); + expect(fs.existsSync(testInfo.outputPath('test-results', 'a-shared-flaky-retry1', 'trace.zip'))).toBeTruthy(); }); test('should record api trace', async ({ runInlineTest, server }, testInfo) => { @@ -86,7 +86,7 @@ test('should record api trace', async ({ runInlineTest, server }, testInfo) => { expect(result.passed).toBe(2); expect(result.failed).toBe(1); // One trace file for request context and one for each APIRequestContext - const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.pwtrace.zip')); + const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip')); expect(trace1.actionTree).toEqual([ 'Before Hooks', ' fixture: request', @@ -105,14 +105,14 @@ test('should record api trace', async ({ runInlineTest, server }, testInfo) => { ' fixture: request', ' apiRequestContext.dispose', ]); - const trace2 = await parseTrace(testInfo.outputPath('test-results', 'a-api-pass', 'trace.pwtrace.zip')); + const trace2 = await parseTrace(testInfo.outputPath('test-results', 'a-api-pass', 'trace.zip')); expect(trace2.actionTree).toEqual([ 'Before Hooks', 'apiRequest.newContext', 'apiRequestContext.get', 'After Hooks', ]); - const trace3 = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.pwtrace.zip')); + const trace3 = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.zip')); expect(trace3.actionTree).toEqual([ 'Before Hooks', ' fixture: request', @@ -204,7 +204,7 @@ test('should not mixup network files between contexts', async ({ runInlineTest, }, { workers: 1, timeout: 15000 }); expect(result.exitCode).toEqual(0); expect(result.passed).toBe(1); - expect(fs.existsSync(testInfo.outputPath('test-results', 'a-example', 'trace.pwtrace.zip'))).toBe(true); + expect(fs.existsSync(testInfo.outputPath('test-results', 'a-example', 'trace.zip'))).toBe(true); }); test('should save sources when requested', async ({ runInlineTest }, testInfo) => { @@ -224,7 +224,7 @@ test('should save sources when requested', async ({ runInlineTest }, testInfo) = `, }, { workers: 1 }); expect(result.exitCode).toEqual(0); - const { resources } = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.pwtrace.zip')); + const { resources } = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip')); expect([...resources.keys()].filter(name => name.startsWith('resources/src@'))).toHaveLength(1); }); @@ -248,7 +248,7 @@ test('should not save sources when not requested', async ({ runInlineTest }, tes `, }, { workers: 1 }); expect(result.exitCode).toEqual(0); - const { resources } = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.pwtrace.zip')); + const { resources } = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip')); expect([...resources.keys()].filter(name => name.startsWith('resources/src@'))).toHaveLength(0); }); @@ -283,8 +283,8 @@ test('should work in serial mode', async ({ runInlineTest }, testInfo) => { expect(result.exitCode).toBe(1); expect(result.passed).toBe(1); expect(result.failed).toBe(1); - expect(fs.existsSync(testInfo.outputPath('test-results', 'a-serial-passes', 'trace.pwtrace.zip'))).toBeFalsy(); - expect(fs.existsSync(testInfo.outputPath('test-results', 'a-serial-fails', 'trace.pwtrace.zip'))).toBeTruthy(); + expect(fs.existsSync(testInfo.outputPath('test-results', 'a-serial-passes', 'trace.zip'))).toBeFalsy(); + expect(fs.existsSync(testInfo.outputPath('test-results', 'a-serial-fails', 'trace.zip'))).toBeTruthy(); }); test('should not override trace file in afterAll', async ({ runInlineTest, server }, testInfo) => { @@ -313,7 +313,7 @@ test('should not override trace file in afterAll', async ({ runInlineTest, serve expect(result.exitCode).toBe(1); expect(result.passed).toBe(1); expect(result.failed).toBe(1); - const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-test-1', 'trace.pwtrace.zip')); + const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip')); expect(trace1.actionTree).toEqual([ 'Before Hooks', @@ -338,7 +338,7 @@ test('should not override trace file in afterAll', async ({ runInlineTest, serve ]); expect(trace1.errors).toEqual([`'oh no!'`]); - const error = await parseTrace(testInfo.outputPath('test-results', 'a-test-2', 'trace.pwtrace.zip')).catch(e => e); + const error = await parseTrace(testInfo.outputPath('test-results', 'a-test-2', 'trace.zip')).catch(e => e); expect(error).toBeTruthy(); }); @@ -366,8 +366,8 @@ test('should retain traces for interrupted tests', async ({ runInlineTest }, tes expect(result.exitCode).toBe(1); expect(result.failed).toBe(1); expect(result.interrupted).toBe(1); - expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.pwtrace.zip'))).toBeTruthy(); - expect(fs.existsSync(testInfo.outputPath('test-results', 'b-test-2', 'trace.pwtrace.zip'))).toBeTruthy(); + expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip'))).toBeTruthy(); + expect(fs.existsSync(testInfo.outputPath('test-results', 'b-test-2', 'trace.zip'))).toBeTruthy(); }); test('should respect --trace', async ({ runInlineTest }, testInfo) => { @@ -382,7 +382,7 @@ test('should respect --trace', async ({ runInlineTest }, testInfo) => { expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); - expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.pwtrace.zip'))).toBeTruthy(); + expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip'))).toBeTruthy(); }); test('should respect PW_TEST_DISABLE_TRACING', async ({ runInlineTest }, testInfo) => { @@ -400,7 +400,7 @@ test('should respect PW_TEST_DISABLE_TRACING', async ({ runInlineTest }, testInf expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); - expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.pwtrace.zip'))).toBe(false); + expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip'))).toBe(false); }); for (const mode of ['off', 'retain-on-failure', 'on-first-retry', 'on-all-retries', 'retain-on-first-failure']) { @@ -465,7 +465,7 @@ test(`trace:retain-on-failure should create trace if context is closed before fa }); `, }, { trace: 'retain-on-failure' }); - const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.pwtrace.zip'); + const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.zip'); const trace = await parseTrace(tracePath); expect(trace.apiNames).toContain('page.goto'); expect(result.failed).toBe(1); @@ -487,7 +487,7 @@ test(`trace:retain-on-failure should create trace if context is closed before fa }); `, }, { trace: 'retain-on-failure' }); - const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.pwtrace.zip'); + const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.zip'); const trace = await parseTrace(tracePath); expect(trace.apiNames).toContain('page.goto'); expect(result.failed).toBe(1); @@ -507,7 +507,7 @@ test(`trace:retain-on-failure should create trace if request context is disposed }); `, }, { trace: 'retain-on-failure' }); - const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.pwtrace.zip'); + const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.zip'); const trace = await parseTrace(tracePath); expect(trace.apiNames).toContain('apiRequestContext.get'); expect(result.failed).toBe(1); @@ -529,7 +529,7 @@ test('should include attachments by default', async ({ runInlineTest, server }, expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); - const trace = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.pwtrace.zip')); + const trace = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip')); expect(trace.apiNames).toEqual([ 'Before Hooks', `attach "foo"`, @@ -559,7 +559,7 @@ test('should opt out of attachments', async ({ runInlineTest, server }, testInfo expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); - const trace = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.pwtrace.zip')); + const trace = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip')); expect(trace.apiNames).toEqual([ 'Before Hooks', `attach "foo"`, @@ -592,7 +592,7 @@ test('should record with custom page fixture', async ({ runInlineTest }, testInf expect(result.exitCode).toBe(1); expect(result.failed).toBe(1); expect(result.output).toContain('failure!'); - const trace = await parseTraceRaw(testInfo.outputPath('test-results', 'a-fails', 'trace.pwtrace.zip')); + const trace = await parseTraceRaw(testInfo.outputPath('test-results', 'a-fails', 'trace.zip')); expect(trace.events).toContainEqual(expect.objectContaining({ type: 'frame-snapshot', })); @@ -617,7 +617,7 @@ test('should expand expect.toPass', async ({ runInlineTest }, testInfo) => { expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); - const trace = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.pwtrace.zip')); + const trace = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip')); expect(trace.actionTree).toEqual([ 'Before Hooks', ' fixture: browser', @@ -656,7 +656,7 @@ test('should show non-expect error in trace', async ({ runInlineTest }, testInfo expect(result.exitCode).toBe(1); expect(result.failed).toBe(1); - const trace = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.pwtrace.zip')); + const trace = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.zip')); expect(trace.actionTree).toEqual([ 'Before Hooks', ' fixture: browser', @@ -692,7 +692,7 @@ test('should show error from beforeAll in trace', async ({ runInlineTest }, test expect(result.exitCode).toBe(1); expect(result.failed).toBe(1); - const trace = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.pwtrace.zip')); + const trace = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.zip')); expect(trace.errors).toEqual(['Error: Oh my!']); }); @@ -730,7 +730,7 @@ test('should not throw when attachment is missing', async ({ runInlineTest }, te expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); - const trace = await parseTrace(testInfo.outputPath('test-results', 'a-passes', 'trace.pwtrace.zip')); + const trace = await parseTrace(testInfo.outputPath('test-results', 'a-passes', 'trace.zip')); expect(trace.actionTree).toContain('attach "screenshot"'); }); @@ -754,7 +754,7 @@ test('should not throw when screenshot on failure fails', async ({ runInlineTest expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); - const trace = await parseTrace(testInfo.outputPath('test-results', 'a-has-pdf-page', 'trace.pwtrace.zip')); + const trace = await parseTrace(testInfo.outputPath('test-results', 'a-has-pdf-page', 'trace.zip')); const attachedScreenshots = trace.actionTree.filter(s => s.trim() === `attach "screenshot"`); // One screenshot for the page, no screenshot for pdf page since it should have failed. expect(attachedScreenshots.length).toBe(1); @@ -778,7 +778,7 @@ test('should use custom expect message in trace', async ({ runInlineTest }, test expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); - const trace = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.pwtrace.zip')); + const trace = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.zip')); expect(trace.actionTree).toEqual([ 'Before Hooks', ' fixture: browser', @@ -837,7 +837,7 @@ test('should not throw when merging traces multiple times', async ({ runInlineTe expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); - expect(fs.existsSync(testInfo.outputPath('test-results', 'a-foo', 'trace.pwtrace.zip'))).toBe(true); + expect(fs.existsSync(testInfo.outputPath('test-results', 'a-foo', 'trace.zip'))).toBe(true); }); test('should record nested steps, even after timeout', async ({ runInlineTest }, testInfo) => { @@ -928,7 +928,7 @@ test('should record nested steps, even after timeout', async ({ runInlineTest }, expect(result.exitCode).toBe(1); expect(result.failed).toBe(1); - const trace = await parseTrace(testInfo.outputPath('test-results', 'a-example', 'trace.pwtrace.zip')); + const trace = await parseTrace(testInfo.outputPath('test-results', 'a-example', 'trace.zip')); expect(trace.actionTree).toEqual([ 'Before Hooks', ' beforeAll hook', @@ -1022,14 +1022,14 @@ test('should attribute worker fixture teardown to the right test', async ({ runI expect(result.exitCode).toBe(1); expect(result.passed).toBe(1); expect(result.failed).toBe(1); - const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-one', 'trace.pwtrace.zip')); + const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-one', 'trace.zip')); expect(trace1.actionTree).toEqual([ 'Before Hooks', ' fixture: foo', ' step in foo setup', 'After Hooks', ]); - const trace2 = await parseTrace(testInfo.outputPath('test-results', 'a-two', 'trace.pwtrace.zip')); + const trace2 = await parseTrace(testInfo.outputPath('test-results', 'a-two', 'trace.zip')); expect(trace2.actionTree).toEqual([ 'Before Hooks', 'After Hooks', @@ -1050,11 +1050,11 @@ test('trace:retain-on-first-failure should create trace but only on first failur `, }, { trace: 'retain-on-first-failure', retries: 1 }); - const retryTracePath = test.info().outputPath('test-results', 'a-fail-retry1', 'trace.pwtrace.zip'); + const retryTracePath = test.info().outputPath('test-results', 'a-fail-retry1', 'trace.zip'); const retryTraceExists = fs.existsSync(retryTracePath); expect(retryTraceExists).toBe(false); - const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.pwtrace.zip'); + const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip'); const trace = await parseTrace(tracePath); expect(trace.apiNames).toContain('page.goto'); expect(result.failed).toBe(1); @@ -1071,7 +1071,7 @@ test('trace:retain-on-first-failure should create trace if context is closed bef }); `, }, { trace: 'retain-on-first-failure' }); - const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.pwtrace.zip'); + const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip'); const trace = await parseTrace(tracePath); expect(trace.apiNames).toContain('page.goto'); expect(result.failed).toBe(1); @@ -1090,7 +1090,7 @@ test('trace:retain-on-first-failure should create trace if context is closed bef }); `, }, { trace: 'retain-on-first-failure' }); - const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.pwtrace.zip'); + const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip'); const trace = await parseTrace(tracePath); expect(trace.apiNames).toContain('page.goto'); expect(result.failed).toBe(1); @@ -1107,7 +1107,7 @@ test('trace:retain-on-first-failure should create trace if request context is di }); `, }, { trace: 'retain-on-first-failure' }); - const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.pwtrace.zip'); + const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip'); const trace = await parseTrace(tracePath); expect(trace.apiNames).toContain('apiRequestContext.get'); expect(result.failed).toBe(1); @@ -1132,7 +1132,7 @@ test('should not corrupt actions when no library trace is present', async ({ run expect(result.exitCode).toBe(1); expect(result.failed).toBe(1); - const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.pwtrace.zip'); + const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip'); const trace = await parseTrace(tracePath); expect(trace.actionTree).toEqual([ 'Before Hooks', @@ -1162,7 +1162,7 @@ test('should record trace for manually created context in a failed test', async expect(result.exitCode).toBe(1); expect(result.failed).toBe(1); - const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.pwtrace.zip'); + const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip'); const trace = await parseTrace(tracePath); expect(trace.actionTree).toEqual([ 'Before Hooks', @@ -1204,7 +1204,7 @@ test('should not nest top level expect into unfinished api calls ', { expect(result.exitCode).toBe(0); expect(result.failed).toBe(0); - const tracePath = test.info().outputPath('test-results', 'a-pass', 'trace.pwtrace.zip'); + const tracePath = test.info().outputPath('test-results', 'a-pass', 'trace.zip'); const trace = await parseTrace(tracePath); expect(trace.actionTree).toEqual([ 'Before Hooks', @@ -1246,7 +1246,7 @@ test('should record trace after fixture teardown timeout', { expect(result.exitCode).toBe(1); expect(result.failed).toBe(1); - const tracePath = test.info().outputPath('test-results', 'a-fails', 'trace.pwtrace.zip'); + const tracePath = test.info().outputPath('test-results', 'a-fails', 'trace.zip'); const trace = await parseTrace(tracePath); expect(trace.actionTree).toEqual([ 'Before Hooks', diff --git a/tests/playwright-test/reporter-attachment.spec.ts b/tests/playwright-test/reporter-attachment.spec.ts index 99345a9107..50d6d1ee0c 100644 --- a/tests/playwright-test/reporter-attachment.spec.ts +++ b/tests/playwright-test/reporter-attachment.spec.ts @@ -66,7 +66,7 @@ test('render trace attachment', async ({ runInlineTest }) => { test('one', async ({}, testInfo) => { testInfo.attachments.push({ name: 'trace', - path: testInfo.outputPath('my dir with space', 'trace.pwtrace.zip'), + path: testInfo.outputPath('my dir with space', 'trace.zip'), contentType: 'application/zip' }); expect(1).toBe(0); @@ -75,8 +75,8 @@ test('render trace attachment', async ({ runInlineTest }) => { }, { reporter: 'line' }); const text = result.output.replace(/\\/g, '/'); expect(text).toContain(' attachment #1: trace (application/zip) ─────────────────────────────────────────────────────────'); - expect(text).toContain(' test-results/a-one/my dir with space/trace.pwtrace.zip'); - expect(text).toContain('npx playwright show-trace "test-results/a-one/my dir with space/trace.pwtrace.zip"'); + expect(text).toContain(' test-results/a-one/my dir with space/trace.zip'); + expect(text).toContain('npx playwright show-trace "test-results/a-one/my dir with space/trace.zip"'); expect(text).toContain(' ────────────────────────────────────────────────────────────────────────────────────────────────'); expect(result.exitCode).toBe(1); });