diff --git a/.github/workflows/tests_electron.yml b/.github/workflows/tests_electron.yml index 82b7bc9bc4..604c2e0e2c 100644 --- a/.github/workflows/tests_electron.yml +++ b/.github/workflows/tests_electron.yml @@ -32,25 +32,11 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: ./.github/actions/run-test with: - node-version: 18 - - run: npm ci - env: - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - - run: npm run build - - run: npx playwright install --with-deps chromium - - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run etest - if: matrix.os == 'ubuntu-latest' - - run: npm run etest - if: matrix.os != 'ubuntu-latest' - - name: Azure Login - uses: azure/login@v2 - if: ${{ !cancelled() && github.event_name == 'push' && github.repository == 'microsoft/playwright' }} - with: - client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} - - run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json - if: ${{ !cancelled() }} - shell: bash + browsers-to-install: chromium + command: npm run etest + bot-name: "electron-${{ matrix.os }}" + flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} + flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} + flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} diff --git a/.github/workflows/tests_secondary.yml b/.github/workflows/tests_secondary.yml index 0aa3c81287..9fd407d4dc 100644 --- a/.github/workflows/tests_secondary.yml +++ b/.github/workflows/tests_secondary.yml @@ -229,7 +229,7 @@ jobs: PWTEST_CHANNEL: chrome chromium_tot: - name: Chromium TOT ${{ matrix.os }} + name: Chromium tip-of-tree ${{ matrix.os }} environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} runs-on: ${{ matrix.os }} strategy: @@ -250,7 +250,7 @@ jobs: PWTEST_CHANNEL: chromium-tip-of-tree chromium_tot_headed: - name: Chromium TOT headed ${{ matrix.os }} + name: Chromium tip-of-tree headed ${{ matrix.os }} environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} runs-on: ${{ matrix.os }} strategy: diff --git a/.github/workflows/tests_video.yml b/.github/workflows/tests_video.yml index a0beb253b0..0733b082a0 100644 --- a/.github/workflows/tests_video.yml +++ b/.github/workflows/tests_video.yml @@ -26,25 +26,13 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: ./.github/actions/run-test with: - node-version: 18 - - run: npm ci - env: - DEBUG: pw:install - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - - run: npm run build - - run: npx playwright install --with-deps ${{ matrix.browser }} chromium - - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }}-* + browsers-to-install: ${{ matrix.browser }} chromium + command: npm run test -- --project=${{ matrix.browser }}-* + bot-name: "${{ matrix.browser }}-${{ matrix.os }}" + flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} + flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} + flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} env: PWTEST_VIDEO: 1 - - name: Azure Login - uses: azure/login@v2 - if: ${{ !cancelled() && github.event_name == 'push' && github.repository == 'microsoft/playwright' }} - with: - client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} - - run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json - if: ${{ !cancelled() }} - shell: bash diff --git a/README.md b/README.md index 668ee8097b..3206b72800 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-125.0.6422.41-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-125.0.1-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-17.4-blue.svg?logo=safari)](https://webkit.org/) +[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-126.0.6478.8-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-126.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-17.4-blue.svg?logo=safari)](https://webkit.org/) ## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright) @@ -8,9 +8,9 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 125.0.6422.41 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Chromium 126.0.6478.8 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 17.4 | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| Firefox 125.0.1 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Firefox 126.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | Headless execution is supported for all browsers on all platforms. Check out [system requirements](https://playwright.dev/docs/intro#system-requirements) for details. diff --git a/docs/src/actionability.md b/docs/src/actionability.md index 337e5fd344..4a84f5f5b2 100644 --- a/docs/src/actionability.md +++ b/docs/src/actionability.md @@ -9,7 +9,7 @@ Playwright performs a range of actionability checks on the elements before makin behave as expected. It auto-waits for all the relevant checks to pass and only then performs the requested action. If the required checks do not pass within the given `timeout`, action fails with the `TimeoutError`. For example, for [`method: Locator.click`], Playwright will ensure that: -- locator resolves to an exactly one element +- locator resolves to exactly one element - element is [Visible] - element is [Stable], as in not animating or completed animation - element [Receives Events], as in not obscured by other elements diff --git a/docs/src/api/class-apirequestcontext.md b/docs/src/api/class-apirequestcontext.md index aaf341c438..0d2be68109 100644 --- a/docs/src/api/class-apirequestcontext.md +++ b/docs/src/api/class-apirequestcontext.md @@ -185,6 +185,12 @@ context cookies from the response. The method will automatically follow redirect All responses returned by [`method: APIRequestContext.get`] and similar methods are stored in the memory, so that you can later call [`method: APIResponse.body`].This method discards all its resources, calling any method on disposed [APIRequestContext] will throw an exception. +### option: APIRequestContext.dispose.reason +* since: v1.45 +- `reason` <[string]> + +The reason to be reported to the operations interrupted by the context disposure. + ## async method: APIRequestContext.fetch * since: v1.16 - returns: <[APIResponse]> diff --git a/docs/src/api/class-browser.md b/docs/src/api/class-browser.md index 360b844d24..abbf4fd96b 100644 --- a/docs/src/api/class-browser.md +++ b/docs/src/api/class-browser.md @@ -133,16 +133,16 @@ System.out.println(browser.contexts().size()); // prints "1" ```python async browser = await pw.webkit.launch() -print(len(browser.contexts())) # prints `0` +print(len(browser.contexts)) # prints `0` context = await browser.new_context() -print(len(browser.contexts())) # prints `1` +print(len(browser.contexts)) # prints `1` ``` ```python sync browser = pw.webkit.launch() -print(len(browser.contexts())) # prints `0` +print(len(browser.contexts)) # prints `0` context = browser.new_context() -print(len(browser.contexts())) # prints `1` +print(len(browser.contexts)) # prints `1` ``` ```csharp diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 960c886fed..e0ee157045 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -3388,7 +3388,7 @@ Function that should be run once [`param: locator`] appears. This function shoul ### param: Page.addLocatorHandler.handler * langs: csharp * since: v1.42 -- `handler` <[function]\([Locator]\)> +- `handler` <[function]\([Locator]\): [Promise]> Function that should be run once [`param: locator`] appears. This function should get rid of the element that blocks actions like click. diff --git a/docs/src/intro-js.md b/docs/src/intro-js.md index d6ac4a3e10..c742ce6fa8 100644 --- a/docs/src/intro-js.md +++ b/docs/src/intro-js.md @@ -50,6 +50,7 @@ pnpm create playwright ``` + @@ -81,10 +82,40 @@ The `tests` folder contains a basic example test to help you get started with te By default tests will be run on all 3 browsers, chromium, firefox and webkit using 3 workers. This can be configured in the [playwright.config file](./test-configuration.md). Tests are run in headless mode meaning no browser will open up when running the tests. Results of the tests and test logs will be shown in the terminal. + + + ```bash npx playwright test ``` + + + + +```bash +yarn playwright test +``` + + + + + +```bash +pnpm exec playwright test +``` + + + + + ![tests running in command line](https://github.com/microsoft/playwright/assets/13063165/981c1b2b-dc7e-4b85-b241-272b44da6628) See our doc on [Running Tests](./running-tests.md) to learn more about running tests in headed mode, running multiple tests, running specific tests etc. @@ -92,19 +123,81 @@ See our doc on [Running Tests](./running-tests.md) to learn more about running t After your test completes, an [HTML Reporter](./test-reporters.md#html-reporter) will be generated, which shows you a full report of your tests allowing you to filter the report by browsers, passed tests, failed tests, skipped tests and flaky tests. You can click on each test and explore the test's errors as well as each step of the test. By default, the HTML report is opened automatically if some of the tests failed. + + + ```bash npx playwright show-report ``` + + + + +```bash +yarn playwright show-report +``` + + + + + +```bash +pnpm exec playwright show-report +``` + + + + + ![HTML Report](https://github.com/microsoft/playwright/assets/13063165/38ec17a7-9e61-4002-b137-a93812765501) ## Running the Example Test in UI Mode Run your tests with [UI Mode](./test-ui-mode.md) for a better developer experience with time travel debugging, watch mode and more. + + + + ```bash npx playwright test --ui ``` + + + + + +```bash +yarn playwright test --ui +``` + + + + + +```bash +pnpm exec playwright test --ui +``` + + + + + ![UI Mode](https://github.com/microsoft/playwright/assets/13063165/c5b501cc-4f5d-485a-87cc-66044c651786) Check out or [detailed guide on UI Mode](./test-ui-mode.md) to learn more about its features. @@ -113,17 +206,84 @@ Check out or [detailed guide on UI Mode](./test-ui-mode.md) to learn more about To update Playwright to the latest version run the following command: + + + + ```bash npm install -D @playwright/test@latest # Also download new browser binaries and their dependencies: npx playwright install --with-deps ``` + + + + + +```bash +yarn add --dev @playwright/test@latest +# Also download new browser binaries and their dependencies: +yarn playwright install --with-deps +``` + + + + + +```bash +pnpm install --save-dev @playwright/test@latest +# Also download new browser binaries and their dependencies: +pnpm exec playwright install --with-deps +``` + + + + + You can always check which version of Playwright you have by running the following command: + + + + ```bash npx playwright --version ``` + + + + +```bash +yarn playwright --version +``` + + + + + +```bash +pnpm exec playwright --version +``` + + + + + ## System requirements - Node.js 18+ diff --git a/docs/src/release-notes-csharp.md b/docs/src/release-notes-csharp.md index df22402b87..54d91386cb 100644 --- a/docs/src/release-notes-csharp.md +++ b/docs/src/release-notes-csharp.md @@ -4,6 +4,82 @@ title: "Release notes" toc_max_heading_level: 2 --- +## Version 1.44 + +### New APIs + +**Accessibility assertions** + +- [`method: LocatorAssertions.toHaveAccessibleName`] checks if the element has the specified accessible name: + ```csharp + var locator = Page.GetByRole(AriaRole.Button); + await Expect(locator).ToHaveAccessibleNameAsync("Submit"); + ``` + +- [`method: LocatorAssertions.toHaveAccessibleDescription`] checks if the element has the specified accessible description: + ```csharp + var locator = Page.GetByRole(AriaRole.Button); + await Expect(locator).ToHaveAccessibleDescriptionAsync("Upload a photo"); + ``` + +- [`method: LocatorAssertions.toHaveRole`] checks if the element has the specified ARIA role: + ```csharp + var locator = Page.GetByTestId("save-button"); + await Expect(locator).ToHaveRoleAsync(AriaRole.Button); + ``` + +**Locator handler** + +- After executing the handler added with [`method: Page.addLocatorHandler`], Playwright will now wait until the overlay that triggered the handler is not visible anymore. You can opt-out of this behavior with the new `NoWaitAfter` option. +- You can use new `Times` option in [`method: Page.addLocatorHandler`] to specify maximum number of times the handler should be run. +- The handler in [`method: Page.addLocatorHandler`] now accepts the locator as argument. +- New [`method: Page.removeLocatorHandler`] method for removing previously added locator handlers. + +```csharp +var locator = Page.GetByText("This interstitial covers the button"); +await Page.AddLocatorHandlerAsync(locator, async (overlay) => +{ + await overlay.Locator("#close").ClickAsync(); +}, new() { Times = 3, NoWaitAfter = true }); +// Run your tests that can be interrupted by the overlay. +// ... +await Page.RemoveLocatorHandlerAsync(locator); +``` + +**Miscellaneous options** + +- New method [`method: FormData.append`] allows to specify repeating fields with the same name in [`Multipart`](./api/class-apirequestcontext#api-request-context-fetch-option-multipart) option in `APIRequestContext.FetchAsync()`: +- ``` + ```csharp + var formData = Context.APIRequest.CreateFormData(); + formData.Append("file", new FilePayload() + { + Name = "f1.js", + MimeType = "text/javascript", + Buffer = System.Text.Encoding.UTF8.GetBytes("var x = 2024;") + }); + formData.Append("file", new FilePayload() + { + Name = "f2.txt", + MimeType = "text/plain", + Buffer = System.Text.Encoding.UTF8.GetBytes("hello") + }); + var response = await Context.APIRequest.PostAsync("https://example.com/uploadFiles", new() { Multipart = formData }); + ``` + +- [`method: PageAssertions.toHaveURL`] now supports `IgnoreCase` [option](./api/class-pageassertions#page-assertions-to-have-url-option-ignore-case). + +### Browser Versions + +* Chromium 125.0.6422.14 +* Mozilla Firefox 125.0.1 +* WebKit 17.4 + +This version was also tested against the following stable channels: + +* Google Chrome 124 +* Microsoft Edge 124 + ## Version 1.43 ### New APIs diff --git a/docs/src/release-notes-java.md b/docs/src/release-notes-java.md index 579d135f58..4a46556f68 100644 --- a/docs/src/release-notes-java.md +++ b/docs/src/release-notes-java.md @@ -4,6 +4,72 @@ title: "Release notes" toc_max_heading_level: 2 --- +## Version 1.44 + +### New APIs + +**Accessibility assertions** + +- [`method: LocatorAssertions.toHaveAccessibleName`] checks if the element has the specified accessible name: + ```java + Locator locator = page.getByRole(AriaRole.BUTTON); + assertThat(locator).hasAccessibleName("Submit"); + ``` + +- [`method: LocatorAssertions.toHaveAccessibleDescription`] checks if the element has the specified accessible description: + ```java + Locator locator = page.getByRole(AriaRole.BUTTON); + assertThat(locator).hasAccessibleDescription("Upload a photo"); + ``` + +- [`method: LocatorAssertions.toHaveRole`] checks if the element has the specified ARIA role: + ```java + Locator locator = page.getByTestId("save-button"); + assertThat(locator).hasRole(AriaRole.BUTTON); + ``` + +**Locator handler** + +- After executing the handler added with [`method: Page.addLocatorHandler`], Playwright will now wait until the overlay that triggered the handler is not visible anymore. You can opt-out of this behavior with the new `setNoWaitAfter` option. +- You can use new `setTimes` option in [`method: Page.addLocatorHandler`] to specify maximum number of times the handler should be run. +- The handler in [`method: Page.addLocatorHandler`] now accepts the locator as argument. +- New [`method: Page.removeLocatorHandler`] method for removing previously added locator handlers. + +```java +Locator locator = page.getByText("This interstitial covers the button"); +page.addLocatorHandler(locator, overlay -> { + overlay.locator("#close").click(); +}, new Page.AddLocatorHandlerOptions().setTimes(3).setNoWaitAfter(true)); +// Run your tests that can be interrupted by the overlay. +// ... +page.removeLocatorHandler(locator); +``` + +**Miscellaneous options** + +- New method [`method: FormData.append`] allows to specify repeating fields with the same name in [`setMultipart`](./api/class-requestoptions#request-options-set-multipart) option in `RequestOptions`: + ```java + FormData formData = FormData.create(); + formData.append("file", new FilePayload("f1.js", "text/javascript", + "var x = 2024;".getBytes(StandardCharsets.UTF_8))); + formData.append("file", new FilePayload("f2.txt", "text/plain", + "hello".getBytes(StandardCharsets.UTF_8))); + APIResponse response = context.request().post("https://example.com/uploadFile", RequestOptions.create().setMultipart(formData)); + ``` + +- `expect(page).toHaveURL(url)` now supports `setIgnoreCase` [option](./api/class-pageassertions#page-assertions-to-have-url-option-ignore-case). + +### Browser Versions + +* Chromium 125.0.6422.14 +* Mozilla Firefox 125.0.1 +* WebKit 17.4 + +This version was also tested against the following stable channels: + +* Google Chrome 124 +* Microsoft Edge 124 + ## Version 1.43 ### New APIs diff --git a/docs/src/release-notes-python.md b/docs/src/release-notes-python.md index 789a89150c..590a4edb88 100644 --- a/docs/src/release-notes-python.md +++ b/docs/src/release-notes-python.md @@ -4,6 +4,60 @@ title: "Release notes" toc_max_heading_level: 2 --- +## Version 1.44 + +### New APIs + +**Accessibility assertions** + +- [`method: LocatorAssertions.toHaveAccessibleName`] checks if the element has the specified accessible name: + ```python + locator = page.get_by_role("button") + expect(locator).to_have_accessible_name("Submit") + ``` + +- [`method: LocatorAssertions.toHaveAccessibleDescription`] checks if the element has the specified accessible description: + ```python + locator = page.get_by_role("button") + expect(locator).to_have_accessible_description("Upload a photo") + ``` + +- [`method: LocatorAssertions.toHaveRole`] checks if the element has the specified ARIA role: + ```python + locator = page.get_by_test_id("save-button") + expect(locator).to_have_role("button") + ``` + +**Locator handler** + +- After executing the handler added with [`method: Page.addLocatorHandler`], Playwright will now wait until the overlay that triggered the handler is not visible anymore. You can opt-out of this behavior with the new `no_wait_after` option. +- You can use new `times` option in [`method: Page.addLocatorHandler`] to specify maximum number of times the handler should be run. +- The handler in [`method: Page.addLocatorHandler`] now accepts the locator as argument. +- New [`method: Page.removeLocatorHandler`] method for removing previously added locator handlers. + +```python +locator = page.get_by_text("This interstitial covers the button") +page.add_locator_handler(locator, lambda overlay: overlay.locator("#close").click(), times=3, no_wait_after=True) +# Run your tests that can be interrupted by the overlay. +# ... +page.remove_locator_handler(locator) +``` + +**Miscellaneous options** + +- [`method: PageAssertions.toHaveURL`] now supports `ignore_case` [option](./api/class-pageassertions#page-assertions-to-have-url-option-ignore-case). + +### Browser Versions + +* Chromium 125.0.6422.14 +* Mozilla Firefox 125.0.1 +* WebKit 17.4 + +This version was also tested against the following stable channels: + +* Google Chrome 124 +* Microsoft Edge 124 + ## Version 1.43 ### New APIs diff --git a/docs/src/running-tests-js.md b/docs/src/running-tests-js.md index 77d201338d..71d8b8adb5 100644 --- a/docs/src/running-tests-js.md +++ b/docs/src/running-tests-js.md @@ -83,6 +83,15 @@ To run a test with a specific title, use the `-g` flag followed by the title of npx playwright test -g "add a todo item" ``` +### Run last failed tests + +To run only the tests that failed in the last test run, first run your tests and then run them again with the `--last-failed` flag. + +```bash +npx playwright test --last-failed +``` + + ### Run tests in VS Code Tests can be run right from VS Code using the [VS Code extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright). Once installed you can simply click the green triangle next to the test you want to run or run all tests from the testing sidebar. Check out our [Getting Started with VS Code](./getting-started-vscode.md#running-tests) guide for more details. diff --git a/docs/src/test-api/class-test.md b/docs/src/test-api/class-test.md index 9b56fbf2e6..7b03353361 100644 --- a/docs/src/test-api/class-test.md +++ b/docs/src/test-api/class-test.md @@ -71,7 +71,8 @@ import { test, expect } from '@playwright/test'; test('basic test', { annotation: { type: 'issue', - description: 'https://github.com/microsoft/playwright/issues/23180', + description: 'feature tags API', + url: 'https://github.com/microsoft/playwright/issues/23180' }, }, async ({ page }) => { await page.goto('https://playwright.dev/'); @@ -97,7 +98,8 @@ Test title. - `tag` ?<[string]|[Array]<[string]>> - `annotation` ?<[Object]|[Array]<[Object]>> - `type` <[string]> Annotation type, for example `'issue'`. - - `description` ?<[string]> Optional annotation description, for example an issue url. + - `description` ?<[string]> Optional annotation description. + - `url` ?<[string]> Optional for example an issue url. Additional test details. @@ -440,6 +442,7 @@ Group title. - `annotation` ?<[Object]|[Array]<[Object]>> - `type` <[string]> - `description` ?<[string]> + - `url` ?<[string]> Additional details for all tests in the group. @@ -568,6 +571,7 @@ Group title. - `annotation` ?<[Object]|[Array]<[Object]>> - `type` <[string]> - `description` ?<[string]> + - `url` ?<[string]> See [`method: Test.describe`] for details description. @@ -623,6 +627,7 @@ Group title. - `annotation` ?<[Object]|[Array]<[Object]>> - `type` <[string]> - `description` ?<[string]> + - `url` ?<[string]> See [`method: Test.describe`] for details description. @@ -676,6 +681,7 @@ Group title. - `annotation` ?<[Object]|[Array]<[Object]>> - `type` <[string]> - `description` ?<[string]> + - `url` ?<[string]> See [`method: Test.describe`] for details description. @@ -727,6 +733,7 @@ Group title. - `annotation` ?<[Object]|[Array]<[Object]>> - `type` <[string]> - `description` ?<[string]> + - `url` ?<[string]> See [`method: Test.describe`] for details description. @@ -782,6 +789,7 @@ Group title. - `annotation` ?<[Object]|[Array]<[Object]>> - `type` <[string]> - `description` ?<[string]> + - `url` ?<[string]> See [`method: Test.describe`] for details description. @@ -839,6 +847,7 @@ Group title. - `annotation` ?<[Object]|[Array]<[Object]>> - `type` <[string]> - `description` ?<[string]> + - `url` ?<[string]> See [`method: Test.describe`] for details description. @@ -891,6 +900,7 @@ Group title. - `annotation` ?<[Object]|[Array]<[Object]>> - `type` <[string]> - `description` ?<[string]> + - `url` ?<[string]> See [`method: Test.describe`] for details description. @@ -1109,6 +1119,7 @@ Test title. - `annotation` ?<[Object]|[Array]<[Object]>> - `type` <[string]> - `description` ?<[string]> + - `url` ?<[string]> See [`method: Test.(call)`] for test details description. @@ -1214,6 +1225,7 @@ Test title. - `annotation` ?<[Object]|[Array]<[Object]>> - `type` <[string]> - `description` ?<[string]> + - `url` ?<[string]> See [`method: Test.(call)`] for test details description. @@ -1291,6 +1303,7 @@ Test title. - `annotation` ?<[Object]|[Array]<[Object]>> - `type` <[string]> - `description` ?<[string]> + - `url` ?<[string]> See [`method: Test.(call)`] for test details description. @@ -1436,6 +1449,7 @@ Test title. - `annotation` ?<[Object]|[Array]<[Object]>> - `type` <[string]> - `description` ?<[string]> + - `url` ?<[string]> See [`method: Test.(call)`] for test details description. diff --git a/docs/src/test-cli-js.md b/docs/src/test-cli-js.md index 6bfb213454..4f012962fc 100644 --- a/docs/src/test-cli-js.md +++ b/docs/src/test-cli-js.md @@ -81,6 +81,7 @@ Complete set of Playwright Test options is available in the [configuration file] | Non-option arguments | Each argument is treated as a regular expression matched against the full test file path. Only tests from the files matching the pattern will be executed. Special symbols like `$` or `*` should be escaped with `\`. In many shells/terminals you may need to quote the arguments. | | `-c ` or `--config `| Configuration file. If not passed, defaults to `playwright.config.ts` or `playwright.config.js` in the current directory. | | `--debug`| Run tests with Playwright Inspector. Shortcut for `PWDEBUG=1` environment variable and `--timeout=0 --max-failures=1 --headed --workers=1` options.| +| `--fail-on-flaky-tests` | Fails test runs that contain flaky tests. By default flaky tests count as successes. | | `--forbid-only` | Whether to disallow `test.only`. Useful on CI.| | `--global-timeout ` | Total timeout for the whole test run in milliseconds. By default, there is no global timeout. Learn more about [various timeouts](./test-timeouts.md).| | `-g ` or `--grep ` | Only run tests matching this regular expression. For example, this will run `'should add to cart'` when passed `-g "add to cart"`. The regular expression will be tested against the string that consists of the test file name, `test.describe` titles if any, test title and all test tags, separated by spaces, e.g. `my-test.spec.ts my-suite my-test @smoke`. The filter does not apply to the tests from dependency projects, i.e. Playwright will still run all tests from [project dependencies](./test-projects.md#dependencies). | diff --git a/docs/src/test-reporters-js.md b/docs/src/test-reporters-js.md index ef46ad4fb0..b131a9a411 100644 --- a/docs/src/test-reporters-js.md +++ b/docs/src/test-reporters-js.md @@ -97,6 +97,15 @@ export default defineConfig({ }); ``` +List report supports the following configuration options and environment variables: + +| Environment Variable Name | Reporter Config Option| Description | Default +|---|---|---|---| +| `PLAYWRIGHT_LIST_PRINT_STEPS` | `printSteps` | Whether to print each step on its own line. | `false` +| `PLAYWRIGHT_FORCE_TTY` | | Whether to produce output suitable for a live terminal. | `true` when terminal is in TTY mode, `false` otherwise. +| `FORCE_COLOR` | | Whether to produce colored output. | `true` when terminal is in TTY mode, `false` otherwise. + + ### Line reporter Line reporter is more concise than the list reporter. It uses a single line to report last finished test, and prints failures when they occur. Line reporter is useful for large test suites where it shows the progress but does not spam the output by listing all the tests. @@ -127,6 +136,14 @@ Running 124 tests using 6 workers [23/124] gitignore.spec.ts - should respect nested .gitignore ``` +Line report supports the following configuration options and environment variables: + +| Environment Variable Name | Reporter Config Option| Description | Default +|---|---|---|---| +| `PLAYWRIGHT_FORCE_TTY` | | Whether to produce output suitable for a live terminal. | `true` when terminal is in TTY mode, `false` otherwise. +| `FORCE_COLOR` | | Whether to produce colored output. | `true` when terminal is in TTY mode, `false` otherwise. + + ### Dot reporter Dot reporter is very concise - it only produces a single character per successful test run. It is the default on CI and useful where you don't want a lot of output. @@ -150,6 +167,24 @@ Running 124 tests using 6 workers ······F············································· ``` +One character is displayed for each test that has run, indicating its status: + +| Character | Description +|---|---| +| `·` | Passed +| `F` | Failed +| `×` | Failed or timed out - and will be retried +| `±` | Passed on retry (flaky) +| `T` | Timed out +| `°` | Skipped + +Dot report supports the following configuration options and environment variables: + +| Environment Variable Name | Reporter Config Option| Description | Default +|---|---|---|---| +| `PLAYWRIGHT_FORCE_TTY` | | Whether to produce output suitable for a live terminal. | `true` when terminal is in TTY mode, `false` otherwise. +| `FORCE_COLOR` | | Whether to produce colored output. | `true` when terminal is in TTY mode, `false` otherwise. + ### HTML reporter HTML reporter produces a self-contained folder that contains report for the test run that can be served as a web page. @@ -159,7 +194,7 @@ npx playwright test --reporter=html ``` By default, HTML report is opened automatically if some of the tests failed. You can control this behavior via the -`open` property in the Playwright config or the `PW_TEST_HTML_REPORT_OPEN` environmental variable. The possible values for that property are `always`, `never` and `on-failure` +`open` property in the Playwright config or the `PLAYWRIGHT_HTML_OPEN` environmental variable. The possible values for that property are `always`, `never` and `on-failure` (default). You can also configure `host` and `port` that are used to serve the HTML report. @@ -173,7 +208,7 @@ export default defineConfig({ ``` By default, report is written into the `playwright-report` folder in the current working directory. One can override -that location using the `PLAYWRIGHT_HTML_REPORT` environment variable or a reporter configuration. +that location using the `PLAYWRIGHT_HTML_OUTPUT_DIR` environment variable or a reporter configuration. In configuration file, pass options directly: @@ -207,6 +242,16 @@ Or if there is a custom folder name: npx playwright show-report my-report ``` +HTML report supports the following configuration options and environment variables: + +| Environment Variable Name | Reporter Config Option| Description | Default +|---|---|---|---| +| `PLAYWRIGHT_HTML_OUTPUT_DIR` | `outputFolder` | Directory to save the report to. | `playwright-report` +| `PLAYWRIGHT_HTML_OPEN` | `open` | When to open the html report in the browser, one of `'always'`, `'never'` or `'on-failure'` | `'on-failure'` +| `PLAYWRIGHT_HTML_HOST` | `host` | When report opens in the browser, it will be served bound to this hostname. | `localhost` +| `PLAYWRIGHT_HTML_PORT` | `port` | When report opens in the browser, it will be served on this port. | `9323` or any available port when `9323` is not available. +| `PLAYWRIGHT_HTML_ATTACHMENTS_BASE_URL` | `attachmentsBaseURL` | A separate location where attachments from the `data` subdirectory are uploaded. Only needed when you upload report and `data` separately to different locations. | `data/` + ### Blob reporter Blob reports contain all the details about the test run and can be used later to produce any other report. Their primary function is to facilitate the merging of reports from [sharded tests](./test-sharding.md). @@ -308,8 +353,8 @@ JUnit report supports following configuration options and environment variables: | `PLAYWRIGHT_JUNIT_OUTPUT_DIR` | | Directory to save the output file. Ignored if output file is not specified. | `cwd` or config directory. | `PLAYWRIGHT_JUNIT_OUTPUT_NAME` | `outputFile` | Base file name for the output, relative to the output dir. | JUnit report is printed to the stdout. | `PLAYWRIGHT_JUNIT_OUTPUT_FILE` | `outputFile` | Full path to the output file. If defined, `PLAYWRIGHT_JUNIT_OUTPUT_DIR` and `PLAYWRIGHT_JUNIT_OUTPUT_NAME` will be ignored. | JUnit report is printed to the stdout. -| | `stripANSIControlSequences` | Whether to remove ANSI control sequences from the text before writing it in the report. | By default output text is added as is. -| | `includeProjectInTestName` | Whether to include Playwright project name in every test case as a name prefix. | By default not included. +| `PLAYWRIGHT_JUNIT_STRIP_ANSI` | `stripANSIControlSequences` | Whether to remove ANSI control sequences from the text before writing it in the report. | By default output text is added as is. +| `PLAYWRIGHT_JUNIT_INCLUDE_PROJECT_IN_TEST_NAME` | `includeProjectInTestName` | Whether to include Playwright project name in every test case as a name prefix. | By default not included. | `PLAYWRIGHT_JUNIT_SUITE_ID` | | Value of the `id` attribute on the root `` report entry. | Empty string. | `PLAYWRIGHT_JUNIT_SUITE_NAME` | | Value of the `name` attribute on the root `` report entry. | Empty string. diff --git a/packages/html-reporter/src/testCaseView.spec.tsx b/packages/html-reporter/src/testCaseView.spec.tsx index 28fe8247f5..0a4da91b6e 100644 --- a/packages/html-reporter/src/testCaseView.spec.tsx +++ b/packages/html-reporter/src/testCaseView.spec.tsx @@ -52,8 +52,8 @@ const testCase: TestCase = { projectName: 'chromium', location: { file: 'test.spec.ts', line: 42, column: 0 }, annotations: [ - { type: 'annotation', description: 'Annotation text' }, - { type: 'annotation', description: 'Another annotation text' }, + { type: 'annotation', description: 'Annotation text', url: 'example url' }, + { type: 'annotation', description: 'Another annotation text', url: 'Another example url' }, ], tags: [], outcome: 'expected', diff --git a/packages/html-reporter/src/types.ts b/packages/html-reporter/src/types.ts index 733e88e8b9..79dc2f3812 100644 --- a/packages/html-reporter/src/types.ts +++ b/packages/html-reporter/src/types.ts @@ -59,7 +59,7 @@ export type TestFileSummary = { stats: Stats; }; -export type TestCaseAnnotation = { type: string, description?: string }; +export type TestCaseAnnotation = { type: string, description?: string, url?: string}; export type TestCaseSummary = { testId: string, diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 80ceb6caac..1def3af185 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -3,37 +3,39 @@ "browsers": [ { "name": "chromium", - "revision": "1118", + "revision": "1119", "installByDefault": true, - "browserVersion": "125.0.6422.41" + "browserVersion": "126.0.6478.8" }, { "name": "chromium-tip-of-tree", - "revision": "1220", + "revision": "1222", "installByDefault": false, - "browserVersion": "126.0.6467.2" + "browserVersion": "126.0.6478.8" }, { "name": "firefox", - "revision": "1449", + "revision": "1450", "installByDefault": true, - "browserVersion": "125.0.1" + "browserVersion": "126.0" }, { "name": "firefox-beta", - "revision": "1449", + "revision": "1450", "installByDefault": false, - "browserVersion": "126.0b1" + "browserVersion": "127.0b3" }, { "name": "webkit", - "revision": "2005", + "revision": "2010", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", "mac10.15": "1616", "mac11": "1816", - "mac11-arm64": "1816" + "mac11-arm64": "1816", + "mac12": "2009", + "mac12-arm64": "2009" }, "browserVersion": "17.4" }, diff --git a/packages/playwright-core/src/client/channelOwner.ts b/packages/playwright-core/src/client/channelOwner.ts index 04af8c5aea..0538dcb143 100644 --- a/packages/playwright-core/src/client/channelOwner.ts +++ b/packages/playwright-core/src/client/channelOwner.ts @@ -143,12 +143,16 @@ export abstract class ChannelOwner { return await this._wrapApiCall(async apiZone => { - const { apiName, frames, csi, callCookie, wallTime } = apiZone.reported ? { apiName: undefined, csi: undefined, callCookie: undefined, frames: [], wallTime: undefined } : apiZone; + const { apiName, frames, csi, callCookie, stepId } = apiZone.reported ? { apiName: undefined, csi: undefined, callCookie: undefined, frames: [], stepId: undefined } : apiZone; apiZone.reported = true; - if (csi && apiName) + let currentStepId = stepId; + if (csi && apiName) { + const out: { stepId?: string } = {}; // this is meant to be awaited so all async hooks can be completed before the actual command gets dispatched - await csi.onApiCallBegin(apiName, params, frames, wallTime, callCookie); - return await this._connection.sendMessageToServer(this, prop, validator(params, '', { tChannelImpl: tChannelImplToWire, binary: this._connection.rawBuffers() ? 'buffer' : 'toBase64' }), apiName, frames, wallTime); + await csi.onApiCallBegin(apiName, params, frames, callCookie, out); + currentStepId = out.stepId; + } + return await this._connection.sendMessageToServer(this, prop, validator(params, '', { tChannelImpl: tChannelImplToWire, binary: this._connection.rawBuffers() ? 'buffer' : 'toBase64' }), apiName, frames, currentStepId); }); }; } @@ -176,7 +180,7 @@ export abstract class ChannelOwner('expectZone'); - const wallTime = expectZone ? expectZone.wallTime : Date.now(); + const stepId = expectZone?.stepId; if (!isInternal && expectZone) apiName = expectZone.title; @@ -187,7 +191,7 @@ export abstract class ChannelOwner ${apiName} started`, isInternal); - const apiZone: ApiZone = { apiName, frames, isInternal, reported: false, csi, callCookie, wallTime }; + const apiZone: ApiZone = { apiName, frames, isInternal, reported: false, csi, callCookie, stepId }; const result = await zones.run('apiZone', apiZone, async () => await func(apiZone)); await csi?.onApiCallEnd(callCookie); logApiCall(logger, `<= ${apiName} succeeded`, isInternal); @@ -244,5 +248,5 @@ type ApiZone = { reported: boolean; csi: ClientInstrumentation | undefined; callCookie: any; - wallTime: number; + stepId?: string; }; diff --git a/packages/playwright-core/src/client/clientInstrumentation.ts b/packages/playwright-core/src/client/clientInstrumentation.ts index 2b5dd00970..dd73299420 100644 --- a/packages/playwright-core/src/client/clientInstrumentation.ts +++ b/packages/playwright-core/src/client/clientInstrumentation.ts @@ -22,7 +22,7 @@ export interface ClientInstrumentation { addListener(listener: ClientInstrumentationListener): void; removeListener(listener: ClientInstrumentationListener): void; removeAllListeners(): void; - onApiCallBegin(apiCall: string, params: Record, frames: StackFrame[], wallTime: number, userData: any): void; + onApiCallBegin(apiCall: string, params: Record, frames: StackFrame[], userData: any, out: { stepId?: string }): void; onApiCallEnd(userData: any, error?: Error): void; onDidCreateBrowserContext(context: BrowserContext): Promise; onDidCreateRequestContext(context: APIRequestContext): Promise; @@ -32,7 +32,7 @@ export interface ClientInstrumentation { } export interface ClientInstrumentationListener { - onApiCallBegin?(apiName: string, params: Record, frames: StackFrame[], wallTime: number, userData: any): void; + onApiCallBegin?(apiName: string, params: Record, frames: StackFrame[], userData: any, out: { stepId?: string }): void; onApiCallEnd?(userData: any, error?: Error): void; onDidCreateBrowserContext?(context: BrowserContext): Promise; onDidCreateRequestContext?(context: APIRequestContext): Promise; diff --git a/packages/playwright-core/src/client/connection.ts b/packages/playwright-core/src/client/connection.ts index 611a8c9f6a..294b5d9f6e 100644 --- a/packages/playwright-core/src/client/connection.ts +++ b/packages/playwright-core/src/client/connection.ts @@ -118,7 +118,7 @@ export class Connection extends EventEmitter { this._tracingCount--; } - async sendMessageToServer(object: ChannelOwner, method: string, params: any, apiName: string | undefined, frames: channels.StackFrame[], wallTime: number | undefined): Promise { + async sendMessageToServer(object: ChannelOwner, method: string, params: any, apiName: string | undefined, frames: channels.StackFrame[], stepId?: string): Promise { if (this._closedError) throw this._closedError; if (object._wasCollected) @@ -133,7 +133,7 @@ export class Connection extends EventEmitter { debugLogger.log('channel', 'SEND> ' + JSON.stringify(message)); } const location = frames[0] ? { file: frames[0].file, line: frames[0].line, column: frames[0].column } : undefined; - const metadata: channels.Metadata = { wallTime, apiName, location, internal: !apiName }; + const metadata: channels.Metadata = { apiName, location, internal: !apiName, stepId }; if (this._tracingCount && frames && type !== 'LocalUtils') this._localUtils?._channel.addStackToTracingNoReply({ callData: { stack: frames, id } }).catch(() => {}); // We need to exit zones before calling into the server, otherwise diff --git a/packages/playwright-core/src/client/fetch.ts b/packages/playwright-core/src/client/fetch.ts index afe622bb6d..34f0af3eae 100644 --- a/packages/playwright-core/src/client/fetch.ts +++ b/packages/playwright-core/src/client/fetch.ts @@ -85,6 +85,7 @@ export class APIRequest implements api.APIRequest { export class APIRequestContext extends ChannelOwner implements api.APIRequestContext { _request?: APIRequest; readonly _tracing: Tracing; + private _closeReason: string | undefined; static from(channel: channels.APIRequestContextChannel): APIRequestContext { return (channel as any)._object; @@ -99,9 +100,10 @@ export class APIRequestContext extends ChannelOwner { + async dispose(options: { reason?: string } = {}): Promise { + this._closeReason = options.reason; await this._instrumentation.onWillCloseRequestContext(this); - await this._channel.dispose(); + await this._channel.dispose(options); this._tracing._resetStackCounter(); this._request?._contexts.delete(this); } @@ -156,6 +158,8 @@ export class APIRequestContext extends ChannelOwner { return await this._wrapApiCall(async () => { + if (this._closeReason) + throw new Error(this._closeReason); assert(options.request || typeof options.url === 'string', 'First argument must be either URL string or Request'); assert((options.data === undefined ? 0 : 1) + (options.form === undefined ? 0 : 1) + (options.multipart === undefined ? 0 : 1) <= 1, `Only one of 'data', 'form' or 'multipart' can be specified`); assert(options.maxRedirects === undefined || options.maxRedirects >= 0, `'maxRedirects' should be greater than or equal to '0'`); diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index 4e22a27dd6..36ade499cf 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -101,7 +101,6 @@ export class Request extends ChannelOwner implements ap if (this._redirectedFrom) this._redirectedFrom._redirectedTo = this; this._provisionalHeaders = new RawHeaders(initializer.headers); - this._fallbackOverrides.postDataBuffer = initializer.postData; this._timing = { startTime: 0, domainLookupStart: -1, @@ -128,11 +127,11 @@ export class Request extends ChannelOwner implements ap } postData(): string | null { - return this._fallbackOverrides.postDataBuffer?.toString('utf-8') || null; + return (this._fallbackOverrides.postDataBuffer || this._initializer.postData)?.toString('utf-8') || null; } postDataBuffer(): Buffer | null { - return this._fallbackOverrides.postDataBuffer || null; + return this._fallbackOverrides.postDataBuffer || this._initializer.postData || null; } postDataJSON(): Object | null { diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index f97cf10969..1cc57fb5a7 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -34,7 +34,7 @@ scheme.Metadata = tObject({ })), apiName: tOptional(tString), internal: tOptional(tBoolean), - wallTime: tOptional(tNumber), + stepId: tOptional(tString), }); scheme.ClientSideCallMetadata = tObject({ id: tNumber, @@ -207,7 +207,9 @@ scheme.APIRequestContextDisposeAPIResponseParams = tObject({ fetchUid: tString, }); scheme.APIRequestContextDisposeAPIResponseResult = tOptional(tObject({})); -scheme.APIRequestContextDisposeParams = tOptional(tObject({})); +scheme.APIRequestContextDisposeParams = tObject({ + reason: tOptional(tString), +}); scheme.APIRequestContextDisposeResult = tOptional(tObject({})); scheme.APIResponse = tObject({ fetchUid: tString, diff --git a/packages/playwright-core/src/server/chromium/crNetworkManager.ts b/packages/playwright-core/src/server/chromium/crNetworkManager.ts index b78dd4084e..3752a5c06f 100644 --- a/packages/playwright-core/src/server/chromium/crNetworkManager.ts +++ b/packages/playwright-core/src/server/chromium/crNetworkManager.ts @@ -317,7 +317,7 @@ export class CRNetworkManager { requestPausedSessionInfo!.session._sendMayFail('Fetch.fulfillRequest', { requestId: requestPausedEvent.requestId, responseCode: 204, - responsePhrase: network.STATUS_TEXTS['204'], + responsePhrase: network.statusText(204), responseHeaders, body: '', }); @@ -376,6 +376,10 @@ export class CRNetworkManager { if (response.body || !expectedLength) return Buffer.from(response.body, response.base64Encoded ? 'base64' : 'utf8'); + // Make sure no network requests sent while reading the body for fulfilled requests. + if (request._route?._fulfilled) + return Buffer.from(''); + // For { + async dispose(params: channels.APIRequestContextDisposeParams, metadata: CallMetadata): Promise { metadata.potentiallyClosesScope = true; - await this._object.dispose(); + await this._object.dispose(params); this._dispose(); } diff --git a/packages/playwright-core/src/server/electron/electron.ts b/packages/playwright-core/src/server/electron/electron.ts index c55257510c..b8f361b48a 100644 --- a/packages/playwright-core/src/server/electron/electron.ts +++ b/packages/playwright-core/src/server/electron/electron.ts @@ -161,7 +161,7 @@ export class Electron extends SdkObject { return controller.run(async progress => { let app: ElectronApplication | undefined = undefined; // --remote-debugging-port=0 must be the last playwright's argument, loader.ts relies on it. - const electronArguments = ['--inspect=0', '--remote-debugging-port=0', ...args]; + let electronArguments = ['--inspect=0', '--remote-debugging-port=0', ...args]; if (os.platform() === 'linux') { const runningAsRoot = process.geteuid && process.geteuid() === 0; @@ -195,6 +195,16 @@ export class Electron extends SdkObject { // Packaged apps might have their own command line handling. electronArguments.unshift('-r', require.resolve('./loader')); } + let shell = false; + if (process.platform === 'win32') { + // On Windows in order to run .cmd files, shell: true is required. + // https://github.com/nodejs/node/issues/52554 + shell = true; + // On Windows, we need to quote the executable path due to shell: true. + command = `"${command}"`; + // On Windows, we need to quote the arguments due to shell: true. + electronArguments = electronArguments.map(arg => `"${arg}"`); + } // When debugging Playwright test that runs Electron, NODE_OPTIONS // will make the debugger attach to Electron's Node. But Playwright @@ -208,9 +218,7 @@ export class Electron extends SdkObject { progress.log(message); browserLogsCollector.log(message); }, - // On Windows in order to run .cmd files, shell: true is required. - // https://github.com/nodejs/node/issues/52554 - shell: process.platform === 'win32', + shell, stdio: 'pipe', cwd: options.cwd, tempDirectories: [artifactsDir], @@ -221,6 +229,8 @@ export class Electron extends SdkObject { onExit: () => app?.emit(ElectronApplication.Events.Close), }); + // All waitForLines must be started immediately. + // Otherwise the lines might come before we are ready. const waitForXserverError = new Promise(async (resolve, reject) => { waitForLine(progress, launchedProcess, /Unable to open X display/).then(() => reject(new Error([ 'Unable to open X display!', @@ -232,17 +242,20 @@ export class Electron extends SdkObject { progress.metadata.log ].join('\n')))).catch(() => {}); }); + const nodeMatchPromise = waitForLine(progress, launchedProcess, /^Debugger listening on (ws:\/\/.*)$/); + const chromeMatchPromise = waitForLine(progress, launchedProcess, /^DevTools listening on (ws:\/\/.*)$/); + const debuggerDisconnectPromise = waitForLine(progress, launchedProcess, /Waiting for the debugger to disconnect\.\.\./); - const nodeMatch = await waitForLine(progress, launchedProcess, /^Debugger listening on (ws:\/\/.*)$/); + const nodeMatch = await nodeMatchPromise; const nodeTransport = await WebSocketTransport.connect(progress, nodeMatch[1]); const nodeConnection = new CRConnection(nodeTransport, helper.debugProtocolLogger(), browserLogsCollector); // Immediately release exiting process under debug. - waitForLine(progress, launchedProcess, /Waiting for the debugger to disconnect\.\.\./).then(() => { + debuggerDisconnectPromise.then(() => { nodeTransport.close(); }).catch(() => {}); const chromeMatch = await Promise.race([ - waitForLine(progress, launchedProcess, /^DevTools listening on (ws:\/\/.*)$/), + chromeMatchPromise, waitForXserverError, ]) as RegExpMatchArray; const chromeTransport = await WebSocketTransport.connect(progress, chromeMatch[1]); diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index fa38723645..e15c5aca8a 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -121,7 +121,7 @@ export abstract class APIRequestContext extends SdkObject { abstract tracing(): Tracing; - abstract dispose(): Promise; + abstract dispose(options: { reason?: string }): Promise; abstract _defaultOptions(): FetchRequestOptions; abstract _addCookies(cookies: channels.NetworkCookie[]): Promise; @@ -336,12 +336,15 @@ export abstract class APIRequestContext extends SdkObject { redirectOptions.rejectUnauthorized = false; // HTTP-redirect fetch step 4: If locationURL is null, then return response. - if (response.headers.location) { + // Best-effort UTF-8 decoding, per spec it's US-ASCII only, but browsers are more lenient. + // Node.js parses it as Latin1 via std::v8::String, so we convert it to UTF-8. + const locationHeaderValue = Buffer.from(response.headers.location ?? '', 'latin1').toString('utf8'); + if (locationHeaderValue) { let locationURL; try { - locationURL = new URL(response.headers.location, url); + locationURL = new URL(locationHeaderValue, url); } catch (error) { - reject(new Error(`uri requested responds with an invalid redirect URL: ${response.headers.location}`)); + reject(new Error(`uri requested responds with an invalid redirect URL: ${locationHeaderValue}`)); request.destroy(); return; } @@ -483,7 +486,8 @@ export class BrowserContextAPIRequestContext extends APIRequestContext { return this._context.tracing; } - override async dispose() { + override async dispose(options: { reason?: string }) { + this._closeReason = options.reason; this.fetchResponses.clear(); } @@ -552,7 +556,8 @@ export class GlobalAPIRequestContext extends APIRequestContext { return this._tracing; } - override async dispose() { + override async dispose(options: { reason?: string }) { + this._closeReason = options.reason; await this._tracing.flush(); await this._tracing.deleteTmpTracesDir(); this._disposeImpl(); diff --git a/packages/playwright-core/src/server/firefox/ffNetworkManager.ts b/packages/playwright-core/src/server/firefox/ffNetworkManager.ts index 081a1c9480..266f5bcb83 100644 --- a/packages/playwright-core/src/server/firefox/ffNetworkManager.ts +++ b/packages/playwright-core/src/server/firefox/ffNetworkManager.ts @@ -242,7 +242,7 @@ class FFRouteImpl implements network.RouteDelegate { await this._session.sendMayFail('Network.fulfillInterceptedRequest', { requestId: this._request._id, status: response.status, - statusText: network.STATUS_TEXTS[String(response.status)] || '', + statusText: network.statusText(response.status), headers: response.headers, base64body, }); diff --git a/packages/playwright-core/src/server/injected/highlight.ts b/packages/playwright-core/src/server/injected/highlight.ts index b9fdc8fa3f..dfc298cb24 100644 --- a/packages/playwright-core/src/server/injected/highlight.ts +++ b/packages/playwright-core/src/server/injected/highlight.ts @@ -55,7 +55,6 @@ export class Highlight { const document = injectedScript.document; this._isUnderTest = injectedScript.isUnderTest; this._glassPaneElement = document.createElement('x-pw-glass'); - this._glassPaneElement.popover = 'manual'; this._glassPaneElement.style.position = 'fixed'; this._glassPaneElement.style.top = '0'; this._glassPaneElement.style.right = '0'; @@ -65,12 +64,6 @@ export class Highlight { this._glassPaneElement.style.pointerEvents = 'none'; this._glassPaneElement.style.display = 'flex'; this._glassPaneElement.style.backgroundColor = 'transparent'; - this._glassPaneElement.style.width = 'inherit'; - this._glassPaneElement.style.height = 'inherit'; - this._glassPaneElement.style.padding = '0'; - this._glassPaneElement.style.margin = '0'; - this._glassPaneElement.style.border = 'none'; - this._glassPaneElement.style.overflow = 'hidden'; for (const eventName of ['click', 'auxclick', 'dragstart', 'input', 'keydown', 'keyup', 'pointerdown', 'pointerup', 'mousedown', 'mouseup', 'mouseleave', 'focus', 'scroll']) { this._glassPaneElement.addEventListener(eventName, e => { e.stopPropagation(); @@ -98,8 +91,6 @@ export class Highlight { install() { this._injectedScript.document.documentElement.appendChild(this._glassPaneElement); - // Popover is not supported in WebKit-macOS < 14.0 - this._glassPaneElement.showPopover?.(); } setLanguage(language: Language) { @@ -116,8 +107,6 @@ export class Highlight { uninstall() { if (this._rafRequest) cancelAnimationFrame(this._rafRequest); - // Popover is not supported in WebKit-macOS < 14.0 - this._glassPaneElement.hidePopover?.(); this._glassPaneElement.remove(); } diff --git a/packages/playwright-core/src/server/injected/selectorGenerator.ts b/packages/playwright-core/src/server/injected/selectorGenerator.ts index b88b25acf4..7e4f51b1b2 100644 --- a/packages/playwright-core/src/server/injected/selectorGenerator.ts +++ b/packages/playwright-core/src/server/injected/selectorGenerator.ts @@ -325,8 +325,10 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i const cssToken: SelectorToken = { engine: 'css', selector: cssEscape(element.nodeName.toLowerCase()), score: kCSSTagNameScore }; for (const alternative of alternatives) candidates.push([cssToken, { engine: 'internal:has-text', selector: escapeForTextSelector(alternative.text, false), score: kTextScore - alternative.scoreBouns }]); - if (text.length <= 80) - candidates.push([cssToken, { engine: 'internal:has-text', selector: '/^' + escapeRegExp(text) + '$/', score: kTextScoreRegex }]); + if (text.length <= 80) { + const re = new RegExp('^' + escapeRegExp(text) + '$'); + candidates.push([cssToken, { engine: 'internal:has-text', selector: escapeForTextSelector(re, false), score: kTextScoreRegex }]); + } } const ariaRole = getAriaRole(element); diff --git a/packages/playwright-core/src/server/instrumentation.ts b/packages/playwright-core/src/server/instrumentation.ts index 5d212279e3..fbaa3345d6 100644 --- a/packages/playwright-core/src/server/instrumentation.ts +++ b/packages/playwright-core/src/server/instrumentation.ts @@ -106,7 +106,6 @@ export function serverSideCallMetadata(): CallMetadata { id: '', startTime: 0, endTime: 0, - wallTime: Date.now(), type: 'Internal', method: '', params: {}, diff --git a/packages/playwright-core/src/server/network.ts b/packages/playwright-core/src/server/network.ts index 83b0093a38..fd62e1751b 100644 --- a/packages/playwright-core/src/server/network.ts +++ b/packages/playwright-core/src/server/network.ts @@ -616,7 +616,7 @@ export interface RouteDelegate { } // List taken from https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml with extra 306 and 418 codes. -export const STATUS_TEXTS: { [status: string]: string } = { +const STATUS_TEXTS: { [status: string]: string } = { '100': 'Continue', '101': 'Switching Protocols', '102': 'Processing', @@ -682,6 +682,10 @@ export const STATUS_TEXTS: { [status: string]: string } = { '511': 'Network Authentication Required', }; +export function statusText(status: number): string { + return STATUS_TEXTS[String(status)] || 'Unknown'; +} + export function singleHeader(name: string, value: string): HeadersArray { return [{ name, value }]; } diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index d5f84ba625..3a770741ae 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -605,7 +605,6 @@ class ContextRecorder extends EventEmitter { frameId: frame.guid, startTime: monotonicTime(), endTime: 0, - wallTime: Date.now(), type: 'Frame', method: action, params, diff --git a/packages/playwright-core/src/server/registry/index.ts b/packages/playwright-core/src/server/registry/index.ts index 9cec89ebd3..b23865663c 100644 --- a/packages/playwright-core/src/server/registry/index.ts +++ b/packages/playwright-core/src/server/registry/index.ts @@ -137,17 +137,17 @@ const DOWNLOAD_PATHS: Record = { 'debian11-arm64': 'builds/firefox/%s/firefox-debian-11-arm64.zip', 'debian12-x64': 'builds/firefox/%s/firefox-debian-12.zip', 'debian12-arm64': 'builds/firefox/%s/firefox-debian-12-arm64.zip', - 'mac10.13': 'builds/firefox/%s/firefox-mac-13.zip', - 'mac10.14': 'builds/firefox/%s/firefox-mac-13.zip', - 'mac10.15': 'builds/firefox/%s/firefox-mac-13.zip', - 'mac11': 'builds/firefox/%s/firefox-mac-13.zip', - 'mac11-arm64': 'builds/firefox/%s/firefox-mac-13-arm64.zip', - 'mac12': 'builds/firefox/%s/firefox-mac-13.zip', - 'mac12-arm64': 'builds/firefox/%s/firefox-mac-13-arm64.zip', - 'mac13': 'builds/firefox/%s/firefox-mac-13.zip', - 'mac13-arm64': 'builds/firefox/%s/firefox-mac-13-arm64.zip', - 'mac14': 'builds/firefox/%s/firefox-mac-13.zip', - 'mac14-arm64': 'builds/firefox/%s/firefox-mac-13-arm64.zip', + 'mac10.13': 'builds/firefox/%s/firefox-mac.zip', + 'mac10.14': 'builds/firefox/%s/firefox-mac.zip', + 'mac10.15': 'builds/firefox/%s/firefox-mac.zip', + 'mac11': 'builds/firefox/%s/firefox-mac.zip', + 'mac11-arm64': 'builds/firefox/%s/firefox-mac-arm64.zip', + 'mac12': 'builds/firefox/%s/firefox-mac.zip', + 'mac12-arm64': 'builds/firefox/%s/firefox-mac-arm64.zip', + 'mac13': 'builds/firefox/%s/firefox-mac.zip', + 'mac13-arm64': 'builds/firefox/%s/firefox-mac-arm64.zip', + 'mac14': 'builds/firefox/%s/firefox-mac.zip', + 'mac14-arm64': 'builds/firefox/%s/firefox-mac-arm64.zip', 'win64': 'builds/firefox/%s/firefox-win64.zip', }, 'firefox-beta': { @@ -162,17 +162,17 @@ const DOWNLOAD_PATHS: Record = { 'debian11-arm64': 'builds/firefox-beta/%s/firefox-beta-debian-11-arm64.zip', 'debian12-x64': 'builds/firefox-beta/%s/firefox-beta-debian-12.zip', 'debian12-arm64': 'builds/firefox-beta/%s/firefox-beta-debian-12-arm64.zip', - 'mac10.13': 'builds/firefox-beta/%s/firefox-beta-mac-13.zip', - 'mac10.14': 'builds/firefox-beta/%s/firefox-beta-mac-13.zip', - 'mac10.15': 'builds/firefox-beta/%s/firefox-beta-mac-13.zip', - 'mac11': 'builds/firefox-beta/%s/firefox-beta-mac-13.zip', - 'mac11-arm64': 'builds/firefox-beta/%s/firefox-beta-mac-13-arm64.zip', - 'mac12': 'builds/firefox-beta/%s/firefox-beta-mac-13.zip', - 'mac12-arm64': 'builds/firefox-beta/%s/firefox-beta-mac-13-arm64.zip', - 'mac13': 'builds/firefox-beta/%s/firefox-beta-mac-13.zip', - 'mac13-arm64': 'builds/firefox-beta/%s/firefox-beta-mac-13-arm64.zip', - 'mac14': 'builds/firefox-beta/%s/firefox-beta-mac-13.zip', - 'mac14-arm64': 'builds/firefox-beta/%s/firefox-beta-mac-13-arm64.zip', + 'mac10.13': 'builds/firefox-beta/%s/firefox-beta-mac.zip', + 'mac10.14': 'builds/firefox-beta/%s/firefox-beta-mac.zip', + 'mac10.15': 'builds/firefox-beta/%s/firefox-beta-mac.zip', + 'mac11': 'builds/firefox-beta/%s/firefox-beta-mac.zip', + 'mac11-arm64': 'builds/firefox-beta/%s/firefox-beta-mac-arm64.zip', + 'mac12': 'builds/firefox-beta/%s/firefox-beta-mac.zip', + 'mac12-arm64': 'builds/firefox-beta/%s/firefox-beta-mac-arm64.zip', + 'mac13': 'builds/firefox-beta/%s/firefox-beta-mac.zip', + 'mac13-arm64': 'builds/firefox-beta/%s/firefox-beta-mac-arm64.zip', + 'mac14': 'builds/firefox-beta/%s/firefox-beta-mac.zip', + 'mac14-arm64': 'builds/firefox-beta/%s/firefox-beta-mac-arm64.zip', 'win64': 'builds/firefox-beta/%s/firefox-beta-win64.zip', }, 'webkit': { diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index 774cd24163..85c783118d 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -45,7 +45,7 @@ import type { ConsoleMessage } from '../../console'; import { Dispatcher } from '../../dispatchers/dispatcher'; import { serializeError } from '../../errors'; -const version: trace.VERSION = 6; +const version: trace.VERSION = 7; export type TracerOptions = { name?: string; @@ -100,10 +100,12 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps this._contextCreatedEvent = { version, type: 'context-options', + origin: 'library', browserName: '', options: {}, platform: process.platform, wallTime: 0, + monotonicTime: 0, sdkLanguage: context.attribution.playwright.options.sdkLanguage, testIdAttributeName }; @@ -177,7 +179,12 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps this._allocateNewTraceFile(this._state); this._fs.mkdir(path.dirname(this._state.traceFile)); - const event: trace.TraceEvent = { ...this._contextCreatedEvent, title: options.title, wallTime: Date.now() }; + const event: trace.TraceEvent = { + ...this._contextCreatedEvent, + title: options.title, + wallTime: Date.now(), + monotonicTime: monotonicTime() + }; this._fs.appendFile(this._state.traceFile, JSON.stringify(event) + '\n'); this._context.instrumentation.addListener(this, this._context); @@ -535,7 +542,7 @@ function createBeforeActionTraceEvent(metadata: CallMetadata): trace.BeforeActio class: metadata.type, method: metadata.method, params: metadata.params, - wallTime: metadata.wallTime, + stepId: metadata.stepId, pageId: metadata.pageId, }; } diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index 3de6bec1b8..741a927757 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -132,7 +132,7 @@ export async function installRootRedirect(server: HttpServer, traceUrls: string[ for (const reporter of options.reporter || []) params.append('reporter', reporter); - const urlPath = `/trace/${options.webApp || 'index.html'}?${params.toString()}`; + const urlPath = `./trace/${options.webApp || 'index.html'}?${params.toString()}`; server.routePath('/', (_, response) => { response.statusCode = 302; response.setHeader('Location', urlPath); @@ -145,7 +145,7 @@ export async function runTraceViewerApp(traceUrls: string[], browserName: string validateTraceUrls(traceUrls); const server = await startTraceViewerServer(options); await installRootRedirect(server, traceUrls, options); - const page = await openTraceViewerApp(server.urlPrefix(), browserName, options); + const page = await openTraceViewerApp(server.urlPrefix('precise'), browserName, options); if (exitOnClose) page.on('close', () => gracefullyProcessExitDoNotHang(0)); return page; @@ -155,7 +155,7 @@ export async function runTraceInBrowser(traceUrls: string[], options: TraceViewe validateTraceUrls(traceUrls); const server = await startTraceViewerServer(options); await installRootRedirect(server, traceUrls, options); - await openTraceInBrowser(server.urlPrefix()); + await openTraceInBrowser(server.urlPrefix('human-readable')); } export async function openTraceViewerApp(url: string, browserName: string, options?: TraceViewerAppOptions): Promise { diff --git a/packages/playwright-core/src/server/webkit/wkInterceptableRequest.ts b/packages/playwright-core/src/server/webkit/wkInterceptableRequest.ts index 55aabf0575..f3c669dfc7 100644 --- a/packages/playwright-core/src/server/webkit/wkInterceptableRequest.ts +++ b/packages/playwright-core/src/server/webkit/wkInterceptableRequest.ts @@ -75,7 +75,7 @@ export class WKInterceptableRequest { requestStart: timingPayload ? wkMillisToRoundishMillis(timingPayload.requestStart) : -1, responseStart: timingPayload ? wkMillisToRoundishMillis(timingPayload.responseStart) : -1, }; - const setCookieSeparator = process.platform === 'darwin' ? ',' : '\n'; + const setCookieSeparator = process.platform === 'darwin' ? ',' : 'playwright-set-cookie-separator'; const response = new network.Response(this.request, responsePayload.status, responsePayload.statusText, headersObjectToArray(responsePayload.headers, ',', setCookieSeparator), timing, getResponseBody, responsePayload.source === 'service-worker'); // No raw response headers in WebKit, use "provisional" ones. @@ -128,7 +128,7 @@ export class WKRouteImpl implements network.RouteDelegate { await this._session.sendMayFail('Network.interceptRequestWithResponse', { requestId: this._requestId, status: response.status, - statusText: network.STATUS_TEXTS[String(response.status)], + statusText: network.statusText(response.status), mimeType, headers, base64Encoded: response.isBase64, diff --git a/packages/playwright-core/src/utils/env.ts b/packages/playwright-core/src/utils/env.ts index 3a13296d8f..2a4dd0bfd4 100644 --- a/packages/playwright-core/src/utils/env.ts +++ b/packages/playwright-core/src/utils/env.ts @@ -21,9 +21,13 @@ export function getFromENV(name: string): string | undefined { return value; } -export function getAsBooleanFromENV(name: string): boolean { +export function getAsBooleanFromENV(name: string, defaultValue?: boolean | undefined): boolean { const value = getFromENV(name); - return !!value && value !== 'false' && value !== '0'; + if (value === 'false' || value === '0') + return false; + if (value) + return true; + return !!defaultValue; } export function getPackageManager() { diff --git a/packages/playwright-core/src/utils/httpServer.ts b/packages/playwright-core/src/utils/httpServer.ts index f387cefaa8..24a84ea502 100644 --- a/packages/playwright-core/src/utils/httpServer.ts +++ b/packages/playwright-core/src/utils/httpServer.ts @@ -34,14 +34,14 @@ export type Transport = { export class HttpServer { private _server: http.Server; - private _urlPrefix: string; + private _urlPrefixPrecise: string = ''; + private _urlPrefixHumanReadable: string = ''; private _port: number = 0; private _started = false; private _routes: { prefix?: string, exact?: string, handler: ServerRouteHandler }[] = []; private _wsGuid: string | undefined; - constructor(address: string = '') { - this._urlPrefix = address; + constructor() { this._server = createHttpServer(this._onRequest.bind(this)); } @@ -102,7 +102,7 @@ export class HttpServer { return this._wsGuid; } - async start(options: { port?: number, preferredPort?: number, host?: string } = {}): Promise { + async start(options: { port?: number, preferredPort?: number, host?: string } = {}): Promise { assert(!this._started, 'server already started'); this._started = true; @@ -121,24 +121,23 @@ export class HttpServer { const address = this._server.address(); assert(address, 'Could not bind server socket'); - if (!this._urlPrefix) { - if (typeof address === 'string') { - this._urlPrefix = address; - } else { - this._port = address.port; - const resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`; - this._urlPrefix = `http://${resolvedHost}:${address.port}`; - } + if (typeof address === 'string') { + this._urlPrefixPrecise = address; + this._urlPrefixHumanReadable = address; + } else { + this._port = address.port; + const resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`; + this._urlPrefixPrecise = `http://${resolvedHost}:${address.port}`; + this._urlPrefixHumanReadable = `http://${host}:${address.port}`; } - return this._urlPrefix; } async stop() { await new Promise(cb => this._server!.close(cb)); } - urlPrefix(): string { - return this._urlPrefix; + urlPrefix(purpose: 'human-readable' | 'precise'): string { + return purpose === 'human-readable' ? this._urlPrefixHumanReadable : this._urlPrefixPrecise; } serveFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string, headers?: { [name: string]: string }): boolean { diff --git a/packages/playwright-core/src/utils/stackTrace.ts b/packages/playwright-core/src/utils/stackTrace.ts index 2864d1ec81..77e1365b3f 100644 --- a/packages/playwright-core/src/utils/stackTrace.ts +++ b/packages/playwright-core/src/utils/stackTrace.ts @@ -140,5 +140,5 @@ Call log: export type ExpectZone = { title: string; - wallTime: number; + stepId: string; }; diff --git a/packages/playwright-core/types/protocol.d.ts b/packages/playwright-core/types/protocol.d.ts index 6d8a2559db..ad0ed7fc4c 100644 --- a/packages/playwright-core/types/protocol.d.ts +++ b/packages/playwright-core/types/protocol.d.ts @@ -841,6 +841,7 @@ CORS RFC1918 enforcement. clientSecurityState?: Network.ClientSecurityState; } export type AttributionReportingIssueType = "PermissionPolicyDisabled"|"UntrustworthyReportingOrigin"|"InsecureContext"|"InvalidHeader"|"InvalidRegisterTriggerHeader"|"SourceAndTriggerHeaders"|"SourceIgnored"|"TriggerIgnored"|"OsSourceIgnored"|"OsTriggerIgnored"|"InvalidRegisterOsSourceHeader"|"InvalidRegisterOsTriggerHeader"|"WebAndOsHeaders"|"NoWebOrOsSupport"|"NavigationRegistrationWithoutTransientUserActivation"|"InvalidInfoHeader"|"NoRegisterSourceHeader"|"NoRegisterTriggerHeader"|"NoRegisterOsSourceHeader"|"NoRegisterOsTriggerHeader"; + export type SharedDictionaryError = "UseErrorCrossOriginNoCorsRequest"|"UseErrorDictionaryLoadFailure"|"UseErrorMatchingDictionaryNotUsed"|"UseErrorUnexpectedContentDictionaryHeader"|"WriteErrorCossOriginNoCorsRequest"|"WriteErrorDisallowedBySettings"|"WriteErrorExpiredResponse"|"WriteErrorFeatureDisabled"|"WriteErrorInsufficientResources"|"WriteErrorInvalidMatchField"|"WriteErrorInvalidStructuredHeader"|"WriteErrorNavigationRequest"|"WriteErrorNoMatchField"|"WriteErrorNonListMatchDestField"|"WriteErrorNonSecureContext"|"WriteErrorNonStringIdField"|"WriteErrorNonStringInMatchDestList"|"WriteErrorNonStringMatchField"|"WriteErrorNonTokenTypeField"|"WriteErrorRequestAborted"|"WriteErrorShuttingDown"|"WriteErrorTooLongIdField"|"WriteErrorUnsupportedType"; /** * Details for issues around "Attribution Reporting API" usage. Explainer: https://github.com/WICG/attribution-reporting-api @@ -870,6 +871,10 @@ instead of "limited-quirks". url: string; location?: SourceCodeLocation; } + export interface SharedDictionaryIssueDetails { + sharedDictionaryError: SharedDictionaryError; + request: AffectedRequest; + } export type GenericIssueErrorType = "CrossOriginPortalPostMessageError"|"FormLabelForNameError"|"FormDuplicateIdForInputError"|"FormInputWithNoLabelError"|"FormAutocompleteAttributeEmptyError"|"FormEmptyIdAndNameAttributesForInputError"|"FormAriaLabelledByToNonExistingId"|"FormInputAssignedAutocompleteValueToIdOrNameAttributeError"|"FormLabelHasNeitherForNorNestedInput"|"FormLabelForMatchesNonExistingIdError"|"FormInputHasWrongButWellIntendedAutocompleteValueError"|"ResponseWasBlockedByORB"; /** * Depending on the concrete errorType, different properties are set. @@ -997,7 +1002,7 @@ registrations being ignored. optional fields in InspectorIssueDetails to convey more specific information about the kind of issue. */ - export type InspectorIssueCode = "CookieIssue"|"MixedContentIssue"|"BlockedByResponseIssue"|"HeavyAdIssue"|"ContentSecurityPolicyIssue"|"SharedArrayBufferIssue"|"LowTextContrastIssue"|"CorsIssue"|"AttributionReportingIssue"|"QuirksModeIssue"|"NavigatorUserAgentIssue"|"GenericIssue"|"DeprecationIssue"|"ClientHintIssue"|"FederatedAuthRequestIssue"|"BounceTrackingIssue"|"CookieDeprecationMetadataIssue"|"StylesheetLoadingIssue"|"FederatedAuthUserInfoRequestIssue"|"PropertyRuleIssue"; + export type InspectorIssueCode = "CookieIssue"|"MixedContentIssue"|"BlockedByResponseIssue"|"HeavyAdIssue"|"ContentSecurityPolicyIssue"|"SharedArrayBufferIssue"|"LowTextContrastIssue"|"CorsIssue"|"AttributionReportingIssue"|"QuirksModeIssue"|"NavigatorUserAgentIssue"|"GenericIssue"|"DeprecationIssue"|"ClientHintIssue"|"FederatedAuthRequestIssue"|"BounceTrackingIssue"|"CookieDeprecationMetadataIssue"|"StylesheetLoadingIssue"|"FederatedAuthUserInfoRequestIssue"|"PropertyRuleIssue"|"SharedDictionaryIssue"; /** * This struct holds a list of optional fields with additional information specific to the kind of issue. When adding a new issue code, please also @@ -1024,6 +1029,7 @@ add a new optional field to this type. stylesheetLoadingIssueDetails?: StylesheetLoadingIssueDetails; propertyRuleIssueDetails?: PropertyRuleIssueDetails; federatedAuthUserInfoRequestIssueDetails?: FederatedAuthUserInfoRequestIssueDetails; + sharedDictionaryIssueDetails?: SharedDictionaryIssueDetails; } /** * A unique id for a DevTools inspector issue. Allows other entities (e.g. @@ -1121,6 +1127,33 @@ using Audits.issueAdded event. } } + /** + * Defines commands and events for browser extensions. Available if the client +is connected using the --remote-debugging-pipe flag and +the --enable-unsafe-extension-debugging flag is set. + */ + export module Extensions { + + + /** + * Installs an unpacked extension from the filesystem similar to +--load-extension CLI flags. Returns extension ID once the extension +has been installed. + */ + export type loadUnpackedParameters = { + /** + * Absolute file path. + */ + path: string; + } + export type loadUnpackedReturnValue = { + /** + * Extension id. + */ + id: string; + } + } + /** * Defines commands and events for Autofill. */ @@ -3450,7 +3483,7 @@ front-end. /** * Pseudo element type. */ - export type PseudoType = "first-line"|"first-letter"|"before"|"after"|"marker"|"backdrop"|"selection"|"target-text"|"spelling-error"|"grammar-error"|"highlight"|"first-line-inherited"|"scroll-marker"|"scroll-markers"|"scrollbar"|"scrollbar-thumb"|"scrollbar-button"|"scrollbar-track"|"scrollbar-track-piece"|"scrollbar-corner"|"resizer"|"input-list-button"|"view-transition"|"view-transition-group"|"view-transition-image-pair"|"view-transition-old"|"view-transition-new"; + export type PseudoType = "first-line"|"first-letter"|"before"|"after"|"marker"|"backdrop"|"selection"|"search-text"|"target-text"|"spelling-error"|"grammar-error"|"highlight"|"first-line-inherited"|"scroll-marker"|"scroll-markers"|"scrollbar"|"scrollbar-thumb"|"scrollbar-button"|"scrollbar-track"|"scrollbar-track-piece"|"scrollbar-corner"|"resizer"|"input-list-button"|"view-transition"|"view-transition-group"|"view-transition-image-pair"|"view-transition-old"|"view-transition-new"; /** * Shadow root type. */ @@ -4426,6 +4459,25 @@ appear on top of all other content. */ nodeIds: NodeId[]; } + /** + * Returns the NodeId of the matched element according to certain relations. + */ + export type getElementByRelationParameters = { + /** + * Id of the node from which to query the relation. + */ + nodeId: NodeId; + /** + * Type of relation to get. + */ + relation: "PopoverTarget"; + } + export type getElementByRelationReturnValue = { + /** + * NodeId of the element matching the queried relation. + */ + nodeId: NodeId; + } /** * Re-does the last undone action. */ @@ -8309,8 +8361,16 @@ records. */ export type ServiceWorkerRouterSource = "network"|"cache"|"fetch-event"|"race-network-and-fetch-handler"; export interface ServiceWorkerRouterInfo { - ruleIdMatched: number; - matchedSourceType: ServiceWorkerRouterSource; + /** + * ID of the rule matched. If there is a matched rule, this field will +be set, otherwiser no value will be set. + */ + ruleIdMatched?: number; + /** + * The router source of the matched rule. If there is a matched rule, this +field will be set, otherwise no value will be set. + */ + matchedSourceType?: ServiceWorkerRouterSource; } /** * HTTP response data. @@ -8385,7 +8445,10 @@ records. */ fromEarlyHints?: boolean; /** - * Information about how Service Worker Static Router was used. + * Information about how ServiceWorker Static Router API was used. If this +field is set with `matchedSourceType` field, a matching rule is found. +If this field is set without `matchedSource`, no matching rule is found. +Otherwise, the API is not used. */ serviceWorkerRouterInfo?: ServiceWorkerRouterInfo; /** @@ -11167,7 +11230,7 @@ as an ad. * All Permissions Policy features. This enum should match the one defined in third_party/blink/renderer/core/permissions_policy/permissions_policy_features.json5. */ - export type PermissionsPolicyFeature = "accelerometer"|"ambient-light-sensor"|"attribution-reporting"|"autoplay"|"bluetooth"|"browsing-topics"|"camera"|"captured-surface-control"|"ch-dpr"|"ch-device-memory"|"ch-downlink"|"ch-ect"|"ch-prefers-color-scheme"|"ch-prefers-reduced-motion"|"ch-prefers-reduced-transparency"|"ch-rtt"|"ch-save-data"|"ch-ua"|"ch-ua-arch"|"ch-ua-bitness"|"ch-ua-platform"|"ch-ua-model"|"ch-ua-mobile"|"ch-ua-form-factors"|"ch-ua-full-version"|"ch-ua-full-version-list"|"ch-ua-platform-version"|"ch-ua-wow64"|"ch-viewport-height"|"ch-viewport-width"|"ch-width"|"clipboard-read"|"clipboard-write"|"compute-pressure"|"cross-origin-isolated"|"direct-sockets"|"display-capture"|"document-domain"|"encrypted-media"|"execution-while-out-of-viewport"|"execution-while-not-rendered"|"focus-without-user-activation"|"fullscreen"|"frobulate"|"gamepad"|"geolocation"|"gyroscope"|"hid"|"identity-credentials-get"|"idle-detection"|"interest-cohort"|"join-ad-interest-group"|"keyboard-map"|"local-fonts"|"magnetometer"|"microphone"|"midi"|"otp-credentials"|"payment"|"picture-in-picture"|"private-aggregation"|"private-state-token-issuance"|"private-state-token-redemption"|"publickey-credentials-create"|"publickey-credentials-get"|"run-ad-auction"|"screen-wake-lock"|"serial"|"shared-autofill"|"shared-storage"|"shared-storage-select-url"|"smart-card"|"speaker-selection"|"storage-access"|"sub-apps"|"sync-xhr"|"unload"|"usb"|"usb-unrestricted"|"vertical-scroll"|"web-printing"|"web-share"|"window-management"|"window-placement"|"xr-spatial-tracking"; + export type PermissionsPolicyFeature = "accelerometer"|"ambient-light-sensor"|"attribution-reporting"|"autoplay"|"bluetooth"|"browsing-topics"|"camera"|"captured-surface-control"|"ch-dpr"|"ch-device-memory"|"ch-downlink"|"ch-ect"|"ch-prefers-color-scheme"|"ch-prefers-reduced-motion"|"ch-prefers-reduced-transparency"|"ch-rtt"|"ch-save-data"|"ch-ua"|"ch-ua-arch"|"ch-ua-bitness"|"ch-ua-platform"|"ch-ua-model"|"ch-ua-mobile"|"ch-ua-form-factors"|"ch-ua-full-version"|"ch-ua-full-version-list"|"ch-ua-platform-version"|"ch-ua-wow64"|"ch-viewport-height"|"ch-viewport-width"|"ch-width"|"clipboard-read"|"clipboard-write"|"compute-pressure"|"cross-origin-isolated"|"direct-sockets"|"display-capture"|"document-domain"|"encrypted-media"|"execution-while-out-of-viewport"|"execution-while-not-rendered"|"focus-without-user-activation"|"fullscreen"|"frobulate"|"gamepad"|"geolocation"|"gyroscope"|"hid"|"identity-credentials-get"|"idle-detection"|"interest-cohort"|"join-ad-interest-group"|"keyboard-map"|"local-fonts"|"magnetometer"|"microphone"|"midi"|"otp-credentials"|"payment"|"picture-in-picture"|"private-aggregation"|"private-state-token-issuance"|"private-state-token-redemption"|"publickey-credentials-create"|"publickey-credentials-get"|"run-ad-auction"|"screen-wake-lock"|"serial"|"shared-autofill"|"shared-storage"|"shared-storage-select-url"|"smart-card"|"speaker-selection"|"storage-access"|"sub-apps"|"sync-xhr"|"unload"|"usb"|"usb-unrestricted"|"vertical-scroll"|"web-printing"|"web-share"|"window-management"|"xr-spatial-tracking"; /** * Reason for a permissions policy feature to be disabled. */ @@ -11755,7 +11818,7 @@ https://github.com/WICG/manifest-incubations/blob/gh-pages/scope_extensions-expl /** * List of not restored reasons for back-forward cache. */ - export type BackForwardCacheNotRestoredReason = "NotPrimaryMainFrame"|"BackForwardCacheDisabled"|"RelatedActiveContentsExist"|"HTTPStatusNotOK"|"SchemeNotHTTPOrHTTPS"|"Loading"|"WasGrantedMediaAccess"|"DisableForRenderFrameHostCalled"|"DomainNotAllowed"|"HTTPMethodNotGET"|"SubframeIsNavigating"|"Timeout"|"CacheLimit"|"JavaScriptExecution"|"RendererProcessKilled"|"RendererProcessCrashed"|"SchedulerTrackedFeatureUsed"|"ConflictingBrowsingInstance"|"CacheFlushed"|"ServiceWorkerVersionActivation"|"SessionRestored"|"ServiceWorkerPostMessage"|"EnteredBackForwardCacheBeforeServiceWorkerHostAdded"|"RenderFrameHostReused_SameSite"|"RenderFrameHostReused_CrossSite"|"ServiceWorkerClaim"|"IgnoreEventAndEvict"|"HaveInnerContents"|"TimeoutPuttingInCache"|"BackForwardCacheDisabledByLowMemory"|"BackForwardCacheDisabledByCommandLine"|"NetworkRequestDatapipeDrainedAsBytesConsumer"|"NetworkRequestRedirected"|"NetworkRequestTimeout"|"NetworkExceedsBufferLimit"|"NavigationCancelledWhileRestoring"|"NotMostRecentNavigationEntry"|"BackForwardCacheDisabledForPrerender"|"UserAgentOverrideDiffers"|"ForegroundCacheLimit"|"BrowsingInstanceNotSwapped"|"BackForwardCacheDisabledForDelegate"|"UnloadHandlerExistsInMainFrame"|"UnloadHandlerExistsInSubFrame"|"ServiceWorkerUnregistration"|"CacheControlNoStore"|"CacheControlNoStoreCookieModified"|"CacheControlNoStoreHTTPOnlyCookieModified"|"NoResponseHead"|"Unknown"|"ActivationNavigationsDisallowedForBug1234857"|"ErrorDocument"|"FencedFramesEmbedder"|"CookieDisabled"|"HTTPAuthRequired"|"CookieFlushed"|"BroadcastChannelOnMessage"|"WebSocket"|"WebTransport"|"WebRTC"|"MainResourceHasCacheControlNoStore"|"MainResourceHasCacheControlNoCache"|"SubresourceHasCacheControlNoStore"|"SubresourceHasCacheControlNoCache"|"ContainsPlugins"|"DocumentLoaded"|"OutstandingNetworkRequestOthers"|"RequestedMIDIPermission"|"RequestedAudioCapturePermission"|"RequestedVideoCapturePermission"|"RequestedBackForwardCacheBlockedSensors"|"RequestedBackgroundWorkPermission"|"BroadcastChannel"|"WebXR"|"SharedWorker"|"WebLocks"|"WebHID"|"WebShare"|"RequestedStorageAccessGrant"|"WebNfc"|"OutstandingNetworkRequestFetch"|"OutstandingNetworkRequestXHR"|"AppBanner"|"Printing"|"WebDatabase"|"PictureInPicture"|"Portal"|"SpeechRecognizer"|"IdleManager"|"PaymentManager"|"SpeechSynthesis"|"KeyboardLock"|"WebOTPService"|"OutstandingNetworkRequestDirectSocket"|"InjectedJavascript"|"InjectedStyleSheet"|"KeepaliveRequest"|"IndexedDBEvent"|"Dummy"|"JsNetworkRequestReceivedCacheControlNoStoreResource"|"WebRTCSticky"|"WebTransportSticky"|"WebSocketSticky"|"SmartCard"|"LiveMediaStreamTrack"|"UnloadHandler"|"ParserAborted"|"ContentSecurityHandler"|"ContentWebAuthenticationAPI"|"ContentFileChooser"|"ContentSerial"|"ContentFileSystemAccess"|"ContentMediaDevicesDispatcherHost"|"ContentWebBluetooth"|"ContentWebUSB"|"ContentMediaSessionService"|"ContentScreenReader"|"EmbedderPopupBlockerTabHelper"|"EmbedderSafeBrowsingTriggeredPopupBlocker"|"EmbedderSafeBrowsingThreatDetails"|"EmbedderAppBannerManager"|"EmbedderDomDistillerViewerSource"|"EmbedderDomDistillerSelfDeletingRequestDelegate"|"EmbedderOomInterventionTabHelper"|"EmbedderOfflinePage"|"EmbedderChromePasswordManagerClientBindCredentialManager"|"EmbedderPermissionRequestManager"|"EmbedderModalDialog"|"EmbedderExtensions"|"EmbedderExtensionMessaging"|"EmbedderExtensionMessagingForOpenPort"|"EmbedderExtensionSentMessageToCachedFrame"; + export type BackForwardCacheNotRestoredReason = "NotPrimaryMainFrame"|"BackForwardCacheDisabled"|"RelatedActiveContentsExist"|"HTTPStatusNotOK"|"SchemeNotHTTPOrHTTPS"|"Loading"|"WasGrantedMediaAccess"|"DisableForRenderFrameHostCalled"|"DomainNotAllowed"|"HTTPMethodNotGET"|"SubframeIsNavigating"|"Timeout"|"CacheLimit"|"JavaScriptExecution"|"RendererProcessKilled"|"RendererProcessCrashed"|"SchedulerTrackedFeatureUsed"|"ConflictingBrowsingInstance"|"CacheFlushed"|"ServiceWorkerVersionActivation"|"SessionRestored"|"ServiceWorkerPostMessage"|"EnteredBackForwardCacheBeforeServiceWorkerHostAdded"|"RenderFrameHostReused_SameSite"|"RenderFrameHostReused_CrossSite"|"ServiceWorkerClaim"|"IgnoreEventAndEvict"|"HaveInnerContents"|"TimeoutPuttingInCache"|"BackForwardCacheDisabledByLowMemory"|"BackForwardCacheDisabledByCommandLine"|"NetworkRequestDatapipeDrainedAsBytesConsumer"|"NetworkRequestRedirected"|"NetworkRequestTimeout"|"NetworkExceedsBufferLimit"|"NavigationCancelledWhileRestoring"|"NotMostRecentNavigationEntry"|"BackForwardCacheDisabledForPrerender"|"UserAgentOverrideDiffers"|"ForegroundCacheLimit"|"BrowsingInstanceNotSwapped"|"BackForwardCacheDisabledForDelegate"|"UnloadHandlerExistsInMainFrame"|"UnloadHandlerExistsInSubFrame"|"ServiceWorkerUnregistration"|"CacheControlNoStore"|"CacheControlNoStoreCookieModified"|"CacheControlNoStoreHTTPOnlyCookieModified"|"NoResponseHead"|"Unknown"|"ActivationNavigationsDisallowedForBug1234857"|"ErrorDocument"|"FencedFramesEmbedder"|"CookieDisabled"|"HTTPAuthRequired"|"CookieFlushed"|"BroadcastChannelOnMessage"|"WebSocket"|"WebTransport"|"WebRTC"|"MainResourceHasCacheControlNoStore"|"MainResourceHasCacheControlNoCache"|"SubresourceHasCacheControlNoStore"|"SubresourceHasCacheControlNoCache"|"ContainsPlugins"|"DocumentLoaded"|"OutstandingNetworkRequestOthers"|"RequestedMIDIPermission"|"RequestedAudioCapturePermission"|"RequestedVideoCapturePermission"|"RequestedBackForwardCacheBlockedSensors"|"RequestedBackgroundWorkPermission"|"BroadcastChannel"|"WebXR"|"SharedWorker"|"WebLocks"|"WebHID"|"WebShare"|"RequestedStorageAccessGrant"|"WebNfc"|"OutstandingNetworkRequestFetch"|"OutstandingNetworkRequestXHR"|"AppBanner"|"Printing"|"WebDatabase"|"PictureInPicture"|"Portal"|"SpeechRecognizer"|"IdleManager"|"PaymentManager"|"SpeechSynthesis"|"KeyboardLock"|"WebOTPService"|"OutstandingNetworkRequestDirectSocket"|"InjectedJavascript"|"InjectedStyleSheet"|"KeepaliveRequest"|"IndexedDBEvent"|"Dummy"|"JsNetworkRequestReceivedCacheControlNoStoreResource"|"WebRTCSticky"|"WebTransportSticky"|"WebSocketSticky"|"SmartCard"|"LiveMediaStreamTrack"|"UnloadHandler"|"ParserAborted"|"ContentSecurityHandler"|"ContentWebAuthenticationAPI"|"ContentFileChooser"|"ContentSerial"|"ContentFileSystemAccess"|"ContentMediaDevicesDispatcherHost"|"ContentWebBluetooth"|"ContentWebUSB"|"ContentMediaSessionService"|"ContentScreenReader"|"EmbedderPopupBlockerTabHelper"|"EmbedderSafeBrowsingTriggeredPopupBlocker"|"EmbedderSafeBrowsingThreatDetails"|"EmbedderAppBannerManager"|"EmbedderDomDistillerViewerSource"|"EmbedderDomDistillerSelfDeletingRequestDelegate"|"EmbedderOomInterventionTabHelper"|"EmbedderOfflinePage"|"EmbedderChromePasswordManagerClientBindCredentialManager"|"EmbedderPermissionRequestManager"|"EmbedderModalDialog"|"EmbedderExtensions"|"EmbedderExtensionMessaging"|"EmbedderExtensionMessagingForOpenPort"|"EmbedderExtensionSentMessageToCachedFrame"|"RequestedByWebViewClient"; /** * Types of not restored reasons for back-forward cache. */ @@ -12334,7 +12397,7 @@ option, use with caution. This API always waits for the manifest to be loaded. If manifestId is provided, and it does not match the manifest of the current document, this API errors out. - If there isn’t a loaded page, this API errors out immediately. + If there is not a loaded page, this API errors out immediately. */ export type getAppManifestParameters = { manifestId?: string; @@ -16690,7 +16753,7 @@ possible for multiple rule sets and links to trigger a single attempt. /** * List of FinalStatus reasons for Prerender2. */ - export type PrerenderFinalStatus = "Activated"|"Destroyed"|"LowEndDevice"|"InvalidSchemeRedirect"|"InvalidSchemeNavigation"|"NavigationRequestBlockedByCsp"|"MainFrameNavigation"|"MojoBinderPolicy"|"RendererProcessCrashed"|"RendererProcessKilled"|"Download"|"TriggerDestroyed"|"NavigationNotCommitted"|"NavigationBadHttpStatus"|"ClientCertRequested"|"NavigationRequestNetworkError"|"CancelAllHostsForTesting"|"DidFailLoad"|"Stop"|"SslCertificateError"|"LoginAuthRequested"|"UaChangeRequiresReload"|"BlockedByClient"|"AudioOutputDeviceRequested"|"MixedContent"|"TriggerBackgrounded"|"MemoryLimitExceeded"|"DataSaverEnabled"|"TriggerUrlHasEffectiveUrl"|"ActivatedBeforeStarted"|"InactivePageRestriction"|"StartFailed"|"TimeoutBackgrounded"|"CrossSiteRedirectInInitialNavigation"|"CrossSiteNavigationInInitialNavigation"|"SameSiteCrossOriginRedirectNotOptInInInitialNavigation"|"SameSiteCrossOriginNavigationNotOptInInInitialNavigation"|"ActivationNavigationParameterMismatch"|"ActivatedInBackground"|"EmbedderHostDisallowed"|"ActivationNavigationDestroyedBeforeSuccess"|"TabClosedByUserGesture"|"TabClosedWithoutUserGesture"|"PrimaryMainFrameRendererProcessCrashed"|"PrimaryMainFrameRendererProcessKilled"|"ActivationFramePolicyNotCompatible"|"PreloadingDisabled"|"BatterySaverEnabled"|"ActivatedDuringMainFrameNavigation"|"PreloadingUnsupportedByWebContents"|"CrossSiteRedirectInMainFrameNavigation"|"CrossSiteNavigationInMainFrameNavigation"|"SameSiteCrossOriginRedirectNotOptInInMainFrameNavigation"|"SameSiteCrossOriginNavigationNotOptInInMainFrameNavigation"|"MemoryPressureOnTrigger"|"MemoryPressureAfterTriggered"|"PrerenderingDisabledByDevTools"|"SpeculationRuleRemoved"|"ActivatedWithAuxiliaryBrowsingContexts"|"MaxNumOfRunningEagerPrerendersExceeded"|"MaxNumOfRunningNonEagerPrerendersExceeded"|"MaxNumOfRunningEmbedderPrerendersExceeded"|"PrerenderingUrlHasEffectiveUrl"|"RedirectedPrerenderingUrlHasEffectiveUrl"|"ActivationUrlHasEffectiveUrl"; + export type PrerenderFinalStatus = "Activated"|"Destroyed"|"LowEndDevice"|"InvalidSchemeRedirect"|"InvalidSchemeNavigation"|"NavigationRequestBlockedByCsp"|"MainFrameNavigation"|"MojoBinderPolicy"|"RendererProcessCrashed"|"RendererProcessKilled"|"Download"|"TriggerDestroyed"|"NavigationNotCommitted"|"NavigationBadHttpStatus"|"ClientCertRequested"|"NavigationRequestNetworkError"|"CancelAllHostsForTesting"|"DidFailLoad"|"Stop"|"SslCertificateError"|"LoginAuthRequested"|"UaChangeRequiresReload"|"BlockedByClient"|"AudioOutputDeviceRequested"|"MixedContent"|"TriggerBackgrounded"|"MemoryLimitExceeded"|"DataSaverEnabled"|"TriggerUrlHasEffectiveUrl"|"ActivatedBeforeStarted"|"InactivePageRestriction"|"StartFailed"|"TimeoutBackgrounded"|"CrossSiteRedirectInInitialNavigation"|"CrossSiteNavigationInInitialNavigation"|"SameSiteCrossOriginRedirectNotOptInInInitialNavigation"|"SameSiteCrossOriginNavigationNotOptInInInitialNavigation"|"ActivationNavigationParameterMismatch"|"ActivatedInBackground"|"EmbedderHostDisallowed"|"ActivationNavigationDestroyedBeforeSuccess"|"TabClosedByUserGesture"|"TabClosedWithoutUserGesture"|"PrimaryMainFrameRendererProcessCrashed"|"PrimaryMainFrameRendererProcessKilled"|"ActivationFramePolicyNotCompatible"|"PreloadingDisabled"|"BatterySaverEnabled"|"ActivatedDuringMainFrameNavigation"|"PreloadingUnsupportedByWebContents"|"CrossSiteRedirectInMainFrameNavigation"|"CrossSiteNavigationInMainFrameNavigation"|"SameSiteCrossOriginRedirectNotOptInInMainFrameNavigation"|"SameSiteCrossOriginNavigationNotOptInInMainFrameNavigation"|"MemoryPressureOnTrigger"|"MemoryPressureAfterTriggered"|"PrerenderingDisabledByDevTools"|"SpeculationRuleRemoved"|"ActivatedWithAuxiliaryBrowsingContexts"|"MaxNumOfRunningEagerPrerendersExceeded"|"MaxNumOfRunningNonEagerPrerendersExceeded"|"MaxNumOfRunningEmbedderPrerendersExceeded"|"PrerenderingUrlHasEffectiveUrl"|"RedirectedPrerenderingUrlHasEffectiveUrl"|"ActivationUrlHasEffectiveUrl"|"JavaScriptInterfaceAdded"|"JavaScriptInterfaceRemoved"|"AllPrerenderingCanceled"; /** * Preloading status values, see also PreloadingTriggeringOutcome. This status is shared by prefetchStatusUpdated and prerenderStatusUpdated. @@ -16921,6 +16984,52 @@ https://web.dev/learn/pwa/web-app-manifest. badgeCount: number; fileHandlers: FileHandler[]; } + /** + * Installs the given manifest identity, optionally using the given install_url +or IWA bundle location. + +TODO(crbug.com/337872319) Support IWA to meet the following specific +requirement. +IWA-specific install description: If the manifest_id is isolated-app://, +install_url_or_bundle_url is required, and can be either an http(s) URL or +file:// URL pointing to a signed web bundle (.swbn). The .swbn file's +signing key must correspond to manifest_id. If Chrome is not in IWA dev +mode, the installation will fail, regardless of the state of the allowlist. + */ + export type installParameters = { + manifestId: string; + /** + * The location of the app or bundle overriding the one derived from the +manifestId. + */ + installUrlOrBundleUrl?: string; + } + export type installReturnValue = { + } + /** + * Uninstals the given manifest_id and closes any opened app windows. + */ + export type uninstallParameters = { + manifestId: string; + } + export type uninstallReturnValue = { + } + /** + * Launches the installed web app, or an url in the same web app instead of the +default start url if it is provided. Returns a tab / web contents based +Target.TargetID which can be used to attach to via Target.attachToTarget or +similar APIs. + */ + export type launchParameters = { + manifestId: string; + url?: string; + } + export type launchReturnValue = { + /** + * ID of the tab target created as a result. + */ + targetId: Target.TargetID; + } } /** @@ -19773,6 +19882,7 @@ Error was thrown. "Audits.enable": Audits.enableParameters; "Audits.checkContrast": Audits.checkContrastParameters; "Audits.checkFormsIssues": Audits.checkFormsIssuesParameters; + "Extensions.loadUnpacked": Extensions.loadUnpackedParameters; "Autofill.trigger": Autofill.triggerParameters; "Autofill.setAddresses": Autofill.setAddressesParameters; "Autofill.disable": Autofill.disableParameters; @@ -19870,6 +19980,7 @@ Error was thrown. "DOM.querySelector": DOM.querySelectorParameters; "DOM.querySelectorAll": DOM.querySelectorAllParameters; "DOM.getTopLayerElements": DOM.getTopLayerElementsParameters; + "DOM.getElementByRelation": DOM.getElementByRelationParameters; "DOM.redo": DOM.redoParameters; "DOM.removeAttribute": DOM.removeAttributeParameters; "DOM.removeNode": DOM.removeNodeParameters; @@ -20256,6 +20367,9 @@ Error was thrown. "FedCm.dismissDialog": FedCm.dismissDialogParameters; "FedCm.resetCooldown": FedCm.resetCooldownParameters; "PWA.getOsAppState": PWA.getOsAppStateParameters; + "PWA.install": PWA.installParameters; + "PWA.uninstall": PWA.uninstallParameters; + "PWA.launch": PWA.launchParameters; "Console.clearMessages": Console.clearMessagesParameters; "Console.disable": Console.disableParameters; "Console.enable": Console.enableParameters; @@ -20361,6 +20475,7 @@ Error was thrown. "Audits.enable": Audits.enableReturnValue; "Audits.checkContrast": Audits.checkContrastReturnValue; "Audits.checkFormsIssues": Audits.checkFormsIssuesReturnValue; + "Extensions.loadUnpacked": Extensions.loadUnpackedReturnValue; "Autofill.trigger": Autofill.triggerReturnValue; "Autofill.setAddresses": Autofill.setAddressesReturnValue; "Autofill.disable": Autofill.disableReturnValue; @@ -20458,6 +20573,7 @@ Error was thrown. "DOM.querySelector": DOM.querySelectorReturnValue; "DOM.querySelectorAll": DOM.querySelectorAllReturnValue; "DOM.getTopLayerElements": DOM.getTopLayerElementsReturnValue; + "DOM.getElementByRelation": DOM.getElementByRelationReturnValue; "DOM.redo": DOM.redoReturnValue; "DOM.removeAttribute": DOM.removeAttributeReturnValue; "DOM.removeNode": DOM.removeNodeReturnValue; @@ -20844,6 +20960,9 @@ Error was thrown. "FedCm.dismissDialog": FedCm.dismissDialogReturnValue; "FedCm.resetCooldown": FedCm.resetCooldownReturnValue; "PWA.getOsAppState": PWA.getOsAppStateReturnValue; + "PWA.install": PWA.installReturnValue; + "PWA.uninstall": PWA.uninstallReturnValue; + "PWA.launch": PWA.launchReturnValue; "Console.clearMessages": Console.clearMessagesReturnValue; "Console.disable": Console.disableReturnValue; "Console.enable": Console.enableReturnValue; diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 99fc9fca1e..70a25c3878 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -15846,8 +15846,14 @@ export interface APIRequestContext { * and similar methods are stored in the memory, so that you can later call * [apiResponse.body()](https://playwright.dev/docs/api/class-apiresponse#api-response-body).This method discards all * its resources, calling any method on disposed {@link APIRequestContext} will throw an exception. + * @param options */ - dispose(): Promise; + dispose(options?: { + /** + * The reason to be reported to the operations interrupted by the context disposure. + */ + reason?: string; + }): Promise; /** * Sends HTTP(S) request and returns its response. The method will populate request cookies from the context and diff --git a/packages/playwright-ct-react17/registerSource.mjs b/packages/playwright-ct-react17/registerSource.mjs index 1c484b2b12..50a888b92a 100644 --- a/packages/playwright-ct-react17/registerSource.mjs +++ b/packages/playwright-ct-react17/registerSource.mjs @@ -86,6 +86,8 @@ window.playwrightMount = async (component, rootElement, hooksConfig) => { window.playwrightUnmount = async rootElement => { if (!__pwReactDOM.unmountComponentAtNode(rootElement)) throw new Error('Component was not mounted'); + + __pwRootRegistry.delete(rootElement); }; window.playwrightUpdate = async (rootElement, component) => { diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index 7bc56cf4f6..3af70211bc 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -35,7 +35,7 @@ export type FixturesWithLocation = { fixtures: Fixtures; location: Location; }; -export type Annotation = { type: string, description?: string }; +export type Annotation = { type: string, description?: string, url?: string }; export const defaultTimeout = 30000; @@ -52,6 +52,7 @@ export class FullConfigInternal { cliProjectFilter?: string[]; cliListOnly = false; cliPassWithNoTests?: boolean; + cliFailOnFlakyTests?: boolean; testIdMatcher?: Matcher; defineConfigWasUsed = false; @@ -265,7 +266,7 @@ export function toReporters(reporters: BuiltInReporter | ReporterDescription[] | export const builtInReporters = ['list', 'line', 'dot', 'json', 'junit', 'null', 'github', 'html', 'blob', 'markdown'] as const; export type BuiltInReporter = typeof builtInReporters[number]; -export type ContextReuseMode = 'none' | 'force' | 'when-possible'; +export type ContextReuseMode = 'none' | 'when-possible'; function resolveScript(id: string | undefined, rootDir: string): string | undefined { if (!id) diff --git a/packages/playwright/src/common/process.ts b/packages/playwright/src/common/process.ts index 4e1e2672ff..14ad995fed 100644 --- a/packages/playwright/src/common/process.ts +++ b/packages/playwright/src/common/process.ts @@ -44,11 +44,12 @@ export class ProcessRunner { } } -let closed = false; +let gracefullyCloseCalled = false; +let forceExitInitiated = false; sendMessageToParent({ method: 'ready' }); -process.on('disconnect', gracefullyCloseAndExit); +process.on('disconnect', () => gracefullyCloseAndExit(true)); process.on('SIGINT', () => {}); process.on('SIGTERM', () => {}); @@ -76,7 +77,7 @@ process.on('message', async (message: any) => { const keys = new Set([...Object.keys(process.env), ...Object.keys(startingEnv)]); const producedEnv: EnvProducedPayload = [...keys].filter(key => startingEnv[key] !== process.env[key]).map(key => [key, process.env[key] ?? null]); sendMessageToParent({ method: '__env_produced__', params: producedEnv }); - await gracefullyCloseAndExit(); + await gracefullyCloseAndExit(false); return; } if (message.method === '__dispatch__') { @@ -92,19 +93,24 @@ process.on('message', async (message: any) => { } }); -async function gracefullyCloseAndExit() { - if (closed) - return; - closed = true; - // Force exit after 30 seconds. - // eslint-disable-next-line no-restricted-properties - setTimeout(() => process.exit(0), 30000); - // Meanwhile, try to gracefully shutdown. - await processRunner?.gracefullyClose().catch(() => {}); - if (processName) - await stopProfiling(processName).catch(() => {}); - // eslint-disable-next-line no-restricted-properties - process.exit(0); +const kForceExitTimeout = +(process.env.PWTEST_FORCE_EXIT_TIMEOUT || 30000); + +async function gracefullyCloseAndExit(forceExit: boolean) { + if (forceExit && !forceExitInitiated) { + forceExitInitiated = true; + // Force exit after 30 seconds. + // eslint-disable-next-line no-restricted-properties + setTimeout(() => process.exit(0), kForceExitTimeout); + } + if (!gracefullyCloseCalled) { + gracefullyCloseCalled = true; + // Meanwhile, try to gracefully shutdown. + await processRunner?.gracefullyClose().catch(() => {}); + if (processName) + await stopProfiling(processName).catch(() => {}); + // eslint-disable-next-line no-restricted-properties + process.exit(0); + } } function sendMessageToParent(message: { method: string, params?: any }) { diff --git a/packages/playwright/src/common/testType.ts b/packages/playwright/src/common/testType.ts index 35e2a4668c..5c7850a3df 100644 --- a/packages/playwright/src/common/testType.ts +++ b/packages/playwright/src/common/testType.ts @@ -263,7 +263,7 @@ export class TestTypeImpl { const testInfo = currentTestInfo(); if (!testInfo) throw new Error(`test.step() can only be called from a test`); - const step = testInfo._addStep({ wallTime: Date.now(), category: 'test.step', title, box: options.box }); + const step = testInfo._addStep({ category: 'test.step', title, box: options.box }); return await zones.run('stepZone', step, async () => { try { const result = await body(); diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 2c105a2640..0a6651f362 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -247,7 +247,7 @@ const playwrightFixtures: Fixtures = ({ const artifactsRecorder = new ArtifactsRecorder(playwright, tracing().artifactsDir(), screenshot); await artifactsRecorder.willStartTest(testInfo as TestInfoImpl); const csiListener: ClientInstrumentationListener = { - onApiCallBegin: (apiName: string, params: Record, frames: StackFrame[], wallTime: number, userData: any) => { + onApiCallBegin: (apiName: string, params: Record, frames: StackFrame[], userData: any, out: { stepId?: string }) => { const testInfo = currentTestInfo(); if (!testInfo || apiName.includes('setTestIdAttribute')) return { userObject: null }; @@ -257,9 +257,9 @@ const playwrightFixtures: Fixtures = ({ title: renderApiCall(apiName, params), apiName, params, - wallTime, }); userData.userObject = step; + out.stepId = step.stepId; }, onApiCallEnd: (userData: any, error?: Error) => { const step = userData.userObject; @@ -356,8 +356,8 @@ const playwrightFixtures: Fixtures = ({ _reuseContext: [async ({ video, _optionContextReuseMode }, use) => { let mode = _optionContextReuseMode; if (process.env.PW_TEST_REUSE_CONTEXT) - mode = process.env.PW_TEST_REUSE_CONTEXT === 'when-possible' ? 'when-possible' : (process.env.PW_TEST_REUSE_CONTEXT ? 'force' : 'none'); - const reuse = mode === 'force' || (mode === 'when-possible' && normalizeVideoMode(video) === 'off'); + mode = 'when-possible'; + const reuse = mode === 'when-possible' && normalizeVideoMode(video) === 'off'; await use(reuse); }, { scope: 'worker', _title: 'context' } as any], @@ -392,7 +392,17 @@ const playwrightFixtures: Fixtures = ({ request: async ({ playwright }, use) => { const request = await playwright.request.newContext(); await use(request); - await request.dispose(); + const hook = (test.info() as TestInfoImpl)._currentHookType(); + if (hook === 'beforeAll') { + await request.dispose({ reason: [ + `Fixture { request } from beforeAll cannot be reused in a test.`, + ` - Recommended fix: use a separate { request } in the test.`, + ` - Alternatively, manually create APIRequestContext in beforeAll and dispose it in afterAll.`, + `See https://playwright.dev/docs/api-testing#sending-api-requests-from-ui-tests for more details.`, + ].join('\n') }); + } else { + await request.dispose(); + } }, }); diff --git a/packages/playwright/src/isomorphic/testServerInterface.ts b/packages/playwright/src/isomorphic/testServerInterface.ts index 56cac53f5b..4abf3f02fe 100644 --- a/packages/playwright/src/isomorphic/testServerInterface.ts +++ b/packages/playwright/src/isomorphic/testServerInterface.ts @@ -94,6 +94,7 @@ export interface TestServerInterface { timeout?: number, reporters?: string[], trace?: 'on' | 'off'; + video?: 'on' | 'off'; projects?: string[]; reuseContext?: boolean; connectWsEndpoint?: string; diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index 5d51a20b5e..0d5d226039 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -259,7 +259,6 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { const defaultTitle = `expect${this._info.isPoll ? '.poll' : ''}${this._info.isSoft ? '.soft' : ''}${this._info.isNot ? '.not' : ''}.${matcherName}${argsSuffix}`; const title = customMessage || defaultTitle; - const wallTime = Date.now(); // This looks like it is unnecessary, but it isn't - we need to filter // out all the frames that belong to the test runner from caught runtime errors. @@ -270,7 +269,6 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { category: 'expect', title: trimLongString(title, 1024), params: args[0] ? { expected: args[0] } : undefined, - wallTime, infectParentStepsWithError: this._info.isSoft, }; @@ -295,7 +293,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { // so they behave like a retriable step. const result = (matcherName === 'toPass' || this._info.isPoll) ? zones.run('stepZone', step, callback) : - zones.run('expectZone', { title, wallTime }, callback); + zones.run('expectZone', { title, stepId: step.stepId }, callback); if (result instanceof Promise) return result.then(finalizer).catch(reportStepError); finalizer(); diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index feca8d644b..b69d3d6b10 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -194,6 +194,7 @@ async function runTests(args: string[], opts: { [key: string]: any }) { config.cliListOnly = !!opts.list; config.cliProjectFilter = opts.project || undefined; config.cliPassWithNoTests = !!opts.passWithNoTests; + config.cliFailOnFlakyTests = !!opts.failOnFlakyTests; const runner = new Runner(config); let status: FullResult['status']; @@ -336,6 +337,7 @@ const testOptions: [string, string][] = [ ['--browser ', `Browser to use for tests, one of "all", "chromium", "firefox" or "webkit" (default: "chromium")`], ['-c, --config ', `Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}"`], ['--debug', `Run tests with Playwright Inspector. Shortcut for "PWDEBUG=1" environment variable and "--timeout=0 --max-failures=1 --headed --workers=1" options`], + ['--fail-on-flaky-tests', `Fail if any test is flagged as flaky (default: false)`], ['--forbid-only', `Fail if test.only is called (default: false)`], ['--fully-parallel', `Run all tests in parallel (default: false)`], ['--global-timeout ', `Maximum time this test suite can run in milliseconds (default: unlimited)`], diff --git a/packages/playwright/src/reporters/base.ts b/packages/playwright/src/reporters/base.ts index 0c023e8761..f418baeea4 100644 --- a/packages/playwright/src/reporters/base.ts +++ b/packages/playwright/src/reporters/base.ts @@ -45,27 +45,41 @@ type TestSummary = { fatalErrors: TestError[]; }; -export const isTTY = !!process.env.PWTEST_TTY_WIDTH || process.stdout.isTTY; -export const ttyWidth = process.env.PWTEST_TTY_WIDTH ? parseInt(process.env.PWTEST_TTY_WIDTH, 10) : process.stdout.columns || 0; -let useColors = isTTY; -if (process.env.DEBUG_COLORS === '0' - || process.env.DEBUG_COLORS === 'false' - || process.env.FORCE_COLOR === '0' - || process.env.FORCE_COLOR === 'false') - useColors = false; -else if (process.env.DEBUG_COLORS || process.env.FORCE_COLOR) - useColors = true; +export const { isTTY, ttyWidth, colors } = (() => { + let isTTY = !!process.stdout.isTTY; + let ttyWidth = process.stdout.columns || 0; + if (process.env.PLAYWRIGHT_FORCE_TTY === 'false' || process.env.PLAYWRIGHT_FORCE_TTY === '0') { + isTTY = false; + ttyWidth = 0; + } else if (process.env.PLAYWRIGHT_FORCE_TTY === 'true' || process.env.PLAYWRIGHT_FORCE_TTY === '1') { + isTTY = true; + ttyWidth = process.stdout.columns || 100; + } else if (process.env.PLAYWRIGHT_FORCE_TTY) { + isTTY = true; + ttyWidth = +process.env.PLAYWRIGHT_FORCE_TTY; + if (isNaN(ttyWidth)) + ttyWidth = 100; + } -export const colors = useColors ? realColors : { - bold: (t: string) => t, - cyan: (t: string) => t, - dim: (t: string) => t, - gray: (t: string) => t, - green: (t: string) => t, - red: (t: string) => t, - yellow: (t: string) => t, - enabled: false, -}; + let useColors = isTTY; + if (process.env.DEBUG_COLORS === '0' || process.env.DEBUG_COLORS === 'false' || + process.env.FORCE_COLOR === '0' || process.env.FORCE_COLOR === 'false') + useColors = false; + else if (process.env.DEBUG_COLORS || process.env.FORCE_COLOR) + useColors = true; + + const colors = useColors ? realColors : { + bold: (t: string) => t, + cyan: (t: string) => t, + dim: (t: string) => t, + gray: (t: string) => t, + green: (t: string) => t, + red: (t: string) => t, + yellow: (t: string) => t, + enabled: false, + }; + return { isTTY, ttyWidth, colors }; +})(); export class BaseReporter implements ReporterV2 { config!: FullConfig; diff --git a/packages/playwright/src/reporters/html.ts b/packages/playwright/src/reporters/html.ts index c5562e0f79..2a5bbc94d4 100644 --- a/packages/playwright/src/reporters/html.ts +++ b/packages/playwright/src/reporters/html.ts @@ -62,14 +62,14 @@ class HtmlReporter extends EmptyReporter { private _outputFolder!: string; private _attachmentsBaseURL!: string; private _open: string | undefined; + private _port: number | undefined; + private _host: string | undefined; private _buildResult: { ok: boolean, singleTestId: string | undefined } | undefined; private _topLevelErrors: TestError[] = []; constructor(options: HtmlReporterOptions) { super(); this._options = options; - if (options._mode === 'test') - process.env.PW_HTML_REPORT = '1'; } override printsToStdio() { @@ -81,9 +81,11 @@ class HtmlReporter extends EmptyReporter { } override onBegin(suite: Suite) { - const { outputFolder, open, attachmentsBaseURL } = this._resolveOptions(); + const { outputFolder, open, attachmentsBaseURL, host, port } = this._resolveOptions(); this._outputFolder = outputFolder; this._open = open; + this._host = host; + this._port = port; this._attachmentsBaseURL = attachmentsBaseURL; const reportedWarnings = new Set(); for (const project of this.config.projects) { @@ -104,12 +106,14 @@ class HtmlReporter extends EmptyReporter { this.suite = suite; } - _resolveOptions(): { outputFolder: string, open: HtmlReportOpenOption, attachmentsBaseURL: string } { + _resolveOptions(): { outputFolder: string, open: HtmlReportOpenOption, attachmentsBaseURL: string, host: string | undefined, port: number | undefined } { const outputFolder = reportFolderFromEnv() ?? resolveReporterOutputPath('playwright-report', this._options.configDir, this._options.outputFolder); return { outputFolder, open: getHtmlReportOptionProcessEnv() || this._options.open || 'on-failure', - attachmentsBaseURL: this._options.attachmentsBaseURL || 'data/' + attachmentsBaseURL: process.env.PLAYWRIGHT_HTML_ATTACHMENTS_BASE_URL || this._options.attachmentsBaseURL || 'data/', + host: process.env.PLAYWRIGHT_HTML_HOST || this._options.host, + port: process.env.PLAYWRIGHT_HTML_PORT ? +process.env.PLAYWRIGHT_HTML_PORT : this._options.port, }; } @@ -135,12 +139,12 @@ class HtmlReporter extends EmptyReporter { const { ok, singleTestId } = this._buildResult; const shouldOpen = !this._options._isTestServer && (this._open === 'always' || (!ok && this._open === 'on-failure')); if (shouldOpen) { - await showHTMLReport(this._outputFolder, this._options.host, this._options.port, singleTestId); + await showHTMLReport(this._outputFolder, this._host, this._port, singleTestId); } else if (this._options._mode === 'test') { const packageManagerCommand = getPackageManagerExecCommand(); const relativeReportPath = this._outputFolder === standaloneDefaultFolder() ? '' : ' ' + path.relative(process.cwd(), this._outputFolder); - const hostArg = this._options.host ? ` --host ${this._options.host}` : ''; - const portArg = this._options.port ? ` --port ${this._options.port}` : ''; + const hostArg = this._host ? ` --host ${this._host}` : ''; + const portArg = this._port ? ` --port ${this._port}` : ''; console.log(''); console.log('To open last HTML report run:'); console.log(colors.cyan(` @@ -151,18 +155,18 @@ class HtmlReporter extends EmptyReporter { } function reportFolderFromEnv(): string | undefined { - if (process.env[`PLAYWRIGHT_HTML_REPORT`]) - return path.resolve(process.cwd(), process.env[`PLAYWRIGHT_HTML_REPORT`]); - return undefined; + // Note: PLAYWRIGHT_HTML_REPORT is for backwards compatibility. + const envValue = process.env.PLAYWRIGHT_HTML_OUTPUT_DIR || process.env.PLAYWRIGHT_HTML_REPORT; + return envValue ? path.resolve(envValue) : undefined; } function getHtmlReportOptionProcessEnv(): HtmlReportOpenOption | undefined { - const processKey = 'PW_TEST_HTML_REPORT_OPEN'; - const htmlOpenEnv = process.env[processKey]; + // Note: PW_TEST_HTML_REPORT_OPEN is for backwards compatibility. + const htmlOpenEnv = process.env.PLAYWRIGHT_HTML_OPEN || process.env.PW_TEST_HTML_REPORT_OPEN; if (!htmlOpenEnv) return undefined; if (!isHtmlReportOption(htmlOpenEnv)) { - console.log(colors.red(`Configuration Error: HTML reporter Invalid value for ${processKey}: ${htmlOpenEnv}. Valid values are: ${htmlReportOptions.join(', ')}`)); + console.log(colors.red(`Configuration Error: HTML reporter Invalid value for PLAYWRIGHT_HTML_OPEN: ${htmlOpenEnv}. Valid values are: ${htmlReportOptions.join(', ')}`)); return undefined; } return htmlOpenEnv; @@ -182,7 +186,8 @@ export async function showHTMLReport(reportFolder: string | undefined, host: str return; } const server = startHtmlReportServer(folder); - let url = await server.start({ port, host, preferredPort: port ? undefined : 9323 }); + await server.start({ port, host, preferredPort: port ? undefined : 9323 }); + let url = server.urlPrefix('human-readable'); console.log(''); console.log(colors.cyan(` Serving HTML report at ${url}. Press Ctrl+C to quit.`)); if (testId) diff --git a/packages/playwright/src/reporters/junit.ts b/packages/playwright/src/reporters/junit.ts index 1f0cbf6362..61cf7e8e99 100644 --- a/packages/playwright/src/reporters/junit.ts +++ b/packages/playwright/src/reporters/junit.ts @@ -19,6 +19,7 @@ import path from 'path'; import type { FullConfig, FullResult, Suite, TestCase } from '../../types/testReporter'; import { formatFailure, resolveOutputFile, stripAnsiEscapes } from './base'; import EmptyReporter from './empty'; +import { getAsBooleanFromENV } from 'playwright-core/lib/utils'; type JUnitOptions = { outputFile?: string, @@ -42,8 +43,8 @@ class JUnitReporter extends EmptyReporter { constructor(options: JUnitOptions) { super(); - this.stripANSIControlSequences = options.stripANSIControlSequences || false; - this.includeProjectInTestName = options.includeProjectInTestName || false; + this.stripANSIControlSequences = getAsBooleanFromENV('PLAYWRIGHT_JUNIT_STRIP_ANSI', !!options.stripANSIControlSequences); + this.includeProjectInTestName = getAsBooleanFromENV('PLAYWRIGHT_JUNIT_INCLUDE_PROJECT_IN_TEST_NAME', !!options.includeProjectInTestName); this.configDir = options.configDir; this.resolvedOutputFile = resolveOutputFile('JUNIT', options)?.outputFile; } diff --git a/packages/playwright/src/reporters/list.ts b/packages/playwright/src/reporters/list.ts index 11f6a8b755..94e507fd4b 100644 --- a/packages/playwright/src/reporters/list.ts +++ b/packages/playwright/src/reporters/list.ts @@ -17,6 +17,7 @@ import { ms as milliseconds } from 'playwright-core/lib/utilsBundle'; import { colors, BaseReporter, formatError, formatTestTitle, isTTY, stepSuffix, stripAnsiEscapes, ttyWidth } from './base'; import type { FullResult, Suite, TestCase, TestError, TestResult, TestStep } from '../../types/testReporter'; +import { getAsBooleanFromENV } from 'playwright-core/lib/utils'; // Allow it in the Visual Studio Code Terminal and the new Windows Terminal const DOES_NOT_SUPPORT_UTF8_IN_TERMINAL = process.platform === 'win32' && process.env.TERM_PROGRAM !== 'vscode' && !process.env.WT_SESSION; @@ -33,9 +34,9 @@ class ListReporter extends BaseReporter { private _needNewLine = false; private _printSteps: boolean; - constructor(options: { omitFailures?: boolean, printSteps?: boolean } = {}) { - super(options); - this._printSteps = isTTY && (options.printSteps || !!process.env.PW_TEST_DEBUG_REPORTERS_PRINT_STEPS); + constructor(options: { printSteps?: boolean } = {}) { + super(); + this._printSteps = isTTY && getAsBooleanFromENV('PLAYWRIGHT_LIST_PRINT_STEPS', options.printSteps); } override printsToStdio() { @@ -229,8 +230,6 @@ class ListReporter extends BaseReporter { // Go down if needed. if (row !== this._lastRow) process.stdout.write(`\u001B[${this._lastRow - row}E`); - if (process.env.PWTEST_TTY_WIDTH) - process.stdout.write('\n'); // For testing. } private _testPrefix(index: string, statusMark: string) { diff --git a/packages/playwright/src/runner/failureTracker.ts b/packages/playwright/src/runner/failureTracker.ts index 702ea668c2..6ea8f81a34 100644 --- a/packages/playwright/src/runner/failureTracker.ts +++ b/packages/playwright/src/runner/failureTracker.ts @@ -48,7 +48,15 @@ export class FailureTracker { } result(): 'failed' | 'passed' { - return this._hasWorkerErrors || this.hasReachedMaxFailures() || this._rootSuite?.allTests().some(test => !test.ok()) ? 'failed' : 'passed'; + return this._hasWorkerErrors || this.hasReachedMaxFailures() || this.hasFailedTests() || (this._config.cliFailOnFlakyTests && this.hasFlakyTests()) ? 'failed' : 'passed'; + } + + hasFailedTests() { + return this._rootSuite?.allTests().some(test => !test.ok()); + } + + hasFlakyTests() { + return this._rootSuite?.allTests().some(test => (test.outcome() === 'flaky')); } maxFailures() { diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index 0809bd43d7..d2806b03f6 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -308,6 +308,7 @@ class TestServerDispatcher implements TestServerInterface { reporter: params.reporters ? params.reporters.map(r => [r]) : undefined, use: { trace: params.trace === 'on' ? { mode: 'on', sources: false, _live: true } : (params.trace === 'off' ? 'off' : undefined), + video: params.video === 'on' ? 'on' : (params.video === 'off' ? 'off' : undefined), headless: params.headed ? false : undefined, _optionContextReuseMode: params.reuseContext ? 'when-possible' : undefined, _optionConnectOptions: params.connectWsEndpoint ? { wsEndpoint: params.connectWsEndpoint } : undefined, @@ -417,9 +418,9 @@ export async function runUIMode(configFile: string | undefined, options: TraceVi return await innerRunTestServer(configLocation, options, async (server: HttpServer, cancelPromise: ManualPromise) => { await installRootRedirect(server, [], { ...options, webApp: 'uiMode.html' }); if (options.host !== undefined || options.port !== undefined) { - await openTraceInBrowser(server.urlPrefix()); + await openTraceInBrowser(server.urlPrefix('human-readable')); } else { - const page = await openTraceViewerApp(server.urlPrefix(), 'chromium', { + const page = await openTraceViewerApp(server.urlPrefix('precise'), 'chromium', { headless: isUnderTest() && process.env.PWTEST_HEADED_FOR_TEST !== '1', persistentContextOptions: { handleSIGINT: false, @@ -434,7 +435,7 @@ export async function runTestServer(configFile: string | undefined, options: { h const configLocation = resolveConfigLocation(configFile); return await innerRunTestServer(configLocation, options, async server => { // eslint-disable-next-line no-console - console.log('Listening on ' + server.urlPrefix().replace('http:', 'ws:') + '/' + server.wsGuid()); + console.log('Listening on ' + server.urlPrefix('precise').replace('http:', 'ws:') + '/' + server.wsGuid()); }); } diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 6e55f68dac..1e9e1f9c46 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -33,8 +33,7 @@ export interface TestStepInternal { complete(result: { error?: Error, attachments?: Attachment[] }): void; stepId: string; title: string; - category: 'hook' | 'fixture' | 'test.step' | string; - wallTime: number; + category: 'hook' | 'fixture' | 'test.step' | 'expect' | string; location?: Location; boxedStack?: StackFrame[]; steps: TestStepInternal[]; @@ -322,11 +321,11 @@ export class TestInfoImpl implements TestInfo { parentStepId: parentStep ? parentStep.stepId : undefined, title: data.title, category: data.category, - wallTime: data.wallTime, + wallTime: Date.now(), location: data.location, }; this._onStepBegin(payload); - this._tracing.appendBeforeActionForStep(stepId, parentStep?.stepId, data.apiName || data.title, data.params, data.wallTime, data.location ? [data.location] : []); + this._tracing.appendBeforeActionForStep(stepId, parentStep?.stepId, data.apiName || data.title, data.params, data.location ? [data.location] : []); return step; } @@ -355,7 +354,7 @@ export class TestInfoImpl implements TestInfo { const location = stage.runnable?.location ? ` at "${formatLocation(stage.runnable.location)}"` : ``; debugTest(`started stage "${stage.title}"${location}`); } - stage.step = stage.stepInfo ? this._addStep({ ...stage.stepInfo, title: stage.title, wallTime: Date.now(), isStage: true }) : undefined; + stage.step = stage.stepInfo ? this._addStep({ ...stage.stepInfo, title: stage.title, isStage: true }) : undefined; this._stages.push(stage); try { @@ -420,7 +419,6 @@ export class TestInfoImpl implements TestInfo { const step = this._addStep({ title: `attach "${name}"`, category: 'attach', - wallTime: Date.now(), }); this._attachmentsPush(attachment); this._onAttach({ diff --git a/packages/playwright/src/worker/testTracing.ts b/packages/playwright/src/worker/testTracing.ts index 82c84000e7..87eeca3ebb 100644 --- a/packages/playwright/src/worker/testTracing.ts +++ b/packages/playwright/src/worker/testTracing.ts @@ -28,6 +28,7 @@ import type { TestInfoImpl } from './testInfo'; export type Attachment = TestInfo['attachments'][0]; export const testTraceEntryName = 'test.trace'; +const version: trace.VERSION = 7; let traceOrdinal = 0; type TraceFixtureValue = PlaywrightWorkerOptions['trace'] | undefined; @@ -41,11 +42,24 @@ export class TestTracing { private _temporaryTraceFiles: string[] = []; private _artifactsDir: string; private _tracesDir: string; + private _contextCreatedEvent: trace.ContextCreatedTraceEvent; constructor(testInfo: TestInfoImpl, artifactsDir: string) { this._testInfo = testInfo; this._artifactsDir = artifactsDir; this._tracesDir = path.join(this._artifactsDir, 'traces'); + this._contextCreatedEvent = { + version, + type: 'context-options', + origin: 'testRunner', + browserName: '', + options: {}, + platform: process.platform, + wallTime: Date.now(), + monotonicTime: monotonicTime(), + sdkLanguage: 'javascript', + }; + this._appendTraceEvent(this._contextCreatedEvent); } private _shouldCaptureTrace() { @@ -220,12 +234,11 @@ export class TestTracing { }); } - appendBeforeActionForStep(callId: string, parentId: string | undefined, apiName: string, params: Record | undefined, wallTime: number, stack: StackFrame[]) { + appendBeforeActionForStep(callId: string, parentId: string | undefined, apiName: string, params: Record | undefined, stack: StackFrame[]) { this._appendTraceEvent({ type: 'before', callId, parentId, - wallTime, startTime: monotonicTime(), class: 'Test', method: 'step', diff --git a/packages/playwright/src/worker/workerMain.ts b/packages/playwright/src/worker/workerMain.ts index 9ae9a86207..ea2cdbaebc 100644 --- a/packages/playwright/src/worker/workerMain.ts +++ b/packages/playwright/src/worker/workerMain.ts @@ -100,12 +100,17 @@ export class WorkerMain extends ProcessRunner { override async gracefullyClose() { try { await this._stop(); + // Ignore top-level errors, they are already inside TestInfo.errors. + const fakeTestInfo = new TestInfoImpl(this._config, this._project, this._params, undefined, 0, () => {}, () => {}, () => {}); + const runnable = { type: 'teardown' } as const; // We have to load the project to get the right deadline below. - await this._loadIfNeeded(); - await this._teardownScopes(); + await fakeTestInfo._runAsStage({ title: 'worker cleanup', runnable }, () => this._loadIfNeeded()).catch(() => {}); + await this._fixtureRunner.teardownScope('test', fakeTestInfo, runnable).catch(() => {}); + await this._fixtureRunner.teardownScope('worker', fakeTestInfo, runnable).catch(() => {}); // Close any other browsers launched in this process. This includes anything launched // manually in the test/hooks and internal browsers like Playwright Inspector. - await gracefullyCloseAll(); + await fakeTestInfo._runAsStage({ title: 'worker cleanup', runnable }, () => gracefullyCloseAll()).catch(() => {}); + this._fatalErrors.push(...fakeTestInfo.errors); } catch (e) { this._fatalErrors.push(serializeError(e)); } @@ -144,15 +149,6 @@ export class WorkerMain extends ProcessRunner { } } - private async _teardownScopes() { - const fakeTestInfo = new TestInfoImpl(this._config, this._project, this._params, undefined, 0, () => {}, () => {}, () => {}); - const runnable = { type: 'teardown' } as const; - // Ignore top-level errors, they are already inside TestInfo.errors. - await this._fixtureRunner.teardownScope('test', fakeTestInfo, runnable).catch(() => {}); - await this._fixtureRunner.teardownScope('worker', fakeTestInfo, runnable).catch(() => {}); - this._fatalErrors.push(...fakeTestInfo.errors); - } - unhandledError(error: Error | any) { // No current test - fatal error. if (!this._currentTest) { diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 9e050e2901..444f135a89 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -2156,7 +2156,8 @@ interface TestFunction { * test('basic test', { * annotation: { * type: 'issue', - * description: 'https://github.com/microsoft/playwright/issues/23180', + * description: 'feature tags API', + * url: 'https://github.com/microsoft/playwright/issues/23180' * }, * }, async ({ page }) => { * await page.goto('https://playwright.dev/'); @@ -2232,7 +2233,8 @@ interface TestFunction { * test('basic test', { * annotation: { * type: 'issue', - * description: 'https://github.com/microsoft/playwright/issues/23180', + * description: 'feature tags API', + * url: 'https://github.com/microsoft/playwright/issues/23180' * }, * }, async ({ page }) => { * await page.goto('https://playwright.dev/'); diff --git a/packages/protocol/src/callMetadata.ts b/packages/protocol/src/callMetadata.ts index b011e9bf61..36e8b828c0 100644 --- a/packages/protocol/src/callMetadata.ts +++ b/packages/protocol/src/callMetadata.ts @@ -32,8 +32,8 @@ export type CallMetadata = { // Service-side is making a call to itself, this metadata does not go // through the dispatcher, so is always excluded from inspector / tracing. isServerSide?: boolean; - // Client wall time. - wallTime: number; + // Test runner step id. + stepId?: string; location?: { file: string, line?: number, column?: number }; log: string[]; error?: SerializedError; diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index 2024e02b56..986fb3a8dd 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -149,7 +149,7 @@ export type Metadata = { }, apiName?: string, internal?: boolean, - wallTime?: number, + stepId?: string, }; export type ClientSideCallMetadata = { @@ -309,7 +309,7 @@ export interface APIRequestContextChannel extends APIRequestContextEventTarget, fetchLog(params: APIRequestContextFetchLogParams, metadata?: CallMetadata): Promise; storageState(params?: APIRequestContextStorageStateParams, metadata?: CallMetadata): Promise; disposeAPIResponse(params: APIRequestContextDisposeAPIResponseParams, metadata?: CallMetadata): Promise; - dispose(params?: APIRequestContextDisposeParams, metadata?: CallMetadata): Promise; + dispose(params: APIRequestContextDisposeParams, metadata?: CallMetadata): Promise; } export type APIRequestContextFetchParams = { url: string, @@ -372,8 +372,12 @@ export type APIRequestContextDisposeAPIResponseOptions = { }; export type APIRequestContextDisposeAPIResponseResult = void; -export type APIRequestContextDisposeParams = {}; -export type APIRequestContextDisposeOptions = {}; +export type APIRequestContextDisposeParams = { + reason?: string, +}; +export type APIRequestContextDisposeOptions = { + reason?: string, +}; export type APIRequestContextDisposeResult = void; export interface APIRequestContextEvents { diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index d3b5f9d00c..14c6e73052 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -33,8 +33,8 @@ Metadata: column: number? apiName: string? internal: boolean? - # The number of milliseconds elapsed since midnight, January 1, 1970 UTC. - wallTime: number? + # Test runner step id. + stepId: string? ClientSideCallMetadata: type: object @@ -330,6 +330,8 @@ APIRequestContext: fetchUid: string dispose: + parameters: + reason: string? APIResponse: diff --git a/packages/trace-viewer/src/entries.ts b/packages/trace-viewer/src/entries.ts index 543e36ec49..bca5751ab2 100644 --- a/packages/trace-viewer/src/entries.ts +++ b/packages/trace-viewer/src/entries.ts @@ -26,7 +26,7 @@ export type ContextEntry = { browserName: string; channel?: string; platform?: string; - wallTime?: number; + wallTime: number; sdkLanguage?: Language; testIdAttributeName?: string; title?: string; @@ -58,6 +58,7 @@ export function createEmptyContext(): ContextEntry { origin: 'testRunner', traceUrl: '', startTime: Number.MAX_SAFE_INTEGER, + wallTime: Number.MAX_SAFE_INTEGER, endTime: 0, browserName: '', options: { @@ -71,6 +72,6 @@ export function createEmptyContext(): ContextEntry { events: [], errors: [], stdio: [], - hasSource: false + hasSource: false, }; } diff --git a/packages/trace-viewer/src/traceModernizer.ts b/packages/trace-viewer/src/traceModernizer.ts index 3ba3605973..a2a987318c 100644 --- a/packages/trace-viewer/src/traceModernizer.ts +++ b/packages/trace-viewer/src/traceModernizer.ts @@ -18,6 +18,7 @@ import type * as trace from '@trace/trace'; import type * as traceV3 from './versions/traceV3'; import type * as traceV4 from './versions/traceV4'; import type * as traceV5 from './versions/traceV5'; +import type * as traceV6 from './versions/traceV6'; import type { ActionEntry, ContextEntry, PageEntry } from './entries'; import type { SnapshotStorage } from './snapshotStorage'; @@ -71,12 +72,13 @@ export class TraceModernizer { switch (event.type) { case 'context-options': { this._version = event.version; - contextEntry.origin = 'library'; + contextEntry.origin = event.origin; contextEntry.browserName = event.browserName; contextEntry.channel = event.channel; contextEntry.title = event.title; contextEntry.platform = event.platform; contextEntry.wallTime = event.wallTime; + contextEntry.startTime = event.monotonicTime; contextEntry.sdkLanguage = event.sdkLanguage; contextEntry.options = event.options; contextEntry.testIdAttributeName = event.testIdAttributeName; @@ -145,11 +147,11 @@ export class TraceModernizer { break; } case 'resource-snapshot': - this._snapshotStorage!.addResource(event.snapshot); + this._snapshotStorage.addResource(event.snapshot); contextEntry.resources.push(event.snapshot); break; case 'frame-snapshot': - this._snapshotStorage!.addFrameSnapshot(event.snapshot); + this._snapshotStorage.addFrameSnapshot(event.snapshot); break; } // Make sure there is a page entry for each page, even without screencast frames, @@ -170,12 +172,18 @@ export class TraceModernizer { } } + private _processedContextCreatedEvent() { + return this._version !== undefined; + } + private _modernize(event: any): trace.TraceEvent[] { - if (this._version === undefined) + // In trace 6->7 we also need to modernize context-options event. + let version = this._version || event.version; + if (version === undefined) return [event]; - const lastVersion: trace.VERSION = 6; + const lastVersion: trace.VERSION = 7; let events = [event]; - for (let version = this._version; version < lastVersion; ++version) + for (; version < lastVersion; ++version) events = (this as any)[`_modernize_${version}_to_${version + 1}`].call(this, events); return events; } @@ -341,8 +349,8 @@ export class TraceModernizer { return event; } - _modernize_5_to_6(events: traceV5.TraceEvent[]): trace.TraceEvent[] { - const result: trace.TraceEvent[] = []; + _modernize_5_to_6(events: traceV5.TraceEvent[]): traceV6.TraceEvent[] { + const result: traceV6.TraceEvent[] = []; for (const event of events) { result.push(event); if (event.type !== 'after' || !event.log.length) @@ -358,4 +366,35 @@ export class TraceModernizer { } return result; } + + _modernize_6_to_7(events: traceV6.TraceEvent[]): trace.TraceEvent[] { + const result: trace.TraceEvent[] = []; + if (!this._processedContextCreatedEvent() && events[0].type !== 'context-options') { + const event: trace.ContextCreatedTraceEvent = { + type: 'context-options', + origin: 'testRunner', + version: 7, + browserName: '', + options: {}, + platform: process.platform, + wallTime: 0, + monotonicTime: 0, + sdkLanguage: 'javascript', + }; + result.push(event); + } + for (const event of events) { + if (event.type === 'context-options') { + result.push({ ...event, monotonicTime: 0, origin: 'library' }); + continue; + } + // Take wall and monotonic time from the first event. + if (!this._contextEntry.wallTime && event.type === 'before') + this._contextEntry.wallTime = event.wallTime; + if (!this._contextEntry.startTime && event.type === 'before') + this._contextEntry.startTime = event.startTime; + result.push(event); + } + return result; + } } diff --git a/packages/trace-viewer/src/ui/callTab.tsx b/packages/trace-viewer/src/ui/callTab.tsx index 463ba5056c..b7cd7a8581 100644 --- a/packages/trace-viewer/src/ui/callTab.tsx +++ b/packages/trace-viewer/src/ui/callTab.tsx @@ -23,9 +23,10 @@ import { CopyToClipboard } from './copyToClipboard'; import { asLocator } from '@isomorphic/locatorGenerators'; import type { Language } from '@isomorphic/locatorGenerators'; import { PlaceholderPanel } from './placeholderPanel'; +import type { ActionTraceEventInContext } from './modelUtil'; export const CallTab: React.FunctionComponent<{ - action: ActionTraceEvent | undefined, + action: ActionTraceEventInContext | undefined, sdkLanguage: Language | undefined, }> = ({ action, sdkLanguage }) => { if (!action) @@ -34,7 +35,8 @@ export const CallTab: React.FunctionComponent<{ // Strip down the waitForEventInfo data, we never need it. delete params.info; const paramKeys = Object.keys(params); - const wallTime = action.wallTime ? new Date(action.wallTime).toLocaleString() : null; + const timeMillis = action.startTime + (action.context.wallTime - action.context.startTime); + const wallTime = new Date(timeMillis).toLocaleString(); const duration = action.endTime ? msToString(action.endTime - action.startTime) : 'Timed Out'; return
diff --git a/packages/trace-viewer/src/ui/logTab.tsx b/packages/trace-viewer/src/ui/logTab.tsx index 9e4995984f..be0469ab91 100644 --- a/packages/trace-viewer/src/ui/logTab.tsx +++ b/packages/trace-viewer/src/ui/logTab.tsx @@ -31,7 +31,7 @@ export const LogTab: React.FunctionComponent<{ if (!action || !action.log.length) return []; const log = action.log; - const wallTimeOffset = action.wallTime - action.startTime; + const wallTimeOffset = action.context.wallTime - action.context.startTime; const entries: { message: string, time: string }[] = []; for (let i = 0; i < log.length; ++i) { let time = ''; diff --git a/packages/trace-viewer/src/ui/modelUtil.ts b/packages/trace-viewer/src/ui/modelUtil.ts index 8a24c89430..ba866ad7ca 100644 --- a/packages/trace-viewer/src/ui/modelUtil.ts +++ b/packages/trace-viewer/src/ui/modelUtil.ts @@ -157,6 +157,8 @@ function indexModel(context: ContextEntry) { } for (const event of context.events) (event as any)[contextSymbol] = context; + for (const resource of context.resources) + (resource as any)[contextSymbol] = context; } function mergeActionsAndUpdateTiming(contexts: ContextEntry[]) { @@ -215,23 +217,33 @@ function mergeActionsAndUpdateTimingSameTrace(contexts: ContextEntry[]) { const libraryContexts = contexts.filter(context => context.origin === 'library'); const testRunnerContexts = contexts.filter(context => context.origin === 'testRunner'); + // Library actions are replaced with corresponding test runner steps. Matching with + // the test runner steps enables us to find parent steps. + // - In the newer versions the actions are matched by explicit step id stored in the + // library context actions. + // - In the older versions the step id is not stored and the match is perfomed based on + // action name and wallTime. + const matchByStepId = libraryContexts.some(c => c.actions.some(a => !!a.stepId)); + for (const context of libraryContexts) { - for (const action of context.actions) - map.set(`${action.apiName}@${action.wallTime}`, { ...action, context }); + for (const action of context.actions) { + const key = matchByStepId ? action.stepId! : `${action.apiName}@${(action as any).wallTime}`; + map.set(key, { ...action, context }); + } } // Protocol call aka library contexts have startTime/endTime as server-side times. // Step aka test runner contexts have startTime/endTime as client-side times. // Adjust startTime/endTime on the library contexts to align them with the test // runner steps. - const delta = monotonicTimeDeltaBetweenLibraryAndRunner(testRunnerContexts, map); + const delta = monotonicTimeDeltaBetweenLibraryAndRunner(testRunnerContexts, map, matchByStepId); if (delta) adjustMonotonicTime(libraryContexts, delta); const nonPrimaryIdToPrimaryId = new Map(); for (const context of testRunnerContexts) { for (const action of context.actions) { - const key = `${action.apiName}@${action.wallTime}`; + const key = matchByStepId ? action.callId : `${action.apiName}@${(action as any).wallTime}`; const existing = map.get(key); if (existing) { nonPrimaryIdToPrimaryId.set(action.callId, existing.callId); @@ -276,7 +288,7 @@ function adjustMonotonicTime(contexts: ContextEntry[], monotonicTimeDelta: numbe } } -function monotonicTimeDeltaBetweenLibraryAndRunner(nonPrimaryContexts: ContextEntry[], libraryActions: Map) { +function monotonicTimeDeltaBetweenLibraryAndRunner(nonPrimaryContexts: ContextEntry[], libraryActions: Map, matchByStepId: boolean) { // We cannot rely on wall time or monotonic time to be the in sync // between library and test runner contexts. So we find first action // that is present in both runner and library contexts and use it @@ -286,7 +298,7 @@ function monotonicTimeDeltaBetweenLibraryAndRunner(nonPrimaryContexts: ContextEn for (const action of context.actions) { if (!action.startTime) continue; - const key = `${action.apiName}@${action.wallTime}`; + const key = matchByStepId ? action.stepId! : `${action.apiName}@${(action as any).wallTime}`; const libraryAction = libraryActions.get(key); if (libraryAction) return action.startTime - libraryAction.startTime; @@ -320,7 +332,7 @@ export function idForAction(action: ActionTraceEvent) { return `${action.pageId || 'none'}:${action.callId}`; } -export function context(action: ActionTraceEvent | trace.EventTraceEvent): ContextEntry { +export function context(action: ActionTraceEvent | trace.EventTraceEvent | ResourceSnapshot): ContextEntry { return (action as any)[contextSymbol]; } diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index b3caa501b5..d007d58198 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -21,12 +21,14 @@ import './networkTab.css'; import { NetworkResourceDetails } from './networkResourceDetails'; import { bytesToString, msToString } from '@web/uiUtils'; import { PlaceholderPanel } from './placeholderPanel'; -import type { MultiTraceModel } from './modelUtil'; +import { context, type MultiTraceModel } from './modelUtil'; import { GridView, type RenderedGridCell } from '@web/components/gridView'; import { SplitView } from '@web/components/splitView'; +import type { ContextEntry } from '../entries'; type NetworkTabModel = { resources: Entry[], + contextIdMap: ContextIdMap, }; type RenderedEntry = { @@ -39,6 +41,7 @@ type RenderedEntry = { start: number, route: string, resource: Entry, + contextId: string, }; type ColumnName = keyof RenderedEntry; type Sorting = { by: ColumnName, negate: boolean}; @@ -54,7 +57,8 @@ export function useNetworkTabModel(model: MultiTraceModel | undefined, selectedT }); return filtered; }, [model, selectedTime]); - return { resources }; + const contextIdMap = React.useMemo(() => new ContextIdMap(model), [model]); + return { resources, contextIdMap }; } export const NetworkTab: React.FunctionComponent<{ @@ -66,11 +70,11 @@ export const NetworkTab: React.FunctionComponent<{ const [selectedEntry, setSelectedEntry] = React.useState(undefined); const { renderedEntries } = React.useMemo(() => { - const renderedEntries = networkModel.resources.map(entry => renderEntry(entry, boundaries)); + const renderedEntries = networkModel.resources.map(entry => renderEntry(entry, boundaries, networkModel.contextIdMap)); if (sorting) sort(renderedEntries, sorting); return { renderedEntries }; - }, [networkModel.resources, sorting, boundaries]); + }, [networkModel.resources, networkModel.contextIdMap, sorting, boundaries]); if (!networkModel.resources.length) return ; @@ -81,7 +85,7 @@ export const NetworkTab: React.FunctionComponent<{ selectedItem={selectedEntry} onSelected={item => setSelectedEntry(item)} onHighlighted={item => onEntryHovered(item?.resource)} - columns={selectedEntry ? ['name'] : ['name', 'method', 'status', 'contentType', 'duration', 'size', 'start', 'route']} + columns={visibleColumns(!!selectedEntry, renderedEntries)} columnTitle={columnTitle} columnWidth={columnWidth} isError={item => item.status.code >= 400} @@ -100,6 +104,8 @@ export const NetworkTab: React.FunctionComponent<{ }; const columnTitle = (column: ColumnName) => { + if (column === 'contextId') + return 'Source'; if (column === 'name') return 'Name'; if (column === 'method') @@ -128,10 +134,28 @@ const columnWidth = (column: ColumnName) => { return 60; if (column === 'contentType') return 200; + if (column === 'contextId') + return 60; return 100; }; +function visibleColumns(entrySelected: boolean, renderedEntries: RenderedEntry[]): (keyof RenderedEntry)[] { + if (entrySelected) + return ['name']; + const columns: (keyof RenderedEntry)[] = []; + if (hasMultipleContexts(renderedEntries)) + columns.push('contextId'); + columns.push('name', 'method', 'status', 'contentType', 'duration', 'size', 'start', 'route'); + return columns; +} + const renderCell = (entry: RenderedEntry, column: ColumnName): RenderedGridCell => { + if (column === 'contextId') { + return { + body: entry.contextId, + title: entry.name.url, + }; + } if (column === 'name') { return { body: entry.name.name, @@ -159,7 +183,57 @@ const renderCell = (entry: RenderedEntry, column: ColumnName): RenderedGridCell return { body: '' }; }; -const renderEntry = (resource: Entry, boundaries: Boundaries): RenderedEntry => { +class ContextIdMap { + private _pagerefToShortId = new Map(); + private _contextToId = new Map(); + private _lastPageId = 0; + private _lastApiRequestContextId = 0; + + constructor(model: MultiTraceModel | undefined) {} + + contextId(resource: Entry): string { + if (resource.pageref) + return this._pageId(resource.pageref); + else if (resource._apiRequest) + return this._apiRequestContextId(resource); + return ''; + } + + private _pageId(pageref: string): string { + let shortId = this._pagerefToShortId.get(pageref); + if (!shortId) { + ++this._lastPageId; + shortId = 'page#' + this._lastPageId; + this._pagerefToShortId.set(pageref, shortId); + } + return shortId; + } + + private _apiRequestContextId(resource: Entry): string { + const contextEntry = context(resource); + if (!contextEntry) + return ''; + let contextId = this._contextToId.get(contextEntry); + if (!contextId) { + ++this._lastApiRequestContextId; + contextId = 'api#' + this._lastApiRequestContextId; + this._contextToId.set(contextEntry, contextId); + } + return contextId; + } +} + +function hasMultipleContexts(renderedEntries: RenderedEntry[]): boolean { + const contextIds = new Set(); + for (const entry of renderedEntries) { + contextIds.add(entry.contextId); + if (contextIds.size > 1) + return true; + } + return false; +} + +const renderEntry = (resource: Entry, boundaries: Boundaries, contextIdGenerator: ContextIdMap): RenderedEntry => { const routeStatus = formatRouteStatus(resource); let resourceName: string; try { @@ -184,7 +258,8 @@ const renderEntry = (resource: Entry, boundaries: Boundaries): RenderedEntry => size: resource.response._transferSize! > 0 ? resource.response._transferSize! : resource.response.bodySize, start: resource._monotonicTime! - boundaries.minimum, route: routeStatus, - resource + resource, + contextId: contextIdGenerator.contextId(resource), }; }; @@ -249,4 +324,7 @@ function comparator(sortBy: ColumnName) { return a.route.localeCompare(b.route); }; } + + if (sortBy === 'contextId') + return (a: RenderedEntry, b: RenderedEntry) => a.contextId.localeCompare(b.contextId); } diff --git a/packages/trace-viewer/src/versions/traceV6.ts b/packages/trace-viewer/src/versions/traceV6.ts new file mode 100644 index 0000000000..2bbbd447b3 --- /dev/null +++ b/packages/trace-viewer/src/versions/traceV6.ts @@ -0,0 +1,239 @@ +/** + * 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 { Entry as ResourceSnapshot } from '../../../trace/src/har'; + +type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl'; +type Point = { x: number, y: number }; +type Size = { width: number, height: number }; + +type StackFrame = { + file: string, + line: number, + column: number, + function?: string, +}; + +type SerializedValue = { + n?: number, + b?: boolean, + s?: string, + v?: 'null' | 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0', + d?: string, + u?: string, + bi?: string, + m?: SerializedValue, + se?: SerializedValue, + r?: { + p: string, + f: string, + }, + a?: SerializedValue[], + o?: { + k: string, + v: SerializedValue, + }[], + h?: number, + id?: number, + ref?: number, +}; + +type SerializedError = { + error?: { + message: string, + name: string, + stack?: string, + }, + value?: SerializedValue, +}; + +type NodeSnapshot = + // Text node. + string | + // Subtree reference, "x snapshots ago, node #y". Could point to a text node. + // Only nodes that are not references are counted, starting from zero, using post-order traversal. + [ [number, number] ] | + // Just node name. + [ string ] | + // Node name, attributes, child nodes. + // Unfortunately, we cannot make this type definition recursive, therefore "any". + [ string, { [attr: string]: string }, ...any ]; + + +type ResourceOverride = { + url: string, + sha1?: string, + ref?: number +}; + +type FrameSnapshot = { + snapshotName?: string, + callId: string, + pageId: string, + frameId: string, + frameUrl: string, + timestamp: number, + collectionTime: number, + doctype?: string, + html: NodeSnapshot, + resourceOverrides: ResourceOverride[], + viewport: { width: number, height: number }, + isMainFrame: boolean, +}; + +export type BrowserContextEventOptions = { + viewport?: Size, + deviceScaleFactor?: number, + isMobile?: boolean, + userAgent?: string, +}; + +export type ContextCreatedTraceEvent = { + version: number, + type: 'context-options', + browserName: string, + channel?: string, + platform: string, + wallTime: number, + title?: string, + options: BrowserContextEventOptions, + sdkLanguage?: Language, + testIdAttributeName?: string, +}; + +export type ScreencastFrameTraceEvent = { + type: 'screencast-frame', + pageId: string, + sha1: string, + width: number, + height: number, + timestamp: number, +}; + +export type BeforeActionTraceEvent = { + type: 'before', + callId: string; + startTime: number; + apiName: string; + class: string; + method: string; + params: Record; + wallTime: number; + beforeSnapshot?: string; + stack?: StackFrame[]; + pageId?: string; + parentId?: string; +}; + +export type InputActionTraceEvent = { + type: 'input', + callId: string; + inputSnapshot?: string; + point?: Point; +}; + +export type AfterActionTraceEventAttachment = { + name: string; + contentType: string; + path?: string; + sha1?: string; + base64?: string; +}; + +export type AfterActionTraceEvent = { + type: 'after', + callId: string; + endTime: number; + afterSnapshot?: string; + error?: SerializedError['error']; + attachments?: AfterActionTraceEventAttachment[]; + result?: any; + point?: Point; +}; + +export type LogTraceEvent = { + type: 'log', + callId: string; + time: number; + message: string; +}; + +export type EventTraceEvent = { + type: 'event', + time: number; + class: string; + method: string; + params: any; + pageId?: string; +}; + +export type ConsoleMessageTraceEvent = { + type: 'console'; + time: number; + pageId?: string; + messageType: string, + text: string, + args?: { preview: string, value: any }[], + location: { + url: string, + lineNumber: number, + columnNumber: number, + }, +}; + +export type ResourceSnapshotTraceEvent = { + type: 'resource-snapshot', + snapshot: ResourceSnapshot, +}; + +export type FrameSnapshotTraceEvent = { + type: 'frame-snapshot', + snapshot: FrameSnapshot, +}; + +export type ActionTraceEvent = { + type: 'action', +} & Omit + & Omit + & Omit; + +export type StdioTraceEvent = { + type: 'stdout' | 'stderr'; + timestamp: number; + text?: string; + base64?: string; +}; + +export type ErrorTraceEvent = { + type: 'error'; + message: string; + stack?: StackFrame[]; +}; + +export type TraceEvent = + ContextCreatedTraceEvent | + ScreencastFrameTraceEvent | + ActionTraceEvent | + BeforeActionTraceEvent | + InputActionTraceEvent | + AfterActionTraceEvent | + EventTraceEvent | + LogTraceEvent | + ConsoleMessageTraceEvent | + ResourceSnapshotTraceEvent | + FrameSnapshotTraceEvent | + StdioTraceEvent | + ErrorTraceEvent; diff --git a/packages/trace/src/trace.ts b/packages/trace/src/trace.ts index 2f4974aae4..ebd3466fd8 100644 --- a/packages/trace/src/trace.ts +++ b/packages/trace/src/trace.ts @@ -21,7 +21,7 @@ import type { FrameSnapshot, ResourceSnapshot } from './snapshot'; export type Size = { width: number, height: number }; // Make sure you add _modernize_N_to_N1(event: any) to traceModel.ts. -export type VERSION = 6; +export type VERSION = 7; export type BrowserContextEventOptions = { viewport?: Size, @@ -33,10 +33,12 @@ export type BrowserContextEventOptions = { export type ContextCreatedTraceEvent = { version: number, type: 'context-options', + origin: 'testRunner' | 'library', browserName: string, channel?: string, platform: string, wallTime: number, + monotonicTime: number, title?: string, options: BrowserContextEventOptions, sdkLanguage?: Language, @@ -60,7 +62,7 @@ export type BeforeActionTraceEvent = { class: string; method: string; params: Record; - wallTime: number; + stepId?: string; beforeSnapshot?: string; stack?: StackFrame[]; pageId?: string; diff --git a/packages/web/src/uiUtils.ts b/packages/web/src/uiUtils.ts index 7e00693234..95d0d42a53 100644 --- a/packages/web/src/uiUtils.ts +++ b/packages/web/src/uiUtils.ts @@ -55,7 +55,7 @@ export function useMeasure() { } export function msToString(ms: number): string { - if (!isFinite(ms)) + if (ms < 0 || !isFinite(ms)) return '-'; if (ms === 0) diff --git a/tests/components/ct-react-vite/tests/unmount.spec.tsx b/tests/components/ct-react-vite/tests/unmount.spec.tsx index 20374d8b8e..4ce2d45889 100644 --- a/tests/components/ct-react-vite/tests/unmount.spec.tsx +++ b/tests/components/ct-react-vite/tests/unmount.spec.tsx @@ -23,3 +23,10 @@ test('unmount twice throws an error', async ({ mount }) => { await component.unmount(); await expect(component.unmount()).rejects.toThrowError('Component was not mounted'); }); + +test('mount then unmount then mount', async ({ mount }) => { + let component = await mount(