diff --git a/.github/workflows/publish_canary.yml b/.github/workflows/publish_canary.yml index 50a1564755..64d25dbd6d 100644 --- a/.github/workflows/publish_canary.yml +++ b/.github/workflows/publish_canary.yml @@ -14,7 +14,7 @@ env: jobs: publish-canary: name: "publish canary NPM" - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 if: github.repository == 'microsoft/playwright' permissions: id-token: write # This is required for OIDC login (azure/login) to succeed diff --git a/.github/workflows/publish_release_driver.yml b/.github/workflows/publish_release_driver.yml index 8ad1a4184a..1eaa4656a7 100644 --- a/.github/workflows/publish_release_driver.yml +++ b/.github/workflows/publish_release_driver.yml @@ -10,7 +10,7 @@ env: jobs: publish-driver-release: name: "publish playwright driver to CDN" - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 if: github.repository == 'microsoft/playwright' permissions: id-token: write # This is required for OIDC login (azure/login) to succeed diff --git a/.github/workflows/tests_secondary.yml b/.github/workflows/tests_secondary.yml index fd5458fb89..a6b473b72b 100644 --- a/.github/workflows/tests_secondary.yml +++ b/.github/workflows/tests_secondary.yml @@ -529,7 +529,7 @@ jobs: build-playwright-driver: name: "build-playwright-driver" - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/README.md b/README.md index 3e2add10dc..8dd6b54ba3 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-128.0.6613.7-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-128.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.0-blue.svg?logo=safari)](https://webkit.org/) +[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-128.0.6613.18-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-128.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.0-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 128.0.6613.7 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Chromium 128.0.6613.18 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 18.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox 128.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | diff --git a/docs/src/api/class-browser.md b/docs/src/api/class-browser.md index 9ae59571fc..8d1d2d99c1 100644 --- a/docs/src/api/class-browser.md +++ b/docs/src/api/class-browser.md @@ -296,6 +296,18 @@ testing frameworks should explicitly create [`method: Browser.newContext`] follo ### option: Browser.newPage.storageStatePath = %%-csharp-java-context-option-storage-state-path-%% * since: v1.9 +## async method: Browser.removeAllListeners +* since: v1.47 + +Removes all the listeners of the given type if the type is given. Otherwise removes all the listeners. + +### param: Browser.removeAllListeners.type +* since: v1.47 +- `type` ?<[string]> + +### option: Browser.removeAllListeners.behavior = %%-remove-all-listeners-options-behavior-%% +* since: v1.47 + ## async method: Browser.startTracing * since: v1.11 * langs: java, js, python diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index 48542a2603..cf7efd7af1 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -1016,6 +1016,18 @@ Creates a new page in the browser context. Returns all open pages in the context. +## async method: BrowserContext.removeAllListeners +* since: v1.47 + +Removes all the listeners of the given type if the type is given. Otherwise removes all the listeners. + +### param: BrowserContext.removeAllListeners.type +* since: v1.47 +- `type` ?<[string]> + +### option: BrowserContext.removeAllListeners.behavior = %%-remove-all-listeners-options-behavior-%% +* since: v1.47 + ## property: BrowserContext.request * since: v1.16 * langs: diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index b64173affe..47303634b0 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -3340,6 +3340,17 @@ Specifies the maximum number of times this handler should be called. Unlimited b By default, after calling the handler Playwright will wait until the overlay becomes hidden, and only then Playwright will continue with the action/assertion that triggered the handler. This option allows to opt-out of this behavior, so that overlay can stay visible after the handler has run. +## async method: Page.removeAllListeners +* since: v1.47 + +Removes all the listeners of the given type if the type is given. Otherwise removes all the listeners. + +### param: Page.removeAllListeners.type +* since: v1.47 +- `type` ?<[string]> + +### option: Page.removeAllListeners.behavior = %%-remove-all-listeners-options-behavior-%% +* since: v1.47 ## async method: Page.removeLocatorHandler * since: v1.44 @@ -3594,7 +3605,7 @@ A glob pattern, regular expression or predicate to match the request URL. Only r * since: v1.32 - `updateMode` <[HarMode]<"full"|"minimal">> -When set to `minimal`, only record information necessary for routing from HAR. This omits sizes, timing, page, cookies, security and other types of HAR information that are not used when replaying from HAR. Defaults to `full`. +When set to `minimal`, only record information necessary for routing from HAR. This omits sizes, timing, page, cookies, security and other types of HAR information that are not used when replaying from HAR. Defaults to `minimal`. ### option: Page.routeFromHAR.updateContent * since: v1.32 diff --git a/docs/src/api/class-route.md b/docs/src/api/class-route.md index dcdca4705a..106fbf55e0 100644 --- a/docs/src/api/class-route.md +++ b/docs/src/api/class-route.md @@ -39,7 +39,7 @@ Optional error code. Defaults to `failed`, could be one of the following: - alias-java: resume - alias-python: continue_ -Continues route's request with optional overrides. +Sends route's request to the network with optional overrides. **Usage** @@ -104,6 +104,8 @@ await page.RouteAsync("**/*", async route => Note that any overrides such as [`option: url`] or [`option: headers`] only apply to the request being routed. If this request results in a redirect, overrides will not be applied to the new redirected request. If you want to propagate a header through redirects, use the combination of [`method: Route.fetch`] and [`method: Route.fulfill`] instead. +[`method: Route.continue`] will immediately send the request to the network, other matching handlers won't be invoked. Use [`method: Route.fallback`] If you want next matching handler in the chain to be invoked. + ### option: Route.continue.url * since: v1.8 - `url` <[string]> @@ -146,13 +148,15 @@ If set changes the request HTTP headers. Header values will be converted to a st ## async method: Route.fallback * since: v1.23 +Continues route's request with optional overrides. The method is similar to [`method: Route.continue`] with the difference that other matching handlers will be invoked before sending the request. + +**Usage** + When several routes match the given pattern, they run in the order opposite to their registration. That way the last registered route can always override all the previous ones. In the example below, request will be handled by the bottom-most handler first, then it'll fall back to the previous one and in the end will be aborted by the first registered route. -**Usage** - ```js await page.route('**/*', async route => { // Runs last. @@ -386,6 +390,8 @@ await page.RouteAsync("**/*", async route => }); ``` +Use [`method: Route.continue`] to immediately send the request to the network, other matching handlers won't be invoked in that case. + ### option: Route.fallback.url * since: v1.23 - `url` <[string]> @@ -503,6 +509,12 @@ If set changes the request URL. New URL must have same protocol as original one. Maximum number of request redirects that will be followed automatically. An error will be thrown if the number is exceeded. Defaults to `20`. Pass `0` to not follow redirects. +### option: Route.fetch.maxRetries +* since: v1.46 +- `maxRetries` <[int]> + +Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries. + ### option: Route.fetch.timeout * since: v1.33 - `timeout` <[float]> diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 78d62060cc..1743066647 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -524,9 +524,9 @@ Does not enforce fixed viewport, allows resizing window in the headed mode. ## context-option-clientCertificates - `clientCertificates` <[Array]<[Object]>> - `origin` <[string]> Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port. - - `certPath` ?<[string]> Path to the file with the certificate in PEM format. - - `keyPath` ?<[string]> Path to the file with the private key in PEM format. - - `pfxPath` ?<[string]> Path to the PFX or PKCS12 encoded private key and certificate chain. + - `certPath` ?<[path]> Path to the file with the certificate in PEM format. + - `keyPath` ?<[path]> Path to the file with the private key in PEM format. + - `pfxPath` ?<[path]> Path to the PFX or PKCS12 encoded private key and certificate chain. - `passphrase` ?<[string]> Passphrase for the private key (PEM or PFX). TLS Client Authentication allows the server to request a client certificate and verify it. @@ -781,6 +781,16 @@ Whether to allow sites to register Service workers. Defaults to `'allow'`. * `'allow'`: [Service Workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) can be registered. * `'block'`: Playwright will block all registration of Service Workers. +## remove-all-listeners-options-behavior +* langs: js +* since: v1.47 +- `behavior` <[RemoveAllListenersBehavior]<"wait"|"ignoreErrors"|"default">> + +Specifies whether to wait for already running listeners and what to do if they throw errors: +* `'default'` - do not wait for current listener calls (if any) to finish, if the listener throws, it may result in unhandled error +* `'wait'` - wait for current listener calls (if any) to finish +* `'ignoreErrors'` - do not wait for current listener calls (if any) to finish, all errors thrown by the listeners after removal are silently caught + ## unroute-all-options-behavior * langs: js, csharp, python * since: v1.41 @@ -791,6 +801,7 @@ Specifies whether to wait for already running handlers and what to do if they th * `'wait'` - wait for current handler calls (if any) to finish * `'ignoreErrors'` - do not wait for current handler calls (if any) to finish, all errors thrown by the handlers after unrouting are silently caught + ## select-options-values * langs: java, js, csharp - `values` <[null]|[string]|[ElementHandle]|[Array]<[string]>|[Object]|[Array]<[ElementHandle]>|[Array]<[Object]>> diff --git a/docs/src/auth.md b/docs/src/auth.md index 1224292775..a070d2d395 100644 --- a/docs/src/auth.md +++ b/docs/src/auth.md @@ -47,8 +47,9 @@ Create `tests/auth.setup.ts` that will prepare authenticated browser state for a ```js title="tests/auth.setup.ts" import { test as setup, expect } from '@playwright/test'; +import path from 'path'; -const authFile = 'playwright/.auth/user.json'; +const authFile = path.join(__dirname, '../playwright/.auth/user.json'); setup('authenticate', async ({ page }) => { // Perform authentication steps. Replace these actions with your own. @@ -113,6 +114,8 @@ test('test', async ({ page }) => { }); ``` +Note that you need to delete the stored state when it expires. If you don't need to keep the state between test runs, write the browser state under [`property: TestProject.outputDir`], which is automatically cleaned up before every test run. + ### Authenticating in UI mode * langs: js diff --git a/docs/src/clock.md b/docs/src/clock.md index a515d46d50..ea14941d82 100644 --- a/docs/src/clock.md +++ b/docs/src/clock.md @@ -31,6 +31,7 @@ The recommended approach is to use `setFixedTime` to set the time to a specific - `requestIdleCallback` - `cancelIdleCallback` - `performance` + - `Event.timeStamp` ::: ## Test with predefined time diff --git a/docs/src/release-notes-csharp.md b/docs/src/release-notes-csharp.md index df1d45cd2c..697ce6641f 100644 --- a/docs/src/release-notes-csharp.md +++ b/docs/src/release-notes-csharp.md @@ -4,6 +4,50 @@ title: "Release notes" toc_max_heading_level: 2 --- +## Version 1.46 + +### TLS Client Certificates + +Playwright now allows to supply client-side certificates, so that server can verify them, as specified by TLS Client Authentication. + +You can provide client certificates as a parameter of [`method: Browser.newContext`] and [`method: APIRequest.newContext`]. The following snippet sets up a client certificate for `https://example.com`: + +```csharp +var context = await Browser.NewContextAsync(new() { + ClientCertificates = [ + new() { + Origin = "https://example.com", + CertPath = "client-certificates/cert.pem", + KeyPath = "client-certificates/key.pem", + } + ] +}); +``` + +### Trace Viewer Updates + +- Content of text attachments is now rendered inline in the attachments pane. +- New setting to show/hide routing actions like [`method: Route.continue`]. +- Request method and status are shown in the network details tab. +- New button to copy source file location to clipboard. +- Metadata pane now displays the `BaseURL`. + +### Miscellaneous + +- New `maxRetries` option in [`method: APIRequestContext.fetch`] which retries on the `ECONNRESET` network error. + +### Browser Versions + +- Chromium 128.0.6613.18 +- Mozilla Firefox 128.0 +- WebKit 18.0 + +This version was also tested against the following stable channels: + +- Google Chrome 127 +- Microsoft Edge 127 + + ## Version 1.45 ### Clock @@ -112,7 +156,6 @@ await Page.RemoveLocatorHandlerAsync(locator); **Miscellaneous options** - New method [`method: FormData.append`] allows to specify repeating fields with the same name in [`Multipart`](./api/class-apirequestcontext#api-request-context-fetch-option-multipart) option in `APIRequestContext.FetchAsync()`: -- ``` ```csharp var formData = Context.APIRequest.CreateFormData(); formData.Append("file", new FilePayload() diff --git a/docs/src/release-notes-java.md b/docs/src/release-notes-java.md index 3980a1b0cf..cb948b15da 100644 --- a/docs/src/release-notes-java.md +++ b/docs/src/release-notes-java.md @@ -4,6 +4,45 @@ title: "Release notes" toc_max_heading_level: 2 --- +## Version 1.46 + +### TLS Client Certificates + +Playwright now allows to supply client-side certificates, so that server can verify them, as specified by TLS Client Authentication. + +You can provide client certificates as a parameter of [`method: Browser.newContext`] and [`method: APIRequest.newContext`]. The following snippet sets up a client certificate for `https://example.com`: + +```java +BrowserContext context = browser.newContext(new Browser.NewContextOptions() + .setClientCertificates(asList(new ClientCertificate("https://example.com") + .setCertPath(Paths.get("client-certificates/cert.pem")) + .setKeyPath(Paths.get("client-certificates/key.pem"))))); +``` + +### Trace Viewer Updates + +- Content of text attachments is now rendered inline in the attachments pane. +- New setting to show/hide routing actions like [`method: Route.continue`]. +- Request method and status are shown in the network details tab. +- New button to copy source file location to clipboard. +- Metadata pane now displays the `baseURL`. + +### Miscellaneous + +- New `maxRetries` option in [`method: APIRequestContext.fetch`] which retries on the `ECONNRESET` network error. + +### Browser Versions + +- Chromium 128.0.6613.18 +- Mozilla Firefox 128.0 +- WebKit 18.0 + +This version was also tested against the following stable channels: + +- Google Chrome 127 +- Microsoft Edge 127 + + ## Version 1.45 ### Clock diff --git a/docs/src/release-notes-js.md b/docs/src/release-notes-js.md index 2b9f485eb0..7898136acf 100644 --- a/docs/src/release-notes-js.md +++ b/docs/src/release-notes-js.md @@ -33,6 +33,18 @@ export default defineConfig({ You can also provide client certificates to a particular [test project](./api/class-testproject#test-project-use) or as a parameter of [`method: Browser.newContext`] and [`method: APIRequest.newContext`]. +### `--only-changed` cli option + +New CLI option `--only-changed` allows to only run test files that have been changed since the last git commit or from a specific git "ref". + +```sh +# Only run test files with uncommitted changes +npx playwright test --only-changed + +# Only run test files changed relative to the "main" branch +npx playwrigh test --only-changed=main +``` + ### Component Testing: New `router` fixture This release introduces an experimental `router` fixture to intercept and handle network requests in component testing. @@ -59,29 +71,24 @@ test('example test', async ({ mount }) => { This fixture is only available in [component tests](./test-components#handling-network-requests). -### Test runner - -- New CLI option `--only-changed` to only run test files that have been changed since the last commit or from a specific git "ref". -- New option to [box a fixture](./test-fixtures#box-fixtures) to minimize the fixture exposure in test reports and error messages. -- New option to provide a [custom fixture title](./test-fixtures#custom-fixture-title) to be used in test reports and error messages. - ### UI Mode / Trace Viewer Updates -- New testing options pane in the UI mode to control test execution, for example "single worker" or "headed browser". -- New setting to show/hide routing actions like `route.continue`. +- Test annotations are now shown in UI mode. +- Content of text attachments is now rendered inline in the attachments pane. +- New setting to show/hide routing actions like [`method: Route.continue`]. - Request method and status are shown in the network details tab. - New button to copy source file location to clipboard. -- Content of text attachments is now rendered inline in the attachments pane. - Metadata pane now displays the `baseURL`. ### Miscellaneous - New `maxRetries` option in [`method: APIRequestContext.fetch`] which retries on the `ECONNRESET` network error. -- Improved link rendering inside annotations and attachments in the html report. +- New option to [box a fixture](./test-fixtures#box-fixtures) to minimize the fixture exposure in test reports and error messages. +- New option to provide a [custom fixture title](./test-fixtures#custom-fixture-title) to be used in test reports and error messages. ### Browser Versions -- Chromium 128.0.6613.7 +- Chromium 128.0.6613.18 - Mozilla Firefox 128.0 - WebKit 18.0 diff --git a/docs/src/release-notes-python.md b/docs/src/release-notes-python.md index 15176ca472..d19589023f 100644 --- a/docs/src/release-notes-python.md +++ b/docs/src/release-notes-python.md @@ -4,6 +4,50 @@ title: "Release notes" toc_max_heading_level: 2 --- +## Version 1.46 + +### TLS Client Certificates + +Playwright now allows to supply client-side certificates, so that server can verify them, as specified by TLS Client Authentication. + +You can provide client certificates as a parameter of [`method: Browser.newContext`] and [`method: APIRequest.newContext`]. The following snippet sets up a client certificate for `https://example.com`: + +```python +context = browser.new_context( + client_certificates=[ + { + "origin": "https://example.com", + "certPath": "client-certificates/cert.pem", + "keyPath": "client-certificates/key.pem", + } + ], +) +``` + +### Trace Viewer Updates + +- Content of text attachments is now rendered inline in the attachments pane. +- New setting to show/hide routing actions like [`method: Route.continue`]. +- Request method and status are shown in the network details tab. +- New button to copy source file location to clipboard. +- Metadata pane now displays the `base_url`. + +### Miscellaneous + +- New `maxRetries` option in [`method: APIRequestContext.fetch`] which retries on the `ECONNRESET` network error. + +### Browser Versions + +- Chromium 128.0.6613.18 +- Mozilla Firefox 128.0 +- WebKit 18.0 + +This version was also tested against the following stable channels: + +- Google Chrome 127 +- Microsoft Edge 127 + + ## Version 1.45 ### Clock diff --git a/docs/src/test-cli-js.md b/docs/src/test-cli-js.md index 346d0bc8b5..005452138a 100644 --- a/docs/src/test-cli-js.md +++ b/docs/src/test-cli-js.md @@ -103,5 +103,6 @@ Complete set of Playwright Test options is available in the [configuration file] | `--shard ` | [Shard](./test-parallel.md#shard-tests-between-multiple-machines) tests and execute only selected shard, specified in the form `current/all`, 1-based, for example `3/5`.| | `--timeout ` | Maximum timeout in milliseconds for each test, defaults to 30 seconds. Learn more about [various timeouts](./test-timeouts.md).| | `--trace ` | Force tracing mode, can be `on`, `off`, `on-first-retry`, `on-all-retries`, `retain-on-failure` | +| `--tsconfig ` | Path to a single tsconfig applicable to all imported files. See [tsconfig resolution](./test-typescript.md#tsconfig-resolution) for more details. | | `--update-snapshots` or `-u` | Whether to update [snapshots](./test-snapshots.md) with actual results instead of comparing them. Use this when snapshot expectations have changed.| | `--workers ` or `-j `| The maximum number of concurrent worker processes that run in [parallel](./test-parallel.md). | diff --git a/docs/src/test-components-js.md b/docs/src/test-components-js.md index dc448eff00..e0bdc000bf 100644 --- a/docs/src/test-components-js.md +++ b/docs/src/test-components-js.md @@ -697,7 +697,7 @@ test('update', async ({ mount }) => { ```js test('update', async ({ mount }) => { - const component = await mount(); + const component = await mount(Component); await component.update({ props: { msg: 'greetings' }, on: { callback: () => {} }, @@ -711,7 +711,7 @@ test('update', async ({ mount }) => { ```js test('update', async ({ mount }) => { - const component = await mount(); + const component = await mount(Component); await component.update({ props: { msg: 'greetings' }, on: { callback: () => {} }, diff --git a/docs/src/test-typescript-js.md b/docs/src/test-typescript-js.md index 09614f0306..5eaa3670a5 100644 --- a/docs/src/test-typescript-js.md +++ b/docs/src/test-typescript-js.md @@ -5,9 +5,9 @@ title: "TypeScript" ## Introduction -Playwright supports TypeScript out of the box. You just write tests in TypeScript, and Playwright will read them, transform to JavaScript and run. Note that Playwright does not check the types and will run tests even if there are non-critical TypeScript compilation errors. +Playwright supports TypeScript out of the box. You just write tests in TypeScript, and Playwright will read them, transform to JavaScript and run. -We recommend you run TypeScript compiler alongside Playwright. For example on GitHub actions: +Note that Playwright does not check the types and will run tests even if there are non-critical TypeScript compilation errors. We recommend you run TypeScript compiler alongside Playwright. For example on GitHub actions: ```yaml jobs: @@ -28,7 +28,7 @@ npx tsc -p tsconfig.json --noEmit -w ## tsconfig.json -Playwright will pick up `tsconfig.json` for each source file it loads. Note that Playwright **only supports** the following tsconfig options: `paths` and `baseUrl`. +Playwright will pick up `tsconfig.json` for each source file it loads. Note that Playwright **only supports** the following tsconfig options: `allowJs`, `baseUrl`, `paths` and `references`. We recommend setting up a separate `tsconfig.json` in the tests directory so that you can change some preferences specifically for the tests. Here is an example directory structure. @@ -49,12 +49,12 @@ playwright.config.ts Playwright supports [path mapping](https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping) declared in the `tsconfig.json`. Make sure that `baseUrl` is also set. -Here is an example `tsconfig.json` that works with Playwright Test: +Here is an example `tsconfig.json` that works with Playwright: -```json +```json title="tsconfig.json" { "compilerOptions": { - "baseUrl": ".", // This must be specified if "paths" is. + "baseUrl": ".", "paths": { "@myhelper/*": ["packages/myhelper/*"] // This mapping is relative to "baseUrl". } @@ -74,6 +74,22 @@ test('example', async ({ page }) => { }); ``` +### tsconfig resolution + +By default, Playwright will look up a closest tsconfig for each imported file by going up the directory structure and looking for `tsconfig.json` or `jsconfig.json`. This way, you can create a `tests/tsconfig.json` file that will be used only for your tests and Playwright will pick it up automatically. + +```sh +# Playwright will choose tsconfig automatically +npx playwrigh test +``` + +Alternatively, you can specify a single tsconfig file to use in the command line, and Playwright will use it for all imported files, not only test files. + +```sh +# Pass a specific tsconfig +npx playwrigh test --tsconfig=tsconfig.test.json +``` + ## Manually compile tests with TypeScript Sometimes, Playwright Test will not be able to transform your TypeScript code correctly, for example when you are using experimental or very recent features of TypeScript, usually configured in `tsconfig.json`. diff --git a/packages/html-reporter/src/chip.tsx b/packages/html-reporter/src/chip.tsx index d236141475..8f4badf97f 100644 --- a/packages/html-reporter/src/chip.tsx +++ b/packages/html-reporter/src/chip.tsx @@ -19,6 +19,7 @@ import './chip.css'; import './colors.css'; import './common.css'; import * as icons from './icons'; +import { clsx } from '@web/uiUtils'; export const Chip: React.FC<{ header: JSX.Element | string, @@ -31,14 +32,14 @@ export const Chip: React.FC<{ }> = ({ header, expanded, setExpanded, children, noInsets, dataTestId, targetRef }) => { return
setExpanded?.(!expanded)} title={typeof header === 'string' ? header : undefined}> {setExpanded && !!expanded && icons.downArrow()} {setExpanded && !expanded && icons.rightArrow()} {header}
- {(!setExpanded || expanded) &&
{children}
} + {(!setExpanded || expanded) &&
{children}
}
; }; diff --git a/packages/html-reporter/src/index.tsx b/packages/html-reporter/src/index.tsx index 683113f32a..3fedf4938a 100644 --- a/packages/html-reporter/src/index.tsx +++ b/packages/html-reporter/src/index.tsx @@ -26,6 +26,12 @@ import { ReportView } from './reportView'; // @ts-ignore const zipjs = zipImport as typeof zip; +import logo from '@web/assets/playwright-logo.svg'; +const link = document.createElement('link'); +link.rel = 'shortcut icon'; +link.href = logo; +document.head.appendChild(link); + const ReportLoader: React.FC = () => { const [report, setReport] = React.useState(); React.useEffect(() => { diff --git a/packages/html-reporter/src/links.tsx b/packages/html-reporter/src/links.tsx index 42b9f9b5b9..55d7d24a6c 100644 --- a/packages/html-reporter/src/links.tsx +++ b/packages/html-reporter/src/links.tsx @@ -20,7 +20,8 @@ import * as icons from './icons'; import { TreeItem } from './treeItem'; import { CopyToClipboard } from './copyToClipboard'; import './links.css'; -import { linkifyText } from './renderUtils'; +import { linkifyText } from '@web/renderUtils'; +import { clsx } from '@web/uiUtils'; export function navigate(href: string) { window.history.pushState({}, '', href); @@ -48,8 +49,8 @@ export const Link: React.FunctionComponent<{ className?: string, title?: string, children: any, -}> = ({ href, click, ctrlClick, className, children, title }) => { - return { +}> = ({ click, ctrlClick, children, ...rest }) => { + return { if (click) { e.preventDefault(); navigate(e.metaKey || e.ctrlKey ? ctrlClick || click : click); @@ -64,7 +65,7 @@ export const ProjectLink: React.FunctionComponent<{ const encoded = encodeURIComponent(projectName); const value = projectName === encoded ? projectName : `"${encoded.replace(/%22/g, '%5C%22')}"`; return - + {projectName} ; diff --git a/packages/html-reporter/src/tabbedPane.tsx b/packages/html-reporter/src/tabbedPane.tsx index 769622137a..02d0c6f3b1 100644 --- a/packages/html-reporter/src/tabbedPane.tsx +++ b/packages/html-reporter/src/tabbedPane.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import { clsx } from '@web/uiUtils'; import './tabbedPane.css'; import * as React from 'react'; @@ -34,7 +35,7 @@ export const TabbedPane: React.FunctionComponent<{
{ tabs.map(tab => ( -
setSelectedTab(tab.id)} key={tab.id}>
{tab.title}
diff --git a/packages/html-reporter/src/testCaseView.spec.tsx b/packages/html-reporter/src/testCaseView.spec.tsx index 624a93805f..afe06cebcb 100644 --- a/packages/html-reporter/src/testCaseView.spec.tsx +++ b/packages/html-reporter/src/testCaseView.spec.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import React from 'react'; import { test, expect } from '@playwright/experimental-ct-react'; import { TestCaseView } from './testCaseView'; import type { TestCase, TestResult } from './types'; diff --git a/packages/html-reporter/src/testCaseView.tsx b/packages/html-reporter/src/testCaseView.tsx index f4d76653cf..4da49261d0 100644 --- a/packages/html-reporter/src/testCaseView.tsx +++ b/packages/html-reporter/src/testCaseView.tsx @@ -23,8 +23,9 @@ import { ProjectLink } from './links'; import { statusIcon } from './statusIcon'; import './testCaseView.css'; import { TestResultView } from './testResultView'; -import { linkifyText } from './renderUtils'; +import { linkifyText } from '@web/renderUtils'; import { hashStringToInt, msToString } from './utils'; +import { clsx } from '@web/uiUtils'; export const TestCaseView: React.FC<{ projectNames: string[], @@ -90,7 +91,7 @@ const LabelsLinkView: React.FC {labels.map(label => (
- + {label.slice(1)} diff --git a/packages/html-reporter/src/testFileView.tsx b/packages/html-reporter/src/testFileView.tsx index 184b2b63ba..bd402a74de 100644 --- a/packages/html-reporter/src/testFileView.tsx +++ b/packages/html-reporter/src/testFileView.tsx @@ -23,6 +23,7 @@ import { generateTraceUrl, Link, navigate, ProjectLink } from './links'; import { statusIcon } from './statusIcon'; import './testFileView.css'; import { video, image, trace } from './icons'; +import { clsx } from '@web/uiUtils'; export const TestFileView: React.FC}> {file.tests.filter(t => filter.matches(t)).map(test => -
+
@@ -101,7 +102,7 @@ const LabelsClickView: React.FC 0 ? ( <> {labels.map(label => ( - onClickHandle(e, label)}> + onClickHandle(e, label)}> {label.slice(1)} ))} diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 5c41aa2e68..fd98db3439 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -3,15 +3,15 @@ "browsers": [ { "name": "chromium", - "revision": "1128", + "revision": "1129", "installByDefault": true, - "browserVersion": "128.0.6613.7" + "browserVersion": "128.0.6613.18" }, { "name": "chromium-tip-of-tree", - "revision": "1244", + "revision": "1246", "installByDefault": false, - "browserVersion": "129.0.6616.0" + "browserVersion": "129.0.6630.0" }, { "name": "firefox", @@ -27,7 +27,7 @@ }, { "name": "webkit", - "revision": "2053", + "revision": "2056", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", @@ -41,7 +41,7 @@ }, { "name": "ffmpeg", - "revision": "1009", + "revision": "1010", "installByDefault": true }, { diff --git a/packages/playwright-core/src/client/eventEmitter.ts b/packages/playwright-core/src/client/eventEmitter.ts index 6de8ca3a31..c0375a70dc 100644 --- a/packages/playwright-core/src/client/eventEmitter.ts +++ b/packages/playwright-core/src/client/eventEmitter.ts @@ -23,7 +23,7 @@ */ type EventType = string | symbol; -type Listener = (...args: any[]) => void; +type Listener = (...args: any[]) => any; type EventMap = Record; import { EventEmitter as OriginalEventEmitter } from 'events'; import type { EventEmitter as EventEmitterType } from 'events'; @@ -34,6 +34,8 @@ export class EventEmitter implements EventEmitterType { private _events: EventMap | undefined = undefined; private _eventsCount = 0; private _maxListeners: number | undefined = undefined; + readonly _pendingHandlers = new Map>>(); + private _rejectionHandler: ((error: Error) => void) | undefined; constructor() { if (this._events === undefined || this._events === Object.getPrototypeOf(this)._events) { @@ -66,17 +68,34 @@ export class EventEmitter implements EventEmitterType { return false; if (typeof handler === 'function') { - Reflect.apply(handler, this, args); + this._callHandler(type, handler, args); } else { const len = handler.length; const listeners = handler.slice(); for (let i = 0; i < len; ++i) - Reflect.apply(listeners[i], this, args); + this._callHandler(type, listeners[i], args); } - return true; } + private _callHandler(type: EventType, handler: Listener, args: any[]): void { + const promise = Reflect.apply(handler, this, args); + if (!(promise instanceof Promise)) + return; + let set = this._pendingHandlers.get(type); + if (!set) { + set = new Set(); + this._pendingHandlers.set(type, set); + } + set.add(promise); + promise.catch(e => { + if (this._rejectionHandler) + this._rejectionHandler(e); + else + throw e; + }).finally(() => set.delete(promise)); + } + addListener(type: EventType, listener: Listener): this { return this._addListener(type, listener, false); } @@ -214,10 +233,34 @@ export class EventEmitter implements EventEmitterType { return this.removeListener(type, listener); } - removeAllListeners(type?: string): this { + removeAllListeners(type?: EventType): this; + removeAllListeners(type: EventType | undefined, options: { behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; + removeAllListeners(type?: string, options?: { behavior?: 'wait'|'ignoreErrors'|'default' }): this | Promise { + this._removeAllListeners(type); + if (!options) + return this; + + if (options.behavior === 'wait') { + const errors: Error[] = []; + this._rejectionHandler = error => errors.push(error); + // eslint-disable-next-line internal-playwright/await-promise-in-class-returns + return this._waitFor(type).then(() => { + if (errors.length) + throw errors[0]; + }); + } + + if (options.behavior === 'ignoreErrors') + this._rejectionHandler = () => {}; + + // eslint-disable-next-line internal-playwright/await-promise-in-class-returns + return Promise.resolve(); + } + + private _removeAllListeners(type?: string) { const events = this._events; if (!events) - return this; + return; // not listening for removeListener, no need to emit if (!events.removeListener) { @@ -230,7 +273,7 @@ export class EventEmitter implements EventEmitterType { else delete events[type]; } - return this; + return; } // emit removeListener for all listeners on all events @@ -241,12 +284,12 @@ export class EventEmitter implements EventEmitterType { key = keys[i]; if (key === 'removeListener') continue; - this.removeAllListeners(key); + this._removeAllListeners(key); } - this.removeAllListeners('removeListener'); + this._removeAllListeners('removeListener'); this._events = Object.create(null); this._eventsCount = 0; - return this; + return; } const listeners = events[type]; @@ -258,8 +301,6 @@ export class EventEmitter implements EventEmitterType { for (let i = listeners.length - 1; i >= 0; i--) this.removeListener(type, listeners[i]); } - - return this; } listeners(type: EventType): Listener[] { @@ -286,6 +327,18 @@ export class EventEmitter implements EventEmitterType { return this._eventsCount > 0 && this._events ? Reflect.ownKeys(this._events) : []; } + private async _waitFor(type?: EventType) { + let promises: Promise[] = []; + if (type) { + promises = [...(this._pendingHandlers.get(type) || [])]; + } else { + promises = []; + for (const [, pending] of this._pendingHandlers) + promises.push(...pending); + } + await Promise.all(promises); + } + private _listeners(target: EventEmitter, type: EventType, unwrap: boolean): Listener[] { const events = target._events; @@ -310,7 +363,7 @@ function checkListener(listener: any) { class OnceWrapper { private _fired = false; - readonly wrapperFunction: (...args: any[]) => void; + readonly wrapperFunction: (...args: any[]) => Promise | void; readonly _listener: Listener; private _eventEmitter: EventEmitter; private _eventType: EventType; diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index da21dff708..80681130ef 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -86,7 +86,7 @@ export abstract class BrowserContext extends SdkObject { private _customCloseHandler?: () => Promise; readonly _tempDirs: string[] = []; private _settingStorageState = false; - readonly initScripts: InitScript[] = []; + initScripts: InitScript[] = []; private _routesInFlight = new Set(); private _debugger!: Debugger; _closeReason: string | undefined; @@ -271,9 +271,7 @@ export abstract class BrowserContext extends SdkObject { protected abstract doClearPermissions(): Promise; protected abstract doSetHTTPCredentials(httpCredentials?: types.Credentials): Promise; protected abstract doAddInitScript(initScript: InitScript): Promise; - protected abstract doRemoveInitScripts(): Promise; - protected abstract doExposeBinding(binding: PageBinding): Promise; - protected abstract doRemoveExposedBindings(): Promise; + protected abstract doRemoveNonInternalInitScripts(): Promise; protected abstract doUpdateRequestInterception(): Promise; protected abstract doClose(reason: string | undefined): Promise; protected abstract onClosePersistent(): void; @@ -320,15 +318,16 @@ export abstract class BrowserContext extends SdkObject { } const binding = new PageBinding(name, playwrightBinding, needsHandle); this._pageBindings.set(name, binding); - await this.doExposeBinding(binding); + await this.doAddInitScript(binding.initScript); + const frames = this.pages().map(page => page.frames()).flat(); + await Promise.all(frames.map(frame => frame.evaluateExpression(binding.initScript.source).catch(e => {}))); } async _removeExposedBindings() { - for (const key of this._pageBindings.keys()) { - if (!key.startsWith('__pw')) + for (const [key, binding] of this._pageBindings) { + if (!binding.internal) this._pageBindings.delete(key); } - await this.doRemoveExposedBindings(); } async grantPermissions(permissions: string[], origin?: string) { @@ -414,8 +413,8 @@ export abstract class BrowserContext extends SdkObject { } async _removeInitScripts(): Promise { - this.initScripts.splice(0, this.initScripts.length); - await this.doRemoveInitScripts(); + this.initScripts = this.initScripts.filter(script => script.internal); + await this.doRemoveNonInternalInitScripts(); } async setRequestInterceptor(handler: network.RouteHandler | undefined): Promise { diff --git a/packages/playwright-core/src/server/chromium/crBrowser.ts b/packages/playwright-core/src/server/chromium/crBrowser.ts index 777ff2eee8..42b916c186 100644 --- a/packages/playwright-core/src/server/chromium/crBrowser.ts +++ b/packages/playwright-core/src/server/chromium/crBrowser.ts @@ -21,7 +21,7 @@ import { Browser } from '../browser'; import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext'; import { assert, createGuid } from '../../utils'; import * as network from '../network'; -import type { InitScript, PageBinding, PageDelegate, Worker } from '../page'; +import type { InitScript, PageDelegate, Worker } from '../page'; import { Page } from '../page'; import { Frame } from '../frames'; import type { Dialog } from '../dialog'; @@ -491,19 +491,9 @@ export class CRBrowserContext extends BrowserContext { await (page._delegate as CRPage).addInitScript(initScript); } - async doRemoveInitScripts() { + async doRemoveNonInternalInitScripts() { for (const page of this.pages()) - await (page._delegate as CRPage).removeInitScripts(); - } - - async doExposeBinding(binding: PageBinding) { - for (const page of this.pages()) - await (page._delegate as CRPage).exposeBinding(binding); - } - - async doRemoveExposedBindings() { - for (const page of this.pages()) - await (page._delegate as CRPage).removeExposedBindings(); + await (page._delegate as CRPage).removeNonInternalInitScripts(); } async doUpdateRequestInterception(): Promise { diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index 53b96ad403..904ed5a479 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -26,7 +26,7 @@ import * as dom from '../dom'; import * as frames from '../frames'; import { helper } from '../helper'; import * as network from '../network'; -import type { InitScript, PageBinding, PageDelegate } from '../page'; +import { type InitScript, PageBinding, type PageDelegate } from '../page'; import { Page, Worker } from '../page'; import type { Progress } from '../progress'; import type * as types from '../types'; @@ -182,15 +182,6 @@ export class CRPage implements PageDelegate { return this._sessionForFrame(frame)._navigate(frame, url, referrer); } - async exposeBinding(binding: PageBinding) { - await this._forAllFrameSessions(frame => frame._initBinding(binding)); - await Promise.all(this._page.frames().map(frame => frame.evaluateExpression(binding.source).catch(e => {}))); - } - - async removeExposedBindings() { - await this._forAllFrameSessions(frame => frame._removeExposedBindings()); - } - async updateExtraHTTPHeaders(): Promise { const headers = network.mergeHeaders([ this._browserContext._options.extraHTTPHeaders, @@ -260,7 +251,7 @@ export class CRPage implements PageDelegate { await this._forAllFrameSessions(frame => frame._evaluateOnNewDocument(initScript, world)); } - async removeInitScripts() { + async removeNonInternalInitScripts() { await this._forAllFrameSessions(frame => frame._removeEvaluatesOnNewDocument()); } @@ -420,7 +411,6 @@ class FrameSession { private _screencastId: string | null = null; private _screencastClients = new Set(); private _evaluateOnNewDocumentIdentifiers: string[] = []; - private _exposedBindingNames: string[] = []; private _metricsOverride: Protocol.Emulation.setDeviceMetricsOverrideParameters | undefined; private _workerSessions = new Map(); @@ -519,9 +509,7 @@ class FrameSession { grantUniveralAccess: true, worldName: UTILITY_WORLD_NAME, }); - for (const binding of this._crPage._browserContext._pageBindings.values()) - frame.evaluateExpression(binding.source).catch(e => {}); - for (const initScript of this._crPage._browserContext.initScripts) + for (const initScript of this._crPage._page.allInitScripts()) frame.evaluateExpression(initScript.source).catch(e => {}); } @@ -541,6 +529,7 @@ class FrameSession { this._client.send('Log.enable', {}), lifecycleEventsEnabled = this._client.send('Page.setLifecycleEventsEnabled', { enabled: true }), this._client.send('Runtime.enable', {}), + this._client.send('Runtime.addBinding', { name: PageBinding.kPlaywrightBinding }), this._client.send('Page.addScriptToEvaluateOnNewDocument', { source: '', worldName: UTILITY_WORLD_NAME, @@ -573,11 +562,7 @@ class FrameSession { promises.push(this._updateGeolocation(true)); promises.push(this._updateEmulateMedia()); promises.push(this._updateFileChooserInterception(true)); - for (const binding of this._crPage._page.allBindings()) - promises.push(this._initBinding(binding)); - for (const initScript of this._crPage._browserContext.initScripts) - promises.push(this._evaluateOnNewDocument(initScript, 'main')); - for (const initScript of this._crPage._page.initScripts) + for (const initScript of this._crPage._page.allInitScripts()) promises.push(this._evaluateOnNewDocument(initScript, 'main')); if (screencastOptions) promises.push(this._startVideoRecording(screencastOptions)); @@ -834,25 +819,6 @@ class FrameSession { this._page._addConsoleMessage(event.type, values, toConsoleMessageLocation(event.stackTrace)); } - async _initBinding(binding: PageBinding) { - const [, response] = await Promise.all([ - this._client.send('Runtime.addBinding', { name: binding.name }), - this._client.send('Page.addScriptToEvaluateOnNewDocument', { source: binding.source }) - ]); - this._exposedBindingNames.push(binding.name); - if (!binding.name.startsWith('__pw')) - this._evaluateOnNewDocumentIdentifiers.push(response.identifier); - } - - async _removeExposedBindings() { - const toRetain: string[] = []; - const toRemove: string[] = []; - for (const name of this._exposedBindingNames) - (name.startsWith('__pw_') ? toRetain : toRemove).push(name); - this._exposedBindingNames = toRetain; - await Promise.all(toRemove.map(name => this._client.send('Runtime.removeBinding', { name }))); - } - async _onBindingCalled(event: Protocol.Runtime.bindingCalledPayload) { const pageOrError = await this._crPage.pageOrError(); if (!(pageOrError instanceof Error)) { @@ -1102,7 +1068,8 @@ class FrameSession { async _evaluateOnNewDocument(initScript: InitScript, world: types.World): Promise { const worldName = world === 'utility' ? UTILITY_WORLD_NAME : undefined; const { identifier } = await this._client.send('Page.addScriptToEvaluateOnNewDocument', { source: initScript.source, worldName }); - this._evaluateOnNewDocumentIdentifiers.push(identifier); + if (!initScript.internal) + this._evaluateOnNewDocumentIdentifiers.push(identifier); } async _removeEvaluatesOnNewDocument(): Promise { diff --git a/packages/playwright-core/src/server/chromium/videoRecorder.ts b/packages/playwright-core/src/server/chromium/videoRecorder.ts index bfb377e79b..25199413f8 100644 --- a/packages/playwright-core/src/server/chromium/videoRecorder.ts +++ b/packages/playwright-core/src/server/chromium/videoRecorder.ts @@ -98,7 +98,7 @@ export class VideoRecorder { const w = options.width; const h = options.height; - const args = `-loglevel error -f image2pipe -avioflags direct -fpsprobesize 0 -probesize 32 -analyzeduration 0 -c:v mjpeg -i - -y -an -r ${fps} -c:v vp8 -qmin 0 -qmax 50 -crf 8 -deadline realtime -speed 8 -b:v 1M -threads 1 -vf pad=${w}:${h}:0:0:gray,crop=${w}:${h}:0:0`.split(' '); + const args = `-loglevel error -f image2pipe -avioflags direct -fpsprobesize 0 -probesize 32 -analyzeduration 0 -c:v mjpeg -i pipe:0 -y -an -r ${fps} -c:v vp8 -qmin 0 -qmax 50 -crf 8 -deadline realtime -speed 8 -b:v 1M -threads 1 -vf pad=${w}:${h}:0:0:gray,crop=${w}:${h}:0:0`.split(' '); args.push(options.outputFile); const progress = this._progress; diff --git a/packages/playwright-core/src/server/deviceDescriptorsSource.json b/packages/playwright-core/src/server/deviceDescriptorsSource.json index 2209498e32..e564e3d895 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/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 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/128.0.6613.18 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/128.0.6613.7 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/128.0.6613.18 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/128.0.6613.7 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/128.0.6613.18 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -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/128.0.6613.7 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/128.0.6613.18 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -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/128.0.6613.7 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/128.0.6613.18 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/128.0.6613.7 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/128.0.6613.18 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/128.0.6613.7 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 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/128.0.6613.18 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/128.0.6613.7 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/128.0.6613.18 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/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 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/128.0.6613.18 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/128.0.6613.7 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/128.0.6613.18 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/128.0.6613.7 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 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/128.0.6613.18 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/128.0.6613.7 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/128.0.6613.18 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/128.0.6613.7 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/128.0.6613.18 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/128.0.6613.7 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/128.0.6613.18 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/128.0.6613.7 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/128.0.6613.18 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/128.0.6613.7 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/128.0.6613.18 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/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Safari/537.36 Edg/128.0.6613.7", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Safari/537.36 Edg/128.0.6613.18", "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/128.0.6613.7 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 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/128.0.6613.7 Safari/537.36 Edg/128.0.6613.7", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Safari/537.36 Edg/128.0.6613.18", "screen": { "width": 1920, "height": 1080 diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index ed487e77d2..aef805798d 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -17,7 +17,6 @@ import type * as channels from '@protocol/channels'; import type { LookupAddress } from 'dns'; import http from 'http'; -import fs from 'fs'; import https from 'https'; import type { Readable, TransformCallback } from 'stream'; import { pipeline, Transform } from 'stream'; @@ -26,7 +25,7 @@ import zlib from 'zlib'; import type { HTTPCredentials } from '../../types/types'; import { TimeoutSettings } from '../common/timeoutSettings'; import { getUserAgent } from '../utils/userAgent'; -import { assert, createGuid, isUnderTest, monotonicTime } from '../utils'; +import { assert, createGuid, monotonicTime } from '../utils'; import { HttpsProxyAgent, SocksProxyAgent } from '../utilsBundle'; import { BrowserContext, verifyClientCertificates } from './browserContext'; import { CookieStore, domainMatches } from './cookieStore'; @@ -41,7 +40,7 @@ import { Tracing } from './trace/recorder/tracing'; import type * as types from './types'; import type { HeadersArray, ProxySettings } from './types'; import { kMaxCookieExpiresDateInSeconds } from './network'; -import { clientCertificatesToTLSOptions } from './socksClientCertificatesInterceptor'; +import { clientCertificatesToTLSOptions, rewriteOpenSSLErrorIfNeeded } from './socksClientCertificatesInterceptor'; type FetchRequestOptions = { userAgent: string; @@ -199,8 +198,6 @@ export abstract class APIRequestContext extends SdkObject { ...clientCertificatesToTLSOptions(this._defaultOptions().clientCertificates, requestUrl.origin), __testHookLookup: (params as any).__testHookLookup, }; - if (process.env.PWTEST_UNSUPPORTED_CUSTOM_CA && isUnderTest()) - options.ca = [fs.readFileSync(process.env.PWTEST_UNSUPPORTED_CUSTOM_CA)]; // rejectUnauthorized = undefined is treated as true in Node.js 12. if (params.ignoreHTTPSErrors || defaults.ignoreHTTPSErrors) options.rejectUnauthorized = false; @@ -214,8 +211,16 @@ export abstract class APIRequestContext extends SdkObject { }); const fetchUid = this._storeResponseBody(fetchResponse.body); this.fetchLog.set(fetchUid, controller.metadata.log); - if (params.failOnStatusCode && (fetchResponse.status < 200 || fetchResponse.status >= 400)) - throw new Error(`${fetchResponse.status} ${fetchResponse.statusText}`); + if (params.failOnStatusCode && (fetchResponse.status < 200 || fetchResponse.status >= 400)) { + let responseText = ''; + if (fetchResponse.body.byteLength) { + let text = fetchResponse.body.toString('utf8'); + if (text.length > 1000) + text = text.substring(0, 997) + '...'; + responseText = `\nResponse text:\n${text}`; + } + throw new Error(`${fetchResponse.status} ${fetchResponse.statusText}${responseText}`); + } return { ...fetchResponse, fetchUid }; } @@ -447,7 +452,7 @@ export abstract class APIRequestContext extends SdkObject { body.on('data', chunk => chunks.push(chunk)); body.on('end', notifyBodyFinished); }); - request.on('error', reject); + request.on('error', error => reject(rewriteOpenSSLErrorIfNeeded(error))); const disposeListener = () => { reject(new Error('Request context disposed.')); diff --git a/packages/playwright-core/src/server/firefox/ffBrowser.ts b/packages/playwright-core/src/server/firefox/ffBrowser.ts index 7ed2d8cb2f..94b90bbcea 100644 --- a/packages/playwright-core/src/server/firefox/ffBrowser.ts +++ b/packages/playwright-core/src/server/firefox/ffBrowser.ts @@ -21,7 +21,8 @@ import type { BrowserOptions } from '../browser'; import { Browser } from '../browser'; import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext'; import * as network from '../network'; -import type { InitScript, Page, PageBinding, PageDelegate } from '../page'; +import type { InitScript, Page, PageDelegate } from '../page'; +import { PageBinding } from '../page'; import type { ConnectionTransport } from '../transport'; import type * as types from '../types'; import type * as channels from '@protocol/channels'; @@ -178,7 +179,10 @@ export class FFBrowserContext extends BrowserContext { override async _initialize() { assert(!this._ffPages().length); const browserContextId = this._browserContextId; - const promises: Promise[] = [super._initialize()]; + const promises: Promise[] = [ + super._initialize(), + this._browser.session.send('Browser.addBinding', { browserContextId: this._browserContextId, name: PageBinding.kPlaywrightBinding, script: '' }), + ]; if (this._options.acceptDownloads !== 'internal-browser-default') { promises.push(this._browser.session.send('Browser.setDownloadOptions', { browserContextId, @@ -353,21 +357,17 @@ export class FFBrowserContext extends BrowserContext { } async doAddInitScript(initScript: InitScript) { - await this._browser.session.send('Browser.setInitScripts', { browserContextId: this._browserContextId, scripts: this.initScripts.map(script => ({ script: script.source })) }); + await this._updateInitScripts(); } - async doRemoveInitScripts() { - await this._browser.session.send('Browser.setInitScripts', { browserContextId: this._browserContextId, scripts: [] }); + async doRemoveNonInternalInitScripts() { + await this._updateInitScripts(); } - async doExposeBinding(binding: PageBinding) { - await this._browser.session.send('Browser.addBinding', { browserContextId: this._browserContextId, name: binding.name, script: binding.source }); - } - - async doRemoveExposedBindings() { - // TODO: implement me. - // This is not a critical problem, what ends up happening is - // an old binding will be restored upon page reload and will point nowhere. + private async _updateInitScripts() { + const bindingScripts = [...this._pageBindings.values()].map(binding => binding.initScript.source); + const initScripts = this.initScripts.map(script => script.source); + await this._browser.session.send('Browser.setInitScripts', { browserContextId: this._browserContextId, scripts: [...bindingScripts, ...initScripts].map(script => ({ script })) }); } async doUpdateRequestInterception(): Promise { diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index aac22d5e50..6778a777f2 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -20,7 +20,7 @@ import * as dom from '../dom'; import type * as frames from '../frames'; import type { RegisteredListener } from '../../utils/eventsHelper'; import { eventsHelper } from '../../utils/eventsHelper'; -import type { PageBinding, PageDelegate } from '../page'; +import type { PageDelegate } from '../page'; import { InitScript } from '../page'; import { Page, Worker } from '../page'; import type * as types from '../types'; @@ -114,7 +114,7 @@ export class FFPage implements PageDelegate { }); // Ideally, we somehow ensure that utility world is created before Page.ready arrives, but currently it is racy. // Therefore, we can end up with an initialized page without utility world, although very unlikely. - this.addInitScript(new InitScript(''), UTILITY_WORLD_NAME).catch(e => this._markAsError(e)); + this.addInitScript(new InitScript('', true), UTILITY_WORLD_NAME).catch(e => this._markAsError(e)); } potentiallyUninitializedPage(): Page { @@ -336,14 +336,6 @@ export class FFPage implements PageDelegate { this._browserContext._browser._videoStarted(this._browserContext, event.screencastId, event.file, this.pageOrError()); } - async exposeBinding(binding: PageBinding) { - await this._session.send('Page.addBinding', { name: binding.name, script: binding.source }); - } - - async removeExposedBindings() { - // TODO: implement me. - } - didClose() { this._markAsError(new TargetClosedError()); this._session.dispose(); @@ -412,9 +404,9 @@ export class FFPage implements PageDelegate { await this._session.send('Page.setInitScripts', { scripts: this._initScripts.map(s => ({ script: s.initScript.source, worldName: s.worldName })) }); } - async removeInitScripts() { - this._initScripts = []; - await this._session.send('Page.setInitScripts', { scripts: [] }); + async removeNonInternalInitScripts() { + this._initScripts = this._initScripts.filter(s => s.initScript.internal); + await this._session.send('Page.setInitScripts', { scripts: this._initScripts.map(s => ({ script: s.initScript.source, worldName: s.worldName })) }); } async closePage(runBeforeUnload: boolean): Promise { diff --git a/packages/playwright-core/src/server/injected/clock.ts b/packages/playwright-core/src/server/injected/clock.ts index afce525be1..48cc9276a2 100644 --- a/packages/playwright-core/src/server/injected/clock.ts +++ b/packages/playwright-core/src/server/injected/clock.ts @@ -697,6 +697,14 @@ export function install(globalObject: WindowOrWorkerGlobalScope, config: Install (globalObject as any).Intl = api[method]!; } else if (method === 'performance') { (globalObject as any).performance = api[method]!; + const kEventTimeStamp = Symbol('playwrightEventTimeStamp'); + Object.defineProperty(Event.prototype, 'timeStamp', { + get() { + if (!this[kEventTimeStamp]) + this[kEventTimeStamp] = api.performance?.now(); + return this[kEventTimeStamp]; + } + }); } else { (globalObject as any)[method] = (...args: any[]) => { return (api[method] as any).apply(api, args); diff --git a/packages/playwright-core/src/server/isomorphic/utilityScriptSerializers.ts b/packages/playwright-core/src/server/isomorphic/utilityScriptSerializers.ts index 0f3895fac5..6df7988caf 100644 --- a/packages/playwright-core/src/server/isomorphic/utilityScriptSerializers.ts +++ b/packages/playwright-core/src/server/isomorphic/utilityScriptSerializers.ts @@ -170,8 +170,16 @@ export function source() { if (typeof value === 'bigint') return { bi: value.toString() }; - if (isError(value)) - return { e: { n: value.name, m: value.message, s: value.stack || '' } }; + if (isError(value)) { + let stack; + if (value.stack?.startsWith(value.name + ': ' + value.message)) { + // v8 + stack = value.stack; + } else { + stack = `${value.name}: ${value.message}\n${value.stack}`; + } + return { e: { n: value.name, m: value.message, s: stack } }; + } if (isDate(value)) return { d: value.toJSON() }; if (isURL(value)) diff --git a/packages/playwright-core/src/server/launchApp.ts b/packages/playwright-core/src/server/launchApp.ts index 12cb343f95..3d119f9d46 100644 --- a/packages/playwright-core/src/server/launchApp.ts +++ b/packages/playwright-core/src/server/launchApp.ts @@ -43,7 +43,7 @@ export async function launchApp(browserType: BrowserType, options: { } const context = await browserType.launchPersistentContext(serverSideCallMetadata(), '', { - channel: findChromiumChannel(options.sdkLanguage), + channel: !options.persistentContextOptions?.executablePath ? findChromiumChannel(options.sdkLanguage) : undefined, noDefaultViewport: true, ignoreDefaultArgs: ['--enable-automation'], colorScheme: 'no-override', @@ -90,6 +90,8 @@ export async function syncLocalStorageWithSettings(page: Page, appName: string) // iframes w/ snapshots, etc. if (location && location.protocol === 'data:') return; + if (window.top !== window) + return; Object.entries(settings).map(([k, v]) => localStorage[k] = v); (window as any).saveSettings = () => { (window as any)._saveSerializedSettings(JSON.stringify({ ...localStorage })); diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 6206681048..8176a1ba82 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -54,10 +54,8 @@ export interface PageDelegate { reload(): Promise; goBack(): Promise; goForward(): Promise; - exposeBinding(binding: PageBinding): Promise; - removeExposedBindings(): Promise; addInitScript(initScript: InitScript): Promise; - removeInitScripts(): Promise; + removeNonInternalInitScripts(): Promise; closePage(runBeforeUnload: boolean): Promise; potentiallyUninitializedPage(): Page; pageOrError(): Promise; @@ -154,7 +152,7 @@ export class Page extends SdkObject { private _emulatedMedia: Partial = {}; private _interceptFileChooser = false; private readonly _pageBindings = new Map(); - readonly initScripts: InitScript[] = []; + initScripts: InitScript[] = []; readonly _screenshotter: Screenshotter; readonly _frameManager: frames.FrameManager; readonly accessibility: accessibility.Accessibility; @@ -342,15 +340,15 @@ export class Page extends SdkObject { throw new Error(`Function "${name}" has been already registered in the browser context`); const binding = new PageBinding(name, playwrightBinding, needsHandle); this._pageBindings.set(name, binding); - await this._delegate.exposeBinding(binding); + await this._delegate.addInitScript(binding.initScript); + await Promise.all(this.frames().map(frame => frame.evaluateExpression(binding.initScript.source).catch(e => {}))); } async _removeExposedBindings() { - for (const key of this._pageBindings.keys()) { - if (!key.startsWith('__pw')) + for (const [key, binding] of this._pageBindings) { + if (!binding.internal) this._pageBindings.delete(key); } - await this._delegate.removeExposedBindings(); } setExtraHTTPHeaders(headers: types.HeadersArray) { @@ -533,8 +531,8 @@ export class Page extends SdkObject { } async _removeInitScripts() { - this.initScripts.splice(0, this.initScripts.length); - await this._delegate.removeInitScripts(); + this.initScripts = this.initScripts.filter(script => script.internal); + await this._delegate.removeNonInternalInitScripts(); } needsRequestInterception(): boolean { @@ -727,8 +725,9 @@ export class Page extends SdkObject { this._browserContext.addVisitedOrigin(origin); } - allBindings() { - return [...this._browserContext._pageBindings.values(), ...this._pageBindings.values()]; + allInitScripts() { + const bindings = [...this._browserContext._pageBindings.values(), ...this._pageBindings.values()]; + return [...bindings.map(binding => binding.initScript), ...this._browserContext.initScripts, ...this.initScripts]; } getBinding(name: string) { @@ -819,23 +818,29 @@ type BindingPayload = { }; export class PageBinding { + static kPlaywrightBinding = '__playwright__binding__'; + readonly name: string; readonly playwrightFunction: frames.FunctionWithSource; - readonly source: string; + readonly initScript: InitScript; readonly needsHandle: boolean; + readonly internal: boolean; constructor(name: string, playwrightFunction: frames.FunctionWithSource, needsHandle: boolean) { this.name = name; this.playwrightFunction = playwrightFunction; - this.source = `(${addPageBinding.toString()})(${JSON.stringify(name)}, ${needsHandle}, (${source})())`; + this.initScript = new InitScript(`(${addPageBinding.toString()})(${JSON.stringify(PageBinding.kPlaywrightBinding)}, ${JSON.stringify(name)}, ${needsHandle}, (${source})())`, true /* internal */); this.needsHandle = needsHandle; + this.internal = name.startsWith('__pw'); } static async dispatch(page: Page, payload: string, context: dom.FrameExecutionContext) { const { name, seq, serializedArgs } = JSON.parse(payload) as BindingPayload; try { assert(context.world); - const binding = page.getBinding(name)!; + const binding = page.getBinding(name); + if (!binding) + throw new Error(`Function "${name}" is not exposed`); let result: any; if (binding.needsHandle) { const handle = await context.evaluateHandle(takeHandle, { name, seq }).catch(e => null); @@ -877,10 +882,8 @@ export class PageBinding { } } -function addPageBinding(bindingName: string, needsHandle: boolean, utilityScriptSerializers: ReturnType) { - const binding = (globalThis as any)[bindingName]; - if (binding.__installed) - return; +function addPageBinding(playwrightBinding: string, bindingName: string, needsHandle: boolean, utilityScriptSerializers: ReturnType) { + const binding = (globalThis as any)[playwrightBinding]; (globalThis as any)[bindingName] = (...args: any[]) => { const me = (globalThis as any)[bindingName]; if (needsHandle && args.slice(1).some(arg => arg !== undefined)) @@ -919,8 +922,9 @@ function addPageBinding(bindingName: string, needsHandle: boolean, utilityScript export class InitScript { readonly source: string; + readonly internal: boolean; - constructor(source: string) { + constructor(source: string, internal?: boolean) { const guid = createGuid(); this.source = `(() => { globalThis.__pwInitScripts = globalThis.__pwInitScripts || {}; @@ -930,6 +934,7 @@ export class InitScript { globalThis.__pwInitScripts[${JSON.stringify(guid)}] = true; ${source} })();`; + this.internal = !!internal; } } diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index 40637302a0..03405c944e 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -127,6 +127,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { useWebSocket: !!process.env.PWTEST_RECORDER_PORT, handleSIGINT, args: process.env.PWTEST_RECORDER_PORT ? [`--remote-debugging-port=${process.env.PWTEST_RECORDER_PORT}`] : [], + executablePath: inspectedContext._browser.options.isChromium ? inspectedContext._browser.options.customExecutablePath : undefined, } }); const controller = new ProgressController(serverSideCallMetadata(), context._browser); diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index cd78c11e40..99b6c1f6ad 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -22,7 +22,7 @@ import fs from 'fs'; import tls from 'tls'; import stream from 'stream'; import { createSocket, createTLSSocket } from '../utils/happy-eyeballs'; -import { isUnderTest, ManualPromise } from '../utils'; +import { escapeHTML, ManualPromise, rewriteErrorMessage } from '../utils'; import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../common/socksProxy'; import { SocksProxy } from '../common/socksProxy'; import type * as channels from '@protocol/channels'; @@ -142,38 +142,18 @@ class SocksProxyConnection { dummyServer.emit('connection', this.internal); dummyServer.on('secureConnection', internalTLS => { debugLogger.log('client-certificates', `Browser->Proxy ${this.host}:${this.port} chooses ALPN ${internalTLS.alpnProtocol}`); - const tlsOptions: tls.ConnectionOptions = { - socket: this.target, - host: this.host, - port: this.port, - rejectUnauthorized: !this.socksProxy.ignoreHTTPSErrors, - ALPNProtocols: [internalTLS.alpnProtocol || 'http/1.1'], - ...clientCertificatesToTLSOptions(this.socksProxy.clientCertificates, `https://${this.host}:${this.port}`), - }; - if (!net.isIP(this.host)) - tlsOptions.servername = this.host; - if (process.env.PWTEST_UNSUPPORTED_CUSTOM_CA && isUnderTest()) - tlsOptions.ca = [fs.readFileSync(process.env.PWTEST_UNSUPPORTED_CUSTOM_CA)]; - const targetTLS = tls.connect(tlsOptions); - targetTLS.on('secureConnect', () => { - internalTLS.pipe(targetTLS); - targetTLS.pipe(internalTLS); - }); - - // Handle close and errors + let targetTLS: tls.TLSSocket | undefined = undefined; const closeBothSockets = () => { internalTLS.end(); - targetTLS.end(); + targetTLS?.end(); }; - internalTLS.on('end', () => closeBothSockets()); - targetTLS.on('end', () => closeBothSockets()); - - internalTLS.on('error', () => closeBothSockets()); - targetTLS.on('error', error => { - debugLogger.log('client-certificates', `error when connecting to target: ${error.message}`); - const responseBody = 'Playwright client-certificate error: ' + error.message; + const handleError = (error: Error) => { + error = rewriteOpenSSLErrorIfNeeded(error); + debugLogger.log('client-certificates', `error when connecting to target: ${error.message.replaceAll('\n', ' ')}`); + const responseBody = escapeHTML('Playwright client-certificate error: ' + error.message) + .replaceAll('\n', '
'); if (internalTLS?.alpnProtocol === 'h2') { // This method is available only in Node.js 20+ if ('performServerHandshake' in http2) { @@ -206,7 +186,38 @@ class SocksProxyConnection { ].join('\r\n')); closeBothSockets(); } + }; + + let secureContext: tls.SecureContext; + try { + secureContext = tls.createSecureContext(clientCertificatesToTLSOptions(this.socksProxy.clientCertificates, new URL(`https://${this.host}:${this.port}`).origin)); + } catch (error) { + handleError(error); + return; + } + + const tlsOptions: tls.ConnectionOptions = { + socket: this.target, + host: this.host, + port: this.port, + rejectUnauthorized: !this.socksProxy.ignoreHTTPSErrors, + ALPNProtocols: [internalTLS.alpnProtocol || 'http/1.1'], + servername: !net.isIP(this.host) ? this.host : undefined, + secureContext, + }; + + targetTLS = tls.connect(tlsOptions); + + targetTLS.on('secureConnect', () => { + internalTLS.pipe(targetTLS); + targetTLS.pipe(internalTLS); }); + + internalTLS.on('end', () => closeBothSockets()); + targetTLS.on('end', () => closeBothSockets()); + + internalTLS.on('error', () => closeBothSockets()); + targetTLS.on('error', handleError); }); }); } @@ -288,3 +299,14 @@ export function clientCertificatesToTLSOptions( function rewriteToLocalhostIfNeeded(host: string): string { return host === 'local.playwright' ? 'localhost' : host; } + +export function rewriteOpenSSLErrorIfNeeded(error: Error): Error { + if (error.message !== 'unsupported') + return error; + return rewriteErrorMessage(error, [ + 'Unsupported TLS certificate.', + 'Most likely, the security algorithm of the given certificate was deprecated by OpenSSL.', + 'For more details, see https://github.com/openssl/openssl/blob/master/README-PROVIDERS.md#the-legacy-provider', + 'You could probably modernize the certificate by following the steps at https://github.com/nodejs/node/issues/40672#issuecomment-1243648223', + ].join('\n')); +} \ No newline at end of file diff --git a/packages/playwright-core/src/server/webkit/wkBrowser.ts b/packages/playwright-core/src/server/webkit/wkBrowser.ts index c94486a5c1..6ebdeed078 100644 --- a/packages/playwright-core/src/server/webkit/wkBrowser.ts +++ b/packages/playwright-core/src/server/webkit/wkBrowser.ts @@ -22,7 +22,7 @@ import type { RegisteredListener } from '../../utils/eventsHelper'; import { assert } from '../../utils'; import { eventsHelper } from '../../utils/eventsHelper'; import * as network from '../network'; -import type { InitScript, Page, PageBinding, PageDelegate } from '../page'; +import type { InitScript, Page, PageDelegate } from '../page'; import type { ConnectionTransport } from '../transport'; import type * as types from '../types'; import type * as channels from '@protocol/channels'; @@ -320,21 +320,11 @@ export class WKBrowserContext extends BrowserContext { await (page._delegate as WKPage)._updateBootstrapScript(); } - async doRemoveInitScripts() { + async doRemoveNonInternalInitScripts() { for (const page of this.pages()) await (page._delegate as WKPage)._updateBootstrapScript(); } - async doExposeBinding(binding: PageBinding) { - for (const page of this.pages()) - await (page._delegate as WKPage).exposeBinding(binding); - } - - async doRemoveExposedBindings() { - for (const page of this.pages()) - await (page._delegate as WKPage).removeExposedBindings(); - } - async doUpdateRequestInterception(): Promise { for (const page of this.pages()) await (page._delegate as WKPage).updateRequestInterception(); diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index d16d013973..26ffd2dbab 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -30,7 +30,7 @@ import { eventsHelper } from '../../utils/eventsHelper'; import { helper } from '../helper'; import type { JSHandle } from '../javascript'; import * as network from '../network'; -import type { InitScript, PageBinding, PageDelegate } from '../page'; +import { type InitScript, PageBinding, type PageDelegate } from '../page'; import { Page } from '../page'; import type { Progress } from '../progress'; import type * as types from '../types'; @@ -179,6 +179,7 @@ export class WKPage implements PageDelegate { const promises: Promise[] = [ // Resource tree should be received before first execution context. session.send('Runtime.enable'), + session.send('Runtime.addBinding', { name: PageBinding.kPlaywrightBinding }), session.send('Page.createUserWorld', { name: UTILITY_WORLD_NAME }).catch(_ => {}), // Worlds are per-process session.send('Console.enable'), session.send('Network.enable'), @@ -200,8 +201,6 @@ export class WKPage implements PageDelegate { const emulatedMedia = this._page.emulatedMedia(); if (emulatedMedia.media || emulatedMedia.colorScheme || emulatedMedia.reducedMotion || emulatedMedia.forcedColors) promises.push(WKPage._setEmulateMedia(session, emulatedMedia.media, emulatedMedia.colorScheme, emulatedMedia.reducedMotion, emulatedMedia.forcedColors)); - for (const binding of this._page.allBindings()) - promises.push(session.send('Runtime.addBinding', { name: binding.name })); const bootstrapScript = this._calculateBootstrapScript(); if (bootstrapScript.length) promises.push(session.send('Page.setBootstrapScript', { source: bootstrapScript })); @@ -768,21 +767,11 @@ export class WKPage implements PageDelegate { }); } - async exposeBinding(binding: PageBinding): Promise { - this._session.send('Runtime.addBinding', { name: binding.name }); - await this._updateBootstrapScript(); - await Promise.all(this._page.frames().map(frame => frame.evaluateExpression(binding.source).catch(e => {}))); - } - - async removeExposedBindings(): Promise { - await this._updateBootstrapScript(); - } - async addInitScript(initScript: InitScript): Promise { await this._updateBootstrapScript(); } - async removeInitScripts() { + async removeNonInternalInitScripts() { await this._updateBootstrapScript(); } @@ -795,11 +784,7 @@ export class WKPage implements PageDelegate { } scripts.push('if (!window.safari) window.safari = { pushNotification: { toString() { return "[object SafariRemoteNotification]"; } } };'); scripts.push('if (!window.GestureEvent) window.GestureEvent = function GestureEvent() {};'); - - for (const binding of this._page.allBindings()) - scripts.push(binding.source); - scripts.push(...this._browserContext.initScripts.map(s => s.source)); - scripts.push(...this._page.initScripts.map(s => s.source)); + scripts.push(...this._page.allInitScripts().map(script => script.source)); return scripts.join(';\n'); } diff --git a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts index df213a1085..23c947cc49 100644 --- a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts +++ b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts @@ -132,3 +132,11 @@ export function escapeRegExp(s: string) { // From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string } + +const escaped = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' }; +export function escapeHTMLAttribute(s: string): string { + return s.replace(/[&<>"']/ug, char => (escaped as any)[char]); +} +export function escapeHTML(s: string): string { + return s.replace(/[&<]/ug, char => (escaped as any)[char]); +} diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 7026f64555..94babc9e80 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -865,6 +865,19 @@ export interface Page { * @param options */ exposeBinding(name: string, playwrightBinding: (source: BindingSource, ...args: any[]) => any, options?: { handle?: boolean }): Promise; + + /** + * Removes all the listeners of the given type if the type is given. Otherwise removes all the listeners. + * @param type + * @param options + */ + removeAllListeners(type?: string): this; + /** + * Removes all the listeners of the given type if the type is given. Otherwise removes all the listeners. + * @param type + * @param options + */ + removeAllListeners(type: string | undefined, options: { behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; /** * Emitted when the page closes. */ @@ -3760,7 +3773,8 @@ export interface Page { /** * When set to `minimal`, only record information necessary for routing from HAR. This omits sizes, timing, page, - * cookies, security and other types of HAR information that are not used when replaying from HAR. Defaults to `full`. + * cookies, security and other types of HAR information that are not used when replaying from HAR. Defaults to + * `minimal`. */ updateMode?: "full"|"minimal"; @@ -7660,6 +7674,19 @@ export interface BrowserContext { * @param arg Optional argument to pass to `script` (only supported when passing a function). */ addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg): Promise; + + /** + * Removes all the listeners of the given type if the type is given. Otherwise removes all the listeners. + * @param type + * @param options + */ + removeAllListeners(type?: string): this; + /** + * Removes all the listeners of the given type if the type is given. Otherwise removes all the listeners. + * @param type + * @param options + */ + removeAllListeners(type: string | undefined, options: { behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; /** * **NOTE** Only works with Chromium browser's persistent context. * @@ -8914,6 +8941,665 @@ export interface BrowserContext { [Symbol.asyncDispose](): Promise; } +/** + * - extends: [EventEmitter] + * + * A Browser is created via + * [browserType.launch([options])](https://playwright.dev/docs/api/class-browsertype#browser-type-launch). An example + * of using a {@link Browser} to create a {@link Page}: + * + * ```js + * const { firefox } = require('playwright'); // Or 'chromium' or 'webkit'. + * + * (async () => { + * const browser = await firefox.launch(); + * const page = await browser.newPage(); + * await page.goto('https://example.com'); + * await browser.close(); + * })(); + * ``` + * + */ +export interface Browser { + /** + * Removes all the listeners of the given type if the type is given. Otherwise removes all the listeners. + * @param type + * @param options + */ + removeAllListeners(type?: string): this; + /** + * Removes all the listeners of the given type if the type is given. Otherwise removes all the listeners. + * @param type + * @param options + */ + removeAllListeners(type: string | undefined, options: { behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; + /** + * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the + * following: + * - Browser application is closed or crashed. + * - The [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close) method was called. + */ + on(event: 'disconnected', listener: (browser: Browser) => any): this; + + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'disconnected', listener: (browser: Browser) => any): this; + + /** + * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the + * following: + * - Browser application is closed or crashed. + * - The [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close) method was called. + */ + addListener(event: 'disconnected', listener: (browser: Browser) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'disconnected', listener: (browser: Browser) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'disconnected', listener: (browser: Browser) => any): this; + + /** + * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the + * following: + * - Browser application is closed or crashed. + * - The [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close) method was called. + */ + prependListener(event: 'disconnected', listener: (browser: Browser) => any): this; + + /** + * Get the browser type (chromium, firefox or webkit) that the browser belongs to. + */ + browserType(): BrowserType; + + /** + * In case this browser is obtained using + * [browserType.launch([options])](https://playwright.dev/docs/api/class-browsertype#browser-type-launch), closes the + * browser and all of its pages (if any were opened). + * + * In case this browser is connected to, clears all created contexts belonging to this browser and disconnects from + * the browser server. + * + * **NOTE** This is similar to force quitting the browser. Therefore, you should call + * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) on + * any {@link BrowserContext}'s you explicitly created earlier with + * [browser.newContext([options])](https://playwright.dev/docs/api/class-browser#browser-new-context) **before** + * calling [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close). + * + * The {@link Browser} object itself is considered to be disposed and cannot be used anymore. + * @param options + */ + close(options?: { + /** + * The reason to be reported to the operations interrupted by the browser closure. + */ + reason?: string; + }): Promise; + + /** + * Returns an array of all open browser contexts. In a newly created browser, this will return zero browser contexts. + * + * **Usage** + * + * ```js + * const browser = await pw.webkit.launch(); + * console.log(browser.contexts().length); // prints `0` + * + * const context = await browser.newContext(); + * console.log(browser.contexts().length); // prints `1` + * ``` + * + */ + contexts(): Array; + + /** + * Indicates that the browser is connected. + */ + isConnected(): boolean; + + /** + * **NOTE** CDP Sessions are only supported on Chromium-based browsers. + * + * Returns the newly created browser session. + */ + newBrowserCDPSession(): Promise; + + /** + * Creates a new browser context. It won't share cookies/cache with other browser contexts. + * + * **NOTE** If directly using this method to create {@link BrowserContext}s, it is best practice to explicitly close + * the returned context via + * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) when + * your code is done with the {@link BrowserContext}, and before calling + * [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close). This will ensure the + * `context` is closed gracefully and any artifacts—like HARs and videos—are fully flushed and saved. + * + * **Usage** + * + * ```js + * (async () => { + * const browser = await playwright.firefox.launch(); // Or 'chromium' or 'webkit'. + * // Create a new incognito browser context. + * const context = await browser.newContext(); + * // Create a new page in a pristine context. + * const page = await context.newPage(); + * await page.goto('https://example.com'); + * + * // Gracefully close up everything + * await context.close(); + * await browser.close(); + * })(); + * ``` + * + * @param options + */ + newContext(options?: BrowserContextOptions): Promise; + + /** + * Creates a new page in a new browser context. Closing this page will close the context as well. + * + * This is a convenience API that should only be used for the single-page scenarios and short snippets. Production + * code and testing frameworks should explicitly create + * [browser.newContext([options])](https://playwright.dev/docs/api/class-browser#browser-new-context) followed by the + * [browserContext.newPage()](https://playwright.dev/docs/api/class-browsercontext#browser-context-new-page) to + * control their exact life times. + * @param options + */ + newPage(options?: { + /** + * Whether to automatically download all the attachments. Defaults to `true` where all the downloads are accepted. + */ + acceptDownloads?: 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), + * [page.waitForURL(url[, options])](https://playwright.dev/docs/api/class-page#page-wait-for-url), + * [page.waitForRequest(urlOrPredicate[, options])](https://playwright.dev/docs/api/class-page#page-wait-for-request), + * or + * [page.waitForResponse(urlOrPredicate[, options])](https://playwright.dev/docs/api/class-page#page-wait-for-response) + * 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. + * Unset by default. Examples: + * - baseURL: `http://localhost:3000` and navigating to `/bar.html` results in `http://localhost:3000/bar.html` + * - baseURL: `http://localhost:3000/foo/` and navigating to `./bar.html` results in + * `http://localhost:3000/foo/bar.html` + * - baseURL: `http://localhost:3000/foo` (without trailing slash) and navigating to `./bar.html` results in + * `http://localhost:3000/bar.html` + */ + baseURL?: string; + + /** + * Toggles bypassing page's Content-Security-Policy. Defaults to `false`. + */ + bypassCSP?: boolean; + + /** + * TLS Client Authentication allows the server to request a client certificate and verify it. + * + * **Details** + * + * An array of client certificates to be used. Each certificate object must have both `certPath` and `keyPath` or a + * single `pfxPath` to load the client certificate. Optionally, `passphrase` property should be provided if the + * certficiate is encrypted. The `origin` property should be provided with an exact match to the request origin that + * the certificate is valid for. + * + * **NOTE** Using Client Certificates in combination with Proxy Servers is not supported. + * + * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it + * work by replacing `localhost` with `local.playwright`. + */ + clientCertificates?: Array<{ + /** + * Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port. + */ + origin: string; + + /** + * Path to the file with the certificate in PEM format. + */ + certPath?: string; + + /** + * Path to the file with the private key in PEM format. + */ + keyPath?: string; + + /** + * Path to the PFX or PKCS12 encoded private key and certificate chain. + */ + pfxPath?: string; + + /** + * Passphrase for the private key (PEM or PFX). + */ + passphrase?: string; + }>; + + /** + * Emulates `'prefers-colors-scheme'` media feature, supported values are `'light'`, `'dark'`, `'no-preference'`. See + * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#page-emulate-media) for more details. + * Passing `null` resets emulation to system defaults. Defaults to `'light'`. + */ + colorScheme?: null|"light"|"dark"|"no-preference"; + + /** + * Specify device scale factor (can be thought of as dpr). Defaults to `1`. Learn more about + * [emulating devices with device scale factor](https://playwright.dev/docs/emulation#devices). + */ + deviceScaleFactor?: number; + + /** + * An object containing additional HTTP headers to be sent with every request. Defaults to none. + */ + extraHTTPHeaders?: { [key: string]: string; }; + + /** + * Emulates `'forced-colors'` media feature, supported values are `'active'`, `'none'`. See + * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#page-emulate-media) for more details. + * Passing `null` resets emulation to system defaults. Defaults to `'none'`. + */ + forcedColors?: null|"active"|"none"; + + geolocation?: { + /** + * Latitude between -90 and 90. + */ + latitude: number; + + /** + * Longitude between -180 and 180. + */ + longitude: number; + + /** + * Non-negative accuracy value. Defaults to `0`. + */ + accuracy?: number; + }; + + /** + * Specifies if viewport supports touch events. Defaults to false. Learn more about + * [mobile emulation](https://playwright.dev/docs/emulation#devices). + */ + hasTouch?: 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. + */ + httpCredentials?: { + username: string; + + password: string; + + /** + * Restrain sending http credentials on specific origin (scheme://host:port). + */ + origin?: string; + + /** + * This option only applies to the requests sent from corresponding {@link APIRequestContext} and does not affect + * requests sent from the browser. `'always'` - `Authorization` header with basic authentication credentials will be + * sent with the each API request. `'unauthorized` - the credentials are only sent when 401 (Unauthorized) response + * with `WWW-Authenticate` header is received. Defaults to `'unauthorized'`. + */ + send?: "unauthorized"|"always"; + }; + + /** + * Whether to ignore HTTPS errors when sending network requests. Defaults to `false`. + */ + ignoreHTTPSErrors?: boolean; + + /** + * Whether the `meta viewport` tag is taken into account and touch events are enabled. isMobile is a part of device, + * so you don't actually need to set it manually. Defaults to `false` and is not supported in Firefox. Learn more + * about [mobile emulation](https://playwright.dev/docs/emulation#ismobile). + */ + isMobile?: boolean; + + /** + * Whether or not to enable JavaScript in the context. Defaults to `true`. Learn more about + * [disabling JavaScript](https://playwright.dev/docs/emulation#javascript-enabled). + */ + javaScriptEnabled?: boolean; + + /** + * Specify user locale, for example `en-GB`, `de-DE`, etc. Locale will affect `navigator.language` value, + * `Accept-Language` request header value as well as number and date formatting rules. Defaults to the system default + * locale. Learn more about emulation in our [emulation guide](https://playwright.dev/docs/emulation#locale--timezone). + */ + locale?: string; + + /** + * Logger sink for Playwright logging. + */ + logger?: Logger; + + /** + * Whether to emulate network being offline. Defaults to `false`. Learn more about + * [network emulation](https://playwright.dev/docs/emulation#offline). + */ + offline?: boolean; + + /** + * A list of permissions to grant to all pages in this context. See + * [browserContext.grantPermissions(permissions[, options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-grant-permissions) + * for more details. Defaults to none. + */ + permissions?: Array; + + /** + * Network proxy settings to use with this context. Defaults to none. + * + * **NOTE** For Chromium on Windows the browser needs to be launched with the global proxy for this option to work. If + * all contexts override the proxy, global proxy will be never used and can be any string, for example `launch({ + * proxy: { server: 'http://per-context' } })`. + */ + proxy?: { + /** + * Proxy to be used for all requests. HTTP and SOCKS proxies are supported, for example `http://myproxy.com:3128` or + * `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP proxy. + */ + server: string; + + /** + * Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`. + */ + bypass?: string; + + /** + * Optional username to use if HTTP proxy requires authentication. + */ + username?: string; + + /** + * Optional password to use if HTTP proxy requires authentication. + */ + password?: string; + }; + + /** + * Enables [HAR](http://www.softwareishard.com/blog/har-12-spec) recording for all pages into `recordHar.path` file. + * If not specified, the HAR is not recorded. Make sure to await + * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) for + * the HAR to be saved. + */ + recordHar?: { + /** + * Optional setting to control whether to omit request content from the HAR. Defaults to `false`. Deprecated, use + * `content` policy instead. + */ + omitContent?: boolean; + + /** + * Optional setting to control resource content management. If `omit` is specified, content is not persisted. If + * `attach` is specified, resources are persisted as separate files or entries in the ZIP archive. If `embed` is + * specified, content is stored inline the HAR file as per HAR specification. Defaults to `attach` for `.zip` output + * files and to `embed` for all other file extensions. + */ + content?: "omit"|"embed"|"attach"; + + /** + * Path on the filesystem to write the HAR file to. If the file name ends with `.zip`, `content: 'attach'` is used by + * default. + */ + path: string; + + /** + * When set to `minimal`, only record information necessary for routing from HAR. This omits sizes, timing, page, + * cookies, security and other types of HAR information that are not used when replaying from HAR. Defaults to `full`. + */ + mode?: "full"|"minimal"; + + /** + * A glob or regex pattern to filter requests that are stored in the HAR. When a `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. Defaults to none. + */ + urlFilter?: string|RegExp; + }; + + /** + * Enables video recording for all pages into `recordVideo.dir` directory. If not specified videos are not recorded. + * Make sure to await + * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) for + * videos to be saved. + */ + recordVideo?: { + /** + * Path to the directory to put videos into. + */ + dir: string; + + /** + * Optional dimensions of the recorded videos. If not specified the size will be equal to `viewport` scaled down to + * fit into 800x800. If `viewport` is not configured explicitly the video size defaults to 800x450. Actual picture of + * each page will be scaled down if necessary to fit the specified size. + */ + size?: { + /** + * Video frame width. + */ + width: number; + + /** + * Video frame height. + */ + height: number; + }; + }; + + /** + * Emulates `'prefers-reduced-motion'` media feature, supported values are `'reduce'`, `'no-preference'`. See + * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#page-emulate-media) for more details. + * Passing `null` resets emulation to system defaults. Defaults to `'no-preference'`. + */ + reducedMotion?: null|"reduce"|"no-preference"; + + /** + * Emulates consistent window screen size available inside web page via `window.screen`. Is only used when the + * `viewport` is set. + */ + screen?: { + /** + * page width in pixels. + */ + width: number; + + /** + * page height in pixels. + */ + height: number; + }; + + /** + * Whether to allow sites to register Service workers. Defaults to `'allow'`. + * - `'allow'`: [Service Workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) can be + * registered. + * - `'block'`: Playwright will block all registration of Service Workers. + */ + serviceWorkers?: "allow"|"block"; + + /** + * Learn more about [storage state and auth](https://playwright.dev/docs/auth). + * + * Populates context with given storage state. This option can be used to initialize context with logged-in + * information obtained via + * [browserContext.storageState([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-storage-state). + */ + storageState?: string|{ + /** + * Cookies to set for context + */ + cookies: Array<{ + name: string; + + value: string; + + /** + * Domain and path are required. For the cookie to apply to all subdomains as well, prefix domain with a dot, like + * this: ".example.com" + */ + domain: string; + + /** + * Domain and path are required + */ + path: string; + + /** + * Unix time in seconds. + */ + expires: number; + + httpOnly: boolean; + + secure: boolean; + + /** + * sameSite flag + */ + sameSite: "Strict"|"Lax"|"None"; + }>; + + /** + * localStorage to set for context + */ + origins: Array<{ + origin: string; + + localStorage: Array<{ + name: string; + + value: string; + }>; + }>; + }; + + /** + * If set to true, enables strict selectors mode for this context. In the strict selectors mode all operations on + * selectors that imply single target DOM element will throw when more than one element matches the selector. This + * option does not affect any Locator APIs (Locators are always strict). Defaults to `false`. See {@link Locator} to + * learn more about the strict mode. + */ + strictSelectors?: boolean; + + /** + * Changes the timezone of the context. See + * [ICU's metaZones.txt](https://cs.chromium.org/chromium/src/third_party/icu/source/data/misc/metaZones.txt?rcl=faee8bc70570192d82d2978a71e2a615788597d1) + * for a list of supported timezone IDs. Defaults to the system timezone. + */ + timezoneId?: string; + + /** + * Specific user agent to use in this context. + */ + userAgent?: string; + + /** + * @deprecated Use `recordVideo` instead. + */ + videoSize?: { + /** + * Video frame width. + */ + width: number; + + /** + * Video frame height. + */ + height: number; + }; + + /** + * @deprecated Use `recordVideo` instead. + */ + videosPath?: string; + + /** + * Emulates consistent viewport for each page. Defaults to an 1280x720 viewport. Use `null` to disable the consistent + * viewport emulation. Learn more about [viewport emulation](https://playwright.dev/docs/emulation#viewport). + * + * **NOTE** The `null` value opts out from the default presets, makes viewport depend on the host window size defined + * by the operating system. It makes the execution of the tests non-deterministic. + */ + viewport?: null|{ + /** + * page width in pixels. + */ + width: number; + + /** + * page height in pixels. + */ + height: number; + }; + }): Promise; + + /** + * **NOTE** This API controls + * [Chromium Tracing](https://www.chromium.org/developers/how-tos/trace-event-profiling-tool) which is a low-level + * chromium-specific debugging tool. API to control [Playwright Tracing](https://playwright.dev/docs/trace-viewer) could be found + * [here](https://playwright.dev/docs/api/class-tracing). + * + * You can use + * [browser.startTracing([page, options])](https://playwright.dev/docs/api/class-browser#browser-start-tracing) and + * [browser.stopTracing()](https://playwright.dev/docs/api/class-browser#browser-stop-tracing) to create a trace file + * that can be opened in Chrome DevTools performance panel. + * + * **Usage** + * + * ```js + * await browser.startTracing(page, { path: 'trace.json' }); + * await page.goto('https://www.google.com'); + * await browser.stopTracing(); + * ``` + * + * @param page Optional, if specified, tracing includes screenshots of the given page. + * @param options + */ + startTracing(page?: Page, options?: { + /** + * specify custom categories to use instead of default. + */ + categories?: Array; + + /** + * A path to write the trace file to. + */ + path?: string; + + /** + * captures screenshots in the trace. + */ + screenshots?: boolean; + }): Promise; + + /** + * **NOTE** This API controls + * [Chromium Tracing](https://www.chromium.org/developers/how-tos/trace-event-profiling-tool) which is a low-level + * chromium-specific debugging tool. API to control [Playwright Tracing](https://playwright.dev/docs/trace-viewer) could be found + * [here](https://playwright.dev/docs/api/class-tracing). + * + * Returns the buffer with trace data. + */ + stopTracing(): Promise; + + /** + * Returns the browser version. + */ + version(): string; + + [Symbol.asyncDispose](): Promise; +} + /** * The Worker class represents a [WebWorker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API). * `worker` event is emitted on the page object to signal a worker creation. `close` event is emitted on the worker @@ -16589,653 +17275,6 @@ export interface APIResponse { [Symbol.asyncDispose](): Promise; } -/** - * - extends: [EventEmitter] - * - * A Browser is created via - * [browserType.launch([options])](https://playwright.dev/docs/api/class-browsertype#browser-type-launch). An example - * of using a {@link Browser} to create a {@link Page}: - * - * ```js - * const { firefox } = require('playwright'); // Or 'chromium' or 'webkit'. - * - * (async () => { - * const browser = await firefox.launch(); - * const page = await browser.newPage(); - * await page.goto('https://example.com'); - * await browser.close(); - * })(); - * ``` - * - */ -export interface Browser extends EventEmitter { - /** - * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the - * following: - * - Browser application is closed or crashed. - * - The [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close) method was called. - */ - on(event: 'disconnected', listener: (browser: Browser) => any): this; - - /** - * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. - */ - once(event: 'disconnected', listener: (browser: Browser) => any): this; - - /** - * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the - * following: - * - Browser application is closed or crashed. - * - The [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close) method was called. - */ - addListener(event: 'disconnected', listener: (browser: Browser) => any): this; - - /** - * Removes an event listener added by `on` or `addListener`. - */ - removeListener(event: 'disconnected', listener: (browser: Browser) => any): this; - - /** - * Removes an event listener added by `on` or `addListener`. - */ - off(event: 'disconnected', listener: (browser: Browser) => any): this; - - /** - * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the - * following: - * - Browser application is closed or crashed. - * - The [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close) method was called. - */ - prependListener(event: 'disconnected', listener: (browser: Browser) => any): this; - - /** - * Get the browser type (chromium, firefox or webkit) that the browser belongs to. - */ - browserType(): BrowserType; - - /** - * In case this browser is obtained using - * [browserType.launch([options])](https://playwright.dev/docs/api/class-browsertype#browser-type-launch), closes the - * browser and all of its pages (if any were opened). - * - * In case this browser is connected to, clears all created contexts belonging to this browser and disconnects from - * the browser server. - * - * **NOTE** This is similar to force quitting the browser. Therefore, you should call - * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) on - * any {@link BrowserContext}'s you explicitly created earlier with - * [browser.newContext([options])](https://playwright.dev/docs/api/class-browser#browser-new-context) **before** - * calling [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close). - * - * The {@link Browser} object itself is considered to be disposed and cannot be used anymore. - * @param options - */ - close(options?: { - /** - * The reason to be reported to the operations interrupted by the browser closure. - */ - reason?: string; - }): Promise; - - /** - * Returns an array of all open browser contexts. In a newly created browser, this will return zero browser contexts. - * - * **Usage** - * - * ```js - * const browser = await pw.webkit.launch(); - * console.log(browser.contexts().length); // prints `0` - * - * const context = await browser.newContext(); - * console.log(browser.contexts().length); // prints `1` - * ``` - * - */ - contexts(): Array; - - /** - * Indicates that the browser is connected. - */ - isConnected(): boolean; - - /** - * **NOTE** CDP Sessions are only supported on Chromium-based browsers. - * - * Returns the newly created browser session. - */ - newBrowserCDPSession(): Promise; - - /** - * Creates a new browser context. It won't share cookies/cache with other browser contexts. - * - * **NOTE** If directly using this method to create {@link BrowserContext}s, it is best practice to explicitly close - * the returned context via - * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) when - * your code is done with the {@link BrowserContext}, and before calling - * [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close). This will ensure the - * `context` is closed gracefully and any artifacts—like HARs and videos—are fully flushed and saved. - * - * **Usage** - * - * ```js - * (async () => { - * const browser = await playwright.firefox.launch(); // Or 'chromium' or 'webkit'. - * // Create a new incognito browser context. - * const context = await browser.newContext(); - * // Create a new page in a pristine context. - * const page = await context.newPage(); - * await page.goto('https://example.com'); - * - * // Gracefully close up everything - * await context.close(); - * await browser.close(); - * })(); - * ``` - * - * @param options - */ - newContext(options?: BrowserContextOptions): Promise; - - /** - * Creates a new page in a new browser context. Closing this page will close the context as well. - * - * This is a convenience API that should only be used for the single-page scenarios and short snippets. Production - * code and testing frameworks should explicitly create - * [browser.newContext([options])](https://playwright.dev/docs/api/class-browser#browser-new-context) followed by the - * [browserContext.newPage()](https://playwright.dev/docs/api/class-browsercontext#browser-context-new-page) to - * control their exact life times. - * @param options - */ - newPage(options?: { - /** - * Whether to automatically download all the attachments. Defaults to `true` where all the downloads are accepted. - */ - acceptDownloads?: 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), - * [page.waitForURL(url[, options])](https://playwright.dev/docs/api/class-page#page-wait-for-url), - * [page.waitForRequest(urlOrPredicate[, options])](https://playwright.dev/docs/api/class-page#page-wait-for-request), - * or - * [page.waitForResponse(urlOrPredicate[, options])](https://playwright.dev/docs/api/class-page#page-wait-for-response) - * 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. - * Unset by default. Examples: - * - baseURL: `http://localhost:3000` and navigating to `/bar.html` results in `http://localhost:3000/bar.html` - * - baseURL: `http://localhost:3000/foo/` and navigating to `./bar.html` results in - * `http://localhost:3000/foo/bar.html` - * - baseURL: `http://localhost:3000/foo` (without trailing slash) and navigating to `./bar.html` results in - * `http://localhost:3000/bar.html` - */ - baseURL?: string; - - /** - * Toggles bypassing page's Content-Security-Policy. Defaults to `false`. - */ - bypassCSP?: boolean; - - /** - * TLS Client Authentication allows the server to request a client certificate and verify it. - * - * **Details** - * - * An array of client certificates to be used. Each certificate object must have both `certPath` and `keyPath` or a - * single `pfxPath` to load the client certificate. Optionally, `passphrase` property should be provided if the - * certficiate is encrypted. The `origin` property should be provided with an exact match to the request origin that - * the certificate is valid for. - * - * **NOTE** Using Client Certificates in combination with Proxy Servers is not supported. - * - * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it - * work by replacing `localhost` with `local.playwright`. - */ - clientCertificates?: Array<{ - /** - * Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port. - */ - origin: string; - - /** - * Path to the file with the certificate in PEM format. - */ - certPath?: string; - - /** - * Path to the file with the private key in PEM format. - */ - keyPath?: string; - - /** - * Path to the PFX or PKCS12 encoded private key and certificate chain. - */ - pfxPath?: string; - - /** - * Passphrase for the private key (PEM or PFX). - */ - passphrase?: string; - }>; - - /** - * Emulates `'prefers-colors-scheme'` media feature, supported values are `'light'`, `'dark'`, `'no-preference'`. See - * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#page-emulate-media) for more details. - * Passing `null` resets emulation to system defaults. Defaults to `'light'`. - */ - colorScheme?: null|"light"|"dark"|"no-preference"; - - /** - * Specify device scale factor (can be thought of as dpr). Defaults to `1`. Learn more about - * [emulating devices with device scale factor](https://playwright.dev/docs/emulation#devices). - */ - deviceScaleFactor?: number; - - /** - * An object containing additional HTTP headers to be sent with every request. Defaults to none. - */ - extraHTTPHeaders?: { [key: string]: string; }; - - /** - * Emulates `'forced-colors'` media feature, supported values are `'active'`, `'none'`. See - * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#page-emulate-media) for more details. - * Passing `null` resets emulation to system defaults. Defaults to `'none'`. - */ - forcedColors?: null|"active"|"none"; - - geolocation?: { - /** - * Latitude between -90 and 90. - */ - latitude: number; - - /** - * Longitude between -180 and 180. - */ - longitude: number; - - /** - * Non-negative accuracy value. Defaults to `0`. - */ - accuracy?: number; - }; - - /** - * Specifies if viewport supports touch events. Defaults to false. Learn more about - * [mobile emulation](https://playwright.dev/docs/emulation#devices). - */ - hasTouch?: 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. - */ - httpCredentials?: { - username: string; - - password: string; - - /** - * Restrain sending http credentials on specific origin (scheme://host:port). - */ - origin?: string; - - /** - * This option only applies to the requests sent from corresponding {@link APIRequestContext} and does not affect - * requests sent from the browser. `'always'` - `Authorization` header with basic authentication credentials will be - * sent with the each API request. `'unauthorized` - the credentials are only sent when 401 (Unauthorized) response - * with `WWW-Authenticate` header is received. Defaults to `'unauthorized'`. - */ - send?: "unauthorized"|"always"; - }; - - /** - * Whether to ignore HTTPS errors when sending network requests. Defaults to `false`. - */ - ignoreHTTPSErrors?: boolean; - - /** - * Whether the `meta viewport` tag is taken into account and touch events are enabled. isMobile is a part of device, - * so you don't actually need to set it manually. Defaults to `false` and is not supported in Firefox. Learn more - * about [mobile emulation](https://playwright.dev/docs/emulation#ismobile). - */ - isMobile?: boolean; - - /** - * Whether or not to enable JavaScript in the context. Defaults to `true`. Learn more about - * [disabling JavaScript](https://playwright.dev/docs/emulation#javascript-enabled). - */ - javaScriptEnabled?: boolean; - - /** - * Specify user locale, for example `en-GB`, `de-DE`, etc. Locale will affect `navigator.language` value, - * `Accept-Language` request header value as well as number and date formatting rules. Defaults to the system default - * locale. Learn more about emulation in our [emulation guide](https://playwright.dev/docs/emulation#locale--timezone). - */ - locale?: string; - - /** - * Logger sink for Playwright logging. - */ - logger?: Logger; - - /** - * Whether to emulate network being offline. Defaults to `false`. Learn more about - * [network emulation](https://playwright.dev/docs/emulation#offline). - */ - offline?: boolean; - - /** - * A list of permissions to grant to all pages in this context. See - * [browserContext.grantPermissions(permissions[, options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-grant-permissions) - * for more details. Defaults to none. - */ - permissions?: Array; - - /** - * Network proxy settings to use with this context. Defaults to none. - * - * **NOTE** For Chromium on Windows the browser needs to be launched with the global proxy for this option to work. If - * all contexts override the proxy, global proxy will be never used and can be any string, for example `launch({ - * proxy: { server: 'http://per-context' } })`. - */ - proxy?: { - /** - * Proxy to be used for all requests. HTTP and SOCKS proxies are supported, for example `http://myproxy.com:3128` or - * `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP proxy. - */ - server: string; - - /** - * Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`. - */ - bypass?: string; - - /** - * Optional username to use if HTTP proxy requires authentication. - */ - username?: string; - - /** - * Optional password to use if HTTP proxy requires authentication. - */ - password?: string; - }; - - /** - * Enables [HAR](http://www.softwareishard.com/blog/har-12-spec) recording for all pages into `recordHar.path` file. - * If not specified, the HAR is not recorded. Make sure to await - * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) for - * the HAR to be saved. - */ - recordHar?: { - /** - * Optional setting to control whether to omit request content from the HAR. Defaults to `false`. Deprecated, use - * `content` policy instead. - */ - omitContent?: boolean; - - /** - * Optional setting to control resource content management. If `omit` is specified, content is not persisted. If - * `attach` is specified, resources are persisted as separate files or entries in the ZIP archive. If `embed` is - * specified, content is stored inline the HAR file as per HAR specification. Defaults to `attach` for `.zip` output - * files and to `embed` for all other file extensions. - */ - content?: "omit"|"embed"|"attach"; - - /** - * Path on the filesystem to write the HAR file to. If the file name ends with `.zip`, `content: 'attach'` is used by - * default. - */ - path: string; - - /** - * When set to `minimal`, only record information necessary for routing from HAR. This omits sizes, timing, page, - * cookies, security and other types of HAR information that are not used when replaying from HAR. Defaults to `full`. - */ - mode?: "full"|"minimal"; - - /** - * A glob or regex pattern to filter requests that are stored in the HAR. When a `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. Defaults to none. - */ - urlFilter?: string|RegExp; - }; - - /** - * Enables video recording for all pages into `recordVideo.dir` directory. If not specified videos are not recorded. - * Make sure to await - * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) for - * videos to be saved. - */ - recordVideo?: { - /** - * Path to the directory to put videos into. - */ - dir: string; - - /** - * Optional dimensions of the recorded videos. If not specified the size will be equal to `viewport` scaled down to - * fit into 800x800. If `viewport` is not configured explicitly the video size defaults to 800x450. Actual picture of - * each page will be scaled down if necessary to fit the specified size. - */ - size?: { - /** - * Video frame width. - */ - width: number; - - /** - * Video frame height. - */ - height: number; - }; - }; - - /** - * Emulates `'prefers-reduced-motion'` media feature, supported values are `'reduce'`, `'no-preference'`. See - * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#page-emulate-media) for more details. - * Passing `null` resets emulation to system defaults. Defaults to `'no-preference'`. - */ - reducedMotion?: null|"reduce"|"no-preference"; - - /** - * Emulates consistent window screen size available inside web page via `window.screen`. Is only used when the - * `viewport` is set. - */ - screen?: { - /** - * page width in pixels. - */ - width: number; - - /** - * page height in pixels. - */ - height: number; - }; - - /** - * Whether to allow sites to register Service workers. Defaults to `'allow'`. - * - `'allow'`: [Service Workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) can be - * registered. - * - `'block'`: Playwright will block all registration of Service Workers. - */ - serviceWorkers?: "allow"|"block"; - - /** - * Learn more about [storage state and auth](https://playwright.dev/docs/auth). - * - * Populates context with given storage state. This option can be used to initialize context with logged-in - * information obtained via - * [browserContext.storageState([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-storage-state). - */ - storageState?: string|{ - /** - * Cookies to set for context - */ - cookies: Array<{ - name: string; - - value: string; - - /** - * Domain and path are required. For the cookie to apply to all subdomains as well, prefix domain with a dot, like - * this: ".example.com" - */ - domain: string; - - /** - * Domain and path are required - */ - path: string; - - /** - * Unix time in seconds. - */ - expires: number; - - httpOnly: boolean; - - secure: boolean; - - /** - * sameSite flag - */ - sameSite: "Strict"|"Lax"|"None"; - }>; - - /** - * localStorage to set for context - */ - origins: Array<{ - origin: string; - - localStorage: Array<{ - name: string; - - value: string; - }>; - }>; - }; - - /** - * If set to true, enables strict selectors mode for this context. In the strict selectors mode all operations on - * selectors that imply single target DOM element will throw when more than one element matches the selector. This - * option does not affect any Locator APIs (Locators are always strict). Defaults to `false`. See {@link Locator} to - * learn more about the strict mode. - */ - strictSelectors?: boolean; - - /** - * Changes the timezone of the context. See - * [ICU's metaZones.txt](https://cs.chromium.org/chromium/src/third_party/icu/source/data/misc/metaZones.txt?rcl=faee8bc70570192d82d2978a71e2a615788597d1) - * for a list of supported timezone IDs. Defaults to the system timezone. - */ - timezoneId?: string; - - /** - * Specific user agent to use in this context. - */ - userAgent?: string; - - /** - * @deprecated Use `recordVideo` instead. - */ - videoSize?: { - /** - * Video frame width. - */ - width: number; - - /** - * Video frame height. - */ - height: number; - }; - - /** - * @deprecated Use `recordVideo` instead. - */ - videosPath?: string; - - /** - * Emulates consistent viewport for each page. Defaults to an 1280x720 viewport. Use `null` to disable the consistent - * viewport emulation. Learn more about [viewport emulation](https://playwright.dev/docs/emulation#viewport). - * - * **NOTE** The `null` value opts out from the default presets, makes viewport depend on the host window size defined - * by the operating system. It makes the execution of the tests non-deterministic. - */ - viewport?: null|{ - /** - * page width in pixels. - */ - width: number; - - /** - * page height in pixels. - */ - height: number; - }; - }): Promise; - - /** - * **NOTE** This API controls - * [Chromium Tracing](https://www.chromium.org/developers/how-tos/trace-event-profiling-tool) which is a low-level - * chromium-specific debugging tool. API to control [Playwright Tracing](https://playwright.dev/docs/trace-viewer) could be found - * [here](https://playwright.dev/docs/api/class-tracing). - * - * You can use - * [browser.startTracing([page, options])](https://playwright.dev/docs/api/class-browser#browser-start-tracing) and - * [browser.stopTracing()](https://playwright.dev/docs/api/class-browser#browser-stop-tracing) to create a trace file - * that can be opened in Chrome DevTools performance panel. - * - * **Usage** - * - * ```js - * await browser.startTracing(page, { path: 'trace.json' }); - * await page.goto('https://www.google.com'); - * await browser.stopTracing(); - * ``` - * - * @param page Optional, if specified, tracing includes screenshots of the given page. - * @param options - */ - startTracing(page?: Page, options?: { - /** - * specify custom categories to use instead of default. - */ - categories?: Array; - - /** - * A path to write the trace file to. - */ - path?: string; - - /** - * captures screenshots in the trace. - */ - screenshots?: boolean; - }): Promise; - - /** - * **NOTE** This API controls - * [Chromium Tracing](https://www.chromium.org/developers/how-tos/trace-event-profiling-tool) which is a low-level - * chromium-specific debugging tool. API to control [Playwright Tracing](https://playwright.dev/docs/trace-viewer) could be found - * [here](https://playwright.dev/docs/api/class-tracing). - * - * Returns the buffer with trace data. - */ - stopTracing(): Promise; - - /** - * Returns the browser version. - */ - version(): string; - - [Symbol.asyncDispose](): Promise; -} - export interface BrowserServer { /** * Emitted when the browser server closes. @@ -19401,7 +19440,7 @@ export interface Route { abort(errorCode?: string): Promise; /** - * Continues route's request with optional overrides. + * Sends route's request to the network with optional overrides. * * **Usage** * @@ -19424,6 +19463,11 @@ export interface Route { * through redirects, use the combination of * [route.fetch([options])](https://playwright.dev/docs/api/class-route#route-fetch) and * [route.fulfill([options])](https://playwright.dev/docs/api/class-route#route-fulfill) instead. + * + * [route.continue([options])](https://playwright.dev/docs/api/class-route#route-continue) will immediately send the + * request to the network, other matching handlers won't be invoked. Use + * [route.fallback([options])](https://playwright.dev/docs/api/class-route#route-fallback) If you want next matching + * handler in the chain to be invoked. * @param options */ continue(options?: { @@ -19449,13 +19493,17 @@ export interface Route { }): Promise; /** + * Continues route's request with optional overrides. The method is similar to + * [route.continue([options])](https://playwright.dev/docs/api/class-route#route-continue) with the difference that + * other matching handlers will be invoked before sending the request. + * + * **Usage** + * * When several routes match the given pattern, they run in the order opposite to their registration. That way the * last registered route can always override all the previous ones. In the example below, request will be handled by * the bottom-most handler first, then it'll fall back to the previous one and in the end will be aborted by the first * registered route. * - * **Usage** - * * ```js * await page.route('**\/*', async route => { * // Runs last. @@ -19511,6 +19559,8 @@ export interface Route { * }); * ``` * + * Use [route.continue([options])](https://playwright.dev/docs/api/class-route#route-continue) to immediately send the + * request to the network, other matching handlers won't be invoked in that case. * @param options */ fallback(options?: { @@ -19570,6 +19620,12 @@ export interface Route { */ maxRedirects?: number; + /** + * Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not + * retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries. + */ + maxRetries?: number; + /** * If set changes the request method (e.g. GET or POST). */ @@ -20196,6 +20252,322 @@ export interface WebSocket { } +interface AccessibilitySnapshotOptions { + /** + * Prune uninteresting nodes from the tree. Defaults to `true`. + */ + interestingOnly?: boolean; + + /** + * The root DOM element for the snapshot. Defaults to the whole page. + */ + root?: ElementHandle; +} + +export interface LaunchOptions { + /** + * **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality. + * + * Additional arguments to pass to the browser instance. The list of Chromium flags can be found + * [here](https://peter.sh/experiments/chromium-command-line-switches/). + */ + args?: Array; + + /** + * Browser distribution channel. Supported values are "chrome", "chrome-beta", "chrome-dev", "chrome-canary", + * "msedge", "msedge-beta", "msedge-dev", "msedge-canary". Read more about using + * [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge). + */ + channel?: string; + + /** + * Enable Chromium sandboxing. Defaults to `false`. + */ + chromiumSandbox?: boolean; + + /** + * **Chromium-only** Whether to auto-open a Developer Tools panel for each tab. If this option is `true`, the + * `headless` option will be set `false`. + * @deprecated Use [debugging tools](https://playwright.dev/docs/debug) instead. + */ + devtools?: boolean; + + /** + * If specified, accepted downloads are downloaded into this directory. Otherwise, temporary directory is created and + * is deleted when browser is closed. In either case, the downloads are deleted when the browser context they were + * created in is closed. + */ + downloadsPath?: string; + + /** + * Specify environment variables that will be visible to the browser. Defaults to `process.env`. + */ + env?: { [key: string]: string|number|boolean; }; + + /** + * Path to a browser executable to run instead of the bundled one. If `executablePath` is a relative path, then it is + * resolved relative to the current working directory. Note that Playwright only works with the bundled Chromium, + * Firefox or WebKit, use at your own risk. + */ + executablePath?: string; + + /** + * Firefox user preferences. Learn more about the Firefox user preferences at + * [`about:config`](https://support.mozilla.org/en-US/kb/about-config-editor-firefox). + */ + firefoxUserPrefs?: { [key: string]: string|number|boolean; }; + + /** + * Close the browser process on SIGHUP. Defaults to `true`. + */ + handleSIGHUP?: boolean; + + /** + * Close the browser process on Ctrl-C. Defaults to `true`. + */ + handleSIGINT?: boolean; + + /** + * Close the browser process on SIGTERM. Defaults to `true`. + */ + handleSIGTERM?: boolean; + + /** + * Whether to run browser in headless mode. More details for + * [Chromium](https://developers.google.com/web/updates/2017/04/headless-chrome) and + * [Firefox](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Headless_mode). Defaults to `true` unless the + * `devtools` option is `true`. + */ + headless?: boolean; + + /** + * If `true`, Playwright does not pass its own configurations args and only uses the ones from `args`. If an array is + * given, then filters out the given default arguments. Dangerous option; use with care. Defaults to `false`. + */ + ignoreDefaultArgs?: boolean|Array; + + /** + * Logger sink for Playwright logging. + */ + logger?: Logger; + + /** + * Network proxy settings. + */ + proxy?: { + /** + * Proxy to be used for all requests. HTTP and SOCKS proxies are supported, for example `http://myproxy.com:3128` or + * `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP proxy. + */ + server: string; + + /** + * Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`. + */ + bypass?: string; + + /** + * Optional username to use if HTTP proxy requires authentication. + */ + username?: string; + + /** + * Optional password to use if HTTP proxy requires authentication. + */ + password?: string; + }; + + /** + * Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going + * on. + */ + slowMo?: number; + + /** + * Maximum time in milliseconds to wait for the browser instance to start. Defaults to `30000` (30 seconds). Pass `0` + * to disable timeout. + */ + timeout?: number; + + /** + * If specified, traces are saved into this directory. + */ + tracesDir?: string; +} + +export interface ConnectOverCDPOptions { + /** + * Deprecated, use the first argument instead. Optional. + */ + endpointURL?: string; + + /** + * Additional HTTP headers to be sent with connect request. Optional. + */ + headers?: { [key: string]: string; }; + + /** + * Logger sink for Playwright logging. Optional. + */ + logger?: Logger; + + /** + * Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going + * on. Defaults to 0. + */ + slowMo?: number; + + /** + * Maximum time in milliseconds to wait for the connection to be established. Defaults to `30000` (30 seconds). Pass + * `0` to disable timeout. + */ + timeout?: number; +} + +export interface ConnectOptions { + /** + * This option exposes network available on the connecting client to the browser being connected to. Consists of a + * list of rules separated by comma. + * + * Available rules: + * 1. Hostname pattern, for example: `example.com`, `*.org:99`, `x.*.y.com`, `*foo.org`. + * 1. IP literal, for example: `127.0.0.1`, `0.0.0.0:99`, `[::1]`, `[0:0::1]:99`. + * 1. `` that matches local loopback interfaces: `localhost`, `*.localhost`, `127.0.0.1`, `[::1]`. + * + * Some common examples: + * 1. `"*"` to expose all network. + * 1. `""` to expose localhost network. + * 1. `"*.test.internal-domain,*.staging.internal-domain,"` to expose test/staging deployments and + * localhost. + */ + exposeNetwork?: string; + + /** + * Additional HTTP headers to be sent with web socket connect request. Optional. + */ + headers?: { [key: string]: string; }; + + /** + * Logger sink for Playwright logging. Optional. + */ + logger?: Logger; + + /** + * Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going + * on. Defaults to 0. + */ + slowMo?: number; + + /** + * Maximum time in milliseconds to wait for the connection to be established. Defaults to `0` (no timeout). + */ + timeout?: number; +} + +export interface LocatorScreenshotOptions { + /** + * When set to `"disabled"`, stops CSS animations, CSS transitions and Web Animations. Animations get different + * treatment depending on their duration: + * - finite animations are fast-forwarded to completion, so they'll fire `transitionend` event. + * - infinite animations are canceled to initial state, and then played over after the screenshot. + * + * Defaults to `"allow"` that leaves animations untouched. + */ + animations?: "disabled"|"allow"; + + /** + * When set to `"hide"`, screenshot will hide text caret. When set to `"initial"`, text caret behavior will not be + * changed. Defaults to `"hide"`. + */ + caret?: "hide"|"initial"; + + /** + * Specify locators that should be masked when the screenshot is taken. Masked elements will be overlaid with a pink + * box `#FF00FF` (customized by `maskColor`) that completely covers its bounding box. + */ + mask?: Array; + + /** + * Specify the color of the overlay box for masked elements, in + * [CSS color format](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value). Default color is pink `#FF00FF`. + */ + maskColor?: string; + + /** + * Hides default white background and allows capturing screenshots with transparency. Not applicable to `jpeg` images. + * Defaults to `false`. + */ + omitBackground?: boolean; + + /** + * The file path to save the image to. The screenshot type will be inferred from file extension. If `path` is a + * relative path, then it is resolved relative to the current working directory. If no path is provided, the image + * won't be saved to the disk. + */ + path?: string; + + /** + * The quality of the image, between 0-100. Not applicable to `png` images. + */ + quality?: number; + + /** + * When set to `"css"`, screenshot will have a single pixel per each css pixel on the page. For high-dpi devices, this + * will keep screenshots small. Using `"device"` option will produce a single pixel per each device pixel, so + * screenshots of high-dpi devices will be twice as large or even larger. + * + * Defaults to `"device"`. + */ + scale?: "css"|"device"; + + /** + * Text of the stylesheet to apply while making the screenshot. This is where you can hide dynamic elements, make + * elements invisible or change their properties to help you creating repeatable screenshots. This stylesheet pierces + * the Shadow DOM and applies to the inner frames. + */ + style?: string; + + /** + * Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout` + * option in the config, or by using the + * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout) + * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods. + */ + timeout?: number; + + /** + * Specify screenshot type, defaults to `png`. + */ + type?: "png"|"jpeg"; +} + +interface ElementHandleWaitForSelectorOptions { + /** + * Defaults to `'visible'`. Can be either: + * - `'attached'` - wait for element to be present in DOM. + * - `'detached'` - wait for element to not be present in DOM. + * - `'visible'` - wait for element to have non-empty bounding box and no `visibility:hidden`. Note that element + * without any content or with `display:none` has an empty bounding box and is not considered visible. + * - `'hidden'` - wait for element to be either detached from DOM, or have an empty bounding box or + * `visibility:hidden`. This is opposite to the `'visible'` option. + */ + state?: "attached"|"detached"|"visible"|"hidden"; + + /** + * When true, the call requires selector to resolve to a single element. If given selector resolves to more than one + * element, the call throws an exception. + */ + strict?: boolean; + + /** + * Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout` + * option in the config, or by using the + * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout) + * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods. + */ + timeout?: number; +} + export interface BrowserContextOptions { /** * Whether to automatically download all the attachments. Defaults to `true` where all the downloads are accepted. @@ -20635,322 +21007,6 @@ export interface Geolocation { accuracy?: number; } -interface AccessibilitySnapshotOptions { - /** - * Prune uninteresting nodes from the tree. Defaults to `true`. - */ - interestingOnly?: boolean; - - /** - * The root DOM element for the snapshot. Defaults to the whole page. - */ - root?: ElementHandle; -} - -export interface LaunchOptions { - /** - * **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality. - * - * Additional arguments to pass to the browser instance. The list of Chromium flags can be found - * [here](https://peter.sh/experiments/chromium-command-line-switches/). - */ - args?: Array; - - /** - * Browser distribution channel. Supported values are "chrome", "chrome-beta", "chrome-dev", "chrome-canary", - * "msedge", "msedge-beta", "msedge-dev", "msedge-canary". Read more about using - * [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge). - */ - channel?: string; - - /** - * Enable Chromium sandboxing. Defaults to `false`. - */ - chromiumSandbox?: boolean; - - /** - * **Chromium-only** Whether to auto-open a Developer Tools panel for each tab. If this option is `true`, the - * `headless` option will be set `false`. - * @deprecated Use [debugging tools](https://playwright.dev/docs/debug) instead. - */ - devtools?: boolean; - - /** - * If specified, accepted downloads are downloaded into this directory. Otherwise, temporary directory is created and - * is deleted when browser is closed. In either case, the downloads are deleted when the browser context they were - * created in is closed. - */ - downloadsPath?: string; - - /** - * Specify environment variables that will be visible to the browser. Defaults to `process.env`. - */ - env?: { [key: string]: string|number|boolean; }; - - /** - * Path to a browser executable to run instead of the bundled one. If `executablePath` is a relative path, then it is - * resolved relative to the current working directory. Note that Playwright only works with the bundled Chromium, - * Firefox or WebKit, use at your own risk. - */ - executablePath?: string; - - /** - * Firefox user preferences. Learn more about the Firefox user preferences at - * [`about:config`](https://support.mozilla.org/en-US/kb/about-config-editor-firefox). - */ - firefoxUserPrefs?: { [key: string]: string|number|boolean; }; - - /** - * Close the browser process on SIGHUP. Defaults to `true`. - */ - handleSIGHUP?: boolean; - - /** - * Close the browser process on Ctrl-C. Defaults to `true`. - */ - handleSIGINT?: boolean; - - /** - * Close the browser process on SIGTERM. Defaults to `true`. - */ - handleSIGTERM?: boolean; - - /** - * Whether to run browser in headless mode. More details for - * [Chromium](https://developers.google.com/web/updates/2017/04/headless-chrome) and - * [Firefox](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Headless_mode). Defaults to `true` unless the - * `devtools` option is `true`. - */ - headless?: boolean; - - /** - * If `true`, Playwright does not pass its own configurations args and only uses the ones from `args`. If an array is - * given, then filters out the given default arguments. Dangerous option; use with care. Defaults to `false`. - */ - ignoreDefaultArgs?: boolean|Array; - - /** - * Logger sink for Playwright logging. - */ - logger?: Logger; - - /** - * Network proxy settings. - */ - proxy?: { - /** - * Proxy to be used for all requests. HTTP and SOCKS proxies are supported, for example `http://myproxy.com:3128` or - * `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP proxy. - */ - server: string; - - /** - * Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`. - */ - bypass?: string; - - /** - * Optional username to use if HTTP proxy requires authentication. - */ - username?: string; - - /** - * Optional password to use if HTTP proxy requires authentication. - */ - password?: string; - }; - - /** - * Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going - * on. - */ - slowMo?: number; - - /** - * Maximum time in milliseconds to wait for the browser instance to start. Defaults to `30000` (30 seconds). Pass `0` - * to disable timeout. - */ - timeout?: number; - - /** - * If specified, traces are saved into this directory. - */ - tracesDir?: string; -} - -export interface ConnectOverCDPOptions { - /** - * Deprecated, use the first argument instead. Optional. - */ - endpointURL?: string; - - /** - * Additional HTTP headers to be sent with connect request. Optional. - */ - headers?: { [key: string]: string; }; - - /** - * Logger sink for Playwright logging. Optional. - */ - logger?: Logger; - - /** - * Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going - * on. Defaults to 0. - */ - slowMo?: number; - - /** - * Maximum time in milliseconds to wait for the connection to be established. Defaults to `30000` (30 seconds). Pass - * `0` to disable timeout. - */ - timeout?: number; -} - -export interface ConnectOptions { - /** - * This option exposes network available on the connecting client to the browser being connected to. Consists of a - * list of rules separated by comma. - * - * Available rules: - * 1. Hostname pattern, for example: `example.com`, `*.org:99`, `x.*.y.com`, `*foo.org`. - * 1. IP literal, for example: `127.0.0.1`, `0.0.0.0:99`, `[::1]`, `[0:0::1]:99`. - * 1. `` that matches local loopback interfaces: `localhost`, `*.localhost`, `127.0.0.1`, `[::1]`. - * - * Some common examples: - * 1. `"*"` to expose all network. - * 1. `""` to expose localhost network. - * 1. `"*.test.internal-domain,*.staging.internal-domain,"` to expose test/staging deployments and - * localhost. - */ - exposeNetwork?: string; - - /** - * Additional HTTP headers to be sent with web socket connect request. Optional. - */ - headers?: { [key: string]: string; }; - - /** - * Logger sink for Playwright logging. Optional. - */ - logger?: Logger; - - /** - * Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going - * on. Defaults to 0. - */ - slowMo?: number; - - /** - * Maximum time in milliseconds to wait for the connection to be established. Defaults to `0` (no timeout). - */ - timeout?: number; -} - -export interface LocatorScreenshotOptions { - /** - * When set to `"disabled"`, stops CSS animations, CSS transitions and Web Animations. Animations get different - * treatment depending on their duration: - * - finite animations are fast-forwarded to completion, so they'll fire `transitionend` event. - * - infinite animations are canceled to initial state, and then played over after the screenshot. - * - * Defaults to `"allow"` that leaves animations untouched. - */ - animations?: "disabled"|"allow"; - - /** - * When set to `"hide"`, screenshot will hide text caret. When set to `"initial"`, text caret behavior will not be - * changed. Defaults to `"hide"`. - */ - caret?: "hide"|"initial"; - - /** - * Specify locators that should be masked when the screenshot is taken. Masked elements will be overlaid with a pink - * box `#FF00FF` (customized by `maskColor`) that completely covers its bounding box. - */ - mask?: Array; - - /** - * Specify the color of the overlay box for masked elements, in - * [CSS color format](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value). Default color is pink `#FF00FF`. - */ - maskColor?: string; - - /** - * Hides default white background and allows capturing screenshots with transparency. Not applicable to `jpeg` images. - * Defaults to `false`. - */ - omitBackground?: boolean; - - /** - * The file path to save the image to. The screenshot type will be inferred from file extension. If `path` is a - * relative path, then it is resolved relative to the current working directory. If no path is provided, the image - * won't be saved to the disk. - */ - path?: string; - - /** - * The quality of the image, between 0-100. Not applicable to `png` images. - */ - quality?: number; - - /** - * When set to `"css"`, screenshot will have a single pixel per each css pixel on the page. For high-dpi devices, this - * will keep screenshots small. Using `"device"` option will produce a single pixel per each device pixel, so - * screenshots of high-dpi devices will be twice as large or even larger. - * - * Defaults to `"device"`. - */ - scale?: "css"|"device"; - - /** - * Text of the stylesheet to apply while making the screenshot. This is where you can hide dynamic elements, make - * elements invisible or change their properties to help you creating repeatable screenshots. This stylesheet pierces - * the Shadow DOM and applies to the inner frames. - */ - style?: string; - - /** - * Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout` - * option in the config, or by using the - * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout) - * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods. - */ - timeout?: number; - - /** - * Specify screenshot type, defaults to `png`. - */ - type?: "png"|"jpeg"; -} - -interface ElementHandleWaitForSelectorOptions { - /** - * Defaults to `'visible'`. Can be either: - * - `'attached'` - wait for element to be present in DOM. - * - `'detached'` - wait for element to not be present in DOM. - * - `'visible'` - wait for element to have non-empty bounding box and no `visibility:hidden`. Note that element - * without any content or with `display:none` has an empty bounding box and is not considered visible. - * - `'hidden'` - wait for element to be either detached from DOM, or have an empty bounding box or - * `visibility:hidden`. This is opposite to the `'visible'` option. - */ - state?: "attached"|"detached"|"visible"|"hidden"; - - /** - * When true, the call requires selector to resolve to a single element. If given selector resolves to more than one - * element, the call throws an exception. - */ - strict?: boolean; - - /** - * Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout` - * option in the config, or by using the - * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout) - * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods. - */ - timeout?: number; -} - export interface Cookie { name: string; diff --git a/packages/playwright-ct-core/src/devServer.ts b/packages/playwright-ct-core/src/devServer.ts index 2e1e7c76bf..3e833fcd9c 100644 --- a/packages/playwright-ct-core/src/devServer.ts +++ b/packages/playwright-ct-core/src/devServer.ts @@ -61,7 +61,7 @@ export async function runDevServer(config: FullConfigInternal): Promise<() => Pr projectOutputs.add(p.project.outputDir); } - const globalWatcher = new Watcher('deep', async () => { + const globalWatcher = new Watcher(async () => { const registry: ComponentRegistry = new Map(); await populateComponentsFromTests(registry); // compare componentRegistry to registry key sets. @@ -80,6 +80,6 @@ export async function runDevServer(config: FullConfigInternal): Promise<() => Pr if (rootModule) devServer.moduleGraph.onFileChange(rootModule.file!); }); - globalWatcher.update([...projectDirs], [...projectOutputs], false); + await globalWatcher.update([...projectDirs], [...projectOutputs], false); return () => Promise.all([devServer.close(), globalWatcher.close()]).then(() => {}); } diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index 610382e175..61be9663fa 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -24,7 +24,6 @@ import { getPackageJsonPath, mergeObjects } from '../util'; import type { Matcher } from '../util'; import type { ConfigCLIOverrides } from './ipc'; import type { FullConfig, FullProject } from '../../types/testReporter'; -import { setTransformConfig } from '../transform/transform'; export type ConfigLocation = { resolvedConfigFile?: string; @@ -128,10 +127,6 @@ export class FullConfigInternal { this.projects = projectConfigs.map(p => new FullProjectInternal(configDir, userConfig, this, p, this.configCLIOverrides, packageJsonDir)); resolveProjectDependencies(this.projects); this._assignUniqueProjectIds(this.projects); - setTransformConfig({ - babelPlugins: privateConfiguration?.babelPlugins || [], - external: userConfig.build?.external || [], - }); this.config.projects = this.projects.map(p => p.project); } diff --git a/packages/playwright/src/common/configLoader.ts b/packages/playwright/src/common/configLoader.ts index 81ea74e8e3..5ed1c68ea7 100644 --- a/packages/playwright/src/common/configLoader.ts +++ b/packages/playwright/src/common/configLoader.ts @@ -18,13 +18,13 @@ import * as fs from 'fs'; import * as path from 'path'; import { gracefullyProcessExitDoNotHang, isRegExp } from 'playwright-core/lib/utils'; import type { ConfigCLIOverrides, SerializedConfig } from './ipc'; -import { requireOrImport } from '../transform/transform'; +import { requireOrImport, setSingleTSConfig, setTransformConfig } from '../transform/transform'; import type { Config, Project } from '../../types/test'; import { errorWithFile, fileIsModule } from '../util'; import type { ConfigLocation } from './config'; import { FullConfigInternal } from './config'; import { addToCompilationCache } from '../transform/compilationCache'; -import { initializeEsmLoader, registerESMLoader } from './esmLoaderHost'; +import { configureESMLoader, configureESMLoaderTransformConfig, registerESMLoader } from './esmLoaderHost'; import { execArgvWithExperimentalLoaderOptions, execArgvWithoutExperimentalLoaderOptions } from '../transform/esmUtils'; const kDefineConfigWasUsed = Symbol('defineConfigWasUsed'); @@ -87,10 +87,7 @@ export const defineConfig = (...configs: any[]) => { export async function deserializeConfig(data: SerializedConfig): Promise { if (data.compilationCache) addToCompilationCache(data.compilationCache); - - const config = await loadConfig(data.location, data.configCLIOverrides); - await initializeEsmLoader(); - return config; + return await loadConfig(data.location, data.configCLIOverrides); } async function loadUserConfig(location: ConfigLocation): Promise { @@ -101,6 +98,11 @@ async function loadUserConfig(location: ConfigLocation): Promise { } export async function loadConfig(location: ConfigLocation, overrides?: ConfigCLIOverrides, ignoreProjectDependencies = false): Promise { + // 1. Setup tsconfig; configure ESM loader with tsconfig and compilation cache. + setSingleTSConfig(overrides?.tsconfig); + await configureESMLoader(); + + // 2. Load and validate playwright config. const userConfig = await loadUserConfig(location); validateConfig(location.resolvedConfigFile || '', userConfig); const fullConfig = new FullConfigInternal(location, userConfig, overrides || {}); @@ -111,6 +113,15 @@ export async function loadConfig(location: ConfigLocation, overrides?: ConfigCLI project.teardown = undefined; } } + + // 3. Load transform options from the playwright config. + const babelPlugins = (userConfig as any)['@playwright/test']?.babelPlugins || []; + const external = userConfig.build?.external || []; + setTransformConfig({ babelPlugins, external }); + + // 4. Send transform options to ESM loader. + await configureESMLoaderTransformConfig(); + return fullConfig; } diff --git a/packages/playwright/src/common/esmLoaderHost.ts b/packages/playwright/src/common/esmLoaderHost.ts index 4b38ea6670..1611b0f91d 100644 --- a/packages/playwright/src/common/esmLoaderHost.ts +++ b/packages/playwright/src/common/esmLoaderHost.ts @@ -16,7 +16,7 @@ import url from 'url'; import { addToCompilationCache, serializeCompilationCache } from '../transform/compilationCache'; -import { transformConfig } from '../transform/transform'; +import { singleTSConfig, transformConfig } from '../transform/transform'; import { PortTransport } from '../transform/portTransport'; let loaderChannel: PortTransport | undefined; @@ -67,9 +67,15 @@ export async function incorporateCompilationCache() { addToCompilationCache(result.cache); } -export async function initializeEsmLoader() { +export async function configureESMLoader() { + if (!loaderChannel) + return; + await loaderChannel.send('setSingleTSConfig', { tsconfig: singleTSConfig() }); + await loaderChannel.send('addToCompilationCache', { cache: serializeCompilationCache() }); +} + +export async function configureESMLoaderTransformConfig() { if (!loaderChannel) return; await loaderChannel.send('setTransformConfig', { config: transformConfig() }); - await loaderChannel.send('addToCompilationCache', { cache: serializeCompilationCache() }); } diff --git a/packages/playwright/src/common/ipc.ts b/packages/playwright/src/common/ipc.ts index c1c7b2da25..da48446801 100644 --- a/packages/playwright/src/common/ipc.ts +++ b/packages/playwright/src/common/ipc.ts @@ -33,6 +33,7 @@ export type ConfigCLIOverrides = { additionalReporters?: ReporterDescription[]; shard?: { current: number, total: number }; timeout?: number; + tsconfig?: string; ignoreSnapshots?: boolean; updateSnapshots?: 'all'|'none'|'missing'; workers?: number | string; diff --git a/packages/playwright/src/fsWatcher.ts b/packages/playwright/src/fsWatcher.ts index a1c09c343d..53e6849b1a 100644 --- a/packages/playwright/src/fsWatcher.ts +++ b/packages/playwright/src/fsWatcher.ts @@ -21,26 +21,24 @@ export type FSEvent = { event: 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkD export class Watcher { private _onChange: (events: FSEvent[]) => void; - private _watchedFiles: string[] = []; + private _watchedPaths: string[] = []; private _ignoredFolders: string[] = []; private _collector: FSEvent[] = []; private _fsWatcher: FSWatcher | undefined; private _throttleTimer: NodeJS.Timeout | undefined; - private _mode: 'flat' | 'deep'; - constructor(mode: 'flat' | 'deep', onChange: (events: FSEvent[]) => void) { - this._mode = mode; + constructor(onChange: (events: FSEvent[]) => void) { this._onChange = onChange; } - update(watchedFiles: string[], ignoredFolders: string[], reportPending: boolean) { - if (JSON.stringify([this._watchedFiles, this._ignoredFolders]) === JSON.stringify(watchedFiles, ignoredFolders)) + async update(watchedPaths: string[], ignoredFolders: string[], reportPending: boolean) { + if (JSON.stringify([this._watchedPaths, this._ignoredFolders]) === JSON.stringify(watchedPaths, ignoredFolders)) return; if (reportPending) this._reportEventsIfAny(); - this._watchedFiles = watchedFiles; + this._watchedPaths = watchedPaths; this._ignoredFolders = ignoredFolders; void this._fsWatcher?.close(); this._fsWatcher = undefined; @@ -48,20 +46,18 @@ export class Watcher { clearTimeout(this._throttleTimer); this._throttleTimer = undefined; - if (!this._watchedFiles.length) + if (!this._watchedPaths.length) return; const ignored = [...this._ignoredFolders, '**/node_modules/**']; - this._fsWatcher = chokidar.watch(watchedFiles, { ignoreInitial: true, ignored }).on('all', async (event, file) => { + this._fsWatcher = chokidar.watch(watchedPaths, { ignoreInitial: true, ignored }).on('all', async (event, file) => { if (this._throttleTimer) clearTimeout(this._throttleTimer); - if (this._mode === 'flat' && event !== 'add' && event !== 'change') - return; - if (this._mode === 'deep' && event !== 'add' && event !== 'change' && event !== 'unlink' && event !== 'addDir' && event !== 'unlinkDir') - return; this._collector.push({ event, file }); this._throttleTimer = setTimeout(() => this._reportEventsIfAny(), 250); }); + + await new Promise((resolve, reject) => this._fsWatcher!.once('ready', resolve).once('error', reject)); } async close() { diff --git a/packages/playwright/src/isomorphic/testServerConnection.ts b/packages/playwright/src/isomorphic/testServerConnection.ts index 156af26e8c..34ce0e31ef 100644 --- a/packages/playwright/src/isomorphic/testServerConnection.ts +++ b/packages/playwright/src/isomorphic/testServerConnection.ts @@ -23,14 +23,12 @@ export class TestServerConnection implements TestServerInterface, TestServerInte readonly onClose: events.Event; readonly onReport: events.Event; readonly onStdio: events.Event<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>; - readonly onListChanged: events.Event; readonly onTestFilesChanged: events.Event<{ testFiles: string[] }>; readonly onLoadTraceRequested: events.Event<{ traceUrl: string }>; private _onCloseEmitter = new events.EventEmitter(); private _onReportEmitter = new events.EventEmitter(); private _onStdioEmitter = new events.EventEmitter<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>(); - private _onListChangedEmitter = new events.EventEmitter(); private _onTestFilesChangedEmitter = new events.EventEmitter<{ testFiles: string[] }>(); private _onLoadTraceRequestedEmitter = new events.EventEmitter<{ traceUrl: string }>(); @@ -44,7 +42,6 @@ export class TestServerConnection implements TestServerInterface, TestServerInte this.onClose = this._onCloseEmitter.event; this.onReport = this._onReportEmitter.event; this.onStdio = this._onStdioEmitter.event; - this.onListChanged = this._onListChangedEmitter.event; this.onTestFilesChanged = this._onTestFilesChangedEmitter.event; this.onLoadTraceRequested = this._onLoadTraceRequestedEmitter.event; @@ -103,8 +100,6 @@ export class TestServerConnection implements TestServerInterface, TestServerInte this._onReportEmitter.fire(params); else if (method === 'stdio') this._onStdioEmitter.fire(params); - else if (method === 'listChanged') - this._onListChangedEmitter.fire(params); else if (method === 'testFilesChanged') this._onTestFilesChangedEmitter.fire(params); else if (method === 'loadTraceRequested') diff --git a/packages/playwright/src/isomorphic/testServerInterface.ts b/packages/playwright/src/isomorphic/testServerInterface.ts index 08214ee723..0460ae56d9 100644 --- a/packages/playwright/src/isomorphic/testServerInterface.ts +++ b/packages/playwright/src/isomorphic/testServerInterface.ts @@ -118,7 +118,6 @@ export interface TestServerInterface { export interface TestServerInterfaceEvents { onReport: Event; onStdio: Event<{ type: 'stdout' | 'stderr', text?: string, buffer?: string }>; - onListChanged: Event; onTestFilesChanged: Event<{ testFiles: string[] }>; onLoadTraceRequested: Event<{ traceUrl: string }>; } @@ -126,7 +125,6 @@ export interface TestServerInterfaceEvents { export interface TestServerInterfaceEventEmitters { dispatchEvent(event: 'report', params: ReportEntry): void; dispatchEvent(event: 'stdio', params: { type: 'stdout' | 'stderr', text?: string, buffer?: string }): void; - dispatchEvent(event: 'listChanged', params: {}): void; dispatchEvent(event: 'testFilesChanged', params: { testFiles: string[] }): void; dispatchEvent(event: 'loadTraceRequested', params: { traceUrl: string }): void; } diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index a6c2c5fead..ac851032df 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -286,6 +286,7 @@ function overridesFromOptions(options: { [key: string]: any }): ConfigCLIOverrid reporter: resolveReporterOption(options.reporter), shard: shardPair ? { current: shardPair[0], total: shardPair[1] } : undefined, timeout: options.timeout ? parseInt(options.timeout, 10) : undefined, + tsconfig: options.tsconfig ? path.resolve(process.cwd(), options.tsconfig) : undefined, ignoreSnapshots: options.ignoreSnapshots ? !!options.ignoreSnapshots : undefined, updateSnapshots: options.updateSnapshots ? 'all' as const : undefined, workers: options.workers, @@ -365,6 +366,7 @@ const testOptions: [string, string][] = [ ['--shard ', `Shard tests and execute only the selected shard, specify in the form "current/all", 1-based, for example "3/5"`], ['--timeout ', `Specify test timeout threshold in milliseconds, zero for unlimited (default: ${defaultTimeout})`], ['--trace ', `Force tracing mode, can be ${kTraceModes.map(mode => `"${mode}"`).join(', ')}`], + ['--tsconfig ', `Path to a single tsconfig applicable to all imported files (default: look up tsconfig for each imported file separately)`], ['--ui', `Run tests in interactive UI mode`], ['--ui-host ', 'Host to serve UI on; specifying this option opens UI in a browser tab'], ['--ui-port ', 'Port to serve UI on, 0 for any free port; specifying this option opens UI in a browser tab'], diff --git a/packages/playwright/src/runner/loaderHost.ts b/packages/playwright/src/runner/loaderHost.ts index cc311e5f6e..e6db22695b 100644 --- a/packages/playwright/src/runner/loaderHost.ts +++ b/packages/playwright/src/runner/loaderHost.ts @@ -22,7 +22,7 @@ import { loadTestFile } from '../common/testLoader'; import type { FullConfigInternal } from '../common/config'; import { PoolBuilder } from '../common/poolBuilder'; import { addToCompilationCache } from '../transform/compilationCache'; -import { incorporateCompilationCache, initializeEsmLoader } from '../common/esmLoaderHost'; +import { incorporateCompilationCache } from '../common/esmLoaderHost'; export class InProcessLoaderHost { private _config: FullConfigInternal; @@ -34,7 +34,6 @@ export class InProcessLoaderHost { } async start(errors: TestError[]) { - await initializeEsmLoader(); return true; } diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index 559f95514a..5a498337af 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -64,8 +64,12 @@ class TestServer { class TestServerDispatcher implements TestServerInterface { private _configLocation: ConfigLocation; - private _globalWatcher: Watcher; - private _testWatcher: Watcher; + + private _watcher: Watcher; + private _watchedProjectDirs = new Set(); + private _ignoredProjectOutputs = new Set(); + private _watchedTestDependencies = new Set(); + private _testRun: { run: Promise, stop: ManualPromise } | undefined; readonly transport: Transport; private _queue = Promise.resolve(); @@ -86,8 +90,7 @@ class TestServerDispatcher implements TestServerInterface { gracefullyProcessExitDoNotHang(0); }, }; - this._globalWatcher = new Watcher('deep', () => this._dispatchEvent('listChanged', {})); - this._testWatcher = new Watcher('flat', events => { + this._watcher = new Watcher(events => { const collector = new Set(); events.forEach(f => collectAffectedTestFiles(f.file, collector)); this._dispatchEvent('testFilesChanged', { testFiles: [...collector] }); @@ -279,24 +282,28 @@ class TestServerDispatcher implements TestServerInterface { await taskRunner.reporter.onEnd({ status }); await taskRunner.reporter.onExit(); - const projectDirs = new Set(); - const projectOutputs = new Set(); + this._watchedProjectDirs = new Set(); + this._ignoredProjectOutputs = new Set(); for (const p of config.projects) { - projectDirs.add(p.project.testDir); - projectOutputs.add(p.project.outputDir); + this._watchedProjectDirs.add(p.project.testDir); + this._ignoredProjectOutputs.add(p.project.outputDir); } const result = await resolveCtDirs(config); if (result) { - projectDirs.add(result.templateDir); - projectOutputs.add(result.outDir); + this._watchedProjectDirs.add(result.templateDir); + this._ignoredProjectOutputs.add(result.outDir); } if (this._watchTestDirs) - this._globalWatcher.update([...projectDirs], [...projectOutputs], false); + await this.updateWatcher(false); return { report, status }; } + private async updateWatcher(reportPending: boolean) { + await this._watcher.update([...this._watchedProjectDirs, ...this._watchedTestDependencies], [...this._ignoredProjectOutputs], reportPending); + } + async runTests(params: Parameters[0]): ReturnType { let result: Awaited> = { status: 'passed' }; this._queue = this._queue.then(async () => { @@ -364,12 +371,12 @@ class TestServerDispatcher implements TestServerInterface { } async watch(params: { fileNames: string[]; }) { - const files = new Set(); + this._watchedTestDependencies = new Set(); for (const fileName of params.fileNames) { - files.add(fileName); - dependenciesForTestFile(fileName).forEach(file => files.add(file)); + this._watchedTestDependencies.add(fileName); + dependenciesForTestFile(fileName).forEach(file => this._watchedTestDependencies.add(file)); } - this._testWatcher.update([...files], [], true); + await this.updateWatcher(true); } async findRelatedTestFiles(params: Parameters[0]): ReturnType { diff --git a/packages/playwright/src/third_party/tsconfig-loader.ts b/packages/playwright/src/third_party/tsconfig-loader.ts index d85ff32100..490704c330 100644 --- a/packages/playwright/src/third_party/tsconfig-loader.ts +++ b/packages/playwright/src/third_party/tsconfig-loader.ts @@ -52,12 +52,8 @@ export interface LoadedTsConfig { allowJs?: boolean; } -export interface TsConfigLoaderParams { - cwd: string; -} - -export function tsConfigLoader({ cwd, }: TsConfigLoaderParams): LoadedTsConfig[] { - const configPath = resolveConfigPath(cwd); +export function tsConfigLoader(tsconfigPathOrDirecotry: string): LoadedTsConfig[] { + const configPath = resolveConfigPath(tsconfigPathOrDirecotry); if (!configPath) return []; @@ -67,12 +63,12 @@ export function tsConfigLoader({ cwd, }: TsConfigLoaderParams): LoadedTsConfig[] return [config, ...references]; } -function resolveConfigPath(cwd: string): string | undefined { - if (fs.statSync(cwd).isFile()) { - return path.resolve(cwd); +function resolveConfigPath(tsconfigPathOrDirecotry: string): string | undefined { + if (fs.statSync(tsconfigPathOrDirecotry).isFile()) { + return path.resolve(tsconfigPathOrDirecotry); } - const configAbsolutePath = walkForTsConfig(cwd); + const configAbsolutePath = walkForTsConfig(tsconfigPathOrDirecotry); return configAbsolutePath ? path.resolve(configAbsolutePath) : undefined; } diff --git a/packages/playwright/src/transform/compilationCache.ts b/packages/playwright/src/transform/compilationCache.ts index 060fc717ef..39c1189ea2 100644 --- a/packages/playwright/src/transform/compilationCache.ts +++ b/packages/playwright/src/transform/compilationCache.ts @@ -64,13 +64,7 @@ const fileDependencies = new Map>(); // Dependencies resolved by the external bundler. const externalDependencies = new Map>(); -let sourceMapSupportInstalled = false; - -export function installSourceMapSupportIfNeeded() { - if (sourceMapSupportInstalled) - return; - sourceMapSupportInstalled = true; - +export function installSourceMapSupport() { Error.stackTraceLimit = 200; sourceMapSupport.install({ diff --git a/packages/playwright/src/transform/esmLoader.ts b/packages/playwright/src/transform/esmLoader.ts index db072128e3..0ef0f29eaf 100644 --- a/packages/playwright/src/transform/esmLoader.ts +++ b/packages/playwright/src/transform/esmLoader.ts @@ -17,7 +17,7 @@ import fs from 'fs'; import url from 'url'; import { addToCompilationCache, currentFileDepsCollector, serializeCompilationCache, startCollectingFileDeps, stopCollectingFileDeps } from './compilationCache'; -import { transformHook, resolveHook, setTransformConfig, shouldTransform } from './transform'; +import { transformHook, resolveHook, setTransformConfig, shouldTransform, setSingleTSConfig } from './transform'; import { PortTransport } from './portTransport'; import { fileIsModule } from '../util'; @@ -89,6 +89,11 @@ function initialize(data: { port: MessagePort }) { function createTransport(port: MessagePort) { return new PortTransport(port, async (method, params) => { + if (method === 'setSingleTSConfig') { + setSingleTSConfig(params.tsconfig); + return; + } + if (method === 'setTransformConfig') { setTransformConfig(params.config); return; diff --git a/packages/playwright/src/transform/transform.ts b/packages/playwright/src/transform/transform.ts index 81f5fdb5fe..1655e95c35 100644 --- a/packages/playwright/src/transform/transform.ts +++ b/packages/playwright/src/transform/transform.ts @@ -25,7 +25,7 @@ import Module from 'module'; import type { BabelPlugin, BabelTransformFunction } from './babelBundle'; import { createFileMatcher, fileIsModule, resolveImportSpecifierExtension } from '../util'; import type { Matcher } from '../util'; -import { getFromCompilationCache, currentFileDepsCollector, belongsToNodeModules, installSourceMapSupportIfNeeded } from './compilationCache'; +import { getFromCompilationCache, currentFileDepsCollector, belongsToNodeModules, installSourceMapSupport } from './compilationCache'; const version = require('../../package.json').version; @@ -57,6 +57,16 @@ export function transformConfig(): TransformConfig { return _transformConfig; } +let _singleTSConfig: string | undefined; + +export function setSingleTSConfig(value: string | undefined) { + _singleTSConfig = value; +} + +export function singleTSConfig(): string | undefined { + return _singleTSConfig; +} + function validateTsConfig(tsconfig: LoadedTsConfig): ParsedTsConfigData { // When no explicit baseUrl is set, resolve paths relative to the tsconfig file. // See https://www.typescriptlang.org/tsconfig#paths @@ -71,12 +81,12 @@ function validateTsConfig(tsconfig: LoadedTsConfig): ParsedTsConfigData { } function loadAndValidateTsconfigsForFile(file: string): ParsedTsConfigData[] { - const cwd = path.dirname(file); - if (!cachedTSConfigs.has(cwd)) { - const loaded = tsConfigLoader({ cwd }); - cachedTSConfigs.set(cwd, loaded.map(validateTsConfig)); + const tsconfigPathOrDirecotry = _singleTSConfig || path.dirname(file); + if (!cachedTSConfigs.has(tsconfigPathOrDirecotry)) { + const loaded = tsConfigLoader(tsconfigPathOrDirecotry); + cachedTSConfigs.set(tsconfigPathOrDirecotry, loaded.map(validateTsConfig)); } - return cachedTSConfigs.get(cwd)!; + return cachedTSConfigs.get(tsconfigPathOrDirecotry)!; } const pathSeparator = process.platform === 'win32' ? ';' : ':'; @@ -201,33 +211,33 @@ function calculateHash(content: string, filePath: string, isModule: boolean, plu } export async function requireOrImport(file: string) { - const revertBabelRequire = installTransform(); + installTransformIfNeeded(); const isModule = fileIsModule(file); - try { - const esmImport = () => eval(`import(${JSON.stringify(url.pathToFileURL(file))})`); - if (isModule) - return await esmImport(); - const result = require(file); - const depsCollector = currentFileDepsCollector(); - if (depsCollector) { - const module = require.cache[file]; - if (module) - collectCJSDependencies(module, depsCollector); - } - return result; - } finally { - revertBabelRequire(); + const esmImport = () => eval(`import(${JSON.stringify(url.pathToFileURL(file))})`); + if (isModule) + return await esmImport(); + const result = require(file); + const depsCollector = currentFileDepsCollector(); + if (depsCollector) { + const module = require.cache[file]; + if (module) + collectCJSDependencies(module, depsCollector); } + return result; } -function installTransform(): () => void { - installSourceMapSupportIfNeeded(); +let transformInstalled = false; - let reverted = false; +function installTransformIfNeeded() { + if (transformInstalled) + return; + transformInstalled = true; + + installSourceMapSupport(); const originalResolveFilename = (Module as any)._resolveFilename; function resolveFilename(this: any, specifier: string, parent: Module, ...rest: any[]) { - if (!reverted && parent) { + if (parent) { const resolved = resolveHook(parent.filename, specifier); if (resolved !== undefined) specifier = resolved; @@ -236,17 +246,11 @@ function installTransform(): () => void { } (Module as any)._resolveFilename = resolveFilename; - const revertPirates = pirates.addHook((code: string, filename: string) => { + pirates.addHook((code: string, filename: string) => { if (!shouldTransform(filename)) return code; return transformHook(code, filename).code; }, { exts: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts', '.cjs', '.cts'] }); - - return () => { - reverted = true; - (Module as any)._resolveFilename = originalResolveFilename; - revertPirates(); - }; } const collectCJSDependencies = (module: Module, dependencies: Set) => { diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 1e9e1f9c46..fac773f394 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -33,7 +33,7 @@ export interface TestStepInternal { complete(result: { error?: Error, attachments?: Attachment[] }): void; stepId: string; title: string; - category: 'hook' | 'fixture' | 'test.step' | 'expect' | string; + category: 'hook' | 'fixture' | 'test.step' | 'expect' | 'attach' | string; location?: Location; boxedStack?: StackFrame[]; steps: TestStepInternal[]; @@ -252,11 +252,6 @@ export class TestInfoImpl implements TestInfo { parentStep = this._findLastStageStep(); } else { parentStep = zones.zoneData('stepZone'); - if (!parentStep && data.category !== 'test.step') { - // API steps (but not test.step calls) can be nested by time, instead of by stack. - // However, do not nest chains of route.continue by checking the title. - parentStep = this._findLastNonFinishedStep(step => step.title !== data.title); - } if (!parentStep) { // If no parent step on stack, assume the current stage as parent. parentStep = this._findLastStageStep(); diff --git a/packages/recorder/src/callLog.tsx b/packages/recorder/src/callLog.tsx index c47b1ac323..eafc9f9826 100644 --- a/packages/recorder/src/callLog.tsx +++ b/packages/recorder/src/callLog.tsx @@ -17,7 +17,7 @@ import './callLog.css'; import * as React from 'react'; import type { CallLog } from './recorderTypes'; -import { msToString } from '@web/uiUtils'; +import { clsx, msToString } from '@web/uiUtils'; import { asLocator } from '@isomorphic/locatorGenerators'; import type { Language } from '@isomorphic/locatorGenerators'; @@ -53,9 +53,9 @@ export const CallLogView: React.FC = ({ titlePrefix = callLog.title + '('; titleSuffix = ')'; } - return
+ return
- { + { const newOverrides = new Map(expandOverrides); newOverrides.set(callLog.id, !isExpanded); setExpandOverrides(newOverrides); @@ -64,7 +64,7 @@ export const CallLogView: React.FC = ({ { callLog.params.url ? {callLog.params.url} : undefined } { locator ? {`page.${locator}`} : undefined } { titleSuffix } - + { typeof callLog.duration === 'number' ? — {msToString(callLog.duration)} : undefined}
{ (isExpanded ? callLog.messages : []).map((message, i) => { diff --git a/packages/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx index 3b47c7d085..b3966fd01a 100644 --- a/packages/recorder/src/recorder.tsx +++ b/packages/recorder/src/recorder.tsx @@ -167,26 +167,27 @@ export const Recorder: React.FC = ({ }}> toggleTheme()}> - - - } + sidebar={ copy(locator)} />] : []} tabs={[ { id: 'locator', title: 'Locator', - render: () => + render: () => }, { id: 'log', title: 'Log', - render: () => + render: () => }, ]} selectedTab={selectedTab} setSelectedTab={setSelectedTab} - /> - + />} + />
; }; diff --git a/packages/trace-viewer/src/embedded.tsx b/packages/trace-viewer/src/embedded.tsx index 8f0a09e560..c916d295c1 100644 --- a/packages/trace-viewer/src/embedded.tsx +++ b/packages/trace-viewer/src/embedded.tsx @@ -17,7 +17,6 @@ import '@web/common.css'; import { applyTheme } from '@web/theme'; import '@web/third_party/vscode/codicon.css'; -import React from 'react'; import * as ReactDOM from 'react-dom'; import { EmbeddedWorkbenchLoader } from './ui/embeddedWorkbenchLoader'; diff --git a/packages/trace-viewer/src/index.tsx b/packages/trace-viewer/src/index.tsx index 3f17856254..993b90a23d 100644 --- a/packages/trace-viewer/src/index.tsx +++ b/packages/trace-viewer/src/index.tsx @@ -17,7 +17,6 @@ import '@web/common.css'; import { applyTheme } from '@web/theme'; import '@web/third_party/vscode/codicon.css'; -import React from 'react'; import * as ReactDOM from 'react-dom'; import { WorkbenchLoader } from './ui/workbenchLoader'; diff --git a/packages/trace-viewer/src/snapshotRenderer.ts b/packages/trace-viewer/src/snapshotRenderer.ts index 730cab4d19..0b458c0cb5 100644 --- a/packages/trace-viewer/src/snapshotRenderer.ts +++ b/packages/trace-viewer/src/snapshotRenderer.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { escapeHTMLAttribute, escapeHTML } from '@isomorphic/stringUtils'; import type { FrameSnapshot, NodeNameAttributesChildNodesSnapshot, NodeSnapshot, RenderedFrameSnapshot, ResourceSnapshot, SubtreeReferenceSnapshot } from '@trace/snapshot'; function isNodeNameAttributesChildNodesSnapshot(n: NodeSnapshot): n is NodeNameAttributesChildNodesSnapshot { @@ -57,7 +58,7 @@ export class SnapshotRenderer { // Old snapshotter was sending lower-case. if (parentTag === 'STYLE' || parentTag === 'style') return rewriteURLsInStyleSheetForCustomProtocol(n); - return escapeText(n); + return escapeHTML(n); } if (!(n as any)._string) { @@ -106,7 +107,7 @@ export class SnapshotRenderer { attrValue = 'link://' + value; else if (attr.toLowerCase() === 'href' || attr.toLowerCase() === 'src' || attr === kCurrentSrcAttribute) attrValue = rewriteURLForCustomProtocol(value); - builder.push(' ', attrName, '="', escapeAttribute(attrValue), '"'); + builder.push(' ', attrName, '="', escapeHTMLAttribute(attrValue), '"'); } builder.push('>'); for (const child of children) @@ -193,14 +194,6 @@ export class SnapshotRenderer { } const autoClosing = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']); -const escaped = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' }; - -function escapeAttribute(s: string): string { - return s.replace(/[&<>"']/ug, char => (escaped as any)[char]); -} -function escapeText(s: string): string { - return s.replace(/[&<]/ug, char => (escaped as any)[char]); -} function snapshotNodes(snapshot: FrameSnapshot): NodeSnapshot[] { if (!(snapshot as any)._nodes) { diff --git a/packages/trace-viewer/src/snapshotServer.ts b/packages/trace-viewer/src/snapshotServer.ts index f1820d9df8..c3c2f5a624 100644 --- a/packages/trace-viewer/src/snapshotServer.ts +++ b/packages/trace-viewer/src/snapshotServer.ts @@ -37,7 +37,7 @@ export class SnapshotServer { return new Response(null, { status: 404 }); const renderedSnapshot = snapshot.render(); this._snapshotIds.set(snapshotUrl, snapshot); - return new Response(renderedSnapshot.html, { status: 200, headers: { 'Content-Type': 'text/html' } }); + return new Response(renderedSnapshot.html, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }); } serveSnapshotInfo(pathname: string, searchParams: URLSearchParams): Response { diff --git a/packages/trace-viewer/src/sw.ts b/packages/trace-viewer/src/sw.ts index 14d26c2a36..7888aa6a30 100644 --- a/packages/trace-viewer/src/sw.ts +++ b/packages/trace-viewer/src/sw.ts @@ -130,13 +130,12 @@ async function doFetch(event: FetchEvent): Promise { } if (relativePath.startsWith('/sha1/')) { - const download = url.searchParams.has('download'); // Sha1 for sources is based on the file path, can't load it of a random model. const sha1 = relativePath.slice('/sha1/'.length); for (const trace of loadedTraces.values()) { const blob = await trace.traceModel.resourceForSha1(sha1); if (blob) - return new Response(blob, { status: 200, headers: download ? downloadHeadersForAttachment(trace.traceModel, sha1) : undefined }); + return new Response(blob, { status: 200, headers: downloadHeaders(url.searchParams) }); } return new Response(null, { status: 404 }); } @@ -157,14 +156,15 @@ async function doFetch(event: FetchEvent): Promise { return snapshotServer.serveResource(lookupUrls, request.method, snapshotUrl); } -function downloadHeadersForAttachment(traceModel: TraceModel, sha1: string): Headers | undefined { - const attachment = traceModel.attachmentForSha1(sha1); - if (!attachment) +function downloadHeaders(searchParams: URLSearchParams): Headers | undefined { + const name = searchParams.get('dn'); + const contentType = searchParams.get('dct'); + if (!name) return; const headers = new Headers(); - headers.set('Content-Disposition', `attachment; filename="attachment"; filename*=UTF-8''${encodeURIComponent(attachment.name)}`); - if (attachment.contentType) - headers.set('Content-Type', attachment.contentType); + headers.set('Content-Disposition', `attachment; filename="attachment"; filename*=UTF-8''${encodeURIComponent(name)}`); + if (contentType) + headers.set('Content-Type', contentType); return headers; } diff --git a/packages/trace-viewer/src/traceModel.ts b/packages/trace-viewer/src/traceModel.ts index 54a77b5aa8..0fc1a73efa 100644 --- a/packages/trace-viewer/src/traceModel.ts +++ b/packages/trace-viewer/src/traceModel.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import type * as trace from '@trace/trace'; import { parseClientSideCallMetadata } from '../../../packages/playwright-core/src/utils/isomorphic/traceUtils'; import type { ContextEntry } from './entries'; import { createEmptyContext } from './entries'; @@ -34,7 +33,6 @@ export class TraceModel { contextEntries: ContextEntry[] = []; private _snapshotStorage: SnapshotStorage | undefined; private _backend!: TraceModelBackend; - private _attachments = new Map(); private _resourceToContentType = new Map(); constructor() { @@ -64,7 +62,7 @@ export class TraceModel { const contextEntry = createEmptyContext(); contextEntry.traceUrl = backend.traceURL(); contextEntry.hasSource = hasSource; - const modernizer = new TraceModernizer(contextEntry, this._snapshotStorage, this._attachments); + const modernizer = new TraceModernizer(contextEntry, this._snapshotStorage); const trace = await this._backend.readText(ordinal + '.trace') || ''; modernizer.appendTrace(trace); @@ -121,10 +119,6 @@ export class TraceModel { return new Blob([blob], { type: this._resourceToContentType.get(sha1) || 'application/octet-stream' }); } - attachmentForSha1(sha1: string): trace.AfterActionTraceEventAttachment | undefined { - return this._attachments.get(sha1); - } - storage(): SnapshotStorage { return this._snapshotStorage!; } diff --git a/packages/trace-viewer/src/traceModernizer.ts b/packages/trace-viewer/src/traceModernizer.ts index e7c65ac41f..b15ad527b0 100644 --- a/packages/trace-viewer/src/traceModernizer.ts +++ b/packages/trace-viewer/src/traceModernizer.ts @@ -34,17 +34,15 @@ const latestVersion: trace.VERSION = 7; export class TraceModernizer { private _contextEntry: ContextEntry; private _snapshotStorage: SnapshotStorage; - private _attachments: Map; private _actionMap = new Map(); private _version: number | undefined; private _pageEntries = new Map(); private _jsHandles = new Map(); private _consoleObjects = new Map(); - constructor(contextEntry: ContextEntry, snapshotStorage: SnapshotStorage, attachments: Map) { + constructor(contextEntry: ContextEntry, snapshotStorage: SnapshotStorage) { this._contextEntry = contextEntry; this._snapshotStorage = snapshotStorage; - this._attachments = attachments; } appendTrace(trace: string) { @@ -129,8 +127,6 @@ export class TraceModernizer { existing!.attachments = event.attachments; if (event.point) existing!.point = event.point; - for (const attachment of event.attachments?.filter(a => a.sha1) || []) - this._attachments.set(attachment.sha1!, attachment); break; } case 'action': { diff --git a/packages/trace-viewer/src/ui/annotationsTab.css b/packages/trace-viewer/src/ui/annotationsTab.css new file mode 100644 index 0000000000..c32cc6f63b --- /dev/null +++ b/packages/trace-viewer/src/ui/annotationsTab.css @@ -0,0 +1,28 @@ +/* + 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. +*/ + +.annotations-tab { + flex: auto; + line-height: 24px; + white-space: pre; + overflow: auto; + user-select: text; +} + +.annotation-item { + margin: 4px 8px; + text-wrap: wrap; +} diff --git a/packages/trace-viewer/src/ui/annotationsTab.tsx b/packages/trace-viewer/src/ui/annotationsTab.tsx new file mode 100644 index 0000000000..5a2a34c9f1 --- /dev/null +++ b/packages/trace-viewer/src/ui/annotationsTab.tsx @@ -0,0 +1,39 @@ +/** + * 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 './annotationsTab.css'; +import { PlaceholderPanel } from './placeholderPanel'; +import { linkifyText } from '@web/renderUtils'; + +type Annotation = { type: string; description?: string; }; + +export const AnnotationsTab: React.FunctionComponent<{ + annotations: Annotation[], +}> = ({ annotations }) => { + + if (!annotations.length) + return ; + + return
+ {annotations.map((annotation, i) => { + return
+ {annotation.type} + {annotation.description && : {linkifyText(annotation.description)}} +
; + })} +
; +}; diff --git a/packages/trace-viewer/src/ui/attachmentsTab.css b/packages/trace-viewer/src/ui/attachmentsTab.css index 66a94121b5..db22a72f5d 100644 --- a/packages/trace-viewer/src/ui/attachmentsTab.css +++ b/packages/trace-viewer/src/ui/attachmentsTab.css @@ -26,13 +26,14 @@ padding-left: 6px; font-weight: bold; text-transform: uppercase; - font-size: 10px; + font-size: 12px; color: var(--vscode-sideBarTitle-foreground); line-height: 24px; } .attachments-section:not(:first-child) { border-top: 1px solid var(--vscode-panel-border); + margin-top: 10px; } .attachment-item { diff --git a/packages/trace-viewer/src/ui/attachmentsTab.tsx b/packages/trace-viewer/src/ui/attachmentsTab.tsx index 22f63e3ba5..375f28cb65 100644 --- a/packages/trace-viewer/src/ui/attachmentsTab.tsx +++ b/packages/trace-viewer/src/ui/attachmentsTab.tsx @@ -23,6 +23,7 @@ import type { AfterActionTraceEventAttachment } from '@trace/trace'; import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; import { isTextualMimeType } from '@isomorphic/mimeType'; import { Expandable } from '@web/components/expandable'; +import { linkifyText } from '@web/renderUtils'; type Attachment = AfterActionTraceEventAttachment & { traceUrl: string }; @@ -36,6 +37,7 @@ const ExpandableAttachment: React.FunctionComponent = const [placeholder, setPlaceholder] = React.useState(null); const isTextAttachment = isTextualMimeType(attachment.contentType); + const hasContent = !!attachment.sha1 || !!attachment.path; React.useEffect(() => { if (expanded && attachmentText === null && placeholder === null) { @@ -49,11 +51,11 @@ const ExpandableAttachment: React.FunctionComponent = } }, [expanded, attachmentText, placeholder, attachment]); - const title = <> - {attachment.name} download - ; + const title = + {linkifyText(attachment.name)} {hasContent && download} + ; - if (!isTextAttachment) + if (!isTextAttachment || !hasContent) return
{title}
; return <> @@ -63,6 +65,8 @@ const ExpandableAttachment: React.FunctionComponent = {expanded && attachmentText !== null && } @@ -93,8 +97,8 @@ export const AttachmentsTab: React.FunctionComponent<{ const entry = diffMap.get(name) || { expected: undefined, actual: undefined, diff: undefined }; entry[type] = attachment; diffMap.set(name, entry); - } - if (attachment.contentType.startsWith('image/')) { + attachments.delete(attachment); + } else if (attachment.contentType.startsWith('image/')) { screenshots.add(attachment); attachments.delete(attachment); } @@ -109,11 +113,11 @@ export const AttachmentsTab: React.FunctionComponent<{ {[...diffMap.values()].map(({ expected, actual, diff }) => { return <> {expected && actual &&
Image diff
} - {expected && actual && } ; })} @@ -134,8 +138,19 @@ export const AttachmentsTab: React.FunctionComponent<{
; }; -function attachmentURL(attachment: Attachment) { - if (attachment.sha1) - return 'sha1/' + attachment.sha1 + '?trace=' + encodeURIComponent(attachment.traceUrl); - return 'file?path=' + encodeURIComponent(attachment.path!); +function attachmentURL(attachment: Attachment, queryParams: Record = {}) { + const params = new URLSearchParams(queryParams); + if (attachment.sha1) { + params.set('trace', attachment.traceUrl); + return 'sha1/' + attachment.sha1 + '?' + params.toString(); + } + params.set('path', attachment.path!); + return 'file?' + params.toString(); +} + +function downloadURL(attachment: Attachment) { + const params = { dn: attachment.name } as Record; + if (attachment.contentType) + params.dct = attachment.contentType; + return attachmentURL(attachment, params); } diff --git a/packages/trace-viewer/src/ui/callTab.tsx b/packages/trace-viewer/src/ui/callTab.tsx index b7cd7a8581..1ab3b5b46f 100644 --- a/packages/trace-viewer/src/ui/callTab.tsx +++ b/packages/trace-viewer/src/ui/callTab.tsx @@ -16,7 +16,7 @@ import type { SerializedValue } from '@protocol/channels'; import type { ActionTraceEvent } from '@trace/trace'; -import { msToString } from '@web/uiUtils'; +import { clsx, msToString } from '@web/uiUtils'; import * as React from 'react'; import './callTab.css'; import { CopyToClipboard } from './copyToClipboard'; @@ -71,7 +71,7 @@ function renderProperty(property: Property, key: string) { text = `"${text}"`; return (
- {property.name}:{text} + {property.name}:{text} { ['string', 'number', 'object', 'locator'].includes(property.type) && } diff --git a/packages/trace-viewer/src/ui/consoleTab.tsx b/packages/trace-viewer/src/ui/consoleTab.tsx index 882419ae38..a7b8318386 100644 --- a/packages/trace-viewer/src/ui/consoleTab.tsx +++ b/packages/trace-viewer/src/ui/consoleTab.tsx @@ -20,7 +20,7 @@ import './consoleTab.css'; import type * as modelUtil from './modelUtil'; import { ListView } from '@web/components/listView'; import type { Boundaries } from '../geometry'; -import { msToString } from '@web/uiUtils'; +import { clsx, msToString } from '@web/uiUtils'; import { ansi2html } from '@web/ansi2html'; import { PlaceholderPanel } from './placeholderPanel'; @@ -124,8 +124,8 @@ export const ConsoleTab: React.FunctionComponent<{ render={entry => { const timestamp = msToString(entry.timestamp - boundaries.minimum); const timestampElement = {timestamp}; - const errorSuffix = entry.isError ? ' status-error' : entry.isWarning ? ' status-warning' : ' status-none'; - const statusElement = entry.browserMessage || entry.browserError ? : ; + const errorSuffix = entry.isError ? 'status-error' : entry.isWarning ? 'status-warning' : 'status-none'; + const statusElement = entry.browserMessage || entry.browserError ? : ; let locationText: string | undefined; let messageBody: JSX.Element[] | string | undefined; let messageInnerHTML: string | undefined; diff --git a/packages/trace-viewer/src/ui/embeddedWorkbenchLoader.tsx b/packages/trace-viewer/src/ui/embeddedWorkbenchLoader.tsx index 17a2211c41..587f930a70 100644 --- a/packages/trace-viewer/src/ui/embeddedWorkbenchLoader.tsx +++ b/packages/trace-viewer/src/ui/embeddedWorkbenchLoader.tsx @@ -86,7 +86,7 @@ export const EmbeddedWorkbenchLoader: React.FunctionComponent = () => {
- + {!traceURLs.length &&
Select test to see the trace
} diff --git a/packages/trace-viewer/src/ui/metadataView.tsx b/packages/trace-viewer/src/ui/metadataView.tsx index 5b50a7f081..c1802a4b4d 100644 --- a/packages/trace-viewer/src/ui/metadataView.tsx +++ b/packages/trace-viewer/src/ui/metadataView.tsx @@ -24,7 +24,8 @@ export const MetadataView: React.FunctionComponent<{ }> = ({ model }) => { if (!model) return <>; - return
+ + return
Time
{!!model.wallTime &&
start time:{new Date(model.wallTime).toLocaleString()}
}
duration:{msToString(model.endTime - model.startTime)}
diff --git a/packages/trace-viewer/src/ui/modelUtil.ts b/packages/trace-viewer/src/ui/modelUtil.ts index 313ec700c7..c82cbc99a6 100644 --- a/packages/trace-viewer/src/ui/modelUtil.ts +++ b/packages/trace-viewer/src/ui/modelUtil.ts @@ -409,3 +409,30 @@ function collectSources(actions: trace.ActionTraceEvent[], errorDescriptors: Err } return result; } + +const kRouteMethods = new Set([ + 'page.route', + 'page.routefromhar', + 'page.unroute', + 'page.unrouteall', + 'browsercontext.route', + 'browsercontext.routefromhar', + 'browsercontext.unroute', + 'browsercontext.unrouteall', +]); +{ + // .NET adds async suffix. + for (const method of [...kRouteMethods]) + kRouteMethods.add(method + 'async'); + // Python methods which contain underscores. + for (const method of [ + 'page.route_from_har', + 'page.unroute_all', + 'context.route_from_har', + 'context.unroute_all', + ]) + kRouteMethods.add(method); +} +export function isRouteAction(action: ActionTraceEventInContext) { + return action.class === 'Route' || kRouteMethods.has(action.apiName.toLowerCase()); +} diff --git a/packages/trace-viewer/src/ui/networkFilters.css b/packages/trace-viewer/src/ui/networkFilters.css new file mode 100644 index 0000000000..836c7102c9 --- /dev/null +++ b/packages/trace-viewer/src/ui/networkFilters.css @@ -0,0 +1,46 @@ +/* + 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. +*/ + +.network-filters { + display: flex; + gap: 16px; + background-color: var(--vscode-sideBar-background); + padding: 4px 8px; + min-height: 32px; +} + +.network-filters input[type="search"] { + padding: 0 5px; +} + +.network-filters-resource-types { + display: flex; + gap: 8px; + align-items: center; +} + +.network-filters-resource-type { + cursor: pointer; + border-radius: 2px; + padding: 3px 8px; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; +} + +.network-filters-resource-type.selected { + background-color: var(--vscode-list-inactiveSelectionBackground); +} diff --git a/packages/trace-viewer/src/ui/networkFilters.tsx b/packages/trace-viewer/src/ui/networkFilters.tsx new file mode 100644 index 0000000000..de6c827e2b --- /dev/null +++ b/packages/trace-viewer/src/ui/networkFilters.tsx @@ -0,0 +1,57 @@ +/** + * 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 './networkFilters.css'; + +const resourceTypes = ['All', 'Fetch', 'HTML', 'JS', 'CSS', 'Font', 'Image'] as const; +export type ResourceType = typeof resourceTypes[number]; + +export type FilterState = { + searchValue: string; + resourceType: ResourceType; +}; + +export const defaultFilterState: FilterState = { searchValue: '', resourceType: 'All' }; + +export const NetworkFilters: React.FunctionComponent<{ + filterState: FilterState, + onFilterStateChange: (filterState: FilterState) => void, +}> = ({ filterState, onFilterStateChange }) => { + return ( +
+ onFilterStateChange({ ...filterState, searchValue: e.target.value })} + /> + +
+ {resourceTypes.map(resourceType => ( +
onFilterStateChange({ ...filterState, resourceType })} + className={`network-filters-resource-type ${filterState.resourceType === resourceType ? 'selected' : ''}`} + > + {resourceType} +
+ ))} +
+
+ ); +}; diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.css b/packages/trace-viewer/src/ui/networkResourceDetails.css index ce0648b8c2..0914d065bb 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.css +++ b/packages/trace-viewer/src/ui/networkResourceDetails.css @@ -61,3 +61,27 @@ .tab-network .tabbed-pane-tab.selected { font-weight: bold; } + +.green-circle::before, +.red-circle::before, +.yellow-circle::before { + content: ""; + display: inline-block; + width: 12px; + height: 12px; + border-radius: 6px; + margin-right: 2px; + align-self: center; +} + +.green-circle::before { + background-color: var(--vscode-charts-green); +} + +.red-circle::before { + background-color: var(--vscode-charts-red); +} + +.yellow-circle::before { + background-color: var(--vscode-charts-yellow); +} diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.tsx b/packages/trace-viewer/src/ui/networkResourceDetails.tsx index e7b1bad4b6..3a760c999f 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.tsx +++ b/packages/trace-viewer/src/ui/networkResourceDetails.tsx @@ -19,7 +19,6 @@ import * as React from 'react'; import './networkResourceDetails.css'; import { TabbedPane } from '@web/components/tabbedPane'; import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; -import type { Language } from '@web/components/codeMirrorWrapper'; import { ToolbarButton } from '@web/components/toolbarButton'; export const NetworkResourceDetails: React.FunctionComponent<{ @@ -55,19 +54,18 @@ export const NetworkResourceDetails: React.FunctionComponent<{ const RequestTab: React.FunctionComponent<{ resource: ResourceSnapshot; }> = ({ resource }) => { - const [requestBody, setRequestBody] = React.useState<{ text: string, language?: Language } | null>(null); + const [requestBody, setRequestBody] = React.useState<{ text: string, mimeType?: string } | null>(null); React.useEffect(() => { const readResources = async () => { if (resource.request.postData) { const requestContentTypeHeader = resource.request.headers.find(q => q.name === 'Content-Type'); const requestContentType = requestContentTypeHeader ? requestContentTypeHeader.value : ''; - const language = mimeTypeToHighlighter(requestContentType); if (resource.request.postData._sha1) { const response = await fetch(`sha1/${resource.request.postData._sha1}`); - setRequestBody({ text: formatBody(await response.text(), requestContentType), language }); + setRequestBody({ text: formatBody(await response.text(), requestContentType), mimeType: requestContentType }); } else { - setRequestBody({ text: formatBody(resource.request.postData.text, requestContentType), language }); + setRequestBody({ text: formatBody(resource.request.postData.text, requestContentType), mimeType: requestContentType }); } } else { setRequestBody(null); @@ -80,15 +78,14 @@ const RequestTab: React.FunctionComponent<{
General
{`URL: ${resource.request.url}`}
{`Method: ${resource.request.method}`}
-
{`Status Code: ${ - resource.response.status >= 200 && resource.response.status < 400 - ? `🟢 ${resource.response.status} ${resource.response.statusText}` - : `🔴 ${resource.response.status} ${resource.response.statusText}` - }`}
+ {resource.response.status !== -1 &&
+ Status Code: + {`${resource.response.status} ${resource.response.statusText}`} +
}
Request Headers
{resource.request.headers.map(pair => `${pair.name}: ${pair.value}`).join('\n')}
{requestBody &&
Request Body
} - {requestBody && } + {requestBody && }
; }; @@ -104,7 +101,7 @@ const ResponseTab: React.FunctionComponent<{ const BodyTab: React.FunctionComponent<{ resource: ResourceSnapshot; }> = ({ resource }) => { - const [responseBody, setResponseBody] = React.useState<{ dataUrl?: string, text?: string, language?: Language } | null>(null); + const [responseBody, setResponseBody] = React.useState<{ dataUrl?: string, text?: string, mimeType?: string } | null>(null); React.useEffect(() => { const readResources = async () => { @@ -119,9 +116,10 @@ const BodyTab: React.FunctionComponent<{ setResponseBody({ dataUrl: (await eventPromise).target.result }); } else { const formattedBody = formatBody(await response.text(), resource.response.content.mimeType); - const language = mimeTypeToHighlighter(resource.response.content.mimeType); - setResponseBody({ text: formattedBody, language }); + setResponseBody({ text: formattedBody, mimeType: resource.response.content.mimeType }); } + } else { + setResponseBody(null); } }; @@ -131,10 +129,18 @@ const BodyTab: React.FunctionComponent<{ return
{!resource.response.content._sha1 &&
Response body is not available for this request.
} {responseBody && responseBody.dataUrl && } - {responseBody && responseBody.text && } + {responseBody && responseBody.text && }
; }; +function statusClass(statusCode: number): string { + if (statusCode < 300 || statusCode === 304) + return 'green-circle'; + if (statusCode < 400) + return 'yellow-circle'; + return 'red-circle'; +} + function formatBody(body: string | null, contentType: string): string { if (body === null) return 'Loading...'; @@ -156,12 +162,3 @@ function formatBody(body: string | null, contentType: string): string { return bodyStr; } - -function mimeTypeToHighlighter(mimeType: string): Language | undefined { - if (mimeType.includes('javascript') || mimeType.includes('json')) - return 'javascript'; - if (mimeType.includes('html')) - return 'html'; - if (mimeType.includes('css')) - return 'css'; -} diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index 311af733a9..207dd33547 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -25,6 +25,7 @@ import { context, type MultiTraceModel } from './modelUtil'; import { GridView, type RenderedGridCell } from '@web/components/gridView'; import { SplitView } from '@web/components/splitView'; import type { ContextEntry } from '../entries'; +import { NetworkFilters, defaultFilterState, type FilterState, type ResourceType } from './networkFilters'; type NetworkTabModel = { resources: Entry[], @@ -68,18 +69,24 @@ export const NetworkTab: React.FunctionComponent<{ }> = ({ boundaries, networkModel, onEntryHovered }) => { const [sorting, setSorting] = React.useState(undefined); const [selectedEntry, setSelectedEntry] = React.useState(undefined); + const [filterState, setFilterState] = React.useState(defaultFilterState); const { renderedEntries } = React.useMemo(() => { - const renderedEntries = networkModel.resources.map(entry => renderEntry(entry, boundaries, networkModel.contextIdMap)); + const renderedEntries = networkModel.resources.map(entry => renderEntry(entry, boundaries, networkModel.contextIdMap)).filter(filterEntry(filterState)); if (sorting) sort(renderedEntries, sorting); return { renderedEntries }; - }, [networkModel.resources, networkModel.contextIdMap, sorting, boundaries]); + }, [networkModel.resources, networkModel.contextIdMap, filterState, sorting, boundaries]); const [columnWidths, setColumnWidths] = React.useState>(() => { return new Map(allColumns().map(column => [column, columnWidth(column)])); }); + const onFilterStateChange = React.useCallback((newFilterState: FilterState) => { + setFilterState(newFilterState); + setSelectedEntry(undefined); + }, []); + if (!networkModel.resources.length) return ; @@ -93,18 +100,24 @@ export const NetworkTab: React.FunctionComponent<{ columnTitle={columnTitle} columnWidths={columnWidths} setColumnWidths={setColumnWidths} - isError={item => item.status.code >= 400} + isError={item => item.status.code >= 400 || item.status.code === -1} isInfo={item => !!item.route} render={(item, column) => renderCell(item, column)} sorting={sorting} setSorting={setSorting} />; return <> + {!selectedEntry && grid} - {selectedEntry && - setSelectedEntry(undefined)} /> - {grid} - } + {selectedEntry && + setSelectedEntry(undefined)} />} + sidebar={grid} + />} ; }; @@ -340,3 +353,21 @@ function comparator(sortBy: ColumnName) { if (sortBy === 'contextId') return (a: RenderedEntry, b: RenderedEntry) => a.contextId.localeCompare(b.contextId); } + +const resourceTypePredicates: Record boolean> = { + 'All': () => true, + 'Fetch': contentType => contentType === 'application/json', + 'HTML': contentType => contentType === 'text/html', + 'CSS': contentType => contentType === 'text/css', + 'JS': contentType => contentType.includes('javascript'), + 'Font': contentType => contentType.includes('font'), + 'Image': contentType => contentType.includes('image'), +}; + +function filterEntry({ searchValue, resourceType }: FilterState) { + return (entry: RenderedEntry) => { + const typePredicate = resourceTypePredicates[resourceType]; + + return typePredicate(entry.contentType) && entry.name.url.toLowerCase().includes(searchValue.toLowerCase()); + }; +} diff --git a/packages/trace-viewer/src/ui/snapshotTab.tsx b/packages/trace-viewer/src/ui/snapshotTab.tsx index a5ced023fd..798025cbbd 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.tsx +++ b/packages/trace-viewer/src/ui/snapshotTab.tsx @@ -20,7 +20,7 @@ import type { ActionTraceEvent } from '@trace/trace'; import { context, prevInList } from './modelUtil'; import { Toolbar } from '@web/components/toolbar'; import { ToolbarButton } from '@web/components/toolbarButton'; -import { useMeasure } from '@web/uiUtils'; +import { clsx, useMeasure } from '@web/uiUtils'; import { InjectedScript } from '@injected/injectedScript'; import { Recorder } from '@injected/recorder/recorder'; import ConsoleAPI from '@injected/consoleApi'; @@ -209,8 +209,8 @@ export const SnapshotTab: React.FunctionComponent<{ }}>
- - + +
diff --git a/packages/trace-viewer/src/ui/sourceTab.tsx b/packages/trace-viewer/src/ui/sourceTab.tsx index 27d64146ec..cfa6e2ccd4 100644 --- a/packages/trace-viewer/src/ui/sourceTab.tsx +++ b/packages/trace-viewer/src/ui/sourceTab.tsx @@ -96,17 +96,20 @@ export const SourceTab: React.FunctionComponent<{ const showStackFrames = (stack?.length ?? 0) > 1; - return -
+ return { fileName && {fileName} - + {location && } } -
- -
; +
} + sidebar={} + />; }; export async function calculateSha1(text: string): Promise { @@ -121,10 +124,9 @@ export async function calculateSha1(text: string): Promise { return hexCodes.join(''); } -function getFileName(fullPath?: string, lineNum?: number): string { +function getFileName(fullPath?: string): string { if (!fullPath) return ''; const pathSep = fullPath?.includes('/') ? '/' : '\\'; - const fileName = fullPath?.split(pathSep).pop() ?? ''; - return lineNum ? `${fileName}:${lineNum}` : fileName; + return fullPath?.split(pathSep).pop() ?? ''; } diff --git a/packages/trace-viewer/src/ui/tag.tsx b/packages/trace-viewer/src/ui/tag.tsx index 29ba1546ba..63a5b32217 100644 --- a/packages/trace-viewer/src/ui/tag.tsx +++ b/packages/trace-viewer/src/ui/tag.tsx @@ -14,11 +14,12 @@ * limitations under the License. */ +import { clsx } from '@web/uiUtils'; import './tag.css'; export const TagView: React.FC<{ tag: string, style?: React.CSSProperties, onClick?: (e: React.MouseEvent) => void }> = ({ tag, style, onClick }) => { return { bars.map((bar, index) => { return
, item: { treeItem?: TreeItem, testFile?: SourceLocation, testCase?: reporterTypes.TestCase }, rootDir?: string, onOpenExternally?: (location: SourceLocation) => void, revealSource?: boolean, -}> = ({ showRouteActionsSetting, item, rootDir, onOpenExternally, revealSource }) => { +}> = ({ item, rootDir, onOpenExternally, revealSource }) => { const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>(); const [counter, setCounter] = React.useState(0); const pollTimer = React.useRef(null); @@ -91,7 +89,6 @@ export const TraceView: React.FC<{ return ; diff --git a/packages/trace-viewer/src/ui/uiModeView.css b/packages/trace-viewer/src/ui/uiModeView.css index f7463aaaac..de89b911c3 100644 --- a/packages/trace-viewer/src/ui/uiModeView.css +++ b/packages/trace-viewer/src/ui/uiModeView.css @@ -24,7 +24,7 @@ } .ui-mode-sidebar > .settings-view { - margin: 0 0 3px 23px; + margin: 0 0 8px 23px; } .ui-mode-sidebar input[type=search] { diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 7c1ed69905..244a25ca5e 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -30,7 +30,7 @@ import { Toolbar } from '@web/components/toolbar'; import type { XtermDataSource } from '@web/components/xtermWrapper'; import { XtermWrapper } from '@web/components/xtermWrapper'; import { useDarkModeSetting } from '@web/theme'; -import { settings, useSetting } from '@web/uiUtils'; +import { clsx, settings, useSetting } from '@web/uiUtils'; import { statusEx, TestTree } from '@testIsomorphic/testTree'; import type { TreeItem } from '@testIsomorphic/testTree'; import { TestServerConnection } from '@testIsomorphic/testServerConnection'; @@ -94,10 +94,12 @@ export const UIModeView: React.FC<{}> = ({ const [isDisconnected, setIsDisconnected] = React.useState(false); const [hasBrowsers, setHasBrowsers] = React.useState(true); const [testServerConnection, setTestServerConnection] = React.useState(); + const [teleSuiteUpdater, setTeleSuiteUpdater] = React.useState(); const [settingsVisible, setSettingsVisible] = React.useState(false); const [testingOptionsVisible, setTestingOptionsVisible] = React.useState(false); const [revealSource, setRevealSource] = React.useState(false); const onRevealSource = React.useCallback(() => setRevealSource(true), [setRevealSource]); + const showTestingOptions = false; const [runWorkers, setRunWorkers] = React.useState(queryParams.workers); const singleWorkerSetting = React.useMemo(() => { @@ -191,20 +193,7 @@ export const UIModeView: React.FC<{}> = ({ pathSeparator, }); - const updateList = async () => { - commandQueue.current = commandQueue.current.then(async () => { - setIsLoading(true); - try { - const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert }); - teleSuiteUpdater.processListReport(result.report); - } catch (e) { - // eslint-disable-next-line no-console - console.log(e); - } finally { - setIsLoading(false); - } - }); - }; + setTeleSuiteUpdater(teleSuiteUpdater); setTestModel(undefined); setIsLoading(true); @@ -223,7 +212,6 @@ export const UIModeView: React.FC<{}> = ({ const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert }); teleSuiteUpdater.processListReport(result.report); - testServerConnection.onListChanged(updateList); testServerConnection.onReport(params => { teleSuiteUpdater.processTestReportEvent(params); }); @@ -336,11 +324,32 @@ export const UIModeView: React.FC<{}> = ({ }); }, [projectFilters, runningState, testModel, testServerConnection, runWorkers, runHeaded, runUpdateSnapshots]); - // Watch implementation. React.useEffect(() => { - if (!testServerConnection) + if (!testServerConnection || !teleSuiteUpdater) return; - const disposable = testServerConnection.onTestFilesChanged(params => { + const disposable = testServerConnection.onTestFilesChanged(async params => { + // fetch the new list of tests + commandQueue.current = commandQueue.current.then(async () => { + setIsLoading(true); + try { + const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert }); + teleSuiteUpdater.processListReport(result.report); + } catch (e) { + // eslint-disable-next-line no-console + console.log(e); + } finally { + setIsLoading(false); + } + }); + await commandQueue.current; + + if (params.testFiles.length === 0) + return; + + // run affected watched tests + const testModel = teleSuiteUpdater.asModel(); + const testTree = new TestTree('', testModel.rootSuite, testModel.loadErrors, projectFilters, pathSeparator); + const testIds: string[] = []; const set = new Set(params.testFiles); if (watchAll) { @@ -363,7 +372,7 @@ export const UIModeView: React.FC<{}> = ({ runTests('queue-if-busy', new Set(testIds)); }); return () => disposable.dispose(); - }, [runTests, testServerConnection, testTree, watchAll, watchedTreeIds]); + }, [runTests, testServerConnection, watchAll, watchedTreeIds, teleSuiteUpdater, projectFilters]); // Shortcuts. React.useEffect(() => { @@ -425,9 +434,14 @@ export const UIModeView: React.FC<{}> = ({
UI Mode disconnected
} - -
-
+ +
Output
xtermDataSource.clear()}> @@ -436,17 +450,16 @@ export const UIModeView: React.FC<{}> = ({
-
+
testServerConnection?.openNoReply({ location: { file: location.file, line: location.line, column: location.column } })} />
-
-
+
} + sidebar={
Playwright logo
Playwright
@@ -497,19 +510,21 @@ export const UIModeView: React.FC<{}> = ({ setFilterText={setFilterText} onRevealSource={onRevealSource} /> - setTestingOptionsVisible(!testingOptionsVisible)}> - -
Testing Options
-
- {testingOptionsVisible && } + {showTestingOptions && <> + setTestingOptionsVisible(!testingOptionsVisible)}> + +
Testing Options
+
+ {testingOptionsVisible && } + } setSettingsVisible(!settingsVisible)}> = ({ showRouteActionsSetting, ]} />}
-
+ } + />
; }; diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index cebad810cf..c2e9075480 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -23,7 +23,7 @@ import { ErrorsTab, useErrorsTabModel } from './errorsTab'; import type { ConsoleEntry } from './consoleTab'; import { ConsoleTab, useConsoleTabModel } from './consoleTab'; import type * as modelUtil from './modelUtil'; -import type { ActionTraceEventInContext, MultiTraceModel } from './modelUtil'; +import { isRouteAction } from './modelUtil'; import type { StackFrame } from '@protocol/channels'; import { NetworkTab, useNetworkTabModel } from './networkTab'; import { SnapshotTab } from './snapshotTab'; @@ -33,10 +33,11 @@ import type { TabbedPaneTabModel } from '@web/components/tabbedPane'; import { Timeline } from './timeline'; import { MetadataView } from './metadataView'; import { AttachmentsTab } from './attachmentsTab'; +import { AnnotationsTab } from './annotationsTab'; import type { Boundaries } from '../geometry'; import { InspectorTab } from './inspectorTab'; import { ToolbarButton } from '@web/components/toolbarButton'; -import { useSetting, msToString, type Setting } from '@web/uiUtils'; +import { useSetting, msToString, clsx } from '@web/uiUtils'; import type { Entry } from '@trace/har'; import './workbench.css'; import { testStatusIcon, testStatusText } from './testUtils'; @@ -44,23 +45,24 @@ import type { UITestStatus } from './testUtils'; import { SettingsView } from './settingsView'; export const Workbench: React.FunctionComponent<{ - model?: MultiTraceModel, + model?: modelUtil.MultiTraceModel, showSourcesFirst?: boolean, rootDir?: string, fallbackLocation?: modelUtil.SourceLocation, - initialSelection?: ActionTraceEventInContext, - onSelectionChanged?: (action: ActionTraceEventInContext) => void, + initialSelection?: modelUtil.ActionTraceEventInContext, + onSelectionChanged?: (action: modelUtil.ActionTraceEventInContext) => void, isLive?: boolean, status?: UITestStatus, + annotations?: { type: string; description?: string; }[]; inert?: boolean, - showRouteActionsSetting?: Setting, openPage?: (url: string, target?: string) => Window | any, onOpenExternally?: (location: modelUtil.SourceLocation) => void, revealSource?: boolean, -}> = ({ showRouteActionsSetting, model, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, status, inert, openPage, onOpenExternally, revealSource }) => { - const [selectedAction, setSelectedActionImpl] = React.useState(undefined); + showSettings?: boolean, +}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => { + const [selectedAction, setSelectedActionImpl] = React.useState(undefined); const [revealedStack, setRevealedStack] = React.useState(undefined); - const [highlightedAction, setHighlightedAction] = React.useState(); + const [highlightedAction, setHighlightedAction] = React.useState(); const [highlightedEntry, setHighlightedEntry] = React.useState(); const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState(); const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState('actions'); @@ -70,17 +72,13 @@ export const Workbench: React.FunctionComponent<{ const activeAction = model ? highlightedAction || selectedAction : undefined; const [selectedTime, setSelectedTime] = React.useState(); const [sidebarLocation, setSidebarLocation] = useSetting<'bottom' | 'right'>('propertiesSidebarLocation', 'bottom'); - const [, , showRouteActionsSettingInternal] = useSetting(showRouteActionsSetting ? undefined : 'show-route-actions', true, 'Show route actions'); - - const showSettings = !showRouteActionsSetting; - showRouteActionsSetting ||= showRouteActionsSettingInternal; - const showRouteActions = showRouteActionsSetting[0]; + const [showRouteActions, , showRouteActionsSetting] = useSetting('show-route-actions', true, 'Show route actions'); const filteredActions = React.useMemo(() => { - return (model?.actions || []).filter(action => showRouteActions || action.class !== 'Route'); + return (model?.actions || []).filter(action => showRouteActions || !isRouteAction(action)); }, [model, showRouteActions]); - const setSelectedAction = React.useCallback((action: ActionTraceEventInContext | undefined) => { + const setSelectedAction = React.useCallback((action: modelUtil.ActionTraceEventInContext | undefined) => { setSelectedActionImpl(action); setRevealedStack(action?.stack); }, [setSelectedActionImpl, setRevealedStack]); @@ -113,7 +111,7 @@ export const Workbench: React.FunctionComponent<{ } }, [model, selectedAction, setSelectedAction, initialSelection]); - const onActionSelected = React.useCallback((action: ActionTraceEventInContext) => { + const onActionSelected = React.useCallback((action: modelUtil.ActionTraceEventInContext) => { setSelectedAction(action); onSelectionChanged?.(action); }, [setSelectedAction, onSelectionChanged]); @@ -227,6 +225,17 @@ export const Workbench: React.FunctionComponent<{ sourceTab, attachmentsTab, ]; + + if (annotations !== undefined) { + const annotationsTab: TabbedPaneTabModel = { + id: 'annotations', + title: 'Annotations', + count: annotations.length, + render: () => + }; + tabs.push(annotationsTab); + } + if (showSourcesFirst) { const sourceTabIndex = tabs.indexOf(sourceTab); tabs.splice(sourceTabIndex, 1); @@ -255,7 +264,7 @@ export const Workbench: React.FunctionComponent<{ title: 'Actions', component:
{status &&
- +
{testStatusText(status)}
{time ? msToString(time) : ''}
@@ -297,9 +306,15 @@ export const Workbench: React.FunctionComponent<{ selectedTime={selectedTime} setSelectedTime={setSelectedTime} /> - - - - - - } + sidebar={ + + } + />} + sidebar={ ]} mode={sidebarLocation === 'bottom' ? 'default' : 'select'} - /> - + />} + />
; }; diff --git a/packages/trace-viewer/src/ui/workbenchLoader.tsx b/packages/trace-viewer/src/ui/workbenchLoader.tsx index 0260df0d7a..e7f94e969a 100644 --- a/packages/trace-viewer/src/ui/workbenchLoader.tsx +++ b/packages/trace-viewer/src/ui/workbenchLoader.tsx @@ -165,7 +165,7 @@ export const WorkbenchLoader: React.FunctionComponent<{
- + {fileForLocalModeError &&
Trace Viewer uses Service Workers to show traces. To view trace:
diff --git a/packages/trace-viewer/src/uiMode.tsx b/packages/trace-viewer/src/uiMode.tsx index 8e9e286e00..f2f846a112 100644 --- a/packages/trace-viewer/src/uiMode.tsx +++ b/packages/trace-viewer/src/uiMode.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import React from 'react'; import '@web/common.css'; import { applyTheme } from '@web/theme'; import '@web/third_party/vscode/codicon.css'; diff --git a/packages/web/src/assets/playwright-logo.svg b/packages/web/src/assets/playwright-logo.svg new file mode 100644 index 0000000000..7b3ca7d6c9 --- /dev/null +++ b/packages/web/src/assets/playwright-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/web/src/components/codeMirrorModule.tsx b/packages/web/src/components/codeMirrorModule.tsx index 56686c45b6..4376e0a18f 100644 --- a/packages/web/src/components/codeMirrorModule.tsx +++ b/packages/web/src/components/codeMirrorModule.tsx @@ -23,6 +23,8 @@ import 'codemirror-shadow-1/mode/htmlmixed/htmlmixed'; import 'codemirror-shadow-1/mode/javascript/javascript'; import 'codemirror-shadow-1/mode/python/python'; import 'codemirror-shadow-1/mode/clike/clike'; +import 'codemirror-shadow-1/mode/markdown/markdown'; +import 'codemirror-shadow-1/addon/mode/simple'; export type CodeMirror = typeof codemirrorType; export default codemirror; diff --git a/packages/web/src/components/codeMirrorWrapper.css b/packages/web/src/components/codeMirrorWrapper.css index 1e79c18274..6988ab6506 100644 --- a/packages/web/src/components/codeMirrorWrapper.css +++ b/packages/web/src/components/codeMirrorWrapper.css @@ -174,3 +174,9 @@ body.dark-mode .CodeMirror span.cm-type { margin: 3px 10px; padding: 5px; } + +.CodeMirror span.cm-link, span.cm-linkified { + color: var(--vscode-textLink-foreground); + text-decoration: underline; + cursor: pointer; +} diff --git a/packages/web/src/components/codeMirrorWrapper.spec.tsx b/packages/web/src/components/codeMirrorWrapper.spec.tsx index 3fb630ae5d..a09a15f9ad 100644 --- a/packages/web/src/components/codeMirrorWrapper.spec.tsx +++ b/packages/web/src/components/codeMirrorWrapper.spec.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import React from 'react'; import { expect, test } from '@playwright/experimental-ct-react'; import { CodeMirrorWrapper } from './codeMirrorWrapper'; diff --git a/packages/web/src/components/codeMirrorWrapper.tsx b/packages/web/src/components/codeMirrorWrapper.tsx index 738f0cfff1..f8180f2e25 100644 --- a/packages/web/src/components/codeMirrorWrapper.tsx +++ b/packages/web/src/components/codeMirrorWrapper.tsx @@ -18,7 +18,7 @@ import './codeMirrorWrapper.css'; import * as React from 'react'; import type { CodeMirror } from './codeMirrorModule'; import { ansi2html } from '../ansi2html'; -import { useMeasure } from '../uiUtils'; +import { useMeasure, kWebLinkRe } from '../uiUtils'; export type SourceHighlight = { line: number; @@ -26,11 +26,13 @@ export type SourceHighlight = { message?: string; }; -export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl' | 'html' | 'css'; +export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl' | 'html' | 'css' | 'markdown'; export interface SourceProps { text: string; language?: Language; + mimeType?: string; + linkify?: boolean; readOnly?: boolean; // 1-based highlight?: SourceHighlight[]; @@ -45,6 +47,8 @@ export interface SourceProps { export const CodeMirrorWrapper: React.FC = ({ text, language, + mimeType, + linkify, readOnly, highlight, revealLine, @@ -63,24 +67,13 @@ export const CodeMirrorWrapper: React.FC = ({ (async () => { // Always load the module first. const CodeMirror = await modulePromise; + defineCustomMode(CodeMirror); const element = codemirrorElement.current; if (!element) return; - let mode = ''; - if (language === 'javascript') - mode = 'javascript'; - if (language === 'python') - mode = 'python'; - if (language === 'java') - mode = 'text/x-java'; - if (language === 'csharp') - mode = 'text/x-csharp'; - if (language === 'html') - mode = 'htmlmixed'; - if (language === 'css') - mode = 'css'; + const mode = languageToMode(language) || mimeTypeToMode(mimeType) || (linkify ? 'text/linkified' : ''); if (codemirrorRef.current && mode === codemirrorRef.current.cm.getOption('mode') @@ -106,7 +99,7 @@ export const CodeMirrorWrapper: React.FC = ({ setCodemirror(cm); return cm; })(); - }, [modulePromise, codemirror, codemirrorElement, language, lineNumbers, wrapLines, readOnly, isFocused]); + }, [modulePromise, codemirror, codemirrorElement, language, mimeType, linkify, lineNumbers, wrapLines, readOnly, isFocused]); React.useEffect(() => { if (codemirrorRef.current) @@ -175,5 +168,69 @@ export const CodeMirrorWrapper: React.FC = ({ }; }, [codemirror, text, highlight, revealLine, focusOnChange, onChange]); - return
; + return
; }; + +function onCodeMirrorClick(event: React.MouseEvent) { + if (!(event.target instanceof HTMLElement)) + return; + let url: string | undefined; + if (event.target.classList.contains('cm-linkified')) { + // 'text/linkified' custom mode + url = event.target.textContent!; + } else if (event.target.classList.contains('cm-link') && event.target.nextElementSibling?.classList.contains('cm-url')) { + // 'markdown' mode + url = event.target.nextElementSibling.textContent!.slice(1, -1); + } + if (url) { + event.preventDefault(); + event.stopPropagation(); + window.open(url, '_blank'); + } +} + +let customModeDefined = false; +function defineCustomMode(cm: CodeMirror) { + if (customModeDefined) + return; + customModeDefined = true; + (cm as any).defineSimpleMode('text/linkified', { + start: [ + { regex: kWebLinkRe, token: 'linkified' }, + ], + }); +} + +function mimeTypeToMode(mimeType: string | undefined): string | undefined { + if (!mimeType) + return; + if (mimeType.includes('javascript') || mimeType.includes('json')) + return 'javascript'; + if (mimeType.includes('python')) + return 'python'; + if (mimeType.includes('csharp')) + return 'text/x-csharp'; + if (mimeType.includes('java')) + return 'text/x-java'; + if (mimeType.includes('markdown')) + return 'markdown'; + if (mimeType.includes('html') || mimeType.includes('svg')) + return 'htmlmixed'; + if (mimeType.includes('css')) + return 'css'; +} + +function languageToMode(language: Language | undefined): string | undefined { + if (!language) + return; + return { + javascript: 'javascript', + jsonl: 'javascript', + python: 'python', + csharp: 'text/x-csharp', + java: 'text/x-java', + markdown: 'markdown', + html: 'htmlmixed', + css: 'css', + }[language]; +} diff --git a/packages/web/src/components/expandable.spec.tsx b/packages/web/src/components/expandable.spec.tsx index ad69f33aed..9536004bec 100644 --- a/packages/web/src/components/expandable.spec.tsx +++ b/packages/web/src/components/expandable.spec.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import React from 'react'; import { expect, test } from '@playwright/experimental-ct-react'; import { Expandable } from './expandable'; diff --git a/packages/web/src/components/expandable.tsx b/packages/web/src/components/expandable.tsx index cb3a9b2254..01e064373f 100644 --- a/packages/web/src/components/expandable.tsx +++ b/packages/web/src/components/expandable.tsx @@ -16,6 +16,7 @@ import * as React from 'react'; import './expandable.css'; +import { clsx } from '../uiUtils'; export const Expandable: React.FunctionComponent> = ({ title, children, setExpanded, expanded, expandOnTitleClick }) => { - return
+ return
expandOnTitleClick && setExpanded(!expanded)}>
!expandOnTitleClick && setExpanded(!expanded)} /> {title} diff --git a/packages/web/src/components/splitView.spec.tsx b/packages/web/src/components/splitView.spec.tsx index 605205059e..a9260a2d48 100644 --- a/packages/web/src/components/splitView.spec.tsx +++ b/packages/web/src/components/splitView.spec.tsx @@ -14,17 +14,18 @@ * limitations under the License. */ -import React from 'react'; import { expect, test } from '@playwright/experimental-ct-react'; import { SplitView } from './splitView'; test.use({ viewport: { width: 500, height: 500 } }); test('should render', async ({ mount }) => { - const component = await mount( -
main
- -
); + const component = await mount( + main
} + sidebar={} + />); const mainBox = await component.locator('#main').boundingBox(); const sidebarBox = await component.locator('#sidebar').boundingBox(); expect.soft(mainBox).toEqual({ x: 0, y: 0, width: 500, height: 400 }); @@ -32,10 +33,13 @@ test('should render', async ({ mount }) => { }); test('should render sidebar first', async ({ mount }) => { - const component = await mount( -
main
- -
); + const component = await mount( + main
} + sidebar={} + />); const mainBox = await component.locator('#main').boundingBox(); const sidebarBox = await component.locator('#sidebar').boundingBox(); expect.soft(mainBox).toEqual({ x: 0, y: 100, width: 500, height: 400 }); @@ -43,10 +47,14 @@ test('should render sidebar first', async ({ mount }) => { }); test('should render horizontal split', async ({ mount }) => { - const component = await mount( -
main
- -
); + const component = await mount( + main
} + sidebar={} + />); const mainBox = await component.locator('#main').boundingBox(); const sidebarBox = await component.locator('#sidebar').boundingBox(); expect.soft(mainBox).toEqual({ x: 100, y: 0, width: 400, height: 500 }); @@ -54,19 +62,25 @@ test('should render horizontal split', async ({ mount }) => { }); test('should hide sidebar', async ({ mount }) => { - const component = await mount( -
main
- -
); + const component = await mount( + main
} + sidebar={} + />); const mainBox = await component.locator('#main').boundingBox(); expect.soft(mainBox).toEqual({ x: 0, y: 0, width: 500, height: 500 }); }); test('drag resize', async ({ page, mount }) => { - const component = await mount( -
main
- -
); + const component = await mount( + main
} + sidebar={} + />); await page.mouse.move(25, 400); await page.mouse.down(); await page.mouse.move(25, 100); diff --git a/packages/web/src/components/splitView.tsx b/packages/web/src/components/splitView.tsx index a252996e73..d2d0fbbd0f 100644 --- a/packages/web/src/components/splitView.tsx +++ b/packages/web/src/components/splitView.tsx @@ -14,7 +14,7 @@ limitations under the License. */ -import { useMeasure, useSetting } from '../uiUtils'; +import { clsx, useMeasure, useSetting } from '../uiUtils'; import './splitView.css'; import * as React from 'react'; @@ -25,21 +25,27 @@ export type SplitViewProps = { orientation?: 'vertical' | 'horizontal'; minSidebarSize?: number; settingName?: string; + + sidebar: React.ReactNode; + main: React.ReactNode; }; const kMinSize = 50; -export const SplitView: React.FC> = ({ +export const SplitView: React.FC = ({ sidebarSize, sidebarHidden = false, sidebarIsFirst = false, orientation = 'vertical', minSidebarSize = kMinSize, settingName, - children + sidebar, + main, }) => { - const [hSize, setHSize] = useSetting(settingName ? settingName + '.' + orientation + ':size' : undefined, Math.max(minSidebarSize, sidebarSize) * window.devicePixelRatio); - const [vSize, setVSize] = useSetting(settingName ? settingName + '.' + orientation + ':size' : undefined, Math.max(minSidebarSize, sidebarSize) * window.devicePixelRatio); + const defaultSize = Math.max(minSidebarSize, sidebarSize) * window.devicePixelRatio; + const [hSize, setHSize] = useSetting(settingName ? settingName + '.' + orientation + ':size' : undefined, defaultSize); + const [vSize, setVSize] = useSetting(settingName ? settingName + '.' + orientation + ':size' : undefined, defaultSize); + const [resizing, setResizing] = React.useState<{ offset: number, size: number } | null>(null); const [measure, ref] = useMeasure(); @@ -54,7 +60,6 @@ export const SplitView: React.FC> = ({ size = measure.width - 10; } - const childrenArray = React.Children.toArray(children); document.body.style.userSelect = resizing ? 'none' : 'inherit'; let resizerStyle: any = {}; if (orientation === 'vertical') { @@ -69,10 +74,10 @@ export const SplitView: React.FC> = ({ resizerStyle = { right: resizing ? 0 : size - 4, left: resizing ? 0 : undefined, width: resizing ? 'initial' : 8 }; } - return
-
{childrenArray[0]}
- { !sidebarHidden &&
{childrenArray[1]}
} - { !sidebarHidden &&
+
{main}
+ {!sidebarHidden &&
{sidebar}
} + {!sidebarHidden &&
setResizing({ offset: orientation === 'vertical' ? event.clientY : event.clientX, size })} @@ -94,6 +99,6 @@ export const SplitView: React.FC> = ({ setHSize(size * window.devicePixelRatio); } }} - >
} + >
}
; }; diff --git a/packages/web/src/components/tabbedPane.tsx b/packages/web/src/components/tabbedPane.tsx index d8c557f4c3..2f5208a673 100644 --- a/packages/web/src/components/tabbedPane.tsx +++ b/packages/web/src/components/tabbedPane.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import { clsx } from '@web/uiUtils'; import './tabbedPane.css'; import { Toolbar } from './toolbar'; import * as React from 'react'; @@ -62,14 +63,10 @@ export const TabbedPane: React.FunctionComponent<{ }}> {tabs.map(tab => { let suffix = ''; - if (tab.count === 1) - suffix = ' 🔵'; - else if (tab.count) - suffix = ` 🔵✖️${tab.count}`; - if (tab.errorCount === 1) - suffix = ` 🔴`; - else if (tab.errorCount) - suffix = ` 🔴✖️${tab.errorCount}`; + if (tab.count) + suffix = ` (${tab.count})`; + if (tab.errorCount) + suffix = ` (${tab.errorCount})`; return ; })} @@ -99,7 +96,7 @@ export const TabbedPaneTab: React.FunctionComponent<{ selected?: boolean, onSelect: (id: string) => void }> = ({ id, title, count, errorCount, selected, onSelect }) => { - return
onSelect(id)} title={title} key={id}> diff --git a/packages/web/src/components/toolbar.tsx b/packages/web/src/components/toolbar.tsx index 023fad0232..c7dd6843d9 100644 --- a/packages/web/src/components/toolbar.tsx +++ b/packages/web/src/components/toolbar.tsx @@ -14,6 +14,7 @@ limitations under the License. */ +import { clsx } from '@web/uiUtils'; import './toolbar.css'; import * as React from 'react'; @@ -31,5 +32,5 @@ export const Toolbar: React.FC> = ({ className, onClick, }) => { - return
{children}
; + return
{children}
; }; diff --git a/packages/html-reporter/src/renderUtils.tsx b/packages/web/src/renderUtils.tsx similarity index 82% rename from packages/html-reporter/src/renderUtils.tsx rename to packages/web/src/renderUtils.tsx index e8d92dc609..71deb5af53 100644 --- a/packages/html-reporter/src/renderUtils.tsx +++ b/packages/web/src/renderUtils.tsx @@ -14,15 +14,14 @@ * limitations under the License. */ -export function linkifyText(description: string) { - const CONTROL_CODES = '\\u0000-\\u0020\\u007f-\\u009f'; - const WEB_LINK_REGEX = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|www\\.)[^\\s' + CONTROL_CODES + '"]{2,}[^\\s' + CONTROL_CODES + '"\')}\\],:;.!?]', 'ug'); +import { kWebLinkRe } from './uiUtils'; +export function linkifyText(description: string) { const result = []; let currentIndex = 0; let match; - while ((match = WEB_LINK_REGEX.exec(description)) !== null) { + while ((match = kWebLinkRe.exec(description)) !== null) { const stringBeforeMatch = description.substring(currentIndex, match.index); if (stringBeforeMatch) result.push(stringBeforeMatch); diff --git a/packages/web/src/shared/imageDiffView.spec.tsx b/packages/web/src/shared/imageDiffView.spec.tsx index 79d256096d..4fefaa1083 100644 --- a/packages/web/src/shared/imageDiffView.spec.tsx +++ b/packages/web/src/shared/imageDiffView.spec.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import React from 'react'; import { test, expect } from '@playwright/experimental-ct-react'; import type { ImageDiff } from './imageDiffView'; import { ImageDiffView } from './imageDiffView'; diff --git a/packages/web/src/shared/imageDiffView.tsx b/packages/web/src/shared/imageDiffView.tsx index f6721876f9..9525b811a6 100644 --- a/packages/web/src/shared/imageDiffView.tsx +++ b/packages/web/src/shared/imageDiffView.tsx @@ -59,8 +59,9 @@ const checkerboardStyle: React.CSSProperties = { }; export const ImageDiffView: React.FC<{ - diff: ImageDiff, -}> = ({ diff }) => { + diff: ImageDiff, + noTargetBlank?: boolean, +}> = ({ diff, noTargetBlank }) => { const [mode, setMode] = React.useState<'diff' | 'actual' | 'expected' | 'slider' | 'sxs'>(diff.diff ? 'diff' : 'actual'); const [showSxsDiff, setShowSxsDiff] = React.useState(false); @@ -117,10 +118,10 @@ export const ImageDiffView: React.FC<{
}
- ; diff --git a/packages/web/src/uiUtils.ts b/packages/web/src/uiUtils.ts index 9901a24bde..54f33d229e 100644 --- a/packages/web/src/uiUtils.ts +++ b/packages/web/src/uiUtils.ts @@ -141,6 +141,7 @@ export function copy(text: string) { export type Setting = readonly [T, (value: T) => void, string]; + export function useSetting(name: string | undefined, defaultValue: S, title?: string): [S, React.Dispatch>, Setting] { if (name) defaultValue = settings.getObject(name, defaultValue); @@ -148,19 +149,32 @@ export function useSetting(name: string | undefined, defaultValue: S, title?: const setValueWrapper = React.useCallback((value: React.SetStateAction) => { if (name) settings.setObject(name, value); - setValue(value); + else + setValue(value); }, [name, setValue]); + + React.useEffect(() => { + if (name) { + const onStoreChange = () => setValue(settings.getObject(name, defaultValue)); + settings.onChangeEmitter.addEventListener(name, onStoreChange); + return () => settings.onChangeEmitter.removeEventListener(name, onStoreChange); + } + }, [defaultValue, name]); + const setting = [value, setValueWrapper, title || name || ''] as Setting; return [value, setValueWrapper, setting]; } export class Settings { + onChangeEmitter = new EventTarget(); + getString(name: string, defaultValue: string): string { return localStorage[name] || defaultValue; } setString(name: string, value: string) { localStorage[name] = value; + this.onChangeEmitter.dispatchEvent(new Event(name)); if ((window as any).saveSettings) (window as any).saveSettings(); } @@ -177,9 +191,19 @@ export class Settings { setObject(name: string, value: T) { localStorage[name] = JSON.stringify(value); + this.onChangeEmitter.dispatchEvent(new Event(name)); + if ((window as any).saveSettings) (window as any).saveSettings(); } } export const settings = new Settings(); + +// inspired by https://www.npmjs.com/package/clsx +export function clsx(...classes: (string | undefined | false)[]) { + return classes.filter(Boolean).join(' '); +} + +const kControlCodesRe = '\\u0000-\\u0020\\u007f-\\u009f'; +export const kWebLinkRe = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|www\\.)[^\\s' + kControlCodesRe + '"]{2,}[^\\s' + kControlCodesRe + '"\')}\\],:;.!?]', 'ug'); diff --git a/tests/assets/cached/bfcached.html b/tests/assets/cached/bfcached.html new file mode 100644 index 0000000000..8c12b001cd --- /dev/null +++ b/tests/assets/cached/bfcached.html @@ -0,0 +1,11 @@ + + +
BFCached
+ diff --git a/tests/assets/client-certificates/README.md b/tests/assets/client-certificates/README.md index b0ee78e707..7ee690de52 100644 --- a/tests/assets/client-certificates/README.md +++ b/tests/assets/client-certificates/README.md @@ -36,6 +36,8 @@ openssl x509 \ -out client/trusted/cert.pem \ -set_serial 01 \ -days 365 +# create pfx +openssl pkcs12 -export -out client/trusted/cert.pfx -inkey client/trusted/key.pem -in client/trusted/cert.pem -passout pass:secure ``` ## Self-signed certificate (invalid) diff --git a/tests/assets/client-certificates/client/trusted/cert-legacy.pfx b/tests/assets/client-certificates/client/trusted/cert-legacy.pfx new file mode 100644 index 0000000000..9f06aa35c8 Binary files /dev/null and b/tests/assets/client-certificates/client/trusted/cert-legacy.pfx differ diff --git a/tests/assets/client-certificates/client/trusted/cert.pfx b/tests/assets/client-certificates/client/trusted/cert.pfx new file mode 100644 index 0000000000..48726d703a Binary files /dev/null and b/tests/assets/client-certificates/client/trusted/cert.pfx differ diff --git a/tests/assets/network-tab/font.woff2 b/tests/assets/network-tab/font.woff2 new file mode 100644 index 0000000000..ceba03549a Binary files /dev/null and b/tests/assets/network-tab/font.woff2 differ diff --git a/tests/assets/network-tab/image.png b/tests/assets/network-tab/image.png new file mode 100644 index 0000000000..3942859ccc Binary files /dev/null and b/tests/assets/network-tab/image.png differ diff --git a/tests/assets/network-tab/network.html b/tests/assets/network-tab/network.html new file mode 100644 index 0000000000..d46ff846dc --- /dev/null +++ b/tests/assets/network-tab/network.html @@ -0,0 +1,21 @@ + + + + + + + + +

Network Tab Test

+ + + diff --git a/tests/assets/network-tab/script.js b/tests/assets/network-tab/script.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/assets/network-tab/style.css b/tests/assets/network-tab/style.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/config/testserver/index.ts b/tests/config/testserver/index.ts index 0596960f6b..fa20a61762 100644 --- a/tests/config/testserver/index.ts +++ b/tests/config/testserver/index.ts @@ -177,6 +177,7 @@ export class TestServer { this._csp.clear(); this._extraHeaders.clear(); this._gzipRoutes.clear(); + this._server.closeAllConnections(); const error = new Error('Static Server has been reset'); for (const subscriber of this._requestSubscribers.values()) subscriber[rejectSymbol].call(null, error); diff --git a/tests/config/traceViewerFixtures.ts b/tests/config/traceViewerFixtures.ts index b389f0c07c..7044e30a29 100644 --- a/tests/config/traceViewerFixtures.ts +++ b/tests/config/traceViewerFixtures.ts @@ -58,7 +58,7 @@ class TraceViewerPage { this.stackFrames = page.getByTestId('stack-trace-list').locator('.list-view-entry'); this.networkRequests = page.getByTestId('network-list').locator('.list-view-entry'); this.snapshotContainer = page.locator('.snapshot-container iframe.snapshot-visible[name=snapshot]'); - this.metadataTab = page.locator('.metadata-view'); + this.metadataTab = page.getByTestId('metadata-view'); } async actionIconsText(action: string) { diff --git a/tests/library/browsercontext-fetch.spec.ts b/tests/library/browsercontext-fetch.spec.ts index bffb1b82f1..733596b481 100644 --- a/tests/library/browsercontext-fetch.spec.ts +++ b/tests/library/browsercontext-fetch.spec.ts @@ -145,6 +145,8 @@ for (const method of ['fetch', 'delete', 'get', 'head', 'patch', 'post', 'put'] failOnStatusCode: true }).catch(e => e); expect(error.message).toContain('404 Not Found'); + if (method !== 'head') + expect(error.message).toContain('Response text:\nFile not found:'); }); it(`${method}should support ignoreHTTPSErrors option`, async ({ context, httpsServer }) => { diff --git a/tests/library/capabilities.spec.ts b/tests/library/capabilities.spec.ts index 60f09d49e9..20dcecfa5e 100644 --- a/tests/library/capabilities.spec.ts +++ b/tests/library/capabilities.spec.ts @@ -147,7 +147,7 @@ it('should not crash on showDirectoryPicker', async ({ page, server, browserName const dir = await (window as any).showDirectoryPicker(); return dir.name; // In headless it throws (aborted), in headed it stalls (Test ended) and waits for the picker to be accepted. - }).catch(e => expect(e.message).toMatch(/((DOMException|AbortError): The user aborted a request|Test ended)/)); + }).catch(e => expect(e.message).toMatch(/((DOMException|AbortError): .*The user aborted a request|Test ended)/)); // The dialog will not be accepted, so we just wait for some time to // to give the browser a chance to crash. await page.waitForTimeout(3_000); diff --git a/tests/library/chromium/bfcache.spec.ts b/tests/library/chromium/bfcache.spec.ts new file mode 100644 index 0000000000..c60f4befaf --- /dev/null +++ b/tests/library/chromium/bfcache.spec.ts @@ -0,0 +1,37 @@ +/** + * Copyright 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 { contextTest as test, expect } from '../../config/browserTest'; + +test.use({ + launchOptions: async ({ launchOptions }, use) => { + await use({ ...launchOptions, ignoreDefaultArgs: ['--disable-back-forward-cache'] }); + } +}); + +test('bindings should work after restoring from bfcache', async ({ page, server }) => { + await page.exposeFunction('add', (a, b) => a + b); + + await page.goto(server.PREFIX + '/cached/bfcached.html'); + expect(await page.evaluate('window.add(1, 2)')).toBe(3); + + await page.setContent(`click me`); + await page.click('a'); + + await page.goBack({ waitUntil: 'commit' }); + await page.evaluate('window.didShow'); + expect(await page.evaluate('window.add(2, 3)')).toBe(5); +}); diff --git a/tests/library/chromium/oopif.spec.ts b/tests/library/chromium/oopif.spec.ts index 5324e6d155..cc1279b8af 100644 --- a/tests/library/chromium/oopif.spec.ts +++ b/tests/library/chromium/oopif.spec.ts @@ -260,8 +260,8 @@ it('should report google.com frame with headed', async ({ browserType, server }) it('ElementHandle.boundingBox() should work', async function({ page, browser, server }) { await page.goto(server.PREFIX + '/dynamic-oopif.html'); await page.$eval('iframe', iframe => { - iframe.style.width = '500px'; - iframe.style.height = '500px'; + iframe.style.width = '520px'; + iframe.style.height = '520px'; iframe.style.marginLeft = '42px'; iframe.style.marginTop = '17px'; }); diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index 6a873ed27f..53b918dc0f 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -82,8 +82,6 @@ test.use({ } }); -test.skip(({ mode }) => mode !== 'default'); - const kDummyFileName = __filename; const kValidationSubTests: [BrowserContextOptions, string][] = [ [{ clientCertificates: [{ origin: 'test' }] }, 'None of cert, key, passphrase or pfx is specified'], @@ -114,7 +112,7 @@ test.describe('fetch', () => { test('should fail with no client certificates provided', async ({ playwright, startCCServer }) => { const serverURL = await startCCServer(); - const request = await playwright.request.newContext(); + const request = await playwright.request.newContext({ ignoreHTTPSErrors: true }); const response = await request.get(serverURL); expect(response.status()).toBe(401); expect(await response.text()).toContain('Sorry, but you need to provide a client certificate to continue.'); @@ -123,6 +121,7 @@ test.describe('fetch', () => { test('should keep supporting http', async ({ playwright, server, asset }) => { const request = await playwright.request.newContext({ + ignoreHTTPSErrors: true, clientCertificates: [{ origin: new URL(server.PREFIX).origin, certPath: asset('client-certificates/client/trusted/cert.pem'), @@ -139,6 +138,7 @@ test.describe('fetch', () => { test('should throw with untrusted client certs', async ({ playwright, startCCServer, asset }) => { const serverURL = await startCCServer(); const request = await playwright.request.newContext({ + ignoreHTTPSErrors: true, clientCertificates: [{ origin: new URL(serverURL).origin, certPath: asset('client-certificates/client/self-signed/cert.pem'), @@ -155,6 +155,7 @@ test.describe('fetch', () => { test('pass with trusted client certificates', async ({ playwright, startCCServer, asset }) => { const serverURL = await startCCServer(); const request = await playwright.request.newContext({ + ignoreHTTPSErrors: true, clientCertificates: [{ origin: new URL(serverURL).origin, certPath: asset('client-certificates/client/trusted/cert.pem'), @@ -168,9 +169,55 @@ test.describe('fetch', () => { await request.dispose(); }); + test('pass with trusted client certificates in pfx format', async ({ playwright, startCCServer, asset }) => { + const serverURL = await startCCServer(); + const request = await playwright.request.newContext({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin, + pfxPath: asset('client-certificates/client/trusted/cert.pfx'), + passphrase: 'secure' + }], + }); + const response = await request.get(serverURL); + expect(response.url()).toBe(serverURL); + expect(response.status()).toBe(200); + expect(await response.text()).toContain('Hello Alice, your certificate was issued by localhost!'); + await request.dispose(); + }); + + test('should throw a http error if the pfx passphrase is incorect', async ({ playwright, startCCServer, asset, browserName }) => { + const serverURL = await startCCServer(); + const request = await playwright.request.newContext({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin, + pfxPath: asset('client-certificates/client/trusted/cert.pfx'), + passphrase: 'this-password-is-incorrect' + }], + }); + await expect(request.get(serverURL)).rejects.toThrow('mac verify failure'); + await request.dispose(); + }); + + test('should fail with matching certificates in legacy pfx format', async ({ playwright, startCCServer, asset, browserName }) => { + const serverURL = await startCCServer(); + const request = await playwright.request.newContext({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin, + pfxPath: asset('client-certificates/client/trusted/cert-legacy.pfx'), + passphrase: 'secure' + }], + }); + await expect(request.get(serverURL)).rejects.toThrow('Unsupported TLS certificate'); + await request.dispose(); + }); + test('should work in the browser with request interception', async ({ browser, playwright, startCCServer, asset }) => { const serverURL = await startCCServer(); const request = await playwright.request.newContext({ + ignoreHTTPSErrors: true, clientCertificates: [{ origin: new URL(serverURL).origin, certPath: asset('client-certificates/client/trusted/cert.pem'), @@ -213,6 +260,7 @@ test.describe('browser', () => { test('should fail with no client certificates', async ({ browser, startCCServer, asset, browserName }) => { const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const page = await browser.newPage({ + ignoreHTTPSErrors: true, clientCertificates: [{ origin: 'https://not-matching.com', certPath: asset('client-certificates/client/trusted/cert.pem'), @@ -227,6 +275,7 @@ test.describe('browser', () => { test('should fail with self-signed client certificates', async ({ browser, startCCServer, asset, browserName }) => { const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const page = await browser.newPage({ + ignoreHTTPSErrors: true, clientCertificates: [{ origin: new URL(serverURL).origin, certPath: asset('client-certificates/client/self-signed/cert.pem'), @@ -241,6 +290,7 @@ test.describe('browser', () => { test('should pass with matching certificates', async ({ browser, startCCServer, asset, browserName }) => { const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const page = await browser.newPage({ + ignoreHTTPSErrors: true, clientCertificates: [{ origin: new URL(serverURL).origin, certPath: asset('client-certificates/client/trusted/cert.pem'), @@ -252,6 +302,51 @@ test.describe('browser', () => { await page.close(); }); + test('should pass with matching certificates in pfx format', async ({ browser, startCCServer, asset, browserName }) => { + const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); + const page = await browser.newPage({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin, + pfxPath: asset('client-certificates/client/trusted/cert.pfx'), + passphrase: 'secure' + }], + }); + await page.goto(serverURL); + await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!'); + await page.close(); + }); + + test('should fail with matching certificates in legacy pfx format', async ({ browser, startCCServer, asset, browserName }) => { + const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); + const page = await browser.newPage({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin, + pfxPath: asset('client-certificates/client/trusted/cert-legacy.pfx'), + passphrase: 'secure' + }], + }); + await page.goto(serverURL); + await expect(page.getByText('Unsupported TLS certificate.')).toBeVisible(); + await page.close(); + }); + + test('should throw a http error if the pfx passphrase is incorect', async ({ browser, startCCServer, asset, browserName }) => { + const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); + const page = await browser.newPage({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin, + pfxPath: asset('client-certificates/client/trusted/cert.pfx'), + passphrase: 'this-password-is-incorrect' + }], + }); + await page.goto(serverURL); + await expect(page.getByText('Playwright client-certificate error: mac verify failure')).toBeVisible(); + await page.close(); + }); + test('should pass with matching certificates on context APIRequestContext instance', async ({ browser, startCCServer, asset, browserName }) => { const serverURL = await startCCServer({ host: '127.0.0.1' }); const baseOptions = { @@ -278,6 +373,7 @@ test.describe('browser', () => { test('should pass with matching certificates and trailing slash', async ({ browser, startCCServer, asset, browserName }) => { const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const page = await browser.newPage({ + ignoreHTTPSErrors: true, clientCertificates: [{ origin: serverURL, certPath: asset('client-certificates/client/trusted/cert.pem'), @@ -307,6 +403,7 @@ test.describe('browser', () => { const enableHTTP1FallbackWhenUsingHttp2 = browserName === 'webkit' && process.platform === 'linux'; const serverURL = await startCCServer({ http2: true, enableHTTP1FallbackWhenUsingHttp2 }); const page = await browser.newPage({ + ignoreHTTPSErrors: true, clientCertificates: [{ origin: new URL(serverURL).origin, certPath: asset('client-certificates/client/trusted/cert.pem'), @@ -335,6 +432,7 @@ test.describe('browser', () => { const serverURL = await startCCServer({ http2: true, enableHTTP1FallbackWhenUsingHttp2: true }); const browser = await browserType.launch({ args: ['--disable-http2'] }); const page = await browser.newPage({ + ignoreHTTPSErrors: true, clientCertificates: [{ origin: new URL(serverURL).origin, certPath: asset('client-certificates/client/trusted/cert.pem'), @@ -359,7 +457,6 @@ test.describe('browser', () => { test.fixme(browserName === 'webkit' && process.platform === 'linux', 'WebKit on Linux does not support http2 https://bugs.webkit.org/show_bug.cgi?id=276990'); test.skip(+process.versions.node.split('.')[0] < 20, 'http2.performServerHandshake is not supported in older Node.js versions'); - process.env.PWTEST_UNSUPPORTED_CUSTOM_CA = asset('empty.html'); const serverURL = await startCCServer({ http2: true }); const page = await browser.newPage({ clientCertificates: [{ @@ -383,6 +480,7 @@ test.describe('browser', () => { test('should pass with matching certificates', async ({ launchPersistent, startCCServer, asset, browserName }) => { const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const { page } = await launchPersistent({ + ignoreHTTPSErrors: true, clientCertificates: [{ origin: new URL(serverURL).origin, certPath: asset('client-certificates/client/trusted/cert.pem'), diff --git a/tests/library/clock.spec.ts b/tests/library/clock.spec.ts index 9fd8426a92..90279fd893 100644 --- a/tests/library/clock.spec.ts +++ b/tests/library/clock.spec.ts @@ -1060,6 +1060,18 @@ it.describe('stubTimers', () => { expect(prev).toBe(0); }); + it('replace Event.prototype.timeStamp', async ({ install }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/31924' }); + const clock = install(); + await clock.runFor(1000); + const event1 = new Event('foo'); + expect(event1.timeStamp).toBe(1000); + await clock.runFor(1000); + const event2 = new Event('foo'); + expect(event2.timeStamp).toBe(2000); + expect(event1.timeStamp).toBe(1000); + }); + it('uninstalls global performance.now', async ({ install }) => { const oldNow = performance.now; const clock = install(); diff --git a/tests/library/events/listener-count.spec.ts b/tests/library/events/listener-count.spec.ts index 8fae8cdfad..d8aaf867a3 100644 --- a/tests/library/events/listener-count.spec.ts +++ b/tests/library/events/listener-count.spec.ts @@ -20,7 +20,6 @@ // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE // USE OR OTHER DEALINGS IN THE SOFTWARE. -import events from 'events'; import { EventEmitter } from '../../../packages/playwright-core/lib/client/eventEmitter'; import { test, expect } from '@playwright/test'; @@ -32,7 +31,6 @@ test('Listener count test', () => { // Allow any type emitter.on(123, () => {}); - expect(events.listenerCount(emitter, 'foo')).toEqual(2); expect(emitter.listenerCount('foo')).toEqual(2); expect(emitter.listenerCount('bar')).toEqual(0); expect(emitter.listenerCount('baz')).toEqual(1); diff --git a/tests/library/events/listeners.spec.ts b/tests/library/events/listeners.spec.ts index 4553ed92aa..86803af178 100644 --- a/tests/library/events/listeners.spec.ts +++ b/tests/library/events/listeners.spec.ts @@ -40,7 +40,7 @@ test('EventEmitter listeners with one listener', () => { expect(listeners).toHaveLength(1); expect(listeners[0]).toEqual(listener); - ee.removeAllListeners('foo'); + void ee.removeAllListeners('foo'); expect>(ee.listeners('foo')).toHaveLength(0); expect(Array.isArray(fooListeners)).toBeTruthy(); diff --git a/tests/library/events/modify-in-emit.spec.ts b/tests/library/events/modify-in-emit.spec.ts index 1d832aa650..25937977f4 100644 --- a/tests/library/events/modify-in-emit.spec.ts +++ b/tests/library/events/modify-in-emit.spec.ts @@ -72,7 +72,7 @@ test('add and remove listeners', () => { e.on('foo', callback1); e.on('foo', callback2); expect(e.listeners('foo')).toHaveLength(2); - e.removeAllListeners('foo'); + void e.removeAllListeners('foo'); expect(e.listeners('foo')).toHaveLength(0); }); diff --git a/tests/library/events/remove-all-listeners-wait.spec.ts b/tests/library/events/remove-all-listeners-wait.spec.ts new file mode 100644 index 0000000000..1f8dcc8dd0 --- /dev/null +++ b/tests/library/events/remove-all-listeners-wait.spec.ts @@ -0,0 +1,80 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ManualPromise } from '../../../packages/playwright-core/lib/utils/manualPromise'; +import { EventEmitter } from '../../../packages/playwright-core/lib/client/eventEmitter'; +import { test, expect } from '@playwright/test'; + +test('should not throw with ignoreErrors', async () => { + const ee = new EventEmitter(); + const releaseHandler = new ManualPromise(); + ee.on('console', async () => { + await releaseHandler; + throw new Error('Error in console handler'); + }); + ee.emit('console'); + await ee.removeAllListeners('console', { behavior: 'ignoreErrors' }); + releaseHandler.resolve(); +}); + +test('should wait', async () => { + const ee = new EventEmitter(); + const releaseHandler = new ManualPromise(); + let value = 0; + ee.on('console', async () => { + await releaseHandler; + value = 42; + }); + ee.emit('console'); + const removePromise = ee.removeAllListeners('console', { behavior: 'wait' }); + releaseHandler.resolve(); + await removePromise; + expect(value).toBe(42); +}); + +test('should wait all', async () => { + const ee = new EventEmitter(); + const releaseHandler = new ManualPromise(); + const values = []; + ee.on('a', async () => { + await releaseHandler; + values.push(42); + }); + ee.on('b', async () => { + await releaseHandler; + values.push(43); + }); + ee.emit('a'); + ee.emit('b'); + const removePromise = ee.removeAllListeners(undefined, { behavior: 'wait' }); + releaseHandler.resolve(); + await removePromise; + expect(values).toEqual([42, 43]); +}); + +test('wait should throw', async () => { + const ee = new EventEmitter(); + const releaseHandler = new ManualPromise(); + ee.on('console', async () => { + await releaseHandler; + throw new Error('Error in handler'); + }); + ee.emit('console'); + const removePromise = ee.removeAllListeners('console', { behavior: 'wait' }); + releaseHandler.resolve(); + await expect(removePromise).rejects.toThrow('Error in handler'); +}); diff --git a/tests/library/events/remove-all-listeners.spec.ts b/tests/library/events/remove-all-listeners.spec.ts index 116b9e768c..275c0700ff 100644 --- a/tests/library/events/remove-all-listeners.spec.ts +++ b/tests/library/events/remove-all-listeners.spec.ts @@ -58,8 +58,8 @@ test('listeners', () => { const barListeners = ee.listeners('bar'); const bazListeners = ee.listeners('baz'); ee.on('removeListener', expectWrapper(['bar', 'baz', 'baz'])); - ee.removeAllListeners('bar'); - ee.removeAllListeners('baz'); + void ee.removeAllListeners('bar'); + void ee.removeAllListeners('baz'); let listeners = ee.listeners('foo'); expect(Array.isArray(listeners)).toBeTruthy(); @@ -91,7 +91,7 @@ test('removeAllListeners removes all listeners', () => { ee.on('bar', () => { }); ee.on('removeListener', expectWrapper(['foo', 'bar', 'removeListener'])); ee.on('removeListener', expectWrapper(['foo', 'bar'])); - ee.removeAllListeners(); + void ee.removeAllListeners(); let listeners = ee.listeners('foo'); expect(Array.isArray(listeners)).toBeTruthy(); @@ -120,7 +120,7 @@ test('listener count after removeAllListeners', () => { ee.on('baz', () => { }); ee.on('baz', () => { }); expect(ee.listeners('baz').length).toEqual(expectLength + 1); - ee.removeAllListeners('baz'); + void ee.removeAllListeners('baz'); expect(ee.listeners('baz').length).toEqual(0); }); diff --git a/tests/library/events/subclass.spec.ts b/tests/library/events/subclass.spec.ts index 37b548a92a..3852657b8d 100644 --- a/tests/library/events/subclass.spec.ts +++ b/tests/library/events/subclass.spec.ts @@ -28,7 +28,7 @@ class MyEE extends EventEmitter { super(); this.once(1, cb); this.emit(1); - this.removeAllListeners(); + void this.removeAllListeners(); } } diff --git a/tests/library/events/symbols.spec.ts b/tests/library/events/symbols.spec.ts index 7efb00f189..1c143def37 100644 --- a/tests/library/events/symbols.spec.ts +++ b/tests/library/events/symbols.spec.ts @@ -34,7 +34,7 @@ test('should support symbols', () => { ee.emit(foo); - ee.removeAllListeners(); + void ee.removeAllListeners(); expect(ee.listeners(foo).length).toEqual(0); ee.on(foo, listener); diff --git a/tests/library/global-fetch.spec.ts b/tests/library/global-fetch.spec.ts index 5915272621..b3bea9e432 100644 --- a/tests/library/global-fetch.spec.ts +++ b/tests/library/global-fetch.spec.ts @@ -17,9 +17,15 @@ import os from 'os'; import * as util from 'util'; import { getPlaywrightVersion } from '../../packages/playwright-core/lib/utils/userAgent'; -import { expect, playwrightTest as it } from '../config/browserTest'; +import { expect, playwrightTest as base } from '../config/browserTest'; import { kTargetClosedErrorMessage } from 'tests/config/errors'; +const it = base.extend({ + context: async () => { + throw new Error('global fetch tests should not use context'); + } +}); + it.skip(({ mode }) => mode !== 'default'); for (const method of ['fetch', 'delete', 'get', 'head', 'patch', 'post', 'put'] as const) { @@ -33,9 +39,11 @@ for (const method of ['fetch', 'delete', 'get', 'head', 'patch', 'post', 'put'] expect(response.headers()['content-type']).toBe('application/json; charset=utf-8'); expect(response.headersArray()).toContainEqual({ name: 'Content-Type', value: 'application/json; charset=utf-8' }); expect(await response.text()).toBe('head' === method ? '' : '{"foo": "bar"}\n'); + await request.dispose(); }); } + it(`should dispose global request`, async function({ playwright, server }) { const request = await playwright.request.newContext(); const response = await request.get(server.PREFIX + '/simple.json'); @@ -43,6 +51,7 @@ it(`should dispose global request`, async function({ playwright, server }) { await request.dispose(); const error = await response.body().catch(e => e); expect(error.message).toContain('Response has been disposed'); + await request.dispose(); }); it('should support global userAgent option', async ({ playwright, server }) => { @@ -54,6 +63,7 @@ it('should support global userAgent option', async ({ playwright, server }) => { expect(response.ok()).toBeTruthy(); expect(response.url()).toBe(server.EMPTY_PAGE); expect(serverRequest.headers['user-agent']).toBe('My Agent'); + await request.dispose(); }); it('should support global timeout option', async ({ playwright, server }) => { @@ -61,6 +71,7 @@ it('should support global timeout option', async ({ playwright, server }) => { server.setRoute('/empty.html', (req, res) => {}); const error = await request.get(server.EMPTY_PAGE).catch(e => e); expect(error.message).toContain('Request timed out after 100ms'); + await request.dispose(); }); it('should propagate extra http headers with redirects', async ({ playwright, server }) => { @@ -76,6 +87,7 @@ it('should propagate extra http headers with redirects', async ({ playwright, se expect(req1.headers['my-secret']).toBe('Value'); expect(req2.headers['my-secret']).toBe('Value'); expect(req3.headers['my-secret']).toBe('Value'); + await request.dispose(); }); it('should support global httpCredentials option', async ({ playwright, server }) => { @@ -96,6 +108,7 @@ it('should return error with wrong credentials', async ({ playwright, server }) const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'wrong' } }); const response = await request.get(server.EMPTY_PAGE); expect(response.status()).toBe(401); + await request.dispose(); }); it('should work with correct credentials and matching origin', async ({ playwright, server }) => { @@ -103,6 +116,7 @@ it('should work with correct credentials and matching origin', async ({ playwrig const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX } }); const response = await request.get(server.EMPTY_PAGE); expect(response.status()).toBe(200); + await request.dispose(); }); it('should work with correct credentials and matching origin case insensitive', async ({ playwright, server }) => { @@ -110,6 +124,7 @@ it('should work with correct credentials and matching origin case insensitive', const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX.toUpperCase() } }); const response = await request.get(server.EMPTY_PAGE); expect(response.status()).toBe(200); + await request.dispose(); }); it('should return error with correct credentials and mismatching scheme', async ({ playwright, server }) => { @@ -117,6 +132,7 @@ it('should return error with correct credentials and mismatching scheme', async const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX.replace('http://', 'https://') } }); const response = await request.get(server.EMPTY_PAGE); expect(response.status()).toBe(401); + await request.dispose(); }); it('should return error with correct credentials and mismatching hostname', async ({ playwright, server }) => { @@ -126,6 +142,7 @@ it('should return error with correct credentials and mismatching hostname', asyn const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: origin } }); const response = await request.get(server.EMPTY_PAGE); expect(response.status()).toBe(401); + await request.dispose(); }); it('should return error with correct credentials and mismatching port', async ({ playwright, server }) => { @@ -134,6 +151,7 @@ it('should return error with correct credentials and mismatching port', async ({ const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: origin } }); const response = await request.get(server.EMPTY_PAGE); expect(response.status()).toBe(401); + await request.dispose(); }); it('should support WWW-Authenticate: Basic', async ({ playwright, server }) => { @@ -152,6 +170,7 @@ it('should support WWW-Authenticate: Basic', async ({ playwright, server }) => { const response = await request.get(server.EMPTY_PAGE); expect(response.status()).toBe(200); expect(credentials).toBe('user:pass'); + await request.dispose(); }); it('should support HTTPCredentials.send', async ({ playwright, server }) => { @@ -176,12 +195,14 @@ it('should support HTTPCredentials.send', async ({ playwright, server }) => { expect(serverRequest.headers.authorization).toBe(undefined); expect(response.status()).toBe(200); } + await request.dispose(); }); it('should support global ignoreHTTPSErrors option', async ({ playwright, httpsServer }) => { const request = await playwright.request.newContext({ ignoreHTTPSErrors: true }); const response = await request.get(httpsServer.EMPTY_PAGE); expect(response.status()).toBe(200); + await request.dispose(); }); it('should propagate ignoreHTTPSErrors on redirects', async ({ playwright, httpsServer }) => { @@ -189,12 +210,14 @@ it('should propagate ignoreHTTPSErrors on redirects', async ({ playwright, https const request = await playwright.request.newContext(); const response = await request.get(httpsServer.PREFIX + '/redir', { ignoreHTTPSErrors: true }); expect(response.status()).toBe(200); + await request.dispose(); }); it('should resolve url relative to global baseURL option', async ({ playwright, server }) => { const request = await playwright.request.newContext({ baseURL: server.PREFIX }); const response = await request.get('/empty.html'); expect(response.url()).toBe(server.EMPTY_PAGE); + await request.dispose(); }); it('should set playwright as user-agent', async ({ playwright, server, isWindows, isLinux, isMac }) => { @@ -221,12 +244,14 @@ it('should set playwright as user-agent', async ({ playwright, server, isWindows expect(userAgentMasked.replace(/; \w+ [^)]+/, '; distro version')).toBe('Playwright/X.X.X (; distro version) node/X.X' + suffix); else if (isMac) expect(userAgentMasked).toBe('Playwright/X.X.X (; macOS X.X) node/X.X' + suffix); + await request.dispose(); }); it('should be able to construct with context options', async ({ playwright, browserType, server }) => { const request = await playwright.request.newContext((browserType as any)._defaultContextOptions); const response = await request.get(server.EMPTY_PAGE); expect(response.ok()).toBeTruthy(); + await request.dispose(); }); it('should return empty body', async ({ playwright, server }) => { @@ -254,6 +279,7 @@ it('should abort requests when context is disposed', async ({ playwright, server expect(result.message).toContain(kTargetClosedErrorMessage); } await connectionClosed; + await request.dispose(); }); it('should abort redirected requests when context is disposed', async ({ playwright, server }) => { @@ -269,6 +295,7 @@ it('should abort redirected requests when context is disposed', async ({ playwri expect(result instanceof Error).toBeTruthy(); expect(result.message).toContain(kTargetClosedErrorMessage); await connectionClosed; + await request.dispose(); }); it('should remove content-length from redirected post requests', async ({ playwright, server }) => { @@ -473,7 +500,6 @@ it('should serialize post data on the client', async ({ playwright, server }) => await postReq; const body = await (await serverReq).postBody; expect(body.toString()).toBe('{"foo":"bar"}'); - // expect(serverRequest.rawHeaders).toContain('vaLUE'); await request.dispose(); }); @@ -486,7 +512,8 @@ it('should throw after dispose', async ({ playwright, server }) => { it('should retry ECONNRESET', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30978' } -}, async ({ context, server }) => { +}, async ({ playwright, server }) => { + const request = await playwright.request.newContext(); let requestCount = 0; server.setRoute('/test', (req, res) => { if (requestCount++ < 3) { @@ -496,8 +523,9 @@ it('should retry ECONNRESET', { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('Hello!'); }); - const response = await context.request.fetch(server.PREFIX + '/test', { maxRetries: 3 }); + const response = await request.fetch(server.PREFIX + '/test', { maxRetries: 3 }); expect(response.status()).toBe(200); expect(await response.text()).toBe('Hello!'); expect(requestCount).toBe(4); + await request.dispose(); }); diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index 539c153df6..e8471c16d6 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -256,6 +256,67 @@ test('should have network requests', async ({ showTraceViewer }) => { await expect(traceViewer.networkRequests.filter({ hasText: '404' })).toHaveCSS('background-color', 'rgb(242, 222, 222)'); }); +test('should filter network requests by resource type', async ({ page, runAndTrace, server }) => { + const traceViewer = await runAndTrace(async () => { + server.setRoute('/api/endpoint', (_, res) => res.setHeader('Content-Type', 'application/json').end()); + await page.goto(`${server.PREFIX}/network-tab/network.html`); + }); + await traceViewer.selectAction('http://localhost'); + await traceViewer.showNetworkTab(); + + await traceViewer.page.getByText('JS', { exact: true }).click(); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('script.js')).toBeVisible(); + + await traceViewer.page.getByText('CSS', { exact: true }).click(); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('style.css')).toBeVisible(); + + await traceViewer.page.getByText('Image', { exact: true }).click(); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('image.png')).toBeVisible(); + + await traceViewer.page.getByText('Fetch', { exact: true }).click(); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('endpoint')).toBeVisible(); + + await traceViewer.page.getByText('HTML', { exact: true }).click(); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('network.html')).toBeVisible(); + + await traceViewer.page.getByText('Font', { exact: true }).click(); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('font.woff2')).toBeVisible(); +}); + +test('should filter network requests by url', async ({ page, runAndTrace, server }) => { + const traceViewer = await runAndTrace(async () => { + await page.goto(`${server.PREFIX}/network-tab/network.html`); + }); + await traceViewer.selectAction('http://localhost'); + await traceViewer.showNetworkTab(); + + await traceViewer.page.getByPlaceholder('Filter network').fill('script.'); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('script.js')).toBeVisible(); + + await traceViewer.page.getByPlaceholder('Filter network').fill('png'); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('image.png')).toBeVisible(); + + await traceViewer.page.getByPlaceholder('Filter network').fill('api/'); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('endpoint')).toBeVisible(); + + await traceViewer.page.getByPlaceholder('Filter network').fill('End'); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('endpoint')).toBeVisible(); + + await traceViewer.page.getByPlaceholder('Filter network').fill('FON'); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('font.woff2')).toBeVisible(); +}); + test('should have network request overrides', async ({ page, server, runAndTrace }) => { const traceViewer = await runAndTrace(async () => { await page.route('**/style.css', route => route.abort()); @@ -291,13 +352,13 @@ test('should show snapshot URL', async ({ page, runAndTrace, server }) => { test('should popup snapshot', async ({ page, runAndTrace, server }) => { const traceViewer = await runAndTrace(async () => { await page.goto(server.EMPTY_PAGE); - await page.setContent('hello'); + await page.setContent('hello äöü 🙂'); }); await traceViewer.snapshotFrame('page.setContent'); const popupPromise = traceViewer.page.context().waitForEvent('page'); await traceViewer.page.getByTitle('Open snapshot in a new tab').click(); const popup = await popupPromise; - await expect(popup.getByText('hello')).toBeVisible(); + await expect(popup.getByText('hello äöü 🙂')).toBeVisible(); }); test('should capture iframe with sandbox attribute', async ({ page, server, runAndTrace }) => { @@ -804,7 +865,7 @@ test('should follow redirects', async ({ page, runAndTrace, server, asset }) => test('should include metainfo', async ({ showTraceViewer }) => { const traceViewer = await showTraceViewer([traceFile]); await traceViewer.page.locator('text=Metadata').click(); - const callLine = traceViewer.page.locator('.metadata-view .call-line'); + const callLine = traceViewer.metadataTab.locator('.call-line'); await expect(callLine.getByText('start time')).toHaveText(/start time:[\d/,: ]+/); await expect(callLine.getByText('duration')).toHaveText(/duration:[\dms]+/); await expect(callLine.getByText('engine')).toHaveText(/engine:[\w]+/); @@ -1357,7 +1418,6 @@ test('should allow hiding route actions', { await traceViewer.page.getByRole('checkbox', { name: 'Show route actions' }).uncheck(); await traceViewer.page.getByText('Actions', { exact: true }).click(); await expect(traceViewer.actionTitles).toHaveText([ - /page.route/, /page.goto.*empty.html/, ]); diff --git a/tests/page/elementhandle-scroll-into-view.spec.ts b/tests/page/elementhandle-scroll-into-view.spec.ts index e2a401ccea..6894b6465c 100644 --- a/tests/page/elementhandle-scroll-into-view.spec.ts +++ b/tests/page/elementhandle-scroll-into-view.spec.ts @@ -80,7 +80,9 @@ it('should scroll display:contents into view', async ({ page, browserName, brows `); const div = await page.$('#target'); await div.scrollIntoViewIfNeeded(); - expect(await page.$eval('#container', e => e.scrollTop)).toBe(350); + const scrollTop = await page.$eval('#container', e => e.scrollTop); + // On Android the value is not exact due to various scale conversions. + expect(Math.abs(scrollTop - 350)).toBeLessThan(1); }); it('should work for visibility:hidden element', async ({ page }) => { diff --git a/tests/page/page-add-locator-handler.spec.ts b/tests/page/page-add-locator-handler.spec.ts index 0062ba15ab..c99dc1d7c4 100644 --- a/tests/page/page-add-locator-handler.spec.ts +++ b/tests/page/page-add-locator-handler.spec.ts @@ -87,7 +87,9 @@ test('should work with a custom check', async ({ page, server }) => { } }); -test('should work with locator.hover()', async ({ page, server }) => { +test('should work with locator.hover()', async ({ page, server, headless }) => { + test.skip(!headless, 'Stray hovers in headed mode'); + await page.goto(server.PREFIX + '/input/handle-locator.html'); await page.addLocatorHandler(page.getByText('This interstitial covers the button'), async () => { diff --git a/tests/page/page-history.spec.ts b/tests/page/page-history.spec.ts index cf6eb2d456..a709ade4ca 100644 --- a/tests/page/page-history.spec.ts +++ b/tests/page/page-history.spec.ts @@ -92,15 +92,17 @@ it('page.goBack should work for file urls', async ({ page, server, asset, browse }); it('goBack/goForward should work with bfcache-able pages', async ({ page, server }) => { - await page.goto(server.PREFIX + '/cached/one-style.html'); - await page.setContent(`click me`); + await page.goto(server.PREFIX + '/cached/bfcached.html'); + await page.setContent(`click me`); await page.click('a'); let response = await page.goBack(); - expect(response.url()).toBe(server.PREFIX + '/cached/one-style.html'); + expect(response.url()).toBe(server.PREFIX + '/cached/bfcached.html'); + // BFCache should be disabled. + expect(await page.evaluate('window.didShow')).toEqual({ persisted: false }); response = await page.goForward(); - expect(response.url()).toBe(server.PREFIX + '/cached/one-style.html?foo'); + expect(response.url()).toBe(server.PREFIX + '/cached/bfcached.html?foo'); }); it('page.reload should work', async ({ page, server }) => { diff --git a/tests/page/page-listeners.spec.ts b/tests/page/page-listeners.spec.ts new file mode 100644 index 0000000000..c22be64553 --- /dev/null +++ b/tests/page/page-listeners.spec.ts @@ -0,0 +1,67 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ManualPromise } from '../../packages/playwright-core/lib/utils/manualPromise'; +import { test as it, expect } from './pageTest'; + +// This test is mostly for type checking, the actual tests are in the library/events. + +it('should not throw with ignoreErrors', async ({ page }) => { + const reachedHandler = new ManualPromise(); + const releaseHandler = new ManualPromise(); + page.on('console', async () => { + reachedHandler.resolve(); + await releaseHandler; + throw new Error('Error in console handler'); + }); + await page.evaluate('console.log(1)'); + await reachedHandler; + await page.removeAllListeners('console', { behavior: 'ignoreErrors' }); + releaseHandler.resolve(); + await page.waitForTimeout(1000); +}); + +it('should wait', async ({ page }) => { + const reachedHandler = new ManualPromise(); + const releaseHandler = new ManualPromise(); + let value = 0; + page.on('console', async () => { + reachedHandler.resolve(); + value = 42; + }); + await page.evaluate('console.log(1)'); + await reachedHandler; + const removePromise = page.removeAllListeners('console', { behavior: 'wait' }); + releaseHandler.resolve(); + await removePromise; + expect(value).toBe(42); +}); + +it('wait should throw', async ({ page }) => { + const reachedHandler = new ManualPromise(); + const releaseHandler = new ManualPromise(); + page.on('console', async () => { + reachedHandler.resolve(); + await releaseHandler; + throw new Error('Error in handler'); + }); + await page.evaluate('console.log(1)'); + await reachedHandler; + const removePromise = page.removeAllListeners('console', { behavior: 'wait' }); + releaseHandler.resolve(); + await expect(removePromise).rejects.toThrow('Error in handler'); +}); diff --git a/tests/playwright-test/esm.spec.ts b/tests/playwright-test/esm.spec.ts index 2eb7cb9536..544cbc5cd5 100644 --- a/tests/playwright-test/esm.spec.ts +++ b/tests/playwright-test/esm.spec.ts @@ -129,8 +129,10 @@ test('should respect path resolver in experimental mode', async ({ runInlineTest const result = await runInlineTest({ 'package.json': JSON.stringify({ type: 'module' }), 'playwright.config.ts': ` + // Make sure that config can use the path mapping. + import { foo } from 'util/b.js'; export default { - projects: [{name: 'foo'}], + projects: [{ name: foo }], }; `, 'tsconfig.json': `{ @@ -148,7 +150,8 @@ test('should respect path resolver in experimental mode', async ({ runInlineTest import { foo } from 'util/b.js'; import { test, expect } from '@playwright/test'; test('check project name', ({}, testInfo) => { - expect(testInfo.project.name).toBe(foo); + expect(testInfo.project.name).toBe('foo'); + expect(foo).toBe('foo'); }); `, 'foo/bar/util/b.ts': ` diff --git a/tests/playwright-test/loader.spec.ts b/tests/playwright-test/loader.spec.ts index 491fa3d2bf..9b39733fc3 100644 --- a/tests/playwright-test/loader.spec.ts +++ b/tests/playwright-test/loader.spec.ts @@ -961,10 +961,11 @@ test('should complain when one test file imports another', async ({ runInlineTes expect(result.output).toContain(`test file "a.test.ts" should not import test file "b.test.ts"`); }); -test('should support dynamic imports of js, ts from js, ts and cjs', async ({ runInlineTest }) => { +test('should support dynamic imports and requires of js, ts from js, ts and cjs', async ({ runInlineTest }) => { const result = await runInlineTest({ 'helper.ts': ` - module.exports.foo = 'foo'; + const foo: string = 'foo'; + module.exports.foo = foo; `, 'helper2.ts': ` module.exports.bar = 'bar'; @@ -972,6 +973,10 @@ test('should support dynamic imports of js, ts from js, ts and cjs', async ({ ru 'helper3.js': ` module.exports.baz = 'baz'; `, + 'helper4.ts': ` + const foo: string = 'foo'; + module.exports.foo = foo; + `, 'passthrough.cjs': ` module.exports.load = () => import('./helper2'); `, @@ -1006,8 +1011,16 @@ test('should support dynamic imports of js, ts from js, ts and cjs', async ({ ru expect(foo).toBe('foo'); }); `, + 'd.test.js': ` + import { test, expect } from '@playwright/test'; + + test('pass', async () => { + const { foo } = require('./helper4'); + expect(foo).toBe('foo'); + }); + `, }, { workers: 1 }); - expect(result.passed).toBe(3); + expect(result.passed).toBe(4); expect(result.exitCode).toBe(0); }); diff --git a/tests/playwright-test/playwright-test-fixtures.ts b/tests/playwright-test/playwright-test-fixtures.ts index 86b07d7a80..f9670fa305 100644 --- a/tests/playwright-test/playwright-test-fixtures.ts +++ b/tests/playwright-test/playwright-test-fixtures.ts @@ -125,6 +125,10 @@ function startPlaywrightTest(childProcess: CommonFixtures['childProcess'], baseD ); if (options.additionalArgs) args.push(...options.additionalArgs); + return startPlaywrightChildProcess(childProcess, baseDir, args, env, options); +} + +function startPlaywrightChildProcess(childProcess: CommonFixtures['childProcess'], baseDir: string, args: string[], env: NodeJS.ProcessEnv, options: RunOptions): TestChildProcess { return childProcess({ command: ['node', cliEntrypoint, ...args], env: cleanEnv(env), @@ -246,6 +250,7 @@ type Fixtures = { deleteFile: (file: string) => Promise; runInlineTest: (files: Files, params?: Params, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise; runCLICommand: (files: Files, command: string, args?: string[], entryPoint?: string) => Promise<{ stdout: string, stderr: string, exitCode: number }>; + startCLICommand: (files: Files, command: string, args?: string[], options?: RunOptions) => Promise; runWatchTest: (files: Files, params?: Params, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise; interactWithTestRunner: (files: Files, params?: Params, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise; runTSC: (files: Files) => Promise; @@ -287,6 +292,15 @@ export const test = base await removeFolders([cacheDir]); }, + startCLICommand: async ({ childProcess }, use, testInfo: TestInfo) => { + const cacheDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-test-cache-')); + await use(async (files: Files, command: string, args?: string[], options: RunOptions = {}) => { + const baseDir = await writeFiles(testInfo, files, true); + return startPlaywrightChildProcess(childProcess, baseDir, [command, ...(args || [])], { PWTEST_CACHE_DIR: cacheDir }, options); + }); + await removeFolders([cacheDir]); + }, + runWatchTest: async ({ interactWithTestRunner }, use, testInfo: TestInfo) => { await use((files, params, env, options) => interactWithTestRunner(files, params, { ...env, PWTEST_WATCH: '1' }, options)); }, diff --git a/tests/playwright-test/playwright.trace.spec.ts b/tests/playwright-test/playwright.trace.spec.ts index 9a692245f0..e4a1efe4d7 100644 --- a/tests/playwright-test/playwright.trace.spec.ts +++ b/tests/playwright-test/playwright.trace.spec.ts @@ -1178,3 +1178,51 @@ test('should record trace for manually created context in a failed test', async // Check console events to make sure that library trace is recorded. expect(trace.events).toContainEqual(expect.objectContaining({ type: 'console', text: 'from the page' })); }); + +test('should not nest top level expect into unfinished api calls ', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/31959' } +}, async ({ runInlineTest, server }) => { + server.setRoute('/index', (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(`
Hello!
`); + }); + server.setRoute('/hang', () => {}); + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('pass', async ({ page }) => { + await page.route('**/api', async route => { + const response = await route.fetch({ url: '${server.PREFIX}/hang' }); + await route.fulfill({ response }); + }); + await page.goto('${server.PREFIX}/index'); + await expect(page.getByText('Hello!')).toBeVisible(); + await page.unrouteAll({ behavior: 'ignoreErrors' }); + }); + `, + }, { trace: 'on' }); + expect(result.exitCode).toBe(0); + expect(result.failed).toBe(0); + + const tracePath = test.info().outputPath('test-results', 'a-pass', 'trace.zip'); + const trace = await parseTrace(tracePath); + expect(trace.actionTree).toEqual([ + 'Before Hooks', + ' fixture: browser', + ' browserType.launch', + ' fixture: context', + ' browser.newContext', + ' fixture: page', + ' browserContext.newPage', + 'page.route', + 'page.goto', + 'route.fetch', + 'expect.toBeVisible', + 'page.unrouteAll', + 'After Hooks', + ' fixture: page', + ' fixture: context', + ]); +}); + + diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index e416cd05c1..768cd28fc2 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -83,7 +83,7 @@ for (const useIntermediateMergeReport of [false] as const) { await expect(page.getByTestId('overall-duration'), 'should contain humanized total time with at most 1 decimal place').toContainText(/^Total time: \d+(\.\d)?(ms|s|m)$/); await expect(page.getByTestId('project-name'), 'should contain project name').toContainText('project-name'); - await expect(page.locator('.metadata-view')).not.toBeVisible(); + await expect(page.getByTestId('metadata-view')).not.toBeVisible(); }); test('should allow navigating to testId=test.id', async ({ runInlineTest, page, showReport }) => { diff --git a/tests/playwright-test/resolver.spec.ts b/tests/playwright-test/resolver.spec.ts index 4092263648..5a0e91b099 100644 --- a/tests/playwright-test/resolver.spec.ts +++ b/tests/playwright-test/resolver.spec.ts @@ -641,3 +641,55 @@ test('should respect tsconfig project references', async ({ runInlineTest }) => expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); }); + +test('should respect --tsconfig option', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + import { foo } from '~/foo'; + export default { + testDir: './tests' + foo, + }; + `, + 'tsconfig.json': `{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/*": ["./does-not-exist/*"], + }, + }, + }`, + 'tsconfig.special.json': `{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/*": ["./mapped-from-root/*"], + }, + }, + }`, + 'mapped-from-root/foo.ts': ` + export const foo = 42; + `, + 'tests42/tsconfig.json': `{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/*": ["../should-be-ignored/*"], + }, + }, + }`, + 'tests42/a.test.ts': ` + import { foo } from '~/foo'; + import { test, expect } from '@playwright/test'; + test('test', ({}) => { + expect(foo).toBe(42); + }); + `, + 'should-be-ignored/foo.ts': ` + export const foo = 43; + `, + }, { tsconfig: 'tsconfig.special.json' }); + + expect(result.passed).toBe(1); + expect(result.exitCode).toBe(0); + expect(result.output).not.toContain(`Could not`); +}); diff --git a/tests/playwright-test/test-server-connection.spec.ts b/tests/playwright-test/test-server-connection.spec.ts new file mode 100644 index 0000000000..af5f0223e5 --- /dev/null +++ b/tests/playwright-test/test-server-connection.spec.ts @@ -0,0 +1,97 @@ +/** + * 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 { test as baseTest, expect } from './ui-mode-fixtures'; + +import { TestServerConnection } from '../../packages/playwright/lib/isomorphic/testServerConnection'; + +class TestServerConnectionUnderTest extends TestServerConnection { + events: [string, any][] = []; + + constructor(wsUrl: string) { + super(wsUrl); + this.onTestFilesChanged(params => this.events.push(['testFilesChanged', params])); + this.onStdio(params => this.events.push(['stdio', params])); + this.onLoadTraceRequested(params => this.events.push(['loadTraceRequested', params])); + } +} + +const test = baseTest.extend<{ testServerConnection: TestServerConnectionUnderTest }>({ + testServerConnection: async ({ startCLICommand }, use, testInfo) => { + testInfo.skip(!globalThis.WebSocket, 'WebSocket not available prior to Node 22.4.0'); + + const testServerProcess = await startCLICommand({}, 'test-server'); + + await testServerProcess.waitForOutput('Listening on'); + const line = testServerProcess.output.split('\n').find(l => l.includes('Listening on')); + const wsEndpoint = line!.split(' ')[2]; + + await use(new TestServerConnectionUnderTest(wsEndpoint)); + + await testServerProcess.kill(); + } +}); + +test('file watching', async ({ testServerConnection, writeFiles }, testInfo) => { + await writeFiles({ + 'utils.ts': ` + export const expected = 42; + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + import { expected } from "./utils"; + test('foo', () => { + expect(123).toBe(expected); + }); + `, + }); + + const tests = await testServerConnection.listTests({}); + expect(tests.report.map(e => e.method)).toEqual(['onConfigure', 'onProject', 'onBegin', 'onEnd']); + + await testServerConnection.watch({ fileNames: [testInfo.outputPath('a.test.ts')] }); + + await writeFiles({ + 'utils.ts': ` + export const expected = 123; + `, + }); + + await expect.poll(() => testServerConnection.events).toHaveLength(1); + expect(testServerConnection.events).toEqual([ + ['testFilesChanged', { testFiles: [testInfo.outputPath('a.test.ts')] }] + ]); +}); + +test('stdio interception', async ({ testServerConnection, writeFiles }) => { + await testServerConnection.initialize({ interceptStdio: true }); + await writeFiles({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('foo', () => { + console.log("this goes to stdout"); + console.error("this goes to stderr"); + expect(true).toBe(true); + }); + `, + }); + + const tests = await testServerConnection.runTests({ trace: 'on' }); + expect(tests).toEqual({ status: 'passed' }); + await expect.poll(() => testServerConnection.events).toEqual(expect.arrayContaining([ + ['stdio', { type: 'stderr', text: 'this goes to stderr\n' }], + ['stdio', { type: 'stdout', text: 'this goes to stdout\n' }] + ])); +}); \ No newline at end of file diff --git a/tests/playwright-test/test-step.spec.ts b/tests/playwright-test/test-step.spec.ts index c35070c945..f7538de3e9 100644 --- a/tests/playwright-test/test-step.spec.ts +++ b/tests/playwright-test/test-step.spec.ts @@ -542,7 +542,7 @@ fixture | fixture: browser `); }); -test('should nest page.continue inside page.goto steps', async ({ runInlineTest }) => { +test('should not nest page.continue inside page.goto steps', async ({ runInlineTest }) => { const result = await runInlineTest({ 'reporter.ts': stepIndentReporter, 'playwright.config.ts': `module.exports = { reporter: './reporter', };`, @@ -566,7 +566,7 @@ fixture | fixture: page pw:api | browserContext.newPage pw:api |page.route @ a.test.ts:4 pw:api |page.goto(http://localhost:1234) @ a.test.ts:5 -pw:api | route.fulfill @ a.test.ts:4 +pw:api |route.fulfill @ a.test.ts:4 hook |After Hooks fixture | fixture: page fixture | fixture: context @@ -1154,7 +1154,7 @@ pw:api | browserContext.newPage fixture | fixture: request pw:api | apiRequest.newContext pw:api |page.waitForNavigation @ a.test.ts:5 -pw:api | page.goto(data:text/html,) @ a.test.ts:6 +pw:api |page.goto(data:text/html,) @ a.test.ts:6 pw:api |page.click(button) @ a.test.ts:8 pw:api |locator.getByRole('button').click @ a.test.ts:9 pw:api |apiRequestContext.get(http://localhost2) @ a.test.ts:10 diff --git a/tests/playwright-test/ui-mode-test-annotations.spec.ts b/tests/playwright-test/ui-mode-test-annotations.spec.ts new file mode 100644 index 0000000000..7a0dea8af1 --- /dev/null +++ b/tests/playwright-test/ui-mode-test-annotations.spec.ts @@ -0,0 +1,49 @@ +/** + * 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 { test, expect } from './ui-mode-fixtures'; + +test('should display annotations', async ({ runUITest }) => { + const { page } = await runUITest({ + 'a.test.ts': ` + import { test } from '@playwright/test'; + test.describe('suite', { + annotation: { type: 'suite annotation', description: 'Some content' } + }, () => { + test('annotation test', { + annotation: { type: 'bug report', description: 'Report https://github.com/microsoft/playwright/issues/30095 is here' } + }, async () => { + test.info().annotations.push({ type: 'test repo', description: 'https://github.com/microsoft/playwright' }); + }); + }); + `, + }); + await page.getByTitle('Run all').click(); + await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)'); + await page.getByRole('listitem').filter({ hasText: 'suite' }).locator('.codicon-chevron-right').click(); + await page.getByText('annotation test').click(); + await page.getByText('Annotations', { exact: true }).click(); + + const annotations = page.locator('.annotations-tab'); + await expect(annotations.getByText('suite annotation')).toBeVisible(); + await expect(annotations.getByText('bug report')).toBeVisible(); + await expect(annotations.locator('.annotation-item').filter({ hasText: 'bug report' }).locator('a')) + .toHaveAttribute('href', 'https://github.com/microsoft/playwright/issues/30095'); + await expect(annotations.getByText('test repo')).toBeVisible(); + await expect(annotations.locator('.annotation-item').filter({ hasText: 'test repo' }).locator('a')) + .toHaveAttribute('href', 'https://github.com/microsoft/playwright'); +}); + diff --git a/tests/playwright-test/ui-mode-test-attachments.spec.ts b/tests/playwright-test/ui-mode-test-attachments.spec.ts index f3c8ac627b..7016a36115 100644 --- a/tests/playwright-test/ui-mode-test-attachments.spec.ts +++ b/tests/playwright-test/ui-mode-test-attachments.spec.ts @@ -23,7 +23,10 @@ test('should contain text attachment', async ({ runUITest }) => { 'a.test.ts': ` import { test } from '@playwright/test'; test('attach test', async () => { + // Attach two files with the same content and different names, + // to make sure each is downloaded with an intended name. await test.info().attach('file attachment', { path: __filename }); + await test.info().attach('file attachment 2', { path: __filename }); await test.info().attach('text attachment', { body: 'hi tester!', contentType: 'text/plain' }); }); `, @@ -35,14 +38,24 @@ test('should contain text attachment', async ({ runUITest }) => { await page.locator('.tab-attachments').getByText('text attachment').click(); await expect(page.locator('.tab-attachments')).toContainText('hi tester!'); - await page.locator('.tab-attachments').getByText('file attachment').click(); + await page.locator('.tab-attachments').getByText('file attachment').first().click(); await expect(page.locator('.tab-attachments')).not.toContainText('attach test'); - const downloadPromise = page.waitForEvent('download'); - await page.getByRole('link', { name: 'download' }).first().click(); - const download = await downloadPromise; - expect(download.suggestedFilename()).toBe('file attachment'); - expect((await readAllFromStream(await download.createReadStream())).toString()).toContain('attach test'); + { + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('link', { name: 'download' }).first().click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe('file attachment'); + expect((await readAllFromStream(await download.createReadStream())).toString()).toContain('attach test'); + } + + { + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('link', { name: 'download' }).nth(1).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe('file attachment 2'); + expect((await readAllFromStream(await download.createReadStream())).toString()).toContain('attach test'); + } }); test('should contain binary attachment', async ({ runUITest }) => { @@ -86,6 +99,55 @@ test('should contain string attachment', async ({ runUITest }) => { expect((await readAllFromStream(await download.createReadStream())).toString()).toEqual('text42'); }); +test('should linkify string attachments', async ({ runUITest, server }) => { + server.setRoute('/one.html', (req, res) => res.end()); + server.setRoute('/two.html', (req, res) => res.end()); + server.setRoute('/three.html', (req, res) => res.end()); + + const { page } = await runUITest({ + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('attach test', async () => { + await test.info().attach('Inline url: ${server.PREFIX + '/one.html'}'); + await test.info().attach('Second', { body: 'Inline link ${server.PREFIX + '/two.html'} to be highlighted.' }); + await test.info().attach('Third', { body: '[markdown link](${server.PREFIX + '/three.html'})', contentType: 'text/markdown' }); + }); + `, + }); + await page.getByText('attach test').click(); + await page.getByTitle('Run all').click(); + await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)'); + await page.getByText('Attachments').click(); + + const attachmentsPane = page.locator('.attachments-tab'); + + { + const url = server.PREFIX + '/one.html'; + const promise = page.waitForEvent('popup'); + await attachmentsPane.getByText(url).click(); + const popup = await promise; + await expect(popup).toHaveURL(url); + } + + { + await attachmentsPane.getByText('Second download').click(); + const url = server.PREFIX + '/two.html'; + const promise = page.waitForEvent('popup'); + await attachmentsPane.getByText(url).click(); + const popup = await promise; + await expect(popup).toHaveURL(url); + } + + { + await attachmentsPane.getByText('Third download').click(); + const url = server.PREFIX + '/three.html'; + const promise = page.waitForEvent('popup'); + await attachmentsPane.getByText('[markdown link]').click(); + const popup = await promise; + await expect(popup).toHaveURL(url); + } +}); + function readAllFromStream(stream: NodeJS.ReadableStream): Promise { return new Promise(resolve => { const chunks: Buffer[] = []; diff --git a/tests/playwright-test/ui-mode-test-network-tab.spec.ts b/tests/playwright-test/ui-mode-test-network-tab.spec.ts new file mode 100644 index 0000000000..45d77aa528 --- /dev/null +++ b/tests/playwright-test/ui-mode-test-network-tab.spec.ts @@ -0,0 +1,95 @@ +/** + * 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 { expect, test } from './ui-mode-fixtures'; + +test('should filter network requests by resource type', async ({ runUITest, server }) => { + server.setRoute('/api/endpoint', (_, res) => res.setHeader('Content-Type', 'application/json').end()); + + const { page } = await runUITest({ + 'network-tab.test.ts': ` + import { test, expect } from '@playwright/test'; + test('network tab test', async ({ page }) => { + await page.goto('${server.PREFIX}/network-tab/network.html'); + }); + `, + }); + + await page.getByText('network tab test').dblclick(); + await page.getByText('Network', { exact: true }).click(); + + const networkItems = page.getByTestId('network-list').getByRole('listitem'); + + await page.getByText('JS', { exact: true }).click(); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('script.js')).toBeVisible(); + + await page.getByText('CSS', { exact: true }).click(); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('style.css')).toBeVisible(); + + await page.getByText('Image', { exact: true }).click(); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('image.png')).toBeVisible(); + + await page.getByText('Fetch', { exact: true }).click(); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('endpoint')).toBeVisible(); + + await page.getByText('HTML', { exact: true }).click(); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('network.html')).toBeVisible(); + + await page.getByText('Font', { exact: true }).click(); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('font.woff2')).toBeVisible(); +}); + +test('should filter network requests by url', async ({ runUITest, server }) => { + const { page } = await runUITest({ + 'network-tab.test.ts': ` + import { test, expect } from '@playwright/test'; + test('network tab test', async ({ page }) => { + await page.goto('${server.PREFIX}/network-tab/network.html'); + }); + `, + }); + + await page.getByText('network tab test').dblclick(); + await page.getByText('Network', { exact: true }).click(); + + const networkItems = page.getByTestId('network-list').getByRole('listitem'); + + await page.getByPlaceholder('Filter network').fill('script.'); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('script.js')).toBeVisible(); + + await page.getByPlaceholder('Filter network').fill('png'); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('image.png')).toBeVisible(); + + await page.getByPlaceholder('Filter network').fill('api/'); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('endpoint')).toBeVisible(); + + await page.getByPlaceholder('Filter network').fill('End'); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('endpoint')).toBeVisible(); + + await page.getByPlaceholder('Filter network').fill('FON'); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('font.woff2')).toBeVisible(); +}); diff --git a/tests/playwright-test/ui-mode-test-screencast.spec.ts b/tests/playwright-test/ui-mode-test-screencast.spec.ts index 7c973f588b..23e4f5872e 100644 --- a/tests/playwright-test/ui-mode-test-screencast.spec.ts +++ b/tests/playwright-test/ui-mode-test-screencast.spec.ts @@ -21,13 +21,15 @@ test.describe.configure({ mode: 'parallel', retries }); test('should show screenshots', async ({ runUITest }) => { const { page } = await runUITest({ 'a.test.ts': ` - import { test } from '@playwright/test'; + import { test, expect } from '@playwright/test'; test('test 1', async ({ page }) => { await page.setContent('
'); + await expect(page.locator('body')).toBeVisible(); await page.waitForTimeout(1000); }); test('test 2', async ({ page }) => { - await page.setContent('
'); + await page.setContent('
hello
'); + await expect(page.locator('body')).toHaveText('hello'); await page.waitForTimeout(1000); }); `, @@ -36,14 +38,10 @@ test('should show screenshots', async ({ runUITest }) => { await expect(page.getByTestId('status-line')).toHaveText('2/2 passed (100%)'); await page.getByText('test 1', { exact: true }).click(); - await expect( - page.locator('.CodeMirror .source-line-running'), - ).toContainText(`test('test 1', async ({ page }) => {`); + await expect(page.getByTestId('actions-tree')).toContainText('expect.toBeVisible'); await expect(page.locator('.film-strip-frame').first()).toBeVisible(); await page.getByText('test 2', { exact: true }).click(); - await expect( - page.locator('.CodeMirror .source-line-running'), - ).toContainText(`await page.waitForTimeout(1000);`); + await expect(page.getByTestId('actions-tree')).toContainText('expect.toHaveText'); await expect(page.locator('.film-strip-frame').first()).toBeVisible(); }); diff --git a/tests/playwright-test/ui-mode-test-watch.spec.ts b/tests/playwright-test/ui-mode-test-watch.spec.ts index f248baaacd..bd04750a1f 100644 --- a/tests/playwright-test/ui-mode-test-watch.spec.ts +++ b/tests/playwright-test/ui-mode-test-watch.spec.ts @@ -220,6 +220,37 @@ test('should watch new file', async ({ runUITest, writeFiles }) => { `); }); +test('should run added test in watched file', async ({ runUITest, writeFiles }) => { + const { page } = await runUITest({ + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('foo', () => {}); + `, + }); + + await page.getByText('a.test.ts').click(); + await page.getByRole('listitem').filter({ hasText: 'a.test.ts' }).getByTitle('Watch').click(); + + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ◯ a.test.ts 👁 <= + ◯ foo + `); + + await writeFiles({ + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('foo', () => {}); + test('bar', () => {}); + `, + }); + + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ✅ a.test.ts 👁 <= + ✅ foo + ✅ bar + `); +}); + test('should queue watches', async ({ runUITest, writeFiles, createLatch }) => { const latch = createLatch(); const { page } = await runUITest({ diff --git a/utils/build/build-playwright-driver.sh b/utils/build/build-playwright-driver.sh index 7aaee25b62..0ee23c15d9 100755 --- a/utils/build/build-playwright-driver.sh +++ b/utils/build/build-playwright-driver.sh @@ -31,7 +31,7 @@ function build { mkdir -p ./output/playwright-${SUFFIX} tar -xzf ./output/playwright-core.tgz -C ./output/playwright-${SUFFIX}/ - curl ${NODE_URL} -o ./output/${NODE_DIR}.${ARCHIVE} + curl --retry 10 --retry-all-errors ${NODE_URL} -o ./output/${NODE_DIR}.${ARCHIVE} NPM_PATH="" if [[ "${ARCHIVE}" == "zip" ]]; then cd ./output diff --git a/utils/doclint/generateDotnetApi.js b/utils/doclint/generateDotnetApi.js index 9c094026f9..aa8210f619 100644 --- a/utils/doclint/generateDotnetApi.js +++ b/utils/doclint/generateDotnetApi.js @@ -400,10 +400,18 @@ function generateNameDefault(member, name, t, parent) { if (names[2] === names[1]) names.pop(); // get rid of duplicates, cheaply let attemptedName = names.pop(); - const typesDiffer = function(left, right) { + const typesDiffer = function(/** @type {Documentation.Type} */ left, /** @type {Documentation.Type} */ right) { if (left.expression && right.expression) return left.expression !== right.expression; - return JSON.stringify(right.properties) !== JSON.stringify(left.properties); + const toExpression = (/** @type {Documentation.Member} */ t) => t.name + t.type?.expression; + const leftOverRightProperties = new Set(left.properties?.map(toExpression) ?? []); + for (const prop of right.properties ?? []) { + const expression = toExpression(prop); + if (!leftOverRightProperties.has(expression)) + return true; + leftOverRightProperties.delete(expression); + } + return leftOverRightProperties.size > 0; }; while (true) { // crude attempt at removing plurality diff --git a/utils/doclint/linting-code-snippets/csharp/linting-code-snippets.csproj b/utils/doclint/linting-code-snippets/csharp/linting-code-snippets.csproj index bc4a164528..3df82e0a6a 100644 --- a/utils/doclint/linting-code-snippets/csharp/linting-code-snippets.csproj +++ b/utils/doclint/linting-code-snippets/csharp/linting-code-snippets.csproj @@ -9,7 +9,7 @@ - + diff --git a/utils/doclint/missingDocs.js b/utils/doclint/missingDocs.js index 8042bc4222..7fffc156dc 100644 --- a/utils/doclint/missingDocs.js +++ b/utils/doclint/missingDocs.js @@ -56,7 +56,7 @@ module.exports = function lint(documentation, jsSources, apiFileName) { continue; } for (const member of cls.membersArray) { - if (member.kind === 'event') + if (member.kind === 'event' || member.alias === 'removeAllListeners') continue; const params = methods.get(member.alias); if (!params) { diff --git a/utils/generate_types/overrides.d.ts b/utils/generate_types/overrides.d.ts index 1f90713e92..578602959a 100644 --- a/utils/generate_types/overrides.d.ts +++ b/utils/generate_types/overrides.d.ts @@ -62,6 +62,9 @@ export interface Page { exposeBinding(name: string, playwrightBinding: (source: BindingSource, arg: JSHandle) => any, options: { handle: true }): Promise; exposeBinding(name: string, playwrightBinding: (source: BindingSource, ...args: any[]) => any, options?: { handle?: boolean }): Promise; + + removeAllListeners(type?: string): this; + removeAllListeners(type: string | undefined, options: { behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; } export interface Frame { @@ -101,6 +104,14 @@ export interface BrowserContext { exposeBinding(name: string, playwrightBinding: (source: BindingSource, ...args: any[]) => any, options?: { handle?: boolean }): Promise; addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg): Promise; + + removeAllListeners(type?: string): this; + removeAllListeners(type: string | undefined, options: { behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; +} + +export interface Browser { + removeAllListeners(type?: string): this; + removeAllListeners(type: string | undefined, options: { behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; } export interface Worker {