Merge branch 'main' into sharding-algorithm

This commit is contained in:
Mathias Leppich 2024-07-02 09:37:30 +02:00
commit 29f67f3522
75 changed files with 1293 additions and 865 deletions

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-127.0.6533.17-blue.svg?logo=google-chrome)](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[![Firefox version](https://img.shields.io/badge/firefox-127.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-17.4-blue.svg?logo=safari)](https://webkit.org/)<!-- GEN:stop --> [![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-127.0.6533.26-blue.svg?logo=google-chrome)](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[![Firefox version](https://img.shields.io/badge/firefox-127.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-17.4-blue.svg?logo=safari)](https://webkit.org/)<!-- GEN:stop -->
## [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 -->127.0.6533.17<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Chromium <!-- GEN:chromium-version -->127.0.6533.26<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| WebKit <!-- GEN:webkit-version -->17.4<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit <!-- GEN:webkit-version -->17.4<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Firefox <!-- GEN:firefox-version -->127.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox <!-- GEN:firefox-version -->127.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |

View file

@ -357,18 +357,14 @@ await context.AddCookiesAsync(new[] { cookie1, cookie2 });
- `cookies` <[Array]<[Object]>> - `cookies` <[Array]<[Object]>>
- `name` <[string]> - `name` <[string]>
- `value` <[string]> - `value` <[string]>
- `url` ?<[string]> either url or domain / path are required. Optional. - `url` ?<[string]> Either url or domain / path are required. Optional.
- `domain` ?<[string]> either url or domain / path are required Optional. - `domain` ?<[string]> For the cookie to apply to all subdomains as well, prefix domain with a dot, like this: ".example.com". Either url or domain / path are required. Optional.
- `path` ?<[string]> either url or domain / path are required Optional. - `path` ?<[string]> Either url or domain / path are required Optional.
- `expires` ?<[float]> Unix time in seconds. Optional. - `expires` ?<[float]> Unix time in seconds. Optional.
- `httpOnly` ?<[boolean]> Optional. - `httpOnly` ?<[boolean]> Optional.
- `secure` ?<[boolean]> Optional. - `secure` ?<[boolean]> Optional.
- `sameSite` ?<[SameSiteAttribute]<"Strict"|"Lax"|"None">> Optional. - `sameSite` ?<[SameSiteAttribute]<"Strict"|"Lax"|"None">> Optional.
Adds cookies to the browser context.
For the cookie to apply to all subdomains as well, prefix domain with a dot, like this: ".example.com".
## async method: BrowserContext.addInitScript ## async method: BrowserContext.addInitScript
* since: v1.8 * since: v1.8
@ -748,83 +744,6 @@ await page.SetContentAsync("<script>\n" +
await page.GetByRole(AriaRole.Button).ClickAsync(); await page.GetByRole(AriaRole.Button).ClickAsync();
``` ```
An example of passing an element handle:
```js
await context.exposeBinding('clicked', async (source, element) => {
console.log(await element.textContent());
}, { handle: true });
await page.setContent(`
<script>
document.addEventListener('click', event => window.clicked(event.target));
</script>
<div>Click me</div>
<div>Or click me</div>
`);
```
```java
context.exposeBinding("clicked", (source, args) -> {
ElementHandle element = (ElementHandle) args[0];
System.out.println(element.textContent());
return null;
}, new BrowserContext.ExposeBindingOptions().setHandle(true));
page.setContent("" +
"<script>\n" +
" document.addEventListener('click', event => window.clicked(event.target));\n" +
"</script>\n" +
"<div>Click me</div>\n" +
"<div>Or click me</div>\n");
```
```python async
async def print(source, element):
print(await element.text_content())
await context.expose_binding("clicked", print, handle=true)
await page.set_content("""
<script>
document.addEventListener('click', event => window.clicked(event.target));
</script>
<div>Click me</div>
<div>Or click me</div>
""")
```
```python sync
def print(source, element):
print(element.text_content())
context.expose_binding("clicked", print, handle=true)
page.set_content("""
<script>
document.addEventListener('click', event => window.clicked(event.target));
</script>
<div>Click me</div>
<div>Or click me</div>
""")
```
```csharp
var result = new TaskCompletionSource<string>();
var page = await Context.NewPageAsync();
await Context.ExposeBindingAsync("clicked", async (BindingSource _, IJSHandle t) =>
{
return result.TrySetResult(await t.AsElement().TextContentAsync());
});
await page.SetContentAsync("<script>\n" +
" document.addEventListener('click', event => window.clicked(event.target));\n" +
"</script>\n" +
"<div>Click me</div>\n" +
"<div>Or click me</div>\n");
await page.ClickAsync("div");
// Note: it makes sense to await the result here, because otherwise, the context
// gets closed and the binding function will throw an exception.
Assert.AreEqual("Click me", await result.Task);
```
### param: BrowserContext.exposeBinding.name ### param: BrowserContext.exposeBinding.name
* since: v1.8 * since: v1.8
- `name` <[string]> - `name` <[string]>
@ -839,6 +758,7 @@ Callback function that will be called in the Playwright's context.
### option: BrowserContext.exposeBinding.handle ### option: BrowserContext.exposeBinding.handle
* since: v1.8 * since: v1.8
* deprecated: This option will be removed in the future.
- `handle` <[boolean]> - `handle` <[boolean]>
Whether to pass the argument as a handle, instead of passing by value. When passing a handle, only one argument is Whether to pass the argument as a handle, instead of passing by value. When passing a handle, only one argument is

View file

@ -136,7 +136,8 @@ page.clock.pause_at("2020-02-02")
``` ```
```java ```java
page.clock().pauseAt(Instant.parse("2020-02-02")); SimpleDateFormat format = new SimpleDateFormat("yyy-MM-dd");
page.clock().pauseAt(format.parse("2020-02-02"));
page.clock().pauseAt("2020-02-02"); page.clock().pauseAt("2020-02-02");
``` ```
@ -182,8 +183,8 @@ page.clock.set_fixed_time("2020-02-02")
``` ```
```java ```java
page.clock().setFixedTime(Instant.now()); page.clock().setFixedTime(new Date());
page.clock().setFixedTime(Instant.parse("2020-02-02")); page.clock().setFixedTime(new SimpleDateFormat("yyy-MM-dd").parse("2020-02-02"));
page.clock().setFixedTime("2020-02-02"); page.clock().setFixedTime("2020-02-02");
``` ```
@ -225,8 +226,8 @@ page.clock.set_system_time("2020-02-02")
``` ```
```java ```java
page.clock().setSystemTime(Instant.now()); page.clock().setSystemTime(new Date());
page.clock().setSystemTime(Instant.parse("2020-02-02")); page.clock().setSystemTime(new SimpleDateFormat("yyy-MM-dd").parse("2020-02-02"));
page.clock().setSystemTime("2020-02-02"); page.clock().setSystemTime("2020-02-02");
``` ```

View file

@ -1817,80 +1817,6 @@ class PageExamples
} }
``` ```
An example of passing an element handle:
```js
await page.exposeBinding('clicked', async (source, element) => {
console.log(await element.textContent());
}, { handle: true });
await page.setContent(`
<script>
document.addEventListener('click', event => window.clicked(event.target));
</script>
<div>Click me</div>
<div>Or click me</div>
`);
```
```java
page.exposeBinding("clicked", (source, args) -> {
ElementHandle element = (ElementHandle) args[0];
System.out.println(element.textContent());
return null;
}, new Page.ExposeBindingOptions().setHandle(true));
page.setContent("" +
"<script>\n" +
" document.addEventListener('click', event => window.clicked(event.target));\n" +
"</script>\n" +
"<div>Click me</div>\n" +
"<div>Or click me</div>\n");
```
```python async
async def print(source, element):
print(await element.text_content())
await page.expose_binding("clicked", print, handle=true)
await page.set_content("""
<script>
document.addEventListener('click', event => window.clicked(event.target));
</script>
<div>Click me</div>
<div>Or click me</div>
""")
```
```python sync
def print(source, element):
print(element.text_content())
page.expose_binding("clicked", print, handle=true)
page.set_content("""
<script>
document.addEventListener('click', event => window.clicked(event.target));
</script>
<div>Click me</div>
<div>Or click me</div>
""")
```
```csharp
var result = new TaskCompletionSource<string>();
await page.ExposeBindingAsync("clicked", async (BindingSource _, IJSHandle t) =>
{
return result.TrySetResult(await t.AsElement().TextContentAsync());
});
await page.SetContentAsync("<script>\n" +
" document.addEventListener('click', event => window.clicked(event.target));\n" +
"</script>\n" +
"<div>Click me</div>\n" +
"<div>Or click me</div>\n");
await page.ClickAsync("div");
Console.WriteLine(await result.Task);
```
### param: Page.exposeBinding.name ### param: Page.exposeBinding.name
* since: v1.8 * since: v1.8
- `name` <[string]> - `name` <[string]>
@ -1905,6 +1831,7 @@ Callback function that will be called in the Playwright's context.
### option: Page.exposeBinding.handle ### option: Page.exposeBinding.handle
* since: v1.8 * since: v1.8
* deprecated: This option will be removed in the future.
- `handle` <[boolean]> - `handle` <[boolean]>
Whether to pass the argument as a handle, instead of passing by value. When passing a handle, only one argument is Whether to pass the argument as a handle, instead of passing by value. When passing a handle, only one argument is

View file

@ -20,3 +20,23 @@ Dispatches a `touchstart` and `touchend` event with a single touch at the positi
### param: Touchscreen.tap.y ### param: Touchscreen.tap.y
* since: v1.8 * since: v1.8
- `y` <[float]> - `y` <[float]>
## async method: Touchscreen.touch
* since: v1.46
Synthesizes a touch event.
### param: Touchscreen.touch.type
* since: v1.46
- `type` <[TouchType]<"touchstart"|"touchend"|"touchmove"|"touchcancel">>
Type of the touch event.
### param: Touchscreen.touch.touches
* since: v1.46
- `touchPoints` <[Array]<[Object]>>
- `x` <[float]> x coordinate of the event in CSS pixels.
- `y` <[float]> y coordinate of the event in CSS pixels.
- `id` ?<[int]> Identifier used to track the touch point between events, must be unique within an event. Optional.
List of touch points for this event. `id` is a unique identifier of a touch point that helps identify it between touch events for the duration of its movement around the surface.

View file

@ -601,6 +601,23 @@ steps:
- 'CI=true' - 'CI=true'
``` ```
### Drone
* langs: js
To run Playwright tests on Drone, use our public Docker image ([see Dockerfile](./docker.md)).
```yml
kind: pipeline
name: default
type: docker
steps:
- name: test
image: mcr.microsoft.com/playwright:v%%VERSION%%-jammy
commands:
- npx playwright test
```
## Caching browsers ## Caching browsers
Caching browser binaries is not recommended, since the amount of time it takes to restore the cache is comparable to the time it takes to download the binaries. Especially under Linux, [operating system dependencies](./browsers.md#install-system-dependencies) need to be installed, which are not cacheable. Caching browser binaries is not recommended, since the amount of time it takes to restore the cache is comparable to the time it takes to download the binaries. Especially under Linux, [operating system dependencies](./browsers.md#install-system-dependencies) need to be installed, which are not cacheable.
@ -633,7 +650,7 @@ By default, Playwright launches browsers in headless mode. See in our [Running t
On Linux agents, headed execution requires [Xvfb](https://en.wikipedia.org/wiki/Xvfb) to be installed. Our [Docker image](./docker.md) and GitHub Action have Xvfb pre-installed. To run browsers in headed mode with Xvfb, add `xvfb-run` before the actual command. On Linux agents, headed execution requires [Xvfb](https://en.wikipedia.org/wiki/Xvfb) to be installed. Our [Docker image](./docker.md) and GitHub Action have Xvfb pre-installed. To run browsers in headed mode with Xvfb, add `xvfb-run` before the actual command.
```bash js ```bash js
xvfb-run npx playwrght test xvfb-run npx playwright test
``` ```
```bash python ```bash python
xvfb-run pytest xvfb-run pytest

View file

@ -2,11 +2,24 @@
id: clock id: clock
title: "Clock" title: "Clock"
--- ---
import LiteYouTube from '@site/src/components/LiteYouTube';
## Introduction ## Introduction
Accurately simulating time-dependent behavior is essential for verifying the correctness of applications. Utilizing [Clock] functionality allows developers to manipulate and control time within tests, enabling the precise validation of features such as rendering time, timeouts, scheduled tasks without the delays and variability of real-time execution. Accurately simulating time-dependent behavior is essential for verifying the correctness of applications. Utilizing [Clock] functionality allows developers to manipulate and control time within tests, enabling the precise validation of features such as rendering time, timeouts, scheduled tasks without the delays and variability of real-time execution.
The [Clock] API provides the following methods to control time:
- `setFixedTime`: Sets the fixed time for `Date.now()` and `new Date()`.
- `install`: initializes the clock and allows you to:
- `pauseAt`: Pauses the time at a specific time.
- `fastForward`: Fast forwards the time.
- `runFor`: Runs the time for a specific duration.
- `resume`: Resumes the time.
- `setSystemTime`: Sets the current system time.
The recommended approach is to use `setFixedTime` to set the time to a specific value. If that doesn't work for your use case, you can use `install` which allows you to pause time later on, fast forward it, tick it, etc. `setSystemTime` is only recommended for advanced use cases.
:::note
[`property: Page.clock`] overrides native global classes and functions related to time allowing them to be manually controlled: [`property: Page.clock`] overrides native global classes and functions related to time allowing them to be manually controlled:
- `Date` - `Date`
- `setTimeout` - `setTimeout`
@ -18,6 +31,7 @@ Accurately simulating time-dependent behavior is essential for verifying the cor
- `requestIdleCallback` - `requestIdleCallback`
- `cancelIdleCallback` - `cancelIdleCallback`
- `performance` - `performance`
:::
## Test with predefined time ## Test with predefined time
@ -118,13 +132,14 @@ expect(page.get_by_test_id("current-time")).to_have_text("2/2/2024, 10:30:00 AM"
```java ```java
// Initialize clock with some time before the test time and let the page load // Initialize clock with some time before the test time and let the page load
// naturally. `Date.now` will progress as the timers fire. // naturally. `Date.now` will progress as the timers fire.
page.clock().install(new Clock.InstallOptions().setTime(Instant.parse("2024-02-02T08:00:00"))); SimpleDateFormat format = new SimpleDateFormat("yyy-MM-dd'T'HH:mm:ss");
page.clock().install(new Clock.InstallOptions().setTime(format.parse("2024-02-02T08:00:00")));
page.navigate("http://localhost:3333"); page.navigate("http://localhost:3333");
Locator locator = page.getByTestId("current-time"); Locator locator = page.getByTestId("current-time");
// Pretend that the user closed the laptop lid and opened it again at 10am. // Pretend that the user closed the laptop lid and opened it again at 10am.
// Pause the time once reached that point. // Pause the time once reached that point.
page.clock().pauseAt(Instant.parse("2024-02-02T10:00:00")); page.clock().pauseAt(format.parse("2024-02-02T10:00:00"));
// Assert the page state. // Assert the page state.
assertThat(locator).hasText("2/2/2024, 10:00:00 AM"); assertThat(locator).hasText("2/2/2024, 10:00:00 AM");
@ -315,15 +330,16 @@ expect(locator).to_have_text("2/2/2024, 10:00:02 AM")
``` ```
```java ```java
SimpleDateFormat format = new SimpleDateFormat("yyy-MM-dd'T'HH:mm:ss");
// Initialize clock with a specific time, let the page load naturally. // Initialize clock with a specific time, let the page load naturally.
page.clock().install(new Clock.InstallOptions() page.clock().install(new Clock.InstallOptions()
.setTime(Instant.parse("2024-02-02T08:00:00"))); .setTime(format.parse("2024-02-02T08:00:00")));
page.navigate("http://localhost:3333"); page.navigate("http://localhost:3333");
Locator locator = page.getByTestId("current-time"); Locator locator = page.getByTestId("current-time");
// Pause the time flow, stop the timers, you now have manual control // Pause the time flow, stop the timers, you now have manual control
// over the page time. // over the page time.
page.clock().pauseAt(Instant.parse("2024-02-02T10:00:00")); page.clock().pauseAt(format.parse("2024-02-02T10:00:00"));
assertThat(locator).hasText("2/2/2024, 10:00:00 AM"); assertThat(locator).hasText("2/2/2024, 10:00:00 AM");
// Tick through time manually, firing all timers in the process. // Tick through time manually, firing all timers in the process.
@ -351,3 +367,10 @@ await Expect(locator).ToHaveTextAsync("2/2/2024, 10:00:00 AM");
await Page.Clock.RunForAsync(2000); await Page.Clock.RunForAsync(2000);
await Expect(locator).ToHaveTextAsync("2/2/2024, 10:00:02 AM"); await Expect(locator).ToHaveTextAsync("2/2/2024, 10:00:02 AM");
``` ```
## Related Videos
<LiteYouTube
id="54_aC-rVKHg"
title="Playwright 1.45"
/>

View file

@ -5,7 +5,7 @@ title: "Docker"
## Introduction ## Introduction
[Dockerfile.jammy] can be used to run Playwright scripts in Docker environment. These image includes the [Playwright browsers](./browsers.md#install-browsers) and [browser system dependencies](./browsers.md#install-system-dependencies). The Playwright package/dependency is not included in the image and should be installed separately. [Dockerfile.jammy] can be used to run Playwright scripts in Docker environment. This image includes the [Playwright browsers](./browsers.md#install-browsers) and [browser system dependencies](./browsers.md#install-system-dependencies). The Playwright package/dependency is not included in the image and should be installed separately.
## Usage ## Usage

View file

@ -1444,14 +1444,6 @@ page.getByText("orange").click();
await page.GetByText("orange").ClickAsync(); await page.GetByText("orange").ClickAsync();
``` ```
```html card
<ul>
<li>apple</li>
<li>banana</li>
<li>orange</li>
</ul>
```
#### Filter by text #### Filter by text
Use the [`method: Locator.filter`] to locate a specific item in a list. Use the [`method: Locator.filter`] to locate a specific item in a list.

View file

@ -4,6 +4,69 @@ title: "Release notes"
toc_max_heading_level: 2 toc_max_heading_level: 2
--- ---
## Version 1.45
### Clock
Utilizing the new [Clock] API allows to manipulate and control time within tests to verify time-related behavior. This API covers many common scenarios, including:
* testing with predefined time;
* keeping consistent time and timers;
* monitoring inactivity;
* ticking through time manually.
```csharp
// 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.InstallAsync(new()
{
TimeDate = new DateTime(2024, 2, 2, 8, 0, 0)
});
await Page.GotoAsync("http://localhost:3333");
// Pretend that the user closed the laptop lid and opened it again at 10am.
// Pause the time once reached that point.
await Page.Clock.PauseAtAsync(new DateTime(2024, 2, 2, 10, 0, 0));
// Assert the page state.
await Expect(Page.GetByTestId("current-time")).ToHaveTextAsync("2/2/2024, 10:00:00 AM");
// Close the laptop lid again and open it at 10:30am.
await Page.Clock.FastForwardAsync("30:00");
await Expect(Page.GetByTestId("current-time")).ToHaveTextAsync("2/2/2024, 10:30:00 AM");
```
See [the clock guide](./clock.md) for more details.
### Miscellaneous
- Method [`method: Locator.setInputFiles`] now supports uploading a directory for `<input type=file webkitdirectory>` elements.
```csharp
await page.GetByLabel("Upload directory").SetInputFilesAsync("mydir");
```
- Multiple methods like [`method: Locator.click`] or [`method: Locator.press`] now support a `ControlOrMeta` modifier key. This key maps to `Meta` on macOS and maps to `Control` on Windows and Linux.
```csharp
// Press the common keyboard shortcut Control+S or Meta+S to trigger a "Save" operation.
await page.Keyboard.PressAsync("ControlOrMeta+S");
```
- New property `httpCredentials.send` in [`method: APIRequest.newContext`] that allows to either always send the `Authorization` header or only send it in response to `401 Unauthorized`.
- Playwright now supports Chromium, Firefox and WebKit on Ubuntu 24.04.
- v1.45 is the last release to receive WebKit update for macOS 12 Monterey. Please update macOS to keep using the latest WebKit.
### Browser Versions
* Chromium 127.0.6533.5
* Mozilla Firefox 127.0
* WebKit 17.4
This version was also tested against the following stable channels:
* Google Chrome 126
* Microsoft Edge 126
## Version 1.44 ## Version 1.44
### New APIs ### New APIs
@ -129,7 +192,7 @@ This version was also tested against the following stable channels:
### New Locator Handler ### New Locator Handler
New method [`method: Page.addLocatorHandler`] registers a callback that will be invoked when specified element becomes visible and may block Playwright actions. The callback can get rid of the overlay. Here is an example that closes a cookie dialog when it appears. New method [`method: Page.addLocatorHandler`] registers a callback that will be invoked when specified element becomes visible and may block Playwright actions. The callback can get rid of the overlay. Here is an example that closes a cookie dialog when it appears.
```csharp ```csharp
// Setup the handler. // Setup the handler.
await Page.AddLocatorHandlerAsync( await Page.AddLocatorHandlerAsync(

View file

@ -4,6 +4,67 @@ title: "Release notes"
toc_max_heading_level: 2 toc_max_heading_level: 2
--- ---
## Version 1.45
### Clock
Utilizing the new [Clock] API allows to manipulate and control time within tests to verify time-related behavior. This API covers many common scenarios, including:
* testing with predefined time;
* keeping consistent time and timers;
* monitoring inactivity;
* ticking through time manually.
```java
// 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(new Clock.InstallOptions().setTime("2024-02-02T08:00:00"));
page.navigate("http://localhost:3333");
Locator locator = page.getByTestId("current-time");
// Pretend that the user closed the laptop lid and opened it again at 10am.
// Pause the time once reached that point.
page.clock().pauseAt("2024-02-02T10:00:00");
// Assert the page state.
assertThat(locator).hasText("2/2/2024, 10:00:00 AM");
// Close the laptop lid again and open it at 10:30am.
page.clock().fastForward("30:00");
assertThat(locator).hasText("2/2/2024, 10:30:00 AM");
```
See [the clock guide](./clock.md) for more details.
### Miscellaneous
- Method [`method: Locator.setInputFiles`] now supports uploading a directory for `<input type=file webkitdirectory>` elements.
```java
page.getByLabel("Upload directory").setInputFiles(Paths.get("mydir"));
```
- Multiple methods like [`method: Locator.click`] or [`method: Locator.press`] now support a `ControlOrMeta` modifier key. This key maps to `Meta` on macOS and maps to `Control` on Windows and Linux.
```java
// Press the common keyboard shortcut Control+S or Meta+S to trigger a "Save" operation.
page.keyboard.press("ControlOrMeta+S");
```
- New property `httpCredentials.send` in [`method: APIRequest.newContext`] that allows to either always send the `Authorization` header or only send it in response to `401 Unauthorized`.
- Playwright now supports Chromium, Firefox and WebKit on Ubuntu 24.04.
- v1.45 is the last release to receive WebKit update for macOS 12 Monterey. Please update macOS to keep using the latest WebKit.
### Browser Versions
* Chromium 127.0.6533.5
* Mozilla Firefox 127.0
* WebKit 17.4
This version was also tested against the following stable channels:
* Google Chrome 126
* Microsoft Edge 126
## Version 1.44 ## Version 1.44
### New APIs ### New APIs
@ -201,7 +262,7 @@ Learn more about the fixtures in our [JUnit guide](./junit.md).
### New Locator Handler ### New Locator Handler
New method [`method: Page.addLocatorHandler`] registers a callback that will be invoked when specified element becomes visible and may block Playwright actions. The callback can get rid of the overlay. Here is an example that closes a cookie dialog when it appears. New method [`method: Page.addLocatorHandler`] registers a callback that will be invoked when specified element becomes visible and may block Playwright actions. The callback can get rid of the overlay. Here is an example that closes a cookie dialog when it appears.
```java ```java
// Setup the handler. // Setup the handler.
page.addLocatorHandler( page.addLocatorHandler(

View file

@ -8,6 +8,11 @@ import LiteYouTube from '@site/src/components/LiteYouTube';
## Version 1.45 ## Version 1.45
<LiteYouTube
id="54_aC-rVKHg"
title="Playwright 1.45"
/>
### Clock ### Clock
Utilizing the new [Clock] API allows to manipulate and control time within tests to verify time-related behavior. This API covers many common scenarios, including: Utilizing the new [Clock] API allows to manipulate and control time within tests to verify time-related behavior. This API covers many common scenarios, including:

View file

@ -4,6 +4,66 @@ title: "Release notes"
toc_max_heading_level: 2 toc_max_heading_level: 2
--- ---
## Version 1.45
### Clock
Utilizing the new [Clock] API allows to manipulate and control time within tests to verify time-related behavior. This API covers many common scenarios, including:
* testing with predefined time;
* keeping consistent time and timers;
* monitoring inactivity;
* ticking through time manually.
```python
# 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, 2, 2, 8, 0, 0))
page.goto("http://localhost:3333")
# Pretend that the user closed the laptop lid and opened it again at 10am.
# Pause the time once reached that point.
page.clock.pause_at(datetime.datetime(2024, 2, 2, 10, 0, 0))
# Assert the page state.
expect(page.get_by_test_id("current-time")).to_have_text("2/2/2024, 10:00:00 AM")
# Close the laptop lid again and open it at 10:30am.
page.clock.fast_forward("30:00")
expect(page.get_by_test_id("current-time")).to_have_text("2/2/2024, 10:30:00 AM")
```
See [the clock guide](./clock.md) for more details.
### Miscellaneous
- Method [`method: Locator.setInputFiles`] now supports uploading a directory for `<input type=file webkitdirectory>` elements.
```python
page.get_by_label("Upload directory").set_input_files('mydir')
```
- Multiple methods like [`method: Locator.click`] or [`method: Locator.press`] now support a `ControlOrMeta` modifier key. This key maps to `Meta` on macOS and maps to `Control` on Windows and Linux.
```python
# Press the common keyboard shortcut Control+S or Meta+S to trigger a "Save" operation.
page.keyboard.press("ControlOrMeta+S")
```
- New property `httpCredentials.send` in [`method: APIRequest.newContext`] that allows to either always send the `Authorization` header or only send it in response to `401 Unauthorized`.
- Playwright now supports Chromium, Firefox and WebKit on Ubuntu 24.04.
- v1.45 is the last release to receive WebKit update for macOS 12 Monterey. Please update macOS to keep using the latest WebKit.
### Browser Versions
* Chromium 127.0.6533.5
* Mozilla Firefox 127.0
* WebKit 17.4
This version was also tested against the following stable channels:
* Google Chrome 126
* Microsoft Edge 126
## Version 1.44 ## Version 1.44
### New APIs ### New APIs
@ -109,7 +169,7 @@ This version was also tested against the following stable channels:
### New Locator Handler ### New Locator Handler
New method [`method: Page.addLocatorHandler`] registers a callback that will be invoked when specified element becomes visible and may block Playwright actions. The callback can get rid of the overlay. Here is an example that closes a cookie dialog when it appears. New method [`method: Page.addLocatorHandler`] registers a callback that will be invoked when specified element becomes visible and may block Playwright actions. The callback can get rid of the overlay. Here is an example that closes a cookie dialog when it appears.
```python ```python
# Setup the handler. # Setup the handler.
page.add_locator_handler( page.add_locator_handler(

View file

@ -43,48 +43,7 @@ Here is how typical test environment setup differs between traditional test styl
<summary>Click to expand the code for the <code>TodoPage</code></summary> <summary>Click to expand the code for the <code>TodoPage</code></summary>
<div> <div>
```js tab=js-js title="todo-page.js" ```js title="todo-page.ts"
export class TodoPage {
/**
* @param {import('@playwright/test').Page} page
*/
constructor(page) {
this.page = page;
this.inputBox = this.page.locator('input.new-todo');
this.todoItems = this.page.getByTestId('todo-item');
}
async goto() {
await this.page.goto('https://demo.playwright.dev/todomvc/');
}
/**
* @param {string} text
*/
async addToDo(text) {
await this.inputBox.fill(text);
await this.inputBox.press('Enter');
}
/**
* @param {string} text
*/
async remove(text) {
const todo = this.todoItems.filter({ hasText: text });
await todo.hover();
await todo.getByLabel('Delete').click();
}
async removeAll() {
while ((await this.todoItems.count()) > 0) {
await this.todoItems.first().hover();
await this.todoItems.getByLabel('Delete').first().click();
}
}
}
```
```js tab=js-ts title="todo-page.ts"
import type { Page, Locator } from '@playwright/test'; import type { Page, Locator } from '@playwright/test';
export class TodoPage { export class TodoPage {
@ -167,48 +126,7 @@ Fixtures have a number of advantages over before/after hooks:
<summary>Click to expand the code for the <code>TodoPage</code></summary> <summary>Click to expand the code for the <code>TodoPage</code></summary>
<div> <div>
```js tab=js-js title="todo-page.js" ```js title="todo-page.ts"
export class TodoPage {
/**
* @param {import('@playwright/test').Page} page
*/
constructor(page) {
this.page = page;
this.inputBox = this.page.locator('input.new-todo');
this.todoItems = this.page.getByTestId('todo-item');
}
async goto() {
await this.page.goto('https://demo.playwright.dev/todomvc/');
}
/**
* @param {string} text
*/
async addToDo(text) {
await this.inputBox.fill(text);
await this.inputBox.press('Enter');
}
/**
* @param {string} text
*/
async remove(text) {
const todo = this.todoItems.filter({ hasText: text });
await todo.hover();
await todo.getByLabel('Delete').click();
}
async removeAll() {
while ((await this.todoItems.count()) > 0) {
await this.todoItems.first().hover();
await this.todoItems.getByLabel('Delete').first().click();
}
}
}
```
```js tab=js-ts title="todo-page.ts"
import type { Page, Locator } from '@playwright/test'; import type { Page, Locator } from '@playwright/test';
export class TodoPage { export class TodoPage {
@ -246,34 +164,7 @@ export class TodoPage {
</div> </div>
</details> </details>
```js tab=js-js title="todo.spec.js" ```js title="example.spec.ts"
const base = require('@playwright/test');
const { TodoPage } = require('./todo-page');
// Extend basic test by providing a "todoPage" fixture.
const test = base.test.extend({
todoPage: async ({ page }, use) => {
const todoPage = new TodoPage(page);
await todoPage.goto();
await todoPage.addToDo('item1');
await todoPage.addToDo('item2');
await use(todoPage);
await todoPage.removeAll();
},
});
test('should add an item', async ({ todoPage }) => {
await todoPage.addToDo('my item');
// ...
});
test('should remove an item', async ({ todoPage }) => {
await todoPage.remove('item1');
// ...
});
```
```js tab=js-ts title="example.spec.ts"
import { test as base } from '@playwright/test'; import { test as base } from '@playwright/test';
import { TodoPage } from './todo-page'; import { TodoPage } from './todo-page';
@ -309,48 +200,8 @@ Below we create two fixtures `todoPage` and `settingsPage` that follow the [Page
<details> <details>
<summary>Click to expand the code for the <code>TodoPage</code> and <code>SettingsPage</code></summary> <summary>Click to expand the code for the <code>TodoPage</code> and <code>SettingsPage</code></summary>
<div> <div>
```js tab=js-js title="todo-page.js"
export class TodoPage {
/**
* @param {import('@playwright/test').Page} page
*/
constructor(page) {
this.page = page;
this.inputBox = this.page.locator('input.new-todo');
this.todoItems = this.page.getByTestId('todo-item');
}
async goto() { ```js title="todo-page.ts"
await this.page.goto('https://demo.playwright.dev/todomvc/');
}
/**
* @param {string} text
*/
async addToDo(text) {
await this.inputBox.fill(text);
await this.inputBox.press('Enter');
}
/**
* @param {string} text
*/
async remove(text) {
const todo = this.todoItems.filter({ hasText: text });
await todo.hover();
await todo.getByLabel('Delete').click();
}
async removeAll() {
while ((await this.todoItems.count()) > 0) {
await this.todoItems.first().hover();
await this.todoItems.getByLabel('Delete').first().click();
}
}
}
```
```js tab=js-ts title="todo-page.ts"
import type { Page, Locator } from '@playwright/test'; import type { Page, Locator } from '@playwright/test';
export class TodoPage { export class TodoPage {
@ -388,22 +239,7 @@ export class TodoPage {
SettingsPage is similar: SettingsPage is similar:
```js tab=js-js title="settings-page.js" ```js title="settings-page.ts"
export class SettingsPage {
/**
* @param {import('@playwright/test').Page} page
*/
constructor(page) {
this.page = page;
}
async switchToDarkMode() {
// ...
}
}
```
```js tab=js-ts title="settings-page.ts"
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
export class SettingsPage { export class SettingsPage {
@ -419,36 +255,7 @@ export class SettingsPage {
</div> </div>
</details> </details>
```js tab=js-js title="my-test.js" ```js title="my-test.ts"
const base = require('@playwright/test');
const { TodoPage } = require('./todo-page');
const { SettingsPage } = require('./settings-page');
// Extend base test by providing "todoPage" and "settingsPage".
// This new "test" can be used in multiple test files, and each of them will get the fixtures.
exports.test = base.test.extend({
todoPage: async ({ page }, use) => {
// Set up the fixture.
const todoPage = new TodoPage(page);
await todoPage.goto();
await todoPage.addToDo('item1');
await todoPage.addToDo('item2');
// Use the fixture value in the test.
await use(todoPage);
// Clean up the fixture.
await todoPage.removeAll();
},
settingsPage: async ({ page }, use) => {
await use(new SettingsPage(page));
},
});
exports.expect = base.expect;
```
```js tab=js-ts title="my-test.ts"
import { test as base } from '@playwright/test'; import { test as base } from '@playwright/test';
import { TodoPage } from './todo-page'; import { TodoPage } from './todo-page';
import { SettingsPage } from './settings-page'; import { SettingsPage } from './settings-page';
@ -493,20 +300,7 @@ Just mention fixture in your test function argument, and test runner will take c
Below we use the `todoPage` and `settingsPage` fixtures defined above. Below we use the `todoPage` and `settingsPage` fixtures defined above.
```js tab=js-js ```js
const { test, expect } = require('./my-test');
test.beforeEach(async ({ settingsPage }) => {
await settingsPage.switchToDarkMode();
});
test('basic test', async ({ todoPage, page }) => {
await todoPage.addToDo('something nice');
await expect(page.getByTestId('todo-title')).toContainText(['something nice']);
});
```
```js tab=js-ts
import { test, expect } from './my-test'; import { test, expect } from './my-test';
test.beforeEach(async ({ settingsPage }) => { test.beforeEach(async ({ settingsPage }) => {
@ -560,47 +354,7 @@ Playwright Test uses [worker processes](./test-parallel.md) to run test files. S
Below we'll create an `account` fixture that will be shared by all tests in the same worker, and override the `page` fixture to login into this account for each test. To generate unique accounts, we'll use the [`property: WorkerInfo.workerIndex`] that is available to any test or fixture. Note the tuple-like syntax for the worker fixture - we have to pass `{scope: 'worker'}` so that test runner sets up this fixture once per worker. Below we'll create an `account` fixture that will be shared by all tests in the same worker, and override the `page` fixture to login into this account for each test. To generate unique accounts, we'll use the [`property: WorkerInfo.workerIndex`] that is available to any test or fixture. Note the tuple-like syntax for the worker fixture - we have to pass `{scope: 'worker'}` so that test runner sets up this fixture once per worker.
```js tab=js-js title="my-test.js" ```js title="my-test.ts"
const base = require('@playwright/test');
exports.test = base.test.extend({
account: [async ({ browser }, use, workerInfo) => {
// Unique username.
const username = 'user' + workerInfo.workerIndex;
const password = 'verysecure';
// Create the account with Playwright.
const page = await browser.newPage();
await page.goto('/signup');
await page.getByLabel('User Name').fill(username);
await page.getByLabel('Password').fill(password);
await page.getByText('Sign up').click();
// Make sure everything is ok.
await expect(page.locator('#result')).toHaveText('Success');
// Do not forget to cleanup.
await page.close();
// Use the account value.
await use({ username, password });
}, { scope: 'worker' }],
page: async ({ page, account }, use) => {
// Sign in with our account.
const { username, password } = account;
await page.goto('/signin');
await page.getByLabel('User Name').fill(username);
await page.getByLabel('Password').fill(password);
await page.getByText('Sign in').click();
await expect(page.getByTestId('userinfo')).toHaveText(username);
// Use signed-in page in the test.
await use(page);
},
});
exports.expect = base.expect;
```
```js tab=js-ts title="my-test.ts"
import { test as base } from '@playwright/test'; import { test as base } from '@playwright/test';
type Account = { type Account = {
@ -652,32 +406,7 @@ Automatic fixtures are set up for each test/worker, even when the test does not
Here is an example fixture that automatically attaches debug logs when the test fails, so we can later review the logs in the reporter. Note how it uses [TestInfo] object that is available in each test/fixture to retrieve metadata about the test being run. Here is an example fixture that automatically attaches debug logs when the test fails, so we can later review the logs in the reporter. Note how it uses [TestInfo] object that is available in each test/fixture to retrieve metadata about the test being run.
```js tab=js-js title="my-test.js" ```js title="my-test.ts"
const debug = require('debug');
const fs = require('fs');
const base = require('@playwright/test');
exports.test = base.test.extend({
saveLogs: [async ({}, use, testInfo) => {
// Collecting logs during the test.
const logs = [];
debug.log = (...args) => logs.push(args.map(String).join(''));
debug.enable('myserver');
await use();
// After the test we can check whether the test passed or failed.
if (testInfo.status !== testInfo.expectedStatus) {
// outputPath() API guarantees a unique file name.
const logFile = testInfo.outputPath('logs.txt');
await fs.promises.writeFile(logFile, logs.join('\n'), 'utf8');
testInfo.attachments.push({ name: 'logs', contentType: 'text/plain', path: logFile });
}
}, { auto: true }],
});
```
```js tab=js-ts title="my-test.ts"
import * as debug from 'debug'; import * as debug from 'debug';
import * as fs from 'fs'; import * as fs from 'fs';
import { test as base } from '@playwright/test'; import { test as base } from '@playwright/test';
@ -707,22 +436,7 @@ export { expect } from '@playwright/test';
By default, fixture shares timeout with the test. However, for slow fixtures, especially [worker-scoped](#worker-scoped-fixtures) ones, it is convenient to have a separate timeout. This way you can keep the overall test timeout small, and give the slow fixture more time. By default, fixture shares timeout with the test. However, for slow fixtures, especially [worker-scoped](#worker-scoped-fixtures) ones, it is convenient to have a separate timeout. This way you can keep the overall test timeout small, and give the slow fixture more time.
```js tab=js-js ```js
const { test: base, expect } = require('@playwright/test');
const test = base.extend({
slowFixture: [async ({}, use) => {
// ... perform a slow operation ...
await use('hello');
}, { timeout: 60000 }]
});
test('example test', async ({ slowFixture }) => {
// ...
});
```
```js tab=js-ts
import { test as base, expect } from '@playwright/test'; import { test as base, expect } from '@playwright/test';
const test = base.extend<{ slowFixture: string }>({ const test = base.extend<{ slowFixture: string }>({
@ -752,48 +466,7 @@ Below we'll create a `defaultItem` option in addition to the `todoPage` fixture
<summary>Click to expand the code for the <code>TodoPage</code></summary> <summary>Click to expand the code for the <code>TodoPage</code></summary>
<div> <div>
```js tab=js-js title="todo-page.js" ```js title="todo-page.ts"
export class TodoPage {
/**
* @param {import('@playwright/test').Page} page
*/
constructor(page) {
this.page = page;
this.inputBox = this.page.locator('input.new-todo');
this.todoItems = this.page.getByTestId('todo-item');
}
async goto() {
await this.page.goto('https://demo.playwright.dev/todomvc/');
}
/**
* @param {string} text
*/
async addToDo(text) {
await this.inputBox.fill(text);
await this.inputBox.press('Enter');
}
/**
* @param {string} text
*/
async remove(text) {
const todo = this.todoItems.filter({ hasText: text });
await todo.hover();
await todo.getByLabel('Delete').click();
}
async removeAll() {
while ((await this.todoItems.count()) > 0) {
await this.todoItems.first().hover();
await this.todoItems.getByLabel('Delete').first().click();
}
}
}
```
```js tab=js-ts title="todo-page.ts"
import type { Page, Locator } from '@playwright/test'; import type { Page, Locator } from '@playwright/test';
export class TodoPage { export class TodoPage {
@ -832,28 +505,7 @@ export class TodoPage {
</div> </div>
</details> </details>
```js tab=js-js title="my-test.js" ```js title="my-test.ts"
const base = require('@playwright/test');
const { TodoPage } = require('./todo-page');
exports.test = base.test.extend({
// Define an option and provide a default value.
// We can later override it in the config.
defaultItem: ['Something nice', { option: true }],
// Our "todoPage" fixture depends on the option.
todoPage: async ({ page, defaultItem }, use) => {
const todoPage = new TodoPage(page);
await todoPage.goto();
await todoPage.addToDo(defaultItem);
await use(todoPage);
await todoPage.removeAll();
},
});
exports.expect = base.expect;
```
```js tab=js-ts title="my-test.ts"
import { test as base } from '@playwright/test'; import { test as base } from '@playwright/test';
import { TodoPage } from './todo-page'; import { TodoPage } from './todo-page';
@ -885,25 +537,7 @@ export { expect } from '@playwright/test';
We can now use `todoPage` fixture as usual, and set the `defaultItem` option in the config file. We can now use `todoPage` fixture as usual, and set the `defaultItem` option in the config file.
```js tab=js-js title="playwright.config.ts" ```js title="playwright.config.ts"
// @ts-check
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
projects: [
{
name: 'shopping',
use: { defaultItem: 'Buy milk' },
},
{
name: 'wellbeing',
use: { defaultItem: 'Exercise!' },
},
]
});
```
```js tab=js-ts title="playwright.config.ts"
import { defineConfig } from '@playwright/test'; import { defineConfig } from '@playwright/test';
import type { MyOptions } from './my-test'; import type { MyOptions } from './my-test';
@ -932,50 +566,7 @@ Fixtures follow these rules to determine the execution order:
Consider the following example: Consider the following example:
```js tab=js-js ```js
const { test: base } = require('@playwright/test');
const test = base.extend({
workerFixture: [async ({ browser }) => {
// workerFixture setup...
await use('workerFixture');
// workerFixture teardown...
}, { scope: 'worker' }],
autoWorkerFixture: [async ({ browser }) => {
// autoWorkerFixture setup...
await use('autoWorkerFixture');
// autoWorkerFixture teardown...
}, { scope: 'worker', auto: true }],
testFixture: [async ({ page, workerFixture }) => {
// testFixture setup...
await use('testFixture');
// testFixture teardown...
}, { scope: 'test' }],
autoTestFixture: [async () => {
// autoTestFixture setup...
await use('autoTestFixture');
// autoTestFixture teardown...
}, { scope: 'test', auto: true }],
unusedFixture: [async ({ page }) => {
// unusedFixture setup...
await use('unusedFixture');
// unusedFixture teardown...
}, { scope: 'test' }],
});
test.beforeAll(async () => { /* ... */ });
test.beforeEach(async ({ page }) => { /* ... */ });
test('first test', async ({ page }) => { /* ... */ });
test('second test', async ({ testFixture }) => { /* ... */ });
test.afterEach(async () => { /* ... */ });
test.afterAll(async () => { /* ... */ });
```
```js tab=js-ts
import { test as base } from '@playwright/test'; import { test as base } from '@playwright/test';
const test = base.extend<{ const test = base.extend<{
@ -1081,3 +672,30 @@ test('passes', async ({ database, page, a11y }) => {
// use database and a11y fixtures. // use database and a11y fixtures.
}); });
``` ```
## Box fixtures
You can minimize the fixture exposure to the reporters UI and error messages via boxing it:
```js
import { test as base } from '@playwright/test';
export const test = base.extend({
_helperFixture: [async ({}, use, testInfo) => {
}, { box: true }],
});
```
## Custom fixture title
You can assign a custom title to a fixture to be used in error messages and in the
reporters UI:
```js
import { test as base } from '@playwright/test';
export const test = base.extend({
_innerFixture: [async ({}, use, testInfo) => {
}, { title: 'my fixture' }],
});
```

View file

@ -3,15 +3,15 @@
"browsers": [ "browsers": [
{ {
"name": "chromium", "name": "chromium",
"revision": "1124", "revision": "1125",
"installByDefault": true, "installByDefault": true,
"browserVersion": "127.0.6533.17" "browserVersion": "127.0.6533.26"
}, },
{ {
"name": "chromium-tip-of-tree", "name": "chromium-tip-of-tree",
"revision": "1231", "revision": "1236",
"installByDefault": false, "installByDefault": false,
"browserVersion": "128.0.6536.0" "browserVersion": "128.0.6561.0"
}, },
{ {
"name": "firefox", "name": "firefox",
@ -27,7 +27,7 @@
}, },
{ {
"name": "webkit", "name": "webkit",
"revision": "2037", "revision": "2041",
"installByDefault": true, "installByDefault": true,
"revisionOverrides": { "revisionOverrides": {
"mac10.14": "1446", "mac10.14": "1446",

View file

@ -85,16 +85,6 @@ export class Browser extends ChannelOwner<channels.BrowserChannel> implements ap
const response = forReuse ? await this._channel.newContextForReuse(contextOptions) : await this._channel.newContext(contextOptions); const response = forReuse ? await this._channel.newContextForReuse(contextOptions) : await this._channel.newContext(contextOptions);
const context = BrowserContext.from(response.context); const context = BrowserContext.from(response.context);
await this._browserType._didCreateContext(context, contextOptions, this._options, options.logger || this._logger); await this._browserType._didCreateContext(context, contextOptions, this._options, options.logger || this._logger);
if (!forReuse && process.env.PW_CLOCK === 'frozen') {
await this._wrapApiCall(async () => {
await context.clock.install({ time: 0 });
await context.clock.pauseAt(1000);
}, true);
} else if (!forReuse && process.env.PW_CLOCK === 'realtime') {
await this._wrapApiCall(async () => {
await context.clock.install({ time: 0 });
}, true);
}
return context; return context;
} }

View file

@ -29,7 +29,7 @@ import type { Page } from './page';
import { ConsoleMessage } from './consoleMessage'; import { ConsoleMessage } from './consoleMessage';
import type { Env, WaitForEventOptions, Headers, BrowserContextOptions } from './types'; import type { Env, WaitForEventOptions, Headers, BrowserContextOptions } from './types';
import { Waiter } from './waiter'; import { Waiter } from './waiter';
import { TargetClosedError } from './errors'; import { TargetClosedError, isTargetClosedError } from './errors';
type ElectronOptions = Omit<channels.ElectronLaunchOptions, 'env'|'extraHTTPHeaders'|'recordHar'|'colorScheme'|'acceptDownloads'> & { type ElectronOptions = Omit<channels.ElectronLaunchOptions, 'env'|'extraHTTPHeaders'|'recordHar'|'colorScheme'|'acceptDownloads'> & {
env?: Env, env?: Env,
@ -116,7 +116,13 @@ export class ElectronApplication extends ChannelOwner<channels.ElectronApplicati
} }
async close() { async close() {
await this._context.close().catch(() => {}); try {
await this._context.close();
} catch (e) {
if (isTargetClosedError(e))
return;
throw e;
}
} }
async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise<any> { async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise<any> {

View file

@ -89,4 +89,8 @@ export class Touchscreen implements api.Touchscreen {
async tap(x: number, y: number) { async tap(x: number, y: number) {
await this._page._channel.touchscreenTap({ x, y }); await this._page._channel.touchscreenTap({ x, y });
} }
async touch(type: 'touchstart'|'touchmove'|'touchend'|'touchcancel', touchPoints: { x: number, y: number, id?: number }[]) {
await this._page._channel.touchscreenTouch({ type, touchPoints });
}
} }

View file

@ -31,6 +31,7 @@ export const slowMoActions = new Set([
'Page.mouseClick', 'Page.mouseClick',
'Page.mouseWheel', 'Page.mouseWheel',
'Page.touchscreenTap', 'Page.touchscreenTap',
'Page.touchscreenTouch',
'Frame.blur', 'Frame.blur',
'Frame.check', 'Frame.check',
'Frame.click', 'Frame.click',
@ -89,6 +90,7 @@ export const commandsWithTracingSnapshots = new Set([
'Page.mouseClick', 'Page.mouseClick',
'Page.mouseWheel', 'Page.mouseWheel',
'Page.touchscreenTap', 'Page.touchscreenTap',
'Page.touchscreenTouch',
'Frame.evalOnSelector', 'Frame.evalOnSelector',
'Frame.evalOnSelectorAll', 'Frame.evalOnSelectorAll',
'Frame.addScriptTag', 'Frame.addScriptTag',

View file

@ -1237,6 +1237,15 @@ scheme.PageTouchscreenTapParams = tObject({
y: tNumber, y: tNumber,
}); });
scheme.PageTouchscreenTapResult = tOptional(tObject({})); scheme.PageTouchscreenTapResult = tOptional(tObject({}));
scheme.PageTouchscreenTouchParams = tObject({
type: tEnum(['touchstart', 'touchend', 'touchmove', 'touchcancel']),
touchPoints: tArray(tObject({
x: tNumber,
y: tNumber,
id: tOptional(tNumber),
})),
});
scheme.PageTouchscreenTouchResult = tOptional(tObject({}));
scheme.PageAccessibilitySnapshotParams = tObject({ scheme.PageAccessibilitySnapshotParams = tObject({
interestingOnly: tOptional(tBoolean), interestingOnly: tOptional(tBoolean),
root: tOptional(tChannel(['ElementHandle'])), root: tOptional(tChannel(['ElementHandle'])),

View file

@ -24,6 +24,7 @@ import type { Download } from './download';
import type * as frames from './frames'; 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 type { PageDelegate } 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';
@ -84,7 +85,7 @@ export abstract class BrowserContext extends SdkObject {
private _customCloseHandler?: () => Promise<any>; private _customCloseHandler?: () => Promise<any>;
readonly _tempDirs: string[] = []; readonly _tempDirs: string[] = [];
private _settingStorageState = false; private _settingStorageState = false;
readonly initScripts: string[] = []; readonly initScripts: InitScript[] = [];
private _routesInFlight = new Set<network.Route>(); private _routesInFlight = new Set<network.Route>();
private _debugger!: Debugger; private _debugger!: Debugger;
_closeReason: string | undefined; _closeReason: string | undefined;
@ -266,7 +267,7 @@ export abstract class BrowserContext extends SdkObject {
protected abstract doGrantPermissions(origin: string, permissions: string[]): Promise<void>; protected abstract doGrantPermissions(origin: string, permissions: string[]): Promise<void>;
protected abstract doClearPermissions(): Promise<void>; protected abstract doClearPermissions(): Promise<void>;
protected abstract doSetHTTPCredentials(httpCredentials?: types.Credentials): Promise<void>; protected abstract doSetHTTPCredentials(httpCredentials?: types.Credentials): Promise<void>;
protected abstract doAddInitScript(expression: string): Promise<void>; protected abstract doAddInitScript(initScript: InitScript): Promise<void>;
protected abstract doRemoveInitScripts(): Promise<void>; protected abstract doRemoveInitScripts(): Promise<void>;
protected abstract doExposeBinding(binding: PageBinding): Promise<void>; protected abstract doExposeBinding(binding: PageBinding): Promise<void>;
protected abstract doRemoveExposedBindings(): Promise<void>; protected abstract doRemoveExposedBindings(): Promise<void>;
@ -403,9 +404,10 @@ export abstract class BrowserContext extends SdkObject {
this._options.httpCredentials = { username, password: password || '' }; this._options.httpCredentials = { username, password: password || '' };
} }
async addInitScript(script: string) { async addInitScript(source: string) {
this.initScripts.push(script); const initScript = new InitScript(source);
await this.doAddInitScript(script); this.initScripts.push(initScript);
await this.doAddInitScript(initScript);
} }
async _removeInitScripts(): Promise<void> { async _removeInitScripts(): Promise<void> {

View file

@ -319,7 +319,7 @@ export class Chromium extends BrowserType {
if (process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW) if (process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW)
chromeArguments.push('--headless=new'); chromeArguments.push('--headless=new');
else else
chromeArguments.push('--headless'); chromeArguments.push('--headless=old');
chromeArguments.push( chromeArguments.push(
'--hide-scrollbars', '--hide-scrollbars',

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 { PageBinding, PageDelegate, Worker } from '../page'; import type { InitScript, PageBinding, PageDelegate, 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';
@ -486,9 +486,9 @@ export class CRBrowserContext extends BrowserContext {
await (sw as CRServiceWorker).updateHttpCredentials(); await (sw as CRServiceWorker).updateHttpCredentials();
} }
async doAddInitScript(source: string) { async doAddInitScript(initScript: InitScript) {
for (const page of this.pages()) for (const page of this.pages())
await (page._delegate as CRPage).addInitScript(source); await (page._delegate as CRPage).addInitScript(initScript);
} }
async doRemoveInitScripts() { async doRemoveInitScripts() {

View file

@ -179,4 +179,19 @@ export class RawTouchscreenImpl implements input.RawTouchscreen {
}), }),
]); ]);
} }
async touch(eventType: 'touchstart'|'touchmove'|'touchend'|'touchcancel', touchPoints: { x: number, y: number, id?: number }[], modifiers: Set<types.KeyboardModifier>) {
let type: 'touchStart' | 'touchMove' | 'touchEnd' | 'touchCancel';
switch (eventType) {
case 'touchstart': type = 'touchStart'; break;
case 'touchmove': type = 'touchMove'; break;
case 'touchend': type = 'touchEnd'; break;
case 'touchcancel': type = 'touchCancel'; break;
default: throw new Error('Invalid eventType: ' + eventType);
}
await this._client.send('Input.dispatchTouchEvent', {
type,
touchPoints,
modifiers: toModifiersMask(modifiers)
});
}
} }

View file

@ -359,11 +359,11 @@ export class CRNetworkManager {
}); });
this._requestIdToRequest.set(requestWillBeSentEvent.requestId, request); this._requestIdToRequest.set(requestWillBeSentEvent.requestId, request);
if (requestPausedEvent) { if (route) {
// We will not receive extra info when intercepting the request. // We may not receive extra info when intercepting the request.
// Use the headers from the Fetch.requestPausedPayload and release the allHeaders() // Use the headers from the Fetch.requestPausedPayload and release the allHeaders()
// right away, so that client can call it from the route handler. // right away, so that client can call it from the route handler.
request.request.setRawRequestHeaders(headersOverride ?? headersObjectToArray(requestPausedEvent.request.headers, '\n')); request.request.setRawRequestHeaders(headersObjectToArray(requestPausedEvent!.request.headers, '\n'));
} }
(this._page?._frameManager || this._serviceWorker)!.requestStarted(request.request, route || undefined); (this._page?._frameManager || this._serviceWorker)!.requestStarted(request.request, route || undefined);
} }

View file

@ -26,7 +26,7 @@ import * as dom from '../dom';
import * as frames from '../frames'; import * as frames from '../frames';
import { helper } from '../helper'; import { helper } from '../helper';
import * as network from '../network'; import * as network from '../network';
import type { PageBinding, PageDelegate } from '../page'; import type { InitScript, PageBinding, PageDelegate } from '../page';
import { Page, Worker } from '../page'; import { Page, Worker } from '../page';
import type { Progress } from '../progress'; import type { Progress } from '../progress';
import type * as types from '../types'; import type * as types from '../types';
@ -256,8 +256,8 @@ export class CRPage implements PageDelegate {
return this._go(+1); return this._go(+1);
} }
async addInitScript(source: string, world: types.World = 'main'): Promise<void> { async addInitScript(initScript: InitScript, world: types.World = 'main'): Promise<void> {
await this._forAllFrameSessions(frame => frame._evaluateOnNewDocument(source, world)); await this._forAllFrameSessions(frame => frame._evaluateOnNewDocument(initScript, world));
} }
async removeInitScripts() { async removeInitScripts() {
@ -511,6 +511,20 @@ class FrameSession {
this._addRendererListeners(); this._addRendererListeners();
} }
const localFrames = this._isMainFrame() ? this._page.frames() : [this._page._frameManager.frame(this._targetId)!];
for (const frame of localFrames) {
// Note: frames might be removed before we send these.
this._client._sendMayFail('Page.createIsolatedWorld', {
frameId: frame._id,
grantUniveralAccess: true,
worldName: UTILITY_WORLD_NAME,
});
for (const binding of this._crPage._browserContext._pageBindings.values())
frame.evaluateExpression(binding.source).catch(e => {});
for (const initScript of this._crPage._browserContext.initScripts)
frame.evaluateExpression(initScript.source).catch(e => {});
}
const isInitialEmptyPage = this._isMainFrame() && this._page.mainFrame().url() === ':'; const isInitialEmptyPage = this._isMainFrame() && this._page.mainFrame().url() === ':';
if (isInitialEmptyPage) { if (isInitialEmptyPage) {
// Ignore lifecycle events, worlds and bindings for the initial empty page. It is never the final page // Ignore lifecycle events, worlds and bindings for the initial empty page. It is never the final page
@ -520,20 +534,6 @@ class FrameSession {
this._eventListeners.push(eventsHelper.addEventListener(this._client, 'Page.lifecycleEvent', event => this._onLifecycleEvent(event))); this._eventListeners.push(eventsHelper.addEventListener(this._client, 'Page.lifecycleEvent', event => this._onLifecycleEvent(event)));
}); });
} else { } else {
const localFrames = this._isMainFrame() ? this._page.frames() : [this._page._frameManager.frame(this._targetId)!];
for (const frame of localFrames) {
// Note: frames might be removed before we send these.
this._client._sendMayFail('Page.createIsolatedWorld', {
frameId: frame._id,
grantUniveralAccess: true,
worldName: UTILITY_WORLD_NAME,
});
for (const binding of this._crPage._browserContext._pageBindings.values())
frame.evaluateExpression(binding.source).catch(e => {});
for (const source of this._crPage._browserContext.initScripts)
frame.evaluateExpression(source).catch(e => {});
}
this._firstNonInitialNavigationCommittedFulfill(); this._firstNonInitialNavigationCommittedFulfill();
this._eventListeners.push(eventsHelper.addEventListener(this._client, 'Page.lifecycleEvent', event => this._onLifecycleEvent(event))); this._eventListeners.push(eventsHelper.addEventListener(this._client, 'Page.lifecycleEvent', event => this._onLifecycleEvent(event)));
} }
@ -575,10 +575,10 @@ class FrameSession {
promises.push(this._updateFileChooserInterception(true)); promises.push(this._updateFileChooserInterception(true));
for (const binding of this._crPage._page.allBindings()) for (const binding of this._crPage._page.allBindings())
promises.push(this._initBinding(binding)); promises.push(this._initBinding(binding));
for (const source of this._crPage._browserContext.initScripts) for (const initScript of this._crPage._browserContext.initScripts)
promises.push(this._evaluateOnNewDocument(source, 'main')); promises.push(this._evaluateOnNewDocument(initScript, 'main'));
for (const source of this._crPage._page.initScripts) for (const initScript of this._crPage._page.initScripts)
promises.push(this._evaluateOnNewDocument(source, 'main')); promises.push(this._evaluateOnNewDocument(initScript, 'main'));
if (screencastOptions) if (screencastOptions)
promises.push(this._startVideoRecording(screencastOptions)); promises.push(this._startVideoRecording(screencastOptions));
} }
@ -1099,9 +1099,9 @@ class FrameSession {
await this._client.send('Page.setInterceptFileChooserDialog', { enabled }).catch(() => {}); // target can be closed. await this._client.send('Page.setInterceptFileChooserDialog', { enabled }).catch(() => {}); // target can be closed.
} }
async _evaluateOnNewDocument(source: string, world: types.World): Promise<void> { async _evaluateOnNewDocument(initScript: InitScript, world: types.World): Promise<void> {
const worldName = world === 'utility' ? UTILITY_WORLD_NAME : undefined; const worldName = world === 'utility' ? UTILITY_WORLD_NAME : undefined;
const { identifier } = await this._client.send('Page.addScriptToEvaluateOnNewDocument', { source, worldName }); const { identifier } = await this._client.send('Page.addScriptToEvaluateOnNewDocument', { source: initScript.source, worldName });
this._evaluateOnNewDocumentIdentifiers.push(identifier); this._evaluateOnNewDocumentIdentifiers.push(identifier);
} }

View file

@ -37,7 +37,7 @@ export function getExceptionMessage(exceptionDetails: Protocol.Runtime.Exception
} }
export async function releaseObject(client: CRSession, objectId: string) { export async function releaseObject(client: CRSession, objectId: string) {
await client.send('Runtime.releaseObject', { objectId }).catch(error => {}); await client.send('Runtime.releaseObject', { objectId }).catch(error => { });
} }
export async function saveProtocolStream(client: CRSession, handle: string, path: string) { export async function saveProtocolStream(client: CRSession, handle: string, path: string) {
@ -91,7 +91,8 @@ export function exceptionToError(exceptionDetails: Protocol.Runtime.ExceptionDet
const err = new Error(message); const err = new Error(message);
err.stack = stack; err.stack = stack;
err.name = name; const nameOverride = exceptionDetails.exception?.preview?.properties.find(o => o.name === 'name');
err.name = nameOverride ? nameOverride.value ?? 'Error' : name;
return err; return err;
} }

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/127.0.6533.17 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 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/127.0.6533.17 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 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/127.0.6533.17 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 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/127.0.6533.17 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 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/127.0.6533.17 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 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/127.0.6533.17 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 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/127.0.6533.17 Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 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/127.0.6533.17 Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Safari/537.36",
"viewport": { "viewport": {
"width": 1138, "width": 1138,
"height": 712 "height": 712
@ -978,7 +978,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/127.0.6533.17 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/127.0.6533.26 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 384, "width": 384,
"height": 640 "height": 640
@ -989,7 +989,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/127.0.6533.17 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/127.0.6533.26 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 640, "width": 640,
"height": 384 "height": 384
@ -1000,7 +1000,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/127.0.6533.17 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/127.0.6533.26 Mobile Safari/537.36 Edge/14.14263",
"viewport": { "viewport": {
"width": 640, "width": 640,
"height": 360 "height": 360
@ -1011,7 +1011,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/127.0.6533.17 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/127.0.6533.26 Mobile Safari/537.36 Edge/14.14263",
"viewport": { "viewport": {
"width": 360, "width": 360,
"height": 640 "height": 640
@ -1022,7 +1022,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/127.0.6533.17 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/127.0.6533.26 Mobile Safari/537.36 Edge/14.14263",
"viewport": { "viewport": {
"width": 360, "width": 360,
"height": 640 "height": 640
@ -1033,7 +1033,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/127.0.6533.17 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/127.0.6533.26 Mobile Safari/537.36 Edge/14.14263",
"viewport": { "viewport": {
"width": 640, "width": 640,
"height": 360 "height": 360
@ -1044,7 +1044,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/127.0.6533.17 Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Safari/537.36",
"viewport": { "viewport": {
"width": 800, "width": 800,
"height": 1280 "height": 1280
@ -1055,7 +1055,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/127.0.6533.17 Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Safari/537.36",
"viewport": { "viewport": {
"width": 1280, "width": 1280,
"height": 800 "height": 800
@ -1066,7 +1066,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/127.0.6533.17 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 384, "width": 384,
"height": 640 "height": 640
@ -1077,7 +1077,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/127.0.6533.17 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 640, "width": 640,
"height": 384 "height": 384
@ -1088,7 +1088,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/127.0.6533.17 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 360, "width": 360,
"height": 640 "height": 640
@ -1099,7 +1099,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/127.0.6533.17 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 640, "width": 640,
"height": 360 "height": 360
@ -1110,7 +1110,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/127.0.6533.17 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/127.0.6533.26 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 412, "width": 412,
"height": 732 "height": 732
@ -1121,7 +1121,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/127.0.6533.17 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/127.0.6533.26 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 732, "width": 732,
"height": 412 "height": 412
@ -1132,7 +1132,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/127.0.6533.17 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 412, "width": 412,
"height": 732 "height": 732
@ -1143,7 +1143,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/127.0.6533.17 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 732, "width": 732,
"height": 412 "height": 412
@ -1154,7 +1154,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/127.0.6533.17 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/127.0.6533.26 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 412, "width": 412,
"height": 732 "height": 732
@ -1165,7 +1165,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/127.0.6533.17 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/127.0.6533.26 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 732, "width": 732,
"height": 412 "height": 412
@ -1176,7 +1176,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/127.0.6533.17 Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Safari/537.36",
"viewport": { "viewport": {
"width": 600, "width": 600,
"height": 960 "height": 960
@ -1187,7 +1187,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/127.0.6533.17 Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Safari/537.36",
"viewport": { "viewport": {
"width": 960, "width": 960,
"height": 600 "height": 600
@ -1242,7 +1242,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/127.0.6533.17 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/127.0.6533.26 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 411, "width": 411,
"height": 731 "height": 731
@ -1253,7 +1253,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/127.0.6533.17 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/127.0.6533.26 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 731, "width": 731,
"height": 411 "height": 411
@ -1264,7 +1264,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/127.0.6533.17 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/127.0.6533.26 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 411, "width": 411,
"height": 823 "height": 823
@ -1275,7 +1275,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/127.0.6533.17 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/127.0.6533.26 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 823, "width": 823,
"height": 411 "height": 411
@ -1286,7 +1286,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/127.0.6533.17 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/127.0.6533.26 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 393, "width": 393,
"height": 786 "height": 786
@ -1297,7 +1297,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/127.0.6533.17 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/127.0.6533.26 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 786, "width": 786,
"height": 393 "height": 393
@ -1308,7 +1308,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/127.0.6533.17 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 353, "width": 353,
"height": 745 "height": 745
@ -1319,7 +1319,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/127.0.6533.17 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 745, "width": 745,
"height": 353 "height": 353
@ -1330,7 +1330,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/127.0.6533.17 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Mobile Safari/537.36",
"screen": { "screen": {
"width": 412, "width": 412,
"height": 892 "height": 892
@ -1345,7 +1345,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/127.0.6533.17 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Mobile Safari/537.36",
"screen": { "screen": {
"height": 892, "height": 892,
"width": 412 "width": 412
@ -1360,7 +1360,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/127.0.6533.17 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Mobile Safari/537.36",
"screen": { "screen": {
"width": 393, "width": 393,
"height": 851 "height": 851
@ -1375,7 +1375,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/127.0.6533.17 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Mobile Safari/537.36",
"screen": { "screen": {
"width": 851, "width": 851,
"height": 393 "height": 393
@ -1390,7 +1390,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/127.0.6533.17 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Mobile Safari/537.36",
"screen": { "screen": {
"width": 412, "width": 412,
"height": 915 "height": 915
@ -1405,7 +1405,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/127.0.6533.17 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Mobile Safari/537.36",
"screen": { "screen": {
"width": 915, "width": 915,
"height": 412 "height": 412
@ -1420,7 +1420,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/127.0.6533.17 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 360, "width": 360,
"height": 640 "height": 640
@ -1431,7 +1431,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/127.0.6533.17 Mobile Safari/537.36", "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Mobile Safari/537.36",
"viewport": { "viewport": {
"width": 640, "width": 640,
"height": 360 "height": 360
@ -1442,7 +1442,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/127.0.6533.17 Safari/537.36", "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Safari/537.36",
"screen": { "screen": {
"width": 1792, "width": 1792,
"height": 1120 "height": 1120
@ -1457,7 +1457,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/127.0.6533.17 Safari/537.36 Edg/127.0.6533.17", "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Safari/537.36 Edg/127.0.6533.26",
"screen": { "screen": {
"width": 1792, "width": 1792,
"height": 1120 "height": 1120
@ -1502,7 +1502,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/127.0.6533.17 Safari/537.36", "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Safari/537.36",
"screen": { "screen": {
"width": 1920, "width": 1920,
"height": 1080 "height": 1080
@ -1517,7 +1517,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/127.0.6533.17 Safari/537.36 Edg/127.0.6533.17", "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.26 Safari/537.36 Edg/127.0.6533.26",
"screen": { "screen": {
"width": 1920, "width": 1920,
"height": 1080 "height": 1080

View file

@ -265,6 +265,10 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
await this._page.touchscreen.tap(params.x, params.y, metadata); await this._page.touchscreen.tap(params.x, params.y, metadata);
} }
async touchscreenTouch(params: channels.PageTouchscreenTouchParams, metadata: CallMetadata) {
await this._page.touchscreen.touch(params.type, params.touchPoints, metadata);
}
async accessibilitySnapshot(params: channels.PageAccessibilitySnapshotParams, metadata: CallMetadata): Promise<channels.PageAccessibilitySnapshotResult> { async accessibilitySnapshot(params: channels.PageAccessibilitySnapshotParams, metadata: CallMetadata): Promise<channels.PageAccessibilitySnapshotResult> {
const rootAXNode = await this._page.accessibility.snapshot({ const rootAXNode = await this._page.accessibility.snapshot({
interestingOnly: params.interestingOnly, interestingOnly: params.interestingOnly,

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 { Page, PageBinding, PageDelegate } from '../page'; import type { InitScript, Page, PageBinding, PageDelegate } 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';
@ -352,8 +352,8 @@ export class FFBrowserContext extends BrowserContext {
await this._browser.session.send('Browser.setHTTPCredentials', { browserContextId: this._browserContextId, credentials }); await this._browser.session.send('Browser.setHTTPCredentials', { browserContextId: this._browserContextId, credentials });
} }
async doAddInitScript(source: string) { async doAddInitScript(initScript: InitScript) {
await this._browser.session.send('Browser.setInitScripts', { browserContextId: this._browserContextId, scripts: this.initScripts.map(script => ({ script })) }); await this._browser.session.send('Browser.setInitScripts', { browserContextId: this._browserContextId, scripts: this.initScripts.map(script => ({ script: script.source })) });
} }
async doRemoveInitScripts() { async doRemoveInitScripts() {

View file

@ -166,4 +166,8 @@ export class RawTouchscreenImpl implements input.RawTouchscreen {
modifiers: toModifiersMask(modifiers), modifiers: toModifiersMask(modifiers),
}); });
} }
async touch(eventType: 'touchstart'|'touchmove'|'touchend'|'touchcancel', touchPoints: { x: number, y: number, id?: number }[], modifiers: Set<types.KeyboardModifier>) {
throw new Error('Not implemented yet.');
}
} }

View file

@ -21,6 +21,7 @@ import type * as frames from '../frames';
import type { RegisteredListener } from '../../utils/eventsHelper'; import type { RegisteredListener } from '../../utils/eventsHelper';
import { eventsHelper } from '../../utils/eventsHelper'; import { eventsHelper } from '../../utils/eventsHelper';
import type { PageBinding, PageDelegate } from '../page'; import type { PageBinding, PageDelegate } from '../page';
import { InitScript } from '../page';
import { Page, Worker } from '../page'; import { Page, Worker } from '../page';
import type * as types from '../types'; import type * as types from '../types';
import { getAccessibilityTree } from './ffAccessibility'; import { getAccessibilityTree } from './ffAccessibility';
@ -56,7 +57,7 @@ export class FFPage implements PageDelegate {
private _eventListeners: RegisteredListener[]; private _eventListeners: RegisteredListener[];
private _workers = new Map<string, { frameId: string, session: FFSession }>(); private _workers = new Map<string, { frameId: string, session: FFSession }>();
private _screencastId: string | undefined; private _screencastId: string | undefined;
private _initScripts: { script: string, worldName?: string }[] = []; private _initScripts: { initScript: InitScript, worldName?: string }[] = [];
constructor(session: FFSession, browserContext: FFBrowserContext, opener: FFPage | null) { constructor(session: FFSession, browserContext: FFBrowserContext, opener: FFPage | null) {
this._session = session; this._session = session;
@ -113,7 +114,7 @@ export class FFPage implements PageDelegate {
}); });
// 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('', UTILITY_WORLD_NAME).catch(e => this._markAsError(e)); this.addInitScript(new InitScript(''), UTILITY_WORLD_NAME).catch(e => this._markAsError(e));
} }
potentiallyUninitializedPage(): Page { potentiallyUninitializedPage(): Page {
@ -406,9 +407,9 @@ export class FFPage implements PageDelegate {
return success; return success;
} }
async addInitScript(script: string, worldName?: string): Promise<void> { async addInitScript(initScript: InitScript, worldName?: string): Promise<void> {
this._initScripts.push({ script, worldName }); this._initScripts.push({ initScript, worldName });
await this._session.send('Page.setInitScripts', { scripts: this._initScripts }); await this._session.send('Page.setInitScripts', { scripts: this._initScripts.map(s => ({ script: s.initScript.source, worldName: s.worldName })) });
} }
async removeInitScripts() { async removeInitScripts() {

View file

@ -578,8 +578,8 @@ class TextAssertionTool implements RecorderTool {
onPointerUp(event: PointerEvent) { onPointerUp(event: PointerEvent) {
const target = this._hoverHighlight?.elements[0]; const target = this._hoverHighlight?.elements[0];
if (this._kind === 'value' && target && target.nodeName === 'INPUT' && (target as HTMLInputElement).disabled) { if (this._kind === 'value' && target && (target.nodeName === 'INPUT' || target.nodeName === 'SELECT') && (target as HTMLInputElement).disabled) {
// Click on a disabled input does not produce a "click" event, but we still want // Click on a disabled input (or select) does not produce a "click" event, but we still want
// to assert the value. // to assert the value.
this._commitAssertValue(); this._commitAssertValue();
} }

View file

@ -308,6 +308,7 @@ function buildLayoutClosure(layout: keyboardLayout.KeyboardLayout): Map<string,
export interface RawTouchscreen { export interface RawTouchscreen {
tap(x: number, y: number, modifiers: Set<types.KeyboardModifier>): Promise<void>; tap(x: number, y: number, modifiers: Set<types.KeyboardModifier>): Promise<void>;
touch(type: 'touchstart'|'touchmove'|'touchend'|'touchcancel', touchPoints: { x: number, y: number, id?: number }[], modifiers: Set<types.KeyboardModifier>): Promise<void>;
} }
export class Touchscreen { export class Touchscreen {
@ -326,4 +327,19 @@ export class Touchscreen {
throw new Error('hasTouch must be enabled on the browser context before using the touchscreen.'); throw new Error('hasTouch must be enabled on the browser context before using the touchscreen.');
await this._raw.tap(x, y, this._page.keyboard._modifiers()); await this._raw.tap(x, y, this._page.keyboard._modifiers());
} }
async touch(type: 'touchstart'|'touchmove'|'touchend'|'touchcancel', touchPoints: { x: number, y: number, id?: number }[], metadata?: CallMetadata) {
if (metadata && touchPoints.length === 1)
metadata.point = { x: touchPoints[0].x, y: touchPoints[0].y };
if (!this._page._browserContext._options.hasTouch)
throw new Error('hasTouch must be enabled on the browser context before using the touchscreen.');
const ids = new Set<number>();
for (const point of touchPoints) {
if (point.id !== undefined) {
if (ids.has(point.id))
throw new Error(`Duplicate touch point id: ${point.id}`);
ids.add(point.id);
}
}
await this._raw.touch(type, touchPoints, this._page.keyboard._modifiers());
}
} }

View file

@ -31,7 +31,7 @@ import * as accessibility from './accessibility';
import { FileChooser } from './fileChooser'; import { FileChooser } from './fileChooser';
import type { Progress } from './progress'; import type { Progress } from './progress';
import { ProgressController } from './progress'; import { ProgressController } from './progress';
import { LongStandingScope, assert, isError } from '../utils'; import { LongStandingScope, assert, createGuid, isError } from '../utils';
import { ManualPromise } from '../utils/manualPromise'; import { ManualPromise } from '../utils/manualPromise';
import { debugLogger } from '../utils/debugLogger'; import { debugLogger } from '../utils/debugLogger';
import type { ImageComparatorOptions } from '../utils/comparators'; import type { ImageComparatorOptions } from '../utils/comparators';
@ -56,7 +56,7 @@ export interface PageDelegate {
goForward(): Promise<boolean>; goForward(): Promise<boolean>;
exposeBinding(binding: PageBinding): Promise<void>; exposeBinding(binding: PageBinding): Promise<void>;
removeExposedBindings(): Promise<void>; removeExposedBindings(): Promise<void>;
addInitScript(source: string): Promise<void>; addInitScript(initScript: InitScript): Promise<void>;
removeInitScripts(): Promise<void>; removeInitScripts(): Promise<void>;
closePage(runBeforeUnload: boolean): Promise<void>; closePage(runBeforeUnload: boolean): Promise<void>;
potentiallyUninitializedPage(): Page; potentiallyUninitializedPage(): Page;
@ -154,7 +154,7 @@ export class Page extends SdkObject {
private _emulatedMedia: Partial<EmulatedMedia> = {}; private _emulatedMedia: Partial<EmulatedMedia> = {};
private _interceptFileChooser = false; private _interceptFileChooser = false;
private readonly _pageBindings = new Map<string, PageBinding>(); private readonly _pageBindings = new Map<string, PageBinding>();
readonly initScripts: string[] = []; readonly initScripts: InitScript[] = [];
readonly _screenshotter: Screenshotter; readonly _screenshotter: Screenshotter;
readonly _frameManager: frames.FrameManager; readonly _frameManager: frames.FrameManager;
readonly accessibility: accessibility.Accessibility; readonly accessibility: accessibility.Accessibility;
@ -527,8 +527,9 @@ export class Page extends SdkObject {
} }
async addInitScript(source: string) { async addInitScript(source: string) {
this.initScripts.push(source); const initScript = new InitScript(source);
await this._delegate.addInitScript(source); this.initScripts.push(initScript);
await this._delegate.addInitScript(initScript);
} }
async _removeInitScripts() { async _removeInitScripts() {
@ -905,6 +906,22 @@ function addPageBinding(bindingName: string, needsHandle: boolean, utilityScript
(globalThis as any)[bindingName].__installed = true; (globalThis as any)[bindingName].__installed = true;
} }
export class InitScript {
readonly source: string;
constructor(source: string) {
const guid = createGuid();
this.source = `(() => {
globalThis.__pwInitScripts = globalThis.__pwInitScripts || {};
const hasInitScript = globalThis.__pwInitScripts[${JSON.stringify(guid)}];
if (hasInitScript)
return;
globalThis.__pwInitScripts[${JSON.stringify(guid)}] = true;
${source}
})();`;
}
}
class FrameThrottler { class FrameThrottler {
private _acks: (() => void)[] = []; private _acks: (() => void)[] = [];
private _defaultInterval: number; private _defaultInterval: number;

View file

@ -5013,6 +5013,23 @@ might return multiple quads for inline nodes.
* UTC time in seconds, counted from January 1, 1970. * UTC time in seconds, counted from January 1, 1970.
*/ */
export type TimeSinceEpoch = number; export type TimeSinceEpoch = number;
/**
* Touch point.
*/
export interface TouchPoint {
/**
* X coordinate of the event relative to the main frame's viewport in CSS pixels.
*/
x: number;
/**
* Y coordinate of the event relative to the main frame's viewport in CSS pixels.
*/
y: number;
/**
* Identifier used to track touch sources between events, must be unique within an event.
*/
id: number;
}
/** /**
@ -5169,6 +5186,26 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the
} }
export type dispatchTapEventReturnValue = { export type dispatchTapEventReturnValue = {
} }
/**
* Dispatches a touch event to the page.
*/
export type dispatchTouchEventParameters = {
/**
* Type of the touch event.
*/
type: "touchStart"|"touchMove"|"touchEnd"|"touchCancel";
/**
* Bit field representing pressed modifier keys. Alt=1, Ctrl=2, Meta/Command=4, Shift=8
(default: 0).
*/
modifiers?: number;
/**
* List of touch points
*/
touchPoints?: TouchPoint[];
}
export type dispatchTouchEventReturnValue = {
}
} }
export module Inspector { export module Inspector {
@ -9532,6 +9569,7 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the
"Input.dispatchMouseEvent": Input.dispatchMouseEventParameters; "Input.dispatchMouseEvent": Input.dispatchMouseEventParameters;
"Input.dispatchWheelEvent": Input.dispatchWheelEventParameters; "Input.dispatchWheelEvent": Input.dispatchWheelEventParameters;
"Input.dispatchTapEvent": Input.dispatchTapEventParameters; "Input.dispatchTapEvent": Input.dispatchTapEventParameters;
"Input.dispatchTouchEvent": Input.dispatchTouchEventParameters;
"Inspector.enable": Inspector.enableParameters; "Inspector.enable": Inspector.enableParameters;
"Inspector.disable": Inspector.disableParameters; "Inspector.disable": Inspector.disableParameters;
"Inspector.initialized": Inspector.initializedParameters; "Inspector.initialized": Inspector.initializedParameters;
@ -9843,6 +9881,7 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the
"Input.dispatchMouseEvent": Input.dispatchMouseEventReturnValue; "Input.dispatchMouseEvent": Input.dispatchMouseEventReturnValue;
"Input.dispatchWheelEvent": Input.dispatchWheelEventReturnValue; "Input.dispatchWheelEvent": Input.dispatchWheelEventReturnValue;
"Input.dispatchTapEvent": Input.dispatchTapEventReturnValue; "Input.dispatchTapEvent": Input.dispatchTapEventReturnValue;
"Input.dispatchTouchEvent": Input.dispatchTouchEventReturnValue;
"Inspector.enable": Inspector.enableReturnValue; "Inspector.enable": Inspector.enableReturnValue;
"Inspector.disable": Inspector.disableReturnValue; "Inspector.disable": Inspector.disableReturnValue;
"Inspector.initialized": Inspector.initializedReturnValue; "Inspector.initialized": Inspector.initializedReturnValue;

View file

@ -22,7 +22,7 @@ import type { RegisteredListener } from '../../utils/eventsHelper';
import { assert } from '../../utils'; import { assert } from '../../utils';
import { eventsHelper } from '../../utils/eventsHelper'; import { eventsHelper } from '../../utils/eventsHelper';
import * as network from '../network'; import * as network from '../network';
import type { Page, PageBinding, PageDelegate } from '../page'; import type { InitScript, Page, PageBinding, PageDelegate } 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';
@ -315,7 +315,7 @@ export class WKBrowserContext extends BrowserContext {
await (page._delegate as WKPage).updateHttpCredentials(); await (page._delegate as WKPage).updateHttpCredentials();
} }
async doAddInitScript(source: string) { async doAddInitScript(initScript: InitScript) {
for (const page of this.pages()) for (const page of this.pages())
await (page._delegate as WKPage)._updateBootstrapScript(); await (page._delegate as WKPage)._updateBootstrapScript();
} }

View file

@ -182,4 +182,20 @@ export class RawTouchscreenImpl implements input.RawTouchscreen {
modifiers: toModifiersMask(modifiers), modifiers: toModifiersMask(modifiers),
}); });
} }
async touch(eventType: 'touchstart'|'touchmove'|'touchend'|'touchcancel', touchPoints: { x: number, y: number, id?: number }[], modifiers: Set<types.KeyboardModifier>) {
let type: 'touchStart' | 'touchMove' | 'touchEnd' | 'touchCancel';
switch (eventType) {
case 'touchstart': type = 'touchStart'; break;
case 'touchmove': type = 'touchMove'; break;
case 'touchend': type = 'touchEnd'; break;
case 'touchcancel': type = 'touchCancel'; break;
default: throw new Error('Invalid eventType: ' + eventType);
}
await this._pageProxySession.send('Input.dispatchTouchEvent', {
type,
touchPoints: touchPoints.map(p => ({ ...p, id: p.id || 0 })),
modifiers: toModifiersMask(modifiers)
});
}
} }

View file

@ -30,7 +30,7 @@ import { eventsHelper } from '../../utils/eventsHelper';
import { helper } from '../helper'; import { helper } from '../helper';
import type { JSHandle } from '../javascript'; import type { JSHandle } from '../javascript';
import * as network from '../network'; import * as network from '../network';
import type { PageBinding, PageDelegate } from '../page'; import type { InitScript, PageBinding, PageDelegate } from '../page';
import { Page } from '../page'; import { Page } from '../page';
import type { Progress } from '../progress'; import type { Progress } from '../progress';
import type * as types from '../types'; import type * as types from '../types';
@ -777,7 +777,7 @@ export class WKPage implements PageDelegate {
await this._updateBootstrapScript(); await this._updateBootstrapScript();
} }
async addInitScript(script: string): Promise<void> { async addInitScript(initScript: InitScript): Promise<void> {
await this._updateBootstrapScript(); await this._updateBootstrapScript();
} }
@ -797,8 +797,8 @@ export class WKPage implements PageDelegate {
for (const binding of this._page.allBindings()) for (const binding of this._page.allBindings())
scripts.push(binding.source); scripts.push(binding.source);
scripts.push(...this._browserContext.initScripts); scripts.push(...this._browserContext.initScripts.map(s => s.source));
scripts.push(...this._page.initScripts); scripts.push(...this._page.initScripts.map(s => s.source));
return scripts.join(';\n'); return scripts.join(';\n');
} }

View file

@ -45,8 +45,9 @@ class HttpsHappyEyeballsAgent extends https.Agent {
} }
} }
export const httpsHappyEyeballsAgent = new HttpsHappyEyeballsAgent(); // These options are aligned with the default Node.js globalAgent options.
export const httpHappyEyeballsAgent = new HttpHappyEyeballsAgent(); export const httpsHappyEyeballsAgent = new HttpsHappyEyeballsAgent({ keepAlive: true });
export const httpHappyEyeballsAgent = new HttpHappyEyeballsAgent({ keepAlive: true });
export async function createSocket(host: string, port: number): Promise<net.Socket> { export async function createSocket(host: string, port: number): Promise<net.Socket> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View file

@ -814,21 +814,6 @@ export interface Page {
* })(); * })();
* ``` * ```
* *
* An example of passing an element handle:
*
* ```js
* await page.exposeBinding('clicked', async (source, element) => {
* console.log(await element.textContent());
* }, { handle: true });
* await page.setContent(`
* <script>
* document.addEventListener('click', event => window.clicked(event.target));
* </script>
* <div>Click me</div>
* <div>Or click me</div>
* `);
* ```
*
* @param name Name of the function on the window object. * @param name Name of the function on the window object.
* @param callback Callback function that will be called in the Playwright's context. * @param callback Callback function that will be called in the Playwright's context.
* @param options * @param options
@ -875,21 +860,6 @@ export interface Page {
* })(); * })();
* ``` * ```
* *
* An example of passing an element handle:
*
* ```js
* await page.exposeBinding('clicked', async (source, element) => {
* console.log(await element.textContent());
* }, { handle: true });
* await page.setContent(`
* <script>
* document.addEventListener('click', event => window.clicked(event.target));
* </script>
* <div>Click me</div>
* <div>Or click me</div>
* `);
* ```
*
* @param name Name of the function on the window object. * @param name Name of the function on the window object.
* @param callback Callback function that will be called in the Playwright's context. * @param callback Callback function that will be called in the Playwright's context.
* @param options * @param options
@ -7637,21 +7607,6 @@ export interface BrowserContext {
* })(); * })();
* ``` * ```
* *
* An example of passing an element handle:
*
* ```js
* await context.exposeBinding('clicked', async (source, element) => {
* console.log(await element.textContent());
* }, { handle: true });
* await page.setContent(`
* <script>
* document.addEventListener('click', event => window.clicked(event.target));
* </script>
* <div>Click me</div>
* <div>Or click me</div>
* `);
* ```
*
* @param name Name of the function on the window object. * @param name Name of the function on the window object.
* @param callback Callback function that will be called in the Playwright's context. * @param callback Callback function that will be called in the Playwright's context.
* @param options * @param options
@ -7693,21 +7648,6 @@ export interface BrowserContext {
* })(); * })();
* ``` * ```
* *
* An example of passing an element handle:
*
* ```js
* await context.exposeBinding('clicked', async (source, element) => {
* console.log(await element.textContent());
* }, { handle: true });
* await page.setContent(`
* <script>
* document.addEventListener('click', event => window.clicked(event.target));
* </script>
* <div>Click me</div>
* <div>Or click me</div>
* `);
* ```
*
* @param name Name of the function on the window object. * @param name Name of the function on the window object.
* @param callback Callback function that will be called in the Playwright's context. * @param callback Callback function that will be called in the Playwright's context.
* @param options * @param options
@ -8346,9 +8286,7 @@ export interface BrowserContext {
* await browserContext.addCookies([cookieObject1, cookieObject2]); * await browserContext.addCookies([cookieObject1, cookieObject2]);
* ``` * ```
* *
* @param cookies Adds cookies to the browser context. * @param cookies
*
* For the cookie to apply to all subdomains as well, prefix domain with a dot, like this: ".example.com".
*/ */
addCookies(cookies: ReadonlyArray<{ addCookies(cookies: ReadonlyArray<{
name: string; name: string;
@ -8356,17 +8294,18 @@ export interface BrowserContext {
value: string; value: string;
/** /**
* either url or domain / path are required. Optional. * Either url or domain / path are required. Optional.
*/ */
url?: string; url?: string;
/** /**
* either url or domain / path are required Optional. * For the cookie to apply to all subdomains as well, prefix domain with a dot, like this: ".example.com". Either url
* or domain / path are required. Optional.
*/ */
domain?: string; domain?: string;
/** /**
* either url or domain / path are required Optional. * Either url or domain / path are required Optional.
*/ */
path?: string; path?: string;
@ -19743,6 +19682,29 @@ export interface Touchscreen {
* @param y * @param y
*/ */
tap(x: number, y: number): Promise<void>; tap(x: number, y: number): Promise<void>;
/**
* Synthesizes a touch event.
* @param type Type of the touch event.
* @param touchPoints List of touch points for this event. `id` is a unique identifier of a touch point that helps identify it between
* touch events for the duration of its movement around the surface.
*/
touch(type: "touchstart"|"touchend"|"touchmove"|"touchcancel", touchPoints: ReadonlyArray<{
/**
* x coordinate of the event in CSS pixels.
*/
x: number;
/**
* y coordinate of the event in CSS pixels.
*/
y: number;
/**
* Identifier used to track the touch point between events, must be unique within an event. Optional.
*/
id?: number;
}>): Promise<void>;
} }
/** /**

View file

@ -26,7 +26,8 @@
"./hooks": { "./hooks": {
"types": "./hooks.d.ts", "types": "./hooks.d.ts",
"default": "./hooks.mjs" "default": "./hooks.mjs"
} },
"./package.json": "./package.json"
}, },
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.46.0-next", "@playwright/experimental-ct-core": "1.46.0-next",

View file

@ -26,7 +26,8 @@
"./hooks": { "./hooks": {
"types": "./hooks.d.ts", "types": "./hooks.d.ts",
"default": "./hooks.mjs" "default": "./hooks.mjs"
} },
"./package.json": "./package.json"
}, },
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.46.0-next", "@playwright/experimental-ct-core": "1.46.0-next",

View file

@ -26,7 +26,8 @@
"./hooks": { "./hooks": {
"types": "./hooks.d.ts", "types": "./hooks.d.ts",
"default": "./hooks.mjs" "default": "./hooks.mjs"
} },
"./package.json": "./package.json"
}, },
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.46.0-next", "@playwright/experimental-ct-core": "1.46.0-next",

View file

@ -26,7 +26,8 @@
"./hooks": { "./hooks": {
"types": "./hooks.d.ts", "types": "./hooks.d.ts",
"default": "./hooks.mjs" "default": "./hooks.mjs"
} },
"./package.json": "./package.json"
}, },
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.46.0-next", "@playwright/experimental-ct-core": "1.46.0-next",

View file

@ -26,7 +26,8 @@
"./hooks": { "./hooks": {
"types": "./hooks.d.ts", "types": "./hooks.d.ts",
"default": "./hooks.mjs" "default": "./hooks.mjs"
} },
"./package.json": "./package.json"
}, },
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.46.0-next", "@playwright/experimental-ct-core": "1.46.0-next",

View file

@ -26,7 +26,8 @@
"./hooks": { "./hooks": {
"types": "./hooks.d.ts", "types": "./hooks.d.ts",
"default": "./hooks.mjs" "default": "./hooks.mjs"
} },
"./package.json": "./package.json"
}, },
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.46.0-next", "@playwright/experimental-ct-core": "1.46.0-next",

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { formatLocation } from '../util'; import { filterStackFile, formatLocation } from '../util';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import type { Fixtures } from '../../types/test'; import type { Fixtures } from '../../types/test';
import type { Location } from '../../types/testReporter'; import type { Location } from '../../types/testReporter';
@ -23,7 +23,7 @@ import type { FixturesWithLocation } from './config';
export type FixtureScope = 'test' | 'worker'; export type FixtureScope = 'test' | 'worker';
type FixtureAuto = boolean | 'all-hooks-included'; type FixtureAuto = boolean | 'all-hooks-included';
const kScopeOrder: FixtureScope[] = ['test', 'worker']; const kScopeOrder: FixtureScope[] = ['test', 'worker'];
type FixtureOptions = { auto?: FixtureAuto, scope?: FixtureScope, option?: boolean, timeout?: number | undefined }; type FixtureOptions = { auto?: FixtureAuto, scope?: FixtureScope, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean };
type FixtureTuple = [ value: any, options: FixtureOptions ]; type FixtureTuple = [ value: any, options: FixtureOptions ];
export type FixtureRegistration = { export type FixtureRegistration = {
// Fixture registration location. // Fixture registration location.
@ -49,8 +49,8 @@ export type FixtureRegistration = {
super?: FixtureRegistration; super?: FixtureRegistration;
// Whether this fixture is an option override value set from the config. // Whether this fixture is an option override value set from the config.
optionOverride?: boolean; optionOverride?: boolean;
// Do not generate the step for this fixture. // Do not generate the step for this fixture, consider it internal.
hideStep?: boolean; box?: boolean;
}; };
export type LoadError = { export type LoadError = {
message: string; message: string;
@ -63,7 +63,7 @@ type OptionOverrides = {
}; };
function isFixtureTuple(value: any): value is FixtureTuple { function isFixtureTuple(value: any): value is FixtureTuple {
return Array.isArray(value) && typeof value[1] === 'object' && ('scope' in value[1] || 'auto' in value[1] || 'option' in value[1] || 'timeout' in value[1]); return Array.isArray(value) && typeof value[1] === 'object';
} }
function isFixtureOption(value: any): value is FixtureTuple { function isFixtureOption(value: any): value is FixtureTuple {
@ -103,15 +103,15 @@ export class FixturePool {
for (const entry of Object.entries(fixtures)) { for (const entry of Object.entries(fixtures)) {
const name = entry[0]; const name = entry[0];
let value = entry[1]; let value = entry[1];
let options: { auto: FixtureAuto, scope: FixtureScope, option: boolean, timeout: number | undefined, customTitle: string | undefined, hideStep: boolean | undefined } | undefined; let options: { auto: FixtureAuto, scope: FixtureScope, option: boolean, timeout: number | undefined, customTitle?: string, box?: boolean } | undefined;
if (isFixtureTuple(value)) { if (isFixtureTuple(value)) {
options = { options = {
auto: value[1].auto ?? false, auto: value[1].auto ?? false,
scope: value[1].scope || 'test', scope: value[1].scope || 'test',
option: !!value[1].option, option: !!value[1].option,
timeout: value[1].timeout, timeout: value[1].timeout,
customTitle: (value[1] as any)._title, customTitle: value[1].title,
hideStep: (value[1] as any)._hideStep, box: value[1].box,
}; };
value = value[0]; value = value[0];
} }
@ -128,9 +128,9 @@ export class FixturePool {
continue; continue;
} }
} else if (previous) { } else if (previous) {
options = { auto: previous.auto, scope: previous.scope, option: previous.option, timeout: previous.timeout, customTitle: previous.customTitle, hideStep: undefined }; options = { auto: previous.auto, scope: previous.scope, option: previous.option, timeout: previous.timeout, customTitle: previous.customTitle, box: previous.box };
} else if (!options) { } else if (!options) {
options = { auto: false, scope: 'test', option: false, timeout: undefined, customTitle: undefined, hideStep: undefined }; options = { auto: false, scope: 'test', option: false, timeout: undefined };
} }
if (!kScopeOrder.includes(options.scope)) { if (!kScopeOrder.includes(options.scope)) {
@ -152,7 +152,7 @@ export class FixturePool {
} }
const deps = fixtureParameterNames(fn, location, e => this._onLoadError(e)); const deps = fixtureParameterNames(fn, location, e => this._onLoadError(e));
const registration: FixtureRegistration = { id: '', name, location, scope: options.scope, fn, auto: options.auto, option: options.option, timeout: options.timeout, customTitle: options.customTitle, hideStep: options.hideStep, deps, super: previous, optionOverride: isOptionsOverride }; const registration: FixtureRegistration = { id: '', name, location, scope: options.scope, fn, auto: options.auto, option: options.option, timeout: options.timeout, customTitle: options.customTitle, box: options.box, deps, super: previous, optionOverride: isOptionsOverride };
registrationId(registration); registrationId(registration);
this._registrations.set(name, registration); this._registrations.set(name, registration);
} }
@ -161,29 +161,36 @@ export class FixturePool {
private validate() { private validate() {
const markers = new Map<FixtureRegistration, 'visiting' | 'visited'>(); const markers = new Map<FixtureRegistration, 'visiting' | 'visited'>();
const stack: FixtureRegistration[] = []; const stack: FixtureRegistration[] = [];
const visit = (registration: FixtureRegistration) => { let hasDependencyErrors = false;
const addDependencyError = (message: string, location: Location) => {
hasDependencyErrors = true;
this._addLoadError(message, location);
};
const visit = (registration: FixtureRegistration, boxedOnly: boolean) => {
markers.set(registration, 'visiting'); markers.set(registration, 'visiting');
stack.push(registration); stack.push(registration);
for (const name of registration.deps) { for (const name of registration.deps) {
const dep = this.resolve(name, registration); const dep = this.resolve(name, registration);
if (!dep) { if (!dep) {
if (name === registration.name) if (name === registration.name)
this._addLoadError(`Fixture "${registration.name}" references itself, but does not have a base implementation.`, registration.location); addDependencyError(`Fixture "${registration.name}" references itself, but does not have a base implementation.`, registration.location);
else else
this._addLoadError(`Fixture "${registration.name}" has unknown parameter "${name}".`, registration.location); addDependencyError(`Fixture "${registration.name}" has unknown parameter "${name}".`, registration.location);
continue; continue;
} }
if (kScopeOrder.indexOf(registration.scope) > kScopeOrder.indexOf(dep.scope)) { if (kScopeOrder.indexOf(registration.scope) > kScopeOrder.indexOf(dep.scope)) {
this._addLoadError(`${registration.scope} fixture "${registration.name}" cannot depend on a ${dep.scope} fixture "${name}" defined in ${formatLocation(dep.location)}.`, registration.location); addDependencyError(`${registration.scope} fixture "${registration.name}" cannot depend on a ${dep.scope} fixture "${name}" defined in ${formatPotentiallyInternalLocation(dep.location)}.`, registration.location);
continue; continue;
} }
if (!markers.has(dep)) { if (!markers.has(dep)) {
visit(dep); visit(dep, boxedOnly);
} else if (markers.get(dep) === 'visiting') { } else if (markers.get(dep) === 'visiting') {
const index = stack.indexOf(dep); const index = stack.indexOf(dep);
const regs = stack.slice(index, stack.length); const allRegs = stack.slice(index, stack.length);
const filteredRegs = allRegs.filter(r => !r.box);
const regs = boxedOnly ? filteredRegs : allRegs;
const names = regs.map(r => `"${r.name}"`); const names = regs.map(r => `"${r.name}"`);
this._addLoadError(`Fixtures ${names.join(' -> ')} -> "${dep.name}" form a dependency cycle: ${regs.map(r => formatLocation(r.location)).join(' -> ')}`, dep.location); addDependencyError(`Fixtures ${names.join(' -> ')} -> "${dep.name}" form a dependency cycle: ${regs.map(r => formatPotentiallyInternalLocation(r.location)).join(' -> ')} -> ${formatPotentiallyInternalLocation(dep.location)}`, dep.location);
continue; continue;
} }
} }
@ -191,11 +198,27 @@ export class FixturePool {
stack.pop(); stack.pop();
}; };
const hash = crypto.createHash('sha1');
const names = Array.from(this._registrations.keys()).sort(); const names = Array.from(this._registrations.keys()).sort();
// First iterate over non-boxed fixtures to provide clear error messages.
for (const name of names) {
const registration = this._registrations.get(name)!;
if (!registration.box)
visit(registration, true);
}
// If no errors found, iterate over boxed fixtures
if (!hasDependencyErrors) {
for (const name of names) {
const registration = this._registrations.get(name)!;
if (registration.box)
visit(registration, false);
}
}
const hash = crypto.createHash('sha1');
for (const name of names) { for (const name of names) {
const registration = this._registrations.get(name)!; const registration = this._registrations.get(name)!;
visit(registration);
if (registration.scope === 'worker') if (registration.scope === 'worker')
hash.update(registration.id + ';'); hash.update(registration.id + ';');
} }
@ -227,6 +250,11 @@ export class FixturePool {
const signatureSymbol = Symbol('signature'); const signatureSymbol = Symbol('signature');
export function formatPotentiallyInternalLocation(location: Location): string {
const isUserFixture = location && filterStackFile(location.file);
return isUserFixture ? formatLocation(location) : '<builtin>';
}
export function fixtureParameterNames(fn: Function | any, location: Location, onError: LoadErrorSink): string[] { export function fixtureParameterNames(fn: Function | any, location: Location, onError: LoadErrorSink): string[] {
if (typeof fn !== 'function') if (typeof fn !== 'function')
return []; return [];

View file

@ -59,13 +59,13 @@ type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & {
const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
defaultBrowserType: ['chromium', { scope: 'worker', option: true }], defaultBrowserType: ['chromium', { scope: 'worker', option: true }],
browserName: [({ defaultBrowserType }, use) => use(defaultBrowserType), { scope: 'worker', option: true }], browserName: [({ defaultBrowserType }, use) => use(defaultBrowserType), { scope: 'worker', option: true }],
_playwrightImpl: [({}, use) => use(require('playwright-core')), { scope: 'worker' }], _playwrightImpl: [({}, use) => use(require('playwright-core')), { scope: 'worker', box: true }],
playwright: [async ({ _playwrightImpl, screenshot }, use) => { playwright: [async ({ _playwrightImpl, screenshot }, use) => {
await connector.setPlaywright(_playwrightImpl, screenshot); await connector.setPlaywright(_playwrightImpl, screenshot);
await use(_playwrightImpl); await use(_playwrightImpl);
await connector.setPlaywright(undefined, screenshot); await connector.setPlaywright(undefined, screenshot);
}, { scope: 'worker', _hideStep: true } as any], }, { scope: 'worker', box: true }],
headless: [({ launchOptions }, use) => use(launchOptions.headless ?? true), { scope: 'worker', option: true }], headless: [({ launchOptions }, use) => use(launchOptions.headless ?? true), { scope: 'worker', option: true }],
channel: [({ launchOptions }, use) => use(launchOptions.channel), { scope: 'worker', option: true }], channel: [({ launchOptions }, use) => use(launchOptions.channel), { scope: 'worker', option: true }],
@ -93,7 +93,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
await use(options); await use(options);
for (const browserType of [playwright.chromium, playwright.firefox, playwright.webkit]) for (const browserType of [playwright.chromium, playwright.firefox, playwright.webkit])
(browserType as any)._defaultLaunchOptions = undefined; (browserType as any)._defaultLaunchOptions = undefined;
}, { scope: 'worker', auto: true }], }, { scope: 'worker', auto: true, box: true }],
browser: [async ({ playwright, browserName, _browserOptions, connectOptions, _reuseContext }, use, testInfo) => { browser: [async ({ playwright, browserName, _browserOptions, connectOptions, _reuseContext }, use, testInfo) => {
if (!['chromium', 'firefox', 'webkit'].includes(browserName)) if (!['chromium', 'firefox', 'webkit'].includes(browserName))
@ -152,7 +152,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
serviceWorkers: [({ contextOptions }, use) => use(contextOptions.serviceWorkers ?? 'allow'), { option: true }], serviceWorkers: [({ contextOptions }, use) => use(contextOptions.serviceWorkers ?? 'allow'), { option: true }],
contextOptions: [{}, { option: true }], contextOptions: [{}, { option: true }],
_combinedContextOptions: async ({ _combinedContextOptions: [async ({
acceptDownloads, acceptDownloads,
bypassCSP, bypassCSP,
colorScheme, colorScheme,
@ -223,7 +223,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
...contextOptions, ...contextOptions,
...options, ...options,
}); });
}, }, { box: true }],
_setupContextOptions: [async ({ playwright, _combinedContextOptions, actionTimeout, navigationTimeout, testIdAttribute }, use, testInfo) => { _setupContextOptions: [async ({ playwright, _combinedContextOptions, actionTimeout, navigationTimeout, testIdAttribute }, use, testInfo) => {
if (testIdAttribute) if (testIdAttribute)
@ -246,9 +246,9 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
(browserType as any)._defaultContextTimeout = undefined; (browserType as any)._defaultContextTimeout = undefined;
(browserType as any)._defaultContextNavigationTimeout = undefined; (browserType as any)._defaultContextNavigationTimeout = undefined;
} }
}, { auto: 'all-hooks-included', _title: 'context configuration' } as any], }, { auto: 'all-hooks-included', title: 'context configuration', box: true } as any],
_contextFactory: [async ({ browser, video, _reuseContext }, use, testInfo) => { _contextFactory: [async ({ browser, video, _reuseContext, _combinedContextOptions /** mitigate dep-via-auto lack of traceability */ }, use, testInfo) => {
const testInfoImpl = testInfo as TestInfoImpl; const testInfoImpl = testInfo as TestInfoImpl;
const videoMode = normalizeVideoMode(video); const videoMode = normalizeVideoMode(video);
const captureVideo = shouldCaptureVideo(videoMode, testInfo) && !_reuseContext; const captureVideo = shouldCaptureVideo(videoMode, testInfo) && !_reuseContext;
@ -274,6 +274,18 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
contexts.set(context, contextData); contexts.set(context, contextData);
if (captureVideo) if (captureVideo)
context.on('page', page => contextData.pagesWithVideo.push(page)); context.on('page', page => contextData.pagesWithVideo.push(page));
if (process.env.PW_CLOCK === 'frozen') {
await (context as any)._wrapApiCall(async () => {
await context.clock.install({ time: 0 });
await context.clock.pauseAt(1000);
}, true);
} else if (process.env.PW_CLOCK === 'realtime') {
await (context as any)._wrapApiCall(async () => {
await context.clock.install({ time: 0 });
}, true);
}
return context; return context;
}); });
@ -301,7 +313,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
} }
})); }));
}, { scope: 'test', _title: 'context' } as any], }, { scope: 'test', title: 'context', box: true }],
_optionContextReuseMode: ['none', { scope: 'worker', option: true }], _optionContextReuseMode: ['none', { scope: 'worker', option: true }],
_optionConnectOptions: [undefined, { scope: 'worker', option: true }], _optionConnectOptions: [undefined, { scope: 'worker', option: true }],
@ -312,7 +324,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
mode = 'when-possible'; mode = 'when-possible';
const reuse = mode === 'when-possible' && normalizeVideoMode(video) === 'off'; const reuse = mode === 'when-possible' && normalizeVideoMode(video) === 'off';
await use(reuse); await use(reuse);
}, { scope: 'worker', _title: 'context' } as any], }, { scope: 'worker', title: 'context', box: true }],
context: async ({ playwright, browser, _reuseContext, _contextFactory }, use, testInfo) => { context: async ({ playwright, browser, _reuseContext, _contextFactory }, use, testInfo) => {
attachConnectedHeaderIfNeeded(testInfo, browser); attachConnectedHeaderIfNeeded(testInfo, browser);
@ -597,7 +609,7 @@ class ArtifactsRecorder {
if ((tracing as any)[this._startedCollectingArtifacts]) if ((tracing as any)[this._startedCollectingArtifacts])
return; return;
(tracing as any)[this._startedCollectingArtifacts] = true; (tracing as any)[this._startedCollectingArtifacts] = true;
if (this._testInfo._tracing.traceOptions()) if (this._testInfo._tracing.traceOptions() && (tracing as any)[kTracingStarted])
await tracing.stopChunk({ path: this._testInfo._tracing.generateNextTraceRecordingPath() }); await tracing.stopChunk({ path: this._testInfo._tracing.generateNextTraceRecordingPath() });
} }
} }

View file

@ -40,10 +40,10 @@ class Fixture {
this.runner = runner; this.runner = runner;
this.registration = registration; this.registration = registration;
this.value = null; this.value = null;
const shouldGenerateStep = !this.registration.hideStep && !this.registration.name.startsWith('_') && !this.registration.option; const shouldGenerateStep = !this.registration.box && !this.registration.option;
const isInternalFixture = this.registration.location && filterStackFile(this.registration.location.file); const isUserFixture = this.registration.location && filterStackFile(this.registration.location.file);
const title = this.registration.customTitle || this.registration.name; const title = this.registration.customTitle || this.registration.name;
const location = isInternalFixture ? this.registration.location : undefined; const location = isUserFixture ? this.registration.location : undefined;
this._stepInfo = shouldGenerateStep ? { category: 'fixture', location } : undefined; this._stepInfo = shouldGenerateStep ? { category: 'fixture', location } : undefined;
this._setupDescription = { this._setupDescription = {
title, title,

View file

@ -261,7 +261,7 @@ export class WorkerMain extends ProcessRunner {
testInfo.expectedStatus = 'failed'; testInfo.expectedStatus = 'failed';
break; break;
case 'slow': case 'slow':
testInfo.slow(); testInfo._timeoutManager.slow();
break; break;
} }
}; };
@ -570,6 +570,9 @@ export class WorkerMain extends ProcessRunner {
if (error instanceof TimeoutManagerError) if (error instanceof TimeoutManagerError)
throw error; throw error;
firstError = firstError ?? error; firstError = firstError ?? error;
// Skip in modifier prevents others from running.
if (error instanceof SkipError)
break;
} }
} }
if (firstError) if (firstError)

View file

@ -4824,13 +4824,13 @@ export type WorkerFixture<R, Args extends KeyValue> = (args: Args, use: (r: R) =
type TestFixtureValue<R, Args extends KeyValue> = Exclude<R, Function> | TestFixture<R, Args>; type TestFixtureValue<R, Args extends KeyValue> = Exclude<R, Function> | TestFixture<R, Args>;
type WorkerFixtureValue<R, Args extends KeyValue> = Exclude<R, Function> | WorkerFixture<R, Args>; type WorkerFixtureValue<R, Args extends KeyValue> = Exclude<R, Function> | WorkerFixture<R, Args>;
export type Fixtures<T extends KeyValue = {}, W extends KeyValue = {}, PT extends KeyValue = {}, PW extends KeyValue = {}> = { 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 }]; [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 }]; [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 }]; [K in keyof W]?: [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 }]; [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 }];
}; };
type BrowserName = 'chromium' | 'firefox' | 'webkit'; type BrowserName = 'chromium' | 'firefox' | 'webkit';

View file

@ -1890,6 +1890,7 @@ export interface PageChannel extends PageEventTarget, EventTargetChannel {
mouseClick(params: PageMouseClickParams, metadata?: CallMetadata): Promise<PageMouseClickResult>; mouseClick(params: PageMouseClickParams, metadata?: CallMetadata): Promise<PageMouseClickResult>;
mouseWheel(params: PageMouseWheelParams, metadata?: CallMetadata): Promise<PageMouseWheelResult>; mouseWheel(params: PageMouseWheelParams, metadata?: CallMetadata): Promise<PageMouseWheelResult>;
touchscreenTap(params: PageTouchscreenTapParams, metadata?: CallMetadata): Promise<PageTouchscreenTapResult>; touchscreenTap(params: PageTouchscreenTapParams, metadata?: CallMetadata): Promise<PageTouchscreenTapResult>;
touchscreenTouch(params: PageTouchscreenTouchParams, metadata?: CallMetadata): Promise<PageTouchscreenTouchResult>;
accessibilitySnapshot(params: PageAccessibilitySnapshotParams, metadata?: CallMetadata): Promise<PageAccessibilitySnapshotResult>; accessibilitySnapshot(params: PageAccessibilitySnapshotParams, metadata?: CallMetadata): Promise<PageAccessibilitySnapshotResult>;
pdf(params: PagePdfParams, metadata?: CallMetadata): Promise<PagePdfResult>; pdf(params: PagePdfParams, metadata?: CallMetadata): Promise<PagePdfResult>;
startJSCoverage(params: PageStartJSCoverageParams, metadata?: CallMetadata): Promise<PageStartJSCoverageResult>; startJSCoverage(params: PageStartJSCoverageParams, metadata?: CallMetadata): Promise<PageStartJSCoverageResult>;
@ -2257,6 +2258,18 @@ export type PageTouchscreenTapOptions = {
}; };
export type PageTouchscreenTapResult = void; export type PageTouchscreenTapResult = void;
export type PageTouchscreenTouchParams = {
type: 'touchstart' | 'touchend' | 'touchmove' | 'touchcancel',
touchPoints: {
x: number,
y: number,
id?: number,
}[],
};
export type PageTouchscreenTouchOptions = {
};
export type PageTouchscreenTouchResult = void;
export type PageAccessibilitySnapshotParams = { export type PageAccessibilitySnapshotParams = {
interestingOnly?: boolean, interestingOnly?: boolean,
root?: ElementHandleChannel, root?: ElementHandleChannel,

View file

@ -1603,6 +1603,27 @@ Page:
slowMo: true slowMo: true
snapshot: true snapshot: true
touchscreenTouch:
parameters:
type:
type: enum
literals:
- touchstart
- touchend
- touchmove
- touchcancel
touchPoints:
type: array
items:
type: object
properties:
x: number
y: number
id: number?
flags:
slowMo: true
snapshot: true
accessibilitySnapshot: accessibilitySnapshot:
parameters: parameters:
interestingOnly: boolean? interestingOnly: boolean?

View file

@ -0,0 +1,27 @@
<!--
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Playwright Trace Viewer for VS Code</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/embedded.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,61 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@web/common.css';
import { applyTheme } from '@web/theme';
import '@web/third_party/vscode/codicon.css';
import React from 'react';
import * as ReactDOM from 'react-dom';
import { EmbeddedWorkbenchLoader } from './ui/embeddedWorkbenchLoader';
(async () => {
applyTheme();
// workaround to send keystrokes back to vscode webview to keep triggering key bindings there
const handleKeyEvent = (e: KeyboardEvent) => {
if (!e.isTrusted)
return;
window.parent?.postMessage({
type: e.type,
key: e.key,
keyCode: e.keyCode,
code: e.code,
shiftKey: e.shiftKey,
altKey: e.altKey,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
repeat: e.repeat,
}, '*');
};
window.addEventListener('keydown', handleKeyEvent);
window.addEventListener('keyup', handleKeyEvent);
if (window.location.protocol !== 'file:') {
if (!navigator.serviceWorker)
throw new Error(`Service workers are not supported.\nMake sure to serve the Trace Viewer (${window.location}) via HTTPS or localhost.`);
navigator.serviceWorker.register('sw.bundle.js');
if (!navigator.serviceWorker.controller) {
await new Promise<void>(f => {
navigator.serviceWorker.oncontrollerchange = () => f();
});
}
// Keep SW running.
setInterval(function() { fetch('ping'); }, 10000);
}
ReactDOM.render(<EmbeddedWorkbenchLoader />, document.querySelector('#root'));
})();

View file

@ -0,0 +1,68 @@
/*
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.empty-state {
display: flex;
align-items: center;
justify-content: center;
flex: auto;
flex-direction: column;
background-color: var(--vscode-editor-background);
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 100;
line-height: 24px;
}
body .empty-state {
background: rgba(255, 255, 255, 0.8);
}
body.dark-mode .empty-state {
background: rgba(0, 0, 0, 0.8);
}
.empty-state .title {
font-size: 24px;
font-weight: bold;
margin-bottom: 30px;
}
.progress {
flex: none;
width: 100%;
height: 3px;
z-index: 10;
}
.inner-progress {
background-color: var(--vscode-progressBar-background);
height: 100%;
}
.workbench-loader {
contain: size;
}
/* Limit to a reasonable minimum viewport */
html, body {
min-width: 550px;
min-height: 450px;
overflow: auto;
}

View file

@ -0,0 +1,96 @@
/*
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from 'react';
import type { ContextEntry } from '../entries';
import { MultiTraceModel } from './modelUtil';
import './embeddedWorkbenchLoader.css';
import { Workbench } from './workbench';
import { currentTheme, toggleTheme } from '@web/theme';
function openPage(url: string, target?: string) {
if (url)
window.parent!.postMessage({ command: 'openExternal', params: { url, target } }, '*');
}
export const EmbeddedWorkbenchLoader: React.FunctionComponent = () => {
const [traceURLs, setTraceURLs] = React.useState<string[]>([]);
const [model, setModel] = React.useState<MultiTraceModel>(emptyModel);
const [progress, setProgress] = React.useState<{ done: number, total: number }>({ done: 0, total: 0 });
const [processingErrorMessage, setProcessingErrorMessage] = React.useState<string | null>(null);
React.useEffect(() => {
window.addEventListener('message', async ({ data: { method, params } }) => {
if (method === 'loadTraceRequested') {
setTraceURLs(params.traceUrl ? [params.traceUrl] : []);
setProcessingErrorMessage(null);
} else if (method === 'applyTheme') {
if (currentTheme() !== params.theme)
toggleTheme();
}
});
// notify vscode that it is now listening to its messages
window.parent!.postMessage({ type: 'loaded' }, '*');
}, []);
React.useEffect(() => {
(async () => {
if (traceURLs.length) {
const swListener = (event: any) => {
if (event.data.method === 'progress')
setProgress(event.data.params);
};
navigator.serviceWorker.addEventListener('message', swListener);
setProgress({ done: 0, total: 1 });
const contextEntries: ContextEntry[] = [];
for (let i = 0; i < traceURLs.length; i++) {
const url = traceURLs[i];
const params = new URLSearchParams();
params.set('trace', url);
const response = await fetch(`contexts?${params.toString()}`);
if (!response.ok) {
setProcessingErrorMessage((await response.json()).error);
return;
}
contextEntries.push(...(await response.json()));
}
navigator.serviceWorker.removeEventListener('message', swListener);
const model = new MultiTraceModel(contextEntries);
setProgress({ done: 0, total: 0 });
setModel(model);
} else {
setModel(emptyModel);
}
})();
}, [traceURLs]);
React.useEffect(() => {
if (processingErrorMessage)
window.parent?.postMessage({ method: 'showErrorMessage', params: { message: processingErrorMessage } }, '*');
}, [processingErrorMessage]);
return <div className='vbox workbench-loader'>
<div className='progress'>
<div className='inner-progress' style={{ width: progress.total ? (100 * progress.done / progress.total) + '%' : 0 }}></div>
</div>
<Workbench model={model} openPage={openPage} />
{!traceURLs.length && <div className='empty-state'>
<div className='title'>Select test to see the trace</div>
</div>}
</div>;
};
export const emptyModel = new MultiTraceModel([]);

View file

@ -38,7 +38,8 @@ export const SnapshotTab: React.FunctionComponent<{
setIsInspecting: (isInspecting: boolean) => void, setIsInspecting: (isInspecting: boolean) => void,
highlightedLocator: string, highlightedLocator: string,
setHighlightedLocator: (locator: string) => void, setHighlightedLocator: (locator: string) => void,
}> = ({ action, sdkLanguage, testIdAttributeName, isInspecting, setIsInspecting, highlightedLocator, setHighlightedLocator }) => { openPage?: (url: string, target?: string) => Window | any,
}> = ({ action, sdkLanguage, testIdAttributeName, isInspecting, setIsInspecting, highlightedLocator, setHighlightedLocator, openPage }) => {
const [measure, ref] = useMeasure<HTMLDivElement>(); const [measure, ref] = useMeasure<HTMLDivElement>();
const [snapshotTab, setSnapshotTab] = React.useState<'action'|'before'|'after'>('action'); const [snapshotTab, setSnapshotTab] = React.useState<'action'|'before'|'after'>('action');
@ -190,7 +191,9 @@ export const SnapshotTab: React.FunctionComponent<{
})} })}
<div style={{ flex: 'auto' }}></div> <div style={{ flex: 'auto' }}></div>
<ToolbarButton icon='link-external' title='Open snapshot in a new tab' disabled={!popoutUrl} onClick={() => { <ToolbarButton icon='link-external' title='Open snapshot in a new tab' disabled={!popoutUrl} onClick={() => {
const win = window.open(popoutUrl || '', '_blank'); if (!openPage)
openPage = window.open;
const win = openPage(popoutUrl || '', '_blank');
win?.addEventListener('DOMContentLoaded', () => { win?.addEventListener('DOMContentLoaded', () => {
const injectedScript = new InjectedScript(win as any, false, sdkLanguage, testIdAttributeName, 1, 'chromium', []); const injectedScript = new InjectedScript(win as any, false, sdkLanguage, testIdAttributeName, 1, 'chromium', []);
new ConsoleAPI(injectedScript); new ConsoleAPI(injectedScript);

View file

@ -51,7 +51,8 @@ export const Workbench: React.FunctionComponent<{
isLive?: boolean, isLive?: boolean,
status?: UITestStatus, status?: UITestStatus,
inert?: boolean, inert?: boolean,
}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, status, inert }) => { openPage?: (url: string, target?: string) => Window | any,
}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, status, inert, openPage }) => {
const [selectedAction, setSelectedActionImpl] = React.useState<ActionTraceEventInContext | undefined>(undefined); const [selectedAction, setSelectedActionImpl] = React.useState<ActionTraceEventInContext | undefined>(undefined);
const [revealedStack, setRevealedStack] = React.useState<StackFrame[] | undefined>(undefined); const [revealedStack, setRevealedStack] = React.useState<StackFrame[] | undefined>(undefined);
const [highlightedAction, setHighlightedAction] = React.useState<ActionTraceEventInContext | undefined>(); const [highlightedAction, setHighlightedAction] = React.useState<ActionTraceEventInContext | undefined>();
@ -234,7 +235,8 @@ export const Workbench: React.FunctionComponent<{
isInspecting={isInspecting} isInspecting={isInspecting}
setIsInspecting={setIsInspecting} setIsInspecting={setIsInspecting}
highlightedLocator={highlightedLocator} highlightedLocator={highlightedLocator}
setHighlightedLocator={locatorPicked} /> setHighlightedLocator={locatorPicked}
openPage={openPage} />
<TabbedPane <TabbedPane
tabs={[ tabs={[
{ {

View file

@ -45,6 +45,7 @@ export default defineConfig({
input: { input: {
index: path.resolve(__dirname, 'index.html'), index: path.resolve(__dirname, 'index.html'),
uiMode: path.resolve(__dirname, 'uiMode.html'), uiMode: path.resolve(__dirname, 'uiMode.html'),
embedded: path.resolve(__dirname, 'embedded.html'),
snapshot: path.resolve(__dirname, 'snapshot.html'), snapshot: path.resolve(__dirname, 'snapshot.html'),
}, },
output: { output: {

View file

@ -15,6 +15,7 @@
*/ */
import { test } from './npmTest'; import { test } from './npmTest';
import fs from 'fs'; import fs from 'fs';
import { expect } from 'packages/playwright-test';
import path from 'path'; import path from 'path';
test('electron should work', async ({ exec, tsc, writeFiles }) => { test('electron should work', async ({ exec, tsc, writeFiles }) => {
@ -39,3 +40,46 @@ test('electron should work with special characters in path', async ({ exec, tmpW
cwd: path.join(folderName) cwd: path.join(folderName)
}); });
}); });
test('should work when wrapped inside @playwright/test and trace is enabled', async ({ exec, tmpWorkspace, writeFiles }) => {
await exec('npm i -D @playwright/test electron@31');
await writeFiles({
'electron-with-tracing.spec.ts': `
import { test, expect, _electron } from '@playwright/test';
test('should work', async ({ trace }) => {
const electronApp = await _electron.launch({ args: [${JSON.stringify(path.join(__dirname, '../electron/electron-window-app.js'))}] });
const window = await electronApp.firstWindow();
if (trace)
await window.context().tracing.start({ screenshots: true, snapshots: true });
await window.goto('data:text/html,<title>Playwright</title><h1>Playwright</h1>');
await expect(window).toHaveTitle(/Playwright/);
await expect(window.getByRole('heading')).toHaveText('Playwright');
const path = test.info().outputPath('electron-trace.zip');
if (trace) {
await window.context().tracing.stop({ path });
test.info().attachments.push({ name: 'trace', path, contentType: 'application/zip' });
}
await electronApp.close();
});
`,
});
const jsonOutputName = test.info().outputPath('report.json');
await exec('npx playwright test --trace=on --reporter=json electron-with-tracing.spec.ts', {
env: { PLAYWRIGHT_JSON_OUTPUT_NAME: jsonOutputName }
});
const traces = [
// our actual trace.
path.join(tmpWorkspace, 'test-results', 'electron-with-tracing-should-work', 'electron-trace.zip'),
// contains the expect() calls
path.join(tmpWorkspace, 'test-results', 'electron-with-tracing-should-work', 'trace.zip'),
];
for (const trace of traces)
expect(fs.existsSync(trace)).toBe(true);
const report = JSON.parse(fs.readFileSync(jsonOutputName, 'utf-8'));
expect(new Set(['trace'])).toEqual(new Set(report.suites[0].specs[0].tests[0].results[0].attachments.map(a => a.name)));
expect(new Set(traces.map(p => fs.realpathSync(p)))).toEqual(new Set(report.suites[0].specs[0].tests[0].results[0].attachments.map(a => a.path)));
});

View file

@ -853,7 +853,7 @@ it('should not hang on a brotli encoded Range request', async ({ context, server
headers: { headers: {
range: 'bytes=0-2', range: 'bytes=0-2',
}, },
})).rejects.toThrow(/(failed to decompress 'br' encoding: Error: unexpected end of file|Parse Error: Data after \`Connection: close\`)/); })).rejects.toThrow(/Parse Error: Expected HTTP/);
}); });
it('should dispose', async function({ context, server }) { it('should dispose', async function({ context, server }) {

View file

@ -139,15 +139,14 @@ it('should not crash on showDirectoryPicker', async ({ page, server, browserName
it.skip(browserName === 'chromium' && browserMajorVersion < 99, 'Fixed in Chromium r956769'); it.skip(browserName === 'chromium' && browserMajorVersion < 99, 'Fixed in Chromium r956769');
it.skip(browserName !== 'chromium', 'showDirectoryPicker is only available in Chromium'); it.skip(browserName !== 'chromium', 'showDirectoryPicker is only available in Chromium');
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
await Promise.race([ page.evaluate(async () => {
page.evaluate(async () => { const dir = await (window as any).showDirectoryPicker();
const dir = await (window as any).showDirectoryPicker(); return dir.name;
return dir.name; // In headless it throws (aborted), in headed it stalls (Test ended) and waits for the picker to be accepted.
}).catch(e => expect(e.message).toContain('DOMException: The user aborted a request')), }).catch(e => expect(e.message).toMatch(/((DOMException|AbortError): The user aborted a request|Test ended)/));
// The dialog will not be accepted, so we just wait for some time to // The dialog will not be accepted, so we just wait for some time to
// to give the browser a chance to crash. // to give the browser a chance to crash.
new Promise(r => setTimeout(r, 1000)) await page.waitForTimeout(3_000);
]);
}); });
it('should not crash on storage.getDirectory()', async ({ page, server, browserName, isMac }) => { it('should not crash on storage.getDirectory()', async ({ page, server, browserName, isMac }) => {

View file

@ -0,0 +1,68 @@
/**
* Copyright 2018 Google Inc. All rights reserved.
* Modifications copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { contextTest as it, expect } from '../../config/browserTest';
it.use({
launchOptions: async ({ launchOptions }, use) => {
await use({ ...launchOptions, args: ['--disable-web-security'] });
}
});
it('test utility world in popup w/ --disable-web-security', async ({ page, server }) => {
server.setRoute('/main.html', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/html'
});
res.end(`<a href="${server.PREFIX}/target.html" target="_blank">Click me</a>`);
});
server.setRoute('/target.html', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/html'
});
res.end(`<html></html>`);
});
await page.goto(server.PREFIX + '/main.html');
const page1Promise = page.context().waitForEvent('page');
await page.getByRole('link', { name: 'Click me' }).click();
const page1 = await page1Promise;
await expect(page1).toHaveURL(/target/);
});
it('test init script w/ --disable-web-security', async ({ page, server }) => {
server.setRoute('/main.html', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/html'
});
res.end(`<a href="${server.PREFIX}/target.html" target="_blank">Click me</a>`);
});
server.setRoute('/target.html', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/html'
});
res.end(`<html></html>`);
});
await page.context().addInitScript('window.injected = 123');
await page.goto(server.PREFIX + '/main.html');
const page1Promise = page.context().waitForEvent('page');
await page.getByRole('link', { name: 'Click me' }).click();
const page1 = await page1Promise;
const value = await page1.evaluate('window.injected');
expect(value).toBe(123);
});

View file

@ -631,6 +631,27 @@ await page.GetByLabel("Coun\\"try").ClickAsync();`);
expect.soft(sources2.get('C#')!.text).toContain(`await Expect(page.Locator("#second")).ToHaveValueAsync("bar")`); expect.soft(sources2.get('C#')!.text).toContain(`await Expect(page.Locator("#second")).ToHaveValueAsync("bar")`);
}); });
test('should assert value on disabled select', async ({ openRecorder, browserName }) => {
const recorder = await openRecorder();
await recorder.setContentAndWait(`
<select id=first><option value=foo1>Foo1</option><option value=bar1>Bar1</option></select>
<select id=second disabled><option value=foo2>Foo2</option><option value=bar2 selected>Bar2</option></select>
`);
await recorder.page.click('x-pw-tool-item.value');
await recorder.hoverOverElement('#second');
const [sources2] = await Promise.all([
recorder.waitForOutput('JavaScript', '#second'),
recorder.trustedClick(),
]);
expect.soft(sources2.get('JavaScript')!.text).toContain(`await expect(page.locator('#second')).toHaveValue('bar2')`);
expect.soft(sources2.get('Python')!.text).toContain(`expect(page.locator("#second")).to_have_value("bar2")`);
expect.soft(sources2.get('Python Async')!.text).toContain(`await expect(page.locator("#second")).to_have_value("bar2")`);
expect.soft(sources2.get('Java')!.text).toContain(`assertThat(page.locator("#second")).hasValue("bar2")`);
expect.soft(sources2.get('C#')!.text).toContain(`await Expect(page.Locator("#second")).ToHaveValueAsync("bar2")`);
});
test('should assert visibility', async ({ openRecorder }) => { test('should assert visibility', async ({ openRecorder }) => {
const recorder = await openRecorder(); const recorder = await openRecorder();

View file

@ -0,0 +1,76 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { contextTest as it, expect } from '../config/browserTest';
import type { Locator } from 'playwright-core';
it.use({ hasTouch: true });
it.fixme(({ browserName }) => browserName === 'firefox');
it('slow swipe events @smoke', async ({ page }) => {
it.fixme();
await page.setContent(`<div id="a" style="background: lightblue; width: 200px; height: 200px">a</div>`);
const eventsHandle = await trackEvents(await page.locator('#a'));
const center = await centerPoint(page.locator('#a'));
await page.touchscreen.touch('touchstart', [{ ...center, id: 1 }]);
expect.soft(await eventsHandle.jsonValue()).toEqual([
'pointerover',
'pointerenter',
'pointerdown',
'touchstart',
]);
await eventsHandle.evaluate(events => events.length = 0);
await page.touchscreen.touch('touchmove', [{ x: center.x + 10, y: center.y + 10, id: 1 }]);
await page.touchscreen.touch('touchmove', [{ x: center.x + 20, y: center.y + 20, id: 1 }]);
expect.soft(await eventsHandle.jsonValue()).toEqual([
'pointermove',
'touchmove',
'pointermove',
'touchmove',
]);
await eventsHandle.evaluate(events => events.length = 0);
await page.touchscreen.touch('touchend', [{ x: center.x + 20, y: center.y + 20, id: 1 }]);
expect.soft(await eventsHandle.jsonValue()).toEqual([
'pointerup',
'pointerout',
'pointerleave',
'touchend',
]);
});
async function trackEvents(target: Locator) {
const eventsHandle = await target.evaluateHandle(target => {
const events: string[] = [];
for (const event of [
'mousedown', 'mouseenter', 'mouseleave', 'mousemove', 'mouseout', 'mouseover', 'mouseup', 'click',
'pointercancel', 'pointerdown', 'pointerenter', 'pointerleave', 'pointermove', 'pointerout', 'pointerover', 'pointerup',
'touchstart', 'touchend', 'touchmove', 'touchcancel',])
target.addEventListener(event, () => events.push(event), { passive: false });
return events;
});
return eventsHandle;
}
async function centerPoint(e: Locator) {
const box = await e.boundingBox();
if (!box)
throw new Error('Element is not visible');
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}

View file

@ -23,5 +23,5 @@ it('clock should be frozen', async ({ page }) => {
it('clock should be realtime', async ({ page }) => { it('clock should be realtime', async ({ page }) => {
it.skip(process.env.PW_CLOCK !== 'realtime'); it.skip(process.env.PW_CLOCK !== 'realtime');
expect(await page.evaluate('Date.now()')).toBeLessThan(1000); expect(await page.evaluate('Date.now()')).toBeLessThan(10000);
}); });

View file

@ -16,7 +16,7 @@
*/ */
import { test as it, expect } from './pageTest'; import { test as it, expect } from './pageTest';
import { attachFrame, chromiumVersionLessThan } from '../config/utils'; import { attachFrame } from '../config/utils';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
@ -37,7 +37,7 @@ it('should upload the file', async ({ page, server, asset }) => {
}, input)).toBe('contents of the file'); }, input)).toBe('contents of the file');
}); });
it('should upload a folder', async ({ page, server, browserName, headless, browserVersion, isAndroid }) => { it('should upload a folder', async ({ page, server, browserName, headless, browserMajorVersion, isAndroid }) => {
it.skip(isAndroid); it.skip(isAndroid);
it.skip(os.platform() === 'darwin' && parseInt(os.release().split('.')[0], 10) <= 21, 'WebKit on macOS-12 is frozen'); it.skip(os.platform() === 'darwin' && parseInt(os.release().split('.')[0], 10) <= 21, 'WebKit on macOS-12 is frozen');
@ -54,7 +54,7 @@ it('should upload a folder', async ({ page, server, browserName, headless, brows
await input.setInputFiles(dir); await input.setInputFiles(dir);
expect(new Set(await page.evaluate(e => [...e.files].map(f => f.webkitRelativePath), input))).toEqual(new Set([ expect(new Set(await page.evaluate(e => [...e.files].map(f => f.webkitRelativePath), input))).toEqual(new Set([
// https://issues.chromium.org/issues/345393164 // https://issues.chromium.org/issues/345393164
...((browserName === 'chromium' && headless && !process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW && chromiumVersionLessThan(browserVersion, '127.0.6533.0')) ? [] : ['file-upload-test/sub-dir/really.txt']), ...((browserName === 'chromium' && headless && !process.env.PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW && browserMajorVersion < 127) ? [] : ['file-upload-test/sub-dir/really.txt']),
'file-upload-test/file1.txt', 'file-upload-test/file1.txt',
'file-upload-test/file2', 'file-upload-test/file2',
])); ]));

View file

@ -256,6 +256,41 @@ test('should detect fixture dependency cycle', async ({ runInlineTest }) => {
expect(result.exitCode).toBe(1); expect(result.exitCode).toBe(1);
}); });
test('should hide boxed fixtures in dependency cycle', async ({ runInlineTest }) => {
const result = await runInlineTest({
'x.spec.ts': `
import { test as base } from '@playwright/test';
const test = base.extend({
storageState: async ({ context, storageState }, use) => {
await use(storageState);
}
});
test('failed', async ({ page }) => {});
`,
});
expect(result.output).toContain('Fixtures "context" -> "storageState" -> "context" form a dependency cycle: <builtin> -> x.spec.ts:3:25 -> <builtin>');
expect(result.exitCode).toBe(1);
});
test('should show boxed fixtures in dependency cycle if there are no public fixtures', async ({ runInlineTest }) => {
const result = await runInlineTest({
'x.spec.ts': `
import { test as base } from '@playwright/test';
const test = base.extend({
f1: [async ({ f2 }, use) => {
await use(f2);
}, { box: true }],
f2: [async ({ f1 }, use) => {
await use(f1);
}, { box: true }],
});
test('failed', async ({ f1, f2 }) => {});
`,
});
expect(result.output).toContain('Fixtures "f1" -> "f2" -> "f1" form a dependency cycle: x.spec.ts:3:25 -> x.spec.ts:3:25 -> x.spec.ts:3:25');
expect(result.exitCode).toBe(1);
});
test('should not reuse fixtures from one file in another one', async ({ runInlineTest }) => { test('should not reuse fixtures from one file in another one', async ({ runInlineTest }) => {
const result = await runInlineTest({ const result = await runInlineTest({
'a.spec.ts': ` 'a.spec.ts': `

View file

@ -294,6 +294,7 @@ for (const useIntermediateMergeReport of [false] as const) {
}); });
test('should include image diff when screenshot failed to generate due to animation', async ({ runInlineTest, page, showReport }) => { test('should include image diff when screenshot failed to generate due to animation', async ({ runInlineTest, page, showReport }) => {
test.skip(process.env.PW_CLOCK === 'frozen', 'Assumes Date.now() changes');
const result = await runInlineTest({ const result = await runInlineTest({
'playwright.config.ts': ` 'playwright.config.ts': `
module.exports = { use: { viewport: { width: 200, height: 200 }} }; module.exports = { use: { viewport: { width: 200, height: 200 }} };

View file

@ -690,3 +690,50 @@ test('static modifiers should be added in serial mode', async ({ runInlineTest }
expect(result.report.suites[0].specs[2].tests[0].annotations).toEqual([{ type: 'skip' }]); expect(result.report.suites[0].specs[2].tests[0].annotations).toEqual([{ type: 'skip' }]);
expect(result.report.suites[0].specs[3].tests[0].annotations).toEqual([]); expect(result.report.suites[0].specs[3].tests[0].annotations).toEqual([]);
}); });
test('should contain only one slow modifier', async ({ runInlineTest }) => {
const result = await runInlineTest({
'slow.test.ts': `
import { test } from '@playwright/test';
test.slow();
test('pass', { annotation: { type: 'issue', description: 'my-value' } }, () => {});
`,
'skip.test.ts': `
import { test } from '@playwright/test';
test.skip();
test('pass', { annotation: { type: 'issue', description: 'my-value' } }, () => {});
`,
'fixme.test.ts': `
import { test } from '@playwright/test';
test.fixme();
test('pass', { annotation: { type: 'issue', description: 'my-value' } }, () => {});
`,
});
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
expect(result.report.suites[0].specs[0].tests[0].annotations).toEqual([{ type: 'fixme' }, { type: 'issue', description: 'my-value' }]);
expect(result.report.suites[1].specs[0].tests[0].annotations).toEqual([{ type: 'skip' }, { type: 'issue', description: 'my-value' }]);
expect(result.report.suites[2].specs[0].tests[0].annotations).toEqual([{ type: 'slow' }, { type: 'issue', description: 'my-value' }]);
});
test('should skip beforeEach hooks upon modifiers', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
import { test } from '@playwright/test';
test('top', () => {});
test.describe(() => {
test.skip(({ viewport }) => true);
test.beforeEach(() => { throw new Error(); });
test.describe(() => {
test.beforeEach(() => { throw new Error(); });
test('test', () => {});
});
});
`,
});
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
expect(result.skipped).toBe(1);
});

View file

@ -182,7 +182,7 @@ test('should respect fixture timeout', async ({ runInlineTest }) => {
slowSetup: [async ({}, use) => { slowSetup: [async ({}, use) => {
await new Promise(f => setTimeout(f, 2000)); await new Promise(f => setTimeout(f, 2000));
await use('hey'); await use('hey');
}, { timeout: 500, _title: 'custom title' }], }, { timeout: 500, title: 'custom title' }],
slowTeardown: [async ({}, use) => { slowTeardown: [async ({}, use) => {
await use('hey'); await use('hey');
await new Promise(f => setTimeout(f, 2000)); await new Promise(f => setTimeout(f, 2000));
@ -227,7 +227,7 @@ test('should respect test.setTimeout in the worker fixture', async ({ runInlineT
slowTeardown: [async ({}, use) => { slowTeardown: [async ({}, use) => {
await use('hey'); await use('hey');
await new Promise(f => setTimeout(f, 2000)); await new Promise(f => setTimeout(f, 2000));
}, { scope: 'worker', timeout: 400, _title: 'custom title' }], }, { scope: 'worker', timeout: 400, title: 'custom title' }],
}); });
test('test ok', async ({ fixture, noTimeout }) => { test('test ok', async ({ fixture, noTimeout }) => {
await new Promise(f => setTimeout(f, 1000)); await new Promise(f => setTimeout(f, 1000));

View file

@ -4,7 +4,7 @@ set -x
trap "cd $(pwd -P)" EXIT trap "cd $(pwd -P)" EXIT
SCRIPT_PATH="$(cd "$(dirname "$0")" ; pwd -P)" SCRIPT_PATH="$(cd "$(dirname "$0")" ; pwd -P)"
NODE_VERSION="20.14.0" # autogenerated via ./update-playwright-driver-version.mjs NODE_VERSION="20.15.0" # autogenerated via ./update-playwright-driver-version.mjs
cd "$(dirname "$0")" cd "$(dirname "$0")"
PACKAGE_VERSION=$(node -p "require('../../package.json').version") PACKAGE_VERSION=$(node -p "require('../../package.json').version")

View file

@ -140,13 +140,13 @@ export type WorkerFixture<R, Args extends KeyValue> = (args: Args, use: (r: R) =
type TestFixtureValue<R, Args extends KeyValue> = Exclude<R, Function> | TestFixture<R, Args>; type TestFixtureValue<R, Args extends KeyValue> = Exclude<R, Function> | TestFixture<R, Args>;
type WorkerFixtureValue<R, Args extends KeyValue> = Exclude<R, Function> | WorkerFixture<R, Args>; type WorkerFixtureValue<R, Args extends KeyValue> = Exclude<R, Function> | WorkerFixture<R, Args>;
export type Fixtures<T extends KeyValue = {}, W extends KeyValue = {}, PT extends KeyValue = {}, PW extends KeyValue = {}> = { 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 }]; [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 }]; [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 }]; [K in keyof W]?: [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 }]; [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 }];
}; };
type BrowserName = 'chromium' | 'firefox' | 'webkit'; type BrowserName = 'chromium' | 'firefox' | 'webkit';