Merge branch 'main' of https://github.com/microsoft/playwright into startTime-ui-mode-fixes

This commit is contained in:
Adam Gastineau 2024-12-18 12:01:44 -08:00
commit d570b8931d
146 changed files with 2386 additions and 900 deletions

View file

@ -234,6 +234,27 @@ jobs:
env: env:
PWTEST_CHANNEL: chromium-tip-of-tree PWTEST_CHANNEL: chromium-tip-of-tree
chromium_tot_headless_shell:
name: Chromium tip-of-tree headless-shell-${{ matrix.os }}
environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-20.04]
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/run-test
with:
browsers-to-install: chromium-tip-of-tree-headless-shell
command: npm run ctest
bot-name: "chromium-tip-of-tree-headless-shell-${{ matrix.os }}"
flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }}
flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }}
flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }}
env:
PWTEST_CHANNEL: chromium-tip-of-tree-headless-shell
firefox_beta: firefox_beta:
name: Firefox Beta ${{ matrix.os }} name: Firefox Beta ${{ matrix.os }}
environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }}

View file

@ -26,6 +26,16 @@ npm run watch
npx playwright install npx playwright install
``` ```
**Experimental dev mode with Hot Module Replacement for recorder/trace-viewer/UI Mode**
```
PW_HMR=1 npm run watch
PW_HMR=1 npx playwright show-trace
PW_HMR=1 npm run ctest -- --ui
PW_HMR=1 npx playwright codegen
PW_HMR=1 npx playwright show-report
```
Playwright is a multi-package repository that uses npm workspaces. For browser APIs, look at [`packages/playwright-core`](https://github.com/microsoft/playwright/blob/main/packages/playwright-core). For test runner, see [`packages/playwright`](https://github.com/microsoft/playwright/blob/main/packages/playwright). Playwright is a multi-package repository that uses npm workspaces. For browser APIs, look at [`packages/playwright-core`](https://github.com/microsoft/playwright/blob/main/packages/playwright-core). For test runner, see [`packages/playwright`](https://github.com/microsoft/playwright/blob/main/packages/playwright).
Note that some files are generated by the build, so the watch process might override your changes if done in the wrong file. For example, TypeScript types for the API are generated from the [`docs/src`](https://github.com/microsoft/playwright/blob/main/docs/src). Note that some files are generated by the build, so the watch process might override your changes if done in the wrong file. For example, TypeScript types for the API are generated from the [`docs/src`](https://github.com/microsoft/playwright/blob/main/docs/src).
@ -35,7 +45,7 @@ Coding style is fully defined in [.eslintrc](https://github.com/microsoft/playwr
npm run lint npm run lint
``` ```
Comments should be generally avoided. If the code would not be understood without comments, consider re-writing the code to make it self-explanatory. Comments should have an explicit purpose and should improve readability rather than hinder it. If the code would not be understood without comments, consider re-writing the code to make it self-explanatory.
### Write documentation ### Write documentation

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.32-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.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)
## [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,7 +8,7 @@ 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.32<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Chromium <!-- GEN:chromium-version -->132.0.6834.46<!-- 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 -->132.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |

View file

@ -96,7 +96,7 @@ In case this browser is connected to, clears all created contexts belonging to t
browser server. browser server.
:::note :::note
This is similar to force quitting the browser. Therefore, you should call [`method: BrowserContext.close`] on any [BrowserContext]'s you explicitly created earlier with [`method: Browser.newContext`] **before** calling [`method: Browser.close`]. This is similar to force-quitting the browser. To close pages gracefully and ensure you receive page close events, call [`method: BrowserContext.close`] on any [BrowserContext] instances you explicitly created earlier using [`method: Browser.newContext`] **before** calling [`method: Browser.close`].
::: :::
The [Browser] object itself is considered to be disposed and cannot be used anymore. The [Browser] object itself is considered to be disposed and cannot be used anymore.

View file

@ -1412,7 +1412,7 @@ This setting will change the default maximum time for all the methods accepting
* since: v1.8 * since: v1.8
- `timeout` <[float]> - `timeout` <[float]>
Maximum time in milliseconds Maximum time in milliseconds. Pass `0` to disable timeout.
## async method: BrowserContext.setExtraHTTPHeaders ## async method: BrowserContext.setExtraHTTPHeaders
* since: v1.8 * since: v1.8

View file

@ -161,6 +161,41 @@ await page.Clock.PauseAtAsync(DateTime.Parse("2020-02-02"));
await page.Clock.PauseAtAsync("2020-02-02"); await page.Clock.PauseAtAsync("2020-02-02");
``` ```
For best results, install the clock before navigating the page and set it to a time slightly before the intended test time. This ensures that all timers run normally during page loading, preventing the page from getting stuck. Once the page has fully loaded, you can safely use [`method: Clock.pauseAt`] to pause the clock.
```js
// Initialize clock with some time before the test time and let the page load
// naturally. `Date.now` will progress as the timers fire.
await page.clock.install({ time: new Date('2024-12-10T08:00:00') });
await page.goto('http://localhost:3333');
await page.clock.pauseAt(new Date('2024-12-10T10:00:00'));
```
```python async
# Initialize clock with some time before the test time and let the page load
# naturally. `Date.now` will progress as the timers fire.
await page.clock.install(time=datetime.datetime(2024, 12, 10, 8, 0, 0))
await page.goto("http://localhost:3333")
await page.clock.pause_at(datetime.datetime(2024, 12, 10, 10, 0, 0))
```
```python sync
# Initialize clock with some time before the test time and let the page load
# naturally. `Date.now` will progress as the timers fire.
page.clock.install(time=datetime.datetime(2024, 12, 10, 8, 0, 0))
page.goto("http://localhost:3333")
page.clock.pause_at(datetime.datetime(2024, 12, 10, 10, 0, 0))
```
```java
// Initialize clock with some time before the test time and let the page load
// naturally. `Date.now` will progress as the timers fire.
SimpleDateFormat format = new SimpleDateFormat("yyy-MM-dd'T'HH:mm:ss");
page.clock().install(new Clock.InstallOptions().setTime(format.parse("2024-12-10T08:00:00")));
page.navigate("http://localhost:3333");
page.clock().pauseAt(format.parse("2024-12-10T10:00:00"));
```
### param: Clock.pauseAt.time ### param: Clock.pauseAt.time
* langs: js, java * langs: js, java
* since: v1.45 * since: v1.45

View file

@ -155,7 +155,7 @@ Additional locator to match.
- returns: <[string]> - returns: <[string]>
Captures the aria snapshot of the given element. Captures the aria snapshot of the given element.
Read more about [aria snapshots](../aria-snapshots.md) and [`method: LocatorAssertions.toMatchAriaSnapshot`] for the corresponding assertion. Read more about [aria snapshots](../aria-snapshots.md) and [`method: LocatorAssertions.toMatchAriaSnapshot#2`] for the corresponding assertion.
**Usage** **Usage**

View file

@ -446,7 +446,7 @@ Expected options currently selected.
* since: v1.49 * since: v1.49
* langs: python * langs: python
The opposite of [`method: LocatorAssertions.toMatchAriaSnapshot`]. The opposite of [`method: LocatorAssertions.toMatchAriaSnapshot#2`].
### param: LocatorAssertions.NotToMatchAriaSnapshot.expected ### param: LocatorAssertions.NotToMatchAriaSnapshot.expected
* since: v1.49 * since: v1.49
@ -2121,7 +2121,58 @@ Expected options currently selected.
* since: v1.23 * since: v1.23
## async method: LocatorAssertions.toMatchAriaSnapshot ## async method: LocatorAssertions.toMatchAriaSnapshot#1
* since: v1.50
* langs:
- alias-java: matchesAriaSnapshot
Asserts that the target element matches the given [accessibility snapshot](../aria-snapshots.md).
**Usage**
```js
await expect(page.locator('body')).toMatchAriaSnapshot();
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'snapshot' });
await expect(page.locator('body')).toMatchAriaSnapshot({ path: '/path/to/snapshot.yml' });
```
```python async
await expect(page.locator('body')).to_match_aria_snapshot(path='/path/to/snapshot.yml')
```
```python sync
expect(page.locator('body')).to_match_aria_snapshot(path='/path/to/snapshot.yml')
```
```csharp
await Expect(page.Locator("body")).ToMatchAriaSnapshotAsync(new { Path = "/path/to/snapshot.yml" });
```
```java
assertThat(page.locator("body")).matchesAriaSnapshot(new LocatorAssertions.MatchesAriaSnapshotOptions().setPath("/path/to/snapshot.yml"));
```
### option: LocatorAssertions.toMatchAriaSnapshot#1.name
* since: v1.50
* langs: js
- `name` <[string]>
Name of the snapshot to store in the snapshot folder corresponding to this test. Generates ordinal name if not specified.
### option: LocatorAssertions.toMatchAriaSnapshot#1.path
* since: v1.50
- `path` <[string]>
Path to the YAML snapshot file.
### option: LocatorAssertions.toMatchAriaSnapshot#1.timeout = %%-js-assertions-timeout-%%
* since: v1.50
### option: LocatorAssertions.toMatchAriaSnapshot#1.timeout = %%-csharp-java-python-assertions-timeout-%%
* since: v1.50
## async method: LocatorAssertions.toMatchAriaSnapshot#2
* since: v1.49 * since: v1.49
* langs: * langs:
- alias-java: matchesAriaSnapshot - alias-java: matchesAriaSnapshot
@ -2170,12 +2221,12 @@ assertThat(page.locator("body")).matchesAriaSnapshot("""
"""); """);
``` ```
### param: LocatorAssertions.toMatchAriaSnapshot.expected ### param: LocatorAssertions.toMatchAriaSnapshot#2.expected
* since: v1.49 * since: v1.49
- `expected` <string> - `expected` <string>
### option: LocatorAssertions.toMatchAriaSnapshot.timeout = %%-js-assertions-timeout-%% ### option: LocatorAssertions.toMatchAriaSnapshot#2.timeout = %%-js-assertions-timeout-%%
* since: v1.49 * since: v1.49
### option: LocatorAssertions.toMatchAriaSnapshot.timeout = %%-csharp-java-python-assertions-timeout-%% ### option: LocatorAssertions.toMatchAriaSnapshot#2.timeout = %%-csharp-java-python-assertions-timeout-%%
* since: v1.49 * since: v1.49

View file

@ -3970,7 +3970,7 @@ This setting will change the default maximum time for all the methods accepting
* since: v1.8 * since: v1.8
- `timeout` <[float]> - `timeout` <[float]>
Maximum time in milliseconds Maximum time in milliseconds. Pass `0` to disable timeout.
## async method: Page.setExtraHTTPHeaders ## async method: Page.setExtraHTTPHeaders
* since: v1.8 * since: v1.8

View file

@ -154,7 +154,7 @@ structure of a page, use the [Chrome DevTools Accessibility Pane](https://develo
## Snapshot matching ## Snapshot matching
The [`method: LocatorAssertions.toMatchAriaSnapshot`] assertion method in Playwright compares the accessible The [`method: LocatorAssertions.toMatchAriaSnapshot#2`] assertion method in Playwright compares the accessible
structure of the locator scope with a predefined aria snapshot template, helping validate the page's state against structure of the locator scope with a predefined aria snapshot template, helping validate the page's state against
testing requirements. testing requirements.

View file

@ -342,7 +342,7 @@ Playwright ships a regular Chromium build for headed operations and a separate [
#### Optimize download size on CI #### Optimize download size on CI
If you are only running tests in headless mode, for example on CI, you can avoid downloading a regular version of Chromium by passing `--only-shell` during installation. If you are only running tests in headless shell (i.e. the `channel` option is not specified), for example on CI, you can avoid downloading the full Chromium browser by passing `--only-shell` during installation.
```bash js ```bash js
# only running tests headlessly # only running tests headlessly

View file

@ -34,6 +34,10 @@ The recommended approach is to use `setFixedTime` to set the time to a specific
- `Event.timeStamp` - `Event.timeStamp`
::: :::
:::warning
If you call `install` at any point in your test, the call _MUST_ occur before any other clock related calls (see note above for list). Calling these methods out of order will result in undefined behavior. For example, you cannot call `setInterval`, followed by `install`, then `clearInterval`, as `install` overrides the native definition of the clock functions.
:::
## Test with predefined time ## Test with predefined time
Often you only need to fake `Date.now` while keeping the timers going. Often you only need to fake `Date.now` while keeping the timers going.

View file

@ -103,6 +103,88 @@ Using `--ipc=host` is recommended when using Chrome ([Docker docs](https://docs.
See our [Continuous Integration guides](./ci.md) for sample configs. See our [Continuous Integration guides](./ci.md) for sample configs.
### Remote Connection
You can run Playwright Server in Docker while keeping your tests running on the host system or another machine. This is useful for running tests on unsupported Linux distributions or remote execution scenarios.
#### Running the Playwright Server
Start the Playwright Server in Docker:
```bash
docker run -p 3000:3000 --rm --init -it --workdir /home/pwuser --user pwuser mcr.microsoft.com/playwright:v%%VERSION%%-noble /bin/sh -c "npx -y playwright@%%VERSION%% run-server --port 3000 --host 0.0.0.0"
```
#### Connecting to the Server
* langs: js
There are two ways to connect to the remote Playwright server:
1. Using environment variable with `@playwright/test`:
```bash
PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:3000/ npx playwright test
```
2. Using the [`method: BrowserType.connect`] API for other applications:
```js
const browser = await playwright['chromium'].connect('ws://127.0.0.1:3000/');
```
#### Connecting to the Server
* langs: python, csharp, java
```python sync
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.connect("ws://127.0.0.1:3000/")
```
```python async
from playwright.async_api import async_playwright
async with async_playwright() as p:
browser = await p.chromium.connect("ws://127.0.0.1:3000/")
```
```csharp
using Microsoft.Playwright;
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.ConnectAsync("ws://127.0.0.1:3000/");
```
```java
package org.example;
import com.microsoft.playwright.*;
import java.nio.file.Paths;
public class App {
public static void main(String[] args) {
try (Playwright playwright = Playwright.create()) {
Browser browser = playwright.chromium().connect("ws://127.0.0.1:3000/");
}
}
}
```
#### Network Configuration
If you need to access local servers from within the Docker container:
```bash
docker run --add-host=hostmachine:host-gateway -p 3000:3000 --rm --init -it --workdir /home/pwuser --user pwuser mcr.microsoft.com/playwright:v%%VERSION%%-noble /bin/sh -c "npx -y playwright@%%VERSION%% run-server --port 3000 --host 0.0.0.0"
```
This makes `hostmachine` point to the host's localhost. Your tests should use `hostmachine` instead of `localhost` when accessing local servers.
:::note
When running tests remotely, ensure the Playwright version in your tests matches the version running in the Docker container.
:::
## Image tags ## Image tags
See [all available image tags]. See [all available image tags].

View file

@ -9,7 +9,7 @@ toc_max_heading_level: 2
### Aria snapshots ### Aria snapshots
New assertion [`method: LocatorAssertions.toMatchAriaSnapshot`] verifies page structure by comparing to an expected accessibility tree, represented as YAML. New assertion [`method: LocatorAssertions.toMatchAriaSnapshot#2`] verifies page structure by comparing to an expected accessibility tree, represented as YAML.
```csharp ```csharp
await page.GotoAsync("https://playwright.dev"); await page.GotoAsync("https://playwright.dev");

View file

@ -8,7 +8,7 @@ toc_max_heading_level: 2
### Aria snapshots ### Aria snapshots
New assertion [`method: LocatorAssertions.toMatchAriaSnapshot`] verifies page structure by comparing to an expected accessibility tree, represented as YAML. New assertion [`method: LocatorAssertions.toMatchAriaSnapshot#2`] verifies page structure by comparing to an expected accessibility tree, represented as YAML.
```java ```java
page.navigate("https://playwright.dev"); page.navigate("https://playwright.dev");

View file

@ -15,7 +15,7 @@ import LiteYouTube from '@site/src/components/LiteYouTube';
### Aria snapshots ### Aria snapshots
New assertion [`method: LocatorAssertions.toMatchAriaSnapshot`] verifies page structure by comparing to an expected accessibility tree, represented as YAML. New assertion [`method: LocatorAssertions.toMatchAriaSnapshot#2`] verifies page structure by comparing to an expected accessibility tree, represented as YAML.
```js ```js
await page.goto('https://playwright.dev'); await page.goto('https://playwright.dev');

View file

@ -8,7 +8,7 @@ toc_max_heading_level: 2
### Aria snapshots ### Aria snapshots
New assertion [`method: LocatorAssertions.toMatchAriaSnapshot`] verifies page structure by comparing to an expected accessibility tree, represented as YAML. New assertion [`method: LocatorAssertions.toMatchAriaSnapshot#2`] verifies page structure by comparing to an expected accessibility tree, represented as YAML.
```python ```python
page.goto("https://playwright.dev") page.goto("https://playwright.dev")

View file

@ -1773,6 +1773,112 @@ Specifies a custom location for the step to be shown in test reports and trace v
Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout). Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout).
## async method: Test.step.fail
* since: v1.50
- returns: <[void]>
Marks a test step as "should fail". Playwright runs this test step and ensures that it actually fails. This is useful for documentation purposes to acknowledge that some functionality is broken until it is fixed.
:::note
If the step exceeds the timeout, a [TimeoutError] is thrown. This indicates the step did not fail as expected.
:::
**Usage**
You can declare a test step as failing, so that Playwright ensures it actually fails.
```js
import { test, expect } from '@playwright/test';
test('my test', async ({ page }) => {
// ...
await test.step.fail('currently failing', async () => {
// ...
});
});
```
### param: Test.step.fail.title
* since: v1.50
- `title` <[string]>
Step name.
### param: Test.step.fail.body
* since: v1.50
- `body` <[function]\(\):[Promise]<[any]>>
Step body.
### option: Test.step.fail.box
* since: v1.50
- `box` <boolean>
Whether to box the step in the report. Defaults to `false`. When the step is boxed, errors thrown from the step internals point to the step call site. See below for more details.
### option: Test.step.fail.location
* since: v1.50
- `location` <[Location]>
Specifies a custom location for the step to be shown in test reports and trace viewer. By default, location of the [`method: Test.step`] call is shown.
### option: Test.step.fail.timeout
* since: v1.50
- `timeout` <[float]>
Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout).
## async method: Test.step.fixme
* since: v1.50
- returns: <[void]>
Mark a test step as "fixme", with the intention to fix it. Playwright will not run the step.
**Usage**
You can declare a test step as failing, so that Playwright ensures it actually fails.
```js
import { test, expect } from '@playwright/test';
test('my test', async ({ page }) => {
// ...
await test.step.fixme('not yet ready', async () => {
// ...
});
});
```
### param: Test.step.fixme.title
* since: v1.50
- `title` <[string]>
Step name.
### param: Test.step.fixme.body
* since: v1.50
- `body` <[function]\(\):[Promise]<[any]>>
Step body.
### option: Test.step.fixme.box
* since: v1.50
- `box` <boolean>
Whether to box the step in the report. Defaults to `false`. When the step is boxed, errors thrown from the step internals point to the step call site. See below for more details.
### option: Test.step.fixme.location
* since: v1.50
- `location` <[Location]>
Specifies a custom location for the step to be shown in test reports and trace viewer. By default, location of the [`method: Test.step`] call is shown.
### option: Test.step.fixme.timeout
* since: v1.50
- `timeout` <[float]>
Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout).
## method: Test.use ## method: Test.use
* since: v1.10 * since: v1.10

View file

@ -37,7 +37,7 @@ When `fullyParallel: true` is enabled, Playwright Test runs individual tests in
**Sharding without fullyParallel** **Sharding without fullyParallel**
Without the fullyParallel setting, Playwright Test defaults to file-level granularity, meaning entire test files are assigned to shards. In this case, the number of tests per file can greatly influence shard distribution. If your test files are not evenly sized (i.e., some files contain many more tests than others), certain shards may end up running significantly more tests, while others may run fewer or even none. Without the fullyParallel setting, Playwright Test defaults to file-level granularity, meaning entire test files are assigned to shards (note that the same file may be assigned to different shards across different projects). In this case, the number of tests per file can greatly influence shard distribution. If your test files are not evenly sized (i.e., some files contain many more tests than others), certain shards may end up running significantly more tests, while others may run fewer or even none.
**Key Takeaways:** **Key Takeaways:**

View file

@ -7,7 +7,7 @@ import LiteYouTube from '@site/src/components/LiteYouTube';
## Introduction ## Introduction
UI Mode lets you explore, run and debug tests with a time travel experience complete with watch mode. All test files are loaded into the testing sidebar where you can expand each file and describe block to individually run, view, watch and debug each test. Filter tests by **text** or **@tag** or by **passed**, **failed** and **skipped** tests as well as by [**projects**](./test-projects) as set in your `playwright.config` file. See a full trace of your tests and hover back and forward over each action to see what was happening during each step and pop out the DOM snapshot to a separate window for a better debugging experience. UI Mode lets you explore, run, and debug tests with a time travel experience complete with a watch mode. All test files are displayed in the testing sidebar, allowing you to expand each file and describe block to individually run, view, watch, and debug each test. Filter tests by **name**, [**projects**](./test-projects) (set in your `playwright.config` file), **@tag**, or by the execution status of **passed**, **failed**, and **skipped**. See a full trace of your tests and hover back and forward over each action to see what was happening during each step. You can also pop out the DOM snapshot of a given moment into a separate window for a better debugging experience.
<LiteYouTube <LiteYouTube
id="d0u6XhXknzU" id="d0u6XhXknzU"

View file

@ -22,7 +22,7 @@ Playwright Trace Viewer is a GUI tool that lets you explore recorded Playwright
## Recording a Trace ## Recording a Trace
By default the [playwright.config](./trace-viewer.md#recording-a-trace-on-ci) file will contain the configuration needed to create a `trace.zip` file for each test. Traces are setup to run `on-first-retry` meaning they will be run on the first retry of a failed test. Also `retries` are set to 2 when running on CI and 0 locally. This means the traces will be recorded on the first retry of a failed test but not on the first run and not on the second retry. By default the [playwright.config](./trace-viewer.md#tracing-on-ci) file will contain the configuration needed to create a `trace.zip` file for each test. Traces are setup to run `on-first-retry` meaning they will be run on the first retry of a failed test. Also `retries` are set to 2 when running on CI and 0 locally. This means the traces will be recorded on the first retry of a failed test but not on the first run and not on the second retry.
```js title="playwright.config.ts" ```js title="playwright.config.ts"
import { defineConfig } from '@playwright/test'; import { defineConfig } from '@playwright/test';

View file

@ -17,106 +17,67 @@ Playwright Trace Viewer is a GUI tool that helps you explore recorded Playwright
title="Viewing Playwright Traces" title="Viewing Playwright Traces"
/> />
## Trace Viewer features ## Opening Trace Viewer
### Actions
In the Actions tab you can see what locator was used for every action and how long each one took to run. Hover over each action of your test and visually see the change in the DOM snapshot. Go back and forward in time and click an action to inspect and debug. Use the Before and After tabs to visually see what happened before and after the action. You can open a saved trace using either the Playwright CLI or in the browser at [trace.playwright.dev](https://trace.playwright.dev). Make sure to add the full path to where your `trace.zip` file is located.
![actions tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/948b65cd-f0fd-4c7f-8e53-2c632b5a07f1) ```bash js
npx playwright show-trace path/to/trace.zip
```
**Selecting each action reveals:** ```bash java
- action snapshots mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace trace.zip"
- action log ```
- source code location
### Screenshots ```bash python
playwright show-trace trace.zip
```
When tracing with the [`option: Tracing.start.screenshots`] option turned on (default), each trace records a screencast and renders it as a film strip. You can hover over the film strip to see a magnified image of for each action and state which helps you easily find the action you want to inspect. ```bash csharp
pwsh bin/Debug/netX/playwright.ps1 show-trace trace.zip
```
Double click on an action to see the time range for that action. You can use the slider in the timeline to increase the actions selected and these will be shown in the Actions tab and all console logs and network logs will be filtered to only show the logs for the actions selected. ### Using [trace.playwright.dev](https://trace.playwright.dev)
![timeline view in trace viewer](https://github.com/microsoft/playwright/assets/13063165/b04a7d75-54bb-4ab2-9e30-e76f6f74a2c8) [trace.playwright.dev](https://trace.playwright.dev) is a statically hosted variant of the Trace Viewer. You can upload trace files using drag and drop or via the `Select file(s)` button.
Trace Viewer loads the trace entirely in your browser and does not transmit any data externally.
### Snapshots <img width="1119" alt="Drop Playwright Trace to load" src="https://user-images.githubusercontent.com/13063165/194577918-b4d45726-2692-4093-8a28-9e73552617ef.png" />
When tracing with the [`option: Tracing.start.snapshots`] option turned on (default), Playwright captures a set of complete DOM snapshots for each action. Depending on the type of the action, it will capture: ### Viewing remote traces
| Type | Description | You can open remote traces directly using its URL. This makes it easy to view the remote trace without having to manually download the file from CI runs, for example.
|------|-------------|
|Before|A snapshot at the time action is called.|
|Action|A snapshot at the moment of the performed input. This type of snapshot is especially useful when exploring where exactly Playwright clicked.|
|After|A snapshot after the action.|
Here is what the typical Action snapshot looks like: ```bash js
npx playwright show-trace https://example.com/trace.zip
```
![action tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/7168d549-eb0a-4964-9c93-483f03711fa9) ```bash java
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace https://example.com/trace.zip"
```
Notice how it highlights both, the DOM Node as well as the exact click position. ```bash python
playwright show-trace https://example.com/trace.zip
```
### Source ```bash csharp
pwsh bin/Debug/netX/playwright.ps1 show-trace https://example.com/trace.zip
```
When you click on an action in the sidebar, the line of code for that action is highlighted in the source panel. When using [trace.playwright.dev](https://trace.playwright.dev), you can also pass the URL of your uploaded trace at some accessible storage (e.g. inside your CI) as a query parameter. CORS (Cross-Origin Resource Sharing) rules might apply.
![showing source code tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/daa8845d-c250-4923-aa7a-5d040da9adc5) ```txt
https://trace.playwright.dev/?trace=https://demo.playwright.dev/reports/todomvc/data/cb0fa77ebd9487a5c899f3ae65a7ffdbac681182.zip
```
### Call ## Recording a trace
The call tab shows you information about the action such as the time it took, what locator was used, if in strict mode and what key was used.
![showing call tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/95498580-f9dd-4932-a123-c37fe7cfc3c2)
### Log
See a full log of your test to better understand what Playwright is doing behind the scenes such as scrolling into view, waiting for element to be visible, enabled and stable and performing actions such as click, fill, press etc.
![showing log of tests in trace viewer](https://github.com/microsoft/playwright/assets/13063165/de621461-3bab-4140-b39d-9f02d6672dbf)
### Errors
If your test fails you will see the error messages for each test in the Errors tab. The timeline will also show a red line highlighting where the error occurred. You can also click on the source tab to see on which line of the source code the error is.
![showing errors in trace viewer](https://github.com/microsoft/playwright/assets/13063165/e9ef77b3-05d1-4df2-852c-981023723d34)
### Console
See console logs from the browser as well as from your test. Different icons are displayed to show you if the console log came from the browser or from the test file.
![showing log of tests in trace viewer](https://github.com/microsoft/playwright/assets/13063165/4107c08d-1eaf-421c-bdd4-9dd2aa641d4a)
Double click on an action from your test in the actions sidebar. This will filter the console to only show the logs that were made during that action. Click the *Show all* button to see all console logs again.
Use the timeline to filter actions, by clicking a start point and dragging to an ending point. The console tab will also be filtered to only show the logs that were made during the actions selected.
### Network
The Network tab shows you all the network requests that were made during your test. You can sort by different types of requests, status code, method, request, content type, duration and size. Click on a request to see more information about it such as the request headers, response headers, request body and response body.
![network requests tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/0a3d1671-8ccd-4f7a-a844-35f5eb37f236)
Double click on an action from your test in the actions sidebar. This will filter the network requests to only show the requests that were made during that action. Click the *Show all* button to see all network requests again.
Use the timeline to filter actions, by clicking a start point and dragging to an ending point. The network tab will also be filtered to only show the network requests that were made during the actions selected.
### Metadata
Next to the Actions tab you will find the Metadata tab which will show you more information on your test such as the Browser, viewport size, test duration and more.
![meta data in trace viewer](https://github.com/microsoft/playwright/assets/13063165/82ab3d33-1ec9-4b8a-9cf2-30a6e2d59091)
### Attachments
* langs: js * langs: js
The "Attachments" tab allows you to explore attachments. If you're doing [visual regression testing](./test-snapshots.md), you'll be able to compare screenshots by examining the image diff, the actual image and the expected image. When you click on the expected image you can use the slider to slide one image over the other so you can easily see the differences in your screenshots. ### Tracing locally
![attachments tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/4386178a-5808-4fa8-9436-315350a23b04)
## Recording a trace locally
* langs: js * langs: js
To record a trace during development mode set the `--trace` flag to `on` when running your tests. You can also use [UI Mode](./test-ui-mode.md) for a better developer experience. To record a trace during development mode set the `--trace` flag to `on` when running your tests. You can also use [UI Mode](./test-ui-mode.md) for a better developer experience, as it traces each test automatically.
```bash ```bash
npx playwright test --trace on npx playwright test --trace on
@ -126,7 +87,7 @@ You can then open the HTML report and click on the trace icon to open the trace.
```bash ```bash
npx playwright show-report npx playwright show-report
``` ```
## Recording a trace on CI ### Tracing on CI
* langs: js * langs: js
Traces should be run on continuous integration on the first retry of a failed test Traces should be run on continuous integration on the first retry of a failed test
@ -592,57 +553,98 @@ public class WithTestNameAttribute : BeforeAfterTestAttribute
</TabItem> </TabItem>
</Tabs> </Tabs>
## Opening the trace ## Trace Viewer features
### Actions
You can open the saved trace using the Playwright CLI or in your browser on [`trace.playwright.dev`](https://trace.playwright.dev). Make sure to add the full path to where your `trace.zip` file is located. In the Actions tab you can see what locator was used for every action and how long each one took to run. Hover over each action of your test and visually see the change in the DOM snapshot. Go back and forward in time and click an action to inspect and debug. Use the Before and After tabs to visually see what happened before and after the action.
```bash js ![actions tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/948b65cd-f0fd-4c7f-8e53-2c632b5a07f1)
npx playwright show-trace path/to/trace.zip
```
```bash java **Selecting each action reveals:**
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace trace.zip" - Action snapshots
``` - Action log
- Source code location
```bash python ### Screenshots
playwright show-trace trace.zip
```
```bash csharp When tracing with the [`option: Tracing.start.screenshots`] option turned on (default), each trace records a screencast and renders it as a film strip. You can hover over the film strip to see a magnified image of for each action and state which helps you easily find the action you want to inspect.
pwsh bin/Debug/netX/playwright.ps1 show-trace trace.zip
```
## Using [trace.playwright.dev](https://trace.playwright.dev) Double click on an action to see the time range for that action. You can use the slider in the timeline to increase the actions selected and these will be shown in the Actions tab and all console logs and network logs will be filtered to only show the logs for the actions selected.
[trace.playwright.dev](https://trace.playwright.dev) is a statically hosted variant of the Trace Viewer. You can upload trace files using drag and drop. ![timeline view in trace viewer](https://github.com/microsoft/playwright/assets/13063165/b04a7d75-54bb-4ab2-9e30-e76f6f74a2c8)
<img width="1119" alt="Drop Playwright Trace to load" src="https://user-images.githubusercontent.com/13063165/194577918-b4d45726-2692-4093-8a28-9e73552617ef.png" /> ### Snapshots
## Viewing remote traces When tracing with the [`option: Tracing.start.snapshots`] option turned on (default), Playwright captures a set of complete DOM snapshots for each action. Depending on the type of the action, it will capture:
You can open remote traces using its URL. They could be generated on a CI run which makes it easy to view the remote trace without having to manually download the file. | Type | Description |
|------|-------------|
|Before|A snapshot at the time action is called.|
|Action|A snapshot at the moment of the performed input. This type of snapshot is especially useful when exploring where exactly Playwright clicked.|
|After|A snapshot after the action.|
```bash js Here is what the typical Action snapshot looks like:
npx playwright show-trace https://example.com/trace.zip
```
```bash java ![action tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/7168d549-eb0a-4964-9c93-483f03711fa9)
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace https://example.com/trace.zip"
```
```bash python Notice how it highlights both, the DOM Node as well as the exact click position.
playwright show-trace https://example.com/trace.zip
```
```bash csharp ### Source
pwsh bin/Debug/netX/playwright.ps1 show-trace https://example.com/trace.zip
``` When you click on an action in the sidebar, the line of code for that action is highlighted in the source panel.
![showing source code tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/daa8845d-c250-4923-aa7a-5d040da9adc5)
### Call
The call tab shows you information about the action such as the time it took, what locator was used, if in strict mode and what key was used.
![showing call tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/95498580-f9dd-4932-a123-c37fe7cfc3c2)
### Log
See a full log of your test to better understand what Playwright is doing behind the scenes such as scrolling into view, waiting for element to be visible, enabled and stable and performing actions such as click, fill, press etc.
![showing log of tests in trace viewer](https://github.com/microsoft/playwright/assets/13063165/de621461-3bab-4140-b39d-9f02d6672dbf)
### Errors
If your test fails you will see the error messages for each test in the Errors tab. The timeline will also show a red line highlighting where the error occurred. You can also click on the source tab to see on which line of the source code the error is.
![showing errors in trace viewer](https://github.com/microsoft/playwright/assets/13063165/e9ef77b3-05d1-4df2-852c-981023723d34)
### Console
See console logs from the browser as well as from your test. Different icons are displayed to show you if the console log came from the browser or from the test file.
![showing log of tests in trace viewer](https://github.com/microsoft/playwright/assets/13063165/4107c08d-1eaf-421c-bdd4-9dd2aa641d4a)
Double click on an action from your test in the actions sidebar. This will filter the console to only show the logs that were made during that action. Click the *Show all* button to see all console logs again.
Use the timeline to filter actions, by clicking a start point and dragging to an ending point. The console tab will also be filtered to only show the logs that were made during the actions selected.
You can also pass the URL of your uploaded trace (e.g. inside your CI) from some accessible storage as a parameter. CORS (Cross-Origin Resource Sharing) rules might apply. ### Network
```txt The Network tab shows you all the network requests that were made during your test. You can sort by different types of requests, status code, method, request, content type, duration and size. Click on a request to see more information about it such as the request headers, response headers, request body and response body.
https://trace.playwright.dev/?trace=https://demo.playwright.dev/reports/todomvc/data/cb0fa77ebd9487a5c899f3ae65a7ffdbac681182.zip
``` ![network requests tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/0a3d1671-8ccd-4f7a-a844-35f5eb37f236)
Double click on an action from your test in the actions sidebar. This will filter the network requests to only show the requests that were made during that action. Click the *Show all* button to see all network requests again.
Use the timeline to filter actions, by clicking a start point and dragging to an ending point. The network tab will also be filtered to only show the network requests that were made during the actions selected.
### Metadata
Next to the Actions tab you will find the Metadata tab which will show you more information on your test such as the Browser, viewport size, test duration and more.
![meta data in trace viewer](https://github.com/microsoft/playwright/assets/13063165/82ab3d33-1ec9-4b8a-9cf2-30a6e2d59091)
### Attachments
* langs: js
The "Attachments" tab allows you to explore attachments. If you're doing [visual regression testing](./test-snapshots.md), you'll be able to compare screenshots by examining the image diff, the actual image and the expected image. When you click on the expected image you can use the slider to slide one image over the other so you can easily see the differences in your screenshots.
![attachments tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/4386178a-5808-4fa8-9436-315350a23b04)

258
package-lock.json generated
View file

@ -27,7 +27,7 @@
"@types/codemirror": "^5.60.7", "@types/codemirror": "^5.60.7",
"@types/formidable": "^2.0.4", "@types/formidable": "^2.0.4",
"@types/immutable": "^3.8.7", "@types/immutable": "^3.8.7",
"@types/node": "^18.19.39", "@types/node": "^18.19.68",
"@types/react": "^18.0.12", "@types/react": "^18.0.12",
"@types/react-dom": "^18.0.5", "@types/react-dom": "^18.0.5",
"@types/ws": "^8.5.3", "@types/ws": "^8.5.3",
@ -60,7 +60,7 @@
"react": "^18.1.0", "react": "^18.1.0",
"react-dom": "^18.1.0", "react-dom": "^18.1.0",
"ssim.js": "^3.5.0", "ssim.js": "^3.5.0",
"typescript": "^5.5.3", "typescript": "^5.7.2",
"vite": "^5.4.6", "vite": "^5.4.6",
"ws": "^8.17.1", "ws": "^8.17.1",
"xml2js": "^0.5.0", "xml2js": "^0.5.0",
@ -397,17 +397,19 @@
} }
}, },
"node_modules/@babel/helper-string-parser": { "node_modules/@babel/helper-string-parser": {
"version": "7.23.4", "version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
"integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
"license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/helper-validator-identifier": { "node_modules/@babel/helper-validator-identifier": {
"version": "7.22.20", "version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
"integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
"license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@ -447,9 +449,13 @@
} }
}, },
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
"version": "7.23.6", "version": "7.26.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz",
"integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.26.3"
},
"bin": { "bin": {
"parser": "bin/babel-parser.js" "parser": "bin/babel-parser.js"
}, },
@ -810,13 +816,13 @@
} }
}, },
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.23.6", "version": "7.26.3",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz",
"integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==",
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-string-parser": "^7.23.4", "@babel/helper-string-parser": "^7.25.9",
"@babel/helper-validator-identifier": "^7.22.20", "@babel/helper-validator-identifier": "^7.25.9"
"to-fast-properties": "^2.0.0"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@ -1409,9 +1415,10 @@
} }
}, },
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.4.15", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
"license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": { "node_modules/@jridgewell/trace-mapping": {
"version": "0.3.21", "version": "0.3.21",
@ -1851,10 +1858,11 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "18.19.39", "version": "18.19.68",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.39.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.68.tgz",
"integrity": "sha512-nPwTRDKUctxw3di5b4TfT3I0sWDiWoPQCZjXhvdkINntwr8lcoVCKsTgnXeRubKIlfnV+eN/HYk6Jb40tbcEAQ==", "integrity": "sha512-QGtpFH1vB99ZmTa63K4/FU8twThj4fuVSBkGddTp7uIL/cuoLWIUSL2RcOaigBhfR+hg5pgGkBnkoOxrTVBMKw==",
"devOptional": true, "devOptional": true,
"license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
} }
@ -2184,126 +2192,126 @@
} }
}, },
"node_modules/@vue/compiler-core": { "node_modules/@vue/compiler-core": {
"version": "3.4.15", "version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.15.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz",
"integrity": "sha512-XcJQVOaxTKCnth1vCxEChteGuwG6wqnUHxAm1DO3gCz0+uXKaJNx8/digSz4dLALCy8n2lKq24jSUs8segoqIw==", "integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==",
"license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@babel/parser": "^7.23.6", "@babel/parser": "^7.25.3",
"@vue/shared": "3.4.15", "@vue/shared": "3.5.13",
"entities": "^4.5.0", "entities": "^4.5.0",
"estree-walker": "^2.0.2", "estree-walker": "^2.0.2",
"source-map-js": "^1.0.2" "source-map-js": "^1.2.0"
}
},
"node_modules/@vue/compiler-core/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"peer": true,
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
} }
}, },
"node_modules/@vue/compiler-core/node_modules/estree-walker": { "node_modules/@vue/compiler-core/node_modules/estree-walker": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT",
"peer": true "peer": true
}, },
"node_modules/@vue/compiler-dom": { "node_modules/@vue/compiler-dom": {
"version": "3.4.15", "version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.15.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz",
"integrity": "sha512-wox0aasVV74zoXyblarOM3AZQz/Z+OunYcIHe1OsGclCHt8RsRm04DObjefaI82u6XDzv+qGWZ24tIsRAIi5MQ==", "integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==",
"license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-core": "3.4.15", "@vue/compiler-core": "3.5.13",
"@vue/shared": "3.4.15" "@vue/shared": "3.5.13"
} }
}, },
"node_modules/@vue/compiler-sfc": { "node_modules/@vue/compiler-sfc": {
"version": "3.4.15", "version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.15.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz",
"integrity": "sha512-LCn5M6QpkpFsh3GQvs2mJUOAlBQcCco8D60Bcqmf3O3w5a+KWS5GvYbrrJBkgvL1BDnTp+e8q0lXCLgHhKguBA==", "integrity": "sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==",
"license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@babel/parser": "^7.23.6", "@babel/parser": "^7.25.3",
"@vue/compiler-core": "3.4.15", "@vue/compiler-core": "3.5.13",
"@vue/compiler-dom": "3.4.15", "@vue/compiler-dom": "3.5.13",
"@vue/compiler-ssr": "3.4.15", "@vue/compiler-ssr": "3.5.13",
"@vue/shared": "3.4.15", "@vue/shared": "3.5.13",
"estree-walker": "^2.0.2", "estree-walker": "^2.0.2",
"magic-string": "^0.30.5", "magic-string": "^0.30.11",
"postcss": "^8.4.33", "postcss": "^8.4.48",
"source-map-js": "^1.0.2" "source-map-js": "^1.2.0"
} }
}, },
"node_modules/@vue/compiler-sfc/node_modules/estree-walker": { "node_modules/@vue/compiler-sfc/node_modules/estree-walker": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT",
"peer": true "peer": true
}, },
"node_modules/@vue/compiler-ssr": { "node_modules/@vue/compiler-ssr": {
"version": "3.4.15", "version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.15.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz",
"integrity": "sha512-1jdeQyiGznr8gjFDadVmOJqZiLNSsMa5ZgqavkPZ8O2wjHv0tVuAEsw5hTdUoUW4232vpBbL/wJhzVW/JwY1Uw==", "integrity": "sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==",
"license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.4.15", "@vue/compiler-dom": "3.5.13",
"@vue/shared": "3.4.15" "@vue/shared": "3.5.13"
} }
}, },
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.4.15", "version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.15.tgz", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.13.tgz",
"integrity": "sha512-55yJh2bsff20K5O84MxSvXKPHHt17I2EomHznvFiJCAZpJTNW8IuLj1xZWMLELRhBK3kkFV/1ErZGHJfah7i7w==", "integrity": "sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==",
"license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@vue/shared": "3.4.15" "@vue/shared": "3.5.13"
} }
}, },
"node_modules/@vue/runtime-core": { "node_modules/@vue/runtime-core": {
"version": "3.4.15", "version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.15.tgz", "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.13.tgz",
"integrity": "sha512-6E3by5m6v1AkW0McCeAyhHTw+3y17YCOKG0U0HDKDscV4Hs0kgNT5G+GCHak16jKgcCDHpI9xe5NKb8sdLCLdw==", "integrity": "sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==",
"license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@vue/reactivity": "3.4.15", "@vue/reactivity": "3.5.13",
"@vue/shared": "3.4.15" "@vue/shared": "3.5.13"
} }
}, },
"node_modules/@vue/runtime-dom": { "node_modules/@vue/runtime-dom": {
"version": "3.4.15", "version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.15.tgz", "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz",
"integrity": "sha512-EVW8D6vfFVq3V/yDKNPBFkZKGMFSvZrUQmx196o/v2tHKdwWdiZjYUBS+0Ez3+ohRyF8Njwy/6FH5gYJ75liUw==", "integrity": "sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==",
"license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@vue/runtime-core": "3.4.15", "@vue/reactivity": "3.5.13",
"@vue/shared": "3.4.15", "@vue/runtime-core": "3.5.13",
"@vue/shared": "3.5.13",
"csstype": "^3.1.3" "csstype": "^3.1.3"
} }
}, },
"node_modules/@vue/server-renderer": { "node_modules/@vue/server-renderer": {
"version": "3.4.15", "version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.15.tgz", "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.13.tgz",
"integrity": "sha512-3HYzaidu9cHjrT+qGUuDhFYvF/j643bHC6uUN9BgM11DVy+pM6ATsG6uPBLnkwOgs7BpJABReLmpL3ZPAsUaqw==", "integrity": "sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==",
"license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-ssr": "3.4.15", "@vue/compiler-ssr": "3.5.13",
"@vue/shared": "3.4.15" "@vue/shared": "3.5.13"
}, },
"peerDependencies": { "peerDependencies": {
"vue": "3.4.15" "vue": "3.5.13"
} }
}, },
"node_modules/@vue/shared": { "node_modules/@vue/shared": {
"version": "3.4.15", "version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.15.tgz", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz",
"integrity": "sha512-KzfPTxVaWfB+eGcGdbSf4CWdaXcGDqckoeXUh7SB3fZdEtzPCK2Vq9B/lRRL3yutax/LWITz+SwvgyOxz5V75g==", "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==",
"license": "MIT",
"peer": true "peer": true
}, },
"node_modules/@zip.js/zip.js": { "node_modules/@zip.js/zip.js": {
@ -3326,6 +3334,19 @@
"once": "^1.4.0" "once": "^1.4.0"
} }
}, },
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-paths": { "node_modules/env-paths": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@ -5236,14 +5257,12 @@
} }
}, },
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.5", "version": "0.30.15",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.15.tgz",
"integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", "integrity": "sha512-zXeaYRgZ6ldS1RJJUrMrYgNJ4fdwnyI6tVqoiIhyCyv5IVTK9BU8Ic2l253GGETQHxI4HNUwhJ3fjDhKqEoaAw==",
"license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15" "@jridgewell/sourcemap-codec": "^1.5.0"
},
"engines": {
"node": ">=12"
} }
}, },
"node_modules/make-dir": { "node_modules/make-dir": {
@ -5396,15 +5415,16 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.7", "version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"license": "MIT",
"bin": { "bin": {
"nanoid": "bin/nanoid.cjs" "nanoid": "bin/nanoid.cjs"
}, },
@ -5772,9 +5792,10 @@
} }
}, },
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.0", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==" "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.1", "version": "2.3.1",
@ -5827,9 +5848,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.4.47", "version": "8.4.49",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
"integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@ -5844,9 +5865,10 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.7", "nanoid": "^3.3.7",
"picocolors": "^1.1.0", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
"engines": { "engines": {
@ -6720,14 +6742,6 @@
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
"dev": true "dev": true
}, },
"node_modules/to-fast-properties": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
"integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
"engines": {
"node": ">=4"
}
},
"node_modules/to-regex-range": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -6888,10 +6902,11 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.5.3", "version": "5.7.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz",
"integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -7450,16 +7465,17 @@
} }
}, },
"node_modules/vue": { "node_modules/vue": {
"version": "3.4.15", "version": "3.5.13",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.4.15.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz",
"integrity": "sha512-jC0GH4KkWLWJOEQjOpkqU1bQsBwf4R1rsFtw5GQJbjHVKWDzO6P0nWWBTmjp1xSemAioDFj1jdaK1qa3DnMQoQ==", "integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==",
"license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.4.15", "@vue/compiler-dom": "3.5.13",
"@vue/compiler-sfc": "3.4.15", "@vue/compiler-sfc": "3.5.13",
"@vue/runtime-dom": "3.4.15", "@vue/runtime-dom": "3.5.13",
"@vue/server-renderer": "3.4.15", "@vue/server-renderer": "3.5.13",
"@vue/shared": "3.4.15" "@vue/shared": "3.5.13"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "*" "typescript": "*"

View file

@ -66,7 +66,7 @@
"@types/codemirror": "^5.60.7", "@types/codemirror": "^5.60.7",
"@types/formidable": "^2.0.4", "@types/formidable": "^2.0.4",
"@types/immutable": "^3.8.7", "@types/immutable": "^3.8.7",
"@types/node": "^18.19.39", "@types/node": "^18.19.68",
"@types/react": "^18.0.12", "@types/react": "^18.0.12",
"@types/react-dom": "^18.0.5", "@types/react-dom": "^18.0.5",
"@types/ws": "^8.5.3", "@types/ws": "^8.5.3",
@ -99,7 +99,7 @@
"react": "^18.1.0", "react": "^18.1.0",
"react-dom": "^18.1.0", "react-dom": "^18.1.0",
"ssim.js": "^3.5.0", "ssim.js": "^3.5.0",
"typescript": "^5.5.3", "typescript": "^5.7.2",
"vite": "^5.4.6", "vite": "^5.4.6",
"ws": "^8.17.1", "ws": "^8.17.1",
"xml2js": "^0.5.0", "xml2js": "^0.5.0",

View file

@ -2,10 +2,5 @@
"name": "html-reporter", "name": "html-reporter",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module"
"scripts": {
"dev": "vite",
"build": "vite build && tsc",
"preview": "vite preview"
}
} }

View file

@ -20,7 +20,7 @@ import './colors.css';
import './common.css'; import './common.css';
import * as icons from './icons'; import * as icons from './icons';
import { clsx } from '@web/uiUtils'; import { clsx } from '@web/uiUtils';
import { useAnchor } from './links'; import { type AnchorID, useAnchor } from './links';
export const Chip: React.FC<{ export const Chip: React.FC<{
header: JSX.Element | string, header: JSX.Element | string,
@ -53,7 +53,7 @@ export const AutoChip: React.FC<{
noInsets?: boolean, noInsets?: boolean,
children?: any, children?: any,
dataTestId?: string, dataTestId?: string,
revealOnAnchorId?: string, revealOnAnchorId?: AnchorID,
}> = ({ header, initialExpanded, noInsets, children, dataTestId, revealOnAnchorId }) => { }> = ({ header, initialExpanded, noInsets, children, dataTestId, revealOnAnchorId }) => {
const [expanded, setExpanded] = React.useState(initialExpanded ?? true); const [expanded, setExpanded] = React.useState(initialExpanded ?? true);
const onReveal = React.useCallback(() => setExpanded(true), []); const onReveal = React.useCallback(() => setExpanded(true), []);

View file

@ -64,6 +64,6 @@ test('should toggle filters', async ({ page, mount }) => {
await expect(page).toHaveURL(/#\?q=s:flaky/); await expect(page).toHaveURL(/#\?q=s:flaky/);
await component.locator('a', { hasText: 'Skipped' }).click(); await component.locator('a', { hasText: 'Skipped' }).click();
await expect(page).toHaveURL(/#\?q=s:skipped/); await expect(page).toHaveURL(/#\?q=s:skipped/);
await component.getByRole('searchbox').fill('annot:annotation type=annotation description'); await component.getByRole('textbox').fill('annot:annotation type=annotation description');
expect(filters).toEqual(['', 's:passed', 's:failed', 's:flaky', 's:skipped', 'annot:annotation type=annotation description']); expect(filters).toEqual(['', 's:passed', 's:failed', 's:flaky', 's:skipped', 'annot:annotation type=annotation description']);
}); });

View file

@ -49,12 +49,14 @@ export const HeaderView: React.FC<React.PropsWithChildren<{
<form className='subnav-search' onSubmit={ <form className='subnav-search' onSubmit={
event => { event => {
event.preventDefault(); event.preventDefault();
navigate(`#?q=${filterText ? encodeURIComponent(filterText) : ''}`); const url = new URL(window.location.href);
url.hash = filterText ? '?' + new URLSearchParams({ q: filterText }) : '';
navigate(url);
} }
}> }>
{icons.search()} {icons.search()}
{/* Use navigationId to reset defaultValue */} {/* Use navigationId to reset defaultValue */}
<input type='search' spellCheck={false} className='form-control subnav-search-input input-contrast width-full' value={filterText} onChange={e => { <input spellCheck={false} className='form-control subnav-search-input input-contrast width-full' value={filterText} onChange={e => {
setFilterText(e.target.value); setFilterText(e.target.value);
}}></input> }}></input>
</form> </form>

View file

@ -15,7 +15,7 @@
*/ */
import type { HTMLReport } from './types'; import type { HTMLReport } from './types';
import type zip from '@zip.js/zip.js'; import type * as zip from '@zip.js/zip.js';
// @ts-ignore // @ts-ignore
import * as zipImport from '@zip.js/zip.js/lib/zip-no-worker-inflate.js'; import * as zipImport from '@zip.js/zip.js/lib/zip-no-worker-inflate.js';
import * as React from 'react'; import * as React from 'react';

View file

@ -14,7 +14,7 @@
limitations under the License. limitations under the License.
*/ */
import type { TestAttachment } from './types'; import type { TestAttachment, TestCase, TestCaseSummary, TestResult, TestResultSummary } from './types';
import * as React from 'react'; import * as React from 'react';
import * as icons from './icons'; import * as icons from './icons';
import { TreeItem } from './treeItem'; import { TreeItem } from './treeItem';
@ -23,7 +23,7 @@ import './links.css';
import { linkifyText } from '@web/renderUtils'; import { linkifyText } from '@web/renderUtils';
import { clsx } from '@web/uiUtils'; import { clsx } from '@web/uiUtils';
export function navigate(href: string) { export function navigate(href: string | URL) {
window.history.pushState({}, '', href); window.history.pushState({}, '', href);
const navEvent = new PopStateEvent('popstate'); const navEvent = new PopStateEvent('popstate');
window.dispatchEvent(navEvent); window.dispatchEvent(navEvent);
@ -72,6 +72,7 @@ export const AttachmentLink: React.FunctionComponent<{
linkName?: string, linkName?: string,
openInNewTab?: boolean, openInNewTab?: boolean,
}> = ({ attachment, href, linkName, openInNewTab }) => { }> = ({ attachment, href, linkName, openInNewTab }) => {
const isAnchored = useIsAnchored('attachment-' + attachment.name);
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>}
@ -82,7 +83,7 @@ export const AttachmentLink: React.FunctionComponent<{
)} )}
</span>} loadChildren={attachment.body ? () => { </span>} loadChildren={attachment.body ? () => {
return [<div key={1} className='attachment-body'><CopyToClipboard value={attachment.body!}/>{linkifyText(attachment.body!)}</div>]; return [<div key={1} className='attachment-body'><CopyToClipboard value={attachment.body!}/>{linkifyText(attachment.body!)}</div>];
} : undefined} depth={0} style={{ lineHeight: '32px' }}></TreeItem>; } : undefined} depth={0} style={{ lineHeight: '32px' }} selected={isAnchored}></TreeItem>;
}; };
export const SearchParamsContext = React.createContext<URLSearchParams>(new URLSearchParams(window.location.hash.slice(1))); export const SearchParamsContext = React.createContext<URLSearchParams>(new URLSearchParams(window.location.hash.slice(1)));
@ -114,31 +115,48 @@ export function generateTraceUrl(traces: TestAttachment[]) {
const kMissingContentType = 'x-playwright/missing'; const kMissingContentType = 'x-playwright/missing';
type AnchorID = string | ((id: string | null) => boolean) | undefined; export type AnchorID = string | string[] | ((id: string) => boolean) | undefined;
export function useAnchor(id: AnchorID, onReveal: () => void) { export function useAnchor(id: AnchorID, onReveal: () => void) {
const searchParams = React.useContext(SearchParamsContext);
const isAnchored = useIsAnchored(id);
React.useEffect(() => { React.useEffect(() => {
if (typeof id === 'undefined') if (isAnchored)
return; onReveal();
}, [isAnchored, onReveal, searchParams]);
}
const listener = () => { export function useIsAnchored(id: AnchorID) {
const params = new URLSearchParams(window.location.hash.slice(1)); const searchParams = React.useContext(SearchParamsContext);
const anchor = params.get('anchor'); const anchor = searchParams.get('anchor');
const isRevealed = typeof id === 'function' ? id(anchor) : anchor === id; if (anchor === null)
if (isRevealed) return false;
onReveal(); if (typeof id === 'undefined')
}; return false;
window.addEventListener('popstate', listener); if (typeof id === 'string')
return () => window.removeEventListener('popstate', listener); return id === anchor;
}, [id, onReveal]); if (Array.isArray(id))
return id.includes(anchor);
return id(anchor);
} }
export function Anchor({ id, children }: React.PropsWithChildren<{ id: AnchorID }>) { export function Anchor({ id, children }: React.PropsWithChildren<{ id: AnchorID }>) {
const ref = React.useRef<HTMLDivElement>(null); const ref = React.useRef<HTMLDivElement>(null);
const onAnchorReveal = React.useCallback(() => { const onAnchorReveal = React.useCallback(() => {
requestAnimationFrame(() => ref.current?.scrollIntoView({ block: 'start', inline: 'start' })); ref.current?.scrollIntoView({ block: 'start', inline: 'start' });
}, []); }, []);
useAnchor(id, onAnchorReveal); useAnchor(id, onAnchorReveal);
return <div ref={ref}>{children}</div>; return <div ref={ref}>{children}</div>;
} }
export function testResultHref({ test, result, anchor }: { test?: TestCase | TestCaseSummary, result?: TestResult | TestResultSummary, anchor?: string }) {
const params = new URLSearchParams();
if (test)
params.set('testId', test.testId);
if (test && result)
params.set('run', '' + test.results.indexOf(result as any));
if (anchor)
params.set('anchor', anchor);
return `#?` + params;
}

View file

@ -19,7 +19,7 @@ import * as React from 'react';
import { TabbedPane } from './tabbedPane'; import { TabbedPane } from './tabbedPane';
import { AutoChip } from './chip'; import { AutoChip } from './chip';
import './common.css'; import './common.css';
import { Link, ProjectLink, SearchParamsContext } from './links'; import { Link, ProjectLink, SearchParamsContext, testResultHref } from './links';
import { statusIcon } from './statusIcon'; import { statusIcon } from './statusIcon';
import './testCaseView.css'; import './testCaseView.css';
import { TestResultView } from './testResultView'; import { TestResultView } from './testResultView';
@ -53,9 +53,9 @@ export const TestCaseView: React.FC<{
{test && <div className='hbox'> {test && <div className='hbox'>
<div className='test-case-path'>{test.path.join(' ')}</div> <div className='test-case-path'>{test.path.join(' ')}</div>
<div style={{ flex: 'auto' }}></div> <div style={{ flex: 'auto' }}></div>
<div className={clsx(!prev && 'hidden')}><Link href={`#?testId=${prev?.testId}${filterParam}`}>« previous</Link></div> <div className={clsx(!prev && 'hidden')}><Link href={testResultHref({ test: prev }) + filterParam}>« previous</Link></div>
<div style={{ width: 10 }}></div> <div style={{ width: 10 }}></div>
<div className={clsx(!next && 'hidden')}><Link href={`#?testId=${next?.testId}${filterParam}`}>next »</Link></div> <div className={clsx(!next && 'hidden')}><Link href={testResultHref({ test: next }) + filterParam}>next »</Link></div>
</div>} </div>}
{test && <div className='test-case-title'>{test?.title}</div>} {test && <div className='test-case-title'>{test?.title}</div>}
{test && <div className='hbox'> {test && <div className='hbox'>

View file

@ -19,7 +19,7 @@ import * as React from 'react';
import { hashStringToInt, msToString } from './utils'; import { hashStringToInt, msToString } from './utils';
import { Chip } from './chip'; import { Chip } from './chip';
import { filterWithToken } from './filter'; import { filterWithToken } from './filter';
import { generateTraceUrl, Link, navigate, ProjectLink, SearchParamsContext } from './links'; import { generateTraceUrl, Link, navigate, ProjectLink, SearchParamsContext, testResultHref } from './links';
import { statusIcon } from './statusIcon'; import { statusIcon } from './statusIcon';
import './testFileView.css'; import './testFileView.css';
import { video, image, trace } from './icons'; import { video, image, trace } from './icons';
@ -48,7 +48,7 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
{statusIcon(test.outcome)} {statusIcon(test.outcome)}
</span> </span>
<span> <span>
<Link href={`#?testId=${test.testId}${filterParam}`} title={[...test.path, test.title].join(' ')}> <Link href={testResultHref({ test }) + filterParam} title={[...test.path, test.title].join(' ')}>
<span className='test-file-title'>{[...test.path, test.title].join(' ')}</span> <span className='test-file-title'>{[...test.path, test.title].join(' ')}</span>
</Link> </Link>
{projectNames.length > 1 && !!test.projectName && {projectNames.length > 1 && !!test.projectName &&
@ -59,7 +59,7 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
<span data-testid='test-duration' style={{ minWidth: '50px', textAlign: 'right' }}>{msToString(test.duration)}</span> <span data-testid='test-duration' style={{ minWidth: '50px', textAlign: 'right' }}>{msToString(test.duration)}</span>
</div> </div>
<div className='test-file-details-row'> <div className='test-file-details-row'>
<Link href={`#?testId=${test.testId}`} title={[...test.path, test.title].join(' ')} className='test-file-path-link'> <Link href={testResultHref({ test })} title={[...test.path, test.title].join(' ')} className='test-file-path-link'>
<span className='test-file-path'>{test.location.file}:{test.location.line}</span> <span className='test-file-path'>{test.location.file}:{test.location.line}</span>
</Link> </Link>
{imageDiffBadge(test)} {imageDiffBadge(test)}
@ -72,15 +72,17 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
}; };
function imageDiffBadge(test: TestCaseSummary): JSX.Element | undefined { function imageDiffBadge(test: TestCaseSummary): JSX.Element | undefined {
const resultWithImageDiff = test.results.find(result => result.attachments.some(attachment => { for (const result of test.results) {
return attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/); for (const attachment of result.attachments) {
})); if (attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/))
return resultWithImageDiff ? <Link href={`#?testId=${test.testId}&anchor=diff-0&run=${test.results.indexOf(resultWithImageDiff)}`} title='View images' className='test-file-badge'>{image()}</Link> : undefined; return <Link href={testResultHref({ test, result, anchor: `attachment-${attachment.name}` })} title='View images' className='test-file-badge'>{image()}</Link>;
}
}
} }
function videoBadge(test: TestCaseSummary): JSX.Element | undefined { function videoBadge(test: TestCaseSummary): JSX.Element | undefined {
const resultWithVideo = test.results.find(result => result.attachments.some(attachment => attachment.name === 'video')); const resultWithVideo = test.results.find(result => result.attachments.some(attachment => attachment.name === 'video'));
return resultWithVideo ? <Link href={`#?testId=${test.testId}&anchor=videos&run=${test.results.indexOf(resultWithVideo)}`} title='View video' className='test-file-badge'>{video()}</Link> : undefined; return resultWithVideo ? <Link href={testResultHref({ test, result: resultWithVideo, anchor: 'attachment-video' })} title='View video' className='test-file-badge'>{video()}</Link> : undefined;
} }
function traceBadge(test: TestCaseSummary): JSX.Element | undefined { function traceBadge(test: TestCaseSummary): JSX.Element | undefined {

View file

@ -20,15 +20,20 @@ import { TreeItem } from './treeItem';
import { msToString } from './utils'; import { msToString } from './utils';
import { AutoChip } from './chip'; import { AutoChip } from './chip';
import { traceImage } from './images'; import { traceImage } from './images';
import { Anchor, AttachmentLink, generateTraceUrl } from './links'; import { Anchor, AttachmentLink, generateTraceUrl, testResultHref } from './links';
import { statusIcon } from './statusIcon'; import { statusIcon } from './statusIcon';
import type { ImageDiff } from '@web/shared/imageDiffView'; import type { ImageDiff } from '@web/shared/imageDiffView';
import { ImageDiffView } from '@web/shared/imageDiffView'; import { ImageDiffView } from '@web/shared/imageDiffView';
import { TestErrorView, TestScreenshotErrorView } from './testErrorView'; import { TestErrorView, TestScreenshotErrorView } from './testErrorView';
import * as icons from './icons';
import './testResultView.css'; import './testResultView.css';
function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiff[] { interface ImageDiffWithAnchors extends ImageDiff {
const snapshotNameToImageDiff = new Map<string, ImageDiff>(); anchors: string[];
}
function groupImageDiffs(screenshots: Set<TestAttachment>): 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)(\.[^.]+)?$/);
if (!match) if (!match)
@ -37,9 +42,10 @@ function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiff[] {
const snapshotName = name + extension; const snapshotName = name + extension;
let imageDiff = snapshotNameToImageDiff.get(snapshotName); let imageDiff = snapshotNameToImageDiff.get(snapshotName);
if (!imageDiff) { if (!imageDiff) {
imageDiff = { name: snapshotName }; imageDiff = { name: snapshotName, anchors: [`attachment-${name}`] };
snapshotNameToImageDiff.set(snapshotName, imageDiff); snapshotNameToImageDiff.set(snapshotName, imageDiff);
} }
imageDiff.anchors.push(`attachment-${attachment.name}`);
if (category === 'actual') if (category === 'actual')
imageDiff.actual = { attachment }; imageDiff.actual = { attachment };
if (category === 'expected') if (category === 'expected')
@ -64,18 +70,19 @@ function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiff[] {
export const TestResultView: React.FC<{ export const TestResultView: React.FC<{
test: TestCase, test: TestCase,
result: TestResult, result: TestResult,
}> = ({ result }) => { }> = ({ test, result }) => {
const { screenshots, videos, traces, otherAttachments, diffs, errors, htmls } = 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 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 htmls = attachments.filter(a => a.contentType.startsWith('text/html'));
const otherAttachments = new Set<TestAttachment>(attachments); const otherAttachments = new Set<TestAttachment>(attachments);
[...screenshots, ...videos, ...traces, ...htmls].forEach(a => otherAttachments.delete(a)); [...screenshots, ...videos, ...traces].forEach(a => otherAttachments.delete(a));
const otherAttachmentAnchors = [...otherAttachments].map(a => `attachment-${a.name}`);
const diffs = groupImageDiffs(screenshots); const diffs = groupImageDiffs(screenshots);
const errors = classifyErrors(result.errors, diffs); const errors = classifyErrors(result.errors, diffs);
return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, htmls }; return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors };
}, [result]); }, [result]);
return <div className='test-result'> return <div className='test-result'>
@ -87,29 +94,29 @@ export const TestResultView: React.FC<{
})} })}
</AutoChip>} </AutoChip>}
{!!result.steps.length && <AutoChip header='Test Steps'> {!!result.steps.length && <AutoChip header='Test Steps'>
{result.steps.map((step, i) => <StepTreeItem key={`step-${i}`} step={step} depth={0}></StepTreeItem>)} {result.steps.map((step, i) => <StepTreeItem key={`step-${i}`} step={step} result={result} test={test} depth={0}/>)}
</AutoChip>} </AutoChip>}
{diffs.map((diff, index) => {diffs.map((diff, index) =>
<Anchor key={`diff-${index}`} id={`diff-${index}`}> <Anchor key={`diff-${index}`} id={diff.anchors}>
<AutoChip dataTestId='test-results-image-diff' header={`Image mismatch: ${diff.name}`} revealOnAnchorId={`diff-${index}`}> <AutoChip dataTestId='test-results-image-diff' header={`Image mismatch: ${diff.name}`} revealOnAnchorId={diff.anchors}>
<ImageDiffView diff={diff}/> <ImageDiffView diff={diff}/>
</AutoChip> </AutoChip>
</Anchor> </Anchor>
)} )}
{!!screenshots.length && <AutoChip header='Screenshots'> {!!screenshots.length && <AutoChip header='Screenshots' revealOnAnchorId={screenshotAnchors}>
{screenshots.map((a, i) => { {screenshots.map((a, i) => {
return <div key={`screenshot-${i}`}> return <Anchor key={`screenshot-${i}`} id={`attachment-${a.name}`}>
<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}></AttachmentLink>
</div>; </Anchor>;
})} })}
</AutoChip>} </AutoChip>}
{!!traces.length && <Anchor id='traces'><AutoChip header='Traces' revealOnAnchorId='traces'> {!!traces.length && <Anchor id='attachment-trace'><AutoChip header='Traces' revealOnAnchorId='attachment-trace'>
{<div> {<div>
<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 }} />
@ -118,7 +125,7 @@ export const TestResultView: React.FC<{
</div>} </div>}
</AutoChip></Anchor>} </AutoChip></Anchor>}
{!!videos.length && <Anchor id='videos'><AutoChip header='Videos' revealOnAnchorId='videos'> {!!videos.length && <Anchor id='attachment-video'><AutoChip header='Videos' revealOnAnchorId='attachment-video'>
{videos.map((a, i) => <div key={`video-${i}`}> {videos.map((a, i) => <div key={`video-${i}`}>
<video controls> <video controls>
<source src={a.path} type={a.contentType}/> <source src={a.path} type={a.contentType}/>
@ -127,11 +134,12 @@ export const TestResultView: React.FC<{
</div>)} </div>)}
</AutoChip></Anchor>} </AutoChip></Anchor>}
{!!(otherAttachments.size + htmls.length) && <AutoChip header='Attachments'> {!!otherAttachments.size && <AutoChip header='Attachments' revealOnAnchorId={otherAttachmentAnchors}>
{[...htmls].map((a, i) => ( {[...otherAttachments].map((a, i) =>
<AttachmentLink key={`html-link-${i}`} attachment={a} openInNewTab />) <Anchor key={`attachment-link-${i}`} id={`attachment-${a.name}`}>
<AttachmentLink attachment={a} openInNewTab={a.contentType.startsWith('text/html')} />
</Anchor>
)} )}
{[...otherAttachments].map((a, i) => <AttachmentLink key={`attachment-link-${i}`} attachment={a}></AttachmentLink>)}
</AutoChip>} </AutoChip>}
</div>; </div>;
}; };
@ -161,19 +169,23 @@ function classifyErrors(testErrors: string[], diffs: ImageDiff[]) {
} }
const StepTreeItem: React.FC<{ const StepTreeItem: React.FC<{
test: TestCase;
result: TestResult;
step: TestStep; step: TestStep;
depth: number, depth: number,
}> = ({ step, depth }) => { }> = ({ test, step, result, depth }) => {
return <TreeItem title={<span> const attachmentName = step.title.match(/^attach "(.*)"$/)?.[1];
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}></StepTreeItem>); const children = step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1} result={result} test={test} />);
if (step.snippet) if (step.snippet)
children.unshift(<TestErrorView testId='test-snippet' key='line' error={step.snippet}></TestErrorView>); children.unshift(<TestErrorView testId='test-snippet' key='line' error={step.snippet}/>);
return children; return children;
} : undefined} depth={depth}></TreeItem>; } : undefined} depth={depth}/>;
}; };

View file

@ -25,6 +25,11 @@
cursor: pointer; cursor: pointer;
} }
.tree-item-title.selected {
text-decoration: underline var(--color-underlinenav-icon);
text-decoration-thickness: 1.5px;
}
.tree-item-body { .tree-item-body {
min-height: 18px; min-height: 18px;
} }

View file

@ -17,6 +17,7 @@
import * as React from 'react'; import * as React from 'react';
import './treeItem.css'; import './treeItem.css';
import * as icons from './icons'; import * as icons from './icons';
import { clsx } from '@web/uiUtils';
export const TreeItem: React.FunctionComponent<{ export const TreeItem: React.FunctionComponent<{
title: JSX.Element, title: JSX.Element,
@ -28,9 +29,8 @@ export const TreeItem: React.FunctionComponent<{
style?: React.CSSProperties, style?: React.CSSProperties,
}> = ({ title, loadChildren, onClick, expandByDefault, depth, selected, style }) => { }> = ({ title, loadChildren, onClick, expandByDefault, depth, selected, style }) => {
const [expanded, setExpanded] = React.useState(expandByDefault || false); const [expanded, setExpanded] = React.useState(expandByDefault || false);
const className = selected ? 'tree-item-title selected' : 'tree-item-title';
return <div className={'tree-item'} style={style}> return <div className={'tree-item'} style={style}>
<span className={className} style={{ whiteSpace: 'nowrap', paddingLeft: depth * 22 + 4 }} onClick={() => { onClick?.(); setExpanded(!expanded); }} > <span className={clsx('tree-item-title', selected && 'selected')} style={{ whiteSpace: 'nowrap', paddingLeft: depth * 22 + 4 }} onClick={() => { onClick?.(); setExpanded(!expanded); }} >
{loadChildren && !!expanded && icons.downArrow()} {loadChildren && !!expanded && icons.downArrow()}
{loadChildren && !expanded && icons.rightArrow()} {loadChildren && !expanded && icons.rightArrow()}
{!loadChildren && <span style={{ visibility: 'hidden' }}>{icons.rightArrow()}</span>} {!loadChildren && <span style={{ visibility: 'hidden' }}>{icons.rightArrow()}</span>}

View file

@ -6,7 +6,7 @@ This project incorporates components from the projects listed below. The origina
- @types/node@17.0.24 (https://github.com/DefinitelyTyped/DefinitelyTyped) - @types/node@17.0.24 (https://github.com/DefinitelyTyped/DefinitelyTyped)
- @types/yauzl@2.10.0 (https://github.com/DefinitelyTyped/DefinitelyTyped) - @types/yauzl@2.10.0 (https://github.com/DefinitelyTyped/DefinitelyTyped)
- agent-base@7.1.1 (https://github.com/TooTallNate/proxy-agents) - agent-base@7.1.3 (https://github.com/TooTallNate/proxy-agents)
- balanced-match@1.0.2 (https://github.com/juliangruber/balanced-match) - balanced-match@1.0.2 (https://github.com/juliangruber/balanced-match)
- brace-expansion@1.1.11 (https://github.com/juliangruber/brace-expansion) - brace-expansion@1.1.11 (https://github.com/juliangruber/brace-expansion)
- buffer-crc32@0.2.13 (https://github.com/brianloveswords/buffer-crc32) - buffer-crc32@0.2.13 (https://github.com/brianloveswords/buffer-crc32)
@ -24,7 +24,7 @@ This project incorporates components from the projects listed below. The origina
- fd-slicer@1.1.0 (https://github.com/andrewrk/node-fd-slicer) - fd-slicer@1.1.0 (https://github.com/andrewrk/node-fd-slicer)
- get-stream@5.2.0 (https://github.com/sindresorhus/get-stream) - get-stream@5.2.0 (https://github.com/sindresorhus/get-stream)
- graceful-fs@4.2.10 (https://github.com/isaacs/node-graceful-fs) - graceful-fs@4.2.10 (https://github.com/isaacs/node-graceful-fs)
- https-proxy-agent@7.0.5 (https://github.com/TooTallNate/proxy-agents) - https-proxy-agent@7.0.6 (https://github.com/TooTallNate/proxy-agents)
- ip-address@9.0.5 (https://github.com/beaugunderson/ip-address) - ip-address@9.0.5 (https://github.com/beaugunderson/ip-address)
- is-docker@2.2.1 (https://github.com/sindresorhus/is-docker) - is-docker@2.2.1 (https://github.com/sindresorhus/is-docker)
- is-wsl@2.2.0 (https://github.com/sindresorhus/is-wsl) - is-wsl@2.2.0 (https://github.com/sindresorhus/is-wsl)
@ -43,7 +43,7 @@ This project incorporates components from the projects listed below. The origina
- retry@0.12.0 (https://github.com/tim-kos/node-retry) - retry@0.12.0 (https://github.com/tim-kos/node-retry)
- signal-exit@3.0.7 (https://github.com/tapjs/signal-exit) - signal-exit@3.0.7 (https://github.com/tapjs/signal-exit)
- smart-buffer@4.2.0 (https://github.com/JoshGlazebrook/smart-buffer) - smart-buffer@4.2.0 (https://github.com/JoshGlazebrook/smart-buffer)
- socks-proxy-agent@8.0.4 (https://github.com/TooTallNate/proxy-agents) - socks-proxy-agent@8.0.5 (https://github.com/TooTallNate/proxy-agents)
- socks@2.8.3 (https://github.com/JoshGlazebrook/socks) - socks@2.8.3 (https://github.com/JoshGlazebrook/socks)
- sprintf-js@1.1.3 (https://github.com/alexei/sprintf.js) - sprintf-js@1.1.3 (https://github.com/alexei/sprintf.js)
- stack-utils@2.0.5 (https://github.com/tapjs/stack-utils) - stack-utils@2.0.5 (https://github.com/tapjs/stack-utils)
@ -105,7 +105,7 @@ MIT License
========================================= =========================================
END OF @types/yauzl@2.10.0 AND INFORMATION END OF @types/yauzl@2.10.0 AND INFORMATION
%% agent-base@7.1.1 NOTICES AND INFORMATION BEGIN HERE %% agent-base@7.1.3 NOTICES AND INFORMATION BEGIN HERE
========================================= =========================================
(The MIT License) (The MIT License)
@ -130,7 +130,7 @@ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
========================================= =========================================
END OF agent-base@7.1.1 AND INFORMATION END OF agent-base@7.1.3 AND INFORMATION
%% balanced-match@1.0.2 NOTICES AND INFORMATION BEGIN HERE %% balanced-match@1.0.2 NOTICES AND INFORMATION BEGIN HERE
========================================= =========================================
@ -542,7 +542,7 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
========================================= =========================================
END OF graceful-fs@4.2.10 AND INFORMATION END OF graceful-fs@4.2.10 AND INFORMATION
%% https-proxy-agent@7.0.5 NOTICES AND INFORMATION BEGIN HERE %% https-proxy-agent@7.0.6 NOTICES AND INFORMATION BEGIN HERE
========================================= =========================================
(The MIT License) (The MIT License)
@ -567,7 +567,7 @@ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
========================================= =========================================
END OF https-proxy-agent@7.0.5 AND INFORMATION END OF https-proxy-agent@7.0.6 AND INFORMATION
%% ip-address@9.0.5 NOTICES AND INFORMATION BEGIN HERE %% ip-address@9.0.5 NOTICES AND INFORMATION BEGIN HERE
========================================= =========================================
@ -1005,7 +1005,7 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
========================================= =========================================
END OF smart-buffer@4.2.0 AND INFORMATION END OF smart-buffer@4.2.0 AND INFORMATION
%% socks-proxy-agent@8.0.4 NOTICES AND INFORMATION BEGIN HERE %% socks-proxy-agent@8.0.5 NOTICES AND INFORMATION BEGIN HERE
========================================= =========================================
(The MIT License) (The MIT License)
@ -1030,7 +1030,7 @@ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
========================================= =========================================
END OF socks-proxy-agent@8.0.4 AND INFORMATION END OF socks-proxy-agent@8.0.5 AND INFORMATION
%% socks@2.8.3 NOTICES AND INFORMATION BEGIN HERE %% socks@2.8.3 NOTICES AND INFORMATION BEGIN HERE
========================================= =========================================

View file

@ -3,21 +3,15 @@
"browsers": [ "browsers": [
{ {
"name": "chromium", "name": "chromium",
"revision": "1151", "revision": "1152",
"installByDefault": true, "installByDefault": true,
"browserVersion": "132.0.6834.32" "browserVersion": "132.0.6834.46"
},
{
"name": "chromium-headless-shell",
"revision": "1151",
"installByDefault": true,
"browserVersion": "132.0.6834.32"
}, },
{ {
"name": "chromium-tip-of-tree", "name": "chromium-tip-of-tree",
"revision": "1284", "revision": "1287",
"installByDefault": false, "installByDefault": false,
"browserVersion": "133.0.6878.0" "browserVersion": "133.0.6901.0"
}, },
{ {
"name": "firefox", "name": "firefox",
@ -33,7 +27,7 @@
}, },
{ {
"name": "webkit", "name": "webkit",
"revision": "2113", "revision": "2119",
"installByDefault": true, "installByDefault": true,
"revisionOverrides": { "revisionOverrides": {
"debian11-x64": "2105", "debian11-x64": "2105",

View file

@ -14,7 +14,7 @@
"diff": "^7.0.0", "diff": "^7.0.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"graceful-fs": "4.2.10", "graceful-fs": "4.2.10",
"https-proxy-agent": "7.0.5", "https-proxy-agent": "7.0.6",
"jpeg-js": "0.4.4", "jpeg-js": "0.4.4",
"mime": "^3.0.0", "mime": "^3.0.0",
"minimatch": "^3.1.2", "minimatch": "^3.1.2",
@ -24,7 +24,7 @@
"proxy-from-env": "1.1.0", "proxy-from-env": "1.1.0",
"retry": "0.12.0", "retry": "0.12.0",
"signal-exit": "3.0.7", "signal-exit": "3.0.7",
"socks-proxy-agent": "8.0.4", "socks-proxy-agent": "8.0.5",
"stack-utils": "2.0.5", "stack-utils": "2.0.5",
"ws": "8.17.1", "ws": "8.17.1",
"yaml": "^2.6.0" "yaml": "^2.6.0"
@ -140,13 +140,10 @@
} }
}, },
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "7.1.1", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
"integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
"license": "MIT", "license": "MIT",
"dependencies": {
"debug": "^4.3.4"
},
"engines": { "engines": {
"node": ">= 14" "node": ">= 14"
} }
@ -244,12 +241,12 @@
"integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="
}, },
"node_modules/https-proxy-agent": { "node_modules/https-proxy-agent": {
"version": "7.0.5", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"agent-base": "^7.0.2", "agent-base": "^7.1.2",
"debug": "4" "debug": "4"
}, },
"engines": { "engines": {
@ -403,12 +400,12 @@
} }
}, },
"node_modules/socks-proxy-agent": { "node_modules/socks-proxy-agent": {
"version": "8.0.4", "version": "8.0.5",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.4.tgz", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
"integrity": "sha512-GNAq/eg8Udq2x0eNiFkr9gRg5bA7PXEWagQdeRX4cPSG+X/8V38v637gim9bjFptMk1QWsCTr0ttrJEiXbNnRw==", "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"agent-base": "^7.1.1", "agent-base": "^7.1.2",
"debug": "^4.3.4", "debug": "^4.3.4",
"socks": "^2.8.3" "socks": "^2.8.3"
}, },
@ -562,12 +559,9 @@
} }
}, },
"agent-base": { "agent-base": {
"version": "7.1.1", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
"integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="
"requires": {
"debug": "^4.3.4"
}
}, },
"balanced-match": { "balanced-match": {
"version": "1.0.2", "version": "1.0.2",
@ -632,11 +626,11 @@
"integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="
}, },
"https-proxy-agent": { "https-proxy-agent": {
"version": "7.0.5", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"requires": { "requires": {
"agent-base": "^7.0.2", "agent-base": "^7.1.2",
"debug": "4" "debug": "4"
} }
}, },
@ -740,11 +734,11 @@
} }
}, },
"socks-proxy-agent": { "socks-proxy-agent": {
"version": "8.0.4", "version": "8.0.5",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.4.tgz", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
"integrity": "sha512-GNAq/eg8Udq2x0eNiFkr9gRg5bA7PXEWagQdeRX4cPSG+X/8V38v637gim9bjFptMk1QWsCTr0ttrJEiXbNnRw==", "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
"requires": { "requires": {
"agent-base": "^7.1.1", "agent-base": "^7.1.2",
"debug": "^4.3.4", "debug": "^4.3.4",
"socks": "^2.8.3" "socks": "^2.8.3"
} }

View file

@ -15,7 +15,7 @@
"diff": "^7.0.0", "diff": "^7.0.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"graceful-fs": "4.2.10", "graceful-fs": "4.2.10",
"https-proxy-agent": "7.0.5", "https-proxy-agent": "7.0.6",
"jpeg-js": "0.4.4", "jpeg-js": "0.4.4",
"mime": "^3.0.0", "mime": "^3.0.0",
"minimatch": "^3.1.2", "minimatch": "^3.1.2",
@ -25,7 +25,7 @@
"proxy-from-env": "1.1.0", "proxy-from-env": "1.1.0",
"retry": "0.12.0", "retry": "0.12.0",
"signal-exit": "3.0.7", "signal-exit": "3.0.7",
"socks-proxy-agent": "8.0.4", "socks-proxy-agent": "8.0.5",
"stack-utils": "2.0.5", "stack-utils": "2.0.5",
"ws": "8.17.1", "ws": "8.17.1",
"yaml": "^2.6.0" "yaml": "^2.6.0"

View file

@ -38,7 +38,7 @@ export class AndroidServerLauncherImpl {
if (options.deviceSerialNumber) { if (options.deviceSerialNumber) {
devices = devices.filter(d => d.serial === options.deviceSerialNumber); devices = devices.filter(d => d.serial === options.deviceSerialNumber);
if (devices.length === 0) if (devices.length === 0)
throw new Error(`No device with serial number '${options.deviceSerialNumber}' not found`); throw new Error(`No device with serial number '${options.deviceSerialNumber}' was found`);
} }
if (devices.length > 1) if (devices.length > 1)

View file

@ -31,7 +31,6 @@ import type { Browser } from '../client/browser';
import type { Page } from '../client/page'; import type { Page } from '../client/page';
import type { BrowserType } from '../client/browserType'; import type { BrowserType } from '../client/browserType';
import type { BrowserContextOptions, LaunchOptions } from '../client/types'; import type { BrowserContextOptions, LaunchOptions } from '../client/types';
import { spawn } from 'child_process';
import { wrapInASCIIBox, isLikelyNpxGlobal, assert, gracefullyProcessExitDoNotHang, getPackageManagerExecCommand } from '../utils'; import { wrapInASCIIBox, isLikelyNpxGlobal, assert, gracefullyProcessExitDoNotHang, getPackageManagerExecCommand } from '../utils';
import type { Executable } from '../server'; import type { Executable } from '../server';
import { registry, writeDockerVersion } from '../server'; import { registry, writeDockerVersion } from '../server';
@ -77,21 +76,6 @@ Examples:
$ codegen --target=python $ codegen --target=python
$ codegen -b webkit https://example.com`); $ codegen -b webkit https://example.com`);
program
.command('debug <app> [args...]', { hidden: true })
.description('run command in debug mode: disable timeout, open inspector')
.allowUnknownOption(true)
.action(function(app, options) {
spawn(app, options, {
env: { ...process.env, PWDEBUG: '1' },
stdio: 'inherit'
});
}).addHelpText('afterAll', `
Examples:
$ debug node test.js
$ debug npm run test`);
function suggestedBrowsersToInstall() { function suggestedBrowsersToInstall() {
return registry.executables().filter(e => e.installType !== 'none' && e.type !== 'tool').map(e => e.name).join(', '); return registry.executables().filter(e => e.installType !== 'none' && e.type !== 'tool').map(e => e.name).join(', ');
} }
@ -291,7 +275,7 @@ program
}); });
program program
.command('run-server', { hidden: true }) .command('run-server')
.option('--port <port>', 'Server port') .option('--port <port>', 'Server port')
.option('--host <host>', 'Server host') .option('--host <host>', 'Server host')
.option('--path <path>', 'Endpoint Path', '/') .option('--path <path>', 'Endpoint Path', '/')

View file

@ -103,7 +103,7 @@ export class ElectronApplication extends ChannelOwner<channels.ElectronApplicati
async firstWindow(options?: { timeout?: number }): Promise<Page> { async firstWindow(options?: { timeout?: number }): Promise<Page> {
if (this._windows.size) if (this._windows.size)
return this._windows.values().next().value; return this._windows.values().next().value!;
return await this.waitForEvent('window', options); return await this.waitForEvent('window', options);
} }

View file

@ -385,6 +385,7 @@ scheme.DebugControllerInitializer = tOptional(tObject({}));
scheme.DebugControllerInspectRequestedEvent = tObject({ scheme.DebugControllerInspectRequestedEvent = tObject({
selector: tString, selector: tString,
locator: tString, locator: tString,
ariaSnapshot: tString,
}); });
scheme.DebugControllerSetModeRequestedEvent = tObject({ scheme.DebugControllerSetModeRequestedEvent = tObject({
mode: tString, mode: tString,

View file

@ -22,7 +22,7 @@ import { Browser } from '../browser';
import { assertBrowserContextIsNotOwned, BrowserContext } from '../browserContext'; import { assertBrowserContextIsNotOwned, BrowserContext } from '../browserContext';
import type { SdkObject } from '../instrumentation'; import type { SdkObject } from '../instrumentation';
import * as network from '../network'; import * as network from '../network';
import type { InitScript, Page, PageDelegate } from '../page'; import type { InitScript, Page } from '../page';
import type { ConnectionTransport } from '../transport'; import type { ConnectionTransport } from '../transport';
import type * as types from '../types'; import type * as types from '../types';
import type { BidiSession } from './bidiConnection'; import type { BidiSession } from './bidiConnection';
@ -99,8 +99,8 @@ export class BidiBrowser extends Browser {
browser._defaultContext = new BidiBrowserContext(browser, undefined, options.persistent); browser._defaultContext = new BidiBrowserContext(browser, undefined, options.persistent);
await (browser._defaultContext as BidiBrowserContext)._initialize(); await (browser._defaultContext as BidiBrowserContext)._initialize();
// Create default page as we cannot get access to the existing one. // Create default page as we cannot get access to the existing one.
const pageDelegate = await browser._defaultContext.newPageDelegate(); const page = await browser._defaultContext.doCreateNewPage();
await pageDelegate.pageOrError(); await page.waitForInitializedOrError();
} }
return browser; return browser;
} }
@ -207,17 +207,17 @@ export class BidiBrowserContext extends BrowserContext {
return [...this._browser._bidiPages.values()].filter(bidiPage => bidiPage._browserContext === this); return [...this._browser._bidiPages.values()].filter(bidiPage => bidiPage._browserContext === this);
} }
pages(): Page[] { override possiblyUninitializedPages(): Page[] {
return this._bidiPages().map(bidiPage => bidiPage._initializedPage).filter(Boolean) as Page[]; return this._bidiPages().map(bidiPage => bidiPage._page);
} }
async newPageDelegate(): Promise<PageDelegate> { override async doCreateNewPage(): Promise<Page> {
assertBrowserContextIsNotOwned(this); assertBrowserContextIsNotOwned(this);
const { context } = await this._browser._browserSession.send('browsingContext.create', { const { context } = await this._browser._browserSession.send('browsingContext.create', {
type: bidi.BrowsingContext.CreateType.Window, type: bidi.BrowsingContext.CreateType.Window,
userContext: this._browserContextId, userContext: this._browserContextId,
}); });
return this._browser._bidiPages.get(context)!; return this._browser._bidiPages.get(context)!._page;
} }
async doGetCookies(urls: string[]): Promise<channels.NetworkCookie[]> { async doGetCookies(urls: string[]): Promise<channels.NetworkCookie[]> {

View file

@ -43,7 +43,6 @@ export class BidiPage implements PageDelegate {
readonly rawKeyboard: RawKeyboardImpl; readonly rawKeyboard: RawKeyboardImpl;
readonly rawTouchscreen: RawTouchscreenImpl; readonly rawTouchscreen: RawTouchscreenImpl;
readonly _page: Page; readonly _page: Page;
private readonly _pagePromise: Promise<Page | Error>;
readonly _session: BidiSession; readonly _session: BidiSession;
readonly _opener: BidiPage | null; readonly _opener: BidiPage | null;
private readonly _realmToContext: Map<string, dom.FrameExecutionContext>; private readonly _realmToContext: Map<string, dom.FrameExecutionContext>;
@ -51,7 +50,6 @@ export class BidiPage implements PageDelegate {
readonly _browserContext: BidiBrowserContext; readonly _browserContext: BidiBrowserContext;
readonly _networkManager: BidiNetworkManager; readonly _networkManager: BidiNetworkManager;
private readonly _pdf: BidiPDF; private readonly _pdf: BidiPDF;
_initializedPage: Page | null = null;
private _initScriptIds: string[] = []; private _initScriptIds: string[] = [];
constructor(browserContext: BidiBrowserContext, bidiSession: BidiSession, opener: BidiPage | null) { constructor(browserContext: BidiBrowserContext, bidiSession: BidiSession, opener: BidiPage | null) {
@ -81,16 +79,10 @@ export class BidiPage implements PageDelegate {
]; ];
// Initialize main frame. // Initialize main frame.
this._pagePromise = this._initialize().finally(async () => { // TODO: Wait for first execution context to be created and maybe about:blank navigated.
await this._page.initOpener(this._opener); this._initialize().then(
}).then(() => { () => this._page.reportAsNew(this._opener?._page),
this._initializedPage = this._page; error => this._page.reportAsNew(this._opener?._page, error));
this._page.reportAsNew();
return this._page;
}).catch(e => {
this._page.reportAsNew(e);
return e;
});
} }
private async _initialize() { private async _initialize() {
@ -109,21 +101,12 @@ export class BidiPage implements PageDelegate {
return Promise.all(this._page.allInitScripts().map(initScript => this.addInitScript(initScript))); return Promise.all(this._page.allInitScripts().map(initScript => this.addInitScript(initScript)));
} }
potentiallyUninitializedPage(): Page {
return this._page;
}
didClose() { didClose() {
this._session.dispose(); this._session.dispose();
eventsHelper.removeEventListeners(this._sessionListeners); eventsHelper.removeEventListeners(this._sessionListeners);
this._page._didClose(); this._page._didClose();
} }
async pageOrError(): Promise<Page | Error> {
// TODO: Wait for first execution context to be created and maybe about:blank navigated.
return this._pagePromise;
}
private _onFrameAttached(frameId: string, parentFrameId: string | null): frames.Frame { private _onFrameAttached(frameId: string, parentFrameId: string | null): frames.Frame {
return this._page._frameManager.frameAttached(frameId, parentFrameId); return this._page._frameManager.frameAttached(frameId, parentFrameId);
} }
@ -372,7 +355,7 @@ export class BidiPage implements PageDelegate {
private async _onScriptMessage(event: bidi.Script.MessageParameters) { private async _onScriptMessage(event: bidi.Script.MessageParameters) {
if (event.channel !== kPlaywrightBindingChannel) if (event.channel !== kPlaywrightBindingChannel)
return; return;
const pageOrError = await this.pageOrError(); const pageOrError = await this._page.waitForInitializedOrError();
if (pageOrError instanceof Error) if (pageOrError instanceof Error)
return; return;
const context = this._realmToContext.get(event.source.realm); const context = this._realmToContext.get(event.source.realm);

View file

@ -24,7 +24,6 @@ import type * as frames from './frames';
import { helper } from './helper'; import { helper } from './helper';
import * as network from './network'; import * as network from './network';
import { InitScript } from './page'; import { InitScript } from './page';
import type { PageDelegate } from './page';
import { Page, PageBinding } from './page'; import { Page, PageBinding } from './page';
import type { Progress, ProgressController } from './progress'; import type { Progress, ProgressController } from './progress';
import type { Selectors } from './selectors'; import type { Selectors } from './selectors';
@ -257,9 +256,13 @@ export abstract class BrowserContext extends SdkObject {
this.emit(BrowserContext.Events.Close); this.emit(BrowserContext.Events.Close);
} }
pages(): Page[] {
return this.possiblyUninitializedPages().filter(page => page.initializedOrUndefined());
}
// BrowserContext methods. // BrowserContext methods.
abstract pages(): Page[]; abstract possiblyUninitializedPages(): Page[];
abstract newPageDelegate(): Promise<PageDelegate>; abstract doCreateNewPage(): Promise<Page>;
abstract addCookies(cookies: channels.SetNetworkCookie[]): Promise<void>; abstract addCookies(cookies: channels.SetNetworkCookie[]): Promise<void>;
abstract setGeolocation(geolocation?: types.Geolocation): Promise<void>; abstract setGeolocation(geolocation?: types.Geolocation): Promise<void>;
abstract setExtraHTTPHeaders(headers: types.HeadersArray): Promise<void>; abstract setExtraHTTPHeaders(headers: types.HeadersArray): Promise<void>;
@ -358,31 +361,34 @@ export abstract class BrowserContext extends SdkObject {
this._timeoutSettings.setDefaultTimeout(timeout); this._timeoutSettings.setDefaultTimeout(timeout);
} }
async _loadDefaultContextAsIs(progress: Progress): Promise<Page[]> { async _loadDefaultContextAsIs(progress: Progress): Promise<Page | undefined> {
if (!this.pages().length) { if (!this.possiblyUninitializedPages().length) {
const waitForEvent = helper.waitForEvent(progress, this, BrowserContext.Events.Page); const waitForEvent = helper.waitForEvent(progress, this, BrowserContext.Events.Page);
progress.cleanupWhenAborted(() => waitForEvent.dispose); progress.cleanupWhenAborted(() => waitForEvent.dispose);
const page = (await waitForEvent.promise) as Page; // Race against BrowserContext.close
if (page._pageIsError) await Promise.race([waitForEvent.promise, this._closePromise]);
throw page._pageIsError;
} }
const pages = this.pages(); const page = this.possiblyUninitializedPages()[0];
if (pages[0]._pageIsError) if (!page)
throw pages[0]._pageIsError; return;
await pages[0].mainFrame()._waitForLoadState(progress, 'load'); const pageOrError = await page.waitForInitializedOrError();
return pages; if (pageOrError instanceof Error)
throw pageOrError;
await page.mainFrame()._waitForLoadState(progress, 'load');
return page;
} }
async _loadDefaultContext(progress: Progress) { async _loadDefaultContext(progress: Progress) {
const pages = await this._loadDefaultContextAsIs(progress); const defaultPage = await this._loadDefaultContextAsIs(progress);
if (!defaultPage)
return;
const browserName = this._browser.options.name; const browserName = this._browser.options.name;
if ((this._options.isMobile && browserName === 'chromium') || (this._options.locale && browserName === 'webkit')) { if ((this._options.isMobile && browserName === 'chromium') || (this._options.locale && browserName === 'webkit')) {
// Workaround for: // Workaround for:
// - chromium fails to change isMobile for existing page; // - chromium fails to change isMobile for existing page;
// - webkit fails to change locale for existing page. // - webkit fails to change locale for existing page.
const oldPage = pages[0];
await this.newPage(progress.metadata); await this.newPage(progress.metadata);
await oldPage.close(progress.metadata); await defaultPage.close(progress.metadata);
} }
} }
@ -480,10 +486,10 @@ export abstract class BrowserContext extends SdkObject {
} }
async newPage(metadata: CallMetadata): Promise<Page> { async newPage(metadata: CallMetadata): Promise<Page> {
const pageDelegate = await this.newPageDelegate(); const page = await this.doCreateNewPage();
if (metadata.isServerSide) if (metadata.isServerSide)
pageDelegate.potentiallyUninitializedPage().markAsServerSideOnly(); page.markAsServerSideOnly();
const pageOrError = await pageDelegate.pageOrError(); const pageOrError = await page.waitForInitializedOrError();
if (pageOrError instanceof Page) { if (pageOrError instanceof Page) {
if (pageOrError.isClosed()) if (pageOrError.isClosed())
throw new Error('Page has been closed.'); throw new Error('Page has been closed.');

View file

@ -299,6 +299,6 @@ class CRAXNode implements accessibility.AXNode {
for (const childId of node._payload.childIds || []) for (const childId of node._payload.childIds || [])
node._children.push(nodeById.get(childId)!); node._children.push(nodeById.get(childId)!);
} }
return nodeById.values().next().value; return nodeById.values().next().value!;
} }
} }

View file

@ -21,7 +21,7 @@ import { Browser } from '../browser';
import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext'; import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext';
import { assert, createGuid } from '../../utils'; import { assert, createGuid } from '../../utils';
import * as network from '../network'; import * as network from '../network';
import type { InitScript, PageDelegate, Worker } from '../page'; import type { InitScript, Worker } from '../page';
import { Page } from '../page'; import { Page } from '../page';
import { Frame } from '../frames'; import { Frame } from '../frames';
import type { Dialog } from '../dialog'; import type { Dialog } from '../dialog';
@ -146,7 +146,7 @@ export class CRBrowser extends Browser {
} }
async _waitForAllPagesToBeInitialized() { async _waitForAllPagesToBeInitialized() {
await Promise.all([...this._crPages.values()].map(page => page.pageOrError())); await Promise.all([...this._crPages.values()].map(crPage => crPage._page.waitForInitializedOrError()));
} }
_onAttachedToTarget({ targetInfo, sessionId, waitingForDebugger }: Protocol.Target.attachedToTargetPayload) { _onAttachedToTarget({ targetInfo, sessionId, waitingForDebugger }: Protocol.Target.attachedToTargetPayload) {
@ -259,10 +259,10 @@ export class CRBrowser extends Browser {
} }
page.willBeginDownload(); page.willBeginDownload();
let originPage = page._initializedPage; let originPage = page._page.initializedOrUndefined();
// If it's a new window download, report it on the opener page. // If it's a new window download, report it on the opener page.
if (!originPage && page._opener) if (!originPage && page._opener)
originPage = page._opener._initializedPage; originPage = page._opener._page.initializedOrUndefined();
if (!originPage) if (!originPage)
return; return;
this._downloadCreated(originPage, payload.guid, payload.url, payload.suggestedFilename); this._downloadCreated(originPage, payload.guid, payload.url, payload.suggestedFilename);
@ -364,11 +364,11 @@ export class CRBrowserContext extends BrowserContext {
return [...this._browser._crPages.values()].filter(crPage => crPage._browserContext === this); return [...this._browser._crPages.values()].filter(crPage => crPage._browserContext === this);
} }
pages(): Page[] { override possiblyUninitializedPages(): Page[] {
return this._crPages().map(crPage => crPage._initializedPage).filter(Boolean) as Page[]; return this._crPages().map(crPage => crPage._page);
} }
async newPageDelegate(): Promise<PageDelegate> { override async doCreateNewPage(): Promise<Page> {
assertBrowserContextIsNotOwned(this); assertBrowserContextIsNotOwned(this);
const oldKeys = this._browser.isClank() ? new Set(this._browser._crPages.keys()) : undefined; const oldKeys = this._browser.isClank() ? new Set(this._browser._crPages.keys()) : undefined;
@ -391,7 +391,7 @@ export class CRBrowserContext extends BrowserContext {
assert(newKeys.size === 1); assert(newKeys.size === 1);
[targetId] = [...newKeys]; [targetId] = [...newKeys];
} }
return this._browser._crPages.get(targetId)!; return this._browser._crPages.get(targetId)!._page;
} }
async doGetCookies(urls: string[]): Promise<channels.NetworkCookie[]> { async doGetCookies(urls: string[]): Promise<channels.NetworkCookie[]> {
@ -544,7 +544,7 @@ export class CRBrowserContext extends BrowserContext {
// When persistent context is closed, we do not necessary get Target.detachedFromTarget // When persistent context is closed, we do not necessary get Target.detachedFromTarget
// for all the background pages. // for all the background pages.
for (const [targetId, backgroundPage] of this._browser._backgroundPages.entries()) { for (const [targetId, backgroundPage] of this._browser._backgroundPages.entries()) {
if (backgroundPage._browserContext === this && backgroundPage._initializedPage) { if (backgroundPage._browserContext === this && backgroundPage._page.initializedOrUndefined()) {
backgroundPage.didClose(); backgroundPage.didClose();
this._browser._backgroundPages.delete(targetId); this._browser._backgroundPages.delete(targetId);
} }
@ -569,8 +569,8 @@ export class CRBrowserContext extends BrowserContext {
backgroundPages(): Page[] { backgroundPages(): Page[] {
const result: Page[] = []; const result: Page[] = [];
for (const backgroundPage of this._browser._backgroundPages.values()) { for (const backgroundPage of this._browser._backgroundPages.values()) {
if (backgroundPage._browserContext === this && backgroundPage._initializedPage) if (backgroundPage._browserContext === this && backgroundPage._page.initializedOrUndefined())
result.push(backgroundPage._initializedPage); result.push(backgroundPage._page);
} }
return result; return result;
} }

View file

@ -96,7 +96,7 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
function rewriteError(error: Error): Protocol.Runtime.evaluateReturnValue { function rewriteError(error: Error): Protocol.Runtime.evaluateReturnValue {
if (error.message.includes('Object reference chain is too long')) if (error.message.includes('Object reference chain is too long'))
return { result: { type: 'undefined' } }; throw new Error('Cannot serialize result: object reference chain is too long.');
if (error.message.includes('Object couldn\'t be returned by value')) if (error.message.includes('Object couldn\'t be returned by value'))
return { result: { type: 'undefined' } }; return { result: { type: 'undefined' } };

View file

@ -65,8 +65,6 @@ export class CRPage implements PageDelegate {
private readonly _pdf: CRPDF; private readonly _pdf: CRPDF;
private readonly _coverage: CRCoverage; private readonly _coverage: CRCoverage;
readonly _browserContext: CRBrowserContext; readonly _browserContext: CRBrowserContext;
private readonly _pagePromise: Promise<Page | Error>;
_initializedPage: Page | null = null;
private _isBackgroundPage: boolean; private _isBackgroundPage: boolean;
// Holds window features for the next popup being opened via window.open, // Holds window features for the next popup being opened via window.open,
@ -108,30 +106,11 @@ export class CRPage implements PageDelegate {
if (viewportSize) if (viewportSize)
this._page._emulatedSize = { viewport: viewportSize, screen: viewportSize }; this._page._emulatedSize = { viewport: viewportSize, screen: viewportSize };
} }
// Note: it is important to call |reportAsNew| before resolving pageOrError promise,
// so that anyone who awaits pageOrError got a ready and reported page.
this._pagePromise = this._mainFrameSession._initialize(bits.hasUIWindow).then(async r => {
await this._page.initOpener(this._opener);
return r;
}).catch(async e => {
await this._page.initOpener(this._opener);
throw e;
}).then(() => {
this._initializedPage = this._page;
this._reportAsNew();
return this._page;
}).catch(e => {
this._reportAsNew(e);
return e;
});
}
potentiallyUninitializedPage(): Page { const createdEvent = this._isBackgroundPage ? CRBrowserContext.CREvents.BackgroundPage : BrowserContext.Events.Page;
return this._page; this._mainFrameSession._initialize(bits.hasUIWindow).then(
} () => this._page.reportAsNew(this._opener?._page, undefined, createdEvent),
error => this._page.reportAsNew(this._opener?._page, error, createdEvent));
private _reportAsNew(error?: Error) {
this._page.reportAsNew(error, this._isBackgroundPage ? CRBrowserContext.CREvents.BackgroundPage : BrowserContext.Events.Page);
} }
private async _forAllFrameSessions(cb: (frame: FrameSession) => Promise<any>) { private async _forAllFrameSessions(cb: (frame: FrameSession) => Promise<any>) {
@ -168,10 +147,6 @@ export class CRPage implements PageDelegate {
this._mainFrameSession._willBeginDownload(); this._mainFrameSession._willBeginDownload();
} }
async pageOrError(): Promise<Page | Error> {
return this._pagePromise;
}
didClose() { didClose() {
for (const session of this._sessions.values()) for (const session of this._sessions.values())
session.dispose(); session.dispose();
@ -432,6 +407,9 @@ class FrameSession {
this._firstNonInitialNavigationCommittedFulfill = f; this._firstNonInitialNavigationCommittedFulfill = f;
this._firstNonInitialNavigationCommittedReject = r; this._firstNonInitialNavigationCommittedReject = r;
}); });
// The Promise is not always awaited (e.g. FrameSession._initialize can throw)
// so we catch errors here to prevent unhandled promise rejection.
this._firstNonInitialNavigationCommittedPromise.catch(() => {});
} }
_isMainFrame(): boolean { _isMainFrame(): boolean {
@ -489,7 +467,7 @@ class FrameSession {
// Note: it is important to start video recorder before sending Page.startScreencast, // Note: it is important to start video recorder before sending Page.startScreencast,
// and it is equally important to send Page.startScreencast before sending Runtime.runIfWaitingForDebugger. // and it is equally important to send Page.startScreencast before sending Runtime.runIfWaitingForDebugger.
await this._createVideoRecorder(screencastId, screencastOptions); await this._createVideoRecorder(screencastId, screencastOptions);
this._crPage.pageOrError().then(p => { this._crPage._page.waitForInitializedOrError().then(p => {
if (p instanceof Error) if (p instanceof Error)
this._stopVideoRecording().catch(() => {}); this._stopVideoRecording().catch(() => {});
}); });
@ -830,7 +808,7 @@ class FrameSession {
} }
async _onBindingCalled(event: Protocol.Runtime.bindingCalledPayload) { async _onBindingCalled(event: Protocol.Runtime.bindingCalledPayload) {
const pageOrError = await this._crPage.pageOrError(); const pageOrError = await this._crPage._page.waitForInitializedOrError();
if (!(pageOrError instanceof Error)) { if (!(pageOrError instanceof Error)) {
const context = this._contextIdToContext.get(event.executionContextId); const context = this._contextIdToContext.get(event.executionContextId);
if (context) if (context)
@ -895,8 +873,7 @@ class FrameSession {
} }
_willBeginDownload() { _willBeginDownload() {
const originPage = this._crPage._initializedPage; if (!this._crPage._page.initializedOrUndefined()) {
if (!originPage) {
// Resume the page creation with an error. The page will automatically close right // Resume the page creation with an error. The page will automatically close right
// after the download begins. // after the download begins.
this._firstNonInitialNavigationCommittedReject(new Error('Starting new page download')); this._firstNonInitialNavigationCommittedReject(new Error('Starting new page download'));
@ -936,7 +913,7 @@ class FrameSession {
}); });
// Wait for the first frame before reporting video to the client. // Wait for the first frame before reporting video to the client.
gotFirstFrame.then(() => { gotFirstFrame.then(() => {
this._crPage._browserContext._browser._videoStarted(this._crPage._browserContext, screencastId, options.outputFile, this._crPage.pageOrError()); this._crPage._browserContext._browser._videoStarted(this._crPage._browserContext, screencastId, options.outputFile, this._crPage._page.waitForInitializedOrError());
}); });
} }

View file

@ -25,6 +25,9 @@ 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 { parseYamlForAriaSnapshot } from './ariaSnapshot';
import type { ParsedYaml } from '../utils/isomorphic/ariaSnapshot';
import { parseYamlTemplate } from '../utils/isomorphic/ariaSnapshot';
import { unsafeLocatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser';
const internalMetadata = serverSideCallMetadata(); const internalMetadata = serverSideCallMetadata();
@ -144,9 +147,17 @@ export class DebugController extends SdkObject {
} }
async highlight(params: { selector?: string, ariaTemplate?: string }) { async highlight(params: { selector?: string, ariaTemplate?: string }) {
// Assert parameters validity.
if (params.selector)
unsafeLocatorOrSelectorAsSelector(this._sdkLanguage, params.selector, 'data-testid');
let parsedYaml: ParsedYaml | undefined;
if (params.ariaTemplate) {
parsedYaml = parseYamlForAriaSnapshot(params.ariaTemplate);
parseYamlTemplate(parsedYaml);
}
for (const recorder of await this._allRecorders()) { for (const recorder of await this._allRecorders()) {
if (params.ariaTemplate) if (parsedYaml)
recorder.setHighlightedAriaTemplate(parseYamlForAriaSnapshot(params.ariaTemplate)); recorder.setHighlightedAriaTemplate(parsedYaml);
else if (params.selector) else if (params.selector)
recorder.setHighlightedSelector(this._sdkLanguage, params.selector); recorder.setHighlightedSelector(this._sdkLanguage, params.selector);
} }
@ -228,7 +239,7 @@ class InspectingRecorderApp extends EmptyRecorderApp {
override async elementPicked(elementInfo: ElementInfo): Promise<void> { override async elementPicked(elementInfo: ElementInfo): Promise<void> {
const locator: string = asLocator(this._debugController._sdkLanguage, elementInfo.selector); const locator: string = asLocator(this._debugController._sdkLanguage, elementInfo.selector);
this._debugController.emit(DebugController.Events.InspectRequested, { selector: elementInfo.selector, locator }); this._debugController.emit(DebugController.Events.InspectRequested, { selector: elementInfo.selector, locator, ariaSnapshot: elementInfo.ariaSnapshot });
} }
override async setSources(sources: Source[]): Promise<void> { override async setSources(sources: Source[]): Promise<void> {

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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 Safari/537.36", "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",
"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.32 Safari/537.36", "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",
"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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 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.46 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.32 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.46 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.32 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.46 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.32 Mobile Safari/537.36", "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",
"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.32 Mobile Safari/537.36", "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",
"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.32 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.46 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.32 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.46 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.32 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.46 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.32 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.46 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.32 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.46 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.32 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.46 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.32 Safari/537.36", "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.46 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.32 Safari/537.36 Edg/132.0.6834.32", "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",
"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.32 Safari/537.36", "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.46 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.32 Safari/537.36 Edg/132.0.6834.32", "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",
"screen": { "screen": {
"width": 1920, "width": 1920,
"height": 1080 "height": 1080

View file

@ -32,8 +32,8 @@ export class DebugControllerDispatcher extends Dispatcher<DebugController, chann
eventsHelper.addEventListener(this._object, DebugController.Events.StateChanged, params => { eventsHelper.addEventListener(this._object, DebugController.Events.StateChanged, params => {
this._dispatchEvent('stateChanged', params); this._dispatchEvent('stateChanged', params);
}), }),
eventsHelper.addEventListener(this._object, DebugController.Events.InspectRequested, ({ selector, locator }) => { eventsHelper.addEventListener(this._object, DebugController.Events.InspectRequested, ({ selector, locator, ariaSnapshot }) => {
this._dispatchEvent('inspectRequested', { selector, locator }); this._dispatchEvent('inspectRequested', { selector, locator, ariaSnapshot });
}), }),
eventsHelper.addEventListener(this._object, DebugController.Events.SourceChanged, ({ text, header, footer, actions }) => { eventsHelper.addEventListener(this._object, DebugController.Events.SourceChanged, ({ text, header, footer, actions }) => {
this._dispatchEvent('sourceChanged', ({ text, header, footer, actions })); this._dispatchEvent('sourceChanged', ({ text, header, footer, actions }));

View file

@ -21,7 +21,7 @@ import type { BrowserOptions } from '../browser';
import { Browser } from '../browser'; import { Browser } from '../browser';
import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext'; import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext';
import * as network from '../network'; import * as network from '../network';
import type { InitScript, Page, PageDelegate } from '../page'; import type { InitScript, Page } from '../page';
import { PageBinding } from '../page'; import { PageBinding } from '../page';
import type { ConnectionTransport } from '../transport'; import type { ConnectionTransport } from '../transport';
import type * as types from '../types'; import type * as types from '../types';
@ -136,14 +136,14 @@ export class FFBrowser extends Browser {
// Abort the navigation that turned into download. // Abort the navigation that turned into download.
ffPage._page._frameManager.frameAbortedNavigation(payload.frameId, 'Download is starting'); ffPage._page._frameManager.frameAbortedNavigation(payload.frameId, 'Download is starting');
let originPage = ffPage._initializedPage; let originPage = ffPage._page.initializedOrUndefined();
// If it's a new window download, report it on the opener page. // If it's a new window download, report it on the opener page.
if (!originPage) { if (!originPage) {
// Resume the page creation with an error. The page will automatically close right // Resume the page creation with an error. The page will automatically close right
// after the download begins. // after the download begins.
ffPage._markAsError(new Error('Starting new page download')); ffPage._markAsError(new Error('Starting new page download'));
if (ffPage._opener) if (ffPage._opener)
originPage = ffPage._opener._initializedPage; originPage = ffPage._opener._page.initializedOrUndefined();
} }
if (!originPage) if (!originPage)
return; return;
@ -267,11 +267,11 @@ export class FFBrowserContext extends BrowserContext {
return Array.from(this._browser._ffPages.values()).filter(ffPage => ffPage._browserContext === this); return Array.from(this._browser._ffPages.values()).filter(ffPage => ffPage._browserContext === this);
} }
pages(): Page[] { override possiblyUninitializedPages(): Page[] {
return this._ffPages().map(ffPage => ffPage._initializedPage).filter(pageOrNull => !!pageOrNull) as Page[]; return this._ffPages().map(ffPage => ffPage._page);
} }
async newPageDelegate(): Promise<PageDelegate> { override async doCreateNewPage(): Promise<Page> {
assertBrowserContextIsNotOwned(this); assertBrowserContextIsNotOwned(this);
const { targetId } = await this._browser.session.send('Browser.newPage', { const { targetId } = await this._browser.session.send('Browser.newPage', {
browserContextId: this._browserContextId browserContextId: this._browserContextId
@ -280,7 +280,7 @@ export class FFBrowserContext extends BrowserContext {
throw new Error(`Invalid timezone ID: ${this._options.timezoneId}`); throw new Error(`Invalid timezone ID: ${this._options.timezoneId}`);
throw e; throw e;
}); });
return this._browser._ffPages.get(targetId)!; return this._browser._ffPages.get(targetId)!._page;
} }
async doGetCookies(urls: string[]): Promise<channels.NetworkCookie[]> { async doGetCookies(urls: string[]): Promise<channels.NetworkCookie[]> {

View file

@ -34,7 +34,6 @@ import type { Protocol } from './protocol';
import type { Progress } from '../progress'; import type { Progress } from '../progress';
import { splitErrorMessage } from '../../utils/stackTrace'; import { splitErrorMessage } from '../../utils/stackTrace';
import { debugLogger } from '../../utils/debugLogger'; import { debugLogger } from '../../utils/debugLogger';
import { ManualPromise } from '../../utils/manualPromise';
import { BrowserContext } from '../browserContext'; import { BrowserContext } from '../browserContext';
import { TargetClosedError } from '../errors'; import { TargetClosedError } from '../errors';
@ -49,9 +48,7 @@ export class FFPage implements PageDelegate {
readonly _page: Page; readonly _page: Page;
readonly _networkManager: FFNetworkManager; readonly _networkManager: FFNetworkManager;
readonly _browserContext: FFBrowserContext; readonly _browserContext: FFBrowserContext;
private _pagePromise = new ManualPromise<Page | Error>(); private _reportedAsNew = false;
_initializedPage: Page | null = null;
private _initializationFailed = false;
readonly _opener: FFPage | null; readonly _opener: FFPage | null;
private readonly _contextIdToContext: Map<string, dom.FrameExecutionContext>; private readonly _contextIdToContext: Map<string, dom.FrameExecutionContext>;
private _eventListeners: RegisteredListener[]; private _eventListeners: RegisteredListener[];
@ -102,40 +99,23 @@ export class FFPage implements PageDelegate {
eventsHelper.addEventListener(this._session, 'Page.screencastFrame', this._onScreencastFrame.bind(this)), eventsHelper.addEventListener(this._session, 'Page.screencastFrame', this._onScreencastFrame.bind(this)),
]; ];
this._session.once('Page.ready', async () => { this._session.once('Page.ready', () => {
await this._page.initOpener(this._opener); if (this._reportedAsNew)
if (this._initializationFailed)
return; return;
// Note: it is important to call |reportAsNew| before resolving pageOrError promise, this._reportedAsNew = true;
// so that anyone who awaits pageOrError got a ready and reported page. this._page.reportAsNew(this._opener?._page);
this._initializedPage = this._page;
this._page.reportAsNew();
this._pagePromise.resolve(this._page);
}); });
// Ideally, we somehow ensure that utility world is created before Page.ready arrives, but currently it is racy. // Ideally, we somehow ensure that utility world is created before Page.ready arrives, but currently it is racy.
// Therefore, we can end up with an initialized page without utility world, although very unlikely. // Therefore, we can end up with an initialized page without utility world, although very unlikely.
this.addInitScript(new InitScript('', true), UTILITY_WORLD_NAME).catch(e => this._markAsError(e)); this.addInitScript(new InitScript('', true), UTILITY_WORLD_NAME).catch(e => this._markAsError(e));
} }
potentiallyUninitializedPage(): Page {
return this._page;
}
async _markAsError(error: Error) { async _markAsError(error: Error) {
// Same error may be report twice: channer disconnected and session.send fails. // Same error may be reported twice: channel disconnected and session.send fails.
if (this._initializationFailed) if (this._reportedAsNew)
return; return;
this._initializationFailed = true; this._reportedAsNew = true;
this._page.reportAsNew(this._opener?._page, error);
if (!this._initializedPage) {
await this._page.initOpener(this._opener);
this._page.reportAsNew(error);
this._pagePromise.resolve(error);
}
}
async pageOrError(): Promise<Page | Error> {
return this._pagePromise;
} }
_onWebSocketCreated(event: Protocol.Page.webSocketCreatedPayload) { _onWebSocketCreated(event: Protocol.Page.webSocketCreatedPayload) {
@ -268,7 +248,7 @@ export class FFPage implements PageDelegate {
} }
async _onBindingCalled(event: Protocol.Page.bindingCalledPayload) { async _onBindingCalled(event: Protocol.Page.bindingCalledPayload) {
const pageOrError = await this.pageOrError(); const pageOrError = await this._page.waitForInitializedOrError();
if (!(pageOrError instanceof Error)) { if (!(pageOrError instanceof Error)) {
const context = this._contextIdToContext.get(event.executionContextId); const context = this._contextIdToContext.get(event.executionContextId);
if (context) if (context)
@ -333,7 +313,7 @@ export class FFPage implements PageDelegate {
} }
_onVideoRecordingStarted(event: Protocol.Page.videoRecordingStartedPayload) { _onVideoRecordingStarted(event: Protocol.Page.videoRecordingStartedPayload) {
this._browserContext._browser._videoStarted(this._browserContext, event.screencastId, event.file, this.pageOrError()); this._browserContext._browser._videoStarted(this._browserContext, event.screencastId, event.file, this._page.waitForInitializedOrError());
} }
didClose() { didClose() {

View file

@ -158,8 +158,10 @@ function toAriaNode(element: Element): AriaNode | null {
if (roleUtils.kAriaSelectedRoles.includes(role)) if (roleUtils.kAriaSelectedRoles.includes(role))
result.selected = roleUtils.getAriaSelected(element); result.selected = roleUtils.getAriaSelected(element);
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
result.children = [element.value]; if (element.type !== 'checkbox' && element.type !== 'radio')
result.children = [element.value];
}
return result; return result;
} }

View file

@ -149,21 +149,24 @@ x-pw-tools-list {
x-pw-tool-item { x-pw-tool-item {
pointer-events: auto; pointer-events: auto;
cursor: pointer;
height: 28px; height: 28px;
width: 28px; width: 28px;
border-radius: 3px; border-radius: 3px;
} }
x-pw-tool-item:not(.disabled) {
cursor: pointer;
}
x-pw-tool-item:not(.disabled):hover { x-pw-tool-item:not(.disabled):hover {
background-color: hsl(0, 0%, 86%); background-color: hsl(0, 0%, 86%);
} }
x-pw-tool-item.active { x-pw-tool-item.toggled {
background-color: rgba(138, 202, 228, 0.5); background-color: rgba(138, 202, 228, 0.5);
} }
x-pw-tool-item.active:not(.disabled):hover { x-pw-tool-item.toggled:not(.disabled):hover {
background-color: #8acae4c4; background-color: #8acae4c4;
} }
@ -179,18 +182,22 @@ x-pw-tool-item.disabled > x-div {
cursor: default; cursor: default;
} }
x-pw-tool-item.record.active { x-pw-tool-item.record.toggled {
background-color: transparent; background-color: transparent;
} }
x-pw-tool-item.record.active:hover { x-pw-tool-item.record.toggled:not(.disabled):hover {
background-color: hsl(0, 0%, 86%); background-color: hsl(0, 0%, 86%);
} }
x-pw-tool-item.record.active > x-div { x-pw-tool-item.record.toggled > x-div {
background-color: #a1260d; background-color: #a1260d;
} }
x-pw-tool-item.record.disabled.toggled > x-div {
opacity: 0.8;
}
x-pw-tool-item.accept > x-div { x-pw-tool-item.accept > x-div {
background-color: #388a34; background-color: #388a34;
} }

View file

@ -457,7 +457,8 @@ export class InjectedScript {
const queryAll = (root: SelectorRoot, body: string) => { const queryAll = (root: SelectorRoot, body: string) => {
if (root.nodeType !== 1 /* Node.ELEMENT_NODE */) if (root.nodeType !== 1 /* Node.ELEMENT_NODE */)
return []; return [];
return isElementVisible(root as Element) === Boolean(body) ? [root as Element] : []; const visible = body === 'true';
return isElementVisible(root as Element) === visible ? [root as Element] : [];
}; };
return { queryAll }; return { queryAll };
} }

View file

@ -883,9 +883,13 @@ class Overlay {
this._dragState = { offsetX: this._offsetX, dragStart: { x: (event as MouseEvent).clientX, y: 0 } }; this._dragState = { offsetX: this._offsetX, dragStart: { x: (event as MouseEvent).clientX, y: 0 } };
}), }),
addEventListener(this._recordToggle, 'click', () => { addEventListener(this._recordToggle, 'click', () => {
if (this._recordToggle.classList.contains('disabled'))
return;
this._recorder.setMode(this._recorder.state.mode === 'none' || this._recorder.state.mode === 'standby' || this._recorder.state.mode === 'inspecting' ? 'recording' : 'standby'); this._recorder.setMode(this._recorder.state.mode === 'none' || this._recorder.state.mode === 'standby' || this._recorder.state.mode === 'inspecting' ? 'recording' : 'standby');
}), }),
addEventListener(this._pickLocatorToggle, 'click', () => { addEventListener(this._pickLocatorToggle, 'click', () => {
if (this._pickLocatorToggle.classList.contains('disabled'))
return;
const newMode: Record<Mode, Mode> = { const newMode: Record<Mode, Mode> = {
'inspecting': 'standby', 'inspecting': 'standby',
'none': 'inspecting', 'none': 'inspecting',
@ -929,15 +933,15 @@ class Overlay {
} }
setUIState(state: UIState) { setUIState(state: UIState) {
this._recordToggle.classList.toggle('active', state.mode === 'recording' || state.mode === 'assertingText' || state.mode === 'assertingVisibility' || state.mode === 'assertingValue' || state.mode === 'recording-inspecting'); this._recordToggle.classList.toggle('toggled', state.mode === 'recording' || state.mode === 'assertingText' || state.mode === 'assertingVisibility' || state.mode === 'assertingValue' || state.mode === 'assertingSnapshot' || state.mode === 'recording-inspecting');
this._pickLocatorToggle.classList.toggle('active', state.mode === 'inspecting' || state.mode === 'recording-inspecting'); this._pickLocatorToggle.classList.toggle('toggled', state.mode === 'inspecting' || state.mode === 'recording-inspecting');
this._assertVisibilityToggle.classList.toggle('active', state.mode === 'assertingVisibility'); this._assertVisibilityToggle.classList.toggle('toggled', state.mode === 'assertingVisibility');
this._assertVisibilityToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting'); this._assertVisibilityToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting');
this._assertTextToggle.classList.toggle('active', state.mode === 'assertingText'); this._assertTextToggle.classList.toggle('toggled', state.mode === 'assertingText');
this._assertTextToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting'); this._assertTextToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting');
this._assertValuesToggle.classList.toggle('active', state.mode === 'assertingValue'); this._assertValuesToggle.classList.toggle('toggled', state.mode === 'assertingValue');
this._assertValuesToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting'); this._assertValuesToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting');
this._assertSnapshotToggle.classList.toggle('active', state.mode === 'assertingSnapshot'); this._assertSnapshotToggle.classList.toggle('toggled', state.mode === 'assertingSnapshot');
this._assertSnapshotToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting'); this._assertSnapshotToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting');
if (this._offsetX !== state.overlay.offsetX) { if (this._offsetX !== state.overlay.offsetX) {
this._offsetX = state.overlay.offsetX; this._offsetX = state.overlay.offsetX;

View file

@ -38,9 +38,9 @@ const kOtherTestIdScore = 2; // other data-test* attributes
const kIframeByAttributeScore = 10; const kIframeByAttributeScore = 10;
const kBeginPenalizedScore = 50; const kBeginPenalizedScore = 50;
const kPlaceholderScore = 100; const kRoleWithNameScore = 100;
const kLabelScore = 120; const kPlaceholderScore = 120;
const kRoleWithNameScore = 140; const kLabelScore = 140;
const kAltTextScore = 160; const kAltTextScore = 160;
const kTextScore = 180; const kTextScore = 180;
const kTitleScore = 200; const kTitleScore = 200;
@ -268,7 +268,7 @@ function buildNoTextCandidates(injectedScript: InjectedScript, element: Element,
if (input.placeholder) { if (input.placeholder) {
candidates.push({ engine: 'internal:attr', selector: `[placeholder=${escapeForAttributeSelector(input.placeholder, true)}]`, score: kPlaceholderScoreExact }); candidates.push({ engine: 'internal:attr', selector: `[placeholder=${escapeForAttributeSelector(input.placeholder, true)}]`, score: kPlaceholderScoreExact });
for (const alternative of suitableTextAlternatives(input.placeholder)) for (const alternative of suitableTextAlternatives(input.placeholder))
candidates.push({ engine: 'internal:attr', selector: `[placeholder=${escapeForAttributeSelector(alternative.text, false)}]`, score: kPlaceholderScore - alternative.scoreBouns }); candidates.push({ engine: 'internal:attr', selector: `[placeholder=${escapeForAttributeSelector(alternative.text, false)}]`, score: kPlaceholderScore - alternative.scoreBonus });
} }
} }
@ -277,7 +277,7 @@ function buildNoTextCandidates(injectedScript: InjectedScript, element: Element,
const labelText = label.normalized; const labelText = label.normalized;
candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(labelText, true), score: kLabelScoreExact }); candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(labelText, true), score: kLabelScoreExact });
for (const alternative of suitableTextAlternatives(labelText)) for (const alternative of suitableTextAlternatives(labelText))
candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(alternative.text, false), score: kLabelScore - alternative.scoreBouns }); candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(alternative.text, false), score: kLabelScore - alternative.scoreBonus });
} }
const ariaRole = getAriaRole(element); const ariaRole = getAriaRole(element);
@ -308,28 +308,28 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i
if (title) { if (title) {
candidates.push([{ engine: 'internal:attr', selector: `[title=${escapeForAttributeSelector(title, true)}]`, score: kTitleScoreExact }]); candidates.push([{ engine: 'internal:attr', selector: `[title=${escapeForAttributeSelector(title, true)}]`, score: kTitleScoreExact }]);
for (const alternative of suitableTextAlternatives(title)) for (const alternative of suitableTextAlternatives(title))
candidates.push([{ engine: 'internal:attr', selector: `[title=${escapeForAttributeSelector(alternative.text, false)}]`, score: kTitleScore - alternative.scoreBouns }]); candidates.push([{ engine: 'internal:attr', selector: `[title=${escapeForAttributeSelector(alternative.text, false)}]`, score: kTitleScore - alternative.scoreBonus }]);
} }
const alt = element.getAttribute('alt'); const alt = element.getAttribute('alt');
if (alt && ['APPLET', 'AREA', 'IMG', 'INPUT'].includes(element.nodeName)) { if (alt && ['APPLET', 'AREA', 'IMG', 'INPUT'].includes(element.nodeName)) {
candidates.push([{ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(alt, true)}]`, score: kAltTextScoreExact }]); candidates.push([{ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(alt, true)}]`, score: kAltTextScoreExact }]);
for (const alternative of suitableTextAlternatives(alt)) for (const alternative of suitableTextAlternatives(alt))
candidates.push([{ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(alternative.text, false)}]`, score: kAltTextScore - alternative.scoreBouns }]); candidates.push([{ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(alternative.text, false)}]`, score: kAltTextScore - alternative.scoreBonus }]);
} }
const text = elementText(injectedScript._evaluator._cacheText, element).normalized; const text = elementText(injectedScript._evaluator._cacheText, element).normalized;
const textAlternatives = text ? suitableTextAlternatives(text) : [];
if (text) { if (text) {
const alternatives = suitableTextAlternatives(text);
if (isTargetNode) { if (isTargetNode) {
if (text.length <= 80) if (text.length <= 80)
candidates.push([{ engine: 'internal:text', selector: escapeForTextSelector(text, true), score: kTextScoreExact }]); candidates.push([{ engine: 'internal:text', selector: escapeForTextSelector(text, true), score: kTextScoreExact }]);
for (const alternative of alternatives) for (const alternative of textAlternatives)
candidates.push([{ engine: 'internal:text', selector: escapeForTextSelector(alternative.text, false), score: kTextScore - alternative.scoreBouns }]); candidates.push([{ engine: 'internal:text', selector: escapeForTextSelector(alternative.text, false), score: kTextScore - alternative.scoreBonus }]);
} }
const cssToken: SelectorToken = { engine: 'css', selector: cssEscape(element.nodeName.toLowerCase()), score: kCSSTagNameScore }; const cssToken: SelectorToken = { engine: 'css', selector: cssEscape(element.nodeName.toLowerCase()), score: kCSSTagNameScore };
for (const alternative of alternatives) for (const alternative of textAlternatives)
candidates.push([cssToken, { engine: 'internal:has-text', selector: escapeForTextSelector(alternative.text, false), score: kTextScore - alternative.scoreBouns }]); candidates.push([cssToken, { engine: 'internal:has-text', selector: escapeForTextSelector(alternative.text, false), score: kTextScore - alternative.scoreBonus }]);
if (text.length <= 80) { if (text.length <= 80) {
const re = new RegExp('^' + escapeRegExp(text) + '$'); const re = new RegExp('^' + escapeRegExp(text) + '$');
candidates.push([cssToken, { engine: 'internal:has-text', selector: escapeForTextSelector(re, false), score: kTextScoreRegex }]); candidates.push([cssToken, { engine: 'internal:has-text', selector: escapeForTextSelector(re, false), score: kTextScoreRegex }]);
@ -340,9 +340,18 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i
if (ariaRole && !['none', 'presentation'].includes(ariaRole)) { if (ariaRole && !['none', 'presentation'].includes(ariaRole)) {
const ariaName = getElementAccessibleName(element, false); const ariaName = getElementAccessibleName(element, false);
if (ariaName) { if (ariaName) {
candidates.push([{ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, true)}]`, score: kRoleWithNameScoreExact }]); const roleToken = { engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, true)}]`, score: kRoleWithNameScoreExact };
candidates.push([roleToken]);
for (const alternative of suitableTextAlternatives(ariaName)) for (const alternative of suitableTextAlternatives(ariaName))
candidates.push([{ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(alternative.text, false)}]`, score: kRoleWithNameScore - alternative.scoreBouns }]); candidates.push([{ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(alternative.text, false)}]`, score: kRoleWithNameScore - alternative.scoreBonus }]);
} else {
const roleToken = { engine: 'internal:role', selector: `${ariaRole}`, score: kRoleWithoutNameScore };
for (const alternative of textAlternatives)
candidates.push([roleToken, { engine: 'internal:has-text', selector: escapeForTextSelector(alternative.text, false), score: kTextScore - alternative.scoreBonus }]);
if (text.length <= 80) {
const re = new RegExp('^' + escapeRegExp(text) + '$');
candidates.push([roleToken, { engine: 'internal:has-text', selector: escapeForTextSelector(re, false), score: kTextScoreRegex }]);
}
} }
} }
@ -527,14 +536,14 @@ function trimWordBoundary(text: string, maxLength: number) {
} }
function suitableTextAlternatives(text: string) { function suitableTextAlternatives(text: string) {
let result: { text: string, scoreBouns: number }[] = []; let result: { text: string, scoreBonus: number }[] = [];
{ {
const match = text.match(/^([\d.,]+)[^.,\w]/); const match = text.match(/^([\d.,]+)[^.,\w]/);
const leadingNumberLength = match ? match[1].length : 0; const leadingNumberLength = match ? match[1].length : 0;
if (leadingNumberLength) { if (leadingNumberLength) {
const alt = trimWordBoundary(text.substring(leadingNumberLength).trimStart(), 80); const alt = trimWordBoundary(text.substring(leadingNumberLength).trimStart(), 80);
result.push({ text: alt, scoreBouns: alt.length <= 30 ? 2 : 1 }); result.push({ text: alt, scoreBonus: alt.length <= 30 ? 2 : 1 });
} }
} }
@ -543,20 +552,20 @@ function suitableTextAlternatives(text: string) {
const trailingNumberLength = match ? match[1].length : 0; const trailingNumberLength = match ? match[1].length : 0;
if (trailingNumberLength) { if (trailingNumberLength) {
const alt = trimWordBoundary(text.substring(0, text.length - trailingNumberLength).trimEnd(), 80); const alt = trimWordBoundary(text.substring(0, text.length - trailingNumberLength).trimEnd(), 80);
result.push({ text: alt, scoreBouns: alt.length <= 30 ? 2 : 1 }); result.push({ text: alt, scoreBonus: alt.length <= 30 ? 2 : 1 });
} }
} }
if (text.length <= 30) { if (text.length <= 30) {
result.push({ text, scoreBouns: 0 }); result.push({ text, scoreBonus: 0 });
} else { } else {
result.push({ text: trimWordBoundary(text, 80), scoreBouns: 0 }); result.push({ text: trimWordBoundary(text, 80), scoreBonus: 0 });
result.push({ text: trimWordBoundary(text, 30), scoreBouns: 1 }); result.push({ text: trimWordBoundary(text, 30), scoreBonus: 1 });
} }
result = result.filter(r => r.text); result = result.filter(r => r.text);
if (!result.length) if (!result.length)
result.push({ text: text.substring(0, 80), scoreBouns: 0 }); result.push({ text: text.substring(0, 80), scoreBonus: 0 });
return result; return result;
} }

View file

@ -73,6 +73,8 @@ export function elementText(cache: Map<Element | ShadowRoot, ElementText>, root:
if (child.nodeType === Node.TEXT_NODE) { if (child.nodeType === Node.TEXT_NODE) {
value.full += child.nodeValue || ''; value.full += child.nodeValue || '';
currentImmediate += child.nodeValue || ''; currentImmediate += child.nodeValue || '';
} else if (child.nodeType === Node.COMMENT_NODE) {
continue;
} else { } else {
if (currentImmediate) if (currentImmediate)
value.immediate.push(currentImmediate); value.immediate.push(currentImmediate);

View file

@ -59,8 +59,6 @@ export interface PageDelegate {
addInitScript(initScript: InitScript): Promise<void>; addInitScript(initScript: InitScript): Promise<void>;
removeNonInternalInitScripts(): Promise<void>; removeNonInternalInitScripts(): Promise<void>;
closePage(runBeforeUnload: boolean): Promise<void>; closePage(runBeforeUnload: boolean): Promise<void>;
potentiallyUninitializedPage(): Page;
pageOrError(): Promise<Page | Error>;
navigateFrame(frame: frames.Frame, url: string, referrer: string | undefined): Promise<frames.GotoResult>; navigateFrame(frame: frames.Frame, url: string, referrer: string | undefined): Promise<frames.GotoResult>;
@ -139,7 +137,8 @@ export class Page extends SdkObject {
private _closedState: 'open' | 'closing' | 'closed' = 'open'; private _closedState: 'open' | 'closing' | 'closed' = 'open';
private _closedPromise = new ManualPromise<void>(); private _closedPromise = new ManualPromise<void>();
private _initialized = false; private _initialized: Page | Error | undefined;
private _initializedPromise = new ManualPromise<Page | Error>();
private _eventsToEmitAfterInitialized: { event: string | symbol, args: any[] }[] = []; private _eventsToEmitAfterInitialized: { event: string | symbol, args: any[] }[] = [];
private _crashed = false; private _crashed = false;
readonly openScope = new LongStandingScope(); readonly openScope = new LongStandingScope();
@ -164,7 +163,6 @@ export class Page extends SdkObject {
_clientRequestInterceptor: network.RouteHandler | undefined; _clientRequestInterceptor: network.RouteHandler | undefined;
_serverRequestInterceptor: network.RouteHandler | undefined; _serverRequestInterceptor: network.RouteHandler | undefined;
_ownedContext: BrowserContext | undefined; _ownedContext: BrowserContext | undefined;
_pageIsError: Error | undefined;
_video: Artifact | null = null; _video: Artifact | null = null;
_opener: Page | undefined; _opener: Page | undefined;
private _isServerSideOnly = false; private _isServerSideOnly = false;
@ -194,23 +192,24 @@ export class Page extends SdkObject {
this.coverage = delegate.coverage ? delegate.coverage() : null; this.coverage = delegate.coverage ? delegate.coverage() : null;
} }
async initOpener(opener: PageDelegate | null) { async reportAsNew(opener: Page | undefined, error: Error | undefined = undefined, contextEvent: string = BrowserContext.Events.Page) {
if (!opener) if (opener) {
return; const openerPageOrError = await opener.waitForInitializedOrError();
const openerPage = await opener.pageOrError(); if (openerPageOrError instanceof Page && !openerPageOrError.isClosed())
if (openerPage instanceof Page && !openerPage.isClosed()) this._opener = openerPageOrError;
this._opener = openerPage; }
this._markInitialized(error, contextEvent);
} }
reportAsNew(error: Error | undefined = undefined, contextEvent: string = BrowserContext.Events.Page) { private _markInitialized(error: Error | undefined = undefined, contextEvent: string = BrowserContext.Events.Page) {
if (error) { if (error) {
// Initialization error could have happened because of // Initialization error could have happened because of
// context/browser closure. Just ignore the page. // context/browser closure. Just ignore the page.
if (this._browserContext.isClosingOrClosed()) if (this._browserContext.isClosingOrClosed())
return; return;
this._setIsError(error); this._frameManager.createDummyMainFrameIfNeeded();
} }
this._initialized = true; this._initialized = error || this;
this.emitOnContext(contextEvent, this); this.emitOnContext(contextEvent, this);
for (const { event, args } of this._eventsToEmitAfterInitialized) for (const { event, args } of this._eventsToEmitAfterInitialized)
@ -224,12 +223,20 @@ export class Page extends SdkObject {
this.emit(Page.Events.Close); this.emit(Page.Events.Close);
else else
this.instrumentation.onPageOpen(this); this.instrumentation.onPageOpen(this);
// Note: it is important to resolve _initializedPromise at the end,
// so that anyone who awaits waitForInitializedOrError got a ready and reported page.
this._initializedPromise.resolve(this._initialized);
} }
initializedOrUndefined() { initializedOrUndefined(): Page | undefined {
return this._initialized ? this : undefined; return this._initialized ? this : undefined;
} }
waitForInitializedOrError(): Promise<Page | Error> {
return this._initializedPromise;
}
emitOnContext(event: string | symbol, ...args: any[]) { emitOnContext(event: string | symbol, ...args: any[]) {
if (this._isServerSideOnly) if (this._isServerSideOnly)
return; return;
@ -709,11 +716,6 @@ export class Page extends SdkObject {
await this._ownedContext.close(options); await this._ownedContext.close(options);
} }
private _setIsError(error: Error) {
this._pageIsError = error;
this._frameManager.createDummyMainFrameIfNeeded();
}
isClosed(): boolean { isClosed(): boolean {
return this._closedState === 'closed'; return this._closedState === 'closed';
} }

View file

@ -300,7 +300,6 @@ async function generateFrameSelectorInParent(parent: Frame, frame: Frame): Promi
}, frameElement); }, frameElement);
return selector; return selector;
} catch (e) { } catch (e) {
return e.toString();
} }
}, monotonicTime() + 2000); }, monotonicTime() + 2000);
if (!result.timedOut && result.result) if (!result.timedOut && result.result)

View file

@ -38,7 +38,7 @@ export class RecorderCollection extends EventEmitter {
restart() { restart() {
this._actions = []; this._actions = [];
this._fireChange(); this.emit('change', []);
} }
setEnabled(enabled: boolean) { setEnabled(enabled: boolean) {
@ -128,6 +128,7 @@ export class RecorderCollection extends EventEmitter {
private _fireChange() { private _fireChange() {
if (!this._enabled) if (!this._enabled)
return; return;
this.emit('change', collapseActions(this._actions)); this.emit('change', collapseActions(this._actions));
} }
} }

View file

@ -174,6 +174,35 @@ const DOWNLOAD_PATHS: Record<BrowserName | InternalTool, DownloadPaths> = {
'mac15-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-mac-arm64.zip', 'mac15-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-mac-arm64.zip',
'win64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-win64.zip', 'win64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-win64.zip',
}, },
'chromium-tip-of-tree-headless-shell': {
'<unknown>': undefined,
'ubuntu18.04-x64': undefined,
'ubuntu20.04-x64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-linux.zip',
'ubuntu22.04-x64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-linux.zip',
'ubuntu24.04-x64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-linux.zip',
'ubuntu18.04-arm64': undefined,
'ubuntu20.04-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-linux-arm64.zip',
'ubuntu22.04-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-linux-arm64.zip',
'ubuntu24.04-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-linux-arm64.zip',
'debian11-x64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-linux.zip',
'debian11-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-linux-arm64.zip',
'debian12-x64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-linux.zip',
'debian12-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-linux-arm64.zip',
'mac10.13': undefined,
'mac10.14': undefined,
'mac10.15': undefined,
'mac11': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-mac.zip',
'mac11-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-mac-arm64.zip',
'mac12': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-mac.zip',
'mac12-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-mac-arm64.zip',
'mac13': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-mac.zip',
'mac13-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-mac-arm64.zip',
'mac14': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-mac.zip',
'mac14-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-mac-arm64.zip',
'mac15': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-mac.zip',
'mac15-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-mac-arm64.zip',
'win64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-headless-shell-win64.zip',
},
'firefox': { 'firefox': {
'<unknown>': undefined, '<unknown>': undefined,
'ubuntu18.04-x64': undefined, 'ubuntu18.04-x64': undefined,
@ -386,7 +415,14 @@ type BrowsersJSONDescriptor = {
}; };
function readDescriptors(browsersJSON: BrowsersJSON): BrowsersJSONDescriptor[] { function readDescriptors(browsersJSON: BrowsersJSON): BrowsersJSONDescriptor[] {
return (browsersJSON['browsers']).map(obj => { const headlessShells: BrowsersJSON['browsers'] = [];
for (const browserName of ['chromium', 'chromium-tip-of-tree']) {
headlessShells.push({
...browsersJSON.browsers.find(browser => browser.name === browserName)!,
name: `${browserName}-headless-shell`,
});
}
return [...browsersJSON.browsers, ...headlessShells].map(obj => {
const name = obj.name; const name = obj.name;
const revisionOverride = (obj.revisionOverrides || {})[hostPlatform]; const revisionOverride = (obj.revisionOverrides || {})[hostPlatform];
const revision = revisionOverride || obj.revision; const revision = revisionOverride || obj.revision;
@ -410,10 +446,10 @@ 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' | 'android'; type InternalTool = 'ffmpeg' | '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']; const allDownloadable = ['android', 'chromium', 'firefox', 'webkit', 'ffmpeg', 'firefox-beta', 'chromium-tip-of-tree', 'chromium-headless-shell', 'chromium-tip-of-tree-headless-shell'];
export interface Executable { export interface Executable {
type: 'browser' | 'tool' | 'channel'; type: 'browser' | 'tool' | 'channel';
@ -514,6 +550,24 @@ export class Registry {
_isHermeticInstallation: true, _isHermeticInstallation: true,
}); });
const chromiumTipOfTreeHeadlessShell = descriptors.find(d => d.name === 'chromium-tip-of-tree-headless-shell')!;
const chromiumTipOfTreeHeadlessShellExecutable = findExecutablePath(chromiumTipOfTreeHeadlessShell.dir, 'chromium-headless-shell');
this._executables.push({
type: 'channel',
name: 'chromium-tip-of-tree-headless-shell',
browserName: 'chromium',
directory: chromiumTipOfTreeHeadlessShell.dir,
executablePath: () => chromiumTipOfTreeHeadlessShellExecutable,
executablePathOrDie: (sdkLanguage: string) => executablePathOrDie('chromium', chromiumTipOfTreeHeadlessShellExecutable, chromiumTipOfTreeHeadlessShell.installByDefault, sdkLanguage),
installType: chromiumTipOfTreeHeadlessShell.installByDefault ? 'download-by-default' : 'download-on-demand',
_validateHostRequirements: (sdkLanguage: string) => this._validateHostRequirements(sdkLanguage, chromiumTipOfTreeHeadlessShell.dir, ['chrome-linux'], [], ['chrome-win']),
downloadURLs: this._downloadURLs(chromiumTipOfTreeHeadlessShell),
browserVersion: chromium.browserVersion,
_install: () => this._downloadExecutable(chromiumTipOfTreeHeadlessShell, chromiumTipOfTreeHeadlessShellExecutable),
_dependencyGroup: 'chromium',
_isHermeticInstallation: true,
});
const chromiumTipOfTree = descriptors.find(d => d.name === 'chromium-tip-of-tree')!; const chromiumTipOfTree = descriptors.find(d => d.name === 'chromium-tip-of-tree')!;
const chromiumTipOfTreeExecutable = findExecutablePath(chromiumTipOfTree.dir, 'chromium'); const chromiumTipOfTreeExecutable = findExecutablePath(chromiumTipOfTree.dir, 'chromium');
this._executables.push({ this._executables.push({

View file

@ -438,13 +438,13 @@ export function frameSnapshotStreamer(snapshotStreamer: string, removeNoScript:
expectValue(value); expectValue(value);
attrs[kSelectedAttribute] = value; attrs[kSelectedAttribute] = value;
} }
if (nodeName === 'CANVAS') { if (nodeName === 'CANVAS' || nodeName === 'IFRAME' || nodeName === 'FRAME') {
const boundingRect = (element as HTMLCanvasElement).getBoundingClientRect(); const boundingRect = (element as HTMLElement).getBoundingClientRect();
const value = JSON.stringify({ const value = JSON.stringify({
left: boundingRect.left / window.innerWidth, left: boundingRect.left,
top: boundingRect.top / window.innerHeight, top: boundingRect.top,
right: boundingRect.right / window.innerWidth, right: boundingRect.right,
bottom: boundingRect.bottom / window.innerHeight bottom: boundingRect.bottom
}); });
expectValue(kBoundingRectAttribute); expectValue(kBoundingRectAttribute);
expectValue(value); expectValue(value);

View file

@ -125,7 +125,13 @@ export async function installRootRedirect(server: HttpServer, traceUrls: string[
for (const reporter of options.reporter || []) for (const reporter of options.reporter || [])
params.append('reporter', reporter); params.append('reporter', reporter);
const urlPath = `./trace/${options.webApp || 'index.html'}?${params.toString()}`; let baseUrl = '.';
if (process.env.PW_HMR) {
baseUrl = 'http://localhost:44223'; // port is hardcoded in build.js
params.set('server', server.urlPrefix('precise'));
}
const urlPath = `${baseUrl}/trace/${options.webApp || 'index.html'}?${params.toString()}`;
server.routePath('/', (_, response) => { server.routePath('/', (_, response) => {
response.statusCode = 302; response.statusCode = 302;
response.setHeader('Location', urlPath); response.setHeader('Location', urlPath);

View file

@ -24,6 +24,7 @@ import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from '../utils/happy-
import type { HeadersArray } from './types'; import type { HeadersArray } from './types';
export const perMessageDeflate = { export const perMessageDeflate = {
clientNoContextTakeover: true,
zlibDeflateOptions: { zlibDeflateOptions: {
level: 3, level: 3,
}, },

View file

@ -20,7 +20,7 @@ import { Browser } from '../browser';
import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext'; import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext';
import { assert } from '../../utils'; import { assert } from '../../utils';
import * as network from '../network'; import * as network from '../network';
import type { InitScript, Page, PageDelegate } from '../page'; import type { InitScript, Page } from '../page';
import type { ConnectionTransport } from '../transport'; import type { ConnectionTransport } from '../transport';
import type * as types from '../types'; import type * as types from '../types';
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
@ -121,14 +121,14 @@ export class WKBrowser extends Browser {
// abort navigation that is still running. We should be able to fix this by // abort navigation that is still running. We should be able to fix this by
// instrumenting policy decision start/proceed/cancel. // instrumenting policy decision start/proceed/cancel.
page._page._frameManager.frameAbortedNavigation(payload.frameId, 'Download is starting'); page._page._frameManager.frameAbortedNavigation(payload.frameId, 'Download is starting');
let originPage = page._initializedPage; let originPage = page._page.initializedOrUndefined();
// If it's a new window download, report it on the opener page. // If it's a new window download, report it on the opener page.
if (!originPage) { if (!originPage) {
// Resume the page creation with an error. The page will automatically close right // Resume the page creation with an error. The page will automatically close right
// after the download begins. // after the download begins.
page._firstNonInitialNavigationCommittedReject(new Error('Starting new page download')); page._firstNonInitialNavigationCommittedReject(new Error('Starting new page download'));
if (page._opener) if (page._opener)
originPage = page._opener._initializedPage; originPage = page._opener._page.initializedOrUndefined();
} }
if (!originPage) if (!originPage)
return; return;
@ -239,14 +239,14 @@ export class WKBrowserContext extends BrowserContext {
return Array.from(this._browser._wkPages.values()).filter(wkPage => wkPage._browserContext === this); return Array.from(this._browser._wkPages.values()).filter(wkPage => wkPage._browserContext === this);
} }
pages(): Page[] { override possiblyUninitializedPages(): Page[] {
return this._wkPages().map(wkPage => wkPage._initializedPage).filter(pageOrNull => !!pageOrNull) as Page[]; return this._wkPages().map(wkPage => wkPage._page);
} }
async newPageDelegate(): Promise<PageDelegate> { override async doCreateNewPage(): Promise<Page> {
assertBrowserContextIsNotOwned(this); assertBrowserContextIsNotOwned(this);
const { pageProxyId } = await this._browser._browserSession.send('Playwright.createPage', { browserContextId: this._browserContextId }); const { pageProxyId } = await this._browser._browserSession.send('Playwright.createPage', { browserContextId: this._browserContextId });
return this._browser._wkPages.get(pageProxyId)!; return this._browser._wkPages.get(pageProxyId)!._page;
} }
async doGetCookies(urls: string[]): Promise<channels.NetworkCookie[]> { async doGetCookies(urls: string[]): Promise<channels.NetworkCookie[]> {

View file

@ -115,6 +115,8 @@ function potentiallyUnserializableValue(remoteObject: Protocol.Runtime.RemoteObj
} }
function rewriteError(error: Error): Error { function rewriteError(error: Error): Error {
if (error.message.includes('Object has too long reference chain'))
throw new Error('Cannot serialize result: object reference chain is too long.');
if (!js.isJavaScriptErrorInEvaluate(error) && !isSessionClosedError(error)) if (!js.isJavaScriptErrorInEvaluate(error) && !isSessionClosedError(error))
return new Error('Execution context was destroyed, most likely because of a navigation.'); return new Error('Execution context was destroyed, most likely because of a navigation.');
return error; return error;

View file

@ -43,7 +43,6 @@ import { WKInterceptableRequest, WKRouteImpl } from './wkInterceptableRequest';
import { WKProvisionalPage } from './wkProvisionalPage'; import { WKProvisionalPage } from './wkProvisionalPage';
import { WKWorkers } from './wkWorkers'; import { WKWorkers } from './wkWorkers';
import { debugLogger } from '../../utils/debugLogger'; import { debugLogger } from '../../utils/debugLogger';
import { ManualPromise } from '../../utils/manualPromise';
import { BrowserContext } from '../browserContext'; import { BrowserContext } from '../browserContext';
import { TargetClosedError } from '../errors'; import { TargetClosedError } from '../errors';
@ -56,7 +55,6 @@ export class WKPage implements PageDelegate {
_session: WKSession; _session: WKSession;
private _provisionalPage: WKProvisionalPage | null = null; private _provisionalPage: WKProvisionalPage | null = null;
readonly _page: Page; readonly _page: Page;
private readonly _pagePromise = new ManualPromise<Page | Error>();
private readonly _pageProxySession: WKSession; private readonly _pageProxySession: WKSession;
readonly _opener: WKPage | null; readonly _opener: WKPage | null;
private readonly _requestIdToRequest = new Map<string, WKInterceptableRequest>(); private readonly _requestIdToRequest = new Map<string, WKInterceptableRequest>();
@ -66,7 +64,6 @@ export class WKPage implements PageDelegate {
private _sessionListeners: RegisteredListener[] = []; private _sessionListeners: RegisteredListener[] = [];
private _eventListeners: RegisteredListener[]; private _eventListeners: RegisteredListener[];
readonly _browserContext: WKBrowserContext; readonly _browserContext: WKBrowserContext;
_initializedPage: Page | null = null;
private _firstNonInitialNavigationCommittedPromise: Promise<void>; private _firstNonInitialNavigationCommittedPromise: Promise<void>;
private _firstNonInitialNavigationCommittedFulfill = () => {}; private _firstNonInitialNavigationCommittedFulfill = () => {};
_firstNonInitialNavigationCommittedReject = (e: Error) => {}; _firstNonInitialNavigationCommittedReject = (e: Error) => {};
@ -111,10 +108,6 @@ export class WKPage implements PageDelegate {
} }
} }
potentiallyUninitializedPage(): Page {
return this._page;
}
private async _initializePageProxySession() { private async _initializePageProxySession() {
if (this._page._browserContext.isSettingStorageState()) if (this._page._browserContext.isSettingStorageState())
return; return;
@ -283,7 +276,7 @@ export class WKPage implements PageDelegate {
} }
handleProvisionalLoadFailed(event: Protocol.Playwright.provisionalLoadFailedPayload) { handleProvisionalLoadFailed(event: Protocol.Playwright.provisionalLoadFailedPayload) {
if (!this._initializedPage) { if (!this._page.initializedOrUndefined()) {
this._firstNonInitialNavigationCommittedReject(new Error('Initial load failed')); this._firstNonInitialNavigationCommittedReject(new Error('Initial load failed'));
return; return;
} }
@ -300,10 +293,6 @@ export class WKPage implements PageDelegate {
this._nextWindowOpenPopupFeatures = event.windowFeatures; this._nextWindowOpenPopupFeatures = event.windowFeatures;
} }
async pageOrError(): Promise<Page | Error> {
return this._pagePromise;
}
private async _onTargetCreated(event: Protocol.Target.targetCreatedPayload) { private async _onTargetCreated(event: Protocol.Target.targetCreatedPayload) {
const { targetInfo } = event; const { targetInfo } = event;
const session = new WKSession(this._pageProxySession.connection, targetInfo.targetId, (message: any) => { const session = new WKSession(this._pageProxySession.connection, targetInfo.targetId, (message: any) => {
@ -316,7 +305,7 @@ export class WKPage implements PageDelegate {
assert(targetInfo.type === 'page', 'Only page targets are expected in WebKit, received: ' + targetInfo.type); assert(targetInfo.type === 'page', 'Only page targets are expected in WebKit, received: ' + targetInfo.type);
if (!targetInfo.isProvisional) { if (!targetInfo.isProvisional) {
assert(!this._initializedPage); assert(!this._page.initializedOrUndefined());
let pageOrError: Page | Error; let pageOrError: Page | Error;
try { try {
this._setSession(session); this._setSession(session);
@ -343,12 +332,7 @@ export class WKPage implements PageDelegate {
// Avoid rejection on disconnect. // Avoid rejection on disconnect.
this._firstNonInitialNavigationCommittedPromise.catch(() => {}); this._firstNonInitialNavigationCommittedPromise.catch(() => {});
} }
await this._page.initOpener(this._opener); this._page.reportAsNew(this._opener?._page, pageOrError instanceof Page ? undefined : pageOrError);
// Note: it is important to call |reportAsNew| before resolving pageOrError promise,
// so that anyone who awaits pageOrError got a ready and reported page.
this._initializedPage = pageOrError instanceof Page ? pageOrError : null;
this._page.reportAsNew(pageOrError instanceof Page ? undefined : pageOrError);
this._pagePromise.resolve(pageOrError);
} else { } else {
assert(targetInfo.isProvisional); assert(targetInfo.isProvisional);
assert(!this._provisionalPage); assert(!this._provisionalPage);
@ -515,7 +499,7 @@ export class WKPage implements PageDelegate {
} }
private async _onBindingCalled(contextId: Protocol.Runtime.ExecutionContextId, argument: string) { private async _onBindingCalled(contextId: Protocol.Runtime.ExecutionContextId, argument: string) {
const pageOrError = await this.pageOrError(); const pageOrError = await this._page.waitForInitializedOrError();
if (!(pageOrError instanceof Error)) { if (!(pageOrError instanceof Error)) {
const context = this._contextIdToContext.get(contextId); const context = this._contextIdToContext.get(contextId);
if (context) if (context)
@ -821,7 +805,7 @@ export class WKPage implements PageDelegate {
toolbarHeight: this._toolbarHeight() toolbarHeight: this._toolbarHeight()
}); });
this._recordingVideoFile = options.outputFile; this._recordingVideoFile = options.outputFile;
this._browserContext._browser._videoStarted(this._browserContext, screencastId, options.outputFile, this.pageOrError()); this._browserContext._browser._videoStarted(this._browserContext, screencastId, options.outputFile, this._page.waitForInitializedOrError());
} }
async _stopVideo(): Promise<void> { async _stopVideo(): Promise<void> {
@ -849,7 +833,7 @@ export class WKPage implements PageDelegate {
this.validateScreenshotDimension(rect.height, omitDeviceScaleFactor); this.validateScreenshotDimension(rect.height, omitDeviceScaleFactor);
const result = await this._session.send('Page.snapshotRect', { ...rect, coordinateSystem: documentRect ? 'Page' : 'Viewport', omitDeviceScaleFactor }); const result = await this._session.send('Page.snapshotRect', { ...rect, coordinateSystem: documentRect ? 'Page' : 'Viewport', omitDeviceScaleFactor });
const prefix = 'data:image/png;base64,'; const prefix = 'data:image/png;base64,';
let buffer = Buffer.from(result.dataURL.substr(prefix.length), 'base64'); let buffer: Buffer = Buffer.from(result.dataURL.substr(prefix.length), 'base64');
if (format === 'jpeg') if (format === 'jpeg')
buffer = jpegjs.encode(PNG.sync.read(buffer), quality).data; buffer = jpegjs.encode(PNG.sync.read(buffer), quality).data;
return buffer; return buffer;

View file

@ -91,7 +91,8 @@ function parseLocator(locator: string, testIdAttributeName: string): { selector:
.replace(/newregex\(([^)]+)\)/g, 'r$1') .replace(/newregex\(([^)]+)\)/g, 'r$1')
.replace(/string=/g, '=') .replace(/string=/g, '=')
.replace(/regex=/g, '=') .replace(/regex=/g, '=')
.replace(/,,/g, ','); .replace(/,,/g, ',')
.replace(/,\)/g, ')');
const preferredQuote = params.map(p => p.quote).filter(quote => '\'"`'.includes(quote))[0] as Quote | undefined; const preferredQuote = params.map(p => p.quote).filter(quote => '\'"`'.includes(quote))[0] as Quote | undefined;
return { selector: transform(template, params, testIdAttributeName), preferredQuote }; return { selector: transform(template, params, testIdAttributeName), preferredQuote };
@ -174,6 +175,7 @@ function transform(template: string, params: TemplateParams, testIdAttributeName
.replace(/filter\(,?hasnot2=([^)]+)\)/g, 'internal:has-not=$1') .replace(/filter\(,?hasnot2=([^)]+)\)/g, 'internal:has-not=$1')
.replace(/,exact=false/g, '') .replace(/,exact=false/g, '')
.replace(/,exact=true/g, 's') .replace(/,exact=true/g, 's')
.replace(/,includehidden=/g, ',include-hidden=')
.replace(/\,/g, ']['); .replace(/\,/g, '][');
const parts = template.split('.'); const parts = template.split('.');
@ -214,25 +216,30 @@ function transform(template: string, params: TemplateParams, testIdAttributeName
} }
export function locatorOrSelectorAsSelector(language: Language, locator: string, testIdAttributeName: string): string { export function locatorOrSelectorAsSelector(language: Language, locator: string, testIdAttributeName: string): string {
try {
return unsafeLocatorOrSelectorAsSelector(language, locator, testIdAttributeName);
} catch (e) {
return '';
}
}
export function unsafeLocatorOrSelectorAsSelector(language: Language, locator: string, testIdAttributeName: string): string {
try { try {
parseSelector(locator); parseSelector(locator);
return locator; return locator;
} catch (e) { } catch (e) {
} }
try { const { selector, preferredQuote } = parseLocator(locator, testIdAttributeName);
const { selector, preferredQuote } = parseLocator(locator, testIdAttributeName); const locators = asLocators(language, selector, undefined, undefined, preferredQuote);
const locators = asLocators(language, selector, undefined, undefined, preferredQuote); const digest = digestForComparison(language, locator);
const digest = digestForComparison(language, locator); if (locators.some(candidate => digestForComparison(language, candidate) === digest))
if (locators.some(candidate => digestForComparison(language, candidate) === digest)) return selector;
return selector;
} catch (e) {
}
return ''; return '';
} }
function digestForComparison(language: Language, locator: string) { function digestForComparison(language: Language, locator: string) {
locator = locator.replace(/\s/g, ''); locator = locator.replace(/\s/g, '');
if (language === 'javascript') if (language === 'javascript')
locator = locator.replace(/\\?["`]/g, '\''); locator = locator.replace(/\\?["`]/g, '\'').replace(/,{}/g, '');
return locator; return locator;
} }

View file

@ -25,6 +25,7 @@ let lastConnectionId = 0;
const kConnectionSymbol = Symbol('kConnection'); const kConnectionSymbol = Symbol('kConnection');
export const perMessageDeflate = { export const perMessageDeflate = {
serverNoContextTakeover: true,
zlibDeflateOptions: { zlibDeflateOptions: {
level: 3, level: 3,
}, },

View file

@ -4294,7 +4294,7 @@ export interface Page {
* takes priority over * takes priority over
* [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout). * [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout).
* *
* @param timeout Maximum time in milliseconds * @param timeout Maximum time in milliseconds. Pass `0` to disable timeout.
*/ */
setDefaultTimeout(timeout: number): void; setDefaultTimeout(timeout: number): void;
@ -9193,7 +9193,7 @@ export interface BrowserContext {
* take priority over * take priority over
* [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout). * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout).
* *
* @param timeout Maximum time in milliseconds * @param timeout Maximum time in milliseconds. Pass `0` to disable timeout.
*/ */
setDefaultTimeout(timeout: number): void; setDefaultTimeout(timeout: number): void;
@ -9589,10 +9589,11 @@ export interface Browser {
* In case this browser is connected to, clears all created contexts belonging to this browser and disconnects from * In case this browser is connected to, clears all created contexts belonging to this browser and disconnects from
* the browser server. * the browser server.
* *
* **NOTE** This is similar to force quitting the browser. Therefore, you should call * **NOTE** This is similar to force-quitting the browser. To close pages gracefully and ensure you receive page close
* events, call
* [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) on * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) on
* any [BrowserContext](https://playwright.dev/docs/api/class-browsercontext)'s you explicitly created earlier with * any [BrowserContext](https://playwright.dev/docs/api/class-browsercontext) instances you explicitly created earlier
* [browser.newContext([options])](https://playwright.dev/docs/api/class-browser#browser-new-context) **before** * using [browser.newContext([options])](https://playwright.dev/docs/api/class-browser#browser-new-context) **before**
* calling [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close). * calling [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close).
* *
* The [Browser](https://playwright.dev/docs/api/class-browser) object itself is considered to be disposed and cannot * The [Browser](https://playwright.dev/docs/api/class-browser) object itself is considered to be disposed and cannot
@ -12428,7 +12429,7 @@ export interface Locator {
/** /**
* Captures the aria snapshot of the given element. Read more about [aria snapshots](https://playwright.dev/docs/aria-snapshots) and * Captures the aria snapshot of the given element. Read more about [aria snapshots](https://playwright.dev/docs/aria-snapshots) and
* [expect(locator).toMatchAriaSnapshot(expected[, options])](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-match-aria-snapshot) * [expect(locator).toMatchAriaSnapshot(expected[, options])](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-match-aria-snapshot-2)
* for the corresponding assertion. * for the corresponding assertion.
* *
* **Usage** * **Usage**
@ -18590,6 +18591,19 @@ export interface Clock {
* await page.clock.pauseAt('2020-02-02'); * await page.clock.pauseAt('2020-02-02');
* ``` * ```
* *
* For best results, install the clock before navigating the page and set it to a time slightly before the intended
* test time. This ensures that all timers run normally during page loading, preventing the page from getting stuck.
* Once the page has fully loaded, you can safely use
* [clock.pauseAt(time)](https://playwright.dev/docs/api/class-clock#clock-pause-at) to pause the clock.
*
* ```js
* // Initialize clock with some time before the test time and let the page load
* // naturally. `Date.now` will progress as the timers fire.
* await page.clock.install({ time: new Date('2024-12-10T08:00:00') });
* await page.goto('http://localhost:3333');
* await page.clock.pauseAt(new Date('2024-12-10T10:00:00'));
* ```
*
* @param time Time to pause at. * @param time Time to pause at.
*/ */
pauseAt(time: number|string|Date): Promise<void>; pauseAt(time: number|string|Date): Promise<void>;

View file

@ -52,7 +52,7 @@ export function defineConfig(config: PlaywrightTestConfig): PlaywrightTestConfig
export function defineConfig<T>(config: PlaywrightTestConfig<T>): PlaywrightTestConfig<T>; export function defineConfig<T>(config: PlaywrightTestConfig<T>): PlaywrightTestConfig<T>;
export function defineConfig<T, W>(config: PlaywrightTestConfig<T, W>): PlaywrightTestConfig<T, W>; export function defineConfig<T, W>(config: PlaywrightTestConfig<T, W>): PlaywrightTestConfig<T, W>;
export function defineConfig(config: PlaywrightTestConfig, ...configs: PlaywrightTestConfig[]): PlaywrightTestConfig; export function defineConfig(config: PlaywrightTestConfig, ...configs: PlaywrightTestConfig[]): PlaywrightTestConfig;
export function defineConfig<T>(config: PlaywrightTestConfig<T>, ...configs: PlaywrightTestConfig[]): PlaywrightTestConfig<T>; export function defineConfig<T>(config: PlaywrightTestConfig<T>, ...configs: PlaywrightTestConfig<T>[]): PlaywrightTestConfig<T>;
export function defineConfig<T, W>(config: PlaywrightTestConfig<T, W>, ...configs: PlaywrightTestConfig[]): PlaywrightTestConfig<T, W>; export function defineConfig<T, W>(config: PlaywrightTestConfig<T, W>, ...configs: PlaywrightTestConfig<T, W>[]): PlaywrightTestConfig<T, W>;
export { expect, devices, Locator } from 'playwright/test'; export { expect, devices, Locator } from 'playwright/test';

View file

@ -14,8 +14,10 @@
* limitations under the License. * limitations under the License.
*/ */
import type React from 'react';
export declare function beforeMount<HooksConfig>( export declare function beforeMount<HooksConfig>(
callback: (params: { hooksConfig?: HooksConfig; App: () => JSX.Element }) => Promise<void | JSX.Element> callback: (params: { hooksConfig?: HooksConfig; App: () => React.JSX.Element }) => Promise<void | React.JSX.Element>
): void; ): void;
export declare function afterMount<HooksConfig>( export declare function afterMount<HooksConfig>(
callback: (params: { hooksConfig?: HooksConfig }) => Promise<void> callback: (params: { hooksConfig?: HooksConfig }) => Promise<void>

View file

@ -14,6 +14,8 @@
* limitations under the License. * limitations under the License.
*/ */
import type React from 'react';
import type { TestType, Locator } from '@playwright/experimental-ct-core'; import type { TestType, Locator } from '@playwright/experimental-ct-core';
export interface MountOptions<HooksConfig> { export interface MountOptions<HooksConfig> {
@ -22,12 +24,12 @@ export interface MountOptions<HooksConfig> {
export interface MountResult extends Locator { export interface MountResult extends Locator {
unmount(): Promise<void>; unmount(): Promise<void>;
update(component: JSX.Element): Promise<void>; update(component: React.JSX.Element): Promise<void>;
} }
export const test: TestType<{ export const test: TestType<{
mount<HooksConfig>( mount<HooksConfig>(
component: JSX.Element, component: React.JSX.Element,
options?: MountOptions<HooksConfig> options?: MountOptions<HooksConfig>
): Promise<MountResult>; ): Promise<MountResult>;
}>; }>;

View file

@ -38,8 +38,8 @@ export type ConfigCLIOverrides = {
timeout?: number; timeout?: number;
tsconfig?: string; tsconfig?: string;
ignoreSnapshots?: boolean; ignoreSnapshots?: boolean;
updateSnapshots?: 'all'|'changed'|'missing'|'none'; updateSnapshots?: 'all' | 'changed' | 'missing' | 'none';
updateSourceMethod?: 'overwrite'|'patch'|'3way'; updateSourceMethod?: 'overwrite' | 'patch' | '3way';
workers?: number | string; workers?: number | string;
projects?: { name: string, use?: any }[], projects?: { name: string, use?: any }[],
use?: any; use?: any;

View file

@ -68,7 +68,7 @@ export async function loadTestFile(file: string, rootDir: string, testErrors?: T
suite.allTests().map(t => files.add(t.location.file)); suite.allTests().map(t => files.add(t.location.file));
if (files.size === 1) { if (files.size === 1) {
// All tests point to one file. // All tests point to one file.
const mappedFile = files.values().next().value; const mappedFile = files.values().next().value!;
if (suite.location.file !== mappedFile) { if (suite.location.file !== mappedFile) {
// The file is different, check for a likely source map case. // The file is different, check for a likely source map case.
if (path.extname(mappedFile) !== path.extname(suite.location.file)) if (path.extname(mappedFile) !== path.extname(suite.location.file))

View file

@ -56,7 +56,9 @@ export class TestTypeImpl {
test.fail.only = wrapFunctionWithLocation(this._createTest.bind(this, 'fail.only')); test.fail.only = wrapFunctionWithLocation(this._createTest.bind(this, 'fail.only'));
test.slow = wrapFunctionWithLocation(this._modifier.bind(this, 'slow')); test.slow = wrapFunctionWithLocation(this._modifier.bind(this, 'slow'));
test.setTimeout = wrapFunctionWithLocation(this._setTimeout.bind(this)); test.setTimeout = wrapFunctionWithLocation(this._setTimeout.bind(this));
test.step = this._step.bind(this); test.step = this._step.bind(this, 'pass');
test.step.fail = this._step.bind(this, 'fail');
test.step.fixme = this._step.bind(this, 'fixme');
test.use = wrapFunctionWithLocation(this._use.bind(this)); test.use = wrapFunctionWithLocation(this._use.bind(this));
test.extend = wrapFunctionWithLocation(this._extend.bind(this)); test.extend = wrapFunctionWithLocation(this._extend.bind(this));
test.info = () => { test.info = () => {
@ -257,22 +259,40 @@ export class TestTypeImpl {
suite._use.push({ fixtures, location }); suite._use.push({ fixtures, location });
} }
async _step<T>(title: string, body: () => T | Promise<T>, options: {box?: boolean, location?: Location, timeout?: number } = {}): Promise<T> { async _step<T>(expectation: 'pass'|'fail'|'fixme', title: string, body: () => T | Promise<T>, options: {box?: boolean, location?: Location, timeout?: number } = {}): Promise<T> {
const testInfo = currentTestInfo(); const testInfo = currentTestInfo();
if (!testInfo) if (!testInfo)
throw new Error(`test.step() can only be called from a test`); throw new Error(`test.step() can only be called from a test`);
if (expectation === 'fixme')
return undefined as T;
const step = testInfo._addStep({ category: 'test.step', title, location: options.location, box: options.box }); const step = testInfo._addStep({ category: 'test.step', title, location: options.location, box: options.box });
return await zones.run('stepZone', step, async () => { return await zones.run('stepZone', step, async () => {
let result;
let error;
try { try {
const result = await raceAgainstDeadline(async () => body(), options.timeout ? monotonicTime() + options.timeout : 0); result = await raceAgainstDeadline(async () => body(), options.timeout ? monotonicTime() + options.timeout : 0);
if (result.timedOut) } catch (e) {
throw new errors.TimeoutError(`Step timeout ${options.timeout}ms exceeded.`); error = e;
step.complete({}); }
return result.result; if (result?.timedOut) {
} catch (error) { const error = new errors.TimeoutError(`Step timeout ${options.timeout}ms exceeded.`);
step.complete({ error }); step.complete({ error });
throw error; throw error;
} }
const expectedToFail = expectation === 'fail';
if (error) {
step.complete({ error });
if (expectedToFail)
return undefined as T;
throw error;
}
if (expectedToFail) {
error = new Error(`Step is expected to fail, but passed`);
step.complete({ error });
throw error;
}
step.complete({});
return result!.result;
}); });
} }

View file

@ -94,7 +94,8 @@ export interface TestServerInterface {
testIds?: string[]; testIds?: string[];
headed?: boolean; headed?: boolean;
workers?: number | string; workers?: number | string;
updateSnapshots?: 'all' | 'none' | 'missing'; updateSnapshots?: 'all' | 'changed' | 'missing' | 'none';
updateSourceMethod?: 'overwrite' | 'patch' | '3way';
reporters?: string[], reporters?: string[],
trace?: 'on' | 'off'; trace?: 'on' | 'off';
video?: 'on' | 'off'; video?: 'on' | 'off';

View file

@ -18,19 +18,25 @@
import type { LocatorEx } from './matchers'; import type { LocatorEx } from './matchers';
import type { ExpectMatcherState } from '../../types/test'; import type { ExpectMatcherState } from '../../types/test';
import { kNoElementsFoundError, matcherHint, type MatcherResult } from './matcherHint'; import { kNoElementsFoundError, matcherHint, type MatcherResult } from './matcherHint';
import { colors } from 'playwright-core/lib/utilsBundle';
import { EXPECTED_COLOR } from '../common/expectBundle'; import { EXPECTED_COLOR } from '../common/expectBundle';
import { callLogText } from '../util'; import { callLogText, sanitizeFilePathBeforeExtension, trimLongString } from '../util';
import { printReceivedStringContainExpectedSubstring } from './expect'; import { printReceivedStringContainExpectedSubstring } from './expect';
import { currentTestInfo } from '../common/globals'; import { currentTestInfo } from '../common/globals';
import type { MatcherReceived } from '@injected/ariaSnapshot'; import type { MatcherReceived } from '@injected/ariaSnapshot';
import { escapeTemplateString } from 'playwright-core/lib/utils'; import { escapeTemplateString, isString, sanitizeForFilePath } from 'playwright-core/lib/utils';
import fs from 'fs';
import path from 'path';
type ToMatchAriaSnapshotExpected = {
name?: string;
path?: string;
} | string;
export async function toMatchAriaSnapshot( export async function toMatchAriaSnapshot(
this: ExpectMatcherState, this: ExpectMatcherState,
receiver: LocatorEx, receiver: LocatorEx,
expected: string, expectedParam: ToMatchAriaSnapshotExpected,
options: { timeout?: number, matchSubstring?: boolean } = {}, options: { timeout?: number } = {},
): Promise<MatcherResult<string | RegExp, string>> { ): Promise<MatcherResult<string | RegExp, string>> {
const matcherName = 'toMatchAriaSnapshot'; const matcherName = 'toMatchAriaSnapshot';
@ -39,7 +45,7 @@ export async function toMatchAriaSnapshot(
throw new Error(`toMatchAriaSnapshot() must be called during the test`); throw new Error(`toMatchAriaSnapshot() must be called during the test`);
if (testInfo._projectInternal.ignoreSnapshots) if (testInfo._projectInternal.ignoreSnapshots)
return { pass: !this.isNot, message: () => '', name: 'toMatchAriaSnapshot', expected }; return { pass: !this.isNot, message: () => '', name: 'toMatchAriaSnapshot', expected: '' };
const updateSnapshots = testInfo.config.updateSnapshots; const updateSnapshots = testInfo.config.updateSnapshots;
@ -48,12 +54,25 @@ export async function toMatchAriaSnapshot(
promise: this.promise, promise: this.promise,
}; };
if (typeof expected !== 'string') { let expected: string;
throw new Error([ let expectedPath: string | undefined;
matcherHint(this, receiver, matcherName, receiver, expected, matcherOptions), if (isString(expectedParam)) {
`${colors.bold('Matcher error')}: ${EXPECTED_COLOR('expected',)} value must be a string`, expected = expectedParam;
this.utils.printWithType('Expected', expected, this.utils.printExpected) } else {
].join('\n\n')); if (expectedParam?.path) {
expectedPath = expectedParam.path;
} else if (expectedParam?.name) {
expectedPath = testInfo.snapshotPath(sanitizeFilePathBeforeExtension(expectedParam.name));
} else {
let snapshotNames = (testInfo as any)[snapshotNamesSymbol] as SnapshotNames;
if (!snapshotNames) {
snapshotNames = { anonymousSnapshotIndex: 0 };
(testInfo as any)[snapshotNamesSymbol] = snapshotNames;
}
const fullTitleWithoutSpec = [...testInfo.titlePath.slice(1), ++snapshotNames.anonymousSnapshotIndex].join(' ');
expectedPath = testInfo.snapshotPath(sanitizeForFilePath(trimLongString(fullTitleWithoutSpec)) + '.yml');
}
expected = await fs.promises.readFile(expectedPath, 'utf8').catch(() => '');
} }
const generateMissingBaseline = updateSnapshots === 'missing' && !expected; const generateMissingBaseline = updateSnapshots === 'missing' && !expected;
@ -102,8 +121,24 @@ export async function toMatchAriaSnapshot(
if ((updateSnapshots === 'all') || if ((updateSnapshots === 'all') ||
(updateSnapshots === 'changed' && pass === this.isNot) || (updateSnapshots === 'changed' && pass === this.isNot) ||
generateMissingBaseline) { generateMissingBaseline) {
const suggestedRebaseline = `toMatchAriaSnapshot(\`\n${escapeTemplateString(indent(typedReceived.regex, '{indent} '))}\n{indent}\`)`; if (expectedPath) {
return { pass: this.isNot, message: () => '', name: 'toMatchAriaSnapshot', suggestedRebaseline }; await fs.promises.mkdir(path.dirname(expectedPath), { recursive: true });
await fs.promises.writeFile(expectedPath, typedReceived.regex, 'utf8');
const relativePath = path.relative(process.cwd(), expectedPath);
if (updateSnapshots === 'missing') {
const message = `A snapshot doesn't exist at ${relativePath}, writing actual.`;
testInfo._hasNonRetriableError = true;
testInfo._failWithError(new Error(message));
} else {
const message = `A snapshot is generated at ${relativePath}.`;
/* eslint-disable no-console */
console.log(message);
}
return { pass: true, message: () => '', name: 'toMatchAriaSnapshot' };
} else {
const suggestedRebaseline = `toMatchAriaSnapshot(\`\n${escapeTemplateString(indent(typedReceived.regex, '{indent} '))}\n{indent}\`)`;
return { pass: false, message: () => '', name: 'toMatchAriaSnapshot', suggestedRebaseline };
}
} }
} }
@ -134,3 +169,9 @@ function unshift(snapshot: string): string {
function indent(snapshot: string, indent: string): string { function indent(snapshot: string, indent: string): string {
return snapshot.split('\n').map(line => indent + line).join('\n'); return snapshot.split('\n').map(line => indent + line).join('\n');
} }
const snapshotNamesSymbol = Symbol('snapshotNames');
type SnapshotNames = {
anonymousSnapshotIndex: number;
};

View file

@ -258,7 +258,7 @@ export class BaseReporter implements ReporterV2 {
console.log(colors.yellow(' Slow test file: ') + file + colors.yellow(` (${milliseconds(duration)})`)); console.log(colors.yellow(' Slow test file: ') + file + colors.yellow(` (${milliseconds(duration)})`));
}); });
if (slowTests.length) if (slowTests.length)
console.log(colors.yellow(' Consider splitting slow test files to speed up parallel execution')); console.log(colors.yellow(' Consider running tests from slow files in parallel, see https://playwright.dev/docs/test-parallel.'));
} }
private _printSummary(summary: string) { private _printSummary(summary: string) {
@ -381,7 +381,7 @@ export function formatTestTitle(config: FullConfig, test: TestCase, step?: TestS
if (omitLocation) if (omitLocation)
location = `${relativeTestPath(config, test)}`; location = `${relativeTestPath(config, test)}`;
else else
location = `${relativeTestPath(config, test)}:${step?.location?.line ?? test.location.line}:${step?.location?.column ?? test.location.column}`; location = `${relativeTestPath(config, test)}:${test.location.line}:${test.location.column}`;
const projectTitle = projectName ? `[${projectName}] ` : ''; const projectTitle = projectName ? `[${projectName}] ` : '';
const testTitle = `${projectTitle}${location} ${titles.join(' ')}`; const testTitle = `${projectTitle}${location} ${titles.join(' ')}`;
const extraTags = test.tags.filter(t => !testTitle.includes(t)); const extraTags = test.tags.filter(t => !testTitle.includes(t));

View file

@ -309,7 +309,7 @@ class HtmlBuilder {
let singleTestId: string | undefined; let singleTestId: string | undefined;
if (htmlReport.stats.total === 1) { if (htmlReport.stats.total === 1) {
const testFile: TestFile = data.values().next().value.testFile; const testFile: TestFile = data.values().next().value!.testFile;
singleTestId = testFile.tests[0].testId; singleTestId = testFile.tests[0].testId;
} }
@ -602,17 +602,10 @@ type JsonAttachment = {
}; };
function stdioAttachment(chunk: Buffer | string, type: 'stdout' | 'stderr'): JsonAttachment { function stdioAttachment(chunk: Buffer | string, type: 'stdout' | 'stderr'): JsonAttachment {
if (typeof chunk === 'string') {
return {
name: type,
contentType: 'text/plain',
body: chunk
};
}
return { return {
name: type, name: type,
contentType: 'application/octet-stream', contentType: 'text/plain',
body: chunk body: typeof chunk === 'string' ? chunk : chunk.toString('utf-8')
}; };
} }

View file

@ -311,6 +311,7 @@ export class TestServerDispatcher implements TestServerInterface {
_optionConnectOptions: params.connectWsEndpoint ? { wsEndpoint: params.connectWsEndpoint } : undefined, _optionConnectOptions: params.connectWsEndpoint ? { wsEndpoint: params.connectWsEndpoint } : undefined,
}, },
...(params.updateSnapshots ? { updateSnapshots: params.updateSnapshots } : {}), ...(params.updateSnapshots ? { updateSnapshots: params.updateSnapshots } : {}),
...(params.updateSourceMethod ? { updateSourceMethod: params.updateSourceMethod } : {}),
...(params.workers ? { workers: params.workers } : {}), ...(params.workers ? { workers: params.workers } : {}),
}; };
if (params.trace === 'on') if (params.trace === 'on')
@ -479,7 +480,7 @@ type StdioPayload = {
}; };
function chunkToPayload(type: 'stdout' | 'stderr', chunk: Buffer | string): StdioPayload { function chunkToPayload(type: 'stdout' | 'stderr', chunk: Buffer | string): StdioPayload {
if (chunk instanceof Buffer) if (chunk instanceof Uint8Array)
return { type, buffer: chunk.toString('base64') }; return { type, buffer: chunk.toString('base64') };
return { type, text: chunk }; return { type, text: chunk };
} }

View file

@ -66,7 +66,7 @@ class Fixture {
} }
await testInfo._runAsStage({ await testInfo._runAsStage({
title: `fixture: ${this.registration.name}`, title: `fixture: ${this.registration.customTitle ?? this.registration.name}`,
runnable: { ...runnable, fixture: this._setupDescription }, runnable: { ...runnable, fixture: this._setupDescription },
stepInfo: this._stepInfo, stepInfo: this._stepInfo,
}, async () => { }, async () => {
@ -131,7 +131,7 @@ class Fixture {
// time remaining in the time slot. This avoids cascading timeouts. // time remaining in the time slot. This avoids cascading timeouts.
if (!testInfo._timeoutManager.isTimeExhaustedFor(fixtureRunnable)) { if (!testInfo._timeoutManager.isTimeExhaustedFor(fixtureRunnable)) {
await testInfo._runAsStage({ await testInfo._runAsStage({
title: `fixture: ${this.registration.name}`, title: `fixture: ${this.registration.customTitle ?? this.registration.name}`,
runnable: fixtureRunnable, runnable: fixtureRunnable,
stepInfo: this._stepInfo, stepInfo: this._stepInfo,
}, async () => { }, async () => {

View file

@ -1891,7 +1891,7 @@ type ConditionBody<TestArgs> = (args: TestArgs) => boolean;
* ``` * ```
* *
*/ */
export interface TestType<TestArgs extends KeyValue, WorkerArgs extends KeyValue> { export interface TestType<TestArgs extends {}, WorkerArgs extends {}> {
/** /**
* Declares a test. * Declares a test.
* - `test(title, body)` * - `test(title, body)`
@ -5551,7 +5551,217 @@ export interface TestType<TestArgs extends KeyValue, WorkerArgs extends KeyValue
* @param body Step body. * @param body Step body.
* @param options * @param options
*/ */
step<T>(title: string, body: () => T | Promise<T>, options?: { box?: boolean, location?: Location, timeout?: number }): Promise<T>; step: {
/**
* Declares a test step that is shown in the report.
*
* **Usage**
*
* ```js
* import { test, expect } from '@playwright/test';
*
* test('test', async ({ page }) => {
* await test.step('Log in', async () => {
* // ...
* });
*
* await test.step('Outer step', async () => {
* // ...
* // You can nest steps inside each other.
* await test.step('Inner step', async () => {
* // ...
* });
* });
* });
* ```
*
* **Details**
*
* The method returns the value returned by the step callback.
*
* ```js
* import { test, expect } from '@playwright/test';
*
* test('test', async ({ page }) => {
* const user = await test.step('Log in', async () => {
* // ...
* return 'john';
* });
* expect(user).toBe('john');
* });
* ```
*
* **Decorator**
*
* You can use TypeScript method decorators to turn a method into a step. Each call to the decorated method will show
* up as a step in the report.
*
* ```js
* function step(target: Function, context: ClassMethodDecoratorContext) {
* return function replacementMethod(...args: any) {
* const name = this.constructor.name + '.' + (context.name as string);
* return test.step(name, async () => {
* return await target.call(this, ...args);
* });
* };
* }
*
* class LoginPage {
* constructor(readonly page: Page) {}
*
* @step
* async login() {
* const account = { username: 'Alice', password: 's3cr3t' };
* await this.page.getByLabel('Username or email address').fill(account.username);
* await this.page.getByLabel('Password').fill(account.password);
* await this.page.getByRole('button', { name: 'Sign in' }).click();
* await expect(this.page.getByRole('button', { name: 'View profile and more' })).toBeVisible();
* }
* }
*
* test('example', async ({ page }) => {
* const loginPage = new LoginPage(page);
* await loginPage.login();
* });
* ```
*
* **Boxing**
*
* When something inside a step fails, you would usually see the error pointing to the exact action that failed. For
* example, consider the following login step:
*
* ```js
* async function login(page) {
* await test.step('login', async () => {
* const account = { username: 'Alice', password: 's3cr3t' };
* await page.getByLabel('Username or email address').fill(account.username);
* await page.getByLabel('Password').fill(account.password);
* await page.getByRole('button', { name: 'Sign in' }).click();
* await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible();
* });
* }
*
* test('example', async ({ page }) => {
* await page.goto('https://github.com/login');
* await login(page);
* });
* ```
*
* ```txt
* Error: Timed out 5000ms waiting for expect(locator).toBeVisible()
* ... error details omitted ...
*
* 8 | await page.getByRole('button', { name: 'Sign in' }).click();
* > 9 | await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible();
* | ^
* 10 | });
* ```
*
* As we see above, the test may fail with an error pointing inside the step. If you would like the error to highlight
* the "login" step instead of its internals, use the `box` option. An error inside a boxed step points to the step
* call site.
*
* ```js
* async function login(page) {
* await test.step('login', async () => {
* // ...
* }, { box: true }); // Note the "box" option here.
* }
* ```
*
* ```txt
* Error: Timed out 5000ms waiting for expect(locator).toBeVisible()
* ... error details omitted ...
*
* 14 | await page.goto('https://github.com/login');
* > 15 | await login(page);
* | ^
* 16 | });
* ```
*
* You can also create a TypeScript decorator for a boxed step, similar to a regular step decorator above:
*
* ```js
* function boxedStep(target: Function, context: ClassMethodDecoratorContext) {
* return function replacementMethod(...args: any) {
* const name = this.constructor.name + '.' + (context.name as string);
* return test.step(name, async () => {
* return await target.call(this, ...args);
* }, { box: true }); // Note the "box" option here.
* };
* }
*
* class LoginPage {
* constructor(readonly page: Page) {}
*
* @boxedStep
* async login() {
* // ....
* }
* }
*
* test('example', async ({ page }) => {
* const loginPage = new LoginPage(page);
* await loginPage.login(); // <-- Error will be reported on this line.
* });
* ```
*
* @param title Step name.
* @param body Step body.
* @param options
*/
<T>(title: string, body: () => T | Promise<T>, options?: { box?: boolean, location?: Location, timeout?: number }): Promise<T>;
/**
* Mark a test step as "fixme", with the intention to fix it. Playwright will not run the step.
*
* **Usage**
*
* You can declare a test step as failing, so that Playwright ensures it actually fails.
*
* ```js
* import { test, expect } from '@playwright/test';
*
* test('my test', async ({ page }) => {
* // ...
* await test.step.fixme('not yet ready', async () => {
* // ...
* });
* });
* ```
*
* @param title Step name.
* @param body Step body.
* @param options
*/
fixme(title: string, body: () => any | Promise<any>, options?: { box?: boolean, location?: Location, timeout?: number }): Promise<void>;
/**
* Marks a test step as "should fail". Playwright runs this test step and ensures that it actually fails. This is
* useful for documentation purposes to acknowledge that some functionality is broken until it is fixed.
*
* **NOTE** If the step exceeds the timeout, a [TimeoutError](https://playwright.dev/docs/api/class-timeouterror) is
* thrown. This indicates the step did not fail as expected.
*
* **Usage**
*
* You can declare a test step as failing, so that Playwright ensures it actually fails.
*
* ```js
* import { test, expect } from '@playwright/test';
*
* test('my test', async ({ page }) => {
* // ...
* await test.step.fail('currently failing', async () => {
* // ...
* });
* });
* ```
*
* @param title Step name.
* @param body Step body.
* @param options
*/
fail(title: string, body: () => any | Promise<any>, options?: { box?: boolean, location?: Location, timeout?: number }): Promise<void>;
}
/** /**
* `expect` function can be used to create test assertions. Read more about [test assertions](https://playwright.dev/docs/test-assertions). * `expect` function can be used to create test assertions. Read more about [test assertions](https://playwright.dev/docs/test-assertions).
* *
@ -5632,7 +5842,7 @@ export interface TestType<TestArgs extends KeyValue, WorkerArgs extends KeyValue
* Learn more about [fixtures](https://playwright.dev/docs/test-fixtures) and [parametrizing tests](https://playwright.dev/docs/test-parameterize). * Learn more about [fixtures](https://playwright.dev/docs/test-fixtures) and [parametrizing tests](https://playwright.dev/docs/test-parameterize).
* @param fixtures An object containing fixtures and/or options. Learn more about [fixtures format](https://playwright.dev/docs/test-fixtures). * @param fixtures An object containing fixtures and/or options. Learn more about [fixtures format](https://playwright.dev/docs/test-fixtures).
*/ */
extend<T extends KeyValue, W extends KeyValue = {}>(fixtures: Fixtures<T, W, TestArgs, WorkerArgs>): TestType<TestArgs & T, WorkerArgs & W>; extend<T extends {}, W extends {} = {}>(fixtures: Fixtures<T, W, TestArgs, WorkerArgs>): TestType<TestArgs & T, WorkerArgs & W>;
/** /**
* Returns information about the currently running test. This method can only be called during the test execution, * Returns information about the currently running test. This method can only be called during the test execution,
* otherwise it throws. * otherwise it throws.
@ -5653,19 +5863,18 @@ export interface TestType<TestArgs extends KeyValue, WorkerArgs extends KeyValue
info(): TestInfo; info(): TestInfo;
} }
type KeyValue = { [key: string]: any }; export type TestFixture<R, Args extends {}> = (args: Args, use: (r: R) => Promise<void>, testInfo: TestInfo) => any;
export type TestFixture<R, Args extends KeyValue> = (args: Args, use: (r: R) => Promise<void>, testInfo: TestInfo) => any; export type WorkerFixture<R, Args extends {}> = (args: Args, use: (r: R) => Promise<void>, workerInfo: WorkerInfo) => any;
export type WorkerFixture<R, Args extends KeyValue> = (args: Args, use: (r: R) => Promise<void>, workerInfo: WorkerInfo) => any; type TestFixtureValue<R, Args extends {}> = Exclude<R, Function> | TestFixture<R, Args>;
type TestFixtureValue<R, Args extends KeyValue> = Exclude<R, Function> | TestFixture<R, Args>; type WorkerFixtureValue<R, Args extends {}> = Exclude<R, Function> | WorkerFixture<R, Args>;
type WorkerFixtureValue<R, Args extends KeyValue> = Exclude<R, Function> | WorkerFixture<R, Args>; export type Fixtures<T extends {} = {}, W extends {} = {}, PT extends {} = {}, PW extends {} = {}> = {
export type Fixtures<T extends KeyValue = {}, W extends KeyValue = {}, PT extends KeyValue = {}, PW extends KeyValue = {}> = {
[K in keyof PW]?: WorkerFixtureValue<PW[K], W & PW> | [WorkerFixtureValue<PW[K], W & PW>, { scope: 'worker', timeout?: number | undefined, title?: string, box?: boolean }]; [K in keyof PW]?: WorkerFixtureValue<PW[K], W & PW> | [WorkerFixtureValue<PW[K], W & PW>, { scope: 'worker', timeout?: number | undefined, title?: string, box?: boolean }];
} & { } & {
[K in keyof PT]?: TestFixtureValue<PT[K], T & W & PT & PW> | [TestFixtureValue<PT[K], T & W & PT & PW>, { scope: 'test', timeout?: number | undefined, title?: string, box?: boolean }]; [K in keyof PT]?: TestFixtureValue<PT[K], T & W & PT & PW> | [TestFixtureValue<PT[K], T & W & PT & PW>, { scope: 'test', timeout?: number | undefined, title?: string, box?: boolean }];
} & { } & {
[K in keyof W]?: [WorkerFixtureValue<W[K], W & PW>, { scope: 'worker', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean }]; [K in Exclude<keyof W, keyof PW | keyof PT>]?: [WorkerFixtureValue<W[K], W & PW>, { scope: 'worker', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean }];
} & { } & {
[K in keyof T]?: TestFixtureValue<T[K], T & W & PT & PW> | [TestFixtureValue<T[K], T & W & PT & PW>, { scope?: 'test', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean }]; [K in Exclude<keyof T, keyof PW | keyof PT>]?: TestFixtureValue<T[K], T & W & PT & PW> | [TestFixtureValue<T[K], T & W & PT & PW>, { scope?: 'test', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean }];
}; };
type BrowserName = 'chromium' | 'firefox' | 'webkit'; type BrowserName = 'chromium' | 'firefox' | 'webkit';
@ -7487,8 +7696,8 @@ export function defineConfig(config: PlaywrightTestConfig): PlaywrightTestConfig
export function defineConfig<T>(config: PlaywrightTestConfig<T>): PlaywrightTestConfig<T>; export function defineConfig<T>(config: PlaywrightTestConfig<T>): PlaywrightTestConfig<T>;
export function defineConfig<T, W>(config: PlaywrightTestConfig<T, W>): PlaywrightTestConfig<T, W>; export function defineConfig<T, W>(config: PlaywrightTestConfig<T, W>): PlaywrightTestConfig<T, W>;
export function defineConfig(config: PlaywrightTestConfig, ...configs: PlaywrightTestConfig[]): PlaywrightTestConfig; export function defineConfig(config: PlaywrightTestConfig, ...configs: PlaywrightTestConfig[]): PlaywrightTestConfig;
export function defineConfig<T>(config: PlaywrightTestConfig<T>, ...configs: PlaywrightTestConfig[]): PlaywrightTestConfig<T>; export function defineConfig<T>(config: PlaywrightTestConfig<T>, ...configs: PlaywrightTestConfig<T>[]): PlaywrightTestConfig<T>;
export function defineConfig<T, W>(config: PlaywrightTestConfig<T, W>, ...configs: PlaywrightTestConfig[]): PlaywrightTestConfig<T, W>; export function defineConfig<T, W>(config: PlaywrightTestConfig<T, W>, ...configs: PlaywrightTestConfig<T, W>[]): PlaywrightTestConfig<T, W>;
type MergedT<List> = List extends [TestType<infer T, any>, ...(infer Rest)] ? T & MergedT<Rest> : {}; type MergedT<List> = List extends [TestType<infer T, any>, ...(infer Rest)] ? T & MergedT<Rest> : {};
type MergedW<List> = List extends [TestType<any, infer W>, ...(infer Rest)] ? W & MergedW<Rest> : {}; type MergedW<List> = List extends [TestType<any, infer W>, ...(infer Rest)] ? W & MergedW<Rest> : {};
@ -8431,6 +8640,37 @@ interface LocatorAssertions {
timeout?: number; timeout?: number;
}): Promise<void>; }): Promise<void>;
/**
* Asserts that the target element matches the given [accessibility snapshot](https://playwright.dev/docs/aria-snapshots).
*
* **Usage**
*
* ```js
* await expect(page.locator('body')).toMatchAriaSnapshot();
* await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'snapshot' });
* await expect(page.locator('body')).toMatchAriaSnapshot({ path: '/path/to/snapshot.yml' });
* ```
*
* @param options
*/
toMatchAriaSnapshot(options?: {
/**
* Name of the snapshot to store in the snapshot folder corresponding to this test. Generates ordinal name if not
* specified.
*/
name?: string;
/**
* Path to the YAML snapshot file.
*/
path?: string;
/**
* Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`.
*/
timeout?: number;
}): Promise<void>;
/** /**
* Asserts that the target element matches the given [accessibility snapshot](https://playwright.dev/docs/aria-snapshots). * Asserts that the target element matches the given [accessibility snapshot](https://playwright.dev/docs/aria-snapshots).
* *

View file

@ -691,6 +691,7 @@ export interface DebugControllerChannel extends DebugControllerEventTarget, Chan
export type DebugControllerInspectRequestedEvent = { export type DebugControllerInspectRequestedEvent = {
selector: string, selector: string,
locator: string, locator: string,
ariaSnapshot: string,
}; };
export type DebugControllerSetModeRequestedEvent = { export type DebugControllerSetModeRequestedEvent = {
mode: string, mode: string,

View file

@ -807,6 +807,7 @@ DebugController:
parameters: parameters:
selector: string selector: string
locator: string locator: string
ariaSnapshot: string
setModeRequested: setModeRequested:
parameters: parameters:

View file

@ -3,11 +3,6 @@
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": {
"dev": "vite",
"build": "vite build && tsc",
"preview": "vite preview"
},
"dependencies": { "dependencies": {
"yaml": "^2.6.0" "yaml": "^2.6.0"
} }

View file

@ -2,11 +2,5 @@
"name": "trace-viewer", "name": "trace-viewer",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module"
"scripts": {
"dev": "vite",
"build": "vite build && tsc",
"build-sw": "vite --config vite.sw.config.ts build && tsc",
"preview": "vite preview"
}
} }

View file

@ -37,7 +37,7 @@ export class LRUCache<K, V> {
const result = compute(); const result = compute();
while (this._map.size && this._size + result.size > this._maxSize) { while (this._map.size && this._size + result.size > this._maxSize) {
const [firstKey, firstValue] = this._map.entries().next().value; const [firstKey, firstValue] = this._map.entries().next().value!;
this._size -= firstValue.size; this._size -= firstValue.size;
this._map.delete(firstKey); this._map.delete(firstKey);
} }

View file

@ -43,10 +43,8 @@ async function loadTrace(traceUrl: string, traceFileName: string | null, client:
const clientId = client?.id ?? ''; const clientId = client?.id ?? '';
let data = clientIdToTraceUrls.get(clientId); let data = clientIdToTraceUrls.get(clientId);
if (!data) { if (!data) {
let traceViewerServerBaseUrl = new URL('../', client?.url ?? self.registration.scope); const clientURL = new URL(client?.url ?? self.registration.scope);
if (traceViewerServerBaseUrl.searchParams.has('server')) const traceViewerServerBaseUrl = new URL(clientURL.searchParams.get('server') ?? '../', clientURL);
traceViewerServerBaseUrl = new URL(traceViewerServerBaseUrl.searchParams.get('server')!, traceViewerServerBaseUrl);
data = { limit, traceUrls: new Set(), traceViewerServer: new TraceViewerServer(traceViewerServerBaseUrl) }; data = { limit, traceUrls: new Set(), traceViewerServer: new TraceViewerServer(traceViewerServerBaseUrl) };
clientIdToTraceUrls.set(clientId, data); clientIdToTraceUrls.set(clientId, data);
} }

View file

@ -152,7 +152,7 @@ export class SnapshotRenderer {
const html = prefix + [ const html = prefix + [
// Hide the document in order to prevent flickering. We will unhide once script has processed shadow. // Hide the document in order to prevent flickering. We will unhide once script has processed shadow.
'<style>*,*::before,*::after { visibility: hidden }</style>', '<style>*,*::before,*::after { visibility: hidden }</style>',
`<script>${snapshotScript(this._callId, this.snapshotName)}</script>` `<script>${snapshotScript(this.viewport(), this._callId, this.snapshotName)}</script>`
].join('') + result.join(''); ].join('') + result.join('');
return { value: html, size: html.length }; return { value: html, size: html.length };
}); });
@ -236,10 +236,39 @@ function snapshotNodes(snapshot: FrameSnapshot): NodeSnapshot[] {
return (snapshot as any)._nodes; return (snapshot as any)._nodes;
} }
function snapshotScript(...targetIds: (string | undefined)[]) { type ViewportSize = { width: number, height: number };
function applyPlaywrightAttributes(unwrapPopoutUrl: (url: string) => string, ...targetIds: (string | undefined)[]) { type BoundingRect = { left: number, top: number, right: number, bottom: number };
type FrameBoundingRectsInfo = {
viewport: ViewportSize;
frames: WeakMap<Element, {
boundingRect: BoundingRect;
scrollLeft: number;
scrollTop: number;
}>;
};
declare global {
interface Window {
__playwright_frame_bounding_rects__: FrameBoundingRectsInfo;
}
}
function snapshotScript(viewport: ViewportSize, ...targetIds: (string | undefined)[]) {
function applyPlaywrightAttributes(unwrapPopoutUrl: (url: string) => string, viewport: ViewportSize, ...targetIds: (string | undefined)[]) {
const isUnderTest = new URLSearchParams(location.search).has('isUnderTest'); const isUnderTest = new URLSearchParams(location.search).has('isUnderTest');
// info to recursively compute canvas position relative to the top snapshot frame.
// Before rendering each iframe, its parent extracts the '__playwright_canvas_render_info__' attribute
// value and keeps in this variable. It can then remove the attribute and render the element,
// which will eventually trigger the same process inside the iframe recursively.
// When there's a canvas to render, we iterate over its ancestor frames to compute
// its position relative to the top snapshot frame.
const frameBoundingRectsInfo = {
viewport,
frames: new WeakMap(),
};
window['__playwright_frame_bounding_rects__'] = frameBoundingRectsInfo;
const kPointerWarningTitle = 'Recorded click position in absolute coordinates did not' + const kPointerWarningTitle = 'Recorded click position in absolute coordinates did not' +
' match the center of the clicked element. This is likely due to a difference between' + ' match the center of the clicked element. This is likely due to a difference between' +
' the test runner and the trace viewer operating systems.'; ' the test runner and the trace viewer operating systems.';
@ -249,6 +278,10 @@ function snapshotScript(...targetIds: (string | undefined)[]) {
const targetElements: Element[] = []; const targetElements: Element[] = [];
const canvasElements: HTMLCanvasElement[] = []; const canvasElements: HTMLCanvasElement[] = [];
let topSnapshotWindow: Window = window;
while (topSnapshotWindow !== topSnapshotWindow.parent && !topSnapshotWindow.location.pathname.match(/\/page@[a-z0-9]+$/))
topSnapshotWindow = topSnapshotWindow.parent;
const visit = (root: Document | ShadowRoot) => { const visit = (root: Document | ShadowRoot) => {
// Collect all scrolled elements for later use. // Collect all scrolled elements for later use.
for (const e of root.querySelectorAll(`[__playwright_scroll_top_]`)) for (const e of root.querySelectorAll(`[__playwright_scroll_top_]`))
@ -288,6 +321,11 @@ function snapshotScript(...targetIds: (string | undefined)[]) {
} }
for (const iframe of root.querySelectorAll('iframe, frame')) { for (const iframe of root.querySelectorAll('iframe, frame')) {
const boundingRectJson = iframe.getAttribute('__playwright_bounding_rect__');
iframe.removeAttribute('__playwright_bounding_rect__');
const boundingRect = boundingRectJson ? JSON.parse(boundingRectJson) : undefined;
if (boundingRect)
frameBoundingRectsInfo.frames.set(iframe, { boundingRect, scrollLeft: 0, scrollTop: 0 });
const src = iframe.getAttribute('__playwright_src__'); const src = iframe.getAttribute('__playwright_src__');
if (!src) { if (!src) {
iframe.setAttribute('src', 'data:text/html,<body style="background: #ddd"></body>'); iframe.setAttribute('src', 'data:text/html,<body style="background: #ddd"></body>');
@ -339,16 +377,20 @@ function snapshotScript(...targetIds: (string | undefined)[]) {
for (const element of scrollTops) { for (const element of scrollTops) {
element.scrollTop = +element.getAttribute('__playwright_scroll_top_')!; element.scrollTop = +element.getAttribute('__playwright_scroll_top_')!;
element.removeAttribute('__playwright_scroll_top_'); element.removeAttribute('__playwright_scroll_top_');
if (frameBoundingRectsInfo.frames.has(element))
frameBoundingRectsInfo.frames.get(element)!.scrollTop = element.scrollTop;
} }
for (const element of scrollLefts) { for (const element of scrollLefts) {
element.scrollLeft = +element.getAttribute('__playwright_scroll_left_')!; element.scrollLeft = +element.getAttribute('__playwright_scroll_left_')!;
element.removeAttribute('__playwright_scroll_left_'); element.removeAttribute('__playwright_scroll_left_');
if (frameBoundingRectsInfo.frames.has(element))
frameBoundingRectsInfo.frames.get(element)!.scrollLeft = element.scrollTop;
} }
document.styleSheets[0].disabled = true; document.styleSheets[0].disabled = true;
const search = new URL(window.location.href).searchParams; const search = new URL(window.location.href).searchParams;
const isTopFrame = window.location.pathname.match(/\/page@[a-z0-9]+$/); const isTopFrame = window === topSnapshotWindow;
if (search.get('pointX') && search.get('pointY')) { if (search.get('pointX') && search.get('pointY')) {
const pointX = +search.get('pointX')!; const pointX = +search.get('pointX')!;
@ -419,16 +461,6 @@ function snapshotScript(...targetIds: (string | undefined)[]) {
context.fillRect(0, 0, canvas.width, canvas.height); context.fillRect(0, 0, canvas.width, canvas.height);
} }
if (!isTopFrame) {
for (const canvas of canvasElements) {
const context = canvas.getContext('2d')!;
drawCheckerboard(context, canvas);
canvas.title = `Playwright displays canvas contents on a best-effort basis. It doesn't support canvas elements inside an iframe yet. If this impacts your workflow, please open an issue so we can prioritize.`;
}
return;
}
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => {
for (const canvas of canvasElements) { for (const canvas of canvasElements) {
@ -446,6 +478,31 @@ function snapshotScript(...targetIds: (string | undefined)[]) {
continue; continue;
} }
let currWindow: Window = window;
while (currWindow !== topSnapshotWindow) {
const iframe = currWindow.frameElement!;
currWindow = currWindow.parent;
const iframeInfo = currWindow['__playwright_frame_bounding_rects__']?.frames.get(iframe);
if (!iframeInfo?.boundingRect)
break;
const leftOffset = iframeInfo.boundingRect.left - iframeInfo.scrollLeft;
const topOffset = iframeInfo.boundingRect.top - iframeInfo.scrollTop;
boundingRect.left += leftOffset;
boundingRect.top += topOffset;
boundingRect.right += leftOffset;
boundingRect.bottom += topOffset;
}
const { width, height } = topSnapshotWindow['__playwright_frame_bounding_rects__'].viewport;
boundingRect.left = boundingRect.left / width;
boundingRect.top = boundingRect.top / height;
boundingRect.right = boundingRect.right / width;
boundingRect.bottom = boundingRect.bottom / height;
const partiallyUncaptured = boundingRect.right > 1 || boundingRect.bottom > 1; const partiallyUncaptured = boundingRect.right > 1 || boundingRect.bottom > 1;
const fullyUncaptured = boundingRect.left > 1 || boundingRect.top > 1; const fullyUncaptured = boundingRect.left > 1 || boundingRect.top > 1;
if (fullyUncaptured) { if (fullyUncaptured) {
@ -483,7 +540,7 @@ function snapshotScript(...targetIds: (string | undefined)[]) {
window.addEventListener('DOMContentLoaded', onDOMContentLoaded); window.addEventListener('DOMContentLoaded', onDOMContentLoaded);
} }
return `\n(${applyPlaywrightAttributes.toString()})(${unwrapPopoutUrl.toString()}${targetIds.map(id => `, "${id}"`).join('')})`; return `\n(${applyPlaywrightAttributes.toString()})(${unwrapPopoutUrl.toString()}, ${JSON.stringify(viewport)}${targetIds.map(id => `, "${id}"`).join('')})`;
} }

View file

@ -129,7 +129,7 @@ const ResponseTab: React.FunctionComponent<{
const BodyTab: React.FunctionComponent<{ const BodyTab: React.FunctionComponent<{
resource: ResourceSnapshot; resource: ResourceSnapshot;
}> = ({ resource }) => { }> = ({ resource }) => {
const [responseBody, setResponseBody] = React.useState<{ dataUrl?: string, text?: string, mimeType?: string, font?: BinaryData } | null>(null); const [responseBody, setResponseBody] = React.useState<{ dataUrl?: string, text?: string, mimeType?: string, font?: BufferSource } | null>(null);
React.useEffect(() => { React.useEffect(() => {
const readResources = async () => { const readResources = async () => {
@ -167,7 +167,7 @@ const BodyTab: React.FunctionComponent<{
}; };
const FontPreview: React.FunctionComponent<{ const FontPreview: React.FunctionComponent<{
font: BinaryData; font: BufferSource;
}> = ({ font }) => { }> = ({ font }) => {
const [isError, setIsError] = React.useState(false); const [isError, setIsError] = React.useState(false);

View file

@ -344,7 +344,8 @@ export function extendSnapshot(snapshot: Snapshot): SnapshotUrls {
const popoutParams = new URLSearchParams(); const popoutParams = new URLSearchParams();
popoutParams.set('r', snapshotUrl); popoutParams.set('r', snapshotUrl);
popoutParams.set('server', serverParam ?? ''); if (serverParam)
popoutParams.set('server', serverParam);
popoutParams.set('trace', context(snapshot.action).traceUrl); popoutParams.set('trace', context(snapshot.action).traceUrl);
if (snapshot.point) { if (snapshot.point) {
popoutParams.set('pointX', String(snapshot.point.x)); popoutParams.set('pointX', String(snapshot.point.x));

View file

@ -47,9 +47,7 @@ const xtermDataSource: XtermDataSource = {
}; };
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search);
let testServerBaseUrl = new URL('../', window.location.href); const testServerBaseUrl = new URL(searchParams.get('server') ?? '../', window.location.href);
if (testServerBaseUrl.searchParams.has('server'))
testServerBaseUrl = new URL(testServerBaseUrl.searchParams.get('server')!, testServerBaseUrl);
const wsURL = new URL(searchParams.get('ws')!, testServerBaseUrl); const wsURL = new URL(searchParams.get('ws')!, testServerBaseUrl);
wsURL.protocol = (wsURL.protocol === 'https:' ? 'wss:' : 'ws:'); wsURL.protocol = (wsURL.protocol === 'https:' ? 'wss:' : 'ws:');
const queryParams = { const queryParams = {

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