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,
|
||||
|
||||
// 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-trailing-spaces": 2,
|
||||
"linebreak-style": [ process.platform === "win32" ? 0 : 2, "unix" ],
|
||||
|
|
@ -123,6 +123,7 @@ module.exports = {
|
|||
"key-spacing": [2, {
|
||||
"beforeColon": false
|
||||
}],
|
||||
"eol-last": 2,
|
||||
|
||||
// copyright
|
||||
"notice/notice": [2, {
|
||||
|
|
|
|||
25
.github/workflows/tests_bidi.yml
vendored
25
.github/workflows/tests_bidi.yml
vendored
|
|
@ -7,6 +7,7 @@ on:
|
|||
- main
|
||||
paths:
|
||||
- .github/workflows/tests_bidi.yml
|
||||
- packages/playwright-core/src/server/bidi/*
|
||||
schedule:
|
||||
# Run every day at midnight
|
||||
- cron: '0 0 * * *'
|
||||
|
|
@ -43,3 +44,27 @@ jobs:
|
|||
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run biditest -- --project=${{ matrix.channel }}*
|
||||
env:
|
||||
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 }}
|
||||
steps:
|
||||
- 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
|
||||
with:
|
||||
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
|
||||
- run: npm install -g yarn@1
|
||||
- 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
|
||||
with:
|
||||
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
|
||||
- run: npm install -g yarn@1
|
||||
- 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
|
||||
with:
|
||||
node-version: ${{ matrix.node_version }}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# 🎭 Playwright
|
||||
|
||||
[](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[](https://webkit.org/)<!-- GEN:stop --> [](https://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)
|
||||
|
||||
|
|
@ -8,9 +8,9 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr
|
|||
|
||||
| | 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: |
|
||||
| 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ Launches Chrome browser on the device, and returns its persistent context.
|
|||
|
||||
### option: AndroidDevice.launchBrowser.pkg
|
||||
* since: v1.9
|
||||
- `command` <[string]>
|
||||
- `pkg` <[string]>
|
||||
|
||||
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:
|
||||
|
||||
```js
|
||||
// Note you can only create DataTransfer in Chromium and Firefox
|
||||
const dataTransfer = await page.evaluateHandle(() => new DataTransfer());
|
||||
await locator.dispatchEvent('dragstart', { dataTransfer });
|
||||
```
|
||||
|
||||
```java
|
||||
// Note you can only create DataTransfer in Chromium and Firefox
|
||||
JSHandle dataTransfer = page.evaluateHandle("() => new DataTransfer()");
|
||||
Map<String, Object> arg = new HashMap<>();
|
||||
arg.put("dataTransfer", dataTransfer);
|
||||
|
|
@ -647,13 +645,11 @@ locator.dispatchEvent("dragstart", arg);
|
|||
```
|
||||
|
||||
```python async
|
||||
# note you can only create data_transfer in chromium and firefox
|
||||
data_transfer = await page.evaluate_handle("new DataTransfer()")
|
||||
await locator.dispatch_event("#source", "dragstart", {"dataTransfer": data_transfer})
|
||||
```
|
||||
|
||||
```python sync
|
||||
# note you can only create data_transfer in chromium and firefox
|
||||
data_transfer = page.evaluate_handle("new DataTransfer()")
|
||||
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.
|
||||
|
||||
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**
|
||||
|
||||
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
|
||||
const newEmail = page.getByRole('button', { name: 'New' });
|
||||
const dialog = page.getByText('Confirm security settings');
|
||||
await expect(newEmail.or(dialog)).toBeVisible();
|
||||
await expect(newEmail.or(dialog).first()).toBeVisible();
|
||||
if (await dialog.isVisible())
|
||||
await page.getByRole('button', { name: 'Dismiss' }).click();
|
||||
await newEmail.click();
|
||||
|
|
@ -1735,7 +1736,7 @@ await newEmail.click();
|
|||
```java
|
||||
Locator newEmail = page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("New"));
|
||||
Locator dialog = page.getByText("Confirm security settings");
|
||||
assertThat(newEmail.or(dialog)).isVisible();
|
||||
assertThat(newEmail.or(dialog).first()).isVisible();
|
||||
if (dialog.isVisible())
|
||||
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Dismiss")).click();
|
||||
newEmail.click();
|
||||
|
|
@ -1744,7 +1745,7 @@ newEmail.click();
|
|||
```python async
|
||||
new_email = page.get_by_role("button", name="New")
|
||||
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()):
|
||||
await page.get_by_role("button", name="Dismiss").click()
|
||||
await new_email.click()
|
||||
|
|
@ -1753,7 +1754,7 @@ await new_email.click()
|
|||
```python sync
|
||||
new_email = page.get_by_role("button", name="New")
|
||||
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()):
|
||||
page.get_by_role("button", name="Dismiss").click()
|
||||
new_email.click()
|
||||
|
|
@ -1762,7 +1763,7 @@ new_email.click()
|
|||
```csharp
|
||||
var newEmail = page.GetByRole(AriaRole.Button, new() { Name = "New" });
|
||||
var dialog = page.GetByText("Confirm security settings");
|
||||
await Expect(newEmail.Or(dialog)).ToBeVisibleAsync();
|
||||
await Expect(newEmail.Or(dialog).First).ToBeVisibleAsync();
|
||||
if (await dialog.IsVisibleAsync())
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Dismiss" }).ClickAsync();
|
||||
await newEmail.ClickAsync();
|
||||
|
|
|
|||
|
|
@ -541,6 +541,16 @@ await Expect(locator).ToBeCheckedAsync();
|
|||
* since: v1.18
|
||||
- `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-%%
|
||||
* since: v1.18
|
||||
|
||||
|
|
@ -1217,6 +1227,56 @@ Expected accessible description.
|
|||
* 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
|
||||
* since: v1.44
|
||||
* langs:
|
||||
|
|
|
|||
|
|
@ -1003,7 +1003,7 @@ Additional arguments to pass to the browser instance. The list of Chromium flags
|
|||
|
||||
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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
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
|
||||
# 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
|
||||
```
|
||||
|
||||
#### 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):
|
||||
|
||||
|
|
@ -419,6 +419,28 @@ pytest test_login.py --browser-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
|
||||
|
||||
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.
|
||||
:::
|
||||
|
||||
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
|
||||
const { chromium } = require('playwright');
|
||||
|
|
@ -18,7 +20,7 @@ const { chromium } = require('playwright');
|
|||
const pathToExtension = require('path').join(__dirname, 'my-extension');
|
||||
const userDataDir = '/tmp/test-user-data-dir';
|
||||
const browserContext = await chromium.launchPersistentContext(userDataDir, {
|
||||
headless: false,
|
||||
channel: 'chromium',
|
||||
args: [
|
||||
`--disable-extensions-except=${pathToExtension}`,
|
||||
`--load-extension=${pathToExtension}`
|
||||
|
|
@ -44,7 +46,7 @@ user_data_dir = "/tmp/test-user-data-dir"
|
|||
async def run(playwright: Playwright):
|
||||
context = await playwright.chromium.launch_persistent_context(
|
||||
user_data_dir,
|
||||
headless=False,
|
||||
channel="chromium",
|
||||
args=[
|
||||
f"--disable-extensions-except={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):
|
||||
context = playwright.chromium.launch_persistent_context(
|
||||
user_data_dir,
|
||||
headless=False,
|
||||
channel="chromium",
|
||||
args=[
|
||||
f"--disable-extensions-except={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.
|
||||
|
||||
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:
|
||||
|
||||
```js title="fixtures.ts"
|
||||
|
|
@ -114,7 +118,7 @@ export const test = base.extend<{
|
|||
context: async ({ }, use) => {
|
||||
const pathToExtension = path.join(__dirname, 'my-extension');
|
||||
const context = await chromium.launchPersistentContext('', {
|
||||
headless: false,
|
||||
channel: 'chromium',
|
||||
args: [
|
||||
`--disable-extensions-except=${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")
|
||||
context = playwright.chromium.launch_persistent_context(
|
||||
"",
|
||||
headless=False,
|
||||
channel="chromium",
|
||||
args=[
|
||||
f"--disable-extensions-except={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")
|
||||
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
|
||||
await page.getByText('Item').click({ modifiers: ['Shift'] });
|
||||
|
||||
// Ctrl + click or Windows and Linux
|
||||
// Ctrl + click on Windows and Linux
|
||||
// Meta + click on macOS
|
||||
await page.getByText('Item').click({ modifiers: ['ControlOrMeta'] });
|
||||
|
||||
|
|
@ -241,7 +241,7 @@ page.getByText("Item").click(new Locator.ClickOptions().setButton(MouseButton.RI
|
|||
// Shift + click
|
||||
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
|
||||
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
|
||||
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
|
||||
await page.get_by_text("Item").click(modifiers=["ControlOrMeta"])
|
||||
|
||||
|
|
@ -309,7 +309,7 @@ await page.GetByText("Item").ClickAsync(new() { Button = MouseButton.Right });
|
|||
// Shift + click
|
||||
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
|
||||
await page.GetByText("Item").ClickAsync(new() { Modifiers = new[] { KeyboardModifier.ControlOrMeta } });
|
||||
|
||||
|
|
|
|||
|
|
@ -286,7 +286,7 @@ pnpm exec playwright --version
|
|||
|
||||
## 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).
|
||||
- macOS 13 Ventura, or later.
|
||||
- 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"`.
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
Launch a development web server (or multiple) during the tests.
|
||||
|
|
|
|||
|
|
@ -695,7 +695,7 @@ test('passes', async ({ database, page, a11y }) => {
|
|||
|
||||
## 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
|
||||
import { test as base } from '@playwright/test';
|
||||
|
|
|
|||
|
|
@ -50,6 +50,16 @@ Start time of this particular test 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
|
||||
* since: v1.10
|
||||
- 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. |
|
||||
| `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"`. |
|
||||
| `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
|
||||
|
||||
|
|
|
|||
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": {
|
||||
"version": "0.0.0"
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"yaml": "^2.6.0"
|
||||
}
|
||||
},
|
||||
"packages/web": {
|
||||
"version": "0.0.0",
|
||||
|
|
|
|||
|
|
@ -51,4 +51,4 @@ export function bundle(): Plugin {
|
|||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,11 @@
|
|||
color: var(--color-scale-orange-6);
|
||||
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) {
|
||||
|
|
@ -93,6 +98,11 @@
|
|||
color: var(--color-scale-orange-2);
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -68,11 +68,12 @@ export const ProjectLink: React.FunctionComponent<{
|
|||
|
||||
export const AttachmentLink: React.FunctionComponent<{
|
||||
attachment: TestAttachment,
|
||||
result: TestResult,
|
||||
href?: string,
|
||||
linkName?: string,
|
||||
openInNewTab?: boolean,
|
||||
}> = ({ attachment, href, linkName, openInNewTab }) => {
|
||||
const isAnchored = useIsAnchored('attachment-' + attachment.name);
|
||||
}> = ({ attachment, result, href, linkName, openInNewTab }) => {
|
||||
const isAnchored = useIsAnchored('attachment-' + result.attachments.indexOf(attachment));
|
||||
return <TreeItem title={<span>
|
||||
{attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()}
|
||||
{attachment.path && <a href={href || attachment.path} download={downloadFileNameForAttachment(attachment)}>{linkName || attachment.name}</a>}
|
||||
|
|
|
|||
|
|
@ -37,8 +37,10 @@ const result: TestResult = {
|
|||
duration: 10,
|
||||
location: { file: 'test.spec.ts', line: 82, column: 0 },
|
||||
steps: [],
|
||||
attachments: [],
|
||||
count: 1,
|
||||
}],
|
||||
attachments: [],
|
||||
}],
|
||||
attachments: [],
|
||||
status: 'passed',
|
||||
|
|
@ -139,6 +141,7 @@ const resultWithAttachment: TestResult = {
|
|||
location: { file: 'test.spec.ts', line: 62, column: 0 },
|
||||
count: 1,
|
||||
steps: [],
|
||||
attachments: [1],
|
||||
}],
|
||||
attachments: [{
|
||||
name: 'first attachment',
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ function imageDiffBadge(test: TestCaseSummary): JSX.Element | undefined {
|
|||
for (const result of test.results) {
|
||||
for (const attachment of result.attachments) {
|
||||
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[];
|
||||
}
|
||||
|
||||
function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiffWithAnchors[] {
|
||||
function groupImageDiffs(screenshots: Set<TestAttachment>, result: TestResult): ImageDiffWithAnchors[] {
|
||||
const snapshotNameToImageDiff = new Map<string, ImageDiffWithAnchors>();
|
||||
for (const attachment of screenshots) {
|
||||
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}`] };
|
||||
snapshotNameToImageDiff.set(snapshotName, imageDiff);
|
||||
}
|
||||
imageDiff.anchors.push(`attachment-${attachment.name}`);
|
||||
imageDiff.anchors.push(`attachment-${result.attachments.indexOf(attachment)}`);
|
||||
if (category === 'actual')
|
||||
imageDiff.actual = { attachment };
|
||||
if (category === 'expected')
|
||||
|
|
@ -72,15 +72,15 @@ export const TestResultView: React.FC<{
|
|||
result: TestResult,
|
||||
}> = ({ test, result }) => {
|
||||
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 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 traces = attachments.filter(a => a.name === 'trace');
|
||||
const otherAttachments = new Set<TestAttachment>(attachments);
|
||||
[...screenshots, ...videos, ...traces].forEach(a => otherAttachments.delete(a));
|
||||
const otherAttachmentAnchors = [...otherAttachments].map(a => `attachment-${a.name}`);
|
||||
const diffs = groupImageDiffs(screenshots);
|
||||
const otherAttachmentAnchors = [...otherAttachments].map(a => `attachment-${attachments.indexOf(a)}`);
|
||||
const diffs = groupImageDiffs(screenshots, result);
|
||||
const errors = classifyErrors(result.errors, diffs);
|
||||
return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors };
|
||||
}, [result]);
|
||||
|
|
@ -107,11 +107,11 @@ export const TestResultView: React.FC<{
|
|||
|
||||
{!!screenshots.length && <AutoChip header='Screenshots' revealOnAnchorId={screenshotAnchors}>
|
||||
{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}>
|
||||
<img className='screenshot' src={a.path} />
|
||||
</a>
|
||||
<AttachmentLink attachment={a}></AttachmentLink>
|
||||
<AttachmentLink attachment={a} result={result}></AttachmentLink>
|
||||
</Anchor>;
|
||||
})}
|
||||
</AutoChip>}
|
||||
|
|
@ -121,7 +121,7 @@ export const TestResultView: React.FC<{
|
|||
<a href={generateTraceUrl(traces)}>
|
||||
<img className='screenshot' src={traceImage} style={{ width: 192, height: 117, marginLeft: 20 }} />
|
||||
</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>}
|
||||
</AutoChip></Anchor>}
|
||||
|
||||
|
|
@ -130,14 +130,14 @@ export const TestResultView: React.FC<{
|
|||
<video controls>
|
||||
<source src={a.path} type={a.contentType}/>
|
||||
</video>
|
||||
<AttachmentLink attachment={a}></AttachmentLink>
|
||||
<AttachmentLink attachment={a} result={result}></AttachmentLink>
|
||||
</div>)}
|
||||
</AutoChip></Anchor>}
|
||||
|
||||
{!!otherAttachments.size && <AutoChip header='Attachments' revealOnAnchorId={otherAttachmentAnchors}>
|
||||
{!!otherAttachments.size && <AutoChip header='Attachments' revealOnAnchorId={otherAttachmentAnchors} dataTestId='attachments'>
|
||||
{[...otherAttachments].map((a, i) =>
|
||||
<Anchor key={`attachment-link-${i}`} id={`attachment-${a.name}`}>
|
||||
<AttachmentLink attachment={a} openInNewTab={a.contentType.startsWith('text/html')} />
|
||||
<Anchor key={`attachment-link-${i}`} id={`attachment-${result.attachments.indexOf(a)}`}>
|
||||
<AttachmentLink attachment={a} result={result} openInNewTab={a.contentType.startsWith('text/html')} />
|
||||
</Anchor>
|
||||
)}
|
||||
</AutoChip>}
|
||||
|
|
@ -174,18 +174,29 @@ const StepTreeItem: React.FC<{
|
|||
step: TestStep;
|
||||
depth: number,
|
||||
}> = ({ test, step, result, depth }) => {
|
||||
const attachmentName = step.title.match(/^attach "(.*)"$/)?.[1];
|
||||
return <TreeItem title={<span aria-label={step.title}>
|
||||
<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')}
|
||||
<span>{step.title}</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>}
|
||||
</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} />);
|
||||
if (step.snippet)
|
||||
children.unshift(<TestErrorView testId='test-snippet' key='line' error={step.snippet}/>);
|
||||
return children;
|
||||
const snippet = step.snippet ? [<TestErrorView testId='test-snippet' key='line' error={step.snippet}/>] : [];
|
||||
const steps = step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1} result={result} test={test} />);
|
||||
const attachments = step.attachments.map(attachmentIndex => (
|
||||
<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}/>;
|
||||
};
|
||||
|
|
|
|||
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;
|
||||
error?: string;
|
||||
steps: TestStep[];
|
||||
attachments: number[];
|
||||
count: number;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -47,4 +47,3 @@ export function hashStringToInt(str: string) {
|
|||
hash = str.charCodeAt(i) + ((hash << 8) - hash);
|
||||
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": [
|
||||
{
|
||||
"name": "chromium",
|
||||
"revision": "1152",
|
||||
"revision": "1153",
|
||||
"installByDefault": true,
|
||||
"browserVersion": "132.0.6834.46"
|
||||
"browserVersion": "132.0.6834.57"
|
||||
},
|
||||
{
|
||||
"name": "chromium-tip-of-tree",
|
||||
"revision": "1287",
|
||||
"revision": "1293",
|
||||
"installByDefault": false,
|
||||
"browserVersion": "133.0.6901.0"
|
||||
"browserVersion": "133.0.6943.0"
|
||||
},
|
||||
{
|
||||
"name": "firefox",
|
||||
"revision": "1466",
|
||||
"revision": "1470",
|
||||
"installByDefault": true,
|
||||
"browserVersion": "132.0"
|
||||
"browserVersion": "133.0.3"
|
||||
},
|
||||
{
|
||||
"name": "firefox-beta",
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
},
|
||||
{
|
||||
"name": "webkit",
|
||||
"revision": "2120",
|
||||
"revision": "2122",
|
||||
"installByDefault": true,
|
||||
"revisionOverrides": {
|
||||
"debian11-x64": "2105",
|
||||
|
|
@ -52,6 +52,11 @@
|
|||
"mac12-arm64": "1011"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "winldd",
|
||||
"revision": "1007",
|
||||
"installByDefault": false
|
||||
},
|
||||
{
|
||||
"name": "android",
|
||||
"revision": "1001",
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@ import { rewriteErrorMessage } from './utils/stackTrace';
|
|||
import { SocksProxy } from './common/socksProxy';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -116,6 +116,9 @@ function checkBrowsersToInstall(args: string[], options: { noShell?: boolean, on
|
|||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'win32')
|
||||
executables.push(registry.findExecutable('winldd')!);
|
||||
|
||||
if (faultyArguments.length)
|
||||
throw new Error(`Invalid installation targets: ${faultyArguments.map(name => `'${name}'`).join(', ')}. Expecting one of: ${suggestedBrowsersToInstall()}`);
|
||||
return executables;
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import { EventEmitter } from './eventEmitter';
|
|||
import type * as channels from '@protocol/channels';
|
||||
import { maybeFindValidator, ValidationError, type ValidatorContext } from '../protocol/validator';
|
||||
import { debugLogger } from '../utils/debugLogger';
|
||||
import type { ExpectZone } from '../utils/stackTrace';
|
||||
import { captureLibraryStackTrace, stringifyStackFrames } from '../utils/stackTrace';
|
||||
import { isUnderTest } from '../utils';
|
||||
import { zones } from '../utils/zones';
|
||||
|
|
@ -148,15 +147,18 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
|
|||
if (validator) {
|
||||
return async (params: any) => {
|
||||
return await this._wrapApiCall(async apiZone => {
|
||||
const { apiName, frames, csi, callCookie, stepId } = apiZone.reported ? { apiName: undefined, csi: undefined, callCookie: undefined, frames: [], stepId: undefined } : apiZone;
|
||||
apiZone.reported = true;
|
||||
let currentStepId = stepId;
|
||||
if (csi && apiName) {
|
||||
const out: { stepId?: string } = {};
|
||||
csi.onApiCallBegin(apiName, params, frames, callCookie, out);
|
||||
currentStepId = out.stepId;
|
||||
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;
|
||||
this._instrumentation.onApiCallBegin(apiZone);
|
||||
logApiCall(this._logger, `=> ${apiZone.apiName} started`);
|
||||
return await this._connection.sendMessageToServer(this, prop, validatedParams, apiZone.apiName, apiZone.frames, apiZone.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> {
|
||||
const logger = this._logger;
|
||||
const apiZone = zones.zoneData<ApiZone>('apiZone');
|
||||
if (apiZone)
|
||||
return await func(apiZone);
|
||||
|
||||
const stackTrace = captureLibraryStackTrace();
|
||||
let apiName: string | undefined = stackTrace.apiName;
|
||||
const frames: channels.StackFrame[] = stackTrace.frames;
|
||||
const existingApiZone = zones.zoneData<ApiZone>('apiZone');
|
||||
if (existingApiZone)
|
||||
return await func(existingApiZone);
|
||||
|
||||
if (isInternal === undefined)
|
||||
isInternal = this._isInternalType;
|
||||
if (isInternal)
|
||||
apiName = 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 = {};
|
||||
const stackTrace = captureLibraryStackTrace();
|
||||
const apiZone: ApiZone = { apiName: stackTrace.apiName, frames: stackTrace.frames, isInternal, reported: false, userData: undefined, stepId: undefined };
|
||||
|
||||
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));
|
||||
csi?.onApiCallEnd(callCookie);
|
||||
logApiCall(logger, `<= ${apiName} succeeded`, isInternal);
|
||||
if (!isInternal) {
|
||||
logApiCall(logger, `<= ${apiZone.apiName} succeeded`);
|
||||
this._instrumentation.onApiCallEnd(apiZone);
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
const innerError = ((process.env.PWDEBUGIMPL || isUnderTest()) && e.stack) ? '\n<inner error>\n' + e.stack : '';
|
||||
if (apiName && !apiName.includes('<anonymous>'))
|
||||
e.message = apiName + ': ' + e.message;
|
||||
if (apiZone.apiName && !apiZone.apiName.includes('<anonymous>'))
|
||||
e.message = apiZone.apiName + ': ' + e.message;
|
||||
const stackFrames = '\n' + stringifyStackFrames(stackTrace.frames).join('\n') + innerError;
|
||||
if (stackFrames.trim())
|
||||
e.stack = e.message + stackFrames;
|
||||
else
|
||||
e.stack = '';
|
||||
csi?.onApiCallEnd(callCookie, e);
|
||||
logApiCall(logger, `<= ${apiName} failed`, isInternal);
|
||||
if (!isInternal) {
|
||||
apiZone.error = e;
|
||||
logApiCall(logger, `<= ${apiZone.apiName} failed`);
|
||||
this._instrumentation.onApiCallEnd(apiZone);
|
||||
}
|
||||
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) {
|
||||
if (isNested)
|
||||
return;
|
||||
function logApiCall(logger: Logger | undefined, message: string) {
|
||||
if (logger && logger.isEnabled('api', 'info'))
|
||||
logger.log('api', 'info', message, [], { color: 'cyan' });
|
||||
debugLogger.log('api', message);
|
||||
|
|
@ -247,11 +235,12 @@ function tChannelImplToWire(names: '*' | string[], arg: any, path: string, conte
|
|||
}
|
||||
|
||||
type ApiZone = {
|
||||
apiName: string | undefined;
|
||||
apiName: string;
|
||||
params?: Record<string, any>;
|
||||
frames: channels.StackFrame[];
|
||||
isInternal: boolean;
|
||||
reported: boolean;
|
||||
csi: ClientInstrumentation | undefined;
|
||||
callCookie: any;
|
||||
userData: any;
|
||||
stepId?: string;
|
||||
error?: Error;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,12 +18,22 @@ import type { StackFrame } from '@protocol/channels';
|
|||
import type { BrowserContext } from './browserContext';
|
||||
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 {
|
||||
addListener(listener: ClientInstrumentationListener): void;
|
||||
removeListener(listener: ClientInstrumentationListener): void;
|
||||
removeAllListeners(): void;
|
||||
onApiCallBegin(apiCall: string, params: Record<string, any>, frames: StackFrame[], userData: any, out: { stepId?: string }): void;
|
||||
onApiCallEnd(userData: any, error?: Error): void;
|
||||
onApiCallBegin(apiCall: ApiCallData): void;
|
||||
onApiCallEnd(apiCal: ApiCallData): void;
|
||||
onWillPause(options: { keepTestTimeout: boolean }): void;
|
||||
|
||||
runAfterCreateBrowserContext(context: BrowserContext): Promise<void>;
|
||||
|
|
@ -33,8 +43,8 @@ export interface ClientInstrumentation {
|
|||
}
|
||||
|
||||
export interface ClientInstrumentationListener {
|
||||
onApiCallBegin?(apiName: string, params: Record<string, any>, frames: StackFrame[], userData: any, out: { stepId?: string }): void;
|
||||
onApiCallEnd?(userData: any, error?: Error): void;
|
||||
onApiCallBegin?(apiCall: ApiCallData): void;
|
||||
onApiCallEnd?(apiCall: ApiCallData): void;
|
||||
onWillPause?(options: { keepTestTimeout: boolean }): void;
|
||||
|
||||
runAfterCreateBrowserContext?(context: BrowserContext): Promise<void>;
|
||||
|
|
|
|||
|
|
@ -78,9 +78,9 @@ export class Connection extends EventEmitter {
|
|||
|
||||
constructor(localUtils: LocalUtils | undefined, instrumentation: ClientInstrumentation | undefined) {
|
||||
super();
|
||||
this._rootObject = new Root(this);
|
||||
this._localUtils = localUtils;
|
||||
this._instrumentation = instrumentation || createInstrumentation();
|
||||
this._localUtils = localUtils;
|
||||
this._rootObject = new Root(this);
|
||||
}
|
||||
|
||||
markAsRemote() {
|
||||
|
|
@ -138,7 +138,7 @@ export class Connection extends EventEmitter {
|
|||
this._localUtils?._channel.addStackToTracingNoReply({ callData: { stack: frames, id } }).catch(() => {});
|
||||
// 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.
|
||||
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 }));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -820,7 +820,7 @@ export class RouteHandler {
|
|||
this._times = times;
|
||||
this.url = url;
|
||||
this.handler = handler;
|
||||
this._svedZone = zones.currentZone();
|
||||
this._svedZone = zones.current().without('apiZone');
|
||||
}
|
||||
|
||||
static prepareInterceptionPatterns(handlers: RouteHandler[]) {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export class Waiter {
|
|||
constructor(channelOwner: ChannelOwner<channels.EventTargetChannel>, event: string) {
|
||||
this._waitId = createGuid();
|
||||
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._dispose = [
|
||||
|
|
|
|||
|
|
@ -20,4 +20,4 @@ export type Rect = Size & Point;
|
|||
export type Quad = [ Point, Point, Point, Point ];
|
||||
export type TimeoutOptions = { timeout?: number };
|
||||
export type NameValue = { name: string, value: string };
|
||||
export type HeadersArray = NameValue[];
|
||||
export type HeadersArray = NameValue[];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ export function createInProcessPlaywright(): PlaywrightAPI {
|
|||
playwrightAPI.firefox._serverLauncher = new BrowserServerLauncherImpl('firefox');
|
||||
playwrightAPI.webkit._serverLauncher = new BrowserServerLauncherImpl('webkit');
|
||||
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.
|
||||
dispatcherConnection.onmessage = message => setImmediate(() => clientConnection.dispatch(message));
|
||||
|
|
|
|||
|
|
@ -89,13 +89,16 @@ export const commandsWithTracingSnapshots = new Set([
|
|||
'Page.mouseClick',
|
||||
'Page.mouseWheel',
|
||||
'Page.touchscreenTap',
|
||||
'Page.accessibilitySnapshot',
|
||||
'Frame.evalOnSelector',
|
||||
'Frame.evalOnSelectorAll',
|
||||
'Frame.addScriptTag',
|
||||
'Frame.addStyleTag',
|
||||
'Frame.ariaSnapshot',
|
||||
'Frame.blur',
|
||||
'Frame.check',
|
||||
'Frame.click',
|
||||
'Frame.content',
|
||||
'Frame.dragAndDrop',
|
||||
'Frame.dblclick',
|
||||
'Frame.dispatchEvent',
|
||||
|
|
@ -116,6 +119,9 @@ export const commandsWithTracingSnapshots = new Set([
|
|||
'Frame.isVisible',
|
||||
'Frame.isEditable',
|
||||
'Frame.press',
|
||||
'Frame.querySelector',
|
||||
'Frame.querySelectorAll',
|
||||
'Frame.queryCount',
|
||||
'Frame.selectOption',
|
||||
'Frame.setContent',
|
||||
'Frame.setInputFiles',
|
||||
|
|
@ -133,8 +139,10 @@ export const commandsWithTracingSnapshots = new Set([
|
|||
'ElementHandle.evaluateExpressionHandle',
|
||||
'ElementHandle.evalOnSelector',
|
||||
'ElementHandle.evalOnSelectorAll',
|
||||
'ElementHandle.boundingBox',
|
||||
'ElementHandle.check',
|
||||
'ElementHandle.click',
|
||||
'ElementHandle.contentFrame',
|
||||
'ElementHandle.dblclick',
|
||||
'ElementHandle.dispatchEvent',
|
||||
'ElementHandle.fill',
|
||||
|
|
@ -150,6 +158,8 @@ export const commandsWithTracingSnapshots = new Set([
|
|||
'ElementHandle.isHidden',
|
||||
'ElementHandle.isVisible',
|
||||
'ElementHandle.press',
|
||||
'ElementHandle.querySelector',
|
||||
'ElementHandle.querySelectorAll',
|
||||
'ElementHandle.screenshot',
|
||||
'ElementHandle.scrollIntoViewIfNeeded',
|
||||
'ElementHandle.selectOption',
|
||||
|
|
@ -187,4 +197,4 @@ export const pausesBeforeInputActions = new Set([
|
|||
'ElementHandle.tap',
|
||||
'ElementHandle.type',
|
||||
'ElementHandle.uncheck'
|
||||
]);
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -2752,4 +2752,4 @@ scheme.JsonPipeSendParams = tObject({
|
|||
});
|
||||
scheme.JsonPipeSendResult = tOptional(tObject({}));
|
||||
scheme.JsonPipeCloseParams = tOptional(tObject({}));
|
||||
scheme.JsonPipeCloseResult = tOptional(tObject({}));
|
||||
scheme.JsonPipeCloseResult = tOptional(tObject({}));
|
||||
|
|
|
|||
|
|
@ -524,5 +524,3 @@ class ClankBrowserProcess implements BrowserProcess {
|
|||
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;
|
||||
page._session.addFrameBrowsingContext(event.context);
|
||||
page._page._frameManager.frameAttached(event.context, parentFrameId);
|
||||
const frame = page._page._frameManager.frame(event.context);
|
||||
if (frame)
|
||||
frame._url = event.url;
|
||||
return;
|
||||
}
|
||||
return;
|
||||
|
|
@ -164,6 +167,7 @@ export class BidiBrowser extends Browser {
|
|||
const session = this._connection.createMainFrameBrowsingContextSession(event.context);
|
||||
const opener = event.originalOpener && this._bidiPages.get(event.originalOpener);
|
||||
const page = new BidiPage(context, session, opener || null);
|
||||
page._page.mainFrame()._url = event.url;
|
||||
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>> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
const actions: bidi.Input.KeySourceAction[] = [];
|
||||
actions.push({ type: 'keyDown', value: getBidiKeyValue(key) });
|
||||
actions.push({ type: 'keyDown', value: getBidiKeyValue(code) });
|
||||
// TODO: add modifiers?
|
||||
await this._performActions(actions);
|
||||
}
|
||||
|
||||
async keyup(modifiers: Set<types.KeyboardModifier>, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number): Promise<void> {
|
||||
const actions: bidi.Input.KeySourceAction[] = [];
|
||||
actions.push({ type: 'keyUp', value: getBidiKeyValue(key) });
|
||||
actions.push({ type: 'keyUp', value: getBidiKeyValue(code) });
|
||||
await this._performActions(actions);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,9 +67,13 @@ export class BidiNetworkManager {
|
|||
if (param.intercepts) {
|
||||
// We do not support intercepting redirects.
|
||||
if (redirectedFrom) {
|
||||
let params = {};
|
||||
if (redirectedFrom._originalRequestRoute?._alreadyContinuedHeaders)
|
||||
params = toBidiRequestHeaders(redirectedFrom._originalRequestRoute._alreadyContinuedHeaders ?? []);
|
||||
|
||||
this._session.sendMayFail('network.continueRequest', {
|
||||
request: param.request.request,
|
||||
...(redirectedFrom._originalRequestRoute?._alreadyContinuedHeaders || {}),
|
||||
...params,
|
||||
});
|
||||
} else {
|
||||
route = new BidiRouteImpl(this._session, param.request.request);
|
||||
|
|
@ -302,11 +306,9 @@ function fromBidiHeaders(bidiHeaders: bidi.Network.Header[]): types.HeadersArray
|
|||
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 cookies = bidiHeaders.filter(h => h.name.toLowerCase() === 'cookie');
|
||||
const headers = bidiHeaders.filter(h => h.name.toLowerCase() !== 'cookie');
|
||||
return { cookies, headers };
|
||||
return { headers: bidiHeaders };
|
||||
}
|
||||
|
||||
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,
|
||||
format: {
|
||||
type: `image/${format === 'png' ? 'png' : 'jpeg'}`,
|
||||
quality: quality || 80,
|
||||
quality: quality ? quality / 100 : 0.8,
|
||||
},
|
||||
origin: documentRect ? 'document' : 'viewport',
|
||||
clip: {
|
||||
|
|
|
|||
|
|
@ -7,18 +7,18 @@
|
|||
|
||||
/* eslint-disable curly */
|
||||
|
||||
export const getBidiKeyValue = (key: string) => {
|
||||
switch (key) {
|
||||
export const getBidiKeyValue = (code: string) => {
|
||||
switch (code) {
|
||||
case '\r':
|
||||
case '\n':
|
||||
key = 'Enter';
|
||||
code = 'Enter';
|
||||
break;
|
||||
}
|
||||
// Measures the number of code points rather than UTF-16 code units.
|
||||
if ([...key].length === 1) {
|
||||
return key;
|
||||
if ([...code].length === 1) {
|
||||
return code;
|
||||
}
|
||||
switch (key) {
|
||||
switch (code) {
|
||||
case 'Cancel':
|
||||
return '\uE001';
|
||||
case 'Help':
|
||||
|
|
@ -131,6 +131,8 @@ export const getBidiKeyValue = (key: string) => {
|
|||
return '\uE052';
|
||||
case 'MetaRight':
|
||||
return '\uE053';
|
||||
case 'Space':
|
||||
return ' ';
|
||||
case 'Digit0':
|
||||
return '0';
|
||||
case 'Digit1':
|
||||
|
|
@ -226,6 +228,6 @@ export const getBidiKeyValue = (key: string) => {
|
|||
case 'Quote':
|
||||
return '"';
|
||||
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);
|
||||
}
|
||||
|
||||
hasBinding(name: string) {
|
||||
return this._pageBindings.has(name);
|
||||
}
|
||||
|
||||
async exposeBinding(name: string, needsHandle: boolean, playwrightBinding: frames.FunctionWithSource): Promise<void> {
|
||||
if (this._pageBindings.has(name))
|
||||
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 || '' };
|
||||
}
|
||||
|
||||
async addInitScript(source: string) {
|
||||
const initScript = new InitScript(source);
|
||||
async addInitScript(source: string, name?: string) {
|
||||
const initScript = new InitScript(source, false /* internal */, name);
|
||||
this.initScripts.push(initScript);
|
||||
await this.doAddInitScript(initScript);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,10 +24,9 @@ import type { Playwright } from './playwright';
|
|||
import { Recorder } from './recorder';
|
||||
import { EmptyRecorderApp } from './recorder/recorderApp';
|
||||
import { asLocator, type Language } from '../utils';
|
||||
import { parseYamlForAriaSnapshot } from './ariaSnapshot';
|
||||
import type { ParsedYaml } from '../utils/isomorphic/ariaSnapshot';
|
||||
import { parseYamlTemplate } from '../utils/isomorphic/ariaSnapshot';
|
||||
import { yaml } from '../utilsBundle';
|
||||
import { unsafeLocatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser';
|
||||
import { parseAriaSnapshotUnsafe } from '../utils/isomorphic/ariaSnapshot';
|
||||
|
||||
const internalMetadata = serverSideCallMetadata();
|
||||
|
||||
|
|
@ -40,9 +39,6 @@ export class DebugController extends SdkObject {
|
|||
SetModeRequested: 'setModeRequested',
|
||||
};
|
||||
|
||||
private _autoCloseTimer: NodeJS.Timeout | undefined;
|
||||
// TODO: remove in 1.27
|
||||
private _autoCloseAllowed = false;
|
||||
private _trackHierarchyListener: InstrumentationListener | undefined;
|
||||
private _playwright: Playwright;
|
||||
_sdkLanguage: Language = 'javascript';
|
||||
|
|
@ -58,22 +54,18 @@ export class DebugController extends SdkObject {
|
|||
this._sdkLanguage = sdkLanguage;
|
||||
}
|
||||
|
||||
setAutoCloseAllowed(allowed: boolean) {
|
||||
this._autoCloseAllowed = allowed;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.setReportStateChanged(false);
|
||||
this.setAutoCloseAllowed(false);
|
||||
}
|
||||
|
||||
setReportStateChanged(enabled: boolean) {
|
||||
if (enabled && !this._trackHierarchyListener) {
|
||||
this._trackHierarchyListener = {
|
||||
onPageOpen: () => this._emitSnapshot(),
|
||||
onPageClose: () => this._emitSnapshot(),
|
||||
onPageOpen: () => this._emitSnapshot(false),
|
||||
onPageClose: () => this._emitSnapshot(false),
|
||||
};
|
||||
this._playwright.instrumentation.addListener(this._trackHierarchyListener, null);
|
||||
this._emitSnapshot(true);
|
||||
} else if (!enabled && this._trackHierarchyListener) {
|
||||
this._playwright.instrumentation.removeListener(this._trackHierarchyListener);
|
||||
this._trackHierarchyListener = undefined;
|
||||
|
|
@ -102,7 +94,6 @@ export class DebugController extends SdkObject {
|
|||
recorder.hideHighlightedSelector();
|
||||
recorder.setMode('none');
|
||||
}
|
||||
this.setAutoCloseEnabled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -127,37 +118,16 @@ export class DebugController extends SdkObject {
|
|||
recorder.setOutput(this._codegenId, params.file);
|
||||
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 }) {
|
||||
// Assert parameters validity.
|
||||
if (params.selector)
|
||||
unsafeLocatorOrSelectorAsSelector(this._sdkLanguage, params.selector, 'data-testid');
|
||||
let parsedYaml: ParsedYaml | undefined;
|
||||
if (params.ariaTemplate) {
|
||||
parsedYaml = parseYamlForAriaSnapshot(params.ariaTemplate);
|
||||
parseYamlTemplate(parsedYaml);
|
||||
}
|
||||
const ariaTemplate = params.ariaTemplate ? parseAriaSnapshotUnsafe(yaml, params.ariaTemplate) : undefined;
|
||||
for (const recorder of await this._allRecorders()) {
|
||||
if (parsedYaml)
|
||||
recorder.setHighlightedAriaTemplate(parsedYaml);
|
||||
if (ariaTemplate)
|
||||
recorder.setHighlightedAriaTemplate(ariaTemplate);
|
||||
else if (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' })));
|
||||
}
|
||||
|
||||
private _emitSnapshot() {
|
||||
const browsers = [];
|
||||
let pageCount = 0;
|
||||
for (const browser of this._playwright.allBrowsers()) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
private _emitSnapshot(initial: boolean) {
|
||||
const pageCount = this._playwright.allPages().length;
|
||||
if (initial && !pageCount)
|
||||
return;
|
||||
this.emit(DebugController.Events.StateChanged, { pageCount });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ function shouldPauseBeforeStep(metadata: CallMetadata): boolean {
|
|||
// Always stop on 'close'
|
||||
if (metadata.method === 'close')
|
||||
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.
|
||||
const step = metadata.type + '.' + metadata.method;
|
||||
// Stop before everything that generates snapshot. But don't stop before those marked as pausesBeforeInputActions
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@
|
|||
"defaultBrowserType": "webkit"
|
||||
},
|
||||
"Galaxy S5": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 360,
|
||||
"height": 640
|
||||
|
|
@ -121,7 +121,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Galaxy S5 landscape": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 640,
|
||||
"height": 360
|
||||
|
|
@ -132,7 +132,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Galaxy S8": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 360,
|
||||
"height": 740
|
||||
|
|
@ -143,7 +143,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Galaxy S8 landscape": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 740,
|
||||
"height": 360
|
||||
|
|
@ -154,7 +154,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Galaxy S9+": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 320,
|
||||
"height": 658
|
||||
|
|
@ -165,7 +165,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Galaxy S9+ landscape": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 658,
|
||||
"height": 320
|
||||
|
|
@ -176,7 +176,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Galaxy Tab S4": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 712,
|
||||
"height": 1138
|
||||
|
|
@ -187,7 +187,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Galaxy Tab S4 landscape": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 1138,
|
||||
"height": 712
|
||||
|
|
@ -1098,7 +1098,7 @@
|
|||
"defaultBrowserType": "webkit"
|
||||
},
|
||||
"LG Optimus L70": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/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": {
|
||||
"width": 384,
|
||||
"height": 640
|
||||
|
|
@ -1109,7 +1109,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"LG Optimus L70 landscape": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/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": {
|
||||
"width": 640,
|
||||
"height": 384
|
||||
|
|
@ -1120,7 +1120,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Microsoft Lumia 550": {
|
||||
"userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 640,
|
||||
"height": 360
|
||||
|
|
@ -1131,7 +1131,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Microsoft Lumia 550 landscape": {
|
||||
"userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 360,
|
||||
"height": 640
|
||||
|
|
@ -1142,7 +1142,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Microsoft Lumia 950": {
|
||||
"userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 360,
|
||||
"height": 640
|
||||
|
|
@ -1153,7 +1153,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Microsoft Lumia 950 landscape": {
|
||||
"userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 640,
|
||||
"height": 360
|
||||
|
|
@ -1164,7 +1164,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Nexus 10": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 800,
|
||||
"height": 1280
|
||||
|
|
@ -1175,7 +1175,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Nexus 10 landscape": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 1280,
|
||||
"height": 800
|
||||
|
|
@ -1186,7 +1186,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Nexus 4": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 384,
|
||||
"height": 640
|
||||
|
|
@ -1197,7 +1197,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Nexus 4 landscape": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 640,
|
||||
"height": 384
|
||||
|
|
@ -1208,7 +1208,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Nexus 5": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 360,
|
||||
"height": 640
|
||||
|
|
@ -1219,7 +1219,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Nexus 5 landscape": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 640,
|
||||
"height": 360
|
||||
|
|
@ -1230,7 +1230,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Nexus 5X": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 412,
|
||||
"height": 732
|
||||
|
|
@ -1241,7 +1241,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Nexus 5X landscape": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 732,
|
||||
"height": 412
|
||||
|
|
@ -1252,7 +1252,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Nexus 6": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 412,
|
||||
"height": 732
|
||||
|
|
@ -1263,7 +1263,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Nexus 6 landscape": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 732,
|
||||
"height": 412
|
||||
|
|
@ -1274,7 +1274,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Nexus 6P": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 412,
|
||||
"height": 732
|
||||
|
|
@ -1285,7 +1285,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Nexus 6P landscape": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 732,
|
||||
"height": 412
|
||||
|
|
@ -1296,7 +1296,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Nexus 7": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 600,
|
||||
"height": 960
|
||||
|
|
@ -1307,7 +1307,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Nexus 7 landscape": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 960,
|
||||
"height": 600
|
||||
|
|
@ -1362,7 +1362,7 @@
|
|||
"defaultBrowserType": "webkit"
|
||||
},
|
||||
"Pixel 2": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 411,
|
||||
"height": 731
|
||||
|
|
@ -1373,7 +1373,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Pixel 2 landscape": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 731,
|
||||
"height": 411
|
||||
|
|
@ -1384,7 +1384,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Pixel 2 XL": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 411,
|
||||
"height": 823
|
||||
|
|
@ -1395,7 +1395,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Pixel 2 XL landscape": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 823,
|
||||
"height": 411
|
||||
|
|
@ -1406,7 +1406,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Pixel 3": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 393,
|
||||
"height": 786
|
||||
|
|
@ -1417,7 +1417,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Pixel 3 landscape": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 786,
|
||||
"height": 393
|
||||
|
|
@ -1428,7 +1428,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Pixel 4": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 353,
|
||||
"height": 745
|
||||
|
|
@ -1439,7 +1439,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Pixel 4 landscape": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 745,
|
||||
"height": 353
|
||||
|
|
@ -1450,7 +1450,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Pixel 4a (5G)": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 412,
|
||||
"height": 892
|
||||
|
|
@ -1465,7 +1465,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Pixel 4a (5G) landscape": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"height": 892,
|
||||
"width": 412
|
||||
|
|
@ -1480,7 +1480,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Pixel 5": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 393,
|
||||
"height": 851
|
||||
|
|
@ -1495,7 +1495,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Pixel 5 landscape": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 851,
|
||||
"height": 393
|
||||
|
|
@ -1510,7 +1510,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Pixel 7": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 412,
|
||||
"height": 915
|
||||
|
|
@ -1525,7 +1525,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Pixel 7 landscape": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 915,
|
||||
"height": 412
|
||||
|
|
@ -1540,7 +1540,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Moto G4": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 360,
|
||||
"height": 640
|
||||
|
|
@ -1551,7 +1551,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Moto G4 landscape": {
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 640,
|
||||
"height": 360
|
||||
|
|
@ -1562,7 +1562,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Desktop Chrome HiDPI": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 1792,
|
||||
"height": 1120
|
||||
|
|
@ -1577,7 +1577,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Desktop Edge HiDPI": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 1792,
|
||||
"height": 1120
|
||||
|
|
@ -1592,7 +1592,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 1792,
|
||||
"height": 1120
|
||||
|
|
@ -1622,7 +1622,7 @@
|
|||
"defaultBrowserType": "webkit"
|
||||
},
|
||||
"Desktop Chrome": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
|
|
@ -1637,7 +1637,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"Desktop Edge": {
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/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": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
|
|
@ -1652,7 +1652,7 @@
|
|||
"defaultBrowserType": "chromium"
|
||||
},
|
||||
"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": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
|
|
|
|||
|
|
@ -3,5 +3,7 @@
|
|||
../../generated/
|
||||
../../protocol/
|
||||
../../utils/
|
||||
../../utils/isomorphic
|
||||
../../utilsBundle.ts
|
||||
../../zipBundle.ts
|
||||
../**
|
||||
|
|
|
|||
|
|
@ -288,7 +288,7 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
|
|||
async setWebSocketInterceptionPatterns(params: channels.PageSetWebSocketInterceptionPatternsParams, metadata: CallMetadata): Promise<void> {
|
||||
this._webSocketInterceptionPatterns = params.patterns;
|
||||
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> {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ import type { CallMetadata } from '../instrumentation';
|
|||
import type { BrowserContextDispatcher } from './browserContextDispatcher';
|
||||
import type { PageDispatcher } from './pageDispatcher';
|
||||
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 {
|
||||
_type_Frame = true;
|
||||
|
|
@ -261,7 +262,7 @@ export class FrameDispatcher extends Dispatcher<Frame, channels.FrameChannel, Br
|
|||
metadata.potentiallyClosesScope = true;
|
||||
let expectedValue = params.expectedValue ? parseArgument(params.expectedValue) : undefined;
|
||||
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 });
|
||||
if (result.received !== undefined)
|
||||
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> {
|
||||
this._webSocketInterceptionPatterns = params.patterns;
|
||||
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> {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import type { BrowserContext } from '../browserContext';
|
|||
import type { Frame } from '../frames';
|
||||
import { Page } from '../page';
|
||||
import type * as channels from '@protocol/channels';
|
||||
import { Dispatcher } from './dispatcher';
|
||||
import { Dispatcher, existingDispatcher } from './dispatcher';
|
||||
import { createGuid, urlMatches } from '../../utils';
|
||||
import { PageDispatcher } from './pageDispatcher';
|
||||
import type { BrowserContextDispatcher } from './browserContextDispatcher';
|
||||
|
|
@ -26,9 +26,6 @@ import * as webSocketMockSource from '../../generated/webSocketMockSource';
|
|||
import type * as ws from '../injected/webSocketMock';
|
||||
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 {
|
||||
_type_WebSocketRoute = true;
|
||||
private _id: string;
|
||||
|
|
@ -57,18 +54,18 @@ export class WebSocketRouteDispatcher extends Dispatcher<{ guid: string }, chann
|
|||
(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;
|
||||
if (!(context as any)[kBindingInstalledSymbol]) {
|
||||
(context as any)[kBindingInstalledSymbol] = true;
|
||||
|
||||
await context.exposeBinding('__pwWebSocketBinding', false, (source, payload: ws.BindingPayload) => {
|
||||
if (!context.hasBinding(kBindingName)) {
|
||||
await context.exposeBinding(kBindingName, false, (source, payload: ws.BindingPayload) => {
|
||||
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;
|
||||
if (pageDispatcher && matchesPattern(pageDispatcher, context._options.baseURL, payload.url))
|
||||
scope = pageDispatcher;
|
||||
else if (matchesPattern(contextDispatcher, context._options.baseURL, payload.url))
|
||||
else if (contextDispatcher && matchesPattern(contextDispatcher, context._options.baseURL, payload.url))
|
||||
scope = contextDispatcher;
|
||||
if (scope) {
|
||||
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]) {
|
||||
(target as any)[kInitScriptInstalledSymbol] = true;
|
||||
const kInitScriptName = 'webSocketMockSource';
|
||||
if (!target.initScripts.find(s => s.name === kInitScriptName)) {
|
||||
await target.addInitScript(`
|
||||
(() => {
|
||||
const module = {};
|
||||
${webSocketMockSource.source}
|
||||
(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'> {
|
||||
const isChecked = async () => {
|
||||
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);
|
||||
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 {
|
||||
if (result === 'error:notconnected')
|
||||
throw new Error('Element is not attached to the DOM');
|
||||
throwElementIsNotAttached();
|
||||
return result;
|
||||
}
|
||||
|
||||
export function throwElementIsNotAttached(): never {
|
||||
throw new Error('Element is not attached to the DOM');
|
||||
}
|
||||
|
||||
export function assertDone(result: 'done'): void {
|
||||
// This function converts 'done' to void and ensures typescript catches unhandled errors.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,4 +77,4 @@ export async function prepareFilesForUpload(frame: Frame, params: channels.Eleme
|
|||
}));
|
||||
|
||||
return { localPaths, localDirectory, filePayloads };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -436,4 +436,3 @@ function toJugglerProxyOptions(proxy: types.ProxySettings) {
|
|||
// Prefs for quick fixes that didn't make it to the build.
|
||||
// Should all be moved to `playwright.cfg`.
|
||||
const kBandaidFirefoxUserPrefs = {};
|
||||
|
||||
|
|
|
|||
|
|
@ -101,4 +101,3 @@ class JugglerReadyState extends BrowserReadyState {
|
|||
this._wsEndpoint.resolve(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -518,6 +518,10 @@ export module Protocol {
|
|||
}|null;
|
||||
};
|
||||
export type setViewportSizeReturnValue = void;
|
||||
export type setZoomParameters = {
|
||||
zoom: number;
|
||||
};
|
||||
export type setZoomReturnValue = void;
|
||||
export type bringToFrontParameters = {
|
||||
};
|
||||
export type bringToFrontReturnValue = void;
|
||||
|
|
@ -1134,6 +1138,7 @@ export module Protocol {
|
|||
"Page.setFileInputFiles": Page.setFileInputFilesParameters;
|
||||
"Page.addBinding": Page.addBindingParameters;
|
||||
"Page.setViewportSize": Page.setViewportSizeParameters;
|
||||
"Page.setZoom": Page.setZoomParameters;
|
||||
"Page.bringToFront": Page.bringToFrontParameters;
|
||||
"Page.setEmulatedMedia": Page.setEmulatedMediaParameters;
|
||||
"Page.setCacheDisabled": Page.setCacheDisabledParameters;
|
||||
|
|
@ -1215,6 +1220,7 @@ export module Protocol {
|
|||
"Page.setFileInputFiles": Page.setFileInputFilesReturnValue;
|
||||
"Page.addBinding": Page.addBindingReturnValue;
|
||||
"Page.setViewportSize": Page.setViewportSizeReturnValue;
|
||||
"Page.setZoom": Page.setZoomReturnValue;
|
||||
"Page.bringToFront": Page.bringToFrontReturnValue;
|
||||
"Page.setEmulatedMedia": Page.setEmulatedMediaReturnValue;
|
||||
"Page.setCacheDisabled": Page.setCacheDisabledReturnValue;
|
||||
|
|
|
|||
|
|
@ -1301,7 +1301,9 @@ export class Frame extends SdkObject {
|
|||
const result = await this._callOnElementOnceMatches(metadata, selector, (injected, element, data) => {
|
||||
return injected.elementState(element, data.state);
|
||||
}, { 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> {
|
||||
|
|
@ -1319,8 +1321,8 @@ export class Frame extends SdkObject {
|
|||
return false;
|
||||
return await resolved.injected.evaluate((injected, { info, root }) => {
|
||||
const element = injected.querySelector(info.parsed, root || document, info.strict);
|
||||
const state = element ? injected.elementState(element, 'visible') : false;
|
||||
return state === 'error:notconnected' ? false : state;
|
||||
const state = element ? injected.elementState(element, 'visible') : { matches: false, received: 'error:notconnected' };
|
||||
return state.matches;
|
||||
}, { info: resolved.info, root: resolved.frame === this ? scope : undefined });
|
||||
} catch (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 {
|
||||
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')
|
||||
return received ? received.raw : received;
|
||||
return received;
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@
|
|||
|
||||
import * as roleUtils from './roleUtils';
|
||||
import { getElementComputedStyle } from './domUtils';
|
||||
import { escapeRegExp, longestCommonSubstring } from '@isomorphic/stringUtils';
|
||||
import { escapeRegExp, longestCommonSubstring, normalizeWhiteSpace } from '@isomorphic/stringUtils';
|
||||
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 & {
|
||||
role: AriaRole | 'fragment';
|
||||
|
|
@ -137,7 +137,7 @@ function toAriaNode(element: Element): AriaNode | null {
|
|||
if (!role || role === 'presentation' || role === 'none')
|
||||
return null;
|
||||
|
||||
const name = roleUtils.getElementAccessibleName(element, false) || '';
|
||||
const name = normalizeWhiteSpace(roleUtils.getElementAccessibleName(element, false) || '');
|
||||
const result: AriaNode = { role, name, children: [], element };
|
||||
|
||||
if (roleUtils.kAriaCheckedRoles.includes(role))
|
||||
|
|
@ -170,7 +170,7 @@ function normalizeStringChildren(rootA11yNode: AriaNode) {
|
|||
const flushChildren = (buffer: string[], normalizedChildren: (AriaNode | string)[]) => {
|
||||
if (!buffer.length)
|
||||
return;
|
||||
const text = normalizeWhitespaceWithin(buffer.join('')).trim();
|
||||
const text = normalizeWhiteSpace(buffer.join(''));
|
||||
if (text)
|
||||
normalizedChildren.push(text);
|
||||
buffer.length = 0;
|
||||
|
|
@ -196,16 +196,14 @@ function normalizeStringChildren(rootA11yNode: AriaNode) {
|
|||
visit(rootA11yNode);
|
||||
}
|
||||
|
||||
const normalizeWhitespaceWithin = (text: string) => text.replace(/[\u200b\s\t\r\n]+/g, ' ');
|
||||
|
||||
function matchesText(text: string, template: RegExp | string | undefined): boolean {
|
||||
function matchesText(text: string, template: AriaRegex | string | undefined): boolean {
|
||||
if (!template)
|
||||
return true;
|
||||
if (!text)
|
||||
return false;
|
||||
if (typeof template === 'string')
|
||||
return text === template;
|
||||
return !!text.match(template);
|
||||
return !!text.match(new RegExp(template.pattern));
|
||||
}
|
||||
|
||||
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')
|
||||
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)
|
||||
return false;
|
||||
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[] {
|
||||
const results: AriaNode[] = [];
|
||||
const visit = (node: AriaNode | string): boolean => {
|
||||
const visit = (node: AriaNode | string, parent: AriaNode | null): boolean => {
|
||||
if (matchesNode(node, template, 0)) {
|
||||
results.push(node as AriaNode);
|
||||
const result = typeof node === 'string' ? parent : node;
|
||||
if (result)
|
||||
results.push(result);
|
||||
return !collectAll;
|
||||
}
|
||||
if (typeof node === 'string')
|
||||
return false;
|
||||
for (const child of node.children || []) {
|
||||
if (visit(child))
|
||||
if (visit(child, node))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
visit(root);
|
||||
visit(root, null);
|
||||
return results;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import type { CSSComplexSelectorList } from '../../utils/isomorphic/cssParser';
|
|||
import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator';
|
||||
import type * as channels from '@protocol/channels';
|
||||
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 { asLocator } 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 type { AriaNode, AriaSnapshot } from './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 ElementStateWithoutStable = 'visible' | 'hidden' | 'enabled' | 'disabled' | 'editable' | 'checked' | 'unchecked';
|
||||
export type ElementState = ElementStateWithoutStable | 'stable';
|
||||
export type ElementState = 'visible' | 'hidden' | 'enabled' | 'disabled' | 'editable' | 'checked' | 'unchecked' | 'indeterminate' | 'stable';
|
||||
export type ElementStateWithoutStable = Exclude<ElementState, 'stable'>;
|
||||
export type ElementStateQueryResult = { matches: boolean, received?: string | 'error:notconnected' };
|
||||
|
||||
export type HitTargetInterceptionResult = {
|
||||
stop: () => 'done' | { hitTargetDescription: string };
|
||||
|
|
@ -85,7 +86,7 @@ export class InjectedScript {
|
|||
isElementVisible,
|
||||
isInsideScope,
|
||||
normalizeWhiteSpace,
|
||||
parseYamlTemplate,
|
||||
parseAriaSnapshot,
|
||||
};
|
||||
|
||||
// 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]') &&
|
||||
!(element as any).isContentEditable) {
|
||||
// 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;
|
||||
}
|
||||
|
|
@ -545,15 +546,15 @@ export class InjectedScript {
|
|||
if (stableResult === false)
|
||||
return { missingState: 'stable' };
|
||||
if (stableResult === 'error:notconnected')
|
||||
return stableResult;
|
||||
return 'error:notconnected';
|
||||
}
|
||||
for (const state of states) {
|
||||
if (state !== 'stable') {
|
||||
const result = this.elementState(node, state);
|
||||
if (result === false)
|
||||
if (result.received === 'error:notconnected')
|
||||
return 'error:notconnected';
|
||||
if (!result.matches)
|
||||
return { missingState: state };
|
||||
if (result === 'error:notconnected')
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -608,38 +609,60 @@ export class InjectedScript {
|
|||
return result;
|
||||
}
|
||||
|
||||
elementState(node: Node, state: ElementStateWithoutStable): boolean | 'error:notconnected' {
|
||||
const element = this.retarget(node, ['stable', 'visible', 'hidden'].includes(state) ? 'none' : 'follow-label');
|
||||
elementState(node: Node, state: ElementStateWithoutStable): ElementStateQueryResult {
|
||||
const element = this.retarget(node, ['visible', 'hidden'].includes(state) ? 'none' : 'follow-label');
|
||||
if (!element || !element.isConnected) {
|
||||
if (state === 'hidden')
|
||||
return true;
|
||||
return 'error:notconnected';
|
||||
return { matches: true, received: 'hidden' };
|
||||
return { matches: false, received: 'error:notconnected' };
|
||||
}
|
||||
|
||||
if (state === 'visible')
|
||||
return isElementVisible(element);
|
||||
if (state === 'hidden')
|
||||
return !isElementVisible(element);
|
||||
if (state === 'visible' || state === 'hidden') {
|
||||
const visible = isElementVisible(element);
|
||||
return {
|
||||
matches: state === 'visible' ? visible : !visible,
|
||||
received: visible ? 'visible' : 'hidden'
|
||||
};
|
||||
}
|
||||
|
||||
const disabled = getAriaDisabled(element);
|
||||
if (state === 'disabled')
|
||||
return disabled;
|
||||
if (state === 'enabled')
|
||||
return !disabled;
|
||||
if (state === 'disabled' || state === 'enabled') {
|
||||
const disabled = getAriaDisabled(element);
|
||||
return {
|
||||
matches: state === 'disabled' ? disabled : !disabled,
|
||||
received: disabled ? 'disabled' : 'enabled'
|
||||
};
|
||||
}
|
||||
|
||||
if (state === 'editable') {
|
||||
const disabled = getAriaDisabled(element);
|
||||
const readonly = getReadonly(element);
|
||||
if (readonly === 'error')
|
||||
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') {
|
||||
const need = state === 'checked';
|
||||
const checked = getChecked(element, false);
|
||||
const checked = getCheckedWithoutMixed(element);
|
||||
if (checked === 'error')
|
||||
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}"`);
|
||||
}
|
||||
|
|
@ -996,13 +1019,46 @@ export class InjectedScript {
|
|||
return { stop };
|
||||
}
|
||||
|
||||
dispatchEvent(node: Node, type: string, eventInit: Object) {
|
||||
dispatchEvent(node: Node, type: string, eventInitObj: Object) {
|
||||
let event;
|
||||
eventInit = { bubbles: true, cancelable: true, composed: true, ...eventInit };
|
||||
const eventInit: any = { bubbles: true, cancelable: true, composed: true, ...eventInitObj };
|
||||
switch (eventType.get(type)) {
|
||||
case 'mouse': event = new MouseEvent(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 'focus': event = new FocusEvent(type, eventInit); break;
|
||||
case 'drag': event = new DragEvent(type, eventInit); break;
|
||||
|
|
@ -1213,44 +1269,65 @@ export class InjectedScript {
|
|||
|
||||
{
|
||||
// Element state / boolean values.
|
||||
let elementState: boolean | 'error:notconnected' | 'error:notcheckbox' | undefined;
|
||||
let result: ElementStateQueryResult | undefined;
|
||||
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') {
|
||||
elementState = this.elementState(element, 'checked');
|
||||
} else if (expression === 'to.be.unchecked') {
|
||||
elementState = this.elementState(element, 'unchecked');
|
||||
const { checked, indeterminate } = options.expectedValue;
|
||||
if (indeterminate) {
|
||||
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') {
|
||||
elementState = this.elementState(element, 'disabled');
|
||||
result = this.elementState(element, 'disabled');
|
||||
} else if (expression === 'to.be.editable') {
|
||||
elementState = this.elementState(element, 'editable');
|
||||
result = this.elementState(element, 'editable');
|
||||
} 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') {
|
||||
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA')
|
||||
elementState = !(element as HTMLInputElement).value;
|
||||
else
|
||||
elementState = !element.textContent?.trim();
|
||||
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {
|
||||
const value = (element as HTMLInputElement).value;
|
||||
result = { matches: !value, received: value ? 'notEmpty' : 'empty' };
|
||||
} else {
|
||||
const text = element.textContent?.trim();
|
||||
result = { matches: !text, received: text ? 'notEmpty' : 'empty' };
|
||||
}
|
||||
} else if (expression === 'to.be.enabled') {
|
||||
elementState = this.elementState(element, 'enabled');
|
||||
result = this.elementState(element, 'enabled');
|
||||
} 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') {
|
||||
elementState = this.elementState(element, 'hidden');
|
||||
result = this.elementState(element, 'hidden');
|
||||
} else if (expression === 'to.be.visible') {
|
||||
elementState = this.elementState(element, 'visible');
|
||||
result = this.elementState(element, 'visible');
|
||||
} else if (expression === 'to.be.attached') {
|
||||
elementState = true;
|
||||
result = {
|
||||
matches: true,
|
||||
received: 'attached',
|
||||
};
|
||||
} else if (expression === 'to.be.detached') {
|
||||
elementState = false;
|
||||
result = {
|
||||
matches: false,
|
||||
received: 'attached',
|
||||
};
|
||||
}
|
||||
|
||||
if (elementState !== undefined) {
|
||||
if (elementState === 'error:notcheckbox')
|
||||
throw this.createStacklessError('Element is not a checkbox');
|
||||
if (elementState === 'error:notconnected')
|
||||
if (result) {
|
||||
if (result.received === 'error:notconnected')
|
||||
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 */);
|
||||
} else if (expression === 'to.have.accessible.description') {
|
||||
received = getElementAccessibleDescription(element, false /* includeHidden */);
|
||||
} else if (expression === 'to.have.accessible.error.message') {
|
||||
received = getElementAccessibleErrorMessage(element);
|
||||
} else if (expression === 'to.have.role') {
|
||||
received = getAriaRole(element) || '';
|
||||
} else if (expression === 'to.have.title') {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -52,7 +52,7 @@ export class PollingRecorder implements RecorderDelegate {
|
|||
const pollPeriod = 1000;
|
||||
if (this._pollRecorderModeTimer)
|
||||
clearTimeout(this._pollRecorderModeTimer);
|
||||
const state = await this._embedder.__pw_recorderState().catch(() => {});
|
||||
const state = await this._embedder.__pw_recorderState().catch(() => null);
|
||||
if (!state) {
|
||||
this._pollRecorderModeTimer = this._recorder.injectedScript.builtinSetTimeout(() => this._pollRecorderMode(), pollPeriod);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1146,8 +1146,7 @@ export class Recorder {
|
|||
const ariaTemplateJSON = JSON.stringify(state.ariaTemplate);
|
||||
if (this._lastHighlightedAriaTemplateJSON !== ariaTemplateJSON) {
|
||||
this._lastHighlightedAriaTemplateJSON = ariaTemplateJSON;
|
||||
const template = state.ariaTemplate ? this.injectedScript.utils.parseYamlTemplate(state.ariaTemplate) : undefined;
|
||||
const elements = template ? this.injectedScript.getAllByAria(this.document, template) : [];
|
||||
const elements = state.ariaTemplate ? this.injectedScript.getAllByAria(this.document, state.ariaTemplate) : [];
|
||||
if (elements.length)
|
||||
highlight = { elements };
|
||||
else
|
||||
|
|
|
|||
|
|
@ -461,6 +461,59 @@ export function getElementAccessibleDescription(element: Element, includeHidden:
|
|||
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 = {
|
||||
visitedElements: Set<Element>,
|
||||
includeHidden?: boolean,
|
||||
|
|
@ -841,7 +894,17 @@ export function getAriaChecked(element: Element): boolean | 'mixed' {
|
|||
const result = getChecked(element, true);
|
||||
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);
|
||||
// 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
|
||||
|
|
@ -972,6 +1035,7 @@ let cacheAccessibleName: Map<Element, string> | undefined;
|
|||
let cacheAccessibleNameHidden: Map<Element, string> | undefined;
|
||||
let cacheAccessibleDescription: Map<Element, string> | undefined;
|
||||
let cacheAccessibleDescriptionHidden: Map<Element, string> | undefined;
|
||||
let cacheAccessibleErrorMessage: Map<Element, string> | undefined;
|
||||
let cacheIsHidden: Map<Element, boolean> | undefined;
|
||||
let cachePseudoContentBefore: Map<Element, string> | undefined;
|
||||
let cachePseudoContentAfter: Map<Element, string> | undefined;
|
||||
|
|
@ -983,6 +1047,7 @@ export function beginAriaCaches() {
|
|||
cacheAccessibleNameHidden ??= new Map();
|
||||
cacheAccessibleDescription ??= new Map();
|
||||
cacheAccessibleDescriptionHidden ??= new Map();
|
||||
cacheAccessibleErrorMessage ??= new Map();
|
||||
cacheIsHidden ??= new Map();
|
||||
cachePseudoContentBefore ??= new Map();
|
||||
cachePseudoContentAfter ??= new Map();
|
||||
|
|
@ -994,6 +1059,7 @@ export function endAriaCaches() {
|
|||
cacheAccessibleNameHidden = undefined;
|
||||
cacheAccessibleDescription = undefined;
|
||||
cacheAccessibleDescriptionHidden = undefined;
|
||||
cacheAccessibleErrorMessage = undefined;
|
||||
cacheIsHidden = undefined;
|
||||
cachePseudoContentBefore = undefined;
|
||||
cachePseudoContentAfter = undefined;
|
||||
|
|
|
|||
|
|
@ -58,8 +58,8 @@ function yamlStringNeedsQuotes(str: string): boolean {
|
|||
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(str))
|
||||
return true;
|
||||
|
||||
// Strings starting with '-' followed by a space need quotes
|
||||
if (/^-\s/.test(str))
|
||||
// Strings starting with '-' need quotes
|
||||
if (/^-/.test(str))
|
||||
return true;
|
||||
|
||||
// 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))
|
||||
return true;
|
||||
|
||||
// YAML array starts with [
|
||||
if (/^\[/.test(str))
|
||||
return true;
|
||||
|
||||
// Non-string types recognized by YAML
|
||||
if (!isNaN(Number(str)) || ['y', 'n', 'yes', 'no', 'true', 'false', 'on', 'off', 'null'].includes(str.toLowerCase()))
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -564,8 +564,8 @@ export class Page extends SdkObject {
|
|||
await this._delegate.bringToFront();
|
||||
}
|
||||
|
||||
async addInitScript(source: string) {
|
||||
const initScript = new InitScript(source);
|
||||
async addInitScript(source: string, name?: string) {
|
||||
const initScript = new InitScript(source, false /* internal */, name);
|
||||
this.initScripts.push(initScript);
|
||||
await this._delegate.addInitScript(initScript);
|
||||
}
|
||||
|
|
@ -953,8 +953,9 @@ function addPageBinding(playwrightBinding: string, bindingName: string, needsHan
|
|||
export class InitScript {
|
||||
readonly source: string;
|
||||
readonly internal: boolean;
|
||||
readonly name?: string;
|
||||
|
||||
constructor(source: string, internal?: boolean) {
|
||||
constructor(source: string, internal?: boolean, name?: string) {
|
||||
const guid = createGuid();
|
||||
this.source = `(() => {
|
||||
globalThis.__pwInitScripts = globalThis.__pwInitScripts || {};
|
||||
|
|
@ -965,6 +966,7 @@ export class InitScript {
|
|||
${source}
|
||||
})();`;
|
||||
this.internal = !!internal;
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import type * as actions from '@recorder/actions';
|
|||
import { buildFullSelector } from '../utils/isomorphic/recorderUtils';
|
||||
import { stringifySelector } from '../utils/isomorphic/selectorParser';
|
||||
import type { Frame } from './frames';
|
||||
import type { ParsedYaml } from '@isomorphic/ariaSnapshot';
|
||||
import type { AriaTemplateNode } from '@isomorphic/ariaSnapshot';
|
||||
|
||||
const recorderSymbol = Symbol('recorderSymbol');
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ export class Recorder implements InstrumentationListener, IRecorder {
|
|||
readonly handleSIGINT: boolean | undefined;
|
||||
private _context: BrowserContext;
|
||||
private _mode: Mode;
|
||||
private _highlightedElement: { selector?: string, ariaTemplate?: ParsedYaml } = {};
|
||||
private _highlightedElement: { selector?: string, ariaTemplate?: AriaTemplateNode } = {};
|
||||
private _overlayState: OverlayState = { offsetX: 0 };
|
||||
private _recorderApp: IRecorderApp | null = null;
|
||||
private _currentCallsMetadata = new Map<CallMetadata, SdkObject>();
|
||||
|
|
@ -249,7 +249,7 @@ export class Recorder implements InstrumentationListener, IRecorder {
|
|||
this._refreshOverlay();
|
||||
}
|
||||
|
||||
setHighlightedAriaTemplate(ariaTemplate: ParsedYaml) {
|
||||
setHighlightedAriaTemplate(ariaTemplate: AriaTemplateNode) {
|
||||
this._highlightedElement = { ariaTemplate };
|
||||
this._refreshOverlay();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ export class ContextRecorder extends EventEmitter {
|
|||
this._listeners.push(eventsHelper.addEventListener(process, 'exit', () => {
|
||||
this._throttledOutputFile?.flush();
|
||||
}));
|
||||
this.setEnabled(true);
|
||||
this.setEnabled(params.mode === 'recording');
|
||||
}
|
||||
|
||||
setOutput(codegenId: string, outputFile?: string) {
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ export async function performAction(callMetadata: CallMetadata, pageAliases: Map
|
|||
await mainFrame.expect(callMetadata, selector, {
|
||||
selector,
|
||||
expression: 'to.be.checked',
|
||||
expectedValue: { checked: action.checked },
|
||||
isNot: !action.checked,
|
||||
timeout: kActionTimeout,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import childProcess from 'child_process';
|
|||
import * as utils from '../../utils';
|
||||
import { spawnAsync } from '../../utils/spawnAsync';
|
||||
import { hostPlatform, isOfficiallySupportedPlatform } from '../../utils/hostPlatform';
|
||||
import { buildPlaywrightCLICommand } from '.';
|
||||
import { buildPlaywrightCLICommand, registry } from '.';
|
||||
import { deps } from './nativeDeps';
|
||||
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 lddPaths: string[] = [];
|
||||
for (const directoryPath of directoryPaths)
|
||||
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();
|
||||
for (const deps of allMissingDeps) {
|
||||
for (const dep of deps)
|
||||
|
|
@ -302,8 +302,8 @@ async function executablesOrSharedLibraries(directoryPath: string): Promise<stri
|
|||
return executablersOrLibraries as string[];
|
||||
}
|
||||
|
||||
async function missingFileDependenciesWindows(filePath: string): Promise<Array<string>> {
|
||||
const executable = path.join(__dirname, '..', '..', '..', 'bin', 'PrintDeps.exe');
|
||||
async function missingFileDependenciesWindows(sdkLanguage: string, filePath: string): Promise<Array<string>> {
|
||||
const executable = registry.findExecutable('winldd')!.executablePathOrDie(sdkLanguage);
|
||||
const dirname = path.dirname(filePath);
|
||||
const { stdout, code } = await spawnAsync(executable, [filePath], {
|
||||
cwd: dirname,
|
||||
|
|
|
|||
|
|
@ -79,6 +79,11 @@ const EXECUTABLE_PATHS = {
|
|||
'mac': ['ffmpeg-mac'],
|
||||
'win': ['ffmpeg-win64.exe'],
|
||||
},
|
||||
'winldd': {
|
||||
'linux': undefined,
|
||||
'mac': undefined,
|
||||
'win': ['PrintDeps.exe'],
|
||||
},
|
||||
};
|
||||
|
||||
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',
|
||||
'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': {
|
||||
'<unknown>': 'builds/android/%s/android.zip',
|
||||
'ubuntu18.04-x64': undefined,
|
||||
|
|
@ -442,7 +476,7 @@ function readDescriptors(browsersJSON: BrowsersJSON): BrowsersJSONDescriptor[] {
|
|||
}
|
||||
|
||||
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 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'];
|
||||
|
|
@ -772,6 +806,22 @@ export class Registry {
|
|||
_dependencyGroup: 'tools',
|
||||
_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')!;
|
||||
this._executables.push({
|
||||
type: 'tool',
|
||||
|
|
@ -944,7 +994,7 @@ export class Registry {
|
|||
if (os.platform() === 'linux')
|
||||
return await validateDependenciesLinux(sdkLanguage, linuxLddDirectories.map(d => path.join(browserDirectory, d)), dlOpenLibraries);
|
||||
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) {
|
||||
|
|
@ -1268,6 +1318,8 @@ export async function installBrowsersForNpmInstall(browsers: string[]) {
|
|||
return false;
|
||||
}
|
||||
const executables: Executable[] = [];
|
||||
if (process.platform === 'win32')
|
||||
executables.push(registry.findExecutable('winldd')!);
|
||||
for (const browserName of browsers) {
|
||||
const executable = registry.findExecutable(browserName);
|
||||
if (!executable || executable.installType === 'none')
|
||||
|
|
|
|||
|
|
@ -1104,4 +1104,3 @@ deps['debian12-arm64'] = {
|
|||
...deps['debian12-x64'].lib2package,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -354,4 +354,4 @@ export function rewriteOpenSSLErrorIfNeeded(error: Error): Error {
|
|||
'For more details, see https://github.com/openssl/openssl/blob/master/README-PROVIDERS.md#the-legacy-provider',
|
||||
'You could probably modernize the certificate by following the steps at https://github.com/nodejs/node/issues/40672#issuecomment-1243648223',
|
||||
].join('\n'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,4 +83,3 @@ export class SocksInterceptor {
|
|||
function tChannelForSocks(names: '*' | string[], arg: any, path: string, context: ValidatorContext) {
|
||||
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.
|
||||
*/
|
||||
sameSite: CookieSameSitePolicy;
|
||||
/**
|
||||
* Cookie partition key. If null and partitioned property is true, then key must be computed.
|
||||
*/
|
||||
partitionKey?: string;
|
||||
}
|
||||
/**
|
||||
* 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 = {
|
||||
cookie: Cookie;
|
||||
/**
|
||||
* If true, then cookie's partition key should be set.
|
||||
*/
|
||||
shouldPartition?: boolean;
|
||||
}
|
||||
export type setCookieReturnValue = {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,4 +105,4 @@ export class WKProvisionalPage {
|
|||
assert(!frameTree.frame.parentId);
|
||||
this._mainFrameId = frameTree.frame.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' |
|
||||
'toolbar' | 'tooltip' | 'tree' | 'treegrid' | 'treeitem';
|
||||
|
||||
export type ParsedYaml = Array<any>;
|
||||
|
||||
export type AriaProps = {
|
||||
checked?: boolean | 'mixed';
|
||||
disabled?: boolean;
|
||||
|
|
@ -35,89 +33,218 @@ export type AriaProps = {
|
|||
selected?: boolean;
|
||||
};
|
||||
|
||||
// We pass parsed template between worlds using JSON, make it easy.
|
||||
export type AriaRegex = { pattern: string };
|
||||
|
||||
export type AriaTemplateTextNode = {
|
||||
kind: 'text';
|
||||
text: RegExp | string;
|
||||
text: AriaRegex | string;
|
||||
};
|
||||
|
||||
export type AriaTemplateRoleNode = AriaProps & {
|
||||
kind: 'role';
|
||||
role: AriaRole | 'fragment';
|
||||
name?: RegExp | string;
|
||||
name?: AriaRegex | string;
|
||||
children?: AriaTemplateNode[];
|
||||
};
|
||||
|
||||
export type AriaTemplateNode = AriaTemplateRoleNode | AriaTemplateTextNode;
|
||||
|
||||
export function parseYamlTemplate(fragment: ParsedYaml): AriaTemplateNode {
|
||||
const result: AriaTemplateNode = { kind: 'role', role: 'fragment' };
|
||||
populateNode(result, fragment);
|
||||
if (result.children && result.children.length === 1)
|
||||
return result.children[0];
|
||||
return result;
|
||||
import type * as yamlTypes from 'yaml';
|
||||
|
||||
type YamlLibrary = {
|
||||
parseDocument: typeof yamlTypes.parseDocument;
|
||||
Scalar: typeof yamlTypes.Scalar;
|
||||
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) {
|
||||
for (const object of container) {
|
||||
if (typeof object === 'string') {
|
||||
const childNode = KeyParser.parse(object);
|
||||
node.children = node.children || [];
|
||||
node.children.push(childNode);
|
||||
continue;
|
||||
export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: yamlTypes.ParseOptions = {}): { fragment: AriaTemplateNode, errors: ParsedYamlError[] } {
|
||||
const lineCounter = new yaml.LineCounter();
|
||||
const parseOptions: yamlTypes.ParseOptions = {
|
||||
keepSourceTokens: true,
|
||||
lineCounter,
|
||||
...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;
|
||||
}
|
||||
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)) {
|
||||
node.children = node.children || [];
|
||||
const value = object[key];
|
||||
|
||||
if (key === 'text') {
|
||||
node.children.push({
|
||||
kind: 'text',
|
||||
text: valueOrRegex(value)
|
||||
const convertMap = (container: AriaTemplateRoleNode, map: yamlTypes.YAMLMap) => {
|
||||
for (const entry of map.items) {
|
||||
container.children = container.children || [];
|
||||
// Key must by a string
|
||||
const keyIsString = entry.key instanceof yaml.Scalar && typeof entry.key.value === 'string';
|
||||
if (!keyIsString) {
|
||||
errors.push({
|
||||
message: 'Only string keys are supported',
|
||||
range: convertRange((entry.key as any).range || map.range),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const childNode = KeyParser.parse(key);
|
||||
if (childNode.kind === 'text') {
|
||||
node.children.push({
|
||||
const key: yamlTypes.Scalar<string> = entry.key as yamlTypes.Scalar<string>;
|
||||
const value = entry.value;
|
||||
|
||||
// - 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',
|
||||
text: valueOrRegex(value)
|
||||
text: valueOrRegex(value.value)
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
node.children.push({
|
||||
...childNode, children: [{
|
||||
// role "name": ...
|
||||
const childNode = KeyParser.parse(key, parseOptions, errors);
|
||||
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',
|
||||
text: valueOrRegex(value)
|
||||
text: valueOrRegex(String(value.value))
|
||||
}]
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
node.children.push(childNode);
|
||||
populateNode(childNode, value);
|
||||
// - role "name":
|
||||
// - 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) {
|
||||
return text.replace(/[\r\n\s\t]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function valueOrRegex(value: string): string | RegExp {
|
||||
return value.startsWith('/') && value.endsWith('/') ? new RegExp(value.slice(1, -1)) : normalizeWhitespace(value);
|
||||
export function valueOrRegex(value: string): string | AriaRegex {
|
||||
return value.startsWith('/') && value.endsWith('/') && value.length > 1 ? { pattern: value.slice(1, -1) } : normalizeWhitespace(value);
|
||||
}
|
||||
|
||||
class KeyParser {
|
||||
export class KeyParser {
|
||||
private _input: string;
|
||||
private _pos: number;
|
||||
private _length: number;
|
||||
|
||||
static parse(input: string): AriaTemplateNode {
|
||||
return new KeyParser(input)._parse();
|
||||
static parse(text: yamlTypes.Scalar<string>, options: yamlTypes.ParseOptions, errors: ParsedYamlError[]): AriaTemplateRoleNode | null {
|
||||
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) {
|
||||
|
|
@ -177,11 +304,11 @@ class KeyParser {
|
|||
this._throwError('Unterminated string');
|
||||
}
|
||||
|
||||
private _throwError(message: string, pos?: number): never {
|
||||
throw new AriaKeyError(message, this._input, pos || this._pos);
|
||||
private _throwError(message: string, offset: number = 0): never {
|
||||
throw new ParserError(message, offset || this._pos);
|
||||
}
|
||||
|
||||
private _readRegex(): string {
|
||||
private _readRegex(): AriaRegex {
|
||||
let result = '';
|
||||
let escaped = false;
|
||||
let insideClass = false;
|
||||
|
|
@ -194,7 +321,7 @@ class KeyParser {
|
|||
escaped = true;
|
||||
result += ch;
|
||||
} else if (ch === '/' && !insideClass) {
|
||||
return result;
|
||||
return { pattern: result };
|
||||
} else if (ch === '[') {
|
||||
insideClass = true;
|
||||
result += ch;
|
||||
|
|
@ -208,16 +335,16 @@ class KeyParser {
|
|||
this._throwError('Unterminated regex');
|
||||
}
|
||||
|
||||
private _readStringOrRegex(): string | RegExp | null {
|
||||
private _readStringOrRegex(): string | AriaRegex | null {
|
||||
const ch = this._peek();
|
||||
if (ch === '"') {
|
||||
this._next();
|
||||
return this._readString();
|
||||
return normalizeWhitespace(this._readString());
|
||||
}
|
||||
|
||||
if (ch === '/') {
|
||||
this._next();
|
||||
return new RegExp(this._readRegex());
|
||||
return this._readRegex();
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
@ -253,7 +380,7 @@ class KeyParser {
|
|||
}
|
||||
}
|
||||
|
||||
_parse(): AriaTemplateNode {
|
||||
_parse(): AriaTemplateRoleNode {
|
||||
this._skipWhitespace();
|
||||
|
||||
const role = this._readIdentifier('role') as AriaTemplateRoleNode['role'];
|
||||
|
|
@ -307,18 +434,11 @@ class KeyParser {
|
|||
}
|
||||
}
|
||||
|
||||
export function parseAriaKey(key: string) {
|
||||
return KeyParser.parse(key);
|
||||
}
|
||||
|
||||
export class AriaKeyError extends Error {
|
||||
readonly shortMessage: string;
|
||||
export class ParserError extends Error {
|
||||
readonly pos: number;
|
||||
|
||||
constructor(message: string, input: string, pos: number) {
|
||||
super(message + ':\n\n' + input + '\n' + ' '.repeat(pos) + '^\n');
|
||||
this.shortMessage = message;
|
||||
constructor(message: string, pos: number) {
|
||||
super(message);
|
||||
this.pos = pos;
|
||||
this.stack = undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export interface LocatorFactory {
|
|||
}
|
||||
|
||||
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[] {
|
||||
|
|
@ -220,7 +220,7 @@ function combineTokens(factory: LocatorFactory, tokens: string[][], maxOutputSiz
|
|||
const visit = (index: number) => {
|
||||
if (index === tokens.length) {
|
||||
result.push(factory.chainLocators(currentTokens));
|
||||
return currentTokens.length < maxOutputSize;
|
||||
return result.length < maxOutputSize;
|
||||
}
|
||||
for (const taken of tokens[index]) {
|
||||
currentTokens[index] = taken;
|
||||
|
|
|
|||
|
|
@ -20,4 +20,4 @@ export function isJsonMimeType(mimeType: string) {
|
|||
|
||||
export function isTextualMimeType(mimeType: string) {
|
||||
return !!mimeType.match(/^(text\/.*?|application\/(json|(x-)?javascript|xml.*?|ecmascript|graphql|x-www-form-urlencoded)|image\/svg(\+xml)?|application\/.*?(\+json|\+xml))(;\s*charset=.*)?$/);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,4 +77,3 @@ function parseOSReleaseText(osReleaseText: string): Map<string, string> {
|
|||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ export async function launchProcess(options: LaunchProcessOptions): Promise<Laun
|
|||
let processClosed = false;
|
||||
let fulfillCleanup = () => {};
|
||||
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}>`);
|
||||
processClosed = true;
|
||||
gracefullyCloseSet.delete(gracefullyClose);
|
||||
|
|
|
|||
|
|
@ -63,4 +63,4 @@ export function findRepeatedSubsequences(s: string[]): { sequence: string[]; cou
|
|||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,54 +19,54 @@ import { AsyncLocalStorage } from 'async_hooks';
|
|||
export type ZoneType = 'apiZone' | 'expectZone' | 'stepZone';
|
||||
|
||||
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 {
|
||||
const zone = Zone._createWithData(this._asyncLocalStorage, type, data);
|
||||
return this._asyncLocalStorage.run(zone, func);
|
||||
return this.current().with(type, data).run(func);
|
||||
}
|
||||
|
||||
zoneData<T>(type: ZoneType): T | undefined {
|
||||
const zone = this._asyncLocalStorage.getStore();
|
||||
return zone?.get(type);
|
||||
return this.current().data(type);
|
||||
}
|
||||
|
||||
currentZone(): Zone {
|
||||
return this._asyncLocalStorage.getStore() ?? Zone._createEmpty(this._asyncLocalStorage);
|
||||
current(): Zone {
|
||||
return this._asyncLocalStorage.getStore() ?? this._emptyZone;
|
||||
}
|
||||
|
||||
exitZones<R>(func: () => R): R {
|
||||
return this._asyncLocalStorage.run(undefined, func);
|
||||
empty(): Zone {
|
||||
return this._emptyZone;
|
||||
}
|
||||
}
|
||||
|
||||
export class Zone {
|
||||
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) {
|
||||
const store = new Map(asyncLocalStorage.getStore()?._data);
|
||||
store.set(type, data);
|
||||
return new Zone(asyncLocalStorage, store);
|
||||
}
|
||||
|
||||
static _createEmpty(asyncLocalStorage: AsyncLocalStorage<Zone|undefined>) {
|
||||
static createEmpty(asyncLocalStorage: AsyncLocalStorage<Zone | undefined>) {
|
||||
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._data = store;
|
||||
}
|
||||
|
||||
run<R>(func: () => R): R {
|
||||
// Reset apiZone and expectZone, but restore stepZone.
|
||||
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);
|
||||
with(type: ZoneType, data: unknown): Zone {
|
||||
return new Zone(this._asyncLocalStorage, new Map(this._data).set(type, data));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 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 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 wsServer: typeof import('../bundles/utils/node_modules/@types/ws').WebSocketServer = require('./utilsBundleImpl').wsServer;
|
||||
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:
|
||||
*
|
||||
* ```js
|
||||
* // Note you can only create DataTransfer in Chromium and Firefox
|
||||
* const dataTransfer = await page.evaluateHandle(() => new 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.
|
||||
*
|
||||
* Note that when both locators match something, the resulting locator will have multiple matches and violate
|
||||
* [locator strictness](https://playwright.dev/docs/locators#strictness) guidelines.
|
||||
* Note that when both locators match something, the resulting locator will have multiple matches, potentially causing
|
||||
* a [locator strictness](https://playwright.dev/docs/locators#strictness) violation.
|
||||
*
|
||||
* **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.
|
||||
*
|
||||
* **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
|
||||
* const newEmail = page.getByRole('button', { name: 'New' });
|
||||
* const dialog = page.getByText('Confirm security settings');
|
||||
* await expect(newEmail.or(dialog)).toBeVisible();
|
||||
* await expect(newEmail.or(dialog).first()).toBeVisible();
|
||||
* if (await dialog.isVisible())
|
||||
* await page.getByRole('button', { name: 'Dismiss' }).click();
|
||||
* await newEmail.click();
|
||||
|
|
@ -14716,7 +14719,7 @@ export interface BrowserType<Unused = {}> {
|
|||
/**
|
||||
* 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
|
||||
* "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.
|
||||
*
|
||||
* 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
|
||||
* "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";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* [emulating devices with device scale factor](https://playwright.dev/docs/emulation#devices).
|
||||
|
|
@ -16717,6 +16715,11 @@ export interface AndroidDevice {
|
|||
*/
|
||||
permissions?: Array<string>;
|
||||
|
||||
/**
|
||||
* Optional package name to launch instead of default Chrome for Android.
|
||||
*/
|
||||
pkg?: string;
|
||||
|
||||
/**
|
||||
* Network proxy settings.
|
||||
*/
|
||||
|
|
@ -21566,7 +21569,7 @@ export interface LaunchOptions {
|
|||
/**
|
||||
* 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
|
||||
* "msedge-canary" to use branded [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge).
|
||||
|
|
|
|||
|
|
@ -18,4 +18,4 @@ import jsxRuntime from './jsx-runtime.js';
|
|||
|
||||
export const jsx = jsxRuntime.jsx;
|
||||
export const jsxs = jsxRuntime.jsxs;
|
||||
export const Fragment = jsxRuntime.Fragment;
|
||||
export const Fragment = jsxRuntime.Fragment;
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export class FullConfigInternal {
|
|||
cliFailOnFlakyTests?: boolean;
|
||||
cliLastFailed?: boolean;
|
||||
testIdMatcher?: Matcher;
|
||||
lastFailedTestIdMatcher?: Matcher;
|
||||
defineConfigWasUsed = false;
|
||||
|
||||
globalSetups: string[] = [];
|
||||
|
|
@ -298,4 +299,4 @@ const configInternalSymbol = Symbol('configInternalSymbol');
|
|||
|
||||
export function getProjectId(project: FullProject): string {
|
||||
return (project as any).__projectId!;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ export type AttachmentPayload = {
|
|||
path?: string;
|
||||
body?: string;
|
||||
contentType: string;
|
||||
stepId?: string;
|
||||
};
|
||||
|
||||
export type TestInfoErrorImpl = TestInfoError & {
|
||||
|
|
|
|||
|
|
@ -18,12 +18,13 @@ import * as fs from 'fs';
|
|||
import * as path from 'path';
|
||||
import type { APIRequestContext, BrowserContext, Browser, BrowserContextOptions, LaunchOptions, Page, Tracing, Video } 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 { TestInfoImpl, TestStepInternal } from './worker/testInfo';
|
||||
import { rootTestType } from './common/testType';
|
||||
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';
|
||||
export { expect } from './matchers/expect';
|
||||
export const _baseTest: TestType<{}, {}> = rootTestType.test;
|
||||
|
|
@ -258,34 +259,43 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
|
|||
|
||||
const tracingGroupSteps: TestStepInternal[] = [];
|
||||
const csiListener: ClientInstrumentationListener = {
|
||||
onApiCallBegin: (apiName: string, params: Record<string, any>, frames: StackFrame[], userData: any, out: { stepId?: string }) => {
|
||||
userData.apiName = apiName;
|
||||
onApiCallBegin: (data: ApiCallData) => {
|
||||
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;
|
||||
const step = testInfo._addStep({
|
||||
location: frames[0] as any,
|
||||
category: 'pw:api',
|
||||
title: renderApiCall(apiName, params),
|
||||
apiName,
|
||||
params,
|
||||
}, 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 });
|
||||
const expectZone = zones.zoneData<ExpectZone>('expectZone');
|
||||
if (expectZone) {
|
||||
// Display the internal locator._expect call under the name of the enclosing expect call,
|
||||
// and connect it to the existing expect step.
|
||||
data.apiName = expectZone.title;
|
||||
data.stepId = expectZone.stepId;
|
||||
return;
|
||||
}
|
||||
const step = userData.step;
|
||||
step?.complete({ error });
|
||||
// In the general case, create a step for each api call and connect them through the stepId.
|
||||
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 }) => {
|
||||
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 Playwright = PlaywrightWorkerArgs['playwright'];
|
||||
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ export type JsonTestStepEnd = {
|
|||
id: string;
|
||||
duration: number;
|
||||
error?: reporterTypes.TestError;
|
||||
attachments?: number[]; // index of JsonTestResultEnd.attachments
|
||||
};
|
||||
|
||||
export type JsonFullResult = {
|
||||
|
|
@ -249,7 +250,7 @@ export class TeleReporterReceiver {
|
|||
const parentStep = payload.parentStepId ? result._stepMap.get(payload.parentStepId) : undefined;
|
||||
|
||||
const location = this._absoluteLocation(payload.location);
|
||||
const step = new TeleTestStep(payload, parentStep, location);
|
||||
const step = new TeleTestStep(payload, parentStep, location, result);
|
||||
if (parentStep)
|
||||
parentStep.steps.push(step);
|
||||
else
|
||||
|
|
@ -262,6 +263,7 @@ export class TeleReporterReceiver {
|
|||
const test = this._tests.get(testId)!;
|
||||
const result = test.results.find(r => r._id === resultId)!;
|
||||
const step = result._stepMap.get(payload.id)!;
|
||||
step._endPayload = payload;
|
||||
step.duration = payload.duration;
|
||||
step.error = payload.error;
|
||||
this._reporter.onStepEnd?.(test, result, step);
|
||||
|
|
@ -512,15 +514,20 @@ class TeleTestStep implements reporterTypes.TestStep {
|
|||
parent: reporterTypes.TestStep | undefined;
|
||||
duration: number = -1;
|
||||
steps: reporterTypes.TestStep[] = [];
|
||||
error: reporterTypes.TestError | undefined;
|
||||
|
||||
private _result: TeleTestResult;
|
||||
_endPayload?: JsonTestStepEnd;
|
||||
|
||||
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.category = payload.category;
|
||||
this.location = location;
|
||||
this.parent = parentStep;
|
||||
this._startTime = payload.startTime;
|
||||
this._result = result;
|
||||
}
|
||||
|
||||
titlePath() {
|
||||
|
|
@ -535,6 +542,10 @@ class TeleTestStep implements reporterTypes.TestStep {
|
|||
set startTime(value: Date) {
|
||||
this._startTime = +value;
|
||||
}
|
||||
|
||||
get attachments() {
|
||||
return this._endPayload?.attachments?.map(index => this._result.attachments[index]) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
export class TeleTestResult implements reporterTypes.TestResult {
|
||||
|
|
@ -550,7 +561,7 @@ export class TeleTestResult implements reporterTypes.TestResult {
|
|||
errors: reporterTypes.TestResult['errors'] = [];
|
||||
error: reporterTypes.TestResult['error'];
|
||||
|
||||
_stepMap: Map<string, reporterTypes.TestStep> = new Map();
|
||||
_stepMap = new Map<string, TeleTestStep>();
|
||||
_id: string;
|
||||
|
||||
private _startTime: number = 0;
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
toContainText,
|
||||
toHaveAccessibleDescription,
|
||||
toHaveAccessibleName,
|
||||
toHaveAccessibleErrorMessage,
|
||||
toHaveAttribute,
|
||||
toHaveClass,
|
||||
toHaveCount,
|
||||
|
|
@ -224,6 +225,7 @@ const customAsyncMatchers = {
|
|||
toContainText,
|
||||
toHaveAccessibleDescription,
|
||||
toHaveAccessibleName,
|
||||
toHaveAccessibleErrorMessage,
|
||||
toHaveAttribute,
|
||||
toHaveClass,
|
||||
toHaveCount,
|
||||
|
|
|
|||
|
|
@ -42,9 +42,8 @@ export function toBeAttached(
|
|||
) {
|
||||
const attached = !options || options.attached === undefined || options.attached;
|
||||
const expected = attached ? 'attached' : 'detached';
|
||||
const unexpected = attached ? 'detached' : 'attached';
|
||||
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 });
|
||||
}, options);
|
||||
}
|
||||
|
|
@ -52,14 +51,25 @@ export function toBeAttached(
|
|||
export function toBeChecked(
|
||||
this: ExpectMatcherState,
|
||||
locator: LocatorEx,
|
||||
options?: { checked?: boolean, timeout?: number },
|
||||
options?: { checked?: boolean, indeterminate?: boolean, timeout?: number },
|
||||
) {
|
||||
const checked = !options || options.checked === undefined || options.checked;
|
||||
const expected = checked ? 'checked' : 'unchecked';
|
||||
const unexpected = checked ? 'unchecked' : 'checked';
|
||||
const arg = checked ? '' : '{ checked: false }';
|
||||
return toBeTruthy.call(this, 'toBeChecked', locator, 'Locator', expected, unexpected, arg, async (isNot, timeout) => {
|
||||
return await locator._expect(checked ? 'to.be.checked' : 'to.be.unchecked', { isNot, timeout });
|
||||
const checked = options?.checked;
|
||||
const indeterminate = options?.indeterminate;
|
||||
const expectedValue = {
|
||||
checked,
|
||||
indeterminate,
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -68,7 +78,7 @@ export function toBeDisabled(
|
|||
locator: LocatorEx,
|
||||
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 });
|
||||
}, options);
|
||||
}
|
||||
|
|
@ -80,9 +90,8 @@ export function toBeEditable(
|
|||
) {
|
||||
const editable = !options || options.editable === undefined || options.editable;
|
||||
const expected = editable ? 'editable' : 'readOnly';
|
||||
const unexpected = editable ? 'readOnly' : 'editable';
|
||||
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 });
|
||||
}, options);
|
||||
}
|
||||
|
|
@ -92,7 +101,7 @@ export function toBeEmpty(
|
|||
locator: LocatorEx,
|
||||
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 });
|
||||
}, options);
|
||||
}
|
||||
|
|
@ -104,9 +113,8 @@ export function toBeEnabled(
|
|||
) {
|
||||
const enabled = !options || options.enabled === undefined || options.enabled;
|
||||
const expected = enabled ? 'enabled' : 'disabled';
|
||||
const unexpected = enabled ? 'disabled' : 'enabled';
|
||||
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 });
|
||||
}, options);
|
||||
}
|
||||
|
|
@ -116,7 +124,7 @@ export function toBeFocused(
|
|||
locator: LocatorEx,
|
||||
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 });
|
||||
}, options);
|
||||
}
|
||||
|
|
@ -126,7 +134,7 @@ export function toBeHidden(
|
|||
locator: LocatorEx,
|
||||
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 });
|
||||
}, options);
|
||||
}
|
||||
|
|
@ -138,9 +146,8 @@ export function toBeVisible(
|
|||
) {
|
||||
const visible = !options || options.visible === undefined || options.visible;
|
||||
const expected = visible ? 'visible' : 'hidden';
|
||||
const unexpected = visible ? 'hidden' : 'visible';
|
||||
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 });
|
||||
}, options);
|
||||
}
|
||||
|
|
@ -150,7 +157,7 @@ export function toBeInViewport(
|
|||
locator: LocatorEx,
|
||||
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 });
|
||||
}, 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(
|
||||
this: ExpectMatcherState,
|
||||
locator: LocatorEx,
|
||||
|
|
@ -220,7 +239,7 @@ export function toHaveAttribute(
|
|||
}
|
||||
}
|
||||
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 });
|
||||
}, options);
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue