Compare commits

...

36 commits

Author SHA1 Message Date
Max Schmitt e1c861cfa7 cherry-pick(#32021): test: fix failing client-certificate tests 2024-08-16 21:45:12 +02:00
Max Schmitt 20b0788101
chore: mark v1.46.1 (#32194) 2024-08-16 20:24:42 +02:00
Max Schmitt 57c324002a cherry-pick(#32192): chore: generate self-signed certificates for socks proxy 2024-08-16 20:22:14 +02:00
Playwright Service 301f179735
cherry-pick(#32189): fix(only-changed): show nice error message about shallow clones (#32190)
This PR cherry-picks the following commits:

- 06ffdd61c9

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2024-08-16 11:46:29 +02:00
Playwright Service b2d6a0916e
cherry-pick(#32164): docs: release video and trace viewer video (#32173)
This PR cherry-picks the following commits:

- f927495

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2024-08-15 13:16:32 +02:00
Simon Knott 7cf7aec97f
cherry-pick(#32094): fix(test runner): run project dependencies of --only-changed test files (#32172)
Closes https://github.com/microsoft/playwright/issues/32070. We were
applying `additionalFileMatcher` not just to `filteredProjectSuites`,
but also to `projectSuites`. `projectSuites` is where we take dependency
projects from, though - so `--only-changed` led to empty dependency
projects, resulting in the reported bug.
    
The fix is to only apply `additionalFileMatcher` on
`filteredProjectSuites`.
2024-08-15 13:10:41 +02:00
Max Schmitt d78ae0179d cherry-pick(#32163): fix(client-certificates): stall on tls handshake errors
Extracted from https://github.com/microsoft/playwright/pull/32158.
2024-08-15 10:26:04 +02:00
Max Schmitt bd13da4132 cherry-pick(#32155): fix(client-certificates): when server does tls renegotiation
Certain https servers like Microsoft IIS aka. TLS servers do the TLS
renegotiation after the TLS handshake. This ends up in two
`'secureConnect'` events due to an upstream Node.js bug:
https://github.com/nodejs/node/issues/54362

Drive-by: Move other listeners like `'close'` / `'end'` to `once()` as
well.

Relates https://github.com/microsoft/playwright/issues/32004
2024-08-14 20:33:02 +02:00
Dmitry Gozman 30684a77e7
cherry-pick(#32066): fix(types): revert type changes made to support TS 5.5 (#32080)
Regressed in #31532. The TS5.5 changes broke chaining of `extend`s where
the first `extend` did not specify any type arguments.

Fixes #32056.
2024-08-08 09:11:44 -07:00
Max Schmitt 5e68061d49 cherry-pick(#32015): docs(release-notes): fix typo in .NET release notes 2024-08-06 07:20:54 +02:00
Dmitry Gozman 99a3631057
chore: mark v1.46 (#32013) 2024-08-05 06:35:35 -07:00
Max Schmitt 929fef348e cherry-pick(#32012): chore(lint): bump Microsoft.CodeAnalysis for linting code snippets 2024-08-05 14:55:42 +02:00
Max Schmitt cf31aa8b4c cherry-pick(#32008): chore(client-certificates): rewrite error for unsupported PFX errors 2024-08-05 14:44:12 +02:00
Max Schmitt ed9b4d9b9a cherry-pick(#32007): fix(client-certificates): report error to the browser if incorrect passphrase 2024-08-05 14:43:58 +02:00
Max Schmitt fca1fa0b95 cherry-pick(#31973): chore: run client-certificate tests in service mode 2024-08-05 14:43:44 +02:00
Dmitry Gozman ff11273c7b cherry-pick(#32010): docs: release notes for 1.46 update 2024-08-05 13:36:33 +01:00
Yury Semikhatsky 4953ac3072
cherry-pick(#31975): chore: remove bright counter from sidebar tab se… (#31976)
…lector

Removing the following icon:


![image](https://github.com/user-attachments/assets/d2de2ed0-f66e-4452-8763-aad1b6e7bb79)

HTML `options` element cannot be styled, so just removing the counter in
sidebar mode:

<img width="348" alt="image"

src="https://github.com/user-attachments/assets/d636dca2-5007-41f7-866e-3a0f604d46fc">
2024-08-02 10:32:48 -07:00
Yury Semikhatsky 4c66f8aeda
cherry-pick(#31970): fix(trace): do not place expect into unfinished … (#31974)
…api calls based on time

Fixes https://github.com/microsoft/playwright/issues/31959
2024-08-01 14:47:37 -07:00
Dmitry Gozman deba37b6b5
cherry-pick(#31960): feat(ui mode): linkify attachment names and content (#31971)
- Pass `contentType` to the CodeMirror.
- Support `text/markdown` mode.
- Custom mode for non-supported types that linkifies urls.
2024-08-01 13:24:14 -07:00
Max Schmitt 2cfe733e30 cherry-pick(#31961): fix(trace-viewer): make 'hide route actions' work for .NET 2024-08-01 21:03:45 +02:00
Dmitry Gozman 5fdf97658e
cherry-pick(#31945): feat(ui-mode): add annotations tab (#31968)
<img width="867" alt="image"

src="https://github.com/user-attachments/assets/7d714723-1d3f-49b2-944a-0a476d79aee8">
2024-08-01 11:53:04 -07:00
Dmitry Gozman 29ba72c06b
cherry-pick(#31952): fix(ui mode): api review feedback (#31964)
- Hide "Testing Options" as not ready.
- Update SettingsView margins.
- Include `page.route` and similar methods into "Show route actions".
2024-08-01 11:23:07 -07:00
Simon Knott b20e154902
cherry-pick(#31958): fix(ui): only populate settings once (#31963)
Cherry-picks https://github.com/microsoft/playwright/pull/31958 into the
release.
2024-08-01 09:28:40 -07:00
Max Schmitt 876e0e4ba9 cherry-pick(#31955): feat(chromium): roll to r1129 2024-08-01 14:33:35 +02:00
Dmitry Gozman 3ab19c6229 cherry-pick(#31928): fix(trace): make sure the correct attachment name is used for downloads
When two attachments have the same content sha1, we used the first one's
name for the downloaded file, no matter which one the user clicked to
download. Now we pass the name explicitly.

References #31912.
2024-08-01 06:44:55 +01:00
Dmitry Gozman 8d35c1b517 cherry-pick(#31920): fix(trace viewer): attachment download
- Update attachments tab margins.
- Make sure to pass `&download` in attachment urls. This makes them
downloadable, regressed in #28727.
- Do not additionally list image diffs as screenshots.

Fixes #31912.
2024-08-01 06:44:22 +01:00
Yury Semikhatsky 71b8e22501
cherry-pick(#31939): chore(trace-viewer): copy only file name without… (#31942)
… line number

As discussed in the meeting, copy only file name which is shown in the
same line, do not include highlighted line number.
2024-07-31 17:59:39 -07:00
Yury Semikhatsky 71e5eade8c
cherry-pick(#31940): chore(trace-viewer): less bright status code icon (#31944)
<img width="182" alt="image"

src="https://github.com/user-attachments/assets/8b381bcc-46e3-45c7-8fd2-e020436d1bff">

<img width="206" alt="image"

src="https://github.com/user-attachments/assets/4ea02b47-a4da-44f7-9c26-13b05374e89d">

<img width="213" alt="image"

src="https://github.com/user-attachments/assets/38b50e2a-f69c-4a78-abb2-2680453fc5fd">
2024-07-31 17:58:25 -07:00
Yury Semikhatsky 7ff46d4596
cherry-pick(#31938): chore(trace-viewer): do not shrink metadata view (#31943)
Avoids the following effect:


![image](https://github.com/user-attachments/assets/694de773-acc0-4266-87f2-eab67a3c7ce2)
2024-07-31 17:58:02 -07:00
Yury Semikhatsky ca9ddff7ca
cherry-pick(#31934): chore: make sure error stack includes message as… (#31935)
… before #31691

This brings stack formatting to how it was prior to
1686e5174d
so that the ports can use it.
2024-07-31 11:07:45 -07:00
Max Schmitt dfecfa5be1 cherry-pick(#31914): chore: various roll fixes for .NET 2024-07-30 19:10:50 +02:00
Dmitry Gozman 468b9b1e7a
cherry-pick(#31894): feat(ui mode): ui updates (#31916)
- Update copy to clipboard button.
- Reveal test source in the Source tab instead of external editor.
- New button to reveal in the external editor in the Source tab.
- Move the Pick Locator button next to snapshot tabs.
2024-07-30 09:23:19 -07:00
Max Schmitt 64e4a9b0eb cherry-pick(#31913): fix(client-certificates): use matching origin for connections on :443
Motivation: When using client-certificates on a website on port `443`,
we would normalise the user input with `new URL` but still generate a
"bad" representation of the "origin" internally, since the just do
concatenated "host:port".

(The origin doesn't contain the port in case of :443)

We use `clientCertificatesToTLSOptions` in two places:

a) for APIRequestContext, there we pass one from the URL constructor
over and
b) from the socks proxy, there we **now** also pass a "good one" over.

Test plan: We don't want to run the tests on port :443, so only manually
validated the fix.

Relates https://github.com/microsoft/playwright/issues/31906
2024-07-30 12:31:50 +02:00
Max Schmitt 446de523c4 cherry-pick(#31898): fix(client-certificates): don't use proxy when using BrowserContext.request 2024-07-29 16:45:34 +02:00
Max Schmitt 2ea14ca2c4 cherry-pick(#31893): chore: add maxRetries to APIRequestContext.delete 2024-07-29 14:53:27 +02:00
Max Schmitt 185a2867c6 cherry-pick(#31897): fix(client-certificates): error response body Content-Length calculation 2024-07-29 14:53:12 +02:00
96 changed files with 1428 additions and 521 deletions

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 -->
## [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

@ -180,6 +180,9 @@ context cookies from the response. The method will automatically follow redirect
### option: APIRequestContext.delete.maxRedirects = %%-js-python-csharp-fetch-option-maxredirects-%% ### option: APIRequestContext.delete.maxRedirects = %%-js-python-csharp-fetch-option-maxredirects-%%
* since: v1.26 * since: v1.26
### option: APIRequestContext.delete.maxRetries = %%-js-python-csharp-fetch-option-maxretries-%%
* since: v1.46
## async method: APIRequestContext.dispose ## async method: APIRequestContext.dispose
* since: v1.16 * since: v1.16

View file

@ -503,6 +503,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.

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

@ -8,9 +8,15 @@ import LiteYouTube from '@site/src/components/LiteYouTube';
## Version 1.46 ## Version 1.46
<LiteYouTube
id="tQo7w-QQBsI"
title="Playwright 1.46"
/>
### TLS Client Certificates ### TLS Client Certificates
Playwright now allows to supply client-side certificates, so that server can verify them, as specified by TLS Client Authentication. Playwright now allows you to supply client-side certificates, so that server can verify them, as specified by TLS Client Authentication.
The following snippet sets up a client certificate for `https://example.com`: The following snippet sets up a client certificate for `https://example.com`:
@ -33,6 +39,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 +77,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

@ -16,7 +16,7 @@ Playwright Trace Viewer is a GUI tool that lets you explore recorded Playwright
- [How to open and view the trace](/trace-viewer-intro.md#opening-the-trace) - [How to open and view the trace](/trace-viewer-intro.md#opening-the-trace)
<LiteYouTube <LiteYouTube
id="lfxjs--9ZQs" id="yP6AnTxC34s"
title="Viewing Playwright Traces" title="Viewing Playwright Traces"
/> />

View file

@ -13,14 +13,14 @@ Playwright Trace Viewer is a GUI tool that helps you explore recorded Playwright
* langs: js * langs: js
<LiteYouTube <LiteYouTube
id="lfxjs--9ZQs" id="yP6AnTxC34s"
title="Viewing Playwright Traces" title="Viewing Playwright Traces"
/> />
## Trace Viewer features ## Trace Viewer features
### Actions ### Actions
In the Actions tab you can see what locator was used for every action and how long each one took to run. Hover over each action of your test and visually see the change in the DOM snapshot. Go back and forward in time and click an action to inspect and debug. Use the Before and After tabs to visually see what happened before and after the action. In the Actions tab you can see what locator was used for every action and how long each one took to run. Hover over each action of your test and visually see the change in the DOM snapshot. Go back and forward in time and click an action to inspect and debug. Use the Before and After tabs to visually see what happened before and after the action.
![actions tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/948b65cd-f0fd-4c7f-8e53-2c632b5a07f1) ![actions tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/948b65cd-f0fd-4c7f-8e53-2c632b5a07f1)
@ -31,7 +31,7 @@ In the Actions tab you can see what locator was used for every action and how lo
### Screenshots ### Screenshots
When tracing with the [`option: screenshots`] option turned on (default), each trace records a screencast and renders it as a film strip. You can hover over the film strip to see a magnified image of for each action and state which helps you easily find the action you want to inspect. When tracing with the [`option: screenshots`] option turned on (default), each trace records a screencast and renders it as a film strip. You can hover over the film strip to see a magnified image of for each action and state which helps you easily find the action you want to inspect.
Double click on an action to see the time range for that action. You can use the slider in the timeline to increase the actions selected and these will be shown in the Actions tab and all console logs and network logs will be filtered to only show the logs for the actions selected. Double click on an action to see the time range for that action. You can use the slider in the timeline to increase the actions selected and these will be shown in the Actions tab and all console logs and network logs will be filtered to only show the logs for the actions selected.
@ -393,7 +393,7 @@ public class ExampleTest : PageTest
[TearDown] [TearDown]
public async Task TearDown() public async Task TearDown()
{ {
var failed = TestContext.CurrentContext.Result.Outcome == NUnit.Framework.Interfaces.ResultState.Error var failed = TestContext.CurrentContext.Result.Outcome == NUnit.Framework.Interfaces.ResultState.Error
|| TestContext.CurrentContext.Result.Outcome == NUnit.Framework.Interfaces.ResultState.Failure; || TestContext.CurrentContext.Result.Outcome == NUnit.Framework.Interfaces.ResultState.Failure;
await Context.Tracing.StopAsync(new() await Context.Tracing.StopAsync(new()
@ -402,7 +402,7 @@ public class ExampleTest : PageTest
TestContext.CurrentContext.WorkDirectory, TestContext.CurrentContext.WorkDirectory,
"playwright-traces", "playwright-traces",
$"{TestContext.CurrentContext.Test.ClassName}.{TestContext.CurrentContext.Test.Name}.zip" $"{TestContext.CurrentContext.Test.ClassName}.{TestContext.CurrentContext.Test.Name}.zip"
) : null, ) : null,
}); });
} }

68
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "playwright-internal", "name": "playwright-internal",
"version": "1.46.0-next", "version": "1.46.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "playwright-internal", "name": "playwright-internal",
"version": "1.46.0-next", "version": "1.46.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
@ -7719,10 +7719,10 @@
} }
}, },
"packages/playwright": { "packages/playwright": {
"version": "1.46.0-next", "version": "1.46.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.46.0-next" "playwright-core": "1.46.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -7736,11 +7736,11 @@
}, },
"packages/playwright-browser-chromium": { "packages/playwright-browser-chromium": {
"name": "@playwright/browser-chromium", "name": "@playwright/browser-chromium",
"version": "1.46.0-next", "version": "1.46.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.46.0-next" "playwright-core": "1.46.1"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@ -7748,11 +7748,11 @@
}, },
"packages/playwright-browser-firefox": { "packages/playwright-browser-firefox": {
"name": "@playwright/browser-firefox", "name": "@playwright/browser-firefox",
"version": "1.46.0-next", "version": "1.46.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.46.0-next" "playwright-core": "1.46.1"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@ -7760,22 +7760,22 @@
}, },
"packages/playwright-browser-webkit": { "packages/playwright-browser-webkit": {
"name": "@playwright/browser-webkit", "name": "@playwright/browser-webkit",
"version": "1.46.0-next", "version": "1.46.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.46.0-next" "playwright-core": "1.46.1"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"packages/playwright-chromium": { "packages/playwright-chromium": {
"version": "1.46.0-next", "version": "1.46.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.46.0-next" "playwright-core": "1.46.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -7785,7 +7785,7 @@
} }
}, },
"packages/playwright-core": { "packages/playwright-core": {
"version": "1.46.0-next", "version": "1.46.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"
@ -7796,11 +7796,11 @@
}, },
"packages/playwright-ct-core": { "packages/playwright-ct-core": {
"name": "@playwright/experimental-ct-core", "name": "@playwright/experimental-ct-core",
"version": "1.46.0-next", "version": "1.46.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.46.0-next", "playwright": "1.46.1",
"playwright-core": "1.46.0-next", "playwright-core": "1.46.1",
"vite": "^5.2.8" "vite": "^5.2.8"
}, },
"engines": { "engines": {
@ -7809,10 +7809,10 @@
}, },
"packages/playwright-ct-react": { "packages/playwright-ct-react": {
"name": "@playwright/experimental-ct-react", "name": "@playwright/experimental-ct-react",
"version": "1.46.0-next", "version": "1.46.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.46.0-next", "@playwright/experimental-ct-core": "1.46.1",
"@vitejs/plugin-react": "^4.2.1" "@vitejs/plugin-react": "^4.2.1"
}, },
"bin": { "bin": {
@ -7824,10 +7824,10 @@
}, },
"packages/playwright-ct-react17": { "packages/playwright-ct-react17": {
"name": "@playwright/experimental-ct-react17", "name": "@playwright/experimental-ct-react17",
"version": "1.46.0-next", "version": "1.46.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.46.0-next", "@playwright/experimental-ct-core": "1.46.1",
"@vitejs/plugin-react": "^4.2.1" "@vitejs/plugin-react": "^4.2.1"
}, },
"bin": { "bin": {
@ -7839,10 +7839,10 @@
}, },
"packages/playwright-ct-solid": { "packages/playwright-ct-solid": {
"name": "@playwright/experimental-ct-solid", "name": "@playwright/experimental-ct-solid",
"version": "1.46.0-next", "version": "1.46.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.46.0-next", "@playwright/experimental-ct-core": "1.46.1",
"vite-plugin-solid": "^2.7.0" "vite-plugin-solid": "^2.7.0"
}, },
"bin": { "bin": {
@ -7857,10 +7857,10 @@
}, },
"packages/playwright-ct-svelte": { "packages/playwright-ct-svelte": {
"name": "@playwright/experimental-ct-svelte", "name": "@playwright/experimental-ct-svelte",
"version": "1.46.0-next", "version": "1.46.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.46.0-next", "@playwright/experimental-ct-core": "1.46.1",
"@sveltejs/vite-plugin-svelte": "^3.0.1" "@sveltejs/vite-plugin-svelte": "^3.0.1"
}, },
"bin": { "bin": {
@ -7875,10 +7875,10 @@
}, },
"packages/playwright-ct-vue": { "packages/playwright-ct-vue": {
"name": "@playwright/experimental-ct-vue", "name": "@playwright/experimental-ct-vue",
"version": "1.46.0-next", "version": "1.46.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.46.0-next", "@playwright/experimental-ct-core": "1.46.1",
"@vitejs/plugin-vue": "^4.2.1" "@vitejs/plugin-vue": "^4.2.1"
}, },
"bin": { "bin": {
@ -7890,10 +7890,10 @@
}, },
"packages/playwright-ct-vue2": { "packages/playwright-ct-vue2": {
"name": "@playwright/experimental-ct-vue2", "name": "@playwright/experimental-ct-vue2",
"version": "1.46.0-next", "version": "1.46.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.46.0-next", "@playwright/experimental-ct-core": "1.46.1",
"@vitejs/plugin-vue2": "^2.2.0" "@vitejs/plugin-vue2": "^2.2.0"
}, },
"bin": { "bin": {
@ -7942,11 +7942,11 @@
} }
}, },
"packages/playwright-firefox": { "packages/playwright-firefox": {
"version": "1.46.0-next", "version": "1.46.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.46.0-next" "playwright-core": "1.46.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -7957,10 +7957,10 @@
}, },
"packages/playwright-test": { "packages/playwright-test": {
"name": "@playwright/test", "name": "@playwright/test",
"version": "1.46.0-next", "version": "1.46.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.46.0-next" "playwright": "1.46.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -7970,11 +7970,11 @@
} }
}, },
"packages/playwright-webkit": { "packages/playwright-webkit": {
"version": "1.46.0-next", "version": "1.46.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.46.0-next" "playwright-core": "1.46.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"

View file

@ -1,7 +1,7 @@
{ {
"name": "playwright-internal", "name": "playwright-internal",
"private": true, "private": true,
"version": "1.46.0-next", "version": "1.46.1",
"description": "A high-level API to automate web browsers", "description": "A high-level API to automate web browsers",
"repository": { "repository": {
"type": "git", "type": "git",

View file

@ -20,7 +20,7 @@ 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';
export function navigate(href: string) { export function navigate(href: string) {
window.history.pushState({}, '', href); window.history.pushState({}, '', href);

View file

@ -23,7 +23,7 @@ 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';
export const TestCaseView: React.FC<{ export const TestCaseView: React.FC<{

View file

@ -1,6 +1,6 @@
{ {
"name": "@playwright/browser-chromium", "name": "@playwright/browser-chromium",
"version": "1.46.0-next", "version": "1.46.1",
"description": "Playwright package that automatically installs Chromium", "description": "Playwright package that automatically installs Chromium",
"repository": { "repository": {
"type": "git", "type": "git",
@ -27,6 +27,6 @@
"install": "node install.js" "install": "node install.js"
}, },
"dependencies": { "dependencies": {
"playwright-core": "1.46.0-next" "playwright-core": "1.46.1"
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@playwright/browser-firefox", "name": "@playwright/browser-firefox",
"version": "1.46.0-next", "version": "1.46.1",
"description": "Playwright package that automatically installs Firefox", "description": "Playwright package that automatically installs Firefox",
"repository": { "repository": {
"type": "git", "type": "git",
@ -27,6 +27,6 @@
"install": "node install.js" "install": "node install.js"
}, },
"dependencies": { "dependencies": {
"playwright-core": "1.46.0-next" "playwright-core": "1.46.1"
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@playwright/browser-webkit", "name": "@playwright/browser-webkit",
"version": "1.46.0-next", "version": "1.46.1",
"description": "Playwright package that automatically installs WebKit", "description": "Playwright package that automatically installs WebKit",
"repository": { "repository": {
"type": "git", "type": "git",
@ -27,6 +27,6 @@
"install": "node install.js" "install": "node install.js"
}, },
"dependencies": { "dependencies": {
"playwright-core": "1.46.0-next" "playwright-core": "1.46.1"
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "playwright-chromium", "name": "playwright-chromium",
"version": "1.46.0-next", "version": "1.46.1",
"description": "A high-level API to automate Chromium", "description": "A high-level API to automate Chromium",
"repository": { "repository": {
"type": "git", "type": "git",
@ -30,6 +30,6 @@
"install": "node install.js" "install": "node install.js"
}, },
"dependencies": { "dependencies": {
"playwright-core": "1.46.0-next" "playwright-core": "1.46.1"
} }
} }

View file

@ -1,11 +0,0 @@
# Certfificates for Socks Proxy
These certificates are used when client certificates are used with
Playwright. Playwright then creates a Socks proxy, which sits between
the browser and the actual target server. The Socks proxy uses this certificiate
to talk to the browser and establishes its own secure TLS connection to the server.
The certificates are generated via:
```bash
openssl req -new -newkey rsa:2048 -days 3650 -nodes -x509 -keyout key.pem -out cert.pem -subj "/CN=localhost"
```

View file

@ -1,19 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIDCTCCAfGgAwIBAgIUTcrzEueVL/OuLHr4LBIPWeS4UL0wDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI0MDcwNDA4NDAzNFoXDTM0MDcw
MjA4NDAzNFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEApof+SZVN4UGma4xJDVHhMSpmEJoCdMPr+HFadJJK/brF
BNOhA1C5wNk8oD/XYo7enAHQH/EsBnq4MMxv79rXTGnIdXMF+43GdMDh5kh81FQy
Esw8Vt4eif9eZkjUxI2GHhR2ovJewmQa7E+SeUB2RzJTqz8QPLhd74JFfgaci+S2
8L37ScVjcw55T1PcNflzB4vwsQHBT3yND0MLDhm+8MLzmTl4Mw5PgIOaBl5Jh8Tr
wQF4eeeB3FPJoMQhTP8aGBjW1mo+NmSSRAPIAZyhmCAnDeC33yRjAaiHjaL5Pr9f
wt5zoF5+U1xWhGXWzGOE6p/VTj62F9a2fOXNHclYJQIDAQABo1MwUTAdBgNVHQ4E
FgQU9BoVzGtb5x70KqGO/89N1hyqi5kwHwYDVR0jBBgwFoAU9BoVzGtb5x70KqGO
/89N1hyqi5kwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAYcbI
wvcfx2p8z0RNN3EA+epKX1SagZyJX4ORIO8kln1sDU+ceHde3n3xnp1dg6HG2qh1
a7CZub/fNUaP9R8+6iiV0wPT7Ybkb2NIJcH1yq+/bfSS5OC5DO0yv9SUADdBoDwa
zOuBAqdcYW1BHYcbAzsQnniRcejHu06ioaS6SwwJ8150rQnLT4Lh9LAl40W6v4nZ
NdTGQETTrbjcgH1ER4IhWTKtVyPOxGF9A/OOawMEdfS8BhUO7YRS4QNFFaQMrJAb
MDhDtjSyDogLr8P43xjjWvQWG9a7zTF0kKEsdJ0cEG5HATpg8bPHmrouxbs2HGeH
kJXzMykrsYyXsInN3w==
-----END CERTIFICATE-----

View file

@ -1,28 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCmh/5JlU3hQaZr
jEkNUeExKmYQmgJ0w+v4cVp0kkr9usUE06EDULnA2TygP9dijt6cAdAf8SwGergw
zG/v2tdMach1cwX7jcZ0wOHmSHzUVDISzDxW3h6J/15mSNTEjYYeFHai8l7CZBrs
T5J5QHZHMlOrPxA8uF3vgkV+BpyL5LbwvftJxWNzDnlPU9w1+XMHi/CxAcFPfI0P
QwsOGb7wwvOZOXgzDk+Ag5oGXkmHxOvBAXh554HcU8mgxCFM/xoYGNbWaj42ZJJE
A8gBnKGYICcN4LffJGMBqIeNovk+v1/C3nOgXn5TXFaEZdbMY4Tqn9VOPrYX1rZ8
5c0dyVglAgMBAAECggEAB6zX4vNPKhUZAvbtvP/rlZUDLDu05kXLX+F1jk7ZxvTv
NKg+UQVM8l7wxN/8YM3944nP2lEGuuu4BoO9mvvmlV6Avy0EdxITNflX0AHCQxT4
U9Z253gIR0ruQl+T8tUk+8jsqNjr1iC//ukx8oWujdx7b7aR3IKQzcOeyU6rs2TN
lyrVVsEaFVi9+wCw0xyiCmPlobrn+egdigw7Zhp2BRinC6W9eMxuPS2hlhQUhBm/
eiD96YWp0RAv/L5qO93reoXIAzrrLdcUgPEnnq1zN7y2xihU2+B2sTph1m/A26+J
yPcXd7vQrXlRXQU6PaCa+0oJULlpiAzy3HPbnr4BkQKBgQDdmekTX8dQqiEZPX1C
017QRFbx0/x/TDFDSeJbDeauMzzCaGqCO2WVmYmTvFtby2G4/6BYowVtJVHm4uJl
XsYk8dWIQGLPIj1Cw7ZieJvb2EVRxgnY2oMaOTOazHzPHFzZV718zwEeZrryT82J
881E8wgM8V3DjkS4ye3TbwvimQKBgQDAYa/IdnpAg5z1TREi9Tt8fnoGpmSscAak
USgeXVsvoNzXXkE94MiiCOOrX1r68TWYDAzq6MKGDewkWOfLwXWR6D5C2LyE1q9P
1pxstgs/nC3ZUTz0yEH47ahSmhywhGlvXXOQEXUSLiVTOdeMCubMqwQW80F1868n
aBHcj5/lbQKBgQDIojjsWaNT3TTqbUmj30vQtI8jlBLgDlPr4FEYr5VT0wAH5BHK
p4xpzgFJyRfOHG312TuMBM087LUinfjsXsp3WJ1EJ0dO0mk0sY3HyfsTKNRaHTt9
Ixnf/DpExS+bNMq73Tyqa6FPrSNFkAtAA4SuEHwRe9aw33ZI+EpjS/8uwQKBgQCi
9NwqSLlLVnColEw0uVdXH+cLJPzX19i4bQo3lkp8MJ2ATJWk7XflUPRQoGf3ckQ8
c9CpVtoXJUnmi+xkeo21Nu0uQFqHhzZewWIk75rdmdR4ZUjl649+ZQkUVviASNjq
fVU7Lp5k9POm6LL9K+rOaPoA2rKTUAQItC2VD4+YjQKBgB6kgvgN6Mz/u0RE3kkV
2GOoP5sso71Hxwh7o6JEzUMhR+e/T/LLcBwEjLYcf1FYRySHsXLn2Ar/Uw1J7pAZ
ud54/at+7mTDliaT8Ar7S9vcso7ZfmuDX9qB9+c77idPskVBPo2tjJbwvFcB6sww
5Elcfmj6tEP4YLJ6Kv3qTPhT
-----END PRIVATE KEY-----

View file

@ -3,9 +3,9 @@
"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",

View file

@ -1,6 +1,6 @@
{ {
"name": "playwright-core", "name": "playwright-core",
"version": "1.46.0-next", "version": "1.46.1",
"description": "A high-level API to automate web browsers", "description": "A high-level API to automate web browsers",
"repository": { "repository": {
"type": "git", "type": "git",

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;
@ -168,7 +167,10 @@ export abstract class APIRequestContext extends SdkObject {
const method = params.method?.toUpperCase() || 'GET'; const method = params.method?.toUpperCase() || 'GET';
const proxy = defaults.proxy; const proxy = defaults.proxy;
let agent; let agent;
if (proxy && proxy.server !== 'per-context' && !shouldBypassProxy(requestUrl, proxy.bypass)) { // When `clientCertificates` is present, we set the `proxy` property to our own socks proxy
// for the browser to use. However, we don't need it here, because we already respect
// `clientCertificates` when fetching from Node.js.
if (proxy && !defaults.clientCertificates?.length && proxy.server !== 'per-context' && !shouldBypassProxy(requestUrl, proxy.bypass)) {
const proxyOpts = url.parse(proxy.server); const proxyOpts = url.parse(proxy.server);
if (proxyOpts.protocol?.startsWith('socks')) { if (proxyOpts.protocol?.startsWith('socks')) {
agent = new SocksProxyAgent({ agent = new SocksProxyAgent({
@ -196,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;
@ -444,7 +444,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

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

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

@ -15,14 +15,12 @@
*/ */
import net from 'net'; import net from 'net';
import path from 'path';
import http2 from 'http2'; import http2 from 'http2';
import type https from 'https'; import type https from 'https';
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, generateSelfSignedCertificate, 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';
@ -32,10 +30,8 @@ let dummyServerTlsOptions: tls.TlsOptions | undefined = undefined;
function loadDummyServerCertsIfNeeded() { function loadDummyServerCertsIfNeeded() {
if (dummyServerTlsOptions) if (dummyServerTlsOptions)
return; return;
dummyServerTlsOptions = { const { cert, key } = generateSelfSignedCertificate();
key: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/key.pem')), dummyServerTlsOptions = { key, cert };
cert: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/cert.pem')),
};
} }
class ALPNCache { class ALPNCache {
@ -60,11 +56,9 @@ class ALPNCache {
ALPNProtocols: ['h2', 'http/1.1'], ALPNProtocols: ['h2', 'http/1.1'],
rejectUnauthorized: false, rejectUnauthorized: false,
}).then(socket => { }).then(socket => {
socket.on('secureConnect', () => { // The server may not respond with ALPN, in which case we default to http/1.1.
// The server may not respond with ALPN, in which case we default to http/1.1. result.resolve(socket.alpnProtocol || 'http/1.1');
result.resolve(socket.alpnProtocol || 'http/1.1'); socket.end();
socket.end();
});
}).catch(error => { }).catch(error => {
debugLogger.log('client-certificates', `ALPN error: ${error.message}`); debugLogger.log('client-certificates', `ALPN error: ${error.message}`);
result.resolve('http/1.1'); result.resolve('http/1.1');
@ -93,8 +87,8 @@ class SocksProxyConnection {
async connect() { async connect() {
this.target = await createSocket(rewriteToLocalhostIfNeeded(this.host), this.port); this.target = await createSocket(rewriteToLocalhostIfNeeded(this.host), this.port);
this.target.on('close', this._targetCloseEventListener); this.target.once('close', this._targetCloseEventListener);
this.target.on('error', error => this.socksProxy._socksProxy.sendSocketError({ uid: this.uid, error: error.message })); this.target.once('error', error => this.socksProxy._socksProxy.sendSocketError({ uid: this.uid, error: error.message }));
this.socksProxy._socksProxy.socketConnected({ this.socksProxy._socksProxy.socketConnected({
uid: this.uid, uid: this.uid,
host: this.target.localAddress!, host: this.target.localAddress!,
@ -138,42 +132,22 @@ class SocksProxyConnection {
...dummyServerTlsOptions, ...dummyServerTlsOptions,
ALPNProtocols: alpnProtocolChosenByServer === 'h2' ? ['h2', 'http/1.1'] : ['http/1.1'], ALPNProtocols: alpnProtocolChosenByServer === 'h2' ? ['h2', 'http/1.1'] : ['http/1.1'],
}); });
this.internal?.on('close', () => dummyServer.close()); this.internal?.once('close', () => dummyServer.close());
dummyServer.emit('connection', this.internal); dummyServer.emit('connection', this.internal);
dummyServer.on('secureConnection', internalTLS => { dummyServer.once('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) {
@ -182,7 +156,7 @@ class SocksProxyConnection {
this.target.removeListener('close', this._targetCloseEventListener); this.target.removeListener('close', this._targetCloseEventListener);
// @ts-expect-error // @ts-expect-error
const session: http2.ServerHttp2Session = http2.performServerHandshake(internalTLS); const session: http2.ServerHttp2Session = http2.performServerHandshake(internalTLS);
session.on('stream', (stream: http2.ServerHttp2Stream) => { session.once('stream', (stream: http2.ServerHttp2Stream) => {
stream.respond({ stream.respond({
'content-type': 'text/html', 'content-type': 'text/html',
[http2.constants.HTTP2_HEADER_STATUS]: 503, [http2.constants.HTTP2_HEADER_STATUS]: 503,
@ -191,7 +165,7 @@ class SocksProxyConnection {
session.close(); session.close();
closeBothSockets(); closeBothSockets();
}); });
stream.on('error', () => closeBothSockets()); stream.once('error', () => closeBothSockets());
}); });
} else { } else {
closeBothSockets(); closeBothSockets();
@ -201,12 +175,42 @@ class SocksProxyConnection {
'HTTP/1.1 503 Internal Server Error', 'HTTP/1.1 503 Internal Server Error',
'Content-Type: text/html; charset=utf-8', 'Content-Type: text/html; charset=utf-8',
'Content-Length: ' + Buffer.byteLength(responseBody), 'Content-Length: ' + Buffer.byteLength(responseBody),
'\r\n', '',
responseBody, responseBody,
].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.once('secureConnect', () => {
internalTLS.pipe(targetTLS);
targetTLS.pipe(internalTLS);
}); });
internalTLS.once('close', () => closeBothSockets());
internalTLS.once('error', () => closeBothSockets());
targetTLS.once('error', handleError);
}); });
}); });
} }
@ -288,3 +292,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

@ -15,6 +15,7 @@
*/ */
import crypto from 'crypto'; import crypto from 'crypto';
import { assert } from './debug';
export function createGuid(): string { export function createGuid(): string {
return crypto.randomBytes(16).toString('hex'); return crypto.randomBytes(16).toString('hex');
@ -25,3 +26,170 @@ export function calculateSha1(buffer: Buffer | string): string {
hash.update(buffer); hash.update(buffer);
return hash.digest('hex'); return hash.digest('hex');
} }
// Variable-length quantity encoding aka. base-128 encoding
function encodeBase128(value: number): Buffer {
const bytes = [];
do {
let byte = value & 0x7f;
value >>>= 7;
if (bytes.length > 0) byte |= 0x80;
bytes.push(byte);
} while (value > 0);
return Buffer.from(bytes.reverse());
};
// ASN1/DER Speficiation: https://www.itu.int/rec/T-REC-X.680-X.693-202102-I/en
class DER {
static encodeSequence(data: Buffer[]): Buffer {
return this._encode(0x30, Buffer.concat(data));
}
static encodeInteger(data: number): Buffer {
assert(data >= -128 && data <= 127);
return this._encode(0x02, Buffer.from([data]));
}
static encodeObjectIdentifier(oid: string): Buffer {
const parts = oid.split('.').map((v) => Number(v));
// Encode the second part, which could be large, using base-128 encoding if necessary
const output = [encodeBase128(40 * parts[0] + parts[1])];
for (let i = 2; i < parts.length; i++) {
output.push(encodeBase128(parts[i]));
}
return this._encode(0x06, Buffer.concat(output));
}
static encodeNull(): Buffer {
return Buffer.from([0x05, 0x00]);
}
static encodeSet(data: Buffer[]): Buffer {
assert(data.length === 1, 'Only one item in the set is supported. We\'d need to sort the data to support more.');
// We expect the data to be already sorted.
return this._encode(0x31, Buffer.concat(data));
}
static encodeExplicitContextDependent(tag: number, data: Buffer): Buffer {
return this._encode(0xa0 + tag, data);
}
static encodePrintableString(data: string): Buffer {
return this._encode(0x13, Buffer.from(data));
}
static encodeBitString(data: Buffer): Buffer {
// The first byte of the content is the number of unused bits at the end
const unusedBits = 0; // Assuming all bits are used
const content = Buffer.concat([Buffer.from([unusedBits]), data]);
return this._encode(0x03, content);
}
static encodeDate(date: Date): Buffer {
const year = date.getUTCFullYear();
const isGeneralizedTime = year >= 2050;
const parts = [
isGeneralizedTime ? year.toString() : year.toString().slice(-2),
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
date.getUTCDate().toString().padStart(2, '0'),
date.getUTCHours().toString().padStart(2, '0'),
date.getUTCMinutes().toString().padStart(2, '0'),
date.getUTCSeconds().toString().padStart(2, '0')
];
const encodedDate = parts.join('') + 'Z';
const tag = isGeneralizedTime ? 0x18 : 0x17; // 0x18 for GeneralizedTime, 0x17 for UTCTime
return this._encode(tag, Buffer.from(encodedDate));
}
private static _encode(tag: number, data: Buffer): Buffer {
const lengthBytes = this._encodeLength(data.length);
return Buffer.concat([Buffer.from([tag]), lengthBytes, data]);
}
private static _encodeLength(length: number): Buffer {
if (length < 128) {
return Buffer.from([length]);
} else {
const lengthBytes = [];
while (length > 0) {
lengthBytes.unshift(length & 0xFF);
length >>= 8;
}
return Buffer.from([0x80 | lengthBytes.length, ...lengthBytes]);
}
}
}
// X.509 Specification: https://datatracker.ietf.org/doc/html/rfc2459#section-4.1
export function generateSelfSignedCertificate() {
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const publicKeyDer = publicKey.export({ type: 'pkcs1', format: 'der' });
const oneYearInMilliseconds = 365 * 24 * 60 * 60 * 1_000;
const notBefore = new Date(new Date().getTime() - oneYearInMilliseconds);
const notAfter = new Date(new Date().getTime() + oneYearInMilliseconds);
// List of fields / structure: https://datatracker.ietf.org/doc/html/rfc2459#section-4.1
const tbsCertificate = DER.encodeSequence([
DER.encodeExplicitContextDependent(0, DER.encodeInteger(1)), // version
DER.encodeInteger(1), // serialNumber
DER.encodeSequence([
DER.encodeObjectIdentifier('1.2.840.113549.1.1.11'), // sha256WithRSAEncryption PKCS #1
DER.encodeNull()
]), // signature
DER.encodeSequence([
DER.encodeSet([
DER.encodeSequence([
DER.encodeObjectIdentifier('2.5.4.3'), // commonName X.520 DN component
DER.encodePrintableString('localhost')
]),
]),
DER.encodeSet([
DER.encodeSequence([
DER.encodeObjectIdentifier('2.5.4.10'), // organizationName X.520 DN component
DER.encodePrintableString('Playwright Client Certificate Support')
])
])
]), // issuer
DER.encodeSequence([
DER.encodeDate(notBefore), // notBefore
DER.encodeDate(notAfter), // notAfter
]), // validity
DER.encodeSequence([
DER.encodeSet([
DER.encodeSequence([
DER.encodeObjectIdentifier('2.5.4.3'), // commonName X.520 DN component
DER.encodePrintableString('localhost')
]),
]),
DER.encodeSet([
DER.encodeSequence([
DER.encodeObjectIdentifier('2.5.4.10'), // organizationName X.520 DN component
DER.encodePrintableString('Playwright Client Certificate Support')
])
])
]), // subject
DER.encodeSequence([
DER.encodeSequence([
DER.encodeObjectIdentifier('1.2.840.113549.1.1.1'), // rsaEncryption PKCS #1
DER.encodeNull()
]),
DER.encodeBitString(publicKeyDer)
]), // SubjectPublicKeyInfo
]);
const signature = crypto.sign('sha256', tbsCertificate, privateKey);
const certificate = DER.encodeSequence([
tbsCertificate,
DER.encodeSequence([
DER.encodeObjectIdentifier('1.2.840.113549.1.1.11'), // sha256WithRSAEncryption PKCS #1
DER.encodeNull()
]),
DER.encodeBitString(signature)
]);
const certPem = [
'-----BEGIN CERTIFICATE-----',
// Split the base64 string into lines of 64 characters
certificate.toString('base64').match(/.{1,64}/g)!.join('\n'),
'-----END CERTIFICATE-----'
].join('\n');
return {
cert: certPem,
key: privateKey.export({ type: 'pkcs1', format: 'pem' }),
};
}

View file

@ -72,14 +72,16 @@ export async function createTLSSocket(options: tls.ConnectionOptions): Promise<t
assert(options.host, 'host is required'); assert(options.host, 'host is required');
if (net.isIP(options.host)) { if (net.isIP(options.host)) {
const socket = tls.connect(options) const socket = tls.connect(options)
socket.on('connect', () => resolve(socket)); socket.on('secureConnect', () => resolve(socket));
socket.on('error', error => reject(error)); socket.on('error', error => reject(error));
} else { } else {
createConnectionAsync(options, (err, socket) => { createConnectionAsync(options, (err, socket) => {
if (err) if (err)
reject(err); reject(err);
if (socket) if (socket) {
resolve(socket); socket.on('secureConnect', () => resolve(socket));
socket.on('error', error => reject(error));
}
}, true).catch(err => reject(err)); }, true).catch(err => reject(err));
} }
}); });

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

View file

@ -15807,6 +15807,12 @@ export interface APIRequestContext {
*/ */
maxRedirects?: number; maxRedirects?: number;
/**
* Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not
* retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries.
*/
maxRetries?: number;
/** /**
* Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this * Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this
* request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless * request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless
@ -19564,6 +19570,12 @@ export interface Route {
*/ */
maxRedirects?: number; maxRedirects?: number;
/**
* Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not
* retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries.
*/
maxRetries?: number;
/** /**
* If set changes the request method (e.g. GET or POST). * If set changes the request method (e.g. GET or POST).
*/ */

View file

@ -1,6 +1,6 @@
{ {
"name": "@playwright/experimental-ct-core", "name": "@playwright/experimental-ct-core",
"version": "1.46.0-next", "version": "1.46.1",
"description": "Playwright Component Testing Helpers", "description": "Playwright Component Testing Helpers",
"repository": { "repository": {
"type": "git", "type": "git",
@ -26,8 +26,8 @@
} }
}, },
"dependencies": { "dependencies": {
"playwright-core": "1.46.0-next", "playwright-core": "1.46.1",
"vite": "^5.2.8", "vite": "^5.2.8",
"playwright": "1.46.0-next" "playwright": "1.46.1"
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@playwright/experimental-ct-react", "name": "@playwright/experimental-ct-react",
"version": "1.46.0-next", "version": "1.46.1",
"description": "Playwright Component Testing for React", "description": "Playwright Component Testing for React",
"repository": { "repository": {
"type": "git", "type": "git",
@ -30,7 +30,7 @@
"./package.json": "./package.json" "./package.json": "./package.json"
}, },
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.46.0-next", "@playwright/experimental-ct-core": "1.46.1",
"@vitejs/plugin-react": "^4.2.1" "@vitejs/plugin-react": "^4.2.1"
}, },
"bin": { "bin": {

View file

@ -1,6 +1,6 @@
{ {
"name": "@playwright/experimental-ct-react17", "name": "@playwright/experimental-ct-react17",
"version": "1.46.0-next", "version": "1.46.1",
"description": "Playwright Component Testing for React", "description": "Playwright Component Testing for React",
"repository": { "repository": {
"type": "git", "type": "git",
@ -30,7 +30,7 @@
"./package.json": "./package.json" "./package.json": "./package.json"
}, },
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.46.0-next", "@playwright/experimental-ct-core": "1.46.1",
"@vitejs/plugin-react": "^4.2.1" "@vitejs/plugin-react": "^4.2.1"
}, },
"bin": { "bin": {

View file

@ -1,6 +1,6 @@
{ {
"name": "@playwright/experimental-ct-solid", "name": "@playwright/experimental-ct-solid",
"version": "1.46.0-next", "version": "1.46.1",
"description": "Playwright Component Testing for Solid", "description": "Playwright Component Testing for Solid",
"repository": { "repository": {
"type": "git", "type": "git",
@ -30,7 +30,7 @@
"./package.json": "./package.json" "./package.json": "./package.json"
}, },
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.46.0-next", "@playwright/experimental-ct-core": "1.46.1",
"vite-plugin-solid": "^2.7.0" "vite-plugin-solid": "^2.7.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,6 +1,6 @@
{ {
"name": "@playwright/experimental-ct-svelte", "name": "@playwright/experimental-ct-svelte",
"version": "1.46.0-next", "version": "1.46.1",
"description": "Playwright Component Testing for Svelte", "description": "Playwright Component Testing for Svelte",
"repository": { "repository": {
"type": "git", "type": "git",
@ -30,7 +30,7 @@
"./package.json": "./package.json" "./package.json": "./package.json"
}, },
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.46.0-next", "@playwright/experimental-ct-core": "1.46.1",
"@sveltejs/vite-plugin-svelte": "^3.0.1" "@sveltejs/vite-plugin-svelte": "^3.0.1"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,6 +1,6 @@
{ {
"name": "@playwright/experimental-ct-vue", "name": "@playwright/experimental-ct-vue",
"version": "1.46.0-next", "version": "1.46.1",
"description": "Playwright Component Testing for Vue", "description": "Playwright Component Testing for Vue",
"repository": { "repository": {
"type": "git", "type": "git",
@ -30,7 +30,7 @@
"./package.json": "./package.json" "./package.json": "./package.json"
}, },
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.46.0-next", "@playwright/experimental-ct-core": "1.46.1",
"@vitejs/plugin-vue": "^4.2.1" "@vitejs/plugin-vue": "^4.2.1"
}, },
"bin": { "bin": {

View file

@ -1,6 +1,6 @@
{ {
"name": "@playwright/experimental-ct-vue2", "name": "@playwright/experimental-ct-vue2",
"version": "1.46.0-next", "version": "1.46.1",
"description": "Playwright Component Testing for Vue2", "description": "Playwright Component Testing for Vue2",
"repository": { "repository": {
"type": "git", "type": "git",
@ -30,7 +30,7 @@
"./package.json": "./package.json" "./package.json": "./package.json"
}, },
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.46.0-next", "@playwright/experimental-ct-core": "1.46.1",
"@vitejs/plugin-vue2": "^2.2.0" "@vitejs/plugin-vue2": "^2.2.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,6 +1,6 @@
{ {
"name": "playwright-firefox", "name": "playwright-firefox",
"version": "1.46.0-next", "version": "1.46.1",
"description": "A high-level API to automate Firefox", "description": "A high-level API to automate Firefox",
"repository": { "repository": {
"type": "git", "type": "git",
@ -30,6 +30,6 @@
"install": "node install.js" "install": "node install.js"
}, },
"dependencies": { "dependencies": {
"playwright-core": "1.46.0-next" "playwright-core": "1.46.1"
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@playwright/test", "name": "@playwright/test",
"version": "1.46.0-next", "version": "1.46.1",
"description": "A high-level API to automate web browsers", "description": "A high-level API to automate web browsers",
"repository": { "repository": {
"type": "git", "type": "git",
@ -30,6 +30,6 @@
}, },
"scripts": {}, "scripts": {},
"dependencies": { "dependencies": {
"playwright": "1.46.0-next" "playwright": "1.46.1"
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "playwright-webkit", "name": "playwright-webkit",
"version": "1.46.0-next", "version": "1.46.1",
"description": "A high-level API to automate WebKit", "description": "A high-level API to automate WebKit",
"repository": { "repository": {
"type": "git", "type": "git",
@ -30,6 +30,6 @@
"install": "node install.js" "install": "node install.js"
}, },
"dependencies": { "dependencies": {
"playwright-core": "1.46.0-next" "playwright-core": "1.46.1"
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "playwright", "name": "playwright",
"version": "1.46.0-next", "version": "1.46.1",
"description": "A high-level API to automate web browsers", "description": "A high-level API to automate web browsers",
"repository": { "repository": {
"type": "git", "type": "git",
@ -58,7 +58,7 @@
}, },
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.46.0-next" "playwright-core": "1.46.1"
}, },
"optionalDependencies": { "optionalDependencies": {
"fsevents": "2.3.2" "fsevents": "2.3.2"

View file

@ -261,6 +261,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
title: renderApiCall(apiName, params), title: renderApiCall(apiName, params),
apiName, apiName,
params, params,
canNestByTime: true,
}); });
userData.userObject = step; userData.userObject = step;
out.stepId = step.stepId; out.stepId = step.stepId;

View file

@ -136,10 +136,10 @@ export async function createRootSuite(testRun: TestRun, errors: TestError[], sho
// Filter file suites for all projects. // Filter file suites for all projects.
for (const [project, fileSuites] of testRun.projectSuites) { for (const [project, fileSuites] of testRun.projectSuites) {
const filteredFileSuites = additionalFileMatcher ? fileSuites.filter(fileSuite => additionalFileMatcher(fileSuite.location!.file)) : fileSuites; const projectSuite = createProjectSuite(project, fileSuites);
const projectSuite = createProjectSuite(project, filteredFileSuites);
projectSuites.set(project, projectSuite); projectSuites.set(project, projectSuite);
const filteredProjectSuite = filterProjectSuite(projectSuite, { cliFileFilters, cliTitleMatcher, testIdMatcher: config.testIdMatcher });
const filteredProjectSuite = filterProjectSuite(projectSuite, { cliFileFilters, cliTitleMatcher, testIdMatcher: config.testIdMatcher, additionalFileMatcher });
filteredProjectSuites.set(project, filteredProjectSuite); filteredProjectSuites.set(project, filteredProjectSuite);
} }
} }
@ -200,8 +200,8 @@ export async function createRootSuite(testRun: TestRun, errors: TestError[], sho
const projectClosure = new Map(buildProjectsClosure(rootSuite.suites.map(suite => suite._fullProject!))); const projectClosure = new Map(buildProjectsClosure(rootSuite.suites.map(suite => suite._fullProject!)));
// Clone file suites for dependency projects. // Clone file suites for dependency projects.
for (const project of projectClosure.keys()) { for (const [project, level] of projectClosure.entries()) {
if (projectClosure.get(project) === 'dependency') if (level === 'dependency')
rootSuite._prependSuite(buildProjectSuite(project, projectSuites.get(project)!)); rootSuite._prependSuite(buildProjectSuite(project, projectSuites.get(project)!));
} }
} }
@ -225,9 +225,9 @@ function createProjectSuite(project: FullProjectInternal, fileSuites: Suite[]):
return projectSuite; return projectSuite;
} }
function filterProjectSuite(projectSuite: Suite, options: { cliFileFilters: TestFileFilter[], cliTitleMatcher?: Matcher, testIdMatcher?: Matcher }): Suite { function filterProjectSuite(projectSuite: Suite, options: { cliFileFilters: TestFileFilter[], cliTitleMatcher?: Matcher, testIdMatcher?: Matcher, additionalFileMatcher?: Matcher }): Suite {
// Fast path. // Fast path.
if (!options.cliFileFilters.length && !options.cliTitleMatcher && !options.testIdMatcher) if (!options.cliFileFilters.length && !options.cliTitleMatcher && !options.testIdMatcher && !options.additionalFileMatcher)
return projectSuite; return projectSuite;
const result = projectSuite._deepClone(); const result = projectSuite._deepClone();
@ -238,6 +238,8 @@ function filterProjectSuite(projectSuite: Suite, options: { cliFileFilters: Test
filterTestsRemoveEmptySuites(result, (test: TestCase) => { filterTestsRemoveEmptySuites(result, (test: TestCase) => {
if (options.cliTitleMatcher && !options.cliTitleMatcher(test._grepTitle())) if (options.cliTitleMatcher && !options.cliTitleMatcher(test._grepTitle()))
return false; return false;
if (options.additionalFileMatcher && !options.additionalFileMatcher(test.location.file))
return false;
return true; return true;
}); });
return result; return result;

View file

@ -27,6 +27,18 @@ export async function detectChangedTestFiles(baseCommit: string, configDir: stri
).split('\n').filter(Boolean); ).split('\n').filter(Boolean);
} catch (_error) { } catch (_error) {
const error = _error as childProcess.SpawnSyncReturns<string>; const error = _error as childProcess.SpawnSyncReturns<string>;
const unknownRevision = error.output.some(line => line?.includes('unknown revision'));
if (unknownRevision) {
const isShallowClone = childProcess.execSync('git rev-parse --is-shallow-repository', { encoding: 'utf-8', stdio: 'pipe' }).trim() === 'true';
if (isShallowClone) {
throw new Error([
`The repository is a shallow clone and does not have '${baseCommit}' available locally.`,
`Note that GitHub Actions checkout is shallow by default: https://github.com/actions/checkout`
].join('\n'));
}
}
throw new Error([ throw new Error([
`Cannot detect changed files for --only-changed mode:`, `Cannot detect changed files for --only-changed mode:`,
`git ${command}`, `git ${command}`,

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[];
@ -44,6 +44,9 @@ export interface TestStepInternal {
infectParentStepsWithError?: boolean; infectParentStepsWithError?: boolean;
box?: boolean; box?: boolean;
isStage?: boolean; isStage?: boolean;
// TODO: this сould be decided based on the category, but pw:api
// is from a different abstraction layer.
canNestByTime?: boolean;
} }
export type TestStage = { export type TestStage = {
@ -252,7 +255,7 @@ 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') { if (!parentStep && data.canNestByTime) {
// API steps (but not test.step calls) can be nested by time, instead of by stack. // 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. // However, do not nest chains of route.continue by checking the title.
parentStep = this._findLastNonFinishedStep(step => step.title !== data.title); parentStep = this._findLastNonFinishedStep(step => step.title !== data.title);

View file

@ -4815,9 +4815,9 @@ export type Fixtures<T extends KeyValue = {}, W extends KeyValue = {}, PT extend
} & { } & {
[K in keyof PT]?: TestFixtureValue<PT[K], T & W & PT & PW> | [TestFixtureValue<PT[K], T & W & PT & PW>, { scope: 'test', timeout?: number | undefined, title?: string, box?: boolean }]; [K in keyof PT]?: TestFixtureValue<PT[K], T & W & PT & PW> | [TestFixtureValue<PT[K], T & W & PT & PW>, { scope: 'test', timeout?: number | undefined, title?: string, box?: boolean }];
} & { } & {
[K in Exclude<keyof W, keyof PW>]?: [WorkerFixtureValue<W[K], W & PW>, { scope: 'worker', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean }]; [K in keyof W]?: [WorkerFixtureValue<W[K], W & PW>, { scope: 'worker', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean }];
} & { } & {
[K in Exclude<keyof T, keyof PT>]?: TestFixtureValue<T[K], T & W & PT & PW> | [TestFixtureValue<T[K], T & W & PT & PW>, { scope?: 'test', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean }]; [K in keyof T]?: TestFixtureValue<T[K], T & W & PT & PW> | [TestFixtureValue<T[K], T & W & PT & PW>, { scope?: 'test', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean }];
}; };
type BrowserName = 'chromium' | 'firefox' | 'webkit'; type BrowserName = 'chromium' | 'firefox' | 'webkit';

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

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

@ -55,16 +55,16 @@
overflow: hidden; overflow: hidden;
line-height: 18px; line-height: 18px;
white-space: nowrap; white-space: nowrap;
max-height: 18px;
} }
.call-line .copy-icon { .call-line:not(:hover) .toolbar-button.copy {
display: none; display: none;
margin-left: 5px;
} }
.call-line:hover .copy-icon { .call-line .toolbar-button.copy {
display: block; margin-left: 5px;
cursor: pointer; transform: scale(0.8);
} }
.call-value { .call-value {

View file

@ -15,23 +15,24 @@
*/ */
import * as React from 'react'; import * as React from 'react';
import { ToolbarButton } from '@web/components/toolbarButton';
export const CopyToClipboard: React.FunctionComponent<{ export const CopyToClipboard: React.FunctionComponent<{
value: string, value: string,
description?: string, description?: string,
}> = ({ value, description }) => { }> = ({ value, description }) => {
const [iconClassName, setIconClassName] = React.useState('codicon-clippy'); const [icon, setIcon] = React.useState('copy');
const handleCopy = React.useCallback(() => { const handleCopy = React.useCallback(() => {
navigator.clipboard.writeText(value).then(() => { navigator.clipboard.writeText(value).then(() => {
setIconClassName('codicon-check'); setIcon('check');
setTimeout(() => { setTimeout(() => {
setIconClassName('codicon-clippy'); setIcon('copy');
}, 3000); }, 3000);
}, () => { }, () => {
setIconClassName('codicon-close'); setIcon('close');
}); });
}, [value]); }, [value]);
return <span title={description ? description : 'Copy'} className={`copy-icon codicon ${iconClassName}`} onClick={handleCopy}/>; return <ToolbarButton title={description ? description : 'Copy'} icon={icon} onClick={handleCopy}/>;
}; };

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

@ -29,7 +29,8 @@ const eventsSymbol = Symbol('events');
export type SourceLocation = { export type SourceLocation = {
file: string; file: string;
line: number; line: number;
source: SourceModel; column: number;
source?: SourceModel;
}; };
export type SourceModel = { export type SourceModel = {
@ -408,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

@ -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: ${ <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,8 +116,7 @@ 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 });
} }
} }
}; };
@ -131,10 +127,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 +160,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

@ -28,6 +28,10 @@
background-color: var(--vscode-sideBar-background); background-color: var(--vscode-sideBar-background);
} }
.snapshot-tab .toolbar .pick-locator {
margin: 0 4px;
}
.snapshot-controls { .snapshot-controls {
flex: none; flex: none;
background-color: var(--vscode-sideBar-background); background-color: var(--vscode-sideBar-background);
@ -102,29 +106,6 @@ iframe.snapshot-visible[name=snapshot] {
padding: 50px; padding: 50px;
} }
.popout-icon {
position: absolute;
top: 0;
right: 0;
color: var(--vscode-sideBarTitle-foreground);
font-size: 14px;
z-index: 100;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
.popout-icon:not(.popout-disabled):hover {
color: var(--vscode-foreground);
}
.popout-icon.popout-disabled {
opacity: var(--vscode-disabledForeground);
}
.snapshot-tab .cm-wrapper { .snapshot-tab .cm-wrapper {
line-height: 23px; line-height: 23px;
margin-right: 4px; margin-right: 4px;

View file

@ -181,6 +181,7 @@ export const SnapshotTab: React.FunctionComponent<{
iframe={iframeRef1.current} iframe={iframeRef1.current}
iteration={loadingRef.current.iteration} /> iteration={loadingRef.current.iteration} />
<Toolbar> <Toolbar>
<ToolbarButton className='pick-locator' title='Pick locator' icon='target' toggled={isInspecting} onClick={() => setIsInspecting(!isInspecting)} />
{['action', 'before', 'after'].map(tab => { {['action', 'before', 'after'].map(tab => {
return <TabbedPaneTab return <TabbedPaneTab
id={tab} id={tab}

View file

@ -23,21 +23,9 @@
} }
.source-tab-file-name { .source-tab-file-name {
height: 24px; padding-left: 8px;
margin-left: 8px; height: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
background-color: var(--vscode-breadcrumb-background); flex: 1 1 auto;
box-shadow: var(--vscode-scrollbar-shadow) 0 6px 6px -6px;
z-index: 10;
} }
.source-tab-file-name .copy-icon.codicon {
display: block;
cursor: pointer;
}
.source-copy-to-clipboard {
display: block;
padding-left: 4px;
}

View file

@ -24,6 +24,8 @@ import type { SourceHighlight } from '@web/components/codeMirrorWrapper';
import type { SourceLocation, SourceModel } from './modelUtil'; import type { SourceLocation, SourceModel } from './modelUtil';
import type { StackFrame } from '@protocol/channels'; import type { StackFrame } from '@protocol/channels';
import { CopyToClipboard } from './copyToClipboard'; import { CopyToClipboard } from './copyToClipboard';
import { ToolbarButton } from '@web/components/toolbarButton';
import { Toolbar } from '@web/components/toolbar';
export const SourceTab: React.FunctionComponent<{ export const SourceTab: React.FunctionComponent<{
stack: StackFrame[] | undefined, stack: StackFrame[] | undefined,
@ -31,7 +33,8 @@ export const SourceTab: React.FunctionComponent<{
sources: Map<string, SourceModel>, sources: Map<string, SourceModel>,
rootDir?: string, rootDir?: string,
fallbackLocation?: SourceLocation, fallbackLocation?: SourceLocation,
}> = ({ stack, sources, rootDir, fallbackLocation, stackFrameLocation }) => { onOpenExternally?: (location: SourceLocation) => void,
}> = ({ stack, sources, rootDir, fallbackLocation, stackFrameLocation, onOpenExternally }) => {
const [lastStack, setLastStack] = React.useState<StackFrame[] | undefined>(); const [lastStack, setLastStack] = React.useState<StackFrame[] | undefined>();
const [selectedFrame, setSelectedFrame] = React.useState<number>(0); const [selectedFrame, setSelectedFrame] = React.useState<number>(0);
@ -42,7 +45,7 @@ export const SourceTab: React.FunctionComponent<{
} }
}, [stack, lastStack, setLastStack, setSelectedFrame]); }, [stack, lastStack, setLastStack, setSelectedFrame]);
const { source, highlight, targetLine, fileName } = useAsyncMemo<{ source: SourceModel, targetLine?: number, fileName?: string, highlight: SourceHighlight[] }>(async () => { const { source, highlight, targetLine, fileName, location } = useAsyncMemo<{ source: SourceModel, targetLine?: number, fileName?: string, highlight: SourceHighlight[], location?: SourceLocation }>(async () => {
const actionLocation = stack?.[selectedFrame]; const actionLocation = stack?.[selectedFrame];
const shouldUseFallback = !actionLocation?.file; const shouldUseFallback = !actionLocation?.file;
if (shouldUseFallback && !fallbackLocation) if (shouldUseFallback && !fallbackLocation)
@ -56,6 +59,7 @@ export const SourceTab: React.FunctionComponent<{
sources.set(file, source); sources.set(file, source);
} }
const location = shouldUseFallback ? fallbackLocation! : actionLocation;
const targetLine = shouldUseFallback ? fallbackLocation?.line || source.errors[0]?.line || 0 : actionLocation.line; const targetLine = shouldUseFallback ? fallbackLocation?.line || source.errors[0]?.line || 0 : actionLocation.line;
const fileName = rootDir && file.startsWith(rootDir) ? file.substring(rootDir.length + 1) : file; const fileName = rootDir && file.startsWith(rootDir) ? file.substring(rootDir.length + 1) : file;
const highlight: SourceHighlight[] = source.errors.map(e => ({ type: 'error', line: e.line, message: e.message })); const highlight: SourceHighlight[] = source.errors.map(e => ({ type: 'error', line: e.line, message: e.message }));
@ -76,21 +80,29 @@ export const SourceTab: React.FunctionComponent<{
source.content = `<Unable to read "${file}">`; source.content = `<Unable to read "${file}">`;
} }
} }
return { source, highlight, targetLine, fileName }; return { source, highlight, targetLine, fileName, location };
}, [stack, selectedFrame, rootDir, fallbackLocation], { source: { errors: [], content: 'Loading\u2026' }, highlight: [] }); }, [stack, selectedFrame, rootDir, fallbackLocation], { source: { errors: [], content: 'Loading\u2026' }, highlight: [] });
const openExternally = React.useCallback(() => {
if (!location)
return;
if (onOpenExternally) {
onOpenExternally(location);
} else {
// This should open an external protocol handler instead of actually navigating away.
window.location.href = `vscode://file//${location.file}:${location.line}`;
}
}, [onOpenExternally, location]);
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 sidebarSize={200} orientation={stackFrameLocation === 'bottom' ? 'vertical' : 'horizontal'} sidebarHidden={!showStackFrames}>
<div className='vbox' data-testid='source-code'> <div className='vbox' data-testid='source-code'>
{fileName && ( { fileName && <Toolbar>
<div className='source-tab-file-name'> <span className='source-tab-file-name'>{fileName}</span>
{fileName} <CopyToClipboard description='Copy filename' value={getFileName(fileName)}/>
<span className='source-copy-to-clipboard'> {location && <ToolbarButton icon='link-external' title='Open in VS Code' onClick={openExternally}></ToolbarButton>}
<CopyToClipboard description='Copy filename' value={getFileName(fileName, targetLine)}/> </Toolbar> }
</span>
</div>
)}
<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} /> <StackTraceView stack={stack} selectedFrame={selectedFrame} setSelectedFrame={setSelectedFrame} />
@ -109,10 +121,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

@ -47,8 +47,9 @@ export const TestListView: React.FC<{
isLoading?: boolean, isLoading?: boolean,
onItemSelected: (item: { treeItem?: TreeItem, testCase?: reporterTypes.TestCase, testFile?: SourceLocation }) => void, onItemSelected: (item: { treeItem?: TreeItem, testCase?: reporterTypes.TestCase, testFile?: SourceLocation }) => void,
requestedCollapseAllCount: number, requestedCollapseAllCount: number,
setFilterText: (text: string) => void; setFilterText: (text: string) => void,
}> = ({ filterText, testModel, testServerConnection, testTree, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, requestedCollapseAllCount, setFilterText }) => { onRevealSource: () => void,
}> = ({ filterText, testModel, testServerConnection, testTree, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, requestedCollapseAllCount, setFilterText, onRevealSource }) => {
const [treeState, setTreeState] = React.useState<TreeState>({ expandedItems: new Map() }); const [treeState, setTreeState] = React.useState<TreeState>({ expandedItems: new Map() });
const [selectedTreeItemId, setSelectedTreeItemId] = React.useState<string | undefined>(); const [selectedTreeItemId, setSelectedTreeItemId] = React.useState<string | undefined>();
const [collapseAllCount, setCollapseAllCount] = React.useState(requestedCollapseAllCount); const [collapseAllCount, setCollapseAllCount] = React.useState(requestedCollapseAllCount);
@ -91,17 +92,7 @@ export const TestListView: React.FC<{
if (!testModel) if (!testModel)
return { selectedTreeItem: undefined }; return { selectedTreeItem: undefined };
const selectedTreeItem = selectedTreeItemId ? testTree.treeItemById(selectedTreeItemId) : undefined; const selectedTreeItem = selectedTreeItemId ? testTree.treeItemById(selectedTreeItemId) : undefined;
let testFile: SourceLocation | undefined; const testFile = itemLocation(selectedTreeItem, testModel);
if (selectedTreeItem) {
testFile = {
file: selectedTreeItem.location.file,
line: selectedTreeItem.location.line,
source: {
errors: testModel.loadErrors.filter(e => e.location?.file === selectedTreeItem.location.file).map(e => ({ line: e.location!.line, message: e.message! })),
content: undefined,
}
};
}
let selectedTest: reporterTypes.TestCase | undefined; let selectedTest: reporterTypes.TestCase | undefined;
if (selectedTreeItem?.kind === 'test') if (selectedTreeItem?.kind === 'test')
selectedTest = selectedTreeItem.test; selectedTest = selectedTreeItem.test;
@ -164,7 +155,7 @@ export const TestListView: React.FC<{
{!!treeItem.duration && treeItem.status !== 'skipped' && <div className='ui-mode-list-item-time'>{msToString(treeItem.duration)}</div>} {!!treeItem.duration && treeItem.status !== 'skipped' && <div className='ui-mode-list-item-time'>{msToString(treeItem.duration)}</div>}
<Toolbar noMinHeight={true} noShadow={true}> <Toolbar noMinHeight={true} noShadow={true}>
<ToolbarButton icon='play' title='Run' onClick={() => runTreeItem(treeItem)} disabled={!!runningState}></ToolbarButton> <ToolbarButton icon='play' title='Run' onClick={() => runTreeItem(treeItem)} disabled={!!runningState}></ToolbarButton>
<ToolbarButton icon='go-to-file' title='Open in VS Code' onClick={() => testServerConnection?.openNoReply({ location: treeItem.location })} style={(treeItem.kind === 'group' && treeItem.subKind === 'folder') ? { visibility: 'hidden' } : {}}></ToolbarButton> <ToolbarButton icon='go-to-file' title='Show source' onClick={onRevealSource} style={(treeItem.kind === 'group' && treeItem.subKind === 'folder') ? { visibility: 'hidden' } : {}}></ToolbarButton>
{!watchAll && <ToolbarButton icon='eye' title='Watch' onClick={() => { {!watchAll && <ToolbarButton icon='eye' title='Watch' onClick={() => {
if (watchedTreeIds.value.has(treeItem.id)) if (watchedTreeIds.value.has(treeItem.id))
watchedTreeIds.value.delete(treeItem.id); watchedTreeIds.value.delete(treeItem.id);
@ -187,3 +178,17 @@ export const TestListView: React.FC<{
autoExpandDepth={filterText ? 5 : 1} autoExpandDepth={filterText ? 5 : 1}
noItemsMessage={isLoading ? 'Loading\u2026' : 'No tests'} />; noItemsMessage={isLoading ? 'Loading\u2026' : 'No tests'} />;
}; };
function itemLocation(item: TreeItem | undefined, model: TestModel | undefined): SourceLocation | undefined {
if (!item || !model)
return;
return {
file: item.location.file,
line: item.location.line,
column: item.location.column,
source: {
errors: model.loadErrors.filter(e => e.location?.file === item.location.file).map(e => ({ line: e.location!.line, message: e.message! })),
content: undefined,
}
};
}

View file

@ -31,7 +31,9 @@ export const TraceView: React.FC<{
showRouteActionsSetting: Setting<boolean>, showRouteActionsSetting: Setting<boolean>,
item: { treeItem?: TreeItem, testFile?: SourceLocation, testCase?: reporterTypes.TestCase }, item: { treeItem?: TreeItem, testFile?: SourceLocation, testCase?: reporterTypes.TestCase },
rootDir?: string, rootDir?: string,
}> = ({ showRouteActionsSetting, item, rootDir }) => { onOpenExternally?: (location: SourceLocation) => void,
revealSource?: boolean,
}> = ({ showRouteActionsSetting, 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);
@ -97,7 +99,11 @@ export const TraceView: React.FC<{
onSelectionChanged={onSelectionChanged} onSelectionChanged={onSelectionChanged}
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}
revealSource={revealSource}
/>;
}; };
const outputDirForTestCase = (testCase: reporterTypes.TestCase): string | undefined => { const outputDirForTestCase = (testCase: reporterTypes.TestCase): string | undefined => {

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

@ -96,6 +96,9 @@ export const UIModeView: React.FC<{}> = ({
const [testServerConnection, setTestServerConnection] = React.useState<TestServerConnection>(); const [testServerConnection, setTestServerConnection] = React.useState<TestServerConnection>();
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 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(() => {
@ -435,7 +438,13 @@ export const UIModeView: React.FC<{}> = ({
<XtermWrapper source={xtermDataSource}></XtermWrapper> <XtermWrapper source={xtermDataSource}></XtermWrapper>
</div> </div>
<div className={'vbox' + (isShowingOutput ? ' hidden' : '')}> <div className={'vbox' + (isShowingOutput ? ' hidden' : '')}>
<TraceView showRouteActionsSetting={showRouteActionsSetting} item={selectedItem} rootDir={testModel?.config?.rootDir} /> <TraceView
showRouteActionsSetting={showRouteActionsSetting}
item={selectedItem}
rootDir={testModel?.config?.rootDir}
revealSource={revealSource}
onOpenExternally={location => testServerConnection?.openNoReply({ location: { file: location.file, line: location.line, column: location.column } })}
/>
</div> </div>
</div> </div>
<div className='vbox ui-mode-sidebar'> <div className='vbox ui-mode-sidebar'>
@ -487,20 +496,23 @@ export const UIModeView: React.FC<{}> = ({
isLoading={isLoading} isLoading={isLoading}
requestedCollapseAllCount={collapseAllCount} requestedCollapseAllCount={collapseAllCount}
setFilterText={setFilterText} setFilterText={setFilterText}
onRevealSource={onRevealSource}
/> />
<Toolbar noShadow={true} noMinHeight={true} className='settings-toolbar' onClick={() => setTestingOptionsVisible(!testingOptionsVisible)}> {showTestingOptions && <>
<span <Toolbar noShadow={true} noMinHeight={true} className='settings-toolbar' onClick={() => setTestingOptionsVisible(!testingOptionsVisible)}>
className={`codicon codicon-${testingOptionsVisible ? 'chevron-down' : 'chevron-right'}`} <span
style={{ marginLeft: 5 }} className={`codicon codicon-${testingOptionsVisible ? 'chevron-down' : 'chevron-right'}`}
title={testingOptionsVisible ? 'Hide Testing Options' : 'Show Testing Options'} style={{ marginLeft: 5 }}
/> title={testingOptionsVisible ? 'Hide Testing Options' : 'Show Testing Options'}
<div className='section-title'>Testing Options</div> />
</Toolbar> <div className='section-title'>Testing Options</div>
{testingOptionsVisible && <SettingsView settings={[ </Toolbar>
singleWorkerSetting, {testingOptionsVisible && <SettingsView settings={[
showBrowserSetting, singleWorkerSetting,
updateSnapshotsSetting, showBrowserSetting,
]} />} 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'}`}

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,6 +33,7 @@ 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';
@ -44,26 +45,29 @@ 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>, showRouteActionsSetting?: Setting<boolean>,
openPage?: (url: string, target?: string) => Window | any, openPage?: (url: string, target?: string) => Window | any,
}> = ({ showRouteActionsSetting, model, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, status, inert, openPage }) => { onOpenExternally?: (location: modelUtil.SourceLocation) => void,
const [selectedAction, setSelectedActionImpl] = React.useState<ActionTraceEventInContext | undefined>(undefined); revealSource?: boolean,
}> = ({ showRouteActionsSetting, model, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, status, annotations, inert, openPage, onOpenExternally, revealSource }) => {
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');
const [selectedPropertiesTab, setSelectedPropertiesTab] = useSetting<string>('propertiesTab', showSourcesFirst ? 'source' : 'call'); const [selectedPropertiesTab, setSelectedPropertiesTab] = useSetting<string>('propertiesTab', showSourcesFirst ? 'source' : 'call');
const [isInspecting, setIsInspecting] = React.useState(false); const [isInspecting, setIsInspectingState] = React.useState(false);
const [highlightedLocator, setHighlightedLocator] = React.useState<string>(''); const [highlightedLocator, setHighlightedLocator] = React.useState<string>('');
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>();
@ -75,10 +79,10 @@ export const Workbench: React.FunctionComponent<{
const showRouteActions = showRouteActionsSetting[0]; 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]);
@ -87,6 +91,7 @@ export const Workbench: React.FunctionComponent<{
React.useEffect(() => { React.useEffect(() => {
setSelectedTime(undefined); setSelectedTime(undefined);
setRevealedStack(undefined);
}, [model]); }, [model]);
React.useEffect(() => { React.useEffect(() => {
@ -110,7 +115,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]);
@ -118,14 +123,25 @@ export const Workbench: React.FunctionComponent<{
const selectPropertiesTab = React.useCallback((tab: string) => { const selectPropertiesTab = React.useCallback((tab: string) => {
setSelectedPropertiesTab(tab); setSelectedPropertiesTab(tab);
if (tab !== 'inspector') if (tab !== 'inspector')
setIsInspecting(false); setIsInspectingState(false);
}, [setSelectedPropertiesTab]); }, [setSelectedPropertiesTab]);
const setIsInspecting = React.useCallback((value: boolean) => {
if (!isInspecting && value)
selectPropertiesTab('inspector');
setIsInspectingState(value);
}, [setIsInspectingState, selectPropertiesTab, isInspecting]);
const locatorPicked = React.useCallback((locator: string) => { const locatorPicked = React.useCallback((locator: string) => {
setHighlightedLocator(locator); setHighlightedLocator(locator);
selectPropertiesTab('inspector'); selectPropertiesTab('inspector');
}, [selectPropertiesTab]); }, [selectPropertiesTab]);
React.useEffect(() => {
if (revealSource)
selectPropertiesTab('source');
}, [revealSource, selectPropertiesTab]);
const consoleModel = useConsoleTabModel(model, selectedTime); const consoleModel = useConsoleTabModel(model, selectedTime);
const networkModel = useNetworkTabModel(model, selectedTime); const networkModel = useNetworkTabModel(model, selectedTime);
const errorsModel = useErrorsTabModel(model); const errorsModel = useErrorsTabModel(model);
@ -174,7 +190,9 @@ export const Workbench: React.FunctionComponent<{
sources={sources} sources={sources}
rootDir={rootDir} rootDir={rootDir}
stackFrameLocation={sidebarLocation === 'bottom' ? 'right' : 'bottom'} stackFrameLocation={sidebarLocation === 'bottom' ? 'right' : 'bottom'}
fallbackLocation={fallbackLocation} /> fallbackLocation={fallbackLocation}
onOpenExternally={onOpenExternally}
/>
}; };
const consoleTab: TabbedPaneTabModel = { const consoleTab: TabbedPaneTabModel = {
id: 'console', id: 'console',
@ -211,6 +229,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);
@ -302,13 +331,6 @@ export const Workbench: React.FunctionComponent<{
tabs={tabs} tabs={tabs}
selectedTab={selectedPropertiesTab} selectedTab={selectedPropertiesTab}
setSelectedTab={selectPropertiesTab} setSelectedTab={selectPropertiesTab}
leftToolbar={[
<ToolbarButton title='Pick locator' icon='target' toggled={isInspecting} onClick={() => {
if (!isInspecting)
selectPropertiesTab('inspector');
setIsInspecting(!isInspecting);
}} />
]}
rightToolbar={[ rightToolbar={[
sidebarLocation === 'bottom' ? sidebarLocation === 'bottom' ?
<ToolbarButton title='Dock to right' icon='layout-sidebar-right-off' onClick={() => { <ToolbarButton title='Dock to right' icon='layout-sidebar-right-off' onClick={() => {

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

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

@ -62,14 +62,10 @@ export const TabbedPane: React.FunctionComponent<{
}}> }}>
{tabs.map(tab => { {tabs.map(tab => {
let suffix = ''; let suffix = '';
if (tab.count === 1) if (tab.count)
suffix = ' 🔵'; suffix = ` (${tab.count})`;
else if (tab.count) if (tab.errorCount)
suffix = ` 🔵✖️${tab.count}`; suffix = ` (${tab.errorCount})`;
if (tab.errorCount === 1)
suffix = ` 🔴`;
else if (tab.errorCount)
suffix = ` 🔴✖️${tab.errorCount}`;
return <option value={tab.id} selected={tab.id === selectedTab}>{tab.title}{suffix}</option>; return <option value={tab.id} selected={tab.id === selectedTab}>{tab.title}{suffix}</option>;
})} })}
</select> </select>

View file

@ -26,6 +26,7 @@ export interface ToolbarButtonProps {
onClick: (e: React.MouseEvent) => void, onClick: (e: React.MouseEvent) => void,
style?: React.CSSProperties, style?: React.CSSProperties,
testId?: string, testId?: string,
className?: string,
} }
export const ToolbarButton: React.FC<React.PropsWithChildren<ToolbarButtonProps>> = ({ export const ToolbarButton: React.FC<React.PropsWithChildren<ToolbarButtonProps>> = ({
@ -37,8 +38,9 @@ export const ToolbarButton: React.FC<React.PropsWithChildren<ToolbarButtonProps>
onClick = () => {}, onClick = () => {},
style, style,
testId, testId,
className,
}) => { }) => {
let className = `toolbar-button ${icon}`; className = (className || '') + ` toolbar-button ${icon}`;
if (toggled) if (toggled)
className += ' toggled'; className += ' toggled';
return <button return <button

View file

@ -14,15 +14,14 @@
* limitations under the License. * limitations under the License.
*/ */
export function linkifyText(description: string) { import { kWebLinkRe } from './uiUtils';
const CONTROL_CODES = '\\u0000-\\u0020\\u007f-\\u009f';
const WEB_LINK_REGEX = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|www\\.)[^\\s' + CONTROL_CODES + '"]{2,}[^\\s' + CONTROL_CODES + '"\')}\\],:;.!?]', 'ug');
export function linkifyText(description: string) {
const result = []; const result = [];
let currentIndex = 0; let currentIndex = 0;
let match; let match;
while ((match = WEB_LINK_REGEX.exec(description)) !== null) { while ((match = kWebLinkRe.exec(description)) !== null) {
const stringBeforeMatch = description.substring(currentIndex, match.index); const stringBeforeMatch = description.substring(currentIndex, match.index);
if (stringBeforeMatch) if (stringBeforeMatch)
result.push(stringBeforeMatch); result.push(stringBeforeMatch);

View file

@ -59,8 +59,9 @@ const checkerboardStyle: React.CSSProperties = {
}; };
export const ImageDiffView: React.FC<{ export const ImageDiffView: React.FC<{
diff: ImageDiff, diff: ImageDiff,
}> = ({ diff }) => { noTargetBlank?: boolean,
}> = ({ diff, noTargetBlank }) => {
const [mode, setMode] = React.useState<'diff' | 'actual' | 'expected' | 'slider' | 'sxs'>(diff.diff ? 'diff' : 'actual'); const [mode, setMode] = React.useState<'diff' | 'actual' | 'expected' | 'slider' | 'sxs'>(diff.diff ? 'diff' : 'actual');
const [showSxsDiff, setShowSxsDiff] = React.useState<boolean>(false); const [showSxsDiff, setShowSxsDiff] = React.useState<boolean>(false);
@ -117,10 +118,10 @@ export const ImageDiffView: React.FC<{
<ImageWithSize image={actualImage} title='Actual' canvasWidth={sxsScale * imageWidth} canvasHeight={sxsScale * imageHeight} scale={sxsScale} /> <ImageWithSize image={actualImage} title='Actual' canvasWidth={sxsScale * imageWidth} canvasHeight={sxsScale * imageHeight} scale={sxsScale} />
</div>} </div>}
</div> </div>
<div style={{ alignSelf: 'start', lineHeight: '18px' }}> <div style={{ alignSelf: 'start', lineHeight: '18px', marginLeft: '15px' }}>
<div>{diff.diff && <a target='_blank' href={diff.diff.attachment.path}>{diff.diff.attachment.name}</a>}</div> <div>{diff.diff && <a target='_blank' href={diff.diff.attachment.path}>{diff.diff.attachment.name}</a>}</div>
<div><a target='_blank' href={diff.actual!.attachment.path}>{diff.actual!.attachment.name}</a></div> <div><a target={noTargetBlank ? '' : '_blank'} href={diff.actual!.attachment.path}>{diff.actual!.attachment.name}</a></div>
<div><a target='_blank' href={diff.expected!.attachment.path}>{diff.expected!.attachment.name}</a></div> <div><a target={noTargetBlank ? '' : '_blank'} href={diff.expected!.attachment.path}>{diff.expected!.attachment.name}</a></div>
</div> </div>
</>} </>}
</div>; </div>;

View file

@ -183,3 +183,6 @@ export class Settings {
} }
export const settings = new Settings(); export const settings = new Settings();
const kControlCodesRe = '\\u0000-\\u0020\\u007f-\\u009f';
export const kWebLinkRe = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|www\\.)[^\\s' + kControlCodesRe + '"]{2,}[^\\s' + kControlCodesRe + '"\')}\\],:;.!?]', 'ug');

View file

@ -36,6 +36,8 @@ openssl x509 \
-out client/trusted/cert.pem \ -out client/trusted/cert.pem \
-set_serial 01 \ -set_serial 01 \
-days 365 -days 365
# create pfx
openssl pkcs12 -export -out client/trusted/cert.pfx -inkey client/trusted/key.pem -in client/trusted/cert.pem -passout pass:secure
``` ```
## Self-signed certificate (invalid) ## Self-signed certificate (invalid)

View file

@ -58,7 +58,7 @@ class TraceViewerPage {
this.stackFrames = page.getByTestId('stack-trace-list').locator('.list-view-entry'); this.stackFrames = page.getByTestId('stack-trace-list').locator('.list-view-entry');
this.networkRequests = page.getByTestId('network-list').locator('.list-view-entry'); this.networkRequests = page.getByTestId('network-list').locator('.list-view-entry');
this.snapshotContainer = page.locator('.snapshot-container iframe.snapshot-visible[name=snapshot]'); this.snapshotContainer = page.locator('.snapshot-container iframe.snapshot-visible[name=snapshot]');
this.metadataTab = page.locator('.metadata-view'); this.metadataTab = page.getByTestId('metadata-view');
} }
async actionIconsText(action: string) { async actionIconsText(action: string) {

View file

@ -1288,7 +1288,7 @@ it('should not work after context dispose', async ({ context, server }) => {
expect(await context.request.get(server.EMPTY_PAGE).catch(e => e.message)).toContain('Test ended.'); expect(await context.request.get(server.EMPTY_PAGE).catch(e => e.message)).toContain('Test ended.');
}); });
it('should retrty ECONNRESET', { it('should retry on ECONNRESET', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30978' } annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30978' }
}, async ({ context, server }) => { }, async ({ context, server }) => {
let requestCount = 0; let requestCount = 0;

View file

@ -15,6 +15,7 @@
*/ */
import fs from 'fs'; import fs from 'fs';
import tls from 'tls';
import type http2 from 'http2'; import type http2 from 'http2';
import type http from 'http'; import type http from 'http';
import { expect, playwrightTest as base } from '../config/browserTest'; import { expect, playwrightTest as base } from '../config/browserTest';
@ -24,6 +25,7 @@ const { createHttpsServer, createHttp2Server } = require('../../packages/playwri
type TestOptions = { type TestOptions = {
startCCServer(options?: { startCCServer(options?: {
host?: string;
http2?: boolean; http2?: boolean;
enableHTTP1FallbackWhenUsingHttp2?: boolean; enableHTTP1FallbackWhenUsingHttp2?: boolean;
useFakeLocalhost?: boolean; useFakeLocalhost?: boolean;
@ -63,7 +65,7 @@ const test = base.extend<TestOptions>({
} }
res.end(parts.map(({ key, value }) => `<div data-testid="${key}">${value}</div>`).join('')); res.end(parts.map(({ key, value }) => `<div data-testid="${key}">${value}</div>`).join(''));
}); });
await new Promise<void>(f => server.listen(0, 'localhost', () => f())); await new Promise<void>(f => server.listen(0, options?.host ?? 'localhost', () => f()));
const host = options?.useFakeLocalhost ? 'local.playwright' : 'localhost'; const host = options?.useFakeLocalhost ? 'local.playwright' : 'localhost';
return `https://${host}:${(server.address() as net.AddressInfo).port}/`; return `https://${host}:${(server.address() as net.AddressInfo).port}/`;
}); });
@ -81,8 +83,6 @@ test.use({
} }
}); });
test.skip(({ mode }) => mode !== 'default');
const kDummyFileName = __filename; const kDummyFileName = __filename;
const kValidationSubTests: [BrowserContextOptions, string][] = [ const kValidationSubTests: [BrowserContextOptions, string][] = [
[{ clientCertificates: [{ origin: 'test' }] }, 'None of cert, key, passphrase or pfx is specified'], [{ clientCertificates: [{ origin: 'test' }] }, 'None of cert, key, passphrase or pfx is specified'],
@ -113,7 +113,7 @@ test.describe('fetch', () => {
test('should fail with no client certificates provided', async ({ playwright, startCCServer }) => { test('should fail with no client certificates provided', async ({ playwright, startCCServer }) => {
const serverURL = await startCCServer(); const serverURL = await startCCServer();
const request = await playwright.request.newContext(); const request = await playwright.request.newContext({ ignoreHTTPSErrors: true });
const response = await request.get(serverURL); const response = await request.get(serverURL);
expect(response.status()).toBe(401); expect(response.status()).toBe(401);
expect(await response.text()).toContain('Sorry, but you need to provide a client certificate to continue.'); expect(await response.text()).toContain('Sorry, but you need to provide a client certificate to continue.');
@ -122,6 +122,7 @@ test.describe('fetch', () => {
test('should keep supporting http', async ({ playwright, server, asset }) => { test('should keep supporting http', async ({ playwright, server, asset }) => {
const request = await playwright.request.newContext({ const request = await playwright.request.newContext({
ignoreHTTPSErrors: true,
clientCertificates: [{ clientCertificates: [{
origin: new URL(server.PREFIX).origin, origin: new URL(server.PREFIX).origin,
certPath: asset('client-certificates/client/trusted/cert.pem'), certPath: asset('client-certificates/client/trusted/cert.pem'),
@ -138,6 +139,7 @@ test.describe('fetch', () => {
test('should throw with untrusted client certs', async ({ playwright, startCCServer, asset }) => { test('should throw with untrusted client certs', async ({ playwright, startCCServer, asset }) => {
const serverURL = await startCCServer(); const serverURL = await startCCServer();
const request = await playwright.request.newContext({ const request = await playwright.request.newContext({
ignoreHTTPSErrors: true,
clientCertificates: [{ clientCertificates: [{
origin: new URL(serverURL).origin, origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/self-signed/cert.pem'), certPath: asset('client-certificates/client/self-signed/cert.pem'),
@ -154,6 +156,7 @@ test.describe('fetch', () => {
test('pass with trusted client certificates', async ({ playwright, startCCServer, asset }) => { test('pass with trusted client certificates', async ({ playwright, startCCServer, asset }) => {
const serverURL = await startCCServer(); const serverURL = await startCCServer();
const request = await playwright.request.newContext({ const request = await playwright.request.newContext({
ignoreHTTPSErrors: true,
clientCertificates: [{ clientCertificates: [{
origin: new URL(serverURL).origin, origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/trusted/cert.pem'), certPath: asset('client-certificates/client/trusted/cert.pem'),
@ -167,9 +170,55 @@ test.describe('fetch', () => {
await request.dispose(); await request.dispose();
}); });
test('pass with trusted client certificates in pfx format', async ({ playwright, startCCServer, asset }) => {
const serverURL = await startCCServer();
const request = await playwright.request.newContext({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
pfxPath: asset('client-certificates/client/trusted/cert.pfx'),
passphrase: 'secure'
}],
});
const response = await request.get(serverURL);
expect(response.url()).toBe(serverURL);
expect(response.status()).toBe(200);
expect(await response.text()).toContain('Hello Alice, your certificate was issued by localhost!');
await request.dispose();
});
test('should throw a http error if the pfx passphrase is incorect', async ({ playwright, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer();
const request = await playwright.request.newContext({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
pfxPath: asset('client-certificates/client/trusted/cert.pfx'),
passphrase: 'this-password-is-incorrect'
}],
});
await expect(request.get(serverURL)).rejects.toThrow('mac verify failure');
await request.dispose();
});
test('should fail with matching certificates in legacy pfx format', async ({ playwright, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer();
const request = await playwright.request.newContext({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
pfxPath: asset('client-certificates/client/trusted/cert-legacy.pfx'),
passphrase: 'secure'
}],
});
await expect(request.get(serverURL)).rejects.toThrow('Unsupported TLS certificate');
await request.dispose();
});
test('should work in the browser with request interception', async ({ browser, playwright, startCCServer, asset }) => { test('should work in the browser with request interception', async ({ browser, playwright, startCCServer, asset }) => {
const serverURL = await startCCServer(); const serverURL = await startCCServer();
const request = await playwright.request.newContext({ const request = await playwright.request.newContext({
ignoreHTTPSErrors: true,
clientCertificates: [{ clientCertificates: [{
origin: new URL(serverURL).origin, origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/trusted/cert.pem'), certPath: asset('client-certificates/client/trusted/cert.pem'),
@ -212,6 +261,7 @@ test.describe('browser', () => {
test('should fail with no client certificates', async ({ browser, startCCServer, asset, browserName }) => { test('should fail with no client certificates', async ({ browser, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
const page = await browser.newPage({ const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{ clientCertificates: [{
origin: 'https://not-matching.com', origin: 'https://not-matching.com',
certPath: asset('client-certificates/client/trusted/cert.pem'), certPath: asset('client-certificates/client/trusted/cert.pem'),
@ -226,6 +276,7 @@ test.describe('browser', () => {
test('should fail with self-signed client certificates', async ({ browser, startCCServer, asset, browserName }) => { test('should fail with self-signed client certificates', async ({ browser, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
const page = await browser.newPage({ const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{ clientCertificates: [{
origin: new URL(serverURL).origin, origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/self-signed/cert.pem'), certPath: asset('client-certificates/client/self-signed/cert.pem'),
@ -240,6 +291,7 @@ test.describe('browser', () => {
test('should pass with matching certificates', async ({ browser, startCCServer, asset, browserName }) => { test('should pass with matching certificates', async ({ browser, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
const page = await browser.newPage({ const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{ clientCertificates: [{
origin: new URL(serverURL).origin, origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/trusted/cert.pem'), certPath: asset('client-certificates/client/trusted/cert.pem'),
@ -251,9 +303,120 @@ test.describe('browser', () => {
await page.close(); await page.close();
}); });
test('should not hang on tls errors during TLS 1.2 handshake', async ({ browser, asset, platform, browserName }) => {
for (const tlsVersion of ['TLSv1.3', 'TLSv1.2'] as const) {
await test.step(`TLS version: ${tlsVersion}`, async () => {
const server = tls.createServer({
key: fs.readFileSync(asset('client-certificates/server/server_key.pem')),
cert: fs.readFileSync(asset('client-certificates/server/server_cert.pem')),
ca: [
fs.readFileSync(asset('client-certificates/server/server_cert.pem')),
],
requestCert: true,
rejectUnauthorized: true,
minVersion: tlsVersion,
maxVersion: tlsVersion,
SNICallback: (servername, cb) => {
// Always reject the connection by passing an error
cb(new Error('Connection rejected'), null);
}
}, () => {
// Do nothing
});
const serverURL = await new Promise<string>(resolve => {
server.listen(0, 'localhost', () => {
const host = browserName === 'webkit' && platform === 'darwin' ? 'local.playwright' : 'localhost';
resolve(`https://${host}:${(server.address() as net.AddressInfo).port}/`);
});
});
const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/self-signed/cert.pem'),
keyPath: asset('client-certificates/client/self-signed/key.pem'),
}],
});
await page.goto(serverURL);
await expect(page.getByText('Playwright client-certificate error: Client network socket disconnected before secure TLS connection was established')).toBeVisible();
await page.close();
await new Promise<void>(resolve => server.close(() => resolve()));
});
}
});
test('should pass with matching certificates in pfx format', async ({ browser, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
pfxPath: asset('client-certificates/client/trusted/cert.pfx'),
passphrase: 'secure'
}],
});
await page.goto(serverURL);
await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!');
await page.close();
});
test('should fail with matching certificates in legacy pfx format', async ({ browser, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
pfxPath: asset('client-certificates/client/trusted/cert-legacy.pfx'),
passphrase: 'secure'
}],
});
await page.goto(serverURL);
await expect(page.getByText('Unsupported TLS certificate.')).toBeVisible();
await page.close();
});
test('should throw a http error if the pfx passphrase is incorect', async ({ browser, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
pfxPath: asset('client-certificates/client/trusted/cert.pfx'),
passphrase: 'this-password-is-incorrect'
}],
});
await page.goto(serverURL);
await expect(page.getByText('Playwright client-certificate error: mac verify failure')).toBeVisible();
await page.close();
});
test('should pass with matching certificates on context APIRequestContext instance', async ({ browser, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ host: '127.0.0.1' });
const baseOptions = {
certPath: asset('client-certificates/client/trusted/cert.pem'),
keyPath: asset('client-certificates/client/trusted/key.pem'),
};
const page = await browser.newPage({
clientCertificates: [{
origin: new URL(serverURL).origin,
...baseOptions,
}, {
origin: new URL(serverURL).origin.replace('localhost', '127.0.0.1'),
...baseOptions,
}],
});
for (const url of [serverURL, serverURL.replace('localhost', '127.0.0.1')]) {
const response = await page.request.get(url);
expect(response.status()).toBe(200);
expect(await response.text()).toContain('Hello Alice, your certificate was issued by localhost!');
}
await page.close();
});
test('should pass with matching certificates and trailing slash', async ({ browser, startCCServer, asset, browserName }) => { test('should pass with matching certificates and trailing slash', async ({ browser, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
const page = await browser.newPage({ const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{ clientCertificates: [{
origin: serverURL, origin: serverURL,
certPath: asset('client-certificates/client/trusted/cert.pem'), certPath: asset('client-certificates/client/trusted/cert.pem'),
@ -274,7 +437,7 @@ test.describe('browser', () => {
}], }],
}); });
await page.goto(browserName === 'webkit' && platform === 'darwin' ? httpsServer.EMPTY_PAGE.replace('localhost', 'local.playwright') : httpsServer.EMPTY_PAGE); await page.goto(browserName === 'webkit' && platform === 'darwin' ? httpsServer.EMPTY_PAGE.replace('localhost', 'local.playwright') : httpsServer.EMPTY_PAGE);
await expect(page.getByText('Playwright client-certificate error')).toBeVisible(); await expect(page.getByText('Playwright client-certificate error: self-signed certificate')).toBeVisible();
await page.close(); await page.close();
}); });
@ -283,6 +446,7 @@ test.describe('browser', () => {
const enableHTTP1FallbackWhenUsingHttp2 = browserName === 'webkit' && process.platform === 'linux'; const enableHTTP1FallbackWhenUsingHttp2 = browserName === 'webkit' && process.platform === 'linux';
const serverURL = await startCCServer({ http2: true, enableHTTP1FallbackWhenUsingHttp2 }); const serverURL = await startCCServer({ http2: true, enableHTTP1FallbackWhenUsingHttp2 });
const page = await browser.newPage({ const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{ clientCertificates: [{
origin: new URL(serverURL).origin, origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/trusted/cert.pem'), certPath: asset('client-certificates/client/trusted/cert.pem'),
@ -311,6 +475,7 @@ test.describe('browser', () => {
const serverURL = await startCCServer({ http2: true, enableHTTP1FallbackWhenUsingHttp2: true }); const serverURL = await startCCServer({ http2: true, enableHTTP1FallbackWhenUsingHttp2: true });
const browser = await browserType.launch({ args: ['--disable-http2'] }); const browser = await browserType.launch({ args: ['--disable-http2'] });
const page = await browser.newPage({ const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{ clientCertificates: [{
origin: new URL(serverURL).origin, origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/trusted/cert.pem'), certPath: asset('client-certificates/client/trusted/cert.pem'),
@ -335,7 +500,6 @@ test.describe('browser', () => {
test.fixme(browserName === 'webkit' && process.platform === 'linux', 'WebKit on Linux does not support http2 https://bugs.webkit.org/show_bug.cgi?id=276990'); test.fixme(browserName === 'webkit' && process.platform === 'linux', 'WebKit on Linux does not support http2 https://bugs.webkit.org/show_bug.cgi?id=276990');
test.skip(+process.versions.node.split('.')[0] < 20, 'http2.performServerHandshake is not supported in older Node.js versions'); test.skip(+process.versions.node.split('.')[0] < 20, 'http2.performServerHandshake is not supported in older Node.js versions');
process.env.PWTEST_UNSUPPORTED_CUSTOM_CA = asset('empty.html');
const serverURL = await startCCServer({ http2: true }); const serverURL = await startCCServer({ http2: true });
const page = await browser.newPage({ const page = await browser.newPage({
clientCertificates: [{ clientCertificates: [{
@ -359,6 +523,7 @@ test.describe('browser', () => {
test('should pass with matching certificates', async ({ launchPersistent, startCCServer, asset, browserName }) => { test('should pass with matching certificates', async ({ launchPersistent, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
const { page } = await launchPersistent({ const { page } = await launchPersistent({
ignoreHTTPSErrors: true,
clientCertificates: [{ clientCertificates: [{
origin: new URL(serverURL).origin, origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/trusted/cert.pem'), certPath: asset('client-certificates/client/trusted/cert.pem'),

View file

@ -17,9 +17,15 @@
import os from 'os'; import os from 'os';
import * as util from 'util'; import * as util from 'util';
import { getPlaywrightVersion } from '../../packages/playwright-core/lib/utils/userAgent'; import { getPlaywrightVersion } from '../../packages/playwright-core/lib/utils/userAgent';
import { expect, playwrightTest as it } from '../config/browserTest'; import { expect, playwrightTest as base } from '../config/browserTest';
import { kTargetClosedErrorMessage } from 'tests/config/errors'; import { kTargetClosedErrorMessage } from 'tests/config/errors';
const it = base.extend({
context: async ({}, use) => {
throw new Error('global fetch tests should not use context');
}
});
it.skip(({ mode }) => mode !== 'default'); it.skip(({ mode }) => mode !== 'default');
for (const method of ['fetch', 'delete', 'get', 'head', 'patch', 'post', 'put'] as const) { for (const method of ['fetch', 'delete', 'get', 'head', 'patch', 'post', 'put'] as const) {
@ -33,9 +39,11 @@ for (const method of ['fetch', 'delete', 'get', 'head', 'patch', 'post', 'put']
expect(response.headers()['content-type']).toBe('application/json; charset=utf-8'); expect(response.headers()['content-type']).toBe('application/json; charset=utf-8');
expect(response.headersArray()).toContainEqual({ name: 'Content-Type', value: 'application/json; charset=utf-8' }); expect(response.headersArray()).toContainEqual({ name: 'Content-Type', value: 'application/json; charset=utf-8' });
expect(await response.text()).toBe('head' === method ? '' : '{"foo": "bar"}\n'); expect(await response.text()).toBe('head' === method ? '' : '{"foo": "bar"}\n');
await request.dispose();
}); });
} }
it(`should dispose global request`, async function({ playwright, server }) { it(`should dispose global request`, async function({ playwright, server }) {
const request = await playwright.request.newContext(); const request = await playwright.request.newContext();
const response = await request.get(server.PREFIX + '/simple.json'); const response = await request.get(server.PREFIX + '/simple.json');
@ -43,6 +51,7 @@ it(`should dispose global request`, async function({ playwright, server }) {
await request.dispose(); await request.dispose();
const error = await response.body().catch(e => e); const error = await response.body().catch(e => e);
expect(error.message).toContain('Response has been disposed'); expect(error.message).toContain('Response has been disposed');
await request.dispose();
}); });
it('should support global userAgent option', async ({ playwright, server }) => { it('should support global userAgent option', async ({ playwright, server }) => {
@ -54,6 +63,7 @@ it('should support global userAgent option', async ({ playwright, server }) => {
expect(response.ok()).toBeTruthy(); expect(response.ok()).toBeTruthy();
expect(response.url()).toBe(server.EMPTY_PAGE); expect(response.url()).toBe(server.EMPTY_PAGE);
expect(serverRequest.headers['user-agent']).toBe('My Agent'); expect(serverRequest.headers['user-agent']).toBe('My Agent');
await request.dispose();
}); });
it('should support global timeout option', async ({ playwright, server }) => { it('should support global timeout option', async ({ playwright, server }) => {
@ -61,6 +71,7 @@ it('should support global timeout option', async ({ playwright, server }) => {
server.setRoute('/empty.html', (req, res) => {}); server.setRoute('/empty.html', (req, res) => {});
const error = await request.get(server.EMPTY_PAGE).catch(e => e); const error = await request.get(server.EMPTY_PAGE).catch(e => e);
expect(error.message).toContain('Request timed out after 100ms'); expect(error.message).toContain('Request timed out after 100ms');
await request.dispose();
}); });
it('should propagate extra http headers with redirects', async ({ playwright, server }) => { it('should propagate extra http headers with redirects', async ({ playwright, server }) => {
@ -76,6 +87,7 @@ it('should propagate extra http headers with redirects', async ({ playwright, se
expect(req1.headers['my-secret']).toBe('Value'); expect(req1.headers['my-secret']).toBe('Value');
expect(req2.headers['my-secret']).toBe('Value'); expect(req2.headers['my-secret']).toBe('Value');
expect(req3.headers['my-secret']).toBe('Value'); expect(req3.headers['my-secret']).toBe('Value');
await request.dispose();
}); });
it('should support global httpCredentials option', async ({ playwright, server }) => { it('should support global httpCredentials option', async ({ playwright, server }) => {
@ -96,6 +108,7 @@ it('should return error with wrong credentials', async ({ playwright, server })
const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'wrong' } }); const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'wrong' } });
const response = await request.get(server.EMPTY_PAGE); const response = await request.get(server.EMPTY_PAGE);
expect(response.status()).toBe(401); expect(response.status()).toBe(401);
await request.dispose();
}); });
it('should work with correct credentials and matching origin', async ({ playwright, server }) => { it('should work with correct credentials and matching origin', async ({ playwright, server }) => {
@ -103,6 +116,7 @@ it('should work with correct credentials and matching origin', async ({ playwrig
const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX } }); const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX } });
const response = await request.get(server.EMPTY_PAGE); const response = await request.get(server.EMPTY_PAGE);
expect(response.status()).toBe(200); expect(response.status()).toBe(200);
await request.dispose();
}); });
it('should work with correct credentials and matching origin case insensitive', async ({ playwright, server }) => { it('should work with correct credentials and matching origin case insensitive', async ({ playwright, server }) => {
@ -110,6 +124,7 @@ it('should work with correct credentials and matching origin case insensitive',
const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX.toUpperCase() } }); const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX.toUpperCase() } });
const response = await request.get(server.EMPTY_PAGE); const response = await request.get(server.EMPTY_PAGE);
expect(response.status()).toBe(200); expect(response.status()).toBe(200);
await request.dispose();
}); });
it('should return error with correct credentials and mismatching scheme', async ({ playwright, server }) => { it('should return error with correct credentials and mismatching scheme', async ({ playwright, server }) => {
@ -117,6 +132,7 @@ it('should return error with correct credentials and mismatching scheme', async
const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX.replace('http://', 'https://') } }); const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX.replace('http://', 'https://') } });
const response = await request.get(server.EMPTY_PAGE); const response = await request.get(server.EMPTY_PAGE);
expect(response.status()).toBe(401); expect(response.status()).toBe(401);
await request.dispose();
}); });
it('should return error with correct credentials and mismatching hostname', async ({ playwright, server }) => { it('should return error with correct credentials and mismatching hostname', async ({ playwright, server }) => {
@ -126,6 +142,7 @@ it('should return error with correct credentials and mismatching hostname', asyn
const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: origin } }); const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: origin } });
const response = await request.get(server.EMPTY_PAGE); const response = await request.get(server.EMPTY_PAGE);
expect(response.status()).toBe(401); expect(response.status()).toBe(401);
await request.dispose();
}); });
it('should return error with correct credentials and mismatching port', async ({ playwright, server }) => { it('should return error with correct credentials and mismatching port', async ({ playwright, server }) => {
@ -134,6 +151,7 @@ it('should return error with correct credentials and mismatching port', async ({
const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: origin } }); const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: origin } });
const response = await request.get(server.EMPTY_PAGE); const response = await request.get(server.EMPTY_PAGE);
expect(response.status()).toBe(401); expect(response.status()).toBe(401);
await request.dispose();
}); });
it('should support WWW-Authenticate: Basic', async ({ playwright, server }) => { it('should support WWW-Authenticate: Basic', async ({ playwright, server }) => {
@ -152,6 +170,7 @@ it('should support WWW-Authenticate: Basic', async ({ playwright, server }) => {
const response = await request.get(server.EMPTY_PAGE); const response = await request.get(server.EMPTY_PAGE);
expect(response.status()).toBe(200); expect(response.status()).toBe(200);
expect(credentials).toBe('user:pass'); expect(credentials).toBe('user:pass');
await request.dispose();
}); });
it('should support HTTPCredentials.send', async ({ playwright, server }) => { it('should support HTTPCredentials.send', async ({ playwright, server }) => {
@ -176,12 +195,14 @@ it('should support HTTPCredentials.send', async ({ playwright, server }) => {
expect(serverRequest.headers.authorization).toBe(undefined); expect(serverRequest.headers.authorization).toBe(undefined);
expect(response.status()).toBe(200); expect(response.status()).toBe(200);
} }
await request.dispose();
}); });
it('should support global ignoreHTTPSErrors option', async ({ playwright, httpsServer }) => { it('should support global ignoreHTTPSErrors option', async ({ playwright, httpsServer }) => {
const request = await playwright.request.newContext({ ignoreHTTPSErrors: true }); const request = await playwright.request.newContext({ ignoreHTTPSErrors: true });
const response = await request.get(httpsServer.EMPTY_PAGE); const response = await request.get(httpsServer.EMPTY_PAGE);
expect(response.status()).toBe(200); expect(response.status()).toBe(200);
await request.dispose();
}); });
it('should propagate ignoreHTTPSErrors on redirects', async ({ playwright, httpsServer }) => { it('should propagate ignoreHTTPSErrors on redirects', async ({ playwright, httpsServer }) => {
@ -189,12 +210,14 @@ it('should propagate ignoreHTTPSErrors on redirects', async ({ playwright, https
const request = await playwright.request.newContext(); const request = await playwright.request.newContext();
const response = await request.get(httpsServer.PREFIX + '/redir', { ignoreHTTPSErrors: true }); const response = await request.get(httpsServer.PREFIX + '/redir', { ignoreHTTPSErrors: true });
expect(response.status()).toBe(200); expect(response.status()).toBe(200);
await request.dispose();
}); });
it('should resolve url relative to global baseURL option', async ({ playwright, server }) => { it('should resolve url relative to global baseURL option', async ({ playwright, server }) => {
const request = await playwright.request.newContext({ baseURL: server.PREFIX }); const request = await playwright.request.newContext({ baseURL: server.PREFIX });
const response = await request.get('/empty.html'); const response = await request.get('/empty.html');
expect(response.url()).toBe(server.EMPTY_PAGE); expect(response.url()).toBe(server.EMPTY_PAGE);
await request.dispose();
}); });
it('should set playwright as user-agent', async ({ playwright, server, isWindows, isLinux, isMac }) => { it('should set playwright as user-agent', async ({ playwright, server, isWindows, isLinux, isMac }) => {
@ -221,12 +244,14 @@ it('should set playwright as user-agent', async ({ playwright, server, isWindows
expect(userAgentMasked.replace(/<ARCH>; \w+ [^)]+/, '<ARCH>; distro version')).toBe('Playwright/X.X.X (<ARCH>; distro version) node/X.X' + suffix); expect(userAgentMasked.replace(/<ARCH>; \w+ [^)]+/, '<ARCH>; distro version')).toBe('Playwright/X.X.X (<ARCH>; distro version) node/X.X' + suffix);
else if (isMac) else if (isMac)
expect(userAgentMasked).toBe('Playwright/X.X.X (<ARCH>; macOS X.X) node/X.X' + suffix); expect(userAgentMasked).toBe('Playwright/X.X.X (<ARCH>; macOS X.X) node/X.X' + suffix);
await request.dispose();
}); });
it('should be able to construct with context options', async ({ playwright, browserType, server }) => { it('should be able to construct with context options', async ({ playwright, browserType, server }) => {
const request = await playwright.request.newContext((browserType as any)._defaultContextOptions); const request = await playwright.request.newContext((browserType as any)._defaultContextOptions);
const response = await request.get(server.EMPTY_PAGE); const response = await request.get(server.EMPTY_PAGE);
expect(response.ok()).toBeTruthy(); expect(response.ok()).toBeTruthy();
await request.dispose();
}); });
it('should return empty body', async ({ playwright, server }) => { it('should return empty body', async ({ playwright, server }) => {
@ -254,6 +279,7 @@ it('should abort requests when context is disposed', async ({ playwright, server
expect(result.message).toContain(kTargetClosedErrorMessage); expect(result.message).toContain(kTargetClosedErrorMessage);
} }
await connectionClosed; await connectionClosed;
await request.dispose();
}); });
it('should abort redirected requests when context is disposed', async ({ playwright, server }) => { it('should abort redirected requests when context is disposed', async ({ playwright, server }) => {
@ -269,6 +295,7 @@ it('should abort redirected requests when context is disposed', async ({ playwri
expect(result instanceof Error).toBeTruthy(); expect(result instanceof Error).toBeTruthy();
expect(result.message).toContain(kTargetClosedErrorMessage); expect(result.message).toContain(kTargetClosedErrorMessage);
await connectionClosed; await connectionClosed;
await request.dispose();
}); });
it('should remove content-length from redirected post requests', async ({ playwright, server }) => { it('should remove content-length from redirected post requests', async ({ playwright, server }) => {
@ -473,7 +500,6 @@ it('should serialize post data on the client', async ({ playwright, server }) =>
await postReq; await postReq;
const body = await (await serverReq).postBody; const body = await (await serverReq).postBody;
expect(body.toString()).toBe('{"foo":"bar"}'); expect(body.toString()).toBe('{"foo":"bar"}');
// expect(serverRequest.rawHeaders).toContain('vaLUE');
await request.dispose(); await request.dispose();
}); });
@ -486,7 +512,8 @@ it('should throw after dispose', async ({ playwright, server }) => {
it('should retry ECONNRESET', { it('should retry ECONNRESET', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30978' } annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30978' }
}, async ({ context, server }) => { }, async ({ playwright, server }) => {
const request = await playwright.request.newContext();
let requestCount = 0; let requestCount = 0;
server.setRoute('/test', (req, res) => { server.setRoute('/test', (req, res) => {
if (requestCount++ < 3) { if (requestCount++ < 3) {
@ -496,8 +523,9 @@ it('should retry ECONNRESET', {
res.writeHead(200, { 'content-type': 'text/plain' }); res.writeHead(200, { 'content-type': 'text/plain' });
res.end('Hello!'); res.end('Hello!');
}); });
const response = await context.request.fetch(server.PREFIX + '/test', { maxRetries: 3 }); const response = await request.fetch(server.PREFIX + '/test', { maxRetries: 3 });
expect(response.status()).toBe(200); expect(response.status()).toBe(200);
expect(await response.text()).toBe('Hello!'); expect(await response.text()).toBe('Hello!');
expect(requestCount).toBe(4); expect(requestCount).toBe(4);
await request.dispose();
}); });

View file

@ -804,7 +804,7 @@ test('should follow redirects', async ({ page, runAndTrace, server, asset }) =>
test('should include metainfo', async ({ showTraceViewer }) => { test('should include metainfo', async ({ showTraceViewer }) => {
const traceViewer = await showTraceViewer([traceFile]); const traceViewer = await showTraceViewer([traceFile]);
await traceViewer.page.locator('text=Metadata').click(); await traceViewer.page.locator('text=Metadata').click();
const callLine = traceViewer.page.locator('.metadata-view .call-line'); const callLine = traceViewer.metadataTab.locator('.call-line');
await expect(callLine.getByText('start time')).toHaveText(/start time:[\d/,: ]+/); await expect(callLine.getByText('start time')).toHaveText(/start time:[\d/,: ]+/);
await expect(callLine.getByText('duration')).toHaveText(/duration:[\dms]+/); await expect(callLine.getByText('duration')).toHaveText(/duration:[\dms]+/);
await expect(callLine.getByText('engine')).toHaveText(/engine:[\w]+/); await expect(callLine.getByText('engine')).toHaveText(/engine:[\w]+/);
@ -1357,7 +1357,6 @@ test('should allow hiding route actions', {
await traceViewer.page.getByRole('checkbox', { name: 'Show route actions' }).uncheck(); await traceViewer.page.getByRole('checkbox', { name: 'Show route actions' }).uncheck();
await traceViewer.page.getByText('Actions', { exact: true }).click(); await traceViewer.page.getByText('Actions', { exact: true }).click();
await expect(traceViewer.actionTitles).toHaveText([ await expect(traceViewer.actionTitles).toHaveText([
/page.route/,
/page.goto.*empty.html/, /page.goto.*empty.html/,
]); ]);

View file

@ -364,4 +364,53 @@ test('UI mode is not supported', async ({ runInlineTest }) => {
const result = await runInlineTest({}, { 'only-changed': true, 'ui': true }); const result = await runInlineTest({}, { 'only-changed': true, 'ui': true });
expect(result.exitCode).toBe(1); expect(result.exitCode).toBe(1);
expect(result.output).toContain('--only-changed is not supported in UI mode'); expect(result.output).toContain('--only-changed is not supported in UI mode');
});
test('should run project dependencies of changed tests', {
annotation: {
type: 'issue',
description: 'https://github.com/microsoft/playwright/issues/32070',
},
}, async ({ runInlineTest, git, writeFiles }) => {
await writeFiles({
'playwright.config.ts': `
module.exports = {
projects: [
{ name: 'setup', testMatch: 'setup.spec.ts', },
{ name: 'main', dependencies: ['setup'] },
],
};
`,
'setup.spec.ts': `
import { test, expect } from '@playwright/test';
test('setup test', async ({ page }) => {
console.log('setup test is executed')
});
`,
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('fails', () => { expect(1).toBe(2); });
`,
'b.spec.ts': `
import { test, expect } from '@playwright/test';
test('fails', () => { expect(1).toBe(2); });
`,
});
git(`add .`);
git(`commit -m init`);
const result = await runInlineTest({
'c.spec.ts': `
import { test, expect } from '@playwright/test';
test('fails', () => { expect(1).toBe(2); });
`
}, { 'only-changed': true });
expect(result.exitCode).toBe(1);
expect(result.failed).toBe(1);
expect(result.passed).toBe(1);
expect(result.output).toContain('setup test is executed');
}); });

View file

@ -1178,3 +1178,51 @@ test('should record trace for manually created context in a failed test', async
// Check console events to make sure that library trace is recorded. // Check console events to make sure that library trace is recorded.
expect(trace.events).toContainEqual(expect.objectContaining({ type: 'console', text: 'from the page' })); expect(trace.events).toContainEqual(expect.objectContaining({ type: 'console', text: 'from the page' }));
}); });
test('should not nest top level expect into unfinished api calls ', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/31959' }
}, async ({ runInlineTest, server }) => {
server.setRoute('/index', (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`<script>fetch('/api')</script><div>Hello!</div>`);
});
server.setRoute('/hang', () => {});
const result = await runInlineTest({
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('pass', async ({ page }) => {
await page.route('**/api', async route => {
const response = await route.fetch({ url: '${server.PREFIX}/hang' });
await route.fulfill({ response });
});
await page.goto('${server.PREFIX}/index');
await expect(page.getByText('Hello!')).toBeVisible();
await page.unrouteAll({ behavior: 'ignoreErrors' });
});
`,
}, { trace: 'on' });
expect(result.exitCode).toBe(0);
expect(result.failed).toBe(0);
const tracePath = test.info().outputPath('test-results', 'a-pass', 'trace.zip');
const trace = await parseTrace(tracePath);
expect(trace.actionTree).toEqual([
'Before Hooks',
' fixture: browser',
' browserType.launch',
' fixture: context',
' browser.newContext',
' fixture: page',
' browserContext.newPage',
'page.route',
'page.goto',
' route.fetch',
' page.unrouteAll',
'expect.toBeVisible',
'After Hooks',
' fixture: page',
' fixture: context',
]);
});

View file

@ -83,7 +83,7 @@ for (const useIntermediateMergeReport of [false] as const) {
await expect(page.getByTestId('overall-duration'), 'should contain humanized total time with at most 1 decimal place').toContainText(/^Total time: \d+(\.\d)?(ms|s|m)$/); await expect(page.getByTestId('overall-duration'), 'should contain humanized total time with at most 1 decimal place').toContainText(/^Total time: \d+(\.\d)?(ms|s|m)$/);
await expect(page.getByTestId('project-name'), 'should contain project name').toContainText('project-name'); await expect(page.getByTestId('project-name'), 'should contain project name').toContainText('project-name');
await expect(page.locator('.metadata-view')).not.toBeVisible(); await expect(page.getByTestId('metadata-view')).not.toBeVisible();
}); });
test('should allow navigating to testId=test.id', async ({ runInlineTest, page, showReport }) => { test('should allow navigating to testId=test.id', async ({ runInlineTest, page, showReport }) => {

View file

@ -19,7 +19,7 @@ import { test, expect } from './playwright-test-fixtures';
test('should check types of fixtures', async ({ runTSC }) => { test('should check types of fixtures', async ({ runTSC }) => {
const result = await runTSC({ const result = await runTSC({
'helper.ts': ` 'helper.ts': `
import { test as base, expect } from '@playwright/test'; import { test as base, expect, Page } from '@playwright/test';
export type MyOptions = { foo: string, bar: number }; export type MyOptions = { foo: string, bar: number };
export const test = base.extend<{ foo: string }, { bar: number }>({ export const test = base.extend<{ foo: string }, { bar: number }>({
foo: 'foo', foo: 'foo',
@ -71,7 +71,7 @@ test('should check types of fixtures', async ({ runTSC }) => {
// @ts-expect-error // @ts-expect-error
baz: true, baz: true,
}); });
const fail9 = test.extend<{ foo: string }>({ const fail9 = test.extend({
foo: [ async ({}, use) => { foo: [ async ({}, use) => {
await use('foo'); await use('foo');
// @ts-expect-error // @ts-expect-error
@ -100,7 +100,21 @@ test('should check types of fixtures', async ({ runTSC }) => {
return y; return y;
}); });
}, },
}) });
const chain1 = base.extend({
page: async ({ page }, use) => {
await use(page);
},
});
const chain2 = chain1.extend<{ pageAsUser: Page }>({
pageAsUser: async ({ page }, use) => {
// @ts-expect-error
const x: number = page;
// @ts-expect-error
await use(x);
},
});
`, `,
'playwright.config.ts': ` 'playwright.config.ts': `
import { MyOptions } from './helper'; import { MyOptions } from './helper';

View file

@ -23,7 +23,10 @@ test('should contain text attachment', async ({ runUITest }) => {
'a.test.ts': ` 'a.test.ts': `
import { test } from '@playwright/test'; import { test } from '@playwright/test';
test('attach test', async () => { test('attach test', async () => {
// Attach two files with the same content and different names,
// to make sure each is downloaded with an intended name.
await test.info().attach('file attachment', { path: __filename }); await test.info().attach('file attachment', { path: __filename });
await test.info().attach('file attachment 2', { path: __filename });
await test.info().attach('text attachment', { body: 'hi tester!', contentType: 'text/plain' }); await test.info().attach('text attachment', { body: 'hi tester!', contentType: 'text/plain' });
}); });
`, `,
@ -35,14 +38,24 @@ test('should contain text attachment', async ({ runUITest }) => {
await page.locator('.tab-attachments').getByText('text attachment').click(); await page.locator('.tab-attachments').getByText('text attachment').click();
await expect(page.locator('.tab-attachments')).toContainText('hi tester!'); await expect(page.locator('.tab-attachments')).toContainText('hi tester!');
await page.locator('.tab-attachments').getByText('file attachment').click(); await page.locator('.tab-attachments').getByText('file attachment').first().click();
await expect(page.locator('.tab-attachments')).not.toContainText('attach test'); await expect(page.locator('.tab-attachments')).not.toContainText('attach test');
const downloadPromise = page.waitForEvent('download'); {
await page.getByRole('link', { name: 'download' }).first().click(); const downloadPromise = page.waitForEvent('download');
const download = await downloadPromise; await page.getByRole('link', { name: 'download' }).first().click();
expect(download.suggestedFilename()).toBe('file attachment'); const download = await downloadPromise;
expect((await readAllFromStream(await download.createReadStream())).toString()).toContain('attach test'); expect(download.suggestedFilename()).toBe('file attachment');
expect((await readAllFromStream(await download.createReadStream())).toString()).toContain('attach test');
}
{
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'download' }).nth(1).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('file attachment 2');
expect((await readAllFromStream(await download.createReadStream())).toString()).toContain('attach test');
}
}); });
test('should contain binary attachment', async ({ runUITest }) => { test('should contain binary attachment', async ({ runUITest }) => {
@ -86,6 +99,55 @@ test('should contain string attachment', async ({ runUITest }) => {
expect((await readAllFromStream(await download.createReadStream())).toString()).toEqual('text42'); expect((await readAllFromStream(await download.createReadStream())).toString()).toEqual('text42');
}); });
test('should linkify string attachments', async ({ runUITest, server }) => {
server.setRoute('/one.html', (req, res) => res.end());
server.setRoute('/two.html', (req, res) => res.end());
server.setRoute('/three.html', (req, res) => res.end());
const { page } = await runUITest({
'a.test.ts': `
import { test } from '@playwright/test';
test('attach test', async () => {
await test.info().attach('Inline url: ${server.PREFIX + '/one.html'}');
await test.info().attach('Second', { body: 'Inline link ${server.PREFIX + '/two.html'} to be highlighted.' });
await test.info().attach('Third', { body: '[markdown link](${server.PREFIX + '/three.html'})', contentType: 'text/markdown' });
});
`,
});
await page.getByText('attach test').click();
await page.getByTitle('Run all').click();
await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)');
await page.getByText('Attachments').click();
const attachmentsPane = page.locator('.attachments-tab');
{
const url = server.PREFIX + '/one.html';
const promise = page.waitForEvent('popup');
await attachmentsPane.getByText(url).click();
const popup = await promise;
await expect(popup).toHaveURL(url);
}
{
await attachmentsPane.getByText('Second download').click();
const url = server.PREFIX + '/two.html';
const promise = page.waitForEvent('popup');
await attachmentsPane.getByText(url).click();
const popup = await promise;
await expect(popup).toHaveURL(url);
}
{
await attachmentsPane.getByText('Third download').click();
const url = server.PREFIX + '/three.html';
const promise = page.waitForEvent('popup');
await attachmentsPane.getByText('[markdown link]').click();
const popup = await promise;
await expect(popup).toHaveURL(url);
}
});
function readAllFromStream(stream: NodeJS.ReadableStream): Promise<Buffer> { function readAllFromStream(stream: NodeJS.ReadableStream): Promise<Buffer> {
return new Promise(resolve => { return new Promise(resolve => {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];

View file

@ -217,7 +217,8 @@ test('should update test locations', async ({ runUITest, writeFiles }) => {
const passesItemLocator = page.getByRole('listitem').filter({ hasText: 'passes' }); const passesItemLocator = page.getByRole('listitem').filter({ hasText: 'passes' });
await passesItemLocator.hover(); await passesItemLocator.hover();
await passesItemLocator.getByTitle('Open in VS Code').click(); await passesItemLocator.getByTitle('Show source').click();
await page.getByTitle('Open in VS Code').click();
expect(messages).toEqual([{ expect(messages).toEqual([{
method: 'open', method: 'open',
@ -247,7 +248,8 @@ test('should update test locations', async ({ runUITest, writeFiles }) => {
messages.length = 0; messages.length = 0;
await passesItemLocator.hover(); await passesItemLocator.hover();
await passesItemLocator.getByTitle('Open in VS Code').click(); await passesItemLocator.getByTitle('Show source').click();
await page.getByTitle('Open in VS Code').click();
expect(messages).toEqual([{ expect(messages).toEqual([{
method: 'open', method: 'open',

View file

@ -400,10 +400,18 @@ function generateNameDefault(member, name, t, parent) {
if (names[2] === names[1]) if (names[2] === names[1])
names.pop(); // get rid of duplicates, cheaply names.pop(); // get rid of duplicates, cheaply
let attemptedName = names.pop(); let attemptedName = names.pop();
const typesDiffer = function(left, right) { const typesDiffer = function(/** @type {Documentation.Type} */ left, /** @type {Documentation.Type} */ right) {
if (left.expression && right.expression) if (left.expression && right.expression)
return left.expression !== right.expression; return left.expression !== right.expression;
return JSON.stringify(right.properties) !== JSON.stringify(left.properties); const toExpression = (/** @type {Documentation.Member} */ t) => t.name + t.type?.expression;
const leftOverRightProperties = new Set(left.properties?.map(toExpression) ?? []);
for (const prop of right.properties ?? []) {
const expression = toExpression(prop);
if (!leftOverRightProperties.has(expression))
return true;
leftOverRightProperties.delete(expression);
}
return leftOverRightProperties.size > 0;
}; };
while (true) { while (true) {
// crude attempt at removing plurality // crude attempt at removing plurality

View file

@ -9,7 +9,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis" Version="4.6.0" /> <PackageReference Include="Microsoft.CodeAnalysis" Version="4.10.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -144,9 +144,9 @@ export type Fixtures<T extends KeyValue = {}, W extends KeyValue = {}, PT extend
} & { } & {
[K in keyof PT]?: TestFixtureValue<PT[K], T & W & PT & PW> | [TestFixtureValue<PT[K], T & W & PT & PW>, { scope: 'test', timeout?: number | undefined, title?: string, box?: boolean }]; [K in keyof PT]?: TestFixtureValue<PT[K], T & W & PT & PW> | [TestFixtureValue<PT[K], T & W & PT & PW>, { scope: 'test', timeout?: number | undefined, title?: string, box?: boolean }];
} & { } & {
[K in Exclude<keyof W, keyof PW>]?: [WorkerFixtureValue<W[K], W & PW>, { scope: 'worker', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean }]; [K in keyof W]?: [WorkerFixtureValue<W[K], W & PW>, { scope: 'worker', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean }];
} & { } & {
[K in Exclude<keyof T, keyof PT>]?: TestFixtureValue<T[K], T & W & PT & PW> | [TestFixtureValue<T[K], T & W & PT & PW>, { scope?: 'test', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean }]; [K in keyof T]?: TestFixtureValue<T[K], T & W & PT & PW> | [TestFixtureValue<T[K], T & W & PT & PW>, { scope?: 'test', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean }];
}; };
type BrowserName = 'chromium' | 'firefox' | 'webkit'; type BrowserName = 'chromium' | 'firefox' | 'webkit';