Compare commits
36 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1c861cfa7 | ||
|
|
20b0788101 | ||
|
|
57c324002a | ||
|
|
301f179735 | ||
|
|
b2d6a0916e | ||
|
|
7cf7aec97f | ||
|
|
d78ae0179d | ||
|
|
bd13da4132 | ||
|
|
30684a77e7 | ||
|
|
5e68061d49 | ||
|
|
99a3631057 | ||
|
|
929fef348e | ||
|
|
cf31aa8b4c | ||
|
|
ed9b4d9b9a | ||
|
|
fca1fa0b95 | ||
|
|
ff11273c7b | ||
|
|
4953ac3072 | ||
|
|
4c66f8aeda | ||
|
|
deba37b6b5 | ||
|
|
2cfe733e30 | ||
|
|
5fdf97658e | ||
|
|
29ba72c06b | ||
|
|
b20e154902 | ||
|
|
876e0e4ba9 | ||
|
|
3ab19c6229 | ||
|
|
8d35c1b517 | ||
|
|
71b8e22501 | ||
|
|
71e5eade8c | ||
|
|
7ff46d4596 | ||
|
|
ca9ddff7ca | ||
|
|
dfecfa5be1 | ||
|
|
468b9b1e7a | ||
|
|
64e4a9b0eb | ||
|
|
446de523c4 | ||
|
|
2ea14ca2c4 | ||
|
|
185a2867c6 |
|
|
@ -1,6 +1,6 @@
|
|||
# 🎭 Playwright
|
||||
|
||||
[](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[](https://webkit.org/)<!-- GEN:stop -->
|
||||
[](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[](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: |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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]>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,50 @@ title: "Release notes"
|
|||
toc_max_heading_level: 2
|
||||
---
|
||||
|
||||
## Version 1.46
|
||||
|
||||
### TLS Client Certificates
|
||||
|
||||
Playwright now allows to supply client-side certificates, so that server can verify them, as specified by TLS Client Authentication.
|
||||
|
||||
You can provide client certificates as a parameter of [`method: Browser.newContext`] and [`method: APIRequest.newContext`]. The following snippet sets up a client certificate for `https://example.com`:
|
||||
|
||||
```csharp
|
||||
var context = await Browser.NewContextAsync(new() {
|
||||
ClientCertificates = [
|
||||
new() {
|
||||
Origin = "https://example.com",
|
||||
CertPath = "client-certificates/cert.pem",
|
||||
KeyPath = "client-certificates/key.pem",
|
||||
}
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
### Trace Viewer Updates
|
||||
|
||||
- Content of text attachments is now rendered inline in the attachments pane.
|
||||
- New setting to show/hide routing actions like [`method: Route.continue`].
|
||||
- Request method and status are shown in the network details tab.
|
||||
- New button to copy source file location to clipboard.
|
||||
- Metadata pane now displays the `BaseURL`.
|
||||
|
||||
### Miscellaneous
|
||||
|
||||
- New `maxRetries` option in [`method: APIRequestContext.fetch`] which retries on the `ECONNRESET` network error.
|
||||
|
||||
### Browser Versions
|
||||
|
||||
- Chromium 128.0.6613.18
|
||||
- Mozilla Firefox 128.0
|
||||
- WebKit 18.0
|
||||
|
||||
This version was also tested against the following stable channels:
|
||||
|
||||
- Google Chrome 127
|
||||
- Microsoft Edge 127
|
||||
|
||||
|
||||
## Version 1.45
|
||||
|
||||
### Clock
|
||||
|
|
@ -112,7 +156,6 @@ await Page.RemoveLocatorHandlerAsync(locator);
|
|||
**Miscellaneous options**
|
||||
|
||||
- New method [`method: FormData.append`] allows to specify repeating fields with the same name in [`Multipart`](./api/class-apirequestcontext#api-request-context-fetch-option-multipart) option in `APIRequestContext.FetchAsync()`:
|
||||
- ```
|
||||
```csharp
|
||||
var formData = Context.APIRequest.CreateFormData();
|
||||
formData.Append("file", new FilePayload()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -8,9 +8,15 @@ import LiteYouTube from '@site/src/components/LiteYouTube';
|
|||
|
||||
## Version 1.46
|
||||
|
||||
<LiteYouTube
|
||||
id="tQo7w-QQBsI"
|
||||
title="Playwright 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.
|
||||
Playwright now allows you to supply client-side certificates, so that server can verify them, as specified by TLS Client Authentication.
|
||||
|
||||
The following snippet sets up a client certificate for `https://example.com`:
|
||||
|
||||
|
|
@ -33,6 +39,18 @@ export default defineConfig({
|
|||
|
||||
You can also provide client certificates to a particular [test project](./api/class-testproject#test-project-use) or as a parameter of [`method: Browser.newContext`] and [`method: APIRequest.newContext`].
|
||||
|
||||
### `--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 +77,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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ Playwright Trace Viewer is a GUI tool that lets you explore recorded Playwright
|
|||
- [How to open and view the trace](/trace-viewer-intro.md#opening-the-trace)
|
||||
|
||||
<LiteYouTube
|
||||
id="lfxjs--9ZQs"
|
||||
id="yP6AnTxC34s"
|
||||
title="Viewing Playwright Traces"
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ Playwright Trace Viewer is a GUI tool that helps you explore recorded Playwright
|
|||
* langs: js
|
||||
|
||||
<LiteYouTube
|
||||
id="lfxjs--9ZQs"
|
||||
id="yP6AnTxC34s"
|
||||
title="Viewing Playwright Traces"
|
||||
/>
|
||||
|
||||
|
|
|
|||
68
package-lock.json
generated
68
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "playwright-internal",
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "playwright-internal",
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
|
|
@ -7719,10 +7719,10 @@
|
|||
}
|
||||
},
|
||||
"packages/playwright": {
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.46.0-next"
|
||||
"playwright-core": "1.46.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
|
|
@ -7736,11 +7736,11 @@
|
|||
},
|
||||
"packages/playwright-browser-chromium": {
|
||||
"name": "@playwright/browser-chromium",
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.46.0-next"
|
||||
"playwright-core": "1.46.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
@ -7748,11 +7748,11 @@
|
|||
},
|
||||
"packages/playwright-browser-firefox": {
|
||||
"name": "@playwright/browser-firefox",
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.46.0-next"
|
||||
"playwright-core": "1.46.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
@ -7760,22 +7760,22 @@
|
|||
},
|
||||
"packages/playwright-browser-webkit": {
|
||||
"name": "@playwright/browser-webkit",
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.46.0-next"
|
||||
"playwright-core": "1.46.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/playwright-chromium": {
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.46.0-next"
|
||||
"playwright-core": "1.46.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
|
|
@ -7785,7 +7785,7 @@
|
|||
}
|
||||
},
|
||||
"packages/playwright-core": {
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"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.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.46.0-next",
|
||||
"playwright-core": "1.46.0-next",
|
||||
"playwright": "1.46.1",
|
||||
"playwright-core": "1.46.1",
|
||||
"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.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@playwright/experimental-ct-core": "1.46.0-next",
|
||||
"@playwright/experimental-ct-core": "1.46.1",
|
||||
"@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.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@playwright/experimental-ct-core": "1.46.0-next",
|
||||
"@playwright/experimental-ct-core": "1.46.1",
|
||||
"@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.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@playwright/experimental-ct-core": "1.46.0-next",
|
||||
"@playwright/experimental-ct-core": "1.46.1",
|
||||
"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.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@playwright/experimental-ct-core": "1.46.0-next",
|
||||
"@playwright/experimental-ct-core": "1.46.1",
|
||||
"@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.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@playwright/experimental-ct-core": "1.46.0-next",
|
||||
"@playwright/experimental-ct-core": "1.46.1",
|
||||
"@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.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@playwright/experimental-ct-core": "1.46.0-next",
|
||||
"@playwright/experimental-ct-core": "1.46.1",
|
||||
"@vitejs/plugin-vue2": "^2.2.0"
|
||||
},
|
||||
"bin": {
|
||||
|
|
@ -7942,11 +7942,11 @@
|
|||
}
|
||||
},
|
||||
"packages/playwright-firefox": {
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.46.0-next"
|
||||
"playwright-core": "1.46.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
|
|
@ -7957,10 +7957,10 @@
|
|||
},
|
||||
"packages/playwright-test": {
|
||||
"name": "@playwright/test",
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.46.0-next"
|
||||
"playwright": "1.46.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
|
|
@ -7970,11 +7970,11 @@
|
|||
}
|
||||
},
|
||||
"packages/playwright-webkit": {
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.46.0-next"
|
||||
"playwright-core": "1.46.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "playwright-internal",
|
||||
"private": true,
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"description": "A high-level API to automate web browsers",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@playwright/browser-chromium",
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"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.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@playwright/browser-firefox",
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"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.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@playwright/browser-webkit",
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"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.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "playwright-chromium",
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"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.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
# Certfificates for Socks Proxy
|
||||
|
||||
These certificates are used when client certificates are used with
|
||||
Playwright. Playwright then creates a Socks proxy, which sits between
|
||||
the browser and the actual target server. The Socks proxy uses this certificiate
|
||||
to talk to the browser and establishes its own secure TLS connection to the server.
|
||||
The certificates are generated via:
|
||||
|
||||
```bash
|
||||
openssl req -new -newkey rsa:2048 -days 3650 -nodes -x509 -keyout key.pem -out cert.pem -subj "/CN=localhost"
|
||||
```
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIIDCTCCAfGgAwIBAgIUTcrzEueVL/OuLHr4LBIPWeS4UL0wDQYJKoZIhvcNAQEL
|
||||
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI0MDcwNDA4NDAzNFoXDTM0MDcw
|
||||
MjA4NDAzNFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
|
||||
AAOCAQ8AMIIBCgKCAQEApof+SZVN4UGma4xJDVHhMSpmEJoCdMPr+HFadJJK/brF
|
||||
BNOhA1C5wNk8oD/XYo7enAHQH/EsBnq4MMxv79rXTGnIdXMF+43GdMDh5kh81FQy
|
||||
Esw8Vt4eif9eZkjUxI2GHhR2ovJewmQa7E+SeUB2RzJTqz8QPLhd74JFfgaci+S2
|
||||
8L37ScVjcw55T1PcNflzB4vwsQHBT3yND0MLDhm+8MLzmTl4Mw5PgIOaBl5Jh8Tr
|
||||
wQF4eeeB3FPJoMQhTP8aGBjW1mo+NmSSRAPIAZyhmCAnDeC33yRjAaiHjaL5Pr9f
|
||||
wt5zoF5+U1xWhGXWzGOE6p/VTj62F9a2fOXNHclYJQIDAQABo1MwUTAdBgNVHQ4E
|
||||
FgQU9BoVzGtb5x70KqGO/89N1hyqi5kwHwYDVR0jBBgwFoAU9BoVzGtb5x70KqGO
|
||||
/89N1hyqi5kwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAYcbI
|
||||
wvcfx2p8z0RNN3EA+epKX1SagZyJX4ORIO8kln1sDU+ceHde3n3xnp1dg6HG2qh1
|
||||
a7CZub/fNUaP9R8+6iiV0wPT7Ybkb2NIJcH1yq+/bfSS5OC5DO0yv9SUADdBoDwa
|
||||
zOuBAqdcYW1BHYcbAzsQnniRcejHu06ioaS6SwwJ8150rQnLT4Lh9LAl40W6v4nZ
|
||||
NdTGQETTrbjcgH1ER4IhWTKtVyPOxGF9A/OOawMEdfS8BhUO7YRS4QNFFaQMrJAb
|
||||
MDhDtjSyDogLr8P43xjjWvQWG9a7zTF0kKEsdJ0cEG5HATpg8bPHmrouxbs2HGeH
|
||||
kJXzMykrsYyXsInN3w==
|
||||
-----END CERTIFICATE-----
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCmh/5JlU3hQaZr
|
||||
jEkNUeExKmYQmgJ0w+v4cVp0kkr9usUE06EDULnA2TygP9dijt6cAdAf8SwGergw
|
||||
zG/v2tdMach1cwX7jcZ0wOHmSHzUVDISzDxW3h6J/15mSNTEjYYeFHai8l7CZBrs
|
||||
T5J5QHZHMlOrPxA8uF3vgkV+BpyL5LbwvftJxWNzDnlPU9w1+XMHi/CxAcFPfI0P
|
||||
QwsOGb7wwvOZOXgzDk+Ag5oGXkmHxOvBAXh554HcU8mgxCFM/xoYGNbWaj42ZJJE
|
||||
A8gBnKGYICcN4LffJGMBqIeNovk+v1/C3nOgXn5TXFaEZdbMY4Tqn9VOPrYX1rZ8
|
||||
5c0dyVglAgMBAAECggEAB6zX4vNPKhUZAvbtvP/rlZUDLDu05kXLX+F1jk7ZxvTv
|
||||
NKg+UQVM8l7wxN/8YM3944nP2lEGuuu4BoO9mvvmlV6Avy0EdxITNflX0AHCQxT4
|
||||
U9Z253gIR0ruQl+T8tUk+8jsqNjr1iC//ukx8oWujdx7b7aR3IKQzcOeyU6rs2TN
|
||||
lyrVVsEaFVi9+wCw0xyiCmPlobrn+egdigw7Zhp2BRinC6W9eMxuPS2hlhQUhBm/
|
||||
eiD96YWp0RAv/L5qO93reoXIAzrrLdcUgPEnnq1zN7y2xihU2+B2sTph1m/A26+J
|
||||
yPcXd7vQrXlRXQU6PaCa+0oJULlpiAzy3HPbnr4BkQKBgQDdmekTX8dQqiEZPX1C
|
||||
017QRFbx0/x/TDFDSeJbDeauMzzCaGqCO2WVmYmTvFtby2G4/6BYowVtJVHm4uJl
|
||||
XsYk8dWIQGLPIj1Cw7ZieJvb2EVRxgnY2oMaOTOazHzPHFzZV718zwEeZrryT82J
|
||||
881E8wgM8V3DjkS4ye3TbwvimQKBgQDAYa/IdnpAg5z1TREi9Tt8fnoGpmSscAak
|
||||
USgeXVsvoNzXXkE94MiiCOOrX1r68TWYDAzq6MKGDewkWOfLwXWR6D5C2LyE1q9P
|
||||
1pxstgs/nC3ZUTz0yEH47ahSmhywhGlvXXOQEXUSLiVTOdeMCubMqwQW80F1868n
|
||||
aBHcj5/lbQKBgQDIojjsWaNT3TTqbUmj30vQtI8jlBLgDlPr4FEYr5VT0wAH5BHK
|
||||
p4xpzgFJyRfOHG312TuMBM087LUinfjsXsp3WJ1EJ0dO0mk0sY3HyfsTKNRaHTt9
|
||||
Ixnf/DpExS+bNMq73Tyqa6FPrSNFkAtAA4SuEHwRe9aw33ZI+EpjS/8uwQKBgQCi
|
||||
9NwqSLlLVnColEw0uVdXH+cLJPzX19i4bQo3lkp8MJ2ATJWk7XflUPRQoGf3ckQ8
|
||||
c9CpVtoXJUnmi+xkeo21Nu0uQFqHhzZewWIk75rdmdR4ZUjl649+ZQkUVviASNjq
|
||||
fVU7Lp5k9POm6LL9K+rOaPoA2rKTUAQItC2VD4+YjQKBgB6kgvgN6Mz/u0RE3kkV
|
||||
2GOoP5sso71Hxwh7o6JEzUMhR+e/T/LLcBwEjLYcf1FYRySHsXLn2Ar/Uw1J7pAZ
|
||||
ud54/at+7mTDliaT8Ar7S9vcso7ZfmuDX9qB9+c77idPskVBPo2tjJbwvFcB6sww
|
||||
5Elcfmj6tEP4YLJ6Kv3qTPhT
|
||||
-----END PRIVATE KEY-----
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "playwright-core",
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"description": "A high-level API to automate web browsers",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.'));
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -15,14 +15,12 @@
|
|||
*/
|
||||
|
||||
import net from 'net';
|
||||
import path from 'path';
|
||||
import http2 from 'http2';
|
||||
import type https from 'https';
|
||||
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, generateSelfSignedCertificate, ManualPromise, rewriteErrorMessage } from '../utils';
|
||||
import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../common/socksProxy';
|
||||
import { SocksProxy } from '../common/socksProxy';
|
||||
import type * as channels from '@protocol/channels';
|
||||
|
|
@ -32,10 +30,8 @@ let dummyServerTlsOptions: tls.TlsOptions | undefined = undefined;
|
|||
function loadDummyServerCertsIfNeeded() {
|
||||
if (dummyServerTlsOptions)
|
||||
return;
|
||||
dummyServerTlsOptions = {
|
||||
key: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/key.pem')),
|
||||
cert: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/cert.pem')),
|
||||
};
|
||||
const { cert, key } = generateSelfSignedCertificate();
|
||||
dummyServerTlsOptions = { key, cert };
|
||||
}
|
||||
|
||||
class ALPNCache {
|
||||
|
|
@ -60,11 +56,9 @@ class ALPNCache {
|
|||
ALPNProtocols: ['h2', 'http/1.1'],
|
||||
rejectUnauthorized: false,
|
||||
}).then(socket => {
|
||||
socket.on('secureConnect', () => {
|
||||
// The server may not respond with ALPN, in which case we default to http/1.1.
|
||||
result.resolve(socket.alpnProtocol || 'http/1.1');
|
||||
socket.end();
|
||||
});
|
||||
}).catch(error => {
|
||||
debugLogger.log('client-certificates', `ALPN error: ${error.message}`);
|
||||
result.resolve('http/1.1');
|
||||
|
|
@ -93,8 +87,8 @@ class SocksProxyConnection {
|
|||
|
||||
async connect() {
|
||||
this.target = await createSocket(rewriteToLocalhostIfNeeded(this.host), this.port);
|
||||
this.target.on('close', this._targetCloseEventListener);
|
||||
this.target.on('error', error => this.socksProxy._socksProxy.sendSocketError({ uid: this.uid, error: error.message }));
|
||||
this.target.once('close', this._targetCloseEventListener);
|
||||
this.target.once('error', error => this.socksProxy._socksProxy.sendSocketError({ uid: this.uid, error: error.message }));
|
||||
this.socksProxy._socksProxy.socketConnected({
|
||||
uid: this.uid,
|
||||
host: this.target.localAddress!,
|
||||
|
|
@ -138,42 +132,22 @@ class SocksProxyConnection {
|
|||
...dummyServerTlsOptions,
|
||||
ALPNProtocols: alpnProtocolChosenByServer === 'h2' ? ['h2', 'http/1.1'] : ['http/1.1'],
|
||||
});
|
||||
this.internal?.on('close', () => dummyServer.close());
|
||||
this.internal?.once('close', () => dummyServer.close());
|
||||
dummyServer.emit('connection', this.internal);
|
||||
dummyServer.on('secureConnection', internalTLS => {
|
||||
dummyServer.once('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) {
|
||||
|
|
@ -182,7 +156,7 @@ class SocksProxyConnection {
|
|||
this.target.removeListener('close', this._targetCloseEventListener);
|
||||
// @ts-expect-error
|
||||
const session: http2.ServerHttp2Session = http2.performServerHandshake(internalTLS);
|
||||
session.on('stream', (stream: http2.ServerHttp2Stream) => {
|
||||
session.once('stream', (stream: http2.ServerHttp2Stream) => {
|
||||
stream.respond({
|
||||
'content-type': 'text/html',
|
||||
[http2.constants.HTTP2_HEADER_STATUS]: 503,
|
||||
|
|
@ -191,7 +165,7 @@ class SocksProxyConnection {
|
|||
session.close();
|
||||
closeBothSockets();
|
||||
});
|
||||
stream.on('error', () => closeBothSockets());
|
||||
stream.once('error', () => closeBothSockets());
|
||||
});
|
||||
} else {
|
||||
closeBothSockets();
|
||||
|
|
@ -201,12 +175,42 @@ 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.once('secureConnect', () => {
|
||||
internalTLS.pipe(targetTLS);
|
||||
targetTLS.pipe(internalTLS);
|
||||
});
|
||||
|
||||
internalTLS.once('close', () => closeBothSockets());
|
||||
|
||||
internalTLS.once('error', () => closeBothSockets());
|
||||
targetTLS.once('error', handleError);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -288,3 +292,14 @@ export function clientCertificatesToTLSOptions(
|
|||
function rewriteToLocalhostIfNeeded(host: string): string {
|
||||
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'));
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { assert } from './debug';
|
||||
|
||||
export function createGuid(): string {
|
||||
return crypto.randomBytes(16).toString('hex');
|
||||
|
|
@ -25,3 +26,170 @@ export function calculateSha1(buffer: Buffer | string): string {
|
|||
hash.update(buffer);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
// Variable-length quantity encoding aka. base-128 encoding
|
||||
function encodeBase128(value: number): Buffer {
|
||||
const bytes = [];
|
||||
do {
|
||||
let byte = value & 0x7f;
|
||||
value >>>= 7;
|
||||
if (bytes.length > 0) byte |= 0x80;
|
||||
bytes.push(byte);
|
||||
} while (value > 0);
|
||||
return Buffer.from(bytes.reverse());
|
||||
};
|
||||
|
||||
// ASN1/DER Speficiation: https://www.itu.int/rec/T-REC-X.680-X.693-202102-I/en
|
||||
class DER {
|
||||
static encodeSequence(data: Buffer[]): Buffer {
|
||||
return this._encode(0x30, Buffer.concat(data));
|
||||
}
|
||||
static encodeInteger(data: number): Buffer {
|
||||
assert(data >= -128 && data <= 127);
|
||||
return this._encode(0x02, Buffer.from([data]));
|
||||
}
|
||||
static encodeObjectIdentifier(oid: string): Buffer {
|
||||
const parts = oid.split('.').map((v) => Number(v));
|
||||
// Encode the second part, which could be large, using base-128 encoding if necessary
|
||||
const output = [encodeBase128(40 * parts[0] + parts[1])];
|
||||
|
||||
for (let i = 2; i < parts.length; i++) {
|
||||
output.push(encodeBase128(parts[i]));
|
||||
}
|
||||
|
||||
return this._encode(0x06, Buffer.concat(output));
|
||||
}
|
||||
static encodeNull(): Buffer {
|
||||
return Buffer.from([0x05, 0x00]);
|
||||
}
|
||||
static encodeSet(data: Buffer[]): Buffer {
|
||||
assert(data.length === 1, 'Only one item in the set is supported. We\'d need to sort the data to support more.');
|
||||
// We expect the data to be already sorted.
|
||||
return this._encode(0x31, Buffer.concat(data));
|
||||
}
|
||||
static encodeExplicitContextDependent(tag: number, data: Buffer): Buffer {
|
||||
return this._encode(0xa0 + tag, data);
|
||||
}
|
||||
static encodePrintableString(data: string): Buffer {
|
||||
return this._encode(0x13, Buffer.from(data));
|
||||
}
|
||||
static encodeBitString(data: Buffer): Buffer {
|
||||
// The first byte of the content is the number of unused bits at the end
|
||||
const unusedBits = 0; // Assuming all bits are used
|
||||
const content = Buffer.concat([Buffer.from([unusedBits]), data]);
|
||||
return this._encode(0x03, content);
|
||||
}
|
||||
static encodeDate(date: Date): Buffer {
|
||||
const year = date.getUTCFullYear();
|
||||
const isGeneralizedTime = year >= 2050;
|
||||
const parts = [
|
||||
isGeneralizedTime ? year.toString() : year.toString().slice(-2),
|
||||
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
|
||||
date.getUTCDate().toString().padStart(2, '0'),
|
||||
date.getUTCHours().toString().padStart(2, '0'),
|
||||
date.getUTCMinutes().toString().padStart(2, '0'),
|
||||
date.getUTCSeconds().toString().padStart(2, '0')
|
||||
];
|
||||
const encodedDate = parts.join('') + 'Z';
|
||||
const tag = isGeneralizedTime ? 0x18 : 0x17; // 0x18 for GeneralizedTime, 0x17 for UTCTime
|
||||
return this._encode(tag, Buffer.from(encodedDate));
|
||||
}
|
||||
private static _encode(tag: number, data: Buffer): Buffer {
|
||||
const lengthBytes = this._encodeLength(data.length);
|
||||
return Buffer.concat([Buffer.from([tag]), lengthBytes, data]);
|
||||
}
|
||||
private static _encodeLength(length: number): Buffer {
|
||||
if (length < 128) {
|
||||
return Buffer.from([length]);
|
||||
} else {
|
||||
const lengthBytes = [];
|
||||
while (length > 0) {
|
||||
lengthBytes.unshift(length & 0xFF);
|
||||
length >>= 8;
|
||||
}
|
||||
return Buffer.from([0x80 | lengthBytes.length, ...lengthBytes]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// X.509 Specification: https://datatracker.ietf.org/doc/html/rfc2459#section-4.1
|
||||
export function generateSelfSignedCertificate() {
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const publicKeyDer = publicKey.export({ type: 'pkcs1', format: 'der' });
|
||||
|
||||
const oneYearInMilliseconds = 365 * 24 * 60 * 60 * 1_000;
|
||||
const notBefore = new Date(new Date().getTime() - oneYearInMilliseconds);
|
||||
const notAfter = new Date(new Date().getTime() + oneYearInMilliseconds);
|
||||
|
||||
// List of fields / structure: https://datatracker.ietf.org/doc/html/rfc2459#section-4.1
|
||||
const tbsCertificate = DER.encodeSequence([
|
||||
DER.encodeExplicitContextDependent(0, DER.encodeInteger(1)), // version
|
||||
DER.encodeInteger(1), // serialNumber
|
||||
DER.encodeSequence([
|
||||
DER.encodeObjectIdentifier('1.2.840.113549.1.1.11'), // sha256WithRSAEncryption PKCS #1
|
||||
DER.encodeNull()
|
||||
]), // signature
|
||||
DER.encodeSequence([
|
||||
DER.encodeSet([
|
||||
DER.encodeSequence([
|
||||
DER.encodeObjectIdentifier('2.5.4.3'), // commonName X.520 DN component
|
||||
DER.encodePrintableString('localhost')
|
||||
]),
|
||||
]),
|
||||
DER.encodeSet([
|
||||
DER.encodeSequence([
|
||||
DER.encodeObjectIdentifier('2.5.4.10'), // organizationName X.520 DN component
|
||||
DER.encodePrintableString('Playwright Client Certificate Support')
|
||||
])
|
||||
])
|
||||
]), // issuer
|
||||
DER.encodeSequence([
|
||||
DER.encodeDate(notBefore), // notBefore
|
||||
DER.encodeDate(notAfter), // notAfter
|
||||
]), // validity
|
||||
DER.encodeSequence([
|
||||
DER.encodeSet([
|
||||
DER.encodeSequence([
|
||||
DER.encodeObjectIdentifier('2.5.4.3'), // commonName X.520 DN component
|
||||
DER.encodePrintableString('localhost')
|
||||
]),
|
||||
]),
|
||||
DER.encodeSet([
|
||||
DER.encodeSequence([
|
||||
DER.encodeObjectIdentifier('2.5.4.10'), // organizationName X.520 DN component
|
||||
DER.encodePrintableString('Playwright Client Certificate Support')
|
||||
])
|
||||
])
|
||||
]), // subject
|
||||
DER.encodeSequence([
|
||||
DER.encodeSequence([
|
||||
DER.encodeObjectIdentifier('1.2.840.113549.1.1.1'), // rsaEncryption PKCS #1
|
||||
DER.encodeNull()
|
||||
]),
|
||||
DER.encodeBitString(publicKeyDer)
|
||||
]), // SubjectPublicKeyInfo
|
||||
]);
|
||||
|
||||
const signature = crypto.sign('sha256', tbsCertificate, privateKey);
|
||||
|
||||
const certificate = DER.encodeSequence([
|
||||
tbsCertificate,
|
||||
DER.encodeSequence([
|
||||
DER.encodeObjectIdentifier('1.2.840.113549.1.1.11'), // sha256WithRSAEncryption PKCS #1
|
||||
DER.encodeNull()
|
||||
]),
|
||||
DER.encodeBitString(signature)
|
||||
]);
|
||||
|
||||
const certPem = [
|
||||
'-----BEGIN CERTIFICATE-----',
|
||||
// Split the base64 string into lines of 64 characters
|
||||
certificate.toString('base64').match(/.{1,64}/g)!.join('\n'),
|
||||
'-----END CERTIFICATE-----'
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
cert: certPem,
|
||||
key: privateKey.export({ type: 'pkcs1', format: 'pem' }),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,14 +72,16 @@ export async function createTLSSocket(options: tls.ConnectionOptions): Promise<t
|
|||
assert(options.host, 'host is required');
|
||||
if (net.isIP(options.host)) {
|
||||
const socket = tls.connect(options)
|
||||
socket.on('connect', () => resolve(socket));
|
||||
socket.on('secureConnect', () => resolve(socket));
|
||||
socket.on('error', error => reject(error));
|
||||
} else {
|
||||
createConnectionAsync(options, (err, socket) => {
|
||||
if (err)
|
||||
reject(err);
|
||||
if (socket)
|
||||
resolve(socket);
|
||||
if (socket) {
|
||||
socket.on('secureConnect', () => resolve(socket));
|
||||
socket.on('error', error => reject(error));
|
||||
}
|
||||
}, true).catch(err => reject(err));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -132,3 +132,11 @@ export function escapeRegExp(s: string) {
|
|||
// From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
}
|
||||
|
||||
const escaped = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' };
|
||||
export function escapeHTMLAttribute(s: string): string {
|
||||
return s.replace(/[&<>"']/ug, char => (escaped as any)[char]);
|
||||
}
|
||||
export function escapeHTML(s: string): string {
|
||||
return s.replace(/[&<]/ug, char => (escaped as any)[char]);
|
||||
}
|
||||
|
|
|
|||
12
packages/playwright-core/types/types.d.ts
vendored
12
packages/playwright-core/types/types.d.ts
vendored
|
|
@ -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).
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@playwright/experimental-ct-core",
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"description": "Playwright Component Testing Helpers",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
@ -26,8 +26,8 @@
|
|||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright-core": "1.46.0-next",
|
||||
"playwright-core": "1.46.1",
|
||||
"vite": "^5.2.8",
|
||||
"playwright": "1.46.0-next"
|
||||
"playwright": "1.46.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@playwright/experimental-ct-react",
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"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.1",
|
||||
"@vitejs/plugin-react": "^4.2.1"
|
||||
},
|
||||
"bin": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@playwright/experimental-ct-react17",
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"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.1",
|
||||
"@vitejs/plugin-react": "^4.2.1"
|
||||
},
|
||||
"bin": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@playwright/experimental-ct-solid",
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"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.1",
|
||||
"vite-plugin-solid": "^2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@playwright/experimental-ct-svelte",
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"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.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@playwright/experimental-ct-vue",
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"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.1",
|
||||
"@vitejs/plugin-vue": "^4.2.1"
|
||||
},
|
||||
"bin": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@playwright/experimental-ct-vue2",
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"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.1",
|
||||
"@vitejs/plugin-vue2": "^2.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "playwright-firefox",
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"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.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@playwright/test",
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"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.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "playwright-webkit",
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"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.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "playwright",
|
||||
"version": "1.46.0-next",
|
||||
"version": "1.46.1",
|
||||
"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.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
|
|
|
|||
|
|
@ -261,6 +261,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
|
|||
title: renderApiCall(apiName, params),
|
||||
apiName,
|
||||
params,
|
||||
canNestByTime: true,
|
||||
});
|
||||
userData.userObject = step;
|
||||
out.stepId = step.stepId;
|
||||
|
|
|
|||
|
|
@ -136,10 +136,10 @@ export async function createRootSuite(testRun: TestRun, errors: TestError[], sho
|
|||
|
||||
// Filter file suites for all projects.
|
||||
for (const [project, fileSuites] of testRun.projectSuites) {
|
||||
const filteredFileSuites = additionalFileMatcher ? fileSuites.filter(fileSuite => additionalFileMatcher(fileSuite.location!.file)) : fileSuites;
|
||||
const projectSuite = createProjectSuite(project, filteredFileSuites);
|
||||
const projectSuite = createProjectSuite(project, fileSuites);
|
||||
projectSuites.set(project, projectSuite);
|
||||
const filteredProjectSuite = filterProjectSuite(projectSuite, { cliFileFilters, cliTitleMatcher, testIdMatcher: config.testIdMatcher });
|
||||
|
||||
const filteredProjectSuite = filterProjectSuite(projectSuite, { cliFileFilters, cliTitleMatcher, testIdMatcher: config.testIdMatcher, additionalFileMatcher });
|
||||
filteredProjectSuites.set(project, filteredProjectSuite);
|
||||
}
|
||||
}
|
||||
|
|
@ -200,8 +200,8 @@ export async function createRootSuite(testRun: TestRun, errors: TestError[], sho
|
|||
const projectClosure = new Map(buildProjectsClosure(rootSuite.suites.map(suite => suite._fullProject!)));
|
||||
|
||||
// Clone file suites for dependency projects.
|
||||
for (const project of projectClosure.keys()) {
|
||||
if (projectClosure.get(project) === 'dependency')
|
||||
for (const [project, level] of projectClosure.entries()) {
|
||||
if (level === 'dependency')
|
||||
rootSuite._prependSuite(buildProjectSuite(project, projectSuites.get(project)!));
|
||||
}
|
||||
}
|
||||
|
|
@ -225,9 +225,9 @@ function createProjectSuite(project: FullProjectInternal, fileSuites: Suite[]):
|
|||
return projectSuite;
|
||||
}
|
||||
|
||||
function filterProjectSuite(projectSuite: Suite, options: { cliFileFilters: TestFileFilter[], cliTitleMatcher?: Matcher, testIdMatcher?: Matcher }): Suite {
|
||||
function filterProjectSuite(projectSuite: Suite, options: { cliFileFilters: TestFileFilter[], cliTitleMatcher?: Matcher, testIdMatcher?: Matcher, additionalFileMatcher?: Matcher }): Suite {
|
||||
// Fast path.
|
||||
if (!options.cliFileFilters.length && !options.cliTitleMatcher && !options.testIdMatcher)
|
||||
if (!options.cliFileFilters.length && !options.cliTitleMatcher && !options.testIdMatcher && !options.additionalFileMatcher)
|
||||
return projectSuite;
|
||||
|
||||
const result = projectSuite._deepClone();
|
||||
|
|
@ -238,6 +238,8 @@ function filterProjectSuite(projectSuite: Suite, options: { cliFileFilters: Test
|
|||
filterTestsRemoveEmptySuites(result, (test: TestCase) => {
|
||||
if (options.cliTitleMatcher && !options.cliTitleMatcher(test._grepTitle()))
|
||||
return false;
|
||||
if (options.additionalFileMatcher && !options.additionalFileMatcher(test.location.file))
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,18 @@ export async function detectChangedTestFiles(baseCommit: string, configDir: stri
|
|||
).split('\n').filter(Boolean);
|
||||
} catch (_error) {
|
||||
const error = _error as childProcess.SpawnSyncReturns<string>;
|
||||
|
||||
const unknownRevision = error.output.some(line => line?.includes('unknown revision'));
|
||||
if (unknownRevision) {
|
||||
const isShallowClone = childProcess.execSync('git rev-parse --is-shallow-repository', { encoding: 'utf-8', stdio: 'pipe' }).trim() === 'true';
|
||||
if (isShallowClone) {
|
||||
throw new Error([
|
||||
`The repository is a shallow clone and does not have '${baseCommit}' available locally.`,
|
||||
`Note that GitHub Actions checkout is shallow by default: https://github.com/actions/checkout`
|
||||
].join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error([
|
||||
`Cannot detect changed files for --only-changed mode:`,
|
||||
`git ${command}`,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
4
packages/playwright/types/test.d.ts
vendored
4
packages/playwright/types/test.d.ts
vendored
|
|
@ -4815,9 +4815,9 @@ export type Fixtures<T extends KeyValue = {}, W extends KeyValue = {}, PT extend
|
|||
} & {
|
||||
[K in keyof PT]?: TestFixtureValue<PT[K], T & W & PT & PW> | [TestFixtureValue<PT[K], T & W & PT & PW>, { scope: 'test', timeout?: number | undefined, title?: string, box?: boolean }];
|
||||
} & {
|
||||
[K in Exclude<keyof W, keyof PW>]?: [WorkerFixtureValue<W[K], W & PW>, { scope: 'worker', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean }];
|
||||
[K in keyof W]?: [WorkerFixtureValue<W[K], W & PW>, { scope: 'worker', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean }];
|
||||
} & {
|
||||
[K in Exclude<keyof T, keyof PT>]?: TestFixtureValue<T[K], T & W & PT & PW> | [TestFixtureValue<T[K], T & W & PT & PW>, { scope?: 'test', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean }];
|
||||
[K in keyof T]?: TestFixtureValue<T[K], T & W & PT & PW> | [TestFixtureValue<T[K], T & W & PT & PW>, { scope?: 'test', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean }];
|
||||
};
|
||||
|
||||
type BrowserName = 'chromium' | 'firefox' | 'webkit';
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { escapeHTMLAttribute, escapeHTML } from '@isomorphic/stringUtils';
|
||||
import type { FrameSnapshot, NodeNameAttributesChildNodesSnapshot, NodeSnapshot, RenderedFrameSnapshot, ResourceSnapshot, SubtreeReferenceSnapshot } from '@trace/snapshot';
|
||||
|
||||
function isNodeNameAttributesChildNodesSnapshot(n: NodeSnapshot): n is NodeNameAttributesChildNodesSnapshot {
|
||||
|
|
@ -57,7 +58,7 @@ export class SnapshotRenderer {
|
|||
// Old snapshotter was sending lower-case.
|
||||
if (parentTag === 'STYLE' || parentTag === 'style')
|
||||
return rewriteURLsInStyleSheetForCustomProtocol(n);
|
||||
return escapeText(n);
|
||||
return escapeHTML(n);
|
||||
}
|
||||
|
||||
if (!(n as any)._string) {
|
||||
|
|
@ -106,7 +107,7 @@ export class SnapshotRenderer {
|
|||
attrValue = 'link://' + value;
|
||||
else if (attr.toLowerCase() === 'href' || attr.toLowerCase() === 'src' || attr === kCurrentSrcAttribute)
|
||||
attrValue = rewriteURLForCustomProtocol(value);
|
||||
builder.push(' ', attrName, '="', escapeAttribute(attrValue), '"');
|
||||
builder.push(' ', attrName, '="', escapeHTMLAttribute(attrValue), '"');
|
||||
}
|
||||
builder.push('>');
|
||||
for (const child of children)
|
||||
|
|
@ -193,14 +194,6 @@ export class SnapshotRenderer {
|
|||
}
|
||||
|
||||
const autoClosing = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
|
||||
const escaped = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' };
|
||||
|
||||
function escapeAttribute(s: string): string {
|
||||
return s.replace(/[&<>"']/ug, char => (escaped as any)[char]);
|
||||
}
|
||||
function escapeText(s: string): string {
|
||||
return s.replace(/[&<]/ug, char => (escaped as any)[char]);
|
||||
}
|
||||
|
||||
function snapshotNodes(snapshot: FrameSnapshot): NodeSnapshot[] {
|
||||
if (!(snapshot as any)._nodes) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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!;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
28
packages/trace-viewer/src/ui/annotationsTab.css
Normal file
28
packages/trace-viewer/src/ui/annotationsTab.css
Normal 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;
|
||||
}
|
||||
39
packages/trace-viewer/src/ui/annotationsTab.tsx
Normal file
39
packages/trace-viewer/src/ui/annotationsTab.tsx
Normal 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>;
|
||||
};
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}/>;
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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() ?? '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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] {
|
||||
|
|
|
|||
|
|
@ -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,7 +496,9 @@ export const UIModeView: React.FC<{}> = ({
|
|||
isLoading={isLoading}
|
||||
requestedCollapseAllCount={collapseAllCount}
|
||||
setFilterText={setFilterText}
|
||||
onRevealSource={onRevealSource}
|
||||
/>
|
||||
{showTestingOptions && <>
|
||||
<Toolbar noShadow={true} noMinHeight={true} className='settings-toolbar' onClick={() => setTestingOptionsVisible(!testingOptionsVisible)}>
|
||||
<span
|
||||
className={`codicon codicon-${testingOptionsVisible ? 'chevron-down' : 'chevron-right'}`}
|
||||
|
|
@ -501,6 +512,7 @@ export const UIModeView: React.FC<{}> = ({
|
|||
showBrowserSetting,
|
||||
updateSnapshotsSetting,
|
||||
]} />}
|
||||
</>}
|
||||
<Toolbar noShadow={true} noMinHeight={true} className='settings-toolbar' onClick={() => setSettingsVisible(!settingsVisible)}>
|
||||
<span
|
||||
className={`codicon codicon-${settingsVisible ? 'chevron-down' : 'chevron-right'}`}
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -60,7 +60,8 @@ const checkerboardStyle: React.CSSProperties = {
|
|||
|
||||
export const ImageDiffView: React.FC<{
|
||||
diff: ImageDiff,
|
||||
}> = ({ diff }) => {
|
||||
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>;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
BIN
tests/assets/client-certificates/client/trusted/cert-legacy.pfx
Normal file
BIN
tests/assets/client-certificates/client/trusted/cert-legacy.pfx
Normal file
Binary file not shown.
BIN
tests/assets/client-certificates/client/trusted/cert.pfx
Normal file
BIN
tests/assets/client-certificates/client/trusted/cert.pfx
Normal file
Binary file not shown.
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import tls from 'tls';
|
||||
import type http2 from 'http2';
|
||||
import type http from 'http';
|
||||
import { expect, playwrightTest as base } from '../config/browserTest';
|
||||
|
|
@ -24,6 +25,7 @@ const { createHttpsServer, createHttp2Server } = require('../../packages/playwri
|
|||
|
||||
type TestOptions = {
|
||||
startCCServer(options?: {
|
||||
host?: string;
|
||||
http2?: boolean;
|
||||
enableHTTP1FallbackWhenUsingHttp2?: boolean;
|
||||
useFakeLocalhost?: boolean;
|
||||
|
|
@ -63,7 +65,7 @@ const test = base.extend<TestOptions>({
|
|||
}
|
||||
res.end(parts.map(({ key, value }) => `<div data-testid="${key}">${value}</div>`).join(''));
|
||||
});
|
||||
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 +83,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 +113,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 +122,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 +139,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 +156,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 +170,55 @@ test.describe('fetch', () => {
|
|||
await request.dispose();
|
||||
});
|
||||
|
||||
test('pass with trusted client certificates in pfx format', async ({ playwright, startCCServer, asset }) => {
|
||||
const serverURL = await startCCServer();
|
||||
const request = await playwright.request.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
clientCertificates: [{
|
||||
origin: new URL(serverURL).origin,
|
||||
pfxPath: asset('client-certificates/client/trusted/cert.pfx'),
|
||||
passphrase: 'secure'
|
||||
}],
|
||||
});
|
||||
const response = await request.get(serverURL);
|
||||
expect(response.url()).toBe(serverURL);
|
||||
expect(response.status()).toBe(200);
|
||||
expect(await response.text()).toContain('Hello Alice, your certificate was issued by localhost!');
|
||||
await request.dispose();
|
||||
});
|
||||
|
||||
test('should throw a http error if the pfx passphrase is incorect', async ({ playwright, startCCServer, asset, browserName }) => {
|
||||
const serverURL = await startCCServer();
|
||||
const request = await playwright.request.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
clientCertificates: [{
|
||||
origin: new URL(serverURL).origin,
|
||||
pfxPath: asset('client-certificates/client/trusted/cert.pfx'),
|
||||
passphrase: 'this-password-is-incorrect'
|
||||
}],
|
||||
});
|
||||
await expect(request.get(serverURL)).rejects.toThrow('mac verify failure');
|
||||
await request.dispose();
|
||||
});
|
||||
|
||||
test('should fail with matching certificates in legacy pfx format', async ({ playwright, startCCServer, asset, browserName }) => {
|
||||
const serverURL = await startCCServer();
|
||||
const request = await playwright.request.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
clientCertificates: [{
|
||||
origin: new URL(serverURL).origin,
|
||||
pfxPath: asset('client-certificates/client/trusted/cert-legacy.pfx'),
|
||||
passphrase: 'secure'
|
||||
}],
|
||||
});
|
||||
await expect(request.get(serverURL)).rejects.toThrow('Unsupported TLS certificate');
|
||||
await request.dispose();
|
||||
});
|
||||
|
||||
test('should work in the browser with request interception', async ({ browser, playwright, startCCServer, asset }) => {
|
||||
const serverURL = await startCCServer();
|
||||
const request = await playwright.request.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
clientCertificates: [{
|
||||
origin: new URL(serverURL).origin,
|
||||
certPath: asset('client-certificates/client/trusted/cert.pem'),
|
||||
|
|
@ -212,6 +261,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 +276,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 +291,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 +303,120 @@ test.describe('browser', () => {
|
|||
await page.close();
|
||||
});
|
||||
|
||||
test('should not hang on tls errors during TLS 1.2 handshake', async ({ browser, asset, platform, browserName }) => {
|
||||
for (const tlsVersion of ['TLSv1.3', 'TLSv1.2'] as const) {
|
||||
await test.step(`TLS version: ${tlsVersion}`, async () => {
|
||||
const server = tls.createServer({
|
||||
key: fs.readFileSync(asset('client-certificates/server/server_key.pem')),
|
||||
cert: fs.readFileSync(asset('client-certificates/server/server_cert.pem')),
|
||||
ca: [
|
||||
fs.readFileSync(asset('client-certificates/server/server_cert.pem')),
|
||||
],
|
||||
requestCert: true,
|
||||
rejectUnauthorized: true,
|
||||
minVersion: tlsVersion,
|
||||
maxVersion: tlsVersion,
|
||||
SNICallback: (servername, cb) => {
|
||||
// Always reject the connection by passing an error
|
||||
cb(new Error('Connection rejected'), null);
|
||||
}
|
||||
}, () => {
|
||||
// Do nothing
|
||||
});
|
||||
const serverURL = await new Promise<string>(resolve => {
|
||||
server.listen(0, 'localhost', () => {
|
||||
const host = browserName === 'webkit' && platform === 'darwin' ? 'local.playwright' : 'localhost';
|
||||
resolve(`https://${host}:${(server.address() as net.AddressInfo).port}/`);
|
||||
});
|
||||
});
|
||||
const page = await browser.newPage({
|
||||
ignoreHTTPSErrors: true,
|
||||
clientCertificates: [{
|
||||
origin: new URL(serverURL).origin,
|
||||
certPath: asset('client-certificates/client/self-signed/cert.pem'),
|
||||
keyPath: asset('client-certificates/client/self-signed/key.pem'),
|
||||
}],
|
||||
});
|
||||
await page.goto(serverURL);
|
||||
await expect(page.getByText('Playwright client-certificate error: Client network socket disconnected before secure TLS connection was established')).toBeVisible();
|
||||
await page.close();
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('should pass with matching certificates in pfx format', async ({ browser, startCCServer, asset, browserName }) => {
|
||||
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
|
||||
const page = await browser.newPage({
|
||||
ignoreHTTPSErrors: true,
|
||||
clientCertificates: [{
|
||||
origin: new URL(serverURL).origin,
|
||||
pfxPath: asset('client-certificates/client/trusted/cert.pfx'),
|
||||
passphrase: 'secure'
|
||||
}],
|
||||
});
|
||||
await page.goto(serverURL);
|
||||
await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!');
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should fail with matching certificates in legacy pfx format', async ({ browser, startCCServer, asset, browserName }) => {
|
||||
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
|
||||
const page = await browser.newPage({
|
||||
ignoreHTTPSErrors: true,
|
||||
clientCertificates: [{
|
||||
origin: new URL(serverURL).origin,
|
||||
pfxPath: asset('client-certificates/client/trusted/cert-legacy.pfx'),
|
||||
passphrase: 'secure'
|
||||
}],
|
||||
});
|
||||
await page.goto(serverURL);
|
||||
await expect(page.getByText('Unsupported TLS certificate.')).toBeVisible();
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should throw a http error if the pfx passphrase is incorect', async ({ browser, startCCServer, asset, browserName }) => {
|
||||
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
|
||||
const page = await browser.newPage({
|
||||
ignoreHTTPSErrors: true,
|
||||
clientCertificates: [{
|
||||
origin: new URL(serverURL).origin,
|
||||
pfxPath: asset('client-certificates/client/trusted/cert.pfx'),
|
||||
passphrase: 'this-password-is-incorrect'
|
||||
}],
|
||||
});
|
||||
await page.goto(serverURL);
|
||||
await expect(page.getByText('Playwright client-certificate error: mac verify failure')).toBeVisible();
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should pass with matching certificates on context APIRequestContext instance', async ({ browser, startCCServer, asset, browserName }) => {
|
||||
const serverURL = await startCCServer({ host: '127.0.0.1' });
|
||||
const baseOptions = {
|
||||
certPath: asset('client-certificates/client/trusted/cert.pem'),
|
||||
keyPath: asset('client-certificates/client/trusted/key.pem'),
|
||||
};
|
||||
const page = await browser.newPage({
|
||||
clientCertificates: [{
|
||||
origin: new URL(serverURL).origin,
|
||||
...baseOptions,
|
||||
}, {
|
||||
origin: new URL(serverURL).origin.replace('localhost', '127.0.0.1'),
|
||||
...baseOptions,
|
||||
}],
|
||||
});
|
||||
for (const url of [serverURL, serverURL.replace('localhost', '127.0.0.1')]) {
|
||||
const response = await page.request.get(url);
|
||||
expect(response.status()).toBe(200);
|
||||
expect(await response.text()).toContain('Hello Alice, your certificate was issued by localhost!');
|
||||
}
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should pass with matching certificates and trailing slash', async ({ browser, startCCServer, asset, browserName }) => {
|
||||
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 +437,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 +446,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 +475,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 +500,6 @@ test.describe('browser', () => {
|
|||
test.fixme(browserName === 'webkit' && process.platform === 'linux', 'WebKit on Linux does not support http2 https://bugs.webkit.org/show_bug.cgi?id=276990');
|
||||
test.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 +523,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'),
|
||||
|
|
|
|||
|
|
@ -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 ({}, use) => {
|
||||
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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -365,3 +365,52 @@ test('UI mode is not supported', async ({ runInlineTest }) => {
|
|||
expect(result.exitCode).toBe(1);
|
||||
expect(result.output).toContain('--only-changed is not supported in UI mode');
|
||||
});
|
||||
|
||||
test('should run project dependencies of changed tests', {
|
||||
annotation: {
|
||||
type: 'issue',
|
||||
description: 'https://github.com/microsoft/playwright/issues/32070',
|
||||
},
|
||||
}, async ({ runInlineTest, git, writeFiles }) => {
|
||||
await writeFiles({
|
||||
'playwright.config.ts': `
|
||||
module.exports = {
|
||||
projects: [
|
||||
{ name: 'setup', testMatch: 'setup.spec.ts', },
|
||||
{ name: 'main', dependencies: ['setup'] },
|
||||
],
|
||||
};
|
||||
`,
|
||||
'setup.spec.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('setup test', async ({ page }) => {
|
||||
console.log('setup test is executed')
|
||||
});
|
||||
`,
|
||||
'a.spec.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('fails', () => { expect(1).toBe(2); });
|
||||
`,
|
||||
'b.spec.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('fails', () => { expect(1).toBe(2); });
|
||||
`,
|
||||
});
|
||||
|
||||
git(`add .`);
|
||||
git(`commit -m init`);
|
||||
|
||||
const result = await runInlineTest({
|
||||
'c.spec.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('fails', () => { expect(1).toBe(2); });
|
||||
`
|
||||
}, { 'only-changed': true });
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.failed).toBe(1);
|
||||
expect(result.passed).toBe(1);
|
||||
|
||||
expect(result.output).toContain('setup test is executed');
|
||||
});
|
||||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { test, expect } from './playwright-test-fixtures';
|
|||
test('should check types of fixtures', async ({ runTSC }) => {
|
||||
const result = await runTSC({
|
||||
'helper.ts': `
|
||||
import { test as base, expect } from '@playwright/test';
|
||||
import { test as base, expect, Page } from '@playwright/test';
|
||||
export type MyOptions = { foo: string, bar: number };
|
||||
export const test = base.extend<{ foo: string }, { bar: number }>({
|
||||
foo: 'foo',
|
||||
|
|
@ -71,7 +71,7 @@ test('should check types of fixtures', async ({ runTSC }) => {
|
|||
// @ts-expect-error
|
||||
baz: true,
|
||||
});
|
||||
const fail9 = test.extend<{ foo: string }>({
|
||||
const fail9 = test.extend({
|
||||
foo: [ async ({}, use) => {
|
||||
await use('foo');
|
||||
// @ts-expect-error
|
||||
|
|
@ -100,7 +100,21 @@ test('should check types of fixtures', async ({ runTSC }) => {
|
|||
return y;
|
||||
});
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const chain1 = base.extend({
|
||||
page: async ({ page }, use) => {
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
const chain2 = chain1.extend<{ pageAsUser: Page }>({
|
||||
pageAsUser: async ({ page }, use) => {
|
||||
// @ts-expect-error
|
||||
const x: number = page;
|
||||
// @ts-expect-error
|
||||
await use(x);
|
||||
},
|
||||
});
|
||||
`,
|
||||
'playwright.config.ts': `
|
||||
import { MyOptions } from './helper';
|
||||
|
|
|
|||
|
|
@ -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' }).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[] = [];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis" Version="4.6.0" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis" Version="4.10.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
4
utils/generate_types/overrides-test.d.ts
vendored
4
utils/generate_types/overrides-test.d.ts
vendored
|
|
@ -144,9 +144,9 @@ export type Fixtures<T extends KeyValue = {}, W extends KeyValue = {}, PT extend
|
|||
} & {
|
||||
[K in keyof PT]?: TestFixtureValue<PT[K], T & W & PT & PW> | [TestFixtureValue<PT[K], T & W & PT & PW>, { scope: 'test', timeout?: number | undefined, title?: string, box?: boolean }];
|
||||
} & {
|
||||
[K in Exclude<keyof W, keyof PW>]?: [WorkerFixtureValue<W[K], W & PW>, { scope: 'worker', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean }];
|
||||
[K in keyof W]?: [WorkerFixtureValue<W[K], W & PW>, { scope: 'worker', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean }];
|
||||
} & {
|
||||
[K in Exclude<keyof T, keyof PT>]?: TestFixtureValue<T[K], T & W & PT & PW> | [TestFixtureValue<T[K], T & W & PT & PW>, { scope?: 'test', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean }];
|
||||
[K in keyof T]?: TestFixtureValue<T[K], T & W & PT & PW> | [TestFixtureValue<T[K], T & W & PT & PW>, { scope?: 'test', auto?: boolean, option?: boolean, timeout?: number | undefined, title?: string, box?: boolean }];
|
||||
};
|
||||
|
||||
type BrowserName = 'chromium' | 'firefox' | 'webkit';
|
||||
|
|
|
|||
Loading…
Reference in a new issue