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
[![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)
@ -8,7 +8,7 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr
| | 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: |
| 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-%%
* since: v1.26
### option: APIRequestContext.delete.maxRetries = %%-js-python-csharp-fetch-option-maxretries-%%
* since: v1.46
## async method: APIRequestContext.dispose
* 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.
Defaults to `20`. Pass `0` to not follow redirects.
### option: Route.fetch.maxRetries
* since: v1.46
- `maxRetries` <[int]>
Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries.
### option: Route.fetch.timeout
* since: v1.33
- `timeout` <[float]>

View file

@ -524,9 +524,9 @@ Does not enforce fixed viewport, allows resizing window in the headed mode.
## context-option-clientCertificates
- `clientCertificates` <[Array]<[Object]>>
- `origin` <[string]> Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port.
- `certPath` ?<[string]> Path to the file with the certificate in PEM format.
- `keyPath` ?<[string]> Path to the file with the private key in PEM format.
- `pfxPath` ?<[string]> Path to the PFX or PKCS12 encoded private key and certificate chain.
- `certPath` ?<[path]> Path to the file with the certificate in PEM format.
- `keyPath` ?<[path]> Path to the file with the private key in PEM format.
- `pfxPath` ?<[path]> Path to the PFX or PKCS12 encoded private key and certificate chain.
- `passphrase` ?<[string]> Passphrase for the private key (PEM or PFX).
TLS Client Authentication allows the server to request a client certificate and verify it.

View file

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

View file

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

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

View file

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

68
package-lock.json generated
View file

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

View file

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

View file

@ -20,7 +20,7 @@ import * as icons from './icons';
import { TreeItem } from './treeItem';
import { CopyToClipboard } from './copyToClipboard';
import './links.css';
import { linkifyText } from './renderUtils';
import { linkifyText } from '@web/renderUtils';
export function navigate(href: string) {
window.history.pushState({}, '', href);

View file

@ -23,7 +23,7 @@ import { ProjectLink } from './links';
import { statusIcon } from './statusIcon';
import './testCaseView.css';
import { TestResultView } from './testResultView';
import { linkifyText } from './renderUtils';
import { linkifyText } from '@web/renderUtils';
import { hashStringToInt, msToString } from './utils';
export const TestCaseView: React.FC<{

View file

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

View file

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

View file

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

View file

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

View file

@ -3,9 +3,9 @@
"browsers": [
{
"name": "chromium",
"revision": "1128",
"revision": "1129",
"installByDefault": true,
"browserVersion": "128.0.6613.7"
"browserVersion": "128.0.6613.18"
},
{
"name": "chromium-tip-of-tree",

View file

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

View file

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

View file

@ -17,7 +17,6 @@
import type * as channels from '@protocol/channels';
import type { LookupAddress } from 'dns';
import http from 'http';
import fs from 'fs';
import https from 'https';
import type { Readable, TransformCallback } from 'stream';
import { pipeline, Transform } from 'stream';
@ -26,7 +25,7 @@ import zlib from 'zlib';
import type { HTTPCredentials } from '../../types/types';
import { TimeoutSettings } from '../common/timeoutSettings';
import { getUserAgent } from '../utils/userAgent';
import { assert, createGuid, isUnderTest, monotonicTime } from '../utils';
import { assert, createGuid, monotonicTime } from '../utils';
import { HttpsProxyAgent, SocksProxyAgent } from '../utilsBundle';
import { BrowserContext, verifyClientCertificates } from './browserContext';
import { CookieStore, domainMatches } from './cookieStore';
@ -41,7 +40,7 @@ import { Tracing } from './trace/recorder/tracing';
import type * as types from './types';
import type { HeadersArray, ProxySettings } from './types';
import { kMaxCookieExpiresDateInSeconds } from './network';
import { clientCertificatesToTLSOptions } from './socksClientCertificatesInterceptor';
import { clientCertificatesToTLSOptions, rewriteOpenSSLErrorIfNeeded } from './socksClientCertificatesInterceptor';
type FetchRequestOptions = {
userAgent: string;
@ -168,7 +167,10 @@ export abstract class APIRequestContext extends SdkObject {
const method = params.method?.toUpperCase() || 'GET';
const proxy = defaults.proxy;
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);
if (proxyOpts.protocol?.startsWith('socks')) {
agent = new SocksProxyAgent({
@ -196,8 +198,6 @@ export abstract class APIRequestContext extends SdkObject {
...clientCertificatesToTLSOptions(this._defaultOptions().clientCertificates, requestUrl.origin),
__testHookLookup: (params as any).__testHookLookup,
};
if (process.env.PWTEST_UNSUPPORTED_CUSTOM_CA && isUnderTest())
options.ca = [fs.readFileSync(process.env.PWTEST_UNSUPPORTED_CUSTOM_CA)];
// rejectUnauthorized = undefined is treated as true in Node.js 12.
if (params.ignoreHTTPSErrors || defaults.ignoreHTTPSErrors)
options.rejectUnauthorized = false;
@ -444,7 +444,7 @@ export abstract class APIRequestContext extends SdkObject {
body.on('data', chunk => chunks.push(chunk));
body.on('end', notifyBodyFinished);
});
request.on('error', reject);
request.on('error', error => reject(rewriteOpenSSLErrorIfNeeded(error)));
const disposeListener = () => {
reject(new Error('Request context disposed.'));

View file

@ -170,8 +170,16 @@ export function source() {
if (typeof value === 'bigint')
return { bi: value.toString() };
if (isError(value))
return { e: { n: value.name, m: value.message, s: value.stack || '' } };
if (isError(value)) {
let stack;
if (value.stack?.startsWith(value.name + ': ' + value.message)) {
// v8
stack = value.stack;
} else {
stack = `${value.name}: ${value.message}\n${value.stack}`;
}
return { e: { n: value.name, m: value.message, s: stack } };
}
if (isDate(value))
return { d: value.toJSON() };
if (isURL(value))

View file

@ -90,6 +90,8 @@ export async function syncLocalStorageWithSettings(page: Page, appName: string)
// iframes w/ snapshots, etc.
if (location && location.protocol === 'data:')
return;
if (window.top !== window)
return;
Object.entries(settings).map(([k, v]) => localStorage[k] = v);
(window as any).saveSettings = () => {
(window as any)._saveSerializedSettings(JSON.stringify({ ...localStorage }));

View file

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

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
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;
/**
* 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
* 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;
/**
* 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).
*/

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -33,7 +33,7 @@ export interface TestStepInternal {
complete(result: { error?: Error, attachments?: Attachment[] }): void;
stepId: string;
title: string;
category: 'hook' | 'fixture' | 'test.step' | 'expect' | string;
category: 'hook' | 'fixture' | 'test.step' | 'expect' | 'attach' | string;
location?: Location;
boxedStack?: StackFrame[];
steps: TestStepInternal[];
@ -44,6 +44,9 @@ export interface TestStepInternal {
infectParentStepsWithError?: boolean;
box?: 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 = {
@ -252,7 +255,7 @@ export class TestInfoImpl implements TestInfo {
parentStep = this._findLastStageStep();
} else {
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.
// However, do not nest chains of route.continue by checking the title.
parentStep = this._findLastNonFinishedStep(step => step.title !== data.title);

View file

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

View file

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

View file

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

View file

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

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;
font-weight: bold;
text-transform: uppercase;
font-size: 10px;
font-size: 12px;
color: var(--vscode-sideBarTitle-foreground);
line-height: 24px;
}
.attachments-section:not(:first-child) {
border-top: 1px solid var(--vscode-panel-border);
margin-top: 10px;
}
.attachment-item {

View file

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

View file

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

View file

@ -15,23 +15,24 @@
*/
import * as React from 'react';
import { ToolbarButton } from '@web/components/toolbarButton';
export const CopyToClipboard: React.FunctionComponent<{
value: string,
description?: string,
}> = ({ value, description }) => {
const [iconClassName, setIconClassName] = React.useState('codicon-clippy');
const [icon, setIcon] = React.useState('copy');
const handleCopy = React.useCallback(() => {
navigator.clipboard.writeText(value).then(() => {
setIconClassName('codicon-check');
setIcon('check');
setTimeout(() => {
setIconClassName('codicon-clippy');
setIcon('copy');
}, 3000);
}, () => {
setIconClassName('codicon-close');
setIcon('close');
});
}, [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 }) => {
if (!model)
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>
{!!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>

View file

@ -29,7 +29,8 @@ const eventsSymbol = Symbol('events');
export type SourceLocation = {
file: string;
line: number;
source: SourceModel;
column: number;
source?: SourceModel;
};
export type SourceModel = {
@ -408,3 +409,30 @@ function collectSources(actions: trace.ActionTraceEvent[], errorDescriptors: Err
}
return result;
}
const kRouteMethods = new Set([
'page.route',
'page.routefromhar',
'page.unroute',
'page.unrouteall',
'browsercontext.route',
'browsercontext.routefromhar',
'browsercontext.unroute',
'browsercontext.unrouteall',
]);
{
// .NET adds async suffix.
for (const method of [...kRouteMethods])
kRouteMethods.add(method + 'async');
// Python methods which contain underscores.
for (const method of [
'page.route_from_har',
'page.unroute_all',
'context.route_from_har',
'context.unroute_all',
])
kRouteMethods.add(method);
}
export function isRouteAction(action: ActionTraceEventInContext) {
return action.class === 'Route' || kRouteMethods.has(action.apiName.toLowerCase());
}

View file

@ -61,3 +61,27 @@
.tab-network .tabbed-pane-tab.selected {
font-weight: bold;
}
.green-circle::before,
.red-circle::before,
.yellow-circle::before {
content: "";
display: inline-block;
width: 12px;
height: 12px;
border-radius: 6px;
margin-right: 2px;
align-self: center;
}
.green-circle::before {
background-color: var(--vscode-charts-green);
}
.red-circle::before {
background-color: var(--vscode-charts-red);
}
.yellow-circle::before {
background-color: var(--vscode-charts-yellow);
}

View file

@ -19,7 +19,6 @@ import * as React from 'react';
import './networkResourceDetails.css';
import { TabbedPane } from '@web/components/tabbedPane';
import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
import type { Language } from '@web/components/codeMirrorWrapper';
import { ToolbarButton } from '@web/components/toolbarButton';
export const NetworkResourceDetails: React.FunctionComponent<{
@ -55,19 +54,18 @@ export const NetworkResourceDetails: React.FunctionComponent<{
const RequestTab: React.FunctionComponent<{
resource: ResourceSnapshot;
}> = ({ resource }) => {
const [requestBody, setRequestBody] = React.useState<{ text: string, language?: Language } | null>(null);
const [requestBody, setRequestBody] = React.useState<{ text: string, mimeType?: string } | null>(null);
React.useEffect(() => {
const readResources = async () => {
if (resource.request.postData) {
const requestContentTypeHeader = resource.request.headers.find(q => q.name === 'Content-Type');
const requestContentType = requestContentTypeHeader ? requestContentTypeHeader.value : '';
const language = mimeTypeToHighlighter(requestContentType);
if (resource.request.postData._sha1) {
const response = await fetch(`sha1/${resource.request.postData._sha1}`);
setRequestBody({ text: formatBody(await response.text(), requestContentType), language });
setRequestBody({ text: formatBody(await response.text(), requestContentType), mimeType: requestContentType });
} else {
setRequestBody({ text: formatBody(resource.request.postData.text, requestContentType), language });
setRequestBody({ text: formatBody(resource.request.postData.text, requestContentType), mimeType: requestContentType });
}
} else {
setRequestBody(null);
@ -80,15 +78,14 @@ const RequestTab: React.FunctionComponent<{
<div className='network-request-details-header'>General</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'>{`Status Code: ${
resource.response.status >= 200 && resource.response.status < 400
? `🟢 ${resource.response.status} ${resource.response.statusText}`
: `🔴 ${resource.response.status} ${resource.response.statusText}`
}`}</div>
<div className='network-request-details-general' style={{ display: 'flex' }}>
Status Code: <span className={statusClass(resource.response.status)} style={{ display: 'inline-flex' }}>
{`${resource.response.status} ${resource.response.statusText}`}
</span></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>
{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>;
};
@ -104,7 +101,7 @@ const ResponseTab: React.FunctionComponent<{
const BodyTab: React.FunctionComponent<{
resource: ResourceSnapshot;
}> = ({ resource }) => {
const [responseBody, setResponseBody] = React.useState<{ dataUrl?: string, text?: string, language?: Language } | null>(null);
const [responseBody, setResponseBody] = React.useState<{ dataUrl?: string, text?: string, mimeType?: string } | null>(null);
React.useEffect(() => {
const readResources = async () => {
@ -119,8 +116,7 @@ const BodyTab: React.FunctionComponent<{
setResponseBody({ dataUrl: (await eventPromise).target.result });
} else {
const formattedBody = formatBody(await response.text(), resource.response.content.mimeType);
const language = mimeTypeToHighlighter(resource.response.content.mimeType);
setResponseBody({ text: formattedBody, language });
setResponseBody({ text: formattedBody, mimeType: resource.response.content.mimeType });
}
}
};
@ -131,10 +127,18 @@ const BodyTab: React.FunctionComponent<{
return <div className='network-request-details-tab'>
{!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.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>;
};
function statusClass(statusCode: number): string {
if (statusCode < 300 || statusCode === 304)
return 'green-circle';
if (statusCode < 400)
return 'yellow-circle';
return 'red-circle';
}
function formatBody(body: string | null, contentType: string): string {
if (body === null)
return 'Loading...';
@ -156,12 +160,3 @@ function formatBody(body: string | null, contentType: string): string {
return bodyStr;
}
function mimeTypeToHighlighter(mimeType: string): Language | undefined {
if (mimeType.includes('javascript') || mimeType.includes('json'))
return 'javascript';
if (mimeType.includes('html'))
return 'html';
if (mimeType.includes('css'))
return 'css';
}

View file

@ -28,6 +28,10 @@
background-color: var(--vscode-sideBar-background);
}
.snapshot-tab .toolbar .pick-locator {
margin: 0 4px;
}
.snapshot-controls {
flex: none;
background-color: var(--vscode-sideBar-background);
@ -102,29 +106,6 @@ iframe.snapshot-visible[name=snapshot] {
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 {
line-height: 23px;
margin-right: 4px;

View file

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

View file

@ -23,21 +23,9 @@
}
.source-tab-file-name {
height: 24px;
margin-left: 8px;
padding-left: 8px;
height: 100%;
display: flex;
align-items: center;
background-color: var(--vscode-breadcrumb-background);
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;
flex: 1 1 auto;
}

View file

@ -24,6 +24,8 @@ import type { SourceHighlight } from '@web/components/codeMirrorWrapper';
import type { SourceLocation, SourceModel } from './modelUtil';
import type { StackFrame } from '@protocol/channels';
import { CopyToClipboard } from './copyToClipboard';
import { ToolbarButton } from '@web/components/toolbarButton';
import { Toolbar } from '@web/components/toolbar';
export const SourceTab: React.FunctionComponent<{
stack: StackFrame[] | undefined,
@ -31,7 +33,8 @@ export const SourceTab: React.FunctionComponent<{
sources: Map<string, SourceModel>,
rootDir?: string,
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 [selectedFrame, setSelectedFrame] = React.useState<number>(0);
@ -42,7 +45,7 @@ export const SourceTab: React.FunctionComponent<{
}
}, [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 shouldUseFallback = !actionLocation?.file;
if (shouldUseFallback && !fallbackLocation)
@ -56,6 +59,7 @@ export const SourceTab: React.FunctionComponent<{
sources.set(file, source);
}
const location = shouldUseFallback ? fallbackLocation! : actionLocation;
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 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}">`;
}
}
return { source, highlight, targetLine, fileName };
return { source, highlight, targetLine, fileName, location };
}, [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;
return <SplitView sidebarSize={200} orientation={stackFrameLocation === 'bottom' ? 'vertical' : 'horizontal'} sidebarHidden={!showStackFrames}>
<div className='vbox' data-testid='source-code'>
{fileName && (
<div className='source-tab-file-name'>
{fileName}
<span className='source-copy-to-clipboard'>
<CopyToClipboard description='Copy filename' value={getFileName(fileName, targetLine)}/>
</span>
</div>
)}
{ fileName && <Toolbar>
<span className='source-tab-file-name'>{fileName}</span>
<CopyToClipboard description='Copy filename' value={getFileName(fileName)}/>
{location && <ToolbarButton icon='link-external' title='Open in VS Code' onClick={openExternally}></ToolbarButton>}
</Toolbar> }
<CodeMirrorWrapper text={source.content || ''} language='javascript' highlight={highlight} revealLine={targetLine} readOnly={true} lineNumbers={true} />
</div>
<StackTraceView stack={stack} selectedFrame={selectedFrame} setSelectedFrame={setSelectedFrame} />
@ -109,10 +121,9 @@ export async function calculateSha1(text: string): Promise<string> {
return hexCodes.join('');
}
function getFileName(fullPath?: string, lineNum?: number): string {
function getFileName(fullPath?: string): string {
if (!fullPath)
return '';
const pathSep = fullPath?.includes('/') ? '/' : '\\';
const fileName = fullPath?.split(pathSep).pop() ?? '';
return lineNum ? `${fileName}:${lineNum}` : fileName;
return fullPath?.split(pathSep).pop() ?? '';
}

View file

@ -47,8 +47,9 @@ export const TestListView: React.FC<{
isLoading?: boolean,
onItemSelected: (item: { treeItem?: TreeItem, testCase?: reporterTypes.TestCase, testFile?: SourceLocation }) => void,
requestedCollapseAllCount: number,
setFilterText: (text: string) => void;
}> = ({ filterText, testModel, testServerConnection, testTree, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, requestedCollapseAllCount, setFilterText }) => {
setFilterText: (text: string) => void,
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 [selectedTreeItemId, setSelectedTreeItemId] = React.useState<string | undefined>();
const [collapseAllCount, setCollapseAllCount] = React.useState(requestedCollapseAllCount);
@ -91,17 +92,7 @@ export const TestListView: React.FC<{
if (!testModel)
return { selectedTreeItem: undefined };
const selectedTreeItem = selectedTreeItemId ? testTree.treeItemById(selectedTreeItemId) : undefined;
let testFile: SourceLocation | undefined;
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,
}
};
}
const testFile = itemLocation(selectedTreeItem, testModel);
let selectedTest: reporterTypes.TestCase | undefined;
if (selectedTreeItem?.kind === '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>}
<Toolbar noMinHeight={true} noShadow={true}>
<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={() => {
if (watchedTreeIds.value.has(treeItem.id))
watchedTreeIds.value.delete(treeItem.id);
@ -187,3 +178,17 @@ export const TestListView: React.FC<{
autoExpandDepth={filterText ? 5 : 1}
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>,
item: { treeItem?: TreeItem, testFile?: SourceLocation, testCase?: reporterTypes.TestCase },
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 [counter, setCounter] = React.useState(0);
const pollTimer = React.useRef<NodeJS.Timeout | null>(null);
@ -97,7 +99,11 @@ export const TraceView: React.FC<{
onSelectionChanged={onSelectionChanged}
fallbackLocation={item.testFile}
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 => {

View file

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

View file

@ -96,6 +96,9 @@ export const UIModeView: React.FC<{}> = ({
const [testServerConnection, setTestServerConnection] = React.useState<TestServerConnection>();
const [settingsVisible, setSettingsVisible] = React.useState(false);
const [testingOptionsVisible, setTestingOptionsVisible] = React.useState(false);
const [revealSource, setRevealSource] = React.useState(false);
const onRevealSource = React.useCallback(() => setRevealSource(true), [setRevealSource]);
const showTestingOptions = false;
const [runWorkers, setRunWorkers] = React.useState(queryParams.workers);
const singleWorkerSetting = React.useMemo(() => {
@ -435,7 +438,13 @@ export const UIModeView: React.FC<{}> = ({
<XtermWrapper source={xtermDataSource}></XtermWrapper>
</div>
<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 className='vbox ui-mode-sidebar'>
@ -487,20 +496,23 @@ export const UIModeView: React.FC<{}> = ({
isLoading={isLoading}
requestedCollapseAllCount={collapseAllCount}
setFilterText={setFilterText}
onRevealSource={onRevealSource}
/>
<Toolbar noShadow={true} noMinHeight={true} className='settings-toolbar' onClick={() => setTestingOptionsVisible(!testingOptionsVisible)}>
<span
className={`codicon codicon-${testingOptionsVisible ? 'chevron-down' : 'chevron-right'}`}
style={{ marginLeft: 5 }}
title={testingOptionsVisible ? 'Hide Testing Options' : 'Show Testing Options'}
/>
<div className='section-title'>Testing Options</div>
</Toolbar>
{testingOptionsVisible && <SettingsView settings={[
singleWorkerSetting,
showBrowserSetting,
updateSnapshotsSetting,
]} />}
{showTestingOptions && <>
<Toolbar noShadow={true} noMinHeight={true} className='settings-toolbar' onClick={() => setTestingOptionsVisible(!testingOptionsVisible)}>
<span
className={`codicon codicon-${testingOptionsVisible ? 'chevron-down' : 'chevron-right'}`}
style={{ marginLeft: 5 }}
title={testingOptionsVisible ? 'Hide Testing Options' : 'Show Testing Options'}
/>
<div className='section-title'>Testing Options</div>
</Toolbar>
{testingOptionsVisible && <SettingsView settings={[
singleWorkerSetting,
showBrowserSetting,
updateSnapshotsSetting,
]} />}
</>}
<Toolbar noShadow={true} noMinHeight={true} className='settings-toolbar' onClick={() => setSettingsVisible(!settingsVisible)}>
<span
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 { ConsoleTab, useConsoleTabModel } from './consoleTab';
import type * as modelUtil from './modelUtil';
import type { ActionTraceEventInContext, MultiTraceModel } from './modelUtil';
import { isRouteAction } from './modelUtil';
import type { StackFrame } from '@protocol/channels';
import { NetworkTab, useNetworkTabModel } from './networkTab';
import { SnapshotTab } from './snapshotTab';
@ -33,6 +33,7 @@ import type { TabbedPaneTabModel } from '@web/components/tabbedPane';
import { Timeline } from './timeline';
import { MetadataView } from './metadataView';
import { AttachmentsTab } from './attachmentsTab';
import { AnnotationsTab } from './annotationsTab';
import type { Boundaries } from '../geometry';
import { InspectorTab } from './inspectorTab';
import { ToolbarButton } from '@web/components/toolbarButton';
@ -44,26 +45,29 @@ import type { UITestStatus } from './testUtils';
import { SettingsView } from './settingsView';
export const Workbench: React.FunctionComponent<{
model?: MultiTraceModel,
model?: modelUtil.MultiTraceModel,
showSourcesFirst?: boolean,
rootDir?: string,
fallbackLocation?: modelUtil.SourceLocation,
initialSelection?: ActionTraceEventInContext,
onSelectionChanged?: (action: ActionTraceEventInContext) => void,
initialSelection?: modelUtil.ActionTraceEventInContext,
onSelectionChanged?: (action: modelUtil.ActionTraceEventInContext) => void,
isLive?: boolean,
status?: UITestStatus,
annotations?: { type: string; description?: string; }[];
inert?: boolean,
showRouteActionsSetting?: Setting<boolean>,
openPage?: (url: string, target?: string) => Window | any,
}> = ({ showRouteActionsSetting, model, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, status, inert, openPage }) => {
const [selectedAction, setSelectedActionImpl] = React.useState<ActionTraceEventInContext | undefined>(undefined);
onOpenExternally?: (location: modelUtil.SourceLocation) => void,
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 [highlightedAction, setHighlightedAction] = React.useState<ActionTraceEventInContext | undefined>();
const [highlightedAction, setHighlightedAction] = React.useState<modelUtil.ActionTraceEventInContext | undefined>();
const [highlightedEntry, setHighlightedEntry] = React.useState<Entry | undefined>();
const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState<ConsoleEntry | undefined>();
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions');
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 activeAction = model ? highlightedAction || selectedAction : undefined;
const [selectedTime, setSelectedTime] = React.useState<Boundaries | undefined>();
@ -75,10 +79,10 @@ export const Workbench: React.FunctionComponent<{
const showRouteActions = showRouteActionsSetting[0];
const filteredActions = React.useMemo(() => {
return (model?.actions || []).filter(action => showRouteActions || action.class !== 'Route');
return (model?.actions || []).filter(action => showRouteActions || !isRouteAction(action));
}, [model, showRouteActions]);
const setSelectedAction = React.useCallback((action: ActionTraceEventInContext | undefined) => {
const setSelectedAction = React.useCallback((action: modelUtil.ActionTraceEventInContext | undefined) => {
setSelectedActionImpl(action);
setRevealedStack(action?.stack);
}, [setSelectedActionImpl, setRevealedStack]);
@ -87,6 +91,7 @@ export const Workbench: React.FunctionComponent<{
React.useEffect(() => {
setSelectedTime(undefined);
setRevealedStack(undefined);
}, [model]);
React.useEffect(() => {
@ -110,7 +115,7 @@ export const Workbench: React.FunctionComponent<{
}
}, [model, selectedAction, setSelectedAction, initialSelection]);
const onActionSelected = React.useCallback((action: ActionTraceEventInContext) => {
const onActionSelected = React.useCallback((action: modelUtil.ActionTraceEventInContext) => {
setSelectedAction(action);
onSelectionChanged?.(action);
}, [setSelectedAction, onSelectionChanged]);
@ -118,14 +123,25 @@ export const Workbench: React.FunctionComponent<{
const selectPropertiesTab = React.useCallback((tab: string) => {
setSelectedPropertiesTab(tab);
if (tab !== 'inspector')
setIsInspecting(false);
setIsInspectingState(false);
}, [setSelectedPropertiesTab]);
const setIsInspecting = React.useCallback((value: boolean) => {
if (!isInspecting && value)
selectPropertiesTab('inspector');
setIsInspectingState(value);
}, [setIsInspectingState, selectPropertiesTab, isInspecting]);
const locatorPicked = React.useCallback((locator: string) => {
setHighlightedLocator(locator);
selectPropertiesTab('inspector');
}, [selectPropertiesTab]);
React.useEffect(() => {
if (revealSource)
selectPropertiesTab('source');
}, [revealSource, selectPropertiesTab]);
const consoleModel = useConsoleTabModel(model, selectedTime);
const networkModel = useNetworkTabModel(model, selectedTime);
const errorsModel = useErrorsTabModel(model);
@ -174,7 +190,9 @@ export const Workbench: React.FunctionComponent<{
sources={sources}
rootDir={rootDir}
stackFrameLocation={sidebarLocation === 'bottom' ? 'right' : 'bottom'}
fallbackLocation={fallbackLocation} />
fallbackLocation={fallbackLocation}
onOpenExternally={onOpenExternally}
/>
};
const consoleTab: TabbedPaneTabModel = {
id: 'console',
@ -211,6 +229,17 @@ export const Workbench: React.FunctionComponent<{
sourceTab,
attachmentsTab,
];
if (annotations !== undefined) {
const annotationsTab: TabbedPaneTabModel = {
id: 'annotations',
title: 'Annotations',
count: annotations.length,
render: () => <AnnotationsTab annotations={annotations} />
};
tabs.push(annotationsTab);
}
if (showSourcesFirst) {
const sourceTabIndex = tabs.indexOf(sourceTab);
tabs.splice(sourceTabIndex, 1);
@ -302,13 +331,6 @@ export const Workbench: React.FunctionComponent<{
tabs={tabs}
selectedTab={selectedPropertiesTab}
setSelectedTab={selectPropertiesTab}
leftToolbar={[
<ToolbarButton title='Pick locator' icon='target' toggled={isInspecting} onClick={() => {
if (!isInspecting)
selectPropertiesTab('inspector');
setIsInspecting(!isInspecting);
}} />
]}
rightToolbar={[
sidebarLocation === 'bottom' ?
<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/python/python';
import 'codemirror-shadow-1/mode/clike/clike';
import 'codemirror-shadow-1/mode/markdown/markdown';
import 'codemirror-shadow-1/addon/mode/simple';
export type CodeMirror = typeof codemirrorType;
export default codemirror;

View file

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

View file

@ -18,7 +18,7 @@ import './codeMirrorWrapper.css';
import * as React from 'react';
import type { CodeMirror } from './codeMirrorModule';
import { ansi2html } from '../ansi2html';
import { useMeasure } from '../uiUtils';
import { useMeasure, kWebLinkRe } from '../uiUtils';
export type SourceHighlight = {
line: number;
@ -26,11 +26,13 @@ export type SourceHighlight = {
message?: string;
};
export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl' | 'html' | 'css';
export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl' | 'html' | 'css' | 'markdown';
export interface SourceProps {
text: string;
language?: Language;
mimeType?: string;
linkify?: boolean;
readOnly?: boolean;
// 1-based
highlight?: SourceHighlight[];
@ -45,6 +47,8 @@ export interface SourceProps {
export const CodeMirrorWrapper: React.FC<SourceProps> = ({
text,
language,
mimeType,
linkify,
readOnly,
highlight,
revealLine,
@ -63,24 +67,13 @@ export const CodeMirrorWrapper: React.FC<SourceProps> = ({
(async () => {
// Always load the module first.
const CodeMirror = await modulePromise;
defineCustomMode(CodeMirror);
const element = codemirrorElement.current;
if (!element)
return;
let mode = '';
if (language === 'javascript')
mode = 'javascript';
if (language === 'python')
mode = 'python';
if (language === 'java')
mode = 'text/x-java';
if (language === 'csharp')
mode = 'text/x-csharp';
if (language === 'html')
mode = 'htmlmixed';
if (language === 'css')
mode = 'css';
const mode = languageToMode(language) || mimeTypeToMode(mimeType) || (linkify ? 'text/linkified' : '');
if (codemirrorRef.current
&& mode === codemirrorRef.current.cm.getOption('mode')
@ -106,7 +99,7 @@ export const CodeMirrorWrapper: React.FC<SourceProps> = ({
setCodemirror(cm);
return cm;
})();
}, [modulePromise, codemirror, codemirrorElement, language, lineNumbers, wrapLines, readOnly, isFocused]);
}, [modulePromise, codemirror, codemirrorElement, language, mimeType, linkify, lineNumbers, wrapLines, readOnly, isFocused]);
React.useEffect(() => {
if (codemirrorRef.current)
@ -175,5 +168,69 @@ export const CodeMirrorWrapper: React.FC<SourceProps> = ({
};
}, [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 => {
let suffix = '';
if (tab.count === 1)
suffix = ' 🔵';
else if (tab.count)
suffix = ` 🔵✖️${tab.count}`;
if (tab.errorCount === 1)
suffix = ` 🔴`;
else if (tab.errorCount)
suffix = ` 🔴✖️${tab.errorCount}`;
if (tab.count)
suffix = ` (${tab.count})`;
if (tab.errorCount)
suffix = ` (${tab.errorCount})`;
return <option value={tab.id} selected={tab.id === selectedTab}>{tab.title}{suffix}</option>;
})}
</select>

View file

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

View file

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

View file

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

View file

@ -183,3 +183,6 @@ export class 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 \
-set_serial 01 \
-days 365
# create pfx
openssl pkcs12 -export -out client/trusted/cert.pfx -inkey client/trusted/key.pem -in client/trusted/cert.pem -passout pass:secure
```
## Self-signed certificate (invalid)

View file

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

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.');
});
it('should retrty ECONNRESET', {
it('should retry on ECONNRESET', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30978' }
}, async ({ context, server }) => {
let requestCount = 0;

View file

@ -24,6 +24,7 @@ const { createHttpsServer, createHttp2Server } = require('../../packages/playwri
type TestOptions = {
startCCServer(options?: {
host?: string;
http2?: boolean;
enableHTTP1FallbackWhenUsingHttp2?: 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(''));
});
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';
return `https://${host}:${(server.address() as net.AddressInfo).port}/`;
});
@ -81,8 +82,6 @@ test.use({
}
});
test.skip(({ mode }) => mode !== 'default');
const kDummyFileName = __filename;
const kValidationSubTests: [BrowserContextOptions, string][] = [
[{ clientCertificates: [{ origin: 'test' }] }, 'None of cert, key, passphrase or pfx is specified'],
@ -113,7 +112,7 @@ test.describe('fetch', () => {
test('should fail with no client certificates provided', async ({ playwright, startCCServer }) => {
const serverURL = await startCCServer();
const request = await playwright.request.newContext();
const request = await playwright.request.newContext({ ignoreHTTPSErrors: true });
const response = await request.get(serverURL);
expect(response.status()).toBe(401);
expect(await response.text()).toContain('Sorry, but you need to provide a client certificate to continue.');
@ -122,6 +121,7 @@ test.describe('fetch', () => {
test('should keep supporting http', async ({ playwright, server, asset }) => {
const request = await playwright.request.newContext({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(server.PREFIX).origin,
certPath: asset('client-certificates/client/trusted/cert.pem'),
@ -138,6 +138,7 @@ test.describe('fetch', () => {
test('should throw with untrusted client certs', async ({ playwright, startCCServer, asset }) => {
const serverURL = await startCCServer();
const request = await playwright.request.newContext({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/self-signed/cert.pem'),
@ -154,6 +155,7 @@ test.describe('fetch', () => {
test('pass with trusted client certificates', async ({ playwright, startCCServer, asset }) => {
const serverURL = await startCCServer();
const request = await playwright.request.newContext({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/trusted/cert.pem'),
@ -167,9 +169,55 @@ test.describe('fetch', () => {
await request.dispose();
});
test('pass with trusted client certificates in pfx format', async ({ playwright, startCCServer, asset }) => {
const serverURL = await startCCServer();
const request = await playwright.request.newContext({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
pfxPath: asset('client-certificates/client/trusted/cert.pfx'),
passphrase: 'secure'
}],
});
const response = await request.get(serverURL);
expect(response.url()).toBe(serverURL);
expect(response.status()).toBe(200);
expect(await response.text()).toContain('Hello Alice, your certificate was issued by localhost!');
await request.dispose();
});
test('should throw a http error if the pfx passphrase is incorect', async ({ playwright, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ 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 }) => {
const serverURL = await startCCServer();
const request = await playwright.request.newContext({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/trusted/cert.pem'),
@ -212,6 +260,7 @@ test.describe('browser', () => {
test('should fail with no client certificates', async ({ browser, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: 'https://not-matching.com',
certPath: asset('client-certificates/client/trusted/cert.pem'),
@ -226,6 +275,7 @@ test.describe('browser', () => {
test('should fail with self-signed client certificates', async ({ browser, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/self-signed/cert.pem'),
@ -240,6 +290,7 @@ test.describe('browser', () => {
test('should pass with matching certificates', async ({ browser, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/trusted/cert.pem'),
@ -251,9 +302,78 @@ test.describe('browser', () => {
await page.close();
});
test('should pass with matching certificates in pfx format', async ({ browser, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
pfxPath: asset('client-certificates/client/trusted/cert.pfx'),
passphrase: 'secure'
}],
});
await page.goto(serverURL);
await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!');
await page.close();
});
test('should fail with matching certificates in legacy pfx format', async ({ browser, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
pfxPath: asset('client-certificates/client/trusted/cert-legacy.pfx'),
passphrase: 'secure'
}],
});
await page.goto(serverURL);
await expect(page.getByText('Unsupported TLS certificate.')).toBeVisible();
await page.close();
});
test('should throw a http error if the pfx passphrase is incorect', async ({ browser, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
pfxPath: asset('client-certificates/client/trusted/cert.pfx'),
passphrase: 'this-password-is-incorrect'
}],
});
await page.goto(serverURL);
await expect(page.getByText('Playwright client-certificate error: mac verify failure')).toBeVisible();
await page.close();
});
test('should pass with matching certificates on context APIRequestContext instance', async ({ browser, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ host: '127.0.0.1' });
const baseOptions = {
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 }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: serverURL,
certPath: asset('client-certificates/client/trusted/cert.pem'),
@ -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 expect(page.getByText('Playwright client-certificate error')).toBeVisible();
await expect(page.getByText('Playwright client-certificate error: self-signed certificate')).toBeVisible();
await page.close();
});
@ -283,6 +403,7 @@ test.describe('browser', () => {
const enableHTTP1FallbackWhenUsingHttp2 = browserName === 'webkit' && process.platform === 'linux';
const serverURL = await startCCServer({ http2: true, enableHTTP1FallbackWhenUsingHttp2 });
const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/trusted/cert.pem'),
@ -311,6 +432,7 @@ test.describe('browser', () => {
const serverURL = await startCCServer({ http2: true, enableHTTP1FallbackWhenUsingHttp2: true });
const browser = await browserType.launch({ args: ['--disable-http2'] });
const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/trusted/cert.pem'),
@ -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.skip(+process.versions.node.split('.')[0] < 20, 'http2.performServerHandshake is not supported in older Node.js versions');
process.env.PWTEST_UNSUPPORTED_CUSTOM_CA = asset('empty.html');
const serverURL = await startCCServer({ http2: true });
const page = await browser.newPage({
clientCertificates: [{
@ -359,6 +480,7 @@ test.describe('browser', () => {
test('should pass with matching certificates', async ({ launchPersistent, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
const { page } = await launchPersistent({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/trusted/cert.pem'),

View file

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

View file

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

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.
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('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 }) => {

View file

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

View file

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

View file

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