Merge branch 'main' into watch-mode-stop-testrun

This commit is contained in:
Simon Knott 2024-09-17 15:52:08 +02:00
commit 922a395def
No known key found for this signature in database
GPG key ID: 8CEDC00028084AEC
107 changed files with 1942 additions and 838 deletions

View file

@ -44,10 +44,10 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: 18
- uses: actions/setup-python@v4 - uses: actions/setup-python@v5
with: with:
python-version: '3.11' python-version: '3.11'
- uses: actions/setup-dotnet@v3 - uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 8.0.x dotnet-version: 8.0.x
- run: npm ci - run: npm ci

View file

@ -66,7 +66,7 @@ jobs:
contents: read # This is required for actions/checkout to succeed contents: read # This is required for actions/checkout to succeed
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-dotnet@v3 - uses: actions/setup-dotnet@v4
with: with:
dotnet-version: '8.0.x' dotnet-version: '8.0.x'
- run: dotnet build - run: dotnet build

View file

@ -50,7 +50,9 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
os: [macos-12, macos-13, macos-14] # Intel: macos-13, macos-14-large
# Arm64: macos-13-xlarge, macos-14
os: [macos-13, macos-13-xlarge, macos-14-large, macos-14]
browser: [chromium, firefox, webkit] browser: [chromium, firefox, webkit]
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
@ -235,7 +237,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
os: [ubuntu-20.04, macos-12, windows-latest] os: [ubuntu-20.04, macos-13, windows-latest]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: ./.github/actions/run-test - uses: ./.github/actions/run-test

View file

@ -34,7 +34,7 @@ jobs:
PLAYWRIGHT_SERVICE_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}-${{ github.sha }} PLAYWRIGHT_SERVICE_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}-${{ github.sha }}
- name: Upload blob report to GitHub - name: Upload blob report to GitHub
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: all-blob-reports name: all-blob-reports
path: blob-report path: blob-report

View file

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

View file

@ -256,6 +256,13 @@ class PageHandler {
return await this._contentPage.send('disposeObject', options); return await this._contentPage.send('disposeObject', options);
} }
async ['Heap.collectGarbage']() {
Services.obs.notifyObservers(null, "child-gc-request");
Cu.forceGC();
Services.obs.notifyObservers(null, "child-cc-request");
Cu.forceCC();
}
async ['Network.getResponseBody']({requestId}) { async ['Network.getResponseBody']({requestId}) {
return this._pageNetwork.getResponseBody(requestId); return this._pageNetwork.getResponseBody(requestId);
} }

View file

@ -487,6 +487,17 @@ const Browser = {
}, },
}; };
const Heap = {
targets: ['page'],
types: {},
events: {},
methods: {
'collectGarbage': {
params: {},
},
},
};
const Network = { const Network = {
targets: ['page'], targets: ['page'],
types: networkTypes, types: networkTypes,
@ -1002,7 +1013,7 @@ const Accessibility = {
} }
this.protocol = { this.protocol = {
domains: {Browser, Page, Runtime, Network, Accessibility}, domains: {Browser, Heap, Page, Runtime, Network, Accessibility},
}; };
this.checkScheme = checkScheme; this.checkScheme = checkScheme;
this.EXPORTED_SYMBOLS = ['protocol', 'checkScheme']; this.EXPORTED_SYMBOLS = ['protocol', 'checkScheme'];

View file

@ -159,7 +159,10 @@ context cookies from the response. The method will automatically follow redirect
### option: APIRequestContext.delete.data = %%-js-python-csharp-fetch-option-data-%% ### option: APIRequestContext.delete.data = %%-js-python-csharp-fetch-option-data-%%
* since: v1.17 * since: v1.17
### option: APIRequestContext.delete.form = %%-js-python-fetch-option-form-%% ### option: APIRequestContext.delete.form = %%-js-fetch-option-form-%%
* since: v1.17
### option: APIRequestContext.delete.form = %%-python-fetch-option-form-%%
* since: v1.17 * since: v1.17
### option: APIRequestContext.delete.form = %%-csharp-fetch-option-form-%% ### option: APIRequestContext.delete.form = %%-csharp-fetch-option-form-%%
@ -332,7 +335,10 @@ If set changes the fetch method (e.g. [PUT](https://developer.mozilla.org/en-US/
### option: APIRequestContext.fetch.data = %%-js-python-csharp-fetch-option-data-%% ### option: APIRequestContext.fetch.data = %%-js-python-csharp-fetch-option-data-%%
* since: v1.16 * since: v1.16
### option: APIRequestContext.fetch.form = %%-js-python-fetch-option-form-%% ### option: APIRequestContext.fetch.form = %%-js-fetch-option-form-%%
* since: v1.16
### option: APIRequestContext.fetch.form = %%-python-fetch-option-form-%%
* since: v1.16 * since: v1.16
### option: APIRequestContext.fetch.form = %%-csharp-fetch-option-form-%% ### option: APIRequestContext.fetch.form = %%-csharp-fetch-option-form-%%
@ -442,7 +448,10 @@ await request.GetAsync("https://example.com/api/getText", new() { Params = query
### option: APIRequestContext.get.data = %%-js-python-csharp-fetch-option-data-%% ### option: APIRequestContext.get.data = %%-js-python-csharp-fetch-option-data-%%
* since: v1.26 * since: v1.26
### option: APIRequestContext.get.form = %%-js-python-fetch-option-form-%% ### option: APIRequestContext.get.form = %%-js-fetch-option-form-%%
* since: v1.26
### option: APIRequestContext.get.form = %%-python-fetch-option-form-%%
* since: v1.26 * since: v1.26
### option: APIRequestContext.get.form = %%-csharp-fetch-option-form-%% ### option: APIRequestContext.get.form = %%-csharp-fetch-option-form-%%
@ -504,7 +513,10 @@ context cookies from the response. The method will automatically follow redirect
### option: APIRequestContext.head.data = %%-js-python-csharp-fetch-option-data-%% ### option: APIRequestContext.head.data = %%-js-python-csharp-fetch-option-data-%%
* since: v1.26 * since: v1.26
### option: APIRequestContext.head.form = %%-js-python-fetch-option-form-%% ### option: APIRequestContext.head.form = %%-python-fetch-option-form-%%
* since: v1.26
### option: APIRequestContext.head.form = %%-js-fetch-option-form-%%
* since: v1.26 * since: v1.26
### option: APIRequestContext.head.form = %%-csharp-fetch-option-form-%% ### option: APIRequestContext.head.form = %%-csharp-fetch-option-form-%%
@ -566,7 +578,10 @@ context cookies from the response. The method will automatically follow redirect
### option: APIRequestContext.patch.data = %%-js-python-csharp-fetch-option-data-%% ### option: APIRequestContext.patch.data = %%-js-python-csharp-fetch-option-data-%%
* since: v1.16 * since: v1.16
### option: APIRequestContext.patch.form = %%-js-python-fetch-option-form-%% ### option: APIRequestContext.patch.form = %%-js-fetch-option-form-%%
* since: v1.16
### option: APIRequestContext.patch.form = %%-python-fetch-option-form-%%
* since: v1.16 * since: v1.16
### option: APIRequestContext.patch.form = %%-csharp-fetch-option-form-%% ### option: APIRequestContext.patch.form = %%-csharp-fetch-option-form-%%
@ -749,7 +764,10 @@ await request.PostAsync("https://example.com/api/uploadScript", new() { Multipar
### option: APIRequestContext.post.data = %%-js-python-csharp-fetch-option-data-%% ### option: APIRequestContext.post.data = %%-js-python-csharp-fetch-option-data-%%
* since: v1.16 * since: v1.16
### option: APIRequestContext.post.form = %%-js-python-fetch-option-form-%% ### option: APIRequestContext.post.form = %%-js-fetch-option-form-%%
* since: v1.16
### option: APIRequestContext.post.form = %%-python-fetch-option-form-%%
* since: v1.16 * since: v1.16
### option: APIRequestContext.post.form = %%-csharp-fetch-option-form-%% ### option: APIRequestContext.post.form = %%-csharp-fetch-option-form-%%
@ -811,7 +829,10 @@ context cookies from the response. The method will automatically follow redirect
### option: APIRequestContext.put.data = %%-js-python-csharp-fetch-option-data-%% ### option: APIRequestContext.put.data = %%-js-python-csharp-fetch-option-data-%%
* since: v1.16 * since: v1.16
### option: APIRequestContext.put.form = %%-js-python-fetch-option-form-%% ### option: APIRequestContext.put.form = %%-python-fetch-option-form-%%
* since: v1.16
### option: APIRequestContext.put.form = %%-js-fetch-option-form-%%
* since: v1.16 * since: v1.16
### option: APIRequestContext.put.form = %%-csharp-fetch-option-form-%% ### option: APIRequestContext.put.form = %%-csharp-fetch-option-form-%%

View file

@ -1654,7 +1654,9 @@ var banana = await page.GetByRole(AriaRole.Listitem).Nth(2);
- alias-python: or_ - alias-python: or_
- returns: <[Locator]> - returns: <[Locator]>
Creates a locator that matches either of the two locators. Creates a locator matching all elements that match one or both of the two locators.
Note that when both locators match something, the resulting locator will have multiple matches and violate [locator strictness](../locators.md#strictness) guidelines.
**Usage** **Usage**

View file

@ -2333,6 +2333,11 @@ last redirect. If cannot go forward, returns `null`.
Navigate to the next page in history. Navigate to the next page in history.
## async method: Page.forceGarbageCollection
* since: v1.47
Force the browser to perform garbage collection.
### option: Page.goForward.waitUntil = %%-navigation-wait-until-%% ### option: Page.goForward.waitUntil = %%-navigation-wait-until-%%
* since: v1.8 * since: v1.8

View file

@ -405,8 +405,16 @@ Request timeout in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to d
Whether to throw on response codes other than 2xx and 3xx. By default response object is returned Whether to throw on response codes other than 2xx and 3xx. By default response object is returned
for all status codes. for all status codes.
## js-python-fetch-option-form ## js-fetch-option-form
* langs: js, python * langs: js
- `form` <[Object]<[string], [string]|[float]|[boolean]>|[FormData]>
Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as
this request body. If this parameter is specified `content-type` header will be set to `application/x-www-form-urlencoded`
unless explicitly provided.
## python-fetch-option-form
* langs: python
- `form` <[Object]<[string], [string]|[float]|[boolean]>> - `form` <[Object]<[string], [string]|[float]|[boolean]>>
Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as
@ -550,10 +558,6 @@ TLS Client Authentication allows the server to request a client certificate and
An array of client certificates to be used. Each certificate object must have either both `certPath` and `keyPath`, a single `pfxPath`, or their corresponding direct value equivalents (`cert` and `key`, or `pfx`). Optionally, `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided with an exact match to the request origin that the certificate is valid for. An array of client certificates to be used. Each certificate object must have either both `certPath` and `keyPath`, a single `pfxPath`, or their corresponding direct value equivalents (`cert` and `key`, or `pfx`). Optionally, `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided with an exact match to the request origin that the certificate is valid for.
:::note
Using Client Certificates in combination with Proxy Servers is not supported.
:::
:::note :::note
When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it work by replacing `localhost` with `local.playwright`. When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it work by replacing `localhost` with `local.playwright`.
::: :::

View file

@ -322,5 +322,5 @@ This step will not work for pull requests created from a forked repository becau
- [Learn how to perform Actions](./input.md) - [Learn how to perform Actions](./input.md)
- [Learn how to write Assertions](./test-assertions.md) - [Learn how to write Assertions](./test-assertions.md)
- [Learn more about the Trace Viewer](/trace-viewer.md) - [Learn more about the Trace Viewer](/trace-viewer.md)
- [Learn more ways of running tests on GitHub Actions](/ci.md) - [Learn more ways of running tests on GitHub Actions](/ci.md#github-actions)
- [Learn more about running tests on other CI providers](/ci.md#github-actions) // TODO: is this link correct? - [Learn more about running tests on other CI providers](/ci.md)

View file

@ -209,6 +209,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/playwright:v%%VERSION%%-jammy image: mcr.microsoft.com/playwright:v%%VERSION%%-jammy
options: --user 1001
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
@ -218,8 +219,6 @@ jobs:
run: npm ci run: npm ci
- name: Run your tests - name: Run your tests
run: npx playwright test run: npx playwright test
env:
HOME: /root
``` ```
```yml python title=".github/workflows/playwright.yml" ```yml python title=".github/workflows/playwright.yml"
@ -235,6 +234,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/playwright/python:v%%VERSION%%-jammy image: mcr.microsoft.com/playwright/python:v%%VERSION%%-jammy
options: --user 1001
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
@ -248,8 +248,6 @@ jobs:
pip install -e . pip install -e .
- name: Run your tests - name: Run your tests
run: pytest run: pytest
env:
HOME: /root
``` ```
```yml java title=".github/workflows/playwright.yml" ```yml java title=".github/workflows/playwright.yml"
@ -265,6 +263,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/playwright/java:v%%VERSION%%-jammy image: mcr.microsoft.com/playwright/java:v%%VERSION%%-jammy
options: --user 1001
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-java@v3 - uses: actions/setup-java@v3
@ -275,8 +274,6 @@ jobs:
run: mvn -B install -D skipTests --no-transfer-progress run: mvn -B install -D skipTests --no-transfer-progress
- name: Run tests - name: Run tests
run: mvn test run: mvn test
env:
HOME: /root
``` ```
```yml csharp title=".github/workflows/playwright.yml" ```yml csharp title=".github/workflows/playwright.yml"
@ -292,6 +289,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/playwright/dotnet:v%%VERSION%%-jammy image: mcr.microsoft.com/playwright/dotnet:v%%VERSION%%-jammy
options: --user 1001
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup dotnet - name: Setup dotnet
@ -301,8 +299,6 @@ jobs:
- run: dotnet build - run: dotnet build
- name: Run your tests - name: Run your tests
run: dotnet test run: dotnet test
env:
HOME: /root
``` ```
#### On deployment #### On deployment

View file

@ -18,8 +18,9 @@ The Network tab in the trace viewer has several nice improvements:
### Miscellaneous ### Miscellaneous
- The `mcr.microsoft.com/playwright-dotnet:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble. - The `mcr.microsoft.com/playwright/dotnet:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble.
To use the 22.04 jammy-based image, please use `mcr.microsoft.com/playwright-dotnet:v1.47.0-jammy` instead. To use the 22.04 jammy-based image, please use `mcr.microsoft.com/playwright/dotnet:v1.47.0-jammy` instead.
- The `:latest`/`:focal`/`:jammy` tag for Playwright Docker images is no longer being published. Pin to a specific version for better stability and reproducibility.
- TLS client certificates can now be passed from memory by passing [`option: cert`] and [`option: key`] as byte arrays instead of file paths. - TLS client certificates can now be passed from memory by passing [`option: cert`] and [`option: key`] as byte arrays instead of file paths.
- [`option: noWaitAfter`] in [`method: Locator.selectOption`] was deprecated. - [`option: noWaitAfter`] in [`method: Locator.selectOption`] was deprecated.
- We've seen reports of WebGL in Webkit misbehaving on GitHub Actions `macos-13`. We recommend upgrading GitHub Actions to `macos-14`. - We've seen reports of WebGL in Webkit misbehaving on GitHub Actions `macos-13`. We recommend upgrading GitHub Actions to `macos-14`.

View file

@ -18,8 +18,9 @@ The Network tab in the trace viewer has several nice improvements:
### Miscellaneous ### Miscellaneous
- The `mcr.microsoft.com/playwright-java:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble. - The `mcr.microsoft.com/playwright/java:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble.
To use the 22.02 jammy-based image, please use `mcr.microsoft.com/playwright-java:v1.47.0-jammy` instead. To use the 22.02 jammy-based image, please use `mcr.microsoft.com/playwright/java:v1.47.0-jammy` instead.
- The `:latest`/`:focal`/`:jammy` tag for Playwright Docker images is no longer being published. Pin to a specific version for better stability and reproducibility.
- TLS client certificates can now be passed from memory by passing [`option: cert`] and [`option: key`] as byte arrays instead of file paths. - TLS client certificates can now be passed from memory by passing [`option: cert`] and [`option: key`] as byte arrays instead of file paths.
- [`option: noWaitAfter`] in [`method: Locator.selectOption`] was deprecated. - [`option: noWaitAfter`] in [`method: Locator.selectOption`] was deprecated.
- We've seen reports of WebGL in Webkit misbehaving on GitHub Actions `macos-13`. We recommend upgrading GitHub Actions to `macos-14`. - We've seen reports of WebGL in Webkit misbehaving on GitHub Actions `macos-13`. We recommend upgrading GitHub Actions to `macos-14`.

View file

@ -18,8 +18,9 @@ The Network tab in the trace viewer has several nice improvements:
### Miscellaneous ### Miscellaneous
- The `mcr.microsoft.com/playwright-python:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble. - The `mcr.microsoft.com/playwright/python:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble.
To use the 22.04 jammy-based image, please use `mcr.microsoft.com/playwright-python:v1.47.0-jammy` instead. To use the 22.04 jammy-based image, please use `mcr.microsoft.com/playwright/python:v1.47.0-jammy` instead.
- The `:latest`/`:focal`/`:jammy` tag for Playwright Docker images is no longer being published. Pin to a specific version for better stability and reproducibility.
- TLS client certificates can now be passed from memory by passing [`option: cert`] and [`option: key`] as bytes instead of file paths. - TLS client certificates can now be passed from memory by passing [`option: cert`] and [`option: key`] as bytes instead of file paths.
- [`option: noWaitAfter`] in [`method: Locator.selectOption`] was deprecated. - [`option: noWaitAfter`] in [`method: Locator.selectOption`] was deprecated.
- We've seen reports of WebGL in Webkit misbehaving on GitHub Actions `macos-13`. We recommend upgrading GitHub Actions to `macos-14`. - We've seen reports of WebGL in Webkit misbehaving on GitHub Actions `macos-13`. We recommend upgrading GitHub Actions to `macos-14`.

View file

@ -20,15 +20,31 @@
width: 24px; width: 24px;
border: none; border: none;
outline: none; outline: none;
color: var(--color-fg-default); color: var(--color-fg-muted);
background: transparent; background: transparent;
padding: 4px; padding: 4px;
cursor: pointer; cursor: pointer;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
border-radius: 4px; border-radius: 4px;
} }
.copy-icon svg {
margin: 0;
}
.copy-icon:not(:disabled):hover { .copy-icon:not(:disabled):hover {
background-color: var(--color-border-default); background-color: var(--color-border-default);
} }
.copy-button-container {
visibility: hidden;
display: inline-flex;
margin-left: 8px;
vertical-align: bottom;
}
.copy-value-container:hover .copy-button-container {
visibility: visible;
}

View file

@ -18,9 +18,14 @@ import * as React from 'react';
import * as icons from './icons'; import * as icons from './icons';
import './copyToClipboard.css'; import './copyToClipboard.css';
export const CopyToClipboard: React.FunctionComponent<{ type CopyToClipboardProps = {
value: string, value: string;
}> = ({ value }) => { };
/**
* A copy to clipboard button.
*/
export const CopyToClipboard: React.FunctionComponent<CopyToClipboardProps> = ({ value }) => {
type IconType = 'copy' | 'check' | 'cross'; type IconType = 'copy' | 'check' | 'cross';
const [icon, setIcon] = React.useState<IconType>('copy'); const [icon, setIcon] = React.useState<IconType>('copy');
const handleCopy = React.useCallback(() => { const handleCopy = React.useCallback(() => {
@ -34,5 +39,21 @@ export const CopyToClipboard: React.FunctionComponent<{
}); });
}, [value]); }, [value]);
const iconElement = icon === 'check' ? icons.check() : icon === 'cross' ? icons.cross() : icons.copy(); const iconElement = icon === 'check' ? icons.check() : icon === 'cross' ? icons.cross() : icons.copy();
return <button className='copy-icon' onClick={handleCopy}>{iconElement}</button>; return <button className='copy-icon' aria-label='Copy to clipboard' onClick={handleCopy}>{iconElement}</button>;
};
type CopyToClipboardContainerProps = CopyToClipboardProps & {
children: React.ReactNode
};
/**
* Container for displaying a copy to clipboard button alongside children.
*/
export const CopyToClipboardContainer: React.FunctionComponent<CopyToClipboardContainerProps> = ({ children, value }) => {
return <span className='copy-value-container'>
{children}
<span className='copy-button-container'>
<CopyToClipboard value={value} />
</span>
</span>;
}; };

View file

@ -76,6 +76,19 @@ test('should render test case', async ({ mount }) => {
await expect(component.getByText('My test')).toBeVisible(); await expect(component.getByText('My test')).toBeVisible();
}); });
test('should render copy buttons for annotations', async ({ mount, page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={testCase} run={0} anchor=''></TestCaseView>);
await expect(component.getByText('Annotation text', { exact: false }).first()).toBeVisible();
component.getByText('Annotation text', { exact: false }).first().hover();
await expect(component.getByLabel('Copy to clipboard').first()).toBeVisible();
await component.getByLabel('Copy to clipboard').first().click();
const handle = await page.evaluateHandle(() => navigator.clipboard.readText());
const clipboardContent = await handle.jsonValue();
expect(clipboardContent).toBe('Annotation text');
});
const annotationLinkRenderingTestCase: TestCase = { const annotationLinkRenderingTestCase: TestCase = {
testId: 'testid', testId: 'testid',
title: 'My test', title: 'My test',

View file

@ -26,6 +26,7 @@ import { TestResultView } from './testResultView';
import { linkifyText } from '@web/renderUtils'; import { linkifyText } from '@web/renderUtils';
import { hashStringToInt, msToString } from './utils'; import { hashStringToInt, msToString } from './utils';
import { clsx } from '@web/uiUtils'; import { clsx } from '@web/uiUtils';
import { CopyToClipboardContainer } from './copyToClipboard';
export const TestCaseView: React.FC<{ export const TestCaseView: React.FC<{
projectNames: string[], projectNames: string[],
@ -73,7 +74,7 @@ function TestCaseAnnotationView({ annotation: { type, description } }: { annotat
return ( return (
<div className='test-case-annotation'> <div className='test-case-annotation'>
<span style={{ fontWeight: 'bold' }}>{type}</span> <span style={{ fontWeight: 'bold' }}>{type}</span>
{description && <span>: {linkifyText(description)}</span>} {description && <CopyToClipboardContainer value={description}>: {linkifyText(description)}</CopyToClipboardContainer>}
</div> </div>
); );
} }

View file

@ -3,31 +3,31 @@
"browsers": [ "browsers": [
{ {
"name": "chromium", "name": "chromium",
"revision": "1134", "revision": "1135",
"installByDefault": true, "installByDefault": true,
"browserVersion": "129.0.6668.29" "browserVersion": "129.0.6668.42"
}, },
{ {
"name": "chromium-tip-of-tree", "name": "chromium-tip-of-tree",
"revision": "1256", "revision": "1259",
"installByDefault": false, "installByDefault": false,
"browserVersion": "130.0.6695.0" "browserVersion": "130.0.6713.0"
}, },
{ {
"name": "firefox", "name": "firefox",
"revision": "1463", "revision": "1464",
"installByDefault": true, "installByDefault": true,
"browserVersion": "130.0" "browserVersion": "130.0"
}, },
{ {
"name": "firefox-beta", "name": "firefox-beta",
"revision": "1463", "revision": "1464",
"installByDefault": false, "installByDefault": false,
"browserVersion": "131.0b2" "browserVersion": "131.0b2"
}, },
{ {
"name": "webkit", "name": "webkit",
"revision": "2073", "revision": "2077",
"installByDefault": true, "installByDefault": true,
"revisionOverrides": { "revisionOverrides": {
"mac10.14": "1446", "mac10.14": "1446",
@ -42,7 +42,11 @@
{ {
"name": "ffmpeg", "name": "ffmpeg",
"revision": "1010", "revision": "1010",
"installByDefault": true "installByDefault": true,
"revisionOverrides": {
"mac12": "1010",
"mac12-arm64": "1010"
}
}, },
{ {
"name": "android", "name": "android",

View file

@ -348,10 +348,10 @@ type CaptureOptions = {
fullPage: boolean; fullPage: boolean;
}; };
async function launchContext(options: Options, headless: boolean, executablePath?: string): Promise<{ browser: Browser, browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, context: BrowserContext }> { async function launchContext(options: Options, extraOptions: LaunchOptions): Promise<{ browser: Browser, browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, context: BrowserContext }> {
validateOptions(options); validateOptions(options);
const browserType = lookupBrowserType(options); const browserType = lookupBrowserType(options);
const launchOptions: LaunchOptions = { headless, executablePath }; const launchOptions: LaunchOptions = extraOptions;
if (options.channel) if (options.channel)
launchOptions.channel = options.channel as any; launchOptions.channel = options.channel as any;
launchOptions.handleSIGINT = false; launchOptions.handleSIGINT = false;
@ -363,7 +363,7 @@ async function launchContext(options: Options, headless: boolean, executablePath
// In headful mode, use host device scale factor for things to look nice. // In headful mode, use host device scale factor for things to look nice.
// In headless, keep things the way it works in Playwright by default. // In headless, keep things the way it works in Playwright by default.
// Assume high-dpi on MacOS. TODO: this is not perfect. // Assume high-dpi on MacOS. TODO: this is not perfect.
if (!headless) if (!extraOptions.headless)
contextOptions.deviceScaleFactor = os.platform() === 'darwin' ? 2 : 1; contextOptions.deviceScaleFactor = os.platform() === 'darwin' ? 2 : 1;
// Work around the WebKit GTK scrolling issue. // Work around the WebKit GTK scrolling issue.
@ -547,7 +547,7 @@ async function openPage(context: BrowserContext, url: string | undefined): Promi
} }
async function open(options: Options, url: string | undefined, language: string) { async function open(options: Options, url: string | undefined, language: string) {
const { context, launchOptions, contextOptions } = await launchContext(options, !!process.env.PWTEST_CLI_HEADLESS, process.env.PWTEST_CLI_EXECUTABLE_PATH); const { context, launchOptions, contextOptions } = await launchContext(options, { headless: !!process.env.PWTEST_CLI_HEADLESS, executablePath: process.env.PWTEST_CLI_EXECUTABLE_PATH });
await context._enableRecorder({ await context._enableRecorder({
language, language,
launchOptions, launchOptions,
@ -560,8 +560,17 @@ async function open(options: Options, url: string | undefined, language: string)
async function codegen(options: Options & { target: string, output?: string, testIdAttribute?: string }, url: string | undefined) { async function codegen(options: Options & { target: string, output?: string, testIdAttribute?: string }, url: string | undefined) {
const { target: language, output: outputFile, testIdAttribute: testIdAttributeName } = options; const { target: language, output: outputFile, testIdAttribute: testIdAttributeName } = options;
const { context, launchOptions, contextOptions } = await launchContext(options, !!process.env.PWTEST_CLI_HEADLESS, process.env.PWTEST_CLI_EXECUTABLE_PATH); const tracesDir = path.join(os.tmpdir(), `recorder-trace-${Date.now()}`);
const { context, launchOptions, contextOptions } = await launchContext(options, {
headless: !!process.env.PWTEST_CLI_HEADLESS,
executablePath: process.env.PWTEST_CLI_EXECUTABLE_PATH,
tracesDir,
});
dotenv.config({ path: 'playwright.env' }); dotenv.config({ path: 'playwright.env' });
if (process.env.PW_RECORDER_IS_TRACE_VIEWER) {
await fs.promises.mkdir(tracesDir, { recursive: true });
await context.tracing.start({ name: 'trace', _live: true });
}
await context._enableRecorder({ await context._enableRecorder({
language, language,
launchOptions, launchOptions,
@ -587,7 +596,7 @@ async function waitForPage(page: Page, captureOptions: CaptureOptions) {
} }
async function screenshot(options: Options, captureOptions: CaptureOptions, url: string, path: string) { async function screenshot(options: Options, captureOptions: CaptureOptions, url: string, path: string) {
const { context } = await launchContext(options, true); const { context } = await launchContext(options, { headless: true });
console.log('Navigating to ' + url); console.log('Navigating to ' + url);
const page = await openPage(context, url); const page = await openPage(context, url);
await waitForPage(page, captureOptions); await waitForPage(page, captureOptions);
@ -600,7 +609,7 @@ async function screenshot(options: Options, captureOptions: CaptureOptions, url:
async function pdf(options: Options, captureOptions: CaptureOptions, url: string, path: string) { async function pdf(options: Options, captureOptions: CaptureOptions, url: string, path: string) {
if (options.browser !== 'chromium') if (options.browser !== 'chromium')
throw new Error('PDF creation is only working with Chromium'); throw new Error('PDF creation is only working with Chromium');
const { context } = await launchContext({ ...options, browser: 'chromium' }, true); const { context } = await launchContext({ ...options, browser: 'chromium' }, { headless: true });
console.log('Navigating to ' + url); console.log('Navigating to ' + url);
const page = await openPage(context, url); const page = await openPage(context, url);
await waitForPage(page, captureOptions); await waitForPage(page, captureOptions);

View file

@ -36,8 +36,8 @@ export type FetchOptions = {
method?: string, method?: string,
headers?: Headers, headers?: Headers,
data?: string | Buffer | Serializable, data?: string | Buffer | Serializable,
form?: { [key: string]: string|number|boolean; }; form?: { [key: string]: string|number|boolean; } | FormData;
multipart?: { [key: string]: string|number|boolean|fs.ReadStream|FilePayload; }; multipart?: { [key: string]: string|number|boolean|fs.ReadStream|FilePayload; } | FormData;
timeout?: number, timeout?: number,
failOnStatusCode?: boolean, failOnStatusCode?: boolean,
ignoreHTTPSErrors?: boolean, ignoreHTTPSErrors?: boolean,
@ -202,7 +202,16 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
throw new Error(`Unexpected 'data' type`); throw new Error(`Unexpected 'data' type`);
} }
} else if (options.form) { } else if (options.form) {
formData = objectToArray(options.form); if (globalThis.FormData && options.form instanceof FormData) {
formData = [];
for (const [name, value] of options.form.entries()) {
if (typeof value !== 'string')
throw new Error(`Expected string for options.form["${name}"], found File. Please use options.multipart instead.`);
formData.push({ name, value });
}
} else {
formData = objectToArray(options.form);
}
} else if (options.multipart) { } else if (options.multipart) {
multipartData = []; multipartData = [];
if (globalThis.FormData && options.multipart instanceof FormData) { if (globalThis.FormData && options.multipart instanceof FormData) {

View file

@ -468,6 +468,10 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
return Response.fromNullable((await this._channel.goForward({ ...options, waitUntil })).response); return Response.fromNullable((await this._channel.goForward({ ...options, waitUntil })).response);
} }
async forceGarbageCollection() {
await this._channel.forceGarbageCollection();
}
async emulateMedia(options: { media?: 'screen' | 'print' | null, colorScheme?: 'dark' | 'light' | 'no-preference' | null, reducedMotion?: 'reduce' | 'no-preference' | null, forcedColors?: 'active' | 'none' | null } = {}) { async emulateMedia(options: { media?: 'screen' | 'print' | null, colorScheme?: 'dark' | 'light' | 'no-preference' | null, reducedMotion?: 'reduce' | 'no-preference' | null, forcedColors?: 'active' | 'none' | null } = {}) {
await this._channel.emulateMedia({ await this._channel.emulateMedia({
media: options.media === null ? 'no-override' : options.media, media: options.media === null ? 'no-override' : options.media,

View file

@ -1122,6 +1122,8 @@ scheme.PageGoForwardParams = tObject({
scheme.PageGoForwardResult = tObject({ scheme.PageGoForwardResult = tObject({
response: tOptional(tChannel(['Response'])), response: tOptional(tChannel(['Response'])),
}); });
scheme.PageForceGarbageCollectionParams = tOptional(tObject({}));
scheme.PageForceGarbageCollectionResult = tOptional(tObject({}));
scheme.PageRegisterLocatorHandlerParams = tObject({ scheme.PageRegisterLocatorHandlerParams = tObject({
selector: tString, selector: tString,
noWaitAfter: tOptional(tBoolean), noWaitAfter: tOptional(tBoolean),

View file

@ -29,6 +29,7 @@ import { validateBrowserContextOptions } from '../browserContext';
import { ProgressController } from '../progress'; import { ProgressController } from '../progress';
import { CRBrowser } from '../chromium/crBrowser'; import { CRBrowser } from '../chromium/crBrowser';
import { helper } from '../helper'; import { helper } from '../helper';
import type * as types from '../types';
import { PipeTransport } from '../../protocol/transport'; import { PipeTransport } from '../../protocol/transport';
import { RecentLogsCollector } from '../../utils/debugLogger'; import { RecentLogsCollector } from '../../utils/debugLogger';
import { gracefullyCloseSet } from '../../utils/processLauncher'; import { gracefullyCloseSet } from '../../utils/processLauncher';
@ -309,7 +310,7 @@ export class AndroidDevice extends SdkObject {
return await this._connectToBrowser(socketName); return await this._connectToBrowser(socketName);
} }
private async _connectToBrowser(socketName: string, options: channels.BrowserNewContextParams = {}): Promise<BrowserContext> { private async _connectToBrowser(socketName: string, options: types.BrowserContextOptions = {}): Promise<BrowserContext> {
const socket = await this._waitForLocalAbstract(socketName); const socket = await this._waitForLocalAbstract(socketName);
const androidBrowser = new AndroidBrowser(this, socket); const androidBrowser = new AndroidBrowser(this, socket);
await androidBrowser._init(); await androidBrowser._init();

View file

@ -94,6 +94,14 @@ export class BidiBrowser extends Browser {
'script', 'script',
], ],
}); });
if (options.persistent) {
browser._defaultContext = new BidiBrowserContext(browser, undefined, options.persistent);
await (browser._defaultContext as BidiBrowserContext)._initialize();
// Create default page as we cannot get access to the existing one.
const pageDelegate = await browser._defaultContext.newPageDelegate();
await pageDelegate.pageOrError();
}
return browser; return browser;
} }
@ -111,7 +119,7 @@ export class BidiBrowser extends Browser {
this._didClose(); this._didClose();
} }
async doCreateNewContext(options: channels.BrowserNewContextParams): Promise<BrowserContext> { async doCreateNewContext(options: types.BrowserContextOptions): Promise<BrowserContext> {
const { userContext } = await this._browserSession.send('browser.createUserContext', {}); const { userContext } = await this._browserSession.send('browser.createUserContext', {});
const context = new BidiBrowserContext(this, userContext, options); const context = new BidiBrowserContext(this, userContext, options);
await context._initialize(); await context._initialize();
@ -190,7 +198,7 @@ export class BidiBrowser extends Browser {
export class BidiBrowserContext extends BrowserContext { export class BidiBrowserContext extends BrowserContext {
declare readonly _browser: BidiBrowser; declare readonly _browser: BidiBrowser;
constructor(browser: BidiBrowser, browserContextId: string | undefined, options: channels.BrowserNewContextParams) { constructor(browser: BidiBrowser, browserContextId: string | undefined, options: types.BrowserContextOptions) {
super(browser, options, browserContextId); super(browser, options, browserContextId);
this._authenticateProxyViaHeader(); this._authenticateProxyViaHeader();
} }
@ -294,10 +302,11 @@ export class BidiBrowserContext extends BrowserContext {
} }
async doClose(reason: string | undefined) { async doClose(reason: string | undefined) {
// TODO: implement for persistent context if (!this._browserContextId) {
if (!this._browserContextId) // Closing persistent context should close the browser.
await this._browser.close({ reason });
return; return;
}
await this._browser._browserSession.send('browser.removeUserContext', { await this._browser._browserSession.send('browser.removeUserContext', {
userContext: this._browserContextId userContext: this._browserContextId
}); });

View file

@ -91,7 +91,7 @@ export class BidiChromium extends BrowserType {
} }
private _innerDefaultArgs(options: types.LaunchOptions): string[] { private _innerDefaultArgs(options: types.LaunchOptions): string[] {
const { args = [], proxy } = options; const { args = [] } = options;
const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir')); const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir'));
if (userDataDirArg) if (userDataDirArg)
throw this._createUserDataDirArgMisuseError('--user-data-dir'); throw this._createUserDataDirArgMisuseError('--user-data-dir');
@ -125,6 +125,7 @@ export class BidiChromium extends BrowserType {
} }
if (options.chromiumSandbox !== true) if (options.chromiumSandbox !== true)
chromeArguments.push('--no-sandbox'); chromeArguments.push('--no-sandbox');
const proxy = options.proxyOverride || options.proxy;
if (proxy) { if (proxy) {
const proxyURL = new URL(proxy.server); const proxyURL = new URL(proxy.server);
const isSocks = proxyURL.protocol === 'socks5:'; const isSocks = proxyURL.protocol === 'socks5:';

View file

@ -76,12 +76,6 @@ export class BidiFirefox extends BrowserType {
firefoxArguments.push('--foreground'); firefoxArguments.push('--foreground');
firefoxArguments.push(`--profile`, userDataDir); firefoxArguments.push(`--profile`, userDataDir);
firefoxArguments.push(...args); firefoxArguments.push(...args);
// TODO: make ephemeral context work without this argument.
firefoxArguments.push('about:blank');
// if (isPersistent)
// firefoxArguments.push('about:blank');
// else
// firefoxArguments.push('-silent');
return firefoxArguments; return firefoxArguments;
} }

View file

@ -22,6 +22,7 @@ import type * as frames from '../frames';
import type * as types from '../types'; import type * as types from '../types';
import * as bidi from './third_party/bidiProtocol'; import * as bidi from './third_party/bidiProtocol';
import type { BidiSession } from './bidiConnection'; import type { BidiSession } from './bidiConnection';
import { parseRawCookie } from '../cookieStore';
export class BidiNetworkManager { export class BidiNetworkManager {
@ -68,7 +69,7 @@ export class BidiNetworkManager {
if (redirectedFrom) { if (redirectedFrom) {
this._session.sendMayFail('network.continueRequest', { this._session.sendMayFail('network.continueRequest', {
request: param.request.request, request: param.request.request,
headers: redirectedFrom._originalRequestRoute?._alreadyContinuedHeaders, ...(redirectedFrom._originalRequestRoute?._alreadyContinuedHeaders || {}),
}); });
} else { } else {
route = new BidiRouteImpl(this._session, param.request.request); route = new BidiRouteImpl(this._session, param.request.request);
@ -245,7 +246,7 @@ class BidiRouteImpl implements network.RouteDelegate {
private _requestId: bidi.Network.Request; private _requestId: bidi.Network.Request;
private _session: BidiSession; private _session: BidiSession;
private _request!: network.Request; private _request!: network.Request;
_alreadyContinuedHeaders: bidi.Network.Header[] | undefined; _alreadyContinuedHeaders: types.HeadersArray | undefined;
constructor(session: BidiSession, requestId: bidi.Network.Request) { constructor(session: BidiSession, requestId: bidi.Network.Request) {
this._session = session; this._session = session;
@ -266,13 +267,12 @@ class BidiRouteImpl implements network.RouteDelegate {
return header; return header;
}); });
} }
this._alreadyContinuedHeaders = toBidiHeaders(headers); this._alreadyContinuedHeaders = headers;
await this._session.sendMayFail('network.continueRequest', { await this._session.sendMayFail('network.continueRequest', {
request: this._requestId, request: this._requestId,
url: overrides.url, url: overrides.url,
method: overrides.method, method: overrides.method,
// TODO: cookies! ...toBidiRequestHeaders(this._alreadyContinuedHeaders),
headers: this._alreadyContinuedHeaders,
body: overrides.postData ? { type: 'base64', value: Buffer.from(overrides.postData).toString('base64') } : undefined, body: overrides.postData ? { type: 'base64', value: Buffer.from(overrides.postData).toString('base64') } : undefined,
}); });
} }
@ -283,7 +283,7 @@ class BidiRouteImpl implements network.RouteDelegate {
request: this._requestId, request: this._requestId,
statusCode: response.status, statusCode: response.status,
reasonPhrase: network.statusText(response.status), reasonPhrase: network.statusText(response.status),
headers: toBidiHeaders(response.headers), ...toBidiResponseHeaders(response.headers),
body: { type: 'base64', value: base64body }, body: { type: 'base64', value: base64body },
}); });
} }
@ -302,6 +302,27 @@ function fromBidiHeaders(bidiHeaders: bidi.Network.Header[]): types.HeadersArray
return result; return result;
} }
function toBidiRequestHeaders(allHeaders: types.HeadersArray): { cookies: bidi.Network.CookieHeader[], headers: bidi.Network.Header[] } {
const bidiHeaders = toBidiHeaders(allHeaders);
const cookies = bidiHeaders.filter(h => h.name.toLowerCase() === 'cookie');
const headers = bidiHeaders.filter(h => h.name.toLowerCase() !== 'cookie');
return { cookies, headers };
}
function toBidiResponseHeaders(headers: types.HeadersArray): { cookies: bidi.Network.SetCookieHeader[], headers: bidi.Network.Header[] } {
const setCookieHeaders = headers.filter(h => h.name.toLowerCase() === 'set-cookie');
const otherHeaders = headers.filter(h => h.name.toLowerCase() !== 'set-cookie');
const rawCookies = setCookieHeaders.map(h => parseRawCookie(h.value));
const cookies: bidi.Network.SetCookieHeader[] = rawCookies.filter(Boolean).map(c => {
return {
...c!,
value: { type: 'string', value: c!.value },
sameSite: toBidiSameSite(c!.sameSite),
};
});
return { cookies, headers: toBidiHeaders(otherHeaders) };
}
function toBidiHeaders(headers: types.HeadersArray): bidi.Network.Header[] { function toBidiHeaders(headers: types.HeadersArray): bidi.Network.Header[] {
return headers.map(({ name, value }) => ({ name, value: { type: 'string', value } })); return headers.map(({ name, value }) => ({ name, value: { type: 'string', value } }));
} }
@ -314,3 +335,13 @@ export function bidiBytesValueToString(value: bidi.Network.BytesValue): string {
return 'unknown value type: ' + (value as any).type; return 'unknown value type: ' + (value as any).type;
} }
function toBidiSameSite(sameSite?: 'Strict' | 'Lax' | 'None'): bidi.Network.SameSite | undefined {
if (!sameSite)
return undefined;
if (sameSite === 'Strict')
return bidi.Network.SameSite.Strict;
if (sameSite === 'Lax')
return bidi.Network.SameSite.Lax;
return bidi.Network.SameSite.None;
}

View file

@ -323,6 +323,10 @@ export class BidiPage implements PageDelegate {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }
async forceGarbageCollection(): Promise<void> {
throw new Error('Method not implemented.');
}
async addInitScript(initScript: InitScript): Promise<void> { async addInitScript(initScript: InitScript): Promise<void> {
const { script } = await this._session.send('script.addPreloadScript', { const { script } = await this._session.send('script.addPreloadScript', {
// TODO: remove function call from the source. // TODO: remove function call from the source.

View file

@ -16,7 +16,7 @@
import type * as types from './types'; import type * as types from './types';
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
import { BrowserContext, createClientCertificatesProxyIfNeeded, validateBrowserContextOptions } from './browserContext'; import { BrowserContext, validateBrowserContextOptions } from './browserContext';
import { Page } from './page'; import { Page } from './page';
import { Download } from './download'; import { Download } from './download';
import type { ProxySettings } from './types'; import type { ProxySettings } from './types';
@ -25,6 +25,7 @@ import type { RecentLogsCollector } from '../utils/debugLogger';
import type { CallMetadata } from './instrumentation'; import type { CallMetadata } from './instrumentation';
import { SdkObject } from './instrumentation'; import { SdkObject } from './instrumentation';
import { Artifact } from './artifact'; import { Artifact } from './artifact';
import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor';
export interface BrowserProcess { export interface BrowserProcess {
onclose?: ((exitCode: number | null, signal: string | null) => void); onclose?: ((exitCode: number | null, signal: string | null) => void);
@ -41,7 +42,7 @@ export type BrowserOptions = {
downloadsPath: string, downloadsPath: string,
tracesDir: string, tracesDir: string,
headful?: boolean, headful?: boolean,
persistent?: channels.BrowserNewContextParams, // Undefined means no persistent context. persistent?: types.BrowserContextOptions, // Undefined means no persistent context.
browserProcess: BrowserProcess, browserProcess: BrowserProcess,
customExecutablePath?: string; customExecutablePath?: string;
proxy?: ProxySettings, proxy?: ProxySettings,
@ -74,15 +75,21 @@ export abstract class Browser extends SdkObject {
this.instrumentation.onBrowserOpen(this); this.instrumentation.onBrowserOpen(this);
} }
abstract doCreateNewContext(options: channels.BrowserNewContextParams): Promise<BrowserContext>; abstract doCreateNewContext(options: types.BrowserContextOptions): Promise<BrowserContext>;
abstract contexts(): BrowserContext[]; abstract contexts(): BrowserContext[];
abstract isConnected(): boolean; abstract isConnected(): boolean;
abstract version(): string; abstract version(): string;
abstract userAgent(): string; abstract userAgent(): string;
async newContext(metadata: CallMetadata, options: channels.BrowserNewContextParams): Promise<BrowserContext> { async newContext(metadata: CallMetadata, options: types.BrowserContextOptions): Promise<BrowserContext> {
validateBrowserContextOptions(options, this.options); validateBrowserContextOptions(options, this.options);
const clientCertificatesProxy = await createClientCertificatesProxyIfNeeded(options, this.options); let clientCertificatesProxy: ClientCertificatesProxy | undefined;
if (options.clientCertificates?.length) {
clientCertificatesProxy = new ClientCertificatesProxy(options);
options = { ...options };
options.proxyOverride = await clientCertificatesProxy.listen();
options.internalIgnoreHTTPSErrors = true;
}
let context; let context;
try { try {
context = await this.doCreateNewContext(options); context = await this.doCreateNewContext(options);

View file

@ -42,7 +42,7 @@ import * as consoleApiSource from '../generated/consoleApiSource';
import { BrowserContextAPIRequestContext } from './fetch'; import { BrowserContextAPIRequestContext } from './fetch';
import type { Artifact } from './artifact'; import type { Artifact } from './artifact';
import { Clock } from './clock'; import { Clock } from './clock';
import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; import type { ClientCertificatesProxy } from './socksClientCertificatesInterceptor';
import { RecorderApp } from './recorder/recorderApp'; import { RecorderApp } from './recorder/recorderApp';
export abstract class BrowserContext extends SdkObject { export abstract class BrowserContext extends SdkObject {
@ -68,7 +68,7 @@ export abstract class BrowserContext extends SdkObject {
readonly _timeoutSettings = new TimeoutSettings(); readonly _timeoutSettings = new TimeoutSettings();
readonly _pageBindings = new Map<string, PageBinding>(); readonly _pageBindings = new Map<string, PageBinding>();
readonly _activeProgressControllers = new Set<ProgressController>(); readonly _activeProgressControllers = new Set<ProgressController>();
readonly _options: channels.BrowserNewContextParams; readonly _options: types.BrowserContextOptions;
_requestInterceptor?: network.RouteHandler; _requestInterceptor?: network.RouteHandler;
private _isPersistentContext: boolean; private _isPersistentContext: boolean;
private _closedStatus: 'open' | 'closing' | 'closed' = 'open'; private _closedStatus: 'open' | 'closing' | 'closed' = 'open';
@ -93,7 +93,7 @@ export abstract class BrowserContext extends SdkObject {
readonly clock: Clock; readonly clock: Clock;
_clientCertificatesProxy: ClientCertificatesProxy | undefined; _clientCertificatesProxy: ClientCertificatesProxy | undefined;
constructor(browser: Browser, options: channels.BrowserNewContextParams, browserContextId: string | undefined) { constructor(browser: Browser, options: types.BrowserContextOptions, browserContextId: string | undefined) {
super(browser, 'browser-context'); super(browser, 'browser-context');
this.attribution.context = this; this.attribution.context = this;
this._browser = browser; this._browser = browser;
@ -659,19 +659,7 @@ export function assertBrowserContextIsNotOwned(context: BrowserContext) {
} }
} }
export async function createClientCertificatesProxyIfNeeded(options: channels.BrowserNewContextOptions, browserOptions?: BrowserOptions) { export function validateBrowserContextOptions(options: types.BrowserContextOptions, browserOptions: BrowserOptions) {
if (!options.clientCertificates?.length)
return;
if ((options.proxy?.server && options.proxy?.server !== 'per-context') || (browserOptions?.proxy?.server && browserOptions?.proxy?.server !== 'http://per-context'))
throw new Error('Cannot specify both proxy and clientCertificates');
verifyClientCertificates(options.clientCertificates);
const clientCertificatesProxy = new ClientCertificatesProxy(options);
options.proxy = { server: await clientCertificatesProxy.listen() };
options.ignoreHTTPSErrors = true;
return clientCertificatesProxy;
}
export function validateBrowserContextOptions(options: channels.BrowserNewContextParams, browserOptions: BrowserOptions) {
if (options.noDefaultViewport && options.deviceScaleFactor !== undefined) if (options.noDefaultViewport && options.deviceScaleFactor !== undefined)
throw new Error(`"deviceScaleFactor" option is not supported with null "viewport"`); throw new Error(`"deviceScaleFactor" option is not supported with null "viewport"`);
if (options.noDefaultViewport && !!options.isMobile) if (options.noDefaultViewport && !!options.isMobile)
@ -720,7 +708,7 @@ export function verifyGeolocation(geolocation?: types.Geolocation) {
throw new Error(`geolocation.accuracy: precondition 0 <= ACCURACY failed.`); throw new Error(`geolocation.accuracy: precondition 0 <= ACCURACY failed.`);
} }
export function verifyClientCertificates(clientCertificates?: channels.BrowserNewContextParams['clientCertificates']) { export function verifyClientCertificates(clientCertificates?: types.BrowserContextOptions['clientCertificates']) {
if (!clientCertificates) if (!clientCertificates)
return; return;
for (const cert of clientCertificates) { for (const cert of clientCertificates) {

View file

@ -18,7 +18,7 @@ import fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import path from 'path'; import path from 'path';
import type { BrowserContext } from './browserContext'; import type { BrowserContext } from './browserContext';
import { createClientCertificatesProxyIfNeeded, normalizeProxySettings, validateBrowserContextOptions } from './browserContext'; import { normalizeProxySettings, validateBrowserContextOptions } from './browserContext';
import type { BrowserName } from './registry'; import type { BrowserName } from './registry';
import { registry } from './registry'; import { registry } from './registry';
import type { ConnectionTransport } from './transport'; import type { ConnectionTransport } from './transport';
@ -39,6 +39,7 @@ import { RecentLogsCollector } from '../utils/debugLogger';
import type { CallMetadata } from './instrumentation'; import type { CallMetadata } from './instrumentation';
import { SdkObject } from './instrumentation'; import { SdkObject } from './instrumentation';
import { type ProtocolError, isProtocolError } from './protocolError'; import { type ProtocolError, isProtocolError } from './protocolError';
import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor';
export const kNoXServerRunningError = 'Looks like you launched a headed browser without having a XServer running.\n' + export const kNoXServerRunningError = 'Looks like you launched a headed browser without having a XServer running.\n' +
'Set either \'headless: true\' or use \'xvfb-run <your-playwright-app>\' before running Playwright.\n\n<3 Playwright Team'; 'Set either \'headless: true\' or use \'xvfb-run <your-playwright-app>\' before running Playwright.\n\n<3 Playwright Team';
@ -92,27 +93,30 @@ export abstract class BrowserType extends SdkObject {
return browser; return browser;
} }
async launchPersistentContext(metadata: CallMetadata, userDataDir: string, options: channels.BrowserTypeLaunchPersistentContextOptions & { useWebSocket?: boolean }): Promise<BrowserContext> { async launchPersistentContext(metadata: CallMetadata, userDataDir: string, options: channels.BrowserTypeLaunchPersistentContextOptions & { useWebSocket?: boolean, internalIgnoreHTTPSErrors?: boolean }): Promise<BrowserContext> {
options = this._validateLaunchOptions(options); const launchOptions = this._validateLaunchOptions(options);
if (this._useBidi) if (this._useBidi)
options.useWebSocket = true; launchOptions.useWebSocket = true;
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
const persistent: channels.BrowserNewContextParams = { ...options };
controller.setLogName('browser'); controller.setLogName('browser');
const browser = await controller.run(async progress => { const browser = await controller.run(async progress => {
// Note: Any initial TLS requests will fail since we rely on the Page/Frames initialize which sets ignoreHTTPSErrors. // Note: Any initial TLS requests will fail since we rely on the Page/Frames initialize which sets ignoreHTTPSErrors.
const clientCertificatesProxy = await createClientCertificatesProxyIfNeeded(persistent); let clientCertificatesProxy: ClientCertificatesProxy | undefined;
if (clientCertificatesProxy) if (options.clientCertificates?.length) {
options.proxy = persistent.proxy; clientCertificatesProxy = new ClientCertificatesProxy(options);
launchOptions.proxyOverride = await clientCertificatesProxy?.listen();
options = { ...options };
options.internalIgnoreHTTPSErrors = true;
}
progress.cleanupWhenAborted(() => clientCertificatesProxy?.close()); progress.cleanupWhenAborted(() => clientCertificatesProxy?.close());
const browser = await this._innerLaunchWithRetries(progress, options, persistent, helper.debugProtocolLogger(), userDataDir).catch(e => { throw this._rewriteStartupLog(e); }); const browser = await this._innerLaunchWithRetries(progress, launchOptions, options, helper.debugProtocolLogger(), userDataDir).catch(e => { throw this._rewriteStartupLog(e); });
browser._defaultContext!._clientCertificatesProxy = clientCertificatesProxy; browser._defaultContext!._clientCertificatesProxy = clientCertificatesProxy;
return browser; return browser;
}, TimeoutSettings.launchTimeout(options)); }, TimeoutSettings.launchTimeout(launchOptions));
return browser._defaultContext!; return browser._defaultContext!;
} }
async _innerLaunchWithRetries(progress: Progress, options: types.LaunchOptions, persistent: channels.BrowserNewContextParams | undefined, protocolLogger: types.ProtocolLogger, userDataDir?: string): Promise<Browser> { async _innerLaunchWithRetries(progress: Progress, options: types.LaunchOptions, persistent: types.BrowserContextOptions | undefined, protocolLogger: types.ProtocolLogger, userDataDir?: string): Promise<Browser> {
try { try {
return await this._innerLaunch(progress, options, persistent, protocolLogger, userDataDir); return await this._innerLaunch(progress, options, persistent, protocolLogger, userDataDir);
} catch (error) { } catch (error) {
@ -126,7 +130,7 @@ export abstract class BrowserType extends SdkObject {
} }
} }
async _innerLaunch(progress: Progress, options: types.LaunchOptions, persistent: channels.BrowserNewContextParams | undefined, protocolLogger: types.ProtocolLogger, maybeUserDataDir?: string): Promise<Browser> { async _innerLaunch(progress: Progress, options: types.LaunchOptions, persistent: types.BrowserContextOptions | undefined, protocolLogger: types.ProtocolLogger, maybeUserDataDir?: string): Promise<Browser> {
options.proxy = options.proxy ? normalizeProxySettings(options.proxy) : undefined; options.proxy = options.proxy ? normalizeProxySettings(options.proxy) : undefined;
const browserLogsCollector = new RecentLogsCollector(); const browserLogsCollector = new RecentLogsCollector();
const { browserProcess, userDataDir, artifactsDir, transport } = await this._launchProcess(progress, options, !!persistent, browserLogsCollector, maybeUserDataDir); const { browserProcess, userDataDir, artifactsDir, transport } = await this._launchProcess(progress, options, !!persistent, browserLogsCollector, maybeUserDataDir);
@ -289,7 +293,7 @@ export abstract class BrowserType extends SdkObject {
throw new Error('Connecting to SELENIUM_REMOTE_URL is only supported by Chromium'); throw new Error('Connecting to SELENIUM_REMOTE_URL is only supported by Chromium');
} }
private _validateLaunchOptions<Options extends types.LaunchOptions>(options: Options): Options { private _validateLaunchOptions(options: types.LaunchOptions): types.LaunchOptions {
const { devtools = false } = options; const { devtools = false } = options;
let { headless = !devtools, downloadsPath, proxy } = options; let { headless = !devtools, downloadsPath, proxy } = options;
if (debugMode()) if (debugMode())

View file

@ -31,7 +31,6 @@ import { CRDevTools } from './crDevTools';
import type { BrowserOptions, BrowserProcess } from '../browser'; import type { BrowserOptions, BrowserProcess } from '../browser';
import { Browser } from '../browser'; import { Browser } from '../browser';
import type * as types from '../types'; import type * as types from '../types';
import type * as channels from '@protocol/channels';
import type { HTTPRequestParams } from '../../utils/network'; import type { HTTPRequestParams } from '../../utils/network';
import { fetchData } from '../../utils/network'; import { fetchData } from '../../utils/network';
import { getUserAgent } from '../../utils/userAgent'; import { getUserAgent } from '../../utils/userAgent';
@ -98,7 +97,7 @@ export class Chromium extends BrowserType {
await cleanedUp; await cleanedUp;
}; };
const browserProcess: BrowserProcess = { close: doClose, kill: doClose }; const browserProcess: BrowserProcess = { close: doClose, kill: doClose };
const persistent: channels.BrowserNewContextParams = { noDefaultViewport: true }; const persistent: types.BrowserContextOptions = { noDefaultViewport: true };
const browserOptions: BrowserOptions = { const browserOptions: BrowserOptions = {
slowMo: options.slowMo, slowMo: options.slowMo,
name: 'chromium', name: 'chromium',
@ -287,7 +286,7 @@ export class Chromium extends BrowserType {
} }
private _innerDefaultArgs(options: types.LaunchOptions): string[] { private _innerDefaultArgs(options: types.LaunchOptions): string[] {
const { args = [], proxy } = options; const { args = [] } = options;
const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir')); const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir'));
if (userDataDirArg) if (userDataDirArg)
throw this._createUserDataDirArgMisuseError('--user-data-dir'); throw this._createUserDataDirArgMisuseError('--user-data-dir');
@ -321,6 +320,7 @@ export class Chromium extends BrowserType {
} }
if (options.chromiumSandbox !== true) if (options.chromiumSandbox !== true)
chromeArguments.push('--no-sandbox'); chromeArguments.push('--no-sandbox');
const proxy = options.proxyOverride || options.proxy;
if (proxy) { if (proxy) {
const proxyURL = new URL(proxy.server); const proxyURL = new URL(proxy.server);
const isSocks = proxyURL.protocol === 'socks5:'; const isSocks = proxyURL.protocol === 'socks5:';

View file

@ -100,18 +100,19 @@ export class CRBrowser extends Browser {
this._session.on('Browser.downloadProgress', this._onDownloadProgress.bind(this)); this._session.on('Browser.downloadProgress', this._onDownloadProgress.bind(this));
} }
async doCreateNewContext(options: channels.BrowserNewContextParams): Promise<BrowserContext> { async doCreateNewContext(options: types.BrowserContextOptions): Promise<BrowserContext> {
const proxy = options.proxyOverride || options.proxy;
let proxyBypassList = undefined; let proxyBypassList = undefined;
if (options.proxy) { if (proxy) {
if (process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK) if (process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK)
proxyBypassList = options.proxy.bypass; proxyBypassList = proxy.bypass;
else else
proxyBypassList = '<-loopback>' + (options.proxy.bypass ? `,${options.proxy.bypass}` : ''); proxyBypassList = '<-loopback>' + (proxy.bypass ? `,${proxy.bypass}` : '');
} }
const { browserContextId } = await this._session.send('Target.createBrowserContext', { const { browserContextId } = await this._session.send('Target.createBrowserContext', {
disposeOnDetach: true, disposeOnDetach: true,
proxyServer: options.proxy ? options.proxy.server : undefined, proxyServer: proxy ? proxy.server : undefined,
proxyBypassList, proxyBypassList,
}); });
const context = new CRBrowserContext(this, browserContextId, options); const context = new CRBrowserContext(this, browserContextId, options);
@ -340,7 +341,7 @@ export class CRBrowserContext extends BrowserContext {
declare readonly _browser: CRBrowser; declare readonly _browser: CRBrowser;
constructor(browser: CRBrowser, browserContextId: string | undefined, options: channels.BrowserNewContextParams) { constructor(browser: CRBrowser, browserContextId: string | undefined, options: types.BrowserContextOptions) {
super(browser, options, browserContextId); super(browser, options, browserContextId);
this._authenticateProxyViaCredentials(); this._authenticateProxyViaCredentials();
} }

View file

@ -247,6 +247,10 @@ export class CRPage implements PageDelegate {
return this._go(+1); return this._go(+1);
} }
async forceGarbageCollection(): Promise<void> {
await this._mainFrameSession._client.send('HeapProfiler.collectGarbage');
}
async addInitScript(initScript: InitScript, world: types.World = 'main'): Promise<void> { async addInitScript(initScript: InitScript, world: types.World = 'main'): Promise<void> {
await this._forAllFrameSessions(frame => frame._evaluateOnNewDocument(initScript, world)); await this._forAllFrameSessions(frame => frame._evaluateOnNewDocument(initScript, world));
} }
@ -543,7 +547,7 @@ class FrameSession {
const options = this._crPage._browserContext._options; const options = this._crPage._browserContext._options;
if (options.bypassCSP) if (options.bypassCSP)
promises.push(this._client.send('Page.setBypassCSP', { enabled: true })); promises.push(this._client.send('Page.setBypassCSP', { enabled: true }));
if (options.ignoreHTTPSErrors) if (options.ignoreHTTPSErrors || options.internalIgnoreHTTPSErrors)
promises.push(this._client.send('Security.setIgnoreCertificateErrors', { ignore: true })); promises.push(this._client.send('Security.setIgnoreCertificateErrors', { ignore: true }));
if (this._isMainFrame()) if (this._isMainFrame())
promises.push(this._updateViewport()); promises.push(this._updateViewport());
@ -1213,7 +1217,7 @@ async function emulateTimezone(session: CRSession, timezoneId: string) {
const contextDelegateSymbol = Symbol('delegate'); const contextDelegateSymbol = Symbol('delegate');
// Chromium reference: https://source.chromium.org/chromium/chromium/src/+/main:components/embedder_support/user_agent_utils.cc;l=434;drc=70a6711e08e9f9e0d8e4c48e9ba5cab62eb010c2 // Chromium reference: https://source.chromium.org/chromium/chromium/src/+/main:components/embedder_support/user_agent_utils.cc;l=434;drc=70a6711e08e9f9e0d8e4c48e9ba5cab62eb010c2
function calculateUserAgentMetadata(options: channels.BrowserNewContextParams) { function calculateUserAgentMetadata(options: types.BrowserContextOptions) {
const ua = options.userAgent; const ua = options.userAgent;
if (!ua) if (!ua)
return undefined; return undefined;

View file

@ -16,7 +16,7 @@
import type { BrowserContextOptions } from '../../../types/types'; import type { BrowserContextOptions } from '../../../types/types';
import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types';
import { sanitizeDeviceOptions, toClickOptions, toKeyboardModifiers, toSignalMap } from './language'; import { sanitizeDeviceOptions, toClickOptionsForSourceCode, toKeyboardModifiers, toSignalMap } from './language';
import { escapeWithQuotes, asLocator } from '../../utils'; import { escapeWithQuotes, asLocator } from '../../utils';
import { deviceDescriptors } from '../deviceDescriptors'; import { deviceDescriptors } from '../deviceDescriptors';
@ -112,7 +112,7 @@ export class CSharpLanguageGenerator implements LanguageGenerator {
let method = 'Click'; let method = 'Click';
if (action.clickCount === 2) if (action.clickCount === 2)
method = 'DblClick'; method = 'DblClick';
const options = toClickOptions(action); const options = toClickOptionsForSourceCode(action);
if (!Object.entries(options).length) if (!Object.entries(options).length)
return `await ${subject}.${this._asLocator(action.selector)}.${method}Async();`; return `await ${subject}.${this._asLocator(action.selector)}.${method}Async();`;
const optionsString = formatObject(options, ' ', 'Locator' + method + 'Options'); const optionsString = formatObject(options, ' ', 'Locator' + method + 'Options');

View file

@ -17,7 +17,7 @@
import type { BrowserContextOptions } from '../../../types/types'; import type { BrowserContextOptions } from '../../../types/types';
import type * as types from '../types'; import type * as types from '../types';
import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types';
import { toClickOptions, toKeyboardModifiers, toSignalMap } from './language'; import { toClickOptionsForSourceCode, toKeyboardModifiers, toSignalMap } from './language';
import { deviceDescriptors } from '../deviceDescriptors'; import { deviceDescriptors } from '../deviceDescriptors';
import { JavaScriptFormatter } from './javascript'; import { JavaScriptFormatter } from './javascript';
import { escapeWithQuotes, asLocator } from '../../utils'; import { escapeWithQuotes, asLocator } from '../../utils';
@ -101,7 +101,7 @@ export class JavaLanguageGenerator implements LanguageGenerator {
let method = 'click'; let method = 'click';
if (action.clickCount === 2) if (action.clickCount === 2)
method = 'dblclick'; method = 'dblclick';
const options = toClickOptions(action); const options = toClickOptionsForSourceCode(action);
const optionsText = formatClickOptions(options); const optionsText = formatClickOptions(options);
return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.${method}(${optionsText});`; return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.${method}(${optionsText});`;
} }

View file

@ -16,7 +16,7 @@
import type { BrowserContextOptions } from '../../../types/types'; import type { BrowserContextOptions } from '../../../types/types';
import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types';
import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptions } from './language'; import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptionsForSourceCode } from './language';
import { deviceDescriptors } from '../deviceDescriptors'; import { deviceDescriptors } from '../deviceDescriptors';
import { escapeWithQuotes, asLocator } from '../../utils'; import { escapeWithQuotes, asLocator } from '../../utils';
@ -85,7 +85,7 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator {
let method = 'click'; let method = 'click';
if (action.clickCount === 2) if (action.clickCount === 2)
method = 'dblclick'; method = 'dblclick';
const options = toClickOptions(action); const options = toClickOptionsForSourceCode(action);
const optionsString = formatOptions(options, false); const optionsString = formatOptions(options, false);
return `await ${subject}.${this._asLocator(action.selector)}.${method}(${optionsString});`; return `await ${subject}.${this._asLocator(action.selector)}.${method}(${optionsString});`;
} }

View file

@ -20,6 +20,7 @@ import type * as types from '../types';
import type { ActionInContext, LanguageGenerator, LanguageGeneratorOptions } from './types'; import type { ActionInContext, LanguageGenerator, LanguageGeneratorOptions } from './types';
export function generateCode(actions: ActionInContext[], languageGenerator: LanguageGenerator, options: LanguageGeneratorOptions) { export function generateCode(actions: ActionInContext[], languageGenerator: LanguageGenerator, options: LanguageGeneratorOptions) {
actions = collapseActions(actions);
const header = languageGenerator.generateHeader(options); const header = languageGenerator.generateHeader(options);
const footer = languageGenerator.generateFooter(options.saveStorage); const footer = languageGenerator.generateFooter(options.saveStorage);
const actionTexts = actions.map(a => languageGenerator.generateAction(a)).filter(Boolean); const actionTexts = actions.map(a => languageGenerator.generateAction(a)).filter(Boolean);
@ -69,16 +70,33 @@ export function toKeyboardModifiers(modifiers: number): types.SmartKeyboardModif
return result; return result;
} }
export function toClickOptions(action: actions.ClickAction): types.MouseClickOptions { export function toClickOptionsForSourceCode(action: actions.ClickAction): types.MouseClickOptions {
const modifiers = toKeyboardModifiers(action.modifiers); const modifiers = toKeyboardModifiers(action.modifiers);
const options: types.MouseClickOptions = {}; const options: types.MouseClickOptions = {};
if (action.button !== 'left') if (action.button !== 'left')
options.button = action.button; options.button = action.button;
if (modifiers.length) if (modifiers.length)
options.modifiers = modifiers; options.modifiers = modifiers;
// Do not render clickCount === 2 for dblclick.
if (action.clickCount > 2) if (action.clickCount > 2)
options.clickCount = action.clickCount; options.clickCount = action.clickCount;
if (action.position) if (action.position)
options.position = action.position; options.position = action.position;
return options; return options;
} }
function collapseActions(actions: ActionInContext[]): ActionInContext[] {
const result: ActionInContext[] = [];
for (const action of actions) {
const lastAction = result[result.length - 1];
const isSameAction = lastAction && lastAction.action.name === action.action.name && lastAction.frame.pageAlias === action.frame.pageAlias && lastAction.frame.framePath.join('|') === action.frame.framePath.join('|');
const isSameSelector = lastAction && 'selector' in lastAction.action && 'selector' in action.action && action.action.selector === lastAction.action.selector;
const shouldMerge = isSameAction && (action.action.name === 'navigate' || (action.action.name === 'fill' && isSameSelector));
if (!shouldMerge) {
result.push(action);
continue;
}
result[result.length - 1] = action;
}
return result;
}

View file

@ -16,7 +16,7 @@
import type { BrowserContextOptions } from '../../../types/types'; import type { BrowserContextOptions } from '../../../types/types';
import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types';
import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptions } from './language'; import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptionsForSourceCode } from './language';
import { escapeWithQuotes, toSnakeCase, asLocator } from '../../utils'; import { escapeWithQuotes, toSnakeCase, asLocator } from '../../utils';
import { deviceDescriptors } from '../deviceDescriptors'; import { deviceDescriptors } from '../deviceDescriptors';
@ -94,7 +94,7 @@ export class PythonLanguageGenerator implements LanguageGenerator {
let method = 'click'; let method = 'click';
if (action.clickCount === 2) if (action.clickCount === 2)
method = 'dblclick'; method = 'dblclick';
const options = toClickOptions(action); const options = toClickOptionsForSourceCode(action);
const optionsString = formatOptions(options, false); const optionsString = formatOptions(options, false);
return `${subject}.${this._asLocator(action.selector)}.${method}(${optionsString})`; return `${subject}.${this._asLocator(action.selector)}.${method}(${optionsString})`;
} }

View file

@ -36,7 +36,7 @@ export type ActionInContext = {
frame: FrameDescription; frame: FrameDescription;
description?: string; description?: string;
action: actions.Action; action: actions.Action;
committed?: boolean; timestamp: number;
}; };
export interface LanguageGenerator { export interface LanguageGenerator {

View file

@ -15,6 +15,7 @@
*/ */
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
import { kMaxCookieExpiresDateInSeconds } from './network';
class Cookie { class Cookie {
private _raw: channels.NetworkCookie; private _raw: channels.NetworkCookie;
@ -115,6 +116,97 @@ export class CookieStore {
} }
} }
type RawCookie = {
name: string,
value: string,
domain?: string,
path?: string,
expires?: number,
httpOnly?: boolean,
secure?: boolean,
sameSite?: 'Strict' | 'Lax' | 'None',
};
export function parseRawCookie(header: string): RawCookie | null {
const pairs = header.split(';').filter(s => s.trim().length > 0).map(p => {
let key = '';
let value = '';
const separatorPos = p.indexOf('=');
if (separatorPos === -1) {
// If only a key is specified, the value is left undefined.
key = p.trim();
} else {
// Otherwise we assume that the key is the element before the first `=`
key = p.slice(0, separatorPos).trim();
// And the value is the rest of the string.
value = p.slice(separatorPos + 1).trim();
}
return [key, value];
});
if (!pairs.length)
return null;
const [name, value] = pairs[0];
const cookie: RawCookie = {
name,
value,
};
for (let i = 1; i < pairs.length; i++) {
const [name, value] = pairs[i];
switch (name.toLowerCase()) {
case 'expires':
const expiresMs = (+new Date(value));
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.1
if (isFinite(expiresMs)) {
if (expiresMs <= 0)
cookie.expires = 0;
else
cookie.expires = Math.min(expiresMs / 1000, kMaxCookieExpiresDateInSeconds);
}
break;
case 'max-age':
const maxAgeSec = parseInt(value, 10);
if (isFinite(maxAgeSec)) {
// From https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2
// If delta-seconds is less than or equal to zero (0), let expiry-time
// be the earliest representable date and time.
if (maxAgeSec <= 0)
cookie.expires = 0;
else
cookie.expires = Math.min(Date.now() / 1000 + maxAgeSec, kMaxCookieExpiresDateInSeconds);
}
break;
case 'domain':
cookie.domain = value.toLocaleLowerCase() || '';
if (cookie.domain && !cookie.domain.startsWith('.') && cookie.domain.includes('.'))
cookie.domain = '.' + cookie.domain;
break;
case 'path':
cookie.path = value || '';
break;
case 'secure':
cookie.secure = true;
break;
case 'httponly':
cookie.httpOnly = true;
break;
case 'samesite':
switch (value.toLowerCase()) {
case 'none':
cookie.sameSite = 'None';
break;
case 'lax':
cookie.sameSite = 'Lax';
break;
case 'strict':
cookie.sameSite = 'Strict';
break;
}
break;
}
}
return cookie;
}
export function domainMatches(value: string, domain: string): boolean { export function domainMatches(value: string, domain: string): boolean {
if (value === domain) if (value === domain)
return true; return true;

View file

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

View file

@ -39,6 +39,7 @@ import type { Dialog } from '../dialog';
import type { ConsoleMessage } from '../console'; import type { ConsoleMessage } from '../console';
import { serializeError } from '../errors'; import { serializeError } from '../errors';
import { ElementHandleDispatcher } from './elementHandlerDispatcher'; import { ElementHandleDispatcher } from './elementHandlerDispatcher';
import { RecorderInTraceViewer } from '../recorder/recorderInTraceViewer';
import { RecorderApp } from '../recorder/recorderApp'; import { RecorderApp } from '../recorder/recorderApp';
export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channels.BrowserContextChannel, DispatcherScope> implements channels.BrowserContextChannel { export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channels.BrowserContextChannel, DispatcherScope> implements channels.BrowserContextChannel {
@ -292,7 +293,8 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
} }
async recorderSupplementEnable(params: channels.BrowserContextRecorderSupplementEnableParams): Promise<void> { async recorderSupplementEnable(params: channels.BrowserContextRecorderSupplementEnableParams): Promise<void> {
await Recorder.show(this._context, RecorderApp.factory(this._context), params); const factory = process.env.PW_RECORDER_IS_TRACE_VIEWER ? RecorderInTraceViewer.factory(this._context) : RecorderApp.factory(this._context);
await Recorder.show(this._context, factory, params);
} }
async pause(params: channels.BrowserContextPauseParams, metadata: CallMetadata) { async pause(params: channels.BrowserContextPauseParams, metadata: CallMetadata) {

View file

@ -137,6 +137,10 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
return { response: ResponseDispatcher.fromNullable(this.parentScope(), await this._page.goForward(metadata, params)) }; return { response: ResponseDispatcher.fromNullable(this.parentScope(), await this._page.goForward(metadata, params)) };
} }
async forceGarbageCollection(params: channels.PageForceGarbageCollectionParams, metadata: CallMetadata): Promise<channels.PageForceGarbageCollectionResult> {
await this._page.forceGarbageCollection();
}
async registerLocatorHandler(params: channels.PageRegisterLocatorHandlerParams, metadata: CallMetadata): Promise<channels.PageRegisterLocatorHandlerResult> { async registerLocatorHandler(params: channels.PageRegisterLocatorHandlerParams, metadata: CallMetadata): Promise<channels.PageRegisterLocatorHandlerResult> {
const uid = this._page.registerLocatorHandler(params.selector, params.noWaitAfter); const uid = this._page.registerLocatorHandler(params.selector, params.noWaitAfter);
return { uid }; return { uid };

View file

@ -36,6 +36,7 @@ import type { BrowserWindow } from 'electron';
import type { Progress } from '../progress'; import type { Progress } from '../progress';
import { ProgressController } from '../progress'; import { ProgressController } from '../progress';
import { helper } from '../helper'; import { helper } from '../helper';
import type * as types from '../types';
import { eventsHelper } from '../../utils/eventsHelper'; import { eventsHelper } from '../../utils/eventsHelper';
import type { BrowserOptions, BrowserProcess } from '../browser'; import type { BrowserOptions, BrowserProcess } from '../browser';
import type { Playwright } from '../playwright'; import type { Playwright } from '../playwright';
@ -265,7 +266,7 @@ export class Electron extends SdkObject {
close: gracefullyClose, close: gracefullyClose,
kill kill
}; };
const contextOptions: channels.BrowserNewContextParams = { const contextOptions: types.BrowserContextOptions = {
...options, ...options,
noDefaultViewport: true, noDefaultViewport: true,
}; };

View file

@ -28,7 +28,7 @@ import { getUserAgent } from '../utils/userAgent';
import { assert, createGuid, monotonicTime } from '../utils'; import { assert, createGuid, monotonicTime } from '../utils';
import { HttpsProxyAgent, SocksProxyAgent } from '../utilsBundle'; import { HttpsProxyAgent, SocksProxyAgent } from '../utilsBundle';
import { BrowserContext, verifyClientCertificates } from './browserContext'; import { BrowserContext, verifyClientCertificates } from './browserContext';
import { CookieStore, domainMatches } from './cookieStore'; import { CookieStore, domainMatches, parseRawCookie } from './cookieStore';
import { MultipartFormData } from './formData'; import { MultipartFormData } from './formData';
import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from '../utils/happy-eyeballs'; import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from '../utils/happy-eyeballs';
import type { CallMetadata } from './instrumentation'; import type { CallMetadata } from './instrumentation';
@ -39,7 +39,6 @@ import { ProgressController } from './progress';
import { Tracing } from './trace/recorder/tracing'; import { Tracing } from './trace/recorder/tracing';
import type * as types from './types'; import type * as types from './types';
import type { HeadersArray, ProxySettings } from './types'; import type { HeadersArray, ProxySettings } from './types';
import { kMaxCookieExpiresDateInSeconds } from './network';
import { getMatchingTLSOptionsForOrigin, rewriteOpenSSLErrorIfNeeded } from './socksClientCertificatesInterceptor'; import { getMatchingTLSOptionsForOrigin, rewriteOpenSSLErrorIfNeeded } from './socksClientCertificatesInterceptor';
type FetchRequestOptions = { type FetchRequestOptions = {
@ -50,7 +49,7 @@ type FetchRequestOptions = {
timeoutSettings: TimeoutSettings; timeoutSettings: TimeoutSettings;
ignoreHTTPSErrors?: boolean; ignoreHTTPSErrors?: boolean;
baseURL?: string; baseURL?: string;
clientCertificates?: channels.BrowserNewContextOptions['clientCertificates']; clientCertificates?: types.BrowserContextOptions['clientCertificates'];
}; };
type HeadersObject = Readonly<{ [name: string]: string }>; type HeadersObject = Readonly<{ [name: string]: string }>;
@ -169,23 +168,11 @@ export abstract class APIRequestContext extends SdkObject {
const method = params.method?.toUpperCase() || 'GET'; const method = params.method?.toUpperCase() || 'GET';
const proxy = defaults.proxy; const proxy = defaults.proxy;
let agent; let agent;
// When `clientCertificates` is present, we set the `proxy` property to our own socks proxy // We skip 'per-context' in order to not break existing users. 'per-context' was previously used to
// for the browser to use. However, we don't need it here, because we already respect // workaround an upstream Chromium bug. Can be removed in the future.
// `clientCertificates` when fetching from Node.js. if (proxy && proxy.server !== 'per-context' && !shouldBypassProxy(requestUrl, proxy.bypass))
if (proxy && !defaults.clientCertificates?.length && proxy.server !== 'per-context' && !shouldBypassProxy(requestUrl, proxy.bypass)) { agent = createProxyAgent(proxy);
const proxyOpts = url.parse(proxy.server);
if (proxyOpts.protocol?.startsWith('socks')) {
agent = new SocksProxyAgent({
host: proxyOpts.hostname,
port: proxyOpts.port || undefined,
});
} else {
if (proxy.username)
proxyOpts.auth = `${proxy.username}:${proxy.password || ''}`;
// TODO: We should use HttpProxyAgent conditional on proxyOpts.protocol instead of always using CONNECT method.
agent = new HttpsProxyAgent(proxyOpts);
}
}
const timeout = defaults.timeoutSettings.timeout(params); const timeout = defaults.timeoutSettings.timeout(params);
const deadline = timeout && (monotonicTime() + timeout); const deadline = timeout && (monotonicTime() + timeout);
@ -578,8 +565,6 @@ export class GlobalAPIRequestContext extends APIRequestContext {
if (!/^\w+:\/\//.test(url)) if (!/^\w+:\/\//.test(url))
url = 'http://' + url; url = 'http://' + url;
proxy.server = url; proxy.server = url;
if (options.clientCertificates)
throw new Error('Cannot specify both proxy and clientCertificates');
} }
if (options.storageState) { if (options.storageState) {
this._origins = options.storageState.origins; this._origins = options.storageState.origins;
@ -630,6 +615,20 @@ export class GlobalAPIRequestContext extends APIRequestContext {
} }
} }
export function createProxyAgent(proxy: types.ProxySettings) {
const proxyOpts = url.parse(proxy.server);
if (proxyOpts.protocol?.startsWith('socks')) {
return new SocksProxyAgent({
host: proxyOpts.hostname,
port: proxyOpts.port || undefined,
});
}
if (proxy.username)
proxyOpts.auth = `${proxy.username}:${proxy.password || ''}`;
// TODO: We should use HttpProxyAgent conditional on proxyOpts.protocol instead of always using CONNECT method.
return new HttpsProxyAgent(proxyOpts);
}
function toHeadersArray(rawHeaders: string[]): types.HeadersArray { function toHeadersArray(rawHeaders: string[]): types.HeadersArray {
const result: types.HeadersArray = []; const result: types.HeadersArray = [];
for (let i = 0; i < rawHeaders.length; i += 2) for (let i = 0; i < rawHeaders.length; i += 2)
@ -640,27 +639,10 @@ function toHeadersArray(rawHeaders: string[]): types.HeadersArray {
const redirectStatus = [301, 302, 303, 307, 308]; const redirectStatus = [301, 302, 303, 307, 308];
function parseCookie(header: string): channels.NetworkCookie | null { function parseCookie(header: string): channels.NetworkCookie | null {
const pairs = header.split(';').filter(s => s.trim().length > 0).map(p => { const raw = parseRawCookie(header);
let key = ''; if (!raw)
let value = '';
const separatorPos = p.indexOf('=');
if (separatorPos === -1) {
// If only a key is specified, the value is left undefined.
key = p.trim();
} else {
// Otherwise we assume that the key is the element before the first `=`
key = p.slice(0, separatorPos).trim();
// And the value is the rest of the string.
value = p.slice(separatorPos + 1).trim();
}
return [key, value];
});
if (!pairs.length)
return null; return null;
const [name, value] = pairs[0];
const cookie: channels.NetworkCookie = { const cookie: channels.NetworkCookie = {
name,
value,
domain: '', domain: '',
path: '', path: '',
expires: -1, expires: -1,
@ -668,62 +650,9 @@ function parseCookie(header: string): channels.NetworkCookie | null {
secure: false, secure: false,
// From https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite // From https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite
// The cookie-sending behavior if SameSite is not specified is SameSite=Lax. // The cookie-sending behavior if SameSite is not specified is SameSite=Lax.
sameSite: 'Lax' sameSite: 'Lax',
...raw
}; };
for (let i = 1; i < pairs.length; i++) {
const [name, value] = pairs[i];
switch (name.toLowerCase()) {
case 'expires':
const expiresMs = (+new Date(value));
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.1
if (isFinite(expiresMs)) {
if (expiresMs <= 0)
cookie.expires = 0;
else
cookie.expires = Math.min(expiresMs / 1000, kMaxCookieExpiresDateInSeconds);
}
break;
case 'max-age':
const maxAgeSec = parseInt(value, 10);
if (isFinite(maxAgeSec)) {
// From https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2
// If delta-seconds is less than or equal to zero (0), let expiry-time
// be the earliest representable date and time.
if (maxAgeSec <= 0)
cookie.expires = 0;
else
cookie.expires = Math.min(Date.now() / 1000 + maxAgeSec, kMaxCookieExpiresDateInSeconds);
}
break;
case 'domain':
cookie.domain = value.toLocaleLowerCase() || '';
if (cookie.domain && !cookie.domain.startsWith('.') && cookie.domain.includes('.'))
cookie.domain = '.' + cookie.domain;
break;
case 'path':
cookie.path = value || '';
break;
case 'secure':
cookie.secure = true;
break;
case 'httponly':
cookie.httpOnly = true;
break;
case 'samesite':
switch (value.toLowerCase()) {
case 'none':
cookie.sameSite = 'None';
break;
case 'lax':
cookie.sameSite = 'Lax';
break;
case 'strict':
cookie.sameSite = 'Strict';
break;
}
break;
}
}
return cookie; return cookie;
} }

View file

@ -58,8 +58,9 @@ export class FFBrowser extends Browser {
browser._defaultContext = new FFBrowserContext(browser, undefined, options.persistent); browser._defaultContext = new FFBrowserContext(browser, undefined, options.persistent);
promises.push((browser._defaultContext as FFBrowserContext)._initialize()); promises.push((browser._defaultContext as FFBrowserContext)._initialize());
} }
if (options.proxy) const proxy = options.originalLaunchOptions.proxyOverride || options.proxy;
promises.push(browser.session.send('Browser.setBrowserProxy', toJugglerProxyOptions(options.proxy))); if (proxy)
promises.push(browser.session.send('Browser.setBrowserProxy', toJugglerProxyOptions(proxy)));
await Promise.all(promises); await Promise.all(promises);
return browser; return browser;
} }
@ -88,7 +89,7 @@ export class FFBrowser extends Browser {
return !this._connection._closed; return !this._connection._closed;
} }
async doCreateNewContext(options: channels.BrowserNewContextParams): Promise<BrowserContext> { async doCreateNewContext(options: types.BrowserContextOptions): Promise<BrowserContext> {
if (options.isMobile) if (options.isMobile)
throw new Error('options.isMobile is not supported in Firefox'); throw new Error('options.isMobile is not supported in Firefox');
const { browserContextId } = await this.session.send('Browser.createBrowserContext', { removeOnDetach: true }); const { browserContextId } = await this.session.send('Browser.createBrowserContext', { removeOnDetach: true });
@ -172,7 +173,7 @@ export class FFBrowser extends Browser {
export class FFBrowserContext extends BrowserContext { export class FFBrowserContext extends BrowserContext {
declare readonly _browser: FFBrowser; declare readonly _browser: FFBrowser;
constructor(browser: FFBrowser, browserContextId: string | undefined, options: channels.BrowserNewContextParams) { constructor(browser: FFBrowser, browserContextId: string | undefined, options: types.BrowserContextOptions) {
super(browser, options, browserContextId); super(browser, options, browserContextId);
} }
@ -205,7 +206,7 @@ export class FFBrowserContext extends BrowserContext {
promises.push(this._browser.session.send('Browser.setUserAgentOverride', { browserContextId, userAgent: this._options.userAgent })); promises.push(this._browser.session.send('Browser.setUserAgentOverride', { browserContextId, userAgent: this._options.userAgent }));
if (this._options.bypassCSP) if (this._options.bypassCSP)
promises.push(this._browser.session.send('Browser.setBypassCSP', { browserContextId, bypassCSP: true })); promises.push(this._browser.session.send('Browser.setBypassCSP', { browserContextId, bypassCSP: true }));
if (this._options.ignoreHTTPSErrors) if (this._options.ignoreHTTPSErrors || this._options.internalIgnoreHTTPSErrors)
promises.push(this._browser.session.send('Browser.setIgnoreHTTPSErrors', { browserContextId, ignoreHTTPSErrors: true })); promises.push(this._browser.session.send('Browser.setIgnoreHTTPSErrors', { browserContextId, ignoreHTTPSErrors: true }));
if (this._options.javaScriptEnabled === false) if (this._options.javaScriptEnabled === false)
promises.push(this._browser.session.send('Browser.setJavaScriptDisabled', { browserContextId, javaScriptDisabled: true })); promises.push(this._browser.session.send('Browser.setJavaScriptDisabled', { browserContextId, javaScriptDisabled: true }));
@ -251,10 +252,11 @@ export class FFBrowserContext extends BrowserContext {
}); });
})); }));
} }
if (this._options.proxy) { const proxy = this._options.proxyOverride || this._options.proxy;
if (proxy) {
promises.push(this._browser.session.send('Browser.setContextProxy', { promises.push(this._browser.session.send('Browser.setContextProxy', {
browserContextId: this._browserContextId, browserContextId: this._browserContextId,
...toJugglerProxyOptions(this._options.proxy) ...toJugglerProxyOptions(proxy)
})); }));
} }

View file

@ -400,6 +400,10 @@ export class FFPage implements PageDelegate {
return success; return success;
} }
async forceGarbageCollection(): Promise<void> {
await this._session.send('Heap.collectGarbage');
}
async addInitScript(initScript: InitScript, worldName?: string): Promise<void> { async addInitScript(initScript: InitScript, worldName?: string): Promise<void> {
this._initScripts.push({ initScript, worldName }); this._initScripts.push({ initScript, worldName });
await this._session.send('Page.setInitScripts', { scripts: this._initScripts.map(s => ({ script: s.initScript.source, worldName: s.worldName })) }); await this._session.send('Page.setInitScripts', { scripts: this._initScripts.map(s => ({ script: s.initScript.source, worldName: s.worldName })) });

View file

@ -315,6 +315,11 @@ export module Protocol {
}; };
export type cancelDownloadReturnValue = void; export type cancelDownloadReturnValue = void;
} }
export module Heap {
export type collectGarbageParameters = {
};
export type collectGarbageReturnValue = void;
}
export module Page { export module Page {
export type DOMPoint = { export type DOMPoint = {
x: number; x: number;
@ -1124,6 +1129,7 @@ export module Protocol {
"Browser.setForcedColors": Browser.setForcedColorsParameters; "Browser.setForcedColors": Browser.setForcedColorsParameters;
"Browser.setVideoRecordingOptions": Browser.setVideoRecordingOptionsParameters; "Browser.setVideoRecordingOptions": Browser.setVideoRecordingOptionsParameters;
"Browser.cancelDownload": Browser.cancelDownloadParameters; "Browser.cancelDownload": Browser.cancelDownloadParameters;
"Heap.collectGarbage": Heap.collectGarbageParameters;
"Page.close": Page.closeParameters; "Page.close": Page.closeParameters;
"Page.setFileInputFiles": Page.setFileInputFilesParameters; "Page.setFileInputFiles": Page.setFileInputFilesParameters;
"Page.addBinding": Page.addBindingParameters; "Page.addBinding": Page.addBindingParameters;
@ -1204,6 +1210,7 @@ export module Protocol {
"Browser.setForcedColors": Browser.setForcedColorsReturnValue; "Browser.setForcedColors": Browser.setForcedColorsReturnValue;
"Browser.setVideoRecordingOptions": Browser.setVideoRecordingOptionsReturnValue; "Browser.setVideoRecordingOptions": Browser.setVideoRecordingOptionsReturnValue;
"Browser.cancelDownload": Browser.cancelDownloadReturnValue; "Browser.cancelDownload": Browser.cancelDownloadReturnValue;
"Heap.collectGarbage": Heap.collectGarbageReturnValue;
"Page.close": Page.closeReturnValue; "Page.close": Page.closeReturnValue;
"Page.setFileInputFiles": Page.setFileInputFilesReturnValue; "Page.setFileInputFiles": Page.setFileInputFilesReturnValue;
"Page.addBinding": Page.addBindingReturnValue; "Page.addBinding": Page.addBindingReturnValue;

View file

@ -90,7 +90,8 @@ export class Highlight {
} }
install() { install() {
this._injectedScript.document.documentElement.appendChild(this._glassPaneElement); if (!this._injectedScript.document.documentElement.contains(this._glassPaneElement))
this._injectedScript.document.documentElement.appendChild(this._glassPaneElement);
} }
setLanguage(language: Language) { setLanguage(language: Language) {

View file

@ -36,6 +36,7 @@ interface RecorderTool {
cursor(): string; cursor(): string;
cleanup?(): void; cleanup?(): void;
onClick?(event: MouseEvent): void; onClick?(event: MouseEvent): void;
onDblClick?(event: MouseEvent): void;
onContextMenu?(event: MouseEvent): void; onContextMenu?(event: MouseEvent): void;
onDragStart?(event: DragEvent): void; onDragStart?(event: DragEvent): void;
onInput?(event: Event): void; onInput?(event: Event): void;
@ -210,6 +211,7 @@ class RecordActionTool implements RecorderTool {
private _hoveredElement: HTMLElement | null = null; private _hoveredElement: HTMLElement | null = null;
private _activeModel: HighlightModel | null = null; private _activeModel: HighlightModel | null = null;
private _expectProgrammaticKeyUp = false; private _expectProgrammaticKeyUp = false;
private _pendingClickAction: { action: actions.ClickAction, timeout: number } | undefined;
constructor(recorder: Recorder) { constructor(recorder: Recorder) {
this._recorder = recorder; this._recorder = recorder;
@ -252,6 +254,38 @@ class RecordActionTool implements RecorderTool {
return; return;
} }
this._cancelPendingClickAction();
// Stall click in case we are observing double-click.
if (event.detail === 1) {
this._pendingClickAction = {
action: {
name: 'click',
selector: this._hoveredModel!.selector,
position: positionForEvent(event),
signals: [],
button: buttonForEvent(event),
modifiers: modifiersForEvent(event),
clickCount: event.detail
},
timeout: this._recorder.injectedScript.builtinSetTimeout(() => this._commitPendingClickAction(), 200)
};
}
}
onDblClick(event: MouseEvent) {
if (isRangeInput(this._hoveredElement))
return;
if (this._shouldIgnoreMouseEvent(event))
return;
// Only allow double click dispatch while action is in progress.
if (this._actionInProgress(event))
return;
if (this._consumedDueToNoModel(event, this._hoveredModel))
return;
this._cancelPendingClickAction();
this._performAction({ this._performAction({
name: 'click', name: 'click',
selector: this._hoveredModel!.selector, selector: this._hoveredModel!.selector,
@ -263,6 +297,18 @@ class RecordActionTool implements RecorderTool {
}); });
} }
private _commitPendingClickAction() {
if (this._pendingClickAction)
this._performAction(this._pendingClickAction.action);
this._cancelPendingClickAction();
}
private _cancelPendingClickAction() {
if (this._pendingClickAction)
clearTimeout(this._pendingClickAction.timeout);
this._pendingClickAction = undefined;
}
onContextMenu(event: MouseEvent) { onContextMenu(event: MouseEvent) {
// the 'contextmenu' event is triggered by a right-click or equivalent action, // the 'contextmenu' event is triggered by a right-click or equivalent action,
// and it prevents the click event from firing for that action, so we always // and it prevents the click event from firing for that action, so we always
@ -915,6 +961,10 @@ class Overlay {
} }
return false; return false;
} }
onDblClick(event: MouseEvent) {
return false;
}
} }
export class Recorder { export class Recorder {
@ -970,6 +1020,7 @@ export class Recorder {
this._listeners = [ this._listeners = [
addEventListener(this.document, 'click', event => this._onClick(event as MouseEvent), true), addEventListener(this.document, 'click', event => this._onClick(event as MouseEvent), true),
addEventListener(this.document, 'auxclick', event => this._onClick(event as MouseEvent), true), addEventListener(this.document, 'auxclick', event => this._onClick(event as MouseEvent), true),
addEventListener(this.document, 'dblclick', event => this._onDblClick(event as MouseEvent), true),
addEventListener(this.document, 'contextmenu', event => this._onContextMenu(event as MouseEvent), true), addEventListener(this.document, 'contextmenu', event => this._onContextMenu(event as MouseEvent), true),
addEventListener(this.document, 'dragstart', event => this._onDragStart(event as DragEvent), true), addEventListener(this.document, 'dragstart', event => this._onDragStart(event as DragEvent), true),
addEventListener(this.document, 'input', event => this._onInput(event), true), addEventListener(this.document, 'input', event => this._onInput(event), true),
@ -985,7 +1036,14 @@ export class Recorder {
addEventListener(this.document, 'focus', event => this._onFocus(event), true), addEventListener(this.document, 'focus', event => this._onFocus(event), true),
addEventListener(this.document, 'scroll', event => this._onScroll(event), true), addEventListener(this.document, 'scroll', event => this._onScroll(event), true),
]; ];
this.highlight.install(); this.highlight.install();
// some frameworks erase the DOM on hydration, this ensures it's reattached
const recreationInterval = setInterval(() => {
this.highlight.install();
}, 500);
this._listeners.push(() => clearInterval(recreationInterval));
this.overlay?.install(); this.overlay?.install();
this.document.adoptedStyleSheets.push(this._stylesheet); this.document.adoptedStyleSheets.push(this._stylesheet);
} }
@ -1043,6 +1101,16 @@ export class Recorder {
this._currentTool.onClick?.(event); this._currentTool.onClick?.(event);
} }
private _onDblClick(event: MouseEvent) {
if (!event.isTrusted)
return;
if (this.overlay?.onDblClick(event))
return;
if (this._ignoreOverlayEvent(event))
return;
this._currentTool.onDblClick?.(event);
}
private _onContextMenu(event: MouseEvent) { private _onContextMenu(event: MouseEvent) {
if (!event.isTrusted) if (!event.isTrusted)
return; return;

View file

@ -54,6 +54,7 @@ export interface PageDelegate {
reload(): Promise<void>; reload(): Promise<void>;
goBack(): Promise<boolean>; goBack(): Promise<boolean>;
goForward(): Promise<boolean>; goForward(): Promise<boolean>;
forceGarbageCollection(): Promise<void>;
addInitScript(initScript: InitScript): Promise<void>; addInitScript(initScript: InitScript): Promise<void>;
removeNonInternalInitScripts(): Promise<void>; removeNonInternalInitScripts(): Promise<void>;
closePage(runBeforeUnload: boolean): Promise<void>; closePage(runBeforeUnload: boolean): Promise<void>;
@ -430,6 +431,10 @@ export class Page extends SdkObject {
}), this._timeoutSettings.navigationTimeout(options)); }), this._timeoutSettings.navigationTimeout(options));
} }
forceGarbageCollection(): Promise<void> {
return this._delegate.forceGarbageCollection();
}
registerLocatorHandler(selector: string, noWaitAfter: boolean | undefined) { registerLocatorHandler(selector: string, noWaitAfter: boolean | undefined) {
const uid = ++this._lastLocatorHandlerUid; const uid = ++this._lastLocatorHandlerUid;
this._locatorHandlers.set(uid, { selector, noWaitAfter }); this._locatorHandlers.set(uid, { selector, noWaitAfter });

View file

@ -26,14 +26,12 @@ import { type Language } from './codegen/types';
import { Debugger } from './debugger'; import { Debugger } from './debugger';
import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation'; import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation';
import { ContextRecorder, generateFrameSelector } from './recorder/contextRecorder'; import { ContextRecorder, generateFrameSelector } from './recorder/contextRecorder';
import { type IRecorderApp } from './recorder/recorderApp'; import type { IRecorderAppFactory, IRecorderApp, IRecorder } from './recorder/recorderFrontend';
import { buildFullSelector, metadataToCallLog } from './recorder/recorderUtils'; import { buildFullSelector, metadataToCallLog } from './recorder/recorderUtils';
const recorderSymbol = Symbol('recorderSymbol'); const recorderSymbol = Symbol('recorderSymbol');
export type RecorderAppFactory = (recorder: Recorder) => Promise<IRecorderApp>; export class Recorder implements InstrumentationListener, IRecorder {
export class Recorder implements InstrumentationListener {
private _context: BrowserContext; private _context: BrowserContext;
private _mode: Mode; private _mode: Mode;
private _highlightedSelector = ''; private _highlightedSelector = '';
@ -47,14 +45,14 @@ export class Recorder implements InstrumentationListener {
private _omitCallTracking = false; private _omitCallTracking = false;
private _currentLanguage: Language; private _currentLanguage: Language;
static showInspector(context: BrowserContext, recorderAppFactory: RecorderAppFactory) { static showInspector(context: BrowserContext, recorderAppFactory: IRecorderAppFactory) {
const params: channels.BrowserContextRecorderSupplementEnableParams = {}; const params: channels.BrowserContextRecorderSupplementEnableParams = {};
if (isUnderTest()) if (isUnderTest())
params.language = process.env.TEST_INSPECTOR_LANGUAGE; params.language = process.env.TEST_INSPECTOR_LANGUAGE;
Recorder.show(context, recorderAppFactory, params).catch(() => {}); Recorder.show(context, recorderAppFactory, params).catch(() => {});
} }
static show(context: BrowserContext, recorderAppFactory: RecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<Recorder> { static show(context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<Recorder> {
let recorderPromise = (context as any)[recorderSymbol] as Promise<Recorder>; let recorderPromise = (context as any)[recorderSymbol] as Promise<Recorder>;
if (!recorderPromise) { if (!recorderPromise) {
recorderPromise = Recorder._create(context, recorderAppFactory, params); recorderPromise = Recorder._create(context, recorderAppFactory, params);
@ -63,7 +61,7 @@ export class Recorder implements InstrumentationListener {
return recorderPromise; return recorderPromise;
} }
private static async _create(context: BrowserContext, recorderAppFactory: RecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<Recorder> { private static async _create(context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<Recorder> {
const recorder = new Recorder(context, params); const recorder = new Recorder(context, params);
const recorderApp = await recorderAppFactory(recorder); const recorderApp = await recorderAppFactory(recorder);
await recorder._install(recorderApp); await recorder._install(recorderApp);
@ -134,10 +132,9 @@ export class Recorder implements InstrumentationListener {
this._context.instrumentation.removeListener(this); this._context.instrumentation.removeListener(this);
this._recorderApp?.close().catch(() => {}); this._recorderApp?.close().catch(() => {});
}); });
this._contextRecorder.on(ContextRecorder.Events.Change, (data: { sources: Source[], primaryFileName: string }) => { this._contextRecorder.on(ContextRecorder.Events.Change, (data: { sources: Source[] }) => {
this._recorderSources = data.sources; this._recorderSources = data.sources;
this._pushAllSources(); this._pushAllSources();
this._recorderApp?.setFileIfNeeded(data.primaryFileName);
}); });
await this._context.exposeBinding('__pw_recorderState', false, source => { await this._context.exposeBinding('__pw_recorderState', false, source => {
@ -296,7 +293,7 @@ export class Recorder implements InstrumentationListener {
} }
this._pushAllSources(); this._pushAllSources();
if (fileToSelect) if (fileToSelect)
this._recorderApp?.setFileIfNeeded(fileToSelect); this._recorderApp?.setFile(fileToSelect);
} }
private _pushAllSources() { private _pushAllSources() {

View file

@ -10,3 +10,6 @@
../../utils/** ../../utils/**
../../utilsBundle.ts ../../utilsBundle.ts
../../zipBundle.ts ../../zipBundle.ts
[recorderInTraceViewer.ts]
../trace/viewer/traceViewer.ts

View file

@ -18,7 +18,7 @@ import type * as channels from '@protocol/channels';
import type { Source } from '@recorder/recorderTypes'; import type { Source } from '@recorder/recorderTypes';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import * as recorderSource from '../../generated/recorderSource'; import * as recorderSource from '../../generated/recorderSource';
import { eventsHelper, isUnderTest, monotonicTime, quoteCSSAttributeValue, type RegisteredListener } from '../../utils'; import { eventsHelper, monotonicTime, quoteCSSAttributeValue, type RegisteredListener } from '../../utils';
import { raceAgainstDeadline } from '../../utils/timeoutRunner'; import { raceAgainstDeadline } from '../../utils/timeoutRunner';
import { BrowserContext } from '../browserContext'; import { BrowserContext } from '../browserContext';
import type { ActionInContext, FrameDescription, LanguageGeneratorOptions, Language, LanguageGenerator } from '../codegen/types'; import type { ActionInContext, FrameDescription, LanguageGeneratorOptions, Language, LanguageGenerator } from '../codegen/types';
@ -27,7 +27,6 @@ import type { Dialog } from '../dialog';
import { Frame } from '../frames'; import { Frame } from '../frames';
import { Page } from '../page'; import { Page } from '../page';
import type * as actions from './recorderActions'; import type * as actions from './recorderActions';
import { performAction } from './recorderRunner';
import { ThrottledFile } from './throttledFile'; import { ThrottledFile } from './throttledFile';
import { RecorderCollection } from './recorderCollection'; import { RecorderCollection } from './recorderCollection';
import { generateCode } from '../codegen/language'; import { generateCode } from '../codegen/language';
@ -48,7 +47,6 @@ export class ContextRecorder extends EventEmitter {
private _lastPopupOrdinal = 0; private _lastPopupOrdinal = 0;
private _lastDialogOrdinal = -1; private _lastDialogOrdinal = -1;
private _lastDownloadOrdinal = -1; private _lastDownloadOrdinal = -1;
private _timers = new Set<NodeJS.Timeout>();
private _context: BrowserContext; private _context: BrowserContext;
private _params: channels.BrowserContextRecorderSupplementEnableParams; private _params: channels.BrowserContextRecorderSupplementEnableParams;
private _delegate: ContextRecorderDelegate; private _delegate: ContextRecorderDelegate;
@ -69,13 +67,13 @@ export class ContextRecorder extends EventEmitter {
// Make a copy of options to modify them later. // Make a copy of options to modify them later.
const languageGeneratorOptions: LanguageGeneratorOptions = { const languageGeneratorOptions: LanguageGeneratorOptions = {
browserName: context._browser.options.name, browserName: context._browser.options.name,
launchOptions: { headless: false, ...params.launchOptions }, launchOptions: { headless: false, ...params.launchOptions, tracesDir: undefined },
contextOptions: { ...params.contextOptions }, contextOptions: { ...params.contextOptions },
deviceName: params.device, deviceName: params.device,
saveStorage: params.saveStorage, saveStorage: params.saveStorage,
}; };
const collection = new RecorderCollection(params.mode === 'recording'); const collection = new RecorderCollection(this._pageAliases, params.mode === 'recording');
collection.on('change', () => { collection.on('change', () => {
this._recorderSources = []; this._recorderSources = [];
for (const languageGenerator of this._orderedLanguages) { for (const languageGenerator of this._orderedLanguages) {
@ -97,10 +95,7 @@ export class ContextRecorder extends EventEmitter {
if (languageGenerator === this._orderedLanguages[0]) if (languageGenerator === this._orderedLanguages[0])
this._throttledOutputFile?.setContent(source.text); this._throttledOutputFile?.setContent(source.text);
} }
this.emit(ContextRecorder.Events.Change, { this.emit(ContextRecorder.Events.Change, { sources: this._recorderSources });
sources: this._recorderSources,
primaryFileName: this._orderedLanguages[0].id
});
}); });
context.on(BrowserContext.Events.BeforeClose, () => { context.on(BrowserContext.Events.BeforeClose, () => {
this._throttledOutputFile?.flush(); this._throttledOutputFile?.flush();
@ -153,9 +148,6 @@ export class ContextRecorder extends EventEmitter {
} }
dispose() { dispose() {
for (const timer of this._timers)
clearTimeout(timer);
this._timers.clear();
eventsHelper.removeEventListeners(this._listeners); eventsHelper.removeEventListeners(this._listeners);
} }
@ -163,13 +155,13 @@ export class ContextRecorder extends EventEmitter {
// First page is called page, others are called popup1, popup2, etc. // First page is called page, others are called popup1, popup2, etc.
const frame = page.mainFrame(); const frame = page.mainFrame();
page.on('close', () => { page.on('close', () => {
this._collection.addAction({ this._collection.addRecordedAction({
frame: this._describeMainFrame(page), frame: this._describeMainFrame(page),
committed: true,
action: { action: {
name: 'closePage', name: 'closePage',
signals: [], signals: [],
} },
timestamp: monotonicTime()
}); });
this._pageAliases.delete(page); this._pageAliases.delete(page);
}); });
@ -185,14 +177,14 @@ export class ContextRecorder extends EventEmitter {
if (page.opener()) { if (page.opener()) {
this._onPopup(page.opener()!, page); this._onPopup(page.opener()!, page);
} else { } else {
this._collection.addAction({ this._collection.addRecordedAction({
frame: this._describeMainFrame(page), frame: this._describeMainFrame(page),
committed: true,
action: { action: {
name: 'openPage', name: 'openPage',
url: page.mainFrame().url(), url: page.mainFrame().url(),
signals: [], signals: [],
} },
timestamp: monotonicTime()
}); });
} }
} }
@ -223,53 +215,24 @@ export class ContextRecorder extends EventEmitter {
return this._params.testIdAttributeName || this._context.selectors().testIdAttributeName() || 'data-testid'; return this._params.testIdAttributeName || this._context.selectors().testIdAttributeName() || 'data-testid';
} }
private async _performAction(frame: Frame, action: actions.PerformOnRecordAction) { private async _createActionInContext(frame: Frame, action: actions.Action): Promise<ActionInContext> {
// Commit last action so that no further signals are added to it.
this._collection.commitLastAction();
const frameDescription = await this._describeFrame(frame); const frameDescription = await this._describeFrame(frame);
const actionInContext: ActionInContext = { const actionInContext: ActionInContext = {
frame: frameDescription, frame: frameDescription,
action, action,
description: undefined, description: undefined,
timestamp: monotonicTime()
}; };
await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext); await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext);
return actionInContext;
}
this._collection.willPerformAction(actionInContext); private async _performAction(frame: Frame, action: actions.PerformOnRecordAction) {
const success = await performAction(this._pageAliases, actionInContext); await this._collection.performAction(await this._createActionInContext(frame, action));
if (success) {
this._collection.didPerformAction(actionInContext);
this._setCommittedAfterTimeout(actionInContext);
} else {
this._collection.performedActionFailed(actionInContext);
}
} }
private async _recordAction(frame: Frame, action: actions.Action) { private async _recordAction(frame: Frame, action: actions.Action) {
// Commit last action so that no further signals are added to it. this._collection.addRecordedAction(await this._createActionInContext(frame, action));
this._collection.commitLastAction();
const frameDescription = await this._describeFrame(frame);
const actionInContext: ActionInContext = {
frame: frameDescription,
action,
description: undefined,
};
await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext);
this._setCommittedAfterTimeout(actionInContext);
this._collection.addAction(actionInContext);
}
private _setCommittedAfterTimeout(actionInContext: ActionInContext) {
const timer = setTimeout(() => {
// Commit the action after 5 seconds so that no further signals are added to it.
actionInContext.committed = true;
this._timers.delete(timer);
}, isUnderTest() ? 500 : 5000);
this._timers.add(timer);
} }
private _onFrameNavigated(frame: Frame, page: Page) { private _onFrameNavigated(frame: Frame, page: Page) {

View file

@ -24,13 +24,13 @@ import type { CallLog, EventData, Mode, Source } from '@recorder/recorderTypes';
import { isUnderTest } from '../../utils'; import { isUnderTest } from '../../utils';
import { mime } from '../../utilsBundle'; import { mime } from '../../utilsBundle';
import { syncLocalStorageWithSettings } from '../launchApp'; import { syncLocalStorageWithSettings } from '../launchApp';
import type { Recorder, RecorderAppFactory } from '../recorder';
import type { BrowserContext } from '../browserContext'; import type { BrowserContext } from '../browserContext';
import { launchApp } from '../launchApp'; import { launchApp } from '../launchApp';
import type { IRecorder, IRecorderApp, IRecorderAppFactory } from './recorderFrontend';
declare global { declare global {
interface Window { interface Window {
playwrightSetFileIfNeeded: (file: string) => void; playwrightSetFile: (file: string) => void;
playwrightSetMode: (mode: Mode) => void; playwrightSetMode: (mode: Mode) => void;
playwrightSetPaused: (paused: boolean) => void; playwrightSetPaused: (paused: boolean) => void;
playwrightSetSources: (sources: Source[]) => void; playwrightSetSources: (sources: Source[]) => void;
@ -42,21 +42,11 @@ declare global {
} }
} }
export interface IRecorderApp extends EventEmitter {
close(): Promise<void>;
setPaused(paused: boolean): Promise<void>;
setMode(mode: Mode): Promise<void>;
setFileIfNeeded(file: string): Promise<void>;
setSelector(selector: string, userGesture?: boolean): Promise<void>;
updateCallLogs(callLogs: CallLog[]): Promise<void>;
setSources(sources: Source[]): Promise<void>;
}
export class EmptyRecorderApp extends EventEmitter implements IRecorderApp { export class EmptyRecorderApp extends EventEmitter implements IRecorderApp {
async close(): Promise<void> {} async close(): Promise<void> {}
async setPaused(paused: boolean): Promise<void> {} async setPaused(paused: boolean): Promise<void> {}
async setMode(mode: Mode): Promise<void> {} async setMode(mode: Mode): Promise<void> {}
async setFileIfNeeded(file: string): Promise<void> {} async setFile(file: string): Promise<void> {}
async setSelector(selector: string, userGesture?: boolean): Promise<void> {} async setSelector(selector: string, userGesture?: boolean): Promise<void> {}
async updateCallLogs(callLogs: CallLog[]): Promise<void> {} async updateCallLogs(callLogs: CallLog[]): Promise<void> {}
async setSources(sources: Source[]): Promise<void> {} async setSources(sources: Source[]): Promise<void> {}
@ -65,9 +55,9 @@ export class EmptyRecorderApp extends EventEmitter implements IRecorderApp {
export class RecorderApp extends EventEmitter implements IRecorderApp { export class RecorderApp extends EventEmitter implements IRecorderApp {
private _page: Page; private _page: Page;
readonly wsEndpoint: string | undefined; readonly wsEndpoint: string | undefined;
private _recorder: Recorder; private _recorder: IRecorder;
constructor(recorder: Recorder, page: Page, wsEndpoint: string | undefined) { constructor(recorder: IRecorder, page: Page, wsEndpoint: string | undefined) {
super(); super();
this.setMaxListeners(0); this.setMaxListeners(0);
this._recorder = recorder; this._recorder = recorder;
@ -113,7 +103,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
await mainFrame.goto(serverSideCallMetadata(), 'https://playwright/index.html'); await mainFrame.goto(serverSideCallMetadata(), 'https://playwright/index.html');
} }
static factory(context: BrowserContext): RecorderAppFactory { static factory(context: BrowserContext): IRecorderAppFactory {
return async recorder => { return async recorder => {
if (process.env.PW_CODEGEN_NO_INSPECTOR) if (process.env.PW_CODEGEN_NO_INSPECTOR)
return new EmptyRecorderApp(); return new EmptyRecorderApp();
@ -121,7 +111,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
}; };
} }
private static async _open(recorder: Recorder, inspectedContext: BrowserContext): Promise<IRecorderApp> { private static async _open(recorder: IRecorder, inspectedContext: BrowserContext): Promise<IRecorderApp> {
const sdkLanguage = inspectedContext.attribution.playwright.options.sdkLanguage; const sdkLanguage = inspectedContext.attribution.playwright.options.sdkLanguage;
const headed = !!inspectedContext._browser.options.headful; const headed = !!inspectedContext._browser.options.headful;
const recorderPlaywright = (require('../playwright').createPlaywright as typeof import('../playwright').createPlaywright)({ sdkLanguage: 'javascript', isInternalPlaywright: true }); const recorderPlaywright = (require('../playwright').createPlaywright as typeof import('../playwright').createPlaywright)({ sdkLanguage: 'javascript', isInternalPlaywright: true });
@ -154,9 +144,9 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
}).toString(), { isFunction: true }, mode).catch(() => {}); }).toString(), { isFunction: true }, mode).catch(() => {});
} }
async setFileIfNeeded(file: string): Promise<void> { async setFile(file: string): Promise<void> {
await this._page.mainFrame().evaluateExpression(((file: string) => { await this._page.mainFrame().evaluateExpression(((file: string) => {
window.playwrightSetFileIfNeeded(file); window.playwrightSetFile(file);
}).toString(), { isFunction: true }, file).catch(() => {}); }).toString(), { isFunction: true }, file).catch(() => {});
} }

View file

@ -16,24 +16,29 @@
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import type { Frame } from '../frames'; import type { Frame } from '../frames';
import type { Page } from '../page';
import type { Signal } from './recorderActions'; import type { Signal } from './recorderActions';
import type { ActionInContext } from '../codegen/types'; import type { ActionInContext } from '../codegen/types';
import { monotonicTime } from '../../utils/time';
import { callMetadataForAction } from './recorderUtils';
import { serializeError } from '../errors';
import { performAction } from './recorderRunner';
import type { CallMetadata } from '@protocol/callMetadata';
import { isUnderTest } from '../../utils/debug';
export class RecorderCollection extends EventEmitter { export class RecorderCollection extends EventEmitter {
private _currentAction: ActionInContext | null = null;
private _lastAction: ActionInContext | null = null;
private _actions: ActionInContext[] = []; private _actions: ActionInContext[] = [];
private _enabled: boolean; private _enabled: boolean;
private _pageAliases: Map<Page, string>;
constructor(enabled: boolean) { constructor(pageAliases: Map<Page, string>, enabled: boolean) {
super(); super();
this._enabled = enabled; this._enabled = enabled;
this._pageAliases = pageAliases;
this.restart(); this.restart();
} }
restart() { restart() {
this._currentAction = null;
this._lastAction = null;
this._actions = []; this._actions = [];
this.emit('change'); this.emit('change');
} }
@ -46,106 +51,73 @@ export class RecorderCollection extends EventEmitter {
this._enabled = enabled; this._enabled = enabled;
} }
addAction(action: ActionInContext) { async performAction(actionInContext: ActionInContext) {
if (!this._enabled) await this._addAction(actionInContext, async callMetadata => {
return; await performAction(callMetadata, this._pageAliases, actionInContext);
this.willPerformAction(action); });
this.didPerformAction(action);
} }
willPerformAction(action: ActionInContext) { addRecordedAction(actionInContext: ActionInContext) {
if (!this._enabled) if (['openPage', 'closePage'].includes(actionInContext.action.name)) {
this._actions.push(actionInContext);
this.emit('change');
return; return;
this._currentAction = action;
}
performedActionFailed(action: ActionInContext) {
if (!this._enabled)
return;
if (this._currentAction === action)
this._currentAction = null;
}
didPerformAction(actionInContext: ActionInContext) {
if (!this._enabled)
return;
const action = actionInContext.action;
let eraseLastAction = false;
if (this._lastAction && this._lastAction.frame.pageAlias === actionInContext.frame.pageAlias) {
const lastAction = this._lastAction.action;
// We augment last action based on the type.
if (this._lastAction && action.name === 'fill' && lastAction.name === 'fill') {
if (action.selector === lastAction.selector)
eraseLastAction = true;
}
if (lastAction && action.name === 'click' && lastAction.name === 'click') {
if (action.selector === lastAction.selector && action.clickCount > lastAction.clickCount)
eraseLastAction = true;
}
if (lastAction && action.name === 'navigate' && lastAction.name === 'navigate') {
if (action.url === lastAction.url) {
// Already at a target URL.
this._currentAction = null;
return;
}
}
// Check and uncheck erase click.
if (lastAction && (action.name === 'check' || action.name === 'uncheck') && lastAction.name === 'click') {
if (action.selector === lastAction.selector)
eraseLastAction = true;
}
} }
this._addAction(actionInContext).catch(() => {});
}
this._lastAction = actionInContext; private async _addAction(actionInContext: ActionInContext, callback?: (callMetadata: CallMetadata) => Promise<void>) {
this._currentAction = null; if (!this._enabled)
if (eraseLastAction) return;
this._actions.pop();
const { callMetadata, mainFrame } = callMetadataForAction(this._pageAliases, actionInContext);
await mainFrame.instrumentation.onBeforeCall(mainFrame, callMetadata);
this._actions.push(actionInContext); this._actions.push(actionInContext);
this.emit('change'); this.emit('change');
} const error = await callback?.(callMetadata).catch((e: Error) => e);
callMetadata.endTime = monotonicTime();
commitLastAction() { callMetadata.error = error ? serializeError(error) : undefined;
if (!this._enabled) await mainFrame.instrumentation.onAfterCall(mainFrame, callMetadata);
return;
const action = this._lastAction;
if (action)
action.committed = true;
} }
signal(pageAlias: string, frame: Frame, signal: Signal) { signal(pageAlias: string, frame: Frame, signal: Signal) {
if (!this._enabled) if (!this._enabled)
return; return;
// Signal either arrives while action is being performed or shortly after. if (signal.name === 'navigation' && frame._page.mainFrame() === frame) {
if (this._currentAction) { const timestamp = monotonicTime();
this._currentAction.action.signals.push(signal); const lastAction = this._actions[this._actions.length - 1];
const signalThreshold = isUnderTest() ? 500 : 5000;
let generateGoto = false;
if (!lastAction)
generateGoto = true;
else if (lastAction.action.name !== 'click' && lastAction.action.name !== 'press')
generateGoto = true;
else if (timestamp - lastAction.timestamp > signalThreshold)
generateGoto = true;
if (generateGoto) {
this.addRecordedAction({
frame: {
pageAlias,
framePath: [],
},
action: {
name: 'navigate',
url: frame.url(),
signals: [],
},
timestamp
});
}
return; return;
} }
if (this._lastAction && (!this._lastAction.committed || signal.name !== 'navigation')) { if (this._actions.length) {
const signals = this._lastAction.action.signals; this._actions[this._actions.length - 1].action.signals.push(signal);
if (signal.name === 'navigation' && signals.length && signals[signals.length - 1].name === 'download')
return;
if (signal.name === 'download' && signals.length && signals[signals.length - 1].name === 'navigation')
signals.length = signals.length - 1;
this._lastAction.action.signals.push(signal);
this.emit('change'); this.emit('change');
return; return;
} }
if (signal.name === 'navigation' && frame._page.mainFrame() === frame) {
this.addAction({
frame: {
pageAlias,
framePath: [],
},
committed: true,
action: {
name: 'navigate',
url: frame.url(),
signals: [],
},
});
}
} }
} }

View file

@ -0,0 +1,35 @@
/**
* 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 type { CallLog, Mode, Source } from '@recorder/recorderTypes';
import type { EventEmitter } from 'events';
export interface IRecorder {
setMode(mode: Mode): void;
mode(): Mode;
}
export interface IRecorderApp extends EventEmitter {
close(): Promise<void>;
setPaused(paused: boolean): Promise<void>;
setMode(mode: Mode): Promise<void>;
setFile(file: string): Promise<void>;
setSelector(selector: string, userGesture?: boolean): Promise<void>;
updateCallLogs(callLogs: CallLog[]): Promise<void>;
setSources(sources: Source[]): Promise<void>;
}
export type IRecorderAppFactory = (recorder: IRecorder) => Promise<IRecorderApp>;

View file

@ -0,0 +1,94 @@
/**
* 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 path from 'path';
import type { CallLog, Mode, Source } from '@recorder/recorderTypes';
import { EventEmitter } from 'events';
import type { IRecorder, IRecorderApp, IRecorderAppFactory } from './recorderFrontend';
import { installRootRedirect, openTraceViewerApp, startTraceViewerServer } from '../trace/viewer/traceViewer';
import type { TraceViewerServerOptions } from '../trace/viewer/traceViewer';
import type { BrowserContext } from '../browserContext';
import { gracefullyProcessExitDoNotHang } from '../../utils/processLauncher';
import type { Transport } from '../../utils/httpServer';
export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp {
private _recorder: IRecorder;
private _transport: Transport;
static factory(context: BrowserContext): IRecorderAppFactory {
return async (recorder: IRecorder) => {
const transport = new RecorderTransport();
const trace = path.join(context._browser.options.tracesDir, 'trace');
await openApp(trace, { transport });
return new RecorderInTraceViewer(context, recorder, transport);
};
}
constructor(context: BrowserContext, recorder: IRecorder, transport: Transport) {
super();
this._recorder = recorder;
this._transport = transport;
}
async close(): Promise<void> {
this._transport.sendEvent?.('close', {});
}
async setPaused(paused: boolean): Promise<void> {
this._transport.sendEvent?.('setPaused', { paused });
}
async setMode(mode: Mode): Promise<void> {
this._transport.sendEvent?.('setMode', { mode });
}
async setFile(file: string): Promise<void> {
this._transport.sendEvent?.('setFileIfNeeded', { file });
}
async setSelector(selector: string, userGesture?: boolean): Promise<void> {
this._transport.sendEvent?.('setSelector', { selector, userGesture });
}
async updateCallLogs(callLogs: CallLog[]): Promise<void> {
this._transport.sendEvent?.('updateCallLogs', { callLogs });
}
async setSources(sources: Source[]): Promise<void> {
this._transport.sendEvent?.('setSources', { sources });
}
}
async function openApp(trace: string, options?: TraceViewerServerOptions & { headless?: boolean }) {
const server = await startTraceViewerServer(options);
await installRootRedirect(server, [trace], { ...options, webApp: 'recorder.html' });
const page = await openTraceViewerApp(server.urlPrefix('precise'), 'chromium', options);
page.on('close', () => gracefullyProcessExitDoNotHang(0));
}
class RecorderTransport implements Transport {
constructor() {
}
async dispatch(method: string, params: any) {
}
onclose() {
}
sendEvent?: (method: string, params: any) => void;
close?: () => void;
}

View file

@ -14,115 +14,130 @@
* limitations under the License. * limitations under the License.
*/ */
import { createGuid, monotonicTime, serializeExpectedTextValues } from '../../utils'; import { serializeExpectedTextValues } from '../../utils';
import { toClickOptions, toKeyboardModifiers } from '../codegen/language'; import { toKeyboardModifiers } from '../codegen/language';
import type { ActionInContext } from '../codegen/types'; import type { ActionInContext } from '../codegen/types';
import type { Frame } from '../frames';
import type { CallMetadata } from '../instrumentation'; import type { CallMetadata } from '../instrumentation';
import type { Page } from '../page'; import type { Page } from '../page';
import { buildFullSelector } from './recorderUtils'; import type * as actions from './recorderActions';
import type * as types from '../types';
import { buildFullSelector, mainFrameForAction } from './recorderUtils';
async function innerPerformAction(mainFrame: Frame, action: string, params: any, cb: (callMetadata: CallMetadata) => Promise<any>): Promise<boolean> { export async function performAction(callMetadata: CallMetadata, pageAliases: Map<Page, string>, actionInContext: ActionInContext) {
const callMetadata: CallMetadata = { const mainFrame = mainFrameForAction(pageAliases, actionInContext);
id: `call@${createGuid()}`,
apiName: 'frame.' + action,
objectId: mainFrame.guid,
pageId: mainFrame._page.guid,
frameId: mainFrame.guid,
startTime: monotonicTime(),
endTime: 0,
type: 'Frame',
method: action,
params,
log: [],
};
try {
await mainFrame.instrumentation.onBeforeCall(mainFrame, callMetadata);
await cb(callMetadata);
} catch (e) {
callMetadata.endTime = monotonicTime();
await mainFrame.instrumentation.onAfterCall(mainFrame, callMetadata);
return false;
}
callMetadata.endTime = monotonicTime();
await mainFrame.instrumentation.onAfterCall(mainFrame, callMetadata);
return true;
}
export async function performAction(pageAliases: Map<Page, string>, actionInContext: ActionInContext): Promise<boolean> {
const pageAlias = actionInContext.frame.pageAlias;
const page = [...pageAliases.entries()].find(([, alias]) => pageAlias === alias)?.[0];
if (!page)
throw new Error('Internal error: page not found');
const mainFrame = page.mainFrame();
const { action } = actionInContext; const { action } = actionInContext;
const kActionTimeout = 5000; const kActionTimeout = 5000;
if (action.name === 'navigate') if (action.name === 'navigate') {
return await innerPerformAction(mainFrame, 'goto', { url: action.url }, callMetadata => mainFrame.goto(callMetadata, action.url, { timeout: kActionTimeout })); await mainFrame.goto(callMetadata, action.url, { timeout: kActionTimeout });
return;
}
if (action.name === 'openPage') if (action.name === 'openPage')
throw Error('Not reached'); throw Error('Not reached');
if (action.name === 'closePage')
return await innerPerformAction(mainFrame, 'close', {}, callMetadata => mainFrame._page.close(callMetadata)); if (action.name === 'closePage') {
await mainFrame._page.close(callMetadata);
return;
}
const selector = buildFullSelector(actionInContext.frame.framePath, action.selector); const selector = buildFullSelector(actionInContext.frame.framePath, action.selector);
if (action.name === 'click') { if (action.name === 'click') {
const options = toClickOptions(action); const options = toClickOptions(action);
return await innerPerformAction(mainFrame, 'click', { selector }, callMetadata => mainFrame.click(callMetadata, selector, { ...options, timeout: kActionTimeout, strict: true })); await mainFrame.click(callMetadata, selector, { ...options, timeout: kActionTimeout, strict: true });
return;
} }
if (action.name === 'press') { if (action.name === 'press') {
const modifiers = toKeyboardModifiers(action.modifiers); const modifiers = toKeyboardModifiers(action.modifiers);
const shortcut = [...modifiers, action.key].join('+'); const shortcut = [...modifiers, action.key].join('+');
return await innerPerformAction(mainFrame, 'press', { selector, key: shortcut }, callMetadata => mainFrame.press(callMetadata, selector, shortcut, { timeout: kActionTimeout, strict: true })); await mainFrame.press(callMetadata, selector, shortcut, { timeout: kActionTimeout, strict: true });
return;
} }
if (action.name === 'fill')
return await innerPerformAction(mainFrame, 'fill', { selector, text: action.text }, callMetadata => mainFrame.fill(callMetadata, selector, action.text, { timeout: kActionTimeout, strict: true })); if (action.name === 'fill') {
if (action.name === 'setInputFiles') await mainFrame.fill(callMetadata, selector, action.text, { timeout: kActionTimeout, strict: true });
return await innerPerformAction(mainFrame, 'setInputFiles', { selector, files: action.files }, callMetadata => mainFrame.setInputFiles(callMetadata, selector, { selector, payloads: [], timeout: kActionTimeout, strict: true })); return;
if (action.name === 'check') }
return await innerPerformAction(mainFrame, 'check', { selector }, callMetadata => mainFrame.check(callMetadata, selector, { timeout: kActionTimeout, strict: true }));
if (action.name === 'uncheck') if (action.name === 'setInputFiles') {
return await innerPerformAction(mainFrame, 'uncheck', { selector }, callMetadata => mainFrame.uncheck(callMetadata, selector, { timeout: kActionTimeout, strict: true })); await mainFrame.setInputFiles(callMetadata, selector, { selector, payloads: [], timeout: kActionTimeout, strict: true });
return;
}
if (action.name === 'check') {
await mainFrame.check(callMetadata, selector, { timeout: kActionTimeout, strict: true });
return;
}
if (action.name === 'uncheck') {
await mainFrame.uncheck(callMetadata, selector, { timeout: kActionTimeout, strict: true });
return;
}
if (action.name === 'select') { if (action.name === 'select') {
const values = action.options.map(value => ({ value })); const values = action.options.map(value => ({ value }));
return await innerPerformAction(mainFrame, 'selectOption', { selector, values }, callMetadata => mainFrame.selectOption(callMetadata, selector, [], values, { timeout: kActionTimeout, strict: true })); await mainFrame.selectOption(callMetadata, selector, [], values, { timeout: kActionTimeout, strict: true });
return;
} }
if (action.name === 'assertChecked') { if (action.name === 'assertChecked') {
return await innerPerformAction(mainFrame, 'expect', { selector }, callMetadata => mainFrame.expect(callMetadata, selector, { await mainFrame.expect(callMetadata, selector, {
selector, selector,
expression: 'to.be.checked', expression: 'to.be.checked',
isNot: !action.checked, isNot: !action.checked,
timeout: kActionTimeout, timeout: kActionTimeout,
})); });
return;
} }
if (action.name === 'assertText') { if (action.name === 'assertText') {
return await innerPerformAction(mainFrame, 'expect', { selector }, callMetadata => mainFrame.expect(callMetadata, selector, { await mainFrame.expect(callMetadata, selector, {
selector, selector,
expression: 'to.have.text', expression: 'to.have.text',
expectedText: serializeExpectedTextValues([action.text], { matchSubstring: true, normalizeWhiteSpace: true }), expectedText: serializeExpectedTextValues([action.text], { matchSubstring: true, normalizeWhiteSpace: true }),
isNot: false, isNot: false,
timeout: kActionTimeout, timeout: kActionTimeout,
})); });
return;
} }
if (action.name === 'assertValue') { if (action.name === 'assertValue') {
return await innerPerformAction(mainFrame, 'expect', { selector }, callMetadata => mainFrame.expect(callMetadata, selector, { await mainFrame.expect(callMetadata, selector, {
selector, selector,
expression: 'to.have.value', expression: 'to.have.value',
expectedValue: action.value, expectedValue: action.value,
isNot: false, isNot: false,
timeout: kActionTimeout, timeout: kActionTimeout,
})); });
return;
} }
if (action.name === 'assertVisible') { if (action.name === 'assertVisible') {
return await innerPerformAction(mainFrame, 'expect', { selector }, callMetadata => mainFrame.expect(callMetadata, selector, { await mainFrame.expect(callMetadata, selector, {
selector, selector,
expression: 'to.be.visible', expression: 'to.be.visible',
isNot: false, isNot: false,
timeout: kActionTimeout, timeout: kActionTimeout,
})); });
return;
} }
throw new Error('Internal error: unexpected action ' + (action as any).name); throw new Error('Internal error: unexpected action ' + (action as any).name);
} }
export function toClickOptions(action: actions.ClickAction): types.MouseClickOptions {
const modifiers = toKeyboardModifiers(action.modifiers);
const options: types.MouseClickOptions = {};
if (action.button !== 'left')
options.button = action.button;
if (modifiers.length)
options.modifiers = modifiers;
if (action.clickCount > 1)
options.clickCount = action.clickCount;
if (action.position)
options.position = action.position;
return options;
}

View file

@ -20,6 +20,9 @@ import type { Page } from '../page';
import type { ActionInContext } from '../codegen/types'; import type { ActionInContext } from '../codegen/types';
import type { Frame } from '../frames'; import type { Frame } from '../frames';
import type * as actions from './recorderActions'; import type * as actions from './recorderActions';
import { toKeyboardModifiers } from '../codegen/language';
import { serializeExpectedTextValues } from '../../utils/expectUtils';
import { createGuid, monotonicTime } from '../../utils';
export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus): CallLog { export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus): CallLog {
let title = metadata.apiName || metadata.method; let title = metadata.apiName || metadata.method;
@ -57,7 +60,7 @@ export function mainFrameForAction(pageAliases: Map<Page, string>, actionInConte
const pageAlias = actionInContext.frame.pageAlias; const pageAlias = actionInContext.frame.pageAlias;
const page = [...pageAliases.entries()].find(([, alias]) => pageAlias === alias)?.[0]; const page = [...pageAliases.entries()].find(([, alias]) => pageAlias === alias)?.[0];
if (!page) if (!page)
throw new Error('Internal error: page not found'); throw new Error(`Internal error: page ${pageAlias} not found in [${[...pageAliases.values()]}]`);
return page.mainFrame(); return page.mainFrame();
} }
@ -72,3 +75,77 @@ export async function frameForAction(pageAliases: Map<Page, string>, actionInCon
throw new Error('Internal error: frame not found'); throw new Error('Internal error: frame not found');
return result.frame; return result.frame;
} }
export function traceParamsForAction(actionInContext: ActionInContext) {
const { action } = actionInContext;
switch (action.name) {
case 'navigate': return { url: action.url };
case 'openPage': return {};
case 'closePage': return {};
}
const selector = buildFullSelector(actionInContext.frame.framePath, action.selector);
switch (action.name) {
case 'click': return { selector, clickCount: action.clickCount };
case 'press': {
const modifiers = toKeyboardModifiers(action.modifiers);
const shortcut = [...modifiers, action.key].join('+');
return { selector, key: shortcut };
}
case 'fill': return { selector, text: action.text };
case 'setInputFiles': return { selector, files: action.files };
case 'check': return { selector };
case 'uncheck': return { selector };
case 'select': return { selector, values: action.options.map(value => ({ value })) };
case 'assertChecked': {
return {
selector,
expression: 'to.be.checked',
isNot: !action.checked,
};
}
case 'assertText': {
return {
selector,
expression: 'to.have.text',
expectedText: serializeExpectedTextValues([action.text], { matchSubstring: true, normalizeWhiteSpace: true }),
isNot: false,
};
}
case 'assertValue': {
return {
selector,
expression: 'to.have.value',
expectedValue: action.value,
isNot: false,
};
}
case 'assertVisible': {
return {
selector,
expression: 'to.be.visible',
isNot: false,
};
}
}
}
export function callMetadataForAction(pageAliases: Map<Page, string>, actionInContext: ActionInContext): { callMetadata: CallMetadata, mainFrame: Frame } {
const mainFrame = mainFrameForAction(pageAliases, actionInContext);
const { action } = actionInContext;
const callMetadata: CallMetadata = {
id: `call@${createGuid()}`,
apiName: 'frame.' + action.name,
objectId: mainFrame.guid,
pageId: mainFrame._page.guid,
frameId: mainFrame.guid,
startTime: monotonicTime(),
endTime: 0,
type: 'Frame',
method: action.name,
params: traceParamsForAction(actionInContext),
log: [],
};
return { callMetadata, mainFrame };
}

View file

@ -23,8 +23,11 @@ import { createSocket, createTLSSocket } from '../utils/happy-eyeballs';
import { escapeHTML, generateSelfSignedCertificate, ManualPromise, rewriteErrorMessage } from '../utils'; import { escapeHTML, generateSelfSignedCertificate, ManualPromise, rewriteErrorMessage } from '../utils';
import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../common/socksProxy'; import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../common/socksProxy';
import { SocksProxy } from '../common/socksProxy'; import { SocksProxy } from '../common/socksProxy';
import type * as channels from '@protocol/channels'; import type * as types from './types';
import { debugLogger } from '../utils/debugLogger'; import { debugLogger } from '../utils/debugLogger';
import { createProxyAgent } from './fetch';
import { EventEmitter } from 'events';
import { verifyClientCertificates } from './browserContext';
let dummyServerTlsOptions: tls.TlsOptions | undefined = undefined; let dummyServerTlsOptions: tls.TlsOptions | undefined = undefined;
function loadDummyServerCertsIfNeeded() { function loadDummyServerCertsIfNeeded() {
@ -94,7 +97,11 @@ class SocksProxyConnection {
} }
async connect() { async connect() {
this.target = await createSocket(rewriteToLocalhostIfNeeded(this.host), this.port); if (this.socksProxy.proxyAgentFromOptions)
this.target = await this.socksProxy.proxyAgentFromOptions.callback(new EventEmitter() as any, { host: rewriteToLocalhostIfNeeded(this.host), port: this.port, secureEndpoint: false });
else
this.target = await createSocket(rewriteToLocalhostIfNeeded(this.host), this.port);
this.target.once('close', this._targetCloseEventListener); this.target.once('close', this._targetCloseEventListener);
this.target.once('error', error => this.socksProxy._socksProxy.sendSocketError({ uid: this.uid, error: error.message })); this.target.once('error', error => this.socksProxy._socksProxy.sendSocketError({ uid: this.uid, error: error.message }));
if (this._closed) { if (this._closed) {
@ -233,12 +240,15 @@ export class ClientCertificatesProxy {
ignoreHTTPSErrors: boolean | undefined; ignoreHTTPSErrors: boolean | undefined;
secureContextMap: Map<string, tls.SecureContext> = new Map(); secureContextMap: Map<string, tls.SecureContext> = new Map();
alpnCache: ALPNCache; alpnCache: ALPNCache;
proxyAgentFromOptions: ReturnType<typeof createProxyAgent> | undefined;
constructor( constructor(
contextOptions: Pick<channels.BrowserNewContextOptions, 'clientCertificates' | 'ignoreHTTPSErrors'> contextOptions: Pick<types.BrowserContextOptions, 'clientCertificates' | 'ignoreHTTPSErrors' | 'proxy'>
) { ) {
verifyClientCertificates(contextOptions.clientCertificates);
this.alpnCache = new ALPNCache(); this.alpnCache = new ALPNCache();
this.ignoreHTTPSErrors = contextOptions.ignoreHTTPSErrors; this.ignoreHTTPSErrors = contextOptions.ignoreHTTPSErrors;
this.proxyAgentFromOptions = contextOptions.proxy ? createProxyAgent(contextOptions.proxy) : undefined;
this._initSecureContexts(contextOptions.clientCertificates); this._initSecureContexts(contextOptions.clientCertificates);
this._socksProxy = new SocksProxy(); this._socksProxy = new SocksProxy();
this._socksProxy.setPattern('*'); this._socksProxy.setPattern('*');
@ -261,9 +271,9 @@ export class ClientCertificatesProxy {
loadDummyServerCertsIfNeeded(); loadDummyServerCertsIfNeeded();
} }
_initSecureContexts(clientCertificates: channels.BrowserNewContextOptions['clientCertificates']) { _initSecureContexts(clientCertificates: types.BrowserContextOptions['clientCertificates']) {
// Step 1. Group certificates by origin. // Step 1. Group certificates by origin.
const origin2certs = new Map<string, channels.BrowserNewContextOptions['clientCertificates']>(); const origin2certs = new Map<string, types.BrowserContextOptions['clientCertificates']>();
for (const cert of clientCertificates || []) { for (const cert of clientCertificates || []) {
const origin = normalizeOrigin(cert.origin); const origin = normalizeOrigin(cert.origin);
const certs = origin2certs.get(origin) || []; const certs = origin2certs.get(origin) || [];
@ -282,9 +292,9 @@ export class ClientCertificatesProxy {
} }
} }
public async listen(): Promise<string> { public async listen() {
const port = await this._socksProxy.listen(0, '127.0.0.1'); const port = await this._socksProxy.listen(0, '127.0.0.1');
return `socks5://127.0.0.1:${port}`; return { server: `socks5://127.0.0.1:${port}` };
} }
public async close() { public async close() {
@ -301,7 +311,7 @@ function normalizeOrigin(origin: string): string {
} }
function convertClientCertificatesToTLSOptions( function convertClientCertificatesToTLSOptions(
clientCertificates: channels.BrowserNewContextOptions['clientCertificates'] clientCertificates: types.BrowserContextOptions['clientCertificates']
): Pick<https.RequestOptions, 'pfx' | 'key' | 'cert'> | undefined { ): Pick<https.RequestOptions, 'pfx' | 'key' | 'cert'> | undefined {
if (!clientCertificates || !clientCertificates.length) if (!clientCertificates || !clientCertificates.length)
return; return;
@ -322,7 +332,7 @@ function convertClientCertificatesToTLSOptions(
} }
export function getMatchingTLSOptionsForOrigin( export function getMatchingTLSOptionsForOrigin(
clientCertificates: channels.BrowserNewContextOptions['clientCertificates'], clientCertificates: types.BrowserContextOptions['clientCertificates'],
origin: string origin: string
): Pick<https.RequestOptions, 'pfx' | 'key' | 'cert'> | undefined { ): Pick<https.RequestOptions, 'pfx' | 'key' | 'cert'> | undefined {
const matchingCerts = clientCertificates?.filter(c => const matchingCerts = clientCertificates?.filter(c =>

View file

@ -150,7 +150,15 @@ export type NormalizedContinueOverrides = {
export type EmulatedSize = { viewport: Size, screen: Size }; export type EmulatedSize = { viewport: Size, screen: Size };
export type LaunchOptions = channels.BrowserTypeLaunchOptions & { useWebSocket?: boolean }; export type LaunchOptions = channels.BrowserTypeLaunchOptions & {
useWebSocket?: boolean,
proxyOverride?: ProxySettings,
};
export type BrowserContextOptions = channels.BrowserNewContextOptions & {
proxyOverride?: ProxySettings;
internalIgnoreHTTPSErrors?: boolean;
};
export type ProtocolLogger = (direction: 'send' | 'receive', message: object) => void; export type ProtocolLogger = (direction: 'send' | 'receive', message: object) => void;

View file

@ -53,7 +53,7 @@ export class WebKit extends BrowserType {
} }
override defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] { override defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] {
const { args = [], proxy, headless } = options; const { args = [], headless } = options;
const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir')); const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir'));
if (userDataDirArg) if (userDataDirArg)
throw this._createUserDataDirArgMisuseError('--user-data-dir'); throw this._createUserDataDirArgMisuseError('--user-data-dir');
@ -68,6 +68,7 @@ export class WebKit extends BrowserType {
webkitArguments.push(`--user-data-dir=${userDataDir}`); webkitArguments.push(`--user-data-dir=${userDataDir}`);
else else
webkitArguments.push(`--no-startup-window`); webkitArguments.push(`--no-startup-window`);
const proxy = options.proxyOverride || options.proxy;
if (proxy) { if (proxy) {
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
webkitArguments.push(`--proxy=${proxy.server}`); webkitArguments.push(`--proxy=${proxy.server}`);

View file

@ -81,12 +81,13 @@ export class WKBrowser extends Browser {
this._didClose(); this._didClose();
} }
async doCreateNewContext(options: channels.BrowserNewContextParams): Promise<BrowserContext> { async doCreateNewContext(options: types.BrowserContextOptions): Promise<BrowserContext> {
const createOptions = options.proxy ? { const proxy = options.proxyOverride || options.proxy;
// Enable socks5 hostname resolution on Windows. Workaround can be removed once fixed upstream. const createOptions = proxy ? {
// Enable socks5 hostname resolution on Windows.
// See https://github.com/microsoft/playwright/issues/20451 // See https://github.com/microsoft/playwright/issues/20451
proxyServer: process.platform === 'win32' ? options.proxy.server.replace(/^socks5:\/\//, 'socks5h://') : options.proxy.server, proxyServer: process.platform === 'win32' ? proxy.server.replace(/^socks5:\/\//, 'socks5h://') : proxy.server,
proxyBypassList: options.proxy.bypass proxyBypassList: proxy.bypass
} : undefined; } : undefined;
const { browserContextId } = await this._browserSession.send('Playwright.createContext', createOptions); const { browserContextId } = await this._browserSession.send('Playwright.createContext', createOptions);
options.userAgent = options.userAgent || DEFAULT_USER_AGENT; options.userAgent = options.userAgent || DEFAULT_USER_AGENT;
@ -206,7 +207,7 @@ export class WKBrowser extends Browser {
export class WKBrowserContext extends BrowserContext { export class WKBrowserContext extends BrowserContext {
declare readonly _browser: WKBrowser; declare readonly _browser: WKBrowser;
constructor(browser: WKBrowser, browserContextId: string | undefined, options: channels.BrowserNewContextParams) { constructor(browser: WKBrowser, browserContextId: string | undefined, options: types.BrowserContextOptions) {
super(browser, options, browserContextId); super(browser, options, browserContextId);
this._validateEmulatedViewport(options.viewport); this._validateEmulatedViewport(options.viewport);
this._authenticateProxyViaHeader(); this._authenticateProxyViaHeader();
@ -221,7 +222,7 @@ export class WKBrowserContext extends BrowserContext {
downloadPath: this._browser.options.downloadsPath, downloadPath: this._browser.options.downloadsPath,
browserContextId browserContextId
})); }));
if (this._options.ignoreHTTPSErrors) if (this._options.ignoreHTTPSErrors || this._options.internalIgnoreHTTPSErrors)
promises.push(this._browser._browserSession.send('Playwright.setIgnoreCertificateErrors', { browserContextId, ignore: true })); promises.push(this._browser._browserSession.send('Playwright.setIgnoreCertificateErrors', { browserContextId, ignore: true }));
if (this._options.locale) if (this._options.locale)
promises.push(this._browser._browserSession.send('Playwright.setLanguages', { browserContextId, languages: [this._options.locale] })); promises.push(this._browser._browserSession.send('Playwright.setLanguages', { browserContextId, languages: [this._options.locale] }));

View file

@ -768,6 +768,10 @@ export class WKPage implements PageDelegate {
}); });
} }
async forceGarbageCollection(): Promise<void> {
await this._session.send('Heap.gc');
}
async addInitScript(initScript: InitScript): Promise<void> { async addInitScript(initScript: InitScript): Promise<void> {
await this._updateBootstrapScript(); await this._updateBootstrapScript();
} }

View file

@ -2554,6 +2554,11 @@ export interface Page {
timeout?: number; timeout?: number;
}): Promise<void>; }): Promise<void>;
/**
* Force the browser to perform garbage collection.
*/
forceGarbageCollection(): Promise<void>;
/** /**
* Returns frame matching the specified criteria. Either `name` or `url` must be specified. * Returns frame matching the specified criteria. Either `name` or `url` must be specified.
* *
@ -9197,8 +9202,6 @@ export interface Browser {
* `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided
* with an exact match to the request origin that the certificate is valid for. * with an exact match to the request origin that the certificate is valid for.
* *
* **NOTE** Using Client Certificates in combination with Proxy Servers is not supported.
*
* **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it
* work by replacing `localhost` with `local.playwright`. * work by replacing `localhost` with `local.playwright`.
*/ */
@ -13055,7 +13058,10 @@ export interface Locator {
nth(index: number): Locator; nth(index: number): Locator;
/** /**
* Creates a locator that matches either of the two locators. * Creates a locator matching all elements that match one or both of the two locators.
*
* Note that when both locators match something, the resulting locator will have multiple matches and violate
* [locator strictness](https://playwright.dev/docs/locators#strictness) guidelines.
* *
* **Usage** * **Usage**
* *
@ -13916,8 +13922,6 @@ export interface BrowserType<Unused = {}> {
* `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided
* with an exact match to the request origin that the certificate is valid for. * with an exact match to the request origin that the certificate is valid for.
* *
* **NOTE** Using Client Certificates in combination with Proxy Servers is not supported.
*
* **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it
* work by replacing `localhost` with `local.playwright`. * work by replacing `localhost` with `local.playwright`.
*/ */
@ -16342,8 +16346,6 @@ export interface APIRequest {
* `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided
* with an exact match to the request origin that the certificate is valid for. * with an exact match to the request origin that the certificate is valid for.
* *
* **NOTE** Using Client Certificates in combination with Proxy Servers is not supported.
*
* **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it
* work by replacing `localhost` with `local.playwright`. * work by replacing `localhost` with `local.playwright`.
*/ */
@ -16559,7 +16561,7 @@ export interface APIRequestContext {
* as this request body. If this parameter is specified `content-type` header will be set to * as this request body. If this parameter is specified `content-type` header will be set to
* `application/x-www-form-urlencoded` unless explicitly provided. * `application/x-www-form-urlencoded` unless explicitly provided.
*/ */
form?: { [key: string]: string|number|boolean; }; form?: { [key: string]: string|number|boolean; }|FormData;
/** /**
* Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by * Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by
@ -16689,7 +16691,7 @@ export interface APIRequestContext {
* as this request body. If this parameter is specified `content-type` header will be set to * as this request body. If this parameter is specified `content-type` header will be set to
* `application/x-www-form-urlencoded` unless explicitly provided. * `application/x-www-form-urlencoded` unless explicitly provided.
*/ */
form?: { [key: string]: string|number|boolean; }; form?: { [key: string]: string|number|boolean; }|FormData;
/** /**
* Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by * Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by
@ -16807,7 +16809,7 @@ export interface APIRequestContext {
* as this request body. If this parameter is specified `content-type` header will be set to * as this request body. If this parameter is specified `content-type` header will be set to
* `application/x-www-form-urlencoded` unless explicitly provided. * `application/x-www-form-urlencoded` unless explicitly provided.
*/ */
form?: { [key: string]: string|number|boolean; }; form?: { [key: string]: string|number|boolean; }|FormData;
/** /**
* Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by * Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by
@ -16893,7 +16895,7 @@ export interface APIRequestContext {
* as this request body. If this parameter is specified `content-type` header will be set to * as this request body. If this parameter is specified `content-type` header will be set to
* `application/x-www-form-urlencoded` unless explicitly provided. * `application/x-www-form-urlencoded` unless explicitly provided.
*/ */
form?: { [key: string]: string|number|boolean; }; form?: { [key: string]: string|number|boolean; }|FormData;
/** /**
* Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by * Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by
@ -16979,7 +16981,7 @@ export interface APIRequestContext {
* as this request body. If this parameter is specified `content-type` header will be set to * as this request body. If this parameter is specified `content-type` header will be set to
* `application/x-www-form-urlencoded` unless explicitly provided. * `application/x-www-form-urlencoded` unless explicitly provided.
*/ */
form?: { [key: string]: string|number|boolean; }; form?: { [key: string]: string|number|boolean; }|FormData;
/** /**
* Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by * Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by
@ -17107,7 +17109,7 @@ export interface APIRequestContext {
* as this request body. If this parameter is specified `content-type` header will be set to * as this request body. If this parameter is specified `content-type` header will be set to
* `application/x-www-form-urlencoded` unless explicitly provided. * `application/x-www-form-urlencoded` unless explicitly provided.
*/ */
form?: { [key: string]: string|number|boolean; }; form?: { [key: string]: string|number|boolean; }|FormData;
/** /**
* Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by * Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by
@ -17193,7 +17195,7 @@ export interface APIRequestContext {
* as this request body. If this parameter is specified `content-type` header will be set to * as this request body. If this parameter is specified `content-type` header will be set to
* `application/x-www-form-urlencoded` unless explicitly provided. * `application/x-www-form-urlencoded` unless explicitly provided.
*/ */
form?: { [key: string]: string|number|boolean; }; form?: { [key: string]: string|number|boolean; }|FormData;
/** /**
* Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by * Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by
@ -20704,8 +20706,6 @@ export interface BrowserContextOptions {
* `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided
* with an exact match to the request origin that the certificate is valid for. * with an exact match to the request origin that the certificate is valid for.
* *
* **NOTE** Using Client Certificates in combination with Proxy Servers is not supported.
*
* **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it
* work by replacing `localhost` with `local.playwright`. * work by replacing `localhost` with `local.playwright`.
*/ */

View file

@ -28,6 +28,7 @@ export interface TestServerInterface {
closeOnDisconnect?: boolean, closeOnDisconnect?: boolean,
interceptStdio?: boolean, interceptStdio?: boolean,
watchTestDirs?: boolean, watchTestDirs?: boolean,
populateDependenciesOnList?: boolean,
}): Promise<void>; }): Promise<void>;
ping(params: {}): Promise<void>; ping(params: {}): Promise<void>;

View file

@ -16,6 +16,7 @@
import { import {
captureRawStack, captureRawStack,
createGuid,
isString, isString,
pollAgainstDeadline } from 'playwright-core/lib/utils'; pollAgainstDeadline } from 'playwright-core/lib/utils';
import type { ExpectZone } from 'playwright-core/lib/utils'; import type { ExpectZone } from 'playwright-core/lib/utils';
@ -104,33 +105,43 @@ export const printReceivedStringContainExpectedResult = (
type ExpectMessage = string | { message?: string }; type ExpectMessage = string | { message?: string };
function createMatchers(actual: unknown, info: ExpectMetaInfo): any { function createMatchers(actual: unknown, info: ExpectMetaInfo, prefix: string[]): any {
return new Proxy(expectLibrary(actual), new ExpectMetaInfoProxyHandler(info)); return new Proxy(expectLibrary(actual), new ExpectMetaInfoProxyHandler(info, prefix));
} }
function createExpect(info: ExpectMetaInfo) { const getCustomMatchersSymbol = Symbol('get custom matchers');
function qualifiedMatcherName(qualifier: string[], matcherName: string) {
return qualifier.join(':') + '$' + matcherName;
}
function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Record<string, Function>) {
const expectInstance: Expect<{}> = new Proxy(expectLibrary, { const expectInstance: Expect<{}> = new Proxy(expectLibrary, {
apply: function(target: any, thisArg: any, argumentsList: [unknown, ExpectMessage?]) { apply: function(target: any, thisArg: any, argumentsList: [unknown, ExpectMessage?]) {
const [actual, messageOrOptions] = argumentsList; const [actual, messageOrOptions] = argumentsList;
const message = isString(messageOrOptions) ? messageOrOptions : messageOrOptions?.message || info.message; const message = isString(messageOrOptions) ? messageOrOptions : messageOrOptions?.message || info.message;
const newInfo = { ...info, message }; const newInfo = { ...info, message };
if (newInfo.isPoll) { if (newInfo.poll) {
if (typeof actual !== 'function') if (typeof actual !== 'function')
throw new Error('`expect.poll()` accepts only function as a first argument'); throw new Error('`expect.poll()` accepts only function as a first argument');
newInfo.generator = actual as any; newInfo.poll.generator = actual as any;
} }
return createMatchers(actual, newInfo); return createMatchers(actual, newInfo, prefix);
}, },
get: function(target: any, property: string) { get: function(target: any, property: string | typeof getCustomMatchersSymbol) {
if (property === 'configure') if (property === 'configure')
return configure; return configure;
if (property === 'extend') { if (property === 'extend') {
return (matchers: any) => { return (matchers: any) => {
const qualifier = [...prefix, createGuid()];
const wrappedMatchers: any = {}; const wrappedMatchers: any = {};
const extendedMatchers: any = { ...customMatchers };
for (const [name, matcher] of Object.entries(matchers)) { for (const [name, matcher] of Object.entries(matchers)) {
wrappedMatchers[name] = function(...args: any[]) { const key = qualifiedMatcherName(qualifier, name);
wrappedMatchers[key] = function(...args: any[]) {
const { isNot, promise, utils } = this; const { isNot, promise, utils } = this;
const newThis: ExpectMatcherState = { const newThis: ExpectMatcherState = {
isNot, isNot,
@ -141,9 +152,12 @@ function createExpect(info: ExpectMetaInfo) {
(newThis as any).equals = throwUnsupportedExpectMatcherError; (newThis as any).equals = throwUnsupportedExpectMatcherError;
return (matcher as any).call(newThis, ...args); return (matcher as any).call(newThis, ...args);
}; };
Object.defineProperty(wrappedMatchers[key], 'name', { value: name });
extendedMatchers[name] = wrappedMatchers[key];
} }
expectLibrary.extend(wrappedMatchers); expectLibrary.extend(wrappedMatchers);
return expectInstance;
return createExpect(info, qualifier, extendedMatchers);
}; };
} }
@ -153,6 +167,9 @@ function createExpect(info: ExpectMetaInfo) {
}; };
} }
if (property === getCustomMatchersSymbol)
return customMatchers;
if (property === 'poll') { if (property === 'poll') {
return (actual: unknown, messageOrOptions?: ExpectMessage & { timeout?: number, intervals?: number[] }) => { return (actual: unknown, messageOrOptions?: ExpectMessage & { timeout?: number, intervals?: number[] }) => {
const poll = isString(messageOrOptions) ? {} : messageOrOptions || {}; const poll = isString(messageOrOptions) ? {} : messageOrOptions || {};
@ -172,13 +189,13 @@ function createExpect(info: ExpectMetaInfo) {
if ('soft' in configuration) if ('soft' in configuration)
newInfo.isSoft = configuration.soft; newInfo.isSoft = configuration.soft;
if ('_poll' in configuration) { if ('_poll' in configuration) {
newInfo.isPoll = !!configuration._poll; newInfo.poll = configuration._poll ? { ...info.poll, generator: () => {} } : undefined;
if (typeof configuration._poll === 'object') { if (typeof configuration._poll === 'object') {
newInfo.pollTimeout = configuration._poll.timeout; newInfo.poll!.timeout = configuration._poll.timeout ?? newInfo.poll!.timeout;
newInfo.pollIntervals = configuration._poll.intervals; newInfo.poll!.intervals = configuration._poll.intervals ?? newInfo.poll!.intervals;
} }
} }
return createExpect(newInfo); return createExpect(newInfo, prefix, customMatchers);
}; };
return expectInstance; return expectInstance;
@ -232,24 +249,38 @@ type ExpectMetaInfo = {
message?: string; message?: string;
isNot?: boolean; isNot?: boolean;
isSoft?: boolean; isSoft?: boolean;
isPoll?: boolean; poll?: {
timeout?: number;
intervals?: number[];
generator: Generator;
};
timeout?: number; timeout?: number;
pollTimeout?: number;
pollIntervals?: number[];
generator?: Generator;
}; };
class ExpectMetaInfoProxyHandler implements ProxyHandler<any> { class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
private _info: ExpectMetaInfo; private _info: ExpectMetaInfo;
private _prefix: string[];
constructor(info: ExpectMetaInfo) { constructor(info: ExpectMetaInfo, prefix: string[]) {
this._info = { ...info }; this._info = { ...info };
this._prefix = prefix;
} }
get(target: Object, matcherName: string | symbol, receiver: any): any { get(target: Object, matcherName: string | symbol, receiver: any): any {
let matcher = Reflect.get(target, matcherName, receiver); let matcher = Reflect.get(target, matcherName, receiver);
if (typeof matcherName !== 'string') if (typeof matcherName !== 'string')
return matcher; return matcher;
let resolvedMatcherName = matcherName;
for (let i = this._prefix.length; i > 0; i--) {
const qualifiedName = qualifiedMatcherName(this._prefix.slice(0, i), matcherName);
if (Reflect.has(target, qualifiedName)) {
matcher = Reflect.get(target, qualifiedName, receiver);
resolvedMatcherName = qualifiedName;
break;
}
}
if (matcher === undefined) if (matcher === undefined)
throw new Error(`expect: Property '${matcherName}' not found.`); throw new Error(`expect: Property '${matcherName}' not found.`);
if (typeof matcher !== 'function') { if (typeof matcher !== 'function') {
@ -257,10 +288,10 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
this._info.isNot = !this._info.isNot; this._info.isNot = !this._info.isNot;
return new Proxy(matcher, this); return new Proxy(matcher, this);
} }
if (this._info.isPoll) { if (this._info.poll) {
if ((customAsyncMatchers as any)[matcherName] || matcherName === 'resolves' || matcherName === 'rejects') if ((customAsyncMatchers as any)[matcherName] || matcherName === 'resolves' || matcherName === 'rejects')
throw new Error(`\`expect.poll()\` does not support "${matcherName}" matcher.`); throw new Error(`\`expect.poll()\` does not support "${matcherName}" matcher.`);
matcher = (...args: any[]) => pollMatcher(matcherName, !!this._info.isNot, this._info.pollIntervals, this._info.pollTimeout ?? currentExpectTimeout(), this._info.generator!, ...args); matcher = (...args: any[]) => pollMatcher(resolvedMatcherName, this._info, this._prefix, ...args);
} }
return (...args: any[]) => { return (...args: any[]) => {
const testInfo = currentTestInfo(); const testInfo = currentTestInfo();
@ -272,7 +303,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
const customMessage = this._info.message || ''; const customMessage = this._info.message || '';
const argsSuffix = computeArgsSuffix(matcherName, args); const argsSuffix = computeArgsSuffix(matcherName, args);
const defaultTitle = `expect${this._info.isPoll ? '.poll' : ''}${this._info.isSoft ? '.soft' : ''}${this._info.isNot ? '.not' : ''}.${matcherName}${argsSuffix}`; const defaultTitle = `expect${this._info.poll ? '.poll' : ''}${this._info.isSoft ? '.soft' : ''}${this._info.isNot ? '.not' : ''}.${matcherName}${argsSuffix}`;
const title = customMessage || defaultTitle; const title = customMessage || defaultTitle;
// This looks like it is unnecessary, but it isn't - we need to filter // This looks like it is unnecessary, but it isn't - we need to filter
@ -306,7 +337,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
const callback = () => matcher.call(target, ...args); const callback = () => matcher.call(target, ...args);
// toPass and poll matchers can contain other steps, expects and API calls, // toPass and poll matchers can contain other steps, expects and API calls,
// so they behave like a retriable step. // so they behave like a retriable step.
const result = (matcherName === 'toPass' || this._info.isPoll) ? const result = (matcherName === 'toPass' || this._info.poll) ?
zones.run('stepZone', step, callback) : zones.run('stepZone', step, callback) :
zones.run<ExpectZone, any>('expectZone', { title, stepId: step.stepId }, callback); zones.run<ExpectZone, any>('expectZone', { title, stepId: step.stepId }, callback);
if (result instanceof Promise) if (result instanceof Promise)
@ -320,25 +351,32 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
} }
} }
async function pollMatcher(matcherName: any, isNot: boolean, pollIntervals: number[] | undefined, timeout: number, generator: () => any, ...args: any[]) { async function pollMatcher(qualifiedMatcherName: string, info: ExpectMetaInfo, prefix: string[], ...args: any[]) {
const testInfo = currentTestInfo(); const testInfo = currentTestInfo();
const poll = info.poll!;
const timeout = poll.timeout ?? currentExpectTimeout();
const { deadline, timeoutMessage } = testInfo ? testInfo._deadlineForMatcher(timeout) : TestInfoImpl._defaultDeadlineForMatcher(timeout); const { deadline, timeoutMessage } = testInfo ? testInfo._deadlineForMatcher(timeout) : TestInfoImpl._defaultDeadlineForMatcher(timeout);
const result = await pollAgainstDeadline<Error|undefined>(async () => { const result = await pollAgainstDeadline<Error|undefined>(async () => {
if (testInfo && currentTestInfo() !== testInfo) if (testInfo && currentTestInfo() !== testInfo)
return { continuePolling: false, result: undefined }; return { continuePolling: false, result: undefined };
const value = await generator(); const innerInfo: ExpectMetaInfo = {
let expectInstance = expectLibrary(value) as any; ...info,
if (isNot) isSoft: false, // soft is outside of poll, not inside
expectInstance = expectInstance.not; poll: undefined,
};
const value = await poll.generator();
try { try {
expectInstance[matcherName].call(expectInstance, ...args); let matchers = createMatchers(value, innerInfo, prefix);
if (info.isNot)
matchers = matchers.not;
matchers[qualifiedMatcherName](...args);
return { continuePolling: false, result: undefined }; return { continuePolling: false, result: undefined };
} catch (error) { } catch (error) {
return { continuePolling: true, result: error }; return { continuePolling: true, result: error };
} }
}, deadline, pollIntervals ?? [100, 250, 500, 1000]); }, deadline, poll.intervals ?? [100, 250, 500, 1000]);
if (result.timedOut) { if (result.timedOut) {
const message = result.result ? [ const message = result.result ? [
@ -375,8 +413,15 @@ function computeArgsSuffix(matcherName: string, args: any[]) {
return value ? `(${value})` : ''; return value ? `(${value})` : '';
} }
export const expect: Expect<{}> = createExpect({}).extend(customMatchers); export const expect: Expect<{}> = createExpect({}, [], {}).extend(customMatchers);
export function mergeExpects(...expects: any[]) { export function mergeExpects(...expects: any[]) {
return expect; let merged = expect;
for (const e of expects) {
const internals = e[getCustomMatchersSymbol];
if (!internals) // non-playwright expects mutate the global expect, so we don't need to do anything special
continue;
merged = merged.extend(internals);
}
return merged;
} }

View file

@ -69,7 +69,7 @@ function addListFilesCommand(program: Command) {
} }
function addClearCacheCommand(program: Command) { function addClearCacheCommand(program: Command) {
const command = program.command('clear-cache', { hidden: true }); const command = program.command('clear-cache');
command.description('clears build and test caches'); command.description('clears build and test caches');
command.option('-c, --config <file>', `Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}"`); command.option('-c, --config <file>', `Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}"`);
command.action(async opts => { command.action(async opts => {
@ -133,7 +133,7 @@ Examples:
} }
function addMergeReportsCommand(program: Command) { function addMergeReportsCommand(program: Command) {
const command = program.command('merge-reports [dir]', { hidden: true }); const command = program.command('merge-reports [dir]');
command.description('merge multiple blob reports (for sharded tests) into a single report'); command.description('merge multiple blob reports (for sharded tests) into a single report');
command.action(async (dir, options) => { command.action(async (dir, options) => {
try { try {

View file

@ -79,6 +79,7 @@ export class TestServerDispatcher implements TestServerInterface {
private _serializer = require.resolve('./uiModeReporter'); private _serializer = require.resolve('./uiModeReporter');
private _watchTestDirs = false; private _watchTestDirs = false;
private _closeOnDisconnect = false; private _closeOnDisconnect = false;
private _populateDependenciesOnList = false;
constructor(configLocation: ConfigLocation) { constructor(configLocation: ConfigLocation) {
this._configLocation = configLocation; this._configLocation = configLocation;
@ -113,6 +114,7 @@ export class TestServerDispatcher implements TestServerInterface {
this._closeOnDisconnect = !!params.closeOnDisconnect; this._closeOnDisconnect = !!params.closeOnDisconnect;
await this._setInterceptStdio(!!params.interceptStdio); await this._setInterceptStdio(!!params.interceptStdio);
this._watchTestDirs = !!params.watchTestDirs; this._watchTestDirs = !!params.watchTestDirs;
this._populateDependenciesOnList = !!params.populateDependenciesOnList;
} }
async ping() {} async ping() {}
@ -252,7 +254,7 @@ export class TestServerDispatcher implements TestServerInterface {
config.cliListOnly = true; config.cliListOnly = true;
const status = await runTasks(new TestRun(config, reporter), [ const status = await runTasks(new TestRun(config, reporter), [
createLoadTask('out-of-process', { failOnLoadErrors: false, filterOnly: false }), createLoadTask('out-of-process', { failOnLoadErrors: false, filterOnly: false, populateDependencies: this._populateDependenciesOnList }),
createReportBeginTask(), createReportBeginTask(),
]); ]);
return { config, report, reporter, status }; return { config, report, reporter, status };

View file

@ -73,6 +73,7 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp
return 'restarted'; return 'restarted';
const options: WatchModeOptions = { ...initialOptions }; const options: WatchModeOptions = { ...initialOptions };
let bufferMode = false;
const testServerDispatcher = new TestServerDispatcher(configLocation); const testServerDispatcher = new TestServerDispatcher(configLocation);
const transport = new InMemoryTransport( const transport = new InMemoryTransport(
@ -94,6 +95,7 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp
const teleSuiteUpdater = new TeleSuiteUpdater({ pathSeparator: path.sep, onUpdate() { } }); const teleSuiteUpdater = new TeleSuiteUpdater({ pathSeparator: path.sep, onUpdate() { } });
const dirtyTestFiles = new Set<string>();
const dirtyTestIds = new Set<string>(); const dirtyTestIds = new Set<string>();
let onDirtyTests = new ManualPromise<'changed'>(); let onDirtyTests = new ManualPromise<'changed'>();
@ -110,19 +112,22 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp
teleSuiteUpdater.processListReport(report); teleSuiteUpdater.processListReport(report);
for (const test of teleSuiteUpdater.rootSuite!.allTests()) { for (const test of teleSuiteUpdater.rootSuite!.allTests()) {
if (changedFiles.has(test.location.file)) if (changedFiles.has(test.location.file)) {
dirtyTestFiles.add(test.location.file);
dirtyTestIds.add(test.id); dirtyTestIds.add(test.id);
}
} }
changedFiles.clear(); changedFiles.clear();
if (dirtyTestIds.size > 0) if (dirtyTestIds.size > 0) {
onDirtyTests.resolve('changed'); onDirtyTests.resolve('changed');
onDirtyTests = new ManualPromise();
}
}); });
}); });
testServerConnection.onReport(report => teleSuiteUpdater.processTestReportEvent(report)); testServerConnection.onReport(report => teleSuiteUpdater.processTestReportEvent(report));
await testServerConnection.initialize({ interceptStdio: false, watchTestDirs: true }); await testServerConnection.initialize({ interceptStdio: false, watchTestDirs: true, populateDependenciesOnList: true });
await testServerConnection.runGlobalSetup({}); await testServerConnection.runGlobalSetup({});
const { report } = await testServerConnection.listTests({}); const { report } = await testServerConnection.listTests({});
@ -133,20 +138,28 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp
let lastRun: { type: 'changed' | 'regular' | 'failed', failedTestIds?: string[], dirtyTestIds?: string[] } = { type: 'regular' }; let lastRun: { type: 'changed' | 'regular' | 'failed', failedTestIds?: string[], dirtyTestIds?: string[] } = { type: 'regular' };
let result: FullResult['status'] = 'passed'; let result: FullResult['status'] = 'passed';
// Enter the watch loop.
await runTests(options, testServerConnection);
while (true) { while (true) {
printPrompt(); if (bufferMode)
printBufferPrompt(dirtyTestFiles, teleSuiteUpdater.config!.rootDir);
else
printPrompt();
const command = await Promise.race([ const command = await Promise.race([
onDirtyTests, onDirtyTests,
readCommand(), readCommand(),
]); ]);
if (command === 'changed') { if (bufferMode && command === 'changed')
onDirtyTests = new ManualPromise(); continue;
const shouldRunChangedFiles = bufferMode ? command === 'run' : command === 'changed';
if (shouldRunChangedFiles) {
if (dirtyTestIds.size === 0)
continue;
const testIds = [...dirtyTestIds]; const testIds = [...dirtyTestIds];
dirtyTestIds.clear(); dirtyTestIds.clear();
dirtyTestFiles.clear();
await runTests(options, testServerConnection, { testIds, title: 'files changed' }); await runTests(options, testServerConnection, { testIds, title: 'files changed' });
lastRun = { type: 'changed', dirtyTestIds: testIds }; lastRun = { type: 'changed', dirtyTestIds: testIds };
continue; continue;
@ -232,6 +245,11 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp
continue; continue;
} }
if (command === 'toggle-buffer-mode') {
bufferMode = !bufferMode;
continue;
}
if (command === 'exit') if (command === 'exit')
break; break;
@ -321,6 +339,7 @@ Change settings
${colors.bold('p')} ${colors.dim('set file filter')} ${colors.bold('p')} ${colors.dim('set file filter')}
${colors.bold('t')} ${colors.dim('set title filter')} ${colors.bold('t')} ${colors.dim('set title filter')}
${colors.bold('s')} ${colors.dim('toggle show & reuse the browser')} ${colors.bold('s')} ${colors.dim('toggle show & reuse the browser')}
${colors.bold('b')} ${colors.dim('toggle buffer mode')}
`); `);
return; return;
} }
@ -333,13 +352,14 @@ Change settings
case 't': return 'grep'; case 't': return 'grep';
case 'f': return 'failed'; case 'f': return 'failed';
case 's': return 'toggle-show-browser'; case 's': return 'toggle-show-browser';
case 'b': return 'toggle-buffer-mode';
} }
}); });
} }
let showBrowserServer: PlaywrightServer | undefined; let showBrowserServer: PlaywrightServer | undefined;
let connectWsEndpoint: string | undefined = undefined; let connectWsEndpoint: string | undefined = undefined;
let seq = 0; let seq = 1;
function printConfiguration(options: WatchModeOptions, title?: string) { function printConfiguration(options: WatchModeOptions, title?: string) {
const packageManagerCommand = getPackageManagerExecCommand(); const packageManagerCommand = getPackageManagerExecCommand();
@ -353,9 +373,7 @@ function printConfiguration(options: WatchModeOptions, title?: string) {
tokens.push(...options.files.map(a => colors.bold(a))); tokens.push(...options.files.map(a => colors.bold(a)));
if (title) if (title)
tokens.push(colors.dim(`(${title})`)); tokens.push(colors.dim(`(${title})`));
if (seq) tokens.push(colors.dim(`#${seq++}`));
tokens.push(colors.dim(`#${seq}`));
++seq;
const lines: string[] = []; const lines: string[] = [];
const sep = separator(); const sep = separator();
lines.push('\x1Bc' + sep); lines.push('\x1Bc' + sep);
@ -364,6 +382,22 @@ function printConfiguration(options: WatchModeOptions, title?: string) {
process.stdout.write(lines.join('\n')); process.stdout.write(lines.join('\n'));
} }
function printBufferPrompt(dirtyTestFiles: Set<string>, rootDir: string) {
const sep = separator();
process.stdout.write('\x1Bc');
process.stdout.write(`${sep}\n`);
if (dirtyTestFiles.size === 0) {
process.stdout.write(`${colors.dim('Waiting for file changes. Press')} ${colors.bold('q')} ${colors.dim('to quit or')} ${colors.bold('h')} ${colors.dim('for more options.')}\n\n`);
return;
}
process.stdout.write(`${colors.dim(`${dirtyTestFiles.size} test ${dirtyTestFiles.size === 1 ? 'file' : 'files'} changed:`)}\n\n`);
for (const file of dirtyTestFiles)
process.stdout.write(` · ${path.relative(rootDir, file)}\n`);
process.stdout.write(`\n${colors.dim(`Press`)} ${colors.bold('enter')} ${colors.dim('to run')}, ${colors.bold('q')} ${colors.dim('to quit or')} ${colors.bold('h')} ${colors.dim('for more options.')}\n\n`);
}
function printPrompt() { function printPrompt() {
const sep = separator(); const sep = separator();
process.stdout.write(` process.stdout.write(`
@ -385,4 +419,4 @@ async function toggleShowBrowser() {
} }
} }
type Command = 'run' | 'failed' | 'repeat' | 'changed' | 'project' | 'file' | 'grep' | 'exit' | 'interrupted' | 'toggle-show-browser'; type Command = 'run' | 'failed' | 'repeat' | 'changed' | 'project' | 'file' | 'grep' | 'exit' | 'interrupted' | 'toggle-show-browser' | 'toggle-buffer-mode';

View file

@ -5211,8 +5211,6 @@ export interface PlaywrightTestOptions {
* `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided
* with an exact match to the request origin that the certificate is valid for. * with an exact match to the request origin that the certificate is valid for.
* *
* **NOTE** Using Client Certificates in combination with Proxy Servers is not supported.
*
* **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it
* work by replacing `localhost` with `local.playwright`. * work by replacing `localhost` with `local.playwright`.
* *

View file

@ -1933,6 +1933,7 @@ export interface PageChannel extends PageEventTarget, EventTargetChannel {
exposeBinding(params: PageExposeBindingParams, metadata?: CallMetadata): Promise<PageExposeBindingResult>; exposeBinding(params: PageExposeBindingParams, metadata?: CallMetadata): Promise<PageExposeBindingResult>;
goBack(params: PageGoBackParams, metadata?: CallMetadata): Promise<PageGoBackResult>; goBack(params: PageGoBackParams, metadata?: CallMetadata): Promise<PageGoBackResult>;
goForward(params: PageGoForwardParams, metadata?: CallMetadata): Promise<PageGoForwardResult>; goForward(params: PageGoForwardParams, metadata?: CallMetadata): Promise<PageGoForwardResult>;
forceGarbageCollection(params?: PageForceGarbageCollectionParams, metadata?: CallMetadata): Promise<PageForceGarbageCollectionResult>;
registerLocatorHandler(params: PageRegisterLocatorHandlerParams, metadata?: CallMetadata): Promise<PageRegisterLocatorHandlerResult>; registerLocatorHandler(params: PageRegisterLocatorHandlerParams, metadata?: CallMetadata): Promise<PageRegisterLocatorHandlerResult>;
resolveLocatorHandlerNoReply(params: PageResolveLocatorHandlerNoReplyParams, metadata?: CallMetadata): Promise<PageResolveLocatorHandlerNoReplyResult>; resolveLocatorHandlerNoReply(params: PageResolveLocatorHandlerNoReplyParams, metadata?: CallMetadata): Promise<PageResolveLocatorHandlerNoReplyResult>;
unregisterLocatorHandler(params: PageUnregisterLocatorHandlerParams, metadata?: CallMetadata): Promise<PageUnregisterLocatorHandlerResult>; unregisterLocatorHandler(params: PageUnregisterLocatorHandlerParams, metadata?: CallMetadata): Promise<PageUnregisterLocatorHandlerResult>;
@ -2070,6 +2071,9 @@ export type PageGoForwardOptions = {
export type PageGoForwardResult = { export type PageGoForwardResult = {
response?: ResponseChannel, response?: ResponseChannel,
}; };
export type PageForceGarbageCollectionParams = {};
export type PageForceGarbageCollectionOptions = {};
export type PageForceGarbageCollectionResult = void;
export type PageRegisterLocatorHandlerParams = { export type PageRegisterLocatorHandlerParams = {
selector: string, selector: string,
noWaitAfter?: boolean, noWaitAfter?: boolean,

View file

@ -1430,6 +1430,8 @@ Page:
slowMo: true slowMo: true
snapshot: true snapshot: true
forceGarbageCollection:
registerLocatorHandler: registerLocatorHandler:
parameters: parameters:
selector: string selector: string

View file

@ -27,14 +27,6 @@ import { asLocator } from '@isomorphic/locatorGenerators';
import { toggleTheme } from '@web/theme'; import { toggleTheme } from '@web/theme';
import { copy } from '@web/uiUtils'; import { copy } from '@web/uiUtils';
declare global {
interface Window {
playwrightSetFileIfNeeded: (file: string) => void;
playwrightSetSelector: (selector: string, focus?: boolean) => void;
dispatch(data: any): Promise<void>;
}
}
export interface RecorderProps { export interface RecorderProps {
sources: Source[], sources: Source[],
paused: boolean, paused: boolean,
@ -56,14 +48,22 @@ export const Recorder: React.FC<RecorderProps> = ({
setFileId(sources[0].id); setFileId(sources[0].id);
}, [fileId, sources]); }, [fileId, sources]);
const source: Source = sources.find(s => s.id === fileId) || { const source = React.useMemo(() => {
id: 'default', if (fileId) {
isRecorded: false, const source = sources.find(s => s.id === fileId);
text: '', if (source)
language: 'javascript', return source;
label: '', }
highlight: [] const source: Source = {
}; id: 'default',
isRecorded: false,
text: '',
language: 'javascript',
label: '',
highlight: []
};
return source;
}, [sources, fileId]);
const [locator, setLocator] = React.useState(''); const [locator, setLocator] = React.useState('');
window.playwrightSetSelector = (selector: string, focus?: boolean) => { window.playwrightSetSelector = (selector: string, focus?: boolean) => {
@ -73,13 +73,7 @@ export const Recorder: React.FC<RecorderProps> = ({
setLocator(asLocator(language, selector)); setLocator(asLocator(language, selector));
}; };
window.playwrightSetFileIfNeeded = (value: string) => { window.playwrightSetFile = setFileId;
const newSource = sources.find(s => s.id === value);
// Do not forcefully switch between two recorded sources, because
// user did explicitly choose one.
if (newSource && !newSource.isRecorded || !source.isRecorded)
setFileId(value);
};
const messagesEndRef = React.useRef<HTMLDivElement>(null); const messagesEndRef = React.useRef<HTMLDivElement>(null);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {

View file

@ -96,7 +96,7 @@ declare global {
playwrightSetSources: (sources: Source[]) => void; playwrightSetSources: (sources: Source[]) => void;
playwrightSetOverlayVisible: (visible: boolean) => void; playwrightSetOverlayVisible: (visible: boolean) => void;
playwrightUpdateLogs: (callLogs: CallLog[]) => void; playwrightUpdateLogs: (callLogs: CallLog[]) => void;
playwrightSetFileIfNeeded: (file: string) => void; playwrightSetFile: (file: string) => void;
playwrightSetSelector: (selector: string, focus?: boolean) => void; playwrightSetSelector: (selector: string, focus?: boolean) => void;
playwrightSourcesEchoForTest: Source[]; playwrightSourcesEchoForTest: Source[];
dispatch(data: any): Promise<void>; dispatch(data: any): Promise<void>;

View file

@ -0,0 +1,28 @@
<!--
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/playwright-logo.svg" type="image/svg+xml">
<title>Playwright Recorder</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/recorder.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,41 @@
/**
* 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 '@web/common.css';
import { applyTheme } from '@web/theme';
import '@web/third_party/vscode/codicon.css';
import * as ReactDOM from 'react-dom/client';
import { RecorderView } from './ui/recorderView';
(async () => {
applyTheme();
if (window.location.protocol !== 'file:') {
if (!navigator.serviceWorker)
throw new Error(`Service workers are not supported.\nMake sure to serve the Recorder (${window.location}) via HTTPS or localhost.`);
navigator.serviceWorker.register('sw.bundle.js');
if (!navigator.serviceWorker.controller) {
await new Promise<void>(f => {
navigator.serviceWorker.oncontrollerchange = () => f();
});
}
// Keep SW running.
setInterval(function() { fetch('ping'); }, 10000);
}
ReactDOM.createRoot(document.querySelector('#root')!).render(<RecorderView />);
})();

View file

@ -22,7 +22,7 @@ import { renderAction } from './actionList';
import type { Language } from '@isomorphic/locatorGenerators'; import type { Language } from '@isomorphic/locatorGenerators';
import type { StackFrame } from '@protocol/channels'; import type { StackFrame } from '@protocol/channels';
type ErrorDescription = { export type ErrorDescription = {
action?: modelUtil.ActionTraceEventInContext; action?: modelUtil.ActionTraceEventInContext;
stack?: StackFrame[]; stack?: StackFrame[];
}; };

View file

@ -342,10 +342,6 @@ export function buildActionTree(actions: ActionTraceEventInContext[]): { rootIte
return { rootItem, itemMap }; return { rootItem, itemMap };
} }
export function idForAction(action: ActionTraceEvent) {
return `${action.pageId || 'none'}:${action.callId}`;
}
export function context(action: ActionTraceEvent | trace.EventTraceEvent | ResourceSnapshot): ContextEntry { export function context(action: ActionTraceEvent | trace.EventTraceEvent | ResourceSnapshot): ContextEntry {
return (action as any)[contextSymbol]; return (action as any)[contextSymbol];
} }

View file

@ -16,6 +16,7 @@
.network-request-details-tab { .network-request-details-tab {
width: 100%; width: 100%;
height: 100%;
user-select: text; user-select: text;
line-height: 24px; line-height: 24px;
margin-left: 10px; margin-left: 10px;

View file

@ -0,0 +1,15 @@
/*
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.
*/

View file

@ -0,0 +1,168 @@
/*
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 './recorderView.css';
import { MultiTraceModel } from './modelUtil';
import type { SourceLocation } from './modelUtil';
import { Workbench } from './workbench';
import type { Mode, Source } from '@recorder/recorderTypes';
import type { ContextEntry } from '../entries';
const searchParams = new URLSearchParams(window.location.search);
const guid = searchParams.get('ws');
const trace = searchParams.get('trace') + '.json';
export const RecorderView: React.FunctionComponent = () => {
const [connection, setConnection] = React.useState<Connection | null>(null);
const [sources, setSources] = React.useState<Source[]>([]);
React.useEffect(() => {
const wsURL = new URL(`../${guid}`, window.location.toString());
wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:');
const webSocket = new WebSocket(wsURL.toString());
setConnection(new Connection(webSocket, { setSources }));
return () => {
webSocket.close();
};
}, []);
React.useEffect(() => {
if (!connection)
return;
connection.setMode('recording');
}, [connection]);
return <div className='vbox workbench-loader'>
<TraceView
traceLocation={trace}
sources={sources} />
</div>;
};
export const TraceView: React.FC<{
traceLocation: string,
sources: Source[],
}> = ({ traceLocation, sources }) => {
const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>();
const [counter, setCounter] = React.useState(0);
const pollTimer = React.useRef<NodeJS.Timeout | null>(null);
React.useEffect(() => {
if (pollTimer.current)
clearTimeout(pollTimer.current);
// Start polling running test.
pollTimer.current = setTimeout(async () => {
try {
const model = await loadSingleTraceFile(traceLocation);
setModel({ model, isLive: true });
} catch {
setModel(undefined);
} finally {
setCounter(counter + 1);
}
}, 500);
return () => {
if (pollTimer.current)
clearTimeout(pollTimer.current);
};
}, [counter, traceLocation]);
const fallbackLocation = React.useMemo(() => {
if (!sources.length)
return undefined;
const fallbackLocation: SourceLocation = {
file: '',
line: 0,
column: 0,
source: {
errors: [],
content: sources[0].text
}
};
return fallbackLocation;
}, [sources]);
return <Workbench
key='workbench'
model={model?.model}
showSourcesFirst={true}
fallbackLocation={fallbackLocation}
isLive={true}
/>;
};
async function loadSingleTraceFile(url: string): Promise<MultiTraceModel> {
const params = new URLSearchParams();
params.set('trace', url);
const response = await fetch(`contexts?${params.toString()}`);
const contextEntries = await response.json() as ContextEntry[];
return new MultiTraceModel(contextEntries);
}
class Connection {
private _lastId = 0;
private _webSocket: WebSocket;
private _callbacks = new Map<number, { resolve: (arg: any) => void, reject: (arg: Error) => void }>();
private _options: { setSources: (sources: Source[]) => void; };
constructor(webSocket: WebSocket, options: { setSources: (sources: Source[]) => void }) {
this._webSocket = webSocket;
this._callbacks = new Map();
this._options = options;
this._webSocket.addEventListener('message', event => {
const message = JSON.parse(event.data);
const { id, result, error, method, params } = message;
if (id) {
const callback = this._callbacks.get(id);
if (!callback)
return;
this._callbacks.delete(id);
if (error)
callback.reject(new Error(error));
else
callback.resolve(result);
} else {
this._dispatchEvent(method, params);
}
});
}
setMode(mode: Mode) {
this._sendMessageNoReply('setMode', { mode });
}
private async _sendMessage(method: string, params?: any): Promise<any> {
const id = ++this._lastId;
const message = { id, method, params };
this._webSocket.send(JSON.stringify(message));
return new Promise((resolve, reject) => {
this._callbacks.set(id, { resolve, reject });
});
}
private _sendMessageNoReply(method: string, params?: any) {
this._sendMessage(method, params).catch(() => { });
}
private _dispatchEvent(method: string, params?: any) {
if (method === 'setSources') {
const { sources } = params as { sources: Source[] };
this._options.setSources(sources);
}
}
}

View file

@ -55,7 +55,7 @@ export const SourceTab: React.FunctionComponent<{
let source = sources.get(file); let source = sources.get(file);
// Fallback location can fall outside the sources model. // Fallback location can fall outside the sources model.
if (!source) { if (!source) {
source = { errors: fallbackLocation?.source?.errors || [], content: undefined }; source = { errors: fallbackLocation?.source?.errors || [], content: fallbackLocation?.source?.content };
sources.set(file, source); sources.set(file, source);
} }
@ -66,7 +66,9 @@ export const SourceTab: React.FunctionComponent<{
highlight.push({ line: targetLine, type: 'running' }); highlight.push({ line: targetLine, type: 'running' });
// After the source update, but before the test run, don't trust the cache. // After the source update, but before the test run, don't trust the cache.
if (source.content === undefined || shouldUseFallback) { if (fallbackLocation?.source?.content !== undefined) {
source.content = fallbackLocation.source.content;
} else if (source.content === undefined || shouldUseFallback) {
const sha1 = await calculateSha1(file); const sha1 = await calculateSha1(file);
try { try {
let response = await fetch(`sha1/src@${sha1}.txt`); let response = await fetch(`sha1/src@${sha1}.txt`);

View file

@ -16,14 +16,13 @@
import { artifactsFolderName } from '@testIsomorphic/folders'; import { artifactsFolderName } from '@testIsomorphic/folders';
import type { TreeItem } from '@testIsomorphic/testTree'; import type { TreeItem } from '@testIsomorphic/testTree';
import type { ActionTraceEvent } from '@trace/trace';
import '@web/common.css'; import '@web/common.css';
import '@web/third_party/vscode/codicon.css'; import '@web/third_party/vscode/codicon.css';
import type * as reporterTypes from 'playwright/types/testReporter'; import type * as reporterTypes from 'playwright/types/testReporter';
import React from 'react'; import React from 'react';
import type { ContextEntry } from '../entries'; import type { ContextEntry } from '../entries';
import type { SourceLocation } from './modelUtil'; import type { SourceLocation } from './modelUtil';
import { idForAction, MultiTraceModel } from './modelUtil'; import { MultiTraceModel } from './modelUtil';
import { Workbench } from './workbench'; import { Workbench } from './workbench';
export const TraceView: React.FC<{ export const TraceView: React.FC<{
@ -42,12 +41,6 @@ export const TraceView: React.FC<{
return { outputDir }; return { outputDir };
}, [item]); }, [item]);
// Preserve user selection upon live-reloading trace model by persisting the action id.
// This avoids auto-selection of the last action every time we reload the model.
const [selectedActionId, setSelectedActionId] = React.useState<string | undefined>();
const onSelectionChanged = React.useCallback((action: ActionTraceEvent) => setSelectedActionId(idForAction(action)), [setSelectedActionId]);
const initialSelection = selectedActionId ? model?.model.actions.find(a => idForAction(a) === selectedActionId) : undefined;
React.useEffect(() => { React.useEffect(() => {
if (pollTimer.current) if (pollTimer.current)
clearTimeout(pollTimer.current); clearTimeout(pollTimer.current);
@ -98,8 +91,6 @@ export const TraceView: React.FC<{
model={model?.model} model={model?.model}
showSourcesFirst={true} showSourcesFirst={true}
rootDir={rootDir} rootDir={rootDir}
initialSelection={initialSelection}
onSelectionChanged={onSelectionChanged}
fallbackLocation={item.testFile} fallbackLocation={item.testFile}
isLive={model?.isLive} isLive={model?.isLive}
status={item.treeItem?.status} status={item.treeItem?.status}

View file

@ -72,6 +72,7 @@ export const UIModeView: React.FC<{}> = ({
}) => { }) => {
const [filterText, setFilterText] = React.useState<string>(''); const [filterText, setFilterText] = React.useState<string>('');
const [isShowingOutput, setIsShowingOutput] = React.useState<boolean>(false); const [isShowingOutput, setIsShowingOutput] = React.useState<boolean>(false);
const [outputContainsError, setOutputContainsError] = React.useState(false);
const [statusFilters, setStatusFilters] = React.useState<Map<string, boolean>>(new Map([ const [statusFilters, setStatusFilters] = React.useState<Map<string, boolean>>(new Map([
['passed', false], ['passed', false],
['failed', false], ['failed', false],
@ -134,6 +135,9 @@ export const UIModeView: React.FC<{}> = ({
} else { } else {
xtermDataSource.write(params.text!); xtermDataSource.write(params.text!);
} }
if (params.type === 'stderr')
setOutputContainsError(true);
}), }),
testServerConnection.onClose(() => setIsDisconnected(true)) testServerConnection.onClose(() => setIsDisconnected(true))
]; ];
@ -168,6 +172,7 @@ export const UIModeView: React.FC<{}> = ({
}, },
onError: error => { onError: error => {
xtermDataSource.write((error.stack || error.value || '') + '\n'); xtermDataSource.write((error.stack || error.value || '') + '\n');
setOutputContainsError(true);
}, },
pathSeparator: queryParams.pathSeparator, pathSeparator: queryParams.pathSeparator,
}); });
@ -426,7 +431,7 @@ export const UIModeView: React.FC<{}> = ({
<div className={clsx('vbox', !isShowingOutput && 'hidden')}> <div className={clsx('vbox', !isShowingOutput && 'hidden')}>
<Toolbar> <Toolbar>
<div className='section-title' style={{ flex: 'none' }}>Output</div> <div className='section-title' style={{ flex: 'none' }}>Output</div>
<ToolbarButton icon='circle-slash' title='Clear output' onClick={() => xtermDataSource.clear()}></ToolbarButton> <ToolbarButton icon='circle-slash' title='Clear output' onClick={() => { xtermDataSource.clear(); setOutputContainsError(false); }}></ToolbarButton>
<div className='spacer'></div> <div className='spacer'></div>
<ToolbarButton icon='close' title='Close' onClick={() => setIsShowingOutput(false)}></ToolbarButton> <ToolbarButton icon='close' title='Close' onClick={() => setIsShowingOutput(false)}></ToolbarButton>
</Toolbar> </Toolbar>
@ -447,7 +452,10 @@ export const UIModeView: React.FC<{}> = ({
<img src='playwright-logo.svg' alt='Playwright logo' /> <img src='playwright-logo.svg' alt='Playwright logo' />
<div className='section-title'>Playwright</div> <div className='section-title'>Playwright</div>
<ToolbarButton icon='refresh' title='Reload' onClick={() => reloadTests()} disabled={isRunningTest || isLoading}></ToolbarButton> <ToolbarButton icon='refresh' title='Reload' onClick={() => reloadTests()} disabled={isRunningTest || isLoading}></ToolbarButton>
<ToolbarButton icon='terminal' title={'Toggle output — ' + (isMac ? '⌃`' : 'Ctrl + `')} toggled={isShowingOutput} onClick={() => { setIsShowingOutput(!isShowingOutput); }} /> <div style={{ position: 'relative' }}>
<ToolbarButton icon={'terminal'} title={'Toggle output — ' + (isMac ? '⌃`' : 'Ctrl + `')} toggled={isShowingOutput} onClick={() => { setIsShowingOutput(!isShowingOutput); }} />
{outputContainsError && <div title='Output contains error' style={{ position: 'absolute', top: 2, right: 2, width: 7, height: 7, borderRadius: '50%', backgroundColor: 'var(--vscode-notificationsErrorIcon-foreground)' }} />}
</div>
{!hasBrowsers && <ToolbarButton icon='lightbulb-autofix' style={{ color: 'var(--vscode-list-warningForeground)' }} title='Playwright browsers are missing' onClick={openInstallDialog} />} {!hasBrowsers && <ToolbarButton icon='lightbulb-autofix' style={{ color: 'var(--vscode-list-warningForeground)' }} title='Playwright browsers are missing' onClick={openInstallDialog} />}
</Toolbar> </Toolbar>
<FiltersView <FiltersView

View file

@ -20,11 +20,11 @@ import { ActionList } from './actionList';
import { CallTab } from './callTab'; import { CallTab } from './callTab';
import { LogTab } from './logTab'; import { LogTab } from './logTab';
import { ErrorsTab, useErrorsTabModel } from './errorsTab'; import { ErrorsTab, useErrorsTabModel } from './errorsTab';
import type { ErrorDescription } from './errorsTab';
import type { ConsoleEntry } from './consoleTab'; import type { ConsoleEntry } from './consoleTab';
import { ConsoleTab, useConsoleTabModel } from './consoleTab'; import { ConsoleTab, useConsoleTabModel } from './consoleTab';
import type * as modelUtil from './modelUtil'; import type * as modelUtil from './modelUtil';
import { isRouteAction } from './modelUtil'; import { isRouteAction } from './modelUtil';
import type { StackFrame } from '@protocol/channels';
import { NetworkTab, useNetworkTabModel } from './networkTab'; import { NetworkTab, useNetworkTabModel } from './networkTab';
import { SnapshotTab } from './snapshotTab'; import { SnapshotTab } from './snapshotTab';
import { SourceTab } from './sourceTab'; import { SourceTab } from './sourceTab';
@ -49,8 +49,6 @@ export const Workbench: React.FunctionComponent<{
showSourcesFirst?: boolean, showSourcesFirst?: boolean,
rootDir?: string, rootDir?: string,
fallbackLocation?: modelUtil.SourceLocation, fallbackLocation?: modelUtil.SourceLocation,
initialSelection?: modelUtil.ActionTraceEventInContext,
onSelectionChanged?: (action: modelUtil.ActionTraceEventInContext) => void,
isLive?: boolean, isLive?: boolean,
status?: UITestStatus, status?: UITestStatus,
annotations?: { type: string; description?: string; }[]; annotations?: { type: string; description?: string; }[];
@ -59,9 +57,10 @@ export const Workbench: React.FunctionComponent<{
onOpenExternally?: (location: modelUtil.SourceLocation) => void, onOpenExternally?: (location: modelUtil.SourceLocation) => void,
revealSource?: boolean, revealSource?: boolean,
showSettings?: boolean, showSettings?: boolean,
}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => { }> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => {
const [selectedAction, setSelectedActionImpl] = React.useState<modelUtil.ActionTraceEventInContext | undefined>(undefined); const [selectedCallId, setSelectedCallId] = React.useState<string | undefined>(undefined);
const [revealedStack, setRevealedStack] = React.useState<StackFrame[] | undefined>(undefined); const [revealedError, setRevealedError] = React.useState<ErrorDescription | undefined>(undefined);
const [highlightedAction, setHighlightedAction] = React.useState<modelUtil.ActionTraceEventInContext | undefined>(); const [highlightedAction, setHighlightedAction] = React.useState<modelUtil.ActionTraceEventInContext | undefined>();
const [highlightedEntry, setHighlightedEntry] = React.useState<Entry | undefined>(); const [highlightedEntry, setHighlightedEntry] = React.useState<Entry | undefined>();
const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState<ConsoleEntry | undefined>(); const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState<ConsoleEntry | undefined>();
@ -69,38 +68,39 @@ export const Workbench: React.FunctionComponent<{
const [selectedPropertiesTab, setSelectedPropertiesTab] = useSetting<string>('propertiesTab', showSourcesFirst ? 'source' : 'call'); const [selectedPropertiesTab, setSelectedPropertiesTab] = useSetting<string>('propertiesTab', showSourcesFirst ? 'source' : 'call');
const [isInspecting, setIsInspectingState] = React.useState(false); const [isInspecting, setIsInspectingState] = React.useState(false);
const [highlightedLocator, setHighlightedLocator] = React.useState<string>(''); const [highlightedLocator, setHighlightedLocator] = React.useState<string>('');
const activeAction = model ? highlightedAction || selectedAction : undefined;
const [selectedTime, setSelectedTime] = React.useState<Boundaries | undefined>(); const [selectedTime, setSelectedTime] = React.useState<Boundaries | undefined>();
const [sidebarLocation, setSidebarLocation] = useSetting<'bottom' | 'right'>('propertiesSidebarLocation', 'bottom'); const [sidebarLocation, setSidebarLocation] = useSetting<'bottom' | 'right'>('propertiesSidebarLocation', 'bottom');
const [showRouteActions, setShowRouteActions] = useSetting('show-route-actions', true); const [showRouteActions, setShowRouteActions] = useSetting('show-route-actions', true);
const [showScreenshot, setShowScreenshot] = useSetting('screenshot-instead-of-snapshot', false); const [showScreenshot, setShowScreenshot] = useSetting('screenshot-instead-of-snapshot', false);
const filteredActions = React.useMemo(() => { const filteredActions = React.useMemo(() => {
return (model?.actions || []).filter(action => showRouteActions || !isRouteAction(action)); return (model?.actions || []).filter(action => showRouteActions || !isRouteAction(action));
}, [model, showRouteActions]); }, [model, showRouteActions]);
const setSelectedAction = React.useCallback((action: modelUtil.ActionTraceEventInContext | undefined) => { const setSelectedAction = React.useCallback((action: modelUtil.ActionTraceEventInContext | undefined) => {
setSelectedActionImpl(action); setSelectedCallId(action?.callId);
setRevealedStack(action?.stack); setRevealedError(undefined);
}, [setSelectedActionImpl, setRevealedStack]); }, []);
const sources = React.useMemo(() => model?.sources || new Map<string, modelUtil.SourceModel>(), [model]); const sources = React.useMemo(() => model?.sources || new Map<string, modelUtil.SourceModel>(), [model]);
React.useEffect(() => { React.useEffect(() => {
setSelectedTime(undefined); setSelectedTime(undefined);
setRevealedStack(undefined); setRevealedError(undefined);
}, [model]); }, [model]);
React.useEffect(() => { const selectedAction = React.useMemo(() => {
if (selectedAction && model?.actions.includes(selectedAction)) if (selectedCallId) {
return; const action = model?.actions.find(a => a.callId === selectedCallId);
if (action)
return action;
}
const failedAction = model?.failedAction(); const failedAction = model?.failedAction();
if (initialSelection && model?.actions.includes(initialSelection)) { if (failedAction)
setSelectedAction(initialSelection); return failedAction;
} else if (failedAction) {
setSelectedAction(failedAction); if (model?.actions.length) {
} else if (model?.actions.length) {
// Select the last non-after hooks item. // Select the last non-after hooks item.
let index = model.actions.length - 1; let index = model.actions.length - 1;
for (let i = 0; i < model.actions.length; ++i) { for (let i = 0; i < model.actions.length; ++i) {
@ -109,15 +109,24 @@ export const Workbench: React.FunctionComponent<{
break; break;
} }
} }
setSelectedAction(model.actions[index]); return model.actions[index];
} }
}, [model, selectedAction, setSelectedAction, initialSelection]); }, [model, selectedCallId]);
const revealedStack = React.useMemo(() => {
if (revealedError)
return revealedError.stack;
return selectedAction?.stack;
}, [selectedAction, revealedError]);
const activeAction = React.useMemo(() => {
return highlightedAction || selectedAction;
}, [selectedAction, highlightedAction]);
const onActionSelected = React.useCallback((action: modelUtil.ActionTraceEventInContext) => { const onActionSelected = React.useCallback((action: modelUtil.ActionTraceEventInContext) => {
setSelectedAction(action); setSelectedAction(action);
setHighlightedAction(undefined); setHighlightedAction(undefined);
onSelectionChanged?.(action); }, [setSelectedAction, setHighlightedAction]);
}, [setSelectedAction, onSelectionChanged, setHighlightedAction]);
const selectPropertiesTab = React.useCallback((tab: string) => { const selectPropertiesTab = React.useCallback((tab: string) => {
setSelectedPropertiesTab(tab); setSelectedPropertiesTab(tab);
@ -177,7 +186,7 @@ export const Workbench: React.FunctionComponent<{
if (error.action) if (error.action)
setSelectedAction(error.action); setSelectedAction(error.action);
else else
setRevealedStack(error.stack); setRevealedError(error);
selectPropertiesTab('source'); selectPropertiesTab('source');
}} /> }} />
}; };

View file

@ -45,6 +45,7 @@ export default defineConfig({
index: path.resolve(__dirname, 'index.html'), index: path.resolve(__dirname, 'index.html'),
uiMode: path.resolve(__dirname, 'uiMode.html'), uiMode: path.resolve(__dirname, 'uiMode.html'),
embedded: path.resolve(__dirname, 'embedded.html'), embedded: path.resolve(__dirname, 'embedded.html'),
recorder: path.resolve(__dirname, 'recorder.html'),
snapshot: path.resolve(__dirname, 'snapshot.html'), snapshot: path.resolve(__dirname, 'snapshot.html'),
}, },
output: { output: {

View file

@ -62,21 +62,21 @@ const test = baseTest.extend<BrowserTestTestFixtures, BrowserTestWorkerFixtures>
await run(playwright[browserName]); await run(playwright[browserName]);
}, { scope: 'worker' }], }, { scope: 'worker' }],
allowsThirdParty: [async ({ browserName, browserMajorVersion, channel }, run) => { allowsThirdParty: [async ({ browserName }, run) => {
if (browserName === 'firefox') if (browserName === 'firefox')
await run(true); await run(true);
else else
await run(false); await run(false);
}, { scope: 'worker' }], }, { scope: 'worker' }],
defaultSameSiteCookieValue: [async ({ browserName, browserMajorVersion, channel, isLinux }, run) => { defaultSameSiteCookieValue: [async ({ browserName, isLinux }, run) => {
if (browserName === 'chromium') if (browserName === 'chromium' || browserName as any === '_bidiChromium')
await run('Lax'); await run('Lax');
else if (browserName === 'webkit' && isLinux) else if (browserName === 'webkit' && isLinux)
await run('Lax'); await run('Lax');
else if (browserName === 'webkit' && !isLinux) else if (browserName === 'webkit' && !isLinux)
await run('None'); await run('None');
else if (browserName === 'firefox') else if (browserName === 'firefox' || browserName as any === '_bidiFirefox')
await run('None'); await run('None');
else else
throw new Error('unknown browser - ' + browserName); throw new Error('unknown browser - ' + browserName);

View file

@ -15,9 +15,11 @@
*/ */
import type { IncomingMessage } from 'http'; import type { IncomingMessage } from 'http';
import type { Socket } from 'net';
import type { ProxyServer } from '../third_party/proxy'; import type { ProxyServer } from '../third_party/proxy';
import { createProxy } from '../third_party/proxy'; import { createProxy } from '../third_party/proxy';
import net from 'net';
import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../../packages/playwright-core/src/common/socksProxy';
import { SocksProxy } from '../../packages/playwright-core/lib/common/socksProxy';
export class TestProxy { export class TestProxy {
readonly PORT: number; readonly PORT: number;
@ -27,7 +29,7 @@ export class TestProxy {
requestUrls: string[] = []; requestUrls: string[] = [];
private readonly _server: ProxyServer; private readonly _server: ProxyServer;
private readonly _sockets = new Set<Socket>(); private readonly _sockets = new Set<net.Socket>();
private _handlers: { event: string, handler: (...args: any[]) => void }[] = []; private _handlers: { event: string, handler: (...args: any[]) => void }[] = [];
static async create(port: number): Promise<TestProxy> { static async create(port: number): Promise<TestProxy> {
@ -90,7 +92,7 @@ export class TestProxy {
this._server.prependListener(event, handler); this._server.prependListener(event, handler);
} }
private _onSocket(socket: Socket) { private _onSocket(socket: net.Socket) {
this._sockets.add(socket); this._sockets.add(socket);
// ECONNRESET and HPE_INVALID_EOF_STATE are legit errors given // ECONNRESET and HPE_INVALID_EOF_STATE are legit errors given
// that tab closing aborts outgoing connections to the server. // that tab closing aborts outgoing connections to the server.
@ -100,5 +102,46 @@ export class TestProxy {
}); });
socket.once('close', () => this._sockets.delete(socket)); socket.once('close', () => this._sockets.delete(socket));
} }
}
export async function setupSocksForwardingServer({
port, forwardPort, allowedTargetPort
}: {
port: number, forwardPort: number, allowedTargetPort: number
}) {
const connectHosts = [];
const connections = new Map<string, net.Socket>();
const socksProxy = new SocksProxy();
socksProxy.setPattern('*');
socksProxy.addListener(SocksProxy.Events.SocksRequested, async (payload: SocksSocketRequestedPayload) => {
if (!['127.0.0.1', 'fake-localhost-127-0-0-1.nip.io', 'localhost'].includes(payload.host) || payload.port !== allowedTargetPort) {
socksProxy.sendSocketError({ uid: payload.uid, error: 'ECONNREFUSED' });
return;
}
const target = new net.Socket();
target.on('error', error => socksProxy.sendSocketError({ uid: payload.uid, error: error.toString() }));
target.on('end', () => socksProxy.sendSocketEnd({ uid: payload.uid }));
target.on('data', data => socksProxy.sendSocketData({ uid: payload.uid, data }));
target.setKeepAlive(false);
target.connect(forwardPort, '127.0.0.1');
target.on('connect', () => {
connections.set(payload.uid, target);
if (!connectHosts.includes(`${payload.host}:${payload.port}`))
connectHosts.push(`${payload.host}:${payload.port}`);
socksProxy.socketConnected({ uid: payload.uid, host: target.localAddress, port: target.localPort });
});
});
socksProxy.addListener(SocksProxy.Events.SocksData, async (payload: SocksSocketDataPayload) => {
connections.get(payload.uid)?.write(payload.data);
});
socksProxy.addListener(SocksProxy.Events.SocksClosed, (payload: SocksSocketClosedPayload) => {
connections.get(payload.uid)?.destroy();
connections.delete(payload.uid);
});
await socksProxy.listen(port, 'localhost');
return {
closeProxyServer: () => socksProxy.close(),
proxyServerAddr: `socks5://localhost:${port}`,
connectHosts,
};
} }

View file

@ -960,6 +960,22 @@ it('should support application/x-www-form-urlencoded', async function({ context,
expect(params.get('file')).toBe('f.js'); expect(params.get('file')).toBe('f.js');
}); });
it('should support application/x-www-form-urlencoded with param lists', async function({ context, page, server }) {
const form = new FormData();
form.append('foo', '1');
form.append('foo', '2');
const [req] = await Promise.all([
server.waitForRequest('/empty.html'),
context.request.post(server.EMPTY_PAGE, { form })
]);
expect(req.method).toBe('POST');
expect(req.headers['content-type']).toBe('application/x-www-form-urlencoded');
const body = (await req.postBody).toString('utf8');
const params = new URLSearchParams(body);
expect(req.headers['content-length']).toBe(String(params.toString().length));
expect(params.getAll('foo')).toEqual(['1', '2']);
});
it('should encode to application/json by default', async function({ context, page, server }) { it('should encode to application/json by default', async function({ context, page, server }) {
const data = { const data = {
firstName: 'John', firstName: 'John',

View file

@ -25,10 +25,6 @@ it.beforeEach(({ server }) => {
}); });
it('should work when passing the proxy only on the context level', async ({ browserName, platform, browserType, server, proxyServer }) => { it('should work when passing the proxy only on the context level', async ({ browserName, platform, browserType, server, proxyServer }) => {
// Currently an upstream bug in the network stack of Chromium which leads that
// the wrong proxy gets used in the BrowserContext.
it.fixme(browserName === 'chromium' && platform === 'win32');
proxyServer.forwardTo(server.PORT); proxyServer.forwardTo(server.PORT);
let browser; let browser;
try { try {

View file

@ -23,6 +23,7 @@ import type http from 'http';
import { expect, playwrightTest as base } from '../config/browserTest'; import { expect, playwrightTest as base } from '../config/browserTest';
import type net from 'net'; import type net from 'net';
import type { BrowserContextOptions } from 'packages/playwright-test'; import type { BrowserContextOptions } from 'packages/playwright-test';
import { setupSocksForwardingServer } from '../config/proxy';
const { createHttpsServer, createHttp2Server } = require('../../packages/playwright-core/lib/utils'); const { createHttpsServer, createHttp2Server } = require('../../packages/playwright-core/lib/utils');
type TestOptions = { type TestOptions = {
@ -76,15 +77,6 @@ const test = base.extend<TestOptions>({
}, },
}); });
test.use({
launchOptions: async ({ launchOptions }, use) => {
await use({
...launchOptions,
proxy: { server: 'per-context' }
});
}
});
const kDummyFileName = __filename; const kDummyFileName = __filename;
const kValidationSubTests: [BrowserContextOptions, string][] = [ const kValidationSubTests: [BrowserContextOptions, string][] = [
[{ clientCertificates: [{ origin: 'test' }] }, 'None of cert, key, passphrase or pfx is specified'], [{ clientCertificates: [{ origin: 'test' }] }, 'None of cert, key, passphrase or pfx is specified'],
@ -97,14 +89,6 @@ const kValidationSubTests: [BrowserContextOptions, string][] = [
passphrase: kDummyFileName, passphrase: kDummyFileName,
}] }]
}, 'pfx is specified together with cert, key or passphrase'], }, 'pfx is specified together with cert, key or passphrase'],
[{
proxy: { server: 'http://localhost:8080' },
clientCertificates: [{
origin: 'test',
certPath: kDummyFileName,
keyPath: kDummyFileName,
}]
}, 'Cannot specify both proxy and clientCertificates'],
]; ];
test.describe('fetch', () => { test.describe('fetch', () => {
@ -189,6 +173,54 @@ test.describe('fetch', () => {
await request.dispose(); await request.dispose();
}); });
test('pass with trusted client certificates and when a http proxy is used', async ({ playwright, startCCServer, proxyServer, asset }) => {
const serverURL = await startCCServer();
proxyServer.forwardTo(parseInt(new URL(serverURL).port, 10), { allowConnectRequests: true });
const request = await playwright.request.newContext({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/trusted/cert.pem'),
keyPath: asset('client-certificates/client/trusted/key.pem'),
}],
proxy: { server: `localhost:${proxyServer.PORT}` }
});
expect(proxyServer.connectHosts).toEqual([]);
const response = await request.get(serverURL);
expect(proxyServer.connectHosts).toEqual([new URL(serverURL).host]);
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('pass with trusted client certificates and when a socks proxy is used', async ({ playwright, startCCServer, asset }) => {
const serverURL = await startCCServer({ host: '127.0.0.1' });
const serverPort = parseInt(new URL(serverURL).port, 10);
const { proxyServerAddr, closeProxyServer, connectHosts } = await setupSocksForwardingServer({
port: test.info().workerIndex + 2048 + 2,
forwardPort: serverPort,
allowedTargetPort: serverPort,
});
const request = await playwright.request.newContext({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/trusted/cert.pem'),
keyPath: asset('client-certificates/client/trusted/key.pem'),
}],
proxy: { server: proxyServerAddr }
});
expect(connectHosts).toEqual([]);
const response = await request.get(serverURL);
expect(connectHosts).toEqual([new URL(serverURL).host]);
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();
await closeProxyServer();
});
test('should throw a http error if the pfx passphrase is incorect', async ({ playwright, startCCServer, asset, browserName }) => { test('should throw a http error if the pfx passphrase is incorect', async ({ playwright, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer(); const serverURL = await startCCServer();
const request = await playwright.request.newContext({ const request = await playwright.request.newContext({
@ -320,6 +352,50 @@ test.describe('browser', () => {
await page.close(); await page.close();
}); });
test('should pass with matching certificates and when a http proxy is used', async ({ browser, startCCServer, asset, browserName, proxyServer }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
proxyServer.forwardTo(parseInt(new URL(serverURL).port, 10), { allowConnectRequests: true });
const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/trusted/cert.pem'),
keyPath: asset('client-certificates/client/trusted/key.pem'),
}],
proxy: { server: `localhost:${proxyServer.PORT}` }
});
expect(proxyServer.connectHosts).toEqual([]);
await page.goto(serverURL);
expect([...new Set(proxyServer.connectHosts)]).toEqual([`localhost:${new URL(serverURL).port}`]);
await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!');
await page.close();
});
test('should pass with matching certificates and when a socks proxy is used', async ({ browser, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin', host: '127.0.0.1' });
const serverPort = parseInt(new URL(serverURL).port, 10);
const { proxyServerAddr, closeProxyServer, connectHosts } = await setupSocksForwardingServer({
port: test.info().workerIndex + 2048 + 2,
forwardPort: serverPort,
allowedTargetPort: serverPort,
});
const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/trusted/cert.pem'),
keyPath: asset('client-certificates/client/trusted/key.pem'),
}],
proxy: { server: proxyServerAddr }
});
expect(connectHosts).toEqual([]);
await page.goto(serverURL);
expect(connectHosts).toEqual([`localhost:${serverPort}`]);
await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!');
await page.close();
await closeProxyServer();
});
test('should not hang on tls errors during TLS 1.2 handshake', async ({ browser, asset, platform, browserName }) => { 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) { for (const tlsVersion of ['TLSv1.3', 'TLSv1.2'] as const) {
await test.step(`TLS version: ${tlsVersion}`, async () => { await test.step(`TLS version: ${tlsVersion}`, async () => {
@ -555,6 +631,7 @@ test.describe('browser', () => {
keyPath: asset('client-certificates/client/trusted/key.pem'), keyPath: asset('client-certificates/client/trusted/key.pem'),
}; };
const page = await browser.newPage({ const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{ clientCertificates: [{
origin: new URL(serverURL).origin, origin: new URL(serverURL).origin,
...baseOptions, ...baseOptions,

View file

@ -52,6 +52,46 @@ await page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).ClickAsync();`)
expect(message.text()).toBe('click'); expect(message.text()).toBe('click');
}); });
test('should double click', async ({ page, openRecorder }) => {
const recorder = await openRecorder();
await recorder.setContentAndWait(`<button onclick="console.log('click ' + event.detail)" ondblclick="console.log('dblclick ' + event.detail)">Submit</button>`);
const locator = await recorder.hoverOverElement('button');
expect(locator).toBe(`getByRole('button', { name: 'Submit' })`);
const messages: string[] = [];
page.on('console', message => {
if (message.text().includes('click'))
messages.push(message.text());
});
const [, sources] = await Promise.all([
page.waitForEvent('console', msg => msg.type() !== 'error' && msg.text() === 'dblclick 2'),
recorder.waitForOutput('JavaScript', 'dblclick'),
recorder.trustedDblclick(),
]);
expect.soft(sources.get('JavaScript')!.text).toContain(`
await page.getByRole('button', { name: 'Submit' }).dblclick();`);
expect.soft(sources.get('Python')!.text).toContain(`
page.get_by_role("button", name="Submit").dblclick()`);
expect.soft(sources.get('Python Async')!.text).toContain(`
await page.get_by_role("button", name="Submit").dblclick()`);
expect.soft(sources.get('Java')!.text).toContain(`
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Submit")).dblclick()`);
expect.soft(sources.get('C#')!.text).toContain(`
await page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).DblClickAsync();`);
expect(messages).toEqual([
'click 1',
'click 2',
'dblclick 2',
]);
});
test('should ignore programmatic events', async ({ page, openRecorder }) => { test('should ignore programmatic events', async ({ page, openRecorder }) => {
const recorder = await openRecorder(); const recorder = await openRecorder();
@ -666,7 +706,7 @@ var page1 = await page.RunAndWaitForPopupAsync(async () =>
expect(popup.url()).toBe('about:blank'); expect(popup.url()).toBe('about:blank');
}); });
test('should assert navigation', async ({ page, openRecorder }) => { test('should attribute navigation to click', async ({ page, openRecorder }) => {
const recorder = await openRecorder(); const recorder = await openRecorder();
await recorder.setContentAndWait(`<a onclick="window.location.href='about:blank#foo'">link</a>`); await recorder.setContentAndWait(`<a onclick="window.location.href='about:blank#foo'">link</a>`);
@ -680,24 +720,42 @@ var page1 = await page.RunAndWaitForPopupAsync(async () =>
]); ]);
expect.soft(sources.get('JavaScript')!.text).toContain(` expect.soft(sources.get('JavaScript')!.text).toContain(`
await page.getByText('link').click();`); await page.goto('about:blank');
await page.getByText('link').click();
// ---------------------
await context.close();`);
expect.soft(sources.get('Playwright Test')!.text).toContain(` expect.soft(sources.get('Playwright Test')!.text).toContain(`
await page.getByText('link').click();`); await page.goto('about:blank');
await page.getByText('link').click();
});`);
expect.soft(sources.get('Java')!.text).toContain(` expect.soft(sources.get('Java')!.text).toContain(`
page.getByText("link").click();`); page.navigate(\"about:blank\");
page.getByText(\"link\").click();
}`);
expect.soft(sources.get('Python')!.text).toContain(` expect.soft(sources.get('Python')!.text).toContain(`
page.get_by_text("link").click()`); page.goto("about:blank")
page.get_by_text("link").click()
# ---------------------
context.close()`);
expect.soft(sources.get('Python Async')!.text).toContain(` expect.soft(sources.get('Python Async')!.text).toContain(`
await page.get_by_text("link").click()`); await page.goto("about:blank")
await page.get_by_text("link").click()
# ---------------------
await context.close()`);
expect.soft(sources.get('Pytest')!.text).toContain(` expect.soft(sources.get('Pytest')!.text).toContain(`
page.goto("about:blank")
page.get_by_text("link").click()`); page.get_by_text("link").click()`);
expect.soft(sources.get('C#')!.text).toContain(` expect.soft(sources.get('C#')!.text).toContain(`
await page.GotoAsync("about:blank");
await page.GetByText("link").ClickAsync();`); await page.GetByText("link").ClickAsync();`);
expect(page.url()).toContain('about:blank#foo'); expect(page.url()).toContain('about:blank#foo');

View file

@ -739,4 +739,21 @@ await page.GetByLabel("Coun\\"try").ClickAsync();`);
expect.soft(sources1.get('Java')!.text).toContain(`assertThat(page.getByRole(AriaRole.TEXTBOX)).isVisible()`); expect.soft(sources1.get('Java')!.text).toContain(`assertThat(page.getByRole(AriaRole.TEXTBOX)).isVisible()`);
expect.soft(sources1.get('C#')!.text).toContain(`await Expect(page.GetByRole(AriaRole.Textbox)).ToBeVisibleAsync()`); expect.soft(sources1.get('C#')!.text).toContain(`await Expect(page.GetByRole(AriaRole.Textbox)).ToBeVisibleAsync()`);
}); });
test('should keep toolbar visible even if webpage erases content in hydration', async ({ openRecorder }) => {
const recorder = await openRecorder();
const hydrate = () => {
setTimeout(() => {
document.documentElement.innerHTML = '<p>Post-Hydration Content</p>';
}, 500);
};
await recorder.setContentAndWait(`
<p>Pre-Hydration Content</p>
<script>(${hydrate})()</script>
`);
await expect(recorder.page.getByText('Post-Hydration Content')).toBeVisible();
await expect(recorder.page.locator('x-pw-glass')).toBeVisible();
});
}); });

View file

@ -191,6 +191,13 @@ class Recorder {
await this.page.mouse.up(options); await this.page.mouse.up(options);
} }
async trustedDblclick() {
await this.page.mouse.down();
await this.page.mouse.up();
await this.page.mouse.down({ clickCount: 2 });
await this.page.mouse.up();
}
async focusElement(selector: string): Promise<string> { async focusElement(selector: string): Promise<string> {
return this.waitForHighlight(() => this.page.focus(selector)); return this.waitForHighlight(() => this.page.focus(selector));
} }

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