Compare commits

...

26 commits

Author SHA1 Message Date
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
83 changed files with 1098 additions and 421 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

View file

@ -4,6 +4,45 @@ title: "Release notes"
toc_max_heading_level: 2 toc_max_heading_level: 2
--- ---
## Version 1.46
### TLS Client Certificates
Playwright now allows to supply client-side certificates, so that server can verify them, as specified by TLS Client Authentication.
You can provide client certificates as a parameter of [`method: Browser.newContext`] and [`method: APIRequest.newContext`]. The following snippet sets up a client certificate for `https://example.com`:
```java
BrowserContext context = browser.newContext(new Browser.NewContextOptions()
.setClientCertificates(asList(new ClientCertificate("https://example.com")
.setCertPath(Paths.get("client-certificates/cert.pem"))
.setKeyPath(Paths.get("client-certificates/key.pem")))));
```
### Trace Viewer Updates
- Content of text attachments is now rendered inline in the attachments pane.
- New setting to show/hide routing actions like [`method: Route.continue`].
- Request method and status are shown in the network details tab.
- New button to copy source file location to clipboard.
- Metadata pane now displays the `baseURL`.
### Miscellaneous
- New `maxRetries` option in [`method: APIRequestContext.fetch`] which retries on the `ECONNRESET` network error.
### Browser Versions
- Chromium 128.0.6613.18
- Mozilla Firefox 128.0
- WebKit 18.0
This version was also tested against the following stable channels:
- Google Chrome 127
- Microsoft Edge 127
## Version 1.45 ## Version 1.45
### Clock ### Clock

View file

@ -33,6 +33,18 @@ export default defineConfig({
You can also provide client certificates to a particular [test project](./api/class-testproject#test-project-use) or as a parameter of [`method: Browser.newContext`] and [`method: APIRequest.newContext`]. You can also provide client certificates to a particular [test project](./api/class-testproject#test-project-use) or as a parameter of [`method: Browser.newContext`] and [`method: APIRequest.newContext`].
### `--only-changed` cli option
New CLI option `--only-changed` allows to only run test files that have been changed since the last git commit or from a specific git "ref".
```sh
# Only run test files with uncommitted changes
npx playwright test --only-changed
# Only run test files changed relative to the "main" branch
npx playwrigh test --only-changed=main
```
### Component Testing: New `router` fixture ### Component Testing: New `router` fixture
This release introduces an experimental `router` fixture to intercept and handle network requests in component testing. This release introduces an experimental `router` fixture to intercept and handle network requests in component testing.
@ -59,29 +71,24 @@ test('example test', async ({ mount }) => {
This fixture is only available in [component tests](./test-components#handling-network-requests). This fixture is only available in [component tests](./test-components#handling-network-requests).
### Test runner
- New CLI option `--only-changed` to only run test files that have been changed since the last commit or from a specific git "ref".
- New option to [box a fixture](./test-fixtures#box-fixtures) to minimize the fixture exposure in test reports and error messages.
- New option to provide a [custom fixture title](./test-fixtures#custom-fixture-title) to be used in test reports and error messages.
### UI Mode / Trace Viewer Updates ### UI Mode / Trace Viewer Updates
- New testing options pane in the UI mode to control test execution, for example "single worker" or "headed browser". - Test annotations are now shown in UI mode.
- New setting to show/hide routing actions like `route.continue`. - Content of text attachments is now rendered inline in the attachments pane.
- New setting to show/hide routing actions like [`method: Route.continue`].
- Request method and status are shown in the network details tab. - Request method and status are shown in the network details tab.
- New button to copy source file location to clipboard. - New button to copy source file location to clipboard.
- Content of text attachments is now rendered inline in the attachments pane.
- Metadata pane now displays the `baseURL`. - Metadata pane now displays the `baseURL`.
### Miscellaneous ### Miscellaneous
- New `maxRetries` option in [`method: APIRequestContext.fetch`] which retries on the `ECONNRESET` network error. - New `maxRetries` option in [`method: APIRequestContext.fetch`] which retries on the `ECONNRESET` network error.
- Improved link rendering inside annotations and attachments in the html report. - New option to [box a fixture](./test-fixtures#box-fixtures) to minimize the fixture exposure in test reports and error messages.
- New option to provide a [custom fixture title](./test-fixtures#custom-fixture-title) to be used in test reports and error messages.
### Browser Versions ### Browser Versions
- Chromium 128.0.6613.7 - Chromium 128.0.6613.18
- Mozilla Firefox 128.0 - Mozilla Firefox 128.0
- WebKit 18.0 - WebKit 18.0

View file

@ -4,6 +4,50 @@ title: "Release notes"
toc_max_heading_level: 2 toc_max_heading_level: 2
--- ---
## Version 1.46
### TLS Client Certificates
Playwright now allows to supply client-side certificates, so that server can verify them, as specified by TLS Client Authentication.
You can provide client certificates as a parameter of [`method: Browser.newContext`] and [`method: APIRequest.newContext`]. The following snippet sets up a client certificate for `https://example.com`:
```python
context = browser.new_context(
client_certificates=[
{
"origin": "https://example.com",
"certPath": "client-certificates/cert.pem",
"keyPath": "client-certificates/key.pem",
}
],
)
```
### Trace Viewer Updates
- Content of text attachments is now rendered inline in the attachments pane.
- New setting to show/hide routing actions like [`method: Route.continue`].
- Request method and status are shown in the network details tab.
- New button to copy source file location to clipboard.
- Metadata pane now displays the `base_url`.
### Miscellaneous
- New `maxRetries` option in [`method: APIRequestContext.fetch`] which retries on the `ECONNRESET` network error.
### Browser Versions
- Chromium 128.0.6613.18
- Mozilla Firefox 128.0
- WebKit 18.0
This version was also tested against the following stable channels:
- Google Chrome 127
- Microsoft Edge 127
## Version 1.45 ## Version 1.45
### Clock ### Clock

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.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "playwright-internal", "name": "playwright-internal",
"version": "1.46.0-next", "version": "1.46.0",
"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.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.46.0-next" "playwright-core": "1.46.0"
}, },
"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.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.46.0-next" "playwright-core": "1.46.0"
}, },
"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.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.46.0-next" "playwright-core": "1.46.0"
}, },
"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.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.46.0-next" "playwright-core": "1.46.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"packages/playwright-chromium": { "packages/playwright-chromium": {
"version": "1.46.0-next", "version": "1.46.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.46.0-next" "playwright-core": "1.46.0"
}, },
"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.0",
"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.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.46.0-next", "playwright": "1.46.0",
"playwright-core": "1.46.0-next", "playwright-core": "1.46.0",
"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.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.46.0-next", "@playwright/experimental-ct-core": "1.46.0",
"@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.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.46.0-next", "@playwright/experimental-ct-core": "1.46.0",
"@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.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.46.0-next", "@playwright/experimental-ct-core": "1.46.0",
"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.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.46.0-next", "@playwright/experimental-ct-core": "1.46.0",
"@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.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.46.0-next", "@playwright/experimental-ct-core": "1.46.0",
"@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.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@playwright/experimental-ct-core": "1.46.0-next", "@playwright/experimental-ct-core": "1.46.0",
"@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.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.46.0-next" "playwright-core": "1.46.0"
}, },
"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.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.46.0-next" "playwright": "1.46.0"
}, },
"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.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.46.0-next" "playwright-core": "1.46.0"
}, },
"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.0",
"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.0",
"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.0"
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@playwright/browser-firefox", "name": "@playwright/browser-firefox",
"version": "1.46.0-next", "version": "1.46.0",
"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.0"
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@playwright/browser-webkit", "name": "@playwright/browser-webkit",
"version": "1.46.0-next", "version": "1.46.0",
"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.0"
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "playwright-chromium", "name": "playwright-chromium",
"version": "1.46.0-next", "version": "1.46.0",
"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.0"
} }
} }

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.0",
"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

@ -22,7 +22,7 @@ import fs from 'fs';
import tls from 'tls'; import tls from 'tls';
import stream from 'stream'; import stream from 'stream';
import { createSocket, createTLSSocket } from '../utils/happy-eyeballs'; import { createSocket, createTLSSocket } from '../utils/happy-eyeballs';
import { isUnderTest, ManualPromise } from '../utils'; import { escapeHTML, ManualPromise, rewriteErrorMessage } from '../utils';
import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../common/socksProxy'; import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../common/socksProxy';
import { SocksProxy } from '../common/socksProxy'; import { SocksProxy } from '../common/socksProxy';
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
@ -142,38 +142,18 @@ class SocksProxyConnection {
dummyServer.emit('connection', this.internal); dummyServer.emit('connection', this.internal);
dummyServer.on('secureConnection', internalTLS => { dummyServer.on('secureConnection', internalTLS => {
debugLogger.log('client-certificates', `Browser->Proxy ${this.host}:${this.port} chooses ALPN ${internalTLS.alpnProtocol}`); debugLogger.log('client-certificates', `Browser->Proxy ${this.host}:${this.port} chooses ALPN ${internalTLS.alpnProtocol}`);
const tlsOptions: tls.ConnectionOptions = {
socket: this.target,
host: this.host,
port: this.port,
rejectUnauthorized: !this.socksProxy.ignoreHTTPSErrors,
ALPNProtocols: [internalTLS.alpnProtocol || 'http/1.1'],
...clientCertificatesToTLSOptions(this.socksProxy.clientCertificates, `https://${this.host}:${this.port}`),
};
if (!net.isIP(this.host))
tlsOptions.servername = this.host;
if (process.env.PWTEST_UNSUPPORTED_CUSTOM_CA && isUnderTest())
tlsOptions.ca = [fs.readFileSync(process.env.PWTEST_UNSUPPORTED_CUSTOM_CA)];
const targetTLS = tls.connect(tlsOptions);
targetTLS.on('secureConnect', () => { let targetTLS: tls.TLSSocket | undefined = undefined;
internalTLS.pipe(targetTLS);
targetTLS.pipe(internalTLS);
});
// Handle close and errors
const closeBothSockets = () => { const closeBothSockets = () => {
internalTLS.end(); internalTLS.end();
targetTLS.end(); targetTLS?.end();
}; };
internalTLS.on('end', () => closeBothSockets()); const handleError = (error: Error) => {
targetTLS.on('end', () => closeBothSockets()); error = rewriteOpenSSLErrorIfNeeded(error);
debugLogger.log('client-certificates', `error when connecting to target: ${error.message.replaceAll('\n', ' ')}`);
internalTLS.on('error', () => closeBothSockets()); const responseBody = escapeHTML('Playwright client-certificate error: ' + error.message)
targetTLS.on('error', error => { .replaceAll('\n', ' <br>');
debugLogger.log('client-certificates', `error when connecting to target: ${error.message}`);
const responseBody = 'Playwright client-certificate error: ' + error.message;
if (internalTLS?.alpnProtocol === 'h2') { if (internalTLS?.alpnProtocol === 'h2') {
// This method is available only in Node.js 20+ // This method is available only in Node.js 20+
if ('performServerHandshake' in http2) { if ('performServerHandshake' in http2) {
@ -201,12 +181,43 @@ 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.on('secureConnect', () => {
internalTLS.pipe(targetTLS);
targetTLS.pipe(internalTLS);
}); });
internalTLS.on('end', () => closeBothSockets());
targetTLS.on('end', () => closeBothSockets());
internalTLS.on('error', () => closeBothSockets());
targetTLS.on('error', handleError);
}); });
}); });
} }
@ -288,3 +299,14 @@ export function clientCertificatesToTLSOptions(
function rewriteToLocalhostIfNeeded(host: string): string { function rewriteToLocalhostIfNeeded(host: string): string {
return host === 'local.playwright' ? 'localhost' : host; return host === 'local.playwright' ? 'localhost' : host;
} }
export function rewriteOpenSSLErrorIfNeeded(error: Error): Error {
if (error.message !== 'unsupported')
return error;
return rewriteErrorMessage(error, [
'Unsupported TLS certificate.',
'Most likely, the security algorithm of the given certificate was deprecated by OpenSSL.',
'For more details, see https://github.com/openssl/openssl/blob/master/README-PROVIDERS.md#the-legacy-provider',
'You could probably modernize the certificate by following the steps at https://github.com/nodejs/node/issues/40672#issuecomment-1243648223',
].join('\n'));
}

View file

@ -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.0",
"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.0",
"vite": "^5.2.8", "vite": "^5.2.8",
"playwright": "1.46.0-next" "playwright": "1.46.0"
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@playwright/experimental-ct-react", "name": "@playwright/experimental-ct-react",
"version": "1.46.0-next", "version": "1.46.0",
"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.0",
"@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.0",
"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.0",
"@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.0",
"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.0",
"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.0",
"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.0",
"@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.0",
"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.0",
"@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.0",
"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.0",
"@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.0",
"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.0"
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@playwright/test", "name": "@playwright/test",
"version": "1.46.0-next", "version": "1.46.0",
"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.0"
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "playwright-webkit", "name": "playwright-webkit",
"version": "1.46.0-next", "version": "1.46.0",
"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.0"
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "playwright", "name": "playwright",
"version": "1.46.0-next", "version": "1.46.0",
"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.0"
}, },
"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

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

@ -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,7 +496,9 @@ export const UIModeView: React.FC<{}> = ({
isLoading={isLoading} isLoading={isLoading}
requestedCollapseAllCount={collapseAllCount} requestedCollapseAllCount={collapseAllCount}
setFilterText={setFilterText} setFilterText={setFilterText}
onRevealSource={onRevealSource}
/> />
{showTestingOptions && <>
<Toolbar noShadow={true} noMinHeight={true} className='settings-toolbar' onClick={() => setTestingOptionsVisible(!testingOptionsVisible)}> <Toolbar noShadow={true} noMinHeight={true} className='settings-toolbar' onClick={() => setTestingOptionsVisible(!testingOptionsVisible)}>
<span <span
className={`codicon codicon-${testingOptionsVisible ? 'chevron-down' : 'chevron-right'}`} className={`codicon codicon-${testingOptionsVisible ? 'chevron-down' : 'chevron-right'}`}
@ -501,6 +512,7 @@ export const UIModeView: React.FC<{}> = ({
showBrowserSetting, showBrowserSetting,
updateSnapshotsSetting, updateSnapshotsSetting,
]} />} ]} />}
</>}
<Toolbar noShadow={true} noMinHeight={true} className='settings-toolbar' onClick={() => setSettingsVisible(!settingsVisible)}> <Toolbar noShadow={true} noMinHeight={true} className='settings-toolbar' onClick={() => setSettingsVisible(!settingsVisible)}>
<span <span
className={`codicon codicon-${settingsVisible ? 'chevron-down' : 'chevron-right'}`} className={`codicon codicon-${settingsVisible ? 'chevron-down' : 'chevron-right'}`}

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

@ -60,7 +60,8 @@ 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

@ -24,6 +24,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 +64,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 +82,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 +112,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 +121,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 +138,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 +155,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 +169,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({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
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({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
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 +260,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 +275,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 +290,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 +302,78 @@ test.describe('browser', () => {
await page.close(); await page.close();
}); });
test('should pass with matching certificates in pfx format', async ({ browser, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
pfxPath: asset('client-certificates/client/trusted/cert.pfx'),
passphrase: 'secure'
}],
});
await page.goto(serverURL);
await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!');
await page.close();
});
test('should fail with matching certificates in legacy pfx format', async ({ browser, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
pfxPath: asset('client-certificates/client/trusted/cert-legacy.pfx'),
passphrase: 'secure'
}],
});
await page.goto(serverURL);
await expect(page.getByText('Unsupported TLS certificate.')).toBeVisible();
await page.close();
});
test('should throw a http error if the pfx passphrase is incorect', async ({ browser, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
pfxPath: asset('client-certificates/client/trusted/cert.pfx'),
passphrase: 'this-password-is-incorrect'
}],
});
await page.goto(serverURL);
await expect(page.getByText('Playwright client-certificate error: mac verify failure')).toBeVisible();
await page.close();
});
test('should pass with matching certificates on context APIRequestContext instance', async ({ browser, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ host: '127.0.0.1' });
const baseOptions = {
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 +394,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 +403,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 +432,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 +457,6 @@ test.describe('browser', () => {
test.fixme(browserName === 'webkit' && process.platform === 'linux', 'WebKit on Linux does not support http2 https://bugs.webkit.org/show_bug.cgi?id=276990'); test.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 +480,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 () => {
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

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

@ -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'); const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'download' }).first().click(); await page.getByRole('link', { name: 'download' }).first().click();
const download = await downloadPromise; const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('file attachment'); expect(download.suggestedFilename()).toBe('file attachment');
expect((await readAllFromStream(await download.createReadStream())).toString()).toContain('attach test'); 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>