Merge branch 'main' into tar-download-3rd-party-lib
This commit is contained in:
commit
30c5ec64c8
|
|
@ -115,7 +115,7 @@ module.exports = {
|
||||||
"@typescript-eslint/type-annotation-spacing": 2,
|
"@typescript-eslint/type-annotation-spacing": 2,
|
||||||
|
|
||||||
// file whitespace
|
// file whitespace
|
||||||
"no-multiple-empty-lines": [2, {"max": 2}],
|
"no-multiple-empty-lines": [2, {"max": 2, "maxEOF": 0}],
|
||||||
"no-mixed-spaces-and-tabs": 2,
|
"no-mixed-spaces-and-tabs": 2,
|
||||||
"no-trailing-spaces": 2,
|
"no-trailing-spaces": 2,
|
||||||
"linebreak-style": [ process.platform === "win32" ? 0 : 2, "unix" ],
|
"linebreak-style": [ process.platform === "win32" ? 0 : 2, "unix" ],
|
||||||
|
|
@ -123,6 +123,7 @@ module.exports = {
|
||||||
"key-spacing": [2, {
|
"key-spacing": [2, {
|
||||||
"beforeColon": false
|
"beforeColon": false
|
||||||
}],
|
}],
|
||||||
|
"eol-last": 2,
|
||||||
|
|
||||||
// copyright
|
// copyright
|
||||||
"notice/notice": [2, {
|
"notice/notice": [2, {
|
||||||
|
|
|
||||||
25
.github/workflows/tests_bidi.yml
vendored
25
.github/workflows/tests_bidi.yml
vendored
|
|
@ -7,6 +7,7 @@ on:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- .github/workflows/tests_bidi.yml
|
- .github/workflows/tests_bidi.yml
|
||||||
|
- packages/playwright-core/src/server/bidi/*
|
||||||
schedule:
|
schedule:
|
||||||
# Run every day at midnight
|
# Run every day at midnight
|
||||||
- cron: '0 0 * * *'
|
- cron: '0 0 * * *'
|
||||||
|
|
@ -43,3 +44,27 @@ jobs:
|
||||||
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run biditest -- --project=${{ matrix.channel }}*
|
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run biditest -- --project=${{ matrix.channel }}*
|
||||||
env:
|
env:
|
||||||
PWTEST_USE_BIDI_EXPECTATIONS: '1'
|
PWTEST_USE_BIDI_EXPECTATIONS: '1'
|
||||||
|
- name: Upload csv report to GitHub
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: csv-report-${{ matrix.channel }}
|
||||||
|
path: test-results/report.csv
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Azure Login
|
||||||
|
if: ${{ !cancelled() && github.ref == 'refs/heads/main' }}
|
||||||
|
uses: azure/login@v2
|
||||||
|
with:
|
||||||
|
client-id: ${{ secrets.AZURE_BLOB_REPORTS_CLIENT_ID }}
|
||||||
|
tenant-id: ${{ secrets.AZURE_BLOB_REPORTS_TENANT_ID }}
|
||||||
|
subscription-id: ${{ secrets.AZURE_BLOB_REPORTS_SUBSCRIPTION_ID }}
|
||||||
|
|
||||||
|
- name: Upload report.csv to Azure
|
||||||
|
if: ${{ !cancelled() && github.ref == 'refs/heads/main' }}
|
||||||
|
run: |
|
||||||
|
REPORT_DIR='bidi-reports'
|
||||||
|
azcopy cp "./test-results/report.csv" "https://mspwblobreport.blob.core.windows.net/\$web/$REPORT_DIR/${{ matrix.channel }}.csv"
|
||||||
|
echo "Report url: https://mspwblobreport.z1.web.core.windows.net/$REPORT_DIR/${{ matrix.channel }}.csv"
|
||||||
|
env:
|
||||||
|
AZCOPY_AUTO_LOGIN_TYPE: AZCLI
|
||||||
|
|
|
||||||
7
.github/workflows/tests_others.yml
vendored
7
.github/workflows/tests_others.yml
vendored
|
|
@ -147,6 +147,13 @@ jobs:
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup Ubuntu Binary Installation # TODO: Remove when https://github.com/electron/electron/issues/42510 is fixed
|
||||||
|
if: ${{ runner.os == 'Linux' }}
|
||||||
|
run: |
|
||||||
|
if grep -q "Ubuntu 24" /etc/os-release; then
|
||||||
|
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||||
|
fi
|
||||||
|
shell: bash
|
||||||
- uses: ./.github/actions/run-test
|
- uses: ./.github/actions/run-test
|
||||||
with:
|
with:
|
||||||
browsers-to-install: chromium
|
browsers-to-install: chromium
|
||||||
|
|
|
||||||
7
.github/workflows/tests_primary.yml
vendored
7
.github/workflows/tests_primary.yml
vendored
|
|
@ -215,6 +215,13 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- run: npm install -g yarn@1
|
- run: npm install -g yarn@1
|
||||||
- run: npm install -g pnpm@8
|
- run: npm install -g pnpm@8
|
||||||
|
- name: Setup Ubuntu Binary Installation # TODO: Remove when https://github.com/electron/electron/issues/42510 is fixed
|
||||||
|
if: ${{ runner.os == 'Linux' }}
|
||||||
|
run: |
|
||||||
|
if grep -q "Ubuntu 24" /etc/os-release; then
|
||||||
|
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||||
|
fi
|
||||||
|
shell: bash
|
||||||
- uses: ./.github/actions/run-test
|
- uses: ./.github/actions/run-test
|
||||||
with:
|
with:
|
||||||
command: npm run itest
|
command: npm run itest
|
||||||
|
|
|
||||||
7
.github/workflows/tests_secondary.yml
vendored
7
.github/workflows/tests_secondary.yml
vendored
|
|
@ -107,6 +107,13 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- run: npm install -g yarn@1
|
- run: npm install -g yarn@1
|
||||||
- run: npm install -g pnpm@8
|
- run: npm install -g pnpm@8
|
||||||
|
- name: Setup Ubuntu Binary Installation # TODO: Remove when https://github.com/electron/electron/issues/42510 is fixed
|
||||||
|
if: ${{ runner.os == 'Linux' }}
|
||||||
|
run: |
|
||||||
|
if grep -q "Ubuntu 24" /etc/os-release; then
|
||||||
|
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||||
|
fi
|
||||||
|
shell: bash
|
||||||
- uses: ./.github/actions/run-test
|
- uses: ./.github/actions/run-test
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node_version }}
|
node-version: ${{ matrix.node_version }}
|
||||||
|
|
|
||||||
|
|
@ -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,9 +8,9 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr
|
||||||
|
|
||||||
| | Linux | macOS | Windows |
|
| | Linux | macOS | Windows |
|
||||||
| :--- | :---: | :---: | :---: |
|
| :--- | :---: | :---: | :---: |
|
||||||
| Chromium <!-- GEN:chromium-version -->132.0.6834.46<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
| Chromium <!-- GEN:chromium-version -->132.0.6834.57<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||||
| WebKit <!-- GEN:webkit-version -->18.2<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
| WebKit <!-- GEN:webkit-version -->18.2<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||||
| Firefox <!-- GEN:firefox-version -->132.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
| Firefox <!-- GEN:firefox-version -->133.0.3<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||||
|
|
||||||
Headless execution is supported for all browsers on all platforms. Check out [system requirements](https://playwright.dev/docs/intro#system-requirements) for details.
|
Headless execution is supported for all browsers on all platforms. Check out [system requirements](https://playwright.dev/docs/intro#system-requirements) for details.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ Launches Chrome browser on the device, and returns its persistent context.
|
||||||
|
|
||||||
### option: AndroidDevice.launchBrowser.pkg
|
### option: AndroidDevice.launchBrowser.pkg
|
||||||
* since: v1.9
|
* since: v1.9
|
||||||
- `command` <[string]>
|
- `pkg` <[string]>
|
||||||
|
|
||||||
Optional package name to launch instead of default Chrome for Android.
|
Optional package name to launch instead of default Chrome for Android.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -633,13 +633,11 @@ properties:
|
||||||
You can also specify [JSHandle] as the property value if you want live objects to be passed into the event:
|
You can also specify [JSHandle] as the property value if you want live objects to be passed into the event:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// Note you can only create DataTransfer in Chromium and Firefox
|
|
||||||
const dataTransfer = await page.evaluateHandle(() => new DataTransfer());
|
const dataTransfer = await page.evaluateHandle(() => new DataTransfer());
|
||||||
await locator.dispatchEvent('dragstart', { dataTransfer });
|
await locator.dispatchEvent('dragstart', { dataTransfer });
|
||||||
```
|
```
|
||||||
|
|
||||||
```java
|
```java
|
||||||
// Note you can only create DataTransfer in Chromium and Firefox
|
|
||||||
JSHandle dataTransfer = page.evaluateHandle("() => new DataTransfer()");
|
JSHandle dataTransfer = page.evaluateHandle("() => new DataTransfer()");
|
||||||
Map<String, Object> arg = new HashMap<>();
|
Map<String, Object> arg = new HashMap<>();
|
||||||
arg.put("dataTransfer", dataTransfer);
|
arg.put("dataTransfer", dataTransfer);
|
||||||
|
|
@ -647,13 +645,11 @@ locator.dispatchEvent("dragstart", arg);
|
||||||
```
|
```
|
||||||
|
|
||||||
```python async
|
```python async
|
||||||
# note you can only create data_transfer in chromium and firefox
|
|
||||||
data_transfer = await page.evaluate_handle("new DataTransfer()")
|
data_transfer = await page.evaluate_handle("new DataTransfer()")
|
||||||
await locator.dispatch_event("#source", "dragstart", {"dataTransfer": data_transfer})
|
await locator.dispatch_event("#source", "dragstart", {"dataTransfer": data_transfer})
|
||||||
```
|
```
|
||||||
|
|
||||||
```python sync
|
```python sync
|
||||||
# note you can only create data_transfer in chromium and firefox
|
|
||||||
data_transfer = page.evaluate_handle("new DataTransfer()")
|
data_transfer = page.evaluate_handle("new DataTransfer()")
|
||||||
locator.dispatch_event("#source", "dragstart", {"dataTransfer": data_transfer})
|
locator.dispatch_event("#source", "dragstart", {"dataTransfer": data_transfer})
|
||||||
```
|
```
|
||||||
|
|
@ -1717,16 +1713,21 @@ var banana = await page.GetByRole(AriaRole.Listitem).Nth(2);
|
||||||
|
|
||||||
Creates a locator matching all elements that match one or both 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.
|
Note that when both locators match something, the resulting locator will have multiple matches, potentially causing a [locator strictness](../locators.md#strictness) violation.
|
||||||
|
|
||||||
**Usage**
|
**Usage**
|
||||||
|
|
||||||
Consider a scenario where you'd like to click on a "New email" button, but sometimes a security settings dialog shows up instead. In this case, you can wait for either a "New email" button, or a dialog and act accordingly.
|
Consider a scenario where you'd like to click on a "New email" button, but sometimes a security settings dialog shows up instead. In this case, you can wait for either a "New email" button, or a dialog and act accordingly.
|
||||||
|
|
||||||
|
:::note
|
||||||
|
If both "New email" button and security dialog appear on screen, the "or" locator will match both of them,
|
||||||
|
possibly throwing the ["strict mode violation" error](../locators.md#strictness). In this case, you can use [`method: Locator.first`] to only match one of them.
|
||||||
|
:::
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const newEmail = page.getByRole('button', { name: 'New' });
|
const newEmail = page.getByRole('button', { name: 'New' });
|
||||||
const dialog = page.getByText('Confirm security settings');
|
const dialog = page.getByText('Confirm security settings');
|
||||||
await expect(newEmail.or(dialog)).toBeVisible();
|
await expect(newEmail.or(dialog).first()).toBeVisible();
|
||||||
if (await dialog.isVisible())
|
if (await dialog.isVisible())
|
||||||
await page.getByRole('button', { name: 'Dismiss' }).click();
|
await page.getByRole('button', { name: 'Dismiss' }).click();
|
||||||
await newEmail.click();
|
await newEmail.click();
|
||||||
|
|
@ -1735,7 +1736,7 @@ await newEmail.click();
|
||||||
```java
|
```java
|
||||||
Locator newEmail = page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("New"));
|
Locator newEmail = page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("New"));
|
||||||
Locator dialog = page.getByText("Confirm security settings");
|
Locator dialog = page.getByText("Confirm security settings");
|
||||||
assertThat(newEmail.or(dialog)).isVisible();
|
assertThat(newEmail.or(dialog).first()).isVisible();
|
||||||
if (dialog.isVisible())
|
if (dialog.isVisible())
|
||||||
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Dismiss")).click();
|
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Dismiss")).click();
|
||||||
newEmail.click();
|
newEmail.click();
|
||||||
|
|
@ -1744,7 +1745,7 @@ newEmail.click();
|
||||||
```python async
|
```python async
|
||||||
new_email = page.get_by_role("button", name="New")
|
new_email = page.get_by_role("button", name="New")
|
||||||
dialog = page.get_by_text("Confirm security settings")
|
dialog = page.get_by_text("Confirm security settings")
|
||||||
await expect(new_email.or_(dialog)).to_be_visible()
|
await expect(new_email.or_(dialog).first).to_be_visible()
|
||||||
if (await dialog.is_visible()):
|
if (await dialog.is_visible()):
|
||||||
await page.get_by_role("button", name="Dismiss").click()
|
await page.get_by_role("button", name="Dismiss").click()
|
||||||
await new_email.click()
|
await new_email.click()
|
||||||
|
|
@ -1753,7 +1754,7 @@ await new_email.click()
|
||||||
```python sync
|
```python sync
|
||||||
new_email = page.get_by_role("button", name="New")
|
new_email = page.get_by_role("button", name="New")
|
||||||
dialog = page.get_by_text("Confirm security settings")
|
dialog = page.get_by_text("Confirm security settings")
|
||||||
expect(new_email.or_(dialog)).to_be_visible()
|
expect(new_email.or_(dialog).first).to_be_visible()
|
||||||
if (dialog.is_visible()):
|
if (dialog.is_visible()):
|
||||||
page.get_by_role("button", name="Dismiss").click()
|
page.get_by_role("button", name="Dismiss").click()
|
||||||
new_email.click()
|
new_email.click()
|
||||||
|
|
@ -1762,7 +1763,7 @@ new_email.click()
|
||||||
```csharp
|
```csharp
|
||||||
var newEmail = page.GetByRole(AriaRole.Button, new() { Name = "New" });
|
var newEmail = page.GetByRole(AriaRole.Button, new() { Name = "New" });
|
||||||
var dialog = page.GetByText("Confirm security settings");
|
var dialog = page.GetByText("Confirm security settings");
|
||||||
await Expect(newEmail.Or(dialog)).ToBeVisibleAsync();
|
await Expect(newEmail.Or(dialog).First).ToBeVisibleAsync();
|
||||||
if (await dialog.IsVisibleAsync())
|
if (await dialog.IsVisibleAsync())
|
||||||
await page.GetByRole(AriaRole.Button, new() { Name = "Dismiss" }).ClickAsync();
|
await page.GetByRole(AriaRole.Button, new() { Name = "Dismiss" }).ClickAsync();
|
||||||
await newEmail.ClickAsync();
|
await newEmail.ClickAsync();
|
||||||
|
|
|
||||||
|
|
@ -541,6 +541,16 @@ await Expect(locator).ToBeCheckedAsync();
|
||||||
* since: v1.18
|
* since: v1.18
|
||||||
- `checked` <[boolean]>
|
- `checked` <[boolean]>
|
||||||
|
|
||||||
|
Provides state to assert for. Asserts for input to be checked by default.
|
||||||
|
This option can't be used when [`option: LocatorAssertions.toBeChecked.indeterminate`] is set to true.
|
||||||
|
|
||||||
|
### option: LocatorAssertions.toBeChecked.indeterminate
|
||||||
|
* since: v1.50
|
||||||
|
- `indeterminate` <[boolean]>
|
||||||
|
|
||||||
|
Asserts that the element is in the indeterminate (mixed) state. Only supported for checkboxes and radio buttons.
|
||||||
|
This option can't be true when [`option: LocatorAssertions.toBeChecked.checked`] is provided.
|
||||||
|
|
||||||
### option: LocatorAssertions.toBeChecked.timeout = %%-js-assertions-timeout-%%
|
### option: LocatorAssertions.toBeChecked.timeout = %%-js-assertions-timeout-%%
|
||||||
* since: v1.18
|
* since: v1.18
|
||||||
|
|
||||||
|
|
@ -1217,6 +1227,56 @@ Expected accessible description.
|
||||||
* since: v1.44
|
* since: v1.44
|
||||||
|
|
||||||
|
|
||||||
|
## async method: LocatorAssertions.toHaveAccessibleErrorMessage
|
||||||
|
* since: v1.50
|
||||||
|
* langs:
|
||||||
|
- alias-java: hasAccessibleErrorMessage
|
||||||
|
|
||||||
|
Ensures the [Locator] points to an element with a given [aria errormessage](https://w3c.github.io/aria/#aria-errormessage).
|
||||||
|
|
||||||
|
**Usage**
|
||||||
|
|
||||||
|
```js
|
||||||
|
const locator = page.getByTestId('username-input');
|
||||||
|
await expect(locator).toHaveAccessibleErrorMessage('Username is required.');
|
||||||
|
```
|
||||||
|
|
||||||
|
```java
|
||||||
|
Locator locator = page.getByTestId("username-input");
|
||||||
|
assertThat(locator).hasAccessibleErrorMessage("Username is required.");
|
||||||
|
```
|
||||||
|
|
||||||
|
```python async
|
||||||
|
locator = page.get_by_test_id("username-input")
|
||||||
|
await expect(locator).to_have_accessible_error_message("Username is required.")
|
||||||
|
```
|
||||||
|
|
||||||
|
```python sync
|
||||||
|
locator = page.get_by_test_id("username-input")
|
||||||
|
expect(locator).to_have_accessible_error_message("Username is required.")
|
||||||
|
```
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var locator = Page.GetByTestId("username-input");
|
||||||
|
await Expect(locator).ToHaveAccessibleErrorMessageAsync("Username is required.");
|
||||||
|
```
|
||||||
|
|
||||||
|
### param: LocatorAssertions.toHaveAccessibleErrorMessage.errorMessage
|
||||||
|
* since: v1.50
|
||||||
|
- `errorMessage` <[string]|[RegExp]>
|
||||||
|
|
||||||
|
Expected accessible error message.
|
||||||
|
|
||||||
|
### option: LocatorAssertions.toHaveAccessibleErrorMessage.timeout = %%-js-assertions-timeout-%%
|
||||||
|
* since: v1.50
|
||||||
|
|
||||||
|
### option: LocatorAssertions.toHaveAccessibleErrorMessage.timeout = %%-csharp-java-python-assertions-timeout-%%
|
||||||
|
* since: v1.50
|
||||||
|
|
||||||
|
### option: LocatorAssertions.toHaveAccessibleErrorMessage.ignoreCase = %%-assertions-ignore-case-%%
|
||||||
|
* since: v1.50
|
||||||
|
|
||||||
|
|
||||||
## async method: LocatorAssertions.toHaveAccessibleName
|
## async method: LocatorAssertions.toHaveAccessibleName
|
||||||
* since: v1.44
|
* since: v1.44
|
||||||
* langs:
|
* langs:
|
||||||
|
|
|
||||||
|
|
@ -1003,7 +1003,7 @@ Additional arguments to pass to the browser instance. The list of Chromium flags
|
||||||
|
|
||||||
Browser distribution channel.
|
Browser distribution channel.
|
||||||
|
|
||||||
Use "chromium" to [opt in to new headless mode](../browsers.md#opt-in-to-new-headless-mode).
|
Use "chromium" to [opt in to new headless mode](../browsers.md#chromium-new-headless-mode).
|
||||||
|
|
||||||
Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or "msedge-canary" to use branded [Google Chrome and Microsoft Edge](../browsers.md#google-chrome--microsoft-edge).
|
Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or "msedge-canary" to use branded [Google Chrome and Microsoft Edge](../browsers.md#google-chrome--microsoft-edge).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -338,11 +338,11 @@ dotnet test --settings:webkit.runsettings
|
||||||
|
|
||||||
For Google Chrome, Microsoft Edge and other Chromium-based browsers, by default, Playwright uses open source Chromium builds. Since the Chromium project is ahead of the branded browsers, when the world is on Google Chrome N, Playwright already supports Chromium N+1 that will be released in Google Chrome and Microsoft Edge a few weeks later.
|
For Google Chrome, Microsoft Edge and other Chromium-based browsers, by default, Playwright uses open source Chromium builds. Since the Chromium project is ahead of the branded browsers, when the world is on Google Chrome N, Playwright already supports Chromium N+1 that will be released in Google Chrome and Microsoft Edge a few weeks later.
|
||||||
|
|
||||||
Playwright ships a regular Chromium build for headed operations and a separate [chromium headless shell](https://developer.chrome.com/blog/chrome-headless-shell) for headless mode. See [issue #33566](https://github.com/microsoft/playwright/issues/33566) for details.
|
### Chromium: headless shell
|
||||||
|
|
||||||
#### Optimize download size on CI
|
Playwright ships a regular Chromium build for headed operations and a separate [chromium headless shell](https://developer.chrome.com/blog/chrome-headless-shell) for headless mode.
|
||||||
|
|
||||||
If you are only running tests in headless shell (i.e. the `channel` option is not specified), for example on CI, you can avoid downloading the full Chromium browser by passing `--only-shell` during installation.
|
If you are only running tests in headless shell (i.e. the `channel` option is **not** specified), for example on CI, you can avoid downloading the full Chromium browser by passing `--only-shell` during installation.
|
||||||
|
|
||||||
```bash js
|
```bash js
|
||||||
# only running tests headlessly
|
# only running tests headlessly
|
||||||
|
|
@ -364,7 +364,7 @@ playwright install --with-deps --only-shell
|
||||||
pwsh bin/Debug/netX/playwright.ps1 install --with-deps --only-shell
|
pwsh bin/Debug/netX/playwright.ps1 install --with-deps --only-shell
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Opt-in to new headless mode
|
### Chromium: new headless mode
|
||||||
|
|
||||||
You can opt into the new headless mode by using `'chromium'` channel. As [official Chrome documentation puts it](https://developer.chrome.com/blog/chrome-headless-shell):
|
You can opt into the new headless mode by using `'chromium'` channel. As [official Chrome documentation puts it](https://developer.chrome.com/blog/chrome-headless-shell):
|
||||||
|
|
||||||
|
|
@ -419,6 +419,28 @@ pytest test_login.py --browser-channel chromium
|
||||||
dotnet test -- Playwright.BrowserName=chromium Playwright.LaunchOptions.Channel=chromium
|
dotnet test -- Playwright.BrowserName=chromium Playwright.LaunchOptions.Channel=chromium
|
||||||
```
|
```
|
||||||
|
|
||||||
|
With the new headless mode, you can skip downloading the headless shell during browser installation by using the `--no-shell` option:
|
||||||
|
|
||||||
|
```bash js
|
||||||
|
# only running tests headlessly
|
||||||
|
npx playwright install --with-deps --no-shell
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash java
|
||||||
|
# only running tests headlessly
|
||||||
|
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install --with-deps --no-shell"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash python
|
||||||
|
# only running tests headlessly
|
||||||
|
playwright install --with-deps --no-shell
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash csharp
|
||||||
|
# only running tests headlessly
|
||||||
|
pwsh bin/Debug/netX/playwright.ps1 install --with-deps --no-shell
|
||||||
|
```
|
||||||
|
|
||||||
### Google Chrome & Microsoft Edge
|
### Google Chrome & Microsoft Edge
|
||||||
|
|
||||||
While Playwright can download and use the recent Chromium build, it can operate against the branded Google Chrome and Microsoft Edge browsers available on the machine (note that Playwright doesn't install them by default). In particular, the current Playwright version will support Stable and Beta channels of these browsers.
|
While Playwright can download and use the recent Chromium build, it can operate against the branded Google Chrome and Microsoft Edge browsers available on the machine (note that Playwright doesn't install them by default). In particular, the current Playwright version will support Stable and Beta channels of these browsers.
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ title: "Chrome extensions"
|
||||||
Extensions only work in Chrome / Chromium launched with a persistent context. Use custom browser args at your own risk, as some of them may break Playwright functionality.
|
Extensions only work in Chrome / Chromium launched with a persistent context. Use custom browser args at your own risk, as some of them may break Playwright functionality.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
The following is code for getting a handle to the [background page](https://developer.chrome.com/extensions/background_pages) of a [Manifest v2](https://developer.chrome.com/docs/extensions/mv2/) extension whose source is located in `./my-extension`:
|
The snippet below retrieves the [background page](https://developer.chrome.com/extensions/background_pages) of a [Manifest v2](https://developer.chrome.com/docs/extensions/mv2/) extension whose source is located in `./my-extension`.
|
||||||
|
|
||||||
|
Note the use of the `chromium` channel that allows to run extensions in headless mode. Alternatively, you can launch the browser in headed mode.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const { chromium } = require('playwright');
|
const { chromium } = require('playwright');
|
||||||
|
|
@ -18,7 +20,7 @@ const { chromium } = require('playwright');
|
||||||
const pathToExtension = require('path').join(__dirname, 'my-extension');
|
const pathToExtension = require('path').join(__dirname, 'my-extension');
|
||||||
const userDataDir = '/tmp/test-user-data-dir';
|
const userDataDir = '/tmp/test-user-data-dir';
|
||||||
const browserContext = await chromium.launchPersistentContext(userDataDir, {
|
const browserContext = await chromium.launchPersistentContext(userDataDir, {
|
||||||
headless: false,
|
channel: 'chromium',
|
||||||
args: [
|
args: [
|
||||||
`--disable-extensions-except=${pathToExtension}`,
|
`--disable-extensions-except=${pathToExtension}`,
|
||||||
`--load-extension=${pathToExtension}`
|
`--load-extension=${pathToExtension}`
|
||||||
|
|
@ -44,7 +46,7 @@ user_data_dir = "/tmp/test-user-data-dir"
|
||||||
async def run(playwright: Playwright):
|
async def run(playwright: Playwright):
|
||||||
context = await playwright.chromium.launch_persistent_context(
|
context = await playwright.chromium.launch_persistent_context(
|
||||||
user_data_dir,
|
user_data_dir,
|
||||||
headless=False,
|
channel="chromium",
|
||||||
args=[
|
args=[
|
||||||
f"--disable-extensions-except={path_to_extension}",
|
f"--disable-extensions-except={path_to_extension}",
|
||||||
f"--load-extension={path_to_extension}",
|
f"--load-extension={path_to_extension}",
|
||||||
|
|
@ -78,7 +80,7 @@ user_data_dir = "/tmp/test-user-data-dir"
|
||||||
def run(playwright: Playwright):
|
def run(playwright: Playwright):
|
||||||
context = playwright.chromium.launch_persistent_context(
|
context = playwright.chromium.launch_persistent_context(
|
||||||
user_data_dir,
|
user_data_dir,
|
||||||
headless=False,
|
channel="chromium",
|
||||||
args=[
|
args=[
|
||||||
f"--disable-extensions-except={path_to_extension}",
|
f"--disable-extensions-except={path_to_extension}",
|
||||||
f"--load-extension={path_to_extension}",
|
f"--load-extension={path_to_extension}",
|
||||||
|
|
@ -101,6 +103,8 @@ with sync_playwright() as playwright:
|
||||||
|
|
||||||
To have the extension loaded when running tests you can use a test fixture to set the context. You can also dynamically retrieve the extension id and use it to load and test the popup page for example.
|
To have the extension loaded when running tests you can use a test fixture to set the context. You can also dynamically retrieve the extension id and use it to load and test the popup page for example.
|
||||||
|
|
||||||
|
Note the use of the `chromium` channel that allows to run extensions in headless mode. Alternatively, you can launch the browser in headed mode.
|
||||||
|
|
||||||
First, add fixtures that will load the extension:
|
First, add fixtures that will load the extension:
|
||||||
|
|
||||||
```js title="fixtures.ts"
|
```js title="fixtures.ts"
|
||||||
|
|
@ -114,7 +118,7 @@ export const test = base.extend<{
|
||||||
context: async ({ }, use) => {
|
context: async ({ }, use) => {
|
||||||
const pathToExtension = path.join(__dirname, 'my-extension');
|
const pathToExtension = path.join(__dirname, 'my-extension');
|
||||||
const context = await chromium.launchPersistentContext('', {
|
const context = await chromium.launchPersistentContext('', {
|
||||||
headless: false,
|
channel: 'chromium',
|
||||||
args: [
|
args: [
|
||||||
`--disable-extensions-except=${pathToExtension}`,
|
`--disable-extensions-except=${pathToExtension}`,
|
||||||
`--load-extension=${pathToExtension}`,
|
`--load-extension=${pathToExtension}`,
|
||||||
|
|
@ -155,7 +159,7 @@ def context(playwright: Playwright) -> Generator[BrowserContext, None, None]:
|
||||||
path_to_extension = Path(__file__).parent.joinpath("my-extension")
|
path_to_extension = Path(__file__).parent.joinpath("my-extension")
|
||||||
context = playwright.chromium.launch_persistent_context(
|
context = playwright.chromium.launch_persistent_context(
|
||||||
"",
|
"",
|
||||||
headless=False,
|
channel="chromium",
|
||||||
args=[
|
args=[
|
||||||
f"--disable-extensions-except={path_to_extension}",
|
f"--disable-extensions-except={path_to_extension}",
|
||||||
f"--load-extension={path_to_extension}",
|
f"--load-extension={path_to_extension}",
|
||||||
|
|
@ -211,33 +215,3 @@ def test_popup_page(page: Page, extension_id: str) -> None:
|
||||||
page.goto(f"chrome-extension://{extension_id}/popup.html")
|
page.goto(f"chrome-extension://{extension_id}/popup.html")
|
||||||
expect(page.locator("body")).to_have_text("my-extension popup")
|
expect(page.locator("body")).to_have_text("my-extension popup")
|
||||||
```
|
```
|
||||||
|
|
||||||
## Headless mode
|
|
||||||
|
|
||||||
By default, Chrome's headless mode in Playwright does not support Chrome extensions. To overcome this limitation, you can run Chrome's persistent context with a new headless mode by using [channel `chromium`](./browsers.md#opt-in-to-new-headless-mode):
|
|
||||||
|
|
||||||
```js title="fixtures.ts"
|
|
||||||
// ...
|
|
||||||
|
|
||||||
const pathToExtension = path.join(__dirname, 'my-extension');
|
|
||||||
const context = await chromium.launchPersistentContext('', {
|
|
||||||
channel: 'chromium',
|
|
||||||
args: [
|
|
||||||
`--disable-extensions-except=${pathToExtension}`,
|
|
||||||
`--load-extension=${pathToExtension}`,
|
|
||||||
],
|
|
||||||
});
|
|
||||||
// ...
|
|
||||||
```
|
|
||||||
|
|
||||||
```python title="conftest.py"
|
|
||||||
path_to_extension = Path(__file__).parent.joinpath("my-extension")
|
|
||||||
context = playwright.chromium.launch_persistent_context(
|
|
||||||
"",
|
|
||||||
channel="chromium",
|
|
||||||
args=[
|
|
||||||
f"--disable-extensions-except={path_to_extension}",
|
|
||||||
f"--load-extension={path_to_extension}",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -217,7 +217,7 @@ await page.getByText('Item').click({ button: 'right' });
|
||||||
// Shift + click
|
// Shift + click
|
||||||
await page.getByText('Item').click({ modifiers: ['Shift'] });
|
await page.getByText('Item').click({ modifiers: ['Shift'] });
|
||||||
|
|
||||||
// Ctrl + click or Windows and Linux
|
// Ctrl + click on Windows and Linux
|
||||||
// Meta + click on macOS
|
// Meta + click on macOS
|
||||||
await page.getByText('Item').click({ modifiers: ['ControlOrMeta'] });
|
await page.getByText('Item').click({ modifiers: ['ControlOrMeta'] });
|
||||||
|
|
||||||
|
|
@ -241,7 +241,7 @@ page.getByText("Item").click(new Locator.ClickOptions().setButton(MouseButton.RI
|
||||||
// Shift + click
|
// Shift + click
|
||||||
page.getByText("Item").click(new Locator.ClickOptions().setModifiers(Arrays.asList(KeyboardModifier.SHIFT)));
|
page.getByText("Item").click(new Locator.ClickOptions().setModifiers(Arrays.asList(KeyboardModifier.SHIFT)));
|
||||||
|
|
||||||
// Ctrl + click or Windows and Linux
|
// Ctrl + click on Windows and Linux
|
||||||
// Meta + click on macOS
|
// Meta + click on macOS
|
||||||
page.getByText("Item").click(new Locator.ClickOptions().setModifiers(Arrays.asList(KeyboardModifier.CONTROL_OR_META)));
|
page.getByText("Item").click(new Locator.ClickOptions().setModifiers(Arrays.asList(KeyboardModifier.CONTROL_OR_META)));
|
||||||
|
|
||||||
|
|
@ -265,7 +265,7 @@ await page.get_by_text("Item").click(button="right")
|
||||||
# Shift + click
|
# Shift + click
|
||||||
await page.get_by_text("Item").click(modifiers=["Shift"])
|
await page.get_by_text("Item").click(modifiers=["Shift"])
|
||||||
|
|
||||||
# Ctrl + click or Windows and Linux
|
# Ctrl + click on Windows and Linux
|
||||||
# Meta + click on macOS
|
# Meta + click on macOS
|
||||||
await page.get_by_text("Item").click(modifiers=["ControlOrMeta"])
|
await page.get_by_text("Item").click(modifiers=["ControlOrMeta"])
|
||||||
|
|
||||||
|
|
@ -309,7 +309,7 @@ await page.GetByText("Item").ClickAsync(new() { Button = MouseButton.Right });
|
||||||
// Shift + click
|
// Shift + click
|
||||||
await page.GetByText("Item").ClickAsync(new() { Modifiers = new[] { KeyboardModifier.Shift } });
|
await page.GetByText("Item").ClickAsync(new() { Modifiers = new[] { KeyboardModifier.Shift } });
|
||||||
|
|
||||||
// Ctrl + click or Windows and Linux
|
// Ctrl + click on Windows and Linux
|
||||||
// Meta + click on macOS
|
// Meta + click on macOS
|
||||||
await page.GetByText("Item").ClickAsync(new() { Modifiers = new[] { KeyboardModifier.ControlOrMeta } });
|
await page.GetByText("Item").ClickAsync(new() { Modifiers = new[] { KeyboardModifier.ControlOrMeta } });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -286,7 +286,7 @@ pnpm exec playwright --version
|
||||||
|
|
||||||
## System requirements
|
## System requirements
|
||||||
|
|
||||||
- Node.js 18+
|
- Latest version of Node.js 18, 20 or 22.
|
||||||
- Windows 10+, Windows Server 2016+ or Windows Subsystem for Linux (WSL).
|
- Windows 10+, Windows Server 2016+ or Windows Subsystem for Linux (WSL).
|
||||||
- macOS 13 Ventura, or later.
|
- macOS 13 Ventura, or later.
|
||||||
- Debian 12, Ubuntu 22.04, Ubuntu 24.04, on x86-64 and arm64 architecture.
|
- Debian 12, Ubuntu 22.04, Ubuntu 24.04, on x86-64 and arm64 architecture.
|
||||||
|
|
|
||||||
|
|
@ -629,6 +629,9 @@ export default defineConfig({
|
||||||
- `stdout` ?<["pipe"|"ignore"]> If `"pipe"`, it will pipe the stdout of the command to the process stdout. If `"ignore"`, it will ignore the stdout of the command. Default to `"ignore"`.
|
- `stdout` ?<["pipe"|"ignore"]> If `"pipe"`, it will pipe the stdout of the command to the process stdout. If `"ignore"`, it will ignore the stdout of the command. Default to `"ignore"`.
|
||||||
- `stderr` ?<["pipe"|"ignore"]> Whether to pipe the stderr of the command to the process stderr or ignore it. Defaults to `"pipe"`.
|
- `stderr` ?<["pipe"|"ignore"]> Whether to pipe the stderr of the command to the process stderr or ignore it. Defaults to `"pipe"`.
|
||||||
- `timeout` ?<[int]> How long to wait for the process to start up and be available in milliseconds. Defaults to 60000.
|
- `timeout` ?<[int]> How long to wait for the process to start up and be available in milliseconds. Defaults to 60000.
|
||||||
|
- `gracefulShutdown` ?<[Object]> How to shut down the process. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ signal: 'SIGINT', timeout: 500 }`, the process group is sent a `SIGINT` signal, followed by `SIGKILL` if it doesn't exit within 500ms. You can also use `SIGTERM` instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't support `SIGINT` and `SIGTERM` signals, so this option is ignored.
|
||||||
|
- `signal` <["SIGINT"|"SIGTERM"]>
|
||||||
|
- `timeout` <[int]>
|
||||||
- `url` ?<[string]> The url on your http server that is expected to return a 2xx, 3xx, 400, 401, 402, or 403 status code when the server is ready to accept connections. Redirects (3xx status codes) are being followed and the new location is checked. Either `port` or `url` should be specified.
|
- `url` ?<[string]> The url on your http server that is expected to return a 2xx, 3xx, 400, 401, 402, or 403 status code when the server is ready to accept connections. Redirects (3xx status codes) are being followed and the new location is checked. Either `port` or `url` should be specified.
|
||||||
|
|
||||||
Launch a development web server (or multiple) during the tests.
|
Launch a development web server (or multiple) during the tests.
|
||||||
|
|
|
||||||
|
|
@ -695,7 +695,7 @@ test('passes', async ({ database, page, a11y }) => {
|
||||||
|
|
||||||
## Box fixtures
|
## Box fixtures
|
||||||
|
|
||||||
Usually, custom fixtures are reported as separate steps in in the UI mode, Trace Viewer and various test reports. They also appear in error messages from the test runner. For frequently-used fixtures, this can mean lots of noise. You can stop the fixtures steps from being shown in the UI by "boxing" it.
|
Usually, custom fixtures are reported as separate steps in the UI mode, Trace Viewer and various test reports. They also appear in error messages from the test runner. For frequently-used fixtures, this can mean lots of noise. You can stop the fixtures steps from being shown in the UI by "boxing" it.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import { test as base } from '@playwright/test';
|
import { test as base } from '@playwright/test';
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,16 @@ Start time of this particular test step.
|
||||||
|
|
||||||
List of steps inside this step.
|
List of steps inside this step.
|
||||||
|
|
||||||
|
## property: TestStep.attachments
|
||||||
|
* since: v1.50
|
||||||
|
- type: <[Array]<[Object]>>
|
||||||
|
- `name` <[string]> Attachment name.
|
||||||
|
- `contentType` <[string]> Content type of this attachment to properly present in the report, for example `'application/json'` or `'image/png'`.
|
||||||
|
- `path` ?<[string]> Optional path on the filesystem to the attached file.
|
||||||
|
- `body` ?<[Buffer]> Optional attachment body used instead of a file.
|
||||||
|
|
||||||
|
The list of files or buffers attached in the step execution through [`method: TestInfo.attach`].
|
||||||
|
|
||||||
## property: TestStep.title
|
## property: TestStep.title
|
||||||
* since: v1.10
|
* since: v1.10
|
||||||
- type: <[string]>
|
- type: <[string]>
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@ export default defineConfig({
|
||||||
| `cwd` | Current working directory of the spawned process, defaults to the directory of the configuration file. |
|
| `cwd` | Current working directory of the spawned process, defaults to the directory of the configuration file. |
|
||||||
| `stdout` | If `"pipe"`, it will pipe the stdout of the command to the process stdout. If `"ignore"`, it will ignore the stdout of the command. Default to `"ignore"`. |
|
| `stdout` | If `"pipe"`, it will pipe the stdout of the command to the process stdout. If `"ignore"`, it will ignore the stdout of the command. Default to `"ignore"`. |
|
||||||
| `stderr` | Whether to pipe the stderr of the command to the process stderr or ignore it. Defaults to `"pipe"`. |
|
| `stderr` | Whether to pipe the stderr of the command to the process stderr or ignore it. Defaults to `"pipe"`. |
|
||||||
| `timeout` | `How long to wait for the process to start up and be available in milliseconds. Defaults to 60000. |
|
| `timeout` | How long to wait for the process to start up and be available in milliseconds. Defaults to 60000. |
|
||||||
|
| `gracefulShutdown` | How to shut down the process. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ signal: 'SIGINT', timeout: 500 }`, the process group is sent a `SIGINT` signal, followed by `SIGKILL` if it doesn't exit within 500ms. You can also use `SIGTERM` instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't support `SIGINT` and `SIGTERM` signals, so this option is ignored. |
|
||||||
|
|
||||||
## Adding a server timeout
|
## Adding a server timeout
|
||||||
|
|
||||||
|
|
|
||||||
144
docs/src/touch-events.md
Normal file
144
docs/src/touch-events.md
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
---
|
||||||
|
id: touch-events
|
||||||
|
title: "Emulating touch events"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
Mobile web sites may listen to [touch events](https://developer.mozilla.org/en-US/docs/Web/API/Touch_events) and react to user touch gestures such as swipe, pinch, tap etc. To test this functionality you can manually generate [TouchEvent]s in the page context using [`method: Locator.evaluate`].
|
||||||
|
|
||||||
|
If your web application relies on [pointer events](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events) instead of touch events, you can use [`method: Locator.click`] and raw [`Mouse`] events to simulate a single-finger touch, and this will trigger all the same pointer events.
|
||||||
|
|
||||||
|
### Dispatching TouchEvent
|
||||||
|
|
||||||
|
You can dispatch touch events to the page using [`method: Locator.dispatchEvent`]. [Touch](https://developer.mozilla.org/en-US/docs/Web/API/Touch) points can be passed as arguments, see examples below.
|
||||||
|
|
||||||
|
#### Emulating pan gesture
|
||||||
|
|
||||||
|
In the example below, we emulate pan gesture that is expected to move the map. The app under test only uses `clientX/clientY` coordinates of the touch point, so we initialize just that. In a more complex scenario you may need to also set `pageX/pageY/screenX/screenY`, if your app needs them.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { test, expect, devices, type Locator } from '@playwright/test';
|
||||||
|
|
||||||
|
test.use({ ...devices['Pixel 7'] });
|
||||||
|
|
||||||
|
async function pan(locator: Locator, deltaX?: number, deltaY?: number, steps?: number) {
|
||||||
|
const { centerX, centerY } = await locator.evaluate((target: HTMLElement) => {
|
||||||
|
const bounds = target.getBoundingClientRect();
|
||||||
|
const centerX = bounds.left + bounds.width / 2;
|
||||||
|
const centerY = bounds.top + bounds.height / 2;
|
||||||
|
return { centerX, centerY };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Providing only clientX and clientY as the app only cares about those.
|
||||||
|
const touches = [{
|
||||||
|
identifier: 0,
|
||||||
|
clientX: centerX,
|
||||||
|
clientY: centerY,
|
||||||
|
}];
|
||||||
|
await locator.dispatchEvent('touchstart',
|
||||||
|
{ touches, changedTouches: touches, targetTouches: touches });
|
||||||
|
|
||||||
|
steps = steps ?? 5;
|
||||||
|
deltaX = deltaX ?? 0;
|
||||||
|
deltaY = deltaY ?? 0;
|
||||||
|
for (let i = 1; i <= steps; i++) {
|
||||||
|
const touches = [{
|
||||||
|
identifier: 0,
|
||||||
|
clientX: centerX + deltaX * i / steps,
|
||||||
|
clientY: centerY + deltaY * i / steps,
|
||||||
|
}];
|
||||||
|
await locator.dispatchEvent('touchmove',
|
||||||
|
{ touches, changedTouches: touches, targetTouches: touches });
|
||||||
|
}
|
||||||
|
|
||||||
|
await locator.dispatchEvent('touchend');
|
||||||
|
}
|
||||||
|
|
||||||
|
test(`pan gesture to move the map`, async ({ page }) => {
|
||||||
|
await page.goto('https://www.google.com/maps/place/@37.4117722,-122.0713234,15z',
|
||||||
|
{ waitUntil: 'commit' });
|
||||||
|
await page.getByRole('button', { name: 'Keep using web' }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Keep using web' })).not.toBeVisible();
|
||||||
|
// Get the map element.
|
||||||
|
const met = page.locator('[data-test-id="met"]');
|
||||||
|
for (let i = 0; i < 5; i++)
|
||||||
|
await pan(met, 200, 100);
|
||||||
|
// Ensure the map has been moved.
|
||||||
|
await expect(met).toHaveScreenshot();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Emulating pinch gesture
|
||||||
|
|
||||||
|
In the example below, we emulate pinch gesture, i.e. two touch points moving closer to each other. It is expected to zoom out the map. The app under test only uses `clientX/clientY` coordinates of touch points, so we initialize just that. In a more complex scenario you may need to also set `pageX/pageY/screenX/screenY`, if your app needs them.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { test, expect, devices, type Locator } from '@playwright/test';
|
||||||
|
|
||||||
|
test.use({ ...devices['Pixel 7'] });
|
||||||
|
|
||||||
|
async function pinch(locator: Locator,
|
||||||
|
arg: { deltaX?: number, deltaY?: number, steps?: number, direction?: 'in' | 'out' }) {
|
||||||
|
const { centerX, centerY } = await locator.evaluate((target: HTMLElement) => {
|
||||||
|
const bounds = target.getBoundingClientRect();
|
||||||
|
const centerX = bounds.left + bounds.width / 2;
|
||||||
|
const centerY = bounds.top + bounds.height / 2;
|
||||||
|
return { centerX, centerY };
|
||||||
|
});
|
||||||
|
|
||||||
|
const deltaX = arg.deltaX ?? 50;
|
||||||
|
const steps = arg.steps ?? 5;
|
||||||
|
const stepDeltaX = deltaX / (steps + 1);
|
||||||
|
|
||||||
|
// Two touch points equally distant from the center of the element.
|
||||||
|
const touches = [
|
||||||
|
{
|
||||||
|
identifier: 0,
|
||||||
|
clientX: centerX - (arg.direction === 'in' ? deltaX : stepDeltaX),
|
||||||
|
clientY: centerY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
identifier: 1,
|
||||||
|
clientX: centerX + (arg.direction === 'in' ? deltaX : stepDeltaX),
|
||||||
|
clientY: centerY,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await locator.dispatchEvent('touchstart',
|
||||||
|
{ touches, changedTouches: touches, targetTouches: touches });
|
||||||
|
|
||||||
|
// Move the touch points towards or away from each other.
|
||||||
|
for (let i = 1; i <= steps; i++) {
|
||||||
|
const offset = (arg.direction === 'in' ? (deltaX - i * stepDeltaX) : (stepDeltaX * (i + 1)));
|
||||||
|
const touches = [
|
||||||
|
{
|
||||||
|
identifier: 0,
|
||||||
|
clientX: centerX - offset,
|
||||||
|
clientY: centerY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
identifier: 0,
|
||||||
|
clientX: centerX + offset,
|
||||||
|
clientY: centerY,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await locator.dispatchEvent('touchmove',
|
||||||
|
{ touches, changedTouches: touches, targetTouches: touches });
|
||||||
|
}
|
||||||
|
|
||||||
|
await locator.dispatchEvent('touchend', { touches: [], changedTouches: [], targetTouches: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
test(`pinch in gesture to zoom out the map`, async ({ page }) => {
|
||||||
|
await page.goto('https://www.google.com/maps/place/@37.4117722,-122.0713234,15z',
|
||||||
|
{ waitUntil: 'commit' });
|
||||||
|
await page.getByRole('button', { name: 'Keep using web' }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Keep using web' })).not.toBeVisible();
|
||||||
|
// Get the map element.
|
||||||
|
const met = page.locator('[data-test-id="met"]');
|
||||||
|
for (let i = 0; i < 5; i++)
|
||||||
|
await pinch(met, { deltaX: 40, direction: 'in' });
|
||||||
|
// Ensure the map has been zoomed out.
|
||||||
|
await expect(met).toHaveScreenshot();
|
||||||
|
});
|
||||||
|
```
|
||||||
5
package-lock.json
generated
5
package-lock.json
generated
|
|
@ -7964,7 +7964,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/trace-viewer": {
|
"packages/trace-viewer": {
|
||||||
"version": "0.0.0"
|
"version": "0.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"yaml": "^2.6.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,11 @@
|
||||||
color: var(--color-scale-orange-6);
|
color: var(--color-scale-orange-6);
|
||||||
border: 1px solid var(--color-scale-orange-4);
|
border: 1px solid var(--color-scale-orange-4);
|
||||||
}
|
}
|
||||||
|
.label-color-gray {
|
||||||
|
background-color: var(--color-scale-gray-0);
|
||||||
|
color: var(--color-scale-gray-6);
|
||||||
|
border: 1px solid var(--color-scale-gray-4);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media(prefers-color-scheme: dark) {
|
@media(prefers-color-scheme: dark) {
|
||||||
|
|
@ -93,6 +98,11 @@
|
||||||
color: var(--color-scale-orange-2);
|
color: var(--color-scale-orange-2);
|
||||||
border: 1px solid var(--color-scale-orange-4);
|
border: 1px solid var(--color-scale-orange-4);
|
||||||
}
|
}
|
||||||
|
.label-color-gray {
|
||||||
|
background-color: var(--color-scale-gray-9);
|
||||||
|
color: var(--color-scale-gray-2);
|
||||||
|
border: 1px solid var(--color-scale-gray-4);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment-body {
|
.attachment-body {
|
||||||
|
|
|
||||||
|
|
@ -68,11 +68,12 @@ export const ProjectLink: React.FunctionComponent<{
|
||||||
|
|
||||||
export const AttachmentLink: React.FunctionComponent<{
|
export const AttachmentLink: React.FunctionComponent<{
|
||||||
attachment: TestAttachment,
|
attachment: TestAttachment,
|
||||||
|
result: TestResult,
|
||||||
href?: string,
|
href?: string,
|
||||||
linkName?: string,
|
linkName?: string,
|
||||||
openInNewTab?: boolean,
|
openInNewTab?: boolean,
|
||||||
}> = ({ attachment, href, linkName, openInNewTab }) => {
|
}> = ({ attachment, result, href, linkName, openInNewTab }) => {
|
||||||
const isAnchored = useIsAnchored('attachment-' + attachment.name);
|
const isAnchored = useIsAnchored('attachment-' + result.attachments.indexOf(attachment));
|
||||||
return <TreeItem title={<span>
|
return <TreeItem title={<span>
|
||||||
{attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()}
|
{attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()}
|
||||||
{attachment.path && <a href={href || attachment.path} download={downloadFileNameForAttachment(attachment)}>{linkName || attachment.name}</a>}
|
{attachment.path && <a href={href || attachment.path} download={downloadFileNameForAttachment(attachment)}>{linkName || attachment.name}</a>}
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,10 @@ const result: TestResult = {
|
||||||
duration: 10,
|
duration: 10,
|
||||||
location: { file: 'test.spec.ts', line: 82, column: 0 },
|
location: { file: 'test.spec.ts', line: 82, column: 0 },
|
||||||
steps: [],
|
steps: [],
|
||||||
|
attachments: [],
|
||||||
count: 1,
|
count: 1,
|
||||||
}],
|
}],
|
||||||
|
attachments: [],
|
||||||
}],
|
}],
|
||||||
attachments: [],
|
attachments: [],
|
||||||
status: 'passed',
|
status: 'passed',
|
||||||
|
|
@ -139,6 +141,7 @@ const resultWithAttachment: TestResult = {
|
||||||
location: { file: 'test.spec.ts', line: 62, column: 0 },
|
location: { file: 'test.spec.ts', line: 62, column: 0 },
|
||||||
count: 1,
|
count: 1,
|
||||||
steps: [],
|
steps: [],
|
||||||
|
attachments: [1],
|
||||||
}],
|
}],
|
||||||
attachments: [{
|
attachments: [{
|
||||||
name: 'first attachment',
|
name: 'first attachment',
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ function imageDiffBadge(test: TestCaseSummary): JSX.Element | undefined {
|
||||||
for (const result of test.results) {
|
for (const result of test.results) {
|
||||||
for (const attachment of result.attachments) {
|
for (const attachment of result.attachments) {
|
||||||
if (attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/))
|
if (attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/))
|
||||||
return <Link href={testResultHref({ test, result, anchor: `attachment-${attachment.name}` })} title='View images' className='test-file-badge'>{image()}</Link>;
|
return <Link href={testResultHref({ test, result, anchor: `attachment-${result.attachments.indexOf(attachment)}` })} title='View images' className='test-file-badge'>{image()}</Link>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ interface ImageDiffWithAnchors extends ImageDiff {
|
||||||
anchors: string[];
|
anchors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiffWithAnchors[] {
|
function groupImageDiffs(screenshots: Set<TestAttachment>, result: TestResult): ImageDiffWithAnchors[] {
|
||||||
const snapshotNameToImageDiff = new Map<string, ImageDiffWithAnchors>();
|
const snapshotNameToImageDiff = new Map<string, ImageDiffWithAnchors>();
|
||||||
for (const attachment of screenshots) {
|
for (const attachment of screenshots) {
|
||||||
const match = attachment.name.match(/^(.*)-(expected|actual|diff|previous)(\.[^.]+)?$/);
|
const match = attachment.name.match(/^(.*)-(expected|actual|diff|previous)(\.[^.]+)?$/);
|
||||||
|
|
@ -45,7 +45,7 @@ function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiffWithAnchors
|
||||||
imageDiff = { name: snapshotName, anchors: [`attachment-${name}`] };
|
imageDiff = { name: snapshotName, anchors: [`attachment-${name}`] };
|
||||||
snapshotNameToImageDiff.set(snapshotName, imageDiff);
|
snapshotNameToImageDiff.set(snapshotName, imageDiff);
|
||||||
}
|
}
|
||||||
imageDiff.anchors.push(`attachment-${attachment.name}`);
|
imageDiff.anchors.push(`attachment-${result.attachments.indexOf(attachment)}`);
|
||||||
if (category === 'actual')
|
if (category === 'actual')
|
||||||
imageDiff.actual = { attachment };
|
imageDiff.actual = { attachment };
|
||||||
if (category === 'expected')
|
if (category === 'expected')
|
||||||
|
|
@ -72,15 +72,15 @@ export const TestResultView: React.FC<{
|
||||||
result: TestResult,
|
result: TestResult,
|
||||||
}> = ({ test, result }) => {
|
}> = ({ test, result }) => {
|
||||||
const { screenshots, videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors } = React.useMemo(() => {
|
const { screenshots, videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors } = React.useMemo(() => {
|
||||||
const attachments = result?.attachments || [];
|
const attachments = result.attachments;
|
||||||
const screenshots = new Set(attachments.filter(a => a.contentType.startsWith('image/')));
|
const screenshots = new Set(attachments.filter(a => a.contentType.startsWith('image/')));
|
||||||
const screenshotAnchors = [...screenshots].map(a => `attachment-${a.name}`);
|
const screenshotAnchors = [...screenshots].map(a => `attachment-${attachments.indexOf(a)}`);
|
||||||
const videos = attachments.filter(a => a.contentType.startsWith('video/'));
|
const videos = attachments.filter(a => a.contentType.startsWith('video/'));
|
||||||
const traces = attachments.filter(a => a.name === 'trace');
|
const traces = attachments.filter(a => a.name === 'trace');
|
||||||
const otherAttachments = new Set<TestAttachment>(attachments);
|
const otherAttachments = new Set<TestAttachment>(attachments);
|
||||||
[...screenshots, ...videos, ...traces].forEach(a => otherAttachments.delete(a));
|
[...screenshots, ...videos, ...traces].forEach(a => otherAttachments.delete(a));
|
||||||
const otherAttachmentAnchors = [...otherAttachments].map(a => `attachment-${a.name}`);
|
const otherAttachmentAnchors = [...otherAttachments].map(a => `attachment-${attachments.indexOf(a)}`);
|
||||||
const diffs = groupImageDiffs(screenshots);
|
const diffs = groupImageDiffs(screenshots, result);
|
||||||
const errors = classifyErrors(result.errors, diffs);
|
const errors = classifyErrors(result.errors, diffs);
|
||||||
return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors };
|
return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors };
|
||||||
}, [result]);
|
}, [result]);
|
||||||
|
|
@ -107,11 +107,11 @@ export const TestResultView: React.FC<{
|
||||||
|
|
||||||
{!!screenshots.length && <AutoChip header='Screenshots' revealOnAnchorId={screenshotAnchors}>
|
{!!screenshots.length && <AutoChip header='Screenshots' revealOnAnchorId={screenshotAnchors}>
|
||||||
{screenshots.map((a, i) => {
|
{screenshots.map((a, i) => {
|
||||||
return <Anchor key={`screenshot-${i}`} id={`attachment-${a.name}`}>
|
return <Anchor key={`screenshot-${i}`} id={`attachment-${result.attachments.indexOf(a)}`}>
|
||||||
<a href={a.path}>
|
<a href={a.path}>
|
||||||
<img className='screenshot' src={a.path} />
|
<img className='screenshot' src={a.path} />
|
||||||
</a>
|
</a>
|
||||||
<AttachmentLink attachment={a}></AttachmentLink>
|
<AttachmentLink attachment={a} result={result}></AttachmentLink>
|
||||||
</Anchor>;
|
</Anchor>;
|
||||||
})}
|
})}
|
||||||
</AutoChip>}
|
</AutoChip>}
|
||||||
|
|
@ -121,7 +121,7 @@ export const TestResultView: React.FC<{
|
||||||
<a href={generateTraceUrl(traces)}>
|
<a href={generateTraceUrl(traces)}>
|
||||||
<img className='screenshot' src={traceImage} style={{ width: 192, height: 117, marginLeft: 20 }} />
|
<img className='screenshot' src={traceImage} style={{ width: 192, height: 117, marginLeft: 20 }} />
|
||||||
</a>
|
</a>
|
||||||
{traces.map((a, i) => <AttachmentLink key={`trace-${i}`} attachment={a} linkName={traces.length === 1 ? 'trace' : `trace-${i + 1}`}></AttachmentLink>)}
|
{traces.map((a, i) => <AttachmentLink key={`trace-${i}`} attachment={a} result={result} linkName={traces.length === 1 ? 'trace' : `trace-${i + 1}`}></AttachmentLink>)}
|
||||||
</div>}
|
</div>}
|
||||||
</AutoChip></Anchor>}
|
</AutoChip></Anchor>}
|
||||||
|
|
||||||
|
|
@ -130,14 +130,14 @@ export const TestResultView: React.FC<{
|
||||||
<video controls>
|
<video controls>
|
||||||
<source src={a.path} type={a.contentType}/>
|
<source src={a.path} type={a.contentType}/>
|
||||||
</video>
|
</video>
|
||||||
<AttachmentLink attachment={a}></AttachmentLink>
|
<AttachmentLink attachment={a} result={result}></AttachmentLink>
|
||||||
</div>)}
|
</div>)}
|
||||||
</AutoChip></Anchor>}
|
</AutoChip></Anchor>}
|
||||||
|
|
||||||
{!!otherAttachments.size && <AutoChip header='Attachments' revealOnAnchorId={otherAttachmentAnchors}>
|
{!!otherAttachments.size && <AutoChip header='Attachments' revealOnAnchorId={otherAttachmentAnchors} dataTestId='attachments'>
|
||||||
{[...otherAttachments].map((a, i) =>
|
{[...otherAttachments].map((a, i) =>
|
||||||
<Anchor key={`attachment-link-${i}`} id={`attachment-${a.name}`}>
|
<Anchor key={`attachment-link-${i}`} id={`attachment-${result.attachments.indexOf(a)}`}>
|
||||||
<AttachmentLink attachment={a} openInNewTab={a.contentType.startsWith('text/html')} />
|
<AttachmentLink attachment={a} result={result} openInNewTab={a.contentType.startsWith('text/html')} />
|
||||||
</Anchor>
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
</AutoChip>}
|
</AutoChip>}
|
||||||
|
|
@ -174,18 +174,29 @@ const StepTreeItem: React.FC<{
|
||||||
step: TestStep;
|
step: TestStep;
|
||||||
depth: number,
|
depth: number,
|
||||||
}> = ({ test, step, result, depth }) => {
|
}> = ({ test, step, result, depth }) => {
|
||||||
const attachmentName = step.title.match(/^attach "(.*)"$/)?.[1];
|
|
||||||
return <TreeItem title={<span aria-label={step.title}>
|
return <TreeItem title={<span aria-label={step.title}>
|
||||||
<span style={{ float: 'right' }}>{msToString(step.duration)}</span>
|
<span style={{ float: 'right' }}>{msToString(step.duration)}</span>
|
||||||
{attachmentName && <a style={{ float: 'right' }} title='link to attachment' href={testResultHref({ test, result, anchor: `attachment-${attachmentName}` })} onClick={evt => { evt.stopPropagation(); }}>{icons.attachment()}</a>}
|
|
||||||
{statusIcon(step.error || step.duration === -1 ? 'failed' : 'passed')}
|
{statusIcon(step.error || step.duration === -1 ? 'failed' : 'passed')}
|
||||||
<span>{step.title}</span>
|
<span>{step.title}</span>
|
||||||
{step.count > 1 && <> ✕ <span className='test-result-counter'>{step.count}</span></>}
|
{step.count > 1 && <> ✕ <span className='test-result-counter'>{step.count}</span></>}
|
||||||
{step.location && <span className='test-result-path'>— {step.location.file}:{step.location.line}</span>}
|
{step.location && <span className='test-result-path'>— {step.location.file}:{step.location.line}</span>}
|
||||||
</span>} loadChildren={step.steps.length + (step.snippet ? 1 : 0) ? () => {
|
</span>} loadChildren={step.steps.length + (step.snippet ? 1 : 0) ? () => {
|
||||||
const children = step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1} result={result} test={test} />);
|
const snippet = step.snippet ? [<TestErrorView testId='test-snippet' key='line' error={step.snippet}/>] : [];
|
||||||
if (step.snippet)
|
const steps = step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1} result={result} test={test} />);
|
||||||
children.unshift(<TestErrorView testId='test-snippet' key='line' error={step.snippet}/>);
|
const attachments = step.attachments.map(attachmentIndex => (
|
||||||
return children;
|
<a key={'' + attachmentIndex}
|
||||||
|
href={testResultHref({ test, result, anchor: `attachment-${attachmentIndex}` })}
|
||||||
|
style={{ paddingLeft: depth * 22 + 4, textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{ margin: '8px 0 0 8px', padding: '2px 10px', cursor: 'pointer' }}
|
||||||
|
className='label label-color-gray'
|
||||||
|
title={`see "${result.attachments[attachmentIndex].name}"`}
|
||||||
|
>
|
||||||
|
{icons.attachment()}{result.attachments[attachmentIndex].name}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
));
|
||||||
|
return snippet.concat(steps, attachments);
|
||||||
} : undefined} depth={depth}/>;
|
} : undefined} depth={depth}/>;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
1
packages/html-reporter/src/types.d.ts
vendored
1
packages/html-reporter/src/types.d.ts
vendored
|
|
@ -108,5 +108,6 @@ export type TestStep = {
|
||||||
snippet?: string;
|
snippet?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
steps: TestStep[];
|
steps: TestStep[];
|
||||||
|
attachments: number[];
|
||||||
count: number;
|
count: number;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -47,4 +47,3 @@ export function hashStringToInt(str: string) {
|
||||||
hash = str.charCodeAt(i) + ((hash << 8) - hash);
|
hash = str.charCodeAt(i) + ((hash << 8) - hash);
|
||||||
return Math.abs(hash % 6);
|
return Math.abs(hash % 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1,2 +0,0 @@
|
||||||
See building instructions at [`/browser_patches/winldd/README.md`](../../../browser_patches/winldd/README.md)
|
|
||||||
|
|
||||||
|
|
@ -3,21 +3,21 @@
|
||||||
"browsers": [
|
"browsers": [
|
||||||
{
|
{
|
||||||
"name": "chromium",
|
"name": "chromium",
|
||||||
"revision": "1152",
|
"revision": "1153",
|
||||||
"installByDefault": true,
|
"installByDefault": true,
|
||||||
"browserVersion": "132.0.6834.46"
|
"browserVersion": "132.0.6834.57"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "chromium-tip-of-tree",
|
"name": "chromium-tip-of-tree",
|
||||||
"revision": "1287",
|
"revision": "1293",
|
||||||
"installByDefault": false,
|
"installByDefault": false,
|
||||||
"browserVersion": "133.0.6901.0"
|
"browserVersion": "133.0.6943.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "firefox",
|
"name": "firefox",
|
||||||
"revision": "1466",
|
"revision": "1470",
|
||||||
"installByDefault": true,
|
"installByDefault": true,
|
||||||
"browserVersion": "132.0"
|
"browserVersion": "133.0.3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "firefox-beta",
|
"name": "firefox-beta",
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "webkit",
|
"name": "webkit",
|
||||||
"revision": "2120",
|
"revision": "2122",
|
||||||
"installByDefault": true,
|
"installByDefault": true,
|
||||||
"revisionOverrides": {
|
"revisionOverrides": {
|
||||||
"debian11-x64": "2105",
|
"debian11-x64": "2105",
|
||||||
|
|
@ -52,6 +52,11 @@
|
||||||
"mac12-arm64": "1011"
|
"mac12-arm64": "1011"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "winldd",
|
||||||
|
"revision": "1007",
|
||||||
|
"installByDefault": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "android",
|
"name": "android",
|
||||||
"revision": "1001",
|
"revision": "1001",
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,9 @@ import { rewriteErrorMessage } from './utils/stackTrace';
|
||||||
import { SocksProxy } from './common/socksProxy';
|
import { SocksProxy } from './common/socksProxy';
|
||||||
|
|
||||||
export class BrowserServerLauncherImpl implements BrowserServerLauncher {
|
export class BrowserServerLauncherImpl implements BrowserServerLauncher {
|
||||||
private _browserName: 'chromium' | 'firefox' | 'webkit';
|
private _browserName: 'chromium' | 'firefox' | 'webkit' | 'bidiFirefox' | 'bidiChromium';
|
||||||
|
|
||||||
constructor(browserName: 'chromium' | 'firefox' | 'webkit') {
|
constructor(browserName: 'chromium' | 'firefox' | 'webkit' | 'bidiFirefox' | 'bidiChromium') {
|
||||||
this._browserName = browserName;
|
this._browserName = browserName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,9 @@ function checkBrowsersToInstall(args: string[], options: { noShell?: boolean, on
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'win32')
|
||||||
|
executables.push(registry.findExecutable('winldd')!);
|
||||||
|
|
||||||
if (faultyArguments.length)
|
if (faultyArguments.length)
|
||||||
throw new Error(`Invalid installation targets: ${faultyArguments.map(name => `'${name}'`).join(', ')}. Expecting one of: ${suggestedBrowsersToInstall()}`);
|
throw new Error(`Invalid installation targets: ${faultyArguments.map(name => `'${name}'`).join(', ')}. Expecting one of: ${suggestedBrowsersToInstall()}`);
|
||||||
return executables;
|
return executables;
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import { EventEmitter } from './eventEmitter';
|
||||||
import type * as channels from '@protocol/channels';
|
import type * as channels from '@protocol/channels';
|
||||||
import { maybeFindValidator, ValidationError, type ValidatorContext } from '../protocol/validator';
|
import { maybeFindValidator, ValidationError, type ValidatorContext } from '../protocol/validator';
|
||||||
import { debugLogger } from '../utils/debugLogger';
|
import { debugLogger } from '../utils/debugLogger';
|
||||||
import type { ExpectZone } from '../utils/stackTrace';
|
|
||||||
import { captureLibraryStackTrace, stringifyStackFrames } from '../utils/stackTrace';
|
import { captureLibraryStackTrace, stringifyStackFrames } from '../utils/stackTrace';
|
||||||
import { isUnderTest } from '../utils';
|
import { isUnderTest } from '../utils';
|
||||||
import { zones } from '../utils/zones';
|
import { zones } from '../utils/zones';
|
||||||
|
|
@ -148,15 +147,18 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
|
||||||
if (validator) {
|
if (validator) {
|
||||||
return async (params: any) => {
|
return async (params: any) => {
|
||||||
return await this._wrapApiCall(async apiZone => {
|
return await this._wrapApiCall(async apiZone => {
|
||||||
const { apiName, frames, csi, callCookie, stepId } = apiZone.reported ? { apiName: undefined, csi: undefined, callCookie: undefined, frames: [], stepId: undefined } : apiZone;
|
const validatedParams = validator(params, '', { tChannelImpl: tChannelImplToWire, binary: this._connection.rawBuffers() ? 'buffer' : 'toBase64' });
|
||||||
|
if (!apiZone.isInternal && !apiZone.reported) {
|
||||||
|
// Reporting/tracing/logging this api call for the first time.
|
||||||
|
apiZone.params = params;
|
||||||
apiZone.reported = true;
|
apiZone.reported = true;
|
||||||
let currentStepId = stepId;
|
this._instrumentation.onApiCallBegin(apiZone);
|
||||||
if (csi && apiName) {
|
logApiCall(this._logger, `=> ${apiZone.apiName} started`);
|
||||||
const out: { stepId?: string } = {};
|
return await this._connection.sendMessageToServer(this, prop, validatedParams, apiZone.apiName, apiZone.frames, apiZone.stepId);
|
||||||
csi.onApiCallBegin(apiName, params, frames, callCookie, out);
|
|
||||||
currentStepId = out.stepId;
|
|
||||||
}
|
}
|
||||||
return await this._connection.sendMessageToServer(this, prop, validator(params, '', { tChannelImpl: tChannelImplToWire, binary: this._connection.rawBuffers() ? 'buffer' : 'toBase64' }), apiName, frames, currentStepId);
|
// Since this api call is either internal, or has already been reported/traced once,
|
||||||
|
// passing undefined apiName will avoid an extra unneeded tracing entry.
|
||||||
|
return await this._connection.sendMessageToServer(this, prop, validatedParams, undefined, [], undefined);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -170,48 +172,36 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
|
||||||
|
|
||||||
async _wrapApiCall<R>(func: (apiZone: ApiZone) => Promise<R>, isInternal?: boolean): Promise<R> {
|
async _wrapApiCall<R>(func: (apiZone: ApiZone) => Promise<R>, isInternal?: boolean): Promise<R> {
|
||||||
const logger = this._logger;
|
const logger = this._logger;
|
||||||
const apiZone = zones.zoneData<ApiZone>('apiZone');
|
const existingApiZone = zones.zoneData<ApiZone>('apiZone');
|
||||||
if (apiZone)
|
if (existingApiZone)
|
||||||
return await func(apiZone);
|
return await func(existingApiZone);
|
||||||
|
|
||||||
const stackTrace = captureLibraryStackTrace();
|
|
||||||
let apiName: string | undefined = stackTrace.apiName;
|
|
||||||
const frames: channels.StackFrame[] = stackTrace.frames;
|
|
||||||
|
|
||||||
if (isInternal === undefined)
|
if (isInternal === undefined)
|
||||||
isInternal = this._isInternalType;
|
isInternal = this._isInternalType;
|
||||||
if (isInternal)
|
const stackTrace = captureLibraryStackTrace();
|
||||||
apiName = undefined;
|
const apiZone: ApiZone = { apiName: stackTrace.apiName, frames: stackTrace.frames, isInternal, reported: false, userData: undefined, stepId: undefined };
|
||||||
|
|
||||||
// Enclosing zone could have provided the apiName and wallTime.
|
|
||||||
const expectZone = zones.zoneData<ExpectZone>('expectZone');
|
|
||||||
const stepId = expectZone?.stepId;
|
|
||||||
if (!isInternal && expectZone)
|
|
||||||
apiName = expectZone.title;
|
|
||||||
|
|
||||||
// If we are coming from the expectZone, there is no need to generate a new
|
|
||||||
// step for the API call, since it will be generated by the expect itself.
|
|
||||||
const csi = isInternal || expectZone ? undefined : this._instrumentation;
|
|
||||||
const callCookie: any = {};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logApiCall(logger, `=> ${apiName} started`, isInternal);
|
|
||||||
const apiZone: ApiZone = { apiName, frames, isInternal, reported: false, csi, callCookie, stepId };
|
|
||||||
const result = await zones.run('apiZone', apiZone, async () => await func(apiZone));
|
const result = await zones.run('apiZone', apiZone, async () => await func(apiZone));
|
||||||
csi?.onApiCallEnd(callCookie);
|
if (!isInternal) {
|
||||||
logApiCall(logger, `<= ${apiName} succeeded`, isInternal);
|
logApiCall(logger, `<= ${apiZone.apiName} succeeded`);
|
||||||
|
this._instrumentation.onApiCallEnd(apiZone);
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const innerError = ((process.env.PWDEBUGIMPL || isUnderTest()) && e.stack) ? '\n<inner error>\n' + e.stack : '';
|
const innerError = ((process.env.PWDEBUGIMPL || isUnderTest()) && e.stack) ? '\n<inner error>\n' + e.stack : '';
|
||||||
if (apiName && !apiName.includes('<anonymous>'))
|
if (apiZone.apiName && !apiZone.apiName.includes('<anonymous>'))
|
||||||
e.message = apiName + ': ' + e.message;
|
e.message = apiZone.apiName + ': ' + e.message;
|
||||||
const stackFrames = '\n' + stringifyStackFrames(stackTrace.frames).join('\n') + innerError;
|
const stackFrames = '\n' + stringifyStackFrames(stackTrace.frames).join('\n') + innerError;
|
||||||
if (stackFrames.trim())
|
if (stackFrames.trim())
|
||||||
e.stack = e.message + stackFrames;
|
e.stack = e.message + stackFrames;
|
||||||
else
|
else
|
||||||
e.stack = '';
|
e.stack = '';
|
||||||
csi?.onApiCallEnd(callCookie, e);
|
if (!isInternal) {
|
||||||
logApiCall(logger, `<= ${apiName} failed`, isInternal);
|
apiZone.error = e;
|
||||||
|
logApiCall(logger, `<= ${apiZone.apiName} failed`);
|
||||||
|
this._instrumentation.onApiCallEnd(apiZone);
|
||||||
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -232,9 +222,7 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function logApiCall(logger: Logger | undefined, message: string, isNested: boolean) {
|
function logApiCall(logger: Logger | undefined, message: string) {
|
||||||
if (isNested)
|
|
||||||
return;
|
|
||||||
if (logger && logger.isEnabled('api', 'info'))
|
if (logger && logger.isEnabled('api', 'info'))
|
||||||
logger.log('api', 'info', message, [], { color: 'cyan' });
|
logger.log('api', 'info', message, [], { color: 'cyan' });
|
||||||
debugLogger.log('api', message);
|
debugLogger.log('api', message);
|
||||||
|
|
@ -247,11 +235,12 @@ function tChannelImplToWire(names: '*' | string[], arg: any, path: string, conte
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiZone = {
|
type ApiZone = {
|
||||||
apiName: string | undefined;
|
apiName: string;
|
||||||
|
params?: Record<string, any>;
|
||||||
frames: channels.StackFrame[];
|
frames: channels.StackFrame[];
|
||||||
isInternal: boolean;
|
isInternal: boolean;
|
||||||
reported: boolean;
|
reported: boolean;
|
||||||
csi: ClientInstrumentation | undefined;
|
userData: any;
|
||||||
callCookie: any;
|
|
||||||
stepId?: string;
|
stepId?: string;
|
||||||
|
error?: Error;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,22 @@ import type { StackFrame } from '@protocol/channels';
|
||||||
import type { BrowserContext } from './browserContext';
|
import type { BrowserContext } from './browserContext';
|
||||||
import type { APIRequestContext } from './fetch';
|
import type { APIRequestContext } from './fetch';
|
||||||
|
|
||||||
|
// Instrumentation can mutate the data, for example change apiName or stepId.
|
||||||
|
export interface ApiCallData {
|
||||||
|
apiName: string;
|
||||||
|
params?: Record<string, any>;
|
||||||
|
frames: StackFrame[];
|
||||||
|
userData: any;
|
||||||
|
stepId?: string;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClientInstrumentation {
|
export interface ClientInstrumentation {
|
||||||
addListener(listener: ClientInstrumentationListener): void;
|
addListener(listener: ClientInstrumentationListener): void;
|
||||||
removeListener(listener: ClientInstrumentationListener): void;
|
removeListener(listener: ClientInstrumentationListener): void;
|
||||||
removeAllListeners(): void;
|
removeAllListeners(): void;
|
||||||
onApiCallBegin(apiCall: string, params: Record<string, any>, frames: StackFrame[], userData: any, out: { stepId?: string }): void;
|
onApiCallBegin(apiCall: ApiCallData): void;
|
||||||
onApiCallEnd(userData: any, error?: Error): void;
|
onApiCallEnd(apiCal: ApiCallData): void;
|
||||||
onWillPause(options: { keepTestTimeout: boolean }): void;
|
onWillPause(options: { keepTestTimeout: boolean }): void;
|
||||||
|
|
||||||
runAfterCreateBrowserContext(context: BrowserContext): Promise<void>;
|
runAfterCreateBrowserContext(context: BrowserContext): Promise<void>;
|
||||||
|
|
@ -33,8 +43,8 @@ export interface ClientInstrumentation {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientInstrumentationListener {
|
export interface ClientInstrumentationListener {
|
||||||
onApiCallBegin?(apiName: string, params: Record<string, any>, frames: StackFrame[], userData: any, out: { stepId?: string }): void;
|
onApiCallBegin?(apiCall: ApiCallData): void;
|
||||||
onApiCallEnd?(userData: any, error?: Error): void;
|
onApiCallEnd?(apiCall: ApiCallData): void;
|
||||||
onWillPause?(options: { keepTestTimeout: boolean }): void;
|
onWillPause?(options: { keepTestTimeout: boolean }): void;
|
||||||
|
|
||||||
runAfterCreateBrowserContext?(context: BrowserContext): Promise<void>;
|
runAfterCreateBrowserContext?(context: BrowserContext): Promise<void>;
|
||||||
|
|
|
||||||
|
|
@ -78,9 +78,9 @@ export class Connection extends EventEmitter {
|
||||||
|
|
||||||
constructor(localUtils: LocalUtils | undefined, instrumentation: ClientInstrumentation | undefined) {
|
constructor(localUtils: LocalUtils | undefined, instrumentation: ClientInstrumentation | undefined) {
|
||||||
super();
|
super();
|
||||||
this._rootObject = new Root(this);
|
|
||||||
this._localUtils = localUtils;
|
|
||||||
this._instrumentation = instrumentation || createInstrumentation();
|
this._instrumentation = instrumentation || createInstrumentation();
|
||||||
|
this._localUtils = localUtils;
|
||||||
|
this._rootObject = new Root(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
markAsRemote() {
|
markAsRemote() {
|
||||||
|
|
@ -138,7 +138,7 @@ export class Connection extends EventEmitter {
|
||||||
this._localUtils?._channel.addStackToTracingNoReply({ callData: { stack: frames, id } }).catch(() => {});
|
this._localUtils?._channel.addStackToTracingNoReply({ callData: { stack: frames, id } }).catch(() => {});
|
||||||
// We need to exit zones before calling into the server, otherwise
|
// We need to exit zones before calling into the server, otherwise
|
||||||
// when we receive events from the server, we would be in an API zone.
|
// when we receive events from the server, we would be in an API zone.
|
||||||
zones.exitZones(() => this.onmessage({ ...message, metadata }));
|
zones.empty().run(() => this.onmessage({ ...message, metadata }));
|
||||||
return await new Promise((resolve, reject) => this._callbacks.set(id, { resolve, reject, apiName, type, method }));
|
return await new Promise((resolve, reject) => this._callbacks.set(id, { resolve, reject, apiName, type, method }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -820,7 +820,7 @@ export class RouteHandler {
|
||||||
this._times = times;
|
this._times = times;
|
||||||
this.url = url;
|
this.url = url;
|
||||||
this.handler = handler;
|
this.handler = handler;
|
||||||
this._svedZone = zones.currentZone();
|
this._svedZone = zones.current().without('apiZone');
|
||||||
}
|
}
|
||||||
|
|
||||||
static prepareInterceptionPatterns(handlers: RouteHandler[]) {
|
static prepareInterceptionPatterns(handlers: RouteHandler[]) {
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export class Waiter {
|
||||||
constructor(channelOwner: ChannelOwner<channels.EventTargetChannel>, event: string) {
|
constructor(channelOwner: ChannelOwner<channels.EventTargetChannel>, event: string) {
|
||||||
this._waitId = createGuid();
|
this._waitId = createGuid();
|
||||||
this._channelOwner = channelOwner;
|
this._channelOwner = channelOwner;
|
||||||
this._savedZone = zones.currentZone();
|
this._savedZone = zones.current().without('apiZone');
|
||||||
|
|
||||||
this._channelOwner._channel.waitForEventInfo({ info: { waitId: this._waitId, phase: 'before', event } }).catch(() => {});
|
this._channelOwner._channel.waitForEventInfo({ info: { waitId: this._waitId, phase: 'before', event } }).catch(() => {});
|
||||||
this._dispose = [
|
this._dispose = [
|
||||||
|
|
|
||||||
|
|
@ -124,4 +124,3 @@ export class FastStats implements Stats {
|
||||||
return (this._sum(this._partialSumMult, x1, y1, x2, y2) - this._sum(this._partialSumC1, x1, y1, x2, y2) * this._sum(this._partialSumC2, x1, y1, x2, y2) / N) / N;
|
return (this._sum(this._partialSumMult, x1, y1, x2, y2) - this._sum(this._partialSumC1, x1, y1, x2, y2) * this._sum(this._partialSumC2, x1, y1, x2, y2) / N) / N;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ export function createInProcessPlaywright(): PlaywrightAPI {
|
||||||
playwrightAPI.firefox._serverLauncher = new BrowserServerLauncherImpl('firefox');
|
playwrightAPI.firefox._serverLauncher = new BrowserServerLauncherImpl('firefox');
|
||||||
playwrightAPI.webkit._serverLauncher = new BrowserServerLauncherImpl('webkit');
|
playwrightAPI.webkit._serverLauncher = new BrowserServerLauncherImpl('webkit');
|
||||||
playwrightAPI._android._serverLauncher = new AndroidServerLauncherImpl();
|
playwrightAPI._android._serverLauncher = new AndroidServerLauncherImpl();
|
||||||
|
playwrightAPI._bidiChromium._serverLauncher = new BrowserServerLauncherImpl('bidiChromium');
|
||||||
|
playwrightAPI._bidiFirefox._serverLauncher = new BrowserServerLauncherImpl('bidiFirefox');
|
||||||
|
|
||||||
// Switch to async dispatch after we got Playwright object.
|
// Switch to async dispatch after we got Playwright object.
|
||||||
dispatcherConnection.onmessage = message => setImmediate(() => clientConnection.dispatch(message));
|
dispatcherConnection.onmessage = message => setImmediate(() => clientConnection.dispatch(message));
|
||||||
|
|
|
||||||
|
|
@ -89,13 +89,16 @@ export const commandsWithTracingSnapshots = new Set([
|
||||||
'Page.mouseClick',
|
'Page.mouseClick',
|
||||||
'Page.mouseWheel',
|
'Page.mouseWheel',
|
||||||
'Page.touchscreenTap',
|
'Page.touchscreenTap',
|
||||||
|
'Page.accessibilitySnapshot',
|
||||||
'Frame.evalOnSelector',
|
'Frame.evalOnSelector',
|
||||||
'Frame.evalOnSelectorAll',
|
'Frame.evalOnSelectorAll',
|
||||||
'Frame.addScriptTag',
|
'Frame.addScriptTag',
|
||||||
'Frame.addStyleTag',
|
'Frame.addStyleTag',
|
||||||
|
'Frame.ariaSnapshot',
|
||||||
'Frame.blur',
|
'Frame.blur',
|
||||||
'Frame.check',
|
'Frame.check',
|
||||||
'Frame.click',
|
'Frame.click',
|
||||||
|
'Frame.content',
|
||||||
'Frame.dragAndDrop',
|
'Frame.dragAndDrop',
|
||||||
'Frame.dblclick',
|
'Frame.dblclick',
|
||||||
'Frame.dispatchEvent',
|
'Frame.dispatchEvent',
|
||||||
|
|
@ -116,6 +119,9 @@ export const commandsWithTracingSnapshots = new Set([
|
||||||
'Frame.isVisible',
|
'Frame.isVisible',
|
||||||
'Frame.isEditable',
|
'Frame.isEditable',
|
||||||
'Frame.press',
|
'Frame.press',
|
||||||
|
'Frame.querySelector',
|
||||||
|
'Frame.querySelectorAll',
|
||||||
|
'Frame.queryCount',
|
||||||
'Frame.selectOption',
|
'Frame.selectOption',
|
||||||
'Frame.setContent',
|
'Frame.setContent',
|
||||||
'Frame.setInputFiles',
|
'Frame.setInputFiles',
|
||||||
|
|
@ -133,8 +139,10 @@ export const commandsWithTracingSnapshots = new Set([
|
||||||
'ElementHandle.evaluateExpressionHandle',
|
'ElementHandle.evaluateExpressionHandle',
|
||||||
'ElementHandle.evalOnSelector',
|
'ElementHandle.evalOnSelector',
|
||||||
'ElementHandle.evalOnSelectorAll',
|
'ElementHandle.evalOnSelectorAll',
|
||||||
|
'ElementHandle.boundingBox',
|
||||||
'ElementHandle.check',
|
'ElementHandle.check',
|
||||||
'ElementHandle.click',
|
'ElementHandle.click',
|
||||||
|
'ElementHandle.contentFrame',
|
||||||
'ElementHandle.dblclick',
|
'ElementHandle.dblclick',
|
||||||
'ElementHandle.dispatchEvent',
|
'ElementHandle.dispatchEvent',
|
||||||
'ElementHandle.fill',
|
'ElementHandle.fill',
|
||||||
|
|
@ -150,6 +158,8 @@ export const commandsWithTracingSnapshots = new Set([
|
||||||
'ElementHandle.isHidden',
|
'ElementHandle.isHidden',
|
||||||
'ElementHandle.isVisible',
|
'ElementHandle.isVisible',
|
||||||
'ElementHandle.press',
|
'ElementHandle.press',
|
||||||
|
'ElementHandle.querySelector',
|
||||||
|
'ElementHandle.querySelectorAll',
|
||||||
'ElementHandle.screenshot',
|
'ElementHandle.screenshot',
|
||||||
'ElementHandle.scrollIntoViewIfNeeded',
|
'ElementHandle.scrollIntoViewIfNeeded',
|
||||||
'ElementHandle.selectOption',
|
'ElementHandle.selectOption',
|
||||||
|
|
|
||||||
|
|
@ -524,5 +524,3 @@ class ClankBrowserProcess implements BrowserProcess {
|
||||||
await this._browser.close();
|
await this._browser.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
/**
|
|
||||||
* 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 { parseYamlTemplate } from '../utils/isomorphic/ariaSnapshot';
|
|
||||||
import type { AriaTemplateNode, ParsedYaml } from '@isomorphic/ariaSnapshot';
|
|
||||||
import { yaml } from '../utilsBundle';
|
|
||||||
|
|
||||||
export function parseAriaSnapshot(text: string): AriaTemplateNode {
|
|
||||||
return parseYamlTemplate(parseYamlForAriaSnapshot(text));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseYamlForAriaSnapshot(text: string): ParsedYaml {
|
|
||||||
const parsed = yaml.parse(text);
|
|
||||||
if (!Array.isArray(parsed))
|
|
||||||
throw new Error('Expected object key starting with "- ":\n\n' + text + '\n');
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
@ -152,6 +152,9 @@ export class BidiBrowser extends Browser {
|
||||||
continue;
|
continue;
|
||||||
page._session.addFrameBrowsingContext(event.context);
|
page._session.addFrameBrowsingContext(event.context);
|
||||||
page._page._frameManager.frameAttached(event.context, parentFrameId);
|
page._page._frameManager.frameAttached(event.context, parentFrameId);
|
||||||
|
const frame = page._page._frameManager.frame(event.context);
|
||||||
|
if (frame)
|
||||||
|
frame._url = event.url;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|
@ -164,6 +167,7 @@ export class BidiBrowser extends Browser {
|
||||||
const session = this._connection.createMainFrameBrowsingContextSession(event.context);
|
const session = this._connection.createMainFrameBrowsingContextSession(event.context);
|
||||||
const opener = event.originalOpener && this._bidiPages.get(event.originalOpener);
|
const opener = event.originalOpener && this._bidiPages.get(event.originalOpener);
|
||||||
const page = new BidiPage(context, session, opener || null);
|
const page = new BidiPage(context, session, opener || null);
|
||||||
|
page._page.mainFrame()._url = event.url;
|
||||||
this._bidiPages.set(event.context, page);
|
this._bidiPages.set(event.context, page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,25 @@ export class BidiExecutionContext implements js.ExecutionContextDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProperties(context: js.ExecutionContext, objectId: js.ObjectId): Promise<Map<string, js.JSHandle>> {
|
async getProperties(context: js.ExecutionContext, objectId: js.ObjectId): Promise<Map<string, js.JSHandle>> {
|
||||||
throw new Error('Method not implemented.');
|
const handle = this.createHandle(context, { objectId });
|
||||||
|
try {
|
||||||
|
const names = await handle.evaluate(object => {
|
||||||
|
const names = [];
|
||||||
|
const descriptors = Object.getOwnPropertyDescriptors(object);
|
||||||
|
for (const name in descriptors) {
|
||||||
|
if (descriptors[name]?.enumerable)
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
});
|
||||||
|
const values = await Promise.all(names.map(name => handle.evaluateHandle((object, name) => object[name], name)));
|
||||||
|
const map = new Map<string, js.JSHandle>();
|
||||||
|
for (let i = 0; i < names.length; i++)
|
||||||
|
map.set(names[i], values[i]);
|
||||||
|
return map;
|
||||||
|
} finally {
|
||||||
|
handle.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createHandle(context: js.ExecutionContext, jsRemoteObject: js.RemoteObject): js.JSHandle {
|
createHandle(context: js.ExecutionContext, jsRemoteObject: js.RemoteObject): js.JSHandle {
|
||||||
|
|
|
||||||
|
|
@ -33,14 +33,14 @@ export class RawKeyboardImpl implements input.RawKeyboard {
|
||||||
|
|
||||||
async keydown(modifiers: Set<types.KeyboardModifier>, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number, autoRepeat: boolean, text: string | undefined): Promise<void> {
|
async keydown(modifiers: Set<types.KeyboardModifier>, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number, autoRepeat: boolean, text: string | undefined): Promise<void> {
|
||||||
const actions: bidi.Input.KeySourceAction[] = [];
|
const actions: bidi.Input.KeySourceAction[] = [];
|
||||||
actions.push({ type: 'keyDown', value: getBidiKeyValue(key) });
|
actions.push({ type: 'keyDown', value: getBidiKeyValue(code) });
|
||||||
// TODO: add modifiers?
|
// TODO: add modifiers?
|
||||||
await this._performActions(actions);
|
await this._performActions(actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
async keyup(modifiers: Set<types.KeyboardModifier>, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number): Promise<void> {
|
async keyup(modifiers: Set<types.KeyboardModifier>, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number): Promise<void> {
|
||||||
const actions: bidi.Input.KeySourceAction[] = [];
|
const actions: bidi.Input.KeySourceAction[] = [];
|
||||||
actions.push({ type: 'keyUp', value: getBidiKeyValue(key) });
|
actions.push({ type: 'keyUp', value: getBidiKeyValue(code) });
|
||||||
await this._performActions(actions);
|
await this._performActions(actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,9 +67,13 @@ export class BidiNetworkManager {
|
||||||
if (param.intercepts) {
|
if (param.intercepts) {
|
||||||
// We do not support intercepting redirects.
|
// We do not support intercepting redirects.
|
||||||
if (redirectedFrom) {
|
if (redirectedFrom) {
|
||||||
|
let params = {};
|
||||||
|
if (redirectedFrom._originalRequestRoute?._alreadyContinuedHeaders)
|
||||||
|
params = toBidiRequestHeaders(redirectedFrom._originalRequestRoute._alreadyContinuedHeaders ?? []);
|
||||||
|
|
||||||
this._session.sendMayFail('network.continueRequest', {
|
this._session.sendMayFail('network.continueRequest', {
|
||||||
request: param.request.request,
|
request: param.request.request,
|
||||||
...(redirectedFrom._originalRequestRoute?._alreadyContinuedHeaders || {}),
|
...params,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
route = new BidiRouteImpl(this._session, param.request.request);
|
route = new BidiRouteImpl(this._session, param.request.request);
|
||||||
|
|
@ -302,11 +306,9 @@ 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[] } {
|
function toBidiRequestHeaders(allHeaders: types.HeadersArray): { headers: bidi.Network.Header[] } {
|
||||||
const bidiHeaders = toBidiHeaders(allHeaders);
|
const bidiHeaders = toBidiHeaders(allHeaders);
|
||||||
const cookies = bidiHeaders.filter(h => h.name.toLowerCase() === 'cookie');
|
return { headers: bidiHeaders };
|
||||||
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[] } {
|
function toBidiResponseHeaders(headers: types.HeadersArray): { cookies: bidi.Network.SetCookieHeader[], headers: bidi.Network.Header[] } {
|
||||||
|
|
|
||||||
|
|
@ -399,7 +399,7 @@ export class BidiPage implements PageDelegate {
|
||||||
context: this._session.sessionId,
|
context: this._session.sessionId,
|
||||||
format: {
|
format: {
|
||||||
type: `image/${format === 'png' ? 'png' : 'jpeg'}`,
|
type: `image/${format === 'png' ? 'png' : 'jpeg'}`,
|
||||||
quality: quality || 80,
|
quality: quality ? quality / 100 : 0.8,
|
||||||
},
|
},
|
||||||
origin: documentRect ? 'document' : 'viewport',
|
origin: documentRect ? 'document' : 'viewport',
|
||||||
clip: {
|
clip: {
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,18 @@
|
||||||
|
|
||||||
/* eslint-disable curly */
|
/* eslint-disable curly */
|
||||||
|
|
||||||
export const getBidiKeyValue = (key: string) => {
|
export const getBidiKeyValue = (code: string) => {
|
||||||
switch (key) {
|
switch (code) {
|
||||||
case '\r':
|
case '\r':
|
||||||
case '\n':
|
case '\n':
|
||||||
key = 'Enter';
|
code = 'Enter';
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Measures the number of code points rather than UTF-16 code units.
|
// Measures the number of code points rather than UTF-16 code units.
|
||||||
if ([...key].length === 1) {
|
if ([...code].length === 1) {
|
||||||
return key;
|
return code;
|
||||||
}
|
}
|
||||||
switch (key) {
|
switch (code) {
|
||||||
case 'Cancel':
|
case 'Cancel':
|
||||||
return '\uE001';
|
return '\uE001';
|
||||||
case 'Help':
|
case 'Help':
|
||||||
|
|
@ -131,6 +131,8 @@ export const getBidiKeyValue = (key: string) => {
|
||||||
return '\uE052';
|
return '\uE052';
|
||||||
case 'MetaRight':
|
case 'MetaRight':
|
||||||
return '\uE053';
|
return '\uE053';
|
||||||
|
case 'Space':
|
||||||
|
return ' ';
|
||||||
case 'Digit0':
|
case 'Digit0':
|
||||||
return '0';
|
return '0';
|
||||||
case 'Digit1':
|
case 'Digit1':
|
||||||
|
|
@ -226,6 +228,6 @@ export const getBidiKeyValue = (key: string) => {
|
||||||
case 'Quote':
|
case 'Quote':
|
||||||
return '"';
|
return '"';
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown key: "${key}"`);
|
throw new Error(`Unknown key: "${code}"`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -314,6 +314,10 @@ export abstract class BrowserContext extends SdkObject {
|
||||||
return this.doSetHTTPCredentials(httpCredentials);
|
return this.doSetHTTPCredentials(httpCredentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasBinding(name: string) {
|
||||||
|
return this._pageBindings.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
async exposeBinding(name: string, needsHandle: boolean, playwrightBinding: frames.FunctionWithSource): Promise<void> {
|
async exposeBinding(name: string, needsHandle: boolean, playwrightBinding: frames.FunctionWithSource): Promise<void> {
|
||||||
if (this._pageBindings.has(name))
|
if (this._pageBindings.has(name))
|
||||||
throw new Error(`Function "${name}" has been already registered`);
|
throw new Error(`Function "${name}" has been already registered`);
|
||||||
|
|
@ -414,8 +418,8 @@ export abstract class BrowserContext extends SdkObject {
|
||||||
this._options.httpCredentials = { username, password: password || '' };
|
this._options.httpCredentials = { username, password: password || '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
async addInitScript(source: string) {
|
async addInitScript(source: string, name?: string) {
|
||||||
const initScript = new InitScript(source);
|
const initScript = new InitScript(source, false /* internal */, name);
|
||||||
this.initScripts.push(initScript);
|
this.initScripts.push(initScript);
|
||||||
await this.doAddInitScript(initScript);
|
await this.doAddInitScript(initScript);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,9 @@ import type { Playwright } from './playwright';
|
||||||
import { Recorder } from './recorder';
|
import { Recorder } from './recorder';
|
||||||
import { EmptyRecorderApp } from './recorder/recorderApp';
|
import { EmptyRecorderApp } from './recorder/recorderApp';
|
||||||
import { asLocator, type Language } from '../utils';
|
import { asLocator, type Language } from '../utils';
|
||||||
import { parseYamlForAriaSnapshot } from './ariaSnapshot';
|
import { yaml } from '../utilsBundle';
|
||||||
import type { ParsedYaml } from '../utils/isomorphic/ariaSnapshot';
|
|
||||||
import { parseYamlTemplate } from '../utils/isomorphic/ariaSnapshot';
|
|
||||||
import { unsafeLocatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser';
|
import { unsafeLocatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser';
|
||||||
|
import { parseAriaSnapshotUnsafe } from '../utils/isomorphic/ariaSnapshot';
|
||||||
|
|
||||||
const internalMetadata = serverSideCallMetadata();
|
const internalMetadata = serverSideCallMetadata();
|
||||||
|
|
||||||
|
|
@ -40,9 +39,6 @@ export class DebugController extends SdkObject {
|
||||||
SetModeRequested: 'setModeRequested',
|
SetModeRequested: 'setModeRequested',
|
||||||
};
|
};
|
||||||
|
|
||||||
private _autoCloseTimer: NodeJS.Timeout | undefined;
|
|
||||||
// TODO: remove in 1.27
|
|
||||||
private _autoCloseAllowed = false;
|
|
||||||
private _trackHierarchyListener: InstrumentationListener | undefined;
|
private _trackHierarchyListener: InstrumentationListener | undefined;
|
||||||
private _playwright: Playwright;
|
private _playwright: Playwright;
|
||||||
_sdkLanguage: Language = 'javascript';
|
_sdkLanguage: Language = 'javascript';
|
||||||
|
|
@ -58,22 +54,18 @@ export class DebugController extends SdkObject {
|
||||||
this._sdkLanguage = sdkLanguage;
|
this._sdkLanguage = sdkLanguage;
|
||||||
}
|
}
|
||||||
|
|
||||||
setAutoCloseAllowed(allowed: boolean) {
|
|
||||||
this._autoCloseAllowed = allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
this.setReportStateChanged(false);
|
this.setReportStateChanged(false);
|
||||||
this.setAutoCloseAllowed(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setReportStateChanged(enabled: boolean) {
|
setReportStateChanged(enabled: boolean) {
|
||||||
if (enabled && !this._trackHierarchyListener) {
|
if (enabled && !this._trackHierarchyListener) {
|
||||||
this._trackHierarchyListener = {
|
this._trackHierarchyListener = {
|
||||||
onPageOpen: () => this._emitSnapshot(),
|
onPageOpen: () => this._emitSnapshot(false),
|
||||||
onPageClose: () => this._emitSnapshot(),
|
onPageClose: () => this._emitSnapshot(false),
|
||||||
};
|
};
|
||||||
this._playwright.instrumentation.addListener(this._trackHierarchyListener, null);
|
this._playwright.instrumentation.addListener(this._trackHierarchyListener, null);
|
||||||
|
this._emitSnapshot(true);
|
||||||
} else if (!enabled && this._trackHierarchyListener) {
|
} else if (!enabled && this._trackHierarchyListener) {
|
||||||
this._playwright.instrumentation.removeListener(this._trackHierarchyListener);
|
this._playwright.instrumentation.removeListener(this._trackHierarchyListener);
|
||||||
this._trackHierarchyListener = undefined;
|
this._trackHierarchyListener = undefined;
|
||||||
|
|
@ -102,7 +94,6 @@ export class DebugController extends SdkObject {
|
||||||
recorder.hideHighlightedSelector();
|
recorder.hideHighlightedSelector();
|
||||||
recorder.setMode('none');
|
recorder.setMode('none');
|
||||||
}
|
}
|
||||||
this.setAutoCloseEnabled(true);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,37 +118,16 @@ export class DebugController extends SdkObject {
|
||||||
recorder.setOutput(this._codegenId, params.file);
|
recorder.setOutput(this._codegenId, params.file);
|
||||||
recorder.setMode(params.mode);
|
recorder.setMode(params.mode);
|
||||||
}
|
}
|
||||||
this.setAutoCloseEnabled(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setAutoCloseEnabled(enabled: boolean) {
|
|
||||||
if (!this._autoCloseAllowed)
|
|
||||||
return;
|
|
||||||
if (this._autoCloseTimer)
|
|
||||||
clearTimeout(this._autoCloseTimer);
|
|
||||||
if (!enabled)
|
|
||||||
return;
|
|
||||||
const heartBeat = () => {
|
|
||||||
if (!this._playwright.allPages().length)
|
|
||||||
gracefullyProcessExitDoNotHang(0);
|
|
||||||
else
|
|
||||||
this._autoCloseTimer = setTimeout(heartBeat, 5000);
|
|
||||||
};
|
|
||||||
this._autoCloseTimer = setTimeout(heartBeat, 30000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async highlight(params: { selector?: string, ariaTemplate?: string }) {
|
async highlight(params: { selector?: string, ariaTemplate?: string }) {
|
||||||
// Assert parameters validity.
|
// Assert parameters validity.
|
||||||
if (params.selector)
|
if (params.selector)
|
||||||
unsafeLocatorOrSelectorAsSelector(this._sdkLanguage, params.selector, 'data-testid');
|
unsafeLocatorOrSelectorAsSelector(this._sdkLanguage, params.selector, 'data-testid');
|
||||||
let parsedYaml: ParsedYaml | undefined;
|
const ariaTemplate = params.ariaTemplate ? parseAriaSnapshotUnsafe(yaml, params.ariaTemplate) : undefined;
|
||||||
if (params.ariaTemplate) {
|
|
||||||
parsedYaml = parseYamlForAriaSnapshot(params.ariaTemplate);
|
|
||||||
parseYamlTemplate(parsedYaml);
|
|
||||||
}
|
|
||||||
for (const recorder of await this._allRecorders()) {
|
for (const recorder of await this._allRecorders()) {
|
||||||
if (parsedYaml)
|
if (ariaTemplate)
|
||||||
recorder.setHighlightedAriaTemplate(parsedYaml);
|
recorder.setHighlightedAriaTemplate(ariaTemplate);
|
||||||
else if (params.selector)
|
else if (params.selector)
|
||||||
recorder.setHighlightedSelector(this._sdkLanguage, params.selector);
|
recorder.setHighlightedSelector(this._sdkLanguage, params.selector);
|
||||||
}
|
}
|
||||||
|
|
@ -188,24 +158,10 @@ export class DebugController extends SdkObject {
|
||||||
await Promise.all(this.allBrowsers().map(browser => browser.close({ reason: 'Close all browsers requested' })));
|
await Promise.all(this.allBrowsers().map(browser => browser.close({ reason: 'Close all browsers requested' })));
|
||||||
}
|
}
|
||||||
|
|
||||||
private _emitSnapshot() {
|
private _emitSnapshot(initial: boolean) {
|
||||||
const browsers = [];
|
const pageCount = this._playwright.allPages().length;
|
||||||
let pageCount = 0;
|
if (initial && !pageCount)
|
||||||
for (const browser of this._playwright.allBrowsers()) {
|
return;
|
||||||
const b = {
|
|
||||||
contexts: [] as any[]
|
|
||||||
};
|
|
||||||
browsers.push(b);
|
|
||||||
for (const context of browser.contexts()) {
|
|
||||||
const c = {
|
|
||||||
pages: [] as any[]
|
|
||||||
};
|
|
||||||
b.contexts.push(c);
|
|
||||||
for (const page of context.pages())
|
|
||||||
c.pages.push(page.mainFrame().url());
|
|
||||||
pageCount += context.pages().length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.emit(DebugController.Events.StateChanged, { pageCount });
|
this.emit(DebugController.Events.StateChanged, { pageCount });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ function shouldPauseBeforeStep(metadata: CallMetadata): boolean {
|
||||||
// Always stop on 'close'
|
// Always stop on 'close'
|
||||||
if (metadata.method === 'close')
|
if (metadata.method === 'close')
|
||||||
return true;
|
return true;
|
||||||
if (metadata.method === 'waitForSelector' || metadata.method === 'waitForEventInfo')
|
if (metadata.method === 'waitForSelector' || metadata.method === 'waitForEventInfo' || metadata.method === 'querySelector' || metadata.method === 'querySelectorAll')
|
||||||
return false; // Never stop on those, primarily for the test harness.
|
return false; // Never stop on those, primarily for the test harness.
|
||||||
const step = metadata.type + '.' + metadata.method;
|
const step = metadata.type + '.' + metadata.method;
|
||||||
// Stop before everything that generates snapshot. But don't stop before those marked as pausesBeforeInputActions
|
// Stop before everything that generates snapshot. But don't stop before those marked as pausesBeforeInputActions
|
||||||
|
|
|
||||||
|
|
@ -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/132.0.6834.46 Mobile Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 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/132.0.6834.57 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/132.0.6834.46 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/132.0.6834.57 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/132.0.6834.46 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/132.0.6834.57 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/132.0.6834.46 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/132.0.6834.57 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/132.0.6834.46 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/132.0.6834.57 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/132.0.6834.46 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/132.0.6834.57 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/132.0.6834.46 Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 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/132.0.6834.57 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/132.0.6834.46 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/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 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/132.0.6834.57 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/132.0.6834.46 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/132.0.6834.57 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/132.0.6834.46 Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 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/132.0.6834.57 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/132.0.6834.46 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/132.0.6834.57 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/132.0.6834.46 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/132.0.6834.57 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/132.0.6834.46 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/132.0.6834.57 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/132.0.6834.46 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/132.0.6834.57 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/132.0.6834.46 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/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Safari/537.36 Edg/132.0.6834.46",
|
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Safari/537.36 Edg/132.0.6834.57",
|
||||||
"screen": {
|
"screen": {
|
||||||
"width": 1792,
|
"width": 1792,
|
||||||
"height": 1120
|
"height": 1120
|
||||||
|
|
@ -1592,7 +1592,7 @@
|
||||||
"defaultBrowserType": "chromium"
|
"defaultBrowserType": "chromium"
|
||||||
},
|
},
|
||||||
"Desktop Firefox HiDPI": {
|
"Desktop Firefox HiDPI": {
|
||||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0",
|
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0.3) Gecko/20100101 Firefox/133.0.3",
|
||||||
"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/132.0.6834.46 Safari/537.36",
|
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Safari/537.36 Edg/132.0.6834.46",
|
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Safari/537.36 Edg/132.0.6834.57",
|
||||||
"screen": {
|
"screen": {
|
||||||
"width": 1920,
|
"width": 1920,
|
||||||
"height": 1080
|
"height": 1080
|
||||||
|
|
@ -1652,7 +1652,7 @@
|
||||||
"defaultBrowserType": "chromium"
|
"defaultBrowserType": "chromium"
|
||||||
},
|
},
|
||||||
"Desktop Firefox": {
|
"Desktop Firefox": {
|
||||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0",
|
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0.3) Gecko/20100101 Firefox/133.0.3",
|
||||||
"screen": {
|
"screen": {
|
||||||
"width": 1920,
|
"width": 1920,
|
||||||
"height": 1080
|
"height": 1080
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,7 @@
|
||||||
../../generated/
|
../../generated/
|
||||||
../../protocol/
|
../../protocol/
|
||||||
../../utils/
|
../../utils/
|
||||||
|
../../utils/isomorphic
|
||||||
|
../../utilsBundle.ts
|
||||||
../../zipBundle.ts
|
../../zipBundle.ts
|
||||||
../**
|
../**
|
||||||
|
|
|
||||||
|
|
@ -288,7 +288,7 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
|
||||||
async setWebSocketInterceptionPatterns(params: channels.PageSetWebSocketInterceptionPatternsParams, metadata: CallMetadata): Promise<void> {
|
async setWebSocketInterceptionPatterns(params: channels.PageSetWebSocketInterceptionPatternsParams, metadata: CallMetadata): Promise<void> {
|
||||||
this._webSocketInterceptionPatterns = params.patterns;
|
this._webSocketInterceptionPatterns = params.patterns;
|
||||||
if (params.patterns.length)
|
if (params.patterns.length)
|
||||||
await WebSocketRouteDispatcher.installIfNeeded(this, this._context);
|
await WebSocketRouteDispatcher.installIfNeeded(this._context);
|
||||||
}
|
}
|
||||||
|
|
||||||
async storageState(params: channels.BrowserContextStorageStateParams, metadata: CallMetadata): Promise<channels.BrowserContextStorageStateResult> {
|
async storageState(params: channels.BrowserContextStorageStateParams, metadata: CallMetadata): Promise<channels.BrowserContextStorageStateResult> {
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ import type { CallMetadata } from '../instrumentation';
|
||||||
import type { BrowserContextDispatcher } from './browserContextDispatcher';
|
import type { BrowserContextDispatcher } from './browserContextDispatcher';
|
||||||
import type { PageDispatcher } from './pageDispatcher';
|
import type { PageDispatcher } from './pageDispatcher';
|
||||||
import { debugAssert } from '../../utils';
|
import { debugAssert } from '../../utils';
|
||||||
import { parseAriaSnapshot } from '../ariaSnapshot';
|
import { parseAriaSnapshotUnsafe } from '../../utils/isomorphic/ariaSnapshot';
|
||||||
|
import { yaml } from '../../utilsBundle';
|
||||||
|
|
||||||
export class FrameDispatcher extends Dispatcher<Frame, channels.FrameChannel, BrowserContextDispatcher | PageDispatcher> implements channels.FrameChannel {
|
export class FrameDispatcher extends Dispatcher<Frame, channels.FrameChannel, BrowserContextDispatcher | PageDispatcher> implements channels.FrameChannel {
|
||||||
_type_Frame = true;
|
_type_Frame = true;
|
||||||
|
|
@ -261,7 +262,7 @@ export class FrameDispatcher extends Dispatcher<Frame, channels.FrameChannel, Br
|
||||||
metadata.potentiallyClosesScope = true;
|
metadata.potentiallyClosesScope = true;
|
||||||
let expectedValue = params.expectedValue ? parseArgument(params.expectedValue) : undefined;
|
let expectedValue = params.expectedValue ? parseArgument(params.expectedValue) : undefined;
|
||||||
if (params.expression === 'to.match.aria' && expectedValue)
|
if (params.expression === 'to.match.aria' && expectedValue)
|
||||||
expectedValue = parseAriaSnapshot(expectedValue);
|
expectedValue = parseAriaSnapshotUnsafe(yaml, expectedValue);
|
||||||
const result = await this._frame.expect(metadata, params.selector, { ...params, expectedValue });
|
const result = await this._frame.expect(metadata, params.selector, { ...params, expectedValue });
|
||||||
if (result.received !== undefined)
|
if (result.received !== undefined)
|
||||||
result.received = serializeResult(result.received);
|
result.received = serializeResult(result.received);
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,7 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
|
||||||
async setWebSocketInterceptionPatterns(params: channels.PageSetWebSocketInterceptionPatternsParams, metadata: CallMetadata): Promise<void> {
|
async setWebSocketInterceptionPatterns(params: channels.PageSetWebSocketInterceptionPatternsParams, metadata: CallMetadata): Promise<void> {
|
||||||
this._webSocketInterceptionPatterns = params.patterns;
|
this._webSocketInterceptionPatterns = params.patterns;
|
||||||
if (params.patterns.length)
|
if (params.patterns.length)
|
||||||
await WebSocketRouteDispatcher.installIfNeeded(this.parentScope(), this._page);
|
await WebSocketRouteDispatcher.installIfNeeded(this._page);
|
||||||
}
|
}
|
||||||
|
|
||||||
async expectScreenshot(params: channels.PageExpectScreenshotParams, metadata: CallMetadata): Promise<channels.PageExpectScreenshotResult> {
|
async expectScreenshot(params: channels.PageExpectScreenshotParams, metadata: CallMetadata): Promise<channels.PageExpectScreenshotResult> {
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import type { BrowserContext } from '../browserContext';
|
||||||
import type { Frame } from '../frames';
|
import type { Frame } from '../frames';
|
||||||
import { Page } from '../page';
|
import { Page } from '../page';
|
||||||
import type * as channels from '@protocol/channels';
|
import type * as channels from '@protocol/channels';
|
||||||
import { Dispatcher } from './dispatcher';
|
import { Dispatcher, existingDispatcher } from './dispatcher';
|
||||||
import { createGuid, urlMatches } from '../../utils';
|
import { createGuid, urlMatches } from '../../utils';
|
||||||
import { PageDispatcher } from './pageDispatcher';
|
import { PageDispatcher } from './pageDispatcher';
|
||||||
import type { BrowserContextDispatcher } from './browserContextDispatcher';
|
import type { BrowserContextDispatcher } from './browserContextDispatcher';
|
||||||
|
|
@ -26,9 +26,6 @@ import * as webSocketMockSource from '../../generated/webSocketMockSource';
|
||||||
import type * as ws from '../injected/webSocketMock';
|
import type * as ws from '../injected/webSocketMock';
|
||||||
import { eventsHelper } from '../../utils/eventsHelper';
|
import { eventsHelper } from '../../utils/eventsHelper';
|
||||||
|
|
||||||
const kBindingInstalledSymbol = Symbol('webSocketRouteBindingInstalled');
|
|
||||||
const kInitScriptInstalledSymbol = Symbol('webSocketRouteInitScriptInstalled');
|
|
||||||
|
|
||||||
export class WebSocketRouteDispatcher extends Dispatcher<{ guid: string }, channels.WebSocketRouteChannel, PageDispatcher | BrowserContextDispatcher> implements channels.WebSocketRouteChannel {
|
export class WebSocketRouteDispatcher extends Dispatcher<{ guid: string }, channels.WebSocketRouteChannel, PageDispatcher | BrowserContextDispatcher> implements channels.WebSocketRouteChannel {
|
||||||
_type_WebSocketRoute = true;
|
_type_WebSocketRoute = true;
|
||||||
private _id: string;
|
private _id: string;
|
||||||
|
|
@ -57,18 +54,18 @@ export class WebSocketRouteDispatcher extends Dispatcher<{ guid: string }, chann
|
||||||
(scope as any)._dispatchEvent('webSocketRoute', { webSocketRoute: this });
|
(scope as any)._dispatchEvent('webSocketRoute', { webSocketRoute: this });
|
||||||
}
|
}
|
||||||
|
|
||||||
static async installIfNeeded(contextDispatcher: BrowserContextDispatcher, target: Page | BrowserContext) {
|
static async installIfNeeded(target: Page | BrowserContext) {
|
||||||
|
const kBindingName = '__pwWebSocketBinding';
|
||||||
const context = target instanceof Page ? target.context() : target;
|
const context = target instanceof Page ? target.context() : target;
|
||||||
if (!(context as any)[kBindingInstalledSymbol]) {
|
if (!context.hasBinding(kBindingName)) {
|
||||||
(context as any)[kBindingInstalledSymbol] = true;
|
await context.exposeBinding(kBindingName, false, (source, payload: ws.BindingPayload) => {
|
||||||
|
|
||||||
await context.exposeBinding('__pwWebSocketBinding', false, (source, payload: ws.BindingPayload) => {
|
|
||||||
if (payload.type === 'onCreate') {
|
if (payload.type === 'onCreate') {
|
||||||
const pageDispatcher = PageDispatcher.fromNullable(contextDispatcher, source.page);
|
const contextDispatcher = existingDispatcher<BrowserContextDispatcher>(context);
|
||||||
|
const pageDispatcher = contextDispatcher ? PageDispatcher.fromNullable(contextDispatcher, source.page) : undefined;
|
||||||
let scope: PageDispatcher | BrowserContextDispatcher | undefined;
|
let scope: PageDispatcher | BrowserContextDispatcher | undefined;
|
||||||
if (pageDispatcher && matchesPattern(pageDispatcher, context._options.baseURL, payload.url))
|
if (pageDispatcher && matchesPattern(pageDispatcher, context._options.baseURL, payload.url))
|
||||||
scope = pageDispatcher;
|
scope = pageDispatcher;
|
||||||
else if (matchesPattern(contextDispatcher, context._options.baseURL, payload.url))
|
else if (contextDispatcher && matchesPattern(contextDispatcher, context._options.baseURL, payload.url))
|
||||||
scope = contextDispatcher;
|
scope = contextDispatcher;
|
||||||
if (scope) {
|
if (scope) {
|
||||||
new WebSocketRouteDispatcher(scope, payload.id, payload.url, source.frame);
|
new WebSocketRouteDispatcher(scope, payload.id, payload.url, source.frame);
|
||||||
|
|
@ -91,15 +88,15 @@ export class WebSocketRouteDispatcher extends Dispatcher<{ guid: string }, chann
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(target as any)[kInitScriptInstalledSymbol]) {
|
const kInitScriptName = 'webSocketMockSource';
|
||||||
(target as any)[kInitScriptInstalledSymbol] = true;
|
if (!target.initScripts.find(s => s.name === kInitScriptName)) {
|
||||||
await target.addInitScript(`
|
await target.addInitScript(`
|
||||||
(() => {
|
(() => {
|
||||||
const module = {};
|
const module = {};
|
||||||
${webSocketMockSource.source}
|
${webSocketMockSource.source}
|
||||||
(module.exports.inject())(globalThis);
|
(module.exports.inject())(globalThis);
|
||||||
})();
|
})();
|
||||||
`);
|
`, kInitScriptName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -778,7 +778,9 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
||||||
async _setChecked(progress: Progress, state: boolean, options: { position?: types.Point } & types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> {
|
async _setChecked(progress: Progress, state: boolean, options: { position?: types.Point } & types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> {
|
||||||
const isChecked = async () => {
|
const isChecked = async () => {
|
||||||
const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'checked'), {});
|
const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'checked'), {});
|
||||||
return throwRetargetableDOMError(result);
|
if (result === 'error:notconnected' || result.received === 'error:notconnected')
|
||||||
|
throwElementIsNotAttached();
|
||||||
|
return result.matches;
|
||||||
};
|
};
|
||||||
await this._markAsTargetElement(progress.metadata);
|
await this._markAsTargetElement(progress.metadata);
|
||||||
if (await isChecked() === state)
|
if (await isChecked() === state)
|
||||||
|
|
@ -913,10 +915,14 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
||||||
|
|
||||||
export function throwRetargetableDOMError<T>(result: T | 'error:notconnected'): T {
|
export function throwRetargetableDOMError<T>(result: T | 'error:notconnected'): T {
|
||||||
if (result === 'error:notconnected')
|
if (result === 'error:notconnected')
|
||||||
throw new Error('Element is not attached to the DOM');
|
throwElementIsNotAttached();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function throwElementIsNotAttached(): never {
|
||||||
|
throw new Error('Element is not attached to the DOM');
|
||||||
|
}
|
||||||
|
|
||||||
export function assertDone(result: 'done'): void {
|
export function assertDone(result: 'done'): void {
|
||||||
// This function converts 'done' to void and ensures typescript catches unhandled errors.
|
// This function converts 'done' to void and ensures typescript catches unhandled errors.
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -436,4 +436,3 @@ function toJugglerProxyOptions(proxy: types.ProxySettings) {
|
||||||
// Prefs for quick fixes that didn't make it to the build.
|
// Prefs for quick fixes that didn't make it to the build.
|
||||||
// Should all be moved to `playwright.cfg`.
|
// Should all be moved to `playwright.cfg`.
|
||||||
const kBandaidFirefoxUserPrefs = {};
|
const kBandaidFirefoxUserPrefs = {};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,4 +101,3 @@ class JugglerReadyState extends BrowserReadyState {
|
||||||
this._wsEndpoint.resolve(undefined);
|
this._wsEndpoint.resolve(undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -518,6 +518,10 @@ export module Protocol {
|
||||||
}|null;
|
}|null;
|
||||||
};
|
};
|
||||||
export type setViewportSizeReturnValue = void;
|
export type setViewportSizeReturnValue = void;
|
||||||
|
export type setZoomParameters = {
|
||||||
|
zoom: number;
|
||||||
|
};
|
||||||
|
export type setZoomReturnValue = void;
|
||||||
export type bringToFrontParameters = {
|
export type bringToFrontParameters = {
|
||||||
};
|
};
|
||||||
export type bringToFrontReturnValue = void;
|
export type bringToFrontReturnValue = void;
|
||||||
|
|
@ -1134,6 +1138,7 @@ export module Protocol {
|
||||||
"Page.setFileInputFiles": Page.setFileInputFilesParameters;
|
"Page.setFileInputFiles": Page.setFileInputFilesParameters;
|
||||||
"Page.addBinding": Page.addBindingParameters;
|
"Page.addBinding": Page.addBindingParameters;
|
||||||
"Page.setViewportSize": Page.setViewportSizeParameters;
|
"Page.setViewportSize": Page.setViewportSizeParameters;
|
||||||
|
"Page.setZoom": Page.setZoomParameters;
|
||||||
"Page.bringToFront": Page.bringToFrontParameters;
|
"Page.bringToFront": Page.bringToFrontParameters;
|
||||||
"Page.setEmulatedMedia": Page.setEmulatedMediaParameters;
|
"Page.setEmulatedMedia": Page.setEmulatedMediaParameters;
|
||||||
"Page.setCacheDisabled": Page.setCacheDisabledParameters;
|
"Page.setCacheDisabled": Page.setCacheDisabledParameters;
|
||||||
|
|
@ -1215,6 +1220,7 @@ export module Protocol {
|
||||||
"Page.setFileInputFiles": Page.setFileInputFilesReturnValue;
|
"Page.setFileInputFiles": Page.setFileInputFilesReturnValue;
|
||||||
"Page.addBinding": Page.addBindingReturnValue;
|
"Page.addBinding": Page.addBindingReturnValue;
|
||||||
"Page.setViewportSize": Page.setViewportSizeReturnValue;
|
"Page.setViewportSize": Page.setViewportSizeReturnValue;
|
||||||
|
"Page.setZoom": Page.setZoomReturnValue;
|
||||||
"Page.bringToFront": Page.bringToFrontReturnValue;
|
"Page.bringToFront": Page.bringToFrontReturnValue;
|
||||||
"Page.setEmulatedMedia": Page.setEmulatedMediaReturnValue;
|
"Page.setEmulatedMedia": Page.setEmulatedMediaReturnValue;
|
||||||
"Page.setCacheDisabled": Page.setCacheDisabledReturnValue;
|
"Page.setCacheDisabled": Page.setCacheDisabledReturnValue;
|
||||||
|
|
|
||||||
|
|
@ -1301,7 +1301,9 @@ export class Frame extends SdkObject {
|
||||||
const result = await this._callOnElementOnceMatches(metadata, selector, (injected, element, data) => {
|
const result = await this._callOnElementOnceMatches(metadata, selector, (injected, element, data) => {
|
||||||
return injected.elementState(element, data.state);
|
return injected.elementState(element, data.state);
|
||||||
}, { state }, options, scope);
|
}, { state }, options, scope);
|
||||||
return dom.throwRetargetableDOMError(result);
|
if (result.received === 'error:notconnected')
|
||||||
|
dom.throwElementIsNotAttached();
|
||||||
|
return result.matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
async isVisible(metadata: CallMetadata, selector: string, options: types.StrictOptions = {}, scope?: dom.ElementHandle): Promise<boolean> {
|
async isVisible(metadata: CallMetadata, selector: string, options: types.StrictOptions = {}, scope?: dom.ElementHandle): Promise<boolean> {
|
||||||
|
|
@ -1319,8 +1321,8 @@ export class Frame extends SdkObject {
|
||||||
return false;
|
return false;
|
||||||
return await resolved.injected.evaluate((injected, { info, root }) => {
|
return await resolved.injected.evaluate((injected, { info, root }) => {
|
||||||
const element = injected.querySelector(info.parsed, root || document, info.strict);
|
const element = injected.querySelector(info.parsed, root || document, info.strict);
|
||||||
const state = element ? injected.elementState(element, 'visible') : false;
|
const state = element ? injected.elementState(element, 'visible') : { matches: false, received: 'error:notconnected' };
|
||||||
return state === 'error:notconnected' ? false : state;
|
return state.matches;
|
||||||
}, { info: resolved.info, root: resolved.frame === this ? scope : undefined });
|
}, { info: resolved.info, root: resolved.frame === this ? scope : undefined });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e) || isSessionClosedError(e))
|
if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e) || isSessionClosedError(e))
|
||||||
|
|
@ -1809,26 +1811,6 @@ function verifyLifecycle(name: string, waitUntil: types.LifecycleEvent): types.L
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderUnexpectedValue(expression: string, received: any): string {
|
function renderUnexpectedValue(expression: string, received: any): string {
|
||||||
if (expression === 'to.be.checked')
|
|
||||||
return received ? 'checked' : 'unchecked';
|
|
||||||
if (expression === 'to.be.unchecked')
|
|
||||||
return received ? 'unchecked' : 'checked';
|
|
||||||
if (expression === 'to.be.visible')
|
|
||||||
return received ? 'visible' : 'hidden';
|
|
||||||
if (expression === 'to.be.hidden')
|
|
||||||
return received ? 'hidden' : 'visible';
|
|
||||||
if (expression === 'to.be.enabled')
|
|
||||||
return received ? 'enabled' : 'disabled';
|
|
||||||
if (expression === 'to.be.disabled')
|
|
||||||
return received ? 'disabled' : 'enabled';
|
|
||||||
if (expression === 'to.be.editable')
|
|
||||||
return received ? 'editable' : 'readonly';
|
|
||||||
if (expression === 'to.be.readonly')
|
|
||||||
return received ? 'readonly' : 'editable';
|
|
||||||
if (expression === 'to.be.empty')
|
|
||||||
return received ? 'empty' : 'not empty';
|
|
||||||
if (expression === 'to.be.focused')
|
|
||||||
return received ? 'focused' : 'not focused';
|
|
||||||
if (expression === 'to.match.aria')
|
if (expression === 'to.match.aria')
|
||||||
return received ? received.raw : received;
|
return received ? received.raw : received;
|
||||||
return received;
|
return received;
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,9 @@
|
||||||
|
|
||||||
import * as roleUtils from './roleUtils';
|
import * as roleUtils from './roleUtils';
|
||||||
import { getElementComputedStyle } from './domUtils';
|
import { getElementComputedStyle } from './domUtils';
|
||||||
import { escapeRegExp, longestCommonSubstring } from '@isomorphic/stringUtils';
|
import { escapeRegExp, longestCommonSubstring, normalizeWhiteSpace } from '@isomorphic/stringUtils';
|
||||||
import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from './yaml';
|
import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from './yaml';
|
||||||
import type { AriaProps, AriaRole, AriaTemplateNode, AriaTemplateRoleNode, AriaTemplateTextNode } from '@isomorphic/ariaSnapshot';
|
import type { AriaProps, AriaRegex, AriaRole, AriaTemplateNode, AriaTemplateRoleNode, AriaTemplateTextNode } from '@isomorphic/ariaSnapshot';
|
||||||
|
|
||||||
export type AriaNode = AriaProps & {
|
export type AriaNode = AriaProps & {
|
||||||
role: AriaRole | 'fragment';
|
role: AriaRole | 'fragment';
|
||||||
|
|
@ -137,7 +137,7 @@ function toAriaNode(element: Element): AriaNode | null {
|
||||||
if (!role || role === 'presentation' || role === 'none')
|
if (!role || role === 'presentation' || role === 'none')
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
const name = roleUtils.getElementAccessibleName(element, false) || '';
|
const name = normalizeWhiteSpace(roleUtils.getElementAccessibleName(element, false) || '');
|
||||||
const result: AriaNode = { role, name, children: [], element };
|
const result: AriaNode = { role, name, children: [], element };
|
||||||
|
|
||||||
if (roleUtils.kAriaCheckedRoles.includes(role))
|
if (roleUtils.kAriaCheckedRoles.includes(role))
|
||||||
|
|
@ -170,7 +170,7 @@ function normalizeStringChildren(rootA11yNode: AriaNode) {
|
||||||
const flushChildren = (buffer: string[], normalizedChildren: (AriaNode | string)[]) => {
|
const flushChildren = (buffer: string[], normalizedChildren: (AriaNode | string)[]) => {
|
||||||
if (!buffer.length)
|
if (!buffer.length)
|
||||||
return;
|
return;
|
||||||
const text = normalizeWhitespaceWithin(buffer.join('')).trim();
|
const text = normalizeWhiteSpace(buffer.join(''));
|
||||||
if (text)
|
if (text)
|
||||||
normalizedChildren.push(text);
|
normalizedChildren.push(text);
|
||||||
buffer.length = 0;
|
buffer.length = 0;
|
||||||
|
|
@ -196,16 +196,14 @@ function normalizeStringChildren(rootA11yNode: AriaNode) {
|
||||||
visit(rootA11yNode);
|
visit(rootA11yNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeWhitespaceWithin = (text: string) => text.replace(/[\u200b\s\t\r\n]+/g, ' ');
|
function matchesText(text: string, template: AriaRegex | string | undefined): boolean {
|
||||||
|
|
||||||
function matchesText(text: string, template: RegExp | string | undefined): boolean {
|
|
||||||
if (!template)
|
if (!template)
|
||||||
return true;
|
return true;
|
||||||
if (!text)
|
if (!text)
|
||||||
return false;
|
return false;
|
||||||
if (typeof template === 'string')
|
if (typeof template === 'string')
|
||||||
return text === template;
|
return text === template;
|
||||||
return !!text.match(template);
|
return !!text.match(new RegExp(template.pattern));
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesTextNode(text: string, template: AriaTemplateTextNode) {
|
function matchesTextNode(text: string, template: AriaTemplateTextNode) {
|
||||||
|
|
@ -243,7 +241,7 @@ function matchesNode(node: AriaNode | string, template: AriaTemplateNode, depth:
|
||||||
if (typeof node === 'string' && template.kind === 'text')
|
if (typeof node === 'string' && template.kind === 'text')
|
||||||
return matchesTextNode(node, template);
|
return matchesTextNode(node, template);
|
||||||
|
|
||||||
if (typeof node === 'object' && template.kind === 'role') {
|
if (node !== null && typeof node === 'object' && template.kind === 'role') {
|
||||||
if (template.role !== 'fragment' && template.role !== node.role)
|
if (template.role !== 'fragment' && template.role !== node.role)
|
||||||
return false;
|
return false;
|
||||||
if (template.checked !== undefined && template.checked !== node.checked)
|
if (template.checked !== undefined && template.checked !== node.checked)
|
||||||
|
|
@ -287,20 +285,22 @@ function containsList(children: (AriaNode | string)[], template: AriaTemplateNod
|
||||||
|
|
||||||
function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll: boolean): AriaNode[] {
|
function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll: boolean): AriaNode[] {
|
||||||
const results: AriaNode[] = [];
|
const results: AriaNode[] = [];
|
||||||
const visit = (node: AriaNode | string): boolean => {
|
const visit = (node: AriaNode | string, parent: AriaNode | null): boolean => {
|
||||||
if (matchesNode(node, template, 0)) {
|
if (matchesNode(node, template, 0)) {
|
||||||
results.push(node as AriaNode);
|
const result = typeof node === 'string' ? parent : node;
|
||||||
|
if (result)
|
||||||
|
results.push(result);
|
||||||
return !collectAll;
|
return !collectAll;
|
||||||
}
|
}
|
||||||
if (typeof node === 'string')
|
if (typeof node === 'string')
|
||||||
return false;
|
return false;
|
||||||
for (const child of node.children || []) {
|
for (const child of node.children || []) {
|
||||||
if (visit(child))
|
if (visit(child, node))
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
visit(root);
|
visit(root, null);
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ import type { CSSComplexSelectorList } from '../../utils/isomorphic/cssParser';
|
||||||
import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator';
|
import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator';
|
||||||
import type * as channels from '@protocol/channels';
|
import type * as channels from '@protocol/channels';
|
||||||
import { Highlight } from './highlight';
|
import { Highlight } from './highlight';
|
||||||
import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription, getReadonly } from './roleUtils';
|
import { getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription, getReadonly, getElementAccessibleErrorMessage, getCheckedAllowMixed, getCheckedWithoutMixed } from './roleUtils';
|
||||||
import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils';
|
import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils';
|
||||||
import { asLocator } from '../../utils/isomorphic/locatorGenerators';
|
import { asLocator } from '../../utils/isomorphic/locatorGenerators';
|
||||||
import type { Language } from '../../utils/isomorphic/locatorGenerators';
|
import type { Language } from '../../utils/isomorphic/locatorGenerators';
|
||||||
|
|
@ -37,12 +37,13 @@ import { cacheNormalizedWhitespaces, normalizeWhiteSpace, trimStringWithEllipsis
|
||||||
import { matchesAriaTree, getAllByAria, generateAriaTree, renderAriaTree } from './ariaSnapshot';
|
import { matchesAriaTree, getAllByAria, generateAriaTree, renderAriaTree } from './ariaSnapshot';
|
||||||
import type { AriaNode, AriaSnapshot } from './ariaSnapshot';
|
import type { AriaNode, AriaSnapshot } from './ariaSnapshot';
|
||||||
import type { AriaTemplateNode } from '@isomorphic/ariaSnapshot';
|
import type { AriaTemplateNode } from '@isomorphic/ariaSnapshot';
|
||||||
import { parseYamlTemplate } from '@isomorphic/ariaSnapshot';
|
import { parseAriaSnapshot } from '@isomorphic/ariaSnapshot';
|
||||||
|
|
||||||
export type FrameExpectParams = Omit<channels.FrameExpectParams, 'expectedValue'> & { expectedValue?: any };
|
export type FrameExpectParams = Omit<channels.FrameExpectParams, 'expectedValue'> & { expectedValue?: any };
|
||||||
|
|
||||||
export type ElementStateWithoutStable = 'visible' | 'hidden' | 'enabled' | 'disabled' | 'editable' | 'checked' | 'unchecked';
|
export type ElementState = 'visible' | 'hidden' | 'enabled' | 'disabled' | 'editable' | 'checked' | 'unchecked' | 'indeterminate' | 'stable';
|
||||||
export type ElementState = ElementStateWithoutStable | 'stable';
|
export type ElementStateWithoutStable = Exclude<ElementState, 'stable'>;
|
||||||
|
export type ElementStateQueryResult = { matches: boolean, received?: string | 'error:notconnected' };
|
||||||
|
|
||||||
export type HitTargetInterceptionResult = {
|
export type HitTargetInterceptionResult = {
|
||||||
stop: () => 'done' | { hitTargetDescription: string };
|
stop: () => 'done' | { hitTargetDescription: string };
|
||||||
|
|
@ -85,7 +86,7 @@ export class InjectedScript {
|
||||||
isElementVisible,
|
isElementVisible,
|
||||||
isInsideScope,
|
isInsideScope,
|
||||||
normalizeWhiteSpace,
|
normalizeWhiteSpace,
|
||||||
parseYamlTemplate,
|
parseAriaSnapshot,
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-globals
|
// eslint-disable-next-line no-restricted-globals
|
||||||
|
|
@ -531,10 +532,10 @@ export class InjectedScript {
|
||||||
if (!element.matches('a, input, textarea, button, select, [role=link], [role=button], [role=checkbox], [role=radio]') &&
|
if (!element.matches('a, input, textarea, button, select, [role=link], [role=button], [role=checkbox], [role=radio]') &&
|
||||||
!(element as any).isContentEditable) {
|
!(element as any).isContentEditable) {
|
||||||
// Go up to the label that might be connected to the input/textarea.
|
// Go up to the label that might be connected to the input/textarea.
|
||||||
element = element.closest('label') || element;
|
const enclosingLabel: HTMLLabelElement | null = element.closest('label');
|
||||||
|
if (enclosingLabel && enclosingLabel.control)
|
||||||
|
element = enclosingLabel.control;
|
||||||
}
|
}
|
||||||
if (element.nodeName === 'LABEL')
|
|
||||||
element = (element as HTMLLabelElement).control || element;
|
|
||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
@ -545,15 +546,15 @@ export class InjectedScript {
|
||||||
if (stableResult === false)
|
if (stableResult === false)
|
||||||
return { missingState: 'stable' };
|
return { missingState: 'stable' };
|
||||||
if (stableResult === 'error:notconnected')
|
if (stableResult === 'error:notconnected')
|
||||||
return stableResult;
|
return 'error:notconnected';
|
||||||
}
|
}
|
||||||
for (const state of states) {
|
for (const state of states) {
|
||||||
if (state !== 'stable') {
|
if (state !== 'stable') {
|
||||||
const result = this.elementState(node, state);
|
const result = this.elementState(node, state);
|
||||||
if (result === false)
|
if (result.received === 'error:notconnected')
|
||||||
|
return 'error:notconnected';
|
||||||
|
if (!result.matches)
|
||||||
return { missingState: state };
|
return { missingState: state };
|
||||||
if (result === 'error:notconnected')
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -608,38 +609,60 @@ export class InjectedScript {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
elementState(node: Node, state: ElementStateWithoutStable): boolean | 'error:notconnected' {
|
elementState(node: Node, state: ElementStateWithoutStable): ElementStateQueryResult {
|
||||||
const element = this.retarget(node, ['stable', 'visible', 'hidden'].includes(state) ? 'none' : 'follow-label');
|
const element = this.retarget(node, ['visible', 'hidden'].includes(state) ? 'none' : 'follow-label');
|
||||||
if (!element || !element.isConnected) {
|
if (!element || !element.isConnected) {
|
||||||
if (state === 'hidden')
|
if (state === 'hidden')
|
||||||
return true;
|
return { matches: true, received: 'hidden' };
|
||||||
return 'error:notconnected';
|
return { matches: false, received: 'error:notconnected' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state === 'visible')
|
if (state === 'visible' || state === 'hidden') {
|
||||||
return isElementVisible(element);
|
const visible = isElementVisible(element);
|
||||||
if (state === 'hidden')
|
return {
|
||||||
return !isElementVisible(element);
|
matches: state === 'visible' ? visible : !visible,
|
||||||
|
received: visible ? 'visible' : 'hidden'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'disabled' || state === 'enabled') {
|
||||||
const disabled = getAriaDisabled(element);
|
const disabled = getAriaDisabled(element);
|
||||||
if (state === 'disabled')
|
return {
|
||||||
return disabled;
|
matches: state === 'disabled' ? disabled : !disabled,
|
||||||
if (state === 'enabled')
|
received: disabled ? 'disabled' : 'enabled'
|
||||||
return !disabled;
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (state === 'editable') {
|
if (state === 'editable') {
|
||||||
|
const disabled = getAriaDisabled(element);
|
||||||
const readonly = getReadonly(element);
|
const readonly = getReadonly(element);
|
||||||
if (readonly === 'error')
|
if (readonly === 'error')
|
||||||
throw this.createStacklessError('Element is not an <input>, <textarea>, <select> or [contenteditable] and does not have a role allowing [aria-readonly]');
|
throw this.createStacklessError('Element is not an <input>, <textarea>, <select> or [contenteditable] and does not have a role allowing [aria-readonly]');
|
||||||
return !disabled && !readonly;
|
return {
|
||||||
|
matches: !disabled && !readonly,
|
||||||
|
received: disabled ? 'disabled' : readonly ? 'readOnly' : 'editable'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state === 'checked' || state === 'unchecked') {
|
if (state === 'checked' || state === 'unchecked') {
|
||||||
const need = state === 'checked';
|
const need = state === 'checked';
|
||||||
const checked = getChecked(element, false);
|
const checked = getCheckedWithoutMixed(element);
|
||||||
if (checked === 'error')
|
if (checked === 'error')
|
||||||
throw this.createStacklessError('Not a checkbox or radio button');
|
throw this.createStacklessError('Not a checkbox or radio button');
|
||||||
return need === checked;
|
return {
|
||||||
|
matches: need === checked,
|
||||||
|
received: checked ? 'checked' : 'unchecked',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'indeterminate') {
|
||||||
|
const checked = getCheckedAllowMixed(element);
|
||||||
|
if (checked === 'error')
|
||||||
|
throw this.createStacklessError('Not a checkbox or radio button');
|
||||||
|
return {
|
||||||
|
matches: checked === 'mixed',
|
||||||
|
received: checked === true ? 'checked' : checked === false ? 'unchecked' : 'mixed',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
throw this.createStacklessError(`Unexpected element state "${state}"`);
|
throw this.createStacklessError(`Unexpected element state "${state}"`);
|
||||||
}
|
}
|
||||||
|
|
@ -996,13 +1019,46 @@ export class InjectedScript {
|
||||||
return { stop };
|
return { stop };
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatchEvent(node: Node, type: string, eventInit: Object) {
|
dispatchEvent(node: Node, type: string, eventInitObj: Object) {
|
||||||
let event;
|
let event;
|
||||||
eventInit = { bubbles: true, cancelable: true, composed: true, ...eventInit };
|
const eventInit: any = { bubbles: true, cancelable: true, composed: true, ...eventInitObj };
|
||||||
switch (eventType.get(type)) {
|
switch (eventType.get(type)) {
|
||||||
case 'mouse': event = new MouseEvent(type, eventInit); break;
|
case 'mouse': event = new MouseEvent(type, eventInit); break;
|
||||||
case 'keyboard': event = new KeyboardEvent(type, eventInit); break;
|
case 'keyboard': event = new KeyboardEvent(type, eventInit); break;
|
||||||
case 'touch': event = new TouchEvent(type, eventInit); break;
|
case 'touch': {
|
||||||
|
// WebKit does not support Touch constructor, but has deprecated createTouch and createTouchList methods.
|
||||||
|
if (this._browserName === 'webkit') {
|
||||||
|
const createTouch = (t: any) => {
|
||||||
|
if (t instanceof Touch)
|
||||||
|
return t;
|
||||||
|
// createTouch does not accept clientX/clientY, so we have to use pageX/pageY.
|
||||||
|
let pageX = t.pageX;
|
||||||
|
if (pageX === undefined && t.clientX !== undefined)
|
||||||
|
pageX = t.clientX + (this.document.scrollingElement?.scrollLeft || 0);
|
||||||
|
let pageY = t.pageY;
|
||||||
|
if (pageY === undefined && t.clientY !== undefined)
|
||||||
|
pageY = t.clientY + (this.document.scrollingElement?.scrollTop || 0);
|
||||||
|
return (this.document as any).createTouch(this.window, t.target ?? node, t.identifier, pageX, pageY, t.screenX, t.screenY, t.radiusX, t.radiusY, t.rotationAngle, t.force);
|
||||||
|
};
|
||||||
|
const createTouchList = (touches: any) => {
|
||||||
|
if (touches instanceof TouchList || !touches)
|
||||||
|
return touches;
|
||||||
|
return (this.document as any).createTouchList(...touches.map(createTouch));
|
||||||
|
};
|
||||||
|
eventInit.target ??= node;
|
||||||
|
eventInit.touches = createTouchList(eventInit.touches);
|
||||||
|
eventInit.targetTouches = createTouchList(eventInit.targetTouches);
|
||||||
|
eventInit.changedTouches = createTouchList(eventInit.changedTouches);
|
||||||
|
event = new TouchEvent(type, eventInit);
|
||||||
|
} else {
|
||||||
|
eventInit.target ??= node;
|
||||||
|
eventInit.touches = eventInit.touches?.map((t: any) => t instanceof Touch ? t : new Touch({ ...t, target: t.target ?? node }));
|
||||||
|
eventInit.targetTouches = eventInit.targetTouches?.map((t: any) => t instanceof Touch ? t : new Touch({ ...t, target: t.target ?? node }));
|
||||||
|
eventInit.changedTouches = eventInit.changedTouches?.map((t: any) => t instanceof Touch ? t : new Touch({ ...t, target: t.target ?? node }));
|
||||||
|
event = new TouchEvent(type, eventInit);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'pointer': event = new PointerEvent(type, eventInit); break;
|
case 'pointer': event = new PointerEvent(type, eventInit); break;
|
||||||
case 'focus': event = new FocusEvent(type, eventInit); break;
|
case 'focus': event = new FocusEvent(type, eventInit); break;
|
||||||
case 'drag': event = new DragEvent(type, eventInit); break;
|
case 'drag': event = new DragEvent(type, eventInit); break;
|
||||||
|
|
@ -1213,44 +1269,65 @@ export class InjectedScript {
|
||||||
|
|
||||||
{
|
{
|
||||||
// Element state / boolean values.
|
// Element state / boolean values.
|
||||||
let elementState: boolean | 'error:notconnected' | 'error:notcheckbox' | undefined;
|
let result: ElementStateQueryResult | undefined;
|
||||||
if (expression === 'to.have.attribute') {
|
if (expression === 'to.have.attribute') {
|
||||||
elementState = element.hasAttribute(options.expressionArg);
|
const hasAttribute = element.hasAttribute(options.expressionArg);
|
||||||
|
result = {
|
||||||
|
matches: hasAttribute,
|
||||||
|
received: hasAttribute ? 'attribute present' : 'attribute not present',
|
||||||
|
};
|
||||||
} else if (expression === 'to.be.checked') {
|
} else if (expression === 'to.be.checked') {
|
||||||
elementState = this.elementState(element, 'checked');
|
const { checked, indeterminate } = options.expectedValue;
|
||||||
} else if (expression === 'to.be.unchecked') {
|
if (indeterminate) {
|
||||||
elementState = this.elementState(element, 'unchecked');
|
if (checked !== undefined)
|
||||||
|
throw this.createStacklessError('Can\'t assert indeterminate and checked at the same time');
|
||||||
|
result = this.elementState(element, 'indeterminate');
|
||||||
|
} else {
|
||||||
|
result = this.elementState(element, checked === false ? 'unchecked' : 'checked');
|
||||||
|
}
|
||||||
} else if (expression === 'to.be.disabled') {
|
} else if (expression === 'to.be.disabled') {
|
||||||
elementState = this.elementState(element, 'disabled');
|
result = this.elementState(element, 'disabled');
|
||||||
} else if (expression === 'to.be.editable') {
|
} else if (expression === 'to.be.editable') {
|
||||||
elementState = this.elementState(element, 'editable');
|
result = this.elementState(element, 'editable');
|
||||||
} else if (expression === 'to.be.readonly') {
|
} else if (expression === 'to.be.readonly') {
|
||||||
elementState = !this.elementState(element, 'editable');
|
result = this.elementState(element, 'editable');
|
||||||
|
result.matches = !result.matches;
|
||||||
} else if (expression === 'to.be.empty') {
|
} else if (expression === 'to.be.empty') {
|
||||||
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA')
|
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {
|
||||||
elementState = !(element as HTMLInputElement).value;
|
const value = (element as HTMLInputElement).value;
|
||||||
else
|
result = { matches: !value, received: value ? 'notEmpty' : 'empty' };
|
||||||
elementState = !element.textContent?.trim();
|
} else {
|
||||||
|
const text = element.textContent?.trim();
|
||||||
|
result = { matches: !text, received: text ? 'notEmpty' : 'empty' };
|
||||||
|
}
|
||||||
} else if (expression === 'to.be.enabled') {
|
} else if (expression === 'to.be.enabled') {
|
||||||
elementState = this.elementState(element, 'enabled');
|
result = this.elementState(element, 'enabled');
|
||||||
} else if (expression === 'to.be.focused') {
|
} else if (expression === 'to.be.focused') {
|
||||||
elementState = this._activelyFocused(element).isFocused;
|
const focused = this._activelyFocused(element).isFocused;
|
||||||
|
result = {
|
||||||
|
matches: focused,
|
||||||
|
received: focused ? 'focused' : 'inactive',
|
||||||
|
};
|
||||||
} else if (expression === 'to.be.hidden') {
|
} else if (expression === 'to.be.hidden') {
|
||||||
elementState = this.elementState(element, 'hidden');
|
result = this.elementState(element, 'hidden');
|
||||||
} else if (expression === 'to.be.visible') {
|
} else if (expression === 'to.be.visible') {
|
||||||
elementState = this.elementState(element, 'visible');
|
result = this.elementState(element, 'visible');
|
||||||
} else if (expression === 'to.be.attached') {
|
} else if (expression === 'to.be.attached') {
|
||||||
elementState = true;
|
result = {
|
||||||
|
matches: true,
|
||||||
|
received: 'attached',
|
||||||
|
};
|
||||||
} else if (expression === 'to.be.detached') {
|
} else if (expression === 'to.be.detached') {
|
||||||
elementState = false;
|
result = {
|
||||||
|
matches: false,
|
||||||
|
received: 'attached',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (elementState !== undefined) {
|
if (result) {
|
||||||
if (elementState === 'error:notcheckbox')
|
if (result.received === 'error:notconnected')
|
||||||
throw this.createStacklessError('Element is not a checkbox');
|
|
||||||
if (elementState === 'error:notconnected')
|
|
||||||
throw this.createStacklessError('Element is not connected');
|
throw this.createStacklessError('Element is not connected');
|
||||||
return { received: elementState, matches: elementState };
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1321,6 +1398,8 @@ export class InjectedScript {
|
||||||
received = getElementAccessibleName(element, false /* includeHidden */);
|
received = getElementAccessibleName(element, false /* includeHidden */);
|
||||||
} else if (expression === 'to.have.accessible.description') {
|
} else if (expression === 'to.have.accessible.description') {
|
||||||
received = getElementAccessibleDescription(element, false /* includeHidden */);
|
received = getElementAccessibleDescription(element, false /* includeHidden */);
|
||||||
|
} else if (expression === 'to.have.accessible.error.message') {
|
||||||
|
received = getElementAccessibleErrorMessage(element);
|
||||||
} else if (expression === 'to.have.role') {
|
} else if (expression === 'to.have.role') {
|
||||||
received = getAriaRole(element) || '';
|
received = getAriaRole(element) || '';
|
||||||
} else if (expression === 'to.have.title') {
|
} else if (expression === 'to.have.title') {
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export class PollingRecorder implements RecorderDelegate {
|
||||||
const pollPeriod = 1000;
|
const pollPeriod = 1000;
|
||||||
if (this._pollRecorderModeTimer)
|
if (this._pollRecorderModeTimer)
|
||||||
clearTimeout(this._pollRecorderModeTimer);
|
clearTimeout(this._pollRecorderModeTimer);
|
||||||
const state = await this._embedder.__pw_recorderState().catch(() => {});
|
const state = await this._embedder.__pw_recorderState().catch(() => null);
|
||||||
if (!state) {
|
if (!state) {
|
||||||
this._pollRecorderModeTimer = this._recorder.injectedScript.builtinSetTimeout(() => this._pollRecorderMode(), pollPeriod);
|
this._pollRecorderModeTimer = this._recorder.injectedScript.builtinSetTimeout(() => this._pollRecorderMode(), pollPeriod);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -1146,8 +1146,7 @@ export class Recorder {
|
||||||
const ariaTemplateJSON = JSON.stringify(state.ariaTemplate);
|
const ariaTemplateJSON = JSON.stringify(state.ariaTemplate);
|
||||||
if (this._lastHighlightedAriaTemplateJSON !== ariaTemplateJSON) {
|
if (this._lastHighlightedAriaTemplateJSON !== ariaTemplateJSON) {
|
||||||
this._lastHighlightedAriaTemplateJSON = ariaTemplateJSON;
|
this._lastHighlightedAriaTemplateJSON = ariaTemplateJSON;
|
||||||
const template = state.ariaTemplate ? this.injectedScript.utils.parseYamlTemplate(state.ariaTemplate) : undefined;
|
const elements = state.ariaTemplate ? this.injectedScript.getAllByAria(this.document, state.ariaTemplate) : [];
|
||||||
const elements = template ? this.injectedScript.getAllByAria(this.document, template) : [];
|
|
||||||
if (elements.length)
|
if (elements.length)
|
||||||
highlight = { elements };
|
highlight = { elements };
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -461,6 +461,59 @@ export function getElementAccessibleDescription(element: Element, includeHidden:
|
||||||
return accessibleDescription;
|
return accessibleDescription;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https://www.w3.org/TR/wai-aria-1.2/#aria-invalid
|
||||||
|
const kAriaInvalidRoles = ['application', 'checkbox', 'combobox', 'gridcell', 'listbox', 'radiogroup', 'slider', 'spinbutton', 'textbox', 'tree', 'columnheader', 'rowheader', 'searchbox', 'switch', 'treegrid'];
|
||||||
|
|
||||||
|
function getAriaInvalid(element: Element): 'false' | 'true' | 'grammar' | 'spelling' {
|
||||||
|
const role = getAriaRole(element) || '';
|
||||||
|
if (!role || !kAriaInvalidRoles.includes(role))
|
||||||
|
return 'false';
|
||||||
|
const ariaInvalid = element.getAttribute('aria-invalid');
|
||||||
|
if (!ariaInvalid || ariaInvalid.trim() === '' || ariaInvalid.toLocaleLowerCase() === 'false')
|
||||||
|
return 'false';
|
||||||
|
if (ariaInvalid === 'true' || ariaInvalid === 'grammar' || ariaInvalid === 'spelling')
|
||||||
|
return ariaInvalid;
|
||||||
|
return 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getValidityInvalid(element: Element) {
|
||||||
|
if ('validity' in element){
|
||||||
|
const validity = element.validity as ValidityState | undefined;
|
||||||
|
return validity?.valid === false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getElementAccessibleErrorMessage(element: Element): string {
|
||||||
|
// SPEC: https://w3c.github.io/aria/#aria-errormessage
|
||||||
|
//
|
||||||
|
// TODO: support https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/validationMessage
|
||||||
|
const cache = cacheAccessibleErrorMessage;
|
||||||
|
let accessibleErrorMessage = cacheAccessibleErrorMessage?.get(element);
|
||||||
|
|
||||||
|
if (accessibleErrorMessage === undefined) {
|
||||||
|
accessibleErrorMessage = '';
|
||||||
|
|
||||||
|
const isAriaInvalid = getAriaInvalid(element) !== 'false';
|
||||||
|
const isValidityInvalid = getValidityInvalid(element);
|
||||||
|
if (isAriaInvalid || isValidityInvalid) {
|
||||||
|
const errorMessageId = element.getAttribute('aria-errormessage');
|
||||||
|
const errorMessages = getIdRefs(element, errorMessageId);
|
||||||
|
// Ideally, this should be a separate "embeddedInErrorMessage", but it would follow the exact same rules.
|
||||||
|
// Relevant vague spec: https://w3c.github.io/core-aam/#ariaErrorMessage.
|
||||||
|
const parts = errorMessages.map(errorMessage => asFlatString(
|
||||||
|
getTextAlternativeInternal(errorMessage, {
|
||||||
|
visitedElements: new Set(),
|
||||||
|
embeddedInDescribedBy: { element: errorMessage, hidden: isElementHiddenForAria(errorMessage) },
|
||||||
|
})
|
||||||
|
));
|
||||||
|
accessibleErrorMessage = parts.join(' ').trim();
|
||||||
|
}
|
||||||
|
cache?.set(element, accessibleErrorMessage);
|
||||||
|
}
|
||||||
|
return accessibleErrorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
type AccessibleNameOptions = {
|
type AccessibleNameOptions = {
|
||||||
visitedElements: Set<Element>,
|
visitedElements: Set<Element>,
|
||||||
includeHidden?: boolean,
|
includeHidden?: boolean,
|
||||||
|
|
@ -841,7 +894,17 @@ export function getAriaChecked(element: Element): boolean | 'mixed' {
|
||||||
const result = getChecked(element, true);
|
const result = getChecked(element, true);
|
||||||
return result === 'error' ? false : result;
|
return result === 'error' ? false : result;
|
||||||
}
|
}
|
||||||
export function getChecked(element: Element, allowMixed: boolean): boolean | 'mixed' | 'error' {
|
|
||||||
|
export function getCheckedAllowMixed(element: Element): boolean | 'mixed' | 'error' {
|
||||||
|
return getChecked(element, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCheckedWithoutMixed(element: Element): boolean | 'error' {
|
||||||
|
const result = getChecked(element, false);
|
||||||
|
return result as boolean | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChecked(element: Element, allowMixed: boolean): boolean | 'mixed' | 'error' {
|
||||||
const tagName = elementSafeTagName(element);
|
const tagName = elementSafeTagName(element);
|
||||||
// https://www.w3.org/TR/wai-aria-1.2/#aria-checked
|
// https://www.w3.org/TR/wai-aria-1.2/#aria-checked
|
||||||
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
|
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
|
||||||
|
|
@ -972,6 +1035,7 @@ let cacheAccessibleName: Map<Element, string> | undefined;
|
||||||
let cacheAccessibleNameHidden: Map<Element, string> | undefined;
|
let cacheAccessibleNameHidden: Map<Element, string> | undefined;
|
||||||
let cacheAccessibleDescription: Map<Element, string> | undefined;
|
let cacheAccessibleDescription: Map<Element, string> | undefined;
|
||||||
let cacheAccessibleDescriptionHidden: Map<Element, string> | undefined;
|
let cacheAccessibleDescriptionHidden: Map<Element, string> | undefined;
|
||||||
|
let cacheAccessibleErrorMessage: Map<Element, string> | undefined;
|
||||||
let cacheIsHidden: Map<Element, boolean> | undefined;
|
let cacheIsHidden: Map<Element, boolean> | undefined;
|
||||||
let cachePseudoContentBefore: Map<Element, string> | undefined;
|
let cachePseudoContentBefore: Map<Element, string> | undefined;
|
||||||
let cachePseudoContentAfter: Map<Element, string> | undefined;
|
let cachePseudoContentAfter: Map<Element, string> | undefined;
|
||||||
|
|
@ -983,6 +1047,7 @@ export function beginAriaCaches() {
|
||||||
cacheAccessibleNameHidden ??= new Map();
|
cacheAccessibleNameHidden ??= new Map();
|
||||||
cacheAccessibleDescription ??= new Map();
|
cacheAccessibleDescription ??= new Map();
|
||||||
cacheAccessibleDescriptionHidden ??= new Map();
|
cacheAccessibleDescriptionHidden ??= new Map();
|
||||||
|
cacheAccessibleErrorMessage ??= new Map();
|
||||||
cacheIsHidden ??= new Map();
|
cacheIsHidden ??= new Map();
|
||||||
cachePseudoContentBefore ??= new Map();
|
cachePseudoContentBefore ??= new Map();
|
||||||
cachePseudoContentAfter ??= new Map();
|
cachePseudoContentAfter ??= new Map();
|
||||||
|
|
@ -994,6 +1059,7 @@ export function endAriaCaches() {
|
||||||
cacheAccessibleNameHidden = undefined;
|
cacheAccessibleNameHidden = undefined;
|
||||||
cacheAccessibleDescription = undefined;
|
cacheAccessibleDescription = undefined;
|
||||||
cacheAccessibleDescriptionHidden = undefined;
|
cacheAccessibleDescriptionHidden = undefined;
|
||||||
|
cacheAccessibleErrorMessage = undefined;
|
||||||
cacheIsHidden = undefined;
|
cacheIsHidden = undefined;
|
||||||
cachePseudoContentBefore = undefined;
|
cachePseudoContentBefore = undefined;
|
||||||
cachePseudoContentAfter = undefined;
|
cachePseudoContentAfter = undefined;
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,8 @@ function yamlStringNeedsQuotes(str: string): boolean {
|
||||||
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(str))
|
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(str))
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
// Strings starting with '-' followed by a space need quotes
|
// Strings starting with '-' need quotes
|
||||||
if (/^-\s/.test(str))
|
if (/^-/.test(str))
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
// Strings containing ':' or '\n' followed by a space or at the end need quotes
|
// Strings containing ':' or '\n' followed by a space or at the end need quotes
|
||||||
|
|
@ -82,6 +82,10 @@ function yamlStringNeedsQuotes(str: string): boolean {
|
||||||
if (/[{}`]/.test(str))
|
if (/[{}`]/.test(str))
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
|
// YAML array starts with [
|
||||||
|
if (/^\[/.test(str))
|
||||||
|
return true;
|
||||||
|
|
||||||
// Non-string types recognized by YAML
|
// Non-string types recognized by YAML
|
||||||
if (!isNaN(Number(str)) || ['y', 'n', 'yes', 'no', 'true', 'false', 'on', 'off', 'null'].includes(str.toLowerCase()))
|
if (!isNaN(Number(str)) || ['y', 'n', 'yes', 'no', 'true', 'false', 'on', 'off', 'null'].includes(str.toLowerCase()))
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -564,8 +564,8 @@ export class Page extends SdkObject {
|
||||||
await this._delegate.bringToFront();
|
await this._delegate.bringToFront();
|
||||||
}
|
}
|
||||||
|
|
||||||
async addInitScript(source: string) {
|
async addInitScript(source: string, name?: string) {
|
||||||
const initScript = new InitScript(source);
|
const initScript = new InitScript(source, false /* internal */, name);
|
||||||
this.initScripts.push(initScript);
|
this.initScripts.push(initScript);
|
||||||
await this._delegate.addInitScript(initScript);
|
await this._delegate.addInitScript(initScript);
|
||||||
}
|
}
|
||||||
|
|
@ -953,8 +953,9 @@ function addPageBinding(playwrightBinding: string, bindingName: string, needsHan
|
||||||
export class InitScript {
|
export class InitScript {
|
||||||
readonly source: string;
|
readonly source: string;
|
||||||
readonly internal: boolean;
|
readonly internal: boolean;
|
||||||
|
readonly name?: string;
|
||||||
|
|
||||||
constructor(source: string, internal?: boolean) {
|
constructor(source: string, internal?: boolean, name?: string) {
|
||||||
const guid = createGuid();
|
const guid = createGuid();
|
||||||
this.source = `(() => {
|
this.source = `(() => {
|
||||||
globalThis.__pwInitScripts = globalThis.__pwInitScripts || {};
|
globalThis.__pwInitScripts = globalThis.__pwInitScripts || {};
|
||||||
|
|
@ -965,6 +966,7 @@ export class InitScript {
|
||||||
${source}
|
${source}
|
||||||
})();`;
|
})();`;
|
||||||
this.internal = !!internal;
|
this.internal = !!internal;
|
||||||
|
this.name = name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import type * as actions from '@recorder/actions';
|
||||||
import { buildFullSelector } from '../utils/isomorphic/recorderUtils';
|
import { buildFullSelector } from '../utils/isomorphic/recorderUtils';
|
||||||
import { stringifySelector } from '../utils/isomorphic/selectorParser';
|
import { stringifySelector } from '../utils/isomorphic/selectorParser';
|
||||||
import type { Frame } from './frames';
|
import type { Frame } from './frames';
|
||||||
import type { ParsedYaml } from '@isomorphic/ariaSnapshot';
|
import type { AriaTemplateNode } from '@isomorphic/ariaSnapshot';
|
||||||
|
|
||||||
const recorderSymbol = Symbol('recorderSymbol');
|
const recorderSymbol = Symbol('recorderSymbol');
|
||||||
|
|
||||||
|
|
@ -40,7 +40,7 @@ export class Recorder implements InstrumentationListener, IRecorder {
|
||||||
readonly handleSIGINT: boolean | undefined;
|
readonly handleSIGINT: boolean | undefined;
|
||||||
private _context: BrowserContext;
|
private _context: BrowserContext;
|
||||||
private _mode: Mode;
|
private _mode: Mode;
|
||||||
private _highlightedElement: { selector?: string, ariaTemplate?: ParsedYaml } = {};
|
private _highlightedElement: { selector?: string, ariaTemplate?: AriaTemplateNode } = {};
|
||||||
private _overlayState: OverlayState = { offsetX: 0 };
|
private _overlayState: OverlayState = { offsetX: 0 };
|
||||||
private _recorderApp: IRecorderApp | null = null;
|
private _recorderApp: IRecorderApp | null = null;
|
||||||
private _currentCallsMetadata = new Map<CallMetadata, SdkObject>();
|
private _currentCallsMetadata = new Map<CallMetadata, SdkObject>();
|
||||||
|
|
@ -249,7 +249,7 @@ export class Recorder implements InstrumentationListener, IRecorder {
|
||||||
this._refreshOverlay();
|
this._refreshOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
setHighlightedAriaTemplate(ariaTemplate: ParsedYaml) {
|
setHighlightedAriaTemplate(ariaTemplate: AriaTemplateNode) {
|
||||||
this._highlightedElement = { ariaTemplate };
|
this._highlightedElement = { ariaTemplate };
|
||||||
this._refreshOverlay();
|
this._refreshOverlay();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ export class ContextRecorder extends EventEmitter {
|
||||||
this._listeners.push(eventsHelper.addEventListener(process, 'exit', () => {
|
this._listeners.push(eventsHelper.addEventListener(process, 'exit', () => {
|
||||||
this._throttledOutputFile?.flush();
|
this._throttledOutputFile?.flush();
|
||||||
}));
|
}));
|
||||||
this.setEnabled(true);
|
this.setEnabled(params.mode === 'recording');
|
||||||
}
|
}
|
||||||
|
|
||||||
setOutput(codegenId: string, outputFile?: string) {
|
setOutput(codegenId: string, outputFile?: string) {
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ export async function performAction(callMetadata: CallMetadata, pageAliases: Map
|
||||||
await mainFrame.expect(callMetadata, selector, {
|
await mainFrame.expect(callMetadata, selector, {
|
||||||
selector,
|
selector,
|
||||||
expression: 'to.be.checked',
|
expression: 'to.be.checked',
|
||||||
|
expectedValue: { checked: action.checked },
|
||||||
isNot: !action.checked,
|
isNot: !action.checked,
|
||||||
timeout: kActionTimeout,
|
timeout: kActionTimeout,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import childProcess from 'child_process';
|
||||||
import * as utils from '../../utils';
|
import * as utils from '../../utils';
|
||||||
import { spawnAsync } from '../../utils/spawnAsync';
|
import { spawnAsync } from '../../utils/spawnAsync';
|
||||||
import { hostPlatform, isOfficiallySupportedPlatform } from '../../utils/hostPlatform';
|
import { hostPlatform, isOfficiallySupportedPlatform } from '../../utils/hostPlatform';
|
||||||
import { buildPlaywrightCLICommand } from '.';
|
import { buildPlaywrightCLICommand, registry } from '.';
|
||||||
import { deps } from './nativeDeps';
|
import { deps } from './nativeDeps';
|
||||||
import { getPlaywrightVersion } from '../../utils/userAgent';
|
import { getPlaywrightVersion } from '../../utils/userAgent';
|
||||||
|
|
||||||
|
|
@ -122,12 +122,12 @@ export async function installDependenciesLinux(targets: Set<DependencyGroup>, dr
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateDependenciesWindows(windowsExeAndDllDirectories: string[]) {
|
export async function validateDependenciesWindows(sdkLanguage: string, windowsExeAndDllDirectories: string[]) {
|
||||||
const directoryPaths = windowsExeAndDllDirectories;
|
const directoryPaths = windowsExeAndDllDirectories;
|
||||||
const lddPaths: string[] = [];
|
const lddPaths: string[] = [];
|
||||||
for (const directoryPath of directoryPaths)
|
for (const directoryPath of directoryPaths)
|
||||||
lddPaths.push(...(await executablesOrSharedLibraries(directoryPath)));
|
lddPaths.push(...(await executablesOrSharedLibraries(directoryPath)));
|
||||||
const allMissingDeps = await Promise.all(lddPaths.map(lddPath => missingFileDependenciesWindows(lddPath)));
|
const allMissingDeps = await Promise.all(lddPaths.map(lddPath => missingFileDependenciesWindows(sdkLanguage, lddPath)));
|
||||||
const missingDeps: Set<string> = new Set();
|
const missingDeps: Set<string> = new Set();
|
||||||
for (const deps of allMissingDeps) {
|
for (const deps of allMissingDeps) {
|
||||||
for (const dep of deps)
|
for (const dep of deps)
|
||||||
|
|
@ -302,8 +302,8 @@ async function executablesOrSharedLibraries(directoryPath: string): Promise<stri
|
||||||
return executablersOrLibraries as string[];
|
return executablersOrLibraries as string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function missingFileDependenciesWindows(filePath: string): Promise<Array<string>> {
|
async function missingFileDependenciesWindows(sdkLanguage: string, filePath: string): Promise<Array<string>> {
|
||||||
const executable = path.join(__dirname, '..', '..', '..', 'bin', 'PrintDeps.exe');
|
const executable = registry.findExecutable('winldd')!.executablePathOrDie(sdkLanguage);
|
||||||
const dirname = path.dirname(filePath);
|
const dirname = path.dirname(filePath);
|
||||||
const { stdout, code } = await spawnAsync(executable, [filePath], {
|
const { stdout, code } = await spawnAsync(executable, [filePath], {
|
||||||
cwd: dirname,
|
cwd: dirname,
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,11 @@ const EXECUTABLE_PATHS = {
|
||||||
'mac': ['ffmpeg-mac'],
|
'mac': ['ffmpeg-mac'],
|
||||||
'win': ['ffmpeg-win64.exe'],
|
'win': ['ffmpeg-win64.exe'],
|
||||||
},
|
},
|
||||||
|
'winldd': {
|
||||||
|
'linux': undefined,
|
||||||
|
'mac': undefined,
|
||||||
|
'win': ['PrintDeps.exe'],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
type DownloadPaths = Record<HostPlatform, string | undefined>;
|
type DownloadPaths = Record<HostPlatform, string | undefined>;
|
||||||
|
|
@ -315,6 +320,35 @@ const DOWNLOAD_PATHS: Record<BrowserName | InternalTool, DownloadPaths> = {
|
||||||
'mac15-arm64': 'builds/ffmpeg/%s/ffmpeg-mac-arm64.tar.br',
|
'mac15-arm64': 'builds/ffmpeg/%s/ffmpeg-mac-arm64.tar.br',
|
||||||
'win64': 'builds/ffmpeg/%s/ffmpeg-win64.tar.br',
|
'win64': 'builds/ffmpeg/%s/ffmpeg-win64.tar.br',
|
||||||
},
|
},
|
||||||
|
'winldd': {
|
||||||
|
'<unknown>': undefined,
|
||||||
|
'ubuntu18.04-x64': undefined,
|
||||||
|
'ubuntu20.04-x64': undefined,
|
||||||
|
'ubuntu22.04-x64': undefined,
|
||||||
|
'ubuntu24.04-x64': undefined,
|
||||||
|
'ubuntu18.04-arm64': undefined,
|
||||||
|
'ubuntu20.04-arm64': undefined,
|
||||||
|
'ubuntu22.04-arm64': undefined,
|
||||||
|
'ubuntu24.04-arm64': undefined,
|
||||||
|
'debian11-x64': undefined,
|
||||||
|
'debian11-arm64': undefined,
|
||||||
|
'debian12-x64': undefined,
|
||||||
|
'debian12-arm64': undefined,
|
||||||
|
'mac10.13': undefined,
|
||||||
|
'mac10.14': undefined,
|
||||||
|
'mac10.15': undefined,
|
||||||
|
'mac11': undefined,
|
||||||
|
'mac11-arm64': undefined,
|
||||||
|
'mac12': undefined,
|
||||||
|
'mac12-arm64': undefined,
|
||||||
|
'mac13': undefined,
|
||||||
|
'mac13-arm64': undefined,
|
||||||
|
'mac14': undefined,
|
||||||
|
'mac14-arm64': undefined,
|
||||||
|
'mac15': undefined,
|
||||||
|
'mac15-arm64': undefined,
|
||||||
|
'win64': 'builds/winldd/%s/winldd-win64.zip',
|
||||||
|
},
|
||||||
'android': {
|
'android': {
|
||||||
'<unknown>': 'builds/android/%s/android.zip',
|
'<unknown>': 'builds/android/%s/android.zip',
|
||||||
'ubuntu18.04-x64': undefined,
|
'ubuntu18.04-x64': undefined,
|
||||||
|
|
@ -442,7 +476,7 @@ function readDescriptors(browsersJSON: BrowsersJSON): BrowsersJSONDescriptor[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BrowserName = 'chromium' | 'firefox' | 'webkit' | 'bidi';
|
export type BrowserName = 'chromium' | 'firefox' | 'webkit' | 'bidi';
|
||||||
type InternalTool = 'ffmpeg' | 'firefox-beta' | 'chromium-tip-of-tree' | 'chromium-headless-shell' | 'chromium-tip-of-tree-headless-shell' | 'android';
|
type InternalTool = 'ffmpeg' | 'winldd' | 'firefox-beta' | 'chromium-tip-of-tree' | 'chromium-headless-shell' | 'chromium-tip-of-tree-headless-shell' | 'android';
|
||||||
type BidiChannel = 'bidi-firefox-stable' | 'bidi-firefox-beta' | 'bidi-firefox-nightly' | 'bidi-chrome-canary' | 'bidi-chrome-stable' | 'bidi-chromium';
|
type BidiChannel = 'bidi-firefox-stable' | 'bidi-firefox-beta' | 'bidi-firefox-nightly' | 'bidi-chrome-canary' | 'bidi-chrome-stable' | 'bidi-chromium';
|
||||||
type ChromiumChannel = 'chrome' | 'chrome-beta' | 'chrome-dev' | 'chrome-canary' | 'msedge' | 'msedge-beta' | 'msedge-dev' | 'msedge-canary';
|
type ChromiumChannel = 'chrome' | 'chrome-beta' | 'chrome-dev' | 'chrome-canary' | 'msedge' | 'msedge-beta' | 'msedge-dev' | 'msedge-canary';
|
||||||
const allDownloadable = ['android', 'chromium', 'firefox', 'webkit', 'ffmpeg', 'firefox-beta', 'chromium-tip-of-tree', 'chromium-headless-shell', 'chromium-tip-of-tree-headless-shell'];
|
const allDownloadable = ['android', 'chromium', 'firefox', 'webkit', 'ffmpeg', 'firefox-beta', 'chromium-tip-of-tree', 'chromium-headless-shell', 'chromium-tip-of-tree-headless-shell'];
|
||||||
|
|
@ -772,6 +806,22 @@ export class Registry {
|
||||||
_dependencyGroup: 'tools',
|
_dependencyGroup: 'tools',
|
||||||
_isHermeticInstallation: true,
|
_isHermeticInstallation: true,
|
||||||
});
|
});
|
||||||
|
const winldd = descriptors.find(d => d.name === 'winldd')!;
|
||||||
|
const winlddExecutable = findExecutablePath(winldd.dir, 'winldd');
|
||||||
|
this._executables.push({
|
||||||
|
type: 'tool',
|
||||||
|
name: 'winldd',
|
||||||
|
browserName: undefined,
|
||||||
|
directory: winldd.dir,
|
||||||
|
executablePath: () => winlddExecutable,
|
||||||
|
executablePathOrDie: (sdkLanguage: string) => executablePathOrDie('winldd', winlddExecutable, winldd.installByDefault, sdkLanguage),
|
||||||
|
installType: process.platform === 'win32' ? 'download-by-default' : 'none',
|
||||||
|
_validateHostRequirements: () => Promise.resolve(),
|
||||||
|
downloadURLs: this._downloadURLs(winldd),
|
||||||
|
_install: () => this._downloadExecutable(winldd, winlddExecutable),
|
||||||
|
_dependencyGroup: 'tools',
|
||||||
|
_isHermeticInstallation: true,
|
||||||
|
});
|
||||||
const android = descriptors.find(d => d.name === 'android')!;
|
const android = descriptors.find(d => d.name === 'android')!;
|
||||||
this._executables.push({
|
this._executables.push({
|
||||||
type: 'tool',
|
type: 'tool',
|
||||||
|
|
@ -944,7 +994,7 @@ export class Registry {
|
||||||
if (os.platform() === 'linux')
|
if (os.platform() === 'linux')
|
||||||
return await validateDependenciesLinux(sdkLanguage, linuxLddDirectories.map(d => path.join(browserDirectory, d)), dlOpenLibraries);
|
return await validateDependenciesLinux(sdkLanguage, linuxLddDirectories.map(d => path.join(browserDirectory, d)), dlOpenLibraries);
|
||||||
if (os.platform() === 'win32' && os.arch() === 'x64')
|
if (os.platform() === 'win32' && os.arch() === 'x64')
|
||||||
return await validateDependenciesWindows(windowsExeAndDllDirectories.map(d => path.join(browserDirectory, d)));
|
return await validateDependenciesWindows(sdkLanguage, windowsExeAndDllDirectories.map(d => path.join(browserDirectory, d)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async installDeps(executablesToInstallDeps: Executable[], dryRun: boolean) {
|
async installDeps(executablesToInstallDeps: Executable[], dryRun: boolean) {
|
||||||
|
|
@ -1268,6 +1318,8 @@ export async function installBrowsersForNpmInstall(browsers: string[]) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const executables: Executable[] = [];
|
const executables: Executable[] = [];
|
||||||
|
if (process.platform === 'win32')
|
||||||
|
executables.push(registry.findExecutable('winldd')!);
|
||||||
for (const browserName of browsers) {
|
for (const browserName of browsers) {
|
||||||
const executable = registry.findExecutable(browserName);
|
const executable = registry.findExecutable(browserName);
|
||||||
if (!executable || executable.installType === 'none')
|
if (!executable || executable.installType === 'none')
|
||||||
|
|
|
||||||
|
|
@ -1104,4 +1104,3 @@ deps['debian12-arm64'] = {
|
||||||
...deps['debian12-x64'].lib2package,
|
...deps['debian12-x64'].lib2package,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,4 +83,3 @@ export class SocksInterceptor {
|
||||||
function tChannelForSocks(names: '*' | string[], arg: any, path: string, context: ValidatorContext) {
|
function tChannelForSocks(names: '*' | string[], arg: any, path: string, context: ValidatorContext) {
|
||||||
throw new ValidationError(`${path}: channels are not expected in SocksSupport`);
|
throw new ValidationError(`${path}: channels are not expected in SocksSupport`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6689,6 +6689,10 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the
|
||||||
* Cookie Same-Site policy.
|
* Cookie Same-Site policy.
|
||||||
*/
|
*/
|
||||||
sameSite: CookieSameSitePolicy;
|
sameSite: CookieSameSitePolicy;
|
||||||
|
/**
|
||||||
|
* Cookie partition key. If null and partitioned property is true, then key must be computed.
|
||||||
|
*/
|
||||||
|
partitionKey?: string;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Accessibility Node
|
* Accessibility Node
|
||||||
|
|
@ -7073,6 +7077,10 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the
|
||||||
*/
|
*/
|
||||||
export type setCookieParameters = {
|
export type setCookieParameters = {
|
||||||
cookie: Cookie;
|
cookie: Cookie;
|
||||||
|
/**
|
||||||
|
* If true, then cookie's partition key should be set.
|
||||||
|
*/
|
||||||
|
shouldPartition?: boolean;
|
||||||
}
|
}
|
||||||
export type setCookieReturnValue = {
|
export type setCookieReturnValue = {
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,6 @@ export type AriaRole = 'alert' | 'alertdialog' | 'application' | 'article' | 'ba
|
||||||
'spinbutton' | 'status' | 'strong' | 'subscript' | 'superscript' | 'switch' | 'tab' | 'table' | 'tablist' | 'tabpanel' | 'term' | 'textbox' | 'time' | 'timer' |
|
'spinbutton' | 'status' | 'strong' | 'subscript' | 'superscript' | 'switch' | 'tab' | 'table' | 'tablist' | 'tabpanel' | 'term' | 'textbox' | 'time' | 'timer' |
|
||||||
'toolbar' | 'tooltip' | 'tree' | 'treegrid' | 'treeitem';
|
'toolbar' | 'tooltip' | 'tree' | 'treegrid' | 'treeitem';
|
||||||
|
|
||||||
export type ParsedYaml = Array<any>;
|
|
||||||
|
|
||||||
export type AriaProps = {
|
export type AriaProps = {
|
||||||
checked?: boolean | 'mixed';
|
checked?: boolean | 'mixed';
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|
@ -35,89 +33,218 @@ export type AriaProps = {
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// We pass parsed template between worlds using JSON, make it easy.
|
||||||
|
export type AriaRegex = { pattern: string };
|
||||||
|
|
||||||
export type AriaTemplateTextNode = {
|
export type AriaTemplateTextNode = {
|
||||||
kind: 'text';
|
kind: 'text';
|
||||||
text: RegExp | string;
|
text: AriaRegex | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AriaTemplateRoleNode = AriaProps & {
|
export type AriaTemplateRoleNode = AriaProps & {
|
||||||
kind: 'role';
|
kind: 'role';
|
||||||
role: AriaRole | 'fragment';
|
role: AriaRole | 'fragment';
|
||||||
name?: RegExp | string;
|
name?: AriaRegex | string;
|
||||||
children?: AriaTemplateNode[];
|
children?: AriaTemplateNode[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AriaTemplateNode = AriaTemplateRoleNode | AriaTemplateTextNode;
|
export type AriaTemplateNode = AriaTemplateRoleNode | AriaTemplateTextNode;
|
||||||
|
|
||||||
export function parseYamlTemplate(fragment: ParsedYaml): AriaTemplateNode {
|
import type * as yamlTypes from 'yaml';
|
||||||
const result: AriaTemplateNode = { kind: 'role', role: 'fragment' };
|
|
||||||
populateNode(result, fragment);
|
type YamlLibrary = {
|
||||||
if (result.children && result.children.length === 1)
|
parseDocument: typeof yamlTypes.parseDocument;
|
||||||
return result.children[0];
|
Scalar: typeof yamlTypes.Scalar;
|
||||||
return result;
|
YAMLMap: typeof yamlTypes.YAMLMap;
|
||||||
|
YAMLSeq: typeof yamlTypes.YAMLSeq;
|
||||||
|
LineCounter: typeof yamlTypes.LineCounter;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ParsedYamlPosition = { line: number; col: number; };
|
||||||
|
|
||||||
|
export type ParsedYamlError = {
|
||||||
|
message: string;
|
||||||
|
range: [ParsedYamlPosition, ParsedYamlPosition];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseAriaSnapshotUnsafe(yaml: YamlLibrary, text: string): AriaTemplateNode {
|
||||||
|
const result = parseAriaSnapshot(yaml, text);
|
||||||
|
if (result.errors.length)
|
||||||
|
throw new Error(result.errors[0].message);
|
||||||
|
return result.fragment;
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateNode(node: AriaTemplateRoleNode, container: ParsedYaml) {
|
export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: yamlTypes.ParseOptions = {}): { fragment: AriaTemplateNode, errors: ParsedYamlError[] } {
|
||||||
for (const object of container) {
|
const lineCounter = new yaml.LineCounter();
|
||||||
if (typeof object === 'string') {
|
const parseOptions: yamlTypes.ParseOptions = {
|
||||||
const childNode = KeyParser.parse(object);
|
keepSourceTokens: true,
|
||||||
node.children = node.children || [];
|
lineCounter,
|
||||||
node.children.push(childNode);
|
...options,
|
||||||
|
};
|
||||||
|
const yamlDoc = yaml.parseDocument(text, parseOptions);
|
||||||
|
const errors: ParsedYamlError[] = [];
|
||||||
|
|
||||||
|
const convertRange = (range: [number, number] | yamlTypes.Range): [ParsedYamlPosition, ParsedYamlPosition] => {
|
||||||
|
return [lineCounter.linePos(range[0]), lineCounter.linePos(range[1])];
|
||||||
|
};
|
||||||
|
|
||||||
|
const addError = (error: yamlTypes.YAMLError) => {
|
||||||
|
errors.push({
|
||||||
|
message: error.message,
|
||||||
|
range: [lineCounter.linePos(error.pos[0]), lineCounter.linePos(error.pos[1])],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertSeq = (container: AriaTemplateRoleNode, seq: yamlTypes.YAMLSeq) => {
|
||||||
|
for (const item of seq.items) {
|
||||||
|
const itemIsString = item instanceof yaml.Scalar && typeof item.value === 'string';
|
||||||
|
if (itemIsString) {
|
||||||
|
const childNode = KeyParser.parse(item, parseOptions, errors);
|
||||||
|
if (childNode) {
|
||||||
|
container.children = container.children || [];
|
||||||
|
container.children.push(childNode);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const itemIsMap = item instanceof yaml.YAMLMap;
|
||||||
|
if (itemIsMap) {
|
||||||
|
convertMap(container, item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
errors.push({
|
||||||
|
message: 'Sequence items should be strings or maps',
|
||||||
|
range: convertRange((item as any).range || seq.range),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
for (const key of Object.keys(object)) {
|
const convertMap = (container: AriaTemplateRoleNode, map: yamlTypes.YAMLMap) => {
|
||||||
node.children = node.children || [];
|
for (const entry of map.items) {
|
||||||
const value = object[key];
|
container.children = container.children || [];
|
||||||
|
// Key must by a string
|
||||||
if (key === 'text') {
|
const keyIsString = entry.key instanceof yaml.Scalar && typeof entry.key.value === 'string';
|
||||||
node.children.push({
|
if (!keyIsString) {
|
||||||
kind: 'text',
|
errors.push({
|
||||||
text: valueOrRegex(value)
|
message: 'Only string keys are supported',
|
||||||
|
range: convertRange((entry.key as any).range || map.range),
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const childNode = KeyParser.parse(key);
|
const key: yamlTypes.Scalar<string> = entry.key as yamlTypes.Scalar<string>;
|
||||||
if (childNode.kind === 'text') {
|
const value = entry.value;
|
||||||
node.children.push({
|
|
||||||
|
// - text: "text"
|
||||||
|
if (key.value === 'text') {
|
||||||
|
const valueIsString = value instanceof yaml.Scalar && typeof value.value === 'string';
|
||||||
|
if (!valueIsString) {
|
||||||
|
errors.push({
|
||||||
|
message: 'Text value should be a string',
|
||||||
|
range: convertRange(((entry.value as any).range || map.range)),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
container.children.push({
|
||||||
kind: 'text',
|
kind: 'text',
|
||||||
text: valueOrRegex(value)
|
text: valueOrRegex(value.value)
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === 'string') {
|
// role "name": ...
|
||||||
node.children.push({
|
const childNode = KeyParser.parse(key, parseOptions, errors);
|
||||||
...childNode, children: [{
|
if (!childNode)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// - role "name": "text"
|
||||||
|
const valueIsScalar = value instanceof yaml.Scalar;
|
||||||
|
if (valueIsScalar) {
|
||||||
|
const type = typeof value.value;
|
||||||
|
if (type !== 'string' && type !== 'number' && type !== 'boolean') {
|
||||||
|
errors.push({
|
||||||
|
message: 'Node value should be a string or a sequence',
|
||||||
|
range: convertRange(((entry.value as any).range || map.range)),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.children.push({
|
||||||
|
...childNode,
|
||||||
|
children: [{
|
||||||
kind: 'text',
|
kind: 'text',
|
||||||
text: valueOrRegex(value)
|
text: valueOrRegex(String(value.value))
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
node.children.push(childNode);
|
// - role "name":
|
||||||
populateNode(childNode, value);
|
// - child
|
||||||
|
const valueIsSequence = value instanceof yaml.YAMLSeq ;
|
||||||
|
if (valueIsSequence) {
|
||||||
|
convertSeq(childNode, value as yamlTypes.YAMLSeq);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
errors.push({
|
||||||
|
message: 'Map values should be strings or sequences',
|
||||||
|
range: convertRange((entry.value as any).range || map.range),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fragment: AriaTemplateNode = { kind: 'role', role: 'fragment' };
|
||||||
|
|
||||||
|
yamlDoc.errors.forEach(addError);
|
||||||
|
if (errors.length)
|
||||||
|
return { errors, fragment };
|
||||||
|
|
||||||
|
if (!(yamlDoc.contents instanceof yaml.YAMLSeq)) {
|
||||||
|
errors.push({
|
||||||
|
message: 'Aria snapshot must be a YAML sequence, elements starting with " -"',
|
||||||
|
range: yamlDoc.contents ? convertRange(yamlDoc.contents!.range) : [{ line: 0, col: 0 }, { line: 0, col: 0 }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (errors.length)
|
||||||
|
return { errors, fragment };
|
||||||
|
|
||||||
|
convertSeq(fragment, yamlDoc.contents as yamlTypes.YAMLSeq);
|
||||||
|
if (errors.length)
|
||||||
|
return { errors, fragment: emptyFragment };
|
||||||
|
if (fragment.children?.length === 1)
|
||||||
|
return { fragment: fragment.children[0], errors };
|
||||||
|
return { fragment, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emptyFragment: AriaTemplateRoleNode = { kind: 'role', role: 'fragment' };
|
||||||
|
|
||||||
function normalizeWhitespace(text: string) {
|
function normalizeWhitespace(text: string) {
|
||||||
return text.replace(/[\r\n\s\t]+/g, ' ').trim();
|
return text.replace(/[\r\n\s\t]+/g, ' ').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function valueOrRegex(value: string): string | RegExp {
|
export function valueOrRegex(value: string): string | AriaRegex {
|
||||||
return value.startsWith('/') && value.endsWith('/') ? new RegExp(value.slice(1, -1)) : normalizeWhitespace(value);
|
return value.startsWith('/') && value.endsWith('/') && value.length > 1 ? { pattern: value.slice(1, -1) } : normalizeWhitespace(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
class KeyParser {
|
export class KeyParser {
|
||||||
private _input: string;
|
private _input: string;
|
||||||
private _pos: number;
|
private _pos: number;
|
||||||
private _length: number;
|
private _length: number;
|
||||||
|
|
||||||
static parse(input: string): AriaTemplateNode {
|
static parse(text: yamlTypes.Scalar<string>, options: yamlTypes.ParseOptions, errors: ParsedYamlError[]): AriaTemplateRoleNode | null {
|
||||||
return new KeyParser(input)._parse();
|
try {
|
||||||
|
return new KeyParser(text.value)._parse();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ParserError) {
|
||||||
|
const message = options.prettyErrors === false ? e.message : e.message + ':\n\n' + text.value + '\n' + ' '.repeat(e.pos) + '^\n';
|
||||||
|
errors.push({
|
||||||
|
message,
|
||||||
|
range: [options.lineCounter!.linePos(text.range![0]), options.lineCounter!.linePos(text.range![0] + e.pos)],
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(input: string) {
|
constructor(input: string) {
|
||||||
|
|
@ -177,11 +304,11 @@ class KeyParser {
|
||||||
this._throwError('Unterminated string');
|
this._throwError('Unterminated string');
|
||||||
}
|
}
|
||||||
|
|
||||||
private _throwError(message: string, pos?: number): never {
|
private _throwError(message: string, offset: number = 0): never {
|
||||||
throw new AriaKeyError(message, this._input, pos || this._pos);
|
throw new ParserError(message, offset || this._pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _readRegex(): string {
|
private _readRegex(): AriaRegex {
|
||||||
let result = '';
|
let result = '';
|
||||||
let escaped = false;
|
let escaped = false;
|
||||||
let insideClass = false;
|
let insideClass = false;
|
||||||
|
|
@ -194,7 +321,7 @@ class KeyParser {
|
||||||
escaped = true;
|
escaped = true;
|
||||||
result += ch;
|
result += ch;
|
||||||
} else if (ch === '/' && !insideClass) {
|
} else if (ch === '/' && !insideClass) {
|
||||||
return result;
|
return { pattern: result };
|
||||||
} else if (ch === '[') {
|
} else if (ch === '[') {
|
||||||
insideClass = true;
|
insideClass = true;
|
||||||
result += ch;
|
result += ch;
|
||||||
|
|
@ -208,16 +335,16 @@ class KeyParser {
|
||||||
this._throwError('Unterminated regex');
|
this._throwError('Unterminated regex');
|
||||||
}
|
}
|
||||||
|
|
||||||
private _readStringOrRegex(): string | RegExp | null {
|
private _readStringOrRegex(): string | AriaRegex | null {
|
||||||
const ch = this._peek();
|
const ch = this._peek();
|
||||||
if (ch === '"') {
|
if (ch === '"') {
|
||||||
this._next();
|
this._next();
|
||||||
return this._readString();
|
return normalizeWhitespace(this._readString());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ch === '/') {
|
if (ch === '/') {
|
||||||
this._next();
|
this._next();
|
||||||
return new RegExp(this._readRegex());
|
return this._readRegex();
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -253,7 +380,7 @@ class KeyParser {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_parse(): AriaTemplateNode {
|
_parse(): AriaTemplateRoleNode {
|
||||||
this._skipWhitespace();
|
this._skipWhitespace();
|
||||||
|
|
||||||
const role = this._readIdentifier('role') as AriaTemplateRoleNode['role'];
|
const role = this._readIdentifier('role') as AriaTemplateRoleNode['role'];
|
||||||
|
|
@ -307,18 +434,11 @@ class KeyParser {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseAriaKey(key: string) {
|
export class ParserError extends Error {
|
||||||
return KeyParser.parse(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AriaKeyError extends Error {
|
|
||||||
readonly shortMessage: string;
|
|
||||||
readonly pos: number;
|
readonly pos: number;
|
||||||
|
|
||||||
constructor(message: string, input: string, pos: number) {
|
constructor(message: string, pos: number) {
|
||||||
super(message + ':\n\n' + input + '\n' + ' '.repeat(pos) + '^\n');
|
super(message);
|
||||||
this.shortMessage = message;
|
|
||||||
this.pos = pos;
|
this.pos = pos;
|
||||||
this.stack = undefined;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ export interface LocatorFactory {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function asLocator(lang: Language, selector: string, isFrameLocator: boolean = false): string {
|
export function asLocator(lang: Language, selector: string, isFrameLocator: boolean = false): string {
|
||||||
return asLocators(lang, selector, isFrameLocator)[0];
|
return asLocators(lang, selector, isFrameLocator, 1)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function asLocators(lang: Language, selector: string, isFrameLocator: boolean = false, maxOutputSize = 20, preferredQuote?: Quote): string[] {
|
export function asLocators(lang: Language, selector: string, isFrameLocator: boolean = false, maxOutputSize = 20, preferredQuote?: Quote): string[] {
|
||||||
|
|
@ -220,7 +220,7 @@ function combineTokens(factory: LocatorFactory, tokens: string[][], maxOutputSiz
|
||||||
const visit = (index: number) => {
|
const visit = (index: number) => {
|
||||||
if (index === tokens.length) {
|
if (index === tokens.length) {
|
||||||
result.push(factory.chainLocators(currentTokens));
|
result.push(factory.chainLocators(currentTokens));
|
||||||
return currentTokens.length < maxOutputSize;
|
return result.length < maxOutputSize;
|
||||||
}
|
}
|
||||||
for (const taken of tokens[index]) {
|
for (const taken of tokens[index]) {
|
||||||
currentTokens[index] = taken;
|
currentTokens[index] = taken;
|
||||||
|
|
|
||||||
|
|
@ -77,4 +77,3 @@ function parseOSReleaseText(osReleaseText: string): Map<string, string> {
|
||||||
}
|
}
|
||||||
return fields;
|
return fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,7 @@ export async function launchProcess(options: LaunchProcessOptions): Promise<Laun
|
||||||
let processClosed = false;
|
let processClosed = false;
|
||||||
let fulfillCleanup = () => {};
|
let fulfillCleanup = () => {};
|
||||||
const waitForCleanup = new Promise<void>(f => fulfillCleanup = f);
|
const waitForCleanup = new Promise<void>(f => fulfillCleanup = f);
|
||||||
spawnedProcess.once('exit', (exitCode, signal) => {
|
spawnedProcess.once('close', (exitCode, signal) => {
|
||||||
options.log(`[pid=${spawnedProcess.pid}] <process did exit: exitCode=${exitCode}, signal=${signal}>`);
|
options.log(`[pid=${spawnedProcess.pid}] <process did exit: exitCode=${exitCode}, signal=${signal}>`);
|
||||||
processClosed = true;
|
processClosed = true;
|
||||||
gracefullyCloseSet.delete(gracefullyClose);
|
gracefullyCloseSet.delete(gracefullyClose);
|
||||||
|
|
|
||||||
|
|
@ -19,54 +19,54 @@ import { AsyncLocalStorage } from 'async_hooks';
|
||||||
export type ZoneType = 'apiZone' | 'expectZone' | 'stepZone';
|
export type ZoneType = 'apiZone' | 'expectZone' | 'stepZone';
|
||||||
|
|
||||||
class ZoneManager {
|
class ZoneManager {
|
||||||
private readonly _asyncLocalStorage = new AsyncLocalStorage<Zone|undefined>();
|
private readonly _asyncLocalStorage = new AsyncLocalStorage<Zone | undefined>();
|
||||||
|
private readonly _emptyZone = Zone.createEmpty(this._asyncLocalStorage);
|
||||||
|
|
||||||
run<T, R>(type: ZoneType, data: T, func: () => R): R {
|
run<T, R>(type: ZoneType, data: T, func: () => R): R {
|
||||||
const zone = Zone._createWithData(this._asyncLocalStorage, type, data);
|
return this.current().with(type, data).run(func);
|
||||||
return this._asyncLocalStorage.run(zone, func);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
zoneData<T>(type: ZoneType): T | undefined {
|
zoneData<T>(type: ZoneType): T | undefined {
|
||||||
const zone = this._asyncLocalStorage.getStore();
|
return this.current().data(type);
|
||||||
return zone?.get(type);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
currentZone(): Zone {
|
current(): Zone {
|
||||||
return this._asyncLocalStorage.getStore() ?? Zone._createEmpty(this._asyncLocalStorage);
|
return this._asyncLocalStorage.getStore() ?? this._emptyZone;
|
||||||
}
|
}
|
||||||
|
|
||||||
exitZones<R>(func: () => R): R {
|
empty(): Zone {
|
||||||
return this._asyncLocalStorage.run(undefined, func);
|
return this._emptyZone;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Zone {
|
export class Zone {
|
||||||
private readonly _asyncLocalStorage: AsyncLocalStorage<Zone | undefined>;
|
private readonly _asyncLocalStorage: AsyncLocalStorage<Zone | undefined>;
|
||||||
private readonly _data: Map<ZoneType, unknown>;
|
private readonly _data: ReadonlyMap<ZoneType, unknown>;
|
||||||
|
|
||||||
static _createWithData(asyncLocalStorage: AsyncLocalStorage<Zone|undefined>, type: ZoneType, data: unknown) {
|
static createEmpty(asyncLocalStorage: AsyncLocalStorage<Zone | undefined>) {
|
||||||
const store = new Map(asyncLocalStorage.getStore()?._data);
|
|
||||||
store.set(type, data);
|
|
||||||
return new Zone(asyncLocalStorage, store);
|
|
||||||
}
|
|
||||||
|
|
||||||
static _createEmpty(asyncLocalStorage: AsyncLocalStorage<Zone|undefined>) {
|
|
||||||
return new Zone(asyncLocalStorage, new Map());
|
return new Zone(asyncLocalStorage, new Map());
|
||||||
}
|
}
|
||||||
|
|
||||||
private constructor(asyncLocalStorage: AsyncLocalStorage<Zone|undefined>, store: Map<ZoneType, unknown>) {
|
private constructor(asyncLocalStorage: AsyncLocalStorage<Zone | undefined>, store: Map<ZoneType, unknown>) {
|
||||||
this._asyncLocalStorage = asyncLocalStorage;
|
this._asyncLocalStorage = asyncLocalStorage;
|
||||||
this._data = store;
|
this._data = store;
|
||||||
}
|
}
|
||||||
|
|
||||||
run<R>(func: () => R): R {
|
with(type: ZoneType, data: unknown): Zone {
|
||||||
// Reset apiZone and expectZone, but restore stepZone.
|
return new Zone(this._asyncLocalStorage, new Map(this._data).set(type, data));
|
||||||
const entries = [...this._data.entries()].filter(([type]) => (type !== 'apiZone' && type !== 'expectZone'));
|
|
||||||
const resetZone = new Zone(this._asyncLocalStorage, new Map(entries));
|
|
||||||
return this._asyncLocalStorage.run(resetZone, func);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get<T>(type: ZoneType): T | undefined {
|
without(type?: ZoneType): Zone {
|
||||||
|
const data = type ? new Map(this._data) : new Map();
|
||||||
|
data.delete(type);
|
||||||
|
return new Zone(this._asyncLocalStorage, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
run<R>(func: () => R): R {
|
||||||
|
return this._asyncLocalStorage.run(this, func);
|
||||||
|
}
|
||||||
|
|
||||||
|
data<T>(type: ZoneType): T | undefined {
|
||||||
return this._data.get(type) as T | undefined;
|
return this._data.get(type) as T | undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export const program: typeof import('../bundles/utils/node_modules/commander').p
|
||||||
export const progress: typeof import('../bundles/utils/node_modules/@types/progress') = require('./utilsBundleImpl').progress;
|
export const progress: typeof import('../bundles/utils/node_modules/@types/progress') = require('./utilsBundleImpl').progress;
|
||||||
export const SocksProxyAgent: typeof import('../bundles/utils/node_modules/socks-proxy-agent').SocksProxyAgent = require('./utilsBundleImpl').SocksProxyAgent;
|
export const SocksProxyAgent: typeof import('../bundles/utils/node_modules/socks-proxy-agent').SocksProxyAgent = require('./utilsBundleImpl').SocksProxyAgent;
|
||||||
export const yaml: typeof import('../bundles/utils/node_modules/yaml') = require('./utilsBundleImpl').yaml;
|
export const yaml: typeof import('../bundles/utils/node_modules/yaml') = require('./utilsBundleImpl').yaml;
|
||||||
|
export type { Scalar as YAMLScalar, YAMLSeq, YAMLMap, YAMLError, Range as YAMLRange } from '../bundles/utils/node_modules/yaml';
|
||||||
export const ws: typeof import('../bundles/utils/node_modules/@types/ws') = require('./utilsBundleImpl').ws;
|
export const ws: typeof import('../bundles/utils/node_modules/@types/ws') = require('./utilsBundleImpl').ws;
|
||||||
export const wsServer: typeof import('../bundles/utils/node_modules/@types/ws').WebSocketServer = require('./utilsBundleImpl').wsServer;
|
export const wsServer: typeof import('../bundles/utils/node_modules/@types/ws').WebSocketServer = require('./utilsBundleImpl').wsServer;
|
||||||
export const wsReceiver = require('./utilsBundleImpl').wsReceiver;
|
export const wsReceiver = require('./utilsBundleImpl').wsReceiver;
|
||||||
|
|
|
||||||
27
packages/playwright-core/types/types.d.ts
vendored
27
packages/playwright-core/types/types.d.ts
vendored
|
|
@ -12915,7 +12915,6 @@ export interface Locator {
|
||||||
* live objects to be passed into the event:
|
* live objects to be passed into the event:
|
||||||
*
|
*
|
||||||
* ```js
|
* ```js
|
||||||
* // Note you can only create DataTransfer in Chromium and Firefox
|
|
||||||
* const dataTransfer = await page.evaluateHandle(() => new DataTransfer());
|
* const dataTransfer = await page.evaluateHandle(() => new DataTransfer());
|
||||||
* await locator.dispatchEvent('dragstart', { dataTransfer });
|
* await locator.dispatchEvent('dragstart', { dataTransfer });
|
||||||
* ```
|
* ```
|
||||||
|
|
@ -13853,18 +13852,22 @@ export interface Locator {
|
||||||
/**
|
/**
|
||||||
* Creates a locator matching all elements that match one or both 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
|
* Note that when both locators match something, the resulting locator will have multiple matches, potentially causing
|
||||||
* [locator strictness](https://playwright.dev/docs/locators#strictness) guidelines.
|
* a [locator strictness](https://playwright.dev/docs/locators#strictness) violation.
|
||||||
*
|
*
|
||||||
* **Usage**
|
* **Usage**
|
||||||
*
|
*
|
||||||
* Consider a scenario where you'd like to click on a "New email" button, but sometimes a security settings dialog
|
* Consider a scenario where you'd like to click on a "New email" button, but sometimes a security settings dialog
|
||||||
* shows up instead. In this case, you can wait for either a "New email" button, or a dialog and act accordingly.
|
* shows up instead. In this case, you can wait for either a "New email" button, or a dialog and act accordingly.
|
||||||
*
|
*
|
||||||
|
* **NOTE** If both "New email" button and security dialog appear on screen, the "or" locator will match both of them,
|
||||||
|
* possibly throwing the ["strict mode violation" error](https://playwright.dev/docs/locators#strictness). In this case, you can use
|
||||||
|
* [locator.first()](https://playwright.dev/docs/api/class-locator#locator-first) to only match one of them.
|
||||||
|
*
|
||||||
* ```js
|
* ```js
|
||||||
* const newEmail = page.getByRole('button', { name: 'New' });
|
* const newEmail = page.getByRole('button', { name: 'New' });
|
||||||
* const dialog = page.getByText('Confirm security settings');
|
* const dialog = page.getByText('Confirm security settings');
|
||||||
* await expect(newEmail.or(dialog)).toBeVisible();
|
* await expect(newEmail.or(dialog).first()).toBeVisible();
|
||||||
* if (await dialog.isVisible())
|
* if (await dialog.isVisible())
|
||||||
* await page.getByRole('button', { name: 'Dismiss' }).click();
|
* await page.getByRole('button', { name: 'Dismiss' }).click();
|
||||||
* await newEmail.click();
|
* await newEmail.click();
|
||||||
|
|
@ -14716,7 +14719,7 @@ export interface BrowserType<Unused = {}> {
|
||||||
/**
|
/**
|
||||||
* Browser distribution channel.
|
* Browser distribution channel.
|
||||||
*
|
*
|
||||||
* Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#opt-in-to-new-headless-mode).
|
* Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#chromium-new-headless-mode).
|
||||||
*
|
*
|
||||||
* Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or
|
* Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or
|
||||||
* "msedge-canary" to use branded [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge).
|
* "msedge-canary" to use branded [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge).
|
||||||
|
|
@ -15215,7 +15218,7 @@ export interface BrowserType<Unused = {}> {
|
||||||
/**
|
/**
|
||||||
* Browser distribution channel.
|
* Browser distribution channel.
|
||||||
*
|
*
|
||||||
* Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#opt-in-to-new-headless-mode).
|
* Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#chromium-new-headless-mode).
|
||||||
*
|
*
|
||||||
* Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or
|
* Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or
|
||||||
* "msedge-canary" to use branded [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge).
|
* "msedge-canary" to use branded [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge).
|
||||||
|
|
@ -16604,11 +16607,6 @@ export interface AndroidDevice {
|
||||||
*/
|
*/
|
||||||
colorScheme?: null|"light"|"dark"|"no-preference";
|
colorScheme?: null|"light"|"dark"|"no-preference";
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional package name to launch instead of default Chrome for Android.
|
|
||||||
*/
|
|
||||||
command?: string;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Specify device scale factor (can be thought of as dpr). Defaults to `1`. Learn more about
|
* Specify device scale factor (can be thought of as dpr). Defaults to `1`. Learn more about
|
||||||
* [emulating devices with device scale factor](https://playwright.dev/docs/emulation#devices).
|
* [emulating devices with device scale factor](https://playwright.dev/docs/emulation#devices).
|
||||||
|
|
@ -16717,6 +16715,11 @@ export interface AndroidDevice {
|
||||||
*/
|
*/
|
||||||
permissions?: Array<string>;
|
permissions?: Array<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional package name to launch instead of default Chrome for Android.
|
||||||
|
*/
|
||||||
|
pkg?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Network proxy settings.
|
* Network proxy settings.
|
||||||
*/
|
*/
|
||||||
|
|
@ -21566,7 +21569,7 @@ export interface LaunchOptions {
|
||||||
/**
|
/**
|
||||||
* Browser distribution channel.
|
* Browser distribution channel.
|
||||||
*
|
*
|
||||||
* Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#opt-in-to-new-headless-mode).
|
* Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#chromium-new-headless-mode).
|
||||||
*
|
*
|
||||||
* Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or
|
* Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or
|
||||||
* "msedge-canary" to use branded [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge).
|
* "msedge-canary" to use branded [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge).
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ export class FullConfigInternal {
|
||||||
cliFailOnFlakyTests?: boolean;
|
cliFailOnFlakyTests?: boolean;
|
||||||
cliLastFailed?: boolean;
|
cliLastFailed?: boolean;
|
||||||
testIdMatcher?: Matcher;
|
testIdMatcher?: Matcher;
|
||||||
|
lastFailedTestIdMatcher?: Matcher;
|
||||||
defineConfigWasUsed = false;
|
defineConfigWasUsed = false;
|
||||||
|
|
||||||
globalSetups: string[] = [];
|
globalSetups: string[] = [];
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ export type AttachmentPayload = {
|
||||||
path?: string;
|
path?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
contentType: string;
|
contentType: string;
|
||||||
|
stepId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TestInfoErrorImpl = TestInfoError & {
|
export type TestInfoErrorImpl = TestInfoError & {
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,13 @@ import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type { APIRequestContext, BrowserContext, Browser, BrowserContextOptions, LaunchOptions, Page, Tracing, Video } from 'playwright-core';
|
import type { APIRequestContext, BrowserContext, Browser, BrowserContextOptions, LaunchOptions, Page, Tracing, Video } from 'playwright-core';
|
||||||
import * as playwrightLibrary from 'playwright-core';
|
import * as playwrightLibrary from 'playwright-core';
|
||||||
import { createGuid, debugMode, addInternalStackPrefix, isString, asLocator, jsonStringifyForceASCII } from 'playwright-core/lib/utils';
|
import { createGuid, debugMode, addInternalStackPrefix, isString, asLocator, jsonStringifyForceASCII, zones } from 'playwright-core/lib/utils';
|
||||||
|
import type { ExpectZone } from 'playwright-core/lib/utils';
|
||||||
import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, VideoMode } from '../types/test';
|
import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, VideoMode } from '../types/test';
|
||||||
import type { TestInfoImpl, TestStepInternal } from './worker/testInfo';
|
import type { TestInfoImpl, TestStepInternal } from './worker/testInfo';
|
||||||
import { rootTestType } from './common/testType';
|
import { rootTestType } from './common/testType';
|
||||||
import type { ContextReuseMode } from './common/config';
|
import type { ContextReuseMode } from './common/config';
|
||||||
import type { ClientInstrumentation, ClientInstrumentationListener } from '../../playwright-core/src/client/clientInstrumentation';
|
import type { ApiCallData, ClientInstrumentation, ClientInstrumentationListener } from '../../playwright-core/src/client/clientInstrumentation';
|
||||||
import { currentTestInfo } from './common/globals';
|
import { currentTestInfo } from './common/globals';
|
||||||
export { expect } from './matchers/expect';
|
export { expect } from './matchers/expect';
|
||||||
export const _baseTest: TestType<{}, {}> = rootTestType.test;
|
export const _baseTest: TestType<{}, {}> = rootTestType.test;
|
||||||
|
|
@ -258,34 +259,43 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
|
||||||
|
|
||||||
const tracingGroupSteps: TestStepInternal[] = [];
|
const tracingGroupSteps: TestStepInternal[] = [];
|
||||||
const csiListener: ClientInstrumentationListener = {
|
const csiListener: ClientInstrumentationListener = {
|
||||||
onApiCallBegin: (apiName: string, params: Record<string, any>, frames: StackFrame[], userData: any, out: { stepId?: string }) => {
|
onApiCallBegin: (data: ApiCallData) => {
|
||||||
userData.apiName = apiName;
|
|
||||||
const testInfo = currentTestInfo();
|
const testInfo = currentTestInfo();
|
||||||
if (!testInfo || apiName.includes('setTestIdAttribute') || apiName === 'tracing.groupEnd')
|
// Some special calls do not get into steps.
|
||||||
|
if (!testInfo || data.apiName.includes('setTestIdAttribute') || data.apiName === 'tracing.groupEnd')
|
||||||
return;
|
return;
|
||||||
const step = testInfo._addStep({
|
const expectZone = zones.zoneData<ExpectZone>('expectZone');
|
||||||
location: frames[0] as any,
|
if (expectZone) {
|
||||||
category: 'pw:api',
|
// Display the internal locator._expect call under the name of the enclosing expect call,
|
||||||
title: renderApiCall(apiName, params),
|
// and connect it to the existing expect step.
|
||||||
apiName,
|
data.apiName = expectZone.title;
|
||||||
params,
|
data.stepId = expectZone.stepId;
|
||||||
}, tracingGroupSteps[tracingGroupSteps.length - 1]);
|
|
||||||
userData.step = step;
|
|
||||||
out.stepId = step.stepId;
|
|
||||||
if (apiName === 'tracing.group')
|
|
||||||
tracingGroupSteps.push(step);
|
|
||||||
},
|
|
||||||
onApiCallEnd: (userData: any, error?: Error) => {
|
|
||||||
// "tracing.group" step will end later, when "tracing.groupEnd" finishes.
|
|
||||||
if (userData.apiName === 'tracing.group')
|
|
||||||
return;
|
|
||||||
if (userData.apiName === 'tracing.groupEnd') {
|
|
||||||
const step = tracingGroupSteps.pop();
|
|
||||||
step?.complete({ error });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const step = userData.step;
|
// In the general case, create a step for each api call and connect them through the stepId.
|
||||||
step?.complete({ error });
|
const step = testInfo._addStep({
|
||||||
|
location: data.frames[0],
|
||||||
|
category: 'pw:api',
|
||||||
|
title: renderApiCall(data.apiName, data.params),
|
||||||
|
apiName: data.apiName,
|
||||||
|
params: data.params,
|
||||||
|
}, tracingGroupSteps[tracingGroupSteps.length - 1]);
|
||||||
|
data.userData = step;
|
||||||
|
data.stepId = step.stepId;
|
||||||
|
if (data.apiName === 'tracing.group')
|
||||||
|
tracingGroupSteps.push(step);
|
||||||
|
},
|
||||||
|
onApiCallEnd: (data: ApiCallData) => {
|
||||||
|
// "tracing.group" step will end later, when "tracing.groupEnd" finishes.
|
||||||
|
if (data.apiName === 'tracing.group')
|
||||||
|
return;
|
||||||
|
if (data.apiName === 'tracing.groupEnd') {
|
||||||
|
const step = tracingGroupSteps.pop();
|
||||||
|
step?.complete({ error: data.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const step = data.userData;
|
||||||
|
step?.complete({ error: data.error });
|
||||||
},
|
},
|
||||||
onWillPause: ({ keepTestTimeout }) => {
|
onWillPause: ({ keepTestTimeout }) => {
|
||||||
if (!keepTestTimeout)
|
if (!keepTestTimeout)
|
||||||
|
|
@ -441,13 +451,6 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
type StackFrame = {
|
|
||||||
file: string,
|
|
||||||
line?: number,
|
|
||||||
column?: number,
|
|
||||||
function?: string,
|
|
||||||
};
|
|
||||||
|
|
||||||
type ScreenshotOption = PlaywrightWorkerOptions['screenshot'] | undefined;
|
type ScreenshotOption = PlaywrightWorkerOptions['screenshot'] | undefined;
|
||||||
type Playwright = PlaywrightWorkerArgs['playwright'];
|
type Playwright = PlaywrightWorkerArgs['playwright'];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ export type JsonTestStepEnd = {
|
||||||
id: string;
|
id: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
error?: reporterTypes.TestError;
|
error?: reporterTypes.TestError;
|
||||||
|
attachments?: number[]; // index of JsonTestResultEnd.attachments
|
||||||
};
|
};
|
||||||
|
|
||||||
export type JsonFullResult = {
|
export type JsonFullResult = {
|
||||||
|
|
@ -249,7 +250,7 @@ export class TeleReporterReceiver {
|
||||||
const parentStep = payload.parentStepId ? result._stepMap.get(payload.parentStepId) : undefined;
|
const parentStep = payload.parentStepId ? result._stepMap.get(payload.parentStepId) : undefined;
|
||||||
|
|
||||||
const location = this._absoluteLocation(payload.location);
|
const location = this._absoluteLocation(payload.location);
|
||||||
const step = new TeleTestStep(payload, parentStep, location);
|
const step = new TeleTestStep(payload, parentStep, location, result);
|
||||||
if (parentStep)
|
if (parentStep)
|
||||||
parentStep.steps.push(step);
|
parentStep.steps.push(step);
|
||||||
else
|
else
|
||||||
|
|
@ -262,6 +263,7 @@ export class TeleReporterReceiver {
|
||||||
const test = this._tests.get(testId)!;
|
const test = this._tests.get(testId)!;
|
||||||
const result = test.results.find(r => r._id === resultId)!;
|
const result = test.results.find(r => r._id === resultId)!;
|
||||||
const step = result._stepMap.get(payload.id)!;
|
const step = result._stepMap.get(payload.id)!;
|
||||||
|
step._endPayload = payload;
|
||||||
step.duration = payload.duration;
|
step.duration = payload.duration;
|
||||||
step.error = payload.error;
|
step.error = payload.error;
|
||||||
this._reporter.onStepEnd?.(test, result, step);
|
this._reporter.onStepEnd?.(test, result, step);
|
||||||
|
|
@ -512,15 +514,20 @@ class TeleTestStep implements reporterTypes.TestStep {
|
||||||
parent: reporterTypes.TestStep | undefined;
|
parent: reporterTypes.TestStep | undefined;
|
||||||
duration: number = -1;
|
duration: number = -1;
|
||||||
steps: reporterTypes.TestStep[] = [];
|
steps: reporterTypes.TestStep[] = [];
|
||||||
|
error: reporterTypes.TestError | undefined;
|
||||||
|
|
||||||
|
private _result: TeleTestResult;
|
||||||
|
_endPayload?: JsonTestStepEnd;
|
||||||
|
|
||||||
private _startTime: number = 0;
|
private _startTime: number = 0;
|
||||||
|
|
||||||
constructor(payload: JsonTestStepStart, parentStep: reporterTypes.TestStep | undefined, location: reporterTypes.Location | undefined) {
|
constructor(payload: JsonTestStepStart, parentStep: reporterTypes.TestStep | undefined, location: reporterTypes.Location | undefined, result: TeleTestResult) {
|
||||||
this.title = payload.title;
|
this.title = payload.title;
|
||||||
this.category = payload.category;
|
this.category = payload.category;
|
||||||
this.location = location;
|
this.location = location;
|
||||||
this.parent = parentStep;
|
this.parent = parentStep;
|
||||||
this._startTime = payload.startTime;
|
this._startTime = payload.startTime;
|
||||||
|
this._result = result;
|
||||||
}
|
}
|
||||||
|
|
||||||
titlePath() {
|
titlePath() {
|
||||||
|
|
@ -535,6 +542,10 @@ class TeleTestStep implements reporterTypes.TestStep {
|
||||||
set startTime(value: Date) {
|
set startTime(value: Date) {
|
||||||
this._startTime = +value;
|
this._startTime = +value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get attachments() {
|
||||||
|
return this._endPayload?.attachments?.map(index => this._result.attachments[index]) ?? [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TeleTestResult implements reporterTypes.TestResult {
|
export class TeleTestResult implements reporterTypes.TestResult {
|
||||||
|
|
@ -550,7 +561,7 @@ export class TeleTestResult implements reporterTypes.TestResult {
|
||||||
errors: reporterTypes.TestResult['errors'] = [];
|
errors: reporterTypes.TestResult['errors'] = [];
|
||||||
error: reporterTypes.TestResult['error'];
|
error: reporterTypes.TestResult['error'];
|
||||||
|
|
||||||
_stepMap: Map<string, reporterTypes.TestStep> = new Map();
|
_stepMap = new Map<string, TeleTestStep>();
|
||||||
_id: string;
|
_id: string;
|
||||||
|
|
||||||
private _startTime: number = 0;
|
private _startTime: number = 0;
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import {
|
||||||
toContainText,
|
toContainText,
|
||||||
toHaveAccessibleDescription,
|
toHaveAccessibleDescription,
|
||||||
toHaveAccessibleName,
|
toHaveAccessibleName,
|
||||||
|
toHaveAccessibleErrorMessage,
|
||||||
toHaveAttribute,
|
toHaveAttribute,
|
||||||
toHaveClass,
|
toHaveClass,
|
||||||
toHaveCount,
|
toHaveCount,
|
||||||
|
|
@ -224,6 +225,7 @@ const customAsyncMatchers = {
|
||||||
toContainText,
|
toContainText,
|
||||||
toHaveAccessibleDescription,
|
toHaveAccessibleDescription,
|
||||||
toHaveAccessibleName,
|
toHaveAccessibleName,
|
||||||
|
toHaveAccessibleErrorMessage,
|
||||||
toHaveAttribute,
|
toHaveAttribute,
|
||||||
toHaveClass,
|
toHaveClass,
|
||||||
toHaveCount,
|
toHaveCount,
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,8 @@ export function toBeAttached(
|
||||||
) {
|
) {
|
||||||
const attached = !options || options.attached === undefined || options.attached;
|
const attached = !options || options.attached === undefined || options.attached;
|
||||||
const expected = attached ? 'attached' : 'detached';
|
const expected = attached ? 'attached' : 'detached';
|
||||||
const unexpected = attached ? 'detached' : 'attached';
|
|
||||||
const arg = attached ? '' : '{ attached: false }';
|
const arg = attached ? '' : '{ attached: false }';
|
||||||
return toBeTruthy.call(this, 'toBeAttached', locator, 'Locator', expected, unexpected, arg, async (isNot, timeout) => {
|
return toBeTruthy.call(this, 'toBeAttached', locator, 'Locator', expected, arg, async (isNot, timeout) => {
|
||||||
return await locator._expect(attached ? 'to.be.attached' : 'to.be.detached', { isNot, timeout });
|
return await locator._expect(attached ? 'to.be.attached' : 'to.be.detached', { isNot, timeout });
|
||||||
}, options);
|
}, options);
|
||||||
}
|
}
|
||||||
|
|
@ -52,14 +51,25 @@ export function toBeAttached(
|
||||||
export function toBeChecked(
|
export function toBeChecked(
|
||||||
this: ExpectMatcherState,
|
this: ExpectMatcherState,
|
||||||
locator: LocatorEx,
|
locator: LocatorEx,
|
||||||
options?: { checked?: boolean, timeout?: number },
|
options?: { checked?: boolean, indeterminate?: boolean, timeout?: number },
|
||||||
) {
|
) {
|
||||||
const checked = !options || options.checked === undefined || options.checked;
|
const checked = options?.checked;
|
||||||
const expected = checked ? 'checked' : 'unchecked';
|
const indeterminate = options?.indeterminate;
|
||||||
const unexpected = checked ? 'unchecked' : 'checked';
|
const expectedValue = {
|
||||||
const arg = checked ? '' : '{ checked: false }';
|
checked,
|
||||||
return toBeTruthy.call(this, 'toBeChecked', locator, 'Locator', expected, unexpected, arg, async (isNot, timeout) => {
|
indeterminate,
|
||||||
return await locator._expect(checked ? 'to.be.checked' : 'to.be.unchecked', { isNot, timeout });
|
};
|
||||||
|
let expected: string;
|
||||||
|
let arg: string;
|
||||||
|
if (options?.indeterminate) {
|
||||||
|
expected = 'indeterminate';
|
||||||
|
arg = `{ indeterminate: true }`;
|
||||||
|
} else {
|
||||||
|
expected = options?.checked === false ? 'unchecked' : 'checked';
|
||||||
|
arg = options?.checked === false ? `{ checked: false }` : '';
|
||||||
|
}
|
||||||
|
return toBeTruthy.call(this, 'toBeChecked', locator, 'Locator', expected, arg, async (isNot, timeout) => {
|
||||||
|
return await locator._expect('to.be.checked', { isNot, timeout, expectedValue });
|
||||||
}, options);
|
}, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,7 +78,7 @@ export function toBeDisabled(
|
||||||
locator: LocatorEx,
|
locator: LocatorEx,
|
||||||
options?: { timeout?: number },
|
options?: { timeout?: number },
|
||||||
) {
|
) {
|
||||||
return toBeTruthy.call(this, 'toBeDisabled', locator, 'Locator', 'disabled', 'enabled', '', async (isNot, timeout) => {
|
return toBeTruthy.call(this, 'toBeDisabled', locator, 'Locator', 'disabled', '', async (isNot, timeout) => {
|
||||||
return await locator._expect('to.be.disabled', { isNot, timeout });
|
return await locator._expect('to.be.disabled', { isNot, timeout });
|
||||||
}, options);
|
}, options);
|
||||||
}
|
}
|
||||||
|
|
@ -80,9 +90,8 @@ export function toBeEditable(
|
||||||
) {
|
) {
|
||||||
const editable = !options || options.editable === undefined || options.editable;
|
const editable = !options || options.editable === undefined || options.editable;
|
||||||
const expected = editable ? 'editable' : 'readOnly';
|
const expected = editable ? 'editable' : 'readOnly';
|
||||||
const unexpected = editable ? 'readOnly' : 'editable';
|
|
||||||
const arg = editable ? '' : '{ editable: false }';
|
const arg = editable ? '' : '{ editable: false }';
|
||||||
return toBeTruthy.call(this, 'toBeEditable', locator, 'Locator', expected, unexpected, arg, async (isNot, timeout) => {
|
return toBeTruthy.call(this, 'toBeEditable', locator, 'Locator', expected, arg, async (isNot, timeout) => {
|
||||||
return await locator._expect(editable ? 'to.be.editable' : 'to.be.readonly', { isNot, timeout });
|
return await locator._expect(editable ? 'to.be.editable' : 'to.be.readonly', { isNot, timeout });
|
||||||
}, options);
|
}, options);
|
||||||
}
|
}
|
||||||
|
|
@ -92,7 +101,7 @@ export function toBeEmpty(
|
||||||
locator: LocatorEx,
|
locator: LocatorEx,
|
||||||
options?: { timeout?: number },
|
options?: { timeout?: number },
|
||||||
) {
|
) {
|
||||||
return toBeTruthy.call(this, 'toBeEmpty', locator, 'Locator', 'empty', 'notEmpty', '', async (isNot, timeout) => {
|
return toBeTruthy.call(this, 'toBeEmpty', locator, 'Locator', 'empty', '', async (isNot, timeout) => {
|
||||||
return await locator._expect('to.be.empty', { isNot, timeout });
|
return await locator._expect('to.be.empty', { isNot, timeout });
|
||||||
}, options);
|
}, options);
|
||||||
}
|
}
|
||||||
|
|
@ -104,9 +113,8 @@ export function toBeEnabled(
|
||||||
) {
|
) {
|
||||||
const enabled = !options || options.enabled === undefined || options.enabled;
|
const enabled = !options || options.enabled === undefined || options.enabled;
|
||||||
const expected = enabled ? 'enabled' : 'disabled';
|
const expected = enabled ? 'enabled' : 'disabled';
|
||||||
const unexpected = enabled ? 'disabled' : 'enabled';
|
|
||||||
const arg = enabled ? '' : '{ enabled: false }';
|
const arg = enabled ? '' : '{ enabled: false }';
|
||||||
return toBeTruthy.call(this, 'toBeEnabled', locator, 'Locator', expected, unexpected, arg, async (isNot, timeout) => {
|
return toBeTruthy.call(this, 'toBeEnabled', locator, 'Locator', expected, arg, async (isNot, timeout) => {
|
||||||
return await locator._expect(enabled ? 'to.be.enabled' : 'to.be.disabled', { isNot, timeout });
|
return await locator._expect(enabled ? 'to.be.enabled' : 'to.be.disabled', { isNot, timeout });
|
||||||
}, options);
|
}, options);
|
||||||
}
|
}
|
||||||
|
|
@ -116,7 +124,7 @@ export function toBeFocused(
|
||||||
locator: LocatorEx,
|
locator: LocatorEx,
|
||||||
options?: { timeout?: number },
|
options?: { timeout?: number },
|
||||||
) {
|
) {
|
||||||
return toBeTruthy.call(this, 'toBeFocused', locator, 'Locator', 'focused', 'inactive', '', async (isNot, timeout) => {
|
return toBeTruthy.call(this, 'toBeFocused', locator, 'Locator', 'focused', '', async (isNot, timeout) => {
|
||||||
return await locator._expect('to.be.focused', { isNot, timeout });
|
return await locator._expect('to.be.focused', { isNot, timeout });
|
||||||
}, options);
|
}, options);
|
||||||
}
|
}
|
||||||
|
|
@ -126,7 +134,7 @@ export function toBeHidden(
|
||||||
locator: LocatorEx,
|
locator: LocatorEx,
|
||||||
options?: { timeout?: number },
|
options?: { timeout?: number },
|
||||||
) {
|
) {
|
||||||
return toBeTruthy.call(this, 'toBeHidden', locator, 'Locator', 'hidden', 'visible', '', async (isNot, timeout) => {
|
return toBeTruthy.call(this, 'toBeHidden', locator, 'Locator', 'hidden', '', async (isNot, timeout) => {
|
||||||
return await locator._expect('to.be.hidden', { isNot, timeout });
|
return await locator._expect('to.be.hidden', { isNot, timeout });
|
||||||
}, options);
|
}, options);
|
||||||
}
|
}
|
||||||
|
|
@ -138,9 +146,8 @@ export function toBeVisible(
|
||||||
) {
|
) {
|
||||||
const visible = !options || options.visible === undefined || options.visible;
|
const visible = !options || options.visible === undefined || options.visible;
|
||||||
const expected = visible ? 'visible' : 'hidden';
|
const expected = visible ? 'visible' : 'hidden';
|
||||||
const unexpected = visible ? 'hidden' : 'visible';
|
|
||||||
const arg = visible ? '' : '{ visible: false }';
|
const arg = visible ? '' : '{ visible: false }';
|
||||||
return toBeTruthy.call(this, 'toBeVisible', locator, 'Locator', expected, unexpected, arg, async (isNot, timeout) => {
|
return toBeTruthy.call(this, 'toBeVisible', locator, 'Locator', expected, arg, async (isNot, timeout) => {
|
||||||
return await locator._expect(visible ? 'to.be.visible' : 'to.be.hidden', { isNot, timeout });
|
return await locator._expect(visible ? 'to.be.visible' : 'to.be.hidden', { isNot, timeout });
|
||||||
}, options);
|
}, options);
|
||||||
}
|
}
|
||||||
|
|
@ -150,7 +157,7 @@ export function toBeInViewport(
|
||||||
locator: LocatorEx,
|
locator: LocatorEx,
|
||||||
options?: { timeout?: number, ratio?: number },
|
options?: { timeout?: number, ratio?: number },
|
||||||
) {
|
) {
|
||||||
return toBeTruthy.call(this, 'toBeInViewport', locator, 'Locator', 'in viewport', 'outside viewport', '', async (isNot, timeout) => {
|
return toBeTruthy.call(this, 'toBeInViewport', locator, 'Locator', 'in viewport', '', async (isNot, timeout) => {
|
||||||
return await locator._expect('to.be.in.viewport', { isNot, expectedNumber: options?.ratio, timeout });
|
return await locator._expect('to.be.in.viewport', { isNot, expectedNumber: options?.ratio, timeout });
|
||||||
}, options);
|
}, options);
|
||||||
}
|
}
|
||||||
|
|
@ -205,6 +212,18 @@ export function toHaveAccessibleName(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toHaveAccessibleErrorMessage(
|
||||||
|
this: ExpectMatcherState,
|
||||||
|
locator: LocatorEx,
|
||||||
|
expected: string | RegExp,
|
||||||
|
options?: { timeout?: number; ignoreCase?: boolean },
|
||||||
|
) {
|
||||||
|
return toMatchText.call(this, 'toHaveAccessibleErrorMessage', locator, 'Locator', async (isNot, timeout) => {
|
||||||
|
const expectedText = serializeExpectedTextValues([expected], { ignoreCase: options?.ignoreCase, normalizeWhiteSpace: true });
|
||||||
|
return await locator._expect('to.have.accessible.error.message', { expectedText: expectedText, isNot, timeout });
|
||||||
|
}, expected, options);
|
||||||
|
}
|
||||||
|
|
||||||
export function toHaveAttribute(
|
export function toHaveAttribute(
|
||||||
this: ExpectMatcherState,
|
this: ExpectMatcherState,
|
||||||
locator: LocatorEx,
|
locator: LocatorEx,
|
||||||
|
|
@ -220,7 +239,7 @@ export function toHaveAttribute(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (expected === undefined) {
|
if (expected === undefined) {
|
||||||
return toBeTruthy.call(this, 'toHaveAttribute', locator, 'Locator', 'have attribute', 'not have attribute', '', async (isNot, timeout) => {
|
return toBeTruthy.call(this, 'toHaveAttribute', locator, 'Locator', 'have attribute', '', async (isNot, timeout) => {
|
||||||
return await locator._expect('to.have.attribute', { expressionArg: name, isNot, timeout });
|
return await locator._expect('to.have.attribute', { expressionArg: name, isNot, timeout });
|
||||||
}, options);
|
}, options);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ export async function toBeTruthy(
|
||||||
receiver: Locator,
|
receiver: Locator,
|
||||||
receiverType: string,
|
receiverType: string,
|
||||||
expected: string,
|
expected: string,
|
||||||
unexpected: string,
|
|
||||||
arg: string,
|
arg: string,
|
||||||
query: (isNot: boolean, timeout: number) => Promise<{ matches: boolean, log?: string[], received?: any, timedOut?: boolean }>,
|
query: (isNot: boolean, timeout: number) => Promise<{ matches: boolean, log?: string[], received?: any, timedOut?: boolean }>,
|
||||||
options: { timeout?: number } = {},
|
options: { timeout?: number } = {},
|
||||||
|
|
@ -50,7 +49,6 @@ export async function toBeTruthy(
|
||||||
}
|
}
|
||||||
|
|
||||||
const notFound = received === kNoElementsFoundError ? received : undefined;
|
const notFound = received === kNoElementsFoundError ? received : undefined;
|
||||||
const actual = pass ? expected : unexpected;
|
|
||||||
let printedReceived: string | undefined;
|
let printedReceived: string | undefined;
|
||||||
let printedExpected: string | undefined;
|
let printedExpected: string | undefined;
|
||||||
if (pass) {
|
if (pass) {
|
||||||
|
|
@ -58,7 +56,7 @@ export async function toBeTruthy(
|
||||||
printedReceived = `Received: ${notFound ? kNoElementsFoundError : expected}`;
|
printedReceived = `Received: ${notFound ? kNoElementsFoundError : expected}`;
|
||||||
} else {
|
} else {
|
||||||
printedExpected = `Expected: ${expected}`;
|
printedExpected = `Expected: ${expected}`;
|
||||||
printedReceived = `Received: ${notFound ? kNoElementsFoundError : unexpected}`;
|
printedReceived = `Received: ${notFound ? kNoElementsFoundError : received}`;
|
||||||
}
|
}
|
||||||
const message = () => {
|
const message = () => {
|
||||||
const header = matcherHint(this, receiver, matcherName, 'locator', arg, matcherOptions, timedOut ? timeout : undefined);
|
const header = matcherHint(this, receiver, matcherName, 'locator', arg, matcherOptions, timedOut ? timeout : undefined);
|
||||||
|
|
@ -68,7 +66,7 @@ export async function toBeTruthy(
|
||||||
return {
|
return {
|
||||||
message,
|
message,
|
||||||
pass,
|
pass,
|
||||||
actual,
|
actual: received,
|
||||||
name: matcherName,
|
name: matcherName,
|
||||||
expected,
|
expected,
|
||||||
log,
|
log,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export type WebServerPluginOptions = {
|
||||||
url?: string;
|
url?: string;
|
||||||
ignoreHTTPSErrors?: boolean;
|
ignoreHTTPSErrors?: boolean;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
gracefulShutdown?: { signal: 'SIGINT' | 'SIGTERM', timeout?: number };
|
||||||
reuseExistingServer?: boolean;
|
reuseExistingServer?: boolean;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
env?: { [key: string]: string; };
|
env?: { [key: string]: string; };
|
||||||
|
|
@ -92,7 +93,7 @@ export class WebServerPlugin implements TestRunnerPlugin {
|
||||||
}
|
}
|
||||||
|
|
||||||
debugWebServer(`Starting WebServer process ${this._options.command}...`);
|
debugWebServer(`Starting WebServer process ${this._options.command}...`);
|
||||||
const { launchedProcess, kill } = await launchProcess({
|
const { launchedProcess, gracefullyClose } = await launchProcess({
|
||||||
command: this._options.command,
|
command: this._options.command,
|
||||||
env: {
|
env: {
|
||||||
...DEFAULT_ENVIRONMENT_VARIABLES,
|
...DEFAULT_ENVIRONMENT_VARIABLES,
|
||||||
|
|
@ -102,14 +103,33 @@ export class WebServerPlugin implements TestRunnerPlugin {
|
||||||
cwd: this._options.cwd,
|
cwd: this._options.cwd,
|
||||||
stdio: 'stdin',
|
stdio: 'stdin',
|
||||||
shell: true,
|
shell: true,
|
||||||
// Reject to indicate that we cannot close the web server gracefully
|
attemptToGracefullyClose: async () => {
|
||||||
// and should fallback to non-graceful shutdown.
|
if (process.platform === 'win32')
|
||||||
attemptToGracefullyClose: () => Promise.reject(),
|
throw new Error('Graceful shutdown is not supported on Windows');
|
||||||
|
if (!this._options.gracefulShutdown)
|
||||||
|
throw new Error('skip graceful shutdown');
|
||||||
|
|
||||||
|
const { signal, timeout = 0 } = this._options.gracefulShutdown;
|
||||||
|
|
||||||
|
// proper usage of SIGINT is to send it to the entire process group, see https://www.cons.org/cracauer/sigint.html
|
||||||
|
// there's no such convention for SIGTERM, so we decide what we want. signaling the process group for consistency.
|
||||||
|
process.kill(-launchedProcess.pid!, signal);
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = timeout !== 0
|
||||||
|
? setTimeout(() => reject(new Error(`process didn't close gracefully within timeout`)), timeout)
|
||||||
|
: undefined;
|
||||||
|
launchedProcess.once('close', (...args) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
log: () => {},
|
log: () => {},
|
||||||
onExit: code => processExitedReject(new Error(code ? `Process from config.webServer was not able to start. Exit code: ${code}` : 'Process from config.webServer exited early.')),
|
onExit: code => processExitedReject(new Error(code ? `Process from config.webServer was not able to start. Exit code: ${code}` : 'Process from config.webServer exited early.')),
|
||||||
tempDirectories: [],
|
tempDirectories: [],
|
||||||
});
|
});
|
||||||
this._killProcess = kill;
|
this._killProcess = gracefullyClose;
|
||||||
|
|
||||||
debugWebServer(`Process started`);
|
debugWebServer(`Process started`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ import { resolveReporterOutputPath } from '../util';
|
||||||
export type TestResultOutput = { chunk: string | Buffer, type: 'stdout' | 'stderr' };
|
export type TestResultOutput = { chunk: string | Buffer, type: 'stdout' | 'stderr' };
|
||||||
export const kOutputSymbol = Symbol('output');
|
export const kOutputSymbol = Symbol('output');
|
||||||
|
|
||||||
|
type Colors = typeof realColors;
|
||||||
|
|
||||||
type ErrorDetails = {
|
type ErrorDetails = {
|
||||||
message: string;
|
message: string;
|
||||||
location?: Location;
|
location?: Location;
|
||||||
|
|
@ -40,7 +42,57 @@ type TestSummary = {
|
||||||
fatalErrors: TestError[];
|
fatalErrors: TestError[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const { isTTY, ttyWidth, colors } = (() => {
|
export type Screen = {
|
||||||
|
resolveFiles: 'cwd' | 'rootDir';
|
||||||
|
colors: Colors;
|
||||||
|
isTTY: boolean;
|
||||||
|
ttyWidth: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const noColors: Colors = {
|
||||||
|
bold: (t: string) => t,
|
||||||
|
cyan: (t: string) => t,
|
||||||
|
dim: (t: string) => t,
|
||||||
|
gray: (t: string) => t,
|
||||||
|
green: (t: string) => t,
|
||||||
|
red: (t: string) => t,
|
||||||
|
yellow: (t: string) => t,
|
||||||
|
black: (t: string) => t,
|
||||||
|
blue: (t: string) => t,
|
||||||
|
magenta: (t: string) => t,
|
||||||
|
white: (t: string) => t,
|
||||||
|
grey: (t: string) => t,
|
||||||
|
bgBlack: (t: string) => t,
|
||||||
|
bgRed: (t: string) => t,
|
||||||
|
bgGreen: (t: string) => t,
|
||||||
|
bgYellow: (t: string) => t,
|
||||||
|
bgBlue: (t: string) => t,
|
||||||
|
bgMagenta: (t: string) => t,
|
||||||
|
bgCyan: (t: string) => t,
|
||||||
|
bgWhite: (t: string) => t,
|
||||||
|
strip: (t: string) => t,
|
||||||
|
stripColors: (t: string) => t,
|
||||||
|
reset: (t: string) => t,
|
||||||
|
italic: (t: string) => t,
|
||||||
|
underline: (t: string) => t,
|
||||||
|
inverse: (t: string) => t,
|
||||||
|
hidden: (t: string) => t,
|
||||||
|
strikethrough: (t: string) => t,
|
||||||
|
rainbow: (t: string) => t,
|
||||||
|
zebra: (t: string) => t,
|
||||||
|
america: (t: string) => t,
|
||||||
|
trap: (t: string) => t,
|
||||||
|
random: (t: string) => t,
|
||||||
|
zalgo: (t: string) => t,
|
||||||
|
|
||||||
|
enabled: false,
|
||||||
|
enable: () => {},
|
||||||
|
disable: () => {},
|
||||||
|
setTheme: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Output goes to terminal.
|
||||||
|
export const terminalScreen: Screen = (() => {
|
||||||
let isTTY = !!process.stdout.isTTY;
|
let isTTY = !!process.stdout.isTTY;
|
||||||
let ttyWidth = process.stdout.columns || 0;
|
let ttyWidth = process.stdout.columns || 0;
|
||||||
if (process.env.PLAYWRIGHT_FORCE_TTY === 'false' || process.env.PLAYWRIGHT_FORCE_TTY === '0') {
|
if (process.env.PLAYWRIGHT_FORCE_TTY === 'false' || process.env.PLAYWRIGHT_FORCE_TTY === '0') {
|
||||||
|
|
@ -63,20 +115,33 @@ export const { isTTY, ttyWidth, colors } = (() => {
|
||||||
else if (process.env.DEBUG_COLORS || process.env.FORCE_COLOR)
|
else if (process.env.DEBUG_COLORS || process.env.FORCE_COLOR)
|
||||||
useColors = true;
|
useColors = true;
|
||||||
|
|
||||||
const colors = useColors ? realColors : {
|
const colors = useColors ? realColors : noColors;
|
||||||
bold: (t: string) => t,
|
return {
|
||||||
cyan: (t: string) => t,
|
resolveFiles: 'cwd',
|
||||||
dim: (t: string) => t,
|
isTTY,
|
||||||
gray: (t: string) => t,
|
ttyWidth,
|
||||||
green: (t: string) => t,
|
colors
|
||||||
red: (t: string) => t,
|
|
||||||
yellow: (t: string) => t,
|
|
||||||
enabled: false,
|
|
||||||
};
|
};
|
||||||
return { isTTY, ttyWidth, colors };
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
export class BaseReporter implements ReporterV2 {
|
// Output does not go to terminal, but colors are controlled with terminal env vars.
|
||||||
|
export const nonTerminalScreen: Screen = {
|
||||||
|
colors: terminalScreen.colors,
|
||||||
|
isTTY: false,
|
||||||
|
ttyWidth: 0,
|
||||||
|
resolveFiles: 'rootDir',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Internal output for post-processing, should always contain real colors.
|
||||||
|
export const internalScreen: Screen = {
|
||||||
|
colors: realColors,
|
||||||
|
isTTY: false,
|
||||||
|
ttyWidth: 0,
|
||||||
|
resolveFiles: 'rootDir',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class TerminalReporter implements ReporterV2 {
|
||||||
|
screen: Screen = terminalScreen;
|
||||||
config!: FullConfig;
|
config!: FullConfig;
|
||||||
suite!: Suite;
|
suite!: Suite;
|
||||||
totalTestCount = 0;
|
totalTestCount = 0;
|
||||||
|
|
@ -122,7 +187,7 @@ export class BaseReporter implements ReporterV2 {
|
||||||
if (result.status !== 'skipped' && result.status !== test.expectedStatus)
|
if (result.status !== 'skipped' && result.status !== test.expectedStatus)
|
||||||
++this._failureCount;
|
++this._failureCount;
|
||||||
const projectName = test.titlePath()[1];
|
const projectName = test.titlePath()[1];
|
||||||
const relativePath = relativeTestPath(this.config, test);
|
const relativePath = relativeTestPath(this.screen, this.config, test);
|
||||||
const fileAndProject = (projectName ? `[${projectName}] › ` : '') + relativePath;
|
const fileAndProject = (projectName ? `[${projectName}] › ` : '') + relativePath;
|
||||||
const entry = this.fileDurations.get(fileAndProject) || { duration: 0, workers: new Set() };
|
const entry = this.fileDurations.get(fileAndProject) || { duration: 0, workers: new Set() };
|
||||||
entry.duration += result.duration;
|
entry.duration += result.duration;
|
||||||
|
|
@ -139,11 +204,11 @@ export class BaseReporter implements ReporterV2 {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fitToScreen(line: string, prefix?: string): string {
|
protected fitToScreen(line: string, prefix?: string): string {
|
||||||
if (!ttyWidth) {
|
if (!this.screen.ttyWidth) {
|
||||||
// Guard against the case where we cannot determine available width.
|
// Guard against the case where we cannot determine available width.
|
||||||
return line;
|
return line;
|
||||||
}
|
}
|
||||||
return fitToWidth(line, ttyWidth, prefix);
|
return fitToWidth(line, this.screen.ttyWidth, prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected generateStartingMessage() {
|
protected generateStartingMessage() {
|
||||||
|
|
@ -151,7 +216,7 @@ export class BaseReporter implements ReporterV2 {
|
||||||
const shardDetails = this.config.shard ? `, shard ${this.config.shard.current} of ${this.config.shard.total}` : '';
|
const shardDetails = this.config.shard ? `, shard ${this.config.shard.current} of ${this.config.shard.total}` : '';
|
||||||
if (!this.totalTestCount)
|
if (!this.totalTestCount)
|
||||||
return '';
|
return '';
|
||||||
return '\n' + colors.dim('Running ') + this.totalTestCount + colors.dim(` test${this.totalTestCount !== 1 ? 's' : ''} using `) + jobs + colors.dim(` worker${jobs !== 1 ? 's' : ''}${shardDetails}`);
|
return '\n' + this.screen.colors.dim('Running ') + this.totalTestCount + this.screen.colors.dim(` test${this.totalTestCount !== 1 ? 's' : ''} using `) + jobs + this.screen.colors.dim(` worker${jobs !== 1 ? 's' : ''}${shardDetails}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getSlowTests(): [string, number][] {
|
protected getSlowTests(): [string, number][] {
|
||||||
|
|
@ -168,28 +233,28 @@ export class BaseReporter implements ReporterV2 {
|
||||||
protected generateSummaryMessage({ didNotRun, skipped, expected, interrupted, unexpected, flaky, fatalErrors }: TestSummary) {
|
protected generateSummaryMessage({ didNotRun, skipped, expected, interrupted, unexpected, flaky, fatalErrors }: TestSummary) {
|
||||||
const tokens: string[] = [];
|
const tokens: string[] = [];
|
||||||
if (unexpected.length) {
|
if (unexpected.length) {
|
||||||
tokens.push(colors.red(` ${unexpected.length} failed`));
|
tokens.push(this.screen.colors.red(` ${unexpected.length} failed`));
|
||||||
for (const test of unexpected)
|
for (const test of unexpected)
|
||||||
tokens.push(colors.red(formatTestHeader(this.config, test, { indent: ' ' })));
|
tokens.push(this.screen.colors.red(this.formatTestHeader(test, { indent: ' ' })));
|
||||||
}
|
}
|
||||||
if (interrupted.length) {
|
if (interrupted.length) {
|
||||||
tokens.push(colors.yellow(` ${interrupted.length} interrupted`));
|
tokens.push(this.screen.colors.yellow(` ${interrupted.length} interrupted`));
|
||||||
for (const test of interrupted)
|
for (const test of interrupted)
|
||||||
tokens.push(colors.yellow(formatTestHeader(this.config, test, { indent: ' ' })));
|
tokens.push(this.screen.colors.yellow(this.formatTestHeader(test, { indent: ' ' })));
|
||||||
}
|
}
|
||||||
if (flaky.length) {
|
if (flaky.length) {
|
||||||
tokens.push(colors.yellow(` ${flaky.length} flaky`));
|
tokens.push(this.screen.colors.yellow(` ${flaky.length} flaky`));
|
||||||
for (const test of flaky)
|
for (const test of flaky)
|
||||||
tokens.push(colors.yellow(formatTestHeader(this.config, test, { indent: ' ' })));
|
tokens.push(this.screen.colors.yellow(this.formatTestHeader(test, { indent: ' ' })));
|
||||||
}
|
}
|
||||||
if (skipped)
|
if (skipped)
|
||||||
tokens.push(colors.yellow(` ${skipped} skipped`));
|
tokens.push(this.screen.colors.yellow(` ${skipped} skipped`));
|
||||||
if (didNotRun)
|
if (didNotRun)
|
||||||
tokens.push(colors.yellow(` ${didNotRun} did not run`));
|
tokens.push(this.screen.colors.yellow(` ${didNotRun} did not run`));
|
||||||
if (expected)
|
if (expected)
|
||||||
tokens.push(colors.green(` ${expected} passed`) + colors.dim(` (${milliseconds(this.result.duration)})`));
|
tokens.push(this.screen.colors.green(` ${expected} passed`) + this.screen.colors.dim(` (${milliseconds(this.result.duration)})`));
|
||||||
if (fatalErrors.length && expected + unexpected.length + interrupted.length + flaky.length > 0)
|
if (fatalErrors.length && expected + unexpected.length + interrupted.length + flaky.length > 0)
|
||||||
tokens.push(colors.red(` ${fatalErrors.length === 1 ? '1 error was not a part of any test' : fatalErrors.length + ' errors were not a part of any test'}, see above for details`));
|
tokens.push(this.screen.colors.red(` ${fatalErrors.length === 1 ? '1 error was not a part of any test' : fatalErrors.length + ' errors were not a part of any test'}, see above for details`));
|
||||||
|
|
||||||
return tokens.join('\n');
|
return tokens.join('\n');
|
||||||
}
|
}
|
||||||
|
|
@ -248,17 +313,17 @@ export class BaseReporter implements ReporterV2 {
|
||||||
private _printFailures(failures: TestCase[]) {
|
private _printFailures(failures: TestCase[]) {
|
||||||
console.log('');
|
console.log('');
|
||||||
failures.forEach((test, index) => {
|
failures.forEach((test, index) => {
|
||||||
console.log(formatFailure(this.config, test, index + 1));
|
console.log(this.formatFailure(test, index + 1));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private _printSlowTests() {
|
private _printSlowTests() {
|
||||||
const slowTests = this.getSlowTests();
|
const slowTests = this.getSlowTests();
|
||||||
slowTests.forEach(([file, duration]) => {
|
slowTests.forEach(([file, duration]) => {
|
||||||
console.log(colors.yellow(' Slow test file: ') + file + colors.yellow(` (${milliseconds(duration)})`));
|
console.log(this.screen.colors.yellow(' Slow test file: ') + file + this.screen.colors.yellow(` (${milliseconds(duration)})`));
|
||||||
});
|
});
|
||||||
if (slowTests.length)
|
if (slowTests.length)
|
||||||
console.log(colors.yellow(' Consider running tests from slow files in parallel, see https://playwright.dev/docs/test-parallel.'));
|
console.log(this.screen.colors.yellow(' Consider running tests from slow files in parallel, see https://playwright.dev/docs/test-parallel.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private _printSummary(summary: string) {
|
private _printSummary(summary: string) {
|
||||||
|
|
@ -269,21 +334,37 @@ export class BaseReporter implements ReporterV2 {
|
||||||
willRetry(test: TestCase): boolean {
|
willRetry(test: TestCase): boolean {
|
||||||
return test.outcome() === 'unexpected' && test.results.length <= test.retries;
|
return test.outcome() === 'unexpected' && test.results.length <= test.retries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formatTestTitle(test: TestCase, step?: TestStep, omitLocation: boolean = false): string {
|
||||||
|
return formatTestTitle(this.screen, this.config, test, step, omitLocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatTestHeader(test: TestCase, options: { indent?: string, index?: number, mode?: 'default' | 'error' } = {}): string {
|
||||||
|
return formatTestHeader(this.screen, this.config, test, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatFailure(test: TestCase, index?: number): string {
|
||||||
|
return formatFailure(this.screen, this.config, test, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatError(error: TestError): ErrorDetails {
|
||||||
|
return formatError(this.screen, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatFailure(config: FullConfig, test: TestCase, index?: number): string {
|
export function formatFailure(screen: Screen, config: FullConfig, test: TestCase, index?: number): string {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
const header = formatTestHeader(config, test, { indent: ' ', index, mode: 'error' });
|
const header = formatTestHeader(screen, config, test, { indent: ' ', index, mode: 'error' });
|
||||||
lines.push(colors.red(header));
|
lines.push(screen.colors.red(header));
|
||||||
for (const result of test.results) {
|
for (const result of test.results) {
|
||||||
const resultLines: string[] = [];
|
const resultLines: string[] = [];
|
||||||
const errors = formatResultFailure(test, result, ' ', colors.enabled);
|
const errors = formatResultFailure(screen, test, result, ' ');
|
||||||
if (!errors.length)
|
if (!errors.length)
|
||||||
continue;
|
continue;
|
||||||
const retryLines = [];
|
const retryLines = [];
|
||||||
if (result.retry) {
|
if (result.retry) {
|
||||||
retryLines.push('');
|
retryLines.push('');
|
||||||
retryLines.push(colors.gray(separator(` Retry #${result.retry}`)));
|
retryLines.push(screen.colors.gray(separator(screen, ` Retry #${result.retry}`)));
|
||||||
}
|
}
|
||||||
resultLines.push(...retryLines);
|
resultLines.push(...retryLines);
|
||||||
resultLines.push(...errors.map(error => '\n' + error.message));
|
resultLines.push(...errors.map(error => '\n' + error.message));
|
||||||
|
|
@ -293,16 +374,16 @@ export function formatFailure(config: FullConfig, test: TestCase, index?: number
|
||||||
if (!attachment.path && !hasPrintableContent)
|
if (!attachment.path && !hasPrintableContent)
|
||||||
continue;
|
continue;
|
||||||
resultLines.push('');
|
resultLines.push('');
|
||||||
resultLines.push(colors.cyan(separator(` attachment #${i + 1}: ${attachment.name} (${attachment.contentType})`)));
|
resultLines.push(screen.colors.cyan(separator(screen, ` attachment #${i + 1}: ${attachment.name} (${attachment.contentType})`)));
|
||||||
if (attachment.path) {
|
if (attachment.path) {
|
||||||
const relativePath = path.relative(process.cwd(), attachment.path);
|
const relativePath = path.relative(process.cwd(), attachment.path);
|
||||||
resultLines.push(colors.cyan(` ${relativePath}`));
|
resultLines.push(screen.colors.cyan(` ${relativePath}`));
|
||||||
// Make this extensible
|
// Make this extensible
|
||||||
if (attachment.name === 'trace') {
|
if (attachment.name === 'trace') {
|
||||||
const packageManagerCommand = getPackageManagerExecCommand();
|
const packageManagerCommand = getPackageManagerExecCommand();
|
||||||
resultLines.push(colors.cyan(` Usage:`));
|
resultLines.push(screen.colors.cyan(` Usage:`));
|
||||||
resultLines.push('');
|
resultLines.push('');
|
||||||
resultLines.push(colors.cyan(` ${packageManagerCommand} playwright show-trace ${quotePathIfNeeded(relativePath)}`));
|
resultLines.push(screen.colors.cyan(` ${packageManagerCommand} playwright show-trace ${quotePathIfNeeded(relativePath)}`));
|
||||||
resultLines.push('');
|
resultLines.push('');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -311,10 +392,10 @@ export function formatFailure(config: FullConfig, test: TestCase, index?: number
|
||||||
if (text.length > 300)
|
if (text.length > 300)
|
||||||
text = text.slice(0, 300) + '...';
|
text = text.slice(0, 300) + '...';
|
||||||
for (const line of text.split('\n'))
|
for (const line of text.split('\n'))
|
||||||
resultLines.push(colors.cyan(` ${line}`));
|
resultLines.push(screen.colors.cyan(` ${line}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resultLines.push(colors.cyan(separator(' ')));
|
resultLines.push(screen.colors.cyan(separator(screen, ' ')));
|
||||||
}
|
}
|
||||||
lines.push(...resultLines);
|
lines.push(...resultLines);
|
||||||
}
|
}
|
||||||
|
|
@ -322,11 +403,11 @@ export function formatFailure(config: FullConfig, test: TestCase, index?: number
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatRetry(result: TestResult) {
|
export function formatRetry(screen: Screen, result: TestResult) {
|
||||||
const retryLines = [];
|
const retryLines = [];
|
||||||
if (result.retry) {
|
if (result.retry) {
|
||||||
retryLines.push('');
|
retryLines.push('');
|
||||||
retryLines.push(colors.gray(separator(` Retry #${result.retry}`)));
|
retryLines.push(screen.colors.gray(separator(screen, ` Retry #${result.retry}`)));
|
||||||
}
|
}
|
||||||
return retryLines;
|
return retryLines;
|
||||||
}
|
}
|
||||||
|
|
@ -337,22 +418,22 @@ function quotePathIfNeeded(path: string): string {
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatResultFailure(test: TestCase, result: TestResult, initialIndent: string, highlightCode: boolean): ErrorDetails[] {
|
export function formatResultFailure(screen: Screen, test: TestCase, result: TestResult, initialIndent: string): ErrorDetails[] {
|
||||||
const errorDetails: ErrorDetails[] = [];
|
const errorDetails: ErrorDetails[] = [];
|
||||||
|
|
||||||
if (result.status === 'passed' && test.expectedStatus === 'failed') {
|
if (result.status === 'passed' && test.expectedStatus === 'failed') {
|
||||||
errorDetails.push({
|
errorDetails.push({
|
||||||
message: indent(colors.red(`Expected to fail, but passed.`), initialIndent),
|
message: indent(screen.colors.red(`Expected to fail, but passed.`), initialIndent),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (result.status === 'interrupted') {
|
if (result.status === 'interrupted') {
|
||||||
errorDetails.push({
|
errorDetails.push({
|
||||||
message: indent(colors.red(`Test was interrupted.`), initialIndent),
|
message: indent(screen.colors.red(`Test was interrupted.`), initialIndent),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const error of result.errors) {
|
for (const error of result.errors) {
|
||||||
const formattedError = formatError(error, highlightCode);
|
const formattedError = formatError(screen, error);
|
||||||
errorDetails.push({
|
errorDetails.push({
|
||||||
message: indent(formattedError.message, initialIndent),
|
message: indent(formattedError.message, initialIndent),
|
||||||
location: formattedError.location,
|
location: formattedError.location,
|
||||||
|
|
@ -361,12 +442,14 @@ export function formatResultFailure(test: TestCase, result: TestResult, initialI
|
||||||
return errorDetails;
|
return errorDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function relativeFilePath(config: FullConfig, file: string): string {
|
export function relativeFilePath(screen: Screen, config: FullConfig, file: string): string {
|
||||||
return path.relative(config.rootDir, file) || path.basename(file);
|
if (screen.resolveFiles === 'cwd')
|
||||||
|
return path.relative(process.cwd(), file);
|
||||||
|
return path.relative(config.rootDir, file);
|
||||||
}
|
}
|
||||||
|
|
||||||
function relativeTestPath(config: FullConfig, test: TestCase): string {
|
function relativeTestPath(screen: Screen, config: FullConfig, test: TestCase): string {
|
||||||
return relativeFilePath(config, test.location.file);
|
return relativeFilePath(screen, config, test.location.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stepSuffix(step: TestStep | undefined) {
|
export function stepSuffix(step: TestStep | undefined) {
|
||||||
|
|
@ -374,22 +457,22 @@ export function stepSuffix(step: TestStep | undefined) {
|
||||||
return stepTitles.map(t => t.split('\n')[0]).map(t => ' › ' + t).join('');
|
return stepTitles.map(t => t.split('\n')[0]).map(t => ' › ' + t).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTestTitle(config: FullConfig, test: TestCase, step?: TestStep, omitLocation: boolean = false): string {
|
function formatTestTitle(screen: Screen, config: FullConfig, test: TestCase, step?: TestStep, omitLocation: boolean = false): string {
|
||||||
// root, project, file, ...describes, test
|
// root, project, file, ...describes, test
|
||||||
const [, projectName, , ...titles] = test.titlePath();
|
const [, projectName, , ...titles] = test.titlePath();
|
||||||
let location;
|
let location;
|
||||||
if (omitLocation)
|
if (omitLocation)
|
||||||
location = `${relativeTestPath(config, test)}`;
|
location = `${relativeTestPath(screen, config, test)}`;
|
||||||
else
|
else
|
||||||
location = `${relativeTestPath(config, test)}:${test.location.line}:${test.location.column}`;
|
location = `${relativeTestPath(screen, config, test)}:${test.location.line}:${test.location.column}`;
|
||||||
const projectTitle = projectName ? `[${projectName}] › ` : '';
|
const projectTitle = projectName ? `[${projectName}] › ` : '';
|
||||||
const testTitle = `${projectTitle}${location} › ${titles.join(' › ')}`;
|
const testTitle = `${projectTitle}${location} › ${titles.join(' › ')}`;
|
||||||
const extraTags = test.tags.filter(t => !testTitle.includes(t));
|
const extraTags = test.tags.filter(t => !testTitle.includes(t));
|
||||||
return `${testTitle}${stepSuffix(step)}${extraTags.length ? ' ' + extraTags.join(' ') : ''}`;
|
return `${testTitle}${stepSuffix(step)}${extraTags.length ? ' ' + extraTags.join(' ') : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTestHeader(config: FullConfig, test: TestCase, options: { indent?: string, index?: number, mode?: 'default' | 'error' } = {}): string {
|
function formatTestHeader(screen: Screen, config: FullConfig, test: TestCase, options: { indent?: string, index?: number, mode?: 'default' | 'error' } = {}): string {
|
||||||
const title = formatTestTitle(config, test);
|
const title = formatTestTitle(screen, config, test);
|
||||||
const header = `${options.indent || ''}${options.index ? options.index + ') ' : ''}${title}`;
|
const header = `${options.indent || ''}${options.index ? options.index + ') ' : ''}${title}`;
|
||||||
let fullHeader = header;
|
let fullHeader = header;
|
||||||
|
|
||||||
|
|
@ -412,10 +495,10 @@ export function formatTestHeader(config: FullConfig, test: TestCase, options: {
|
||||||
}
|
}
|
||||||
fullHeader = header + (stepPaths.size === 1 ? stepPaths.values().next().value : '');
|
fullHeader = header + (stepPaths.size === 1 ? stepPaths.values().next().value : '');
|
||||||
}
|
}
|
||||||
return separator(fullHeader);
|
return separator(screen, fullHeader);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatError(error: TestError, highlightCode: boolean): ErrorDetails {
|
export function formatError(screen: Screen, error: TestError): ErrorDetails {
|
||||||
const message = error.message || error.value || '';
|
const message = error.message || error.value || '';
|
||||||
const stack = error.stack;
|
const stack = error.stack;
|
||||||
if (!stack && !error.location)
|
if (!stack && !error.location)
|
||||||
|
|
@ -430,21 +513,21 @@ export function formatError(error: TestError, highlightCode: boolean): ErrorDeta
|
||||||
|
|
||||||
if (error.snippet) {
|
if (error.snippet) {
|
||||||
let snippet = error.snippet;
|
let snippet = error.snippet;
|
||||||
if (!highlightCode)
|
if (!screen.colors.enabled)
|
||||||
snippet = stripAnsiEscapes(snippet);
|
snippet = stripAnsiEscapes(snippet);
|
||||||
tokens.push('');
|
tokens.push('');
|
||||||
tokens.push(snippet);
|
tokens.push(snippet);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsedStack && parsedStack.stackLines.length)
|
if (parsedStack && parsedStack.stackLines.length)
|
||||||
tokens.push(colors.dim(parsedStack.stackLines.join('\n')));
|
tokens.push(screen.colors.dim(parsedStack.stackLines.join('\n')));
|
||||||
|
|
||||||
let location = error.location;
|
let location = error.location;
|
||||||
if (parsedStack && !location)
|
if (parsedStack && !location)
|
||||||
location = parsedStack.location;
|
location = parsedStack.location;
|
||||||
|
|
||||||
if (error.cause)
|
if (error.cause)
|
||||||
tokens.push(colors.dim('[cause]: ') + formatError(error.cause, highlightCode).message);
|
tokens.push(screen.colors.dim('[cause]: ') + formatError(screen, error.cause).message);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
location,
|
location,
|
||||||
|
|
@ -452,11 +535,11 @@ export function formatError(error: TestError, highlightCode: boolean): ErrorDeta
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function separator(text: string = ''): string {
|
export function separator(screen: Screen, text: string = ''): string {
|
||||||
if (text)
|
if (text)
|
||||||
text += ' ';
|
text += ' ';
|
||||||
const columns = Math.min(100, ttyWidth || 100);
|
const columns = Math.min(100, screen.ttyWidth || 100);
|
||||||
return text + colors.dim('─'.repeat(Math.max(0, columns - text.length)));
|
return text + screen.colors.dim('─'.repeat(Math.max(0, columns - text.length)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function indent(lines: string, tab: string) {
|
function indent(lines: string, tab: string) {
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,10 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { colors, BaseReporter, formatError } from './base';
|
import { TerminalReporter } from './base';
|
||||||
import type { FullResult, TestCase, TestResult, Suite, TestError } from '../../types/testReporter';
|
import type { FullResult, TestCase, TestResult, Suite, TestError } from '../../types/testReporter';
|
||||||
|
|
||||||
class DotReporter extends BaseReporter {
|
class DotReporter extends TerminalReporter {
|
||||||
private _counter = 0;
|
private _counter = 0;
|
||||||
|
|
||||||
override onBegin(suite: Suite) {
|
override onBegin(suite: Suite) {
|
||||||
|
|
@ -45,23 +45,23 @@ class DotReporter extends BaseReporter {
|
||||||
}
|
}
|
||||||
++this._counter;
|
++this._counter;
|
||||||
if (result.status === 'skipped') {
|
if (result.status === 'skipped') {
|
||||||
process.stdout.write(colors.yellow('°'));
|
process.stdout.write(this.screen.colors.yellow('°'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.willRetry(test)) {
|
if (this.willRetry(test)) {
|
||||||
process.stdout.write(colors.gray('×'));
|
process.stdout.write(this.screen.colors.gray('×'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
switch (test.outcome()) {
|
switch (test.outcome()) {
|
||||||
case 'expected': process.stdout.write(colors.green('·')); break;
|
case 'expected': process.stdout.write(this.screen.colors.green('·')); break;
|
||||||
case 'unexpected': process.stdout.write(colors.red(result.status === 'timedOut' ? 'T' : 'F')); break;
|
case 'unexpected': process.stdout.write(this.screen.colors.red(result.status === 'timedOut' ? 'T' : 'F')); break;
|
||||||
case 'flaky': process.stdout.write(colors.yellow('±')); break;
|
case 'flaky': process.stdout.write(this.screen.colors.yellow('±')); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override onError(error: TestError): void {
|
override onError(error: TestError): void {
|
||||||
super.onError(error);
|
super.onError(error);
|
||||||
console.log('\n' + formatError(error, colors.enabled).message);
|
console.log('\n' + this.formatError(error).message);
|
||||||
this._counter = 0;
|
this._counter = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
import { ms as milliseconds } from 'playwright-core/lib/utilsBundle';
|
import { ms as milliseconds } from 'playwright-core/lib/utilsBundle';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { BaseReporter, colors, formatError, formatResultFailure, formatRetry, formatTestHeader, formatTestTitle, stripAnsiEscapes } from './base';
|
import { TerminalReporter, formatResultFailure, formatRetry, noColors, stripAnsiEscapes } from './base';
|
||||||
import type { TestCase, FullResult, TestError } from '../../types/testReporter';
|
import type { TestCase, FullResult, TestError } from '../../types/testReporter';
|
||||||
|
|
||||||
type GitHubLogType = 'debug' | 'notice' | 'warning' | 'error';
|
type GitHubLogType = 'debug' | 'notice' | 'warning' | 'error';
|
||||||
|
|
@ -56,9 +56,14 @@ class GitHubLogger {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GitHubReporter extends BaseReporter {
|
export class GitHubReporter extends TerminalReporter {
|
||||||
githubLogger = new GitHubLogger();
|
githubLogger = new GitHubLogger();
|
||||||
|
|
||||||
|
constructor(options: { omitFailures?: boolean } = {}) {
|
||||||
|
super(options);
|
||||||
|
this.screen = { ...this.screen, colors: noColors };
|
||||||
|
}
|
||||||
|
|
||||||
printsToStdio() {
|
printsToStdio() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -69,7 +74,7 @@ export class GitHubReporter extends BaseReporter {
|
||||||
}
|
}
|
||||||
|
|
||||||
override onError(error: TestError) {
|
override onError(error: TestError) {
|
||||||
const errorMessage = formatError(error, false).message;
|
const errorMessage = this.formatError(error).message;
|
||||||
this.githubLogger.error(errorMessage);
|
this.githubLogger.error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,10 +105,10 @@ export class GitHubReporter extends BaseReporter {
|
||||||
|
|
||||||
private _printFailureAnnotations(failures: TestCase[]) {
|
private _printFailureAnnotations(failures: TestCase[]) {
|
||||||
failures.forEach((test, index) => {
|
failures.forEach((test, index) => {
|
||||||
const title = formatTestTitle(this.config, test);
|
const title = this.formatTestTitle(test);
|
||||||
const header = formatTestHeader(this.config, test, { indent: ' ', index: index + 1, mode: 'error' });
|
const header = this.formatTestHeader(test, { indent: ' ', index: index + 1, mode: 'error' });
|
||||||
for (const result of test.results) {
|
for (const result of test.results) {
|
||||||
const errors = formatResultFailure(test, result, ' ', colors.enabled);
|
const errors = formatResultFailure(this.screen, test, result, ' ');
|
||||||
for (const error of errors) {
|
for (const error of errors) {
|
||||||
const options: GitHubLogOptions = {
|
const options: GitHubLogOptions = {
|
||||||
file: workspaceRelativePath(error.location?.file || test.location.file),
|
file: workspaceRelativePath(error.location?.file || test.location.file),
|
||||||
|
|
@ -113,7 +118,7 @@ export class GitHubReporter extends BaseReporter {
|
||||||
options.line = error.location.line;
|
options.line = error.location.line;
|
||||||
options.col = error.location.column;
|
options.col = error.location.column;
|
||||||
}
|
}
|
||||||
const message = [header, ...formatRetry(result), error.message].join('\n');
|
const message = [header, ...formatRetry(this.screen, result), error.message].join('\n');
|
||||||
this.githubLogger.error(message, options);
|
this.githubLogger.error(message, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,16 +14,16 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { open } from 'playwright-core/lib/utilsBundle';
|
import { colors, open } from 'playwright-core/lib/utilsBundle';
|
||||||
import { MultiMap, getPackageManagerExecCommand } from 'playwright-core/lib/utils';
|
import { MultiMap, getPackageManagerExecCommand } from 'playwright-core/lib/utils';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import type { TransformCallback } from 'stream';
|
import type { TransformCallback } from 'stream';
|
||||||
import { Transform } from 'stream';
|
import { Transform } from 'stream';
|
||||||
import { codeFrameColumns } from '../transform/babelBundle';
|
import { codeFrameColumns } from '../transform/babelBundle';
|
||||||
import type { FullResult, FullConfig, Location, Suite, TestCase as TestCasePublic, TestResult as TestResultPublic, TestStep as TestStepPublic, TestError } from '../../types/testReporter';
|
import type * as api from '../../types/testReporter';
|
||||||
import { HttpServer, assert, calculateSha1, copyFileAndMakeWritable, gracefullyProcessExitDoNotHang, removeFolders, sanitizeForFilePath, toPosixPath } from 'playwright-core/lib/utils';
|
import { HttpServer, assert, calculateSha1, copyFileAndMakeWritable, gracefullyProcessExitDoNotHang, removeFolders, sanitizeForFilePath, toPosixPath } from 'playwright-core/lib/utils';
|
||||||
import { colors, formatError, formatResultFailure, stripAnsiEscapes } from './base';
|
import { formatError, formatResultFailure, internalScreen, stripAnsiEscapes } from './base';
|
||||||
import { resolveReporterOutputPath } from '../util';
|
import { resolveReporterOutputPath } from '../util';
|
||||||
import type { Metadata } from '../../types/test';
|
import type { Metadata } from '../../types/test';
|
||||||
import type { ZipFile } from 'playwright-core/lib/zipBundle';
|
import type { ZipFile } from 'playwright-core/lib/zipBundle';
|
||||||
|
|
@ -56,8 +56,8 @@ type HtmlReporterOptions = {
|
||||||
};
|
};
|
||||||
|
|
||||||
class HtmlReporter implements ReporterV2 {
|
class HtmlReporter implements ReporterV2 {
|
||||||
private config!: FullConfig;
|
private config!: api.FullConfig;
|
||||||
private suite!: Suite;
|
private suite!: api.Suite;
|
||||||
private _options: HtmlReporterOptions;
|
private _options: HtmlReporterOptions;
|
||||||
private _outputFolder!: string;
|
private _outputFolder!: string;
|
||||||
private _attachmentsBaseURL!: string;
|
private _attachmentsBaseURL!: string;
|
||||||
|
|
@ -65,7 +65,7 @@ class HtmlReporter implements ReporterV2 {
|
||||||
private _port: number | undefined;
|
private _port: number | undefined;
|
||||||
private _host: string | undefined;
|
private _host: string | undefined;
|
||||||
private _buildResult: { ok: boolean, singleTestId: string | undefined } | undefined;
|
private _buildResult: { ok: boolean, singleTestId: string | undefined } | undefined;
|
||||||
private _topLevelErrors: TestError[] = [];
|
private _topLevelErrors: api.TestError[] = [];
|
||||||
|
|
||||||
constructor(options: HtmlReporterOptions) {
|
constructor(options: HtmlReporterOptions) {
|
||||||
this._options = options;
|
this._options = options;
|
||||||
|
|
@ -79,11 +79,11 @@ class HtmlReporter implements ReporterV2 {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
onConfigure(config: FullConfig) {
|
onConfigure(config: api.FullConfig) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
onBegin(suite: Suite) {
|
onBegin(suite: api.Suite) {
|
||||||
const { outputFolder, open, attachmentsBaseURL, host, port } = this._resolveOptions();
|
const { outputFolder, open, attachmentsBaseURL, host, port } = this._resolveOptions();
|
||||||
this._outputFolder = outputFolder;
|
this._outputFolder = outputFolder;
|
||||||
this._open = open;
|
this._open = open;
|
||||||
|
|
@ -125,11 +125,11 @@ class HtmlReporter implements ReporterV2 {
|
||||||
return !!relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
|
return !!relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
onError(error: TestError): void {
|
onError(error: api.TestError): void {
|
||||||
this._topLevelErrors.push(error);
|
this._topLevelErrors.push(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
async onEnd(result: FullResult) {
|
async onEnd(result: api.FullResult) {
|
||||||
const projectSuites = this.suite.suites;
|
const projectSuites = this.suite.suites;
|
||||||
await removeFolders([this._outputFolder]);
|
await removeFolders([this._outputFolder]);
|
||||||
const builder = new HtmlBuilder(this.config, this._outputFolder, this._attachmentsBaseURL);
|
const builder = new HtmlBuilder(this.config, this._outputFolder, this._attachmentsBaseURL);
|
||||||
|
|
@ -223,14 +223,14 @@ export function startHtmlReportServer(folder: string): HttpServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
class HtmlBuilder {
|
class HtmlBuilder {
|
||||||
private _config: FullConfig;
|
private _config: api.FullConfig;
|
||||||
private _reportFolder: string;
|
private _reportFolder: string;
|
||||||
private _stepsInFile = new MultiMap<string, TestStep>();
|
private _stepsInFile = new MultiMap<string, TestStep>();
|
||||||
private _dataZipFile: ZipFile;
|
private _dataZipFile: ZipFile;
|
||||||
private _hasTraces = false;
|
private _hasTraces = false;
|
||||||
private _attachmentsBaseURL: string;
|
private _attachmentsBaseURL: string;
|
||||||
|
|
||||||
constructor(config: FullConfig, outputDir: string, attachmentsBaseURL: string) {
|
constructor(config: api.FullConfig, outputDir: string, attachmentsBaseURL: string) {
|
||||||
this._config = config;
|
this._config = config;
|
||||||
this._reportFolder = outputDir;
|
this._reportFolder = outputDir;
|
||||||
fs.mkdirSync(this._reportFolder, { recursive: true });
|
fs.mkdirSync(this._reportFolder, { recursive: true });
|
||||||
|
|
@ -238,7 +238,7 @@ class HtmlBuilder {
|
||||||
this._attachmentsBaseURL = attachmentsBaseURL;
|
this._attachmentsBaseURL = attachmentsBaseURL;
|
||||||
}
|
}
|
||||||
|
|
||||||
async build(metadata: Metadata, projectSuites: Suite[], result: FullResult, topLevelErrors: TestError[]): Promise<{ ok: boolean, singleTestId: string | undefined }> {
|
async build(metadata: Metadata, projectSuites: api.Suite[], result: api.FullResult, topLevelErrors: api.TestError[]): Promise<{ ok: boolean, singleTestId: string | undefined }> {
|
||||||
const data = new Map<string, { testFile: TestFile, testFileSummary: TestFileSummary }>();
|
const data = new Map<string, { testFile: TestFile, testFileSummary: TestFileSummary }>();
|
||||||
for (const projectSuite of projectSuites) {
|
for (const projectSuite of projectSuites) {
|
||||||
for (const fileSuite of projectSuite.suites) {
|
for (const fileSuite of projectSuite.suites) {
|
||||||
|
|
@ -297,7 +297,7 @@ class HtmlBuilder {
|
||||||
files: [...data.values()].map(e => e.testFileSummary),
|
files: [...data.values()].map(e => e.testFileSummary),
|
||||||
projectNames: projectSuites.map(r => r.project()!.name),
|
projectNames: projectSuites.map(r => r.project()!.name),
|
||||||
stats: { ...[...data.values()].reduce((a, e) => addStats(a, e.testFileSummary.stats), emptyStats()) },
|
stats: { ...[...data.values()].reduce((a, e) => addStats(a, e.testFileSummary.stats), emptyStats()) },
|
||||||
errors: topLevelErrors.map(error => formatError(error, true).message),
|
errors: topLevelErrors.map(error => formatError(internalScreen, error).message),
|
||||||
};
|
};
|
||||||
htmlReport.files.sort((f1, f2) => {
|
htmlReport.files.sort((f1, f2) => {
|
||||||
const w1 = f1.stats.unexpected * 1000 + f1.stats.flaky;
|
const w1 = f1.stats.unexpected * 1000 + f1.stats.flaky;
|
||||||
|
|
@ -378,7 +378,7 @@ class HtmlBuilder {
|
||||||
this._dataZipFile.addBuffer(Buffer.from(JSON.stringify(data)), fileName);
|
this._dataZipFile.addBuffer(Buffer.from(JSON.stringify(data)), fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _processSuite(suite: Suite, projectName: string, path: string[], outTests: TestEntry[]) {
|
private _processSuite(suite: api.Suite, projectName: string, path: string[], outTests: TestEntry[]) {
|
||||||
const newPath = [...path, suite.title];
|
const newPath = [...path, suite.title];
|
||||||
suite.entries().forEach(e => {
|
suite.entries().forEach(e => {
|
||||||
if (e.type === 'test')
|
if (e.type === 'test')
|
||||||
|
|
@ -388,7 +388,7 @@ class HtmlBuilder {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private _createTestEntry(test: TestCasePublic, projectName: string, path: string[]): TestEntry {
|
private _createTestEntry(test: api.TestCase, projectName: string, path: string[]): TestEntry {
|
||||||
const duration = test.results.reduce((a, r) => a + r.duration, 0);
|
const duration = test.results.reduce((a, r) => a + r.duration, 0);
|
||||||
const location = this._relativeLocation(test.location)!;
|
const location = this._relativeLocation(test.location)!;
|
||||||
path = path.slice(1).filter(path => path.length > 0);
|
path = path.slice(1).filter(path => path.length > 0);
|
||||||
|
|
@ -500,13 +500,13 @@ class HtmlBuilder {
|
||||||
}).filter(Boolean) as TestAttachment[];
|
}).filter(Boolean) as TestAttachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
private _createTestResult(test: TestCasePublic, result: TestResultPublic): TestResult {
|
private _createTestResult(test: api.TestCase, result: api.TestResult): TestResult {
|
||||||
return {
|
return {
|
||||||
duration: result.duration,
|
duration: result.duration,
|
||||||
startTime: result.startTime.toISOString(),
|
startTime: result.startTime.toISOString(),
|
||||||
retry: result.retry,
|
retry: result.retry,
|
||||||
steps: dedupeSteps(result.steps).map(s => this._createTestStep(s)),
|
steps: dedupeSteps(result.steps).map(s => this._createTestStep(s, result)),
|
||||||
errors: formatResultFailure(test, result, '', true).map(error => error.message),
|
errors: formatResultFailure(internalScreen, test, result, '').map(error => error.message),
|
||||||
status: result.status,
|
status: result.status,
|
||||||
attachments: this._serializeAttachments([
|
attachments: this._serializeAttachments([
|
||||||
...result.attachments,
|
...result.attachments,
|
||||||
|
|
@ -515,23 +515,29 @@ class HtmlBuilder {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private _createTestStep(dedupedStep: DedupedStep): TestStep {
|
private _createTestStep(dedupedStep: DedupedStep, result: api.TestResult): TestStep {
|
||||||
const { step, duration, count } = dedupedStep;
|
const { step, duration, count } = dedupedStep;
|
||||||
const result: TestStep = {
|
const testStep: TestStep = {
|
||||||
title: step.title,
|
title: step.title,
|
||||||
startTime: step.startTime.toISOString(),
|
startTime: step.startTime.toISOString(),
|
||||||
duration,
|
duration,
|
||||||
steps: dedupeSteps(step.steps).map(s => this._createTestStep(s)),
|
steps: dedupeSteps(step.steps).map(s => this._createTestStep(s, result)),
|
||||||
|
attachments: step.attachments.map(s => {
|
||||||
|
const index = result.attachments.indexOf(s);
|
||||||
|
if (index === -1)
|
||||||
|
throw new Error('Unexpected, attachment not found');
|
||||||
|
return index;
|
||||||
|
}),
|
||||||
location: this._relativeLocation(step.location),
|
location: this._relativeLocation(step.location),
|
||||||
error: step.error?.message,
|
error: step.error?.message,
|
||||||
count
|
count
|
||||||
};
|
};
|
||||||
if (step.location)
|
if (step.location)
|
||||||
this._stepsInFile.set(step.location.file, result);
|
this._stepsInFile.set(step.location.file, testStep);
|
||||||
return result;
|
return testStep;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _relativeLocation(location: Location | undefined): Location | undefined {
|
private _relativeLocation(location: api.Location | undefined): api.Location | undefined {
|
||||||
if (!location)
|
if (!location)
|
||||||
return undefined;
|
return undefined;
|
||||||
const file = toPosixPath(path.relative(this._config.rootDir, location.file));
|
const file = toPosixPath(path.relative(this._config.rootDir, location.file));
|
||||||
|
|
@ -609,9 +615,9 @@ function stdioAttachment(chunk: Buffer | string, type: 'stdout' | 'stderr'): Jso
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type DedupedStep = { step: TestStepPublic, count: number, duration: number };
|
type DedupedStep = { step: api.TestStep, count: number, duration: number };
|
||||||
|
|
||||||
function dedupeSteps(steps: TestStepPublic[]) {
|
function dedupeSteps(steps: api.TestStep[]) {
|
||||||
const result: DedupedStep[] = [];
|
const result: DedupedStep[] = [];
|
||||||
let lastResult = undefined;
|
let lastResult = undefined;
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import fs from 'fs';
|
||||||
import { codeFrameColumns } from '../transform/babelBundle';
|
import { codeFrameColumns } from '../transform/babelBundle';
|
||||||
import type { FullConfig, TestCase, TestError, TestResult, FullResult, TestStep } from '../../types/testReporter';
|
import type { FullConfig, TestCase, TestError, TestResult, FullResult, TestStep } from '../../types/testReporter';
|
||||||
import { Suite } from '../common/test';
|
import { Suite } from '../common/test';
|
||||||
import { colors, prepareErrorStack, relativeFilePath } from './base';
|
import { internalScreen, prepareErrorStack, relativeFilePath } from './base';
|
||||||
import type { ReporterV2 } from './reporterV2';
|
import type { ReporterV2 } from './reporterV2';
|
||||||
import { monotonicTime } from 'playwright-core/lib/utils';
|
import { monotonicTime } from 'playwright-core/lib/utils';
|
||||||
import { Multiplexer } from './multiplexer';
|
import { Multiplexer } from './multiplexer';
|
||||||
|
|
@ -125,7 +125,7 @@ function addLocationAndSnippetToError(config: FullConfig, error: TestError, file
|
||||||
const codeFrame = codeFrameColumns(source, { start: location }, { highlightCode: true });
|
const codeFrame = codeFrameColumns(source, { start: location }, { highlightCode: true });
|
||||||
// Convert /var/folders to /private/var/folders on Mac.
|
// Convert /var/folders to /private/var/folders on Mac.
|
||||||
if (!file || fs.realpathSync(file) !== location.file) {
|
if (!file || fs.realpathSync(file) !== location.file) {
|
||||||
tokens.push(colors.gray(` at `) + `${relativeFilePath(config, location.file)}:${location.line}`);
|
tokens.push(internalScreen.colors.gray(` at `) + `${relativeFilePath(internalScreen, config, location.file)}:${location.line}`);
|
||||||
tokens.push('');
|
tokens.push('');
|
||||||
}
|
}
|
||||||
tokens.push(codeFrame);
|
tokens.push(codeFrame);
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import type { FullConfig, TestCase, Suite, TestResult, TestError, TestStep, FullResult, Location, JSONReport, JSONReportSuite, JSONReportSpec, JSONReportTest, JSONReportTestResult, JSONReportTestStep, JSONReportError } from '../../types/testReporter';
|
import type { FullConfig, TestCase, Suite, TestResult, TestError, TestStep, FullResult, Location, JSONReport, JSONReportSuite, JSONReportSpec, JSONReportTest, JSONReportTestResult, JSONReportTestStep, JSONReportError } from '../../types/testReporter';
|
||||||
import { formatError, prepareErrorStack, resolveOutputFile } from './base';
|
import { formatError, nonTerminalScreen, prepareErrorStack, resolveOutputFile } from './base';
|
||||||
import { MultiMap, toPosixPath } from 'playwright-core/lib/utils';
|
import { MultiMap, toPosixPath } from 'playwright-core/lib/utils';
|
||||||
import { getProjectId } from '../common/config';
|
import { getProjectId } from '../common/config';
|
||||||
import type { ReporterV2 } from './reporterV2';
|
import type { ReporterV2 } from './reporterV2';
|
||||||
|
|
@ -222,7 +222,7 @@ class JSONReporter implements ReporterV2 {
|
||||||
}
|
}
|
||||||
|
|
||||||
private _serializeError(error: TestError): JSONReportError {
|
private _serializeError(error: TestError): JSONReportError {
|
||||||
return formatError(error, true);
|
return formatError(nonTerminalScreen, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _serializeTestStep(step: TestStep): JSONReportTestStep {
|
private _serializeTestStep(step: TestStep): JSONReportTestStep {
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import type { FullConfig, FullResult, Suite, TestCase } from '../../types/testReporter';
|
import type { FullConfig, FullResult, Suite, TestCase } from '../../types/testReporter';
|
||||||
import { formatFailure, resolveOutputFile, stripAnsiEscapes } from './base';
|
import { formatFailure, nonTerminalScreen, resolveOutputFile, stripAnsiEscapes } from './base';
|
||||||
import { getAsBooleanFromENV } from 'playwright-core/lib/utils';
|
import { getAsBooleanFromENV } from 'playwright-core/lib/utils';
|
||||||
import type { ReporterV2 } from './reporterV2';
|
import type { ReporterV2 } from './reporterV2';
|
||||||
|
|
||||||
|
|
@ -188,7 +188,7 @@ class JUnitReporter implements ReporterV2 {
|
||||||
message: `${path.basename(test.location.file)}:${test.location.line}:${test.location.column} ${test.title}`,
|
message: `${path.basename(test.location.file)}:${test.location.line}:${test.location.column} ${test.title}`,
|
||||||
type: 'FAILURE',
|
type: 'FAILURE',
|
||||||
},
|
},
|
||||||
text: stripAnsiEscapes(formatFailure(this.config, test))
|
text: stripAnsiEscapes(formatFailure(nonTerminalScreen, this.config, test))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,10 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { colors, BaseReporter, formatError, formatFailure, formatTestTitle } from './base';
|
import { TerminalReporter } from './base';
|
||||||
import type { TestCase, Suite, TestResult, FullResult, TestStep, TestError } from '../../types/testReporter';
|
import type { TestCase, Suite, TestResult, FullResult, TestStep, TestError } from '../../types/testReporter';
|
||||||
|
|
||||||
class LineReporter extends BaseReporter {
|
class LineReporter extends TerminalReporter {
|
||||||
private _current = 0;
|
private _current = 0;
|
||||||
private _failures = 0;
|
private _failures = 0;
|
||||||
private _lastTest: TestCase | undefined;
|
private _lastTest: TestCase | undefined;
|
||||||
|
|
@ -50,7 +50,7 @@ class LineReporter extends BaseReporter {
|
||||||
stream.write(`\u001B[1A\u001B[2K`);
|
stream.write(`\u001B[1A\u001B[2K`);
|
||||||
if (test && this._lastTest !== test) {
|
if (test && this._lastTest !== test) {
|
||||||
// Write new header for the output.
|
// Write new header for the output.
|
||||||
const title = colors.dim(formatTestTitle(this.config, test));
|
const title = this.screen.colors.dim(this.formatTestTitle(test));
|
||||||
stream.write(this.fitToScreen(title) + `\n`);
|
stream.write(this.fitToScreen(title) + `\n`);
|
||||||
this._lastTest = test;
|
this._lastTest = test;
|
||||||
}
|
}
|
||||||
|
|
@ -82,7 +82,7 @@ class LineReporter extends BaseReporter {
|
||||||
if (!this.willRetry(test) && (test.outcome() === 'flaky' || test.outcome() === 'unexpected' || result.status === 'interrupted')) {
|
if (!this.willRetry(test) && (test.outcome() === 'flaky' || test.outcome() === 'unexpected' || result.status === 'interrupted')) {
|
||||||
if (!process.env.PW_TEST_DEBUG_REPORTERS)
|
if (!process.env.PW_TEST_DEBUG_REPORTERS)
|
||||||
process.stdout.write(`\u001B[1A\u001B[2K`);
|
process.stdout.write(`\u001B[1A\u001B[2K`);
|
||||||
console.log(formatFailure(this.config, test, ++this._failures));
|
console.log(this.formatFailure(test, ++this._failures));
|
||||||
console.log();
|
console.log();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -90,8 +90,8 @@ class LineReporter extends BaseReporter {
|
||||||
private _updateLine(test: TestCase, result: TestResult, step?: TestStep) {
|
private _updateLine(test: TestCase, result: TestResult, step?: TestStep) {
|
||||||
const retriesPrefix = this.totalTestCount < this._current ? ` (retries)` : ``;
|
const retriesPrefix = this.totalTestCount < this._current ? ` (retries)` : ``;
|
||||||
const prefix = `[${this._current}/${this.totalTestCount}]${retriesPrefix} `;
|
const prefix = `[${this._current}/${this.totalTestCount}]${retriesPrefix} `;
|
||||||
const currentRetrySuffix = result.retry ? colors.yellow(` (retry #${result.retry})`) : '';
|
const currentRetrySuffix = result.retry ? this.screen.colors.yellow(` (retry #${result.retry})`) : '';
|
||||||
const title = formatTestTitle(this.config, test, step) + currentRetrySuffix;
|
const title = this.formatTestTitle(test, step) + currentRetrySuffix;
|
||||||
if (process.env.PW_TEST_DEBUG_REPORTERS)
|
if (process.env.PW_TEST_DEBUG_REPORTERS)
|
||||||
process.stdout.write(`${prefix + title}\n`);
|
process.stdout.write(`${prefix + title}\n`);
|
||||||
else
|
else
|
||||||
|
|
@ -101,7 +101,7 @@ class LineReporter extends BaseReporter {
|
||||||
override onError(error: TestError): void {
|
override onError(error: TestError): void {
|
||||||
super.onError(error);
|
super.onError(error);
|
||||||
|
|
||||||
const message = formatError(error, colors.enabled).message + '\n';
|
const message = this.formatError(error).message + '\n';
|
||||||
if (!process.env.PW_TEST_DEBUG_REPORTERS && this._didBegin)
|
if (!process.env.PW_TEST_DEBUG_REPORTERS && this._didBegin)
|
||||||
process.stdout.write(`\u001B[1A\u001B[2K`);
|
process.stdout.write(`\u001B[1A\u001B[2K`);
|
||||||
process.stdout.write(message);
|
process.stdout.write(message);
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue