Merge remote-tracking branch 'origin/main' into settings-dialog-with-canvas-option3
This commit is contained in:
commit
ddd2b8c828
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -26,6 +26,16 @@ npm run watch
|
|||
npx playwright install
|
||||
```
|
||||
|
||||
**Experimental dev mode with Hot Module Replacement for recorder/trace-viewer/UI Mode**
|
||||
|
||||
```
|
||||
PW_HMR=1 npm run watch
|
||||
PW_HMR=1 npx playwright show-trace
|
||||
PW_HMR=1 npm run ctest -- --ui
|
||||
PW_HMR=1 npx playwright codegen
|
||||
PW_HMR=1 npx playwright show-report
|
||||
```
|
||||
|
||||
Playwright is a multi-package repository that uses npm workspaces. For browser APIs, look at [`packages/playwright-core`](https://github.com/microsoft/playwright/blob/main/packages/playwright-core). For test runner, see [`packages/playwright`](https://github.com/microsoft/playwright/blob/main/packages/playwright).
|
||||
|
||||
Note that some files are generated by the build, so the watch process might override your changes if done in the wrong file. For example, TypeScript types for the API are generated from the [`docs/src`](https://github.com/microsoft/playwright/blob/main/docs/src).
|
||||
|
|
|
|||
|
|
@ -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,7 +8,7 @@ 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: |
|
||||
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ In case this browser is connected to, clears all created contexts belonging to t
|
|||
browser server.
|
||||
|
||||
:::note
|
||||
This is similar to force quitting the browser. Therefore, you should call [`method: BrowserContext.close`] on any [BrowserContext]'s you explicitly created earlier with [`method: Browser.newContext`] **before** calling [`method: Browser.close`].
|
||||
This is similar to force-quitting the browser. To close pages gracefully and ensure you receive page close events, call [`method: BrowserContext.close`] on any [BrowserContext] instances you explicitly created earlier using [`method: Browser.newContext`] **before** calling [`method: Browser.close`].
|
||||
:::
|
||||
|
||||
The [Browser] object itself is considered to be disposed and cannot be used anymore.
|
||||
|
|
|
|||
|
|
@ -1717,16 +1717,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 +1740,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 +1749,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 +1758,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 +1767,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();
|
||||
|
|
|
|||
|
|
@ -1217,6 +1217,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.
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ def test_popup_page(page: Page, extension_id: str) -> None:
|
|||
|
||||
## 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):
|
||||
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#chromium-new-headless-mode):
|
||||
|
||||
```js title="fixtures.ts"
|
||||
// ...
|
||||
|
|
|
|||
|
|
@ -1773,6 +1773,112 @@ Specifies a custom location for the step to be shown in test reports and trace v
|
|||
|
||||
Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout).
|
||||
|
||||
## async method: Test.step.fail
|
||||
* since: v1.50
|
||||
- returns: <[void]>
|
||||
|
||||
Marks a test step as "should fail". Playwright runs this test step and ensures that it actually fails. This is useful for documentation purposes to acknowledge that some functionality is broken until it is fixed.
|
||||
|
||||
:::note
|
||||
If the step exceeds the timeout, a [TimeoutError] is thrown. This indicates the step did not fail as expected.
|
||||
:::
|
||||
|
||||
**Usage**
|
||||
|
||||
You can declare a test step as failing, so that Playwright ensures it actually fails.
|
||||
|
||||
```js
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('my test', async ({ page }) => {
|
||||
// ...
|
||||
await test.step.fail('currently failing', async () => {
|
||||
// ...
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### param: Test.step.fail.title
|
||||
* since: v1.50
|
||||
- `title` <[string]>
|
||||
|
||||
Step name.
|
||||
|
||||
### param: Test.step.fail.body
|
||||
* since: v1.50
|
||||
- `body` <[function]\(\):[Promise]<[any]>>
|
||||
|
||||
Step body.
|
||||
|
||||
### option: Test.step.fail.box
|
||||
* since: v1.50
|
||||
- `box` <boolean>
|
||||
|
||||
Whether to box the step in the report. Defaults to `false`. When the step is boxed, errors thrown from the step internals point to the step call site. See below for more details.
|
||||
|
||||
### option: Test.step.fail.location
|
||||
* since: v1.50
|
||||
- `location` <[Location]>
|
||||
|
||||
Specifies a custom location for the step to be shown in test reports and trace viewer. By default, location of the [`method: Test.step`] call is shown.
|
||||
|
||||
### option: Test.step.fail.timeout
|
||||
* since: v1.50
|
||||
- `timeout` <[float]>
|
||||
|
||||
Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout).
|
||||
|
||||
## async method: Test.step.fixme
|
||||
* since: v1.50
|
||||
- returns: <[void]>
|
||||
|
||||
Mark a test step as "fixme", with the intention to fix it. Playwright will not run the step.
|
||||
|
||||
**Usage**
|
||||
|
||||
You can declare a test step as failing, so that Playwright ensures it actually fails.
|
||||
|
||||
```js
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('my test', async ({ page }) => {
|
||||
// ...
|
||||
await test.step.fixme('not yet ready', async () => {
|
||||
// ...
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### param: Test.step.fixme.title
|
||||
* since: v1.50
|
||||
- `title` <[string]>
|
||||
|
||||
Step name.
|
||||
|
||||
### param: Test.step.fixme.body
|
||||
* since: v1.50
|
||||
- `body` <[function]\(\):[Promise]<[any]>>
|
||||
|
||||
Step body.
|
||||
|
||||
### option: Test.step.fixme.box
|
||||
* since: v1.50
|
||||
- `box` <boolean>
|
||||
|
||||
Whether to box the step in the report. Defaults to `false`. When the step is boxed, errors thrown from the step internals point to the step call site. See below for more details.
|
||||
|
||||
### option: Test.step.fixme.location
|
||||
* since: v1.50
|
||||
- `location` <[Location]>
|
||||
|
||||
Specifies a custom location for the step to be shown in test reports and trace viewer. By default, location of the [`method: Test.step`] call is shown.
|
||||
|
||||
### option: Test.step.fixme.timeout
|
||||
* since: v1.50
|
||||
- `timeout` <[float]>
|
||||
|
||||
Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout).
|
||||
|
||||
## method: Test.use
|
||||
* since: v1.10
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -129,7 +129,13 @@ You can use the `globalSetup` option in the [configuration file](./test-configur
|
|||
Similarly, use `globalTeardown` to run something once after all the tests. Alternatively, let `globalSetup` return a function that will be used as a global teardown. You can pass data such as port number, authentication tokens, etc. from your global setup to your tests using environment variables.
|
||||
|
||||
:::note
|
||||
Using `globalSetup` and `globalTeardown` will not produce traces or artifacts, and options like `headless` or `testIdAttribute` specified in the config file are not applied. If you want to produce traces and artifacts and respect config options, use [project dependencies](#option-1-project-dependencies).
|
||||
Beware of `globalSetup` and `globalTeardown` caveats:
|
||||
|
||||
- These methods will not produce traces or artifacts unless explictly enabled, as described in [Capturing trace of failures during global setup](#capturing-trace-of-failures-during-global-setup).
|
||||
- Options sush as `headless` or `testIdAttribute` specified in the config file are not applied,
|
||||
- An uncaught exception thrown in `globalSetup` will prevent Playwright from running tests, and no test results will appear in reporters.
|
||||
|
||||
Consider using [project dependencies](#option-1-project-dependencies) to produce traces, artifacts, respect config options and get test results in reporters even in case of a setup failure.
|
||||
:::
|
||||
|
||||
```js title="playwright.config.ts"
|
||||
|
|
|
|||
|
|
@ -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]>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import LiteYouTube from '@site/src/components/LiteYouTube';
|
|||
|
||||
## Introduction
|
||||
|
||||
UI Mode lets you explore, run and debug tests with a time travel experience complete with watch mode. All test files are loaded into the testing sidebar where you can expand each file and describe block to individually run, view, watch and debug each test. Filter tests by **text** or **@tag** or by **passed**, **failed** and **skipped** tests as well as by [**projects**](./test-projects) as set in your `playwright.config` file. See a full trace of your tests and hover back and forward over each action to see what was happening during each step and pop out the DOM snapshot to a separate window for a better debugging experience.
|
||||
UI Mode lets you explore, run, and debug tests with a time travel experience complete with a watch mode. All test files are displayed in the testing sidebar, allowing you to expand each file and describe block to individually run, view, watch, and debug each test. Filter tests by **name**, [**projects**](./test-projects) (set in your `playwright.config` file), **@tag**, or by the execution status of **passed**, **failed**, and **skipped**. See a full trace of your tests and hover back and forward over each action to see what was happening during each step. You can also pop out the DOM snapshot of a given moment into a separate window for a better debugging experience.
|
||||
|
||||
<LiteYouTube
|
||||
id="d0u6XhXknzU"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ Playwright Trace Viewer is a GUI tool that lets you explore recorded Playwright
|
|||
|
||||
## Recording a Trace
|
||||
|
||||
By default the [playwright.config](./trace-viewer.md#recording-a-trace-on-ci) file will contain the configuration needed to create a `trace.zip` file for each test. Traces are setup to run `on-first-retry` meaning they will be run on the first retry of a failed test. Also `retries` are set to 2 when running on CI and 0 locally. This means the traces will be recorded on the first retry of a failed test but not on the first run and not on the second retry.
|
||||
By default the [playwright.config](./trace-viewer.md#tracing-on-ci) file will contain the configuration needed to create a `trace.zip` file for each test. Traces are setup to run `on-first-retry` meaning they will be run on the first retry of a failed test. Also `retries` are set to 2 when running on CI and 0 locally. This means the traces will be recorded on the first retry of a failed test but not on the first run and not on the second retry.
|
||||
|
||||
```js title="playwright.config.ts"
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
|
|
|||
|
|
@ -17,106 +17,67 @@ Playwright Trace Viewer is a GUI tool that helps you explore recorded Playwright
|
|||
title="Viewing Playwright Traces"
|
||||
/>
|
||||
|
||||
## Trace Viewer features
|
||||
### Actions
|
||||
## Opening Trace Viewer
|
||||
|
||||
In the Actions tab you can see what locator was used for every action and how long each one took to run. Hover over each action of your test and visually see the change in the DOM snapshot. Go back and forward in time and click an action to inspect and debug. Use the Before and After tabs to visually see what happened before and after the action.
|
||||
You can open a saved trace using either the Playwright CLI or in the browser at [trace.playwright.dev](https://trace.playwright.dev). Make sure to add the full path to where your `trace.zip` file is located.
|
||||
|
||||

|
||||
```bash js
|
||||
npx playwright show-trace path/to/trace.zip
|
||||
```
|
||||
|
||||
**Selecting each action reveals:**
|
||||
- action snapshots
|
||||
- action log
|
||||
- source code location
|
||||
```bash java
|
||||
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace trace.zip"
|
||||
```
|
||||
|
||||
### Screenshots
|
||||
```bash python
|
||||
playwright show-trace trace.zip
|
||||
```
|
||||
|
||||
When tracing with the [`option: Tracing.start.screenshots`] option turned on (default), each trace records a screencast and renders it as a film strip. You can hover over the film strip to see a magnified image of for each action and state which helps you easily find the action you want to inspect.
|
||||
```bash csharp
|
||||
pwsh bin/Debug/netX/playwright.ps1 show-trace trace.zip
|
||||
```
|
||||
|
||||
Double click on an action to see the time range for that action. You can use the slider in the timeline to increase the actions selected and these will be shown in the Actions tab and all console logs and network logs will be filtered to only show the logs for the actions selected.
|
||||
### Using [trace.playwright.dev](https://trace.playwright.dev)
|
||||
|
||||

|
||||
[trace.playwright.dev](https://trace.playwright.dev) is a statically hosted variant of the Trace Viewer. You can upload trace files using drag and drop or via the `Select file(s)` button.
|
||||
|
||||
Trace Viewer loads the trace entirely in your browser and does not transmit any data externally.
|
||||
|
||||
### Snapshots
|
||||
<img width="1119" alt="Drop Playwright Trace to load" src="https://user-images.githubusercontent.com/13063165/194577918-b4d45726-2692-4093-8a28-9e73552617ef.png" />
|
||||
|
||||
When tracing with the [`option: Tracing.start.snapshots`] option turned on (default), Playwright captures a set of complete DOM snapshots for each action. Depending on the type of the action, it will capture:
|
||||
### Viewing remote traces
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
|Before|A snapshot at the time action is called.|
|
||||
|Action|A snapshot at the moment of the performed input. This type of snapshot is especially useful when exploring where exactly Playwright clicked.|
|
||||
|After|A snapshot after the action.|
|
||||
You can open remote traces directly using its URL. This makes it easy to view the remote trace without having to manually download the file from CI runs, for example.
|
||||
|
||||
Here is what the typical Action snapshot looks like:
|
||||
```bash js
|
||||
npx playwright show-trace https://example.com/trace.zip
|
||||
```
|
||||
|
||||

|
||||
```bash java
|
||||
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace https://example.com/trace.zip"
|
||||
```
|
||||
|
||||
Notice how it highlights both, the DOM Node as well as the exact click position.
|
||||
```bash python
|
||||
playwright show-trace https://example.com/trace.zip
|
||||
```
|
||||
|
||||
### Source
|
||||
```bash csharp
|
||||
pwsh bin/Debug/netX/playwright.ps1 show-trace https://example.com/trace.zip
|
||||
```
|
||||
|
||||
When you click on an action in the sidebar, the line of code for that action is highlighted in the source panel.
|
||||
When using [trace.playwright.dev](https://trace.playwright.dev), you can also pass the URL of your uploaded trace at some accessible storage (e.g. inside your CI) as a query parameter. CORS (Cross-Origin Resource Sharing) rules might apply.
|
||||
|
||||

|
||||
```txt
|
||||
https://trace.playwright.dev/?trace=https://demo.playwright.dev/reports/todomvc/data/cb0fa77ebd9487a5c899f3ae65a7ffdbac681182.zip
|
||||
```
|
||||
|
||||
### Call
|
||||
|
||||
The call tab shows you information about the action such as the time it took, what locator was used, if in strict mode and what key was used.
|
||||
|
||||

|
||||
|
||||
### Log
|
||||
|
||||
See a full log of your test to better understand what Playwright is doing behind the scenes such as scrolling into view, waiting for element to be visible, enabled and stable and performing actions such as click, fill, press etc.
|
||||
|
||||

|
||||
|
||||
### Errors
|
||||
|
||||
If your test fails you will see the error messages for each test in the Errors tab. The timeline will also show a red line highlighting where the error occurred. You can also click on the source tab to see on which line of the source code the error is.
|
||||
|
||||

|
||||
|
||||
### Console
|
||||
|
||||
See console logs from the browser as well as from your test. Different icons are displayed to show you if the console log came from the browser or from the test file.
|
||||
|
||||

|
||||
|
||||
Double click on an action from your test in the actions sidebar. This will filter the console to only show the logs that were made during that action. Click the *Show all* button to see all console logs again.
|
||||
|
||||
Use the timeline to filter actions, by clicking a start point and dragging to an ending point. The console tab will also be filtered to only show the logs that were made during the actions selected.
|
||||
|
||||
|
||||
### Network
|
||||
|
||||
The Network tab shows you all the network requests that were made during your test. You can sort by different types of requests, status code, method, request, content type, duration and size. Click on a request to see more information about it such as the request headers, response headers, request body and response body.
|
||||
|
||||

|
||||
|
||||
Double click on an action from your test in the actions sidebar. This will filter the network requests to only show the requests that were made during that action. Click the *Show all* button to see all network requests again.
|
||||
|
||||
Use the timeline to filter actions, by clicking a start point and dragging to an ending point. The network tab will also be filtered to only show the network requests that were made during the actions selected.
|
||||
|
||||
### Metadata
|
||||
|
||||
Next to the Actions tab you will find the Metadata tab which will show you more information on your test such as the Browser, viewport size, test duration and more.
|
||||
|
||||

|
||||
|
||||
### Attachments
|
||||
## Recording a trace
|
||||
* langs: js
|
||||
|
||||
The "Attachments" tab allows you to explore attachments. If you're doing [visual regression testing](./test-snapshots.md), you'll be able to compare screenshots by examining the image diff, the actual image and the expected image. When you click on the expected image you can use the slider to slide one image over the other so you can easily see the differences in your screenshots.
|
||||
|
||||

|
||||
|
||||
|
||||
## Recording a trace locally
|
||||
### Tracing locally
|
||||
* langs: js
|
||||
|
||||
To record a trace during development mode set the `--trace` flag to `on` when running your tests. You can also use [UI Mode](./test-ui-mode.md) for a better developer experience.
|
||||
To record a trace during development mode set the `--trace` flag to `on` when running your tests. You can also use [UI Mode](./test-ui-mode.md) for a better developer experience, as it traces each test automatically.
|
||||
|
||||
```bash
|
||||
npx playwright test --trace on
|
||||
|
|
@ -126,7 +87,7 @@ You can then open the HTML report and click on the trace icon to open the trace.
|
|||
```bash
|
||||
npx playwright show-report
|
||||
```
|
||||
## Recording a trace on CI
|
||||
### Tracing on CI
|
||||
* langs: js
|
||||
|
||||
Traces should be run on continuous integration on the first retry of a failed test
|
||||
|
|
@ -592,57 +553,98 @@ public class WithTestNameAttribute : BeforeAfterTestAttribute
|
|||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Opening the trace
|
||||
## Trace Viewer features
|
||||
### Actions
|
||||
|
||||
You can open the saved trace using the Playwright CLI or in your browser on [`trace.playwright.dev`](https://trace.playwright.dev). Make sure to add the full path to where your `trace.zip` file is located.
|
||||
In the Actions tab you can see what locator was used for every action and how long each one took to run. Hover over each action of your test and visually see the change in the DOM snapshot. Go back and forward in time and click an action to inspect and debug. Use the Before and After tabs to visually see what happened before and after the action.
|
||||
|
||||
```bash js
|
||||
npx playwright show-trace path/to/trace.zip
|
||||
```
|
||||

|
||||
|
||||
```bash java
|
||||
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace trace.zip"
|
||||
```
|
||||
**Selecting each action reveals:**
|
||||
- Action snapshots
|
||||
- Action log
|
||||
- Source code location
|
||||
|
||||
```bash python
|
||||
playwright show-trace trace.zip
|
||||
```
|
||||
### Screenshots
|
||||
|
||||
```bash csharp
|
||||
pwsh bin/Debug/netX/playwright.ps1 show-trace trace.zip
|
||||
```
|
||||
When tracing with the [`option: Tracing.start.screenshots`] option turned on (default), each trace records a screencast and renders it as a film strip. You can hover over the film strip to see a magnified image of for each action and state which helps you easily find the action you want to inspect.
|
||||
|
||||
## Using [trace.playwright.dev](https://trace.playwright.dev)
|
||||
Double click on an action to see the time range for that action. You can use the slider in the timeline to increase the actions selected and these will be shown in the Actions tab and all console logs and network logs will be filtered to only show the logs for the actions selected.
|
||||
|
||||
[trace.playwright.dev](https://trace.playwright.dev) is a statically hosted variant of the Trace Viewer. You can upload trace files using drag and drop.
|
||||

|
||||
|
||||
|
||||
<img width="1119" alt="Drop Playwright Trace to load" src="https://user-images.githubusercontent.com/13063165/194577918-b4d45726-2692-4093-8a28-9e73552617ef.png" />
|
||||
### Snapshots
|
||||
|
||||
## Viewing remote traces
|
||||
When tracing with the [`option: Tracing.start.snapshots`] option turned on (default), Playwright captures a set of complete DOM snapshots for each action. Depending on the type of the action, it will capture:
|
||||
|
||||
You can open remote traces using its URL. They could be generated on a CI run which makes it easy to view the remote trace without having to manually download the file.
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
|Before|A snapshot at the time action is called.|
|
||||
|Action|A snapshot at the moment of the performed input. This type of snapshot is especially useful when exploring where exactly Playwright clicked.|
|
||||
|After|A snapshot after the action.|
|
||||
|
||||
```bash js
|
||||
npx playwright show-trace https://example.com/trace.zip
|
||||
```
|
||||
Here is what the typical Action snapshot looks like:
|
||||
|
||||
```bash java
|
||||
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace https://example.com/trace.zip"
|
||||
```
|
||||

|
||||
|
||||
```bash python
|
||||
playwright show-trace https://example.com/trace.zip
|
||||
```
|
||||
Notice how it highlights both, the DOM Node as well as the exact click position.
|
||||
|
||||
```bash csharp
|
||||
pwsh bin/Debug/netX/playwright.ps1 show-trace https://example.com/trace.zip
|
||||
```
|
||||
### Source
|
||||
|
||||
When you click on an action in the sidebar, the line of code for that action is highlighted in the source panel.
|
||||
|
||||

|
||||
|
||||
### Call
|
||||
|
||||
The call tab shows you information about the action such as the time it took, what locator was used, if in strict mode and what key was used.
|
||||
|
||||

|
||||
|
||||
### Log
|
||||
|
||||
See a full log of your test to better understand what Playwright is doing behind the scenes such as scrolling into view, waiting for element to be visible, enabled and stable and performing actions such as click, fill, press etc.
|
||||
|
||||

|
||||
|
||||
### Errors
|
||||
|
||||
If your test fails you will see the error messages for each test in the Errors tab. The timeline will also show a red line highlighting where the error occurred. You can also click on the source tab to see on which line of the source code the error is.
|
||||
|
||||

|
||||
|
||||
### Console
|
||||
|
||||
See console logs from the browser as well as from your test. Different icons are displayed to show you if the console log came from the browser or from the test file.
|
||||
|
||||

|
||||
|
||||
Double click on an action from your test in the actions sidebar. This will filter the console to only show the logs that were made during that action. Click the *Show all* button to see all console logs again.
|
||||
|
||||
Use the timeline to filter actions, by clicking a start point and dragging to an ending point. The console tab will also be filtered to only show the logs that were made during the actions selected.
|
||||
|
||||
|
||||
You can also pass the URL of your uploaded trace (e.g. inside your CI) from some accessible storage as a parameter. CORS (Cross-Origin Resource Sharing) rules might apply.
|
||||
### Network
|
||||
|
||||
```txt
|
||||
https://trace.playwright.dev/?trace=https://demo.playwright.dev/reports/todomvc/data/cb0fa77ebd9487a5c899f3ae65a7ffdbac681182.zip
|
||||
```
|
||||
The Network tab shows you all the network requests that were made during your test. You can sort by different types of requests, status code, method, request, content type, duration and size. Click on a request to see more information about it such as the request headers, response headers, request body and response body.
|
||||
|
||||

|
||||
|
||||
Double click on an action from your test in the actions sidebar. This will filter the network requests to only show the requests that were made during that action. Click the *Show all* button to see all network requests again.
|
||||
|
||||
Use the timeline to filter actions, by clicking a start point and dragging to an ending point. The network tab will also be filtered to only show the network requests that were made during the actions selected.
|
||||
|
||||
### Metadata
|
||||
|
||||
Next to the Actions tab you will find the Metadata tab which will show you more information on your test such as the Browser, viewport size, test duration and more.
|
||||
|
||||

|
||||
|
||||
### Attachments
|
||||
* langs: js
|
||||
|
||||
The "Attachments" tab allows you to explore attachments. If you're doing [visual regression testing](./test-snapshots.md), you'll be able to compare screenshots by examining the image diff, the actual image and the expected image. When you click on the expected image you can use the slider to slide one image over the other so you can easily see the differences in your screenshots.
|
||||
|
||||

|
||||
|
||||
|
|
|
|||
|
|
@ -51,4 +51,4 @@ export function bundle(): Plugin {
|
|||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import './colors.css';
|
|||
import './common.css';
|
||||
import * as icons from './icons';
|
||||
import { clsx } from '@web/uiUtils';
|
||||
import { useAnchor } from './links';
|
||||
import { type AnchorID, useAnchor } from './links';
|
||||
|
||||
export const Chip: React.FC<{
|
||||
header: JSX.Element | string,
|
||||
|
|
@ -53,7 +53,7 @@ export const AutoChip: React.FC<{
|
|||
noInsets?: boolean,
|
||||
children?: any,
|
||||
dataTestId?: string,
|
||||
revealOnAnchorId?: string,
|
||||
revealOnAnchorId?: AnchorID,
|
||||
}> = ({ header, initialExpanded, noInsets, children, dataTestId, revealOnAnchorId }) => {
|
||||
const [expanded, setExpanded] = React.useState(initialExpanded ?? true);
|
||||
const onReveal = React.useCallback(() => setExpanded(true), []);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { TestAttachment } from './types';
|
||||
import type { TestAttachment, TestCase, TestCaseSummary, TestResult, TestResultSummary } from './types';
|
||||
import * as React from 'react';
|
||||
import * as icons from './icons';
|
||||
import { TreeItem } from './treeItem';
|
||||
|
|
@ -68,10 +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 }) => {
|
||||
}> = ({ 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>}
|
||||
|
|
@ -82,7 +84,7 @@ export const AttachmentLink: React.FunctionComponent<{
|
|||
)}
|
||||
</span>} loadChildren={attachment.body ? () => {
|
||||
return [<div key={1} className='attachment-body'><CopyToClipboard value={attachment.body!}/>{linkifyText(attachment.body!)}</div>];
|
||||
} : undefined} depth={0} style={{ lineHeight: '32px' }}></TreeItem>;
|
||||
} : undefined} depth={0} style={{ lineHeight: '32px' }} selected={isAnchored}></TreeItem>;
|
||||
};
|
||||
|
||||
export const SearchParamsContext = React.createContext<URLSearchParams>(new URLSearchParams(window.location.hash.slice(1)));
|
||||
|
|
@ -114,31 +116,48 @@ export function generateTraceUrl(traces: TestAttachment[]) {
|
|||
|
||||
const kMissingContentType = 'x-playwright/missing';
|
||||
|
||||
type AnchorID = string | ((id: string | null) => boolean) | undefined;
|
||||
export type AnchorID = string | string[] | ((id: string) => boolean) | undefined;
|
||||
|
||||
export function useAnchor(id: AnchorID, onReveal: () => void) {
|
||||
const searchParams = React.useContext(SearchParamsContext);
|
||||
const isAnchored = useIsAnchored(id);
|
||||
React.useEffect(() => {
|
||||
if (typeof id === 'undefined')
|
||||
return;
|
||||
if (isAnchored)
|
||||
onReveal();
|
||||
}, [isAnchored, onReveal, searchParams]);
|
||||
}
|
||||
|
||||
const listener = () => {
|
||||
const params = new URLSearchParams(window.location.hash.slice(1));
|
||||
const anchor = params.get('anchor');
|
||||
const isRevealed = typeof id === 'function' ? id(anchor) : anchor === id;
|
||||
if (isRevealed)
|
||||
onReveal();
|
||||
};
|
||||
window.addEventListener('popstate', listener);
|
||||
return () => window.removeEventListener('popstate', listener);
|
||||
}, [id, onReveal]);
|
||||
export function useIsAnchored(id: AnchorID) {
|
||||
const searchParams = React.useContext(SearchParamsContext);
|
||||
const anchor = searchParams.get('anchor');
|
||||
if (anchor === null)
|
||||
return false;
|
||||
if (typeof id === 'undefined')
|
||||
return false;
|
||||
if (typeof id === 'string')
|
||||
return id === anchor;
|
||||
if (Array.isArray(id))
|
||||
return id.includes(anchor);
|
||||
return id(anchor);
|
||||
}
|
||||
|
||||
export function Anchor({ id, children }: React.PropsWithChildren<{ id: AnchorID }>) {
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const onAnchorReveal = React.useCallback(() => {
|
||||
requestAnimationFrame(() => ref.current?.scrollIntoView({ block: 'start', inline: 'start' }));
|
||||
ref.current?.scrollIntoView({ block: 'start', inline: 'start' });
|
||||
}, []);
|
||||
useAnchor(id, onAnchorReveal);
|
||||
|
||||
return <div ref={ref}>{children}</div>;
|
||||
}
|
||||
|
||||
export function testResultHref({ test, result, anchor }: { test?: TestCase | TestCaseSummary, result?: TestResult | TestResultSummary, anchor?: string }) {
|
||||
const params = new URLSearchParams();
|
||||
if (test)
|
||||
params.set('testId', test.testId);
|
||||
if (test && result)
|
||||
params.set('run', '' + test.results.indexOf(result as any));
|
||||
if (anchor)
|
||||
params.set('anchor', anchor);
|
||||
return `#?` + params;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import * as React from 'react';
|
|||
import { TabbedPane } from './tabbedPane';
|
||||
import { AutoChip } from './chip';
|
||||
import './common.css';
|
||||
import { Link, ProjectLink, SearchParamsContext } from './links';
|
||||
import { Link, ProjectLink, SearchParamsContext, testResultHref } from './links';
|
||||
import { statusIcon } from './statusIcon';
|
||||
import './testCaseView.css';
|
||||
import { TestResultView } from './testResultView';
|
||||
|
|
@ -53,9 +53,9 @@ export const TestCaseView: React.FC<{
|
|||
{test && <div className='hbox'>
|
||||
<div className='test-case-path'>{test.path.join(' › ')}</div>
|
||||
<div style={{ flex: 'auto' }}></div>
|
||||
<div className={clsx(!prev && 'hidden')}><Link href={`#?testId=${prev?.testId}${filterParam}`}>« previous</Link></div>
|
||||
<div className={clsx(!prev && 'hidden')}><Link href={testResultHref({ test: prev }) + filterParam}>« previous</Link></div>
|
||||
<div style={{ width: 10 }}></div>
|
||||
<div className={clsx(!next && 'hidden')}><Link href={`#?testId=${next?.testId}${filterParam}`}>next »</Link></div>
|
||||
<div className={clsx(!next && 'hidden')}><Link href={testResultHref({ test: next }) + filterParam}>next »</Link></div>
|
||||
</div>}
|
||||
{test && <div className='test-case-title'>{test?.title}</div>}
|
||||
{test && <div className='hbox'>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import * as React from 'react';
|
|||
import { hashStringToInt, msToString } from './utils';
|
||||
import { Chip } from './chip';
|
||||
import { filterWithToken } from './filter';
|
||||
import { generateTraceUrl, Link, navigate, ProjectLink, SearchParamsContext } from './links';
|
||||
import { generateTraceUrl, Link, navigate, ProjectLink, SearchParamsContext, testResultHref } from './links';
|
||||
import { statusIcon } from './statusIcon';
|
||||
import './testFileView.css';
|
||||
import { video, image, trace } from './icons';
|
||||
|
|
@ -48,7 +48,7 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
|
|||
{statusIcon(test.outcome)}
|
||||
</span>
|
||||
<span>
|
||||
<Link href={`#?testId=${test.testId}${filterParam}`} title={[...test.path, test.title].join(' › ')}>
|
||||
<Link href={testResultHref({ test }) + filterParam} title={[...test.path, test.title].join(' › ')}>
|
||||
<span className='test-file-title'>{[...test.path, test.title].join(' › ')}</span>
|
||||
</Link>
|
||||
{projectNames.length > 1 && !!test.projectName &&
|
||||
|
|
@ -59,7 +59,7 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
|
|||
<span data-testid='test-duration' style={{ minWidth: '50px', textAlign: 'right' }}>{msToString(test.duration)}</span>
|
||||
</div>
|
||||
<div className='test-file-details-row'>
|
||||
<Link href={`#?testId=${test.testId}`} title={[...test.path, test.title].join(' › ')} className='test-file-path-link'>
|
||||
<Link href={testResultHref({ test })} title={[...test.path, test.title].join(' › ')} className='test-file-path-link'>
|
||||
<span className='test-file-path'>{test.location.file}:{test.location.line}</span>
|
||||
</Link>
|
||||
{imageDiffBadge(test)}
|
||||
|
|
@ -72,15 +72,17 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
|
|||
};
|
||||
|
||||
function imageDiffBadge(test: TestCaseSummary): JSX.Element | undefined {
|
||||
const resultWithImageDiff = test.results.find(result => result.attachments.some(attachment => {
|
||||
return attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/);
|
||||
}));
|
||||
return resultWithImageDiff ? <Link href={`#?testId=${test.testId}&anchor=diff-0&run=${test.results.indexOf(resultWithImageDiff)}`} title='View images' className='test-file-badge'>{image()}</Link> : 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-${result.attachments.indexOf(attachment)}` })} title='View images' className='test-file-badge'>{image()}</Link>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function videoBadge(test: TestCaseSummary): JSX.Element | undefined {
|
||||
const resultWithVideo = test.results.find(result => result.attachments.some(attachment => attachment.name === 'video'));
|
||||
return resultWithVideo ? <Link href={`#?testId=${test.testId}&anchor=videos&run=${test.results.indexOf(resultWithVideo)}`} title='View video' className='test-file-badge'>{video()}</Link> : undefined;
|
||||
return resultWithVideo ? <Link href={testResultHref({ test, result: resultWithVideo, anchor: 'attachment-video' })} title='View video' className='test-file-badge'>{video()}</Link> : undefined;
|
||||
}
|
||||
|
||||
function traceBadge(test: TestCaseSummary): JSX.Element | undefined {
|
||||
|
|
|
|||
|
|
@ -20,15 +20,20 @@ import { TreeItem } from './treeItem';
|
|||
import { msToString } from './utils';
|
||||
import { AutoChip } from './chip';
|
||||
import { traceImage } from './images';
|
||||
import { Anchor, AttachmentLink, generateTraceUrl } from './links';
|
||||
import { Anchor, AttachmentLink, generateTraceUrl, testResultHref } from './links';
|
||||
import { statusIcon } from './statusIcon';
|
||||
import type { ImageDiff } from '@web/shared/imageDiffView';
|
||||
import { ImageDiffView } from '@web/shared/imageDiffView';
|
||||
import { TestErrorView, TestScreenshotErrorView } from './testErrorView';
|
||||
import * as icons from './icons';
|
||||
import './testResultView.css';
|
||||
|
||||
function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiff[] {
|
||||
const snapshotNameToImageDiff = new Map<string, ImageDiff>();
|
||||
interface ImageDiffWithAnchors extends ImageDiff {
|
||||
anchors: string[];
|
||||
}
|
||||
|
||||
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)(\.[^.]+)?$/);
|
||||
if (!match)
|
||||
|
|
@ -37,9 +42,10 @@ function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiff[] {
|
|||
const snapshotName = name + extension;
|
||||
let imageDiff = snapshotNameToImageDiff.get(snapshotName);
|
||||
if (!imageDiff) {
|
||||
imageDiff = { name: snapshotName };
|
||||
imageDiff = { name: snapshotName, anchors: [`attachment-${name}`] };
|
||||
snapshotNameToImageDiff.set(snapshotName, imageDiff);
|
||||
}
|
||||
imageDiff.anchors.push(`attachment-${result.attachments.indexOf(attachment)}`);
|
||||
if (category === 'actual')
|
||||
imageDiff.actual = { attachment };
|
||||
if (category === 'expected')
|
||||
|
|
@ -64,18 +70,19 @@ function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiff[] {
|
|||
export const TestResultView: React.FC<{
|
||||
test: TestCase,
|
||||
result: TestResult,
|
||||
}> = ({ result }) => {
|
||||
const { screenshots, videos, traces, otherAttachments, diffs, errors, htmls } = React.useMemo(() => {
|
||||
const attachments = result?.attachments || [];
|
||||
}> = ({ test, result }) => {
|
||||
const { screenshots, videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors } = React.useMemo(() => {
|
||||
const attachments = result.attachments;
|
||||
const screenshots = new Set(attachments.filter(a => a.contentType.startsWith('image/')));
|
||||
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 htmls = attachments.filter(a => a.contentType.startsWith('text/html'));
|
||||
const otherAttachments = new Set<TestAttachment>(attachments);
|
||||
[...screenshots, ...videos, ...traces, ...htmls].forEach(a => otherAttachments.delete(a));
|
||||
const diffs = groupImageDiffs(screenshots);
|
||||
[...screenshots, ...videos, ...traces].forEach(a => otherAttachments.delete(a));
|
||||
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, htmls };
|
||||
return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors };
|
||||
}, [result]);
|
||||
|
||||
return <div className='test-result'>
|
||||
|
|
@ -87,51 +94,52 @@ export const TestResultView: React.FC<{
|
|||
})}
|
||||
</AutoChip>}
|
||||
{!!result.steps.length && <AutoChip header='Test Steps'>
|
||||
{result.steps.map((step, i) => <StepTreeItem key={`step-${i}`} step={step} depth={0}></StepTreeItem>)}
|
||||
{result.steps.map((step, i) => <StepTreeItem key={`step-${i}`} step={step} result={result} test={test} depth={0}/>)}
|
||||
</AutoChip>}
|
||||
|
||||
{diffs.map((diff, index) =>
|
||||
<Anchor key={`diff-${index}`} id={`diff-${index}`}>
|
||||
<AutoChip dataTestId='test-results-image-diff' header={`Image mismatch: ${diff.name}`} revealOnAnchorId={`diff-${index}`}>
|
||||
<Anchor key={`diff-${index}`} id={diff.anchors}>
|
||||
<AutoChip dataTestId='test-results-image-diff' header={`Image mismatch: ${diff.name}`} revealOnAnchorId={diff.anchors}>
|
||||
<ImageDiffView diff={diff}/>
|
||||
</AutoChip>
|
||||
</Anchor>
|
||||
)}
|
||||
|
||||
{!!screenshots.length && <AutoChip header='Screenshots'>
|
||||
{!!screenshots.length && <AutoChip header='Screenshots' revealOnAnchorId={screenshotAnchors}>
|
||||
{screenshots.map((a, i) => {
|
||||
return <div key={`screenshot-${i}`}>
|
||||
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>
|
||||
</div>;
|
||||
<AttachmentLink attachment={a} result={result}></AttachmentLink>
|
||||
</Anchor>;
|
||||
})}
|
||||
</AutoChip>}
|
||||
|
||||
{!!traces.length && <Anchor id='traces'><AutoChip header='Traces' revealOnAnchorId='traces'>
|
||||
{!!traces.length && <Anchor id='attachment-trace'><AutoChip header='Traces' revealOnAnchorId='attachment-trace'>
|
||||
{<div>
|
||||
<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>}
|
||||
|
||||
{!!videos.length && <Anchor id='videos'><AutoChip header='Videos' revealOnAnchorId='videos'>
|
||||
{!!videos.length && <Anchor id='attachment-video'><AutoChip header='Videos' revealOnAnchorId='attachment-video'>
|
||||
{videos.map((a, i) => <div key={`video-${i}`}>
|
||||
<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 + htmls.length) && <AutoChip header='Attachments'>
|
||||
{[...htmls].map((a, i) => (
|
||||
<AttachmentLink key={`html-link-${i}`} attachment={a} openInNewTab />)
|
||||
{!!otherAttachments.size && <AutoChip header='Attachments' revealOnAnchorId={otherAttachmentAnchors}>
|
||||
{[...otherAttachments].map((a, i) =>
|
||||
<Anchor key={`attachment-link-${i}`} id={`attachment-${result.attachments.indexOf(a)}`}>
|
||||
<AttachmentLink attachment={a} result={result} openInNewTab={a.contentType.startsWith('text/html')} />
|
||||
</Anchor>
|
||||
)}
|
||||
{[...otherAttachments].map((a, i) => <AttachmentLink key={`attachment-link-${i}`} attachment={a}></AttachmentLink>)}
|
||||
</AutoChip>}
|
||||
</div>;
|
||||
};
|
||||
|
|
@ -161,19 +169,22 @@ function classifyErrors(testErrors: string[], diffs: ImageDiff[]) {
|
|||
}
|
||||
|
||||
const StepTreeItem: React.FC<{
|
||||
test: TestCase;
|
||||
result: TestResult;
|
||||
step: TestStep;
|
||||
depth: number,
|
||||
}> = ({ step, depth }) => {
|
||||
return <TreeItem title={<span>
|
||||
}> = ({ test, step, result, depth }) => {
|
||||
return <TreeItem title={<span aria-label={step.title}>
|
||||
<span style={{ float: 'right' }}>{msToString(step.duration)}</span>
|
||||
{step.attachments.length > 0 && <a style={{ float: 'right' }} title='link to attachment' href={testResultHref({ test, result, anchor: `attachment-${step.attachments[0]}` })} 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}></StepTreeItem>);
|
||||
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}></TestErrorView>);
|
||||
children.unshift(<TestErrorView testId='test-snippet' key='line' error={step.snippet}/>);
|
||||
return children;
|
||||
} : undefined} depth={depth}></TreeItem>;
|
||||
} : undefined} depth={depth}/>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tree-item-title.selected {
|
||||
text-decoration: underline var(--color-underlinenav-icon);
|
||||
text-decoration-thickness: 1.5px;
|
||||
}
|
||||
|
||||
.tree-item-body {
|
||||
min-height: 18px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
import * as React from 'react';
|
||||
import './treeItem.css';
|
||||
import * as icons from './icons';
|
||||
import { clsx } from '@web/uiUtils';
|
||||
|
||||
export const TreeItem: React.FunctionComponent<{
|
||||
title: JSX.Element,
|
||||
|
|
@ -28,9 +29,8 @@ export const TreeItem: React.FunctionComponent<{
|
|||
style?: React.CSSProperties,
|
||||
}> = ({ title, loadChildren, onClick, expandByDefault, depth, selected, style }) => {
|
||||
const [expanded, setExpanded] = React.useState(expandByDefault || false);
|
||||
const className = selected ? 'tree-item-title selected' : 'tree-item-title';
|
||||
return <div className={'tree-item'} style={style}>
|
||||
<span className={className} style={{ whiteSpace: 'nowrap', paddingLeft: depth * 22 + 4 }} onClick={() => { onClick?.(); setExpanded(!expanded); }} >
|
||||
<span className={clsx('tree-item-title', selected && 'selected')} style={{ whiteSpace: 'nowrap', paddingLeft: depth * 22 + 4 }} onClick={() => { onClick?.(); setExpanded(!expanded); }} >
|
||||
{loadChildren && !!expanded && icons.downArrow()}
|
||||
{loadChildren && !expanded && icons.rightArrow()}
|
||||
{!loadChildren && <span style={{ visibility: 'hidden' }}>{icons.rightArrow()}</span>}
|
||||
|
|
|
|||
|
|
@ -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,15 +3,15 @@
|
|||
"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": "1286",
|
||||
"revision": "1291",
|
||||
"installByDefault": false,
|
||||
"browserVersion": "133.0.6891.0"
|
||||
"browserVersion": "133.0.6929.0"
|
||||
},
|
||||
{
|
||||
"name": "firefox",
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
},
|
||||
{
|
||||
"name": "webkit",
|
||||
"revision": "2119",
|
||||
"revision": "2122",
|
||||
"installByDefault": true,
|
||||
"revisionOverrides": {
|
||||
"debian11-x64": "2105",
|
||||
|
|
@ -45,13 +45,18 @@
|
|||
},
|
||||
{
|
||||
"name": "ffmpeg",
|
||||
"revision": "1010",
|
||||
"revision": "1011",
|
||||
"installByDefault": true,
|
||||
"revisionOverrides": {
|
||||
"mac12": "1010",
|
||||
"mac12-arm64": "1010"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -187,4 +187,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({}));
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { debugLogger } from '../utils/debugLogger';
|
|||
export type ClientType = 'controller' | 'launch-browser' | 'reuse-browser' | 'pre-launched-browser-or-android';
|
||||
|
||||
type Options = {
|
||||
allowFSPaths: boolean,
|
||||
socksProxyPattern: string | undefined,
|
||||
browserName: string | null,
|
||||
launchOptions: LaunchOptions,
|
||||
|
|
@ -60,7 +61,7 @@ export class PlaywrightConnection {
|
|||
this._ws = ws;
|
||||
this._preLaunched = preLaunched;
|
||||
this._options = options;
|
||||
options.launchOptions = filterLaunchOptions(options.launchOptions);
|
||||
options.launchOptions = filterLaunchOptions(options.launchOptions, options.allowFSPaths);
|
||||
if (clientType === 'reuse-browser' || clientType === 'pre-launched-browser-or-android')
|
||||
assert(preLaunched.playwright);
|
||||
if (clientType === 'pre-launched-browser-or-android')
|
||||
|
|
@ -284,7 +285,7 @@ function launchOptionsHash(options: LaunchOptions) {
|
|||
return JSON.stringify(copy);
|
||||
}
|
||||
|
||||
function filterLaunchOptions(options: LaunchOptions): LaunchOptions {
|
||||
function filterLaunchOptions(options: LaunchOptions, allowFSPaths: boolean): LaunchOptions {
|
||||
return {
|
||||
channel: options.channel,
|
||||
args: options.args,
|
||||
|
|
@ -296,7 +297,8 @@ function filterLaunchOptions(options: LaunchOptions): LaunchOptions {
|
|||
chromiumSandbox: options.chromiumSandbox,
|
||||
firefoxUserPrefs: options.firefoxUserPrefs,
|
||||
slowMo: options.slowMo,
|
||||
executablePath: isUnderTest() ? options.executablePath : undefined,
|
||||
executablePath: (isUnderTest() || allowFSPaths) ? options.executablePath : undefined,
|
||||
downloadsPath: allowFSPaths ? options.downloadsPath : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export class PlaywrightServer {
|
|||
return new PlaywrightConnection(
|
||||
semaphore.acquire(),
|
||||
clientType, ws,
|
||||
{ socksProxyPattern: proxyValue, browserName, launchOptions },
|
||||
{ socksProxyPattern: proxyValue, browserName, launchOptions, allowFSPaths: this._options.mode === 'extension' },
|
||||
{
|
||||
playwright: this._preLaunchedPlaywright,
|
||||
browser: this._options.preLaunchedBrowser,
|
||||
|
|
|
|||
|
|
@ -524,5 +524,3 @@ class ClankBrowserProcess implements BrowserProcess {
|
|||
await this._browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { Browser } from '../browser';
|
|||
import { assertBrowserContextIsNotOwned, BrowserContext } from '../browserContext';
|
||||
import type { SdkObject } from '../instrumentation';
|
||||
import * as network from '../network';
|
||||
import type { InitScript, Page, PageDelegate } from '../page';
|
||||
import type { InitScript, Page } from '../page';
|
||||
import type { ConnectionTransport } from '../transport';
|
||||
import type * as types from '../types';
|
||||
import type { BidiSession } from './bidiConnection';
|
||||
|
|
@ -99,8 +99,8 @@ export class BidiBrowser extends Browser {
|
|||
browser._defaultContext = new BidiBrowserContext(browser, undefined, options.persistent);
|
||||
await (browser._defaultContext as BidiBrowserContext)._initialize();
|
||||
// Create default page as we cannot get access to the existing one.
|
||||
const pageDelegate = await browser._defaultContext.newPageDelegate();
|
||||
await pageDelegate.pageOrError();
|
||||
const page = await browser._defaultContext.doCreateNewPage();
|
||||
await page.waitForInitializedOrError();
|
||||
}
|
||||
return browser;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -207,21 +211,17 @@ export class BidiBrowserContext extends BrowserContext {
|
|||
return [...this._browser._bidiPages.values()].filter(bidiPage => bidiPage._browserContext === this);
|
||||
}
|
||||
|
||||
pages(): Page[] {
|
||||
return this._bidiPages().map(bidiPage => bidiPage._initializedPage).filter(Boolean) as Page[];
|
||||
override possiblyUninitializedPages(): Page[] {
|
||||
return this._bidiPages().map(bidiPage => bidiPage._page);
|
||||
}
|
||||
|
||||
pagesOrErrors() {
|
||||
return this._bidiPages().map(bidiPage => bidiPage.pageOrError());
|
||||
}
|
||||
|
||||
async newPageDelegate(): Promise<PageDelegate> {
|
||||
override async doCreateNewPage(): Promise<Page> {
|
||||
assertBrowserContextIsNotOwned(this);
|
||||
const { context } = await this._browser._browserSession.send('browsingContext.create', {
|
||||
type: bidi.BrowsingContext.CreateType.Window,
|
||||
userContext: this._browserContextId,
|
||||
});
|
||||
return this._browser._bidiPages.get(context)!;
|
||||
return this._browser._bidiPages.get(context)!._page;
|
||||
}
|
||||
|
||||
async doGetCookies(urls: string[]): Promise<channels.NetworkCookie[]> {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ export class BidiPage implements PageDelegate {
|
|||
readonly rawKeyboard: RawKeyboardImpl;
|
||||
readonly rawTouchscreen: RawTouchscreenImpl;
|
||||
readonly _page: Page;
|
||||
private readonly _pagePromise: Promise<Page | Error>;
|
||||
readonly _session: BidiSession;
|
||||
readonly _opener: BidiPage | null;
|
||||
private readonly _realmToContext: Map<string, dom.FrameExecutionContext>;
|
||||
|
|
@ -51,7 +50,6 @@ export class BidiPage implements PageDelegate {
|
|||
readonly _browserContext: BidiBrowserContext;
|
||||
readonly _networkManager: BidiNetworkManager;
|
||||
private readonly _pdf: BidiPDF;
|
||||
_initializedPage: Page | null = null;
|
||||
private _initScriptIds: string[] = [];
|
||||
|
||||
constructor(browserContext: BidiBrowserContext, bidiSession: BidiSession, opener: BidiPage | null) {
|
||||
|
|
@ -81,16 +79,10 @@ export class BidiPage implements PageDelegate {
|
|||
];
|
||||
|
||||
// Initialize main frame.
|
||||
this._pagePromise = this._initialize().finally(async () => {
|
||||
await this._page.initOpener(this._opener);
|
||||
}).then(() => {
|
||||
this._initializedPage = this._page;
|
||||
this._page.reportAsNew();
|
||||
return this._page;
|
||||
}).catch(e => {
|
||||
this._page.reportAsNew(e);
|
||||
return e;
|
||||
});
|
||||
// TODO: Wait for first execution context to be created and maybe about:blank navigated.
|
||||
this._initialize().then(
|
||||
() => this._page.reportAsNew(this._opener?._page),
|
||||
error => this._page.reportAsNew(this._opener?._page, error));
|
||||
}
|
||||
|
||||
private async _initialize() {
|
||||
|
|
@ -109,21 +101,12 @@ export class BidiPage implements PageDelegate {
|
|||
return Promise.all(this._page.allInitScripts().map(initScript => this.addInitScript(initScript)));
|
||||
}
|
||||
|
||||
potentiallyUninitializedPage(): Page {
|
||||
return this._page;
|
||||
}
|
||||
|
||||
didClose() {
|
||||
this._session.dispose();
|
||||
eventsHelper.removeEventListeners(this._sessionListeners);
|
||||
this._page._didClose();
|
||||
}
|
||||
|
||||
async pageOrError(): Promise<Page | Error> {
|
||||
// TODO: Wait for first execution context to be created and maybe about:blank navigated.
|
||||
return this._pagePromise;
|
||||
}
|
||||
|
||||
private _onFrameAttached(frameId: string, parentFrameId: string | null): frames.Frame {
|
||||
return this._page._frameManager.frameAttached(frameId, parentFrameId);
|
||||
}
|
||||
|
|
@ -372,7 +355,7 @@ export class BidiPage implements PageDelegate {
|
|||
private async _onScriptMessage(event: bidi.Script.MessageParameters) {
|
||||
if (event.channel !== kPlaywrightBindingChannel)
|
||||
return;
|
||||
const pageOrError = await this.pageOrError();
|
||||
const pageOrError = await this._page.waitForInitializedOrError();
|
||||
if (pageOrError instanceof Error)
|
||||
return;
|
||||
const context = this._realmToContext.get(event.source.realm);
|
||||
|
|
@ -416,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: {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import type * as frames from './frames';
|
|||
import { helper } from './helper';
|
||||
import * as network from './network';
|
||||
import { InitScript } from './page';
|
||||
import type { PageDelegate } from './page';
|
||||
import { Page, PageBinding } from './page';
|
||||
import type { Progress, ProgressController } from './progress';
|
||||
import type { Selectors } from './selectors';
|
||||
|
|
@ -257,10 +256,13 @@ export abstract class BrowserContext extends SdkObject {
|
|||
this.emit(BrowserContext.Events.Close);
|
||||
}
|
||||
|
||||
pages(): Page[] {
|
||||
return this.possiblyUninitializedPages().filter(page => page.initializedOrUndefined());
|
||||
}
|
||||
|
||||
// BrowserContext methods.
|
||||
abstract pages(): Page[];
|
||||
abstract pagesOrErrors(): Promise<Page | Error>[];
|
||||
abstract newPageDelegate(): Promise<PageDelegate>;
|
||||
abstract possiblyUninitializedPages(): Page[];
|
||||
abstract doCreateNewPage(): Promise<Page>;
|
||||
abstract addCookies(cookies: channels.SetNetworkCookie[]): Promise<void>;
|
||||
abstract setGeolocation(geolocation?: types.Geolocation): Promise<void>;
|
||||
abstract setExtraHTTPHeaders(headers: types.HeadersArray): Promise<void>;
|
||||
|
|
@ -312,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`);
|
||||
|
|
@ -359,38 +365,34 @@ export abstract class BrowserContext extends SdkObject {
|
|||
this._timeoutSettings.setDefaultTimeout(timeout);
|
||||
}
|
||||
|
||||
async _loadDefaultContextAsIs(progress: Progress): Promise<Page> {
|
||||
let pageOrError;
|
||||
if (!this.pagesOrErrors().length) {
|
||||
async _loadDefaultContextAsIs(progress: Progress): Promise<Page | undefined> {
|
||||
if (!this.possiblyUninitializedPages().length) {
|
||||
const waitForEvent = helper.waitForEvent(progress, this, BrowserContext.Events.Page);
|
||||
progress.cleanupWhenAborted(() => waitForEvent.dispose);
|
||||
// Race against BrowserContext.close
|
||||
pageOrError = await Promise.race([
|
||||
waitForEvent.promise as Promise<Page>,
|
||||
this._closePromise,
|
||||
]);
|
||||
// Consider Page initialization errors
|
||||
if (pageOrError instanceof Page)
|
||||
pageOrError = await pageOrError._delegate.pageOrError();
|
||||
} else {
|
||||
pageOrError = await this.pagesOrErrors()[0];
|
||||
await Promise.race([waitForEvent.promise, this._closePromise]);
|
||||
}
|
||||
const page = this.possiblyUninitializedPages()[0];
|
||||
if (!page)
|
||||
return;
|
||||
const pageOrError = await page.waitForInitializedOrError();
|
||||
if (pageOrError instanceof Error)
|
||||
throw pageOrError;
|
||||
await pageOrError.mainFrame()._waitForLoadState(progress, 'load');
|
||||
return pageOrError;
|
||||
await page.mainFrame()._waitForLoadState(progress, 'load');
|
||||
return page;
|
||||
}
|
||||
|
||||
async _loadDefaultContext(progress: Progress) {
|
||||
const defaultPage = await this._loadDefaultContextAsIs(progress);
|
||||
if (!defaultPage)
|
||||
return;
|
||||
const browserName = this._browser.options.name;
|
||||
if ((this._options.isMobile && browserName === 'chromium') || (this._options.locale && browserName === 'webkit')) {
|
||||
// Workaround for:
|
||||
// - chromium fails to change isMobile for existing page;
|
||||
// - webkit fails to change locale for existing page.
|
||||
const oldPage = defaultPage;
|
||||
await this.newPage(progress.metadata);
|
||||
await oldPage.close(progress.metadata);
|
||||
await defaultPage.close(progress.metadata);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -416,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);
|
||||
}
|
||||
|
|
@ -488,10 +490,10 @@ export abstract class BrowserContext extends SdkObject {
|
|||
}
|
||||
|
||||
async newPage(metadata: CallMetadata): Promise<Page> {
|
||||
const pageDelegate = await this.newPageDelegate();
|
||||
const page = await this.doCreateNewPage();
|
||||
if (metadata.isServerSide)
|
||||
pageDelegate.potentiallyUninitializedPage().markAsServerSideOnly();
|
||||
const pageOrError = await pageDelegate.pageOrError();
|
||||
page.markAsServerSideOnly();
|
||||
const pageOrError = await page.waitForInitializedOrError();
|
||||
if (pageOrError instanceof Page) {
|
||||
if (pageOrError.isClosed())
|
||||
throw new Error('Page has been closed.');
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { Browser } from '../browser';
|
|||
import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext';
|
||||
import { assert, createGuid } from '../../utils';
|
||||
import * as network from '../network';
|
||||
import type { InitScript, PageDelegate, Worker } from '../page';
|
||||
import type { InitScript, Worker } from '../page';
|
||||
import { Page } from '../page';
|
||||
import { Frame } from '../frames';
|
||||
import type { Dialog } from '../dialog';
|
||||
|
|
@ -146,7 +146,7 @@ export class CRBrowser extends Browser {
|
|||
}
|
||||
|
||||
async _waitForAllPagesToBeInitialized() {
|
||||
await Promise.all([...this._crPages.values()].map(page => page.pageOrError()));
|
||||
await Promise.all([...this._crPages.values()].map(crPage => crPage._page.waitForInitializedOrError()));
|
||||
}
|
||||
|
||||
_onAttachedToTarget({ targetInfo, sessionId, waitingForDebugger }: Protocol.Target.attachedToTargetPayload) {
|
||||
|
|
@ -259,10 +259,10 @@ export class CRBrowser extends Browser {
|
|||
}
|
||||
page.willBeginDownload();
|
||||
|
||||
let originPage = page._initializedPage;
|
||||
let originPage = page._page.initializedOrUndefined();
|
||||
// If it's a new window download, report it on the opener page.
|
||||
if (!originPage && page._opener)
|
||||
originPage = page._opener._initializedPage;
|
||||
originPage = page._opener._page.initializedOrUndefined();
|
||||
if (!originPage)
|
||||
return;
|
||||
this._downloadCreated(originPage, payload.guid, payload.url, payload.suggestedFilename);
|
||||
|
|
@ -364,15 +364,11 @@ export class CRBrowserContext extends BrowserContext {
|
|||
return [...this._browser._crPages.values()].filter(crPage => crPage._browserContext === this);
|
||||
}
|
||||
|
||||
pages(): Page[] {
|
||||
return this._crPages().map(crPage => crPage._initializedPage).filter(Boolean) as Page[];
|
||||
override possiblyUninitializedPages(): Page[] {
|
||||
return this._crPages().map(crPage => crPage._page);
|
||||
}
|
||||
|
||||
pagesOrErrors() {
|
||||
return this._crPages().map(crPage => crPage.pageOrError());
|
||||
}
|
||||
|
||||
async newPageDelegate(): Promise<PageDelegate> {
|
||||
override async doCreateNewPage(): Promise<Page> {
|
||||
assertBrowserContextIsNotOwned(this);
|
||||
|
||||
const oldKeys = this._browser.isClank() ? new Set(this._browser._crPages.keys()) : undefined;
|
||||
|
|
@ -395,7 +391,7 @@ export class CRBrowserContext extends BrowserContext {
|
|||
assert(newKeys.size === 1);
|
||||
[targetId] = [...newKeys];
|
||||
}
|
||||
return this._browser._crPages.get(targetId)!;
|
||||
return this._browser._crPages.get(targetId)!._page;
|
||||
}
|
||||
|
||||
async doGetCookies(urls: string[]): Promise<channels.NetworkCookie[]> {
|
||||
|
|
@ -548,7 +544,7 @@ export class CRBrowserContext extends BrowserContext {
|
|||
// When persistent context is closed, we do not necessary get Target.detachedFromTarget
|
||||
// for all the background pages.
|
||||
for (const [targetId, backgroundPage] of this._browser._backgroundPages.entries()) {
|
||||
if (backgroundPage._browserContext === this && backgroundPage._initializedPage) {
|
||||
if (backgroundPage._browserContext === this && backgroundPage._page.initializedOrUndefined()) {
|
||||
backgroundPage.didClose();
|
||||
this._browser._backgroundPages.delete(targetId);
|
||||
}
|
||||
|
|
@ -573,8 +569,8 @@ export class CRBrowserContext extends BrowserContext {
|
|||
backgroundPages(): Page[] {
|
||||
const result: Page[] = [];
|
||||
for (const backgroundPage of this._browser._backgroundPages.values()) {
|
||||
if (backgroundPage._browserContext === this && backgroundPage._initializedPage)
|
||||
result.push(backgroundPage._initializedPage);
|
||||
if (backgroundPage._browserContext === this && backgroundPage._page.initializedOrUndefined())
|
||||
result.push(backgroundPage._page);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
|
|||
|
||||
function rewriteError(error: Error): Protocol.Runtime.evaluateReturnValue {
|
||||
if (error.message.includes('Object reference chain is too long'))
|
||||
return { result: { type: 'undefined' } };
|
||||
throw new Error('Cannot serialize result: object reference chain is too long.');
|
||||
if (error.message.includes('Object couldn\'t be returned by value'))
|
||||
return { result: { type: 'undefined' } };
|
||||
|
||||
|
|
|
|||
|
|
@ -65,8 +65,6 @@ export class CRPage implements PageDelegate {
|
|||
private readonly _pdf: CRPDF;
|
||||
private readonly _coverage: CRCoverage;
|
||||
readonly _browserContext: CRBrowserContext;
|
||||
private readonly _pagePromise: Promise<Page | Error>;
|
||||
_initializedPage: Page | null = null;
|
||||
private _isBackgroundPage: boolean;
|
||||
|
||||
// Holds window features for the next popup being opened via window.open,
|
||||
|
|
@ -108,30 +106,11 @@ export class CRPage implements PageDelegate {
|
|||
if (viewportSize)
|
||||
this._page._emulatedSize = { viewport: viewportSize, screen: viewportSize };
|
||||
}
|
||||
// Note: it is important to call |reportAsNew| before resolving pageOrError promise,
|
||||
// so that anyone who awaits pageOrError got a ready and reported page.
|
||||
this._pagePromise = this._mainFrameSession._initialize(bits.hasUIWindow).then(async r => {
|
||||
await this._page.initOpener(this._opener);
|
||||
return r;
|
||||
}).catch(async e => {
|
||||
await this._page.initOpener(this._opener);
|
||||
throw e;
|
||||
}).then(() => {
|
||||
this._initializedPage = this._page;
|
||||
this._reportAsNew();
|
||||
return this._page;
|
||||
}).catch(e => {
|
||||
this._reportAsNew(e);
|
||||
return e;
|
||||
});
|
||||
}
|
||||
|
||||
potentiallyUninitializedPage(): Page {
|
||||
return this._page;
|
||||
}
|
||||
|
||||
private _reportAsNew(error?: Error) {
|
||||
this._page.reportAsNew(error, this._isBackgroundPage ? CRBrowserContext.CREvents.BackgroundPage : BrowserContext.Events.Page);
|
||||
const createdEvent = this._isBackgroundPage ? CRBrowserContext.CREvents.BackgroundPage : BrowserContext.Events.Page;
|
||||
this._mainFrameSession._initialize(bits.hasUIWindow).then(
|
||||
() => this._page.reportAsNew(this._opener?._page, undefined, createdEvent),
|
||||
error => this._page.reportAsNew(this._opener?._page, error, createdEvent));
|
||||
}
|
||||
|
||||
private async _forAllFrameSessions(cb: (frame: FrameSession) => Promise<any>) {
|
||||
|
|
@ -168,10 +147,6 @@ export class CRPage implements PageDelegate {
|
|||
this._mainFrameSession._willBeginDownload();
|
||||
}
|
||||
|
||||
async pageOrError(): Promise<Page | Error> {
|
||||
return this._pagePromise;
|
||||
}
|
||||
|
||||
didClose() {
|
||||
for (const session of this._sessions.values())
|
||||
session.dispose();
|
||||
|
|
@ -492,7 +467,7 @@ class FrameSession {
|
|||
// Note: it is important to start video recorder before sending Page.startScreencast,
|
||||
// and it is equally important to send Page.startScreencast before sending Runtime.runIfWaitingForDebugger.
|
||||
await this._createVideoRecorder(screencastId, screencastOptions);
|
||||
this._crPage.pageOrError().then(p => {
|
||||
this._crPage._page.waitForInitializedOrError().then(p => {
|
||||
if (p instanceof Error)
|
||||
this._stopVideoRecording().catch(() => {});
|
||||
});
|
||||
|
|
@ -833,7 +808,7 @@ class FrameSession {
|
|||
}
|
||||
|
||||
async _onBindingCalled(event: Protocol.Runtime.bindingCalledPayload) {
|
||||
const pageOrError = await this._crPage.pageOrError();
|
||||
const pageOrError = await this._crPage._page.waitForInitializedOrError();
|
||||
if (!(pageOrError instanceof Error)) {
|
||||
const context = this._contextIdToContext.get(event.executionContextId);
|
||||
if (context)
|
||||
|
|
@ -898,8 +873,7 @@ class FrameSession {
|
|||
}
|
||||
|
||||
_willBeginDownload() {
|
||||
const originPage = this._crPage._initializedPage;
|
||||
if (!originPage) {
|
||||
if (!this._crPage._page.initializedOrUndefined()) {
|
||||
// Resume the page creation with an error. The page will automatically close right
|
||||
// after the download begins.
|
||||
this._firstNonInitialNavigationCommittedReject(new Error('Starting new page download'));
|
||||
|
|
@ -939,7 +913,7 @@ class FrameSession {
|
|||
});
|
||||
// Wait for the first frame before reporting video to the client.
|
||||
gotFirstFrame.then(() => {
|
||||
this._crPage._browserContext._browser._videoStarted(this._crPage._browserContext, screencastId, options.outputFile, this._crPage.pageOrError());
|
||||
this._crPage._browserContext._browser._videoStarted(this._crPage._browserContext, screencastId, options.outputFile, this._crPage._page.waitForInitializedOrError());
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -171,8 +171,10 @@ export class CSharpLanguageGenerator implements LanguageGenerator {
|
|||
using var playwright = await Playwright.CreateAsync();
|
||||
await using var browser = await playwright.${toPascal(options.browserName)}.LaunchAsync(${formatObject(options.launchOptions, ' ', 'BrowserTypeLaunchOptions')});
|
||||
var context = await browser.NewContextAsync(${formatContextOptions(options.contextOptions, options.deviceName)});`);
|
||||
if (options.contextOptions.recordHar)
|
||||
formatter.add(` await context.RouteFromHARAsync(${quote(options.contextOptions.recordHar.path)});`);
|
||||
if (options.contextOptions.recordHar) {
|
||||
const url = options.contextOptions.recordHar.urlFilter;
|
||||
formatter.add(` await context.RouteFromHARAsync(${quote(options.contextOptions.recordHar.path)}${url ? `, ${formatObject({ url }, ' ', 'BrowserContextRouteFromHAROptions')}` : ''});`);
|
||||
}
|
||||
formatter.newLine();
|
||||
return formatter.format();
|
||||
}
|
||||
|
|
@ -198,8 +200,10 @@ export class CSharpLanguageGenerator implements LanguageGenerator {
|
|||
formatter.add(` [${this._mode === 'nunit' ? 'Test' : 'TestMethod'}]
|
||||
public async Task MyTest()
|
||||
{`);
|
||||
if (options.contextOptions.recordHar)
|
||||
formatter.add(` await context.RouteFromHARAsync(${quote(options.contextOptions.recordHar.path)});`);
|
||||
if (options.contextOptions.recordHar) {
|
||||
const url = options.contextOptions.recordHar.urlFilter;
|
||||
formatter.add(` await Context.RouteFromHARAsync(${quote(options.contextOptions.recordHar.path)}${url ? `, ${formatObject({ url }, ' ', 'BrowserContextRouteFromHAROptions')}` : ''});`);
|
||||
}
|
||||
return formatter.format();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -150,28 +150,38 @@ export class JavaLanguageGenerator implements LanguageGenerator {
|
|||
import com.microsoft.playwright.Page;
|
||||
import com.microsoft.playwright.options.*;
|
||||
|
||||
import org.junit.jupiter.api.*;
|
||||
${options.contextOptions.recordHar ? `import java.nio.file.Paths;\n` : ''}import org.junit.jupiter.api.*;
|
||||
import static com.microsoft.playwright.assertions.PlaywrightAssertions.*;
|
||||
|
||||
@UsePlaywright
|
||||
public class TestExample {
|
||||
@Test
|
||||
void test(Page page) {`);
|
||||
if (options.contextOptions.recordHar) {
|
||||
const url = options.contextOptions.recordHar.urlFilter;
|
||||
const recordHarOptions = typeof url === 'string' ? `, new Page.RouteFromHAROptions()
|
||||
.setUrl(${quote(url)})` : '';
|
||||
formatter.add(` page.routeFromHAR(Paths.get(${quote(options.contextOptions.recordHar.path)})${recordHarOptions});`);
|
||||
}
|
||||
return formatter.format();
|
||||
}
|
||||
formatter.add(`
|
||||
import com.microsoft.playwright.*;
|
||||
import com.microsoft.playwright.options.*;
|
||||
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
|
||||
import java.util.*;
|
||||
${options.contextOptions.recordHar ? `import java.nio.file.Paths;\n` : ''}import java.util.*;
|
||||
|
||||
public class Example {
|
||||
public static void main(String[] args) {
|
||||
try (Playwright playwright = Playwright.create()) {
|
||||
Browser browser = playwright.${options.browserName}().launch(${formatLaunchOptions(options.launchOptions)});
|
||||
BrowserContext context = browser.newContext(${formatContextOptions(options.contextOptions, options.deviceName)});`);
|
||||
if (options.contextOptions.recordHar)
|
||||
formatter.add(` context.routeFromHAR(${quote(options.contextOptions.recordHar.path)});`);
|
||||
if (options.contextOptions.recordHar) {
|
||||
const url = options.contextOptions.recordHar.urlFilter;
|
||||
const recordHarOptions = typeof url === 'string' ? `, new BrowserContext.RouteFromHAROptions()
|
||||
.setUrl(${quote(url)})` : '';
|
||||
formatter.add(` context.routeFromHAR(Paths.get(${quote(options.contextOptions.recordHar.path)})${recordHarOptions});`);
|
||||
}
|
||||
return formatter.format();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -147,8 +147,10 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator {
|
|||
import { test, expect${options.deviceName ? ', devices' : ''} } from '@playwright/test';
|
||||
${useText ? '\ntest.use(' + useText + ');\n' : ''}
|
||||
test('test', async ({ page }) => {`);
|
||||
if (options.contextOptions.recordHar)
|
||||
formatter.add(` await page.routeFromHAR(${quote(options.contextOptions.recordHar.path)});`);
|
||||
if (options.contextOptions.recordHar) {
|
||||
const url = options.contextOptions.recordHar.urlFilter;
|
||||
formatter.add(` await page.routeFromHAR(${quote(options.contextOptions.recordHar.path)}${url ? `, ${formatOptions({ url }, false)}` : ''});`);
|
||||
}
|
||||
return formatter.format();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ export class PythonLanguageGenerator implements LanguageGenerator {
|
|||
|
||||
generateHeader(options: LanguageGeneratorOptions): string {
|
||||
const formatter = new PythonFormatter();
|
||||
const recordHar = options.contextOptions.recordHar;
|
||||
if (this._isPyTest) {
|
||||
const contextOptions = formatContextOptions(options.contextOptions, options.deviceName, true /* asDict */);
|
||||
const fixture = contextOptions ? `
|
||||
|
|
@ -146,13 +147,13 @@ def browser_context_args(browser_context_args, playwright) {
|
|||
return {${contextOptions}}
|
||||
}
|
||||
` : '';
|
||||
formatter.add(`${options.deviceName ? 'import pytest\n' : ''}import re
|
||||
formatter.add(`${options.deviceName || contextOptions ? 'import pytest\n' : ''}import re
|
||||
from playwright.sync_api import Page, expect
|
||||
${fixture}
|
||||
|
||||
def test_example(page: Page) -> None {`);
|
||||
if (options.contextOptions.recordHar)
|
||||
formatter.add(` page.route_from_har(${quote(options.contextOptions.recordHar.path)})`);
|
||||
if (recordHar)
|
||||
formatter.add(` page.route_from_har(${quote(recordHar.path)}${typeof recordHar.urlFilter === 'string' ? `, url=${quote(recordHar.urlFilter)}` : ''})`);
|
||||
} else if (this._isAsync) {
|
||||
formatter.add(`
|
||||
import asyncio
|
||||
|
|
@ -163,8 +164,8 @@ from playwright.async_api import Playwright, async_playwright, expect
|
|||
async def run(playwright: Playwright) -> None {
|
||||
browser = await playwright.${options.browserName}.launch(${formatOptions(options.launchOptions, false)})
|
||||
context = await browser.new_context(${formatContextOptions(options.contextOptions, options.deviceName)})`);
|
||||
if (options.contextOptions.recordHar)
|
||||
formatter.add(` await page.route_from_har(${quote(options.contextOptions.recordHar.path)})`);
|
||||
if (recordHar)
|
||||
formatter.add(` await context.route_from_har(${quote(recordHar.path)}${typeof recordHar.urlFilter === 'string' ? `, url=${quote(recordHar.urlFilter)}` : ''})`);
|
||||
} else {
|
||||
formatter.add(`
|
||||
import re
|
||||
|
|
@ -174,8 +175,8 @@ from playwright.sync_api import Playwright, sync_playwright, expect
|
|||
def run(playwright: Playwright) -> None {
|
||||
browser = playwright.${options.browserName}.launch(${formatOptions(options.launchOptions, false)})
|
||||
context = browser.new_context(${formatContextOptions(options.contextOptions, options.deviceName)})`);
|
||||
if (options.contextOptions.recordHar)
|
||||
formatter.add(` context.route_from_har(${quote(options.contextOptions.recordHar.path)})`);
|
||||
if (recordHar)
|
||||
formatter.add(` context.route_from_har(${quote(recordHar.path)}${typeof recordHar.urlFilter === 'string' ? `, url=${quote(recordHar.urlFilter)}` : ''})`);
|
||||
}
|
||||
return formatter.format();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ 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 { unsafeLocatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser';
|
||||
|
||||
const internalMetadata = serverSideCallMetadata();
|
||||
|
||||
|
|
@ -144,9 +147,17 @@ export class DebugController extends SdkObject {
|
|||
}
|
||||
|
||||
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);
|
||||
}
|
||||
for (const recorder of await this._allRecorders()) {
|
||||
if (params.ariaTemplate)
|
||||
recorder.setHighlightedAriaTemplate(parseYamlForAriaSnapshot(params.ariaTemplate));
|
||||
if (parsedYaml)
|
||||
recorder.setHighlightedAriaTemplate(parsedYaml);
|
||||
else if (params.selector)
|
||||
recorder.setHighlightedSelector(this._sdkLanguage, params.selector);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,4 +77,4 @@ export async function prepareFilesForUpload(frame: Frame, params: channels.Eleme
|
|||
}));
|
||||
|
||||
return { localPaths, localDirectory, filePayloads };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import type { BrowserOptions } from '../browser';
|
|||
import { Browser } from '../browser';
|
||||
import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext';
|
||||
import * as network from '../network';
|
||||
import type { InitScript, Page, PageDelegate } from '../page';
|
||||
import type { InitScript, Page } from '../page';
|
||||
import { PageBinding } from '../page';
|
||||
import type { ConnectionTransport } from '../transport';
|
||||
import type * as types from '../types';
|
||||
|
|
@ -136,14 +136,14 @@ export class FFBrowser extends Browser {
|
|||
// Abort the navigation that turned into download.
|
||||
ffPage._page._frameManager.frameAbortedNavigation(payload.frameId, 'Download is starting');
|
||||
|
||||
let originPage = ffPage._initializedPage;
|
||||
let originPage = ffPage._page.initializedOrUndefined();
|
||||
// If it's a new window download, report it on the opener page.
|
||||
if (!originPage) {
|
||||
// Resume the page creation with an error. The page will automatically close right
|
||||
// after the download begins.
|
||||
ffPage._markAsError(new Error('Starting new page download'));
|
||||
if (ffPage._opener)
|
||||
originPage = ffPage._opener._initializedPage;
|
||||
originPage = ffPage._opener._page.initializedOrUndefined();
|
||||
}
|
||||
if (!originPage)
|
||||
return;
|
||||
|
|
@ -267,15 +267,11 @@ export class FFBrowserContext extends BrowserContext {
|
|||
return Array.from(this._browser._ffPages.values()).filter(ffPage => ffPage._browserContext === this);
|
||||
}
|
||||
|
||||
pages(): Page[] {
|
||||
return this._ffPages().map(ffPage => ffPage._initializedPage).filter(pageOrNull => !!pageOrNull) as Page[];
|
||||
override possiblyUninitializedPages(): Page[] {
|
||||
return this._ffPages().map(ffPage => ffPage._page);
|
||||
}
|
||||
|
||||
pagesOrErrors() {
|
||||
return this._ffPages().map(ffPage => ffPage.pageOrError());
|
||||
}
|
||||
|
||||
async newPageDelegate(): Promise<PageDelegate> {
|
||||
override async doCreateNewPage(): Promise<Page> {
|
||||
assertBrowserContextIsNotOwned(this);
|
||||
const { targetId } = await this._browser.session.send('Browser.newPage', {
|
||||
browserContextId: this._browserContextId
|
||||
|
|
@ -284,7 +280,7 @@ export class FFBrowserContext extends BrowserContext {
|
|||
throw new Error(`Invalid timezone ID: ${this._options.timezoneId}`);
|
||||
throw e;
|
||||
});
|
||||
return this._browser._ffPages.get(targetId)!;
|
||||
return this._browser._ffPages.get(targetId)!._page;
|
||||
}
|
||||
|
||||
async doGetCookies(urls: string[]): Promise<channels.NetworkCookie[]> {
|
||||
|
|
@ -440,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 = {};
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ import type { Protocol } from './protocol';
|
|||
import type { Progress } from '../progress';
|
||||
import { splitErrorMessage } from '../../utils/stackTrace';
|
||||
import { debugLogger } from '../../utils/debugLogger';
|
||||
import { ManualPromise } from '../../utils/manualPromise';
|
||||
import { BrowserContext } from '../browserContext';
|
||||
import { TargetClosedError } from '../errors';
|
||||
|
||||
|
|
@ -49,9 +48,7 @@ export class FFPage implements PageDelegate {
|
|||
readonly _page: Page;
|
||||
readonly _networkManager: FFNetworkManager;
|
||||
readonly _browserContext: FFBrowserContext;
|
||||
private _pagePromise = new ManualPromise<Page | Error>();
|
||||
_initializedPage: Page | null = null;
|
||||
private _initializationFailed = false;
|
||||
private _reportedAsNew = false;
|
||||
readonly _opener: FFPage | null;
|
||||
private readonly _contextIdToContext: Map<string, dom.FrameExecutionContext>;
|
||||
private _eventListeners: RegisteredListener[];
|
||||
|
|
@ -102,40 +99,23 @@ export class FFPage implements PageDelegate {
|
|||
eventsHelper.addEventListener(this._session, 'Page.screencastFrame', this._onScreencastFrame.bind(this)),
|
||||
|
||||
];
|
||||
this._session.once('Page.ready', async () => {
|
||||
await this._page.initOpener(this._opener);
|
||||
if (this._initializationFailed)
|
||||
this._session.once('Page.ready', () => {
|
||||
if (this._reportedAsNew)
|
||||
return;
|
||||
// Note: it is important to call |reportAsNew| before resolving pageOrError promise,
|
||||
// so that anyone who awaits pageOrError got a ready and reported page.
|
||||
this._initializedPage = this._page;
|
||||
this._page.reportAsNew();
|
||||
this._pagePromise.resolve(this._page);
|
||||
this._reportedAsNew = true;
|
||||
this._page.reportAsNew(this._opener?._page);
|
||||
});
|
||||
// Ideally, we somehow ensure that utility world is created before Page.ready arrives, but currently it is racy.
|
||||
// Therefore, we can end up with an initialized page without utility world, although very unlikely.
|
||||
this.addInitScript(new InitScript('', true), UTILITY_WORLD_NAME).catch(e => this._markAsError(e));
|
||||
}
|
||||
|
||||
potentiallyUninitializedPage(): Page {
|
||||
return this._page;
|
||||
}
|
||||
|
||||
async _markAsError(error: Error) {
|
||||
// Same error may be report twice: channer disconnected and session.send fails.
|
||||
if (this._initializationFailed)
|
||||
// Same error may be reported twice: channel disconnected and session.send fails.
|
||||
if (this._reportedAsNew)
|
||||
return;
|
||||
this._initializationFailed = true;
|
||||
|
||||
if (!this._initializedPage) {
|
||||
await this._page.initOpener(this._opener);
|
||||
this._page.reportAsNew(error);
|
||||
this._pagePromise.resolve(error);
|
||||
}
|
||||
}
|
||||
|
||||
async pageOrError(): Promise<Page | Error> {
|
||||
return this._pagePromise;
|
||||
this._reportedAsNew = true;
|
||||
this._page.reportAsNew(this._opener?._page, error);
|
||||
}
|
||||
|
||||
_onWebSocketCreated(event: Protocol.Page.webSocketCreatedPayload) {
|
||||
|
|
@ -268,7 +248,7 @@ export class FFPage implements PageDelegate {
|
|||
}
|
||||
|
||||
async _onBindingCalled(event: Protocol.Page.bindingCalledPayload) {
|
||||
const pageOrError = await this.pageOrError();
|
||||
const pageOrError = await this._page.waitForInitializedOrError();
|
||||
if (!(pageOrError instanceof Error)) {
|
||||
const context = this._contextIdToContext.get(event.executionContextId);
|
||||
if (context)
|
||||
|
|
@ -333,7 +313,7 @@ export class FFPage implements PageDelegate {
|
|||
}
|
||||
|
||||
_onVideoRecordingStarted(event: Protocol.Page.videoRecordingStartedPayload) {
|
||||
this._browserContext._browser._videoStarted(this._browserContext, event.screencastId, event.file, this.pageOrError());
|
||||
this._browserContext._browser._videoStarted(this._browserContext, event.screencastId, event.file, this._page.waitForInitializedOrError());
|
||||
}
|
||||
|
||||
didClose() {
|
||||
|
|
|
|||
|
|
@ -101,4 +101,3 @@ class JugglerReadyState extends BrowserReadyState {
|
|||
this._wsEndpoint.resolve(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -149,21 +149,24 @@ x-pw-tools-list {
|
|||
|
||||
x-pw-tool-item {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
x-pw-tool-item:not(.disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
x-pw-tool-item:not(.disabled):hover {
|
||||
background-color: hsl(0, 0%, 86%);
|
||||
}
|
||||
|
||||
x-pw-tool-item.active {
|
||||
x-pw-tool-item.toggled {
|
||||
background-color: rgba(138, 202, 228, 0.5);
|
||||
}
|
||||
|
||||
x-pw-tool-item.active:not(.disabled):hover {
|
||||
x-pw-tool-item.toggled:not(.disabled):hover {
|
||||
background-color: #8acae4c4;
|
||||
}
|
||||
|
||||
|
|
@ -179,18 +182,22 @@ x-pw-tool-item.disabled > x-div {
|
|||
cursor: default;
|
||||
}
|
||||
|
||||
x-pw-tool-item.record.active {
|
||||
x-pw-tool-item.record.toggled {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
x-pw-tool-item.record.active:hover {
|
||||
x-pw-tool-item.record.toggled:not(.disabled):hover {
|
||||
background-color: hsl(0, 0%, 86%);
|
||||
}
|
||||
|
||||
x-pw-tool-item.record.active > x-div {
|
||||
x-pw-tool-item.record.toggled > x-div {
|
||||
background-color: #a1260d;
|
||||
}
|
||||
|
||||
x-pw-tool-item.record.disabled.toggled > x-div {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
x-pw-tool-item.accept > x-div {
|
||||
background-color: #388a34;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription, getReadonly, getElementAccessibleErrorMessage } from './roleUtils';
|
||||
import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils';
|
||||
import { asLocator } from '../../utils/isomorphic/locatorGenerators';
|
||||
import type { Language } from '../../utils/isomorphic/locatorGenerators';
|
||||
|
|
@ -457,7 +457,8 @@ export class InjectedScript {
|
|||
const queryAll = (root: SelectorRoot, body: string) => {
|
||||
if (root.nodeType !== 1 /* Node.ELEMENT_NODE */)
|
||||
return [];
|
||||
return isElementVisible(root as Element) === Boolean(body) ? [root as Element] : [];
|
||||
const visible = body === 'true';
|
||||
return isElementVisible(root as Element) === visible ? [root as Element] : [];
|
||||
};
|
||||
return { queryAll };
|
||||
}
|
||||
|
|
@ -995,13 +996,20 @@ 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': {
|
||||
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;
|
||||
|
|
@ -1320,6 +1328,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
|
|
@ -883,9 +883,13 @@ class Overlay {
|
|||
this._dragState = { offsetX: this._offsetX, dragStart: { x: (event as MouseEvent).clientX, y: 0 } };
|
||||
}),
|
||||
addEventListener(this._recordToggle, 'click', () => {
|
||||
if (this._recordToggle.classList.contains('disabled'))
|
||||
return;
|
||||
this._recorder.setMode(this._recorder.state.mode === 'none' || this._recorder.state.mode === 'standby' || this._recorder.state.mode === 'inspecting' ? 'recording' : 'standby');
|
||||
}),
|
||||
addEventListener(this._pickLocatorToggle, 'click', () => {
|
||||
if (this._pickLocatorToggle.classList.contains('disabled'))
|
||||
return;
|
||||
const newMode: Record<Mode, Mode> = {
|
||||
'inspecting': 'standby',
|
||||
'none': 'inspecting',
|
||||
|
|
@ -929,15 +933,15 @@ class Overlay {
|
|||
}
|
||||
|
||||
setUIState(state: UIState) {
|
||||
this._recordToggle.classList.toggle('active', state.mode === 'recording' || state.mode === 'assertingText' || state.mode === 'assertingVisibility' || state.mode === 'assertingValue' || state.mode === 'recording-inspecting');
|
||||
this._pickLocatorToggle.classList.toggle('active', state.mode === 'inspecting' || state.mode === 'recording-inspecting');
|
||||
this._assertVisibilityToggle.classList.toggle('active', state.mode === 'assertingVisibility');
|
||||
this._recordToggle.classList.toggle('toggled', state.mode === 'recording' || state.mode === 'assertingText' || state.mode === 'assertingVisibility' || state.mode === 'assertingValue' || state.mode === 'assertingSnapshot' || state.mode === 'recording-inspecting');
|
||||
this._pickLocatorToggle.classList.toggle('toggled', state.mode === 'inspecting' || state.mode === 'recording-inspecting');
|
||||
this._assertVisibilityToggle.classList.toggle('toggled', state.mode === 'assertingVisibility');
|
||||
this._assertVisibilityToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting');
|
||||
this._assertTextToggle.classList.toggle('active', state.mode === 'assertingText');
|
||||
this._assertTextToggle.classList.toggle('toggled', state.mode === 'assertingText');
|
||||
this._assertTextToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting');
|
||||
this._assertValuesToggle.classList.toggle('active', state.mode === 'assertingValue');
|
||||
this._assertValuesToggle.classList.toggle('toggled', state.mode === 'assertingValue');
|
||||
this._assertValuesToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting');
|
||||
this._assertSnapshotToggle.classList.toggle('active', state.mode === 'assertingSnapshot');
|
||||
this._assertSnapshotToggle.classList.toggle('toggled', state.mode === 'assertingSnapshot');
|
||||
this._assertSnapshotToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting');
|
||||
if (this._offsetX !== state.overlay.offsetX) {
|
||||
this._offsetX = state.overlay.offsetX;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -972,6 +1025,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 +1037,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 +1049,7 @@ export function endAriaCaches() {
|
|||
cacheAccessibleNameHidden = undefined;
|
||||
cacheAccessibleDescription = undefined;
|
||||
cacheAccessibleDescriptionHidden = undefined;
|
||||
cacheAccessibleErrorMessage = undefined;
|
||||
cacheIsHidden = undefined;
|
||||
cachePseudoContentBefore = undefined;
|
||||
cachePseudoContentAfter = undefined;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -59,8 +59,6 @@ export interface PageDelegate {
|
|||
addInitScript(initScript: InitScript): Promise<void>;
|
||||
removeNonInternalInitScripts(): Promise<void>;
|
||||
closePage(runBeforeUnload: boolean): Promise<void>;
|
||||
potentiallyUninitializedPage(): Page;
|
||||
pageOrError(): Promise<Page | Error>;
|
||||
|
||||
navigateFrame(frame: frames.Frame, url: string, referrer: string | undefined): Promise<frames.GotoResult>;
|
||||
|
||||
|
|
@ -139,7 +137,8 @@ export class Page extends SdkObject {
|
|||
|
||||
private _closedState: 'open' | 'closing' | 'closed' = 'open';
|
||||
private _closedPromise = new ManualPromise<void>();
|
||||
private _initialized = false;
|
||||
private _initialized: Page | Error | undefined;
|
||||
private _initializedPromise = new ManualPromise<Page | Error>();
|
||||
private _eventsToEmitAfterInitialized: { event: string | symbol, args: any[] }[] = [];
|
||||
private _crashed = false;
|
||||
readonly openScope = new LongStandingScope();
|
||||
|
|
@ -193,15 +192,16 @@ export class Page extends SdkObject {
|
|||
this.coverage = delegate.coverage ? delegate.coverage() : null;
|
||||
}
|
||||
|
||||
async initOpener(opener: PageDelegate | null) {
|
||||
if (!opener)
|
||||
return;
|
||||
const openerPage = await opener.pageOrError();
|
||||
if (openerPage instanceof Page && !openerPage.isClosed())
|
||||
this._opener = openerPage;
|
||||
async reportAsNew(opener: Page | undefined, error: Error | undefined = undefined, contextEvent: string = BrowserContext.Events.Page) {
|
||||
if (opener) {
|
||||
const openerPageOrError = await opener.waitForInitializedOrError();
|
||||
if (openerPageOrError instanceof Page && !openerPageOrError.isClosed())
|
||||
this._opener = openerPageOrError;
|
||||
}
|
||||
this._markInitialized(error, contextEvent);
|
||||
}
|
||||
|
||||
reportAsNew(error: Error | undefined = undefined, contextEvent: string = BrowserContext.Events.Page) {
|
||||
private _markInitialized(error: Error | undefined = undefined, contextEvent: string = BrowserContext.Events.Page) {
|
||||
if (error) {
|
||||
// Initialization error could have happened because of
|
||||
// context/browser closure. Just ignore the page.
|
||||
|
|
@ -209,7 +209,7 @@ export class Page extends SdkObject {
|
|||
return;
|
||||
this._frameManager.createDummyMainFrameIfNeeded();
|
||||
}
|
||||
this._initialized = true;
|
||||
this._initialized = error || this;
|
||||
this.emitOnContext(contextEvent, this);
|
||||
|
||||
for (const { event, args } of this._eventsToEmitAfterInitialized)
|
||||
|
|
@ -223,12 +223,20 @@ export class Page extends SdkObject {
|
|||
this.emit(Page.Events.Close);
|
||||
else
|
||||
this.instrumentation.onPageOpen(this);
|
||||
|
||||
// Note: it is important to resolve _initializedPromise at the end,
|
||||
// so that anyone who awaits waitForInitializedOrError got a ready and reported page.
|
||||
this._initializedPromise.resolve(this._initialized);
|
||||
}
|
||||
|
||||
initializedOrUndefined() {
|
||||
initializedOrUndefined(): Page | undefined {
|
||||
return this._initialized ? this : undefined;
|
||||
}
|
||||
|
||||
waitForInitializedOrError(): Promise<Page | Error> {
|
||||
return this._initializedPromise;
|
||||
}
|
||||
|
||||
emitOnContext(event: string | symbol, ...args: any[]) {
|
||||
if (this._isServerSideOnly)
|
||||
return;
|
||||
|
|
@ -556,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);
|
||||
}
|
||||
|
|
@ -945,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 || {};
|
||||
|
|
@ -957,6 +966,7 @@ export class InitScript {
|
|||
${source}
|
||||
})();`;
|
||||
this.internal = !!internal;
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -300,7 +300,6 @@ async function generateFrameSelectorInParent(parent: Frame, frame: Frame): Promi
|
|||
}, frameElement);
|
||||
return selector;
|
||||
} catch (e) {
|
||||
return e.toString();
|
||||
}
|
||||
}, monotonicTime() + 2000);
|
||||
if (!result.timedOut && result.result)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -37,13 +37,9 @@ const PACKAGE_PATH = path.join(__dirname, '..', '..', '..');
|
|||
const BIN_PATH = path.join(__dirname, '..', '..', '..', 'bin');
|
||||
|
||||
const PLAYWRIGHT_CDN_MIRRORS = [
|
||||
'https://playwright.azureedge.net/dbazure/download/playwright', // ESRP CDN
|
||||
'https://cdn.playwright.dev/dbazure/download/playwright', // ESRP CDN
|
||||
'https://playwright.download.prss.microsoft.com/dbazure/download/playwright', // Directly hit ESRP CDN
|
||||
|
||||
// Old endpoints which hit the Storage Bucket directly:
|
||||
'https://playwright.azureedge.net',
|
||||
'https://playwright-akamai.azureedge.net', // Actually Edgio which will be retired Q4 2025.
|
||||
'https://playwright-verizon.azureedge.net', // Actually Edgio which will be retired Q4 2025.
|
||||
'https://cdn.playwright.dev', // Hit the Storage Bucket directly
|
||||
];
|
||||
|
||||
if (process.env.PW_TEST_CDN_THAT_SHOULD_WORK) {
|
||||
|
|
@ -83,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>;
|
||||
|
|
@ -319,6 +320,35 @@ const DOWNLOAD_PATHS: Record<BrowserName | InternalTool, DownloadPaths> = {
|
|||
'mac15-arm64': 'builds/ffmpeg/%s/ffmpeg-mac-arm64.zip',
|
||||
'win64': 'builds/ffmpeg/%s/ffmpeg-win64.zip',
|
||||
},
|
||||
'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,
|
||||
|
|
@ -446,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'];
|
||||
|
|
@ -776,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',
|
||||
|
|
@ -948,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) {
|
||||
|
|
@ -1269,6 +1315,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 = {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { Browser } from '../browser';
|
|||
import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext';
|
||||
import { assert } from '../../utils';
|
||||
import * as network from '../network';
|
||||
import type { InitScript, Page, PageDelegate } from '../page';
|
||||
import type { InitScript, Page } from '../page';
|
||||
import type { ConnectionTransport } from '../transport';
|
||||
import type * as types from '../types';
|
||||
import type * as channels from '@protocol/channels';
|
||||
|
|
@ -121,14 +121,14 @@ export class WKBrowser extends Browser {
|
|||
// abort navigation that is still running. We should be able to fix this by
|
||||
// instrumenting policy decision start/proceed/cancel.
|
||||
page._page._frameManager.frameAbortedNavigation(payload.frameId, 'Download is starting');
|
||||
let originPage = page._initializedPage;
|
||||
let originPage = page._page.initializedOrUndefined();
|
||||
// If it's a new window download, report it on the opener page.
|
||||
if (!originPage) {
|
||||
// Resume the page creation with an error. The page will automatically close right
|
||||
// after the download begins.
|
||||
page._firstNonInitialNavigationCommittedReject(new Error('Starting new page download'));
|
||||
if (page._opener)
|
||||
originPage = page._opener._initializedPage;
|
||||
originPage = page._opener._page.initializedOrUndefined();
|
||||
}
|
||||
if (!originPage)
|
||||
return;
|
||||
|
|
@ -239,18 +239,14 @@ export class WKBrowserContext extends BrowserContext {
|
|||
return Array.from(this._browser._wkPages.values()).filter(wkPage => wkPage._browserContext === this);
|
||||
}
|
||||
|
||||
pages(): Page[] {
|
||||
return this._wkPages().map(wkPage => wkPage._initializedPage).filter(pageOrNull => !!pageOrNull) as Page[];
|
||||
override possiblyUninitializedPages(): Page[] {
|
||||
return this._wkPages().map(wkPage => wkPage._page);
|
||||
}
|
||||
|
||||
pagesOrErrors() {
|
||||
return this._wkPages().map(wkPage => wkPage.pageOrError());
|
||||
}
|
||||
|
||||
async newPageDelegate(): Promise<PageDelegate> {
|
||||
override async doCreateNewPage(): Promise<Page> {
|
||||
assertBrowserContextIsNotOwned(this);
|
||||
const { pageProxyId } = await this._browser._browserSession.send('Playwright.createPage', { browserContextId: this._browserContextId });
|
||||
return this._browser._wkPages.get(pageProxyId)!;
|
||||
return this._browser._wkPages.get(pageProxyId)!._page;
|
||||
}
|
||||
|
||||
async doGetCookies(urls: string[]): Promise<channels.NetworkCookie[]> {
|
||||
|
|
|
|||
|
|
@ -115,6 +115,8 @@ function potentiallyUnserializableValue(remoteObject: Protocol.Runtime.RemoteObj
|
|||
}
|
||||
|
||||
function rewriteError(error: Error): Error {
|
||||
if (error.message.includes('Object has too long reference chain'))
|
||||
throw new Error('Cannot serialize result: object reference chain is too long.');
|
||||
if (!js.isJavaScriptErrorInEvaluate(error) && !isSessionClosedError(error))
|
||||
return new Error('Execution context was destroyed, most likely because of a navigation.');
|
||||
return error;
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ import { WKInterceptableRequest, WKRouteImpl } from './wkInterceptableRequest';
|
|||
import { WKProvisionalPage } from './wkProvisionalPage';
|
||||
import { WKWorkers } from './wkWorkers';
|
||||
import { debugLogger } from '../../utils/debugLogger';
|
||||
import { ManualPromise } from '../../utils/manualPromise';
|
||||
import { BrowserContext } from '../browserContext';
|
||||
import { TargetClosedError } from '../errors';
|
||||
|
||||
|
|
@ -56,7 +55,6 @@ export class WKPage implements PageDelegate {
|
|||
_session: WKSession;
|
||||
private _provisionalPage: WKProvisionalPage | null = null;
|
||||
readonly _page: Page;
|
||||
private readonly _pagePromise = new ManualPromise<Page | Error>();
|
||||
private readonly _pageProxySession: WKSession;
|
||||
readonly _opener: WKPage | null;
|
||||
private readonly _requestIdToRequest = new Map<string, WKInterceptableRequest>();
|
||||
|
|
@ -66,7 +64,6 @@ export class WKPage implements PageDelegate {
|
|||
private _sessionListeners: RegisteredListener[] = [];
|
||||
private _eventListeners: RegisteredListener[];
|
||||
readonly _browserContext: WKBrowserContext;
|
||||
_initializedPage: Page | null = null;
|
||||
private _firstNonInitialNavigationCommittedPromise: Promise<void>;
|
||||
private _firstNonInitialNavigationCommittedFulfill = () => {};
|
||||
_firstNonInitialNavigationCommittedReject = (e: Error) => {};
|
||||
|
|
@ -111,10 +108,6 @@ export class WKPage implements PageDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
potentiallyUninitializedPage(): Page {
|
||||
return this._page;
|
||||
}
|
||||
|
||||
private async _initializePageProxySession() {
|
||||
if (this._page._browserContext.isSettingStorageState())
|
||||
return;
|
||||
|
|
@ -283,7 +276,7 @@ export class WKPage implements PageDelegate {
|
|||
}
|
||||
|
||||
handleProvisionalLoadFailed(event: Protocol.Playwright.provisionalLoadFailedPayload) {
|
||||
if (!this._initializedPage) {
|
||||
if (!this._page.initializedOrUndefined()) {
|
||||
this._firstNonInitialNavigationCommittedReject(new Error('Initial load failed'));
|
||||
return;
|
||||
}
|
||||
|
|
@ -300,10 +293,6 @@ export class WKPage implements PageDelegate {
|
|||
this._nextWindowOpenPopupFeatures = event.windowFeatures;
|
||||
}
|
||||
|
||||
async pageOrError(): Promise<Page | Error> {
|
||||
return this._pagePromise;
|
||||
}
|
||||
|
||||
private async _onTargetCreated(event: Protocol.Target.targetCreatedPayload) {
|
||||
const { targetInfo } = event;
|
||||
const session = new WKSession(this._pageProxySession.connection, targetInfo.targetId, (message: any) => {
|
||||
|
|
@ -316,7 +305,7 @@ export class WKPage implements PageDelegate {
|
|||
assert(targetInfo.type === 'page', 'Only page targets are expected in WebKit, received: ' + targetInfo.type);
|
||||
|
||||
if (!targetInfo.isProvisional) {
|
||||
assert(!this._initializedPage);
|
||||
assert(!this._page.initializedOrUndefined());
|
||||
let pageOrError: Page | Error;
|
||||
try {
|
||||
this._setSession(session);
|
||||
|
|
@ -343,12 +332,7 @@ export class WKPage implements PageDelegate {
|
|||
// Avoid rejection on disconnect.
|
||||
this._firstNonInitialNavigationCommittedPromise.catch(() => {});
|
||||
}
|
||||
await this._page.initOpener(this._opener);
|
||||
// Note: it is important to call |reportAsNew| before resolving pageOrError promise,
|
||||
// so that anyone who awaits pageOrError got a ready and reported page.
|
||||
this._initializedPage = pageOrError instanceof Page ? pageOrError : null;
|
||||
this._page.reportAsNew(pageOrError instanceof Page ? undefined : pageOrError);
|
||||
this._pagePromise.resolve(pageOrError);
|
||||
this._page.reportAsNew(this._opener?._page, pageOrError instanceof Page ? undefined : pageOrError);
|
||||
} else {
|
||||
assert(targetInfo.isProvisional);
|
||||
assert(!this._provisionalPage);
|
||||
|
|
@ -515,7 +499,7 @@ export class WKPage implements PageDelegate {
|
|||
}
|
||||
|
||||
private async _onBindingCalled(contextId: Protocol.Runtime.ExecutionContextId, argument: string) {
|
||||
const pageOrError = await this.pageOrError();
|
||||
const pageOrError = await this._page.waitForInitializedOrError();
|
||||
if (!(pageOrError instanceof Error)) {
|
||||
const context = this._contextIdToContext.get(contextId);
|
||||
if (context)
|
||||
|
|
@ -821,7 +805,7 @@ export class WKPage implements PageDelegate {
|
|||
toolbarHeight: this._toolbarHeight()
|
||||
});
|
||||
this._recordingVideoFile = options.outputFile;
|
||||
this._browserContext._browser._videoStarted(this._browserContext, screencastId, options.outputFile, this.pageOrError());
|
||||
this._browserContext._browser._videoStarted(this._browserContext, screencastId, options.outputFile, this._page.waitForInitializedOrError());
|
||||
}
|
||||
|
||||
async _stopVideo(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -105,4 +105,4 @@ export class WKProvisionalPage {
|
|||
assert(!frameTree.frame.parentId);
|
||||
this._mainFrameId = frameTree.frame.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -216,19 +216,24 @@ function transform(template: string, params: TemplateParams, testIdAttributeName
|
|||
}
|
||||
|
||||
export function locatorOrSelectorAsSelector(language: Language, locator: string, testIdAttributeName: string): string {
|
||||
try {
|
||||
return unsafeLocatorOrSelectorAsSelector(language, locator, testIdAttributeName);
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function unsafeLocatorOrSelectorAsSelector(language: Language, locator: string, testIdAttributeName: string): string {
|
||||
try {
|
||||
parseSelector(locator);
|
||||
return locator;
|
||||
} catch (e) {
|
||||
}
|
||||
try {
|
||||
const { selector, preferredQuote } = parseLocator(locator, testIdAttributeName);
|
||||
const locators = asLocators(language, selector, undefined, undefined, preferredQuote);
|
||||
const digest = digestForComparison(language, locator);
|
||||
if (locators.some(candidate => digestForComparison(language, candidate) === digest))
|
||||
return selector;
|
||||
} catch (e) {
|
||||
}
|
||||
const { selector, preferredQuote } = parseLocator(locator, testIdAttributeName);
|
||||
const locators = asLocators(language, selector, undefined, undefined, preferredQuote);
|
||||
const digest = digestForComparison(language, locator);
|
||||
if (locators.some(candidate => digestForComparison(language, candidate) === digest))
|
||||
return selector;
|
||||
return '';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
packages/playwright-core/types/types.d.ts
vendored
23
packages/playwright-core/types/types.d.ts
vendored
|
|
@ -9589,10 +9589,11 @@ export interface Browser {
|
|||
* In case this browser is connected to, clears all created contexts belonging to this browser and disconnects from
|
||||
* the browser server.
|
||||
*
|
||||
* **NOTE** This is similar to force quitting the browser. Therefore, you should call
|
||||
* **NOTE** This is similar to force-quitting the browser. To close pages gracefully and ensure you receive page close
|
||||
* events, call
|
||||
* [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) on
|
||||
* any [BrowserContext](https://playwright.dev/docs/api/class-browsercontext)'s you explicitly created earlier with
|
||||
* [browser.newContext([options])](https://playwright.dev/docs/api/class-browser#browser-new-context) **before**
|
||||
* any [BrowserContext](https://playwright.dev/docs/api/class-browsercontext) instances you explicitly created earlier
|
||||
* using [browser.newContext([options])](https://playwright.dev/docs/api/class-browser#browser-new-context) **before**
|
||||
* calling [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close).
|
||||
*
|
||||
* The [Browser](https://playwright.dev/docs/api/class-browser) object itself is considered to be disposed and cannot
|
||||
|
|
@ -13852,18 +13853,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();
|
||||
|
|
@ -14715,7 +14720,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).
|
||||
|
|
@ -15214,7 +15219,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).
|
||||
|
|
@ -21565,7 +21570,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!;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ export type ConfigCLIOverrides = {
|
|||
timeout?: number;
|
||||
tsconfig?: string;
|
||||
ignoreSnapshots?: boolean;
|
||||
updateSnapshots?: 'all'|'changed'|'missing'|'none';
|
||||
updateSourceMethod?: 'overwrite'|'patch'|'3way';
|
||||
updateSnapshots?: 'all' | 'changed' | 'missing' | 'none';
|
||||
updateSourceMethod?: 'overwrite' | 'patch' | '3way';
|
||||
workers?: number | string;
|
||||
projects?: { name: string, use?: any }[],
|
||||
use?: any;
|
||||
|
|
@ -75,6 +75,7 @@ export type AttachmentPayload = {
|
|||
path?: string;
|
||||
body?: string;
|
||||
contentType: string;
|
||||
stepId?: string;
|
||||
};
|
||||
|
||||
export type TestInfoErrorImpl = TestInfoError & {
|
||||
|
|
|
|||
|
|
@ -56,7 +56,9 @@ export class TestTypeImpl {
|
|||
test.fail.only = wrapFunctionWithLocation(this._createTest.bind(this, 'fail.only'));
|
||||
test.slow = wrapFunctionWithLocation(this._modifier.bind(this, 'slow'));
|
||||
test.setTimeout = wrapFunctionWithLocation(this._setTimeout.bind(this));
|
||||
test.step = this._step.bind(this);
|
||||
test.step = this._step.bind(this, 'pass');
|
||||
test.step.fail = this._step.bind(this, 'fail');
|
||||
test.step.fixme = this._step.bind(this, 'fixme');
|
||||
test.use = wrapFunctionWithLocation(this._use.bind(this));
|
||||
test.extend = wrapFunctionWithLocation(this._extend.bind(this));
|
||||
test.info = () => {
|
||||
|
|
@ -257,22 +259,40 @@ export class TestTypeImpl {
|
|||
suite._use.push({ fixtures, location });
|
||||
}
|
||||
|
||||
async _step<T>(title: string, body: () => T | Promise<T>, options: {box?: boolean, location?: Location, timeout?: number } = {}): Promise<T> {
|
||||
async _step<T>(expectation: 'pass'|'fail'|'fixme', title: string, body: () => T | Promise<T>, options: {box?: boolean, location?: Location, timeout?: number } = {}): Promise<T> {
|
||||
const testInfo = currentTestInfo();
|
||||
if (!testInfo)
|
||||
throw new Error(`test.step() can only be called from a test`);
|
||||
if (expectation === 'fixme')
|
||||
return undefined as T;
|
||||
const step = testInfo._addStep({ category: 'test.step', title, location: options.location, box: options.box });
|
||||
return await zones.run('stepZone', step, async () => {
|
||||
let result;
|
||||
let error;
|
||||
try {
|
||||
const result = await raceAgainstDeadline(async () => body(), options.timeout ? monotonicTime() + options.timeout : 0);
|
||||
if (result.timedOut)
|
||||
throw new errors.TimeoutError(`Step timeout ${options.timeout}ms exceeded.`);
|
||||
step.complete({});
|
||||
return result.result;
|
||||
} catch (error) {
|
||||
result = await raceAgainstDeadline(async () => body(), options.timeout ? monotonicTime() + options.timeout : 0);
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
if (result?.timedOut) {
|
||||
const error = new errors.TimeoutError(`Step timeout ${options.timeout}ms exceeded.`);
|
||||
step.complete({ error });
|
||||
throw error;
|
||||
}
|
||||
const expectedToFail = expectation === 'fail';
|
||||
if (error) {
|
||||
step.complete({ error });
|
||||
if (expectedToFail)
|
||||
return undefined as T;
|
||||
throw error;
|
||||
}
|
||||
if (expectedToFail) {
|
||||
error = new Error(`Step is expected to fail, but passed`);
|
||||
step.complete({ error });
|
||||
throw error;
|
||||
}
|
||||
step.complete({});
|
||||
return result!.result;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -94,7 +94,8 @@ export interface TestServerInterface {
|
|||
testIds?: string[];
|
||||
headed?: boolean;
|
||||
workers?: number | string;
|
||||
updateSnapshots?: 'all' | 'none' | 'missing';
|
||||
updateSnapshots?: 'all' | 'changed' | 'missing' | 'none';
|
||||
updateSourceMethod?: 'overwrite' | 'patch' | '3way';
|
||||
reporters?: string[],
|
||||
trace?: 'on' | 'off';
|
||||
video?: 'on' | 'off';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -205,6 +205,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,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export type WebServerPluginOptions = {
|
|||
url?: string;
|
||||
ignoreHTTPSErrors?: boolean;
|
||||
timeout?: number;
|
||||
gracefulShutdown?: { signal: 'SIGINT' | 'SIGTERM', timeout?: number };
|
||||
reuseExistingServer?: boolean;
|
||||
cwd?: string;
|
||||
env?: { [key: string]: string; };
|
||||
|
|
@ -92,7 +93,7 @@ export class WebServerPlugin implements TestRunnerPlugin {
|
|||
}
|
||||
|
||||
debugWebServer(`Starting WebServer process ${this._options.command}...`);
|
||||
const { launchedProcess, kill } = await launchProcess({
|
||||
const { launchedProcess, gracefullyClose } = await launchProcess({
|
||||
command: this._options.command,
|
||||
env: {
|
||||
...DEFAULT_ENVIRONMENT_VARIABLES,
|
||||
|
|
@ -102,14 +103,33 @@ export class WebServerPlugin implements TestRunnerPlugin {
|
|||
cwd: this._options.cwd,
|
||||
stdio: 'stdin',
|
||||
shell: true,
|
||||
// Reject to indicate that we cannot close the web server gracefully
|
||||
// and should fallback to non-graceful shutdown.
|
||||
attemptToGracefullyClose: () => Promise.reject(),
|
||||
attemptToGracefullyClose: async () => {
|
||||
if (process.platform === 'win32')
|
||||
throw new Error('Graceful shutdown is not supported on Windows');
|
||||
if (!this._options.gracefulShutdown)
|
||||
throw new Error('skip graceful shutdown');
|
||||
|
||||
const { signal, timeout = 0 } = this._options.gracefulShutdown;
|
||||
|
||||
// proper usage of SIGINT is to send it to the entire process group, see https://www.cons.org/cracauer/sigint.html
|
||||
// there's no such convention for SIGTERM, so we decide what we want. signaling the process group for consistency.
|
||||
process.kill(-launchedProcess.pid!, signal);
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = timeout !== 0
|
||||
? setTimeout(() => reject(new Error(`process didn't close gracefully within timeout`)), timeout)
|
||||
: undefined;
|
||||
launchedProcess.once('close', (...args) => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
log: () => {},
|
||||
onExit: code => processExitedReject(new Error(code ? `Process from config.webServer was not able to start. Exit code: ${code}` : 'Process from config.webServer exited early.')),
|
||||
tempDirectories: [],
|
||||
});
|
||||
this._killProcess = kill;
|
||||
this._killProcess = gracefullyClose;
|
||||
|
||||
debugWebServer(`Process started`);
|
||||
|
||||
|
|
|
|||
|
|
@ -258,7 +258,7 @@ export class BaseReporter implements ReporterV2 {
|
|||
console.log(colors.yellow(' Slow test file: ') + file + colors.yellow(` (${milliseconds(duration)})`));
|
||||
});
|
||||
if (slowTests.length)
|
||||
console.log(colors.yellow(' Consider splitting slow test files to speed up parallel execution'));
|
||||
console.log(colors.yellow(' Consider running tests from slow files in parallel, see https://playwright.dev/docs/test-parallel.'));
|
||||
}
|
||||
|
||||
private _printSummary(summary: string) {
|
||||
|
|
@ -381,7 +381,7 @@ export function formatTestTitle(config: FullConfig, test: TestCase, step?: TestS
|
|||
if (omitLocation)
|
||||
location = `${relativeTestPath(config, test)}`;
|
||||
else
|
||||
location = `${relativeTestPath(config, test)}:${step?.location?.line ?? test.location.line}:${step?.location?.column ?? test.location.column}`;
|
||||
location = `${relativeTestPath(config, test)}:${test.location.line}:${test.location.column}`;
|
||||
const projectTitle = projectName ? `[${projectName}] › ` : '';
|
||||
const testTitle = `${projectTitle}${location} › ${titles.join(' › ')}`;
|
||||
const extraTags = test.tags.filter(t => !testTitle.includes(t));
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import path from 'path';
|
|||
import type { TransformCallback } from 'stream';
|
||||
import { Transform } from 'stream';
|
||||
import { codeFrameColumns } from '../transform/babelBundle';
|
||||
import type { FullResult, FullConfig, Location, Suite, TestCase as TestCasePublic, TestResult as TestResultPublic, TestStep as TestStepPublic, TestError } from '../../types/testReporter';
|
||||
import type * as api from '../../types/testReporter';
|
||||
import { HttpServer, assert, calculateSha1, copyFileAndMakeWritable, gracefullyProcessExitDoNotHang, removeFolders, sanitizeForFilePath, toPosixPath } from 'playwright-core/lib/utils';
|
||||
import { colors, formatError, formatResultFailure, stripAnsiEscapes } from './base';
|
||||
import { resolveReporterOutputPath } from '../util';
|
||||
|
|
@ -56,8 +56,8 @@ type HtmlReporterOptions = {
|
|||
};
|
||||
|
||||
class HtmlReporter implements ReporterV2 {
|
||||
private config!: FullConfig;
|
||||
private suite!: Suite;
|
||||
private config!: api.FullConfig;
|
||||
private suite!: api.Suite;
|
||||
private _options: HtmlReporterOptions;
|
||||
private _outputFolder!: string;
|
||||
private _attachmentsBaseURL!: string;
|
||||
|
|
@ -65,7 +65,7 @@ class HtmlReporter implements ReporterV2 {
|
|||
private _port: number | undefined;
|
||||
private _host: string | undefined;
|
||||
private _buildResult: { ok: boolean, singleTestId: string | undefined } | undefined;
|
||||
private _topLevelErrors: TestError[] = [];
|
||||
private _topLevelErrors: api.TestError[] = [];
|
||||
|
||||
constructor(options: HtmlReporterOptions) {
|
||||
this._options = options;
|
||||
|
|
@ -79,11 +79,11 @@ class HtmlReporter implements ReporterV2 {
|
|||
return false;
|
||||
}
|
||||
|
||||
onConfigure(config: FullConfig) {
|
||||
onConfigure(config: api.FullConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
onBegin(suite: Suite) {
|
||||
onBegin(suite: api.Suite) {
|
||||
const { outputFolder, open, attachmentsBaseURL, host, port } = this._resolveOptions();
|
||||
this._outputFolder = outputFolder;
|
||||
this._open = open;
|
||||
|
|
@ -125,11 +125,11 @@ class HtmlReporter implements ReporterV2 {
|
|||
return !!relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
|
||||
}
|
||||
|
||||
onError(error: TestError): void {
|
||||
onError(error: api.TestError): void {
|
||||
this._topLevelErrors.push(error);
|
||||
}
|
||||
|
||||
async onEnd(result: FullResult) {
|
||||
async onEnd(result: api.FullResult) {
|
||||
const projectSuites = this.suite.suites;
|
||||
await removeFolders([this._outputFolder]);
|
||||
const builder = new HtmlBuilder(this.config, this._outputFolder, this._attachmentsBaseURL);
|
||||
|
|
@ -223,14 +223,14 @@ export function startHtmlReportServer(folder: string): HttpServer {
|
|||
}
|
||||
|
||||
class HtmlBuilder {
|
||||
private _config: FullConfig;
|
||||
private _config: api.FullConfig;
|
||||
private _reportFolder: string;
|
||||
private _stepsInFile = new MultiMap<string, TestStep>();
|
||||
private _dataZipFile: ZipFile;
|
||||
private _hasTraces = false;
|
||||
private _attachmentsBaseURL: string;
|
||||
|
||||
constructor(config: FullConfig, outputDir: string, attachmentsBaseURL: string) {
|
||||
constructor(config: api.FullConfig, outputDir: string, attachmentsBaseURL: string) {
|
||||
this._config = config;
|
||||
this._reportFolder = outputDir;
|
||||
fs.mkdirSync(this._reportFolder, { recursive: true });
|
||||
|
|
@ -238,7 +238,7 @@ class HtmlBuilder {
|
|||
this._attachmentsBaseURL = attachmentsBaseURL;
|
||||
}
|
||||
|
||||
async build(metadata: Metadata, projectSuites: Suite[], result: FullResult, topLevelErrors: TestError[]): Promise<{ ok: boolean, singleTestId: string | undefined }> {
|
||||
async build(metadata: Metadata, projectSuites: api.Suite[], result: api.FullResult, topLevelErrors: api.TestError[]): Promise<{ ok: boolean, singleTestId: string | undefined }> {
|
||||
const data = new Map<string, { testFile: TestFile, testFileSummary: TestFileSummary }>();
|
||||
for (const projectSuite of projectSuites) {
|
||||
for (const fileSuite of projectSuite.suites) {
|
||||
|
|
@ -378,7 +378,7 @@ class HtmlBuilder {
|
|||
this._dataZipFile.addBuffer(Buffer.from(JSON.stringify(data)), fileName);
|
||||
}
|
||||
|
||||
private _processSuite(suite: Suite, projectName: string, path: string[], outTests: TestEntry[]) {
|
||||
private _processSuite(suite: api.Suite, projectName: string, path: string[], outTests: TestEntry[]) {
|
||||
const newPath = [...path, suite.title];
|
||||
suite.entries().forEach(e => {
|
||||
if (e.type === 'test')
|
||||
|
|
@ -388,7 +388,7 @@ class HtmlBuilder {
|
|||
});
|
||||
}
|
||||
|
||||
private _createTestEntry(test: TestCasePublic, projectName: string, path: string[]): TestEntry {
|
||||
private _createTestEntry(test: api.TestCase, projectName: string, path: string[]): TestEntry {
|
||||
const duration = test.results.reduce((a, r) => a + r.duration, 0);
|
||||
const location = this._relativeLocation(test.location)!;
|
||||
path = path.slice(1).filter(path => path.length > 0);
|
||||
|
|
@ -500,12 +500,12 @@ class HtmlBuilder {
|
|||
}).filter(Boolean) as TestAttachment[];
|
||||
}
|
||||
|
||||
private _createTestResult(test: TestCasePublic, result: TestResultPublic): TestResult {
|
||||
private _createTestResult(test: api.TestCase, result: api.TestResult): TestResult {
|
||||
return {
|
||||
duration: result.duration,
|
||||
startTime: result.startTime.toISOString(),
|
||||
retry: result.retry,
|
||||
steps: dedupeSteps(result.steps).map(s => this._createTestStep(s)),
|
||||
steps: dedupeSteps(result.steps).map(s => this._createTestStep(s, result)),
|
||||
errors: formatResultFailure(test, result, '', true).map(error => error.message),
|
||||
status: result.status,
|
||||
attachments: this._serializeAttachments([
|
||||
|
|
@ -515,23 +515,29 @@ class HtmlBuilder {
|
|||
};
|
||||
}
|
||||
|
||||
private _createTestStep(dedupedStep: DedupedStep): TestStep {
|
||||
private _createTestStep(dedupedStep: DedupedStep, result: api.TestResult): TestStep {
|
||||
const { step, duration, count } = dedupedStep;
|
||||
const result: TestStep = {
|
||||
const testStep: TestStep = {
|
||||
title: step.title,
|
||||
startTime: step.startTime.toISOString(),
|
||||
duration,
|
||||
steps: dedupeSteps(step.steps).map(s => this._createTestStep(s)),
|
||||
steps: dedupeSteps(step.steps).map(s => this._createTestStep(s, result)),
|
||||
attachments: step.attachments.map(s => {
|
||||
const index = result.attachments.indexOf(s);
|
||||
if (index === -1)
|
||||
throw new Error('Unexpected, attachment not found');
|
||||
return index;
|
||||
}),
|
||||
location: this._relativeLocation(step.location),
|
||||
error: step.error?.message,
|
||||
count
|
||||
};
|
||||
if (step.location)
|
||||
this._stepsInFile.set(step.location.file, result);
|
||||
return result;
|
||||
this._stepsInFile.set(step.location.file, testStep);
|
||||
return testStep;
|
||||
}
|
||||
|
||||
private _relativeLocation(location: Location | undefined): Location | undefined {
|
||||
private _relativeLocation(location: api.Location | undefined): api.Location | undefined {
|
||||
if (!location)
|
||||
return undefined;
|
||||
const file = toPosixPath(path.relative(this._config.rootDir, location.file));
|
||||
|
|
@ -609,9 +615,9 @@ function stdioAttachment(chunk: Buffer | string, type: 'stdout' | 'stderr'): Jso
|
|||
};
|
||||
}
|
||||
|
||||
type DedupedStep = { step: TestStepPublic, count: number, duration: number };
|
||||
type DedupedStep = { step: api.TestStep, count: number, duration: number };
|
||||
|
||||
function dedupeSteps(steps: TestStepPublic[]) {
|
||||
function dedupeSteps(steps: api.TestStep[]) {
|
||||
const result: DedupedStep[] = [];
|
||||
let lastResult = undefined;
|
||||
for (const step of steps) {
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ export class TeleReporterEmitter implements ReporterV2 {
|
|||
params: {
|
||||
testId: test.id,
|
||||
resultId: (result as any)[this._idSymbol],
|
||||
step: this._serializeStepEnd(step)
|
||||
step: this._serializeStepEnd(step, result)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -251,11 +251,12 @@ export class TeleReporterEmitter implements ReporterV2 {
|
|||
};
|
||||
}
|
||||
|
||||
private _serializeStepEnd(step: reporterTypes.TestStep): teleReceiver.JsonTestStepEnd {
|
||||
private _serializeStepEnd(step: reporterTypes.TestStep, result: reporterTypes.TestResult): teleReceiver.JsonTestStepEnd {
|
||||
return {
|
||||
id: (step as any)[this._idSymbol],
|
||||
duration: step.duration,
|
||||
error: step.error,
|
||||
attachments: step.attachments.map(a => result.attachments.indexOf(a)),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -320,6 +320,7 @@ class JobDispatcher {
|
|||
startTime: new Date(params.wallTime),
|
||||
duration: -1,
|
||||
steps: [],
|
||||
attachments: [],
|
||||
location: params.location,
|
||||
};
|
||||
steps.set(params.stepId, step);
|
||||
|
|
@ -361,6 +362,13 @@ class JobDispatcher {
|
|||
body: params.body !== undefined ? Buffer.from(params.body, 'base64') : undefined
|
||||
};
|
||||
data.result.attachments.push(attachment);
|
||||
if (params.stepId) {
|
||||
const step = data.steps.get(params.stepId);
|
||||
if (step)
|
||||
step.attachments.push(attachment);
|
||||
else
|
||||
this._reporter.onStdErr?.('Internal error: step id not found: ' + params.stepId);
|
||||
}
|
||||
}
|
||||
|
||||
private _failTestWithErrors(test: TestCase, errors: TestError[]) {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue