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_others.yml b/.github/workflows/tests_others.yml index 66728e720f..ed18e1541c 100644 --- a/.github/workflows/tests_others.yml +++ b/.github/workflows/tests_others.yml @@ -147,6 +147,13 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + - name: Setup Ubuntu Binary Installation # TODO: Remove when https://github.com/electron/electron/issues/42510 is fixed + if: ${{ runner.os == 'Linux' }} + run: | + if grep -q "Ubuntu 24" /etc/os-release; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + shell: bash - uses: ./.github/actions/run-test with: browsers-to-install: chromium diff --git a/.github/workflows/tests_primary.yml b/.github/workflows/tests_primary.yml index 02847957c4..b8ec84ee56 100644 --- a/.github/workflows/tests_primary.yml +++ b/.github/workflows/tests_primary.yml @@ -215,6 +215,13 @@ jobs: - uses: actions/checkout@v4 - run: npm install -g yarn@1 - run: npm install -g pnpm@8 + - name: Setup Ubuntu Binary Installation # TODO: Remove when https://github.com/electron/electron/issues/42510 is fixed + if: ${{ runner.os == 'Linux' }} + run: | + if grep -q "Ubuntu 24" /etc/os-release; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + shell: bash - uses: ./.github/actions/run-test with: command: npm run itest diff --git a/.github/workflows/tests_secondary.yml b/.github/workflows/tests_secondary.yml index 5afd2f5491..5bf017453d 100644 --- a/.github/workflows/tests_secondary.yml +++ b/.github/workflows/tests_secondary.yml @@ -107,6 +107,13 @@ jobs: - uses: actions/checkout@v4 - run: npm install -g yarn@1 - run: npm install -g pnpm@8 + - name: Setup Ubuntu Binary Installation # TODO: Remove when https://github.com/electron/electron/issues/42510 is fixed + if: ${{ runner.os == 'Linux' }} + run: | + if grep -q "Ubuntu 24" /etc/os-release; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + shell: bash - uses: ./.github/actions/run-test with: node-version: ${{ matrix.node_version }} diff --git a/README.md b/README.md index 913aac3269..b2c569a402 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.46-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-133.0.3-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,9 +8,9 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 132.0.6834.46 | :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: | +| Firefox 133.0.3 | :white_check_mark: | :white_check_mark: | :white_check_mark: | Headless execution is supported for all browsers on all platforms. Check out [system requirements](https://playwright.dev/docs/intro#system-requirements) for details. diff --git a/docs/src/api/class-androiddevice.md b/docs/src/api/class-androiddevice.md index 729b36f922..86b003638b 100644 --- a/docs/src/api/class-androiddevice.md +++ b/docs/src/api/class-androiddevice.md @@ -136,7 +136,7 @@ Launches Chrome browser on the device, and returns its persistent context. ### option: AndroidDevice.launchBrowser.pkg * since: v1.9 -- `command` <[string]> +- `pkg` <[string]> Optional package name to launch instead of default Chrome for Android. diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index e93d02d9d8..26b4b475d2 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -633,13 +633,11 @@ properties: You can also specify [JSHandle] as the property value if you want live objects to be passed into the event: ```js -// Note you can only create DataTransfer in Chromium and Firefox const dataTransfer = await page.evaluateHandle(() => new DataTransfer()); await locator.dispatchEvent('dragstart', { dataTransfer }); ``` ```java -// Note you can only create DataTransfer in Chromium and Firefox JSHandle dataTransfer = page.evaluateHandle("() => new DataTransfer()"); Map arg = new HashMap<>(); arg.put("dataTransfer", dataTransfer); @@ -647,13 +645,11 @@ locator.dispatchEvent("dragstart", arg); ``` ```python async -# note you can only create data_transfer in chromium and firefox data_transfer = await page.evaluate_handle("new DataTransfer()") await locator.dispatch_event("#source", "dragstart", {"dataTransfer": data_transfer}) ``` ```python sync -# note you can only create data_transfer in chromium and firefox data_transfer = page.evaluate_handle("new DataTransfer()") locator.dispatch_event("#source", "dragstart", {"dataTransfer": data_transfer}) ``` @@ -1717,16 +1713,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 +1736,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 +1745,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 +1754,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 +1763,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 7e61e1b3a5..968d6375d7 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -541,6 +541,16 @@ await Expect(locator).ToBeCheckedAsync(); * since: v1.18 - `checked` <[boolean]> +Provides state to assert for. Asserts for input to be checked by default. +This option can't be used when [`option: LocatorAssertions.toBeChecked.indeterminate`] is set to true. + +### option: LocatorAssertions.toBeChecked.indeterminate +* since: v1.50 +- `indeterminate` <[boolean]> + +Asserts that the element is in the indeterminate (mixed) state. Only supported for checkboxes and radio buttons. +This option can't be true when [`option: LocatorAssertions.toBeChecked.checked`] is provided. + ### option: LocatorAssertions.toBeChecked.timeout = %%-js-assertions-timeout-%% * since: v1.18 @@ -1217,6 +1227,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: 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/browsers.md b/docs/src/browsers.md index 90c0b2850b..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 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. +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..5cabc1c864 100644 --- a/docs/src/chrome-extensions-js-python.md +++ b/docs/src/chrome-extensions-js-python.md @@ -9,7 +9,9 @@ title: "Chrome extensions" Extensions only work in Chrome / Chromium launched with a persistent context. Use custom browser args at your own risk, as some of them may break Playwright functionality. ::: -The following is code for getting a handle to the [background page](https://developer.chrome.com/extensions/background_pages) of a [Manifest v2](https://developer.chrome.com/docs/extensions/mv2/) extension whose source is located in `./my-extension`: +The snippet below retrieves the [background page](https://developer.chrome.com/extensions/background_pages) of a [Manifest v2](https://developer.chrome.com/docs/extensions/mv2/) extension whose source is located in `./my-extension`. + +Note the use of the `chromium` channel that allows to run extensions in headless mode. Alternatively, you can launch the browser in headed mode. ```js const { chromium } = require('playwright'); @@ -18,7 +20,7 @@ const { chromium } = require('playwright'); const pathToExtension = require('path').join(__dirname, 'my-extension'); const userDataDir = '/tmp/test-user-data-dir'; const browserContext = await chromium.launchPersistentContext(userDataDir, { - headless: false, + channel: 'chromium', args: [ `--disable-extensions-except=${pathToExtension}`, `--load-extension=${pathToExtension}` @@ -44,7 +46,7 @@ user_data_dir = "/tmp/test-user-data-dir" async def run(playwright: Playwright): context = await playwright.chromium.launch_persistent_context( user_data_dir, - headless=False, + channel="chromium", args=[ f"--disable-extensions-except={path_to_extension}", f"--load-extension={path_to_extension}", @@ -78,7 +80,7 @@ user_data_dir = "/tmp/test-user-data-dir" def run(playwright: Playwright): context = playwright.chromium.launch_persistent_context( user_data_dir, - headless=False, + channel="chromium", args=[ f"--disable-extensions-except={path_to_extension}", f"--load-extension={path_to_extension}", @@ -101,6 +103,8 @@ with sync_playwright() as playwright: To have the extension loaded when running tests you can use a test fixture to set the context. You can also dynamically retrieve the extension id and use it to load and test the popup page for example. +Note the use of the `chromium` channel that allows to run extensions in headless mode. Alternatively, you can launch the browser in headed mode. + First, add fixtures that will load the extension: ```js title="fixtures.ts" @@ -114,7 +118,7 @@ export const test = base.extend<{ context: async ({ }, use) => { const pathToExtension = path.join(__dirname, 'my-extension'); const context = await chromium.launchPersistentContext('', { - headless: false, + channel: 'chromium', args: [ `--disable-extensions-except=${pathToExtension}`, `--load-extension=${pathToExtension}`, @@ -155,7 +159,7 @@ def context(playwright: Playwright) -> Generator[BrowserContext, None, None]: path_to_extension = Path(__file__).parent.joinpath("my-extension") context = playwright.chromium.launch_persistent_context( "", - headless=False, + channel="chromium", args=[ f"--disable-extensions-except={path_to_extension}", f"--load-extension={path_to_extension}", @@ -211,33 +215,3 @@ def test_popup_page(page: Page, extension_id: str) -> None: page.goto(f"chrome-extension://{extension_id}/popup.html") expect(page.locator("body")).to_have_text("my-extension popup") ``` - -## 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): - -```js title="fixtures.ts" -// ... - -const pathToExtension = path.join(__dirname, 'my-extension'); -const context = await chromium.launchPersistentContext('', { - channel: 'chromium', - args: [ - `--disable-extensions-except=${pathToExtension}`, - `--load-extension=${pathToExtension}`, - ], -}); -// ... -``` - -```python title="conftest.py" -path_to_extension = Path(__file__).parent.joinpath("my-extension") -context = playwright.chromium.launch_persistent_context( - "", - channel="chromium", - args=[ - f"--disable-extensions-except={path_to_extension}", - f"--load-extension={path_to_extension}", - ], -) -``` diff --git a/docs/src/input.md b/docs/src/input.md index b6fab7d005..4f71c2be67 100644 --- a/docs/src/input.md +++ b/docs/src/input.md @@ -217,7 +217,7 @@ await page.getByText('Item').click({ button: 'right' }); // Shift + click await page.getByText('Item').click({ modifiers: ['Shift'] }); -// Ctrl + click or Windows and Linux +// Ctrl + click on Windows and Linux // Meta + click on macOS await page.getByText('Item').click({ modifiers: ['ControlOrMeta'] }); @@ -241,7 +241,7 @@ page.getByText("Item").click(new Locator.ClickOptions().setButton(MouseButton.RI // Shift + click page.getByText("Item").click(new Locator.ClickOptions().setModifiers(Arrays.asList(KeyboardModifier.SHIFT))); -// Ctrl + click or Windows and Linux +// Ctrl + click on Windows and Linux // Meta + click on macOS page.getByText("Item").click(new Locator.ClickOptions().setModifiers(Arrays.asList(KeyboardModifier.CONTROL_OR_META))); @@ -265,7 +265,7 @@ await page.get_by_text("Item").click(button="right") # Shift + click await page.get_by_text("Item").click(modifiers=["Shift"]) -# Ctrl + click or Windows and Linux +# Ctrl + click on Windows and Linux # Meta + click on macOS await page.get_by_text("Item").click(modifiers=["ControlOrMeta"]) @@ -309,7 +309,7 @@ await page.GetByText("Item").ClickAsync(new() { Button = MouseButton.Right }); // Shift + click await page.GetByText("Item").ClickAsync(new() { Modifiers = new[] { KeyboardModifier.Shift } }); -// Ctrl + click or Windows and Linux +// Ctrl + click on Windows and Linux // Meta + click on macOS await page.GetByText("Item").ClickAsync(new() { Modifiers = new[] { KeyboardModifier.ControlOrMeta } }); diff --git a/docs/src/intro-js.md b/docs/src/intro-js.md index 321629662b..389a8b4dc8 100644 --- a/docs/src/intro-js.md +++ b/docs/src/intro-js.md @@ -286,7 +286,7 @@ pnpm exec playwright --version ## System requirements -- Node.js 18+ +- Latest version of Node.js 18, 20 or 22. - Windows 10+, Windows Server 2016+ or Windows Subsystem for Linux (WSL). - macOS 13 Ventura, or later. - Debian 12, Ubuntu 22.04, Ubuntu 24.04, on x86-64 and arm64 architecture. diff --git a/docs/src/test-api/class-testconfig.md b/docs/src/test-api/class-testconfig.md index 37b9ca5f27..90425fcf4c 100644 --- a/docs/src/test-api/class-testconfig.md +++ b/docs/src/test-api/class-testconfig.md @@ -629,6 +629,9 @@ export default defineConfig({ - `stdout` ?<["pipe"|"ignore"]> If `"pipe"`, it will pipe the stdout of the command to the process stdout. If `"ignore"`, it will ignore the stdout of the command. Default to `"ignore"`. - `stderr` ?<["pipe"|"ignore"]> Whether to pipe the stderr of the command to the process stderr or ignore it. Defaults to `"pipe"`. - `timeout` ?<[int]> How long to wait for the process to start up and be available in milliseconds. Defaults to 60000. + - `gracefulShutdown` ?<[Object]> How to shut down the process. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ signal: 'SIGINT', timeout: 500 }`, the process group is sent a `SIGINT` signal, followed by `SIGKILL` if it doesn't exit within 500ms. You can also use `SIGTERM` instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't support `SIGINT` and `SIGTERM` signals, so this option is ignored. + - `signal` <["SIGINT"|"SIGTERM"]> + - `timeout` <[int]> - `url` ?<[string]> The url on your http server that is expected to return a 2xx, 3xx, 400, 401, 402, or 403 status code when the server is ready to accept connections. Redirects (3xx status codes) are being followed and the new location is checked. Either `port` or `url` should be specified. Launch a development web server (or multiple) during the tests. 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-reporter-api/class-teststep.md b/docs/src/test-reporter-api/class-teststep.md index 43b8474abe..ef16e4849a 100644 --- a/docs/src/test-reporter-api/class-teststep.md +++ b/docs/src/test-reporter-api/class-teststep.md @@ -50,6 +50,16 @@ Start time of this particular test step. List of steps inside this step. +## property: TestStep.attachments +* since: v1.50 +- type: <[Array]<[Object]>> + - `name` <[string]> Attachment name. + - `contentType` <[string]> Content type of this attachment to properly present in the report, for example `'application/json'` or `'image/png'`. + - `path` ?<[string]> Optional path on the filesystem to the attached file. + - `body` ?<[Buffer]> Optional attachment body used instead of a file. + +The list of files or buffers attached in the step execution through [`method: TestInfo.attach`]. + ## property: TestStep.title * since: v1.10 - type: <[string]> diff --git a/docs/src/test-webserver-js.md b/docs/src/test-webserver-js.md index aba072d56a..f4c86197c0 100644 --- a/docs/src/test-webserver-js.md +++ b/docs/src/test-webserver-js.md @@ -36,7 +36,8 @@ export default defineConfig({ | `cwd` | Current working directory of the spawned process, defaults to the directory of the configuration file. | | `stdout` | If `"pipe"`, it will pipe the stdout of the command to the process stdout. If `"ignore"`, it will ignore the stdout of the command. Default to `"ignore"`. | | `stderr` | Whether to pipe the stderr of the command to the process stderr or ignore it. Defaults to `"pipe"`. | -| `timeout` | `How long to wait for the process to start up and be available in milliseconds. Defaults to 60000. | +| `timeout` | How long to wait for the process to start up and be available in milliseconds. Defaults to 60000. | +| `gracefulShutdown` | How to shut down the process. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ signal: 'SIGINT', timeout: 500 }`, the process group is sent a `SIGINT` signal, followed by `SIGKILL` if it doesn't exit within 500ms. You can also use `SIGTERM` instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't support `SIGINT` and `SIGTERM` signals, so this option is ignored. | ## Adding a server timeout diff --git a/docs/src/touch-events.md b/docs/src/touch-events.md new file mode 100644 index 0000000000..a1d394dc62 --- /dev/null +++ b/docs/src/touch-events.md @@ -0,0 +1,144 @@ +--- +id: touch-events +title: "Emulating touch events" +--- + +## Introduction + +Mobile web sites may listen to [touch events](https://developer.mozilla.org/en-US/docs/Web/API/Touch_events) and react to user touch gestures such as swipe, pinch, tap etc. To test this functionality you can manually generate [TouchEvent]s in the page context using [`method: Locator.evaluate`]. + +If your web application relies on [pointer events](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events) instead of touch events, you can use [`method: Locator.click`] and raw [`Mouse`] events to simulate a single-finger touch, and this will trigger all the same pointer events. + +### Dispatching TouchEvent + +You can dispatch touch events to the page using [`method: Locator.dispatchEvent`]. [Touch](https://developer.mozilla.org/en-US/docs/Web/API/Touch) points can be passed as arguments, see examples below. + +#### Emulating pan gesture + +In the example below, we emulate pan gesture that is expected to move the map. The app under test only uses `clientX/clientY` coordinates of the touch point, so we initialize just that. In a more complex scenario you may need to also set `pageX/pageY/screenX/screenY`, if your app needs them. + +```js +import { test, expect, devices, type Locator } from '@playwright/test'; + +test.use({ ...devices['Pixel 7'] }); + +async function pan(locator: Locator, deltaX?: number, deltaY?: number, steps?: number) { + const { centerX, centerY } = await locator.evaluate((target: HTMLElement) => { + const bounds = target.getBoundingClientRect(); + const centerX = bounds.left + bounds.width / 2; + const centerY = bounds.top + bounds.height / 2; + return { centerX, centerY }; + }); + + // Providing only clientX and clientY as the app only cares about those. + const touches = [{ + identifier: 0, + clientX: centerX, + clientY: centerY, + }]; + await locator.dispatchEvent('touchstart', + { touches, changedTouches: touches, targetTouches: touches }); + + steps = steps ?? 5; + deltaX = deltaX ?? 0; + deltaY = deltaY ?? 0; + for (let i = 1; i <= steps; i++) { + const touches = [{ + identifier: 0, + clientX: centerX + deltaX * i / steps, + clientY: centerY + deltaY * i / steps, + }]; + await locator.dispatchEvent('touchmove', + { touches, changedTouches: touches, targetTouches: touches }); + } + + await locator.dispatchEvent('touchend'); +} + +test(`pan gesture to move the map`, async ({ page }) => { + await page.goto('https://www.google.com/maps/place/@37.4117722,-122.0713234,15z', + { waitUntil: 'commit' }); + await page.getByRole('button', { name: 'Keep using web' }).click(); + await expect(page.getByRole('button', { name: 'Keep using web' })).not.toBeVisible(); + // Get the map element. + const met = page.locator('[data-test-id="met"]'); + for (let i = 0; i < 5; i++) + await pan(met, 200, 100); + // Ensure the map has been moved. + await expect(met).toHaveScreenshot(); +}); +``` + +#### Emulating pinch gesture + +In the example below, we emulate pinch gesture, i.e. two touch points moving closer to each other. It is expected to zoom out the map. The app under test only uses `clientX/clientY` coordinates of touch points, so we initialize just that. In a more complex scenario you may need to also set `pageX/pageY/screenX/screenY`, if your app needs them. + +```js +import { test, expect, devices, type Locator } from '@playwright/test'; + +test.use({ ...devices['Pixel 7'] }); + +async function pinch(locator: Locator, + arg: { deltaX?: number, deltaY?: number, steps?: number, direction?: 'in' | 'out' }) { + const { centerX, centerY } = await locator.evaluate((target: HTMLElement) => { + const bounds = target.getBoundingClientRect(); + const centerX = bounds.left + bounds.width / 2; + const centerY = bounds.top + bounds.height / 2; + return { centerX, centerY }; + }); + + const deltaX = arg.deltaX ?? 50; + const steps = arg.steps ?? 5; + const stepDeltaX = deltaX / (steps + 1); + + // Two touch points equally distant from the center of the element. + const touches = [ + { + identifier: 0, + clientX: centerX - (arg.direction === 'in' ? deltaX : stepDeltaX), + clientY: centerY, + }, + { + identifier: 1, + clientX: centerX + (arg.direction === 'in' ? deltaX : stepDeltaX), + clientY: centerY, + }, + ]; + await locator.dispatchEvent('touchstart', + { touches, changedTouches: touches, targetTouches: touches }); + + // Move the touch points towards or away from each other. + for (let i = 1; i <= steps; i++) { + const offset = (arg.direction === 'in' ? (deltaX - i * stepDeltaX) : (stepDeltaX * (i + 1))); + const touches = [ + { + identifier: 0, + clientX: centerX - offset, + clientY: centerY, + }, + { + identifier: 0, + clientX: centerX + offset, + clientY: centerY, + }, + ]; + await locator.dispatchEvent('touchmove', + { touches, changedTouches: touches, targetTouches: touches }); + } + + await locator.dispatchEvent('touchend', { touches: [], changedTouches: [], targetTouches: [] }); +} + +test(`pinch in gesture to zoom out the map`, async ({ page }) => { + await page.goto('https://www.google.com/maps/place/@37.4117722,-122.0713234,15z', + { waitUntil: 'commit' }); + await page.getByRole('button', { name: 'Keep using web' }).click(); + await expect(page.getByRole('button', { name: 'Keep using web' })).not.toBeVisible(); + // Get the map element. + const met = page.locator('[data-test-id="met"]'); + for (let i = 0; i < 5; i++) + await pinch(met, { deltaX: 40, direction: 'in' }); + // Ensure the map has been zoomed out. + await expect(met).toHaveScreenshot(); +}); +``` diff --git a/package-lock.json b/package-lock.json index 0c4d97edad..411ef13b62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7964,7 +7964,10 @@ } }, "packages/trace-viewer": { - "version": "0.0.0" + "version": "0.0.0", + "dependencies": { + "yaml": "^2.6.0" + } }, "packages/web": { "version": "0.0.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/src/links.css b/packages/html-reporter/src/links.css index 4abe8a6caa..eb9390844b 100644 --- a/packages/html-reporter/src/links.css +++ b/packages/html-reporter/src/links.css @@ -60,6 +60,11 @@ color: var(--color-scale-orange-6); border: 1px solid var(--color-scale-orange-4); } + .label-color-gray { + background-color: var(--color-scale-gray-0); + color: var(--color-scale-gray-6); + border: 1px solid var(--color-scale-gray-4); + } } @media(prefers-color-scheme: dark) { @@ -93,6 +98,11 @@ color: var(--color-scale-orange-2); border: 1px solid var(--color-scale-orange-4); } + .label-color-gray { + background-color: var(--color-scale-gray-9); + color: var(--color-scale-gray-2); + border: 1px solid var(--color-scale-gray-4); + } } .attachment-body { diff --git a/packages/html-reporter/src/links.tsx b/packages/html-reporter/src/links.tsx index b8db4c0e9e..5f199568b5 100644 --- a/packages/html-reporter/src/links.tsx +++ b/packages/html-reporter/src/links.tsx @@ -68,11 +68,12 @@ export const ProjectLink: React.FunctionComponent<{ export const AttachmentLink: React.FunctionComponent<{ attachment: TestAttachment, + result: TestResult, href?: string, linkName?: string, openInNewTab?: boolean, -}> = ({ attachment, href, linkName, openInNewTab }) => { - const isAnchored = useIsAnchored('attachment-' + attachment.name); +}> = ({ attachment, result, href, linkName, openInNewTab }) => { + const isAnchored = useIsAnchored('attachment-' + result.attachments.indexOf(attachment)); return {attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()} {attachment.path && {linkName || attachment.name}} diff --git a/packages/html-reporter/src/testCaseView.spec.tsx b/packages/html-reporter/src/testCaseView.spec.tsx index b7a9f9405b..7cc9f8991c 100644 --- a/packages/html-reporter/src/testCaseView.spec.tsx +++ b/packages/html-reporter/src/testCaseView.spec.tsx @@ -37,8 +37,10 @@ const result: TestResult = { duration: 10, location: { file: 'test.spec.ts', line: 82, column: 0 }, steps: [], + attachments: [], count: 1, }], + attachments: [], }], attachments: [], status: 'passed', @@ -139,6 +141,7 @@ const resultWithAttachment: TestResult = { location: { file: 'test.spec.ts', line: 62, column: 0 }, count: 1, steps: [], + attachments: [1], }], attachments: [{ name: 'first attachment', diff --git a/packages/html-reporter/src/testFileView.tsx b/packages/html-reporter/src/testFileView.tsx index f8fad1d646..00ea004136 100644 --- a/packages/html-reporter/src/testFileView.tsx +++ b/packages/html-reporter/src/testFileView.tsx @@ -75,7 +75,7 @@ function imageDiffBadge(test: TestCaseSummary): JSX.Element | 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()}; + return {image()}; } } } diff --git a/packages/html-reporter/src/testResultView.tsx b/packages/html-reporter/src/testResultView.tsx index 410677cb02..4c504a0118 100644 --- a/packages/html-reporter/src/testResultView.tsx +++ b/packages/html-reporter/src/testResultView.tsx @@ -32,7 +32,7 @@ interface ImageDiffWithAnchors extends ImageDiff { anchors: string[]; } -function groupImageDiffs(screenshots: Set): ImageDiffWithAnchors[] { +function groupImageDiffs(screenshots: Set, result: TestResult): ImageDiffWithAnchors[] { const snapshotNameToImageDiff = new Map(); for (const attachment of screenshots) { const match = attachment.name.match(/^(.*)-(expected|actual|diff|previous)(\.[^.]+)?$/); @@ -45,7 +45,7 @@ function groupImageDiffs(screenshots: Set): ImageDiffWithAnchors imageDiff = { name: snapshotName, anchors: [`attachment-${name}`] }; snapshotNameToImageDiff.set(snapshotName, imageDiff); } - imageDiff.anchors.push(`attachment-${attachment.name}`); + imageDiff.anchors.push(`attachment-${result.attachments.indexOf(attachment)}`); if (category === 'actual') imageDiff.actual = { attachment }; if (category === 'expected') @@ -72,15 +72,15 @@ export const TestResultView: React.FC<{ result: TestResult, }> = ({ test, result }) => { const { screenshots, videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors } = React.useMemo(() => { - const attachments = result?.attachments || []; + const attachments = result.attachments; const screenshots = new Set(attachments.filter(a => a.contentType.startsWith('image/'))); - const screenshotAnchors = [...screenshots].map(a => `attachment-${a.name}`); + const screenshotAnchors = [...screenshots].map(a => `attachment-${attachments.indexOf(a)}`); const videos = attachments.filter(a => a.contentType.startsWith('video/')); const traces = attachments.filter(a => a.name === 'trace'); const otherAttachments = new Set(attachments); [...screenshots, ...videos, ...traces].forEach(a => otherAttachments.delete(a)); - const otherAttachmentAnchors = [...otherAttachments].map(a => `attachment-${a.name}`); - const diffs = groupImageDiffs(screenshots); + const otherAttachmentAnchors = [...otherAttachments].map(a => `attachment-${attachments.indexOf(a)}`); + const diffs = groupImageDiffs(screenshots, result); const errors = classifyErrors(result.errors, diffs); return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors }; }, [result]); @@ -107,11 +107,11 @@ export const TestResultView: React.FC<{ {!!screenshots.length && {screenshots.map((a, i) => { - return + return - + ; })} } @@ -121,7 +121,7 @@ export const TestResultView: React.FC<{ - {traces.map((a, i) => )} + {traces.map((a, i) => )} } } @@ -130,14 +130,14 @@ export const TestResultView: React.FC<{ - + )} } - {!!otherAttachments.size && + {!!otherAttachments.size && {[...otherAttachments].map((a, i) => - - + + )} } @@ -174,18 +174,29 @@ const StepTreeItem: React.FC<{ step: TestStep; depth: number, }> = ({ 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) => ); - if (step.snippet) - children.unshift(); - return children; + const snippet = step.snippet ? [] : []; + const steps = step.steps.map((s, i) => ); + const attachments = step.attachments.map(attachmentIndex => ( + + + {icons.attachment()}{result.attachments[attachmentIndex].name} + + + )); + return snippet.concat(steps, attachments); } : undefined} depth={depth}/>; }; diff --git a/packages/html-reporter/src/types.d.ts b/packages/html-reporter/src/types.d.ts index 733e88e8b9..7a99184739 100644 --- a/packages/html-reporter/src/types.d.ts +++ b/packages/html-reporter/src/types.d.ts @@ -108,5 +108,6 @@ export type TestStep = { snippet?: string; error?: string; steps: TestStep[]; + attachments: number[]; count: number; }; 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/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 e4b7ce3f53..2e17a3d697 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -3,21 +3,21 @@ "browsers": [ { "name": "chromium", - "revision": "1152", + "revision": "1153", "installByDefault": true, - "browserVersion": "132.0.6834.46" + "browserVersion": "132.0.6834.57" }, { "name": "chromium-tip-of-tree", - "revision": "1287", + "revision": "1293", "installByDefault": false, - "browserVersion": "133.0.6901.0" + "browserVersion": "133.0.6943.0" }, { "name": "firefox", - "revision": "1466", + "revision": "1470", "installByDefault": true, - "browserVersion": "132.0" + "browserVersion": "133.0.3" }, { "name": "firefox-beta", @@ -27,7 +27,7 @@ }, { "name": "webkit", - "revision": "2120", + "revision": "2122", "installByDefault": true, "revisionOverrides": { "debian11-x64": "2105", @@ -52,6 +52,11 @@ "mac12-arm64": "1011" } }, + { + "name": "winldd", + "revision": "1007", + "installByDefault": false + }, { "name": "android", "revision": "1001", diff --git a/packages/playwright-core/src/browserServerImpl.ts b/packages/playwright-core/src/browserServerImpl.ts index dfe960c5ea..d59c95bfb1 100644 --- a/packages/playwright-core/src/browserServerImpl.ts +++ b/packages/playwright-core/src/browserServerImpl.ts @@ -29,9 +29,9 @@ import { rewriteErrorMessage } from './utils/stackTrace'; import { SocksProxy } from './common/socksProxy'; export class BrowserServerLauncherImpl implements BrowserServerLauncher { - private _browserName: 'chromium' | 'firefox' | 'webkit'; + private _browserName: 'chromium' | 'firefox' | 'webkit' | 'bidiFirefox' | 'bidiChromium'; - constructor(browserName: 'chromium' | 'firefox' | 'webkit') { + constructor(browserName: 'chromium' | 'firefox' | 'webkit' | 'bidiFirefox' | 'bidiChromium') { this._browserName = browserName; } diff --git a/packages/playwright-core/src/cli/program.ts b/packages/playwright-core/src/cli/program.ts index 7ce1c4f928..5cd941d5d4 100644 --- a/packages/playwright-core/src/cli/program.ts +++ b/packages/playwright-core/src/cli/program.ts @@ -116,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; diff --git a/packages/playwright-core/src/client/channelOwner.ts b/packages/playwright-core/src/client/channelOwner.ts index a5d753507b..d48ba439f0 100644 --- a/packages/playwright-core/src/client/channelOwner.ts +++ b/packages/playwright-core/src/client/channelOwner.ts @@ -18,7 +18,6 @@ import { EventEmitter } from './eventEmitter'; import type * as channels from '@protocol/channels'; import { maybeFindValidator, ValidationError, type ValidatorContext } from '../protocol/validator'; import { debugLogger } from '../utils/debugLogger'; -import type { ExpectZone } from '../utils/stackTrace'; import { captureLibraryStackTrace, stringifyStackFrames } from '../utils/stackTrace'; import { isUnderTest } from '../utils'; import { zones } from '../utils/zones'; @@ -148,15 +147,18 @@ export abstract class ChannelOwner { return await this._wrapApiCall(async apiZone => { - const { apiName, frames, csi, callCookie, stepId } = apiZone.reported ? { apiName: undefined, csi: undefined, callCookie: undefined, frames: [], stepId: undefined } : apiZone; - apiZone.reported = true; - let currentStepId = stepId; - if (csi && apiName) { - const out: { stepId?: string } = {}; - csi.onApiCallBegin(apiName, params, frames, callCookie, out); - currentStepId = out.stepId; + const validatedParams = validator(params, '', { tChannelImpl: tChannelImplToWire, binary: this._connection.rawBuffers() ? 'buffer' : 'toBase64' }); + if (!apiZone.isInternal && !apiZone.reported) { + // Reporting/tracing/logging this api call for the first time. + apiZone.params = params; + apiZone.reported = true; + this._instrumentation.onApiCallBegin(apiZone); + logApiCall(this._logger, `=> ${apiZone.apiName} started`); + return await this._connection.sendMessageToServer(this, prop, validatedParams, apiZone.apiName, apiZone.frames, apiZone.stepId); } - return await this._connection.sendMessageToServer(this, prop, validator(params, '', { tChannelImpl: tChannelImplToWire, binary: this._connection.rawBuffers() ? 'buffer' : 'toBase64' }), apiName, frames, currentStepId); + // Since this api call is either internal, or has already been reported/traced once, + // passing undefined apiName will avoid an extra unneeded tracing entry. + return await this._connection.sendMessageToServer(this, prop, validatedParams, undefined, [], undefined); }); }; } @@ -170,48 +172,36 @@ export abstract class ChannelOwner(func: (apiZone: ApiZone) => Promise, isInternal?: boolean): Promise { const logger = this._logger; - const apiZone = zones.zoneData('apiZone'); - if (apiZone) - return await func(apiZone); - - const stackTrace = captureLibraryStackTrace(); - let apiName: string | undefined = stackTrace.apiName; - const frames: channels.StackFrame[] = stackTrace.frames; + const existingApiZone = zones.zoneData('apiZone'); + if (existingApiZone) + return await func(existingApiZone); if (isInternal === undefined) isInternal = this._isInternalType; - if (isInternal) - apiName = undefined; - - // Enclosing zone could have provided the apiName and wallTime. - const expectZone = zones.zoneData('expectZone'); - const stepId = expectZone?.stepId; - if (!isInternal && expectZone) - apiName = expectZone.title; - - // If we are coming from the expectZone, there is no need to generate a new - // step for the API call, since it will be generated by the expect itself. - const csi = isInternal || expectZone ? undefined : this._instrumentation; - const callCookie: any = {}; + const stackTrace = captureLibraryStackTrace(); + const apiZone: ApiZone = { apiName: stackTrace.apiName, frames: stackTrace.frames, isInternal, reported: false, userData: undefined, stepId: undefined }; try { - logApiCall(logger, `=> ${apiName} started`, isInternal); - const apiZone: ApiZone = { apiName, frames, isInternal, reported: false, csi, callCookie, stepId }; const result = await zones.run('apiZone', apiZone, async () => await func(apiZone)); - csi?.onApiCallEnd(callCookie); - logApiCall(logger, `<= ${apiName} succeeded`, isInternal); + if (!isInternal) { + logApiCall(logger, `<= ${apiZone.apiName} succeeded`); + this._instrumentation.onApiCallEnd(apiZone); + } return result; } catch (e) { const innerError = ((process.env.PWDEBUGIMPL || isUnderTest()) && e.stack) ? '\n\n' + e.stack : ''; - if (apiName && !apiName.includes('')) - e.message = apiName + ': ' + e.message; + if (apiZone.apiName && !apiZone.apiName.includes('')) + e.message = apiZone.apiName + ': ' + e.message; const stackFrames = '\n' + stringifyStackFrames(stackTrace.frames).join('\n') + innerError; if (stackFrames.trim()) e.stack = e.message + stackFrames; else e.stack = ''; - csi?.onApiCallEnd(callCookie, e); - logApiCall(logger, `<= ${apiName} failed`, isInternal); + if (!isInternal) { + apiZone.error = e; + logApiCall(logger, `<= ${apiZone.apiName} failed`); + this._instrumentation.onApiCallEnd(apiZone); + } throw e; } } @@ -232,9 +222,7 @@ export abstract class ChannelOwner; frames: channels.StackFrame[]; isInternal: boolean; reported: boolean; - csi: ClientInstrumentation | undefined; - callCookie: any; + userData: any; stepId?: string; + error?: Error; }; diff --git a/packages/playwright-core/src/client/clientInstrumentation.ts b/packages/playwright-core/src/client/clientInstrumentation.ts index 55c787df05..e2e8c6678e 100644 --- a/packages/playwright-core/src/client/clientInstrumentation.ts +++ b/packages/playwright-core/src/client/clientInstrumentation.ts @@ -18,12 +18,22 @@ import type { StackFrame } from '@protocol/channels'; import type { BrowserContext } from './browserContext'; import type { APIRequestContext } from './fetch'; +// Instrumentation can mutate the data, for example change apiName or stepId. +export interface ApiCallData { + apiName: string; + params?: Record; + frames: StackFrame[]; + userData: any; + stepId?: string; + error?: Error; +} + export interface ClientInstrumentation { addListener(listener: ClientInstrumentationListener): void; removeListener(listener: ClientInstrumentationListener): void; removeAllListeners(): void; - onApiCallBegin(apiCall: string, params: Record, frames: StackFrame[], userData: any, out: { stepId?: string }): void; - onApiCallEnd(userData: any, error?: Error): void; + onApiCallBegin(apiCall: ApiCallData): void; + onApiCallEnd(apiCal: ApiCallData): void; onWillPause(options: { keepTestTimeout: boolean }): void; runAfterCreateBrowserContext(context: BrowserContext): Promise; @@ -33,8 +43,8 @@ export interface ClientInstrumentation { } export interface ClientInstrumentationListener { - onApiCallBegin?(apiName: string, params: Record, frames: StackFrame[], userData: any, out: { stepId?: string }): void; - onApiCallEnd?(userData: any, error?: Error): void; + onApiCallBegin?(apiCall: ApiCallData): void; + onApiCallEnd?(apiCall: ApiCallData): void; onWillPause?(options: { keepTestTimeout: boolean }): void; runAfterCreateBrowserContext?(context: BrowserContext): Promise; diff --git a/packages/playwright-core/src/client/connection.ts b/packages/playwright-core/src/client/connection.ts index 75d1878da3..375a84265f 100644 --- a/packages/playwright-core/src/client/connection.ts +++ b/packages/playwright-core/src/client/connection.ts @@ -78,9 +78,9 @@ export class Connection extends EventEmitter { constructor(localUtils: LocalUtils | undefined, instrumentation: ClientInstrumentation | undefined) { super(); - this._rootObject = new Root(this); - this._localUtils = localUtils; this._instrumentation = instrumentation || createInstrumentation(); + this._localUtils = localUtils; + this._rootObject = new Root(this); } markAsRemote() { @@ -138,7 +138,7 @@ export class Connection extends EventEmitter { this._localUtils?._channel.addStackToTracingNoReply({ callData: { stack: frames, id } }).catch(() => {}); // We need to exit zones before calling into the server, otherwise // when we receive events from the server, we would be in an API zone. - zones.exitZones(() => this.onmessage({ ...message, metadata })); + zones.empty().run(() => this.onmessage({ ...message, metadata })); return await new Promise((resolve, reject) => this._callbacks.set(id, { resolve, reject, apiName, type, method })); } diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index cb18681ccf..a6b40307b3 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -820,7 +820,7 @@ export class RouteHandler { this._times = times; this.url = url; this.handler = handler; - this._svedZone = zones.currentZone(); + this._svedZone = zones.current().without('apiZone'); } static prepareInterceptionPatterns(handlers: RouteHandler[]) { diff --git a/packages/playwright-core/src/client/waiter.ts b/packages/playwright-core/src/client/waiter.ts index 7b57fe8960..408a01f3d0 100644 --- a/packages/playwright-core/src/client/waiter.ts +++ b/packages/playwright-core/src/client/waiter.ts @@ -35,7 +35,7 @@ export class Waiter { constructor(channelOwner: ChannelOwner, event: string) { this._waitId = createGuid(); this._channelOwner = channelOwner; - this._savedZone = zones.currentZone(); + this._savedZone = zones.current().without('apiZone'); this._channelOwner._channel.waitForEventInfo({ info: { waitId: this._waitId, phase: 'before', event } }).catch(() => {}); this._dispose = [ 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/inProcessFactory.ts b/packages/playwright-core/src/inProcessFactory.ts index 1397c81958..a757294da8 100644 --- a/packages/playwright-core/src/inProcessFactory.ts +++ b/packages/playwright-core/src/inProcessFactory.ts @@ -41,6 +41,8 @@ export function createInProcessPlaywright(): PlaywrightAPI { playwrightAPI.firefox._serverLauncher = new BrowserServerLauncherImpl('firefox'); playwrightAPI.webkit._serverLauncher = new BrowserServerLauncherImpl('webkit'); playwrightAPI._android._serverLauncher = new AndroidServerLauncherImpl(); + playwrightAPI._bidiChromium._serverLauncher = new BrowserServerLauncherImpl('bidiChromium'); + playwrightAPI._bidiFirefox._serverLauncher = new BrowserServerLauncherImpl('bidiFirefox'); // Switch to async dispatch after we got Playwright object. dispatcherConnection.onmessage = message => setImmediate(() => clientConnection.dispatch(message)); diff --git a/packages/playwright-core/src/protocol/debug.ts b/packages/playwright-core/src/protocol/debug.ts index 4f44f5941e..c58c3e4aaf 100644 --- a/packages/playwright-core/src/protocol/debug.ts +++ b/packages/playwright-core/src/protocol/debug.ts @@ -89,13 +89,16 @@ export const commandsWithTracingSnapshots = new Set([ 'Page.mouseClick', 'Page.mouseWheel', 'Page.touchscreenTap', + 'Page.accessibilitySnapshot', 'Frame.evalOnSelector', 'Frame.evalOnSelectorAll', 'Frame.addScriptTag', 'Frame.addStyleTag', + 'Frame.ariaSnapshot', 'Frame.blur', 'Frame.check', 'Frame.click', + 'Frame.content', 'Frame.dragAndDrop', 'Frame.dblclick', 'Frame.dispatchEvent', @@ -116,6 +119,9 @@ export const commandsWithTracingSnapshots = new Set([ 'Frame.isVisible', 'Frame.isEditable', 'Frame.press', + 'Frame.querySelector', + 'Frame.querySelectorAll', + 'Frame.queryCount', 'Frame.selectOption', 'Frame.setContent', 'Frame.setInputFiles', @@ -133,8 +139,10 @@ export const commandsWithTracingSnapshots = new Set([ 'ElementHandle.evaluateExpressionHandle', 'ElementHandle.evalOnSelector', 'ElementHandle.evalOnSelectorAll', + 'ElementHandle.boundingBox', 'ElementHandle.check', 'ElementHandle.click', + 'ElementHandle.contentFrame', 'ElementHandle.dblclick', 'ElementHandle.dispatchEvent', 'ElementHandle.fill', @@ -150,6 +158,8 @@ export const commandsWithTracingSnapshots = new Set([ 'ElementHandle.isHidden', 'ElementHandle.isVisible', 'ElementHandle.press', + 'ElementHandle.querySelector', + 'ElementHandle.querySelectorAll', 'ElementHandle.screenshot', 'ElementHandle.scrollIntoViewIfNeeded', 'ElementHandle.selectOption', @@ -187,4 +197,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 f4db833e02..9b14551fb8 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -2752,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/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/ariaSnapshot.ts b/packages/playwright-core/src/server/ariaSnapshot.ts deleted file mode 100644 index 516688fef3..0000000000 --- a/packages/playwright-core/src/server/ariaSnapshot.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * 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 { parseYamlTemplate } from '../utils/isomorphic/ariaSnapshot'; -import type { AriaTemplateNode, ParsedYaml } from '@isomorphic/ariaSnapshot'; -import { yaml } from '../utilsBundle'; - -export function parseAriaSnapshot(text: string): AriaTemplateNode { - return parseYamlTemplate(parseYamlForAriaSnapshot(text)); -} - -export function parseYamlForAriaSnapshot(text: string): ParsedYaml { - const parsed = yaml.parse(text); - if (!Array.isArray(parsed)) - throw new Error('Expected object key starting with "- ":\n\n' + text + '\n'); - return parsed; -} diff --git a/packages/playwright-core/src/server/bidi/bidiBrowser.ts b/packages/playwright-core/src/server/bidi/bidiBrowser.ts index 955f6274a3..96c48ea2a8 100644 --- a/packages/playwright-core/src/server/bidi/bidiBrowser.ts +++ b/packages/playwright-core/src/server/bidi/bidiBrowser.ts @@ -152,6 +152,9 @@ export class BidiBrowser extends Browser { continue; page._session.addFrameBrowsingContext(event.context); page._page._frameManager.frameAttached(event.context, parentFrameId); + const frame = page._page._frameManager.frame(event.context); + if (frame) + frame._url = event.url; return; } return; @@ -164,6 +167,7 @@ export class BidiBrowser extends Browser { const session = this._connection.createMainFrameBrowsingContextSession(event.context); const opener = event.originalOpener && this._bidiPages.get(event.originalOpener); const page = new BidiPage(context, session, opener || null); + page._page.mainFrame()._url = event.url; this._bidiPages.set(event.context, page); } diff --git a/packages/playwright-core/src/server/bidi/bidiExecutionContext.ts b/packages/playwright-core/src/server/bidi/bidiExecutionContext.ts index f53b160ccf..de1b50bd1a 100644 --- a/packages/playwright-core/src/server/bidi/bidiExecutionContext.ts +++ b/packages/playwright-core/src/server/bidi/bidiExecutionContext.ts @@ -103,7 +103,25 @@ export class BidiExecutionContext implements js.ExecutionContextDelegate { } async getProperties(context: js.ExecutionContext, objectId: js.ObjectId): Promise> { - throw new Error('Method not implemented.'); + const handle = this.createHandle(context, { objectId }); + try { + const names = await handle.evaluate(object => { + const names = []; + const descriptors = Object.getOwnPropertyDescriptors(object); + for (const name in descriptors) { + if (descriptors[name]?.enumerable) + names.push(name); + } + return names; + }); + const values = await Promise.all(names.map(name => handle.evaluateHandle((object, name) => object[name], name))); + const map = new Map(); + for (let i = 0; i < names.length; i++) + map.set(names[i], values[i]); + return map; + } finally { + handle.dispose(); + } } createHandle(context: js.ExecutionContext, jsRemoteObject: js.RemoteObject): js.JSHandle { diff --git a/packages/playwright-core/src/server/bidi/bidiInput.ts b/packages/playwright-core/src/server/bidi/bidiInput.ts index 3550051a6a..01b773178d 100644 --- a/packages/playwright-core/src/server/bidi/bidiInput.ts +++ b/packages/playwright-core/src/server/bidi/bidiInput.ts @@ -33,14 +33,14 @@ export class RawKeyboardImpl implements input.RawKeyboard { async keydown(modifiers: Set, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number, autoRepeat: boolean, text: string | undefined): Promise { const actions: bidi.Input.KeySourceAction[] = []; - actions.push({ type: 'keyDown', value: getBidiKeyValue(key) }); + actions.push({ type: 'keyDown', value: getBidiKeyValue(code) }); // TODO: add modifiers? await this._performActions(actions); } async keyup(modifiers: Set, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number): Promise { const actions: bidi.Input.KeySourceAction[] = []; - actions.push({ type: 'keyUp', value: getBidiKeyValue(key) }); + actions.push({ type: 'keyUp', value: getBidiKeyValue(code) }); await this._performActions(actions); } diff --git a/packages/playwright-core/src/server/bidi/bidiNetworkManager.ts b/packages/playwright-core/src/server/bidi/bidiNetworkManager.ts index b7c314bd10..88eaf54fbe 100644 --- a/packages/playwright-core/src/server/bidi/bidiNetworkManager.ts +++ b/packages/playwright-core/src/server/bidi/bidiNetworkManager.ts @@ -67,9 +67,13 @@ export class BidiNetworkManager { if (param.intercepts) { // We do not support intercepting redirects. if (redirectedFrom) { + let params = {}; + if (redirectedFrom._originalRequestRoute?._alreadyContinuedHeaders) + params = toBidiRequestHeaders(redirectedFrom._originalRequestRoute._alreadyContinuedHeaders ?? []); + this._session.sendMayFail('network.continueRequest', { request: param.request.request, - ...(redirectedFrom._originalRequestRoute?._alreadyContinuedHeaders || {}), + ...params, }); } else { route = new BidiRouteImpl(this._session, param.request.request); @@ -302,11 +306,9 @@ function fromBidiHeaders(bidiHeaders: bidi.Network.Header[]): types.HeadersArray return result; } -function toBidiRequestHeaders(allHeaders: types.HeadersArray): { cookies: bidi.Network.CookieHeader[], headers: bidi.Network.Header[] } { +function toBidiRequestHeaders(allHeaders: types.HeadersArray): { headers: bidi.Network.Header[] } { const bidiHeaders = toBidiHeaders(allHeaders); - const cookies = bidiHeaders.filter(h => h.name.toLowerCase() === 'cookie'); - const headers = bidiHeaders.filter(h => h.name.toLowerCase() !== 'cookie'); - return { cookies, headers }; + return { headers: bidiHeaders }; } function toBidiResponseHeaders(headers: types.HeadersArray): { cookies: bidi.Network.SetCookieHeader[], headers: bidi.Network.Header[] } { diff --git a/packages/playwright-core/src/server/bidi/bidiPage.ts b/packages/playwright-core/src/server/bidi/bidiPage.ts index 9b501c5484..cf0662738b 100644 --- a/packages/playwright-core/src/server/bidi/bidiPage.ts +++ b/packages/playwright-core/src/server/bidi/bidiPage.ts @@ -399,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/bidi/third_party/bidiKeyboard.ts b/packages/playwright-core/src/server/bidi/third_party/bidiKeyboard.ts index 307d83fb87..c2d60ff66b 100644 --- a/packages/playwright-core/src/server/bidi/third_party/bidiKeyboard.ts +++ b/packages/playwright-core/src/server/bidi/third_party/bidiKeyboard.ts @@ -7,18 +7,18 @@ /* eslint-disable curly */ -export const getBidiKeyValue = (key: string) => { - switch (key) { +export const getBidiKeyValue = (code: string) => { + switch (code) { case '\r': case '\n': - key = 'Enter'; + code = 'Enter'; break; } // Measures the number of code points rather than UTF-16 code units. - if ([...key].length === 1) { - return key; + if ([...code].length === 1) { + return code; } - switch (key) { + switch (code) { case 'Cancel': return '\uE001'; case 'Help': @@ -131,6 +131,8 @@ export const getBidiKeyValue = (key: string) => { return '\uE052'; case 'MetaRight': return '\uE053'; + case 'Space': + return ' '; case 'Digit0': return '0'; case 'Digit1': @@ -226,6 +228,6 @@ export const getBidiKeyValue = (key: string) => { case 'Quote': return '"'; default: - throw new Error(`Unknown key: "${key}"`); + throw new Error(`Unknown key: "${code}"`); } }; diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index fc20c52bb5..8a835d3726 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -314,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`); @@ -414,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); } diff --git a/packages/playwright-core/src/server/debugController.ts b/packages/playwright-core/src/server/debugController.ts index b810e2fa65..468b550f48 100644 --- a/packages/playwright-core/src/server/debugController.ts +++ b/packages/playwright-core/src/server/debugController.ts @@ -24,10 +24,9 @@ import type { Playwright } from './playwright'; 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 { yaml } from '../utilsBundle'; import { unsafeLocatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser'; +import { parseAriaSnapshotUnsafe } from '../utils/isomorphic/ariaSnapshot'; const internalMetadata = serverSideCallMetadata(); @@ -40,9 +39,6 @@ export class DebugController extends SdkObject { SetModeRequested: 'setModeRequested', }; - private _autoCloseTimer: NodeJS.Timeout | undefined; - // TODO: remove in 1.27 - private _autoCloseAllowed = false; private _trackHierarchyListener: InstrumentationListener | undefined; private _playwright: Playwright; _sdkLanguage: Language = 'javascript'; @@ -58,22 +54,18 @@ export class DebugController extends SdkObject { this._sdkLanguage = sdkLanguage; } - setAutoCloseAllowed(allowed: boolean) { - this._autoCloseAllowed = allowed; - } - dispose() { this.setReportStateChanged(false); - this.setAutoCloseAllowed(false); } setReportStateChanged(enabled: boolean) { if (enabled && !this._trackHierarchyListener) { this._trackHierarchyListener = { - onPageOpen: () => this._emitSnapshot(), - onPageClose: () => this._emitSnapshot(), + onPageOpen: () => this._emitSnapshot(false), + onPageClose: () => this._emitSnapshot(false), }; this._playwright.instrumentation.addListener(this._trackHierarchyListener, null); + this._emitSnapshot(true); } else if (!enabled && this._trackHierarchyListener) { this._playwright.instrumentation.removeListener(this._trackHierarchyListener); this._trackHierarchyListener = undefined; @@ -102,7 +94,6 @@ export class DebugController extends SdkObject { recorder.hideHighlightedSelector(); recorder.setMode('none'); } - this.setAutoCloseEnabled(true); return; } @@ -127,37 +118,16 @@ export class DebugController extends SdkObject { recorder.setOutput(this._codegenId, params.file); recorder.setMode(params.mode); } - this.setAutoCloseEnabled(true); - } - - async setAutoCloseEnabled(enabled: boolean) { - if (!this._autoCloseAllowed) - return; - if (this._autoCloseTimer) - clearTimeout(this._autoCloseTimer); - if (!enabled) - return; - const heartBeat = () => { - if (!this._playwright.allPages().length) - gracefullyProcessExitDoNotHang(0); - else - this._autoCloseTimer = setTimeout(heartBeat, 5000); - }; - this._autoCloseTimer = setTimeout(heartBeat, 30000); } 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); - } + const ariaTemplate = params.ariaTemplate ? parseAriaSnapshotUnsafe(yaml, params.ariaTemplate) : undefined; for (const recorder of await this._allRecorders()) { - if (parsedYaml) - recorder.setHighlightedAriaTemplate(parsedYaml); + if (ariaTemplate) + recorder.setHighlightedAriaTemplate(ariaTemplate); else if (params.selector) recorder.setHighlightedSelector(this._sdkLanguage, params.selector); } @@ -188,24 +158,10 @@ export class DebugController extends SdkObject { await Promise.all(this.allBrowsers().map(browser => browser.close({ reason: 'Close all browsers requested' }))); } - private _emitSnapshot() { - const browsers = []; - let pageCount = 0; - for (const browser of this._playwright.allBrowsers()) { - const b = { - contexts: [] as any[] - }; - browsers.push(b); - for (const context of browser.contexts()) { - const c = { - pages: [] as any[] - }; - b.contexts.push(c); - for (const page of context.pages()) - c.pages.push(page.mainFrame().url()); - pageCount += context.pages().length; - } - } + private _emitSnapshot(initial: boolean) { + const pageCount = this._playwright.allPages().length; + if (initial && !pageCount) + return; this.emit(DebugController.Events.StateChanged, { pageCount }); } diff --git a/packages/playwright-core/src/server/debugger.ts b/packages/playwright-core/src/server/debugger.ts index 420b438ccb..53a96ba8f2 100644 --- a/packages/playwright-core/src/server/debugger.ts +++ b/packages/playwright-core/src/server/debugger.ts @@ -134,7 +134,7 @@ function shouldPauseBeforeStep(metadata: CallMetadata): boolean { // Always stop on 'close' if (metadata.method === 'close') return true; - if (metadata.method === 'waitForSelector' || metadata.method === 'waitForEventInfo') + if (metadata.method === 'waitForSelector' || metadata.method === 'waitForEventInfo' || metadata.method === 'querySelector' || metadata.method === 'querySelectorAll') return false; // Never stop on those, primarily for the test harness. const step = metadata.type + '.' + metadata.method; // Stop before everything that generates snapshot. But don't stop before those marked as pausesBeforeInputActions diff --git a/packages/playwright-core/src/server/deviceDescriptorsSource.json b/packages/playwright-core/src/server/deviceDescriptorsSource.json index 49d9edde41..5b455bd485 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 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.46 Safari/537.36 Edg/132.0.6834.46", + "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 @@ -1592,7 +1592,7 @@ "defaultBrowserType": "chromium" }, "Desktop Firefox HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0.3) Gecko/20100101 Firefox/133.0.3", "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.46 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.46 Safari/537.36 Edg/132.0.6834.46", + "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 @@ -1652,7 +1652,7 @@ "defaultBrowserType": "chromium" }, "Desktop Firefox": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0.3) Gecko/20100101 Firefox/133.0.3", "screen": { "width": 1920, "height": 1080 diff --git a/packages/playwright-core/src/server/dispatchers/DEPS.list b/packages/playwright-core/src/server/dispatchers/DEPS.list index de1039af05..cefc3fa04c 100644 --- a/packages/playwright-core/src/server/dispatchers/DEPS.list +++ b/packages/playwright-core/src/server/dispatchers/DEPS.list @@ -3,5 +3,7 @@ ../../generated/ ../../protocol/ ../../utils/ +../../utils/isomorphic +../../utilsBundle.ts ../../zipBundle.ts ../** 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/frameDispatcher.ts b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts index 2f172df694..3426389a9c 100644 --- a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts @@ -26,7 +26,8 @@ import type { CallMetadata } from '../instrumentation'; import type { BrowserContextDispatcher } from './browserContextDispatcher'; import type { PageDispatcher } from './pageDispatcher'; import { debugAssert } from '../../utils'; -import { parseAriaSnapshot } from '../ariaSnapshot'; +import { parseAriaSnapshotUnsafe } from '../../utils/isomorphic/ariaSnapshot'; +import { yaml } from '../../utilsBundle'; export class FrameDispatcher extends Dispatcher implements channels.FrameChannel { _type_Frame = true; @@ -261,7 +262,7 @@ export class FrameDispatcher 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/dom.ts b/packages/playwright-core/src/server/dom.ts index 8e65c7c67f..962f385c90 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -778,7 +778,9 @@ export class ElementHandle extends js.JSHandle { async _setChecked(progress: Progress, state: boolean, options: { position?: types.Point } & types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> { const isChecked = async () => { const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'checked'), {}); - return throwRetargetableDOMError(result); + if (result === 'error:notconnected' || result.received === 'error:notconnected') + throwElementIsNotAttached(); + return result.matches; }; await this._markAsTargetElement(progress.metadata); if (await isChecked() === state) @@ -913,10 +915,14 @@ export class ElementHandle extends js.JSHandle { export function throwRetargetableDOMError(result: T | 'error:notconnected'): T { if (result === 'error:notconnected') - throw new Error('Element is not attached to the DOM'); + throwElementIsNotAttached(); return result; } +export function throwElementIsNotAttached(): never { + throw new Error('Element is not attached to the DOM'); +} + export function assertDone(result: 'done'): void { // This function converts 'done' to void and ensures typescript catches unhandled errors. } 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 92998a7946..1d1c60e6ce 100644 --- a/packages/playwright-core/src/server/firefox/ffBrowser.ts +++ b/packages/playwright-core/src/server/firefox/ffBrowser.ts @@ -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/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/firefox/protocol.d.ts b/packages/playwright-core/src/server/firefox/protocol.d.ts index f4a44d9d4d..3b661cb6cb 100644 --- a/packages/playwright-core/src/server/firefox/protocol.d.ts +++ b/packages/playwright-core/src/server/firefox/protocol.d.ts @@ -518,6 +518,10 @@ export module Protocol { }|null; }; export type setViewportSizeReturnValue = void; + export type setZoomParameters = { + zoom: number; + }; + export type setZoomReturnValue = void; export type bringToFrontParameters = { }; export type bringToFrontReturnValue = void; @@ -1134,6 +1138,7 @@ export module Protocol { "Page.setFileInputFiles": Page.setFileInputFilesParameters; "Page.addBinding": Page.addBindingParameters; "Page.setViewportSize": Page.setViewportSizeParameters; + "Page.setZoom": Page.setZoomParameters; "Page.bringToFront": Page.bringToFrontParameters; "Page.setEmulatedMedia": Page.setEmulatedMediaParameters; "Page.setCacheDisabled": Page.setCacheDisabledParameters; @@ -1215,6 +1220,7 @@ export module Protocol { "Page.setFileInputFiles": Page.setFileInputFilesReturnValue; "Page.addBinding": Page.addBindingReturnValue; "Page.setViewportSize": Page.setViewportSizeReturnValue; + "Page.setZoom": Page.setZoomReturnValue; "Page.bringToFront": Page.bringToFrontReturnValue; "Page.setEmulatedMedia": Page.setEmulatedMediaReturnValue; "Page.setCacheDisabled": Page.setCacheDisabledReturnValue; diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index ad793aded8..1d2098b92c 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1301,7 +1301,9 @@ export class Frame extends SdkObject { const result = await this._callOnElementOnceMatches(metadata, selector, (injected, element, data) => { return injected.elementState(element, data.state); }, { state }, options, scope); - return dom.throwRetargetableDOMError(result); + if (result.received === 'error:notconnected') + dom.throwElementIsNotAttached(); + return result.matches; } async isVisible(metadata: CallMetadata, selector: string, options: types.StrictOptions = {}, scope?: dom.ElementHandle): Promise { @@ -1319,8 +1321,8 @@ export class Frame extends SdkObject { return false; return await resolved.injected.evaluate((injected, { info, root }) => { const element = injected.querySelector(info.parsed, root || document, info.strict); - const state = element ? injected.elementState(element, 'visible') : false; - return state === 'error:notconnected' ? false : state; + const state = element ? injected.elementState(element, 'visible') : { matches: false, received: 'error:notconnected' }; + return state.matches; }, { info: resolved.info, root: resolved.frame === this ? scope : undefined }); } catch (e) { if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e) || isSessionClosedError(e)) @@ -1809,26 +1811,6 @@ function verifyLifecycle(name: string, waitUntil: types.LifecycleEvent): types.L } function renderUnexpectedValue(expression: string, received: any): string { - if (expression === 'to.be.checked') - return received ? 'checked' : 'unchecked'; - if (expression === 'to.be.unchecked') - return received ? 'unchecked' : 'checked'; - if (expression === 'to.be.visible') - return received ? 'visible' : 'hidden'; - if (expression === 'to.be.hidden') - return received ? 'hidden' : 'visible'; - if (expression === 'to.be.enabled') - return received ? 'enabled' : 'disabled'; - if (expression === 'to.be.disabled') - return received ? 'disabled' : 'enabled'; - if (expression === 'to.be.editable') - return received ? 'editable' : 'readonly'; - if (expression === 'to.be.readonly') - return received ? 'readonly' : 'editable'; - if (expression === 'to.be.empty') - return received ? 'empty' : 'not empty'; - if (expression === 'to.be.focused') - return received ? 'focused' : 'not focused'; if (expression === 'to.match.aria') return received ? received.raw : received; return received; diff --git a/packages/playwright-core/src/server/injected/ariaSnapshot.ts b/packages/playwright-core/src/server/injected/ariaSnapshot.ts index 84537cddd7..5f6782fe7f 100644 --- a/packages/playwright-core/src/server/injected/ariaSnapshot.ts +++ b/packages/playwright-core/src/server/injected/ariaSnapshot.ts @@ -16,9 +16,9 @@ import * as roleUtils from './roleUtils'; import { getElementComputedStyle } from './domUtils'; -import { escapeRegExp, longestCommonSubstring } from '@isomorphic/stringUtils'; +import { escapeRegExp, longestCommonSubstring, normalizeWhiteSpace } from '@isomorphic/stringUtils'; import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from './yaml'; -import type { AriaProps, AriaRole, AriaTemplateNode, AriaTemplateRoleNode, AriaTemplateTextNode } from '@isomorphic/ariaSnapshot'; +import type { AriaProps, AriaRegex, AriaRole, AriaTemplateNode, AriaTemplateRoleNode, AriaTemplateTextNode } from '@isomorphic/ariaSnapshot'; export type AriaNode = AriaProps & { role: AriaRole | 'fragment'; @@ -137,7 +137,7 @@ function toAriaNode(element: Element): AriaNode | null { if (!role || role === 'presentation' || role === 'none') return null; - const name = roleUtils.getElementAccessibleName(element, false) || ''; + const name = normalizeWhiteSpace(roleUtils.getElementAccessibleName(element, false) || ''); const result: AriaNode = { role, name, children: [], element }; if (roleUtils.kAriaCheckedRoles.includes(role)) @@ -170,7 +170,7 @@ function normalizeStringChildren(rootA11yNode: AriaNode) { const flushChildren = (buffer: string[], normalizedChildren: (AriaNode | string)[]) => { if (!buffer.length) return; - const text = normalizeWhitespaceWithin(buffer.join('')).trim(); + const text = normalizeWhiteSpace(buffer.join('')); if (text) normalizedChildren.push(text); buffer.length = 0; @@ -196,16 +196,14 @@ function normalizeStringChildren(rootA11yNode: AriaNode) { visit(rootA11yNode); } -const normalizeWhitespaceWithin = (text: string) => text.replace(/[\u200b\s\t\r\n]+/g, ' '); - -function matchesText(text: string, template: RegExp | string | undefined): boolean { +function matchesText(text: string, template: AriaRegex | string | undefined): boolean { if (!template) return true; if (!text) return false; if (typeof template === 'string') return text === template; - return !!text.match(template); + return !!text.match(new RegExp(template.pattern)); } function matchesTextNode(text: string, template: AriaTemplateTextNode) { @@ -243,7 +241,7 @@ function matchesNode(node: AriaNode | string, template: AriaTemplateNode, depth: if (typeof node === 'string' && template.kind === 'text') return matchesTextNode(node, template); - if (typeof node === 'object' && template.kind === 'role') { + if (node !== null && typeof node === 'object' && template.kind === 'role') { if (template.role !== 'fragment' && template.role !== node.role) return false; if (template.checked !== undefined && template.checked !== node.checked) @@ -287,20 +285,22 @@ function containsList(children: (AriaNode | string)[], template: AriaTemplateNod function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll: boolean): AriaNode[] { const results: AriaNode[] = []; - const visit = (node: AriaNode | string): boolean => { + const visit = (node: AriaNode | string, parent: AriaNode | null): boolean => { if (matchesNode(node, template, 0)) { - results.push(node as AriaNode); + const result = typeof node === 'string' ? parent : node; + if (result) + results.push(result); return !collectAll; } if (typeof node === 'string') return false; for (const child of node.children || []) { - if (visit(child)) + if (visit(child, node)) return true; } return false; }; - visit(root); + visit(root, null); return results; } diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index a1ffdf893c..4ffb8ba2d8 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 { getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription, getReadonly, getElementAccessibleErrorMessage, getCheckedAllowMixed, getCheckedWithoutMixed } from './roleUtils'; import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils'; import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import type { Language } from '../../utils/isomorphic/locatorGenerators'; @@ -37,12 +37,13 @@ import { cacheNormalizedWhitespaces, normalizeWhiteSpace, trimStringWithEllipsis import { matchesAriaTree, getAllByAria, generateAriaTree, renderAriaTree } from './ariaSnapshot'; import type { AriaNode, AriaSnapshot } from './ariaSnapshot'; import type { AriaTemplateNode } from '@isomorphic/ariaSnapshot'; -import { parseYamlTemplate } from '@isomorphic/ariaSnapshot'; +import { parseAriaSnapshot } from '@isomorphic/ariaSnapshot'; export type FrameExpectParams = Omit & { expectedValue?: any }; -export type ElementStateWithoutStable = 'visible' | 'hidden' | 'enabled' | 'disabled' | 'editable' | 'checked' | 'unchecked'; -export type ElementState = ElementStateWithoutStable | 'stable'; +export type ElementState = 'visible' | 'hidden' | 'enabled' | 'disabled' | 'editable' | 'checked' | 'unchecked' | 'indeterminate' | 'stable'; +export type ElementStateWithoutStable = Exclude; +export type ElementStateQueryResult = { matches: boolean, received?: string | 'error:notconnected' }; export type HitTargetInterceptionResult = { stop: () => 'done' | { hitTargetDescription: string }; @@ -85,7 +86,7 @@ export class InjectedScript { isElementVisible, isInsideScope, normalizeWhiteSpace, - parseYamlTemplate, + parseAriaSnapshot, }; // eslint-disable-next-line no-restricted-globals @@ -531,10 +532,10 @@ export class InjectedScript { if (!element.matches('a, input, textarea, button, select, [role=link], [role=button], [role=checkbox], [role=radio]') && !(element as any).isContentEditable) { // Go up to the label that might be connected to the input/textarea. - element = element.closest('label') || element; + const enclosingLabel: HTMLLabelElement | null = element.closest('label'); + if (enclosingLabel && enclosingLabel.control) + element = enclosingLabel.control; } - if (element.nodeName === 'LABEL') - element = (element as HTMLLabelElement).control || element; } return element; } @@ -545,15 +546,15 @@ export class InjectedScript { if (stableResult === false) return { missingState: 'stable' }; if (stableResult === 'error:notconnected') - return stableResult; + return 'error:notconnected'; } for (const state of states) { if (state !== 'stable') { const result = this.elementState(node, state); - if (result === false) + if (result.received === 'error:notconnected') + return 'error:notconnected'; + if (!result.matches) return { missingState: state }; - if (result === 'error:notconnected') - return result; } } } @@ -608,38 +609,60 @@ export class InjectedScript { return result; } - elementState(node: Node, state: ElementStateWithoutStable): boolean | 'error:notconnected' { - const element = this.retarget(node, ['stable', 'visible', 'hidden'].includes(state) ? 'none' : 'follow-label'); + elementState(node: Node, state: ElementStateWithoutStable): ElementStateQueryResult { + const element = this.retarget(node, ['visible', 'hidden'].includes(state) ? 'none' : 'follow-label'); if (!element || !element.isConnected) { if (state === 'hidden') - return true; - return 'error:notconnected'; + return { matches: true, received: 'hidden' }; + return { matches: false, received: 'error:notconnected' }; } - if (state === 'visible') - return isElementVisible(element); - if (state === 'hidden') - return !isElementVisible(element); + if (state === 'visible' || state === 'hidden') { + const visible = isElementVisible(element); + return { + matches: state === 'visible' ? visible : !visible, + received: visible ? 'visible' : 'hidden' + }; + } - const disabled = getAriaDisabled(element); - if (state === 'disabled') - return disabled; - if (state === 'enabled') - return !disabled; + if (state === 'disabled' || state === 'enabled') { + const disabled = getAriaDisabled(element); + return { + matches: state === 'disabled' ? disabled : !disabled, + received: disabled ? 'disabled' : 'enabled' + }; + } if (state === 'editable') { + const disabled = getAriaDisabled(element); const readonly = getReadonly(element); if (readonly === 'error') throw this.createStacklessError('Element is not an ,