diff --git a/README.md b/README.md index 5f85005dac..e784fceda6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🎭 Playwright -[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-134.0.6998.23-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-135.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.2-blue.svg?logo=safari)](https://webkit.org/) [![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) [![Chromium version](https://img.shields.io/badge/chromium-134.0.6998.35-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-135.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.2-blue.svg?logo=safari)](https://webkit.org/) [![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 134.0.6998.23 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Chromium 134.0.6998.35 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 18.2 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox 135.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | diff --git a/docs/src/api/class-apirequest.md b/docs/src/api/class-apirequest.md index 37bb2cb999..686be4303c 100644 --- a/docs/src/api/class-apirequest.md +++ b/docs/src/api/class-apirequest.md @@ -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 diff --git a/docs/src/api/class-apirequestcontext.md b/docs/src/api/class-apirequestcontext.md index 165b9f3bd6..a9b7170e84 100644 --- a/docs/src/api/class-apirequestcontext.md +++ b/docs/src/api/class-apirequestcontext.md @@ -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` ? -Defaults to `true`. Set to `false` to omit IndexedDB from snapshot. +Set to `true` to include IndexedDB in the storage state snapshot. diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index f831bd4cae..91e43e22dd 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -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` ? -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 diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index 0fdb1d1229..42c09c79f1 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -1090,6 +1090,9 @@ await rowLocator ### option: Locator.filter.hasNotText = %%-locator-option-has-not-text-%% * since: v1.33 +### option: Locator.filter.visible = %%-locator-option-visible-%% +* since: v1.51 + ## method: Locator.first * since: v1.14 - returns: <[Locator]> @@ -2332,7 +2335,7 @@ This method expects [Locator] to point to an ## async method: Locator.tap * since: v1.14 -Perform a tap gesture on the element matching the locator. +Perform a tap gesture on the element matching the locator. For examples of emulating other gestures by manually dispatching touch events, see the [emulating legacy touch events](../touch-events.md) page. **Details** diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index d7b4463265..26b487d0ff 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -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 diff --git a/docs/src/api/class-pageassertions.md b/docs/src/api/class-pageassertions.md index 38a05b9df8..ef8c7d4f6e 100644 --- a/docs/src/api/class-pageassertions.md +++ b/docs/src/api/class-pageassertions.md @@ -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 diff --git a/docs/src/api/class-touchscreen.md b/docs/src/api/class-touchscreen.md index bd6e1ed9f0..d7871addd7 100644 --- a/docs/src/api/class-touchscreen.md +++ b/docs/src/api/class-touchscreen.md @@ -4,6 +4,8 @@ The Touchscreen class operates in main-frame CSS pixels relative to the top-left corner of the viewport. Methods on the touchscreen can only be used in browser contexts that have been initialized with `hasTouch` set to true. +This class is limited to emulating tap gestures. For examples of other gestures simulated by manually dispatching touch events, see the [emulating legacy touch events](../touch-events.md) page. + ## async method: Touchscreen.tap * since: v1.8 diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 9a87db7f6a..a31ea6047c 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -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-%% diff --git a/docs/src/aria-snapshots.md b/docs/src/aria-snapshots.md index 25b05164ad..0467cf3878 100644 --- a/docs/src/aria-snapshots.md +++ b/docs/src/aria-snapshots.md @@ -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: diff --git a/docs/src/locators.md b/docs/src/locators.md index c3a2817670..b0a1c0e8f5 100644 --- a/docs/src/locators.md +++ b/docs/src/locators.md @@ -751,10 +751,10 @@ page.locator("x-details", new Page.LocatorOptions().setHasText("Details")) .click(); ``` ```python async -await page.locator("x-details", has_text="Details" ).click() +await page.locator("x-details", has_text="Details").click() ``` ```python sync -page.locator("x-details", has_text="Details" ).click() +page.locator("x-details", has_text="Details").click() ``` ```csharp await page @@ -1310,19 +1310,19 @@ Consider a page with two buttons, the first invisible and the second [visible](. * This will only find a second button, because it is visible, and then click it. ```js - await page.locator('button').locator('visible=true').click(); + await page.locator('button').filter({ visible: true }).click(); ``` ```java - page.locator("button").locator("visible=true").click(); + page.locator("button").filter(new Locator.FilterOptions.setVisible(true)).click(); ``` ```python async - await page.locator("button").locator("visible=true").click() + await page.locator("button").filter(visible=True).click() ``` ```python sync - page.locator("button").locator("visible=true").click() + page.locator("button").filter(visible=True).click() ``` ```csharp - await page.Locator("button").Locator("visible=true").ClickAsync(); + await page.Locator("button").Filter(new() { Visible = true }).ClickAsync(); ``` ## Lists diff --git a/docs/src/network.md b/docs/src/network.md index fc2471a321..bdb5b71c6e 100644 --- a/docs/src/network.md +++ b/docs/src/network.md @@ -708,9 +708,13 @@ Playwright uses simplified glob patterns for URL matching in network interceptio - A double `**` matches any characters including `/` 1. Question mark `?` matches any single character except `/` 1. Curly braces `{}` can be used to match a list of options separated by commas `,` +1. Square brackets `[]` can be used to match a set of characters +1. Backslash `\` can be used to escape any of special characters (note to escape backslash itself as `\\`) Examples: - `https://example.com/*.js` matches `https://example.com/file.js` but not `https://example.com/path/file.js` +- `https://example.com/\\?page=1` matches `https://example.com/?page=1` but not `https://example.com` +- `**/v[0-9]*` matches `https://example.com/v1/` but not `https://example.com/vote/` - `**/*.js` matches both `https://example.com/file.js` and `https://example.com/path/file.js` - `**/*.{png,jpg,jpeg}` matches all image requests diff --git a/docs/src/test-api/class-fullconfig.md b/docs/src/test-api/class-fullconfig.md index 923c9fa858..edbc991bfd 100644 --- a/docs/src/test-api/class-fullconfig.md +++ b/docs/src/test-api/class-fullconfig.md @@ -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`]. diff --git a/docs/src/test-api/class-testconfig.md b/docs/src/test-api/class-testconfig.md index d3690f859c..c9828aa10d 100644 --- a/docs/src/test-api/class-testconfig.md +++ b/docs/src/test-api/class-testconfig.md @@ -239,7 +239,10 @@ export default defineConfig({ Metadata contains key-value pairs to be included in the report. For example, HTML report will display it as key-value pairs, and JSON report will include metadata serialized as json. -See also [`property: TestConfig.populateGitInfo`] that populates metadata. +* Providing `gitCommit: 'generate'` property will populate it with the git commit details. +* Providing `gitDiff: 'generate'` property will populate it with the git diff details. + +On selected CI providers, both will be generated automatically. Specifying values will prevent the automatic generation. **Usage** @@ -326,26 +329,6 @@ This path will serve as the base directory for each test file snapshot directory ## property: TestConfig.snapshotPathTemplate = %%-test-config-snapshot-path-template-%% * since: v1.28 -## property: TestConfig.populateGitInfo -* since: v1.51 -- type: ?<[boolean]> - -Whether to populate `'git.commit.info'` field of the [`property: TestConfig.metadata`] with Git commit info and CI/CD information. - -This information will appear in the HTML and JSON reports and is available in the Reporter API. - -On Github Actions, this feature is enabled by default. - -**Usage** - -```js title="playwright.config.ts" -import { defineConfig } from '@playwright/test'; - -export default defineConfig({ - populateGitInfo: !!process.env.CI, -}); -``` - ## property: TestConfig.preserveOutput * since: v1.10 - type: ?<[PreserveOutput]<"always"|"never"|"failures-only">> @@ -450,7 +433,7 @@ export default defineConfig({ * since: v1.10 - type: ?<[null]|[Object]> - `max` <[int]> The maximum number of slow test files to report. Defaults to `5`. - - `threshold` <[float]> Test duration in milliseconds that is considered slow. Defaults to 15 seconds. + - `threshold` <[float]> Test file duration in milliseconds that is considered slow. Defaults to 5 minutes. Whether to report slow test files. Pass `null` to disable this feature. @@ -684,7 +667,7 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ webServer: { command: 'npm run start', - url: 'http://127.0.0.1:3000', + url: 'http://localhost:3000', timeout: 120 * 1000, reuseExistingServer: !process.env.CI, }, @@ -713,19 +696,19 @@ export default defineConfig({ webServer: [ { command: 'npm run start', - url: 'http://127.0.0.1:3000', + url: 'http://localhost:3000', timeout: 120 * 1000, reuseExistingServer: !process.env.CI, }, { command: 'npm run backend', - url: 'http://127.0.0.1:3333', + url: 'http://localhost:3333', timeout: 120 * 1000, reuseExistingServer: !process.env.CI, } ], use: { - baseURL: 'http://127.0.0.1:3000', + baseURL: 'http://localhost:3000', }, }); ``` diff --git a/docs/src/test-configuration-js.md b/docs/src/test-configuration-js.md index 822bd4ea0d..e3831c1911 100644 --- a/docs/src/test-configuration-js.md +++ b/docs/src/test-configuration-js.md @@ -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, }, }); diff --git a/docs/src/test-use-options-js.md b/docs/src/test-use-options-js.md index 6e1da0a228..12f97db46d 100644 --- a/docs/src/test-use-options-js.md +++ b/docs/src/test-use-options-js.md @@ -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', diff --git a/docs/src/test-webserver-js.md b/docs/src/test-webserver-js.md index 985b487677..d4a7bbb19b 100644 --- a/docs/src/test-webserver-js.md +++ b/docs/src/test-webserver-js.md @@ -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', }, }); ``` diff --git a/docs/src/touch-events.md b/docs/src/touch-events.md index a1d394dc62..de42abac7a 100644 --- a/docs/src/touch-events.md +++ b/docs/src/touch-events.md @@ -1,19 +1,13 @@ --- id: touch-events -title: "Emulating touch events" +title: "Emulating legacy touch events" --- ## Introduction -Mobile web sites may listen to [touch events](https://developer.mozilla.org/en-US/docs/Web/API/Touch_events) and react to user touch gestures such as swipe, pinch, tap etc. To test this functionality you can manually generate [TouchEvent]s in the page context using [`method: Locator.evaluate`]. +Web applications that handle [touch events](https://developer.mozilla.org/en-US/docs/Web/API/Touch_events) to respond to gestures like swipe, pinch, and tap can be tested by manually dispatching [TouchEvent](https://developer.mozilla.org/en-US/docs/Web/API/TouchEvent/TouchEvent)s to the page. The examples below demonstrate how to use [`method: Locator.dispatchEvent`] and pass [Touch](https://developer.mozilla.org/en-US/docs/Web/API/Touch) points as arguments. -If your web application relies on [pointer events](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events) instead of touch events, you can use [`method: Locator.click`] and raw [`Mouse`] events to simulate a single-finger touch, and this will trigger all the same pointer events. - -### Dispatching TouchEvent - -You can dispatch touch events to the page using [`method: Locator.dispatchEvent`]. [Touch](https://developer.mozilla.org/en-US/docs/Web/API/Touch) points can be passed as arguments, see examples below. - -#### Emulating pan gesture +### Emulating pan gesture In the example below, we emulate pan gesture that is expected to move the map. The app under test only uses `clientX/clientY` coordinates of the touch point, so we initialize just that. In a more complex scenario you may need to also set `pageX/pageY/screenX/screenY`, if your app needs them. @@ -69,7 +63,7 @@ test(`pan gesture to move the map`, async ({ page }) => { }); ``` -#### Emulating pinch gesture +### Emulating pinch gesture In the example below, we emulate pinch gesture, i.e. two touch points moving closer to each other. It is expected to zoom out the map. The app under test only uses `clientX/clientY` coordinates of touch points, so we initialize just that. In a more complex scenario you may need to also set `pageX/pageY/screenX/screenY`, if your app needs them. diff --git a/eslint.config.mjs b/eslint.config.mjs index b5486d8760..41799d05f9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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, diff --git a/packages/html-reporter/src/metadataView.css b/packages/html-reporter/src/metadataView.css index 0cbced5250..c383d6776a 100644 --- a/packages/html-reporter/src/metadataView.css +++ b/packages/html-reporter/src/metadataView.css @@ -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 { diff --git a/packages/html-reporter/src/metadataView.tsx b/packages/html-reporter/src/metadataView.tsx index 0f54bb4249..dcc27acb25 100644 --- a/packages/html-reporter/src/metadataView.tsx +++ b/packages/html-reporter/src/metadataView.tsx @@ -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([]); - -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 {children}; -} - -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, { 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, { error } } -export const MetadataView = () => { - return ; +export const MetadataView: React.FC<{ metadata: Metadata }> = params => { + return ; }; -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
- {gitCommitInfo && <> - - {entries.length > 0 &&
} - } -
- {entries.map(([propertyName, value]) => { + {commitInfo.ci && !commitInfo.gitCommit && } + {commitInfo.gitCommit && } + {otherEntries.length > 0 && (commitInfo.gitCommit || commitInfo.ci) &&
} +
+ {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 ( -
+
{propertyName} : {linkifyText(trimmedValue)} @@ -104,48 +81,39 @@ const InnerMetadataView = () => {
; }; -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
-
-
- {link ? ( - - {subject} - - ) : - {subject} - } -
-
- {author} - on {shortTimestamp} - {info['ci.link'] && ( - <> - · - Logs - - )} -
+const CiInfoView: React.FC<{ info: CIInfo }> = ({ info }) => { + const title = info.prTitle || `Commit ${info.commitHash}`; + const link = info.prHref || info.commitHref; + return
+ - {link ? ( - - {shortSubject} - - ) : !!shortSubject && {shortSubject}}
; }; + +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
+
+ {link && {title}} + {!link && {title}} +
+
+ {author} + on {shortTimestamp} +
+
; +}; + +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; +}; diff --git a/packages/html-reporter/src/reportContext.tsx b/packages/html-reporter/src/reportContext.tsx new file mode 100644 index 0000000000..0ea8ab1e50 --- /dev/null +++ b/packages/html-reporter/src/reportContext.tsx @@ -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(undefined); + +export function HTMLReportContextProvider({ report, children }: React.PropsWithChildren<{ report: HTMLReport | undefined }>) { + return {children}; +} + +export function useHTMLReport() { + return React.useContext(HTMLReportContext); +} diff --git a/packages/html-reporter/src/reportView.tsx b/packages/html-reporter/src/reportView.tsx index baf85f32a8..12b97584f3 100644 --- a/packages/html-reporter/src/reportView.tsx +++ b/packages/html-reporter/src/reportView.tsx @@ -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
+ return
{report?.json() && } @@ -89,7 +89,7 @@ export const ReportView: React.FC<{ {!!report && }
-
; +
; }; const TestCaseViewLoader: React.FC<{ diff --git a/packages/html-reporter/src/testErrorView.tsx b/packages/html-reporter/src/testErrorView.tsx index 42ec8d9ff6..3d253459ba 100644 --- a/packages/html-reporter/src/testErrorView.tsx +++ b/packages/html-reporter/src/testErrorView.tsx @@ -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 (
@@ -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); diff --git a/packages/html-reporter/src/testFilesView.tsx b/packages/html-reporter/src/testFilesView.tsx index 49e2233669..4b2c48ae1d 100644 --- a/packages/html-reporter/src/testFilesView.tsx +++ b/packages/html-reporter/src/testFilesView.tsx @@ -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 <>
- {metadataEntries.length > 0 &&
+ {!isMetadataEmpty(report.metadata) &&
{metadataVisible ? icons.downArrow() : icons.rightArrow()}Metadata
} {report.projectNames.length === 1 && !!report.projectNames[0] &&
Project: {report.projectNames[0]}
} @@ -83,7 +82,7 @@ export const TestFilesHeader: React.FC<{
{report ? new Date(report.startTime).toLocaleString() : ''}
Total time: {msToString(report.duration ?? 0)}
- {metadataVisible && } + {metadataVisible && } {!!report.errors.length && {report.errors.map((error, index) => )} } diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index ff2ef43aa1..abdc85e923 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -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; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - 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; }>; }>; @@ -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; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - 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; }>; }; @@ -13224,6 +13129,11 @@ export interface Locator { * `
Playwright
`. */ hasText?: string|RegExp; + + /** + * Only matches visible or invisible elements. + */ + visible?: boolean; }): Locator; /** @@ -14426,7 +14336,8 @@ export interface Locator { }): Promise; /** - * Perform a tap gesture on the element matching the locator. + * Perform a tap gesture on the element matching the locator. For examples of emulating other gestures by manually + * dispatching touch events, see the [emulating legacy touch events](https://playwright.dev/docs/touch-events) page. * * **Details** * @@ -14812,12 +14723,6 @@ export interface BrowserType { */ acceptDownloads?: boolean; - /** - * An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By - * default, response object is returned for all status codes. - */ - apiRequestFailsOnErrorStatus?: boolean; - /** * **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality. * @@ -16708,12 +16613,6 @@ export interface AndroidDevice { */ acceptDownloads?: boolean; - /** - * An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By - * default, response object is returned for all status codes. - */ - apiRequestFailsOnErrorStatus?: boolean; - /** * **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality. * @@ -17560,12 +17459,6 @@ export interface APIRequest { * @param options */ newContext(options?: { - /** - * An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By - * default, response object is returned for all status codes. - */ - apiRequestFailsOnErrorStatus?: boolean; - /** * Methods like * [apiRequestContext.get(url[, options])](https://playwright.dev/docs/api/class-apirequestcontext#api-request-context-get) @@ -17641,6 +17534,12 @@ export interface APIRequest { */ extraHTTPHeaders?: { [key: string]: string; }; + /** + * Whether to throw on response codes other than 2xx and 3xx. By default response object is returned for all status + * codes. + */ + failOnStatusCode?: boolean; + /** * Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no * origin is specified, the username and password are sent to any servers upon unauthorized responses. @@ -17742,55 +17641,7 @@ export interface APIRequest { /** * indexedDB to set for context */ - indexedDB?: Array<{ - /** - * database name - */ - name: string; - - /** - * database version - */ - version: number; - - stores: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - 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; }>; }; @@ -18564,7 +18415,7 @@ export interface APIRequestContext { */ storageState(options?: { /** - * Defaults to `true`. Set to `false` to omit IndexedDB from snapshot. + * Set to `true` to include IndexedDB in the storage state snapshot. */ indexedDB?: boolean; @@ -18606,49 +18457,7 @@ export interface APIRequestContext { value: string; }>; - indexedDB: Array<{ - name: string; - - version: number; - - stores: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - 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; }>; }>; @@ -21310,6 +21119,9 @@ export interface Selectors { /** * The Touchscreen class operates in main-frame CSS pixels relative to the top-left corner of the viewport. Methods on * the touchscreen can only be used in browser contexts that have been initialized with `hasTouch` set to true. + * + * This class is limited to emulating tap gestures. For examples of other gestures simulated by manually dispatching + * touch events, see the [emulating legacy touch events](https://playwright.dev/docs/touch-events) page. */ export interface Touchscreen { /** @@ -22142,12 +21954,6 @@ export interface BrowserContextOptions { */ acceptDownloads?: boolean; - /** - * An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By - * default, response object is returned for all status codes. - */ - apiRequestFailsOnErrorStatus?: boolean; - /** * When using [page.goto(url[, options])](https://playwright.dev/docs/api/class-page#page-goto), * [page.route(url, handler[, options])](https://playwright.dev/docs/api/class-page#page-route), @@ -22503,55 +22309,7 @@ export interface BrowserContextOptions { /** * indexedDB to set for context */ - indexedDB?: Array<{ - /** - * database name - */ - name: string; - - /** - * database version - */ - version: number; - - stores: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - 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; }>; }; diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index f992c357eb..b858a735d5 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -3,15 +3,15 @@ "browsers": [ { "name": "chromium", - "revision": "1160", + "revision": "1161", "installByDefault": true, - "browserVersion": "134.0.6998.23" + "browserVersion": "134.0.6998.35" }, { "name": "chromium-headless-shell", - "revision": "1160", + "revision": "1161", "installByDefault": true, - "browserVersion": "134.0.6998.23" + "browserVersion": "134.0.6998.35" }, { "name": "chromium-tip-of-tree", diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 445379083e..accee95288 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -510,7 +510,7 @@ export class BrowserContext extends ChannelOwner async function prepareStorageState(platform: Platform, options: BrowserContextOptions): Promise { 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) { diff --git a/packages/playwright-core/src/client/fetch.ts b/packages/playwright-core/src/client/fetch.ts index 8bdf38631d..dada11ed30 100644 --- a/packages/playwright-core/src/client/fetch.ts +++ b/packages/playwright-core/src/client/fetch.ts @@ -338,16 +338,18 @@ export class APIResponse implements api.APIResponse { } async body(): Promise { - try { - const result = await this._request._channel.fetchResponseBody({ fetchUid: this._fetchUid() }); - if (result.binary === undefined) - throw new Error('Response has been disposed'); - return result.binary; - } catch (e) { - if (isTargetClosedError(e)) - throw new Error('Response has been disposed'); - throw e; - } + return await this._request._wrapApiCall(async () => { + try { + const result = await this._request._channel.fetchResponseBody({ fetchUid: this._fetchUid() }); + if (result.binary === undefined) + throw new Error('Response has been disposed'); + return result.binary; + } catch (e) { + if (isTargetClosedError(e)) + throw new Error('Response has been disposed'); + throw e; + } + }, true); } async text(): Promise { diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 5d0f0aa0c3..a915c0a8ef 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -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): Locator { if (isString(selectorOrLocator)) return new Locator(this._frame, this._selector + ' >> ' + selectorOrLocator, options); if (selectorOrLocator._frame !== this._frame) diff --git a/packages/playwright-core/src/client/types.ts b/packages/playwright-core/src/client/types.ts index 29dcd5112c..53c805a496 100644 --- a/packages/playwright-core/src/client/types.ts +++ b/packages/playwright-core/src/client/types.ts @@ -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 & { indexedDB: unknown[] })[], }; export type SetStorageState = { cookies?: channels.SetNetworkCookie[], - origins?: channels.SetOriginStorage[] + origins?: (Omit & { indexedDB?: unknown[] })[] }; export type LifecycleEvent = channels.LifecycleEvent; diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index bd36e905b4..f4944d6e09 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -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, diff --git a/packages/playwright-core/src/server/bidi/bidiExecutionContext.ts b/packages/playwright-core/src/server/bidi/bidiExecutionContext.ts index c9b1890af9..431e1b9841 100644 --- a/packages/playwright-core/src/server/bidi/bidiExecutionContext.ts +++ b/packages/playwright-core/src/server/bidi/bidiExecutionContext.ts @@ -61,7 +61,7 @@ export class BidiExecutionContext implements js.ExecutionContextDelegate { throw new js.JavaScriptErrorInEvaluate('Unexpected response type: ' + JSON.stringify(response)); } - async rawEvaluateHandle(expression: string): Promise { + async rawEvaluateHandle(context: js.ExecutionContext, expression: string): Promise { const response = await this._session.send('script.evaluate', { expression, target: this._target, @@ -72,7 +72,7 @@ export class BidiExecutionContext implements js.ExecutionContextDelegate { }); if (response.type === 'success') { if ('handle' in response.result) - return response.result.handle!; + return createHandle(context, response.result); throw new js.JavaScriptErrorInEvaluate('Cannot get handle: ' + JSON.stringify(response.result)); } if (response.type === 'exception') @@ -80,14 +80,14 @@ export class BidiExecutionContext implements js.ExecutionContextDelegate { throw new js.JavaScriptErrorInEvaluate('Unexpected response type: ' + JSON.stringify(response)); } - async evaluateWithArguments(functionDeclaration: string, returnByValue: boolean, utilityScript: js.JSHandle, values: any[], objectIds: string[]): Promise { + async evaluateWithArguments(functionDeclaration: string, returnByValue: boolean, utilityScript: js.JSHandle, values: any[], handles: js.JSHandle[]): Promise { const response = await this._session.send('script.callFunction', { functionDeclaration, target: this._target, arguments: [ { handle: utilityScript._objectId! }, ...values.map(BidiSerializer.serialize), - ...objectIds.map(handle => ({ handle })), + ...handles.map(handle => ({ handle: handle._objectId! })), ], resultOwnership: returnByValue ? undefined : bidi.Script.ResultOwnership.Root, // Necessary for the handle to be returned. serializationOptions: returnByValue ? {} : { maxObjectDepth: 0, maxDomDepth: 0 }, @@ -121,10 +121,12 @@ export class BidiExecutionContext implements js.ExecutionContextDelegate { return map; } - async releaseHandle(objectId: js.ObjectId): Promise { + async releaseHandle(handle: js.JSHandle): Promise { + if (!handle._objectId) + return; await this._session.send('script.disown', { target: this._target, - handles: [objectId], + handles: [handle._objectId], }); } diff --git a/packages/playwright-core/src/server/bidi/bidiInput.ts b/packages/playwright-core/src/server/bidi/bidiInput.ts index e40b13bb2e..e67c07ba8f 100644 --- a/packages/playwright-core/src/server/bidi/bidiInput.ts +++ b/packages/playwright-core/src/server/bidi/bidiInput.ts @@ -79,9 +79,6 @@ export class RawMouseImpl implements input.RawMouse { } async move(x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, forClick: boolean): Promise { - // Bidi throws when x/y are not integers. - x = Math.floor(x); - y = Math.floor(y); await this._performActions([{ type: 'pointerMove', x, y }]); } diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index a57af3bf12..b2bf4bf202 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -511,7 +511,7 @@ export abstract class BrowserContext extends SdkObject { this._origins.add(origin); } - async storageState(indexedDB = true): Promise { + async storageState(indexedDB = false): Promise { const result: channels.BrowserContextStorageStateResult = { cookies: await this.cookies(), origins: [] diff --git a/packages/playwright-core/src/server/chromium/crExecutionContext.ts b/packages/playwright-core/src/server/chromium/crExecutionContext.ts index 4f4c505422..da11f1e686 100644 --- a/packages/playwright-core/src/server/chromium/crExecutionContext.ts +++ b/packages/playwright-core/src/server/chromium/crExecutionContext.ts @@ -46,24 +46,24 @@ export class CRExecutionContext implements js.ExecutionContextDelegate { return remoteObject.value; } - async rawEvaluateHandle(expression: string): Promise { + async rawEvaluateHandle(context: js.ExecutionContext, expression: string): Promise { const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.evaluate', { expression, contextId: this._contextId, }).catch(rewriteError); if (exceptionDetails) throw new js.JavaScriptErrorInEvaluate(getExceptionMessage(exceptionDetails)); - return remoteObject.objectId!; + return createHandle(context, remoteObject); } - async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle, values: any[], objectIds: string[]): Promise { + async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle, values: any[], handles: js.JSHandle[]): Promise { const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.callFunctionOn', { functionDeclaration: expression, objectId: utilityScript._objectId, arguments: [ { objectId: utilityScript._objectId }, ...values.map(value => ({ value })), - ...objectIds.map(objectId => ({ objectId })), + ...handles.map(handle => ({ objectId: handle._objectId! })), ], returnByValue, awaitPromise: true, @@ -88,8 +88,10 @@ export class CRExecutionContext implements js.ExecutionContextDelegate { return result; } - async releaseHandle(objectId: js.ObjectId): Promise { - await releaseObject(this._client, objectId); + async releaseHandle(handle: js.JSHandle): Promise { + if (!handle._objectId) + return; + await releaseObject(this._client, handle._objectId); } } diff --git a/packages/playwright-core/src/server/deviceDescriptorsSource.json b/packages/playwright-core/src/server/deviceDescriptorsSource.json index 867ee2bc49..3c510bd73f 100644 --- a/packages/playwright-core/src/server/deviceDescriptorsSource.json +++ b/packages/playwright-core/src/server/deviceDescriptorsSource.json @@ -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 diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index 3366a1a50d..fbea38b814 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -105,7 +105,11 @@ export class FrameExecutionContext extends js.ExecutionContext { ); })(); `; - this._injectedScriptPromise = this.rawEvaluateHandle(source).then(objectId => new js.JSHandle(this, 'object', 'InjectedScript', objectId)); + this._injectedScriptPromise = this.rawEvaluateHandle(source) + .then(handle => { + handle._setPreview('InjectedScript'); + return handle; + }); } return this._injectedScriptPromise; } @@ -118,7 +122,7 @@ export class ElementHandle extends js.JSHandle { declare readonly _objectId: string; readonly _frame: frames.Frame; - constructor(context: FrameExecutionContext, objectId: js.ObjectId) { + constructor(context: FrameExecutionContext, objectId: string) { super(context, 'node', undefined, objectId); this._page = context.frame._page; this._frame = context.frame; diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 984d5e3067..e7647759a2 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -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 { + override async storageState(indexedDB = false): Promise { return { cookies: this._cookieStore.allCookies(), origins: (this._origins || []).map(origin => ({ ...origin, indexedDB: indexedDB ? origin.indexedDB : [] })), diff --git a/packages/playwright-core/src/server/firefox/ffExecutionContext.ts b/packages/playwright-core/src/server/firefox/ffExecutionContext.ts index d0388ae2fe..1b9af6bfaa 100644 --- a/packages/playwright-core/src/server/firefox/ffExecutionContext.ts +++ b/packages/playwright-core/src/server/firefox/ffExecutionContext.ts @@ -44,23 +44,23 @@ export class FFExecutionContext implements js.ExecutionContextDelegate { return payload.result!.value; } - async rawEvaluateHandle(expression: string): Promise { + async rawEvaluateHandle(context: js.ExecutionContext, expression: string): Promise { const payload = await this._session.send('Runtime.evaluate', { expression, returnByValue: false, executionContextId: this._executionContextId, }).catch(rewriteError); checkException(payload.exceptionDetails); - return payload.result!.objectId!; + return createHandle(context, payload.result!); } - async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle, values: any[], objectIds: string[]): Promise { + async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle, values: any[], handles: js.JSHandle[]): Promise { const payload = await this._session.send('Runtime.callFunction', { functionDeclaration: expression, args: [ { objectId: utilityScript._objectId, value: undefined }, ...values.map(value => ({ value })), - ...objectIds.map(objectId => ({ objectId, value: undefined })), + ...handles.map(handle => ({ objectId: handle._objectId!, value: undefined })), ], returnByValue, executionContextId: this._executionContextId @@ -82,10 +82,12 @@ export class FFExecutionContext implements js.ExecutionContextDelegate { return result; } - async releaseHandle(objectId: js.ObjectId): Promise { + async releaseHandle(handle: js.JSHandle): Promise { + if (!handle._objectId) + return; await this._session.send('Runtime.disposeObject', { executionContextId: this._executionContextId, - objectId + objectId: handle._objectId, }); } } diff --git a/packages/playwright-core/src/server/injected/consoleApi.ts b/packages/playwright-core/src/server/injected/consoleApi.ts index c79ee6610c..5bcd94a5bf 100644 --- a/packages/playwright-core/src/server/injected/consoleApi.ts +++ b/packages/playwright-core/src/server/injected/consoleApi.ts @@ -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}`); diff --git a/packages/playwright-core/src/server/injected/roleUtils.ts b/packages/playwright-core/src/server/injected/roleUtils.ts index d56075ea58..5b4e25940d 100644 --- a/packages/playwright-core/src/server/injected/roleUtils.ts +++ b/packages/playwright-core/src/server/injected/roleUtils.ts @@ -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[] { diff --git a/packages/playwright-core/src/server/isomorphic/utilityScriptSerializers.ts b/packages/playwright-core/src/server/isomorphic/utilityScriptSerializers.ts index 6df7988caf..08a167240f 100644 --- a/packages/playwright-core/src/server/isomorphic/utilityScriptSerializers.ts +++ b/packages/playwright-core/src/server/isomorphic/utilityScriptSerializers.ts @@ -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: '; + // eslint-disable-next-line no-restricted-globals if (typeof globalThis.Document === 'function' && value instanceof globalThis.Document) return 'ref: '; + // eslint-disable-next-line no-restricted-globals if (typeof globalThis.Node === 'function' && value instanceof globalThis.Node) return 'ref: '; } diff --git a/packages/playwright-core/src/server/javascript.ts b/packages/playwright-core/src/server/javascript.ts index edb0d95bc9..f01cb88bcf 100644 --- a/packages/playwright-core/src/server/javascript.ts +++ b/packages/playwright-core/src/server/javascript.ts @@ -23,8 +23,6 @@ import { LongStandingScope } from '../utils/isomorphic/manualPromise'; import type * as dom from './dom'; import type { UtilityScript } from './injected/utilityScript'; -export type ObjectId = string; - interface TaggedAsJSHandle { __jshandle: T; } @@ -49,10 +47,10 @@ export type SmartHandle = T extends Node ? dom.ElementHandle : JSHandle export interface ExecutionContextDelegate { rawEvaluateJSON(expression: string): Promise; - rawEvaluateHandle(expression: string): Promise; - evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: JSHandle, values: any[], objectIds: ObjectId[]): Promise; + rawEvaluateHandle(context: ExecutionContext, expression: string): Promise; + evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: JSHandle, values: any[], handles: JSHandle[]): Promise; getProperties(object: JSHandle): Promise>; - releaseHandle(objectId: ObjectId): Promise; + releaseHandle(handle: JSHandle): Promise; } export class ExecutionContext extends SdkObject { @@ -79,21 +77,21 @@ export class ExecutionContext extends SdkObject { return this._raceAgainstContextDestroyed(this.delegate.rawEvaluateJSON(expression)); } - rawEvaluateHandle(expression: string): Promise { - return this._raceAgainstContextDestroyed(this.delegate.rawEvaluateHandle(expression)); + rawEvaluateHandle(expression: string): Promise { + return this._raceAgainstContextDestroyed(this.delegate.rawEvaluateHandle(this, expression)); } - async evaluateWithArguments(expression: string, returnByValue: boolean, values: any[], objectIds: ObjectId[]): Promise { + async evaluateWithArguments(expression: string, returnByValue: boolean, values: any[], handles: JSHandle[]): Promise { const utilityScript = await this._utilityScript(); - return this._raceAgainstContextDestroyed(this.delegate.evaluateWithArguments(expression, returnByValue, utilityScript, values, objectIds)); + return this._raceAgainstContextDestroyed(this.delegate.evaluateWithArguments(expression, returnByValue, utilityScript, values, handles)); } getProperties(object: JSHandle): Promise> { return this._raceAgainstContextDestroyed(this.delegate.getProperties(object)); } - releaseHandle(objectId: ObjectId): Promise { - return this.delegate.releaseHandle(objectId); + releaseHandle(handle: JSHandle): Promise { + return this.delegate.releaseHandle(handle); } adoptIfNeeded(handle: JSHandle): Promise | null { @@ -108,7 +106,11 @@ export class ExecutionContext extends SdkObject { ${utilityScriptSource.source} return new (module.exports.UtilityScript())(${isUnderTest()}); })();`; - this._utilityScriptPromise = this._raceAgainstContextDestroyed(this.delegate.rawEvaluateHandle(source).then(objectId => new JSHandle(this, 'object', 'UtilityScript', objectId))); + this._utilityScriptPromise = this._raceAgainstContextDestroyed(this.delegate.rawEvaluateHandle(this, source)) + .then(handle => { + handle._setPreview('UtilityScript'); + return handle; + }); } return this._utilityScriptPromise; } @@ -122,13 +124,13 @@ export class JSHandle extends SdkObject { __jshandle: T = true as any; readonly _context: ExecutionContext; _disposed = false; - readonly _objectId: ObjectId | undefined; + readonly _objectId: string | undefined; readonly _value: any; private _objectType: string; protected _preview: string; private _previewCallback: ((preview: string) => void) | undefined; - constructor(context: ExecutionContext, type: string, preview: string | undefined, objectId?: ObjectId, value?: any) { + constructor(context: ExecutionContext, type: string, preview: string | undefined, objectId?: string, value?: any) { super(context, 'handle'); this._context = context; this._objectId = objectId; @@ -185,7 +187,7 @@ export class JSHandle extends SdkObject { if (!this._objectId) return this._value; const script = `(utilityScript, ...args) => utilityScript.jsonValue(...args)`; - return this._context.evaluateWithArguments(script, true, [true], [this._objectId]); + return this._context.evaluateWithArguments(script, true, [true], [this]); } asElement(): dom.ElementHandle | null { @@ -197,7 +199,7 @@ export class JSHandle extends SdkObject { return; this._disposed = true; if (this._objectId) { - this._context.releaseHandle(this._objectId).catch(e => {}); + this._context.releaseHandle(this).catch(e => {}); if ((globalThis as any).leakedJSHandles) (globalThis as any).leakedJSHandles.delete(this); } @@ -254,11 +256,11 @@ export async function evaluateExpression(context: ExecutionContext, expression: return { fallThrough: handle }; })); - const utilityScriptObjectIds: ObjectId[] = []; + const utilityScriptObjects: JSHandle[] = []; for (const handle of await Promise.all(handles)) { if (handle._context !== context) throw new JavaScriptErrorInEvaluate('JSHandles can be evaluated only in the context they were created!'); - utilityScriptObjectIds.push(handle._objectId!); + utilityScriptObjects.push(handle); } // See UtilityScript for arguments. @@ -266,7 +268,7 @@ export async function evaluateExpression(context: ExecutionContext, expression: const script = `(utilityScript, ...args) => utilityScript.evaluate(...args)`; try { - return await context.evaluateWithArguments(script, options.returnByValue || false, utilityScriptValues, utilityScriptObjectIds); + return await context.evaluateWithArguments(script, options.returnByValue || false, utilityScriptValues, utilityScriptObjects); } finally { toDispose.map(handlePromise => handlePromise.then(handle => handle.dispose())); } diff --git a/packages/playwright-core/src/server/webkit/wkExecutionContext.ts b/packages/playwright-core/src/server/webkit/wkExecutionContext.ts index f9386d3adc..c7533d1ac5 100644 --- a/packages/playwright-core/src/server/webkit/wkExecutionContext.ts +++ b/packages/playwright-core/src/server/webkit/wkExecutionContext.ts @@ -48,7 +48,7 @@ export class WKExecutionContext implements js.ExecutionContextDelegate { } } - async rawEvaluateHandle(expression: string): Promise { + async rawEvaluateHandle(context: js.ExecutionContext, expression: string): Promise { try { const response = await this._session.send('Runtime.evaluate', { expression, @@ -57,13 +57,13 @@ export class WKExecutionContext implements js.ExecutionContextDelegate { }); if (response.wasThrown) throw new js.JavaScriptErrorInEvaluate(response.result.description); - return response.result.objectId!; + return createHandle(context, response.result); } catch (error) { throw rewriteError(error); } } - async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle, values: any[], objectIds: string[]): Promise { + async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle, values: any[], handles: js.JSHandle[]): Promise { try { const response = await this._session.send('Runtime.callFunctionOn', { functionDeclaration: expression, @@ -71,7 +71,7 @@ export class WKExecutionContext implements js.ExecutionContextDelegate { arguments: [ { objectId: utilityScript._objectId }, ...values.map(value => ({ value })), - ...objectIds.map(objectId => ({ objectId })), + ...handles.map(handle => ({ objectId: handle._objectId! })), ], returnByValue, emulateUserGesture: true, @@ -101,8 +101,10 @@ export class WKExecutionContext implements js.ExecutionContextDelegate { return result; } - async releaseHandle(objectId: js.ObjectId): Promise { - await this._session.send('Runtime.releaseObject', { objectId }); + async releaseHandle(handle: js.JSHandle): Promise { + if (!handle._objectId) + return; + await this._session.send('Runtime.releaseObject', { objectId: handle._objectId }); } } diff --git a/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts b/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts index 7ffe9b4de9..5fbc23ec44 100644 --- a/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts +++ b/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts @@ -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 { diff --git a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts index 77ce63f313..5be1f36b96 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts @@ -21,7 +21,7 @@ import type { NestedSelectorBody } from './selectorParser'; import type { ParsedSelector } from './selectorParser'; export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl'; -export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'has-text' | 'has-not-text' | 'has' | 'hasNot' | 'frame' | 'frame-locator' | 'and' | 'or' | 'chain'; +export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'visible' | 'has-text' | 'has-not-text' | 'has' | 'hasNot' | 'frame' | 'frame-locator' | 'and' | 'or' | 'chain'; export type LocatorBase = 'page' | 'locator' | 'frame-locator'; export type Quote = '\'' | '"' | '`'; @@ -68,6 +68,10 @@ function innerAsLocators(factory: LocatorFactory, parsed: ParsedSelector, isFram tokens.push([factory.generateLocator(base, 'nth', part.body as string)]); continue; } + if (part.name === 'visible') { + tokens.push([factory.generateLocator(base, 'visible', part.body as string), factory.generateLocator(base, 'default', `visible=${part.body}`)]); + continue; + } if (part.name === 'internal:text') { const { exact, text } = detectExact(part.body as string); tokens.push([factory.generateLocator(base, 'text', text, { exact })]); @@ -275,6 +279,8 @@ export class JavaScriptLocatorFactory implements LocatorFactory { return `first()`; case 'last': return `last()`; + case 'visible': + return `filter({ visible: ${body === 'true' ? 'true' : 'false'} })`; case 'role': const attrs: string[] = []; if (isRegExp(options.name)) { @@ -369,6 +375,8 @@ export class PythonLocatorFactory implements LocatorFactory { return `first`; case 'last': return `last`; + case 'visible': + return `filter(visible=${body === 'true' ? 'True' : 'False'})`; case 'role': const attrs: string[] = []; if (isRegExp(options.name)) { @@ -476,6 +484,8 @@ export class JavaLocatorFactory implements LocatorFactory { return `first()`; case 'last': return `last()`; + case 'visible': + return `filter(new ${clazz}.FilterOptions().setVisible(${body === 'true' ? 'true' : 'false'}))`; case 'role': const attrs: string[] = []; if (isRegExp(options.name)) { @@ -573,6 +583,8 @@ export class CSharpLocatorFactory implements LocatorFactory { return `First`; case 'last': return `Last`; + case 'visible': + return `Filter(new() { Visible = ${body === 'true' ? 'true' : 'false'} })`; case 'role': const attrs: string[] = []; if (isRegExp(options.name)) { diff --git a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts index f89dbb50f7..6dcbc1cbcc 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts @@ -170,6 +170,8 @@ function transform(template: string, params: TemplateParams, testIdAttributeName .replace(/first(\(\))?/g, 'nth=0') .replace(/last(\(\))?/g, 'nth=-1') .replace(/nth\(([^)]+)\)/g, 'nth=$1') + .replace(/filter\(,?visible=true\)/g, 'visible=true') + .replace(/filter\(,?visible=false\)/g, 'visible=false') .replace(/filter\(,?hastext=([^)]+)\)/g, 'internal:has-text=$1') .replace(/filter\(,?hasnottext=([^)]+)\)/g, 'internal:has-not-text=$1') .replace(/filter\(,?has2=([^)]+)\)/g, 'internal:has=$1') diff --git a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts index ed81c9a033..5a7602c97a 100644 --- a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts +++ b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts @@ -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; diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index ff2ef43aa1..abdc85e923 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -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; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - 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; }>; }>; @@ -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; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - 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; }>; }; @@ -13224,6 +13129,11 @@ export interface Locator { * `
Playwright
`. */ hasText?: string|RegExp; + + /** + * Only matches visible or invisible elements. + */ + visible?: boolean; }): Locator; /** @@ -14426,7 +14336,8 @@ export interface Locator { }): Promise; /** - * Perform a tap gesture on the element matching the locator. + * Perform a tap gesture on the element matching the locator. For examples of emulating other gestures by manually + * dispatching touch events, see the [emulating legacy touch events](https://playwright.dev/docs/touch-events) page. * * **Details** * @@ -14812,12 +14723,6 @@ export interface BrowserType { */ acceptDownloads?: boolean; - /** - * An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By - * default, response object is returned for all status codes. - */ - apiRequestFailsOnErrorStatus?: boolean; - /** * **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality. * @@ -16708,12 +16613,6 @@ export interface AndroidDevice { */ acceptDownloads?: boolean; - /** - * An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By - * default, response object is returned for all status codes. - */ - apiRequestFailsOnErrorStatus?: boolean; - /** * **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality. * @@ -17560,12 +17459,6 @@ export interface APIRequest { * @param options */ newContext(options?: { - /** - * An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By - * default, response object is returned for all status codes. - */ - apiRequestFailsOnErrorStatus?: boolean; - /** * Methods like * [apiRequestContext.get(url[, options])](https://playwright.dev/docs/api/class-apirequestcontext#api-request-context-get) @@ -17641,6 +17534,12 @@ export interface APIRequest { */ extraHTTPHeaders?: { [key: string]: string; }; + /** + * Whether to throw on response codes other than 2xx and 3xx. By default response object is returned for all status + * codes. + */ + failOnStatusCode?: boolean; + /** * Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no * origin is specified, the username and password are sent to any servers upon unauthorized responses. @@ -17742,55 +17641,7 @@ export interface APIRequest { /** * indexedDB to set for context */ - indexedDB?: Array<{ - /** - * database name - */ - name: string; - - /** - * database version - */ - version: number; - - stores: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - 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; }>; }; @@ -18564,7 +18415,7 @@ export interface APIRequestContext { */ storageState(options?: { /** - * Defaults to `true`. Set to `false` to omit IndexedDB from snapshot. + * Set to `true` to include IndexedDB in the storage state snapshot. */ indexedDB?: boolean; @@ -18606,49 +18457,7 @@ export interface APIRequestContext { value: string; }>; - indexedDB: Array<{ - name: string; - - version: number; - - stores: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - 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; }>; }>; @@ -21310,6 +21119,9 @@ export interface Selectors { /** * The Touchscreen class operates in main-frame CSS pixels relative to the top-left corner of the viewport. Methods on * the touchscreen can only be used in browser contexts that have been initialized with `hasTouch` set to true. + * + * This class is limited to emulating tap gestures. For examples of other gestures simulated by manually dispatching + * touch events, see the [emulating legacy touch events](https://playwright.dev/docs/touch-events) page. */ export interface Touchscreen { /** @@ -22142,12 +21954,6 @@ export interface BrowserContextOptions { */ acceptDownloads?: boolean; - /** - * An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By - * default, response object is returned for all status codes. - */ - apiRequestFailsOnErrorStatus?: boolean; - /** * When using [page.goto(url[, options])](https://playwright.dev/docs/api/class-page#page-goto), * [page.route(url, handler[, options])](https://playwright.dev/docs/api/class-page#page-route), @@ -22503,55 +22309,7 @@ export interface BrowserContextOptions { /** * indexedDB to set for context */ - indexedDB?: Array<{ - /** - * database name - */ - name: string; - - /** - * database version - */ - version: number; - - stores: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - 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; }>; }; diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index 3ee2ec12ea..273a88723b 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -48,7 +48,6 @@ export class FullConfigInternal { readonly plugins: TestRunnerPluginRegistration[]; readonly projects: FullProjectInternal[] = []; readonly singleTSConfigPath?: string; - readonly populateGitInfo: boolean; readonly recreateWorkerAfterFailure: boolean; cliArgs: string[] = []; cliGrep: string | undefined; @@ -79,7 +78,6 @@ export class FullConfigInternal { const privateConfiguration = (userConfig as any)['@playwright/test']; this.plugins = (privateConfiguration?.plugins || []).map((p: any) => ({ factory: p })); this.singleTSConfigPath = pathResolve(configDir, userConfig.tsconfig); - this.populateGitInfo = takeFirst(userConfig.populateGitInfo, defaultPopulateGitInfo); this.globalSetups = (Array.isArray(userConfig.globalSetup) ? userConfig.globalSetup : [userConfig.globalSetup]).map(s => resolveScript(s, configDir)).filter(script => script !== undefined); this.globalTeardowns = (Array.isArray(userConfig.globalTeardown) ? userConfig.globalTeardown : [userConfig.globalTeardown]).map(s => resolveScript(s, configDir)).filter(script => script !== undefined); @@ -97,14 +95,14 @@ export class FullConfigInternal { fullyParallel: takeFirst(configCLIOverrides.fullyParallel, userConfig.fullyParallel, false), globalSetup: this.globalSetups[0] ?? null, globalTeardown: this.globalTeardowns[0] ?? null, - globalTimeout: takeFirst(configCLIOverrides.globalTimeout, userConfig.globalTimeout, 0), + globalTimeout: takeFirst(configCLIOverrides.debug ? 0 : undefined, configCLIOverrides.globalTimeout, userConfig.globalTimeout, 0), grep: takeFirst(userConfig.grep, defaultGrep), grepInvert: takeFirst(userConfig.grepInvert, null), maxFailures: takeFirst(configCLIOverrides.debug ? 1 : undefined, configCLIOverrides.maxFailures, userConfig.maxFailures, 0), metadata: userConfig.metadata, preserveOutput: takeFirst(userConfig.preserveOutput, 'always'), reporter: takeFirst(configCLIOverrides.reporter, resolveReporters(userConfig.reporter, configDir), [[defaultReporter]]), - reportSlowTests: takeFirst(userConfig.reportSlowTests, { max: 5, threshold: 15000 }), + reportSlowTests: takeFirst(userConfig.reportSlowTests, { max: 5, threshold: 300_000 /* 5 minutes */ }), quiet: takeFirst(configCLIOverrides.quiet, userConfig.quiet, false), projects: [], shard: takeFirst(configCLIOverrides.shard, userConfig.shard, null), @@ -304,7 +302,6 @@ function resolveScript(id: string | undefined, rootDir: string): string | undefi export const defaultGrep = /.*/; export const defaultReporter = process.env.CI ? 'dot' : 'list'; -const defaultPopulateGitInfo = process.env.GITHUB_ACTIONS === 'true'; const configInternalSymbol = Symbol('configInternalSymbol'); diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index e8afe5a74a..68b29bf3d6 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -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); }); diff --git a/packages/playwright/src/isomorphic/teleReceiver.ts b/packages/playwright/src/isomorphic/teleReceiver.ts index b9aa5edba9..29b1beb74d 100644 --- a/packages/playwright/src/isomorphic/teleReceiver.ts +++ b/packages/playwright/src/isomorphic/teleReceiver.ts @@ -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, diff --git a/packages/playwright/src/isomorphic/types.d.ts b/packages/playwright/src/isomorphic/types.d.ts index 2619c0df33..cbdb01cc52 100644 --- a/packages/playwright/src/isomorphic/types.d.ts +++ b/packages/playwright/src/isomorphic/types.d.ts @@ -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; +}; diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts index ad1855a88e..0c76a97c68 100644 --- a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts @@ -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; diff --git a/packages/playwright/src/plugins/gitCommitInfoPlugin.ts b/packages/playwright/src/plugins/gitCommitInfoPlugin.ts index 7ade4a005c..bba2acfb73 100644 --- a/packages/playwright/src/plugins/gitCommitInfoPlugin.ts +++ b/packages/playwright/src/plugins/gitCommitInfoPlugin.ts @@ -14,108 +14,151 @@ * limitations under the License. */ -import fs from 'fs'; +import * as fs from 'fs'; -import { createGuid, spawnAsync } from 'playwright-core/lib/utils'; +import { spawnAsync } from 'playwright-core/lib/utils'; import type { TestRunnerPlugin } from './'; import type { FullConfig } from '../../types/testReporter'; import type { FullConfigInternal } from '../common/config'; -import type { GitCommitInfo } from '../isomorphic/types'; +import type { GitCommitInfo, CIInfo, UserMetadataWithCommitInfo } from '../isomorphic/types'; -const GIT_OPERATIONS_TIMEOUT_MS = 1500; +const GIT_OPERATIONS_TIMEOUT_MS = 3000; export const addGitCommitInfoPlugin = (fullConfig: FullConfigInternal) => { - if (fullConfig.populateGitInfo) - fullConfig.plugins.push({ factory: gitCommitInfo }); + fullConfig.plugins.push({ factory: gitCommitInfoPlugin }); }; -export const gitCommitInfo = (options?: GitCommitInfoPluginOptions): TestRunnerPlugin => { +type GitCommitInfoPluginOptions = { + directory?: string; +}; + +export const gitCommitInfoPlugin = (options?: GitCommitInfoPluginOptions): TestRunnerPlugin => { return { name: 'playwright:git-commit-info', setup: async (config: FullConfig, configDir: string) => { - const fromEnv = await linksFromEnv(); - const fromCLI = await gitStatusFromCLI(options?.directory || configDir, fromEnv); - config.metadata = config.metadata || {}; - config.metadata['git.commit.info'] = { ...fromEnv, ...fromCLI }; + const metadata = config.metadata as UserMetadataWithCommitInfo; + const ci = await ciInfo(); + if (!metadata.ci && ci) + metadata.ci = ci; + + if ((ci && !metadata.gitCommit) || metadata.gitCommit === 'generate') { + const git = await gitCommitInfo(options?.directory || configDir).catch(e => { + // eslint-disable-next-line no-console + console.error('Failed to get git commit info', e); + }); + if (git) + metadata.gitCommit = git; + } + + if ((ci && !metadata.gitDiff) || metadata.gitDiff === 'generate') { + const diffResult = await gitDiff(options?.directory || configDir, ci).catch(e => { + // eslint-disable-next-line no-console + console.error('Failed to get git diff', e); + }); + if (diffResult) + metadata.gitDiff = diffResult; + } }, }; }; -interface GitCommitInfoPluginOptions { - directory?: string; -} - -async function linksFromEnv() { - const out: Partial = {}; - // 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 { + 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): Promise { - const separator = `:${createGuid().slice(0, 4)}:`; +async function gitCommitInfo(gitDir: string): Promise { + const separator = `---786eec917292---`; + const tokens = [ + '%H', // commit hash + '%h', // abbreviated commit hash + '%s', // subject + '%B', // raw body (unwrapped subject and body) + '%an', // author name + '%ae', // author email + '%at', // author date, UNIX timestamp + '%cn', // committer name + '%ce', // committer email + '%ct', // committer date, UNIX timestamp + '', // branch + ]; const commitInfoResult = await spawnAsync( - 'git', - ['show', '-s', `--format=%H${separator}%s${separator}%an${separator}%ae${separator}%ct`, 'HEAD'], - { stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS } + `git log -1 --pretty=format:"${tokens.join(separator)}" && git rev-parse --abbrev-ref HEAD`, [], + { stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS, shell: true } ); if (commitInfoResult.code) - return; + return undefined; const showOutput = commitInfoResult.stdout.trim(); - const [id, subject, author, email, rawTimestamp] = showOutput.split(separator); - let timestamp: number = Number.parseInt(rawTimestamp, 10); - timestamp = Number.isInteger(timestamp) ? timestamp * 1000 : 0; + const [hash, shortHash, subject, body, authorName, authorEmail, authorTime, committerName, committerEmail, committerTime, branch] = showOutput.split(separator); - const result: GitCommitInfo = { - 'revision.id': id, - 'revision.author': author, - 'revision.email': email, - 'revision.subject': subject, - 'revision.timestamp': timestamp, + return { + shortHash, + hash, + subject, + body, + author: { + name: authorName, + email: authorEmail, + time: +authorTime * 1000, + }, + committer: { + name: committerName, + email: committerEmail, + time: +committerTime * 1000, + }, + branch: branch.trim(), }; - - const diffLimit = 1_000_000; // 1MB - if (envInfo['pull.base']) { - const pullDiffResult = await spawnAsync( - 'git', - ['diff', envInfo['pull.base']], - { stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS } - ); - if (!pullDiffResult.code) - result['pull.diff'] = pullDiffResult.stdout.substring(0, diffLimit); - } else { - const diffResult = await spawnAsync( - 'git', - ['diff', 'HEAD~1'], - { stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS } - ); - if (!diffResult.code) - result['revision.diff'] = diffResult.stdout.substring(0, diffLimit); - } - - return result; +} + +async function gitDiff(gitDir: string, ci?: CIInfo): Promise { + const diffLimit = 100_000; + const baseHash = ci?.baseHash ?? 'HEAD~1'; + + const pullDiffResult = await spawnAsync( + 'git', + ['diff', baseHash], + { stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS } + ); + if (!pullDiffResult.code) + return pullDiffResult.stdout.substring(0, diffLimit); } diff --git a/packages/playwright/src/plugins/index.ts b/packages/playwright/src/plugins/index.ts index 2f7995cb2f..7734145468 100644 --- a/packages/playwright/src/plugins/index.ts +++ b/packages/playwright/src/plugins/index.ts @@ -35,4 +35,3 @@ export type TestRunnerPluginRegistration = { }; export { webServer } from './webServerPlugin'; -export { gitCommitInfo } from './gitCommitInfoPlugin'; diff --git a/packages/playwright/src/util.ts b/packages/playwright/src/util.ts index 53760970bd..c992313e06 100644 --- a/packages/playwright/src/util.ts +++ b/packages/playwright/src/util.ts @@ -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(); } diff --git a/packages/playwright/src/worker/fixtureRunner.ts b/packages/playwright/src/worker/fixtureRunner.ts index 555f86ab57..51bd517dde 100644 --- a/packages/playwright/src/worker/fixtureRunner.ts +++ b/packages/playwright/src/worker/fixtureRunner.ts @@ -35,7 +35,7 @@ class Fixture { private _selfTeardownComplete: Promise | 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(); _usages = new Set(); @@ -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 { diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 5bac56776d..72bb9fb025 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -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('stepZone') - ?? this._findLastStageStep(this._steps); // If no parent step on stack, assume the current stage as parent. + return currentZone().data('stepZone') ?? this._findLastPredefinedStep(this._steps); } _addStep(data: Omit, 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) { - 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) { + 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) { + 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)); } diff --git a/packages/playwright/src/worker/timeoutManager.ts b/packages/playwright/src/worker/timeoutManager.ts index 65e97c9f36..c003342d15 100644 --- a/packages/playwright/src/worker/timeoutManager.ts +++ b/packages/playwright/src/worker/timeoutManager.ts @@ -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(runnable: RunnableDescription | undefined, cb: () => Promise): Promise { - if (!runnable) - return await cb(); + async withRunnable(runnable: RunnableDescription, cb: () => Promise): Promise { 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}`); } } diff --git a/packages/playwright/src/worker/workerMain.ts b/packages/playwright/src/worker/workerMain.ts index 96c69f345b..90513275a4 100644 --- a/packages/playwright/src/worker/workerMain.ts +++ b/packages/playwright/src/worker/workerMain.ts @@ -115,12 +115,12 @@ export class WorkerMain extends ProcessRunner { const fakeTestInfo = new TestInfoImpl(this._config, this._project, this._params, undefined, 0, () => {}, () => {}, () => {}); const runnable = { type: 'teardown' } as const; // We have to load the project to get the right deadline below. - await fakeTestInfo._runAsStage({ title: 'worker cleanup', runnable }, () => this._loadIfNeeded()).catch(() => {}); + await fakeTestInfo._runWithTimeout(runnable, () => this._loadIfNeeded()).catch(() => {}); await this._fixtureRunner.teardownScope('test', fakeTestInfo, runnable).catch(() => {}); await this._fixtureRunner.teardownScope('worker', fakeTestInfo, runnable).catch(() => {}); // Close any other browsers launched in this process. This includes anything launched // manually in the test/hooks and internal browsers like Playwright Inspector. - await fakeTestInfo._runAsStage({ title: 'worker cleanup', runnable }, () => gracefullyCloseAll()).catch(() => {}); + await fakeTestInfo._runWithTimeout(runnable, () => gracefullyCloseAll()).catch(() => {}); this._fatalErrors.push(...fakeTestInfo.errors); } catch (e) { this._fatalErrors.push(testInfoError(e)); @@ -330,8 +330,8 @@ export class WorkerMain extends ProcessRunner { testInfo._floatingPromiseScope.clear(); }; - await testInfo._runAsStage({ title: 'setup and test' }, async () => { - await testInfo._runAsStage({ title: 'start tracing', runnable: { type: 'test' } }, async () => { + await (async () => { + await testInfo._runWithTimeout({ type: 'test' }, async () => { // Ideally, "trace" would be an config-level option belonging to the // test runner instead of a fixture belonging to Playwright. // However, for backwards compatibility, we have to read it from a fixture today. @@ -356,7 +356,7 @@ export class WorkerMain extends ProcessRunner { await removeFolders([testInfo.outputDir]); let testFunctionParams: object | null = null; - await testInfo._runAsStage({ title: 'Before Hooks', stepInfo: { category: 'hook' } }, async () => { + await testInfo._runAsStep({ title: 'Before Hooks', category: 'hook' }, async () => { // Run "beforeAll" hooks, unless already run during previous tests. for (const suite of suites) await this._runBeforeAllHooksForSuite(suite, testInfo); @@ -376,13 +376,13 @@ export class WorkerMain extends ProcessRunner { return; } - await testInfo._runAsStage({ title: 'test function', runnable: { type: 'test' } }, async () => { + await testInfo._runWithTimeout({ type: 'test' }, async () => { // Now run the test itself. const fn = test.fn; // Extract a variable to get a better stack trace ("myTest" vs "TestCase.myTest [as fn]"). await fn(testFunctionParams, testInfo); checkForFloatingPromises('the test'); }); - }).catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors. + })().catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors. // Update duration, so it is available in fixture teardown and afterEach hooks. testInfo.duration = testInfo._timeoutManager.defaultSlot().elapsed | 0; @@ -396,12 +396,12 @@ export class WorkerMain extends ProcessRunner { const FAILURE_AND_RECREATE_WORKER = testInfo._isFailure() && this._config.recreateWorkerAfterFailure; - await testInfo._runAsStage({ title: 'After Hooks', stepInfo: { category: 'hook' } }, async () => { + await testInfo._runAsStep({ title: 'After Hooks', category: 'hook' }, async () => { let firstAfterHooksError: Error | undefined; try { // Run "immediately upon test function finish" callback. - await testInfo._runAsStage({ title: 'on-test-function-finish', runnable: { type: 'test', slot: afterHooksSlot } }, async () => testInfo._onDidFinishTestFunction?.()); + await testInfo._runWithTimeout({ type: 'test', slot: afterHooksSlot }, async () => testInfo._onDidFinishTestFunction?.()); } catch (error) { firstAfterHooksError = firstAfterHooksError ?? error; } @@ -451,7 +451,7 @@ export class WorkerMain extends ProcessRunner { // Mark as "cleaned up" early to avoid running cleanup twice. this._didRunFullCleanup = true; - await testInfo._runAsStage({ title: 'Worker Cleanup', stepInfo: { category: 'hook' } }, async () => { + await testInfo._runAsStep({ title: 'Worker Cleanup', category: 'hook' }, async () => { let firstWorkerCleanupError: Error | undefined; // Give it more time for the full cleanup. @@ -484,7 +484,7 @@ export class WorkerMain extends ProcessRunner { } const tracingSlot = { timeout: this._project.project.timeout, elapsed: 0 }; - await testInfo._runAsStage({ title: 'stop tracing', runnable: { type: 'test', slot: tracingSlot } }, async () => { + await testInfo._runWithTimeout({ type: 'test', slot: tracingSlot }, async () => { await testInfo._tracing.stopIfNeeded(); }).catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors. @@ -537,7 +537,7 @@ export class WorkerMain extends ProcessRunner { let firstError: Error | undefined; for (const hook of this._collectHooksAndModifiers(suite, type, testInfo)) { try { - await testInfo._runAsStage({ title: hook.title, stepInfo: { category: 'hook', location: hook.location } }, async () => { + await testInfo._runAsStep({ title: hook.title, category: 'hook', location: hook.location }, async () => { // Separate time slot for each beforeAll/afterAll hook. const timeSlot = { timeout: this._project.project.timeout, elapsed: 0 }; const runnable = { type: hook.type, slot: timeSlot, location: hook.location }; @@ -590,7 +590,7 @@ export class WorkerMain extends ProcessRunner { continue; } try { - await testInfo._runAsStage({ title: hook.title, stepInfo: { category: 'hook', location: hook.location } }, async () => { + await testInfo._runAsStep({ title: hook.title, category: 'hook', location: hook.location }, async () => { await this._fixtureRunner.resolveParametersAndRunFunction(hook.fn, testInfo, 'test', runnable); }); } catch (error) { diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index da82c0828d..46afd5f8c1 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -884,7 +884,7 @@ interface TestConfig { * 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 { * 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 { /** * 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 { */ outputDir?: string; - /** - * Whether to populate `'git.commit.info'` field of the - * [testConfig.metadata](https://playwright.dev/docs/api/class-testconfig#test-config-metadata) with Git commit info - * and CI/CD information. - * - * This information will appear in the HTML and JSON reports and is available in the Reporter API. - * - * On Github Actions, this feature is enabled by default. - * - * **Usage** - * - * ```js - * // playwright.config.ts - * import { defineConfig } from '@playwright/test'; - * - * export default defineConfig({ - * populateGitInfo: !!process.env.CI, - * }); - * ``` - * - */ - populateGitInfo?: boolean; - /** * Whether to preserve test output in the * [testConfig.outputDir](https://playwright.dev/docs/api/class-testconfig#test-config-output-dir). Defaults to @@ -1467,7 +1445,7 @@ interface TestConfig { max: number; /** - * Test duration in milliseconds that is considered slow. Defaults to 15 seconds. + * Test file duration in milliseconds that is considered slow. Defaults to 5 minutes. */ threshold: number; }; @@ -1919,12 +1897,12 @@ export interface FullConfig { */ reportSlowTests: null|{ /** - * The maximum number of slow test files to report. Defaults to `5`. + * The maximum number of slow test files to report. */ max: number; /** - * Test duration in milliseconds that is considered slow. Defaults to 15 seconds. + * Test file duration in milliseconds that is considered slow. */ threshold: number; }; @@ -8817,14 +8795,14 @@ interface LocatorAssertions { /** * Asserts that the target element matches the given [accessibility snapshot](https://playwright.dev/docs/aria-snapshots). * - * Snapshot is stored in a separate `.yml` file in a location configured by `expect.toMatchAriaSnapshot.pathTemplate` - * and/or `snapshotPathTemplate` properties in the configuration file. + * Snapshot is stored in a separate `.snapshot.yml` file in a location configured by + * `expect.toMatchAriaSnapshot.pathTemplate` and/or `snapshotPathTemplate` properties in the configuration file. * * **Usage** * * ```js * await expect(page.locator('body')).toMatchAriaSnapshot(); - * await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'body.yml' }); + * await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'body.snapshot.yml' }); * ``` * * @param options @@ -8926,13 +8904,25 @@ interface PageAssertions { * **Usage** * * ```js - * await expect(page).toHaveURL(/.*checkout/); + * // Check for the page URL to be 'https://playwright.dev/docs/intro' (including query string) + * await expect(page).toHaveURL('https://playwright.dev/docs/intro'); + * + * // Check for the page URL to contain 'doc', followed by an optional 's', followed by '/' + * await expect(page).toHaveURL(/docs?\//); + * + * // Check for the predicate to be satisfied + * // For example: verify query strings + * await expect(page).toHaveURL(url => { + * const params = url.searchParams; + * return params.has('search') && params.has('options') && params.get('id') === '5'; + * }); * ``` * - * @param url Expected URL string, RegExp, or predicate receiving [URL] to match. When a - * [`baseURL`](https://playwright.dev/docs/api/class-browser#browser-new-context-option-base-url) via the context - * options was provided and the passed URL is a path, it gets merged via the - * [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. + * @param url Expected URL string, RegExp, or predicate receiving [URL] to match. When + * [`baseURL`](https://playwright.dev/docs/api/class-browser#browser-new-context-option-base-url) is provided via the + * context options and the `url` argument is a string, the two values are merged via the + * [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor and used for the comparison + * against the current browser URL. * @param options */ toHaveURL(url: string|RegExp|((url: URL) => boolean), options?: { diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 117f0e287e..1c5a201c40 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -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, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 1044f8ccb2..5b2f136386 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -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: diff --git a/packages/trace-viewer/src/ui/errorsTab.tsx b/packages/trace-viewer/src/ui/errorsTab.tsx index d7b9ac7eba..eca5e2ea66 100644 --- a/packages/trace-viewer/src/ui/errorsTab.tsx +++ b/packages/trace-viewer/src/ui/errorsTab.tsx @@ -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(undefined); +const CommitInfoContext = React.createContext(undefined); -export function GitCommitInfoProvider({ children, gitCommitInfo }: React.PropsWithChildren<{ gitCommitInfo: GitCommitInfo }>) { - return {children}; +export function CommitInfoProvider({ children, commitInfo }: React.PropsWithChildren<{ commitInfo: MetadataWithCommitInfo }>) { + return {children}; } -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, width: number) { + const selectedFrame = stack?.[0]; + const { source } = useSources(stack, 0, sources); + return React.useMemo(() => { + if (!source.content) + return ''; + + const targetLine = selectedFrame?.line ?? 0; + + const lines = source.content.split('\n'); + const start = Math.max(0, targetLine - width); + const end = Math.min(lines.length, targetLine + width); + const lineNumberWidth = String(end).length; + const codeFrame = lines.slice(start, end).map((line, i) => { + const lineNumber = start + i + 1; + const paddedLineNumber = String(lineNumber).padStart(lineNumberWidth, ' '); + if (lineNumber !== targetLine) + return ` ${(paddedLineNumber)} | ${line}`; + + let highlightLine = `> ${paddedLineNumber} | ${line}`; + if (selectedFrame?.column) + highlightLine += `\n${' '.repeat(4 + lineNumberWidth + selectedFrame.column)}^`; + return highlightLine; + }).join('\n'); + return codeFrame; + }, [source, selectedFrame, width]); +} + const CopyPromptButton: React.FC<{ error: string; + codeFrame: string; pageSnapshot?: string; diff?: string; -}> = ({ error, pageSnapshot, diff }) => { +}> = ({ error, codeFrame, pageSnapshot, diff }) => { const prompt = React.useMemo( () => fixTestPrompt( - error, + error + '\n\n' + codeFrame, diff, pageSnapshot ), - [error, diff, pageSnapshot] + [error, diff, codeFrame, pageSnapshot] ); return ( @@ -72,7 +102,7 @@ const CopyPromptButton: React.FC<{ value={prompt} description='Copy as Prompt' copiedDescription={<>Copied } - style={{ width: '90px', justifyContent: 'center' }} + style={{ width: '120px', justifyContent: 'center' }} /> ); }; @@ -97,11 +127,10 @@ export function useErrorsTabModel(model: modelUtil.MultiTraceModel | undefined): }, [model]); } -function Error({ message, error, errorId, sdkLanguage, pageSnapshot, revealInSource }: { message: string, error: ErrorDescription, errorId: string, sdkLanguage: Language, pageSnapshot?: string, revealInSource: (error: ErrorDescription) => void }) { +function Error({ message, error, errorId, sdkLanguage, pageSnapshot, revealInSource, sources }: { message: string, error: ErrorDescription, errorId: string, sdkLanguage: Language, pageSnapshot?: string, revealInSource: (error: ErrorDescription) => void, sources: Map }) { 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
} {llmAvailable - ? - : } + ? + : }
@@ -180,9 +211,10 @@ export const ErrorsTab: React.FunctionComponent<{ errorsModel: ErrorsTabModel, actions: modelUtil.ActionTraceEventInContext[], wallTime: number, + sources: Map, 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
{[...errorsModel.errors.entries()].map(([message, error]) => { const errorId = `error-${wallTime}-${message}`; - return ; + return ; })}
; }; diff --git a/packages/trace-viewer/src/ui/sourceTab.tsx b/packages/trace-viewer/src/ui/sourceTab.tsx index 1dd9170f67..fa205da661 100644 --- a/packages/trace-viewer/src/ui/sourceTab.tsx +++ b/packages/trace-viewer/src/ui/sourceTab.tsx @@ -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, - rootDir?: string, - fallbackLocation?: SourceLocation, - onOpenExternally?: (location: SourceLocation) => void, -}> = ({ stack, sources, rootDir, fallbackLocation, stackFrameLocation, onOpenExternally }) => { - const [lastStack, setLastStack] = React.useState(); - const [selectedFrame, setSelectedFrame] = React.useState(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, 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, + rootDir?: string, + fallbackLocation?: SourceLocation, + onOpenExternally?: (location: SourceLocation) => void, +}> = ({ stack, sources, rootDir, fallbackLocation, stackFrameLocation, onOpenExternally }) => { + const [lastStack, setLastStack] = React.useState(); + const [selectedFrame, setSelectedFrame] = React.useState(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) diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 441163c5d9..4762b6057e 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -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<{}> = ({
- + = ({ revealSource={revealSource} onOpenExternally={location => testServerConnection?.openNoReply({ location: { file: location.file, line: location.line, column: location.column } })} /> - +
} sidebar={
diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index 34f27d65cb..3e361ad14c 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -193,7 +193,7 @@ export const Workbench: React.FunctionComponent<{ id: 'errors', title: 'Errors', errorCount: errorsModel.errors.size, - render: () => { + render: () => { if (error.action) setSelectedAction(error.action); else diff --git a/tests/library/browsercontext-fetchFailOnStatusCode.spec.ts b/tests/library/browsercontext-fetchFailOnStatusCode.spec.ts deleted file mode 100644 index e922218d68..0000000000 --- a/tests/library/browsercontext-fetchFailOnStatusCode.spec.ts +++ /dev/null @@ -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(); -}); diff --git a/tests/library/browsercontext-storage-state.spec.ts b/tests/library/browsercontext-storage-state.spec.ts index d1431e88fd..e22c66f23c 100644 --- a/tests/library/browsercontext-storage-state.spec.ts +++ b/tests/library/browsercontext-storage-state.spec.ts @@ -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: [] }); }); diff --git a/tests/library/global-fetch-cookie.spec.ts b/tests/library/global-fetch-cookie.spec.ts index f2f522a619..e34cf1561b 100644 --- a/tests/library/global-fetch-cookie.spec.ts +++ b/tests/library/global-fetch-cookie.spec.ts @@ -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(); }); diff --git a/tests/library/global-fetch.spec.ts b/tests/library/global-fetch.spec.ts index 9a402ed152..53db88e325 100644 --- a/tests/library/global-fetch.spec.ts +++ b/tests/library/global-fetch.spec.ts @@ -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.'); diff --git a/tests/library/inspector/console-api.spec.ts b/tests/library/inspector/console-api.spec.ts index 50f4c5f063..2b40b8943a 100644 --- a/tests/library/inspector/console-api.spec.ts +++ b/tests/library/inspector/console-api.spec.ts @@ -92,13 +92,14 @@ it('should support locator.or()', async ({ page }) => { }); it('should support playwright.getBy*', async ({ page }) => { - await page.setContent('HelloWorld'); + await page.setContent('HelloWorld
one
two
'); 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 }) => { diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index 417e096e29..cc54a143e8 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -320,6 +320,21 @@ it('reverse engineer hasNotText', async ({ page }) => { }); }); +it('reverse engineer visible', async ({ page }) => { + expect.soft(generate(page.getByText('Hello').filter({ visible: true }).locator('div'))).toEqual({ + csharp: `GetByText("Hello").Filter(new() { Visible = true }).Locator("div")`, + java: `getByText("Hello").filter(new Locator.FilterOptions().setVisible(true)).locator("div")`, + javascript: `getByText('Hello').filter({ visible: true }).locator('div')`, + python: `get_by_text("Hello").filter(visible=True).locator("div")`, + }); + expect.soft(generate(page.getByText('Hello').filter({ visible: false }).locator('div'))).toEqual({ + csharp: `GetByText("Hello").Filter(new() { Visible = false }).Locator("div")`, + java: `getByText("Hello").filter(new Locator.FilterOptions().setVisible(false)).locator("div")`, + javascript: `getByText('Hello').filter({ visible: false }).locator('div')`, + python: `get_by_text("Hello").filter(visible=False).locator("div")`, + }); +}); + it('reverse engineer has', async ({ page }) => { expect.soft(generate(page.getByText('Hello').filter({ has: page.locator('div').getByText('bye') }))).toEqual({ csharp: `GetByText("Hello").Filter(new() { Has = Locator("div").GetByText("bye") })`, diff --git a/tests/library/role-utils.spec.ts b/tests/library/role-utils.spec.ts index 1b625a106a..777d32c58f 100644 --- a/tests/library/role-utils.spec.ts +++ b/tests/library/role-utils.spec.ts @@ -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(` + + `); + 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' }); diff --git a/tests/page/expect-to-have-text.spec.ts b/tests/page/expect-to-have-text.spec.ts index d10700e98b..45ab745579 100644 --- a/tests/page/expect-to-have-text.spec.ts +++ b/tests/page/expect-to-have-text.spec.ts @@ -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 }) => { diff --git a/tests/page/interception.spec.ts b/tests/page/interception.spec.ts index 9447a80fcd..d3443f9015 100644 --- a/tests/page/interception.spec.ts +++ b/tests/page/interception.spec.ts @@ -90,7 +90,14 @@ it('should work with glob', async () => { expect(globToRegex('http://localhost:3000/signin-oidc*').test('http://localhost:3000/signin-oidc/foo')).toBeFalsy(); expect(globToRegex('http://localhost:3000/signin-oidc*').test('http://localhost:3000/signin-oidcnice')).toBeTruthy(); - expect(globToRegex('**/three-columns/settings.html?**id=[a-z]**').test('http://mydomain:8080/blah/blah/three-columns/settings.html?id=settings-e3c58efe-02e9-44b0-97ac-dd138100cf7c&blah')).toBeTruthy(); + // range [] + expect(globToRegex('**/api/v[0-9]').test('http://example.com/api/v1')).toBeTruthy(); + expect(globToRegex('**/api/v[0-9]').test('http://example.com/api/version')).toBeFalsy(); + + // query params + expect(globToRegex('**/api\\?param').test('http://example.com/api?param')).toBeTruthy(); + expect(globToRegex('**/api\\?param').test('http://example.com/api-param')).toBeFalsy(); + expect(globToRegex('**/three-columns/settings.html\\?**id=[a-z]**').test('http://mydomain:8080/blah/blah/three-columns/settings.html?id=settings-e3c58efe-02e9-44b0-97ac-dd138100cf7c&blah')).toBeTruthy(); expect(globToRegex('\\?')).toEqual(/^\?$/); expect(globToRegex('\\')).toEqual(/^\\$/); diff --git a/tests/page/locator-misc-2.spec.ts b/tests/page/locator-misc-2.spec.ts index 202b9a2957..f09749eca5 100644 --- a/tests/page/locator-misc-2.spec.ts +++ b/tests/page/locator-misc-2.spec.ts @@ -150,6 +150,22 @@ it('should combine visible with other selectors', async ({ page }) => { await expect(page.locator('.item >> visible=true >> text=data3')).toHaveText('visible data3'); }); +it('should support filter(visible)', async ({ page }) => { + await page.setContent(`
+ +
visible data1
+ +
visible data2
+ +
visible data3
+
+ `); + const locator = page.locator('.item').filter({ visible: true }).nth(1); + await expect(locator).toHaveText('visible data2'); + await expect(page.locator('.item').filter({ visible: true }).getByText('data3')).toHaveText('visible data3'); + await expect(page.locator('.item').filter({ visible: false }).getByText('data1')).toHaveText('Hidden data1'); +}); + it('locator.count should work with deleted Map in main world', async ({ page }) => { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/11254' }); await page.evaluate('Map = 1'); diff --git a/tests/page/page-aria-snapshot.spec.ts b/tests/page/page-aria-snapshot.spec.ts index 78bfb667d8..e5ba5a6c4b 100644 --- a/tests/page/page-aria-snapshot.spec.ts +++ b/tests/page/page-aria-snapshot.spec.ts @@ -515,6 +515,7 @@ it('should normalize whitespace', async ({ page }) => { one \n two link  \n 1 + `); 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" `); }); diff --git a/tests/page/page-check.spec.ts b/tests/page/page-check.spec.ts index 01b00ddc55..4ea0f9e50c 100644 --- a/tests/page/page-check.spec.ts +++ b/tests/page/page-check.spec.ts @@ -21,6 +21,7 @@ it('should check the box @smoke', async ({ page }) => { await page.setContent(``); 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 }) => { diff --git a/tests/playwright-test/aria-snapshot-file.spec.ts b/tests/playwright-test/aria-snapshot-file.spec.ts index c121d623d1..f02ae3b26b 100644 --- a/tests/playwright-test/aria-snapshot-file.spec.ts +++ b/tests/playwright-test/aria-snapshot-file.spec.ts @@ -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(\`

hello world

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

hello world

\`); - await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-1.yml' }); + await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-1.snapshot.yml' }); await page.setContent(\`

hello world 2

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

hello world

\`); - await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-1.yml' }); + await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-1.snapshot.yml' }); await page.setContent(\`

hello world 2

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

hello world

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

hello new world

\`); + 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(\`

hello world

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

hello world

\`); - await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.yml' }); + await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.snapshot.yml' }); }); ` }); diff --git a/tests/playwright-test/playwright.artifacts.spec.ts b/tests/playwright-test/playwright.artifacts.spec.ts index 5a8a6fcbef..d54fe5265c 100644 --- a/tests/playwright-test/playwright.artifacts.spec.ts +++ b/tests/playwright-test/playwright.artifacts.spec.ts @@ -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', ]); }); diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 9783194073..3d3a927b04 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -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 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 / + - 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 on/ - - link "Logs" - - link "Pull Request" - - text: 'foo : value1 bar : {"prop":"value2"} baz : ["value3",123]' + - list: + - listitem: + - link "My PR" + - listitem: /William / + - 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', diff --git a/tests/playwright-test/test-step.spec.ts b/tests/playwright-test/test-step.spec.ts index d35e9de57a..1cb8950ccf 100644 --- a/tests/playwright-test/test-step.spec.ts +++ b/tests/playwright-test/test-step.spec.ts @@ -1520,9 +1520,7 @@ pw:api | browserContext.newPage test.step |custom step @ a.test.ts:4 pw:api | page.route @ a.test.ts:5 pw:api | page.goto(${server.EMPTY_PAGE}) @ a.test.ts:12 -pw:api | apiResponse.text @ a.test.ts:7 expect | expect.toBe @ a.test.ts:8 -pw:api | apiResponse.text @ a.test.ts:9 hook |After Hooks fixture | fixture: page fixture | fixture: context diff --git a/tests/playwright-test/timeout.spec.ts b/tests/playwright-test/timeout.spec.ts index cb4e365b4b..9d6001078d 100644 --- a/tests/playwright-test/timeout.spec.ts +++ b/tests/playwright-test/timeout.spec.ts @@ -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': ` diff --git a/tests/playwright-test/ui-mode-metadata.spec.ts b/tests/playwright-test/ui-mode-metadata.spec.ts index 8f032ea987..bfbbba08a5 100644 --- a/tests/playwright-test/ui-mode-metadata.spec.ts +++ b/tests/playwright-test/ui-mode-metadata.spec.ts @@ -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', }); diff --git a/tests/playwright-test/ui-mode-trace.spec.ts b/tests/playwright-test/ui-mode-trace.spec.ts index 61278ce362..e25161f475 100644 --- a/tests/playwright-test/ui-mode-trace.spec.ts +++ b/tests/playwright-test/ui-mode-trace.spec.ts @@ -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()); }); diff --git a/utils/workspace.js b/utils/workspace.js index 7e6850a262..ef03a1fc10 100755 --- a/utils/workspace.js +++ b/utils/workspace.js @@ -125,7 +125,7 @@ class Workspace { } await maybeWriteJSON(pkg.packageJSONPath, pkg.packageJSON); } - + // Re-run npm i to make package-lock dirty. child_process.execSync('npm i'); return hasChanges; @@ -167,6 +167,11 @@ const workspace = new Workspace(ROOT_PATH, [ path: path.join(ROOT_PATH, 'packages', 'playwright-chromium'), files: LICENCE_FILES, }), + new PWPackage({ + name: '@playwright/client', + path: path.join(ROOT_PATH, 'packages', 'playwright-client'), + files: LICENCE_FILES, + }), new PWPackage({ name: '@playwright/experimental-tools', path: path.join(ROOT_PATH, 'packages', 'playwright-tools'),