Merge branch 'main' into main

Signed-off-by: Alex Schwartz <alexschwartz01@gmail.com>
This commit is contained in:
Alex Schwartz 2025-02-27 14:52:25 -05:00 committed by GitHub
commit 123bc22dbb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
89 changed files with 966 additions and 1351 deletions

View file

@ -1,6 +1,6 @@
# 🎭 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-134.0.6998.23-blue.svg?logo=google-chrome)](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[![Firefox version](https://img.shields.io/badge/firefox-135.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[![WebKit version](https://img.shields.io/badge/webkit-18.2-blue.svg?logo=safari)](https://webkit.org/)<!-- GEN:stop --> [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord)
[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[![Chromium version](https://img.shields.io/badge/chromium-134.0.6998.35-blue.svg?logo=google-chrome)](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[![Firefox version](https://img.shields.io/badge/firefox-135.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[![WebKit version](https://img.shields.io/badge/webkit-18.2-blue.svg?logo=safari)](https://webkit.org/)<!-- GEN:stop --> [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord)
## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright)
@ -8,7 +8,7 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr
| | Linux | macOS | Windows |
| :--- | :---: | :---: | :---: |
| Chromium <!-- GEN:chromium-version -->134.0.6998.23<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Chromium <!-- GEN:chromium-version -->134.0.6998.35<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| WebKit <!-- GEN:webkit-version -->18.2<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Firefox <!-- GEN:firefox-version -->135.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |

View file

@ -21,8 +21,12 @@ Creates new instances of [APIRequestContext].
### option: APIRequest.newContext.extraHTTPHeaders = %%-context-option-extrahttpheaders-%%
* since: v1.16
### option: APIRequest.newContext.apiRequestFailsOnErrorStatus = %%-context-option-apiRequestFailsOnErrorStatus-%%
### option: APIRequest.newContext.failOnStatusCode
* since: v1.51
- `failOnStatusCode` <[boolean]>
Whether to throw on response codes other than 2xx and 3xx. By default response object is returned
for all status codes.
### option: APIRequest.newContext.httpCredentials = %%-context-option-httpcredentials-%%
* since: v1.16
@ -67,25 +71,7 @@ Methods like [`method: APIRequestContext.get`] take the base URL into considerat
- `localStorage` <[Array]<[Object]>>
- `name` <[string]>
- `value` <[string]>
- `indexedDB` ?<[Array]<[Object]>> indexedDB to set for context
- `name` <[string]> database name
- `version` <[int]> database version
- `stores` <[Array]<[Object]>>
- `name` <[string]>
- `keyPath` ?<[string]>
- `keyPathArray` ?<[Array]<[string]>>
- `autoIncrement` <[boolean]>
- `indexes` <[Array]<[Object]>>
- `name` <[string]>
- `keyPath` ?<[string]>
- `keyPathArray` ?<[Array]<[string]>>
- `unique` <[boolean]>
- `multiEntry` <[boolean]>
- `records` <[Array]<[Object]>>
- `key` ?<[Object]>
- `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types.
- `value` ?<[Object]>
- `valueEncoded` ?<[Object]> if `value` is not JSON-serializable, this contains an encoded version that preserves types.
- `indexedDB` ?<[Array]<[unknown]>> indexedDB to set for context
Populates context with given storage state. This option can be used to initialize context with logged-in information
obtained via [`method: BrowserContext.storageState`] or [`method: APIRequestContext.storageState`]. Either a path to the

View file

@ -880,25 +880,7 @@ context cookies from the response. The method will automatically follow redirect
- `localStorage` <[Array]<[Object]>>
- `name` <[string]>
- `value` <[string]>
- `indexedDB` <[Array]<[Object]>>
- `name` <[string]>
- `version` <[int]>
- `stores` <[Array]<[Object]>>
- `name` <[string]>
- `keyPath` ?<[string]>
- `keyPathArray` ?<[Array]<[string]>>
- `autoIncrement` <[boolean]>
- `indexes` <[Array]<[Object]>>
- `name` <[string]>
- `keyPath` ?<[string]>
- `keyPathArray` ?<[Array]<[string]>>
- `unique` <[boolean]>
- `multiEntry` <[boolean]>
- `records` <[Array]<[Object]>>
- `key` ?<[Object]>
- `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types.
- `value` ?<[Object]>
- `valueEncoded` ?<[Object]> if `value` is not JSON-serializable, this contains an encoded version that preserves types.
- `indexedDB` <[Array]<[unknown]>>
Returns storage state for this request context, contains current cookies and local storage snapshot if it was passed to the constructor.
@ -914,4 +896,4 @@ Returns storage state for this request context, contains current cookies and loc
* since: v1.51
- `indexedDB` ?<boolean>
Defaults to `true`. Set to `false` to omit IndexedDB from snapshot.
Set to `true` to include IndexedDB in the storage state snapshot.

View file

@ -1511,32 +1511,10 @@ Whether to emulate network being offline for the browser context.
- `localStorage` <[Array]<[Object]>>
- `name` <[string]>
- `value` <[string]>
- `indexedDB` <[Array]<[Object]>>
- `name` <[string]>
- `version` <[int]>
- `stores` <[Array]<[Object]>>
- `name` <[string]>
- `keyPath` ?<[string]>
- `keyPathArray` ?<[Array]<[string]>>
- `autoIncrement` <[boolean]>
- `indexes` <[Array]<[Object]>>
- `name` <[string]>
- `keyPath` ?<[string]>
- `keyPathArray` ?<[Array]<[string]>>
- `unique` <[boolean]>
- `multiEntry` <[boolean]>
- `records` <[Array]<[Object]>>
- `key` ?<[Object]>
- `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types.
- `value` ?<[Object]>
- `valueEncoded` ?<[Object]> if `value` is not JSON-serializable, this contains an encoded version that preserves types.
- `indexedDB` <[Array]<[unknown]>>
Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB snapshot.
:::note
IndexedDBs with typed arrays are currently not supported.
:::
## async method: BrowserContext.storageState
* since: v1.8
* langs: csharp, java
@ -1549,7 +1527,12 @@ IndexedDBs with typed arrays are currently not supported.
* since: v1.51
- `indexedDB` ?<boolean>
Defaults to `true`. Set to `false` to omit IndexedDB from snapshot.
Set to `true` to include IndexedDB in the storage state snapshot.
If your application uses IndexedDB to store authentication tokens, like Firebase Authentication, enable this.
:::note
IndexedDBs with typed arrays are currently not supported.
:::
## property: BrowserContext.tracing
* since: v1.12

View file

@ -1090,6 +1090,9 @@ await rowLocator
### option: Locator.filter.hasNotText = %%-locator-option-has-not-text-%%
* since: v1.33
### option: Locator.filter.visible = %%-locator-option-visible-%%
* since: v1.51
## method: Locator.first
* since: v1.14
- returns: <[Locator]>
@ -2332,7 +2335,7 @@ This method expects [Locator] to point to an
## async method: Locator.tap
* since: v1.14
Perform a tap gesture on the element matching the locator.
Perform a tap gesture on the element matching the locator. For examples of emulating other gestures by manually dispatching touch events, see the [emulating legacy touch events](../touch-events.md) page.
**Details**

View file

@ -2263,13 +2263,13 @@ assertThat(page.locator("body")).matchesAriaSnapshot("""
Asserts that the target element matches the given [accessibility snapshot](../aria-snapshots.md).
Snapshot is stored in a separate `.yml` file in a location configured by `expect.toMatchAriaSnapshot.pathTemplate` and/or `snapshotPathTemplate` properties in the configuration file.
Snapshot is stored in a separate `.snapshot.yml` file in a location configured by `expect.toMatchAriaSnapshot.pathTemplate` and/or `snapshotPathTemplate` properties in the configuration file.
**Usage**
```js
await expect(page.locator('body')).toMatchAriaSnapshot();
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'body.yml' });
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'body.snapshot.yml' });
```
### option: LocatorAssertions.toMatchAriaSnapshot#2.name

View file

@ -296,7 +296,18 @@ Ensures the page is navigated to the given URL.
**Usage**
```js
await expect(page).toHaveURL(/.*checkout/);
// Check for the page URL to be 'https://playwright.dev/docs/intro' (including query string)
await expect(page).toHaveURL('https://playwright.dev/docs/intro');
// Check for the page URL to contain 'doc', followed by an optional 's', followed by '/'
await expect(page).toHaveURL(/docs?\//);
// Check for the predicate to be satisfied
// For example: verify query strings
await expect(page).toHaveURL(url => {
const params = url.searchParams;
return params.has('search') && params.has('options') && params.get('id') === '5';
});
```
```java
@ -328,7 +339,7 @@ await Expect(Page).ToHaveURLAsync(new Regex(".*checkout"));
- `url` <[string]|[RegExp]|[function]\([URL]\):[boolean]>
Expected URL string, RegExp, or predicate receiving [URL] to match.
When a [`option: Browser.newContext.baseURL`] via the context options was provided and the passed URL is a path, it gets merged via the [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor.
When [`option: Browser.newContext.baseURL`] is provided via the context options and the `url` argument is a string, the two values are merged via the [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor and used for the comparison against the current browser URL.
### option: PageAssertions.toHaveURL.ignoreCase
* since: v1.44

View file

@ -4,6 +4,8 @@
The Touchscreen class operates in main-frame CSS pixels relative to the top-left corner of the viewport. Methods on the
touchscreen can only be used in browser contexts that have been initialized with `hasTouch` set to true.
This class is limited to emulating tap gestures. For examples of other gestures simulated by manually dispatching touch events, see the [emulating legacy touch events](../touch-events.md) page.
## async method: Touchscreen.tap
* since: v1.8

View file

@ -264,25 +264,7 @@ Specify environment variables that will be visible to the browser. Defaults to `
- `localStorage` <[Array]<[Object]>> localStorage to set for context
- `name` <[string]>
- `value` <[string]>
- `indexedDB` ?<[Array]<[Object]>> indexedDB to set for context
- `name` <[string]> database name
- `version` <[int]> database version
- `stores` <[Array]<[Object]>>
- `name` <[string]>
- `keyPath` ?<[string]>
- `keyPathArray` ?<[Array]<[string]>>
- `autoIncrement` <[boolean]>
- `indexes` <[Array]<[Object]>>
- `name` <[string]>
- `keyPath` ?<[string]>
- `keyPathArray` ?<[Array]<[string]>>
- `unique` <[boolean]>
- `multiEntry` <[boolean]>
- `records` <[Array]<[Object]>>
- `key` ?<[Object]>
- `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types.
- `value` ?<[Object]>
- `valueEncoded` ?<[Object]> if `value` is not JSON-serializable, this contains an encoded version that preserves types.
- `indexedDB` ?<[Array]<[unknown]>> indexedDB to set for context
Learn more about [storage state and auth](../auth.md).
@ -639,11 +621,6 @@ A list of permissions to grant to all pages in this context. See
An object containing additional HTTP headers to be sent with every request. Defaults to none.
## context-option-apiRequestFailsOnErrorStatus
- `apiRequestFailsOnErrorStatus` <[boolean]>
An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By default, response object is returned for all status codes.
## context-option-offline
- `offline` <[boolean]>
@ -1001,7 +978,6 @@ between the same pixel in compared images, between zero (strict) and one (lax),
- %%-context-option-locale-%%
- %%-context-option-permissions-%%
- %%-context-option-extrahttpheaders-%%
- %%-context-option-apiRequestFailsOnErrorStatus-%%
- %%-context-option-offline-%%
- %%-context-option-httpcredentials-%%
- %%-context-option-colorscheme-%%
@ -1179,6 +1155,11 @@ Note that outer and inner locators must belong to the same frame. Inner locator
Matches elements that do not contain specified text somewhere inside, possibly in a child or a descendant element. When passed a [string], matching is case-insensitive and searches for a substring.
## locator-option-visible
- `visible` <[boolean]>
Only matches visible or invisible elements.
## locator-options-list-v1.14
- %%-locator-option-has-text-%%
- %%-locator-option-has-%%

View file

@ -339,10 +339,10 @@ npx playwright test --update-snapshots --update-source-mode=3way
#### Snapshots as separate files
To store your snapshots in a separate file, use the `toMatchAriaSnapshot` method with the `name` option, specifying a `.yml` file extension.
To store your snapshots in a separate file, use the `toMatchAriaSnapshot` method with the `name` option, specifying a `.snapshot.yml` file extension.
```js
await expect(page.getByRole('main')).toMatchAriaSnapshot({ name: 'main-snapshot.yml' });
await expect(page.getByRole('main')).toMatchAriaSnapshot({ name: 'main.snapshot.yml' });
```
By default, snapshots from a test file `example.spec.ts` are placed in the `example.spec.ts-snapshots` directory. As snapshots should be the same across browsers, only one snapshot is saved even if testing with multiple browsers. Should you wish, you can customize the [snapshot path template](./api/class-testconfig#test-config-snapshot-path-template) using the following configuration:

View file

@ -751,10 +751,10 @@ page.locator("x-details", new Page.LocatorOptions().setHasText("Details"))
.click();
```
```python async
await page.locator("x-details", has_text="Details" ).click()
await page.locator("x-details", has_text="Details").click()
```
```python sync
page.locator("x-details", has_text="Details" ).click()
page.locator("x-details", has_text="Details").click()
```
```csharp
await page
@ -1310,19 +1310,19 @@ Consider a page with two buttons, the first invisible and the second [visible](.
* This will only find a second button, because it is visible, and then click it.
```js
await page.locator('button').locator('visible=true').click();
await page.locator('button').filter({ visible: true }).click();
```
```java
page.locator("button").locator("visible=true").click();
page.locator("button").filter(new Locator.FilterOptions.setVisible(true)).click();
```
```python async
await page.locator("button").locator("visible=true").click()
await page.locator("button").filter(visible=True).click()
```
```python sync
page.locator("button").locator("visible=true").click()
page.locator("button").filter(visible=True).click()
```
```csharp
await page.Locator("button").Locator("visible=true").ClickAsync();
await page.Locator("button").Filter(new() { Visible = true }).ClickAsync();
```
## Lists

View file

@ -708,9 +708,13 @@ Playwright uses simplified glob patterns for URL matching in network interceptio
- A double `**` matches any characters including `/`
1. Question mark `?` matches any single character except `/`
1. Curly braces `{}` can be used to match a list of options separated by commas `,`
1. Square brackets `[]` can be used to match a set of characters
1. Backslash `\` can be used to escape any of special characters (note to escape backslash itself as `\\`)
Examples:
- `https://example.com/*.js` matches `https://example.com/file.js` but not `https://example.com/path/file.js`
- `https://example.com/\\?page=1` matches `https://example.com/?page=1` but not `https://example.com`
- `**/v[0-9]*` matches `https://example.com/v1/` but not `https://example.com/vote/`
- `**/*.js` matches both `https://example.com/file.js` and `https://example.com/path/file.js`
- `**/*.{png,jpg,jpeg}` matches all image requests

View file

@ -93,8 +93,8 @@ See [`property: TestConfig.reporter`].
## property: FullConfig.reportSlowTests
* since: v1.10
- type: <[null]|[Object]>
- `max` <[int]> The maximum number of slow test files to report. Defaults to `5`.
- `threshold` <[float]> Test duration in milliseconds that is considered slow. Defaults to 15 seconds.
- `max` <[int]> The maximum number of slow test files to report.
- `threshold` <[float]> Test file duration in milliseconds that is considered slow.
See [`property: TestConfig.reportSlowTests`].

View file

@ -239,7 +239,10 @@ export default defineConfig({
Metadata contains key-value pairs to be included in the report. For example, HTML report will display it as key-value pairs, and JSON report will include metadata serialized as json.
See also [`property: TestConfig.populateGitInfo`] that populates metadata.
* Providing `gitCommit: 'generate'` property will populate it with the git commit details.
* Providing `gitDiff: 'generate'` property will populate it with the git diff details.
On selected CI providers, both will be generated automatically. Specifying values will prevent the automatic generation.
**Usage**
@ -326,26 +329,6 @@ This path will serve as the base directory for each test file snapshot directory
## property: TestConfig.snapshotPathTemplate = %%-test-config-snapshot-path-template-%%
* since: v1.28
## property: TestConfig.populateGitInfo
* since: v1.51
- type: ?<[boolean]>
Whether to populate `'git.commit.info'` field of the [`property: TestConfig.metadata`] with Git commit info and CI/CD information.
This information will appear in the HTML and JSON reports and is available in the Reporter API.
On Github Actions, this feature is enabled by default.
**Usage**
```js title="playwright.config.ts"
import { defineConfig } from '@playwright/test';
export default defineConfig({
populateGitInfo: !!process.env.CI,
});
```
## property: TestConfig.preserveOutput
* since: v1.10
- type: ?<[PreserveOutput]<"always"|"never"|"failures-only">>
@ -450,7 +433,7 @@ export default defineConfig({
* since: v1.10
- type: ?<[null]|[Object]>
- `max` <[int]> The maximum number of slow test files to report. Defaults to `5`.
- `threshold` <[float]> Test duration in milliseconds that is considered slow. Defaults to 15 seconds.
- `threshold` <[float]> Test file duration in milliseconds that is considered slow. Defaults to 5 minutes.
Whether to report slow test files. Pass `null` to disable this feature.
@ -684,7 +667,7 @@ import { defineConfig } from '@playwright/test';
export default defineConfig({
webServer: {
command: 'npm run start',
url: 'http://127.0.0.1:3000',
url: 'http://localhost:3000',
timeout: 120 * 1000,
reuseExistingServer: !process.env.CI,
},
@ -713,19 +696,19 @@ export default defineConfig({
webServer: [
{
command: 'npm run start',
url: 'http://127.0.0.1:3000',
url: 'http://localhost:3000',
timeout: 120 * 1000,
reuseExistingServer: !process.env.CI,
},
{
command: 'npm run backend',
url: 'http://127.0.0.1:3333',
url: 'http://localhost:3333',
timeout: 120 * 1000,
reuseExistingServer: !process.env.CI,
}
],
use: {
baseURL: 'http://127.0.0.1:3000',
baseURL: 'http://localhost:3000',
},
});
```

View file

@ -35,7 +35,7 @@ export default defineConfig({
use: {
// Base URL to use in actions like `await page.goto('/')`.
baseURL: 'http://127.0.0.1:3000',
baseURL: 'http://localhost:3000',
// Collect trace when retrying the failed test.
trace: 'on-first-retry',
@ -50,7 +50,7 @@ export default defineConfig({
// Run your local dev server before starting the tests.
webServer: {
command: 'npm run start',
url: 'http://127.0.0.1:3000',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});

View file

@ -17,7 +17,7 @@ import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
// Base URL to use in actions like `await page.goto('/')`.
baseURL: 'http://127.0.0.1:3000',
baseURL: 'http://localhost:3000',
// Populates context with given storage state.
storageState: 'state.json',

View file

@ -18,7 +18,7 @@ export default defineConfig({
// Run your local dev server before starting the tests
webServer: {
command: 'npm run start',
url: 'http://127.0.0.1:3000',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
stdout: 'ignore',
stderr: 'pipe',
@ -52,7 +52,7 @@ export default defineConfig({
// Run your local dev server before starting the tests
webServer: {
command: 'npm run start',
url: 'http://127.0.0.1:3000',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
@ -63,7 +63,7 @@ export default defineConfig({
It is also recommended to specify the `baseURL` in the `use: {}` section of your config, so that tests can use relative urls and you don't have to specify the full URL over and over again.
When using [`method: Page.goto`], [`method: Page.route`], [`method: Page.waitForURL`], [`method: Page.waitForRequest`], or [`method: Page.waitForResponse`] it takes the base URL in consideration by using the [`URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor for building the corresponding URL. For Example, by setting the baseURL to `http://127.0.0.1:3000` and navigating to `/login` in your tests, Playwright will run the test using `http://127.0.0.1:3000/login`.
When using [`method: Page.goto`], [`method: Page.route`], [`method: Page.waitForURL`], [`method: Page.waitForRequest`], or [`method: Page.waitForResponse`] it takes the base URL in consideration by using the [`URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor for building the corresponding URL. For Example, by setting the baseURL to `http://localhost:3000` and navigating to `/login` in your tests, Playwright will run the test using `http://localhost:3000/login`.
```js title="playwright.config.ts"
import { defineConfig } from '@playwright/test';
@ -74,11 +74,11 @@ export default defineConfig({
// Run your local dev server before starting the tests
webServer: {
command: 'npm run start',
url: 'http://127.0.0.1:3000',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: 'http://127.0.0.1:3000',
baseURL: 'http://localhost:3000',
},
});
```
@ -89,7 +89,7 @@ Now you can use a relative path when navigating the page:
import { test } from '@playwright/test';
test('test', async ({ page }) => {
// This will navigate to http://127.0.0.1:3000/login
// This will navigate to http://localhost:3000/login
await page.goto('./login');
});
```
@ -106,19 +106,19 @@ export default defineConfig({
webServer: [
{
command: 'npm run start',
url: 'http://127.0.0.1:3000',
url: 'http://localhost:3000',
timeout: 120 * 1000,
reuseExistingServer: !process.env.CI,
},
{
command: 'npm run backend',
url: 'http://127.0.0.1:3333',
url: 'http://localhost:3333',
timeout: 120 * 1000,
reuseExistingServer: !process.env.CI,
}
],
use: {
baseURL: 'http://127.0.0.1:3000',
baseURL: 'http://localhost:3000',
},
});
```

View file

@ -1,19 +1,13 @@
---
id: touch-events
title: "Emulating touch events"
title: "Emulating legacy touch events"
---
## Introduction
Mobile web sites may listen to [touch events](https://developer.mozilla.org/en-US/docs/Web/API/Touch_events) and react to user touch gestures such as swipe, pinch, tap etc. To test this functionality you can manually generate [TouchEvent]s in the page context using [`method: Locator.evaluate`].
Web applications that handle [touch events](https://developer.mozilla.org/en-US/docs/Web/API/Touch_events) to respond to gestures like swipe, pinch, and tap can be tested by manually dispatching [TouchEvent](https://developer.mozilla.org/en-US/docs/Web/API/TouchEvent/TouchEvent)s to the page. The examples below demonstrate how to use [`method: Locator.dispatchEvent`] and pass [Touch](https://developer.mozilla.org/en-US/docs/Web/API/Touch) points as arguments.
If your web application relies on [pointer events](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events) instead of touch events, you can use [`method: Locator.click`] and raw [`Mouse`] events to simulate a single-finger touch, and this will trigger all the same pointer events.
### Dispatching TouchEvent
You can dispatch touch events to the page using [`method: Locator.dispatchEvent`]. [Touch](https://developer.mozilla.org/en-US/docs/Web/API/Touch) points can be passed as arguments, see examples below.
#### Emulating pan gesture
### Emulating pan gesture
In the example below, we emulate pan gesture that is expected to move the map. The app under test only uses `clientX/clientY` coordinates of the touch point, so we initialize just that. In a more complex scenario you may need to also set `pageX/pageY/screenX/screenY`, if your app needs them.
@ -69,7 +63,7 @@ test(`pan gesture to move the map`, async ({ page }) => {
});
```
#### Emulating pinch gesture
### Emulating pinch gesture
In the example below, we emulate pinch gesture, i.e. two touch points moving closer to each other. It is expected to zoom out the map. The app under test only uses `clientX/clientY` coordinates of touch points, so we initialize just that. In a more complex scenario you may need to also set `pageX/pageY/screenX/screenY`, if your app needs them.

View file

@ -253,7 +253,11 @@ export default [{
'no-console': 'off'
}
}, {
files: ['packages/playwright-core/src/server/injected/**/*.ts'],
files: [
'packages/playwright-core/src/server/injected/**/*.ts',
'packages/playwright-core/src/server/isomorphic/**/*.ts',
'packages/playwright-core/src/utils/isomorphic/**/*.ts',
],
languageOptions: languageOptionsWithTsConfig,
rules: {
...noWebGlobalsRules,

View file

@ -37,10 +37,6 @@
line-height: 24px;
}
.metadata-section {
align-items: center;
}
.metadata-properties {
display: flex;
flex-direction: column;
@ -57,9 +53,8 @@
border-bottom: 1px solid var(--color-border-default);
}
.git-commit-info a {
.metadata-view a {
color: var(--color-fg-default);
font-weight: 600;
}
.copyable-property {

View file

@ -20,32 +20,10 @@ import './common.css';
import './theme.css';
import './metadataView.css';
import type { Metadata } from '@playwright/test';
import type { GitCommitInfo } from '@testIsomorphic/types';
import type { CIInfo, GitCommitInfo, MetadataWithCommitInfo } from '@testIsomorphic/types';
import { CopyToClipboardContainer } from './copyToClipboard';
import { linkifyText } from '@web/renderUtils';
type MetadataEntries = [string, unknown][];
export const MetadataContext = React.createContext<MetadataEntries>([]);
export function MetadataProvider({ metadata, children }: React.PropsWithChildren<{ metadata: Metadata }>) {
const entries = React.useMemo(() => {
// TODO: do not plumb actualWorkers through metadata.
return Object.entries(metadata).filter(([key]) => key !== 'actualWorkers');
}, [metadata]);
return <MetadataContext.Provider value={entries}>{children}</MetadataContext.Provider>;
}
export function useMetadata() {
return React.useContext(MetadataContext);
}
export function useGitCommitInfo() {
const metadataEntries = useMetadata();
return metadataEntries.find(([key]) => key === 'git.commit.info')?.[1] as GitCommitInfo | undefined;
}
class ErrorBoundary extends React.Component<React.PropsWithChildren<{}>, { error: Error | null, errorInfo: React.ErrorInfo | null }> {
override state: { error: Error | null, errorInfo: React.ErrorInfo | null } = {
error: null,
@ -72,27 +50,26 @@ class ErrorBoundary extends React.Component<React.PropsWithChildren<{}>, { error
}
}
export const MetadataView = () => {
return <ErrorBoundary><InnerMetadataView/></ErrorBoundary>;
export const MetadataView: React.FC<{ metadata: Metadata }> = params => {
return <ErrorBoundary><InnerMetadataView metadata={params.metadata}/></ErrorBoundary>;
};
const InnerMetadataView = () => {
const metadataEntries = useMetadata();
const gitCommitInfo = useGitCommitInfo();
const entries = metadataEntries.filter(([key]) => key !== 'git.commit.info');
if (!gitCommitInfo && !entries.length)
return null;
const InnerMetadataView: React.FC<{ metadata: Metadata }> = params => {
const commitInfo = params.metadata as MetadataWithCommitInfo;
const otherEntries = Object.entries(params.metadata).filter(([key]) => !ignoreKeys.has(key));
const hasMetadata = commitInfo.ci || commitInfo.gitCommit || otherEntries.length > 0;
if (!hasMetadata)
return;
return <div className='metadata-view'>
{gitCommitInfo && <>
<GitCommitInfoView info={gitCommitInfo}/>
{entries.length > 0 && <div className='metadata-separator' />}
</>}
<div className='metadata-section metadata-properties'>
{entries.map(([propertyName, value]) => {
{commitInfo.ci && !commitInfo.gitCommit && <CiInfoView info={commitInfo.ci}/>}
{commitInfo.gitCommit && <GitCommitInfoView ci={commitInfo.ci} commit={commitInfo.gitCommit}/>}
{otherEntries.length > 0 && (commitInfo.gitCommit || commitInfo.ci) && <div className='metadata-separator' />}
<div className='metadata-section metadata-properties' role='list'>
{otherEntries.map(([propertyName, value]) => {
const valueString = typeof value !== 'object' || value === null || value === undefined ? String(value) : JSON.stringify(value);
const trimmedValue = valueString.length > 1000 ? valueString.slice(0, 1000) + '\u2026' : valueString;
return (
<div key={propertyName} className='copyable-property'>
<div key={propertyName} className='copyable-property' role='listitem'>
<CopyToClipboardContainer value={valueString}>
<span style={{ fontWeight: 'bold' }} title={propertyName}>{propertyName}</span>
: <span title={trimmedValue}>{linkifyText(trimmedValue)}</span>
@ -104,48 +81,39 @@ const InnerMetadataView = () => {
</div>;
};
const GitCommitInfoView: React.FC<{ info: GitCommitInfo }> = ({ info }) => {
const email = info['revision.email'] ? ` <${info['revision.email']}>` : '';
const author = `${info['revision.author'] || ''}${email}`;
let subject = info['revision.subject'] || '';
let link = info['revision.link'];
let shortSubject = info['revision.id']?.slice(0, 7) || 'unknown';
if (info['pull.link'] && info['pull.title']) {
subject = info['pull.title'];
link = info['pull.link'];
shortSubject = link ? 'Pull Request' : '';
}
const shortTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(info['revision.timestamp']);
const longTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(info['revision.timestamp']);
return <div className='hbox git-commit-info metadata-section'>
<div className='vbox metadata-properties'>
<div>
{link ? (
<a href={link} target='_blank' rel='noopener noreferrer' title={subject}>
{subject}
</a>
) : <span title={subject}>
{subject}
</span>}
</div>
<div className='hbox'>
<span className='mr-1'>{author}</span>
<span title={longTimestamp}> on {shortTimestamp}</span>
{info['ci.link'] && (
<>
<span className='mx-2'>·</span>
<a href={info['ci.link']} target='_blank' rel='noopener noreferrer' title='CI/CD logs'>Logs</a>
</>
)}
</div>
const CiInfoView: React.FC<{ info: CIInfo }> = ({ info }) => {
const title = info.prTitle || `Commit ${info.commitHash}`;
const link = info.prHref || info.commitHref;
return <div className='metadata-section' role='list'>
<div role='listitem'>
<a href={link} target='_blank' rel='noopener noreferrer' title={title}>{title}</a>
</div>
{link ? (
<a href={link} target='_blank' rel='noopener noreferrer' title='View commit details'>
{shortSubject}
</a>
) : !!shortSubject && <span>{shortSubject}</span>}
</div>;
};
const GitCommitInfoView: React.FC<{ ci?: CIInfo, commit: GitCommitInfo }> = ({ ci, commit }) => {
const title = ci?.prTitle || commit.subject;
const link = ci?.prHref || ci?.commitHref;
const email = ` <${commit.author.email}>`;
const author = `${commit.author.name}${email}`;
const shortTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(commit.committer.time);
const longTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(commit.committer.time);
return <div className='metadata-section' role='list'>
<div role='listitem'>
{link && <a href={link} target='_blank' rel='noopener noreferrer' title={title}>{title}</a>}
{!link && <span title={title}>{title}</span>}
</div>
<div role='listitem' className='hbox'>
<span className='mr-1'>{author}</span>
<span title={longTimestamp}> on {shortTimestamp}</span>
</div>
</div>;
};
const ignoreKeys = new Set(['ci', 'gitCommit', 'gitDiff', 'actualWorkers']);
export const isMetadataEmpty = (metadata: MetadataWithCommitInfo): boolean => {
const otherEntries = Object.entries(metadata).filter(([key]) => !ignoreKeys.has(key));
return !metadata.ci && !metadata.gitCommit && !otherEntries.length;
};

View file

@ -0,0 +1,29 @@
/*
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 { HTMLReport } from './types';
const HTMLReportContext = React.createContext<HTMLReport | undefined>(undefined);
export function HTMLReportContextProvider({ report, children }: React.PropsWithChildren<{ report: HTMLReport | undefined }>) {
return <HTMLReportContext.Provider value={report}>{children}</HTMLReportContext.Provider>;
}
export function useHTMLReport() {
return React.useContext(HTMLReportContext);
}

View file

@ -26,7 +26,7 @@ import './reportView.css';
import { TestCaseView } from './testCaseView';
import { TestFilesHeader, TestFilesView } from './testFilesView';
import './theme.css';
import { MetadataProvider } from './metadataView';
import { HTMLReportContextProvider } from './reportContext';
declare global {
interface Window {
@ -73,7 +73,7 @@ export const ReportView: React.FC<{
return result;
}, [report, filter]);
return <MetadataProvider metadata={report?.json().metadata ?? {}}><div className='htmlreport vbox px-4 pb-4'>
return <HTMLReportContextProvider report={report?.json()}><div className='htmlreport vbox px-4 pb-4'>
<main>
{report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
<Route predicate={testFilesRoutePredicate}>
@ -89,7 +89,7 @@ export const ReportView: React.FC<{
{!!report && <TestCaseViewLoader report={report} tests={filteredTests.tests} testIdToFileIdMap={testIdToFileIdMap} />}
</Route>
</main>
</div></MetadataProvider>;
</div></HTMLReportContextProvider>;
};
const TestCaseViewLoader: React.FC<{

View file

@ -21,9 +21,14 @@ import type { ImageDiff } from '@web/shared/imageDiffView';
import { ImageDiffView } from '@web/shared/imageDiffView';
import type { TestResult } from './types';
import { fixTestPrompt } from '@web/components/prompts';
import { useGitCommitInfo } from './metadataView';
import { useHTMLReport } from './reportContext';
import type { MetadataWithCommitInfo } from '@playwright/isomorphic/types';
export const TestErrorView: React.FC<{ error: string; testId?: string; result?: TestResult }> = ({ error, testId, result }) => {
export const TestErrorView: React.FC<{
error: string;
testId?: string;
result?: TestResult
}> = ({ error, testId, result }) => {
return (
<CodeSnippet code={error} testId={testId}>
<div style={{ float: 'right', margin: 10 }}>
@ -47,12 +52,13 @@ const PromptButton: React.FC<{
error: string;
result?: TestResult;
}> = ({ error, result }) => {
const gitCommitInfo = useGitCommitInfo();
const report = useHTMLReport();
const commitInfo = report?.metadata as MetadataWithCommitInfo | undefined;
const prompt = React.useMemo(() => fixTestPrompt(
error,
gitCommitInfo?.['pull.diff'] ?? gitCommitInfo?.['revision.diff'],
commitInfo?.gitDiff,
result?.attachments.find(a => a.name === 'pageSnapshot')?.body
), [gitCommitInfo, result, error]);
), [commitInfo, result, error]);
const [copied, setCopied] = React.useState(false);

View file

@ -22,7 +22,7 @@ import { msToString } from './utils';
import { AutoChip } from './chip';
import { TestErrorView } from './testErrorView';
import * as icons from './icons';
import { MetadataView, useMetadata } from './metadataView';
import { isMetadataEmpty, MetadataView } from './metadataView';
export const TestFilesView: React.FC<{
tests: TestFileSummary[],
@ -67,13 +67,12 @@ export const TestFilesHeader: React.FC<{
metadataVisible: boolean,
toggleMetadataVisible: () => void,
}> = ({ report, filteredStats, metadataVisible, toggleMetadataVisible }) => {
const metadataEntries = useMetadata();
if (!report)
return null;
return <>
<div className='mx-1' style={{ display: 'flex', marginTop: 10 }}>
<div className='test-file-header-info'>
{metadataEntries.length > 0 && <div className='metadata-toggle' role='button' onClick={toggleMetadataVisible} title={metadataVisible ? 'Hide metadata' : 'Show metadata'}>
{!isMetadataEmpty(report.metadata) && <div className='metadata-toggle' role='button' onClick={toggleMetadataVisible} title={metadataVisible ? 'Hide metadata' : 'Show metadata'}>
{metadataVisible ? icons.downArrow() : icons.rightArrow()}Metadata
</div>}
{report.projectNames.length === 1 && !!report.projectNames[0] && <div data-testid='project-name'>Project: {report.projectNames[0]}</div>}
@ -83,7 +82,7 @@ export const TestFilesHeader: React.FC<{
<div data-testid='overall-time' style={{ color: 'var(--color-fg-subtle)', marginRight: '10px' }}>{report ? new Date(report.startTime).toLocaleString() : ''}</div>
<div data-testid='overall-duration' style={{ color: 'var(--color-fg-subtle)' }}>Total time: {msToString(report.duration ?? 0)}</div>
</div>
{metadataVisible && <MetadataView/>}
{metadataVisible && <MetadataView metadata={report.metadata}/>}
{!!report.errors.length && <AutoChip header='Errors' dataTestId='report-errors'>
{report.errors.map((error, index) => <TestErrorView key={'test-report-error-message-' + index} error={error}></TestErrorView>)}
</AutoChip>}

View file

@ -9267,14 +9267,15 @@ export interface BrowserContext {
/**
* Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB
* snapshot.
*
* **NOTE** IndexedDBs with typed arrays are currently not supported.
*
* @param options
*/
storageState(options?: {
/**
* Defaults to `true`. Set to `false` to omit IndexedDB from snapshot.
* Set to `true` to include IndexedDB in the storage state snapshot. If your application uses IndexedDB to store
* authentication tokens, like Firebase Authentication, enable this.
*
* **NOTE** IndexedDBs with typed arrays are currently not supported.
*
*/
indexedDB?: boolean;
@ -9316,49 +9317,7 @@ export interface BrowserContext {
value: string;
}>;
indexedDB: Array<{
name: string;
version: number;
stores: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
unique: boolean;
multiEntry: boolean;
}>;
records: Array<{
key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value?: Object;
/**
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
*/
valueEncoded?: Object;
}>;
}>;
}>;
indexedDB: Array<unknown>;
}>;
}>;
@ -9741,12 +9700,6 @@ export interface Browser {
*/
acceptDownloads?: boolean;
/**
* An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By
* default, response object is returned for all status codes.
*/
apiRequestFailsOnErrorStatus?: boolean;
/**
* When using [page.goto(url[, options])](https://playwright.dev/docs/api/class-page#page-goto),
* [page.route(url, handler[, options])](https://playwright.dev/docs/api/class-page#page-route),
@ -10135,55 +10088,7 @@ export interface Browser {
/**
* indexedDB to set for context
*/
indexedDB?: Array<{
/**
* database name
*/
name: string;
/**
* database version
*/
version: number;
stores: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
unique: boolean;
multiEntry: boolean;
}>;
records: Array<{
key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value?: Object;
/**
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
*/
valueEncoded?: Object;
}>;
}>;
}>;
indexedDB?: Array<unknown>;
}>;
};
@ -13224,6 +13129,11 @@ export interface Locator {
* `<article><div>Playwright</div></article>`.
*/
hasText?: string|RegExp;
/**
* Only matches visible or invisible elements.
*/
visible?: boolean;
}): Locator;
/**
@ -14426,7 +14336,8 @@ export interface Locator {
}): Promise<void>;
/**
* Perform a tap gesture on the element matching the locator.
* Perform a tap gesture on the element matching the locator. For examples of emulating other gestures by manually
* dispatching touch events, see the [emulating legacy touch events](https://playwright.dev/docs/touch-events) page.
*
* **Details**
*
@ -14812,12 +14723,6 @@ export interface BrowserType<Unused = {}> {
*/
acceptDownloads?: boolean;
/**
* An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By
* default, response object is returned for all status codes.
*/
apiRequestFailsOnErrorStatus?: boolean;
/**
* **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality.
*
@ -16708,12 +16613,6 @@ export interface AndroidDevice {
*/
acceptDownloads?: boolean;
/**
* An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By
* default, response object is returned for all status codes.
*/
apiRequestFailsOnErrorStatus?: boolean;
/**
* **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality.
*
@ -17560,12 +17459,6 @@ export interface APIRequest {
* @param options
*/
newContext(options?: {
/**
* An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By
* default, response object is returned for all status codes.
*/
apiRequestFailsOnErrorStatus?: boolean;
/**
* Methods like
* [apiRequestContext.get(url[, options])](https://playwright.dev/docs/api/class-apirequestcontext#api-request-context-get)
@ -17641,6 +17534,12 @@ export interface APIRequest {
*/
extraHTTPHeaders?: { [key: string]: string; };
/**
* Whether to throw on response codes other than 2xx and 3xx. By default response object is returned for all status
* codes.
*/
failOnStatusCode?: boolean;
/**
* Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no
* origin is specified, the username and password are sent to any servers upon unauthorized responses.
@ -17742,55 +17641,7 @@ export interface APIRequest {
/**
* indexedDB to set for context
*/
indexedDB?: Array<{
/**
* database name
*/
name: string;
/**
* database version
*/
version: number;
stores: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
unique: boolean;
multiEntry: boolean;
}>;
records: Array<{
key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value?: Object;
/**
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
*/
valueEncoded?: Object;
}>;
}>;
}>;
indexedDB?: Array<unknown>;
}>;
};
@ -18564,7 +18415,7 @@ export interface APIRequestContext {
*/
storageState(options?: {
/**
* Defaults to `true`. Set to `false` to omit IndexedDB from snapshot.
* Set to `true` to include IndexedDB in the storage state snapshot.
*/
indexedDB?: boolean;
@ -18606,49 +18457,7 @@ export interface APIRequestContext {
value: string;
}>;
indexedDB: Array<{
name: string;
version: number;
stores: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
unique: boolean;
multiEntry: boolean;
}>;
records: Array<{
key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value?: Object;
/**
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
*/
valueEncoded?: Object;
}>;
}>;
}>;
indexedDB: Array<unknown>;
}>;
}>;
@ -21310,6 +21119,9 @@ export interface Selectors {
/**
* The Touchscreen class operates in main-frame CSS pixels relative to the top-left corner of the viewport. Methods on
* the touchscreen can only be used in browser contexts that have been initialized with `hasTouch` set to true.
*
* This class is limited to emulating tap gestures. For examples of other gestures simulated by manually dispatching
* touch events, see the [emulating legacy touch events](https://playwright.dev/docs/touch-events) page.
*/
export interface Touchscreen {
/**
@ -22142,12 +21954,6 @@ export interface BrowserContextOptions {
*/
acceptDownloads?: boolean;
/**
* An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By
* default, response object is returned for all status codes.
*/
apiRequestFailsOnErrorStatus?: boolean;
/**
* When using [page.goto(url[, options])](https://playwright.dev/docs/api/class-page#page-goto),
* [page.route(url, handler[, options])](https://playwright.dev/docs/api/class-page#page-route),
@ -22503,55 +22309,7 @@ export interface BrowserContextOptions {
/**
* indexedDB to set for context
*/
indexedDB?: Array<{
/**
* database name
*/
name: string;
/**
* database version
*/
version: number;
stores: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
unique: boolean;
multiEntry: boolean;
}>;
records: Array<{
key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value?: Object;
/**
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
*/
valueEncoded?: Object;
}>;
}>;
}>;
indexedDB?: Array<unknown>;
}>;
};

View file

@ -3,15 +3,15 @@
"browsers": [
{
"name": "chromium",
"revision": "1160",
"revision": "1161",
"installByDefault": true,
"browserVersion": "134.0.6998.23"
"browserVersion": "134.0.6998.35"
},
{
"name": "chromium-headless-shell",
"revision": "1160",
"revision": "1161",
"installByDefault": true,
"browserVersion": "134.0.6998.23"
"browserVersion": "134.0.6998.35"
},
{
"name": "chromium-tip-of-tree",

View file

@ -510,7 +510,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
async function prepareStorageState(platform: Platform, options: BrowserContextOptions): Promise<channels.BrowserNewContextParams['storageState']> {
if (typeof options.storageState !== 'string')
return options.storageState;
return options.storageState as any;
try {
return JSON.parse(await platform.fs().promises.readFile(options.storageState, 'utf8'));
} catch (e) {

View file

@ -338,16 +338,18 @@ export class APIResponse implements api.APIResponse {
}
async body(): Promise<Buffer> {
try {
const result = await this._request._channel.fetchResponseBody({ fetchUid: this._fetchUid() });
if (result.binary === undefined)
throw new Error('Response has been disposed');
return result.binary;
} catch (e) {
if (isTargetClosedError(e))
throw new Error('Response has been disposed');
throw e;
}
return await this._request._wrapApiCall(async () => {
try {
const result = await this._request._channel.fetchResponseBody({ fetchUid: this._fetchUid() });
if (result.binary === undefined)
throw new Error('Response has been disposed');
return result.binary;
} catch (e) {
if (isTargetClosedError(e))
throw new Error('Response has been disposed');
throw e;
}
}, true);
}
async text(): Promise<string> {

View file

@ -35,6 +35,7 @@ export type LocatorOptions = {
hasNotText?: string | RegExp;
has?: Locator;
hasNot?: Locator;
visible?: boolean;
};
export class Locator implements api.Locator {
@ -65,6 +66,9 @@ export class Locator implements api.Locator {
this._selector += ` >> internal:has-not=` + JSON.stringify(locator._selector);
}
if (options?.visible !== undefined)
this._selector += ` >> visible=${options.visible ? 'true' : 'false'}`;
if (this._frame._platform.inspectCustom)
(this as any)[this._frame._platform.inspectCustom] = () => this._inspect();
}
@ -150,7 +154,7 @@ export class Locator implements api.Locator {
return await this._frame._highlight(this._selector);
}
locator(selectorOrLocator: string | Locator, options?: LocatorOptions): Locator {
locator(selectorOrLocator: string | Locator, options?: Omit<LocatorOptions, 'visible'>): Locator {
if (isString(selectorOrLocator))
return new Locator(this._frame, this._selector + ' >> ' + selectorOrLocator, options);
if (selectorOrLocator._frame !== this._frame)

View file

@ -37,11 +37,11 @@ export type SelectOptionOptions = { force?: boolean, timeout?: number };
export type FilePayload = { name: string, mimeType: string, buffer: Buffer };
export type StorageState = {
cookies: channels.NetworkCookie[],
origins: channels.OriginStorage[],
origins: (Omit<channels.OriginStorage, 'indexedDB'> & { indexedDB: unknown[] })[],
};
export type SetStorageState = {
cookies?: channels.SetNetworkCookie[],
origins?: channels.SetOriginStorage[]
origins?: (Omit<channels.SetOriginStorage, 'indexedDB'> & { indexedDB?: unknown[] })[]
};
export type LifecycleEvent = channels.LifecycleEvent;

View file

@ -370,7 +370,7 @@ scheme.PlaywrightNewRequestParams = tObject({
userAgent: tOptional(tString),
ignoreHTTPSErrors: tOptional(tBoolean),
extraHTTPHeaders: tOptional(tArray(tType('NameValue'))),
apiRequestFailsOnErrorStatus: tOptional(tBoolean),
failOnStatusCode: tOptional(tBoolean),
clientCertificates: tOptional(tArray(tObject({
origin: tString,
cert: tOptional(tBinary),
@ -600,7 +600,6 @@ scheme.BrowserTypeLaunchPersistentContextParams = tObject({
})),
permissions: tOptional(tArray(tString)),
extraHTTPHeaders: tOptional(tArray(tType('NameValue'))),
apiRequestFailsOnErrorStatus: tOptional(tBoolean),
offline: tOptional(tBoolean),
httpCredentials: tOptional(tObject({
username: tString,
@ -688,7 +687,6 @@ scheme.BrowserNewContextParams = tObject({
})),
permissions: tOptional(tArray(tString)),
extraHTTPHeaders: tOptional(tArray(tType('NameValue'))),
apiRequestFailsOnErrorStatus: tOptional(tBoolean),
offline: tOptional(tBoolean),
httpCredentials: tOptional(tObject({
username: tString,
@ -759,7 +757,6 @@ scheme.BrowserNewContextForReuseParams = tObject({
})),
permissions: tOptional(tArray(tString)),
extraHTTPHeaders: tOptional(tArray(tType('NameValue'))),
apiRequestFailsOnErrorStatus: tOptional(tBoolean),
offline: tOptional(tBoolean),
httpCredentials: tOptional(tObject({
username: tString,
@ -2667,7 +2664,6 @@ scheme.AndroidDeviceLaunchBrowserParams = tObject({
})),
permissions: tOptional(tArray(tString)),
extraHTTPHeaders: tOptional(tArray(tType('NameValue'))),
apiRequestFailsOnErrorStatus: tOptional(tBoolean),
offline: tOptional(tBoolean),
httpCredentials: tOptional(tObject({
username: tString,

View file

@ -61,7 +61,7 @@ export class BidiExecutionContext implements js.ExecutionContextDelegate {
throw new js.JavaScriptErrorInEvaluate('Unexpected response type: ' + JSON.stringify(response));
}
async rawEvaluateHandle(expression: string): Promise<js.ObjectId> {
async rawEvaluateHandle(context: js.ExecutionContext, expression: string): Promise<js.JSHandle> {
const response = await this._session.send('script.evaluate', {
expression,
target: this._target,
@ -72,7 +72,7 @@ export class BidiExecutionContext implements js.ExecutionContextDelegate {
});
if (response.type === 'success') {
if ('handle' in response.result)
return response.result.handle!;
return createHandle(context, response.result);
throw new js.JavaScriptErrorInEvaluate('Cannot get handle: ' + JSON.stringify(response.result));
}
if (response.type === 'exception')
@ -80,14 +80,14 @@ export class BidiExecutionContext implements js.ExecutionContextDelegate {
throw new js.JavaScriptErrorInEvaluate('Unexpected response type: ' + JSON.stringify(response));
}
async evaluateWithArguments(functionDeclaration: string, returnByValue: boolean, utilityScript: js.JSHandle<any>, values: any[], objectIds: string[]): Promise<any> {
async evaluateWithArguments(functionDeclaration: string, returnByValue: boolean, utilityScript: js.JSHandle, values: any[], handles: js.JSHandle[]): Promise<any> {
const response = await this._session.send('script.callFunction', {
functionDeclaration,
target: this._target,
arguments: [
{ handle: utilityScript._objectId! },
...values.map(BidiSerializer.serialize),
...objectIds.map(handle => ({ handle })),
...handles.map(handle => ({ handle: handle._objectId! })),
],
resultOwnership: returnByValue ? undefined : bidi.Script.ResultOwnership.Root, // Necessary for the handle to be returned.
serializationOptions: returnByValue ? {} : { maxObjectDepth: 0, maxDomDepth: 0 },
@ -121,10 +121,12 @@ export class BidiExecutionContext implements js.ExecutionContextDelegate {
return map;
}
async releaseHandle(objectId: js.ObjectId): Promise<void> {
async releaseHandle(handle: js.JSHandle): Promise<void> {
if (!handle._objectId)
return;
await this._session.send('script.disown', {
target: this._target,
handles: [objectId],
handles: [handle._objectId],
});
}

View file

@ -79,9 +79,6 @@ export class RawMouseImpl implements input.RawMouse {
}
async move(x: number, y: number, button: types.MouseButton | 'none', buttons: Set<types.MouseButton>, modifiers: Set<types.KeyboardModifier>, forClick: boolean): Promise<void> {
// Bidi throws when x/y are not integers.
x = Math.floor(x);
y = Math.floor(y);
await this._performActions([{ type: 'pointerMove', x, y }]);
}

View file

@ -511,7 +511,7 @@ export abstract class BrowserContext extends SdkObject {
this._origins.add(origin);
}
async storageState(indexedDB = true): Promise<channels.BrowserContextStorageStateResult> {
async storageState(indexedDB = false): Promise<channels.BrowserContextStorageStateResult> {
const result: channels.BrowserContextStorageStateResult = {
cookies: await this.cookies(),
origins: []

View file

@ -46,24 +46,24 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
return remoteObject.value;
}
async rawEvaluateHandle(expression: string): Promise<js.ObjectId> {
async rawEvaluateHandle(context: js.ExecutionContext, expression: string): Promise<js.JSHandle> {
const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.evaluate', {
expression,
contextId: this._contextId,
}).catch(rewriteError);
if (exceptionDetails)
throw new js.JavaScriptErrorInEvaluate(getExceptionMessage(exceptionDetails));
return remoteObject.objectId!;
return createHandle(context, remoteObject);
}
async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle<any>, values: any[], objectIds: string[]): Promise<any> {
async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle, values: any[], handles: js.JSHandle[]): Promise<any> {
const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.callFunctionOn', {
functionDeclaration: expression,
objectId: utilityScript._objectId,
arguments: [
{ objectId: utilityScript._objectId },
...values.map(value => ({ value })),
...objectIds.map(objectId => ({ objectId })),
...handles.map(handle => ({ objectId: handle._objectId! })),
],
returnByValue,
awaitPromise: true,
@ -88,8 +88,10 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
return result;
}
async releaseHandle(objectId: js.ObjectId): Promise<void> {
await releaseObject(this._client, objectId);
async releaseHandle(handle: js.JSHandle): Promise<void> {
if (!handle._objectId)
return;
await releaseObject(this._client, handle._objectId);
}
}

View file

@ -110,7 +110,7 @@
"defaultBrowserType": "webkit"
},
"Galaxy S5": {
"userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 360,
"height": 640
@ -121,7 +121,7 @@
"defaultBrowserType": "chromium"
},
"Galaxy S5 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 640,
"height": 360
@ -132,7 +132,7 @@
"defaultBrowserType": "chromium"
},
"Galaxy S8": {
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 360,
"height": 740
@ -143,7 +143,7 @@
"defaultBrowserType": "chromium"
},
"Galaxy S8 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 740,
"height": 360
@ -154,7 +154,7 @@
"defaultBrowserType": "chromium"
},
"Galaxy S9+": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 320,
"height": 658
@ -165,7 +165,7 @@
"defaultBrowserType": "chromium"
},
"Galaxy S9+ landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 658,
"height": 320
@ -176,7 +176,7 @@
"defaultBrowserType": "chromium"
},
"Galaxy Tab S4": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Safari/537.36",
"viewport": {
"width": 712,
"height": 1138
@ -187,7 +187,7 @@
"defaultBrowserType": "chromium"
},
"Galaxy Tab S4 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Safari/537.36",
"viewport": {
"width": 1138,
"height": 712
@ -1098,7 +1098,7 @@
"defaultBrowserType": "webkit"
},
"LG Optimus L70": {
"userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/134.0.6998.23 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/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 384,
"height": 640
@ -1109,7 +1109,7 @@
"defaultBrowserType": "chromium"
},
"LG Optimus L70 landscape": {
"userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/134.0.6998.23 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/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 640,
"height": 384
@ -1120,7 +1120,7 @@
"defaultBrowserType": "chromium"
},
"Microsoft Lumia 550": {
"userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 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/134.0.6998.35 Mobile Safari/537.36 Edge/14.14263",
"viewport": {
"width": 360,
"height": 640
@ -1131,7 +1131,7 @@
"defaultBrowserType": "chromium"
},
"Microsoft Lumia 550 landscape": {
"userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 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/134.0.6998.35 Mobile Safari/537.36 Edge/14.14263",
"viewport": {
"width": 640,
"height": 360
@ -1142,7 +1142,7 @@
"defaultBrowserType": "chromium"
},
"Microsoft Lumia 950": {
"userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 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/134.0.6998.35 Mobile Safari/537.36 Edge/14.14263",
"viewport": {
"width": 360,
"height": 640
@ -1153,7 +1153,7 @@
"defaultBrowserType": "chromium"
},
"Microsoft Lumia 950 landscape": {
"userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 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/134.0.6998.35 Mobile Safari/537.36 Edge/14.14263",
"viewport": {
"width": 640,
"height": 360
@ -1164,7 +1164,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 10": {
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Safari/537.36",
"viewport": {
"width": 800,
"height": 1280
@ -1175,7 +1175,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 10 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Safari/537.36",
"viewport": {
"width": 1280,
"height": 800
@ -1186,7 +1186,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 4": {
"userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 384,
"height": 640
@ -1197,7 +1197,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 4 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 640,
"height": 384
@ -1208,7 +1208,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 5": {
"userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 360,
"height": 640
@ -1219,7 +1219,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 5 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 640,
"height": 360
@ -1230,7 +1230,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 5X": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 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/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 412,
"height": 732
@ -1241,7 +1241,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 5X landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 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/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 732,
"height": 412
@ -1252,7 +1252,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 6": {
"userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 412,
"height": 732
@ -1263,7 +1263,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 6 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 732,
"height": 412
@ -1274,7 +1274,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 6P": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 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/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 412,
"height": 732
@ -1285,7 +1285,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 6P landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 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/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 732,
"height": 412
@ -1296,7 +1296,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 7": {
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Safari/537.36",
"viewport": {
"width": 600,
"height": 960
@ -1307,7 +1307,7 @@
"defaultBrowserType": "chromium"
},
"Nexus 7 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Safari/537.36",
"viewport": {
"width": 960,
"height": 600
@ -1362,7 +1362,7 @@
"defaultBrowserType": "webkit"
},
"Pixel 2": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 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/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 411,
"height": 731
@ -1373,7 +1373,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 2 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 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/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 731,
"height": 411
@ -1384,7 +1384,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 2 XL": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 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/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 411,
"height": 823
@ -1395,7 +1395,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 2 XL landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 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/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 823,
"height": 411
@ -1406,7 +1406,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 3": {
"userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 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/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 393,
"height": 786
@ -1417,7 +1417,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 3 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 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/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 786,
"height": 393
@ -1428,7 +1428,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 4": {
"userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 353,
"height": 745
@ -1439,7 +1439,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 4 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 745,
"height": 353
@ -1450,7 +1450,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 4a (5G)": {
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36",
"screen": {
"width": 412,
"height": 892
@ -1465,7 +1465,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 4a (5G) landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36",
"screen": {
"height": 892,
"width": 412
@ -1480,7 +1480,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 5": {
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36",
"screen": {
"width": 393,
"height": 851
@ -1495,7 +1495,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 5 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36",
"screen": {
"width": 851,
"height": 393
@ -1510,7 +1510,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 7": {
"userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36",
"screen": {
"width": 412,
"height": 915
@ -1525,7 +1525,7 @@
"defaultBrowserType": "chromium"
},
"Pixel 7 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36",
"screen": {
"width": 915,
"height": 412
@ -1540,7 +1540,7 @@
"defaultBrowserType": "chromium"
},
"Moto G4": {
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 360,
"height": 640
@ -1551,7 +1551,7 @@
"defaultBrowserType": "chromium"
},
"Moto G4 landscape": {
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36",
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36",
"viewport": {
"width": 640,
"height": 360
@ -1562,7 +1562,7 @@
"defaultBrowserType": "chromium"
},
"Desktop Chrome HiDPI": {
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Safari/537.36",
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Safari/537.36",
"screen": {
"width": 1792,
"height": 1120
@ -1577,7 +1577,7 @@
"defaultBrowserType": "chromium"
},
"Desktop Edge HiDPI": {
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Safari/537.36 Edg/134.0.6998.23",
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Safari/537.36 Edg/134.0.6998.35",
"screen": {
"width": 1792,
"height": 1120
@ -1622,7 +1622,7 @@
"defaultBrowserType": "webkit"
},
"Desktop Chrome": {
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Safari/537.36",
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Safari/537.36",
"screen": {
"width": 1920,
"height": 1080
@ -1637,7 +1637,7 @@
"defaultBrowserType": "chromium"
},
"Desktop Edge": {
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Safari/537.36 Edg/134.0.6998.23",
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Safari/537.36 Edg/134.0.6998.35",
"screen": {
"width": 1920,
"height": 1080

View file

@ -105,7 +105,11 @@ export class FrameExecutionContext extends js.ExecutionContext {
);
})();
`;
this._injectedScriptPromise = this.rawEvaluateHandle(source).then(objectId => new js.JSHandle(this, 'object', 'InjectedScript', objectId));
this._injectedScriptPromise = this.rawEvaluateHandle(source)
.then(handle => {
handle._setPreview('InjectedScript');
return handle;
});
}
return this._injectedScriptPromise;
}
@ -118,7 +122,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
declare readonly _objectId: string;
readonly _frame: frames.Frame;
constructor(context: FrameExecutionContext, objectId: js.ObjectId) {
constructor(context: FrameExecutionContext, objectId: string) {
super(context, 'node', undefined, objectId);
this._page = context.frame._page;
this._frame = context.frame;

View file

@ -51,7 +51,7 @@ import type { Readable, TransformCallback } from 'stream';
type FetchRequestOptions = {
userAgent: string;
extraHTTPHeaders?: HeadersArray;
apiRequestFailsOnErrorStatus?: boolean;
failOnStatusCode?: boolean;
httpCredentials?: HTTPCredentials;
proxy?: ProxySettings;
timeoutSettings: TimeoutSettings;
@ -212,7 +212,7 @@ export abstract class APIRequestContext extends SdkObject {
});
const fetchUid = this._storeResponseBody(fetchResponse.body);
this.fetchLog.set(fetchUid, controller.metadata.log);
const failOnStatusCode = params.failOnStatusCode !== undefined ? params.failOnStatusCode : !!defaults.apiRequestFailsOnErrorStatus;
const failOnStatusCode = params.failOnStatusCode !== undefined ? params.failOnStatusCode : !!defaults.failOnStatusCode;
if (failOnStatusCode && (fetchResponse.status < 200 || fetchResponse.status >= 400)) {
let responseText = '';
if (fetchResponse.body.byteLength) {
@ -608,7 +608,7 @@ export class BrowserContextAPIRequestContext extends APIRequestContext {
return {
userAgent: this._context._options.userAgent || this._context._browser.userAgent(),
extraHTTPHeaders: this._context._options.extraHTTPHeaders,
apiRequestFailsOnErrorStatus: this._context._options.apiRequestFailsOnErrorStatus,
failOnStatusCode: undefined,
httpCredentials: this._context._options.httpCredentials,
proxy: this._context._options.proxy || this._context._browser.options.proxy,
timeoutSettings: this._context._timeoutSettings,
@ -660,7 +660,7 @@ export class GlobalAPIRequestContext extends APIRequestContext {
baseURL: options.baseURL,
userAgent: options.userAgent || getUserAgent(),
extraHTTPHeaders: options.extraHTTPHeaders,
apiRequestFailsOnErrorStatus: !!options.apiRequestFailsOnErrorStatus,
failOnStatusCode: !!options.failOnStatusCode,
ignoreHTTPSErrors: !!options.ignoreHTTPSErrors,
httpCredentials: options.httpCredentials,
clientCertificates: options.clientCertificates,
@ -693,7 +693,7 @@ export class GlobalAPIRequestContext extends APIRequestContext {
return this._cookieStore.cookies(url);
}
override async storageState(indexedDB = true): Promise<channels.APIRequestContextStorageStateResult> {
override async storageState(indexedDB = false): Promise<channels.APIRequestContextStorageStateResult> {
return {
cookies: this._cookieStore.allCookies(),
origins: (this._origins || []).map(origin => ({ ...origin, indexedDB: indexedDB ? origin.indexedDB : [] })),

View file

@ -44,23 +44,23 @@ export class FFExecutionContext implements js.ExecutionContextDelegate {
return payload.result!.value;
}
async rawEvaluateHandle(expression: string): Promise<js.ObjectId> {
async rawEvaluateHandle(context: js.ExecutionContext, expression: string): Promise<js.JSHandle> {
const payload = await this._session.send('Runtime.evaluate', {
expression,
returnByValue: false,
executionContextId: this._executionContextId,
}).catch(rewriteError);
checkException(payload.exceptionDetails);
return payload.result!.objectId!;
return createHandle(context, payload.result!);
}
async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle<any>, values: any[], objectIds: string[]): Promise<any> {
async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle, values: any[], handles: js.JSHandle[]): Promise<any> {
const payload = await this._session.send('Runtime.callFunction', {
functionDeclaration: expression,
args: [
{ objectId: utilityScript._objectId, value: undefined },
...values.map(value => ({ value })),
...objectIds.map(objectId => ({ objectId, value: undefined })),
...handles.map(handle => ({ objectId: handle._objectId!, value: undefined })),
],
returnByValue,
executionContextId: this._executionContextId
@ -82,10 +82,12 @@ export class FFExecutionContext implements js.ExecutionContextDelegate {
return result;
}
async releaseHandle(objectId: js.ObjectId): Promise<void> {
async releaseHandle(handle: js.JSHandle): Promise<void> {
if (!handle._objectId)
return;
await this._session.send('Runtime.disposeObject', {
executionContextId: this._executionContextId,
objectId
objectId: handle._objectId,
});
}
}

View file

@ -29,7 +29,7 @@ class Locator {
element: Element | undefined;
elements: Element[] | undefined;
constructor(injectedScript: InjectedScript, selector: string, options?: { hasText?: string | RegExp, hasNotText?: string | RegExp, has?: Locator, hasNot?: Locator }) {
constructor(injectedScript: InjectedScript, selector: string, options?: { hasText?: string | RegExp, hasNotText?: string | RegExp, has?: Locator, hasNot?: Locator, visible?: boolean }) {
if (options?.hasText)
selector += ` >> internal:has-text=${escapeForTextSelector(options.hasText, false)}`;
if (options?.hasNotText)
@ -38,6 +38,8 @@ class Locator {
selector += ` >> internal:has=` + JSON.stringify(options.has[selectorSymbol]);
if (options?.hasNot)
selector += ` >> internal:has-not=` + JSON.stringify(options.hasNot[selectorSymbol]);
if (options?.visible !== undefined)
selector += ` >> visible=${options.visible ? 'true' : 'false'}`;
this[selectorSymbol] = selector;
if (selector) {
const parsed = injectedScript.parseSelector(selector);
@ -46,7 +48,7 @@ class Locator {
}
const selectorBase = selector;
const self = this as any;
self.locator = (selector: string, options?: { hasText?: string | RegExp, has?: Locator }): Locator => {
self.locator = (selector: string, options?: { hasText?: string | RegExp, hasNotText?: string | RegExp, has?: Locator, hasNot?: Locator }): Locator => {
return new Locator(injectedScript, selectorBase ? selectorBase + ' >> ' + selector : selector, options);
};
self.getByTestId = (testId: string): Locator => self.locator(getByTestIdSelector(injectedScript.testIdAttributeNameForStrictErrorAndConsoleCodegen(), testId));
@ -56,7 +58,7 @@ class Locator {
self.getByText = (text: string | RegExp, options?: { exact?: boolean }): Locator => self.locator(getByTextSelector(text, options));
self.getByTitle = (text: string | RegExp, options?: { exact?: boolean }): Locator => self.locator(getByTitleSelector(text, options));
self.getByRole = (role: string, options: ByRoleOptions = {}): Locator => self.locator(getByRoleSelector(role, options));
self.filter = (options?: { hasText?: string | RegExp, has?: Locator }): Locator => new Locator(injectedScript, selector, options);
self.filter = (options?: { hasText?: string | RegExp, hasNotText?: string | RegExp, has?: Locator, hasNot?: Locator, visible?: boolean }): Locator => new Locator(injectedScript, selector, options);
self.first = (): Locator => self.locator('nth=0');
self.last = (): Locator => self.locator('nth=-1');
self.nth = (index: number): Locator => self.locator(`nth=${index}`);

View file

@ -337,7 +337,7 @@ function trimFlatString(s: string): string {
function asFlatString(s: string): string {
// "Flat string" at https://w3c.github.io/accname/#terminology
// Note that non-breaking spaces are preserved.
return s.split('\u00A0').map(chunk => chunk.replace(/\r\n/g, '\n').replace(/\s\s*/g, ' ')).join('\u00A0').trim();
return s.split('\u00A0').map(chunk => chunk.replace(/\r\n/g, '\n').replace(/[\u200b\u00ad]/g, '').replace(/\s\s*/g, ' ')).join('\u00A0').trim();
}
function queryInAriaOwned(element: Element, selector: string): Element[] {

View file

@ -129,10 +129,13 @@ export function source() {
function serialize(value: any, handleSerializer: (value: any) => HandleOrValue, visitorInfo: VisitorInfo): SerializedValue {
if (value && typeof value === 'object') {
// eslint-disable-next-line no-restricted-globals
if (typeof globalThis.Window === 'function' && value instanceof globalThis.Window)
return 'ref: <Window>';
// eslint-disable-next-line no-restricted-globals
if (typeof globalThis.Document === 'function' && value instanceof globalThis.Document)
return 'ref: <Document>';
// eslint-disable-next-line no-restricted-globals
if (typeof globalThis.Node === 'function' && value instanceof globalThis.Node)
return 'ref: <Node>';
}

View file

@ -23,8 +23,6 @@ import { LongStandingScope } from '../utils/isomorphic/manualPromise';
import type * as dom from './dom';
import type { UtilityScript } from './injected/utilityScript';
export type ObjectId = string;
interface TaggedAsJSHandle<T> {
__jshandle: T;
}
@ -49,10 +47,10 @@ export type SmartHandle<T> = T extends Node ? dom.ElementHandle<T> : JSHandle<T>
export interface ExecutionContextDelegate {
rawEvaluateJSON(expression: string): Promise<any>;
rawEvaluateHandle(expression: string): Promise<ObjectId>;
evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: JSHandle<any>, values: any[], objectIds: ObjectId[]): Promise<any>;
rawEvaluateHandle(context: ExecutionContext, expression: string): Promise<JSHandle>;
evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: JSHandle, values: any[], handles: JSHandle[]): Promise<any>;
getProperties(object: JSHandle): Promise<Map<string, JSHandle>>;
releaseHandle(objectId: ObjectId): Promise<void>;
releaseHandle(handle: JSHandle): Promise<void>;
}
export class ExecutionContext extends SdkObject {
@ -79,21 +77,21 @@ export class ExecutionContext extends SdkObject {
return this._raceAgainstContextDestroyed(this.delegate.rawEvaluateJSON(expression));
}
rawEvaluateHandle(expression: string): Promise<ObjectId> {
return this._raceAgainstContextDestroyed(this.delegate.rawEvaluateHandle(expression));
rawEvaluateHandle(expression: string): Promise<JSHandle> {
return this._raceAgainstContextDestroyed(this.delegate.rawEvaluateHandle(this, expression));
}
async evaluateWithArguments(expression: string, returnByValue: boolean, values: any[], objectIds: ObjectId[]): Promise<any> {
async evaluateWithArguments(expression: string, returnByValue: boolean, values: any[], handles: JSHandle[]): Promise<any> {
const utilityScript = await this._utilityScript();
return this._raceAgainstContextDestroyed(this.delegate.evaluateWithArguments(expression, returnByValue, utilityScript, values, objectIds));
return this._raceAgainstContextDestroyed(this.delegate.evaluateWithArguments(expression, returnByValue, utilityScript, values, handles));
}
getProperties(object: JSHandle): Promise<Map<string, JSHandle>> {
return this._raceAgainstContextDestroyed(this.delegate.getProperties(object));
}
releaseHandle(objectId: ObjectId): Promise<void> {
return this.delegate.releaseHandle(objectId);
releaseHandle(handle: JSHandle): Promise<void> {
return this.delegate.releaseHandle(handle);
}
adoptIfNeeded(handle: JSHandle): Promise<JSHandle> | null {
@ -108,7 +106,11 @@ export class ExecutionContext extends SdkObject {
${utilityScriptSource.source}
return new (module.exports.UtilityScript())(${isUnderTest()});
})();`;
this._utilityScriptPromise = this._raceAgainstContextDestroyed(this.delegate.rawEvaluateHandle(source).then(objectId => new JSHandle(this, 'object', 'UtilityScript', objectId)));
this._utilityScriptPromise = this._raceAgainstContextDestroyed(this.delegate.rawEvaluateHandle(this, source))
.then(handle => {
handle._setPreview('UtilityScript');
return handle;
});
}
return this._utilityScriptPromise;
}
@ -122,13 +124,13 @@ export class JSHandle<T = any> extends SdkObject {
__jshandle: T = true as any;
readonly _context: ExecutionContext;
_disposed = false;
readonly _objectId: ObjectId | undefined;
readonly _objectId: string | undefined;
readonly _value: any;
private _objectType: string;
protected _preview: string;
private _previewCallback: ((preview: string) => void) | undefined;
constructor(context: ExecutionContext, type: string, preview: string | undefined, objectId?: ObjectId, value?: any) {
constructor(context: ExecutionContext, type: string, preview: string | undefined, objectId?: string, value?: any) {
super(context, 'handle');
this._context = context;
this._objectId = objectId;
@ -185,7 +187,7 @@ export class JSHandle<T = any> extends SdkObject {
if (!this._objectId)
return this._value;
const script = `(utilityScript, ...args) => utilityScript.jsonValue(...args)`;
return this._context.evaluateWithArguments(script, true, [true], [this._objectId]);
return this._context.evaluateWithArguments(script, true, [true], [this]);
}
asElement(): dom.ElementHandle | null {
@ -197,7 +199,7 @@ export class JSHandle<T = any> extends SdkObject {
return;
this._disposed = true;
if (this._objectId) {
this._context.releaseHandle(this._objectId).catch(e => {});
this._context.releaseHandle(this).catch(e => {});
if ((globalThis as any).leakedJSHandles)
(globalThis as any).leakedJSHandles.delete(this);
}
@ -254,11 +256,11 @@ export async function evaluateExpression(context: ExecutionContext, expression:
return { fallThrough: handle };
}));
const utilityScriptObjectIds: ObjectId[] = [];
const utilityScriptObjects: JSHandle[] = [];
for (const handle of await Promise.all(handles)) {
if (handle._context !== context)
throw new JavaScriptErrorInEvaluate('JSHandles can be evaluated only in the context they were created!');
utilityScriptObjectIds.push(handle._objectId!);
utilityScriptObjects.push(handle);
}
// See UtilityScript for arguments.
@ -266,7 +268,7 @@ export async function evaluateExpression(context: ExecutionContext, expression:
const script = `(utilityScript, ...args) => utilityScript.evaluate(...args)`;
try {
return await context.evaluateWithArguments(script, options.returnByValue || false, utilityScriptValues, utilityScriptObjectIds);
return await context.evaluateWithArguments(script, options.returnByValue || false, utilityScriptValues, utilityScriptObjects);
} finally {
toDispose.map(handlePromise => handlePromise.then(handle => handle.dispose()));
}

View file

@ -48,7 +48,7 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
}
}
async rawEvaluateHandle(expression: string): Promise<js.ObjectId> {
async rawEvaluateHandle(context: js.ExecutionContext, expression: string): Promise<js.JSHandle> {
try {
const response = await this._session.send('Runtime.evaluate', {
expression,
@ -57,13 +57,13 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
});
if (response.wasThrown)
throw new js.JavaScriptErrorInEvaluate(response.result.description);
return response.result.objectId!;
return createHandle(context, response.result);
} catch (error) {
throw rewriteError(error);
}
}
async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle<any>, values: any[], objectIds: string[]): Promise<any> {
async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle<any>, values: any[], handles: js.JSHandle[]): Promise<any> {
try {
const response = await this._session.send('Runtime.callFunctionOn', {
functionDeclaration: expression,
@ -71,7 +71,7 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
arguments: [
{ objectId: utilityScript._objectId },
...values.map(value => ({ value })),
...objectIds.map(objectId => ({ objectId })),
...handles.map(handle => ({ objectId: handle._objectId! })),
],
returnByValue,
emulateUserGesture: true,
@ -101,8 +101,10 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
return result;
}
async releaseHandle(objectId: js.ObjectId): Promise<void> {
await this._session.send('Runtime.releaseObject', { objectId });
async releaseHandle(handle: js.JSHandle): Promise<void> {
if (!handle._objectId)
return;
await this._session.send('Runtime.releaseObject', { objectId: handle._objectId });
}
}

View file

@ -220,7 +220,8 @@ export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: yaml
const emptyFragment: AriaTemplateRoleNode = { kind: 'role', role: 'fragment' };
function normalizeWhitespace(text: string) {
return text.replace(/[\r\n\s\t]+/g, ' ').trim();
// TODO: why is this different from normalizeWhitespace in stringUtils.ts?
return text.replace(/[\u200b\u00ad]/g, '').replace(/[\r\n\s\t]+/g, ' ').trim();
}
export function valueOrRegex(value: string): string | AriaRegex {

View file

@ -21,7 +21,7 @@ import type { NestedSelectorBody } from './selectorParser';
import type { ParsedSelector } from './selectorParser';
export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'has-text' | 'has-not-text' | 'has' | 'hasNot' | 'frame' | 'frame-locator' | 'and' | 'or' | 'chain';
export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'visible' | 'has-text' | 'has-not-text' | 'has' | 'hasNot' | 'frame' | 'frame-locator' | 'and' | 'or' | 'chain';
export type LocatorBase = 'page' | 'locator' | 'frame-locator';
export type Quote = '\'' | '"' | '`';
@ -68,6 +68,10 @@ function innerAsLocators(factory: LocatorFactory, parsed: ParsedSelector, isFram
tokens.push([factory.generateLocator(base, 'nth', part.body as string)]);
continue;
}
if (part.name === 'visible') {
tokens.push([factory.generateLocator(base, 'visible', part.body as string), factory.generateLocator(base, 'default', `visible=${part.body}`)]);
continue;
}
if (part.name === 'internal:text') {
const { exact, text } = detectExact(part.body as string);
tokens.push([factory.generateLocator(base, 'text', text, { exact })]);
@ -275,6 +279,8 @@ export class JavaScriptLocatorFactory implements LocatorFactory {
return `first()`;
case 'last':
return `last()`;
case 'visible':
return `filter({ visible: ${body === 'true' ? 'true' : 'false'} })`;
case 'role':
const attrs: string[] = [];
if (isRegExp(options.name)) {
@ -369,6 +375,8 @@ export class PythonLocatorFactory implements LocatorFactory {
return `first`;
case 'last':
return `last`;
case 'visible':
return `filter(visible=${body === 'true' ? 'True' : 'False'})`;
case 'role':
const attrs: string[] = [];
if (isRegExp(options.name)) {
@ -476,6 +484,8 @@ export class JavaLocatorFactory implements LocatorFactory {
return `first()`;
case 'last':
return `last()`;
case 'visible':
return `filter(new ${clazz}.FilterOptions().setVisible(${body === 'true' ? 'true' : 'false'}))`;
case 'role':
const attrs: string[] = [];
if (isRegExp(options.name)) {
@ -573,6 +583,8 @@ export class CSharpLocatorFactory implements LocatorFactory {
return `First`;
case 'last':
return `Last`;
case 'visible':
return `Filter(new() { Visible = ${body === 'true' ? 'true' : 'false'} })`;
case 'role':
const attrs: string[] = [];
if (isRegExp(options.name)) {

View file

@ -170,6 +170,8 @@ function transform(template: string, params: TemplateParams, testIdAttributeName
.replace(/first(\(\))?/g, 'nth=0')
.replace(/last(\(\))?/g, 'nth=-1')
.replace(/nth\(([^)]+)\)/g, 'nth=$1')
.replace(/filter\(,?visible=true\)/g, 'visible=true')
.replace(/filter\(,?visible=false\)/g, 'visible=false')
.replace(/filter\(,?hastext=([^)]+)\)/g, 'internal:has-text=$1')
.replace(/filter\(,?hasnottext=([^)]+)\)/g, 'internal:has-not-text=$1')
.replace(/filter\(,?has2=([^)]+)\)/g, 'internal:has=$1')

View file

@ -83,7 +83,7 @@ export function cacheNormalizedWhitespaces() {
export function normalizeWhiteSpace(text: string): string {
let result = normalizedWhitespaceCache?.get(text);
if (result === undefined) {
result = text.replace(/\u200b/g, '').trim().replace(/\s+/g, ' ');
result = text.replace(/[\u200b\u00ad]/g, '').trim().replace(/\s+/g, ' ');
normalizedWhitespaceCache?.set(text, result);
}
return result;

View file

@ -9267,14 +9267,15 @@ export interface BrowserContext {
/**
* Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB
* snapshot.
*
* **NOTE** IndexedDBs with typed arrays are currently not supported.
*
* @param options
*/
storageState(options?: {
/**
* Defaults to `true`. Set to `false` to omit IndexedDB from snapshot.
* Set to `true` to include IndexedDB in the storage state snapshot. If your application uses IndexedDB to store
* authentication tokens, like Firebase Authentication, enable this.
*
* **NOTE** IndexedDBs with typed arrays are currently not supported.
*
*/
indexedDB?: boolean;
@ -9316,49 +9317,7 @@ export interface BrowserContext {
value: string;
}>;
indexedDB: Array<{
name: string;
version: number;
stores: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
unique: boolean;
multiEntry: boolean;
}>;
records: Array<{
key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value?: Object;
/**
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
*/
valueEncoded?: Object;
}>;
}>;
}>;
indexedDB: Array<unknown>;
}>;
}>;
@ -9741,12 +9700,6 @@ export interface Browser {
*/
acceptDownloads?: boolean;
/**
* An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By
* default, response object is returned for all status codes.
*/
apiRequestFailsOnErrorStatus?: boolean;
/**
* When using [page.goto(url[, options])](https://playwright.dev/docs/api/class-page#page-goto),
* [page.route(url, handler[, options])](https://playwright.dev/docs/api/class-page#page-route),
@ -10135,55 +10088,7 @@ export interface Browser {
/**
* indexedDB to set for context
*/
indexedDB?: Array<{
/**
* database name
*/
name: string;
/**
* database version
*/
version: number;
stores: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
unique: boolean;
multiEntry: boolean;
}>;
records: Array<{
key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value?: Object;
/**
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
*/
valueEncoded?: Object;
}>;
}>;
}>;
indexedDB?: Array<unknown>;
}>;
};
@ -13224,6 +13129,11 @@ export interface Locator {
* `<article><div>Playwright</div></article>`.
*/
hasText?: string|RegExp;
/**
* Only matches visible or invisible elements.
*/
visible?: boolean;
}): Locator;
/**
@ -14426,7 +14336,8 @@ export interface Locator {
}): Promise<void>;
/**
* Perform a tap gesture on the element matching the locator.
* Perform a tap gesture on the element matching the locator. For examples of emulating other gestures by manually
* dispatching touch events, see the [emulating legacy touch events](https://playwright.dev/docs/touch-events) page.
*
* **Details**
*
@ -14812,12 +14723,6 @@ export interface BrowserType<Unused = {}> {
*/
acceptDownloads?: boolean;
/**
* An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By
* default, response object is returned for all status codes.
*/
apiRequestFailsOnErrorStatus?: boolean;
/**
* **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality.
*
@ -16708,12 +16613,6 @@ export interface AndroidDevice {
*/
acceptDownloads?: boolean;
/**
* An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By
* default, response object is returned for all status codes.
*/
apiRequestFailsOnErrorStatus?: boolean;
/**
* **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality.
*
@ -17560,12 +17459,6 @@ export interface APIRequest {
* @param options
*/
newContext(options?: {
/**
* An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By
* default, response object is returned for all status codes.
*/
apiRequestFailsOnErrorStatus?: boolean;
/**
* Methods like
* [apiRequestContext.get(url[, options])](https://playwright.dev/docs/api/class-apirequestcontext#api-request-context-get)
@ -17641,6 +17534,12 @@ export interface APIRequest {
*/
extraHTTPHeaders?: { [key: string]: string; };
/**
* Whether to throw on response codes other than 2xx and 3xx. By default response object is returned for all status
* codes.
*/
failOnStatusCode?: boolean;
/**
* Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no
* origin is specified, the username and password are sent to any servers upon unauthorized responses.
@ -17742,55 +17641,7 @@ export interface APIRequest {
/**
* indexedDB to set for context
*/
indexedDB?: Array<{
/**
* database name
*/
name: string;
/**
* database version
*/
version: number;
stores: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
unique: boolean;
multiEntry: boolean;
}>;
records: Array<{
key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value?: Object;
/**
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
*/
valueEncoded?: Object;
}>;
}>;
}>;
indexedDB?: Array<unknown>;
}>;
};
@ -18564,7 +18415,7 @@ export interface APIRequestContext {
*/
storageState(options?: {
/**
* Defaults to `true`. Set to `false` to omit IndexedDB from snapshot.
* Set to `true` to include IndexedDB in the storage state snapshot.
*/
indexedDB?: boolean;
@ -18606,49 +18457,7 @@ export interface APIRequestContext {
value: string;
}>;
indexedDB: Array<{
name: string;
version: number;
stores: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
unique: boolean;
multiEntry: boolean;
}>;
records: Array<{
key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value?: Object;
/**
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
*/
valueEncoded?: Object;
}>;
}>;
}>;
indexedDB: Array<unknown>;
}>;
}>;
@ -21310,6 +21119,9 @@ export interface Selectors {
/**
* The Touchscreen class operates in main-frame CSS pixels relative to the top-left corner of the viewport. Methods on
* the touchscreen can only be used in browser contexts that have been initialized with `hasTouch` set to true.
*
* This class is limited to emulating tap gestures. For examples of other gestures simulated by manually dispatching
* touch events, see the [emulating legacy touch events](https://playwright.dev/docs/touch-events) page.
*/
export interface Touchscreen {
/**
@ -22142,12 +21954,6 @@ export interface BrowserContextOptions {
*/
acceptDownloads?: boolean;
/**
* An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By
* default, response object is returned for all status codes.
*/
apiRequestFailsOnErrorStatus?: boolean;
/**
* When using [page.goto(url[, options])](https://playwright.dev/docs/api/class-page#page-goto),
* [page.route(url, handler[, options])](https://playwright.dev/docs/api/class-page#page-route),
@ -22503,55 +22309,7 @@ export interface BrowserContextOptions {
/**
* indexedDB to set for context
*/
indexedDB?: Array<{
/**
* database name
*/
name: string;
/**
* database version
*/
version: number;
stores: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
autoIncrement: boolean;
indexes: Array<{
name: string;
keyPath?: string;
keyPathArray?: Array<string>;
unique: boolean;
multiEntry: boolean;
}>;
records: Array<{
key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value?: Object;
/**
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
*/
valueEncoded?: Object;
}>;
}>;
}>;
indexedDB?: Array<unknown>;
}>;
};

View file

@ -48,7 +48,6 @@ export class FullConfigInternal {
readonly plugins: TestRunnerPluginRegistration[];
readonly projects: FullProjectInternal[] = [];
readonly singleTSConfigPath?: string;
readonly populateGitInfo: boolean;
readonly recreateWorkerAfterFailure: boolean;
cliArgs: string[] = [];
cliGrep: string | undefined;
@ -79,7 +78,6 @@ export class FullConfigInternal {
const privateConfiguration = (userConfig as any)['@playwright/test'];
this.plugins = (privateConfiguration?.plugins || []).map((p: any) => ({ factory: p }));
this.singleTSConfigPath = pathResolve(configDir, userConfig.tsconfig);
this.populateGitInfo = takeFirst(userConfig.populateGitInfo, defaultPopulateGitInfo);
this.globalSetups = (Array.isArray(userConfig.globalSetup) ? userConfig.globalSetup : [userConfig.globalSetup]).map(s => resolveScript(s, configDir)).filter(script => script !== undefined);
this.globalTeardowns = (Array.isArray(userConfig.globalTeardown) ? userConfig.globalTeardown : [userConfig.globalTeardown]).map(s => resolveScript(s, configDir)).filter(script => script !== undefined);
@ -97,14 +95,14 @@ export class FullConfigInternal {
fullyParallel: takeFirst(configCLIOverrides.fullyParallel, userConfig.fullyParallel, false),
globalSetup: this.globalSetups[0] ?? null,
globalTeardown: this.globalTeardowns[0] ?? null,
globalTimeout: takeFirst(configCLIOverrides.globalTimeout, userConfig.globalTimeout, 0),
globalTimeout: takeFirst(configCLIOverrides.debug ? 0 : undefined, configCLIOverrides.globalTimeout, userConfig.globalTimeout, 0),
grep: takeFirst(userConfig.grep, defaultGrep),
grepInvert: takeFirst(userConfig.grepInvert, null),
maxFailures: takeFirst(configCLIOverrides.debug ? 1 : undefined, configCLIOverrides.maxFailures, userConfig.maxFailures, 0),
metadata: userConfig.metadata,
preserveOutput: takeFirst(userConfig.preserveOutput, 'always'),
reporter: takeFirst(configCLIOverrides.reporter, resolveReporters(userConfig.reporter, configDir), [[defaultReporter]]),
reportSlowTests: takeFirst(userConfig.reportSlowTests, { max: 5, threshold: 15000 }),
reportSlowTests: takeFirst(userConfig.reportSlowTests, { max: 5, threshold: 300_000 /* 5 minutes */ }),
quiet: takeFirst(configCLIOverrides.quiet, userConfig.quiet, false),
projects: [],
shard: takeFirst(configCLIOverrides.shard, userConfig.shard, null),
@ -304,7 +302,6 @@ function resolveScript(id: string | undefined, rootDir: string): string | undefi
export const defaultGrep = /.*/;
export const defaultReporter = process.env.CI ? 'dot' : 'list';
const defaultPopulateGitInfo = process.env.GITHUB_ACTIONS === 'true';
const configInternalSymbol = Symbol('configInternalSymbol');

View file

@ -628,7 +628,7 @@ class ArtifactsRecorder {
await page.screenshot({ ...screenshotOptions, timeout: 5000, path, caret: 'initial' });
});
this._pageSnapshotRecorder = new SnapshotRecorder(this, pageSnapshot, 'pageSnapshot', 'text/plain', '.ariasnapshot', async (page, path) => {
this._pageSnapshotRecorder = new SnapshotRecorder(this, pageSnapshot, 'pageSnapshot', 'text/plain', '.snapshot.yml', async (page, path) => {
const ariaSnapshot = await page.locator('body').ariaSnapshot({ timeout: 5000 });
await fs.promises.writeFile(path, ariaSnapshot);
});

View file

@ -605,7 +605,7 @@ export const baseFullConfig: reporterTypes.FullConfig = {
preserveOutput: 'always',
projects: [],
reporter: [[process.env.CI ? 'dot' : 'list']],
reportSlowTests: { max: 5, threshold: 15000 },
reportSlowTests: { max: 5, threshold: 300_000 /* 5 minutes */ },
configFile: '',
rootDir: '',
quiet: false,

View file

@ -14,17 +14,42 @@
* limitations under the License.
*/
export interface GitCommitInfo {
'revision.id'?: string;
'revision.author'?: string;
'revision.email'?: string;
'revision.subject'?: string;
'revision.timestamp'?: number | Date;
'revision.link'?: string;
'revision.diff'?: string;
'pull.link'?: string;
'pull.diff'?: string;
'pull.base'?: string;
'pull.title'?: string;
'ci.link'?: string;
}
export type GitCommitInfo = {
shortHash: string;
hash: string;
subject: string;
body: string;
author: {
name: string;
email: string;
time: number;
};
committer: {
name: string;
email: string
time: number;
};
branch: string;
};
export type CIInfo = {
commitHref: string;
prHref?: string;
prTitle?: string;
buildHref?: string;
commitHash?: string;
baseHash?: string;
branch?: string;
};
export type UserMetadataWithCommitInfo = {
ci?: CIInfo;
gitCommit?: GitCommitInfo | 'generate';
gitDiff?: string | 'generate';
};
export type MetadataWithCommitInfo = {
ci?: CIInfo;
gitCommit?: GitCommitInfo;
gitDiff?: string;
};

View file

@ -22,7 +22,7 @@ import { escapeTemplateString, isString, sanitizeForFilePath } from 'playwright-
import { kNoElementsFoundError, matcherHint } from './matcherHint';
import { EXPECTED_COLOR } from '../common/expectBundle';
import { callLogText, sanitizeFilePathBeforeExtension, trimLongString } from '../util';
import { callLogText, fileExistsAsync, sanitizeFilePathBeforeExtension, trimLongString } from '../util';
import { printReceivedStringContainExpectedSubstring } from './expect';
import { currentTestInfo } from '../common/globals';
@ -70,7 +70,8 @@ export async function toMatchAriaSnapshot(
timeout = options.timeout ?? this.timeout;
} else {
if (expectedParam?.name) {
expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeFilePathBeforeExtension(expectedParam.name)]);
const ext = expectedParam.name!.endsWith('.snapshot.yml') ? '.snapshot.yml' : undefined;
expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeFilePathBeforeExtension(expectedParam.name, ext)]);
} else {
let snapshotNames = (testInfo as any)[snapshotNamesSymbol] as SnapshotNames;
if (!snapshotNames) {
@ -78,7 +79,14 @@ export async function toMatchAriaSnapshot(
(testInfo as any)[snapshotNamesSymbol] = snapshotNames;
}
const fullTitleWithoutSpec = [...testInfo.titlePath.slice(1), ++snapshotNames.anonymousSnapshotIndex].join(' ');
expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeForFilePath(trimLongString(fullTitleWithoutSpec)) + '.yml']);
expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeForFilePath(trimLongString(fullTitleWithoutSpec))], '.snapshot.yml');
// in 1.51, we changed the default template to use .snapshot.yml extension
// for backwards compatibility, we check for the legacy .yml extension
if (!(await fileExistsAsync(expectedPath))) {
const legacyPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeForFilePath(trimLongString(fullTitleWithoutSpec))], '.yml');
if (await fileExistsAsync(legacyPath))
expectedPath = legacyPath;
}
}
expected = await fs.promises.readFile(expectedPath, 'utf8').catch(() => '');
timeout = expectedParam?.timeout ?? this.timeout;

View file

@ -14,108 +14,151 @@
* limitations under the License.
*/
import fs from 'fs';
import * as fs from 'fs';
import { createGuid, spawnAsync } from 'playwright-core/lib/utils';
import { spawnAsync } from 'playwright-core/lib/utils';
import type { TestRunnerPlugin } from './';
import type { FullConfig } from '../../types/testReporter';
import type { FullConfigInternal } from '../common/config';
import type { GitCommitInfo } from '../isomorphic/types';
import type { GitCommitInfo, CIInfo, UserMetadataWithCommitInfo } from '../isomorphic/types';
const GIT_OPERATIONS_TIMEOUT_MS = 1500;
const GIT_OPERATIONS_TIMEOUT_MS = 3000;
export const addGitCommitInfoPlugin = (fullConfig: FullConfigInternal) => {
if (fullConfig.populateGitInfo)
fullConfig.plugins.push({ factory: gitCommitInfo });
fullConfig.plugins.push({ factory: gitCommitInfoPlugin });
};
export const gitCommitInfo = (options?: GitCommitInfoPluginOptions): TestRunnerPlugin => {
type GitCommitInfoPluginOptions = {
directory?: string;
};
export const gitCommitInfoPlugin = (options?: GitCommitInfoPluginOptions): TestRunnerPlugin => {
return {
name: 'playwright:git-commit-info',
setup: async (config: FullConfig, configDir: string) => {
const fromEnv = await linksFromEnv();
const fromCLI = await gitStatusFromCLI(options?.directory || configDir, fromEnv);
config.metadata = config.metadata || {};
config.metadata['git.commit.info'] = { ...fromEnv, ...fromCLI };
const metadata = config.metadata as UserMetadataWithCommitInfo;
const ci = await ciInfo();
if (!metadata.ci && ci)
metadata.ci = ci;
if ((ci && !metadata.gitCommit) || metadata.gitCommit === 'generate') {
const git = await gitCommitInfo(options?.directory || configDir).catch(e => {
// eslint-disable-next-line no-console
console.error('Failed to get git commit info', e);
});
if (git)
metadata.gitCommit = git;
}
if ((ci && !metadata.gitDiff) || metadata.gitDiff === 'generate') {
const diffResult = await gitDiff(options?.directory || configDir, ci).catch(e => {
// eslint-disable-next-line no-console
console.error('Failed to get git diff', e);
});
if (diffResult)
metadata.gitDiff = diffResult;
}
},
};
};
interface GitCommitInfoPluginOptions {
directory?: string;
}
async function linksFromEnv() {
const out: Partial<GitCommitInfo> = {};
// Jenkins: https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables
if (process.env.BUILD_URL)
out['ci.link'] = process.env.BUILD_URL;
// GitLab: https://docs.gitlab.com/ee/ci/variables/predefined_variables.html
if (process.env.CI_PROJECT_URL && process.env.CI_COMMIT_SHA)
out['revision.link'] = `${process.env.CI_PROJECT_URL}/-/commit/${process.env.CI_COMMIT_SHA}`;
if (process.env.CI_JOB_URL)
out['ci.link'] = process.env.CI_JOB_URL;
// GitHub: https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables
if (process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_SHA)
out['revision.link'] = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/commit/${process.env.GITHUB_SHA}`;
if (process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID)
out['ci.link'] = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
if (process.env.GITHUB_EVENT_PATH) {
async function ciInfo(): Promise<CIInfo | undefined> {
if (process.env.GITHUB_ACTIONS) {
let pr: { title: string, number: number } | undefined;
try {
const json = JSON.parse(await fs.promises.readFile(process.env.GITHUB_EVENT_PATH, 'utf8'));
if (json.pull_request) {
out['pull.title'] = json.pull_request.title;
out['pull.link'] = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/pull/${json.pull_request.number}`;
out['pull.base'] = json.pull_request.base.ref;
}
const json = JSON.parse(await fs.promises.readFile(process.env.GITHUB_EVENT_PATH!, 'utf8'));
pr = { title: json.pull_request.title, number: json.pull_request.number };
} catch {
}
return {
commitHref: `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/commit/${process.env.GITHUB_SHA}`,
prHref: pr ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/pull/${pr.number}` : undefined,
prTitle: pr ? pr.title : undefined,
buildHref: `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`,
commitHash: process.env.GITHUB_SHA,
baseHash: process.env.GITHUB_BASE_REF,
branch: process.env.GITHUB_REF_NAME,
};
}
return out;
if (process.env.GITLAB_CI) {
return {
commitHref: `${process.env.CI_PROJECT_URL}/-/commit/${process.env.CI_COMMIT_SHA}`,
prHref: process.env.CI_MERGE_REQUEST_IID ? `${process.env.CI_PROJECT_URL}/-/merge_requests/${process.env.CI_MERGE_REQUEST_IID}` : undefined,
buildHref: process.env.CI_JOB_URL,
commitHash: process.env.CI_COMMIT_SHA,
baseHash: process.env.CI_COMMIT_BEFORE_SHA,
branch: process.env.CI_COMMIT_REF_NAME,
};
}
if (process.env.JENKINS_URL && process.env.BUILD_URL) {
return {
commitHref: process.env.BUILD_URL,
commitHash: process.env.GIT_COMMIT,
baseHash: process.env.GIT_PREVIOUS_COMMIT,
branch: process.env.GIT_BRANCH,
};
}
// Open to PRs.
}
async function gitStatusFromCLI(gitDir: string, envInfo: Pick<GitCommitInfo, 'pull.base'>): Promise<GitCommitInfo | undefined> {
const separator = `:${createGuid().slice(0, 4)}:`;
async function gitCommitInfo(gitDir: string): Promise<GitCommitInfo | undefined> {
const separator = `---786eec917292---`;
const tokens = [
'%H', // commit hash
'%h', // abbreviated commit hash
'%s', // subject
'%B', // raw body (unwrapped subject and body)
'%an', // author name
'%ae', // author email
'%at', // author date, UNIX timestamp
'%cn', // committer name
'%ce', // committer email
'%ct', // committer date, UNIX timestamp
'', // branch
];
const commitInfoResult = await spawnAsync(
'git',
['show', '-s', `--format=%H${separator}%s${separator}%an${separator}%ae${separator}%ct`, 'HEAD'],
{ stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS }
`git log -1 --pretty=format:"${tokens.join(separator)}" && git rev-parse --abbrev-ref HEAD`, [],
{ stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS, shell: true }
);
if (commitInfoResult.code)
return;
return undefined;
const showOutput = commitInfoResult.stdout.trim();
const [id, subject, author, email, rawTimestamp] = showOutput.split(separator);
let timestamp: number = Number.parseInt(rawTimestamp, 10);
timestamp = Number.isInteger(timestamp) ? timestamp * 1000 : 0;
const [hash, shortHash, subject, body, authorName, authorEmail, authorTime, committerName, committerEmail, committerTime, branch] = showOutput.split(separator);
const result: GitCommitInfo = {
'revision.id': id,
'revision.author': author,
'revision.email': email,
'revision.subject': subject,
'revision.timestamp': timestamp,
return {
shortHash,
hash,
subject,
body,
author: {
name: authorName,
email: authorEmail,
time: +authorTime * 1000,
},
committer: {
name: committerName,
email: committerEmail,
time: +committerTime * 1000,
},
branch: branch.trim(),
};
const diffLimit = 1_000_000; // 1MB
if (envInfo['pull.base']) {
const pullDiffResult = await spawnAsync(
'git',
['diff', envInfo['pull.base']],
{ stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS }
);
if (!pullDiffResult.code)
result['pull.diff'] = pullDiffResult.stdout.substring(0, diffLimit);
} else {
const diffResult = await spawnAsync(
'git',
['diff', 'HEAD~1'],
{ stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS }
);
if (!diffResult.code)
result['revision.diff'] = diffResult.stdout.substring(0, diffLimit);
}
return result;
}
async function gitDiff(gitDir: string, ci?: CIInfo): Promise<string | undefined> {
const diffLimit = 100_000;
const baseHash = ci?.baseHash ?? 'HEAD~1';
const pullDiffResult = await spawnAsync(
'git',
['diff', baseHash],
{ stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS }
);
if (!pullDiffResult.code)
return pullDiffResult.stdout.substring(0, diffLimit);
}

View file

@ -35,4 +35,3 @@ export type TestRunnerPluginRegistration = {
};
export { webServer } from './webServerPlugin';
export { gitCommitInfo } from './gitCommitInfoPlugin';

View file

@ -206,8 +206,8 @@ export function addSuffixToFilePath(filePath: string, suffix: string): string {
return base + suffix + ext;
}
export function sanitizeFilePathBeforeExtension(filePath: string): string {
const ext = path.extname(filePath);
export function sanitizeFilePathBeforeExtension(filePath: string, ext?: string): string {
ext ??= path.extname(filePath);
const base = filePath.substring(0, filePath.length - ext.length);
return sanitizeForFilePath(base) + ext;
}
@ -391,6 +391,15 @@ function fileExists(resolved: string) {
return fs.statSync(resolved, { throwIfNoEntry: false })?.isFile();
}
export async function fileExistsAsync(resolved: string) {
try {
const stat = await fs.promises.stat(resolved);
return stat.isFile();
} catch {
return false;
}
}
function dirExists(resolved: string) {
return fs.statSync(resolved, { throwIfNoEntry: false })?.isDirectory();
}

View file

@ -35,7 +35,7 @@ class Fixture {
private _selfTeardownComplete: Promise<void> | undefined;
private _setupDescription: FixtureDescription;
private _teardownDescription: FixtureDescription;
private _stepInfo: { category: 'fixture', location?: Location } | undefined;
private _stepInfo: { title: string, category: 'fixture', location?: Location } | undefined;
_deps = new Set<Fixture>();
_usages = new Set<Fixture>();
@ -47,7 +47,7 @@ class Fixture {
const isUserFixture = this.registration.location && filterStackFile(this.registration.location.file);
const title = this.registration.customTitle || this.registration.name;
const location = isUserFixture ? this.registration.location : undefined;
this._stepInfo = shouldGenerateStep ? { category: 'fixture', location } : undefined;
this._stepInfo = shouldGenerateStep ? { title: `fixture: ${title}`, category: 'fixture', location } : undefined;
this._setupDescription = {
title,
phase: 'setup',
@ -68,13 +68,11 @@ class Fixture {
return;
}
await testInfo._runAsStage({
title: `fixture: ${this.registration.customTitle ?? this.registration.name}`,
runnable: { ...runnable, fixture: this._setupDescription },
stepInfo: this._stepInfo,
}, async () => {
await this._setupInternal(testInfo);
});
const run = () => testInfo._runWithTimeout({ ...runnable, fixture: this._setupDescription }, () => this._setupInternal(testInfo));
if (this._stepInfo)
await testInfo._runAsStep(this._stepInfo, run);
else
await run();
}
private async _setupInternal(testInfo: TestInfoImpl) {
@ -133,13 +131,11 @@ class Fixture {
// Do not even start the teardown for a fixture that does not have any
// time remaining in the time slot. This avoids cascading timeouts.
if (!testInfo._timeoutManager.isTimeExhaustedFor(fixtureRunnable)) {
await testInfo._runAsStage({
title: `fixture: ${this.registration.customTitle ?? this.registration.name}`,
runnable: fixtureRunnable,
stepInfo: this._stepInfo,
}, async () => {
await this._teardownInternal();
});
const run = () => testInfo._runWithTimeout(fixtureRunnable, () => this._teardownInternal());
if (this._stepInfo)
await testInfo._runAsStep(this._stepInfo, run);
else
await run();
}
} finally {
// To preserve fixtures integrity, forcefully cleanup fixtures
@ -268,9 +264,7 @@ export class FixtureRunner {
// Do not run the function when fixture setup has already failed.
return null;
}
await testInfo._runAsStage({ title: 'run function', runnable }, async () => {
await fn(params, testInfo);
});
await testInfo._runWithTimeout(runnable, () => fn(params, testInfo));
}
private async _setupFixtureForRegistration(registration: FixtureRegistration, testInfo: TestInfoImpl, runnable: RunnableDescription): Promise<Fixture> {

View file

@ -20,7 +20,7 @@ import path from 'path';
import { captureRawStack, monotonicTime, sanitizeForFilePath, stringifyStackFrames, currentZone } from 'playwright-core/lib/utils';
import { TimeoutManager, TimeoutManagerError, kMaxDeadline } from './timeoutManager';
import { debugTest, filteredStackTrace, formatLocation, getContainedPath, normalizeAndSaveAttachment, trimLongString, windowsFilesystemFriendlyLength } from '../util';
import { filteredStackTrace, getContainedPath, normalizeAndSaveAttachment, trimLongString, windowsFilesystemFriendlyLength } from '../util';
import { TestTracing } from './testTracing';
import { testInfoError } from './util';
import { FloatingPromiseScope } from './floatingPromiseScope';
@ -50,16 +50,8 @@ export interface TestStepInternal {
error?: TestInfoErrorImpl;
infectParentStepsWithError?: boolean;
box?: boolean;
isStage?: boolean;
}
export type TestStage = {
title: string;
stepInfo?: { category: 'hook' | 'fixture', location?: Location };
runnable?: RunnableDescription;
step?: TestStepInternal;
};
export class TestInfoImpl implements TestInfo {
private _onStepBegin: (payload: StepBeginPayload) => void;
private _onStepEnd: (payload: StepEndPayload) => void;
@ -235,28 +227,27 @@ export class TestInfoImpl implements TestInfo {
}
}
private _findLastStageStep(steps: TestStepInternal[]): TestStepInternal | undefined {
// Find the deepest step that is marked as isStage and has not finished yet.
private _findLastPredefinedStep(steps: TestStepInternal[]): TestStepInternal | undefined {
// Find the deepest predefined step that has not finished yet.
for (let i = steps.length - 1; i >= 0; i--) {
const child = this._findLastStageStep(steps[i].steps);
const child = this._findLastPredefinedStep(steps[i].steps);
if (child)
return child;
if (steps[i].isStage && !steps[i].endWallTime)
if ((steps[i].category === 'hook' || steps[i].category === 'fixture') && !steps[i].endWallTime)
return steps[i];
}
}
private _parentStep() {
return currentZone().data<TestStepInternal>('stepZone')
?? this._findLastStageStep(this._steps); // If no parent step on stack, assume the current stage as parent.
return currentZone().data<TestStepInternal>('stepZone') ?? this._findLastPredefinedStep(this._steps);
}
_addStep(data: Omit<TestStepInternal, 'complete' | 'stepId' | 'steps' | 'attachmentIndices' | 'info'>, parentStep?: TestStepInternal): TestStepInternal {
const stepId = `${data.category}@${++this._lastStepId}`;
if (data.isStage) {
// Predefined stages form a fixed hierarchy - use the current one as parent.
parentStep = this._findLastStageStep(this._steps);
if (data.category === 'hook' || data.category === 'fixture') {
// Predefined steps form a fixed hierarchy - use the current one as parent.
parentStep = this._findLastPredefinedStep(this._steps);
} else {
if (!parentStep)
parentStep = this._parentStep();
@ -355,21 +346,23 @@ export class TestInfoImpl implements TestInfo {
this._tracing.appendForError(serialized);
}
async _runAsStage(stage: TestStage, cb: () => Promise<any>) {
if (debugTest.enabled) {
const location = stage.runnable?.location ? ` at "${formatLocation(stage.runnable.location)}"` : ``;
debugTest(`started stage "${stage.title}"${location}`);
}
stage.step = stage.stepInfo ? this._addStep({ ...stage.stepInfo, title: stage.title, isStage: true }) : undefined;
async _runAsStep(stepInfo: { title: string, category: 'hook' | 'fixture', location?: Location }, cb: () => Promise<any>) {
const step = this._addStep(stepInfo);
try {
await this._timeoutManager.withRunnable(stage.runnable, async () => {
await cb();
step.complete({});
} catch (error) {
step.complete({ error });
throw error;
}
}
async _runWithTimeout(runnable: RunnableDescription, cb: () => Promise<any>) {
try {
await this._timeoutManager.withRunnable(runnable, async () => {
try {
await cb();
} catch (e) {
// Only handle errors directly thrown by the user code.
if (!stage.runnable)
throw e;
if (this._allowSkips && (e instanceof SkipError)) {
if (this.status === 'passed')
this.status = 'skipped';
@ -377,7 +370,7 @@ export class TestInfoImpl implements TestInfo {
// Unfortunately, we have to handle user errors and timeout errors differently.
// Consider the following scenario:
// - locator.click times out
// - all stages containing the test function finish with TimeoutManagerError
// - all steps containing the test function finish with TimeoutManagerError
// - test finishes, the page is closed and this triggers locator.click error
// - we would like to present the locator.click error to the user
// - therefore, we need a try/catch inside the "run with timeout" block and capture the error
@ -386,16 +379,12 @@ export class TestInfoImpl implements TestInfo {
throw e;
}
});
stage.step?.complete({});
} catch (error) {
// When interrupting, we arrive here with a TimeoutManagerError, but we should not
// consider it a timeout.
if (!this._wasInterrupted && (error instanceof TimeoutManagerError) && stage.runnable)
if (!this._wasInterrupted && (error instanceof TimeoutManagerError))
this._failWithError(error);
stage.step?.complete({ error });
throw error;
} finally {
debugTest(`finished stage "${stage.title}"`);
}
}
@ -430,7 +419,7 @@ export class TestInfoImpl implements TestInfo {
} else {
// trace viewer has no means of representing attachments outside of a step, so we create an artificial action
const callId = `attach@${++this._lastStepId}`;
this._tracing.appendBeforeActionForStep(callId, this._findLastStageStep(this._steps)?.stepId, 'attach', `attach "${attachment.name}"`, undefined, []);
this._tracing.appendBeforeActionForStep(callId, this._findLastPredefinedStep(this._steps)?.stepId, 'attach', `attach "${attachment.name}"`, undefined, []);
this._tracing.appendAfterActionForStep(callId, undefined, [attachment]);
}
@ -463,9 +452,11 @@ export class TestInfoImpl implements TestInfo {
return sanitizeForFilePath(trimLongString(fullTitleWithoutSpec));
}
_resolveSnapshotPath(template: string | undefined, defaultTemplate: string, pathSegments: string[]) {
_resolveSnapshotPath(template: string | undefined, defaultTemplate: string, pathSegments: string[], extension?: string) {
const subPath = path.join(...pathSegments);
const parsedSubPath = path.parse(subPath);
const dir = path.dirname(subPath);
const ext = extension ?? path.extname(subPath);
const name = path.basename(subPath, ext);
const relativeTestFilePath = path.relative(this.project.testDir, this._requireFile);
const parsedRelativeTestFilePath = path.parse(relativeTestFilePath);
const projectNamePathSegment = sanitizeForFilePath(this.project.name);
@ -481,8 +472,8 @@ export class TestInfoImpl implements TestInfo {
.replace(/\{(.)?testName\}/g, '$1' + this._fsSanitizedTestName())
.replace(/\{(.)?testFileName\}/g, '$1' + parsedRelativeTestFilePath.base)
.replace(/\{(.)?testFilePath\}/g, '$1' + relativeTestFilePath)
.replace(/\{(.)?arg\}/g, '$1' + path.join(parsedSubPath.dir, parsedSubPath.name))
.replace(/\{(.)?ext\}/g, parsedSubPath.ext ? '$1' + parsedSubPath.ext : '');
.replace(/\{(.)?arg\}/g, '$1' + path.join(dir, name))
.replace(/\{(.)?ext\}/g, ext ? '$1' + ext : '');
return path.normalize(path.resolve(this._configInternal.configDir, snapshotPath));
}

View file

@ -17,6 +17,8 @@
import { ManualPromise, monotonicTime } from 'playwright-core/lib/utils';
import { colors } from 'playwright-core/lib/utils';
import { debugTest, formatLocation } from '../util';
import type { Location } from '../../types/testReporter';
export type TimeSlot = {
@ -76,9 +78,7 @@ export class TimeoutManager {
return slot.timeout > 0 && (slot.elapsed >= slot.timeout - 1);
}
async withRunnable<T>(runnable: RunnableDescription | undefined, cb: () => Promise<T>): Promise<T> {
if (!runnable)
return await cb();
async withRunnable<T>(runnable: RunnableDescription, cb: () => Promise<T>): Promise<T> {
if (this._running)
throw new Error(`Internal error: duplicate runnable`);
const running = this._running = {
@ -89,7 +89,13 @@ export class TimeoutManager {
timer: undefined,
timeoutPromise: new ManualPromise(),
};
let debugTitle = '';
try {
if (debugTest.enabled) {
debugTitle = runnable.fixture ? `${runnable.fixture.phase} "${runnable.fixture.title}"` : runnable.type;
const location = runnable.location ? ` at "${formatLocation(runnable.location)}"` : ``;
debugTest(`started ${debugTitle}${location}`);
}
this._updateTimeout(running);
return await Promise.race([
cb(),
@ -101,6 +107,8 @@ export class TimeoutManager {
running.timer = undefined;
running.slot.elapsed += monotonicTime() - running.start;
this._running = undefined;
if (debugTest.enabled)
debugTest(`finished ${debugTitle}`);
}
}

View file

@ -115,12 +115,12 @@ export class WorkerMain extends ProcessRunner {
const fakeTestInfo = new TestInfoImpl(this._config, this._project, this._params, undefined, 0, () => {}, () => {}, () => {});
const runnable = { type: 'teardown' } as const;
// We have to load the project to get the right deadline below.
await fakeTestInfo._runAsStage({ title: 'worker cleanup', runnable }, () => this._loadIfNeeded()).catch(() => {});
await fakeTestInfo._runWithTimeout(runnable, () => this._loadIfNeeded()).catch(() => {});
await this._fixtureRunner.teardownScope('test', fakeTestInfo, runnable).catch(() => {});
await this._fixtureRunner.teardownScope('worker', fakeTestInfo, runnable).catch(() => {});
// Close any other browsers launched in this process. This includes anything launched
// manually in the test/hooks and internal browsers like Playwright Inspector.
await fakeTestInfo._runAsStage({ title: 'worker cleanup', runnable }, () => gracefullyCloseAll()).catch(() => {});
await fakeTestInfo._runWithTimeout(runnable, () => gracefullyCloseAll()).catch(() => {});
this._fatalErrors.push(...fakeTestInfo.errors);
} catch (e) {
this._fatalErrors.push(testInfoError(e));
@ -330,8 +330,8 @@ export class WorkerMain extends ProcessRunner {
testInfo._floatingPromiseScope.clear();
};
await testInfo._runAsStage({ title: 'setup and test' }, async () => {
await testInfo._runAsStage({ title: 'start tracing', runnable: { type: 'test' } }, async () => {
await (async () => {
await testInfo._runWithTimeout({ type: 'test' }, async () => {
// Ideally, "trace" would be an config-level option belonging to the
// test runner instead of a fixture belonging to Playwright.
// However, for backwards compatibility, we have to read it from a fixture today.
@ -356,7 +356,7 @@ export class WorkerMain extends ProcessRunner {
await removeFolders([testInfo.outputDir]);
let testFunctionParams: object | null = null;
await testInfo._runAsStage({ title: 'Before Hooks', stepInfo: { category: 'hook' } }, async () => {
await testInfo._runAsStep({ title: 'Before Hooks', category: 'hook' }, async () => {
// Run "beforeAll" hooks, unless already run during previous tests.
for (const suite of suites)
await this._runBeforeAllHooksForSuite(suite, testInfo);
@ -376,13 +376,13 @@ export class WorkerMain extends ProcessRunner {
return;
}
await testInfo._runAsStage({ title: 'test function', runnable: { type: 'test' } }, async () => {
await testInfo._runWithTimeout({ type: 'test' }, async () => {
// Now run the test itself.
const fn = test.fn; // Extract a variable to get a better stack trace ("myTest" vs "TestCase.myTest [as fn]").
await fn(testFunctionParams, testInfo);
checkForFloatingPromises('the test');
});
}).catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors.
})().catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors.
// Update duration, so it is available in fixture teardown and afterEach hooks.
testInfo.duration = testInfo._timeoutManager.defaultSlot().elapsed | 0;
@ -396,12 +396,12 @@ export class WorkerMain extends ProcessRunner {
const FAILURE_AND_RECREATE_WORKER = testInfo._isFailure() && this._config.recreateWorkerAfterFailure;
await testInfo._runAsStage({ title: 'After Hooks', stepInfo: { category: 'hook' } }, async () => {
await testInfo._runAsStep({ title: 'After Hooks', category: 'hook' }, async () => {
let firstAfterHooksError: Error | undefined;
try {
// Run "immediately upon test function finish" callback.
await testInfo._runAsStage({ title: 'on-test-function-finish', runnable: { type: 'test', slot: afterHooksSlot } }, async () => testInfo._onDidFinishTestFunction?.());
await testInfo._runWithTimeout({ type: 'test', slot: afterHooksSlot }, async () => testInfo._onDidFinishTestFunction?.());
} catch (error) {
firstAfterHooksError = firstAfterHooksError ?? error;
}
@ -451,7 +451,7 @@ export class WorkerMain extends ProcessRunner {
// Mark as "cleaned up" early to avoid running cleanup twice.
this._didRunFullCleanup = true;
await testInfo._runAsStage({ title: 'Worker Cleanup', stepInfo: { category: 'hook' } }, async () => {
await testInfo._runAsStep({ title: 'Worker Cleanup', category: 'hook' }, async () => {
let firstWorkerCleanupError: Error | undefined;
// Give it more time for the full cleanup.
@ -484,7 +484,7 @@ export class WorkerMain extends ProcessRunner {
}
const tracingSlot = { timeout: this._project.project.timeout, elapsed: 0 };
await testInfo._runAsStage({ title: 'stop tracing', runnable: { type: 'test', slot: tracingSlot } }, async () => {
await testInfo._runWithTimeout({ type: 'test', slot: tracingSlot }, async () => {
await testInfo._tracing.stopIfNeeded();
}).catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors.
@ -537,7 +537,7 @@ export class WorkerMain extends ProcessRunner {
let firstError: Error | undefined;
for (const hook of this._collectHooksAndModifiers(suite, type, testInfo)) {
try {
await testInfo._runAsStage({ title: hook.title, stepInfo: { category: 'hook', location: hook.location } }, async () => {
await testInfo._runAsStep({ title: hook.title, category: 'hook', location: hook.location }, async () => {
// Separate time slot for each beforeAll/afterAll hook.
const timeSlot = { timeout: this._project.project.timeout, elapsed: 0 };
const runnable = { type: hook.type, slot: timeSlot, location: hook.location };
@ -590,7 +590,7 @@ export class WorkerMain extends ProcessRunner {
continue;
}
try {
await testInfo._runAsStage({ title: hook.title, stepInfo: { category: 'hook', location: hook.location } }, async () => {
await testInfo._runAsStep({ title: hook.title, category: 'hook', location: hook.location }, async () => {
await this._fixtureRunner.resolveParametersAndRunFunction(hook.fn, testInfo, 'test', runnable);
});
} catch (error) {

View file

@ -884,7 +884,7 @@ interface TestConfig<TestArgs = {}, WorkerArgs = {}> {
* export default defineConfig({
* webServer: {
* command: 'npm run start',
* url: 'http://127.0.0.1:3000',
* url: 'http://localhost:3000',
* timeout: 120 * 1000,
* reuseExistingServer: !process.env.CI,
* },
@ -915,19 +915,19 @@ interface TestConfig<TestArgs = {}, WorkerArgs = {}> {
* webServer: [
* {
* command: 'npm run start',
* url: 'http://127.0.0.1:3000',
* url: 'http://localhost:3000',
* timeout: 120 * 1000,
* reuseExistingServer: !process.env.CI,
* },
* {
* command: 'npm run backend',
* url: 'http://127.0.0.1:3333',
* url: 'http://localhost:3333',
* timeout: 120 * 1000,
* reuseExistingServer: !process.env.CI,
* }
* ],
* use: {
* baseURL: 'http://127.0.0.1:3000',
* baseURL: 'http://localhost:3000',
* },
* });
* ```
@ -1284,10 +1284,11 @@ interface TestConfig<TestArgs = {}, WorkerArgs = {}> {
/**
* Metadata contains key-value pairs to be included in the report. For example, HTML report will display it as
* key-value pairs, and JSON report will include metadata serialized as json.
* - Providing `gitCommit: 'generate'` property will populate it with the git commit details.
* - Providing `gitDiff: 'generate'` property will populate it with the git diff details.
*
* See also
* [testConfig.populateGitInfo](https://playwright.dev/docs/api/class-testconfig#test-config-populate-git-info) that
* populates metadata.
* On selected CI providers, both will be generated automatically. Specifying values will prevent the automatic
* generation.
*
* **Usage**
*
@ -1360,29 +1361,6 @@ interface TestConfig<TestArgs = {}, WorkerArgs = {}> {
*/
outputDir?: string;
/**
* Whether to populate `'git.commit.info'` field of the
* [testConfig.metadata](https://playwright.dev/docs/api/class-testconfig#test-config-metadata) with Git commit info
* and CI/CD information.
*
* This information will appear in the HTML and JSON reports and is available in the Reporter API.
*
* On Github Actions, this feature is enabled by default.
*
* **Usage**
*
* ```js
* // playwright.config.ts
* import { defineConfig } from '@playwright/test';
*
* export default defineConfig({
* populateGitInfo: !!process.env.CI,
* });
* ```
*
*/
populateGitInfo?: boolean;
/**
* Whether to preserve test output in the
* [testConfig.outputDir](https://playwright.dev/docs/api/class-testconfig#test-config-output-dir). Defaults to
@ -1467,7 +1445,7 @@ interface TestConfig<TestArgs = {}, WorkerArgs = {}> {
max: number;
/**
* Test duration in milliseconds that is considered slow. Defaults to 15 seconds.
* Test file duration in milliseconds that is considered slow. Defaults to 5 minutes.
*/
threshold: number;
};
@ -1919,12 +1897,12 @@ export interface FullConfig<TestArgs = {}, WorkerArgs = {}> {
*/
reportSlowTests: null|{
/**
* The maximum number of slow test files to report. Defaults to `5`.
* The maximum number of slow test files to report.
*/
max: number;
/**
* Test duration in milliseconds that is considered slow. Defaults to 15 seconds.
* Test file duration in milliseconds that is considered slow.
*/
threshold: number;
};
@ -8817,14 +8795,14 @@ interface LocatorAssertions {
/**
* Asserts that the target element matches the given [accessibility snapshot](https://playwright.dev/docs/aria-snapshots).
*
* Snapshot is stored in a separate `.yml` file in a location configured by `expect.toMatchAriaSnapshot.pathTemplate`
* and/or `snapshotPathTemplate` properties in the configuration file.
* Snapshot is stored in a separate `.snapshot.yml` file in a location configured by
* `expect.toMatchAriaSnapshot.pathTemplate` and/or `snapshotPathTemplate` properties in the configuration file.
*
* **Usage**
*
* ```js
* await expect(page.locator('body')).toMatchAriaSnapshot();
* await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'body.yml' });
* await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'body.snapshot.yml' });
* ```
*
* @param options
@ -8926,13 +8904,25 @@ interface PageAssertions {
* **Usage**
*
* ```js
* await expect(page).toHaveURL(/.*checkout/);
* // Check for the page URL to be 'https://playwright.dev/docs/intro' (including query string)
* await expect(page).toHaveURL('https://playwright.dev/docs/intro');
*
* // Check for the page URL to contain 'doc', followed by an optional 's', followed by '/'
* await expect(page).toHaveURL(/docs?\//);
*
* // Check for the predicate to be satisfied
* // For example: verify query strings
* await expect(page).toHaveURL(url => {
* const params = url.searchParams;
* return params.has('search') && params.has('options') && params.get('id') === '5';
* });
* ```
*
* @param url Expected URL string, RegExp, or predicate receiving [URL] to match. When a
* [`baseURL`](https://playwright.dev/docs/api/class-browser#browser-new-context-option-base-url) via the context
* options was provided and the passed URL is a path, it gets merged via the
* [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor.
* @param url Expected URL string, RegExp, or predicate receiving [URL] to match. When
* [`baseURL`](https://playwright.dev/docs/api/class-browser#browser-new-context-option-base-url) is provided via the
* context options and the `url` argument is a string, the two values are merged via the
* [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor and used for the comparison
* against the current browser URL.
* @param options
*/
toHaveURL(url: string|RegExp|((url: URL) => boolean), options?: {

View file

@ -623,7 +623,7 @@ export type PlaywrightNewRequestParams = {
userAgent?: string,
ignoreHTTPSErrors?: boolean,
extraHTTPHeaders?: NameValue[],
apiRequestFailsOnErrorStatus?: boolean,
failOnStatusCode?: boolean,
clientCertificates?: {
origin: string,
cert?: Binary,
@ -655,7 +655,7 @@ export type PlaywrightNewRequestOptions = {
userAgent?: string,
ignoreHTTPSErrors?: boolean,
extraHTTPHeaders?: NameValue[],
apiRequestFailsOnErrorStatus?: boolean,
failOnStatusCode?: boolean,
clientCertificates?: {
origin: string,
cert?: Binary,
@ -1029,7 +1029,6 @@ export type BrowserTypeLaunchPersistentContextParams = {
},
permissions?: string[],
extraHTTPHeaders?: NameValue[],
apiRequestFailsOnErrorStatus?: boolean,
offline?: boolean,
httpCredentials?: {
username: string,
@ -1111,7 +1110,6 @@ export type BrowserTypeLaunchPersistentContextOptions = {
},
permissions?: string[],
extraHTTPHeaders?: NameValue[],
apiRequestFailsOnErrorStatus?: boolean,
offline?: boolean,
httpCredentials?: {
username: string,
@ -1228,7 +1226,6 @@ export type BrowserNewContextParams = {
},
permissions?: string[],
extraHTTPHeaders?: NameValue[],
apiRequestFailsOnErrorStatus?: boolean,
offline?: boolean,
httpCredentials?: {
username: string,
@ -1296,7 +1293,6 @@ export type BrowserNewContextOptions = {
},
permissions?: string[],
extraHTTPHeaders?: NameValue[],
apiRequestFailsOnErrorStatus?: boolean,
offline?: boolean,
httpCredentials?: {
username: string,
@ -1367,7 +1363,6 @@ export type BrowserNewContextForReuseParams = {
},
permissions?: string[],
extraHTTPHeaders?: NameValue[],
apiRequestFailsOnErrorStatus?: boolean,
offline?: boolean,
httpCredentials?: {
username: string,
@ -1435,7 +1430,6 @@ export type BrowserNewContextForReuseOptions = {
},
permissions?: string[],
extraHTTPHeaders?: NameValue[],
apiRequestFailsOnErrorStatus?: boolean,
offline?: boolean,
httpCredentials?: {
username: string,
@ -4802,7 +4796,6 @@ export type AndroidDeviceLaunchBrowserParams = {
},
permissions?: string[],
extraHTTPHeaders?: NameValue[],
apiRequestFailsOnErrorStatus?: boolean,
offline?: boolean,
httpCredentials?: {
username: string,
@ -4868,7 +4861,6 @@ export type AndroidDeviceLaunchBrowserOptions = {
},
permissions?: string[],
extraHTTPHeaders?: NameValue[],
apiRequestFailsOnErrorStatus?: boolean,
offline?: boolean,
httpCredentials?: {
username: string,

View file

@ -520,7 +520,6 @@ ContextOptions:
extraHTTPHeaders:
type: array?
items: NameValue
apiRequestFailsOnErrorStatus: boolean?
offline: boolean?
httpCredentials:
type: object?
@ -752,7 +751,7 @@ Playwright:
extraHTTPHeaders:
type: array?
items: NameValue
apiRequestFailsOnErrorStatus: boolean?
failOnStatusCode: boolean?
clientCertificates:
type: array?
items:

View file

@ -24,20 +24,21 @@ import type { StackFrame } from '@protocol/channels';
import { CopyToClipboardTextButton } from './copyToClipboard';
import { attachmentURL } from './attachmentsTab';
import { fixTestPrompt } from '@web/components/prompts';
import type { GitCommitInfo } from '@testIsomorphic/types';
import type { MetadataWithCommitInfo } from '@testIsomorphic/types';
import { AIConversation } from './aiConversation';
import { ToolbarButton } from '@web/components/toolbarButton';
import { useIsLLMAvailable, useLLMChat } from './llm';
import { useAsyncMemo } from '@web/uiUtils';
import { useSources } from './sourceTab';
const GitCommitInfoContext = React.createContext<GitCommitInfo | undefined>(undefined);
const CommitInfoContext = React.createContext<MetadataWithCommitInfo | undefined>(undefined);
export function GitCommitInfoProvider({ children, gitCommitInfo }: React.PropsWithChildren<{ gitCommitInfo: GitCommitInfo }>) {
return <GitCommitInfoContext.Provider value={gitCommitInfo}>{children}</GitCommitInfoContext.Provider>;
export function CommitInfoProvider({ children, commitInfo }: React.PropsWithChildren<{ commitInfo: MetadataWithCommitInfo }>) {
return <CommitInfoContext.Provider value={commitInfo}>{children}</CommitInfoContext.Provider>;
}
export function useGitCommitInfo() {
return React.useContext(GitCommitInfoContext);
export function useCommitInfo() {
return React.useContext(CommitInfoContext);
}
function usePageSnapshot(actions: modelUtil.ActionTraceEventInContext[]) {
@ -53,18 +54,47 @@ function usePageSnapshot(actions: modelUtil.ActionTraceEventInContext[]) {
}, [actions], undefined);
}
function useCodeFrame(stack: StackFrame[] | undefined, sources: Map<string, modelUtil.SourceModel>, width: number) {
const selectedFrame = stack?.[0];
const { source } = useSources(stack, 0, sources);
return React.useMemo(() => {
if (!source.content)
return '';
const targetLine = selectedFrame?.line ?? 0;
const lines = source.content.split('\n');
const start = Math.max(0, targetLine - width);
const end = Math.min(lines.length, targetLine + width);
const lineNumberWidth = String(end).length;
const codeFrame = lines.slice(start, end).map((line, i) => {
const lineNumber = start + i + 1;
const paddedLineNumber = String(lineNumber).padStart(lineNumberWidth, ' ');
if (lineNumber !== targetLine)
return ` ${(paddedLineNumber)} | ${line}`;
let highlightLine = `> ${paddedLineNumber} | ${line}`;
if (selectedFrame?.column)
highlightLine += `\n${' '.repeat(4 + lineNumberWidth + selectedFrame.column)}^`;
return highlightLine;
}).join('\n');
return codeFrame;
}, [source, selectedFrame, width]);
}
const CopyPromptButton: React.FC<{
error: string;
codeFrame: string;
pageSnapshot?: string;
diff?: string;
}> = ({ error, pageSnapshot, diff }) => {
}> = ({ error, codeFrame, pageSnapshot, diff }) => {
const prompt = React.useMemo(
() => fixTestPrompt(
error,
error + '\n\n' + codeFrame,
diff,
pageSnapshot
),
[error, diff, pageSnapshot]
[error, diff, codeFrame, pageSnapshot]
);
return (
@ -72,7 +102,7 @@ const CopyPromptButton: React.FC<{
value={prompt}
description='Copy as Prompt'
copiedDescription={<>Copied <span className='codicon codicon-copy' style={{ marginLeft: '5px' }}/></>}
style={{ width: '90px', justifyContent: 'center' }}
style={{ width: '120px', justifyContent: 'center' }}
/>
);
};
@ -97,11 +127,10 @@ export function useErrorsTabModel(model: modelUtil.MultiTraceModel | undefined):
}, [model]);
}
function Error({ message, error, errorId, sdkLanguage, pageSnapshot, revealInSource }: { message: string, error: ErrorDescription, errorId: string, sdkLanguage: Language, pageSnapshot?: string, revealInSource: (error: ErrorDescription) => void }) {
function Error({ message, error, errorId, sdkLanguage, pageSnapshot, revealInSource, sources }: { message: string, error: ErrorDescription, errorId: string, sdkLanguage: Language, pageSnapshot?: string, revealInSource: (error: ErrorDescription) => void, sources: Map<string, modelUtil.SourceModel> }) {
const [showLLM, setShowLLM] = React.useState(false);
const llmAvailable = useIsLLMAvailable();
const gitCommitInfo = useGitCommitInfo();
const diff = gitCommitInfo?.['pull.diff'] ?? gitCommitInfo?.['revision.diff'];
const metadata = useCommitInfo();
let location: string | undefined;
let longLocation: string | undefined;
@ -112,6 +141,8 @@ function Error({ message, error, errorId, sdkLanguage, pageSnapshot, revealInSou
longLocation = stackFrame.file + ':' + stackFrame.line;
}
const codeFrame = useCodeFrame(error.stack, sources, 3);
return <div style={{ display: 'flex', flexDirection: 'column', overflowX: 'clip' }}>
<div className='hbox' style={{
alignItems: 'center',
@ -127,8 +158,8 @@ function Error({ message, error, errorId, sdkLanguage, pageSnapshot, revealInSou
</div>}
<span style={{ position: 'absolute', right: '5px' }}>
{llmAvailable
? <FixWithAIButton conversationId={errorId} onChange={setShowLLM} value={showLLM} error={message} diff={diff} pageSnapshot={pageSnapshot} />
: <CopyPromptButton error={message} pageSnapshot={pageSnapshot} diff={diff} />}
? <FixWithAIButton conversationId={errorId} onChange={setShowLLM} value={showLLM} error={message} diff={metadata?.gitDiff} pageSnapshot={pageSnapshot} />
: <CopyPromptButton error={message} codeFrame={codeFrame} pageSnapshot={pageSnapshot} diff={metadata?.gitDiff} />}
</span>
</div>
@ -180,9 +211,10 @@ export const ErrorsTab: React.FunctionComponent<{
errorsModel: ErrorsTabModel,
actions: modelUtil.ActionTraceEventInContext[],
wallTime: number,
sources: Map<string, modelUtil.SourceModel>,
sdkLanguage: Language,
revealInSource: (error: ErrorDescription) => void,
}> = ({ errorsModel, sdkLanguage, revealInSource, actions, wallTime }) => {
}> = ({ errorsModel, sdkLanguage, revealInSource, actions, wallTime, sources }) => {
const pageSnapshot = usePageSnapshot(actions);
if (!errorsModel.errors.size)
@ -191,7 +223,7 @@ export const ErrorsTab: React.FunctionComponent<{
return <div className='fill' style={{ overflow: 'auto' }}>
{[...errorsModel.errors.entries()].map(([message, error]) => {
const errorId = `error-${wallTime}-${message}`;
return <Error key={errorId} errorId={errorId} message={message} error={error} revealInSource={revealInSource} sdkLanguage={sdkLanguage} pageSnapshot={pageSnapshot} />;
return <Error key={errorId} errorId={errorId} message={message} error={error} sources={sources} revealInSource={revealInSource} sdkLanguage={sdkLanguage} pageSnapshot={pageSnapshot} />;
})}
</div>;
};

View file

@ -27,25 +27,8 @@ import { CopyToClipboard } from './copyToClipboard';
import { ToolbarButton } from '@web/components/toolbarButton';
import { Toolbar } from '@web/components/toolbar';
export const SourceTab: React.FunctionComponent<{
stack?: StackFrame[],
stackFrameLocation: 'bottom' | 'right',
sources: Map<string, SourceModel>,
rootDir?: string,
fallbackLocation?: SourceLocation,
onOpenExternally?: (location: SourceLocation) => void,
}> = ({ stack, sources, rootDir, fallbackLocation, stackFrameLocation, onOpenExternally }) => {
const [lastStack, setLastStack] = React.useState<StackFrame[] | undefined>();
const [selectedFrame, setSelectedFrame] = React.useState<number>(0);
React.useEffect(() => {
if (lastStack !== stack) {
setLastStack(stack);
setSelectedFrame(0);
}
}, [stack, lastStack, setLastStack, setSelectedFrame]);
const { source, highlight, targetLine, fileName, location } = useAsyncMemo<{ source: SourceModel, targetLine?: number, fileName?: string, highlight: SourceHighlight[], location?: SourceLocation }>(async () => {
export function useSources(stack: StackFrame[] | undefined, selectedFrame: number, sources: Map<string, SourceModel>, rootDir?: string, fallbackLocation?: SourceLocation) {
return useAsyncMemo<{ source: SourceModel, targetLine?: number, fileName?: string, highlight: SourceHighlight[], location?: SourceLocation }>(async () => {
const actionLocation = stack?.[selectedFrame];
const shouldUseFallback = !actionLocation?.file;
if (shouldUseFallback && !fallbackLocation)
@ -84,6 +67,27 @@ export const SourceTab: React.FunctionComponent<{
}
return { source, highlight, targetLine, fileName, location };
}, [stack, selectedFrame, rootDir, fallbackLocation], { source: { errors: [], content: 'Loading\u2026' }, highlight: [] });
}
export const SourceTab: React.FunctionComponent<{
stack?: StackFrame[],
stackFrameLocation: 'bottom' | 'right',
sources: Map<string, SourceModel>,
rootDir?: string,
fallbackLocation?: SourceLocation,
onOpenExternally?: (location: SourceLocation) => void,
}> = ({ stack, sources, rootDir, fallbackLocation, stackFrameLocation, onOpenExternally }) => {
const [lastStack, setLastStack] = React.useState<StackFrame[] | undefined>();
const [selectedFrame, setSelectedFrame] = React.useState<number>(0);
React.useEffect(() => {
if (lastStack !== stack) {
setLastStack(stack);
setSelectedFrame(0);
}
}, [stack, lastStack, setLastStack, setSelectedFrame]);
const { source, highlight, targetLine, fileName, location } = useSources(stack, selectedFrame, sources, rootDir, fallbackLocation);
const openExternally = React.useCallback(() => {
if (!location)

View file

@ -37,8 +37,9 @@ import { TestListView } from './uiModeTestListView';
import { TraceView } from './uiModeTraceView';
import { SettingsView } from './settingsView';
import { DefaultSettingsView } from './defaultSettingsView';
import { GitCommitInfoProvider } from './errorsTab';
import { CommitInfoProvider } from './errorsTab';
import { LLMProvider } from './llm';
import type { MetadataWithCommitInfo } from '@testIsomorphic/types';
let xtermSize = { cols: 80, rows: 24 };
const xtermDataSource: XtermDataSource = {
@ -432,7 +433,7 @@ export const UIModeView: React.FC<{}> = ({
<XtermWrapper source={xtermDataSource}></XtermWrapper>
</div>
<div className={clsx('vbox', isShowingOutput && 'hidden')}>
<GitCommitInfoProvider gitCommitInfo={testModel?.config.metadata['git.commit.info']}>
<CommitInfoProvider commitInfo={testModel?.config.metadata as MetadataWithCommitInfo}>
<TraceView
pathSeparator={queryParams.pathSeparator}
item={selectedItem}
@ -440,7 +441,7 @@ export const UIModeView: React.FC<{}> = ({
revealSource={revealSource}
onOpenExternally={location => testServerConnection?.openNoReply({ location: { file: location.file, line: location.line, column: location.column } })}
/>
</GitCommitInfoProvider>
</CommitInfoProvider>
</div>
</div>}
sidebar={<div className='vbox ui-mode-sidebar'>

View file

@ -193,7 +193,7 @@ export const Workbench: React.FunctionComponent<{
id: 'errors',
title: 'Errors',
errorCount: errorsModel.errors.size,
render: () => <ErrorsTab errorsModel={errorsModel} sdkLanguage={sdkLanguage} revealInSource={error => {
render: () => <ErrorsTab errorsModel={errorsModel} sources={sources} sdkLanguage={sdkLanguage} revealInSource={error => {
if (error.action)
setSelectedAction(error.action);
else

View file

@ -1,67 +0,0 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
*
* 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 { browserTest as it, expect } from '../config/browserTest';
it('should throw when apiRequestFailsOnErrorStatus is set to true inside BrowserContext options', async ({ browser, server }) => {
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/34204' });
const context = await browser.newContext({ apiRequestFailsOnErrorStatus: true });
server.setRoute('/empty.html', (req, res) => {
res.writeHead(404, { 'Content-Length': 10, 'Content-Type': 'text/plain' });
res.end('Not found.');
});
const error = await context.request.fetch(server.EMPTY_PAGE).catch(e => e);
expect(error.message).toContain('404 Not Found');
await context.close();
});
it('should not throw when failOnStatusCode is set to false inside BrowserContext options', async ({ browser, server }) => {
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/34204' });
const context = await browser.newContext({ apiRequestFailsOnErrorStatus: false });
server.setRoute('/empty.html', (req, res) => {
res.writeHead(404, { 'Content-Length': 10, 'Content-Type': 'text/plain' });
res.end('Not found.');
});
const error = await context.request.fetch(server.EMPTY_PAGE).catch(e => e);
expect(error.message).toBeUndefined();
await context.close();
});
it('should throw when apiRequestFailsOnErrorStatus is set to true inside browserType.launchPersistentContext options', async ({ browserType, server, createUserDataDir }) => {
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/34204' });
const userDataDir = await createUserDataDir();
const context = await browserType.launchPersistentContext(userDataDir, { apiRequestFailsOnErrorStatus: true });
server.setRoute('/empty.html', (req, res) => {
res.writeHead(404, { 'Content-Length': 10, 'Content-Type': 'text/plain' });
res.end('Not found.');
});
const error = await context.request.fetch(server.EMPTY_PAGE).catch(e => e);
expect(error.message).toContain('404 Not Found');
await context.close();
});
it('should not throw when apiRequestFailsOnErrorStatus is set to false inside browserType.launchPersistentContext options', async ({ browserType, server, createUserDataDir }) => {
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/34204' });
const userDataDir = await createUserDataDir();
const context = await browserType.launchPersistentContext(userDataDir, { apiRequestFailsOnErrorStatus: false });
server.setRoute('/empty.html', (req, res) => {
res.writeHead(404, { 'Content-Length': 10, 'Content-Type': 'text/plain' });
res.end('Not found.');
});
const error = await context.request.fetch(server.EMPTY_PAGE).catch(e => e);
expect(error.message).toBeUndefined();
await context.close();
});

View file

@ -110,7 +110,7 @@ it('should round-trip through the file', async ({ contextFactory }, testInfo) =>
});
const path = testInfo.outputPath('storage-state.json');
const state = await context.storageState({ path });
const state = await context.storageState({ path, indexedDB: true });
const written = await fs.promises.readFile(path, 'utf8');
expect(JSON.stringify(state, undefined, 2)).toBe(written);
@ -365,7 +365,7 @@ it('should support IndexedDB', async ({ page, server, contextFactory }) => {
await page.getByLabel('Mins').fill('1');
await page.getByText('Add Task').click();
const storageState = await page.context().storageState();
const storageState = await page.context().storageState({ indexedDB: true });
expect(storageState.origins).toEqual([
{
origin: server.PREFIX,
@ -438,7 +438,7 @@ it('should support IndexedDB', async ({ page, server, contextFactory }) => {
]);
const context = await contextFactory({ storageState });
expect(await context.storageState()).toEqual(storageState);
expect(await context.storageState({ indexedDB: true })).toEqual(storageState);
const recreatedPage = await context.newPage();
await recreatedPage.goto(server.PREFIX + '/to-do-notifications/index.html');
@ -448,5 +448,5 @@ it('should support IndexedDB', async ({ page, server, contextFactory }) => {
- text: /Pet the cat/
`);
expect(await context.storageState({ indexedDB: false })).toEqual({ cookies: [], origins: [] });
expect(await context.storageState()).toEqual({ cookies: [], origins: [] });
});

View file

@ -376,7 +376,7 @@ it('should preserve local storage on import/export of storage state', async ({ p
};
const request = await playwright.request.newContext({ storageState });
await request.get(server.EMPTY_PAGE);
const exportedState = await request.storageState();
const exportedState = await request.storageState({ indexedDB: true });
expect(exportedState).toEqual(storageState);
await request.dispose();
});

View file

@ -537,9 +537,9 @@ it('should retry ECONNRESET', {
await request.dispose();
});
it('should throw when apiRequestFailsOnErrorStatus is set to true inside APIRequest context options', async ({ playwright, server }) => {
it('should throw when failOnStatusCode is set to true inside APIRequest context options', async ({ playwright, server }) => {
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/34204' });
const request = await playwright.request.newContext({ apiRequestFailsOnErrorStatus: true });
const request = await playwright.request.newContext({ failOnStatusCode: true });
server.setRoute('/empty.html', (req, res) => {
res.writeHead(404, { 'Content-Length': 10, 'Content-Type': 'text/plain' });
res.end('Not found.');
@ -549,9 +549,9 @@ it('should throw when apiRequestFailsOnErrorStatus is set to true inside APIRequ
await request.dispose();
});
it('should not throw when apiRequestFailsOnErrorStatus is set to false inside APIRequest context options', async ({ playwright, server }) => {
it('should not throw when failOnStatusCode is set to false inside APIRequest context options', async ({ playwright, server }) => {
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/34204' });
const request = await playwright.request.newContext({ apiRequestFailsOnErrorStatus: false });
const request = await playwright.request.newContext({ failOnStatusCode: false });
server.setRoute('/empty.html', (req, res) => {
res.writeHead(404, { 'Content-Length': 10, 'Content-Type': 'text/plain' });
res.end('Not found.');

View file

@ -92,13 +92,14 @@ it('should support locator.or()', async ({ page }) => {
});
it('should support playwright.getBy*', async ({ page }) => {
await page.setContent('<span>Hello</span><span title="world">World</span>');
await page.setContent('<span>Hello</span><span title="world">World</span><div>one</div><div style="display:none">two</div>');
expect(await page.evaluate(`playwright.getByText('hello').element.innerHTML`)).toContain('Hello');
expect(await page.evaluate(`playwright.getByTitle('world').element.innerHTML`)).toContain('World');
expect(await page.evaluate(`playwright.locator('span').filter({ hasText: 'hello' }).element.innerHTML`)).toContain('Hello');
expect(await page.evaluate(`playwright.locator('span').first().element.innerHTML`)).toContain('Hello');
expect(await page.evaluate(`playwright.locator('span').last().element.innerHTML`)).toContain('World');
expect(await page.evaluate(`playwright.locator('span').nth(1).element.innerHTML`)).toContain('World');
expect(await page.evaluate(`playwright.locator('div').filter({ visible: false }).element.innerHTML`)).toContain('two');
});
it('expected properties on playwright object', async ({ page }) => {

View file

@ -320,6 +320,21 @@ it('reverse engineer hasNotText', async ({ page }) => {
});
});
it('reverse engineer visible', async ({ page }) => {
expect.soft(generate(page.getByText('Hello').filter({ visible: true }).locator('div'))).toEqual({
csharp: `GetByText("Hello").Filter(new() { Visible = true }).Locator("div")`,
java: `getByText("Hello").filter(new Locator.FilterOptions().setVisible(true)).locator("div")`,
javascript: `getByText('Hello').filter({ visible: true }).locator('div')`,
python: `get_by_text("Hello").filter(visible=True).locator("div")`,
});
expect.soft(generate(page.getByText('Hello').filter({ visible: false }).locator('div'))).toEqual({
csharp: `GetByText("Hello").Filter(new() { Visible = false }).Locator("div")`,
java: `getByText("Hello").filter(new Locator.FilterOptions().setVisible(false)).locator("div")`,
javascript: `getByText('Hello').filter({ visible: false }).locator('div')`,
python: `get_by_text("Hello").filter(visible=False).locator("div")`,
});
});
it('reverse engineer has', async ({ page }) => {
expect.soft(generate(page.getByText('Hello').filter({ has: page.locator('div').getByText('bye') }))).toEqual({
csharp: `GetByText("Hello").Filter(new() { Has = Locator("div").GetByText("bye") })`,

View file

@ -372,6 +372,13 @@ test('display:contents should be visible when contents are visible', async ({ pa
await expect(page.getByRole('button')).toHaveCount(1);
});
test('should remove soft hyphens and zero-width spaces', async ({ page }) => {
await page.setContent(`
<button>1\u00ad2\u200b3</button>
`);
expect.soft(await getNameAndRole(page, 'button')).toEqual({ role: 'button', name: '123' });
});
test('label/labelled-by aria-hidden with descendants', async ({ page }) => {
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29796' });

View file

@ -71,6 +71,8 @@ test.describe('toHaveText with text', () => {
await expect(locator).toHaveText('text CONTENT', { ignoreCase: true });
// Should support falsy ignoreCase.
await expect(locator).not.toHaveText('TEXT', { ignoreCase: false });
// Should normalize soft hyphens.
await expect(locator).toHaveText('T\u00ade\u00adxt content');
});
test('pass contain', async ({ page }) => {

View file

@ -90,7 +90,14 @@ it('should work with glob', async () => {
expect(globToRegex('http://localhost:3000/signin-oidc*').test('http://localhost:3000/signin-oidc/foo')).toBeFalsy();
expect(globToRegex('http://localhost:3000/signin-oidc*').test('http://localhost:3000/signin-oidcnice')).toBeTruthy();
expect(globToRegex('**/three-columns/settings.html?**id=[a-z]**').test('http://mydomain:8080/blah/blah/three-columns/settings.html?id=settings-e3c58efe-02e9-44b0-97ac-dd138100cf7c&blah')).toBeTruthy();
// range []
expect(globToRegex('**/api/v[0-9]').test('http://example.com/api/v1')).toBeTruthy();
expect(globToRegex('**/api/v[0-9]').test('http://example.com/api/version')).toBeFalsy();
// query params
expect(globToRegex('**/api\\?param').test('http://example.com/api?param')).toBeTruthy();
expect(globToRegex('**/api\\?param').test('http://example.com/api-param')).toBeFalsy();
expect(globToRegex('**/three-columns/settings.html\\?**id=[a-z]**').test('http://mydomain:8080/blah/blah/three-columns/settings.html?id=settings-e3c58efe-02e9-44b0-97ac-dd138100cf7c&blah')).toBeTruthy();
expect(globToRegex('\\?')).toEqual(/^\?$/);
expect(globToRegex('\\')).toEqual(/^\\$/);

View file

@ -150,6 +150,22 @@ it('should combine visible with other selectors', async ({ page }) => {
await expect(page.locator('.item >> visible=true >> text=data3')).toHaveText('visible data3');
});
it('should support filter(visible)', async ({ page }) => {
await page.setContent(`<div>
<div class="item" style="display: none">Hidden data0</div>
<div class="item">visible data1</div>
<div class="item" style="display: none">Hidden data1</div>
<div class="item">visible data2</div>
<div class="item" style="display: none">Hidden data2</div>
<div class="item">visible data3</div>
</div>
`);
const locator = page.locator('.item').filter({ visible: true }).nth(1);
await expect(locator).toHaveText('visible data2');
await expect(page.locator('.item').filter({ visible: true }).getByText('data3')).toHaveText('visible data3');
await expect(page.locator('.item').filter({ visible: false }).getByText('data1')).toHaveText('Hidden data1');
});
it('locator.count should work with deleted Map in main world', async ({ page }) => {
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/11254' });
await page.evaluate('Map = 1');

View file

@ -515,6 +515,7 @@ it('should normalize whitespace', async ({ page }) => {
<summary> one \n two <a href="#"> link &nbsp;\n 1 </a> </summary>
</details>
<input value=' hello &nbsp; world '>
<button>hello\u00ad\u200bworld</button>
`);
await checkAndMatchSnapshot(page.locator('body'), `
@ -522,6 +523,7 @@ it('should normalize whitespace', async ({ page }) => {
- text: one two
- link "link 1"
- textbox: hello world
- button "helloworld"
`);
// Weird whitespace in the template should be normalized.
@ -532,6 +534,7 @@ it('should normalize whitespace', async ({ page }) => {
two
- link " link 1 "
- textbox: hello world
- button "he\u00adlloworld\u200b"
`);
});

View file

@ -21,6 +21,7 @@ it('should check the box @smoke', async ({ page }) => {
await page.setContent(`<input id='checkbox' type='checkbox'></input>`);
await page.check('input');
expect(await page.evaluate(() => window['checkbox'].checked)).toBe(true);
await expect(page.locator('input[type="checkbox"]')).toBeChecked({ timeout: 1000 });
});
it('should not check the checked box', async ({ page }) => {

View file

@ -22,14 +22,14 @@ test.describe.configure({ mode: 'parallel' });
test('should match snapshot with name', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'a.spec.ts-snapshots/test.yml': `
'a.spec.ts-snapshots/test.snapshot.yml': `
- heading "hello world"
`,
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<h1>hello world</h1>\`);
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.yml' });
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.snapshot.yml' });
});
`
});
@ -43,66 +43,66 @@ test('should generate multiple missing', async ({ runInlineTest }, testInfo) =>
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<h1>hello world</h1>\`);
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-1.yml' });
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-1.snapshot.yml' });
await page.setContent(\`<h1>hello world 2</h1>\`);
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-2.yml' });
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-2.snapshot.yml' });
});
`
});
expect(result.exitCode).toBe(1);
expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-1.yml, writing actual`);
expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-2.yml, writing actual`);
const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-1.yml'), 'utf8');
expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-1.snapshot.yml, writing actual`);
expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-2.snapshot.yml, writing actual`);
const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-1.snapshot.yml'), 'utf8');
expect(snapshot1).toBe('- heading "hello world" [level=1]');
const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-2.yml'), 'utf8');
const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-2.snapshot.yml'), 'utf8');
expect(snapshot2).toBe('- heading "hello world 2" [level=1]');
});
test('should rebaseline all', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'a.spec.ts-snapshots/test-1.yml': `
'a.spec.ts-snapshots/test-1.snapshot.yml': `
- heading "foo"
`,
'a.spec.ts-snapshots/test-2.yml': `
'a.spec.ts-snapshots/test-2.snapshot.yml': `
- heading "bar"
`,
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<h1>hello world</h1>\`);
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-1.yml' });
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-1.snapshot.yml' });
await page.setContent(\`<h1>hello world 2</h1>\`);
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-2.yml' });
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-2.snapshot.yml' });
});
`
}, { 'update-snapshots': 'all' });
expect(result.exitCode).toBe(0);
expect(result.output).toContain(`A snapshot is generated at a.spec.ts-snapshots${path.sep}test-1.yml`);
expect(result.output).toContain(`A snapshot is generated at a.spec.ts-snapshots${path.sep}test-2.yml`);
const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-1.yml'), 'utf8');
expect(result.output).toContain(`A snapshot is generated at a.spec.ts-snapshots${path.sep}test-1.snapshot.yml`);
expect(result.output).toContain(`A snapshot is generated at a.spec.ts-snapshots${path.sep}test-2.snapshot.yml`);
const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-1.snapshot.yml'), 'utf8');
expect(snapshot1).toBe('- heading "hello world" [level=1]');
const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-2.yml'), 'utf8');
const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-2.snapshot.yml'), 'utf8');
expect(snapshot2).toBe('- heading "hello world 2" [level=1]');
});
test('should not rebaseline matching', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'a.spec.ts-snapshots/test.yml': `
'a.spec.ts-snapshots/test.snapshot.yml': `
- heading "hello world"
`,
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<h1>hello world</h1>\`);
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.yml' });
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.snapshot.yml' });
});
`
}, { 'update-snapshots': 'changed' });
expect(result.exitCode).toBe(0);
const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test.yml'), 'utf8');
const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test.snapshot.yml'), 'utf8');
expect(snapshot1.trim()).toBe('- heading "hello world"');
});
@ -120,14 +120,32 @@ test('should generate snapshot name', async ({ runInlineTest }, testInfo) => {
});
expect(result.exitCode).toBe(1);
expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-name-1.yml, writing actual`);
expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-name-2.yml, writing actual`);
const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-name-1.yml'), 'utf8');
expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-name-1.snapshot.yml, writing actual`);
expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-name-2.snapshot.yml, writing actual`);
const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-name-1.snapshot.yml'), 'utf8');
expect(snapshot1).toBe('- heading "hello world" [level=1]');
const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-name-2.yml'), 'utf8');
const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-name-2.snapshot.yml'), 'utf8');
expect(snapshot2).toBe('- heading "hello world 2" [level=1]');
});
test('backwards compat with .yml extension', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.spec.ts-snapshots/test-1.yml': `
- heading "hello old world"
`,
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<h1>hello new world</h1>\`);
await expect(page.locator('body')).toMatchAriaSnapshot();
});
`
}, { 'update-snapshots': 'changed' });
expect(result.exitCode).toBe(0);
expect(result.output).toContain(`A snapshot is generated at a.spec.ts-snapshots${path.sep}test-1.yml.`);
});
for (const updateSnapshots of ['all', 'changed', 'missing', 'none']) {
test(`should update snapshot with the update-snapshots=${updateSnapshots} (config)`, async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
@ -143,13 +161,13 @@ for (const updateSnapshots of ['all', 'changed', 'missing', 'none']) {
await expect(page.locator('body')).toMatchAriaSnapshot({ timeout: 1 });
});
`,
'a.spec.ts-snapshots/test-1.yml': '- heading "Old content" [level=1]',
'a.spec.ts-snapshots/test-1.snapshot.yml': '- heading "Old content" [level=1]',
});
const rebase = updateSnapshots === 'all' || updateSnapshots === 'changed';
expect(result.exitCode).toBe(rebase ? 0 : 1);
if (rebase) {
const snapshotOutputPath = testInfo.outputPath('a.spec.ts-snapshots/test-1.yml');
const snapshotOutputPath = testInfo.outputPath('a.spec.ts-snapshots/test-1.snapshot.yml');
expect(result.output).toContain(`A snapshot is generated at`);
const data = fs.readFileSync(snapshotOutputPath);
expect(data.toString()).toBe('- heading "New content" [level=1]');
@ -169,7 +187,7 @@ test('should respect timeout', async ({ runInlineTest }, testInfo) => {
await expect(page.locator('body')).toMatchAriaSnapshot({ timeout: 1 });
});
`,
'a.spec.ts-snapshots/test-1.yml': '- heading "new world" [level=1]',
'a.spec.ts-snapshots/test-1.snapshot.yml': '- heading "new world" [level=1]',
});
expect(result.exitCode).toBe(1);
@ -183,14 +201,14 @@ test('should respect config.snapshotPathTemplate', async ({ runInlineTest }, tes
snapshotPathTemplate: 'my-snapshots/{testFilePath}/{arg}{ext}',
};
`,
'my-snapshots/dir/a.spec.ts/test.yml': `
'my-snapshots/dir/a.spec.ts/test.snapshot.yml': `
- heading "hello world"
`,
'dir/a.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<h1>hello world</h1>\`);
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.yml' });
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.snapshot.yml' });
});
`
});
@ -210,17 +228,17 @@ test('should respect config.expect.toMatchAriaSnapshot.pathTemplate', async ({ r
},
};
`,
'my-snapshots/dir/a.spec.ts/test.yml': `
'my-snapshots/dir/a.spec.ts/test.snapshot.yml': `
- heading "wrong one"
`,
'actual-snapshots/dir/a.spec.ts/test.yml': `
'actual-snapshots/dir/a.spec.ts/test.snapshot.yml': `
- heading "hello world"
`,
'dir/a.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<h1>hello world</h1>\`);
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.yml' });
await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.snapshot.yml' });
});
`
});

View file

@ -435,29 +435,29 @@ test('should work with pageSnapshot: on', async ({ runInlineTest }, testInfo) =>
expect(listFiles(testInfo.outputPath('test-results'))).toEqual([
'.last-run.json',
'artifacts-failing',
' test-failed-1.ariasnapshot',
' test-failed-1.snapshot.yml',
'artifacts-own-context-failing',
' test-failed-1.ariasnapshot',
' test-failed-1.snapshot.yml',
'artifacts-own-context-passing',
' test-finished-1.ariasnapshot',
' test-finished-1.snapshot.yml',
'artifacts-passing',
' test-finished-1.ariasnapshot',
' test-finished-1.snapshot.yml',
'artifacts-persistent-failing',
' test-failed-1.ariasnapshot',
' test-failed-1.snapshot.yml',
'artifacts-persistent-passing',
' test-finished-1.ariasnapshot',
' test-finished-1.snapshot.yml',
'artifacts-shared-shared-failing',
' test-failed-1.ariasnapshot',
' test-failed-2.ariasnapshot',
' test-failed-1.snapshot.yml',
' test-failed-2.snapshot.yml',
'artifacts-shared-shared-passing',
' test-finished-1.ariasnapshot',
' test-finished-2.ariasnapshot',
' test-finished-1.snapshot.yml',
' test-finished-2.snapshot.yml',
'artifacts-two-contexts',
' test-finished-1.ariasnapshot',
' test-finished-2.ariasnapshot',
' test-finished-1.snapshot.yml',
' test-finished-2.snapshot.yml',
'artifacts-two-contexts-failing',
' test-failed-1.ariasnapshot',
' test-failed-2.ariasnapshot',
' test-failed-1.snapshot.yml',
' test-failed-2.snapshot.yml',
]);
});
@ -475,16 +475,16 @@ test('should work with pageSnapshot: only-on-failure', async ({ runInlineTest },
expect(listFiles(testInfo.outputPath('test-results'))).toEqual([
'.last-run.json',
'artifacts-failing',
' test-failed-1.ariasnapshot',
' test-failed-1.snapshot.yml',
'artifacts-own-context-failing',
' test-failed-1.ariasnapshot',
' test-failed-1.snapshot.yml',
'artifacts-persistent-failing',
' test-failed-1.ariasnapshot',
' test-failed-1.snapshot.yml',
'artifacts-shared-shared-failing',
' test-failed-1.ariasnapshot',
' test-failed-2.ariasnapshot',
' test-failed-1.snapshot.yml',
' test-failed-2.snapshot.yml',
'artifacts-two-contexts-failing',
' test-failed-1.ariasnapshot',
' test-failed-2.ariasnapshot',
' test-failed-1.snapshot.yml',
' test-failed-2.snapshot.yml',
]);
});

View file

@ -1187,12 +1187,11 @@ for (const useIntermediateMergeReport of [true, false] as const) {
]);
});
test('should include metadata with populateGitInfo = true', async ({ runInlineTest, writeFiles, showReport, page }) => {
test('should include metadata with gitCommit', async ({ runInlineTest, writeFiles, showReport, page }) => {
const files = {
'uncommitted.txt': `uncommitted file`,
'playwright.config.ts': `
export default {
populateGitInfo: true,
metadata: { foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] }
};
`,
@ -1220,6 +1219,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
const result = await runInlineTest(files, { reporter: 'dot,html' }, {
PLAYWRIGHT_HTML_OPEN: 'never',
GITHUB_ACTIONS: '1',
GITHUB_REPOSITORY: 'microsoft/playwright-example-for-test',
GITHUB_SERVER_URL: 'https://playwright.dev',
GITHUB_SHA: 'example-sha',
@ -1230,19 +1230,22 @@ for (const useIntermediateMergeReport of [true, false] as const) {
expect(result.exitCode).toBe(0);
await page.getByRole('button', { name: 'Metadata' }).click();
await expect(page.locator('.metadata-view')).toMatchAriaSnapshot(`
- 'link "chore(html): make this test look nice"'
- text: /^William <shakespeare@example.local> on/
- link /^[a-f0-9]{7}$/
- text: 'foo : value1 bar : {"prop":"value2"} baz : ["value3",123]'
- list:
- listitem:
- 'link "chore(html): make this test look nice"'
- listitem: /William <shakespeare@example\\.local>/
- list:
- listitem: "foo : value1"
- listitem: "bar : {\\"prop\\":\\"value2\\"}"
- listitem: "baz : [\\"value3\\",123]"
`);
});
test('should include metadata with populateGitInfo on GHA', async ({ runInlineTest, writeFiles, showReport, page }) => {
test('should include metadata on GHA', async ({ runInlineTest, writeFiles, showReport, page }) => {
const files = {
'uncommitted.txt': `uncommitted file`,
'playwright.config.ts': `
export default {
populateGitInfo: true,
metadata: { foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] }
};
`,
@ -1279,6 +1282,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
const result = await runInlineTest(files, { reporter: 'dot,html' }, {
PLAYWRIGHT_HTML_OPEN: 'never',
GITHUB_ACTIONS: '1',
GITHUB_REPOSITORY: 'microsoft/playwright-example-for-test',
GITHUB_RUN_ID: 'example-run-id',
GITHUB_SERVER_URL: 'https://playwright.dev',
@ -1291,18 +1295,21 @@ for (const useIntermediateMergeReport of [true, false] as const) {
expect(result.exitCode).toBe(0);
await page.getByRole('button', { name: 'Metadata' }).click();
await expect(page.locator('.metadata-view')).toMatchAriaSnapshot(`
- 'link "My PR"'
- text: /^William <shakespeare@example.local> on/
- link "Logs"
- link "Pull Request"
- text: 'foo : value1 bar : {"prop":"value2"} baz : ["value3",123]'
- list:
- listitem:
- link "My PR"
- listitem: /William <shakespeare@example.local>/
- list:
- listitem: "foo : value1"
- listitem: "bar : {\\"prop\\":\\"value2\\"}"
- listitem: "baz : [\\"value3\\",123]"
`);
});
test('should not include git metadata with populateGitInfo = false', async ({ runInlineTest, showReport, page }) => {
test('should not include git metadata w/o gitCommit', async ({ runInlineTest, showReport, page }) => {
const result = await runInlineTest({
'playwright.config.ts': `
export default { populateGitInfo: false };
export default {};
`,
'example.spec.ts': `
import { test, expect } from '@playwright/test';
@ -1323,7 +1330,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
'playwright.config.ts': `
export default {
metadata: {
'git.commit.info': { 'revision.timestamp': 'hi' }
gitCommit: { author: { date: 'hi' } }
},
};
`,
@ -2757,8 +2764,11 @@ for (const useIntermediateMergeReport of [true, false] as const) {
'uncommitted.txt': `uncommitted file`,
'playwright.config.ts': `
export default {
populateGitInfo: true,
metadata: { foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] }
metadata: {
foo: 'value1',
bar: { prop: 'value2' },
baz: ['value3', 123]
}
};
`,
'example.spec.ts': `
@ -2788,6 +2798,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
const result = await runInlineTest(files, { reporter: 'dot,html' }, {
PLAYWRIGHT_HTML_OPEN: 'never',
GITHUB_ACTIONS: '1',
GITHUB_REPOSITORY: 'microsoft/playwright-example-for-test',
GITHUB_RUN_ID: 'example-run-id',
GITHUB_SERVER_URL: 'https://playwright.dev',

View file

@ -1520,9 +1520,7 @@ pw:api | browserContext.newPage
test.step |custom step @ a.test.ts:4
pw:api | page.route @ a.test.ts:5
pw:api | page.goto(${server.EMPTY_PAGE}) @ a.test.ts:12
pw:api | apiResponse.text @ a.test.ts:7
expect | expect.toBe @ a.test.ts:8
pw:api | apiResponse.text @ a.test.ts:9
hook |After Hooks
fixture | fixture: page
fixture | fixture: context

View file

@ -164,6 +164,26 @@ test('should ignore test.setTimeout when debugging', async ({ runInlineTest }) =
expect(result.passed).toBe(1);
});
test('should ignore globalTimeout when debugging', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/34911' },
}, async ({ runInlineTest }) => {
const result = await runInlineTest({
'playwright.config.ts': `
export default {
globalTimeout: 100,
};
`,
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('my test', async ({ }) => {
await new Promise(f => setTimeout(f, 2000));
});
`
}, { debug: true });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});
test('should respect fixture timeout', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.spec.ts': `

View file

@ -21,14 +21,13 @@ test('should render html report git info metadata', async ({ runUITest }) => {
'reporter.ts': `
module.exports = class Reporter {
onBegin(config, suite) {
console.log('ci.link:', config.metadata['git.commit.info']['ci.link']);
console.log('ci.link:', config.metadata['ci'].commitHref);
}
}
`,
'playwright.config.ts': `
import { defineConfig } from '@playwright/test';
export default defineConfig({
populateGitInfo: true,
reporter: './reporter.ts',
});
`,
@ -37,6 +36,7 @@ test('should render html report git info metadata', async ({ runUITest }) => {
test('should work', async ({}) => {});
`
}, {
JENKINS_URL: '1',
BUILD_URL: 'https://playwright.dev',
});

View file

@ -503,11 +503,11 @@ test('skipped steps should have an indicator', async ({ runUITest }) => {
test('should show copy prompt button in errors tab', async ({ runUITest }) => {
const { page } = await runUITest({
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('fails', async () => {
expect(1).toBe(2);
});
`,
import { test, expect } from '@playwright/test';
test('fails', async () => {
expect(1).toBe(2);
});
`.trim(),
});
await page.getByText('fails').dblclick();
@ -517,4 +517,11 @@ test('should show copy prompt button in errors tab', async ({ runUITest }) => {
await page.locator('.tab-errors').getByRole('button', { name: 'Copy as Prompt' }).click();
const prompt = await page.evaluate(() => navigator.clipboard.readText());
expect(prompt, 'contains error').toContain('expect(received).toBe(expected)');
expect(prompt, 'contains codeframe').toContain(`
1 | import { test, expect } from '@playwright/test';
2 | test('fails', async () => {
> 3 | expect(1).toBe(2);
^
4 | });
`.trim());
});

View file

@ -125,7 +125,7 @@ class Workspace {
}
await maybeWriteJSON(pkg.packageJSONPath, pkg.packageJSON);
}
// Re-run npm i to make package-lock dirty.
child_process.execSync('npm i');
return hasChanges;
@ -167,6 +167,11 @@ const workspace = new Workspace(ROOT_PATH, [
path: path.join(ROOT_PATH, 'packages', 'playwright-chromium'),
files: LICENCE_FILES,
}),
new PWPackage({
name: '@playwright/client',
path: path.join(ROOT_PATH, 'packages', 'playwright-client'),
files: LICENCE_FILES,
}),
new PWPackage({
name: '@playwright/experimental-tools',
path: path.join(ROOT_PATH, 'packages', 'playwright-tools'),