Compare commits

..

22 commits

Author SHA1 Message Date
Pavel Feldman 31f4a05eb6
chore: make git diff different for CI and local (#34955) 2025-02-27 14:06:13 -08:00
Pavel Feldman 7a61aa25e6
chore: provide blob name for web and html reporter tests (#34940) 2025-02-27 13:22:07 -08:00
Adam Gastineau 67d6f7f603
chore: temporarily disable floating promise warning messages (#34957) 2025-02-27 12:45:30 -08:00
Adam Gastineau b0ceed51a5
docs: Improve toHaveURL doc clarity (#34935) 2025-02-27 11:00:50 -08:00
Dmitry Gozman 08ea36caa2
chore: default reportSlowTests to 5 minutes (#34950) 2025-02-27 16:31:32 +00:00
Dmitry Gozman 70cc2b14e2
chore: apply "injected" eslint rules to "isomorphic" (#34953) 2025-02-27 16:31:05 +00:00
Playwright Service ad64f8d859
feat(chromium): roll to r1161 (#34952)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2025-02-27 17:01:07 +01:00
Simon Knott a1146fd4a3
chore: copy as prompt in ui should have codeframe (#34943)
Signed-off-by: Simon Knott <info@simonknott.de>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-02-27 15:50:46 +01:00
Dmitry Gozman 3ce9ae6a7d
chore: replace locator.visible with filter({ visible }) (#34947) 2025-02-27 13:44:53 +00:00
Simon Knott 837abfbc15
chore: document indexeddb type as "unknown" (#34944) 2025-02-27 14:29:22 +01:00
Simon Knott 10fc0ef221
chore: make indexeddb opt-in (#34942) 2025-02-27 14:27:54 +01:00
Pavel Feldman 58db3f7e3f
chore: restore pr title in html report (#34937) 2025-02-26 19:22:31 -08:00
Henrik Skupin a803e6053a
chore(bidi): use fractional coordinates for pointerAction (#34929) 2025-02-26 16:28:14 -08:00
Yury Semikhatsky b5fe029c1b
chore: remove failOnStatusCode from Browser.newContext, rename (#34936) 2025-02-26 15:03:21 -08:00
Pavel Feldman cd23a224f6
chore: another iteration on gitCommit/gitDiff props (#34926) 2025-02-26 08:40:30 -08:00
Simon Knott 17c4d8e5ec
chore: change generated filename for toMatchAriaSnapshot from .yml to .snapshot.yml (#34931)
Signed-off-by: Simon Knott <info@simonknott.de>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Dmitry Gozman <dgozman@gmail.com>
2025-02-26 16:29:56 +01:00
Simon Knott a04a93c1fd
chore: change pageSnapshot extension to .snapshot.yml (#34930) 2025-02-26 14:27:17 +01:00
Dmitry Gozman 439427c14e
chore: remove stages from TestInfoImpl (#34919) 2025-02-26 10:55:02 +00:00
Yury Semikhatsky aaac9923fd
fix: disable global timeout when debugging (#34922) 2025-02-25 11:33:15 -08:00
Pavel Feldman 411f938296
chore: clean up git commit metadata props and UI (#34867) 2025-02-25 09:21:17 -08:00
Dmitry Gozman b148cbad76
fix: remove unicode soft hyphen in normalizeWhitespace (#34920) 2025-02-25 16:54:02 +00:00
Simon Knott a9bbf4b56d
docs: recommend localhost over 127.0.0.1 (#34918) 2025-02-25 15:40:38 +01:00
81 changed files with 1124 additions and 1581 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]>
@ -2478,18 +2481,6 @@ When all steps combined have not finished during the specified [`option: timeout
### option: Locator.uncheck.trial = %%-input-trial-%%
* since: v1.14
## method: Locator.visible
* since: v1.51
- returns: <[Locator]>
Returns a locator that only matches [visible](../actionability.md#visible) elements.
### option: Locator.visible.visible
* since: v1.51
- `visible` <[boolean]>
Whether to match visible or invisible elements.
## async method: Locator.waitFor
* since: v1.16

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

@ -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').visible().click();
await page.locator('button').filter({ visible: true }).click();
```
```java
page.locator("button").visible().click();
page.locator("button").filter(new Locator.FilterOptions.setVisible(true)).click();
```
```python async
await page.locator("button").visible().click()
await page.locator("button").filter(visible=True).click()
```
```python sync
page.locator("button").visible().click()
page.locator("button").filter(visible=True).click()
```
```csharp
await page.Locator("button").Visible().ClickAsync();
await page.Locator("button").Filter(new() { Visible = true }).ClickAsync();
```
## Lists

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">>
@ -446,7 +429,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.
@ -680,7 +663,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,
},
@ -709,19 +692,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

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

@ -23,7 +23,11 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
snapshotPathTemplate: '{testDir}/__screenshots__/{projectName}/{testFilePath}/{arg}{ext}',
reporter: process.env.CI ? 'blob' : 'html',
reporter: process.env.CI ? [
['blob', { fileName: `${process.env.PWTEST_BOT_NAME}.zip` }],
] : [
['html']
],
use: {
ctPort: 3101,
ctViteConfig: {

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;
/**
@ -14615,17 +14525,6 @@ export interface Locator {
trial?: boolean;
}): Promise<void>;
/**
* Returns a locator that only matches [visible](https://playwright.dev/docs/actionability#visible) elements.
* @param options
*/
visible(options?: {
/**
* Whether to match visible or invisible elements.
*/
visible?: boolean;
}): Locator;
/**
* Returns when element specified by locator satisfies the
* [`state`](https://playwright.dev/docs/api/class-locator#locator-wait-for-option-state) option.
@ -14824,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.
*
@ -16720,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.
*
@ -17572,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)
@ -17653,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.
@ -17754,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>;
}>;
};
@ -18576,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;
@ -18618,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>;
}>;
}>;
@ -22157,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),
@ -22518,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,27 +3,27 @@
"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",
"revision": "1306",
"revision": "1304",
"installByDefault": false,
"browserVersion": "135.0.7035.0"
"browserVersion": "135.0.7021.0"
},
{
"name": "chromium-tip-of-tree-headless-shell",
"revision": "1306",
"revision": "1304",
"installByDefault": false,
"browserVersion": "135.0.7035.0"
"browserVersion": "135.0.7021.0"
},
{
"name": "firefox",

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

@ -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)
@ -218,11 +222,6 @@ export class Locator implements api.Locator {
return new Locator(this._frame, this._selector + ` >> nth=${index}`);
}
visible(options: { visible?: boolean } = {}): Locator {
const { visible = true } = options;
return new Locator(this._frame, this._selector + ` >> visible=${visible ? 'true' : 'false'}`);
}
and(locator: Locator): Locator {
if (locator._frame !== this._frame)
throw new Error(`Locators must belong to the same 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

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

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

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

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

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

@ -280,7 +280,7 @@ export class JavaScriptLocatorFactory implements LocatorFactory {
case 'last':
return `last()`;
case 'visible':
return `visible(${body === 'true' ? '' : '{ visible: false }'})`;
return `filter({ visible: ${body === 'true' ? 'true' : 'false'} })`;
case 'role':
const attrs: string[] = [];
if (isRegExp(options.name)) {
@ -376,7 +376,7 @@ export class PythonLocatorFactory implements LocatorFactory {
case 'last':
return `last`;
case 'visible':
return `visible(${body === 'true' ? '' : 'visible=False'})`;
return `filter(visible=${body === 'true' ? 'True' : 'False'})`;
case 'role':
const attrs: string[] = [];
if (isRegExp(options.name)) {
@ -485,7 +485,7 @@ export class JavaLocatorFactory implements LocatorFactory {
case 'last':
return `last()`;
case 'visible':
return `visible(${body === 'true' ? '' : `new ${clazz}.VisibleOptions().setVisible(false)`})`;
return `filter(new ${clazz}.FilterOptions().setVisible(${body === 'true' ? 'true' : 'false'}))`;
case 'role':
const attrs: string[] = [];
if (isRegExp(options.name)) {
@ -584,7 +584,7 @@ export class CSharpLocatorFactory implements LocatorFactory {
case 'last':
return `Last`;
case 'visible':
return `Visible(${body === 'true' ? '' : 'new() { Visible = false }'})`;
return `Filter(new() { Visible = ${body === 'true' ? 'true' : 'false'} })`;
case 'role':
const attrs: string[] = [];
if (isRegExp(options.name)) {

View file

@ -170,9 +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(/visible\(,?visible=true\)/g, 'visible=true')
.replace(/visible\(,?visible=false\)/g, 'visible=false')
.replace(/visible\(\)/g, 'visible=true')
.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;
/**
@ -14615,17 +14525,6 @@ export interface Locator {
trial?: boolean;
}): Promise<void>;
/**
* Returns a locator that only matches [visible](https://playwright.dev/docs/actionability#visible) elements.
* @param options
*/
visible(options?: {
/**
* Whether to match visible or invisible elements.
*/
visible?: boolean;
}): Locator;
/**
* Returns when element specified by locator satisfies the
* [`state`](https://playwright.dev/docs/api/class-locator#locator-wait-for-option-state) option.
@ -14824,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.
*
@ -16720,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.
*
@ -17572,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)
@ -17653,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.
@ -17754,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>;
}>;
};
@ -18576,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;
@ -18618,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>;
}>;
}>;
@ -22157,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),
@ -22518,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;
cliArgs: string[] = [];
cliGrep: string | undefined;
cliGrepInvert: string | undefined;
@ -78,7 +77,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);
@ -94,14 +92,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),
@ -301,7 +299,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,170 @@
* 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)}:`;
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 }
);
if (commitInfoResult.code)
return;
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;
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 output = await runGit(`git log -1 --pretty=format:"${tokens.join(separator)}" && git rev-parse --abbrev-ref HEAD`, gitDir);
if (!output)
return undefined;
const [hash, shortHash, subject, body, authorName, authorEmail, authorTime, committerName, committerEmail, committerTime, branch] = output.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);
async function gitDiff(gitDir: string, ci?: CIInfo): Promise<string | undefined> {
const diffLimit = 100_000;
if (ci) {
// First try the diff against the base branch.
const diff = await runGit(`git diff ${ci.baseHash}`, gitDir);
if (diff)
return diff.substring(0, diffLimit);
// Grow history for shallow checkout.
const output = await runGit('git fetch --deepen=1 && git show HEAD', gitDir);
return output?.substring(0, diffLimit);
}
return result;
// Check dirty state first.
const uncommitted = await runGit('git diff', gitDir);
if (uncommitted)
return uncommitted.substring(0, diffLimit);
// Assume non-shallow checkout on local.
const diff = await runGit('git diff HEAD~1', gitDir);
return diff?.substring(0, diffLimit);
}
async function runGit(command: string, cwd: string): Promise<string | undefined> {
const result = await spawnAsync(
command,
[],
{ stdio: 'pipe', cwd, timeout: GIT_OPERATIONS_TIMEOUT_MS, shell: true }
);
if (result.code) {
// eslint-disable-next-line no-console
console.error(`Failed to run ${command}: ${result.stderr}`);
}
return result.code ? undefined : result.stdout.trim();
}

View file

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

View file

@ -270,7 +270,8 @@ export class TerminalReporter implements ReporterV2 {
if (full && summary.failuresToPrint.length && !this._omitFailures)
this._printFailures(summary.failuresToPrint);
this._printSlowTests();
this._printWarnings();
// TODO: 1.52: Make warning display prettier
// this._printWarnings();
this._printSummary(summaryMessage);
}

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

@ -23,6 +23,9 @@ export class FloatingPromiseScope {
* **NOTE:** Returning from an async function wraps the result in a promise, regardless of whether the return value is a promise. This will automatically mark the promise as awaited. Avoid this.
*/
wrapPromiseAPIResult<T>(promise: Promise<T>): Promise<T> {
if (process.env.PW_DISABLE_FLOATING_PROMISES_WARNING)
return promise;
const promiseProxy = new Proxy(promise, {
get: (target, prop, receiver) => {
if (prop === 'then') {

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));
@ -324,14 +324,17 @@ export class WorkerMain extends ProcessRunner {
// Create warning if any of the async calls were not awaited in various stages.
const checkForFloatingPromises = (functionDescription: string) => {
if (process.env.PW_DISABLE_FLOATING_PROMISES_WARNING)
return;
if (!testInfo._floatingPromiseScope.hasFloatingPromises())
return;
testInfo.annotations.push({ type: 'warning', description: `Some async calls were not awaited by the end of ${functionDescription}. This can cause flakiness.` });
// TODO: 1.52: Actually build annotations
// testInfo.annotations.push({ type: 'warning', description: `Some async calls were not awaited by the end of ${functionDescription}. This can cause flakiness.` });
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 +359,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 +379,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;
@ -393,12 +396,12 @@ export class WorkerMain extends ProcessRunner {
// After hooks get an additional timeout.
const afterHooksTimeout = calculateMaxTimeout(this._project.project.timeout, testInfo.timeout);
const afterHooksSlot = { timeout: afterHooksTimeout, elapsed: 0 };
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;
}
@ -448,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.
@ -481,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.
@ -534,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 };
@ -587,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
@ -1465,7 +1443,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;
};
@ -1917,12 +1895,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;
};
@ -8815,14 +8793,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
@ -8924,13 +8902,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 (
@ -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

@ -19,8 +19,7 @@ import type { ReporterDescription } from '@playwright/test';
const reporters = () => {
const result: ReporterDescription[] = process.env.CI ? [
['html'],
['blob'],
['blob', { fileName: `${process.env.PWTEST_BOT_NAME}.zip` }],
] : [
['html']
];

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

@ -321,23 +321,17 @@ it('reverse engineer hasNotText', async ({ page }) => {
});
it('reverse engineer visible', async ({ page }) => {
expect.soft(generate(page.getByText('Hello').visible().locator('div'))).toEqual({
csharp: `GetByText("Hello").Visible().Locator("div")`,
java: `getByText("Hello").visible().locator("div")`,
javascript: `getByText('Hello').visible().locator('div')`,
python: `get_by_text("Hello").visible().locator("div")`,
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').visible({ visible: true }).locator('div'))).toEqual({
csharp: `GetByText("Hello").Visible().Locator("div")`,
java: `getByText("Hello").visible().locator("div")`,
javascript: `getByText('Hello').visible().locator('div')`,
python: `get_by_text("Hello").visible().locator("div")`,
});
expect.soft(generate(page.getByText('Hello').visible({ visible: false }).locator('div'))).toEqual({
csharp: `GetByText("Hello").Visible(new() { Visible = false }).Locator("div")`,
java: `getByText("Hello").visible(new Locator.VisibleOptions().setVisible(false)).locator("div")`,
javascript: `getByText('Hello').visible({ visible: false }).locator('div')`,
python: `get_by_text("Hello").visible(visible=False).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")`,
});
});

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

@ -150,7 +150,7 @@ it('should combine visible with other selectors', async ({ page }) => {
await expect(page.locator('.item >> visible=true >> text=data3')).toHaveText('visible data3');
});
it('should support .visible()', async ({ page }) => {
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>
@ -160,11 +160,10 @@ it('should support .visible()', async ({ page }) => {
<div class="item">visible data3</div>
</div>
`);
const locator = page.locator('.item').visible().nth(1);
const locator = page.locator('.item').filter({ visible: true }).nth(1);
await expect(locator).toHaveText('visible data2');
await expect(page.locator('.item').visible().getByText('data3')).toHaveText('visible data3');
await expect(page.locator('.item').visible({ visible: true }).getByText('data2')).toHaveText('visible data2');
await expect(page.locator('.item').visible({ visible: false }).getByText('data1')).toHaveText('Hidden data1');
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 }) => {

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

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

@ -14,249 +14,274 @@
* limitations under the License.
*/
import { test, expect } from './playwright-test-fixtures';
// import { JSONReport } from 'packages/playwright-test/reporter';
// import { test, expect } from './playwright-test-fixtures';
const warningSnippet = 'Some async calls were not awaited';
// const warningSnippet = 'Some async calls were not awaited';
test.describe.configure({ mode: 'parallel' });
// test.describe.configure({ mode: 'parallel' });
test.describe('await', () => {
test('should not care about non-API promises', async ({ runInlineTest }) => {
const { exitCode, stdout } = await runInlineTest({
'a.test.ts': `
import { test } from '@playwright/test';
test('test', () => {
new Promise(() => {});
});
`
});
expect(exitCode).toBe(0);
expect(stdout).not.toContain(warningSnippet);
});
// const getWarnings = (report: JSONReport) => report.suites.flatMap(s => s.specs).flatMap(s => s.tests).flatMap(t => t.annotations).filter(a => a.type === 'warning');
test('should warn about missing await on expects when failing', async ({ runInlineTest }) => {
const { exitCode, stdout } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('custom test name', async ({ page }) => {
expect(page.locator('div')).toHaveText('A', { timeout: 100 });
});
`
});
expect(exitCode).toBe(1);
expect(stdout).toContain(warningSnippet);
expect(stdout).toContain('the test');
expect(stdout).toContain('custom test name');
});
// test.describe('await', () => {
// test('should not care about non-API promises', async ({ runInlineTest }) => {
// const { exitCode, report } = await runInlineTest({
// 'a.test.ts': `
// import { test } from '@playwright/test';
// test('test', () => {
// new Promise(() => {});
// });
// `
// });
// expect(exitCode).toBe(0);
// const warnings = getWarnings(report);
// expect(warnings.length).toEqual(0);
// });
test('should warn about missing await on expects when passing', async ({ runInlineTest }) => {
const { exitCode, stdout } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent('<div>A</div>');
expect(page.locator('div')).toHaveText('A');
});
`
});
expect(exitCode).toBe(0);
expect(stdout).toContain(warningSnippet);
});
// test('should warn about missing await on expects when failing', async ({ runInlineTest }) => {
// const { exitCode, report } = await runInlineTest({
// 'a.test.ts': `
// import { test, expect } from '@playwright/test';
// test('custom test name', async ({ page }) => {
// expect(page.locator('div')).toHaveText('A', { timeout: 100 });
// });
// `
// });
// expect(exitCode).toBe(1);
// const warnings = getWarnings(report);
// expect(warnings.length).toEqual(1);
// expect(warnings[0].description).toContain(warningSnippet);
// expect(warnings[0].description).toContain('the test');
// });
test('should not warn when not missing await on expects when failing', async ({ runInlineTest }) => {
const { exitCode, stdout } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await expect(page.locator('div')).toHaveText('A', { timeout: 100 });
});
`
});
expect(exitCode).toBe(1);
expect(stdout).not.toContain(warningSnippet);
});
// test('should warn about missing await on expects when passing', async ({ runInlineTest }) => {
// const { exitCode, report } = await runInlineTest({
// 'a.test.ts': `
// import { test, expect } from '@playwright/test';
// test('test', async ({ page }) => {
// await page.setContent('<div>A</div>');
// expect(page.locator('div')).toHaveText('A');
// });
// `
// });
// expect(exitCode).toBe(0);
// const warnings = getWarnings(report);
// expect(warnings.length).toEqual(1);
// expect(warnings[0].description).toContain(warningSnippet);
// });
test('should not warn when not missing await on expects when passing', async ({ runInlineTest }) => {
const { exitCode, stdout } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent('<div>A</div>');
await expect(page.locator('div')).toHaveText('A');
});
`
});
expect(exitCode).toBe(0);
expect(stdout).not.toContain(warningSnippet);
});
// test('should not warn when not missing await on expects when failing', async ({ runInlineTest }) => {
// const { exitCode, report } = await runInlineTest({
// 'a.test.ts': `
// import { test, expect } from '@playwright/test';
// test('test', async ({ page }) => {
// await expect(page.locator('div')).toHaveText('A', { timeout: 100 });
// });
// `
// });
// expect(exitCode).toBe(1);
// const warnings = getWarnings(report);
// expect(warnings.length).toEqual(0);
// });
test('should not warn when using then on expects when passing', async ({ runInlineTest }) => {
const { exitCode, stdout } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent('<div>A</div>');
expect(page.locator('div')).toHaveText('A').then(() => {});
});
`
});
expect(exitCode).toBe(0);
expect(stdout).not.toContain(warningSnippet);
});
// test('should not warn when not missing await on expects when passing', async ({ runInlineTest }) => {
// const { exitCode, report } = await runInlineTest({
// 'a.test.ts': `
// import { test, expect } from '@playwright/test';
// test('test', async ({ page }) => {
// await page.setContent('<div>A</div>');
// await expect(page.locator('div')).toHaveText('A');
// });
// `
// });
// expect(exitCode).toBe(0);
// const warnings = getWarnings(report);
// expect(warnings.length).toEqual(0);
// });
test('should warn about missing await on reject', async ({ runInlineTest }) => {
const { exitCode, stdout } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
expect(Promise.reject(new Error('foo'))).rejects.toThrow('foo');
});
`
});
expect(exitCode).toBe(0);
expect(stdout).toContain(warningSnippet);
});
// test('should not warn when using then on expects when passing', async ({ runInlineTest }) => {
// const { exitCode, report } = await runInlineTest({
// 'a.test.ts': `
// import { test, expect } from '@playwright/test';
// test('test', async ({ page }) => {
// await page.setContent('<div>A</div>');
// expect(page.locator('div')).toHaveText('A').then(() => {});
// });
// `
// });
// expect(exitCode).toBe(0);
// const warnings = getWarnings(report);
// expect(warnings.length).toEqual(0);
// });
test('should warn about missing await on reject.not', async ({ runInlineTest }) => {
const { exitCode, stdout } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
expect(Promise.reject(new Error('foo'))).rejects.not.toThrow('foo');
});
`
});
expect(exitCode).toBe(1);
expect(stdout).toContain(warningSnippet);
});
// test('should warn about missing await on reject', async ({ runInlineTest }) => {
// const { exitCode, report } = await runInlineTest({
// 'a.test.ts': `
// import { test, expect } from '@playwright/test';
// test('test', async ({ page }) => {
// expect(Promise.reject(new Error('foo'))).rejects.toThrow('foo');
// });
// `
// });
// expect(exitCode).toBe(0);
// const warnings = getWarnings(report);
// expect(warnings.length).toEqual(1);
// expect(warnings[0].description).toContain(warningSnippet);
// });
test('should warn about missing await on test.step', async ({ runInlineTest }) => {
const { exitCode, stdout } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent('<div>A</div>');
test.step('step', () => {});
await expect(page.locator('div')).toHaveText('A');
});
`
});
expect(exitCode).toBe(0);
expect(stdout).toContain(warningSnippet);
});
// test('should warn about missing await on reject.not', async ({ runInlineTest }) => {
// const { exitCode, report } = await runInlineTest({
// 'a.test.ts': `
// import { test, expect } from '@playwright/test';
// test('test', async ({ page }) => {
// expect(Promise.reject(new Error('foo'))).rejects.not.toThrow('foo');
// });
// `
// });
// expect(exitCode).toBe(1);
// const warnings = getWarnings(report);
// expect(warnings.length).toEqual(1);
// expect(warnings[0].description).toContain(warningSnippet);
// });
test('should not warn when not missing await on test.step', async ({ runInlineTest }) => {
const { exitCode, stdout } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent('<div>A</div>');
await test.step('step', () => {});
await expect(page.locator('div')).toHaveText('A');
});
`
});
expect(exitCode).toBe(0);
expect(stdout).not.toContain(warningSnippet);
});
// test('should warn about missing await on test.step', async ({ runInlineTest }) => {
// const { exitCode, report } = await runInlineTest({
// 'a.test.ts': `
// import { test, expect } from '@playwright/test';
// test('test', async ({ page }) => {
// await page.setContent('<div>A</div>');
// test.step('step', () => {});
// await expect(page.locator('div')).toHaveText('A');
// });
// `
// });
// expect(exitCode).toBe(0);
// const warnings = getWarnings(report);
// expect(warnings.length).toEqual(1);
// expect(warnings[0].description).toContain(warningSnippet);
// });
test('should warn about missing await on test.step.skip', async ({ runInlineTest }) => {
const { exitCode, stdout } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent('<div>A</div>');
test.step.skip('step', () => {});
await expect(page.locator('div')).toHaveText('A');
});
`
});
expect(exitCode).toBe(0);
expect(stdout).toContain(warningSnippet);
});
// test('should not warn when not missing await on test.step', async ({ runInlineTest }) => {
// const { exitCode, report } = await runInlineTest({
// 'a.test.ts': `
// import { test, expect } from '@playwright/test';
// test('test', async ({ page }) => {
// await page.setContent('<div>A</div>');
// await test.step('step', () => {});
// await expect(page.locator('div')).toHaveText('A');
// });
// `
// });
// expect(exitCode).toBe(0);
// const warnings = getWarnings(report);
// expect(warnings.length).toEqual(0);
// });
test('traced promise should be instanceof Promise', async ({ runInlineTest }) => {
const { exitCode } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent('<div>A</div>');
const expectPromise = expect(page.locator('div')).toHaveText('A');
expect(expectPromise instanceof Promise).toBeTruthy();
});
`
});
expect(exitCode).toBe(0);
});
// test('should warn about missing await on test.step.skip', async ({ runInlineTest }) => {
// const { exitCode, report } = await runInlineTest({
// 'a.test.ts': `
// import { test, expect } from '@playwright/test';
// test('test', async ({ page }) => {
// await page.setContent('<div>A</div>');
// test.step.skip('step', () => {});
// await expect(page.locator('div')).toHaveText('A');
// });
// `
// });
// expect(exitCode).toBe(0);
// const warnings = getWarnings(report);
// expect(warnings.length).toEqual(1);
// expect(warnings[0].description).toContain(warningSnippet);
// });
test('should warn about missing await in before hooks', async ({ runInlineTest }) => {
const group = ['beforeAll', 'beforeEach'];
for (const hook of group) {
await test.step(hook, async () => {
const { exitCode, stdout } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
let page;
test.${hook}(async ({ browser }) => {
page = await browser.newPage();
await page.setContent('<div>A</div>');
expect(page.locator('div')).toHaveText('A');
});
test('test ${hook}', async () => {
await expect(page.locator('div')).toBeVisible();
});
`
});
// test('traced promise should be instanceof Promise', async ({ runInlineTest }) => {
// const { exitCode } = await runInlineTest({
// 'a.test.ts': `
// import { test, expect } from '@playwright/test';
// test('test', async ({ page }) => {
// await page.setContent('<div>A</div>');
// const expectPromise = expect(page.locator('div')).toHaveText('A');
// expect(expectPromise instanceof Promise).toBeTruthy();
// });
// `
// });
// expect(exitCode).toBe(0);
// });
expect(exitCode).toBe(0);
expect(stdout).toContain(warningSnippet);
expect(stdout).toContain(`${group[0]}/${group[1]} hooks`);
});
}
});
// test('should warn about missing await in before hooks', async ({ runInlineTest }) => {
// const group = ['beforeAll', 'beforeEach'];
// for (const hook of group) {
// await test.step(hook, async () => {
// const { exitCode, report } = await runInlineTest({
// 'a.test.ts': `
// import { test, expect } from '@playwright/test';
// let page;
// test.${hook}(async ({ browser }) => {
// page = await browser.newPage();
// await page.setContent('<div>A</div>');
// expect(page.locator('div')).toHaveText('A');
// });
// test('test ${hook}', async () => {
// await expect(page.locator('div')).toBeVisible();
// });
// `
// });
test.describe('should warn about missing await in after hooks', () => {
const group = ['afterAll', 'afterEach'];
for (const hook of group) {
test(hook, async ({ runInlineTest }) => {
const { exitCode, stdout } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
let page;
test('test ${hook}', async ({ browser }) => {
await expect(Promise.resolve()).resolves.toBe(undefined);
});
test.${hook}(async () => {
expect(Promise.resolve()).resolves.toBe(undefined);
});
`
});
// expect(exitCode).toBe(0);
// const warnings = getWarnings(report);
// expect(warnings.length).toEqual(1);
// expect(warnings[0].description).toContain(warningSnippet);
// expect(warnings[0].description).toContain(`${group[0]}/${group[1]} hooks`);
// });
// }
// });
expect(exitCode).toBe(0);
expect(stdout).toContain(warningSnippet);
expect(stdout).toContain(`${group[0]}/${group[1]} hooks`);
});
}
});
// test.describe('should warn about missing await in after hooks', () => {
// const group = ['afterAll', 'afterEach'];
// for (const hook of group) {
// test(hook, async ({ runInlineTest }) => {
// const { exitCode, report } = await runInlineTest({
// 'a.test.ts': `
// import { test, expect } from '@playwright/test';
// let page;
// test('test ${hook}', async ({ browser }) => {
// await expect(Promise.resolve()).resolves.toBe(undefined);
// });
// test.${hook}(async () => {
// expect(Promise.resolve()).resolves.toBe(undefined);
// });
// `
// });
test('should warn about missing await across hooks and test', async ({ runInlineTest }) => {
const { exitCode, stdout } = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test.beforeAll(async () => {
expect(Promise.resolve()).resolves.toBe(undefined);
});
test('test', async () => {
expect(Promise.resolve()).resolves.toBe(undefined);
});
test.afterEach(async () => {
expect(Promise.resolve()).resolves.toBe(undefined);
});
`
});
expect(exitCode).toBe(0);
expect(stdout).toContain(`${warningSnippet} by the end of beforeAll/beforeEach hooks.`);
expect(stdout).toContain(`${warningSnippet} by the end of the test.`);
expect(stdout).toContain(`${warningSnippet} by the end of afterAll/afterEach hooks.`);
});
});
// expect(exitCode).toBe(0);
// const warnings = getWarnings(report);
// expect(warnings.length).toEqual(1);
// expect(warnings[0].description).toContain(warningSnippet);
// expect(warnings[0].description).toContain(`${group[0]}/${group[1]} hooks`);
// });
// }
// });
// test('should warn about missing await across hooks and test', async ({ runInlineTest }) => {
// const { exitCode, report } = await runInlineTest({
// 'a.test.ts': `
// import { test, expect } from '@playwright/test';
// test.beforeAll(async () => {
// expect(Promise.resolve()).resolves.toBe(undefined);
// });
// test('test', async () => {
// expect(Promise.resolve()).resolves.toBe(undefined);
// });
// test.afterEach(async () => {
// expect(Promise.resolve()).resolves.toBe(undefined);
// });
// `
// });
// expect(exitCode).toBe(0);
// const warnings = getWarnings(report);
// expect(warnings.length).toEqual(3);
// expect(warnings[0].description).toContain(`${warningSnippet} by the end of beforeAll/beforeEach hooks.`);
// expect(warnings[1].description).toContain(`${warningSnippet} by the end of the test.`);
// expect(warnings[2].description).toContain(`${warningSnippet} by the end of afterAll/afterEach hooks.`);
// });
// });