Merge branch 'main' into watch-mode-stop-testrun
This commit is contained in:
commit
922a395def
4
.github/workflows/infra.yml
vendored
4
.github/workflows/infra.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
2
.github/workflows/tests_others.yml
vendored
2
.github/workflows/tests_others.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
6
.github/workflows/tests_secondary.yml
vendored
6
.github/workflows/tests_secondary.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
2
.github/workflows/tests_service.yml
vendored
2
.github/workflows/tests_service.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# 🎭 Playwright
|
# 🎭 Playwright
|
||||||
|
|
||||||
[](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[](https://webkit.org/)<!-- GEN:stop --> [](https://aka.ms/playwright/discord)
|
[](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[](https://webkit.org/)<!-- GEN:stop --> [](https://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: |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'];
|
||||||
|
|
|
||||||
|
|
@ -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-%%
|
||||||
|
|
|
||||||
|
|
@ -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**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
:::
|
:::
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ Playwright tests can be ran on any CI provider. In this section we will cover ru
|
||||||
|
|
||||||
#### You will learn
|
#### You will learn
|
||||||
* langs: python, java, csharp
|
* langs: python, java, csharp
|
||||||
|
|
||||||
- [How to set up GitHub Actions](/ci-intro.md#setting-up-github-actions)
|
- [How to set up GitHub Actions](/ci-intro.md#setting-up-github-actions)
|
||||||
- [How to view test logs](/ci-intro.md#viewing-test-logs)
|
- [How to view test logs](/ci-intro.md#viewing-test-logs)
|
||||||
- [How to view the trace](/ci-intro.md#viewing-the-trace)
|
- [How to view the trace](/ci-intro.md#viewing-the-trace)
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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:';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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:';
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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});`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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});`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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})`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 })) });
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,6 @@
|
||||||
../../utils/**
|
../../utils/**
|
||||||
../../utilsBundle.ts
|
../../utilsBundle.ts
|
||||||
../../zipBundle.ts
|
../../zipBundle.ts
|
||||||
|
|
||||||
|
[recorderInTraceViewer.ts]
|
||||||
|
../trace/viewer/traceViewer.ts
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 =>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
|
|
@ -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] }));
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
32
packages/playwright-core/types/types.d.ts
vendored
32
packages/playwright-core/types/types.d.ts
vendored
|
|
@ -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`.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
2
packages/playwright/types/test.d.ts
vendored
2
packages/playwright/types/test.d.ts
vendored
|
|
@ -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`.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -1430,6 +1430,8 @@ Page:
|
||||||
slowMo: true
|
slowMo: true
|
||||||
snapshot: true
|
snapshot: true
|
||||||
|
|
||||||
|
forceGarbageCollection:
|
||||||
|
|
||||||
registerLocatorHandler:
|
registerLocatorHandler:
|
||||||
parameters:
|
parameters:
|
||||||
selector: string
|
selector: string
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
28
packages/trace-viewer/recorder.html
Normal file
28
packages/trace-viewer/recorder.html
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<!--
|
||||||
|
Copyright (c) Microsoft Corporation.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
<!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>
|
||||||
41
packages/trace-viewer/src/recorder.tsx
Normal file
41
packages/trace-viewer/src/recorder.tsx
Normal 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 />);
|
||||||
|
})();
|
||||||
|
|
@ -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[];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
15
packages/trace-viewer/src/ui/recorderView.css
Normal file
15
packages/trace-viewer/src/ui/recorderView.css
Normal 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.
|
||||||
|
*/
|
||||||
168
packages/trace-viewer/src/ui/recorderView.tsx
Normal file
168
packages/trace-viewer/src/ui/recorderView.tsx
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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`);
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}} />
|
}} />
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue