diff --git a/.github/workflows/infra.yml b/.github/workflows/infra.yml index 3580ecc97a..f33c8535f0 100644 --- a/.github/workflows/infra.yml +++ b/.github/workflows/infra.yml @@ -44,10 +44,10 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 18 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: '3.11' - - uses: actions/setup-dotnet@v3 + - uses: actions/setup-dotnet@v4 with: dotnet-version: 8.0.x - run: npm ci diff --git a/.github/workflows/tests_others.yml b/.github/workflows/tests_others.yml index 783f3fe2ff..434c5aa9d3 100644 --- a/.github/workflows/tests_others.yml +++ b/.github/workflows/tests_others.yml @@ -66,7 +66,7 @@ jobs: contents: read # This is required for actions/checkout to succeed steps: - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v3 + - uses: actions/setup-dotnet@v4 with: dotnet-version: '8.0.x' - run: dotnet build diff --git a/.github/workflows/tests_secondary.yml b/.github/workflows/tests_secondary.yml index a6b473b72b..8d4bbd1c78 100644 --- a/.github/workflows/tests_secondary.yml +++ b/.github/workflows/tests_secondary.yml @@ -50,7 +50,9 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-12, macos-13, macos-14] + # Intel: macos-13, macos-14-large + # Arm64: macos-13-xlarge, macos-14 + os: [macos-13, macos-13-xlarge, macos-14-large, macos-14] browser: [chromium, firefox, webkit] runs-on: ${{ matrix.os }} steps: @@ -235,7 +237,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-20.04, macos-12, windows-latest] + os: [ubuntu-20.04, macos-13, windows-latest] steps: - uses: actions/checkout@v4 - uses: ./.github/actions/run-test diff --git a/.github/workflows/tests_service.yml b/.github/workflows/tests_service.yml index 2788b0cf08..2d68740006 100644 --- a/.github/workflows/tests_service.yml +++ b/.github/workflows/tests_service.yml @@ -34,7 +34,7 @@ jobs: PLAYWRIGHT_SERVICE_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}-${{ github.sha }} - name: Upload blob report to GitHub if: ${{ !cancelled() }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: all-blob-reports path: blob-report diff --git a/README.md b/README.md index 48c0014914..d0d8e53dd0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🎭 Playwright -[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-129.0.6668.29-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-130.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.0-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord) +[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-129.0.6668.42-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-130.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.0-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord) ## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright) @@ -8,7 +8,7 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 129.0.6668.29 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Chromium 129.0.6668.42 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 18.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox 130.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | diff --git a/browser_patches/firefox/juggler/protocol/PageHandler.js b/browser_patches/firefox/juggler/protocol/PageHandler.js index 8fa9a06361..bab151b392 100644 --- a/browser_patches/firefox/juggler/protocol/PageHandler.js +++ b/browser_patches/firefox/juggler/protocol/PageHandler.js @@ -256,6 +256,13 @@ class PageHandler { return await this._contentPage.send('disposeObject', options); } + async ['Heap.collectGarbage']() { + Services.obs.notifyObservers(null, "child-gc-request"); + Cu.forceGC(); + Services.obs.notifyObservers(null, "child-cc-request"); + Cu.forceCC(); + } + async ['Network.getResponseBody']({requestId}) { return this._pageNetwork.getResponseBody(requestId); } diff --git a/browser_patches/firefox/juggler/protocol/Protocol.js b/browser_patches/firefox/juggler/protocol/Protocol.js index 6c9b700f05..2b7ad56d6a 100644 --- a/browser_patches/firefox/juggler/protocol/Protocol.js +++ b/browser_patches/firefox/juggler/protocol/Protocol.js @@ -487,6 +487,17 @@ const Browser = { }, }; +const Heap = { + targets: ['page'], + types: {}, + events: {}, + methods: { + 'collectGarbage': { + params: {}, + }, + }, +}; + const Network = { targets: ['page'], types: networkTypes, @@ -1002,7 +1013,7 @@ const Accessibility = { } this.protocol = { - domains: {Browser, Page, Runtime, Network, Accessibility}, + domains: {Browser, Heap, Page, Runtime, Network, Accessibility}, }; this.checkScheme = checkScheme; this.EXPORTED_SYMBOLS = ['protocol', 'checkScheme']; diff --git a/docs/src/api/class-apirequestcontext.md b/docs/src/api/class-apirequestcontext.md index 1d3e728235..f56087d1bd 100644 --- a/docs/src/api/class-apirequestcontext.md +++ b/docs/src/api/class-apirequestcontext.md @@ -159,7 +159,10 @@ context cookies from the response. The method will automatically follow redirect ### option: APIRequestContext.delete.data = %%-js-python-csharp-fetch-option-data-%% * since: v1.17 -### option: APIRequestContext.delete.form = %%-js-python-fetch-option-form-%% +### option: APIRequestContext.delete.form = %%-js-fetch-option-form-%% +* since: v1.17 + +### option: APIRequestContext.delete.form = %%-python-fetch-option-form-%% * since: v1.17 ### option: APIRequestContext.delete.form = %%-csharp-fetch-option-form-%% @@ -332,7 +335,10 @@ If set changes the fetch method (e.g. [PUT](https://developer.mozilla.org/en-US/ ### option: APIRequestContext.fetch.data = %%-js-python-csharp-fetch-option-data-%% * since: v1.16 -### option: APIRequestContext.fetch.form = %%-js-python-fetch-option-form-%% +### option: APIRequestContext.fetch.form = %%-js-fetch-option-form-%% +* since: v1.16 + +### option: APIRequestContext.fetch.form = %%-python-fetch-option-form-%% * since: v1.16 ### option: APIRequestContext.fetch.form = %%-csharp-fetch-option-form-%% @@ -442,7 +448,10 @@ await request.GetAsync("https://example.com/api/getText", new() { Params = query ### option: APIRequestContext.get.data = %%-js-python-csharp-fetch-option-data-%% * since: v1.26 -### option: APIRequestContext.get.form = %%-js-python-fetch-option-form-%% +### option: APIRequestContext.get.form = %%-js-fetch-option-form-%% +* since: v1.26 + +### option: APIRequestContext.get.form = %%-python-fetch-option-form-%% * since: v1.26 ### option: APIRequestContext.get.form = %%-csharp-fetch-option-form-%% @@ -504,7 +513,10 @@ context cookies from the response. The method will automatically follow redirect ### option: APIRequestContext.head.data = %%-js-python-csharp-fetch-option-data-%% * since: v1.26 -### option: APIRequestContext.head.form = %%-js-python-fetch-option-form-%% +### option: APIRequestContext.head.form = %%-python-fetch-option-form-%% +* since: v1.26 + +### option: APIRequestContext.head.form = %%-js-fetch-option-form-%% * since: v1.26 ### option: APIRequestContext.head.form = %%-csharp-fetch-option-form-%% @@ -566,7 +578,10 @@ context cookies from the response. The method will automatically follow redirect ### option: APIRequestContext.patch.data = %%-js-python-csharp-fetch-option-data-%% * since: v1.16 -### option: APIRequestContext.patch.form = %%-js-python-fetch-option-form-%% +### option: APIRequestContext.patch.form = %%-js-fetch-option-form-%% +* since: v1.16 + +### option: APIRequestContext.patch.form = %%-python-fetch-option-form-%% * since: v1.16 ### option: APIRequestContext.patch.form = %%-csharp-fetch-option-form-%% @@ -749,7 +764,10 @@ await request.PostAsync("https://example.com/api/uploadScript", new() { Multipar ### option: APIRequestContext.post.data = %%-js-python-csharp-fetch-option-data-%% * since: v1.16 -### option: APIRequestContext.post.form = %%-js-python-fetch-option-form-%% +### option: APIRequestContext.post.form = %%-js-fetch-option-form-%% +* since: v1.16 + +### option: APIRequestContext.post.form = %%-python-fetch-option-form-%% * since: v1.16 ### option: APIRequestContext.post.form = %%-csharp-fetch-option-form-%% @@ -811,7 +829,10 @@ context cookies from the response. The method will automatically follow redirect ### option: APIRequestContext.put.data = %%-js-python-csharp-fetch-option-data-%% * since: v1.16 -### option: APIRequestContext.put.form = %%-js-python-fetch-option-form-%% +### option: APIRequestContext.put.form = %%-python-fetch-option-form-%% +* since: v1.16 + +### option: APIRequestContext.put.form = %%-js-fetch-option-form-%% * since: v1.16 ### option: APIRequestContext.put.form = %%-csharp-fetch-option-form-%% diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index 4df0035098..88658b5494 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -1654,7 +1654,9 @@ var banana = await page.GetByRole(AriaRole.Listitem).Nth(2); - alias-python: or_ - returns: <[Locator]> -Creates a locator that matches either of the two locators. +Creates a locator matching all elements that match one or both of the two locators. + +Note that when both locators match something, the resulting locator will have multiple matches and violate [locator strictness](../locators.md#strictness) guidelines. **Usage** diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index e1aa908041..d36a96ee72 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -2333,6 +2333,11 @@ last redirect. If cannot go forward, returns `null`. Navigate to the next page in history. +## async method: Page.forceGarbageCollection +* since: v1.47 + +Force the browser to perform garbage collection. + ### option: Page.goForward.waitUntil = %%-navigation-wait-until-%% * since: v1.8 diff --git a/docs/src/api/params.md b/docs/src/api/params.md index cbec1a5e25..de930ee97e 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -405,8 +405,16 @@ Request timeout in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to d Whether to throw on response codes other than 2xx and 3xx. By default response object is returned for all status codes. -## js-python-fetch-option-form -* langs: js, python +## js-fetch-option-form +* langs: js +- `form` <[Object]<[string], [string]|[float]|[boolean]>|[FormData]> + +Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as +this request body. If this parameter is specified `content-type` header will be set to `application/x-www-form-urlencoded` +unless explicitly provided. + +## python-fetch-option-form +* langs: python - `form` <[Object]<[string], [string]|[float]|[boolean]>> Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as @@ -550,10 +558,6 @@ TLS Client Authentication allows the server to request a client certificate and An array of client certificates to be used. Each certificate object must have either both `certPath` and `keyPath`, a single `pfxPath`, or their corresponding direct value equivalents (`cert` and `key`, or `pfx`). Optionally, `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided with an exact match to the request origin that the certificate is valid for. -:::note -Using Client Certificates in combination with Proxy Servers is not supported. -::: - :::note When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it work by replacing `localhost` with `local.playwright`. ::: diff --git a/docs/src/ci-intro.md b/docs/src/ci-intro.md index 492130088a..9e0ee0eb4f 100644 --- a/docs/src/ci-intro.md +++ b/docs/src/ci-intro.md @@ -25,7 +25,7 @@ Playwright tests can be ran on any CI provider. In this section we will cover ru #### You will learn * langs: python, java, csharp - + - [How to set up GitHub Actions](/ci-intro.md#setting-up-github-actions) - [How to view test logs](/ci-intro.md#viewing-test-logs) - [How to view the trace](/ci-intro.md#viewing-the-trace) @@ -322,5 +322,5 @@ This step will not work for pull requests created from a forked repository becau - [Learn how to perform Actions](./input.md) - [Learn how to write Assertions](./test-assertions.md) - [Learn more about the Trace Viewer](/trace-viewer.md) -- [Learn more ways of running tests on GitHub Actions](/ci.md) -- [Learn more about running tests on other CI providers](/ci.md#github-actions) // TODO: is this link correct? \ No newline at end of file +- [Learn more ways of running tests on GitHub Actions](/ci.md#github-actions) +- [Learn more about running tests on other CI providers](/ci.md) diff --git a/docs/src/ci.md b/docs/src/ci.md index 97ea4ad8d2..745ecc9d50 100644 --- a/docs/src/ci.md +++ b/docs/src/ci.md @@ -209,6 +209,7 @@ jobs: runs-on: ubuntu-latest container: image: mcr.microsoft.com/playwright:v%%VERSION%%-jammy + options: --user 1001 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -218,8 +219,6 @@ jobs: run: npm ci - name: Run your tests run: npx playwright test - env: - HOME: /root ``` ```yml python title=".github/workflows/playwright.yml" @@ -235,6 +234,7 @@ jobs: runs-on: ubuntu-latest container: image: mcr.microsoft.com/playwright/python:v%%VERSION%%-jammy + options: --user 1001 steps: - uses: actions/checkout@v4 - name: Set up Python @@ -248,8 +248,6 @@ jobs: pip install -e . - name: Run your tests run: pytest - env: - HOME: /root ``` ```yml java title=".github/workflows/playwright.yml" @@ -265,6 +263,7 @@ jobs: runs-on: ubuntu-latest container: image: mcr.microsoft.com/playwright/java:v%%VERSION%%-jammy + options: --user 1001 steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v3 @@ -275,8 +274,6 @@ jobs: run: mvn -B install -D skipTests --no-transfer-progress - name: Run tests run: mvn test - env: - HOME: /root ``` ```yml csharp title=".github/workflows/playwright.yml" @@ -292,6 +289,7 @@ jobs: runs-on: ubuntu-latest container: image: mcr.microsoft.com/playwright/dotnet:v%%VERSION%%-jammy + options: --user 1001 steps: - uses: actions/checkout@v4 - name: Setup dotnet @@ -301,8 +299,6 @@ jobs: - run: dotnet build - name: Run your tests run: dotnet test - env: - HOME: /root ``` #### On deployment diff --git a/docs/src/release-notes-csharp.md b/docs/src/release-notes-csharp.md index 6c357ba3b5..b6a249b0eb 100644 --- a/docs/src/release-notes-csharp.md +++ b/docs/src/release-notes-csharp.md @@ -18,8 +18,9 @@ The Network tab in the trace viewer has several nice improvements: ### Miscellaneous -- The `mcr.microsoft.com/playwright-dotnet:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble. - To use the 22.04 jammy-based image, please use `mcr.microsoft.com/playwright-dotnet:v1.47.0-jammy` instead. +- The `mcr.microsoft.com/playwright/dotnet:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble. + To use the 22.04 jammy-based image, please use `mcr.microsoft.com/playwright/dotnet:v1.47.0-jammy` instead. +- The `:latest`/`:focal`/`:jammy` tag for Playwright Docker images is no longer being published. Pin to a specific version for better stability and reproducibility. - TLS client certificates can now be passed from memory by passing [`option: cert`] and [`option: key`] as byte arrays instead of file paths. - [`option: noWaitAfter`] in [`method: Locator.selectOption`] was deprecated. - We've seen reports of WebGL in Webkit misbehaving on GitHub Actions `macos-13`. We recommend upgrading GitHub Actions to `macos-14`. diff --git a/docs/src/release-notes-java.md b/docs/src/release-notes-java.md index 2b9789e8bc..89a49f7451 100644 --- a/docs/src/release-notes-java.md +++ b/docs/src/release-notes-java.md @@ -18,8 +18,9 @@ The Network tab in the trace viewer has several nice improvements: ### Miscellaneous -- The `mcr.microsoft.com/playwright-java:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble. - To use the 22.02 jammy-based image, please use `mcr.microsoft.com/playwright-java:v1.47.0-jammy` instead. +- The `mcr.microsoft.com/playwright/java:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble. + To use the 22.02 jammy-based image, please use `mcr.microsoft.com/playwright/java:v1.47.0-jammy` instead. +- The `:latest`/`:focal`/`:jammy` tag for Playwright Docker images is no longer being published. Pin to a specific version for better stability and reproducibility. - TLS client certificates can now be passed from memory by passing [`option: cert`] and [`option: key`] as byte arrays instead of file paths. - [`option: noWaitAfter`] in [`method: Locator.selectOption`] was deprecated. - We've seen reports of WebGL in Webkit misbehaving on GitHub Actions `macos-13`. We recommend upgrading GitHub Actions to `macos-14`. diff --git a/docs/src/release-notes-python.md b/docs/src/release-notes-python.md index 985c19d388..7defa1c0d1 100644 --- a/docs/src/release-notes-python.md +++ b/docs/src/release-notes-python.md @@ -18,8 +18,9 @@ The Network tab in the trace viewer has several nice improvements: ### Miscellaneous -- The `mcr.microsoft.com/playwright-python:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble. - To use the 22.04 jammy-based image, please use `mcr.microsoft.com/playwright-python:v1.47.0-jammy` instead. +- The `mcr.microsoft.com/playwright/python:v1.47.0` now serves a Playwright image based on Ubuntu 24.04 Noble. + To use the 22.04 jammy-based image, please use `mcr.microsoft.com/playwright/python:v1.47.0-jammy` instead. +- The `:latest`/`:focal`/`:jammy` tag for Playwright Docker images is no longer being published. Pin to a specific version for better stability and reproducibility. - TLS client certificates can now be passed from memory by passing [`option: cert`] and [`option: key`] as bytes instead of file paths. - [`option: noWaitAfter`] in [`method: Locator.selectOption`] was deprecated. - We've seen reports of WebGL in Webkit misbehaving on GitHub Actions `macos-13`. We recommend upgrading GitHub Actions to `macos-14`. diff --git a/packages/html-reporter/src/copyToClipboard.css b/packages/html-reporter/src/copyToClipboard.css index 5790b626c0..818100fff7 100644 --- a/packages/html-reporter/src/copyToClipboard.css +++ b/packages/html-reporter/src/copyToClipboard.css @@ -20,15 +20,31 @@ width: 24px; border: none; outline: none; - color: var(--color-fg-default); + color: var(--color-fg-muted); background: transparent; padding: 4px; cursor: pointer; display: inline-flex; align-items: center; + justify-content: center; border-radius: 4px; } +.copy-icon svg { + margin: 0; +} + .copy-icon:not(:disabled):hover { background-color: var(--color-border-default); } + +.copy-button-container { + visibility: hidden; + display: inline-flex; + margin-left: 8px; + vertical-align: bottom; +} + +.copy-value-container:hover .copy-button-container { + visibility: visible; +} diff --git a/packages/html-reporter/src/copyToClipboard.tsx b/packages/html-reporter/src/copyToClipboard.tsx index a24015a671..17b1dfbf95 100644 --- a/packages/html-reporter/src/copyToClipboard.tsx +++ b/packages/html-reporter/src/copyToClipboard.tsx @@ -18,9 +18,14 @@ import * as React from 'react'; import * as icons from './icons'; import './copyToClipboard.css'; -export const CopyToClipboard: React.FunctionComponent<{ - value: string, -}> = ({ value }) => { +type CopyToClipboardProps = { + value: string; +}; + +/** + * A copy to clipboard button. + */ +export const CopyToClipboard: React.FunctionComponent = ({ value }) => { type IconType = 'copy' | 'check' | 'cross'; const [icon, setIcon] = React.useState('copy'); const handleCopy = React.useCallback(() => { @@ -34,5 +39,21 @@ export const CopyToClipboard: React.FunctionComponent<{ }); }, [value]); const iconElement = icon === 'check' ? icons.check() : icon === 'cross' ? icons.cross() : icons.copy(); - return ; + return ; +}; + +type CopyToClipboardContainerProps = CopyToClipboardProps & { + children: React.ReactNode +}; + +/** + * Container for displaying a copy to clipboard button alongside children. + */ +export const CopyToClipboardContainer: React.FunctionComponent = ({ children, value }) => { + return + {children} + + + + ; }; diff --git a/packages/html-reporter/src/testCaseView.spec.tsx b/packages/html-reporter/src/testCaseView.spec.tsx index afe06cebcb..72552f5184 100644 --- a/packages/html-reporter/src/testCaseView.spec.tsx +++ b/packages/html-reporter/src/testCaseView.spec.tsx @@ -76,6 +76,19 @@ test('should render test case', async ({ mount }) => { await expect(component.getByText('My test')).toBeVisible(); }); +test('should render copy buttons for annotations', async ({ mount, page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + + const component = await mount(); + await expect(component.getByText('Annotation text', { exact: false }).first()).toBeVisible(); + component.getByText('Annotation text', { exact: false }).first().hover(); + await expect(component.getByLabel('Copy to clipboard').first()).toBeVisible(); + await component.getByLabel('Copy to clipboard').first().click(); + const handle = await page.evaluateHandle(() => navigator.clipboard.readText()); + const clipboardContent = await handle.jsonValue(); + expect(clipboardContent).toBe('Annotation text'); +}); + const annotationLinkRenderingTestCase: TestCase = { testId: 'testid', title: 'My test', diff --git a/packages/html-reporter/src/testCaseView.tsx b/packages/html-reporter/src/testCaseView.tsx index 5bed3c8309..1fe4f42ed3 100644 --- a/packages/html-reporter/src/testCaseView.tsx +++ b/packages/html-reporter/src/testCaseView.tsx @@ -26,6 +26,7 @@ import { TestResultView } from './testResultView'; import { linkifyText } from '@web/renderUtils'; import { hashStringToInt, msToString } from './utils'; import { clsx } from '@web/uiUtils'; +import { CopyToClipboardContainer } from './copyToClipboard'; export const TestCaseView: React.FC<{ projectNames: string[], @@ -73,7 +74,7 @@ function TestCaseAnnotationView({ annotation: { type, description } }: { annotat return (
{type} - {description && : {linkifyText(description)}} + {description && : {linkifyText(description)}}
); } diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 3a1834117c..dc91235659 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -3,31 +3,31 @@ "browsers": [ { "name": "chromium", - "revision": "1134", + "revision": "1135", "installByDefault": true, - "browserVersion": "129.0.6668.29" + "browserVersion": "129.0.6668.42" }, { "name": "chromium-tip-of-tree", - "revision": "1256", + "revision": "1259", "installByDefault": false, - "browserVersion": "130.0.6695.0" + "browserVersion": "130.0.6713.0" }, { "name": "firefox", - "revision": "1463", + "revision": "1464", "installByDefault": true, "browserVersion": "130.0" }, { "name": "firefox-beta", - "revision": "1463", + "revision": "1464", "installByDefault": false, "browserVersion": "131.0b2" }, { "name": "webkit", - "revision": "2073", + "revision": "2077", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", @@ -42,7 +42,11 @@ { "name": "ffmpeg", "revision": "1010", - "installByDefault": true + "installByDefault": true, + "revisionOverrides": { + "mac12": "1010", + "mac12-arm64": "1010" + } }, { "name": "android", diff --git a/packages/playwright-core/src/cli/program.ts b/packages/playwright-core/src/cli/program.ts index 9c68271e80..d8fa8230c6 100644 --- a/packages/playwright-core/src/cli/program.ts +++ b/packages/playwright-core/src/cli/program.ts @@ -348,10 +348,10 @@ type CaptureOptions = { fullPage: boolean; }; -async function launchContext(options: Options, headless: boolean, executablePath?: string): Promise<{ browser: Browser, browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, context: BrowserContext }> { +async function launchContext(options: Options, extraOptions: LaunchOptions): Promise<{ browser: Browser, browserName: string, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, context: BrowserContext }> { validateOptions(options); const browserType = lookupBrowserType(options); - const launchOptions: LaunchOptions = { headless, executablePath }; + const launchOptions: LaunchOptions = extraOptions; if (options.channel) launchOptions.channel = options.channel as any; launchOptions.handleSIGINT = false; @@ -363,7 +363,7 @@ async function launchContext(options: Options, headless: boolean, executablePath // In headful mode, use host device scale factor for things to look nice. // In headless, keep things the way it works in Playwright by default. // Assume high-dpi on MacOS. TODO: this is not perfect. - if (!headless) + if (!extraOptions.headless) contextOptions.deviceScaleFactor = os.platform() === 'darwin' ? 2 : 1; // Work around the WebKit GTK scrolling issue. @@ -547,7 +547,7 @@ async function openPage(context: BrowserContext, url: string | undefined): Promi } async function open(options: Options, url: string | undefined, language: string) { - const { context, launchOptions, contextOptions } = await launchContext(options, !!process.env.PWTEST_CLI_HEADLESS, process.env.PWTEST_CLI_EXECUTABLE_PATH); + const { context, launchOptions, contextOptions } = await launchContext(options, { headless: !!process.env.PWTEST_CLI_HEADLESS, executablePath: process.env.PWTEST_CLI_EXECUTABLE_PATH }); await context._enableRecorder({ language, launchOptions, @@ -560,8 +560,17 @@ async function open(options: Options, url: string | undefined, language: string) async function codegen(options: Options & { target: string, output?: string, testIdAttribute?: string }, url: string | undefined) { const { target: language, output: outputFile, testIdAttribute: testIdAttributeName } = options; - const { context, launchOptions, contextOptions } = await launchContext(options, !!process.env.PWTEST_CLI_HEADLESS, process.env.PWTEST_CLI_EXECUTABLE_PATH); + const tracesDir = path.join(os.tmpdir(), `recorder-trace-${Date.now()}`); + const { context, launchOptions, contextOptions } = await launchContext(options, { + headless: !!process.env.PWTEST_CLI_HEADLESS, + executablePath: process.env.PWTEST_CLI_EXECUTABLE_PATH, + tracesDir, + }); dotenv.config({ path: 'playwright.env' }); + if (process.env.PW_RECORDER_IS_TRACE_VIEWER) { + await fs.promises.mkdir(tracesDir, { recursive: true }); + await context.tracing.start({ name: 'trace', _live: true }); + } await context._enableRecorder({ language, launchOptions, @@ -587,7 +596,7 @@ async function waitForPage(page: Page, captureOptions: CaptureOptions) { } async function screenshot(options: Options, captureOptions: CaptureOptions, url: string, path: string) { - const { context } = await launchContext(options, true); + const { context } = await launchContext(options, { headless: true }); console.log('Navigating to ' + url); const page = await openPage(context, url); await waitForPage(page, captureOptions); @@ -600,7 +609,7 @@ async function screenshot(options: Options, captureOptions: CaptureOptions, url: async function pdf(options: Options, captureOptions: CaptureOptions, url: string, path: string) { if (options.browser !== 'chromium') throw new Error('PDF creation is only working with Chromium'); - const { context } = await launchContext({ ...options, browser: 'chromium' }, true); + const { context } = await launchContext({ ...options, browser: 'chromium' }, { headless: true }); console.log('Navigating to ' + url); const page = await openPage(context, url); await waitForPage(page, captureOptions); diff --git a/packages/playwright-core/src/client/fetch.ts b/packages/playwright-core/src/client/fetch.ts index 87c31579b5..58928532ac 100644 --- a/packages/playwright-core/src/client/fetch.ts +++ b/packages/playwright-core/src/client/fetch.ts @@ -36,8 +36,8 @@ export type FetchOptions = { method?: string, headers?: Headers, data?: string | Buffer | Serializable, - form?: { [key: string]: string|number|boolean; }; - multipart?: { [key: string]: string|number|boolean|fs.ReadStream|FilePayload; }; + form?: { [key: string]: string|number|boolean; } | FormData; + multipart?: { [key: string]: string|number|boolean|fs.ReadStream|FilePayload; } | FormData; timeout?: number, failOnStatusCode?: boolean, ignoreHTTPSErrors?: boolean, @@ -202,7 +202,16 @@ export class APIRequestContext extends ChannelOwner implements api.Page return Response.fromNullable((await this._channel.goForward({ ...options, waitUntil })).response); } + async forceGarbageCollection() { + await this._channel.forceGarbageCollection(); + } + async emulateMedia(options: { media?: 'screen' | 'print' | null, colorScheme?: 'dark' | 'light' | 'no-preference' | null, reducedMotion?: 'reduce' | 'no-preference' | null, forcedColors?: 'active' | 'none' | null } = {}) { await this._channel.emulateMedia({ media: options.media === null ? 'no-override' : options.media, diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index b67edcbca8..abea7f8fce 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1122,6 +1122,8 @@ scheme.PageGoForwardParams = tObject({ scheme.PageGoForwardResult = tObject({ response: tOptional(tChannel(['Response'])), }); +scheme.PageForceGarbageCollectionParams = tOptional(tObject({})); +scheme.PageForceGarbageCollectionResult = tOptional(tObject({})); scheme.PageRegisterLocatorHandlerParams = tObject({ selector: tString, noWaitAfter: tOptional(tBoolean), diff --git a/packages/playwright-core/src/server/android/android.ts b/packages/playwright-core/src/server/android/android.ts index 0b4cb331b0..1af083916c 100644 --- a/packages/playwright-core/src/server/android/android.ts +++ b/packages/playwright-core/src/server/android/android.ts @@ -29,6 +29,7 @@ import { validateBrowserContextOptions } from '../browserContext'; import { ProgressController } from '../progress'; import { CRBrowser } from '../chromium/crBrowser'; import { helper } from '../helper'; +import type * as types from '../types'; import { PipeTransport } from '../../protocol/transport'; import { RecentLogsCollector } from '../../utils/debugLogger'; import { gracefullyCloseSet } from '../../utils/processLauncher'; @@ -309,7 +310,7 @@ export class AndroidDevice extends SdkObject { return await this._connectToBrowser(socketName); } - private async _connectToBrowser(socketName: string, options: channels.BrowserNewContextParams = {}): Promise { + private async _connectToBrowser(socketName: string, options: types.BrowserContextOptions = {}): Promise { const socket = await this._waitForLocalAbstract(socketName); const androidBrowser = new AndroidBrowser(this, socket); await androidBrowser._init(); diff --git a/packages/playwright-core/src/server/bidi/bidiBrowser.ts b/packages/playwright-core/src/server/bidi/bidiBrowser.ts index 0c658a82b4..9861fc80cf 100644 --- a/packages/playwright-core/src/server/bidi/bidiBrowser.ts +++ b/packages/playwright-core/src/server/bidi/bidiBrowser.ts @@ -94,6 +94,14 @@ export class BidiBrowser extends Browser { 'script', ], }); + + if (options.persistent) { + browser._defaultContext = new BidiBrowserContext(browser, undefined, options.persistent); + await (browser._defaultContext as BidiBrowserContext)._initialize(); + // Create default page as we cannot get access to the existing one. + const pageDelegate = await browser._defaultContext.newPageDelegate(); + await pageDelegate.pageOrError(); + } return browser; } @@ -111,7 +119,7 @@ export class BidiBrowser extends Browser { this._didClose(); } - async doCreateNewContext(options: channels.BrowserNewContextParams): Promise { + async doCreateNewContext(options: types.BrowserContextOptions): Promise { const { userContext } = await this._browserSession.send('browser.createUserContext', {}); const context = new BidiBrowserContext(this, userContext, options); await context._initialize(); @@ -190,7 +198,7 @@ export class BidiBrowser extends Browser { export class BidiBrowserContext extends BrowserContext { declare readonly _browser: BidiBrowser; - constructor(browser: BidiBrowser, browserContextId: string | undefined, options: channels.BrowserNewContextParams) { + constructor(browser: BidiBrowser, browserContextId: string | undefined, options: types.BrowserContextOptions) { super(browser, options, browserContextId); this._authenticateProxyViaHeader(); } @@ -294,10 +302,11 @@ export class BidiBrowserContext extends BrowserContext { } async doClose(reason: string | undefined) { - // TODO: implement for persistent context - if (!this._browserContextId) + if (!this._browserContextId) { + // Closing persistent context should close the browser. + await this._browser.close({ reason }); return; - + } await this._browser._browserSession.send('browser.removeUserContext', { userContext: this._browserContextId }); diff --git a/packages/playwright-core/src/server/bidi/bidiChromium.ts b/packages/playwright-core/src/server/bidi/bidiChromium.ts index e94dabf072..32751bd51a 100644 --- a/packages/playwright-core/src/server/bidi/bidiChromium.ts +++ b/packages/playwright-core/src/server/bidi/bidiChromium.ts @@ -91,7 +91,7 @@ export class BidiChromium extends BrowserType { } private _innerDefaultArgs(options: types.LaunchOptions): string[] { - const { args = [], proxy } = options; + const { args = [] } = options; const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir')); if (userDataDirArg) throw this._createUserDataDirArgMisuseError('--user-data-dir'); @@ -125,6 +125,7 @@ export class BidiChromium extends BrowserType { } if (options.chromiumSandbox !== true) chromeArguments.push('--no-sandbox'); + const proxy = options.proxyOverride || options.proxy; if (proxy) { const proxyURL = new URL(proxy.server); const isSocks = proxyURL.protocol === 'socks5:'; diff --git a/packages/playwright-core/src/server/bidi/bidiFirefox.ts b/packages/playwright-core/src/server/bidi/bidiFirefox.ts index 3a2da48c25..3fb7c15b90 100644 --- a/packages/playwright-core/src/server/bidi/bidiFirefox.ts +++ b/packages/playwright-core/src/server/bidi/bidiFirefox.ts @@ -76,12 +76,6 @@ export class BidiFirefox extends BrowserType { firefoxArguments.push('--foreground'); firefoxArguments.push(`--profile`, userDataDir); firefoxArguments.push(...args); - // TODO: make ephemeral context work without this argument. - firefoxArguments.push('about:blank'); - // if (isPersistent) - // firefoxArguments.push('about:blank'); - // else - // firefoxArguments.push('-silent'); return firefoxArguments; } diff --git a/packages/playwright-core/src/server/bidi/bidiNetworkManager.ts b/packages/playwright-core/src/server/bidi/bidiNetworkManager.ts index 00846b124a..b7c314bd10 100644 --- a/packages/playwright-core/src/server/bidi/bidiNetworkManager.ts +++ b/packages/playwright-core/src/server/bidi/bidiNetworkManager.ts @@ -22,6 +22,7 @@ import type * as frames from '../frames'; import type * as types from '../types'; import * as bidi from './third_party/bidiProtocol'; import type { BidiSession } from './bidiConnection'; +import { parseRawCookie } from '../cookieStore'; export class BidiNetworkManager { @@ -68,7 +69,7 @@ export class BidiNetworkManager { if (redirectedFrom) { this._session.sendMayFail('network.continueRequest', { request: param.request.request, - headers: redirectedFrom._originalRequestRoute?._alreadyContinuedHeaders, + ...(redirectedFrom._originalRequestRoute?._alreadyContinuedHeaders || {}), }); } else { route = new BidiRouteImpl(this._session, param.request.request); @@ -245,7 +246,7 @@ class BidiRouteImpl implements network.RouteDelegate { private _requestId: bidi.Network.Request; private _session: BidiSession; private _request!: network.Request; - _alreadyContinuedHeaders: bidi.Network.Header[] | undefined; + _alreadyContinuedHeaders: types.HeadersArray | undefined; constructor(session: BidiSession, requestId: bidi.Network.Request) { this._session = session; @@ -266,13 +267,12 @@ class BidiRouteImpl implements network.RouteDelegate { return header; }); } - this._alreadyContinuedHeaders = toBidiHeaders(headers); + this._alreadyContinuedHeaders = headers; await this._session.sendMayFail('network.continueRequest', { request: this._requestId, url: overrides.url, method: overrides.method, - // TODO: cookies! - headers: this._alreadyContinuedHeaders, + ...toBidiRequestHeaders(this._alreadyContinuedHeaders), body: overrides.postData ? { type: 'base64', value: Buffer.from(overrides.postData).toString('base64') } : undefined, }); } @@ -283,7 +283,7 @@ class BidiRouteImpl implements network.RouteDelegate { request: this._requestId, statusCode: response.status, reasonPhrase: network.statusText(response.status), - headers: toBidiHeaders(response.headers), + ...toBidiResponseHeaders(response.headers), body: { type: 'base64', value: base64body }, }); } @@ -302,6 +302,27 @@ function fromBidiHeaders(bidiHeaders: bidi.Network.Header[]): types.HeadersArray return result; } +function toBidiRequestHeaders(allHeaders: types.HeadersArray): { cookies: bidi.Network.CookieHeader[], headers: bidi.Network.Header[] } { + const bidiHeaders = toBidiHeaders(allHeaders); + const cookies = bidiHeaders.filter(h => h.name.toLowerCase() === 'cookie'); + const headers = bidiHeaders.filter(h => h.name.toLowerCase() !== 'cookie'); + return { cookies, headers }; +} + +function toBidiResponseHeaders(headers: types.HeadersArray): { cookies: bidi.Network.SetCookieHeader[], headers: bidi.Network.Header[] } { + const setCookieHeaders = headers.filter(h => h.name.toLowerCase() === 'set-cookie'); + const otherHeaders = headers.filter(h => h.name.toLowerCase() !== 'set-cookie'); + const rawCookies = setCookieHeaders.map(h => parseRawCookie(h.value)); + const cookies: bidi.Network.SetCookieHeader[] = rawCookies.filter(Boolean).map(c => { + return { + ...c!, + value: { type: 'string', value: c!.value }, + sameSite: toBidiSameSite(c!.sameSite), + }; + }); + return { cookies, headers: toBidiHeaders(otherHeaders) }; +} + function toBidiHeaders(headers: types.HeadersArray): bidi.Network.Header[] { return headers.map(({ name, value }) => ({ name, value: { type: 'string', value } })); } @@ -314,3 +335,13 @@ export function bidiBytesValueToString(value: bidi.Network.BytesValue): string { return 'unknown value type: ' + (value as any).type; } + +function toBidiSameSite(sameSite?: 'Strict' | 'Lax' | 'None'): bidi.Network.SameSite | undefined { + if (!sameSite) + return undefined; + if (sameSite === 'Strict') + return bidi.Network.SameSite.Strict; + if (sameSite === 'Lax') + return bidi.Network.SameSite.Lax; + return bidi.Network.SameSite.None; +} diff --git a/packages/playwright-core/src/server/bidi/bidiPage.ts b/packages/playwright-core/src/server/bidi/bidiPage.ts index f06924d70f..c2d499bd67 100644 --- a/packages/playwright-core/src/server/bidi/bidiPage.ts +++ b/packages/playwright-core/src/server/bidi/bidiPage.ts @@ -323,6 +323,10 @@ export class BidiPage implements PageDelegate { throw new Error('Method not implemented.'); } + async forceGarbageCollection(): Promise { + throw new Error('Method not implemented.'); + } + async addInitScript(initScript: InitScript): Promise { const { script } = await this._session.send('script.addPreloadScript', { // TODO: remove function call from the source. diff --git a/packages/playwright-core/src/server/browser.ts b/packages/playwright-core/src/server/browser.ts index 663b9be377..252443bc44 100644 --- a/packages/playwright-core/src/server/browser.ts +++ b/packages/playwright-core/src/server/browser.ts @@ -16,7 +16,7 @@ import type * as types from './types'; import type * as channels from '@protocol/channels'; -import { BrowserContext, createClientCertificatesProxyIfNeeded, validateBrowserContextOptions } from './browserContext'; +import { BrowserContext, validateBrowserContextOptions } from './browserContext'; import { Page } from './page'; import { Download } from './download'; import type { ProxySettings } from './types'; @@ -25,6 +25,7 @@ import type { RecentLogsCollector } from '../utils/debugLogger'; import type { CallMetadata } from './instrumentation'; import { SdkObject } from './instrumentation'; import { Artifact } from './artifact'; +import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; export interface BrowserProcess { onclose?: ((exitCode: number | null, signal: string | null) => void); @@ -41,7 +42,7 @@ export type BrowserOptions = { downloadsPath: string, tracesDir: string, headful?: boolean, - persistent?: channels.BrowserNewContextParams, // Undefined means no persistent context. + persistent?: types.BrowserContextOptions, // Undefined means no persistent context. browserProcess: BrowserProcess, customExecutablePath?: string; proxy?: ProxySettings, @@ -74,15 +75,21 @@ export abstract class Browser extends SdkObject { this.instrumentation.onBrowserOpen(this); } - abstract doCreateNewContext(options: channels.BrowserNewContextParams): Promise; + abstract doCreateNewContext(options: types.BrowserContextOptions): Promise; abstract contexts(): BrowserContext[]; abstract isConnected(): boolean; abstract version(): string; abstract userAgent(): string; - async newContext(metadata: CallMetadata, options: channels.BrowserNewContextParams): Promise { + async newContext(metadata: CallMetadata, options: types.BrowserContextOptions): Promise { validateBrowserContextOptions(options, this.options); - const clientCertificatesProxy = await createClientCertificatesProxyIfNeeded(options, this.options); + let clientCertificatesProxy: ClientCertificatesProxy | undefined; + if (options.clientCertificates?.length) { + clientCertificatesProxy = new ClientCertificatesProxy(options); + options = { ...options }; + options.proxyOverride = await clientCertificatesProxy.listen(); + options.internalIgnoreHTTPSErrors = true; + } let context; try { context = await this.doCreateNewContext(options); diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 8ddbe68f89..499356ca49 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -42,7 +42,7 @@ import * as consoleApiSource from '../generated/consoleApiSource'; import { BrowserContextAPIRequestContext } from './fetch'; import type { Artifact } from './artifact'; import { Clock } from './clock'; -import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; +import type { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; import { RecorderApp } from './recorder/recorderApp'; export abstract class BrowserContext extends SdkObject { @@ -68,7 +68,7 @@ export abstract class BrowserContext extends SdkObject { readonly _timeoutSettings = new TimeoutSettings(); readonly _pageBindings = new Map(); readonly _activeProgressControllers = new Set(); - readonly _options: channels.BrowserNewContextParams; + readonly _options: types.BrowserContextOptions; _requestInterceptor?: network.RouteHandler; private _isPersistentContext: boolean; private _closedStatus: 'open' | 'closing' | 'closed' = 'open'; @@ -93,7 +93,7 @@ export abstract class BrowserContext extends SdkObject { readonly clock: Clock; _clientCertificatesProxy: ClientCertificatesProxy | undefined; - constructor(browser: Browser, options: channels.BrowserNewContextParams, browserContextId: string | undefined) { + constructor(browser: Browser, options: types.BrowserContextOptions, browserContextId: string | undefined) { super(browser, 'browser-context'); this.attribution.context = this; this._browser = browser; @@ -659,19 +659,7 @@ export function assertBrowserContextIsNotOwned(context: BrowserContext) { } } -export async function createClientCertificatesProxyIfNeeded(options: channels.BrowserNewContextOptions, browserOptions?: BrowserOptions) { - if (!options.clientCertificates?.length) - return; - if ((options.proxy?.server && options.proxy?.server !== 'per-context') || (browserOptions?.proxy?.server && browserOptions?.proxy?.server !== 'http://per-context')) - throw new Error('Cannot specify both proxy and clientCertificates'); - verifyClientCertificates(options.clientCertificates); - const clientCertificatesProxy = new ClientCertificatesProxy(options); - options.proxy = { server: await clientCertificatesProxy.listen() }; - options.ignoreHTTPSErrors = true; - return clientCertificatesProxy; -} - -export function validateBrowserContextOptions(options: channels.BrowserNewContextParams, browserOptions: BrowserOptions) { +export function validateBrowserContextOptions(options: types.BrowserContextOptions, browserOptions: BrowserOptions) { if (options.noDefaultViewport && options.deviceScaleFactor !== undefined) throw new Error(`"deviceScaleFactor" option is not supported with null "viewport"`); if (options.noDefaultViewport && !!options.isMobile) @@ -720,7 +708,7 @@ export function verifyGeolocation(geolocation?: types.Geolocation) { throw new Error(`geolocation.accuracy: precondition 0 <= ACCURACY failed.`); } -export function verifyClientCertificates(clientCertificates?: channels.BrowserNewContextParams['clientCertificates']) { +export function verifyClientCertificates(clientCertificates?: types.BrowserContextOptions['clientCertificates']) { if (!clientCertificates) return; for (const cert of clientCertificates) { diff --git a/packages/playwright-core/src/server/browserType.ts b/packages/playwright-core/src/server/browserType.ts index d0c3174a59..abc15a20f4 100644 --- a/packages/playwright-core/src/server/browserType.ts +++ b/packages/playwright-core/src/server/browserType.ts @@ -18,7 +18,7 @@ import fs from 'fs'; import * as os from 'os'; import path from 'path'; import type { BrowserContext } from './browserContext'; -import { createClientCertificatesProxyIfNeeded, normalizeProxySettings, validateBrowserContextOptions } from './browserContext'; +import { normalizeProxySettings, validateBrowserContextOptions } from './browserContext'; import type { BrowserName } from './registry'; import { registry } from './registry'; import type { ConnectionTransport } from './transport'; @@ -39,6 +39,7 @@ import { RecentLogsCollector } from '../utils/debugLogger'; import type { CallMetadata } from './instrumentation'; import { SdkObject } from './instrumentation'; import { type ProtocolError, isProtocolError } from './protocolError'; +import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; export const kNoXServerRunningError = 'Looks like you launched a headed browser without having a XServer running.\n' + 'Set either \'headless: true\' or use \'xvfb-run \' before running Playwright.\n\n<3 Playwright Team'; @@ -92,27 +93,30 @@ export abstract class BrowserType extends SdkObject { return browser; } - async launchPersistentContext(metadata: CallMetadata, userDataDir: string, options: channels.BrowserTypeLaunchPersistentContextOptions & { useWebSocket?: boolean }): Promise { - options = this._validateLaunchOptions(options); + async launchPersistentContext(metadata: CallMetadata, userDataDir: string, options: channels.BrowserTypeLaunchPersistentContextOptions & { useWebSocket?: boolean, internalIgnoreHTTPSErrors?: boolean }): Promise { + const launchOptions = this._validateLaunchOptions(options); if (this._useBidi) - options.useWebSocket = true; + launchOptions.useWebSocket = true; const controller = new ProgressController(metadata, this); - const persistent: channels.BrowserNewContextParams = { ...options }; controller.setLogName('browser'); const browser = await controller.run(async progress => { // Note: Any initial TLS requests will fail since we rely on the Page/Frames initialize which sets ignoreHTTPSErrors. - const clientCertificatesProxy = await createClientCertificatesProxyIfNeeded(persistent); - if (clientCertificatesProxy) - options.proxy = persistent.proxy; + let clientCertificatesProxy: ClientCertificatesProxy | undefined; + if (options.clientCertificates?.length) { + clientCertificatesProxy = new ClientCertificatesProxy(options); + launchOptions.proxyOverride = await clientCertificatesProxy?.listen(); + options = { ...options }; + options.internalIgnoreHTTPSErrors = true; + } progress.cleanupWhenAborted(() => clientCertificatesProxy?.close()); - const browser = await this._innerLaunchWithRetries(progress, options, persistent, helper.debugProtocolLogger(), userDataDir).catch(e => { throw this._rewriteStartupLog(e); }); + const browser = await this._innerLaunchWithRetries(progress, launchOptions, options, helper.debugProtocolLogger(), userDataDir).catch(e => { throw this._rewriteStartupLog(e); }); browser._defaultContext!._clientCertificatesProxy = clientCertificatesProxy; return browser; - }, TimeoutSettings.launchTimeout(options)); + }, TimeoutSettings.launchTimeout(launchOptions)); return browser._defaultContext!; } - async _innerLaunchWithRetries(progress: Progress, options: types.LaunchOptions, persistent: channels.BrowserNewContextParams | undefined, protocolLogger: types.ProtocolLogger, userDataDir?: string): Promise { + async _innerLaunchWithRetries(progress: Progress, options: types.LaunchOptions, persistent: types.BrowserContextOptions | undefined, protocolLogger: types.ProtocolLogger, userDataDir?: string): Promise { try { return await this._innerLaunch(progress, options, persistent, protocolLogger, userDataDir); } catch (error) { @@ -126,7 +130,7 @@ export abstract class BrowserType extends SdkObject { } } - async _innerLaunch(progress: Progress, options: types.LaunchOptions, persistent: channels.BrowserNewContextParams | undefined, protocolLogger: types.ProtocolLogger, maybeUserDataDir?: string): Promise { + async _innerLaunch(progress: Progress, options: types.LaunchOptions, persistent: types.BrowserContextOptions | undefined, protocolLogger: types.ProtocolLogger, maybeUserDataDir?: string): Promise { options.proxy = options.proxy ? normalizeProxySettings(options.proxy) : undefined; const browserLogsCollector = new RecentLogsCollector(); const { browserProcess, userDataDir, artifactsDir, transport } = await this._launchProcess(progress, options, !!persistent, browserLogsCollector, maybeUserDataDir); @@ -289,7 +293,7 @@ export abstract class BrowserType extends SdkObject { throw new Error('Connecting to SELENIUM_REMOTE_URL is only supported by Chromium'); } - private _validateLaunchOptions(options: Options): Options { + private _validateLaunchOptions(options: types.LaunchOptions): types.LaunchOptions { const { devtools = false } = options; let { headless = !devtools, downloadsPath, proxy } = options; if (debugMode()) diff --git a/packages/playwright-core/src/server/chromium/chromium.ts b/packages/playwright-core/src/server/chromium/chromium.ts index d84ae3952e..30bd2871b8 100644 --- a/packages/playwright-core/src/server/chromium/chromium.ts +++ b/packages/playwright-core/src/server/chromium/chromium.ts @@ -31,7 +31,6 @@ import { CRDevTools } from './crDevTools'; import type { BrowserOptions, BrowserProcess } from '../browser'; import { Browser } from '../browser'; import type * as types from '../types'; -import type * as channels from '@protocol/channels'; import type { HTTPRequestParams } from '../../utils/network'; import { fetchData } from '../../utils/network'; import { getUserAgent } from '../../utils/userAgent'; @@ -98,7 +97,7 @@ export class Chromium extends BrowserType { await cleanedUp; }; const browserProcess: BrowserProcess = { close: doClose, kill: doClose }; - const persistent: channels.BrowserNewContextParams = { noDefaultViewport: true }; + const persistent: types.BrowserContextOptions = { noDefaultViewport: true }; const browserOptions: BrowserOptions = { slowMo: options.slowMo, name: 'chromium', @@ -287,7 +286,7 @@ export class Chromium extends BrowserType { } private _innerDefaultArgs(options: types.LaunchOptions): string[] { - const { args = [], proxy } = options; + const { args = [] } = options; const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir')); if (userDataDirArg) throw this._createUserDataDirArgMisuseError('--user-data-dir'); @@ -321,6 +320,7 @@ export class Chromium extends BrowserType { } if (options.chromiumSandbox !== true) chromeArguments.push('--no-sandbox'); + const proxy = options.proxyOverride || options.proxy; if (proxy) { const proxyURL = new URL(proxy.server); const isSocks = proxyURL.protocol === 'socks5:'; diff --git a/packages/playwright-core/src/server/chromium/crBrowser.ts b/packages/playwright-core/src/server/chromium/crBrowser.ts index 42b916c186..e0409c1b16 100644 --- a/packages/playwright-core/src/server/chromium/crBrowser.ts +++ b/packages/playwright-core/src/server/chromium/crBrowser.ts @@ -100,18 +100,19 @@ export class CRBrowser extends Browser { this._session.on('Browser.downloadProgress', this._onDownloadProgress.bind(this)); } - async doCreateNewContext(options: channels.BrowserNewContextParams): Promise { + async doCreateNewContext(options: types.BrowserContextOptions): Promise { + const proxy = options.proxyOverride || options.proxy; let proxyBypassList = undefined; - if (options.proxy) { + if (proxy) { if (process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK) - proxyBypassList = options.proxy.bypass; + proxyBypassList = proxy.bypass; else - proxyBypassList = '<-loopback>' + (options.proxy.bypass ? `,${options.proxy.bypass}` : ''); + proxyBypassList = '<-loopback>' + (proxy.bypass ? `,${proxy.bypass}` : ''); } const { browserContextId } = await this._session.send('Target.createBrowserContext', { disposeOnDetach: true, - proxyServer: options.proxy ? options.proxy.server : undefined, + proxyServer: proxy ? proxy.server : undefined, proxyBypassList, }); const context = new CRBrowserContext(this, browserContextId, options); @@ -340,7 +341,7 @@ export class CRBrowserContext extends BrowserContext { declare readonly _browser: CRBrowser; - constructor(browser: CRBrowser, browserContextId: string | undefined, options: channels.BrowserNewContextParams) { + constructor(browser: CRBrowser, browserContextId: string | undefined, options: types.BrowserContextOptions) { super(browser, options, browserContextId); this._authenticateProxyViaCredentials(); } diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index 002dfa09be..fbdc9db91a 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -247,6 +247,10 @@ export class CRPage implements PageDelegate { return this._go(+1); } + async forceGarbageCollection(): Promise { + await this._mainFrameSession._client.send('HeapProfiler.collectGarbage'); + } + async addInitScript(initScript: InitScript, world: types.World = 'main'): Promise { await this._forAllFrameSessions(frame => frame._evaluateOnNewDocument(initScript, world)); } @@ -543,7 +547,7 @@ class FrameSession { const options = this._crPage._browserContext._options; if (options.bypassCSP) promises.push(this._client.send('Page.setBypassCSP', { enabled: true })); - if (options.ignoreHTTPSErrors) + if (options.ignoreHTTPSErrors || options.internalIgnoreHTTPSErrors) promises.push(this._client.send('Security.setIgnoreCertificateErrors', { ignore: true })); if (this._isMainFrame()) promises.push(this._updateViewport()); @@ -1213,7 +1217,7 @@ async function emulateTimezone(session: CRSession, timezoneId: string) { const contextDelegateSymbol = Symbol('delegate'); // Chromium reference: https://source.chromium.org/chromium/chromium/src/+/main:components/embedder_support/user_agent_utils.cc;l=434;drc=70a6711e08e9f9e0d8e4c48e9ba5cab62eb010c2 -function calculateUserAgentMetadata(options: channels.BrowserNewContextParams) { +function calculateUserAgentMetadata(options: types.BrowserContextOptions) { const ua = options.userAgent; if (!ua) return undefined; diff --git a/packages/playwright-core/src/server/codegen/csharp.ts b/packages/playwright-core/src/server/codegen/csharp.ts index f11435a0c2..2244a372fc 100644 --- a/packages/playwright-core/src/server/codegen/csharp.ts +++ b/packages/playwright-core/src/server/codegen/csharp.ts @@ -16,7 +16,7 @@ import type { BrowserContextOptions } from '../../../types/types'; import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; -import { sanitizeDeviceOptions, toClickOptions, toKeyboardModifiers, toSignalMap } from './language'; +import { sanitizeDeviceOptions, toClickOptionsForSourceCode, toKeyboardModifiers, toSignalMap } from './language'; import { escapeWithQuotes, asLocator } from '../../utils'; import { deviceDescriptors } from '../deviceDescriptors'; @@ -112,7 +112,7 @@ export class CSharpLanguageGenerator implements LanguageGenerator { let method = 'Click'; if (action.clickCount === 2) method = 'DblClick'; - const options = toClickOptions(action); + const options = toClickOptionsForSourceCode(action); if (!Object.entries(options).length) return `await ${subject}.${this._asLocator(action.selector)}.${method}Async();`; const optionsString = formatObject(options, ' ', 'Locator' + method + 'Options'); diff --git a/packages/playwright-core/src/server/codegen/java.ts b/packages/playwright-core/src/server/codegen/java.ts index 47c6fa3619..3a640d36e2 100644 --- a/packages/playwright-core/src/server/codegen/java.ts +++ b/packages/playwright-core/src/server/codegen/java.ts @@ -17,7 +17,7 @@ import type { BrowserContextOptions } from '../../../types/types'; import type * as types from '../types'; import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; -import { toClickOptions, toKeyboardModifiers, toSignalMap } from './language'; +import { toClickOptionsForSourceCode, toKeyboardModifiers, toSignalMap } from './language'; import { deviceDescriptors } from '../deviceDescriptors'; import { JavaScriptFormatter } from './javascript'; import { escapeWithQuotes, asLocator } from '../../utils'; @@ -101,7 +101,7 @@ export class JavaLanguageGenerator implements LanguageGenerator { let method = 'click'; if (action.clickCount === 2) method = 'dblclick'; - const options = toClickOptions(action); + const options = toClickOptionsForSourceCode(action); const optionsText = formatClickOptions(options); return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.${method}(${optionsText});`; } diff --git a/packages/playwright-core/src/server/codegen/javascript.ts b/packages/playwright-core/src/server/codegen/javascript.ts index 1c1ba3f1cb..c3ed05d4d4 100644 --- a/packages/playwright-core/src/server/codegen/javascript.ts +++ b/packages/playwright-core/src/server/codegen/javascript.ts @@ -16,7 +16,7 @@ import type { BrowserContextOptions } from '../../../types/types'; import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; -import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptions } from './language'; +import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptionsForSourceCode } from './language'; import { deviceDescriptors } from '../deviceDescriptors'; import { escapeWithQuotes, asLocator } from '../../utils'; @@ -85,7 +85,7 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { let method = 'click'; if (action.clickCount === 2) method = 'dblclick'; - const options = toClickOptions(action); + const options = toClickOptionsForSourceCode(action); const optionsString = formatOptions(options, false); return `await ${subject}.${this._asLocator(action.selector)}.${method}(${optionsString});`; } diff --git a/packages/playwright-core/src/server/codegen/language.ts b/packages/playwright-core/src/server/codegen/language.ts index 72cfb9083d..4b1ba99b6f 100644 --- a/packages/playwright-core/src/server/codegen/language.ts +++ b/packages/playwright-core/src/server/codegen/language.ts @@ -20,6 +20,7 @@ import type * as types from '../types'; import type { ActionInContext, LanguageGenerator, LanguageGeneratorOptions } from './types'; export function generateCode(actions: ActionInContext[], languageGenerator: LanguageGenerator, options: LanguageGeneratorOptions) { + actions = collapseActions(actions); const header = languageGenerator.generateHeader(options); const footer = languageGenerator.generateFooter(options.saveStorage); const actionTexts = actions.map(a => languageGenerator.generateAction(a)).filter(Boolean); @@ -69,16 +70,33 @@ export function toKeyboardModifiers(modifiers: number): types.SmartKeyboardModif return result; } -export function toClickOptions(action: actions.ClickAction): types.MouseClickOptions { +export function toClickOptionsForSourceCode(action: actions.ClickAction): types.MouseClickOptions { const modifiers = toKeyboardModifiers(action.modifiers); const options: types.MouseClickOptions = {}; if (action.button !== 'left') options.button = action.button; if (modifiers.length) options.modifiers = modifiers; + // Do not render clickCount === 2 for dblclick. if (action.clickCount > 2) options.clickCount = action.clickCount; if (action.position) options.position = action.position; return options; } + +function collapseActions(actions: ActionInContext[]): ActionInContext[] { + const result: ActionInContext[] = []; + for (const action of actions) { + const lastAction = result[result.length - 1]; + const isSameAction = lastAction && lastAction.action.name === action.action.name && lastAction.frame.pageAlias === action.frame.pageAlias && lastAction.frame.framePath.join('|') === action.frame.framePath.join('|'); + const isSameSelector = lastAction && 'selector' in lastAction.action && 'selector' in action.action && action.action.selector === lastAction.action.selector; + const shouldMerge = isSameAction && (action.action.name === 'navigate' || (action.action.name === 'fill' && isSameSelector)); + if (!shouldMerge) { + result.push(action); + continue; + } + result[result.length - 1] = action; + } + return result; +} diff --git a/packages/playwright-core/src/server/codegen/python.ts b/packages/playwright-core/src/server/codegen/python.ts index 6ed101bcf0..6c2b60dc70 100644 --- a/packages/playwright-core/src/server/codegen/python.ts +++ b/packages/playwright-core/src/server/codegen/python.ts @@ -16,7 +16,7 @@ import type { BrowserContextOptions } from '../../../types/types'; import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; -import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptions } from './language'; +import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptionsForSourceCode } from './language'; import { escapeWithQuotes, toSnakeCase, asLocator } from '../../utils'; import { deviceDescriptors } from '../deviceDescriptors'; @@ -94,7 +94,7 @@ export class PythonLanguageGenerator implements LanguageGenerator { let method = 'click'; if (action.clickCount === 2) method = 'dblclick'; - const options = toClickOptions(action); + const options = toClickOptionsForSourceCode(action); const optionsString = formatOptions(options, false); return `${subject}.${this._asLocator(action.selector)}.${method}(${optionsString})`; } diff --git a/packages/playwright-core/src/server/codegen/types.ts b/packages/playwright-core/src/server/codegen/types.ts index 96f2aa85d1..48c1141a3e 100644 --- a/packages/playwright-core/src/server/codegen/types.ts +++ b/packages/playwright-core/src/server/codegen/types.ts @@ -36,7 +36,7 @@ export type ActionInContext = { frame: FrameDescription; description?: string; action: actions.Action; - committed?: boolean; + timestamp: number; }; export interface LanguageGenerator { diff --git a/packages/playwright-core/src/server/cookieStore.ts b/packages/playwright-core/src/server/cookieStore.ts index fbf3f718f0..d1842660c7 100644 --- a/packages/playwright-core/src/server/cookieStore.ts +++ b/packages/playwright-core/src/server/cookieStore.ts @@ -15,6 +15,7 @@ */ import type * as channels from '@protocol/channels'; +import { kMaxCookieExpiresDateInSeconds } from './network'; class Cookie { private _raw: channels.NetworkCookie; @@ -115,6 +116,97 @@ export class CookieStore { } } +type RawCookie = { + name: string, + value: string, + domain?: string, + path?: string, + expires?: number, + httpOnly?: boolean, + secure?: boolean, + sameSite?: 'Strict' | 'Lax' | 'None', +}; + +export function parseRawCookie(header: string): RawCookie | null { + const pairs = header.split(';').filter(s => s.trim().length > 0).map(p => { + let key = ''; + let value = ''; + const separatorPos = p.indexOf('='); + if (separatorPos === -1) { + // If only a key is specified, the value is left undefined. + key = p.trim(); + } else { + // Otherwise we assume that the key is the element before the first `=` + key = p.slice(0, separatorPos).trim(); + // And the value is the rest of the string. + value = p.slice(separatorPos + 1).trim(); + } + return [key, value]; + }); + if (!pairs.length) + return null; + const [name, value] = pairs[0]; + const cookie: RawCookie = { + name, + value, + }; + for (let i = 1; i < pairs.length; i++) { + const [name, value] = pairs[i]; + switch (name.toLowerCase()) { + case 'expires': + const expiresMs = (+new Date(value)); + // https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.1 + if (isFinite(expiresMs)) { + if (expiresMs <= 0) + cookie.expires = 0; + else + cookie.expires = Math.min(expiresMs / 1000, kMaxCookieExpiresDateInSeconds); + } + break; + case 'max-age': + const maxAgeSec = parseInt(value, 10); + if (isFinite(maxAgeSec)) { + // From https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2 + // If delta-seconds is less than or equal to zero (0), let expiry-time + // be the earliest representable date and time. + if (maxAgeSec <= 0) + cookie.expires = 0; + else + cookie.expires = Math.min(Date.now() / 1000 + maxAgeSec, kMaxCookieExpiresDateInSeconds); + } + break; + case 'domain': + cookie.domain = value.toLocaleLowerCase() || ''; + if (cookie.domain && !cookie.domain.startsWith('.') && cookie.domain.includes('.')) + cookie.domain = '.' + cookie.domain; + break; + case 'path': + cookie.path = value || ''; + break; + case 'secure': + cookie.secure = true; + break; + case 'httponly': + cookie.httpOnly = true; + break; + case 'samesite': + switch (value.toLowerCase()) { + case 'none': + cookie.sameSite = 'None'; + break; + case 'lax': + cookie.sameSite = 'Lax'; + break; + case 'strict': + cookie.sameSite = 'Strict'; + break; + } + break; + } + } + return cookie; +} + export function domainMatches(value: string, domain: string): boolean { if (value === domain) return true; diff --git a/packages/playwright-core/src/server/deviceDescriptorsSource.json b/packages/playwright-core/src/server/deviceDescriptorsSource.json index 384f6f2377..efb2801f2c 100644 --- a/packages/playwright-core/src/server/deviceDescriptorsSource.json +++ b/packages/playwright-core/src/server/deviceDescriptorsSource.json @@ -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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36", "viewport": { "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/129.0.6668.29 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36 Edge/14.14263", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36 Edge/14.14263", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36 Edge/14.14263", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36 Edge/14.14263", "viewport": { "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/129.0.6668.29 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36", "viewport": { "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/129.0.6668.29 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36", "viewport": { "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/129.0.6668.29 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "screen": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "screen": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "screen": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "screen": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "screen": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "screen": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Mobile Safari/537.36", "viewport": { "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/129.0.6668.29 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36", "screen": { "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/129.0.6668.29 Safari/537.36 Edg/129.0.6668.29", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36 Edg/129.0.6668.42", "screen": { "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/129.0.6668.29 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36", "screen": { "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/129.0.6668.29 Safari/537.36 Edg/129.0.6668.29", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.42 Safari/537.36 Edg/129.0.6668.42", "screen": { "width": 1920, "height": 1080 diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index c70d8e825a..5c8fa550a7 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -39,6 +39,7 @@ import type { Dialog } from '../dialog'; import type { ConsoleMessage } from '../console'; import { serializeError } from '../errors'; import { ElementHandleDispatcher } from './elementHandlerDispatcher'; +import { RecorderInTraceViewer } from '../recorder/recorderInTraceViewer'; import { RecorderApp } from '../recorder/recorderApp'; export class BrowserContextDispatcher extends Dispatcher implements channels.BrowserContextChannel { @@ -292,7 +293,8 @@ export class BrowserContextDispatcher extends Dispatcher { - await Recorder.show(this._context, RecorderApp.factory(this._context), params); + const factory = process.env.PW_RECORDER_IS_TRACE_VIEWER ? RecorderInTraceViewer.factory(this._context) : RecorderApp.factory(this._context); + await Recorder.show(this._context, factory, params); } async pause(params: channels.BrowserContextPauseParams, metadata: CallMetadata) { diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index 3101cd051d..a97ddbf1f0 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -137,6 +137,10 @@ export class PageDispatcher extends Dispatcher { + await this._page.forceGarbageCollection(); + } + async registerLocatorHandler(params: channels.PageRegisterLocatorHandlerParams, metadata: CallMetadata): Promise { const uid = this._page.registerLocatorHandler(params.selector, params.noWaitAfter); return { uid }; diff --git a/packages/playwright-core/src/server/electron/electron.ts b/packages/playwright-core/src/server/electron/electron.ts index b8f361b48a..1606c407d5 100644 --- a/packages/playwright-core/src/server/electron/electron.ts +++ b/packages/playwright-core/src/server/electron/electron.ts @@ -36,6 +36,7 @@ import type { BrowserWindow } from 'electron'; import type { Progress } from '../progress'; import { ProgressController } from '../progress'; import { helper } from '../helper'; +import type * as types from '../types'; import { eventsHelper } from '../../utils/eventsHelper'; import type { BrowserOptions, BrowserProcess } from '../browser'; import type { Playwright } from '../playwright'; @@ -265,7 +266,7 @@ export class Electron extends SdkObject { close: gracefullyClose, kill }; - const contextOptions: channels.BrowserNewContextParams = { + const contextOptions: types.BrowserContextOptions = { ...options, noDefaultViewport: true, }; diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 7a6102a50d..fc4e2c027d 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -28,7 +28,7 @@ import { getUserAgent } from '../utils/userAgent'; import { assert, createGuid, monotonicTime } from '../utils'; import { HttpsProxyAgent, SocksProxyAgent } from '../utilsBundle'; import { BrowserContext, verifyClientCertificates } from './browserContext'; -import { CookieStore, domainMatches } from './cookieStore'; +import { CookieStore, domainMatches, parseRawCookie } from './cookieStore'; import { MultipartFormData } from './formData'; import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from '../utils/happy-eyeballs'; import type { CallMetadata } from './instrumentation'; @@ -39,7 +39,6 @@ import { ProgressController } from './progress'; import { Tracing } from './trace/recorder/tracing'; import type * as types from './types'; import type { HeadersArray, ProxySettings } from './types'; -import { kMaxCookieExpiresDateInSeconds } from './network'; import { getMatchingTLSOptionsForOrigin, rewriteOpenSSLErrorIfNeeded } from './socksClientCertificatesInterceptor'; type FetchRequestOptions = { @@ -50,7 +49,7 @@ type FetchRequestOptions = { timeoutSettings: TimeoutSettings; ignoreHTTPSErrors?: boolean; baseURL?: string; - clientCertificates?: channels.BrowserNewContextOptions['clientCertificates']; + clientCertificates?: types.BrowserContextOptions['clientCertificates']; }; type HeadersObject = Readonly<{ [name: string]: string }>; @@ -169,23 +168,11 @@ export abstract class APIRequestContext extends SdkObject { const method = params.method?.toUpperCase() || 'GET'; const proxy = defaults.proxy; let agent; - // When `clientCertificates` is present, we set the `proxy` property to our own socks proxy - // for the browser to use. However, we don't need it here, because we already respect - // `clientCertificates` when fetching from Node.js. - if (proxy && !defaults.clientCertificates?.length && proxy.server !== 'per-context' && !shouldBypassProxy(requestUrl, proxy.bypass)) { - const proxyOpts = url.parse(proxy.server); - if (proxyOpts.protocol?.startsWith('socks')) { - agent = new SocksProxyAgent({ - host: proxyOpts.hostname, - port: proxyOpts.port || undefined, - }); - } else { - if (proxy.username) - proxyOpts.auth = `${proxy.username}:${proxy.password || ''}`; - // TODO: We should use HttpProxyAgent conditional on proxyOpts.protocol instead of always using CONNECT method. - agent = new HttpsProxyAgent(proxyOpts); - } - } + // We skip 'per-context' in order to not break existing users. 'per-context' was previously used to + // workaround an upstream Chromium bug. Can be removed in the future. + if (proxy && proxy.server !== 'per-context' && !shouldBypassProxy(requestUrl, proxy.bypass)) + agent = createProxyAgent(proxy); + const timeout = defaults.timeoutSettings.timeout(params); const deadline = timeout && (monotonicTime() + timeout); @@ -578,8 +565,6 @@ export class GlobalAPIRequestContext extends APIRequestContext { if (!/^\w+:\/\//.test(url)) url = 'http://' + url; proxy.server = url; - if (options.clientCertificates) - throw new Error('Cannot specify both proxy and clientCertificates'); } if (options.storageState) { this._origins = options.storageState.origins; @@ -630,6 +615,20 @@ export class GlobalAPIRequestContext extends APIRequestContext { } } +export function createProxyAgent(proxy: types.ProxySettings) { + const proxyOpts = url.parse(proxy.server); + if (proxyOpts.protocol?.startsWith('socks')) { + return new SocksProxyAgent({ + host: proxyOpts.hostname, + port: proxyOpts.port || undefined, + }); + } + if (proxy.username) + proxyOpts.auth = `${proxy.username}:${proxy.password || ''}`; + // TODO: We should use HttpProxyAgent conditional on proxyOpts.protocol instead of always using CONNECT method. + return new HttpsProxyAgent(proxyOpts); +} + function toHeadersArray(rawHeaders: string[]): types.HeadersArray { const result: types.HeadersArray = []; for (let i = 0; i < rawHeaders.length; i += 2) @@ -640,27 +639,10 @@ function toHeadersArray(rawHeaders: string[]): types.HeadersArray { const redirectStatus = [301, 302, 303, 307, 308]; function parseCookie(header: string): channels.NetworkCookie | null { - const pairs = header.split(';').filter(s => s.trim().length > 0).map(p => { - let key = ''; - let value = ''; - const separatorPos = p.indexOf('='); - if (separatorPos === -1) { - // If only a key is specified, the value is left undefined. - key = p.trim(); - } else { - // Otherwise we assume that the key is the element before the first `=` - key = p.slice(0, separatorPos).trim(); - // And the value is the rest of the string. - value = p.slice(separatorPos + 1).trim(); - } - return [key, value]; - }); - if (!pairs.length) + const raw = parseRawCookie(header); + if (!raw) return null; - const [name, value] = pairs[0]; const cookie: channels.NetworkCookie = { - name, - value, domain: '', path: '', expires: -1, @@ -668,62 +650,9 @@ function parseCookie(header: string): channels.NetworkCookie | null { secure: false, // From https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite // The cookie-sending behavior if SameSite is not specified is SameSite=Lax. - sameSite: 'Lax' + sameSite: 'Lax', + ...raw }; - for (let i = 1; i < pairs.length; i++) { - const [name, value] = pairs[i]; - switch (name.toLowerCase()) { - case 'expires': - const expiresMs = (+new Date(value)); - // https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.1 - if (isFinite(expiresMs)) { - if (expiresMs <= 0) - cookie.expires = 0; - else - cookie.expires = Math.min(expiresMs / 1000, kMaxCookieExpiresDateInSeconds); - } - break; - case 'max-age': - const maxAgeSec = parseInt(value, 10); - if (isFinite(maxAgeSec)) { - // From https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2 - // If delta-seconds is less than or equal to zero (0), let expiry-time - // be the earliest representable date and time. - if (maxAgeSec <= 0) - cookie.expires = 0; - else - cookie.expires = Math.min(Date.now() / 1000 + maxAgeSec, kMaxCookieExpiresDateInSeconds); - } - break; - case 'domain': - cookie.domain = value.toLocaleLowerCase() || ''; - if (cookie.domain && !cookie.domain.startsWith('.') && cookie.domain.includes('.')) - cookie.domain = '.' + cookie.domain; - break; - case 'path': - cookie.path = value || ''; - break; - case 'secure': - cookie.secure = true; - break; - case 'httponly': - cookie.httpOnly = true; - break; - case 'samesite': - switch (value.toLowerCase()) { - case 'none': - cookie.sameSite = 'None'; - break; - case 'lax': - cookie.sameSite = 'Lax'; - break; - case 'strict': - cookie.sameSite = 'Strict'; - break; - } - break; - } - } return cookie; } diff --git a/packages/playwright-core/src/server/firefox/ffBrowser.ts b/packages/playwright-core/src/server/firefox/ffBrowser.ts index 94b90bbcea..b26a2850ee 100644 --- a/packages/playwright-core/src/server/firefox/ffBrowser.ts +++ b/packages/playwright-core/src/server/firefox/ffBrowser.ts @@ -58,8 +58,9 @@ export class FFBrowser extends Browser { browser._defaultContext = new FFBrowserContext(browser, undefined, options.persistent); promises.push((browser._defaultContext as FFBrowserContext)._initialize()); } - if (options.proxy) - promises.push(browser.session.send('Browser.setBrowserProxy', toJugglerProxyOptions(options.proxy))); + const proxy = options.originalLaunchOptions.proxyOverride || options.proxy; + if (proxy) + promises.push(browser.session.send('Browser.setBrowserProxy', toJugglerProxyOptions(proxy))); await Promise.all(promises); return browser; } @@ -88,7 +89,7 @@ export class FFBrowser extends Browser { return !this._connection._closed; } - async doCreateNewContext(options: channels.BrowserNewContextParams): Promise { + async doCreateNewContext(options: types.BrowserContextOptions): Promise { if (options.isMobile) throw new Error('options.isMobile is not supported in Firefox'); const { browserContextId } = await this.session.send('Browser.createBrowserContext', { removeOnDetach: true }); @@ -172,7 +173,7 @@ export class FFBrowser extends Browser { export class FFBrowserContext extends BrowserContext { declare readonly _browser: FFBrowser; - constructor(browser: FFBrowser, browserContextId: string | undefined, options: channels.BrowserNewContextParams) { + constructor(browser: FFBrowser, browserContextId: string | undefined, options: types.BrowserContextOptions) { super(browser, options, browserContextId); } @@ -205,7 +206,7 @@ export class FFBrowserContext extends BrowserContext { promises.push(this._browser.session.send('Browser.setUserAgentOverride', { browserContextId, userAgent: this._options.userAgent })); if (this._options.bypassCSP) promises.push(this._browser.session.send('Browser.setBypassCSP', { browserContextId, bypassCSP: true })); - if (this._options.ignoreHTTPSErrors) + if (this._options.ignoreHTTPSErrors || this._options.internalIgnoreHTTPSErrors) promises.push(this._browser.session.send('Browser.setIgnoreHTTPSErrors', { browserContextId, ignoreHTTPSErrors: true })); if (this._options.javaScriptEnabled === false) promises.push(this._browser.session.send('Browser.setJavaScriptDisabled', { browserContextId, javaScriptDisabled: true })); @@ -251,10 +252,11 @@ export class FFBrowserContext extends BrowserContext { }); })); } - if (this._options.proxy) { + const proxy = this._options.proxyOverride || this._options.proxy; + if (proxy) { promises.push(this._browser.session.send('Browser.setContextProxy', { browserContextId: this._browserContextId, - ...toJugglerProxyOptions(this._options.proxy) + ...toJugglerProxyOptions(proxy) })); } diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index d1066876eb..03a27954dd 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -400,6 +400,10 @@ export class FFPage implements PageDelegate { return success; } + async forceGarbageCollection(): Promise { + await this._session.send('Heap.collectGarbage'); + } + async addInitScript(initScript: InitScript, worldName?: string): Promise { this._initScripts.push({ initScript, worldName }); await this._session.send('Page.setInitScripts', { scripts: this._initScripts.map(s => ({ script: s.initScript.source, worldName: s.worldName })) }); diff --git a/packages/playwright-core/src/server/firefox/protocol.d.ts b/packages/playwright-core/src/server/firefox/protocol.d.ts index 1da6d70122..f4a44d9d4d 100644 --- a/packages/playwright-core/src/server/firefox/protocol.d.ts +++ b/packages/playwright-core/src/server/firefox/protocol.d.ts @@ -315,6 +315,11 @@ export module Protocol { }; export type cancelDownloadReturnValue = void; } + export module Heap { + export type collectGarbageParameters = { + }; + export type collectGarbageReturnValue = void; + } export module Page { export type DOMPoint = { x: number; @@ -1124,6 +1129,7 @@ export module Protocol { "Browser.setForcedColors": Browser.setForcedColorsParameters; "Browser.setVideoRecordingOptions": Browser.setVideoRecordingOptionsParameters; "Browser.cancelDownload": Browser.cancelDownloadParameters; + "Heap.collectGarbage": Heap.collectGarbageParameters; "Page.close": Page.closeParameters; "Page.setFileInputFiles": Page.setFileInputFilesParameters; "Page.addBinding": Page.addBindingParameters; @@ -1204,6 +1210,7 @@ export module Protocol { "Browser.setForcedColors": Browser.setForcedColorsReturnValue; "Browser.setVideoRecordingOptions": Browser.setVideoRecordingOptionsReturnValue; "Browser.cancelDownload": Browser.cancelDownloadReturnValue; + "Heap.collectGarbage": Heap.collectGarbageReturnValue; "Page.close": Page.closeReturnValue; "Page.setFileInputFiles": Page.setFileInputFilesReturnValue; "Page.addBinding": Page.addBindingReturnValue; diff --git a/packages/playwright-core/src/server/injected/highlight.ts b/packages/playwright-core/src/server/injected/highlight.ts index c85216e106..c89df54a6f 100644 --- a/packages/playwright-core/src/server/injected/highlight.ts +++ b/packages/playwright-core/src/server/injected/highlight.ts @@ -90,7 +90,8 @@ export class Highlight { } install() { - this._injectedScript.document.documentElement.appendChild(this._glassPaneElement); + if (!this._injectedScript.document.documentElement.contains(this._glassPaneElement)) + this._injectedScript.document.documentElement.appendChild(this._glassPaneElement); } setLanguage(language: Language) { diff --git a/packages/playwright-core/src/server/injected/recorder/recorder.ts b/packages/playwright-core/src/server/injected/recorder/recorder.ts index 8cbf11964f..dfbd4608f0 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -36,6 +36,7 @@ interface RecorderTool { cursor(): string; cleanup?(): void; onClick?(event: MouseEvent): void; + onDblClick?(event: MouseEvent): void; onContextMenu?(event: MouseEvent): void; onDragStart?(event: DragEvent): void; onInput?(event: Event): void; @@ -210,6 +211,7 @@ class RecordActionTool implements RecorderTool { private _hoveredElement: HTMLElement | null = null; private _activeModel: HighlightModel | null = null; private _expectProgrammaticKeyUp = false; + private _pendingClickAction: { action: actions.ClickAction, timeout: number } | undefined; constructor(recorder: Recorder) { this._recorder = recorder; @@ -252,6 +254,38 @@ class RecordActionTool implements RecorderTool { return; } + this._cancelPendingClickAction(); + + // Stall click in case we are observing double-click. + if (event.detail === 1) { + this._pendingClickAction = { + action: { + name: 'click', + selector: this._hoveredModel!.selector, + position: positionForEvent(event), + signals: [], + button: buttonForEvent(event), + modifiers: modifiersForEvent(event), + clickCount: event.detail + }, + timeout: this._recorder.injectedScript.builtinSetTimeout(() => this._commitPendingClickAction(), 200) + }; + } + } + + onDblClick(event: MouseEvent) { + if (isRangeInput(this._hoveredElement)) + return; + if (this._shouldIgnoreMouseEvent(event)) + return; + // Only allow double click dispatch while action is in progress. + if (this._actionInProgress(event)) + return; + if (this._consumedDueToNoModel(event, this._hoveredModel)) + return; + + this._cancelPendingClickAction(); + this._performAction({ name: 'click', selector: this._hoveredModel!.selector, @@ -263,6 +297,18 @@ class RecordActionTool implements RecorderTool { }); } + private _commitPendingClickAction() { + if (this._pendingClickAction) + this._performAction(this._pendingClickAction.action); + this._cancelPendingClickAction(); + } + + private _cancelPendingClickAction() { + if (this._pendingClickAction) + clearTimeout(this._pendingClickAction.timeout); + this._pendingClickAction = undefined; + } + onContextMenu(event: MouseEvent) { // the 'contextmenu' event is triggered by a right-click or equivalent action, // and it prevents the click event from firing for that action, so we always @@ -915,6 +961,10 @@ class Overlay { } return false; } + + onDblClick(event: MouseEvent) { + return false; + } } export class Recorder { @@ -970,6 +1020,7 @@ export class Recorder { this._listeners = [ addEventListener(this.document, 'click', event => this._onClick(event as MouseEvent), true), addEventListener(this.document, 'auxclick', event => this._onClick(event as MouseEvent), true), + addEventListener(this.document, 'dblclick', event => this._onDblClick(event as MouseEvent), true), addEventListener(this.document, 'contextmenu', event => this._onContextMenu(event as MouseEvent), true), addEventListener(this.document, 'dragstart', event => this._onDragStart(event as DragEvent), true), addEventListener(this.document, 'input', event => this._onInput(event), true), @@ -985,7 +1036,14 @@ export class Recorder { addEventListener(this.document, 'focus', event => this._onFocus(event), true), addEventListener(this.document, 'scroll', event => this._onScroll(event), true), ]; + this.highlight.install(); + // some frameworks erase the DOM on hydration, this ensures it's reattached + const recreationInterval = setInterval(() => { + this.highlight.install(); + }, 500); + this._listeners.push(() => clearInterval(recreationInterval)); + this.overlay?.install(); this.document.adoptedStyleSheets.push(this._stylesheet); } @@ -1043,6 +1101,16 @@ export class Recorder { this._currentTool.onClick?.(event); } + private _onDblClick(event: MouseEvent) { + if (!event.isTrusted) + return; + if (this.overlay?.onDblClick(event)) + return; + if (this._ignoreOverlayEvent(event)) + return; + this._currentTool.onDblClick?.(event); + } + private _onContextMenu(event: MouseEvent) { if (!event.isTrusted) return; diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index f2e59aa56f..aeaeb0af88 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -54,6 +54,7 @@ export interface PageDelegate { reload(): Promise; goBack(): Promise; goForward(): Promise; + forceGarbageCollection(): Promise; addInitScript(initScript: InitScript): Promise; removeNonInternalInitScripts(): Promise; closePage(runBeforeUnload: boolean): Promise; @@ -430,6 +431,10 @@ export class Page extends SdkObject { }), this._timeoutSettings.navigationTimeout(options)); } + forceGarbageCollection(): Promise { + return this._delegate.forceGarbageCollection(); + } + registerLocatorHandler(selector: string, noWaitAfter: boolean | undefined) { const uid = ++this._lastLocatorHandlerUid; this._locatorHandlers.set(uid, { selector, noWaitAfter }); diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 97316c2f9e..ddaa035811 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -26,14 +26,12 @@ import { type Language } from './codegen/types'; import { Debugger } from './debugger'; import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation'; import { ContextRecorder, generateFrameSelector } from './recorder/contextRecorder'; -import { type IRecorderApp } from './recorder/recorderApp'; +import type { IRecorderAppFactory, IRecorderApp, IRecorder } from './recorder/recorderFrontend'; import { buildFullSelector, metadataToCallLog } from './recorder/recorderUtils'; const recorderSymbol = Symbol('recorderSymbol'); -export type RecorderAppFactory = (recorder: Recorder) => Promise; - -export class Recorder implements InstrumentationListener { +export class Recorder implements InstrumentationListener, IRecorder { private _context: BrowserContext; private _mode: Mode; private _highlightedSelector = ''; @@ -47,14 +45,14 @@ export class Recorder implements InstrumentationListener { private _omitCallTracking = false; private _currentLanguage: Language; - static showInspector(context: BrowserContext, recorderAppFactory: RecorderAppFactory) { + static showInspector(context: BrowserContext, recorderAppFactory: IRecorderAppFactory) { const params: channels.BrowserContextRecorderSupplementEnableParams = {}; if (isUnderTest()) params.language = process.env.TEST_INSPECTOR_LANGUAGE; Recorder.show(context, recorderAppFactory, params).catch(() => {}); } - static show(context: BrowserContext, recorderAppFactory: RecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { + static show(context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { let recorderPromise = (context as any)[recorderSymbol] as Promise; if (!recorderPromise) { recorderPromise = Recorder._create(context, recorderAppFactory, params); @@ -63,7 +61,7 @@ export class Recorder implements InstrumentationListener { return recorderPromise; } - private static async _create(context: BrowserContext, recorderAppFactory: RecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { + private static async _create(context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { const recorder = new Recorder(context, params); const recorderApp = await recorderAppFactory(recorder); await recorder._install(recorderApp); @@ -134,10 +132,9 @@ export class Recorder implements InstrumentationListener { this._context.instrumentation.removeListener(this); this._recorderApp?.close().catch(() => {}); }); - this._contextRecorder.on(ContextRecorder.Events.Change, (data: { sources: Source[], primaryFileName: string }) => { + this._contextRecorder.on(ContextRecorder.Events.Change, (data: { sources: Source[] }) => { this._recorderSources = data.sources; this._pushAllSources(); - this._recorderApp?.setFileIfNeeded(data.primaryFileName); }); await this._context.exposeBinding('__pw_recorderState', false, source => { @@ -296,7 +293,7 @@ export class Recorder implements InstrumentationListener { } this._pushAllSources(); if (fileToSelect) - this._recorderApp?.setFileIfNeeded(fileToSelect); + this._recorderApp?.setFile(fileToSelect); } private _pushAllSources() { diff --git a/packages/playwright-core/src/server/recorder/DEPS.list b/packages/playwright-core/src/server/recorder/DEPS.list index 22ec3dfc2f..f3bbfc23bf 100644 --- a/packages/playwright-core/src/server/recorder/DEPS.list +++ b/packages/playwright-core/src/server/recorder/DEPS.list @@ -10,3 +10,6 @@ ../../utils/** ../../utilsBundle.ts ../../zipBundle.ts + +[recorderInTraceViewer.ts] +../trace/viewer/traceViewer.ts diff --git a/packages/playwright-core/src/server/recorder/contextRecorder.ts b/packages/playwright-core/src/server/recorder/contextRecorder.ts index 17d2c2c130..71a1d3ec75 100644 --- a/packages/playwright-core/src/server/recorder/contextRecorder.ts +++ b/packages/playwright-core/src/server/recorder/contextRecorder.ts @@ -18,7 +18,7 @@ import type * as channels from '@protocol/channels'; import type { Source } from '@recorder/recorderTypes'; import { EventEmitter } from 'events'; import * as recorderSource from '../../generated/recorderSource'; -import { eventsHelper, isUnderTest, monotonicTime, quoteCSSAttributeValue, type RegisteredListener } from '../../utils'; +import { eventsHelper, monotonicTime, quoteCSSAttributeValue, type RegisteredListener } from '../../utils'; import { raceAgainstDeadline } from '../../utils/timeoutRunner'; import { BrowserContext } from '../browserContext'; import type { ActionInContext, FrameDescription, LanguageGeneratorOptions, Language, LanguageGenerator } from '../codegen/types'; @@ -27,7 +27,6 @@ import type { Dialog } from '../dialog'; import { Frame } from '../frames'; import { Page } from '../page'; import type * as actions from './recorderActions'; -import { performAction } from './recorderRunner'; import { ThrottledFile } from './throttledFile'; import { RecorderCollection } from './recorderCollection'; import { generateCode } from '../codegen/language'; @@ -48,7 +47,6 @@ export class ContextRecorder extends EventEmitter { private _lastPopupOrdinal = 0; private _lastDialogOrdinal = -1; private _lastDownloadOrdinal = -1; - private _timers = new Set(); private _context: BrowserContext; private _params: channels.BrowserContextRecorderSupplementEnableParams; private _delegate: ContextRecorderDelegate; @@ -69,13 +67,13 @@ export class ContextRecorder extends EventEmitter { // Make a copy of options to modify them later. const languageGeneratorOptions: LanguageGeneratorOptions = { browserName: context._browser.options.name, - launchOptions: { headless: false, ...params.launchOptions }, + launchOptions: { headless: false, ...params.launchOptions, tracesDir: undefined }, contextOptions: { ...params.contextOptions }, deviceName: params.device, saveStorage: params.saveStorage, }; - const collection = new RecorderCollection(params.mode === 'recording'); + const collection = new RecorderCollection(this._pageAliases, params.mode === 'recording'); collection.on('change', () => { this._recorderSources = []; for (const languageGenerator of this._orderedLanguages) { @@ -97,10 +95,7 @@ export class ContextRecorder extends EventEmitter { if (languageGenerator === this._orderedLanguages[0]) this._throttledOutputFile?.setContent(source.text); } - this.emit(ContextRecorder.Events.Change, { - sources: this._recorderSources, - primaryFileName: this._orderedLanguages[0].id - }); + this.emit(ContextRecorder.Events.Change, { sources: this._recorderSources }); }); context.on(BrowserContext.Events.BeforeClose, () => { this._throttledOutputFile?.flush(); @@ -153,9 +148,6 @@ export class ContextRecorder extends EventEmitter { } dispose() { - for (const timer of this._timers) - clearTimeout(timer); - this._timers.clear(); eventsHelper.removeEventListeners(this._listeners); } @@ -163,13 +155,13 @@ export class ContextRecorder extends EventEmitter { // First page is called page, others are called popup1, popup2, etc. const frame = page.mainFrame(); page.on('close', () => { - this._collection.addAction({ + this._collection.addRecordedAction({ frame: this._describeMainFrame(page), - committed: true, action: { name: 'closePage', signals: [], - } + }, + timestamp: monotonicTime() }); this._pageAliases.delete(page); }); @@ -185,14 +177,14 @@ export class ContextRecorder extends EventEmitter { if (page.opener()) { this._onPopup(page.opener()!, page); } else { - this._collection.addAction({ + this._collection.addRecordedAction({ frame: this._describeMainFrame(page), - committed: true, action: { name: 'openPage', url: page.mainFrame().url(), signals: [], - } + }, + timestamp: monotonicTime() }); } } @@ -223,53 +215,24 @@ export class ContextRecorder extends EventEmitter { return this._params.testIdAttributeName || this._context.selectors().testIdAttributeName() || 'data-testid'; } - private async _performAction(frame: Frame, action: actions.PerformOnRecordAction) { - // Commit last action so that no further signals are added to it. - this._collection.commitLastAction(); - + private async _createActionInContext(frame: Frame, action: actions.Action): Promise { const frameDescription = await this._describeFrame(frame); const actionInContext: ActionInContext = { frame: frameDescription, action, description: undefined, + timestamp: monotonicTime() }; - await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext); + return actionInContext; + } - this._collection.willPerformAction(actionInContext); - const success = await performAction(this._pageAliases, actionInContext); - if (success) { - this._collection.didPerformAction(actionInContext); - this._setCommittedAfterTimeout(actionInContext); - } else { - this._collection.performedActionFailed(actionInContext); - } + private async _performAction(frame: Frame, action: actions.PerformOnRecordAction) { + await this._collection.performAction(await this._createActionInContext(frame, action)); } private async _recordAction(frame: Frame, action: actions.Action) { - // Commit last action so that no further signals are added to it. - this._collection.commitLastAction(); - - const frameDescription = await this._describeFrame(frame); - const actionInContext: ActionInContext = { - frame: frameDescription, - action, - description: undefined, - }; - - await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext); - - this._setCommittedAfterTimeout(actionInContext); - this._collection.addAction(actionInContext); - } - - private _setCommittedAfterTimeout(actionInContext: ActionInContext) { - const timer = setTimeout(() => { - // Commit the action after 5 seconds so that no further signals are added to it. - actionInContext.committed = true; - this._timers.delete(timer); - }, isUnderTest() ? 500 : 5000); - this._timers.add(timer); + this._collection.addRecordedAction(await this._createActionInContext(frame, action)); } private _onFrameNavigated(frame: Frame, page: Page) { diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index 0faf191ea5..67d8b6e8dc 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -24,13 +24,13 @@ import type { CallLog, EventData, Mode, Source } from '@recorder/recorderTypes'; import { isUnderTest } from '../../utils'; import { mime } from '../../utilsBundle'; import { syncLocalStorageWithSettings } from '../launchApp'; -import type { Recorder, RecorderAppFactory } from '../recorder'; import type { BrowserContext } from '../browserContext'; import { launchApp } from '../launchApp'; +import type { IRecorder, IRecorderApp, IRecorderAppFactory } from './recorderFrontend'; declare global { interface Window { - playwrightSetFileIfNeeded: (file: string) => void; + playwrightSetFile: (file: string) => void; playwrightSetMode: (mode: Mode) => void; playwrightSetPaused: (paused: boolean) => void; playwrightSetSources: (sources: Source[]) => void; @@ -42,21 +42,11 @@ declare global { } } -export interface IRecorderApp extends EventEmitter { - close(): Promise; - setPaused(paused: boolean): Promise; - setMode(mode: Mode): Promise; - setFileIfNeeded(file: string): Promise; - setSelector(selector: string, userGesture?: boolean): Promise; - updateCallLogs(callLogs: CallLog[]): Promise; - setSources(sources: Source[]): Promise; -} - export class EmptyRecorderApp extends EventEmitter implements IRecorderApp { async close(): Promise {} async setPaused(paused: boolean): Promise {} async setMode(mode: Mode): Promise {} - async setFileIfNeeded(file: string): Promise {} + async setFile(file: string): Promise {} async setSelector(selector: string, userGesture?: boolean): Promise {} async updateCallLogs(callLogs: CallLog[]): Promise {} async setSources(sources: Source[]): Promise {} @@ -65,9 +55,9 @@ export class EmptyRecorderApp extends EventEmitter implements IRecorderApp { export class RecorderApp extends EventEmitter implements IRecorderApp { private _page: Page; readonly wsEndpoint: string | undefined; - private _recorder: Recorder; + private _recorder: IRecorder; - constructor(recorder: Recorder, page: Page, wsEndpoint: string | undefined) { + constructor(recorder: IRecorder, page: Page, wsEndpoint: string | undefined) { super(); this.setMaxListeners(0); this._recorder = recorder; @@ -113,7 +103,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { await mainFrame.goto(serverSideCallMetadata(), 'https://playwright/index.html'); } - static factory(context: BrowserContext): RecorderAppFactory { + static factory(context: BrowserContext): IRecorderAppFactory { return async recorder => { if (process.env.PW_CODEGEN_NO_INSPECTOR) return new EmptyRecorderApp(); @@ -121,7 +111,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { }; } - private static async _open(recorder: Recorder, inspectedContext: BrowserContext): Promise { + private static async _open(recorder: IRecorder, inspectedContext: BrowserContext): Promise { const sdkLanguage = inspectedContext.attribution.playwright.options.sdkLanguage; const headed = !!inspectedContext._browser.options.headful; const recorderPlaywright = (require('../playwright').createPlaywright as typeof import('../playwright').createPlaywright)({ sdkLanguage: 'javascript', isInternalPlaywright: true }); @@ -154,9 +144,9 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { }).toString(), { isFunction: true }, mode).catch(() => {}); } - async setFileIfNeeded(file: string): Promise { + async setFile(file: string): Promise { await this._page.mainFrame().evaluateExpression(((file: string) => { - window.playwrightSetFileIfNeeded(file); + window.playwrightSetFile(file); }).toString(), { isFunction: true }, file).catch(() => {}); } diff --git a/packages/playwright-core/src/server/recorder/recorderCollection.ts b/packages/playwright-core/src/server/recorder/recorderCollection.ts index 29da778ffb..e9c2b31427 100644 --- a/packages/playwright-core/src/server/recorder/recorderCollection.ts +++ b/packages/playwright-core/src/server/recorder/recorderCollection.ts @@ -16,24 +16,29 @@ import { EventEmitter } from 'events'; import type { Frame } from '../frames'; +import type { Page } from '../page'; import type { Signal } from './recorderActions'; import type { ActionInContext } from '../codegen/types'; +import { monotonicTime } from '../../utils/time'; +import { callMetadataForAction } from './recorderUtils'; +import { serializeError } from '../errors'; +import { performAction } from './recorderRunner'; +import type { CallMetadata } from '@protocol/callMetadata'; +import { isUnderTest } from '../../utils/debug'; export class RecorderCollection extends EventEmitter { - private _currentAction: ActionInContext | null = null; - private _lastAction: ActionInContext | null = null; private _actions: ActionInContext[] = []; private _enabled: boolean; + private _pageAliases: Map; - constructor(enabled: boolean) { + constructor(pageAliases: Map, enabled: boolean) { super(); this._enabled = enabled; + this._pageAliases = pageAliases; this.restart(); } restart() { - this._currentAction = null; - this._lastAction = null; this._actions = []; this.emit('change'); } @@ -46,106 +51,73 @@ export class RecorderCollection extends EventEmitter { this._enabled = enabled; } - addAction(action: ActionInContext) { - if (!this._enabled) - return; - this.willPerformAction(action); - this.didPerformAction(action); + async performAction(actionInContext: ActionInContext) { + await this._addAction(actionInContext, async callMetadata => { + await performAction(callMetadata, this._pageAliases, actionInContext); + }); } - willPerformAction(action: ActionInContext) { - if (!this._enabled) + addRecordedAction(actionInContext: ActionInContext) { + if (['openPage', 'closePage'].includes(actionInContext.action.name)) { + this._actions.push(actionInContext); + this.emit('change'); return; - this._currentAction = action; - } - - performedActionFailed(action: ActionInContext) { - if (!this._enabled) - return; - if (this._currentAction === action) - this._currentAction = null; - } - - didPerformAction(actionInContext: ActionInContext) { - if (!this._enabled) - return; - const action = actionInContext.action; - let eraseLastAction = false; - if (this._lastAction && this._lastAction.frame.pageAlias === actionInContext.frame.pageAlias) { - const lastAction = this._lastAction.action; - // We augment last action based on the type. - if (this._lastAction && action.name === 'fill' && lastAction.name === 'fill') { - if (action.selector === lastAction.selector) - eraseLastAction = true; - } - if (lastAction && action.name === 'click' && lastAction.name === 'click') { - if (action.selector === lastAction.selector && action.clickCount > lastAction.clickCount) - eraseLastAction = true; - } - if (lastAction && action.name === 'navigate' && lastAction.name === 'navigate') { - if (action.url === lastAction.url) { - // Already at a target URL. - this._currentAction = null; - return; - } - } - // Check and uncheck erase click. - if (lastAction && (action.name === 'check' || action.name === 'uncheck') && lastAction.name === 'click') { - if (action.selector === lastAction.selector) - eraseLastAction = true; - } } + this._addAction(actionInContext).catch(() => {}); + } - this._lastAction = actionInContext; - this._currentAction = null; - if (eraseLastAction) - this._actions.pop(); + private async _addAction(actionInContext: ActionInContext, callback?: (callMetadata: CallMetadata) => Promise) { + if (!this._enabled) + return; + + const { callMetadata, mainFrame } = callMetadataForAction(this._pageAliases, actionInContext); + await mainFrame.instrumentation.onBeforeCall(mainFrame, callMetadata); this._actions.push(actionInContext); this.emit('change'); - } - - commitLastAction() { - if (!this._enabled) - return; - const action = this._lastAction; - if (action) - action.committed = true; + const error = await callback?.(callMetadata).catch((e: Error) => e); + callMetadata.endTime = monotonicTime(); + callMetadata.error = error ? serializeError(error) : undefined; + await mainFrame.instrumentation.onAfterCall(mainFrame, callMetadata); } signal(pageAlias: string, frame: Frame, signal: Signal) { if (!this._enabled) return; - // Signal either arrives while action is being performed or shortly after. - if (this._currentAction) { - this._currentAction.action.signals.push(signal); + if (signal.name === 'navigation' && frame._page.mainFrame() === frame) { + const timestamp = monotonicTime(); + const lastAction = this._actions[this._actions.length - 1]; + const signalThreshold = isUnderTest() ? 500 : 5000; + + let generateGoto = false; + if (!lastAction) + generateGoto = true; + else if (lastAction.action.name !== 'click' && lastAction.action.name !== 'press') + generateGoto = true; + else if (timestamp - lastAction.timestamp > signalThreshold) + generateGoto = true; + + if (generateGoto) { + this.addRecordedAction({ + frame: { + pageAlias, + framePath: [], + }, + action: { + name: 'navigate', + url: frame.url(), + signals: [], + }, + timestamp + }); + } return; } - if (this._lastAction && (!this._lastAction.committed || signal.name !== 'navigation')) { - const signals = this._lastAction.action.signals; - if (signal.name === 'navigation' && signals.length && signals[signals.length - 1].name === 'download') - return; - if (signal.name === 'download' && signals.length && signals[signals.length - 1].name === 'navigation') - signals.length = signals.length - 1; - this._lastAction.action.signals.push(signal); + if (this._actions.length) { + this._actions[this._actions.length - 1].action.signals.push(signal); this.emit('change'); return; } - - if (signal.name === 'navigation' && frame._page.mainFrame() === frame) { - this.addAction({ - frame: { - pageAlias, - framePath: [], - }, - committed: true, - action: { - name: 'navigate', - url: frame.url(), - signals: [], - }, - }); - } } } diff --git a/packages/playwright-core/src/server/recorder/recorderFrontend.ts b/packages/playwright-core/src/server/recorder/recorderFrontend.ts new file mode 100644 index 0000000000..162c9f9964 --- /dev/null +++ b/packages/playwright-core/src/server/recorder/recorderFrontend.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { CallLog, Mode, Source } from '@recorder/recorderTypes'; +import type { EventEmitter } from 'events'; + +export interface IRecorder { + setMode(mode: Mode): void; + mode(): Mode; +} + +export interface IRecorderApp extends EventEmitter { + close(): Promise; + setPaused(paused: boolean): Promise; + setMode(mode: Mode): Promise; + setFile(file: string): Promise; + setSelector(selector: string, userGesture?: boolean): Promise; + updateCallLogs(callLogs: CallLog[]): Promise; + setSources(sources: Source[]): Promise; +} + +export type IRecorderAppFactory = (recorder: IRecorder) => Promise; diff --git a/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts b/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts new file mode 100644 index 0000000000..a9fd766141 --- /dev/null +++ b/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts @@ -0,0 +1,94 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from 'path'; +import type { CallLog, Mode, Source } from '@recorder/recorderTypes'; +import { EventEmitter } from 'events'; +import type { IRecorder, IRecorderApp, IRecorderAppFactory } from './recorderFrontend'; +import { installRootRedirect, openTraceViewerApp, startTraceViewerServer } from '../trace/viewer/traceViewer'; +import type { TraceViewerServerOptions } from '../trace/viewer/traceViewer'; +import type { BrowserContext } from '../browserContext'; +import { gracefullyProcessExitDoNotHang } from '../../utils/processLauncher'; +import type { Transport } from '../../utils/httpServer'; + +export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp { + private _recorder: IRecorder; + private _transport: Transport; + + static factory(context: BrowserContext): IRecorderAppFactory { + return async (recorder: IRecorder) => { + const transport = new RecorderTransport(); + const trace = path.join(context._browser.options.tracesDir, 'trace'); + await openApp(trace, { transport }); + return new RecorderInTraceViewer(context, recorder, transport); + }; + } + + constructor(context: BrowserContext, recorder: IRecorder, transport: Transport) { + super(); + this._recorder = recorder; + this._transport = transport; + } + + async close(): Promise { + this._transport.sendEvent?.('close', {}); + } + + async setPaused(paused: boolean): Promise { + this._transport.sendEvent?.('setPaused', { paused }); + } + + async setMode(mode: Mode): Promise { + this._transport.sendEvent?.('setMode', { mode }); + } + + async setFile(file: string): Promise { + this._transport.sendEvent?.('setFileIfNeeded', { file }); + } + + async setSelector(selector: string, userGesture?: boolean): Promise { + this._transport.sendEvent?.('setSelector', { selector, userGesture }); + } + + async updateCallLogs(callLogs: CallLog[]): Promise { + this._transport.sendEvent?.('updateCallLogs', { callLogs }); + } + + async setSources(sources: Source[]): Promise { + this._transport.sendEvent?.('setSources', { sources }); + } +} + +async function openApp(trace: string, options?: TraceViewerServerOptions & { headless?: boolean }) { + const server = await startTraceViewerServer(options); + await installRootRedirect(server, [trace], { ...options, webApp: 'recorder.html' }); + const page = await openTraceViewerApp(server.urlPrefix('precise'), 'chromium', options); + page.on('close', () => gracefullyProcessExitDoNotHang(0)); +} + +class RecorderTransport implements Transport { + constructor() { + } + + async dispatch(method: string, params: any) { + } + + onclose() { + } + + sendEvent?: (method: string, params: any) => void; + close?: () => void; +} diff --git a/packages/playwright-core/src/server/recorder/recorderRunner.ts b/packages/playwright-core/src/server/recorder/recorderRunner.ts index b6bdfd1a72..f5358d6097 100644 --- a/packages/playwright-core/src/server/recorder/recorderRunner.ts +++ b/packages/playwright-core/src/server/recorder/recorderRunner.ts @@ -14,115 +14,130 @@ * limitations under the License. */ -import { createGuid, monotonicTime, serializeExpectedTextValues } from '../../utils'; -import { toClickOptions, toKeyboardModifiers } from '../codegen/language'; +import { serializeExpectedTextValues } from '../../utils'; +import { toKeyboardModifiers } from '../codegen/language'; import type { ActionInContext } from '../codegen/types'; -import type { Frame } from '../frames'; import type { CallMetadata } from '../instrumentation'; import type { Page } from '../page'; -import { buildFullSelector } from './recorderUtils'; +import type * as actions from './recorderActions'; +import type * as types from '../types'; +import { buildFullSelector, mainFrameForAction } from './recorderUtils'; -async function innerPerformAction(mainFrame: Frame, action: string, params: any, cb: (callMetadata: CallMetadata) => Promise): Promise { - const callMetadata: CallMetadata = { - id: `call@${createGuid()}`, - apiName: 'frame.' + action, - objectId: mainFrame.guid, - pageId: mainFrame._page.guid, - frameId: mainFrame.guid, - startTime: monotonicTime(), - endTime: 0, - type: 'Frame', - method: action, - params, - log: [], - }; - - try { - await mainFrame.instrumentation.onBeforeCall(mainFrame, callMetadata); - await cb(callMetadata); - } catch (e) { - callMetadata.endTime = monotonicTime(); - await mainFrame.instrumentation.onAfterCall(mainFrame, callMetadata); - return false; - } - - callMetadata.endTime = monotonicTime(); - await mainFrame.instrumentation.onAfterCall(mainFrame, callMetadata); - return true; -} - -export async function performAction(pageAliases: Map, actionInContext: ActionInContext): Promise { - const pageAlias = actionInContext.frame.pageAlias; - const page = [...pageAliases.entries()].find(([, alias]) => pageAlias === alias)?.[0]; - if (!page) - throw new Error('Internal error: page not found'); - const mainFrame = page.mainFrame(); +export async function performAction(callMetadata: CallMetadata, pageAliases: Map, actionInContext: ActionInContext) { + const mainFrame = mainFrameForAction(pageAliases, actionInContext); const { action } = actionInContext; + const kActionTimeout = 5000; - if (action.name === 'navigate') - return await innerPerformAction(mainFrame, 'goto', { url: action.url }, callMetadata => mainFrame.goto(callMetadata, action.url, { timeout: kActionTimeout })); + if (action.name === 'navigate') { + await mainFrame.goto(callMetadata, action.url, { timeout: kActionTimeout }); + return; + } + if (action.name === 'openPage') throw Error('Not reached'); - if (action.name === 'closePage') - return await innerPerformAction(mainFrame, 'close', {}, callMetadata => mainFrame._page.close(callMetadata)); + + if (action.name === 'closePage') { + await mainFrame._page.close(callMetadata); + return; + } const selector = buildFullSelector(actionInContext.frame.framePath, action.selector); if (action.name === 'click') { const options = toClickOptions(action); - return await innerPerformAction(mainFrame, 'click', { selector }, callMetadata => mainFrame.click(callMetadata, selector, { ...options, timeout: kActionTimeout, strict: true })); + await mainFrame.click(callMetadata, selector, { ...options, timeout: kActionTimeout, strict: true }); + return; } + if (action.name === 'press') { const modifiers = toKeyboardModifiers(action.modifiers); const shortcut = [...modifiers, action.key].join('+'); - return await innerPerformAction(mainFrame, 'press', { selector, key: shortcut }, callMetadata => mainFrame.press(callMetadata, selector, shortcut, { timeout: kActionTimeout, strict: true })); + await mainFrame.press(callMetadata, selector, shortcut, { timeout: kActionTimeout, strict: true }); + return; } - if (action.name === 'fill') - return await innerPerformAction(mainFrame, 'fill', { selector, text: action.text }, callMetadata => mainFrame.fill(callMetadata, selector, action.text, { timeout: kActionTimeout, strict: true })); - if (action.name === 'setInputFiles') - return await innerPerformAction(mainFrame, 'setInputFiles', { selector, files: action.files }, callMetadata => mainFrame.setInputFiles(callMetadata, selector, { selector, payloads: [], timeout: kActionTimeout, strict: true })); - if (action.name === 'check') - return await innerPerformAction(mainFrame, 'check', { selector }, callMetadata => mainFrame.check(callMetadata, selector, { timeout: kActionTimeout, strict: true })); - if (action.name === 'uncheck') - return await innerPerformAction(mainFrame, 'uncheck', { selector }, callMetadata => mainFrame.uncheck(callMetadata, selector, { timeout: kActionTimeout, strict: true })); + + if (action.name === 'fill') { + await mainFrame.fill(callMetadata, selector, action.text, { timeout: kActionTimeout, strict: true }); + return; + } + + if (action.name === 'setInputFiles') { + await mainFrame.setInputFiles(callMetadata, selector, { selector, payloads: [], timeout: kActionTimeout, strict: true }); + return; + } + + if (action.name === 'check') { + await mainFrame.check(callMetadata, selector, { timeout: kActionTimeout, strict: true }); + return; + } + + if (action.name === 'uncheck') { + await mainFrame.uncheck(callMetadata, selector, { timeout: kActionTimeout, strict: true }); + return; + } + if (action.name === 'select') { const values = action.options.map(value => ({ value })); - return await innerPerformAction(mainFrame, 'selectOption', { selector, values }, callMetadata => mainFrame.selectOption(callMetadata, selector, [], values, { timeout: kActionTimeout, strict: true })); + await mainFrame.selectOption(callMetadata, selector, [], values, { timeout: kActionTimeout, strict: true }); + return; } + if (action.name === 'assertChecked') { - return await innerPerformAction(mainFrame, 'expect', { selector }, callMetadata => mainFrame.expect(callMetadata, selector, { + await mainFrame.expect(callMetadata, selector, { selector, expression: 'to.be.checked', isNot: !action.checked, timeout: kActionTimeout, - })); + }); + return; } + if (action.name === 'assertText') { - return await innerPerformAction(mainFrame, 'expect', { selector }, callMetadata => mainFrame.expect(callMetadata, selector, { + await mainFrame.expect(callMetadata, selector, { selector, expression: 'to.have.text', expectedText: serializeExpectedTextValues([action.text], { matchSubstring: true, normalizeWhiteSpace: true }), isNot: false, timeout: kActionTimeout, - })); + }); + return; } + if (action.name === 'assertValue') { - return await innerPerformAction(mainFrame, 'expect', { selector }, callMetadata => mainFrame.expect(callMetadata, selector, { + await mainFrame.expect(callMetadata, selector, { selector, expression: 'to.have.value', expectedValue: action.value, isNot: false, timeout: kActionTimeout, - })); + }); + return; } + if (action.name === 'assertVisible') { - return await innerPerformAction(mainFrame, 'expect', { selector }, callMetadata => mainFrame.expect(callMetadata, selector, { + await mainFrame.expect(callMetadata, selector, { selector, expression: 'to.be.visible', isNot: false, timeout: kActionTimeout, - })); + }); + return; } + throw new Error('Internal error: unexpected action ' + (action as any).name); } + +export function toClickOptions(action: actions.ClickAction): types.MouseClickOptions { + const modifiers = toKeyboardModifiers(action.modifiers); + const options: types.MouseClickOptions = {}; + if (action.button !== 'left') + options.button = action.button; + if (modifiers.length) + options.modifiers = modifiers; + if (action.clickCount > 1) + options.clickCount = action.clickCount; + if (action.position) + options.position = action.position; + return options; +} diff --git a/packages/playwright-core/src/server/recorder/recorderUtils.ts b/packages/playwright-core/src/server/recorder/recorderUtils.ts index b4949115d2..ac6c970489 100644 --- a/packages/playwright-core/src/server/recorder/recorderUtils.ts +++ b/packages/playwright-core/src/server/recorder/recorderUtils.ts @@ -20,6 +20,9 @@ import type { Page } from '../page'; import type { ActionInContext } from '../codegen/types'; import type { Frame } from '../frames'; import type * as actions from './recorderActions'; +import { toKeyboardModifiers } from '../codegen/language'; +import { serializeExpectedTextValues } from '../../utils/expectUtils'; +import { createGuid, monotonicTime } from '../../utils'; export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus): CallLog { let title = metadata.apiName || metadata.method; @@ -57,7 +60,7 @@ export function mainFrameForAction(pageAliases: Map, actionInConte const pageAlias = actionInContext.frame.pageAlias; const page = [...pageAliases.entries()].find(([, alias]) => pageAlias === alias)?.[0]; if (!page) - throw new Error('Internal error: page not found'); + throw new Error(`Internal error: page ${pageAlias} not found in [${[...pageAliases.values()]}]`); return page.mainFrame(); } @@ -72,3 +75,77 @@ export async function frameForAction(pageAliases: Map, actionInCon throw new Error('Internal error: frame not found'); return result.frame; } + +export function traceParamsForAction(actionInContext: ActionInContext) { + const { action } = actionInContext; + + switch (action.name) { + case 'navigate': return { url: action.url }; + case 'openPage': return {}; + case 'closePage': return {}; + } + + const selector = buildFullSelector(actionInContext.frame.framePath, action.selector); + switch (action.name) { + case 'click': return { selector, clickCount: action.clickCount }; + case 'press': { + const modifiers = toKeyboardModifiers(action.modifiers); + const shortcut = [...modifiers, action.key].join('+'); + return { selector, key: shortcut }; + } + case 'fill': return { selector, text: action.text }; + case 'setInputFiles': return { selector, files: action.files }; + case 'check': return { selector }; + case 'uncheck': return { selector }; + case 'select': return { selector, values: action.options.map(value => ({ value })) }; + case 'assertChecked': { + return { + selector, + expression: 'to.be.checked', + isNot: !action.checked, + }; + } + case 'assertText': { + return { + selector, + expression: 'to.have.text', + expectedText: serializeExpectedTextValues([action.text], { matchSubstring: true, normalizeWhiteSpace: true }), + isNot: false, + }; + } + case 'assertValue': { + return { + selector, + expression: 'to.have.value', + expectedValue: action.value, + isNot: false, + }; + } + case 'assertVisible': { + return { + selector, + expression: 'to.be.visible', + isNot: false, + }; + } + } +} + +export function callMetadataForAction(pageAliases: Map, actionInContext: ActionInContext): { callMetadata: CallMetadata, mainFrame: Frame } { + const mainFrame = mainFrameForAction(pageAliases, actionInContext); + const { action } = actionInContext; + const callMetadata: CallMetadata = { + id: `call@${createGuid()}`, + apiName: 'frame.' + action.name, + objectId: mainFrame.guid, + pageId: mainFrame._page.guid, + frameId: mainFrame.guid, + startTime: monotonicTime(), + endTime: 0, + type: 'Frame', + method: action.name, + params: traceParamsForAction(actionInContext), + log: [], + }; + return { callMetadata, mainFrame }; +} diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index 2dd900bf89..08aaa4f25a 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -23,8 +23,11 @@ import { createSocket, createTLSSocket } from '../utils/happy-eyeballs'; import { escapeHTML, generateSelfSignedCertificate, ManualPromise, rewriteErrorMessage } from '../utils'; import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../common/socksProxy'; import { SocksProxy } from '../common/socksProxy'; -import type * as channels from '@protocol/channels'; +import type * as types from './types'; import { debugLogger } from '../utils/debugLogger'; +import { createProxyAgent } from './fetch'; +import { EventEmitter } from 'events'; +import { verifyClientCertificates } from './browserContext'; let dummyServerTlsOptions: tls.TlsOptions | undefined = undefined; function loadDummyServerCertsIfNeeded() { @@ -94,7 +97,11 @@ class SocksProxyConnection { } async connect() { - this.target = await createSocket(rewriteToLocalhostIfNeeded(this.host), this.port); + if (this.socksProxy.proxyAgentFromOptions) + this.target = await this.socksProxy.proxyAgentFromOptions.callback(new EventEmitter() as any, { host: rewriteToLocalhostIfNeeded(this.host), port: this.port, secureEndpoint: false }); + else + this.target = await createSocket(rewriteToLocalhostIfNeeded(this.host), this.port); + this.target.once('close', this._targetCloseEventListener); this.target.once('error', error => this.socksProxy._socksProxy.sendSocketError({ uid: this.uid, error: error.message })); if (this._closed) { @@ -233,12 +240,15 @@ export class ClientCertificatesProxy { ignoreHTTPSErrors: boolean | undefined; secureContextMap: Map = new Map(); alpnCache: ALPNCache; + proxyAgentFromOptions: ReturnType | undefined; constructor( - contextOptions: Pick + contextOptions: Pick ) { + verifyClientCertificates(contextOptions.clientCertificates); this.alpnCache = new ALPNCache(); this.ignoreHTTPSErrors = contextOptions.ignoreHTTPSErrors; + this.proxyAgentFromOptions = contextOptions.proxy ? createProxyAgent(contextOptions.proxy) : undefined; this._initSecureContexts(contextOptions.clientCertificates); this._socksProxy = new SocksProxy(); this._socksProxy.setPattern('*'); @@ -261,9 +271,9 @@ export class ClientCertificatesProxy { loadDummyServerCertsIfNeeded(); } - _initSecureContexts(clientCertificates: channels.BrowserNewContextOptions['clientCertificates']) { + _initSecureContexts(clientCertificates: types.BrowserContextOptions['clientCertificates']) { // Step 1. Group certificates by origin. - const origin2certs = new Map(); + const origin2certs = new Map(); for (const cert of clientCertificates || []) { const origin = normalizeOrigin(cert.origin); const certs = origin2certs.get(origin) || []; @@ -282,9 +292,9 @@ export class ClientCertificatesProxy { } } - public async listen(): Promise { + public async listen() { const port = await this._socksProxy.listen(0, '127.0.0.1'); - return `socks5://127.0.0.1:${port}`; + return { server: `socks5://127.0.0.1:${port}` }; } public async close() { @@ -301,7 +311,7 @@ function normalizeOrigin(origin: string): string { } function convertClientCertificatesToTLSOptions( - clientCertificates: channels.BrowserNewContextOptions['clientCertificates'] + clientCertificates: types.BrowserContextOptions['clientCertificates'] ): Pick | undefined { if (!clientCertificates || !clientCertificates.length) return; @@ -322,7 +332,7 @@ function convertClientCertificatesToTLSOptions( } export function getMatchingTLSOptionsForOrigin( - clientCertificates: channels.BrowserNewContextOptions['clientCertificates'], + clientCertificates: types.BrowserContextOptions['clientCertificates'], origin: string ): Pick | undefined { const matchingCerts = clientCertificates?.filter(c => diff --git a/packages/playwright-core/src/server/types.ts b/packages/playwright-core/src/server/types.ts index 226216c397..b58ea5af83 100644 --- a/packages/playwright-core/src/server/types.ts +++ b/packages/playwright-core/src/server/types.ts @@ -150,7 +150,15 @@ export type NormalizedContinueOverrides = { export type EmulatedSize = { viewport: Size, screen: Size }; -export type LaunchOptions = channels.BrowserTypeLaunchOptions & { useWebSocket?: boolean }; +export type LaunchOptions = channels.BrowserTypeLaunchOptions & { + useWebSocket?: boolean, + proxyOverride?: ProxySettings, +}; + +export type BrowserContextOptions = channels.BrowserNewContextOptions & { + proxyOverride?: ProxySettings; + internalIgnoreHTTPSErrors?: boolean; +}; export type ProtocolLogger = (direction: 'send' | 'receive', message: object) => void; diff --git a/packages/playwright-core/src/server/webkit/webkit.ts b/packages/playwright-core/src/server/webkit/webkit.ts index b25b62a421..9a11f6c56d 100644 --- a/packages/playwright-core/src/server/webkit/webkit.ts +++ b/packages/playwright-core/src/server/webkit/webkit.ts @@ -53,7 +53,7 @@ export class WebKit extends BrowserType { } override defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] { - const { args = [], proxy, headless } = options; + const { args = [], headless } = options; const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir')); if (userDataDirArg) throw this._createUserDataDirArgMisuseError('--user-data-dir'); @@ -68,6 +68,7 @@ export class WebKit extends BrowserType { webkitArguments.push(`--user-data-dir=${userDataDir}`); else webkitArguments.push(`--no-startup-window`); + const proxy = options.proxyOverride || options.proxy; if (proxy) { if (process.platform === 'darwin') { webkitArguments.push(`--proxy=${proxy.server}`); diff --git a/packages/playwright-core/src/server/webkit/wkBrowser.ts b/packages/playwright-core/src/server/webkit/wkBrowser.ts index 92231edd75..c9bda10ddd 100644 --- a/packages/playwright-core/src/server/webkit/wkBrowser.ts +++ b/packages/playwright-core/src/server/webkit/wkBrowser.ts @@ -81,12 +81,13 @@ export class WKBrowser extends Browser { this._didClose(); } - async doCreateNewContext(options: channels.BrowserNewContextParams): Promise { - const createOptions = options.proxy ? { - // Enable socks5 hostname resolution on Windows. Workaround can be removed once fixed upstream. + async doCreateNewContext(options: types.BrowserContextOptions): Promise { + const proxy = options.proxyOverride || options.proxy; + const createOptions = proxy ? { + // Enable socks5 hostname resolution on Windows. // See https://github.com/microsoft/playwright/issues/20451 - proxyServer: process.platform === 'win32' ? options.proxy.server.replace(/^socks5:\/\//, 'socks5h://') : options.proxy.server, - proxyBypassList: options.proxy.bypass + proxyServer: process.platform === 'win32' ? proxy.server.replace(/^socks5:\/\//, 'socks5h://') : proxy.server, + proxyBypassList: proxy.bypass } : undefined; const { browserContextId } = await this._browserSession.send('Playwright.createContext', createOptions); options.userAgent = options.userAgent || DEFAULT_USER_AGENT; @@ -206,7 +207,7 @@ export class WKBrowser extends Browser { export class WKBrowserContext extends BrowserContext { declare readonly _browser: WKBrowser; - constructor(browser: WKBrowser, browserContextId: string | undefined, options: channels.BrowserNewContextParams) { + constructor(browser: WKBrowser, browserContextId: string | undefined, options: types.BrowserContextOptions) { super(browser, options, browserContextId); this._validateEmulatedViewport(options.viewport); this._authenticateProxyViaHeader(); @@ -221,7 +222,7 @@ export class WKBrowserContext extends BrowserContext { downloadPath: this._browser.options.downloadsPath, browserContextId })); - if (this._options.ignoreHTTPSErrors) + if (this._options.ignoreHTTPSErrors || this._options.internalIgnoreHTTPSErrors) promises.push(this._browser._browserSession.send('Playwright.setIgnoreCertificateErrors', { browserContextId, ignore: true })); if (this._options.locale) promises.push(this._browser._browserSession.send('Playwright.setLanguages', { browserContextId, languages: [this._options.locale] })); diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index c3954b4882..2f579b619b 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -768,6 +768,10 @@ export class WKPage implements PageDelegate { }); } + async forceGarbageCollection(): Promise { + await this._session.send('Heap.gc'); + } + async addInitScript(initScript: InitScript): Promise { await this._updateBootstrapScript(); } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 765c84c36e..ad102f9271 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -2554,6 +2554,11 @@ export interface Page { timeout?: number; }): Promise; + /** + * Force the browser to perform garbage collection. + */ + forceGarbageCollection(): Promise; + /** * Returns frame matching the specified criteria. Either `name` or `url` must be specified. * @@ -9197,8 +9202,6 @@ export interface Browser { * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * with an exact match to the request origin that the certificate is valid for. * - * **NOTE** Using Client Certificates in combination with Proxy Servers is not supported. - * * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * work by replacing `localhost` with `local.playwright`. */ @@ -13055,7 +13058,10 @@ export interface Locator { nth(index: number): Locator; /** - * Creates a locator that matches either of the two locators. + * Creates a locator matching all elements that match one or both of the two locators. + * + * Note that when both locators match something, the resulting locator will have multiple matches and violate + * [locator strictness](https://playwright.dev/docs/locators#strictness) guidelines. * * **Usage** * @@ -13916,8 +13922,6 @@ export interface BrowserType { * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * with an exact match to the request origin that the certificate is valid for. * - * **NOTE** Using Client Certificates in combination with Proxy Servers is not supported. - * * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * work by replacing `localhost` with `local.playwright`. */ @@ -16342,8 +16346,6 @@ export interface APIRequest { * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * with an exact match to the request origin that the certificate is valid for. * - * **NOTE** Using Client Certificates in combination with Proxy Servers is not supported. - * * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * work by replacing `localhost` with `local.playwright`. */ @@ -16559,7 +16561,7 @@ export interface APIRequestContext { * as this request body. If this parameter is specified `content-type` header will be set to * `application/x-www-form-urlencoded` unless explicitly provided. */ - form?: { [key: string]: string|number|boolean; }; + form?: { [key: string]: string|number|boolean; }|FormData; /** * Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by @@ -16689,7 +16691,7 @@ export interface APIRequestContext { * as this request body. If this parameter is specified `content-type` header will be set to * `application/x-www-form-urlencoded` unless explicitly provided. */ - form?: { [key: string]: string|number|boolean; }; + form?: { [key: string]: string|number|boolean; }|FormData; /** * Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by @@ -16807,7 +16809,7 @@ export interface APIRequestContext { * as this request body. If this parameter is specified `content-type` header will be set to * `application/x-www-form-urlencoded` unless explicitly provided. */ - form?: { [key: string]: string|number|boolean; }; + form?: { [key: string]: string|number|boolean; }|FormData; /** * Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by @@ -16893,7 +16895,7 @@ export interface APIRequestContext { * as this request body. If this parameter is specified `content-type` header will be set to * `application/x-www-form-urlencoded` unless explicitly provided. */ - form?: { [key: string]: string|number|boolean; }; + form?: { [key: string]: string|number|boolean; }|FormData; /** * Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by @@ -16979,7 +16981,7 @@ export interface APIRequestContext { * as this request body. If this parameter is specified `content-type` header will be set to * `application/x-www-form-urlencoded` unless explicitly provided. */ - form?: { [key: string]: string|number|boolean; }; + form?: { [key: string]: string|number|boolean; }|FormData; /** * Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by @@ -17107,7 +17109,7 @@ export interface APIRequestContext { * as this request body. If this parameter is specified `content-type` header will be set to * `application/x-www-form-urlencoded` unless explicitly provided. */ - form?: { [key: string]: string|number|boolean; }; + form?: { [key: string]: string|number|boolean; }|FormData; /** * Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by @@ -17193,7 +17195,7 @@ export interface APIRequestContext { * as this request body. If this parameter is specified `content-type` header will be set to * `application/x-www-form-urlencoded` unless explicitly provided. */ - form?: { [key: string]: string|number|boolean; }; + form?: { [key: string]: string|number|boolean; }|FormData; /** * Allows to set HTTP headers. These headers will apply to the fetched request as well as any redirects initiated by @@ -20704,8 +20706,6 @@ export interface BrowserContextOptions { * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * with an exact match to the request origin that the certificate is valid for. * - * **NOTE** Using Client Certificates in combination with Proxy Servers is not supported. - * * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * work by replacing `localhost` with `local.playwright`. */ diff --git a/packages/playwright/src/isomorphic/testServerInterface.ts b/packages/playwright/src/isomorphic/testServerInterface.ts index 28f82688dc..22cb9e35ef 100644 --- a/packages/playwright/src/isomorphic/testServerInterface.ts +++ b/packages/playwright/src/isomorphic/testServerInterface.ts @@ -28,6 +28,7 @@ export interface TestServerInterface { closeOnDisconnect?: boolean, interceptStdio?: boolean, watchTestDirs?: boolean, + populateDependenciesOnList?: boolean, }): Promise; ping(params: {}): Promise; diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index ea796bfc72..3a254ae7bf 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -16,6 +16,7 @@ import { captureRawStack, + createGuid, isString, pollAgainstDeadline } from 'playwright-core/lib/utils'; import type { ExpectZone } from 'playwright-core/lib/utils'; @@ -104,33 +105,43 @@ export const printReceivedStringContainExpectedResult = ( type ExpectMessage = string | { message?: string }; -function createMatchers(actual: unknown, info: ExpectMetaInfo): any { - return new Proxy(expectLibrary(actual), new ExpectMetaInfoProxyHandler(info)); +function createMatchers(actual: unknown, info: ExpectMetaInfo, prefix: string[]): any { + return new Proxy(expectLibrary(actual), new ExpectMetaInfoProxyHandler(info, prefix)); } -function createExpect(info: ExpectMetaInfo) { +const getCustomMatchersSymbol = Symbol('get custom matchers'); + +function qualifiedMatcherName(qualifier: string[], matcherName: string) { + return qualifier.join(':') + '$' + matcherName; +} + +function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Record) { const expectInstance: Expect<{}> = new Proxy(expectLibrary, { apply: function(target: any, thisArg: any, argumentsList: [unknown, ExpectMessage?]) { const [actual, messageOrOptions] = argumentsList; const message = isString(messageOrOptions) ? messageOrOptions : messageOrOptions?.message || info.message; const newInfo = { ...info, message }; - if (newInfo.isPoll) { + if (newInfo.poll) { if (typeof actual !== 'function') throw new Error('`expect.poll()` accepts only function as a first argument'); - newInfo.generator = actual as any; + newInfo.poll.generator = actual as any; } - return createMatchers(actual, newInfo); + return createMatchers(actual, newInfo, prefix); }, - get: function(target: any, property: string) { + get: function(target: any, property: string | typeof getCustomMatchersSymbol) { if (property === 'configure') return configure; if (property === 'extend') { return (matchers: any) => { + const qualifier = [...prefix, createGuid()]; + const wrappedMatchers: any = {}; + const extendedMatchers: any = { ...customMatchers }; for (const [name, matcher] of Object.entries(matchers)) { - wrappedMatchers[name] = function(...args: any[]) { + const key = qualifiedMatcherName(qualifier, name); + wrappedMatchers[key] = function(...args: any[]) { const { isNot, promise, utils } = this; const newThis: ExpectMatcherState = { isNot, @@ -141,9 +152,12 @@ function createExpect(info: ExpectMetaInfo) { (newThis as any).equals = throwUnsupportedExpectMatcherError; return (matcher as any).call(newThis, ...args); }; + Object.defineProperty(wrappedMatchers[key], 'name', { value: name }); + extendedMatchers[name] = wrappedMatchers[key]; } expectLibrary.extend(wrappedMatchers); - return expectInstance; + + return createExpect(info, qualifier, extendedMatchers); }; } @@ -153,6 +167,9 @@ function createExpect(info: ExpectMetaInfo) { }; } + if (property === getCustomMatchersSymbol) + return customMatchers; + if (property === 'poll') { return (actual: unknown, messageOrOptions?: ExpectMessage & { timeout?: number, intervals?: number[] }) => { const poll = isString(messageOrOptions) ? {} : messageOrOptions || {}; @@ -172,13 +189,13 @@ function createExpect(info: ExpectMetaInfo) { if ('soft' in configuration) newInfo.isSoft = configuration.soft; if ('_poll' in configuration) { - newInfo.isPoll = !!configuration._poll; + newInfo.poll = configuration._poll ? { ...info.poll, generator: () => {} } : undefined; if (typeof configuration._poll === 'object') { - newInfo.pollTimeout = configuration._poll.timeout; - newInfo.pollIntervals = configuration._poll.intervals; + newInfo.poll!.timeout = configuration._poll.timeout ?? newInfo.poll!.timeout; + newInfo.poll!.intervals = configuration._poll.intervals ?? newInfo.poll!.intervals; } } - return createExpect(newInfo); + return createExpect(newInfo, prefix, customMatchers); }; return expectInstance; @@ -232,24 +249,38 @@ type ExpectMetaInfo = { message?: string; isNot?: boolean; isSoft?: boolean; - isPoll?: boolean; + poll?: { + timeout?: number; + intervals?: number[]; + generator: Generator; + }; timeout?: number; - pollTimeout?: number; - pollIntervals?: number[]; - generator?: Generator; }; class ExpectMetaInfoProxyHandler implements ProxyHandler { private _info: ExpectMetaInfo; + private _prefix: string[]; - constructor(info: ExpectMetaInfo) { + constructor(info: ExpectMetaInfo, prefix: string[]) { this._info = { ...info }; + this._prefix = prefix; } get(target: Object, matcherName: string | symbol, receiver: any): any { let matcher = Reflect.get(target, matcherName, receiver); if (typeof matcherName !== 'string') return matcher; + + let resolvedMatcherName = matcherName; + for (let i = this._prefix.length; i > 0; i--) { + const qualifiedName = qualifiedMatcherName(this._prefix.slice(0, i), matcherName); + if (Reflect.has(target, qualifiedName)) { + matcher = Reflect.get(target, qualifiedName, receiver); + resolvedMatcherName = qualifiedName; + break; + } + } + if (matcher === undefined) throw new Error(`expect: Property '${matcherName}' not found.`); if (typeof matcher !== 'function') { @@ -257,10 +288,10 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { this._info.isNot = !this._info.isNot; return new Proxy(matcher, this); } - if (this._info.isPoll) { + if (this._info.poll) { if ((customAsyncMatchers as any)[matcherName] || matcherName === 'resolves' || matcherName === 'rejects') throw new Error(`\`expect.poll()\` does not support "${matcherName}" matcher.`); - matcher = (...args: any[]) => pollMatcher(matcherName, !!this._info.isNot, this._info.pollIntervals, this._info.pollTimeout ?? currentExpectTimeout(), this._info.generator!, ...args); + matcher = (...args: any[]) => pollMatcher(resolvedMatcherName, this._info, this._prefix, ...args); } return (...args: any[]) => { const testInfo = currentTestInfo(); @@ -272,7 +303,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { const customMessage = this._info.message || ''; const argsSuffix = computeArgsSuffix(matcherName, args); - const defaultTitle = `expect${this._info.isPoll ? '.poll' : ''}${this._info.isSoft ? '.soft' : ''}${this._info.isNot ? '.not' : ''}.${matcherName}${argsSuffix}`; + const defaultTitle = `expect${this._info.poll ? '.poll' : ''}${this._info.isSoft ? '.soft' : ''}${this._info.isNot ? '.not' : ''}.${matcherName}${argsSuffix}`; const title = customMessage || defaultTitle; // This looks like it is unnecessary, but it isn't - we need to filter @@ -306,7 +337,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { const callback = () => matcher.call(target, ...args); // toPass and poll matchers can contain other steps, expects and API calls, // so they behave like a retriable step. - const result = (matcherName === 'toPass' || this._info.isPoll) ? + const result = (matcherName === 'toPass' || this._info.poll) ? zones.run('stepZone', step, callback) : zones.run('expectZone', { title, stepId: step.stepId }, callback); if (result instanceof Promise) @@ -320,25 +351,32 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { } } -async function pollMatcher(matcherName: any, isNot: boolean, pollIntervals: number[] | undefined, timeout: number, generator: () => any, ...args: any[]) { +async function pollMatcher(qualifiedMatcherName: string, info: ExpectMetaInfo, prefix: string[], ...args: any[]) { const testInfo = currentTestInfo(); + const poll = info.poll!; + const timeout = poll.timeout ?? currentExpectTimeout(); const { deadline, timeoutMessage } = testInfo ? testInfo._deadlineForMatcher(timeout) : TestInfoImpl._defaultDeadlineForMatcher(timeout); const result = await pollAgainstDeadline(async () => { if (testInfo && currentTestInfo() !== testInfo) return { continuePolling: false, result: undefined }; - const value = await generator(); - let expectInstance = expectLibrary(value) as any; - if (isNot) - expectInstance = expectInstance.not; + const innerInfo: ExpectMetaInfo = { + ...info, + isSoft: false, // soft is outside of poll, not inside + poll: undefined, + }; + const value = await poll.generator(); try { - expectInstance[matcherName].call(expectInstance, ...args); + let matchers = createMatchers(value, innerInfo, prefix); + if (info.isNot) + matchers = matchers.not; + matchers[qualifiedMatcherName](...args); return { continuePolling: false, result: undefined }; } catch (error) { return { continuePolling: true, result: error }; } - }, deadline, pollIntervals ?? [100, 250, 500, 1000]); + }, deadline, poll.intervals ?? [100, 250, 500, 1000]); if (result.timedOut) { const message = result.result ? [ @@ -375,8 +413,15 @@ function computeArgsSuffix(matcherName: string, args: any[]) { return value ? `(${value})` : ''; } -export const expect: Expect<{}> = createExpect({}).extend(customMatchers); +export const expect: Expect<{}> = createExpect({}, [], {}).extend(customMatchers); export function mergeExpects(...expects: any[]) { - return expect; + let merged = expect; + for (const e of expects) { + const internals = e[getCustomMatchersSymbol]; + if (!internals) // non-playwright expects mutate the global expect, so we don't need to do anything special + continue; + merged = merged.extend(internals); + } + return merged; } diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index 1bf2fb42b2..775ef27065 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -69,7 +69,7 @@ function addListFilesCommand(program: Command) { } function addClearCacheCommand(program: Command) { - const command = program.command('clear-cache', { hidden: true }); + const command = program.command('clear-cache'); command.description('clears build and test caches'); command.option('-c, --config ', `Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}"`); command.action(async opts => { @@ -133,7 +133,7 @@ Examples: } function addMergeReportsCommand(program: Command) { - const command = program.command('merge-reports [dir]', { hidden: true }); + const command = program.command('merge-reports [dir]'); command.description('merge multiple blob reports (for sharded tests) into a single report'); command.action(async (dir, options) => { try { diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index f34a7314f1..5d67385dc5 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -79,6 +79,7 @@ export class TestServerDispatcher implements TestServerInterface { private _serializer = require.resolve('./uiModeReporter'); private _watchTestDirs = false; private _closeOnDisconnect = false; + private _populateDependenciesOnList = false; constructor(configLocation: ConfigLocation) { this._configLocation = configLocation; @@ -113,6 +114,7 @@ export class TestServerDispatcher implements TestServerInterface { this._closeOnDisconnect = !!params.closeOnDisconnect; await this._setInterceptStdio(!!params.interceptStdio); this._watchTestDirs = !!params.watchTestDirs; + this._populateDependenciesOnList = !!params.populateDependenciesOnList; } async ping() {} @@ -252,7 +254,7 @@ export class TestServerDispatcher implements TestServerInterface { config.cliListOnly = true; const status = await runTasks(new TestRun(config, reporter), [ - createLoadTask('out-of-process', { failOnLoadErrors: false, filterOnly: false }), + createLoadTask('out-of-process', { failOnLoadErrors: false, filterOnly: false, populateDependencies: this._populateDependenciesOnList }), createReportBeginTask(), ]); return { config, report, reporter, status }; diff --git a/packages/playwright/src/runner/watchMode.ts b/packages/playwright/src/runner/watchMode.ts index 6a4619359c..e086ac21e2 100644 --- a/packages/playwright/src/runner/watchMode.ts +++ b/packages/playwright/src/runner/watchMode.ts @@ -73,6 +73,7 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp return 'restarted'; const options: WatchModeOptions = { ...initialOptions }; + let bufferMode = false; const testServerDispatcher = new TestServerDispatcher(configLocation); const transport = new InMemoryTransport( @@ -94,6 +95,7 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp const teleSuiteUpdater = new TeleSuiteUpdater({ pathSeparator: path.sep, onUpdate() { } }); + const dirtyTestFiles = new Set(); const dirtyTestIds = new Set(); let onDirtyTests = new ManualPromise<'changed'>(); @@ -110,19 +112,22 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp teleSuiteUpdater.processListReport(report); for (const test of teleSuiteUpdater.rootSuite!.allTests()) { - if (changedFiles.has(test.location.file)) + if (changedFiles.has(test.location.file)) { + dirtyTestFiles.add(test.location.file); dirtyTestIds.add(test.id); + } } - changedFiles.clear(); - if (dirtyTestIds.size > 0) + if (dirtyTestIds.size > 0) { onDirtyTests.resolve('changed'); + onDirtyTests = new ManualPromise(); + } }); }); testServerConnection.onReport(report => teleSuiteUpdater.processTestReportEvent(report)); - await testServerConnection.initialize({ interceptStdio: false, watchTestDirs: true }); + await testServerConnection.initialize({ interceptStdio: false, watchTestDirs: true, populateDependenciesOnList: true }); await testServerConnection.runGlobalSetup({}); const { report } = await testServerConnection.listTests({}); @@ -133,20 +138,28 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp let lastRun: { type: 'changed' | 'regular' | 'failed', failedTestIds?: string[], dirtyTestIds?: string[] } = { type: 'regular' }; let result: FullResult['status'] = 'passed'; - // Enter the watch loop. - await runTests(options, testServerConnection); - while (true) { - printPrompt(); + if (bufferMode) + printBufferPrompt(dirtyTestFiles, teleSuiteUpdater.config!.rootDir); + else + printPrompt(); + const command = await Promise.race([ onDirtyTests, readCommand(), ]); - if (command === 'changed') { - onDirtyTests = new ManualPromise(); + if (bufferMode && command === 'changed') + continue; + + const shouldRunChangedFiles = bufferMode ? command === 'run' : command === 'changed'; + if (shouldRunChangedFiles) { + if (dirtyTestIds.size === 0) + continue; + const testIds = [...dirtyTestIds]; dirtyTestIds.clear(); + dirtyTestFiles.clear(); await runTests(options, testServerConnection, { testIds, title: 'files changed' }); lastRun = { type: 'changed', dirtyTestIds: testIds }; continue; @@ -232,6 +245,11 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp continue; } + if (command === 'toggle-buffer-mode') { + bufferMode = !bufferMode; + continue; + } + if (command === 'exit') break; @@ -321,6 +339,7 @@ Change settings ${colors.bold('p')} ${colors.dim('set file filter')} ${colors.bold('t')} ${colors.dim('set title filter')} ${colors.bold('s')} ${colors.dim('toggle show & reuse the browser')} + ${colors.bold('b')} ${colors.dim('toggle buffer mode')} `); return; } @@ -333,13 +352,14 @@ Change settings case 't': return 'grep'; case 'f': return 'failed'; case 's': return 'toggle-show-browser'; + case 'b': return 'toggle-buffer-mode'; } }); } let showBrowserServer: PlaywrightServer | undefined; let connectWsEndpoint: string | undefined = undefined; -let seq = 0; +let seq = 1; function printConfiguration(options: WatchModeOptions, title?: string) { const packageManagerCommand = getPackageManagerExecCommand(); @@ -353,9 +373,7 @@ function printConfiguration(options: WatchModeOptions, title?: string) { tokens.push(...options.files.map(a => colors.bold(a))); if (title) tokens.push(colors.dim(`(${title})`)); - if (seq) - tokens.push(colors.dim(`#${seq}`)); - ++seq; + tokens.push(colors.dim(`#${seq++}`)); const lines: string[] = []; const sep = separator(); lines.push('\x1Bc' + sep); @@ -364,6 +382,22 @@ function printConfiguration(options: WatchModeOptions, title?: string) { process.stdout.write(lines.join('\n')); } +function printBufferPrompt(dirtyTestFiles: Set, rootDir: string) { + const sep = separator(); + process.stdout.write('\x1Bc'); + process.stdout.write(`${sep}\n`); + + if (dirtyTestFiles.size === 0) { + process.stdout.write(`${colors.dim('Waiting for file changes. Press')} ${colors.bold('q')} ${colors.dim('to quit or')} ${colors.bold('h')} ${colors.dim('for more options.')}\n\n`); + return; + } + + process.stdout.write(`${colors.dim(`${dirtyTestFiles.size} test ${dirtyTestFiles.size === 1 ? 'file' : 'files'} changed:`)}\n\n`); + for (const file of dirtyTestFiles) + process.stdout.write(` · ${path.relative(rootDir, file)}\n`); + process.stdout.write(`\n${colors.dim(`Press`)} ${colors.bold('enter')} ${colors.dim('to run')}, ${colors.bold('q')} ${colors.dim('to quit or')} ${colors.bold('h')} ${colors.dim('for more options.')}\n\n`); +} + function printPrompt() { const sep = separator(); process.stdout.write(` @@ -385,4 +419,4 @@ async function toggleShowBrowser() { } } -type Command = 'run' | 'failed' | 'repeat' | 'changed' | 'project' | 'file' | 'grep' | 'exit' | 'interrupted' | 'toggle-show-browser'; +type Command = 'run' | 'failed' | 'repeat' | 'changed' | 'project' | 'file' | 'grep' | 'exit' | 'interrupted' | 'toggle-show-browser' | 'toggle-buffer-mode'; diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 73cc415a87..e17a43843c 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -5211,8 +5211,6 @@ export interface PlaywrightTestOptions { * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * with an exact match to the request origin that the certificate is valid for. * - * **NOTE** Using Client Certificates in combination with Proxy Servers is not supported. - * * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * work by replacing `localhost` with `local.playwright`. * diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index 143f1ad0e0..689f0275b1 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -1933,6 +1933,7 @@ export interface PageChannel extends PageEventTarget, EventTargetChannel { exposeBinding(params: PageExposeBindingParams, metadata?: CallMetadata): Promise; goBack(params: PageGoBackParams, metadata?: CallMetadata): Promise; goForward(params: PageGoForwardParams, metadata?: CallMetadata): Promise; + forceGarbageCollection(params?: PageForceGarbageCollectionParams, metadata?: CallMetadata): Promise; registerLocatorHandler(params: PageRegisterLocatorHandlerParams, metadata?: CallMetadata): Promise; resolveLocatorHandlerNoReply(params: PageResolveLocatorHandlerNoReplyParams, metadata?: CallMetadata): Promise; unregisterLocatorHandler(params: PageUnregisterLocatorHandlerParams, metadata?: CallMetadata): Promise; @@ -2070,6 +2071,9 @@ export type PageGoForwardOptions = { export type PageGoForwardResult = { response?: ResponseChannel, }; +export type PageForceGarbageCollectionParams = {}; +export type PageForceGarbageCollectionOptions = {}; +export type PageForceGarbageCollectionResult = void; export type PageRegisterLocatorHandlerParams = { selector: string, noWaitAfter?: boolean, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 4f064ffa08..ce206ab569 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1430,6 +1430,8 @@ Page: slowMo: true snapshot: true + forceGarbageCollection: + registerLocatorHandler: parameters: selector: string diff --git a/packages/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx index 486aeae701..31bad2b70b 100644 --- a/packages/recorder/src/recorder.tsx +++ b/packages/recorder/src/recorder.tsx @@ -27,14 +27,6 @@ import { asLocator } from '@isomorphic/locatorGenerators'; import { toggleTheme } from '@web/theme'; import { copy } from '@web/uiUtils'; -declare global { - interface Window { - playwrightSetFileIfNeeded: (file: string) => void; - playwrightSetSelector: (selector: string, focus?: boolean) => void; - dispatch(data: any): Promise; - } -} - export interface RecorderProps { sources: Source[], paused: boolean, @@ -56,14 +48,22 @@ export const Recorder: React.FC = ({ setFileId(sources[0].id); }, [fileId, sources]); - const source: Source = sources.find(s => s.id === fileId) || { - id: 'default', - isRecorded: false, - text: '', - language: 'javascript', - label: '', - highlight: [] - }; + const source = React.useMemo(() => { + if (fileId) { + const source = sources.find(s => s.id === fileId); + if (source) + return source; + } + const source: Source = { + id: 'default', + isRecorded: false, + text: '', + language: 'javascript', + label: '', + highlight: [] + }; + return source; + }, [sources, fileId]); const [locator, setLocator] = React.useState(''); window.playwrightSetSelector = (selector: string, focus?: boolean) => { @@ -73,13 +73,7 @@ export const Recorder: React.FC = ({ setLocator(asLocator(language, selector)); }; - window.playwrightSetFileIfNeeded = (value: string) => { - const newSource = sources.find(s => s.id === value); - // Do not forcefully switch between two recorded sources, because - // user did explicitly choose one. - if (newSource && !newSource.isRecorded || !source.isRecorded) - setFileId(value); - }; + window.playwrightSetFile = setFileId; const messagesEndRef = React.useRef(null); React.useLayoutEffect(() => { diff --git a/packages/recorder/src/recorderTypes.ts b/packages/recorder/src/recorderTypes.ts index c56984ad6d..a5791e2306 100644 --- a/packages/recorder/src/recorderTypes.ts +++ b/packages/recorder/src/recorderTypes.ts @@ -96,7 +96,7 @@ declare global { playwrightSetSources: (sources: Source[]) => void; playwrightSetOverlayVisible: (visible: boolean) => void; playwrightUpdateLogs: (callLogs: CallLog[]) => void; - playwrightSetFileIfNeeded: (file: string) => void; + playwrightSetFile: (file: string) => void; playwrightSetSelector: (selector: string, focus?: boolean) => void; playwrightSourcesEchoForTest: Source[]; dispatch(data: any): Promise; diff --git a/packages/trace-viewer/recorder.html b/packages/trace-viewer/recorder.html new file mode 100644 index 0000000000..c33d6586e5 --- /dev/null +++ b/packages/trace-viewer/recorder.html @@ -0,0 +1,28 @@ + + + + + + + + Playwright Recorder + + +
+ + + diff --git a/packages/trace-viewer/src/recorder.tsx b/packages/trace-viewer/src/recorder.tsx new file mode 100644 index 0000000000..4de705d4fc --- /dev/null +++ b/packages/trace-viewer/src/recorder.tsx @@ -0,0 +1,41 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '@web/common.css'; +import { applyTheme } from '@web/theme'; +import '@web/third_party/vscode/codicon.css'; +import * as ReactDOM from 'react-dom/client'; +import { RecorderView } from './ui/recorderView'; + +(async () => { + applyTheme(); + + if (window.location.protocol !== 'file:') { + if (!navigator.serviceWorker) + throw new Error(`Service workers are not supported.\nMake sure to serve the Recorder (${window.location}) via HTTPS or localhost.`); + navigator.serviceWorker.register('sw.bundle.js'); + if (!navigator.serviceWorker.controller) { + await new Promise(f => { + navigator.serviceWorker.oncontrollerchange = () => f(); + }); + } + + // Keep SW running. + setInterval(function() { fetch('ping'); }, 10000); + } + + ReactDOM.createRoot(document.querySelector('#root')!).render(); +})(); diff --git a/packages/trace-viewer/src/ui/errorsTab.tsx b/packages/trace-viewer/src/ui/errorsTab.tsx index 71b6059c29..acf5bf838e 100644 --- a/packages/trace-viewer/src/ui/errorsTab.tsx +++ b/packages/trace-viewer/src/ui/errorsTab.tsx @@ -22,7 +22,7 @@ import { renderAction } from './actionList'; import type { Language } from '@isomorphic/locatorGenerators'; import type { StackFrame } from '@protocol/channels'; -type ErrorDescription = { +export type ErrorDescription = { action?: modelUtil.ActionTraceEventInContext; stack?: StackFrame[]; }; diff --git a/packages/trace-viewer/src/ui/modelUtil.ts b/packages/trace-viewer/src/ui/modelUtil.ts index 0c22d954f4..a544d4dc3f 100644 --- a/packages/trace-viewer/src/ui/modelUtil.ts +++ b/packages/trace-viewer/src/ui/modelUtil.ts @@ -342,10 +342,6 @@ export function buildActionTree(actions: ActionTraceEventInContext[]): { rootIte return { rootItem, itemMap }; } -export function idForAction(action: ActionTraceEvent) { - return `${action.pageId || 'none'}:${action.callId}`; -} - export function context(action: ActionTraceEvent | trace.EventTraceEvent | ResourceSnapshot): ContextEntry { return (action as any)[contextSymbol]; } diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.css b/packages/trace-viewer/src/ui/networkResourceDetails.css index a2d3f5a86f..59989b89dd 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.css +++ b/packages/trace-viewer/src/ui/networkResourceDetails.css @@ -16,6 +16,7 @@ .network-request-details-tab { width: 100%; + height: 100%; user-select: text; line-height: 24px; margin-left: 10px; diff --git a/packages/trace-viewer/src/ui/recorderView.css b/packages/trace-viewer/src/ui/recorderView.css new file mode 100644 index 0000000000..ad03e78e7d --- /dev/null +++ b/packages/trace-viewer/src/ui/recorderView.css @@ -0,0 +1,15 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ diff --git a/packages/trace-viewer/src/ui/recorderView.tsx b/packages/trace-viewer/src/ui/recorderView.tsx new file mode 100644 index 0000000000..940fd146a9 --- /dev/null +++ b/packages/trace-viewer/src/ui/recorderView.tsx @@ -0,0 +1,168 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import * as React from 'react'; +import './recorderView.css'; +import { MultiTraceModel } from './modelUtil'; +import type { SourceLocation } from './modelUtil'; +import { Workbench } from './workbench'; +import type { Mode, Source } from '@recorder/recorderTypes'; +import type { ContextEntry } from '../entries'; + +const searchParams = new URLSearchParams(window.location.search); +const guid = searchParams.get('ws'); +const trace = searchParams.get('trace') + '.json'; + +export const RecorderView: React.FunctionComponent = () => { + const [connection, setConnection] = React.useState(null); + const [sources, setSources] = React.useState([]); + React.useEffect(() => { + const wsURL = new URL(`../${guid}`, window.location.toString()); + wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:'); + const webSocket = new WebSocket(wsURL.toString()); + setConnection(new Connection(webSocket, { setSources })); + return () => { + webSocket.close(); + }; + }, []); + + React.useEffect(() => { + if (!connection) + return; + connection.setMode('recording'); + }, [connection]); + + return
+ +
; +}; + +export const TraceView: React.FC<{ + traceLocation: string, + sources: Source[], +}> = ({ traceLocation, sources }) => { + const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>(); + const [counter, setCounter] = React.useState(0); + const pollTimer = React.useRef(null); + + React.useEffect(() => { + if (pollTimer.current) + clearTimeout(pollTimer.current); + + // Start polling running test. + pollTimer.current = setTimeout(async () => { + try { + const model = await loadSingleTraceFile(traceLocation); + setModel({ model, isLive: true }); + } catch { + setModel(undefined); + } finally { + setCounter(counter + 1); + } + }, 500); + return () => { + if (pollTimer.current) + clearTimeout(pollTimer.current); + }; + }, [counter, traceLocation]); + + const fallbackLocation = React.useMemo(() => { + if (!sources.length) + return undefined; + const fallbackLocation: SourceLocation = { + file: '', + line: 0, + column: 0, + source: { + errors: [], + content: sources[0].text + } + }; + return fallbackLocation; + }, [sources]); + + return ; +}; + +async function loadSingleTraceFile(url: string): Promise { + const params = new URLSearchParams(); + params.set('trace', url); + const response = await fetch(`contexts?${params.toString()}`); + const contextEntries = await response.json() as ContextEntry[]; + return new MultiTraceModel(contextEntries); +} + +class Connection { + private _lastId = 0; + private _webSocket: WebSocket; + private _callbacks = new Map void, reject: (arg: Error) => void }>(); + private _options: { setSources: (sources: Source[]) => void; }; + + constructor(webSocket: WebSocket, options: { setSources: (sources: Source[]) => void }) { + this._webSocket = webSocket; + this._callbacks = new Map(); + this._options = options; + + this._webSocket.addEventListener('message', event => { + const message = JSON.parse(event.data); + const { id, result, error, method, params } = message; + if (id) { + const callback = this._callbacks.get(id); + if (!callback) + return; + this._callbacks.delete(id); + if (error) + callback.reject(new Error(error)); + else + callback.resolve(result); + } else { + this._dispatchEvent(method, params); + } + }); + } + + setMode(mode: Mode) { + this._sendMessageNoReply('setMode', { mode }); + } + + private async _sendMessage(method: string, params?: any): Promise { + const id = ++this._lastId; + const message = { id, method, params }; + this._webSocket.send(JSON.stringify(message)); + return new Promise((resolve, reject) => { + this._callbacks.set(id, { resolve, reject }); + }); + } + + private _sendMessageNoReply(method: string, params?: any) { + this._sendMessage(method, params).catch(() => { }); + } + + private _dispatchEvent(method: string, params?: any) { + if (method === 'setSources') { + const { sources } = params as { sources: Source[] }; + this._options.setSources(sources); + } + } +} diff --git a/packages/trace-viewer/src/ui/sourceTab.tsx b/packages/trace-viewer/src/ui/sourceTab.tsx index cf704a8438..ce54b34d53 100644 --- a/packages/trace-viewer/src/ui/sourceTab.tsx +++ b/packages/trace-viewer/src/ui/sourceTab.tsx @@ -55,7 +55,7 @@ export const SourceTab: React.FunctionComponent<{ let source = sources.get(file); // Fallback location can fall outside the sources model. if (!source) { - source = { errors: fallbackLocation?.source?.errors || [], content: undefined }; + source = { errors: fallbackLocation?.source?.errors || [], content: fallbackLocation?.source?.content }; sources.set(file, source); } @@ -66,7 +66,9 @@ export const SourceTab: React.FunctionComponent<{ highlight.push({ line: targetLine, type: 'running' }); // After the source update, but before the test run, don't trust the cache. - if (source.content === undefined || shouldUseFallback) { + if (fallbackLocation?.source?.content !== undefined) { + source.content = fallbackLocation.source.content; + } else if (source.content === undefined || shouldUseFallback) { const sha1 = await calculateSha1(file); try { let response = await fetch(`sha1/src@${sha1}.txt`); diff --git a/packages/trace-viewer/src/ui/uiModeTraceView.tsx b/packages/trace-viewer/src/ui/uiModeTraceView.tsx index 48fd59e3ca..c0763fee3d 100644 --- a/packages/trace-viewer/src/ui/uiModeTraceView.tsx +++ b/packages/trace-viewer/src/ui/uiModeTraceView.tsx @@ -16,14 +16,13 @@ import { artifactsFolderName } from '@testIsomorphic/folders'; import type { TreeItem } from '@testIsomorphic/testTree'; -import type { ActionTraceEvent } from '@trace/trace'; import '@web/common.css'; import '@web/third_party/vscode/codicon.css'; import type * as reporterTypes from 'playwright/types/testReporter'; import React from 'react'; import type { ContextEntry } from '../entries'; import type { SourceLocation } from './modelUtil'; -import { idForAction, MultiTraceModel } from './modelUtil'; +import { MultiTraceModel } from './modelUtil'; import { Workbench } from './workbench'; export const TraceView: React.FC<{ @@ -42,12 +41,6 @@ export const TraceView: React.FC<{ return { outputDir }; }, [item]); - // Preserve user selection upon live-reloading trace model by persisting the action id. - // This avoids auto-selection of the last action every time we reload the model. - const [selectedActionId, setSelectedActionId] = React.useState(); - const onSelectionChanged = React.useCallback((action: ActionTraceEvent) => setSelectedActionId(idForAction(action)), [setSelectedActionId]); - const initialSelection = selectedActionId ? model?.model.actions.find(a => idForAction(a) === selectedActionId) : undefined; - React.useEffect(() => { if (pollTimer.current) clearTimeout(pollTimer.current); @@ -98,8 +91,6 @@ export const TraceView: React.FC<{ model={model?.model} showSourcesFirst={true} rootDir={rootDir} - initialSelection={initialSelection} - onSelectionChanged={onSelectionChanged} fallbackLocation={item.testFile} isLive={model?.isLive} status={item.treeItem?.status} diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 2c4b61fdad..a97716bdc4 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -72,6 +72,7 @@ export const UIModeView: React.FC<{}> = ({ }) => { const [filterText, setFilterText] = React.useState(''); const [isShowingOutput, setIsShowingOutput] = React.useState(false); + const [outputContainsError, setOutputContainsError] = React.useState(false); const [statusFilters, setStatusFilters] = React.useState>(new Map([ ['passed', false], ['failed', false], @@ -134,6 +135,9 @@ export const UIModeView: React.FC<{}> = ({ } else { xtermDataSource.write(params.text!); } + + if (params.type === 'stderr') + setOutputContainsError(true); }), testServerConnection.onClose(() => setIsDisconnected(true)) ]; @@ -168,6 +172,7 @@ export const UIModeView: React.FC<{}> = ({ }, onError: error => { xtermDataSource.write((error.stack || error.value || '') + '\n'); + setOutputContainsError(true); }, pathSeparator: queryParams.pathSeparator, }); @@ -426,7 +431,7 @@ export const UIModeView: React.FC<{}> = ({
Output
- xtermDataSource.clear()}> + { xtermDataSource.clear(); setOutputContainsError(false); }}>
setIsShowingOutput(false)}>
@@ -447,7 +452,10 @@ export const UIModeView: React.FC<{}> = ({ Playwright logo
Playwright
reloadTests()} disabled={isRunningTest || isLoading}> - { setIsShowingOutput(!isShowingOutput); }} /> +
+ { setIsShowingOutput(!isShowingOutput); }} /> + {outputContainsError &&
} +
{!hasBrowsers && } void, isLive?: boolean, status?: UITestStatus, annotations?: { type: string; description?: string; }[]; @@ -59,9 +57,10 @@ export const Workbench: React.FunctionComponent<{ onOpenExternally?: (location: modelUtil.SourceLocation) => void, revealSource?: boolean, showSettings?: boolean, -}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => { - const [selectedAction, setSelectedActionImpl] = React.useState(undefined); - const [revealedStack, setRevealedStack] = React.useState(undefined); +}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => { + const [selectedCallId, setSelectedCallId] = React.useState(undefined); + const [revealedError, setRevealedError] = React.useState(undefined); + const [highlightedAction, setHighlightedAction] = React.useState(); const [highlightedEntry, setHighlightedEntry] = React.useState(); const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState(); @@ -69,38 +68,39 @@ export const Workbench: React.FunctionComponent<{ const [selectedPropertiesTab, setSelectedPropertiesTab] = useSetting('propertiesTab', showSourcesFirst ? 'source' : 'call'); const [isInspecting, setIsInspectingState] = React.useState(false); const [highlightedLocator, setHighlightedLocator] = React.useState(''); - const activeAction = model ? highlightedAction || selectedAction : undefined; const [selectedTime, setSelectedTime] = React.useState(); const [sidebarLocation, setSidebarLocation] = useSetting<'bottom' | 'right'>('propertiesSidebarLocation', 'bottom'); const [showRouteActions, setShowRouteActions] = useSetting('show-route-actions', true); const [showScreenshot, setShowScreenshot] = useSetting('screenshot-instead-of-snapshot', false); - const filteredActions = React.useMemo(() => { return (model?.actions || []).filter(action => showRouteActions || !isRouteAction(action)); }, [model, showRouteActions]); const setSelectedAction = React.useCallback((action: modelUtil.ActionTraceEventInContext | undefined) => { - setSelectedActionImpl(action); - setRevealedStack(action?.stack); - }, [setSelectedActionImpl, setRevealedStack]); + setSelectedCallId(action?.callId); + setRevealedError(undefined); + }, []); const sources = React.useMemo(() => model?.sources || new Map(), [model]); React.useEffect(() => { setSelectedTime(undefined); - setRevealedStack(undefined); + setRevealedError(undefined); }, [model]); - React.useEffect(() => { - if (selectedAction && model?.actions.includes(selectedAction)) - return; + const selectedAction = React.useMemo(() => { + if (selectedCallId) { + const action = model?.actions.find(a => a.callId === selectedCallId); + if (action) + return action; + } + const failedAction = model?.failedAction(); - if (initialSelection && model?.actions.includes(initialSelection)) { - setSelectedAction(initialSelection); - } else if (failedAction) { - setSelectedAction(failedAction); - } else if (model?.actions.length) { + if (failedAction) + return failedAction; + + if (model?.actions.length) { // Select the last non-after hooks item. let index = model.actions.length - 1; for (let i = 0; i < model.actions.length; ++i) { @@ -109,15 +109,24 @@ export const Workbench: React.FunctionComponent<{ break; } } - setSelectedAction(model.actions[index]); + return model.actions[index]; } - }, [model, selectedAction, setSelectedAction, initialSelection]); + }, [model, selectedCallId]); + + const revealedStack = React.useMemo(() => { + if (revealedError) + return revealedError.stack; + return selectedAction?.stack; + }, [selectedAction, revealedError]); + + const activeAction = React.useMemo(() => { + return highlightedAction || selectedAction; + }, [selectedAction, highlightedAction]); const onActionSelected = React.useCallback((action: modelUtil.ActionTraceEventInContext) => { setSelectedAction(action); setHighlightedAction(undefined); - onSelectionChanged?.(action); - }, [setSelectedAction, onSelectionChanged, setHighlightedAction]); + }, [setSelectedAction, setHighlightedAction]); const selectPropertiesTab = React.useCallback((tab: string) => { setSelectedPropertiesTab(tab); @@ -177,7 +186,7 @@ export const Workbench: React.FunctionComponent<{ if (error.action) setSelectedAction(error.action); else - setRevealedStack(error.stack); + setRevealedError(error); selectPropertiesTab('source'); }} /> }; diff --git a/packages/trace-viewer/vite.config.ts b/packages/trace-viewer/vite.config.ts index 13310ca0f7..0e2e9cb642 100644 --- a/packages/trace-viewer/vite.config.ts +++ b/packages/trace-viewer/vite.config.ts @@ -45,6 +45,7 @@ export default defineConfig({ index: path.resolve(__dirname, 'index.html'), uiMode: path.resolve(__dirname, 'uiMode.html'), embedded: path.resolve(__dirname, 'embedded.html'), + recorder: path.resolve(__dirname, 'recorder.html'), snapshot: path.resolve(__dirname, 'snapshot.html'), }, output: { diff --git a/tests/config/browserTest.ts b/tests/config/browserTest.ts index 196f3604db..44aaca9d26 100644 --- a/tests/config/browserTest.ts +++ b/tests/config/browserTest.ts @@ -62,21 +62,21 @@ const test = baseTest.extend await run(playwright[browserName]); }, { scope: 'worker' }], - allowsThirdParty: [async ({ browserName, browserMajorVersion, channel }, run) => { + allowsThirdParty: [async ({ browserName }, run) => { if (browserName === 'firefox') await run(true); else await run(false); }, { scope: 'worker' }], - defaultSameSiteCookieValue: [async ({ browserName, browserMajorVersion, channel, isLinux }, run) => { - if (browserName === 'chromium') + defaultSameSiteCookieValue: [async ({ browserName, isLinux }, run) => { + if (browserName === 'chromium' || browserName as any === '_bidiChromium') await run('Lax'); else if (browserName === 'webkit' && isLinux) await run('Lax'); else if (browserName === 'webkit' && !isLinux) await run('None'); - else if (browserName === 'firefox') + else if (browserName === 'firefox' || browserName as any === '_bidiFirefox') await run('None'); else throw new Error('unknown browser - ' + browserName); diff --git a/tests/config/proxy.ts b/tests/config/proxy.ts index 08546c9283..dc2d51b3ec 100644 --- a/tests/config/proxy.ts +++ b/tests/config/proxy.ts @@ -15,9 +15,11 @@ */ import type { IncomingMessage } from 'http'; -import type { Socket } from 'net'; import type { ProxyServer } from '../third_party/proxy'; import { createProxy } from '../third_party/proxy'; +import net from 'net'; +import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../../packages/playwright-core/src/common/socksProxy'; +import { SocksProxy } from '../../packages/playwright-core/lib/common/socksProxy'; export class TestProxy { readonly PORT: number; @@ -27,7 +29,7 @@ export class TestProxy { requestUrls: string[] = []; private readonly _server: ProxyServer; - private readonly _sockets = new Set(); + private readonly _sockets = new Set(); private _handlers: { event: string, handler: (...args: any[]) => void }[] = []; static async create(port: number): Promise { @@ -90,7 +92,7 @@ export class TestProxy { this._server.prependListener(event, handler); } - private _onSocket(socket: Socket) { + private _onSocket(socket: net.Socket) { this._sockets.add(socket); // ECONNRESET and HPE_INVALID_EOF_STATE are legit errors given // that tab closing aborts outgoing connections to the server. @@ -100,5 +102,46 @@ export class TestProxy { }); socket.once('close', () => this._sockets.delete(socket)); } - +} + +export async function setupSocksForwardingServer({ + port, forwardPort, allowedTargetPort +}: { + port: number, forwardPort: number, allowedTargetPort: number +}) { + const connectHosts = []; + const connections = new Map(); + const socksProxy = new SocksProxy(); + socksProxy.setPattern('*'); + socksProxy.addListener(SocksProxy.Events.SocksRequested, async (payload: SocksSocketRequestedPayload) => { + if (!['127.0.0.1', 'fake-localhost-127-0-0-1.nip.io', 'localhost'].includes(payload.host) || payload.port !== allowedTargetPort) { + socksProxy.sendSocketError({ uid: payload.uid, error: 'ECONNREFUSED' }); + return; + } + const target = new net.Socket(); + target.on('error', error => socksProxy.sendSocketError({ uid: payload.uid, error: error.toString() })); + target.on('end', () => socksProxy.sendSocketEnd({ uid: payload.uid })); + target.on('data', data => socksProxy.sendSocketData({ uid: payload.uid, data })); + target.setKeepAlive(false); + target.connect(forwardPort, '127.0.0.1'); + target.on('connect', () => { + connections.set(payload.uid, target); + if (!connectHosts.includes(`${payload.host}:${payload.port}`)) + connectHosts.push(`${payload.host}:${payload.port}`); + socksProxy.socketConnected({ uid: payload.uid, host: target.localAddress, port: target.localPort }); + }); + }); + socksProxy.addListener(SocksProxy.Events.SocksData, async (payload: SocksSocketDataPayload) => { + connections.get(payload.uid)?.write(payload.data); + }); + socksProxy.addListener(SocksProxy.Events.SocksClosed, (payload: SocksSocketClosedPayload) => { + connections.get(payload.uid)?.destroy(); + connections.delete(payload.uid); + }); + await socksProxy.listen(port, 'localhost'); + return { + closeProxyServer: () => socksProxy.close(), + proxyServerAddr: `socks5://localhost:${port}`, + connectHosts, + }; } diff --git a/tests/library/browsercontext-fetch.spec.ts b/tests/library/browsercontext-fetch.spec.ts index 51a98dec4a..d10b182cf0 100644 --- a/tests/library/browsercontext-fetch.spec.ts +++ b/tests/library/browsercontext-fetch.spec.ts @@ -960,6 +960,22 @@ it('should support application/x-www-form-urlencoded', async function({ context, expect(params.get('file')).toBe('f.js'); }); +it('should support application/x-www-form-urlencoded with param lists', async function({ context, page, server }) { + const form = new FormData(); + form.append('foo', '1'); + form.append('foo', '2'); + const [req] = await Promise.all([ + server.waitForRequest('/empty.html'), + context.request.post(server.EMPTY_PAGE, { form }) + ]); + expect(req.method).toBe('POST'); + expect(req.headers['content-type']).toBe('application/x-www-form-urlencoded'); + const body = (await req.postBody).toString('utf8'); + const params = new URLSearchParams(body); + expect(req.headers['content-length']).toBe(String(params.toString().length)); + expect(params.getAll('foo')).toEqual(['1', '2']); +}); + it('should encode to application/json by default', async function({ context, page, server }) { const data = { firstName: 'John', diff --git a/tests/library/browsercontext-proxy.spec.ts b/tests/library/browsercontext-proxy.spec.ts index 466e866e04..460c242998 100644 --- a/tests/library/browsercontext-proxy.spec.ts +++ b/tests/library/browsercontext-proxy.spec.ts @@ -25,10 +25,6 @@ it.beforeEach(({ server }) => { }); it('should work when passing the proxy only on the context level', async ({ browserName, platform, browserType, server, proxyServer }) => { - // Currently an upstream bug in the network stack of Chromium which leads that - // the wrong proxy gets used in the BrowserContext. - it.fixme(browserName === 'chromium' && platform === 'win32'); - proxyServer.forwardTo(server.PORT); let browser; try { diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index 75ca2468f7..156d80adfb 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -23,6 +23,7 @@ import type http from 'http'; import { expect, playwrightTest as base } from '../config/browserTest'; import type net from 'net'; import type { BrowserContextOptions } from 'packages/playwright-test'; +import { setupSocksForwardingServer } from '../config/proxy'; const { createHttpsServer, createHttp2Server } = require('../../packages/playwright-core/lib/utils'); type TestOptions = { @@ -76,15 +77,6 @@ const test = base.extend({ }, }); -test.use({ - launchOptions: async ({ launchOptions }, use) => { - await use({ - ...launchOptions, - proxy: { server: 'per-context' } - }); - } -}); - const kDummyFileName = __filename; const kValidationSubTests: [BrowserContextOptions, string][] = [ [{ clientCertificates: [{ origin: 'test' }] }, 'None of cert, key, passphrase or pfx is specified'], @@ -97,14 +89,6 @@ const kValidationSubTests: [BrowserContextOptions, string][] = [ passphrase: kDummyFileName, }] }, 'pfx is specified together with cert, key or passphrase'], - [{ - proxy: { server: 'http://localhost:8080' }, - clientCertificates: [{ - origin: 'test', - certPath: kDummyFileName, - keyPath: kDummyFileName, - }] - }, 'Cannot specify both proxy and clientCertificates'], ]; test.describe('fetch', () => { @@ -189,6 +173,54 @@ test.describe('fetch', () => { await request.dispose(); }); + test('pass with trusted client certificates and when a http proxy is used', async ({ playwright, startCCServer, proxyServer, asset }) => { + const serverURL = await startCCServer(); + proxyServer.forwardTo(parseInt(new URL(serverURL).port, 10), { allowConnectRequests: true }); + const request = await playwright.request.newContext({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin, + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), + }], + proxy: { server: `localhost:${proxyServer.PORT}` } + }); + expect(proxyServer.connectHosts).toEqual([]); + const response = await request.get(serverURL); + expect(proxyServer.connectHosts).toEqual([new URL(serverURL).host]); + expect(response.url()).toBe(serverURL); + expect(response.status()).toBe(200); + expect(await response.text()).toContain('Hello Alice, your certificate was issued by localhost!'); + await request.dispose(); + }); + + test('pass with trusted client certificates and when a socks proxy is used', async ({ playwright, startCCServer, asset }) => { + const serverURL = await startCCServer({ host: '127.0.0.1' }); + const serverPort = parseInt(new URL(serverURL).port, 10); + const { proxyServerAddr, closeProxyServer, connectHosts } = await setupSocksForwardingServer({ + port: test.info().workerIndex + 2048 + 2, + forwardPort: serverPort, + allowedTargetPort: serverPort, + }); + const request = await playwright.request.newContext({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin, + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), + }], + proxy: { server: proxyServerAddr } + }); + expect(connectHosts).toEqual([]); + const response = await request.get(serverURL); + expect(connectHosts).toEqual([new URL(serverURL).host]); + expect(response.url()).toBe(serverURL); + expect(response.status()).toBe(200); + expect(await response.text()).toContain('Hello Alice, your certificate was issued by localhost!'); + await request.dispose(); + await closeProxyServer(); + }); + test('should throw a http error if the pfx passphrase is incorect', async ({ playwright, startCCServer, asset, browserName }) => { const serverURL = await startCCServer(); const request = await playwright.request.newContext({ @@ -320,6 +352,50 @@ test.describe('browser', () => { await page.close(); }); + test('should pass with matching certificates and when a http proxy is used', async ({ browser, startCCServer, asset, browserName, proxyServer }) => { + const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); + proxyServer.forwardTo(parseInt(new URL(serverURL).port, 10), { allowConnectRequests: true }); + const page = await browser.newPage({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin, + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), + }], + proxy: { server: `localhost:${proxyServer.PORT}` } + }); + expect(proxyServer.connectHosts).toEqual([]); + await page.goto(serverURL); + expect([...new Set(proxyServer.connectHosts)]).toEqual([`localhost:${new URL(serverURL).port}`]); + await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!'); + await page.close(); + }); + + test('should pass with matching certificates and when a socks proxy is used', async ({ browser, startCCServer, asset, browserName }) => { + const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin', host: '127.0.0.1' }); + const serverPort = parseInt(new URL(serverURL).port, 10); + const { proxyServerAddr, closeProxyServer, connectHosts } = await setupSocksForwardingServer({ + port: test.info().workerIndex + 2048 + 2, + forwardPort: serverPort, + allowedTargetPort: serverPort, + }); + const page = await browser.newPage({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin, + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), + }], + proxy: { server: proxyServerAddr } + }); + expect(connectHosts).toEqual([]); + await page.goto(serverURL); + expect(connectHosts).toEqual([`localhost:${serverPort}`]); + await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!'); + await page.close(); + await closeProxyServer(); + }); + test('should not hang on tls errors during TLS 1.2 handshake', async ({ browser, asset, platform, browserName }) => { for (const tlsVersion of ['TLSv1.3', 'TLSv1.2'] as const) { await test.step(`TLS version: ${tlsVersion}`, async () => { @@ -555,6 +631,7 @@ test.describe('browser', () => { keyPath: asset('client-certificates/client/trusted/key.pem'), }; const page = await browser.newPage({ + ignoreHTTPSErrors: true, clientCertificates: [{ origin: new URL(serverURL).origin, ...baseOptions, diff --git a/tests/library/inspector/cli-codegen-1.spec.ts b/tests/library/inspector/cli-codegen-1.spec.ts index a58a8a38b9..c8046ef9b4 100644 --- a/tests/library/inspector/cli-codegen-1.spec.ts +++ b/tests/library/inspector/cli-codegen-1.spec.ts @@ -52,6 +52,46 @@ await page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).ClickAsync();`) expect(message.text()).toBe('click'); }); + test('should double click', async ({ page, openRecorder }) => { + const recorder = await openRecorder(); + + await recorder.setContentAndWait(``); + + const locator = await recorder.hoverOverElement('button'); + expect(locator).toBe(`getByRole('button', { name: 'Submit' })`); + + const messages: string[] = []; + page.on('console', message => { + if (message.text().includes('click')) + messages.push(message.text()); + }); + const [, sources] = await Promise.all([ + page.waitForEvent('console', msg => msg.type() !== 'error' && msg.text() === 'dblclick 2'), + recorder.waitForOutput('JavaScript', 'dblclick'), + recorder.trustedDblclick(), + ]); + + expect.soft(sources.get('JavaScript')!.text).toContain(` + await page.getByRole('button', { name: 'Submit' }).dblclick();`); + + expect.soft(sources.get('Python')!.text).toContain(` + page.get_by_role("button", name="Submit").dblclick()`); + + expect.soft(sources.get('Python Async')!.text).toContain(` + await page.get_by_role("button", name="Submit").dblclick()`); + + expect.soft(sources.get('Java')!.text).toContain(` + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Submit")).dblclick()`); + + expect.soft(sources.get('C#')!.text).toContain(` +await page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).DblClickAsync();`); + + expect(messages).toEqual([ + 'click 1', + 'click 2', + 'dblclick 2', + ]); + }); test('should ignore programmatic events', async ({ page, openRecorder }) => { const recorder = await openRecorder(); @@ -666,7 +706,7 @@ var page1 = await page.RunAndWaitForPopupAsync(async () => expect(popup.url()).toBe('about:blank'); }); - test('should assert navigation', async ({ page, openRecorder }) => { + test('should attribute navigation to click', async ({ page, openRecorder }) => { const recorder = await openRecorder(); await recorder.setContentAndWait(`link`); @@ -680,24 +720,42 @@ var page1 = await page.RunAndWaitForPopupAsync(async () => ]); expect.soft(sources.get('JavaScript')!.text).toContain(` - await page.getByText('link').click();`); + await page.goto('about:blank'); + await page.getByText('link').click(); + + // --------------------- + await context.close();`); expect.soft(sources.get('Playwright Test')!.text).toContain(` - await page.getByText('link').click();`); + await page.goto('about:blank'); + await page.getByText('link').click(); +});`); expect.soft(sources.get('Java')!.text).toContain(` - page.getByText("link").click();`); + page.navigate(\"about:blank\"); + page.getByText(\"link\").click(); + }`); expect.soft(sources.get('Python')!.text).toContain(` - page.get_by_text("link").click()`); + page.goto("about:blank") + page.get_by_text("link").click() + + # --------------------- + context.close()`); expect.soft(sources.get('Python Async')!.text).toContain(` - await page.get_by_text("link").click()`); + await page.goto("about:blank") + await page.get_by_text("link").click() + + # --------------------- + await context.close()`); expect.soft(sources.get('Pytest')!.text).toContain(` + page.goto("about:blank") page.get_by_text("link").click()`); expect.soft(sources.get('C#')!.text).toContain(` +await page.GotoAsync("about:blank"); await page.GetByText("link").ClickAsync();`); expect(page.url()).toContain('about:blank#foo'); diff --git a/tests/library/inspector/cli-codegen-3.spec.ts b/tests/library/inspector/cli-codegen-3.spec.ts index 6828576456..b2f0c39988 100644 --- a/tests/library/inspector/cli-codegen-3.spec.ts +++ b/tests/library/inspector/cli-codegen-3.spec.ts @@ -739,4 +739,21 @@ await page.GetByLabel("Coun\\"try").ClickAsync();`); expect.soft(sources1.get('Java')!.text).toContain(`assertThat(page.getByRole(AriaRole.TEXTBOX)).isVisible()`); expect.soft(sources1.get('C#')!.text).toContain(`await Expect(page.GetByRole(AriaRole.Textbox)).ToBeVisibleAsync()`); }); + + test('should keep toolbar visible even if webpage erases content in hydration', async ({ openRecorder }) => { + const recorder = await openRecorder(); + + const hydrate = () => { + setTimeout(() => { + document.documentElement.innerHTML = '

Post-Hydration Content

'; + }, 500); + }; + await recorder.setContentAndWait(` +

Pre-Hydration Content

+ + `); + + await expect(recorder.page.getByText('Post-Hydration Content')).toBeVisible(); + await expect(recorder.page.locator('x-pw-glass')).toBeVisible(); + }); }); diff --git a/tests/library/inspector/inspectorTest.ts b/tests/library/inspector/inspectorTest.ts index 6ebbc1fdd1..02acdeb6fd 100644 --- a/tests/library/inspector/inspectorTest.ts +++ b/tests/library/inspector/inspectorTest.ts @@ -191,6 +191,13 @@ class Recorder { await this.page.mouse.up(options); } + async trustedDblclick() { + await this.page.mouse.down(); + await this.page.mouse.up(); + await this.page.mouse.down({ clickCount: 2 }); + await this.page.mouse.up(); + } + async focusElement(selector: string): Promise { return this.waitForHighlight(() => this.page.focus(selector)); } diff --git a/tests/library/proxy.spec.ts b/tests/library/proxy.spec.ts index 344763bb1d..b2ddcadeba 100644 --- a/tests/library/proxy.spec.ts +++ b/tests/library/proxy.spec.ts @@ -14,10 +14,9 @@ * limitations under the License. */ +import { setupSocksForwardingServer } from '../config/proxy'; import { playwrightTest as it, expect } from '../config/browserTest'; import net from 'net'; -import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../../packages/playwright-core/src/common/socksProxy'; -import { SocksProxy } from '../../packages/playwright-core/lib/common/socksProxy'; it.skip(({ mode }) => mode.startsWith('service')); @@ -145,7 +144,6 @@ it('should authenticate', async ({ browserType, server }) => { }); it('should work with authenticate followed by redirect', async ({ browserName, browserType, server }) => { - it.fixme(browserName === 'firefox', 'https://github.com/microsoft/playwright/issues/10095'); function hasAuth(req, res) { const auth = req.headers['proxy-authorization']; if (!auth) { @@ -289,43 +287,13 @@ it('should use proxy with emulated user agent', async ({ browserType }) => { expect(requestText).toContain('MyUserAgent'); }); -async function setupSocksForwardingServer(port: number, forwardPort: number) { - const connections = new Map(); - const socksProxy = new SocksProxy(); - socksProxy.setPattern('*'); - socksProxy.addListener(SocksProxy.Events.SocksRequested, async (payload: SocksSocketRequestedPayload) => { - if (!['127.0.0.1', 'fake-localhost-127-0-0-1.nip.io'].includes(payload.host) || payload.port !== 1337) { - socksProxy.sendSocketError({ uid: payload.uid, error: 'ECONNREFUSED' }); - return; - } - const target = new net.Socket(); - target.on('error', error => socksProxy.sendSocketError({ uid: payload.uid, error: error.toString() })); - target.on('end', () => socksProxy.sendSocketEnd({ uid: payload.uid })); - target.on('data', data => socksProxy.sendSocketData({ uid: payload.uid, data })); - target.setKeepAlive(false); - target.connect(forwardPort, '127.0.0.1'); - target.on('connect', () => { - connections.set(payload.uid, target); - socksProxy.socketConnected({ uid: payload.uid, host: target.localAddress, port: target.localPort }); - }); - }); - socksProxy.addListener(SocksProxy.Events.SocksData, async (payload: SocksSocketDataPayload) => { - connections.get(payload.uid)?.write(payload.data); - }); - socksProxy.addListener(SocksProxy.Events.SocksClosed, (payload: SocksSocketClosedPayload) => { - connections.get(payload.uid)?.destroy(); - connections.delete(payload.uid); - }); - await socksProxy.listen(port, 'localhost'); - return { - closeProxyServer: () => socksProxy.close(), - proxyServerAddr: `socks5://localhost:${port}`, - }; -} -it('should use SOCKS proxy for websocket requests', async ({ browserName, platform, browserType, server }, testInfo) => { - it.fixme(browserName === 'webkit' && platform !== 'linux'); - const { proxyServerAddr, closeProxyServer } = await setupSocksForwardingServer(testInfo.workerIndex + 2048 + 2, server.PORT); +it('should use SOCKS proxy for websocket requests', async ({ browserType, server }) => { + const { proxyServerAddr, closeProxyServer } = await setupSocksForwardingServer({ + port: it.info().workerIndex + 2048 + 2, + forwardPort: server.PORT, + allowedTargetPort: 1337, + }); const browser = await browserType.launch({ proxy: { server: proxyServerAddr, diff --git a/tests/page/page-force-gc.spec.ts b/tests/page/page-force-gc.spec.ts new file mode 100644 index 0000000000..038d471eba --- /dev/null +++ b/tests/page/page-force-gc.spec.ts @@ -0,0 +1,27 @@ +/** + * Copyright 2024 Adobe Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './pageTest'; + +test('should work', async ({ page }) => { + await page.evaluate(() => { + globalThis.objectToDestroy = {}; + globalThis.weakRef = new WeakRef(globalThis.objectToDestroy); + }); + await page.evaluate(() => globalThis.objectToDestroy = null); + await page.forceGarbageCollection(); + expect(await page.evaluate(() => globalThis.weakRef.deref())).toBe(undefined); +}); diff --git a/tests/page/pageTestApi.ts b/tests/page/pageTestApi.ts index cf497e76c0..1ccfd608e9 100644 --- a/tests/page/pageTestApi.ts +++ b/tests/page/pageTestApi.ts @@ -29,7 +29,7 @@ export type PageWorkerFixtures = { screenshot: ScreenshotMode | { mode: ScreenshotMode } & Pick; trace: 'off' | 'on' | 'retain-on-failure' | 'on-first-retry' | 'retain-on-first-failure' | 'on-all-retries' | /** deprecated */ 'retry-with-trace'; video: VideoMode | { mode: VideoMode, size: ViewportSize }; - browserName: 'chromium' | 'firefox' | 'webkit'; + browserName: 'chromium' | 'firefox' | 'webkit' | '_bidiFirefox' | '_bidiChromium'; browserVersion: string; browserMajorVersion: number; electronMajorVersion: number; diff --git a/tests/playwright-test/expect-poll.spec.ts b/tests/playwright-test/expect-poll.spec.ts index 344fdccdee..208ac4ccce 100644 --- a/tests/playwright-test/expect-poll.spec.ts +++ b/tests/playwright-test/expect-poll.spec.ts @@ -262,4 +262,20 @@ test('should propagate string exception from async arrow function', { annotation }); expect(result.output).toContain('some error'); -}); \ No newline at end of file +}); + +test('should show custom message', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/32582' } +}, async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('should fail', async () => { + await expect.poll(() => 1, { message: 'custom message', timeout: 500 }).toBe(2); + }); + `, + }); + expect(result.output).toContain('Error: custom message'); + expect(result.output).toContain('Expected: 2'); + expect(result.output).toContain('Received: 1'); +}); diff --git a/tests/playwright-test/expect.spec.ts b/tests/playwright-test/expect.spec.ts index 63928e86fb..a541ad9c96 100644 --- a/tests/playwright-test/expect.spec.ts +++ b/tests/playwright-test/expect.spec.ts @@ -18,41 +18,6 @@ import path from 'path'; import { test, expect, parseTestRunnerOutput, stripAnsi } from './playwright-test-fixtures'; const { spawnAsync } = require('../../packages/playwright-core/lib/utils'); -test('should be able to call expect.extend in config', async ({ runInlineTest }) => { - const result = await runInlineTest({ - 'helper.ts': ` - import { test as base, expect } from '@playwright/test'; - expect.extend({ - toBeWithinRange(received, floor, ceiling) { - const pass = received >= floor && received <= ceiling; - if (pass) { - return { - message: () => - 'passed', - pass: true, - }; - } else { - return { - message: () => 'failed', - pass: false, - }; - } - }, - }); - export const test = base; - `, - 'expect-test.spec.ts': ` - import { test } from './helper'; - test('numeric ranges', () => { - test.expect(100).toBeWithinRange(90, 110); - test.expect(101).not.toBeWithinRange(0, 100); - }); - ` - }); - expect(result.exitCode).toBe(0); - expect(result.passed).toBe(1); -}); - test('should not expand huge arrays', async ({ runInlineTest }) => { const result = await runInlineTest({ 'expect-test.spec.ts': ` @@ -1043,8 +1008,8 @@ test('should expose timeout to custom matchers', async ({ runInlineTest, runTSC test('should throw error when using .equals()', async ({ runInlineTest }) => { const result = await runInlineTest({ 'helper.ts': ` - import { test as base, expect } from '@playwright/test'; - expect.extend({ + import { test as base, expect as baseExpect } from '@playwright/test'; + export const expect = baseExpect.extend({ toBeWithinRange(received, floor, ceiling) { this.equals(1, 2); }, @@ -1052,10 +1017,10 @@ test('should throw error when using .equals()', async ({ runInlineTest }) => { export const test = base; `, 'expect-test.spec.ts': ` - import { test } from './helper'; + import { test, expect } from './helper'; test('numeric ranges', () => { - test.expect(() => { - test.expect(100).toBeWithinRange(90, 110); + expect(() => { + expect(100).toBeWithinRange(90, 110); }).toThrowError('It looks like you are using custom expect matchers that are not compatible with Playwright. See https://aka.ms/playwright/expect-compatibility'); }); ` @@ -1063,3 +1028,44 @@ test('should throw error when using .equals()', async ({ runInlineTest }) => { expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); }); + +test('expect.extend should be immutable', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'expect-test.spec.ts': ` + import { test, expect } from '@playwright/test'; + const expectFoo = expect.extend({ + toFoo() { + console.log('%%foo'); + return { pass: true }; + } + }); + const expectFoo2 = expect.extend({ + toFoo() { + console.log('%%foo2'); + return { pass: true }; + } + }); + const expectBar = expectFoo.extend({ + toBar() { + console.log('%%bar'); + return { pass: true }; + } + }); + test('logs', () => { + expect(expectFoo).not.toBe(expectFoo2); + expect(expectFoo).not.toBe(expectBar); + + expectFoo().toFoo(); + expectFoo2().toFoo(); + expectBar().toFoo(); + expectBar().toBar(); + }); + ` + }); + expect(result.outputLines).toEqual([ + 'foo', + 'foo2', + 'foo', + 'bar', + ]); +}); \ No newline at end of file diff --git a/tests/playwright-test/test-step.spec.ts b/tests/playwright-test/test-step.spec.ts index f7538de3e9..d14bccd98b 100644 --- a/tests/playwright-test/test-step.spec.ts +++ b/tests/playwright-test/test-step.spec.ts @@ -311,7 +311,9 @@ test('should report custom expect steps', async ({ runInlineTest }) => { }; `, 'a.test.ts': ` - expect.extend({ + import { test, expect as baseExpect } from '@playwright/test'; + + const expect = baseExpect.extend({ toBeWithinRange(received, floor, ceiling) { const pass = received >= floor && received <= ceiling; if (pass) { @@ -338,7 +340,6 @@ test('should report custom expect steps', async ({ runInlineTest }) => { }, }); - import { test, expect } from '@playwright/test'; test('fail', async ({}) => { expect(15).toBeWithinRange(10, 20); await expect(1).toBeFailingAsync(22); @@ -349,8 +350,8 @@ test('should report custom expect steps', async ({ runInlineTest }) => { expect(result.exitCode).toBe(1); expect(result.output).toBe(` hook |Before Hooks -expect |expect.toBeWithinRange @ a.test.ts:31 -expect |expect.toBeFailingAsync @ a.test.ts:32 +expect |expect.toBeWithinRange @ a.test.ts:32 +expect |expect.toBeFailingAsync @ a.test.ts:33 expect |↪ error: Error: It fails! hook |After Hooks hook |Worker Cleanup @@ -986,9 +987,12 @@ expect |expect.poll.toHaveLength @ a.test.ts:14 pw:api | page.goto(about:blank) @ a.test.ts:7 test.step | inner step attempt: 0 @ a.test.ts:8 expect | expect.toBe @ a.test.ts:10 +expect | expect.toHaveLength @ a.test.ts:6 +expect | ↪ error: Error: expect(received).toHaveLength(expected) pw:api | page.goto(about:blank) @ a.test.ts:7 test.step | inner step attempt: 1 @ a.test.ts:8 expect | expect.toBe @ a.test.ts:10 +expect | expect.toHaveLength @ a.test.ts:6 hook |After Hooks fixture | fixture: page fixture | fixture: context @@ -1035,9 +1039,12 @@ expect |expect.poll.toBe @ a.test.ts:13 expect | expect.toHaveText @ a.test.ts:7 test.step | iteration 1 @ a.test.ts:9 expect | expect.toBeVisible @ a.test.ts:10 +expect | expect.toBe @ a.test.ts:6 +expect | ↪ error: Error: expect(received).toBe(expected) // Object.is equality expect | expect.toHaveText @ a.test.ts:7 test.step | iteration 2 @ a.test.ts:9 expect | expect.toBeVisible @ a.test.ts:10 +expect | expect.toBe @ a.test.ts:6 hook |After Hooks fixture | fixture: page fixture | fixture: context diff --git a/tests/playwright-test/watch.spec.ts b/tests/playwright-test/watch.spec.ts index b27ff42137..f03c0d82e0 100644 --- a/tests/playwright-test/watch.spec.ts +++ b/tests/playwright-test/watch.spec.ts @@ -174,15 +174,16 @@ test('should print dependencies in mixed CJS/ESM mode 2', async ({ runInlineTest }); }); -test('should perform initial run', async ({ runWatchTest }) => { +test('should not perform initial run', async ({ runWatchTest }) => { const testProcess = await runWatchTest({ 'a.test.ts': ` import { test, expect } from '@playwright/test'; test('passes', () => {}); `, }); - await testProcess.waitForOutput('a.test.ts:3:11 › passes'); await testProcess.waitForOutput('Waiting for file changes.'); + + expect(testProcess.output).not.toContain('a.test.ts'); }); test('should quit on Q', async ({ runWatchTest }) => { @@ -206,7 +207,6 @@ test('should run tests on Enter', async ({ runWatchTest }) => { test('passes', () => {}); `, }); - await testProcess.waitForOutput('a.test.ts:3:11 › passes'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); testProcess.write('\r\n'); @@ -222,7 +222,6 @@ test('should run tests on R', async ({ runWatchTest }) => { test('passes', () => {}); `, }); - await testProcess.waitForOutput('a.test.ts:3:11 › passes'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); testProcess.write('r'); @@ -246,6 +245,10 @@ test('should run failed tests on F', async ({ runWatchTest }) => { test('fails', () => { expect(1).toBe(2); }); `, }); + await testProcess.waitForOutput('Waiting for file changes.'); + testProcess.write('\r\n'); + + await testProcess.waitForOutput('npx playwright test #1'); await testProcess.waitForOutput('a.test.ts:3:11 › passes'); await testProcess.waitForOutput('b.test.ts:3:11 › passes'); await testProcess.waitForOutput('c.test.ts:3:11 › fails'); @@ -253,7 +256,7 @@ test('should run failed tests on F', async ({ runWatchTest }) => { await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); testProcess.write('f'); - await testProcess.waitForOutput('npx playwright test (running failed tests) #1'); + await testProcess.waitForOutput('npx playwright test (running failed tests) #2'); await testProcess.waitForOutput('c.test.ts:3:11 › fails'); expect(testProcess.output).not.toContain('a.test.ts:3:11'); }); @@ -269,8 +272,6 @@ test('should respect file filter P', async ({ runWatchTest }) => { test('passes', () => {}); `, }); - await testProcess.waitForOutput('a.test.ts:3:11 › passes'); - await testProcess.waitForOutput('b.test.ts:3:11 › passes'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); testProcess.write('p'); @@ -294,6 +295,11 @@ test('should respect project filter C', async ({ runWatchTest, writeFiles }) => `, }; const testProcess = await runWatchTest(files, { project: 'foo' }); + await testProcess.waitForOutput('Waiting for file changes.'); + testProcess.clearOutput(); + testProcess.write('\r\n'); + + await testProcess.waitForOutput('npx playwright test --project foo #1'); await testProcess.waitForOutput('[foo] › a.test.ts:3:11 › passes'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); @@ -303,16 +309,21 @@ test('should respect project filter C', async ({ runWatchTest, writeFiles }) => await testProcess.waitForOutput('bar'); testProcess.write(' '); testProcess.write('\r\n'); - await testProcess.waitForOutput('npx playwright test --project foo #1'); + await testProcess.waitForOutput('npx playwright test --project foo #2'); await testProcess.waitForOutput('[foo] › a.test.ts:3:11 › passes'); expect(testProcess.output).not.toContain('[bar] › a.test.ts:3:11 › passes'); + await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); await writeFiles(files); // file change triggers listTests with project filter await testProcess.waitForOutput('[foo] › a.test.ts:3:11 › passes'); + testProcess.clearOutput(); + await testProcess.waitForOutput('Waiting for file changes.'); + testProcess.write('c'); + testProcess.clearOutput(); await testProcess.waitForOutput('Select projects'); await testProcess.waitForOutput('foo'); await testProcess.waitForOutput('bar'); // second selection should still show all @@ -329,8 +340,6 @@ test('should respect file filter P and split files', async ({ runWatchTest }) => test('passes', () => {}); `, }); - await testProcess.waitForOutput('a.test.ts:3:11 › passes'); - await testProcess.waitForOutput('b.test.ts:3:11 › passes'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); testProcess.write('p'); @@ -353,8 +362,6 @@ test('should respect title filter T', async ({ runWatchTest }) => { test('title 2', () => {}); `, }); - await testProcess.waitForOutput('a.test.ts:3:11 › title 1'); - await testProcess.waitForOutput('b.test.ts:3:11 › title 2'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); testProcess.write('t'); @@ -381,6 +388,11 @@ test('should re-run failed tests on F > R', async ({ runWatchTest }) => { test('fails', () => { expect(1).toBe(2); }); `, }); + await testProcess.waitForOutput('Waiting for file changes.'); + testProcess.clearOutput(); + testProcess.write('\r\n'); + + await testProcess.waitForOutput('npx playwright test #1'); await testProcess.waitForOutput('a.test.ts:3:11 › passes'); await testProcess.waitForOutput('b.test.ts:3:11 › passes'); await testProcess.waitForOutput('c.test.ts:3:11 › fails'); @@ -388,12 +400,12 @@ test('should re-run failed tests on F > R', async ({ runWatchTest }) => { await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); testProcess.write('f'); - await testProcess.waitForOutput('npx playwright test (running failed tests) #1'); + await testProcess.waitForOutput('npx playwright test (running failed tests) #2'); await testProcess.waitForOutput('c.test.ts:3:11 › fails'); expect(testProcess.output).not.toContain('a.test.ts:3:11'); testProcess.clearOutput(); testProcess.write('r'); - await testProcess.waitForOutput('npx playwright test (re-running tests) #2'); + await testProcess.waitForOutput('npx playwright test (re-running tests) #3'); await testProcess.waitForOutput('c.test.ts:3:11 › fails'); expect(testProcess.output).not.toContain('a.test.ts:3:11'); }); @@ -413,10 +425,6 @@ test('should run on changed files', async ({ runWatchTest, writeFiles }) => { test('fails', () => { expect(1).toBe(2); }); `, }); - await testProcess.waitForOutput('a.test.ts:3:11 › passes'); - await testProcess.waitForOutput('b.test.ts:3:11 › passes'); - await testProcess.waitForOutput('c.test.ts:3:11 › fails'); - await testProcess.waitForOutput('Error: expect(received).toBe(expected)'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); await writeFiles({ @@ -457,9 +465,6 @@ test('should run on changed deps', async ({ runWatchTest, writeFiles }) => { console.log('old helper'); `, }); - await testProcess.waitForOutput('a.test.ts:3:11 › passes'); - await testProcess.waitForOutput('b.test.ts:4:11 › passes'); - await testProcess.waitForOutput('old helper'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); await writeFiles({ @@ -490,9 +495,6 @@ test('should run on changed deps in ESM', async ({ runWatchTest, writeFiles }) = console.log('old helper'); `, }); - await testProcess.waitForOutput('a.test.ts:3:7 › passes'); - await testProcess.waitForOutput('b.test.ts:4:7 › passes'); - await testProcess.waitForOutput('old helper'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); await writeFiles({ @@ -521,10 +523,6 @@ test('should re-run changed files on R', async ({ runWatchTest, writeFiles }) => test('fails', () => { expect(1).toBe(2); }); `, }); - await testProcess.waitForOutput('a.test.ts:3:11 › passes'); - await testProcess.waitForOutput('b.test.ts:3:11 › passes'); - await testProcess.waitForOutput('c.test.ts:3:11 › fails'); - await testProcess.waitForOutput('Error: expect(received).toBe(expected)'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); await writeFiles({ @@ -556,8 +554,6 @@ test('should not trigger on changes to non-tests', async ({ runWatchTest, writeF test('passes', () => {}); `, }); - await testProcess.waitForOutput('a.test.ts:3:11 › passes'); - await testProcess.waitForOutput('b.test.ts:3:11 › passes'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); @@ -582,6 +578,10 @@ test('should only watch selected projects', async ({ runWatchTest, writeFiles }) test('passes', () => {}); `, }, undefined, undefined, { additionalArgs: ['--project=foo'] }); + await testProcess.waitForOutput('Waiting for file changes.'); + testProcess.clearOutput(); + testProcess.write('\r\n'); + await testProcess.waitForOutput('npx playwright test --project foo'); await testProcess.waitForOutput('[foo] › a.test.ts:3:11 › passes'); expect(testProcess.output).not.toContain('[bar]'); @@ -612,6 +612,10 @@ test('should watch filtered files', async ({ runWatchTest, writeFiles }) => { test('passes', () => {}); `, }, undefined, undefined, { additionalArgs: ['a.test.ts'] }); + await testProcess.waitForOutput('Waiting for file changes.'); + testProcess.clearOutput(); + testProcess.write('\r\n'); + await testProcess.waitForOutput('npx playwright test a.test.ts'); await testProcess.waitForOutput('a.test.ts:3:11 › passes'); expect(testProcess.output).not.toContain('b.test'); @@ -640,6 +644,10 @@ test('should not watch unfiltered files', async ({ runWatchTest, writeFiles }) = test('passes', () => {}); `, }, undefined, undefined, { additionalArgs: ['a.test.ts'] }); + await testProcess.waitForOutput('Waiting for file changes.'); + testProcess.clearOutput(); + testProcess.write('\r\n'); + await testProcess.waitForOutput('npx playwright test a.test.ts'); await testProcess.waitForOutput('a.test.ts:3:11 › passes'); expect(testProcess.output).not.toContain('b.test'); @@ -684,10 +692,7 @@ test('should run CT on changed deps', async ({ runWatchTest, writeFiles }) => { }); `, }); - await testProcess.waitForOutput('button.spec.tsx:4:11 › pass'); - await testProcess.waitForOutput('link.spec.tsx:3:11 › pass'); await testProcess.waitForOutput('Waiting for file changes.'); - testProcess.clearOutput(); await writeFiles({ 'src/button.tsx': ` export const Button = () => ; @@ -732,10 +737,7 @@ test('should run CT on indirect deps change', async ({ runWatchTest, writeFiles }); `, }); - await testProcess.waitForOutput('button.spec.tsx:4:11 › pass'); - await testProcess.waitForOutput('link.spec.tsx:3:11 › pass'); await testProcess.waitForOutput('Waiting for file changes.'); - testProcess.clearOutput(); await writeFiles({ 'src/button.css': ` button { color: blue; } @@ -776,10 +778,7 @@ test('should run CT on indirect deps change ESM mode', async ({ runWatchTest, wr }); `, }); - await testProcess.waitForOutput('button.spec.tsx:4:7 › pass'); - await testProcess.waitForOutput('link.spec.tsx:3:7 › pass'); await testProcess.waitForOutput('Waiting for file changes.'); - testProcess.clearOutput(); await writeFiles({ 'src/button.css': ` button { color: blue; } @@ -809,6 +808,10 @@ test('should run global teardown before exiting', async ({ runWatchTest }) => { }); `, }); + await testProcess.waitForOutput('Waiting for file changes.'); + testProcess.clearOutput(); + testProcess.write('\r\n'); + await testProcess.waitForOutput('a.test.ts:3:11 › passes'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.write('\x1B'); @@ -834,3 +837,49 @@ test('should stop testrun on pressing escape', async ({ runWatchTest }) => { testProcess.write('\x1B'); await testProcess.waitForOutput('1 interrupted'); }); + +test('buffer mode', async ({ runWatchTest, writeFiles }) => { + const testProcess = await runWatchTest({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('passes', () => {}); + `, + 'b.test.ts': ` + import { test, expect } from '@playwright/test'; + test('passes in b', () => {}); + `, + }); + + testProcess.clearOutput(); + testProcess.write('b'); + await testProcess.waitForOutput('Waiting for file changes. Press q to quit'); + + + testProcess.clearOutput(); + await writeFiles({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('passes again', () => {}); + `, + }); + + await testProcess.waitForOutput('1 test file changed:'); + await testProcess.waitForOutput('a.test.ts'); + + testProcess.clearOutput(); + await writeFiles({ + 'b.test.ts': ` + import { test, expect } from '@playwright/test'; + test('passes in b again', () => {}); + `, + }); + await testProcess.waitForOutput('2 test files changed:'); + await testProcess.waitForOutput('a.test.ts'); + await testProcess.waitForOutput('b.test.ts'); + + testProcess.clearOutput(); + testProcess.write('\r\n'); + + await testProcess.waitForOutput('a.test.ts:3:11 › passes'); + await testProcess.waitForOutput('b.test.ts:3:11 › passes'); +});