diff --git a/.eslintrc.js b/.eslintrc.js index a116a37036..e71a4ffd09 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -115,7 +115,7 @@ module.exports = { "@typescript-eslint/type-annotation-spacing": 2, // file whitespace - "no-multiple-empty-lines": [2, {"max": 2}], + "no-multiple-empty-lines": [2, {"max": 2, "maxEOF": 0}], "no-mixed-spaces-and-tabs": 2, "no-trailing-spaces": 2, "linebreak-style": [ process.platform === "win32" ? 0 : 2, "unix" ], @@ -123,6 +123,7 @@ module.exports = { "key-spacing": [2, { "beforeColon": false }], + "eol-last": 2, // copyright "notice/notice": [2, { diff --git a/.github/workflows/tests_bidi.yml b/.github/workflows/tests_bidi.yml index 46b16aac7b..6be824869a 100644 --- a/.github/workflows/tests_bidi.yml +++ b/.github/workflows/tests_bidi.yml @@ -7,6 +7,7 @@ on: - main paths: - .github/workflows/tests_bidi.yml + - packages/playwright-core/src/server/bidi/* schedule: # Run every day at midnight - cron: '0 0 * * *' @@ -43,3 +44,27 @@ jobs: run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run biditest -- --project=${{ matrix.channel }}* env: PWTEST_USE_BIDI_EXPECTATIONS: '1' + - name: Upload csv report to GitHub + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: csv-report-${{ matrix.channel }} + path: test-results/report.csv + retention-days: 7 + + - name: Azure Login + if: ${{ !cancelled() && github.ref == 'refs/heads/main' }} + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_BLOB_REPORTS_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_BLOB_REPORTS_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_BLOB_REPORTS_SUBSCRIPTION_ID }} + + - name: Upload report.csv to Azure + if: ${{ !cancelled() && github.ref == 'refs/heads/main' }} + run: | + REPORT_DIR='bidi-reports' + azcopy cp "./test-results/report.csv" "https://mspwblobreport.blob.core.windows.net/\$web/$REPORT_DIR/${{ matrix.channel }}.csv" + echo "Report url: https://mspwblobreport.z1.web.core.windows.net/$REPORT_DIR/${{ matrix.channel }}.csv" + env: + AZCOPY_AUTO_LOGIN_TYPE: AZCLI diff --git a/.github/workflows/tests_secondary.yml b/.github/workflows/tests_secondary.yml index de9434d7df..5afd2f5491 100644 --- a/.github/workflows/tests_secondary.yml +++ b/.github/workflows/tests_secondary.yml @@ -234,6 +234,27 @@ jobs: env: PWTEST_CHANNEL: chromium-tip-of-tree + chromium_tot_headless_shell: + name: Chromium tip-of-tree headless-shell-${{ matrix.os }} + environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-20.04] + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/run-test + with: + browsers-to-install: chromium-tip-of-tree-headless-shell + command: npm run ctest + bot-name: "chromium-tip-of-tree-headless-shell-${{ 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_CHANNEL: chromium-tip-of-tree-headless-shell + firefox_beta: name: Firefox Beta ${{ matrix.os }} environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb06a17e77..440e268f8d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,16 @@ npm run watch npx playwright install ``` +**Experimental dev mode with Hot Module Replacement for recorder/trace-viewer/UI Mode** + +``` +PW_HMR=1 npm run watch +PW_HMR=1 npx playwright show-trace +PW_HMR=1 npm run ctest -- --ui +PW_HMR=1 npx playwright codegen +PW_HMR=1 npx playwright show-report +``` + Playwright is a multi-package repository that uses npm workspaces. For browser APIs, look at [`packages/playwright-core`](https://github.com/microsoft/playwright/blob/main/packages/playwright-core). For test runner, see [`packages/playwright`](https://github.com/microsoft/playwright/blob/main/packages/playwright). Note that some files are generated by the build, so the watch process might override your changes if done in the wrong file. For example, TypeScript types for the API are generated from the [`docs/src`](https://github.com/microsoft/playwright/blob/main/docs/src). @@ -35,7 +45,7 @@ Coding style is fully defined in [.eslintrc](https://github.com/microsoft/playwr npm run lint ``` -Comments should be generally avoided. If the code would not be understood without comments, consider re-writing the code to make it self-explanatory. +Comments should have an explicit purpose and should improve readability rather than hinder it. If the code would not be understood without comments, consider re-writing the code to make it self-explanatory. ### Write documentation diff --git a/README.md b/README.md index 625e13fb11..7d100113c5 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-132.0.6834.15-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-132.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.2-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord) +[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-132.0.6834.57-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-132.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.2-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord) ## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright) @@ -8,7 +8,7 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 132.0.6834.15 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Chromium 132.0.6834.57 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 18.2 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox 132.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | diff --git a/docs/src/api/class-browser.md b/docs/src/api/class-browser.md index 4dabfc52e4..7867ce5c8b 100644 --- a/docs/src/api/class-browser.md +++ b/docs/src/api/class-browser.md @@ -96,7 +96,7 @@ In case this browser is connected to, clears all created contexts belonging to t browser server. :::note -This is similar to force quitting the browser. Therefore, you should call [`method: BrowserContext.close`] on any [BrowserContext]'s you explicitly created earlier with [`method: Browser.newContext`] **before** calling [`method: Browser.close`]. +This is similar to force-quitting the browser. To close pages gracefully and ensure you receive page close events, call [`method: BrowserContext.close`] on any [BrowserContext] instances you explicitly created earlier using [`method: Browser.newContext`] **before** calling [`method: Browser.close`]. ::: The [Browser] object itself is considered to be disposed and cannot be used anymore. diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index 121ce1fdf6..85cbc4f62a 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -1412,7 +1412,7 @@ This setting will change the default maximum time for all the methods accepting * since: v1.8 - `timeout` <[float]> -Maximum time in milliseconds +Maximum time in milliseconds. Pass `0` to disable timeout. ## async method: BrowserContext.setExtraHTTPHeaders * since: v1.8 diff --git a/docs/src/api/class-clock.md b/docs/src/api/class-clock.md index 38ca329769..e5610b4242 100644 --- a/docs/src/api/class-clock.md +++ b/docs/src/api/class-clock.md @@ -161,6 +161,41 @@ await page.Clock.PauseAtAsync(DateTime.Parse("2020-02-02")); await page.Clock.PauseAtAsync("2020-02-02"); ``` +For best results, install the clock before navigating the page and set it to a time slightly before the intended test time. This ensures that all timers run normally during page loading, preventing the page from getting stuck. Once the page has fully loaded, you can safely use [`method: Clock.pauseAt`] to pause the clock. + +```js +// Initialize clock with some time before the test time and let the page load +// naturally. `Date.now` will progress as the timers fire. +await page.clock.install({ time: new Date('2024-12-10T08:00:00') }); +await page.goto('http://localhost:3333'); +await page.clock.pauseAt(new Date('2024-12-10T10:00:00')); +``` + +```python async +# Initialize clock with some time before the test time and let the page load +# naturally. `Date.now` will progress as the timers fire. +await page.clock.install(time=datetime.datetime(2024, 12, 10, 8, 0, 0)) +await page.goto("http://localhost:3333") +await page.clock.pause_at(datetime.datetime(2024, 12, 10, 10, 0, 0)) +``` + +```python sync +# Initialize clock with some time before the test time and let the page load +# naturally. `Date.now` will progress as the timers fire. +page.clock.install(time=datetime.datetime(2024, 12, 10, 8, 0, 0)) +page.goto("http://localhost:3333") +page.clock.pause_at(datetime.datetime(2024, 12, 10, 10, 0, 0)) +``` + +```java +// Initialize clock with some time before the test time and let the page load +// naturally. `Date.now` will progress as the timers fire. +SimpleDateFormat format = new SimpleDateFormat("yyy-MM-dd'T'HH:mm:ss"); +page.clock().install(new Clock.InstallOptions().setTime(format.parse("2024-12-10T08:00:00"))); +page.navigate("http://localhost:3333"); +page.clock().pauseAt(format.parse("2024-12-10T10:00:00")); +``` + ### param: Clock.pauseAt.time * langs: js, java * since: v1.45 diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index e61b119c69..38a3546e41 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -155,7 +155,7 @@ Additional locator to match. - returns: <[string]> Captures the aria snapshot of the given element. -Read more about [aria snapshots](../aria-snapshots.md) and [`method: LocatorAssertions.toMatchAriaSnapshot`] for the corresponding assertion. +Read more about [aria snapshots](../aria-snapshots.md) and [`method: LocatorAssertions.toMatchAriaSnapshot#2`] for the corresponding assertion. **Usage** @@ -1717,16 +1717,21 @@ var banana = await page.GetByRole(AriaRole.Listitem).Nth(2); Creates a locator matching all elements that match one or both of the two locators. -Note that when both locators match something, the resulting locator will have multiple matches and violate [locator strictness](../locators.md#strictness) guidelines. +Note that when both locators match something, the resulting locator will have multiple matches, potentially causing a [locator strictness](../locators.md#strictness) violation. **Usage** Consider a scenario where you'd like to click on a "New email" button, but sometimes a security settings dialog shows up instead. In this case, you can wait for either a "New email" button, or a dialog and act accordingly. +:::note +If both "New email" button and security dialog appear on screen, the "or" locator will match both of them, +possibly throwing the ["strict mode violation" error](../locators.md#strictness). In this case, you can use [`method: Locator.first`] to only match one of them. +::: + ```js const newEmail = page.getByRole('button', { name: 'New' }); const dialog = page.getByText('Confirm security settings'); -await expect(newEmail.or(dialog)).toBeVisible(); +await expect(newEmail.or(dialog).first()).toBeVisible(); if (await dialog.isVisible()) await page.getByRole('button', { name: 'Dismiss' }).click(); await newEmail.click(); @@ -1735,7 +1740,7 @@ await newEmail.click(); ```java Locator newEmail = page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("New")); Locator dialog = page.getByText("Confirm security settings"); -assertThat(newEmail.or(dialog)).isVisible(); +assertThat(newEmail.or(dialog).first()).isVisible(); if (dialog.isVisible()) page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Dismiss")).click(); newEmail.click(); @@ -1744,7 +1749,7 @@ newEmail.click(); ```python async new_email = page.get_by_role("button", name="New") dialog = page.get_by_text("Confirm security settings") -await expect(new_email.or_(dialog)).to_be_visible() +await expect(new_email.or_(dialog).first).to_be_visible() if (await dialog.is_visible()): await page.get_by_role("button", name="Dismiss").click() await new_email.click() @@ -1753,7 +1758,7 @@ await new_email.click() ```python sync new_email = page.get_by_role("button", name="New") dialog = page.get_by_text("Confirm security settings") -expect(new_email.or_(dialog)).to_be_visible() +expect(new_email.or_(dialog).first).to_be_visible() if (dialog.is_visible()): page.get_by_role("button", name="Dismiss").click() new_email.click() @@ -1762,7 +1767,7 @@ new_email.click() ```csharp var newEmail = page.GetByRole(AriaRole.Button, new() { Name = "New" }); var dialog = page.GetByText("Confirm security settings"); -await Expect(newEmail.Or(dialog)).ToBeVisibleAsync(); +await Expect(newEmail.Or(dialog).First).ToBeVisibleAsync(); if (await dialog.IsVisibleAsync()) await page.GetByRole(AriaRole.Button, new() { Name = "Dismiss" }).ClickAsync(); await newEmail.ClickAsync(); diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index 7a640c4ef6..c2adf3afc5 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -446,7 +446,7 @@ Expected options currently selected. * since: v1.49 * langs: python -The opposite of [`method: LocatorAssertions.toMatchAriaSnapshot`]. +The opposite of [`method: LocatorAssertions.toMatchAriaSnapshot#2`]. ### param: LocatorAssertions.NotToMatchAriaSnapshot.expected * since: v1.49 @@ -1217,6 +1217,56 @@ Expected accessible description. * since: v1.44 +## async method: LocatorAssertions.toHaveAccessibleErrorMessage +* since: v1.50 +* langs: + - alias-java: hasAccessibleErrorMessage + +Ensures the [Locator] points to an element with a given [aria errormessage](https://w3c.github.io/aria/#aria-errormessage). + +**Usage** + +```js +const locator = page.getByTestId('username-input'); +await expect(locator).toHaveAccessibleErrorMessage('Username is required.'); +``` + +```java +Locator locator = page.getByTestId("username-input"); +assertThat(locator).hasAccessibleErrorMessage("Username is required."); +``` + +```python async +locator = page.get_by_test_id("username-input") +await expect(locator).to_have_accessible_error_message("Username is required.") +``` + +```python sync +locator = page.get_by_test_id("username-input") +expect(locator).to_have_accessible_error_message("Username is required.") +``` + +```csharp +var locator = Page.GetByTestId("username-input"); +await Expect(locator).ToHaveAccessibleErrorMessageAsync("Username is required."); +``` + +### param: LocatorAssertions.toHaveAccessibleErrorMessage.errorMessage +* since: v1.50 +- `errorMessage` <[string]|[RegExp]> + +Expected accessible error message. + +### option: LocatorAssertions.toHaveAccessibleErrorMessage.timeout = %%-js-assertions-timeout-%% +* since: v1.50 + +### option: LocatorAssertions.toHaveAccessibleErrorMessage.timeout = %%-csharp-java-python-assertions-timeout-%% +* since: v1.50 + +### option: LocatorAssertions.toHaveAccessibleErrorMessage.ignoreCase = %%-assertions-ignore-case-%% +* since: v1.50 + + ## async method: LocatorAssertions.toHaveAccessibleName * since: v1.44 * langs: @@ -2121,7 +2171,58 @@ Expected options currently selected. * since: v1.23 -## async method: LocatorAssertions.toMatchAriaSnapshot +## async method: LocatorAssertions.toMatchAriaSnapshot#1 +* since: v1.50 +* langs: + - alias-java: matchesAriaSnapshot + +Asserts that the target element matches the given [accessibility snapshot](../aria-snapshots.md). + +**Usage** + +```js +await expect(page.locator('body')).toMatchAriaSnapshot(); +await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'snapshot' }); +await expect(page.locator('body')).toMatchAriaSnapshot({ path: '/path/to/snapshot.yml' }); +``` + +```python async +await expect(page.locator('body')).to_match_aria_snapshot(path='/path/to/snapshot.yml') +``` + +```python sync +expect(page.locator('body')).to_match_aria_snapshot(path='/path/to/snapshot.yml') +``` + +```csharp +await Expect(page.Locator("body")).ToMatchAriaSnapshotAsync(new { Path = "/path/to/snapshot.yml" }); +``` + +```java +assertThat(page.locator("body")).matchesAriaSnapshot(new LocatorAssertions.MatchesAriaSnapshotOptions().setPath("/path/to/snapshot.yml")); +``` + +### option: LocatorAssertions.toMatchAriaSnapshot#1.name +* since: v1.50 +* langs: js +- `name` <[string]> + +Name of the snapshot to store in the snapshot folder corresponding to this test. Generates ordinal name if not specified. + +### option: LocatorAssertions.toMatchAriaSnapshot#1.path +* since: v1.50 +- `path` <[string]> + +Path to the YAML snapshot file. + +### option: LocatorAssertions.toMatchAriaSnapshot#1.timeout = %%-js-assertions-timeout-%% +* since: v1.50 + +### option: LocatorAssertions.toMatchAriaSnapshot#1.timeout = %%-csharp-java-python-assertions-timeout-%% +* since: v1.50 + + +## async method: LocatorAssertions.toMatchAriaSnapshot#2 * since: v1.49 * langs: - alias-java: matchesAriaSnapshot @@ -2170,12 +2271,12 @@ assertThat(page.locator("body")).matchesAriaSnapshot(""" """); ``` -### param: LocatorAssertions.toMatchAriaSnapshot.expected +### param: LocatorAssertions.toMatchAriaSnapshot#2.expected * since: v1.49 - `expected` -### option: LocatorAssertions.toMatchAriaSnapshot.timeout = %%-js-assertions-timeout-%% +### option: LocatorAssertions.toMatchAriaSnapshot#2.timeout = %%-js-assertions-timeout-%% * since: v1.49 -### option: LocatorAssertions.toMatchAriaSnapshot.timeout = %%-csharp-java-python-assertions-timeout-%% +### option: LocatorAssertions.toMatchAriaSnapshot#2.timeout = %%-csharp-java-python-assertions-timeout-%% * since: v1.49 diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index f65a904932..ec58c027b3 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -2762,10 +2762,6 @@ This method requires Playwright to be started in a headed mode, with a falsy [`o Returns the PDF buffer. -:::note -Generating a pdf is currently only supported in Chromium headless. -::: - `page.pdf()` generates a pdf of the page with `print` css media. To generate a pdf with `screen` media, call [`method: Page.emulateMedia`] before calling `page.pdf()`: @@ -3974,7 +3970,7 @@ This setting will change the default maximum time for all the methods accepting * since: v1.8 - `timeout` <[float]> -Maximum time in milliseconds +Maximum time in milliseconds. Pass `0` to disable timeout. ## async method: Page.setExtraHTTPHeaders * since: v1.8 diff --git a/docs/src/api/params.md b/docs/src/api/params.md index e059fffe46..a1f909a4fb 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -1003,7 +1003,7 @@ Additional arguments to pass to the browser instance. The list of Chromium flags Browser distribution channel. -Use "chromium" to [opt in to new headless mode](../browsers.md#opt-in-to-new-headless-mode). +Use "chromium" to [opt in to new headless mode](../browsers.md#chromium-new-headless-mode). Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or "msedge-canary" to use branded [Google Chrome and Microsoft Edge](../browsers.md#google-chrome--microsoft-edge). diff --git a/docs/src/aria-snapshots.md b/docs/src/aria-snapshots.md index e190fd3022..27d1c0ebce 100644 --- a/docs/src/aria-snapshots.md +++ b/docs/src/aria-snapshots.md @@ -154,7 +154,7 @@ structure of a page, use the [Chrome DevTools Accessibility Pane](https://develo ## Snapshot matching -The [`method: LocatorAssertions.toMatchAriaSnapshot`] assertion method in Playwright compares the accessible +The [`method: LocatorAssertions.toMatchAriaSnapshot#2`] assertion method in Playwright compares the accessible structure of the locator scope with a predefined aria snapshot template, helping validate the page's state against testing requirements. diff --git a/docs/src/browsers.md b/docs/src/browsers.md index 83ea0f60c3..1cc10d7a8d 100644 --- a/docs/src/browsers.md +++ b/docs/src/browsers.md @@ -338,11 +338,11 @@ dotnet test --settings:webkit.runsettings For Google Chrome, Microsoft Edge and other Chromium-based browsers, by default, Playwright uses open source Chromium builds. Since the Chromium project is ahead of the branded browsers, when the world is on Google Chrome N, Playwright already supports Chromium N+1 that will be released in Google Chrome and Microsoft Edge a few weeks later. -Playwright ships a regular Chromium build for headed operations and a separate [chromium headless shell](https://developer.chrome.com/blog/chrome-headless-shell) for headless mode. See [issue #33566](https://github.com/microsoft/playwright/issues/33566) for details. +### Chromium: headless shell -#### Optimize download size on CI +Playwright ships a regular Chromium build for headed operations and a separate [chromium headless shell](https://developer.chrome.com/blog/chrome-headless-shell) for headless mode. -If you are only running tests in headless mode, for example on CI, you can avoid downloading a regular version of Chromium by passing `--only-shell` during installation. +If you are only running tests in headless shell (i.e. the `channel` option is **not** specified), for example on CI, you can avoid downloading the full Chromium browser by passing `--only-shell` during installation. ```bash js # only running tests headlessly @@ -364,7 +364,7 @@ playwright install --with-deps --only-shell pwsh bin/Debug/netX/playwright.ps1 install --with-deps --only-shell ``` -#### Opt-in to new headless mode +### Chromium: new headless mode You can opt into the new headless mode by using `'chromium'` channel. As [official Chrome documentation puts it](https://developer.chrome.com/blog/chrome-headless-shell): @@ -419,6 +419,28 @@ pytest test_login.py --browser-channel chromium dotnet test -- Playwright.BrowserName=chromium Playwright.LaunchOptions.Channel=chromium ``` +With the new headless mode, you can skip downloading the headless shell during browser installation by using the `--no-shell` option: + +```bash js +# only running tests headlessly +npx playwright install --with-deps --no-shell +``` + +```bash java +# only running tests headlessly +mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install --with-deps --no-shell" +``` + +```bash python +# only running tests headlessly +playwright install --with-deps --no-shell +``` + +```bash csharp +# only running tests headlessly +pwsh bin/Debug/netX/playwright.ps1 install --with-deps --no-shell +``` + ### Google Chrome & Microsoft Edge While Playwright can download and use the recent Chromium build, it can operate against the branded Google Chrome and Microsoft Edge browsers available on the machine (note that Playwright doesn't install them by default). In particular, the current Playwright version will support Stable and Beta channels of these browsers. diff --git a/docs/src/chrome-extensions-js-python.md b/docs/src/chrome-extensions-js-python.md index 1142e9b3a7..edbe7c06c9 100644 --- a/docs/src/chrome-extensions-js-python.md +++ b/docs/src/chrome-extensions-js-python.md @@ -214,7 +214,7 @@ def test_popup_page(page: Page, extension_id: str) -> None: ## Headless mode -By default, Chrome's headless mode in Playwright does not support Chrome extensions. To overcome this limitation, you can run Chrome's persistent context with a new headless mode by using [channel `chromium`](./browsers.md#opt-in-to-new-headless-mode): +By default, Chrome's headless mode in Playwright does not support Chrome extensions. To overcome this limitation, you can run Chrome's persistent context with a new headless mode by using [channel `chromium`](./browsers.md#chromium-new-headless-mode): ```js title="fixtures.ts" // ... diff --git a/docs/src/clock.md b/docs/src/clock.md index 2f846e4fd3..44582f450b 100644 --- a/docs/src/clock.md +++ b/docs/src/clock.md @@ -34,6 +34,10 @@ The recommended approach is to use `setFixedTime` to set the time to a specific - `Event.timeStamp` ::: +:::warning +If you call `install` at any point in your test, the call _MUST_ occur before any other clock related calls (see note above for list). Calling these methods out of order will result in undefined behavior. For example, you cannot call `setInterval`, followed by `install`, then `clearInterval`, as `install` overrides the native definition of the clock functions. +::: + ## Test with predefined time Often you only need to fake `Date.now` while keeping the timers going. diff --git a/docs/src/docker.md b/docs/src/docker.md index b2a3e906b9..abb647e35b 100644 --- a/docs/src/docker.md +++ b/docs/src/docker.md @@ -103,6 +103,88 @@ Using `--ipc=host` is recommended when using Chrome ([Docker docs](https://docs. See our [Continuous Integration guides](./ci.md) for sample configs. +### Remote Connection + +You can run Playwright Server in Docker while keeping your tests running on the host system or another machine. This is useful for running tests on unsupported Linux distributions or remote execution scenarios. + +#### Running the Playwright Server + +Start the Playwright Server in Docker: + +```bash +docker run -p 3000:3000 --rm --init -it --workdir /home/pwuser --user pwuser mcr.microsoft.com/playwright:v%%VERSION%%-noble /bin/sh -c "npx -y playwright@%%VERSION%% run-server --port 3000 --host 0.0.0.0" +``` + +#### Connecting to the Server +* langs: js + +There are two ways to connect to the remote Playwright server: + +1. Using environment variable with `@playwright/test`: + +```bash +PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:3000/ npx playwright test +``` + +2. Using the [`method: BrowserType.connect`] API for other applications: + +```js +const browser = await playwright['chromium'].connect('ws://127.0.0.1:3000/'); +``` + +#### Connecting to the Server +* langs: python, csharp, java + +```python sync +from playwright.sync_api import sync_playwright + +with sync_playwright() as p: + browser = p.chromium.connect("ws://127.0.0.1:3000/") +``` + +```python async +from playwright.async_api import async_playwright + +async with async_playwright() as p: + browser = await p.chromium.connect("ws://127.0.0.1:3000/") +``` + +```csharp +using Microsoft.Playwright; + +using var playwright = await Playwright.CreateAsync(); +await using var browser = await playwright.Chromium.ConnectAsync("ws://127.0.0.1:3000/"); +``` + +```java +package org.example; + +import com.microsoft.playwright.*; +import java.nio.file.Paths; + +public class App { + public static void main(String[] args) { + try (Playwright playwright = Playwright.create()) { + Browser browser = playwright.chromium().connect("ws://127.0.0.1:3000/"); + } + } +} +``` + +#### Network Configuration + +If you need to access local servers from within the Docker container: + +```bash +docker run --add-host=hostmachine:host-gateway -p 3000:3000 --rm --init -it --workdir /home/pwuser --user pwuser mcr.microsoft.com/playwright:v%%VERSION%%-noble /bin/sh -c "npx -y playwright@%%VERSION%% run-server --port 3000 --host 0.0.0.0" +``` + +This makes `hostmachine` point to the host's localhost. Your tests should use `hostmachine` instead of `localhost` when accessing local servers. + +:::note +When running tests remotely, ensure the Playwright version in your tests matches the version running in the Docker container. +::: + ## Image tags See [all available image tags]. diff --git a/docs/src/release-notes-csharp.md b/docs/src/release-notes-csharp.md index 7c1ffc87ab..0d2681c874 100644 --- a/docs/src/release-notes-csharp.md +++ b/docs/src/release-notes-csharp.md @@ -9,7 +9,7 @@ toc_max_heading_level: 2 ### Aria snapshots -New assertion [`method: LocatorAssertions.toMatchAriaSnapshot`] verifies page structure by comparing to an expected accessibility tree, represented as YAML. +New assertion [`method: LocatorAssertions.toMatchAriaSnapshot#2`] verifies page structure by comparing to an expected accessibility tree, represented as YAML. ```csharp await page.GotoAsync("https://playwright.dev"); diff --git a/docs/src/release-notes-java.md b/docs/src/release-notes-java.md index 8130c77f07..317c1c5398 100644 --- a/docs/src/release-notes-java.md +++ b/docs/src/release-notes-java.md @@ -8,7 +8,7 @@ toc_max_heading_level: 2 ### Aria snapshots -New assertion [`method: LocatorAssertions.toMatchAriaSnapshot`] verifies page structure by comparing to an expected accessibility tree, represented as YAML. +New assertion [`method: LocatorAssertions.toMatchAriaSnapshot#2`] verifies page structure by comparing to an expected accessibility tree, represented as YAML. ```java page.navigate("https://playwright.dev"); diff --git a/docs/src/release-notes-js.md b/docs/src/release-notes-js.md index 06a0fe3b19..0595933b80 100644 --- a/docs/src/release-notes-js.md +++ b/docs/src/release-notes-js.md @@ -15,7 +15,7 @@ import LiteYouTube from '@site/src/components/LiteYouTube'; ### Aria snapshots -New assertion [`method: LocatorAssertions.toMatchAriaSnapshot`] verifies page structure by comparing to an expected accessibility tree, represented as YAML. +New assertion [`method: LocatorAssertions.toMatchAriaSnapshot#2`] verifies page structure by comparing to an expected accessibility tree, represented as YAML. ```js await page.goto('https://playwright.dev'); diff --git a/docs/src/release-notes-python.md b/docs/src/release-notes-python.md index fc7dd1ccd1..f2002f15fd 100644 --- a/docs/src/release-notes-python.md +++ b/docs/src/release-notes-python.md @@ -8,7 +8,7 @@ toc_max_heading_level: 2 ### Aria snapshots -New assertion [`method: LocatorAssertions.toMatchAriaSnapshot`] verifies page structure by comparing to an expected accessibility tree, represented as YAML. +New assertion [`method: LocatorAssertions.toMatchAriaSnapshot#2`] verifies page structure by comparing to an expected accessibility tree, represented as YAML. ```python page.goto("https://playwright.dev") diff --git a/docs/src/test-api/class-test.md b/docs/src/test-api/class-test.md index 77a11c073f..d6f1d87513 100644 --- a/docs/src/test-api/class-test.md +++ b/docs/src/test-api/class-test.md @@ -1773,6 +1773,112 @@ Specifies a custom location for the step to be shown in test reports and trace v Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout). +## async method: Test.step.fail +* since: v1.50 +- returns: <[void]> + +Marks a test step as "should fail". Playwright runs this test step and ensures that it actually fails. This is useful for documentation purposes to acknowledge that some functionality is broken until it is fixed. + +:::note +If the step exceeds the timeout, a [TimeoutError] is thrown. This indicates the step did not fail as expected. +::: + +**Usage** + +You can declare a test step as failing, so that Playwright ensures it actually fails. + +```js +import { test, expect } from '@playwright/test'; + +test('my test', async ({ page }) => { + // ... + await test.step.fail('currently failing', async () => { + // ... + }); +}); +``` + +### param: Test.step.fail.title +* since: v1.50 +- `title` <[string]> + +Step name. + +### param: Test.step.fail.body +* since: v1.50 +- `body` <[function]\(\):[Promise]<[any]>> + +Step body. + +### option: Test.step.fail.box +* since: v1.50 +- `box` + +Whether to box the step in the report. Defaults to `false`. When the step is boxed, errors thrown from the step internals point to the step call site. See below for more details. + +### option: Test.step.fail.location +* since: v1.50 +- `location` <[Location]> + +Specifies a custom location for the step to be shown in test reports and trace viewer. By default, location of the [`method: Test.step`] call is shown. + +### option: Test.step.fail.timeout +* since: v1.50 +- `timeout` <[float]> + +Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout). + +## async method: Test.step.fixme +* since: v1.50 +- returns: <[void]> + +Mark a test step as "fixme", with the intention to fix it. Playwright will not run the step. + +**Usage** + +You can declare a test step as failing, so that Playwright ensures it actually fails. + +```js +import { test, expect } from '@playwright/test'; + +test('my test', async ({ page }) => { + // ... + await test.step.fixme('not yet ready', async () => { + // ... + }); +}); +``` + +### param: Test.step.fixme.title +* since: v1.50 +- `title` <[string]> + +Step name. + +### param: Test.step.fixme.body +* since: v1.50 +- `body` <[function]\(\):[Promise]<[any]>> + +Step body. + +### option: Test.step.fixme.box +* since: v1.50 +- `box` + +Whether to box the step in the report. Defaults to `false`. When the step is boxed, errors thrown from the step internals point to the step call site. See below for more details. + +### option: Test.step.fixme.location +* since: v1.50 +- `location` <[Location]> + +Specifies a custom location for the step to be shown in test reports and trace viewer. By default, location of the [`method: Test.step`] call is shown. + +### option: Test.step.fixme.timeout +* since: v1.50 +- `timeout` <[float]> + +Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout). + ## method: Test.use * since: v1.10 diff --git a/docs/src/test-api/class-testoptions.md b/docs/src/test-api/class-testoptions.md index 20dad210e7..c285430616 100644 --- a/docs/src/test-api/class-testoptions.md +++ b/docs/src/test-api/class-testoptions.md @@ -392,8 +392,11 @@ export default defineConfig({ }); ``` -## property: TestOptions.locale = %%-context-option-locale-%% +## property: TestOptions.locale * since: v1.10 +- type: <[string]> + +Specify user locale, for example `en-GB`, `de-DE`, etc. Locale will affect `navigator.language` value, `Accept-Language` request header value as well as number and date formatting rules. Defaults to `en-US`. Learn more about emulation in our [emulation guide](../emulation.md#locale--timezone). **Usage** diff --git a/docs/src/test-fixtures-js.md b/docs/src/test-fixtures-js.md index 1d19d0acdb..9bdd4391ad 100644 --- a/docs/src/test-fixtures-js.md +++ b/docs/src/test-fixtures-js.md @@ -695,7 +695,7 @@ test('passes', async ({ database, page, a11y }) => { ## Box fixtures -Usually, custom fixtures are reported as separate steps in in the UI mode, Trace Viewer and various test reports. They also appear in error messages from the test runner. For frequently-used fixtures, this can mean lots of noise. You can stop the fixtures steps from being shown in the UI by "boxing" it. +Usually, custom fixtures are reported as separate steps in the UI mode, Trace Viewer and various test reports. They also appear in error messages from the test runner. For frequently-used fixtures, this can mean lots of noise. You can stop the fixtures steps from being shown in the UI by "boxing" it. ```js import { test as base } from '@playwright/test'; diff --git a/docs/src/test-global-setup-teardown-js.md b/docs/src/test-global-setup-teardown-js.md index 883bdf25d6..04617979ea 100644 --- a/docs/src/test-global-setup-teardown-js.md +++ b/docs/src/test-global-setup-teardown-js.md @@ -129,7 +129,13 @@ You can use the `globalSetup` option in the [configuration file](./test-configur Similarly, use `globalTeardown` to run something once after all the tests. Alternatively, let `globalSetup` return a function that will be used as a global teardown. You can pass data such as port number, authentication tokens, etc. from your global setup to your tests using environment variables. :::note -Using `globalSetup` and `globalTeardown` will not produce traces or artifacts, and options like `headless` or `testIdAttribute` specified in the config file are not applied. If you want to produce traces and artifacts and respect config options, use [project dependencies](#option-1-project-dependencies). +Beware of `globalSetup` and `globalTeardown` caveats: + +- These methods will not produce traces or artifacts unless explictly enabled, as described in [Capturing trace of failures during global setup](#capturing-trace-of-failures-during-global-setup). +- Options sush as `headless` or `testIdAttribute` specified in the config file are not applied, +- An uncaught exception thrown in `globalSetup` will prevent Playwright from running tests, and no test results will appear in reporters. + +Consider using [project dependencies](#option-1-project-dependencies) to produce traces, artifacts, respect config options and get test results in reporters even in case of a setup failure. ::: ```js title="playwright.config.ts" diff --git a/docs/src/test-sharding-js.md b/docs/src/test-sharding-js.md index d4b474ca9b..3b0f55e333 100644 --- a/docs/src/test-sharding-js.md +++ b/docs/src/test-sharding-js.md @@ -37,7 +37,7 @@ When `fullyParallel: true` is enabled, Playwright Test runs individual tests in **Sharding without fullyParallel** -Without the fullyParallel setting, Playwright Test defaults to file-level granularity, meaning entire test files are assigned to shards. In this case, the number of tests per file can greatly influence shard distribution. If your test files are not evenly sized (i.e., some files contain many more tests than others), certain shards may end up running significantly more tests, while others may run fewer or even none. +Without the fullyParallel setting, Playwright Test defaults to file-level granularity, meaning entire test files are assigned to shards (note that the same file may be assigned to different shards across different projects). In this case, the number of tests per file can greatly influence shard distribution. If your test files are not evenly sized (i.e., some files contain many more tests than others), certain shards may end up running significantly more tests, while others may run fewer or even none. **Key Takeaways:** diff --git a/docs/src/test-ui-mode-js.md b/docs/src/test-ui-mode-js.md index 4fb021e6f6..41a00264d9 100644 --- a/docs/src/test-ui-mode-js.md +++ b/docs/src/test-ui-mode-js.md @@ -7,7 +7,7 @@ import LiteYouTube from '@site/src/components/LiteYouTube'; ## Introduction -UI Mode lets you explore, run and debug tests with a time travel experience complete with watch mode. All test files are loaded into the testing sidebar where you can expand each file and describe block to individually run, view, watch and debug each test. Filter tests by **text** or **@tag** or by **passed**, **failed** and **skipped** tests as well as by [**projects**](./test-projects) as set in your `playwright.config` file. See a full trace of your tests and hover back and forward over each action to see what was happening during each step and pop out the DOM snapshot to a separate window for a better debugging experience. +UI Mode lets you explore, run, and debug tests with a time travel experience complete with a watch mode. All test files are displayed in the testing sidebar, allowing you to expand each file and describe block to individually run, view, watch, and debug each test. Filter tests by **name**, [**projects**](./test-projects) (set in your `playwright.config` file), **@tag**, or by the execution status of **passed**, **failed**, and **skipped**. See a full trace of your tests and hover back and forward over each action to see what was happening during each step. You can also pop out the DOM snapshot of a given moment into a separate window for a better debugging experience. -## Trace Viewer features -### Actions +## Opening Trace Viewer -In the Actions tab you can see what locator was used for every action and how long each one took to run. Hover over each action of your test and visually see the change in the DOM snapshot. Go back and forward in time and click an action to inspect and debug. Use the Before and After tabs to visually see what happened before and after the action. +You can open a saved trace using either the Playwright CLI or in the browser at [trace.playwright.dev](https://trace.playwright.dev). Make sure to add the full path to where your `trace.zip` file is located. -![actions tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/948b65cd-f0fd-4c7f-8e53-2c632b5a07f1) +```bash js +npx playwright show-trace path/to/trace.zip +``` -**Selecting each action reveals:** -- action snapshots -- action log -- source code location +```bash java +mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace trace.zip" +``` -### Screenshots +```bash python +playwright show-trace trace.zip +``` -When tracing with the [`option: Tracing.start.screenshots`] option turned on (default), each trace records a screencast and renders it as a film strip. You can hover over the film strip to see a magnified image of for each action and state which helps you easily find the action you want to inspect. +```bash csharp +pwsh bin/Debug/netX/playwright.ps1 show-trace trace.zip +``` -Double click on an action to see the time range for that action. You can use the slider in the timeline to increase the actions selected and these will be shown in the Actions tab and all console logs and network logs will be filtered to only show the logs for the actions selected. +### Using [trace.playwright.dev](https://trace.playwright.dev) -![timeline view in trace viewer](https://github.com/microsoft/playwright/assets/13063165/b04a7d75-54bb-4ab2-9e30-e76f6f74a2c8) +[trace.playwright.dev](https://trace.playwright.dev) is a statically hosted variant of the Trace Viewer. You can upload trace files using drag and drop or via the `Select file(s)` button. +Trace Viewer loads the trace entirely in your browser and does not transmit any data externally. -### Snapshots +Drop Playwright Trace to load -When tracing with the [`option: Tracing.start.snapshots`] option turned on (default), Playwright captures a set of complete DOM snapshots for each action. Depending on the type of the action, it will capture: +### Viewing remote traces -| Type | Description | -|------|-------------| -|Before|A snapshot at the time action is called.| -|Action|A snapshot at the moment of the performed input. This type of snapshot is especially useful when exploring where exactly Playwright clicked.| -|After|A snapshot after the action.| +You can open remote traces directly using its URL. This makes it easy to view the remote trace without having to manually download the file from CI runs, for example. -Here is what the typical Action snapshot looks like: +```bash js +npx playwright show-trace https://example.com/trace.zip +``` -![action tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/7168d549-eb0a-4964-9c93-483f03711fa9) +```bash java +mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace https://example.com/trace.zip" +``` -Notice how it highlights both, the DOM Node as well as the exact click position. +```bash python +playwright show-trace https://example.com/trace.zip +``` -### Source +```bash csharp +pwsh bin/Debug/netX/playwright.ps1 show-trace https://example.com/trace.zip +``` -When you click on an action in the sidebar, the line of code for that action is highlighted in the source panel. +When using [trace.playwright.dev](https://trace.playwright.dev), you can also pass the URL of your uploaded trace at some accessible storage (e.g. inside your CI) as a query parameter. CORS (Cross-Origin Resource Sharing) rules might apply. -![showing source code tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/daa8845d-c250-4923-aa7a-5d040da9adc5) +```txt +https://trace.playwright.dev/?trace=https://demo.playwright.dev/reports/todomvc/data/cb0fa77ebd9487a5c899f3ae65a7ffdbac681182.zip +``` -### Call - -The call tab shows you information about the action such as the time it took, what locator was used, if in strict mode and what key was used. - -![showing call tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/95498580-f9dd-4932-a123-c37fe7cfc3c2) - -### Log - -See a full log of your test to better understand what Playwright is doing behind the scenes such as scrolling into view, waiting for element to be visible, enabled and stable and performing actions such as click, fill, press etc. - -![showing log of tests in trace viewer](https://github.com/microsoft/playwright/assets/13063165/de621461-3bab-4140-b39d-9f02d6672dbf) - -### Errors - -If your test fails you will see the error messages for each test in the Errors tab. The timeline will also show a red line highlighting where the error occurred. You can also click on the source tab to see on which line of the source code the error is. - -![showing errors in trace viewer](https://github.com/microsoft/playwright/assets/13063165/e9ef77b3-05d1-4df2-852c-981023723d34) - -### Console - -See console logs from the browser as well as from your test. Different icons are displayed to show you if the console log came from the browser or from the test file. - -![showing log of tests in trace viewer](https://github.com/microsoft/playwright/assets/13063165/4107c08d-1eaf-421c-bdd4-9dd2aa641d4a) - -Double click on an action from your test in the actions sidebar. This will filter the console to only show the logs that were made during that action. Click the *Show all* button to see all console logs again. - -Use the timeline to filter actions, by clicking a start point and dragging to an ending point. The console tab will also be filtered to only show the logs that were made during the actions selected. - - -### Network - -The Network tab shows you all the network requests that were made during your test. You can sort by different types of requests, status code, method, request, content type, duration and size. Click on a request to see more information about it such as the request headers, response headers, request body and response body. - -![network requests tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/0a3d1671-8ccd-4f7a-a844-35f5eb37f236) - -Double click on an action from your test in the actions sidebar. This will filter the network requests to only show the requests that were made during that action. Click the *Show all* button to see all network requests again. - -Use the timeline to filter actions, by clicking a start point and dragging to an ending point. The network tab will also be filtered to only show the network requests that were made during the actions selected. - -### Metadata - -Next to the Actions tab you will find the Metadata tab which will show you more information on your test such as the Browser, viewport size, test duration and more. - -![meta data in trace viewer](https://github.com/microsoft/playwright/assets/13063165/82ab3d33-1ec9-4b8a-9cf2-30a6e2d59091) - -### Attachments +## Recording a trace * langs: js -The "Attachments" tab allows you to explore attachments. If you're doing [visual regression testing](./test-snapshots.md), you'll be able to compare screenshots by examining the image diff, the actual image and the expected image. When you click on the expected image you can use the slider to slide one image over the other so you can easily see the differences in your screenshots. - -![attachments tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/4386178a-5808-4fa8-9436-315350a23b04) - - -## Recording a trace locally +### Tracing locally * langs: js -To record a trace during development mode set the `--trace` flag to `on` when running your tests. You can also use [UI Mode](./test-ui-mode.md) for a better developer experience. +To record a trace during development mode set the `--trace` flag to `on` when running your tests. You can also use [UI Mode](./test-ui-mode.md) for a better developer experience, as it traces each test automatically. ```bash npx playwright test --trace on @@ -126,7 +87,7 @@ You can then open the HTML report and click on the trace icon to open the trace. ```bash npx playwright show-report ``` -## Recording a trace on CI +### Tracing on CI * langs: js Traces should be run on continuous integration on the first retry of a failed test @@ -592,57 +553,98 @@ public class WithTestNameAttribute : BeforeAfterTestAttribute -## Opening the trace +## Trace Viewer features +### Actions -You can open the saved trace using the Playwright CLI or in your browser on [`trace.playwright.dev`](https://trace.playwright.dev). Make sure to add the full path to where your `trace.zip` file is located. +In the Actions tab you can see what locator was used for every action and how long each one took to run. Hover over each action of your test and visually see the change in the DOM snapshot. Go back and forward in time and click an action to inspect and debug. Use the Before and After tabs to visually see what happened before and after the action. -```bash js -npx playwright show-trace path/to/trace.zip -``` +![actions tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/948b65cd-f0fd-4c7f-8e53-2c632b5a07f1) -```bash java -mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace trace.zip" -``` +**Selecting each action reveals:** +- Action snapshots +- Action log +- Source code location -```bash python -playwright show-trace trace.zip -``` +### Screenshots -```bash csharp -pwsh bin/Debug/netX/playwright.ps1 show-trace trace.zip -``` +When tracing with the [`option: Tracing.start.screenshots`] option turned on (default), each trace records a screencast and renders it as a film strip. You can hover over the film strip to see a magnified image of for each action and state which helps you easily find the action you want to inspect. -## Using [trace.playwright.dev](https://trace.playwright.dev) +Double click on an action to see the time range for that action. You can use the slider in the timeline to increase the actions selected and these will be shown in the Actions tab and all console logs and network logs will be filtered to only show the logs for the actions selected. -[trace.playwright.dev](https://trace.playwright.dev) is a statically hosted variant of the Trace Viewer. You can upload trace files using drag and drop. +![timeline view in trace viewer](https://github.com/microsoft/playwright/assets/13063165/b04a7d75-54bb-4ab2-9e30-e76f6f74a2c8) -Drop Playwright Trace to load +### Snapshots -## Viewing remote traces +When tracing with the [`option: Tracing.start.snapshots`] option turned on (default), Playwright captures a set of complete DOM snapshots for each action. Depending on the type of the action, it will capture: -You can open remote traces using its URL. They could be generated on a CI run which makes it easy to view the remote trace without having to manually download the file. +| Type | Description | +|------|-------------| +|Before|A snapshot at the time action is called.| +|Action|A snapshot at the moment of the performed input. This type of snapshot is especially useful when exploring where exactly Playwright clicked.| +|After|A snapshot after the action.| -```bash js -npx playwright show-trace https://example.com/trace.zip -``` +Here is what the typical Action snapshot looks like: -```bash java -mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace https://example.com/trace.zip" -``` +![action tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/7168d549-eb0a-4964-9c93-483f03711fa9) -```bash python -playwright show-trace https://example.com/trace.zip -``` +Notice how it highlights both, the DOM Node as well as the exact click position. -```bash csharp -pwsh bin/Debug/netX/playwright.ps1 show-trace https://example.com/trace.zip -``` +### Source + +When you click on an action in the sidebar, the line of code for that action is highlighted in the source panel. + +![showing source code tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/daa8845d-c250-4923-aa7a-5d040da9adc5) + +### Call + +The call tab shows you information about the action such as the time it took, what locator was used, if in strict mode and what key was used. + +![showing call tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/95498580-f9dd-4932-a123-c37fe7cfc3c2) + +### Log + +See a full log of your test to better understand what Playwright is doing behind the scenes such as scrolling into view, waiting for element to be visible, enabled and stable and performing actions such as click, fill, press etc. + +![showing log of tests in trace viewer](https://github.com/microsoft/playwright/assets/13063165/de621461-3bab-4140-b39d-9f02d6672dbf) + +### Errors + +If your test fails you will see the error messages for each test in the Errors tab. The timeline will also show a red line highlighting where the error occurred. You can also click on the source tab to see on which line of the source code the error is. + +![showing errors in trace viewer](https://github.com/microsoft/playwright/assets/13063165/e9ef77b3-05d1-4df2-852c-981023723d34) + +### Console + +See console logs from the browser as well as from your test. Different icons are displayed to show you if the console log came from the browser or from the test file. + +![showing log of tests in trace viewer](https://github.com/microsoft/playwright/assets/13063165/4107c08d-1eaf-421c-bdd4-9dd2aa641d4a) + +Double click on an action from your test in the actions sidebar. This will filter the console to only show the logs that were made during that action. Click the *Show all* button to see all console logs again. + +Use the timeline to filter actions, by clicking a start point and dragging to an ending point. The console tab will also be filtered to only show the logs that were made during the actions selected. -You can also pass the URL of your uploaded trace (e.g. inside your CI) from some accessible storage as a parameter. CORS (Cross-Origin Resource Sharing) rules might apply. +### Network -```txt -https://trace.playwright.dev/?trace=https://demo.playwright.dev/reports/todomvc/data/cb0fa77ebd9487a5c899f3ae65a7ffdbac681182.zip -``` +The Network tab shows you all the network requests that were made during your test. You can sort by different types of requests, status code, method, request, content type, duration and size. Click on a request to see more information about it such as the request headers, response headers, request body and response body. + +![network requests tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/0a3d1671-8ccd-4f7a-a844-35f5eb37f236) + +Double click on an action from your test in the actions sidebar. This will filter the network requests to only show the requests that were made during that action. Click the *Show all* button to see all network requests again. + +Use the timeline to filter actions, by clicking a start point and dragging to an ending point. The network tab will also be filtered to only show the network requests that were made during the actions selected. + +### Metadata + +Next to the Actions tab you will find the Metadata tab which will show you more information on your test such as the Browser, viewport size, test duration and more. + +![meta data in trace viewer](https://github.com/microsoft/playwright/assets/13063165/82ab3d33-1ec9-4b8a-9cf2-30a6e2d59091) + +### Attachments +* langs: js + +The "Attachments" tab allows you to explore attachments. If you're doing [visual regression testing](./test-snapshots.md), you'll be able to compare screenshots by examining the image diff, the actual image and the expected image. When you click on the expected image you can use the slider to slide one image over the other so you can easily see the differences in your screenshots. + +![attachments tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/4386178a-5808-4fa8-9436-315350a23b04) diff --git a/package-lock.json b/package-lock.json index 6a8526b8c2..0c4d97edad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "@types/codemirror": "^5.60.7", "@types/formidable": "^2.0.4", "@types/immutable": "^3.8.7", - "@types/node": "^18.19.39", + "@types/node": "^18.19.68", "@types/react": "^18.0.12", "@types/react-dom": "^18.0.5", "@types/ws": "^8.5.3", @@ -60,7 +60,7 @@ "react": "^18.1.0", "react-dom": "^18.1.0", "ssim.js": "^3.5.0", - "typescript": "^5.5.3", + "typescript": "^5.7.2", "vite": "^5.4.6", "ws": "^8.17.1", "xml2js": "^0.5.0", @@ -397,17 +397,19 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", - "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -447,9 +449,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", - "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", + "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.3" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -810,13 +816,13 @@ } }, "node_modules/@babel/types": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", - "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", + "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1409,9 +1415,10 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.21", @@ -1851,10 +1858,11 @@ } }, "node_modules/@types/node": { - "version": "18.19.39", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.39.tgz", - "integrity": "sha512-nPwTRDKUctxw3di5b4TfT3I0sWDiWoPQCZjXhvdkINntwr8lcoVCKsTgnXeRubKIlfnV+eN/HYk6Jb40tbcEAQ==", + "version": "18.19.68", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.68.tgz", + "integrity": "sha512-QGtpFH1vB99ZmTa63K4/FU8twThj4fuVSBkGddTp7uIL/cuoLWIUSL2RcOaigBhfR+hg5pgGkBnkoOxrTVBMKw==", "devOptional": true, + "license": "MIT", "dependencies": { "undici-types": "~5.26.4" } @@ -2184,126 +2192,126 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.15.tgz", - "integrity": "sha512-XcJQVOaxTKCnth1vCxEChteGuwG6wqnUHxAm1DO3gCz0+uXKaJNx8/digSz4dLALCy8n2lKq24jSUs8segoqIw==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz", + "integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==", + "license": "MIT", "peer": true, "dependencies": { - "@babel/parser": "^7.23.6", - "@vue/shared": "3.4.15", + "@babel/parser": "^7.25.3", + "@vue/shared": "3.5.13", "entities": "^4.5.0", "estree-walker": "^2.0.2", - "source-map-js": "^1.0.2" - } - }, - "node_modules/@vue/compiler-core/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "peer": true, - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "source-map-js": "^1.2.0" } }, "node_modules/@vue/compiler-core/node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT", "peer": true }, "node_modules/@vue/compiler-dom": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.15.tgz", - "integrity": "sha512-wox0aasVV74zoXyblarOM3AZQz/Z+OunYcIHe1OsGclCHt8RsRm04DObjefaI82u6XDzv+qGWZ24tIsRAIi5MQ==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz", + "integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==", + "license": "MIT", "peer": true, "dependencies": { - "@vue/compiler-core": "3.4.15", - "@vue/shared": "3.4.15" + "@vue/compiler-core": "3.5.13", + "@vue/shared": "3.5.13" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.15.tgz", - "integrity": "sha512-LCn5M6QpkpFsh3GQvs2mJUOAlBQcCco8D60Bcqmf3O3w5a+KWS5GvYbrrJBkgvL1BDnTp+e8q0lXCLgHhKguBA==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz", + "integrity": "sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==", + "license": "MIT", "peer": true, "dependencies": { - "@babel/parser": "^7.23.6", - "@vue/compiler-core": "3.4.15", - "@vue/compiler-dom": "3.4.15", - "@vue/compiler-ssr": "3.4.15", - "@vue/shared": "3.4.15", + "@babel/parser": "^7.25.3", + "@vue/compiler-core": "3.5.13", + "@vue/compiler-dom": "3.5.13", + "@vue/compiler-ssr": "3.5.13", + "@vue/shared": "3.5.13", "estree-walker": "^2.0.2", - "magic-string": "^0.30.5", - "postcss": "^8.4.33", - "source-map-js": "^1.0.2" + "magic-string": "^0.30.11", + "postcss": "^8.4.48", + "source-map-js": "^1.2.0" } }, "node_modules/@vue/compiler-sfc/node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT", "peer": true }, "node_modules/@vue/compiler-ssr": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.15.tgz", - "integrity": "sha512-1jdeQyiGznr8gjFDadVmOJqZiLNSsMa5ZgqavkPZ8O2wjHv0tVuAEsw5hTdUoUW4232vpBbL/wJhzVW/JwY1Uw==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz", + "integrity": "sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==", + "license": "MIT", "peer": true, "dependencies": { - "@vue/compiler-dom": "3.4.15", - "@vue/shared": "3.4.15" + "@vue/compiler-dom": "3.5.13", + "@vue/shared": "3.5.13" } }, "node_modules/@vue/reactivity": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.15.tgz", - "integrity": "sha512-55yJh2bsff20K5O84MxSvXKPHHt17I2EomHznvFiJCAZpJTNW8IuLj1xZWMLELRhBK3kkFV/1ErZGHJfah7i7w==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.13.tgz", + "integrity": "sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==", + "license": "MIT", "peer": true, "dependencies": { - "@vue/shared": "3.4.15" + "@vue/shared": "3.5.13" } }, "node_modules/@vue/runtime-core": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.15.tgz", - "integrity": "sha512-6E3by5m6v1AkW0McCeAyhHTw+3y17YCOKG0U0HDKDscV4Hs0kgNT5G+GCHak16jKgcCDHpI9xe5NKb8sdLCLdw==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.13.tgz", + "integrity": "sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==", + "license": "MIT", "peer": true, "dependencies": { - "@vue/reactivity": "3.4.15", - "@vue/shared": "3.4.15" + "@vue/reactivity": "3.5.13", + "@vue/shared": "3.5.13" } }, "node_modules/@vue/runtime-dom": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.15.tgz", - "integrity": "sha512-EVW8D6vfFVq3V/yDKNPBFkZKGMFSvZrUQmx196o/v2tHKdwWdiZjYUBS+0Ez3+ohRyF8Njwy/6FH5gYJ75liUw==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz", + "integrity": "sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==", + "license": "MIT", "peer": true, "dependencies": { - "@vue/runtime-core": "3.4.15", - "@vue/shared": "3.4.15", + "@vue/reactivity": "3.5.13", + "@vue/runtime-core": "3.5.13", + "@vue/shared": "3.5.13", "csstype": "^3.1.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.15.tgz", - "integrity": "sha512-3HYzaidu9cHjrT+qGUuDhFYvF/j643bHC6uUN9BgM11DVy+pM6ATsG6uPBLnkwOgs7BpJABReLmpL3ZPAsUaqw==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.13.tgz", + "integrity": "sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==", + "license": "MIT", "peer": true, "dependencies": { - "@vue/compiler-ssr": "3.4.15", - "@vue/shared": "3.4.15" + "@vue/compiler-ssr": "3.5.13", + "@vue/shared": "3.5.13" }, "peerDependencies": { - "vue": "3.4.15" + "vue": "3.5.13" } }, "node_modules/@vue/shared": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.15.tgz", - "integrity": "sha512-KzfPTxVaWfB+eGcGdbSf4CWdaXcGDqckoeXUh7SB3fZdEtzPCK2Vq9B/lRRL3yutax/LWITz+SwvgyOxz5V75g==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", + "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==", + "license": "MIT", "peer": true }, "node_modules/@zip.js/zip.js": { @@ -3326,6 +3334,19 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -5236,14 +5257,12 @@ } }, "node_modules/magic-string": { - "version": "0.30.5", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", - "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", + "version": "0.30.15", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.15.tgz", + "integrity": "sha512-zXeaYRgZ6ldS1RJJUrMrYgNJ4fdwnyI6tVqoiIhyCyv5IVTK9BU8Ic2l253GGETQHxI4HNUwhJ3fjDhKqEoaAw==", + "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "engines": { - "node": ">=12" + "@jridgewell/sourcemap-codec": "^1.5.0" } }, "node_modules/make-dir": { @@ -5396,15 +5415,16 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -5772,9 +5792,10 @@ } }, "node_modules/picocolors": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", - "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -5827,9 +5848,9 @@ } }, "node_modules/postcss": { - "version": "8.4.47", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", - "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", "funding": [ { "type": "opencollective", @@ -5844,9 +5865,10 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.1.0", + "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, "engines": { @@ -6720,14 +6742,6 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "engines": { - "node": ">=4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6888,10 +6902,11 @@ } }, "node_modules/typescript": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", - "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "devOptional": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7450,16 +7465,17 @@ } }, "node_modules/vue": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.15.tgz", - "integrity": "sha512-jC0GH4KkWLWJOEQjOpkqU1bQsBwf4R1rsFtw5GQJbjHVKWDzO6P0nWWBTmjp1xSemAioDFj1jdaK1qa3DnMQoQ==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz", + "integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==", + "license": "MIT", "peer": true, "dependencies": { - "@vue/compiler-dom": "3.4.15", - "@vue/compiler-sfc": "3.4.15", - "@vue/runtime-dom": "3.4.15", - "@vue/server-renderer": "3.4.15", - "@vue/shared": "3.4.15" + "@vue/compiler-dom": "3.5.13", + "@vue/compiler-sfc": "3.5.13", + "@vue/runtime-dom": "3.5.13", + "@vue/server-renderer": "3.5.13", + "@vue/shared": "3.5.13" }, "peerDependencies": { "typescript": "*" diff --git a/package.json b/package.json index f36e9039e3..5eafd4d805 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "@types/codemirror": "^5.60.7", "@types/formidable": "^2.0.4", "@types/immutable": "^3.8.7", - "@types/node": "^18.19.39", + "@types/node": "^18.19.68", "@types/react": "^18.0.12", "@types/react-dom": "^18.0.5", "@types/ws": "^8.5.3", @@ -99,7 +99,7 @@ "react": "^18.1.0", "react-dom": "^18.1.0", "ssim.js": "^3.5.0", - "typescript": "^5.5.3", + "typescript": "^5.7.2", "vite": "^5.4.6", "ws": "^8.17.1", "xml2js": "^0.5.0", diff --git a/packages/html-reporter/bundle.ts b/packages/html-reporter/bundle.ts index 4c6bc02632..63530cfaad 100644 --- a/packages/html-reporter/bundle.ts +++ b/packages/html-reporter/bundle.ts @@ -51,4 +51,4 @@ export function bundle(): Plugin { } }, }; -} \ No newline at end of file +} diff --git a/packages/html-reporter/package.json b/packages/html-reporter/package.json index c1beb75b89..0b5ad5fb24 100644 --- a/packages/html-reporter/package.json +++ b/packages/html-reporter/package.json @@ -2,10 +2,5 @@ "name": "html-reporter", "private": true, "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build && tsc", - "preview": "vite preview" - } + "type": "module" } diff --git a/packages/html-reporter/src/chip.tsx b/packages/html-reporter/src/chip.tsx index f94dcbc6d6..cdd07777a6 100644 --- a/packages/html-reporter/src/chip.tsx +++ b/packages/html-reporter/src/chip.tsx @@ -20,7 +20,7 @@ import './colors.css'; import './common.css'; import * as icons from './icons'; import { clsx } from '@web/uiUtils'; -import { useAnchor } from './links'; +import { type AnchorID, useAnchor } from './links'; export const Chip: React.FC<{ header: JSX.Element | string, @@ -53,7 +53,7 @@ export const AutoChip: React.FC<{ noInsets?: boolean, children?: any, dataTestId?: string, - revealOnAnchorId?: string, + revealOnAnchorId?: AnchorID, }> = ({ header, initialExpanded, noInsets, children, dataTestId, revealOnAnchorId }) => { const [expanded, setExpanded] = React.useState(initialExpanded ?? true); const onReveal = React.useCallback(() => setExpanded(true), []); diff --git a/packages/html-reporter/src/headerView.spec.tsx b/packages/html-reporter/src/headerView.spec.tsx index f783a33c1d..202cd5fa7b 100644 --- a/packages/html-reporter/src/headerView.spec.tsx +++ b/packages/html-reporter/src/headerView.spec.tsx @@ -64,6 +64,6 @@ test('should toggle filters', async ({ page, mount }) => { await expect(page).toHaveURL(/#\?q=s:flaky/); await component.locator('a', { hasText: 'Skipped' }).click(); await expect(page).toHaveURL(/#\?q=s:skipped/); - await component.getByRole('searchbox').fill('annot:annotation type=annotation description'); + await component.getByRole('textbox').fill('annot:annotation type=annotation description'); expect(filters).toEqual(['', 's:passed', 's:failed', 's:flaky', 's:skipped', 'annot:annotation type=annotation description']); }); diff --git a/packages/html-reporter/src/headerView.tsx b/packages/html-reporter/src/headerView.tsx index 01f92f1ecb..8384f9e31d 100644 --- a/packages/html-reporter/src/headerView.tsx +++ b/packages/html-reporter/src/headerView.tsx @@ -49,12 +49,14 @@ export const HeaderView: React.FC { event.preventDefault(); - navigate(`#?q=${filterText ? encodeURIComponent(filterText) : ''}`); + const url = new URL(window.location.href); + url.hash = filterText ? '?' + new URLSearchParams({ q: filterText }) : ''; + navigate(url); } }> {icons.search()} {/* Use navigationId to reset defaultValue */} - { + { setFilterText(e.target.value); }}> diff --git a/packages/html-reporter/src/index.tsx b/packages/html-reporter/src/index.tsx index 16c1ae1a88..4ae3b02591 100644 --- a/packages/html-reporter/src/index.tsx +++ b/packages/html-reporter/src/index.tsx @@ -15,7 +15,7 @@ */ import type { HTMLReport } from './types'; -import type zip from '@zip.js/zip.js'; +import type * as zip from '@zip.js/zip.js'; // @ts-ignore import * as zipImport from '@zip.js/zip.js/lib/zip-no-worker-inflate.js'; import * as React from 'react'; diff --git a/packages/html-reporter/src/links.tsx b/packages/html-reporter/src/links.tsx index 4beb7b65e0..b8db4c0e9e 100644 --- a/packages/html-reporter/src/links.tsx +++ b/packages/html-reporter/src/links.tsx @@ -14,7 +14,7 @@ limitations under the License. */ -import type { TestAttachment } from './types'; +import type { TestAttachment, TestCase, TestCaseSummary, TestResult, TestResultSummary } from './types'; import * as React from 'react'; import * as icons from './icons'; import { TreeItem } from './treeItem'; @@ -23,7 +23,7 @@ import './links.css'; import { linkifyText } from '@web/renderUtils'; import { clsx } from '@web/uiUtils'; -export function navigate(href: string) { +export function navigate(href: string | URL) { window.history.pushState({}, '', href); const navEvent = new PopStateEvent('popstate'); window.dispatchEvent(navEvent); @@ -72,6 +72,7 @@ export const AttachmentLink: React.FunctionComponent<{ linkName?: string, openInNewTab?: boolean, }> = ({ attachment, href, linkName, openInNewTab }) => { + const isAnchored = useIsAnchored('attachment-' + attachment.name); return {attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()} {attachment.path && {linkName || attachment.name}} @@ -82,7 +83,7 @@ export const AttachmentLink: React.FunctionComponent<{ )} } loadChildren={attachment.body ? () => { return [
{linkifyText(attachment.body!)}
]; - } : undefined} depth={0} style={{ lineHeight: '32px' }}>
; + } : undefined} depth={0} style={{ lineHeight: '32px' }} selected={isAnchored}>; }; export const SearchParamsContext = React.createContext(new URLSearchParams(window.location.hash.slice(1))); @@ -114,31 +115,48 @@ export function generateTraceUrl(traces: TestAttachment[]) { const kMissingContentType = 'x-playwright/missing'; -type AnchorID = string | ((id: string | null) => boolean) | undefined; +export type AnchorID = string | string[] | ((id: string) => boolean) | undefined; export function useAnchor(id: AnchorID, onReveal: () => void) { + const searchParams = React.useContext(SearchParamsContext); + const isAnchored = useIsAnchored(id); React.useEffect(() => { - if (typeof id === 'undefined') - return; + if (isAnchored) + onReveal(); + }, [isAnchored, onReveal, searchParams]); +} - const listener = () => { - const params = new URLSearchParams(window.location.hash.slice(1)); - const anchor = params.get('anchor'); - const isRevealed = typeof id === 'function' ? id(anchor) : anchor === id; - if (isRevealed) - onReveal(); - }; - window.addEventListener('popstate', listener); - return () => window.removeEventListener('popstate', listener); - }, [id, onReveal]); +export function useIsAnchored(id: AnchorID) { + const searchParams = React.useContext(SearchParamsContext); + const anchor = searchParams.get('anchor'); + if (anchor === null) + return false; + if (typeof id === 'undefined') + return false; + if (typeof id === 'string') + return id === anchor; + if (Array.isArray(id)) + return id.includes(anchor); + return id(anchor); } export function Anchor({ id, children }: React.PropsWithChildren<{ id: AnchorID }>) { const ref = React.useRef(null); const onAnchorReveal = React.useCallback(() => { - requestAnimationFrame(() => ref.current?.scrollIntoView({ block: 'start', inline: 'start' })); + ref.current?.scrollIntoView({ block: 'start', inline: 'start' }); }, []); useAnchor(id, onAnchorReveal); return
{children}
; } + +export function testResultHref({ test, result, anchor }: { test?: TestCase | TestCaseSummary, result?: TestResult | TestResultSummary, anchor?: string }) { + const params = new URLSearchParams(); + if (test) + params.set('testId', test.testId); + if (test && result) + params.set('run', '' + test.results.indexOf(result as any)); + if (anchor) + params.set('anchor', anchor); + return `#?` + params; +} diff --git a/packages/html-reporter/src/testCaseView.tsx b/packages/html-reporter/src/testCaseView.tsx index 4e9785ad8a..e4ffa9c15b 100644 --- a/packages/html-reporter/src/testCaseView.tsx +++ b/packages/html-reporter/src/testCaseView.tsx @@ -19,7 +19,7 @@ import * as React from 'react'; import { TabbedPane } from './tabbedPane'; import { AutoChip } from './chip'; import './common.css'; -import { Link, ProjectLink, SearchParamsContext } from './links'; +import { Link, ProjectLink, SearchParamsContext, testResultHref } from './links'; import { statusIcon } from './statusIcon'; import './testCaseView.css'; import { TestResultView } from './testResultView'; @@ -53,9 +53,9 @@ export const TestCaseView: React.FC<{ {test &&
{test.path.join(' › ')}
-
« previous
+
« previous
-
next »
+
next »
} {test &&
{test?.title}
} {test &&
diff --git a/packages/html-reporter/src/testFileView.tsx b/packages/html-reporter/src/testFileView.tsx index 6b31d2ebe2..f8fad1d646 100644 --- a/packages/html-reporter/src/testFileView.tsx +++ b/packages/html-reporter/src/testFileView.tsx @@ -19,7 +19,7 @@ import * as React from 'react'; import { hashStringToInt, msToString } from './utils'; import { Chip } from './chip'; import { filterWithToken } from './filter'; -import { generateTraceUrl, Link, navigate, ProjectLink, SearchParamsContext } from './links'; +import { generateTraceUrl, Link, navigate, ProjectLink, SearchParamsContext, testResultHref } from './links'; import { statusIcon } from './statusIcon'; import './testFileView.css'; import { video, image, trace } from './icons'; @@ -48,7 +48,7 @@ export const TestFileView: React.FC - + {[...test.path, test.title].join(' › ')} {projectNames.length > 1 && !!test.projectName && @@ -59,7 +59,7 @@ export const TestFileView: React.FC{msToString(test.duration)}
- + {test.location.file}:{test.location.line} {imageDiffBadge(test)} @@ -72,15 +72,17 @@ export const TestFileView: React.FC result.attachments.some(attachment => { - return attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/); - })); - return resultWithImageDiff ? {image()} : undefined; + for (const result of test.results) { + for (const attachment of result.attachments) { + if (attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/)) + return {image()}; + } + } } function videoBadge(test: TestCaseSummary): JSX.Element | undefined { const resultWithVideo = test.results.find(result => result.attachments.some(attachment => attachment.name === 'video')); - return resultWithVideo ? {video()} : undefined; + return resultWithVideo ? {video()} : undefined; } function traceBadge(test: TestCaseSummary): JSX.Element | undefined { diff --git a/packages/html-reporter/src/testResultView.tsx b/packages/html-reporter/src/testResultView.tsx index 9170f2023d..410677cb02 100644 --- a/packages/html-reporter/src/testResultView.tsx +++ b/packages/html-reporter/src/testResultView.tsx @@ -20,15 +20,20 @@ import { TreeItem } from './treeItem'; import { msToString } from './utils'; import { AutoChip } from './chip'; import { traceImage } from './images'; -import { Anchor, AttachmentLink, generateTraceUrl } from './links'; +import { Anchor, AttachmentLink, generateTraceUrl, testResultHref } from './links'; import { statusIcon } from './statusIcon'; import type { ImageDiff } from '@web/shared/imageDiffView'; import { ImageDiffView } from '@web/shared/imageDiffView'; import { TestErrorView, TestScreenshotErrorView } from './testErrorView'; +import * as icons from './icons'; import './testResultView.css'; -function groupImageDiffs(screenshots: Set): ImageDiff[] { - const snapshotNameToImageDiff = new Map(); +interface ImageDiffWithAnchors extends ImageDiff { + anchors: string[]; +} + +function groupImageDiffs(screenshots: Set): ImageDiffWithAnchors[] { + const snapshotNameToImageDiff = new Map(); for (const attachment of screenshots) { const match = attachment.name.match(/^(.*)-(expected|actual|diff|previous)(\.[^.]+)?$/); if (!match) @@ -37,9 +42,10 @@ function groupImageDiffs(screenshots: Set): ImageDiff[] { const snapshotName = name + extension; let imageDiff = snapshotNameToImageDiff.get(snapshotName); if (!imageDiff) { - imageDiff = { name: snapshotName }; + imageDiff = { name: snapshotName, anchors: [`attachment-${name}`] }; snapshotNameToImageDiff.set(snapshotName, imageDiff); } + imageDiff.anchors.push(`attachment-${attachment.name}`); if (category === 'actual') imageDiff.actual = { attachment }; if (category === 'expected') @@ -64,18 +70,19 @@ function groupImageDiffs(screenshots: Set): ImageDiff[] { export const TestResultView: React.FC<{ test: TestCase, result: TestResult, -}> = ({ result }) => { - const { screenshots, videos, traces, otherAttachments, diffs, errors, htmls } = React.useMemo(() => { +}> = ({ test, result }) => { + const { screenshots, videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors } = React.useMemo(() => { const attachments = result?.attachments || []; const screenshots = new Set(attachments.filter(a => a.contentType.startsWith('image/'))); + const screenshotAnchors = [...screenshots].map(a => `attachment-${a.name}`); const videos = attachments.filter(a => a.contentType.startsWith('video/')); const traces = attachments.filter(a => a.name === 'trace'); - const htmls = attachments.filter(a => a.contentType.startsWith('text/html')); const otherAttachments = new Set(attachments); - [...screenshots, ...videos, ...traces, ...htmls].forEach(a => otherAttachments.delete(a)); + [...screenshots, ...videos, ...traces].forEach(a => otherAttachments.delete(a)); + const otherAttachmentAnchors = [...otherAttachments].map(a => `attachment-${a.name}`); const diffs = groupImageDiffs(screenshots); const errors = classifyErrors(result.errors, diffs); - return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, htmls }; + return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors }; }, [result]); return
@@ -87,29 +94,29 @@ export const TestResultView: React.FC<{ })} } {!!result.steps.length && - {result.steps.map((step, i) => )} + {result.steps.map((step, i) => )} } {diffs.map((diff, index) => - - + + )} - {!!screenshots.length && + {!!screenshots.length && {screenshots.map((a, i) => { - return
+ return -
; +
; })} } - {!!traces.length && + {!!traces.length && {} } - {!!videos.length && + {!!videos.length && {videos.map((a, i) =>
)}
} - {!!(otherAttachments.size + htmls.length) && - {[...htmls].map((a, i) => ( - ) + {!!otherAttachments.size && + {[...otherAttachments].map((a, i) => + + + )} - {[...otherAttachments].map((a, i) => )} }
; }; @@ -161,19 +169,23 @@ function classifyErrors(testErrors: string[], diffs: ImageDiff[]) { } const StepTreeItem: React.FC<{ + test: TestCase; + result: TestResult; step: TestStep; depth: number, -}> = ({ step, depth }) => { - return +}> = ({ test, step, result, depth }) => { + const attachmentName = step.title.match(/^attach "(.*)"$/)?.[1]; + return {msToString(step.duration)} + {attachmentName && { evt.stopPropagation(); }}>{icons.attachment()}} {statusIcon(step.error || step.duration === -1 ? 'failed' : 'passed')} {step.title} {step.count > 1 && <> ✕ {step.count}} {step.location && — {step.location.file}:{step.location.line}} } loadChildren={step.steps.length + (step.snippet ? 1 : 0) ? () => { - const children = step.steps.map((s, i) => ); + const children = step.steps.map((s, i) => ); if (step.snippet) - children.unshift(); + children.unshift(); return children; - } : undefined} depth={depth}>; + } : undefined} depth={depth}/>; }; diff --git a/packages/html-reporter/src/treeItem.css b/packages/html-reporter/src/treeItem.css index a8cedc4f6a..f37a759c2d 100644 --- a/packages/html-reporter/src/treeItem.css +++ b/packages/html-reporter/src/treeItem.css @@ -25,6 +25,11 @@ cursor: pointer; } +.tree-item-title.selected { + text-decoration: underline var(--color-underlinenav-icon); + text-decoration-thickness: 1.5px; +} + .tree-item-body { min-height: 18px; } diff --git a/packages/html-reporter/src/treeItem.tsx b/packages/html-reporter/src/treeItem.tsx index 507a9c0e71..926a398a05 100644 --- a/packages/html-reporter/src/treeItem.tsx +++ b/packages/html-reporter/src/treeItem.tsx @@ -17,6 +17,7 @@ import * as React from 'react'; import './treeItem.css'; import * as icons from './icons'; +import { clsx } from '@web/uiUtils'; export const TreeItem: React.FunctionComponent<{ title: JSX.Element, @@ -28,9 +29,8 @@ export const TreeItem: React.FunctionComponent<{ style?: React.CSSProperties, }> = ({ title, loadChildren, onClick, expandByDefault, depth, selected, style }) => { const [expanded, setExpanded] = React.useState(expandByDefault || false); - const className = selected ? 'tree-item-title selected' : 'tree-item-title'; return
- { onClick?.(); setExpanded(!expanded); }} > + { onClick?.(); setExpanded(!expanded); }} > {loadChildren && !!expanded && icons.downArrow()} {loadChildren && !expanded && icons.rightArrow()} {!loadChildren && {icons.rightArrow()}} diff --git a/packages/html-reporter/src/types.ts b/packages/html-reporter/src/types.d.ts similarity index 100% rename from packages/html-reporter/src/types.ts rename to packages/html-reporter/src/types.d.ts diff --git a/packages/html-reporter/src/utils.ts b/packages/html-reporter/src/utils.ts index 65404b2fe7..eec765969b 100644 --- a/packages/html-reporter/src/utils.ts +++ b/packages/html-reporter/src/utils.ts @@ -47,4 +47,3 @@ export function hashStringToInt(str: string) { hash = str.charCodeAt(i) + ((hash << 8) - hash); return Math.abs(hash % 6); } - diff --git a/packages/playwright-core/ThirdPartyNotices.txt b/packages/playwright-core/ThirdPartyNotices.txt index 89a6418ab8..2dc33e8c8f 100644 --- a/packages/playwright-core/ThirdPartyNotices.txt +++ b/packages/playwright-core/ThirdPartyNotices.txt @@ -6,7 +6,7 @@ This project incorporates components from the projects listed below. The origina - @types/node@17.0.24 (https://github.com/DefinitelyTyped/DefinitelyTyped) - @types/yauzl@2.10.0 (https://github.com/DefinitelyTyped/DefinitelyTyped) -- agent-base@7.1.1 (https://github.com/TooTallNate/proxy-agents) +- agent-base@7.1.3 (https://github.com/TooTallNate/proxy-agents) - balanced-match@1.0.2 (https://github.com/juliangruber/balanced-match) - brace-expansion@1.1.11 (https://github.com/juliangruber/brace-expansion) - buffer-crc32@0.2.13 (https://github.com/brianloveswords/buffer-crc32) @@ -24,7 +24,7 @@ This project incorporates components from the projects listed below. The origina - fd-slicer@1.1.0 (https://github.com/andrewrk/node-fd-slicer) - get-stream@5.2.0 (https://github.com/sindresorhus/get-stream) - graceful-fs@4.2.10 (https://github.com/isaacs/node-graceful-fs) -- https-proxy-agent@7.0.5 (https://github.com/TooTallNate/proxy-agents) +- https-proxy-agent@7.0.6 (https://github.com/TooTallNate/proxy-agents) - ip-address@9.0.5 (https://github.com/beaugunderson/ip-address) - is-docker@2.2.1 (https://github.com/sindresorhus/is-docker) - is-wsl@2.2.0 (https://github.com/sindresorhus/is-wsl) @@ -43,7 +43,7 @@ This project incorporates components from the projects listed below. The origina - retry@0.12.0 (https://github.com/tim-kos/node-retry) - signal-exit@3.0.7 (https://github.com/tapjs/signal-exit) - smart-buffer@4.2.0 (https://github.com/JoshGlazebrook/smart-buffer) -- socks-proxy-agent@8.0.4 (https://github.com/TooTallNate/proxy-agents) +- socks-proxy-agent@8.0.5 (https://github.com/TooTallNate/proxy-agents) - socks@2.8.3 (https://github.com/JoshGlazebrook/socks) - sprintf-js@1.1.3 (https://github.com/alexei/sprintf.js) - stack-utils@2.0.5 (https://github.com/tapjs/stack-utils) @@ -105,7 +105,7 @@ MIT License ========================================= END OF @types/yauzl@2.10.0 AND INFORMATION -%% agent-base@7.1.1 NOTICES AND INFORMATION BEGIN HERE +%% agent-base@7.1.3 NOTICES AND INFORMATION BEGIN HERE ========================================= (The MIT License) @@ -130,7 +130,7 @@ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ========================================= -END OF agent-base@7.1.1 AND INFORMATION +END OF agent-base@7.1.3 AND INFORMATION %% balanced-match@1.0.2 NOTICES AND INFORMATION BEGIN HERE ========================================= @@ -542,7 +542,7 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ========================================= END OF graceful-fs@4.2.10 AND INFORMATION -%% https-proxy-agent@7.0.5 NOTICES AND INFORMATION BEGIN HERE +%% https-proxy-agent@7.0.6 NOTICES AND INFORMATION BEGIN HERE ========================================= (The MIT License) @@ -567,7 +567,7 @@ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ========================================= -END OF https-proxy-agent@7.0.5 AND INFORMATION +END OF https-proxy-agent@7.0.6 AND INFORMATION %% ip-address@9.0.5 NOTICES AND INFORMATION BEGIN HERE ========================================= @@ -1005,7 +1005,7 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ========================================= END OF smart-buffer@4.2.0 AND INFORMATION -%% socks-proxy-agent@8.0.4 NOTICES AND INFORMATION BEGIN HERE +%% socks-proxy-agent@8.0.5 NOTICES AND INFORMATION BEGIN HERE ========================================= (The MIT License) @@ -1030,7 +1030,7 @@ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ========================================= -END OF socks-proxy-agent@8.0.4 AND INFORMATION +END OF socks-proxy-agent@8.0.5 AND INFORMATION %% socks@2.8.3 NOTICES AND INFORMATION BEGIN HERE ========================================= diff --git a/packages/playwright-core/bin/PrintDeps.exe b/packages/playwright-core/bin/PrintDeps.exe deleted file mode 100644 index eb8ddf4d7b..0000000000 Binary files a/packages/playwright-core/bin/PrintDeps.exe and /dev/null differ diff --git a/packages/playwright-core/bin/README.md b/packages/playwright-core/bin/README.md deleted file mode 100644 index 2426643de5..0000000000 --- a/packages/playwright-core/bin/README.md +++ /dev/null @@ -1,2 +0,0 @@ -See building instructions at [`/browser_patches/winldd/README.md`](../../../browser_patches/winldd/README.md) - diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 6f5d925d12..9bafa13f3b 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -3,21 +3,15 @@ "browsers": [ { "name": "chromium", - "revision": "1151", + "revision": "1153", "installByDefault": true, - "browserVersion": "132.0.6834.32" - }, - { - "name": "chromium-headless-shell", - "revision": "1151", - "installByDefault": true, - "browserVersion": "132.0.6834.32" + "browserVersion": "132.0.6834.57" }, { "name": "chromium-tip-of-tree", - "revision": "1284", + "revision": "1291", "installByDefault": false, - "browserVersion": "133.0.6878.0" + "browserVersion": "133.0.6929.0" }, { "name": "firefox", @@ -33,7 +27,7 @@ }, { "name": "webkit", - "revision": "2113", + "revision": "2122", "installByDefault": true, "revisionOverrides": { "debian11-x64": "2105", @@ -51,13 +45,18 @@ }, { "name": "ffmpeg", - "revision": "1010", + "revision": "1011", "installByDefault": true, "revisionOverrides": { "mac12": "1010", "mac12-arm64": "1010" } }, + { + "name": "winldd", + "revision": "1007", + "installByDefault": false + }, { "name": "android", "revision": "1001", diff --git a/packages/playwright-core/bundles/utils/package-lock.json b/packages/playwright-core/bundles/utils/package-lock.json index 6d9b0562e3..44aabaca9d 100644 --- a/packages/playwright-core/bundles/utils/package-lock.json +++ b/packages/playwright-core/bundles/utils/package-lock.json @@ -14,7 +14,7 @@ "diff": "^7.0.0", "dotenv": "^16.4.5", "graceful-fs": "4.2.10", - "https-proxy-agent": "7.0.5", + "https-proxy-agent": "7.0.6", "jpeg-js": "0.4.4", "mime": "^3.0.0", "minimatch": "^3.1.2", @@ -24,7 +24,7 @@ "proxy-from-env": "1.1.0", "retry": "0.12.0", "signal-exit": "3.0.7", - "socks-proxy-agent": "8.0.4", + "socks-proxy-agent": "8.0.5", "stack-utils": "2.0.5", "ws": "8.17.1", "yaml": "^2.6.0" @@ -140,13 +140,10 @@ } }, "node_modules/agent-base": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", - "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, "engines": { "node": ">= 14" } @@ -244,12 +241,12 @@ "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" }, "node_modules/https-proxy-agent": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", - "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "license": "MIT", "dependencies": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { @@ -403,12 +400,12 @@ } }, "node_modules/socks-proxy-agent": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.4.tgz", - "integrity": "sha512-GNAq/eg8Udq2x0eNiFkr9gRg5bA7PXEWagQdeRX4cPSG+X/8V38v637gim9bjFptMk1QWsCTr0ttrJEiXbNnRw==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", "license": "MIT", "dependencies": { - "agent-base": "^7.1.1", + "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" }, @@ -562,12 +559,9 @@ } }, "agent-base": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", - "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", - "requires": { - "debug": "^4.3.4" - } + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==" }, "balanced-match": { "version": "1.0.2", @@ -632,11 +626,11 @@ "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" }, "https-proxy-agent": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", - "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "requires": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "4" } }, @@ -740,11 +734,11 @@ } }, "socks-proxy-agent": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.4.tgz", - "integrity": "sha512-GNAq/eg8Udq2x0eNiFkr9gRg5bA7PXEWagQdeRX4cPSG+X/8V38v637gim9bjFptMk1QWsCTr0ttrJEiXbNnRw==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", "requires": { - "agent-base": "^7.1.1", + "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } diff --git a/packages/playwright-core/bundles/utils/package.json b/packages/playwright-core/bundles/utils/package.json index 1900d423f5..a409cca037 100644 --- a/packages/playwright-core/bundles/utils/package.json +++ b/packages/playwright-core/bundles/utils/package.json @@ -15,7 +15,7 @@ "diff": "^7.0.0", "dotenv": "^16.4.5", "graceful-fs": "4.2.10", - "https-proxy-agent": "7.0.5", + "https-proxy-agent": "7.0.6", "jpeg-js": "0.4.4", "mime": "^3.0.0", "minimatch": "^3.1.2", @@ -25,7 +25,7 @@ "proxy-from-env": "1.1.0", "retry": "0.12.0", "signal-exit": "3.0.7", - "socks-proxy-agent": "8.0.4", + "socks-proxy-agent": "8.0.5", "stack-utils": "2.0.5", "ws": "8.17.1", "yaml": "^2.6.0" diff --git a/packages/playwright-core/src/androidServerImpl.ts b/packages/playwright-core/src/androidServerImpl.ts index a0d7bb5496..f0108e67c7 100644 --- a/packages/playwright-core/src/androidServerImpl.ts +++ b/packages/playwright-core/src/androidServerImpl.ts @@ -38,7 +38,7 @@ export class AndroidServerLauncherImpl { if (options.deviceSerialNumber) { devices = devices.filter(d => d.serial === options.deviceSerialNumber); if (devices.length === 0) - throw new Error(`No device with serial number '${options.deviceSerialNumber}' not found`); + throw new Error(`No device with serial number '${options.deviceSerialNumber}' was found`); } if (devices.length > 1) diff --git a/packages/playwright-core/src/cli/program.ts b/packages/playwright-core/src/cli/program.ts index 7d0659d26f..5cd941d5d4 100644 --- a/packages/playwright-core/src/cli/program.ts +++ b/packages/playwright-core/src/cli/program.ts @@ -31,7 +31,6 @@ import type { Browser } from '../client/browser'; import type { Page } from '../client/page'; import type { BrowserType } from '../client/browserType'; import type { BrowserContextOptions, LaunchOptions } from '../client/types'; -import { spawn } from 'child_process'; import { wrapInASCIIBox, isLikelyNpxGlobal, assert, gracefullyProcessExitDoNotHang, getPackageManagerExecCommand } from '../utils'; import type { Executable } from '../server'; import { registry, writeDockerVersion } from '../server'; @@ -77,21 +76,6 @@ Examples: $ codegen --target=python $ codegen -b webkit https://example.com`); -program - .command('debug [args...]', { hidden: true }) - .description('run command in debug mode: disable timeout, open inspector') - .allowUnknownOption(true) - .action(function(app, options) { - spawn(app, options, { - env: { ...process.env, PWDEBUG: '1' }, - stdio: 'inherit' - }); - }).addHelpText('afterAll', ` -Examples: - - $ debug node test.js - $ debug npm run test`); - function suggestedBrowsersToInstall() { return registry.executables().filter(e => e.installType !== 'none' && e.type !== 'tool').map(e => e.name).join(', '); } @@ -132,6 +116,9 @@ function checkBrowsersToInstall(args: string[], options: { noShell?: boolean, on } } + if (process.platform === 'win32') + executables.push(registry.findExecutable('winldd')!); + if (faultyArguments.length) throw new Error(`Invalid installation targets: ${faultyArguments.map(name => `'${name}'`).join(', ')}. Expecting one of: ${suggestedBrowsersToInstall()}`); return executables; @@ -291,7 +278,7 @@ program }); program - .command('run-server', { hidden: true }) + .command('run-server') .option('--port ', 'Server port') .option('--host ', 'Server host') .option('--path ', 'Endpoint Path', '/') diff --git a/packages/playwright-core/src/client/electron.ts b/packages/playwright-core/src/client/electron.ts index fcabfa494f..fe5b6e189e 100644 --- a/packages/playwright-core/src/client/electron.ts +++ b/packages/playwright-core/src/client/electron.ts @@ -103,7 +103,7 @@ export class ElectronApplication extends ChannelOwner { if (this._windows.size) - return this._windows.values().next().value; + return this._windows.values().next().value!; return await this.waitForEvent('window', options); } diff --git a/packages/playwright-core/src/common/types.ts b/packages/playwright-core/src/common/types.ts index 55145b5565..f71d4237cd 100644 --- a/packages/playwright-core/src/common/types.ts +++ b/packages/playwright-core/src/common/types.ts @@ -20,4 +20,4 @@ export type Rect = Size & Point; export type Quad = [ Point, Point, Point, Point ]; export type TimeoutOptions = { timeout?: number }; export type NameValue = { name: string, value: string }; -export type HeadersArray = NameValue[]; \ No newline at end of file +export type HeadersArray = NameValue[]; diff --git a/packages/playwright-core/src/image_tools/stats.ts b/packages/playwright-core/src/image_tools/stats.ts index b35371b4a1..79b170243c 100644 --- a/packages/playwright-core/src/image_tools/stats.ts +++ b/packages/playwright-core/src/image_tools/stats.ts @@ -124,4 +124,3 @@ export class FastStats implements Stats { return (this._sum(this._partialSumMult, x1, y1, x2, y2) - this._sum(this._partialSumC1, x1, y1, x2, y2) * this._sum(this._partialSumC2, x1, y1, x2, y2) / N) / N; } } - diff --git a/packages/playwright-core/src/protocol/debug.ts b/packages/playwright-core/src/protocol/debug.ts index 4f44f5941e..f29ae6be82 100644 --- a/packages/playwright-core/src/protocol/debug.ts +++ b/packages/playwright-core/src/protocol/debug.ts @@ -187,4 +187,4 @@ export const pausesBeforeInputActions = new Set([ 'ElementHandle.tap', 'ElementHandle.type', 'ElementHandle.uncheck' -]); \ No newline at end of file +]); diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 7fe92a7947..9b14551fb8 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -385,6 +385,7 @@ scheme.DebugControllerInitializer = tOptional(tObject({})); scheme.DebugControllerInspectRequestedEvent = tObject({ selector: tString, locator: tString, + ariaSnapshot: tString, }); scheme.DebugControllerSetModeRequestedEvent = tObject({ mode: tString, @@ -2751,4 +2752,4 @@ scheme.JsonPipeSendParams = tObject({ }); scheme.JsonPipeSendResult = tOptional(tObject({})); scheme.JsonPipeCloseParams = tOptional(tObject({})); -scheme.JsonPipeCloseResult = tOptional(tObject({})); \ No newline at end of file +scheme.JsonPipeCloseResult = tOptional(tObject({})); diff --git a/packages/playwright-core/src/remote/playwrightConnection.ts b/packages/playwright-core/src/remote/playwrightConnection.ts index cce31207a8..ea607bdb17 100644 --- a/packages/playwright-core/src/remote/playwrightConnection.ts +++ b/packages/playwright-core/src/remote/playwrightConnection.ts @@ -32,6 +32,7 @@ import { debugLogger } from '../utils/debugLogger'; export type ClientType = 'controller' | 'launch-browser' | 'reuse-browser' | 'pre-launched-browser-or-android'; type Options = { + allowFSPaths: boolean, socksProxyPattern: string | undefined, browserName: string | null, launchOptions: LaunchOptions, @@ -60,7 +61,7 @@ export class PlaywrightConnection { this._ws = ws; this._preLaunched = preLaunched; this._options = options; - options.launchOptions = filterLaunchOptions(options.launchOptions); + options.launchOptions = filterLaunchOptions(options.launchOptions, options.allowFSPaths); if (clientType === 'reuse-browser' || clientType === 'pre-launched-browser-or-android') assert(preLaunched.playwright); if (clientType === 'pre-launched-browser-or-android') @@ -284,7 +285,7 @@ function launchOptionsHash(options: LaunchOptions) { return JSON.stringify(copy); } -function filterLaunchOptions(options: LaunchOptions): LaunchOptions { +function filterLaunchOptions(options: LaunchOptions, allowFSPaths: boolean): LaunchOptions { return { channel: options.channel, args: options.args, @@ -296,7 +297,8 @@ function filterLaunchOptions(options: LaunchOptions): LaunchOptions { chromiumSandbox: options.chromiumSandbox, firefoxUserPrefs: options.firefoxUserPrefs, slowMo: options.slowMo, - executablePath: isUnderTest() ? options.executablePath : undefined, + executablePath: (isUnderTest() || allowFSPaths) ? options.executablePath : undefined, + downloadsPath: allowFSPaths ? options.downloadsPath : undefined, }; } diff --git a/packages/playwright-core/src/remote/playwrightServer.ts b/packages/playwright-core/src/remote/playwrightServer.ts index 121cc2d83a..5cd99285ed 100644 --- a/packages/playwright-core/src/remote/playwrightServer.ts +++ b/packages/playwright-core/src/remote/playwrightServer.ts @@ -102,7 +102,7 @@ export class PlaywrightServer { return new PlaywrightConnection( semaphore.acquire(), clientType, ws, - { socksProxyPattern: proxyValue, browserName, launchOptions }, + { socksProxyPattern: proxyValue, browserName, launchOptions, allowFSPaths: this._options.mode === 'extension' }, { playwright: this._preLaunchedPlaywright, browser: this._options.preLaunchedBrowser, diff --git a/packages/playwright-core/src/server/android/android.ts b/packages/playwright-core/src/server/android/android.ts index 1af083916c..b6303935b7 100644 --- a/packages/playwright-core/src/server/android/android.ts +++ b/packages/playwright-core/src/server/android/android.ts @@ -524,5 +524,3 @@ class ClankBrowserProcess implements BrowserProcess { await this._browser.close(); } } - - diff --git a/packages/playwright-core/src/server/bidi/bidiBrowser.ts b/packages/playwright-core/src/server/bidi/bidiBrowser.ts index 9861fc80cf..955f6274a3 100644 --- a/packages/playwright-core/src/server/bidi/bidiBrowser.ts +++ b/packages/playwright-core/src/server/bidi/bidiBrowser.ts @@ -22,7 +22,7 @@ import { Browser } from '../browser'; import { assertBrowserContextIsNotOwned, BrowserContext } from '../browserContext'; import type { SdkObject } from '../instrumentation'; import * as network from '../network'; -import type { InitScript, Page, PageDelegate } from '../page'; +import type { InitScript, Page } from '../page'; import type { ConnectionTransport } from '../transport'; import type * as types from '../types'; import type { BidiSession } from './bidiConnection'; @@ -99,8 +99,8 @@ export class BidiBrowser extends Browser { browser._defaultContext = new BidiBrowserContext(browser, undefined, options.persistent); await (browser._defaultContext as BidiBrowserContext)._initialize(); // Create default page as we cannot get access to the existing one. - const pageDelegate = await browser._defaultContext.newPageDelegate(); - await pageDelegate.pageOrError(); + const page = await browser._defaultContext.doCreateNewPage(); + await page.waitForInitializedOrError(); } return browser; } @@ -207,17 +207,17 @@ export class BidiBrowserContext extends BrowserContext { return [...this._browser._bidiPages.values()].filter(bidiPage => bidiPage._browserContext === this); } - pages(): Page[] { - return this._bidiPages().map(bidiPage => bidiPage._initializedPage).filter(Boolean) as Page[]; + override possiblyUninitializedPages(): Page[] { + return this._bidiPages().map(bidiPage => bidiPage._page); } - async newPageDelegate(): Promise { + override async doCreateNewPage(): Promise { assertBrowserContextIsNotOwned(this); const { context } = await this._browser._browserSession.send('browsingContext.create', { type: bidi.BrowsingContext.CreateType.Window, userContext: this._browserContextId, }); - return this._browser._bidiPages.get(context)!; + return this._browser._bidiPages.get(context)!._page; } async doGetCookies(urls: string[]): Promise { diff --git a/packages/playwright-core/src/server/bidi/bidiPage.ts b/packages/playwright-core/src/server/bidi/bidiPage.ts index 56bb43cb1a..cf0662738b 100644 --- a/packages/playwright-core/src/server/bidi/bidiPage.ts +++ b/packages/playwright-core/src/server/bidi/bidiPage.ts @@ -43,7 +43,6 @@ export class BidiPage implements PageDelegate { readonly rawKeyboard: RawKeyboardImpl; readonly rawTouchscreen: RawTouchscreenImpl; readonly _page: Page; - private readonly _pagePromise: Promise; readonly _session: BidiSession; readonly _opener: BidiPage | null; private readonly _realmToContext: Map; @@ -51,7 +50,6 @@ export class BidiPage implements PageDelegate { readonly _browserContext: BidiBrowserContext; readonly _networkManager: BidiNetworkManager; private readonly _pdf: BidiPDF; - _initializedPage: Page | null = null; private _initScriptIds: string[] = []; constructor(browserContext: BidiBrowserContext, bidiSession: BidiSession, opener: BidiPage | null) { @@ -81,16 +79,10 @@ export class BidiPage implements PageDelegate { ]; // Initialize main frame. - this._pagePromise = this._initialize().finally(async () => { - await this._page.initOpener(this._opener); - }).then(() => { - this._initializedPage = this._page; - this._page.reportAsNew(); - return this._page; - }).catch(e => { - this._page.reportAsNew(e); - return e; - }); + // TODO: Wait for first execution context to be created and maybe about:blank navigated. + this._initialize().then( + () => this._page.reportAsNew(this._opener?._page), + error => this._page.reportAsNew(this._opener?._page, error)); } private async _initialize() { @@ -109,21 +101,12 @@ export class BidiPage implements PageDelegate { return Promise.all(this._page.allInitScripts().map(initScript => this.addInitScript(initScript))); } - potentiallyUninitializedPage(): Page { - return this._page; - } - didClose() { this._session.dispose(); eventsHelper.removeEventListeners(this._sessionListeners); this._page._didClose(); } - async pageOrError(): Promise { - // TODO: Wait for first execution context to be created and maybe about:blank navigated. - return this._pagePromise; - } - private _onFrameAttached(frameId: string, parentFrameId: string | null): frames.Frame { return this._page._frameManager.frameAttached(frameId, parentFrameId); } @@ -372,7 +355,7 @@ export class BidiPage implements PageDelegate { private async _onScriptMessage(event: bidi.Script.MessageParameters) { if (event.channel !== kPlaywrightBindingChannel) return; - const pageOrError = await this.pageOrError(); + const pageOrError = await this._page.waitForInitializedOrError(); if (pageOrError instanceof Error) return; const context = this._realmToContext.get(event.source.realm); @@ -416,7 +399,7 @@ export class BidiPage implements PageDelegate { context: this._session.sessionId, format: { type: `image/${format === 'png' ? 'png' : 'jpeg'}`, - quality: quality || 80, + quality: quality ? quality / 100 : 0.8, }, origin: documentRect ? 'document' : 'viewport', clip: { diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 025bd0f388..8a835d3726 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -24,7 +24,6 @@ import type * as frames from './frames'; import { helper } from './helper'; import * as network from './network'; import { InitScript } from './page'; -import type { PageDelegate } from './page'; import { Page, PageBinding } from './page'; import type { Progress, ProgressController } from './progress'; import type { Selectors } from './selectors'; @@ -257,9 +256,13 @@ export abstract class BrowserContext extends SdkObject { this.emit(BrowserContext.Events.Close); } + pages(): Page[] { + return this.possiblyUninitializedPages().filter(page => page.initializedOrUndefined()); + } + // BrowserContext methods. - abstract pages(): Page[]; - abstract newPageDelegate(): Promise; + abstract possiblyUninitializedPages(): Page[]; + abstract doCreateNewPage(): Promise; abstract addCookies(cookies: channels.SetNetworkCookie[]): Promise; abstract setGeolocation(geolocation?: types.Geolocation): Promise; abstract setExtraHTTPHeaders(headers: types.HeadersArray): Promise; @@ -311,6 +314,10 @@ export abstract class BrowserContext extends SdkObject { return this.doSetHTTPCredentials(httpCredentials); } + hasBinding(name: string) { + return this._pageBindings.has(name); + } + async exposeBinding(name: string, needsHandle: boolean, playwrightBinding: frames.FunctionWithSource): Promise { if (this._pageBindings.has(name)) throw new Error(`Function "${name}" has been already registered`); @@ -358,31 +365,34 @@ export abstract class BrowserContext extends SdkObject { this._timeoutSettings.setDefaultTimeout(timeout); } - async _loadDefaultContextAsIs(progress: Progress): Promise { - if (!this.pages().length) { + async _loadDefaultContextAsIs(progress: Progress): Promise { + if (!this.possiblyUninitializedPages().length) { const waitForEvent = helper.waitForEvent(progress, this, BrowserContext.Events.Page); progress.cleanupWhenAborted(() => waitForEvent.dispose); - const page = (await waitForEvent.promise) as Page; - if (page._pageIsError) - throw page._pageIsError; + // Race against BrowserContext.close + await Promise.race([waitForEvent.promise, this._closePromise]); } - const pages = this.pages(); - if (pages[0]._pageIsError) - throw pages[0]._pageIsError; - await pages[0].mainFrame()._waitForLoadState(progress, 'load'); - return pages; + const page = this.possiblyUninitializedPages()[0]; + if (!page) + return; + const pageOrError = await page.waitForInitializedOrError(); + if (pageOrError instanceof Error) + throw pageOrError; + await page.mainFrame()._waitForLoadState(progress, 'load'); + return page; } async _loadDefaultContext(progress: Progress) { - const pages = await this._loadDefaultContextAsIs(progress); + const defaultPage = await this._loadDefaultContextAsIs(progress); + if (!defaultPage) + return; const browserName = this._browser.options.name; if ((this._options.isMobile && browserName === 'chromium') || (this._options.locale && browserName === 'webkit')) { // Workaround for: // - chromium fails to change isMobile for existing page; // - webkit fails to change locale for existing page. - const oldPage = pages[0]; await this.newPage(progress.metadata); - await oldPage.close(progress.metadata); + await defaultPage.close(progress.metadata); } } @@ -408,8 +418,8 @@ export abstract class BrowserContext extends SdkObject { this._options.httpCredentials = { username, password: password || '' }; } - async addInitScript(source: string) { - const initScript = new InitScript(source); + async addInitScript(source: string, name?: string) { + const initScript = new InitScript(source, false /* internal */, name); this.initScripts.push(initScript); await this.doAddInitScript(initScript); } @@ -480,10 +490,10 @@ export abstract class BrowserContext extends SdkObject { } async newPage(metadata: CallMetadata): Promise { - const pageDelegate = await this.newPageDelegate(); + const page = await this.doCreateNewPage(); if (metadata.isServerSide) - pageDelegate.potentiallyUninitializedPage().markAsServerSideOnly(); - const pageOrError = await pageDelegate.pageOrError(); + page.markAsServerSideOnly(); + const pageOrError = await page.waitForInitializedOrError(); if (pageOrError instanceof Page) { if (pageOrError.isClosed()) throw new Error('Page has been closed.'); diff --git a/packages/playwright-core/src/server/chromium/crAccessibility.ts b/packages/playwright-core/src/server/chromium/crAccessibility.ts index 30d02ee7bf..4114663a0e 100644 --- a/packages/playwright-core/src/server/chromium/crAccessibility.ts +++ b/packages/playwright-core/src/server/chromium/crAccessibility.ts @@ -299,6 +299,6 @@ class CRAXNode implements accessibility.AXNode { for (const childId of node._payload.childIds || []) node._children.push(nodeById.get(childId)!); } - return nodeById.values().next().value; + return nodeById.values().next().value!; } } diff --git a/packages/playwright-core/src/server/chromium/crBrowser.ts b/packages/playwright-core/src/server/chromium/crBrowser.ts index a6d77e4ae7..9f03803dcb 100644 --- a/packages/playwright-core/src/server/chromium/crBrowser.ts +++ b/packages/playwright-core/src/server/chromium/crBrowser.ts @@ -21,7 +21,7 @@ import { Browser } from '../browser'; import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext'; import { assert, createGuid } from '../../utils'; import * as network from '../network'; -import type { InitScript, PageDelegate, Worker } from '../page'; +import type { InitScript, Worker } from '../page'; import { Page } from '../page'; import { Frame } from '../frames'; import type { Dialog } from '../dialog'; @@ -146,7 +146,7 @@ export class CRBrowser extends Browser { } async _waitForAllPagesToBeInitialized() { - await Promise.all([...this._crPages.values()].map(page => page.pageOrError())); + await Promise.all([...this._crPages.values()].map(crPage => crPage._page.waitForInitializedOrError())); } _onAttachedToTarget({ targetInfo, sessionId, waitingForDebugger }: Protocol.Target.attachedToTargetPayload) { @@ -259,10 +259,10 @@ export class CRBrowser extends Browser { } page.willBeginDownload(); - let originPage = page._initializedPage; + let originPage = page._page.initializedOrUndefined(); // If it's a new window download, report it on the opener page. if (!originPage && page._opener) - originPage = page._opener._initializedPage; + originPage = page._opener._page.initializedOrUndefined(); if (!originPage) return; this._downloadCreated(originPage, payload.guid, payload.url, payload.suggestedFilename); @@ -364,11 +364,11 @@ export class CRBrowserContext extends BrowserContext { return [...this._browser._crPages.values()].filter(crPage => crPage._browserContext === this); } - pages(): Page[] { - return this._crPages().map(crPage => crPage._initializedPage).filter(Boolean) as Page[]; + override possiblyUninitializedPages(): Page[] { + return this._crPages().map(crPage => crPage._page); } - async newPageDelegate(): Promise { + override async doCreateNewPage(): Promise { assertBrowserContextIsNotOwned(this); const oldKeys = this._browser.isClank() ? new Set(this._browser._crPages.keys()) : undefined; @@ -391,7 +391,7 @@ export class CRBrowserContext extends BrowserContext { assert(newKeys.size === 1); [targetId] = [...newKeys]; } - return this._browser._crPages.get(targetId)!; + return this._browser._crPages.get(targetId)!._page; } async doGetCookies(urls: string[]): Promise { @@ -544,7 +544,7 @@ export class CRBrowserContext extends BrowserContext { // When persistent context is closed, we do not necessary get Target.detachedFromTarget // for all the background pages. for (const [targetId, backgroundPage] of this._browser._backgroundPages.entries()) { - if (backgroundPage._browserContext === this && backgroundPage._initializedPage) { + if (backgroundPage._browserContext === this && backgroundPage._page.initializedOrUndefined()) { backgroundPage.didClose(); this._browser._backgroundPages.delete(targetId); } @@ -569,8 +569,8 @@ export class CRBrowserContext extends BrowserContext { backgroundPages(): Page[] { const result: Page[] = []; for (const backgroundPage of this._browser._backgroundPages.values()) { - if (backgroundPage._browserContext === this && backgroundPage._initializedPage) - result.push(backgroundPage._initializedPage); + if (backgroundPage._browserContext === this && backgroundPage._page.initializedOrUndefined()) + result.push(backgroundPage._page); } return result; } diff --git a/packages/playwright-core/src/server/chromium/crExecutionContext.ts b/packages/playwright-core/src/server/chromium/crExecutionContext.ts index 1cd58de7af..661d216fb4 100644 --- a/packages/playwright-core/src/server/chromium/crExecutionContext.ts +++ b/packages/playwright-core/src/server/chromium/crExecutionContext.ts @@ -96,7 +96,7 @@ export class CRExecutionContext implements js.ExecutionContextDelegate { function rewriteError(error: Error): Protocol.Runtime.evaluateReturnValue { if (error.message.includes('Object reference chain is too long')) - return { result: { type: 'undefined' } }; + throw new Error('Cannot serialize result: object reference chain is too long.'); if (error.message.includes('Object couldn\'t be returned by value')) return { result: { type: 'undefined' } }; diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index bfad678f1c..0dc4703547 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -65,8 +65,6 @@ export class CRPage implements PageDelegate { private readonly _pdf: CRPDF; private readonly _coverage: CRCoverage; readonly _browserContext: CRBrowserContext; - private readonly _pagePromise: Promise; - _initializedPage: Page | null = null; private _isBackgroundPage: boolean; // Holds window features for the next popup being opened via window.open, @@ -108,30 +106,11 @@ export class CRPage implements PageDelegate { if (viewportSize) this._page._emulatedSize = { viewport: viewportSize, screen: viewportSize }; } - // Note: it is important to call |reportAsNew| before resolving pageOrError promise, - // so that anyone who awaits pageOrError got a ready and reported page. - this._pagePromise = this._mainFrameSession._initialize(bits.hasUIWindow).then(async r => { - await this._page.initOpener(this._opener); - return r; - }).catch(async e => { - await this._page.initOpener(this._opener); - throw e; - }).then(() => { - this._initializedPage = this._page; - this._reportAsNew(); - return this._page; - }).catch(e => { - this._reportAsNew(e); - return e; - }); - } - potentiallyUninitializedPage(): Page { - return this._page; - } - - private _reportAsNew(error?: Error) { - this._page.reportAsNew(error, this._isBackgroundPage ? CRBrowserContext.CREvents.BackgroundPage : BrowserContext.Events.Page); + const createdEvent = this._isBackgroundPage ? CRBrowserContext.CREvents.BackgroundPage : BrowserContext.Events.Page; + this._mainFrameSession._initialize(bits.hasUIWindow).then( + () => this._page.reportAsNew(this._opener?._page, undefined, createdEvent), + error => this._page.reportAsNew(this._opener?._page, error, createdEvent)); } private async _forAllFrameSessions(cb: (frame: FrameSession) => Promise) { @@ -168,10 +147,6 @@ export class CRPage implements PageDelegate { this._mainFrameSession._willBeginDownload(); } - async pageOrError(): Promise { - return this._pagePromise; - } - didClose() { for (const session of this._sessions.values()) session.dispose(); @@ -432,6 +407,9 @@ class FrameSession { this._firstNonInitialNavigationCommittedFulfill = f; this._firstNonInitialNavigationCommittedReject = r; }); + // The Promise is not always awaited (e.g. FrameSession._initialize can throw) + // so we catch errors here to prevent unhandled promise rejection. + this._firstNonInitialNavigationCommittedPromise.catch(() => {}); } _isMainFrame(): boolean { @@ -489,7 +467,7 @@ class FrameSession { // Note: it is important to start video recorder before sending Page.startScreencast, // and it is equally important to send Page.startScreencast before sending Runtime.runIfWaitingForDebugger. await this._createVideoRecorder(screencastId, screencastOptions); - this._crPage.pageOrError().then(p => { + this._crPage._page.waitForInitializedOrError().then(p => { if (p instanceof Error) this._stopVideoRecording().catch(() => {}); }); @@ -830,7 +808,7 @@ class FrameSession { } async _onBindingCalled(event: Protocol.Runtime.bindingCalledPayload) { - const pageOrError = await this._crPage.pageOrError(); + const pageOrError = await this._crPage._page.waitForInitializedOrError(); if (!(pageOrError instanceof Error)) { const context = this._contextIdToContext.get(event.executionContextId); if (context) @@ -895,8 +873,7 @@ class FrameSession { } _willBeginDownload() { - const originPage = this._crPage._initializedPage; - if (!originPage) { + if (!this._crPage._page.initializedOrUndefined()) { // Resume the page creation with an error. The page will automatically close right // after the download begins. this._firstNonInitialNavigationCommittedReject(new Error('Starting new page download')); @@ -936,7 +913,7 @@ class FrameSession { }); // Wait for the first frame before reporting video to the client. gotFirstFrame.then(() => { - this._crPage._browserContext._browser._videoStarted(this._crPage._browserContext, screencastId, options.outputFile, this._crPage.pageOrError()); + this._crPage._browserContext._browser._videoStarted(this._crPage._browserContext, screencastId, options.outputFile, this._crPage._page.waitForInitializedOrError()); }); } diff --git a/packages/playwright-core/src/server/codegen/csharp.ts b/packages/playwright-core/src/server/codegen/csharp.ts index f9166c2a91..548a06343a 100644 --- a/packages/playwright-core/src/server/codegen/csharp.ts +++ b/packages/playwright-core/src/server/codegen/csharp.ts @@ -171,8 +171,10 @@ export class CSharpLanguageGenerator implements LanguageGenerator { using var playwright = await Playwright.CreateAsync(); await using var browser = await playwright.${toPascal(options.browserName)}.LaunchAsync(${formatObject(options.launchOptions, ' ', 'BrowserTypeLaunchOptions')}); var context = await browser.NewContextAsync(${formatContextOptions(options.contextOptions, options.deviceName)});`); - if (options.contextOptions.recordHar) - formatter.add(` await context.RouteFromHARAsync(${quote(options.contextOptions.recordHar.path)});`); + if (options.contextOptions.recordHar) { + const url = options.contextOptions.recordHar.urlFilter; + formatter.add(` await context.RouteFromHARAsync(${quote(options.contextOptions.recordHar.path)}${url ? `, ${formatObject({ url }, ' ', 'BrowserContextRouteFromHAROptions')}` : ''});`); + } formatter.newLine(); return formatter.format(); } @@ -198,8 +200,10 @@ export class CSharpLanguageGenerator implements LanguageGenerator { formatter.add(` [${this._mode === 'nunit' ? 'Test' : 'TestMethod'}] public async Task MyTest() {`); - if (options.contextOptions.recordHar) - formatter.add(` await context.RouteFromHARAsync(${quote(options.contextOptions.recordHar.path)});`); + if (options.contextOptions.recordHar) { + const url = options.contextOptions.recordHar.urlFilter; + formatter.add(` await Context.RouteFromHARAsync(${quote(options.contextOptions.recordHar.path)}${url ? `, ${formatObject({ url }, ' ', 'BrowserContextRouteFromHAROptions')}` : ''});`); + } return formatter.format(); } diff --git a/packages/playwright-core/src/server/codegen/java.ts b/packages/playwright-core/src/server/codegen/java.ts index 1fafa0642c..ac04783c23 100644 --- a/packages/playwright-core/src/server/codegen/java.ts +++ b/packages/playwright-core/src/server/codegen/java.ts @@ -150,28 +150,38 @@ export class JavaLanguageGenerator implements LanguageGenerator { import com.microsoft.playwright.Page; import com.microsoft.playwright.options.*; - import org.junit.jupiter.api.*; + ${options.contextOptions.recordHar ? `import java.nio.file.Paths;\n` : ''}import org.junit.jupiter.api.*; import static com.microsoft.playwright.assertions.PlaywrightAssertions.*; @UsePlaywright public class TestExample { @Test void test(Page page) {`); + if (options.contextOptions.recordHar) { + const url = options.contextOptions.recordHar.urlFilter; + const recordHarOptions = typeof url === 'string' ? `, new Page.RouteFromHAROptions() + .setUrl(${quote(url)})` : ''; + formatter.add(` page.routeFromHAR(Paths.get(${quote(options.contextOptions.recordHar.path)})${recordHarOptions});`); + } return formatter.format(); } formatter.add(` import com.microsoft.playwright.*; import com.microsoft.playwright.options.*; import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; - import java.util.*; + ${options.contextOptions.recordHar ? `import java.nio.file.Paths;\n` : ''}import java.util.*; public class Example { public static void main(String[] args) { try (Playwright playwright = Playwright.create()) { Browser browser = playwright.${options.browserName}().launch(${formatLaunchOptions(options.launchOptions)}); BrowserContext context = browser.newContext(${formatContextOptions(options.contextOptions, options.deviceName)});`); - if (options.contextOptions.recordHar) - formatter.add(` context.routeFromHAR(${quote(options.contextOptions.recordHar.path)});`); + if (options.contextOptions.recordHar) { + const url = options.contextOptions.recordHar.urlFilter; + const recordHarOptions = typeof url === 'string' ? `, new BrowserContext.RouteFromHAROptions() + .setUrl(${quote(url)})` : ''; + formatter.add(` context.routeFromHAR(Paths.get(${quote(options.contextOptions.recordHar.path)})${recordHarOptions});`); + } return formatter.format(); } diff --git a/packages/playwright-core/src/server/codegen/javascript.ts b/packages/playwright-core/src/server/codegen/javascript.ts index e5f72ce122..80cb10926b 100644 --- a/packages/playwright-core/src/server/codegen/javascript.ts +++ b/packages/playwright-core/src/server/codegen/javascript.ts @@ -147,8 +147,10 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { import { test, expect${options.deviceName ? ', devices' : ''} } from '@playwright/test'; ${useText ? '\ntest.use(' + useText + ');\n' : ''} test('test', async ({ page }) => {`); - if (options.contextOptions.recordHar) - formatter.add(` await page.routeFromHAR(${quote(options.contextOptions.recordHar.path)});`); + if (options.contextOptions.recordHar) { + const url = options.contextOptions.recordHar.urlFilter; + formatter.add(` await page.routeFromHAR(${quote(options.contextOptions.recordHar.path)}${url ? `, ${formatOptions({ url }, false)}` : ''});`); + } return formatter.format(); } diff --git a/packages/playwright-core/src/server/codegen/python.ts b/packages/playwright-core/src/server/codegen/python.ts index 8d4ea7659d..714265a25c 100644 --- a/packages/playwright-core/src/server/codegen/python.ts +++ b/packages/playwright-core/src/server/codegen/python.ts @@ -137,6 +137,7 @@ export class PythonLanguageGenerator implements LanguageGenerator { generateHeader(options: LanguageGeneratorOptions): string { const formatter = new PythonFormatter(); + const recordHar = options.contextOptions.recordHar; if (this._isPyTest) { const contextOptions = formatContextOptions(options.contextOptions, options.deviceName, true /* asDict */); const fixture = contextOptions ? ` @@ -146,13 +147,13 @@ def browser_context_args(browser_context_args, playwright) { return {${contextOptions}} } ` : ''; - formatter.add(`${options.deviceName ? 'import pytest\n' : ''}import re + formatter.add(`${options.deviceName || contextOptions ? 'import pytest\n' : ''}import re from playwright.sync_api import Page, expect ${fixture} def test_example(page: Page) -> None {`); - if (options.contextOptions.recordHar) - formatter.add(` page.route_from_har(${quote(options.contextOptions.recordHar.path)})`); + if (recordHar) + formatter.add(` page.route_from_har(${quote(recordHar.path)}${typeof recordHar.urlFilter === 'string' ? `, url=${quote(recordHar.urlFilter)}` : ''})`); } else if (this._isAsync) { formatter.add(` import asyncio @@ -163,8 +164,8 @@ from playwright.async_api import Playwright, async_playwright, expect async def run(playwright: Playwright) -> None { browser = await playwright.${options.browserName}.launch(${formatOptions(options.launchOptions, false)}) context = await browser.new_context(${formatContextOptions(options.contextOptions, options.deviceName)})`); - if (options.contextOptions.recordHar) - formatter.add(` await page.route_from_har(${quote(options.contextOptions.recordHar.path)})`); + if (recordHar) + formatter.add(` await context.route_from_har(${quote(recordHar.path)}${typeof recordHar.urlFilter === 'string' ? `, url=${quote(recordHar.urlFilter)}` : ''})`); } else { formatter.add(` import re @@ -174,8 +175,8 @@ from playwright.sync_api import Playwright, sync_playwright, expect def run(playwright: Playwright) -> None { browser = playwright.${options.browserName}.launch(${formatOptions(options.launchOptions, false)}) context = browser.new_context(${formatContextOptions(options.contextOptions, options.deviceName)})`); - if (options.contextOptions.recordHar) - formatter.add(` context.route_from_har(${quote(options.contextOptions.recordHar.path)})`); + if (recordHar) + formatter.add(` context.route_from_har(${quote(recordHar.path)}${typeof recordHar.urlFilter === 'string' ? `, url=${quote(recordHar.urlFilter)}` : ''})`); } return formatter.format(); } diff --git a/packages/playwright-core/src/server/debugController.ts b/packages/playwright-core/src/server/debugController.ts index 251dce7e03..b810e2fa65 100644 --- a/packages/playwright-core/src/server/debugController.ts +++ b/packages/playwright-core/src/server/debugController.ts @@ -25,6 +25,9 @@ import { Recorder } from './recorder'; import { EmptyRecorderApp } from './recorder/recorderApp'; import { asLocator, type Language } from '../utils'; import { parseYamlForAriaSnapshot } from './ariaSnapshot'; +import type { ParsedYaml } from '../utils/isomorphic/ariaSnapshot'; +import { parseYamlTemplate } from '../utils/isomorphic/ariaSnapshot'; +import { unsafeLocatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser'; const internalMetadata = serverSideCallMetadata(); @@ -144,9 +147,17 @@ export class DebugController extends SdkObject { } async highlight(params: { selector?: string, ariaTemplate?: string }) { + // Assert parameters validity. + if (params.selector) + unsafeLocatorOrSelectorAsSelector(this._sdkLanguage, params.selector, 'data-testid'); + let parsedYaml: ParsedYaml | undefined; + if (params.ariaTemplate) { + parsedYaml = parseYamlForAriaSnapshot(params.ariaTemplate); + parseYamlTemplate(parsedYaml); + } for (const recorder of await this._allRecorders()) { - if (params.ariaTemplate) - recorder.setHighlightedAriaTemplate(parseYamlForAriaSnapshot(params.ariaTemplate)); + if (parsedYaml) + recorder.setHighlightedAriaTemplate(parsedYaml); else if (params.selector) recorder.setHighlightedSelector(this._sdkLanguage, params.selector); } @@ -228,7 +239,7 @@ class InspectingRecorderApp extends EmptyRecorderApp { override async elementPicked(elementInfo: ElementInfo): Promise { const locator: string = asLocator(this._debugController._sdkLanguage, elementInfo.selector); - this._debugController.emit(DebugController.Events.InspectRequested, { selector: elementInfo.selector, locator }); + this._debugController.emit(DebugController.Events.InspectRequested, { selector: elementInfo.selector, locator, ariaSnapshot: elementInfo.ariaSnapshot }); } override async setSources(sources: Source[]): Promise { diff --git a/packages/playwright-core/src/server/deviceDescriptorsSource.json b/packages/playwright-core/src/server/deviceDescriptorsSource.json index ea07b3140a..d2777c80e9 100644 --- a/packages/playwright-core/src/server/deviceDescriptorsSource.json +++ b/packages/playwright-core/src/server/deviceDescriptorsSource.json @@ -110,7 +110,7 @@ "defaultBrowserType": "webkit" }, "Galaxy S5": { - "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -121,7 +121,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -132,7 +132,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S8": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 360, "height": 740 @@ -143,7 +143,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S8 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 740, "height": 360 @@ -154,7 +154,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S9+": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 320, "height": 658 @@ -165,7 +165,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S9+ landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 658, "height": 320 @@ -176,7 +176,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S4": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Safari/537.36", "viewport": { "width": 712, "height": 1138 @@ -187,7 +187,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Safari/537.36", "viewport": { "width": 1138, "height": 712 @@ -1098,7 +1098,7 @@ "defaultBrowserType": "webkit" }, "LG Optimus L70": { - "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1109,7 +1109,7 @@ "defaultBrowserType": "chromium" }, "LG Optimus L70 landscape": { - "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1120,7 +1120,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 550": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1131,7 +1131,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 550 landscape": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1142,7 +1142,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 950": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1153,7 +1153,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 950 landscape": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1164,7 +1164,7 @@ "defaultBrowserType": "chromium" }, "Nexus 10": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Safari/537.36", "viewport": { "width": 800, "height": 1280 @@ -1175,7 +1175,7 @@ "defaultBrowserType": "chromium" }, "Nexus 10 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Safari/537.36", "viewport": { "width": 1280, "height": 800 @@ -1186,7 +1186,7 @@ "defaultBrowserType": "chromium" }, "Nexus 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1197,7 +1197,7 @@ "defaultBrowserType": "chromium" }, "Nexus 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1208,7 +1208,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1219,7 +1219,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1230,7 +1230,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5X": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1241,7 +1241,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5X landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1252,7 +1252,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1263,7 +1263,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1274,7 +1274,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6P": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1285,7 +1285,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6P landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1296,7 +1296,7 @@ "defaultBrowserType": "chromium" }, "Nexus 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Safari/537.36", "viewport": { "width": 600, "height": 960 @@ -1307,7 +1307,7 @@ "defaultBrowserType": "chromium" }, "Nexus 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Safari/537.36", "viewport": { "width": 960, "height": 600 @@ -1362,7 +1362,7 @@ "defaultBrowserType": "webkit" }, "Pixel 2": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 411, "height": 731 @@ -1373,7 +1373,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 731, "height": 411 @@ -1384,7 +1384,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 XL": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 411, "height": 823 @@ -1395,7 +1395,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 XL landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 823, "height": 411 @@ -1406,7 +1406,7 @@ "defaultBrowserType": "chromium" }, "Pixel 3": { - "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 393, "height": 786 @@ -1417,7 +1417,7 @@ "defaultBrowserType": "chromium" }, "Pixel 3 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 786, "height": 393 @@ -1428,7 +1428,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 353, "height": 745 @@ -1439,7 +1439,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 745, "height": 353 @@ -1450,7 +1450,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G)": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "screen": { "width": 412, "height": 892 @@ -1465,7 +1465,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G) landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "screen": { "height": 892, "width": 412 @@ -1480,7 +1480,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "screen": { "width": 393, "height": 851 @@ -1495,7 +1495,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "screen": { "width": 851, "height": 393 @@ -1510,7 +1510,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "screen": { "width": 412, "height": 915 @@ -1525,7 +1525,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "screen": { "width": 915, "height": 412 @@ -1540,7 +1540,7 @@ "defaultBrowserType": "chromium" }, "Moto G4": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1551,7 +1551,7 @@ "defaultBrowserType": "chromium" }, "Moto G4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1562,7 +1562,7 @@ "defaultBrowserType": "chromium" }, "Desktop Chrome HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Safari/537.36", "screen": { "width": 1792, "height": 1120 @@ -1577,7 +1577,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Safari/537.36 Edg/132.0.6834.15", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Safari/537.36 Edg/132.0.6834.57", "screen": { "width": 1792, "height": 1120 @@ -1622,7 +1622,7 @@ "defaultBrowserType": "webkit" }, "Desktop Chrome": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Safari/537.36", "screen": { "width": 1920, "height": 1080 @@ -1637,7 +1637,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.15 Safari/537.36 Edg/132.0.6834.15", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Safari/537.36 Edg/132.0.6834.57", "screen": { "width": 1920, "height": 1080 diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index c6ffce49f7..3579f4b3bb 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -288,7 +288,7 @@ export class BrowserContextDispatcher extends Dispatcher { this._webSocketInterceptionPatterns = params.patterns; if (params.patterns.length) - await WebSocketRouteDispatcher.installIfNeeded(this, this._context); + await WebSocketRouteDispatcher.installIfNeeded(this._context); } async storageState(params: channels.BrowserContextStorageStateParams, metadata: CallMetadata): Promise { diff --git a/packages/playwright-core/src/server/dispatchers/debugControllerDispatcher.ts b/packages/playwright-core/src/server/dispatchers/debugControllerDispatcher.ts index 77d7b503ab..fc722d1bd3 100644 --- a/packages/playwright-core/src/server/dispatchers/debugControllerDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/debugControllerDispatcher.ts @@ -32,8 +32,8 @@ export class DebugControllerDispatcher extends Dispatcher { this._dispatchEvent('stateChanged', params); }), - eventsHelper.addEventListener(this._object, DebugController.Events.InspectRequested, ({ selector, locator }) => { - this._dispatchEvent('inspectRequested', { selector, locator }); + eventsHelper.addEventListener(this._object, DebugController.Events.InspectRequested, ({ selector, locator, ariaSnapshot }) => { + this._dispatchEvent('inspectRequested', { selector, locator, ariaSnapshot }); }), eventsHelper.addEventListener(this._object, DebugController.Events.SourceChanged, ({ text, header, footer, actions }) => { this._dispatchEvent('sourceChanged', ({ text, header, footer, actions })); diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index 5e3b73a73f..6e8c47929b 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -191,7 +191,7 @@ export class PageDispatcher extends Dispatcher { this._webSocketInterceptionPatterns = params.patterns; if (params.patterns.length) - await WebSocketRouteDispatcher.installIfNeeded(this.parentScope(), this._page); + await WebSocketRouteDispatcher.installIfNeeded(this._page); } async expectScreenshot(params: channels.PageExpectScreenshotParams, metadata: CallMetadata): Promise { diff --git a/packages/playwright-core/src/server/dispatchers/webSocketRouteDispatcher.ts b/packages/playwright-core/src/server/dispatchers/webSocketRouteDispatcher.ts index 87f469b7d0..bcbc89fe03 100644 --- a/packages/playwright-core/src/server/dispatchers/webSocketRouteDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/webSocketRouteDispatcher.ts @@ -18,7 +18,7 @@ import type { BrowserContext } from '../browserContext'; import type { Frame } from '../frames'; import { Page } from '../page'; import type * as channels from '@protocol/channels'; -import { Dispatcher } from './dispatcher'; +import { Dispatcher, existingDispatcher } from './dispatcher'; import { createGuid, urlMatches } from '../../utils'; import { PageDispatcher } from './pageDispatcher'; import type { BrowserContextDispatcher } from './browserContextDispatcher'; @@ -26,9 +26,6 @@ import * as webSocketMockSource from '../../generated/webSocketMockSource'; import type * as ws from '../injected/webSocketMock'; import { eventsHelper } from '../../utils/eventsHelper'; -const kBindingInstalledSymbol = Symbol('webSocketRouteBindingInstalled'); -const kInitScriptInstalledSymbol = Symbol('webSocketRouteInitScriptInstalled'); - export class WebSocketRouteDispatcher extends Dispatcher<{ guid: string }, channels.WebSocketRouteChannel, PageDispatcher | BrowserContextDispatcher> implements channels.WebSocketRouteChannel { _type_WebSocketRoute = true; private _id: string; @@ -57,18 +54,18 @@ export class WebSocketRouteDispatcher extends Dispatcher<{ guid: string }, chann (scope as any)._dispatchEvent('webSocketRoute', { webSocketRoute: this }); } - static async installIfNeeded(contextDispatcher: BrowserContextDispatcher, target: Page | BrowserContext) { + static async installIfNeeded(target: Page | BrowserContext) { + const kBindingName = '__pwWebSocketBinding'; const context = target instanceof Page ? target.context() : target; - if (!(context as any)[kBindingInstalledSymbol]) { - (context as any)[kBindingInstalledSymbol] = true; - - await context.exposeBinding('__pwWebSocketBinding', false, (source, payload: ws.BindingPayload) => { + if (!context.hasBinding(kBindingName)) { + await context.exposeBinding(kBindingName, false, (source, payload: ws.BindingPayload) => { if (payload.type === 'onCreate') { - const pageDispatcher = PageDispatcher.fromNullable(contextDispatcher, source.page); + const contextDispatcher = existingDispatcher(context); + const pageDispatcher = contextDispatcher ? PageDispatcher.fromNullable(contextDispatcher, source.page) : undefined; let scope: PageDispatcher | BrowserContextDispatcher | undefined; if (pageDispatcher && matchesPattern(pageDispatcher, context._options.baseURL, payload.url)) scope = pageDispatcher; - else if (matchesPattern(contextDispatcher, context._options.baseURL, payload.url)) + else if (contextDispatcher && matchesPattern(contextDispatcher, context._options.baseURL, payload.url)) scope = contextDispatcher; if (scope) { new WebSocketRouteDispatcher(scope, payload.id, payload.url, source.frame); @@ -91,15 +88,15 @@ export class WebSocketRouteDispatcher extends Dispatcher<{ guid: string }, chann }); } - if (!(target as any)[kInitScriptInstalledSymbol]) { - (target as any)[kInitScriptInstalledSymbol] = true; + const kInitScriptName = 'webSocketMockSource'; + if (!target.initScripts.find(s => s.name === kInitScriptName)) { await target.addInitScript(` (() => { const module = {}; ${webSocketMockSource.source} (module.exports.inject())(globalThis); })(); - `); + `, kInitScriptName); } } diff --git a/packages/playwright-core/src/server/fileUploadUtils.ts b/packages/playwright-core/src/server/fileUploadUtils.ts index 22ac13b127..2696ba0116 100644 --- a/packages/playwright-core/src/server/fileUploadUtils.ts +++ b/packages/playwright-core/src/server/fileUploadUtils.ts @@ -77,4 +77,4 @@ export async function prepareFilesForUpload(frame: Frame, params: channels.Eleme })); return { localPaths, localDirectory, filePayloads }; -} \ No newline at end of file +} diff --git a/packages/playwright-core/src/server/firefox/ffBrowser.ts b/packages/playwright-core/src/server/firefox/ffBrowser.ts index b26a2850ee..1d1c60e6ce 100644 --- a/packages/playwright-core/src/server/firefox/ffBrowser.ts +++ b/packages/playwright-core/src/server/firefox/ffBrowser.ts @@ -21,7 +21,7 @@ import type { BrowserOptions } from '../browser'; import { Browser } from '../browser'; import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext'; import * as network from '../network'; -import type { InitScript, Page, PageDelegate } from '../page'; +import type { InitScript, Page } from '../page'; import { PageBinding } from '../page'; import type { ConnectionTransport } from '../transport'; import type * as types from '../types'; @@ -136,14 +136,14 @@ export class FFBrowser extends Browser { // Abort the navigation that turned into download. ffPage._page._frameManager.frameAbortedNavigation(payload.frameId, 'Download is starting'); - let originPage = ffPage._initializedPage; + let originPage = ffPage._page.initializedOrUndefined(); // If it's a new window download, report it on the opener page. if (!originPage) { // Resume the page creation with an error. The page will automatically close right // after the download begins. ffPage._markAsError(new Error('Starting new page download')); if (ffPage._opener) - originPage = ffPage._opener._initializedPage; + originPage = ffPage._opener._page.initializedOrUndefined(); } if (!originPage) return; @@ -267,11 +267,11 @@ export class FFBrowserContext extends BrowserContext { return Array.from(this._browser._ffPages.values()).filter(ffPage => ffPage._browserContext === this); } - pages(): Page[] { - return this._ffPages().map(ffPage => ffPage._initializedPage).filter(pageOrNull => !!pageOrNull) as Page[]; + override possiblyUninitializedPages(): Page[] { + return this._ffPages().map(ffPage => ffPage._page); } - async newPageDelegate(): Promise { + override async doCreateNewPage(): Promise { assertBrowserContextIsNotOwned(this); const { targetId } = await this._browser.session.send('Browser.newPage', { browserContextId: this._browserContextId @@ -280,7 +280,7 @@ export class FFBrowserContext extends BrowserContext { throw new Error(`Invalid timezone ID: ${this._options.timezoneId}`); throw e; }); - return this._browser._ffPages.get(targetId)!; + return this._browser._ffPages.get(targetId)!._page; } async doGetCookies(urls: string[]): Promise { @@ -436,4 +436,3 @@ function toJugglerProxyOptions(proxy: types.ProxySettings) { // Prefs for quick fixes that didn't make it to the build. // Should all be moved to `playwright.cfg`. const kBandaidFirefoxUserPrefs = {}; - diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index 61790feae4..68559d7c7e 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -34,7 +34,6 @@ import type { Protocol } from './protocol'; import type { Progress } from '../progress'; import { splitErrorMessage } from '../../utils/stackTrace'; import { debugLogger } from '../../utils/debugLogger'; -import { ManualPromise } from '../../utils/manualPromise'; import { BrowserContext } from '../browserContext'; import { TargetClosedError } from '../errors'; @@ -49,9 +48,7 @@ export class FFPage implements PageDelegate { readonly _page: Page; readonly _networkManager: FFNetworkManager; readonly _browserContext: FFBrowserContext; - private _pagePromise = new ManualPromise(); - _initializedPage: Page | null = null; - private _initializationFailed = false; + private _reportedAsNew = false; readonly _opener: FFPage | null; private readonly _contextIdToContext: Map; private _eventListeners: RegisteredListener[]; @@ -102,40 +99,23 @@ export class FFPage implements PageDelegate { eventsHelper.addEventListener(this._session, 'Page.screencastFrame', this._onScreencastFrame.bind(this)), ]; - this._session.once('Page.ready', async () => { - await this._page.initOpener(this._opener); - if (this._initializationFailed) + this._session.once('Page.ready', () => { + if (this._reportedAsNew) return; - // Note: it is important to call |reportAsNew| before resolving pageOrError promise, - // so that anyone who awaits pageOrError got a ready and reported page. - this._initializedPage = this._page; - this._page.reportAsNew(); - this._pagePromise.resolve(this._page); + this._reportedAsNew = true; + this._page.reportAsNew(this._opener?._page); }); // Ideally, we somehow ensure that utility world is created before Page.ready arrives, but currently it is racy. // Therefore, we can end up with an initialized page without utility world, although very unlikely. this.addInitScript(new InitScript('', true), UTILITY_WORLD_NAME).catch(e => this._markAsError(e)); } - potentiallyUninitializedPage(): Page { - return this._page; - } - async _markAsError(error: Error) { - // Same error may be report twice: channer disconnected and session.send fails. - if (this._initializationFailed) + // Same error may be reported twice: channel disconnected and session.send fails. + if (this._reportedAsNew) return; - this._initializationFailed = true; - - if (!this._initializedPage) { - await this._page.initOpener(this._opener); - this._page.reportAsNew(error); - this._pagePromise.resolve(error); - } - } - - async pageOrError(): Promise { - return this._pagePromise; + this._reportedAsNew = true; + this._page.reportAsNew(this._opener?._page, error); } _onWebSocketCreated(event: Protocol.Page.webSocketCreatedPayload) { @@ -268,7 +248,7 @@ export class FFPage implements PageDelegate { } async _onBindingCalled(event: Protocol.Page.bindingCalledPayload) { - const pageOrError = await this.pageOrError(); + const pageOrError = await this._page.waitForInitializedOrError(); if (!(pageOrError instanceof Error)) { const context = this._contextIdToContext.get(event.executionContextId); if (context) @@ -333,7 +313,7 @@ export class FFPage implements PageDelegate { } _onVideoRecordingStarted(event: Protocol.Page.videoRecordingStartedPayload) { - this._browserContext._browser._videoStarted(this._browserContext, event.screencastId, event.file, this.pageOrError()); + this._browserContext._browser._videoStarted(this._browserContext, event.screencastId, event.file, this._page.waitForInitializedOrError()); } didClose() { diff --git a/packages/playwright-core/src/server/firefox/firefox.ts b/packages/playwright-core/src/server/firefox/firefox.ts index 9fbc409a56..1e0e4cc055 100644 --- a/packages/playwright-core/src/server/firefox/firefox.ts +++ b/packages/playwright-core/src/server/firefox/firefox.ts @@ -101,4 +101,3 @@ class JugglerReadyState extends BrowserReadyState { this._wsEndpoint.resolve(undefined); } } - diff --git a/packages/playwright-core/src/server/injected/ariaSnapshot.ts b/packages/playwright-core/src/server/injected/ariaSnapshot.ts index d541646f0e..84537cddd7 100644 --- a/packages/playwright-core/src/server/injected/ariaSnapshot.ts +++ b/packages/playwright-core/src/server/injected/ariaSnapshot.ts @@ -158,8 +158,10 @@ function toAriaNode(element: Element): AriaNode | null { if (roleUtils.kAriaSelectedRoles.includes(role)) result.selected = roleUtils.getAriaSelected(element); - if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) - result.children = [element.value]; + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { + if (element.type !== 'checkbox' && element.type !== 'radio') + result.children = [element.value]; + } return result; } diff --git a/packages/playwright-core/src/server/injected/highlight.css b/packages/playwright-core/src/server/injected/highlight.css index 096f931161..3acfd3fb1c 100644 --- a/packages/playwright-core/src/server/injected/highlight.css +++ b/packages/playwright-core/src/server/injected/highlight.css @@ -149,21 +149,24 @@ x-pw-tools-list { x-pw-tool-item { pointer-events: auto; - cursor: pointer; height: 28px; width: 28px; border-radius: 3px; } +x-pw-tool-item:not(.disabled) { + cursor: pointer; +} + x-pw-tool-item:not(.disabled):hover { background-color: hsl(0, 0%, 86%); } -x-pw-tool-item.active { +x-pw-tool-item.toggled { background-color: rgba(138, 202, 228, 0.5); } -x-pw-tool-item.active:not(.disabled):hover { +x-pw-tool-item.toggled:not(.disabled):hover { background-color: #8acae4c4; } @@ -179,18 +182,22 @@ x-pw-tool-item.disabled > x-div { cursor: default; } -x-pw-tool-item.record.active { +x-pw-tool-item.record.toggled { background-color: transparent; } -x-pw-tool-item.record.active:hover { +x-pw-tool-item.record.toggled:not(.disabled):hover { background-color: hsl(0, 0%, 86%); } -x-pw-tool-item.record.active > x-div { +x-pw-tool-item.record.toggled > x-div { background-color: #a1260d; } +x-pw-tool-item.record.disabled.toggled > x-div { + opacity: 0.8; +} + x-pw-tool-item.accept > x-div { background-color: #388a34; } diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 74ff799ff1..21fcca69a4 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -29,7 +29,7 @@ import type { CSSComplexSelectorList } from '../../utils/isomorphic/cssParser'; import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator'; import type * as channels from '@protocol/channels'; import { Highlight } from './highlight'; -import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription, getReadonly } from './roleUtils'; +import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription, getReadonly, getElementAccessibleErrorMessage } from './roleUtils'; import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils'; import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import type { Language } from '../../utils/isomorphic/locatorGenerators'; @@ -457,7 +457,8 @@ export class InjectedScript { const queryAll = (root: SelectorRoot, body: string) => { if (root.nodeType !== 1 /* Node.ELEMENT_NODE */) return []; - return isElementVisible(root as Element) === Boolean(body) ? [root as Element] : []; + const visible = body === 'true'; + return isElementVisible(root as Element) === visible ? [root as Element] : []; }; return { queryAll }; } @@ -1320,6 +1321,8 @@ export class InjectedScript { received = getElementAccessibleName(element, false /* includeHidden */); } else if (expression === 'to.have.accessible.description') { received = getElementAccessibleDescription(element, false /* includeHidden */); + } else if (expression === 'to.have.accessible.error.message') { + received = getElementAccessibleErrorMessage(element); } else if (expression === 'to.have.role') { received = getAriaRole(element) || ''; } else if (expression === 'to.have.title') { diff --git a/packages/playwright-core/src/server/injected/recorder/clipPaths.ts b/packages/playwright-core/src/server/injected/recorder/clipPaths.ts index a1e016542a..1ac490843d 100644 --- a/packages/playwright-core/src/server/injected/recorder/clipPaths.ts +++ b/packages/playwright-core/src/server/injected/recorder/clipPaths.ts @@ -28,4 +28,4 @@ import type { SvgJson } from './recorder'; // eslint-disable-next-line key-spacing, object-curly-spacing, comma-spacing, quotes const svgJson: SvgJson = {"tagName":"svg","children":[{"tagName":"defs","children":[{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-gripper"},"children":[{"tagName":"path","attrs":{"d":"M5 3h2v2H5zm0 4h2v2H5zm0 4h2v2H5zm4-8h2v2H9zm0 4h2v2H9zm0 4h2v2H9z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-circle-large-filled"},"children":[{"tagName":"path","attrs":{"d":"M8 1a6.8 6.8 0 0 1 1.86.253 6.899 6.899 0 0 1 3.083 1.805 6.903 6.903 0 0 1 1.804 3.083C14.916 6.738 15 7.357 15 8s-.084 1.262-.253 1.86a6.9 6.9 0 0 1-.704 1.674 7.157 7.157 0 0 1-2.516 2.509 6.966 6.966 0 0 1-1.668.71A6.984 6.984 0 0 1 8 15a6.984 6.984 0 0 1-1.86-.246 7.098 7.098 0 0 1-1.674-.711 7.3 7.3 0 0 1-1.415-1.094 7.295 7.295 0 0 1-1.094-1.415 7.098 7.098 0 0 1-.71-1.675A6.985 6.985 0 0 1 1 8c0-.643.082-1.262.246-1.86a6.968 6.968 0 0 1 .711-1.667 7.156 7.156 0 0 1 2.509-2.516 6.895 6.895 0 0 1 1.675-.704A6.808 6.808 0 0 1 8 1z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-inspect"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M1 3l1-1h12l1 1v6h-1V3H2v8h5v1H2l-1-1V3zm14.707 9.707L9 6v9.414l2.707-2.707h4zM10 13V8.414l3.293 3.293h-2L10 13z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-whole-word"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M0 11H1V13H15V11H16V14H15H1H0V11Z"}},{"tagName":"path","attrs":{"d":"M6.84048 11H5.95963V10.1406H5.93814C5.555 10.7995 4.99104 11.1289 4.24625 11.1289C3.69839 11.1289 3.26871 10.9839 2.95718 10.6938C2.64924 10.4038 2.49527 10.0189 2.49527 9.53906C2.49527 8.51139 3.10041 7.91341 4.3107 7.74512L5.95963 7.51416C5.95963 6.57959 5.58186 6.1123 4.82632 6.1123C4.16389 6.1123 3.56591 6.33789 3.03238 6.78906V5.88672C3.57307 5.54297 4.19612 5.37109 4.90152 5.37109C6.19416 5.37109 6.84048 6.05501 6.84048 7.42285V11ZM5.95963 8.21777L4.63297 8.40039C4.22476 8.45768 3.91682 8.55973 3.70914 8.70654C3.50145 8.84977 3.39761 9.10579 3.39761 9.47461C3.39761 9.74316 3.4925 9.96338 3.68228 10.1353C3.87564 10.3035 4.13166 10.3877 4.45035 10.3877C4.8872 10.3877 5.24706 10.2355 5.52994 9.93115C5.8164 9.62321 5.95963 9.2347 5.95963 8.76562V8.21777Z"}},{"tagName":"path","attrs":{"d":"M9.3475 10.2051H9.32601V11H8.44515V2.85742H9.32601V6.4668H9.3475C9.78076 5.73633 10.4146 5.37109 11.2489 5.37109C11.9543 5.37109 12.5057 5.61816 12.9032 6.1123C13.3042 6.60286 13.5047 7.26172 13.5047 8.08887C13.5047 9.00911 13.2809 9.74674 12.8333 10.3018C12.3857 10.8532 11.7734 11.1289 10.9964 11.1289C10.2695 11.1289 9.71989 10.821 9.3475 10.2051ZM9.32601 7.98682V8.75488C9.32601 9.20964 9.47282 9.59635 9.76644 9.91504C10.0636 10.2301 10.4396 10.3877 10.8944 10.3877C11.4279 10.3877 11.8451 10.1836 12.1458 9.77539C12.4502 9.36719 12.6024 8.79964 12.6024 8.07275C12.6024 7.46045 12.4609 6.98063 12.1781 6.6333C11.8952 6.28597 11.512 6.1123 11.0286 6.1123C10.5166 6.1123 10.1048 6.29134 9.7933 6.64941C9.48177 7.00391 9.32601 7.44971 9.32601 7.98682Z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-eye"},"children":[{"tagName":"path","attrs":{"d":"M7.99993 6.00316C9.47266 6.00316 10.6666 7.19708 10.6666 8.66981C10.6666 10.1426 9.47266 11.3365 7.99993 11.3365C6.52715 11.3365 5.33324 10.1426 5.33324 8.66981C5.33324 7.19708 6.52715 6.00316 7.99993 6.00316ZM7.99993 7.00315C7.07946 7.00315 6.33324 7.74935 6.33324 8.66981C6.33324 9.59028 7.07946 10.3365 7.99993 10.3365C8.9204 10.3365 9.6666 9.59028 9.6666 8.66981C9.6666 7.74935 8.9204 7.00315 7.99993 7.00315ZM7.99993 3.66675C11.0756 3.66675 13.7307 5.76675 14.4673 8.70968C14.5344 8.97755 14.3716 9.24908 14.1037 9.31615C13.8358 9.38315 13.5643 9.22041 13.4973 8.95248C12.8713 6.45205 10.6141 4.66675 7.99993 4.66675C5.38454 4.66675 3.12664 6.45359 2.50182 8.95555C2.43491 9.22341 2.16348 9.38635 1.89557 9.31948C1.62766 9.25255 1.46471 8.98115 1.53162 8.71321C2.26701 5.76856 4.9229 3.66675 7.99993 3.66675Z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-symbol-constant"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M4 6h8v1H4V6zm8 3H4v1h8V9z"}},{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M1 4l1-1h12l1 1v8l-1 1H2l-1-1V4zm1 0v8h12V4H2z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-check"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M14.431 3.323l-8.47 10-.79-.036-3.35-4.77.818-.574 2.978 4.24 8.051-9.506.764.646z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-close"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-pass"},"children":[{"tagName":"path","attrs":{"d":"M6.27 10.87h.71l4.56-4.56-.71-.71-4.2 4.21-1.92-1.92L4 8.6l2.27 2.27z"}},{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M8.6 1c1.6.1 3.1.9 4.2 2 1.3 1.4 2 3.1 2 5.1 0 1.6-.6 3.1-1.6 4.4-1 1.2-2.4 2.1-4 2.4-1.6.3-3.2.1-4.6-.7-1.4-.8-2.5-2-3.1-3.5C.9 9.2.8 7.5 1.3 6c.5-1.6 1.4-2.9 2.8-3.8C5.4 1.3 7 .9 8.6 1zm.5 12.9c1.3-.3 2.5-1 3.4-2.1.8-1.1 1.3-2.4 1.2-3.8 0-1.6-.6-3.2-1.7-4.3-1-1-2.2-1.6-3.6-1.7-1.3-.1-2.7.2-3.8 1-1.1.8-1.9 1.9-2.3 3.3-.4 1.3-.4 2.7.2 4 .6 1.3 1.5 2.3 2.7 3 1.2.7 2.6.9 3.9.6z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-gist"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M10.57 1.14l3.28 3.3.15.36v9.7l-.5.5h-11l-.5-.5v-13l.5-.5h7.72l.35.14zM10 5h3l-3-3v3zM3 2v12h10V6H9.5L9 5.5V2H3zm2.062 7.533l1.817-1.828L6.17 7 4 9.179v.707l2.171 2.174.707-.707-1.816-1.82zM8.8 7.714l.7-.709 2.189 2.175v.709L9.5 12.062l-.705-.709 1.831-1.82L8.8 7.714z"}}]}]}]}; -export default svgJson; \ No newline at end of file +export default svgJson; diff --git a/packages/playwright-core/src/server/injected/recorder/recorder.ts b/packages/playwright-core/src/server/injected/recorder/recorder.ts index 7374035706..b24feec5d1 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -883,9 +883,13 @@ class Overlay { this._dragState = { offsetX: this._offsetX, dragStart: { x: (event as MouseEvent).clientX, y: 0 } }; }), addEventListener(this._recordToggle, 'click', () => { + if (this._recordToggle.classList.contains('disabled')) + return; this._recorder.setMode(this._recorder.state.mode === 'none' || this._recorder.state.mode === 'standby' || this._recorder.state.mode === 'inspecting' ? 'recording' : 'standby'); }), addEventListener(this._pickLocatorToggle, 'click', () => { + if (this._pickLocatorToggle.classList.contains('disabled')) + return; const newMode: Record = { 'inspecting': 'standby', 'none': 'inspecting', @@ -929,15 +933,15 @@ class Overlay { } setUIState(state: UIState) { - this._recordToggle.classList.toggle('active', state.mode === 'recording' || state.mode === 'assertingText' || state.mode === 'assertingVisibility' || state.mode === 'assertingValue' || state.mode === 'recording-inspecting'); - this._pickLocatorToggle.classList.toggle('active', state.mode === 'inspecting' || state.mode === 'recording-inspecting'); - this._assertVisibilityToggle.classList.toggle('active', state.mode === 'assertingVisibility'); + this._recordToggle.classList.toggle('toggled', state.mode === 'recording' || state.mode === 'assertingText' || state.mode === 'assertingVisibility' || state.mode === 'assertingValue' || state.mode === 'assertingSnapshot' || state.mode === 'recording-inspecting'); + this._pickLocatorToggle.classList.toggle('toggled', state.mode === 'inspecting' || state.mode === 'recording-inspecting'); + this._assertVisibilityToggle.classList.toggle('toggled', state.mode === 'assertingVisibility'); this._assertVisibilityToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting'); - this._assertTextToggle.classList.toggle('active', state.mode === 'assertingText'); + this._assertTextToggle.classList.toggle('toggled', state.mode === 'assertingText'); this._assertTextToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting'); - this._assertValuesToggle.classList.toggle('active', state.mode === 'assertingValue'); + this._assertValuesToggle.classList.toggle('toggled', state.mode === 'assertingValue'); this._assertValuesToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting'); - this._assertSnapshotToggle.classList.toggle('active', state.mode === 'assertingSnapshot'); + this._assertSnapshotToggle.classList.toggle('toggled', state.mode === 'assertingSnapshot'); this._assertSnapshotToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting'); if (this._offsetX !== state.overlay.offsetX) { this._offsetX = state.overlay.offsetX; diff --git a/packages/playwright-core/src/server/injected/roleUtils.ts b/packages/playwright-core/src/server/injected/roleUtils.ts index cc9e6a70d0..f74c893c1b 100644 --- a/packages/playwright-core/src/server/injected/roleUtils.ts +++ b/packages/playwright-core/src/server/injected/roleUtils.ts @@ -461,6 +461,59 @@ export function getElementAccessibleDescription(element: Element, includeHidden: return accessibleDescription; } +// https://www.w3.org/TR/wai-aria-1.2/#aria-invalid +const kAriaInvalidRoles = ['application', 'checkbox', 'combobox', 'gridcell', 'listbox', 'radiogroup', 'slider', 'spinbutton', 'textbox', 'tree', 'columnheader', 'rowheader', 'searchbox', 'switch', 'treegrid']; + +function getAriaInvalid(element: Element): 'false' | 'true' | 'grammar' | 'spelling' { + const role = getAriaRole(element) || ''; + if (!role || !kAriaInvalidRoles.includes(role)) + return 'false'; + const ariaInvalid = element.getAttribute('aria-invalid'); + if (!ariaInvalid || ariaInvalid.trim() === '' || ariaInvalid.toLocaleLowerCase() === 'false') + return 'false'; + if (ariaInvalid === 'true' || ariaInvalid === 'grammar' || ariaInvalid === 'spelling') + return ariaInvalid; + return 'true'; +} + +function getValidityInvalid(element: Element) { + if ('validity' in element){ + const validity = element.validity as ValidityState | undefined; + return validity?.valid === false; + } + return false; +} + +export function getElementAccessibleErrorMessage(element: Element): string { + // SPEC: https://w3c.github.io/aria/#aria-errormessage + // + // TODO: support https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/validationMessage + const cache = cacheAccessibleErrorMessage; + let accessibleErrorMessage = cacheAccessibleErrorMessage?.get(element); + + if (accessibleErrorMessage === undefined) { + accessibleErrorMessage = ''; + + const isAriaInvalid = getAriaInvalid(element) !== 'false'; + const isValidityInvalid = getValidityInvalid(element); + if (isAriaInvalid || isValidityInvalid) { + const errorMessageId = element.getAttribute('aria-errormessage'); + const errorMessages = getIdRefs(element, errorMessageId); + // Ideally, this should be a separate "embeddedInErrorMessage", but it would follow the exact same rules. + // Relevant vague spec: https://w3c.github.io/core-aam/#ariaErrorMessage. + const parts = errorMessages.map(errorMessage => asFlatString( + getTextAlternativeInternal(errorMessage, { + visitedElements: new Set(), + embeddedInDescribedBy: { element: errorMessage, hidden: isElementHiddenForAria(errorMessage) }, + }) + )); + accessibleErrorMessage = parts.join(' ').trim(); + } + cache?.set(element, accessibleErrorMessage); + } + return accessibleErrorMessage; +} + type AccessibleNameOptions = { visitedElements: Set, includeHidden?: boolean, @@ -972,6 +1025,7 @@ let cacheAccessibleName: Map | undefined; let cacheAccessibleNameHidden: Map | undefined; let cacheAccessibleDescription: Map | undefined; let cacheAccessibleDescriptionHidden: Map | undefined; +let cacheAccessibleErrorMessage: Map | undefined; let cacheIsHidden: Map | undefined; let cachePseudoContentBefore: Map | undefined; let cachePseudoContentAfter: Map | undefined; @@ -983,6 +1037,7 @@ export function beginAriaCaches() { cacheAccessibleNameHidden ??= new Map(); cacheAccessibleDescription ??= new Map(); cacheAccessibleDescriptionHidden ??= new Map(); + cacheAccessibleErrorMessage ??= new Map(); cacheIsHidden ??= new Map(); cachePseudoContentBefore ??= new Map(); cachePseudoContentAfter ??= new Map(); @@ -994,6 +1049,7 @@ export function endAriaCaches() { cacheAccessibleNameHidden = undefined; cacheAccessibleDescription = undefined; cacheAccessibleDescriptionHidden = undefined; + cacheAccessibleErrorMessage = undefined; cacheIsHidden = undefined; cachePseudoContentBefore = undefined; cachePseudoContentAfter = undefined; diff --git a/packages/playwright-core/src/server/injected/selectorGenerator.ts b/packages/playwright-core/src/server/injected/selectorGenerator.ts index 7aa22e6f0c..04f1598762 100644 --- a/packages/playwright-core/src/server/injected/selectorGenerator.ts +++ b/packages/playwright-core/src/server/injected/selectorGenerator.ts @@ -38,9 +38,9 @@ const kOtherTestIdScore = 2; // other data-test* attributes const kIframeByAttributeScore = 10; const kBeginPenalizedScore = 50; -const kPlaceholderScore = 100; -const kLabelScore = 120; -const kRoleWithNameScore = 140; +const kRoleWithNameScore = 100; +const kPlaceholderScore = 120; +const kLabelScore = 140; const kAltTextScore = 160; const kTextScore = 180; const kTitleScore = 200; @@ -268,7 +268,7 @@ function buildNoTextCandidates(injectedScript: InjectedScript, element: Element, if (input.placeholder) { candidates.push({ engine: 'internal:attr', selector: `[placeholder=${escapeForAttributeSelector(input.placeholder, true)}]`, score: kPlaceholderScoreExact }); for (const alternative of suitableTextAlternatives(input.placeholder)) - candidates.push({ engine: 'internal:attr', selector: `[placeholder=${escapeForAttributeSelector(alternative.text, false)}]`, score: kPlaceholderScore - alternative.scoreBouns }); + candidates.push({ engine: 'internal:attr', selector: `[placeholder=${escapeForAttributeSelector(alternative.text, false)}]`, score: kPlaceholderScore - alternative.scoreBonus }); } } @@ -277,7 +277,7 @@ function buildNoTextCandidates(injectedScript: InjectedScript, element: Element, const labelText = label.normalized; candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(labelText, true), score: kLabelScoreExact }); for (const alternative of suitableTextAlternatives(labelText)) - candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(alternative.text, false), score: kLabelScore - alternative.scoreBouns }); + candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(alternative.text, false), score: kLabelScore - alternative.scoreBonus }); } const ariaRole = getAriaRole(element); @@ -308,28 +308,28 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i if (title) { candidates.push([{ engine: 'internal:attr', selector: `[title=${escapeForAttributeSelector(title, true)}]`, score: kTitleScoreExact }]); for (const alternative of suitableTextAlternatives(title)) - candidates.push([{ engine: 'internal:attr', selector: `[title=${escapeForAttributeSelector(alternative.text, false)}]`, score: kTitleScore - alternative.scoreBouns }]); + candidates.push([{ engine: 'internal:attr', selector: `[title=${escapeForAttributeSelector(alternative.text, false)}]`, score: kTitleScore - alternative.scoreBonus }]); } const alt = element.getAttribute('alt'); if (alt && ['APPLET', 'AREA', 'IMG', 'INPUT'].includes(element.nodeName)) { candidates.push([{ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(alt, true)}]`, score: kAltTextScoreExact }]); for (const alternative of suitableTextAlternatives(alt)) - candidates.push([{ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(alternative.text, false)}]`, score: kAltTextScore - alternative.scoreBouns }]); + candidates.push([{ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(alternative.text, false)}]`, score: kAltTextScore - alternative.scoreBonus }]); } const text = elementText(injectedScript._evaluator._cacheText, element).normalized; + const textAlternatives = text ? suitableTextAlternatives(text) : []; if (text) { - const alternatives = suitableTextAlternatives(text); if (isTargetNode) { if (text.length <= 80) candidates.push([{ engine: 'internal:text', selector: escapeForTextSelector(text, true), score: kTextScoreExact }]); - for (const alternative of alternatives) - candidates.push([{ engine: 'internal:text', selector: escapeForTextSelector(alternative.text, false), score: kTextScore - alternative.scoreBouns }]); + for (const alternative of textAlternatives) + candidates.push([{ engine: 'internal:text', selector: escapeForTextSelector(alternative.text, false), score: kTextScore - alternative.scoreBonus }]); } 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 }]); + for (const alternative of textAlternatives) + candidates.push([cssToken, { engine: 'internal:has-text', selector: escapeForTextSelector(alternative.text, false), score: kTextScore - alternative.scoreBonus }]); if (text.length <= 80) { const re = new RegExp('^' + escapeRegExp(text) + '$'); candidates.push([cssToken, { engine: 'internal:has-text', selector: escapeForTextSelector(re, false), score: kTextScoreRegex }]); @@ -340,9 +340,18 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i if (ariaRole && !['none', 'presentation'].includes(ariaRole)) { const ariaName = getElementAccessibleName(element, false); if (ariaName) { - candidates.push([{ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, true)}]`, score: kRoleWithNameScoreExact }]); + const roleToken = { engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, true)}]`, score: kRoleWithNameScoreExact }; + candidates.push([roleToken]); for (const alternative of suitableTextAlternatives(ariaName)) - candidates.push([{ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(alternative.text, false)}]`, score: kRoleWithNameScore - alternative.scoreBouns }]); + candidates.push([{ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(alternative.text, false)}]`, score: kRoleWithNameScore - alternative.scoreBonus }]); + } else { + const roleToken = { engine: 'internal:role', selector: `${ariaRole}`, score: kRoleWithoutNameScore }; + for (const alternative of textAlternatives) + candidates.push([roleToken, { engine: 'internal:has-text', selector: escapeForTextSelector(alternative.text, false), score: kTextScore - alternative.scoreBonus }]); + if (text.length <= 80) { + const re = new RegExp('^' + escapeRegExp(text) + '$'); + candidates.push([roleToken, { engine: 'internal:has-text', selector: escapeForTextSelector(re, false), score: kTextScoreRegex }]); + } } } @@ -527,14 +536,14 @@ function trimWordBoundary(text: string, maxLength: number) { } function suitableTextAlternatives(text: string) { - let result: { text: string, scoreBouns: number }[] = []; + let result: { text: string, scoreBonus: number }[] = []; { const match = text.match(/^([\d.,]+)[^.,\w]/); const leadingNumberLength = match ? match[1].length : 0; if (leadingNumberLength) { const alt = trimWordBoundary(text.substring(leadingNumberLength).trimStart(), 80); - result.push({ text: alt, scoreBouns: alt.length <= 30 ? 2 : 1 }); + result.push({ text: alt, scoreBonus: alt.length <= 30 ? 2 : 1 }); } } @@ -543,20 +552,20 @@ function suitableTextAlternatives(text: string) { const trailingNumberLength = match ? match[1].length : 0; if (trailingNumberLength) { const alt = trimWordBoundary(text.substring(0, text.length - trailingNumberLength).trimEnd(), 80); - result.push({ text: alt, scoreBouns: alt.length <= 30 ? 2 : 1 }); + result.push({ text: alt, scoreBonus: alt.length <= 30 ? 2 : 1 }); } } if (text.length <= 30) { - result.push({ text, scoreBouns: 0 }); + result.push({ text, scoreBonus: 0 }); } else { - result.push({ text: trimWordBoundary(text, 80), scoreBouns: 0 }); - result.push({ text: trimWordBoundary(text, 30), scoreBouns: 1 }); + result.push({ text: trimWordBoundary(text, 80), scoreBonus: 0 }); + result.push({ text: trimWordBoundary(text, 30), scoreBonus: 1 }); } result = result.filter(r => r.text); if (!result.length) - result.push({ text: text.substring(0, 80), scoreBouns: 0 }); + result.push({ text: text.substring(0, 80), scoreBonus: 0 }); return result; } diff --git a/packages/playwright-core/src/server/injected/selectorUtils.ts b/packages/playwright-core/src/server/injected/selectorUtils.ts index 746e12af9d..da0f869fa0 100644 --- a/packages/playwright-core/src/server/injected/selectorUtils.ts +++ b/packages/playwright-core/src/server/injected/selectorUtils.ts @@ -73,6 +73,8 @@ export function elementText(cache: Map, root: if (child.nodeType === Node.TEXT_NODE) { value.full += child.nodeValue || ''; currentImmediate += child.nodeValue || ''; + } else if (child.nodeType === Node.COMMENT_NODE) { + continue; } else { if (currentImmediate) value.immediate.push(currentImmediate); diff --git a/packages/playwright-core/src/server/injected/yaml.ts b/packages/playwright-core/src/server/injected/yaml.ts index 7daebc574a..a9365c15bd 100644 --- a/packages/playwright-core/src/server/injected/yaml.ts +++ b/packages/playwright-core/src/server/injected/yaml.ts @@ -82,6 +82,10 @@ function yamlStringNeedsQuotes(str: string): boolean { if (/[{}`]/.test(str)) return true; + // YAML array starts with [ + if (/^\[/.test(str)) + return true; + // Non-string types recognized by YAML if (!isNaN(Number(str)) || ['y', 'n', 'yes', 'no', 'true', 'false', 'on', 'off', 'null'].includes(str.toLowerCase())) return true; diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index d626b1ed3c..9b85837b65 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -59,8 +59,6 @@ export interface PageDelegate { addInitScript(initScript: InitScript): Promise; removeNonInternalInitScripts(): Promise; closePage(runBeforeUnload: boolean): Promise; - potentiallyUninitializedPage(): Page; - pageOrError(): Promise; navigateFrame(frame: frames.Frame, url: string, referrer: string | undefined): Promise; @@ -139,7 +137,8 @@ export class Page extends SdkObject { private _closedState: 'open' | 'closing' | 'closed' = 'open'; private _closedPromise = new ManualPromise(); - private _initialized = false; + private _initialized: Page | Error | undefined; + private _initializedPromise = new ManualPromise(); private _eventsToEmitAfterInitialized: { event: string | symbol, args: any[] }[] = []; private _crashed = false; readonly openScope = new LongStandingScope(); @@ -164,7 +163,6 @@ export class Page extends SdkObject { _clientRequestInterceptor: network.RouteHandler | undefined; _serverRequestInterceptor: network.RouteHandler | undefined; _ownedContext: BrowserContext | undefined; - _pageIsError: Error | undefined; _video: Artifact | null = null; _opener: Page | undefined; private _isServerSideOnly = false; @@ -194,23 +192,24 @@ export class Page extends SdkObject { this.coverage = delegate.coverage ? delegate.coverage() : null; } - async initOpener(opener: PageDelegate | null) { - if (!opener) - return; - const openerPage = await opener.pageOrError(); - if (openerPage instanceof Page && !openerPage.isClosed()) - this._opener = openerPage; + async reportAsNew(opener: Page | undefined, error: Error | undefined = undefined, contextEvent: string = BrowserContext.Events.Page) { + if (opener) { + const openerPageOrError = await opener.waitForInitializedOrError(); + if (openerPageOrError instanceof Page && !openerPageOrError.isClosed()) + this._opener = openerPageOrError; + } + this._markInitialized(error, contextEvent); } - reportAsNew(error: Error | undefined = undefined, contextEvent: string = BrowserContext.Events.Page) { + private _markInitialized(error: Error | undefined = undefined, contextEvent: string = BrowserContext.Events.Page) { if (error) { // Initialization error could have happened because of // context/browser closure. Just ignore the page. if (this._browserContext.isClosingOrClosed()) return; - this._setIsError(error); + this._frameManager.createDummyMainFrameIfNeeded(); } - this._initialized = true; + this._initialized = error || this; this.emitOnContext(contextEvent, this); for (const { event, args } of this._eventsToEmitAfterInitialized) @@ -224,12 +223,20 @@ export class Page extends SdkObject { this.emit(Page.Events.Close); else this.instrumentation.onPageOpen(this); + + // Note: it is important to resolve _initializedPromise at the end, + // so that anyone who awaits waitForInitializedOrError got a ready and reported page. + this._initializedPromise.resolve(this._initialized); } - initializedOrUndefined() { + initializedOrUndefined(): Page | undefined { return this._initialized ? this : undefined; } + waitForInitializedOrError(): Promise { + return this._initializedPromise; + } + emitOnContext(event: string | symbol, ...args: any[]) { if (this._isServerSideOnly) return; @@ -557,8 +564,8 @@ export class Page extends SdkObject { await this._delegate.bringToFront(); } - async addInitScript(source: string) { - const initScript = new InitScript(source); + async addInitScript(source: string, name?: string) { + const initScript = new InitScript(source, false /* internal */, name); this.initScripts.push(initScript); await this._delegate.addInitScript(initScript); } @@ -709,11 +716,6 @@ export class Page extends SdkObject { await this._ownedContext.close(options); } - private _setIsError(error: Error) { - this._pageIsError = error; - this._frameManager.createDummyMainFrameIfNeeded(); - } - isClosed(): boolean { return this._closedState === 'closed'; } @@ -951,8 +953,9 @@ function addPageBinding(playwrightBinding: string, bindingName: string, needsHan export class InitScript { readonly source: string; readonly internal: boolean; + readonly name?: string; - constructor(source: string, internal?: boolean) { + constructor(source: string, internal?: boolean, name?: string) { const guid = createGuid(); this.source = `(() => { globalThis.__pwInitScripts = globalThis.__pwInitScripts || {}; @@ -963,6 +966,7 @@ export class InitScript { ${source} })();`; this.internal = !!internal; + this.name = name; } } diff --git a/packages/playwright-core/src/server/recorder/contextRecorder.ts b/packages/playwright-core/src/server/recorder/contextRecorder.ts index d7a3c908e8..2e4f445087 100644 --- a/packages/playwright-core/src/server/recorder/contextRecorder.ts +++ b/packages/playwright-core/src/server/recorder/contextRecorder.ts @@ -300,7 +300,6 @@ async function generateFrameSelectorInParent(parent: Frame, frame: Frame): Promi }, frameElement); return selector; } catch (e) { - return e.toString(); } }, monotonicTime() + 2000); if (!result.timedOut && result.result) diff --git a/packages/playwright-core/src/server/recorder/recorderCollection.ts b/packages/playwright-core/src/server/recorder/recorderCollection.ts index b5216d7382..2643dece91 100644 --- a/packages/playwright-core/src/server/recorder/recorderCollection.ts +++ b/packages/playwright-core/src/server/recorder/recorderCollection.ts @@ -38,7 +38,7 @@ export class RecorderCollection extends EventEmitter { restart() { this._actions = []; - this._fireChange(); + this.emit('change', []); } setEnabled(enabled: boolean) { @@ -128,6 +128,7 @@ export class RecorderCollection extends EventEmitter { private _fireChange() { if (!this._enabled) return; + this.emit('change', collapseActions(this._actions)); } } diff --git a/packages/playwright-core/src/server/registry/dependencies.ts b/packages/playwright-core/src/server/registry/dependencies.ts index 44f56f3d2f..60ef80d846 100644 --- a/packages/playwright-core/src/server/registry/dependencies.ts +++ b/packages/playwright-core/src/server/registry/dependencies.ts @@ -21,7 +21,7 @@ import childProcess from 'child_process'; import * as utils from '../../utils'; import { spawnAsync } from '../../utils/spawnAsync'; import { hostPlatform, isOfficiallySupportedPlatform } from '../../utils/hostPlatform'; -import { buildPlaywrightCLICommand } from '.'; +import { buildPlaywrightCLICommand, registry } from '.'; import { deps } from './nativeDeps'; import { getPlaywrightVersion } from '../../utils/userAgent'; @@ -122,12 +122,12 @@ export async function installDependenciesLinux(targets: Set, dr }); } -export async function validateDependenciesWindows(windowsExeAndDllDirectories: string[]) { +export async function validateDependenciesWindows(sdkLanguage: string, windowsExeAndDllDirectories: string[]) { const directoryPaths = windowsExeAndDllDirectories; const lddPaths: string[] = []; for (const directoryPath of directoryPaths) lddPaths.push(...(await executablesOrSharedLibraries(directoryPath))); - const allMissingDeps = await Promise.all(lddPaths.map(lddPath => missingFileDependenciesWindows(lddPath))); + const allMissingDeps = await Promise.all(lddPaths.map(lddPath => missingFileDependenciesWindows(sdkLanguage, lddPath))); const missingDeps: Set = new Set(); for (const deps of allMissingDeps) { for (const dep of deps) @@ -302,8 +302,8 @@ async function executablesOrSharedLibraries(directoryPath: string): Promise> { - const executable = path.join(__dirname, '..', '..', '..', 'bin', 'PrintDeps.exe'); +async function missingFileDependenciesWindows(sdkLanguage: string, filePath: string): Promise> { + const executable = registry.findExecutable('winldd')!.executablePathOrDie(sdkLanguage); const dirname = path.dirname(filePath); const { stdout, code } = await spawnAsync(executable, [filePath], { cwd: dirname, diff --git a/packages/playwright-core/src/server/registry/index.ts b/packages/playwright-core/src/server/registry/index.ts index cd2f2f1d32..c4d7f2ffbb 100644 --- a/packages/playwright-core/src/server/registry/index.ts +++ b/packages/playwright-core/src/server/registry/index.ts @@ -37,13 +37,9 @@ const PACKAGE_PATH = path.join(__dirname, '..', '..', '..'); const BIN_PATH = path.join(__dirname, '..', '..', '..', 'bin'); const PLAYWRIGHT_CDN_MIRRORS = [ - 'https://playwright.azureedge.net/dbazure/download/playwright', // ESRP CDN + 'https://cdn.playwright.dev/dbazure/download/playwright', // ESRP CDN 'https://playwright.download.prss.microsoft.com/dbazure/download/playwright', // Directly hit ESRP CDN - - // Old endpoints which hit the Storage Bucket directly: - 'https://playwright.azureedge.net', - 'https://playwright-akamai.azureedge.net', // Actually Edgio which will be retired Q4 2025. - 'https://playwright-verizon.azureedge.net', // Actually Edgio which will be retired Q4 2025. + 'https://cdn.playwright.dev', // Hit the Storage Bucket directly ]; if (process.env.PW_TEST_CDN_THAT_SHOULD_WORK) { @@ -83,6 +79,11 @@ const EXECUTABLE_PATHS = { 'mac': ['ffmpeg-mac'], 'win': ['ffmpeg-win64.exe'], }, + 'winldd': { + 'linux': undefined, + 'mac': undefined, + 'win': ['PrintDeps.exe'], + }, }; type DownloadPaths = Record; @@ -174,6 +175,35 @@ const DOWNLOAD_PATHS: Record = { 'mac15-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-mac-arm64.zip', 'win64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-win64.zip', }, + 'chromium-tip-of-tree-headless-shell': { + '': undefined, + 'ubuntu18.04-x64': undefined, + 'ubuntu20.04-x64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-linux.zip', + 'ubuntu22.04-x64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-linux.zip', + 'ubuntu24.04-x64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-linux.zip', + 'ubuntu18.04-arm64': undefined, + 'ubuntu20.04-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-linux-arm64.zip', + 'ubuntu22.04-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-linux-arm64.zip', + 'ubuntu24.04-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-linux-arm64.zip', + 'debian11-x64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-linux.zip', + 'debian11-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-linux-arm64.zip', + 'debian12-x64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-linux.zip', + 'debian12-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-linux-arm64.zip', + 'mac10.13': undefined, + 'mac10.14': undefined, + 'mac10.15': undefined, + 'mac11': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-mac.zip', + 'mac11-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-mac-arm64.zip', + 'mac12': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-mac.zip', + 'mac12-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-mac-arm64.zip', + 'mac13': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-mac.zip', + 'mac13-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-mac-arm64.zip', + 'mac14': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-mac.zip', + 'mac14-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-mac-arm64.zip', + 'mac15': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-mac.zip', + 'mac15-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-mac-arm64.zip', + 'win64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-win64.zip', + }, 'firefox': { '': undefined, 'ubuntu18.04-x64': undefined, @@ -290,6 +320,35 @@ const DOWNLOAD_PATHS: Record = { 'mac15-arm64': 'builds/ffmpeg/%s/ffmpeg-mac-arm64.zip', 'win64': 'builds/ffmpeg/%s/ffmpeg-win64.zip', }, + 'winldd': { + '': undefined, + 'ubuntu18.04-x64': undefined, + 'ubuntu20.04-x64': undefined, + 'ubuntu22.04-x64': undefined, + 'ubuntu24.04-x64': undefined, + 'ubuntu18.04-arm64': undefined, + 'ubuntu20.04-arm64': undefined, + 'ubuntu22.04-arm64': undefined, + 'ubuntu24.04-arm64': undefined, + 'debian11-x64': undefined, + 'debian11-arm64': undefined, + 'debian12-x64': undefined, + 'debian12-arm64': undefined, + 'mac10.13': undefined, + 'mac10.14': undefined, + 'mac10.15': undefined, + 'mac11': undefined, + 'mac11-arm64': undefined, + 'mac12': undefined, + 'mac12-arm64': undefined, + 'mac13': undefined, + 'mac13-arm64': undefined, + 'mac14': undefined, + 'mac14-arm64': undefined, + 'mac15': undefined, + 'mac15-arm64': undefined, + 'win64': 'builds/winldd/%s/winldd-win64.zip', + }, 'android': { '': 'builds/android/%s/android.zip', 'ubuntu18.04-x64': undefined, @@ -386,7 +445,14 @@ type BrowsersJSONDescriptor = { }; function readDescriptors(browsersJSON: BrowsersJSON): BrowsersJSONDescriptor[] { - return (browsersJSON['browsers']).map(obj => { + const headlessShells: BrowsersJSON['browsers'] = []; + for (const browserName of ['chromium', 'chromium-tip-of-tree']) { + headlessShells.push({ + ...browsersJSON.browsers.find(browser => browser.name === browserName)!, + name: `${browserName}-headless-shell`, + }); + } + return [...browsersJSON.browsers, ...headlessShells].map(obj => { const name = obj.name; const revisionOverride = (obj.revisionOverrides || {})[hostPlatform]; const revision = revisionOverride || obj.revision; @@ -410,10 +476,10 @@ function readDescriptors(browsersJSON: BrowsersJSON): BrowsersJSONDescriptor[] { } export type BrowserName = 'chromium' | 'firefox' | 'webkit' | 'bidi'; -type InternalTool = 'ffmpeg' | 'firefox-beta' | 'chromium-tip-of-tree' | 'chromium-headless-shell' | 'android'; +type InternalTool = 'ffmpeg' | 'winldd' | 'firefox-beta' | 'chromium-tip-of-tree' | 'chromium-headless-shell' | 'chromium-tip-of-tree-headless-shell' | 'android'; type BidiChannel = 'bidi-firefox-stable' | 'bidi-firefox-beta' | 'bidi-firefox-nightly' | 'bidi-chrome-canary' | 'bidi-chrome-stable' | 'bidi-chromium'; type ChromiumChannel = 'chrome' | 'chrome-beta' | 'chrome-dev' | 'chrome-canary' | 'msedge' | 'msedge-beta' | 'msedge-dev' | 'msedge-canary'; -const allDownloadable = ['android', 'chromium', 'firefox', 'webkit', 'ffmpeg', 'firefox-beta', 'chromium-tip-of-tree', 'chromium-headless-shell']; +const allDownloadable = ['android', 'chromium', 'firefox', 'webkit', 'ffmpeg', 'firefox-beta', 'chromium-tip-of-tree', 'chromium-headless-shell', 'chromium-tip-of-tree-headless-shell']; export interface Executable { type: 'browser' | 'tool' | 'channel'; @@ -514,6 +580,24 @@ export class Registry { _isHermeticInstallation: true, }); + const chromiumTipOfTreeHeadlessShell = descriptors.find(d => d.name === 'chromium-tip-of-tree-headless-shell')!; + const chromiumTipOfTreeHeadlessShellExecutable = findExecutablePath(chromiumTipOfTreeHeadlessShell.dir, 'chromium-headless-shell'); + this._executables.push({ + type: 'channel', + name: 'chromium-tip-of-tree-headless-shell', + browserName: 'chromium', + directory: chromiumTipOfTreeHeadlessShell.dir, + executablePath: () => chromiumTipOfTreeHeadlessShellExecutable, + executablePathOrDie: (sdkLanguage: string) => executablePathOrDie('chromium', chromiumTipOfTreeHeadlessShellExecutable, chromiumTipOfTreeHeadlessShell.installByDefault, sdkLanguage), + installType: chromiumTipOfTreeHeadlessShell.installByDefault ? 'download-by-default' : 'download-on-demand', + _validateHostRequirements: (sdkLanguage: string) => this._validateHostRequirements(sdkLanguage, chromiumTipOfTreeHeadlessShell.dir, ['chrome-linux'], [], ['chrome-win']), + downloadURLs: this._downloadURLs(chromiumTipOfTreeHeadlessShell), + browserVersion: chromium.browserVersion, + _install: () => this._downloadExecutable(chromiumTipOfTreeHeadlessShell, chromiumTipOfTreeHeadlessShellExecutable), + _dependencyGroup: 'chromium', + _isHermeticInstallation: true, + }); + const chromiumTipOfTree = descriptors.find(d => d.name === 'chromium-tip-of-tree')!; const chromiumTipOfTreeExecutable = findExecutablePath(chromiumTipOfTree.dir, 'chromium'); this._executables.push({ @@ -722,6 +806,22 @@ export class Registry { _dependencyGroup: 'tools', _isHermeticInstallation: true, }); + const winldd = descriptors.find(d => d.name === 'winldd')!; + const winlddExecutable = findExecutablePath(winldd.dir, 'winldd'); + this._executables.push({ + type: 'tool', + name: 'winldd', + browserName: undefined, + directory: winldd.dir, + executablePath: () => winlddExecutable, + executablePathOrDie: (sdkLanguage: string) => executablePathOrDie('winldd', winlddExecutable, winldd.installByDefault, sdkLanguage), + installType: process.platform === 'win32' ? 'download-by-default' : 'none', + _validateHostRequirements: () => Promise.resolve(), + downloadURLs: this._downloadURLs(winldd), + _install: () => this._downloadExecutable(winldd, winlddExecutable), + _dependencyGroup: 'tools', + _isHermeticInstallation: true, + }); const android = descriptors.find(d => d.name === 'android')!; this._executables.push({ type: 'tool', @@ -894,7 +994,7 @@ export class Registry { if (os.platform() === 'linux') return await validateDependenciesLinux(sdkLanguage, linuxLddDirectories.map(d => path.join(browserDirectory, d)), dlOpenLibraries); if (os.platform() === 'win32' && os.arch() === 'x64') - return await validateDependenciesWindows(windowsExeAndDllDirectories.map(d => path.join(browserDirectory, d))); + return await validateDependenciesWindows(sdkLanguage, windowsExeAndDllDirectories.map(d => path.join(browserDirectory, d))); } async installDeps(executablesToInstallDeps: Executable[], dryRun: boolean) { @@ -1215,6 +1315,8 @@ export async function installBrowsersForNpmInstall(browsers: string[]) { return false; } const executables: Executable[] = []; + if (process.platform === 'win32') + executables.push(registry.findExecutable('winldd')!); for (const browserName of browsers) { const executable = registry.findExecutable(browserName); if (!executable || executable.installType === 'none') diff --git a/packages/playwright-core/src/server/registry/nativeDeps.ts b/packages/playwright-core/src/server/registry/nativeDeps.ts index 6e25e3a14f..9653210a45 100644 --- a/packages/playwright-core/src/server/registry/nativeDeps.ts +++ b/packages/playwright-core/src/server/registry/nativeDeps.ts @@ -1104,4 +1104,3 @@ deps['debian12-arm64'] = { ...deps['debian12-x64'].lib2package, }, }; - diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index 4e850f4a84..2517ea24ce 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -354,4 +354,4 @@ export function rewriteOpenSSLErrorIfNeeded(error: Error): Error { 'For more details, see https://github.com/openssl/openssl/blob/master/README-PROVIDERS.md#the-legacy-provider', 'You could probably modernize the certificate by following the steps at https://github.com/nodejs/node/issues/40672#issuecomment-1243648223', ].join('\n')); -} \ No newline at end of file +} diff --git a/packages/playwright-core/src/server/socksInterceptor.ts b/packages/playwright-core/src/server/socksInterceptor.ts index 498e8bfe73..6b29636a00 100644 --- a/packages/playwright-core/src/server/socksInterceptor.ts +++ b/packages/playwright-core/src/server/socksInterceptor.ts @@ -83,4 +83,3 @@ export class SocksInterceptor { function tChannelForSocks(names: '*' | string[], arg: any, path: string, context: ValidatorContext) { throw new ValidationError(`${path}: channels are not expected in SocksSupport`); } - diff --git a/packages/playwright-core/src/server/trace/recorder/snapshotterInjected.ts b/packages/playwright-core/src/server/trace/recorder/snapshotterInjected.ts index 4d575ef0e9..4730c3e5dd 100644 --- a/packages/playwright-core/src/server/trace/recorder/snapshotterInjected.ts +++ b/packages/playwright-core/src/server/trace/recorder/snapshotterInjected.ts @@ -438,13 +438,13 @@ export function frameSnapshotStreamer(snapshotStreamer: string, removeNoScript: expectValue(value); attrs[kSelectedAttribute] = value; } - if (nodeName === 'CANVAS') { - const boundingRect = (element as HTMLCanvasElement).getBoundingClientRect(); + if (nodeName === 'CANVAS' || nodeName === 'IFRAME' || nodeName === 'FRAME') { + const boundingRect = (element as HTMLElement).getBoundingClientRect(); const value = JSON.stringify({ - left: boundingRect.left / window.innerWidth, - top: boundingRect.top / window.innerHeight, - right: boundingRect.right / window.innerWidth, - bottom: boundingRect.bottom / window.innerHeight + left: boundingRect.left, + top: boundingRect.top, + right: boundingRect.right, + bottom: boundingRect.bottom }); expectValue(kBoundingRectAttribute); expectValue(value); diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index b8dc3e5314..f852f6c996 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -125,7 +125,13 @@ 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()}`; + let baseUrl = '.'; + if (process.env.PW_HMR) { + baseUrl = 'http://localhost:44223'; // port is hardcoded in build.js + params.set('server', server.urlPrefix('precise')); + } + + const urlPath = `${baseUrl}/trace/${options.webApp || 'index.html'}?${params.toString()}`; server.routePath('/', (_, response) => { response.statusCode = 302; response.setHeader('Location', urlPath); diff --git a/packages/playwright-core/src/server/transport.ts b/packages/playwright-core/src/server/transport.ts index e213e030e7..507225b04c 100644 --- a/packages/playwright-core/src/server/transport.ts +++ b/packages/playwright-core/src/server/transport.ts @@ -24,6 +24,7 @@ import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from '../utils/happy- import type { HeadersArray } from './types'; export const perMessageDeflate = { + clientNoContextTakeover: true, zlibDeflateOptions: { level: 3, }, diff --git a/packages/playwright-core/src/server/webkit/protocol.d.ts b/packages/playwright-core/src/server/webkit/protocol.d.ts index 7c279f9e1e..9abd47bcfd 100644 --- a/packages/playwright-core/src/server/webkit/protocol.d.ts +++ b/packages/playwright-core/src/server/webkit/protocol.d.ts @@ -6689,6 +6689,10 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the * Cookie Same-Site policy. */ sameSite: CookieSameSitePolicy; + /** + * Cookie partition key. If null and partitioned property is true, then key must be computed. + */ + partitionKey?: string; } /** * Accessibility Node @@ -7073,6 +7077,10 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the */ export type setCookieParameters = { cookie: Cookie; + /** + * If true, then cookie's partition key should be set. + */ + shouldPartition?: boolean; } export type setCookieReturnValue = { } diff --git a/packages/playwright-core/src/server/webkit/wkBrowser.ts b/packages/playwright-core/src/server/webkit/wkBrowser.ts index f4f9f732a5..86833088d8 100644 --- a/packages/playwright-core/src/server/webkit/wkBrowser.ts +++ b/packages/playwright-core/src/server/webkit/wkBrowser.ts @@ -20,7 +20,7 @@ import { Browser } from '../browser'; import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext'; import { assert } from '../../utils'; import * as network from '../network'; -import type { InitScript, Page, PageDelegate } from '../page'; +import type { InitScript, Page } from '../page'; import type { ConnectionTransport } from '../transport'; import type * as types from '../types'; import type * as channels from '@protocol/channels'; @@ -121,14 +121,14 @@ export class WKBrowser extends Browser { // abort navigation that is still running. We should be able to fix this by // instrumenting policy decision start/proceed/cancel. page._page._frameManager.frameAbortedNavigation(payload.frameId, 'Download is starting'); - let originPage = page._initializedPage; + let originPage = page._page.initializedOrUndefined(); // If it's a new window download, report it on the opener page. if (!originPage) { // Resume the page creation with an error. The page will automatically close right // after the download begins. page._firstNonInitialNavigationCommittedReject(new Error('Starting new page download')); if (page._opener) - originPage = page._opener._initializedPage; + originPage = page._opener._page.initializedOrUndefined(); } if (!originPage) return; @@ -239,14 +239,14 @@ export class WKBrowserContext extends BrowserContext { return Array.from(this._browser._wkPages.values()).filter(wkPage => wkPage._browserContext === this); } - pages(): Page[] { - return this._wkPages().map(wkPage => wkPage._initializedPage).filter(pageOrNull => !!pageOrNull) as Page[]; + override possiblyUninitializedPages(): Page[] { + return this._wkPages().map(wkPage => wkPage._page); } - async newPageDelegate(): Promise { + override async doCreateNewPage(): Promise { assertBrowserContextIsNotOwned(this); const { pageProxyId } = await this._browser._browserSession.send('Playwright.createPage', { browserContextId: this._browserContextId }); - return this._browser._wkPages.get(pageProxyId)!; + return this._browser._wkPages.get(pageProxyId)!._page; } async doGetCookies(urls: string[]): Promise { diff --git a/packages/playwright-core/src/server/webkit/wkExecutionContext.ts b/packages/playwright-core/src/server/webkit/wkExecutionContext.ts index 52b8ba3677..2b75745cbd 100644 --- a/packages/playwright-core/src/server/webkit/wkExecutionContext.ts +++ b/packages/playwright-core/src/server/webkit/wkExecutionContext.ts @@ -115,6 +115,8 @@ function potentiallyUnserializableValue(remoteObject: Protocol.Runtime.RemoteObj } function rewriteError(error: Error): Error { + if (error.message.includes('Object has too long reference chain')) + throw new Error('Cannot serialize result: object reference chain is too long.'); if (!js.isJavaScriptErrorInEvaluate(error) && !isSessionClosedError(error)) return new Error('Execution context was destroyed, most likely because of a navigation.'); return error; diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index a6e2145ce4..15005f589b 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -43,7 +43,6 @@ import { WKInterceptableRequest, WKRouteImpl } from './wkInterceptableRequest'; import { WKProvisionalPage } from './wkProvisionalPage'; import { WKWorkers } from './wkWorkers'; import { debugLogger } from '../../utils/debugLogger'; -import { ManualPromise } from '../../utils/manualPromise'; import { BrowserContext } from '../browserContext'; import { TargetClosedError } from '../errors'; @@ -56,7 +55,6 @@ export class WKPage implements PageDelegate { _session: WKSession; private _provisionalPage: WKProvisionalPage | null = null; readonly _page: Page; - private readonly _pagePromise = new ManualPromise(); private readonly _pageProxySession: WKSession; readonly _opener: WKPage | null; private readonly _requestIdToRequest = new Map(); @@ -66,7 +64,6 @@ export class WKPage implements PageDelegate { private _sessionListeners: RegisteredListener[] = []; private _eventListeners: RegisteredListener[]; readonly _browserContext: WKBrowserContext; - _initializedPage: Page | null = null; private _firstNonInitialNavigationCommittedPromise: Promise; private _firstNonInitialNavigationCommittedFulfill = () => {}; _firstNonInitialNavigationCommittedReject = (e: Error) => {}; @@ -111,10 +108,6 @@ export class WKPage implements PageDelegate { } } - potentiallyUninitializedPage(): Page { - return this._page; - } - private async _initializePageProxySession() { if (this._page._browserContext.isSettingStorageState()) return; @@ -283,7 +276,7 @@ export class WKPage implements PageDelegate { } handleProvisionalLoadFailed(event: Protocol.Playwright.provisionalLoadFailedPayload) { - if (!this._initializedPage) { + if (!this._page.initializedOrUndefined()) { this._firstNonInitialNavigationCommittedReject(new Error('Initial load failed')); return; } @@ -300,10 +293,6 @@ export class WKPage implements PageDelegate { this._nextWindowOpenPopupFeatures = event.windowFeatures; } - async pageOrError(): Promise { - return this._pagePromise; - } - private async _onTargetCreated(event: Protocol.Target.targetCreatedPayload) { const { targetInfo } = event; const session = new WKSession(this._pageProxySession.connection, targetInfo.targetId, (message: any) => { @@ -316,7 +305,7 @@ export class WKPage implements PageDelegate { assert(targetInfo.type === 'page', 'Only page targets are expected in WebKit, received: ' + targetInfo.type); if (!targetInfo.isProvisional) { - assert(!this._initializedPage); + assert(!this._page.initializedOrUndefined()); let pageOrError: Page | Error; try { this._setSession(session); @@ -343,12 +332,7 @@ export class WKPage implements PageDelegate { // Avoid rejection on disconnect. this._firstNonInitialNavigationCommittedPromise.catch(() => {}); } - await this._page.initOpener(this._opener); - // Note: it is important to call |reportAsNew| before resolving pageOrError promise, - // so that anyone who awaits pageOrError got a ready and reported page. - this._initializedPage = pageOrError instanceof Page ? pageOrError : null; - this._page.reportAsNew(pageOrError instanceof Page ? undefined : pageOrError); - this._pagePromise.resolve(pageOrError); + this._page.reportAsNew(this._opener?._page, pageOrError instanceof Page ? undefined : pageOrError); } else { assert(targetInfo.isProvisional); assert(!this._provisionalPage); @@ -515,7 +499,7 @@ export class WKPage implements PageDelegate { } private async _onBindingCalled(contextId: Protocol.Runtime.ExecutionContextId, argument: string) { - const pageOrError = await this.pageOrError(); + const pageOrError = await this._page.waitForInitializedOrError(); if (!(pageOrError instanceof Error)) { const context = this._contextIdToContext.get(contextId); if (context) @@ -821,7 +805,7 @@ export class WKPage implements PageDelegate { toolbarHeight: this._toolbarHeight() }); this._recordingVideoFile = options.outputFile; - this._browserContext._browser._videoStarted(this._browserContext, screencastId, options.outputFile, this.pageOrError()); + this._browserContext._browser._videoStarted(this._browserContext, screencastId, options.outputFile, this._page.waitForInitializedOrError()); } async _stopVideo(): Promise { @@ -849,7 +833,7 @@ export class WKPage implements PageDelegate { this.validateScreenshotDimension(rect.height, omitDeviceScaleFactor); const result = await this._session.send('Page.snapshotRect', { ...rect, coordinateSystem: documentRect ? 'Page' : 'Viewport', omitDeviceScaleFactor }); const prefix = 'data:image/png;base64,'; - let buffer = Buffer.from(result.dataURL.substr(prefix.length), 'base64'); + let buffer: Buffer = Buffer.from(result.dataURL.substr(prefix.length), 'base64'); if (format === 'jpeg') buffer = jpegjs.encode(PNG.sync.read(buffer), quality).data; return buffer; diff --git a/packages/playwright-core/src/server/webkit/wkProvisionalPage.ts b/packages/playwright-core/src/server/webkit/wkProvisionalPage.ts index b8af1b9ca3..6d7459c978 100644 --- a/packages/playwright-core/src/server/webkit/wkProvisionalPage.ts +++ b/packages/playwright-core/src/server/webkit/wkProvisionalPage.ts @@ -105,4 +105,4 @@ export class WKProvisionalPage { assert(!frameTree.frame.parentId); this._mainFrameId = frameTree.frame.id; } -} \ No newline at end of file +} diff --git a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts index 04f3040547..355d0cec5f 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts @@ -36,7 +36,7 @@ export interface LocatorFactory { } export function asLocator(lang: Language, selector: string, isFrameLocator: boolean = false): string { - return asLocators(lang, selector, isFrameLocator)[0]; + return asLocators(lang, selector, isFrameLocator, 1)[0]; } export function asLocators(lang: Language, selector: string, isFrameLocator: boolean = false, maxOutputSize = 20, preferredQuote?: Quote): string[] { @@ -220,7 +220,7 @@ function combineTokens(factory: LocatorFactory, tokens: string[][], maxOutputSiz const visit = (index: number) => { if (index === tokens.length) { result.push(factory.chainLocators(currentTokens)); - return currentTokens.length < maxOutputSize; + return result.length < maxOutputSize; } for (const taken of tokens[index]) { currentTokens[index] = taken; diff --git a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts index e0c10b53fd..fff3d078ff 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts @@ -91,7 +91,8 @@ function parseLocator(locator: string, testIdAttributeName: string): { selector: .replace(/newregex\(([^)]+)\)/g, 'r$1') .replace(/string=/g, '=') .replace(/regex=/g, '=') - .replace(/,,/g, ','); + .replace(/,,/g, ',') + .replace(/,\)/g, ')'); const preferredQuote = params.map(p => p.quote).filter(quote => '\'"`'.includes(quote))[0] as Quote | undefined; return { selector: transform(template, params, testIdAttributeName), preferredQuote }; @@ -174,6 +175,7 @@ function transform(template: string, params: TemplateParams, testIdAttributeName .replace(/filter\(,?hasnot2=([^)]+)\)/g, 'internal:has-not=$1') .replace(/,exact=false/g, '') .replace(/,exact=true/g, 's') + .replace(/,includehidden=/g, ',include-hidden=') .replace(/\,/g, ']['); const parts = template.split('.'); @@ -214,25 +216,30 @@ function transform(template: string, params: TemplateParams, testIdAttributeName } export function locatorOrSelectorAsSelector(language: Language, locator: string, testIdAttributeName: string): string { + try { + return unsafeLocatorOrSelectorAsSelector(language, locator, testIdAttributeName); + } catch (e) { + return ''; + } +} + +export function unsafeLocatorOrSelectorAsSelector(language: Language, locator: string, testIdAttributeName: string): string { try { parseSelector(locator); return locator; } catch (e) { } - try { - const { selector, preferredQuote } = parseLocator(locator, testIdAttributeName); - const locators = asLocators(language, selector, undefined, undefined, preferredQuote); - const digest = digestForComparison(language, locator); - if (locators.some(candidate => digestForComparison(language, candidate) === digest)) - return selector; - } catch (e) { - } + const { selector, preferredQuote } = parseLocator(locator, testIdAttributeName); + const locators = asLocators(language, selector, undefined, undefined, preferredQuote); + const digest = digestForComparison(language, locator); + if (locators.some(candidate => digestForComparison(language, candidate) === digest)) + return selector; return ''; } function digestForComparison(language: Language, locator: string) { locator = locator.replace(/\s/g, ''); if (language === 'javascript') - locator = locator.replace(/\\?["`]/g, '\''); + locator = locator.replace(/\\?["`]/g, '\'').replace(/,{}/g, ''); return locator; } diff --git a/packages/playwright-core/src/utils/isomorphic/mimeType.ts b/packages/playwright-core/src/utils/isomorphic/mimeType.ts index 2f8b9d4829..407d935281 100644 --- a/packages/playwright-core/src/utils/isomorphic/mimeType.ts +++ b/packages/playwright-core/src/utils/isomorphic/mimeType.ts @@ -20,4 +20,4 @@ export function isJsonMimeType(mimeType: string) { export function isTextualMimeType(mimeType: string) { return !!mimeType.match(/^(text\/.*?|application\/(json|(x-)?javascript|xml.*?|ecmascript|graphql|x-www-form-urlencoded)|image\/svg(\+xml)?|application\/.*?(\+json|\+xml))(;\s*charset=.*)?$/); -} \ No newline at end of file +} diff --git a/packages/playwright-core/src/utils/linuxUtils.ts b/packages/playwright-core/src/utils/linuxUtils.ts index d8e227f107..5d98c823a5 100644 --- a/packages/playwright-core/src/utils/linuxUtils.ts +++ b/packages/playwright-core/src/utils/linuxUtils.ts @@ -77,4 +77,3 @@ function parseOSReleaseText(osReleaseText: string): Map { } return fields; } - diff --git a/packages/playwright-core/src/utils/sequence.ts b/packages/playwright-core/src/utils/sequence.ts index 27756fabeb..b063e5c488 100644 --- a/packages/playwright-core/src/utils/sequence.ts +++ b/packages/playwright-core/src/utils/sequence.ts @@ -63,4 +63,4 @@ export function findRepeatedSubsequences(s: string[]): { sequence: string[]; cou } return result; -} \ No newline at end of file +} diff --git a/packages/playwright-core/src/utils/wsServer.ts b/packages/playwright-core/src/utils/wsServer.ts index 63c0ed1fed..636a57c3cc 100644 --- a/packages/playwright-core/src/utils/wsServer.ts +++ b/packages/playwright-core/src/utils/wsServer.ts @@ -25,6 +25,7 @@ let lastConnectionId = 0; const kConnectionSymbol = Symbol('kConnection'); export const perMessageDeflate = { + serverNoContextTakeover: true, zlibDeflateOptions: { level: 3, }, diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 7086d87f2e..78c1c668c4 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -3609,8 +3609,6 @@ export interface Page { /** * Returns the PDF buffer. * - * **NOTE** Generating a pdf is currently only supported in Chromium headless. - * * `page.pdf()` generates a pdf of the page with `print` css media. To generate a pdf with `screen` media, call * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#page-emulate-media) before calling * `page.pdf()`: @@ -4296,7 +4294,7 @@ export interface Page { * takes priority over * [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout). * - * @param timeout Maximum time in milliseconds + * @param timeout Maximum time in milliseconds. Pass `0` to disable timeout. */ setDefaultTimeout(timeout: number): void; @@ -9195,7 +9193,7 @@ export interface BrowserContext { * take priority over * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout). * - * @param timeout Maximum time in milliseconds + * @param timeout Maximum time in milliseconds. Pass `0` to disable timeout. */ setDefaultTimeout(timeout: number): void; @@ -9591,10 +9589,11 @@ export interface Browser { * In case this browser is connected to, clears all created contexts belonging to this browser and disconnects from * the browser server. * - * **NOTE** This is similar to force quitting the browser. Therefore, you should call + * **NOTE** This is similar to force-quitting the browser. To close pages gracefully and ensure you receive page close + * events, call * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) on - * any [BrowserContext](https://playwright.dev/docs/api/class-browsercontext)'s you explicitly created earlier with - * [browser.newContext([options])](https://playwright.dev/docs/api/class-browser#browser-new-context) **before** + * any [BrowserContext](https://playwright.dev/docs/api/class-browsercontext) instances you explicitly created earlier + * using [browser.newContext([options])](https://playwright.dev/docs/api/class-browser#browser-new-context) **before** * calling [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close). * * The [Browser](https://playwright.dev/docs/api/class-browser) object itself is considered to be disposed and cannot @@ -12430,7 +12429,7 @@ export interface Locator { /** * Captures the aria snapshot of the given element. Read more about [aria snapshots](https://playwright.dev/docs/aria-snapshots) and - * [expect(locator).toMatchAriaSnapshot(expected[, options])](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-match-aria-snapshot) + * [expect(locator).toMatchAriaSnapshot(expected[, options])](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-match-aria-snapshot-2) * for the corresponding assertion. * * **Usage** @@ -13854,18 +13853,22 @@ export interface Locator { /** * Creates a locator matching all elements that match one or both of the two locators. * - * Note that when both locators match something, the resulting locator will have multiple matches and violate - * [locator strictness](https://playwright.dev/docs/locators#strictness) guidelines. + * Note that when both locators match something, the resulting locator will have multiple matches, potentially causing + * a [locator strictness](https://playwright.dev/docs/locators#strictness) violation. * * **Usage** * * Consider a scenario where you'd like to click on a "New email" button, but sometimes a security settings dialog * shows up instead. In this case, you can wait for either a "New email" button, or a dialog and act accordingly. * + * **NOTE** If both "New email" button and security dialog appear on screen, the "or" locator will match both of them, + * possibly throwing the ["strict mode violation" error](https://playwright.dev/docs/locators#strictness). In this case, you can use + * [locator.first()](https://playwright.dev/docs/api/class-locator#locator-first) to only match one of them. + * * ```js * const newEmail = page.getByRole('button', { name: 'New' }); * const dialog = page.getByText('Confirm security settings'); - * await expect(newEmail.or(dialog)).toBeVisible(); + * await expect(newEmail.or(dialog).first()).toBeVisible(); * if (await dialog.isVisible()) * await page.getByRole('button', { name: 'Dismiss' }).click(); * await newEmail.click(); @@ -14717,7 +14720,7 @@ export interface BrowserType { /** * Browser distribution channel. * - * Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#opt-in-to-new-headless-mode). + * Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#chromium-new-headless-mode). * * Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or * "msedge-canary" to use branded [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge). @@ -15216,7 +15219,7 @@ export interface BrowserType { /** * Browser distribution channel. * - * Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#opt-in-to-new-headless-mode). + * Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#chromium-new-headless-mode). * * Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or * "msedge-canary" to use branded [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge). @@ -18592,6 +18595,19 @@ export interface Clock { * await page.clock.pauseAt('2020-02-02'); * ``` * + * For best results, install the clock before navigating the page and set it to a time slightly before the intended + * test time. This ensures that all timers run normally during page loading, preventing the page from getting stuck. + * Once the page has fully loaded, you can safely use + * [clock.pauseAt(time)](https://playwright.dev/docs/api/class-clock#clock-pause-at) to pause the clock. + * + * ```js + * // Initialize clock with some time before the test time and let the page load + * // naturally. `Date.now` will progress as the timers fire. + * await page.clock.install({ time: new Date('2024-12-10T08:00:00') }); + * await page.goto('http://localhost:3333'); + * await page.clock.pauseAt(new Date('2024-12-10T10:00:00')); + * ``` + * * @param time Time to pause at. */ pauseAt(time: number|string|Date): Promise; @@ -21554,7 +21570,7 @@ export interface LaunchOptions { /** * Browser distribution channel. * - * Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#opt-in-to-new-headless-mode). + * Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#chromium-new-headless-mode). * * Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or * "msedge-canary" to use branded [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge). diff --git a/packages/playwright-ct-core/index.d.ts b/packages/playwright-ct-core/index.d.ts index 9c323ef6df..f6710ee1f6 100644 --- a/packages/playwright-ct-core/index.d.ts +++ b/packages/playwright-ct-core/index.d.ts @@ -52,7 +52,7 @@ export function defineConfig(config: PlaywrightTestConfig): PlaywrightTestConfig export function defineConfig(config: PlaywrightTestConfig): PlaywrightTestConfig; export function defineConfig(config: PlaywrightTestConfig): PlaywrightTestConfig; export function defineConfig(config: PlaywrightTestConfig, ...configs: PlaywrightTestConfig[]): PlaywrightTestConfig; -export function defineConfig(config: PlaywrightTestConfig, ...configs: PlaywrightTestConfig[]): PlaywrightTestConfig; -export function defineConfig(config: PlaywrightTestConfig, ...configs: PlaywrightTestConfig[]): PlaywrightTestConfig; +export function defineConfig(config: PlaywrightTestConfig, ...configs: PlaywrightTestConfig[]): PlaywrightTestConfig; +export function defineConfig(config: PlaywrightTestConfig, ...configs: PlaywrightTestConfig[]): PlaywrightTestConfig; export { expect, devices, Locator } from 'playwright/test'; diff --git a/packages/playwright-ct-react/hooks.d.ts b/packages/playwright-ct-react/hooks.d.ts index 1093f1a3a3..f05ad1ffdf 100644 --- a/packages/playwright-ct-react/hooks.d.ts +++ b/packages/playwright-ct-react/hooks.d.ts @@ -14,8 +14,10 @@ * limitations under the License. */ +import type React from 'react'; + export declare function beforeMount( - callback: (params: { hooksConfig?: HooksConfig; App: () => JSX.Element }) => Promise + callback: (params: { hooksConfig?: HooksConfig; App: () => React.JSX.Element }) => Promise ): void; export declare function afterMount( callback: (params: { hooksConfig?: HooksConfig }) => Promise diff --git a/packages/playwright-ct-react/index.d.ts b/packages/playwright-ct-react/index.d.ts index c5e6d6d2da..d7000c5c85 100644 --- a/packages/playwright-ct-react/index.d.ts +++ b/packages/playwright-ct-react/index.d.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import type React from 'react'; + import type { TestType, Locator } from '@playwright/experimental-ct-core'; export interface MountOptions { @@ -22,12 +24,12 @@ export interface MountOptions { export interface MountResult extends Locator { unmount(): Promise; - update(component: JSX.Element): Promise; + update(component: React.JSX.Element): Promise; } export const test: TestType<{ mount( - component: JSX.Element, + component: React.JSX.Element, options?: MountOptions ): Promise; }>; diff --git a/packages/playwright/jsx-runtime.mjs b/packages/playwright/jsx-runtime.mjs index 742708825e..1dfd5835aa 100644 --- a/packages/playwright/jsx-runtime.mjs +++ b/packages/playwright/jsx-runtime.mjs @@ -18,4 +18,4 @@ import jsxRuntime from './jsx-runtime.js'; export const jsx = jsxRuntime.jsx; export const jsxs = jsxRuntime.jsxs; -export const Fragment = jsxRuntime.Fragment; \ No newline at end of file +export const Fragment = jsxRuntime.Fragment; diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index 0e8babce10..443cb58319 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -56,6 +56,7 @@ export class FullConfigInternal { cliFailOnFlakyTests?: boolean; cliLastFailed?: boolean; testIdMatcher?: Matcher; + lastFailedTestIdMatcher?: Matcher; defineConfigWasUsed = false; globalSetups: string[] = []; @@ -298,4 +299,4 @@ const configInternalSymbol = Symbol('configInternalSymbol'); export function getProjectId(project: FullProject): string { return (project as any).__projectId!; -} \ No newline at end of file +} diff --git a/packages/playwright/src/common/ipc.ts b/packages/playwright/src/common/ipc.ts index ad0e91f5c3..909df3dc8f 100644 --- a/packages/playwright/src/common/ipc.ts +++ b/packages/playwright/src/common/ipc.ts @@ -38,8 +38,8 @@ export type ConfigCLIOverrides = { timeout?: number; tsconfig?: string; ignoreSnapshots?: boolean; - updateSnapshots?: 'all'|'changed'|'missing'|'none'; - updateSourceMethod?: 'overwrite'|'patch'|'3way'; + updateSnapshots?: 'all' | 'changed' | 'missing' | 'none'; + updateSourceMethod?: 'overwrite' | 'patch' | '3way'; workers?: number | string; projects?: { name: string, use?: any }[], use?: any; diff --git a/packages/playwright/src/common/testLoader.ts b/packages/playwright/src/common/testLoader.ts index a85d93545d..6a8940ee19 100644 --- a/packages/playwright/src/common/testLoader.ts +++ b/packages/playwright/src/common/testLoader.ts @@ -68,7 +68,7 @@ export async function loadTestFile(file: string, rootDir: string, testErrors?: T suite.allTests().map(t => files.add(t.location.file)); if (files.size === 1) { // All tests point to one file. - const mappedFile = files.values().next().value; + const mappedFile = files.values().next().value!; if (suite.location.file !== mappedFile) { // The file is different, check for a likely source map case. if (path.extname(mappedFile) !== path.extname(suite.location.file)) diff --git a/packages/playwright/src/common/testType.ts b/packages/playwright/src/common/testType.ts index d3c2f1c23a..61f9b36824 100644 --- a/packages/playwright/src/common/testType.ts +++ b/packages/playwright/src/common/testType.ts @@ -56,7 +56,9 @@ export class TestTypeImpl { test.fail.only = wrapFunctionWithLocation(this._createTest.bind(this, 'fail.only')); test.slow = wrapFunctionWithLocation(this._modifier.bind(this, 'slow')); test.setTimeout = wrapFunctionWithLocation(this._setTimeout.bind(this)); - test.step = this._step.bind(this); + test.step = this._step.bind(this, 'pass'); + test.step.fail = this._step.bind(this, 'fail'); + test.step.fixme = this._step.bind(this, 'fixme'); test.use = wrapFunctionWithLocation(this._use.bind(this)); test.extend = wrapFunctionWithLocation(this._extend.bind(this)); test.info = () => { @@ -257,22 +259,40 @@ export class TestTypeImpl { suite._use.push({ fixtures, location }); } - async _step(title: string, body: () => T | Promise, options: {box?: boolean, location?: Location, timeout?: number } = {}): Promise { + async _step(expectation: 'pass'|'fail'|'fixme', title: string, body: () => T | Promise, options: {box?: boolean, location?: Location, timeout?: number } = {}): Promise { const testInfo = currentTestInfo(); if (!testInfo) throw new Error(`test.step() can only be called from a test`); + if (expectation === 'fixme') + return undefined as T; const step = testInfo._addStep({ category: 'test.step', title, location: options.location, box: options.box }); return await zones.run('stepZone', step, async () => { + let result; + let error; try { - const result = await raceAgainstDeadline(async () => body(), options.timeout ? monotonicTime() + options.timeout : 0); - if (result.timedOut) - throw new errors.TimeoutError(`Step timeout ${options.timeout}ms exceeded.`); - step.complete({}); - return result.result; - } catch (error) { + result = await raceAgainstDeadline(async () => body(), options.timeout ? monotonicTime() + options.timeout : 0); + } catch (e) { + error = e; + } + if (result?.timedOut) { + const error = new errors.TimeoutError(`Step timeout ${options.timeout}ms exceeded.`); step.complete({ error }); throw error; } + const expectedToFail = expectation === 'fail'; + if (error) { + step.complete({ error }); + if (expectedToFail) + return undefined as T; + throw error; + } + if (expectedToFail) { + error = new Error(`Step is expected to fail, but passed`); + step.complete({ error }); + throw error; + } + step.complete({}); + return result!.result; }); } diff --git a/packages/playwright/src/isomorphic/testServerInterface.ts b/packages/playwright/src/isomorphic/testServerInterface.ts index 257d04c681..694610ecdd 100644 --- a/packages/playwright/src/isomorphic/testServerInterface.ts +++ b/packages/playwright/src/isomorphic/testServerInterface.ts @@ -94,7 +94,8 @@ export interface TestServerInterface { testIds?: string[]; headed?: boolean; workers?: number | string; - updateSnapshots?: 'all' | 'none' | 'missing'; + updateSnapshots?: 'all' | 'changed' | 'missing' | 'none'; + updateSourceMethod?: 'overwrite' | 'patch' | '3way'; reporters?: string[], trace?: 'on' | 'off'; video?: 'on' | 'off'; diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index 0bd116e7a1..d4c3287d33 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -35,6 +35,7 @@ import { toContainText, toHaveAccessibleDescription, toHaveAccessibleName, + toHaveAccessibleErrorMessage, toHaveAttribute, toHaveClass, toHaveCount, @@ -224,6 +225,7 @@ const customAsyncMatchers = { toContainText, toHaveAccessibleDescription, toHaveAccessibleName, + toHaveAccessibleErrorMessage, toHaveAttribute, toHaveClass, toHaveCount, diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts index 8a8089e91e..3962c0cae9 100644 --- a/packages/playwright/src/matchers/matchers.ts +++ b/packages/playwright/src/matchers/matchers.ts @@ -205,6 +205,18 @@ export function toHaveAccessibleName( } } +export function toHaveAccessibleErrorMessage( + this: ExpectMatcherState, + locator: LocatorEx, + expected: string | RegExp, + options?: { timeout?: number; ignoreCase?: boolean }, +) { + return toMatchText.call(this, 'toHaveAccessibleErrorMessage', locator, 'Locator', async (isNot, timeout) => { + const expectedText = serializeExpectedTextValues([expected], { ignoreCase: options?.ignoreCase, normalizeWhiteSpace: true }); + return await locator._expect('to.have.accessible.error.message', { expectedText: expectedText, isNot, timeout }); + }, expected, options); +} + export function toHaveAttribute( this: ExpectMatcherState, locator: LocatorEx, diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts index 552ad2e36d..152ceb6ba9 100644 --- a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts @@ -18,19 +18,25 @@ import type { LocatorEx } from './matchers'; import type { ExpectMatcherState } from '../../types/test'; import { kNoElementsFoundError, matcherHint, type MatcherResult } from './matcherHint'; -import { colors } from 'playwright-core/lib/utilsBundle'; import { EXPECTED_COLOR } from '../common/expectBundle'; -import { callLogText } from '../util'; +import { callLogText, sanitizeFilePathBeforeExtension, trimLongString } from '../util'; import { printReceivedStringContainExpectedSubstring } from './expect'; import { currentTestInfo } from '../common/globals'; import type { MatcherReceived } from '@injected/ariaSnapshot'; -import { escapeTemplateString } from 'playwright-core/lib/utils'; +import { escapeTemplateString, isString, sanitizeForFilePath } from 'playwright-core/lib/utils'; +import fs from 'fs'; +import path from 'path'; + +type ToMatchAriaSnapshotExpected = { + name?: string; + path?: string; +} | string; export async function toMatchAriaSnapshot( this: ExpectMatcherState, receiver: LocatorEx, - expected: string, - options: { timeout?: number, matchSubstring?: boolean } = {}, + expectedParam: ToMatchAriaSnapshotExpected, + options: { timeout?: number } = {}, ): Promise> { const matcherName = 'toMatchAriaSnapshot'; @@ -39,7 +45,7 @@ export async function toMatchAriaSnapshot( throw new Error(`toMatchAriaSnapshot() must be called during the test`); if (testInfo._projectInternal.ignoreSnapshots) - return { pass: !this.isNot, message: () => '', name: 'toMatchAriaSnapshot', expected }; + return { pass: !this.isNot, message: () => '', name: 'toMatchAriaSnapshot', expected: '' }; const updateSnapshots = testInfo.config.updateSnapshots; @@ -48,12 +54,25 @@ export async function toMatchAriaSnapshot( promise: this.promise, }; - if (typeof expected !== 'string') { - throw new Error([ - matcherHint(this, receiver, matcherName, receiver, expected, matcherOptions), - `${colors.bold('Matcher error')}: ${EXPECTED_COLOR('expected',)} value must be a string`, - this.utils.printWithType('Expected', expected, this.utils.printExpected) - ].join('\n\n')); + let expected: string; + let expectedPath: string | undefined; + if (isString(expectedParam)) { + expected = expectedParam; + } else { + if (expectedParam?.path) { + expectedPath = expectedParam.path; + } else if (expectedParam?.name) { + expectedPath = testInfo.snapshotPath(sanitizeFilePathBeforeExtension(expectedParam.name)); + } else { + let snapshotNames = (testInfo as any)[snapshotNamesSymbol] as SnapshotNames; + if (!snapshotNames) { + snapshotNames = { anonymousSnapshotIndex: 0 }; + (testInfo as any)[snapshotNamesSymbol] = snapshotNames; + } + const fullTitleWithoutSpec = [...testInfo.titlePath.slice(1), ++snapshotNames.anonymousSnapshotIndex].join(' '); + expectedPath = testInfo.snapshotPath(sanitizeForFilePath(trimLongString(fullTitleWithoutSpec)) + '.yml'); + } + expected = await fs.promises.readFile(expectedPath, 'utf8').catch(() => ''); } const generateMissingBaseline = updateSnapshots === 'missing' && !expected; @@ -102,8 +121,24 @@ export async function toMatchAriaSnapshot( if ((updateSnapshots === 'all') || (updateSnapshots === 'changed' && pass === this.isNot) || generateMissingBaseline) { - const suggestedRebaseline = `toMatchAriaSnapshot(\`\n${escapeTemplateString(indent(typedReceived.regex, '{indent} '))}\n{indent}\`)`; - return { pass: this.isNot, message: () => '', name: 'toMatchAriaSnapshot', suggestedRebaseline }; + if (expectedPath) { + await fs.promises.mkdir(path.dirname(expectedPath), { recursive: true }); + await fs.promises.writeFile(expectedPath, typedReceived.regex, 'utf8'); + const relativePath = path.relative(process.cwd(), expectedPath); + if (updateSnapshots === 'missing') { + const message = `A snapshot doesn't exist at ${relativePath}, writing actual.`; + testInfo._hasNonRetriableError = true; + testInfo._failWithError(new Error(message)); + } else { + const message = `A snapshot is generated at ${relativePath}.`; + /* eslint-disable no-console */ + console.log(message); + } + return { pass: true, message: () => '', name: 'toMatchAriaSnapshot' }; + } else { + const suggestedRebaseline = `toMatchAriaSnapshot(\`\n${escapeTemplateString(indent(typedReceived.regex, '{indent} '))}\n{indent}\`)`; + return { pass: false, message: () => '', name: 'toMatchAriaSnapshot', suggestedRebaseline }; + } } } @@ -134,3 +169,9 @@ function unshift(snapshot: string): string { function indent(snapshot: string, indent: string): string { return snapshot.split('\n').map(line => indent + line).join('\n'); } + +const snapshotNamesSymbol = Symbol('snapshotNames'); + +type SnapshotNames = { + anonymousSnapshotIndex: number; +}; diff --git a/packages/playwright/src/reporters/base.ts b/packages/playwright/src/reporters/base.ts index 9317e4e1bc..38b69d9d65 100644 --- a/packages/playwright/src/reporters/base.ts +++ b/packages/playwright/src/reporters/base.ts @@ -258,7 +258,7 @@ export class BaseReporter implements ReporterV2 { console.log(colors.yellow(' Slow test file: ') + file + colors.yellow(` (${milliseconds(duration)})`)); }); if (slowTests.length) - console.log(colors.yellow(' Consider splitting slow test files to speed up parallel execution')); + console.log(colors.yellow(' Consider running tests from slow files in parallel, see https://playwright.dev/docs/test-parallel.')); } private _printSummary(summary: string) { @@ -381,7 +381,7 @@ export function formatTestTitle(config: FullConfig, test: TestCase, step?: TestS if (omitLocation) location = `${relativeTestPath(config, test)}`; else - location = `${relativeTestPath(config, test)}:${step?.location?.line ?? test.location.line}:${step?.location?.column ?? test.location.column}`; + location = `${relativeTestPath(config, test)}:${test.location.line}:${test.location.column}`; const projectTitle = projectName ? `[${projectName}] › ` : ''; const testTitle = `${projectTitle}${location} › ${titles.join(' › ')}`; const extraTags = test.tags.filter(t => !testTitle.includes(t)); diff --git a/packages/playwright/src/reporters/html.ts b/packages/playwright/src/reporters/html.ts index 406be15e83..75d345e319 100644 --- a/packages/playwright/src/reporters/html.ts +++ b/packages/playwright/src/reporters/html.ts @@ -21,7 +21,7 @@ import path from 'path'; import type { TransformCallback } from 'stream'; import { Transform } from 'stream'; import { codeFrameColumns } from '../transform/babelBundle'; -import type { FullResult, FullConfig, Location, Suite, TestCase as TestCasePublic, TestResult as TestResultPublic, TestStep as TestStepPublic, TestError } from '../../types/testReporter'; +import type * as api from '../../types/testReporter'; import { HttpServer, assert, calculateSha1, copyFileAndMakeWritable, gracefullyProcessExitDoNotHang, removeFolders, sanitizeForFilePath, toPosixPath } from 'playwright-core/lib/utils'; import { colors, formatError, formatResultFailure, stripAnsiEscapes } from './base'; import { resolveReporterOutputPath } from '../util'; @@ -56,8 +56,8 @@ type HtmlReporterOptions = { }; class HtmlReporter implements ReporterV2 { - private config!: FullConfig; - private suite!: Suite; + private config!: api.FullConfig; + private suite!: api.Suite; private _options: HtmlReporterOptions; private _outputFolder!: string; private _attachmentsBaseURL!: string; @@ -65,7 +65,7 @@ class HtmlReporter implements ReporterV2 { private _port: number | undefined; private _host: string | undefined; private _buildResult: { ok: boolean, singleTestId: string | undefined } | undefined; - private _topLevelErrors: TestError[] = []; + private _topLevelErrors: api.TestError[] = []; constructor(options: HtmlReporterOptions) { this._options = options; @@ -79,11 +79,11 @@ class HtmlReporter implements ReporterV2 { return false; } - onConfigure(config: FullConfig) { + onConfigure(config: api.FullConfig) { this.config = config; } - onBegin(suite: Suite) { + onBegin(suite: api.Suite) { const { outputFolder, open, attachmentsBaseURL, host, port } = this._resolveOptions(); this._outputFolder = outputFolder; this._open = open; @@ -125,11 +125,11 @@ class HtmlReporter implements ReporterV2 { return !!relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath); } - onError(error: TestError): void { + onError(error: api.TestError): void { this._topLevelErrors.push(error); } - async onEnd(result: FullResult) { + async onEnd(result: api.FullResult) { const projectSuites = this.suite.suites; await removeFolders([this._outputFolder]); const builder = new HtmlBuilder(this.config, this._outputFolder, this._attachmentsBaseURL); @@ -223,14 +223,14 @@ export function startHtmlReportServer(folder: string): HttpServer { } class HtmlBuilder { - private _config: FullConfig; + private _config: api.FullConfig; private _reportFolder: string; private _stepsInFile = new MultiMap(); private _dataZipFile: ZipFile; private _hasTraces = false; private _attachmentsBaseURL: string; - constructor(config: FullConfig, outputDir: string, attachmentsBaseURL: string) { + constructor(config: api.FullConfig, outputDir: string, attachmentsBaseURL: string) { this._config = config; this._reportFolder = outputDir; fs.mkdirSync(this._reportFolder, { recursive: true }); @@ -238,7 +238,7 @@ class HtmlBuilder { this._attachmentsBaseURL = attachmentsBaseURL; } - async build(metadata: Metadata, projectSuites: Suite[], result: FullResult, topLevelErrors: TestError[]): Promise<{ ok: boolean, singleTestId: string | undefined }> { + async build(metadata: Metadata, projectSuites: api.Suite[], result: api.FullResult, topLevelErrors: api.TestError[]): Promise<{ ok: boolean, singleTestId: string | undefined }> { const data = new Map(); for (const projectSuite of projectSuites) { for (const fileSuite of projectSuite.suites) { @@ -309,7 +309,7 @@ class HtmlBuilder { let singleTestId: string | undefined; if (htmlReport.stats.total === 1) { - const testFile: TestFile = data.values().next().value.testFile; + const testFile: TestFile = data.values().next().value!.testFile; singleTestId = testFile.tests[0].testId; } @@ -378,7 +378,7 @@ class HtmlBuilder { this._dataZipFile.addBuffer(Buffer.from(JSON.stringify(data)), fileName); } - private _processSuite(suite: Suite, projectName: string, path: string[], outTests: TestEntry[]) { + private _processSuite(suite: api.Suite, projectName: string, path: string[], outTests: TestEntry[]) { const newPath = [...path, suite.title]; suite.entries().forEach(e => { if (e.type === 'test') @@ -388,7 +388,7 @@ class HtmlBuilder { }); } - private _createTestEntry(test: TestCasePublic, projectName: string, path: string[]): TestEntry { + private _createTestEntry(test: api.TestCase, projectName: string, path: string[]): TestEntry { const duration = test.results.reduce((a, r) => a + r.duration, 0); const location = this._relativeLocation(test.location)!; path = path.slice(1).filter(path => path.length > 0); @@ -500,7 +500,7 @@ class HtmlBuilder { }).filter(Boolean) as TestAttachment[]; } - private _createTestResult(test: TestCasePublic, result: TestResultPublic): TestResult { + private _createTestResult(test: api.TestCase, result: api.TestResult): TestResult { return { duration: result.duration, startTime: result.startTime.toISOString(), @@ -531,7 +531,7 @@ class HtmlBuilder { return result; } - private _relativeLocation(location: Location | undefined): Location | undefined { + private _relativeLocation(location: api.Location | undefined): api.Location | undefined { if (!location) return undefined; const file = toPosixPath(path.relative(this._config.rootDir, location.file)); @@ -602,23 +602,16 @@ type JsonAttachment = { }; function stdioAttachment(chunk: Buffer | string, type: 'stdout' | 'stderr'): JsonAttachment { - if (typeof chunk === 'string') { - return { - name: type, - contentType: 'text/plain', - body: chunk - }; - } return { name: type, - contentType: 'application/octet-stream', - body: chunk + contentType: 'text/plain', + body: typeof chunk === 'string' ? chunk : chunk.toString('utf-8') }; } -type DedupedStep = { step: TestStepPublic, count: number, duration: number }; +type DedupedStep = { step: api.TestStep, count: number, duration: number }; -function dedupeSteps(steps: TestStepPublic[]) { +function dedupeSteps(steps: api.TestStep[]) { const result: DedupedStep[] = []; let lastResult = undefined; for (const step of steps) { diff --git a/packages/playwright/src/runner/lastRun.ts b/packages/playwright/src/runner/lastRun.ts index 407543041e..2152f977cf 100644 --- a/packages/playwright/src/runner/lastRun.ts +++ b/packages/playwright/src/runner/lastRun.ts @@ -43,7 +43,7 @@ export class LastRunReporter implements ReporterV2 { return; try { const lastRunInfo = JSON.parse(await fs.promises.readFile(this._lastRunFile, 'utf8')) as LastRunInfo; - this._config.testIdMatcher = id => lastRunInfo.failedTests.includes(id); + this._config.lastFailedTestIdMatcher = id => lastRunInfo.failedTests.includes(id); } catch { } } diff --git a/packages/playwright/src/runner/loadUtils.ts b/packages/playwright/src/runner/loadUtils.ts index 1315eea6e4..62799747b6 100644 --- a/packages/playwright/src/runner/loadUtils.ts +++ b/packages/playwright/src/runner/loadUtils.ts @@ -194,6 +194,10 @@ export async function createRootSuite(testRun: TestRun, errors: TestError[], sho filterTestsRemoveEmptySuites(rootSuite, test => testsInThisShard.has(test)); } + // Explicitly apply --last-failed filter after sharding. + if (config.lastFailedTestIdMatcher) + filterByTestIds(rootSuite, config.lastFailedTestIdMatcher); + // Now prepend dependency projects without filtration. { // Filtering 'only' and sharding might have reduced the number of top-level projects. diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index 981a1580f0..08fa4b9353 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -311,6 +311,7 @@ export class TestServerDispatcher implements TestServerInterface { _optionConnectOptions: params.connectWsEndpoint ? { wsEndpoint: params.connectWsEndpoint } : undefined, }, ...(params.updateSnapshots ? { updateSnapshots: params.updateSnapshots } : {}), + ...(params.updateSourceMethod ? { updateSourceMethod: params.updateSourceMethod } : {}), ...(params.workers ? { workers: params.workers } : {}), }; if (params.trace === 'on') @@ -479,7 +480,7 @@ type StdioPayload = { }; function chunkToPayload(type: 'stdout' | 'stderr', chunk: Buffer | string): StdioPayload { - if (chunk instanceof Buffer) + if (chunk instanceof Uint8Array) return { type, buffer: chunk.toString('base64') }; return { type, text: chunk }; } diff --git a/packages/playwright/src/runner/vcs.ts b/packages/playwright/src/runner/vcs.ts index 6f7ed55c9a..da7c4c2cc8 100644 --- a/packages/playwright/src/runner/vcs.ts +++ b/packages/playwright/src/runner/vcs.ts @@ -54,4 +54,4 @@ export async function detectChangedTestFiles(baseCommit: string, configDir: stri const trackedFilesWithChanges = gitFileList(`diff ${baseCommit} --name-only`).map(file => path.join(gitRoot, file)); return new Set(affectedTestFiles([...untrackedFiles, ...trackedFilesWithChanges])); -} \ No newline at end of file +} diff --git a/packages/playwright/src/worker/fixtureRunner.ts b/packages/playwright/src/worker/fixtureRunner.ts index e67393eb0d..f4fc373610 100644 --- a/packages/playwright/src/worker/fixtureRunner.ts +++ b/packages/playwright/src/worker/fixtureRunner.ts @@ -66,7 +66,7 @@ class Fixture { } await testInfo._runAsStage({ - title: `fixture: ${this.registration.name}`, + title: `fixture: ${this.registration.customTitle ?? this.registration.name}`, runnable: { ...runnable, fixture: this._setupDescription }, stepInfo: this._stepInfo, }, async () => { @@ -131,7 +131,7 @@ class Fixture { // time remaining in the time slot. This avoids cascading timeouts. if (!testInfo._timeoutManager.isTimeExhaustedFor(fixtureRunnable)) { await testInfo._runAsStage({ - title: `fixture: ${this.registration.name}`, + title: `fixture: ${this.registration.customTitle ?? this.registration.name}`, runnable: fixtureRunnable, stepInfo: this._stepInfo, }, async () => { diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 8d05bdafef..7c9cae5415 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -1891,7 +1891,7 @@ type ConditionBody = (args: TestArgs) => boolean; * ``` * */ -export interface TestType { +export interface TestType { /** * Declares a test. * - `test(title, body)` @@ -5551,7 +5551,217 @@ export interface TestType(title: string, body: () => T | Promise, options?: { box?: boolean, location?: Location, timeout?: number }): Promise; + step: { + /** + * Declares a test step that is shown in the report. + * + * **Usage** + * + * ```js + * import { test, expect } from '@playwright/test'; + * + * test('test', async ({ page }) => { + * await test.step('Log in', async () => { + * // ... + * }); + * + * await test.step('Outer step', async () => { + * // ... + * // You can nest steps inside each other. + * await test.step('Inner step', async () => { + * // ... + * }); + * }); + * }); + * ``` + * + * **Details** + * + * The method returns the value returned by the step callback. + * + * ```js + * import { test, expect } from '@playwright/test'; + * + * test('test', async ({ page }) => { + * const user = await test.step('Log in', async () => { + * // ... + * return 'john'; + * }); + * expect(user).toBe('john'); + * }); + * ``` + * + * **Decorator** + * + * You can use TypeScript method decorators to turn a method into a step. Each call to the decorated method will show + * up as a step in the report. + * + * ```js + * function step(target: Function, context: ClassMethodDecoratorContext) { + * return function replacementMethod(...args: any) { + * const name = this.constructor.name + '.' + (context.name as string); + * return test.step(name, async () => { + * return await target.call(this, ...args); + * }); + * }; + * } + * + * class LoginPage { + * constructor(readonly page: Page) {} + * + * @step + * async login() { + * const account = { username: 'Alice', password: 's3cr3t' }; + * await this.page.getByLabel('Username or email address').fill(account.username); + * await this.page.getByLabel('Password').fill(account.password); + * await this.page.getByRole('button', { name: 'Sign in' }).click(); + * await expect(this.page.getByRole('button', { name: 'View profile and more' })).toBeVisible(); + * } + * } + * + * test('example', async ({ page }) => { + * const loginPage = new LoginPage(page); + * await loginPage.login(); + * }); + * ``` + * + * **Boxing** + * + * When something inside a step fails, you would usually see the error pointing to the exact action that failed. For + * example, consider the following login step: + * + * ```js + * async function login(page) { + * await test.step('login', async () => { + * const account = { username: 'Alice', password: 's3cr3t' }; + * await page.getByLabel('Username or email address').fill(account.username); + * await page.getByLabel('Password').fill(account.password); + * await page.getByRole('button', { name: 'Sign in' }).click(); + * await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible(); + * }); + * } + * + * test('example', async ({ page }) => { + * await page.goto('https://github.com/login'); + * await login(page); + * }); + * ``` + * + * ```txt + * Error: Timed out 5000ms waiting for expect(locator).toBeVisible() + * ... error details omitted ... + * + * 8 | await page.getByRole('button', { name: 'Sign in' }).click(); + * > 9 | await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible(); + * | ^ + * 10 | }); + * ``` + * + * As we see above, the test may fail with an error pointing inside the step. If you would like the error to highlight + * the "login" step instead of its internals, use the `box` option. An error inside a boxed step points to the step + * call site. + * + * ```js + * async function login(page) { + * await test.step('login', async () => { + * // ... + * }, { box: true }); // Note the "box" option here. + * } + * ``` + * + * ```txt + * Error: Timed out 5000ms waiting for expect(locator).toBeVisible() + * ... error details omitted ... + * + * 14 | await page.goto('https://github.com/login'); + * > 15 | await login(page); + * | ^ + * 16 | }); + * ``` + * + * You can also create a TypeScript decorator for a boxed step, similar to a regular step decorator above: + * + * ```js + * function boxedStep(target: Function, context: ClassMethodDecoratorContext) { + * return function replacementMethod(...args: any) { + * const name = this.constructor.name + '.' + (context.name as string); + * return test.step(name, async () => { + * return await target.call(this, ...args); + * }, { box: true }); // Note the "box" option here. + * }; + * } + * + * class LoginPage { + * constructor(readonly page: Page) {} + * + * @boxedStep + * async login() { + * // .... + * } + * } + * + * test('example', async ({ page }) => { + * const loginPage = new LoginPage(page); + * await loginPage.login(); // <-- Error will be reported on this line. + * }); + * ``` + * + * @param title Step name. + * @param body Step body. + * @param options + */ + (title: string, body: () => T | Promise, options?: { box?: boolean, location?: Location, timeout?: number }): Promise; + /** + * Mark a test step as "fixme", with the intention to fix it. Playwright will not run the step. + * + * **Usage** + * + * You can declare a test step as failing, so that Playwright ensures it actually fails. + * + * ```js + * import { test, expect } from '@playwright/test'; + * + * test('my test', async ({ page }) => { + * // ... + * await test.step.fixme('not yet ready', async () => { + * // ... + * }); + * }); + * ``` + * + * @param title Step name. + * @param body Step body. + * @param options + */ + fixme(title: string, body: () => any | Promise, options?: { box?: boolean, location?: Location, timeout?: number }): Promise; + /** + * Marks a test step as "should fail". Playwright runs this test step and ensures that it actually fails. This is + * useful for documentation purposes to acknowledge that some functionality is broken until it is fixed. + * + * **NOTE** If the step exceeds the timeout, a [TimeoutError](https://playwright.dev/docs/api/class-timeouterror) is + * thrown. This indicates the step did not fail as expected. + * + * **Usage** + * + * You can declare a test step as failing, so that Playwright ensures it actually fails. + * + * ```js + * import { test, expect } from '@playwright/test'; + * + * test('my test', async ({ page }) => { + * // ... + * await test.step.fail('currently failing', async () => { + * // ... + * }); + * }); + * ``` + * + * @param title Step name. + * @param body Step body. + * @param options + */ + fail(title: string, body: () => any | Promise, options?: { box?: boolean, location?: Location, timeout?: number }): Promise; + } /** * `expect` function can be used to create test assertions. Read more about [test assertions](https://playwright.dev/docs/test-assertions). * @@ -5632,7 +5842,7 @@ export interface TestType(fixtures: Fixtures): TestType; + extend(fixtures: Fixtures): TestType; /** * Returns information about the currently running test. This method can only be called during the test execution, * otherwise it throws. @@ -5653,19 +5863,18 @@ export interface TestType = (args: Args, use: (r: R) => Promise, testInfo: TestInfo) => any; -export type WorkerFixture = (args: Args, use: (r: R) => Promise, workerInfo: WorkerInfo) => any; -type TestFixtureValue = Exclude | TestFixture; -type WorkerFixtureValue = Exclude | WorkerFixture; -export type Fixtures = { +export type TestFixture = (args: Args, use: (r: R) => Promise, testInfo: TestInfo) => any; +export type WorkerFixture = (args: Args, use: (r: R) => Promise, workerInfo: WorkerInfo) => any; +type TestFixtureValue = Exclude | TestFixture; +type WorkerFixtureValue = Exclude | WorkerFixture; +export type Fixtures = { [K in keyof PW]?: WorkerFixtureValue | [WorkerFixtureValue, { scope: 'worker', timeout?: number | undefined, title?: string, box?: boolean }]; } & { [K in keyof PT]?: TestFixtureValue | [TestFixtureValue, { scope: 'test', timeout?: number | undefined, title?: string, box?: boolean }]; } & { - [K in keyof W]?: [WorkerFixtureValue, { scope: 'worker', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean }]; + [K in Exclude]?: [WorkerFixtureValue, { scope: 'worker', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean }]; } & { - [K in keyof T]?: TestFixtureValue | [TestFixtureValue, { scope?: 'test', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean }]; + [K in Exclude]?: TestFixtureValue | [TestFixtureValue, { scope?: 'test', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean }]; }; type BrowserName = 'chromium' | 'firefox' | 'webkit'; @@ -5793,7 +6002,7 @@ export interface PlaywrightWorkerOptions { /** * Browser distribution channel. * - * Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#opt-in-to-new-headless-mode). + * Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#chromium-new-headless-mode). * * Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or * "msedge-canary" to use branded [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge). @@ -6247,8 +6456,8 @@ export interface PlaywrightTestOptions { javaScriptEnabled: boolean; /** * Specify user locale, for example `en-GB`, `de-DE`, etc. Locale will affect `navigator.language` value, - * `Accept-Language` request header value as well as number and date formatting rules. Defaults to the system default - * locale. Learn more about emulation in our [emulation guide](https://playwright.dev/docs/emulation#locale--timezone). + * `Accept-Language` request header value as well as number and date formatting rules. Defaults to `en-US`. Learn more + * about emulation in our [emulation guide](https://playwright.dev/docs/emulation#locale--timezone). * * **Usage** * @@ -7487,8 +7696,8 @@ export function defineConfig(config: PlaywrightTestConfig): PlaywrightTestConfig export function defineConfig(config: PlaywrightTestConfig): PlaywrightTestConfig; export function defineConfig(config: PlaywrightTestConfig): PlaywrightTestConfig; export function defineConfig(config: PlaywrightTestConfig, ...configs: PlaywrightTestConfig[]): PlaywrightTestConfig; -export function defineConfig(config: PlaywrightTestConfig, ...configs: PlaywrightTestConfig[]): PlaywrightTestConfig; -export function defineConfig(config: PlaywrightTestConfig, ...configs: PlaywrightTestConfig[]): PlaywrightTestConfig; +export function defineConfig(config: PlaywrightTestConfig, ...configs: PlaywrightTestConfig[]): PlaywrightTestConfig; +export function defineConfig(config: PlaywrightTestConfig, ...configs: PlaywrightTestConfig[]): PlaywrightTestConfig; type MergedT = List extends [TestType, ...(infer Rest)] ? T & MergedT : {}; type MergedW = List extends [TestType, ...(infer Rest)] ? W & MergedW : {}; @@ -7903,6 +8112,34 @@ interface LocatorAssertions { timeout?: number; }): Promise; + /** + * Ensures the [Locator](https://playwright.dev/docs/api/class-locator) points to an element with a given + * [aria errormessage](https://w3c.github.io/aria/#aria-errormessage). + * + * **Usage** + * + * ```js + * const locator = page.getByTestId('username-input'); + * await expect(locator).toHaveAccessibleErrorMessage('Username is required.'); + * ``` + * + * @param errorMessage Expected accessible error message. + * @param options + */ + toHaveAccessibleErrorMessage(errorMessage: string|RegExp, options?: { + /** + * Whether to perform case-insensitive match. + * [`ignoreCase`](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-have-accessible-error-message-option-ignore-case) + * option takes precedence over the corresponding regular expression flag if specified. + */ + ignoreCase?: boolean; + + /** + * Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`. + */ + timeout?: number; + }): Promise; + /** * Ensures the [Locator](https://playwright.dev/docs/api/class-locator) points to an element with a given * [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). @@ -8431,6 +8668,37 @@ interface LocatorAssertions { timeout?: number; }): Promise; + /** + * Asserts that the target element matches the given [accessibility snapshot](https://playwright.dev/docs/aria-snapshots). + * + * **Usage** + * + * ```js + * await expect(page.locator('body')).toMatchAriaSnapshot(); + * await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'snapshot' }); + * await expect(page.locator('body')).toMatchAriaSnapshot({ path: '/path/to/snapshot.yml' }); + * ``` + * + * @param options + */ + toMatchAriaSnapshot(options?: { + /** + * Name of the snapshot to store in the snapshot folder corresponding to this test. Generates ordinal name if not + * specified. + */ + name?: string; + + /** + * Path to the YAML snapshot file. + */ + path?: string; + + /** + * Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`. + */ + timeout?: number; + }): Promise; + /** * Asserts that the target element matches the given [accessibility snapshot](https://playwright.dev/docs/aria-snapshots). * diff --git a/packages/protocol/src/callMetadata.ts b/packages/protocol/src/callMetadata.d.ts similarity index 100% rename from packages/protocol/src/callMetadata.ts rename to packages/protocol/src/callMetadata.d.ts diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.d.ts similarity index 99% rename from packages/protocol/src/channels.ts rename to packages/protocol/src/channels.d.ts index 27c4242fe5..6f9e36f0c3 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.d.ts @@ -691,6 +691,7 @@ export interface DebugControllerChannel extends DebugControllerEventTarget, Chan export type DebugControllerInspectRequestedEvent = { selector: string, locator: string, + ariaSnapshot: string, }; export type DebugControllerSetModeRequestedEvent = { mode: string, @@ -4982,3 +4983,4 @@ export interface JsonPipeEvents { 'message': JsonPipeMessageEvent; 'closed': JsonPipeClosedEvent; } + diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index d9597c2295..9997989f77 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -807,6 +807,7 @@ DebugController: parameters: selector: string locator: string + ariaSnapshot: string setModeRequested: parameters: diff --git a/packages/recorder/package.json b/packages/recorder/package.json index 0bad170fee..97e52eb9a5 100644 --- a/packages/recorder/package.json +++ b/packages/recorder/package.json @@ -3,11 +3,6 @@ "private": true, "version": "0.0.0", "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build && tsc", - "preview": "vite preview" - }, "dependencies": { "yaml": "^2.6.0" } diff --git a/packages/recorder/src/actions.ts b/packages/recorder/src/actions.d.ts similarity index 100% rename from packages/recorder/src/actions.ts rename to packages/recorder/src/actions.d.ts diff --git a/packages/recorder/src/recorderTypes.ts b/packages/recorder/src/recorderTypes.d.ts similarity index 100% rename from packages/recorder/src/recorderTypes.ts rename to packages/recorder/src/recorderTypes.d.ts diff --git a/packages/trace-viewer/bundle.ts b/packages/trace-viewer/bundle.ts index eaf09a6cc3..38375f8d76 100644 --- a/packages/trace-viewer/bundle.ts +++ b/packages/trace-viewer/bundle.ts @@ -28,4 +28,4 @@ export function bundle(): Plugin { }, }, }; -} \ No newline at end of file +} diff --git a/packages/trace-viewer/package.json b/packages/trace-viewer/package.json index e1b745bf83..66cf4e1e58 100644 --- a/packages/trace-viewer/package.json +++ b/packages/trace-viewer/package.json @@ -2,11 +2,5 @@ "name": "trace-viewer", "private": true, "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build && tsc", - "build-sw": "vite --config vite.sw.config.ts build && tsc", - "preview": "vite preview" - } + "type": "module" } diff --git a/packages/trace-viewer/src/sw/lruCache.ts b/packages/trace-viewer/src/sw/lruCache.ts index 573a15ea49..2d5eae8542 100644 --- a/packages/trace-viewer/src/sw/lruCache.ts +++ b/packages/trace-viewer/src/sw/lruCache.ts @@ -37,7 +37,7 @@ export class LRUCache { const result = compute(); while (this._map.size && this._size + result.size > this._maxSize) { - const [firstKey, firstValue] = this._map.entries().next().value; + const [firstKey, firstValue] = this._map.entries().next().value!; this._size -= firstValue.size; this._map.delete(firstKey); } diff --git a/packages/trace-viewer/src/sw/main.ts b/packages/trace-viewer/src/sw/main.ts index bac616792f..d96fa9db7f 100644 --- a/packages/trace-viewer/src/sw/main.ts +++ b/packages/trace-viewer/src/sw/main.ts @@ -63,22 +63,9 @@ async function loadTrace( const clientId = client?.id ?? ''; let data = clientIdToTraceUrls.get(clientId); if (!data) { - let traceViewerServerBaseUrl = new URL( - '../', - client?.url ?? self.registration.scope - ); - if (traceViewerServerBaseUrl.searchParams.has('server')) { - traceViewerServerBaseUrl = new URL( - traceViewerServerBaseUrl.searchParams.get('server')!, - traceViewerServerBaseUrl - ); - } - - data = { - limit, - traceUrls: new Set(), - traceViewerServer: new TraceViewerServer(traceViewerServerBaseUrl), - }; + const clientURL = new URL(client?.url ?? self.registration.scope); + const traceViewerServerBaseUrl = new URL(clientURL.searchParams.get('server') ?? '../', clientURL); + data = { limit, traceUrls: new Set(), traceViewerServer: new TraceViewerServer(traceViewerServerBaseUrl) }; clientIdToTraceUrls.set(clientId, data); } data.traceUrls.add(traceUrl); diff --git a/packages/trace-viewer/src/sw/snapshotRenderer.ts b/packages/trace-viewer/src/sw/snapshotRenderer.ts index 93363878ab..41c23ffa9c 100644 --- a/packages/trace-viewer/src/sw/snapshotRenderer.ts +++ b/packages/trace-viewer/src/sw/snapshotRenderer.ts @@ -152,7 +152,7 @@ export class SnapshotRenderer { const html = prefix + [ // Hide the document in order to prevent flickering. We will unhide once script has processed shadow. '', - `` + `` ].join('') + result.join(''); return { value: html, size: html.length }; }); @@ -236,10 +236,39 @@ function snapshotNodes(snapshot: FrameSnapshot): NodeSnapshot[] { return (snapshot as any)._nodes; } -function snapshotScript(...targetIds: (string | undefined)[]) { - function applyPlaywrightAttributes(unwrapPopoutUrl: (url: string) => string, ...targetIds: (string | undefined)[]) { +type ViewportSize = { width: number, height: number }; +type BoundingRect = { left: number, top: number, right: number, bottom: number }; +type FrameBoundingRectsInfo = { + viewport: ViewportSize; + frames: WeakMap; +}; + +declare global { + interface Window { + __playwright_frame_bounding_rects__: FrameBoundingRectsInfo; + } +} + +function snapshotScript(viewport: ViewportSize, ...targetIds: (string | undefined)[]) { + function applyPlaywrightAttributes(unwrapPopoutUrl: (url: string) => string, viewport: ViewportSize, ...targetIds: (string | undefined)[]) { const isUnderTest = new URLSearchParams(location.search).has('isUnderTest'); + // info to recursively compute canvas position relative to the top snapshot frame. + // Before rendering each iframe, its parent extracts the '__playwright_canvas_render_info__' attribute + // value and keeps in this variable. It can then remove the attribute and render the element, + // which will eventually trigger the same process inside the iframe recursively. + // When there's a canvas to render, we iterate over its ancestor frames to compute + // its position relative to the top snapshot frame. + const frameBoundingRectsInfo = { + viewport, + frames: new WeakMap(), + }; + window['__playwright_frame_bounding_rects__'] = frameBoundingRectsInfo; + const kPointerWarningTitle = 'Recorded click position in absolute coordinates did not' + ' match the center of the clicked element. This is likely due to a difference between' + ' the test runner and the trace viewer operating systems.'; @@ -249,6 +278,10 @@ function snapshotScript(...targetIds: (string | undefined)[]) { const targetElements: Element[] = []; const canvasElements: HTMLCanvasElement[] = []; + let topSnapshotWindow: Window = window; + while (topSnapshotWindow !== topSnapshotWindow.parent && !topSnapshotWindow.location.pathname.match(/\/page@[a-z0-9]+$/)) + topSnapshotWindow = topSnapshotWindow.parent; + const visit = (root: Document | ShadowRoot) => { // Collect all scrolled elements for later use. for (const e of root.querySelectorAll(`[__playwright_scroll_top_]`)) @@ -288,6 +321,11 @@ function snapshotScript(...targetIds: (string | undefined)[]) { } for (const iframe of root.querySelectorAll('iframe, frame')) { + const boundingRectJson = iframe.getAttribute('__playwright_bounding_rect__'); + iframe.removeAttribute('__playwright_bounding_rect__'); + const boundingRect = boundingRectJson ? JSON.parse(boundingRectJson) : undefined; + if (boundingRect) + frameBoundingRectsInfo.frames.set(iframe, { boundingRect, scrollLeft: 0, scrollTop: 0 }); const src = iframe.getAttribute('__playwright_src__'); if (!src) { iframe.setAttribute('src', 'data:text/html,'); @@ -339,16 +377,20 @@ function snapshotScript(...targetIds: (string | undefined)[]) { for (const element of scrollTops) { element.scrollTop = +element.getAttribute('__playwright_scroll_top_')!; element.removeAttribute('__playwright_scroll_top_'); + if (frameBoundingRectsInfo.frames.has(element)) + frameBoundingRectsInfo.frames.get(element)!.scrollTop = element.scrollTop; } for (const element of scrollLefts) { element.scrollLeft = +element.getAttribute('__playwright_scroll_left_')!; element.removeAttribute('__playwright_scroll_left_'); + if (frameBoundingRectsInfo.frames.has(element)) + frameBoundingRectsInfo.frames.get(element)!.scrollLeft = element.scrollTop; } document.styleSheets[0].disabled = true; const search = new URL(window.location.href).searchParams; - const isTopFrame = window.location.pathname.match(/\/page@[a-z0-9]+$/); + const isTopFrame = window === topSnapshotWindow; if (search.get('pointX') && search.get('pointY')) { const pointX = +search.get('pointX')!; @@ -419,16 +461,6 @@ function snapshotScript(...targetIds: (string | undefined)[]) { context.fillRect(0, 0, canvas.width, canvas.height); } - - if (!isTopFrame) { - for (const canvas of canvasElements) { - const context = canvas.getContext('2d')!; - drawCheckerboard(context, canvas); - canvas.title = `Playwright displays canvas contents on a best-effort basis. It doesn't support canvas elements inside an iframe yet. If this impacts your workflow, please open an issue so we can prioritize.`; - } - return; - } - const img = new Image(); img.onload = () => { for (const canvas of canvasElements) { @@ -446,6 +478,31 @@ function snapshotScript(...targetIds: (string | undefined)[]) { continue; } + let currWindow: Window = window; + while (currWindow !== topSnapshotWindow) { + const iframe = currWindow.frameElement!; + currWindow = currWindow.parent; + + const iframeInfo = currWindow['__playwright_frame_bounding_rects__']?.frames.get(iframe); + if (!iframeInfo?.boundingRect) + break; + + const leftOffset = iframeInfo.boundingRect.left - iframeInfo.scrollLeft; + const topOffset = iframeInfo.boundingRect.top - iframeInfo.scrollTop; + + boundingRect.left += leftOffset; + boundingRect.top += topOffset; + boundingRect.right += leftOffset; + boundingRect.bottom += topOffset; + } + + const { width, height } = topSnapshotWindow['__playwright_frame_bounding_rects__'].viewport; + + boundingRect.left = boundingRect.left / width; + boundingRect.top = boundingRect.top / height; + boundingRect.right = boundingRect.right / width; + boundingRect.bottom = boundingRect.bottom / height; + const partiallyUncaptured = boundingRect.right > 1 || boundingRect.bottom > 1; const fullyUncaptured = boundingRect.left > 1 || boundingRect.top > 1; if (fullyUncaptured) { @@ -483,7 +540,7 @@ function snapshotScript(...targetIds: (string | undefined)[]) { window.addEventListener('DOMContentLoaded', onDOMContentLoaded); } - return `\n(${applyPlaywrightAttributes.toString()})(${unwrapPopoutUrl.toString()}${targetIds.map(id => `, "${id}"`).join('')})`; + return `\n(${applyPlaywrightAttributes.toString()})(${unwrapPopoutUrl.toString()}, ${JSON.stringify(viewport)}${targetIds.map(id => `, "${id}"`).join('')})`; } diff --git a/packages/trace-viewer/src/sw/traceModelBackends.ts b/packages/trace-viewer/src/sw/traceModelBackends.ts index 95efffd502..4f8ea94c3d 100644 --- a/packages/trace-viewer/src/sw/traceModelBackends.ts +++ b/packages/trace-viewer/src/sw/traceModelBackends.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type zip from '@zip.js/zip.js'; +import type * as zip from '@zip.js/zip.js'; // @ts-ignore import * as zipImport from '@zip.js/zip.js/lib/zip-no-worker-inflate.js'; import type { TraceModelBackend } from './traceModel'; @@ -160,4 +160,4 @@ export class TraceViewerServer { return; return response; } -} \ No newline at end of file +} diff --git a/packages/trace-viewer/src/third_party/devtools.ts b/packages/trace-viewer/src/third_party/devtools.ts index 27c520cbce..cc03330240 100644 --- a/packages/trace-viewer/src/third_party/devtools.ts +++ b/packages/trace-viewer/src/third_party/devtools.ts @@ -282,4 +282,4 @@ export async function generateFetchCall(resource: Entry, style: FetchStyle = Fet async function fetchRequestPostData(resource: Entry) { return resource.request.postData?._sha1 ? await fetch(`sha1/${resource.request.postData._sha1}`).then(r => r.text()) : resource.request.postData?.text; -} \ No newline at end of file +} diff --git a/packages/trace-viewer/src/ui/actionList.tsx b/packages/trace-viewer/src/ui/actionList.tsx index 101c532aea..1deb8ecd88 100644 --- a/packages/trace-viewer/src/ui/actionList.tsx +++ b/packages/trace-viewer/src/ui/actionList.tsx @@ -150,4 +150,4 @@ function excludeOrigin(url: string): string { } catch (error) { return url; } -} \ No newline at end of file +} diff --git a/packages/trace-viewer/src/ui/callTab.tsx b/packages/trace-viewer/src/ui/callTab.tsx index 1ab3b5b46f..1c78920f86 100644 --- a/packages/trace-viewer/src/ui/callTab.tsx +++ b/packages/trace-viewer/src/ui/callTab.tsx @@ -27,50 +27,63 @@ import type { ActionTraceEventInContext } from './modelUtil'; export const CallTab: React.FunctionComponent<{ action: ActionTraceEventInContext | undefined, + startTimeOffset: number, sdkLanguage: Language | undefined, -}> = ({ action, sdkLanguage }) => { +}> = ({ action, startTimeOffset, sdkLanguage }) => { + // We never need the waitForEventInfo (`info`). + const paramKeys = React.useMemo(() => Object.keys(action?.params ?? {}).filter(name => name !== 'info'), [action]); + if (!action) return ; - const params = { ...action.params }; - // Strip down the waitForEventInfo data, we never need it. - delete params.info; - const paramKeys = Object.keys(params); - const timeMillis = action.startTime + (action.context.wallTime - action.context.startTime); - const wallTime = new Date(timeMillis).toLocaleString(); + + // Calculate execution time relative to the test runner's start time + const startTimeMillis = action.startTime - startTimeOffset; + const startTime = msToString(startTimeMillis); + const duration = action.endTime ? msToString(action.endTime - action.startTime) : 'Timed Out'; - return
-
{action.apiName}
- {<> -
Time
- {wallTime &&
wall time:{wallTime}
} -
duration:{duration}
- } - { !!paramKeys.length &&
Parameters
} - { - !!paramKeys.length && paramKeys.map((name, index) => renderProperty(propertyToString(action, name, params[name], sdkLanguage), 'param-' + index)) - } - { !!action.result &&
Return value
} - { - !!action.result && Object.keys(action.result).map((name, index) => - renderProperty(propertyToString(action, name, action.result[name], sdkLanguage), 'result-' + index) - ) - } -
; + return ( +
+
{action.apiName}
+ { + <> +
Time
+ + + + } + { + !!paramKeys.length && <> +
Parameters
+ {paramKeys.map(name => renderProperty(propertyToString(action, name, action.params[name], sdkLanguage)))} + + } + { + !!action.result && <> +
Return value
+ {Object.keys(action.result).map(name => + renderProperty(propertyToString(action, name, action.result[name], sdkLanguage)) + )} + + } +
+ ); }; +const DateTimeCallLine: React.FC<{ name: string, value: string }> = ({ name, value }) =>
{name}{value}
; + type Property = { name: string; type: 'string' | 'number' | 'object' | 'locator' | 'handle' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'function'; text: string; }; -function renderProperty(property: Property, key: string) { +function renderProperty(property: Property) { let text = property.text.replace(/\n/g, '↵'); if (property.type === 'string') text = `"${text}"`; return ( -
+
{property.name}:{text} { ['string', 'number', 'object', 'locator'].includes(property.type) && diff --git a/packages/trace-viewer/src/ui/metadataView.tsx b/packages/trace-viewer/src/ui/metadataView.tsx index c1802a4b4d..88c2e2bf93 100644 --- a/packages/trace-viewer/src/ui/metadataView.tsx +++ b/packages/trace-viewer/src/ui/metadataView.tsx @@ -25,9 +25,11 @@ export const MetadataView: React.FunctionComponent<{ if (!model) return <>; + const wallTime = model.wallTime !== undefined ? new Date(model.wallTime).toLocaleString(undefined, { timeZoneName: 'short' }) : undefined; + return
Time
- {!!model.wallTime &&
start time:{new Date(model.wallTime).toLocaleString()}
} + {!!wallTime &&
start time:{wallTime}
}
duration:{msToString(model.endTime - model.startTime)}
Browser
engine:{model.browserName}
diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.tsx b/packages/trace-viewer/src/ui/networkResourceDetails.tsx index 9805d42c6f..aaa78d1786 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.tsx +++ b/packages/trace-viewer/src/ui/networkResourceDetails.tsx @@ -24,12 +24,14 @@ import { generateCurlCommand, generateFetchCall } from '../third_party/devtools' import { CopyToClipboardTextButton } from './copyToClipboard'; import { getAPIRequestCodeGen } from './codegen'; import type { Language } from '@isomorphic/locatorGenerators'; +import { msToString } from '@web/uiUtils'; export const NetworkResourceDetails: React.FunctionComponent<{ resource: ResourceSnapshot; - onClose: () => void; sdkLanguage: Language; -}> = ({ resource, onClose, sdkLanguage }) => { + startTimeOffset: number; + onClose: () => void; +}> = ({ resource, sdkLanguage, startTimeOffset, onClose }) => { const [selectedTab, setSelectedTab] = React.useState('request'); return , + render: () => , }, { id: 'response', @@ -59,7 +61,8 @@ export const NetworkResourceDetails: React.FunctionComponent<{ const RequestTab: React.FunctionComponent<{ resource: ResourceSnapshot; sdkLanguage: Language; -}> = ({ resource, sdkLanguage }) => { + startTimeOffset: number; +}> = ({ resource, sdkLanguage, startTimeOffset }) => { const [requestBody, setRequestBody] = React.useState<{ text: string, mimeType?: string } | null>(null); React.useEffect(() => { @@ -96,6 +99,9 @@ const RequestTab: React.FunctionComponent<{ : null}
Request Headers
{resource.request.headers.map(pair => `${pair.name}: ${pair.value}`).join('\n')}
+
Time
+
{`Start: ${msToString(startTimeOffset)}`}
+
{`Duration: ${msToString(resource.time)}`}
generateCurlCommand(resource)} /> @@ -120,7 +126,7 @@ const ResponseTab: React.FunctionComponent<{ const BodyTab: React.FunctionComponent<{ resource: ResourceSnapshot; }> = ({ resource }) => { - const [responseBody, setResponseBody] = React.useState<{ dataUrl?: string, text?: string, mimeType?: string, font?: BinaryData } | null>(null); + const [responseBody, setResponseBody] = React.useState<{ dataUrl?: string, text?: string, mimeType?: string, font?: BufferSource } | null>(null); React.useEffect(() => { const readResources = async () => { @@ -158,7 +164,7 @@ const BodyTab: React.FunctionComponent<{ }; const FontPreview: React.FunctionComponent<{ - font: BinaryData; + font: BufferSource; }> = ({ font }) => { const [isError, setIsError] = React.useState(false); diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index 56cf9325b4..ceaafdcec5 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -117,7 +117,7 @@ export const NetworkTab: React.FunctionComponent<{ sidebarIsFirst={true} orientation='horizontal' settingName='networkResourceDetails' - main={ setSelectedEntry(undefined)} sdkLanguage={sdkLanguage} />} + main={ setSelectedEntry(undefined)} />} sidebar={grid} />} ; diff --git a/packages/trace-viewer/src/ui/settingsView.css b/packages/trace-viewer/src/ui/settingsView.css index 3ac8597e35..f759f0b07a 100644 --- a/packages/trace-viewer/src/ui/settingsView.css +++ b/packages/trace-viewer/src/ui/settingsView.css @@ -16,24 +16,25 @@ .settings-view { flex: none; - margin-top: 4px; + padding: 4px 0px; + row-gap: 8px; + user-select: none; +} + +.settings-view .setting { + display: flex; + align-items: center; } .settings-view .setting label { - display: flex; - flex-direction: row; - align-items: center; - margin: 4px 2px; -} + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; -.settings-view .setting:first-of-type label { - margin-top: 2px; -} - -.settings-view .setting:last-of-type label { - margin-bottom: 2px; + cursor: pointer; } .settings-view .setting input { margin-right: 5px; + flex-shrink: 0; } diff --git a/packages/trace-viewer/src/ui/settingsView.tsx b/packages/trace-viewer/src/ui/settingsView.tsx index 0a4340b2b6..69439e773c 100644 --- a/packages/trace-viewer/src/ui/settingsView.tsx +++ b/packages/trace-viewer/src/ui/settingsView.tsx @@ -18,22 +18,32 @@ import * as React from 'react'; import './settingsView.css'; export type Setting = { - value: T, - set: (value: T) => void, - title: string + value: T; + set: (value: T) => void; + name: string; + title?: string; }; export const SettingsView: React.FunctionComponent<{ - settings: Setting[], + settings: Setting[]; }> = ({ settings }) => { - return
- {settings.map(({ value, set, title }) => { - return
- -
; - })} -
; + return ( +
+ {settings.map(({ value, set, name, title }) => { + const labelId = `setting-${name}`; + + return ( +
+ set(!value)} + /> + +
+ ); + })} +
+ ); }; diff --git a/packages/trace-viewer/src/ui/shared/dialog.tsx b/packages/trace-viewer/src/ui/shared/dialog.tsx new file mode 100644 index 0000000000..a58119eca7 --- /dev/null +++ b/packages/trace-viewer/src/ui/shared/dialog.tsx @@ -0,0 +1,145 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import * as React from 'react'; + +export interface DialogProps { + className?: string; + open: boolean; + width: number; + verticalOffset?: number; + requestClose?: () => void; + anchor?: React.RefObject; +} + +export const Dialog: React.FC> = ({ + className, + open, + width, + verticalOffset, + requestClose, + anchor, + children, +}) => { + const dialogRef = React.useRef(null); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, setRecalculateDimensionsCount] = React.useState(0); + + let style: React.CSSProperties | undefined = undefined; + + if (anchor?.current) { + const bounds = anchor.current.getBoundingClientRect(); + + style = { + margin: 0, + top: bounds.bottom + (verticalOffset ?? 0), + left: buildTopLeftCoord(bounds, width), + width, + zIndex: 1, + }; + } + + React.useEffect(() => { + const onClick = (event: MouseEvent) => { + if (!dialogRef.current || !(event.target instanceof Node)) + return; + + if (!dialogRef.current.contains(event.target)) + requestClose?.(); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') + requestClose?.(); + }; + + if (open) { + document.addEventListener('mousedown', onClick); + document.addEventListener('keydown', onKeyDown); + + return () => { + document.removeEventListener('mousedown', onClick); + document.removeEventListener('keydown', onKeyDown); + }; + } + + return () => {}; + }, [open, requestClose]); + + React.useEffect(() => { + const onResize = () => setRecalculateDimensionsCount(count => count + 1); + + window.addEventListener('resize', onResize); + + return () => { + window.removeEventListener('resize', onResize); + }; + }, []); + + return ( + open && ( + + {children} + + ) + ); +}; + +const buildTopLeftCoord = (bounds: DOMRect, width: number): number => { + const leftAlignCoord = buildTopLeftCoordWithAlignment(bounds, width, 'left'); + + if (leftAlignCoord.inBounds) + return leftAlignCoord.value; + + const rightAlignCoord = buildTopLeftCoordWithAlignment( + bounds, + width, + 'right' + ); + + if (rightAlignCoord.inBounds) + return rightAlignCoord.value; + + return leftAlignCoord.value; +}; + +const buildTopLeftCoordWithAlignment = ( + bounds: DOMRect, + width: number, + alignment: 'left' | 'right' +): { + value: number; + inBounds: boolean; +} => { + const maxLeft = document.documentElement.clientWidth; + + if (alignment === 'left') { + const value = bounds.left; + + return { + value, + inBounds: value + width <= maxLeft, + }; + } else { + const value = bounds.right - width; + + return { + value, + inBounds: bounds.right - width >= 0, + }; + } +}; diff --git a/packages/trace-viewer/src/ui/snapshotTab.tsx b/packages/trace-viewer/src/ui/snapshotTab.tsx index 6f32ccf931..54d0d0ce26 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.tsx +++ b/packages/trace-viewer/src/ui/snapshotTab.tsx @@ -344,7 +344,8 @@ export function extendSnapshot(snapshot: Snapshot): SnapshotUrls { const popoutParams = new URLSearchParams(); popoutParams.set('r', snapshotUrl); - popoutParams.set('server', serverParam ?? ''); + if (serverParam) + popoutParams.set('server', serverParam); popoutParams.set('trace', context(snapshot.action).traceUrl); if (snapshot.point) { popoutParams.set('pointX', String(snapshot.point.x)); diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index be0dd8f55f..6ae2fdbd8c 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -47,9 +47,7 @@ const xtermDataSource: XtermDataSource = { }; const searchParams = new URLSearchParams(window.location.search); -let testServerBaseUrl = new URL('../', window.location.href); -if (testServerBaseUrl.searchParams.has('server')) - testServerBaseUrl = new URL(testServerBaseUrl.searchParams.get('server')!, testServerBaseUrl); +const testServerBaseUrl = new URL(searchParams.get('server') ?? '../', window.location.href); const wsURL = new URL(searchParams.get('ws')!, testServerBaseUrl); wsURL.protocol = (wsURL.protocol === 'https:' ? 'wss:' : 'ws:'); const queryParams = { @@ -510,9 +508,9 @@ export const UIModeView: React.FC<{}> = ({
Testing Options
{testingOptionsVisible && } } setSettingsVisible(!settingsVisible)}> @@ -524,7 +522,7 @@ export const UIModeView: React.FC<{}> = ({
Settings
{settingsVisible && }
} diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index 24bedf90e1..c5b5180b63 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -175,7 +175,7 @@ export const Workbench: React.FunctionComponent<{ const callTab: TabbedPaneTabModel = { id: 'call', title: 'Call', - render: () => + render: () => }; const logTab: TabbedPaneTabModel = { id: 'log', diff --git a/packages/web/src/components/errorMessage.tsx b/packages/web/src/components/errorMessage.tsx index c9f4500ece..a37f28e2ec 100644 --- a/packages/web/src/components/errorMessage.tsx +++ b/packages/web/src/components/errorMessage.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ansi2html } from '@web/ansi2html'; +import { ansi2html } from '../ansi2html'; import * as React from 'react'; import './errorMessage.css'; diff --git a/packages/web/src/components/gridView.tsx b/packages/web/src/components/gridView.tsx index 10fc48c247..303de4b8d3 100644 --- a/packages/web/src/components/gridView.tsx +++ b/packages/web/src/components/gridView.tsx @@ -18,7 +18,7 @@ import * as React from 'react'; import { ListView } from './listView'; import type { ListViewProps } from './listView'; import './gridView.css'; -import { ResizeView } from '@web/shared/resizeView'; +import { ResizeView } from '../shared/resizeView'; export type Sorting = { by: keyof T, negate: boolean }; diff --git a/packages/web/src/components/listView.tsx b/packages/web/src/components/listView.tsx index 73f9b65b8f..079936c4a1 100644 --- a/packages/web/src/components/listView.tsx +++ b/packages/web/src/components/listView.tsx @@ -16,7 +16,7 @@ import * as React from 'react'; import './listView.css'; -import { clsx, scrollIntoViewIfNeeded } from '@web/uiUtils'; +import { clsx, scrollIntoViewIfNeeded } from '../uiUtils'; export type ListViewProps = { name: string, diff --git a/packages/web/src/components/sourceChooser.tsx b/packages/web/src/components/sourceChooser.tsx index 22b91c61a5..091dce8f98 100644 --- a/packages/web/src/components/sourceChooser.tsx +++ b/packages/web/src/components/sourceChooser.tsx @@ -59,4 +59,4 @@ export function emptySource(): Source { label: '', highlight: [] }; -} \ No newline at end of file +} diff --git a/packages/web/src/components/splitView.spec.tsx b/packages/web/src/components/splitView.spec.tsx index a9260a2d48..ca11520912 100644 --- a/packages/web/src/components/splitView.spec.tsx +++ b/packages/web/src/components/splitView.spec.tsx @@ -90,4 +90,3 @@ test('drag resize', async ({ page, mount }) => { expect.soft(mainBox).toEqual({ x: 0, y: 0, width: 500, height: 100 }); expect.soft(sidebarBox).toEqual({ x: 0, y: 101, width: 500, height: 399 }); }); - diff --git a/packages/web/src/components/tabbedPane.tsx b/packages/web/src/components/tabbedPane.tsx index fca5852110..2f5966cc51 100644 --- a/packages/web/src/components/tabbedPane.tsx +++ b/packages/web/src/components/tabbedPane.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { clsx } from '@web/uiUtils'; +import { clsx } from '../uiUtils'; import './tabbedPane.css'; import { Toolbar } from './toolbar'; import * as React from 'react'; diff --git a/packages/web/src/components/toolbar.tsx b/packages/web/src/components/toolbar.tsx index a81b834f4a..32a4227d7f 100644 --- a/packages/web/src/components/toolbar.tsx +++ b/packages/web/src/components/toolbar.tsx @@ -14,7 +14,7 @@ limitations under the License. */ -import { clsx } from '@web/uiUtils'; +import { clsx } from '../uiUtils'; import './toolbar.css'; import * as React from 'react'; diff --git a/packages/web/src/components/toolbarButton.tsx b/packages/web/src/components/toolbarButton.tsx index 2cdd85b9b7..521951bfee 100644 --- a/packages/web/src/components/toolbarButton.tsx +++ b/packages/web/src/components/toolbarButton.tsx @@ -17,7 +17,7 @@ import './toolbarButton.css'; import '../third_party/vscode/codicon.css'; import * as React from 'react'; -import { clsx } from '@web/uiUtils'; +import { clsx } from '../uiUtils'; export interface ToolbarButtonProps { title: string, diff --git a/packages/web/src/components/treeView.tsx b/packages/web/src/components/treeView.tsx index 242e834ede..f478ba4025 100644 --- a/packages/web/src/components/treeView.tsx +++ b/packages/web/src/components/treeView.tsx @@ -15,7 +15,7 @@ */ import * as React from 'react'; -import { clsx, scrollIntoViewIfNeeded } from '@web/uiUtils'; +import { clsx, scrollIntoViewIfNeeded } from '../uiUtils'; import './treeView.css'; export type TreeItem = { @@ -180,7 +180,7 @@ export function TreeView({ const itemData = treeItems.get(child as T); return itemData && () { const target = ref.current; if (!target) return; + + const bounds = target.getBoundingClientRect(); + + setMeasure(new DOMRect(0, 0, bounds.width, bounds.height)); + const resizeObserver = new ResizeObserver((entries: any) => { const entry = entries[entries.length - 1]; if (entry && entry.contentRect) diff --git a/tests/android/webview.spec.ts b/tests/android/webview.spec.ts index a0d03b69b6..fac61b5a9a 100644 --- a/tests/android/webview.spec.ts +++ b/tests/android/webview.spec.ts @@ -68,4 +68,4 @@ test('select webview from socketName', async function({ androidDevice }) { await newPage.close(); await context.close(); -}); \ No newline at end of file +}); diff --git a/tests/bidi/csvReporter.ts b/tests/bidi/csvReporter.ts new file mode 100644 index 0000000000..f227550ef3 --- /dev/null +++ b/tests/bidi/csvReporter.ts @@ -0,0 +1,87 @@ +/** + * 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 { + FullConfig, FullResult, Reporter, Suite +} from '@playwright/test/reporter'; +import { stripAnsi } from '../config/utils'; +import fs from 'fs'; +import path from 'path'; + + +type ReporterOptions = { + outputFile?: string, + configDir: string, +}; + +class CsvReporter implements Reporter { + private _suite: Suite; + private _options: ReporterOptions; + private _pendingWrite: Promise; + + constructor(options: ReporterOptions) { + this._options = options; + } + + onBegin(config: FullConfig, suite: Suite) { + this._suite = suite; + } + + onEnd(result: FullResult) { + const rows = [['Test Name', 'Expected Status', 'Status', 'Error Message']]; + for (const project of this._suite.suites) { + for (const file of project.suites) { + for (const test of file.allTests()) { + // Report fixme tests as failing. + const fixme = test.annotations.find(a => a.type === 'fixme'); + if (test.ok() && !fixme) + continue; + const row = []; + row.push(csvEscape(`${file.title} :: ${test.title}`)); + row.push(test.expectedStatus); + row.push(test.outcome()); + if (fixme) { + row.push('fixme' + (fixme.description ? `: ${fixme.description}` : '')); + } else { + const result = test.results.find(r => r.error); + const errorMessage = stripAnsi(result?.error?.message.replace(/\s+/g, ' ').trim().substring(0, 1024)); + row.push(csvEscape(errorMessage ?? '')); + } + rows.push(row); + } + } + } + const csv = rows.map(r => r.join(',')).join('\n'); + const reportFile = path.resolve(this._options.configDir, this._options.outputFile || 'test-results.csv'); + this._pendingWrite = fs.promises.writeFile(reportFile, csv); + } + + async onExit() { + await this._pendingWrite; + } + + printsToStdio(): boolean { + return false; + } +} + +function csvEscape(str) { + if (str.includes('"') || str.includes(',') || str.includes('\n')) + return `"${str.replace(/"/g, '""')}"`; + return str; +} + +export default CsvReporter; diff --git a/tests/bidi/expectationReporter.ts b/tests/bidi/expectationReporter.ts index e136149cc4..65371165ff 100644 --- a/tests/bidi/expectationReporter.ts +++ b/tests/bidi/expectationReporter.ts @@ -86,4 +86,4 @@ function getOutcome(test: TestCase): TestExpectation { return 'unknown'; } -export default ExpectationReporter; \ No newline at end of file +export default ExpectationReporter; diff --git a/tests/bidi/expectationUtil.ts b/tests/bidi/expectationUtil.ts index cdf9b779f2..a9f093c46f 100644 --- a/tests/bidi/expectationUtil.ts +++ b/tests/bidi/expectationUtil.ts @@ -29,7 +29,7 @@ export async function createSkipTestPredicate(projectName: string): Promise { const key = info.titlePath.join(' › '); const expectation = expectationsMap.get(key); - return expectation === 'fail' || expectation === 'timeout'; + return expectation === 'timeout'; }; } diff --git a/tests/bidi/playwright.config.ts b/tests/bidi/playwright.config.ts index 39df88f537..5fb370482c 100644 --- a/tests/bidi/playwright.config.ts +++ b/tests/bidi/playwright.config.ts @@ -39,8 +39,10 @@ const reporters = () => { hasDebugOutput ? ['list'] : ['dot'], ['json', { outputFile: path.join(outputDir, 'report.json') }], ['blob', { fileName: `${process.env.PWTEST_BOT_NAME}.zip` }], + ['./csvReporter', { outputFile: path.join(outputDir, 'report.csv') }], ] : [ ['html', { open: 'on-failure' }], + ['./csvReporter', { outputFile: path.join(outputDir, 'report.csv') }], ['./expectationReporter', { rebase: false }], ]; return result; @@ -58,7 +60,7 @@ const config: Config { const testWithServer = test.extend(serverFixtures); testWithServer( 'components routing should go through context', + // @ts-ignore "serverFixtures" are imported from the impl without any types async ({ mount, context, server }) => { server.setRoute('/hello', (req: any, res: any) => { res.write('served via server'); diff --git a/tests/components/ct-vue-cli/package.json b/tests/components/ct-vue-cli/package.json index 6abe7531cb..1c3653d027 100644 --- a/tests/components/ct-vue-cli/package.json +++ b/tests/components/ct-vue-cli/package.json @@ -10,8 +10,8 @@ }, "dependencies": { "core-js": "^3.8.3", - "vue": "3.2.36", - "vue-router": "^4.1.5" + "vue": "^3.2.36", + "vue-router": "^4.5.0" }, "devDependencies": { "@babel/core": "^7.23.2", @@ -20,7 +20,7 @@ "@vue/cli-plugin-eslint": "~5.0.0", "@vue/cli-plugin-router": "~5.0.0", "@vue/cli-service": "~5.0.0", - "@vue/tsconfig": "^0.1.3", + "@vue/tsconfig": "^0.7.0", "eslint": "^7.32.0", "eslint-plugin-vue": "^8.0.3" }, diff --git a/tests/components/ct-vue-cli/tsconfig.app.json b/tests/components/ct-vue-cli/tsconfig.app.json index 5e1fc21721..4d8e68abb8 100644 --- a/tests/components/ct-vue-cli/tsconfig.app.json +++ b/tests/components/ct-vue-cli/tsconfig.app.json @@ -1,5 +1,5 @@ { - "extends": "@vue/tsconfig/tsconfig.web.json", + "extends": "@vue/tsconfig/tsconfig.dom.json", "include": ["src/**/*", "src/**/*.vue"], "exclude": ["src/**/*.spec.*/*"], "compilerOptions": { diff --git a/tests/components/ct-vue-cli/tsconfig.config.json b/tests/components/ct-vue-cli/tsconfig.config.json index d554caf51b..4c52107add 100644 --- a/tests/components/ct-vue-cli/tsconfig.config.json +++ b/tests/components/ct-vue-cli/tsconfig.config.json @@ -1,5 +1,5 @@ { - "extends": "@vue/tsconfig/tsconfig.node.json", + "extends": "@vue/tsconfig/tsconfig.dom.json", "include": ["playwright.config.*"], "compilerOptions": { "composite": true, diff --git a/tests/components/ct-vue-vite/tsconfig.node.json b/tests/components/ct-vue-vite/tsconfig.node.json deleted file mode 100644 index 43cc105313..0000000000 --- a/tests/components/ct-vue-vite/tsconfig.node.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "@vue/tsconfig/tsconfig.dom.json", - "include": ["vite.config.*", "playwright.config.*"], - "compilerOptions": { - "composite": true, - "types": ["node"], - "ignoreDeprecations": "5.0" - } -} \ No newline at end of file diff --git a/tests/config/browserTest.ts b/tests/config/browserTest.ts index c729052715..7836b2e38c 100644 --- a/tests/config/browserTest.ts +++ b/tests/config/browserTest.ts @@ -99,7 +99,7 @@ const test = baseTest.extend isWebView2: [false, { scope: 'worker' }], isHeadlessShell: [async ({ browserName, channel, headless }, use) => { - await use(browserName === 'chromium' && (channel === 'chromium-headless-shell' || (!channel && headless))); + await use(browserName === 'chromium' && (channel === 'chromium-headless-shell' || channel === 'chromium-tip-of-tree-headless-shell' || (!channel && headless))); }, { scope: 'worker' }], contextFactory: async ({ _contextFactory }: any, run) => { @@ -188,8 +188,7 @@ const test = baseTest.extend }, { scope: 'worker' }], autoSkipBidiTest: [async ({ bidiTestSkipPredicate }, run) => { - if (bidiTestSkipPredicate(test.info())) - test.skip(true); + test.fixme(bidiTestSkipPredicate(test.info()), 'marked as timeout in bidi expectations'); await run(); }, { auto: true, scope: 'test' }], }); diff --git a/tests/config/proxy.ts b/tests/config/proxy.ts index 0e71e47790..7efe389b12 100644 --- a/tests/config/proxy.ts +++ b/tests/config/proxy.ts @@ -32,6 +32,7 @@ export class TestProxy { connectHosts: string[] = []; requestUrls: string[] = []; + wsUrls: string[] = []; private readonly _server: ProxyServer; private readonly _sockets = new Set(); @@ -58,11 +59,16 @@ export class TestProxy { await new Promise(x => this._server.close(x)); } - forwardTo(port: number, options?: { allowConnectRequests: boolean }) { + forwardTo(port: number, options?: { allowConnectRequests?: boolean, prefix?: string, preserveHostname?: boolean }) { this._prependHandler('request', (req: IncomingMessage) => { this.requestUrls.push(req.url); - const url = new URL(req.url); - url.host = `127.0.0.1:${port}`; + const url = new URL(req.url, `http://${req.headers.host}`); + if (options?.preserveHostname) + url.port = '' + port; + else + url.host = `127.0.0.1:${port}`; + if (options?.prefix) + url.pathname = url.pathname.replace(options.prefix, ''); req.url = url.toString(); }); this._prependHandler('connect', (req: IncomingMessage) => { @@ -73,6 +79,17 @@ export class TestProxy { this.connectHosts.push(req.url); req.url = `127.0.0.1:${port}`; }); + this._prependHandler('upgrade', (req: IncomingMessage) => { + this.wsUrls.push(req.url); + const url = new URL(req.url, `http://${req.headers.host}`); + if (options?.preserveHostname) + url.port = '' + port; + else + url.host = `127.0.0.1:${port}`; + if (options?.prefix) + url.pathname = url.pathname.replace(options.prefix, ''); + req.url = url.toString(); + }); } setAuthHandler(handler: (req: IncomingMessage) => boolean) { diff --git a/tests/config/remoteServer.ts b/tests/config/remoteServer.ts index 04305f31a4..6d9710fd44 100644 --- a/tests/config/remoteServer.ts +++ b/tests/config/remoteServer.ts @@ -92,7 +92,7 @@ export class RemoteServer implements PlaywrightServer { handleSIGINT: true, handleSIGTERM: true, handleSIGHUP: true, - executablePath: browserOptions.channel ? undefined : browserOptions.executablePath || browserType.executablePath(), + executablePath: browserOptions.channel ? undefined : browserOptions.executablePath, logger: undefined, }; const options = { diff --git a/tests/expect/toThrowMatchers.test.ts b/tests/expect/toThrowMatchers.test.ts index a5d5e52ba8..bc64d13240 100644 --- a/tests/expect/toThrowMatchers.test.ts +++ b/tests/expect/toThrowMatchers.test.ts @@ -588,4 +588,4 @@ for (const toThrow of ['toThrowError', 'toThrow'] as const) { ).toThrowErrorMatchingSnapshot(); }); }); -} \ No newline at end of file +} diff --git a/tests/installation/npmTest.ts b/tests/installation/npmTest.ts index ab2b9b2a88..4e39fb8c98 100644 --- a/tests/installation/npmTest.ts +++ b/tests/installation/npmTest.ts @@ -31,22 +31,22 @@ export const TMP_WORKSPACES = path.join(os.platform() === 'darwin' ? '/tmp' : os const debug = debugLogger('itest'); const expect = _expect.extend({ - toHaveLoggedSoftwareDownload(received: any, browsers: ('chromium' | 'chromium-headless-shell' | 'firefox' | 'webkit' | 'ffmpeg')[]) { + toHaveLoggedSoftwareDownload(received: string, browsers: ('chromium' | 'chromium-headless-shell' | 'firefox' | 'webkit' | 'winldd' |'ffmpeg')[]) { if (typeof received !== 'string') throw new Error(`Expected argument to be a string.`); const downloaded = new Set(); let index = 0; while (true) { - const match = received.substring(index).match(/(chromium|chromium headless shell|firefox|webkit|ffmpeg)[\s\d\.]+\(?playwright build v\d+\)? downloaded/im); + const match = received.substring(index).match(/(chromium|chromium headless shell|firefox|webkit|winldd|ffmpeg)[\s\d\.]+\(?playwright build v\d+\)? downloaded/im); if (!match) break; downloaded.add(match[1].replace(/\s/g, '-').toLowerCase()); index += match.index + 1; } - const expected = browsers; - if (expected.length === downloaded.size && expected.every(browser => downloaded.has(browser))) { + const expected = new Set(browsers); + if (expected.size === downloaded.size && [...expected].every(browser => downloaded.has(browser))) { return { pass: true, message: () => 'Expected not to download browsers, but did.' @@ -75,7 +75,7 @@ type NPMTestFixtures = { _auto: void; _browsersPath: string; tmpWorkspace: string; - installedSoftwareOnDisk: () => Promise; + checkInstalledSoftwareOnDisk: (browsers: string[]) => Promise; writeFiles: (nameToContents: Record) => Promise; exec: (cmd: string, ...argsAndOrOptions: ArgsOrOptions) => Promise; tsc: (args: string) => Promise; @@ -146,10 +146,13 @@ export const test = _test await use(registry); await registry.shutdown(); }, - installedSoftwareOnDisk: async ({ isolateBrowsers, _browsersPath }, use) => { - if (!isolateBrowsers) - throw new Error(`Test that checks browser installation must set "isolateBrowsers" to true`); - await use(async () => fs.promises.readdir(_browsersPath).catch(() => []).then(files => files.map(f => f.split('-')[0].replace(/_/g, '-')).filter(f => !f.startsWith('.')))); + checkInstalledSoftwareOnDisk: async ({ isolateBrowsers, _browsersPath }, use) => { + await use(async expected => { + if (!isolateBrowsers) + throw new Error(`Test that checks browser installation must set "isolateBrowsers" to true`); + const actual = await fs.promises.readdir(_browsersPath).catch(() => []).then(files => files.map(f => f.split('-')[0].replace(/_/g, '-')).filter(f => !f.startsWith('.'))); + expect(new Set(actual)).toEqual(new Set(expected)); + }); }, exec: async ({ tmpWorkspace, _browsersPath, isolateBrowsers }, use, testInfo) => { await use(async (cmd: string, ...argsAndOrOptions: [] | [...string[]] | [...string[], ExecOptions] | [ExecOptions]) => { diff --git a/tests/installation/npx-global.spec.ts b/tests/installation/npx-global.spec.ts index 6a3da572ef..0df4d4829b 100755 --- a/tests/installation/npx-global.spec.ts +++ b/tests/installation/npx-global.spec.ts @@ -17,26 +17,26 @@ import { test, expect } from './npmTest'; test.use({ isolateBrowsers: true, allowGlobalInstall: true }); -test('npx playwright --help should not download browsers', async ({ exec, installedSoftwareOnDisk }) => { +test('npx playwright --help should not download browsers', async ({ exec, checkInstalledSoftwareOnDisk }) => { const result = await exec('npx playwright --help'); expect(result).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); expect(result).not.toContain(`To avoid unexpected behavior, please install your dependencies first`); }); -test('npx playwright codegen', async ({ exec, installedSoftwareOnDisk }) => { +test('npx playwright codegen', async ({ exec, checkInstalledSoftwareOnDisk }) => { const stdio = await exec('npx playwright codegen', { expectToExitWithError: true }); expect(stdio).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); expect(stdio).toContain(`Please run the following command to download new browsers`); }); -test('npx playwright install global', async ({ exec, installedSoftwareOnDisk }) => { +test('npx playwright install global', async ({ exec, checkInstalledSoftwareOnDisk }) => { test.skip(process.platform === 'win32', 'isLikelyNpxGlobal() does not work in this setup on our bots'); const result = await exec('npx playwright install'); expect(result).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); - expect(await installedSoftwareOnDisk()).toEqual(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); + await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); expect(result).not.toContain(`Please run the following command to download new browsers`); expect(result).toContain(`To avoid unexpected behavior, please install your dependencies first`); }); diff --git a/tests/installation/playwright-cdn.spec.ts b/tests/installation/playwright-cdn.spec.ts index af0339f03b..776aec42ad 100644 --- a/tests/installation/playwright-cdn.spec.ts +++ b/tests/installation/playwright-cdn.spec.ts @@ -19,11 +19,9 @@ import net from 'net'; import type { AddressInfo } from 'net'; const CDNS = [ - 'https://playwright.azureedge.net/dbazure/download/playwright', // ESRP + 'https://cdn.playwright.dev/dbazure/download/playwright', // ESRP 'https://playwright.download.prss.microsoft.com/dbazure/download/playwright', // ESRP Fallback - 'https://playwright.azureedge.net', - 'https://playwright-akamai.azureedge.net', - 'https://playwright-verizon.azureedge.net', + 'https://cdn.playwright.dev', ]; const DL_STAT_BLOCK = /^.*from url: (.*)$\n^.*to location: (.*)$\n^.*response status code: (.*)$\n^.*total bytes: (\d+)$\n^.*download complete, size: (\d+)$\n^.*SUCCESS downloading (\w+) .*$/gm; @@ -39,12 +37,14 @@ const parsedDownloads = (rawLogs: string) => { test.use({ isolateBrowsers: true }); +const extraInstalledSoftware = process.platform === 'win32' ? ['winldd' as const] : []; + for (const cdn of CDNS) { - test(`playwright cdn failover should work (${cdn})`, async ({ exec, installedSoftwareOnDisk }) => { + test(`playwright cdn failover should work (${cdn})`, async ({ exec, checkInstalledSoftwareOnDisk }) => { await exec('npm i playwright'); const result = await exec('npx playwright install', { env: { PW_TEST_CDN_THAT_SHOULD_WORK: cdn, DEBUG: 'pw:install' } }); - expect(result).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); - expect(await installedSoftwareOnDisk()).toEqual(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); + expect(result).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware]); + await checkInstalledSoftwareOnDisk((['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware])); const dls = parsedDownloads(result); for (const software of ['chromium', 'ffmpeg', 'firefox', 'webkit']) expect(dls).toContainEqual({ status: 200, name: software, url: expect.stringContaining(cdn) }); diff --git a/tests/installation/playwright-cli-install-should-work.spec.ts b/tests/installation/playwright-cli-install-should-work.spec.ts index 1c0cea6277..2825a9a44f 100755 --- a/tests/installation/playwright-cli-install-should-work.spec.ts +++ b/tests/installation/playwright-cli-install-should-work.spec.ts @@ -14,23 +14,26 @@ * limitations under the License. */ import { test, expect } from './npmTest'; +import { chromium } from '@playwright/test'; import path from 'path'; test.use({ isolateBrowsers: true }); -test('install command should work', async ({ exec, installedSoftwareOnDisk }) => { +const extraInstalledSoftware = process.platform === 'win32' ? ['winldd' as const] : []; + +test('install command should work', async ({ exec, checkInstalledSoftwareOnDisk }) => { await exec('npm i playwright'); await test.step('playwright install chromium', async () => { const result = await exec('npx playwright install chromium'); - expect(result).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg']); - expect(await installedSoftwareOnDisk()).toEqual(['chromium', 'chromium-headless-shell', 'ffmpeg']); + expect(result).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', ...extraInstalledSoftware]); + await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', ...extraInstalledSoftware]); }); await test.step('playwright install', async () => { const result = await exec('npx playwright install'); expect(result).toHaveLoggedSoftwareDownload(['firefox', 'webkit']); - expect(await installedSoftwareOnDisk()).toEqual(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); + await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware]); }); await exec('node sanity.js playwright', { env: { PLAYWRIGHT_BROWSERS_PATH: '0' } }); @@ -47,12 +50,12 @@ test('install command should work', async ({ exec, installedSoftwareOnDisk }) => } }); -test('should be able to remove browsers', async ({ exec, installedSoftwareOnDisk }) => { +test('should be able to remove browsers', async ({ exec, checkInstalledSoftwareOnDisk }) => { await exec('npm i playwright'); await exec('npx playwright install chromium'); - expect(await installedSoftwareOnDisk()).toEqual(['chromium', 'chromium-headless-shell', 'ffmpeg']); + await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', ...extraInstalledSoftware]); await exec('npx playwright uninstall'); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([...extraInstalledSoftware]); }); test('should print the right install command without browsers', async ({ exec }) => { @@ -90,8 +93,51 @@ test('subsequent installs works', async ({ exec }) => { await exec('node --unhandled-rejections=strict', path.join('node_modules', '@playwright', 'browser-chromium', 'install.js')); }); -test('install playwright-chromium should work', async ({ exec, installedSoftwareOnDisk }) => { +test('install playwright-chromium should work', async ({ exec }) => { await exec('npm i playwright-chromium'); await exec('npx playwright install chromium'); await exec('node sanity.js playwright-chromium chromium'); }); + +test('should print error if recording video without ffmpeg', async ({ exec, writeFiles }) => { + await exec('npm i playwright'); + + await writeFiles({ + 'launch.js': ` + const playwright = require('playwright'); + (async () => { + const browser = await playwright.chromium.launch({ executablePath: ${JSON.stringify(chromium.executablePath())} }); + try { + const context = await browser.newContext({ recordVideo: { dir: 'videos' } }); + const page = await context.newPage(); + } finally { + await browser.close(); + } + })().catch(e => { + console.error(e); + process.exit(1); + }); + `, + 'launchPersistentContext.js': ` + const playwright = require('playwright'); + process.on('unhandledRejection', (e) => console.error('unhandledRejection', e)); + (async () => { + const context = await playwright.chromium.launchPersistentContext('', { executablePath: ${JSON.stringify(chromium.executablePath())}, recordVideo: { dir: 'videos' } }); + })().catch(e => { + console.error(e); + process.exit(1); + }); + `, + }); + + await test.step('BrowserType.launch', async () => { + const result = await exec('node', 'launch.js', { expectToExitWithError: true }); + expect(result).toContain(`browserContext.newPage: Executable doesn't exist at`); + }); + + await test.step('BrowserType.launchPersistentContext', async () => { + const result = await exec('node', 'launchPersistentContext.js', { expectToExitWithError: true }); + expect(result).not.toContain('unhandledRejection'); + expect(result).toContain(`browserType.launchPersistentContext: Executable doesn't exist at`); + }); +}); diff --git a/tests/installation/playwright-packages-install-behavior.spec.ts b/tests/installation/playwright-packages-install-behavior.spec.ts index 9975abce99..2474d889eb 100755 --- a/tests/installation/playwright-packages-install-behavior.spec.ts +++ b/tests/installation/playwright-packages-install-behavior.spec.ts @@ -18,16 +18,18 @@ import { test, expect } from './npmTest'; test.use({ isolateBrowsers: true }); +const extraInstalledSoftware = process.platform === 'win32' ? ['winldd' as const] : []; + for (const browser of ['chromium', 'firefox', 'webkit']) { - test(`playwright-${browser} should work`, async ({ exec, installedSoftwareOnDisk }) => { + test(`playwright-${browser} should work`, async ({ exec, checkInstalledSoftwareOnDisk }) => { const pkg = `playwright-${browser}`; const result = await exec('npm i --foreground-scripts', pkg); const browserName = pkg.split('-')[1]; - const expectedSoftware = [browserName]; + const expectedSoftware = [browserName, ...extraInstalledSoftware]; if (browserName === 'chromium') expectedSoftware.push('chromium-headless-shell', 'ffmpeg'); expect(result).toHaveLoggedSoftwareDownload(expectedSoftware as any); - expect(await installedSoftwareOnDisk()).toEqual(expectedSoftware); + await checkInstalledSoftwareOnDisk(expectedSoftware); expect(result).not.toContain(`To avoid unexpected behavior, please install your dependencies first`); await exec('node sanity.js', pkg, browser); await exec('node', `esm-${pkg}.mjs`); @@ -35,75 +37,75 @@ for (const browser of ['chromium', 'firefox', 'webkit']) { } for (const browser of ['chromium', 'firefox', 'webkit']) { - test(`@playwright/browser-${browser} should work`, async ({ exec, installedSoftwareOnDisk }) => { + test(`@playwright/browser-${browser} should work`, async ({ exec, checkInstalledSoftwareOnDisk }) => { const pkg = `@playwright/browser-${browser}`; - const expectedSoftware = [browser]; + const expectedSoftware = [browser, ...extraInstalledSoftware]; if (browser === 'chromium') expectedSoftware.push('chromium-headless-shell', 'ffmpeg'); const result1 = await exec('npm i --foreground-scripts', pkg); expect(result1).toHaveLoggedSoftwareDownload(expectedSoftware as any); - expect(await installedSoftwareOnDisk()).toEqual(expectedSoftware); + await checkInstalledSoftwareOnDisk(expectedSoftware); expect(result1).not.toContain(`To avoid unexpected behavior, please install your dependencies first`); const result2 = await exec('npm i --foreground-scripts playwright'); expect(result2).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual(expectedSoftware); + await checkInstalledSoftwareOnDisk(expectedSoftware); await exec('node sanity.js playwright', browser); await exec('node browser-only.js', pkg); }); } -test(`playwright-core should work`, async ({ exec, installedSoftwareOnDisk }) => { +test(`playwright-core should work`, async ({ exec, checkInstalledSoftwareOnDisk }) => { const result1 = await exec('npm i --foreground-scripts playwright-core'); expect(result1).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); const stdio = await exec('npx playwright-core', 'test', '-c', '.', { expectToExitWithError: true }); expect(stdio).toContain(`Please install @playwright/test package`); }); -test(`playwright should work`, async ({ exec, installedSoftwareOnDisk }) => { +test(`playwright should work`, async ({ exec, checkInstalledSoftwareOnDisk }) => { const result1 = await exec('npm i --foreground-scripts playwright'); expect(result1).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); const result2 = await exec('npx playwright install'); - expect(result2).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); - expect(await installedSoftwareOnDisk()).toEqual(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); + expect(result2).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware]); + await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware]); await exec('node sanity.js playwright chromium firefox webkit'); await exec('node esm-playwright.mjs'); }); -test(`playwright should work with chromium --no-shell`, async ({ exec, installedSoftwareOnDisk }) => { +test(`playwright should work with chromium --no-shell`, async ({ exec, checkInstalledSoftwareOnDisk }) => { const result1 = await exec('npm i --foreground-scripts playwright'); expect(result1).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); const result2 = await exec('npx playwright install chromium --no-shell'); - expect(result2).toHaveLoggedSoftwareDownload(['chromium', 'ffmpeg']); - expect(await installedSoftwareOnDisk()).toEqual(['chromium', 'ffmpeg']); + expect(result2).toHaveLoggedSoftwareDownload(['chromium', 'ffmpeg', ...extraInstalledSoftware]); + await checkInstalledSoftwareOnDisk(['chromium', 'ffmpeg', ...extraInstalledSoftware]); }); -test(`playwright should work with chromium --only-shell`, async ({ exec, installedSoftwareOnDisk }) => { +test(`playwright should work with chromium --only-shell`, async ({ exec, checkInstalledSoftwareOnDisk }) => { const result1 = await exec('npm i --foreground-scripts playwright'); expect(result1).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); const result2 = await exec('npx playwright install --only-shell'); - expect(result2).toHaveLoggedSoftwareDownload(['chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); - expect(await installedSoftwareOnDisk()).toEqual(['chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); + expect(result2).toHaveLoggedSoftwareDownload(['chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware]); + await checkInstalledSoftwareOnDisk(['chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware]); }); -test('@playwright/test should work', async ({ exec, installedSoftwareOnDisk }) => { +test('@playwright/test should work', async ({ exec, checkInstalledSoftwareOnDisk }) => { const result1 = await exec('npm i --foreground-scripts @playwright/test'); expect(result1).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); await exec('npx playwright test -c . sample.spec.js', { expectToExitWithError: true, message: 'should not be able to run tests without installing browsers' }); const result2 = await exec('npx playwright install'); - expect(result2).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); - expect(await installedSoftwareOnDisk()).toEqual(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); + expect(result2).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware]); + await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware]); await exec('node sanity.js @playwright/test chromium firefox webkit'); await exec('node', 'esm-playwright-test.mjs'); diff --git a/tests/installation/playwright-test-plugin.spec.ts b/tests/installation/playwright-test-plugin.spec.ts index 5762b49900..fe4d24cd93 100755 --- a/tests/installation/playwright-test-plugin.spec.ts +++ b/tests/installation/playwright-test-plugin.spec.ts @@ -26,7 +26,6 @@ function patchPackageJsonForPreReleaseIfNeeded(tmpWorkspace: string) { // Workaround per https://stackoverflow.com/questions/71479750/npm-install-pre-release-versions-for-peer-dependency. const pkg = JSON.parse(fs.readFileSync(path.resolve(tmpWorkspace, 'package.json'), 'utf-8')); if (pkg.dependencies['@playwright/test'].match(/\d+\.\d+-\w+/)) { - console.log(`Setting overrides in package.json to make pre-release version of peer dependency work.`); pkg.overrides = { '@playwright/test': '$@playwright/test' }; fs.writeFileSync(path.resolve(tmpWorkspace, 'package.json'), JSON.stringify(pkg, null, 2)); } diff --git a/tests/installation/skip-browser-download.spec.ts b/tests/installation/skip-browser-download.spec.ts index 7cf9cdf602..21d4443dca 100755 --- a/tests/installation/skip-browser-download.spec.ts +++ b/tests/installation/skip-browser-download.spec.ts @@ -17,10 +17,10 @@ import { test, expect } from './npmTest'; test.use({ isolateBrowsers: true }); -test('should skip browser installs', async ({ exec, installedSoftwareOnDisk }) => { +test('should skip browser installs', async ({ exec, checkInstalledSoftwareOnDisk }) => { const result = await exec('npm i --foreground-scripts playwright @playwright/browser-firefox', { env: { PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' } }); expect(result).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); expect(result).toContain(`Skipping browsers download because`); if (process.platform === 'linux') { diff --git a/tests/library/browsercontext-har.spec.ts b/tests/library/browsercontext-har.spec.ts index 4a7834013a..796a37426a 100644 --- a/tests/library/browsercontext-har.spec.ts +++ b/tests/library/browsercontext-har.spec.ts @@ -556,4 +556,4 @@ it('should ignore aborted requests', async ({ contextFactory, server }) => { const result = await Promise.race([evalPromise, page2.waitForTimeout(1000).then(() => 'timeout')]); expect(result).toBe('timeout'); } -}); \ No newline at end of file +}); diff --git a/tests/library/browsercontext-reuse.spec.ts b/tests/library/browsercontext-reuse.spec.ts index 30319f0aad..2d65d472b3 100644 --- a/tests/library/browsercontext-reuse.spec.ts +++ b/tests/library/browsercontext-reuse.spec.ts @@ -15,7 +15,7 @@ */ import { browserTest, expect } from '../config/browserTest'; -import type { BrowserContext } from '@playwright/test'; +import type { BrowserContext, Page } from '@playwright/test'; const test = browserTest.extend<{ reusedContext: () => Promise }>({ reusedContext: async ({ browserType, browser }, use) => { @@ -287,3 +287,42 @@ test('should continue issuing events after closing the reused page', async ({ re ]); } }); + +test('should work with routeWebSocket', async ({ reusedContext, server, browser }, testInfo) => { + async function setup(page: Page, suffix: string) { + await page.routeWebSocket(/ws1/, ws => { + ws.onMessage(message => { + ws.send('page-mock-' + suffix); + }); + }); + await page.context().routeWebSocket(/.*/, ws => { + ws.onMessage(message => { + ws.send('context-mock-' + suffix); + }); + }); + await page.goto('about:blank'); + await page.evaluate(({ port }) => { + window.log = []; + (window as any).ws1 = new WebSocket('ws://localhost:' + port + '/ws1'); + (window as any).ws1.addEventListener('message', event => window.log.push(`ws1:${event.data}`)); + (window as any).ws2 = new WebSocket('ws://localhost:' + port + '/ws2'); + (window as any).ws2.addEventListener('message', event => window.log.push(`ws2:${event.data}`)); + }, { port: server.PORT }); + } + + let context = await reusedContext(); + let page = await context.newPage(); + await setup(page, 'before'); + await page.evaluate(() => (window as any).ws1.send('request')); + await expect.poll(() => page.evaluate(() => window.log)).toEqual([`ws1:page-mock-before`]); + await page.evaluate(() => (window as any).ws2.send('request')); + await expect.poll(() => page.evaluate(() => window.log)).toEqual([`ws1:page-mock-before`, `ws2:context-mock-before`]); + + context = await reusedContext(); + page = context.pages()[0]; + await setup(page, 'after'); + await page.evaluate(() => (window as any).ws1.send('request')); + await expect.poll(() => page.evaluate(() => window.log)).toEqual([`ws1:page-mock-after`]); + await page.evaluate(() => (window as any).ws2.send('request')); + await expect.poll(() => page.evaluate(() => window.log)).toEqual([`ws1:page-mock-after`, `ws2:context-mock-after`]); +}); diff --git a/tests/library/chromium/chromium.spec.ts b/tests/library/chromium/chromium.spec.ts index 61aa811dca..910e0830ff 100644 --- a/tests/library/chromium/chromium.spec.ts +++ b/tests/library/chromium/chromium.spec.ts @@ -629,4 +629,4 @@ test.describe('PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1', () => { const req = await requestPromise; expect(req.headers['x-custom-header']).toBe('custom!'); }); -}); \ No newline at end of file +}); diff --git a/tests/library/debug-controller.spec.ts b/tests/library/debug-controller.spec.ts index 6050d4e645..b71cae12a5 100644 --- a/tests/library/debug-controller.spec.ts +++ b/tests/library/debug-controller.spec.ts @@ -84,9 +84,11 @@ test('should pick element', async ({ backend, connectedBrowser }) => { expect(events).toEqual([ { + ariaSnapshot: '- button "Submit"', selector: 'internal:role=button[name=\"Submit\"i]', locator: 'getByRole(\'button\', { name: \'Submit\' })', }, { + ariaSnapshot: '- button "Submit"', selector: 'internal:role=button[name=\"Submit\"i]', locator: 'getByRole(\'button\', { name: \'Submit\' })', }, @@ -298,3 +300,9 @@ test('should highlight aria template', async ({ backend, connectedBrowser }, tes const box2 = roundBox(await highlight.boundingBox()); expect(box1).toEqual(box2); }); + +test('should report error in aria template', async ({ backend }) => { + await backend.navigate({ url: `data:text/html,` }); + const error = await backend.highlight({ ariaTemplate: `- button "Submit` }).catch(e => e); + expect(error.message).toContain('Unterminated string:'); +}); diff --git a/tests/library/headful.spec.ts b/tests/library/headful.spec.ts index d0c9f99450..b74563850a 100644 --- a/tests/library/headful.spec.ts +++ b/tests/library/headful.spec.ts @@ -19,7 +19,7 @@ import { PNG } from 'playwright-core/lib/utilsBundle'; import { expect, playwrightTest as it } from '../config/browserTest'; it.use({ headless: false }); -it.skip(({ channel }) => channel === 'chromium-headless-shell', 'shell is never headed'); +it.skip(({ channel }) => channel === 'chromium-headless-shell' || channel === 'chromium-tip-of-tree-headless-shell', 'shell is never headed'); it('should have default url when launching browser @smoke', async ({ launchPersistent }) => { const { context } = await launchPersistent(); diff --git a/tests/library/inspector/cli-codegen-1.spec.ts b/tests/library/inspector/cli-codegen-1.spec.ts index a876a25e47..0fd5f69d0b 100644 --- a/tests/library/inspector/cli-codegen-1.spec.ts +++ b/tests/library/inspector/cli-codegen-1.spec.ts @@ -926,4 +926,34 @@ await page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).ClickAsync();`) const predicate = (msg: ConsoleMessage) => msg.type() === 'error' && /Content[\- ]Security[\- ]Policy/i.test(msg.text()); await expect(page.waitForEvent('console', { predicate, timeout: 1000 })).rejects.toThrow(); }); + + test('should clear when recording is disabled', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/33802' } }, async ({ openRecorder }) => { + const { recorder } = await openRecorder(); + + await recorder.setContentAndWait(` + + + `); + + await recorder.hoverOverElement('#foo'); + let [sources] = await Promise.all([ + recorder.waitForOutput('JavaScript', 'click'), + recorder.trustedClick(), + ]); + + expect(sources.get('JavaScript').text).toContain(`getByRole('button', { name: 'Foo' }).click()`); + + await recorder.recorderPage.getByRole('button', { name: 'Record' }).click(); + await recorder.recorderPage.getByRole('button', { name: 'Clear' }).click(); + await recorder.recorderPage.getByRole('button', { name: 'Record' }).click(); + + await recorder.hoverOverElement('#bar'); + [sources] = await Promise.all([ + recorder.waitForOutput('JavaScript', 'click'), + recorder.trustedClick(), + ]); + + expect(sources.get('JavaScript').text).toContain(`getByRole('button', { name: 'Bar' }).click()`); + expect(sources.get('JavaScript').text).not.toContain(`getByRole('button', { name: 'Foo' })`); + }); }); diff --git a/tests/library/inspector/cli-codegen-3.spec.ts b/tests/library/inspector/cli-codegen-3.spec.ts index a57e7d5405..22032a8f26 100644 --- a/tests/library/inspector/cli-codegen-3.spec.ts +++ b/tests/library/inspector/cli-codegen-3.spec.ts @@ -472,7 +472,7 @@ await page.GetByTestId("testid").ClickAsync();`); await recorder.setContentAndWait(``); const locator = await recorder.hoverOverElement('input'); - expect(locator).toBe(`getByPlaceholder('Country')`); + expect(locator).toBe(`getByRole('textbox', { name: 'Country' })`); const [sources] = await Promise.all([ recorder.waitForOutput('JavaScript', 'click'), @@ -480,19 +480,19 @@ await page.GetByTestId("testid").ClickAsync();`); ]); expect.soft(sources.get('JavaScript')!.text).toContain(` - await page.getByPlaceholder('Country').click();`); + await page.getByRole('textbox', { name: 'Country' }).click();`); expect.soft(sources.get('Python')!.text).toContain(` - page.get_by_placeholder("Country").click()`); + page.get_by_role("textbox", name="Country").click()`); expect.soft(sources.get('Python Async')!.text).toContain(` - await page.get_by_placeholder("Country").click()`); + await page.get_by_role("textbox", name="Country").click()`); expect.soft(sources.get('Java')!.text).toContain(` - page.getByPlaceholder("Country").click()`); + page.getByRole(AriaRole.TEXTBOX, new Page.GetByRoleOptions().setName("Country")).click()`); expect.soft(sources.get('C#')!.text).toContain(` -await page.GetByPlaceholder("Country").ClickAsync();`); +await page.GetByRole(AriaRole.Textbox, new() { Name = "Country" }).ClickAsync();`); }); test('should generate getByAltText', async ({ openRecorder }) => { @@ -530,7 +530,7 @@ await page.GetByAltText("Country").ClickAsync();`); await recorder.setContentAndWait(``); const locator = await recorder.hoverOverElement('input'); - expect(locator).toBe(`getByLabel('Country')`); + expect(locator).toBe(`getByRole('textbox', { name: 'Country' })`); const [sources] = await Promise.all([ recorder.waitForOutput('JavaScript', 'click'), @@ -538,19 +538,19 @@ await page.GetByAltText("Country").ClickAsync();`); ]); expect.soft(sources.get('JavaScript')!.text).toContain(` - await page.getByLabel('Country').click();`); + await page.getByRole('textbox', { name: 'Country' }).click();`); expect.soft(sources.get('Python')!.text).toContain(` - page.get_by_label("Country").click()`); + page.get_by_role("textbox", name="Country").click()`); expect.soft(sources.get('Python Async')!.text).toContain(` - await page.get_by_label("Country").click()`); + await page.get_by_role("textbox", name="Country").click()`); expect.soft(sources.get('Java')!.text).toContain(` - page.getByLabel("Country").click()`); + page.getByRole(AriaRole.TEXTBOX, new Page.GetByRoleOptions().setName("Country")).click()`); expect.soft(sources.get('C#')!.text).toContain(` -await page.GetByLabel("Country").ClickAsync();`); +await page.GetByRole(AriaRole.Textbox, new() { Name = "Country" }).ClickAsync();`); }); test('should generate getByLabel without regex', async ({ openRecorder }) => { @@ -559,7 +559,7 @@ await page.GetByLabel("Country").ClickAsync();`); await recorder.setContentAndWait(``); const locator = await recorder.hoverOverElement('input'); - expect(locator).toBe(`getByLabel('Coun"try')`); + expect(locator).toBe(`getByRole('textbox', { name: 'Coun"try' })`); const [sources] = await Promise.all([ recorder.waitForOutput('JavaScript', 'click'), @@ -567,19 +567,19 @@ await page.GetByLabel("Country").ClickAsync();`); ]); expect.soft(sources.get('JavaScript')!.text).toContain(` - await page.getByLabel('Coun\"try').click();`); + await page.getByRole('textbox', { name: 'Coun\"try' }).click();`); expect.soft(sources.get('Python')!.text).toContain(` - page.get_by_label("Coun\\"try").click()`); + page.get_by_role("textbox", name="Coun\\"try").click()`); expect.soft(sources.get('Python Async')!.text).toContain(` - await page.get_by_label("Coun\\"try").click()`); + await page.get_by_role("textbox", name="Coun\\"try").click()`); expect.soft(sources.get('Java')!.text).toContain(` - page.getByLabel("Coun\\"try").click()`); + page.getByRole(AriaRole.TEXTBOX, new Page.GetByRoleOptions().setName(\"Coun\\\"try\")).click();`); expect.soft(sources.get('C#')!.text).toContain(` -await page.GetByLabel("Coun\\"try").ClickAsync();`); +await page.GetByRole(AriaRole.Textbox, new() { Name = \"Coun\\\"try\" }).ClickAsync();`); }); test('should consume pointer events', async ({ openRecorder }) => { diff --git a/tests/library/inspector/cli-codegen-csharp.spec.ts b/tests/library/inspector/cli-codegen-csharp.spec.ts index ffa32ceaf5..8b79f23b2a 100644 --- a/tests/library/inspector/cli-codegen-csharp.spec.ts +++ b/tests/library/inspector/cli-codegen-csharp.spec.ts @@ -179,6 +179,20 @@ test('should work with --save-har', async ({ runCLI }, testInfo) => { expect(json.log.creator.name).toBe('Playwright'); }); +test('should work with --save-har and --save-har-glob', async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `await context.RouteFromHARAsync(${JSON.stringify(harFileName)}, new BrowserContextRouteFromHAROptions +{ + Url = "**/*.js", +});`; + const cli = runCLI(['--target=csharp', `--save-har=${harFileName}`, '--save-har-glob=**/*.js'], { + autoExitWhen: expectedResult, + }); + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); +}); + for (const testFramework of ['nunit', 'mstest'] as const) { test(`should not print context options method override in ${testFramework} if no options were passed`, async ({ runCLI }) => { const cli = runCLI([`--target=csharp-${testFramework}`, emptyHTML]); @@ -201,7 +215,7 @@ for (const testFramework of ['nunit', 'mstest'] as const) { test(`should work with --save-har in ${testFramework}`, async ({ runCLI }, testInfo) => { const harFileName = testInfo.outputPath('har.har'); - const expectedResult = `await context.RouteFromHARAsync(${JSON.stringify(harFileName)});`; + const expectedResult = `await Context.RouteFromHARAsync(${JSON.stringify(harFileName)});`; const cli = runCLI([`--target=csharp-${testFramework}`, `--save-har=${harFileName}`], { autoExitWhen: expectedResult, }); @@ -209,6 +223,20 @@ for (const testFramework of ['nunit', 'mstest'] as const) { const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); expect(json.log.creator.name).toBe('Playwright'); }); + + test(`should work with --save-har and --save-har-glob in ${testFramework}`, async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `await Context.RouteFromHARAsync(${JSON.stringify(harFileName)}, new BrowserContextRouteFromHAROptions + { + Url = "**/*.js", + });`; + const cli = runCLI([`--target=csharp-${testFramework}`, `--save-har=${harFileName}`, '--save-har-glob=**/*.js'], { + autoExitWhen: expectedResult, + }); + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); + }); } test(`should print a valid basic program in mstest`, async ({ runCLI }) => { diff --git a/tests/library/inspector/cli-codegen-java.spec.ts b/tests/library/inspector/cli-codegen-java.spec.ts index 93f55132ed..2fd4a8fd42 100644 --- a/tests/library/inspector/cli-codegen-java.spec.ts +++ b/tests/library/inspector/cli-codegen-java.spec.ts @@ -89,10 +89,24 @@ test('should print load/save storage_state', async ({ runCLI, browserName }, tes await cli.waitFor(expectedResult2); }); -test('should work with --save-har', async ({ runCLI }, testInfo) => { +test('should work with --save-har and --save-har-glob as java-library', async ({ runCLI }, testInfo) => { const harFileName = testInfo.outputPath('har.har'); - const expectedResult = `context.routeFromHAR(${JSON.stringify(harFileName)});`; - const cli = runCLI(['--target=java', `--save-har=${harFileName}`], { + const expectedResult = `context.routeFromHAR(Paths.get(${JSON.stringify(harFileName)}), new BrowserContext.RouteFromHAROptions() + .setUrl("**/*.js"));`; + const cli = runCLI(['--target=java', `--save-har=${harFileName}`, '--save-har-glob=**/*.js'], { + autoExitWhen: expectedResult, + }); + + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); +}); + +test('should work with --save-har and --save-har-glob as java-junit', async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `page.routeFromHAR(Paths.get(${JSON.stringify(harFileName)}), new Page.RouteFromHAROptions() + .setUrl("**/*.js"));`; + const cli = runCLI(['--target=java-junit', `--save-har=${harFileName}`, '--save-har-glob=**/*.js'], { autoExitWhen: expectedResult, }); diff --git a/tests/library/inspector/cli-codegen-pytest.spec.ts b/tests/library/inspector/cli-codegen-pytest.spec.ts index e1bd608ef5..ce9cd97ee0 100644 --- a/tests/library/inspector/cli-codegen-pytest.spec.ts +++ b/tests/library/inspector/cli-codegen-pytest.spec.ts @@ -69,3 +69,25 @@ def test_example(page: Page) -> None: page.goto("${emptyHTML}") `); }); + +test('should work with --save-har', async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `page.route_from_har(${JSON.stringify(harFileName)})`; + const cli = runCLI(['--target=python-pytest', `--save-har=${harFileName}`], { + autoExitWhen: expectedResult, + }); + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); +}); + +test('should work with --save-har and --save-har-glob', async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `page.route_from_har(${JSON.stringify(harFileName)}, url="**/*.js")`; + const cli = runCLI(['--target=python-pytest', `--save-har=${harFileName}`, '--save-har-glob=**/*.js'], { + autoExitWhen: expectedResult, + }); + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); +}); diff --git a/tests/library/inspector/cli-codegen-python-async.spec.ts b/tests/library/inspector/cli-codegen-python-async.spec.ts index 3c04a5d8fa..94cc947ae4 100644 --- a/tests/library/inspector/cli-codegen-python-async.spec.ts +++ b/tests/library/inspector/cli-codegen-python-async.spec.ts @@ -146,7 +146,7 @@ asyncio.run(main()) test('should work with --save-har', async ({ runCLI }, testInfo) => { const harFileName = testInfo.outputPath('har.har'); - const expectedResult = `await page.route_from_har(${JSON.stringify(harFileName)})`; + const expectedResult = `await context.route_from_har(${JSON.stringify(harFileName)})`; const cli = runCLI(['--target=python-async', `--save-har=${harFileName}`], { autoExitWhen: expectedResult, }); @@ -154,3 +154,14 @@ test('should work with --save-har', async ({ runCLI }, testInfo) => { const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); expect(json.log.creator.name).toBe('Playwright'); }); + +test('should work with --save-har and --save-har-glob', async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `await context.route_from_har(${JSON.stringify(harFileName)}, url="**/*.js")`; + const cli = runCLI(['--target=python-async', `--save-har=${harFileName}`, '--save-har-glob=**/*.js'], { + autoExitWhen: expectedResult, + }); + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); +}); diff --git a/tests/library/inspector/cli-codegen-python.spec.ts b/tests/library/inspector/cli-codegen-python.spec.ts index 2bccbf3d93..13898efb5f 100644 --- a/tests/library/inspector/cli-codegen-python.spec.ts +++ b/tests/library/inspector/cli-codegen-python.spec.ts @@ -129,3 +129,25 @@ with sync_playwright() as playwright: `; await cli.waitFor(expectedResult2); }); + +test('should work with --save-har', async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `context.route_from_har(${JSON.stringify(harFileName)})`; + const cli = runCLI(['--target=python-async', `--save-har=${harFileName}`], { + autoExitWhen: expectedResult, + }); + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); +}); + +test('should work with --save-har and --save-har-glob', async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `context.route_from_har(${JSON.stringify(harFileName)}, url="**/*.js")`; + const cli = runCLI(['--target=python-async', `--save-har=${harFileName}`, '--save-har-glob=**/*.js'], { + autoExitWhen: expectedResult, + }); + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); +}); diff --git a/tests/library/inspector/cli-codegen-test.spec.ts b/tests/library/inspector/cli-codegen-test.spec.ts index d76caee422..b24871877f 100644 --- a/tests/library/inspector/cli-codegen-test.spec.ts +++ b/tests/library/inspector/cli-codegen-test.spec.ts @@ -108,3 +108,18 @@ test('should generate routeFromHAR with --save-har', async ({ runCLI }, testInfo const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); expect(json.log.creator.name).toBe('Playwright'); }); + +test('should generate routeFromHAR with --save-har and --save-har-glob', async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `test('test', async ({ page }) => { + await page.routeFromHAR('${harFileName.replace(/\\/g, '\\\\')}', { + url: '**/*.js' + }); +});`; + const cli = runCLI(['--target=playwright-test', `--save-har=${harFileName}`, '--save-har-glob=**/*.js'], { + autoExitWhen: expectedResult, + }); + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); +}); diff --git a/tests/library/launcher.spec.ts b/tests/library/launcher.spec.ts index bb244c0b60..049e9e1834 100644 --- a/tests/library/launcher.spec.ts +++ b/tests/library/launcher.spec.ts @@ -45,6 +45,7 @@ it('should throw a friendly error if its headed and there is no xserver on linux it.skip(platform !== 'linux'); it.skip(mode.startsWith('service')); it.skip(channel === 'chromium-headless-shell', 'shell is never headed'); + it.skip(channel === 'chromium-tip-of-tree-headless-shell', 'shell is never headed'); const error: Error = await browserType.launch({ headless: false, diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index 4df72977f4..417e096e29 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -196,6 +196,12 @@ it('reverse engineer getByRole', async ({ page }) => { java: `getByRole(AriaRole.BUTTON)`, csharp: `GetByRole(AriaRole.Button)`, }); + expect.soft(generate(page.getByRole('heading', {}))).toEqual({ + javascript: "getByRole('heading')", + python: 'get_by_role("heading")', + java: 'getByRole(AriaRole.HEADING)', + csharp: 'GetByRole(AriaRole.Heading)' + }); expect.soft(generate(page.getByRole('button', { name: 'Hello' }))).toEqual({ javascript: `getByRole('button', { name: 'Hello' })`, python: `get_by_role("button", name="Hello")`, @@ -559,6 +565,12 @@ it('parseLocator css', async () => { expect.soft(parseLocator('csharp', `Locator("css=.foo")`, '')).toBe(`css=.foo`); }); + +it('parseLocator options', async () => { + expect.soft(parseLocator('javascript', `getByRole('heading', {})`, '')).toBe(`internal:role=heading`); + expect.soft(parseLocator('javascript', `getByRole('checkbox', { checked:false, includeHidden: true })`, '')).toBe(`internal:role=checkbox[checked=false][include-hidden=true]`); +}); + it('parse locators strictly', () => { const selector = 'div >> internal:has-text=\"Goodbye world\"i >> span'; @@ -603,3 +615,27 @@ it('parseLocator frames', async () => { expect.soft(parseLocator('java', `locator("iframe").contentFrame().getByText("foo")`, '')).toBe(`iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); expect.soft(parseLocator('java', `frameLocator("iframe").getByText("foo")`, '')).toBe(`iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); }); + +it('should not oom in locator parser', async ({ page }) => { + const l = page.locator.bind(page); + const locator = page.locator('text=L1').or(l('text=L2').or(l('text=L3').or(l('text=L4')).or(l('#f0') + .contentFrame().locator('#f0_mid_0') + .contentFrame().locator('text=L5').or(l('text=L6'))).or(l('#f0') + .contentFrame().locator('#f0_mid_0') + .contentFrame().locator('text=L7') + .or(l('text=L8'))))).or(l('text=L9').or(l('text=L10').or(l('text=L11')).or(l('#f0') + .contentFrame().locator('#f0_mid_0') + .contentFrame().locator('text=L12').or(l('text=L13'))).or(l('#f0') + .contentFrame().locator('#f0_mid_0') + .contentFrame().locator('text=L14').or(l('text=L15'))))).or(l('text=L16').or(l('text=L17').or(l('text=L18')).or(l('#f0') + .contentFrame().locator('#f0_mid_0') + .contentFrame().locator('text=L19').or(l('text=L20'))).or(l('#f0') + .contentFrame().locator('#f0_mid_0') + .contentFrame().locator('text=L21').or(l('text=L22'))))).or(l('text=L23').or(l('text=L24').or(l('text=L25')).or(l('#f0') + .contentFrame().locator('#f0_mid_0') + .contentFrame().locator('text=L26').or(l('text=L27'))).or(l('#f0') + .contentFrame().locator('#f0_mid_0') + .contentFrame().locator('text=L28').or(l('text=L29'))))); + const error = await locator.count().catch(e => e); + expect(error.message).toContain('Frame locators are not allowed inside composite locators'); +}); diff --git a/tests/library/page-event-crash.spec.ts b/tests/library/page-event-crash.spec.ts index 1bf9c396dd..d185fa5a19 100644 --- a/tests/library/page-event-crash.spec.ts +++ b/tests/library/page-event-crash.spec.ts @@ -32,6 +32,10 @@ const test = testBase.extend<{ crash: () => void }, { dummy: string }>({ dummy: ['', { scope: 'worker' }], }); +test.beforeEach(({ platform, browserName }) => { + test.slow(platform === 'linux' && browserName === 'webkit', 'WebKit/Linux tests are consistently slower on some Linux environments. Most likely WebContent process is not getting terminated properly and is causing the slowdown.'); +}); + test('should emit crash event when page crashes', async ({ page, crash }) => { await page.setContent(`
This page should crash
`); crash(); diff --git a/tests/library/pdf.spec.ts b/tests/library/pdf.spec.ts index 3ef002e25c..38e8c7b5be 100644 --- a/tests/library/pdf.spec.ts +++ b/tests/library/pdf.spec.ts @@ -17,9 +17,8 @@ import { browserTest as it, expect } from '../config/browserTest'; import fs from 'fs'; -it('should be able to save file', async ({ contextFactory, headless, browserName }, testInfo) => { - it.skip(!headless || browserName !== 'chromium', 'Printing to pdf is currently only supported in headless chromium.'); - +it('should be able to save file', async ({ contextFactory, browserName }, testInfo) => { + it.skip(browserName !== 'chromium', 'Printing to pdf is currently only supported in chromium.'); const context = await contextFactory(); const page = await context.newPage(); const outputFile = testInfo.outputPath('output.pdf'); @@ -27,9 +26,8 @@ it('should be able to save file', async ({ contextFactory, headless, browserName expect(fs.readFileSync(outputFile).byteLength).toBeGreaterThan(0); }); -it('should be able to generate outline', async ({ contextFactory, server, headless, browserName }, testInfo) => { - it.skip(!headless || browserName !== 'chromium', 'Printing to pdf is currently only supported in headless chromium.'); - // const context = await contextFactory(); +it('should be able to generate outline', async ({ contextFactory, server, browserName }, testInfo) => { + it.skip(browserName !== 'chromium', 'Printing to pdf is currently only supported in chromium.'); const context = await contextFactory({ baseURL: server.PREFIX, }); diff --git a/tests/library/popup.spec.ts b/tests/library/popup.spec.ts index 1dcede604b..8f2f1df7dc 100644 --- a/tests/library/popup.spec.ts +++ b/tests/library/popup.spec.ts @@ -288,4 +288,4 @@ async function waitForRafs(page: Page, count: number): Promise { }; window.builtinRequestAnimationFrame(onRaf); }), count); -} \ No newline at end of file +} diff --git a/tests/library/selector-generator.spec.ts b/tests/library/selector-generator.spec.ts index e9df1afc6e..1feaf538e7 100644 --- a/tests/library/selector-generator.spec.ts +++ b/tests/library/selector-generator.spec.ts @@ -58,7 +58,7 @@ it.describe('selector generator', () => { it('should not escape spaces inside named attr selectors', async ({ page }) => { await page.setContent(``); - expect(await generate(page, 'input')).toBe('internal:attr=[placeholder=\"Foo b ar\"i]'); + expect(await generate(page, 'input')).toBe('internal:role=textbox[name=\"Foo b ar\"i]'); }); it('should generate text for ', async ({ page }) => { @@ -91,7 +91,7 @@ it.describe('selector generator', () => { it('should try to improve label text by shortening', async ({ page }) => { await page.setContent(``); - expect(await generate(page, 'input')).toBe('internal:label="Longest verbose description"i'); + expect(await generate(page, 'input')).toBe('internal:role=textbox[name=\"Longest verbose description\"i]'); }); it('should not improve guid text', async ({ page }) => { @@ -309,14 +309,14 @@ it.describe('selector generator', () => { expect(await generate(page, 'input[mark="1"]')).toBe('internal:role=textbox >> nth=1'); }); - it.describe('should prioritise attributes correctly', () => { + it.describe('should prioritize attributes correctly', () => { it('role', async ({ page }) => { await page.setContent(``); expect(await generate(page, 'input')).toBe('internal:role=textbox'); }); it('placeholder', async ({ page }) => { await page.setContent(``); - expect(await generate(page, 'input')).toBe('internal:attr=[placeholder=\"foobar\"i]'); + expect(await generate(page, 'input')).toBe('internal:role=textbox[name=\"foobar\"i]'); }); it('name', async ({ page }) => { await page.setContent(` @@ -437,7 +437,7 @@ it.describe('selector generator', () => { it('should accept valid aria-label for candidate consideration', async ({ page }) => { await page.setContent(``); - expect(await generate(page, 'button')).toBe('internal:label="ariaLabel"i'); + expect(await generate(page, 'button')).toBe('internal:role=button[name=\"ariaLabel\"i]'); }); it('should ignore empty role for candidate consideration', async ({ page }) => { @@ -469,15 +469,15 @@ it.describe('selector generator', () => {
text
`); - expect(await generate(page, '#target1')).toBe('internal:label="Target1"i'); - expect(await generate(page, '#target2')).toBe('internal:label="Target2"i'); - expect(await generate(page, '#target3')).toBe('internal:label="Target3"i'); - expect(await generate(page, '#target4')).toBe('internal:label="Target4"i'); - expect(await generate(page, '#target5')).toBe('#target5'); - expect(await generate(page, '#target6')).toBe('internal:text="text"i'); + expect.soft(await generate(page, '#target1')).toBe('internal:role=textbox[name=\"Target1\"i]'); + expect.soft(await generate(page, '#target2')).toBe('internal:role=button[name=\"Target2\"i]'); + expect.soft(await generate(page, '#target3')).toBe('internal:label=\"Target3\"i'); + expect.soft(await generate(page, '#target4')).toBe('internal:label=\"Target4\"i'); + expect.soft(await generate(page, '#target5')).toBe('#target5'); + expect.soft(await generate(page, '#target6')).toBe('internal:text="text"i'); await page.setContent(``); - expect(await generate(page, 'input')).toBe('internal:label="Coun\\\"try"i'); + expect(await generate(page, 'input')).toBe('internal:role=textbox[name=\"Coun\\\"try\"i]'); }); it('should prefer role other input[type]', async ({ page }) => { @@ -514,7 +514,7 @@ it.describe('selector generator', () => { `); - expect(await generate(page, 'input')).toBe('internal:attr=[placeholder=\"Text\"s]'); + expect(await generate(page, 'input')).toBe('internal:role=textbox[name=\"Text\"s]'); }); it('should generate exact role when necessary', async ({ page }) => { @@ -530,7 +530,7 @@ it.describe('selector generator', () => { `); - expect(await generate(page, 'input')).toBe('internal:label=\"Text\"s'); + expect(await generate(page, 'input')).toBe('internal:role=textbox[name=\"Text\"s]'); }); it('should generate relative selector', async ({ page }) => { @@ -596,4 +596,23 @@ it.describe('selector generator', () => { `span >> nth=1`, ]); }); + + it('should prefer role with hasText to css with hasText', async ({ page }) => { + await page.setContent(` +
    +
  • + + buy flowers +
  • +
  • + + sell milk +
  • +
+ `); + expect(await generateMultiple(page, 'input')).toEqual([ + `internal:role=listitem >> internal:has-text=\"buy flowers\"i >> internal:label=\"Toggle Todo\"i`, + `internal:label=\"Toggle Todo\"i >> nth=0`, + ]); + }); }); diff --git a/tests/library/snapshotter.spec.ts b/tests/library/snapshotter.spec.ts index 7ada643dcc..3e9cfce46f 100644 --- a/tests/library/snapshotter.spec.ts +++ b/tests/library/snapshotter.spec.ts @@ -268,10 +268,12 @@ it.describe('snapshots', () => { }); }); -function distillSnapshot(snapshot, distillTarget = true) { +function distillSnapshot(snapshot, options: { distillTarget: boolean, distillBoundingRect: boolean } = { distillTarget: true, distillBoundingRect: true }) { let { html } = snapshot.render(); - if (distillTarget) + if (options.distillTarget) html = html.replace(/\s__playwright_target__="[^"]+"/g, ''); + if (options.distillBoundingRect) + html = html.replace(/\s__playwright_bounding_rect__="[^"]+"/g, ''); return html .replace(/