Merge branch 'main' into fix-31811

This commit is contained in:
Simon Knott 2024-08-07 17:10:53 +02:00
commit 6fc98af1d0
No known key found for this signature in database
GPG key ID: 8CEDC00028084AEC
157 changed files with 3313 additions and 1697 deletions

View file

@ -14,7 +14,7 @@ env:
jobs: jobs:
publish-canary: publish-canary:
name: "publish canary NPM" name: "publish canary NPM"
runs-on: ubuntu-20.04 runs-on: ubuntu-24.04
if: github.repository == 'microsoft/playwright' if: github.repository == 'microsoft/playwright'
permissions: permissions:
id-token: write # This is required for OIDC login (azure/login) to succeed id-token: write # This is required for OIDC login (azure/login) to succeed

View file

@ -10,7 +10,7 @@ env:
jobs: jobs:
publish-driver-release: publish-driver-release:
name: "publish playwright driver to CDN" name: "publish playwright driver to CDN"
runs-on: ubuntu-20.04 runs-on: ubuntu-24.04
if: github.repository == 'microsoft/playwright' if: github.repository == 'microsoft/playwright'
permissions: permissions:
id-token: write # This is required for OIDC login (azure/login) to succeed id-token: write # This is required for OIDC login (azure/login) to succeed

View file

@ -529,7 +529,7 @@ jobs:
build-playwright-driver: build-playwright-driver:
name: "build-playwright-driver" name: "build-playwright-driver"
runs-on: ubuntu-20.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4

View file

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

View file

@ -296,6 +296,18 @@ testing frameworks should explicitly create [`method: Browser.newContext`] follo
### option: Browser.newPage.storageStatePath = %%-csharp-java-context-option-storage-state-path-%% ### option: Browser.newPage.storageStatePath = %%-csharp-java-context-option-storage-state-path-%%
* since: v1.9 * 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 ## async method: Browser.startTracing
* since: v1.11 * since: v1.11
* langs: java, js, python * langs: java, js, python

View file

@ -1016,6 +1016,18 @@ Creates a new page in the browser context.
Returns all open pages in the 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 ## property: BrowserContext.request
* since: v1.16 * since: v1.16
* langs: * langs:

View file

@ -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. 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 ## async method: Page.removeLocatorHandler
* since: v1.44 * since: v1.44
@ -3594,7 +3605,7 @@ A glob pattern, regular expression or predicate to match the request URL. Only r
* since: v1.32 * since: v1.32
- `updateMode` <[HarMode]<"full"|"minimal">> - `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 ### option: Page.routeFromHAR.updateContent
* since: v1.32 * since: v1.32

View file

@ -39,7 +39,7 @@ Optional error code. Defaults to `failed`, could be one of the following:
- alias-java: resume - alias-java: resume
- alias-python: continue_ - alias-python: continue_
Continues route's request with optional overrides. Sends route's request to the network with optional overrides.
**Usage** **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. 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 ### option: Route.continue.url
* since: v1.8 * since: v1.8
- `url` <[string]> - `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 ## async method: Route.fallback
* since: v1.23 * 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. 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, 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 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. in the end will be aborted by the first registered route.
**Usage**
```js ```js
await page.route('**/*', async route => { await page.route('**/*', async route => {
// Runs last. // 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 ### option: Route.fallback.url
* since: v1.23 * since: v1.23
- `url` <[string]> - `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. 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. 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 ### option: Route.fetch.timeout
* since: v1.33 * since: v1.33
- `timeout` <[float]> - `timeout` <[float]>

View file

@ -524,9 +524,9 @@ Does not enforce fixed viewport, allows resizing window in the headed mode.
## context-option-clientCertificates ## context-option-clientCertificates
- `clientCertificates` <[Array]<[Object]>> - `clientCertificates` <[Array]<[Object]>>
- `origin` <[string]> Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port. - `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. - `certPath` ?<[path]> Path to the file with the certificate in PEM format.
- `keyPath` ?<[string]> Path to the file with the private key in PEM format. - `keyPath` ?<[path]> 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. - `pfxPath` ?<[path]> Path to the PFX or PKCS12 encoded private key and certificate chain.
- `passphrase` ?<[string]> Passphrase for the private key (PEM or PFX). - `passphrase` ?<[string]> Passphrase for the private key (PEM or PFX).
TLS Client Authentication allows the server to request a client certificate and verify it. 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. * `'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. * `'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 ## unroute-all-options-behavior
* langs: js, csharp, python * langs: js, csharp, python
* since: v1.41 * 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 * `'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 * `'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 ## select-options-values
* langs: java, js, csharp * langs: java, js, csharp
- `values` <[null]|[string]|[ElementHandle]|[Array]<[string]>|[Object]|[Array]<[ElementHandle]>|[Array]<[Object]>> - `values` <[null]|[string]|[ElementHandle]|[Array]<[string]>|[Object]|[Array]<[ElementHandle]>|[Array]<[Object]>>

View file

@ -47,8 +47,9 @@ Create `tests/auth.setup.ts` that will prepare authenticated browser state for a
```js title="tests/auth.setup.ts" ```js title="tests/auth.setup.ts"
import { test as setup, expect } from '@playwright/test'; 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 }) => { setup('authenticate', async ({ page }) => {
// Perform authentication steps. Replace these actions with your own. // 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 ### Authenticating in UI mode
* langs: js * langs: js

View file

@ -31,6 +31,7 @@ The recommended approach is to use `setFixedTime` to set the time to a specific
- `requestIdleCallback` - `requestIdleCallback`
- `cancelIdleCallback` - `cancelIdleCallback`
- `performance` - `performance`
- `Event.timeStamp`
::: :::
## Test with predefined time ## Test with predefined time

View file

@ -4,6 +4,50 @@ title: "Release notes"
toc_max_heading_level: 2 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 ## Version 1.45
### Clock ### Clock
@ -112,7 +156,6 @@ await Page.RemoveLocatorHandlerAsync(locator);
**Miscellaneous options** **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()`: - 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 ```csharp
var formData = Context.APIRequest.CreateFormData(); var formData = Context.APIRequest.CreateFormData();
formData.Append("file", new FilePayload() formData.Append("file", new FilePayload()

View file

@ -4,6 +4,45 @@ title: "Release notes"
toc_max_heading_level: 2 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 ## Version 1.45
### Clock ### Clock

View file

@ -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`]. 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 ### Component Testing: New `router` fixture
This release introduces an experimental `router` fixture to intercept and handle network requests in component testing. 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). 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 ### UI Mode / Trace Viewer Updates
- New testing options pane in the UI mode to control test execution, for example "single worker" or "headed browser". - Test annotations are now shown in UI mode.
- New setting to show/hide routing actions like `route.continue`. - 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. - Request method and status are shown in the network details tab.
- New button to copy source file location to clipboard. - 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`. - Metadata pane now displays the `baseURL`.
### Miscellaneous ### Miscellaneous
- New `maxRetries` option in [`method: APIRequestContext.fetch`] which retries on the `ECONNRESET` network error. - 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 ### Browser Versions
- Chromium 128.0.6613.7 - Chromium 128.0.6613.18
- Mozilla Firefox 128.0 - Mozilla Firefox 128.0
- WebKit 18.0 - WebKit 18.0

View file

@ -4,6 +4,50 @@ title: "Release notes"
toc_max_heading_level: 2 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 ## Version 1.45
### Clock ### Clock

View file

@ -103,5 +103,6 @@ Complete set of Playwright Test options is available in the [configuration file]
| `--shard <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`.| | `--shard <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 <number>` | Maximum timeout in milliseconds for each test, defaults to 30 seconds. Learn more about [various timeouts](./test-timeouts.md).| | `--timeout <number>` | Maximum timeout in milliseconds for each test, defaults to 30 seconds. Learn more about [various timeouts](./test-timeouts.md).|
| `--trace <mode>` | Force tracing mode, can be `on`, `off`, `on-first-retry`, `on-all-retries`, `retain-on-failure` | | `--trace <mode>` | Force tracing mode, can be `on`, `off`, `on-first-retry`, `on-all-retries`, `retain-on-failure` |
| `--tsconfig <path>` | 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.| | `--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 <number>` or `-j <number>`| The maximum number of concurrent worker processes that run in [parallel](./test-parallel.md). | | `--workers <number>` or `-j <number>`| The maximum number of concurrent worker processes that run in [parallel](./test-parallel.md). |

View file

@ -697,7 +697,7 @@ test('update', async ({ mount }) => {
```js ```js
test('update', async ({ mount }) => { test('update', async ({ mount }) => {
const component = await mount(<Component/>); const component = await mount(Component);
await component.update({ await component.update({
props: { msg: 'greetings' }, props: { msg: 'greetings' },
on: { callback: () => {} }, on: { callback: () => {} },
@ -711,7 +711,7 @@ test('update', async ({ mount }) => {
```js ```js
test('update', async ({ mount }) => { test('update', async ({ mount }) => {
const component = await mount(<Component/>); const component = await mount(Component);
await component.update({ await component.update({
props: { msg: 'greetings' }, props: { msg: 'greetings' },
on: { callback: () => {} }, on: { callback: () => {} },

View file

@ -5,9 +5,9 @@ title: "TypeScript"
## Introduction ## 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 ```yaml
jobs: jobs:
@ -28,7 +28,7 @@ npx tsc -p tsconfig.json --noEmit -w
## tsconfig.json ## 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. 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. 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": { "compilerOptions": {
"baseUrl": ".", // This must be specified if "paths" is. "baseUrl": ".",
"paths": { "paths": {
"@myhelper/*": ["packages/myhelper/*"] // This mapping is relative to "baseUrl". "@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 ## 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`. 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`.

View file

@ -19,6 +19,7 @@ import './chip.css';
import './colors.css'; import './colors.css';
import './common.css'; import './common.css';
import * as icons from './icons'; import * as icons from './icons';
import { clsx } from '@web/uiUtils';
export const Chip: React.FC<{ export const Chip: React.FC<{
header: JSX.Element | string, header: JSX.Element | string,
@ -31,14 +32,14 @@ export const Chip: React.FC<{
}> = ({ header, expanded, setExpanded, children, noInsets, dataTestId, targetRef }) => { }> = ({ header, expanded, setExpanded, children, noInsets, dataTestId, targetRef }) => {
return <div className='chip' data-testid={dataTestId} ref={targetRef}> return <div className='chip' data-testid={dataTestId} ref={targetRef}>
<div <div
className={'chip-header' + (setExpanded ? ' expanded-' + expanded : '')} className={clsx('chip-header', setExpanded && ' expanded-' + expanded)}
onClick={() => setExpanded?.(!expanded)} onClick={() => setExpanded?.(!expanded)}
title={typeof header === 'string' ? header : undefined}> title={typeof header === 'string' ? header : undefined}>
{setExpanded && !!expanded && icons.downArrow()} {setExpanded && !!expanded && icons.downArrow()}
{setExpanded && !expanded && icons.rightArrow()} {setExpanded && !expanded && icons.rightArrow()}
{header} {header}
</div> </div>
{(!setExpanded || expanded) && <div className={'chip-body' + (noInsets ? ' chip-body-no-insets' : '')}>{children}</div>} {(!setExpanded || expanded) && <div className={clsx('chip-body', noInsets && 'chip-body-no-insets')}>{children}</div>}
</div>; </div>;
}; };

View file

@ -26,6 +26,12 @@ import { ReportView } from './reportView';
// @ts-ignore // @ts-ignore
const zipjs = zipImport as typeof zip; 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 ReportLoader: React.FC = () => {
const [report, setReport] = React.useState<LoadedReport | undefined>(); const [report, setReport] = React.useState<LoadedReport | undefined>();
React.useEffect(() => { React.useEffect(() => {

View file

@ -20,7 +20,8 @@ import * as icons from './icons';
import { TreeItem } from './treeItem'; import { TreeItem } from './treeItem';
import { CopyToClipboard } from './copyToClipboard'; import { CopyToClipboard } from './copyToClipboard';
import './links.css'; import './links.css';
import { linkifyText } from './renderUtils'; import { linkifyText } from '@web/renderUtils';
import { clsx } from '@web/uiUtils';
export function navigate(href: string) { export function navigate(href: string) {
window.history.pushState({}, '', href); window.history.pushState({}, '', href);
@ -48,8 +49,8 @@ export const Link: React.FunctionComponent<{
className?: string, className?: string,
title?: string, title?: string,
children: any, children: any,
}> = ({ href, click, ctrlClick, className, children, title }) => { }> = ({ click, ctrlClick, children, ...rest }) => {
return <a style={{ textDecoration: 'none', color: 'var(--color-fg-default)', cursor: 'pointer' }} href={href} className={`${className || ''}`} title={title} onClick={e => { return <a {...rest} style={{ textDecoration: 'none', color: 'var(--color-fg-default)', cursor: 'pointer' }} onClick={e => {
if (click) { if (click) {
e.preventDefault(); e.preventDefault();
navigate(e.metaKey || e.ctrlKey ? ctrlClick || click : click); navigate(e.metaKey || e.ctrlKey ? ctrlClick || click : click);
@ -64,7 +65,7 @@ export const ProjectLink: React.FunctionComponent<{
const encoded = encodeURIComponent(projectName); const encoded = encodeURIComponent(projectName);
const value = projectName === encoded ? projectName : `"${encoded.replace(/%22/g, '%5C%22')}"`; const value = projectName === encoded ? projectName : `"${encoded.replace(/%22/g, '%5C%22')}"`;
return <Link href={`#?q=p:${value}`}> return <Link href={`#?q=p:${value}`}>
<span className={'label label-color-' + (projectNames.indexOf(projectName) % 6)} style={{ margin: '6px 0 0 6px' }}> <span className={clsx('label', `label-color-${projectNames.indexOf(projectName) % 6}`)} style={{ margin: '6px 0 0 6px' }}>
{projectName} {projectName}
</span> </span>
</Link>; </Link>;

View file

@ -14,6 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { clsx } from '@web/uiUtils';
import './tabbedPane.css'; import './tabbedPane.css';
import * as React from 'react'; import * as React from 'react';
@ -34,7 +35,7 @@ export const TabbedPane: React.FunctionComponent<{
<div className='hbox' style={{ flex: 'none' }}> <div className='hbox' style={{ flex: 'none' }}>
<div className='tabbed-pane-tab-strip'>{ <div className='tabbed-pane-tab-strip'>{
tabs.map(tab => ( tabs.map(tab => (
<div className={'tabbed-pane-tab-element ' + (selectedTab === tab.id ? 'selected' : '')} <div className={clsx('tabbed-pane-tab-element', selectedTab === tab.id && 'selected')}
onClick={() => setSelectedTab(tab.id)} onClick={() => setSelectedTab(tab.id)}
key={tab.id}> key={tab.id}>
<div className='tabbed-pane-tab-label'>{tab.title}</div> <div className='tabbed-pane-tab-label'>{tab.title}</div>

View file

@ -14,7 +14,6 @@
* limitations under the License. * limitations under the License.
*/ */
import React from 'react';
import { test, expect } from '@playwright/experimental-ct-react'; import { test, expect } from '@playwright/experimental-ct-react';
import { TestCaseView } from './testCaseView'; import { TestCaseView } from './testCaseView';
import type { TestCase, TestResult } from './types'; import type { TestCase, TestResult } from './types';

View file

@ -23,8 +23,9 @@ import { ProjectLink } from './links';
import { statusIcon } from './statusIcon'; import { statusIcon } from './statusIcon';
import './testCaseView.css'; import './testCaseView.css';
import { TestResultView } from './testResultView'; import { TestResultView } from './testResultView';
import { linkifyText } from './renderUtils'; import { linkifyText } from '@web/renderUtils';
import { hashStringToInt, msToString } from './utils'; import { hashStringToInt, msToString } from './utils';
import { clsx } from '@web/uiUtils';
export const TestCaseView: React.FC<{ export const TestCaseView: React.FC<{
projectNames: string[], projectNames: string[],
@ -90,7 +91,7 @@ const LabelsLinkView: React.FC<React.PropsWithChildren<{
<> <>
{labels.map(label => ( {labels.map(label => (
<a key={label} style={{ textDecoration: 'none', color: 'var(--color-fg-default)' }} href={`#?q=${label}`} > <a key={label} style={{ textDecoration: 'none', color: 'var(--color-fg-default)' }} href={`#?q=${label}`} >
<span style={{ margin: '6px 0 0 6px', cursor: 'pointer' }} className={'label label-color-' + (hashStringToInt(label))}> <span style={{ margin: '6px 0 0 6px', cursor: 'pointer' }} className={clsx('label', 'label-color-' + hashStringToInt(label))}>
{label.slice(1)} {label.slice(1)}
</span> </span>
</a> </a>

View file

@ -23,6 +23,7 @@ import { generateTraceUrl, Link, navigate, ProjectLink } from './links';
import { statusIcon } from './statusIcon'; import { statusIcon } from './statusIcon';
import './testFileView.css'; import './testFileView.css';
import { video, image, trace } from './icons'; import { video, image, trace } from './icons';
import { clsx } from '@web/uiUtils';
export const TestFileView: React.FC<React.PropsWithChildren<{ export const TestFileView: React.FC<React.PropsWithChildren<{
report: HTMLReport; report: HTMLReport;
@ -39,7 +40,7 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
{file.fileName} {file.fileName}
</span>}> </span>}>
{file.tests.filter(t => filter.matches(t)).map(test => {file.tests.filter(t => filter.matches(t)).map(test =>
<div key={`test-${test.testId}`} className={'test-file-test test-file-test-outcome-' + test.outcome}> <div key={`test-${test.testId}`} className={clsx('test-file-test', 'test-file-test-outcome-' + test.outcome)}>
<div className='hbox' style={{ alignItems: 'flex-start' }}> <div className='hbox' style={{ alignItems: 'flex-start' }}>
<div className='hbox'> <div className='hbox'>
<span className='test-file-test-status-icon'> <span className='test-file-test-status-icon'>
@ -101,7 +102,7 @@ const LabelsClickView: React.FC<React.PropsWithChildren<{
return labels.length > 0 ? ( return labels.length > 0 ? (
<> <>
{labels.map(label => ( {labels.map(label => (
<span key={label} style={{ margin: '6px 0 0 6px', cursor: 'pointer' }} className={'label label-color-' + (hashStringToInt(label))} onClick={e => onClickHandle(e, label)}> <span key={label} style={{ margin: '6px 0 0 6px', cursor: 'pointer' }} className={clsx('label', 'label-color-' + hashStringToInt(label))} onClick={e => onClickHandle(e, label)}>
{label.slice(1)} {label.slice(1)}
</span> </span>
))} ))}

View file

@ -3,15 +3,15 @@
"browsers": [ "browsers": [
{ {
"name": "chromium", "name": "chromium",
"revision": "1128", "revision": "1129",
"installByDefault": true, "installByDefault": true,
"browserVersion": "128.0.6613.7" "browserVersion": "128.0.6613.18"
}, },
{ {
"name": "chromium-tip-of-tree", "name": "chromium-tip-of-tree",
"revision": "1244", "revision": "1246",
"installByDefault": false, "installByDefault": false,
"browserVersion": "129.0.6616.0" "browserVersion": "129.0.6630.0"
}, },
{ {
"name": "firefox", "name": "firefox",
@ -27,7 +27,7 @@
}, },
{ {
"name": "webkit", "name": "webkit",
"revision": "2053", "revision": "2056",
"installByDefault": true, "installByDefault": true,
"revisionOverrides": { "revisionOverrides": {
"mac10.14": "1446", "mac10.14": "1446",
@ -41,7 +41,7 @@
}, },
{ {
"name": "ffmpeg", "name": "ffmpeg",
"revision": "1009", "revision": "1010",
"installByDefault": true "installByDefault": true
}, },
{ {

View file

@ -23,7 +23,7 @@
*/ */
type EventType = string | symbol; type EventType = string | symbol;
type Listener = (...args: any[]) => void; type Listener = (...args: any[]) => any;
type EventMap = Record<EventType, Listener | Listener[]>; type EventMap = Record<EventType, Listener | Listener[]>;
import { EventEmitter as OriginalEventEmitter } from 'events'; import { EventEmitter as OriginalEventEmitter } from 'events';
import type { EventEmitter as EventEmitterType } from 'events'; import type { EventEmitter as EventEmitterType } from 'events';
@ -34,6 +34,8 @@ export class EventEmitter implements EventEmitterType {
private _events: EventMap | undefined = undefined; private _events: EventMap | undefined = undefined;
private _eventsCount = 0; private _eventsCount = 0;
private _maxListeners: number | undefined = undefined; private _maxListeners: number | undefined = undefined;
readonly _pendingHandlers = new Map<EventType, Set<Promise<void>>>();
private _rejectionHandler: ((error: Error) => void) | undefined;
constructor() { constructor() {
if (this._events === undefined || this._events === Object.getPrototypeOf(this)._events) { if (this._events === undefined || this._events === Object.getPrototypeOf(this)._events) {
@ -66,15 +68,32 @@ export class EventEmitter implements EventEmitterType {
return false; return false;
if (typeof handler === 'function') { if (typeof handler === 'function') {
Reflect.apply(handler, this, args); this._callHandler(type, handler, args);
} else { } else {
const len = handler.length; const len = handler.length;
const listeners = handler.slice(); const listeners = handler.slice();
for (let i = 0; i < len; ++i) for (let i = 0; i < len; ++i)
Reflect.apply(listeners[i], this, args); this._callHandler(type, listeners[i], args);
}
return true;
} }
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 { addListener(type: EventType, listener: Listener): this {
@ -214,10 +233,34 @@ export class EventEmitter implements EventEmitterType {
return this.removeListener(type, listener); return this.removeListener(type, listener);
} }
removeAllListeners(type?: string): this { removeAllListeners(type?: EventType): this;
removeAllListeners(type: EventType | undefined, options: { behavior?: 'wait'|'ignoreErrors'|'default' }): Promise<void>;
removeAllListeners(type?: string, options?: { behavior?: 'wait'|'ignoreErrors'|'default' }): this | Promise<void> {
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; const events = this._events;
if (!events) if (!events)
return this; return;
// not listening for removeListener, no need to emit // not listening for removeListener, no need to emit
if (!events.removeListener) { if (!events.removeListener) {
@ -230,7 +273,7 @@ export class EventEmitter implements EventEmitterType {
else else
delete events[type]; delete events[type];
} }
return this; return;
} }
// emit removeListener for all listeners on all events // emit removeListener for all listeners on all events
@ -241,12 +284,12 @@ export class EventEmitter implements EventEmitterType {
key = keys[i]; key = keys[i];
if (key === 'removeListener') if (key === 'removeListener')
continue; continue;
this.removeAllListeners(key); this._removeAllListeners(key);
} }
this.removeAllListeners('removeListener'); this._removeAllListeners('removeListener');
this._events = Object.create(null); this._events = Object.create(null);
this._eventsCount = 0; this._eventsCount = 0;
return this; return;
} }
const listeners = events[type]; const listeners = events[type];
@ -258,8 +301,6 @@ export class EventEmitter implements EventEmitterType {
for (let i = listeners.length - 1; i >= 0; i--) for (let i = listeners.length - 1; i >= 0; i--)
this.removeListener(type, listeners[i]); this.removeListener(type, listeners[i]);
} }
return this;
} }
listeners(type: EventType): Listener[] { listeners(type: EventType): Listener[] {
@ -286,6 +327,18 @@ export class EventEmitter implements EventEmitterType {
return this._eventsCount > 0 && this._events ? Reflect.ownKeys(this._events) : []; return this._eventsCount > 0 && this._events ? Reflect.ownKeys(this._events) : [];
} }
private async _waitFor(type?: EventType) {
let promises: Promise<void>[] = [];
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[] { private _listeners(target: EventEmitter, type: EventType, unwrap: boolean): Listener[] {
const events = target._events; const events = target._events;
@ -310,7 +363,7 @@ function checkListener(listener: any) {
class OnceWrapper { class OnceWrapper {
private _fired = false; private _fired = false;
readonly wrapperFunction: (...args: any[]) => void; readonly wrapperFunction: (...args: any[]) => Promise<void> | void;
readonly _listener: Listener; readonly _listener: Listener;
private _eventEmitter: EventEmitter; private _eventEmitter: EventEmitter;
private _eventType: EventType; private _eventType: EventType;

View file

@ -86,7 +86,7 @@ export abstract class BrowserContext extends SdkObject {
private _customCloseHandler?: () => Promise<any>; private _customCloseHandler?: () => Promise<any>;
readonly _tempDirs: string[] = []; readonly _tempDirs: string[] = [];
private _settingStorageState = false; private _settingStorageState = false;
readonly initScripts: InitScript[] = []; initScripts: InitScript[] = [];
private _routesInFlight = new Set<network.Route>(); private _routesInFlight = new Set<network.Route>();
private _debugger!: Debugger; private _debugger!: Debugger;
_closeReason: string | undefined; _closeReason: string | undefined;
@ -271,9 +271,7 @@ export abstract class BrowserContext extends SdkObject {
protected abstract doClearPermissions(): Promise<void>; protected abstract doClearPermissions(): Promise<void>;
protected abstract doSetHTTPCredentials(httpCredentials?: types.Credentials): Promise<void>; protected abstract doSetHTTPCredentials(httpCredentials?: types.Credentials): Promise<void>;
protected abstract doAddInitScript(initScript: InitScript): Promise<void>; protected abstract doAddInitScript(initScript: InitScript): Promise<void>;
protected abstract doRemoveInitScripts(): Promise<void>; protected abstract doRemoveNonInternalInitScripts(): Promise<void>;
protected abstract doExposeBinding(binding: PageBinding): Promise<void>;
protected abstract doRemoveExposedBindings(): Promise<void>;
protected abstract doUpdateRequestInterception(): Promise<void>; protected abstract doUpdateRequestInterception(): Promise<void>;
protected abstract doClose(reason: string | undefined): Promise<void>; protected abstract doClose(reason: string | undefined): Promise<void>;
protected abstract onClosePersistent(): void; protected abstract onClosePersistent(): void;
@ -320,15 +318,16 @@ export abstract class BrowserContext extends SdkObject {
} }
const binding = new PageBinding(name, playwrightBinding, needsHandle); const binding = new PageBinding(name, playwrightBinding, needsHandle);
this._pageBindings.set(name, binding); 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() { async _removeExposedBindings() {
for (const key of this._pageBindings.keys()) { for (const [key, binding] of this._pageBindings) {
if (!key.startsWith('__pw')) if (!binding.internal)
this._pageBindings.delete(key); this._pageBindings.delete(key);
} }
await this.doRemoveExposedBindings();
} }
async grantPermissions(permissions: string[], origin?: string) { async grantPermissions(permissions: string[], origin?: string) {
@ -414,8 +413,8 @@ export abstract class BrowserContext extends SdkObject {
} }
async _removeInitScripts(): Promise<void> { async _removeInitScripts(): Promise<void> {
this.initScripts.splice(0, this.initScripts.length); this.initScripts = this.initScripts.filter(script => script.internal);
await this.doRemoveInitScripts(); await this.doRemoveNonInternalInitScripts();
} }
async setRequestInterceptor(handler: network.RouteHandler | undefined): Promise<void> { async setRequestInterceptor(handler: network.RouteHandler | undefined): Promise<void> {

View file

@ -21,7 +21,7 @@ import { Browser } from '../browser';
import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext'; import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext';
import { assert, createGuid } from '../../utils'; import { assert, createGuid } from '../../utils';
import * as network from '../network'; import * as network from '../network';
import type { InitScript, PageBinding, PageDelegate, Worker } from '../page'; import type { InitScript, PageDelegate, Worker } from '../page';
import { Page } from '../page'; import { Page } from '../page';
import { Frame } from '../frames'; import { Frame } from '../frames';
import type { Dialog } from '../dialog'; import type { Dialog } from '../dialog';
@ -491,19 +491,9 @@ export class CRBrowserContext extends BrowserContext {
await (page._delegate as CRPage).addInitScript(initScript); await (page._delegate as CRPage).addInitScript(initScript);
} }
async doRemoveInitScripts() { async doRemoveNonInternalInitScripts() {
for (const page of this.pages()) for (const page of this.pages())
await (page._delegate as CRPage).removeInitScripts(); await (page._delegate as CRPage).removeNonInternalInitScripts();
}
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();
} }
async doUpdateRequestInterception(): Promise<void> { async doUpdateRequestInterception(): Promise<void> {

View file

@ -26,7 +26,7 @@ import * as dom from '../dom';
import * as frames from '../frames'; import * as frames from '../frames';
import { helper } from '../helper'; import { helper } from '../helper';
import * as network from '../network'; import * as network from '../network';
import type { InitScript, PageBinding, PageDelegate } from '../page'; import { type InitScript, PageBinding, type PageDelegate } from '../page';
import { Page, Worker } from '../page'; import { Page, Worker } from '../page';
import type { Progress } from '../progress'; import type { Progress } from '../progress';
import type * as types from '../types'; import type * as types from '../types';
@ -182,15 +182,6 @@ export class CRPage implements PageDelegate {
return this._sessionForFrame(frame)._navigate(frame, url, referrer); 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<void> { async updateExtraHTTPHeaders(): Promise<void> {
const headers = network.mergeHeaders([ const headers = network.mergeHeaders([
this._browserContext._options.extraHTTPHeaders, this._browserContext._options.extraHTTPHeaders,
@ -260,7 +251,7 @@ export class CRPage implements PageDelegate {
await this._forAllFrameSessions(frame => frame._evaluateOnNewDocument(initScript, world)); await this._forAllFrameSessions(frame => frame._evaluateOnNewDocument(initScript, world));
} }
async removeInitScripts() { async removeNonInternalInitScripts() {
await this._forAllFrameSessions(frame => frame._removeEvaluatesOnNewDocument()); await this._forAllFrameSessions(frame => frame._removeEvaluatesOnNewDocument());
} }
@ -420,7 +411,6 @@ class FrameSession {
private _screencastId: string | null = null; private _screencastId: string | null = null;
private _screencastClients = new Set<any>(); private _screencastClients = new Set<any>();
private _evaluateOnNewDocumentIdentifiers: string[] = []; private _evaluateOnNewDocumentIdentifiers: string[] = [];
private _exposedBindingNames: string[] = [];
private _metricsOverride: Protocol.Emulation.setDeviceMetricsOverrideParameters | undefined; private _metricsOverride: Protocol.Emulation.setDeviceMetricsOverrideParameters | undefined;
private _workerSessions = new Map<string, CRSession>(); private _workerSessions = new Map<string, CRSession>();
@ -519,9 +509,7 @@ class FrameSession {
grantUniveralAccess: true, grantUniveralAccess: true,
worldName: UTILITY_WORLD_NAME, worldName: UTILITY_WORLD_NAME,
}); });
for (const binding of this._crPage._browserContext._pageBindings.values()) for (const initScript of this._crPage._page.allInitScripts())
frame.evaluateExpression(binding.source).catch(e => {});
for (const initScript of this._crPage._browserContext.initScripts)
frame.evaluateExpression(initScript.source).catch(e => {}); frame.evaluateExpression(initScript.source).catch(e => {});
} }
@ -541,6 +529,7 @@ class FrameSession {
this._client.send('Log.enable', {}), this._client.send('Log.enable', {}),
lifecycleEventsEnabled = this._client.send('Page.setLifecycleEventsEnabled', { enabled: true }), lifecycleEventsEnabled = this._client.send('Page.setLifecycleEventsEnabled', { enabled: true }),
this._client.send('Runtime.enable', {}), this._client.send('Runtime.enable', {}),
this._client.send('Runtime.addBinding', { name: PageBinding.kPlaywrightBinding }),
this._client.send('Page.addScriptToEvaluateOnNewDocument', { this._client.send('Page.addScriptToEvaluateOnNewDocument', {
source: '', source: '',
worldName: UTILITY_WORLD_NAME, worldName: UTILITY_WORLD_NAME,
@ -573,11 +562,7 @@ class FrameSession {
promises.push(this._updateGeolocation(true)); promises.push(this._updateGeolocation(true));
promises.push(this._updateEmulateMedia()); promises.push(this._updateEmulateMedia());
promises.push(this._updateFileChooserInterception(true)); promises.push(this._updateFileChooserInterception(true));
for (const binding of this._crPage._page.allBindings()) for (const initScript of this._crPage._page.allInitScripts())
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)
promises.push(this._evaluateOnNewDocument(initScript, 'main')); promises.push(this._evaluateOnNewDocument(initScript, 'main'));
if (screencastOptions) if (screencastOptions)
promises.push(this._startVideoRecording(screencastOptions)); promises.push(this._startVideoRecording(screencastOptions));
@ -834,25 +819,6 @@ class FrameSession {
this._page._addConsoleMessage(event.type, values, toConsoleMessageLocation(event.stackTrace)); 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) { async _onBindingCalled(event: Protocol.Runtime.bindingCalledPayload) {
const pageOrError = await this._crPage.pageOrError(); const pageOrError = await this._crPage.pageOrError();
if (!(pageOrError instanceof Error)) { if (!(pageOrError instanceof Error)) {
@ -1102,6 +1068,7 @@ class FrameSession {
async _evaluateOnNewDocument(initScript: InitScript, world: types.World): Promise<void> { async _evaluateOnNewDocument(initScript: InitScript, world: types.World): Promise<void> {
const worldName = world === 'utility' ? UTILITY_WORLD_NAME : undefined; const worldName = world === 'utility' ? UTILITY_WORLD_NAME : undefined;
const { identifier } = await this._client.send('Page.addScriptToEvaluateOnNewDocument', { source: initScript.source, worldName }); const { identifier } = await this._client.send('Page.addScriptToEvaluateOnNewDocument', { source: initScript.source, worldName });
if (!initScript.internal)
this._evaluateOnNewDocumentIdentifiers.push(identifier); this._evaluateOnNewDocumentIdentifiers.push(identifier);
} }

View file

@ -98,7 +98,7 @@ export class VideoRecorder {
const w = options.width; const w = options.width;
const h = options.height; 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); args.push(options.outputFile);
const progress = this._progress; const progress = this._progress;

View file

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

View file

@ -17,7 +17,6 @@
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
import type { LookupAddress } from 'dns'; import type { LookupAddress } from 'dns';
import http from 'http'; import http from 'http';
import fs from 'fs';
import https from 'https'; import https from 'https';
import type { Readable, TransformCallback } from 'stream'; import type { Readable, TransformCallback } from 'stream';
import { pipeline, Transform } from 'stream'; import { pipeline, Transform } from 'stream';
@ -26,7 +25,7 @@ import zlib from 'zlib';
import type { HTTPCredentials } from '../../types/types'; import type { HTTPCredentials } from '../../types/types';
import { TimeoutSettings } from '../common/timeoutSettings'; import { TimeoutSettings } from '../common/timeoutSettings';
import { getUserAgent } from '../utils/userAgent'; import { getUserAgent } from '../utils/userAgent';
import { assert, createGuid, isUnderTest, monotonicTime } from '../utils'; import { assert, createGuid, monotonicTime } from '../utils';
import { HttpsProxyAgent, SocksProxyAgent } from '../utilsBundle'; import { HttpsProxyAgent, SocksProxyAgent } from '../utilsBundle';
import { BrowserContext, verifyClientCertificates } from './browserContext'; import { BrowserContext, verifyClientCertificates } from './browserContext';
import { CookieStore, domainMatches } from './cookieStore'; import { CookieStore, domainMatches } from './cookieStore';
@ -41,7 +40,7 @@ import { Tracing } from './trace/recorder/tracing';
import type * as types from './types'; import type * as types from './types';
import type { HeadersArray, ProxySettings } from './types'; import type { HeadersArray, ProxySettings } from './types';
import { kMaxCookieExpiresDateInSeconds } from './network'; import { kMaxCookieExpiresDateInSeconds } from './network';
import { clientCertificatesToTLSOptions } from './socksClientCertificatesInterceptor'; import { clientCertificatesToTLSOptions, rewriteOpenSSLErrorIfNeeded } from './socksClientCertificatesInterceptor';
type FetchRequestOptions = { type FetchRequestOptions = {
userAgent: string; userAgent: string;
@ -199,8 +198,6 @@ export abstract class APIRequestContext extends SdkObject {
...clientCertificatesToTLSOptions(this._defaultOptions().clientCertificates, requestUrl.origin), ...clientCertificatesToTLSOptions(this._defaultOptions().clientCertificates, requestUrl.origin),
__testHookLookup: (params as any).__testHookLookup, __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. // rejectUnauthorized = undefined is treated as true in Node.js 12.
if (params.ignoreHTTPSErrors || defaults.ignoreHTTPSErrors) if (params.ignoreHTTPSErrors || defaults.ignoreHTTPSErrors)
options.rejectUnauthorized = false; options.rejectUnauthorized = false;
@ -214,8 +211,16 @@ export abstract class APIRequestContext extends SdkObject {
}); });
const fetchUid = this._storeResponseBody(fetchResponse.body); const fetchUid = this._storeResponseBody(fetchResponse.body);
this.fetchLog.set(fetchUid, controller.metadata.log); this.fetchLog.set(fetchUid, controller.metadata.log);
if (params.failOnStatusCode && (fetchResponse.status < 200 || fetchResponse.status >= 400)) if (params.failOnStatusCode && (fetchResponse.status < 200 || fetchResponse.status >= 400)) {
throw new Error(`${fetchResponse.status} ${fetchResponse.statusText}`); 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 }; return { ...fetchResponse, fetchUid };
} }
@ -447,7 +452,7 @@ export abstract class APIRequestContext extends SdkObject {
body.on('data', chunk => chunks.push(chunk)); body.on('data', chunk => chunks.push(chunk));
body.on('end', notifyBodyFinished); body.on('end', notifyBodyFinished);
}); });
request.on('error', reject); request.on('error', error => reject(rewriteOpenSSLErrorIfNeeded(error)));
const disposeListener = () => { const disposeListener = () => {
reject(new Error('Request context disposed.')); reject(new Error('Request context disposed.'));

View file

@ -21,7 +21,8 @@ import type { BrowserOptions } from '../browser';
import { Browser } from '../browser'; import { Browser } from '../browser';
import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext'; import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext';
import * as network from '../network'; import * as network from '../network';
import type { InitScript, Page, PageBinding, PageDelegate } from '../page'; import type { InitScript, Page, PageDelegate } from '../page';
import { PageBinding } from '../page';
import type { ConnectionTransport } from '../transport'; import type { ConnectionTransport } from '../transport';
import type * as types from '../types'; import type * as types from '../types';
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
@ -178,7 +179,10 @@ export class FFBrowserContext extends BrowserContext {
override async _initialize() { override async _initialize() {
assert(!this._ffPages().length); assert(!this._ffPages().length);
const browserContextId = this._browserContextId; const browserContextId = this._browserContextId;
const promises: Promise<any>[] = [super._initialize()]; const promises: Promise<any>[] = [
super._initialize(),
this._browser.session.send('Browser.addBinding', { browserContextId: this._browserContextId, name: PageBinding.kPlaywrightBinding, script: '' }),
];
if (this._options.acceptDownloads !== 'internal-browser-default') { if (this._options.acceptDownloads !== 'internal-browser-default') {
promises.push(this._browser.session.send('Browser.setDownloadOptions', { promises.push(this._browser.session.send('Browser.setDownloadOptions', {
browserContextId, browserContextId,
@ -353,21 +357,17 @@ export class FFBrowserContext extends BrowserContext {
} }
async doAddInitScript(initScript: InitScript) { 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() { async doRemoveNonInternalInitScripts() {
await this._browser.session.send('Browser.setInitScripts', { browserContextId: this._browserContextId, scripts: [] }); await this._updateInitScripts();
} }
async doExposeBinding(binding: PageBinding) { private async _updateInitScripts() {
await this._browser.session.send('Browser.addBinding', { browserContextId: this._browserContextId, name: binding.name, script: binding.source }); 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 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.
} }
async doUpdateRequestInterception(): Promise<void> { async doUpdateRequestInterception(): Promise<void> {

View file

@ -20,7 +20,7 @@ import * as dom from '../dom';
import type * as frames from '../frames'; import type * as frames from '../frames';
import type { RegisteredListener } from '../../utils/eventsHelper'; import type { RegisteredListener } from '../../utils/eventsHelper';
import { eventsHelper } from '../../utils/eventsHelper'; import { eventsHelper } from '../../utils/eventsHelper';
import type { PageBinding, PageDelegate } from '../page'; import type { PageDelegate } from '../page';
import { InitScript } from '../page'; import { InitScript } from '../page';
import { Page, Worker } from '../page'; import { Page, Worker } from '../page';
import type * as types from '../types'; import type * as types from '../types';
@ -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. // Ideally, we somehow ensure that utility world is created before Page.ready arrives, but currently it is racy.
// Therefore, we can end up with an initialized page without utility world, although very unlikely. // Therefore, we can end up with an initialized page without utility world, although very unlikely.
this.addInitScript(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 { potentiallyUninitializedPage(): Page {
@ -336,14 +336,6 @@ export class FFPage implements PageDelegate {
this._browserContext._browser._videoStarted(this._browserContext, event.screencastId, event.file, this.pageOrError()); 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() { didClose() {
this._markAsError(new TargetClosedError()); this._markAsError(new TargetClosedError());
this._session.dispose(); 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 })) }); await this._session.send('Page.setInitScripts', { scripts: this._initScripts.map(s => ({ script: s.initScript.source, worldName: s.worldName })) });
} }
async removeInitScripts() { async removeNonInternalInitScripts() {
this._initScripts = []; this._initScripts = this._initScripts.filter(s => s.initScript.internal);
await this._session.send('Page.setInitScripts', { scripts: [] }); await this._session.send('Page.setInitScripts', { scripts: this._initScripts.map(s => ({ script: s.initScript.source, worldName: s.worldName })) });
} }
async closePage(runBeforeUnload: boolean): Promise<void> { async closePage(runBeforeUnload: boolean): Promise<void> {

View file

@ -697,6 +697,14 @@ export function install(globalObject: WindowOrWorkerGlobalScope, config: Install
(globalObject as any).Intl = api[method]!; (globalObject as any).Intl = api[method]!;
} else if (method === 'performance') { } else if (method === 'performance') {
(globalObject as any).performance = api[method]!; (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 { } else {
(globalObject as any)[method] = (...args: any[]) => { (globalObject as any)[method] = (...args: any[]) => {
return (api[method] as any).apply(api, args); return (api[method] as any).apply(api, args);

View file

@ -170,8 +170,16 @@ export function source() {
if (typeof value === 'bigint') if (typeof value === 'bigint')
return { bi: value.toString() }; return { bi: value.toString() };
if (isError(value)) if (isError(value)) {
return { e: { n: value.name, m: value.message, s: value.stack || '' } }; 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)) if (isDate(value))
return { d: value.toJSON() }; return { d: value.toJSON() };
if (isURL(value)) if (isURL(value))

View file

@ -43,7 +43,7 @@ export async function launchApp(browserType: BrowserType, options: {
} }
const context = await browserType.launchPersistentContext(serverSideCallMetadata(), '', { const context = await browserType.launchPersistentContext(serverSideCallMetadata(), '', {
channel: findChromiumChannel(options.sdkLanguage), channel: !options.persistentContextOptions?.executablePath ? findChromiumChannel(options.sdkLanguage) : undefined,
noDefaultViewport: true, noDefaultViewport: true,
ignoreDefaultArgs: ['--enable-automation'], ignoreDefaultArgs: ['--enable-automation'],
colorScheme: 'no-override', colorScheme: 'no-override',
@ -90,6 +90,8 @@ export async function syncLocalStorageWithSettings(page: Page, appName: string)
// iframes w/ snapshots, etc. // iframes w/ snapshots, etc.
if (location && location.protocol === 'data:') if (location && location.protocol === 'data:')
return; return;
if (window.top !== window)
return;
Object.entries(settings).map(([k, v]) => localStorage[k] = v); Object.entries(settings).map(([k, v]) => localStorage[k] = v);
(window as any).saveSettings = () => { (window as any).saveSettings = () => {
(window as any)._saveSerializedSettings(JSON.stringify({ ...localStorage })); (window as any)._saveSerializedSettings(JSON.stringify({ ...localStorage }));

View file

@ -54,10 +54,8 @@ export interface PageDelegate {
reload(): Promise<void>; reload(): Promise<void>;
goBack(): Promise<boolean>; goBack(): Promise<boolean>;
goForward(): Promise<boolean>; goForward(): Promise<boolean>;
exposeBinding(binding: PageBinding): Promise<void>;
removeExposedBindings(): Promise<void>;
addInitScript(initScript: InitScript): Promise<void>; addInitScript(initScript: InitScript): Promise<void>;
removeInitScripts(): Promise<void>; removeNonInternalInitScripts(): Promise<void>;
closePage(runBeforeUnload: boolean): Promise<void>; closePage(runBeforeUnload: boolean): Promise<void>;
potentiallyUninitializedPage(): Page; potentiallyUninitializedPage(): Page;
pageOrError(): Promise<Page | Error>; pageOrError(): Promise<Page | Error>;
@ -154,7 +152,7 @@ export class Page extends SdkObject {
private _emulatedMedia: Partial<EmulatedMedia> = {}; private _emulatedMedia: Partial<EmulatedMedia> = {};
private _interceptFileChooser = false; private _interceptFileChooser = false;
private readonly _pageBindings = new Map<string, PageBinding>(); private readonly _pageBindings = new Map<string, PageBinding>();
readonly initScripts: InitScript[] = []; initScripts: InitScript[] = [];
readonly _screenshotter: Screenshotter; readonly _screenshotter: Screenshotter;
readonly _frameManager: frames.FrameManager; readonly _frameManager: frames.FrameManager;
readonly accessibility: accessibility.Accessibility; 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`); throw new Error(`Function "${name}" has been already registered in the browser context`);
const binding = new PageBinding(name, playwrightBinding, needsHandle); const binding = new PageBinding(name, playwrightBinding, needsHandle);
this._pageBindings.set(name, binding); 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() { async _removeExposedBindings() {
for (const key of this._pageBindings.keys()) { for (const [key, binding] of this._pageBindings) {
if (!key.startsWith('__pw')) if (!binding.internal)
this._pageBindings.delete(key); this._pageBindings.delete(key);
} }
await this._delegate.removeExposedBindings();
} }
setExtraHTTPHeaders(headers: types.HeadersArray) { setExtraHTTPHeaders(headers: types.HeadersArray) {
@ -533,8 +531,8 @@ export class Page extends SdkObject {
} }
async _removeInitScripts() { async _removeInitScripts() {
this.initScripts.splice(0, this.initScripts.length); this.initScripts = this.initScripts.filter(script => script.internal);
await this._delegate.removeInitScripts(); await this._delegate.removeNonInternalInitScripts();
} }
needsRequestInterception(): boolean { needsRequestInterception(): boolean {
@ -727,8 +725,9 @@ export class Page extends SdkObject {
this._browserContext.addVisitedOrigin(origin); this._browserContext.addVisitedOrigin(origin);
} }
allBindings() { allInitScripts() {
return [...this._browserContext._pageBindings.values(), ...this._pageBindings.values()]; const bindings = [...this._browserContext._pageBindings.values(), ...this._pageBindings.values()];
return [...bindings.map(binding => binding.initScript), ...this._browserContext.initScripts, ...this.initScripts];
} }
getBinding(name: string) { getBinding(name: string) {
@ -819,23 +818,29 @@ type BindingPayload = {
}; };
export class PageBinding { export class PageBinding {
static kPlaywrightBinding = '__playwright__binding__';
readonly name: string; readonly name: string;
readonly playwrightFunction: frames.FunctionWithSource; readonly playwrightFunction: frames.FunctionWithSource;
readonly source: string; readonly initScript: InitScript;
readonly needsHandle: boolean; readonly needsHandle: boolean;
readonly internal: boolean;
constructor(name: string, playwrightFunction: frames.FunctionWithSource, needsHandle: boolean) { constructor(name: string, playwrightFunction: frames.FunctionWithSource, needsHandle: boolean) {
this.name = name; this.name = name;
this.playwrightFunction = playwrightFunction; 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.needsHandle = needsHandle;
this.internal = name.startsWith('__pw');
} }
static async dispatch(page: Page, payload: string, context: dom.FrameExecutionContext) { static async dispatch(page: Page, payload: string, context: dom.FrameExecutionContext) {
const { name, seq, serializedArgs } = JSON.parse(payload) as BindingPayload; const { name, seq, serializedArgs } = JSON.parse(payload) as BindingPayload;
try { try {
assert(context.world); 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; let result: any;
if (binding.needsHandle) { if (binding.needsHandle) {
const handle = await context.evaluateHandle(takeHandle, { name, seq }).catch(e => null); 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<typeof source>) { function addPageBinding(playwrightBinding: string, bindingName: string, needsHandle: boolean, utilityScriptSerializers: ReturnType<typeof source>) {
const binding = (globalThis as any)[bindingName]; const binding = (globalThis as any)[playwrightBinding];
if (binding.__installed)
return;
(globalThis as any)[bindingName] = (...args: any[]) => { (globalThis as any)[bindingName] = (...args: any[]) => {
const me = (globalThis as any)[bindingName]; const me = (globalThis as any)[bindingName];
if (needsHandle && args.slice(1).some(arg => arg !== undefined)) if (needsHandle && args.slice(1).some(arg => arg !== undefined))
@ -919,8 +922,9 @@ function addPageBinding(bindingName: string, needsHandle: boolean, utilityScript
export class InitScript { export class InitScript {
readonly source: string; readonly source: string;
readonly internal: boolean;
constructor(source: string) { constructor(source: string, internal?: boolean) {
const guid = createGuid(); const guid = createGuid();
this.source = `(() => { this.source = `(() => {
globalThis.__pwInitScripts = globalThis.__pwInitScripts || {}; globalThis.__pwInitScripts = globalThis.__pwInitScripts || {};
@ -930,6 +934,7 @@ export class InitScript {
globalThis.__pwInitScripts[${JSON.stringify(guid)}] = true; globalThis.__pwInitScripts[${JSON.stringify(guid)}] = true;
${source} ${source}
})();`; })();`;
this.internal = !!internal;
} }
} }

View file

@ -127,6 +127,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
useWebSocket: !!process.env.PWTEST_RECORDER_PORT, useWebSocket: !!process.env.PWTEST_RECORDER_PORT,
handleSIGINT, handleSIGINT,
args: process.env.PWTEST_RECORDER_PORT ? [`--remote-debugging-port=${process.env.PWTEST_RECORDER_PORT}`] : [], 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); const controller = new ProgressController(serverSideCallMetadata(), context._browser);

View file

@ -22,7 +22,7 @@ import fs from 'fs';
import tls from 'tls'; import tls from 'tls';
import stream from 'stream'; import stream from 'stream';
import { createSocket, createTLSSocket } from '../utils/happy-eyeballs'; 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 type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../common/socksProxy';
import { SocksProxy } from '../common/socksProxy'; import { SocksProxy } from '../common/socksProxy';
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
@ -142,38 +142,18 @@ class SocksProxyConnection {
dummyServer.emit('connection', this.internal); dummyServer.emit('connection', this.internal);
dummyServer.on('secureConnection', internalTLS => { dummyServer.on('secureConnection', internalTLS => {
debugLogger.log('client-certificates', `Browser->Proxy ${this.host}:${this.port} chooses ALPN ${internalTLS.alpnProtocol}`); 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', () => { let targetTLS: tls.TLSSocket | undefined = undefined;
internalTLS.pipe(targetTLS);
targetTLS.pipe(internalTLS);
});
// Handle close and errors
const closeBothSockets = () => { const closeBothSockets = () => {
internalTLS.end(); internalTLS.end();
targetTLS.end(); targetTLS?.end();
}; };
internalTLS.on('end', () => closeBothSockets()); const handleError = (error: Error) => {
targetTLS.on('end', () => closeBothSockets()); error = rewriteOpenSSLErrorIfNeeded(error);
debugLogger.log('client-certificates', `error when connecting to target: ${error.message.replaceAll('\n', ' ')}`);
internalTLS.on('error', () => closeBothSockets()); const responseBody = escapeHTML('Playwright client-certificate error: ' + error.message)
targetTLS.on('error', error => { .replaceAll('\n', ' <br>');
debugLogger.log('client-certificates', `error when connecting to target: ${error.message}`);
const responseBody = 'Playwright client-certificate error: ' + error.message;
if (internalTLS?.alpnProtocol === 'h2') { if (internalTLS?.alpnProtocol === 'h2') {
// This method is available only in Node.js 20+ // This method is available only in Node.js 20+
if ('performServerHandshake' in http2) { if ('performServerHandshake' in http2) {
@ -206,7 +186,38 @@ class SocksProxyConnection {
].join('\r\n')); ].join('\r\n'));
closeBothSockets(); 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 { function rewriteToLocalhostIfNeeded(host: string): string {
return host === 'local.playwright' ? 'localhost' : host; 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'));
}

View file

@ -22,7 +22,7 @@ import type { RegisteredListener } from '../../utils/eventsHelper';
import { assert } from '../../utils'; import { assert } from '../../utils';
import { eventsHelper } from '../../utils/eventsHelper'; import { eventsHelper } from '../../utils/eventsHelper';
import * as network from '../network'; import * as network from '../network';
import type { InitScript, Page, PageBinding, PageDelegate } from '../page'; import type { InitScript, Page, PageDelegate } from '../page';
import type { ConnectionTransport } from '../transport'; import type { ConnectionTransport } from '../transport';
import type * as types from '../types'; import type * as types from '../types';
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
@ -320,21 +320,11 @@ export class WKBrowserContext extends BrowserContext {
await (page._delegate as WKPage)._updateBootstrapScript(); await (page._delegate as WKPage)._updateBootstrapScript();
} }
async doRemoveInitScripts() { async doRemoveNonInternalInitScripts() {
for (const page of this.pages()) for (const page of this.pages())
await (page._delegate as WKPage)._updateBootstrapScript(); 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<void> { async doUpdateRequestInterception(): Promise<void> {
for (const page of this.pages()) for (const page of this.pages())
await (page._delegate as WKPage).updateRequestInterception(); await (page._delegate as WKPage).updateRequestInterception();

View file

@ -30,7 +30,7 @@ import { eventsHelper } from '../../utils/eventsHelper';
import { helper } from '../helper'; import { helper } from '../helper';
import type { JSHandle } from '../javascript'; import type { JSHandle } from '../javascript';
import * as network from '../network'; import * as network from '../network';
import type { InitScript, PageBinding, PageDelegate } from '../page'; import { type InitScript, PageBinding, type PageDelegate } from '../page';
import { Page } from '../page'; import { Page } from '../page';
import type { Progress } from '../progress'; import type { Progress } from '../progress';
import type * as types from '../types'; import type * as types from '../types';
@ -179,6 +179,7 @@ export class WKPage implements PageDelegate {
const promises: Promise<any>[] = [ const promises: Promise<any>[] = [
// Resource tree should be received before first execution context. // Resource tree should be received before first execution context.
session.send('Runtime.enable'), 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('Page.createUserWorld', { name: UTILITY_WORLD_NAME }).catch(_ => {}), // Worlds are per-process
session.send('Console.enable'), session.send('Console.enable'),
session.send('Network.enable'), session.send('Network.enable'),
@ -200,8 +201,6 @@ export class WKPage implements PageDelegate {
const emulatedMedia = this._page.emulatedMedia(); const emulatedMedia = this._page.emulatedMedia();
if (emulatedMedia.media || emulatedMedia.colorScheme || emulatedMedia.reducedMotion || emulatedMedia.forcedColors) if (emulatedMedia.media || emulatedMedia.colorScheme || emulatedMedia.reducedMotion || emulatedMedia.forcedColors)
promises.push(WKPage._setEmulateMedia(session, 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(); const bootstrapScript = this._calculateBootstrapScript();
if (bootstrapScript.length) if (bootstrapScript.length)
promises.push(session.send('Page.setBootstrapScript', { source: bootstrapScript })); promises.push(session.send('Page.setBootstrapScript', { source: bootstrapScript }));
@ -768,21 +767,11 @@ export class WKPage implements PageDelegate {
}); });
} }
async exposeBinding(binding: PageBinding): Promise<void> {
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<void> {
await this._updateBootstrapScript();
}
async addInitScript(initScript: InitScript): Promise<void> { async addInitScript(initScript: InitScript): Promise<void> {
await this._updateBootstrapScript(); await this._updateBootstrapScript();
} }
async removeInitScripts() { async removeNonInternalInitScripts() {
await this._updateBootstrapScript(); 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.safari) window.safari = { pushNotification: { toString() { return "[object SafariRemoteNotification]"; } } };');
scripts.push('if (!window.GestureEvent) window.GestureEvent = function GestureEvent() {};'); scripts.push('if (!window.GestureEvent) window.GestureEvent = function GestureEvent() {};');
scripts.push(...this._page.allInitScripts().map(script => script.source));
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));
return scripts.join(';\n'); return scripts.join(';\n');
} }

View file

@ -132,3 +132,11 @@ export function escapeRegExp(s: string) {
// From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping // From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
} }
const escaped = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' };
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]);
}

File diff suppressed because it is too large Load diff

View file

@ -61,7 +61,7 @@ export async function runDevServer(config: FullConfigInternal): Promise<() => Pr
projectOutputs.add(p.project.outputDir); projectOutputs.add(p.project.outputDir);
} }
const globalWatcher = new Watcher('deep', async () => { const globalWatcher = new Watcher(async () => {
const registry: ComponentRegistry = new Map(); const registry: ComponentRegistry = new Map();
await populateComponentsFromTests(registry); await populateComponentsFromTests(registry);
// compare componentRegistry to registry key sets. // compare componentRegistry to registry key sets.
@ -80,6 +80,6 @@ export async function runDevServer(config: FullConfigInternal): Promise<() => Pr
if (rootModule) if (rootModule)
devServer.moduleGraph.onFileChange(rootModule.file!); devServer.moduleGraph.onFileChange(rootModule.file!);
}); });
globalWatcher.update([...projectDirs], [...projectOutputs], false); await globalWatcher.update([...projectDirs], [...projectOutputs], false);
return () => Promise.all([devServer.close(), globalWatcher.close()]).then(() => {}); return () => Promise.all([devServer.close(), globalWatcher.close()]).then(() => {});
} }

View file

@ -24,7 +24,6 @@ import { getPackageJsonPath, mergeObjects } from '../util';
import type { Matcher } from '../util'; import type { Matcher } from '../util';
import type { ConfigCLIOverrides } from './ipc'; import type { ConfigCLIOverrides } from './ipc';
import type { FullConfig, FullProject } from '../../types/testReporter'; import type { FullConfig, FullProject } from '../../types/testReporter';
import { setTransformConfig } from '../transform/transform';
export type ConfigLocation = { export type ConfigLocation = {
resolvedConfigFile?: string; resolvedConfigFile?: string;
@ -128,10 +127,6 @@ export class FullConfigInternal {
this.projects = projectConfigs.map(p => new FullProjectInternal(configDir, userConfig, this, p, this.configCLIOverrides, packageJsonDir)); this.projects = projectConfigs.map(p => new FullProjectInternal(configDir, userConfig, this, p, this.configCLIOverrides, packageJsonDir));
resolveProjectDependencies(this.projects); resolveProjectDependencies(this.projects);
this._assignUniqueProjectIds(this.projects); this._assignUniqueProjectIds(this.projects);
setTransformConfig({
babelPlugins: privateConfiguration?.babelPlugins || [],
external: userConfig.build?.external || [],
});
this.config.projects = this.projects.map(p => p.project); this.config.projects = this.projects.map(p => p.project);
} }

View file

@ -18,13 +18,13 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { gracefullyProcessExitDoNotHang, isRegExp } from 'playwright-core/lib/utils'; import { gracefullyProcessExitDoNotHang, isRegExp } from 'playwright-core/lib/utils';
import type { ConfigCLIOverrides, SerializedConfig } from './ipc'; 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 type { Config, Project } from '../../types/test';
import { errorWithFile, fileIsModule } from '../util'; import { errorWithFile, fileIsModule } from '../util';
import type { ConfigLocation } from './config'; import type { ConfigLocation } from './config';
import { FullConfigInternal } from './config'; import { FullConfigInternal } from './config';
import { addToCompilationCache } from '../transform/compilationCache'; import { addToCompilationCache } from '../transform/compilationCache';
import { initializeEsmLoader, registerESMLoader } from './esmLoaderHost'; import { configureESMLoader, configureESMLoaderTransformConfig, registerESMLoader } from './esmLoaderHost';
import { execArgvWithExperimentalLoaderOptions, execArgvWithoutExperimentalLoaderOptions } from '../transform/esmUtils'; import { execArgvWithExperimentalLoaderOptions, execArgvWithoutExperimentalLoaderOptions } from '../transform/esmUtils';
const kDefineConfigWasUsed = Symbol('defineConfigWasUsed'); const kDefineConfigWasUsed = Symbol('defineConfigWasUsed');
@ -87,10 +87,7 @@ export const defineConfig = (...configs: any[]) => {
export async function deserializeConfig(data: SerializedConfig): Promise<FullConfigInternal> { export async function deserializeConfig(data: SerializedConfig): Promise<FullConfigInternal> {
if (data.compilationCache) if (data.compilationCache)
addToCompilationCache(data.compilationCache); addToCompilationCache(data.compilationCache);
return await loadConfig(data.location, data.configCLIOverrides);
const config = await loadConfig(data.location, data.configCLIOverrides);
await initializeEsmLoader();
return config;
} }
async function loadUserConfig(location: ConfigLocation): Promise<Config> { async function loadUserConfig(location: ConfigLocation): Promise<Config> {
@ -101,6 +98,11 @@ async function loadUserConfig(location: ConfigLocation): Promise<Config> {
} }
export async function loadConfig(location: ConfigLocation, overrides?: ConfigCLIOverrides, ignoreProjectDependencies = false): Promise<FullConfigInternal> { export async function loadConfig(location: ConfigLocation, overrides?: ConfigCLIOverrides, ignoreProjectDependencies = false): Promise<FullConfigInternal> {
// 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); const userConfig = await loadUserConfig(location);
validateConfig(location.resolvedConfigFile || '<default config>', userConfig); validateConfig(location.resolvedConfigFile || '<default config>', userConfig);
const fullConfig = new FullConfigInternal(location, userConfig, overrides || {}); const fullConfig = new FullConfigInternal(location, userConfig, overrides || {});
@ -111,6 +113,15 @@ export async function loadConfig(location: ConfigLocation, overrides?: ConfigCLI
project.teardown = undefined; 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; return fullConfig;
} }

View file

@ -16,7 +16,7 @@
import url from 'url'; import url from 'url';
import { addToCompilationCache, serializeCompilationCache } from '../transform/compilationCache'; import { addToCompilationCache, serializeCompilationCache } from '../transform/compilationCache';
import { transformConfig } from '../transform/transform'; import { singleTSConfig, transformConfig } from '../transform/transform';
import { PortTransport } from '../transform/portTransport'; import { PortTransport } from '../transform/portTransport';
let loaderChannel: PortTransport | undefined; let loaderChannel: PortTransport | undefined;
@ -67,9 +67,15 @@ export async function incorporateCompilationCache() {
addToCompilationCache(result.cache); 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) if (!loaderChannel)
return; return;
await loaderChannel.send('setTransformConfig', { config: transformConfig() }); await loaderChannel.send('setTransformConfig', { config: transformConfig() });
await loaderChannel.send('addToCompilationCache', { cache: serializeCompilationCache() });
} }

View file

@ -33,6 +33,7 @@ export type ConfigCLIOverrides = {
additionalReporters?: ReporterDescription[]; additionalReporters?: ReporterDescription[];
shard?: { current: number, total: number }; shard?: { current: number, total: number };
timeout?: number; timeout?: number;
tsconfig?: string;
ignoreSnapshots?: boolean; ignoreSnapshots?: boolean;
updateSnapshots?: 'all'|'none'|'missing'; updateSnapshots?: 'all'|'none'|'missing';
workers?: number | string; workers?: number | string;

View file

@ -21,26 +21,24 @@ export type FSEvent = { event: 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkD
export class Watcher { export class Watcher {
private _onChange: (events: FSEvent[]) => void; private _onChange: (events: FSEvent[]) => void;
private _watchedFiles: string[] = []; private _watchedPaths: string[] = [];
private _ignoredFolders: string[] = []; private _ignoredFolders: string[] = [];
private _collector: FSEvent[] = []; private _collector: FSEvent[] = [];
private _fsWatcher: FSWatcher | undefined; private _fsWatcher: FSWatcher | undefined;
private _throttleTimer: NodeJS.Timeout | undefined; private _throttleTimer: NodeJS.Timeout | undefined;
private _mode: 'flat' | 'deep';
constructor(mode: 'flat' | 'deep', onChange: (events: FSEvent[]) => void) { constructor(onChange: (events: FSEvent[]) => void) {
this._mode = mode;
this._onChange = onChange; this._onChange = onChange;
} }
update(watchedFiles: string[], ignoredFolders: string[], reportPending: boolean) { async update(watchedPaths: string[], ignoredFolders: string[], reportPending: boolean) {
if (JSON.stringify([this._watchedFiles, this._ignoredFolders]) === JSON.stringify(watchedFiles, ignoredFolders)) if (JSON.stringify([this._watchedPaths, this._ignoredFolders]) === JSON.stringify(watchedPaths, ignoredFolders))
return; return;
if (reportPending) if (reportPending)
this._reportEventsIfAny(); this._reportEventsIfAny();
this._watchedFiles = watchedFiles; this._watchedPaths = watchedPaths;
this._ignoredFolders = ignoredFolders; this._ignoredFolders = ignoredFolders;
void this._fsWatcher?.close(); void this._fsWatcher?.close();
this._fsWatcher = undefined; this._fsWatcher = undefined;
@ -48,20 +46,18 @@ export class Watcher {
clearTimeout(this._throttleTimer); clearTimeout(this._throttleTimer);
this._throttleTimer = undefined; this._throttleTimer = undefined;
if (!this._watchedFiles.length) if (!this._watchedPaths.length)
return; return;
const ignored = [...this._ignoredFolders, '**/node_modules/**']; 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) if (this._throttleTimer)
clearTimeout(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._collector.push({ event, file });
this._throttleTimer = setTimeout(() => this._reportEventsIfAny(), 250); this._throttleTimer = setTimeout(() => this._reportEventsIfAny(), 250);
}); });
await new Promise((resolve, reject) => this._fsWatcher!.once('ready', resolve).once('error', reject));
} }
async close() { async close() {

View file

@ -23,14 +23,12 @@ export class TestServerConnection implements TestServerInterface, TestServerInte
readonly onClose: events.Event<void>; readonly onClose: events.Event<void>;
readonly onReport: events.Event<any>; readonly onReport: events.Event<any>;
readonly onStdio: events.Event<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>; readonly onStdio: events.Event<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>;
readonly onListChanged: events.Event<void>;
readonly onTestFilesChanged: events.Event<{ testFiles: string[] }>; readonly onTestFilesChanged: events.Event<{ testFiles: string[] }>;
readonly onLoadTraceRequested: events.Event<{ traceUrl: string }>; readonly onLoadTraceRequested: events.Event<{ traceUrl: string }>;
private _onCloseEmitter = new events.EventEmitter<void>(); private _onCloseEmitter = new events.EventEmitter<void>();
private _onReportEmitter = new events.EventEmitter<any>(); private _onReportEmitter = new events.EventEmitter<any>();
private _onStdioEmitter = new events.EventEmitter<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>(); private _onStdioEmitter = new events.EventEmitter<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>();
private _onListChangedEmitter = new events.EventEmitter<void>();
private _onTestFilesChangedEmitter = new events.EventEmitter<{ testFiles: string[] }>(); private _onTestFilesChangedEmitter = new events.EventEmitter<{ testFiles: string[] }>();
private _onLoadTraceRequestedEmitter = new events.EventEmitter<{ traceUrl: string }>(); private _onLoadTraceRequestedEmitter = new events.EventEmitter<{ traceUrl: string }>();
@ -44,7 +42,6 @@ export class TestServerConnection implements TestServerInterface, TestServerInte
this.onClose = this._onCloseEmitter.event; this.onClose = this._onCloseEmitter.event;
this.onReport = this._onReportEmitter.event; this.onReport = this._onReportEmitter.event;
this.onStdio = this._onStdioEmitter.event; this.onStdio = this._onStdioEmitter.event;
this.onListChanged = this._onListChangedEmitter.event;
this.onTestFilesChanged = this._onTestFilesChangedEmitter.event; this.onTestFilesChanged = this._onTestFilesChangedEmitter.event;
this.onLoadTraceRequested = this._onLoadTraceRequestedEmitter.event; this.onLoadTraceRequested = this._onLoadTraceRequestedEmitter.event;
@ -103,8 +100,6 @@ export class TestServerConnection implements TestServerInterface, TestServerInte
this._onReportEmitter.fire(params); this._onReportEmitter.fire(params);
else if (method === 'stdio') else if (method === 'stdio')
this._onStdioEmitter.fire(params); this._onStdioEmitter.fire(params);
else if (method === 'listChanged')
this._onListChangedEmitter.fire(params);
else if (method === 'testFilesChanged') else if (method === 'testFilesChanged')
this._onTestFilesChangedEmitter.fire(params); this._onTestFilesChangedEmitter.fire(params);
else if (method === 'loadTraceRequested') else if (method === 'loadTraceRequested')

View file

@ -118,7 +118,6 @@ export interface TestServerInterface {
export interface TestServerInterfaceEvents { export interface TestServerInterfaceEvents {
onReport: Event<any>; onReport: Event<any>;
onStdio: Event<{ type: 'stdout' | 'stderr', text?: string, buffer?: string }>; onStdio: Event<{ type: 'stdout' | 'stderr', text?: string, buffer?: string }>;
onListChanged: Event<void>;
onTestFilesChanged: Event<{ testFiles: string[] }>; onTestFilesChanged: Event<{ testFiles: string[] }>;
onLoadTraceRequested: Event<{ traceUrl: string }>; onLoadTraceRequested: Event<{ traceUrl: string }>;
} }
@ -126,7 +125,6 @@ export interface TestServerInterfaceEvents {
export interface TestServerInterfaceEventEmitters { export interface TestServerInterfaceEventEmitters {
dispatchEvent(event: 'report', params: ReportEntry): void; dispatchEvent(event: 'report', params: ReportEntry): void;
dispatchEvent(event: 'stdio', params: { type: 'stdout' | 'stderr', text?: string, buffer?: string }): 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: 'testFilesChanged', params: { testFiles: string[] }): void;
dispatchEvent(event: 'loadTraceRequested', params: { traceUrl: string }): void; dispatchEvent(event: 'loadTraceRequested', params: { traceUrl: string }): void;
} }

View file

@ -286,6 +286,7 @@ function overridesFromOptions(options: { [key: string]: any }): ConfigCLIOverrid
reporter: resolveReporterOption(options.reporter), reporter: resolveReporterOption(options.reporter),
shard: shardPair ? { current: shardPair[0], total: shardPair[1] } : undefined, shard: shardPair ? { current: shardPair[0], total: shardPair[1] } : undefined,
timeout: options.timeout ? parseInt(options.timeout, 10) : 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, ignoreSnapshots: options.ignoreSnapshots ? !!options.ignoreSnapshots : undefined,
updateSnapshots: options.updateSnapshots ? 'all' as const : undefined, updateSnapshots: options.updateSnapshots ? 'all' as const : undefined,
workers: options.workers, workers: options.workers,
@ -365,6 +366,7 @@ const testOptions: [string, string][] = [
['--shard <shard>', `Shard tests and execute only the selected shard, specify in the form "current/all", 1-based, for example "3/5"`], ['--shard <shard>', `Shard tests and execute only the selected shard, specify in the form "current/all", 1-based, for example "3/5"`],
['--timeout <timeout>', `Specify test timeout threshold in milliseconds, zero for unlimited (default: ${defaultTimeout})`], ['--timeout <timeout>', `Specify test timeout threshold in milliseconds, zero for unlimited (default: ${defaultTimeout})`],
['--trace <mode>', `Force tracing mode, can be ${kTraceModes.map(mode => `"${mode}"`).join(', ')}`], ['--trace <mode>', `Force tracing mode, can be ${kTraceModes.map(mode => `"${mode}"`).join(', ')}`],
['--tsconfig <path>', `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', `Run tests in interactive UI mode`],
['--ui-host <host>', 'Host to serve UI on; specifying this option opens UI in a browser tab'], ['--ui-host <host>', 'Host to serve UI on; specifying this option opens UI in a browser tab'],
['--ui-port <port>', 'Port to serve UI on, 0 for any free port; specifying this option opens UI in a browser tab'], ['--ui-port <port>', 'Port to serve UI on, 0 for any free port; specifying this option opens UI in a browser tab'],

View file

@ -22,7 +22,7 @@ import { loadTestFile } from '../common/testLoader';
import type { FullConfigInternal } from '../common/config'; import type { FullConfigInternal } from '../common/config';
import { PoolBuilder } from '../common/poolBuilder'; import { PoolBuilder } from '../common/poolBuilder';
import { addToCompilationCache } from '../transform/compilationCache'; import { addToCompilationCache } from '../transform/compilationCache';
import { incorporateCompilationCache, initializeEsmLoader } from '../common/esmLoaderHost'; import { incorporateCompilationCache } from '../common/esmLoaderHost';
export class InProcessLoaderHost { export class InProcessLoaderHost {
private _config: FullConfigInternal; private _config: FullConfigInternal;
@ -34,7 +34,6 @@ export class InProcessLoaderHost {
} }
async start(errors: TestError[]) { async start(errors: TestError[]) {
await initializeEsmLoader();
return true; return true;
} }

View file

@ -64,8 +64,12 @@ class TestServer {
class TestServerDispatcher implements TestServerInterface { class TestServerDispatcher implements TestServerInterface {
private _configLocation: ConfigLocation; private _configLocation: ConfigLocation;
private _globalWatcher: Watcher;
private _testWatcher: Watcher; private _watcher: Watcher;
private _watchedProjectDirs = new Set<string>();
private _ignoredProjectOutputs = new Set<string>();
private _watchedTestDependencies = new Set<string>();
private _testRun: { run: Promise<reporterTypes.FullResult['status']>, stop: ManualPromise<void> } | undefined; private _testRun: { run: Promise<reporterTypes.FullResult['status']>, stop: ManualPromise<void> } | undefined;
readonly transport: Transport; readonly transport: Transport;
private _queue = Promise.resolve(); private _queue = Promise.resolve();
@ -86,8 +90,7 @@ class TestServerDispatcher implements TestServerInterface {
gracefullyProcessExitDoNotHang(0); gracefullyProcessExitDoNotHang(0);
}, },
}; };
this._globalWatcher = new Watcher('deep', () => this._dispatchEvent('listChanged', {})); this._watcher = new Watcher(events => {
this._testWatcher = new Watcher('flat', events => {
const collector = new Set<string>(); const collector = new Set<string>();
events.forEach(f => collectAffectedTestFiles(f.file, collector)); events.forEach(f => collectAffectedTestFiles(f.file, collector));
this._dispatchEvent('testFilesChanged', { testFiles: [...collector] }); this._dispatchEvent('testFilesChanged', { testFiles: [...collector] });
@ -279,24 +282,28 @@ class TestServerDispatcher implements TestServerInterface {
await taskRunner.reporter.onEnd({ status }); await taskRunner.reporter.onEnd({ status });
await taskRunner.reporter.onExit(); await taskRunner.reporter.onExit();
const projectDirs = new Set<string>(); this._watchedProjectDirs = new Set();
const projectOutputs = new Set<string>(); this._ignoredProjectOutputs = new Set();
for (const p of config.projects) { for (const p of config.projects) {
projectDirs.add(p.project.testDir); this._watchedProjectDirs.add(p.project.testDir);
projectOutputs.add(p.project.outputDir); this._ignoredProjectOutputs.add(p.project.outputDir);
} }
const result = await resolveCtDirs(config); const result = await resolveCtDirs(config);
if (result) { if (result) {
projectDirs.add(result.templateDir); this._watchedProjectDirs.add(result.templateDir);
projectOutputs.add(result.outDir); this._ignoredProjectOutputs.add(result.outDir);
} }
if (this._watchTestDirs) if (this._watchTestDirs)
this._globalWatcher.update([...projectDirs], [...projectOutputs], false); await this.updateWatcher(false);
return { report, status }; return { report, status };
} }
private async updateWatcher(reportPending: boolean) {
await this._watcher.update([...this._watchedProjectDirs, ...this._watchedTestDependencies], [...this._ignoredProjectOutputs], reportPending);
}
async runTests(params: Parameters<TestServerInterface['runTests']>[0]): ReturnType<TestServerInterface['runTests']> { async runTests(params: Parameters<TestServerInterface['runTests']>[0]): ReturnType<TestServerInterface['runTests']> {
let result: Awaited<ReturnType<TestServerInterface['runTests']>> = { status: 'passed' }; let result: Awaited<ReturnType<TestServerInterface['runTests']>> = { status: 'passed' };
this._queue = this._queue.then(async () => { this._queue = this._queue.then(async () => {
@ -364,12 +371,12 @@ class TestServerDispatcher implements TestServerInterface {
} }
async watch(params: { fileNames: string[]; }) { async watch(params: { fileNames: string[]; }) {
const files = new Set<string>(); this._watchedTestDependencies = new Set();
for (const fileName of params.fileNames) { for (const fileName of params.fileNames) {
files.add(fileName); this._watchedTestDependencies.add(fileName);
dependenciesForTestFile(fileName).forEach(file => files.add(file)); dependenciesForTestFile(fileName).forEach(file => this._watchedTestDependencies.add(file));
} }
this._testWatcher.update([...files], [], true); await this.updateWatcher(true);
} }
async findRelatedTestFiles(params: Parameters<TestServerInterface['findRelatedTestFiles']>[0]): ReturnType<TestServerInterface['findRelatedTestFiles']> { async findRelatedTestFiles(params: Parameters<TestServerInterface['findRelatedTestFiles']>[0]): ReturnType<TestServerInterface['findRelatedTestFiles']> {

View file

@ -52,12 +52,8 @@ export interface LoadedTsConfig {
allowJs?: boolean; allowJs?: boolean;
} }
export interface TsConfigLoaderParams { export function tsConfigLoader(tsconfigPathOrDirecotry: string): LoadedTsConfig[] {
cwd: string; const configPath = resolveConfigPath(tsconfigPathOrDirecotry);
}
export function tsConfigLoader({ cwd, }: TsConfigLoaderParams): LoadedTsConfig[] {
const configPath = resolveConfigPath(cwd);
if (!configPath) if (!configPath)
return []; return [];
@ -67,12 +63,12 @@ export function tsConfigLoader({ cwd, }: TsConfigLoaderParams): LoadedTsConfig[]
return [config, ...references]; return [config, ...references];
} }
function resolveConfigPath(cwd: string): string | undefined { function resolveConfigPath(tsconfigPathOrDirecotry: string): string | undefined {
if (fs.statSync(cwd).isFile()) { if (fs.statSync(tsconfigPathOrDirecotry).isFile()) {
return path.resolve(cwd); return path.resolve(tsconfigPathOrDirecotry);
} }
const configAbsolutePath = walkForTsConfig(cwd); const configAbsolutePath = walkForTsConfig(tsconfigPathOrDirecotry);
return configAbsolutePath ? path.resolve(configAbsolutePath) : undefined; return configAbsolutePath ? path.resolve(configAbsolutePath) : undefined;
} }

View file

@ -64,13 +64,7 @@ const fileDependencies = new Map<string, Set<string>>();
// Dependencies resolved by the external bundler. // Dependencies resolved by the external bundler.
const externalDependencies = new Map<string, Set<string>>(); const externalDependencies = new Map<string, Set<string>>();
let sourceMapSupportInstalled = false; export function installSourceMapSupport() {
export function installSourceMapSupportIfNeeded() {
if (sourceMapSupportInstalled)
return;
sourceMapSupportInstalled = true;
Error.stackTraceLimit = 200; Error.stackTraceLimit = 200;
sourceMapSupport.install({ sourceMapSupport.install({

View file

@ -17,7 +17,7 @@
import fs from 'fs'; import fs from 'fs';
import url from 'url'; import url from 'url';
import { addToCompilationCache, currentFileDepsCollector, serializeCompilationCache, startCollectingFileDeps, stopCollectingFileDeps } from './compilationCache'; 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 { PortTransport } from './portTransport';
import { fileIsModule } from '../util'; import { fileIsModule } from '../util';
@ -89,6 +89,11 @@ function initialize(data: { port: MessagePort }) {
function createTransport(port: MessagePort) { function createTransport(port: MessagePort) {
return new PortTransport(port, async (method, params) => { return new PortTransport(port, async (method, params) => {
if (method === 'setSingleTSConfig') {
setSingleTSConfig(params.tsconfig);
return;
}
if (method === 'setTransformConfig') { if (method === 'setTransformConfig') {
setTransformConfig(params.config); setTransformConfig(params.config);
return; return;

View file

@ -25,7 +25,7 @@ import Module from 'module';
import type { BabelPlugin, BabelTransformFunction } from './babelBundle'; import type { BabelPlugin, BabelTransformFunction } from './babelBundle';
import { createFileMatcher, fileIsModule, resolveImportSpecifierExtension } from '../util'; import { createFileMatcher, fileIsModule, resolveImportSpecifierExtension } from '../util';
import type { Matcher } 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; const version = require('../../package.json').version;
@ -57,6 +57,16 @@ export function transformConfig(): TransformConfig {
return _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 { function validateTsConfig(tsconfig: LoadedTsConfig): ParsedTsConfigData {
// When no explicit baseUrl is set, resolve paths relative to the tsconfig file. // When no explicit baseUrl is set, resolve paths relative to the tsconfig file.
// See https://www.typescriptlang.org/tsconfig#paths // See https://www.typescriptlang.org/tsconfig#paths
@ -71,12 +81,12 @@ function validateTsConfig(tsconfig: LoadedTsConfig): ParsedTsConfigData {
} }
function loadAndValidateTsconfigsForFile(file: string): ParsedTsConfigData[] { function loadAndValidateTsconfigsForFile(file: string): ParsedTsConfigData[] {
const cwd = path.dirname(file); const tsconfigPathOrDirecotry = _singleTSConfig || path.dirname(file);
if (!cachedTSConfigs.has(cwd)) { if (!cachedTSConfigs.has(tsconfigPathOrDirecotry)) {
const loaded = tsConfigLoader({ cwd }); const loaded = tsConfigLoader(tsconfigPathOrDirecotry);
cachedTSConfigs.set(cwd, loaded.map(validateTsConfig)); cachedTSConfigs.set(tsconfigPathOrDirecotry, loaded.map(validateTsConfig));
} }
return cachedTSConfigs.get(cwd)!; return cachedTSConfigs.get(tsconfigPathOrDirecotry)!;
} }
const pathSeparator = process.platform === 'win32' ? ';' : ':'; const pathSeparator = process.platform === 'win32' ? ';' : ':';
@ -201,9 +211,8 @@ function calculateHash(content: string, filePath: string, isModule: boolean, plu
} }
export async function requireOrImport(file: string) { export async function requireOrImport(file: string) {
const revertBabelRequire = installTransform(); installTransformIfNeeded();
const isModule = fileIsModule(file); const isModule = fileIsModule(file);
try {
const esmImport = () => eval(`import(${JSON.stringify(url.pathToFileURL(file))})`); const esmImport = () => eval(`import(${JSON.stringify(url.pathToFileURL(file))})`);
if (isModule) if (isModule)
return await esmImport(); return await esmImport();
@ -215,19 +224,20 @@ export async function requireOrImport(file: string) {
collectCJSDependencies(module, depsCollector); collectCJSDependencies(module, depsCollector);
} }
return result; return result;
} finally {
revertBabelRequire();
}
} }
function installTransform(): () => void { let transformInstalled = false;
installSourceMapSupportIfNeeded();
let reverted = false; function installTransformIfNeeded() {
if (transformInstalled)
return;
transformInstalled = true;
installSourceMapSupport();
const originalResolveFilename = (Module as any)._resolveFilename; const originalResolveFilename = (Module as any)._resolveFilename;
function resolveFilename(this: any, specifier: string, parent: Module, ...rest: any[]) { function resolveFilename(this: any, specifier: string, parent: Module, ...rest: any[]) {
if (!reverted && parent) { if (parent) {
const resolved = resolveHook(parent.filename, specifier); const resolved = resolveHook(parent.filename, specifier);
if (resolved !== undefined) if (resolved !== undefined)
specifier = resolved; specifier = resolved;
@ -236,17 +246,11 @@ function installTransform(): () => void {
} }
(Module as any)._resolveFilename = resolveFilename; (Module as any)._resolveFilename = resolveFilename;
const revertPirates = pirates.addHook((code: string, filename: string) => { pirates.addHook((code: string, filename: string) => {
if (!shouldTransform(filename)) if (!shouldTransform(filename))
return code; return code;
return transformHook(code, filename).code; return transformHook(code, filename).code;
}, { exts: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts', '.cjs', '.cts'] }); }, { exts: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts', '.cjs', '.cts'] });
return () => {
reverted = true;
(Module as any)._resolveFilename = originalResolveFilename;
revertPirates();
};
} }
const collectCJSDependencies = (module: Module, dependencies: Set<string>) => { const collectCJSDependencies = (module: Module, dependencies: Set<string>) => {

View file

@ -33,7 +33,7 @@ export interface TestStepInternal {
complete(result: { error?: Error, attachments?: Attachment[] }): void; complete(result: { error?: Error, attachments?: Attachment[] }): void;
stepId: string; stepId: string;
title: string; title: string;
category: 'hook' | 'fixture' | 'test.step' | 'expect' | string; category: 'hook' | 'fixture' | 'test.step' | 'expect' | 'attach' | string;
location?: Location; location?: Location;
boxedStack?: StackFrame[]; boxedStack?: StackFrame[];
steps: TestStepInternal[]; steps: TestStepInternal[];
@ -252,11 +252,6 @@ export class TestInfoImpl implements TestInfo {
parentStep = this._findLastStageStep(); parentStep = this._findLastStageStep();
} else { } else {
parentStep = zones.zoneData<TestStepInternal>('stepZone'); parentStep = zones.zoneData<TestStepInternal>('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 (!parentStep) {
// If no parent step on stack, assume the current stage as parent. // If no parent step on stack, assume the current stage as parent.
parentStep = this._findLastStageStep(); parentStep = this._findLastStageStep();

View file

@ -17,7 +17,7 @@
import './callLog.css'; import './callLog.css';
import * as React from 'react'; import * as React from 'react';
import type { CallLog } from './recorderTypes'; import type { CallLog } from './recorderTypes';
import { msToString } from '@web/uiUtils'; import { clsx, msToString } from '@web/uiUtils';
import { asLocator } from '@isomorphic/locatorGenerators'; import { asLocator } from '@isomorphic/locatorGenerators';
import type { Language } from '@isomorphic/locatorGenerators'; import type { Language } from '@isomorphic/locatorGenerators';
@ -53,9 +53,9 @@ export const CallLogView: React.FC<CallLogProps> = ({
titlePrefix = callLog.title + '('; titlePrefix = callLog.title + '(';
titleSuffix = ')'; titleSuffix = ')';
} }
return <div className={`call-log-call ${callLog.status}`} key={callLog.id}> return <div className={clsx('call-log-call', callLog.status)} key={callLog.id}>
<div className='call-log-call-header'> <div className='call-log-call-header'>
<span className={`codicon codicon-chevron-${isExpanded ? 'down' : 'right'}`} style={{ cursor: 'pointer' }}onClick={() => { <span className={clsx('codicon', `codicon-chevron-${isExpanded ? 'down' : 'right'}`)} style={{ cursor: 'pointer' }}onClick={() => {
const newOverrides = new Map(expandOverrides); const newOverrides = new Map(expandOverrides);
newOverrides.set(callLog.id, !isExpanded); newOverrides.set(callLog.id, !isExpanded);
setExpandOverrides(newOverrides); setExpandOverrides(newOverrides);
@ -64,7 +64,7 @@ export const CallLogView: React.FC<CallLogProps> = ({
{ callLog.params.url ? <span className='call-log-details'><span className='call-log-url' title={callLog.params.url}>{callLog.params.url}</span></span> : undefined } { callLog.params.url ? <span className='call-log-details'><span className='call-log-url' title={callLog.params.url}>{callLog.params.url}</span></span> : undefined }
{ locator ? <span className='call-log-details'><span className='call-log-selector' title={`page.${locator}`}>{`page.${locator}`}</span></span> : undefined } { locator ? <span className='call-log-details'><span className='call-log-selector' title={`page.${locator}`}>{`page.${locator}`}</span></span> : undefined }
{ titleSuffix } { titleSuffix }
<span className={'codicon ' + iconClass(callLog)}></span> <span className={clsx('codicon', iconClass(callLog))}></span>
{ typeof callLog.duration === 'number' ? <span className='call-log-time'> {msToString(callLog.duration)}</span> : undefined} { typeof callLog.duration === 'number' ? <span className='call-log-time'> {msToString(callLog.duration)}</span> : undefined}
</div> </div>
{ (isExpanded ? callLog.messages : []).map((message, i) => { { (isExpanded ? callLog.messages : []).map((message, i) => {

View file

@ -167,26 +167,27 @@ export const Recorder: React.FC<RecorderProps> = ({
}}></ToolbarButton> }}></ToolbarButton>
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}></ToolbarButton> <ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}></ToolbarButton>
</Toolbar> </Toolbar>
<SplitView sidebarSize={200}> <SplitView
<CodeMirrorWrapper text={source.text} language={source.language} highlight={source.highlight} revealLine={source.revealLine} readOnly={true} lineNumbers={true}/> sidebarSize={200}
<TabbedPane main={<CodeMirrorWrapper text={source.text} language={source.language} highlight={source.highlight} revealLine={source.revealLine} readOnly={true} lineNumbers={true} />}
sidebar={<TabbedPane
rightToolbar={selectedTab === 'locator' ? [<ToolbarButton icon='files' title='Copy' onClick={() => copy(locator)} />] : []} rightToolbar={selectedTab === 'locator' ? [<ToolbarButton icon='files' title='Copy' onClick={() => copy(locator)} />] : []}
tabs={[ tabs={[
{ {
id: 'locator', id: 'locator',
title: 'Locator', title: 'Locator',
render: () => <CodeMirrorWrapper text={locator} language={source.language} readOnly={false} focusOnChange={true} onChange={onEditorChange} wrapLines={true}/> render: () => <CodeMirrorWrapper text={locator} language={source.language} readOnly={false} focusOnChange={true} onChange={onEditorChange} wrapLines={true} />
}, },
{ {
id: 'log', id: 'log',
title: 'Log', title: 'Log',
render: () => <CallLogView language={source.language} log={Array.from(log.values())}/> render: () => <CallLogView language={source.language} log={Array.from(log.values())} />
}, },
]} ]}
selectedTab={selectedTab} selectedTab={selectedTab}
setSelectedTab={setSelectedTab} setSelectedTab={setSelectedTab}
/>}
/> />
</SplitView>
</div>; </div>;
}; };

View file

@ -17,7 +17,6 @@
import '@web/common.css'; import '@web/common.css';
import { applyTheme } from '@web/theme'; import { applyTheme } from '@web/theme';
import '@web/third_party/vscode/codicon.css'; import '@web/third_party/vscode/codicon.css';
import React from 'react';
import * as ReactDOM from 'react-dom'; import * as ReactDOM from 'react-dom';
import { EmbeddedWorkbenchLoader } from './ui/embeddedWorkbenchLoader'; import { EmbeddedWorkbenchLoader } from './ui/embeddedWorkbenchLoader';

View file

@ -17,7 +17,6 @@
import '@web/common.css'; import '@web/common.css';
import { applyTheme } from '@web/theme'; import { applyTheme } from '@web/theme';
import '@web/third_party/vscode/codicon.css'; import '@web/third_party/vscode/codicon.css';
import React from 'react';
import * as ReactDOM from 'react-dom'; import * as ReactDOM from 'react-dom';
import { WorkbenchLoader } from './ui/workbenchLoader'; import { WorkbenchLoader } from './ui/workbenchLoader';

View file

@ -14,6 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { escapeHTMLAttribute, escapeHTML } from '@isomorphic/stringUtils';
import type { FrameSnapshot, NodeNameAttributesChildNodesSnapshot, NodeSnapshot, RenderedFrameSnapshot, ResourceSnapshot, SubtreeReferenceSnapshot } from '@trace/snapshot'; import type { FrameSnapshot, NodeNameAttributesChildNodesSnapshot, NodeSnapshot, RenderedFrameSnapshot, ResourceSnapshot, SubtreeReferenceSnapshot } from '@trace/snapshot';
function isNodeNameAttributesChildNodesSnapshot(n: NodeSnapshot): n is NodeNameAttributesChildNodesSnapshot { function isNodeNameAttributesChildNodesSnapshot(n: NodeSnapshot): n is NodeNameAttributesChildNodesSnapshot {
@ -57,7 +58,7 @@ export class SnapshotRenderer {
// Old snapshotter was sending lower-case. // Old snapshotter was sending lower-case.
if (parentTag === 'STYLE' || parentTag === 'style') if (parentTag === 'STYLE' || parentTag === 'style')
return rewriteURLsInStyleSheetForCustomProtocol(n); return rewriteURLsInStyleSheetForCustomProtocol(n);
return escapeText(n); return escapeHTML(n);
} }
if (!(n as any)._string) { if (!(n as any)._string) {
@ -106,7 +107,7 @@ export class SnapshotRenderer {
attrValue = 'link://' + value; attrValue = 'link://' + value;
else if (attr.toLowerCase() === 'href' || attr.toLowerCase() === 'src' || attr === kCurrentSrcAttribute) else if (attr.toLowerCase() === 'href' || attr.toLowerCase() === 'src' || attr === kCurrentSrcAttribute)
attrValue = rewriteURLForCustomProtocol(value); attrValue = rewriteURLForCustomProtocol(value);
builder.push(' ', attrName, '="', escapeAttribute(attrValue), '"'); builder.push(' ', attrName, '="', escapeHTMLAttribute(attrValue), '"');
} }
builder.push('>'); builder.push('>');
for (const child of children) 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 autoClosing = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
const escaped = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' };
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[] { function snapshotNodes(snapshot: FrameSnapshot): NodeSnapshot[] {
if (!(snapshot as any)._nodes) { if (!(snapshot as any)._nodes) {

View file

@ -37,7 +37,7 @@ export class SnapshotServer {
return new Response(null, { status: 404 }); return new Response(null, { status: 404 });
const renderedSnapshot = snapshot.render(); const renderedSnapshot = snapshot.render();
this._snapshotIds.set(snapshotUrl, snapshot); 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 { serveSnapshotInfo(pathname: string, searchParams: URLSearchParams): Response {

View file

@ -130,13 +130,12 @@ async function doFetch(event: FetchEvent): Promise<Response> {
} }
if (relativePath.startsWith('/sha1/')) { 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. // Sha1 for sources is based on the file path, can't load it of a random model.
const sha1 = relativePath.slice('/sha1/'.length); const sha1 = relativePath.slice('/sha1/'.length);
for (const trace of loadedTraces.values()) { for (const trace of loadedTraces.values()) {
const blob = await trace.traceModel.resourceForSha1(sha1); const blob = await trace.traceModel.resourceForSha1(sha1);
if (blob) 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 }); return new Response(null, { status: 404 });
} }
@ -157,14 +156,15 @@ async function doFetch(event: FetchEvent): Promise<Response> {
return snapshotServer.serveResource(lookupUrls, request.method, snapshotUrl); return snapshotServer.serveResource(lookupUrls, request.method, snapshotUrl);
} }
function downloadHeadersForAttachment(traceModel: TraceModel, sha1: string): Headers | undefined { function downloadHeaders(searchParams: URLSearchParams): Headers | undefined {
const attachment = traceModel.attachmentForSha1(sha1); const name = searchParams.get('dn');
if (!attachment) const contentType = searchParams.get('dct');
if (!name)
return; return;
const headers = new Headers(); const headers = new Headers();
headers.set('Content-Disposition', `attachment; filename="attachment"; filename*=UTF-8''${encodeURIComponent(attachment.name)}`); headers.set('Content-Disposition', `attachment; filename="attachment"; filename*=UTF-8''${encodeURIComponent(name)}`);
if (attachment.contentType) if (contentType)
headers.set('Content-Type', attachment.contentType); headers.set('Content-Type', contentType);
return headers; return headers;
} }

View file

@ -14,7 +14,6 @@
* limitations under the License. * limitations under the License.
*/ */
import type * as trace from '@trace/trace';
import { parseClientSideCallMetadata } from '../../../packages/playwright-core/src/utils/isomorphic/traceUtils'; import { parseClientSideCallMetadata } from '../../../packages/playwright-core/src/utils/isomorphic/traceUtils';
import type { ContextEntry } from './entries'; import type { ContextEntry } from './entries';
import { createEmptyContext } from './entries'; import { createEmptyContext } from './entries';
@ -34,7 +33,6 @@ export class TraceModel {
contextEntries: ContextEntry[] = []; contextEntries: ContextEntry[] = [];
private _snapshotStorage: SnapshotStorage | undefined; private _snapshotStorage: SnapshotStorage | undefined;
private _backend!: TraceModelBackend; private _backend!: TraceModelBackend;
private _attachments = new Map<string, trace.AfterActionTraceEventAttachment>();
private _resourceToContentType = new Map<string, string>(); private _resourceToContentType = new Map<string, string>();
constructor() { constructor() {
@ -64,7 +62,7 @@ export class TraceModel {
const contextEntry = createEmptyContext(); const contextEntry = createEmptyContext();
contextEntry.traceUrl = backend.traceURL(); contextEntry.traceUrl = backend.traceURL();
contextEntry.hasSource = hasSource; 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') || ''; const trace = await this._backend.readText(ordinal + '.trace') || '';
modernizer.appendTrace(trace); modernizer.appendTrace(trace);
@ -121,10 +119,6 @@ export class TraceModel {
return new Blob([blob], { type: this._resourceToContentType.get(sha1) || 'application/octet-stream' }); 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 { storage(): SnapshotStorage {
return this._snapshotStorage!; return this._snapshotStorage!;
} }

View file

@ -34,17 +34,15 @@ const latestVersion: trace.VERSION = 7;
export class TraceModernizer { export class TraceModernizer {
private _contextEntry: ContextEntry; private _contextEntry: ContextEntry;
private _snapshotStorage: SnapshotStorage; private _snapshotStorage: SnapshotStorage;
private _attachments: Map<string, trace.AfterActionTraceEventAttachment>;
private _actionMap = new Map<string, ActionEntry>(); private _actionMap = new Map<string, ActionEntry>();
private _version: number | undefined; private _version: number | undefined;
private _pageEntries = new Map<string, PageEntry>(); private _pageEntries = new Map<string, PageEntry>();
private _jsHandles = new Map<string, { preview: string }>(); private _jsHandles = new Map<string, { preview: string }>();
private _consoleObjects = new Map<string, { type: string, text: string, location: { url: string, lineNumber: number, columnNumber: number }, args?: { preview: string, value: string }[] }>(); private _consoleObjects = new Map<string, { type: string, text: string, location: { url: string, lineNumber: number, columnNumber: number }, args?: { preview: string, value: string }[] }>();
constructor(contextEntry: ContextEntry, snapshotStorage: SnapshotStorage, attachments: Map<string, trace.AfterActionTraceEventAttachment>) { constructor(contextEntry: ContextEntry, snapshotStorage: SnapshotStorage) {
this._contextEntry = contextEntry; this._contextEntry = contextEntry;
this._snapshotStorage = snapshotStorage; this._snapshotStorage = snapshotStorage;
this._attachments = attachments;
} }
appendTrace(trace: string) { appendTrace(trace: string) {
@ -129,8 +127,6 @@ export class TraceModernizer {
existing!.attachments = event.attachments; existing!.attachments = event.attachments;
if (event.point) if (event.point)
existing!.point = event.point; existing!.point = event.point;
for (const attachment of event.attachments?.filter(a => a.sha1) || [])
this._attachments.set(attachment.sha1!, attachment);
break; break;
} }
case 'action': { case 'action': {

View file

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

View file

@ -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 <PlaceholderPanel text='No annotations' />;
return <div className='annotations-tab'>
{annotations.map((annotation, i) => {
return <div className='annotation-item' key={`annotation-${i}`}>
<span style={{ fontWeight: 'bold' }}>{annotation.type}</span>
{annotation.description && <span>: {linkifyText(annotation.description)}</span>}
</div>;
})}
</div>;
};

View file

@ -26,13 +26,14 @@
padding-left: 6px; padding-left: 6px;
font-weight: bold; font-weight: bold;
text-transform: uppercase; text-transform: uppercase;
font-size: 10px; font-size: 12px;
color: var(--vscode-sideBarTitle-foreground); color: var(--vscode-sideBarTitle-foreground);
line-height: 24px; line-height: 24px;
} }
.attachments-section:not(:first-child) { .attachments-section:not(:first-child) {
border-top: 1px solid var(--vscode-panel-border); border-top: 1px solid var(--vscode-panel-border);
margin-top: 10px;
} }
.attachment-item { .attachment-item {

View file

@ -23,6 +23,7 @@ import type { AfterActionTraceEventAttachment } from '@trace/trace';
import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
import { isTextualMimeType } from '@isomorphic/mimeType'; import { isTextualMimeType } from '@isomorphic/mimeType';
import { Expandable } from '@web/components/expandable'; import { Expandable } from '@web/components/expandable';
import { linkifyText } from '@web/renderUtils';
type Attachment = AfterActionTraceEventAttachment & { traceUrl: string }; type Attachment = AfterActionTraceEventAttachment & { traceUrl: string };
@ -36,6 +37,7 @@ const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> =
const [placeholder, setPlaceholder] = React.useState<string | null>(null); const [placeholder, setPlaceholder] = React.useState<string | null>(null);
const isTextAttachment = isTextualMimeType(attachment.contentType); const isTextAttachment = isTextualMimeType(attachment.contentType);
const hasContent = !!attachment.sha1 || !!attachment.path;
React.useEffect(() => { React.useEffect(() => {
if (expanded && attachmentText === null && placeholder === null) { if (expanded && attachmentText === null && placeholder === null) {
@ -49,11 +51,11 @@ const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> =
} }
}, [expanded, attachmentText, placeholder, attachment]); }, [expanded, attachmentText, placeholder, attachment]);
const title = <> const title = <span style={{ marginLeft: 5 }}>
{attachment.name} <a style={{ marginLeft: 5 }} href={attachmentURL(attachment) + '&download'}>download</a> {linkifyText(attachment.name)} {hasContent && <a style={{ marginLeft: 5 }} href={downloadURL(attachment)}>download</a>}
</>; </span>;
if (!isTextAttachment) if (!isTextAttachment || !hasContent)
return <div style={{ marginLeft: 20 }}>{title}</div>; return <div style={{ marginLeft: 20 }}>{title}</div>;
return <> return <>
@ -63,6 +65,8 @@ const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> =
{expanded && attachmentText !== null && <CodeMirrorWrapper {expanded && attachmentText !== null && <CodeMirrorWrapper
text={attachmentText} text={attachmentText}
readOnly readOnly
mimeType={attachment.contentType}
linkify={true}
lineNumbers={true} lineNumbers={true}
wrapLines={false}> wrapLines={false}>
</CodeMirrorWrapper>} </CodeMirrorWrapper>}
@ -93,8 +97,8 @@ export const AttachmentsTab: React.FunctionComponent<{
const entry = diffMap.get(name) || { expected: undefined, actual: undefined, diff: undefined }; const entry = diffMap.get(name) || { expected: undefined, actual: undefined, diff: undefined };
entry[type] = attachment; entry[type] = attachment;
diffMap.set(name, entry); diffMap.set(name, entry);
} attachments.delete(attachment);
if (attachment.contentType.startsWith('image/')) { } else if (attachment.contentType.startsWith('image/')) {
screenshots.add(attachment); screenshots.add(attachment);
attachments.delete(attachment); attachments.delete(attachment);
} }
@ -109,11 +113,11 @@ export const AttachmentsTab: React.FunctionComponent<{
{[...diffMap.values()].map(({ expected, actual, diff }) => { {[...diffMap.values()].map(({ expected, actual, diff }) => {
return <> return <>
{expected && actual && <div className='attachments-section'>Image diff</div>} {expected && actual && <div className='attachments-section'>Image diff</div>}
{expected && actual && <ImageDiffView diff={{ {expected && actual && <ImageDiffView noTargetBlank={true} diff={{
name: 'Image diff', name: 'Image diff',
expected: { attachment: { ...expected, path: attachmentURL(expected) }, title: 'Expected' }, expected: { attachment: { ...expected, path: downloadURL(expected) }, title: 'Expected' },
actual: { attachment: { ...actual, path: attachmentURL(actual) } }, actual: { attachment: { ...actual, path: downloadURL(actual) } },
diff: diff ? { attachment: { ...diff, path: attachmentURL(diff) } } : undefined, diff: diff ? { attachment: { ...diff, path: downloadURL(diff) } } : undefined,
}} />} }} />}
</>; </>;
})} })}
@ -134,8 +138,19 @@ export const AttachmentsTab: React.FunctionComponent<{
</div>; </div>;
}; };
function attachmentURL(attachment: Attachment) { function attachmentURL(attachment: Attachment, queryParams: Record<string, string> = {}) {
if (attachment.sha1) const params = new URLSearchParams(queryParams);
return 'sha1/' + attachment.sha1 + '?trace=' + encodeURIComponent(attachment.traceUrl); if (attachment.sha1) {
return 'file?path=' + encodeURIComponent(attachment.path!); 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<string, string>;
if (attachment.contentType)
params.dct = attachment.contentType;
return attachmentURL(attachment, params);
} }

View file

@ -16,7 +16,7 @@
import type { SerializedValue } from '@protocol/channels'; import type { SerializedValue } from '@protocol/channels';
import type { ActionTraceEvent } from '@trace/trace'; import type { ActionTraceEvent } from '@trace/trace';
import { msToString } from '@web/uiUtils'; import { clsx, msToString } from '@web/uiUtils';
import * as React from 'react'; import * as React from 'react';
import './callTab.css'; import './callTab.css';
import { CopyToClipboard } from './copyToClipboard'; import { CopyToClipboard } from './copyToClipboard';
@ -71,7 +71,7 @@ function renderProperty(property: Property, key: string) {
text = `"${text}"`; text = `"${text}"`;
return ( return (
<div key={key} className='call-line'> <div key={key} className='call-line'>
{property.name}:<span className={`call-value ${property.type}`} title={property.text}>{text}</span> {property.name}:<span className={clsx('call-value', property.type)} title={property.text}>{text}</span>
{ ['string', 'number', 'object', 'locator'].includes(property.type) && { ['string', 'number', 'object', 'locator'].includes(property.type) &&
<CopyToClipboard value={property.text} /> <CopyToClipboard value={property.text} />
} }

View file

@ -20,7 +20,7 @@ import './consoleTab.css';
import type * as modelUtil from './modelUtil'; import type * as modelUtil from './modelUtil';
import { ListView } from '@web/components/listView'; import { ListView } from '@web/components/listView';
import type { Boundaries } from '../geometry'; import type { Boundaries } from '../geometry';
import { msToString } from '@web/uiUtils'; import { clsx, msToString } from '@web/uiUtils';
import { ansi2html } from '@web/ansi2html'; import { ansi2html } from '@web/ansi2html';
import { PlaceholderPanel } from './placeholderPanel'; import { PlaceholderPanel } from './placeholderPanel';
@ -124,8 +124,8 @@ export const ConsoleTab: React.FunctionComponent<{
render={entry => { render={entry => {
const timestamp = msToString(entry.timestamp - boundaries.minimum); const timestamp = msToString(entry.timestamp - boundaries.minimum);
const timestampElement = <span className='console-time'>{timestamp}</span>; const timestampElement = <span className='console-time'>{timestamp}</span>;
const errorSuffix = entry.isError ? ' status-error' : entry.isWarning ? ' status-warning' : ' status-none'; const errorSuffix = entry.isError ? 'status-error' : entry.isWarning ? 'status-warning' : 'status-none';
const statusElement = entry.browserMessage || entry.browserError ? <span className={'codicon codicon-browser' + errorSuffix} title='Browser message'></span> : <span className={'codicon codicon-file' + errorSuffix} title='Runner message'></span>; const statusElement = entry.browserMessage || entry.browserError ? <span className={clsx('codicon', 'codicon-browser', errorSuffix)} title='Browser message'></span> : <span className={clsx('codicon', 'codicon-file', errorSuffix)} title='Runner message'></span>;
let locationText: string | undefined; let locationText: string | undefined;
let messageBody: JSX.Element[] | string | undefined; let messageBody: JSX.Element[] | string | undefined;
let messageInnerHTML: string | undefined; let messageInnerHTML: string | undefined;

View file

@ -86,7 +86,7 @@ export const EmbeddedWorkbenchLoader: React.FunctionComponent = () => {
<div className='progress'> <div className='progress'>
<div className='inner-progress' style={{ width: progress.total ? (100 * progress.done / progress.total) + '%' : 0 }}></div> <div className='inner-progress' style={{ width: progress.total ? (100 * progress.done / progress.total) + '%' : 0 }}></div>
</div> </div>
<Workbench model={model} openPage={openPage} /> <Workbench model={model} openPage={openPage} showSettings />
{!traceURLs.length && <div className='empty-state'> {!traceURLs.length && <div className='empty-state'>
<div className='title'>Select test to see the trace</div> <div className='title'>Select test to see the trace</div>
</div>} </div>}

View file

@ -24,7 +24,8 @@ export const MetadataView: React.FunctionComponent<{
}> = ({ model }) => { }> = ({ model }) => {
if (!model) if (!model)
return <></>; return <></>;
return <div className='metadata-view vbox'>
return <div data-testid='metadata-view' className='vbox' style={{ flexShrink: 0 }}>
<div className='call-section' style={{ paddingTop: 2 }}>Time</div> <div className='call-section' style={{ paddingTop: 2 }}>Time</div>
{!!model.wallTime && <div className='call-line'>start time:<span className='call-value datetime' title={new Date(model.wallTime).toLocaleString()}>{new Date(model.wallTime).toLocaleString()}</span></div>} {!!model.wallTime && <div className='call-line'>start time:<span className='call-value datetime' title={new Date(model.wallTime).toLocaleString()}>{new Date(model.wallTime).toLocaleString()}</span></div>}
<div className='call-line'>duration:<span className='call-value number' title={msToString(model.endTime - model.startTime)}>{msToString(model.endTime - model.startTime)}</span></div> <div className='call-line'>duration:<span className='call-value number' title={msToString(model.endTime - model.startTime)}>{msToString(model.endTime - model.startTime)}</span></div>

View file

@ -409,3 +409,30 @@ function collectSources(actions: trace.ActionTraceEvent[], errorDescriptors: Err
} }
return result; 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());
}

View file

@ -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);
}

View file

@ -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 (
<div className='network-filters'>
<input
type='search'
placeholder='Filter network'
spellCheck={false}
value={filterState.searchValue}
onChange={e => onFilterStateChange({ ...filterState, searchValue: e.target.value })}
/>
<div className='network-filters-resource-types'>
{resourceTypes.map(resourceType => (
<div
key={resourceType}
title={resourceType}
onClick={() => onFilterStateChange({ ...filterState, resourceType })}
className={`network-filters-resource-type ${filterState.resourceType === resourceType ? 'selected' : ''}`}
>
{resourceType}
</div>
))}
</div>
</div>
);
};

View file

@ -61,3 +61,27 @@
.tab-network .tabbed-pane-tab.selected { .tab-network .tabbed-pane-tab.selected {
font-weight: bold; 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);
}

View file

@ -19,7 +19,6 @@ import * as React from 'react';
import './networkResourceDetails.css'; import './networkResourceDetails.css';
import { TabbedPane } from '@web/components/tabbedPane'; import { TabbedPane } from '@web/components/tabbedPane';
import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
import type { Language } from '@web/components/codeMirrorWrapper';
import { ToolbarButton } from '@web/components/toolbarButton'; import { ToolbarButton } from '@web/components/toolbarButton';
export const NetworkResourceDetails: React.FunctionComponent<{ export const NetworkResourceDetails: React.FunctionComponent<{
@ -55,19 +54,18 @@ export const NetworkResourceDetails: React.FunctionComponent<{
const RequestTab: React.FunctionComponent<{ const RequestTab: React.FunctionComponent<{
resource: ResourceSnapshot; resource: ResourceSnapshot;
}> = ({ resource }) => { }> = ({ 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(() => { React.useEffect(() => {
const readResources = async () => { const readResources = async () => {
if (resource.request.postData) { if (resource.request.postData) {
const requestContentTypeHeader = resource.request.headers.find(q => q.name === 'Content-Type'); const requestContentTypeHeader = resource.request.headers.find(q => q.name === 'Content-Type');
const requestContentType = requestContentTypeHeader ? requestContentTypeHeader.value : ''; const requestContentType = requestContentTypeHeader ? requestContentTypeHeader.value : '';
const language = mimeTypeToHighlighter(requestContentType);
if (resource.request.postData._sha1) { if (resource.request.postData._sha1) {
const response = await fetch(`sha1/${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 { } else {
setRequestBody({ text: formatBody(resource.request.postData.text, requestContentType), language }); setRequestBody({ text: formatBody(resource.request.postData.text, requestContentType), mimeType: requestContentType });
} }
} else { } else {
setRequestBody(null); setRequestBody(null);
@ -80,15 +78,14 @@ const RequestTab: React.FunctionComponent<{
<div className='network-request-details-header'>General</div> <div className='network-request-details-header'>General</div>
<div className='network-request-details-url'>{`URL: ${resource.request.url}`}</div> <div className='network-request-details-url'>{`URL: ${resource.request.url}`}</div>
<div className='network-request-details-general'>{`Method: ${resource.request.method}`}</div> <div className='network-request-details-general'>{`Method: ${resource.request.method}`}</div>
<div className='network-request-details-general'>{`Status Code: ${ {resource.response.status !== -1 && <div className='network-request-details-general' style={{ display: 'flex' }}>
resource.response.status >= 200 && resource.response.status < 400 Status Code: <span className={statusClass(resource.response.status)} style={{ display: 'inline-flex' }}>
? `🟢 ${resource.response.status} ${resource.response.statusText}` {`${resource.response.status} ${resource.response.statusText}`}
: `🔴 ${resource.response.status} ${resource.response.statusText}` </span></div>}
}`}</div>
<div className='network-request-details-header'>Request Headers</div> <div className='network-request-details-header'>Request Headers</div>
<div className='network-request-details-headers'>{resource.request.headers.map(pair => `${pair.name}: ${pair.value}`).join('\n')}</div> <div className='network-request-details-headers'>{resource.request.headers.map(pair => `${pair.name}: ${pair.value}`).join('\n')}</div>
{requestBody && <div className='network-request-details-header'>Request Body</div>} {requestBody && <div className='network-request-details-header'>Request Body</div>}
{requestBody && <CodeMirrorWrapper text={requestBody.text} language={requestBody.language} readOnly lineNumbers={true}/>} {requestBody && <CodeMirrorWrapper text={requestBody.text} mimeType={requestBody.mimeType} readOnly lineNumbers={true}/>}
</div>; </div>;
}; };
@ -104,7 +101,7 @@ const ResponseTab: React.FunctionComponent<{
const BodyTab: React.FunctionComponent<{ const BodyTab: React.FunctionComponent<{
resource: ResourceSnapshot; resource: ResourceSnapshot;
}> = ({ resource }) => { }> = ({ 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(() => { React.useEffect(() => {
const readResources = async () => { const readResources = async () => {
@ -119,9 +116,10 @@ const BodyTab: React.FunctionComponent<{
setResponseBody({ dataUrl: (await eventPromise).target.result }); setResponseBody({ dataUrl: (await eventPromise).target.result });
} else { } else {
const formattedBody = formatBody(await response.text(), resource.response.content.mimeType); const formattedBody = formatBody(await response.text(), resource.response.content.mimeType);
const language = mimeTypeToHighlighter(resource.response.content.mimeType); setResponseBody({ text: formattedBody, mimeType: resource.response.content.mimeType });
setResponseBody({ text: formattedBody, language });
} }
} else {
setResponseBody(null);
} }
}; };
@ -131,10 +129,18 @@ const BodyTab: React.FunctionComponent<{
return <div className='network-request-details-tab'> return <div className='network-request-details-tab'>
{!resource.response.content._sha1 && <div>Response body is not available for this request.</div>} {!resource.response.content._sha1 && <div>Response body is not available for this request.</div>}
{responseBody && responseBody.dataUrl && <img draggable='false' src={responseBody.dataUrl} />} {responseBody && responseBody.dataUrl && <img draggable='false' src={responseBody.dataUrl} />}
{responseBody && responseBody.text && <CodeMirrorWrapper text={responseBody.text} language={responseBody.language} readOnly lineNumbers={true}/>} {responseBody && responseBody.text && <CodeMirrorWrapper text={responseBody.text} mimeType={responseBody.mimeType} readOnly lineNumbers={true}/>}
</div>; </div>;
}; };
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 { function formatBody(body: string | null, contentType: string): string {
if (body === null) if (body === null)
return 'Loading...'; return 'Loading...';
@ -156,12 +162,3 @@ function formatBody(body: string | null, contentType: string): string {
return bodyStr; 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';
}

View file

@ -25,6 +25,7 @@ import { context, type MultiTraceModel } from './modelUtil';
import { GridView, type RenderedGridCell } from '@web/components/gridView'; import { GridView, type RenderedGridCell } from '@web/components/gridView';
import { SplitView } from '@web/components/splitView'; import { SplitView } from '@web/components/splitView';
import type { ContextEntry } from '../entries'; import type { ContextEntry } from '../entries';
import { NetworkFilters, defaultFilterState, type FilterState, type ResourceType } from './networkFilters';
type NetworkTabModel = { type NetworkTabModel = {
resources: Entry[], resources: Entry[],
@ -68,18 +69,24 @@ export const NetworkTab: React.FunctionComponent<{
}> = ({ boundaries, networkModel, onEntryHovered }) => { }> = ({ boundaries, networkModel, onEntryHovered }) => {
const [sorting, setSorting] = React.useState<Sorting | undefined>(undefined); const [sorting, setSorting] = React.useState<Sorting | undefined>(undefined);
const [selectedEntry, setSelectedEntry] = React.useState<RenderedEntry | undefined>(undefined); const [selectedEntry, setSelectedEntry] = React.useState<RenderedEntry | undefined>(undefined);
const [filterState, setFilterState] = React.useState(defaultFilterState);
const { renderedEntries } = React.useMemo(() => { 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) if (sorting)
sort(renderedEntries, sorting); sort(renderedEntries, sorting);
return { renderedEntries }; return { renderedEntries };
}, [networkModel.resources, networkModel.contextIdMap, sorting, boundaries]); }, [networkModel.resources, networkModel.contextIdMap, filterState, sorting, boundaries]);
const [columnWidths, setColumnWidths] = React.useState<Map<ColumnName, number>>(() => { const [columnWidths, setColumnWidths] = React.useState<Map<ColumnName, number>>(() => {
return new Map(allColumns().map(column => [column, columnWidth(column)])); return new Map(allColumns().map(column => [column, columnWidth(column)]));
}); });
const onFilterStateChange = React.useCallback((newFilterState: FilterState) => {
setFilterState(newFilterState);
setSelectedEntry(undefined);
}, []);
if (!networkModel.resources.length) if (!networkModel.resources.length)
return <PlaceholderPanel text='No network calls' />; return <PlaceholderPanel text='No network calls' />;
@ -93,18 +100,24 @@ export const NetworkTab: React.FunctionComponent<{
columnTitle={columnTitle} columnTitle={columnTitle}
columnWidths={columnWidths} columnWidths={columnWidths}
setColumnWidths={setColumnWidths} setColumnWidths={setColumnWidths}
isError={item => item.status.code >= 400} isError={item => item.status.code >= 400 || item.status.code === -1}
isInfo={item => !!item.route} isInfo={item => !!item.route}
render={(item, column) => renderCell(item, column)} render={(item, column) => renderCell(item, column)}
sorting={sorting} sorting={sorting}
setSorting={setSorting} setSorting={setSorting}
/>; />;
return <> return <>
<NetworkFilters filterState={filterState} onFilterStateChange={onFilterStateChange} />
{!selectedEntry && grid} {!selectedEntry && grid}
{selectedEntry && <SplitView sidebarSize={columnWidths.get('name')!} sidebarIsFirst={true} orientation='horizontal' settingName='networkResourceDetails'> {selectedEntry &&
<NetworkResourceDetails resource={selectedEntry.resource} onClose={() => setSelectedEntry(undefined)} /> <SplitView
{grid} sidebarSize={columnWidths.get('name')!}
</SplitView>} sidebarIsFirst={true}
orientation='horizontal'
settingName='networkResourceDetails'
main={<NetworkResourceDetails resource={selectedEntry.resource} onClose={() => setSelectedEntry(undefined)} />}
sidebar={grid}
/>}
</>; </>;
}; };
@ -340,3 +353,21 @@ function comparator(sortBy: ColumnName) {
if (sortBy === 'contextId') if (sortBy === 'contextId')
return (a: RenderedEntry, b: RenderedEntry) => a.contextId.localeCompare(b.contextId); return (a: RenderedEntry, b: RenderedEntry) => a.contextId.localeCompare(b.contextId);
} }
const resourceTypePredicates: Record<ResourceType, (contentType: string) => 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());
};
}

View file

@ -20,7 +20,7 @@ import type { ActionTraceEvent } from '@trace/trace';
import { context, prevInList } from './modelUtil'; import { context, prevInList } from './modelUtil';
import { Toolbar } from '@web/components/toolbar'; import { Toolbar } from '@web/components/toolbar';
import { ToolbarButton } from '@web/components/toolbarButton'; import { ToolbarButton } from '@web/components/toolbarButton';
import { useMeasure } from '@web/uiUtils'; import { clsx, useMeasure } from '@web/uiUtils';
import { InjectedScript } from '@injected/injectedScript'; import { InjectedScript } from '@injected/injectedScript';
import { Recorder } from '@injected/recorder/recorder'; import { Recorder } from '@injected/recorder/recorder';
import ConsoleAPI from '@injected/consoleApi'; import ConsoleAPI from '@injected/consoleApi';
@ -209,8 +209,8 @@ export const SnapshotTab: React.FunctionComponent<{
}}> }}>
<BrowserFrame url={snapshotInfo.url} /> <BrowserFrame url={snapshotInfo.url} />
<div className='snapshot-switcher'> <div className='snapshot-switcher'>
<iframe ref={iframeRef0} name='snapshot' title='DOM Snapshot' className={loadingRef.current.visibleIframe === 0 ? 'snapshot-visible' : ''}></iframe> <iframe ref={iframeRef0} name='snapshot' title='DOM Snapshot' className={clsx(loadingRef.current.visibleIframe === 0 && 'snapshot-visible')}></iframe>
<iframe ref={iframeRef1} name='snapshot' title='DOM Snapshot' className={loadingRef.current.visibleIframe === 1 ? 'snapshot-visible' : ''}></iframe> <iframe ref={iframeRef1} name='snapshot' title='DOM Snapshot' className={clsx(loadingRef.current.visibleIframe === 1 && 'snapshot-visible')}></iframe>
</div> </div>
</div> </div>
</div> </div>

View file

@ -96,17 +96,20 @@ export const SourceTab: React.FunctionComponent<{
const showStackFrames = (stack?.length ?? 0) > 1; const showStackFrames = (stack?.length ?? 0) > 1;
return <SplitView sidebarSize={200} orientation={stackFrameLocation === 'bottom' ? 'vertical' : 'horizontal'} sidebarHidden={!showStackFrames}> return <SplitView
<div className='vbox' data-testid='source-code'> sidebarSize={200}
orientation={stackFrameLocation === 'bottom' ? 'vertical' : 'horizontal'}
sidebarHidden={!showStackFrames}
main={<div className='vbox' data-testid='source-code'>
{ fileName && <Toolbar> { fileName && <Toolbar>
<span className='source-tab-file-name'>{fileName}</span> <span className='source-tab-file-name'>{fileName}</span>
<CopyToClipboard description='Copy filename' value={getFileName(fileName, targetLine)}/> <CopyToClipboard description='Copy filename' value={getFileName(fileName)}/>
{location && <ToolbarButton icon='link-external' title='Open in VS Code' onClick={openExternally}></ToolbarButton>} {location && <ToolbarButton icon='link-external' title='Open in VS Code' onClick={openExternally}></ToolbarButton>}
</Toolbar> } </Toolbar> }
<CodeMirrorWrapper text={source.content || ''} language='javascript' highlight={highlight} revealLine={targetLine} readOnly={true} lineNumbers={true} /> <CodeMirrorWrapper text={source.content || ''} language='javascript' highlight={highlight} revealLine={targetLine} readOnly={true} lineNumbers={true} />
</div> </div>}
<StackTraceView stack={stack} selectedFrame={selectedFrame} setSelectedFrame={setSelectedFrame} /> sidebar={<StackTraceView stack={stack} selectedFrame={selectedFrame} setSelectedFrame={setSelectedFrame} />}
</SplitView>; />;
}; };
export async function calculateSha1(text: string): Promise<string> { export async function calculateSha1(text: string): Promise<string> {
@ -121,10 +124,9 @@ export async function calculateSha1(text: string): Promise<string> {
return hexCodes.join(''); return hexCodes.join('');
} }
function getFileName(fullPath?: string, lineNum?: number): string { function getFileName(fullPath?: string): string {
if (!fullPath) if (!fullPath)
return ''; return '';
const pathSep = fullPath?.includes('/') ? '/' : '\\'; const pathSep = fullPath?.includes('/') ? '/' : '\\';
const fileName = fullPath?.split(pathSep).pop() ?? ''; return fullPath?.split(pathSep).pop() ?? '';
return lineNum ? `${fileName}:${lineNum}` : fileName;
} }

View file

@ -14,11 +14,12 @@
* limitations under the License. * limitations under the License.
*/ */
import { clsx } from '@web/uiUtils';
import './tag.css'; import './tag.css';
export const TagView: React.FC<{ tag: string, style?: React.CSSProperties, onClick?: (e: React.MouseEvent) => void }> = ({ tag, style, onClick }) => { export const TagView: React.FC<{ tag: string, style?: React.CSSProperties, onClick?: (e: React.MouseEvent) => void }> = ({ tag, style, onClick }) => {
return <span return <span
className={`tag tag-color-${tagNameToColor(tag)}`} className={clsx('tag', `tag-color-${tagNameToColor(tag)}`)}
onClick={onClick} onClick={onClick}
style={{ margin: '6px 0 0 6px', ...style }} style={{ margin: '6px 0 0 6px', ...style }}
title={`Click to filter by tag: ${tag}`} title={`Click to filter by tag: ${tag}`}

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { msToString, useMeasure } from '@web/uiUtils'; import { clsx, msToString, useMeasure } from '@web/uiUtils';
import { GlassPane } from '@web/shared/glassPane'; import { GlassPane } from '@web/shared/glassPane';
import * as React from 'react'; import * as React from 'react';
import type { Boundaries } from '../geometry'; import type { Boundaries } from '../geometry';
@ -252,11 +252,12 @@ export const Timeline: React.FunctionComponent<{
<div className='timeline-bars'>{ <div className='timeline-bars'>{
bars.map((bar, index) => { bars.map((bar, index) => {
return <div key={index} return <div key={index}
className={'timeline-bar' + (bar.action ? ' action' : '') className={clsx('timeline-bar',
+ (bar.resource ? ' network' : '') bar.action && 'action',
+ (bar.consoleMessage ? ' console-message' : '') bar.resource && 'network',
+ (bar.active ? ' active' : '') bar.consoleMessage && 'console-message',
+ (bar.error ? ' error' : '')} bar.active && 'active',
bar.error && 'error')}
style={{ style={{
left: bar.leftPosition, left: bar.leftPosition,
width: Math.max(5, bar.rightPosition - bar.leftPosition), width: Math.max(5, bar.rightPosition - bar.leftPosition),

View file

@ -25,15 +25,13 @@ import type { ContextEntry } from '../entries';
import type { SourceLocation } from './modelUtil'; import type { SourceLocation } from './modelUtil';
import { idForAction, MultiTraceModel } from './modelUtil'; import { idForAction, MultiTraceModel } from './modelUtil';
import { Workbench } from './workbench'; import { Workbench } from './workbench';
import { type Setting } from '@web/uiUtils';
export const TraceView: React.FC<{ export const TraceView: React.FC<{
showRouteActionsSetting: Setting<boolean>,
item: { treeItem?: TreeItem, testFile?: SourceLocation, testCase?: reporterTypes.TestCase }, item: { treeItem?: TreeItem, testFile?: SourceLocation, testCase?: reporterTypes.TestCase },
rootDir?: string, rootDir?: string,
onOpenExternally?: (location: SourceLocation) => void, onOpenExternally?: (location: SourceLocation) => void,
revealSource?: boolean, revealSource?: boolean,
}> = ({ showRouteActionsSetting, item, rootDir, onOpenExternally, revealSource }) => { }> = ({ item, rootDir, onOpenExternally, revealSource }) => {
const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>(); const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>();
const [counter, setCounter] = React.useState(0); const [counter, setCounter] = React.useState(0);
const pollTimer = React.useRef<NodeJS.Timeout | null>(null); const pollTimer = React.useRef<NodeJS.Timeout | null>(null);
@ -91,7 +89,6 @@ export const TraceView: React.FC<{
return <Workbench return <Workbench
key='workbench' key='workbench'
showRouteActionsSetting={showRouteActionsSetting}
model={model?.model} model={model?.model}
showSourcesFirst={true} showSourcesFirst={true}
rootDir={rootDir} rootDir={rootDir}
@ -100,6 +97,7 @@ export const TraceView: React.FC<{
fallbackLocation={item.testFile} fallbackLocation={item.testFile}
isLive={model?.isLive} isLive={model?.isLive}
status={item.treeItem?.status} status={item.treeItem?.status}
annotations={item.testCase?.annotations || []}
onOpenExternally={onOpenExternally} onOpenExternally={onOpenExternally}
revealSource={revealSource} revealSource={revealSource}
/>; />;

View file

@ -24,7 +24,7 @@
} }
.ui-mode-sidebar > .settings-view { .ui-mode-sidebar > .settings-view {
margin: 0 0 3px 23px; margin: 0 0 8px 23px;
} }
.ui-mode-sidebar input[type=search] { .ui-mode-sidebar input[type=search] {

View file

@ -30,7 +30,7 @@ import { Toolbar } from '@web/components/toolbar';
import type { XtermDataSource } from '@web/components/xtermWrapper'; import type { XtermDataSource } from '@web/components/xtermWrapper';
import { XtermWrapper } from '@web/components/xtermWrapper'; import { XtermWrapper } from '@web/components/xtermWrapper';
import { useDarkModeSetting } from '@web/theme'; 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 { statusEx, TestTree } from '@testIsomorphic/testTree';
import type { TreeItem } from '@testIsomorphic/testTree'; import type { TreeItem } from '@testIsomorphic/testTree';
import { TestServerConnection } from '@testIsomorphic/testServerConnection'; import { TestServerConnection } from '@testIsomorphic/testServerConnection';
@ -94,10 +94,12 @@ export const UIModeView: React.FC<{}> = ({
const [isDisconnected, setIsDisconnected] = React.useState(false); const [isDisconnected, setIsDisconnected] = React.useState(false);
const [hasBrowsers, setHasBrowsers] = React.useState(true); const [hasBrowsers, setHasBrowsers] = React.useState(true);
const [testServerConnection, setTestServerConnection] = React.useState<TestServerConnection>(); const [testServerConnection, setTestServerConnection] = React.useState<TestServerConnection>();
const [teleSuiteUpdater, setTeleSuiteUpdater] = React.useState<TeleSuiteUpdater>();
const [settingsVisible, setSettingsVisible] = React.useState(false); const [settingsVisible, setSettingsVisible] = React.useState(false);
const [testingOptionsVisible, setTestingOptionsVisible] = React.useState(false); const [testingOptionsVisible, setTestingOptionsVisible] = React.useState(false);
const [revealSource, setRevealSource] = React.useState(false); const [revealSource, setRevealSource] = React.useState(false);
const onRevealSource = React.useCallback(() => setRevealSource(true), [setRevealSource]); const onRevealSource = React.useCallback(() => setRevealSource(true), [setRevealSource]);
const showTestingOptions = false;
const [runWorkers, setRunWorkers] = React.useState(queryParams.workers); const [runWorkers, setRunWorkers] = React.useState(queryParams.workers);
const singleWorkerSetting = React.useMemo(() => { const singleWorkerSetting = React.useMemo(() => {
@ -191,20 +193,7 @@ export const UIModeView: React.FC<{}> = ({
pathSeparator, pathSeparator,
}); });
const updateList = async () => { setTeleSuiteUpdater(teleSuiteUpdater);
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);
}
});
};
setTestModel(undefined); setTestModel(undefined);
setIsLoading(true); 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 }); const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert });
teleSuiteUpdater.processListReport(result.report); teleSuiteUpdater.processListReport(result.report);
testServerConnection.onListChanged(updateList);
testServerConnection.onReport(params => { testServerConnection.onReport(params => {
teleSuiteUpdater.processTestReportEvent(params); teleSuiteUpdater.processTestReportEvent(params);
}); });
@ -336,11 +324,32 @@ export const UIModeView: React.FC<{}> = ({
}); });
}, [projectFilters, runningState, testModel, testServerConnection, runWorkers, runHeaded, runUpdateSnapshots]); }, [projectFilters, runningState, testModel, testServerConnection, runWorkers, runHeaded, runUpdateSnapshots]);
// Watch implementation.
React.useEffect(() => { React.useEffect(() => {
if (!testServerConnection) if (!testServerConnection || !teleSuiteUpdater)
return; 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 testIds: string[] = [];
const set = new Set(params.testFiles); const set = new Set(params.testFiles);
if (watchAll) { if (watchAll) {
@ -363,7 +372,7 @@ export const UIModeView: React.FC<{}> = ({
runTests('queue-if-busy', new Set(testIds)); runTests('queue-if-busy', new Set(testIds));
}); });
return () => disposable.dispose(); return () => disposable.dispose();
}, [runTests, testServerConnection, testTree, watchAll, watchedTreeIds]); }, [runTests, testServerConnection, watchAll, watchedTreeIds, teleSuiteUpdater, projectFilters]);
// Shortcuts. // Shortcuts.
React.useEffect(() => { React.useEffect(() => {
@ -425,9 +434,14 @@ export const UIModeView: React.FC<{}> = ({
<div className='title'>UI Mode disconnected</div> <div className='title'>UI Mode disconnected</div>
<div><a href='#' onClick={() => window.location.href = '/'}>Reload the page</a> to reconnect</div> <div><a href='#' onClick={() => window.location.href = '/'}>Reload the page</a> to reconnect</div>
</div>} </div>}
<SplitView sidebarSize={250} minSidebarSize={150} orientation='horizontal' sidebarIsFirst={true} settingName='testListSidebar'> <SplitView
<div className='vbox'> sidebarSize={250}
<div className={'vbox' + (isShowingOutput ? '' : ' hidden')}> minSidebarSize={150}
orientation='horizontal'
sidebarIsFirst={true}
settingName='testListSidebar'
main={<div className='vbox'>
<div className={clsx('vbox', !isShowingOutput && 'hidden')}>
<Toolbar> <Toolbar>
<div className='section-title' style={{ flex: 'none' }}>Output</div> <div className='section-title' style={{ flex: 'none' }}>Output</div>
<ToolbarButton icon='circle-slash' title='Clear output' onClick={() => xtermDataSource.clear()}></ToolbarButton> <ToolbarButton icon='circle-slash' title='Clear output' onClick={() => xtermDataSource.clear()}></ToolbarButton>
@ -436,17 +450,16 @@ export const UIModeView: React.FC<{}> = ({
</Toolbar> </Toolbar>
<XtermWrapper source={xtermDataSource}></XtermWrapper> <XtermWrapper source={xtermDataSource}></XtermWrapper>
</div> </div>
<div className={'vbox' + (isShowingOutput ? ' hidden' : '')}> <div className={clsx('vbox', isShowingOutput && 'hidden')}>
<TraceView <TraceView
showRouteActionsSetting={showRouteActionsSetting}
item={selectedItem} item={selectedItem}
rootDir={testModel?.config?.rootDir} rootDir={testModel?.config?.rootDir}
revealSource={revealSource} revealSource={revealSource}
onOpenExternally={location => testServerConnection?.openNoReply({ location: { file: location.file, line: location.line, column: location.column } })} onOpenExternally={location => testServerConnection?.openNoReply({ location: { file: location.file, line: location.line, column: location.column } })}
/> />
</div> </div>
</div> </div>}
<div className='vbox ui-mode-sidebar'> sidebar={<div className='vbox ui-mode-sidebar'>
<Toolbar noShadow={true} noMinHeight={true}> <Toolbar noShadow={true} noMinHeight={true}>
<img src='playwright-logo.svg' alt='Playwright logo' /> <img src='playwright-logo.svg' alt='Playwright logo' />
<div className='section-title'>Playwright</div> <div className='section-title'>Playwright</div>
@ -497,6 +510,7 @@ export const UIModeView: React.FC<{}> = ({
setFilterText={setFilterText} setFilterText={setFilterText}
onRevealSource={onRevealSource} onRevealSource={onRevealSource}
/> />
{showTestingOptions && <>
<Toolbar noShadow={true} noMinHeight={true} className='settings-toolbar' onClick={() => setTestingOptionsVisible(!testingOptionsVisible)}> <Toolbar noShadow={true} noMinHeight={true} className='settings-toolbar' onClick={() => setTestingOptionsVisible(!testingOptionsVisible)}>
<span <span
className={`codicon codicon-${testingOptionsVisible ? 'chevron-down' : 'chevron-right'}`} className={`codicon codicon-${testingOptionsVisible ? 'chevron-down' : 'chevron-right'}`}
@ -510,6 +524,7 @@ export const UIModeView: React.FC<{}> = ({
showBrowserSetting, showBrowserSetting,
updateSnapshotsSetting, updateSnapshotsSetting,
]} />} ]} />}
</>}
<Toolbar noShadow={true} noMinHeight={true} className='settings-toolbar' onClick={() => setSettingsVisible(!settingsVisible)}> <Toolbar noShadow={true} noMinHeight={true} className='settings-toolbar' onClick={() => setSettingsVisible(!settingsVisible)}>
<span <span
className={`codicon codicon-${settingsVisible ? 'chevron-down' : 'chevron-right'}`} className={`codicon codicon-${settingsVisible ? 'chevron-down' : 'chevron-right'}`}
@ -523,6 +538,7 @@ export const UIModeView: React.FC<{}> = ({
showRouteActionsSetting, showRouteActionsSetting,
]} />} ]} />}
</div> </div>
</SplitView> }
/>
</div>; </div>;
}; };

View file

@ -23,7 +23,7 @@ import { ErrorsTab, useErrorsTabModel } from './errorsTab';
import type { ConsoleEntry } from './consoleTab'; import type { ConsoleEntry } from './consoleTab';
import { ConsoleTab, useConsoleTabModel } from './consoleTab'; import { ConsoleTab, useConsoleTabModel } from './consoleTab';
import type * as modelUtil from './modelUtil'; import type * as modelUtil from './modelUtil';
import type { ActionTraceEventInContext, MultiTraceModel } from './modelUtil'; import { isRouteAction } from './modelUtil';
import type { StackFrame } from '@protocol/channels'; import type { StackFrame } from '@protocol/channels';
import { NetworkTab, useNetworkTabModel } from './networkTab'; import { NetworkTab, useNetworkTabModel } from './networkTab';
import { SnapshotTab } from './snapshotTab'; import { SnapshotTab } from './snapshotTab';
@ -33,10 +33,11 @@ import type { TabbedPaneTabModel } from '@web/components/tabbedPane';
import { Timeline } from './timeline'; import { Timeline } from './timeline';
import { MetadataView } from './metadataView'; import { MetadataView } from './metadataView';
import { AttachmentsTab } from './attachmentsTab'; import { AttachmentsTab } from './attachmentsTab';
import { AnnotationsTab } from './annotationsTab';
import type { Boundaries } from '../geometry'; import type { Boundaries } from '../geometry';
import { InspectorTab } from './inspectorTab'; import { InspectorTab } from './inspectorTab';
import { ToolbarButton } from '@web/components/toolbarButton'; 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 type { Entry } from '@trace/har';
import './workbench.css'; import './workbench.css';
import { testStatusIcon, testStatusText } from './testUtils'; import { testStatusIcon, testStatusText } from './testUtils';
@ -44,23 +45,24 @@ import type { UITestStatus } from './testUtils';
import { SettingsView } from './settingsView'; import { SettingsView } from './settingsView';
export const Workbench: React.FunctionComponent<{ export const Workbench: React.FunctionComponent<{
model?: MultiTraceModel, model?: modelUtil.MultiTraceModel,
showSourcesFirst?: boolean, showSourcesFirst?: boolean,
rootDir?: string, rootDir?: string,
fallbackLocation?: modelUtil.SourceLocation, fallbackLocation?: modelUtil.SourceLocation,
initialSelection?: ActionTraceEventInContext, initialSelection?: modelUtil.ActionTraceEventInContext,
onSelectionChanged?: (action: ActionTraceEventInContext) => void, onSelectionChanged?: (action: modelUtil.ActionTraceEventInContext) => void,
isLive?: boolean, isLive?: boolean,
status?: UITestStatus, status?: UITestStatus,
annotations?: { type: string; description?: string; }[];
inert?: boolean, inert?: boolean,
showRouteActionsSetting?: Setting<boolean>,
openPage?: (url: string, target?: string) => Window | any, openPage?: (url: string, target?: string) => Window | any,
onOpenExternally?: (location: modelUtil.SourceLocation) => void, onOpenExternally?: (location: modelUtil.SourceLocation) => void,
revealSource?: boolean, revealSource?: boolean,
}> = ({ showRouteActionsSetting, model, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, status, inert, openPage, onOpenExternally, revealSource }) => { showSettings?: boolean,
const [selectedAction, setSelectedActionImpl] = React.useState<ActionTraceEventInContext | undefined>(undefined); }> = ({ model, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => {
const [selectedAction, setSelectedActionImpl] = React.useState<modelUtil.ActionTraceEventInContext | undefined>(undefined);
const [revealedStack, setRevealedStack] = React.useState<StackFrame[] | undefined>(undefined); const [revealedStack, setRevealedStack] = React.useState<StackFrame[] | undefined>(undefined);
const [highlightedAction, setHighlightedAction] = React.useState<ActionTraceEventInContext | undefined>(); const [highlightedAction, setHighlightedAction] = React.useState<modelUtil.ActionTraceEventInContext | undefined>();
const [highlightedEntry, setHighlightedEntry] = React.useState<Entry | undefined>(); const [highlightedEntry, setHighlightedEntry] = React.useState<Entry | undefined>();
const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState<ConsoleEntry | undefined>(); const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState<ConsoleEntry | undefined>();
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions'); const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions');
@ -70,17 +72,13 @@ export const Workbench: React.FunctionComponent<{
const activeAction = model ? highlightedAction || selectedAction : undefined; const activeAction = model ? highlightedAction || selectedAction : undefined;
const [selectedTime, setSelectedTime] = React.useState<Boundaries | undefined>(); const [selectedTime, setSelectedTime] = React.useState<Boundaries | undefined>();
const [sidebarLocation, setSidebarLocation] = useSetting<'bottom' | 'right'>('propertiesSidebarLocation', 'bottom'); const [sidebarLocation, setSidebarLocation] = useSetting<'bottom' | 'right'>('propertiesSidebarLocation', 'bottom');
const [, , showRouteActionsSettingInternal] = useSetting(showRouteActionsSetting ? undefined : 'show-route-actions', true, 'Show route actions'); const [showRouteActions, , showRouteActionsSetting] = useSetting('show-route-actions', true, 'Show route actions');
const showSettings = !showRouteActionsSetting;
showRouteActionsSetting ||= showRouteActionsSettingInternal;
const showRouteActions = showRouteActionsSetting[0];
const filteredActions = React.useMemo(() => { const filteredActions = React.useMemo(() => {
return (model?.actions || []).filter(action => showRouteActions || action.class !== 'Route'); return (model?.actions || []).filter(action => showRouteActions || !isRouteAction(action));
}, [model, showRouteActions]); }, [model, showRouteActions]);
const setSelectedAction = React.useCallback((action: ActionTraceEventInContext | undefined) => { const setSelectedAction = React.useCallback((action: modelUtil.ActionTraceEventInContext | undefined) => {
setSelectedActionImpl(action); setSelectedActionImpl(action);
setRevealedStack(action?.stack); setRevealedStack(action?.stack);
}, [setSelectedActionImpl, setRevealedStack]); }, [setSelectedActionImpl, setRevealedStack]);
@ -113,7 +111,7 @@ export const Workbench: React.FunctionComponent<{
} }
}, [model, selectedAction, setSelectedAction, initialSelection]); }, [model, selectedAction, setSelectedAction, initialSelection]);
const onActionSelected = React.useCallback((action: ActionTraceEventInContext) => { const onActionSelected = React.useCallback((action: modelUtil.ActionTraceEventInContext) => {
setSelectedAction(action); setSelectedAction(action);
onSelectionChanged?.(action); onSelectionChanged?.(action);
}, [setSelectedAction, onSelectionChanged]); }, [setSelectedAction, onSelectionChanged]);
@ -227,6 +225,17 @@ export const Workbench: React.FunctionComponent<{
sourceTab, sourceTab,
attachmentsTab, attachmentsTab,
]; ];
if (annotations !== undefined) {
const annotationsTab: TabbedPaneTabModel = {
id: 'annotations',
title: 'Annotations',
count: annotations.length,
render: () => <AnnotationsTab annotations={annotations} />
};
tabs.push(annotationsTab);
}
if (showSourcesFirst) { if (showSourcesFirst) {
const sourceTabIndex = tabs.indexOf(sourceTab); const sourceTabIndex = tabs.indexOf(sourceTab);
tabs.splice(sourceTabIndex, 1); tabs.splice(sourceTabIndex, 1);
@ -255,7 +264,7 @@ export const Workbench: React.FunctionComponent<{
title: 'Actions', title: 'Actions',
component: <div className='vbox'> component: <div className='vbox'>
{status && <div className='workbench-run-status'> {status && <div className='workbench-run-status'>
<span className={`codicon ${testStatusIcon(status)}`}></span> <span className={clsx('codicon', testStatusIcon(status))}></span>
<div>{testStatusText(status)}</div> <div>{testStatusText(status)}</div>
<div className='spacer'></div> <div className='spacer'></div>
<div className='workbench-run-duration'>{time ? msToString(time) : ''}</div> <div className='workbench-run-duration'>{time ? msToString(time) : ''}</div>
@ -297,9 +306,15 @@ export const Workbench: React.FunctionComponent<{
selectedTime={selectedTime} selectedTime={selectedTime}
setSelectedTime={setSelectedTime} setSelectedTime={setSelectedTime}
/> />
<SplitView sidebarSize={250} orientation={sidebarLocation === 'bottom' ? 'vertical' : 'horizontal'} settingName='propertiesSidebar'> <SplitView
<SplitView sidebarSize={250} orientation='horizontal' sidebarIsFirst={true} settingName='actionListSidebar'> sidebarSize={250}
<SnapshotTab orientation={sidebarLocation === 'bottom' ? 'vertical' : 'horizontal'} settingName='propertiesSidebar'
main={<SplitView
sidebarSize={250}
orientation='horizontal'
sidebarIsFirst
settingName='actionListSidebar'
main={<SnapshotTab
action={activeAction} action={activeAction}
sdkLanguage={sdkLanguage} sdkLanguage={sdkLanguage}
testIdAttributeName={model?.testIdAttributeName || 'data-testid'} testIdAttributeName={model?.testIdAttributeName || 'data-testid'}
@ -307,14 +322,16 @@ export const Workbench: React.FunctionComponent<{
setIsInspecting={setIsInspecting} setIsInspecting={setIsInspecting}
highlightedLocator={highlightedLocator} highlightedLocator={highlightedLocator}
setHighlightedLocator={locatorPicked} setHighlightedLocator={locatorPicked}
openPage={openPage} /> openPage={openPage} />}
sidebar={
<TabbedPane <TabbedPane
tabs={showSettings ? [actionsTab, metadataTab, settingsTab] : [actionsTab, metadataTab]} tabs={showSettings ? [actionsTab, metadataTab, settingsTab] : [actionsTab, metadataTab]}
selectedTab={selectedNavigatorTab} selectedTab={selectedNavigatorTab}
setSelectedTab={setSelectedNavigatorTab} setSelectedTab={setSelectedNavigatorTab}
/> />
</SplitView> }
<TabbedPane />}
sidebar={<TabbedPane
tabs={tabs} tabs={tabs}
selectedTab={selectedPropertiesTab} selectedTab={selectedPropertiesTab}
setSelectedTab={selectPropertiesTab} setSelectedTab={selectPropertiesTab}
@ -328,7 +345,7 @@ export const Workbench: React.FunctionComponent<{
}} /> }} />
]} ]}
mode={sidebarLocation === 'bottom' ? 'default' : 'select'} mode={sidebarLocation === 'bottom' ? 'default' : 'select'}
/>}
/> />
</SplitView>
</div>; </div>;
}; };

View file

@ -165,7 +165,7 @@ export const WorkbenchLoader: React.FunctionComponent<{
<div className='progress'> <div className='progress'>
<div className='inner-progress' style={{ width: progress.total ? (100 * progress.done / progress.total) + '%' : 0 }}></div> <div className='inner-progress' style={{ width: progress.total ? (100 * progress.done / progress.total) + '%' : 0 }}></div>
</div> </div>
<Workbench model={model} inert={showFileUploadDropArea} /> <Workbench model={model} inert={showFileUploadDropArea} showSettings />
{fileForLocalModeError && <div className='drop-target'> {fileForLocalModeError && <div className='drop-target'>
<div>Trace Viewer uses Service Workers to show traces. To view trace:</div> <div>Trace Viewer uses Service Workers to show traces. To view trace:</div>
<div style={{ paddingTop: 20 }}> <div style={{ paddingTop: 20 }}>

View file

@ -14,7 +14,6 @@
* limitations under the License. * limitations under the License.
*/ */
import React from 'react';
import '@web/common.css'; import '@web/common.css';
import { applyTheme } from '@web/theme'; import { applyTheme } from '@web/theme';
import '@web/third_party/vscode/codicon.css'; import '@web/third_party/vscode/codicon.css';

View file

@ -0,0 +1,9 @@
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M136.444 221.556C123.558 225.213 115.104 231.625 109.535 238.032C114.869 233.364 122.014 229.08 131.652 226.348C141.51 223.554 149.92 223.574 156.869 224.915V219.481C150.941 218.939 144.145 219.371 136.444 221.556ZM108.946 175.876L61.0895 188.484C61.0895 188.484 61.9617 189.716 63.5767 191.36L104.153 180.668C104.153 180.668 103.578 188.077 98.5847 194.705C108.03 187.559 108.946 175.876 108.946 175.876ZM149.005 288.347C81.6582 306.486 46.0272 228.438 35.2396 187.928C30.2556 169.229 28.0799 155.067 27.5 145.928C27.4377 144.979 27.4665 144.179 27.5336 143.446C24.04 143.657 22.3674 145.473 22.7077 150.721C23.2876 159.855 25.4633 174.016 30.4473 192.721C41.2301 233.225 76.8659 311.273 144.213 293.134C158.872 289.185 169.885 281.992 178.152 272.81C170.532 279.692 160.995 285.112 149.005 288.347ZM161.661 128.11V132.903H188.077C187.535 131.206 186.989 129.677 186.447 128.11H161.661Z" fill="#2D4552"/>
<path d="M193.981 167.584C205.861 170.958 212.144 179.287 215.465 186.658L228.711 190.42C228.711 190.42 226.904 164.623 203.57 157.995C181.741 151.793 168.308 170.124 166.674 172.496C173.024 167.972 182.297 164.268 193.981 167.584ZM299.422 186.777C277.573 180.547 264.145 198.916 262.535 201.255C268.89 196.736 278.158 193.031 289.837 196.362C301.698 199.741 307.976 208.06 311.307 215.436L324.572 219.212C324.572 219.212 322.736 193.41 299.422 186.777ZM286.262 254.795L176.072 223.99C176.072 223.99 177.265 230.038 181.842 237.869L274.617 263.805C282.255 259.386 286.262 254.795 286.262 254.795ZM209.867 321.102C122.618 297.71 133.166 186.543 147.284 133.865C153.097 112.156 159.073 96.0203 164.029 85.204C161.072 84.5953 158.623 86.1529 156.203 91.0746C150.941 101.747 144.212 119.124 137.7 143.45C123.586 196.127 113.038 307.29 200.283 330.682C241.406 341.699 273.442 324.955 297.323 298.659C274.655 319.19 245.714 330.701 209.867 321.102Z" fill="#2D4552"/>
<path d="M161.661 262.296V239.863L99.3324 257.537C99.3324 257.537 103.938 230.777 136.444 221.556C146.302 218.762 154.713 218.781 161.661 220.123V128.11H192.869C189.471 117.61 186.184 109.526 183.423 103.909C178.856 94.612 174.174 100.775 163.545 109.665C156.059 115.919 137.139 129.261 108.668 136.933C80.1966 144.61 57.179 142.574 47.5752 140.911C33.9601 138.562 26.8387 135.572 27.5049 145.928C28.0847 155.062 30.2605 169.224 35.2445 187.928C46.0272 228.433 81.663 306.481 149.01 288.342C166.602 283.602 179.019 274.233 187.626 262.291H161.661V262.296ZM61.0848 188.484L108.946 175.876C108.946 175.876 107.551 194.288 89.6087 199.018C71.6614 203.743 61.0848 188.484 61.0848 188.484Z" fill="#E2574C"/>
<path d="M341.786 129.174C329.345 131.355 299.498 134.072 262.612 124.185C225.716 114.304 201.236 97.0224 191.537 88.8994C177.788 77.3834 171.74 69.3802 165.788 81.4857C160.526 92.163 153.797 109.54 147.284 133.866C133.171 186.543 122.623 297.706 209.867 321.098C297.093 344.47 343.53 242.92 357.644 190.238C364.157 165.917 367.013 147.5 367.799 135.625C368.695 122.173 359.455 126.078 341.786 129.174ZM166.497 172.756C166.497 172.756 180.246 151.372 203.565 158C226.899 164.628 228.706 190.425 228.706 190.425L166.497 172.756ZM223.42 268.713C182.403 256.698 176.077 223.99 176.077 223.99L286.262 254.796C286.262 254.791 264.021 280.578 223.42 268.713ZM262.377 201.495C262.377 201.495 276.107 180.126 299.422 186.773C322.736 193.411 324.572 219.208 324.572 219.208L262.377 201.495Z" fill="#2EAD33"/>
<path d="M139.88 246.04L99.3324 257.532C99.3324 257.532 103.737 232.44 133.607 222.496L110.647 136.33L108.663 136.933C80.1918 144.611 57.1742 142.574 47.5704 140.911C33.9554 138.563 26.834 135.572 27.5001 145.929C28.08 155.063 30.2557 169.224 35.2397 187.929C46.0225 228.433 81.6583 306.481 149.005 288.342L150.989 287.719L139.88 246.04ZM61.0848 188.485L108.946 175.876C108.946 175.876 107.551 194.288 89.6087 199.018C71.6615 203.743 61.0848 188.485 61.0848 188.485Z" fill="#D65348"/>
<path d="M225.27 269.163L223.415 268.712C182.398 256.698 176.072 223.99 176.072 223.99L232.89 239.872L262.971 124.281L262.607 124.185C225.711 114.304 201.232 97.0224 191.532 88.8994C177.783 77.3834 171.735 69.3802 165.783 81.4857C160.526 92.163 153.797 109.54 147.284 133.866C133.171 186.543 122.623 297.706 209.867 321.097L211.655 321.5L225.27 269.163ZM166.497 172.756C166.497 172.756 180.246 151.372 203.565 158C226.899 164.628 228.706 190.425 228.706 190.425L166.497 172.756Z" fill="#1D8D22"/>
<path d="M141.946 245.451L131.072 248.537C133.641 263.019 138.169 276.917 145.276 289.195C146.513 288.922 147.74 288.687 149 288.342C152.302 287.451 155.364 286.348 158.312 285.145C150.371 273.361 145.118 259.789 141.946 245.451ZM137.7 143.451C132.112 164.307 127.113 194.326 128.489 224.436C130.952 223.367 133.554 222.371 136.444 221.551L138.457 221.101C136.003 188.939 141.308 156.165 147.284 133.866C148.799 128.225 150.318 122.978 151.832 118.085C149.393 119.637 146.767 121.228 143.776 122.867C141.759 129.093 139.722 135.898 137.7 143.451Z" fill="#C04B41"/>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View file

@ -23,6 +23,8 @@ import 'codemirror-shadow-1/mode/htmlmixed/htmlmixed';
import 'codemirror-shadow-1/mode/javascript/javascript'; import 'codemirror-shadow-1/mode/javascript/javascript';
import 'codemirror-shadow-1/mode/python/python'; import 'codemirror-shadow-1/mode/python/python';
import 'codemirror-shadow-1/mode/clike/clike'; 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 type CodeMirror = typeof codemirrorType;
export default codemirror; export default codemirror;

View file

@ -174,3 +174,9 @@ body.dark-mode .CodeMirror span.cm-type {
margin: 3px 10px; margin: 3px 10px;
padding: 5px; padding: 5px;
} }
.CodeMirror span.cm-link, span.cm-linkified {
color: var(--vscode-textLink-foreground);
text-decoration: underline;
cursor: pointer;
}

View file

@ -14,7 +14,6 @@
* limitations under the License. * limitations under the License.
*/ */
import React from 'react';
import { expect, test } from '@playwright/experimental-ct-react'; import { expect, test } from '@playwright/experimental-ct-react';
import { CodeMirrorWrapper } from './codeMirrorWrapper'; import { CodeMirrorWrapper } from './codeMirrorWrapper';

View file

@ -18,7 +18,7 @@ import './codeMirrorWrapper.css';
import * as React from 'react'; import * as React from 'react';
import type { CodeMirror } from './codeMirrorModule'; import type { CodeMirror } from './codeMirrorModule';
import { ansi2html } from '../ansi2html'; import { ansi2html } from '../ansi2html';
import { useMeasure } from '../uiUtils'; import { useMeasure, kWebLinkRe } from '../uiUtils';
export type SourceHighlight = { export type SourceHighlight = {
line: number; line: number;
@ -26,11 +26,13 @@ export type SourceHighlight = {
message?: string; 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 { export interface SourceProps {
text: string; text: string;
language?: Language; language?: Language;
mimeType?: string;
linkify?: boolean;
readOnly?: boolean; readOnly?: boolean;
// 1-based // 1-based
highlight?: SourceHighlight[]; highlight?: SourceHighlight[];
@ -45,6 +47,8 @@ export interface SourceProps {
export const CodeMirrorWrapper: React.FC<SourceProps> = ({ export const CodeMirrorWrapper: React.FC<SourceProps> = ({
text, text,
language, language,
mimeType,
linkify,
readOnly, readOnly,
highlight, highlight,
revealLine, revealLine,
@ -63,24 +67,13 @@ export const CodeMirrorWrapper: React.FC<SourceProps> = ({
(async () => { (async () => {
// Always load the module first. // Always load the module first.
const CodeMirror = await modulePromise; const CodeMirror = await modulePromise;
defineCustomMode(CodeMirror);
const element = codemirrorElement.current; const element = codemirrorElement.current;
if (!element) if (!element)
return; return;
let mode = ''; const mode = languageToMode(language) || mimeTypeToMode(mimeType) || (linkify ? 'text/linkified' : '');
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';
if (codemirrorRef.current if (codemirrorRef.current
&& mode === codemirrorRef.current.cm.getOption('mode') && mode === codemirrorRef.current.cm.getOption('mode')
@ -106,7 +99,7 @@ export const CodeMirrorWrapper: React.FC<SourceProps> = ({
setCodemirror(cm); setCodemirror(cm);
return cm; return cm;
})(); })();
}, [modulePromise, codemirror, codemirrorElement, language, lineNumbers, wrapLines, readOnly, isFocused]); }, [modulePromise, codemirror, codemirrorElement, language, mimeType, linkify, lineNumbers, wrapLines, readOnly, isFocused]);
React.useEffect(() => { React.useEffect(() => {
if (codemirrorRef.current) if (codemirrorRef.current)
@ -175,5 +168,69 @@ export const CodeMirrorWrapper: React.FC<SourceProps> = ({
}; };
}, [codemirror, text, highlight, revealLine, focusOnChange, onChange]); }, [codemirror, text, highlight, revealLine, focusOnChange, onChange]);
return <div className='cm-wrapper' ref={codemirrorElement}></div>; return <div className='cm-wrapper' ref={codemirrorElement} onClick={onCodeMirrorClick}></div>;
}; };
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];
}

View file

@ -14,7 +14,6 @@
* limitations under the License. * limitations under the License.
*/ */
import React from 'react';
import { expect, test } from '@playwright/experimental-ct-react'; import { expect, test } from '@playwright/experimental-ct-react';
import { Expandable } from './expandable'; import { Expandable } from './expandable';

Some files were not shown because too many files have changed in this diff Show more