Merge branch 'main' into tar-download-3rd-party-lib

This commit is contained in:
Simon Knott 2025-01-13 11:00:56 +01:00
commit 30c5ec64c8
No known key found for this signature in database
GPG key ID: 8CEDC00028084AEC
241 changed files with 3255 additions and 8588 deletions

View file

@ -115,7 +115,7 @@ module.exports = {
"@typescript-eslint/type-annotation-spacing": 2, "@typescript-eslint/type-annotation-spacing": 2,
// file whitespace // 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-mixed-spaces-and-tabs": 2,
"no-trailing-spaces": 2, "no-trailing-spaces": 2,
"linebreak-style": [ process.platform === "win32" ? 0 : 2, "unix" ], "linebreak-style": [ process.platform === "win32" ? 0 : 2, "unix" ],
@ -123,6 +123,7 @@ module.exports = {
"key-spacing": [2, { "key-spacing": [2, {
"beforeColon": false "beforeColon": false
}], }],
"eol-last": 2,
// copyright // copyright
"notice/notice": [2, { "notice/notice": [2, {

View file

@ -7,6 +7,7 @@ on:
- main - main
paths: paths:
- .github/workflows/tests_bidi.yml - .github/workflows/tests_bidi.yml
- packages/playwright-core/src/server/bidi/*
schedule: schedule:
# Run every day at midnight # Run every day at midnight
- cron: '0 0 * * *' - cron: '0 0 * * *'
@ -43,3 +44,27 @@ jobs:
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run biditest -- --project=${{ matrix.channel }}* run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run biditest -- --project=${{ matrix.channel }}*
env: env:
PWTEST_USE_BIDI_EXPECTATIONS: '1' 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

View file

@ -147,6 +147,13 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@v4 - 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 - uses: ./.github/actions/run-test
with: with:
browsers-to-install: chromium browsers-to-install: chromium

View file

@ -215,6 +215,13 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- run: npm install -g yarn@1 - run: npm install -g yarn@1
- run: npm install -g pnpm@8 - 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 - uses: ./.github/actions/run-test
with: with:
command: npm run itest command: npm run itest

View file

@ -107,6 +107,13 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- run: npm install -g yarn@1 - run: npm install -g yarn@1
- run: npm install -g pnpm@8 - 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 - uses: ./.github/actions/run-test
with: with:
node-version: ${{ matrix.node_version }} node-version: ${{ matrix.node_version }}

View file

@ -1,6 +1,6 @@
# 🎭 Playwright # 🎭 Playwright
[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[![Chromium version](https://img.shields.io/badge/chromium-132.0.6834.46-blue.svg?logo=google-chrome)](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[![Firefox version](https://img.shields.io/badge/firefox-132.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[![WebKit version](https://img.shields.io/badge/webkit-18.2-blue.svg?logo=safari)](https://webkit.org/)<!-- GEN:stop --> [![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) <!-- GEN:chromium-version-badge -->[![Chromium version](https://img.shields.io/badge/chromium-132.0.6834.57-blue.svg?logo=google-chrome)](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[![Firefox version](https://img.shields.io/badge/firefox-133.0.3-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[![WebKit version](https://img.shields.io/badge/webkit-18.2-blue.svg?logo=safari)](https://webkit.org/)<!-- GEN:stop --> [![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) ## [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 | | | Linux | macOS | Windows |
| :--- | :---: | :---: | :---: | | :--- | :---: | :---: | :---: |
| Chromium <!-- GEN:chromium-version -->132.0.6834.46<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Chromium <!-- GEN:chromium-version -->132.0.6834.57<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| WebKit <!-- GEN:webkit-version -->18.2<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit <!-- GEN:webkit-version -->18.2<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Firefox <!-- GEN:firefox-version -->132.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox <!-- GEN:firefox-version -->133.0.3<!-- GEN:stop --> | :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. Headless execution is supported for all browsers on all platforms. Check out [system requirements](https://playwright.dev/docs/intro#system-requirements) for details.

View file

@ -136,7 +136,7 @@ Launches Chrome browser on the device, and returns its persistent context.
### option: AndroidDevice.launchBrowser.pkg ### option: AndroidDevice.launchBrowser.pkg
* since: v1.9 * since: v1.9
- `command` <[string]> - `pkg` <[string]>
Optional package name to launch instead of default Chrome for Android. Optional package name to launch instead of default Chrome for Android.

View file

@ -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: You can also specify [JSHandle] as the property value if you want live objects to be passed into the event:
```js ```js
// Note you can only create DataTransfer in Chromium and Firefox
const dataTransfer = await page.evaluateHandle(() => new DataTransfer()); const dataTransfer = await page.evaluateHandle(() => new DataTransfer());
await locator.dispatchEvent('dragstart', { dataTransfer }); await locator.dispatchEvent('dragstart', { dataTransfer });
``` ```
```java ```java
// Note you can only create DataTransfer in Chromium and Firefox
JSHandle dataTransfer = page.evaluateHandle("() => new DataTransfer()"); JSHandle dataTransfer = page.evaluateHandle("() => new DataTransfer()");
Map<String, Object> arg = new HashMap<>(); Map<String, Object> arg = new HashMap<>();
arg.put("dataTransfer", dataTransfer); arg.put("dataTransfer", dataTransfer);
@ -647,13 +645,11 @@ locator.dispatchEvent("dragstart", arg);
``` ```
```python async ```python async
# note you can only create data_transfer in chromium and firefox
data_transfer = await page.evaluate_handle("new DataTransfer()") data_transfer = await page.evaluate_handle("new DataTransfer()")
await locator.dispatch_event("#source", "dragstart", {"dataTransfer": data_transfer}) await locator.dispatch_event("#source", "dragstart", {"dataTransfer": data_transfer})
``` ```
```python sync ```python sync
# note you can only create data_transfer in chromium and firefox
data_transfer = page.evaluate_handle("new DataTransfer()") data_transfer = page.evaluate_handle("new DataTransfer()")
locator.dispatch_event("#source", "dragstart", {"dataTransfer": data_transfer}) 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. 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** **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. 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 ```js
const newEmail = page.getByRole('button', { name: 'New' }); const newEmail = page.getByRole('button', { name: 'New' });
const dialog = page.getByText('Confirm security settings'); const dialog = page.getByText('Confirm security settings');
await expect(newEmail.or(dialog)).toBeVisible(); await expect(newEmail.or(dialog).first()).toBeVisible();
if (await dialog.isVisible()) if (await dialog.isVisible())
await page.getByRole('button', { name: 'Dismiss' }).click(); await page.getByRole('button', { name: 'Dismiss' }).click();
await newEmail.click(); await newEmail.click();
@ -1735,7 +1736,7 @@ await newEmail.click();
```java ```java
Locator newEmail = page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("New")); Locator newEmail = page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("New"));
Locator dialog = page.getByText("Confirm security settings"); Locator dialog = page.getByText("Confirm security settings");
assertThat(newEmail.or(dialog)).isVisible(); assertThat(newEmail.or(dialog).first()).isVisible();
if (dialog.isVisible()) if (dialog.isVisible())
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Dismiss")).click(); page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Dismiss")).click();
newEmail.click(); newEmail.click();
@ -1744,7 +1745,7 @@ newEmail.click();
```python async ```python async
new_email = page.get_by_role("button", name="New") new_email = page.get_by_role("button", name="New")
dialog = page.get_by_text("Confirm security settings") 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()): if (await dialog.is_visible()):
await page.get_by_role("button", name="Dismiss").click() await page.get_by_role("button", name="Dismiss").click()
await new_email.click() await new_email.click()
@ -1753,7 +1754,7 @@ await new_email.click()
```python sync ```python sync
new_email = page.get_by_role("button", name="New") new_email = page.get_by_role("button", name="New")
dialog = page.get_by_text("Confirm security settings") 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()): if (dialog.is_visible()):
page.get_by_role("button", name="Dismiss").click() page.get_by_role("button", name="Dismiss").click()
new_email.click() new_email.click()
@ -1762,7 +1763,7 @@ new_email.click()
```csharp ```csharp
var newEmail = page.GetByRole(AriaRole.Button, new() { Name = "New" }); var newEmail = page.GetByRole(AriaRole.Button, new() { Name = "New" });
var dialog = page.GetByText("Confirm security settings"); var dialog = page.GetByText("Confirm security settings");
await Expect(newEmail.Or(dialog)).ToBeVisibleAsync(); await Expect(newEmail.Or(dialog).First).ToBeVisibleAsync();
if (await dialog.IsVisibleAsync()) if (await dialog.IsVisibleAsync())
await page.GetByRole(AriaRole.Button, new() { Name = "Dismiss" }).ClickAsync(); await page.GetByRole(AriaRole.Button, new() { Name = "Dismiss" }).ClickAsync();
await newEmail.ClickAsync(); await newEmail.ClickAsync();

View file

@ -541,6 +541,16 @@ await Expect(locator).ToBeCheckedAsync();
* since: v1.18 * since: v1.18
- `checked` <[boolean]> - `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-%% ### option: LocatorAssertions.toBeChecked.timeout = %%-js-assertions-timeout-%%
* since: v1.18 * since: v1.18
@ -1217,6 +1227,56 @@ Expected accessible description.
* since: v1.44 * 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 ## async method: LocatorAssertions.toHaveAccessibleName
* since: v1.44 * since: v1.44
* langs: * langs:

View file

@ -1003,7 +1003,7 @@ Additional arguments to pass to the browser instance. The list of Chromium flags
Browser distribution channel. 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). 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).

View file

@ -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. 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 ```bash js
# only running tests headlessly # 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 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): 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 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 ### 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. 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.

View file

@ -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. 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 ```js
const { chromium } = require('playwright'); const { chromium } = require('playwright');
@ -18,7 +20,7 @@ const { chromium } = require('playwright');
const pathToExtension = require('path').join(__dirname, 'my-extension'); const pathToExtension = require('path').join(__dirname, 'my-extension');
const userDataDir = '/tmp/test-user-data-dir'; const userDataDir = '/tmp/test-user-data-dir';
const browserContext = await chromium.launchPersistentContext(userDataDir, { const browserContext = await chromium.launchPersistentContext(userDataDir, {
headless: false, channel: 'chromium',
args: [ args: [
`--disable-extensions-except=${pathToExtension}`, `--disable-extensions-except=${pathToExtension}`,
`--load-extension=${pathToExtension}` `--load-extension=${pathToExtension}`
@ -44,7 +46,7 @@ user_data_dir = "/tmp/test-user-data-dir"
async def run(playwright: Playwright): async def run(playwright: Playwright):
context = await playwright.chromium.launch_persistent_context( context = await playwright.chromium.launch_persistent_context(
user_data_dir, user_data_dir,
headless=False, channel="chromium",
args=[ args=[
f"--disable-extensions-except={path_to_extension}", f"--disable-extensions-except={path_to_extension}",
f"--load-extension={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): def run(playwright: Playwright):
context = playwright.chromium.launch_persistent_context( context = playwright.chromium.launch_persistent_context(
user_data_dir, user_data_dir,
headless=False, channel="chromium",
args=[ args=[
f"--disable-extensions-except={path_to_extension}", f"--disable-extensions-except={path_to_extension}",
f"--load-extension={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. 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: First, add fixtures that will load the extension:
```js title="fixtures.ts" ```js title="fixtures.ts"
@ -114,7 +118,7 @@ export const test = base.extend<{
context: async ({ }, use) => { context: async ({ }, use) => {
const pathToExtension = path.join(__dirname, 'my-extension'); const pathToExtension = path.join(__dirname, 'my-extension');
const context = await chromium.launchPersistentContext('', { const context = await chromium.launchPersistentContext('', {
headless: false, channel: 'chromium',
args: [ args: [
`--disable-extensions-except=${pathToExtension}`, `--disable-extensions-except=${pathToExtension}`,
`--load-extension=${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") path_to_extension = Path(__file__).parent.joinpath("my-extension")
context = playwright.chromium.launch_persistent_context( context = playwright.chromium.launch_persistent_context(
"", "",
headless=False, channel="chromium",
args=[ args=[
f"--disable-extensions-except={path_to_extension}", f"--disable-extensions-except={path_to_extension}",
f"--load-extension={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") page.goto(f"chrome-extension://{extension_id}/popup.html")
expect(page.locator("body")).to_have_text("my-extension popup") 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}",
],
)
```

View file

@ -217,7 +217,7 @@ await page.getByText('Item').click({ button: 'right' });
// Shift + click // Shift + click
await page.getByText('Item').click({ modifiers: ['Shift'] }); await page.getByText('Item').click({ modifiers: ['Shift'] });
// Ctrl + click or Windows and Linux // Ctrl + click on Windows and Linux
// Meta + click on macOS // Meta + click on macOS
await page.getByText('Item').click({ modifiers: ['ControlOrMeta'] }); await page.getByText('Item').click({ modifiers: ['ControlOrMeta'] });
@ -241,7 +241,7 @@ page.getByText("Item").click(new Locator.ClickOptions().setButton(MouseButton.RI
// Shift + click // Shift + click
page.getByText("Item").click(new Locator.ClickOptions().setModifiers(Arrays.asList(KeyboardModifier.SHIFT))); 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 // Meta + click on macOS
page.getByText("Item").click(new Locator.ClickOptions().setModifiers(Arrays.asList(KeyboardModifier.CONTROL_OR_META))); 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 # Shift + click
await page.get_by_text("Item").click(modifiers=["Shift"]) 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 # Meta + click on macOS
await page.get_by_text("Item").click(modifiers=["ControlOrMeta"]) await page.get_by_text("Item").click(modifiers=["ControlOrMeta"])
@ -309,7 +309,7 @@ await page.GetByText("Item").ClickAsync(new() { Button = MouseButton.Right });
// Shift + click // Shift + click
await page.GetByText("Item").ClickAsync(new() { Modifiers = new[] { KeyboardModifier.Shift } }); 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 // Meta + click on macOS
await page.GetByText("Item").ClickAsync(new() { Modifiers = new[] { KeyboardModifier.ControlOrMeta } }); await page.GetByText("Item").ClickAsync(new() { Modifiers = new[] { KeyboardModifier.ControlOrMeta } });

View file

@ -286,7 +286,7 @@ pnpm exec playwright --version
## System requirements ## 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). - Windows 10+, Windows Server 2016+ or Windows Subsystem for Linux (WSL).
- macOS 13 Ventura, or later. - macOS 13 Ventura, or later.
- Debian 12, Ubuntu 22.04, Ubuntu 24.04, on x86-64 and arm64 architecture. - Debian 12, Ubuntu 22.04, Ubuntu 24.04, on x86-64 and arm64 architecture.

View file

@ -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"`. - `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"`. - `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. - `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. - `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. Launch a development web server (or multiple) during the tests.

View file

@ -695,7 +695,7 @@ test('passes', async ({ database, page, a11y }) => {
## Box fixtures ## 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 ```js
import { test as base } from '@playwright/test'; import { test as base } from '@playwright/test';

View file

@ -50,6 +50,16 @@ Start time of this particular test step.
List of steps inside this 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 ## property: TestStep.title
* since: v1.10 * since: v1.10
- type: <[string]> - type: <[string]>

View file

@ -36,7 +36,8 @@ export default defineConfig({
| `cwd` | Current working directory of the spawned process, defaults to the directory of the configuration file. | | `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"`. | | `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"`. | | `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 ## Adding a server timeout

144
docs/src/touch-events.md Normal file
View file

@ -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();
});
```

5
package-lock.json generated
View file

@ -7964,7 +7964,10 @@
} }
}, },
"packages/trace-viewer": { "packages/trace-viewer": {
"version": "0.0.0" "version": "0.0.0",
"dependencies": {
"yaml": "^2.6.0"
}
}, },
"packages/web": { "packages/web": {
"version": "0.0.0", "version": "0.0.0",

View file

@ -51,4 +51,4 @@ export function bundle(): Plugin {
} }
}, },
}; };
} }

View file

@ -60,6 +60,11 @@
color: var(--color-scale-orange-6); color: var(--color-scale-orange-6);
border: 1px solid var(--color-scale-orange-4); 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) { @media(prefers-color-scheme: dark) {
@ -93,6 +98,11 @@
color: var(--color-scale-orange-2); color: var(--color-scale-orange-2);
border: 1px solid var(--color-scale-orange-4); 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 { .attachment-body {

View file

@ -68,11 +68,12 @@ export const ProjectLink: React.FunctionComponent<{
export const AttachmentLink: React.FunctionComponent<{ export const AttachmentLink: React.FunctionComponent<{
attachment: TestAttachment, attachment: TestAttachment,
result: TestResult,
href?: string, href?: string,
linkName?: string, linkName?: string,
openInNewTab?: boolean, openInNewTab?: boolean,
}> = ({ attachment, href, linkName, openInNewTab }) => { }> = ({ attachment, result, href, linkName, openInNewTab }) => {
const isAnchored = useIsAnchored('attachment-' + attachment.name); const isAnchored = useIsAnchored('attachment-' + result.attachments.indexOf(attachment));
return <TreeItem title={<span> return <TreeItem title={<span>
{attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()} {attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()}
{attachment.path && <a href={href || attachment.path} download={downloadFileNameForAttachment(attachment)}>{linkName || attachment.name}</a>} {attachment.path && <a href={href || attachment.path} download={downloadFileNameForAttachment(attachment)}>{linkName || attachment.name}</a>}

View file

@ -37,8 +37,10 @@ const result: TestResult = {
duration: 10, duration: 10,
location: { file: 'test.spec.ts', line: 82, column: 0 }, location: { file: 'test.spec.ts', line: 82, column: 0 },
steps: [], steps: [],
attachments: [],
count: 1, count: 1,
}], }],
attachments: [],
}], }],
attachments: [], attachments: [],
status: 'passed', status: 'passed',
@ -139,6 +141,7 @@ const resultWithAttachment: TestResult = {
location: { file: 'test.spec.ts', line: 62, column: 0 }, location: { file: 'test.spec.ts', line: 62, column: 0 },
count: 1, count: 1,
steps: [], steps: [],
attachments: [1],
}], }],
attachments: [{ attachments: [{
name: 'first attachment', name: 'first attachment',

View file

@ -75,7 +75,7 @@ function imageDiffBadge(test: TestCaseSummary): JSX.Element | undefined {
for (const result of test.results) { for (const result of test.results) {
for (const attachment of result.attachments) { for (const attachment of result.attachments) {
if (attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/)) if (attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/))
return <Link href={testResultHref({ test, result, anchor: `attachment-${attachment.name}` })} title='View images' className='test-file-badge'>{image()}</Link>; return <Link href={testResultHref({ test, result, anchor: `attachment-${result.attachments.indexOf(attachment)}` })} title='View images' className='test-file-badge'>{image()}</Link>;
} }
} }
} }

View file

@ -32,7 +32,7 @@ interface ImageDiffWithAnchors extends ImageDiff {
anchors: string[]; anchors: string[];
} }
function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiffWithAnchors[] { function groupImageDiffs(screenshots: Set<TestAttachment>, result: TestResult): ImageDiffWithAnchors[] {
const snapshotNameToImageDiff = new Map<string, ImageDiffWithAnchors>(); const snapshotNameToImageDiff = new Map<string, ImageDiffWithAnchors>();
for (const attachment of screenshots) { for (const attachment of screenshots) {
const match = attachment.name.match(/^(.*)-(expected|actual|diff|previous)(\.[^.]+)?$/); const match = attachment.name.match(/^(.*)-(expected|actual|diff|previous)(\.[^.]+)?$/);
@ -45,7 +45,7 @@ function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiffWithAnchors
imageDiff = { name: snapshotName, anchors: [`attachment-${name}`] }; imageDiff = { name: snapshotName, anchors: [`attachment-${name}`] };
snapshotNameToImageDiff.set(snapshotName, imageDiff); snapshotNameToImageDiff.set(snapshotName, imageDiff);
} }
imageDiff.anchors.push(`attachment-${attachment.name}`); imageDiff.anchors.push(`attachment-${result.attachments.indexOf(attachment)}`);
if (category === 'actual') if (category === 'actual')
imageDiff.actual = { attachment }; imageDiff.actual = { attachment };
if (category === 'expected') if (category === 'expected')
@ -72,15 +72,15 @@ export const TestResultView: React.FC<{
result: TestResult, result: TestResult,
}> = ({ test, result }) => { }> = ({ test, result }) => {
const { screenshots, videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors } = React.useMemo(() => { 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 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 videos = attachments.filter(a => a.contentType.startsWith('video/'));
const traces = attachments.filter(a => a.name === 'trace'); const traces = attachments.filter(a => a.name === 'trace');
const otherAttachments = new Set<TestAttachment>(attachments); const otherAttachments = new Set<TestAttachment>(attachments);
[...screenshots, ...videos, ...traces].forEach(a => otherAttachments.delete(a)); [...screenshots, ...videos, ...traces].forEach(a => otherAttachments.delete(a));
const otherAttachmentAnchors = [...otherAttachments].map(a => `attachment-${a.name}`); const otherAttachmentAnchors = [...otherAttachments].map(a => `attachment-${attachments.indexOf(a)}`);
const diffs = groupImageDiffs(screenshots); const diffs = groupImageDiffs(screenshots, result);
const errors = classifyErrors(result.errors, diffs); const errors = classifyErrors(result.errors, diffs);
return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors }; return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors };
}, [result]); }, [result]);
@ -107,11 +107,11 @@ export const TestResultView: React.FC<{
{!!screenshots.length && <AutoChip header='Screenshots' revealOnAnchorId={screenshotAnchors}> {!!screenshots.length && <AutoChip header='Screenshots' revealOnAnchorId={screenshotAnchors}>
{screenshots.map((a, i) => { {screenshots.map((a, i) => {
return <Anchor key={`screenshot-${i}`} id={`attachment-${a.name}`}> return <Anchor key={`screenshot-${i}`} id={`attachment-${result.attachments.indexOf(a)}`}>
<a href={a.path}> <a href={a.path}>
<img className='screenshot' src={a.path} /> <img className='screenshot' src={a.path} />
</a> </a>
<AttachmentLink attachment={a}></AttachmentLink> <AttachmentLink attachment={a} result={result}></AttachmentLink>
</Anchor>; </Anchor>;
})} })}
</AutoChip>} </AutoChip>}
@ -121,7 +121,7 @@ export const TestResultView: React.FC<{
<a href={generateTraceUrl(traces)}> <a href={generateTraceUrl(traces)}>
<img className='screenshot' src={traceImage} style={{ width: 192, height: 117, marginLeft: 20 }} /> <img className='screenshot' src={traceImage} style={{ width: 192, height: 117, marginLeft: 20 }} />
</a> </a>
{traces.map((a, i) => <AttachmentLink key={`trace-${i}`} attachment={a} linkName={traces.length === 1 ? 'trace' : `trace-${i + 1}`}></AttachmentLink>)} {traces.map((a, i) => <AttachmentLink key={`trace-${i}`} attachment={a} result={result} linkName={traces.length === 1 ? 'trace' : `trace-${i + 1}`}></AttachmentLink>)}
</div>} </div>}
</AutoChip></Anchor>} </AutoChip></Anchor>}
@ -130,14 +130,14 @@ export const TestResultView: React.FC<{
<video controls> <video controls>
<source src={a.path} type={a.contentType}/> <source src={a.path} type={a.contentType}/>
</video> </video>
<AttachmentLink attachment={a}></AttachmentLink> <AttachmentLink attachment={a} result={result}></AttachmentLink>
</div>)} </div>)}
</AutoChip></Anchor>} </AutoChip></Anchor>}
{!!otherAttachments.size && <AutoChip header='Attachments' revealOnAnchorId={otherAttachmentAnchors}> {!!otherAttachments.size && <AutoChip header='Attachments' revealOnAnchorId={otherAttachmentAnchors} dataTestId='attachments'>
{[...otherAttachments].map((a, i) => {[...otherAttachments].map((a, i) =>
<Anchor key={`attachment-link-${i}`} id={`attachment-${a.name}`}> <Anchor key={`attachment-link-${i}`} id={`attachment-${result.attachments.indexOf(a)}`}>
<AttachmentLink attachment={a} openInNewTab={a.contentType.startsWith('text/html')} /> <AttachmentLink attachment={a} result={result} openInNewTab={a.contentType.startsWith('text/html')} />
</Anchor> </Anchor>
)} )}
</AutoChip>} </AutoChip>}
@ -174,18 +174,29 @@ const StepTreeItem: React.FC<{
step: TestStep; step: TestStep;
depth: number, depth: number,
}> = ({ test, step, result, depth }) => { }> = ({ test, step, result, depth }) => {
const attachmentName = step.title.match(/^attach "(.*)"$/)?.[1];
return <TreeItem title={<span aria-label={step.title}> return <TreeItem title={<span aria-label={step.title}>
<span style={{ float: 'right' }}>{msToString(step.duration)}</span> <span style={{ float: 'right' }}>{msToString(step.duration)}</span>
{attachmentName && <a style={{ float: 'right' }} title='link to attachment' href={testResultHref({ test, result, anchor: `attachment-${attachmentName}` })} onClick={evt => { evt.stopPropagation(); }}>{icons.attachment()}</a>}
{statusIcon(step.error || step.duration === -1 ? 'failed' : 'passed')} {statusIcon(step.error || step.duration === -1 ? 'failed' : 'passed')}
<span>{step.title}</span> <span>{step.title}</span>
{step.count > 1 && <> <span className='test-result-counter'>{step.count}</span></>} {step.count > 1 && <> <span className='test-result-counter'>{step.count}</span></>}
{step.location && <span className='test-result-path'> {step.location.file}:{step.location.line}</span>} {step.location && <span className='test-result-path'> {step.location.file}:{step.location.line}</span>}
</span>} loadChildren={step.steps.length + (step.snippet ? 1 : 0) ? () => { </span>} loadChildren={step.steps.length + (step.snippet ? 1 : 0) ? () => {
const children = step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1} result={result} test={test} />); const snippet = step.snippet ? [<TestErrorView testId='test-snippet' key='line' error={step.snippet}/>] : [];
if (step.snippet) const steps = step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1} result={result} test={test} />);
children.unshift(<TestErrorView testId='test-snippet' key='line' error={step.snippet}/>); const attachments = step.attachments.map(attachmentIndex => (
return children; <a key={'' + attachmentIndex}
href={testResultHref({ test, result, anchor: `attachment-${attachmentIndex}` })}
style={{ paddingLeft: depth * 22 + 4, textDecoration: 'none' }}
>
<span
style={{ margin: '8px 0 0 8px', padding: '2px 10px', cursor: 'pointer' }}
className='label label-color-gray'
title={`see "${result.attachments[attachmentIndex].name}"`}
>
{icons.attachment()}{result.attachments[attachmentIndex].name}
</span>
</a>
));
return snippet.concat(steps, attachments);
} : undefined} depth={depth}/>; } : undefined} depth={depth}/>;
}; };

View file

@ -108,5 +108,6 @@ export type TestStep = {
snippet?: string; snippet?: string;
error?: string; error?: string;
steps: TestStep[]; steps: TestStep[];
attachments: number[];
count: number; count: number;
}; };

View file

@ -47,4 +47,3 @@ export function hashStringToInt(str: string) {
hash = str.charCodeAt(i) + ((hash << 8) - hash); hash = str.charCodeAt(i) + ((hash << 8) - hash);
return Math.abs(hash % 6); return Math.abs(hash % 6);
} }

View file

@ -1,2 +0,0 @@
See building instructions at [`/browser_patches/winldd/README.md`](../../../browser_patches/winldd/README.md)

View file

@ -3,21 +3,21 @@
"browsers": [ "browsers": [
{ {
"name": "chromium", "name": "chromium",
"revision": "1152", "revision": "1153",
"installByDefault": true, "installByDefault": true,
"browserVersion": "132.0.6834.46" "browserVersion": "132.0.6834.57"
}, },
{ {
"name": "chromium-tip-of-tree", "name": "chromium-tip-of-tree",
"revision": "1287", "revision": "1293",
"installByDefault": false, "installByDefault": false,
"browserVersion": "133.0.6901.0" "browserVersion": "133.0.6943.0"
}, },
{ {
"name": "firefox", "name": "firefox",
"revision": "1466", "revision": "1470",
"installByDefault": true, "installByDefault": true,
"browserVersion": "132.0" "browserVersion": "133.0.3"
}, },
{ {
"name": "firefox-beta", "name": "firefox-beta",
@ -27,7 +27,7 @@
}, },
{ {
"name": "webkit", "name": "webkit",
"revision": "2120", "revision": "2122",
"installByDefault": true, "installByDefault": true,
"revisionOverrides": { "revisionOverrides": {
"debian11-x64": "2105", "debian11-x64": "2105",
@ -52,6 +52,11 @@
"mac12-arm64": "1011" "mac12-arm64": "1011"
} }
}, },
{
"name": "winldd",
"revision": "1007",
"installByDefault": false
},
{ {
"name": "android", "name": "android",
"revision": "1001", "revision": "1001",

View file

@ -29,9 +29,9 @@ import { rewriteErrorMessage } from './utils/stackTrace';
import { SocksProxy } from './common/socksProxy'; import { SocksProxy } from './common/socksProxy';
export class BrowserServerLauncherImpl implements BrowserServerLauncher { 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; this._browserName = browserName;
} }

View file

@ -116,6 +116,9 @@ function checkBrowsersToInstall(args: string[], options: { noShell?: boolean, on
} }
} }
if (process.platform === 'win32')
executables.push(registry.findExecutable('winldd')!);
if (faultyArguments.length) if (faultyArguments.length)
throw new Error(`Invalid installation targets: ${faultyArguments.map(name => `'${name}'`).join(', ')}. Expecting one of: ${suggestedBrowsersToInstall()}`); throw new Error(`Invalid installation targets: ${faultyArguments.map(name => `'${name}'`).join(', ')}. Expecting one of: ${suggestedBrowsersToInstall()}`);
return executables; return executables;

View file

@ -18,7 +18,6 @@ import { EventEmitter } from './eventEmitter';
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
import { maybeFindValidator, ValidationError, type ValidatorContext } from '../protocol/validator'; import { maybeFindValidator, ValidationError, type ValidatorContext } from '../protocol/validator';
import { debugLogger } from '../utils/debugLogger'; import { debugLogger } from '../utils/debugLogger';
import type { ExpectZone } from '../utils/stackTrace';
import { captureLibraryStackTrace, stringifyStackFrames } from '../utils/stackTrace'; import { captureLibraryStackTrace, stringifyStackFrames } from '../utils/stackTrace';
import { isUnderTest } from '../utils'; import { isUnderTest } from '../utils';
import { zones } from '../utils/zones'; import { zones } from '../utils/zones';
@ -148,15 +147,18 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
if (validator) { if (validator) {
return async (params: any) => { return async (params: any) => {
return await this._wrapApiCall(async apiZone => { return await this._wrapApiCall(async apiZone => {
const { apiName, frames, csi, callCookie, stepId } = apiZone.reported ? { apiName: undefined, csi: undefined, callCookie: undefined, frames: [], stepId: undefined } : apiZone; const validatedParams = validator(params, '', { tChannelImpl: tChannelImplToWire, binary: this._connection.rawBuffers() ? 'buffer' : 'toBase64' });
apiZone.reported = true; if (!apiZone.isInternal && !apiZone.reported) {
let currentStepId = stepId; // Reporting/tracing/logging this api call for the first time.
if (csi && apiName) { apiZone.params = params;
const out: { stepId?: string } = {}; apiZone.reported = true;
csi.onApiCallBegin(apiName, params, frames, callCookie, out); this._instrumentation.onApiCallBegin(apiZone);
currentStepId = out.stepId; 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<T extends channels.Channel = channels.Channel
async _wrapApiCall<R>(func: (apiZone: ApiZone) => Promise<R>, isInternal?: boolean): Promise<R> { async _wrapApiCall<R>(func: (apiZone: ApiZone) => Promise<R>, isInternal?: boolean): Promise<R> {
const logger = this._logger; const logger = this._logger;
const apiZone = zones.zoneData<ApiZone>('apiZone'); const existingApiZone = zones.zoneData<ApiZone>('apiZone');
if (apiZone) if (existingApiZone)
return await func(apiZone); return await func(existingApiZone);
const stackTrace = captureLibraryStackTrace();
let apiName: string | undefined = stackTrace.apiName;
const frames: channels.StackFrame[] = stackTrace.frames;
if (isInternal === undefined) if (isInternal === undefined)
isInternal = this._isInternalType; isInternal = this._isInternalType;
if (isInternal) const stackTrace = captureLibraryStackTrace();
apiName = undefined; const apiZone: ApiZone = { apiName: stackTrace.apiName, frames: stackTrace.frames, isInternal, reported: false, userData: undefined, stepId: undefined };
// Enclosing zone could have provided the apiName and wallTime.
const expectZone = zones.zoneData<ExpectZone>('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 = {};
try { 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)); const result = await zones.run('apiZone', apiZone, async () => await func(apiZone));
csi?.onApiCallEnd(callCookie); if (!isInternal) {
logApiCall(logger, `<= ${apiName} succeeded`, isInternal); logApiCall(logger, `<= ${apiZone.apiName} succeeded`);
this._instrumentation.onApiCallEnd(apiZone);
}
return result; return result;
} catch (e) { } catch (e) {
const innerError = ((process.env.PWDEBUGIMPL || isUnderTest()) && e.stack) ? '\n<inner error>\n' + e.stack : ''; const innerError = ((process.env.PWDEBUGIMPL || isUnderTest()) && e.stack) ? '\n<inner error>\n' + e.stack : '';
if (apiName && !apiName.includes('<anonymous>')) if (apiZone.apiName && !apiZone.apiName.includes('<anonymous>'))
e.message = apiName + ': ' + e.message; e.message = apiZone.apiName + ': ' + e.message;
const stackFrames = '\n' + stringifyStackFrames(stackTrace.frames).join('\n') + innerError; const stackFrames = '\n' + stringifyStackFrames(stackTrace.frames).join('\n') + innerError;
if (stackFrames.trim()) if (stackFrames.trim())
e.stack = e.message + stackFrames; e.stack = e.message + stackFrames;
else else
e.stack = ''; e.stack = '';
csi?.onApiCallEnd(callCookie, e); if (!isInternal) {
logApiCall(logger, `<= ${apiName} failed`, isInternal); apiZone.error = e;
logApiCall(logger, `<= ${apiZone.apiName} failed`);
this._instrumentation.onApiCallEnd(apiZone);
}
throw e; throw e;
} }
} }
@ -232,9 +222,7 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
} }
} }
function logApiCall(logger: Logger | undefined, message: string, isNested: boolean) { function logApiCall(logger: Logger | undefined, message: string) {
if (isNested)
return;
if (logger && logger.isEnabled('api', 'info')) if (logger && logger.isEnabled('api', 'info'))
logger.log('api', 'info', message, [], { color: 'cyan' }); logger.log('api', 'info', message, [], { color: 'cyan' });
debugLogger.log('api', message); debugLogger.log('api', message);
@ -247,11 +235,12 @@ function tChannelImplToWire(names: '*' | string[], arg: any, path: string, conte
} }
type ApiZone = { type ApiZone = {
apiName: string | undefined; apiName: string;
params?: Record<string, any>;
frames: channels.StackFrame[]; frames: channels.StackFrame[];
isInternal: boolean; isInternal: boolean;
reported: boolean; reported: boolean;
csi: ClientInstrumentation | undefined; userData: any;
callCookie: any;
stepId?: string; stepId?: string;
error?: Error;
}; };

View file

@ -18,12 +18,22 @@ import type { StackFrame } from '@protocol/channels';
import type { BrowserContext } from './browserContext'; import type { BrowserContext } from './browserContext';
import type { APIRequestContext } from './fetch'; import type { APIRequestContext } from './fetch';
// Instrumentation can mutate the data, for example change apiName or stepId.
export interface ApiCallData {
apiName: string;
params?: Record<string, any>;
frames: StackFrame[];
userData: any;
stepId?: string;
error?: Error;
}
export interface ClientInstrumentation { export interface ClientInstrumentation {
addListener(listener: ClientInstrumentationListener): void; addListener(listener: ClientInstrumentationListener): void;
removeListener(listener: ClientInstrumentationListener): void; removeListener(listener: ClientInstrumentationListener): void;
removeAllListeners(): void; removeAllListeners(): void;
onApiCallBegin(apiCall: string, params: Record<string, any>, frames: StackFrame[], userData: any, out: { stepId?: string }): void; onApiCallBegin(apiCall: ApiCallData): void;
onApiCallEnd(userData: any, error?: Error): void; onApiCallEnd(apiCal: ApiCallData): void;
onWillPause(options: { keepTestTimeout: boolean }): void; onWillPause(options: { keepTestTimeout: boolean }): void;
runAfterCreateBrowserContext(context: BrowserContext): Promise<void>; runAfterCreateBrowserContext(context: BrowserContext): Promise<void>;
@ -33,8 +43,8 @@ export interface ClientInstrumentation {
} }
export interface ClientInstrumentationListener { export interface ClientInstrumentationListener {
onApiCallBegin?(apiName: string, params: Record<string, any>, frames: StackFrame[], userData: any, out: { stepId?: string }): void; onApiCallBegin?(apiCall: ApiCallData): void;
onApiCallEnd?(userData: any, error?: Error): void; onApiCallEnd?(apiCall: ApiCallData): void;
onWillPause?(options: { keepTestTimeout: boolean }): void; onWillPause?(options: { keepTestTimeout: boolean }): void;
runAfterCreateBrowserContext?(context: BrowserContext): Promise<void>; runAfterCreateBrowserContext?(context: BrowserContext): Promise<void>;

View file

@ -78,9 +78,9 @@ export class Connection extends EventEmitter {
constructor(localUtils: LocalUtils | undefined, instrumentation: ClientInstrumentation | undefined) { constructor(localUtils: LocalUtils | undefined, instrumentation: ClientInstrumentation | undefined) {
super(); super();
this._rootObject = new Root(this);
this._localUtils = localUtils;
this._instrumentation = instrumentation || createInstrumentation(); this._instrumentation = instrumentation || createInstrumentation();
this._localUtils = localUtils;
this._rootObject = new Root(this);
} }
markAsRemote() { markAsRemote() {
@ -138,7 +138,7 @@ export class Connection extends EventEmitter {
this._localUtils?._channel.addStackToTracingNoReply({ callData: { stack: frames, id } }).catch(() => {}); this._localUtils?._channel.addStackToTracingNoReply({ callData: { stack: frames, id } }).catch(() => {});
// We need to exit zones before calling into the server, otherwise // 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. // 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 })); return await new Promise((resolve, reject) => this._callbacks.set(id, { resolve, reject, apiName, type, method }));
} }

View file

@ -820,7 +820,7 @@ export class RouteHandler {
this._times = times; this._times = times;
this.url = url; this.url = url;
this.handler = handler; this.handler = handler;
this._svedZone = zones.currentZone(); this._svedZone = zones.current().without('apiZone');
} }
static prepareInterceptionPatterns(handlers: RouteHandler[]) { static prepareInterceptionPatterns(handlers: RouteHandler[]) {

View file

@ -35,7 +35,7 @@ export class Waiter {
constructor(channelOwner: ChannelOwner<channels.EventTargetChannel>, event: string) { constructor(channelOwner: ChannelOwner<channels.EventTargetChannel>, event: string) {
this._waitId = createGuid(); this._waitId = createGuid();
this._channelOwner = channelOwner; 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._channelOwner._channel.waitForEventInfo({ info: { waitId: this._waitId, phase: 'before', event } }).catch(() => {});
this._dispose = [ this._dispose = [

View file

@ -20,4 +20,4 @@ export type Rect = Size & Point;
export type Quad = [ Point, Point, Point, Point ]; export type Quad = [ Point, Point, Point, Point ];
export type TimeoutOptions = { timeout?: number }; export type TimeoutOptions = { timeout?: number };
export type NameValue = { name: string, value: string }; export type NameValue = { name: string, value: string };
export type HeadersArray = NameValue[]; export type HeadersArray = NameValue[];

View file

@ -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; 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;
} }
} }

View file

@ -41,6 +41,8 @@ export function createInProcessPlaywright(): PlaywrightAPI {
playwrightAPI.firefox._serverLauncher = new BrowserServerLauncherImpl('firefox'); playwrightAPI.firefox._serverLauncher = new BrowserServerLauncherImpl('firefox');
playwrightAPI.webkit._serverLauncher = new BrowserServerLauncherImpl('webkit'); playwrightAPI.webkit._serverLauncher = new BrowserServerLauncherImpl('webkit');
playwrightAPI._android._serverLauncher = new AndroidServerLauncherImpl(); 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. // Switch to async dispatch after we got Playwright object.
dispatcherConnection.onmessage = message => setImmediate(() => clientConnection.dispatch(message)); dispatcherConnection.onmessage = message => setImmediate(() => clientConnection.dispatch(message));

View file

@ -89,13 +89,16 @@ export const commandsWithTracingSnapshots = new Set([
'Page.mouseClick', 'Page.mouseClick',
'Page.mouseWheel', 'Page.mouseWheel',
'Page.touchscreenTap', 'Page.touchscreenTap',
'Page.accessibilitySnapshot',
'Frame.evalOnSelector', 'Frame.evalOnSelector',
'Frame.evalOnSelectorAll', 'Frame.evalOnSelectorAll',
'Frame.addScriptTag', 'Frame.addScriptTag',
'Frame.addStyleTag', 'Frame.addStyleTag',
'Frame.ariaSnapshot',
'Frame.blur', 'Frame.blur',
'Frame.check', 'Frame.check',
'Frame.click', 'Frame.click',
'Frame.content',
'Frame.dragAndDrop', 'Frame.dragAndDrop',
'Frame.dblclick', 'Frame.dblclick',
'Frame.dispatchEvent', 'Frame.dispatchEvent',
@ -116,6 +119,9 @@ export const commandsWithTracingSnapshots = new Set([
'Frame.isVisible', 'Frame.isVisible',
'Frame.isEditable', 'Frame.isEditable',
'Frame.press', 'Frame.press',
'Frame.querySelector',
'Frame.querySelectorAll',
'Frame.queryCount',
'Frame.selectOption', 'Frame.selectOption',
'Frame.setContent', 'Frame.setContent',
'Frame.setInputFiles', 'Frame.setInputFiles',
@ -133,8 +139,10 @@ export const commandsWithTracingSnapshots = new Set([
'ElementHandle.evaluateExpressionHandle', 'ElementHandle.evaluateExpressionHandle',
'ElementHandle.evalOnSelector', 'ElementHandle.evalOnSelector',
'ElementHandle.evalOnSelectorAll', 'ElementHandle.evalOnSelectorAll',
'ElementHandle.boundingBox',
'ElementHandle.check', 'ElementHandle.check',
'ElementHandle.click', 'ElementHandle.click',
'ElementHandle.contentFrame',
'ElementHandle.dblclick', 'ElementHandle.dblclick',
'ElementHandle.dispatchEvent', 'ElementHandle.dispatchEvent',
'ElementHandle.fill', 'ElementHandle.fill',
@ -150,6 +158,8 @@ export const commandsWithTracingSnapshots = new Set([
'ElementHandle.isHidden', 'ElementHandle.isHidden',
'ElementHandle.isVisible', 'ElementHandle.isVisible',
'ElementHandle.press', 'ElementHandle.press',
'ElementHandle.querySelector',
'ElementHandle.querySelectorAll',
'ElementHandle.screenshot', 'ElementHandle.screenshot',
'ElementHandle.scrollIntoViewIfNeeded', 'ElementHandle.scrollIntoViewIfNeeded',
'ElementHandle.selectOption', 'ElementHandle.selectOption',
@ -187,4 +197,4 @@ export const pausesBeforeInputActions = new Set([
'ElementHandle.tap', 'ElementHandle.tap',
'ElementHandle.type', 'ElementHandle.type',
'ElementHandle.uncheck' 'ElementHandle.uncheck'
]); ]);

View file

@ -2752,4 +2752,4 @@ scheme.JsonPipeSendParams = tObject({
}); });
scheme.JsonPipeSendResult = tOptional(tObject({})); scheme.JsonPipeSendResult = tOptional(tObject({}));
scheme.JsonPipeCloseParams = tOptional(tObject({})); scheme.JsonPipeCloseParams = tOptional(tObject({}));
scheme.JsonPipeCloseResult = tOptional(tObject({})); scheme.JsonPipeCloseResult = tOptional(tObject({}));

View file

@ -524,5 +524,3 @@ class ClankBrowserProcess implements BrowserProcess {
await this._browser.close(); await this._browser.close();
} }
} }

View file

@ -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;
}

View file

@ -152,6 +152,9 @@ export class BidiBrowser extends Browser {
continue; continue;
page._session.addFrameBrowsingContext(event.context); page._session.addFrameBrowsingContext(event.context);
page._page._frameManager.frameAttached(event.context, parentFrameId); page._page._frameManager.frameAttached(event.context, parentFrameId);
const frame = page._page._frameManager.frame(event.context);
if (frame)
frame._url = event.url;
return; return;
} }
return; return;
@ -164,6 +167,7 @@ export class BidiBrowser extends Browser {
const session = this._connection.createMainFrameBrowsingContextSession(event.context); const session = this._connection.createMainFrameBrowsingContextSession(event.context);
const opener = event.originalOpener && this._bidiPages.get(event.originalOpener); const opener = event.originalOpener && this._bidiPages.get(event.originalOpener);
const page = new BidiPage(context, session, opener || null); const page = new BidiPage(context, session, opener || null);
page._page.mainFrame()._url = event.url;
this._bidiPages.set(event.context, page); this._bidiPages.set(event.context, page);
} }

View file

@ -103,7 +103,25 @@ export class BidiExecutionContext implements js.ExecutionContextDelegate {
} }
async getProperties(context: js.ExecutionContext, objectId: js.ObjectId): Promise<Map<string, js.JSHandle>> { async getProperties(context: js.ExecutionContext, objectId: js.ObjectId): Promise<Map<string, js.JSHandle>> {
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<string, js.JSHandle>();
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 { createHandle(context: js.ExecutionContext, jsRemoteObject: js.RemoteObject): js.JSHandle {

View file

@ -33,14 +33,14 @@ export class RawKeyboardImpl implements input.RawKeyboard {
async keydown(modifiers: Set<types.KeyboardModifier>, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number, autoRepeat: boolean, text: string | undefined): Promise<void> { async keydown(modifiers: Set<types.KeyboardModifier>, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number, autoRepeat: boolean, text: string | undefined): Promise<void> {
const actions: bidi.Input.KeySourceAction[] = []; const actions: bidi.Input.KeySourceAction[] = [];
actions.push({ type: 'keyDown', value: getBidiKeyValue(key) }); actions.push({ type: 'keyDown', value: getBidiKeyValue(code) });
// TODO: add modifiers? // TODO: add modifiers?
await this._performActions(actions); await this._performActions(actions);
} }
async keyup(modifiers: Set<types.KeyboardModifier>, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number): Promise<void> { async keyup(modifiers: Set<types.KeyboardModifier>, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number): Promise<void> {
const actions: bidi.Input.KeySourceAction[] = []; const actions: bidi.Input.KeySourceAction[] = [];
actions.push({ type: 'keyUp', value: getBidiKeyValue(key) }); actions.push({ type: 'keyUp', value: getBidiKeyValue(code) });
await this._performActions(actions); await this._performActions(actions);
} }

View file

@ -67,9 +67,13 @@ export class BidiNetworkManager {
if (param.intercepts) { if (param.intercepts) {
// We do not support intercepting redirects. // We do not support intercepting redirects.
if (redirectedFrom) { if (redirectedFrom) {
let params = {};
if (redirectedFrom._originalRequestRoute?._alreadyContinuedHeaders)
params = toBidiRequestHeaders(redirectedFrom._originalRequestRoute._alreadyContinuedHeaders ?? []);
this._session.sendMayFail('network.continueRequest', { this._session.sendMayFail('network.continueRequest', {
request: param.request.request, request: param.request.request,
...(redirectedFrom._originalRequestRoute?._alreadyContinuedHeaders || {}), ...params,
}); });
} else { } else {
route = new BidiRouteImpl(this._session, param.request.request); route = new BidiRouteImpl(this._session, param.request.request);
@ -302,11 +306,9 @@ function fromBidiHeaders(bidiHeaders: bidi.Network.Header[]): types.HeadersArray
return result; 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 bidiHeaders = toBidiHeaders(allHeaders);
const cookies = bidiHeaders.filter(h => h.name.toLowerCase() === 'cookie'); return { headers: bidiHeaders };
const headers = bidiHeaders.filter(h => h.name.toLowerCase() !== 'cookie');
return { cookies, headers };
} }
function toBidiResponseHeaders(headers: types.HeadersArray): { cookies: bidi.Network.SetCookieHeader[], headers: bidi.Network.Header[] } { function toBidiResponseHeaders(headers: types.HeadersArray): { cookies: bidi.Network.SetCookieHeader[], headers: bidi.Network.Header[] } {

View file

@ -399,7 +399,7 @@ export class BidiPage implements PageDelegate {
context: this._session.sessionId, context: this._session.sessionId,
format: { format: {
type: `image/${format === 'png' ? 'png' : 'jpeg'}`, type: `image/${format === 'png' ? 'png' : 'jpeg'}`,
quality: quality || 80, quality: quality ? quality / 100 : 0.8,
}, },
origin: documentRect ? 'document' : 'viewport', origin: documentRect ? 'document' : 'viewport',
clip: { clip: {

View file

@ -7,18 +7,18 @@
/* eslint-disable curly */ /* eslint-disable curly */
export const getBidiKeyValue = (key: string) => { export const getBidiKeyValue = (code: string) => {
switch (key) { switch (code) {
case '\r': case '\r':
case '\n': case '\n':
key = 'Enter'; code = 'Enter';
break; break;
} }
// Measures the number of code points rather than UTF-16 code units. // Measures the number of code points rather than UTF-16 code units.
if ([...key].length === 1) { if ([...code].length === 1) {
return key; return code;
} }
switch (key) { switch (code) {
case 'Cancel': case 'Cancel':
return '\uE001'; return '\uE001';
case 'Help': case 'Help':
@ -131,6 +131,8 @@ export const getBidiKeyValue = (key: string) => {
return '\uE052'; return '\uE052';
case 'MetaRight': case 'MetaRight':
return '\uE053'; return '\uE053';
case 'Space':
return ' ';
case 'Digit0': case 'Digit0':
return '0'; return '0';
case 'Digit1': case 'Digit1':
@ -226,6 +228,6 @@ export const getBidiKeyValue = (key: string) => {
case 'Quote': case 'Quote':
return '"'; return '"';
default: default:
throw new Error(`Unknown key: "${key}"`); throw new Error(`Unknown key: "${code}"`);
} }
}; };

View file

@ -314,6 +314,10 @@ export abstract class BrowserContext extends SdkObject {
return this.doSetHTTPCredentials(httpCredentials); return this.doSetHTTPCredentials(httpCredentials);
} }
hasBinding(name: string) {
return this._pageBindings.has(name);
}
async exposeBinding(name: string, needsHandle: boolean, playwrightBinding: frames.FunctionWithSource): Promise<void> { async exposeBinding(name: string, needsHandle: boolean, playwrightBinding: frames.FunctionWithSource): Promise<void> {
if (this._pageBindings.has(name)) if (this._pageBindings.has(name))
throw new Error(`Function "${name}" has been already registered`); 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 || '' }; this._options.httpCredentials = { username, password: password || '' };
} }
async addInitScript(source: string) { async addInitScript(source: string, name?: string) {
const initScript = new InitScript(source); const initScript = new InitScript(source, false /* internal */, name);
this.initScripts.push(initScript); this.initScripts.push(initScript);
await this.doAddInitScript(initScript); await this.doAddInitScript(initScript);
} }

View file

@ -24,10 +24,9 @@ import type { Playwright } from './playwright';
import { Recorder } from './recorder'; import { Recorder } from './recorder';
import { EmptyRecorderApp } from './recorder/recorderApp'; import { EmptyRecorderApp } from './recorder/recorderApp';
import { asLocator, type Language } from '../utils'; import { asLocator, type Language } from '../utils';
import { parseYamlForAriaSnapshot } from './ariaSnapshot'; import { yaml } from '../utilsBundle';
import type { ParsedYaml } from '../utils/isomorphic/ariaSnapshot';
import { parseYamlTemplate } from '../utils/isomorphic/ariaSnapshot';
import { unsafeLocatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser'; import { unsafeLocatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser';
import { parseAriaSnapshotUnsafe } from '../utils/isomorphic/ariaSnapshot';
const internalMetadata = serverSideCallMetadata(); const internalMetadata = serverSideCallMetadata();
@ -40,9 +39,6 @@ export class DebugController extends SdkObject {
SetModeRequested: 'setModeRequested', SetModeRequested: 'setModeRequested',
}; };
private _autoCloseTimer: NodeJS.Timeout | undefined;
// TODO: remove in 1.27
private _autoCloseAllowed = false;
private _trackHierarchyListener: InstrumentationListener | undefined; private _trackHierarchyListener: InstrumentationListener | undefined;
private _playwright: Playwright; private _playwright: Playwright;
_sdkLanguage: Language = 'javascript'; _sdkLanguage: Language = 'javascript';
@ -58,22 +54,18 @@ export class DebugController extends SdkObject {
this._sdkLanguage = sdkLanguage; this._sdkLanguage = sdkLanguage;
} }
setAutoCloseAllowed(allowed: boolean) {
this._autoCloseAllowed = allowed;
}
dispose() { dispose() {
this.setReportStateChanged(false); this.setReportStateChanged(false);
this.setAutoCloseAllowed(false);
} }
setReportStateChanged(enabled: boolean) { setReportStateChanged(enabled: boolean) {
if (enabled && !this._trackHierarchyListener) { if (enabled && !this._trackHierarchyListener) {
this._trackHierarchyListener = { this._trackHierarchyListener = {
onPageOpen: () => this._emitSnapshot(), onPageOpen: () => this._emitSnapshot(false),
onPageClose: () => this._emitSnapshot(), onPageClose: () => this._emitSnapshot(false),
}; };
this._playwright.instrumentation.addListener(this._trackHierarchyListener, null); this._playwright.instrumentation.addListener(this._trackHierarchyListener, null);
this._emitSnapshot(true);
} else if (!enabled && this._trackHierarchyListener) { } else if (!enabled && this._trackHierarchyListener) {
this._playwright.instrumentation.removeListener(this._trackHierarchyListener); this._playwright.instrumentation.removeListener(this._trackHierarchyListener);
this._trackHierarchyListener = undefined; this._trackHierarchyListener = undefined;
@ -102,7 +94,6 @@ export class DebugController extends SdkObject {
recorder.hideHighlightedSelector(); recorder.hideHighlightedSelector();
recorder.setMode('none'); recorder.setMode('none');
} }
this.setAutoCloseEnabled(true);
return; return;
} }
@ -127,37 +118,16 @@ export class DebugController extends SdkObject {
recorder.setOutput(this._codegenId, params.file); recorder.setOutput(this._codegenId, params.file);
recorder.setMode(params.mode); 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 }) { async highlight(params: { selector?: string, ariaTemplate?: string }) {
// Assert parameters validity. // Assert parameters validity.
if (params.selector) if (params.selector)
unsafeLocatorOrSelectorAsSelector(this._sdkLanguage, params.selector, 'data-testid'); unsafeLocatorOrSelectorAsSelector(this._sdkLanguage, params.selector, 'data-testid');
let parsedYaml: ParsedYaml | undefined; const ariaTemplate = params.ariaTemplate ? parseAriaSnapshotUnsafe(yaml, params.ariaTemplate) : undefined;
if (params.ariaTemplate) {
parsedYaml = parseYamlForAriaSnapshot(params.ariaTemplate);
parseYamlTemplate(parsedYaml);
}
for (const recorder of await this._allRecorders()) { for (const recorder of await this._allRecorders()) {
if (parsedYaml) if (ariaTemplate)
recorder.setHighlightedAriaTemplate(parsedYaml); recorder.setHighlightedAriaTemplate(ariaTemplate);
else if (params.selector) else if (params.selector)
recorder.setHighlightedSelector(this._sdkLanguage, 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' }))); await Promise.all(this.allBrowsers().map(browser => browser.close({ reason: 'Close all browsers requested' })));
} }
private _emitSnapshot() { private _emitSnapshot(initial: boolean) {
const browsers = []; const pageCount = this._playwright.allPages().length;
let pageCount = 0; if (initial && !pageCount)
for (const browser of this._playwright.allBrowsers()) { return;
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;
}
}
this.emit(DebugController.Events.StateChanged, { pageCount }); this.emit(DebugController.Events.StateChanged, { pageCount });
} }

View file

@ -134,7 +134,7 @@ function shouldPauseBeforeStep(metadata: CallMetadata): boolean {
// Always stop on 'close' // Always stop on 'close'
if (metadata.method === 'close') if (metadata.method === 'close')
return true; 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. return false; // Never stop on those, primarily for the test harness.
const step = metadata.type + '.' + metadata.method; const step = metadata.type + '.' + metadata.method;
// Stop before everything that generates snapshot. But don't stop before those marked as pausesBeforeInputActions // Stop before everything that generates snapshot. But don't stop before those marked as pausesBeforeInputActions

View file

@ -110,7 +110,7 @@
"defaultBrowserType": "webkit" "defaultBrowserType": "webkit"
}, },
"Galaxy S5": { "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": { "viewport": {
"width": 360, "width": 360,
"height": 640 "height": 640
@ -121,7 +121,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Galaxy S5 landscape": { "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": { "viewport": {
"width": 640, "width": 640,
"height": 360 "height": 360
@ -132,7 +132,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Galaxy S8": { "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": { "viewport": {
"width": 360, "width": 360,
"height": 740 "height": 740
@ -143,7 +143,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Galaxy S8 landscape": { "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": { "viewport": {
"width": 740, "width": 740,
"height": 360 "height": 360
@ -154,7 +154,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Galaxy S9+": { "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": { "viewport": {
"width": 320, "width": 320,
"height": 658 "height": 658
@ -165,7 +165,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Galaxy S9+ landscape": { "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": { "viewport": {
"width": 658, "width": 658,
"height": 320 "height": 320
@ -176,7 +176,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Galaxy Tab S4": { "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": { "viewport": {
"width": 712, "width": 712,
"height": 1138 "height": 1138
@ -187,7 +187,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Galaxy Tab S4 landscape": { "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": { "viewport": {
"width": 1138, "width": 1138,
"height": 712 "height": 712
@ -1098,7 +1098,7 @@
"defaultBrowserType": "webkit" "defaultBrowserType": "webkit"
}, },
"LG Optimus L70": { "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": { "viewport": {
"width": 384, "width": 384,
"height": 640 "height": 640
@ -1109,7 +1109,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"LG Optimus L70 landscape": { "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": { "viewport": {
"width": 640, "width": 640,
"height": 384 "height": 384
@ -1120,7 +1120,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Microsoft Lumia 550": { "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": { "viewport": {
"width": 640, "width": 640,
"height": 360 "height": 360
@ -1131,7 +1131,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Microsoft Lumia 550 landscape": { "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": { "viewport": {
"width": 360, "width": 360,
"height": 640 "height": 640
@ -1142,7 +1142,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Microsoft Lumia 950": { "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": { "viewport": {
"width": 360, "width": 360,
"height": 640 "height": 640
@ -1153,7 +1153,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Microsoft Lumia 950 landscape": { "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": { "viewport": {
"width": 640, "width": 640,
"height": 360 "height": 360
@ -1164,7 +1164,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 10": { "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": { "viewport": {
"width": 800, "width": 800,
"height": 1280 "height": 1280
@ -1175,7 +1175,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 10 landscape": { "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": { "viewport": {
"width": 1280, "width": 1280,
"height": 800 "height": 800
@ -1186,7 +1186,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 4": { "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": { "viewport": {
"width": 384, "width": 384,
"height": 640 "height": 640
@ -1197,7 +1197,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 4 landscape": { "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": { "viewport": {
"width": 640, "width": 640,
"height": 384 "height": 384
@ -1208,7 +1208,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 5": { "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": { "viewport": {
"width": 360, "width": 360,
"height": 640 "height": 640
@ -1219,7 +1219,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 5 landscape": { "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": { "viewport": {
"width": 640, "width": 640,
"height": 360 "height": 360
@ -1230,7 +1230,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 5X": { "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": { "viewport": {
"width": 412, "width": 412,
"height": 732 "height": 732
@ -1241,7 +1241,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 5X landscape": { "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": { "viewport": {
"width": 732, "width": 732,
"height": 412 "height": 412
@ -1252,7 +1252,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 6": { "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": { "viewport": {
"width": 412, "width": 412,
"height": 732 "height": 732
@ -1263,7 +1263,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 6 landscape": { "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": { "viewport": {
"width": 732, "width": 732,
"height": 412 "height": 412
@ -1274,7 +1274,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 6P": { "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": { "viewport": {
"width": 412, "width": 412,
"height": 732 "height": 732
@ -1285,7 +1285,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 6P landscape": { "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": { "viewport": {
"width": 732, "width": 732,
"height": 412 "height": 412
@ -1296,7 +1296,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 7": { "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": { "viewport": {
"width": 600, "width": 600,
"height": 960 "height": 960
@ -1307,7 +1307,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Nexus 7 landscape": { "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": { "viewport": {
"width": 960, "width": 960,
"height": 600 "height": 600
@ -1362,7 +1362,7 @@
"defaultBrowserType": "webkit" "defaultBrowserType": "webkit"
}, },
"Pixel 2": { "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": { "viewport": {
"width": 411, "width": 411,
"height": 731 "height": 731
@ -1373,7 +1373,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 2 landscape": { "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": { "viewport": {
"width": 731, "width": 731,
"height": 411 "height": 411
@ -1384,7 +1384,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 2 XL": { "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": { "viewport": {
"width": 411, "width": 411,
"height": 823 "height": 823
@ -1395,7 +1395,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 2 XL landscape": { "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": { "viewport": {
"width": 823, "width": 823,
"height": 411 "height": 411
@ -1406,7 +1406,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 3": { "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": { "viewport": {
"width": 393, "width": 393,
"height": 786 "height": 786
@ -1417,7 +1417,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 3 landscape": { "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": { "viewport": {
"width": 786, "width": 786,
"height": 393 "height": 393
@ -1428,7 +1428,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 4": { "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": { "viewport": {
"width": 353, "width": 353,
"height": 745 "height": 745
@ -1439,7 +1439,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 4 landscape": { "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": { "viewport": {
"width": 745, "width": 745,
"height": 353 "height": 353
@ -1450,7 +1450,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 4a (5G)": { "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": { "screen": {
"width": 412, "width": 412,
"height": 892 "height": 892
@ -1465,7 +1465,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 4a (5G) landscape": { "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": { "screen": {
"height": 892, "height": 892,
"width": 412 "width": 412
@ -1480,7 +1480,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 5": { "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": { "screen": {
"width": 393, "width": 393,
"height": 851 "height": 851
@ -1495,7 +1495,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 5 landscape": { "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": { "screen": {
"width": 851, "width": 851,
"height": 393 "height": 393
@ -1510,7 +1510,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 7": { "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": { "screen": {
"width": 412, "width": 412,
"height": 915 "height": 915
@ -1525,7 +1525,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Pixel 7 landscape": { "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": { "screen": {
"width": 915, "width": 915,
"height": 412 "height": 412
@ -1540,7 +1540,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Moto G4": { "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": { "viewport": {
"width": 360, "width": 360,
"height": 640 "height": 640
@ -1551,7 +1551,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Moto G4 landscape": { "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": { "viewport": {
"width": 640, "width": 640,
"height": 360 "height": 360
@ -1562,7 +1562,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Desktop Chrome HiDPI": { "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": { "screen": {
"width": 1792, "width": 1792,
"height": 1120 "height": 1120
@ -1577,7 +1577,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Desktop Edge HiDPI": { "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": { "screen": {
"width": 1792, "width": 1792,
"height": 1120 "height": 1120
@ -1592,7 +1592,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Desktop Firefox HiDPI": { "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": { "screen": {
"width": 1792, "width": 1792,
"height": 1120 "height": 1120
@ -1622,7 +1622,7 @@
"defaultBrowserType": "webkit" "defaultBrowserType": "webkit"
}, },
"Desktop Chrome": { "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": { "screen": {
"width": 1920, "width": 1920,
"height": 1080 "height": 1080
@ -1637,7 +1637,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Desktop Edge": { "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": { "screen": {
"width": 1920, "width": 1920,
"height": 1080 "height": 1080
@ -1652,7 +1652,7 @@
"defaultBrowserType": "chromium" "defaultBrowserType": "chromium"
}, },
"Desktop Firefox": { "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": { "screen": {
"width": 1920, "width": 1920,
"height": 1080 "height": 1080

View file

@ -3,5 +3,7 @@
../../generated/ ../../generated/
../../protocol/ ../../protocol/
../../utils/ ../../utils/
../../utils/isomorphic
../../utilsBundle.ts
../../zipBundle.ts ../../zipBundle.ts
../** ../**

View file

@ -288,7 +288,7 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
async setWebSocketInterceptionPatterns(params: channels.PageSetWebSocketInterceptionPatternsParams, metadata: CallMetadata): Promise<void> { async setWebSocketInterceptionPatterns(params: channels.PageSetWebSocketInterceptionPatternsParams, metadata: CallMetadata): Promise<void> {
this._webSocketInterceptionPatterns = params.patterns; this._webSocketInterceptionPatterns = params.patterns;
if (params.patterns.length) if (params.patterns.length)
await WebSocketRouteDispatcher.installIfNeeded(this, this._context); await WebSocketRouteDispatcher.installIfNeeded(this._context);
} }
async storageState(params: channels.BrowserContextStorageStateParams, metadata: CallMetadata): Promise<channels.BrowserContextStorageStateResult> { async storageState(params: channels.BrowserContextStorageStateParams, metadata: CallMetadata): Promise<channels.BrowserContextStorageStateResult> {

View file

@ -26,7 +26,8 @@ import type { CallMetadata } from '../instrumentation';
import type { BrowserContextDispatcher } from './browserContextDispatcher'; import type { BrowserContextDispatcher } from './browserContextDispatcher';
import type { PageDispatcher } from './pageDispatcher'; import type { PageDispatcher } from './pageDispatcher';
import { debugAssert } from '../../utils'; import { debugAssert } from '../../utils';
import { parseAriaSnapshot } from '../ariaSnapshot'; import { parseAriaSnapshotUnsafe } from '../../utils/isomorphic/ariaSnapshot';
import { yaml } from '../../utilsBundle';
export class FrameDispatcher extends Dispatcher<Frame, channels.FrameChannel, BrowserContextDispatcher | PageDispatcher> implements channels.FrameChannel { export class FrameDispatcher extends Dispatcher<Frame, channels.FrameChannel, BrowserContextDispatcher | PageDispatcher> implements channels.FrameChannel {
_type_Frame = true; _type_Frame = true;
@ -261,7 +262,7 @@ export class FrameDispatcher extends Dispatcher<Frame, channels.FrameChannel, Br
metadata.potentiallyClosesScope = true; metadata.potentiallyClosesScope = true;
let expectedValue = params.expectedValue ? parseArgument(params.expectedValue) : undefined; let expectedValue = params.expectedValue ? parseArgument(params.expectedValue) : undefined;
if (params.expression === 'to.match.aria' && expectedValue) if (params.expression === 'to.match.aria' && expectedValue)
expectedValue = parseAriaSnapshot(expectedValue); expectedValue = parseAriaSnapshotUnsafe(yaml, expectedValue);
const result = await this._frame.expect(metadata, params.selector, { ...params, expectedValue }); const result = await this._frame.expect(metadata, params.selector, { ...params, expectedValue });
if (result.received !== undefined) if (result.received !== undefined)
result.received = serializeResult(result.received); result.received = serializeResult(result.received);

View file

@ -191,7 +191,7 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
async setWebSocketInterceptionPatterns(params: channels.PageSetWebSocketInterceptionPatternsParams, metadata: CallMetadata): Promise<void> { async setWebSocketInterceptionPatterns(params: channels.PageSetWebSocketInterceptionPatternsParams, metadata: CallMetadata): Promise<void> {
this._webSocketInterceptionPatterns = params.patterns; this._webSocketInterceptionPatterns = params.patterns;
if (params.patterns.length) if (params.patterns.length)
await WebSocketRouteDispatcher.installIfNeeded(this.parentScope(), this._page); await WebSocketRouteDispatcher.installIfNeeded(this._page);
} }
async expectScreenshot(params: channels.PageExpectScreenshotParams, metadata: CallMetadata): Promise<channels.PageExpectScreenshotResult> { async expectScreenshot(params: channels.PageExpectScreenshotParams, metadata: CallMetadata): Promise<channels.PageExpectScreenshotResult> {

View file

@ -18,7 +18,7 @@ import type { BrowserContext } from '../browserContext';
import type { Frame } from '../frames'; import type { Frame } from '../frames';
import { Page } from '../page'; import { Page } from '../page';
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
import { Dispatcher } from './dispatcher'; import { Dispatcher, existingDispatcher } from './dispatcher';
import { createGuid, urlMatches } from '../../utils'; import { createGuid, urlMatches } from '../../utils';
import { PageDispatcher } from './pageDispatcher'; import { PageDispatcher } from './pageDispatcher';
import type { BrowserContextDispatcher } from './browserContextDispatcher'; import type { BrowserContextDispatcher } from './browserContextDispatcher';
@ -26,9 +26,6 @@ import * as webSocketMockSource from '../../generated/webSocketMockSource';
import type * as ws from '../injected/webSocketMock'; import type * as ws from '../injected/webSocketMock';
import { eventsHelper } from '../../utils/eventsHelper'; 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 { export class WebSocketRouteDispatcher extends Dispatcher<{ guid: string }, channels.WebSocketRouteChannel, PageDispatcher | BrowserContextDispatcher> implements channels.WebSocketRouteChannel {
_type_WebSocketRoute = true; _type_WebSocketRoute = true;
private _id: string; private _id: string;
@ -57,18 +54,18 @@ export class WebSocketRouteDispatcher extends Dispatcher<{ guid: string }, chann
(scope as any)._dispatchEvent('webSocketRoute', { webSocketRoute: this }); (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; const context = target instanceof Page ? target.context() : target;
if (!(context as any)[kBindingInstalledSymbol]) { if (!context.hasBinding(kBindingName)) {
(context as any)[kBindingInstalledSymbol] = true; await context.exposeBinding(kBindingName, false, (source, payload: ws.BindingPayload) => {
await context.exposeBinding('__pwWebSocketBinding', false, (source, payload: ws.BindingPayload) => {
if (payload.type === 'onCreate') { if (payload.type === 'onCreate') {
const pageDispatcher = PageDispatcher.fromNullable(contextDispatcher, source.page); const contextDispatcher = existingDispatcher<BrowserContextDispatcher>(context);
const pageDispatcher = contextDispatcher ? PageDispatcher.fromNullable(contextDispatcher, source.page) : undefined;
let scope: PageDispatcher | BrowserContextDispatcher | undefined; let scope: PageDispatcher | BrowserContextDispatcher | undefined;
if (pageDispatcher && matchesPattern(pageDispatcher, context._options.baseURL, payload.url)) if (pageDispatcher && matchesPattern(pageDispatcher, context._options.baseURL, payload.url))
scope = pageDispatcher; scope = pageDispatcher;
else if (matchesPattern(contextDispatcher, context._options.baseURL, payload.url)) else if (contextDispatcher && matchesPattern(contextDispatcher, context._options.baseURL, payload.url))
scope = contextDispatcher; scope = contextDispatcher;
if (scope) { if (scope) {
new WebSocketRouteDispatcher(scope, payload.id, payload.url, source.frame); 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]) { const kInitScriptName = 'webSocketMockSource';
(target as any)[kInitScriptInstalledSymbol] = true; if (!target.initScripts.find(s => s.name === kInitScriptName)) {
await target.addInitScript(` await target.addInitScript(`
(() => { (() => {
const module = {}; const module = {};
${webSocketMockSource.source} ${webSocketMockSource.source}
(module.exports.inject())(globalThis); (module.exports.inject())(globalThis);
})(); })();
`); `, kInitScriptName);
} }
} }

View file

@ -778,7 +778,9 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async _setChecked(progress: Progress, state: boolean, options: { position?: types.Point } & types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> { async _setChecked(progress: Progress, state: boolean, options: { position?: types.Point } & types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> {
const isChecked = async () => { const isChecked = async () => {
const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'checked'), {}); 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); await this._markAsTargetElement(progress.metadata);
if (await isChecked() === state) if (await isChecked() === state)
@ -913,10 +915,14 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
export function throwRetargetableDOMError<T>(result: T | 'error:notconnected'): T { export function throwRetargetableDOMError<T>(result: T | 'error:notconnected'): T {
if (result === 'error:notconnected') if (result === 'error:notconnected')
throw new Error('Element is not attached to the DOM'); throwElementIsNotAttached();
return result; return result;
} }
export function throwElementIsNotAttached(): never {
throw new Error('Element is not attached to the DOM');
}
export function assertDone(result: 'done'): void { export function assertDone(result: 'done'): void {
// This function converts 'done' to void and ensures typescript catches unhandled errors. // This function converts 'done' to void and ensures typescript catches unhandled errors.
} }

View file

@ -77,4 +77,4 @@ export async function prepareFilesForUpload(frame: Frame, params: channels.Eleme
})); }));
return { localPaths, localDirectory, filePayloads }; return { localPaths, localDirectory, filePayloads };
} }

View file

@ -436,4 +436,3 @@ function toJugglerProxyOptions(proxy: types.ProxySettings) {
// Prefs for quick fixes that didn't make it to the build. // Prefs for quick fixes that didn't make it to the build.
// Should all be moved to `playwright.cfg`. // Should all be moved to `playwright.cfg`.
const kBandaidFirefoxUserPrefs = {}; const kBandaidFirefoxUserPrefs = {};

View file

@ -101,4 +101,3 @@ class JugglerReadyState extends BrowserReadyState {
this._wsEndpoint.resolve(undefined); this._wsEndpoint.resolve(undefined);
} }
} }

View file

@ -518,6 +518,10 @@ export module Protocol {
}|null; }|null;
}; };
export type setViewportSizeReturnValue = void; export type setViewportSizeReturnValue = void;
export type setZoomParameters = {
zoom: number;
};
export type setZoomReturnValue = void;
export type bringToFrontParameters = { export type bringToFrontParameters = {
}; };
export type bringToFrontReturnValue = void; export type bringToFrontReturnValue = void;
@ -1134,6 +1138,7 @@ export module Protocol {
"Page.setFileInputFiles": Page.setFileInputFilesParameters; "Page.setFileInputFiles": Page.setFileInputFilesParameters;
"Page.addBinding": Page.addBindingParameters; "Page.addBinding": Page.addBindingParameters;
"Page.setViewportSize": Page.setViewportSizeParameters; "Page.setViewportSize": Page.setViewportSizeParameters;
"Page.setZoom": Page.setZoomParameters;
"Page.bringToFront": Page.bringToFrontParameters; "Page.bringToFront": Page.bringToFrontParameters;
"Page.setEmulatedMedia": Page.setEmulatedMediaParameters; "Page.setEmulatedMedia": Page.setEmulatedMediaParameters;
"Page.setCacheDisabled": Page.setCacheDisabledParameters; "Page.setCacheDisabled": Page.setCacheDisabledParameters;
@ -1215,6 +1220,7 @@ export module Protocol {
"Page.setFileInputFiles": Page.setFileInputFilesReturnValue; "Page.setFileInputFiles": Page.setFileInputFilesReturnValue;
"Page.addBinding": Page.addBindingReturnValue; "Page.addBinding": Page.addBindingReturnValue;
"Page.setViewportSize": Page.setViewportSizeReturnValue; "Page.setViewportSize": Page.setViewportSizeReturnValue;
"Page.setZoom": Page.setZoomReturnValue;
"Page.bringToFront": Page.bringToFrontReturnValue; "Page.bringToFront": Page.bringToFrontReturnValue;
"Page.setEmulatedMedia": Page.setEmulatedMediaReturnValue; "Page.setEmulatedMedia": Page.setEmulatedMediaReturnValue;
"Page.setCacheDisabled": Page.setCacheDisabledReturnValue; "Page.setCacheDisabled": Page.setCacheDisabledReturnValue;

View file

@ -1301,7 +1301,9 @@ export class Frame extends SdkObject {
const result = await this._callOnElementOnceMatches(metadata, selector, (injected, element, data) => { const result = await this._callOnElementOnceMatches(metadata, selector, (injected, element, data) => {
return injected.elementState(element, data.state); return injected.elementState(element, data.state);
}, { state }, options, scope); }, { 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<boolean> { async isVisible(metadata: CallMetadata, selector: string, options: types.StrictOptions = {}, scope?: dom.ElementHandle): Promise<boolean> {
@ -1319,8 +1321,8 @@ export class Frame extends SdkObject {
return false; return false;
return await resolved.injected.evaluate((injected, { info, root }) => { return await resolved.injected.evaluate((injected, { info, root }) => {
const element = injected.querySelector(info.parsed, root || document, info.strict); const element = injected.querySelector(info.parsed, root || document, info.strict);
const state = element ? injected.elementState(element, 'visible') : false; const state = element ? injected.elementState(element, 'visible') : { matches: false, received: 'error:notconnected' };
return state === 'error:notconnected' ? false : state; return state.matches;
}, { info: resolved.info, root: resolved.frame === this ? scope : undefined }); }, { info: resolved.info, root: resolved.frame === this ? scope : undefined });
} catch (e) { } catch (e) {
if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e) || isSessionClosedError(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 { 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') if (expression === 'to.match.aria')
return received ? received.raw : received; return received ? received.raw : received;
return received; return received;

View file

@ -16,9 +16,9 @@
import * as roleUtils from './roleUtils'; import * as roleUtils from './roleUtils';
import { getElementComputedStyle } from './domUtils'; import { getElementComputedStyle } from './domUtils';
import { escapeRegExp, longestCommonSubstring } from '@isomorphic/stringUtils'; import { escapeRegExp, longestCommonSubstring, normalizeWhiteSpace } from '@isomorphic/stringUtils';
import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from './yaml'; 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 & { export type AriaNode = AriaProps & {
role: AriaRole | 'fragment'; role: AriaRole | 'fragment';
@ -137,7 +137,7 @@ function toAriaNode(element: Element): AriaNode | null {
if (!role || role === 'presentation' || role === 'none') if (!role || role === 'presentation' || role === 'none')
return null; return null;
const name = roleUtils.getElementAccessibleName(element, false) || ''; const name = normalizeWhiteSpace(roleUtils.getElementAccessibleName(element, false) || '');
const result: AriaNode = { role, name, children: [], element }; const result: AriaNode = { role, name, children: [], element };
if (roleUtils.kAriaCheckedRoles.includes(role)) if (roleUtils.kAriaCheckedRoles.includes(role))
@ -170,7 +170,7 @@ function normalizeStringChildren(rootA11yNode: AriaNode) {
const flushChildren = (buffer: string[], normalizedChildren: (AriaNode | string)[]) => { const flushChildren = (buffer: string[], normalizedChildren: (AriaNode | string)[]) => {
if (!buffer.length) if (!buffer.length)
return; return;
const text = normalizeWhitespaceWithin(buffer.join('')).trim(); const text = normalizeWhiteSpace(buffer.join(''));
if (text) if (text)
normalizedChildren.push(text); normalizedChildren.push(text);
buffer.length = 0; buffer.length = 0;
@ -196,16 +196,14 @@ function normalizeStringChildren(rootA11yNode: AriaNode) {
visit(rootA11yNode); visit(rootA11yNode);
} }
const normalizeWhitespaceWithin = (text: string) => text.replace(/[\u200b\s\t\r\n]+/g, ' '); function matchesText(text: string, template: AriaRegex | string | undefined): boolean {
function matchesText(text: string, template: RegExp | string | undefined): boolean {
if (!template) if (!template)
return true; return true;
if (!text) if (!text)
return false; return false;
if (typeof template === 'string') if (typeof template === 'string')
return text === template; return text === template;
return !!text.match(template); return !!text.match(new RegExp(template.pattern));
} }
function matchesTextNode(text: string, template: AriaTemplateTextNode) { 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') if (typeof node === 'string' && template.kind === 'text')
return matchesTextNode(node, template); 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) if (template.role !== 'fragment' && template.role !== node.role)
return false; return false;
if (template.checked !== undefined && template.checked !== node.checked) 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[] { function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll: boolean): AriaNode[] {
const results: AriaNode[] = []; const results: AriaNode[] = [];
const visit = (node: AriaNode | string): boolean => { const visit = (node: AriaNode | string, parent: AriaNode | null): boolean => {
if (matchesNode(node, template, 0)) { if (matchesNode(node, template, 0)) {
results.push(node as AriaNode); const result = typeof node === 'string' ? parent : node;
if (result)
results.push(result);
return !collectAll; return !collectAll;
} }
if (typeof node === 'string') if (typeof node === 'string')
return false; return false;
for (const child of node.children || []) { for (const child of node.children || []) {
if (visit(child)) if (visit(child, node))
return true; return true;
} }
return false; return false;
}; };
visit(root); visit(root, null);
return results; return results;
} }

View file

@ -29,7 +29,7 @@ import type { CSSComplexSelectorList } from '../../utils/isomorphic/cssParser';
import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator'; import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator';
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
import { Highlight } from './highlight'; 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 { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils';
import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import { asLocator } from '../../utils/isomorphic/locatorGenerators';
import type { Language } 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 { matchesAriaTree, getAllByAria, generateAriaTree, renderAriaTree } from './ariaSnapshot';
import type { AriaNode, AriaSnapshot } from './ariaSnapshot'; import type { AriaNode, AriaSnapshot } from './ariaSnapshot';
import type { AriaTemplateNode } from '@isomorphic/ariaSnapshot'; import type { AriaTemplateNode } from '@isomorphic/ariaSnapshot';
import { parseYamlTemplate } from '@isomorphic/ariaSnapshot'; import { parseAriaSnapshot } from '@isomorphic/ariaSnapshot';
export type FrameExpectParams = Omit<channels.FrameExpectParams, 'expectedValue'> & { expectedValue?: any }; export type FrameExpectParams = Omit<channels.FrameExpectParams, 'expectedValue'> & { expectedValue?: any };
export type ElementStateWithoutStable = 'visible' | 'hidden' | 'enabled' | 'disabled' | 'editable' | 'checked' | 'unchecked'; export type ElementState = 'visible' | 'hidden' | 'enabled' | 'disabled' | 'editable' | 'checked' | 'unchecked' | 'indeterminate' | 'stable';
export type ElementState = ElementStateWithoutStable | 'stable'; export type ElementStateWithoutStable = Exclude<ElementState, 'stable'>;
export type ElementStateQueryResult = { matches: boolean, received?: string | 'error:notconnected' };
export type HitTargetInterceptionResult = { export type HitTargetInterceptionResult = {
stop: () => 'done' | { hitTargetDescription: string }; stop: () => 'done' | { hitTargetDescription: string };
@ -85,7 +86,7 @@ export class InjectedScript {
isElementVisible, isElementVisible,
isInsideScope, isInsideScope,
normalizeWhiteSpace, normalizeWhiteSpace,
parseYamlTemplate, parseAriaSnapshot,
}; };
// eslint-disable-next-line no-restricted-globals // 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]') && if (!element.matches('a, input, textarea, button, select, [role=link], [role=button], [role=checkbox], [role=radio]') &&
!(element as any).isContentEditable) { !(element as any).isContentEditable) {
// Go up to the label that might be connected to the input/textarea. // 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; return element;
} }
@ -545,15 +546,15 @@ export class InjectedScript {
if (stableResult === false) if (stableResult === false)
return { missingState: 'stable' }; return { missingState: 'stable' };
if (stableResult === 'error:notconnected') if (stableResult === 'error:notconnected')
return stableResult; return 'error:notconnected';
} }
for (const state of states) { for (const state of states) {
if (state !== 'stable') { if (state !== 'stable') {
const result = this.elementState(node, state); const result = this.elementState(node, state);
if (result === false) if (result.received === 'error:notconnected')
return 'error:notconnected';
if (!result.matches)
return { missingState: state }; return { missingState: state };
if (result === 'error:notconnected')
return result;
} }
} }
} }
@ -608,38 +609,60 @@ export class InjectedScript {
return result; return result;
} }
elementState(node: Node, state: ElementStateWithoutStable): boolean | 'error:notconnected' { elementState(node: Node, state: ElementStateWithoutStable): ElementStateQueryResult {
const element = this.retarget(node, ['stable', 'visible', 'hidden'].includes(state) ? 'none' : 'follow-label'); const element = this.retarget(node, ['visible', 'hidden'].includes(state) ? 'none' : 'follow-label');
if (!element || !element.isConnected) { if (!element || !element.isConnected) {
if (state === 'hidden') if (state === 'hidden')
return true; return { matches: true, received: 'hidden' };
return 'error:notconnected'; return { matches: false, received: 'error:notconnected' };
} }
if (state === 'visible') if (state === 'visible' || state === 'hidden') {
return isElementVisible(element); const visible = isElementVisible(element);
if (state === 'hidden') return {
return !isElementVisible(element); matches: state === 'visible' ? visible : !visible,
received: visible ? 'visible' : 'hidden'
};
}
const disabled = getAriaDisabled(element); if (state === 'disabled' || state === 'enabled') {
if (state === 'disabled') const disabled = getAriaDisabled(element);
return disabled; return {
if (state === 'enabled') matches: state === 'disabled' ? disabled : !disabled,
return !disabled; received: disabled ? 'disabled' : 'enabled'
};
}
if (state === 'editable') { if (state === 'editable') {
const disabled = getAriaDisabled(element);
const readonly = getReadonly(element); const readonly = getReadonly(element);
if (readonly === 'error') if (readonly === 'error')
throw this.createStacklessError('Element is not an <input>, <textarea>, <select> or [contenteditable] and does not have a role allowing [aria-readonly]'); throw this.createStacklessError('Element is not an <input>, <textarea>, <select> or [contenteditable] and does not have a role allowing [aria-readonly]');
return !disabled && !readonly; return {
matches: !disabled && !readonly,
received: disabled ? 'disabled' : readonly ? 'readOnly' : 'editable'
};
} }
if (state === 'checked' || state === 'unchecked') { if (state === 'checked' || state === 'unchecked') {
const need = state === 'checked'; const need = state === 'checked';
const checked = getChecked(element, false); const checked = getCheckedWithoutMixed(element);
if (checked === 'error') if (checked === 'error')
throw this.createStacklessError('Not a checkbox or radio button'); throw this.createStacklessError('Not a checkbox or radio button');
return need === checked; return {
matches: need === checked,
received: checked ? 'checked' : 'unchecked',
};
}
if (state === 'indeterminate') {
const checked = getCheckedAllowMixed(element);
if (checked === 'error')
throw this.createStacklessError('Not a checkbox or radio button');
return {
matches: checked === 'mixed',
received: checked === true ? 'checked' : checked === false ? 'unchecked' : 'mixed',
};
} }
throw this.createStacklessError(`Unexpected element state "${state}"`); throw this.createStacklessError(`Unexpected element state "${state}"`);
} }
@ -996,13 +1019,46 @@ export class InjectedScript {
return { stop }; return { stop };
} }
dispatchEvent(node: Node, type: string, eventInit: Object) { dispatchEvent(node: Node, type: string, eventInitObj: Object) {
let event; let event;
eventInit = { bubbles: true, cancelable: true, composed: true, ...eventInit }; const eventInit: any = { bubbles: true, cancelable: true, composed: true, ...eventInitObj };
switch (eventType.get(type)) { switch (eventType.get(type)) {
case 'mouse': event = new MouseEvent(type, eventInit); break; case 'mouse': event = new MouseEvent(type, eventInit); break;
case 'keyboard': event = new KeyboardEvent(type, eventInit); break; case 'keyboard': event = new KeyboardEvent(type, eventInit); break;
case 'touch': event = new TouchEvent(type, eventInit); break; case 'touch': {
// WebKit does not support Touch constructor, but has deprecated createTouch and createTouchList methods.
if (this._browserName === 'webkit') {
const createTouch = (t: any) => {
if (t instanceof Touch)
return t;
// createTouch does not accept clientX/clientY, so we have to use pageX/pageY.
let pageX = t.pageX;
if (pageX === undefined && t.clientX !== undefined)
pageX = t.clientX + (this.document.scrollingElement?.scrollLeft || 0);
let pageY = t.pageY;
if (pageY === undefined && t.clientY !== undefined)
pageY = t.clientY + (this.document.scrollingElement?.scrollTop || 0);
return (this.document as any).createTouch(this.window, t.target ?? node, t.identifier, pageX, pageY, t.screenX, t.screenY, t.radiusX, t.radiusY, t.rotationAngle, t.force);
};
const createTouchList = (touches: any) => {
if (touches instanceof TouchList || !touches)
return touches;
return (this.document as any).createTouchList(...touches.map(createTouch));
};
eventInit.target ??= node;
eventInit.touches = createTouchList(eventInit.touches);
eventInit.targetTouches = createTouchList(eventInit.targetTouches);
eventInit.changedTouches = createTouchList(eventInit.changedTouches);
event = new TouchEvent(type, eventInit);
} else {
eventInit.target ??= node;
eventInit.touches = eventInit.touches?.map((t: any) => t instanceof Touch ? t : new Touch({ ...t, target: t.target ?? node }));
eventInit.targetTouches = eventInit.targetTouches?.map((t: any) => t instanceof Touch ? t : new Touch({ ...t, target: t.target ?? node }));
eventInit.changedTouches = eventInit.changedTouches?.map((t: any) => t instanceof Touch ? t : new Touch({ ...t, target: t.target ?? node }));
event = new TouchEvent(type, eventInit);
}
break;
}
case 'pointer': event = new PointerEvent(type, eventInit); break; case 'pointer': event = new PointerEvent(type, eventInit); break;
case 'focus': event = new FocusEvent(type, eventInit); break; case 'focus': event = new FocusEvent(type, eventInit); break;
case 'drag': event = new DragEvent(type, eventInit); break; case 'drag': event = new DragEvent(type, eventInit); break;
@ -1213,44 +1269,65 @@ export class InjectedScript {
{ {
// Element state / boolean values. // Element state / boolean values.
let elementState: boolean | 'error:notconnected' | 'error:notcheckbox' | undefined; let result: ElementStateQueryResult | undefined;
if (expression === 'to.have.attribute') { if (expression === 'to.have.attribute') {
elementState = element.hasAttribute(options.expressionArg); const hasAttribute = element.hasAttribute(options.expressionArg);
result = {
matches: hasAttribute,
received: hasAttribute ? 'attribute present' : 'attribute not present',
};
} else if (expression === 'to.be.checked') { } else if (expression === 'to.be.checked') {
elementState = this.elementState(element, 'checked'); const { checked, indeterminate } = options.expectedValue;
} else if (expression === 'to.be.unchecked') { if (indeterminate) {
elementState = this.elementState(element, 'unchecked'); if (checked !== undefined)
throw this.createStacklessError('Can\'t assert indeterminate and checked at the same time');
result = this.elementState(element, 'indeterminate');
} else {
result = this.elementState(element, checked === false ? 'unchecked' : 'checked');
}
} else if (expression === 'to.be.disabled') { } else if (expression === 'to.be.disabled') {
elementState = this.elementState(element, 'disabled'); result = this.elementState(element, 'disabled');
} else if (expression === 'to.be.editable') { } else if (expression === 'to.be.editable') {
elementState = this.elementState(element, 'editable'); result = this.elementState(element, 'editable');
} else if (expression === 'to.be.readonly') { } else if (expression === 'to.be.readonly') {
elementState = !this.elementState(element, 'editable'); result = this.elementState(element, 'editable');
result.matches = !result.matches;
} else if (expression === 'to.be.empty') { } else if (expression === 'to.be.empty') {
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {
elementState = !(element as HTMLInputElement).value; const value = (element as HTMLInputElement).value;
else result = { matches: !value, received: value ? 'notEmpty' : 'empty' };
elementState = !element.textContent?.trim(); } else {
const text = element.textContent?.trim();
result = { matches: !text, received: text ? 'notEmpty' : 'empty' };
}
} else if (expression === 'to.be.enabled') { } else if (expression === 'to.be.enabled') {
elementState = this.elementState(element, 'enabled'); result = this.elementState(element, 'enabled');
} else if (expression === 'to.be.focused') { } else if (expression === 'to.be.focused') {
elementState = this._activelyFocused(element).isFocused; const focused = this._activelyFocused(element).isFocused;
result = {
matches: focused,
received: focused ? 'focused' : 'inactive',
};
} else if (expression === 'to.be.hidden') { } else if (expression === 'to.be.hidden') {
elementState = this.elementState(element, 'hidden'); result = this.elementState(element, 'hidden');
} else if (expression === 'to.be.visible') { } else if (expression === 'to.be.visible') {
elementState = this.elementState(element, 'visible'); result = this.elementState(element, 'visible');
} else if (expression === 'to.be.attached') { } else if (expression === 'to.be.attached') {
elementState = true; result = {
matches: true,
received: 'attached',
};
} else if (expression === 'to.be.detached') { } else if (expression === 'to.be.detached') {
elementState = false; result = {
matches: false,
received: 'attached',
};
} }
if (elementState !== undefined) { if (result) {
if (elementState === 'error:notcheckbox') if (result.received === 'error:notconnected')
throw this.createStacklessError('Element is not a checkbox');
if (elementState === 'error:notconnected')
throw this.createStacklessError('Element is not connected'); throw this.createStacklessError('Element is not connected');
return { received: elementState, matches: elementState }; return result;
} }
} }
@ -1321,6 +1398,8 @@ export class InjectedScript {
received = getElementAccessibleName(element, false /* includeHidden */); received = getElementAccessibleName(element, false /* includeHidden */);
} else if (expression === 'to.have.accessible.description') { } else if (expression === 'to.have.accessible.description') {
received = getElementAccessibleDescription(element, false /* includeHidden */); received = getElementAccessibleDescription(element, false /* includeHidden */);
} else if (expression === 'to.have.accessible.error.message') {
received = getElementAccessibleErrorMessage(element);
} else if (expression === 'to.have.role') { } else if (expression === 'to.have.role') {
received = getAriaRole(element) || ''; received = getAriaRole(element) || '';
} else if (expression === 'to.have.title') { } else if (expression === 'to.have.title') {

File diff suppressed because one or more lines are too long

View file

@ -52,7 +52,7 @@ export class PollingRecorder implements RecorderDelegate {
const pollPeriod = 1000; const pollPeriod = 1000;
if (this._pollRecorderModeTimer) if (this._pollRecorderModeTimer)
clearTimeout(this._pollRecorderModeTimer); clearTimeout(this._pollRecorderModeTimer);
const state = await this._embedder.__pw_recorderState().catch(() => {}); const state = await this._embedder.__pw_recorderState().catch(() => null);
if (!state) { if (!state) {
this._pollRecorderModeTimer = this._recorder.injectedScript.builtinSetTimeout(() => this._pollRecorderMode(), pollPeriod); this._pollRecorderModeTimer = this._recorder.injectedScript.builtinSetTimeout(() => this._pollRecorderMode(), pollPeriod);
return; return;

View file

@ -1146,8 +1146,7 @@ export class Recorder {
const ariaTemplateJSON = JSON.stringify(state.ariaTemplate); const ariaTemplateJSON = JSON.stringify(state.ariaTemplate);
if (this._lastHighlightedAriaTemplateJSON !== ariaTemplateJSON) { if (this._lastHighlightedAriaTemplateJSON !== ariaTemplateJSON) {
this._lastHighlightedAriaTemplateJSON = ariaTemplateJSON; this._lastHighlightedAriaTemplateJSON = ariaTemplateJSON;
const template = state.ariaTemplate ? this.injectedScript.utils.parseYamlTemplate(state.ariaTemplate) : undefined; const elements = state.ariaTemplate ? this.injectedScript.getAllByAria(this.document, state.ariaTemplate) : [];
const elements = template ? this.injectedScript.getAllByAria(this.document, template) : [];
if (elements.length) if (elements.length)
highlight = { elements }; highlight = { elements };
else else

View file

@ -461,6 +461,59 @@ export function getElementAccessibleDescription(element: Element, includeHidden:
return accessibleDescription; return accessibleDescription;
} }
// https://www.w3.org/TR/wai-aria-1.2/#aria-invalid
const kAriaInvalidRoles = ['application', 'checkbox', 'combobox', 'gridcell', 'listbox', 'radiogroup', 'slider', 'spinbutton', 'textbox', 'tree', 'columnheader', 'rowheader', 'searchbox', 'switch', 'treegrid'];
function getAriaInvalid(element: Element): 'false' | 'true' | 'grammar' | 'spelling' {
const role = getAriaRole(element) || '';
if (!role || !kAriaInvalidRoles.includes(role))
return 'false';
const ariaInvalid = element.getAttribute('aria-invalid');
if (!ariaInvalid || ariaInvalid.trim() === '' || ariaInvalid.toLocaleLowerCase() === 'false')
return 'false';
if (ariaInvalid === 'true' || ariaInvalid === 'grammar' || ariaInvalid === 'spelling')
return ariaInvalid;
return 'true';
}
function getValidityInvalid(element: Element) {
if ('validity' in element){
const validity = element.validity as ValidityState | undefined;
return validity?.valid === false;
}
return false;
}
export function getElementAccessibleErrorMessage(element: Element): string {
// SPEC: https://w3c.github.io/aria/#aria-errormessage
//
// TODO: support https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/validationMessage
const cache = cacheAccessibleErrorMessage;
let accessibleErrorMessage = cacheAccessibleErrorMessage?.get(element);
if (accessibleErrorMessage === undefined) {
accessibleErrorMessage = '';
const isAriaInvalid = getAriaInvalid(element) !== 'false';
const isValidityInvalid = getValidityInvalid(element);
if (isAriaInvalid || isValidityInvalid) {
const errorMessageId = element.getAttribute('aria-errormessage');
const errorMessages = getIdRefs(element, errorMessageId);
// Ideally, this should be a separate "embeddedInErrorMessage", but it would follow the exact same rules.
// Relevant vague spec: https://w3c.github.io/core-aam/#ariaErrorMessage.
const parts = errorMessages.map(errorMessage => asFlatString(
getTextAlternativeInternal(errorMessage, {
visitedElements: new Set(),
embeddedInDescribedBy: { element: errorMessage, hidden: isElementHiddenForAria(errorMessage) },
})
));
accessibleErrorMessage = parts.join(' ').trim();
}
cache?.set(element, accessibleErrorMessage);
}
return accessibleErrorMessage;
}
type AccessibleNameOptions = { type AccessibleNameOptions = {
visitedElements: Set<Element>, visitedElements: Set<Element>,
includeHidden?: boolean, includeHidden?: boolean,
@ -841,7 +894,17 @@ export function getAriaChecked(element: Element): boolean | 'mixed' {
const result = getChecked(element, true); const result = getChecked(element, true);
return result === 'error' ? false : result; return result === 'error' ? false : result;
} }
export function getChecked(element: Element, allowMixed: boolean): boolean | 'mixed' | 'error' {
export function getCheckedAllowMixed(element: Element): boolean | 'mixed' | 'error' {
return getChecked(element, true);
}
export function getCheckedWithoutMixed(element: Element): boolean | 'error' {
const result = getChecked(element, false);
return result as boolean | 'error';
}
function getChecked(element: Element, allowMixed: boolean): boolean | 'mixed' | 'error' {
const tagName = elementSafeTagName(element); const tagName = elementSafeTagName(element);
// https://www.w3.org/TR/wai-aria-1.2/#aria-checked // https://www.w3.org/TR/wai-aria-1.2/#aria-checked
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings // https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
@ -972,6 +1035,7 @@ let cacheAccessibleName: Map<Element, string> | undefined;
let cacheAccessibleNameHidden: Map<Element, string> | undefined; let cacheAccessibleNameHidden: Map<Element, string> | undefined;
let cacheAccessibleDescription: Map<Element, string> | undefined; let cacheAccessibleDescription: Map<Element, string> | undefined;
let cacheAccessibleDescriptionHidden: Map<Element, string> | undefined; let cacheAccessibleDescriptionHidden: Map<Element, string> | undefined;
let cacheAccessibleErrorMessage: Map<Element, string> | undefined;
let cacheIsHidden: Map<Element, boolean> | undefined; let cacheIsHidden: Map<Element, boolean> | undefined;
let cachePseudoContentBefore: Map<Element, string> | undefined; let cachePseudoContentBefore: Map<Element, string> | undefined;
let cachePseudoContentAfter: Map<Element, string> | undefined; let cachePseudoContentAfter: Map<Element, string> | undefined;
@ -983,6 +1047,7 @@ export function beginAriaCaches() {
cacheAccessibleNameHidden ??= new Map(); cacheAccessibleNameHidden ??= new Map();
cacheAccessibleDescription ??= new Map(); cacheAccessibleDescription ??= new Map();
cacheAccessibleDescriptionHidden ??= new Map(); cacheAccessibleDescriptionHidden ??= new Map();
cacheAccessibleErrorMessage ??= new Map();
cacheIsHidden ??= new Map(); cacheIsHidden ??= new Map();
cachePseudoContentBefore ??= new Map(); cachePseudoContentBefore ??= new Map();
cachePseudoContentAfter ??= new Map(); cachePseudoContentAfter ??= new Map();
@ -994,6 +1059,7 @@ export function endAriaCaches() {
cacheAccessibleNameHidden = undefined; cacheAccessibleNameHidden = undefined;
cacheAccessibleDescription = undefined; cacheAccessibleDescription = undefined;
cacheAccessibleDescriptionHidden = undefined; cacheAccessibleDescriptionHidden = undefined;
cacheAccessibleErrorMessage = undefined;
cacheIsHidden = undefined; cacheIsHidden = undefined;
cachePseudoContentBefore = undefined; cachePseudoContentBefore = undefined;
cachePseudoContentAfter = undefined; cachePseudoContentAfter = undefined;

View file

@ -58,8 +58,8 @@ function yamlStringNeedsQuotes(str: string): boolean {
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(str)) if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(str))
return true; return true;
// Strings starting with '-' followed by a space need quotes // Strings starting with '-' need quotes
if (/^-\s/.test(str)) if (/^-/.test(str))
return true; return true;
// Strings containing ':' or '\n' followed by a space or at the end need quotes // Strings containing ':' or '\n' followed by a space or at the end need quotes
@ -82,6 +82,10 @@ function yamlStringNeedsQuotes(str: string): boolean {
if (/[{}`]/.test(str)) if (/[{}`]/.test(str))
return true; return true;
// YAML array starts with [
if (/^\[/.test(str))
return true;
// Non-string types recognized by YAML // Non-string types recognized by YAML
if (!isNaN(Number(str)) || ['y', 'n', 'yes', 'no', 'true', 'false', 'on', 'off', 'null'].includes(str.toLowerCase())) if (!isNaN(Number(str)) || ['y', 'n', 'yes', 'no', 'true', 'false', 'on', 'off', 'null'].includes(str.toLowerCase()))
return true; return true;

View file

@ -564,8 +564,8 @@ export class Page extends SdkObject {
await this._delegate.bringToFront(); await this._delegate.bringToFront();
} }
async addInitScript(source: string) { async addInitScript(source: string, name?: string) {
const initScript = new InitScript(source); const initScript = new InitScript(source, false /* internal */, name);
this.initScripts.push(initScript); this.initScripts.push(initScript);
await this._delegate.addInitScript(initScript); await this._delegate.addInitScript(initScript);
} }
@ -953,8 +953,9 @@ function addPageBinding(playwrightBinding: string, bindingName: string, needsHan
export class InitScript { export class InitScript {
readonly source: string; readonly source: string;
readonly internal: boolean; readonly internal: boolean;
readonly name?: string;
constructor(source: string, internal?: boolean) { constructor(source: string, internal?: boolean, name?: string) {
const guid = createGuid(); const guid = createGuid();
this.source = `(() => { this.source = `(() => {
globalThis.__pwInitScripts = globalThis.__pwInitScripts || {}; globalThis.__pwInitScripts = globalThis.__pwInitScripts || {};
@ -965,6 +966,7 @@ export class InitScript {
${source} ${source}
})();`; })();`;
this.internal = !!internal; this.internal = !!internal;
this.name = name;
} }
} }

View file

@ -32,7 +32,7 @@ import type * as actions from '@recorder/actions';
import { buildFullSelector } from '../utils/isomorphic/recorderUtils'; import { buildFullSelector } from '../utils/isomorphic/recorderUtils';
import { stringifySelector } from '../utils/isomorphic/selectorParser'; import { stringifySelector } from '../utils/isomorphic/selectorParser';
import type { Frame } from './frames'; import type { Frame } from './frames';
import type { ParsedYaml } from '@isomorphic/ariaSnapshot'; import type { AriaTemplateNode } from '@isomorphic/ariaSnapshot';
const recorderSymbol = Symbol('recorderSymbol'); const recorderSymbol = Symbol('recorderSymbol');
@ -40,7 +40,7 @@ export class Recorder implements InstrumentationListener, IRecorder {
readonly handleSIGINT: boolean | undefined; readonly handleSIGINT: boolean | undefined;
private _context: BrowserContext; private _context: BrowserContext;
private _mode: Mode; private _mode: Mode;
private _highlightedElement: { selector?: string, ariaTemplate?: ParsedYaml } = {}; private _highlightedElement: { selector?: string, ariaTemplate?: AriaTemplateNode } = {};
private _overlayState: OverlayState = { offsetX: 0 }; private _overlayState: OverlayState = { offsetX: 0 };
private _recorderApp: IRecorderApp | null = null; private _recorderApp: IRecorderApp | null = null;
private _currentCallsMetadata = new Map<CallMetadata, SdkObject>(); private _currentCallsMetadata = new Map<CallMetadata, SdkObject>();
@ -249,7 +249,7 @@ export class Recorder implements InstrumentationListener, IRecorder {
this._refreshOverlay(); this._refreshOverlay();
} }
setHighlightedAriaTemplate(ariaTemplate: ParsedYaml) { setHighlightedAriaTemplate(ariaTemplate: AriaTemplateNode) {
this._highlightedElement = { ariaTemplate }; this._highlightedElement = { ariaTemplate };
this._refreshOverlay(); this._refreshOverlay();
} }

View file

@ -108,7 +108,7 @@ export class ContextRecorder extends EventEmitter {
this._listeners.push(eventsHelper.addEventListener(process, 'exit', () => { this._listeners.push(eventsHelper.addEventListener(process, 'exit', () => {
this._throttledOutputFile?.flush(); this._throttledOutputFile?.flush();
})); }));
this.setEnabled(true); this.setEnabled(params.mode === 'recording');
} }
setOutput(codegenId: string, outputFile?: string) { setOutput(codegenId: string, outputFile?: string) {

View file

@ -87,6 +87,7 @@ export async function performAction(callMetadata: CallMetadata, pageAliases: Map
await mainFrame.expect(callMetadata, selector, { await mainFrame.expect(callMetadata, selector, {
selector, selector,
expression: 'to.be.checked', expression: 'to.be.checked',
expectedValue: { checked: action.checked },
isNot: !action.checked, isNot: !action.checked,
timeout: kActionTimeout, timeout: kActionTimeout,
}); });

View file

@ -21,7 +21,7 @@ import childProcess from 'child_process';
import * as utils from '../../utils'; import * as utils from '../../utils';
import { spawnAsync } from '../../utils/spawnAsync'; import { spawnAsync } from '../../utils/spawnAsync';
import { hostPlatform, isOfficiallySupportedPlatform } from '../../utils/hostPlatform'; import { hostPlatform, isOfficiallySupportedPlatform } from '../../utils/hostPlatform';
import { buildPlaywrightCLICommand } from '.'; import { buildPlaywrightCLICommand, registry } from '.';
import { deps } from './nativeDeps'; import { deps } from './nativeDeps';
import { getPlaywrightVersion } from '../../utils/userAgent'; import { getPlaywrightVersion } from '../../utils/userAgent';
@ -122,12 +122,12 @@ export async function installDependenciesLinux(targets: Set<DependencyGroup>, dr
}); });
} }
export async function validateDependenciesWindows(windowsExeAndDllDirectories: string[]) { export async function validateDependenciesWindows(sdkLanguage: string, windowsExeAndDllDirectories: string[]) {
const directoryPaths = windowsExeAndDllDirectories; const directoryPaths = windowsExeAndDllDirectories;
const lddPaths: string[] = []; const lddPaths: string[] = [];
for (const directoryPath of directoryPaths) for (const directoryPath of directoryPaths)
lddPaths.push(...(await executablesOrSharedLibraries(directoryPath))); lddPaths.push(...(await executablesOrSharedLibraries(directoryPath)));
const allMissingDeps = await Promise.all(lddPaths.map(lddPath => missingFileDependenciesWindows(lddPath))); const allMissingDeps = await Promise.all(lddPaths.map(lddPath => missingFileDependenciesWindows(sdkLanguage, lddPath)));
const missingDeps: Set<string> = new Set(); const missingDeps: Set<string> = new Set();
for (const deps of allMissingDeps) { for (const deps of allMissingDeps) {
for (const dep of deps) for (const dep of deps)
@ -302,8 +302,8 @@ async function executablesOrSharedLibraries(directoryPath: string): Promise<stri
return executablersOrLibraries as string[]; return executablersOrLibraries as string[];
} }
async function missingFileDependenciesWindows(filePath: string): Promise<Array<string>> { async function missingFileDependenciesWindows(sdkLanguage: string, filePath: string): Promise<Array<string>> {
const executable = path.join(__dirname, '..', '..', '..', 'bin', 'PrintDeps.exe'); const executable = registry.findExecutable('winldd')!.executablePathOrDie(sdkLanguage);
const dirname = path.dirname(filePath); const dirname = path.dirname(filePath);
const { stdout, code } = await spawnAsync(executable, [filePath], { const { stdout, code } = await spawnAsync(executable, [filePath], {
cwd: dirname, cwd: dirname,

View file

@ -79,6 +79,11 @@ const EXECUTABLE_PATHS = {
'mac': ['ffmpeg-mac'], 'mac': ['ffmpeg-mac'],
'win': ['ffmpeg-win64.exe'], 'win': ['ffmpeg-win64.exe'],
}, },
'winldd': {
'linux': undefined,
'mac': undefined,
'win': ['PrintDeps.exe'],
},
}; };
type DownloadPaths = Record<HostPlatform, string | undefined>; type DownloadPaths = Record<HostPlatform, string | undefined>;
@ -315,6 +320,35 @@ const DOWNLOAD_PATHS: Record<BrowserName | InternalTool, DownloadPaths> = {
'mac15-arm64': 'builds/ffmpeg/%s/ffmpeg-mac-arm64.tar.br', 'mac15-arm64': 'builds/ffmpeg/%s/ffmpeg-mac-arm64.tar.br',
'win64': 'builds/ffmpeg/%s/ffmpeg-win64.tar.br', 'win64': 'builds/ffmpeg/%s/ffmpeg-win64.tar.br',
}, },
'winldd': {
'<unknown>': undefined,
'ubuntu18.04-x64': undefined,
'ubuntu20.04-x64': undefined,
'ubuntu22.04-x64': undefined,
'ubuntu24.04-x64': undefined,
'ubuntu18.04-arm64': undefined,
'ubuntu20.04-arm64': undefined,
'ubuntu22.04-arm64': undefined,
'ubuntu24.04-arm64': undefined,
'debian11-x64': undefined,
'debian11-arm64': undefined,
'debian12-x64': undefined,
'debian12-arm64': undefined,
'mac10.13': undefined,
'mac10.14': undefined,
'mac10.15': undefined,
'mac11': undefined,
'mac11-arm64': undefined,
'mac12': undefined,
'mac12-arm64': undefined,
'mac13': undefined,
'mac13-arm64': undefined,
'mac14': undefined,
'mac14-arm64': undefined,
'mac15': undefined,
'mac15-arm64': undefined,
'win64': 'builds/winldd/%s/winldd-win64.zip',
},
'android': { 'android': {
'<unknown>': 'builds/android/%s/android.zip', '<unknown>': 'builds/android/%s/android.zip',
'ubuntu18.04-x64': undefined, 'ubuntu18.04-x64': undefined,
@ -442,7 +476,7 @@ function readDescriptors(browsersJSON: BrowsersJSON): BrowsersJSONDescriptor[] {
} }
export type BrowserName = 'chromium' | 'firefox' | 'webkit' | 'bidi'; export type BrowserName = 'chromium' | 'firefox' | 'webkit' | 'bidi';
type InternalTool = 'ffmpeg' | 'firefox-beta' | 'chromium-tip-of-tree' | 'chromium-headless-shell' | 'chromium-tip-of-tree-headless-shell' | 'android'; type InternalTool = 'ffmpeg' | 'winldd' | 'firefox-beta' | 'chromium-tip-of-tree' | 'chromium-headless-shell' | 'chromium-tip-of-tree-headless-shell' | 'android';
type BidiChannel = 'bidi-firefox-stable' | 'bidi-firefox-beta' | 'bidi-firefox-nightly' | 'bidi-chrome-canary' | 'bidi-chrome-stable' | 'bidi-chromium'; type BidiChannel = 'bidi-firefox-stable' | 'bidi-firefox-beta' | 'bidi-firefox-nightly' | 'bidi-chrome-canary' | 'bidi-chrome-stable' | 'bidi-chromium';
type ChromiumChannel = 'chrome' | 'chrome-beta' | 'chrome-dev' | 'chrome-canary' | 'msedge' | 'msedge-beta' | 'msedge-dev' | 'msedge-canary'; type ChromiumChannel = 'chrome' | 'chrome-beta' | 'chrome-dev' | 'chrome-canary' | 'msedge' | 'msedge-beta' | 'msedge-dev' | 'msedge-canary';
const allDownloadable = ['android', 'chromium', 'firefox', 'webkit', 'ffmpeg', 'firefox-beta', 'chromium-tip-of-tree', 'chromium-headless-shell', 'chromium-tip-of-tree-headless-shell']; const allDownloadable = ['android', 'chromium', 'firefox', 'webkit', 'ffmpeg', 'firefox-beta', 'chromium-tip-of-tree', 'chromium-headless-shell', 'chromium-tip-of-tree-headless-shell'];
@ -772,6 +806,22 @@ export class Registry {
_dependencyGroup: 'tools', _dependencyGroup: 'tools',
_isHermeticInstallation: true, _isHermeticInstallation: true,
}); });
const winldd = descriptors.find(d => d.name === 'winldd')!;
const winlddExecutable = findExecutablePath(winldd.dir, 'winldd');
this._executables.push({
type: 'tool',
name: 'winldd',
browserName: undefined,
directory: winldd.dir,
executablePath: () => winlddExecutable,
executablePathOrDie: (sdkLanguage: string) => executablePathOrDie('winldd', winlddExecutable, winldd.installByDefault, sdkLanguage),
installType: process.platform === 'win32' ? 'download-by-default' : 'none',
_validateHostRequirements: () => Promise.resolve(),
downloadURLs: this._downloadURLs(winldd),
_install: () => this._downloadExecutable(winldd, winlddExecutable),
_dependencyGroup: 'tools',
_isHermeticInstallation: true,
});
const android = descriptors.find(d => d.name === 'android')!; const android = descriptors.find(d => d.name === 'android')!;
this._executables.push({ this._executables.push({
type: 'tool', type: 'tool',
@ -944,7 +994,7 @@ export class Registry {
if (os.platform() === 'linux') if (os.platform() === 'linux')
return await validateDependenciesLinux(sdkLanguage, linuxLddDirectories.map(d => path.join(browserDirectory, d)), dlOpenLibraries); return await validateDependenciesLinux(sdkLanguage, linuxLddDirectories.map(d => path.join(browserDirectory, d)), dlOpenLibraries);
if (os.platform() === 'win32' && os.arch() === 'x64') if (os.platform() === 'win32' && os.arch() === 'x64')
return await validateDependenciesWindows(windowsExeAndDllDirectories.map(d => path.join(browserDirectory, d))); return await validateDependenciesWindows(sdkLanguage, windowsExeAndDllDirectories.map(d => path.join(browserDirectory, d)));
} }
async installDeps(executablesToInstallDeps: Executable[], dryRun: boolean) { async installDeps(executablesToInstallDeps: Executable[], dryRun: boolean) {
@ -1268,6 +1318,8 @@ export async function installBrowsersForNpmInstall(browsers: string[]) {
return false; return false;
} }
const executables: Executable[] = []; const executables: Executable[] = [];
if (process.platform === 'win32')
executables.push(registry.findExecutable('winldd')!);
for (const browserName of browsers) { for (const browserName of browsers) {
const executable = registry.findExecutable(browserName); const executable = registry.findExecutable(browserName);
if (!executable || executable.installType === 'none') if (!executable || executable.installType === 'none')

View file

@ -1104,4 +1104,3 @@ deps['debian12-arm64'] = {
...deps['debian12-x64'].lib2package, ...deps['debian12-x64'].lib2package,
}, },
}; };

View file

@ -354,4 +354,4 @@ export function rewriteOpenSSLErrorIfNeeded(error: Error): Error {
'For more details, see https://github.com/openssl/openssl/blob/master/README-PROVIDERS.md#the-legacy-provider', 'For more details, see https://github.com/openssl/openssl/blob/master/README-PROVIDERS.md#the-legacy-provider',
'You could probably modernize the certificate by following the steps at https://github.com/nodejs/node/issues/40672#issuecomment-1243648223', 'You could probably modernize the certificate by following the steps at https://github.com/nodejs/node/issues/40672#issuecomment-1243648223',
].join('\n')); ].join('\n'));
} }

View file

@ -83,4 +83,3 @@ export class SocksInterceptor {
function tChannelForSocks(names: '*' | string[], arg: any, path: string, context: ValidatorContext) { function tChannelForSocks(names: '*' | string[], arg: any, path: string, context: ValidatorContext) {
throw new ValidationError(`${path}: channels are not expected in SocksSupport`); throw new ValidationError(`${path}: channels are not expected in SocksSupport`);
} }

View file

@ -6689,6 +6689,10 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the
* Cookie Same-Site policy. * Cookie Same-Site policy.
*/ */
sameSite: CookieSameSitePolicy; sameSite: CookieSameSitePolicy;
/**
* Cookie partition key. If null and partitioned property is true, then key must be computed.
*/
partitionKey?: string;
} }
/** /**
* Accessibility Node * Accessibility Node
@ -7073,6 +7077,10 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the
*/ */
export type setCookieParameters = { export type setCookieParameters = {
cookie: Cookie; cookie: Cookie;
/**
* If true, then cookie's partition key should be set.
*/
shouldPartition?: boolean;
} }
export type setCookieReturnValue = { export type setCookieReturnValue = {
} }

View file

@ -105,4 +105,4 @@ export class WKProvisionalPage {
assert(!frameTree.frame.parentId); assert(!frameTree.frame.parentId);
this._mainFrameId = frameTree.frame.id; this._mainFrameId = frameTree.frame.id;
} }
} }

View file

@ -24,8 +24,6 @@ export type AriaRole = 'alert' | 'alertdialog' | 'application' | 'article' | 'ba
'spinbutton' | 'status' | 'strong' | 'subscript' | 'superscript' | 'switch' | 'tab' | 'table' | 'tablist' | 'tabpanel' | 'term' | 'textbox' | 'time' | 'timer' | 'spinbutton' | 'status' | 'strong' | 'subscript' | 'superscript' | 'switch' | 'tab' | 'table' | 'tablist' | 'tabpanel' | 'term' | 'textbox' | 'time' | 'timer' |
'toolbar' | 'tooltip' | 'tree' | 'treegrid' | 'treeitem'; 'toolbar' | 'tooltip' | 'tree' | 'treegrid' | 'treeitem';
export type ParsedYaml = Array<any>;
export type AriaProps = { export type AriaProps = {
checked?: boolean | 'mixed'; checked?: boolean | 'mixed';
disabled?: boolean; disabled?: boolean;
@ -35,89 +33,218 @@ export type AriaProps = {
selected?: boolean; selected?: boolean;
}; };
// We pass parsed template between worlds using JSON, make it easy.
export type AriaRegex = { pattern: string };
export type AriaTemplateTextNode = { export type AriaTemplateTextNode = {
kind: 'text'; kind: 'text';
text: RegExp | string; text: AriaRegex | string;
}; };
export type AriaTemplateRoleNode = AriaProps & { export type AriaTemplateRoleNode = AriaProps & {
kind: 'role'; kind: 'role';
role: AriaRole | 'fragment'; role: AriaRole | 'fragment';
name?: RegExp | string; name?: AriaRegex | string;
children?: AriaTemplateNode[]; children?: AriaTemplateNode[];
}; };
export type AriaTemplateNode = AriaTemplateRoleNode | AriaTemplateTextNode; export type AriaTemplateNode = AriaTemplateRoleNode | AriaTemplateTextNode;
export function parseYamlTemplate(fragment: ParsedYaml): AriaTemplateNode { import type * as yamlTypes from 'yaml';
const result: AriaTemplateNode = { kind: 'role', role: 'fragment' };
populateNode(result, fragment); type YamlLibrary = {
if (result.children && result.children.length === 1) parseDocument: typeof yamlTypes.parseDocument;
return result.children[0]; Scalar: typeof yamlTypes.Scalar;
return result; YAMLMap: typeof yamlTypes.YAMLMap;
YAMLSeq: typeof yamlTypes.YAMLSeq;
LineCounter: typeof yamlTypes.LineCounter;
};
type ParsedYamlPosition = { line: number; col: number; };
export type ParsedYamlError = {
message: string;
range: [ParsedYamlPosition, ParsedYamlPosition];
};
export function parseAriaSnapshotUnsafe(yaml: YamlLibrary, text: string): AriaTemplateNode {
const result = parseAriaSnapshot(yaml, text);
if (result.errors.length)
throw new Error(result.errors[0].message);
return result.fragment;
} }
function populateNode(node: AriaTemplateRoleNode, container: ParsedYaml) { export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: yamlTypes.ParseOptions = {}): { fragment: AriaTemplateNode, errors: ParsedYamlError[] } {
for (const object of container) { const lineCounter = new yaml.LineCounter();
if (typeof object === 'string') { const parseOptions: yamlTypes.ParseOptions = {
const childNode = KeyParser.parse(object); keepSourceTokens: true,
node.children = node.children || []; lineCounter,
node.children.push(childNode); ...options,
continue; };
const yamlDoc = yaml.parseDocument(text, parseOptions);
const errors: ParsedYamlError[] = [];
const convertRange = (range: [number, number] | yamlTypes.Range): [ParsedYamlPosition, ParsedYamlPosition] => {
return [lineCounter.linePos(range[0]), lineCounter.linePos(range[1])];
};
const addError = (error: yamlTypes.YAMLError) => {
errors.push({
message: error.message,
range: [lineCounter.linePos(error.pos[0]), lineCounter.linePos(error.pos[1])],
});
};
const convertSeq = (container: AriaTemplateRoleNode, seq: yamlTypes.YAMLSeq) => {
for (const item of seq.items) {
const itemIsString = item instanceof yaml.Scalar && typeof item.value === 'string';
if (itemIsString) {
const childNode = KeyParser.parse(item, parseOptions, errors);
if (childNode) {
container.children = container.children || [];
container.children.push(childNode);
}
continue;
}
const itemIsMap = item instanceof yaml.YAMLMap;
if (itemIsMap) {
convertMap(container, item);
continue;
}
errors.push({
message: 'Sequence items should be strings or maps',
range: convertRange((item as any).range || seq.range),
});
} }
};
for (const key of Object.keys(object)) { const convertMap = (container: AriaTemplateRoleNode, map: yamlTypes.YAMLMap) => {
node.children = node.children || []; for (const entry of map.items) {
const value = object[key]; container.children = container.children || [];
// Key must by a string
if (key === 'text') { const keyIsString = entry.key instanceof yaml.Scalar && typeof entry.key.value === 'string';
node.children.push({ if (!keyIsString) {
kind: 'text', errors.push({
text: valueOrRegex(value) message: 'Only string keys are supported',
range: convertRange((entry.key as any).range || map.range),
}); });
continue; continue;
} }
const childNode = KeyParser.parse(key); const key: yamlTypes.Scalar<string> = entry.key as yamlTypes.Scalar<string>;
if (childNode.kind === 'text') { const value = entry.value;
node.children.push({
// - text: "text"
if (key.value === 'text') {
const valueIsString = value instanceof yaml.Scalar && typeof value.value === 'string';
if (!valueIsString) {
errors.push({
message: 'Text value should be a string',
range: convertRange(((entry.value as any).range || map.range)),
});
continue;
}
container.children.push({
kind: 'text', kind: 'text',
text: valueOrRegex(value) text: valueOrRegex(value.value)
}); });
continue; continue;
} }
if (typeof value === 'string') { // role "name": ...
node.children.push({ const childNode = KeyParser.parse(key, parseOptions, errors);
...childNode, children: [{ if (!childNode)
continue;
// - role "name": "text"
const valueIsScalar = value instanceof yaml.Scalar;
if (valueIsScalar) {
const type = typeof value.value;
if (type !== 'string' && type !== 'number' && type !== 'boolean') {
errors.push({
message: 'Node value should be a string or a sequence',
range: convertRange(((entry.value as any).range || map.range)),
});
continue;
}
container.children.push({
...childNode,
children: [{
kind: 'text', kind: 'text',
text: valueOrRegex(value) text: valueOrRegex(String(value.value))
}] }]
}); });
continue; continue;
} }
node.children.push(childNode); // - role "name":
populateNode(childNode, value); // - child
const valueIsSequence = value instanceof yaml.YAMLSeq ;
if (valueIsSequence) {
convertSeq(childNode, value as yamlTypes.YAMLSeq);
continue;
}
errors.push({
message: 'Map values should be strings or sequences',
range: convertRange((entry.value as any).range || map.range),
});
} }
};
const fragment: AriaTemplateNode = { kind: 'role', role: 'fragment' };
yamlDoc.errors.forEach(addError);
if (errors.length)
return { errors, fragment };
if (!(yamlDoc.contents instanceof yaml.YAMLSeq)) {
errors.push({
message: 'Aria snapshot must be a YAML sequence, elements starting with " -"',
range: yamlDoc.contents ? convertRange(yamlDoc.contents!.range) : [{ line: 0, col: 0 }, { line: 0, col: 0 }],
});
} }
if (errors.length)
return { errors, fragment };
convertSeq(fragment, yamlDoc.contents as yamlTypes.YAMLSeq);
if (errors.length)
return { errors, fragment: emptyFragment };
if (fragment.children?.length === 1)
return { fragment: fragment.children[0], errors };
return { fragment, errors };
} }
const emptyFragment: AriaTemplateRoleNode = { kind: 'role', role: 'fragment' };
function normalizeWhitespace(text: string) { function normalizeWhitespace(text: string) {
return text.replace(/[\r\n\s\t]+/g, ' ').trim(); return text.replace(/[\r\n\s\t]+/g, ' ').trim();
} }
function valueOrRegex(value: string): string | RegExp { export function valueOrRegex(value: string): string | AriaRegex {
return value.startsWith('/') && value.endsWith('/') ? new RegExp(value.slice(1, -1)) : normalizeWhitespace(value); return value.startsWith('/') && value.endsWith('/') && value.length > 1 ? { pattern: value.slice(1, -1) } : normalizeWhitespace(value);
} }
class KeyParser { export class KeyParser {
private _input: string; private _input: string;
private _pos: number; private _pos: number;
private _length: number; private _length: number;
static parse(input: string): AriaTemplateNode { static parse(text: yamlTypes.Scalar<string>, options: yamlTypes.ParseOptions, errors: ParsedYamlError[]): AriaTemplateRoleNode | null {
return new KeyParser(input)._parse(); try {
return new KeyParser(text.value)._parse();
} catch (e) {
if (e instanceof ParserError) {
const message = options.prettyErrors === false ? e.message : e.message + ':\n\n' + text.value + '\n' + ' '.repeat(e.pos) + '^\n';
errors.push({
message,
range: [options.lineCounter!.linePos(text.range![0]), options.lineCounter!.linePos(text.range![0] + e.pos)],
});
return null;
}
throw e;
}
} }
constructor(input: string) { constructor(input: string) {
@ -177,11 +304,11 @@ class KeyParser {
this._throwError('Unterminated string'); this._throwError('Unterminated string');
} }
private _throwError(message: string, pos?: number): never { private _throwError(message: string, offset: number = 0): never {
throw new AriaKeyError(message, this._input, pos || this._pos); throw new ParserError(message, offset || this._pos);
} }
private _readRegex(): string { private _readRegex(): AriaRegex {
let result = ''; let result = '';
let escaped = false; let escaped = false;
let insideClass = false; let insideClass = false;
@ -194,7 +321,7 @@ class KeyParser {
escaped = true; escaped = true;
result += ch; result += ch;
} else if (ch === '/' && !insideClass) { } else if (ch === '/' && !insideClass) {
return result; return { pattern: result };
} else if (ch === '[') { } else if (ch === '[') {
insideClass = true; insideClass = true;
result += ch; result += ch;
@ -208,16 +335,16 @@ class KeyParser {
this._throwError('Unterminated regex'); this._throwError('Unterminated regex');
} }
private _readStringOrRegex(): string | RegExp | null { private _readStringOrRegex(): string | AriaRegex | null {
const ch = this._peek(); const ch = this._peek();
if (ch === '"') { if (ch === '"') {
this._next(); this._next();
return this._readString(); return normalizeWhitespace(this._readString());
} }
if (ch === '/') { if (ch === '/') {
this._next(); this._next();
return new RegExp(this._readRegex()); return this._readRegex();
} }
return null; return null;
@ -253,7 +380,7 @@ class KeyParser {
} }
} }
_parse(): AriaTemplateNode { _parse(): AriaTemplateRoleNode {
this._skipWhitespace(); this._skipWhitespace();
const role = this._readIdentifier('role') as AriaTemplateRoleNode['role']; const role = this._readIdentifier('role') as AriaTemplateRoleNode['role'];
@ -307,18 +434,11 @@ class KeyParser {
} }
} }
export function parseAriaKey(key: string) { export class ParserError extends Error {
return KeyParser.parse(key);
}
export class AriaKeyError extends Error {
readonly shortMessage: string;
readonly pos: number; readonly pos: number;
constructor(message: string, input: string, pos: number) { constructor(message: string, pos: number) {
super(message + ':\n\n' + input + '\n' + ' '.repeat(pos) + '^\n'); super(message);
this.shortMessage = message;
this.pos = pos; this.pos = pos;
this.stack = undefined;
} }
} }

View file

@ -36,7 +36,7 @@ export interface LocatorFactory {
} }
export function asLocator(lang: Language, selector: string, isFrameLocator: boolean = false): string { export function asLocator(lang: Language, selector: string, isFrameLocator: boolean = false): string {
return asLocators(lang, selector, isFrameLocator)[0]; return asLocators(lang, selector, isFrameLocator, 1)[0];
} }
export function asLocators(lang: Language, selector: string, isFrameLocator: boolean = false, maxOutputSize = 20, preferredQuote?: Quote): string[] { export function asLocators(lang: Language, selector: string, isFrameLocator: boolean = false, maxOutputSize = 20, preferredQuote?: Quote): string[] {
@ -220,7 +220,7 @@ function combineTokens(factory: LocatorFactory, tokens: string[][], maxOutputSiz
const visit = (index: number) => { const visit = (index: number) => {
if (index === tokens.length) { if (index === tokens.length) {
result.push(factory.chainLocators(currentTokens)); result.push(factory.chainLocators(currentTokens));
return currentTokens.length < maxOutputSize; return result.length < maxOutputSize;
} }
for (const taken of tokens[index]) { for (const taken of tokens[index]) {
currentTokens[index] = taken; currentTokens[index] = taken;

View file

@ -20,4 +20,4 @@ export function isJsonMimeType(mimeType: string) {
export function isTextualMimeType(mimeType: string) { export function isTextualMimeType(mimeType: string) {
return !!mimeType.match(/^(text\/.*?|application\/(json|(x-)?javascript|xml.*?|ecmascript|graphql|x-www-form-urlencoded)|image\/svg(\+xml)?|application\/.*?(\+json|\+xml))(;\s*charset=.*)?$/); return !!mimeType.match(/^(text\/.*?|application\/(json|(x-)?javascript|xml.*?|ecmascript|graphql|x-www-form-urlencoded)|image\/svg(\+xml)?|application\/.*?(\+json|\+xml))(;\s*charset=.*)?$/);
} }

View file

@ -77,4 +77,3 @@ function parseOSReleaseText(osReleaseText: string): Map<string, string> {
} }
return fields; return fields;
} }

View file

@ -180,7 +180,7 @@ export async function launchProcess(options: LaunchProcessOptions): Promise<Laun
let processClosed = false; let processClosed = false;
let fulfillCleanup = () => {}; let fulfillCleanup = () => {};
const waitForCleanup = new Promise<void>(f => fulfillCleanup = f); const waitForCleanup = new Promise<void>(f => fulfillCleanup = f);
spawnedProcess.once('exit', (exitCode, signal) => { spawnedProcess.once('close', (exitCode, signal) => {
options.log(`[pid=${spawnedProcess.pid}] <process did exit: exitCode=${exitCode}, signal=${signal}>`); options.log(`[pid=${spawnedProcess.pid}] <process did exit: exitCode=${exitCode}, signal=${signal}>`);
processClosed = true; processClosed = true;
gracefullyCloseSet.delete(gracefullyClose); gracefullyCloseSet.delete(gracefullyClose);

View file

@ -63,4 +63,4 @@ export function findRepeatedSubsequences(s: string[]): { sequence: string[]; cou
} }
return result; return result;
} }

View file

@ -19,54 +19,54 @@ import { AsyncLocalStorage } from 'async_hooks';
export type ZoneType = 'apiZone' | 'expectZone' | 'stepZone'; export type ZoneType = 'apiZone' | 'expectZone' | 'stepZone';
class ZoneManager { class ZoneManager {
private readonly _asyncLocalStorage = new AsyncLocalStorage<Zone|undefined>(); private readonly _asyncLocalStorage = new AsyncLocalStorage<Zone | undefined>();
private readonly _emptyZone = Zone.createEmpty(this._asyncLocalStorage);
run<T, R>(type: ZoneType, data: T, func: () => R): R { run<T, R>(type: ZoneType, data: T, func: () => R): R {
const zone = Zone._createWithData(this._asyncLocalStorage, type, data); return this.current().with(type, data).run(func);
return this._asyncLocalStorage.run(zone, func);
} }
zoneData<T>(type: ZoneType): T | undefined { zoneData<T>(type: ZoneType): T | undefined {
const zone = this._asyncLocalStorage.getStore(); return this.current().data(type);
return zone?.get(type);
} }
currentZone(): Zone { current(): Zone {
return this._asyncLocalStorage.getStore() ?? Zone._createEmpty(this._asyncLocalStorage); return this._asyncLocalStorage.getStore() ?? this._emptyZone;
} }
exitZones<R>(func: () => R): R { empty(): Zone {
return this._asyncLocalStorage.run(undefined, func); return this._emptyZone;
} }
} }
export class Zone { export class Zone {
private readonly _asyncLocalStorage: AsyncLocalStorage<Zone | undefined>; private readonly _asyncLocalStorage: AsyncLocalStorage<Zone | undefined>;
private readonly _data: Map<ZoneType, unknown>; private readonly _data: ReadonlyMap<ZoneType, unknown>;
static _createWithData(asyncLocalStorage: AsyncLocalStorage<Zone|undefined>, type: ZoneType, data: unknown) { static createEmpty(asyncLocalStorage: AsyncLocalStorage<Zone | undefined>) {
const store = new Map(asyncLocalStorage.getStore()?._data);
store.set(type, data);
return new Zone(asyncLocalStorage, store);
}
static _createEmpty(asyncLocalStorage: AsyncLocalStorage<Zone|undefined>) {
return new Zone(asyncLocalStorage, new Map()); return new Zone(asyncLocalStorage, new Map());
} }
private constructor(asyncLocalStorage: AsyncLocalStorage<Zone|undefined>, store: Map<ZoneType, unknown>) { private constructor(asyncLocalStorage: AsyncLocalStorage<Zone | undefined>, store: Map<ZoneType, unknown>) {
this._asyncLocalStorage = asyncLocalStorage; this._asyncLocalStorage = asyncLocalStorage;
this._data = store; this._data = store;
} }
run<R>(func: () => R): R { with(type: ZoneType, data: unknown): Zone {
// Reset apiZone and expectZone, but restore stepZone. return new Zone(this._asyncLocalStorage, new Map(this._data).set(type, data));
const entries = [...this._data.entries()].filter(([type]) => (type !== 'apiZone' && type !== 'expectZone'));
const resetZone = new Zone(this._asyncLocalStorage, new Map(entries));
return this._asyncLocalStorage.run(resetZone, func);
} }
get<T>(type: ZoneType): T | undefined { without(type?: ZoneType): Zone {
const data = type ? new Map(this._data) : new Map();
data.delete(type);
return new Zone(this._asyncLocalStorage, data);
}
run<R>(func: () => R): R {
return this._asyncLocalStorage.run(this, func);
}
data<T>(type: ZoneType): T | undefined {
return this._data.get(type) as T | undefined; return this._data.get(type) as T | undefined;
} }
} }

View file

@ -33,6 +33,7 @@ export const program: typeof import('../bundles/utils/node_modules/commander').p
export const progress: typeof import('../bundles/utils/node_modules/@types/progress') = require('./utilsBundleImpl').progress; export const progress: typeof import('../bundles/utils/node_modules/@types/progress') = require('./utilsBundleImpl').progress;
export const SocksProxyAgent: typeof import('../bundles/utils/node_modules/socks-proxy-agent').SocksProxyAgent = require('./utilsBundleImpl').SocksProxyAgent; export const SocksProxyAgent: typeof import('../bundles/utils/node_modules/socks-proxy-agent').SocksProxyAgent = require('./utilsBundleImpl').SocksProxyAgent;
export const yaml: typeof import('../bundles/utils/node_modules/yaml') = require('./utilsBundleImpl').yaml; export const yaml: typeof import('../bundles/utils/node_modules/yaml') = require('./utilsBundleImpl').yaml;
export type { Scalar as YAMLScalar, YAMLSeq, YAMLMap, YAMLError, Range as YAMLRange } from '../bundles/utils/node_modules/yaml';
export const ws: typeof import('../bundles/utils/node_modules/@types/ws') = require('./utilsBundleImpl').ws; export const ws: typeof import('../bundles/utils/node_modules/@types/ws') = require('./utilsBundleImpl').ws;
export const wsServer: typeof import('../bundles/utils/node_modules/@types/ws').WebSocketServer = require('./utilsBundleImpl').wsServer; export const wsServer: typeof import('../bundles/utils/node_modules/@types/ws').WebSocketServer = require('./utilsBundleImpl').wsServer;
export const wsReceiver = require('./utilsBundleImpl').wsReceiver; export const wsReceiver = require('./utilsBundleImpl').wsReceiver;

View file

@ -12915,7 +12915,6 @@ export interface Locator {
* live objects to be passed into the event: * live objects to be passed into the event:
* *
* ```js * ```js
* // Note you can only create DataTransfer in Chromium and Firefox
* const dataTransfer = await page.evaluateHandle(() => new DataTransfer()); * const dataTransfer = await page.evaluateHandle(() => new DataTransfer());
* await locator.dispatchEvent('dragstart', { dataTransfer }); * await locator.dispatchEvent('dragstart', { dataTransfer });
* ``` * ```
@ -13853,18 +13852,22 @@ export interface Locator {
/** /**
* Creates a locator matching all elements that match one or both of the two locators. * Creates a locator matching all elements that match one or both of the two locators.
* *
* Note that when both locators match something, the resulting locator will have multiple matches and violate * Note that when both locators match something, the resulting locator will have multiple matches, potentially causing
* [locator strictness](https://playwright.dev/docs/locators#strictness) guidelines. * a [locator strictness](https://playwright.dev/docs/locators#strictness) violation.
* *
* **Usage** * **Usage**
* *
* Consider a scenario where you'd like to click on a "New email" button, but sometimes a security settings dialog * 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. * shows up instead. In this case, you can wait for either a "New email" button, or a dialog and act accordingly.
* *
* **NOTE** If both "New email" button and security dialog appear on screen, the "or" locator will match both of them,
* possibly throwing the ["strict mode violation" error](https://playwright.dev/docs/locators#strictness). In this case, you can use
* [locator.first()](https://playwright.dev/docs/api/class-locator#locator-first) to only match one of them.
*
* ```js * ```js
* const newEmail = page.getByRole('button', { name: 'New' }); * const newEmail = page.getByRole('button', { name: 'New' });
* const dialog = page.getByText('Confirm security settings'); * const dialog = page.getByText('Confirm security settings');
* await expect(newEmail.or(dialog)).toBeVisible(); * await expect(newEmail.or(dialog).first()).toBeVisible();
* if (await dialog.isVisible()) * if (await dialog.isVisible())
* await page.getByRole('button', { name: 'Dismiss' }).click(); * await page.getByRole('button', { name: 'Dismiss' }).click();
* await newEmail.click(); * await newEmail.click();
@ -14716,7 +14719,7 @@ export interface BrowserType<Unused = {}> {
/** /**
* Browser distribution channel. * Browser distribution channel.
* *
* Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#opt-in-to-new-headless-mode). * Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#chromium-new-headless-mode).
* *
* Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or * Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or
* "msedge-canary" to use branded [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge). * "msedge-canary" to use branded [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge).
@ -15215,7 +15218,7 @@ export interface BrowserType<Unused = {}> {
/** /**
* Browser distribution channel. * Browser distribution channel.
* *
* Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#opt-in-to-new-headless-mode). * Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#chromium-new-headless-mode).
* *
* Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or * Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or
* "msedge-canary" to use branded [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge). * "msedge-canary" to use branded [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge).
@ -16604,11 +16607,6 @@ export interface AndroidDevice {
*/ */
colorScheme?: null|"light"|"dark"|"no-preference"; colorScheme?: null|"light"|"dark"|"no-preference";
/**
* Optional package name to launch instead of default Chrome for Android.
*/
command?: string;
/** /**
* Specify device scale factor (can be thought of as dpr). Defaults to `1`. Learn more about * Specify device scale factor (can be thought of as dpr). Defaults to `1`. Learn more about
* [emulating devices with device scale factor](https://playwright.dev/docs/emulation#devices). * [emulating devices with device scale factor](https://playwright.dev/docs/emulation#devices).
@ -16717,6 +16715,11 @@ export interface AndroidDevice {
*/ */
permissions?: Array<string>; permissions?: Array<string>;
/**
* Optional package name to launch instead of default Chrome for Android.
*/
pkg?: string;
/** /**
* Network proxy settings. * Network proxy settings.
*/ */
@ -21566,7 +21569,7 @@ export interface LaunchOptions {
/** /**
* Browser distribution channel. * Browser distribution channel.
* *
* Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#opt-in-to-new-headless-mode). * Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#chromium-new-headless-mode).
* *
* Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or * Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or
* "msedge-canary" to use branded [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge). * "msedge-canary" to use branded [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge).

View file

@ -18,4 +18,4 @@ import jsxRuntime from './jsx-runtime.js';
export const jsx = jsxRuntime.jsx; export const jsx = jsxRuntime.jsx;
export const jsxs = jsxRuntime.jsxs; export const jsxs = jsxRuntime.jsxs;
export const Fragment = jsxRuntime.Fragment; export const Fragment = jsxRuntime.Fragment;

View file

@ -56,6 +56,7 @@ export class FullConfigInternal {
cliFailOnFlakyTests?: boolean; cliFailOnFlakyTests?: boolean;
cliLastFailed?: boolean; cliLastFailed?: boolean;
testIdMatcher?: Matcher; testIdMatcher?: Matcher;
lastFailedTestIdMatcher?: Matcher;
defineConfigWasUsed = false; defineConfigWasUsed = false;
globalSetups: string[] = []; globalSetups: string[] = [];
@ -298,4 +299,4 @@ const configInternalSymbol = Symbol('configInternalSymbol');
export function getProjectId(project: FullProject): string { export function getProjectId(project: FullProject): string {
return (project as any).__projectId!; return (project as any).__projectId!;
} }

View file

@ -75,6 +75,7 @@ export type AttachmentPayload = {
path?: string; path?: string;
body?: string; body?: string;
contentType: string; contentType: string;
stepId?: string;
}; };
export type TestInfoErrorImpl = TestInfoError & { export type TestInfoErrorImpl = TestInfoError & {

View file

@ -18,12 +18,13 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import type { APIRequestContext, BrowserContext, Browser, BrowserContextOptions, LaunchOptions, Page, Tracing, Video } from 'playwright-core'; import type { APIRequestContext, BrowserContext, Browser, BrowserContextOptions, LaunchOptions, Page, Tracing, Video } from 'playwright-core';
import * as playwrightLibrary from 'playwright-core'; import * as playwrightLibrary from 'playwright-core';
import { createGuid, debugMode, addInternalStackPrefix, isString, asLocator, jsonStringifyForceASCII } from 'playwright-core/lib/utils'; import { createGuid, debugMode, addInternalStackPrefix, isString, asLocator, jsonStringifyForceASCII, zones } from 'playwright-core/lib/utils';
import type { ExpectZone } from 'playwright-core/lib/utils';
import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, VideoMode } from '../types/test'; import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, VideoMode } from '../types/test';
import type { TestInfoImpl, TestStepInternal } from './worker/testInfo'; import type { TestInfoImpl, TestStepInternal } from './worker/testInfo';
import { rootTestType } from './common/testType'; import { rootTestType } from './common/testType';
import type { ContextReuseMode } from './common/config'; import type { ContextReuseMode } from './common/config';
import type { ClientInstrumentation, ClientInstrumentationListener } from '../../playwright-core/src/client/clientInstrumentation'; import type { ApiCallData, ClientInstrumentation, ClientInstrumentationListener } from '../../playwright-core/src/client/clientInstrumentation';
import { currentTestInfo } from './common/globals'; import { currentTestInfo } from './common/globals';
export { expect } from './matchers/expect'; export { expect } from './matchers/expect';
export const _baseTest: TestType<{}, {}> = rootTestType.test; export const _baseTest: TestType<{}, {}> = rootTestType.test;
@ -258,34 +259,43 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
const tracingGroupSteps: TestStepInternal[] = []; const tracingGroupSteps: TestStepInternal[] = [];
const csiListener: ClientInstrumentationListener = { const csiListener: ClientInstrumentationListener = {
onApiCallBegin: (apiName: string, params: Record<string, any>, frames: StackFrame[], userData: any, out: { stepId?: string }) => { onApiCallBegin: (data: ApiCallData) => {
userData.apiName = apiName;
const testInfo = currentTestInfo(); const testInfo = currentTestInfo();
if (!testInfo || apiName.includes('setTestIdAttribute') || apiName === 'tracing.groupEnd') // Some special calls do not get into steps.
if (!testInfo || data.apiName.includes('setTestIdAttribute') || data.apiName === 'tracing.groupEnd')
return; return;
const step = testInfo._addStep({ const expectZone = zones.zoneData<ExpectZone>('expectZone');
location: frames[0] as any, if (expectZone) {
category: 'pw:api', // Display the internal locator._expect call under the name of the enclosing expect call,
title: renderApiCall(apiName, params), // and connect it to the existing expect step.
apiName, data.apiName = expectZone.title;
params, data.stepId = expectZone.stepId;
}, tracingGroupSteps[tracingGroupSteps.length - 1]);
userData.step = step;
out.stepId = step.stepId;
if (apiName === 'tracing.group')
tracingGroupSteps.push(step);
},
onApiCallEnd: (userData: any, error?: Error) => {
// "tracing.group" step will end later, when "tracing.groupEnd" finishes.
if (userData.apiName === 'tracing.group')
return;
if (userData.apiName === 'tracing.groupEnd') {
const step = tracingGroupSteps.pop();
step?.complete({ error });
return; return;
} }
const step = userData.step; // In the general case, create a step for each api call and connect them through the stepId.
step?.complete({ error }); const step = testInfo._addStep({
location: data.frames[0],
category: 'pw:api',
title: renderApiCall(data.apiName, data.params),
apiName: data.apiName,
params: data.params,
}, tracingGroupSteps[tracingGroupSteps.length - 1]);
data.userData = step;
data.stepId = step.stepId;
if (data.apiName === 'tracing.group')
tracingGroupSteps.push(step);
},
onApiCallEnd: (data: ApiCallData) => {
// "tracing.group" step will end later, when "tracing.groupEnd" finishes.
if (data.apiName === 'tracing.group')
return;
if (data.apiName === 'tracing.groupEnd') {
const step = tracingGroupSteps.pop();
step?.complete({ error: data.error });
return;
}
const step = data.userData;
step?.complete({ error: data.error });
}, },
onWillPause: ({ keepTestTimeout }) => { onWillPause: ({ keepTestTimeout }) => {
if (!keepTestTimeout) if (!keepTestTimeout)
@ -441,13 +451,6 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
}, },
}); });
type StackFrame = {
file: string,
line?: number,
column?: number,
function?: string,
};
type ScreenshotOption = PlaywrightWorkerOptions['screenshot'] | undefined; type ScreenshotOption = PlaywrightWorkerOptions['screenshot'] | undefined;
type Playwright = PlaywrightWorkerArgs['playwright']; type Playwright = PlaywrightWorkerArgs['playwright'];

View file

@ -108,6 +108,7 @@ export type JsonTestStepEnd = {
id: string; id: string;
duration: number; duration: number;
error?: reporterTypes.TestError; error?: reporterTypes.TestError;
attachments?: number[]; // index of JsonTestResultEnd.attachments
}; };
export type JsonFullResult = { export type JsonFullResult = {
@ -249,7 +250,7 @@ export class TeleReporterReceiver {
const parentStep = payload.parentStepId ? result._stepMap.get(payload.parentStepId) : undefined; const parentStep = payload.parentStepId ? result._stepMap.get(payload.parentStepId) : undefined;
const location = this._absoluteLocation(payload.location); const location = this._absoluteLocation(payload.location);
const step = new TeleTestStep(payload, parentStep, location); const step = new TeleTestStep(payload, parentStep, location, result);
if (parentStep) if (parentStep)
parentStep.steps.push(step); parentStep.steps.push(step);
else else
@ -262,6 +263,7 @@ export class TeleReporterReceiver {
const test = this._tests.get(testId)!; const test = this._tests.get(testId)!;
const result = test.results.find(r => r._id === resultId)!; const result = test.results.find(r => r._id === resultId)!;
const step = result._stepMap.get(payload.id)!; const step = result._stepMap.get(payload.id)!;
step._endPayload = payload;
step.duration = payload.duration; step.duration = payload.duration;
step.error = payload.error; step.error = payload.error;
this._reporter.onStepEnd?.(test, result, step); this._reporter.onStepEnd?.(test, result, step);
@ -512,15 +514,20 @@ class TeleTestStep implements reporterTypes.TestStep {
parent: reporterTypes.TestStep | undefined; parent: reporterTypes.TestStep | undefined;
duration: number = -1; duration: number = -1;
steps: reporterTypes.TestStep[] = []; steps: reporterTypes.TestStep[] = [];
error: reporterTypes.TestError | undefined;
private _result: TeleTestResult;
_endPayload?: JsonTestStepEnd;
private _startTime: number = 0; private _startTime: number = 0;
constructor(payload: JsonTestStepStart, parentStep: reporterTypes.TestStep | undefined, location: reporterTypes.Location | undefined) { constructor(payload: JsonTestStepStart, parentStep: reporterTypes.TestStep | undefined, location: reporterTypes.Location | undefined, result: TeleTestResult) {
this.title = payload.title; this.title = payload.title;
this.category = payload.category; this.category = payload.category;
this.location = location; this.location = location;
this.parent = parentStep; this.parent = parentStep;
this._startTime = payload.startTime; this._startTime = payload.startTime;
this._result = result;
} }
titlePath() { titlePath() {
@ -535,6 +542,10 @@ class TeleTestStep implements reporterTypes.TestStep {
set startTime(value: Date) { set startTime(value: Date) {
this._startTime = +value; this._startTime = +value;
} }
get attachments() {
return this._endPayload?.attachments?.map(index => this._result.attachments[index]) ?? [];
}
} }
export class TeleTestResult implements reporterTypes.TestResult { export class TeleTestResult implements reporterTypes.TestResult {
@ -550,7 +561,7 @@ export class TeleTestResult implements reporterTypes.TestResult {
errors: reporterTypes.TestResult['errors'] = []; errors: reporterTypes.TestResult['errors'] = [];
error: reporterTypes.TestResult['error']; error: reporterTypes.TestResult['error'];
_stepMap: Map<string, reporterTypes.TestStep> = new Map(); _stepMap = new Map<string, TeleTestStep>();
_id: string; _id: string;
private _startTime: number = 0; private _startTime: number = 0;

View file

@ -35,6 +35,7 @@ import {
toContainText, toContainText,
toHaveAccessibleDescription, toHaveAccessibleDescription,
toHaveAccessibleName, toHaveAccessibleName,
toHaveAccessibleErrorMessage,
toHaveAttribute, toHaveAttribute,
toHaveClass, toHaveClass,
toHaveCount, toHaveCount,
@ -224,6 +225,7 @@ const customAsyncMatchers = {
toContainText, toContainText,
toHaveAccessibleDescription, toHaveAccessibleDescription,
toHaveAccessibleName, toHaveAccessibleName,
toHaveAccessibleErrorMessage,
toHaveAttribute, toHaveAttribute,
toHaveClass, toHaveClass,
toHaveCount, toHaveCount,

View file

@ -42,9 +42,8 @@ export function toBeAttached(
) { ) {
const attached = !options || options.attached === undefined || options.attached; const attached = !options || options.attached === undefined || options.attached;
const expected = attached ? 'attached' : 'detached'; const expected = attached ? 'attached' : 'detached';
const unexpected = attached ? 'detached' : 'attached';
const arg = attached ? '' : '{ attached: false }'; const arg = attached ? '' : '{ attached: false }';
return toBeTruthy.call(this, 'toBeAttached', locator, 'Locator', expected, unexpected, arg, async (isNot, timeout) => { return toBeTruthy.call(this, 'toBeAttached', locator, 'Locator', expected, arg, async (isNot, timeout) => {
return await locator._expect(attached ? 'to.be.attached' : 'to.be.detached', { isNot, timeout }); return await locator._expect(attached ? 'to.be.attached' : 'to.be.detached', { isNot, timeout });
}, options); }, options);
} }
@ -52,14 +51,25 @@ export function toBeAttached(
export function toBeChecked( export function toBeChecked(
this: ExpectMatcherState, this: ExpectMatcherState,
locator: LocatorEx, locator: LocatorEx,
options?: { checked?: boolean, timeout?: number }, options?: { checked?: boolean, indeterminate?: boolean, timeout?: number },
) { ) {
const checked = !options || options.checked === undefined || options.checked; const checked = options?.checked;
const expected = checked ? 'checked' : 'unchecked'; const indeterminate = options?.indeterminate;
const unexpected = checked ? 'unchecked' : 'checked'; const expectedValue = {
const arg = checked ? '' : '{ checked: false }'; checked,
return toBeTruthy.call(this, 'toBeChecked', locator, 'Locator', expected, unexpected, arg, async (isNot, timeout) => { indeterminate,
return await locator._expect(checked ? 'to.be.checked' : 'to.be.unchecked', { isNot, timeout }); };
let expected: string;
let arg: string;
if (options?.indeterminate) {
expected = 'indeterminate';
arg = `{ indeterminate: true }`;
} else {
expected = options?.checked === false ? 'unchecked' : 'checked';
arg = options?.checked === false ? `{ checked: false }` : '';
}
return toBeTruthy.call(this, 'toBeChecked', locator, 'Locator', expected, arg, async (isNot, timeout) => {
return await locator._expect('to.be.checked', { isNot, timeout, expectedValue });
}, options); }, options);
} }
@ -68,7 +78,7 @@ export function toBeDisabled(
locator: LocatorEx, locator: LocatorEx,
options?: { timeout?: number }, options?: { timeout?: number },
) { ) {
return toBeTruthy.call(this, 'toBeDisabled', locator, 'Locator', 'disabled', 'enabled', '', async (isNot, timeout) => { return toBeTruthy.call(this, 'toBeDisabled', locator, 'Locator', 'disabled', '', async (isNot, timeout) => {
return await locator._expect('to.be.disabled', { isNot, timeout }); return await locator._expect('to.be.disabled', { isNot, timeout });
}, options); }, options);
} }
@ -80,9 +90,8 @@ export function toBeEditable(
) { ) {
const editable = !options || options.editable === undefined || options.editable; const editable = !options || options.editable === undefined || options.editable;
const expected = editable ? 'editable' : 'readOnly'; const expected = editable ? 'editable' : 'readOnly';
const unexpected = editable ? 'readOnly' : 'editable';
const arg = editable ? '' : '{ editable: false }'; const arg = editable ? '' : '{ editable: false }';
return toBeTruthy.call(this, 'toBeEditable', locator, 'Locator', expected, unexpected, arg, async (isNot, timeout) => { return toBeTruthy.call(this, 'toBeEditable', locator, 'Locator', expected, arg, async (isNot, timeout) => {
return await locator._expect(editable ? 'to.be.editable' : 'to.be.readonly', { isNot, timeout }); return await locator._expect(editable ? 'to.be.editable' : 'to.be.readonly', { isNot, timeout });
}, options); }, options);
} }
@ -92,7 +101,7 @@ export function toBeEmpty(
locator: LocatorEx, locator: LocatorEx,
options?: { timeout?: number }, options?: { timeout?: number },
) { ) {
return toBeTruthy.call(this, 'toBeEmpty', locator, 'Locator', 'empty', 'notEmpty', '', async (isNot, timeout) => { return toBeTruthy.call(this, 'toBeEmpty', locator, 'Locator', 'empty', '', async (isNot, timeout) => {
return await locator._expect('to.be.empty', { isNot, timeout }); return await locator._expect('to.be.empty', { isNot, timeout });
}, options); }, options);
} }
@ -104,9 +113,8 @@ export function toBeEnabled(
) { ) {
const enabled = !options || options.enabled === undefined || options.enabled; const enabled = !options || options.enabled === undefined || options.enabled;
const expected = enabled ? 'enabled' : 'disabled'; const expected = enabled ? 'enabled' : 'disabled';
const unexpected = enabled ? 'disabled' : 'enabled';
const arg = enabled ? '' : '{ enabled: false }'; const arg = enabled ? '' : '{ enabled: false }';
return toBeTruthy.call(this, 'toBeEnabled', locator, 'Locator', expected, unexpected, arg, async (isNot, timeout) => { return toBeTruthy.call(this, 'toBeEnabled', locator, 'Locator', expected, arg, async (isNot, timeout) => {
return await locator._expect(enabled ? 'to.be.enabled' : 'to.be.disabled', { isNot, timeout }); return await locator._expect(enabled ? 'to.be.enabled' : 'to.be.disabled', { isNot, timeout });
}, options); }, options);
} }
@ -116,7 +124,7 @@ export function toBeFocused(
locator: LocatorEx, locator: LocatorEx,
options?: { timeout?: number }, options?: { timeout?: number },
) { ) {
return toBeTruthy.call(this, 'toBeFocused', locator, 'Locator', 'focused', 'inactive', '', async (isNot, timeout) => { return toBeTruthy.call(this, 'toBeFocused', locator, 'Locator', 'focused', '', async (isNot, timeout) => {
return await locator._expect('to.be.focused', { isNot, timeout }); return await locator._expect('to.be.focused', { isNot, timeout });
}, options); }, options);
} }
@ -126,7 +134,7 @@ export function toBeHidden(
locator: LocatorEx, locator: LocatorEx,
options?: { timeout?: number }, options?: { timeout?: number },
) { ) {
return toBeTruthy.call(this, 'toBeHidden', locator, 'Locator', 'hidden', 'visible', '', async (isNot, timeout) => { return toBeTruthy.call(this, 'toBeHidden', locator, 'Locator', 'hidden', '', async (isNot, timeout) => {
return await locator._expect('to.be.hidden', { isNot, timeout }); return await locator._expect('to.be.hidden', { isNot, timeout });
}, options); }, options);
} }
@ -138,9 +146,8 @@ export function toBeVisible(
) { ) {
const visible = !options || options.visible === undefined || options.visible; const visible = !options || options.visible === undefined || options.visible;
const expected = visible ? 'visible' : 'hidden'; const expected = visible ? 'visible' : 'hidden';
const unexpected = visible ? 'hidden' : 'visible';
const arg = visible ? '' : '{ visible: false }'; const arg = visible ? '' : '{ visible: false }';
return toBeTruthy.call(this, 'toBeVisible', locator, 'Locator', expected, unexpected, arg, async (isNot, timeout) => { return toBeTruthy.call(this, 'toBeVisible', locator, 'Locator', expected, arg, async (isNot, timeout) => {
return await locator._expect(visible ? 'to.be.visible' : 'to.be.hidden', { isNot, timeout }); return await locator._expect(visible ? 'to.be.visible' : 'to.be.hidden', { isNot, timeout });
}, options); }, options);
} }
@ -150,7 +157,7 @@ export function toBeInViewport(
locator: LocatorEx, locator: LocatorEx,
options?: { timeout?: number, ratio?: number }, options?: { timeout?: number, ratio?: number },
) { ) {
return toBeTruthy.call(this, 'toBeInViewport', locator, 'Locator', 'in viewport', 'outside viewport', '', async (isNot, timeout) => { return toBeTruthy.call(this, 'toBeInViewport', locator, 'Locator', 'in viewport', '', async (isNot, timeout) => {
return await locator._expect('to.be.in.viewport', { isNot, expectedNumber: options?.ratio, timeout }); return await locator._expect('to.be.in.viewport', { isNot, expectedNumber: options?.ratio, timeout });
}, options); }, options);
} }
@ -205,6 +212,18 @@ export function toHaveAccessibleName(
} }
} }
export function toHaveAccessibleErrorMessage(
this: ExpectMatcherState,
locator: LocatorEx,
expected: string | RegExp,
options?: { timeout?: number; ignoreCase?: boolean },
) {
return toMatchText.call(this, 'toHaveAccessibleErrorMessage', locator, 'Locator', async (isNot, timeout) => {
const expectedText = serializeExpectedTextValues([expected], { ignoreCase: options?.ignoreCase, normalizeWhiteSpace: true });
return await locator._expect('to.have.accessible.error.message', { expectedText: expectedText, isNot, timeout });
}, expected, options);
}
export function toHaveAttribute( export function toHaveAttribute(
this: ExpectMatcherState, this: ExpectMatcherState,
locator: LocatorEx, locator: LocatorEx,
@ -220,7 +239,7 @@ export function toHaveAttribute(
} }
} }
if (expected === undefined) { if (expected === undefined) {
return toBeTruthy.call(this, 'toHaveAttribute', locator, 'Locator', 'have attribute', 'not have attribute', '', async (isNot, timeout) => { return toBeTruthy.call(this, 'toHaveAttribute', locator, 'Locator', 'have attribute', '', async (isNot, timeout) => {
return await locator._expect('to.have.attribute', { expressionArg: name, isNot, timeout }); return await locator._expect('to.have.attribute', { expressionArg: name, isNot, timeout });
}, options); }, options);
} }

Some files were not shown because too many files have changed in this diff Show more