diff --git a/.eslintignore b/.eslintignore index f7365e0082..152cbbe228 100644 --- a/.eslintignore +++ b/.eslintignore @@ -19,4 +19,5 @@ tests/components/ tests/installation/fixture-scripts/ examples/ DEPS -.cache/ \ No newline at end of file +.cache/ +utils/ diff --git a/.eslintrc-with-ts-config.js b/.eslintrc-with-ts-config.js new file mode 100644 index 0000000000..b06ec00195 --- /dev/null +++ b/.eslintrc-with-ts-config.js @@ -0,0 +1,15 @@ +module.exports = { + extends: "./.eslintrc.js", + parserOptions: { + ecmaVersion: 9, + sourceType: "module", + project: "./tsconfig.json", + }, + rules: { + "@typescript-eslint/no-base-to-string": "error", + "@typescript-eslint/no-unnecessary-boolean-literal-compare": 2, + }, + parserOptions: { + project: "./tsconfig.json" + }, +}; diff --git a/.eslintrc.js b/.eslintrc.js index 7bc5a0868f..bff9ffeeb4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -48,6 +48,7 @@ module.exports = { "arrow-parens": [2, "as-needed"], "prefer-const": 2, "quote-props": [2, "consistent"], + "nonblock-statement-body-position": [2, "below"], // anti-patterns "no-var": 2, diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 5baad382eb..062d7c7e74 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -23,7 +23,7 @@ body: value: | ## Make a minimal reproduction To file the report, you will need a GitHub repository with a minimal (but complete) example and simple/clear steps on how to reproduce the bug. - The simpler you can make it, the more likely we are to successfully verify and fix the bug. + The simpler you can make it, the more likely we are to successfully verify and fix the bug. You can create a new project with `npm init playwright@latest new-project` and then add the test code there. - type: markdown attributes: value: | diff --git a/.github/workflows/cherry_pick_into_release_branch.yml b/.github/workflows/cherry_pick_into_release_branch.yml index 6350d7ea0d..f48028b14b 100644 --- a/.github/workflows/cherry_pick_into_release_branch.yml +++ b/.github/workflows/cherry_pick_into_release_branch.yml @@ -12,6 +12,9 @@ on: description: Comma-separated list of commit hashes to cherry-pick required: true +permissions: + contents: write + jobs: roll: runs-on: ubuntu-22.04 diff --git a/.github/workflows/tests_primary.yml b/.github/workflows/tests_primary.yml index 0ad4b68294..8c491514ae 100644 --- a/.github/workflows/tests_primary.yml +++ b/.github/workflows/tests_primary.yml @@ -55,7 +55,7 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - run: npm run build - run: npx playwright install --with-deps ${{ matrix.browser }} chromium - - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }} + - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }}-* - run: node tests/config/checkCoverage.js ${{ matrix.browser }} - run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json if: always() @@ -87,7 +87,7 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - run: npm run build - run: npx playwright install --with-deps chromium-tip-of-tree - - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=chromium + - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=chromium-* env: PWTEST_CHANNEL: chromium-tip-of-tree PWTEST_BOT_NAME: "${{ matrix.os }}-chromium-tip-of-tree" diff --git a/.github/workflows/tests_secondary.yml b/.github/workflows/tests_secondary.yml index 816bf5c3f9..7659c338bc 100644 --- a/.github/workflows/tests_secondary.yml +++ b/.github/workflows/tests_secondary.yml @@ -42,7 +42,7 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - run: npm run build - run: npx playwright install --with-deps ${{ matrix.browser }} chromium - - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }} + - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }}-* - run: node tests/config/checkCoverage.js ${{ matrix.browser }} - run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json if: always() @@ -75,7 +75,7 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - run: npm run build - run: npx playwright install --with-deps ${{ matrix.browser }} chromium - - run: npm run test -- --project=${{ matrix.browser }} + - run: npm run test -- --project=${{ matrix.browser }}-* - run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json if: always() shell: bash @@ -106,10 +106,10 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - run: npm run build - run: npx playwright install --with-deps ${{ matrix.browser }} chromium - - run: npm run test -- --project=${{ matrix.browser }} --workers=1 + - run: npm run test -- --project=${{ matrix.browser }}-* --workers=1 if: matrix.browser == 'firefox' shell: bash - - run: npm run test -- --project=${{ matrix.browser }} + - run: npm run test -- --project=${{ matrix.browser }}-* if: matrix.browser != 'firefox' shell: bash - run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json @@ -175,9 +175,9 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - run: npm run build - run: npx playwright install --with-deps ${{ matrix.browser }} chromium - - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }} --headed + - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }}-* --headed if: always() && startsWith(matrix.os, 'ubuntu-') - - run: npm run test -- --project=${{ matrix.browser }} --headed + - run: npm run test -- --project=${{ matrix.browser }}-* --headed if: always() && !startsWith(matrix.os, 'ubuntu-') - run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json if: always() @@ -247,7 +247,7 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - run: npm run build - run: npx playwright install --with-deps ${{ matrix.browser }} chromium ${{ matrix.channel }} - - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }} + - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }}-* env: PWTEST_TRACE: 1 PWTEST_CHANNEL: ${{ matrix.channel }} @@ -868,7 +868,7 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - run: npm run build - run: npx playwright install --with-deps chromium - - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=chromium + - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=chromium-* env: PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW: 1 - run: node tests/config/checkCoverage.js chromium diff --git a/.github/workflows/tests_service.yml b/.github/workflows/tests_service.yml index 9c932f38e4..b8c192f988 100644 --- a/.github/workflows/tests_service.yml +++ b/.github/workflows/tests_service.yml @@ -23,7 +23,7 @@ jobs: env: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - run: npm run build - - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }} --workers=10 --retries=0 + - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }}-* --workers=10 --retries=0 env: PWTEST_MODE: service2 PWTEST_TRACE: 1 diff --git a/.github/workflows/tests_video.yml b/.github/workflows/tests_video.yml index f39b544f93..3acda7cec7 100644 --- a/.github/workflows/tests_video.yml +++ b/.github/workflows/tests_video.yml @@ -32,7 +32,7 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - run: npm run build - run: npx playwright install --with-deps ${{ matrix.browser }} chromium - - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }} + - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }}-* env: PWTEST_VIDEO: 1 - run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json diff --git a/.github/workflows/tests_webview2.yml b/.github/workflows/tests_webview2.yml index 425a7bbc8a..26c97f6062 100644 --- a/.github/workflows/tests_webview2.yml +++ b/.github/workflows/tests_webview2.yml @@ -38,6 +38,12 @@ jobs: - run: npm run build - run: dotnet build working-directory: tests/webview2/webview2-app/ + - name: Update to Evergreen WebView2 Runtime + shell: pwsh + run: | + # See here: https://developer.microsoft.com/en-us/microsoft-edge/webview2/ + Invoke-WebRequest -Uri 'https://go.microsoft.com/fwlink/p/?LinkId=2124703' -OutFile 'setup.exe' + Start-Process -FilePath setup.exe -Verb RunAs -Wait - run: npm run webview2test - run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json if: always() diff --git a/.github/workflows/trigger_build_chromium_with_symbols.yml b/.github/workflows/trigger_build_chromium_with_symbols.yml deleted file mode 100644 index e0e31acd57..0000000000 --- a/.github/workflows/trigger_build_chromium_with_symbols.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: "Trigger: Chromium with Symbols Builds" - -on: - workflow_dispatch: - release: - types: [published] - -jobs: - trigger: - name: "trigger" - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: - node-version: 18 - - name: Get Chromium revision - id: chromium-version - run: | - REVISION=$(node -e "console.log(require('./packages/playwright-core/browsers.json').browsers.find(b => b.name === 'chromium-with-symbols').revision)") - echo "REVISION=$REVISION" >> $GITHUB_OUTPUT - - run: | - curl -X POST \ - -H "Accept: application/vnd.github.v3+json" \ - -H "Authorization: token ${GH_TOKEN}" \ - --data "{\"event_type\": \"build_chromium_with_symbols\", \"client_payload\": {\"revision\": \"${CHROMIUM_REVISION}\"}}" \ - https://api.github.com/repos/microsoft/playwright-browsers/dispatches - env: - GH_TOKEN: ${{ secrets.REPOSITORY_DISPATCH_PERSONAL_ACCESS_TOKEN }} - CHROMIUM_REVISION: ${{ steps.chromium-version.outputs.REVISION }} diff --git a/README.md b/README.md index 14737d10d4..93be49146e 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-123.0.6312.4-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-123.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-17.4-blue.svg?logo=safari)](https://webkit.org/) +[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-124.0.6367.8-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-123.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-17.4-blue.svg?logo=safari)](https://webkit.org/) ## [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 123.0.6312.4 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Chromium 124.0.6367.8 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 17.4 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox 123.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | diff --git a/docs/src/accessibility-testing-java.md b/docs/src/accessibility-testing-java.md index a14064452c..15bb998481 100644 --- a/docs/src/accessibility-testing-java.md +++ b/docs/src/accessibility-testing-java.md @@ -238,3 +238,5 @@ public class HomepageTests extends AxeTestFixtures { } } ``` + +See experimental [JUnit integration](./junit.md) to automatically initialize Playwright objects and more. diff --git a/docs/src/api-testing-java.md b/docs/src/api-testing-java.md index 80faa968d4..41265776a5 100644 --- a/docs/src/api-testing-java.md +++ b/docs/src/api-testing-java.md @@ -375,6 +375,8 @@ public class TestGitHubAPI { } ``` +See experimental [JUnit integration](./junit.md) to automatically initialize Playwright objects and more. + ## Prepare server state via API calls The following test creates a new issue via API and then navigates to the list of all issues in the diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index 87f6369b5c..11df502854 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -186,7 +186,10 @@ context.on("dialog", lambda dialog: dialog.accept()) ``` ```csharp -context.Dialog += (_, dialog) => dialog.AcceptAsync(); +Context.Dialog += async (_, dialog) => +{ + await dialog.AcceptAsync(); +}; ``` :::note @@ -390,7 +393,7 @@ browser_context.add_init_script(path="preload.js") ``` ```csharp -await context.AddInitScriptAsync(scriptPath: "preload.js"); +await Context.AddInitScriptAsync(scriptPath: "preload.js"); ``` :::note @@ -1010,6 +1013,27 @@ Creates a new page in the browser context. Returns all open pages in the context. +## async method: BrowserContext.removeCookies +* since: v1.43 + +Removes cookies from context. At least one of the removal criteria should be provided. + +**Usage** + +```js +await browserContext.removeCookies({ name: 'session-id' }); +await browserContext.removeCookies({ domain: 'my-origin.com' }); +await browserContext.removeCookies({ path: '/api/v1' }); +await browserContext.removeCookies({ name: 'session-id', domain: 'my-origin.com' }); +``` + +### param: BrowserContext.removeCookies.filter +* since: v1.43 +- `filter` <[Object]> + - `name` ?<[string]> + - `domain` ?<[string]> + - `path` ?<[string]> + ## property: BrowserContext.request * since: v1.16 * langs: @@ -1159,7 +1183,7 @@ context.route("/api/**", handle_route) await page.RouteAsync("/api/**", async r => { if (r.Request.PostData.Contains("my-string")) - await r.FulfillAsync(body: "mocked-data"); + await r.FulfillAsync(new() { Body = "mocked-data" }); else await r.ContinueAsync(); }); diff --git a/docs/src/api/class-framelocator.md b/docs/src/api/class-framelocator.md index c90ff1fa19..cf75fc8422 100644 --- a/docs/src/api/class-framelocator.md +++ b/docs/src/api/class-framelocator.md @@ -74,28 +74,59 @@ await page.FrameLocator(".result-frame").First.getByRole(AriaRole.Button).ClickA **Converting Locator to FrameLocator** -If you have a [Locator] object pointing to an `iframe` it can be converted to [FrameLocator] using [`:scope`](https://developer.mozilla.org/en-US/docs/Web/CSS/:scope) CSS selector: +If you have a [Locator] object pointing to an `iframe` it can be converted to [FrameLocator] using [`method: Locator.enterFrame`]. + +**Converting FrameLocator to Locator** + +If you have a [FrameLocator] object it can be converted to [Locator] pointing to the same `iframe` using [`method: FrameLocator.exitFrame`]. + + +## method: FrameLocator.exitFrame +* since: v1.43 +- returns: <[Locator]> + +Returns a [Locator] object pointing to the same `iframe` as this frame locator. + +Useful when you have a [FrameLocator] object obtained somewhere, and later on would like to interact with the `iframe` element. + +**Usage** ```js -const frameLocator = locator.frameLocator(':scope'); +const frameLocator = page.frameLocator('iframe[name="embedded"]'); +// ... +const locator = frameLocator.exitFrame(); +await expect(locator).toBeVisible(); ``` ```java -Locator frameLocator = locator.frameLocator(':scope'); +FrameLocator frameLocator = page.frameLocator("iframe[name=\"embedded\"]"); +// ... +Locator locator = frameLocator.exitFrame(); +assertThat(locator).isVisible(); ``` ```python async -frameLocator = locator.frame_locator(":scope") +frame_locator = page.frame_locator("iframe[name=\"embedded\"]") +# ... +locator = frame_locator.exit_frame +await expect(locator).to_be_visible() ``` ```python sync -frameLocator = locator.frame_locator(":scope") +frame_locator = page.frame_locator("iframe[name=\"embedded\"]") +# ... +locator = frame_locator.exit_frame +expect(locator).to_be_visible() ``` ```csharp -var frameLocator = locator.FrameLocator(":scope"); +var frameLocator = Page.FrameLocator("iframe[name=\"embedded\"]"); +// ... +var locator = frameLocator.ExitFrame; +await Expect(locator).ToBeVisibleAsync(); ``` + ## method: FrameLocator.first * since: v1.17 - returns: <[FrameLocator]> diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index bea8507def..c254adc1c0 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -747,6 +747,51 @@ Resolves given locator to the first matching DOM element. If there are no matchi Resolves given locator to all matching DOM elements. If there are no matching elements, returns an empty list. +## method: Locator.enterFrame +* since: v1.43 +- returns: <[FrameLocator]> + +Returns a [FrameLocator] object pointing to the same `iframe` as this locator. + +Useful when you have a [Locator] object obtained somewhere, and later on would like to interact with the content inside the frame. + +**Usage** + +```js +const locator = page.locator('iframe[name="embedded"]'); +// ... +const frameLocator = locator.enterFrame(); +await frameLocator.getByRole('button').click(); +``` + +```java +Locator locator = page.locator("iframe[name=\"embedded\"]"); +// ... +FrameLocator frameLocator = locator.enterFrame(); +frameLocator.getByRole(AriaRole.BUTTON).click(); +``` + +```python async +locator = page.locator("iframe[name=\"embedded\"]") +# ... +frame_locator = locator.enter_frame +await frame_locator.get_by_role("button").click() +``` + +```python sync +locator = page.locator("iframe[name=\"embedded\"]") +# ... +frame_locator = locator.enter_frame +frame_locator.get_by_role("button").click() +``` + +```csharp +var locator = Page.Locator("iframe[name=\"embedded\"]"); +// ... +var frameLocator = locator.EnterFrame; +await frameLocator.GetByRole(AriaRole.Button).ClickAsync(); +``` + ## async method: Locator.evaluate * since: v1.14 - returns: <[Serializable]> diff --git a/docs/src/api/class-logger.md b/docs/src/api/class-logger.md index a3a4b7c463..5ab4f3128a 100644 --- a/docs/src/api/class-logger.md +++ b/docs/src/api/class-logger.md @@ -10,7 +10,7 @@ const { chromium } = require('playwright'); // Or 'firefox' or 'webkit'. (async () => { const browser = await chromium.launch({ logger: { - isEnabled: (name, severity) => name === 'browser', + isEnabled: (name, severity) => name === 'api', log: (name, severity, message, args) => console.log(`${name} ${message}`) } }); diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 922a7df3bf..caea5998b6 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -608,7 +608,7 @@ page.add_init_script(path="./preload.js") ``` ```csharp -await page.AddInitScriptAsync(scriptPath: "./preload.js"); +await Page.AddInitScriptAsync(scriptPath: "./preload.js"); ``` :::note @@ -3146,32 +3146,38 @@ return value resolves to `[]`. ## async method: Page.addLocatorHandler * since: v1.42 -Sometimes, the web page can show an overlay that obstructs elements behind it and prevents certain actions, like click, from completing. When such an overlay is shown predictably, we recommend dismissing it as a part of your test flow. However, sometimes such an overlay may appear non-deterministically, for example certain cookies consent dialogs behave this way. In this case, [`method: Page.addLocatorHandler`] allows handling an overlay during an action that it would block. +:::warning Experimental +This method is experimental and its behavior may change in the upcoming releases. +::: -This method registers a handler for an overlay that is executed once the locator is visible on the page. The handler should get rid of the overlay so that actions blocked by it can proceed. This is useful for nondeterministic interstitial pages or dialogs, like a cookie consent dialog. +When testing a web page, sometimes unexpected overlays like a "Sign up" dialog appear and block actions you want to automate, e.g. clicking a button. These overlays don't always show up in the same way or at the same time, making them tricky to handle in automated tests. -Note that execution time of the handler counts towards the timeout of the action/assertion that executed the handler. +This method lets you set up a special function, called a handler, that activates when it detects that overlay is visible. The handler's job is to remove the overlay, allowing your test to continue as if the overlay wasn't there. -You can register multiple handlers. However, only a single handler will be running at a time. Any actions inside a handler must not require another handler to run. +Things to keep in mind: +* When an overlay is shown predictably, we recommend explicitly waiting for it in your test and dismissing it as a part of your normal test flow, instead of using [`method: Page.addLocatorHandler`]. +* Playwright checks for the overlay every time before executing or retrying an action that requires an [actionability check](../actionability.md), or before performing an auto-waiting assertion check. When overlay is visible, Playwright calls the handler first, and then proceeds with the action/assertion. Note that the handler is only called when you perform an action/assertion - if the overlay becomes visible but you don't perform any actions, the handler will not be triggered. +* The execution time of the handler counts towards the timeout of the action/assertion that executed the handler. If your handler takes too long, it might cause timeouts. +* You can register multiple handlers. However, only a single handler will be running at a time. Make sure the actions within a handler don't depend on another handler. :::warning -Running the interceptor will alter your page state mid-test. For example it will change the currently focused element and move the mouse. Make sure that the actions that run after the interceptor are self-contained and do not rely on the focus and mouse state. +Running the handler will alter your page state mid-test. For example it will change the currently focused element and move the mouse. Make sure that actions that run after the handler are self-contained and do not rely on the focus and mouse state being unchanged.

For example, consider a test that calls [`method: Locator.focus`] followed by [`method: Keyboard.press`]. If your handler clicks a button between these two actions, the focused element most likely will be wrong, and key press will happen on the unexpected element. Use [`method: Locator.press`] instead to avoid this problem.

-Another example is a series of mouse actions, where [`method: Mouse.move`] is followed by [`method: Mouse.down`]. Again, when the handler runs between these two actions, the mouse position will be wrong during the mouse down. Prefer methods like [`method: Locator.click`] that are self-contained. +Another example is a series of mouse actions, where [`method: Mouse.move`] is followed by [`method: Mouse.down`]. Again, when the handler runs between these two actions, the mouse position will be wrong during the mouse down. Prefer self-contained actions like [`method: Locator.click`] that do not rely on the state being unchanged by a handler. ::: **Usage** -An example that closes a cookie dialog when it appears: +An example that closes a "Sign up to the newsletter" dialog when it appears: ```js // Setup the handler. -await page.addLocatorHandler(page.getByRole('button', { name: 'Accept all cookies' }), async () => { - await page.getByRole('button', { name: 'Reject all cookies' }).click(); +await page.addLocatorHandler(page.getByText('Sign up to the newsletter'), async () => { + await page.getByRole('button', { name: 'No thanks' }).click(); }); // Write the test as usual. @@ -3181,8 +3187,8 @@ await page.getByRole('button', { name: 'Start here' }).click(); ```java // Setup the handler. -page.addLocatorHandler(page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Accept all cookies")), () => { - page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Reject all cookies")).click(); +page.addLocatorHandler(page.getByText("Sign up to the newsletter"), () => { + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("No thanks")).click(); }); // Write the test as usual. @@ -3193,8 +3199,8 @@ page.getByRole("button", Page.GetByRoleOptions().setName("Start here")).click(); ```python sync # Setup the handler. def handler(): - page.get_by_role("button", name="Reject all cookies").click() -page.add_locator_handler(page.get_by_role("button", name="Accept all cookies"), handler) + page.get_by_role("button", name="No thanks").click() +page.add_locator_handler(page.get_by_text("Sign up to the newsletter"), handler) # Write the test as usual. page.goto("https://example.com") @@ -3204,8 +3210,8 @@ page.get_by_role("button", name="Start here").click() ```python async # Setup the handler. def handler(): - await page.get_by_role("button", name="Reject all cookies").click() -await page.add_locator_handler(page.get_by_role("button", name="Accept all cookies"), handler) + await page.get_by_role("button", name="No thanks").click() +await page.add_locator_handler(page.get_by_text("Sign up to the newsletter"), handler) # Write the test as usual. await page.goto("https://example.com") @@ -3214,8 +3220,8 @@ await page.get_by_role("button", name="Start here").click() ```csharp // Setup the handler. -await page.AddLocatorHandlerAsync(page.GetByRole(AriaRole.Button, new() { Name = "Accept all cookies" }), async () => { - await page.GetByRole(AriaRole.Button, new() { Name = "Reject all cookies" }).ClickAsync(); +await page.AddLocatorHandlerAsync(page.GetByText("Sign up to the newsletter"), async () => { + await page.GetByRole(AriaRole.Button, new() { Name = "No thanks" }).ClickAsync(); }); // Write the test as usual. @@ -3228,7 +3234,7 @@ An example that skips the "Confirm your security details" page when it is shown: ```js // Setup the handler. await page.addLocatorHandler(page.getByText('Confirm your security details'), async () => { - await page.getByRole('button', 'Remind me later').click(); + await page.getByRole('button', { name: 'Remind me later' }).click(); }); // Write the test as usual. diff --git a/docs/src/api/params.md b/docs/src/api/params.md index b28104814e..e3b2894c3c 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -999,6 +999,7 @@ disable timeout. If specified, traces are saved into this directory. ## browser-option-devtools +* deprecated: Use [debugging tools](../debug.md) instead. - `devtools` <[boolean]> **Chromium-only** Whether to auto-open a Developer Tools panel for each tab. If this option is `true`, the diff --git a/docs/src/auth.md b/docs/src/auth.md index df03d89c60..ba7af6f4cd 100644 --- a/docs/src/auth.md +++ b/docs/src/auth.md @@ -113,6 +113,13 @@ test('test', async ({ page }) => { }); ``` +### Authenticating in UI mode +* langs: js + +UI mode will not run the `setup` project by default to improve testing speed. We recommend to authenticate by manually running the `auth.setup.ts` from time to time, whenever existing authentication expires. + +First [enable the `setup` project in the filters](./test-ui-mode#filtering-tests), then click the triangle button next to `auth.setup.ts` file, and then disable the `setup` project in the filters again. + ## Moderate: one account per parallel worker * langs: js @@ -256,7 +263,7 @@ existing authentication state instead. Playwright provides a way to reuse the signed-in state in the tests. That way you can log in only once and then skip the log in step for all of the tests. -Web apps use cookie-based or token-based authentication, where authenticated state is stored as [cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies) or in [local storage](https://developer.mozilla.org/en-US/docs/Web/API/Storage). Playwright provides [browserContext.storageState([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-storage-state) method that can be used to retrieve storage state from authenticated contexts and then create new contexts with pre-populated state. +Web apps use cookie-based or token-based authentication, where authenticated state is stored as [cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies) or in [local storage](https://developer.mozilla.org/en-US/docs/Web/API/Storage). Playwright provides [`method: BrowserContext.storageState`] method that can be used to retrieve storage state from authenticated contexts and then create new contexts with pre-populated state. Cookies and local storage state can be used across different browsers. They depend on your application's authentication model: some apps might require both cookies and local storage. @@ -457,6 +464,8 @@ test.describe(() => { }); ``` +See also about [authenticating in the UI mode](#authenticating-in-ui-mode). + ### Testing multiple roles together * langs: js diff --git a/docs/src/ci-intro.md b/docs/src/ci-intro.md index da4f31522f..f5393cfc9e 100644 --- a/docs/src/ci-intro.md +++ b/docs/src/ci-intro.md @@ -8,7 +8,7 @@ title: "CI GitHub Actions" Playwright tests can be run on any CI provider. In this section we will cover running tests on GitHub using GitHub actions. If you would like to see how to configure other CI providers check out our detailed [doc on Continuous Integration](./ci.md). -When [installing Playwright](./intro.md) using the [VS Code extension](./getting-started-vscode.md) or with `npm init playwright@latest` you are given the option to add a [GitHub Actions](https://docs.github.com/en/actions). This creates a `playwright.yml` file inside a `.github/workflows` folder containing everything you need so that your tests run on each push and pull request into the main/master branch. +When [installing Playwright](./intro.md) using the [VS Code extension](./getting-started-vscode.md) or with `npm init playwright@latest` you are given the option to add a [GitHub Actions](https://docs.github.com/en/actions) workflow. This creates a `playwright.yml` file inside a `.github/workflows` folder containing everything you need so that your tests run on each push and pull request into the main/master branch. #### You will learn * langs: js diff --git a/docs/src/dialogs.md b/docs/src/dialogs.md index c210ffdb3c..1d8938778c 100644 --- a/docs/src/dialogs.md +++ b/docs/src/dialogs.md @@ -32,8 +32,11 @@ page.get_by_role("button").click() ``` ```csharp -page.Dialog += (_, dialog) => dialog.AcceptAsync(); -await page.GetByRole(AriaRole.Button).ClickAsync(); +Page.Dialog += async (_, dialog) => +{ + await dialog.AcceptAsync(); +}; +await Page.GetByRole(AriaRole.Button).ClickAsync(); ``` :::note @@ -116,10 +119,10 @@ page.close(run_before_unload=True) ``` ```csharp -page.Dialog += (_, dialog) => +Page.Dialog += async (_, dialog) => { Assert.AreEqual("beforeunload", dialog.Type); - dialog.DismissAsync(); + await dialog.DismissAsync(); }; -await page.CloseAsync(runBeforeUnload: true); +await Page.CloseAsync(new() { RunBeforeUnload = true }); ``` diff --git a/docs/src/junit-java.md b/docs/src/junit-java.md new file mode 100644 index 0000000000..7ea43399dd --- /dev/null +++ b/docs/src/junit-java.md @@ -0,0 +1,180 @@ +--- +id: junit +title: "JUnit (experimental)" +--- + +## Introduction + +With a few lines of code, you can hook up Playwright to your favorite Java test runner. + +In [JUnit](https://junit.org/junit5/), you can use Playwright [fixtures](./junit.md#fixtures) to automatically initialize [Playwright], [Browser], [BrowserContext] or [Page]. In the example below, all three test methods use the same +[Browser]. Each test uses its own [BrowserContext] and [Page]. + + + +```java +package org.example; + +import com.microsoft.playwright.Page; +import com.microsoft.playwright.junit.UsePlaywright; +import org.junit.jupiter.api.Test; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@UsePlaywright +public class TestExample { + @Test + void shouldClickButton(Page page) { + page.navigate("data:text/html,"); + page.locator("button").click(); + assertEquals("Clicked", page.evaluate("result")); + } + + @Test + void shouldCheckTheBox(Page page) { + page.setContent(""); + page.locator("input").check(); + assertEquals(true, page.evaluate("window['checkbox'].checked")); + } + + @Test + void shouldSearchWiki(Page page) { + page.navigate("https://www.wikipedia.org/"); + page.locator("input[name=\"search\"]").click(); + page.locator("input[name=\"search\"]").fill("playwright"); + page.locator("input[name=\"search\"]").press("Enter"); + assertThat(page).hasURL("https://en.wikipedia.org/wiki/Playwright"); + } +} +``` + +## Fixtures + +Simply add JUnit annotation `@UsePlaywright` to your test classes to enable Playwright fixtures. Test fixtures are used to establish environment for each test, giving the test everything it needs and nothing else. + +```java +@UsePlaywright +public class TestExample { + + @Test + void basicTest(Page page) { + page.navigate("https://playwright.dev/"); + + assertThat(page).hasTitle(Pattern.compile("Playwright")); + } +} +``` + +The `Page page` argument tells JUnit to setup the `page` fixture and provide it to your test method. + +Here is a list of the pre-defined fixtures: + +|Fixture |Type |Description | +|:-------------|:------------------|:--------------------------------| +|page |[Page] |Isolated page for this test run.| +|browserContext|[BrowserContext] |Isolated context for this test run. The `page` fixture belongs to this context as well.| +|browser |[Browser] |Browsers are shared across tests to optimize resources.| +|playwright |[Playwright] |Playwright instance is shared between tests running on the same thread.| +|request |[APIRequestContext]|Isolated APIRequestContext for this test run. Learn how to do [API testing](./api-testing).| + +## Customizing options + +To customize fixture options, you should implement an `OptionsFactory` and specify the class in the `@UsePlaywright()` annotation. + +You can easily override launch options for [`method: BrowserType.launch`], or context options for [`method: Browser.newContext`] and [`method: APIRequest.newContext`]. See the following example: + +```java +import com.microsoft.playwright.junit.Options; +import com.microsoft.playwright.junit.OptionsFactory; +import com.microsoft.playwright.junit.UsePlaywright; + +@UsePlaywright(MyTest.CustomOptions.class) +public class MyTest { + + public static class CustomOptions implements OptionsFactory { + @Override + public Options getOptions() { + return new Options() + .setHeadless(false) + .setContextOption(new Browser.NewContextOptions() + .setBaseURL("https://github.com")) + .setApiRequestOptions(new APIRequest.NewContextOptions() + .setBaseURL("https://playwright.dev")); + } + } + + @Test + public void testWithCustomOptions(Page page, APIRequestContext request) { + page.navigate("/"); + assertThat(page).hasURL(Pattern.compile("github")); + + APIResponse response = request.get("/"); + assertTrue(response.text().contains("Playwright")); + } +} +``` + +## Running Tests in Parallel + +By default JUnit will run all tests sequentially on a single thread. Since JUnit 5.3 you can change this behavior to run tests in parallel +to speed up execution (see [this page](https://junit.org/junit5/docs/snapshot/user-guide/index.html#writing-tests-parallel-execution)). +Since it is not safe to use same Playwright objects from multiple threads without extra synchronization we recommend you create Playwright +instance per thread and use it on that thread exclusively. Here is an example how to run multiple test classes in parallel. + +```java +@UsePlaywright +class Test1 { + @Test + void shouldClickButton(Page page) { + page.navigate("data:text/html,"); + page.locator("button").click(); + assertEquals("Clicked", page.evaluate("result")); + } + + @Test + void shouldCheckTheBox(Page page) { + page.setContent(""); + page.locator("input").check(); + assertEquals(true, page.evaluate("window['checkbox'].checked")); + } + + @Test + void shouldSearchWiki(Page page) { + page.navigate("https://www.wikipedia.org/"); + page.locator("input[name=\"search\"]").click(); + page.locator("input[name=\"search\"]").fill("playwright"); + page.locator("input[name=\"search\"]").press("Enter"); + assertThat(page).hasURL("https://en.wikipedia.org/wiki/Playwright"); + } +} + +@UsePlaywright +class Test2 { + @Test + void shouldReturnInnerHTML(Page page) { + page.setContent("
hello
"); + assertEquals("hello", page.innerHTML("css=div")); + } + + @Test + void shouldClickButton(Page page) { + Page popup = page.waitForPopup(() -> { + page.evaluate("window.open('about:blank');"); + }); + assertEquals("about:blank", popup.url()); + } +} +``` + + +Configure JUnit to run tests in each class sequentially and run multiple classes on parallel threads (with max +number of thread equal to 1/2 of the number of CPU cores): + +```bash +junit.jupiter.execution.parallel.enabled = true +junit.jupiter.execution.parallel.mode.default = same_thread +junit.jupiter.execution.parallel.mode.classes.default = concurrent +junit.jupiter.execution.parallel.config.strategy=dynamic +junit.jupiter.execution.parallel.config.dynamic.factor=0.5 +``` diff --git a/docs/src/network.md b/docs/src/network.md index 438e929e0c..f1fbc620b3 100644 --- a/docs/src/network.md +++ b/docs/src/network.md @@ -550,7 +550,7 @@ await page.RouteAsync("**/*", async route => { }); // Continue requests as POST. -await page.RouteAsync("**/*", async route => await route.ContinueAsync(method: "POST")); +await Page.RouteAsync("**/*", async route => await route.ContinueAsync(new() { Method = "POST" })); ``` You can continue requests with modifications. Example above removes an HTTP header from the outgoing requests. diff --git a/docs/src/release-notes-csharp.md b/docs/src/release-notes-csharp.md index b562b982bd..040b66e218 100644 --- a/docs/src/release-notes-csharp.md +++ b/docs/src/release-notes-csharp.md @@ -4,6 +4,45 @@ title: "Release notes" toc_max_heading_level: 2 --- +## Version 1.42 + +### New Locator Handler + +New method [`method: Page.addLocatorHandler`] registers a callback that will be invoked when specified element becomes visible and may block Playwright actions. The callback can get rid of the overlay. Here is an example that closes a cookie dialog when it appears. + +```csharp +// Setup the handler. +await Page.AddLocatorHandlerAsync( + Page.GetByRole(AriaRole.Heading, new() { Name = "Hej! You are in control of your cookies." }), + async () => + { + await Page.GetByRole(AriaRole.Button, new() { Name = "Accept all" }).ClickAsync(); + }); +// Write the test as usual. +await Page.GotoAsync("https://www.ikea.com/"); +await Page.GetByRole(AriaRole.Link, new() { Name = "Collection of blue and white" }).ClickAsync(); +await Expect(Page.GetByRole(AriaRole.Heading, new() { Name = "Light and easy" })).ToBeVisibleAsync(); +``` + +### New APIs + +- [`method: Page.pdf`] accepts two new options [`option: tagged`] and [`option: outline`]. + +### Announcements + +* ⚠️ Ubuntu 18 is not supported anymore. + +### Browser Versions + +* Chromium 123.0.6312.4 +* Mozilla Firefox 123.0 +* WebKit 17.4 + +This version was also tested against the following stable channels: + +* Google Chrome 122 +* Microsoft Edge 123 + ## Version 1.41 ### New APIs @@ -511,7 +550,7 @@ This version was also tested against the following stable channels: ### New .runsettings file support -`Microsoft.Playwright.NUnit` and `Microsoft.Playwright.MSTest` will now consider the `.runsettings` file and passed settings via the CLI when running end-to-end tests. See in the [documentation](https://playwright.dev/dotnet/docs/test-runners) for a full list of supported settings. +`Microsoft.Playwright.NUnit` and `Microsoft.Playwright.MSTest` will now consider the `.runsettings` file and passed settings via the CLI when running end-to-end tests. See in the [documentation](./test-runners) for a full list of supported settings. The following does now work: @@ -574,7 +613,7 @@ Linux support looks like this: ### New introduction docs -We rewrote our Getting Started docs to be more end-to-end testing focused. Check them out on [playwright.dev](https://playwright.dev/dotnet/docs/intro). +We rewrote our Getting Started docs to be more end-to-end testing focused. Check them out on [playwright.dev](./intro). ## Version 1.23 @@ -952,31 +991,31 @@ This version of Playwright was also tested against the following stable channels ### 🖱️ Mouse Wheel -By using [`Page.Mouse.WheelAsync`](https://playwright.dev/dotnet/docs/next/api/class-mouse#mouse-wheel) you are now able to scroll vertically or horizontally. +By using [`method: Mouse.wheel`] you are now able to scroll vertically or horizontally. ### 📜 New Headers API Previously it was not possible to get multiple header values of a response. This is now possible and additional helper functions are available: -- [Request.AllHeadersAsync()](https://playwright.dev/dotnet/docs/next/api/class-request#request-all-headers) -- [Request.HeadersArrayAsync()](https://playwright.dev/dotnet/docs/next/api/class-request#request-headers-array) -- [Request.HeaderValueAsync(name: string)](https://playwright.dev/dotnet/docs/next/api/class-request#request-header-value) -- [Response.AllHeadersAsync()](https://playwright.dev/dotnet/docs/next/api/class-response#response-all-headers) -- [Response.HeadersArrayAsync()](https://playwright.dev/dotnet/docs/next/api/class-response#response-headers-array) -- [Response.HeaderValueAsync(name: string)](https://playwright.dev/dotnet/docs/next/api/class-response#response-header-value) -- [Response.HeaderValuesAsync(name: string)](https://playwright.dev/dotnet/docs/next/api/class-response#response-header-values) +- [`method: Request.allHeaders`] +- [`method: Request.headersArray`] +- [`method: Request.headerValue`] +- [`method: Response.allHeaders`] +- [`method: Response.headersArray`] +- [`method: Response.headerValue`] +- [`method: Response.headerValues`] ### 🌈 Forced-Colors emulation -Its now possible to emulate the `forced-colors` CSS media feature by passing it in the [context options](https://playwright.dev/dotnet/docs/next/api/class-browser#browser-new-context-option-forced-colors) or calling [Page.EmulateMediaAsync()](https://playwright.dev/dotnet/docs/next/api/class-page#page-emulate-media). +Its now possible to emulate the `forced-colors` CSS media feature by passing it in the [`method: Browser.newContext`] or calling [`method: Page.emulateMedia`]. ### New APIs -- [Page.RouteAsync()](https://playwright.dev/dotnet/docs/next/api/class-page#page-route) accepts new `times` option to specify how many times this route should be matched. -- [Page.SetCheckedAsync(selector: string, checked: Boolean)](https://playwright.dev/dotnet/docs/next/api/class-page#page-set-checked) and [Locator.SetCheckedAsync(selector: string, checked: Boolean)](https://playwright.dev/dotnet/docs/next/api/class-locator#locator-set-checked) was introduced to set the checked state of a checkbox. -- [Request.SizesAsync()](https://playwright.dev/dotnet/docs/next/api/class-request#request-sizes) Returns resource size information for given http request. -- [Tracing.StartChunkAsync()](https://playwright.dev/dotnet/docs/next/api/class-tracing#tracing-start-chunk) - Start a new trace chunk. -- [Tracing.StopChunkAsync()](https://playwright.dev/dotnet/docs/next/api/class-tracing#tracing-stop-chunk) - Stops a new trace chunk. +- [`method: Page.route`] accepts new `times` option to specify how many times this route should be matched. +- [`method: Page.setChecked`] and [`method: Locator.setChecked`] were introduced to set the checked state of a checkbox. +- [`method: Request.sizes`] Returns resource size information for given http request. +- [`method: Tracing.startChunk`] - Start a new trace chunk. +- [`method: Tracing.stopChunk`] - Stops a new trace chunk. ### Important ⚠ * ⬆ .NET Core Apps 2.1 are **no longer** supported for our CLI tooling. As of August 31st, 2021, .NET Core 2.1 is no [longer supported](https://devblogs.microsoft.com/dotnet/net-core-2-1-will-reach-end-of-support-on-august-21-2021/) and will not receive any security updates. We've decided to move the CLI forward and require .NET Core 3.1 as a minimum. diff --git a/docs/src/release-notes-java.md b/docs/src/release-notes-java.md index 387dda000a..3d4df2f980 100644 --- a/docs/src/release-notes-java.md +++ b/docs/src/release-notes-java.md @@ -4,6 +4,126 @@ title: "Release notes" toc_max_heading_level: 2 --- +## Version 1.42 + +### Experimental JUnit integration + +Add new [`@UsePlaywright`](./junit.md) annotation to your test classes to start using Playwright +fixtures for [Page], [BrowserContext], [Browser], [APIRequestContext] and [Playwright] in the +test methods. + +```java +package org.example; + +import com.microsoft.playwright.Page; +import com.microsoft.playwright.junit.UsePlaywright; +import org.junit.jupiter.api.Test; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@UsePlaywright +public class TestExample { + void shouldNavigateToInstallationGuide(Page page) { + page.navigate("https://playwright.dev/java/"); + page.getByRole(AriaRole.LINK, new Page.GetByRoleOptions().setName("Docs")).click(); + assertThat(page.getByRole(AriaRole.HEADING, new Page.GetByRoleOptions().setName("Installation"))).isVisible(); + } + + @Test + void shouldCheckTheBox(Page page) { + page.setContent(""); + page.locator("input").check(); + assertEquals(true, page.evaluate("window['checkbox'].checked")); + } + + @Test + void shouldSearchWiki(Page page) { + page.navigate("https://www.wikipedia.org/"); + page.locator("input[name=\"search\"]").click(); + page.locator("input[name=\"search\"]").fill("playwright"); + page.locator("input[name=\"search\"]").press("Enter"); + assertThat(page).hasURL("https://en.wikipedia.org/wiki/Playwright"); + } +} +``` + +In the example above, all three test methods use the same [Browser]. Each test +uses its own [BrowserContext] and [Page]. + +**Custom options** + +Implement your own `OptionsFactory` to initialize the fixtures with custom configuration. + +```java +import com.microsoft.playwright.junit.Options; +import com.microsoft.playwright.junit.OptionsFactory; +import com.microsoft.playwright.junit.UsePlaywright; + +@UsePlaywright(MyTest.CustomOptions.class) +public class MyTest { + + public static class CustomOptions implements OptionsFactory { + @Override + public Options getOptions() { + return new Options() + .setHeadless(false) + .setContextOption(new Browser.NewContextOptions() + .setBaseURL("https://github.com")) + .setApiRequestOptions(new APIRequest.NewContextOptions() + .setBaseURL("https://playwright.dev")); + } + } + + @Test + public void testWithCustomOptions(Page page, APIRequestContext request) { + page.navigate("/"); + assertThat(page).hasURL(Pattern.compile("github")); + + APIResponse response = request.get("/"); + assertTrue(response.text().contains("Playwright")); + } +} +``` + +Learn more about the fixtures in our [JUnit guide](./junit.md). + +### New Locator Handler + +New method [`method: Page.addLocatorHandler`] registers a callback that will be invoked when specified element becomes visible and may block Playwright actions. The callback can get rid of the overlay. Here is an example that closes a cookie dialog when it appears. + +```java +// Setup the handler. +page.addLocatorHandler( + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Hej! You are in control of your cookies.")), + () - > { + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Accept all")).click(); + }); +// Write the test as usual. +page.navigate("https://www.ikea.com/"); +page.getByRole(AriaRole.LINK, new Page.GetByRoleOptions().setName("Collection of blue and white")).click(); +assertThat(page.getByRole(AriaRole.HEADING, new Page.GetByRoleOptions().setName("Light and easy"))).isVisible(); +``` + +### New APIs + +- [`method: Page.pdf`] accepts two new options [`option: tagged`] and [`option: outline`]. + +### Announcements + +* ⚠️ Ubuntu 18 is not supported anymore. + +### Browser Versions + +* Chromium 123.0.6312.4 +* Mozilla Firefox 123.0 +* WebKit 17.4 + +This version was also tested against the following stable channels: + +* Google Chrome 122 +* Microsoft Edge 123 + ## Version 1.41 ### New APIs @@ -931,31 +1051,31 @@ This version of Playwright was also tested against the following stable channels ### 🖱️ Mouse Wheel -By using [`Mouse.wheel`](https://playwright.dev/java/docs/api/class-mouse#mouse-wheel) you are now able to scroll vertically or horizontally. +By using [`method: Mouse.wheel`] you are now able to scroll vertically or horizontally. ### 📜 New Headers API Previously it was not possible to get multiple header values of a response. This is now possible and additional helper functions are available: -- [Request.allHeaders()](https://playwright.dev/java/docs/api/class-request#request-all-headers) -- [Request.headersArray()](https://playwright.dev/java/docs/api/class-request#request-headers-array) -- [Request.headerValue(name: string)](https://playwright.dev/java/docs/api/class-request#request-header-value) -- [Response.allHeaders()](https://playwright.dev/java/docs/api/class-response#response-all-headers) -- [Response.headersArray()](https://playwright.dev/java/docs/api/class-response#response-headers-array) -- [Response.headerValue(name: string)](https://playwright.dev/java/docs/api/class-response#response-header-value) -- [Response.headerValues(name: string)](https://playwright.dev/java/docs/api/class-response#response-header-values) +- [`method: Request.allHeaders`] +- [`method: Request.headersArray`] +- [`method: Request.headerValue`] +- [`method: Response.allHeaders`] +- [`method: Response.headersArray`] +- [`method: Response.headerValue`] +- [`method: Response.headerValues`] ### 🌈 Forced-Colors emulation -Its now possible to emulate the `forced-colors` CSS media feature by passing it in the [context options](https://playwright.dev/java/docs/api/class-browser#browser-new-context-option-color-scheme) or calling [Page.emulateMedia()](https://playwright.dev/java/docs/api/class-page#page-emulate-media). +Its now possible to emulate the `forced-colors` CSS media feature by passing it in the [`method: Browser.newContext`] or calling [`method: Page.emulateMedia`]. ### New APIs -- [Page.route()](https://playwright.dev/java/docs/api/class-page#page-route) accepts new `times` option to specify how many times this route should be matched. -- [Page.setChecked(selector: string, checked: boolean)](https://playwright.dev/java/docs/api/class-page#page-set-checked) and [Locator.setChecked(selector: string, checked: boolean)](https://playwright.dev/java/docs/api/class-locator#locator-set-checked) was introduced to set the checked state of a checkbox. -- [Request.sizes()](https://playwright.dev/java/docs/api/class-request#request-sizes) Returns resource size information for given http request. -- [Tracing.startChunk()](https://playwright.dev/java/docs/api/class-tracing#tracing-start-chunk) - Start a new trace chunk. -- [Tracing.stopChunk()](https://playwright.dev/java/docs/api/class-tracing#tracing-stop-chunk) - Stops a new trace chunk. +- [`method: Page.route`] accepts new `times` option to specify how many times this route should be matched. +- [`method: Page.setChecked`] and [`method: Locator.setChecked`] were introduced to set the checked state of a checkbox. +- [`method: Request.sizes`] Returns resource size information for given http request. +- [`method: Tracing.startChunk`] - Start a new trace chunk. +- [`method: Tracing.stopChunk`] - Stops a new trace chunk. ### Browser Versions diff --git a/docs/src/release-notes-js.md b/docs/src/release-notes-js.md index c496c20ffd..423dcc9b60 100644 --- a/docs/src/release-notes-js.md +++ b/docs/src/release-notes-js.md @@ -6,6 +6,89 @@ toc_max_heading_level: 2 import LiteYouTube from '@site/src/components/LiteYouTube'; +## Version 1.42 + + + +### New APIs + +- New method [`method: Page.addLocatorHandler`] registers a callback that will be invoked when specified element becomes visible and may block Playwright actions. The callback can get rid of the overlay. Here is an example that closes a cookie dialog when it appears: +```js +// Setup the handler. +await page.addLocatorHandler( + page.getByRole('heading', { name: 'Hej! You are in control of your cookies.' }), + async () => { + await page.getByRole('button', { name: 'Accept all' }).click(); + }); +// Write the test as usual. +await page.goto('https://www.ikea.com/'); +await page.getByRole('link', { name: 'Collection of blue and white' }).click(); +await expect(page.getByRole('heading', { name: 'Light and easy' })).toBeVisible(); +``` + +- `expect(callback).toPass()` timeout can now be configured by `expect.toPass.timeout` option [globally](./api/class-testconfig#test-config-expect) or in [project config](./api/class-testproject#test-project-expect) + +- [`event: ElectronApplication.console`] event is emitted when Electron main process calls console API methods. +```js +electronApp.on('console', async msg => { + const values = []; + for (const arg of msg.args()) + values.push(await arg.jsonValue()); + console.log(...values); +}); +await electronApp.evaluate(() => console.log('hello', 5, { foo: 'bar' })); +``` + +- [New syntax](./test-annotations#tag-tests) for adding tags to the tests (@-tokens in the test title are still supported): +```js +test('test customer login', { + tag: ['@fast', '@login'], +}, async ({ page }) => { + // ... +}); +``` + Use `--grep` command line option to run only tests with certain tags. +```sh +npx playwright test --grep @fast +``` + +- `--project` command line [flag](./test-cli#reference) now supports '*' wildcard: +```sh +npx playwright test --project='*mobile*' +``` + +- [New syntax](./test-annotations#annotate-tests) for test annotations: +```js +test('test full report', { + annotation: [ + { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/23180' }, + { type: 'docs', description: 'https://playwright.dev/docs/test-annotations#tag-tests' }, + ], +}, async ({ page }) => { + // ... +}); +``` + +- [`method: Page.pdf`] accepts two new options [`tagged`](./api/class-page#page-pdf-option-tagged) and [`outline`](./api/class-page#page-pdf-option-outline). + +### Announcements + +* ⚠️ Ubuntu 18 is not supported anymore. + +### Browser Versions + +* Chromium 123.0.6312.4 +* Mozilla Firefox 123.0 +* WebKit 17.4 + +This version was also tested against the following stable channels: + +* Google Chrome 122 +* Microsoft Edge 123 + ## Version 1.41 ### New APIs @@ -1900,31 +1983,31 @@ This version of Playwright was also tested against the following stable channels #### 🖱️ Mouse Wheel -By using [`Page.mouse.wheel`](https://playwright.dev/docs/api/class-mouse#mouse-wheel) you are now able to scroll vertically or horizontally. +By using [`method: Mouse.wheel`] you are now able to scroll vertically or horizontally. #### 📜 New Headers API Previously it was not possible to get multiple header values of a response. This is now possible and additional helper functions are available: -- [Request.allHeaders()](https://playwright.dev/docs/api/class-request#request-all-headers) -- [Request.headersArray()](https://playwright.dev/docs/api/class-request#request-headers-array) -- [Request.headerValue(name: string)](https://playwright.dev/docs/api/class-request#request-header-value) -- [Response.allHeaders()](https://playwright.dev/docs/api/class-response#response-all-headers) -- [Response.headersArray()](https://playwright.dev/docs/api/class-response#response-headers-array) -- [Response.headerValue(name: string)](https://playwright.dev/docs/api/class-response#response-header-value) -- [Response.headerValues(name: string)](https://playwright.dev/docs/api/class-response#response-header-values) +- [`method: Request.allHeaders`] +- [`method: Request.headersArray`] +- [`method: Request.headerValue`] +- [`method: Response.allHeaders`] +- [`method: Response.headersArray`] +- [`method: Response.headerValue`] +- [`method: Response.headerValues`] #### 🌈 Forced-Colors emulation -Its now possible to emulate the `forced-colors` CSS media feature by passing it in the [context options](https://playwright.dev/docs/api/class-browser#browser-new-context-option-forced-colors) or calling [Page.emulateMedia()](https://playwright.dev/docs/api/class-page#page-emulate-media). +Its now possible to emulate the `forced-colors` CSS media feature by passing it in the [`method: Browser.newContext`] or calling [`method: Page.emulateMedia`]. #### New APIs -- [Page.route()](https://playwright.dev/docs/api/class-page#page-route) accepts new `times` option to specify how many times this route should be matched. -- [Page.setChecked(selector: string, checked: boolean)](https://playwright.dev/docs/api/class-page#page-set-checked) and [Locator.setChecked(selector: string, checked: boolean)](https://playwright.dev/docs/api/class-locator#locator-set-checked) was introduced to set the checked state of a checkbox. -- [Request.sizes()](https://playwright.dev/docs/api/class-request#request-sizes) Returns resource size information for given http request. -- [BrowserContext.tracing.startChunk()](https://playwright.dev/docs/api/class-tracing#tracing-start-chunk) - Start a new trace chunk. -- [BrowserContext.tracing.stopChunk()](https://playwright.dev/docs/api/class-tracing#tracing-stop-chunk) - Stops a new trace chunk. +- [`method: Page.route`] accepts new `times` option to specify how many times this route should be matched. +- [`method: Page.setChecked`] and [`method: Locator.setChecked`] were introduced to set the checked state of a checkbox. +- [`method: Request.sizes`] Returns resource size information for given http request. +- [`method: Tracing.startChunk`] - Start a new trace chunk. +- [`method: Tracing.stopChunk`] - Stops a new trace chunk. ### 🎭 Playwright Test @@ -1939,11 +2022,11 @@ test.describe.parallel('group', () => { }); ``` -By default, tests in a single file are run in order. If you have many independent tests in a single file, you can now run them in parallel with [test.describe.parallel(title, callback)](https://playwright.dev/docs/api/class-test#test-describe-parallel). +By default, tests in a single file are run in order. If you have many independent tests in a single file, you can now run them in parallel with [test.describe.parallel(title, callback)](./api/class-test#test-describe-parallel). #### 🛠 Add `--debug` CLI flag -By using `npx playwright test --debug` it will enable the [Playwright Inspector](https://playwright.dev/docs/debug#playwright-inspector) for you to debug your tests. +By using `npx playwright test --debug` it will enable the [Playwright Inspector](./debug#playwright-inspector) for you to debug your tests. ### Browser Versions diff --git a/docs/src/release-notes-python.md b/docs/src/release-notes-python.md index cc92290af1..72481e0284 100644 --- a/docs/src/release-notes-python.md +++ b/docs/src/release-notes-python.md @@ -4,6 +4,43 @@ title: "Release notes" toc_max_heading_level: 2 --- +## Version 1.42 + +### New Locator Handler + +New method [`method: Page.addLocatorHandler`] registers a callback that will be invoked when specified element becomes visible and may block Playwright actions. The callback can get rid of the overlay. Here is an example that closes a cookie dialog when it appears. + +```python +# Setup the handler. +page.add_locator_handler( + page.get_by_role("heading", name="Hej! You are in control of your cookies."), + lambda: page.get_by_role("button", name="Accept all").click(), +) +# Write the test as usual. +page.goto("https://www.ikea.com/") +page.get_by_role("link", name="Collection of blue and white").click() +expect(page.get_by_role("heading", name="Light and easy")).to_be_visible() +``` + +### New APIs + +- [`method: Page.pdf`] accepts two new options [`option: tagged`] and [`option: outline`]. + +### Announcements + +* ⚠️ Ubuntu 18 is not supported anymore. + +### Browser Versions + +* Chromium 123.0.6312.4 +* Mozilla Firefox 123.0 +* WebKit 17.4 + +This version was also tested against the following stable channels: + +* Google Chrome 122 +* Microsoft Edge 123 + ## Version 1.41 ### New APIs @@ -1003,31 +1040,31 @@ This version of Playwright was also tested against the following stable channels ### 🖱️ Mouse Wheel -By using [`Page.mouse.wheel`](https://playwright.dev/python/docs/api/class-mouse#mouse-wheel) you are now able to scroll vertically or horizontally. +By using [`method: Mouse.wheel`] you are now able to scroll vertically or horizontally. ### 📜 New Headers API Previously it was not possible to get multiple header values of a response. This is now possible and additional helper functions are available: -- [Request.all_headers()](https://playwright.dev/python/docs/api/class-request#request-all-headers) -- [Request.headers_array()](https://playwright.dev/python/docs/api/class-request#request-headers-array) -- [Request.header_value(name: str)](https://playwright.dev/python/docs/api/class-request#request-header-value) -- [Response.all_headers()](https://playwright.dev/python/docs/api/class-response#response-all-headers) -- [Response.headers_array()](https://playwright.dev/python/docs/api/class-response#response-headers-array) -- [Response.header_value(name: str)](https://playwright.dev/python/docs/api/class-response#response-header-value) -- [Response.header_values(name: str)](https://playwright.dev/python/docs/api/class-response#response-header-values) +- [`method: Request.allHeaders`] +- [`method: Request.headersArray`] +- [`method: Request.headerValue`] +- [`method: Response.allHeaders`] +- [`method: Response.headersArray`] +- [`method: Response.headerValue`] +- [`method: Response.headerValues`] ### 🌈 Forced-Colors emulation -Its now possible to emulate the `forced-colors` CSS media feature by passing it in the [context options](https://playwright.dev/python/docs/api/class-browser#browser-new-context-option-forced-colors) or calling [Page.emulate_media()](https://playwright.dev/python/docs/api/class-page#page-emulate-media). +Its now possible to emulate the `forced-colors` CSS media feature by passing it in the [`method: Browser.newContext`] or calling [`method: Page.emulateMedia`]. ### New APIs -- [Page.route()](https://playwright.dev/python/docs/api/class-page#page-route) accepts new `times` option to specify how many times this route should be matched. -- [Page.set_checked(selector: str, checked: bool)](https://playwright.dev/python/docs/api/class-page#page-set-checked) and [Locator.set_checked(selector: str, checked: bool)](https://playwright.dev/python/docs/api/class-locator#locator-set-checked) was introduced to set the checked state of a checkbox. -- [Request.sizes()](https://playwright.dev/python/docs/api/class-request#request-sizes) Returns resource size information for given http request. -- [BrowserContext.tracing.start_chunk()](https://playwright.dev/python/docs/api/class-tracing#tracing-start-chunk) - Start a new trace chunk. -- [BrowserContext.tracing.stop_chunk()](https://playwright.dev/python/docs/api/class-tracing#tracing-stop-chunk) - Stops a new trace chunk. +- [`method: Page.route`] accepts new `times` option to specify how many times this route should be matched. +- [`method: Page.setChecked`] and [`method: Locator.setChecked`] were introduced to set the checked state of a checkbox. +- [`method: Request.sizes`] Returns resource size information for given http request. +- [`method: Tracing.startChunk`] - Start a new trace chunk. +- [`method: Tracing.stopChunk`] - Stops a new trace chunk. ### Browser Versions diff --git a/docs/src/running-tests-java.md b/docs/src/running-tests-java.md index d769a73e51..69943f5470 100644 --- a/docs/src/running-tests-java.md +++ b/docs/src/running-tests-java.md @@ -83,6 +83,8 @@ public class TestExample { See [here](./test-runners.md) for further details on how to run tests in parallel, etc. +See experimental [JUnit integration](./junit.md) to automatically initialize Playwright objects and more. + ## What's Next - [Debugging tests](./debug.md) diff --git a/docs/src/service-workers-experimental-network-events-js.md b/docs/src/service-workers-experimental-network-events-js.md index 96b8ead1e5..0928460b54 100644 --- a/docs/src/service-workers-experimental-network-events-js.md +++ b/docs/src/service-workers-experimental-network-events-js.md @@ -137,10 +137,12 @@ self.addEventListener('fetch', event => { (async () => { // 1. Try to first serve directly from caches const response = await caches.match(event.request); - if (response) return response; + if (response) + return response; // 2. Re-write request for /foo to /bar - if (event.request.url.endsWith('foo')) return fetch('./bar'); + if (event.request.url.endsWith('foo')) + return fetch('./bar'); // 3. Prevent tracker.js from being retrieved, and returns a placeholder response if (event.request.url.endsWith('tracker.js')) { diff --git a/docs/src/test-api/class-testinfo.md b/docs/src/test-api/class-testinfo.md index d766920b06..97e9c16ebf 100644 --- a/docs/src/test-api/class-testinfo.md +++ b/docs/src/test-api/class-testinfo.md @@ -210,6 +210,14 @@ Optional description that will be reflected in a test report. Test function as passed to `test(title, testFunction)`. +## property: TestInfo.tags +* since: v1.43 +- type: <[Array]<[string]>> + +Tags that apply to the test. Learn more about [tags](../test-annotations.md#tag-tests). + +Note that any changes made to this list while the test is running will not be visible to test reporters. + ## property: TestInfo.testId * since: v1.32 - type: <[string]> diff --git a/docs/src/test-api/class-testoptions.md b/docs/src/test-api/class-testoptions.md index 946171a935..0fba476d73 100644 --- a/docs/src/test-api/class-testoptions.md +++ b/docs/src/test-api/class-testoptions.md @@ -546,8 +546,8 @@ export default defineConfig({ ## property: TestOptions.trace * since: v1.10 -- type: <[Object]|[TraceMode]<"off"|"on"|"retain-on-failure"|"on-first-retry">> - - `mode` <[TraceMode]<"off"|"on"|"retain-on-failure"|"on-first-retry"|"on-all-retries">> Trace recording mode. +- type: <[Object]|[TraceMode]<"off"|"on"|"retain-on-failure"|"on-first-retry"|"retain-on-first-failure">> + - `mode` <[TraceMode]<"off"|"on"|"retain-on-failure"|"on-first-retry"|"on-all-retries"|"retain-on-first-failure">> Trace recording mode. - `attachments` ?<[boolean]> Whether to include test attachments. Defaults to true. Optional. - `screenshots` ?<[boolean]> Whether to capture screenshots during tracing. Screenshots are used to build a timeline preview. Defaults to true. Optional. - `snapshots` ?<[boolean]> Whether to capture DOM snapshot on every action. Defaults to true. Optional. @@ -559,6 +559,7 @@ Whether to record trace for each test. Defaults to `'off'`. * `'retain-on-failure'`: Record trace for each test, but remove all traces from successful test runs. * `'on-first-retry'`: Record trace only when retrying a test for the first time. * `'on-all-retries'`: Record traces only when retrying for all retries. +* `'retain-on-first-failure'`: Record traces only when the test fails for the first time. For more control, pass an object that specifies `mode` and trace features to enable. diff --git a/docs/src/test-configuration-js.md b/docs/src/test-configuration-js.md index ea598c8c8a..99fd492816 100644 --- a/docs/src/test-configuration-js.md +++ b/docs/src/test-configuration-js.md @@ -147,6 +147,6 @@ export default defineConfig({ | Option | Description | | :- | :- | | [`property: TestConfig.expect`] | [Web first assertions](./test-assertions.md) like `expect(locator).toHaveText()` have a separate timeout of 5 seconds by default. This is the maximum time the `expect()` should wait for the condition to be met. Learn more about [test and expect timeouts](./test-timeouts.md) and how to set them for a single test. | -| [`method: PageAssertions.toHaveScreenshot#1`] | Configuration for the `expect(locator).toHaveScreeshot()` method. | +| [`method: PageAssertions.toHaveScreenshot#1`] | Configuration for the `expect(locator).toHaveScreenshot()` method. | | [`method: SnapshotAssertions.toMatchSnapshot#1`]| Configuration for the `expect(locator).toMatchSnapshot()` method.| diff --git a/docs/src/test-reporters-js.md b/docs/src/test-reporters-js.md index 2673739c9c..ecece58dcd 100644 --- a/docs/src/test-reporters-js.md +++ b/docs/src/test-reporters-js.md @@ -354,6 +354,7 @@ npx playwright test --reporter="./myreporter/my-awesome-reporter.ts" * [Allure](https://www.npmjs.com/package/allure-playwright) * [Argos Visual Testing](https://argos-ci.com/docs/playwright) * [Currents](https://www.npmjs.com/package/@currents/playwright) +* [GitHub Actions Reporter](https://www.npmjs.com/package/@estruyf/github-actions-reporter) * [Monocart](https://github.com/cenfun/monocart-reporter) * [ReportPortal](https://github.com/reportportal/agent-js-playwright) * [Serenity/JS](https://serenity-js.org/handbook/test-runners/playwright-test) diff --git a/docs/src/test-runners-java.md b/docs/src/test-runners-java.md index f9c808b5b7..d29da2e8ba 100644 --- a/docs/src/test-runners-java.md +++ b/docs/src/test-runners-java.md @@ -87,6 +87,8 @@ public class TestExample { } ``` +See experimental [JUnit integration](./junit.md) to automatically initialize Playwright objects and more. + ### Running Tests in Parallel By default JUnit will run all tests sequentially on a single thread. Since JUnit 5.3 you can change this behavior to run tests in parallel diff --git a/docs/src/test-runners-python.md b/docs/src/test-runners-python.md index 402d33987f..0e3ffde0dd 100644 --- a/docs/src/test-runners-python.md +++ b/docs/src/test-runners-python.md @@ -53,14 +53,14 @@ def test_my_app_is_working(fixture_name): **Function scope**: These fixtures are created when requested in a test function and destroyed when the test ends. -- `context`: New [browser context](https://playwright.dev/python/docs/browser-contexts) for a test. -- `page`: New [browser page](https://playwright.dev/python/docs/pages) for a test. +- `context`: New [browser context](./browser-contexts) for a test. +- `page`: New [browser page](./pages) for a test. **Session scope**: These fixtures are created when requested in a test function and destroyed when all tests end. -- `playwright`: [Playwright](https://playwright.dev/python/docs/api/class-playwright) instance. -- `browser_type`: [BrowserType](https://playwright.dev/python/docs/api/class-browsertype) instance of the current browser. -- `browser`: [Browser](https://playwright.dev/python/docs/api/class-browser) instance launched by Playwright. +- `playwright`: [Playwright](./api/class-playwright) instance. +- `browser_type`: [BrowserType](./api/class-browsertype) instance of the current browser. +- `browser`: [Browser](./api/class-browser) instance launched by Playwright. - `browser_name`: Browser name as string. - `browser_channel`: Browser channel as string. - `is_chromium`, `is_webkit`, `is_firefox`: Booleans for the respective browser types. diff --git a/docs/src/test-snapshots-js.md b/docs/src/test-snapshots-js.md index a6e5b98636..d2c7606adf 100644 --- a/docs/src/test-snapshots-js.md +++ b/docs/src/test-snapshots-js.md @@ -44,6 +44,8 @@ The snapshot name `example-test-1-chromium-darwin.png` consists of a few parts: - `chromium-darwin` - the browser name and the platform. Screenshots differ between browsers and platforms due to different rendering, fonts and more, so you will need different snapshots for them. If you use multiple projects in your [configuration file](./test-configuration.md), project name will be used instead of `chromium`. +The snapshot name and path can be configured with [`snapshotPathTemplate`](./api/class-testproject#test-project-snapshot-path-template) in the playwright config. + ## Updating screenshots Sometimes you need to update the reference screenshot, for example when the page has changed. Do this with the `--update-snapshots` flag. diff --git a/docs/src/test-typescript-js.md b/docs/src/test-typescript-js.md index 465f2c2d93..12a1173ea4 100644 --- a/docs/src/test-typescript-js.md +++ b/docs/src/test-typescript-js.md @@ -5,7 +5,26 @@ title: "TypeScript" ## Introduction -Playwright supports TypeScript out of the box. You just write tests in TypeScript, and Playwright will read them, transform to JavaScript and run. +Playwright supports TypeScript out of the box. You just write tests in TypeScript, and Playwright will read them, transform to JavaScript and run. Note that Playwright does not check the types and will run tests even if there are non-critical TypeScript compilation errors. + +We recommend you run TypeScript compiler alongside Playwright. For example on GitHub actions: + +```yaml +jobs: + test: + runs-on: ubuntu-latest + steps: + ... + - name: Run type checks + run: npx tsc -p tsconfig.json --noEmit + - name: Run Playwright tests + run: npx playwright test +``` + +For local development, you can run `tsc` in [watch](https://www.typescriptlang.org/docs/handbook/configuring-watch.html) mode like this: +```sh +npx tsc -p tsconfig.json --noEmit -w +``` ## tsconfig.json diff --git a/package-lock.json b/package-lock.json index 15febb2051..d225f8eafc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "playwright-internal", - "version": "1.42.0-next", + "version": "1.43.0-next", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "playwright-internal", - "version": "1.42.0-next", + "version": "1.43.0-next", "license": "Apache-2.0", "workspaces": [ "packages/*" @@ -8360,10 +8360,10 @@ } }, "packages/playwright": { - "version": "1.42.0-next", + "version": "1.43.0-next", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.42.0-next" + "playwright-core": "1.43.0-next" }, "bin": { "playwright": "cli.js" @@ -8377,11 +8377,11 @@ }, "packages/playwright-browser-chromium": { "name": "@playwright/browser-chromium", - "version": "1.42.0-next", + "version": "1.43.0-next", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.42.0-next" + "playwright-core": "1.43.0-next" }, "engines": { "node": ">=16" @@ -8389,11 +8389,11 @@ }, "packages/playwright-browser-firefox": { "name": "@playwright/browser-firefox", - "version": "1.42.0-next", + "version": "1.43.0-next", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.42.0-next" + "playwright-core": "1.43.0-next" }, "engines": { "node": ">=16" @@ -8401,22 +8401,22 @@ }, "packages/playwright-browser-webkit": { "name": "@playwright/browser-webkit", - "version": "1.42.0-next", + "version": "1.43.0-next", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.42.0-next" + "playwright-core": "1.43.0-next" }, "engines": { "node": ">=16" } }, "packages/playwright-chromium": { - "version": "1.42.0-next", + "version": "1.43.0-next", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.42.0-next" + "playwright-core": "1.43.0-next" }, "bin": { "playwright": "cli.js" @@ -8426,7 +8426,7 @@ } }, "packages/playwright-core": { - "version": "1.42.0-next", + "version": "1.43.0-next", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -8614,11 +8614,11 @@ }, "packages/playwright-ct-core": { "name": "@playwright/experimental-ct-core", - "version": "1.42.0-next", + "version": "1.43.0-next", "license": "Apache-2.0", "dependencies": { - "playwright": "1.42.0-next", - "playwright-core": "1.42.0-next", + "playwright": "1.43.0-next", + "playwright-core": "1.43.0-next", "vite": "^5.0.12" }, "bin": { @@ -8630,15 +8630,14 @@ }, "packages/playwright-ct-react": { "name": "@playwright/experimental-ct-react", - "version": "1.42.0-next", + "version": "1.43.0-next", "license": "Apache-2.0", "dependencies": { - "@playwright/experimental-ct-core": "1.42.0-next", + "@playwright/experimental-ct-core": "1.43.0-next", "@vitejs/plugin-react": "^4.2.1" }, "bin": { - "playwright": "cli.js", - "pw-react": "cli.js" + "playwright": "cli.js" }, "engines": { "node": ">=16" @@ -8646,15 +8645,14 @@ }, "packages/playwright-ct-react17": { "name": "@playwright/experimental-ct-react17", - "version": "1.42.0-next", + "version": "1.43.0-next", "license": "Apache-2.0", "dependencies": { - "@playwright/experimental-ct-core": "1.42.0-next", + "@playwright/experimental-ct-core": "1.43.0-next", "@vitejs/plugin-react": "^4.2.1" }, "bin": { - "playwright": "cli.js", - "pw-react17": "cli.js" + "playwright": "cli.js" }, "engines": { "node": ">=16" @@ -8662,15 +8660,14 @@ }, "packages/playwright-ct-solid": { "name": "@playwright/experimental-ct-solid", - "version": "1.42.0-next", + "version": "1.43.0-next", "license": "Apache-2.0", "dependencies": { - "@playwright/experimental-ct-core": "1.42.0-next", + "@playwright/experimental-ct-core": "1.43.0-next", "vite-plugin-solid": "^2.7.0" }, "bin": { - "playwright": "cli.js", - "pw-solid": "cli.js" + "playwright": "cli.js" }, "devDependencies": { "solid-js": "^1.7.0" @@ -8681,15 +8678,14 @@ }, "packages/playwright-ct-svelte": { "name": "@playwright/experimental-ct-svelte", - "version": "1.42.0-next", + "version": "1.43.0-next", "license": "Apache-2.0", "dependencies": { - "@playwright/experimental-ct-core": "1.42.0-next", + "@playwright/experimental-ct-core": "1.43.0-next", "@sveltejs/vite-plugin-svelte": "^3.0.1" }, "bin": { - "playwright": "cli.js", - "pw-svelte": "cli.js" + "playwright": "cli.js" }, "devDependencies": { "svelte": "^4.2.8" @@ -8700,15 +8696,14 @@ }, "packages/playwright-ct-vue": { "name": "@playwright/experimental-ct-vue", - "version": "1.42.0-next", + "version": "1.43.0-next", "license": "Apache-2.0", "dependencies": { - "@playwright/experimental-ct-core": "1.42.0-next", + "@playwright/experimental-ct-core": "1.43.0-next", "@vitejs/plugin-vue": "^4.2.1" }, "bin": { - "playwright": "cli.js", - "pw-vue": "cli.js" + "playwright": "cli.js" }, "engines": { "node": ">=16" @@ -8716,15 +8711,14 @@ }, "packages/playwright-ct-vue2": { "name": "@playwright/experimental-ct-vue2", - "version": "1.42.0-next", + "version": "1.43.0-next", "license": "Apache-2.0", "dependencies": { - "@playwright/experimental-ct-core": "1.42.0-next", + "@playwright/experimental-ct-core": "1.43.0-next", "@vitejs/plugin-vue2": "^2.2.0" }, "bin": { - "playwright": "cli.js", - "pw-vue2": "cli.js" + "playwright": "cli.js" }, "devDependencies": { "vue": "^2.7.14" @@ -8769,11 +8763,11 @@ } }, "packages/playwright-firefox": { - "version": "1.42.0-next", + "version": "1.43.0-next", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.42.0-next" + "playwright-core": "1.43.0-next" }, "bin": { "playwright": "cli.js" @@ -8784,10 +8778,10 @@ }, "packages/playwright-test": { "name": "@playwright/test", - "version": "1.42.0-next", + "version": "1.43.0-next", "license": "Apache-2.0", "dependencies": { - "playwright": "1.42.0-next" + "playwright": "1.43.0-next" }, "bin": { "playwright": "cli.js" @@ -8797,11 +8791,11 @@ } }, "packages/playwright-webkit": { - "version": "1.42.0-next", + "version": "1.43.0-next", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.42.0-next" + "playwright-core": "1.43.0-next" }, "bin": { "playwright": "cli.js" diff --git a/package.json b/package.json index 89550ec4e4..253206f667 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "playwright-internal", "private": true, - "version": "1.42.0-next", + "version": "1.43.0-next", "description": "A high-level API to automate web browsers", "repository": { "type": "git", @@ -16,9 +16,9 @@ }, "license": "Apache-2.0", "scripts": { - "ctest": "playwright test --config=tests/library/playwright.config.ts --project=chromium", - "ftest": "playwright test --config=tests/library/playwright.config.ts --project=firefox", - "wtest": "playwright test --config=tests/library/playwright.config.ts --project=webkit", + "ctest": "playwright test --config=tests/library/playwright.config.ts --project=chromium-*", + "ftest": "playwright test --config=tests/library/playwright.config.ts --project=firefox-*", + "wtest": "playwright test --config=tests/library/playwright.config.ts --project=webkit-*", "atest": "playwright test --config=tests/android/playwright.config.ts", "etest": "playwright test --config=tests/electron/playwright.config.ts", "webview2test": "playwright test --config=tests/webview2/playwright.config.ts", @@ -29,7 +29,7 @@ "ttest": "node ./tests/playwright-test/stable-test-runner/node_modules/@playwright/test/cli test --config=tests/playwright-test/playwright.config.ts", "ct": "playwright test tests/components/test-all.spec.js --reporter=list", "test": "playwright test --config=tests/library/playwright.config.ts", - "eslint": "eslint --cache --report-unused-disable-directives --ext ts,tsx .", + "eslint": "eslint --cache --report-unused-disable-directives --ext ts,tsx,js,jsx,mjs .", "tsc": "tsc -p .", "build-installer": "babel -s --extensions \".ts\" --out-dir packages/playwright-core/lib/utils/ packages/playwright-core/src/utils", "doc": "node utils/doclint/cli.js", diff --git a/packages/.eslintrc-with-ts-config.js b/packages/.eslintrc-with-ts-config.js deleted file mode 100644 index dea9d4ef41..0000000000 --- a/packages/.eslintrc-with-ts-config.js +++ /dev/null @@ -1,9 +0,0 @@ -module.exports = { - extends: ".eslintrc.js", - rules: { - "@typescript-eslint/no-base-to-string": "error", - }, - parserOptions: { - project: "./tsconfig.json" - }, -}; diff --git a/packages/html-reporter/src/filter.ts b/packages/html-reporter/src/filter.ts index 6f7ef14525..97cf675936 100644 --- a/packages/html-reporter/src/filter.ts +++ b/packages/html-reporter/src/filter.ts @@ -14,7 +14,6 @@ limitations under the License. */ -import { testCaseLabels } from './labelUtils'; import type { TestCaseSummary } from './types'; export class Filter { @@ -108,13 +107,13 @@ export class Filter { if (test.outcome === 'skipped') status = 'skipped'; const searchValues: SearchValues = { - text: (status + ' ' + test.projectName + ' ' + (test.botName || '') + ' ' + test.location.file + ' ' + test.path.join(' ') + ' ' + test.title).toLowerCase(), + text: (status + ' ' + test.projectName + ' ' + test.tags.join(' ') + ' ' + test.location.file + ' ' + test.path.join(' ') + ' ' + test.title).toLowerCase(), project: test.projectName.toLowerCase(), status: status as any, file: test.location.file, line: String(test.location.line), column: String(test.location.column), - labels: testCaseLabels(test).map(label => label.toLowerCase()), + labels: test.tags.map(tag => tag.toLowerCase()), }; (test as any).searchValues = searchValues; } diff --git a/packages/html-reporter/src/labelUtils.tsx b/packages/html-reporter/src/labelUtils.tsx index 57d9c43c9a..014ec77d59 100644 --- a/packages/html-reporter/src/labelUtils.tsx +++ b/packages/html-reporter/src/labelUtils.tsx @@ -14,22 +14,6 @@ * limitations under the License. */ -import type { TestCaseSummary } from './types'; - -const labelsSymbol = Symbol('labels'); - -// Note: all labels start with "@" -export function testCaseLabels(test: TestCaseSummary): string[] { - if (!(test as any)[labelsSymbol]) { - const labels: string[] = []; - if (test.botName) - labels.push('@' + test.botName); - labels.push(...test.tags); - (test as any)[labelsSymbol] = labels; - } - return (test as any)[labelsSymbol]; -} - // hash string to integer in range [0, 6] for color index, to get same color for same tag export function hashStringToInt(str: string) { let hash = 0; diff --git a/packages/html-reporter/src/testCaseView.tsx b/packages/html-reporter/src/testCaseView.tsx index 4b79908edf..eecbdac9f5 100644 --- a/packages/html-reporter/src/testCaseView.tsx +++ b/packages/html-reporter/src/testCaseView.tsx @@ -23,7 +23,7 @@ import { ProjectLink } from './links'; import { statusIcon } from './statusIcon'; import './testCaseView.css'; import { TestResultView } from './testResultView'; -import { hashStringToInt, testCaseLabels } from './labelUtils'; +import { hashStringToInt } from './labelUtils'; import { msToString } from './uiUtils'; export const TestCaseView: React.FC<{ @@ -37,7 +37,7 @@ export const TestCaseView: React.FC<{ const labels = React.useMemo(() => { if (!test) return undefined; - return testCaseLabels(test); + return test.tags; }, [test]); return
diff --git a/packages/html-reporter/src/testFileView.tsx b/packages/html-reporter/src/testFileView.tsx index 619d6263d2..4f508c42b8 100644 --- a/packages/html-reporter/src/testFileView.tsx +++ b/packages/html-reporter/src/testFileView.tsx @@ -23,7 +23,7 @@ import { generateTraceUrl, Link, navigate, ProjectLink } from './links'; import { statusIcon } from './statusIcon'; import './testFileView.css'; import { video, image, trace } from './icons'; -import { hashStringToInt, testCaseLabels } from './labelUtils'; +import { hashStringToInt } from './labelUtils'; export const TestFileView: React.FC {report.projectNames.length > 1 && !!test.projectName && } - +
{msToString(test.duration)} diff --git a/packages/playwright-browser-chromium/package.json b/packages/playwright-browser-chromium/package.json index 4c16717df4..e4ce3d992f 100644 --- a/packages/playwright-browser-chromium/package.json +++ b/packages/playwright-browser-chromium/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/browser-chromium", - "version": "1.42.0-next", + "version": "1.43.0-next", "description": "Playwright package that automatically installs Chromium", "repository": { "type": "git", @@ -27,6 +27,6 @@ "install": "node install.js" }, "dependencies": { - "playwright-core": "1.42.0-next" + "playwright-core": "1.43.0-next" } } diff --git a/packages/playwright-browser-firefox/package.json b/packages/playwright-browser-firefox/package.json index 1c100b2fce..866216d956 100644 --- a/packages/playwright-browser-firefox/package.json +++ b/packages/playwright-browser-firefox/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/browser-firefox", - "version": "1.42.0-next", + "version": "1.43.0-next", "description": "Playwright package that automatically installs Firefox", "repository": { "type": "git", @@ -27,6 +27,6 @@ "install": "node install.js" }, "dependencies": { - "playwright-core": "1.42.0-next" + "playwright-core": "1.43.0-next" } } diff --git a/packages/playwright-browser-webkit/package.json b/packages/playwright-browser-webkit/package.json index 270c601bd9..4e64a3aa48 100644 --- a/packages/playwright-browser-webkit/package.json +++ b/packages/playwright-browser-webkit/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/browser-webkit", - "version": "1.42.0-next", + "version": "1.43.0-next", "description": "Playwright package that automatically installs WebKit", "repository": { "type": "git", @@ -27,6 +27,6 @@ "install": "node install.js" }, "dependencies": { - "playwright-core": "1.42.0-next" + "playwright-core": "1.43.0-next" } } diff --git a/packages/playwright-chromium/cli.js b/packages/playwright-chromium/cli.js index 86adb86a84..f46d8b409e 100755 --- a/packages/playwright-chromium/cli.js +++ b/packages/playwright-chromium/cli.js @@ -15,5 +15,5 @@ * limitations under the License. */ -const { program } = require('playwright-core/lib/program'); +const { program } = require('playwright-core/lib/cli/program'); program.parse(process.argv); diff --git a/packages/playwright-chromium/package.json b/packages/playwright-chromium/package.json index 5f7431d84a..c39bc5031b 100644 --- a/packages/playwright-chromium/package.json +++ b/packages/playwright-chromium/package.json @@ -1,6 +1,6 @@ { "name": "playwright-chromium", - "version": "1.42.0-next", + "version": "1.43.0-next", "description": "A high-level API to automate Chromium", "repository": { "type": "git", @@ -30,6 +30,6 @@ "install": "node install.js" }, "dependencies": { - "playwright-core": "1.42.0-next" + "playwright-core": "1.43.0-next" } } diff --git a/packages/playwright-core/.eslintrc.js b/packages/playwright-core/.eslintrc.js index ae8768db65..84888f1ae3 100644 --- a/packages/playwright-core/.eslintrc.js +++ b/packages/playwright-core/.eslintrc.js @@ -1,3 +1,3 @@ module.exports = { - extends: "../.eslintrc-with-ts-config.js", + extends: "../../.eslintrc-with-ts-config.js", }; diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index bf862ae74c..beaf0e4901 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -3,43 +3,37 @@ "browsers": [ { "name": "chromium", - "revision": "1105", + "revision": "1110", "installByDefault": true, - "browserVersion": "123.0.6312.4" - }, - { - "name": "chromium-with-symbols", - "revision": "1105", - "installByDefault": false, - "browserVersion": "123.0.6312.4" + "browserVersion": "124.0.6367.8" }, { "name": "chromium-tip-of-tree", - "revision": "1195", + "revision": "1204", "installByDefault": false, - "browserVersion": "123.0.6312.0" + "browserVersion": "125.0.6370.0" }, { "name": "firefox", - "revision": "1440", + "revision": "1445", "installByDefault": true, "browserVersion": "123.0" }, { "name": "firefox-asan", - "revision": "1440", + "revision": "1445", "installByDefault": false, "browserVersion": "123.0" }, { "name": "firefox-beta", - "revision": "1440", + "revision": "1444", "installByDefault": false, "browserVersion": "124.0b3" }, { "name": "webkit", - "revision": "1983", + "revision": "1990", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", diff --git a/packages/playwright-core/package.json b/packages/playwright-core/package.json index c846a750e7..769dd27737 100644 --- a/packages/playwright-core/package.json +++ b/packages/playwright-core/package.json @@ -1,6 +1,6 @@ { "name": "playwright-core", - "version": "1.42.0-next", + "version": "1.43.0-next", "description": "A high-level API to automate web browsers", "repository": { "type": "git", diff --git a/packages/playwright-core/src/cli/program.ts b/packages/playwright-core/src/cli/program.ts index c38683a832..ad943c049e 100644 --- a/packages/playwright-core/src/cli/program.ts +++ b/packages/playwright-core/src/cli/program.ts @@ -23,8 +23,8 @@ import type { Command } from '../utilsBundle'; import { program } from '../utilsBundle'; export { program } from '../utilsBundle'; import { runDriver, runServer, printApiJson, launchBrowserServer } from './driver'; -import type { OpenTraceViewerOptions } from '../server/trace/viewer/traceViewer'; -import { openTraceInBrowser, openTraceViewerApp } from '../server/trace/viewer/traceViewer'; +import { runTraceInBrowser, runTraceViewerApp } from '../server/trace/viewer/traceViewer'; +import type { TraceViewerServerOptions } from '../server/trace/viewer/traceViewer'; import * as playwright from '../..'; import type { BrowserContext } from '../client/browserContext'; import type { Browser } from '../client/browser'; @@ -305,19 +305,16 @@ program if (options.browser === 'wk') options.browser = 'webkit'; - const openOptions: OpenTraceViewerOptions = { - headless: false, + const openOptions: TraceViewerServerOptions = { host: options.host, port: +options.port, isServer: !!options.stdin, }; - if (options.port !== undefined || options.host !== undefined) { - openTraceInBrowser(traces, openOptions).catch(logErrorAndExit); - } else { - openTraceViewerApp(traces, options.browser, openOptions).then(page => { - page.on('close', () => gracefullyProcessExitDoNotHang(0)); - }).catch(logErrorAndExit); - } + + if (options.port !== undefined || options.host !== undefined) + runTraceInBrowser(traces, openOptions).catch(logErrorAndExit); + else + runTraceViewerApp(traces, options.browser, openOptions, true).catch(logErrorAndExit); }).addHelpText('afterAll', ` Examples: diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 6f32385ee8..37d6b6744f 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -269,6 +269,10 @@ export class BrowserContext extends ChannelOwner await this._channel.clearCookies(); } + async removeCookies(filter: network.RemoveNetworkCookieParam): Promise { + await this._channel.removeCookies({ filter }); + } + async grantPermissions(permissions: string[], options?: { origin?: string }): Promise { await this._channel.grantPermissions({ permissions, ...options }); } @@ -526,7 +530,7 @@ export async function prepareBrowserContextParams(options: BrowserContextOptions function toAcceptDownloadsProtocol(acceptDownloads?: boolean) { if (acceptDownloads === undefined) return undefined; - if (acceptDownloads === true) + if (acceptDownloads) return 'accept'; return 'deny'; } diff --git a/packages/playwright-core/src/client/jsHandle.ts b/packages/playwright-core/src/client/jsHandle.ts index ad03609e40..bb85894870 100644 --- a/packages/playwright-core/src/client/jsHandle.ts +++ b/packages/playwright-core/src/client/jsHandle.ts @@ -19,6 +19,7 @@ import { ChannelOwner } from './channelOwner'; import { parseSerializedValue, serializeValue } from '../protocol/serializers'; import type * as api from '../../types/types'; import type * as structs from '../../types/structs'; +import { isTargetClosedError } from './errors'; export class JSHandle extends ChannelOwner implements api.JSHandle { private _preview: string; @@ -68,7 +69,13 @@ export class JSHandle extends ChannelOwner im } async dispose() { - return await this._channel.dispose(); + try { + await this._channel.dispose(); + } catch (e) { + if (isTargetClosedError(e)) + return; + throw e; + } } async _objectCount() { diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 36ded11194..a24286e71e 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -192,6 +192,10 @@ export class Locator implements api.Locator { return await this._frame.$$(this._selector); } + enterFrame() { + return new FrameLocator(this._frame, this._selector); + } + first(): Locator { return new Locator(this._frame, this._selector + ' >> nth=0'); } @@ -404,6 +408,10 @@ export class FrameLocator implements api.FrameLocator { return this.locator(getByRoleSelector(role, options)); } + exitFrame() { + return new Locator(this._frame, this._frameSelector); + } + frameLocator(selector: string): FrameLocator { return new FrameLocator(this._frame, this._frameSelector + ' >> internal:control=enter-frame >> ' + selector); } diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index 6f35fdbc70..f58048655e 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -58,6 +58,12 @@ export type SetNetworkCookieParam = { sameSite?: 'Strict' | 'Lax' | 'None' }; +export type RemoveNetworkCookieParam = { + name?: string, + domain?: string, + path?: string, +}; + type SerializedFallbackOverrides = { url?: string; method?: string; @@ -135,7 +141,7 @@ export class Request extends ChannelOwner implements ap return null; const contentType = this.headers()['content-type']; - if (contentType === 'application/x-www-form-urlencoded') { + if (contentType?.includes('application/x-www-form-urlencoded')) { const entries: Record = {}; const parsed = new URLSearchParams(postData); for (const [k, v] of parsed.entries()) diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index f33a3891b6..4a8f7fb3d1 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -828,6 +828,14 @@ scheme.BrowserContextAddInitScriptParams = tObject({ scheme.BrowserContextAddInitScriptResult = tOptional(tObject({})); scheme.BrowserContextClearCookiesParams = tOptional(tObject({})); scheme.BrowserContextClearCookiesResult = tOptional(tObject({})); +scheme.BrowserContextRemoveCookiesParams = tObject({ + filter: tObject({ + name: tOptional(tString), + domain: tOptional(tString), + path: tOptional(tString), + }), +}); +scheme.BrowserContextRemoveCookiesResult = tOptional(tObject({})); scheme.BrowserContextClearPermissionsParams = tOptional(tObject({})); scheme.BrowserContextClearPermissionsResult = tOptional(tObject({})); scheme.BrowserContextCloseParams = tObject({ diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 0bc14f45e1..2a5dfe765f 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -276,6 +276,22 @@ export abstract class BrowserContext extends SdkObject { return await this.doGetCookies(urls as string[]); } + async removeCookies(filter: {name?: string, domain?: string, path?: string}): Promise { + if (!filter.name && !filter.domain && !filter.path) + throw new Error(`Either name, domain or path are required`); + + const currentCookies = await this.cookies(); + + const cookiesToKeep = currentCookies.filter(cookie => { + return !((!filter.name || filter.name === cookie.name) && + (!filter.domain || filter.domain === cookie.domain) && + (!filter.path || filter.path === cookie.path)); + }); + + await this.clearCookies(); + await this.addCookies(cookiesToKeep); + } + setHTTPCredentials(httpCredentials?: types.Credentials): Promise { return this.doSetHTTPCredentials(httpCredentials); } @@ -468,14 +484,34 @@ export abstract class BrowserContext extends SdkObject { cookies: await this.cookies(), origins: [] }; - if (this._origins.size) { + const originsToSave = new Set(this._origins); + + // First try collecting storage stage from existing pages. + for (const page of this.pages()) { + const origin = page.mainFrame().origin(); + if (!origin || !originsToSave.has(origin)) + continue; + try { + const storage = await page.mainFrame().nonStallingEvaluateInExistingContext(`({ + localStorage: Object.keys(localStorage).map(name => ({ name, value: localStorage.getItem(name) })), + })`, false, 'utility'); + if (storage.localStorage.length) + result.origins.push({ origin, localStorage: storage.localStorage } as channels.OriginStorage); + originsToSave.delete(origin); + } catch { + // When failed on the live page, we'll retry on the blank page below. + } + } + + // If there are still origins to save, create a blank page to iterate over origins. + if (originsToSave.size) { const internalMetadata = serverSideCallMetadata(); const page = await this.newPage(internalMetadata); await page._setServerRequestInterceptor(handler => { handler.fulfill({ body: '', requestUrl: handler.request().url() }).catch(() => {}); return true; }); - for (const origin of this._origins) { + for (const origin of originsToSave) { const originStorage: channels.OriginStorage = { origin, localStorage: [] }; const frame = page.mainFrame(); await frame.goto(internalMetadata, origin); diff --git a/packages/playwright-core/src/server/chromium/crBrowser.ts b/packages/playwright-core/src/server/chromium/crBrowser.ts index 0217005427..9af7e6ba9d 100644 --- a/packages/playwright-core/src/server/chromium/crBrowser.ts +++ b/packages/playwright-core/src/server/chromium/crBrowser.ts @@ -562,7 +562,7 @@ export class CRBrowserContext extends BrowserContext { override async clearCache(): Promise { for (const page of this._crPages()) - await page._mainFrameSession._networkManager.clearCache(); + await page._networkManager.clearCache(); } async cancelDownload(guid: string) { @@ -594,7 +594,8 @@ export class CRBrowserContext extends BrowserContext { targetId = (page._delegate as CRPage)._targetId; } else if (page instanceof Frame) { const session = (page._page._delegate as CRPage)._sessions.get(page._id); - if (!session) throw new Error(`This frame does not have a separate CDP session, it is a part of the parent frame's session`); + if (!session) + throw new Error(`This frame does not have a separate CDP session, it is a part of the parent frame's session`); targetId = session._targetId; } else { throw new Error('page: expected Page or Frame'); diff --git a/packages/playwright-core/src/server/chromium/crNetworkManager.ts b/packages/playwright-core/src/server/chromium/crNetworkManager.ts index f3952b82fd..afe753046a 100644 --- a/packages/playwright-core/src/server/chromium/crNetworkManager.ts +++ b/packages/playwright-core/src/server/chromium/crNetworkManager.ts @@ -26,70 +26,88 @@ import type * as contexts from '../browserContext'; import type * as frames from '../frames'; import type * as types from '../types'; import type { CRPage } from './crPage'; -import { assert, headersObjectToArray } from '../../utils'; +import { assert, headersArrayToObject, headersObjectToArray } from '../../utils'; import type { CRServiceWorker } from './crServiceWorker'; -import { isProtocolError } from '../protocolError'; +import { isProtocolError, isSessionClosedError } from '../protocolError'; type SessionInfo = { session: CRSession; + isMain?: boolean; workerFrame?: frames.Frame; + eventListeners: RegisteredListener[]; }; export class CRNetworkManager { - private _session: CRSession; private _page: Page | null; private _serviceWorker: CRServiceWorker | null; - private _parentManager: CRNetworkManager | null; private _requestIdToRequest = new Map(); private _requestIdToRequestWillBeSentEvent = new Map(); private _credentials: {origin?: string, username: string, password: string} | null = null; private _attemptedAuthentications = new Set(); private _userRequestInterceptionEnabled = false; private _protocolRequestInterceptionEnabled = false; + private _offline = false; + private _extraHTTPHeaders: types.HeadersArray = []; private _requestIdToRequestPausedEvent = new Map(); - private _eventListeners: RegisteredListener[]; private _responseExtraInfoTracker = new ResponseExtraInfoTracker(); + private _sessions = new Map(); - constructor(session: CRSession, page: Page | null, serviceWorker: CRServiceWorker | null, parentManager: CRNetworkManager | null) { - this._session = session; + constructor(page: Page | null, serviceWorker: CRServiceWorker | null) { this._page = page; this._serviceWorker = serviceWorker; - this._parentManager = parentManager; - this._eventListeners = this.instrumentNetworkEvents({ session }); } - instrumentNetworkEvents(sessionInfo: SessionInfo): RegisteredListener[] { - const listeners = [ - eventsHelper.addEventListener(sessionInfo.session, 'Fetch.requestPaused', this._onRequestPaused.bind(this, sessionInfo)), - eventsHelper.addEventListener(sessionInfo.session, 'Fetch.authRequired', this._onAuthRequired.bind(this)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.requestWillBeSent', this._onRequestWillBeSent.bind(this, sessionInfo)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.requestWillBeSentExtraInfo', this._onRequestWillBeSentExtraInfo.bind(this)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.requestServedFromCache', this._onRequestServedFromCache.bind(this)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.responseReceived', this._onResponseReceived.bind(this, sessionInfo)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.responseReceivedExtraInfo', this._onResponseReceivedExtraInfo.bind(this)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.loadingFinished', this._onLoadingFinished.bind(this)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.loadingFailed', this._onLoadingFailed.bind(this, sessionInfo)), + async addSession(session: CRSession, workerFrame?: frames.Frame, isMain?: boolean) { + const sessionInfo: SessionInfo = { session, isMain, workerFrame, eventListeners: [] }; + sessionInfo.eventListeners = [ + eventsHelper.addEventListener(session, 'Fetch.requestPaused', this._onRequestPaused.bind(this, sessionInfo)), + eventsHelper.addEventListener(session, 'Fetch.authRequired', this._onAuthRequired.bind(this, sessionInfo)), + eventsHelper.addEventListener(session, 'Network.requestWillBeSent', this._onRequestWillBeSent.bind(this, sessionInfo)), + eventsHelper.addEventListener(session, 'Network.requestWillBeSentExtraInfo', this._onRequestWillBeSentExtraInfo.bind(this)), + eventsHelper.addEventListener(session, 'Network.requestServedFromCache', this._onRequestServedFromCache.bind(this)), + eventsHelper.addEventListener(session, 'Network.responseReceived', this._onResponseReceived.bind(this, sessionInfo)), + eventsHelper.addEventListener(session, 'Network.responseReceivedExtraInfo', this._onResponseReceivedExtraInfo.bind(this)), + eventsHelper.addEventListener(session, 'Network.loadingFinished', this._onLoadingFinished.bind(this, sessionInfo)), + eventsHelper.addEventListener(session, 'Network.loadingFailed', this._onLoadingFailed.bind(this, sessionInfo)), ]; if (this._page) { - listeners.push(...[ - eventsHelper.addEventListener(sessionInfo.session, 'Network.webSocketCreated', e => this._page!._frameManager.onWebSocketCreated(e.requestId, e.url)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.webSocketWillSendHandshakeRequest', e => this._page!._frameManager.onWebSocketRequest(e.requestId)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.webSocketHandshakeResponseReceived', e => this._page!._frameManager.onWebSocketResponse(e.requestId, e.response.status, e.response.statusText)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page!._frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page!._frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.webSocketClosed', e => this._page!._frameManager.webSocketClosed(e.requestId)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.webSocketFrameError', e => this._page!._frameManager.webSocketError(e.requestId, e.errorMessage)), + sessionInfo.eventListeners.push(...[ + eventsHelper.addEventListener(session, 'Network.webSocketCreated', e => this._page!._frameManager.onWebSocketCreated(e.requestId, e.url)), + eventsHelper.addEventListener(session, 'Network.webSocketWillSendHandshakeRequest', e => this._page!._frameManager.onWebSocketRequest(e.requestId)), + eventsHelper.addEventListener(session, 'Network.webSocketHandshakeResponseReceived', e => this._page!._frameManager.onWebSocketResponse(e.requestId, e.response.status, e.response.statusText)), + eventsHelper.addEventListener(session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page!._frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData)), + eventsHelper.addEventListener(session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page!._frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData)), + eventsHelper.addEventListener(session, 'Network.webSocketClosed', e => this._page!._frameManager.webSocketClosed(e.requestId)), + eventsHelper.addEventListener(session, 'Network.webSocketFrameError', e => this._page!._frameManager.webSocketError(e.requestId, e.errorMessage)), ]); } - return listeners; + this._sessions.set(session, sessionInfo); + await Promise.all([ + session.send('Network.enable'), + this._updateProtocolRequestInterceptionForSession(sessionInfo, true /* initial */), + this._setOfflineForSession(sessionInfo, true /* initial */), + this._setExtraHTTPHeadersForSession(sessionInfo, true /* initial */), + ]); } - async initialize() { - await this._session.send('Network.enable'); + removeSession(session: CRSession) { + const info = this._sessions.get(session); + if (info) + eventsHelper.removeEventListeners(info.eventListeners); + this._sessions.delete(session); } - dispose() { - eventsHelper.removeEventListeners(this._eventListeners); + private async _forEachSession(cb: (sessionInfo: SessionInfo) => Promise) { + await Promise.all([...this._sessions.values()].map(info => { + if (info.isMain) + return cb(info); + return cb(info).catch(e => { + // Broadcasting a message to the closed target should be a noop. + if (isSessionClosedError(e)) + return; + throw e; + }); + })); } async authenticate(credentials: types.Credentials | null) { @@ -98,8 +116,20 @@ export class CRNetworkManager { } async setOffline(offline: boolean) { - await this._session.send('Network.emulateNetworkConditions', { - offline, + if (offline === this._offline) + return; + this._offline = offline; + await this._forEachSession(info => this._setOfflineForSession(info)); + } + + private async _setOfflineForSession(info: SessionInfo, initial?: boolean) { + if (initial && !this._offline) + return; + // Workers are affected by the owner frame's Network.emulateNetworkConditions. + if (info.workerFrame) + return; + await info.session.send('Network.emulateNetworkConditions', { + offline: this._offline, // values of 0 remove any active throttling. crbug.com/456324#c9 latency: 0, downloadThroughput: -1, @@ -117,28 +147,46 @@ export class CRNetworkManager { if (enabled === this._protocolRequestInterceptionEnabled) return; this._protocolRequestInterceptionEnabled = enabled; - if (enabled) { - await Promise.all([ - this._session.send('Network.setCacheDisabled', { cacheDisabled: true }), - this._session.send('Fetch.enable', { - handleAuthRequests: true, - patterns: [{ urlPattern: '*', requestStage: 'Request' }], - }), - ]); - } else { - await Promise.all([ - this._session.send('Network.setCacheDisabled', { cacheDisabled: false }), - this._session.send('Fetch.disable') - ]); + await this._forEachSession(info => this._updateProtocolRequestInterceptionForSession(info)); + } + + private async _updateProtocolRequestInterceptionForSession(info: SessionInfo, initial?: boolean) { + const enabled = this._protocolRequestInterceptionEnabled; + if (initial && !enabled) + return; + const cachePromise = info.session.send('Network.setCacheDisabled', { cacheDisabled: enabled }); + let fetchPromise = Promise.resolve(undefined); + if (!info.workerFrame) { + if (enabled) + fetchPromise = info.session.send('Fetch.enable', { handleAuthRequests: true, patterns: [{ urlPattern: '*', requestStage: 'Request' }] }); + else + fetchPromise = info.session.send('Fetch.disable'); } + await Promise.all([cachePromise, fetchPromise]); + } + + async setExtraHTTPHeaders(extraHTTPHeaders: types.HeadersArray) { + if (!this._extraHTTPHeaders.length && !extraHTTPHeaders.length) + return; + this._extraHTTPHeaders = extraHTTPHeaders; + await this._forEachSession(info => this._setExtraHTTPHeadersForSession(info)); + } + + private async _setExtraHTTPHeadersForSession(info: SessionInfo, initial?: boolean) { + if (initial && !this._extraHTTPHeaders.length) + return; + await info.session.send('Network.setExtraHTTPHeaders', { headers: headersArrayToObject(this._extraHTTPHeaders, false /* lowerCase */) }); } async clearCache() { - // Sending 'Network.setCacheDisabled' with 'cacheDisabled = true' will clear the MemoryCache. - await this._session.send('Network.setCacheDisabled', { cacheDisabled: true }); - if (!this._protocolRequestInterceptionEnabled) - await this._session.send('Network.setCacheDisabled', { cacheDisabled: false }); - await this._session.send('Network.clearBrowserCache'); + await this._forEachSession(async info => { + // Sending 'Network.setCacheDisabled' with 'cacheDisabled = true' will clear the MemoryCache. + await info.session.send('Network.setCacheDisabled', { cacheDisabled: true }); + if (!this._protocolRequestInterceptionEnabled) + await info.session.send('Network.setCacheDisabled', { cacheDisabled: false }); + if (!info.workerFrame) + await info.session.send('Network.clearBrowserCache'); + }); } _onRequestWillBeSent(sessionInfo: SessionInfo, event: Protocol.Network.requestWillBeSentPayload) { @@ -165,7 +213,7 @@ export class CRNetworkManager { this._responseExtraInfoTracker.requestWillBeSentExtraInfo(event); } - _onAuthRequired(event: Protocol.Fetch.authRequiredPayload) { + _onAuthRequired(sessionInfo: SessionInfo, event: Protocol.Fetch.authRequiredPayload) { let response: 'Default' | 'CancelAuth' | 'ProvideCredentials' = 'Default'; const shouldProvideCredentials = this._shouldProvideCredentials(event.request.url); if (this._attemptedAuthentications.has(event.requestId)) { @@ -175,7 +223,7 @@ export class CRNetworkManager { this._attemptedAuthentications.add(event.requestId); } const { username, password } = shouldProvideCredentials && this._credentials ? this._credentials : { username: undefined, password: undefined }; - this._session._sendMayFail('Fetch.continueWithAuth', { + sessionInfo.session._sendMayFail('Fetch.continueWithAuth', { requestId: event.requestId, authChallengeResponse: { response, username, password }, }); @@ -191,7 +239,7 @@ export class CRNetworkManager { if (!event.networkId) { // Fetch without networkId means that request was not recognized by inspector, and // it will never receive Network.requestWillBeSent. Continue the request to not affect it. - this._session._sendMayFail('Fetch.continueRequest', { requestId: event.requestId }); + sessionInfo.session._sendMayFail('Fetch.continueRequest', { requestId: event.requestId }); return; } if (event.request.url.startsWith('data:')) @@ -215,7 +263,7 @@ export class CRNetworkManager { // // Note: make sure not to prematurely continue the redirect, which shares the // `networkId` between the original request and the redirect. - this._session._sendMayFail('Fetch.continueRequest', { + sessionInfo.session._sendMayFail('Fetch.continueRequest', { ...alreadyContinuedParams, requestId: event.requestId, }); @@ -266,7 +314,7 @@ export class CRNetworkManager { ]; if (requestHeaders['Access-Control-Request-Headers']) responseHeaders.push({ name: 'Access-Control-Allow-Headers', value: requestHeaders['Access-Control-Request-Headers'] }); - this._session._sendMayFail('Fetch.fulfillRequest', { + sessionInfo.session._sendMayFail('Fetch.fulfillRequest', { requestId: requestPausedEvent.requestId, responseCode: 204, responsePhrase: network.STATUS_TEXTS['204'], @@ -279,7 +327,7 @@ export class CRNetworkManager { // Non-service-worker requests MUST have a frame—if they don't, we pretend there was no request if (!frame && !this._serviceWorker) { if (requestPausedEvent) - this._session._sendMayFail('Fetch.continueRequest', { requestId: requestPausedEvent.requestId }); + sessionInfo.session._sendMayFail('Fetch.continueRequest', { requestId: requestPausedEvent.requestId }); return; } @@ -289,9 +337,9 @@ export class CRNetworkManager { if (redirectedFrom || (!this._userRequestInterceptionEnabled && this._protocolRequestInterceptionEnabled)) { // Chromium does not preserve header overrides between redirects, so we have to do it ourselves. const headers = redirectedFrom?._originalRequestRoute?._alreadyContinuedParams?.headers; - this._session._sendMayFail('Fetch.continueRequest', { requestId: requestPausedEvent.requestId, headers }); + sessionInfo.session._sendMayFail('Fetch.continueRequest', { requestId: requestPausedEvent.requestId, headers }); } else { - route = new RouteImpl(this._session, requestPausedEvent.requestId); + route = new RouteImpl(sessionInfo.session, requestPausedEvent.requestId); } } const isNavigationRequest = requestWillBeSentEvent.requestId === requestWillBeSentEvent.loaderId && requestWillBeSentEvent.type === 'Document'; @@ -426,16 +474,15 @@ export class CRNetworkManager { (this._page?._frameManager || this._serviceWorker)!.requestReceivedResponse(response); } - _onLoadingFinished(event: Protocol.Network.loadingFinishedPayload) { + _onLoadingFinished(sessionInfo: SessionInfo, event: Protocol.Network.loadingFinishedPayload) { this._responseExtraInfoTracker.loadingFinished(event); - let request = this._requestIdToRequest.get(event.requestId); - if (!request) - request = this._maybeAdoptMainRequest(event.requestId); + const request = this._requestIdToRequest.get(event.requestId); // For certain requestIds we never receive requestWillBeSent event. // @see https://crbug.com/750469 if (!request) return; + this._maybeUpdateOOPIFMainRequest(sessionInfo, request); // Under certain conditions we never get the Network.responseReceived // event from protocol. @see https://crbug.com/883475 @@ -453,8 +500,6 @@ export class CRNetworkManager { this._responseExtraInfoTracker.loadingFailed(event); let request = this._requestIdToRequest.get(event.requestId); - if (!request) - request = this._maybeAdoptMainRequest(event.requestId); if (!request) { const requestWillBeSentEvent = this._requestIdToRequestWillBeSentEvent.get(event.requestId); @@ -472,33 +517,27 @@ export class CRNetworkManager { // @see https://crbug.com/750469 if (!request) return; + this._maybeUpdateOOPIFMainRequest(sessionInfo, request); const response = request.request._existingResponse(); if (response) { response.setTransferSize(null); response.setEncodedBodySize(null); response._requestFinished(helper.secondsToRoundishMillis(event.timestamp - request._timestamp)); + } else { + // Loading failed before response has arrived - there will be no extra info events. + request.request.setRawRequestHeaders(null); } this._deleteRequest(request); - request.request._setFailureText(event.errorText); + request.request._setFailureText(event.errorText || event.blockedReason || ''); (this._page?._frameManager || this._serviceWorker)!.requestFailed(request.request, !!event.canceled); } - private _maybeAdoptMainRequest(requestId: Protocol.Network.RequestId): InterceptableRequest | undefined { + private _maybeUpdateOOPIFMainRequest(sessionInfo: SessionInfo, request: InterceptableRequest) { // OOPIF has a main request that starts in the parent session but finishes in the child session. - if (!this._parentManager) - return; - const request = this._parentManager._requestIdToRequest.get(requestId); - // Main requests have matching loaderId and requestId. - if (!request || request._documentId !== requestId) - return; - this._requestIdToRequest.set(requestId, request); - request.session = this._session; - this._parentManager._requestIdToRequest.delete(requestId); - if (request._interceptionId && this._parentManager._attemptedAuthentications.has(request._interceptionId)) { - this._parentManager._attemptedAuthentications.delete(request._interceptionId); - this._attemptedAuthentications.add(request._interceptionId); - } - return request; + // We check for the main request by matching loaderId and requestId, and if it now belongs to + // a child session, migrate it there. + if (request.session !== sessionInfo.session && !sessionInfo.isMain && request._documentId === request._requestId) + request.session = sessionInfo.session; } } diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index 2c0d3f94b4..2828991e11 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -20,7 +20,7 @@ import type { RegisteredListener } from '../../utils/eventsHelper'; import { eventsHelper } from '../../utils/eventsHelper'; import { registry } from '../registry'; import { rewriteErrorMessage } from '../../utils/stackTrace'; -import { assert, createGuid, headersArrayToObject } from '../../utils'; +import { assert, createGuid } from '../../utils'; import * as dialog from '../dialog'; import * as dom from '../dom'; import * as frames from '../frames'; @@ -61,6 +61,7 @@ export class CRPage implements PageDelegate { readonly rawTouchscreen: RawTouchscreenImpl; readonly _targetId: string; readonly _opener: CRPage | null; + readonly _networkManager: CRNetworkManager; private readonly _pdf: CRPDF; private readonly _coverage: CRCoverage; readonly _browserContext: CRBrowserContext; @@ -92,6 +93,13 @@ export class CRPage implements PageDelegate { this._coverage = new CRCoverage(client); this._browserContext = browserContext; this._page = new Page(this, browserContext); + this._networkManager = new CRNetworkManager(this._page, null); + // Sync any browser context state to the network manager. This does not talk over CDP because + // we have not connected any sessions to the network manager yet. + this.updateOffline(); + this.updateExtraHTTPHeaders(); + this.updateHttpCredentials(); + this.updateRequestInterception(); this._mainFrameSession = new FrameSession(this, client, targetId, null); this._sessions.set(targetId, this._mainFrameSession); if (opener && !browserContext._options.noDefaultViewport) { @@ -184,7 +192,11 @@ export class CRPage implements PageDelegate { } async updateExtraHTTPHeaders(): Promise { - await this._forAllFrameSessions(frame => frame._updateExtraHTTPHeaders(false)); + const headers = network.mergeHeaders([ + this._browserContext._options.extraHTTPHeaders, + this._page.extraHTTPHeaders() + ]); + await this._networkManager.setExtraHTTPHeaders(headers); } async updateGeolocation(): Promise { @@ -192,11 +204,11 @@ export class CRPage implements PageDelegate { } async updateOffline(): Promise { - await this._forAllFrameSessions(frame => frame._updateOffline(false)); + await this._networkManager.setOffline(!!this._browserContext._options.offline); } async updateHttpCredentials(): Promise { - await this._forAllFrameSessions(frame => frame._updateHttpCredentials(false)); + await this._networkManager.authenticate(this._browserContext._options.httpCredentials || null); } async updateEmulatedViewportSize(preserveWindowBoundaries?: boolean): Promise { @@ -216,7 +228,7 @@ export class CRPage implements PageDelegate { } async updateRequestInterception(): Promise { - await this._forAllFrameSessions(frame => frame._updateRequestInterception()); + await this._networkManager.setRequestInterception(this._page.needsRequestInterception()); } async updateFileChooserInterception() { @@ -333,7 +345,7 @@ export class CRPage implements PageDelegate { injected.setInputFiles(node, files), files); } - async setInputFilePaths(progress: Progress, handle: dom.ElementHandle, files: string[]): Promise { + async setInputFilePaths(handle: dom.ElementHandle, files: string[]): Promise { const frame = await handle.ownerFrame(); if (!frame) throw new Error('Cannot set input files to detached input element'); @@ -392,7 +404,6 @@ class FrameSession { readonly _client: CRSession; readonly _crPage: CRPage; readonly _page: Page; - readonly _networkManager: CRNetworkManager; private readonly _parentSession: FrameSession | null; private readonly _childSessions = new Set(); private readonly _contextIdToContext = new Map(); @@ -418,7 +429,6 @@ class FrameSession { this._crPage = crPage; this._page = crPage._page; this._targetId = targetId; - this._networkManager = new CRNetworkManager(client, this._page, null, parentSession ? parentSession._networkManager : null); this._parentSession = parentSession; if (parentSession) parentSession._childSessions.add(this); @@ -533,7 +543,7 @@ class FrameSession { source: '', worldName: UTILITY_WORLD_NAME, }), - this._networkManager.initialize(), + this._crPage._networkManager.addSession(this._client, undefined, this._isMainFrame()), this._client.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }), ]; if (!isSettingStorageState) { @@ -559,10 +569,6 @@ class FrameSession { if (!this._crPage._browserContext._browser.options.headful) promises.push(this._setDefaultFontFamilies(this._client)); promises.push(this._updateGeolocation(true)); - promises.push(this._updateExtraHTTPHeaders(true)); - promises.push(this._updateRequestInterception()); - promises.push(this._updateOffline(true)); - promises.push(this._updateHttpCredentials(true)); promises.push(this._updateEmulateMedia()); promises.push(this._updateFileChooserInterception(true)); for (const binding of this._crPage._page.allBindings()) @@ -586,7 +592,7 @@ class FrameSession { if (this._parentSession) this._parentSession._childSessions.delete(this); eventsHelper.removeEventListeners(this._eventListeners); - this._networkManager.dispose(); + this._crPage._networkManager.removeSession(this._client); this._crPage._sessions.delete(this._targetId); this._client.dispose(); } @@ -752,7 +758,8 @@ class FrameSession { }); // This might fail if the target is closed before we initialize. session._sendMayFail('Runtime.enable'); - session._sendMayFail('Network.enable'); + // TODO: attribute workers to the right frame. + this._crPage._networkManager.addSession(session, this._page._frameManager.frame(this._targetId) ?? undefined).catch(() => {}); session._sendMayFail('Runtime.runIfWaitingForDebugger'); session._sendMayFail('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }); session.on('Target.attachedToTarget', event => this._onAttachedToTarget(event)); @@ -762,8 +769,6 @@ class FrameSession { this._page._addConsoleMessage(event.type, args, toConsoleMessageLocation(event.stackTrace)); }); session.on('Runtime.exceptionThrown', exception => this._page.emitOnContextOnceInitialized(BrowserContext.Events.PageError, exceptionToError(exception.exceptionDetails), this._page)); - // TODO: attribute workers to the right frame. - this._networkManager.instrumentNetworkEvents({ session, workerFrame: this._page._frameManager.frame(this._targetId) ?? undefined }); } _onDetachedFromTarget(event: Protocol.Target.detachedFromTargetPayload) { @@ -981,33 +986,12 @@ class FrameSession { await this._client._sendMayFail('Page.stopScreencast'); } - async _updateExtraHTTPHeaders(initial: boolean): Promise { - const headers = network.mergeHeaders([ - this._crPage._browserContext._options.extraHTTPHeaders, - this._page.extraHTTPHeaders() - ]); - if (!initial || headers.length) - await this._client.send('Network.setExtraHTTPHeaders', { headers: headersArrayToObject(headers, false /* lowerCase */) }); - } - async _updateGeolocation(initial: boolean): Promise { const geolocation = this._crPage._browserContext._options.geolocation; if (!initial || geolocation) await this._client.send('Emulation.setGeolocationOverride', geolocation || {}); } - async _updateOffline(initial: boolean): Promise { - const offline = !!this._crPage._browserContext._options.offline; - if (!initial || offline) - await this._networkManager.setOffline(offline); - } - - async _updateHttpCredentials(initial: boolean): Promise { - const credentials = this._crPage._browserContext._options.httpCredentials || null; - if (!initial || credentials) - await this._networkManager.authenticate(credentials); - } - async _updateViewport(preserveWindowBoundaries?: boolean): Promise { if (this._crPage._browserContext._browser.isClank()) return; @@ -1106,10 +1090,6 @@ class FrameSession { await session.send('Page.setFontFamilies', fontFamilies); } - async _updateRequestInterception(): Promise { - await this._networkManager.setRequestInterception(this._page.needsRequestInterception()); - } - async _updateFileChooserInterception(initial: boolean) { const enabled = this._page.fileChooserIntercepted(); if (initial && !enabled) diff --git a/packages/playwright-core/src/server/chromium/crServiceWorker.ts b/packages/playwright-core/src/server/chromium/crServiceWorker.ts index 11d6385356..4de63fac55 100644 --- a/packages/playwright-core/src/server/chromium/crServiceWorker.ts +++ b/packages/playwright-core/src/server/chromium/crServiceWorker.ts @@ -34,13 +34,13 @@ export class CRServiceWorker extends Worker { this._session = session; this._browserContext = browserContext; if (!!process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS) - this._networkManager = new CRNetworkManager(session, null, this, null); + this._networkManager = new CRNetworkManager(null, this); session.once('Runtime.executionContextCreated', event => { this._createExecutionContext(new CRExecutionContext(session, event.context)); }); if (this._networkManager && this._isNetworkInspectionEnabled()) { - this._networkManager.initialize().catch(() => {}); + this._networkManager.addSession(session, undefined, true /* isMain */).catch(() => {}); this.updateRequestInterception(); this.updateExtraHTTPHeaders(true); this.updateHttpCredentials(true); @@ -56,6 +56,7 @@ export class CRServiceWorker extends Worker { } override didClose() { + this._networkManager?.removeSession(this._session); this._session.dispose(); super.didClose(); } diff --git a/packages/playwright-core/src/server/chromium/protocol.d.ts b/packages/playwright-core/src/server/chromium/protocol.d.ts index 83b8d235ce..0ea475d12e 100644 --- a/packages/playwright-core/src/server/chromium/protocol.d.ts +++ b/packages/playwright-core/src/server/chromium/protocol.d.ts @@ -831,7 +831,7 @@ CORS RFC1918 enforcement. resourceIPAddressSpace?: Network.IPAddressSpace; clientSecurityState?: Network.ClientSecurityState; } - export type AttributionReportingIssueType = "PermissionPolicyDisabled"|"UntrustworthyReportingOrigin"|"InsecureContext"|"InvalidHeader"|"InvalidRegisterTriggerHeader"|"SourceAndTriggerHeaders"|"SourceIgnored"|"TriggerIgnored"|"OsSourceIgnored"|"OsTriggerIgnored"|"InvalidRegisterOsSourceHeader"|"InvalidRegisterOsTriggerHeader"|"WebAndOsHeaders"|"NoWebOrOsSupport"|"NavigationRegistrationWithoutTransientUserActivation"; + export type AttributionReportingIssueType = "PermissionPolicyDisabled"|"UntrustworthyReportingOrigin"|"InsecureContext"|"InvalidHeader"|"InvalidRegisterTriggerHeader"|"SourceAndTriggerHeaders"|"SourceIgnored"|"TriggerIgnored"|"OsSourceIgnored"|"OsTriggerIgnored"|"InvalidRegisterOsSourceHeader"|"InvalidRegisterOsTriggerHeader"|"WebAndOsHeaders"|"NoWebOrOsSupport"|"NavigationRegistrationWithoutTransientUserActivation"|"InvalidInfoHeader"|"NoRegisterSourceHeader"|"NoRegisterTriggerHeader"|"NoRegisterOsSourceHeader"|"NoRegisterOsTriggerHeader"; /** * Details for issues around "Attribution Reporting API" usage. Explainer: https://github.com/WICG/attribution-reporting-api @@ -2493,6 +2493,28 @@ stylesheet rules) this rule came from. */ tryRules: CSSTryRule[]; } + /** + * CSS @position-try rule representation. + */ + export interface CSSPositionTryRule { + /** + * The prelude dashed-ident name + */ + name: Value; + /** + * The css style sheet identifier (absent for user agent stylesheet and user-specified +stylesheet rules) this rule came from. + */ + styleSheetId?: StyleSheetId; + /** + * Parent stylesheet's origin. + */ + origin: StyleSheetOrigin; + /** + * Associated style declaration. + */ + style: CSSStyle; + } /** * CSS keyframes rule representation. */ @@ -2820,6 +2842,10 @@ attributes) for a DOM node identified by `nodeId`. * A list of CSS position fallbacks matching this node. */ cssPositionFallbackRules?: CSSPositionFallbackRule[]; + /** + * A list of CSS @position-try rules matching this node, based on the position-try-options property. + */ + cssPositionTryRules?: CSSPositionTryRule[]; /** * A list of CSS at-property rules matching this node. */ @@ -2882,6 +2908,17 @@ the full layer tree for the tree scope and their ordering. export type getLayersForNodeReturnValue = { rootLayer: CSSLayerData; } + /** + * Given a CSS selector text and a style sheet ID, getLocationForSelector +returns an array of locations of the CSS selector in the style sheet. + */ + export type getLocationForSelectorParameters = { + styleSheetId: StyleSheetId; + selectorText: string; + } + export type getLocationForSelectorReturnValue = { + ranges: SourceRange[]; + } /** * Starts tracking the given computed styles for updates. The specified array of properties replaces the one previously specified. Pass empty array to disable tracking. @@ -8052,6 +8089,7 @@ milliseconds relatively to this requestTime. headers: Headers; /** * HTTP POST request data. +Use postDataEntries instead. */ postData?: string; /** @@ -8059,7 +8097,7 @@ milliseconds relatively to this requestTime. */ hasPostData?: boolean; /** - * Request body elements. This will be converted from base64 to binary + * Request body elements (post data broken into individual entries). */ postDataEntries?: PostDataEntry[]; /** @@ -9787,6 +9825,18 @@ matches provided URL. * Connection type if known. */ connectionType?: ConnectionType; + /** + * WebRTC packet loss (percent, 0-100). 0 disables packet loss emulation, 100 drops all the packets. + */ + packetLoss?: number; + /** + * WebRTC packet queue length (packet). 0 removes any queue length limitations. + */ + packetQueueLength?: number; + /** + * WebRTC packetReordering feature. + */ + packetReordering?: boolean; } export type emulateNetworkConditionsReturnValue = { } @@ -11065,7 +11115,7 @@ as an ad. * All Permissions Policy features. This enum should match the one defined in third_party/blink/renderer/core/permissions_policy/permissions_policy_features.json5. */ - export type PermissionsPolicyFeature = "accelerometer"|"ambient-light-sensor"|"attribution-reporting"|"autoplay"|"bluetooth"|"browsing-topics"|"camera"|"captured-surface-control"|"ch-dpr"|"ch-device-memory"|"ch-downlink"|"ch-ect"|"ch-prefers-color-scheme"|"ch-prefers-reduced-motion"|"ch-prefers-reduced-transparency"|"ch-rtt"|"ch-save-data"|"ch-ua"|"ch-ua-arch"|"ch-ua-bitness"|"ch-ua-platform"|"ch-ua-model"|"ch-ua-mobile"|"ch-ua-form-factor"|"ch-ua-full-version"|"ch-ua-full-version-list"|"ch-ua-platform-version"|"ch-ua-wow64"|"ch-viewport-height"|"ch-viewport-width"|"ch-width"|"clipboard-read"|"clipboard-write"|"compute-pressure"|"cross-origin-isolated"|"direct-sockets"|"display-capture"|"document-domain"|"encrypted-media"|"execution-while-out-of-viewport"|"execution-while-not-rendered"|"focus-without-user-activation"|"fullscreen"|"frobulate"|"gamepad"|"geolocation"|"gyroscope"|"hid"|"identity-credentials-get"|"idle-detection"|"interest-cohort"|"join-ad-interest-group"|"keyboard-map"|"local-fonts"|"magnetometer"|"microphone"|"midi"|"otp-credentials"|"payment"|"picture-in-picture"|"private-aggregation"|"private-state-token-issuance"|"private-state-token-redemption"|"publickey-credentials-create"|"publickey-credentials-get"|"run-ad-auction"|"screen-wake-lock"|"serial"|"shared-autofill"|"shared-storage"|"shared-storage-select-url"|"smart-card"|"speaker-selection"|"storage-access"|"sub-apps"|"sync-xhr"|"unload"|"usb"|"usb-unrestricted"|"vertical-scroll"|"web-printing"|"web-share"|"window-management"|"window-placement"|"xr-spatial-tracking"; + export type PermissionsPolicyFeature = "accelerometer"|"ambient-light-sensor"|"attribution-reporting"|"autoplay"|"bluetooth"|"browsing-topics"|"camera"|"captured-surface-control"|"ch-dpr"|"ch-device-memory"|"ch-downlink"|"ch-ect"|"ch-prefers-color-scheme"|"ch-prefers-reduced-motion"|"ch-prefers-reduced-transparency"|"ch-rtt"|"ch-save-data"|"ch-ua"|"ch-ua-arch"|"ch-ua-bitness"|"ch-ua-platform"|"ch-ua-model"|"ch-ua-mobile"|"ch-ua-form-factors"|"ch-ua-full-version"|"ch-ua-full-version-list"|"ch-ua-platform-version"|"ch-ua-wow64"|"ch-viewport-height"|"ch-viewport-width"|"ch-width"|"clipboard-read"|"clipboard-write"|"compute-pressure"|"cross-origin-isolated"|"direct-sockets"|"display-capture"|"document-domain"|"encrypted-media"|"execution-while-out-of-viewport"|"execution-while-not-rendered"|"focus-without-user-activation"|"fullscreen"|"frobulate"|"gamepad"|"geolocation"|"gyroscope"|"hid"|"identity-credentials-get"|"idle-detection"|"interest-cohort"|"join-ad-interest-group"|"keyboard-map"|"local-fonts"|"magnetometer"|"microphone"|"midi"|"otp-credentials"|"payment"|"picture-in-picture"|"private-aggregation"|"private-state-token-issuance"|"private-state-token-redemption"|"publickey-credentials-create"|"publickey-credentials-get"|"run-ad-auction"|"screen-wake-lock"|"serial"|"shared-autofill"|"shared-storage"|"shared-storage-select-url"|"smart-card"|"speaker-selection"|"storage-access"|"sub-apps"|"sync-xhr"|"unload"|"usb"|"usb-unrestricted"|"vertical-scroll"|"web-printing"|"web-share"|"window-management"|"window-placement"|"xr-spatial-tracking"; /** * Reason for a permissions policy feature to be disabled. */ @@ -11534,7 +11584,7 @@ Example URLs: http://www.google.com/file.html -> "google.com" /** * List of not restored reasons for back-forward cache. */ - export type BackForwardCacheNotRestoredReason = "NotPrimaryMainFrame"|"BackForwardCacheDisabled"|"RelatedActiveContentsExist"|"HTTPStatusNotOK"|"SchemeNotHTTPOrHTTPS"|"Loading"|"WasGrantedMediaAccess"|"DisableForRenderFrameHostCalled"|"DomainNotAllowed"|"HTTPMethodNotGET"|"SubframeIsNavigating"|"Timeout"|"CacheLimit"|"JavaScriptExecution"|"RendererProcessKilled"|"RendererProcessCrashed"|"SchedulerTrackedFeatureUsed"|"ConflictingBrowsingInstance"|"CacheFlushed"|"ServiceWorkerVersionActivation"|"SessionRestored"|"ServiceWorkerPostMessage"|"EnteredBackForwardCacheBeforeServiceWorkerHostAdded"|"RenderFrameHostReused_SameSite"|"RenderFrameHostReused_CrossSite"|"ServiceWorkerClaim"|"IgnoreEventAndEvict"|"HaveInnerContents"|"TimeoutPuttingInCache"|"BackForwardCacheDisabledByLowMemory"|"BackForwardCacheDisabledByCommandLine"|"NetworkRequestDatapipeDrainedAsBytesConsumer"|"NetworkRequestRedirected"|"NetworkRequestTimeout"|"NetworkExceedsBufferLimit"|"NavigationCancelledWhileRestoring"|"NotMostRecentNavigationEntry"|"BackForwardCacheDisabledForPrerender"|"UserAgentOverrideDiffers"|"ForegroundCacheLimit"|"BrowsingInstanceNotSwapped"|"BackForwardCacheDisabledForDelegate"|"UnloadHandlerExistsInMainFrame"|"UnloadHandlerExistsInSubFrame"|"ServiceWorkerUnregistration"|"CacheControlNoStore"|"CacheControlNoStoreCookieModified"|"CacheControlNoStoreHTTPOnlyCookieModified"|"NoResponseHead"|"Unknown"|"ActivationNavigationsDisallowedForBug1234857"|"ErrorDocument"|"FencedFramesEmbedder"|"CookieDisabled"|"HTTPAuthRequired"|"CookieFlushed"|"WebSocket"|"WebTransport"|"WebRTC"|"MainResourceHasCacheControlNoStore"|"MainResourceHasCacheControlNoCache"|"SubresourceHasCacheControlNoStore"|"SubresourceHasCacheControlNoCache"|"ContainsPlugins"|"DocumentLoaded"|"OutstandingNetworkRequestOthers"|"RequestedMIDIPermission"|"RequestedAudioCapturePermission"|"RequestedVideoCapturePermission"|"RequestedBackForwardCacheBlockedSensors"|"RequestedBackgroundWorkPermission"|"BroadcastChannel"|"WebXR"|"SharedWorker"|"WebLocks"|"WebHID"|"WebShare"|"RequestedStorageAccessGrant"|"WebNfc"|"OutstandingNetworkRequestFetch"|"OutstandingNetworkRequestXHR"|"AppBanner"|"Printing"|"WebDatabase"|"PictureInPicture"|"Portal"|"SpeechRecognizer"|"IdleManager"|"PaymentManager"|"SpeechSynthesis"|"KeyboardLock"|"WebOTPService"|"OutstandingNetworkRequestDirectSocket"|"InjectedJavascript"|"InjectedStyleSheet"|"KeepaliveRequest"|"IndexedDBEvent"|"Dummy"|"JsNetworkRequestReceivedCacheControlNoStoreResource"|"WebRTCSticky"|"WebTransportSticky"|"WebSocketSticky"|"SmartCard"|"LiveMediaStreamTrack"|"UnloadHandler"|"ContentSecurityHandler"|"ContentWebAuthenticationAPI"|"ContentFileChooser"|"ContentSerial"|"ContentFileSystemAccess"|"ContentMediaDevicesDispatcherHost"|"ContentWebBluetooth"|"ContentWebUSB"|"ContentMediaSessionService"|"ContentScreenReader"|"EmbedderPopupBlockerTabHelper"|"EmbedderSafeBrowsingTriggeredPopupBlocker"|"EmbedderSafeBrowsingThreatDetails"|"EmbedderAppBannerManager"|"EmbedderDomDistillerViewerSource"|"EmbedderDomDistillerSelfDeletingRequestDelegate"|"EmbedderOomInterventionTabHelper"|"EmbedderOfflinePage"|"EmbedderChromePasswordManagerClientBindCredentialManager"|"EmbedderPermissionRequestManager"|"EmbedderModalDialog"|"EmbedderExtensions"|"EmbedderExtensionMessaging"|"EmbedderExtensionMessagingForOpenPort"|"EmbedderExtensionSentMessageToCachedFrame"; + export type BackForwardCacheNotRestoredReason = "NotPrimaryMainFrame"|"BackForwardCacheDisabled"|"RelatedActiveContentsExist"|"HTTPStatusNotOK"|"SchemeNotHTTPOrHTTPS"|"Loading"|"WasGrantedMediaAccess"|"DisableForRenderFrameHostCalled"|"DomainNotAllowed"|"HTTPMethodNotGET"|"SubframeIsNavigating"|"Timeout"|"CacheLimit"|"JavaScriptExecution"|"RendererProcessKilled"|"RendererProcessCrashed"|"SchedulerTrackedFeatureUsed"|"ConflictingBrowsingInstance"|"CacheFlushed"|"ServiceWorkerVersionActivation"|"SessionRestored"|"ServiceWorkerPostMessage"|"EnteredBackForwardCacheBeforeServiceWorkerHostAdded"|"RenderFrameHostReused_SameSite"|"RenderFrameHostReused_CrossSite"|"ServiceWorkerClaim"|"IgnoreEventAndEvict"|"HaveInnerContents"|"TimeoutPuttingInCache"|"BackForwardCacheDisabledByLowMemory"|"BackForwardCacheDisabledByCommandLine"|"NetworkRequestDatapipeDrainedAsBytesConsumer"|"NetworkRequestRedirected"|"NetworkRequestTimeout"|"NetworkExceedsBufferLimit"|"NavigationCancelledWhileRestoring"|"NotMostRecentNavigationEntry"|"BackForwardCacheDisabledForPrerender"|"UserAgentOverrideDiffers"|"ForegroundCacheLimit"|"BrowsingInstanceNotSwapped"|"BackForwardCacheDisabledForDelegate"|"UnloadHandlerExistsInMainFrame"|"UnloadHandlerExistsInSubFrame"|"ServiceWorkerUnregistration"|"CacheControlNoStore"|"CacheControlNoStoreCookieModified"|"CacheControlNoStoreHTTPOnlyCookieModified"|"NoResponseHead"|"Unknown"|"ActivationNavigationsDisallowedForBug1234857"|"ErrorDocument"|"FencedFramesEmbedder"|"CookieDisabled"|"HTTPAuthRequired"|"CookieFlushed"|"BroadcastChannelOnMessage"|"WebSocket"|"WebTransport"|"WebRTC"|"MainResourceHasCacheControlNoStore"|"MainResourceHasCacheControlNoCache"|"SubresourceHasCacheControlNoStore"|"SubresourceHasCacheControlNoCache"|"ContainsPlugins"|"DocumentLoaded"|"OutstandingNetworkRequestOthers"|"RequestedMIDIPermission"|"RequestedAudioCapturePermission"|"RequestedVideoCapturePermission"|"RequestedBackForwardCacheBlockedSensors"|"RequestedBackgroundWorkPermission"|"BroadcastChannel"|"WebXR"|"SharedWorker"|"WebLocks"|"WebHID"|"WebShare"|"RequestedStorageAccessGrant"|"WebNfc"|"OutstandingNetworkRequestFetch"|"OutstandingNetworkRequestXHR"|"AppBanner"|"Printing"|"WebDatabase"|"PictureInPicture"|"Portal"|"SpeechRecognizer"|"IdleManager"|"PaymentManager"|"SpeechSynthesis"|"KeyboardLock"|"WebOTPService"|"OutstandingNetworkRequestDirectSocket"|"InjectedJavascript"|"InjectedStyleSheet"|"KeepaliveRequest"|"IndexedDBEvent"|"Dummy"|"JsNetworkRequestReceivedCacheControlNoStoreResource"|"WebRTCSticky"|"WebTransportSticky"|"WebSocketSticky"|"SmartCard"|"LiveMediaStreamTrack"|"UnloadHandler"|"ParserAborted"|"ContentSecurityHandler"|"ContentWebAuthenticationAPI"|"ContentFileChooser"|"ContentSerial"|"ContentFileSystemAccess"|"ContentMediaDevicesDispatcherHost"|"ContentWebBluetooth"|"ContentWebUSB"|"ContentMediaSessionService"|"ContentScreenReader"|"EmbedderPopupBlockerTabHelper"|"EmbedderSafeBrowsingTriggeredPopupBlocker"|"EmbedderSafeBrowsingThreatDetails"|"EmbedderAppBannerManager"|"EmbedderDomDistillerViewerSource"|"EmbedderDomDistillerSelfDeletingRequestDelegate"|"EmbedderOomInterventionTabHelper"|"EmbedderOfflinePage"|"EmbedderChromePasswordManagerClientBindCredentialManager"|"EmbedderPermissionRequestManager"|"EmbedderModalDialog"|"EmbedderExtensions"|"EmbedderExtensionMessaging"|"EmbedderExtensionMessagingForOpenPort"|"EmbedderExtensionSentMessageToCachedFrame"; /** * Types of not restored reasons for back-forward cache. */ @@ -11811,7 +11861,7 @@ open. */ type: DialogType; /** - * True if browser is capable showing or acting on the given dialog. When browser has no + * True iff browser is capable showing or acting on the given dialog. When browser has no dialog handler for given target, calling alert while Page domain is engaged will stall the page execution. Execution can be resumed via calling Page.handleJavaScriptDialog. */ @@ -13533,34 +13583,10 @@ Tokens from that issuer. * Enum of network fetches auctions can do. */ export type InterestGroupAuctionFetchType = "bidderJs"|"bidderWasm"|"sellerJs"|"bidderTrustedSignals"|"sellerTrustedSignals"; - /** - * Ad advertising element inside an interest group. - */ - export interface InterestGroupAd { - renderURL: string; - metadata?: string; - } - /** - * The full details of an interest group. - */ - export interface InterestGroupDetails { - ownerOrigin: string; - name: string; - expirationTime: Network.TimeSinceEpoch; - joiningOrigin: string; - biddingLogicURL?: string; - biddingWasmHelperURL?: string; - updateURL?: string; - trustedBiddingSignalsURL?: string; - trustedBiddingSignalsKeys: string[]; - userBiddingSignals?: string; - ads: InterestGroupAd[]; - adComponents: InterestGroupAd[]; - } /** * Enum of shared storage access types. */ - export type SharedStorageAccessType = "documentAddModule"|"documentSelectURL"|"documentRun"|"documentSet"|"documentAppend"|"documentDelete"|"documentClear"|"workletSet"|"workletAppend"|"workletDelete"|"workletClear"|"workletGet"|"workletKeys"|"workletEntries"|"workletLength"|"workletRemainingBudget"; + export type SharedStorageAccessType = "documentAddModule"|"documentSelectURL"|"documentRun"|"documentSet"|"documentAppend"|"documentDelete"|"documentClear"|"documentGet"|"workletSet"|"workletAppend"|"workletDelete"|"workletClear"|"workletGet"|"workletKeys"|"workletEntries"|"workletLength"|"workletRemainingBudget"|"headerSet"|"headerAppend"|"headerDelete"|"headerClear"; /** * Struct for a single key-value pair in an origin's shared storage. */ @@ -13644,22 +13670,28 @@ SharedStorageAccessType.documentAppend, SharedStorageAccessType.documentDelete, SharedStorageAccessType.workletSet, SharedStorageAccessType.workletAppend, -SharedStorageAccessType.workletDelete, and -SharedStorageAccessType.workletGet. +SharedStorageAccessType.workletDelete, +SharedStorageAccessType.workletGet, +SharedStorageAccessType.headerSet, +SharedStorageAccessType.headerAppend, and +SharedStorageAccessType.headerDelete. */ key?: string; /** * Value for a specific entry in an origin's shared storage. Present only for SharedStorageAccessType.documentSet, SharedStorageAccessType.documentAppend, -SharedStorageAccessType.workletSet, and -SharedStorageAccessType.workletAppend. +SharedStorageAccessType.workletSet, +SharedStorageAccessType.workletAppend, +SharedStorageAccessType.headerSet, and +SharedStorageAccessType.headerAppend. */ value?: string; /** * Whether or not to set an entry for a key if that key is already present. -Present only for SharedStorageAccessType.documentSet and -SharedStorageAccessType.workletSet. +Present only for SharedStorageAccessType.documentSet, +SharedStorageAccessType.workletSet, and +SharedStorageAccessType.headerSet. */ ignoreIfPresent?: boolean; } @@ -13789,6 +13821,23 @@ int } export type AttributionReportingEventLevelResult = "success"|"successDroppedLowerPriority"|"internalError"|"noCapacityForAttributionDestination"|"noMatchingSources"|"deduplicated"|"excessiveAttributions"|"priorityTooLow"|"neverAttributedSource"|"excessiveReportingOrigins"|"noMatchingSourceFilterData"|"prohibitedByBrowserPolicy"|"noMatchingConfigurations"|"excessiveReports"|"falselyAttributedSource"|"reportWindowPassed"|"notRegistered"|"reportWindowNotStarted"|"noMatchingTriggerData"; export type AttributionReportingAggregatableResult = "success"|"internalError"|"noCapacityForAttributionDestination"|"noMatchingSources"|"excessiveAttributions"|"excessiveReportingOrigins"|"noHistograms"|"insufficientBudget"|"noMatchingSourceFilterData"|"notRegistered"|"prohibitedByBrowserPolicy"|"deduplicated"|"reportWindowPassed"|"excessiveReports"; + /** + * A single Related Website Set object. + */ + export interface RelatedWebsiteSet { + /** + * The primary site of this set, along with the ccTLDs if there is any. + */ + primarySites: string[]; + /** + * The associated sites of this set, along with the ccTLDs if there is any. + */ + associatedSites: string[]; + /** + * The service sites of this set, along with the ccTLDs if there is any. + */ + serviceSites: string[]; + } /** * A cache's contents have been modified. @@ -14216,7 +14265,13 @@ Leaves other stored data, including the issuer's Redemption Records, intact. name: string; } export type getInterestGroupDetailsReturnValue = { - details: InterestGroupDetails; + /** + * This largely corresponds to: +https://wicg.github.io/turtledove/#dictdef-generatebidinterestgroup +but has absolute expirationTime instead of relative lifetimeMs and +also adds joiningOrigin. + */ + details: { [key: string]: string }; } /** * Enables/Disables issuing of interestGroupAccessed events. @@ -14345,6 +14400,15 @@ interestGroupAuctionNetworkRequestCreated. } export type setAttributionReportingTrackingReturnValue = { } + /** + * Returns the effective Related Website Sets in use by this profile for the browser +session. The effective Related Website Sets will not change during a browser session. + */ + export type getRelatedWebsiteSetsParameters = { + } + export type getRelatedWebsiteSetsReturnValue = { + sets: RelatedWebsiteSet[]; + } } /** @@ -14582,10 +14646,10 @@ supported. export type SessionID = string; export interface TargetInfo { targetId: TargetID; - type: string; /** * List of types: https://source.chromium.org/chromium/chromium/src/+/main:content/browser/devtools/devtools_agent_host_impl.cc?ss=chromium&q=f:devtools%20-f:out%20%22::kTypeTab%5B%5D%22 */ + type: string; title: string; url: string; /** @@ -16385,7 +16449,7 @@ See also: requestId?: Network.RequestId; /** * Error information -`errorMessage` is null if `errorType` is null. +`errorMessage` is null iff `errorType` is null. */ errorType?: RuleSetErrorType; /** @@ -19515,6 +19579,7 @@ Error was thrown. "CSS.getPlatformFontsForNode": CSS.getPlatformFontsForNodeParameters; "CSS.getStyleSheetText": CSS.getStyleSheetTextParameters; "CSS.getLayersForNode": CSS.getLayersForNodeParameters; + "CSS.getLocationForSelector": CSS.getLocationForSelectorParameters; "CSS.trackComputedStyleUpdates": CSS.trackComputedStyleUpdatesParameters; "CSS.takeComputedStyleUpdates": CSS.takeComputedStyleUpdatesParameters; "CSS.setEffectivePropertyValueForNode": CSS.setEffectivePropertyValueForNodeParameters; @@ -19885,6 +19950,7 @@ Error was thrown. "Storage.runBounceTrackingMitigations": Storage.runBounceTrackingMitigationsParameters; "Storage.setAttributionReportingLocalTestingMode": Storage.setAttributionReportingLocalTestingModeParameters; "Storage.setAttributionReportingTracking": Storage.setAttributionReportingTrackingParameters; + "Storage.getRelatedWebsiteSets": Storage.getRelatedWebsiteSetsParameters; "SystemInfo.getInfo": SystemInfo.getInfoParameters; "SystemInfo.getFeatureState": SystemInfo.getFeatureStateParameters; "SystemInfo.getProcessInfo": SystemInfo.getProcessInfoParameters; @@ -20097,6 +20163,7 @@ Error was thrown. "CSS.getPlatformFontsForNode": CSS.getPlatformFontsForNodeReturnValue; "CSS.getStyleSheetText": CSS.getStyleSheetTextReturnValue; "CSS.getLayersForNode": CSS.getLayersForNodeReturnValue; + "CSS.getLocationForSelector": CSS.getLocationForSelectorReturnValue; "CSS.trackComputedStyleUpdates": CSS.trackComputedStyleUpdatesReturnValue; "CSS.takeComputedStyleUpdates": CSS.takeComputedStyleUpdatesReturnValue; "CSS.setEffectivePropertyValueForNode": CSS.setEffectivePropertyValueForNodeReturnValue; @@ -20467,6 +20534,7 @@ Error was thrown. "Storage.runBounceTrackingMitigations": Storage.runBounceTrackingMitigationsReturnValue; "Storage.setAttributionReportingLocalTestingMode": Storage.setAttributionReportingLocalTestingModeReturnValue; "Storage.setAttributionReportingTracking": Storage.setAttributionReportingTrackingReturnValue; + "Storage.getRelatedWebsiteSets": Storage.getRelatedWebsiteSetsReturnValue; "SystemInfo.getInfo": SystemInfo.getInfoReturnValue; "SystemInfo.getFeatureState": SystemInfo.getFeatureStateReturnValue; "SystemInfo.getProcessInfo": SystemInfo.getProcessInfoReturnValue; diff --git a/packages/playwright-core/src/server/deviceDescriptorsSource.json b/packages/playwright-core/src/server/deviceDescriptorsSource.json index 6191ef5d2e..49a3c9446e 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/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 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/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 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/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 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/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 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/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 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/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 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/123.0.6312.4 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 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/123.0.6312.4 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Safari/537.36", "viewport": { "width": 1138, "height": 712 @@ -978,7 +978,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/123.0.6312.4 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/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -989,7 +989,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/123.0.6312.4 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/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1000,7 +1000,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/123.0.6312.4 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/124.0.6367.8 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1011,7 +1011,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/123.0.6312.4 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/124.0.6367.8 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1022,7 +1022,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/123.0.6312.4 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/124.0.6367.8 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1033,7 +1033,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/123.0.6312.4 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/124.0.6367.8 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1044,7 +1044,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/123.0.6312.4 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Safari/537.36", "viewport": { "width": 800, "height": 1280 @@ -1055,7 +1055,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/123.0.6312.4 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Safari/537.36", "viewport": { "width": 1280, "height": 800 @@ -1066,7 +1066,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/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1077,7 +1077,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/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1088,7 +1088,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1099,7 +1099,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/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1110,7 +1110,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/123.0.6312.4 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/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1121,7 +1121,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/123.0.6312.4 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/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1132,7 +1132,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/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1143,7 +1143,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/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1154,7 +1154,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/123.0.6312.4 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/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1165,7 +1165,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/123.0.6312.4 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/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1176,7 +1176,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/123.0.6312.4 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Safari/537.36", "viewport": { "width": 600, "height": 960 @@ -1187,7 +1187,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/123.0.6312.4 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Safari/537.36", "viewport": { "width": 960, "height": 600 @@ -1242,7 +1242,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/123.0.6312.4 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/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 411, "height": 731 @@ -1253,7 +1253,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/123.0.6312.4 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/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 731, "height": 411 @@ -1264,7 +1264,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/123.0.6312.4 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/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 411, "height": 823 @@ -1275,7 +1275,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/123.0.6312.4 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/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 823, "height": 411 @@ -1286,7 +1286,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/123.0.6312.4 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/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 393, "height": 786 @@ -1297,7 +1297,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/123.0.6312.4 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/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 786, "height": 393 @@ -1308,7 +1308,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 353, "height": 745 @@ -1319,7 +1319,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 745, "height": 353 @@ -1330,7 +1330,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G)": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "screen": { "width": 412, "height": 892 @@ -1345,7 +1345,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G) landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "screen": { "height": 892, "width": 412 @@ -1360,7 +1360,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "screen": { "width": 393, "height": 851 @@ -1375,7 +1375,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "screen": { "width": 851, "height": 393 @@ -1390,7 +1390,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "screen": { "width": 412, "height": 915 @@ -1405,7 +1405,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "screen": { "width": 915, "height": 412 @@ -1420,7 +1420,7 @@ "defaultBrowserType": "chromium" }, "Moto G4": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1431,7 +1431,7 @@ "defaultBrowserType": "chromium" }, "Moto G4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1442,7 +1442,7 @@ "defaultBrowserType": "chromium" }, "Desktop Chrome HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Safari/537.36", "screen": { "width": 1792, "height": 1120 @@ -1457,7 +1457,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Safari/537.36 Edg/123.0.6312.4", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Safari/537.36 Edg/124.0.6367.8", "screen": { "width": 1792, "height": 1120 @@ -1502,7 +1502,7 @@ "defaultBrowserType": "webkit" }, "Desktop Chrome": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Safari/537.36", "screen": { "width": 1920, "height": 1080 @@ -1517,7 +1517,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Safari/537.36 Edg/123.0.6312.4", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Safari/537.36 Edg/124.0.6367.8", "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 b4a06a67b5..d04418866a 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -224,6 +224,10 @@ export class BrowserContextDispatcher extends Dispatcher { + await this._context.removeCookies(params.filter); + } + async grantPermissions(params: channels.BrowserContextGrantPermissionsParams): Promise { await this._context.grantPermissions(params.permissions, params.origin); } diff --git a/packages/playwright-core/src/server/dispatchers/dispatcher.ts b/packages/playwright-core/src/server/dispatchers/dispatcher.ts index 0b250fe76e..44d68c73a2 100644 --- a/packages/playwright-core/src/server/dispatchers/dispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/dispatcher.ts @@ -24,7 +24,6 @@ import { SdkObject } from '../instrumentation'; import type { PlaywrightDispatcher } from './playwrightDispatcher'; import { eventsHelper } from '../..//utils/eventsHelper'; import type { RegisteredListener } from '../..//utils/eventsHelper'; -import type * as trace from '@trace/trace'; import { isProtocolError } from '../protocolError'; export const dispatcherSymbol = Symbol('dispatcher'); @@ -81,7 +80,7 @@ export class Dispatcher extends js.JSHandle { await progress.beforeInputAction(this); await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => { progress.throwIfAborted(); // Avoid action that has side-effects. - if (localPaths) - await this._page._delegate.setInputFilePaths(progress, retargeted, localPaths); - else + if (localPaths) { + await Promise.all(localPaths.map(localPath => ( + fs.promises.access(localPath, fs.constants.F_OK) + ))); + await this._page._delegate.setInputFilePaths(retargeted, localPaths); + } else { await this._page._delegate.setInputFiles(retargeted, filePayloads!); + } }); return 'done'; } diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index d2d2a03412..49435823c2 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -40,7 +40,7 @@ import { TargetClosedError } from '../errors'; export const UTILITY_WORLD_NAME = '__playwright_utility_world__'; export class FFPage implements PageDelegate { - readonly cspErrorsAsynchronousForInlineScipts = true; + readonly cspErrorsAsynchronousForInlineScripts = true; readonly rawMouse: RawMouseImpl; readonly rawKeyboard: RawKeyboardImpl; readonly rawTouchscreen: RawTouchscreenImpl; @@ -543,16 +543,12 @@ export class FFPage implements PageDelegate { injected.setInputFiles(node, files), files); } - async setInputFilePaths(progress: Progress, handle: dom.ElementHandle, files: string[]): Promise { - await Promise.all([ - this._session.send('Page.setFileInputFiles', { - frameId: handle._context.frame._id, - objectId: handle._objectId, - files - }), - handle.dispatchEvent(progress.metadata, 'input'), - handle.dispatchEvent(progress.metadata, 'change') - ]); + async setInputFilePaths(handle: dom.ElementHandle, files: string[]): Promise { + await this._session.send('Page.setFileInputFiles', { + frameId: handle._context.frame._id, + objectId: handle._objectId, + files + }); } async adoptElementHandle(handle: dom.ElementHandle, to: dom.FrameExecutionContext): Promise> { diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index dab9a1ebcc..fb4dcee705 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -914,6 +914,12 @@ export class Frame extends SdkObject { return this._url; } + origin(): string | undefined { + if (!this._url.startsWith('http')) + return; + return network.parsedURL(this._url)?.origin; + } + parentFrame(): Frame | null { return this._parentFrame; } @@ -942,7 +948,7 @@ export class Frame extends SdkObject { const result = (await context.evaluateHandle(addScriptContent, { content: content!, type })).asElement()!; // Another round trip to the browser to ensure that we receive CSP error messages // (if any) logged asynchronously in a separate task on the content main thread. - if (this._page._delegate.cspErrorsAsynchronousForInlineScipts) + if (this._page._delegate.cspErrorsAsynchronousForInlineScripts) await context.evaluate(() => true); return result; }); @@ -1686,6 +1692,17 @@ export class Frame extends SdkObject { if (db.name) indexedDB.deleteDatabase(db.name!); } + + // Clean StorageManager + const root = await navigator.storage.getDirectory(); + const entries = await (root as any).entries(); + // Manual loop instead of for await because in Firefox's utility context instanceof AsyncIterable is not working. + let entry = await entries.next(); + while (!entry.done) { + const [name] = entry.value; + await root.removeEntry(name, { recursive: true }); + entry = await entries.next(); + } }, { ls: newStorage?.localStorage }).catch(() => {}); } diff --git a/packages/playwright-core/src/server/index.ts b/packages/playwright-core/src/server/index.ts index 9d619c205a..439febfe18 100644 --- a/packages/playwright-core/src/server/index.ts +++ b/packages/playwright-core/src/server/index.ts @@ -29,6 +29,6 @@ export { createPlaywright } from './playwright'; export type { DispatcherScope } from './dispatchers/dispatcher'; export type { Playwright } from './playwright'; -export { openTraceInBrowser, openTraceViewerApp } from './trace/viewer/traceViewer'; +export { openTraceInBrowser, openTraceViewerApp, runTraceViewerApp, startTraceViewerServer, installRootRedirect } from './trace/viewer/traceViewer'; export { serverSideCallMetadata } from './instrumentation'; export { SocksProxy } from '../common/socksProxy'; diff --git a/packages/playwright-core/src/server/injected/highlight.css b/packages/playwright-core/src/server/injected/highlight.css index 9ee0370720..4efa63de0a 100644 --- a/packages/playwright-core/src/server/injected/highlight.css +++ b/packages/playwright-core/src/server/injected/highlight.css @@ -231,6 +231,12 @@ x-pw-tool-item.value > x-div { mask-image: url("data:image/svg+xml;utf8,"); } +x-pw-tool-item.screenshot > x-div { + /* codicon: device-camera */ + -webkit-mask-image: url("data:image/svg+xml;utf8,"); + mask-image: url("data:image/svg+xml;utf8,"); +} + x-pw-tool-item.accept > x-div { -webkit-mask-image: url("data:image/svg+xml;utf8,"); mask-image: url("data:image/svg+xml;utf8,"); diff --git a/packages/playwright-core/src/server/injected/recorder/recorder.ts b/packages/playwright-core/src/server/injected/recorder/recorder.ts index 2074ffa67f..e85f91c44c 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -761,6 +761,7 @@ class Overlay { private _assertVisibilityToggle: HTMLElement; private _assertTextToggle: HTMLElement; private _assertValuesToggle: HTMLElement; + private _assertScreenshotButton: HTMLElement; private _offsetX = 0; private _dragState: { offsetX: number, dragStart: { x: number, y: number } } | undefined; private _measure: { width: number, height: number } = { width: 0, height: 0 }; @@ -807,6 +808,12 @@ class Overlay { this._assertValuesToggle.appendChild(this._recorder.injectedScript.document.createElement('x-div')); toolsListElement.appendChild(this._assertValuesToggle); + this._assertScreenshotButton = this._recorder.injectedScript.document.createElement('x-pw-tool-item'); + this._assertScreenshotButton.title = 'Assert screenshot'; + this._assertScreenshotButton.classList.add('screenshot'); + this._assertScreenshotButton.appendChild(this._recorder.injectedScript.document.createElement('x-div')); + toolsListElement.appendChild(this._assertScreenshotButton); + this._updateVisualPosition(); this._refreshListeners(); } @@ -845,6 +852,15 @@ class Overlay { if (!this._assertValuesToggle.classList.contains('disabled')) this._recorder.delegate.setMode?.(this._recorder.state.mode === 'assertingValue' ? 'recording' : 'assertingValue'); }), + addEventListener(this._assertScreenshotButton, 'click', () => { + if (!this._assertScreenshotButton.classList.contains('disabled')) { + this._recorder.delegate.recordAction?.({ + name: 'assertScreenshot', + signals: [], + }); + this.flashToolSucceeded('assertScreenshot'); + } + }), ]; } @@ -867,6 +883,7 @@ class Overlay { this._assertTextToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting'); this._assertValuesToggle.classList.toggle('active', state.mode === 'assertingValue'); this._assertValuesToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting'); + this._assertScreenshotButton.classList.toggle('disabled', state.mode !== 'recording'); if (this._offsetX !== state.overlay.offsetX) { this._offsetX = state.overlay.offsetX; this._updateVisualPosition(); @@ -877,8 +894,12 @@ class Overlay { this._showOverlay(); } - flashToolSucceeded(tool: 'assertingVisibility' | 'assertingValue') { - const element = tool === 'assertingVisibility' ? this._assertVisibilityToggle : this._assertValuesToggle; + flashToolSucceeded(tool: 'assertingVisibility' | 'assertingValue' | 'assertScreenshot') { + const element = { + 'assertingVisibility': this._assertVisibilityToggle, + 'assertingValue': this._assertValuesToggle, + 'assertScreenshot': this._assertScreenshotButton, + }[tool]; element.classList.add('succeeded'); setTimeout(() => element.classList.remove('succeeded'), 2000); } diff --git a/packages/playwright-core/src/server/injected/roleSelectorEngine.ts b/packages/playwright-core/src/server/injected/roleSelectorEngine.ts index 56643199e5..b647f81b8e 100644 --- a/packages/playwright-core/src/server/injected/roleSelectorEngine.ts +++ b/packages/playwright-core/src/server/injected/roleSelectorEngine.ts @@ -16,10 +16,9 @@ import type { SelectorEngine, SelectorRoot } from './selectorEngine'; import { matchesAttributePart } from './selectorUtils'; -import { beginAriaCaches, endAriaCaches, getAriaChecked, getAriaDisabled, getAriaExpanded, getAriaLevel, getAriaPressed, getAriaSelected, getElementAccessibleName, getElementsByRole, isElementHiddenForAria, kAriaCheckedRoles, kAriaExpandedRoles, kAriaLevelRoles, kAriaPressedRoles, kAriaSelectedRoles } from './roleUtils'; +import { beginAriaCaches, endAriaCaches, getAriaChecked, getAriaDisabled, getAriaExpanded, getAriaLevel, getAriaPressed, getAriaRole, getAriaSelected, getElementAccessibleName, isElementHiddenForAria, kAriaCheckedRoles, kAriaExpandedRoles, kAriaLevelRoles, kAriaPressedRoles, kAriaSelectedRoles } from './roleUtils'; import { parseAttributeSelector, type AttributeSelectorPart, type AttributeSelectorOperator } from '../../utils/isomorphic/selectorParser'; import { normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils'; -import { isInsideScope } from './domUtils'; type RoleEngineOptions = { role: string; @@ -126,27 +125,26 @@ function validateAttributes(attrs: AttributeSelectorPart[], role: string): RoleE } function queryRole(scope: SelectorRoot, options: RoleEngineOptions, internal: boolean): Element[] { - const doc = scope.nodeType === 9 /* Node.DOCUMENT_NODE */ ? scope as Document : scope.ownerDocument; - const elements = doc ? getElementsByRole(doc, options.role) : []; - return elements.filter(element => { - if (!isInsideScope(scope, element)) - return false; + const result: Element[] = []; + const match = (element: Element) => { + if (getAriaRole(element) !== options.role) + return; if (options.selected !== undefined && getAriaSelected(element) !== options.selected) - return false; + return; if (options.checked !== undefined && getAriaChecked(element) !== options.checked) - return false; + return; if (options.pressed !== undefined && getAriaPressed(element) !== options.pressed) - return false; + return; if (options.expanded !== undefined && getAriaExpanded(element) !== options.expanded) - return false; + return; if (options.level !== undefined && getAriaLevel(element) !== options.level) - return false; + return; if (options.disabled !== undefined && getAriaDisabled(element) !== options.disabled) - return false; + return; if (!options.includeHidden) { const isHidden = isElementHiddenForAria(element); if (isHidden) - return false; + return; } if (options.name !== undefined) { // Always normalize whitespace in the accessible name. @@ -157,10 +155,25 @@ function queryRole(scope: SelectorRoot, options: RoleEngineOptions, internal: bo if (internal && !options.exact && options.nameOp === '=') options.nameOp = '*='; if (!matchesAttributePart(accessibleName, { name: '', jsonPath: [], op: options.nameOp || '=', value: options.name, caseSensitive: !!options.exact })) - return false; + return; } - return true; - }); + result.push(element); + }; + + const query = (root: Element | ShadowRoot | Document) => { + const shadows: ShadowRoot[] = []; + if ((root as Element).shadowRoot) + shadows.push((root as Element).shadowRoot!); + for (const element of root.querySelectorAll('*')) { + match(element); + if (element.shadowRoot) + shadows.push(element.shadowRoot); + } + shadows.forEach(query); + }; + + query(scope); + return result; } export function createRoleEngine(internal: boolean): SelectorEngine { diff --git a/packages/playwright-core/src/server/injected/roleUtils.ts b/packages/playwright-core/src/server/injected/roleUtils.ts index a42b60233e..6cecb1d5a5 100644 --- a/packages/playwright-core/src/server/injected/roleUtils.ts +++ b/packages/playwright-core/src/server/injected/roleUtils.ts @@ -845,51 +845,11 @@ function getAccessibleNameFromAssociatedLabels(labels: Iterable !!accessibleName).join(' '); } -export function getElementsByRole(document: Document, role: string): Element[] { - if (document === cacheElementsByRoleDocument) - return cacheElementsByRole!.get(role) || []; - const map = calculateElementsByRoleMap(document); - if (cachesCounter) { - cacheElementsByRoleDocument = document; - cacheElementsByRole = map; - } - return map.get(role) || []; -} - -function calculateElementsByRoleMap(document: Document) { - const result = new Map(); - - const visit = (root: Element | ShadowRoot | Document) => { - const shadows: ShadowRoot[] = []; - if ((root as Element).shadowRoot) - shadows.push((root as Element).shadowRoot!); - for (const element of root.querySelectorAll('*')) { - const role = getAriaRole(element); - if (role) { - let list = result.get(role); - if (!list) { - list = []; - result.set(role, list); - } - list.push(element); - } - if (element.shadowRoot) - shadows.push(element.shadowRoot); - } - shadows.forEach(visit); - }; - visit(document); - - return result; -} - let cacheAccessibleName: Map | undefined; let cacheAccessibleNameHidden: Map | undefined; let cacheIsHidden: Map | undefined; let cachePseudoContentBefore: Map | undefined; let cachePseudoContentAfter: Map | undefined; -let cacheElementsByRole: Map | undefined; -let cacheElementsByRoleDocument: Document | undefined; let cachesCounter = 0; export function beginAriaCaches() { @@ -908,7 +868,5 @@ export function endAriaCaches() { cacheIsHidden = undefined; cachePseudoContentBefore = undefined; cachePseudoContentAfter = undefined; - cacheElementsByRole = undefined; - cacheElementsByRoleDocument = undefined; } } diff --git a/packages/playwright-core/src/server/injected/vueSelectorEngine.ts b/packages/playwright-core/src/server/injected/vueSelectorEngine.ts index 0ba552d558..154f36b983 100644 --- a/packages/playwright-core/src/server/injected/vueSelectorEngine.ts +++ b/packages/playwright-core/src/server/injected/vueSelectorEngine.ts @@ -86,12 +86,18 @@ function buildComponentsTreeVue3(instance: VueVNode): ComponentNode { // @see https://github.com/vuejs/devtools/blob/e7132f3392b975e39e1d9a23cf30456c270099c2/packages/app-backend-vue3/src/components/util.ts#L29 function getInstanceName(instance: VueVNode): string { const name = getComponentTypeName(instance.type || {}); - if (name) return name; - if (instance.root === instance) return 'Root'; - for (const key in instance.parent?.type?.components) - if (instance.parent?.type.components[key] === instance.type) return saveComponentName(instance, key); - for (const key in instance.appContext?.components) - if (instance.appContext.components[key] === instance.type) return saveComponentName(instance, key); + if (name) + return name; + if (instance.root === instance) + return 'Root'; + for (const key in instance.parent?.type?.components) { + if (instance.parent?.type.components[key] === instance.type) + return saveComponentName(instance, key); + } + for (const key in instance.appContext?.components) { + if (instance.appContext.components[key] === instance.type) + return saveComponentName(instance, key); + } return 'Anonymous Component'; } @@ -132,7 +138,8 @@ function buildComponentsTreeVue3(instance: VueVNode): ComponentNode { // @see https://github.com/vuejs/devtools/blob/e7132f3392b975e39e1d9a23cf30456c270099c2/packages/app-backend-vue3/src/components/el.ts#L15 function getFragmentRootElements(vnode: any): Element[] { - if (!vnode.children) return []; + if (!vnode.children) + return []; const list = []; diff --git a/packages/playwright-core/src/server/instrumentation.ts b/packages/playwright-core/src/server/instrumentation.ts index 7ff19202d9..5d212279e3 100644 --- a/packages/playwright-core/src/server/instrumentation.ts +++ b/packages/playwright-core/src/server/instrumentation.ts @@ -36,7 +36,6 @@ export type Attribution = { import type { CallMetadata } from '@protocol/callMetadata'; export type { CallMetadata } from '@protocol/callMetadata'; -import type * as trace from '@trace/trace'; export const kTestSdkObjects = new WeakSet(); @@ -63,7 +62,6 @@ export interface Instrumentation { onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle): Promise; onCallLog(sdkObject: SdkObject, metadata: CallMetadata, logName: string, message: string): void; onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise; - onEvent(sdkObject: SdkObject, event: trace.EventTraceEvent): void; onPageOpen(page: Page): void; onPageClose(page: Page): void; onBrowserOpen(browser: Browser): void; @@ -75,7 +73,6 @@ export interface InstrumentationListener { onBeforeInputAction?(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle): Promise; onCallLog?(sdkObject: SdkObject, metadata: CallMetadata, logName: string, message: string): void; onAfterCall?(sdkObject: SdkObject, metadata: CallMetadata): Promise; - onEvent?(sdkObject: SdkObject, event: trace.EventTraceEvent): void; onPageOpen?(page: Page): void; onPageClose?(page: Page): void; onBrowserOpen?(browser: Browser): void; diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 359fed8360..2c3fd8f6dd 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -19,7 +19,7 @@ import type * as dom from './dom'; import * as frames from './frames'; import * as input from './input'; import * as js from './javascript'; -import * as network from './network'; +import type * as network from './network'; import type * as channels from '@protocol/channels'; import type { ScreenshotOptions } from './screenshotter'; import { Screenshotter, validateScreenshotOptions } from './screenshotter'; @@ -80,7 +80,7 @@ export interface PageDelegate { getOwnerFrame(handle: dom.ElementHandle): Promise; // Returns frameId. getContentQuads(handle: dom.ElementHandle): Promise; setInputFiles(handle: dom.ElementHandle, files: types.FilePayload[]): Promise; - setInputFilePaths(progress: Progress, handle: dom.ElementHandle, files: string[]): Promise; + setInputFilePaths(handle: dom.ElementHandle, files: string[]): Promise; getBoundingBox(handle: dom.ElementHandle): Promise; getFrameElement(frame: frames.Frame): Promise; scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'error:notvisible' | 'error:notconnected' | 'done'>; @@ -95,7 +95,7 @@ export interface PageDelegate { // Work around Chrome's non-associated input and protocol. inputActionEpilogue(): Promise; // Work around for asynchronously dispatched CSP errors in Firefox. - readonly cspErrorsAsynchronousForInlineScipts?: boolean; + readonly cspErrorsAsynchronousForInlineScripts?: boolean; // Work around for mouse position in Firefox. resetForReuse(): Promise; // WebKit hack. @@ -706,12 +706,9 @@ export class Page extends SdkObject { frameNavigatedToNewDocument(frame: frames.Frame) { this.emit(Page.Events.InternalFrameNavigatedToNewDocument, frame); - const url = frame.url(); - if (!url.startsWith('http')) - return; - const purl = network.parsedURL(url); - if (purl) - this._browserContext.addVisitedOrigin(purl.origin); + const origin = frame.origin(); + if (origin) + this._browserContext.addVisitedOrigin(origin); } allBindings() { diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 997e1244d2..d5f84ba625 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -626,13 +626,8 @@ class ContextRecorder extends EventEmitter { callMetadata.endTime = monotonicTime(); await frame.instrumentation.onAfterCall(frame, callMetadata); - 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); - }, 5000); + this._setCommittedAfterTimeout(actionInContext); this._generator.didPerformAction(actionInContext); - this._timers.add(timer); }; const kActionTimeout = 5000; @@ -664,9 +659,19 @@ class ContextRecorder extends EventEmitter { frame: frameDescription, action }; + this._setCommittedAfterTimeout(actionInContext); this._generator.addAction(actionInContext); } + private _setCommittedAfterTimeout(actionInContext: ActionInContext) { + const timer = setTimeout(() => { + // Commit the action after 5 seconds so that no further signals are added to it. + actionInContext.committed = true; + this._timers.delete(timer); + }, isUnderTest() ? 500 : 5000); + this._timers.add(timer); + } + private _onFrameNavigated(frame: Frame, page: Page) { const pageAlias = this._pageAliases.get(page); this._generator.signal(pageAlias!, frame, { name: 'navigation', url: frame.url() }); diff --git a/packages/playwright-core/src/server/recorder/csharp.ts b/packages/playwright-core/src/server/recorder/csharp.ts index 46fadc244a..2b42ae4e2c 100644 --- a/packages/playwright-core/src/server/recorder/csharp.ts +++ b/packages/playwright-core/src/server/recorder/csharp.ts @@ -164,6 +164,8 @@ export class CSharpLanguageGenerator implements LanguageGenerator { const assertion = action.value ? `ToHaveValueAsync(${quote(action.value)})` : `ToBeEmptyAsync()`; return `await Expect(${subject}.${this._asLocator(action.selector)}).${assertion};`; } + case 'assertScreenshot': + return `// AssertScreenshot(await ${subject}.ScreenshotAsync());`; } } diff --git a/packages/playwright-core/src/server/recorder/java.ts b/packages/playwright-core/src/server/recorder/java.ts index d4ebfdeea4..384584d272 100644 --- a/packages/playwright-core/src/server/recorder/java.ts +++ b/packages/playwright-core/src/server/recorder/java.ts @@ -152,6 +152,8 @@ export class JavaLanguageGenerator implements LanguageGenerator { const assertion = action.value ? `hasValue(${quote(action.value)})` : `isEmpty()`; return `assertThat(${subject}.${this._asLocator(action.selector, inFrameLocator)}).${assertion};`; } + case 'assertScreenshot': + return `// assertScreenshot(${subject}.screenshot());`; } } diff --git a/packages/playwright-core/src/server/recorder/javascript.ts b/packages/playwright-core/src/server/recorder/javascript.ts index 548e0f6071..e30c6c6e59 100644 --- a/packages/playwright-core/src/server/recorder/javascript.ts +++ b/packages/playwright-core/src/server/recorder/javascript.ts @@ -135,6 +135,8 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { const assertion = action.value ? `toHaveValue(${quote(action.value)})` : `toBeEmpty()`; return `${this._isTest ? '' : '// '}await expect(${subject}.${this._asLocator(action.selector)}).${assertion};`; } + case 'assertScreenshot': + return `${this._isTest ? '' : '// '}await expect(${subject}).toHaveScreenshot();`; } } diff --git a/packages/playwright-core/src/server/recorder/python.ts b/packages/playwright-core/src/server/recorder/python.ts index b00e02178c..d9ddc0fd9c 100644 --- a/packages/playwright-core/src/server/recorder/python.ts +++ b/packages/playwright-core/src/server/recorder/python.ts @@ -73,7 +73,7 @@ export class PythonLanguageGenerator implements LanguageGenerator { if (signals.dialog) formatter.add(` ${pageAlias}.once("dialog", lambda dialog: dialog.dismiss())`); - let code = `${this._awaitPrefix}${this._generateActionCall(subject, action)}`; + let code = this._generateActionCall(subject, action); if (signals.popup) { code = `${this._asyncPrefix}with ${pageAlias}.expect_popup() as ${signals.popup.popupAlias}_info { @@ -99,7 +99,7 @@ export class PythonLanguageGenerator implements LanguageGenerator { case 'openPage': throw Error('Not reached'); case 'closePage': - return `${subject}.close()`; + return `${this._awaitPrefix}${subject}.close()`; case 'click': { let method = 'click'; if (action.clickCount === 2) @@ -115,35 +115,37 @@ export class PythonLanguageGenerator implements LanguageGenerator { if (action.position) options.position = action.position; const optionsString = formatOptions(options, false); - return `${subject}.${this._asLocator(action.selector)}.${method}(${optionsString})`; + return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.${method}(${optionsString})`; } case 'check': - return `${subject}.${this._asLocator(action.selector)}.check()`; + return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.check()`; case 'uncheck': - return `${subject}.${this._asLocator(action.selector)}.uncheck()`; + return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.uncheck()`; case 'fill': - return `${subject}.${this._asLocator(action.selector)}.fill(${quote(action.text)})`; + return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.fill(${quote(action.text)})`; case 'setInputFiles': - return `${subject}.${this._asLocator(action.selector)}.set_input_files(${formatValue(action.files.length === 1 ? action.files[0] : action.files)})`; + return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.set_input_files(${formatValue(action.files.length === 1 ? action.files[0] : action.files)})`; case 'press': { const modifiers = toModifiers(action.modifiers); const shortcut = [...modifiers, action.key].join('+'); - return `${subject}.${this._asLocator(action.selector)}.press(${quote(shortcut)})`; + return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.press(${quote(shortcut)})`; } case 'navigate': - return `${subject}.goto(${quote(action.url)})`; + return `${this._awaitPrefix}${subject}.goto(${quote(action.url)})`; case 'select': - return `${subject}.${this._asLocator(action.selector)}.select_option(${formatValue(action.options.length === 1 ? action.options[0] : action.options)})`; + return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.select_option(${formatValue(action.options.length === 1 ? action.options[0] : action.options)})`; case 'assertText': - return `expect(${subject}.${this._asLocator(action.selector)}).${action.substring ? 'to_contain_text' : 'to_have_text'}(${quote(action.text)})`; + return `${this._awaitPrefix}expect(${subject}.${this._asLocator(action.selector)}).${action.substring ? 'to_contain_text' : 'to_have_text'}(${quote(action.text)})`; case 'assertChecked': - return `expect(${subject}.${this._asLocator(action.selector)}).${action.checked ? 'to_be_checked()' : 'not_to_be_checked()'}`; + return `${this._awaitPrefix}expect(${subject}.${this._asLocator(action.selector)}).${action.checked ? 'to_be_checked()' : 'not_to_be_checked()'}`; case 'assertVisible': - return `expect(${subject}.${this._asLocator(action.selector)}).to_be_visible()`; + return `${this._awaitPrefix}expect(${subject}.${this._asLocator(action.selector)}).to_be_visible()`; case 'assertValue': { const assertion = action.value ? `to_have_value(${quote(action.value)})` : `to_be_empty()`; - return `expect(${subject}.${this._asLocator(action.selector)}).${assertion};`; + return `${this._awaitPrefix}expect(${subject}.${this._asLocator(action.selector)}).${assertion}`; } + case 'assertScreenshot': + return `# assert_screenshot(${this._awaitPrefix}${subject}.screenshot())`; } } @@ -162,7 +164,7 @@ def browser_context_args(browser_context_args, playwright) { return {${contextOptions}} } ` : ''; - formatter.add(`${options.deviceName ? 'import pytest\n' : ''} + formatter.add(`${options.deviceName ? 'import pytest\n' : ''}import re from playwright.sync_api import Page, expect ${fixture} @@ -170,7 +172,7 @@ def test_example(page: Page) -> None {`); } else if (this._isAsync) { formatter.add(` import asyncio - +import re from playwright.async_api import Playwright, async_playwright, expect @@ -179,6 +181,7 @@ async def run(playwright: Playwright) -> None { context = await browser.new_context(${formatContextOptions(options.contextOptions, options.deviceName)})`); } else { formatter.add(` +import re from playwright.sync_api import Playwright, sync_playwright, expect diff --git a/packages/playwright-core/src/server/recorder/recorderActions.ts b/packages/playwright-core/src/server/recorder/recorderActions.ts index 3c9720cbc4..ff8c168dbc 100644 --- a/packages/playwright-core/src/server/recorder/recorderActions.ts +++ b/packages/playwright-core/src/server/recorder/recorderActions.ts @@ -30,6 +30,7 @@ export type ActionName = 'assertText' | 'assertValue' | 'assertChecked' | + 'assertScreenshot' | 'assertVisible'; export type ActionBase = { @@ -119,7 +120,11 @@ export type AssertVisibleAction = ActionBase & { selector: string, }; -export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction | AssertVisibleAction; +export type AssertScreenshotAction = ActionBase & { + name: 'assertScreenshot', +}; + +export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction | AssertVisibleAction | AssertScreenshotAction; export type AssertAction = AssertCheckedAction | AssertValueAction | AssertTextAction | AssertVisibleAction; // Signals. diff --git a/packages/playwright-core/src/server/registry/index.ts b/packages/playwright-core/src/server/registry/index.ts index fa3a1bbf78..c3547c1118 100644 --- a/packages/playwright-core/src/server/registry/index.ts +++ b/packages/playwright-core/src/server/registry/index.ts @@ -121,29 +121,6 @@ const DOWNLOAD_PATHS: Record = { 'mac13-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-mac-arm64.zip', 'win64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-win64.zip', }, - 'chromium-with-symbols': { - '': undefined, - 'ubuntu18.04-x64': undefined, - 'ubuntu20.04-x64': 'builds/chromium/%s/chromium-with-symbols-linux.zip', - 'ubuntu22.04-x64': 'builds/chromium/%s/chromium-with-symbols-linux.zip', - 'ubuntu18.04-arm64': undefined, - 'ubuntu20.04-arm64': 'builds/chromium/%s/chromium-with-symbols-linux-arm64.zip', - 'ubuntu22.04-arm64': 'builds/chromium/%s/chromium-with-symbols-linux-arm64.zip', - 'debian11-x64': 'builds/chromium/%s/chromium-with-symbols-linux.zip', - 'debian11-arm64': 'builds/chromium/%s/chromium-with-symbols-linux-arm64.zip', - 'debian12-x64': 'builds/chromium/%s/chromium-with-symbols-linux.zip', - 'debian12-arm64': 'builds/chromium/%s/chromium-with-symbols-linux-arm64.zip', - 'mac10.13': 'builds/chromium/%s/chromium-with-symbols-mac.zip', - 'mac10.14': 'builds/chromium/%s/chromium-with-symbols-mac.zip', - 'mac10.15': 'builds/chromium/%s/chromium-with-symbols-mac.zip', - 'mac11': 'builds/chromium/%s/chromium-with-symbols-mac.zip', - 'mac11-arm64': 'builds/chromium/%s/chromium-with-symbols-mac-arm64.zip', - 'mac12': 'builds/chromium/%s/chromium-with-symbols-mac.zip', - 'mac12-arm64': 'builds/chromium/%s/chromium-with-symbols-mac-arm64.zip', - 'mac13': 'builds/chromium/%s/chromium-with-symbols-mac.zip', - 'mac13-arm64': 'builds/chromium/%s/chromium-with-symbols-mac-arm64.zip', - 'win64': 'builds/chromium/%s/chromium-with-symbols-win64.zip', - }, 'firefox': { '': undefined, 'ubuntu18.04-x64': undefined, @@ -368,9 +345,9 @@ function readDescriptors(browsersJSON: BrowsersJSON) { } export type BrowserName = 'chromium' | 'firefox' | 'webkit'; -type InternalTool = 'ffmpeg' | 'firefox-beta' | 'firefox-asan' | 'chromium-with-symbols' | 'chromium-tip-of-tree' | 'android'; +type InternalTool = 'ffmpeg' | 'firefox-beta' | 'firefox-asan' | 'chromium-tip-of-tree' | 'android'; type ChromiumChannel = 'chrome' | 'chrome-beta' | 'chrome-dev' | 'chrome-canary' | 'msedge' | 'msedge-beta' | 'msedge-dev' | 'msedge-canary'; -const allDownloadable = ['chromium', 'firefox', 'webkit', 'ffmpeg', 'firefox-beta', 'chromium-with-symbols', 'chromium-tip-of-tree']; +const allDownloadable = ['chromium', 'firefox', 'webkit', 'ffmpeg', 'firefox-beta', 'chromium-tip-of-tree']; export interface Executable { type: 'browser' | 'tool' | 'channel'; @@ -453,24 +430,6 @@ export class Registry { _isHermeticInstallation: true, }); - const chromiumWithSymbols = descriptors.find(d => d.name === 'chromium-with-symbols')!; - const chromiumWithSymbolsExecutable = findExecutablePath(chromiumWithSymbols.dir, 'chromium'); - this._executables.push({ - type: 'tool', - name: 'chromium-with-symbols', - browserName: 'chromium', - directory: chromiumWithSymbols.dir, - executablePath: () => chromiumWithSymbolsExecutable, - executablePathOrDie: (sdkLanguage: string) => executablePathOrDie('chromium-with-symbols', chromiumWithSymbolsExecutable, chromiumWithSymbols.installByDefault, sdkLanguage), - installType: chromiumWithSymbols.installByDefault ? 'download-by-default' : 'download-on-demand', - _validateHostRequirements: (sdkLanguage: string) => this._validateHostRequirements(sdkLanguage, 'chromium', chromiumWithSymbols.dir, ['chrome-linux'], [], ['chrome-win']), - downloadURLs: this._downloadURLs(chromiumWithSymbols), - browserVersion: chromiumWithSymbols.browserVersion, - _install: () => this._downloadExecutable(chromiumWithSymbols, chromiumWithSymbolsExecutable), - _dependencyGroup: 'chromium', - _isHermeticInstallation: true, - }); - const chromiumTipOfTree = descriptors.find(d => d.name === 'chromium-tip-of-tree')!; const chromiumTipOfTreeExecutable = findExecutablePath(chromiumTipOfTree.dir, 'chromium'); this._executables.push({ diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index 52fe777134..774cd24163 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -43,6 +43,7 @@ import { Snapshotter } from './snapshotter'; import { yazl } from '../../../zipBundle'; import type { ConsoleMessage } from '../../console'; import { Dispatcher } from '../../dispatchers/dispatcher'; +import { serializeError } from '../../errors'; const version: trace.VERSION = 6; @@ -182,6 +183,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps this._context.instrumentation.addListener(this, this._context); this._eventListeners.push( eventsHelper.addEventListener(this._context, BrowserContext.Events.Console, this._onConsoleMessage.bind(this)), + eventsHelper.addEventListener(this._context, BrowserContext.Events.PageError, this._onPageError.bind(this)), ); if (this._state.options.screenshots) this._startScreencast(); @@ -396,18 +398,6 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps return this._captureSnapshot(event.afterSnapshot, sdkObject, metadata); } - onEvent(sdkObject: SdkObject, event: trace.EventTraceEvent) { - if (!sdkObject.attribution.context) - return; - if (event.method === 'console' || - (event.method === '__create__' && event.class === 'ConsoleMessage') || - (event.method === '__create__' && event.class === 'JSHandle')) { - // Console messages are handled separately. - return; - } - this._appendTraceEvent(event); - } - onEntryStarted(entry: har.Entry) { this._pendingHarEntries.add(entry); } @@ -456,6 +446,18 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps this._appendTraceEvent(event); } + private _onPageError(error: Error, page: Page) { + const event: trace.EventTraceEvent = { + type: 'event', + time: monotonicTime(), + class: 'BrowserContext', + method: 'pageError', + params: { error: serializeError(error) }, + pageId: page.guid, + }; + this._appendTraceEvent(event); + } + private _startScreencastInPage(page: Page) { page.setScreencastOptions(kScreencastOptions); const prefix = page.guid; diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index f328ec6f05..aa6ef60271 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -17,34 +17,35 @@ import path from 'path'; import fs from 'fs'; import { HttpServer } from '../../../utils/httpServer'; -import { createGuid, gracefullyProcessExitDoNotHang, isUnderTest } from '../../../utils'; +import type { Transport } from '../../../utils/httpServer'; +import { gracefullyProcessExitDoNotHang, isUnderTest } from '../../../utils'; import { syncLocalStorageWithSettings } from '../../launchApp'; import { serverSideCallMetadata } from '../../instrumentation'; import { createPlaywright } from '../../playwright'; import { ProgressController } from '../../progress'; -import { open, wsServer } from '../../../utilsBundle'; +import { open } from '../../../utilsBundle'; import type { Page } from '../../page'; import type { BrowserType } from '../../browserType'; import { launchApp } from '../../launchApp'; -export type Transport = { - sendEvent?: (method: string, params: any) => void; - dispatch: (method: string, params: any) => Promise; - close?: () => void; - onclose: () => void; -}; - -export type OpenTraceViewerOptions = { - app?: string; - headless?: boolean; +export type TraceViewerServerOptions = { host?: string; port?: number; isServer?: boolean; transport?: Transport; +}; + +export type TraceViewerRedirectOptions = { + webApp?: string; + isServer?: boolean; +}; + +export type TraceViewerAppOptions = { + headless?: boolean; persistentContextOptions?: Parameters[2]; }; -async function startTraceViewerServer(traceUrls: string[], options?: OpenTraceViewerOptions): Promise<{ server: HttpServer, url: string }> { +async function validateTraceUrls(traceUrls: string[]) { for (const traceUrl of traceUrls) { let traceFile = traceUrl; // If .json is requested, we'll synthesize it. @@ -54,10 +55,13 @@ async function startTraceViewerServer(traceUrls: string[], options?: OpenTraceVi if (!traceUrl.startsWith('http://') && !traceUrl.startsWith('https://') && !fs.existsSync(traceFile) && !fs.existsSync(traceFile + '.trace')) { // eslint-disable-next-line no-console console.error(`Trace file ${traceUrl} does not exist!`); - gracefullyProcessExitDoNotHang(1); + return false; } } + return true; +} +export async function startTraceViewerServer(options?: TraceViewerServerOptions): Promise { const server = new HttpServer(); server.routePrefix('/trace', (request, response) => { const url = new URL('http://localhost' + request.url!); @@ -88,36 +92,25 @@ async function startTraceViewerServer(traceUrls: string[], options?: OpenTraceVi return server.serveFile(request, response, absolutePath); }); - const params = traceUrls.map(t => `trace=${encodeURIComponent(t)}`); const transport = options?.transport || (options?.isServer ? new StdinServer() : undefined); + if (transport) + server.createWebSocket(transport); - if (transport) { - const guid = createGuid(); - params.push('ws=' + guid); - const wss = new wsServer({ server: server.server(), path: '/' + guid }); - wss.on('connection', ws => { - transport.sendEvent = (method, params) => ws.send(JSON.stringify({ method, params })); - transport.close = () => ws.close(); - ws.on('message', async (message: string) => { - const { id, method, params } = JSON.parse(message); - const result = await transport.dispatch(method, params); - ws.send(JSON.stringify({ id, result })); - }); - ws.on('close', () => transport.onclose()); - ws.on('error', () => transport.onclose()); - }); - } + const { host, port } = options || {}; + await server.start({ preferredPort: port, host }); + return server; +} +export async function installRootRedirect(server: HttpServer, traceUrls: string[], options: TraceViewerRedirectOptions) { + const params = (traceUrls || []).map(t => `trace=${encodeURIComponent(t)}`); + if (server.wsGuid()) + params.push('ws=' + server.wsGuid()); if (options?.isServer) params.push('isServer'); if (isUnderTest()) params.push('isUnderTest=true'); - - const { host, port } = options || {}; - const url = await server.start({ preferredPort: port, host }); - const { app } = options || {}; const searchQuery = params.length ? '?' + params.join('&') : ''; - const urlPath = `/trace/${app || 'index.html'}${searchQuery}`; + const urlPath = `/trace/${options.webApp || 'index.html'}${searchQuery}`; server.routePath('/', (request, response) => { response.statusCode = 302; @@ -125,12 +118,28 @@ async function startTraceViewerServer(traceUrls: string[], options?: OpenTraceVi response.end(); return true; }); - - return { server, url }; } -export async function openTraceViewerApp(traceUrls: string[], browserName: string, options?: OpenTraceViewerOptions): Promise { - const { url } = await startTraceViewerServer(traceUrls, options); +export async function runTraceViewerApp(traceUrls: string[], browserName: string, options: TraceViewerServerOptions & { headless?: boolean }, exitOnClose?: boolean) { + if (!validateTraceUrls(traceUrls)) + return; + const server = await startTraceViewerServer(options); + await installRootRedirect(server, traceUrls, options); + const page = await openTraceViewerApp(server.urlPrefix(), browserName, options); + if (exitOnClose) + page.on('close', () => gracefullyProcessExitDoNotHang(0)); + return page; +} + +export async function runTraceInBrowser(traceUrls: string[], options: TraceViewerServerOptions) { + if (!validateTraceUrls(traceUrls)) + return; + const server = await startTraceViewerServer(options); + await installRootRedirect(server, traceUrls, options); + await openTraceInBrowser(server.urlPrefix()); +} + +export async function openTraceViewerApp(url: string, browserName: string, options?: TraceViewerAppOptions): Promise { const traceViewerPlaywright = createPlaywright({ sdkLanguage: 'javascript', isInternalPlaywright: true }); const traceViewerBrowser = isUnderTest() ? 'chromium' : browserName; @@ -141,7 +150,7 @@ export async function openTraceViewerApp(traceUrls: string[], browserName: strin persistentContextOptions: { ...options?.persistentContextOptions, useWebSocket: isUnderTest(), - headless: options?.headless, + headless: !!options?.headless, }, }); @@ -163,8 +172,7 @@ export async function openTraceViewerApp(traceUrls: string[], browserName: strin return page; } -export async function openTraceInBrowser(traceUrls: string[], options?: OpenTraceViewerOptions) { - const { url } = await startTraceViewerServer(traceUrls, options); +export async function openTraceInBrowser(url: string) { // eslint-disable-next-line no-console console.log('\nListening on ' + url); if (!isUnderTest()) @@ -202,10 +210,10 @@ class StdinServer implements Transport { sendEvent?: (method: string, params: any) => void; close?: () => void; - private _loadTrace(url: string) { - this._traceUrl = url; + private _loadTrace(traceUrl: string) { + this._traceUrl = traceUrl; clearTimeout(this._pollTimer); - this.sendEvent?.('loadTrace', { url }); + this.sendEvent?.('loadTraceRequested', { traceUrl }); } private _pollLoadTrace(url: string) { diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index 038010f456..e0326072f7 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -966,7 +966,7 @@ export class WKPage implements PageDelegate { await this._session.send('DOM.setInputFiles', { objectId, files: protocolFiles }); } - async setInputFilePaths(progress: Progress, handle: dom.ElementHandle, paths: string[]): Promise { + async setInputFilePaths(handle: dom.ElementHandle, paths: string[]): Promise { const pageProxyId = this._pageProxySession.sessionId; const objectId = handle._objectId; await Promise.all([ diff --git a/packages/playwright-core/src/utils/httpServer.ts b/packages/playwright-core/src/utils/httpServer.ts index 32901eb3d4..32012e6f96 100644 --- a/packages/playwright-core/src/utils/httpServer.ts +++ b/packages/playwright-core/src/utils/httpServer.ts @@ -17,19 +17,28 @@ import type http from 'http'; import fs from 'fs'; import path from 'path'; -import { mime } from '../utilsBundle'; +import { mime, wsServer } from '../utilsBundle'; import { assert } from './debug'; import { createHttpServer } from './network'; import { ManualPromise } from './manualPromise'; +import { createGuid } from './crypto'; export type ServerRouteHandler = (request: http.IncomingMessage, response: http.ServerResponse) => boolean; +export type Transport = { + sendEvent?: (method: string, params: any) => void; + dispatch: (method: string, params: any) => Promise; + close?: () => void; + onclose: () => void; +}; + export class HttpServer { private _server: http.Server; private _urlPrefix: string; private _port: number = 0; private _started = false; private _routes: { prefix?: string, exact?: string, handler: ServerRouteHandler }[] = []; + private _wsGuid: string | undefined; constructor(address: string = '') { this._urlPrefix = address; @@ -68,6 +77,31 @@ export class HttpServer { } } + createWebSocket(transport: Transport, guid?: string) { + assert(!this._wsGuid, 'can only create one main websocket transport per server'); + this._wsGuid = guid || createGuid(); + const wss = new wsServer({ server: this._server, path: '/' + this._wsGuid }); + wss.on('connection', ws => { + transport.sendEvent = (method, params) => ws.send(JSON.stringify({ method, params })); + transport.close = () => ws.close(); + ws.on('message', async message => { + const { id, method, params } = JSON.parse(String(message)); + try { + const result = await transport.dispatch(method, params); + ws.send(JSON.stringify({ id, result })); + } catch (e) { + ws.send(JSON.stringify({ id, error: String(e) })); + } + }); + ws.on('close', () => transport.onclose()); + ws.on('error', () => transport.onclose()); + }); + } + + wsGuid(): string | undefined { + return this._wsGuid; + } + async start(options: { port?: number, preferredPort?: number, host?: string } = {}): Promise { assert(!this._started, 'server already started'); this._started = true; diff --git a/packages/playwright-core/src/utils/isomorphic/cssTokenizer.ts b/packages/playwright-core/src/utils/isomorphic/cssTokenizer.ts index 12fa08e80d..f72ef27eb4 100644 --- a/packages/playwright-core/src/utils/isomorphic/cssTokenizer.ts +++ b/packages/playwright-core/src/utils/isomorphic/cssTokenizer.ts @@ -48,8 +48,10 @@ function preprocess(str: string): number[] { if (code === 0xd && str.charCodeAt(i + 1) === 0xa) { code = 0xa; i++; } - if (code === 0xd || code === 0xc) code = 0xa; - if (code === 0x0) code = 0xfffd; + if (code === 0xd || code === 0xc) + code = 0xa; + if (code === 0x0) + code = 0xfffd; if (between(code, 0xd800, 0xdbff) && between(str.charCodeAt(i + 1), 0xdc00, 0xdfff)) { // Decode a surrogate pair into an astral codepoint. const lead = code - 0xd800; @@ -63,7 +65,8 @@ function preprocess(str: string): number[] { } function stringFromCode(code: number) { - if (code <= 0xffff) return String.fromCharCode(code); + if (code <= 0xffff) + return String.fromCharCode(code); // Otherwise, encode astral char as surrogate pair. code -= Math.pow(2, 16); const lead = Math.floor(code / Math.pow(2, 10)) + 0xd800; @@ -107,8 +110,10 @@ export function tokenize(str1: string): CSSTokenInterface[] { num = 1; i += num; code = codepoint(i); - if (newline(code)) incrLineno(); - else column += num; + if (newline(code)) + incrLineno(); + else + column += num; // console.log('Consume '+i+' '+String.fromCharCode(code) + ' 0x' + code.toString(16)); return true; }; @@ -125,7 +130,8 @@ export function tokenize(str1: string): CSSTokenInterface[] { return true; }; const eof = function(codepoint?: number): boolean { - if (codepoint === undefined) codepoint = code; + if (codepoint === undefined) + codepoint = code; return codepoint === -1; }; const donothing = function() { }; @@ -138,12 +144,14 @@ export function tokenize(str1: string): CSSTokenInterface[] { consumeComments(); consume(); if (whitespace(code)) { - while (whitespace(next())) consume(); + while (whitespace(next())) + consume(); return new WhitespaceToken(); } else if (code === 0x22) {return consumeAStringToken();} else if (code === 0x23) { if (namechar(next()) || areAValidEscape(next(1), next(2))) { const token = new HashToken(''); - if (wouldStartAnIdentifier(next(1), next(2), next(3))) token.type = 'id'; + if (wouldStartAnIdentifier(next(1), next(2), next(3))) + token.type = 'id'; token.value = consumeAName(); return token; } else { @@ -288,7 +296,8 @@ export function tokenize(str1: string): CSSTokenInterface[] { const str = consumeAName(); if (str.toLowerCase() === 'url' && next() === 0x28) { consume(); - while (whitespace(next(1)) && whitespace(next(2))) consume(); + while (whitespace(next(1)) && whitespace(next(2))) + consume(); if (next() === 0x22 || next() === 0x27) return new FunctionToken(str); else if (whitespace(next()) && (next(2) === 0x22 || next(2) === 0x27)) @@ -305,7 +314,8 @@ export function tokenize(str1: string): CSSTokenInterface[] { }; const consumeAStringToken = function(endingCodePoint?: number): CSSParserToken { - if (endingCodePoint === undefined) endingCodePoint = code; + if (endingCodePoint === undefined) + endingCodePoint = code; let string = ''; while (consume()) { if (code === endingCodePoint || eof()) { @@ -331,13 +341,16 @@ export function tokenize(str1: string): CSSTokenInterface[] { const consumeAURLToken = function(): CSSTokenInterface { const token = new URLToken(''); - while (whitespace(next())) consume(); - if (eof(next())) return token; + while (whitespace(next())) + consume(); + if (eof(next())) + return token; while (consume()) { if (code === 0x29 || eof()) { return token; } else if (whitespace(code)) { - while (whitespace(next())) consume(); + while (whitespace(next())) + consume(); if (next() === 0x29 || eof(next())) { consume(); return token; @@ -379,9 +392,11 @@ export function tokenize(str1: string): CSSTokenInterface[] { break; } } - if (whitespace(next())) consume(); + if (whitespace(next())) + consume(); let value = parseInt(digits.map(function(x) { return String.fromCharCode(x); }).join(''), 16); - if (value > maximumallowedcodepoint) value = 0xfffd; + if (value > maximumallowedcodepoint) + value = 0xfffd; return value; } else if (eof()) { return 0xfffd; @@ -391,8 +406,10 @@ export function tokenize(str1: string): CSSTokenInterface[] { }; const areAValidEscape = function(c1: number, c2: number) { - if (c1 !== 0x5c) return false; - if (newline(c2)) return false; + if (c1 !== 0x5c) + return false; + if (newline(c2)) + return false; return true; }; const startsWithAValidEscape = function() { @@ -416,11 +433,14 @@ export function tokenize(str1: string): CSSTokenInterface[] { const wouldStartANumber = function(c1: number, c2: number, c3: number) { if (c1 === 0x2b || c1 === 0x2d) { - if (digit(c2)) return true; - if (c2 === 0x2e && digit(c3)) return true; + if (digit(c2)) + return true; + if (c2 === 0x2e && digit(c3)) + return true; return false; } else if (c1 === 0x2e) { - if (digit(c2)) return true; + if (digit(c2)) + return true; return false; } else if (digit(c1)) { return true; @@ -519,7 +539,8 @@ export function tokenize(str1: string): CSSTokenInterface[] { while (!eof(next())) { tokens.push(consumeAToken()); iterationCount++; - if (iterationCount > str.length * 2) throw new Error("I'm infinite-looping!"); + if (iterationCount > str.length * 2) + throw new Error("I'm infinite-looping!"); } return tokens; } diff --git a/packages/playwright-core/src/utils/network.ts b/packages/playwright-core/src/utils/network.ts index bf834abaa0..8cacb8aeb6 100644 --- a/packages/playwright-core/src/utils/network.ts +++ b/packages/playwright-core/src/utils/network.ts @@ -181,9 +181,8 @@ export async function isURLAvailable(url: URL, ignoreHTTPSErrors: boolean, onLog async function httpStatusCode(url: URL, ignoreHTTPSErrors: boolean, onLog?: (data: string) => void, onStdErr?: (data: string) => void): Promise { return new Promise(resolve => { - onLog?.(`HTTP HEAD: ${url}`); + onLog?.(`HTTP GET: ${url}`); httpRequest({ - method: 'HEAD', url: url.toString(), headers: { Accept: '*/*' }, rejectUnauthorized: !ignoreHTTPSErrors diff --git a/packages/playwright-core/src/utils/timeoutRunner.ts b/packages/playwright-core/src/utils/timeoutRunner.ts index fc4db8aed9..622019565a 100644 --- a/packages/playwright-core/src/utils/timeoutRunner.ts +++ b/packages/playwright-core/src/utils/timeoutRunner.ts @@ -14,108 +14,22 @@ * limitations under the License. */ -import { ManualPromise } from './manualPromise'; import { monotonicTime } from './'; -export class TimeoutRunnerError extends Error {} - -type TimeoutRunnerData = { - lastElapsedSync: number, - timer: NodeJS.Timeout | undefined, - timeoutPromise: ManualPromise, -}; - -export const MaxTime = 2147483647; // 2^31-1 - -export class TimeoutRunner { - private _running: TimeoutRunnerData | undefined; - private _timeout: number; - private _elapsed: number; - private _deadline = MaxTime; - - constructor(timeout: number) { - this._timeout = timeout; - this._elapsed = 0; - } - - async run(cb: () => Promise): Promise { - const running = this._running = { - lastElapsedSync: monotonicTime(), - timer: undefined, - timeoutPromise: new ManualPromise(), - }; - try { - const resultPromise = Promise.race([ - cb(), - running.timeoutPromise - ]); - this._updateTimeout(running, this._timeout); - return await resultPromise; - } finally { - this._updateTimeout(running, 0); - if (this._running === running) - this._running = undefined; - } - } - - interrupt() { - if (this._running) - this._updateTimeout(this._running, -1); - } - - elapsed() { - this._syncElapsedAndStart(); - return this._elapsed; - } - - deadline(): number { - return this._deadline; - } - - updateTimeout(timeout: number, elapsed?: number) { - this._timeout = timeout; - if (elapsed !== undefined) { - this._syncElapsedAndStart(); - this._elapsed = elapsed; - } - if (this._running) - this._updateTimeout(this._running, timeout); - } - - private _syncElapsedAndStart() { - if (this._running) { - const now = monotonicTime(); - this._elapsed += now - this._running.lastElapsedSync; - this._running.lastElapsedSync = now; - } - } - - private _updateTimeout(running: TimeoutRunnerData, timeout: number) { - if (running.timer) { - clearTimeout(running.timer); - running.timer = undefined; - } - this._syncElapsedAndStart(); - this._deadline = timeout ? monotonicTime() + timeout : MaxTime; - if (timeout === 0) - return; - timeout = timeout - this._elapsed; - if (timeout <= 0) - running.timeoutPromise.reject(new TimeoutRunnerError()); - else - running.timer = setTimeout(() => running.timeoutPromise.reject(new TimeoutRunnerError()), timeout); - } -} - export async function raceAgainstDeadline(cb: () => Promise, deadline: number): Promise<{ result: T, timedOut: false } | { timedOut: true }> { - const runner = new TimeoutRunner((deadline || MaxTime) - monotonicTime()); - try { - return { result: await runner.run(cb), timedOut: false }; - } catch (e) { - if (e instanceof TimeoutRunnerError) - return { timedOut: true }; - throw e; - } + let timer: NodeJS.Timeout | undefined; + return Promise.race([ + cb().then(result => { + return { result, timedOut: false }; + }), + new Promise<{ timedOut: true }>(resolve => { + const kMaxDeadline = 2147483647; // 2^31-1 + const timeout = (deadline || kMaxDeadline) - monotonicTime(); + timer = setTimeout(() => resolve({ timedOut: true }), timeout); + }), + ]).finally(() => { + clearTimeout(timer); + }); } export async function pollAgainstDeadline(callback: () => Promise<{ continuePolling: boolean, result: T }>, deadline: number, pollIntervals: number[] = [100, 250, 500, 1000]): Promise<{ result?: T, timedOut: boolean }> { diff --git a/packages/playwright-core/types/protocol.d.ts b/packages/playwright-core/types/protocol.d.ts index 83b8d235ce..0ea475d12e 100644 --- a/packages/playwright-core/types/protocol.d.ts +++ b/packages/playwright-core/types/protocol.d.ts @@ -831,7 +831,7 @@ CORS RFC1918 enforcement. resourceIPAddressSpace?: Network.IPAddressSpace; clientSecurityState?: Network.ClientSecurityState; } - export type AttributionReportingIssueType = "PermissionPolicyDisabled"|"UntrustworthyReportingOrigin"|"InsecureContext"|"InvalidHeader"|"InvalidRegisterTriggerHeader"|"SourceAndTriggerHeaders"|"SourceIgnored"|"TriggerIgnored"|"OsSourceIgnored"|"OsTriggerIgnored"|"InvalidRegisterOsSourceHeader"|"InvalidRegisterOsTriggerHeader"|"WebAndOsHeaders"|"NoWebOrOsSupport"|"NavigationRegistrationWithoutTransientUserActivation"; + export type AttributionReportingIssueType = "PermissionPolicyDisabled"|"UntrustworthyReportingOrigin"|"InsecureContext"|"InvalidHeader"|"InvalidRegisterTriggerHeader"|"SourceAndTriggerHeaders"|"SourceIgnored"|"TriggerIgnored"|"OsSourceIgnored"|"OsTriggerIgnored"|"InvalidRegisterOsSourceHeader"|"InvalidRegisterOsTriggerHeader"|"WebAndOsHeaders"|"NoWebOrOsSupport"|"NavigationRegistrationWithoutTransientUserActivation"|"InvalidInfoHeader"|"NoRegisterSourceHeader"|"NoRegisterTriggerHeader"|"NoRegisterOsSourceHeader"|"NoRegisterOsTriggerHeader"; /** * Details for issues around "Attribution Reporting API" usage. Explainer: https://github.com/WICG/attribution-reporting-api @@ -2493,6 +2493,28 @@ stylesheet rules) this rule came from. */ tryRules: CSSTryRule[]; } + /** + * CSS @position-try rule representation. + */ + export interface CSSPositionTryRule { + /** + * The prelude dashed-ident name + */ + name: Value; + /** + * The css style sheet identifier (absent for user agent stylesheet and user-specified +stylesheet rules) this rule came from. + */ + styleSheetId?: StyleSheetId; + /** + * Parent stylesheet's origin. + */ + origin: StyleSheetOrigin; + /** + * Associated style declaration. + */ + style: CSSStyle; + } /** * CSS keyframes rule representation. */ @@ -2820,6 +2842,10 @@ attributes) for a DOM node identified by `nodeId`. * A list of CSS position fallbacks matching this node. */ cssPositionFallbackRules?: CSSPositionFallbackRule[]; + /** + * A list of CSS @position-try rules matching this node, based on the position-try-options property. + */ + cssPositionTryRules?: CSSPositionTryRule[]; /** * A list of CSS at-property rules matching this node. */ @@ -2882,6 +2908,17 @@ the full layer tree for the tree scope and their ordering. export type getLayersForNodeReturnValue = { rootLayer: CSSLayerData; } + /** + * Given a CSS selector text and a style sheet ID, getLocationForSelector +returns an array of locations of the CSS selector in the style sheet. + */ + export type getLocationForSelectorParameters = { + styleSheetId: StyleSheetId; + selectorText: string; + } + export type getLocationForSelectorReturnValue = { + ranges: SourceRange[]; + } /** * Starts tracking the given computed styles for updates. The specified array of properties replaces the one previously specified. Pass empty array to disable tracking. @@ -8052,6 +8089,7 @@ milliseconds relatively to this requestTime. headers: Headers; /** * HTTP POST request data. +Use postDataEntries instead. */ postData?: string; /** @@ -8059,7 +8097,7 @@ milliseconds relatively to this requestTime. */ hasPostData?: boolean; /** - * Request body elements. This will be converted from base64 to binary + * Request body elements (post data broken into individual entries). */ postDataEntries?: PostDataEntry[]; /** @@ -9787,6 +9825,18 @@ matches provided URL. * Connection type if known. */ connectionType?: ConnectionType; + /** + * WebRTC packet loss (percent, 0-100). 0 disables packet loss emulation, 100 drops all the packets. + */ + packetLoss?: number; + /** + * WebRTC packet queue length (packet). 0 removes any queue length limitations. + */ + packetQueueLength?: number; + /** + * WebRTC packetReordering feature. + */ + packetReordering?: boolean; } export type emulateNetworkConditionsReturnValue = { } @@ -11065,7 +11115,7 @@ as an ad. * All Permissions Policy features. This enum should match the one defined in third_party/blink/renderer/core/permissions_policy/permissions_policy_features.json5. */ - export type PermissionsPolicyFeature = "accelerometer"|"ambient-light-sensor"|"attribution-reporting"|"autoplay"|"bluetooth"|"browsing-topics"|"camera"|"captured-surface-control"|"ch-dpr"|"ch-device-memory"|"ch-downlink"|"ch-ect"|"ch-prefers-color-scheme"|"ch-prefers-reduced-motion"|"ch-prefers-reduced-transparency"|"ch-rtt"|"ch-save-data"|"ch-ua"|"ch-ua-arch"|"ch-ua-bitness"|"ch-ua-platform"|"ch-ua-model"|"ch-ua-mobile"|"ch-ua-form-factor"|"ch-ua-full-version"|"ch-ua-full-version-list"|"ch-ua-platform-version"|"ch-ua-wow64"|"ch-viewport-height"|"ch-viewport-width"|"ch-width"|"clipboard-read"|"clipboard-write"|"compute-pressure"|"cross-origin-isolated"|"direct-sockets"|"display-capture"|"document-domain"|"encrypted-media"|"execution-while-out-of-viewport"|"execution-while-not-rendered"|"focus-without-user-activation"|"fullscreen"|"frobulate"|"gamepad"|"geolocation"|"gyroscope"|"hid"|"identity-credentials-get"|"idle-detection"|"interest-cohort"|"join-ad-interest-group"|"keyboard-map"|"local-fonts"|"magnetometer"|"microphone"|"midi"|"otp-credentials"|"payment"|"picture-in-picture"|"private-aggregation"|"private-state-token-issuance"|"private-state-token-redemption"|"publickey-credentials-create"|"publickey-credentials-get"|"run-ad-auction"|"screen-wake-lock"|"serial"|"shared-autofill"|"shared-storage"|"shared-storage-select-url"|"smart-card"|"speaker-selection"|"storage-access"|"sub-apps"|"sync-xhr"|"unload"|"usb"|"usb-unrestricted"|"vertical-scroll"|"web-printing"|"web-share"|"window-management"|"window-placement"|"xr-spatial-tracking"; + export type PermissionsPolicyFeature = "accelerometer"|"ambient-light-sensor"|"attribution-reporting"|"autoplay"|"bluetooth"|"browsing-topics"|"camera"|"captured-surface-control"|"ch-dpr"|"ch-device-memory"|"ch-downlink"|"ch-ect"|"ch-prefers-color-scheme"|"ch-prefers-reduced-motion"|"ch-prefers-reduced-transparency"|"ch-rtt"|"ch-save-data"|"ch-ua"|"ch-ua-arch"|"ch-ua-bitness"|"ch-ua-platform"|"ch-ua-model"|"ch-ua-mobile"|"ch-ua-form-factors"|"ch-ua-full-version"|"ch-ua-full-version-list"|"ch-ua-platform-version"|"ch-ua-wow64"|"ch-viewport-height"|"ch-viewport-width"|"ch-width"|"clipboard-read"|"clipboard-write"|"compute-pressure"|"cross-origin-isolated"|"direct-sockets"|"display-capture"|"document-domain"|"encrypted-media"|"execution-while-out-of-viewport"|"execution-while-not-rendered"|"focus-without-user-activation"|"fullscreen"|"frobulate"|"gamepad"|"geolocation"|"gyroscope"|"hid"|"identity-credentials-get"|"idle-detection"|"interest-cohort"|"join-ad-interest-group"|"keyboard-map"|"local-fonts"|"magnetometer"|"microphone"|"midi"|"otp-credentials"|"payment"|"picture-in-picture"|"private-aggregation"|"private-state-token-issuance"|"private-state-token-redemption"|"publickey-credentials-create"|"publickey-credentials-get"|"run-ad-auction"|"screen-wake-lock"|"serial"|"shared-autofill"|"shared-storage"|"shared-storage-select-url"|"smart-card"|"speaker-selection"|"storage-access"|"sub-apps"|"sync-xhr"|"unload"|"usb"|"usb-unrestricted"|"vertical-scroll"|"web-printing"|"web-share"|"window-management"|"window-placement"|"xr-spatial-tracking"; /** * Reason for a permissions policy feature to be disabled. */ @@ -11534,7 +11584,7 @@ Example URLs: http://www.google.com/file.html -> "google.com" /** * List of not restored reasons for back-forward cache. */ - export type BackForwardCacheNotRestoredReason = "NotPrimaryMainFrame"|"BackForwardCacheDisabled"|"RelatedActiveContentsExist"|"HTTPStatusNotOK"|"SchemeNotHTTPOrHTTPS"|"Loading"|"WasGrantedMediaAccess"|"DisableForRenderFrameHostCalled"|"DomainNotAllowed"|"HTTPMethodNotGET"|"SubframeIsNavigating"|"Timeout"|"CacheLimit"|"JavaScriptExecution"|"RendererProcessKilled"|"RendererProcessCrashed"|"SchedulerTrackedFeatureUsed"|"ConflictingBrowsingInstance"|"CacheFlushed"|"ServiceWorkerVersionActivation"|"SessionRestored"|"ServiceWorkerPostMessage"|"EnteredBackForwardCacheBeforeServiceWorkerHostAdded"|"RenderFrameHostReused_SameSite"|"RenderFrameHostReused_CrossSite"|"ServiceWorkerClaim"|"IgnoreEventAndEvict"|"HaveInnerContents"|"TimeoutPuttingInCache"|"BackForwardCacheDisabledByLowMemory"|"BackForwardCacheDisabledByCommandLine"|"NetworkRequestDatapipeDrainedAsBytesConsumer"|"NetworkRequestRedirected"|"NetworkRequestTimeout"|"NetworkExceedsBufferLimit"|"NavigationCancelledWhileRestoring"|"NotMostRecentNavigationEntry"|"BackForwardCacheDisabledForPrerender"|"UserAgentOverrideDiffers"|"ForegroundCacheLimit"|"BrowsingInstanceNotSwapped"|"BackForwardCacheDisabledForDelegate"|"UnloadHandlerExistsInMainFrame"|"UnloadHandlerExistsInSubFrame"|"ServiceWorkerUnregistration"|"CacheControlNoStore"|"CacheControlNoStoreCookieModified"|"CacheControlNoStoreHTTPOnlyCookieModified"|"NoResponseHead"|"Unknown"|"ActivationNavigationsDisallowedForBug1234857"|"ErrorDocument"|"FencedFramesEmbedder"|"CookieDisabled"|"HTTPAuthRequired"|"CookieFlushed"|"WebSocket"|"WebTransport"|"WebRTC"|"MainResourceHasCacheControlNoStore"|"MainResourceHasCacheControlNoCache"|"SubresourceHasCacheControlNoStore"|"SubresourceHasCacheControlNoCache"|"ContainsPlugins"|"DocumentLoaded"|"OutstandingNetworkRequestOthers"|"RequestedMIDIPermission"|"RequestedAudioCapturePermission"|"RequestedVideoCapturePermission"|"RequestedBackForwardCacheBlockedSensors"|"RequestedBackgroundWorkPermission"|"BroadcastChannel"|"WebXR"|"SharedWorker"|"WebLocks"|"WebHID"|"WebShare"|"RequestedStorageAccessGrant"|"WebNfc"|"OutstandingNetworkRequestFetch"|"OutstandingNetworkRequestXHR"|"AppBanner"|"Printing"|"WebDatabase"|"PictureInPicture"|"Portal"|"SpeechRecognizer"|"IdleManager"|"PaymentManager"|"SpeechSynthesis"|"KeyboardLock"|"WebOTPService"|"OutstandingNetworkRequestDirectSocket"|"InjectedJavascript"|"InjectedStyleSheet"|"KeepaliveRequest"|"IndexedDBEvent"|"Dummy"|"JsNetworkRequestReceivedCacheControlNoStoreResource"|"WebRTCSticky"|"WebTransportSticky"|"WebSocketSticky"|"SmartCard"|"LiveMediaStreamTrack"|"UnloadHandler"|"ContentSecurityHandler"|"ContentWebAuthenticationAPI"|"ContentFileChooser"|"ContentSerial"|"ContentFileSystemAccess"|"ContentMediaDevicesDispatcherHost"|"ContentWebBluetooth"|"ContentWebUSB"|"ContentMediaSessionService"|"ContentScreenReader"|"EmbedderPopupBlockerTabHelper"|"EmbedderSafeBrowsingTriggeredPopupBlocker"|"EmbedderSafeBrowsingThreatDetails"|"EmbedderAppBannerManager"|"EmbedderDomDistillerViewerSource"|"EmbedderDomDistillerSelfDeletingRequestDelegate"|"EmbedderOomInterventionTabHelper"|"EmbedderOfflinePage"|"EmbedderChromePasswordManagerClientBindCredentialManager"|"EmbedderPermissionRequestManager"|"EmbedderModalDialog"|"EmbedderExtensions"|"EmbedderExtensionMessaging"|"EmbedderExtensionMessagingForOpenPort"|"EmbedderExtensionSentMessageToCachedFrame"; + export type BackForwardCacheNotRestoredReason = "NotPrimaryMainFrame"|"BackForwardCacheDisabled"|"RelatedActiveContentsExist"|"HTTPStatusNotOK"|"SchemeNotHTTPOrHTTPS"|"Loading"|"WasGrantedMediaAccess"|"DisableForRenderFrameHostCalled"|"DomainNotAllowed"|"HTTPMethodNotGET"|"SubframeIsNavigating"|"Timeout"|"CacheLimit"|"JavaScriptExecution"|"RendererProcessKilled"|"RendererProcessCrashed"|"SchedulerTrackedFeatureUsed"|"ConflictingBrowsingInstance"|"CacheFlushed"|"ServiceWorkerVersionActivation"|"SessionRestored"|"ServiceWorkerPostMessage"|"EnteredBackForwardCacheBeforeServiceWorkerHostAdded"|"RenderFrameHostReused_SameSite"|"RenderFrameHostReused_CrossSite"|"ServiceWorkerClaim"|"IgnoreEventAndEvict"|"HaveInnerContents"|"TimeoutPuttingInCache"|"BackForwardCacheDisabledByLowMemory"|"BackForwardCacheDisabledByCommandLine"|"NetworkRequestDatapipeDrainedAsBytesConsumer"|"NetworkRequestRedirected"|"NetworkRequestTimeout"|"NetworkExceedsBufferLimit"|"NavigationCancelledWhileRestoring"|"NotMostRecentNavigationEntry"|"BackForwardCacheDisabledForPrerender"|"UserAgentOverrideDiffers"|"ForegroundCacheLimit"|"BrowsingInstanceNotSwapped"|"BackForwardCacheDisabledForDelegate"|"UnloadHandlerExistsInMainFrame"|"UnloadHandlerExistsInSubFrame"|"ServiceWorkerUnregistration"|"CacheControlNoStore"|"CacheControlNoStoreCookieModified"|"CacheControlNoStoreHTTPOnlyCookieModified"|"NoResponseHead"|"Unknown"|"ActivationNavigationsDisallowedForBug1234857"|"ErrorDocument"|"FencedFramesEmbedder"|"CookieDisabled"|"HTTPAuthRequired"|"CookieFlushed"|"BroadcastChannelOnMessage"|"WebSocket"|"WebTransport"|"WebRTC"|"MainResourceHasCacheControlNoStore"|"MainResourceHasCacheControlNoCache"|"SubresourceHasCacheControlNoStore"|"SubresourceHasCacheControlNoCache"|"ContainsPlugins"|"DocumentLoaded"|"OutstandingNetworkRequestOthers"|"RequestedMIDIPermission"|"RequestedAudioCapturePermission"|"RequestedVideoCapturePermission"|"RequestedBackForwardCacheBlockedSensors"|"RequestedBackgroundWorkPermission"|"BroadcastChannel"|"WebXR"|"SharedWorker"|"WebLocks"|"WebHID"|"WebShare"|"RequestedStorageAccessGrant"|"WebNfc"|"OutstandingNetworkRequestFetch"|"OutstandingNetworkRequestXHR"|"AppBanner"|"Printing"|"WebDatabase"|"PictureInPicture"|"Portal"|"SpeechRecognizer"|"IdleManager"|"PaymentManager"|"SpeechSynthesis"|"KeyboardLock"|"WebOTPService"|"OutstandingNetworkRequestDirectSocket"|"InjectedJavascript"|"InjectedStyleSheet"|"KeepaliveRequest"|"IndexedDBEvent"|"Dummy"|"JsNetworkRequestReceivedCacheControlNoStoreResource"|"WebRTCSticky"|"WebTransportSticky"|"WebSocketSticky"|"SmartCard"|"LiveMediaStreamTrack"|"UnloadHandler"|"ParserAborted"|"ContentSecurityHandler"|"ContentWebAuthenticationAPI"|"ContentFileChooser"|"ContentSerial"|"ContentFileSystemAccess"|"ContentMediaDevicesDispatcherHost"|"ContentWebBluetooth"|"ContentWebUSB"|"ContentMediaSessionService"|"ContentScreenReader"|"EmbedderPopupBlockerTabHelper"|"EmbedderSafeBrowsingTriggeredPopupBlocker"|"EmbedderSafeBrowsingThreatDetails"|"EmbedderAppBannerManager"|"EmbedderDomDistillerViewerSource"|"EmbedderDomDistillerSelfDeletingRequestDelegate"|"EmbedderOomInterventionTabHelper"|"EmbedderOfflinePage"|"EmbedderChromePasswordManagerClientBindCredentialManager"|"EmbedderPermissionRequestManager"|"EmbedderModalDialog"|"EmbedderExtensions"|"EmbedderExtensionMessaging"|"EmbedderExtensionMessagingForOpenPort"|"EmbedderExtensionSentMessageToCachedFrame"; /** * Types of not restored reasons for back-forward cache. */ @@ -11811,7 +11861,7 @@ open. */ type: DialogType; /** - * True if browser is capable showing or acting on the given dialog. When browser has no + * True iff browser is capable showing or acting on the given dialog. When browser has no dialog handler for given target, calling alert while Page domain is engaged will stall the page execution. Execution can be resumed via calling Page.handleJavaScriptDialog. */ @@ -13533,34 +13583,10 @@ Tokens from that issuer. * Enum of network fetches auctions can do. */ export type InterestGroupAuctionFetchType = "bidderJs"|"bidderWasm"|"sellerJs"|"bidderTrustedSignals"|"sellerTrustedSignals"; - /** - * Ad advertising element inside an interest group. - */ - export interface InterestGroupAd { - renderURL: string; - metadata?: string; - } - /** - * The full details of an interest group. - */ - export interface InterestGroupDetails { - ownerOrigin: string; - name: string; - expirationTime: Network.TimeSinceEpoch; - joiningOrigin: string; - biddingLogicURL?: string; - biddingWasmHelperURL?: string; - updateURL?: string; - trustedBiddingSignalsURL?: string; - trustedBiddingSignalsKeys: string[]; - userBiddingSignals?: string; - ads: InterestGroupAd[]; - adComponents: InterestGroupAd[]; - } /** * Enum of shared storage access types. */ - export type SharedStorageAccessType = "documentAddModule"|"documentSelectURL"|"documentRun"|"documentSet"|"documentAppend"|"documentDelete"|"documentClear"|"workletSet"|"workletAppend"|"workletDelete"|"workletClear"|"workletGet"|"workletKeys"|"workletEntries"|"workletLength"|"workletRemainingBudget"; + export type SharedStorageAccessType = "documentAddModule"|"documentSelectURL"|"documentRun"|"documentSet"|"documentAppend"|"documentDelete"|"documentClear"|"documentGet"|"workletSet"|"workletAppend"|"workletDelete"|"workletClear"|"workletGet"|"workletKeys"|"workletEntries"|"workletLength"|"workletRemainingBudget"|"headerSet"|"headerAppend"|"headerDelete"|"headerClear"; /** * Struct for a single key-value pair in an origin's shared storage. */ @@ -13644,22 +13670,28 @@ SharedStorageAccessType.documentAppend, SharedStorageAccessType.documentDelete, SharedStorageAccessType.workletSet, SharedStorageAccessType.workletAppend, -SharedStorageAccessType.workletDelete, and -SharedStorageAccessType.workletGet. +SharedStorageAccessType.workletDelete, +SharedStorageAccessType.workletGet, +SharedStorageAccessType.headerSet, +SharedStorageAccessType.headerAppend, and +SharedStorageAccessType.headerDelete. */ key?: string; /** * Value for a specific entry in an origin's shared storage. Present only for SharedStorageAccessType.documentSet, SharedStorageAccessType.documentAppend, -SharedStorageAccessType.workletSet, and -SharedStorageAccessType.workletAppend. +SharedStorageAccessType.workletSet, +SharedStorageAccessType.workletAppend, +SharedStorageAccessType.headerSet, and +SharedStorageAccessType.headerAppend. */ value?: string; /** * Whether or not to set an entry for a key if that key is already present. -Present only for SharedStorageAccessType.documentSet and -SharedStorageAccessType.workletSet. +Present only for SharedStorageAccessType.documentSet, +SharedStorageAccessType.workletSet, and +SharedStorageAccessType.headerSet. */ ignoreIfPresent?: boolean; } @@ -13789,6 +13821,23 @@ int } export type AttributionReportingEventLevelResult = "success"|"successDroppedLowerPriority"|"internalError"|"noCapacityForAttributionDestination"|"noMatchingSources"|"deduplicated"|"excessiveAttributions"|"priorityTooLow"|"neverAttributedSource"|"excessiveReportingOrigins"|"noMatchingSourceFilterData"|"prohibitedByBrowserPolicy"|"noMatchingConfigurations"|"excessiveReports"|"falselyAttributedSource"|"reportWindowPassed"|"notRegistered"|"reportWindowNotStarted"|"noMatchingTriggerData"; export type AttributionReportingAggregatableResult = "success"|"internalError"|"noCapacityForAttributionDestination"|"noMatchingSources"|"excessiveAttributions"|"excessiveReportingOrigins"|"noHistograms"|"insufficientBudget"|"noMatchingSourceFilterData"|"notRegistered"|"prohibitedByBrowserPolicy"|"deduplicated"|"reportWindowPassed"|"excessiveReports"; + /** + * A single Related Website Set object. + */ + export interface RelatedWebsiteSet { + /** + * The primary site of this set, along with the ccTLDs if there is any. + */ + primarySites: string[]; + /** + * The associated sites of this set, along with the ccTLDs if there is any. + */ + associatedSites: string[]; + /** + * The service sites of this set, along with the ccTLDs if there is any. + */ + serviceSites: string[]; + } /** * A cache's contents have been modified. @@ -14216,7 +14265,13 @@ Leaves other stored data, including the issuer's Redemption Records, intact. name: string; } export type getInterestGroupDetailsReturnValue = { - details: InterestGroupDetails; + /** + * This largely corresponds to: +https://wicg.github.io/turtledove/#dictdef-generatebidinterestgroup +but has absolute expirationTime instead of relative lifetimeMs and +also adds joiningOrigin. + */ + details: { [key: string]: string }; } /** * Enables/Disables issuing of interestGroupAccessed events. @@ -14345,6 +14400,15 @@ interestGroupAuctionNetworkRequestCreated. } export type setAttributionReportingTrackingReturnValue = { } + /** + * Returns the effective Related Website Sets in use by this profile for the browser +session. The effective Related Website Sets will not change during a browser session. + */ + export type getRelatedWebsiteSetsParameters = { + } + export type getRelatedWebsiteSetsReturnValue = { + sets: RelatedWebsiteSet[]; + } } /** @@ -14582,10 +14646,10 @@ supported. export type SessionID = string; export interface TargetInfo { targetId: TargetID; - type: string; /** * List of types: https://source.chromium.org/chromium/chromium/src/+/main:content/browser/devtools/devtools_agent_host_impl.cc?ss=chromium&q=f:devtools%20-f:out%20%22::kTypeTab%5B%5D%22 */ + type: string; title: string; url: string; /** @@ -16385,7 +16449,7 @@ See also: requestId?: Network.RequestId; /** * Error information -`errorMessage` is null if `errorType` is null. +`errorMessage` is null iff `errorType` is null. */ errorType?: RuleSetErrorType; /** @@ -19515,6 +19579,7 @@ Error was thrown. "CSS.getPlatformFontsForNode": CSS.getPlatformFontsForNodeParameters; "CSS.getStyleSheetText": CSS.getStyleSheetTextParameters; "CSS.getLayersForNode": CSS.getLayersForNodeParameters; + "CSS.getLocationForSelector": CSS.getLocationForSelectorParameters; "CSS.trackComputedStyleUpdates": CSS.trackComputedStyleUpdatesParameters; "CSS.takeComputedStyleUpdates": CSS.takeComputedStyleUpdatesParameters; "CSS.setEffectivePropertyValueForNode": CSS.setEffectivePropertyValueForNodeParameters; @@ -19885,6 +19950,7 @@ Error was thrown. "Storage.runBounceTrackingMitigations": Storage.runBounceTrackingMitigationsParameters; "Storage.setAttributionReportingLocalTestingMode": Storage.setAttributionReportingLocalTestingModeParameters; "Storage.setAttributionReportingTracking": Storage.setAttributionReportingTrackingParameters; + "Storage.getRelatedWebsiteSets": Storage.getRelatedWebsiteSetsParameters; "SystemInfo.getInfo": SystemInfo.getInfoParameters; "SystemInfo.getFeatureState": SystemInfo.getFeatureStateParameters; "SystemInfo.getProcessInfo": SystemInfo.getProcessInfoParameters; @@ -20097,6 +20163,7 @@ Error was thrown. "CSS.getPlatformFontsForNode": CSS.getPlatformFontsForNodeReturnValue; "CSS.getStyleSheetText": CSS.getStyleSheetTextReturnValue; "CSS.getLayersForNode": CSS.getLayersForNodeReturnValue; + "CSS.getLocationForSelector": CSS.getLocationForSelectorReturnValue; "CSS.trackComputedStyleUpdates": CSS.trackComputedStyleUpdatesReturnValue; "CSS.takeComputedStyleUpdates": CSS.takeComputedStyleUpdatesReturnValue; "CSS.setEffectivePropertyValueForNode": CSS.setEffectivePropertyValueForNodeReturnValue; @@ -20467,6 +20534,7 @@ Error was thrown. "Storage.runBounceTrackingMitigations": Storage.runBounceTrackingMitigationsReturnValue; "Storage.setAttributionReportingLocalTestingMode": Storage.setAttributionReportingLocalTestingModeReturnValue; "Storage.setAttributionReportingTracking": Storage.setAttributionReportingTrackingReturnValue; + "Storage.getRelatedWebsiteSets": Storage.getRelatedWebsiteSetsReturnValue; "SystemInfo.getInfo": SystemInfo.getInfoReturnValue; "SystemInfo.getFeatureState": SystemInfo.getFeatureStateReturnValue; "SystemInfo.getProcessInfo": SystemInfo.getProcessInfoReturnValue; diff --git a/packages/playwright-core/types/structs.d.ts b/packages/playwright-core/types/structs.d.ts index eadbb2de01..9c621cff35 100644 --- a/packages/playwright-core/types/structs.d.ts +++ b/packages/playwright-core/types/structs.d.ts @@ -40,6 +40,6 @@ export type Unboxed = export type PageFunction0 = string | (() => R | Promise); export type PageFunction = string | ((arg: Unboxed) => R | Promise); export type PageFunctionOn = string | ((on: On, arg2: Unboxed) => R | Promise); -export type SmartHandle = T extends Node ? ElementHandle : JSHandle; +export type SmartHandle = [T] extends [Node] ? ElementHandle : JSHandle; export type ElementHandleForTag = ElementHandle; export type BindingSource = { context: BrowserContext, page: Page, frame: Frame }; diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 95c0ddcdd0..235de894cd 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -1781,26 +1781,32 @@ export interface Page { prependListener(event: 'worker', listener: (worker: Worker) => void): this; /** - * Sometimes, the web page can show an overlay that obstructs elements behind it and prevents certain actions, like - * click, from completing. When such an overlay is shown predictably, we recommend dismissing it as a part of your - * test flow. However, sometimes such an overlay may appear non-deterministically, for example certain cookies consent - * dialogs behave this way. In this case, - * [page.addLocatorHandler(locator, handler)](https://playwright.dev/docs/api/class-page#page-add-locator-handler) - * allows handling an overlay during an action that it would block. + * **NOTE** This method is experimental and its behavior may change in the upcoming releases. * - * This method registers a handler for an overlay that is executed once the locator is visible on the page. The - * handler should get rid of the overlay so that actions blocked by it can proceed. This is useful for - * nondeterministic interstitial pages or dialogs, like a cookie consent dialog. + * When testing a web page, sometimes unexpected overlays like a "Sign up" dialog appear and block actions you want to + * automate, e.g. clicking a button. These overlays don't always show up in the same way or at the same time, making + * them tricky to handle in automated tests. * - * Note that execution time of the handler counts towards the timeout of the action/assertion that executed the - * handler. + * This method lets you set up a special function, called a handler, that activates when it detects that overlay is + * visible. The handler's job is to remove the overlay, allowing your test to continue as if the overlay wasn't there. * - * You can register multiple handlers. However, only a single handler will be running at a time. Any actions inside a - * handler must not require another handler to run. + * Things to keep in mind: + * - When an overlay is shown predictably, we recommend explicitly waiting for it in your test and dismissing it as + * a part of your normal test flow, instead of using + * [page.addLocatorHandler(locator, handler)](https://playwright.dev/docs/api/class-page#page-add-locator-handler). + * - Playwright checks for the overlay every time before executing or retrying an action that requires an + * [actionability check](https://playwright.dev/docs/actionability), or before performing an auto-waiting assertion check. When overlay + * is visible, Playwright calls the handler first, and then proceeds with the action/assertion. Note that the + * handler is only called when you perform an action/assertion - if the overlay becomes visible but you don't + * perform any actions, the handler will not be triggered. + * - The execution time of the handler counts towards the timeout of the action/assertion that executed the handler. + * If your handler takes too long, it might cause timeouts. + * - You can register multiple handlers. However, only a single handler will be running at a time. Make sure the + * actions within a handler don't depend on another handler. * - * **NOTE** Running the interceptor will alter your page state mid-test. For example it will change the currently - * focused element and move the mouse. Make sure that the actions that run after the interceptor are self-contained - * and do not rely on the focus and mouse state.

For example, consider a test that calls + * **NOTE** Running the handler will alter your page state mid-test. For example it will change the currently focused + * element and move the mouse. Make sure that actions that run after the handler are self-contained and do not rely on + * the focus and mouse state being unchanged.

For example, consider a test that calls * [locator.focus([options])](https://playwright.dev/docs/api/class-locator#locator-focus) followed by * [keyboard.press(key[, options])](https://playwright.dev/docs/api/class-keyboard#keyboard-press). If your handler * clicks a button between these two actions, the focused element most likely will be wrong, and key press will happen @@ -1809,17 +1815,18 @@ export interface Page { * problem.

Another example is a series of mouse actions, where * [mouse.move(x, y[, options])](https://playwright.dev/docs/api/class-mouse#mouse-move) is followed by * [mouse.down([options])](https://playwright.dev/docs/api/class-mouse#mouse-down). Again, when the handler runs - * between these two actions, the mouse position will be wrong during the mouse down. Prefer methods like - * [locator.click([options])](https://playwright.dev/docs/api/class-locator#locator-click) that are self-contained. + * between these two actions, the mouse position will be wrong during the mouse down. Prefer self-contained actions + * like [locator.click([options])](https://playwright.dev/docs/api/class-locator#locator-click) that do not rely on + * the state being unchanged by a handler. * * **Usage** * - * An example that closes a cookie dialog when it appears: + * An example that closes a "Sign up to the newsletter" dialog when it appears: * * ```js * // Setup the handler. - * await page.addLocatorHandler(page.getByRole('button', { name: 'Accept all cookies' }), async () => { - * await page.getByRole('button', { name: 'Reject all cookies' }).click(); + * await page.addLocatorHandler(page.getByText('Sign up to the newsletter'), async () => { + * await page.getByRole('button', { name: 'No thanks' }).click(); * }); * * // Write the test as usual. @@ -1832,7 +1839,7 @@ export interface Page { * ```js * // Setup the handler. * await page.addLocatorHandler(page.getByText('Confirm your security details'), async () => { - * await page.getByRole('button', 'Remind me later').click(); + * await page.getByRole('button', { name: 'Remind me later' }).click(); * }); * * // Write the test as usual. @@ -8438,6 +8445,28 @@ export interface BrowserContext { */ pages(): Array; + /** + * Removes cookies from context. At least one of the removal criteria should be provided. + * + * **Usage** + * + * ```js + * await browserContext.removeCookies({ name: 'session-id' }); + * await browserContext.removeCookies({ domain: 'my-origin.com' }); + * await browserContext.removeCookies({ path: '/api/v1' }); + * await browserContext.removeCookies({ name: 'session-id', domain: 'my-origin.com' }); + * ``` + * + * @param filter + */ + removeCookies(filter: { + name?: string; + + domain?: string; + + path?: string; + }): Promise; + /** * Routing provides the capability to modify network requests that are made by any page in the browser context. Once * route is enabled, every request matching the url pattern will stall unless it's continued, fulfilled or aborted. @@ -11433,6 +11462,24 @@ export interface Locator { */ elementHandles(): Promise>; + /** + * Returns a {@link FrameLocator} object pointing to the same `iframe` as this locator. + * + * Useful when you have a {@link Locator} object obtained somewhere, and later on would like to interact with the + * content inside the frame. + * + * **Usage** + * + * ```js + * const locator = page.locator('iframe[name="embedded"]'); + * // ... + * const frameLocator = locator.enterFrame(); + * await frameLocator.getByRole('button').click(); + * ``` + * + */ + enterFrame(): FrameLocator; + /** * Set a value to the input field. * @@ -13125,6 +13172,7 @@ export interface BrowserType { /** * **Chromium-only** Whether to auto-open a Developer Tools panel for each tab. If this option is `true`, the * `headless` option will be set `false`. + * @deprecated Use [debugging tools](https://playwright.dev/docs/debug) instead. */ devtools?: boolean; @@ -13529,6 +13577,7 @@ export interface BrowserType { /** * **Chromium-only** Whether to auto-open a Developer Tools panel for each tab. If this option is `true`, the * `headless` option will be set `false`. + * @deprecated Use [debugging tools](https://playwright.dev/docs/debug) instead. */ devtools?: boolean; @@ -17734,14 +17783,32 @@ export interface FileChooser { * **Converting Locator to FrameLocator** * * If you have a {@link Locator} object pointing to an `iframe` it can be converted to {@link FrameLocator} using - * [`:scope`](https://developer.mozilla.org/en-US/docs/Web/CSS/:scope) CSS selector: + * [locator.enterFrame()](https://playwright.dev/docs/api/class-locator#locator-enter-frame). * - * ```js - * const frameLocator = locator.frameLocator(':scope'); - * ``` + * **Converting FrameLocator to Locator** * + * If you have a {@link FrameLocator} object it can be converted to {@link Locator} pointing to the same `iframe` + * using [frameLocator.exitFrame()](https://playwright.dev/docs/api/class-framelocator#frame-locator-exit-frame). */ export interface FrameLocator { + /** + * Returns a {@link Locator} object pointing to the same `iframe` as this frame locator. + * + * Useful when you have a {@link FrameLocator} object obtained somewhere, and later on would like to interact with the + * `iframe` element. + * + * **Usage** + * + * ```js + * const frameLocator = page.frameLocator('iframe[name="embedded"]'); + * // ... + * const locator = frameLocator.exitFrame(); + * await expect(locator).toBeVisible(); + * ``` + * + */ + exitFrame(): Locator; + /** * Returns locator to the first matching frame. */ @@ -18313,7 +18380,7 @@ export interface Keyboard { * (async () => { * const browser = await chromium.launch({ * logger: { - * isEnabled: (name, severity) => name === 'browser', + * isEnabled: (name, severity) => name === 'api', * log: (name, severity, message, args) => console.log(`${name} ${message}`) * } * }); @@ -20205,6 +20272,7 @@ export interface LaunchOptions { /** * **Chromium-only** Whether to auto-open a Developer Tools panel for each tab. If this option is `true`, the * `headless` option will be set `false`. + * @deprecated Use [debugging tools](https://playwright.dev/docs/debug) instead. */ devtools?: boolean; diff --git a/packages/playwright-ct-core/.eslintrc.js b/packages/playwright-ct-core/.eslintrc.js index ae8768db65..84888f1ae3 100644 --- a/packages/playwright-ct-core/.eslintrc.js +++ b/packages/playwright-ct-core/.eslintrc.js @@ -1,3 +1,3 @@ module.exports = { - extends: "../.eslintrc-with-ts-config.js", + extends: "../../.eslintrc-with-ts-config.js", }; diff --git a/packages/playwright-ct-core/package.json b/packages/playwright-ct-core/package.json index 99dd286fca..fd63ced898 100644 --- a/packages/playwright-ct-core/package.json +++ b/packages/playwright-ct-core/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/experimental-ct-core", - "version": "1.42.0-next", + "version": "1.43.0-next", "description": "Playwright Component Testing Helpers", "repository": { "type": "git", @@ -26,9 +26,9 @@ } }, "dependencies": { - "playwright-core": "1.42.0-next", + "playwright-core": "1.43.0-next", "vite": "^5.0.12", - "playwright": "1.42.0-next" + "playwright": "1.43.0-next" }, "bin": { "playwright": "cli.js" diff --git a/packages/playwright-ct-core/src/tsxTransform.ts b/packages/playwright-ct-core/src/tsxTransform.ts index 0a61d4e3d9..ab33c114ba 100644 --- a/packages/playwright-ct-core/src/tsxTransform.ts +++ b/packages/playwright-ct-core/src/tsxTransform.ts @@ -75,7 +75,7 @@ export default declare((api: BabelAPI) => { const ext = path.extname(importNode.source.value); // Convert all non-JS imports into refs. - if (!allJsExtensions.has(ext)) { + if (artifactExtensions.has(ext)) { for (const specifier of importNode.specifiers) { if (t.isImportNamespaceSpecifier(specifier)) continue; @@ -171,4 +171,29 @@ export function importInfo(importNode: T.ImportDeclaration, specifier: T.ImportS return { localName: specifier.local.name, info: result }; } -const allJsExtensions = new Set(['.js', '.jsx', '.cjs', '.mjs', '.ts', '.tsx', '.cts', '.mts', '']); +const artifactExtensions = new Set([ + // Frameworks + '.vue', + '.svelte', + + // Images + '.jpg', '.jpeg', + '.png', + '.gif', + '.svg', + '.bmp', + '.webp', + '.ico', + + // CSS + '.css', + + // Fonts + '.woff', '.woff2', + '.ttf', + '.otf', + '.eot', + + // Other assets + '.json', +]); \ No newline at end of file diff --git a/packages/playwright-ct-core/types/component.d.ts b/packages/playwright-ct-core/types/component.d.ts index 7502ec2f06..5bd5d7e017 100644 --- a/packages/playwright-ct-core/types/component.d.ts +++ b/packages/playwright-ct-core/types/component.d.ts @@ -14,8 +14,6 @@ * limitations under the License. */ -import type { ImportRegistry } from '../src/injected/importRegistry'; - type JsonPrimitive = string | number | boolean | null; type JsonValue = JsonPrimitive | JsonObject | JsonArray; type JsonArray = JsonValue[]; diff --git a/packages/playwright-ct-react/package.json b/packages/playwright-ct-react/package.json index 5702342623..30b8f15871 100644 --- a/packages/playwright-ct-react/package.json +++ b/packages/playwright-ct-react/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/experimental-ct-react", - "version": "1.42.0-next", + "version": "1.43.0-next", "description": "Playwright Component Testing for React", "repository": { "type": "git", @@ -29,11 +29,10 @@ } }, "dependencies": { - "@playwright/experimental-ct-core": "1.42.0-next", + "@playwright/experimental-ct-core": "1.43.0-next", "@vitejs/plugin-react": "^4.2.1" }, "bin": { - "playwright": "cli.js", - "pw-react": "cli.js" + "playwright": "cli.js" } } diff --git a/packages/playwright-ct-react/registerSource.mjs b/packages/playwright-ct-react/registerSource.mjs index 10dad4c0db..9ad2612bc4 100644 --- a/packages/playwright-ct-react/registerSource.mjs +++ b/packages/playwright-ct-react/registerSource.mjs @@ -40,13 +40,11 @@ function __pwRender(value) { if (isJsxComponent(v)) { const component = v; const props = component.props ? __pwRender(component.props) : {}; - const {children, ...propsWithoutChildren} = props; - /** @type {[any, any, any?]} */ - const createElementArguments = [component.type, propsWithoutChildren]; - if(children){ + const { children, ...propsWithoutChildren } = props; + const createElementArguments = [propsWithoutChildren]; + if (children) createElementArguments.push(children); - } - return { result: __pwReact.createElement(...createElementArguments) }; + return { result: __pwReact.createElement(component.type, ...createElementArguments) }; } }); } diff --git a/packages/playwright-ct-react17/cli.js b/packages/playwright-ct-react17/cli.js index b6f935eb52..9cc834ee95 100755 --- a/packages/playwright-ct-react17/cli.js +++ b/packages/playwright-ct-react17/cli.js @@ -15,8 +15,6 @@ * limitations under the License. */ -const { program, initializePlugin } = require('@playwright/experimental-ct-core/lib/program'); -const { _framework } = require('./index'); +const { program } = require('@playwright/experimental-ct-core/lib/program'); -initializePlugin(_framework); program.parse(process.argv); diff --git a/packages/playwright-ct-react17/package.json b/packages/playwright-ct-react17/package.json index 4e926cca74..1ac183e407 100644 --- a/packages/playwright-ct-react17/package.json +++ b/packages/playwright-ct-react17/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/experimental-ct-react17", - "version": "1.42.0-next", + "version": "1.43.0-next", "description": "Playwright Component Testing for React", "repository": { "type": "git", @@ -29,11 +29,10 @@ } }, "dependencies": { - "@playwright/experimental-ct-core": "1.42.0-next", + "@playwright/experimental-ct-core": "1.43.0-next", "@vitejs/plugin-react": "^4.2.1" }, "bin": { - "playwright": "cli.js", - "pw-react17": "cli.js" + "playwright": "cli.js" } } diff --git a/packages/playwright-ct-react17/registerSource.mjs b/packages/playwright-ct-react17/registerSource.mjs index 8dfc1d24e9..158984f3ac 100644 --- a/packages/playwright-ct-react17/registerSource.mjs +++ b/packages/playwright-ct-react17/registerSource.mjs @@ -40,14 +40,11 @@ function __pwRender(value) { if (isJsxComponent(v)) { const component = v; const props = component.props ? __pwRender(component.props) : {}; - - const {children, ...propsWithoutChildren} = props; - /** @type {[any, any, any?]} */ - const createElementArguments = [component.type, propsWithoutChildren]; - if(children){ + const { children, ...propsWithoutChildren } = props; + const createElementArguments = [propsWithoutChildren]; + if (children) createElementArguments.push(children); - } - return { result: __pwReact.createElement(...createElementArguments) }; + return { result: __pwReact.createElement(component.type, ...createElementArguments) }; } }); } diff --git a/packages/playwright-ct-solid/cli.js b/packages/playwright-ct-solid/cli.js index b6f935eb52..9cc834ee95 100755 --- a/packages/playwright-ct-solid/cli.js +++ b/packages/playwright-ct-solid/cli.js @@ -15,8 +15,6 @@ * limitations under the License. */ -const { program, initializePlugin } = require('@playwright/experimental-ct-core/lib/program'); -const { _framework } = require('./index'); +const { program } = require('@playwright/experimental-ct-core/lib/program'); -initializePlugin(_framework); program.parse(process.argv); diff --git a/packages/playwright-ct-solid/package.json b/packages/playwright-ct-solid/package.json index ac65ccb548..54833eeba3 100644 --- a/packages/playwright-ct-solid/package.json +++ b/packages/playwright-ct-solid/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/experimental-ct-solid", - "version": "1.42.0-next", + "version": "1.43.0-next", "description": "Playwright Component Testing for Solid", "repository": { "type": "git", @@ -29,14 +29,13 @@ } }, "dependencies": { - "@playwright/experimental-ct-core": "1.42.0-next", + "@playwright/experimental-ct-core": "1.43.0-next", "vite-plugin-solid": "^2.7.0" }, "devDependencies": { "solid-js": "^1.7.0" }, "bin": { - "playwright": "cli.js", - "pw-solid": "cli.js" + "playwright": "cli.js" } } diff --git a/packages/playwright-ct-solid/registerSource.mjs b/packages/playwright-ct-solid/registerSource.mjs index a76792c513..d0077dd494 100644 --- a/packages/playwright-ct-solid/registerSource.mjs +++ b/packages/playwright-ct-solid/registerSource.mjs @@ -19,9 +19,7 @@ import { render as __pwSolidRender, createComponent as __pwSolidCreateComponent } from 'solid-js/web'; import __pwH from 'solid-js/h'; - /** @typedef {import('../playwright-ct-core/types/component').JsxComponent} JsxComponent */ -/** @typedef {() => import('solid-js').JSX.Element} FrameworkComponent */ /** * @param {any} component @@ -32,42 +30,20 @@ function isJsxComponent(component) { } /** - * @param {any} child + * @param {any} value */ -function __pwCreateChild(child) { - if (Array.isArray(child)) - return child.map(grandChild => __pwCreateChild(grandChild)); - if (isJsxComponent(child)) - return __pwCreateComponent(child); - return child; -} - -/** - * @param {JsxComponent} component - * @returns {any[] | undefined} - */ -function __pwJsxChildArray(component) { - if (!component.props.children) - return; - if (Array.isArray(component.props.children)) - return component.props.children; - return [component.props.children]; -} - -/** - * @param {JsxComponent} component - */ -function __pwCreateComponent(component) { - const children = __pwJsxChildArray(component)?.map(child => __pwCreateChild(child)).filter(child => { - if (typeof child === 'string') - return !!child.trim(); - return true; +function __pwCreateComponent(value) { + return window.__pwTransformObject(value, v => { + if (isJsxComponent(v)) { + const component = v; + const props = component.props ? __pwCreateComponent(component.props) : {}; + if (typeof component.type === 'string') { + const { children, ...propsWithoutChildren } = props; + return { result: __pwH(component.type, propsWithoutChildren, children) }; + } + return { result: __pwSolidCreateComponent(component.type, props) }; + } }); - - if (typeof component.type === 'string') - return __pwH(component.type, component.props, children); - - return __pwSolidCreateComponent(component.type, { ...component.props, children }); } const __pwUnmountKey = Symbol('unmountKey'); @@ -96,6 +72,7 @@ window.playwrightUnmount = async rootElement => { throw new Error('Component was not mounted'); unmount(); + delete rootElement[__pwUnmountKey]; }; window.playwrightUpdate = async (rootElement, component) => { diff --git a/packages/playwright-ct-svelte/cli.js b/packages/playwright-ct-svelte/cli.js index b6f935eb52..9cc834ee95 100755 --- a/packages/playwright-ct-svelte/cli.js +++ b/packages/playwright-ct-svelte/cli.js @@ -15,8 +15,6 @@ * limitations under the License. */ -const { program, initializePlugin } = require('@playwright/experimental-ct-core/lib/program'); -const { _framework } = require('./index'); +const { program } = require('@playwright/experimental-ct-core/lib/program'); -initializePlugin(_framework); program.parse(process.argv); diff --git a/packages/playwright-ct-svelte/package.json b/packages/playwright-ct-svelte/package.json index f0809a3d4a..4aeaa91a11 100644 --- a/packages/playwright-ct-svelte/package.json +++ b/packages/playwright-ct-svelte/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/experimental-ct-svelte", - "version": "1.42.0-next", + "version": "1.43.0-next", "description": "Playwright Component Testing for Svelte", "repository": { "type": "git", @@ -29,14 +29,13 @@ } }, "dependencies": { - "@playwright/experimental-ct-core": "1.42.0-next", + "@playwright/experimental-ct-core": "1.43.0-next", "@sveltejs/vite-plugin-svelte": "^3.0.1" }, "devDependencies": { "svelte": "^4.2.8" }, "bin": { - "playwright": "cli.js", - "pw-svelte": "cli.js" + "playwright": "cli.js" } } diff --git a/packages/playwright-ct-svelte/registerSource.mjs b/packages/playwright-ct-svelte/registerSource.mjs index 4552a4ba0a..5901458813 100644 --- a/packages/playwright-ct-svelte/registerSource.mjs +++ b/packages/playwright-ct-svelte/registerSource.mjs @@ -42,8 +42,8 @@ function __pwCreateSlots(slots) { for (const slotName in slots) { const template = document - .createRange() - .createContextualFragment(slots[slotName]); + .createRange() + .createContextualFragment(slots[slotName]); svelteSlots[slotName] = [createSlotFn(template)]; } @@ -55,7 +55,8 @@ function __pwCreateSlots(slots) { __pwInsert(target, element, anchor); }, d: function destroy(detaching) { - if (detaching) __pwDetach(element); + if (detaching) + __pwDetach(element); }, l: __pwNoop, }; @@ -108,6 +109,7 @@ window.playwrightUnmount = async rootElement => { if (!svelteComponent) throw new Error('Component was not mounted'); svelteComponent.$destroy(); + delete rootElement[__pwSvelteComponentKey]; }; window.playwrightUpdate = async (rootElement, component) => { diff --git a/packages/playwright-ct-vue/cli.js b/packages/playwright-ct-vue/cli.js index b6f935eb52..9cc834ee95 100755 --- a/packages/playwright-ct-vue/cli.js +++ b/packages/playwright-ct-vue/cli.js @@ -15,8 +15,6 @@ * limitations under the License. */ -const { program, initializePlugin } = require('@playwright/experimental-ct-core/lib/program'); -const { _framework } = require('./index'); +const { program } = require('@playwright/experimental-ct-core/lib/program'); -initializePlugin(_framework); program.parse(process.argv); diff --git a/packages/playwright-ct-vue/package.json b/packages/playwright-ct-vue/package.json index 7ab9b652cb..d125aad45d 100644 --- a/packages/playwright-ct-vue/package.json +++ b/packages/playwright-ct-vue/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/experimental-ct-vue", - "version": "1.42.0-next", + "version": "1.43.0-next", "description": "Playwright Component Testing for Vue", "repository": { "type": "git", @@ -29,11 +29,10 @@ } }, "dependencies": { - "@playwright/experimental-ct-core": "1.42.0-next", + "@playwright/experimental-ct-core": "1.43.0-next", "@vitejs/plugin-vue": "^4.2.1" }, "bin": { - "playwright": "cli.js", - "pw-vue": "cli.js" + "playwright": "cli.js" } } diff --git a/packages/playwright-ct-vue/registerSource.mjs b/packages/playwright-ct-vue/registerSource.mjs index 5256a241a4..07ce5298f4 100644 --- a/packages/playwright-ct-vue/registerSource.mjs +++ b/packages/playwright-ct-vue/registerSource.mjs @@ -88,6 +88,9 @@ function __pwCreateSlot(html) { }; } +/** + * @param {string | string[]} slot + */ function __pwSlotToFunction(slot) { if (typeof slot === 'string') return __pwCreateSlot(slot)(); @@ -175,7 +178,11 @@ function __pwCreateComponent(component) { return { Component: component.type, props, slots: lastArg, listeners }; } +/** + * @param {any} slots + */ function __pwWrapFunctions(slots) { + /** @type {import('vue').ComponentInternalInstance['slots']} */ const slotsWithRenderFunctions = {}; if (!Array.isArray(slots)) { for (const [key, value] of Object.entries(slots || {})) @@ -198,25 +205,27 @@ function __pwCreateWrapper(component) { return wrapper; } -/** - * @returns {any} - */ -function __pwCreateDevTools() { - return { +function __pwSetDevTools() { + __pwSetDevtoolsHook({ emit(eventType, ...payload) { - if (eventType === 'component:emit') { - const [, componentVM, event, eventArgs] = payload; - for (const [wrapper, listeners] of __pwAllListeners) { - if (wrapper.component !== componentVM) - continue; - const listener = listeners[event]; - if (!listener) - return; - listener(...eventArgs); - } + if (eventType !== 'component:emit') + return; + + const [, componentVM, event, eventArgs] = payload; + for (const [wrapper, listeners] of __pwAllListeners) { + if (wrapper.component !== componentVM) + continue; + const listener = listeners[event]; + if (!listener) + return; + listener(...eventArgs); } - } - }; + }, + on() {}, + off() {}, + once() {}, + appRecords: [] + }, {}); } const __pwAppKey = Symbol('appKey'); @@ -230,7 +239,7 @@ window.playwrightMount = async (component, rootElement, hooksConfig) => { return wrapper; } }); - __pwSetDevtoolsHook(__pwCreateDevTools(), {}); + __pwSetDevTools(); for (const hook of window.__pw_hooks_before_mount || []) await hook({ app, hooksConfig }); @@ -242,13 +251,16 @@ window.playwrightMount = async (component, rootElement, hooksConfig) => { }; window.playwrightUnmount = async rootElement => { - const app = /** @type {import('vue').App} */ (rootElement[__pwAppKey]); + /** @type {import('vue').App | undefined} */ + const app = rootElement[__pwAppKey]; if (!app) throw new Error('Component was not mounted'); app.unmount(); + delete rootElement[__pwAppKey]; }; window.playwrightUpdate = async (rootElement, component) => { + /** @type {import('vue').VNode | undefined} */ const wrapper = rootElement[__pwWrapperKey]; if (!wrapper) throw new Error('Component was not mounted'); diff --git a/packages/playwright-ct-vue2/cli.js b/packages/playwright-ct-vue2/cli.js index b6f935eb52..9cc834ee95 100755 --- a/packages/playwright-ct-vue2/cli.js +++ b/packages/playwright-ct-vue2/cli.js @@ -15,8 +15,6 @@ * limitations under the License. */ -const { program, initializePlugin } = require('@playwright/experimental-ct-core/lib/program'); -const { _framework } = require('./index'); +const { program } = require('@playwright/experimental-ct-core/lib/program'); -initializePlugin(_framework); program.parse(process.argv); diff --git a/packages/playwright-ct-vue2/package.json b/packages/playwright-ct-vue2/package.json index 0ba67d6bd6..aed18496f7 100644 --- a/packages/playwright-ct-vue2/package.json +++ b/packages/playwright-ct-vue2/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/experimental-ct-vue2", - "version": "1.42.0-next", + "version": "1.43.0-next", "description": "Playwright Component Testing for Vue2", "repository": { "type": "git", @@ -29,14 +29,13 @@ } }, "dependencies": { - "@playwright/experimental-ct-core": "1.42.0-next", + "@playwright/experimental-ct-core": "1.43.0-next", "@vitejs/plugin-vue2": "^2.2.0" }, "devDependencies": { "vue": "^2.7.14" }, "bin": { - "playwright": "cli.js", - "pw-vue2": "cli.js" + "playwright": "cli.js" } } diff --git a/packages/playwright-ct-vue2/registerSource.mjs b/packages/playwright-ct-vue2/registerSource.mjs index b0a7ae71dc..19b4d41c08 100644 --- a/packages/playwright-ct-vue2/registerSource.mjs +++ b/packages/playwright-ct-vue2/registerSource.mjs @@ -182,6 +182,7 @@ window.playwrightUnmount = async rootElement => { throw new Error('Component was not mounted'); component.$destroy(); component.$el.remove(); + delete rootElement[instanceKey]; }; window.playwrightUpdate = async (element, options) => { diff --git a/packages/playwright-firefox/cli.js b/packages/playwright-firefox/cli.js index 86adb86a84..f46d8b409e 100755 --- a/packages/playwright-firefox/cli.js +++ b/packages/playwright-firefox/cli.js @@ -15,5 +15,5 @@ * limitations under the License. */ -const { program } = require('playwright-core/lib/program'); +const { program } = require('playwright-core/lib/cli/program'); program.parse(process.argv); diff --git a/packages/playwright-firefox/package.json b/packages/playwright-firefox/package.json index 83c9f54637..e2aed567e6 100644 --- a/packages/playwright-firefox/package.json +++ b/packages/playwright-firefox/package.json @@ -1,6 +1,6 @@ { "name": "playwright-firefox", - "version": "1.42.0-next", + "version": "1.43.0-next", "description": "A high-level API to automate Firefox", "repository": { "type": "git", @@ -30,6 +30,6 @@ "install": "node install.js" }, "dependencies": { - "playwright-core": "1.42.0-next" + "playwright-core": "1.43.0-next" } } diff --git a/packages/playwright-test/package.json b/packages/playwright-test/package.json index ee88606959..c3174bde51 100644 --- a/packages/playwright-test/package.json +++ b/packages/playwright-test/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/test", - "version": "1.42.0-next", + "version": "1.43.0-next", "description": "A high-level API to automate web browsers", "repository": { "type": "git", @@ -30,6 +30,6 @@ }, "scripts": {}, "dependencies": { - "playwright": "1.42.0-next" + "playwright": "1.43.0-next" } } diff --git a/packages/playwright-webkit/cli.js b/packages/playwright-webkit/cli.js index 86adb86a84..f46d8b409e 100755 --- a/packages/playwright-webkit/cli.js +++ b/packages/playwright-webkit/cli.js @@ -15,5 +15,5 @@ * limitations under the License. */ -const { program } = require('playwright-core/lib/program'); +const { program } = require('playwright-core/lib/cli/program'); program.parse(process.argv); diff --git a/packages/playwright-webkit/package.json b/packages/playwright-webkit/package.json index a72ba97917..a269a30da4 100644 --- a/packages/playwright-webkit/package.json +++ b/packages/playwright-webkit/package.json @@ -1,6 +1,6 @@ { "name": "playwright-webkit", - "version": "1.42.0-next", + "version": "1.43.0-next", "description": "A high-level API to automate WebKit", "repository": { "type": "git", @@ -30,6 +30,6 @@ "install": "node install.js" }, "dependencies": { - "playwright-core": "1.42.0-next" + "playwright-core": "1.43.0-next" } } diff --git a/packages/playwright/.eslintrc.js b/packages/playwright/.eslintrc.js index 67c9b6313d..b7e707b35f 100644 --- a/packages/playwright/.eslintrc.js +++ b/packages/playwright/.eslintrc.js @@ -1,14 +1,5 @@ -const path = require('path'); - module.exports = { - extends: '../.eslintrc.js', - parser: "@typescript-eslint/parser", - plugins: ["@typescript-eslint", "notice"], - parserOptions: { - ecmaVersion: 9, - sourceType: "module", - project: path.join(__dirname, '..', '..', 'tsconfig.json'), - }, + extends: '../../.eslintrc-with-ts-config.js', rules: { '@typescript-eslint/no-floating-promises': 'error', }, diff --git a/packages/playwright/package.json b/packages/playwright/package.json index 39e4cf23ec..5072f27c14 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -1,6 +1,6 @@ { "name": "playwright", - "version": "1.42.0-next", + "version": "1.43.0-next", "description": "A high-level API to automate web browsers", "repository": { "type": "git", @@ -58,7 +58,7 @@ }, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.42.0-next" + "playwright-core": "1.43.0-next" }, "optionalDependencies": { "fsevents": "2.3.2" diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index d1c86fe159..cfe294a75c 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -58,11 +58,6 @@ export class FullConfigInternal { testIdMatcher?: Matcher; defineConfigWasUsed = false; - // TODO: when merging reports, there could be no internal config. This is very unfortunate. - static from(config: FullConfig): FullConfigInternal | undefined { - return (config as any)[configInternalSymbol]; - } - constructor(location: ConfigLocation, userConfig: Config, configCLIOverrides: ConfigCLIOverrides) { if (configCLIOverrides.projects && userConfig.projects) throw new Error(`Cannot use --browser option when configuration file defines projects. Specify browserName in the projects instead.`); diff --git a/packages/playwright/src/common/configLoader.ts b/packages/playwright/src/common/configLoader.ts index 59ba309b2e..08a9c0a48d 100644 --- a/packages/playwright/src/common/configLoader.ts +++ b/packages/playwright/src/common/configLoader.ts @@ -334,25 +334,33 @@ export async function loadEmptyConfigForMergeReports() { } export function restartWithExperimentalTsEsm(configFile: string | undefined, force: boolean = false): boolean { - const nodeVersion = +process.versions.node.split('.')[0]; - // New experimental loader is only supported on Node 16+. - if (nodeVersion < 16) - return false; - if (!configFile && !force) - return false; + // Opt-out switch. if (process.env.PW_DISABLE_TS_ESM) return false; - // Node.js < 20 + + // There are two esm loader APIs: + // - Older API that needs a process restart. Available in Node 16, 17, and non-latest 18, 19 and 20. + // - Newer API that works in-process. Available in Node 21+ and latest 18, 19 and 20. + + // First check whether we have already restarted with the ESM loader from the older API. if ((globalThis as any).__esmLoaderPortPreV20) { // clear execArgv after restart, so that childProcess.fork in user code does not inherit our loader. process.execArgv = execArgvWithoutExperimentalLoaderOptions(); return false; } - if (!force && !fileIsModule(configFile!)) - return false; - // Node.js < 20 + // Now check for the newer API presence. if (!require('node:module').register) { + // Older API is experimental, only supported on Node 16+. + const nodeVersion = +process.versions.node.split('.')[0]; + if (nodeVersion < 16) + return false; + + // With older API requiring a process restart, do so conditionally on the config. + const configIsModule = !!configFile && fileIsModule(configFile); + if (!force && !configIsModule) + return false; + const innerProcess = (require('child_process') as typeof import('child_process')).fork(require.resolve('../../cli'), process.argv.slice(2), { env: { ...process.env, @@ -367,7 +375,8 @@ export function restartWithExperimentalTsEsm(configFile: string | undefined, for }); return true; } - // Nodejs >= 21 + + // With the newer API, always enable the ESM loader, because it does not need a restart. registerESMLoader(); return false; } diff --git a/packages/playwright/src/common/ipc.ts b/packages/playwright/src/common/ipc.ts index bb35f6c06d..3c3ad40f02 100644 --- a/packages/playwright/src/common/ipc.ts +++ b/packages/playwright/src/common/ipc.ts @@ -15,7 +15,7 @@ */ import util from 'util'; -import { serializeCompilationCache } from '../transform/compilationCache'; +import { type SerializedCompilationCache, serializeCompilationCache } from '../transform/compilationCache'; import type { ConfigLocation, FullConfigInternal } from './config'; import type { ReporterDescription, TestInfoError, TestStatus } from '../../types/test'; @@ -43,7 +43,7 @@ export type ConfigCLIOverrides = { export type SerializedConfig = { location: ConfigLocation; configCLIOverrides: ConfigCLIOverrides; - compilationCache: any; + compilationCache?: SerializedCompilationCache; }; export type TtyParams = { diff --git a/packages/playwright/src/common/process.ts b/packages/playwright/src/common/process.ts index f082e958c2..23de620649 100644 --- a/packages/playwright/src/common/process.ts +++ b/packages/playwright/src/common/process.ts @@ -131,4 +131,26 @@ function setTtyParams(stream: WriteStream, params: TtyParams) { count = 16; return count <= 2 ** params.colorDepth; })as any; + + // Stubs for the rest of the methods to avoid exceptions in user code. + stream.clearLine = (dir: any, callback?: () => void) => { + callback?.(); + return true; + }; + stream.clearScreenDown = (callback?: () => void) => { + callback?.(); + return true; + }; + (stream as any).cursorTo = (x: number, y?: number | (() => void), callback?: () => void) => { + if (callback) + callback(); + else if (y instanceof Function) + y(); + return true; + }; + stream.moveCursor = (dx: number, dy: number, callback?: () => void) => { + callback?.(); + return true; + }; + stream.getWindowSize = () => [stream.columns, stream.rows]; } diff --git a/packages/playwright/src/common/suiteUtils.ts b/packages/playwright/src/common/suiteUtils.ts index c2140a8a96..81dc9d5465 100644 --- a/packages/playwright/src/common/suiteUtils.ts +++ b/packages/playwright/src/common/suiteUtils.ts @@ -90,7 +90,8 @@ export function applyRepeatEachIndex(project: FullProjectInternal, fileSuite: Su // Assign test properties with project-specific values. fileSuite.forEachTest((test, suite) => { if (repeatEachIndex) { - const testIdExpression = `[project=${project.id}]${test.titlePath().join('\x1e')} (repeat:${repeatEachIndex})`; + const [file, ...titles] = test.titlePath(); + const testIdExpression = `[project=${project.id}]${toPosixPath(file)}\x1e${titles.join('\x1e')} (repeat:${repeatEachIndex})`; const testId = suite._fileId + '-' + calculateSha1(testIdExpression).slice(0, 20); test.id = testId; test.repeatEachIndex = repeatEachIndex; diff --git a/packages/playwright/src/common/test.ts b/packages/playwright/src/common/test.ts index ec67168826..0d05ac5dfd 100644 --- a/packages/playwright/src/common/test.ts +++ b/packages/playwright/src/common/test.ts @@ -16,7 +16,6 @@ import type { FixturePool } from './fixtures'; import type * as reporterTypes from '../../types/testReporter'; -import type { SuitePrivate } from '../../types/reporterPrivate'; import type { TestTypeImpl } from './testType'; import { rootTestType } from './testType'; import type { Annotation, FixturesWithLocation, FullProjectInternal } from './config'; @@ -40,7 +39,7 @@ export type Modifier = { description: string | undefined }; -export class Suite extends Base implements SuitePrivate { +export class Suite extends Base { location?: Location; parent?: Suite; _use: FixturesWithLocation[] = []; diff --git a/packages/playwright/src/common/testType.ts b/packages/playwright/src/common/testType.ts index 1411b249b4..35e2a4668c 100644 --- a/packages/playwright/src/common/testType.ts +++ b/packages/playwright/src/common/testType.ts @@ -21,7 +21,7 @@ import { wrapFunctionWithLocation } from '../transform/transform'; import type { FixturesWithLocation } from './config'; import type { Fixtures, TestType, TestDetails } from '../../types/test'; import type { Location } from '../../types/testReporter'; -import { getPackageManagerExecCommand } from 'playwright-core/lib/utils'; +import { getPackageManagerExecCommand, zones } from 'playwright-core/lib/utils'; const testTypeSymbol = Symbol('testType'); @@ -263,9 +263,16 @@ export class TestTypeImpl { const testInfo = currentTestInfo(); if (!testInfo) throw new Error(`test.step() can only be called from a test`); - return testInfo._runAsStep({ category: 'test.step', title, box: options.box }, async () => { - // Make sure that internal "step" is not leaked to the user callback. - return await body(); + const step = testInfo._addStep({ wallTime: Date.now(), category: 'test.step', title, box: options.box }); + return await zones.run('stepZone', step, async () => { + try { + const result = await body(); + step.complete({}); + return result; + } catch (error) { + step.complete({ error }); + throw error; + } }); } diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 59b70a1f62..2c105a2640 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -302,8 +302,8 @@ const playwrightFixtures: Fixtures = ({ const contexts = new Map(); await use(async options => { - const hook = hookType(testInfoImpl); - if (hook) { + const hook = testInfoImpl._currentHookType(); + if (hook === 'beforeAll' || hook === 'afterAll') { throw new Error([ `"context" and "page" fixtures are not supported in "${hook}" since they are created on a per-test basis.`, `If you would like to reuse a single page between tests, create context manually with browser.newContext(). See https://aka.ms/playwright/reuse-page for details.`, @@ -396,12 +396,6 @@ const playwrightFixtures: Fixtures = ({ }, }); -function hookType(testInfo: TestInfoImpl): 'beforeAll' | 'afterAll' | undefined { - const type = testInfo._timeoutManager.currentRunnableType(); - if (type === 'beforeAll' || type === 'afterAll') - return type; -} - type StackFrame = { file: string, line?: number, diff --git a/packages/trace-viewer/src/events.ts b/packages/playwright/src/isomorphic/events.ts similarity index 100% rename from packages/trace-viewer/src/events.ts rename to packages/playwright/src/isomorphic/events.ts diff --git a/packages/playwright/src/isomorphic/teleReceiver.ts b/packages/playwright/src/isomorphic/teleReceiver.ts index a698f2b119..e675ce336b 100644 --- a/packages/playwright/src/isomorphic/teleReceiver.ts +++ b/packages/playwright/src/isomorphic/teleReceiver.ts @@ -14,25 +14,19 @@ * limitations under the License. */ -import type { FullConfig, FullResult, Location, TestError, TestResult, TestStatus, TestStep } from '../../types/testReporter'; import type { Annotation } from '../common/config'; import type { FullProject, Metadata } from '../../types/test'; import type * as reporterTypes from '../../types/testReporter'; -import type { SuitePrivate } from '../../types/reporterPrivate'; import type { ReporterV2 } from '../reporters/reporterV2'; -import { StringInternPool } from './stringInternPool'; -export type JsonLocation = Location; +export type StringIntern = (s: string) => string; +export type JsonLocation = reporterTypes.Location; export type JsonError = string; export type JsonStackFrame = { file: string, line: number, column: number }; export type JsonStdIOType = 'stdout' | 'stderr'; -export type JsonConfig = Pick & { - listOnly: boolean; -}; - -export type MergeReporterConfig = Pick; +export type JsonConfig = Pick; export type JsonPattern = { s?: string; @@ -40,18 +34,20 @@ export type JsonPattern = { }; export type JsonProject = { - id: string; grep: JsonPattern[]; grepInvert: JsonPattern[]; metadata: Metadata; name: string; dependencies: string[]; + // This is relative to root dir. snapshotDir: string; + // This is relative to root dir. outputDir: string; repeatEach: number; retries: number; suites: JsonSuite[]; teardown?: string; + // This is relative to root dir. testDir: string; testIgnore: JsonPattern[]; testMatch: JsonPattern[]; @@ -59,13 +55,10 @@ export type JsonProject = { }; export type JsonSuite = { - type: 'root' | 'project' | 'file' | 'describe'; title: string; location?: JsonLocation; suites: JsonSuite[]; tests: JsonTestCase[]; - fileId: string | undefined; - parallelMode: 'none' | 'default' | 'serial' | 'parallel'; }; export type JsonTestCase = { @@ -74,11 +67,12 @@ export type JsonTestCase = { location: JsonLocation; retries: number; tags?: string[]; + repeatEachIndex: number; }; export type JsonTestEnd = { testId: string; - expectedStatus: TestStatus; + expectedStatus: reporterTypes.TestStatus; timeout: number; annotations: { type: string, description?: string }[]; }; @@ -91,13 +85,13 @@ export type JsonTestResultStart = { startTime: number; }; -export type JsonAttachment = Omit & { base64?: string }; +export type JsonAttachment = Omit & { base64?: string }; export type JsonTestResultEnd = { id: string; duration: number; - status: TestStatus; - errors: TestError[]; + status: reporterTypes.TestStatus; + errors: reporterTypes.TestError[]; attachments: JsonAttachment[]; }; @@ -107,17 +101,17 @@ export type JsonTestStepStart = { title: string; category: string, startTime: number; - location?: Location; + location?: reporterTypes.Location; }; export type JsonTestStepEnd = { id: string; duration: number; - error?: TestError; + error?: reporterTypes.TestError; }; export type JsonFullResult = { - status: FullResult['status']; + status: reporterTypes.FullResult['status']; startTime: number; duration: number; }; @@ -127,25 +121,32 @@ export type JsonEvent = { params: any }; +type TeleReporterReceiverOptions = { + mergeProjects: boolean; + mergeTestCases: boolean; + resolvePath: (rootDir: string, relativePath: string) => string; + configOverrides?: Pick; + clearPreviousResultsWhenTestBegins?: boolean; +}; + export class TeleReporterReceiver { private _rootSuite: TeleSuite; - private _pathSeparator: string; + private _options: TeleReporterReceiverOptions; private _reporter: Partial; private _tests = new Map(); private _rootDir!: string; - private _listOnly = false; - private _clearPreviousResultsWhenTestBegins: boolean = false; - private _reuseTestCases: boolean; - private _reportConfig: MergeReporterConfig | undefined; - private _config!: FullConfig; - private _stringPool = new StringInternPool(); + private _config!: reporterTypes.FullConfig; - constructor(pathSeparator: string, reporter: Partial, reuseTestCases: boolean, reportConfig?: MergeReporterConfig) { + constructor(reporter: Partial, options: TeleReporterReceiverOptions) { this._rootSuite = new TeleSuite('', 'root'); - this._pathSeparator = pathSeparator; + this._options = options; this._reporter = reporter; - this._reuseTestCases = reuseTestCases; - this._reportConfig = reportConfig; + } + + reset() { + this._rootSuite.suites = []; + this._rootSuite.tests = []; + this._tests.clear(); } dispatch(message: JsonEvent): Promise | void { @@ -192,19 +193,14 @@ export class TeleReporterReceiver { return this._onExit(); } - _setClearPreviousResultsWhenTestBegins() { - this._clearPreviousResultsWhenTestBegins = true; - } - private _onConfigure(config: JsonConfig) { this._rootDir = config.rootDir; - this._listOnly = config.listOnly; this._config = this._parseConfig(config); this._reporter.onConfigure?.(this._config); } private _onProject(project: JsonProject) { - let projectSuite = this._rootSuite.suites.find(suite => suite.project()!.__projectId === project.id); + let projectSuite = this._options.mergeProjects ? this._rootSuite.suites.find(suite => suite.project()!.name === project.name) : undefined; if (!projectSuite) { projectSuite = new TeleSuite(project.name, 'project'); this._rootSuite.suites.push(projectSuite); @@ -213,23 +209,6 @@ export class TeleReporterReceiver { // Always update project in watch mode. projectSuite._project = this._parseProject(project); this._mergeSuitesInto(project.suites, projectSuite); - - // Remove deleted tests when listing. Empty suites will be auto-filtered - // in the UI layer. - if (this._listOnly) { - const testIds = new Set(); - const collectIds = (suite: JsonSuite) => { - suite.tests.map(t => t.testId).forEach(testId => testIds.add(testId)); - suite.suites.forEach(collectIds); - }; - project.suites.forEach(collectIds); - - const filterTests = (suite: TeleSuite) => { - suite.tests = suite.tests.filter(t => testIds.has(t.id)); - suite.suites.forEach(filterTests); - }; - filterTests(projectSuite); - } } private _onBegin() { @@ -238,14 +217,13 @@ export class TeleReporterReceiver { private _onTestBegin(testId: string, payload: JsonTestResultStart) { const test = this._tests.get(testId)!; - if (this._clearPreviousResultsWhenTestBegins) + if (this._options.clearPreviousResultsWhenTestBegins) test._clearResults(); const testResult = test._createTestResult(payload.id); testResult.retry = payload.retry; testResult.workerIndex = payload.workerIndex; testResult.parallelIndex = payload.parallelIndex; testResult.setStartTimeNumber(payload.startTime); - testResult.statusEx = 'running'; this._reporter.onTestBegin?.(test, testResult); } @@ -254,22 +232,21 @@ export class TeleReporterReceiver { test.timeout = testEndPayload.timeout; test.expectedStatus = testEndPayload.expectedStatus; test.annotations = testEndPayload.annotations; - const result = test.resultsMap.get(payload.id)!; + const result = test._resultsMap.get(payload.id)!; result.duration = payload.duration; result.status = payload.status; - result.statusEx = payload.status; result.errors = payload.errors; result.error = result.errors?.[0]; result.attachments = this._parseAttachments(payload.attachments); this._reporter.onTestEnd?.(test, result); // Free up the memory as won't see these step ids. - result.stepMap = new Map(); + result._stepMap = new Map(); } private _onStepBegin(testId: string, resultId: string, payload: JsonTestStepStart) { const test = this._tests.get(testId)!; - const result = test.resultsMap.get(resultId)!; - const parentStep = payload.parentStepId ? result.stepMap.get(payload.parentStepId) : undefined; + const result = test._resultsMap.get(resultId)!; + const parentStep = payload.parentStepId ? result._stepMap.get(payload.parentStepId) : undefined; const location = this._absoluteLocation(payload.location); const step = new TeleTestStep(payload, parentStep, location); @@ -277,27 +254,27 @@ export class TeleReporterReceiver { parentStep.steps.push(step); else result.steps.push(step); - result.stepMap.set(payload.id, step); + result._stepMap.set(payload.id, step); this._reporter.onStepBegin?.(test, result, step); } private _onStepEnd(testId: string, resultId: string, payload: JsonTestStepEnd) { const test = this._tests.get(testId)!; - const result = test.resultsMap.get(resultId)!; - const step = result.stepMap.get(payload.id)!; + const result = test._resultsMap.get(resultId)!; + const step = result._stepMap.get(payload.id)!; step.duration = payload.duration; step.error = payload.error; this._reporter.onStepEnd?.(test, result, step); } - private _onError(error: TestError) { + private _onError(error: reporterTypes.TestError) { this._reporter.onError?.(error); } private _onStdIO(type: JsonStdIOType, testId: string | undefined, resultId: string | undefined, data: string, isBase64: boolean) { const chunk = isBase64 ? ((globalThis as any).Buffer ? Buffer.from(data, 'base64') : atob(data)) : data; const test = testId ? this._tests.get(testId) : undefined; - const result = test && resultId ? test.resultsMap.get(resultId) : undefined; + const result = test && resultId ? test._resultsMap.get(resultId) : undefined; if (type === 'stdout') { result?.stdout.push(chunk); this._reporter.onStdOut?.(chunk, test, result); @@ -316,25 +293,22 @@ export class TeleReporterReceiver { } private _onExit(): Promise | void { - // Free up the memory from the string pool. - this._stringPool = new StringInternPool(); return this._reporter.onExit?.(); } - private _parseConfig(config: JsonConfig): FullConfig { + private _parseConfig(config: JsonConfig): reporterTypes.FullConfig { const result = { ...baseFullConfig, ...config }; - if (this._reportConfig) { - result.configFile = this._reportConfig.configFile; - result.reportSlowTests = this._reportConfig.reportSlowTests; - result.quiet = this._reportConfig.quiet; - result.reporter = [...this._reportConfig.reporter]; + if (this._options.configOverrides) { + result.configFile = this._options.configOverrides.configFile; + result.reportSlowTests = this._options.configOverrides.reportSlowTests; + result.quiet = this._options.configOverrides.quiet; + result.reporter = [...this._options.configOverrides.reporter]; } return result; } private _parseProject(project: JsonProject): TeleFullProject { return { - __projectId: project.id, metadata: project.metadata, name: project.name, outputDir: this._absolutePath(project.outputDir), @@ -353,7 +327,7 @@ export class TeleReporterReceiver { }; } - private _parseAttachments(attachments: JsonAttachment[]): TestResult['attachments'] { + private _parseAttachments(attachments: JsonAttachment[]): reporterTypes.TestResult['attachments'] { return attachments.map(a => { return { ...a, @@ -366,13 +340,11 @@ export class TeleReporterReceiver { for (const jsonSuite of jsonSuites) { let targetSuite = parent.suites.find(s => s.title === jsonSuite.title); if (!targetSuite) { - targetSuite = new TeleSuite(jsonSuite.title, jsonSuite.type); + targetSuite = new TeleSuite(jsonSuite.title, parent._type === 'project' ? 'file' : 'describe'); targetSuite.parent = parent; parent.suites.push(targetSuite); } targetSuite.location = this._absoluteLocation(jsonSuite.location); - targetSuite._fileId = jsonSuite.fileId; - targetSuite._parallelMode = jsonSuite.parallelMode; this._mergeSuitesInto(jsonSuite.suites, targetSuite); this._mergeTestsInto(jsonSuite.tests, targetSuite); } @@ -380,9 +352,9 @@ export class TeleReporterReceiver { private _mergeTestsInto(jsonTests: JsonTestCase[], parent: TeleSuite) { for (const jsonTest of jsonTests) { - let targetTest = this._reuseTestCases ? parent.tests.find(s => s.title === jsonTest.title) : undefined; + let targetTest = this._options.mergeTestCases ? parent.tests.find(s => s.title === jsonTest.title && s.repeatEachIndex === jsonTest.repeatEachIndex) : undefined; if (!targetTest) { - targetTest = new TeleTestCase(jsonTest.testId, jsonTest.title, this._absoluteLocation(jsonTest.location)); + targetTest = new TeleTestCase(jsonTest.testId, jsonTest.title, this._absoluteLocation(jsonTest.location), jsonTest.repeatEachIndex); targetTest.parent = parent; parent.tests.push(targetTest); this._tests.set(targetTest.id, targetTest); @@ -399,9 +371,9 @@ export class TeleReporterReceiver { return test; } - private _absoluteLocation(location: Location): Location; - private _absoluteLocation(location?: Location): Location | undefined; - private _absoluteLocation(location: Location | undefined): Location | undefined { + private _absoluteLocation(location: reporterTypes.Location): reporterTypes.Location; + private _absoluteLocation(location?: reporterTypes.Location): reporterTypes.Location | undefined; + private _absoluteLocation(location: reporterTypes.Location | undefined): reporterTypes.Location | undefined { if (!location) return location; return { @@ -413,23 +385,21 @@ export class TeleReporterReceiver { private _absolutePath(relativePath: string): string; private _absolutePath(relativePath?: string): string | undefined; private _absolutePath(relativePath?: string): string | undefined { - if (!relativePath) - return relativePath; - return this._stringPool.internString(this._rootDir + this._pathSeparator + relativePath); + if (relativePath === undefined) + return; + return this._options.resolvePath(this._rootDir, relativePath); } - } -export class TeleSuite implements SuitePrivate { +export class TeleSuite implements reporterTypes.Suite { title: string; - location?: Location; + location?: reporterTypes.Location; parent?: TeleSuite; _requireFile: string = ''; suites: TeleSuite[] = []; tests: TeleTestCase[] = []; _timeout: number | undefined; _retries: number | undefined; - _fileId: string | undefined; _project: TeleFullProject | undefined; _parallelMode: 'none' | 'default' | 'serial' | 'parallel' = 'none'; readonly _type: 'root' | 'project' | 'file' | 'describe'; @@ -470,7 +440,7 @@ export class TeleTestCase implements reporterTypes.TestCase { title: string; fn = () => {}; results: TeleTestResult[] = []; - location: Location; + location: reporterTypes.Location; parent!: TeleSuite; expectedStatus: reporterTypes.TestStatus = 'passed'; @@ -481,12 +451,13 @@ export class TeleTestCase implements reporterTypes.TestCase { repeatEachIndex = 0; id: string; - resultsMap = new Map(); + _resultsMap = new Map(); - constructor(id: string, title: string, location: Location) { + constructor(id: string, title: string, location: reporterTypes.Location, repeatEachIndex: number) { this.id = id; this.title = title; this.location = location; + this.repeatEachIndex = repeatEachIndex; } titlePath(): string[] { @@ -520,28 +491,28 @@ export class TeleTestCase implements reporterTypes.TestCase { _clearResults() { this.results = []; - this.resultsMap.clear(); + this._resultsMap.clear(); } _createTestResult(id: string): TeleTestResult { const result = new TeleTestResult(this.results.length); this.results.push(result); - this.resultsMap.set(id, result); + this._resultsMap.set(id, result); return result; } } -class TeleTestStep implements TestStep { +class TeleTestStep implements reporterTypes.TestStep { title: string; category: string; - location: Location | undefined; - parent: TestStep | undefined; + location: reporterTypes.Location | undefined; + parent: reporterTypes.TestStep | undefined; duration: number = -1; - steps: TestStep[] = []; + steps: reporterTypes.TestStep[] = []; private _startTime: number = 0; - constructor(payload: JsonTestStepStart, parentStep: TestStep | undefined, location: Location | undefined) { + constructor(payload: JsonTestStepStart, parentStep: reporterTypes.TestStep | undefined, location: reporterTypes.Location | undefined) { this.title = payload.title; this.category = payload.category; this.location = location; @@ -571,13 +542,12 @@ class TeleTestResult implements reporterTypes.TestResult { stdout: reporterTypes.TestResult['stdout'] = []; stderr: reporterTypes.TestResult['stderr'] = []; attachments: reporterTypes.TestResult['attachments'] = []; - status: TestStatus = 'skipped'; + status: reporterTypes.TestStatus = 'skipped'; steps: TeleTestStep[] = []; errors: reporterTypes.TestResult['errors'] = []; error: reporterTypes.TestResult['error']; - stepMap: Map = new Map(); - statusEx: reporterTypes.TestResult['status'] | 'scheduled' | 'running' = 'scheduled'; + _stepMap: Map = new Map(); private _startTime: number = 0; @@ -598,9 +568,9 @@ class TeleTestResult implements reporterTypes.TestResult { } } -export type TeleFullProject = FullProject & { __projectId: string }; +export type TeleFullProject = FullProject; -export const baseFullConfig: FullConfig = { +export const baseFullConfig: reporterTypes.FullConfig = { forbidOnly: false, fullyParallel: false, globalSetup: null, diff --git a/packages/playwright/src/isomorphic/testServerConnection.ts b/packages/playwright/src/isomorphic/testServerConnection.ts new file mode 100644 index 0000000000..3a07fbcb31 --- /dev/null +++ b/packages/playwright/src/isomorphic/testServerConnection.ts @@ -0,0 +1,183 @@ +/** + * 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 { TestServerInterface, TestServerInterfaceEvents } from '@testIsomorphic/testServerInterface'; +import type * as reporterTypes from 'playwright/types/testReporter'; +import * as events from './events'; + +export class TestServerConnection implements TestServerInterface, TestServerInterfaceEvents { + readonly onClose: events.Event; + readonly onReport: events.Event; + readonly onStdio: events.Event<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>; + readonly onListChanged: events.Event; + readonly onTestFilesChanged: events.Event<{ testFiles: string[] }>; + readonly onLoadTraceRequested: events.Event<{ traceUrl: string }>; + + private _onCloseEmitter = new events.EventEmitter(); + private _onReportEmitter = new events.EventEmitter(); + private _onStdioEmitter = new events.EventEmitter<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>(); + private _onListChangedEmitter = new events.EventEmitter(); + private _onTestFilesChangedEmitter = new events.EventEmitter<{ testFiles: string[] }>(); + private _onLoadTraceRequestedEmitter = new events.EventEmitter<{ traceUrl: string }>(); + + private _lastId = 0; + private _ws: WebSocket; + private _callbacks = new Map void, reject: (arg: Error) => void }>(); + private _connectedPromise: Promise; + + constructor(wsURL: string) { + this.onClose = this._onCloseEmitter.event; + this.onReport = this._onReportEmitter.event; + this.onStdio = this._onStdioEmitter.event; + this.onListChanged = this._onListChangedEmitter.event; + this.onTestFilesChanged = this._onTestFilesChangedEmitter.event; + this.onLoadTraceRequested = this._onLoadTraceRequestedEmitter.event; + + this._ws = new WebSocket(wsURL); + this._ws.addEventListener('message', event => { + const message = JSON.parse(String(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); + } + }); + const pingInterval = setInterval(() => this._sendMessage('ping').catch(() => {}), 30000); + this._connectedPromise = new Promise((f, r) => { + this._ws.addEventListener('open', () => { + f(); + this._ws.send(JSON.stringify({ method: 'ready' })); + }); + this._ws.addEventListener('error', r); + }); + this._ws.addEventListener('close', () => { + this._onCloseEmitter.fire(); + clearInterval(pingInterval); + }); + } + + private async _sendMessage(method: string, params?: any): Promise { + const logForTest = (globalThis as any).__logForTest; + logForTest?.({ method, params }); + + await this._connectedPromise; + const id = ++this._lastId; + const message = { id, method, params }; + this._ws.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 === 'report') + this._onReportEmitter.fire(params); + else if (method === 'stdio') + this._onStdioEmitter.fire(params); + else if (method === 'listChanged') + this._onListChangedEmitter.fire(params); + else if (method === 'testFilesChanged') + this._onTestFilesChangedEmitter.fire(params); + } + + async ping(): Promise { + await this._sendMessage('ping'); + } + + async pingNoReply() { + this._sendMessageNoReply('ping'); + } + + async watch(params: { fileNames: string[]; }): Promise { + await this._sendMessage('watch', params); + } + + watchNoReply(params: { fileNames: string[]; }) { + this._sendMessageNoReply('watch', params); + } + + async open(params: { location: reporterTypes.Location; }): Promise { + await this._sendMessage('open', params); + } + + openNoReply(params: { location: reporterTypes.Location; }) { + this._sendMessageNoReply('open', params); + } + + async resizeTerminal(params: { cols: number; rows: number; }): Promise { + await this._sendMessage('resizeTerminal', params); + } + + resizeTerminalNoReply(params: { cols: number; rows: number; }) { + this._sendMessageNoReply('resizeTerminal', params); + } + + async checkBrowsers(): Promise<{ hasBrowsers: boolean; }> { + return await this._sendMessage('checkBrowsers'); + } + + async installBrowsers(): Promise { + await this._sendMessage('installBrowsers'); + } + + async runGlobalSetup(): Promise<'passed' | 'failed' | 'timedout' | 'interrupted'> { + return await this._sendMessage('runGlobalSetup'); + } + + async runGlobalTeardown(): Promise<'passed' | 'failed' | 'timedout' | 'interrupted'> { + return await this._sendMessage('runGlobalTeardown'); + } + + async listFiles(): Promise<{ projects: { name: string; testDir: string; use: { testIdAttribute?: string | undefined; }; files: string[]; }[]; cliEntryPoint?: string | undefined; error?: reporterTypes.TestError | undefined; }> { + return await this._sendMessage('listFiles'); + } + + async listTests(params: { reporter?: string | undefined; fileNames?: string[] | undefined; }): Promise<{ report: any[] }> { + return await this._sendMessage('listTests', params); + } + + async runTests(params: { reporter?: string | undefined; locations?: string[] | undefined; grep?: string | undefined; testIds?: string[] | undefined; headed?: boolean | undefined; oneWorker?: boolean | undefined; trace?: 'off' | 'on' | undefined; projects?: string[] | undefined; reuseContext?: boolean | undefined; connectWsEndpoint?: string | undefined; }): Promise<{ status: reporterTypes.FullResult['status'] }> { + return await this._sendMessage('runTests', params); + } + + async findRelatedTestFiles(params: { files: string[]; }): Promise<{ testFiles: string[]; errors?: reporterTypes.TestError[] | undefined; }> { + return await this._sendMessage('findRelatedTestFiles', params); + } + + async stopTests(): Promise { + await this._sendMessage('stopTests'); + } + + stopTestsNoReply() { + this._sendMessageNoReply('stopTests'); + } + + async closeGracefully(): Promise { + await this._sendMessage('closeGracefully'); + } +} diff --git a/packages/playwright/src/isomorphic/testServerInterface.ts b/packages/playwright/src/isomorphic/testServerInterface.ts new file mode 100644 index 0000000000..77fdd6049c --- /dev/null +++ b/packages/playwright/src/isomorphic/testServerInterface.ts @@ -0,0 +1,96 @@ +/** + * 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 * as reporterTypes from '../../types/testReporter'; +import type { Event } from './events'; + +export interface TestServerInterface { + ping(): Promise; + + watch(params: { + fileNames: string[]; + }): Promise; + + open(params: { location: reporterTypes.Location }): Promise; + + resizeTerminal(params: { cols: number, rows: number }): Promise; + + checkBrowsers(): Promise<{ hasBrowsers: boolean }>; + + installBrowsers(): Promise; + + runGlobalSetup(): Promise; + + runGlobalTeardown(): Promise; + + listFiles(): Promise<{ + projects: { + name: string; + testDir: string; + use: { testIdAttribute?: string }; + files: string[]; + }[]; + cliEntryPoint?: string; + error?: reporterTypes.TestError; + }>; + + /** + * Returns list of teleReporter events. + */ + listTests(params: { + reporter?: string; + fileNames?: string[]; + }): Promise<{ report: any[] }>; + + runTests(params: { + reporter?: string; + locations?: string[]; + grep?: string; + testIds?: string[]; + headed?: boolean; + oneWorker?: boolean; + trace?: 'on' | 'off'; + projects?: string[]; + reuseContext?: boolean; + connectWsEndpoint?: string; + }): Promise<{ status: reporterTypes.FullResult['status'] }>; + + findRelatedTestFiles(params: { + files: string[]; + }): Promise<{ testFiles: string[]; errors?: reporterTypes.TestError[]; }>; + + stopTests(): Promise; + + closeGracefully(): Promise; +} + +export interface TestServerInterfaceEvents { + onClose: Event; + onReport: Event; + onStdio: Event<{ type: 'stdout' | 'stderr', text?: string, buffer?: string }>; + onListChanged: Event; + onTestFilesChanged: Event<{ testFiles: string[] }>; + onLoadTraceRequested: Event<{ traceUrl: string }>; +} + +export interface TestServerInterfaceEventEmitters { + dispatchEvent(event: 'close', params: {}): void; + dispatchEvent(event: 'report', params: any): void; + dispatchEvent(event: 'stdio', params: { type: 'stdout' | 'stderr', text?: string, buffer?: string }): void; + dispatchEvent(event: 'listChanged', params: {}): void; + dispatchEvent(event: 'testFilesChanged', params: { testFiles: string[] }): void; + dispatchEvent(event: 'loadTraceRequested', params: { traceUrl: string }): void; +} diff --git a/packages/playwright/src/isomorphic/testTree.ts b/packages/playwright/src/isomorphic/testTree.ts new file mode 100644 index 0000000000..64f414a763 --- /dev/null +++ b/packages/playwright/src/isomorphic/testTree.ts @@ -0,0 +1,352 @@ +/** + * 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. + */ + +export type TestItemStatus = 'none' | 'running' | 'scheduled' | 'passed' | 'failed' | 'skipped'; +import type * as reporterTypes from '../../types/testReporter'; + +export type TreeItemBase = { + kind: 'root' | 'group' | 'case' | 'test', + id: string; + title: string; + location: reporterTypes.Location, + duration: number; + parent: TreeItem | undefined; + children: TreeItem[]; + status: TestItemStatus; +}; + +export type GroupItem = TreeItemBase & { + kind: 'group'; + subKind: 'folder' | 'file' | 'describe'; + hasLoadErrors: boolean; + children: (TestCaseItem | GroupItem)[]; +}; + +export type TestCaseItem = TreeItemBase & { + kind: 'case', + tests: reporterTypes.TestCase[]; + children: TestItem[]; + test: reporterTypes.TestCase | undefined; + project: reporterTypes.FullProject | undefined; + tags: Array; +}; + +export type TestItem = TreeItemBase & { + kind: 'test', + test: reporterTypes.TestCase; + project: reporterTypes.FullProject; +}; + +export type TreeItem = GroupItem | TestCaseItem | TestItem; + +export class TestTree { + rootItem: GroupItem; + private _treeItemById = new Map(); + private _treeItemByTestId = new Map(); + readonly pathSeparator: string; + + constructor(rootFolder: string, rootSuite: reporterTypes.Suite | undefined, loadErrors: reporterTypes.TestError[], projectFilters: Map | undefined, pathSeparator: string) { + const filterProjects = projectFilters && [...projectFilters.values()].some(Boolean); + this.pathSeparator = pathSeparator; + this.rootItem = { + kind: 'group', + subKind: 'folder', + id: rootFolder, + title: '', + location: { file: '', line: 0, column: 0 }, + duration: 0, + parent: undefined, + children: [], + status: 'none', + hasLoadErrors: false, + }; + this._treeItemById.set(rootFolder, this.rootItem); + + const visitSuite = (project: reporterTypes.FullProject, parentSuite: reporterTypes.Suite, parentGroup: GroupItem) => { + for (const suite of parentSuite.suites) { + const title = suite.title || ''; + let group = parentGroup.children.find(item => item.kind === 'group' && item.title === title) as GroupItem | undefined; + if (!group) { + group = { + kind: 'group', + subKind: 'describe', + id: 'suite:' + parentSuite.titlePath().join('\x1e') + '\x1e' + title, // account for anonymous suites + title, + location: suite.location!, + duration: 0, + parent: parentGroup, + children: [], + status: 'none', + hasLoadErrors: false, + }; + this._addChild(parentGroup, group); + } + visitSuite(project, suite, group); + } + + for (const test of parentSuite.tests) { + const title = test.title; + let testCaseItem = parentGroup.children.find(t => t.kind !== 'group' && t.title === title) as TestCaseItem; + if (!testCaseItem) { + testCaseItem = { + kind: 'case', + id: 'test:' + test.titlePath().join('\x1e'), + title, + parent: parentGroup, + children: [], + tests: [], + location: test.location, + duration: 0, + status: 'none', + project: undefined, + test: undefined, + tags: test.tags, + }; + this._addChild(parentGroup, testCaseItem); + } + + const result = test.results[0]; + let status: 'none' | 'running' | 'scheduled' | 'passed' | 'failed' | 'skipped' = 'none'; + if ((result as any)?.[statusEx] === 'scheduled') + status = 'scheduled'; + else if ((result as any)?.[statusEx] === 'running') + status = 'running'; + else if (result?.status === 'skipped') + status = 'skipped'; + else if (result?.status === 'interrupted') + status = 'none'; + else if (result && test.outcome() !== 'expected') + status = 'failed'; + else if (result && test.outcome() === 'expected') + status = 'passed'; + + testCaseItem.tests.push(test); + const testItem: TestItem = { + kind: 'test', + id: test.id, + title: project.name, + location: test.location!, + test, + parent: testCaseItem, + children: [], + status, + duration: test.results.length ? Math.max(0, test.results[0].duration) : 0, + project, + }; + this._addChild(testCaseItem, testItem); + this._treeItemByTestId.set(test.id, testItem); + testCaseItem.duration = (testCaseItem.children as TestItem[]).reduce((a, b) => a + b.duration, 0); + } + }; + + for (const projectSuite of rootSuite?.suites || []) { + if (filterProjects && !projectFilters.get(projectSuite.title)) + continue; + for (const fileSuite of projectSuite.suites) { + const fileItem = this._fileItem(fileSuite.location!.file.split(pathSeparator), true); + visitSuite(projectSuite.project()!, fileSuite, fileItem); + } + } + + for (const loadError of loadErrors) { + if (!loadError.location) + continue; + const fileItem = this._fileItem(loadError.location.file.split(pathSeparator), true); + fileItem.hasLoadErrors = true; + } + } + + private _addChild(parent: TreeItem, child: TreeItem) { + parent.children.push(child); + child.parent = parent; + this._treeItemById.set(child.id, child); + } + + filterTree(filterText: string, statusFilters: Map, runningTestIds: Set | undefined) { + const tokens = filterText.trim().toLowerCase().split(' '); + const filtersStatuses = [...statusFilters.values()].some(Boolean); + + const filter = (testCase: TestCaseItem) => { + const titleWithTags = [...testCase.tests[0].titlePath(), ...testCase.tests[0].tags].join(' ').toLowerCase(); + if (!tokens.every(token => titleWithTags.includes(token)) && !testCase.tests.some(t => runningTestIds?.has(t.id))) + return false; + testCase.children = (testCase.children as TestItem[]).filter(test => { + return !filtersStatuses || runningTestIds?.has(test.test.id) || statusFilters.get(test.status); + }); + testCase.tests = (testCase.children as TestItem[]).map(c => c.test); + return !!testCase.children.length; + }; + + const visit = (treeItem: GroupItem) => { + const newChildren: (GroupItem | TestCaseItem)[] = []; + for (const child of treeItem.children) { + if (child.kind === 'case') { + if (filter(child)) + newChildren.push(child); + } else { + visit(child); + if (child.children.length || child.hasLoadErrors) + newChildren.push(child); + } + } + treeItem.children = newChildren; + }; + visit(this.rootItem); + } + + private _fileItem(filePath: string[], isFile: boolean): GroupItem { + if (filePath.length === 0) + return this.rootItem; + const fileName = filePath.join(this.pathSeparator); + const existingFileItem = this._treeItemById.get(fileName); + if (existingFileItem) + return existingFileItem as GroupItem; + const parentFileItem = this._fileItem(filePath.slice(0, filePath.length - 1), false); + const fileItem: GroupItem = { + kind: 'group', + subKind: isFile ? 'file' : 'folder', + id: fileName, + title: filePath[filePath.length - 1], + location: { file: fileName, line: 0, column: 0 }, + duration: 0, + parent: parentFileItem, + children: [], + status: 'none', + hasLoadErrors: false, + }; + this._addChild(parentFileItem, fileItem); + return fileItem; + } + + sortAndPropagateStatus() { + sortAndPropagateStatus(this.rootItem); + } + + flattenForSingleProject() { + const visit = (treeItem: TreeItem) => { + if (treeItem.kind === 'case' && treeItem.children.length === 1) { + treeItem.project = treeItem.children[0].project; + treeItem.test = treeItem.children[0].test; + treeItem.children = []; + this._treeItemByTestId.set(treeItem.test.id, treeItem); + } else { + treeItem.children.forEach(visit); + } + }; + visit(this.rootItem); + } + + shortenRoot() { + let shortRoot = this.rootItem; + while (shortRoot.children.length === 1 && shortRoot.children[0].kind === 'group' && shortRoot.children[0].subKind === 'folder') + shortRoot = shortRoot.children[0]; + shortRoot.location = this.rootItem.location; + this.rootItem = shortRoot; + } + + testIds(): Set { + const result = new Set(); + const visit = (treeItem: TreeItem) => { + if (treeItem.kind === 'case') + treeItem.tests.forEach(t => result.add(t.id)); + treeItem.children.forEach(visit); + }; + visit(this.rootItem); + return result; + } + + fileNames(): string[] { + const result = new Set(); + const visit = (treeItem: TreeItem) => { + if (treeItem.kind === 'group' && treeItem.subKind === 'file') + result.add(treeItem.id); + else + treeItem.children.forEach(visit); + }; + visit(this.rootItem); + return [...result]; + } + + flatTreeItems(): TreeItem[] { + const result: TreeItem[] = []; + const visit = (treeItem: TreeItem) => { + result.push(treeItem); + treeItem.children.forEach(visit); + }; + visit(this.rootItem); + return result; + } + + treeItemById(id: string): TreeItem | undefined { + return this._treeItemById.get(id); + } + + collectTestIds(treeItem?: TreeItem): Set { + const testIds = new Set(); + if (!treeItem) + return testIds; + + const visit = (treeItem: TreeItem) => { + if (treeItem.kind === 'case') + treeItem.tests.map(t => t.id).forEach(id => testIds.add(id)); + else if (treeItem.kind === 'test') + testIds.add(treeItem.id); + else + treeItem.children?.forEach(visit); + }; + visit(treeItem); + return testIds; + } +} + +export function sortAndPropagateStatus(treeItem: TreeItem) { + for (const child of treeItem.children) + sortAndPropagateStatus(child); + + if (treeItem.kind === 'group') { + treeItem.children.sort((a, b) => { + const fc = a.location.file.localeCompare(b.location.file); + return fc || a.location.line - b.location.line; + }); + } + + let allPassed = treeItem.children.length > 0; + let allSkipped = treeItem.children.length > 0; + let hasFailed = false; + let hasRunning = false; + let hasScheduled = false; + + for (const child of treeItem.children) { + allSkipped = allSkipped && child.status === 'skipped'; + allPassed = allPassed && (child.status === 'passed' || child.status === 'skipped'); + hasFailed = hasFailed || child.status === 'failed'; + hasRunning = hasRunning || child.status === 'running'; + hasScheduled = hasScheduled || child.status === 'scheduled'; + } + + if (hasRunning) + treeItem.status = 'running'; + else if (hasScheduled) + treeItem.status = 'scheduled'; + else if (hasFailed) + treeItem.status = 'failed'; + else if (allSkipped) + treeItem.status = 'skipped'; + else if (allPassed) + treeItem.status = 'passed'; +} + +export const statusEx = Symbol('statusEx'); diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index 0b6b3da8ad..55f121474f 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -267,7 +267,6 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { params: args[0] ? { expected: args[0] } : undefined, wallTime, infectParentStepsWithError: this._info.isSoft, - isSoft: this._info.isSoft, }; const step = testInfo._addStep(stepInfo); @@ -275,7 +274,9 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { const reportStepError = (jestError: ExpectError) => { const error = new ExpectError(jestError, customMessage, stackFrames); step.complete({ error }); - if (!this._info.isSoft) + if (this._info.isSoft) + testInfo._failWithError(error); + else throw error; }; diff --git a/packages/playwright/src/matchers/matcherHint.ts b/packages/playwright/src/matchers/matcherHint.ts index 229ef6ca28..17d893431c 100644 --- a/packages/playwright/src/matchers/matcherHint.ts +++ b/packages/playwright/src/matchers/matcherHint.ts @@ -25,7 +25,7 @@ export function matcherHint(state: ExpectMatcherContext, locator: Locator | unde if (timeout) header = colors.red(`Timed out ${timeout}ms waiting for `) + header; if (locator) - header += `Locator: ${locator}\n`; + header += `Locator: ${String(locator)}\n`; return header; } diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts index 3dc08ba223..01d1dfad25 100644 --- a/packages/playwright/src/matchers/matchers.ts +++ b/packages/playwright/src/matchers/matchers.ts @@ -40,7 +40,7 @@ export function toBeAttached( locator: LocatorEx, options?: { attached?: boolean, timeout?: number }, ) { - const attached = !options || options.attached === undefined || options.attached === true; + const attached = !options || options.attached === undefined || options.attached; const expected = attached ? 'attached' : 'detached'; const unexpected = attached ? 'detached' : 'attached'; const arg = attached ? '' : '{ attached: false }'; @@ -54,7 +54,7 @@ export function toBeChecked( locator: LocatorEx, options?: { checked?: boolean, timeout?: number }, ) { - const checked = !options || options.checked === undefined || options.checked === true; + const checked = !options || options.checked === undefined || options.checked; const expected = checked ? 'checked' : 'unchecked'; const unexpected = checked ? 'unchecked' : 'checked'; const arg = checked ? '' : '{ checked: false }'; @@ -78,7 +78,7 @@ export function toBeEditable( locator: LocatorEx, options?: { editable?: boolean, timeout?: number }, ) { - const editable = !options || options.editable === undefined || options.editable === true; + const editable = !options || options.editable === undefined || options.editable; const expected = editable ? 'editable' : 'readOnly'; const unexpected = editable ? 'readOnly' : 'editable'; const arg = editable ? '' : '{ editable: false }'; @@ -102,7 +102,7 @@ export function toBeEnabled( locator: LocatorEx, options?: { enabled?: boolean, timeout?: number }, ) { - const enabled = !options || options.enabled === undefined || options.enabled === true; + const enabled = !options || options.enabled === undefined || options.enabled; const expected = enabled ? 'enabled' : 'disabled'; const unexpected = enabled ? 'disabled' : 'enabled'; const arg = enabled ? '' : '{ enabled: false }'; @@ -136,7 +136,7 @@ export function toBeVisible( locator: LocatorEx, options?: { visible?: boolean, timeout?: number }, ) { - const visible = !options || options.visible === undefined || options.visible === true; + const visible = !options || options.visible === undefined || options.visible; const expected = visible ? 'visible' : 'hidden'; const unexpected = visible ? 'hidden' : 'visible'; const arg = visible ? '' : '{ visible: false }'; diff --git a/packages/playwright/src/matchers/toMatchSnapshot.ts b/packages/playwright/src/matchers/toMatchSnapshot.ts index 80e602f5ad..442483a0de 100644 --- a/packages/playwright/src/matchers/toMatchSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchSnapshot.ts @@ -22,7 +22,9 @@ import { getComparator, sanitizeForFilePath, zones } from 'playwright-core/lib/u import { addSuffixToFilePath, trimLongString, callLogText, - expectTypes } from '../util'; + expectTypes, + sanitizeFilePathBeforeExtension, + windowsFilesystemFriendlyLength } from '../util'; import { colors } from 'playwright-core/lib/utilsBundle'; import fs from 'fs'; import path from 'path'; @@ -73,7 +75,7 @@ const NonConfigProperties: (keyof ToHaveScreenshotOptions)[] = [ class SnapshotHelper { readonly testInfo: TestInfoImpl; - readonly snapshotName: string; + readonly outputBaseName: string; readonly legacyExpectedPath: string; readonly previousPath: string; readonly snapshotPath: string; @@ -91,7 +93,6 @@ class SnapshotHelper { testInfo: TestInfoImpl, matcherName: string, locator: Locator | undefined, - snapshotPathResolver: (...pathSegments: string[]) => string, anonymousSnapshotExtension: string, configOptions: ToHaveScreenshotConfigOptions, nameOrOptions: NameOrSegments | { name?: NameOrSegments } & ToHaveScreenshotOptions, @@ -102,9 +103,9 @@ class SnapshotHelper { name = nameOrOptions; this.options = { ...optOptions }; } else { - name = nameOrOptions.name; - this.options = { ...nameOrOptions }; - delete (this.options as any).name; + const { name: nameFromOptions, ...options } = nameOrOptions; + this.options = options; + name = nameFromOptions; } let snapshotNames = (testInfo as any)[snapshotNamesSymbol] as SnapshotNames; @@ -122,25 +123,34 @@ class SnapshotHelper { // // noop // expect.toMatchSnapshot('a.png') - let actualModifier = ''; + let inputPathSegments: string[]; if (!name) { const fullTitleWithoutSpec = [ ...testInfo.titlePath.slice(1), ++snapshotNames.anonymousSnapshotIndex, ].join(' '); - name = sanitizeForFilePath(trimLongString(fullTitleWithoutSpec)) + '.' + anonymousSnapshotExtension; - this.snapshotName = name; + inputPathSegments = [sanitizeForFilePath(trimLongString(fullTitleWithoutSpec)) + '.' + anonymousSnapshotExtension]; + // Trim the output file paths more aggresively to avoid hitting Windows filesystem limits. + this.outputBaseName = sanitizeForFilePath(trimLongString(fullTitleWithoutSpec, windowsFilesystemFriendlyLength)) + '.' + anonymousSnapshotExtension; } else { + // We intentionally do not sanitize user-provided array of segments, but for backwards + // compatibility we do sanitize the name if it is a single string. + // See https://github.com/microsoft/playwright/pull/9156 + inputPathSegments = Array.isArray(name) ? name : [sanitizeFilePathBeforeExtension(name)]; const joinedName = Array.isArray(name) ? name.join(path.sep) : name; snapshotNames.namedSnapshotIndex[joinedName] = (snapshotNames.namedSnapshotIndex[joinedName] || 0) + 1; const index = snapshotNames.namedSnapshotIndex[joinedName]; - if (index > 1) { - actualModifier = `-${index - 1}`; - this.snapshotName = `${joinedName}-${index - 1}`; - } else { - this.snapshotName = joinedName; - } + if (index > 1) + this.outputBaseName = addSuffixToFilePath(joinedName, `-${index - 1}`); + else + this.outputBaseName = joinedName; } + this.snapshotPath = testInfo.snapshotPath(...inputPathSegments); + const outputFile = testInfo._getOutputPath(sanitizeFilePathBeforeExtension(this.outputBaseName)); + this.legacyExpectedPath = addSuffixToFilePath(outputFile, '-expected'); + this.previousPath = addSuffixToFilePath(outputFile, '-previous'); + this.actualPath = addSuffixToFilePath(outputFile, '-actual'); + this.diffPath = addSuffixToFilePath(outputFile, '-diff'); const filteredConfigOptions = { ...configOptions }; for (const prop of NonConfigProperties) @@ -162,16 +172,6 @@ class SnapshotHelper { if (this.options.maxDiffPixelRatio !== undefined && (this.options.maxDiffPixelRatio < 0 || this.options.maxDiffPixelRatio > 1)) throw new Error('`maxDiffPixelRatio` option value must be between 0 and 1'); - // sanitizes path if string - const inputPathSegments = Array.isArray(name) ? name : [addSuffixToFilePath(name, '', undefined, true)]; - const outputPathSegments = Array.isArray(name) ? name : [addSuffixToFilePath(name, actualModifier, undefined, true)]; - this.snapshotPath = snapshotPathResolver(...inputPathSegments); - const inputFile = testInfo._getOutputPath(...inputPathSegments); - const outputFile = testInfo._getOutputPath(...outputPathSegments); - this.legacyExpectedPath = addSuffixToFilePath(inputFile, '-expected'); - this.previousPath = addSuffixToFilePath(outputFile, '-previous'); - this.actualPath = addSuffixToFilePath(outputFile, '-actual'); - this.diffPath = addSuffixToFilePath(outputFile, '-diff'); this.matcherName = matcherName; this.locator = locator; @@ -223,7 +223,7 @@ class SnapshotHelper { if (isWriteMissingMode) { writeFileSync(this.snapshotPath, actual); writeFileSync(this.actualPath, actual); - this.testInfo.attachments.push({ name: addSuffixToFilePath(this.snapshotName, '-actual'), contentType: this.mimeType, path: this.actualPath }); + this.testInfo.attachments.push({ name: addSuffixToFilePath(this.outputBaseName, '-actual'), contentType: this.mimeType, path: this.actualPath }); } const message = `A snapshot doesn't exist at ${this.snapshotPath}${isWriteMissingMode ? ', writing actual.' : '.'}`; if (this.updateSnapshots === 'all') { @@ -232,7 +232,8 @@ class SnapshotHelper { return this.createMatcherResult(message, true); } if (this.updateSnapshots === 'missing') { - this.testInfo._failWithError(new Error(message), false /* isHardError */, false /* retriable */); + this.testInfo._hasNonRetriableError = true; + this.testInfo._failWithError(new Error(message)); return this.createMatcherResult('', true); } return this.createMatcherResult(message, false); @@ -257,22 +258,22 @@ class SnapshotHelper { // Copy the expectation inside the `test-results/` folder for backwards compatibility, // so that one can upload `test-results/` directory and have all the data inside. writeFileSync(this.legacyExpectedPath, expected); - this.testInfo.attachments.push({ name: addSuffixToFilePath(this.snapshotName, '-expected'), contentType: this.mimeType, path: this.snapshotPath }); + this.testInfo.attachments.push({ name: addSuffixToFilePath(this.outputBaseName, '-expected'), contentType: this.mimeType, path: this.snapshotPath }); output.push(`\nExpected: ${colors.yellow(this.snapshotPath)}`); } if (previous !== undefined) { writeFileSync(this.previousPath, previous); - this.testInfo.attachments.push({ name: addSuffixToFilePath(this.snapshotName, '-previous'), contentType: this.mimeType, path: this.previousPath }); + this.testInfo.attachments.push({ name: addSuffixToFilePath(this.outputBaseName, '-previous'), contentType: this.mimeType, path: this.previousPath }); output.push(`Previous: ${colors.yellow(this.previousPath)}`); } if (actual !== undefined) { writeFileSync(this.actualPath, actual); - this.testInfo.attachments.push({ name: addSuffixToFilePath(this.snapshotName, '-actual'), contentType: this.mimeType, path: this.actualPath }); + this.testInfo.attachments.push({ name: addSuffixToFilePath(this.outputBaseName, '-actual'), contentType: this.mimeType, path: this.actualPath }); output.push(`Received: ${colors.yellow(this.actualPath)}`); } if (diff !== undefined) { writeFileSync(this.diffPath, diff); - this.testInfo.attachments.push({ name: addSuffixToFilePath(this.snapshotName, '-diff'), contentType: this.mimeType, path: this.diffPath }); + this.testInfo.attachments.push({ name: addSuffixToFilePath(this.outputBaseName, '-diff'), contentType: this.mimeType, path: this.diffPath }); output.push(` Diff: ${colors.yellow(this.diffPath)}`); } @@ -304,10 +305,10 @@ export function toMatchSnapshot( if (testInfo._configInternal.ignoreSnapshots) return { pass: !this.isNot, message: () => '', name: 'toMatchSnapshot', expected: nameOrOptions }; + const configOptions = testInfo._projectInternal.expect?.toMatchSnapshot || {}; const helper = new SnapshotHelper( - testInfo, 'toMatchSnapshot', undefined, testInfo.snapshotPath.bind(testInfo), determineFileExtension(received), - testInfo._projectInternal.expect?.toMatchSnapshot || {}, - nameOrOptions, optOptions); + testInfo, 'toMatchSnapshot', undefined, determineFileExtension(received), + configOptions, nameOrOptions, optOptions); if (this.isNot) { if (!fs.existsSync(helper.snapshotPath)) @@ -362,10 +363,7 @@ export async function toHaveScreenshot( expectTypes(pageOrLocator, ['Page', 'Locator'], 'toHaveScreenshot'); const [page, locator] = pageOrLocator.constructor.name === 'Page' ? [(pageOrLocator as PageEx), undefined] : [(pageOrLocator as Locator).page() as PageEx, pageOrLocator as Locator]; const configOptions = testInfo._projectInternal.expect?.toHaveScreenshot || {}; - const snapshotPathResolver = testInfo.snapshotPath.bind(testInfo); - const helper = new SnapshotHelper( - testInfo, 'toHaveScreenshot', locator, snapshotPathResolver, 'png', - configOptions, nameOrOptions, optOptions); + const helper = new SnapshotHelper(testInfo, 'toHaveScreenshot', locator, 'png', configOptions, nameOrOptions, optOptions); if (!helper.snapshotPath.toLowerCase().endsWith('.png')) throw new Error(`Screenshot name "${path.basename(helper.snapshotPath)}" must have '.png' extension`); expectTypes(pageOrLocator, ['Page', 'Locator'], 'toHaveScreenshot'); diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index d24add96d3..bb65d1982a 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -34,7 +34,6 @@ export { program } from 'playwright-core/lib/cli/program'; import type { ReporterDescription } from '../types/test'; import { prepareErrorStack } from './reporters/base'; import { cacheDir } from './transform/compilationCache'; -import { runTestServer } from './runner/testServer'; function addTestCommand(program: Command) { const command = program.command('test [test-filter...]'); @@ -108,9 +107,9 @@ function addFindRelatedTestFilesCommand(program: Command) { function addTestServerCommand(program: Command) { const command = program.command('test-server', { hidden: true }); command.description('start test server'); - command.action(() => { - void runTestServer(); - }); + command.option('--host ', 'Host to start the server on', 'localhost'); + command.option('--port ', 'Port to start the server on', '0'); + command.action(opts => runTestServer(opts)); } function addShowReportCommand(program: Command) { @@ -166,7 +165,7 @@ async function runTests(args: string[], opts: { [key: string]: any }) { const runner = new Runner(config); let status: FullResult['status']; if (opts.ui || opts.uiHost || opts.uiPort) - status = await runner.uiAllTests({ host: opts.uiHost, port: opts.uiPort ? +opts.uiPort : undefined }); + status = await runner.runUIMode({ host: opts.uiHost, port: opts.uiPort ? +opts.uiPort : undefined }); else if (process.env.PWTEST_WATCH) status = await runner.watchAllTests(); else @@ -176,6 +175,19 @@ async function runTests(args: string[], opts: { [key: string]: any }) { gracefullyProcessExitDoNotHang(exitCode); } +async function runTestServer(opts: { [key: string]: any }) { + const config = await loadConfigFromFileRestartIfNeeded(opts.config, overridesFromOptions(opts), opts.deps === false); + if (!config) + return; + config.cliPassWithNoTests = true; + const runner = new Runner(config); + const host = opts.host || 'localhost'; + const port = opts.port ? +opts.port : 0; + const status = await runner.runTestServer({ host, port }); + const exitCode = status === 'interrupted' ? 130 : (status === 'passed' ? 0 : 1); + gracefullyProcessExitDoNotHang(exitCode); +} + export async function withRunnerAndMutedWrite(configFile: string | undefined, callback: (runner: Runner) => Promise) { // Redefine process.stdout.write in case config decides to pollute stdio. const stdoutWrite = process.stdout.write.bind(process.stdout); @@ -290,7 +302,7 @@ function resolveReporter(id: string) { return require.resolve(id, { paths: [process.cwd()] }); } -const kTraceModes: TraceMode[] = ['on', 'off', 'on-first-retry', 'on-all-retries', 'retain-on-failure']; +const kTraceModes: TraceMode[] = ['on', 'off', 'on-first-retry', 'on-all-retries', 'retain-on-failure', 'retain-on-first-failure']; const testOptions: [string, string][] = [ ['--browser ', `Browser to use for tests, one of "all", "chromium", "firefox" or "webkit" (default: "chromium")`], diff --git a/packages/playwright/src/reporters/base.ts b/packages/playwright/src/reporters/base.ts index 91f34faf74..fff2703208 100644 --- a/packages/playwright/src/reporters/base.ts +++ b/packages/playwright/src/reporters/base.ts @@ -17,7 +17,6 @@ import { colors as realColors, ms as milliseconds, parseStackTraceLine } from 'playwright-core/lib/utilsBundle'; import path from 'path'; import type { FullConfig, TestCase, Suite, TestResult, TestError, FullResult, TestStep, Location } from '../../types/testReporter'; -import type { SuitePrivate } from '../../types/reporterPrivate'; import { getPackageManagerExecCommand } from 'playwright-core/lib/utils'; import type { ReporterV2 } from './reporterV2'; export type TestResultOutput = { chunk: string | Buffer, type: 'stdout' | 'stderr' }; @@ -72,7 +71,7 @@ export class BaseReporter implements ReporterV2 { suite!: Suite; totalTestCount = 0; result!: FullResult; - private fileDurations = new Map(); + private fileDurations = new Map }>(); private _omitFailures: boolean; private _fatalErrors: TestError[] = []; private _failureCount: number = 0; @@ -115,16 +114,13 @@ export class BaseReporter implements ReporterV2 { onTestEnd(test: TestCase, result: TestResult) { if (result.status !== 'skipped' && result.status !== test.expectedStatus) ++this._failureCount; - // Ignore any tests that are run in parallel. - for (let suite: Suite | undefined = test.parent; suite; suite = suite.parent) { - if ((suite as SuitePrivate)._parallelMode === 'parallel') - return; - } const projectName = test.titlePath()[1]; const relativePath = relativeTestPath(this.config, test); const fileAndProject = (projectName ? `[${projectName}] › ` : '') + relativePath; - const duration = this.fileDurations.get(fileAndProject) || 0; - this.fileDurations.set(fileAndProject, duration + result.duration); + const entry = this.fileDurations.get(fileAndProject) || { duration: 0, workers: new Set() }; + entry.duration += result.duration; + entry.workers.add(result.workerIndex); + this.fileDurations.set(fileAndProject, entry); } onError(error: TestError) { @@ -167,7 +163,8 @@ export class BaseReporter implements ReporterV2 { protected getSlowTests(): [string, number][] { if (!this.config.reportSlowTests) return []; - const fileDurations = [...this.fileDurations.entries()]; + // Only pick durations that were served by single worker. + const fileDurations = [...this.fileDurations.entries()].filter(([key, value]) => value.workers.size === 1).map(([key, value]) => [key, value.duration]) as [string, number][]; fileDurations.sort((a, b) => b[1] - a[1]); const count = Math.min(fileDurations.length, this.config.reportSlowTests.max || Number.POSITIVE_INFINITY); const threshold = this.config.reportSlowTests.threshold; diff --git a/packages/playwright/src/reporters/blob.ts b/packages/playwright/src/reporters/blob.ts index 03848a4c76..019fa8b7ca 100644 --- a/packages/playwright/src/reporters/blob.ts +++ b/packages/playwright/src/reporters/blob.ts @@ -50,7 +50,7 @@ export class BlobReporter extends TeleReporterEmitter { private _reportName!: string; constructor(options: BlobReporterOptions) { - super(message => this._messages.push(message), false); + super(message => this._messages.push(message)); this._options = options; if (this._options.fileName && !this._options.fileName.endsWith('.zip')) throw new Error(`Blob report file name must end with .zip extension: ${this._options.fileName}`); diff --git a/packages/playwright/src/reporters/html.ts b/packages/playwright/src/reporters/html.ts index 3f21de3cde..e0262122d1 100644 --- a/packages/playwright/src/reporters/html.ts +++ b/packages/playwright/src/reporters/html.ts @@ -22,7 +22,6 @@ import type { TransformCallback } from 'stream'; import { Transform } from 'stream'; import { codeFrameColumns } from '../transform/babelBundle'; import type { FullResult, FullConfig, Location, Suite, TestCase as TestCasePublic, TestResult as TestResultPublic, TestStep as TestStepPublic, TestError } from '../../types/testReporter'; -import type { SuitePrivate } from '../../types/reporterPrivate'; import { HttpServer, assert, calculateSha1, copyFileAndMakeWritable, gracefullyProcessExitDoNotHang, removeFolders, sanitizeForFilePath, toPosixPath } from 'playwright-core/lib/utils'; import { colors, formatError, formatResultFailure, stripAnsiEscapes } from './base'; import { resolveReporterOutputPath } from '../util'; @@ -31,7 +30,6 @@ import type { ZipFile } from 'playwright-core/lib/zipBundle'; import { yazl } from 'playwright-core/lib/zipBundle'; import { mime } from 'playwright-core/lib/utilsBundle'; import type { HTMLReport, Stats, TestAttachment, TestCase, TestCaseSummary, TestFile, TestFileSummary, TestResult, TestStep } from '@html-reporter/types'; -import { FullConfigInternal } from '../common/config'; import EmptyReporter from './empty'; type TestEntry = { @@ -53,6 +51,7 @@ type HtmlReporterOptions = { host?: string, port?: number, attachmentsBaseURL?: string, + _mode?: string; }; class HtmlReporter extends EmptyReporter { @@ -125,12 +124,11 @@ class HtmlReporter extends EmptyReporter { override async onExit() { if (process.env.CI || !this._buildResult) return; - const { ok, singleTestId } = this._buildResult; const shouldOpen = this._open === 'always' || (!ok && this._open === 'on-failure'); if (shouldOpen) { await showHTMLReport(this._outputFolder, this._options.host, this._options.port, singleTestId); - } else if (!FullConfigInternal.from(this.config)?.cliListOnly) { + } else if (this._options._mode === 'test') { const packageManagerCommand = getPackageManagerExecCommand(); const relativeReportPath = this._outputFolder === standaloneDefaultFolder() ? '' : ' ' + path.relative(process.cwd(), this._outputFolder); const hostArg = this._options.host ? ` --host ${this._options.host}` : ''; @@ -227,9 +225,12 @@ class HtmlBuilder { async build(metadata: Metadata, projectSuites: Suite[], result: FullResult, topLevelErrors: TestError[]): Promise<{ ok: boolean, singleTestId: string | undefined }> { const data = new Map(); for (const projectSuite of projectSuites) { + const testDir = projectSuite.project()!.testDir; for (const fileSuite of projectSuite.suites) { const fileName = this._relativeLocation(fileSuite.location)!.file; - const fileId = (fileSuite as SuitePrivate)._fileId!; + // Preserve file ids computed off the testDir. + const relativeFile = path.relative(testDir, fileSuite.location!.file); + const fileId = calculateSha1(toPosixPath(relativeFile)).slice(0, 20); let fileEntry = data.get(fileId); if (!fileEntry) { fileEntry = { @@ -240,7 +241,7 @@ class HtmlBuilder { } const { testFile, testFileSummary } = fileEntry; const testEntries: TestEntry[] = []; - this._processJsonSuite(fileSuite, fileId, projectSuite.project()!.name, projectSuite.project()!.metadata?.reportName, [], testEntries); + this._processJsonSuite(fileSuite, fileId, projectSuite.project()!.name, [], testEntries); for (const test of testEntries) { testFile.tests.push(test.testCase); testFileSummary.tests.push(test.testCaseSummary); @@ -340,25 +341,28 @@ class HtmlBuilder { this._dataZipFile.addBuffer(Buffer.from(JSON.stringify(data)), fileName); } - private _processJsonSuite(suite: Suite, fileId: string, projectName: string, botName: string | undefined, path: string[], outTests: TestEntry[]) { + private _processJsonSuite(suite: Suite, fileId: string, projectName: string, path: string[], outTests: TestEntry[]) { const newPath = [...path, suite.title]; - suite.suites.forEach(s => this._processJsonSuite(s, fileId, projectName, botName, newPath, outTests)); - suite.tests.forEach(t => outTests.push(this._createTestEntry(t, projectName, botName, newPath))); + suite.suites.forEach(s => this._processJsonSuite(s, fileId, projectName, newPath, outTests)); + suite.tests.forEach(t => outTests.push(this._createTestEntry(fileId, t, projectName, newPath))); } - private _createTestEntry(test: TestCasePublic, projectName: string, botName: string | undefined, path: string[]): TestEntry { + private _createTestEntry(fileId: string, test: TestCasePublic, projectName: string, path: string[]): TestEntry { const duration = test.results.reduce((a, r) => a + r.duration, 0); const location = this._relativeLocation(test.location)!; path = path.slice(1); + const [file, ...titles] = test.titlePath(); + const testIdExpression = `[project=${projectName}]${toPosixPath(file)}\x1e${titles.join('\x1e')} (repeat:${test.repeatEachIndex})`; + const testId = fileId + '-' + calculateSha1(testIdExpression).slice(0, 20); + const results = test.results.map(r => this._createTestResult(test, r)); return { testCase: { - testId: test.id, + testId, title: test.title, projectName, - botName, location, duration, // Annotations can be pushed directly, with a wrong type. @@ -370,10 +374,9 @@ class HtmlBuilder { ok: test.outcome() === 'expected' || test.outcome() === 'flaky', }, testCaseSummary: { - testId: test.id, + testId, title: test.title, projectName, - botName, location, duration, // Annotations can be pushed directly, with a wrong type. diff --git a/packages/playwright/src/reporters/merge.ts b/packages/playwright/src/reporters/merge.ts index 9e81f92e7a..ef34c409cc 100644 --- a/packages/playwright/src/reporters/merge.ts +++ b/packages/playwright/src/reporters/merge.ts @@ -23,7 +23,7 @@ import { TeleReporterReceiver } from '../isomorphic/teleReceiver'; import { JsonStringInternalizer, StringInternPool } from '../isomorphic/stringInternPool'; import { createReporters } from '../runner/reporters'; import { Multiplexer } from './multiplexer'; -import { ZipFile, calculateSha1 } from 'playwright-core/lib/utils'; +import { ZipFile } from 'playwright-core/lib/utils'; import { currentBlobReportVersion, type BlobReportMetadata } from './blob'; import { relativeFilePath } from '../util'; import type { TestError } from '../../types/testReporter'; @@ -51,9 +51,14 @@ export async function createMergedReport(config: FullConfigInternal, dir: string if (shardFiles.length === 0) throw new Error(`No report files found in ${dir}`); const eventData = await mergeEvents(dir, shardFiles, stringPool, printStatus, rootDirOverride); - // If expicit config is provided, use platform path separator, otherwise use the one from the report (if any). - const pathSep = rootDirOverride ? path.sep : (eventData.pathSeparatorFromMetadata ?? path.sep); - const receiver = new TeleReporterReceiver(pathSep, multiplexer, false, config.config); + // If explicit config is provided, use platform path separator, otherwise use the one from the report (if any). + const pathSeparator = rootDirOverride ? path.sep : (eventData.pathSeparatorFromMetadata ?? path.sep); + const receiver = new TeleReporterReceiver(multiplexer, { + mergeProjects: false, + mergeTestCases: false, + resolvePath: (rootDir, relativePath) => stringPool.internString(rootDir + pathSeparator + relativePath), + configOverrides: config.config, + }); printStatus(`processing test events`); const dispatchEvents = async (events: JsonEvent[]) => { @@ -183,22 +188,15 @@ async function mergeEvents(dir: string, shardReportFiles: string[], stringPool: return a.file.localeCompare(b.file); }); - const saltSet = new Set(); - printStatus(`merging events`); const reports: ReportData[] = []; - for (const { file, parsedEvents, metadata, localPath } of blobs) { + for (let i = 0; i < blobs.length; ++i) { // Generate unique salt for each blob. - const sha1 = calculateSha1(metadata.name || path.basename(file)).substring(0, 16); - let salt = sha1; - for (let i = 0; saltSet.has(salt); i++) - salt = sha1 + '-' + i; - saltSet.add(salt); - + const { parsedEvents, metadata, localPath } = blobs[i]; const eventPatchers = new JsonEventPatchers(); - eventPatchers.patchers.push(new IdsPatcher(stringPool, metadata.name, salt)); + eventPatchers.patchers.push(new IdsPatcher(stringPool, metadata.name, String(i))); // Only patch path separators if we are merging reports with explicit config. if (rootDirOverride) eventPatchers.patchers.push(new PathSeparatorPatcher(metadata.pathSeparator)); @@ -248,7 +246,6 @@ function mergeConfigureEvents(configureEvents: JsonEvent[], rootDirOverride: str rootDir: '', version: '', workers: 0, - listOnly: false }; for (const event of configureEvents) config = mergeConfigs(config, event.params.config); @@ -355,10 +352,14 @@ class UniqueFileNameGenerator { } class IdsPatcher { - constructor( - private _stringPool: StringInternPool, - private _reportName: string | undefined, - private _salt: string) { + private _stringPool: StringInternPool; + private _botName: string | undefined; + private _salt: string; + + constructor(stringPool: StringInternPool, botName: string | undefined, salt: string) { + this._stringPool = stringPool; + this._botName = botName; + this._salt = salt; } patchEvent(event: JsonEvent) { @@ -381,13 +382,18 @@ class IdsPatcher { private _onProject(project: JsonProject) { project.metadata = project.metadata ?? {}; - project.metadata.reportName = this._reportName; - project.id = this._stringPool.internString(project.id + this._salt); + project.metadata.botName = this._botName; project.suites.forEach(suite => this._updateTestIds(suite)); } private _updateTestIds(suite: JsonSuite) { - suite.tests.forEach(test => test.testId = this._mapTestId(test.testId)); + suite.tests.forEach(test => { + test.testId = this._mapTestId(test.testId); + if (this._botName) { + test.tags = test.tags || []; + test.tags.unshift('@' + this._botName); + } + }); suite.suites.forEach(suite => this._updateTestIds(suite)); } diff --git a/packages/playwright/src/reporters/teleEmitter.ts b/packages/playwright/src/reporters/teleEmitter.ts index 7f4caf6ad5..2baef221aa 100644 --- a/packages/playwright/src/reporters/teleEmitter.ts +++ b/packages/playwright/src/reporters/teleEmitter.ts @@ -16,41 +16,43 @@ import path from 'path'; import { createGuid } from 'playwright-core/lib/utils'; -import type { SuitePrivate } from '../../types/reporterPrivate'; -import type { FullConfig, FullResult, Location, TestCase, TestError, TestResult, TestStep } from '../../types/testReporter'; -import { FullConfigInternal, getProjectId } from '../common/config'; -import type { Suite } from '../common/test'; -import type { JsonAttachment, JsonConfig, JsonEvent, JsonFullResult, JsonProject, JsonStdIOType, JsonSuite, JsonTestCase, JsonTestEnd, JsonTestResultEnd, JsonTestResultStart, JsonTestStepEnd, JsonTestStepStart } from '../isomorphic/teleReceiver'; +import type * as reporterTypes from '../../types/testReporter'; +import type * as teleReceiver from '../isomorphic/teleReceiver'; import { serializeRegexPatterns } from '../isomorphic/teleReceiver'; import type { ReporterV2 } from './reporterV2'; -export class TeleReporterEmitter implements ReporterV2 { - private _messageSink: (message: JsonEvent) => void; - private _rootDir!: string; - private _skipBuffers: boolean; +export type TeleReporterEmitterOptions = { + omitOutput?: boolean; + omitBuffers?: boolean; +}; - constructor(messageSink: (message: JsonEvent) => void, skipBuffers: boolean) { +export class TeleReporterEmitter implements ReporterV2 { + private _messageSink: (message: teleReceiver.JsonEvent) => void; + private _rootDir!: string; + private _emitterOptions: TeleReporterEmitterOptions; + + constructor(messageSink: (message: teleReceiver.JsonEvent) => void, options: TeleReporterEmitterOptions = {}) { this._messageSink = messageSink; - this._skipBuffers = skipBuffers; + this._emitterOptions = options; } version(): 'v2' { return 'v2'; } - onConfigure(config: FullConfig) { + onConfigure(config: reporterTypes.FullConfig) { this._rootDir = config.rootDir; this._messageSink({ method: 'onConfigure', params: { config: this._serializeConfig(config) } }); } - onBegin(suite: Suite) { + onBegin(suite: reporterTypes.Suite) { const projects = suite.suites.map(projectSuite => this._serializeProject(projectSuite)); for (const project of projects) this._messageSink({ method: 'onProject', params: { project } }); this._messageSink({ method: 'onBegin', params: undefined }); } - onTestBegin(test: TestCase, result: TestResult): void { + onTestBegin(test: reporterTypes.TestCase, result: reporterTypes.TestResult): void { (result as any)[idSymbol] = createGuid(); this._messageSink({ method: 'onTestBegin', @@ -61,8 +63,8 @@ export class TeleReporterEmitter implements ReporterV2 { }); } - onTestEnd(test: TestCase, result: TestResult): void { - const testEnd: JsonTestEnd = { + onTestEnd(test: reporterTypes.TestCase, result: reporterTypes.TestResult): void { + const testEnd: teleReceiver.JsonTestEnd = { testId: test.id, expectedStatus: test.expectedStatus, annotations: test.annotations, @@ -77,7 +79,7 @@ export class TeleReporterEmitter implements ReporterV2 { }); } - onStepBegin(test: TestCase, result: TestResult, step: TestStep): void { + onStepBegin(test: reporterTypes.TestCase, result: reporterTypes.TestResult, step: reporterTypes.TestStep): void { (step as any)[idSymbol] = createGuid(); this._messageSink({ method: 'onStepBegin', @@ -89,7 +91,7 @@ export class TeleReporterEmitter implements ReporterV2 { }); } - onStepEnd(test: TestCase, result: TestResult, step: TestStep): void { + onStepEnd(test: reporterTypes.TestCase, result: reporterTypes.TestResult, step: reporterTypes.TestStep): void { this._messageSink({ method: 'onStepEnd', params: { @@ -100,22 +102,24 @@ export class TeleReporterEmitter implements ReporterV2 { }); } - onError(error: TestError): void { + onError(error: reporterTypes.TestError): void { this._messageSink({ method: 'onError', params: { error } }); } - onStdOut(chunk: string | Buffer, test?: TestCase, result?: TestResult): void { + onStdOut(chunk: string | Buffer, test?: reporterTypes.TestCase, result?: reporterTypes.TestResult): void { this._onStdIO('stdout', chunk, test, result); } - onStdErr(chunk: string | Buffer, test?: TestCase, result?: TestResult): void { + onStdErr(chunk: string | Buffer, test?: reporterTypes.TestCase, result?: reporterTypes.TestResult): void { this._onStdIO('stderr', chunk, test, result); } - private _onStdIO(type: JsonStdIOType, chunk: string | Buffer, test: void | TestCase, result: void | TestResult): void { + private _onStdIO(type: teleReceiver.JsonStdIOType, chunk: string | Buffer, test: void | reporterTypes.TestCase, result: void | reporterTypes.TestResult): void { + if (this._emitterOptions.omitOutput) + return; const isBase64 = typeof chunk !== 'string'; const data = isBase64 ? chunk.toString('base64') : chunk; this._messageSink({ @@ -124,8 +128,8 @@ export class TeleReporterEmitter implements ReporterV2 { }); } - async onEnd(result: FullResult) { - const resultPayload: JsonFullResult = { + async onEnd(result: reporterTypes.FullResult) { + const resultPayload: teleReceiver.JsonFullResult = { status: result.status, startTime: result.startTime.getTime(), duration: result.duration, @@ -145,7 +149,7 @@ export class TeleReporterEmitter implements ReporterV2 { return false; } - private _serializeConfig(config: FullConfig): JsonConfig { + private _serializeConfig(config: reporterTypes.FullConfig): teleReceiver.JsonConfig { return { configFile: this._relativePath(config.configFile), globalTimeout: config.globalTimeout, @@ -154,14 +158,12 @@ export class TeleReporterEmitter implements ReporterV2 { rootDir: config.rootDir, version: config.version, workers: config.workers, - listOnly: !!FullConfigInternal.from(config)?.cliListOnly, }; } - private _serializeProject(suite: Suite): JsonProject { + private _serializeProject(suite: reporterTypes.Suite): teleReceiver.JsonProject { const project = suite.project()!; - const report: JsonProject = { - id: getProjectId(project), + const report: teleReceiver.JsonProject = { metadata: project.metadata, name: project.name, outputDir: this._relativePath(project.outputDir), @@ -183,12 +185,9 @@ export class TeleReporterEmitter implements ReporterV2 { return report; } - private _serializeSuite(suite: Suite): JsonSuite { + private _serializeSuite(suite: reporterTypes.Suite): teleReceiver.JsonSuite { const result = { - type: suite._type, title: suite.title, - fileId: (suite as SuitePrivate)._fileId, - parallelMode: (suite as SuitePrivate)._parallelMode, location: this._relativeLocation(suite.location), suites: suite.suites.map(s => this._serializeSuite(s)), tests: suite.tests.map(t => this._serializeTest(t)), @@ -196,17 +195,18 @@ export class TeleReporterEmitter implements ReporterV2 { return result; } - private _serializeTest(test: TestCase): JsonTestCase { + private _serializeTest(test: reporterTypes.TestCase): teleReceiver.JsonTestCase { return { testId: test.id, title: test.title, location: this._relativeLocation(test.location), retries: test.retries, tags: test.tags, + repeatEachIndex: test.repeatEachIndex, }; } - private _serializeResultStart(result: TestResult): JsonTestResultStart { + private _serializeResultStart(result: reporterTypes.TestResult): teleReceiver.JsonTestResultStart { return { id: (result as any)[idSymbol], retry: result.retry, @@ -216,7 +216,7 @@ export class TeleReporterEmitter implements ReporterV2 { }; } - private _serializeResultEnd(result: TestResult): JsonTestResultEnd { + private _serializeResultEnd(result: reporterTypes.TestResult): teleReceiver.JsonTestResultEnd { return { id: (result as any)[idSymbol], duration: result.duration, @@ -226,17 +226,17 @@ export class TeleReporterEmitter implements ReporterV2 { }; } - _serializeAttachments(attachments: TestResult['attachments']): JsonAttachment[] { + _serializeAttachments(attachments: reporterTypes.TestResult['attachments']): teleReceiver.JsonAttachment[] { return attachments.map(a => { return { ...a, // There is no Buffer in the browser, so there is no point in sending the data there. - base64: (a.body && !this._skipBuffers) ? a.body.toString('base64') : undefined, + base64: (a.body && !this._emitterOptions.omitBuffers) ? a.body.toString('base64') : undefined, }; }); } - private _serializeStepStart(step: TestStep): JsonTestStepStart { + private _serializeStepStart(step: reporterTypes.TestStep): teleReceiver.JsonTestStepStart { return { id: (step as any)[idSymbol], parentStepId: (step.parent as any)?.[idSymbol], @@ -247,7 +247,7 @@ export class TeleReporterEmitter implements ReporterV2 { }; } - private _serializeStepEnd(step: TestStep): JsonTestStepEnd { + private _serializeStepEnd(step: reporterTypes.TestStep): teleReceiver.JsonTestStepEnd { return { id: (step as any)[idSymbol], duration: step.duration, @@ -255,9 +255,9 @@ export class TeleReporterEmitter implements ReporterV2 { }; } - private _relativeLocation(location: Location): Location; - private _relativeLocation(location?: Location): Location | undefined; - private _relativeLocation(location: Location | undefined): Location | undefined { + private _relativeLocation(location: reporterTypes.Location): reporterTypes.Location; + private _relativeLocation(location?: reporterTypes.Location): reporterTypes.Location | undefined; + private _relativeLocation(location: reporterTypes.Location | undefined): reporterTypes.Location | undefined { if (!location) return location; return { diff --git a/packages/playwright/src/runner/loadUtils.ts b/packages/playwright/src/runner/loadUtils.ts index f6ecccb3d6..4b881264d2 100644 --- a/packages/playwright/src/runner/loadUtils.ts +++ b/packages/playwright/src/runner/loadUtils.ts @@ -316,7 +316,7 @@ export function loadGlobalHook(config: FullConfigInternal, file: string): Promis return requireOrImportDefaultFunction(path.resolve(config.config.rootDir, file), false); } -export function loadReporter(config: FullConfigInternal | undefined, file: string): Promise Reporter> { +export function loadReporter(config: FullConfigInternal, file: string): Promise Reporter> { return requireOrImportDefaultFunction(config ? path.resolve(config.config.rootDir, file) : file, true); } diff --git a/packages/playwright/src/runner/reporters.ts b/packages/playwright/src/runner/reporters.ts index 2285e9bee2..7e846c493f 100644 --- a/packages/playwright/src/runner/reporters.ts +++ b/packages/playwright/src/runner/reporters.ts @@ -33,7 +33,7 @@ import { BlobReporter } from '../reporters/blob'; import type { ReporterDescription } from '../../types/test'; import { type ReporterV2, wrapReporterAsV2 } from '../reporters/reporterV2'; -export async function createReporters(config: FullConfigInternal, mode: 'list' | 'run' | 'ui' | 'merge', descriptions?: ReporterDescription[]): Promise { +export async function createReporters(config: FullConfigInternal, mode: 'list' | 'test' | 'ui' | 'merge', descriptions?: ReporterDescription[]): Promise { const defaultReporters: { [key in BuiltInReporter]: new(arg: any) => ReporterV2 } = { blob: BlobReporter, dot: mode === 'list' ? ListModeReporter : DotReporter, @@ -50,9 +50,10 @@ export async function createReporters(config: FullConfigInternal, mode: 'list' | descriptions ??= config.config.reporter; if (config.configCLIOverrides.additionalReporters) descriptions = [...descriptions, ...config.configCLIOverrides.additionalReporters]; + const runOptions = reporterOptions(config, mode); for (const r of descriptions) { const [name, arg] = r; - const options = { ...arg, configDir: config.configDir }; + const options = { ...runOptions, ...arg }; if (name in defaultReporters) { reporters.push(new defaultReporters[name as keyof typeof defaultReporters](options)); } else { @@ -62,7 +63,7 @@ export async function createReporters(config: FullConfigInternal, mode: 'list' | } if (process.env.PW_TEST_REPORTER) { const reporterConstructor = await loadReporter(config, process.env.PW_TEST_REPORTER); - reporters.push(wrapReporterAsV2(new reporterConstructor())); + reporters.push(wrapReporterAsV2(new reporterConstructor(runOptions))); } const someReporterPrintsToStdio = reporters.some(r => r.printsToStdio()); @@ -77,6 +78,20 @@ export async function createReporters(config: FullConfigInternal, mode: 'list' | return reporters; } +export async function createReporterForTestServer(config: FullConfigInternal, file: string, mode: 'test' | 'list', messageSink: (message: any) => void): Promise { + const reporterConstructor = await loadReporter(config, file); + const runOptions = reporterOptions(config, mode, messageSink); + const instance = new reporterConstructor(runOptions); + return wrapReporterAsV2(instance); +} + +function reporterOptions(config: FullConfigInternal, mode: 'list' | 'test' | 'ui' | 'merge', send?: (message: any) => void) { + return { + configDir: config.configDir, + _send: send, + }; +} + class ListModeReporter extends EmptyReporter { private config!: FullConfig; diff --git a/packages/playwright/src/runner/runner.ts b/packages/playwright/src/runner/runner.ts index 142f0958cd..4a2d329159 100644 --- a/packages/playwright/src/runner/runner.ts +++ b/packages/playwright/src/runner/runner.ts @@ -16,7 +16,8 @@ */ import path from 'path'; -import { monotonicTime } from 'playwright-core/lib/utils'; +import type { HttpServer, ManualPromise } from 'playwright-core/lib/utils'; +import { isUnderTest, monotonicTime } from 'playwright-core/lib/utils'; import type { FullResult, TestError } from '../../types/testReporter'; import { webServerPluginsForConfig } from '../plugins/webServerPlugin'; import { collectFilesForProject, filterProjects } from './projectUtils'; @@ -25,12 +26,13 @@ import { TestRun, createTaskRunner, createTaskRunnerForList } from './tasks'; import type { FullConfigInternal } from '../common/config'; import { colors } from 'playwright-core/lib/utilsBundle'; import { runWatchModeLoop } from './watchMode'; -import { runUIMode } from './uiMode'; +import { runTestServer } from './testServer'; import { InternalReporter } from '../reporters/internalReporter'; import { Multiplexer } from '../reporters/multiplexer'; import type { Suite } from '../common/test'; import { wrapReporterAsV2 } from '../reporters/reporterV2'; import { affectedTestFiles } from '../transform/compilationCache'; +import { installRootRedirect, openTraceInBrowser, openTraceViewerApp } from 'playwright-core/lib/server'; type ProjectConfigWithFiles = { name: string; @@ -83,7 +85,7 @@ export class Runner { // Legacy webServer support. webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); - const reporter = new InternalReporter(new Multiplexer(await createReporters(config, listOnly ? 'list' : 'run'))); + const reporter = new InternalReporter(new Multiplexer(await createReporters(config, listOnly ? 'list' : 'test'))); const taskRunner = listOnly ? createTaskRunnerForList(config, reporter, 'in-process', { failOnLoadErrors: true }) : createTaskRunner(config, reporter); @@ -146,10 +148,32 @@ export class Runner { return await runWatchModeLoop(config); } - async uiAllTests(options: { host?: string, port?: number }): Promise { + async runUIMode(options: { host?: string, port?: number }): Promise { const config = this._config; webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); - return await runUIMode(config, options); + return await runTestServer(config, options, async (server: HttpServer, cancelPromise: ManualPromise) => { + await installRootRedirect(server, [], { webApp: 'uiMode.html' }); + if (options.host !== undefined || options.port !== undefined) { + await openTraceInBrowser(server.urlPrefix()); + } else { + const page = await openTraceViewerApp(server.urlPrefix(), 'chromium', { + headless: isUnderTest() && process.env.PWTEST_HEADED_FOR_TEST !== '1', + persistentContextOptions: { + handleSIGINT: false, + }, + }); + page.on('close', () => cancelPromise.resolve()); + } + }); + } + + async runTestServer(options: { host?: string, port?: number }): Promise { + const config = this._config; + webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); + return await runTestServer(config, options, async server => { + // eslint-disable-next-line no-console + console.log('Listening on ' + server.urlPrefix().replace('http:', 'ws:') + '/' + server.wsGuid()); + }); } async findRelatedTestFiles(mode: 'in-process' | 'out-of-process', files: string[]): Promise { diff --git a/packages/playwright/src/runner/tasks.ts b/packages/playwright/src/runner/tasks.ts index 1cd57034e4..1f890586bc 100644 --- a/packages/playwright/src/runner/tasks.ts +++ b/packages/playwright/src/runner/tasks.ts @@ -78,7 +78,7 @@ export function createTaskRunnerForWatchSetup(config: FullConfigInternal, report export function createTaskRunnerForWatch(config: FullConfigInternal, reporter: ReporterV2, additionalFileMatcher?: Matcher): TaskRunner { const taskRunner = new TaskRunner(reporter, 0); - taskRunner.addTask('load tests', createLoadTask('out-of-process', { filterOnly: true, failOnLoadErrors: false, doNotRunTestsOutsideProjectFilter: true, additionalFileMatcher })); + taskRunner.addTask('load tests', createLoadTask('out-of-process', { filterOnly: true, failOnLoadErrors: false, doNotRunDepsOutsideProjectFilter: true, additionalFileMatcher })); addRunTasks(taskRunner, config); return taskRunner; } @@ -86,7 +86,7 @@ export function createTaskRunnerForWatch(config: FullConfigInternal, reporter: R export function createTaskRunnerForTestServer(config: FullConfigInternal, reporter: ReporterV2): TaskRunner { const taskRunner = new TaskRunner(reporter, 0); addGlobalSetupTasks(taskRunner, config); - taskRunner.addTask('load tests', createLoadTask('out-of-process', { filterOnly: true, failOnLoadErrors: false, doNotRunTestsOutsideProjectFilter: true })); + taskRunner.addTask('load tests', createLoadTask('out-of-process', { filterOnly: true, failOnLoadErrors: false, doNotRunDepsOutsideProjectFilter: true })); addRunTasks(taskRunner, config); return taskRunner; } @@ -195,10 +195,10 @@ function createRemoveOutputDirsTask(): Task { }; } -function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filterOnly: boolean, failOnLoadErrors: boolean, doNotRunTestsOutsideProjectFilter?: boolean, additionalFileMatcher?: Matcher }): Task { +function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filterOnly: boolean, failOnLoadErrors: boolean, doNotRunDepsOutsideProjectFilter?: boolean, additionalFileMatcher?: Matcher }): Task { return { setup: async (testRun, errors, softErrors) => { - await collectProjectsAndTestFiles(testRun, !!options.doNotRunTestsOutsideProjectFilter, options.additionalFileMatcher); + await collectProjectsAndTestFiles(testRun, !!options.doNotRunDepsOutsideProjectFilter, options.additionalFileMatcher); await loadFileSuites(testRun, mode, options.failOnLoadErrors ? errors : softErrors); testRun.rootSuite = await createRootSuite(testRun, options.failOnLoadErrors ? errors : softErrors, !!options.filterOnly); testRun.failureTracker.onRootSuite(testRun.rootSuite); diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index 0b93adb7c5..b915834d24 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -1,11 +1,11 @@ /** - * Copyright (c) Microsoft Corporation. + * Copyright Microsoft Corporation. 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 + * 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, @@ -14,221 +14,328 @@ * limitations under the License. */ -import type http from 'http'; +import fs from 'fs'; import path from 'path'; -import { ManualPromise, createGuid, gracefullyProcessExitDoNotHang } from 'playwright-core/lib/utils'; -import { WSServer } from 'playwright-core/lib/utils'; -import type { WebSocket } from 'playwright-core/lib/utilsBundle'; -import type { FullResult, TestError } from 'playwright/types/testReporter'; -import { loadConfig, restartWithExperimentalTsEsm } from '../common/configLoader'; -import { InternalReporter } from '../reporters/internalReporter'; -import { Multiplexer } from '../reporters/multiplexer'; -import { createReporters } from './reporters'; -import { TestRun, createTaskRunnerForList, createTaskRunnerForTestServer } from './tasks'; -import type { ConfigCLIOverrides } from '../common/ipc'; -import { Runner } from './runner'; -import type { FindRelatedTestFilesReport } from './runner'; +import { registry, startTraceViewerServer } from 'playwright-core/lib/server'; +import { ManualPromise, gracefullyProcessExitDoNotHang, isUnderTest } from 'playwright-core/lib/utils'; +import type { Transport, HttpServer } from 'playwright-core/lib/utils'; +import type * as reporterTypes from '../../types/testReporter'; +import { collectAffectedTestFiles, dependenciesForTestFile } from '../transform/compilationCache'; import type { FullConfigInternal } from '../common/config'; -import type { TestServerInterface } from './testServerInterface'; +import { InternalReporter } from '../reporters/internalReporter'; +import { createReporterForTestServer, createReporters } from './reporters'; +import { TestRun, createTaskRunnerForList, createTaskRunnerForWatch, createTaskRunnerForWatchSetup } from './tasks'; +import { open } from 'playwright-core/lib/utilsBundle'; +import ListReporter from '../reporters/list'; +import { Multiplexer } from '../reporters/multiplexer'; +import { SigIntWatcher } from './sigIntWatcher'; +import { Watcher } from '../fsWatcher'; +import type { TestServerInterface, TestServerInterfaceEventEmitters } from '../isomorphic/testServerInterface'; +import { Runner } from './runner'; import { serializeError } from '../util'; import { prepareErrorStack } from '../reporters/base'; -import { loadReporter } from './loadUtils'; -import { wrapReporterAsV2 } from '../reporters/reporterV2'; -export async function runTestServer() { - if (restartWithExperimentalTsEsm(undefined, true)) - return null; - process.env.PW_TEST_HTML_REPORT_OPEN = 'never'; - const wss = new WSServer({ - onConnection(request: http.IncomingMessage, url: URL, ws: WebSocket, id: string) { - const dispatcher = new Dispatcher(ws); - ws.on('message', async message => { - const { id, method, params } = JSON.parse(message.toString()); - try { - const result = await (dispatcher as any)[method](params); - ws.send(JSON.stringify({ id, result })); - } catch (e) { - // eslint-disable-next-line no-console - console.error(e); - } - }); - return { - async close() {} - }; - }, - }); - const url = await wss.listen(0, 'localhost', '/' + createGuid()); - // eslint-disable-next-line no-console - process.on('exit', () => wss.close().catch(console.error)); - // eslint-disable-next-line no-console - console.log(`Listening on ${url}`); - process.stdin.on('close', () => gracefullyProcessExitDoNotHang(0)); -} +class TestServer { + private _config: FullConfigInternal; + private _dispatcher: TestServerDispatcher | undefined; + private _originalStdoutWrite: NodeJS.WriteStream['write']; + private _originalStderrWrite: NodeJS.WriteStream['write']; -class Dispatcher implements TestServerInterface { - private _testRun: { run: Promise, stop: ManualPromise } | undefined; - private _ws: WebSocket; + constructor(config: FullConfigInternal) { + this._config = config; + process.env.PW_LIVE_TRACE_STACKS = '1'; + config.cliListOnly = false; + config.cliPassWithNoTests = true; + config.config.preserveOutput = 'always'; - constructor(ws: WebSocket) { - this._ws = ws; + for (const p of config.projects) { + p.project.retries = 0; + p.project.repeatEach = 1; + } + config.configCLIOverrides.use = config.configCLIOverrides.use || {}; + config.configCLIOverrides.use.trace = { mode: 'on', sources: false, _live: true }; - process.stdout.write = ((chunk: string | Buffer, cb?: Buffer | Function, cb2?: Function) => { - this._dispatchEvent('stdio', chunkToPayload('stdout', chunk)); - if (typeof cb === 'function') - (cb as any)(); - if (typeof cb2 === 'function') - (cb2 as any)(); - return true; - }) as any; - process.stderr.write = ((chunk: string | Buffer, cb?: Buffer | Function, cb2?: Function) => { - this._dispatchEvent('stdio', chunkToPayload('stderr', chunk)); - if (typeof cb === 'function') - (cb as any)(); - if (typeof cb2 === 'function') - (cb2 as any)(); - return true; - }) as any; + this._originalStdoutWrite = process.stdout.write; + this._originalStderrWrite = process.stderr.write; } - async listFiles(params: { - configFile: string; - }): Promise<{ - projects: { - name: string; - testDir: string; - use: { testIdAttribute?: string }; - files: string[]; - }[]; - cliEntryPoint?: string; - error?: TestError; - }> { + async start(options: { host?: string, port?: number }): Promise { + this._dispatcher = new TestServerDispatcher(this._config); + return await startTraceViewerServer({ ...options, transport: this._dispatcher.transport }); + } + + async stop() { + await this._dispatcher?.runGlobalTeardown(); + } + + wireStdIO() { + if (!process.env.PWTEST_DEBUG) { + process.stdout.write = (chunk: string | Buffer) => { + this._dispatcher?._dispatchEvent('stdio', chunkToPayload('stdout', chunk)); + return true; + }; + process.stderr.write = (chunk: string | Buffer) => { + this._dispatcher?._dispatchEvent('stdio', chunkToPayload('stderr', chunk)); + return true; + }; + } + } + + unwireStdIO() { + if (!process.env.PWTEST_DEBUG) { + process.stdout.write = this._originalStdoutWrite; + process.stderr.write = this._originalStderrWrite; + } + } +} + +class TestServerDispatcher implements TestServerInterface { + private _config: FullConfigInternal; + private _globalWatcher: Watcher; + private _testWatcher: Watcher; + private _testRun: { run: Promise, stop: ManualPromise } | undefined; + readonly transport: Transport; + private _queue = Promise.resolve(); + private _globalCleanup: (() => Promise) | undefined; + readonly _dispatchEvent: TestServerInterfaceEventEmitters['dispatchEvent']; + + constructor(config: FullConfigInternal) { + this._config = config; + this.transport = { + dispatch: (method, params) => (this as any)[method](params), + onclose: () => {}, + }; + this._globalWatcher = new Watcher('deep', () => this._dispatchEvent('listChanged', {})); + this._testWatcher = new Watcher('flat', events => { + const collector = new Set(); + events.forEach(f => collectAffectedTestFiles(f.file, collector)); + this._dispatchEvent('testFilesChanged', { testFiles: [...collector] }); + }); + this._dispatchEvent = (method, params) => this.transport.sendEvent?.(method, params); + } + + async ping() {} + + async open(params: { location: reporterTypes.Location }) { + if (isUnderTest()) + return; + // eslint-disable-next-line no-console + open('vscode://file/' + params.location.file + ':' + params.location.line).catch(e => console.error(e)); + } + + async resizeTerminal(params: { cols: number; rows: number; }) { + process.stdout.columns = params.cols; + process.stdout.rows = params.rows; + process.stderr.columns = params.cols; + process.stderr.columns = params.rows; + } + + async checkBrowsers(): Promise<{ hasBrowsers: boolean; }> { + return { hasBrowsers: hasSomeBrowsers() }; + } + + async installBrowsers() { + await installBrowsers(); + } + + async runGlobalSetup(): Promise { + await this.runGlobalTeardown(); + + const reporter = new InternalReporter(new ListReporter()); + const taskRunner = createTaskRunnerForWatchSetup(this._config, reporter); + reporter.onConfigure(this._config.config); + const testRun = new TestRun(this._config, reporter); + const { status, cleanup: globalCleanup } = await taskRunner.runDeferCleanup(testRun, 0); + await reporter.onEnd({ status }); + await reporter.onExit(); + if (status !== 'passed') { + await globalCleanup(); + return status; + } + this._globalCleanup = globalCleanup; + return status; + } + + async runGlobalTeardown() { + const result = (await this._globalCleanup?.()) || 'passed'; + this._globalCleanup = undefined; + return result; + } + + async listFiles() { try { - const config = await this._loadConfig(params.configFile); - const runner = new Runner(config); + const runner = new Runner(this._config); return runner.listTestFiles(); } catch (e) { - const error: TestError = serializeError(e); + const error: reporterTypes.TestError = serializeError(e); error.location = prepareErrorStack(e.stack).location; return { projects: [], error }; } } - async listTests(params: { - configFile: string; - locations: string[]; - reporter: string; - env: NodeJS.ProcessEnv; - }) { - const config = await this._loadConfig(params.configFile); - config.cliArgs = params.locations || []; - const wireReporter = await this._createReporter(params.reporter); - const reporter = new InternalReporter(new Multiplexer([wireReporter])); - const taskRunner = createTaskRunnerForList(config, reporter, 'out-of-process', { failOnLoadErrors: true }); - const testRun = new TestRun(config, reporter); - reporter.onConfigure(config.config); - - const taskStatus = await taskRunner.run(testRun, 0); - let status: FullResult['status'] = testRun.failureTracker.result(); - if (status === 'passed' && taskStatus !== 'passed') - status = taskStatus; - const modifiedResult = await reporter.onEnd({ status }); - if (modifiedResult && modifiedResult.status) - status = modifiedResult.status; - await reporter.onExit(); + async listTests(params: { reporter?: string; fileNames: string[]; }) { + let report: any[] = []; + this._queue = this._queue.then(async () => { + report = await this._innerListTests(params); + }).catch(printInternalError); + await this._queue; + return { report }; } - async test(params: { - configFile: string; - locations: string[]; - reporter: string; - env: NodeJS.ProcessEnv; - headed?: boolean; - oneWorker?: boolean; - trace?: 'on' | 'off'; - projects?: string[]; - grep?: string; - reuseContext?: boolean; - connectWsEndpoint?: string; - }) { - await this._stopTests(); + private async _innerListTests(params: { reporter?: string; fileNames?: string[]; }) { + const report: any[] = []; + const wireReporter = await createReporterForTestServer(this._config, params.reporter || require.resolve('./uiModeReporter'), 'list', e => report.push(e)); + const reporter = new InternalReporter(wireReporter); + this._config.cliArgs = params.fileNames || []; + this._config.cliListOnly = true; + this._config.testIdMatcher = undefined; + const taskRunner = createTaskRunnerForList(this._config, reporter, 'out-of-process', { failOnLoadErrors: false }); + const testRun = new TestRun(this._config, reporter); + reporter.onConfigure(this._config.config); + const status = await taskRunner.run(testRun, 0); + await reporter.onEnd({ status }); + await reporter.onExit(); - const overrides: ConfigCLIOverrides = { - repeatEach: 1, - retries: 0, - preserveOutputDir: true, - use: { - trace: params.trace, - headless: params.headed ? false : undefined, - _optionContextReuseMode: params.reuseContext ? 'when-possible' : undefined, - _optionConnectOptions: params.connectWsEndpoint ? { wsEndpoint: params.connectWsEndpoint } : undefined, - }, - workers: params.oneWorker ? 1 : undefined, - }; + const projectDirs = new Set(); + const projectOutputs = new Set(); + for (const p of this._config.projects) { + projectDirs.add(p.project.testDir); + projectOutputs.add(p.project.outputDir); + } - const config = await this._loadConfig(params.configFile, overrides); - config.cliListOnly = false; - config.cliArgs = params.locations || []; - config.cliGrep = params.grep; - config.cliProjectFilter = params.projects?.length ? params.projects : undefined; + const result = await resolveCtDirs(this._config); + if (result) { + projectDirs.add(result.templateDir); + projectOutputs.add(result.outDir); + } - const wireReporter = await this._createReporter(params.reporter); - const configReporters = await createReporters(config, 'run'); - const reporter = new InternalReporter(new Multiplexer([...configReporters, wireReporter])); - const taskRunner = createTaskRunnerForTestServer(config, reporter); - const testRun = new TestRun(config, reporter); - reporter.onConfigure(config.config); + this._globalWatcher.update([...projectDirs], [...projectOutputs], false); + return report; + } + + async runTests(params: { reporter?: string; locations?: string[] | undefined; grep?: string | undefined; testIds?: string[] | undefined; headed?: boolean | undefined; oneWorker?: boolean | undefined; trace?: 'off' | 'on' | undefined; projects?: string[] | undefined; reuseContext?: boolean | undefined; connectWsEndpoint?: string | undefined; }) { + let status: reporterTypes.FullResult['status']; + this._queue = this._queue.then(async () => { + status = await this._innerRunTests(params).catch(printInternalError) || 'failed'; + }); + await this._queue; + return { status: status! }; + } + + private async _innerRunTests(params: { reporter?: string; locations?: string[] | undefined; grep?: string | undefined; testIds?: string[] | undefined; headed?: boolean | undefined; oneWorker?: boolean | undefined; trace?: 'off' | 'on' | undefined; projects?: string[] | undefined; reuseContext?: boolean | undefined; connectWsEndpoint?: string | undefined; }): Promise { + await this.stopTests(); + const { testIds, projects, locations, grep } = params; + + const testIdSet = testIds ? new Set(testIds) : null; + this._config.cliArgs = locations ? locations : []; + this._config.cliGrep = grep; + this._config.cliListOnly = false; + this._config.cliProjectFilter = projects?.length ? projects : undefined; + this._config.testIdMatcher = id => !testIdSet || testIdSet.has(id); + + const reporters = await createReporters(this._config, 'ui'); + reporters.push(await createReporterForTestServer(this._config, params.reporter || require.resolve('./uiModeReporter'), 'list', e => this._dispatchEvent('report', e))); + const reporter = new InternalReporter(new Multiplexer(reporters)); + const taskRunner = createTaskRunnerForWatch(this._config, reporter); + const testRun = new TestRun(this._config, reporter); + reporter.onConfigure(this._config.config); const stop = new ManualPromise(); const run = taskRunner.run(testRun, 0, stop).then(async status => { await reporter.onEnd({ status }); await reporter.onExit(); this._testRun = undefined; + this._config.testIdMatcher = undefined; return status; }); this._testRun = { run, stop }; - await run; + return await run; } - async findRelatedTestFiles(params: { - configFile: string; - files: string[]; - }): Promise { - const config = await this._loadConfig(params.configFile); - const runner = new Runner(config); + async watch(params: { fileNames: string[]; }) { + const files = new Set(); + for (const fileName of params.fileNames) { + files.add(fileName); + dependenciesForTestFile(fileName).forEach(file => files.add(file)); + } + this._testWatcher.update([...files], [], true); + } + + findRelatedTestFiles(params: { files: string[]; }): Promise<{ testFiles: string[]; errors?: reporterTypes.TestError[] | undefined; }> { + const runner = new Runner(this._config); return runner.findRelatedTestFiles('out-of-process', params.files); } - async stop(params: { - configFile: string; - }) { - await this._stopTests(); + async stopTests() { + this._testRun?.stop?.resolve(); + await this._testRun?.run; } async closeGracefully() { gracefullyProcessExitDoNotHang(0); } - - private async _stopTests() { - this._testRun?.stop?.resolve(); - await this._testRun?.run; - } - - private _dispatchEvent(method: string, params: any) { - this._ws.send(JSON.stringify({ method, params })); - } - - private async _loadConfig(configFile: string, overrides?: ConfigCLIOverrides): Promise { - return loadConfig({ resolvedConfigFile: configFile, configDir: path.dirname(configFile) }, overrides); - } - - private async _createReporter(file: string) { - const reporterConstructor = await loadReporter(undefined, file); - const instance = new reporterConstructor((message: any) => this._dispatchEvent('report', message)); - return wrapReporterAsV2(instance); - } } -function chunkToPayload(type: 'stdout' | 'stderr', chunk: Buffer | string) { +export async function runTestServer(config: FullConfigInternal, options: { host?: string, port?: number }, openUI: (server: HttpServer, cancelPromise: ManualPromise) => Promise): Promise { + const testServer = new TestServer(config); + const cancelPromise = new ManualPromise(); + const sigintWatcher = new SigIntWatcher(); + void sigintWatcher.promise().then(() => cancelPromise.resolve()); + try { + const server = await testServer.start(options); + await openUI(server, cancelPromise); + testServer.wireStdIO(); + await cancelPromise; + } finally { + testServer.unwireStdIO(); + await testServer.stop(); + sigintWatcher.disarm(); + } + return sigintWatcher.hadSignal() ? 'interrupted' : 'passed'; +} + +type StdioPayload = { + type: 'stdout' | 'stderr'; + text?: string; + buffer?: string; +}; + +function chunkToPayload(type: 'stdout' | 'stderr', chunk: Buffer | string): StdioPayload { if (chunk instanceof Buffer) return { type, buffer: chunk.toString('base64') }; return { type, text: chunk }; } + +function hasSomeBrowsers(): boolean { + for (const browserName of ['chromium', 'webkit', 'firefox']) { + try { + registry.findExecutable(browserName)!.executablePathOrDie('javascript'); + return true; + } catch { + } + } + return false; +} + +async function installBrowsers() { + const executables = registry.defaultExecutables(); + await registry.install(executables, false); +} + +function printInternalError(e: Error) { + // eslint-disable-next-line no-console + console.error('Internal error:', e); +} + +// TODO: remove CT dependency. +export async function resolveCtDirs(config: FullConfigInternal) { + const use = config.config.projects[0].use as any; + const relativeTemplateDir = use.ctTemplateDir || 'playwright'; + const templateDir = await fs.promises.realpath(path.normalize(path.join(config.configDir, relativeTemplateDir))).catch(() => undefined); + if (!templateDir) + return null; + const outDir = use.ctCacheDir ? path.resolve(config.configDir, use.ctCacheDir) : path.resolve(templateDir, '.cache'); + return { + outDir, + templateDir + }; +} diff --git a/packages/playwright/src/runner/testServerInterface.ts b/packages/playwright/src/runner/testServerInterface.ts deleted file mode 100644 index 1e844d8123..0000000000 --- a/packages/playwright/src/runner/testServerInterface.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { TestError } from '../../types/testReporter'; - -export interface TestServerInterface { - listFiles(params: { - configFile: string; - }): Promise<{ - projects: { - name: string; - testDir: string; - use: { testIdAttribute?: string }; - files: string[]; - }[]; - cliEntryPoint?: string; - error?: TestError; - }>; - - listTests(params: { - configFile: string; - locations: string[]; - reporter: string; - }): Promise; - - test(params: { - configFile: string; - locations: string[]; - reporter: string; - headed?: boolean; - oneWorker?: boolean; - trace?: 'on' | 'off'; - projects?: string[]; - grep?: string; - reuseContext?: boolean; - connectWsEndpoint?: string; - }): Promise; - - findRelatedTestFiles(params: { - configFile: string; - files: string[]; - }): Promise<{ testFiles: string[]; errors?: TestError[]; }>; - - stop(params: { - configFile: string; - }): Promise; - - closeGracefully(): Promise; -} - -export interface TestServerEvents { - on(event: 'report', listener: (params: any) => void): void; - on(event: 'stdio', listener: (params: { type: 'stdout' | 'stderr', text?: string, buffer?: string }) => void): void; -} diff --git a/packages/playwright/src/runner/uiMode.ts b/packages/playwright/src/runner/uiMode.ts deleted file mode 100644 index d0071e14f1..0000000000 --- a/packages/playwright/src/runner/uiMode.ts +++ /dev/null @@ -1,272 +0,0 @@ -/** - * Copyright Microsoft Corporation. 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 { openTraceViewerApp, openTraceInBrowser, registry } from 'playwright-core/lib/server'; -import { isUnderTest, ManualPromise } from 'playwright-core/lib/utils'; -import type { FullResult } from '../../types/testReporter'; -import { collectAffectedTestFiles, dependenciesForTestFile } from '../transform/compilationCache'; -import type { FullConfigInternal } from '../common/config'; -import { InternalReporter } from '../reporters/internalReporter'; -import { TeleReporterEmitter } from '../reporters/teleEmitter'; -import { createReporters } from './reporters'; -import { TestRun, createTaskRunnerForList, createTaskRunnerForWatch, createTaskRunnerForWatchSetup } from './tasks'; -import { open } from 'playwright-core/lib/utilsBundle'; -import ListReporter from '../reporters/list'; -import type { OpenTraceViewerOptions, Transport } from 'playwright-core/lib/server/trace/viewer/traceViewer'; -import { Multiplexer } from '../reporters/multiplexer'; -import { SigIntWatcher } from './sigIntWatcher'; -import { Watcher } from '../fsWatcher'; - -class UIMode { - private _config: FullConfigInternal; - private _transport!: Transport; - private _testRun: { run: Promise, stop: ManualPromise } | undefined; - globalCleanup: (() => Promise) | undefined; - private _globalWatcher: Watcher; - private _testWatcher: Watcher; - private _originalStdoutWrite: NodeJS.WriteStream['write']; - private _originalStderrWrite: NodeJS.WriteStream['write']; - - constructor(config: FullConfigInternal) { - this._config = config; - process.env.PW_LIVE_TRACE_STACKS = '1'; - config.cliListOnly = false; - config.cliPassWithNoTests = true; - config.config.preserveOutput = 'always'; - - for (const p of config.projects) { - p.project.retries = 0; - p.project.repeatEach = 1; - } - config.configCLIOverrides.use = config.configCLIOverrides.use || {}; - config.configCLIOverrides.use.trace = { mode: 'on', sources: false, _live: true }; - - this._originalStdoutWrite = process.stdout.write; - this._originalStderrWrite = process.stderr.write; - this._globalWatcher = new Watcher('deep', () => this._dispatchEvent('listChanged', {})); - this._testWatcher = new Watcher('flat', events => { - const collector = new Set(); - events.forEach(f => collectAffectedTestFiles(f.file, collector)); - this._dispatchEvent('testFilesChanged', { testFileNames: [...collector] }); - }); - } - - async runGlobalSetup(): Promise { - const reporter = new InternalReporter(new ListReporter()); - const taskRunner = createTaskRunnerForWatchSetup(this._config, reporter); - reporter.onConfigure(this._config.config); - const testRun = new TestRun(this._config, reporter); - const { status, cleanup: globalCleanup } = await taskRunner.runDeferCleanup(testRun, 0); - await reporter.onEnd({ status }); - await reporter.onExit(); - if (status !== 'passed') { - await globalCleanup(); - return status; - } - this.globalCleanup = globalCleanup; - return status; - } - - async showUI(options: { host?: string, port?: number }, cancelPromise: ManualPromise) { - let queue = Promise.resolve(); - - this._transport = { - dispatch: async (method, params) => { - if (method === 'ping') - return; - - if (method === 'watch') { - this._watchFiles(params.fileNames); - return; - } - if (method === 'open' && params.location) { - open('vscode://file/' + params.location).catch(e => this._originalStderrWrite.call(process.stderr, String(e))); - return; - } - if (method === 'resizeTerminal') { - process.stdout.columns = params.cols; - process.stdout.rows = params.rows; - process.stderr.columns = params.cols; - process.stderr.columns = params.rows; - return; - } - if (method === 'stop') { - void this._stopTests(); - return; - } - if (method === 'checkBrowsers') - return { hasBrowsers: hasSomeBrowsers() }; - if (method === 'installBrowsers') { - await installBrowsers(); - return; - } - - queue = queue.then(() => this._queueListOrRun(method, params)); - await queue; - }, - - onclose: () => { }, - }; - const openOptions: OpenTraceViewerOptions = { - app: 'uiMode.html', - headless: isUnderTest() && process.env.PWTEST_HEADED_FOR_TEST !== '1', - transport: this._transport, - host: options.host, - port: options.port, - persistentContextOptions: { - handleSIGINT: false, - }, - }; - if (options.host !== undefined || options.port !== undefined) { - await openTraceInBrowser([], openOptions); - } else { - const page = await openTraceViewerApp([], 'chromium', openOptions); - page.on('close', () => cancelPromise.resolve()); - } - - if (!process.env.PWTEST_DEBUG) { - process.stdout.write = (chunk: string | Buffer) => { - this._dispatchEvent('stdio', chunkToPayload('stdout', chunk)); - return true; - }; - process.stderr.write = (chunk: string | Buffer) => { - this._dispatchEvent('stdio', chunkToPayload('stderr', chunk)); - return true; - }; - } - await cancelPromise; - - if (!process.env.PWTEST_DEBUG) { - process.stdout.write = this._originalStdoutWrite; - process.stderr.write = this._originalStderrWrite; - } - } - - private async _queueListOrRun(method: string, params: any) { - if (method === 'list') - await this._listTests(); - if (method === 'run') - await this._runTests(params.testIds, params.projects); - } - - private _dispatchEvent(method: string, params?: any) { - this._transport.sendEvent?.(method, params); - } - - private async _listTests() { - const reporter = new InternalReporter(new TeleReporterEmitter(e => this._dispatchEvent(e.method, e.params), true)); - this._config.cliListOnly = true; - this._config.testIdMatcher = undefined; - const taskRunner = createTaskRunnerForList(this._config, reporter, 'out-of-process', { failOnLoadErrors: false }); - const testRun = new TestRun(this._config, reporter); - reporter.onConfigure(this._config.config); - const status = await taskRunner.run(testRun, 0); - await reporter.onEnd({ status }); - await reporter.onExit(); - - const projectDirs = new Set(); - const projectOutputs = new Set(); - for (const p of this._config.projects) { - projectDirs.add(p.project.testDir); - projectOutputs.add(p.project.outputDir); - } - this._globalWatcher.update([...projectDirs], [...projectOutputs], false); - } - - private async _runTests(testIds: string[], projects: string[]) { - await this._stopTests(); - - const testIdSet = testIds ? new Set(testIds) : null; - this._config.cliListOnly = false; - this._config.cliProjectFilter = projects.length ? projects : undefined; - this._config.testIdMatcher = id => !testIdSet || testIdSet.has(id); - - const reporters = await createReporters(this._config, 'ui'); - reporters.push(new TeleReporterEmitter(e => this._dispatchEvent(e.method, e.params), true)); - const reporter = new InternalReporter(new Multiplexer(reporters)); - const taskRunner = createTaskRunnerForWatch(this._config, reporter); - const testRun = new TestRun(this._config, reporter); - reporter.onConfigure(this._config.config); - const stop = new ManualPromise(); - const run = taskRunner.run(testRun, 0, stop).then(async status => { - await reporter.onEnd({ status }); - await reporter.onExit(); - this._testRun = undefined; - this._config.testIdMatcher = undefined; - return status; - }); - this._testRun = { run, stop }; - await run; - } - - private _watchFiles(fileNames: string[]) { - const files = new Set(); - for (const fileName of fileNames) { - files.add(fileName); - dependenciesForTestFile(fileName).forEach(file => files.add(file)); - } - this._testWatcher.update([...files], [], true); - } - - private async _stopTests() { - this._testRun?.stop?.resolve(); - await this._testRun?.run; - } -} - -export async function runUIMode(config: FullConfigInternal, options: { host?: string, port?: number }): Promise { - const uiMode = new UIMode(config); - const globalSetupStatus = await uiMode.runGlobalSetup(); - if (globalSetupStatus !== 'passed') - return globalSetupStatus; - const cancelPromise = new ManualPromise(); - const sigintWatcher = new SigIntWatcher(); - void sigintWatcher.promise().then(() => cancelPromise.resolve()); - try { - await uiMode.showUI(options, cancelPromise); - } finally { - sigintWatcher.disarm(); - } - return await uiMode.globalCleanup?.() || (sigintWatcher.hadSignal() ? 'interrupted' : 'passed'); -} - -type StdioPayload = { - type: 'stdout' | 'stderr'; - text?: string; - buffer?: string; -}; - -function chunkToPayload(type: 'stdout' | 'stderr', chunk: Buffer | string): StdioPayload { - if (chunk instanceof Buffer) - return { type, buffer: chunk.toString('base64') }; - return { type, text: chunk }; -} - -function hasSomeBrowsers(): boolean { - for (const browserName of ['chromium', 'webkit', 'firefox']) { - try { - registry.findExecutable(browserName)!.executablePathOrDie('javascript'); - return true; - } catch { - } - } - return false; -} - -async function installBrowsers() { - const executables = registry.defaultExecutables(); - await registry.install(executables, false); -} diff --git a/packages/playwright/types/reporterPrivate.ts b/packages/playwright/src/runner/uiModeReporter.ts similarity index 72% rename from packages/playwright/types/reporterPrivate.ts rename to packages/playwright/src/runner/uiModeReporter.ts index 198b2fe9ea..8de663149a 100644 --- a/packages/playwright/types/reporterPrivate.ts +++ b/packages/playwright/src/runner/uiModeReporter.ts @@ -14,9 +14,12 @@ * limitations under the License. */ -import type { Suite } from './testReporter'; +import { TeleReporterEmitter } from '../reporters/teleEmitter'; -export interface SuitePrivate extends Suite { - _fileId: string | undefined; - _parallelMode: 'none' | 'default' | 'serial' | 'parallel'; +class UIModeReporter extends TeleReporterEmitter { + constructor(options: any) { + super(options._send, { omitBuffers: true }); + } } + +export default UIModeReporter; diff --git a/packages/playwright/src/third_party/tsconfig-loader.ts b/packages/playwright/src/third_party/tsconfig-loader.ts index ffe8af1a00..d85ff32100 100644 --- a/packages/playwright/src/third_party/tsconfig-loader.ts +++ b/packages/playwright/src/third_party/tsconfig-loader.ts @@ -44,8 +44,11 @@ interface TsConfig { export interface LoadedTsConfig { tsConfigPath: string; - baseUrl?: string; - paths?: { [key: string]: Array }; + paths?: { + mapping: { [key: string]: Array }; + pathsBasePath: string; // absolute path + }; + absoluteBaseUrl?: string; allowJs?: boolean; } @@ -101,7 +104,8 @@ function resolveConfigFile(baseConfigFile: string, referencedConfigFile: string) referencedConfigFile += '.json'; const currentDir = path.dirname(baseConfigFile); let resolvedConfigFile = path.resolve(currentDir, referencedConfigFile); - if (referencedConfigFile.indexOf('/') !== -1 && referencedConfigFile.indexOf('.') !== -1 && !fs.existsSync(referencedConfigFile)) + // TODO: I don't see how this makes sense, delete in the next minor release. + if (referencedConfigFile.includes('/') && referencedConfigFile.includes('.') && !fs.existsSync(resolvedConfigFile)) resolvedConfigFile = path.join(currentDir, 'node_modules', referencedConfigFile); return resolvedConfigFile; } @@ -117,6 +121,7 @@ function loadTsConfig( let result: LoadedTsConfig = { tsConfigPath: configFilePath, }; + // Retain result instance below, so that caching works. visited.set(configFilePath, result); if (!fs.existsSync(configFilePath)) @@ -130,23 +135,27 @@ function loadTsConfig( for (const extendedConfig of extendsArray) { const extendedConfigPath = resolveConfigFile(configFilePath, extendedConfig); const base = loadTsConfig(extendedConfigPath, references, visited); - - // baseUrl should be interpreted as relative to the base tsconfig, - // but we need to update it so it is relative to the original tsconfig being loaded - if (base.baseUrl && base.baseUrl) { - const extendsDir = path.dirname(extendedConfig); - base.baseUrl = path.join(extendsDir, base.baseUrl); - } - result = { ...result, ...base, tsConfigPath: configFilePath }; + // Retain result instance, so that caching works. + Object.assign(result, base, { tsConfigPath: configFilePath }); } - const loadedConfig = Object.fromEntries(Object.entries({ - baseUrl: parsedConfig.compilerOptions?.baseUrl, - paths: parsedConfig.compilerOptions?.paths, - allowJs: parsedConfig?.compilerOptions?.allowJs, - }).filter(([, value]) => value !== undefined)); - - result = { ...result, ...loadedConfig }; + if (parsedConfig.compilerOptions?.allowJs !== undefined) + result.allowJs = parsedConfig.compilerOptions.allowJs; + if (parsedConfig.compilerOptions?.paths !== undefined) { + // We must store pathsBasePath from the config that defines "paths" and later resolve + // based on this absolute path, when no "baseUrl" is specified. See tsc for reference: + // https://github.com/microsoft/TypeScript/blob/353ccb7688351ae33ccf6e0acb913aa30621eaf4/src/compiler/commandLineParser.ts#L3129 + // https://github.com/microsoft/TypeScript/blob/353ccb7688351ae33ccf6e0acb913aa30621eaf4/src/compiler/moduleSpecifiers.ts#L510 + result.paths = { + mapping: parsedConfig.compilerOptions.paths, + pathsBasePath: path.dirname(configFilePath), + }; + } + if (parsedConfig.compilerOptions?.baseUrl !== undefined) { + // Follow tsc and resolve all relative file paths in the config right away. + // This way it is safe to inherit paths between the configs. + result.absoluteBaseUrl = path.resolve(path.dirname(configFilePath), parsedConfig.compilerOptions.baseUrl); + } for (const ref of parsedConfig.references || []) references.push(loadTsConfig(resolveConfigFile(configFilePath, ref.path), references, visited)); diff --git a/packages/playwright/src/transform/compilationCache.ts b/packages/playwright/src/transform/compilationCache.ts index caa4b08d23..e0154840b0 100644 --- a/packages/playwright/src/transform/compilationCache.ts +++ b/packages/playwright/src/transform/compilationCache.ts @@ -27,7 +27,7 @@ export type MemoryCache = { moduleUrl?: string; }; -type SerializedCompilationCache = { +export type SerializedCompilationCache = { sourceMaps: [string, string][], memoryCache: [string, MemoryCache][], fileDependencies: [string, string[]][], @@ -158,15 +158,19 @@ export function serializeCompilationCache(): SerializedCompilationCache { }; } -export function addToCompilationCache(payload: any) { +export function addToCompilationCache(payload: SerializedCompilationCache) { for (const entry of payload.sourceMaps) sourceMaps.set(entry[0], entry[1]); for (const entry of payload.memoryCache) memoryCache.set(entry[0], entry[1]); - for (const entry of payload.fileDependencies) - fileDependencies.set(entry[0], new Set(entry[1])); - for (const entry of payload.externalDependencies) - externalDependencies.set(entry[0], new Set(entry[1])); + for (const entry of payload.fileDependencies) { + const existing = fileDependencies.get(entry[0]) || []; + fileDependencies.set(entry[0], new Set([...entry[1], ...existing])); + } + for (const entry of payload.externalDependencies) { + const existing = externalDependencies.get(entry[0]) || []; + externalDependencies.set(entry[0], new Set([...entry[1], ...existing])); + } } function calculateCachePath(filePath: string, hash: string): string { @@ -249,9 +253,9 @@ const kPlaywrightCoveragePrefix = path.resolve(__dirname, '../../../../tests/con export function belongsToNodeModules(file: string) { if (file.includes(`${path.sep}node_modules${path.sep}`)) return true; - if (file.startsWith(kPlaywrightInternalPrefix) && file.endsWith('.js')) + if (file.startsWith(kPlaywrightInternalPrefix) && (file.endsWith('.js') || file.endsWith('.mjs'))) return true; - if (file.startsWith(kPlaywrightCoveragePrefix) && file.endsWith('.js')) + if (file.startsWith(kPlaywrightCoveragePrefix) && (file.endsWith('.js') || file.endsWith('.mjs'))) return true; return false; } diff --git a/packages/playwright/src/transform/transform.ts b/packages/playwright/src/transform/transform.ts index 82b5acdda1..2f6431c4f1 100644 --- a/packages/playwright/src/transform/transform.ts +++ b/packages/playwright/src/transform/transform.ts @@ -30,7 +30,7 @@ import { getFromCompilationCache, currentFileDepsCollector, belongsToNodeModules const version = require('../../package.json').version; type ParsedTsConfigData = { - absoluteBaseUrl: string; + pathsBase?: string; paths: { key: string, values: string[] }[]; allowJs: boolean; }; @@ -58,16 +58,15 @@ export function transformConfig(): TransformConfig { } function validateTsConfig(tsconfig: LoadedTsConfig): ParsedTsConfigData { - // Make 'baseUrl' absolute, because it is relative to the tsconfig.json, not to cwd. // When no explicit baseUrl is set, resolve paths relative to the tsconfig file. // See https://www.typescriptlang.org/tsconfig#paths - const absoluteBaseUrl = path.resolve(path.dirname(tsconfig.tsConfigPath), tsconfig.baseUrl ?? '.'); + const pathsBase = tsconfig.absoluteBaseUrl ?? tsconfig.paths?.pathsBasePath; // Only add the catch-all mapping when baseUrl is specified - const pathsFallback = tsconfig.baseUrl ? [{ key: '*', values: ['*'] }] : []; + const pathsFallback = tsconfig.absoluteBaseUrl ? [{ key: '*', values: ['*'] }] : []; return { allowJs: !!tsconfig.allowJs, - absoluteBaseUrl, - paths: Object.entries(tsconfig.paths || {}).map(([key, values]) => ({ key, values })).concat(pathsFallback) + pathsBase, + paths: Object.entries(tsconfig.paths?.mapping || {}).map(([key, values]) => ({ key, values })).concat(pathsFallback) }; } @@ -132,7 +131,7 @@ export function resolveHook(filename: string, specifier: string): string | undef let candidate = value; if (value.includes('*')) candidate = candidate.replace('*', matchedPartOfSpecifier); - candidate = path.resolve(tsconfig.absoluteBaseUrl, candidate); + candidate = path.resolve(tsconfig.pathsBase!, candidate); const existing = resolveImportSpecifierExtension(candidate); if (existing) { longestPrefixLength = keyPrefix.length; @@ -241,7 +240,7 @@ function installTransform(): () => void { if (!shouldTransform(filename)) return code; return transformHook(code, filename).code; - }, { exts: ['.ts', '.tsx', '.js', '.jsx', '.mjs'] }); + }, { exts: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts', '.cjs', '.cts'] }); return () => { reverted = true; diff --git a/packages/playwright/src/util.ts b/packages/playwright/src/util.ts index 34907428c9..d6cabd07c6 100644 --- a/packages/playwright/src/util.ts +++ b/packages/playwright/src/util.ts @@ -185,6 +185,8 @@ export function expectTypes(receiver: any, types: string[], matcherName: string) } } +export const windowsFilesystemFriendlyLength = 60; + export function trimLongString(s: string, length = 100) { if (s.length <= length) return s; @@ -195,12 +197,16 @@ export function trimLongString(s: string, length = 100) { return s.substring(0, start) + middle + s.slice(-end); } -export function addSuffixToFilePath(filePath: string, suffix: string, customExtension?: string, sanitize = false): string { - const dirname = path.dirname(filePath); +export function addSuffixToFilePath(filePath: string, suffix: string): string { const ext = path.extname(filePath); - const name = path.basename(filePath, ext); - const base = path.join(dirname, name); - return (sanitize ? sanitizeForFilePath(base) : base) + suffix + (customExtension || ext); + const base = filePath.substring(0, filePath.length - ext.length); + return base + suffix + ext; +} + +export function sanitizeFilePathBeforeExtension(filePath: string): string { + const ext = path.extname(filePath); + const base = filePath.substring(0, filePath.length - ext.length); + return sanitizeForFilePath(base) + ext; } /** @@ -208,7 +214,8 @@ export function addSuffixToFilePath(filePath: string, suffix: string, customExte */ export function getContainedPath(parentPath: string, subPath: string = ''): string | null { const resolvedPath = path.resolve(parentPath, subPath); - if (resolvedPath === parentPath || resolvedPath.startsWith(parentPath + path.sep)) return resolvedPath; + if (resolvedPath === parentPath || resolvedPath.startsWith(parentPath + path.sep)) + return resolvedPath; return null; } diff --git a/packages/playwright/src/worker/fixtureRunner.ts b/packages/playwright/src/worker/fixtureRunner.ts index 85c2dddab9..f425f1cd3e 100644 --- a/packages/playwright/src/worker/fixtureRunner.ts +++ b/packages/playwright/src/worker/fixtureRunner.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { formatLocation, debugTest, filterStackFile } from '../util'; +import { formatLocation, filterStackFile } from '../util'; import { ManualPromise } from 'playwright-core/lib/utils'; import type { TestInfoImpl } from './testInfo'; -import type { FixtureDescription } from './timeoutManager'; +import { TimeoutManagerError, type FixtureDescription, type RunnableDescription } from './timeoutManager'; import { fixtureParameterNames, type FixturePool, type FixtureRegistration, type FixtureScope } from '../common/fixtures'; import type { WorkerInfo } from '../../types/test'; import type { Location } from '../../types/testReporter'; @@ -30,11 +30,9 @@ class Fixture { private _useFuncFinished: ManualPromise | undefined; private _selfTeardownComplete: Promise | undefined; - private _teardownWithDepsComplete: Promise | undefined; private _setupDescription: FixtureDescription; private _teardownDescription: FixtureDescription; - private _shouldGenerateStep = false; - private _isInternalFixture = false; + private _stepInfo: { category: 'fixture', location?: Location } | undefined; _deps = new Set(); _usages = new Set(); @@ -42,22 +40,24 @@ class Fixture { this.runner = runner; this.registration = registration; this.value = null; + const shouldGenerateStep = !this.registration.hideStep && !this.registration.name.startsWith('_') && !this.registration.option; + const isInternalFixture = this.registration.location && filterStackFile(this.registration.location.file); const title = this.registration.customTitle || this.registration.name; + const location = isInternalFixture ? this.registration.location : undefined; + this._stepInfo = shouldGenerateStep ? { category: 'fixture', location } : undefined; this._setupDescription = { title, phase: 'setup', - location: registration.location, + location, slot: this.registration.timeout === undefined ? undefined : { timeout: this.registration.timeout, elapsed: 0, } }; this._teardownDescription = { ...this._setupDescription, phase: 'teardown' }; - this._shouldGenerateStep = !this.registration.hideStep && !this.registration.name.startsWith('_') && !this.registration.option; - this._isInternalFixture = this.registration.location && filterStackFile(this.registration.location.file); } - async setup(testInfo: TestInfoImpl) { + async setup(testInfo: TestInfoImpl, runnable: RunnableDescription) { this.runner.instanceForId.set(this.registration.id, this); if (typeof this.registration.fn !== 'function') { @@ -65,22 +65,13 @@ class Fixture { return; } - testInfo._timeoutManager.setCurrentFixture(this._setupDescription); - const beforeStep = this._shouldGenerateStep ? testInfo._addStep({ + await testInfo._runAsStage({ title: `fixture: ${this.registration.name}`, - category: 'fixture', - location: this._isInternalFixture ? this.registration.location : undefined, - wallTime: Date.now(), - }) : undefined; - try { + runnable: { ...runnable, fixture: this._setupDescription }, + stepInfo: this._stepInfo, + }, async () => { await this._setupInternal(testInfo); - beforeStep?.complete({}); - } catch (error) { - beforeStep?.complete({ error }); - throw error; - } finally { - testInfo._timeoutManager.setCurrentFixture(undefined); - } + }); } private async _setupInternal(testInfo: TestInfoImpl) { @@ -107,7 +98,6 @@ class Fixture { let called = false; const useFuncStarted = new ManualPromise(); - debugTest(`setup ${this.registration.name}`); const useFunc = async (value: any) => { if (called) throw new Error(`Cannot provide fixture value for the second time`); @@ -134,25 +124,14 @@ class Fixture { await useFuncStarted; } - async teardown(testInfo: TestInfoImpl) { - const afterStep = this._shouldGenerateStep ? testInfo?._addStep({ - wallTime: Date.now(), + async teardown(testInfo: TestInfoImpl, runnable: RunnableDescription) { + await testInfo._runAsStage({ title: `fixture: ${this.registration.name}`, - category: 'fixture', - location: this._isInternalFixture ? this.registration.location : undefined, - }) : undefined; - testInfo._timeoutManager.setCurrentFixture(this._teardownDescription); - try { - if (!this._teardownWithDepsComplete) - this._teardownWithDepsComplete = this._teardownInternal(); - await this._teardownWithDepsComplete; - afterStep?.complete({}); - } catch (error) { - afterStep?.complete({ error }); - throw error; - } finally { - testInfo._timeoutManager.setCurrentFixture(undefined); - } + runnable: { ...runnable, fixture: this._teardownDescription }, + stepInfo: this._stepInfo, + }, async () => { + await this._teardownInternal(); + }); } private async _teardownInternal() { @@ -165,17 +144,21 @@ class Fixture { this._usages.clear(); } if (this._useFuncFinished) { - debugTest(`teardown ${this.registration.name}`); this._useFuncFinished.resolve(); + this._useFuncFinished = undefined; await this._selfTeardownComplete; } } finally { - for (const dep of this._deps) - dep._usages.delete(this); - this.runner.instanceForId.delete(this.registration.id); + this._cleanupInstance(); } } + _cleanupInstance() { + for (const dep of this._deps) + dep._usages.delete(this); + this.runner.instanceForId.delete(this.registration.id); + } + _collectFixturesInTeardownOrder(scope: FixtureScope, collector: Set) { if (this.registration.scope !== scope) return; @@ -214,19 +197,36 @@ export class FixtureRunner { collector.add(registration); } - async teardownScope(scope: FixtureScope, testInfo: TestInfoImpl, onFixtureError: (error: Error) => void) { + async teardownScope(scope: FixtureScope, testInfo: TestInfoImpl, runnable: RunnableDescription) { // Teardown fixtures in the reverse order. const fixtures = Array.from(this.instanceForId.values()).reverse(); const collector = new Set(); for (const fixture of fixtures) fixture._collectFixturesInTeardownOrder(scope, collector); - for (const fixture of collector) - await fixture.teardown(testInfo).catch(onFixtureError); - if (scope === 'test') - this.testScopeClean = true; + try { + let firstError: Error | undefined; + for (const fixture of collector) { + try { + await fixture.teardown(testInfo, runnable); + } catch (error) { + if (error instanceof TimeoutManagerError) + throw error; + firstError = firstError ?? error; + } + } + if (firstError) + throw firstError; + } finally { + // To preserve fixtures integrity, forcefully cleanup fixtures that did not teardown + // due to a timeout in one of them. + for (const fixture of collector) + fixture._cleanupInstance(); + if (scope === 'test') + this.testScopeClean = true; + } } - async resolveParametersForFunction(fn: Function, testInfo: TestInfoImpl, autoFixtures: 'worker' | 'test' | 'all-hooks-only'): Promise { + async resolveParametersForFunction(fn: Function, testInfo: TestInfoImpl, autoFixtures: 'worker' | 'test' | 'all-hooks-only', runnable: RunnableDescription): Promise { const collector = new Set(); // Collect automatic fixtures. @@ -250,17 +250,14 @@ export class FixtureRunner { this._collectFixturesInSetupOrder(this.pool!.resolve(name)!, collector); // Setup fixtures. - for (const registration of collector) { - const fixture = await this._setupFixtureForRegistration(registration, testInfo); - if (fixture.failed) - return null; - } + for (const registration of collector) + await this._setupFixtureForRegistration(registration, testInfo, runnable); // Create params object. const params: { [key: string]: any } = {}; for (const name of names) { const registration = this.pool!.resolve(name)!; - const fixture = this.instanceForId.get(registration.id)!; + const fixture = this.instanceForId.get(registration.id); if (!fixture || fixture.failed) return null; params[name] = fixture.value; @@ -268,16 +265,18 @@ export class FixtureRunner { return params; } - async resolveParametersAndRunFunction(fn: Function, testInfo: TestInfoImpl, autoFixtures: 'worker' | 'test' | 'all-hooks-only') { - const params = await this.resolveParametersForFunction(fn, testInfo, autoFixtures); + async resolveParametersAndRunFunction(fn: Function, testInfo: TestInfoImpl, autoFixtures: 'worker' | 'test' | 'all-hooks-only', runnable: RunnableDescription) { + const params = await this.resolveParametersForFunction(fn, testInfo, autoFixtures, runnable); if (params === null) { // Do not run the function when fixture setup has already failed. return null; } - return fn(params, testInfo); + await testInfo._runAsStage({ title: 'run function', runnable }, async () => { + await fn(params, testInfo); + }); } - private async _setupFixtureForRegistration(registration: FixtureRegistration, testInfo: TestInfoImpl): Promise { + private async _setupFixtureForRegistration(registration: FixtureRegistration, testInfo: TestInfoImpl, runnable: RunnableDescription): Promise { if (registration.scope === 'test') this.testScopeClean = false; @@ -286,7 +285,7 @@ export class FixtureRunner { return fixture; fixture = new Fixture(this, registration); - await fixture.setup(testInfo); + await fixture.setup(testInfo, runnable); return fixture; } diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index e79bd61529..3e0dab8b95 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -16,15 +16,15 @@ import fs from 'fs'; import path from 'path'; -import { MaxTime, captureRawStack, monotonicTime, zones, sanitizeForFilePath, stringifyStackFrames } from 'playwright-core/lib/utils'; +import { captureRawStack, monotonicTime, zones, sanitizeForFilePath, stringifyStackFrames } from 'playwright-core/lib/utils'; import type { TestInfoError, TestInfo, TestStatus, FullProject, FullConfig } from '../../types/test'; import type { AttachmentPayload, StepBeginPayload, StepEndPayload, WorkerInitParams } from '../common/ipc'; import type { TestCase } from '../common/test'; -import { TimeoutManager } from './timeoutManager'; -import type { RunnableType, TimeSlot } from './timeoutManager'; +import { TimeoutManager, TimeoutManagerError, kMaxDeadline } from './timeoutManager'; +import type { RunnableDescription } from './timeoutManager'; import type { Annotation, FullConfigInternal, FullProjectInternal } from '../common/config'; import type { Location } from '../../types/testReporter'; -import { filteredStackTrace, getContainedPath, normalizeAndSaveAttachment, serializeError, trimLongString } from '../util'; +import { debugTest, filteredStackTrace, formatLocation, getContainedPath, normalizeAndSaveAttachment, serializeError, trimLongString, windowsFilesystemFriendlyLength } from '../util'; import { TestTracing } from './testTracing'; import type { Attachment } from './testTracing'; import type { StackFrame } from '@protocol/channels'; @@ -44,10 +44,16 @@ export interface TestStepInternal { error?: TestInfoError; infectParentStepsWithError?: boolean; box?: boolean; - isSoft?: boolean; - forceNoParent?: boolean; + isStage?: boolean; } +export type TestStage = { + title: string; + stepInfo?: { category: 'hook' | 'fixture', location?: Location }; + runnable?: RunnableDescription; + step?: TestStepInternal; +}; + export class TestInfoImpl implements TestInfo { private _onStepBegin: (payload: StepBeginPayload) => void; private _onStepEnd: (payload: StepEndPayload) => void; @@ -55,19 +61,19 @@ export class TestInfoImpl implements TestInfo { readonly _timeoutManager: TimeoutManager; readonly _startTime: number; readonly _startWallTime: number; - private _hasHardError: boolean = false; readonly _tracing: TestTracing; - _didTimeout = false; _wasInterrupted = false; _lastStepId = 0; private readonly _requireFile: string; readonly _projectInternal: FullProjectInternal; readonly _configInternal: FullConfigInternal; - readonly _steps: TestStepInternal[] = []; + private readonly _steps: TestStepInternal[] = []; _onDidFinishTestFunction: (() => Promise) | undefined; - + private readonly _stages: TestStage[] = []; _hasNonRetriableError = false; + _hasUnhandledError = false; + _allowSkips = false; // ------------ TestInfo fields ------------ readonly testId: string; @@ -81,6 +87,7 @@ export class TestInfoImpl implements TestInfo { readonly titlePath: string[]; readonly file: string; readonly line: number; + readonly tags: string[]; readonly column: number; readonly fn: Function; expectedStatus: TestStatus; @@ -105,7 +112,7 @@ export class TestInfoImpl implements TestInfo { } get timeout(): number { - return this._timeoutManager.defaultSlotTimings().timeout; + return this._timeoutManager.defaultSlot().timeout; } set timeout(timeout: number) { @@ -114,7 +121,7 @@ export class TestInfoImpl implements TestInfo { _deadlineForMatcher(timeout: number): { deadline: number, timeoutMessage: string } { const startTime = monotonicTime(); - const matcherDeadline = timeout ? startTime + timeout : MaxTime; + const matcherDeadline = timeout ? startTime + timeout : kMaxDeadline; const testDeadline = this._timeoutManager.currentSlotDeadline() - 250; const matcherMessage = `Timeout ${timeout}ms exceeded while waiting on the predicate`; const testMessage = `Test timeout of ${this.timeout}ms exceeded`; @@ -156,17 +163,18 @@ export class TestInfoImpl implements TestInfo { this.file = test?.location.file ?? ''; this.line = test?.location.line ?? 0; this.column = test?.location.column ?? 0; + this.tags = test?.tags ?? []; this.fn = test?.fn ?? (() => {}); this.expectedStatus = test?.expectedStatus ?? 'skipped'; this._timeoutManager = new TimeoutManager(this.project.timeout); this.outputDir = (() => { - const relativeTestFilePath = path.relative(this.project.testDir, this._requireFile.replace(/\.(spec|test)\.(js|ts|mjs)$/, '')); + const relativeTestFilePath = path.relative(this.project.testDir, this._requireFile.replace(/\.(spec|test)\.(js|ts|jsx|tsx|mjs|mts|cjs|cts)$/, '')); const sanitizedRelativePath = relativeTestFilePath.replace(process.platform === 'win32' ? new RegExp('\\\\', 'g') : new RegExp('/', 'g'), '-'); const fullTitleWithoutSpec = this.titlePath.slice(1).join(' '); - let testOutputDir = trimLongString(sanitizedRelativePath + '-' + sanitizeForFilePath(fullTitleWithoutSpec)); + let testOutputDir = trimLongString(sanitizedRelativePath + '-' + sanitizeForFilePath(fullTitleWithoutSpec), windowsFilesystemFriendlyLength); if (projectInternal.id) testOutputDir += '-' + sanitizeForFilePath(projectInternal.id); if (this.retry) @@ -218,36 +226,6 @@ export class TestInfoImpl implements TestInfo { } } - async _runWithTimeout(cb: () => Promise): Promise { - const timeoutError = await this._timeoutManager.runWithTimeout(cb); - // When interrupting, we arrive here with a timeoutError, but we should not - // consider it a timeout. - if (!this._wasInterrupted && timeoutError && !this._didTimeout) { - this._didTimeout = true; - const serialized = serializeError(timeoutError); - this.errors.push(serialized); - this._tracing.appendForError(serialized); - // Do not overwrite existing failure upon hook/teardown timeout. - if (this.status === 'passed' || this.status === 'skipped') - this.status = 'timedOut'; - } - this.duration = this._timeoutManager.defaultSlotTimings().elapsed | 0; - } - - async _runAndFailOnError(fn: () => Promise, skips?: 'allowSkips'): Promise { - try { - await fn(); - } catch (error) { - if (skips === 'allowSkips' && error instanceof SkipError) { - if (this.status === 'passed') - this.status = 'skipped'; - } else { - this._failWithError(error, true /* isHardError */, true /* retriable */); - return error; - } - } - } - private _findLastNonFinishedStep(filter: (step: TestStepInternal) => boolean) { let result: TestStepInternal | undefined; const visit = (step: TestStepInternal) => { @@ -259,33 +237,32 @@ export class TestInfoImpl implements TestInfo { return result; } + private _findLastStageStep() { + for (let i = this._stages.length - 1; i >= 0; i--) { + if (this._stages[i].step) + return this._stages[i].step; + } + } + _addStep(data: Omit): TestStepInternal { const stepId = `${data.category}@${++this._lastStepId}`; const rawStack = captureRawStack(); let parentStep: TestStepInternal | undefined; - if (data.category === 'hook' || data.category === 'fixture') { - // Predefined steps form a fixed hierarchy - find the last non-finished one. - parentStep = this._findLastNonFinishedStep(step => step.category === 'fixture' || step.category === 'hook'); + if (data.isStage) { + // Predefined stages form a fixed hierarchy - use the current one as parent. + parentStep = this._findLastStageStep(); } else { parentStep = zones.zoneData('stepZone', rawStack!) || undefined; - if (parentStep?.category === 'hook' || parentStep?.category === 'fixture') { - // Prefer last non-finished predefined step over the on-stack one, because - // some predefined steps may be missing on the stack. - parentStep = this._findLastNonFinishedStep(step => step.category === 'fixture' || step.category === 'hook'); - } else if (!parentStep) { - if (data.category === 'test.step') { - // Nest test.step without a good stack in the last non-finished predefined step like a hook. - parentStep = this._findLastNonFinishedStep(step => step.category === 'fixture' || step.category === 'hook'); - } else { - // Do not nest chains of route.continue. - parentStep = this._findLastNonFinishedStep(step => step.title !== data.title); - } + if (!parentStep && data.category !== 'test.step') { + // API steps (but not test.step calls) can be nested by time, instead of by stack. + // However, do not nest chains of route.continue by checking the title. + parentStep = this._findLastNonFinishedStep(step => step.title !== data.title); + } + if (!parentStep) { + // If no parent step on stack, assume the current stage as parent. + parentStep = this._findLastStageStep(); } - } - if (data.forceNoParent) { - // This is used to reset step hierarchy after test timeout. - parentStep = undefined; } const filteredStack = filteredStackTrace(rawStack); @@ -336,9 +313,6 @@ export class TestInfoImpl implements TestInfo { this._onStepEnd(payload); const errorForTrace = step.error ? { name: '', message: step.error.message || '', stack: step.error.stack } : undefined; this._tracing.appendAfterActionForStep(stepId, errorForTrace, result.attachments); - - if (step.isSoft && result.error) - this._failWithError(result.error, false /* isHardError */, true /* retriable */); } }; const parentStepList = parentStep ? parentStep.steps : this._steps; @@ -366,19 +340,9 @@ export class TestInfoImpl implements TestInfo { this.status = 'interrupted'; } - _failWithError(error: Error, isHardError: boolean, retriable: boolean) { - if (!retriable) - this._hasNonRetriableError = true; - // Do not overwrite any previous hard errors. - // Some (but not all) scenarios include: - // - expect() that fails after uncaught exception. - // - fail after the timeout, e.g. due to fixture teardown. - if (isHardError && this._hasHardError) - return; - if (isHardError) - this._hasHardError = true; + _failWithError(error: Error) { if (this.status === 'passed' || this.status === 'skipped') - this.status = 'failed'; + this.status = error instanceof TimeoutManagerError ? 'timedOut' : 'failed'; const serialized = serializeError(error); const step = (error as any)[stepSymbol] as TestStepInternal | undefined; if (step && step.boxedStack) @@ -387,39 +351,66 @@ export class TestInfoImpl implements TestInfo { this._tracing.appendForError(serialized); } - async _runAsStepWithRunnable( - stepInfo: Omit & { - wallTime?: number, - runnableType: RunnableType; - runnableSlot?: TimeSlot; - }, cb: (step: TestStepInternal) => Promise): Promise { - return await this._timeoutManager.withRunnable({ - type: stepInfo.runnableType, - slot: stepInfo.runnableSlot, - location: stepInfo.location, - }, async () => { - return await this._runAsStep(stepInfo, cb); - }); - } + async _runAsStage(stage: TestStage, cb: () => Promise) { + if (debugTest.enabled) { + const location = stage.runnable?.location ? ` at "${formatLocation(stage.runnable.location)}"` : ``; + debugTest(`started stage "${stage.title}"${location}`); + } + stage.step = stage.stepInfo ? this._addStep({ ...stage.stepInfo, title: stage.title, wallTime: Date.now(), isStage: true }) : undefined; + this._stages.push(stage); - async _runAsStep(stepInfo: Omit & { wallTime?: number }, cb: (step: TestStepInternal) => Promise): Promise { - const step = this._addStep({ wallTime: Date.now(), ...stepInfo }); - return await zones.run('stepZone', step, async () => { - try { - const result = await cb(step); - step.complete({}); - return result; - } catch (e) { - step.complete({ error: e instanceof SkipError ? undefined : e }); - throw e; - } - }); + try { + await this._timeoutManager.withRunnable(stage.runnable, async () => { + try { + await cb(); + } catch (e) { + // Only handle errors directly thrown by the user code. + if (!stage.runnable) + throw e; + if (this._allowSkips && (e instanceof SkipError)) { + if (this.status === 'passed') + this.status = 'skipped'; + } else { + // Unfortunately, we have to handle user errors and timeout errors differently. + // Consider the following scenario: + // - locator.click times out + // - all stages containing the test function finish with TimeoutManagerError + // - test finishes, the page is closed and this triggers locator.click error + // - we would like to present the locator.click error to the user + // - therefore, we need a try/catch inside the "run with timeout" block and capture the error + this._failWithError(e); + } + throw e; + } + }); + stage.step?.complete({}); + } catch (error) { + // When interrupting, we arrive here with a TimeoutManagerError, but we should not + // consider it a timeout. + if (!this._wasInterrupted && (error instanceof TimeoutManagerError) && stage.runnable) + this._failWithError(error); + stage.step?.complete({ error }); + throw error; + } finally { + if (this._stages[this._stages.length - 1] !== stage) + throw new Error(`Internal error: inconsistent stages!`); + this._stages.pop(); + debugTest(`finished stage "${stage.title}"`); + } } _isFailure() { return this.status !== 'skipped' && this.status !== this.expectedStatus; } + _currentHookType() { + for (let i = this._stages.length - 1; i >= 0; i--) { + const type = this._stages[i].runnable?.type; + if (type && ['beforeAll', 'afterAll', 'beforeEach', 'afterEach'].includes(type)) + return type; + } + } + // ------------ TestInfo methods ------------ async attach(name: string, options: { path?: string, body?: string | Buffer, contentType?: string } = {}) { @@ -506,7 +497,7 @@ export class TestInfoImpl implements TestInfo { } } -class SkipError extends Error { +export class SkipError extends Error { } const stepSymbol = Symbol('step'); diff --git a/packages/playwright/src/worker/testTracing.ts b/packages/playwright/src/worker/testTracing.ts index 579bf8bc87..82c84000e7 100644 --- a/packages/playwright/src/worker/testTracing.ts +++ b/packages/playwright/src/worker/testTracing.ts @@ -48,8 +48,31 @@ export class TestTracing { this._tracesDir = path.join(this._artifactsDir, 'traces'); } + private _shouldCaptureTrace() { + if (process.env.PW_TEST_DISABLE_TRACING) + return false; + + if (this._options?.mode === 'on') + return true; + + if (this._options?.mode === 'retain-on-failure') + return true; + + if (this._options?.mode === 'on-first-retry' && this._testInfo.retry === 1) + return true; + + if (this._options?.mode === 'on-all-retries' && this._testInfo.retry > 0) + return true; + + if (this._options?.mode === 'retain-on-first-failure' && this._testInfo.retry === 0) + return true; + + return false; + } + async startIfNeeded(value: TraceFixtureValue) { const defaultTraceOptions: TraceOptions = { screenshots: true, snapshots: true, sources: true, attachments: true, _live: false, mode: 'off' }; + if (!value) { this._options = defaultTraceOptions; } else if (typeof value === 'string') { @@ -59,9 +82,7 @@ export class TestTracing { this._options = { ...defaultTraceOptions, ...value, mode: (mode as string) === 'retry-with-trace' ? 'on-first-retry' : mode }; } - let shouldCaptureTrace = this._options.mode === 'on' || this._options.mode === 'retain-on-failure' || (this._options.mode === 'on-first-retry' && this._testInfo.retry === 1) || (this._options.mode === 'on-all-retries' && this._testInfo.retry > 0); - shouldCaptureTrace = shouldCaptureTrace && !process.env.PW_TEST_DISABLE_TRACING; - if (!shouldCaptureTrace) { + if (!this._shouldCaptureTrace()) { this._options = undefined; return; } @@ -110,7 +131,8 @@ export class TestTracing { return; const testFailed = this._testInfo.status !== this._testInfo.expectedStatus; - const shouldAbandonTrace = !testFailed && this._options.mode === 'retain-on-failure'; + const shouldAbandonTrace = !testFailed && (this._options.mode === 'retain-on-failure' || this._options.mode === 'retain-on-first-failure'); + if (shouldAbandonTrace) { for (const file of this._temporaryTraceFiles) await fs.promises.unlink(file).catch(() => {}); diff --git a/packages/playwright/src/worker/timeoutManager.ts b/packages/playwright/src/worker/timeoutManager.ts index 94e182f682..6287bc232c 100644 --- a/packages/playwright/src/worker/timeoutManager.ts +++ b/packages/playwright/src/worker/timeoutManager.ts @@ -15,7 +15,7 @@ */ import { colors } from 'playwright-core/lib/utilsBundle'; -import { TimeoutRunner, TimeoutRunnerError } from 'playwright-core/lib/utils'; +import { ManualPromise, monotonicTime } from 'playwright-core/lib/utils'; import type { Location } from '../../types/testReporter'; export type TimeSlot = { @@ -23,121 +23,125 @@ export type TimeSlot = { elapsed: number; }; -export type RunnableType = 'test' | 'beforeAll' | 'afterAll' | 'beforeEach' | 'afterEach' | 'afterHooks' | 'slow' | 'skip' | 'fail' | 'fixme' | 'teardown'; +type RunnableType = 'test' | 'beforeAll' | 'afterAll' | 'beforeEach' | 'afterEach' | 'slow' | 'skip' | 'fail' | 'fixme' | 'teardown'; export type RunnableDescription = { type: RunnableType; location?: Location; slot?: TimeSlot; // Falls back to test slot. + fixture?: FixtureDescription; }; export type FixtureDescription = { title: string; phase: 'setup' | 'teardown'; location?: Location; - slot?: TimeSlot; // Falls back to current runnable slot. + slot?: TimeSlot; // Falls back to the runnable slot. }; +type Running = { + runnable: RunnableDescription; + slot: TimeSlot; + start: number; + deadline: number; + timer: NodeJS.Timeout | undefined; + timeoutPromise: ManualPromise; +}; +export const kMaxDeadline = 2147483647; // 2^31-1 + export class TimeoutManager { private _defaultSlot: TimeSlot; - private _defaultRunnable: RunnableDescription; - private _runnable: RunnableDescription; - private _fixture: FixtureDescription | undefined; - private _timeoutRunner: TimeoutRunner; + private _running?: Running; constructor(timeout: number) { this._defaultSlot = { timeout, elapsed: 0 }; - this._defaultRunnable = { type: 'test', slot: this._defaultSlot }; - this._runnable = this._defaultRunnable; - this._timeoutRunner = new TimeoutRunner(timeout); } interrupt() { - this._timeoutRunner.interrupt(); + if (this._running) + this._running.timeoutPromise.reject(this._createTimeoutError(this._running)); } - async withRunnable(runnable: RunnableDescription, cb: () => Promise): Promise { - const existingRunnable = this._runnable; - const effectiveRunnable = { ...runnable }; - if (!effectiveRunnable.slot) - effectiveRunnable.slot = this._runnable.slot; - this._updateRunnables(effectiveRunnable, undefined); - try { + async withRunnable(runnable: RunnableDescription | undefined, cb: () => Promise): Promise { + if (!runnable) return await cb(); + if (this._running) + throw new Error(`Internal error: duplicate runnable`); + const running = this._running = { + runnable, + slot: runnable.fixture?.slot || runnable.slot || this._defaultSlot, + start: monotonicTime(), + deadline: kMaxDeadline, + timer: undefined, + timeoutPromise: new ManualPromise(), + }; + try { + this._updateTimeout(running); + return await Promise.race([ + cb(), + running.timeoutPromise, + ]); } finally { - this._updateRunnables(existingRunnable, undefined); + if (running.timer) + clearTimeout(running.timer); + running.timer = undefined; + running.slot.elapsed += monotonicTime() - running.start; + this._running = undefined; } } - setCurrentFixture(fixture: FixtureDescription | undefined) { - this._updateRunnables(this._runnable, fixture); + private _updateTimeout(running: Running) { + if (running.timer) + clearTimeout(running.timer); + running.timer = undefined; + if (!running.slot.timeout) { + running.deadline = kMaxDeadline; + return; + } + running.deadline = running.start + (running.slot.timeout - running.slot.elapsed); + const timeout = running.deadline - monotonicTime(); + if (timeout <= 0) + running.timeoutPromise.reject(this._createTimeoutError(running)); + else + running.timer = setTimeout(() => running.timeoutPromise.reject(this._createTimeoutError(running)), timeout); } - defaultSlotTimings() { - const slot = this._currentSlot(); - slot.elapsed = this._timeoutRunner.elapsed(); + defaultSlot() { return this._defaultSlot; } slow() { - const slot = this._currentSlot(); + const slot = this._running ? this._running.slot : this._defaultSlot; slot.timeout = slot.timeout * 3; - this._timeoutRunner.updateTimeout(slot.timeout); - } - - async runWithTimeout(cb: () => Promise): Promise { - try { - await this._timeoutRunner.run(cb); - } catch (error) { - if (!(error instanceof TimeoutRunnerError)) - throw error; - return this._createTimeoutError(); - } + if (this._running) + this._updateTimeout(this._running); } setTimeout(timeout: number) { - const slot = this._currentSlot(); + const slot = this._running ? this._running.slot : this._defaultSlot; if (!slot.timeout) return; // Zero timeout means some debug mode - do not set a timeout. slot.timeout = timeout; - this._timeoutRunner.updateTimeout(timeout); - } - - currentRunnableType() { - return this._runnable.type; + if (this._running) + this._updateTimeout(this._running); } currentSlotDeadline() { - return this._timeoutRunner.deadline(); + return this._running ? this._running.deadline : kMaxDeadline; } - private _currentSlot() { - return this._fixture?.slot || this._runnable.slot || this._defaultSlot; - } - - private _updateRunnables(runnable: RunnableDescription, fixture: FixtureDescription | undefined) { - let slot = this._currentSlot(); - slot.elapsed = this._timeoutRunner.elapsed(); - - this._runnable = runnable; - this._fixture = fixture; - - slot = this._currentSlot(); - this._timeoutRunner.updateTimeout(slot.timeout, slot.elapsed); - } - - private _createTimeoutError(): Error { + private _createTimeoutError(running: Running): Error { let message = ''; - const timeout = this._currentSlot().timeout; - switch (this._runnable.type) { - case 'afterHooks': + const timeout = running.slot.timeout; + const runnable = running.runnable; + switch (runnable.type) { case 'test': { - if (this._fixture) { - if (this._fixture.phase === 'setup') { - message = `Test timeout of ${timeout}ms exceeded while setting up "${this._fixture.title}".`; + if (runnable.fixture) { + if (runnable.fixture.phase === 'setup') { + message = `Test timeout of ${timeout}ms exceeded while setting up "${runnable.fixture.title}".`; } else { message = [ - `Test finished within timeout of ${timeout}ms, but tearing down "${this._fixture.title}" ran out of time.`, + `Test finished within timeout of ${timeout}ms, but tearing down "${runnable.fixture.title}" ran out of time.`, `Please allow more time for the test, since teardown is attributed towards the test timeout budget.`, ].join('\n'); } @@ -148,15 +152,15 @@ export class TimeoutManager { } case 'afterEach': case 'beforeEach': - message = `Test timeout of ${timeout}ms exceeded while running "${this._runnable.type}" hook.`; + message = `Test timeout of ${timeout}ms exceeded while running "${runnable.type}" hook.`; break; case 'beforeAll': case 'afterAll': - message = `"${this._runnable.type}" hook timeout of ${timeout}ms exceeded.`; + message = `"${runnable.type}" hook timeout of ${timeout}ms exceeded.`; break; case 'teardown': { - if (this._fixture) - message = `Worker teardown timeout of ${timeout}ms exceeded while ${this._fixture.phase === 'setup' ? 'setting up' : 'tearing down'} "${this._fixture.title}".`; + if (runnable.fixture) + message = `Worker teardown timeout of ${timeout}ms exceeded while ${runnable.fixture.phase === 'setup' ? 'setting up' : 'tearing down'} "${runnable.fixture.title}".`; else message = `Worker teardown timeout of ${timeout}ms exceeded.`; break; @@ -165,18 +169,20 @@ export class TimeoutManager { case 'slow': case 'fixme': case 'fail': - message = `"${this._runnable.type}" modifier timeout of ${timeout}ms exceeded.`; + message = `"${runnable.type}" modifier timeout of ${timeout}ms exceeded.`; break; } - const fixtureWithSlot = this._fixture?.slot ? this._fixture : undefined; + const fixtureWithSlot = runnable.fixture?.slot ? runnable.fixture : undefined; if (fixtureWithSlot) message = `Fixture "${fixtureWithSlot.title}" timeout of ${timeout}ms exceeded during ${fixtureWithSlot.phase}.`; message = colors.red(message); - const location = (fixtureWithSlot || this._runnable).location; - const error = new Error(message); + const location = (fixtureWithSlot || runnable).location; + const error = new TimeoutManagerError(message); error.name = ''; // Include location for hooks, modifiers and fixtures to distinguish between them. error.stack = message + (location ? `\n at ${location.file}:${location.line}:${location.column}` : ''); return error; } } + +export class TimeoutManagerError extends Error {} diff --git a/packages/playwright/src/worker/workerMain.ts b/packages/playwright/src/worker/workerMain.ts index d678e485aa..20c7feca6a 100644 --- a/packages/playwright/src/worker/workerMain.ts +++ b/packages/playwright/src/worker/workerMain.ts @@ -15,7 +15,7 @@ */ import { colors } from 'playwright-core/lib/utilsBundle'; -import { debugTest, formatLocation, relativeFilePath, serializeError } from '../util'; +import { debugTest, relativeFilePath, serializeError } from '../util'; import { type TestBeginPayload, type TestEndPayload, type RunPayload, type DonePayload, type WorkerInitParams, type TeardownErrorsPayload, stdioChunkToParams } from '../common/ipc'; import { setCurrentTestInfo, setIsWorkerProcess } from '../common/globals'; import { deserializeConfig } from '../common/configLoader'; @@ -23,15 +23,15 @@ import type { Suite, TestCase } from '../common/test'; import type { Annotation, FullConfigInternal, FullProjectInternal } from '../common/config'; import { FixtureRunner } from './fixtureRunner'; import { ManualPromise, gracefullyCloseAll, removeFolders } from 'playwright-core/lib/utils'; -import { TestInfoImpl } from './testInfo'; +import { SkipError, TestInfoImpl } from './testInfo'; import { ProcessRunner } from '../common/process'; import { loadTestFile } from '../common/testLoader'; import { applyRepeatEachIndex, bindFileSuiteToProject, filterTestsRemoveEmptySuites } from '../common/suiteUtils'; import { PoolBuilder } from '../common/poolBuilder'; import type { TestInfoError } from '../../types/test'; import type { Location } from '../../types/testReporter'; -import type { FixtureScope } from '../common/fixtures'; import { inheritFixutreNames } from '../common/fixtures'; +import { type TimeSlot, TimeoutManagerError } from './timeoutManager'; export class WorkerMain extends ProcessRunner { private _params: WorkerInitParams; @@ -144,31 +144,12 @@ export class WorkerMain extends ProcessRunner { } } - private async _teardownScope(scope: FixtureScope, testInfo: TestInfoImpl) { - const error = await this._teardownScopeAndReturnFirstError(scope, testInfo); - if (error) - throw error; - } - - private async _teardownScopeAndReturnFirstError(scope: FixtureScope, testInfo: TestInfoImpl): Promise { - let error: Error | undefined; - await this._fixtureRunner.teardownScope(scope, testInfo, e => { - testInfo._failWithError(e, true, false); - if (error === undefined) - error = e; - }); - return error; - } - private async _teardownScopes() { - // TODO: separate timeout for teardown? const fakeTestInfo = new TestInfoImpl(this._config, this._project, this._params, undefined, 0, () => {}, () => {}, () => {}); - await fakeTestInfo._timeoutManager.withRunnable({ type: 'teardown' }, async () => { - await fakeTestInfo._runWithTimeout(async () => { - await this._teardownScopeAndReturnFirstError('test', fakeTestInfo); - await this._teardownScopeAndReturnFirstError('worker', fakeTestInfo); - }); - }); + const runnable = { type: 'teardown' } as const; + // Ignore top-level errors, they are already inside TestInfo.errors. + await this._fixtureRunner.teardownScope('test', fakeTestInfo, runnable).catch(() => {}); + await this._fixtureRunner.teardownScope('worker', fakeTestInfo, runnable).catch(() => {}); this._fatalErrors.push(...fakeTestInfo.errors); } @@ -185,7 +166,10 @@ export class WorkerMain extends ProcessRunner { // and unhandled errors - both lead to the test failing. This is good for regular tests, // so that you can, e.g. expect() from inside an event handler. The test fails, // and we restart the worker. - this._currentTest._failWithError(error, true /* isHardError */, true /* retriable */); + if (!this._currentTest._hasUnhandledError) { + this._currentTest._hasUnhandledError = true; + this._currentTest._failWithError(error); + } // For tests marked with test.fail(), this might be a problem when unhandled error // is not coming from the user test code (legit failure), but from fixtures or test runner. @@ -323,11 +307,11 @@ export class WorkerMain extends ProcessRunner { this._lastRunningTests.push(test); if (this._lastRunningTests.length > 10) this._lastRunningTests.shift(); - let didFailBeforeAllForSuite: Suite | undefined; let shouldRunAfterEachHooks = false; - await testInfo._runWithTimeout(async () => { - const traceError = await testInfo._runAndFailOnError(async () => { + testInfo._allowSkips = true; + await testInfo._runAsStage({ title: 'setup and test' }, async () => { + await testInfo._runAsStage({ title: 'start tracing', runnable: { type: 'test' } }, async () => { // Ideally, "trace" would be an config-level option belonging to the // test runner instead of a fixture belonging to Playwright. // However, for backwards compatibility, we have to read it from a fixture today. @@ -339,8 +323,6 @@ export class WorkerMain extends ProcessRunner { throw new Error(`"trace" option cannot be a function`); await testInfo._tracing.startIfNeeded(traceFixtureRegistration.fn); }); - if (traceError) - return; if (this._isStopped || isSkipped) { // Two reasons to get here: @@ -348,45 +330,23 @@ export class WorkerMain extends ProcessRunner { // - Worker is requested to stop, but was not able to run full cleanup yet. // We should skip the test, but run the cleanup. testInfo.status = 'skipped'; - didFailBeforeAllForSuite = undefined; return; } await removeFolders([testInfo.outputDir]); let testFunctionParams: object | null = null; - await testInfo._runAsStep({ category: 'hook', title: 'Before Hooks' }, async step => { + await testInfo._runAsStage({ title: 'Before Hooks', stepInfo: { category: 'hook' } }, async () => { // Run "beforeAll" hooks, unless already run during previous tests. - for (const suite of suites) { - didFailBeforeAllForSuite = suite; // Assume failure, unless reset below. - const beforeAllError = await this._runBeforeAllHooksForSuite(suite, testInfo); - if (beforeAllError) { - step.complete({ error: beforeAllError }); - return; - } - didFailBeforeAllForSuite = undefined; - if (testInfo.expectedStatus === 'skipped') - return; - } + for (const suite of suites) + await this._runBeforeAllHooksForSuite(suite, testInfo); - const beforeEachError = await testInfo._runAndFailOnError(async () => { - // Run "beforeEach" hooks. Once started with "beforeEach", we must run all "afterEach" hooks as well. - shouldRunAfterEachHooks = true; - await this._runEachHooksForSuites(suites, 'beforeEach', testInfo); - }, 'allowSkips'); - if (beforeEachError) { - step.complete({ error: beforeEachError }); - return; - } - if (testInfo.expectedStatus === 'skipped') - return; + // Run "beforeEach" hooks. Once started with "beforeEach", we must run all "afterEach" hooks as well. + shouldRunAfterEachHooks = true; + await this._runEachHooksForSuites(suites, 'beforeEach', testInfo); - const fixturesError = await testInfo._runAndFailOnError(async () => { - // Setup fixtures required by the test. - testFunctionParams = await this._fixtureRunner.resolveParametersForFunction(test.fn, testInfo, 'test'); - }, 'allowSkips'); - if (fixturesError) - step.complete({ error: fixturesError }); + // Setup fixtures required by the test. + testFunctionParams = await this._fixtureRunner.resolveParametersForFunction(test.fn, testInfo, 'test', { type: 'test' }); }); if (testFunctionParams === null) { @@ -394,101 +354,120 @@ export class WorkerMain extends ProcessRunner { return; } - await testInfo._runAndFailOnError(async () => { + await testInfo._runAsStage({ title: 'test function', runnable: { type: 'test' } }, async () => { // Now run the test itself. - debugTest(`test function started`); const fn = test.fn; // Extract a variable to get a better stack trace ("myTest" vs "TestCase.myTest [as fn]"). await fn(testFunctionParams, testInfo); - debugTest(`test function finished`); - }, 'allowSkips'); - }); - - if (didFailBeforeAllForSuite) { - // This will inform dispatcher that we should not run more tests from this group - // because we had a beforeAll error. - // This behavior avoids getting the same common error for each test. - this._skipRemainingTestsInSuite = didFailBeforeAllForSuite; - } - - // A timed-out test gets a full additional timeout to run after hooks. - const afterHooksSlot = testInfo._didTimeout ? { timeout: this._project.project.timeout, elapsed: 0 } : undefined; - await testInfo._runAsStepWithRunnable({ category: 'hook', title: 'After Hooks', runnableType: 'afterHooks', runnableSlot: afterHooksSlot, forceNoParent: true }, async step => { - let firstAfterHooksError: Error | undefined; - await testInfo._runWithTimeout(async () => { - // Note: do not wrap all teardown steps together, because failure in any of them - // does not prevent further teardown steps from running. - - // Run "immediately upon test function finish" callback. - debugTest(`on-test-function-finish callback started`); - const didFinishTestFunctionError = await testInfo._runAndFailOnError(async () => testInfo._onDidFinishTestFunction?.()); - firstAfterHooksError = firstAfterHooksError || didFinishTestFunctionError; - debugTest(`on-test-function-finish callback finished`); - - // Run "afterEach" hooks, unless we failed at beforeAll stage. - if (shouldRunAfterEachHooks) { - const afterEachError = await testInfo._runAndFailOnError(() => this._runEachHooksForSuites(reversedSuites, 'afterEach', testInfo)); - firstAfterHooksError = firstAfterHooksError || afterEachError; - } - - // Teardown test-scoped fixtures. Attribute to 'test' so that users understand - // they should probably increase the test timeout to fix this issue. - debugTest(`tearing down test scope started`); - const testScopeError = await this._teardownScopeAndReturnFirstError('test', testInfo); - debugTest(`tearing down test scope finished`); - firstAfterHooksError = firstAfterHooksError || testScopeError; - - // Run "afterAll" hooks for suites that are not shared with the next test. - // In case of failure the worker will be stopped and we have to make sure that afterAll - // hooks run before worker fixtures teardown. - for (const suite of reversedSuites) { - if (!nextSuites.has(suite) || testInfo._isFailure()) { - const afterAllError = await this._runAfterAllHooksForSuite(suite, testInfo); - firstAfterHooksError = firstAfterHooksError || afterAllError; - } - } }); + }).catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors. - if (testInfo._isFailure()) - this._isStopped = true; + // Update duration, so it is available in fixture teardown and afterEach hooks. + testInfo.duration = testInfo._timeoutManager.defaultSlot().elapsed | 0; - if (this._isStopped) { - // Run all remaining "afterAll" hooks and teardown all fixtures when worker is shutting down. - // Mark as "cleaned up" early to avoid running cleanup twice. - this._didRunFullCleanup = true; + // No skips in after hooks. + testInfo._allowSkips = true; - // Give it more time for the full cleanup. - await testInfo._runWithTimeout(async () => { - debugTest(`running full cleanup after the failure`); + // After hooks get an additional timeout. + const afterHooksTimeout = calculateMaxTimeout(this._project.project.timeout, testInfo.timeout); + const afterHooksSlot = { timeout: afterHooksTimeout, elapsed: 0 }; + await testInfo._runAsStage({ title: 'After Hooks', stepInfo: { category: 'hook' } }, async () => { + let firstAfterHooksError: Error | undefined; + let didTimeoutInAfterHooks = false; - const teardownSlot = { timeout: this._project.project.timeout, elapsed: 0 }; - await testInfo._timeoutManager.withRunnable({ type: 'teardown', slot: teardownSlot }, async () => { - // Attribute to 'test' so that users understand they should probably increate the test timeout to fix this issue. - debugTest(`tearing down test scope started`); - const testScopeError = await this._teardownScopeAndReturnFirstError('test', testInfo); - debugTest(`tearing down test scope finished`); - firstAfterHooksError = firstAfterHooksError || testScopeError; - - for (const suite of reversedSuites) { - const afterAllError = await this._runAfterAllHooksForSuite(suite, testInfo); - firstAfterHooksError = firstAfterHooksError || afterAllError; - } - - // Attribute to 'teardown' because worker fixtures are not perceived as a part of a test. - debugTest(`tearing down worker scope started`); - const workerScopeError = await this._teardownScopeAndReturnFirstError('worker', testInfo); - debugTest(`tearing down worker scope finished`); - firstAfterHooksError = firstAfterHooksError || workerScopeError; - }); - }); + try { + // Run "immediately upon test function finish" callback. + await testInfo._runAsStage({ title: 'on-test-function-finish', runnable: { type: 'test', slot: afterHooksSlot } }, async () => testInfo._onDidFinishTestFunction?.()); + } catch (error) { + if (error instanceof TimeoutManagerError) + didTimeoutInAfterHooks = true; + firstAfterHooksError = firstAfterHooksError ?? error; } - if (firstAfterHooksError) - step.complete({ error: firstAfterHooksError }); - }); + try { + // Run "afterEach" hooks, unless we failed at beforeAll stage. + if (!didTimeoutInAfterHooks && shouldRunAfterEachHooks) + await this._runEachHooksForSuites(reversedSuites, 'afterEach', testInfo, afterHooksSlot); + } catch (error) { + if (error instanceof TimeoutManagerError) + didTimeoutInAfterHooks = true; + firstAfterHooksError = firstAfterHooksError ?? error; + } - await testInfo._runAndFailOnError(async () => { + try { + if (!didTimeoutInAfterHooks) { + // Teardown test-scoped fixtures. Attribute to 'test' so that users understand + // they should probably increase the test timeout to fix this issue. + await this._fixtureRunner.teardownScope('test', testInfo, { type: 'test', slot: afterHooksSlot }); + } + } catch (error) { + if (error instanceof TimeoutManagerError) + didTimeoutInAfterHooks = true; + firstAfterHooksError = firstAfterHooksError ?? error; + } + + // Run "afterAll" hooks for suites that are not shared with the next test. + // In case of failure the worker will be stopped and we have to make sure that afterAll + // hooks run before worker fixtures teardown. + for (const suite of reversedSuites) { + if (!nextSuites.has(suite) || testInfo._isFailure()) { + try { + await this._runAfterAllHooksForSuite(suite, testInfo); + } catch (error) { + // Continue running "afterAll" hooks even after some of them timeout. + firstAfterHooksError = firstAfterHooksError ?? error; + } + } + } + if (firstAfterHooksError) + throw firstAfterHooksError; + }).catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors. + + if (testInfo._isFailure()) + this._isStopped = true; + + if (this._isStopped) { + // Run all remaining "afterAll" hooks and teardown all fixtures when worker is shutting down. + // Mark as "cleaned up" early to avoid running cleanup twice. + this._didRunFullCleanup = true; + + await testInfo._runAsStage({ title: 'Worker Cleanup', stepInfo: { category: 'hook' } }, async () => { + let firstWorkerCleanupError: Error | undefined; + + // Give it more time for the full cleanup. + const teardownSlot = { timeout: this._project.project.timeout, elapsed: 0 }; + try { + // Attribute to 'test' so that users understand they should probably increate the test timeout to fix this issue. + await this._fixtureRunner.teardownScope('test', testInfo, { type: 'test', slot: teardownSlot }); + } catch (error) { + firstWorkerCleanupError = firstWorkerCleanupError ?? error; + } + + for (const suite of reversedSuites) { + try { + await this._runAfterAllHooksForSuite(suite, testInfo); + } catch (error) { + firstWorkerCleanupError = firstWorkerCleanupError ?? error; + } + } + + try { + // Attribute to 'teardown' because worker fixtures are not perceived as a part of a test. + await this._fixtureRunner.teardownScope('worker', testInfo, { type: 'teardown', slot: teardownSlot }); + } catch (error) { + firstWorkerCleanupError = firstWorkerCleanupError ?? error; + } + + if (firstWorkerCleanupError) + throw firstWorkerCleanupError; + }).catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors. + } + + const tracingSlot = { timeout: this._project.project.timeout, elapsed: 0 }; + await testInfo._runAsStage({ title: 'stop tracing', runnable: { type: 'test', slot: tracingSlot } }, async () => { await testInfo._tracing.stopIfNeeded(); - }); + }).catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors. + + testInfo.duration = (testInfo._timeoutManager.defaultSlot().elapsed + afterHooksSlot.elapsed) | 0; this._currentTest = null; setCurrentTestInfo(null); @@ -529,27 +508,21 @@ export class WorkerMain extends ProcessRunner { return; const extraAnnotations: Annotation[] = []; this._activeSuites.set(suite, extraAnnotations); - return await this._runAllHooksForSuite(suite, testInfo, 'beforeAll', extraAnnotations); + await this._runAllHooksForSuite(suite, testInfo, 'beforeAll', extraAnnotations); } private async _runAllHooksForSuite(suite: Suite, testInfo: TestInfoImpl, type: 'beforeAll' | 'afterAll', extraAnnotations?: Annotation[]) { - const allowSkips = type === 'beforeAll'; + // Always run all the hooks, and capture the first error. let firstError: Error | undefined; for (const hook of this._collectHooksAndModifiers(suite, type, testInfo)) { - debugTest(`${hook.type} hook at "${formatLocation(hook.location)}" started`); - const error = await testInfo._runAndFailOnError(async () => { - // Separate time slot for each beforeAll/afterAll hook. - const timeSlot = { timeout: this._project.project.timeout, elapsed: 0 }; - await testInfo._runAsStepWithRunnable({ - category: 'hook', - title: hook.title, - location: hook.location, - runnableType: hook.type, - runnableSlot: timeSlot, - }, async () => { + try { + await testInfo._runAsStage({ title: hook.title, stepInfo: { category: 'hook', location: hook.location } }, async () => { + // Separate time slot for each beforeAll/afterAll hook. + const timeSlot = { timeout: this._project.project.timeout, elapsed: 0 }; + const runnable = { type: hook.type, slot: timeSlot, location: hook.location }; const existingAnnotations = new Set(testInfo.annotations); try { - await this._fixtureRunner.resolveParametersAndRunFunction(hook.fn, testInfo, 'all-hooks-only'); + await this._fixtureRunner.resolveParametersAndRunFunction(hook.fn, testInfo, 'all-hooks-only', runnable); } finally { if (extraAnnotations) { // Inherit all annotations defined in the beforeAll/modifer to all tests in the suite. @@ -558,44 +531,51 @@ export class WorkerMain extends ProcessRunner { } // Each beforeAll/afterAll hook has its own scope for test fixtures. Attribute to the same runnable and timeSlot. // Note: we must teardown even after hook fails, because we'll run more hooks. - await this._teardownScope('test', testInfo); + await this._fixtureRunner.teardownScope('test', testInfo, runnable); } }); - }, allowSkips ? 'allowSkips' : undefined); - firstError = firstError || error; - debugTest(`${hook.type} hook at "${formatLocation(hook.location)}" finished`); - // Skip inside a beforeAll hook/modifier prevents others from running. - if (allowSkips && testInfo.expectedStatus === 'skipped') - break; + } catch (error) { + firstError = firstError ?? error; + // Skip in beforeAll/modifier prevents others from running. + if (type === 'beforeAll' && (error instanceof SkipError)) + break; + if (type === 'beforeAll' && !this._skipRemainingTestsInSuite) { + // This will inform dispatcher that we should not run more tests from this group + // because we had a beforeAll error. + // This behavior avoids getting the same common error for each test. + this._skipRemainingTestsInSuite = suite; + } + } } - return firstError; + if (firstError) + throw firstError; } - private async _runAfterAllHooksForSuite(suite: Suite, testInfo: TestInfoImpl): Promise { + private async _runAfterAllHooksForSuite(suite: Suite, testInfo: TestInfoImpl) { if (!this._activeSuites.has(suite)) return; this._activeSuites.delete(suite); - return await this._runAllHooksForSuite(suite, testInfo, 'afterAll'); + await this._runAllHooksForSuite(suite, testInfo, 'afterAll'); } - private async _runEachHooksForSuites(suites: Suite[], type: 'beforeEach' | 'afterEach', testInfo: TestInfoImpl) { + private async _runEachHooksForSuites(suites: Suite[], type: 'beforeEach' | 'afterEach', testInfo: TestInfoImpl, slot?: TimeSlot) { + // Always run all the hooks, unless one of the times out, and capture the first error. + let firstError: Error | undefined; const hooks = suites.map(suite => this._collectHooksAndModifiers(suite, type, testInfo)).flat(); - let error: Error | undefined; for (const hook of hooks) { try { - await testInfo._runAsStepWithRunnable({ - category: 'hook', - title: hook.title, - location: hook.location, - runnableType: hook.type, - }, () => this._fixtureRunner.resolveParametersAndRunFunction(hook.fn, testInfo, 'test')); - } catch (e) { - // Always run all the hooks, and capture the first error. - error = error || e; + await testInfo._runAsStage({ title: hook.title, stepInfo: { category: 'hook', location: hook.location } }, async () => { + const runnable = { type: hook.type, location: hook.location, slot }; + await this._fixtureRunner.resolveParametersAndRunFunction(hook.fn, testInfo, 'test', runnable); + }); + } catch (error) { + if (error instanceof TimeoutManagerError) + throw error; + firstError = firstError ?? error; } } - if (error) - throw error; + if (firstError) + throw firstError; } } @@ -635,4 +615,9 @@ function formatTestTitle(test: TestCase, projectName: string) { return `${projectTitle}${location} › ${titles.join(' › ')}`; } +function calculateMaxTimeout(t1: number, t2: number) { + // Zero means "no timeout". + return (!t1 || !t2) ? 0 : Math.max(t1, t2); +} + export const create = (params: WorkerInitParams) => new WorkerMain(params); diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 7e329ae595..497b137774 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -2312,6 +2312,13 @@ export interface TestInfo { */ status?: "passed"|"failed"|"timedOut"|"skipped"|"interrupted"; + /** + * Tags that apply to the test. Learn more about [tags](https://playwright.dev/docs/test-annotations#tag-tests). + * + * Note that any changes made to this list while the test is running will not be visible to test reporters. + */ + tags: Array; + /** * Test id matching the test case id in the reporter API. */ @@ -5586,6 +5593,7 @@ export interface PlaywrightWorkerOptions { * - `'retain-on-failure'`: Record trace for each test, but remove all traces from successful test runs. * - `'on-first-retry'`: Record trace only when retrying a test for the first time. * - `'on-all-retries'`: Record traces only when retrying for all retries. + * - `'retain-on-first-failure'`: Record traces only when the test fails for the first time. * * For more control, pass an object that specifies `mode` and trace features to enable. * @@ -5636,7 +5644,7 @@ export interface PlaywrightWorkerOptions { } export type ScreenshotMode = 'off' | 'on' | 'only-on-failure'; -export type TraceMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry' | 'on-all-retries'; +export type TraceMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry' | 'on-all-retries' | 'retain-on-first-failure'; export type VideoMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry'; /** @@ -6956,7 +6964,24 @@ interface GenericAssertions { type FunctionAssertions = { /** - * Retries the callback until it passes. + * Retries the callback until all assertions within it pass or the `timeout` value is reached. + * The `intervals` parameter can be used to establish the probing frequency or pattern. + * + * **Usage** + * ```js + * await expect(async () => { + * const response = await page.request.get('https://api.example.com'); + * expect(response.status()).toBe(200); + * }).toPass({ + * // Probe, wait 1s, probe, wait 2s, probe, wait 10s, probe, wait 10s, probe + * intervals: [1_000, 2_000, 10_000], // Defaults to [100, 250, 500, 1000]. + * timeout: 60_000 // Defaults to 0 + * }); + * ``` + * + * Note that by default `toPass` does not respect custom expect timeout. + * + * @param options */ toPass(options?: { timeout?: number, intervals?: number[] }): Promise; }; @@ -7099,7 +7124,8 @@ type MergedExpect = Expect>; export function mergeExpects(...expects: List): MergedExpect; // This is required to not export everything by default. See https://github.com/Microsoft/TypeScript/issues/19545#issuecomment-340490459 -export {}; +export { }; + /** diff --git a/packages/playwright/types/testReporter.d.ts b/packages/playwright/types/testReporter.d.ts index 728f5299ef..ed6f62e71d 100644 --- a/packages/playwright/types/testReporter.d.ts +++ b/packages/playwright/types/testReporter.d.ts @@ -16,7 +16,7 @@ */ import type { FullConfig, FullProject, TestStatus, Metadata } from './test'; -export type { FullConfig, TestStatus } from './test'; +export type { FullConfig, TestStatus, FullProject } from './test'; /** * `Suite` is a group of tests. All tests in Playwright Test form the following hierarchy: diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index 379b5a48e8..c0af7ac678 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -1427,6 +1427,7 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, EventT addCookies(params: BrowserContextAddCookiesParams, metadata?: CallMetadata): Promise; addInitScript(params: BrowserContextAddInitScriptParams, metadata?: CallMetadata): Promise; clearCookies(params?: BrowserContextClearCookiesParams, metadata?: CallMetadata): Promise; + removeCookies(params: BrowserContextRemoveCookiesParams, metadata?: CallMetadata): Promise; clearPermissions(params?: BrowserContextClearPermissionsParams, metadata?: CallMetadata): Promise; close(params: BrowserContextCloseParams, metadata?: CallMetadata): Promise; cookies(params: BrowserContextCookiesParams, metadata?: CallMetadata): Promise; @@ -1523,6 +1524,17 @@ export type BrowserContextAddInitScriptResult = void; export type BrowserContextClearCookiesParams = {}; export type BrowserContextClearCookiesOptions = {}; export type BrowserContextClearCookiesResult = void; +export type BrowserContextRemoveCookiesParams = { + filter: { + name?: string, + domain?: string, + path?: string, + }, +}; +export type BrowserContextRemoveCookiesOptions = { + +}; +export type BrowserContextRemoveCookiesResult = void; export type BrowserContextClearPermissionsParams = {}; export type BrowserContextClearPermissionsOptions = {}; export type BrowserContextClearPermissionsResult = void; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index c21cd006e9..acb847b08b 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1032,6 +1032,15 @@ BrowserContext: clearCookies: + removeCookies: + parameters: + filter: + type: object + properties: + name: string? + domain: string? + path: string? + clearPermissions: close: @@ -3222,7 +3231,7 @@ ElectronApplication: events: close: console: - parameters: + parameters: $mixin: ConsoleMessage Android: diff --git a/packages/trace-viewer/src/sw.ts b/packages/trace-viewer/src/sw.ts index 2334887fa5..841056f457 100644 --- a/packages/trace-viewer/src/sw.ts +++ b/packages/trace-viewer/src/sw.ts @@ -68,6 +68,10 @@ async function loadTrace(traceUrl: string, traceFileName: string | null, clientI // @ts-ignore async function doFetch(event: FetchEvent): Promise { + // In order to make Accessibility Insights for Web work. + if (event.request.url.startsWith('chrome-extension://')) + return fetch(event.request); + const request = event.request; const client = await self.clients.get(event.clientId); @@ -155,7 +159,7 @@ function downloadHeadersForAttachment(traceModel: TraceModel, sha1: string): Hea if (!attachment) return; const headers = new Headers(); - headers.set('Content-Disposition', `attachment; filename="${attachment.name}"`); + headers.set('Content-Disposition', `attachment; filename="attachment"; filename*=UTF-8''${encodeURIComponent(attachment.name)}`); if (attachment.contentType) headers.set('Content-Type', attachment.contentType); return headers; diff --git a/packages/trace-viewer/src/ui/modelUtil.ts b/packages/trace-viewer/src/ui/modelUtil.ts index 1b6b7cdff4..d1f1adbb2f 100644 --- a/packages/trace-viewer/src/ui/modelUtil.ts +++ b/packages/trace-viewer/src/ui/modelUtil.ts @@ -89,11 +89,12 @@ export class MultiTraceModel { this.platform = primaryContext?.platform || ''; this.title = primaryContext?.title || ''; this.options = primaryContext?.options || {}; + // Next call updates all timestamps for all events in non-primary contexts, so it must be done first. + this.actions = mergeActionsAndUpdateTiming(contexts); + this.pages = ([] as PageEntry[]).concat(...contexts.map(c => c.pages)); this.wallTime = contexts.map(c => c.wallTime).reduce((prev, cur) => Math.min(prev || Number.MAX_VALUE, cur!), Number.MAX_VALUE); this.startTime = contexts.map(c => c.startTime).reduce((prev, cur) => Math.min(prev, cur), Number.MAX_VALUE); this.endTime = contexts.map(c => c.endTime).reduce((prev, cur) => Math.max(prev, cur), Number.MIN_VALUE); - this.pages = ([] as PageEntry[]).concat(...contexts.map(c => c.pages)); - this.actions = mergeActions(contexts); this.events = ([] as (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[]).concat(...contexts.map(c => c.events)); this.stdio = ([] as trace.StdioTraceEvent[]).concat(...contexts.map(c => c.stdio)); this.errors = ([] as trace.ErrorTraceEvent[]).concat(...contexts.map(c => c.errors)); @@ -158,7 +159,7 @@ function indexModel(context: ContextEntry) { (event as any)[contextSymbol] = context; } -function mergeActions(contexts: ContextEntry[]) { +function mergeActionsAndUpdateTiming(contexts: ContextEntry[]) { const map = new Map(); // Protocol call aka isPrimary contexts have startTime/endTime as server-side times. @@ -176,12 +177,16 @@ function mergeActions(contexts: ContextEntry[]) { } const nonPrimaryIdToPrimaryId = new Map(); + const nonPrimaryTimeDelta = new Map(); for (const context of nonPrimaryContexts) { for (const action of context.actions) { if (offset) { const duration = action.endTime - action.startTime; - if (action.startTime) - action.startTime = action.wallTime + offset; + if (action.startTime) { + const newStartTime = action.wallTime + offset; + nonPrimaryTimeDelta.set(context, newStartTime - action.startTime); + action.startTime = newStartTime; + } if (action.endTime) action.endTime = action.startTime + duration; } @@ -204,6 +209,17 @@ function mergeActions(contexts: ContextEntry[]) { } } + for (const [context, timeDelta] of nonPrimaryTimeDelta) { + context.startTime += timeDelta; + context.endTime += timeDelta; + for (const event of context.events) + event.time += timeDelta; + for (const page of context.pages) { + for (const frame of page.screencastFrames) + frame.timestamp += timeDelta; + } + } + const result = [...map.values()]; result.sort((a1, a2) => { if (a2.parentId === a1.callId) diff --git a/packages/trace-viewer/src/ui/sourceTab.tsx b/packages/trace-viewer/src/ui/sourceTab.tsx index aebbbd872f..a3acad64a2 100644 --- a/packages/trace-viewer/src/ui/sourceTab.tsx +++ b/packages/trace-viewer/src/ui/sourceTab.tsx @@ -26,11 +26,11 @@ import type { StackFrame } from '@protocol/channels'; export const SourceTab: React.FunctionComponent<{ stack: StackFrame[] | undefined, + stackFrameLocation: 'bottom' | 'right', sources: Map, - hideStackFrames?: boolean, rootDir?: string, fallbackLocation?: SourceLocation, -}> = ({ stack, sources, hideStackFrames, rootDir, fallbackLocation }) => { +}> = ({ stack, sources, rootDir, fallbackLocation, stackFrameLocation }) => { const [lastStack, setLastStack] = React.useState(); const [selectedFrame, setSelectedFrame] = React.useState(0); @@ -78,7 +78,9 @@ export const SourceTab: React.FunctionComponent<{ return { source, highlight, targetLine, fileName }; }, [stack, selectedFrame, rootDir, fallbackLocation], { source: { errors: [], content: 'Loading\u2026' }, highlight: [] }); - return + const showStackFrames = (stack?.length ?? 0) > 1; + + return
{fileName &&
{fileName}
} diff --git a/packages/trace-viewer/src/ui/teleSuiteUpdater.ts b/packages/trace-viewer/src/ui/teleSuiteUpdater.ts new file mode 100644 index 0000000000..253ad61a13 --- /dev/null +++ b/packages/trace-viewer/src/ui/teleSuiteUpdater.ts @@ -0,0 +1,146 @@ +/** + * 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 { TeleReporterReceiver, TeleSuite } from '@testIsomorphic/teleReceiver'; +import { statusEx } from '@testIsomorphic/testTree'; +import type { ReporterV2 } from 'playwright/src/reporters/reporterV2'; +import type * as reporterTypes from 'playwright/types/testReporter'; +import type { Progress, TestModel } from './uiModeModel'; + +export type TeleSuiteUpdaterOptions = { + onUpdate: (force?: boolean) => void, + onError?: (error: reporterTypes.TestError) => void; + pathSeparator: string; +}; + +export class TeleSuiteUpdater { + rootSuite: reporterTypes.Suite | undefined; + config: reporterTypes.FullConfig | undefined; + readonly loadErrors: reporterTypes.TestError[] = []; + readonly progress: Progress = { + total: 0, + passed: 0, + failed: 0, + skipped: 0, + }; + + private _receiver: TeleReporterReceiver; + private _lastRunReceiver: TeleReporterReceiver | undefined; + private _lastRunTestCount = 0; + private _options: TeleSuiteUpdaterOptions; + + constructor(options: TeleSuiteUpdaterOptions) { + this._receiver = new TeleReporterReceiver(this._createReporter(), { + mergeProjects: true, + mergeTestCases: true, + resolvePath: (rootDir, relativePath) => rootDir + options.pathSeparator + relativePath, + clearPreviousResultsWhenTestBegins: true, + }); + this._options = options; + } + + private _createReporter(): ReporterV2 { + return { + version: () => 'v2', + + onConfigure: (c: reporterTypes.FullConfig) => { + this.config = c; + // TeleReportReceiver is merging everything into a single suite, so when we + // run one test, we still get many tests via rootSuite.allTests().length. + // To work around that, have a dedicated per-run receiver that will only have + // suite for a single test run, and hence will have correct total. + this._lastRunReceiver = new TeleReporterReceiver({ + onBegin: (suite: reporterTypes.Suite) => { + this._lastRunTestCount = suite.allTests().length; + this._lastRunReceiver = undefined; + } + }, { + mergeProjects: true, + mergeTestCases: false, + resolvePath: (rootDir, relativePath) => rootDir + this._options.pathSeparator + relativePath, + }); + }, + + onBegin: (suite: reporterTypes.Suite) => { + if (!this.rootSuite) + this.rootSuite = suite; + this.progress.total = this._lastRunTestCount; + this.progress.passed = 0; + this.progress.failed = 0; + this.progress.skipped = 0; + this._options.onUpdate(true); + }, + + onEnd: () => { + this._options.onUpdate(true); + }, + + onTestBegin: (test: reporterTypes.TestCase, testResult: reporterTypes.TestResult) => { + (testResult as any)[statusEx] = 'running'; + this._options.onUpdate(); + }, + + onTestEnd: (test: reporterTypes.TestCase, testResult: reporterTypes.TestResult) => { + if (test.outcome() === 'skipped') + ++this.progress.skipped; + else if (test.outcome() === 'unexpected') + ++this.progress.failed; + else + ++this.progress.passed; + (testResult as any)[statusEx] = testResult.status; + this._options.onUpdate(); + }, + + onError: (error: reporterTypes.TestError) => { + this.loadErrors.push(error); + this._options.onError?.(error); + this._options.onUpdate(); + }, + + printsToStdio: () => { + return false; + }, + + onStdOut: () => {}, + onStdErr: () => {}, + onExit: () => {}, + onStepBegin: () => {}, + onStepEnd: () => {}, + }; + } + + processListReport(report: any[]) { + this._receiver.reset(); + for (const message of report) + this._receiver.dispatch(message); + } + + processTestReportEvent(message: any) { + // The order of receiver dispatches matters here, we want to assign `lastRunTestCount` + // before we use it. + this._lastRunReceiver?.dispatch(message)?.catch(() => {}); + this._receiver.dispatch(message)?.catch(() => {}); + } + + asModel(): TestModel { + return { + rootSuite: this.rootSuite || new TeleSuite('', 'root'), + config: this.config!, + loadErrors: this.loadErrors, + progress: this.progress, + }; + } +} diff --git a/packages/trace-viewer/src/ui/timeline.css b/packages/trace-viewer/src/ui/timeline.css index dfc9e23682..be06c4c53b 100644 --- a/packages/trace-viewer/src/ui/timeline.css +++ b/packages/trace-viewer/src/ui/timeline.css @@ -23,6 +23,7 @@ cursor: text; user-select: none; margin-left: 10px; + overflow-x: clip; } .timeline-divider { diff --git a/packages/trace-viewer/src/ui/uiModeFiltersView.css b/packages/trace-viewer/src/ui/uiModeFiltersView.css new file mode 100644 index 0000000000..5967da3e23 --- /dev/null +++ b/packages/trace-viewer/src/ui/uiModeFiltersView.css @@ -0,0 +1,71 @@ +/* + 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. +*/ + +.filters { + flex: none; + display: flex; + flex-direction: column; + margin: 2px 0; +} + +.filter-list { + padding: 0 10px 10px 10px; + user-select: none; +} + +.filter-title, +.filter-summary { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + user-select: none; + cursor: pointer; +} + +.filter-label { + color: var(--vscode-disabledForeground); +} + +.filter-summary { + line-height: 24px; + margin-left: 24px; +} + +.filter-summary .filter-label { + margin-left: 5px; +} + +.filter-entry { + line-height: 24px; +} + +.filter-entry label { + display: flex; + align-items: center; + cursor: pointer; +} + +.filter-entry input { + flex: none; + display: flex; + align-items: center; + cursor: pointer; +} + +.filter-entry label div { + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/packages/trace-viewer/src/ui/uiModeFiltersView.tsx b/packages/trace-viewer/src/ui/uiModeFiltersView.tsx new file mode 100644 index 0000000000..a6cff6ecf8 --- /dev/null +++ b/packages/trace-viewer/src/ui/uiModeFiltersView.tsx @@ -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 '@web/common.css'; +import { Expandable } from '@web/components/expandable'; +import '@web/third_party/vscode/codicon.css'; +import { settings } from '@web/uiUtils'; +import React from 'react'; +import './uiModeFiltersView.css'; +import type { TestModel } from './uiModeModel'; + +export const FiltersView: React.FC<{ + filterText: string; + setFilterText: (text: string) => void; + statusFilters: Map; + setStatusFilters: (filters: Map) => void; + projectFilters: Map; + setProjectFilters: (filters: Map) => void; + testModel: TestModel | undefined, + runTests: () => void; +}> = ({ filterText, setFilterText, statusFilters, setStatusFilters, projectFilters, setProjectFilters, testModel, runTests }) => { + const [expanded, setExpanded] = React.useState(false); + const inputRef = React.useRef(null); + React.useEffect(() => { + inputRef.current?.focus(); + }, []); + + const statusLine = [...statusFilters.entries()].filter(([_, v]) => v).map(([s]) => s).join(' ') || 'all'; + const projectsLine = [...projectFilters.entries()].filter(([_, v]) => v).map(([p]) => p).join(' ') || 'all'; + return
+ { + setFilterText(e.target.value); + }} + onKeyDown={e => { + if (e.key === 'Enter') + runTests(); + }} />}> + +
setExpanded(!expanded)}> + Status: {statusLine} + Projects: {projectsLine} +
+ {expanded &&
+
+ {[...statusFilters.entries()].map(([status, value]) => { + return
+ +
; + })} +
+
+ {[...projectFilters.entries()].map(([projectName, value]) => { + return
+ +
; + })} +
+
} +
; +}; diff --git a/packages/trace-viewer/src/ui/uiModeModel.ts b/packages/trace-viewer/src/ui/uiModeModel.ts new file mode 100644 index 0000000000..97952cfbfd --- /dev/null +++ b/packages/trace-viewer/src/ui/uiModeModel.ts @@ -0,0 +1,33 @@ +/* + 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 * as reporterTypes from 'playwright/types/testReporter'; + +export type Progress = { + total: number; + passed: number; + failed: number; + skipped: number; +}; + +export type TestModel = { + config: reporterTypes.FullConfig; + rootSuite: reporterTypes.Suite; + loadErrors: reporterTypes.TestError[]; + progress: Progress; +}; + +export const pathSeparator = navigator.userAgent.toLowerCase().includes('windows') ? '\\' : '/'; diff --git a/packages/trace-viewer/src/ui/uiModeTestListView.css b/packages/trace-viewer/src/ui/uiModeTestListView.css new file mode 100644 index 0000000000..ae6fd624ee --- /dev/null +++ b/packages/trace-viewer/src/ui/uiModeTestListView.css @@ -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. +*/ + +.ui-mode-list-item { + flex: auto; +} + +.ui-mode-list-item-title { + flex: auto; + text-overflow: ellipsis; + overflow: hidden; +} + +.ui-mode-list-item-time { + flex: none; + color: var(--vscode-editorCodeLens-foreground); + margin: 0 4px; + user-select: none; +} + +.tests-list-view .list-view-entry.selected .ui-mode-list-item-time, +.tests-list-view .list-view-entry.highlighted .ui-mode-list-item-time { + display: none; +} + +.tests-list-view .list-view-entry:not(.highlighted):not(.selected) .toolbar-button:not(.toggled) { + display: none; +} diff --git a/packages/trace-viewer/src/ui/uiModeTestListView.tsx b/packages/trace-viewer/src/ui/uiModeTestListView.tsx new file mode 100644 index 0000000000..7ca7f95bdc --- /dev/null +++ b/packages/trace-viewer/src/ui/uiModeTestListView.tsx @@ -0,0 +1,169 @@ +/** + * 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 { TreeItem } from '@testIsomorphic/testTree'; +import type { TestTree } from '@testIsomorphic/testTree'; +import '@web/common.css'; +import { Toolbar } from '@web/components/toolbar'; +import { ToolbarButton } from '@web/components/toolbarButton'; +import type { TreeState } from '@web/components/treeView'; +import { TreeView } from '@web/components/treeView'; +import '@web/third_party/vscode/codicon.css'; +import { msToString } from '@web/uiUtils'; +import type * as reporterTypes from 'playwright/types/testReporter'; +import React from 'react'; +import type { SourceLocation } from './modelUtil'; +import { testStatusIcon } from './testUtils'; +import type { TestModel } from './uiModeModel'; +import './uiModeTestListView.css'; +import type { TestServerConnection } from '@testIsomorphic/testServerConnection'; + +const TestTreeView = TreeView; + +export const TestListView: React.FC<{ + filterText: string, + testTree: TestTree, + testServerConnection: TestServerConnection | undefined, + testModel?: TestModel, + runTests: (mode: 'bounce-if-busy' | 'queue-if-busy', testIds: Set) => void, + runningState?: { testIds: Set, itemSelectedByUser?: boolean }, + watchAll: boolean, + watchedTreeIds: { value: Set }, + setWatchedTreeIds: (ids: { value: Set }) => void, + isLoading?: boolean, + onItemSelected: (item: { treeItem?: TreeItem, testCase?: reporterTypes.TestCase, testFile?: SourceLocation }) => void, + requestedCollapseAllCount: number, +}> = ({ filterText, testModel, testServerConnection, testTree, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, requestedCollapseAllCount }) => { + const [treeState, setTreeState] = React.useState({ expandedItems: new Map() }); + const [selectedTreeItemId, setSelectedTreeItemId] = React.useState(); + const [collapseAllCount, setCollapseAllCount] = React.useState(requestedCollapseAllCount); + + // Look for a first failure within the run batch to select it. + React.useEffect(() => { + // If collapse was requested, clear the expanded items and return w/o selected item. + if (collapseAllCount !== requestedCollapseAllCount) { + treeState.expandedItems.clear(); + for (const item of testTree.flatTreeItems()) + treeState.expandedItems.set(item.id, false); + setCollapseAllCount(requestedCollapseAllCount); + setSelectedTreeItemId(undefined); + setTreeState({ ...treeState }); + return; + } + + if (!runningState || runningState.itemSelectedByUser) + return; + let selectedTreeItem: TreeItem | undefined; + const visit = (treeItem: TreeItem) => { + treeItem.children.forEach(visit); + if (selectedTreeItem) + return; + if (treeItem.status === 'failed') { + if (treeItem.kind === 'test' && runningState.testIds.has(treeItem.test.id)) + selectedTreeItem = treeItem; + else if (treeItem.kind === 'case' && runningState.testIds.has(treeItem.tests[0]?.id)) + selectedTreeItem = treeItem; + } + }; + visit(testTree.rootItem); + + if (selectedTreeItem) + setSelectedTreeItemId(selectedTreeItem.id); + }, [runningState, setSelectedTreeItemId, testTree, collapseAllCount, setCollapseAllCount, requestedCollapseAllCount, treeState, setTreeState]); + + // Compute selected item. + const { selectedTreeItem } = React.useMemo(() => { + if (!testModel) + return { selectedTreeItem: undefined }; + const selectedTreeItem = selectedTreeItemId ? testTree.treeItemById(selectedTreeItemId) : undefined; + let testFile: SourceLocation | undefined; + if (selectedTreeItem) { + testFile = { + file: selectedTreeItem.location.file, + line: selectedTreeItem.location.line, + source: { + errors: testModel.loadErrors.filter(e => e.location?.file === selectedTreeItem.location.file).map(e => ({ line: e.location!.line, message: e.message! })), + content: undefined, + } + }; + } + let selectedTest: reporterTypes.TestCase | undefined; + if (selectedTreeItem?.kind === 'test') + selectedTest = selectedTreeItem.test; + else if (selectedTreeItem?.kind === 'case' && selectedTreeItem.tests.length === 1) + selectedTest = selectedTreeItem.tests[0]; + onItemSelected({ treeItem: selectedTreeItem, testCase: selectedTest, testFile }); + return { selectedTreeItem }; + }, [onItemSelected, selectedTreeItemId, testModel, testTree]); + + // Update watch all. + React.useEffect(() => { + if (isLoading) + return; + if (watchAll) { + testServerConnection?.watchNoReply({ fileNames: testTree.fileNames() }); + } else { + const fileNames = new Set(); + for (const itemId of watchedTreeIds.value) { + const treeItem = testTree.treeItemById(itemId); + const fileName = treeItem?.location.file; + if (fileName) + fileNames.add(fileName); + } + testServerConnection?.watchNoReply({ fileNames: [...fileNames] }); + } + }, [isLoading, testTree, watchAll, watchedTreeIds, testServerConnection]); + + const runTreeItem = (treeItem: TreeItem) => { + setSelectedTreeItemId(treeItem.id); + runTests('bounce-if-busy', testTree.collectTestIds(treeItem)); + }; + + return { + return
+
{treeItem.title}
+ {!!treeItem.duration && treeItem.status !== 'skipped' &&
{msToString(treeItem.duration)}
} + + runTreeItem(treeItem)} disabled={!!runningState}> + testServerConnection?.openNoReply({ location: treeItem.location })} style={(treeItem.kind === 'group' && treeItem.subKind === 'folder') ? { visibility: 'hidden' } : {}}> + {!watchAll && { + if (watchedTreeIds.value.has(treeItem.id)) + watchedTreeIds.value.delete(treeItem.id); + else + watchedTreeIds.value.add(treeItem.id); + setWatchedTreeIds({ ...watchedTreeIds }); + }} toggled={watchedTreeIds.value.has(treeItem.id)}>} + +
; + }} + icon={treeItem => testStatusIcon(treeItem.status)} + selectedItem={selectedTreeItem} + onAccepted={runTreeItem} + onSelected={treeItem => { + if (runningState) + runningState.itemSelectedByUser = true; + setSelectedTreeItemId(treeItem.id); + }} + isError={treeItem => treeItem.kind === 'group' ? treeItem.hasLoadErrors : false} + autoExpandDepth={filterText ? 5 : 1} + noItemsMessage={isLoading ? 'Loading\u2026' : 'No tests'} />; +}; diff --git a/packages/trace-viewer/src/ui/uiModeTraceView.tsx b/packages/trace-viewer/src/ui/uiModeTraceView.tsx new file mode 100644 index 0000000000..86fd3fbd8c --- /dev/null +++ b/packages/trace-viewer/src/ui/uiModeTraceView.tsx @@ -0,0 +1,114 @@ +/** + * 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 { 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 { Workbench } from './workbench'; + +export const TraceView: React.FC<{ + item: { treeItem?: TreeItem, testFile?: SourceLocation, testCase?: reporterTypes.TestCase }, + rootDir?: string, +}> = ({ item, rootDir }) => { + const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>(); + const [counter, setCounter] = React.useState(0); + const pollTimer = React.useRef(null); + + const { outputDir } = React.useMemo(() => { + const outputDir = item.testCase ? outputDirForTestCase(item.testCase) : undefined; + 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); + + const result = item.testCase?.results[0]; + if (!result) { + setModel(undefined); + return; + } + + // Test finished. + const attachment = result && result.duration >= 0 && result.attachments.find(a => a.name === 'trace'); + if (attachment && attachment.path) { + loadSingleTraceFile(attachment.path).then(model => setModel({ model, isLive: false })); + return; + } + + if (!outputDir) { + setModel(undefined); + return; + } + + const traceLocation = `${outputDir}/${artifactsFolderName(result!.workerIndex)}/traces/${item.testCase?.id}.json`; + // 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); + }; + }, [outputDir, item, setModel, counter, setCounter]); + + return ; +}; + +const outputDirForTestCase = (testCase: reporterTypes.TestCase): string | undefined => { + for (let suite: reporterTypes.Suite | undefined = testCase.parent; suite; suite = suite.parent) { + if (suite.project()) + return suite.project()?.outputDir; + } + return undefined; +}; + +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); +} diff --git a/packages/trace-viewer/src/ui/uiModeView.css b/packages/trace-viewer/src/ui/uiModeView.css index 3c18145805..a45e586c52 100644 --- a/packages/trace-viewer/src/ui/uiModeView.css +++ b/packages/trace-viewer/src/ui/uiModeView.css @@ -30,28 +30,6 @@ color: var(--vscode-debugIcon-stopForeground); } -.ui-mode-list-item { - flex: auto; -} - -.ui-mode-list-item-title { - flex: auto; - text-overflow: ellipsis; - overflow: hidden; -} - -.ui-mode-list-item-time { - flex: none; - color: var(--vscode-editorCodeLens-foreground); - margin: 0 4px; - user-select: none; -} - -.list-view-entry.selected .ui-mode-list-item-time, -.list-view-entry.highlighted .ui-mode-list-item-time { - display: none; -} - .ui-mode .section-title { display: flex; flex: auto; @@ -111,10 +89,6 @@ text-overflow: ellipsis; } -.list-view-entry:not(.highlighted):not(.selected) .toolbar-button:not(.toggled) { - display: none; -} - .ui-mode-sidebar input[type=search] { flex: auto; padding: 0 5px; @@ -125,59 +99,3 @@ color: var(--vscode-input-foreground); background-color: var(--vscode-input-background); } - -.filters { - flex: none; - display: flex; - flex-direction: column; - margin: 2px 0; -} - -.filter-list { - padding: 0 10px 10px 10px; - user-select: none; -} - -.filter-title, -.filter-summary { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - user-select: none; - cursor: pointer; -} - -.filter-label { - color: var(--vscode-disabledForeground); -} - -.filter-summary { - line-height: 24px; - margin-left: 24px; -} - -.filter-summary .filter-label { - margin-left: 5px; -} - -.filter-entry { - line-height: 24px; -} - -.filter-entry label { - display: flex; - align-items: center; - cursor: pointer; -} - -.filter-entry input { - flex: none; - display: flex; - align-items: center; - cursor: pointer; -} - -.filter-entry label div { - overflow: hidden; - text-overflow: ellipsis; -} diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 1becb6645f..06bb83bc45 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -15,132 +15,220 @@ */ import '@web/third_party/vscode/codicon.css'; -import { Workbench } from './workbench'; import '@web/common.css'; import React from 'react'; -import { TreeView } from '@web/components/treeView'; -import type { TreeState } from '@web/components/treeView'; -import { baseFullConfig, TeleReporterReceiver, TeleSuite } from '@testIsomorphic/teleReceiver'; +import { TeleSuite } from '@testIsomorphic/teleReceiver'; +import { TeleSuiteUpdater } from './teleSuiteUpdater'; +import type { Progress } from './uiModeModel'; import type { TeleTestCase } from '@testIsomorphic/teleReceiver'; -import type { FullConfig, Suite, TestCase, Location, TestError } from 'playwright/types/testReporter'; +import type * as reporterTypes from 'playwright/types/testReporter'; import { SplitView } from '@web/components/splitView'; -import { idForAction, MultiTraceModel } from './modelUtil'; import type { SourceLocation } from './modelUtil'; import './uiModeView.css'; import { ToolbarButton } from '@web/components/toolbarButton'; import { Toolbar } from '@web/components/toolbar'; -import type { ContextEntry } from '../entries'; import type { XtermDataSource } from '@web/components/xtermWrapper'; import { XtermWrapper } from '@web/components/xtermWrapper'; -import { Expandable } from '@web/components/expandable'; import { toggleTheme } from '@web/theme'; -import { artifactsFolderName } from '@testIsomorphic/folders'; -import { msToString, settings, useSetting } from '@web/uiUtils'; -import type { ActionTraceEvent } from '@trace/trace'; -import { connect } from './wsPort'; -import { testStatusIcon } from './testUtils'; -import type { UITestStatus } from './testUtils'; +import { settings, useSetting } from '@web/uiUtils'; +import { statusEx, TestTree } from '@testIsomorphic/testTree'; +import type { TreeItem } from '@testIsomorphic/testTree'; +import { TestServerConnection } from '@testIsomorphic/testServerConnection'; +import { pathSeparator } from './uiModeModel'; +import type { TestModel } from './uiModeModel'; +import { FiltersView } from './uiModeFiltersView'; +import { TestListView } from './uiModeTestListView'; +import { TraceView } from './uiModeTraceView'; -let updateRootSuite: (config: FullConfig, rootSuite: Suite, loadErrors: TestError[], progress: Progress | undefined) => void = () => {}; -let runWatchedTests = (fileNames: string[]) => {}; let xtermSize = { cols: 80, rows: 24 }; - -let sendMessage: (method: string, params?: any) => Promise = async () => {}; - const xtermDataSource: XtermDataSource = { pending: [], clear: () => {}, write: data => xtermDataSource.pending.push(data), - resize: (cols: number, rows: number) => { - xtermSize = { cols, rows }; - sendMessageNoReply('resizeTerminal', { cols, rows }); - }, -}; - -type TestModel = { - config: FullConfig | undefined; - rootSuite: Suite | undefined; - loadErrors: TestError[]; + resize: () => {}, }; export const UIModeView: React.FC<{}> = ({ }) => { const [filterText, setFilterText] = React.useState(''); const [isShowingOutput, setIsShowingOutput] = React.useState(false); - const [statusFilters, setStatusFilters] = React.useState>(new Map([ ['passed', false], ['failed', false], ['skipped', false], ])); const [projectFilters, setProjectFilters] = React.useState>(new Map()); - const [testModel, setTestModel] = React.useState({ config: undefined, rootSuite: undefined, loadErrors: [] }); + const [testModel, setTestModel] = React.useState(); const [progress, setProgress] = React.useState(); - const [selectedItem, setSelectedItem] = React.useState<{ treeItem?: TreeItem, testFile?: SourceLocation, testCase?: TestCase }>({}); + const [selectedItem, setSelectedItem] = React.useState<{ treeItem?: TreeItem, testFile?: SourceLocation, testCase?: reporterTypes.TestCase }>({}); const [visibleTestIds, setVisibleTestIds] = React.useState>(new Set()); const [isLoading, setIsLoading] = React.useState(false); const [runningState, setRunningState] = React.useState<{ testIds: Set, itemSelectedByUser?: boolean } | undefined>(); const [watchAll, setWatchAll] = useSetting('watch-all', false); const [watchedTreeIds, setWatchedTreeIds] = React.useState<{ value: Set }>({ value: new Set() }); - const runTestPromiseChain = React.useRef(Promise.resolve()); + const commandQueue = React.useRef(Promise.resolve()); const runTestBacklog = React.useRef>(new Set()); const [collapseAllCount, setCollapseAllCount] = React.useState(0); const [isDisconnected, setIsDisconnected] = React.useState(false); const [hasBrowsers, setHasBrowsers] = React.useState(true); + const [testServerConnection, setTestServerConnection] = React.useState(); const inputRef = React.useRef(null); const reloadTests = React.useCallback(() => { - setIsLoading(true); - setWatchedTreeIds({ value: new Set() }); - updateRootSuite(baseFullConfig, new TeleSuite('', 'root'), [], undefined); - refreshRootSuite(true).then(async () => { - setIsLoading(false); - const { hasBrowsers } = await sendMessage('checkBrowsers'); - setHasBrowsers(hasBrowsers); - }); + const guid = new URLSearchParams(window.location.search).get('ws'); + const wsURL = new URL(`../${guid}`, window.location.toString()); + wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:'); + setTestServerConnection(new TestServerConnection(wsURL.toString())); }, []); + // Load tests on startup. React.useEffect(() => { inputRef.current?.focus(); setIsLoading(true); - connect({ onEvent: dispatchEvent, onClose: () => setIsDisconnected(true) }).then(send => { - sendMessage = async (method, params) => { - const logForTest = (window as any).__logForTest; - logForTest?.({ method, params }); - await send(method, params); - }; - reloadTests(); - }); + reloadTests(); }, [reloadTests]); - updateRootSuite = React.useCallback((config: FullConfig, rootSuite: Suite, loadErrors: TestError[], newProgress: Progress | undefined) => { + // Wire server connection to the auxiliary UI features. + React.useEffect(() => { + if (!testServerConnection) + return; + const disposables = [ + testServerConnection.onStdio(params => { + if (params.buffer) { + const data = atob(params.buffer); + xtermDataSource.write(data); + } else { + xtermDataSource.write(params.text!); + } + }), + testServerConnection.onClose(() => setIsDisconnected(true)) + ]; + xtermDataSource.resize = (cols, rows) => { + xtermSize = { cols, rows }; + testServerConnection.resizeTerminalNoReply({ cols, rows }); + }; + return () => { + for (const disposable of disposables) + disposable.dispose(); + }; + }, [testServerConnection]); + + // This is the main routine, every time connection updates it starts the + // whole workflow. + React.useEffect(() => { + if (!testServerConnection) + return; + + let throttleTimer: NodeJS.Timeout | undefined; + const teleSuiteUpdater = new TeleSuiteUpdater({ + onUpdate: immediate => { + clearTimeout(throttleTimer); + throttleTimer = undefined; + if (immediate) { + setTestModel(teleSuiteUpdater.asModel()); + } else if (!throttleTimer) { + throttleTimer = setTimeout(() => { + setTestModel(teleSuiteUpdater.asModel()); + }, 250); + } + }, + onError: error => { + xtermDataSource.write((error.stack || error.value || '') + '\n'); + }, + pathSeparator, + }); + + const updateList = async () => { + commandQueue.current = commandQueue.current.then(async () => { + setIsLoading(true); + try { + const result = await testServerConnection.listTests({}); + teleSuiteUpdater.processListReport(result.report); + } catch (e) { + // eslint-disable-next-line no-console + console.log(e); + } finally { + setIsLoading(false); + } + }); + }; + + setTestModel(undefined); + setIsLoading(true); + setWatchedTreeIds({ value: new Set() }); + (async () => { + const status = await testServerConnection.runGlobalSetup(); + if (status !== 'passed') + return; + const result = await testServerConnection.listTests({}); + teleSuiteUpdater.processListReport(result.report); + + testServerConnection.onListChanged(updateList); + testServerConnection.onReport(params => { + teleSuiteUpdater.processTestReportEvent(params); + }); + setIsLoading(false); + + const { hasBrowsers } = await testServerConnection.checkBrowsers(); + setHasBrowsers(hasBrowsers); + })(); + return () => { + clearTimeout(throttleTimer); + }; + }, [testServerConnection]); + + // Update project filter default values. + React.useEffect(() => { + if (!testModel) + return; + + const { config, rootSuite } = testModel; const selectedProjects = config.configFile ? settings.getObject(config.configFile + ':projects', undefined) : undefined; - for (const projectName of projectFilters.keys()) { + const newFilter = new Map(projectFilters); + for (const projectName of newFilter.keys()) { if (!rootSuite.suites.find(s => s.title === projectName)) - projectFilters.delete(projectName); + newFilter.delete(projectName); } for (const projectSuite of rootSuite.suites) { - if (!projectFilters.has(projectSuite.title)) - projectFilters.set(projectSuite.title, !!selectedProjects?.includes(projectSuite.title)); + if (!newFilter.has(projectSuite.title)) + newFilter.set(projectSuite.title, !!selectedProjects?.includes(projectSuite.title)); } - if (!selectedProjects && projectFilters.size && ![...projectFilters.values()].includes(true)) - projectFilters.set(projectFilters.entries().next().value[0], true); + if (!selectedProjects && newFilter.size && ![...newFilter.values()].includes(true)) + newFilter.set(newFilter.entries().next().value[0], true); + if (projectFilters.size !== newFilter.size || [...projectFilters].some(([k, v]) => newFilter.get(k) !== v)) + setProjectFilters(newFilter); + }, [projectFilters, testModel]); - setTestModel({ config, rootSuite, loadErrors }); - setProjectFilters(new Map(projectFilters)); - if (runningState && newProgress) - setProgress(newProgress); - else if (!newProgress) + // Update progress. + React.useEffect(() => { + if (runningState && testModel?.progress) + setProgress(testModel.progress); + else if (!testModel) setProgress(undefined); - }, [projectFilters, runningState]); + }, [testModel, runningState]); + + // Test tree is built from the model and filters. + const { testTree } = React.useMemo(() => { + if (!testModel) + return { testTree: new TestTree('', new TeleSuite('', 'root'), [], projectFilters, pathSeparator) }; + const testTree = new TestTree('', testModel.rootSuite, testModel.loadErrors, projectFilters, pathSeparator); + testTree.filterTree(filterText, statusFilters, runningState?.testIds); + testTree.sortAndPropagateStatus(); + testTree.shortenRoot(); + testTree.flattenForSingleProject(); + setVisibleTestIds(testTree.testIds()); + return { testTree }; + }, [filterText, testModel, statusFilters, projectFilters, setVisibleTestIds, runningState]); const runTests = React.useCallback((mode: 'queue-if-busy' | 'bounce-if-busy', testIds: Set) => { + if (!testServerConnection || !testModel) + return; if (mode === 'bounce-if-busy' && runningState) return; runTestBacklog.current = new Set([...runTestBacklog.current, ...testIds]); - runTestPromiseChain.current = runTestPromiseChain.current.then(async () => { + commandQueue.current = commandQueue.current.then(async () => { const testIds = runTestBacklog.current; runTestBacklog.current = new Set(); if (!testIds.size) @@ -151,7 +239,8 @@ export const UIModeView: React.FC<{}> = ({ for (const test of testModel.rootSuite?.allTests() || []) { if (testIds.has(test.id)) { (test as TeleTestCase)._clearResults(); - (test as TeleTestCase)._createTestResult('pending'); + const result = (test as TeleTestCase)._createTestResult('pending'); + (result as any)[statusEx] = 'scheduled'; } } setTestModel({ ...testModel }); @@ -162,7 +251,7 @@ export const UIModeView: React.FC<{}> = ({ setProgress({ total: 0, passed: 0, failed: 0, skipped: 0 }); setRunningState({ testIds }); - await sendMessage('run', { testIds: [...testIds], projects: [...projectFilters].filter(([_, v]) => v).map(([p]) => p) }); + await testServerConnection.runTests({ testIds: [...testIds], projects: [...projectFilters].filter(([_, v]) => v).map(([p]) => p) }); // Clear pending tests in case of interrupt. for (const test of testModel.rootSuite?.allTests() || []) { if (test.results[0]?.duration === -1) @@ -171,7 +260,55 @@ export const UIModeView: React.FC<{}> = ({ setTestModel({ ...testModel }); setRunningState(undefined); }); - }, [projectFilters, runningState, testModel]); + }, [projectFilters, runningState, testModel, testServerConnection]); + + // Watch implementation. + React.useEffect(() => { + if (!testServerConnection) + return; + const disposable = testServerConnection.onTestFilesChanged(params => { + const testIds: string[] = []; + const set = new Set(params.testFiles); + if (watchAll) { + const visit = (treeItem: TreeItem) => { + const fileName = treeItem.location.file; + if (fileName && set.has(fileName)) + testIds.push(...testTree.collectTestIds(treeItem)); + if (treeItem.kind === 'group' && treeItem.subKind === 'folder') + treeItem.children.forEach(visit); + }; + visit(testTree.rootItem); + } else { + for (const treeId of watchedTreeIds.value) { + const treeItem = testTree.treeItemById(treeId); + const fileName = treeItem?.location.file; + if (fileName && set.has(fileName)) + testIds.push(...testTree.collectTestIds(treeItem)); + } + } + runTests('queue-if-busy', new Set(testIds)); + }); + return () => disposable.dispose(); + }, [runTests, testServerConnection, testTree, watchAll, watchedTreeIds]); + + // Shortcuts. + React.useEffect(() => { + if (!testServerConnection) + return; + const onShortcutEvent = (e: KeyboardEvent) => { + if (e.code === 'F6') { + e.preventDefault(); + testServerConnection?.stopTestsNoReply(); + } else if (e.code === 'F5') { + e.preventDefault(); + reloadTests(); + } + }; + addEventListener('keydown', onShortcutEvent); + return () => { + removeEventListener('keydown', onShortcutEvent); + }; + }, [runTests, reloadTests, testServerConnection]); const isRunningTest = !!runningState; const dialogRef = React.useRef(null); @@ -188,12 +325,12 @@ export const UIModeView: React.FC<{}> = ({ const installBrowsers = React.useCallback((e: React.MouseEvent) => { closeInstallDialog(e); setIsShowingOutput(true); - sendMessage('installBrowsers').then(async () => { + testServerConnection?.installBrowsers().then(async () => { setIsShowingOutput(false); - const { hasBrowsers } = await sendMessage('checkBrowsers'); + const { hasBrowsers } = await testServerConnection?.checkBrowsers(); setHasBrowsers(hasBrowsers); }); - }, [closeInstallDialog]); + }, [closeInstallDialog, testServerConnection]); return
{!hasBrowsers && @@ -223,7 +360,7 @@ export const UIModeView: React.FC<{}> = ({
- +
@@ -253,7 +390,7 @@ export const UIModeView: React.FC<{}> = ({
Running {progress.passed}/{runningState.testIds.size} passed ({(progress.passed / runningState.testIds.size) * 100 | 0}%)
} runTests('bounce-if-busy', visibleTestIds)} disabled={isRunningTest || isLoading}> - sendMessageNoReply('stop')} disabled={!isRunningTest || isLoading}> + testServerConnection?.stopTests()} disabled={!isRunningTest || isLoading}> { setWatchedTreeIds({ value: new Set() }); setWatchAll(!watchAll); @@ -262,15 +399,14 @@ export const UIModeView: React.FC<{}> = ({ setCollapseAllCount(collapseAllCount + 1); }} /> - = ({
; }; - -const FiltersView: React.FC<{ - filterText: string; - setFilterText: (text: string) => void; - statusFilters: Map; - setStatusFilters: (filters: Map) => void; - projectFilters: Map; - setProjectFilters: (filters: Map) => void; - testModel: TestModel | undefined, - runTests: () => void; -}> = ({ filterText, setFilterText, statusFilters, setStatusFilters, projectFilters, setProjectFilters, testModel, runTests }) => { - const [expanded, setExpanded] = React.useState(false); - const inputRef = React.useRef(null); - React.useEffect(() => { - inputRef.current?.focus(); - }, []); - - const statusLine = [...statusFilters.entries()].filter(([_, v]) => v).map(([s]) => s).join(' ') || 'all'; - const projectsLine = [...projectFilters.entries()].filter(([_, v]) => v).map(([p]) => p).join(' ') || 'all'; - return
- { - setFilterText(e.target.value); - }} - onKeyDown={e => { - if (e.key === 'Enter') - runTests(); - }} />}> - -
setExpanded(!expanded)}> - Status: {statusLine} - Projects: {projectsLine} -
- {expanded &&
-
- {[...statusFilters.entries()].map(([status, value]) => { - return
- -
; - })} -
-
- {[...projectFilters.entries()].map(([projectName, value]) => { - return
- -
; - })} -
-
} -
; -}; - -const TestTreeView = TreeView; - -const TestList: React.FC<{ - statusFilters: Map, - projectFilters: Map, - filterText: string, - testModel: TestModel, - runTests: (mode: 'bounce-if-busy' | 'queue-if-busy', testIds: Set) => void, - runningState?: { testIds: Set, itemSelectedByUser?: boolean }, - watchAll: boolean, - watchedTreeIds: { value: Set }, - setWatchedTreeIds: (ids: { value: Set }) => void, - isLoading?: boolean, - setVisibleTestIds: (testIds: Set) => void, - onItemSelected: (item: { treeItem?: TreeItem, testCase?: TestCase, testFile?: SourceLocation }) => void, - requestedCollapseAllCount: number, -}> = ({ statusFilters, projectFilters, filterText, testModel, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, setVisibleTestIds, requestedCollapseAllCount }) => { - const [treeState, setTreeState] = React.useState({ expandedItems: new Map() }); - const [selectedTreeItemId, setSelectedTreeItemId] = React.useState(); - const [collapseAllCount, setCollapseAllCount] = React.useState(requestedCollapseAllCount); - - // Build the test tree. - const { rootItem, treeItemMap, fileNames } = React.useMemo(() => { - let rootItem = createTree(testModel.rootSuite, testModel.loadErrors, projectFilters); - filterTree(rootItem, filterText, statusFilters, runningState?.testIds); - sortAndPropagateStatus(rootItem); - rootItem = shortenRoot(rootItem); - - hideOnlyTests(rootItem); - const treeItemMap = new Map(); - const visibleTestIds = new Set(); - const fileNames = new Set(); - const visit = (treeItem: TreeItem) => { - if (treeItem.kind === 'group' && treeItem.location.file) - fileNames.add(treeItem.location.file); - if (treeItem.kind === 'case') - treeItem.tests.forEach(t => visibleTestIds.add(t.id)); - treeItem.children.forEach(visit); - treeItemMap.set(treeItem.id, treeItem); - }; - visit(rootItem); - setVisibleTestIds(visibleTestIds); - return { rootItem, treeItemMap, fileNames }; - }, [filterText, testModel, statusFilters, projectFilters, setVisibleTestIds, runningState]); - - // Look for a first failure within the run batch to select it. - React.useEffect(() => { - // If collapse was requested, clear the expanded items and return w/o selected item. - if (collapseAllCount !== requestedCollapseAllCount) { - treeState.expandedItems.clear(); - for (const item of treeItemMap.keys()) - treeState.expandedItems.set(item, false); - setCollapseAllCount(requestedCollapseAllCount); - setSelectedTreeItemId(undefined); - setTreeState({ ...treeState }); - return; - } - - if (!runningState || runningState.itemSelectedByUser) - return; - let selectedTreeItem: TreeItem | undefined; - const visit = (treeItem: TreeItem) => { - treeItem.children.forEach(visit); - if (selectedTreeItem) - return; - if (treeItem.status === 'failed') { - if (treeItem.kind === 'test' && runningState.testIds.has(treeItem.test.id)) - selectedTreeItem = treeItem; - else if (treeItem.kind === 'case' && runningState.testIds.has(treeItem.tests[0]?.id)) - selectedTreeItem = treeItem; - } - }; - visit(rootItem); - - if (selectedTreeItem) - setSelectedTreeItemId(selectedTreeItem.id); - }, [runningState, setSelectedTreeItemId, rootItem, collapseAllCount, setCollapseAllCount, requestedCollapseAllCount, treeState, setTreeState, treeItemMap]); - - // Compute selected item. - const { selectedTreeItem } = React.useMemo(() => { - const selectedTreeItem = selectedTreeItemId ? treeItemMap.get(selectedTreeItemId) : undefined; - let testFile: SourceLocation | undefined; - if (selectedTreeItem) { - testFile = { - file: selectedTreeItem.location.file, - line: selectedTreeItem.location.line, - source: { - errors: testModel.loadErrors.filter(e => e.location?.file === selectedTreeItem.location.file).map(e => ({ line: e.location!.line, message: e.message! })), - content: undefined, - } - }; - } - let selectedTest: TestCase | undefined; - if (selectedTreeItem?.kind === 'test') - selectedTest = selectedTreeItem.test; - else if (selectedTreeItem?.kind === 'case' && selectedTreeItem.tests.length === 1) - selectedTest = selectedTreeItem.tests[0]; - onItemSelected({ treeItem: selectedTreeItem, testCase: selectedTest, testFile }); - return { selectedTreeItem }; - }, [onItemSelected, selectedTreeItemId, testModel, treeItemMap]); - - // Update watch all. - React.useEffect(() => { - if (isLoading) - return; - if (watchAll) { - sendMessageNoReply('watch', { fileNames: [...fileNames] }); - } else { - const fileNames = new Set(); - for (const itemId of watchedTreeIds.value) { - const treeItem = treeItemMap.get(itemId); - const fileName = treeItem?.location.file; - if (fileName) - fileNames.add(fileName); - } - sendMessageNoReply('watch', { fileNames: [...fileNames] }); - } - }, [isLoading, rootItem, fileNames, watchAll, watchedTreeIds, treeItemMap]); - - const runTreeItem = (treeItem: TreeItem) => { - setSelectedTreeItemId(treeItem.id); - runTests('bounce-if-busy', collectTestIds(treeItem)); - }; - - runWatchedTests = (changedTestFiles: string[]) => { - const testIds: string[] = []; - const set = new Set(changedTestFiles); - if (watchAll) { - const visit = (treeItem: TreeItem) => { - const fileName = treeItem.location.file; - if (fileName && set.has(fileName)) - testIds.push(...collectTestIds(treeItem)); - if (treeItem.kind === 'group' && treeItem.subKind === 'folder') - treeItem.children.forEach(visit); - }; - visit(rootItem); - } else { - for (const treeId of watchedTreeIds.value) { - const treeItem = treeItemMap.get(treeId); - const fileName = treeItem?.location.file; - if (fileName && set.has(fileName)) - testIds.push(...collectTestIds(treeItem)); - } - } - runTests('queue-if-busy', new Set(testIds)); - }; - - return { - return
-
{treeItem.title}
- {!!treeItem.duration && treeItem.status !== 'skipped' &&
{msToString(treeItem.duration)}
} - - runTreeItem(treeItem)} disabled={!!runningState}> - sendMessageNoReply('open', { location: locationToOpen(treeItem) })} style={(treeItem.kind === 'group' && treeItem.subKind === 'folder') ? { visibility: 'hidden' } : {}}> - {!watchAll && { - if (watchedTreeIds.value.has(treeItem.id)) - watchedTreeIds.value.delete(treeItem.id); - else - watchedTreeIds.value.add(treeItem.id); - setWatchedTreeIds({ ...watchedTreeIds }); - }} toggled={watchedTreeIds.value.has(treeItem.id)}>} - -
; - }} - icon={treeItem => testStatusIcon(treeItem.status)} - selectedItem={selectedTreeItem} - onAccepted={runTreeItem} - onSelected={treeItem => { - if (runningState) - runningState.itemSelectedByUser = true; - setSelectedTreeItemId(treeItem.id); - }} - isError={treeItem => treeItem.kind === 'group' ? treeItem.hasLoadErrors : false} - autoExpandDepth={filterText ? 5 : 1} - noItemsMessage={isLoading ? 'Loading\u2026' : 'No tests'} />; -}; - -const TraceView: React.FC<{ - item: { treeItem?: TreeItem, testFile?: SourceLocation, testCase?: TestCase }, - rootDir?: string, -}> = ({ item, rootDir }) => { - const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>(); - const [counter, setCounter] = React.useState(0); - const pollTimer = React.useRef(null); - - const { outputDir } = React.useMemo(() => { - const outputDir = item.testCase ? outputDirForTestCase(item.testCase) : undefined; - 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); - - const result = item.testCase?.results[0]; - if (!result) { - setModel(undefined); - return; - } - - // Test finished. - const attachment = result && result.duration >= 0 && result.attachments.find(a => a.name === 'trace'); - if (attachment && attachment.path) { - loadSingleTraceFile(attachment.path).then(model => setModel({ model, isLive: false })); - return; - } - - if (!outputDir) { - setModel(undefined); - return; - } - - const traceLocation = `${outputDir}/${artifactsFolderName(result!.workerIndex)}/traces/${item.testCase?.id}.json`; - // 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); - }; - }, [outputDir, item, setModel, counter, setCounter]); - - return ; -}; - -let receiver: TeleReporterReceiver | undefined; -let lastRunReceiver: TeleReporterReceiver | undefined; -let lastRunTestCount: number; - -let throttleTimer: NodeJS.Timeout | undefined; -let throttleData: { config: FullConfig, rootSuite: Suite, loadErrors: TestError[], progress: Progress } | undefined; -const throttledAction = () => { - clearTimeout(throttleTimer); - throttleTimer = undefined; - updateRootSuite(throttleData!.config, throttleData!.rootSuite, throttleData!.loadErrors, throttleData!.progress); -}; - -const throttleUpdateRootSuite = (config: FullConfig, rootSuite: Suite, loadErrors: TestError[], progress: Progress, immediate = false) => { - throttleData = { config, rootSuite, loadErrors, progress }; - if (immediate) - throttledAction(); - else if (!throttleTimer) - throttleTimer = setTimeout(throttledAction, 250); -}; - -const refreshRootSuite = (eraseResults: boolean): Promise => { - if (!eraseResults) - return sendMessage('list', {}); - - let rootSuite: Suite; - const loadErrors: TestError[] = []; - const progress: Progress = { - total: 0, - passed: 0, - failed: 0, - skipped: 0, - }; - let config: FullConfig; - receiver = new TeleReporterReceiver(pathSeparator, { - version: () => 'v2', - - onConfigure: (c: FullConfig) => { - config = c; - // TeleReportReceiver is merging everything into a single suite, so when we - // run one test, we still get many tests via rootSuite.allTests().length. - // To work around that, have a dedicated per-run receiver that will only have - // suite for a single test run, and hence will have correct total. - lastRunReceiver = new TeleReporterReceiver(pathSeparator, { - onBegin: (suite: Suite) => { - lastRunTestCount = suite.allTests().length; - lastRunReceiver = undefined; - } - }, false); - }, - - onBegin: (suite: Suite) => { - if (!rootSuite) - rootSuite = suite; - progress.total = lastRunTestCount; - progress.passed = 0; - progress.failed = 0; - progress.skipped = 0; - throttleUpdateRootSuite(config, rootSuite, loadErrors, progress, true); - }, - - onEnd: () => { - throttleUpdateRootSuite(config, rootSuite, loadErrors, progress, true); - }, - - onTestBegin: () => { - throttleUpdateRootSuite(config, rootSuite, loadErrors, progress); - }, - - onTestEnd: (test: TestCase) => { - if (test.outcome() === 'skipped') - ++progress.skipped; - else if (test.outcome() === 'unexpected') - ++progress.failed; - else - ++progress.passed; - throttleUpdateRootSuite(config, rootSuite, loadErrors, progress); - }, - - onError: (error: TestError) => { - xtermDataSource.write((error.stack || error.value || '') + '\n'); - loadErrors.push(error); - throttleUpdateRootSuite(config, rootSuite ?? new TeleSuite('', 'root'), loadErrors, progress); - }, - - printsToStdio: () => { - return false; - }, - - onStdOut: () => {}, - onStdErr: () => {}, - onExit: () => {}, - onStepBegin: () => {}, - onStepEnd: () => {}, - }, true); - receiver._setClearPreviousResultsWhenTestBegins(); - return sendMessage('list', {}); -}; - -const sendMessageNoReply = (method: string, params?: any) => { - if ((window as any)._overrideProtocolForTest) { - (window as any)._overrideProtocolForTest({ method, params }).catch(() => {}); - return; - } - sendMessage(method, params).catch((e: Error) => { - // eslint-disable-next-line no-console - console.error(e); - }); -}; - -const dispatchEvent = (method: string, params?: any) => { - if (method === 'listChanged') { - refreshRootSuite(false).catch(() => {}); - return; - } - - if (method === 'testFilesChanged') { - runWatchedTests(params.testFileNames); - return; - } - - if (method === 'stdio') { - if (params.buffer) { - const data = atob(params.buffer); - xtermDataSource.write(data); - } else { - xtermDataSource.write(params.text); - } - return; - } - - // The order of receiver dispatches matters here, we want to assign `lastRunTestCount` - // before we use it. - lastRunReceiver?.dispatch({ method, params })?.catch(() => {}); - receiver?.dispatch({ method, params })?.catch(() => {}); -}; - -const outputDirForTestCase = (testCase: TestCase): string | undefined => { - for (let suite: Suite | undefined = testCase.parent; suite; suite = suite.parent) { - if (suite.project()) - return suite.project()?.outputDir; - } - return undefined; -}; - -const locationToOpen = (treeItem?: TreeItem) => { - if (!treeItem) - return; - return treeItem.location.file + ':' + treeItem.location.line; -}; - -const collectTestIds = (treeItem?: TreeItem): Set => { - const testIds = new Set(); - if (!treeItem) - return testIds; - - const visit = (treeItem: TreeItem) => { - if (treeItem.kind === 'case') - treeItem.tests.map(t => t.id).forEach(id => testIds.add(id)); - else if (treeItem.kind === 'test') - testIds.add(treeItem.id); - else - treeItem.children?.forEach(visit); - }; - visit(treeItem); - return testIds; -}; - -type Progress = { - total: number; - passed: number; - failed: number; - skipped: number; -}; - -type TreeItemBase = { - kind: 'root' | 'group' | 'case' | 'test', - id: string; - title: string; - location: Location, - duration: number; - parent: TreeItem | undefined; - children: TreeItem[]; - status: UITestStatus; -}; - -type GroupItem = TreeItemBase & { - kind: 'group'; - subKind: 'folder' | 'file' | 'describe'; - hasLoadErrors: boolean; - children: (TestCaseItem | GroupItem)[]; -}; - -type TestCaseItem = TreeItemBase & { - kind: 'case', - tests: TestCase[]; - children: TestItem[]; -}; - -type TestItem = TreeItemBase & { - kind: 'test', - test: TestCase; - project: string; -}; - -type TreeItem = GroupItem | TestCaseItem | TestItem; - -function getFileItem(rootItem: GroupItem, filePath: string[], isFile: boolean, fileItems: Map): GroupItem { - if (filePath.length === 0) - return rootItem; - const fileName = filePath.join(pathSeparator); - const existingFileItem = fileItems.get(fileName); - if (existingFileItem) - return existingFileItem; - const parentFileItem = getFileItem(rootItem, filePath.slice(0, filePath.length - 1), false, fileItems); - const fileItem: GroupItem = { - kind: 'group', - subKind: isFile ? 'file' : 'folder', - id: fileName, - title: filePath[filePath.length - 1], - location: { file: fileName, line: 0, column: 0 }, - duration: 0, - parent: parentFileItem, - children: [], - status: 'none', - hasLoadErrors: false, - }; - parentFileItem.children.push(fileItem); - fileItems.set(fileName, fileItem); - return fileItem; -} - -function createTree(rootSuite: Suite | undefined, loadErrors: TestError[], projectFilters: Map): GroupItem { - const filterProjects = [...projectFilters.values()].some(Boolean); - const rootItem: GroupItem = { - kind: 'group', - subKind: 'folder', - id: 'root', - title: '', - location: { file: '', line: 0, column: 0 }, - duration: 0, - parent: undefined, - children: [], - status: 'none', - hasLoadErrors: false, - }; - - const visitSuite = (projectName: string, parentSuite: Suite, parentGroup: GroupItem) => { - for (const suite of parentSuite.suites) { - const title = suite.title || ''; - let group = parentGroup.children.find(item => item.kind === 'group' && item.title === title) as GroupItem | undefined; - if (!group) { - group = { - kind: 'group', - subKind: 'describe', - id: 'suite:' + parentSuite.titlePath().join('\x1e') + '\x1e' + title, // account for anonymous suites - title, - location: suite.location!, - duration: 0, - parent: parentGroup, - children: [], - status: 'none', - hasLoadErrors: false, - }; - parentGroup.children.push(group); - } - visitSuite(projectName, suite, group); - } - - for (const test of parentSuite.tests) { - const title = test.title; - let testCaseItem = parentGroup.children.find(t => t.kind !== 'group' && t.title === title) as TestCaseItem; - if (!testCaseItem) { - testCaseItem = { - kind: 'case', - id: 'test:' + test.titlePath().join('\x1e'), - title, - parent: parentGroup, - children: [], - tests: [], - location: test.location, - duration: 0, - status: 'none', - }; - parentGroup.children.push(testCaseItem); - } - - const result = (test as TeleTestCase).results[0]; - let status: 'none' | 'running' | 'scheduled' | 'passed' | 'failed' | 'skipped' = 'none'; - if (result?.statusEx === 'scheduled') - status = 'scheduled'; - else if (result?.statusEx === 'running') - status = 'running'; - else if (result?.status === 'skipped') - status = 'skipped'; - else if (result?.status === 'interrupted') - status = 'none'; - else if (result && test.outcome() !== 'expected') - status = 'failed'; - else if (result && test.outcome() === 'expected') - status = 'passed'; - - testCaseItem.tests.push(test); - testCaseItem.children.push({ - kind: 'test', - id: test.id, - title: projectName, - location: test.location!, - test, - parent: testCaseItem, - children: [], - status, - duration: test.results.length ? Math.max(0, test.results[0].duration) : 0, - project: projectName, - }); - testCaseItem.duration = (testCaseItem.children as TestItem[]).reduce((a, b) => a + b.duration, 0); - } - }; - - const fileMap = new Map(); - for (const projectSuite of rootSuite?.suites || []) { - if (filterProjects && !projectFilters.get(projectSuite.title)) - continue; - for (const fileSuite of projectSuite.suites) { - const fileItem = getFileItem(rootItem, fileSuite.location!.file.split(pathSeparator), true, fileMap); - visitSuite(projectSuite.title, fileSuite, fileItem); - } - } - - for (const loadError of loadErrors) { - if (!loadError.location) - continue; - const fileItem = getFileItem(rootItem, loadError.location.file.split(pathSeparator), true, fileMap); - fileItem.hasLoadErrors = true; - } - return rootItem; -} - -function filterTree(rootItem: GroupItem, filterText: string, statusFilters: Map, runningTestIds: Set | undefined) { - const tokens = filterText.trim().toLowerCase().split(' '); - const filtersStatuses = [...statusFilters.values()].some(Boolean); - - const filter = (testCase: TestCaseItem) => { - const title = testCase.tests[0].titlePath().join(' ').toLowerCase(); - if (!tokens.every(token => title.includes(token)) && !testCase.tests.some(t => runningTestIds?.has(t.id))) - return false; - testCase.children = (testCase.children as TestItem[]).filter(test => { - return !filtersStatuses || runningTestIds?.has(test.test.id) || statusFilters.get(test.status); - }); - testCase.tests = (testCase.children as TestItem[]).map(c => c.test); - return !!testCase.children.length; - }; - - const visit = (treeItem: GroupItem) => { - const newChildren: (GroupItem | TestCaseItem)[] = []; - for (const child of treeItem.children) { - if (child.kind === 'case') { - if (filter(child)) - newChildren.push(child); - } else { - visit(child); - if (child.children.length || child.hasLoadErrors) - newChildren.push(child); - } - } - treeItem.children = newChildren; - }; - visit(rootItem); -} - -function sortAndPropagateStatus(treeItem: TreeItem) { - for (const child of treeItem.children) - sortAndPropagateStatus(child); - - if (treeItem.kind === 'group') { - treeItem.children.sort((a, b) => { - const fc = a.location.file.localeCompare(b.location.file); - return fc || a.location.line - b.location.line; - }); - } - - let allPassed = treeItem.children.length > 0; - let allSkipped = treeItem.children.length > 0; - let hasFailed = false; - let hasRunning = false; - let hasScheduled = false; - - for (const child of treeItem.children) { - allSkipped = allSkipped && child.status === 'skipped'; - allPassed = allPassed && (child.status === 'passed' || child.status === 'skipped'); - hasFailed = hasFailed || child.status === 'failed'; - hasRunning = hasRunning || child.status === 'running'; - hasScheduled = hasScheduled || child.status === 'scheduled'; - } - - if (hasRunning) - treeItem.status = 'running'; - else if (hasScheduled) - treeItem.status = 'scheduled'; - else if (hasFailed) - treeItem.status = 'failed'; - else if (allSkipped) - treeItem.status = 'skipped'; - else if (allPassed) - treeItem.status = 'passed'; -} - -function shortenRoot(rootItem: GroupItem): GroupItem { - let shortRoot = rootItem; - while (shortRoot.children.length === 1 && shortRoot.children[0].kind === 'group' && shortRoot.children[0].subKind === 'folder') - shortRoot = shortRoot.children[0]; - shortRoot.location = rootItem.location; - return shortRoot; -} - -function hideOnlyTests(rootItem: GroupItem) { - const visit = (treeItem: TreeItem) => { - if (treeItem.kind === 'case' && treeItem.children.length === 1) - treeItem.children = []; - else - treeItem.children.forEach(visit); - }; - visit(rootItem); -} - -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); -} - -const pathSeparator = navigator.userAgent.toLowerCase().includes('windows') ? '\\' : '/'; diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index dc08b95fff..c50b345b75 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -43,7 +43,6 @@ import type { UITestStatus } from './testUtils'; export const Workbench: React.FunctionComponent<{ model?: MultiTraceModel, - hideStackFrames?: boolean, showSourcesFirst?: boolean, rootDir?: string, fallbackLocation?: modelUtil.SourceLocation, @@ -51,7 +50,8 @@ export const Workbench: React.FunctionComponent<{ onSelectionChanged?: (action: ActionTraceEventInContext) => void, isLive?: boolean, status?: UITestStatus, -}> = ({ model, hideStackFrames, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, status }) => { + inert?: boolean, +}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, status, inert }) => { const [selectedAction, setSelectedActionImpl] = React.useState(undefined); const [revealedStack, setRevealedStack] = React.useState(undefined); const [highlightedAction, setHighlightedAction] = React.useState(); @@ -158,8 +158,8 @@ export const Workbench: React.FunctionComponent<{ render: () => }; const consoleTab: TabbedPaneTabModel = { @@ -214,7 +214,7 @@ export const Workbench: React.FunctionComponent<{ else if (model && model.wallTime) time = Date.now() - model.wallTime; - return
+ return
= () => { @@ -84,17 +84,14 @@ export const WorkbenchLoader: React.FunctionComponent<{ } if (params.has('isServer')) { - connect({ - onEvent(method: string, params?: any) { - if (method === 'loadTrace') { - setTraceURLs(params!.url ? [params!.url] : []); - setDragOver(false); - setProcessingErrorMessage(null); - } - }, - onClose() {} - }).then(sendMessage => { - sendMessage('ready'); + const guid = new URLSearchParams(window.location.search).get('ws'); + const wsURL = new URL(`../${guid}`, window.location.toString()); + wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:'); + const testServerConnection = new TestServerConnection(wsURL.toString()); + testServerConnection.onLoadTraceRequested(async params => { + setTraceURLs(params.traceUrl ? [params.traceUrl] : []); + setDragOver(false); + setProcessingErrorMessage(null); }); } else if (!newTraceURLs.some(url => url.startsWith('blob:'))) { // Don't re-use blob file URLs on page load (results in Fetch error) @@ -137,8 +134,10 @@ export const WorkbenchLoader: React.FunctionComponent<{ })(); }, [isServer, traceURLs, uploadedTraceNames]); + const showFileUploadDropArea = !!(!isServer && !dragOver && !fileForLocalModeError && (!traceURLs.length || processingErrorMessage)); + return
{ event.preventDefault(); setDragOver(true); }}> -
+
Playwright logo
@@ -150,7 +149,7 @@ export const WorkbenchLoader: React.FunctionComponent<{
- + {fileForLocalModeError &&
Trace Viewer uses Service Workers to show traces. To view trace:
@@ -159,9 +158,9 @@ export const WorkbenchLoader: React.FunctionComponent<{
3. Drop the trace from the download shelf into the page
} - {!isServer && !dragOver && !fileForLocalModeError && (!traceURLs.length || processingErrorMessage) &&
-
{processingErrorMessage}
-
Drop Playwright Trace to load
+ {showFileUploadDropArea &&
+
{processingErrorMessage}
+
Drop Playwright Trace to load
or
+ }} type='button'>Select file(s)
Playwright Trace Viewer is a Progressive Web App, it does not send your trace anywhere, it opens it locally.
} diff --git a/packages/trace-viewer/src/ui/wsPort.ts b/packages/trace-viewer/src/ui/wsPort.ts deleted file mode 100644 index fc08d4cdf4..0000000000 --- a/packages/trace-viewer/src/ui/wsPort.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -let lastId = 0; -let _ws: WebSocket; -const callbacks = new Map void, reject: (arg: Error) => void }>(); - -export async function connect(options: { onEvent: (method: string, params?: any) => void, onClose: () => void }): Promise<(method: string, params?: any) => Promise> { - const guid = new URLSearchParams(window.location.search).get('ws'); - const ws = new WebSocket(`${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.hostname}:${window.location.port}/${guid}`); - await new Promise(f => ws.addEventListener('open', f)); - ws.addEventListener('close', options.onClose); - ws.addEventListener('message', event => { - const message = JSON.parse(event.data); - const { id, result, error, method, params } = message; - if (id) { - const callback = callbacks.get(id); - if (!callback) - return; - callbacks.delete(id); - if (error) - callback.reject(new Error(error)); - else - callback.resolve(result); - } else { - options.onEvent(method, params); - } - }); - _ws = ws; - setInterval(() => sendMessage('ping').catch(() => {}), 30000); - return sendMessage; -} - -const sendMessage = async (method: string, params?: any): Promise => { - const id = ++lastId; - const message = { id, method, params }; - _ws.send(JSON.stringify(message)); - return new Promise((resolve, reject) => { - callbacks.set(id, { resolve, reject }); - }); -}; diff --git a/packages/web/src/components/listView.tsx b/packages/web/src/components/listView.tsx index a9bd9f8fb3..323e2cf506 100644 --- a/packages/web/src/components/listView.tsx +++ b/packages/web/src/components/listView.tsx @@ -85,7 +85,7 @@ export function ListView({ itemListRef.current.scrollTop = scrollPositions.get(name) || 0; }, [name]); - return
0 ? 'list' : undefined} data-testid={dataTestId || (name + '-list')}> + return
0 ? 'list' : undefined} data-testid={dataTestId || (name + '-list')}>
; export default function CheckChildrenProp(props: DefaultChildrenProps) { - const content = 'children' in props ? props.children : 'No Children'; - return
-

Welcome!

-
- {content} -
-
- Thanks for visiting. -
-
+ return <>{'children' in props ? props.children : 'No Children'} } diff --git a/tests/components/ct-react-vite/tests/children.spec.tsx b/tests/components/ct-react-vite/tests/children.spec.tsx index d671b14e64..03da7d2c55 100644 --- a/tests/components/ct-react-vite/tests/children.spec.tsx +++ b/tests/components/ct-react-vite/tests/children.spec.tsx @@ -60,7 +60,7 @@ test('render number as child', async ({ mount }) => { await expect(component).toContainText('1337'); }); -test('render without children', async ({ mount }) => { +test('absence of children when children prop is not provided', async ({ mount }) => { const component = await mount(); await expect(component).toContainText('No Children'); }); diff --git a/tests/components/ct-react-vite/tests/unmount.spec.tsx b/tests/components/ct-react-vite/tests/unmount.spec.tsx index acc11c4fdb..20374d8b8e 100644 --- a/tests/components/ct-react-vite/tests/unmount.spec.tsx +++ b/tests/components/ct-react-vite/tests/unmount.spec.tsx @@ -17,3 +17,9 @@ test('unmount a multi root component', async ({ mount, page }) => { await expect(page.locator('#root')).not.toContainText('root 1'); await expect(page.locator('#root')).not.toContainText('root 2'); }); + +test('unmount twice throws an error', async ({ mount }) => { + const component = await mount(`); + await page.locator('button').evaluate(button => { + window['clicks'] = 0; + button.addEventListener('click', () => ++window['clicks'], false); + }); + return page; + }; + + const clickInPage = async (page, count) => { + for (let i = 0; i < count; ++i) + await page.locator('button').click(); + }; + + const getClicks = async page => page.evaluate(() => window['clicks']); + + const page1 = await createPage(); + const page2 = await createPage(); + + const CLICK_COUNT = 20; + await Promise.all([ + clickInPage(page1, CLICK_COUNT), + clickInPage(page2, CLICK_COUNT), + ]); + expect(await getClicks(page1)).toBe(CLICK_COUNT); + expect(await getClicks(page2)).toBe(CLICK_COUNT); + + await page1.close(); + await page2.close(); +}); + it('window.open should use parent tab context', async function({ browser, server }) { const context = await browser.newContext(); const page = await context.newPage(); diff --git a/tests/library/browsercontext-remove-cookies.spec.ts b/tests/library/browsercontext-remove-cookies.spec.ts new file mode 100644 index 0000000000..f1524e6ef1 --- /dev/null +++ b/tests/library/browsercontext-remove-cookies.spec.ts @@ -0,0 +1,231 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * Modifications 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 { contextTest as it, expect } from '../config/browserTest'; + +it('should remove cookies by name', async ({ context, page, server }) => { + await context.addCookies([{ + name: 'cookie1', + value: '1', + domain: new URL(server.PREFIX).hostname, + path: '/', + }, + { + name: 'cookie2', + value: '2', + domain: new URL(server.PREFIX).hostname, + path: '/', + } + ]); + await page.goto(server.PREFIX); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1; cookie2=2'); + await context.removeCookies({ name: 'cookie1' }); + expect(await page.evaluate('document.cookie')).toBe('cookie2=2'); +}); + +it('should remove cookies by domain', async ({ context, page, server }) => { + await context.addCookies([{ + name: 'cookie1', + value: '1', + domain: new URL(server.PREFIX).hostname, + path: '/', + }, + { + name: 'cookie2', + value: '2', + domain: new URL(server.CROSS_PROCESS_PREFIX).hostname, + path: '/', + } + ]); + await page.goto(server.PREFIX); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); + await page.goto(server.CROSS_PROCESS_PREFIX); + expect(await page.evaluate('document.cookie')).toBe('cookie2=2'); + await context.removeCookies({ domain: new URL(server.CROSS_PROCESS_PREFIX).hostname }); + expect(await page.evaluate('document.cookie')).toBe(''); + await page.goto(server.PREFIX); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); +}); + +it('should remove cookies by path', async ({ context, page, server }) => { + await context.addCookies([{ + name: 'cookie1', + value: '1', + domain: new URL(server.PREFIX).hostname, + path: '/api/v1', + }, + { + name: 'cookie2', + value: '2', + domain: new URL(server.PREFIX).hostname, + path: '/api/v2', + }, + { + name: 'cookie3', + value: '3', + domain: new URL(server.PREFIX).hostname, + path: '/', + } + ]); + await page.goto(server.PREFIX + '/api/v1'); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1; cookie3=3'); + await context.removeCookies({ path: '/api/v1' }); + expect(await page.evaluate('document.cookie')).toBe('cookie3=3'); + await page.goto(server.PREFIX + '/api/v2'); + expect(await page.evaluate('document.cookie')).toBe('cookie2=2; cookie3=3'); + await page.goto(server.PREFIX + '/'); + expect(await page.evaluate('document.cookie')).toBe('cookie3=3'); +}); + +it('should remove cookies by name and domain', async ({ context, page, server }) => { + await context.addCookies([{ + name: 'cookie1', + value: '1', + domain: new URL(server.PREFIX).hostname, + path: '/', + }, + { + name: 'cookie1', + value: '1', + domain: new URL(server.CROSS_PROCESS_PREFIX).hostname, + path: '/', + } + ]); + await page.goto(server.PREFIX); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); + await context.removeCookies({ name: 'cookie1', domain: new URL(server.PREFIX).hostname }); + expect(await page.evaluate('document.cookie')).toBe(''); + await page.goto(server.CROSS_PROCESS_PREFIX); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); +}); + +it('should remove cookies by name and path', async ({ context, page, server }) => { + await context.addCookies([{ + name: 'cookie1', + value: '1', + domain: new URL(server.PREFIX).hostname, + path: '/api/v1', + }, + { + name: 'cookie1', + value: '1', + domain: new URL(server.PREFIX).hostname, + path: '/api/v2', + }, + { + name: 'cookie3', + value: '3', + domain: new URL(server.PREFIX).hostname, + path: '/', + } + ]); + await page.goto(server.PREFIX + '/api/v1'); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1; cookie3=3'); + await context.removeCookies({ name: 'cookie1', path: '/api/v1' }); + expect(await page.evaluate('document.cookie')).toBe('cookie3=3'); + await page.goto(server.PREFIX + '/api/v2'); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1; cookie3=3'); + await page.goto(server.PREFIX + '/'); + expect(await page.evaluate('document.cookie')).toBe('cookie3=3'); +}); + +it('should remove cookies by domain and path', async ({ context, page, server }) => { + await context.addCookies([{ + name: 'cookie1', + value: '1', + domain: new URL(server.PREFIX).hostname, + path: '/api/v1', + }, + { + name: 'cookie2', + value: '2', + domain: new URL(server.PREFIX).hostname, + path: '/api/v2', + }, + { + name: 'cookie3', + value: '3', + domain: new URL(server.CROSS_PROCESS_PREFIX).hostname, + path: '/api/v1', + }, + { + name: 'cookie4', + value: '4', + domain: new URL(server.CROSS_PROCESS_PREFIX).hostname, + path: '/api/v2', + } + ]); + await page.goto(server.PREFIX + '/api/v1'); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); + await context.removeCookies({ domain: new URL(server.PREFIX).hostname, path: '/api/v1' }); + expect(await page.evaluate('document.cookie')).toBe(''); + await page.goto(server.PREFIX + '/api/v2'); + expect(await page.evaluate('document.cookie')).toBe('cookie2=2'); + await page.goto(server.CROSS_PROCESS_PREFIX + '/api/v2'); + expect(await page.evaluate('document.cookie')).toBe('cookie4=4'); +}); + +it('should remove cookies by name, domain and path', async ({ context, page, server }) => { + await context.addCookies([{ + name: 'cookie1', + value: '1', + domain: new URL(server.PREFIX).hostname, + path: '/api/v1', + }, + { + name: 'cookie2', + value: '2', + domain: new URL(server.PREFIX).hostname, + path: '/api/v2', + }, + { + name: 'cookie1', + value: '1', + domain: new URL(server.CROSS_PROCESS_PREFIX).hostname, + path: '/api/v1', + }, + ]); + await page.goto(server.PREFIX + '/api/v1'); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); + await context.removeCookies({ name: 'cookie1', domain: new URL(server.PREFIX).hostname, path: '/api/v1' }); + expect(await page.evaluate('document.cookie')).toBe(''); + await page.goto(server.PREFIX + '/api/v2'); + expect(await page.evaluate('document.cookie')).toBe('cookie2=2'); + await page.goto(server.CROSS_PROCESS_PREFIX + '/api/v1'); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); +}); + +it('should throw if empty object is passed', async ({ context, page, server }) => { + await context.addCookies([{ + name: 'cookie1', + value: '1', + domain: new URL(server.PREFIX).hostname, + path: '/', + }, + { + name: 'cookie2', + value: '2', + domain: new URL(server.PREFIX).hostname, + path: '/', + }, + ]); + await page.goto(server.PREFIX + '/'); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1; cookie2=2'); + const error = await context.removeCookies({ }).catch(e => e); + expect(error.message).toContain(`Either name, domain or path are required`); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1; cookie2=2'); +}); diff --git a/tests/library/browsercontext-reuse.spec.ts b/tests/library/browsercontext-reuse.spec.ts index c25d66d588..057bc1d223 100644 --- a/tests/library/browsercontext-reuse.spec.ts +++ b/tests/library/browsercontext-reuse.spec.ts @@ -15,13 +15,16 @@ */ import { browserTest, expect } from '../config/browserTest'; -import type { BrowserContext } from '@playwright/test'; +import type { BrowserContext, BrowserContextOptions } from '@playwright/test'; -const test = browserTest.extend<{ reusedContext: () => Promise }>({ +const test = browserTest.extend<{ reusedContext: (options?: BrowserContextOptions) => Promise }>({ reusedContext: async ({ browserType, browser }, use) => { - await use(async () => { + await use(async (options: BrowserContextOptions = {}) => { const defaultContextOptions = (browserType as any)._defaultContextOptions; - const context = await (browser as any)._newContextForReuse(defaultContextOptions); + const context = await (browser as any)._newContextForReuse({ + ...defaultContextOptions, + ...options, + }); return context; }); }, @@ -235,6 +238,33 @@ test('should reset mouse position', async ({ reusedContext, browserName, platfor await expect(page.locator('#two')).toHaveCSS('background-color', 'rgb(0, 0, 255)'); }); +test('should reset Origin Private File System', async ({ reusedContext, httpsServer, browserName }) => { + test.skip(browserName === 'webkit', 'getDirectory is not supported in ephemeral context in WebKit https://github.com/microsoft/playwright/issues/18235#issuecomment-1289792576'); + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29901' }); + + let context = await reusedContext({ ignoreHTTPSErrors: true }); + let page = await context.newPage(); + await page.goto(httpsServer.EMPTY_PAGE); + await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + await root.getDirectoryHandle('someDirectoryName', { create: true }); + await root.getFileHandle('foo.txt', { create: true }); + }); + + context = await reusedContext({ ignoreHTTPSErrors: true }); + page = await context.newPage(); + await page.goto(httpsServer.EMPTY_PAGE); + const { directoryExits, fileExits } = await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + let directoryExits = true, fileExits = true; + await root.getDirectoryHandle('someDirectoryName').catch(() => { directoryExits = false; }); + await root.getFileHandle('foo.txt').catch(() => { fileExits = false; }); + return { directoryExits, fileExits }; + }); + expect(directoryExits).toBe(false); + expect(fileExits).toBe(false); +}); + test('should reset tracing', async ({ reusedContext, trace }, testInfo) => { test.skip(trace === 'on'); diff --git a/tests/library/browsercontext-storage-state.spec.ts b/tests/library/browsercontext-storage-state.spec.ts index 2e4fa6b050..7bd2581c9a 100644 --- a/tests/library/browsercontext-storage-state.spec.ts +++ b/tests/library/browsercontext-storage-state.spec.ts @@ -34,17 +34,17 @@ it('should capture local storage', async ({ contextFactory }) => { }); const { origins } = await context.storageState(); expect(origins).toEqual([{ - origin: 'https://www.example.com', - localStorage: [{ - name: 'name1', - value: 'value1' - }], - }, { origin: 'https://www.domain.com', localStorage: [{ name: 'name2', value: 'value2' }], + }, { + origin: 'https://www.example.com', + localStorage: [{ + name: 'name1', + value: 'value1' + }], }]); }); @@ -222,3 +222,56 @@ it('should serialize storageState with lone surrogates', async ({ page, context, const storageState = await context.storageState(); expect(storageState.origins[0].localStorage[0].value).toBe(String.fromCharCode(55934)); }); + +it('should work when service worker is intefering', async ({ page, context, server, isAndroid, isElectron }) => { + it.skip(isAndroid); + it.skip(isElectron); + + server.setRoute('/', (req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }); + res.end(` + + `); + }); + + server.setRoute('/sw.js', (req, res) => { + res.writeHead(200, { 'content-type': 'application/javascript' }); + res.end(` + const kHtmlPage = \` + + \`; + + console.log('from sw 1'); + self.addEventListener('fetch', event => { + console.log('fetching ' + event.request.url); + const blob = new Blob([kHtmlPage], { type: 'text/html' }); + const response = new Response(blob, { status: 200 , statusText: 'OK' }); + event.respondWith(response); + }); + + self.addEventListener('activate', event => { + console.log('from sw 2'); + event.waitUntil(clients.claim()); + }); + `); + }); + + await page.goto(server.PREFIX); + await page.evaluate(() => window['activationPromise']); + + const storageState = await context.storageState(); + expect(storageState.origins[0].localStorage[0]).toEqual({ name: 'foo', value: 'bar' }); +}); diff --git a/tests/library/capabilities.spec.ts b/tests/library/capabilities.spec.ts index 40db91fdfa..d713260f5e 100644 --- a/tests/library/capabilities.spec.ts +++ b/tests/library/capabilities.spec.ts @@ -302,3 +302,100 @@ it('Intl.ListFormat should work', async ({ page, server, browserName }) => { }); expect(formatted).toBe('first, second, or third'); }); + +it('service worker should cover the iframe', async ({ page, server }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29267' }); + + server.setRoute('/sw.html', (req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }).end(` + + `); + }); + + server.setRoute('/iframe.html', (req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }).end(`
from the server
`); + }); + + server.setRoute('/sw.js', (req, res) => { + res.writeHead(200, { 'content-type': 'application/javascript' }).end(` + const kIframeHtml = "
from the service worker
"; + + self.addEventListener('fetch', event => { + if (event.request.url.endsWith('iframe.html')) { + const blob = new Blob([kIframeHtml], { type: 'text/html' }); + const response = new Response(blob, { status: 200 , statusText: 'OK' }); + event.respondWith(response); + return; + } + event.respondWith(fetch(event.request)); + }); + + self.addEventListener('activate', event => { + event.waitUntil(clients.claim()); + }); + `); + }); + + await page.goto(server.PREFIX + '/sw.html'); + await page.evaluate(() => window['activationPromise']); + + await page.evaluate(() => { + const iframe = document.createElement('iframe'); + iframe.src = '/iframe.html'; + document.body.appendChild(iframe); + }); + + await expect(page.frameLocator('iframe').locator('div')).toHaveText('from the service worker'); +}); + +it('service worker should register in an iframe', async ({ page, server }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29267' }); + + server.setRoute('/main.html', (req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }).end(` + + `); + }); + + server.setRoute('/dir/iframe.html', (req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }).end(` + + `); + }); + + server.setRoute('/dir/sw.js', (req, res) => { + res.writeHead(200, { 'content-type': 'application/javascript' }).end(` + const kIframeHtml = "
from the service worker
"; + + self.addEventListener('fetch', event => { + if (event.request.url.endsWith('html')) { + event.respondWith(fetch(event.request)); + return; + } + const blob = new Blob(['responseFromServiceWorker'], { type: 'text/plain' }); + const response = new Response(blob, { status: 200 , statusText: 'OK' }); + event.respondWith(response); + }); + + self.addEventListener('activate', event => { + event.waitUntil(clients.claim()); + }); + `); + }); + + await page.goto(server.PREFIX + '/main.html'); + const iframe = page.frames()[1]; + await iframe.evaluate(() => window['activationPromise']); + + const response = await iframe.evaluate(async () => { + const response = await fetch('foo.txt'); + return response.text(); + }); + expect(response).toBe('responseFromServiceWorker'); +}); diff --git a/tests/library/debug-controller.spec.ts b/tests/library/debug-controller.spec.ts index 779a4a3d09..2da0ee1bf3 100644 --- a/tests/library/debug-controller.spec.ts +++ b/tests/library/debug-controller.spec.ts @@ -19,11 +19,12 @@ import { PlaywrightServer } from '../../packages/playwright-core/lib/remote/play import { createGuid } from '../../packages/playwright-core/lib/utils/crypto'; import { Backend } from '../config/debugControllerBackend'; import type { Browser, BrowserContext } from '@playwright/test'; +import type * as channels from '@protocol/channels'; type BrowserWithReuse = Browser & { _newContextForReuse: () => Promise }; type Fixtures = { wsEndpoint: string; - backend: Backend; + backend: channels.DebugControllerChannel; connectedBrowserFactory: () => Promise; connectedBrowser: BrowserWithReuse; }; @@ -40,7 +41,7 @@ const test = baseTest.extend({ const backend = new Backend(); await backend.connect(wsEndpoint); await backend.initialize(); - await use(backend); + await use(backend.channel); await backend.close(); }, connectedBrowserFactory: async ({ wsEndpoint, browserType }, use) => { @@ -70,7 +71,7 @@ test('should pick element', async ({ backend, connectedBrowser }) => { const events = []; backend.on('inspectRequested', event => events.push(event)); - await backend.setMode({ mode: 'inspecting' }); + await backend.setRecorderMode({ mode: 'inspecting' }); const context = await connectedBrowser._newContextForReuse(); const [page] = context.pages(); @@ -90,7 +91,7 @@ test('should pick element', async ({ backend, connectedBrowser }) => { ]); // No events after mode disabled - await backend.setMode({ mode: 'none' }); + await backend.setRecorderMode({ mode: 'none' }); await page.locator('body').click(); expect(events).toHaveLength(2); }); @@ -163,7 +164,7 @@ test('should record', async ({ backend, connectedBrowser }) => { const events = []; backend.on('sourceChanged', event => events.push(event)); - await backend.setMode({ mode: 'recording' }); + await backend.setRecorderMode({ mode: 'recording' }); const context = await connectedBrowser._newContextForReuse(); const [page] = context.pages(); @@ -189,7 +190,7 @@ test('test', async ({ page }) => { }); const length = events.length; // No events after mode disabled - await backend.setMode({ mode: 'none' }); + await backend.setRecorderMode({ mode: 'none' }); await page.getByRole('button').click(); expect(events).toHaveLength(length); }); @@ -207,7 +208,7 @@ test('should record custom data-testid', async ({ backend, connectedBrowser }) = await page.setContent(`
One
`); // 2. "Record at cursor". - await backend.setMode({ mode: 'recording', testIdAttributeName: 'data-custom-id' }); + await backend.setRecorderMode({ mode: 'recording', testIdAttributeName: 'data-custom-id' }); // 3. Record a click action. await page.locator('div').click(); diff --git a/tests/library/inspector/cli-codegen-1.spec.ts b/tests/library/inspector/cli-codegen-1.spec.ts index a0c5596eea..a54ae4d421 100644 --- a/tests/library/inspector/cli-codegen-1.spec.ts +++ b/tests/library/inspector/cli-codegen-1.spec.ts @@ -817,4 +817,28 @@ await page.GetByRole(AriaRole.Slider).FillAsync("10");`); expect.soft(sources.get('C#')!.text).toContain(` await page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).ClickAsync();`); }); + + test('should record omnibox navigations after performAction', async ({ page, openRecorder, server }) => { + const recorder = await openRecorder(); + await recorder.setContentAndWait(``); + await Promise.all([ + recorder.waitForOutput('JavaScript', 'click'), + page.locator('button').click(), + ]); + await page.waitForTimeout(500); + await page.goto(server.PREFIX + `/empty.html`); + await recorder.waitForOutput('JavaScript', `await page.goto('${server.PREFIX}/empty.html');`); + }); + + test('should record omnibox navigations after recordAction', async ({ page, openRecorder, server }) => { + const recorder = await openRecorder(); + await recorder.setContentAndWait(``); + await Promise.all([ + recorder.waitForOutput('JavaScript', 'fill'), + page.locator('textarea').fill('Hello world'), + ]); + await page.waitForTimeout(500); + await page.goto(server.PREFIX + `/empty.html`); + await recorder.waitForOutput('JavaScript', `await page.goto('${server.PREFIX}/empty.html');`); + }); }); diff --git a/tests/library/inspector/cli-codegen-3.spec.ts b/tests/library/inspector/cli-codegen-3.spec.ts index 96a6295f13..4afa8dd51d 100644 --- a/tests/library/inspector/cli-codegen-3.spec.ts +++ b/tests/library/inspector/cli-codegen-3.spec.ts @@ -648,4 +648,18 @@ 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 assert screenshot', async ({ openRecorder }) => { + const recorder = await openRecorder(); + await recorder.setContentAndWait(`
Hello, world
`); + const [sources] = await Promise.all([ + recorder.waitForOutput('JavaScript', 'toHaveScreenshot'), + recorder.page.click('x-pw-tool-item.screenshot'), + ]); + expect.soft(sources.get('JavaScript')!.text).toContain(`await expect(page).toHaveScreenshot()`); + expect.soft(sources.get('Python')!.text).toContain(`# assert_screenshot(page.screenshot())`); + expect.soft(sources.get('Python Async')!.text).toContain(`# assert_screenshot(await page.screenshot())`); + expect.soft(sources.get('Java')!.text).toContain(`// assertScreenshot(page.screenshot());`); + expect.soft(sources.get('C#')!.text).toContain(`// AssertScreenshot(await page.ScreenshotAsync())`); + }); }); diff --git a/tests/library/inspector/cli-codegen-pytest.spec.ts b/tests/library/inspector/cli-codegen-pytest.spec.ts index 42a1eefa2e..e1bd608ef5 100644 --- a/tests/library/inspector/cli-codegen-pytest.spec.ts +++ b/tests/library/inspector/cli-codegen-pytest.spec.ts @@ -22,7 +22,8 @@ const emptyHTML = new URL('file://' + path.join(__dirname, '..', '..', 'assets', test('should print the correct imports and context options', async ({ runCLI }) => { const cli = runCLI(['--target=python-pytest', emptyHTML]); - const expectedResult = `from playwright.sync_api import Page, expect + const expectedResult = `import re +from playwright.sync_api import Page, expect def test_example(page: Page) -> None:`; @@ -39,7 +40,7 @@ test('should print the correct context options when using a device and lang', as await cli.waitForCleanExit(); const content = fs.readFileSync(tmpFile); expect(content.toString()).toBe(`import pytest - +import re from playwright.sync_api import Page, expect @@ -60,7 +61,8 @@ test('should save the codegen output to a file if specified', async ({ runCLI }, }); await cli.waitForCleanExit(); const content = fs.readFileSync(tmpFile); - expect(content.toString()).toBe(`from playwright.sync_api import Page, expect + expect(content.toString()).toBe(`import re +from playwright.sync_api import Page, expect def test_example(page: Page) -> None: diff --git a/tests/library/inspector/cli-codegen-python-async.spec.ts b/tests/library/inspector/cli-codegen-python-async.spec.ts index d765d55d9c..02647c5508 100644 --- a/tests/library/inspector/cli-codegen-python-async.spec.ts +++ b/tests/library/inspector/cli-codegen-python-async.spec.ts @@ -26,7 +26,7 @@ const launchOptions = (channel: string) => { test('should print the correct imports and context options', async ({ browserName, channel, runCLI }) => { const cli = runCLI(['--target=python-async', emptyHTML]); const expectedResult = `import asyncio - +import re from playwright.async_api import Playwright, async_playwright, expect @@ -39,7 +39,7 @@ async def run(playwright: Playwright) -> None: test('should print the correct context options for custom settings', async ({ browserName, channel, runCLI }) => { const cli = runCLI(['--color-scheme=light', '--target=python-async', emptyHTML]); const expectedResult = `import asyncio - +import re from playwright.async_api import Playwright, async_playwright, expect @@ -54,7 +54,7 @@ test('should print the correct context options when using a device', async ({ br const cli = runCLI(['--device=Pixel 2', '--target=python-async', emptyHTML]); const expectedResult = `import asyncio - +import re from playwright.async_api import Playwright, async_playwright, expect @@ -69,7 +69,7 @@ test('should print the correct context options when using a device and additiona const cli = runCLI(['--color-scheme=light', '--device=iPhone 11', '--target=python-async', emptyHTML]); const expectedResult = `import asyncio - +import re from playwright.async_api import Playwright, async_playwright, expect @@ -87,7 +87,7 @@ test('should save the codegen output to a file if specified', async ({ browserNa await cli.waitForCleanExit(); const content = fs.readFileSync(tmpFile); expect(content.toString()).toBe(`import asyncio - +import re from playwright.async_api import Playwright, async_playwright, expect @@ -118,7 +118,7 @@ test('should print load/save storage_state', async ({ browserName, channel, runC await fs.promises.writeFile(loadFileName, JSON.stringify({ cookies: [], origins: [] }), 'utf8'); const cli = runCLI([`--load-storage=${loadFileName}`, `--save-storage=${saveFileName}`, '--target=python-async', emptyHTML]); const expectedResult1 = `import asyncio - +import re from playwright.async_api import Playwright, async_playwright, expect diff --git a/tests/library/inspector/cli-codegen-python.spec.ts b/tests/library/inspector/cli-codegen-python.spec.ts index 5daa340a28..2bccbf3d93 100644 --- a/tests/library/inspector/cli-codegen-python.spec.ts +++ b/tests/library/inspector/cli-codegen-python.spec.ts @@ -25,7 +25,8 @@ const launchOptions = (channel: string) => { test('should print the correct imports and context options', async ({ runCLI, channel, browserName }) => { const cli = runCLI(['--target=python', emptyHTML]); - const expectedResult = `from playwright.sync_api import Playwright, sync_playwright, expect + const expectedResult = `import re +from playwright.sync_api import Playwright, sync_playwright, expect def run(playwright: Playwright) -> None: @@ -36,7 +37,8 @@ def run(playwright: Playwright) -> None: test('should print the correct context options for custom settings', async ({ runCLI, channel, browserName }) => { const cli = runCLI(['--color-scheme=light', '--target=python', emptyHTML]); - const expectedResult = `from playwright.sync_api import Playwright, sync_playwright, expect + const expectedResult = `import re +from playwright.sync_api import Playwright, sync_playwright, expect def run(playwright: Playwright) -> None: @@ -49,7 +51,8 @@ test('should print the correct context options when using a device', async ({ br test.skip(browserName !== 'chromium'); const cli = runCLI(['--device=Pixel 2', '--target=python', emptyHTML]); - const expectedResult = `from playwright.sync_api import Playwright, sync_playwright, expect + const expectedResult = `import re +from playwright.sync_api import Playwright, sync_playwright, expect def run(playwright: Playwright) -> None: @@ -62,7 +65,8 @@ test('should print the correct context options when using a device and additiona test.skip(browserName !== 'webkit'); const cli = runCLI(['--color-scheme=light', '--device=iPhone 11', '--target=python', emptyHTML]); - const expectedResult = `from playwright.sync_api import Playwright, sync_playwright, expect + const expectedResult = `import re +from playwright.sync_api import Playwright, sync_playwright, expect def run(playwright: Playwright) -> None: @@ -78,7 +82,8 @@ test('should save the codegen output to a file if specified', async ({ runCLI, c }); await cli.waitForCleanExit(); const content = fs.readFileSync(tmpFile); - expect(content.toString()).toBe(`from playwright.sync_api import Playwright, sync_playwright, expect + expect(content.toString()).toBe(`import re +from playwright.sync_api import Playwright, sync_playwright, expect def run(playwright: Playwright) -> None: @@ -103,7 +108,8 @@ test('should print load/save storage_state', async ({ runCLI, channel, browserNa const saveFileName = testInfo.outputPath('save.json'); await fs.promises.writeFile(loadFileName, JSON.stringify({ cookies: [], origins: [] }), 'utf8'); const cli = runCLI([`--load-storage=${loadFileName}`, `--save-storage=${saveFileName}`, '--target=python', emptyHTML]); - const expectedResult1 = `from playwright.sync_api import Playwright, sync_playwright, expect + const expectedResult1 = `import re +from playwright.sync_api import Playwright, sync_playwright, expect def run(playwright: Playwright) -> None: diff --git a/tests/library/playwright.config.ts b/tests/library/playwright.config.ts index 771b8f2499..568bf6f8aa 100644 --- a/tests/library/playwright.config.ts +++ b/tests/library/playwright.config.ts @@ -109,10 +109,10 @@ for (const browserName of browserNames) { const testIgnore: RegExp[] = browserNames.filter(b => b !== browserName).map(b => new RegExp(b)); for (const folder of ['library', 'page']) { config.projects.push({ - name: browserName, + name: `${browserName}-${folder}`, testDir: path.join(testDir, folder), testIgnore, - snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{ext}', + snapshotPathTemplate: `{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-${browserName}{ext}`, use: { mode, browserName, diff --git a/tests/page/elementhandle-misc.spec.ts b/tests/page/elementhandle-misc.spec.ts index 2be0d10c8a..7224e87779 100644 --- a/tests/page/elementhandle-misc.spec.ts +++ b/tests/page/elementhandle-misc.spec.ts @@ -85,3 +85,12 @@ it('should focus a button', async ({ page, server }) => { await button.focus(); expect(await button.evaluate(button => document.activeElement === button)).toBe(true); }); + +it('should allow disposing twice', async ({ page }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29945' }); + await page.setContent('
39
'); + const element = await page.$('section'); + expect(element).toBeTruthy(); + await element.dispose(); + await element.dispose(); +}); diff --git a/tests/page/locator-frame.spec.ts b/tests/page/locator-frame.spec.ts index 08da3dff0a..4b79039ff9 100644 --- a/tests/page/locator-frame.spec.ts +++ b/tests/page/locator-frame.spec.ts @@ -21,7 +21,7 @@ import { test as it, expect } from './pageTest'; async function routeIframe(page: Page) { await page.route('**/empty.html', route => { route.fulfill({ - body: '', + body: '', contentType: 'text/html' }).catch(() => {}); }); @@ -298,3 +298,23 @@ it('should work with COEP/COOP/CORP isolated iframe', async ({ page, server, bro await page.frameLocator('iframe').getByRole('button').click(); expect(await page.frames()[1].evaluate(() => window['__clicked'])).toBe(true); }); + +it('locator.enterFrame should work', async ({ page, server }) => { + await routeIframe(page); + await page.goto(server.EMPTY_PAGE); + const locator = page.locator('iframe'); + const frameLocator = locator.enterFrame(); + const button = frameLocator.locator('button'); + expect(await button.innerText()).toBe('Hello iframe'); + await expect(button).toHaveText('Hello iframe'); + await button.click(); +}); + +it('frameLocator.exitFrame should work', async ({ page, server }) => { + await routeIframe(page); + await page.goto(server.EMPTY_PAGE); + const frameLocator = page.frameLocator('iframe'); + const locator = frameLocator.exitFrame(); + await expect(locator).toBeVisible(); + expect(await locator.getAttribute('name')).toBe('frame1'); +}); diff --git a/tests/page/page-event-console.spec.ts b/tests/page/page-event-console.spec.ts index df611dd9dd..3b5f652fc9 100644 --- a/tests/page/page-event-console.spec.ts +++ b/tests/page/page-event-console.spec.ts @@ -38,7 +38,10 @@ it('should work @smoke', async ({ page, browserName }) => { it('should emit same log twice', async ({ page }) => { const messages = []; page.on('console', m => messages.push(m.text())); - await page.evaluate(() => { for (let i = 0; i < 2; ++i) console.log('hello'); }); + await page.evaluate(() => { + for (let i = 0; i < 2; ++i) + console.log('hello'); + }); expect(messages).toEqual(['hello', 'hello']); }); diff --git a/tests/page/page-network-request.spec.ts b/tests/page/page-network-request.spec.ts index 70fc77b61f..624e36abfd 100644 --- a/tests/page/page-network-request.spec.ts +++ b/tests/page/page-network-request.spec.ts @@ -17,6 +17,7 @@ import { test as it, expect } from './pageTest'; import { attachFrame } from '../config/utils'; +import fs from 'fs'; it('should work for main frame navigation request', async ({ page, server }) => { const requests = []; @@ -289,6 +290,20 @@ it('should parse the data if content-type is application/x-www-form-urlencoded', expect(request.postDataJSON()).toEqual({ 'foo': 'bar', 'baz': '123' }); }); +it('should parse the data if content-type is application/x-www-form-urlencoded; charset=UTF-8', async ({ page, server }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29872' }); + await page.goto(server.EMPTY_PAGE); + const requestPromise = page.waitForRequest('**/post'); + await page.evaluate(() => fetch('./post', { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8', + }, + body: 'foo=bar&baz=123' + })); + expect((await requestPromise).postDataJSON()).toEqual({ 'foo': 'bar', 'baz': '123' }); +}); + it('should get |undefined| with postDataJSON() when there is no post data', async ({ page, server }) => { const response = await page.goto(server.EMPTY_PAGE); expect(response.request().postDataJSON()).toBe(null); @@ -482,3 +497,41 @@ it('page.reload return 304 status code', async ({ page, server, browserName }) = expect(response2.statusText()).toBe('Not Modified'); } }); + +it('should handle mixed-content blocked requests', async ({ page, asset, browserName }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29833' }); + it.skip(browserName !== 'chromium', 'FF and WK actually succeed with the request, and block afterwards'); + + await page.route('**/mixedcontent.html', route => { + void route.fulfill({ + status: 200, + contentType: 'text/html', + body: ` + + + + +- + `, + }); + }); + await page.route('**/iconfont.woff2', async route => { + const body = await fs.promises.readFile(asset('webfont/iconfont2.woff')); + await route.fulfill({ body }); + }); + + const [request] = await Promise.all([ + page.waitForEvent('requestfailed', r => r.url().includes('iconfont.woff2')), + page.goto('https://example.com/mixedcontent.html'), + ]); + const headers = await request.allHeaders(); + expect(headers['origin']).toBeTruthy(); + expect(request.failure().errorText).toBe('mixed-content'); +}); diff --git a/tests/page/page-network-response.spec.ts b/tests/page/page-network-response.spec.ts index 0c698beebd..b856362fa6 100644 --- a/tests/page/page-network-response.spec.ts +++ b/tests/page/page-network-response.spec.ts @@ -347,3 +347,55 @@ it('should return body for prefetch script', async ({ page, server, browserName const body = await response.body(); expect(body.toString()).toBe('// Scripts will be pre-fetched'); }); + +it('should bypass disk cache when interception is enabled', async ({ page, server, browserName }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30000' }); + it.fixme(browserName === 'firefox', 'Returns cached response.'); + await page.goto(server.PREFIX + '/frames/one-frame.html'); + await page.route('**/api*', route => route.continue()); + { + const requests = []; + server.setRoute('/api', (req, res) => { + requests.push(req); + res.statusCode = 200; + res.setHeader('content-type', 'text/plain'); + res.setHeader('cache-control', 'public, max-age=31536000'); + res.end('Hello'); + }); + for (let i = 0; i < 3; i++) { + await it.step(`main frame iteration ${i}`, async () => { + const respPromise = page.waitForResponse('**/api'); + await page.evaluate(async () => { + const response = await fetch('/api'); + return response.status; + }); + const response = await respPromise; + expect(response.status()).toBe(200); + expect(requests.length).toBe(i + 1); + }); + } + } + + { + const requests = []; + server.setRoute('/frame/api', (req, res) => { + requests.push(req); + res.statusCode = 200; + res.setHeader('content-type', 'text/plain'); + res.setHeader('cache-control', 'public, max-age=31536000'); + res.end('Hello'); + }); + for (let i = 0; i < 3; i++) { + await it.step(`subframe iteration ${i}`, async () => { + const respPromise = page.waitForResponse('**/frame/api'); + await page.frame({ url: '**/frame.html' }).evaluate(async () => { + const response = await fetch('/frame/api'); + return response.status; + }); + const response = await respPromise; + expect(response.status()).toBe(200); + expect(requests.length).toBe(i + 1); + }); + } + } +}); diff --git a/tests/page/page-screenshot.spec.ts b/tests/page/page-screenshot.spec.ts index 47188f52a4..278dd38228 100644 --- a/tests/page/page-screenshot.spec.ts +++ b/tests/page/page-screenshot.spec.ts @@ -723,7 +723,7 @@ it.describe('page screenshot animations', () => { el.addEventListener('transitionend', () => { const time = Date.now(); // Block main thread for 200ms, emulating heavy layout. - while (Date.now() - time < 200) ; + while (Date.now() - time < 200) {} const h1 = document.createElement('h1'); h1.textContent = 'woof-woof'; document.body.append(h1); diff --git a/tests/page/page-set-input-files.spec.ts b/tests/page/page-set-input-files.spec.ts index 966459a2c9..eb7aacaa97 100644 --- a/tests/page/page-set-input-files.spec.ts +++ b/tests/page/page-set-input-files.spec.ts @@ -37,6 +37,23 @@ it('should upload the file', async ({ page, server, asset }) => { }, input)).toBe('contents of the file'); }); +it('should upload a file after popup', async ({ page, server, asset, browserName }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29923' }); + it.fixme(browserName === 'firefox'); + await page.goto(server.PREFIX + '/input/fileupload.html'); + { + const [popup] = await Promise.all([ + page.waitForEvent('popup'), + page.evaluate(() => window['__popup'] = window.open('about:blank')), + ]); + await popup.close(); + } + const filePath = path.relative(process.cwd(), asset('file-to-upload.txt')); + const input = await page.$('input'); + await input.setInputFiles(filePath); + expect(await page.evaluate(e => e.files[0].name, input)).toBe('file-to-upload.txt'); +}); + it('should upload large file', async ({ page, server, browserName, isMac, isAndroid, isWebView2, mode }, testInfo) => { it.skip(browserName === 'webkit' && isMac && parseInt(os.release(), 10) < 20, 'WebKit for macOS 10.15 is frozen and does not have corresponding protocol features.'); it.skip(isAndroid); @@ -88,6 +105,14 @@ it('should upload large file', async ({ page, server, browserName, isMac, isAndr await Promise.all([uploadFile, file1.filepath].map(fs.promises.unlink)); }); +it('should throw an error if the file does not exist', async ({ page, server, asset }) => { + await page.goto(server.PREFIX + '/input/fileupload.html'); + const input = await page.$('input'); + const error = await input.setInputFiles('i actually do not exist.txt').catch(e => e); + expect(error.message).toContain('ENOENT: no such file or directory'); + expect(error.message).toContain('i actually do not exist.txt'); +}); + it('should upload multiple large files', async ({ page, server, browserName, isMac, isAndroid, isWebView2, mode }, testInfo) => { it.skip(browserName === 'webkit' && isMac && parseInt(os.release(), 10) < 20, 'WebKit for macOS 10.15 is frozen and does not have corresponding protocol features.'); it.skip(isAndroid); diff --git a/tests/page/pageTestApi.ts b/tests/page/pageTestApi.ts index 77340dfb2c..a7b124a84b 100644 --- a/tests/page/pageTestApi.ts +++ b/tests/page/pageTestApi.ts @@ -27,7 +27,7 @@ export type PageWorkerFixtures = { headless: boolean; channel: string; screenshot: ScreenshotMode | { mode: ScreenshotMode } & Pick; - trace: 'off' | 'on' | 'retain-on-failure' | 'on-first-retry' | 'on-all-retries' | /** deprecated */ 'retry-with-trace'; + 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'; browserVersion: string; diff --git a/tests/page/selectors-role.spec.ts b/tests/page/selectors-role.spec.ts index d936f1b229..f5680982b1 100644 --- a/tests/page/selectors-role.spec.ts +++ b/tests/page/selectors-role.spec.ts @@ -484,3 +484,20 @@ test('should support output accessible name', async ({ page }) => { await page.setContent(``); await expect(page.getByRole('status', { name: 'Output1' })).toBeVisible(); }); + +test('should not match scope by default', async ({ page }) => { + await page.setContent(` +
    +
  • + Parent list +
      +
    • child 1
    • +
    • child 2
    • +
    +
  • +
+ `); + const children = page.getByRole('listitem', { name: 'Parent list' }).getByRole('listitem'); + await expect(children).toHaveCount(2); + await expect(children).toHaveText(['child 1', 'child 2']); +}); diff --git a/tests/page/workers.spec.ts b/tests/page/workers.spec.ts index 9ad0e32238..3b4d1bf5c8 100644 --- a/tests/page/workers.spec.ts +++ b/tests/page/workers.spec.ts @@ -224,3 +224,32 @@ it('should report and intercept network from nested worker', async function({ pa await expect.poll(() => messages).toEqual(['{"foo":"not bar"}', '{"foo":"not bar"}']); }); + +it('should support extra http headers', async ({ page, server }) => { + await page.setExtraHTTPHeaders({ foo: 'bar' }); + const [worker, request1] = await Promise.all([ + page.waitForEvent('worker'), + server.waitForRequest('/worker/worker.js'), + page.goto(server.PREFIX + '/worker/worker.html'), + ]); + const [request2] = await Promise.all([ + server.waitForRequest('/one-style.css'), + worker.evaluate(url => fetch(url), server.PREFIX + '/one-style.css'), + ]); + expect(request1.headers['foo']).toBe('bar'); + expect(request2.headers['foo']).toBe('bar'); +}); + +it('should support offline', async ({ page, server, browserName }) => { + it.fixme(browserName === 'firefox'); + + const [worker] = await Promise.all([ + page.waitForEvent('worker'), + page.goto(server.PREFIX + '/worker/worker.html'), + ]); + await page.context().setOffline(true); + expect(await worker.evaluate(() => navigator.onLine)).toBe(false); + expect(await worker.evaluate(() => fetch('/one-style.css').catch(e => 'error'))).toBe('error'); + await page.context().setOffline(false); + expect(await worker.evaluate(() => navigator.onLine)).toBe(true); +}); diff --git a/tests/playwright-test/basic.spec.ts b/tests/playwright-test/basic.spec.ts index 4c4b28ceeb..6476255936 100644 --- a/tests/playwright-test/basic.spec.ts +++ b/tests/playwright-test/basic.spec.ts @@ -559,7 +559,7 @@ test('should not allow mixing test types', async ({ runInlineTest }) => { export const test2 = test.extend({ value: 42, }); - + test.describe("test1 suite", () => { test2("test 2", async () => {}); }); diff --git a/tests/playwright-test/esm.spec.ts b/tests/playwright-test/esm.spec.ts index 7097659d0d..ff542f212e 100644 --- a/tests/playwright-test/esm.spec.ts +++ b/tests/playwright-test/esm.spec.ts @@ -552,7 +552,9 @@ test('should load cjs config and test in non-ESM mode', async ({ runInlineTest } expect(result.passed).toBe(2); }); -test('should disallow ESM when config is cjs', async ({ runInlineTest }) => { +test('should allow ESM when config is cjs', async ({ runInlineTest, nodeVersion }) => { + test.skip(nodeVersion.major < 18, 'ESM loader is enabled conditionally with older API'); + const result = await runInlineTest({ 'package.json': `{ "type": "module" }`, 'playwright.config.cjs': ` @@ -567,8 +569,24 @@ test('should disallow ESM when config is cjs', async ({ runInlineTest }) => { `, }); - expect(result.exitCode).toBe(1); - expect(result.output).toContain('Unknown file extension ".ts"'); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('should load mts without config', async ({ runInlineTest, nodeVersion }) => { + test.skip(nodeVersion.major < 18, 'ESM loader is enabled conditionally with older API'); + + const result = await runInlineTest({ + 'a.test.mts': ` + import { test, expect } from '@playwright/test'; + test('check project name', ({}, testInfo) => { + expect(true).toBe(true); + }); + `, + }); + + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); }); test('should be able to use use execSync with a Node.js file inside a spec', async ({ runInlineTest }) => { diff --git a/tests/playwright-test/fixture-errors.spec.ts b/tests/playwright-test/fixture-errors.spec.ts index 90dc344c4d..ae323c287a 100644 --- a/tests/playwright-test/fixture-errors.spec.ts +++ b/tests/playwright-test/fixture-errors.spec.ts @@ -425,6 +425,38 @@ test('should give enough time for fixture teardown', async ({ runInlineTest }) = }); `, }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + expect(result.outputLines).toEqual([ + 'teardown start', + 'teardown finished', + ]); +}); + +test('should not give enough time for second fixture teardown after timeout', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test as base, expect } from '@playwright/test'; + const test = base.extend({ + fixture2: async ({ }, use) => { + await use(); + console.log('\\n%%teardown2 start'); + await new Promise(f => setTimeout(f, 3000)); + console.log('\\n%%teardown2 finished'); + }, + fixture: async ({ fixture2 }, use) => { + await use(); + console.log('\\n%%teardown start'); + await new Promise(f => setTimeout(f, 3000)); + console.log('\\n%%teardown finished'); + }, + }); + test('fast enough but close', async ({ fixture }) => { + test.setTimeout(3000); + await new Promise(f => setTimeout(f, 2000)); + }); + `, + }, { timeout: 2000 }); expect(result.exitCode).toBe(1); expect(result.failed).toBe(1); expect(result.output).toContain('Test finished within timeout of 3000ms, but tearing down "fixture" ran out of time.'); @@ -495,8 +527,7 @@ test('should not report fixture teardown timeout twice', async ({ runInlineTest expect(result.failed).toBe(1); expect(result.output).toContain('Test finished within timeout of 1000ms, but tearing down "fixture" ran out of time.'); expect(result.output).not.toContain('base.extend'); // Should not point to the location. - // TODO: this should be "not.toContain" actually. - expect(result.output).toContain('Worker teardown timeout of 1000ms exceeded while tearing down "fixture".'); + expect(result.output).not.toContain('Worker teardown timeout'); }); test('should handle fixture teardown error after test timeout and continue', async ({ runInlineTest }) => { @@ -676,3 +707,50 @@ test('tear down base fixture after error in derived', async ({ runInlineTest }) 'context teardown failed', ]); }); + +test('should not continue with scope teardown after fixture teardown timeout', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test as base, expect } from '@playwright/test'; + const test = base.extend({ + fixture: async ({ }, use) => { + await use(); + console.log('in fixture teardown'); + }, + fixture2: async ({ fixture }, use) => { + await use(); + console.log('in fixture2 teardown'); + await new Promise(() => {}); + }, + }); + test.use({ trace: 'on' }); + test('good', async ({ fixture2 }) => { + }); + `, + }, { reporter: 'list', timeout: 1000 }); + expect(result.exitCode).toBe(1); + expect(result.failed).toBe(1); + expect(result.output).toContain('Test finished within timeout of 1000ms, but tearing down "fixture2" ran out of time.'); + expect(result.output).not.toContain('in fixture teardown'); +}); + +test('should report fixture teardown error after test error', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test as base, expect } from '@playwright/test'; + const test = base.extend({ + foo: async ({ }, use) => { + await use(); + throw new Error('Error from the fixture foo'); + }, + }); + test('fails', async ({ foo }) => { + throw new Error('Error from the test'); + }); + `, + }); + expect(result.exitCode).toBe(1); + expect(result.failed).toBe(1); + expect(result.output).toContain('Error from the fixture foo'); + expect(result.output).toContain('Error from the test'); +}); diff --git a/tests/playwright-test/golden.spec.ts b/tests/playwright-test/golden.spec.ts index 2cba3b0623..e768e1cf60 100644 --- a/tests/playwright-test/golden.spec.ts +++ b/tests/playwright-test/golden.spec.ts @@ -90,6 +90,76 @@ test('should generate default name', async ({ runInlineTest }, testInfo) => { expect(fs.existsSync(testInfo.outputPath('a.spec.js-snapshots', 'is-a-test-5.dat'))).toBe(true); }); +test('should generate separate actual results for repeating names', async ({ runInlineTest }, testInfo) => { + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29719' }); + const result = await runInlineTest({ + ...files, + 'a.spec.js-snapshots/foo.txt': `b`, + 'a.spec.js-snapshots/bar/baz.txt': `c`, + 'a.spec.js': ` + const { test, expect } = require('./helper'); + test.afterEach(async ({}, testInfo) => { + console.log('## ' + JSON.stringify(testInfo.attachments)); + }); + test('is a test', ({}) => { + expect.soft('a').toMatchSnapshot('foo.txt'); + expect.soft('a').toMatchSnapshot('foo.txt'); + expect.soft('b').toMatchSnapshot(['bar', 'baz.txt']); + expect.soft('b').toMatchSnapshot(['bar', 'baz.txt']); + }); + ` + }); + + const outputText = result.output; + const attachments = outputText.split('\n').filter(l => l.startsWith('## ')).map(l => l.substring(3)).map(l => JSON.parse(l))[0]; + for (const attachment of attachments) { + attachment.path = attachment.path.replace(/\\/g, '/').replace(/.*test-results\//, ''); + attachment.name = attachment.name.replace(/\\/g, '/'); + } + expect(attachments).toEqual([ + { + 'name': 'foo-expected.txt', + 'contentType': 'text/plain', + 'path': 'golden-should-generate-separate-actual-results-for-repeating-names-playwright-test/a.spec.js-snapshots/foo.txt' + }, + { + 'name': 'foo-actual.txt', + 'contentType': 'text/plain', + 'path': 'a-is-a-test/foo-actual.txt' + }, + { + 'name': 'foo-1-expected.txt', + 'contentType': 'text/plain', + 'path': 'golden-should-generate-separate-actual-results-for-repeating-names-playwright-test/a.spec.js-snapshots/foo.txt' + }, + { + 'name': 'foo-1-actual.txt', + 'contentType': 'text/plain', + 'path': 'a-is-a-test/foo-1-actual.txt' + }, + { + 'name': 'bar/baz-expected.txt', + 'contentType': 'text/plain', + 'path': 'golden-should-generate-separate-actual-results-for-repeating-names-playwright-test/a.spec.js-snapshots/bar/baz.txt' + }, + { + 'name': 'bar/baz-actual.txt', + 'contentType': 'text/plain', + 'path': 'a-is-a-test/bar-baz-actual.txt' + }, + { + 'name': 'bar/baz-1-expected.txt', + 'contentType': 'text/plain', + 'path': 'golden-should-generate-separate-actual-results-for-repeating-names-playwright-test/a.spec.js-snapshots/bar/baz.txt' + }, + { + 'name': 'bar/baz-1-actual.txt', + 'contentType': 'text/plain', + 'path': 'a-is-a-test/bar-baz-1-actual.txt' + } + ]); +}); + test('should compile with different option combinations', async ({ runTSC }) => { const result = await runTSC({ 'a.spec.ts': ` @@ -907,12 +977,12 @@ test('should attach expected/actual/diff with snapshot path', async ({ runInline { name: 'test/path/snapshot-actual.png', contentType: 'image/png', - path: 'a-is-a-test/test/path/snapshot-actual.png' + path: 'a-is-a-test/test-path-snapshot-actual.png' }, { name: 'test/path/snapshot-diff.png', contentType: 'image/png', - path: 'a-is-a-test/test/path/snapshot-diff.png' + path: 'a-is-a-test/test-path-snapshot-diff.png' } ]); }); diff --git a/tests/playwright-test/hooks.spec.ts b/tests/playwright-test/hooks.spec.ts index dcc497f6dc..4d8999ba3f 100644 --- a/tests/playwright-test/hooks.spec.ts +++ b/tests/playwright-test/hooks.spec.ts @@ -526,7 +526,7 @@ test('afterAll timeout should be reported, run other afterAll hooks, and continu test.afterAll(async () => { console.log('\\n%%afterAll2'); }); - test('does not run', () => { + test('run in a different worker', () => { console.log('\\n%%test2'); }); `, diff --git a/tests/playwright-test/playwright-test-fixtures.ts b/tests/playwright-test/playwright-test-fixtures.ts index 825068f51f..7850a52f07 100644 --- a/tests/playwright-test/playwright-test-fixtures.ts +++ b/tests/playwright-test/playwright-test-fixtures.ts @@ -254,8 +254,8 @@ type Fixtures = { }; export const test = base - .extend(commonFixtures) - .extend(serverFixtures) + .extend(commonFixtures as any) + .extend(serverFixtures as any) .extend({ writeFiles: async ({}, use, testInfo) => { await use(files => writeFiles(testInfo, files, false)); @@ -455,4 +455,4 @@ export default defineConfig({ }, projects: [{name: 'default'}], }); -`; \ No newline at end of file +`; diff --git a/tests/playwright-test/playwright.artifacts.spec.ts b/tests/playwright-test/playwright.artifacts.spec.ts index 5a54d8f981..8a6db7fd2e 100644 --- a/tests/playwright-test/playwright.artifacts.spec.ts +++ b/tests/playwright-test/playwright.artifacts.spec.ts @@ -338,6 +338,31 @@ test('should work with trace: on-all-retries', async ({ runInlineTest }, testInf ]); }); +test('should work with trace: retain-on-first-failure', async ({ runInlineTest }, testInfo) => { + const result = await runInlineTest({ + ...testFiles, + 'playwright.config.ts': ` + module.exports = { use: { trace: 'retain-on-first-failure' } }; + `, + }, { workers: 1, retries: 2 }); + + expect(result.exitCode).toBe(1); + expect(result.passed).toBe(5); + expect(result.failed).toBe(5); + expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ + 'artifacts-failing', + ' trace.zip', + 'artifacts-own-context-failing', + ' trace.zip', + 'artifacts-persistent-failing', + ' trace.zip', + 'artifacts-shared-shared-failing', + ' trace.zip', + 'artifacts-two-contexts-failing', + ' trace.zip', + ]); +}); + test('should take screenshot when page is closed in afterEach', async ({ runInlineTest }, testInfo) => { const result = await runInlineTest({ 'playwright.config.ts': ` diff --git a/tests/playwright-test/playwright.ct-react.spec.ts b/tests/playwright-test/playwright.ct-react.spec.ts index a92f8297f1..6f1780ce55 100644 --- a/tests/playwright-test/playwright.ct-react.spec.ts +++ b/tests/playwright-test/playwright.ct-react.spec.ts @@ -511,3 +511,37 @@ test('should allow props children', async ({ runInlineTest }) => { expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); }); + +test('should allow import from shared file', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': playwrightCtConfigText, + 'playwright/index.html': ``, + 'playwright/index.ts': ``, + 'src/component.tsx': ` + export const Component = (props: { content: string }) => { + return
{props.content}
+ }; + `, + 'src/component.shared.tsx': ` + export const componentMock = { content: 'This is a content.' }; + `, + 'src/component.render.tsx': ` + import {Component} from './component'; + import {componentMock} from './component.shared'; + export const ComponentTest = () => { + return ; + }; + `, + 'src/component.spec.tsx': ` + import { expect, test } from '@playwright/experimental-ct-react'; + import { ComponentTest } from './component.render'; + import { componentMock } from './component.shared'; + test('component renders', async ({ mount }) => { + const component = await mount(); + await expect(component).toContainText(componentMock.content) + })` + }, { workers: 1 }); + + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); diff --git a/tests/playwright-test/playwright.reuse.spec.ts b/tests/playwright-test/playwright.reuse.spec.ts index 67a02613c5..799b6fa3d7 100644 --- a/tests/playwright-test/playwright.reuse.spec.ts +++ b/tests/playwright-test/playwright.reuse.spec.ts @@ -212,7 +212,7 @@ test('should clean storage', async ({ runInlineTest }) => { let lastContextGuid; test.beforeEach(async ({ page }) => { - await page.route('**/*', route => route.fulfill('')); + await page.route('**/*', route => route.fulfill({ body: '', contentType: 'text/html' })); await page.goto('http://example.com'); }); @@ -273,7 +273,7 @@ test('should restore localStorage', async ({ runInlineTest }) => { }); test.beforeEach(async ({ page }) => { - await page.route('**/*', route => route.fulfill('')); + await page.route('**/*', route => route.fulfill({ body: '', contentType: 'text/html' })); await page.goto('http://example.com'); }); @@ -330,7 +330,7 @@ test('should clean db', async ({ runInlineTest }) => { let lastContextGuid; test.beforeEach(async ({ page }) => { - await page.route('**/*', route => route.fulfill('')); + await page.route('**/*', route => route.fulfill({ body: '', contentType: 'text/html' })); await page.goto('http://example.com'); }); @@ -380,7 +380,7 @@ test('should restore cookies', async ({ runInlineTest }) => { }); test.beforeEach(async ({ page }) => { - await page.route('**/*', route => route.fulfill('')); + await page.route('**/*', route => route.fulfill({ body: '', contentType: 'text/html' })); await page.goto('http://example.com'); }); diff --git a/tests/playwright-test/playwright.trace.spec.ts b/tests/playwright-test/playwright.trace.spec.ts index 69f1912752..dee65c9a52 100644 --- a/tests/playwright-test/playwright.trace.spec.ts +++ b/tests/playwright-test/playwright.trace.spec.ts @@ -129,11 +129,11 @@ test('should record api trace', async ({ runInlineTest, server }, testInfo) => { ' fixture: context', ' fixture: request', ' apiRequestContext.dispose', + 'Worker Cleanup', ' fixture: browser', ]); }); - test('should not throw with trace: on-first-retry and two retries in the same worker', async ({ runInlineTest }, testInfo) => { const files = {}; for (let i = 0; i < 6; i++) { @@ -333,6 +333,7 @@ test('should not override trace file in afterAll', async ({ runInlineTest, serve ' apiRequestContext.get', ' fixture: request', ' apiRequestContext.dispose', + 'Worker Cleanup', ' fixture: browser', ]); expect(trace1.errors).toEqual([`'oh no!'`]); @@ -402,7 +403,7 @@ test('should respect PW_TEST_DISABLE_TRACING', async ({ runInlineTest }, testInf expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip'))).toBe(false); }); -for (const mode of ['off', 'retain-on-failure', 'on-first-retry', 'on-all-retries']) { +for (const mode of ['off', 'retain-on-failure', 'on-first-retry', 'on-all-retries', 'retain-on-first-failure']) { test(`trace:${mode} should not create trace zip artifact if page test passed`, async ({ runInlineTest }) => { const result = await runInlineTest({ 'a.spec.ts': ` @@ -668,6 +669,7 @@ test('should show non-expect error in trace', async ({ runInlineTest }, testInfo 'After Hooks', ' fixture: page', ' fixture: context', + 'Worker Cleanup', ' fixture: browser', ]); expect(trace.errors).toEqual(['ReferenceError: undefinedVariable1 is not defined']); @@ -753,7 +755,7 @@ test('should not throw when screenshot on failure fails', async ({ runInlineTest expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); const trace = await parseTrace(testInfo.outputPath('test-results', 'a-has-pdf-page', 'trace.zip')); - const attachedScreenshots = trace.actionTree.filter(s => s === ` attach "screenshot"`); + const attachedScreenshots = trace.actionTree.filter(s => s.trim() === `attach "screenshot"`); // One screenshot for the page, no screenshot for pdf page since it should have failed. expect(attachedScreenshots.length).toBe(1); }); @@ -986,6 +988,7 @@ test('should record nested steps, even after timeout', async ({ runInlineTest }, ' barPage teardown', ' step in barPage teardown', ' page.close', + 'Worker Cleanup', ' fixture: browser', ]); }); @@ -1030,7 +1033,82 @@ test('should attribute worker fixture teardown to the right test', async ({ runI expect(trace2.actionTree).toEqual([ 'Before Hooks', 'After Hooks', + 'Worker Cleanup', ' fixture: foo', ' step in foo teardown', ]); }); + +test('trace:retain-on-first-failure should create trace but only on first failure', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fail', async ({ page }) => { + await page.goto('about:blank'); + expect(true).toBe(false); + }); + `, + }, { trace: 'retain-on-first-failure', retries: 1 }); + + const retryTracePath = test.info().outputPath('test-results', 'a-fail-retry1', 'trace.zip'); + const retryTraceExists = fs.existsSync(retryTracePath); + expect(retryTraceExists).toBe(false); + + const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip'); + const trace = await parseTrace(tracePath); + expect(trace.apiNames).toContain('page.goto'); + expect(result.failed).toBe(1); +}); + +test('trace:retain-on-first-failure should create trace if context is closed before failure in the test', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fail', async ({ page, context }) => { + await page.goto('about:blank'); + await context.close(); + expect(1).toBe(2); + }); + `, + }, { trace: 'retain-on-first-failure' }); + const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip'); + const trace = await parseTrace(tracePath); + expect(trace.apiNames).toContain('page.goto'); + expect(result.failed).toBe(1); +}); + +test('trace:retain-on-first-failure should create trace if context is closed before failure in afterEach', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fail', async ({ page, context }) => { + }); + test.afterEach(async ({ page, context }) => { + await page.goto('about:blank'); + await context.close(); + expect(1).toBe(2); + }); + `, + }, { trace: 'retain-on-first-failure' }); + const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip'); + const trace = await parseTrace(tracePath); + expect(trace.apiNames).toContain('page.goto'); + expect(result.failed).toBe(1); +}); + +test('trace:retain-on-first-failure should create trace if request context is disposed before failure', async ({ runInlineTest, server }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fail', async ({ request }) => { + expect(await request.get('${server.EMPTY_PAGE}')).toBeOK(); + await request.dispose(); + expect(1).toBe(2); + }); + `, + }, { trace: 'retain-on-first-failure' }); + const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip'); + const trace = await parseTrace(tracePath); + expect(trace.apiNames).toContain('apiRequestContext.get'); + expect(result.failed).toBe(1); +}); diff --git a/tests/playwright-test/reporter-blob.spec.ts b/tests/playwright-test/reporter-blob.spec.ts index f336e916ff..6e18651af9 100644 --- a/tests/playwright-test/reporter-blob.spec.ts +++ b/tests/playwright-test/reporter-blob.spec.ts @@ -212,7 +212,7 @@ test('should merge into html with dependencies', async ({ runInlineTest, mergeRe const { exitCode, output } = await mergeReports(reportDir, { 'PW_TEST_HTML_REPORT_OPEN': 'never' }, { additionalArgs: ['--reporter', 'html'] }); expect(exitCode).toBe(0); - expect(output).toContain('To open last HTML report run:'); + expect(output).not.toContain('To open last HTML report run:'); await showReport(); @@ -377,7 +377,7 @@ test('total time is from test run not from merge', async ({ runInlineTest, merge const { exitCode, output } = await mergeReports(reportDir, { 'PW_TEST_HTML_REPORT_OPEN': 'never' }, { additionalArgs: ['--reporter', 'html'] }); expect(exitCode).toBe(0); - expect(output).toContain('To open last HTML report run:'); + expect(output).not.toContain('To open last HTML report run:'); await showReport(); @@ -1152,7 +1152,7 @@ test('preserve steps in html report', async ({ runInlineTest, mergeReports, show const { exitCode, output } = await mergeReports(reportDir, { 'PW_TEST_HTML_REPORT_OPEN': 'never' }, { additionalArgs: ['--reporter', 'html'], cwd: mergeCwd }); expect(exitCode).toBe(0); - expect(output).toContain('To open last HTML report run:'); + expect(output).not.toContain('To open last HTML report run:'); await showReport(); @@ -1205,9 +1205,9 @@ test('preserve reportName on projects', async ({ runInlineTest, mergeReports }) class EchoReporter { onBegin(config, suite) { - const projects = suite.suites.map(s => s.project()).sort((a, b) => a.metadata.reportName.localeCompare(b.metadata.reportName)); + const projects = suite.suites.map(s => s.project()).sort((a, b) => a.metadata.botName.localeCompare(b.metadata.botName)); console.log('projectNames: ' + projects.map(p => p.name)); - console.log('reportNames: ' + projects.map(p => p.metadata.reportName)); + console.log('botNames: ' + projects.map(p => p.metadata.botName)); } } module.exports = EchoReporter; @@ -1233,7 +1233,7 @@ test('preserve reportName on projects', async ({ runInlineTest, mergeReports }) const { exitCode, output } = await mergeReports(reportDir, {}, { additionalArgs: ['--reporter', test.info().outputPath('echo-reporter.js')] }); expect(exitCode).toBe(0); expect(output).toContain(`projectNames: foo,foo`); - expect(output).toContain(`reportNames: first,second`); + expect(output).toContain(`botNames: first,second`); }); test('no reports error', async ({ runInlineTest, mergeReports }) => { diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 84f37be292..ad39504c64 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -261,8 +261,8 @@ for (const useIntermediateMergeReport of [false, true] as const) { await expect(page.locator('data-testid=test-result-image-mismatch')).toHaveCount(3); await expect(page.locator('text=Image mismatch:')).toHaveText([ 'Image mismatch: expected.png', - 'Image mismatch: expected.png-1', - 'Image mismatch: expected.png-2', + 'Image mismatch: expected-1.png', + 'Image mismatch: expected-2.png', ]); }); @@ -442,8 +442,11 @@ for (const useIntermediateMergeReport of [false, true] as const) { `, 'a.test.js': ` import { test, expect } from '@playwright/test'; + async function evaluateWrapper(page, expression) { + await page.evaluate(expression); + } test('passes', async ({ page }) => { - await page.evaluate('2 + 2'); + await evaluateWrapper(page, '2 + 2'); }); `, }, { reporter: 'dot,html' }, { PW_TEST_HTML_REPORT_OPEN: 'never' }); @@ -468,6 +471,36 @@ for (const useIntermediateMergeReport of [false, true] as const) { await expect(page.getByTestId('stack-trace-list').locator('.list-view-entry.selected')).toContainText('a.test.js'); }); + test('should not show stack trace', async ({ runInlineTest, page, showReport }) => { + const result = await runInlineTest({ + 'playwright.config.js': ` + module.exports = { use: { trace: 'on' } }; + `, + 'a.test.js': ` + import { test, expect } from '@playwright/test'; + test('passes', async ({ page }) => { + await page.evaluate('2 + 2'); + }); + `, + }, { reporter: 'dot,html' }, { PW_TEST_HTML_REPORT_OPEN: 'never' }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + + await showReport(); + await page.click('text=passes'); + await page.click('img'); + await page.click('.action-title >> text=page.evaluate'); + await page.click('text=Source'); + + await expect(page.locator('.CodeMirror-line')).toContainText([ + /import.*test/, + /page\.evaluate/ + ]); + await expect(page.locator('.source-line-running')).toContainText('page.evaluate'); + + await expect(page.getByTestId('stack-trace-list')).toHaveCount(0); + }); + test('should show trace title', async ({ runInlineTest, page, showReport }) => { const result = await runInlineTest({ 'playwright.config.js': ` @@ -2019,9 +2052,9 @@ for (const useIntermediateMergeReport of [false, true] as const) { // Failing test first, then sorted by the run order. await expect(page.locator('.test-file-test')).toHaveText([ - /main › fails\d+m?smain.spec.ts:9/, - /main › first › passes\d+m?sfirst.ts:12/, - /main › second › passes\d+m?ssecond.ts:5/, + /main › fails\d+m?s?main.spec.ts:9/, + /main › first › passes\d+m?s?first.ts:12/, + /main › second › passes\d+m?s?second.ts:5/, ]); }); diff --git a/tests/playwright-test/reporter.spec.ts b/tests/playwright-test/reporter.spec.ts index aaa24f7fbd..3c0f81e1f0 100644 --- a/tests/playwright-test/reporter.spec.ts +++ b/tests/playwright-test/reporter.spec.ts @@ -277,6 +277,8 @@ for (const useIntermediateMergeReport of [false, true] as const) { `end {\"title\":\"expect.toBeTruthy\",\"category\":\"expect\",\"error\":{\"message\":\"Error: \\u001b[2mexpect(\\u001b[22m\\u001b[31mreceived\\u001b[39m\\u001b[2m).\\u001b[22mtoBeTruthy\\u001b[2m()\\u001b[22m\\n\\nReceived: \\u001b[31mfalse\\u001b[39m\",\"stack\":\"\",\"location\":\"\",\"snippet\":\"\"}}`, `begin {\"title\":\"After Hooks\",\"category\":\"hook\"}`, `end {\"title\":\"After Hooks\",\"category\":\"hook\"}`, + `begin {\"title\":\"Worker Cleanup\",\"category\":\"hook\"}`, + `end {\"title\":\"Worker Cleanup\",\"category\":\"hook\"}`, `begin {\"title\":\"Before Hooks\",\"category\":\"hook\"}`, `end {\"title\":\"Before Hooks\",\"category\":\"hook\"}`, `begin {\"title\":\"expect.not.toBeTruthy\",\"category\":\"expect\"}`, @@ -460,9 +462,11 @@ for (const useIntermediateMergeReport of [false, true] as const) { `end {\"title\":\"fixture: page\",\"category\":\"fixture\"}`, `begin {\"title\":\"fixture: context\",\"category\":\"fixture\"}`, `end {\"title\":\"fixture: context\",\"category\":\"fixture\"}`, + `end {\"title\":\"After Hooks\",\"category\":\"hook\",\"steps\":[{\"title\":\"fixture: page\",\"category\":\"fixture\"},{\"title\":\"fixture: context\",\"category\":\"fixture\"}]}`, + `begin {\"title\":\"Worker Cleanup\",\"category\":\"hook\"}`, `begin {\"title\":\"fixture: browser\",\"category\":\"fixture\"}`, `end {\"title\":\"fixture: browser\",\"category\":\"fixture\"}`, - `end {\"title\":\"After Hooks\",\"category\":\"hook\",\"steps\":[{\"title\":\"fixture: page\",\"category\":\"fixture\"},{\"title\":\"fixture: context\",\"category\":\"fixture\"},{\"title\":\"fixture: browser\",\"category\":\"fixture\"}]}`, + `end {\"title\":\"Worker Cleanup\",\"category\":\"hook\",\"steps\":[{\"title\":\"fixture: browser\",\"category\":\"fixture\"}]}`, ]); }); diff --git a/tests/playwright-test/resolver.spec.ts b/tests/playwright-test/resolver.spec.ts index 778efefe1f..4092263648 100644 --- a/tests/playwright-test/resolver.spec.ts +++ b/tests/playwright-test/resolver.spec.ts @@ -505,6 +505,9 @@ test('should support extends in tsconfig.json', async ({ runInlineTest }) => { }`, 'tsconfig.base1.json': `{ "extends": "./tsconfig.base.json", + "compilerOptions": { + "allowJs": true, + }, }`, 'tsconfig.base2.json': `{ "compilerOptions": { @@ -518,7 +521,9 @@ test('should support extends in tsconfig.json', async ({ runInlineTest }) => { }, }, }`, - 'a.test.ts': ` + 'a.test.js': ` + // This js file is affected by tsconfig because allowJs is inherited. + // Next line resolve to the final baseUrl ("dir") + relative path mapping ("./foo/bar/util/*"). const { foo } = require('util/file'); import { test, expect } from '@playwright/test'; test('test', ({}, testInfo) => { @@ -534,6 +539,36 @@ test('should support extends in tsconfig.json', async ({ runInlineTest }) => { expect(result.exitCode).toBe(0); }); +test('should resolve paths relative to the originating config when extending and no baseUrl', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'tsconfig.json': `{ + "extends": ["./dir/tsconfig.base.json"], + }`, + 'dir/tsconfig.base.json': `{ + "compilerOptions": { + "paths": { + "~/*": ["../mapped/*"], + }, + }, + }`, + 'a.test.ts': ` + // This resolves relative to the base tsconfig that defined path mapping, + // because there is no baseUrl in the final tsconfig. + const { foo } = require('~/file'); + import { test, expect } from '@playwright/test'; + test('test', ({}, testInfo) => { + expect(foo).toBe('foo'); + }); + `, + 'mapped/file.ts': ` + module.exports = { foo: 'foo' }; + `, + }); + + expect(result.passed).toBe(1); + expect(result.exitCode).toBe(0); +}); + test('should import packages with non-index main script through path resolver', async ({ runInlineTest }) => { const result = await runInlineTest({ 'app/pkg/main.ts': ` diff --git a/tests/playwright-test/stable-test-runner/package-lock.json b/tests/playwright-test/stable-test-runner/package-lock.json index 2594cfcffd..d19c693595 100644 --- a/tests/playwright-test/stable-test-runner/package-lock.json +++ b/tests/playwright-test/stable-test-runner/package-lock.json @@ -5,15 +5,15 @@ "packages": { "": { "dependencies": { - "@playwright/test": "1.41.0-beta-1705101589000" + "@playwright/test": "1.42.0-beta-1708998235000" } }, "node_modules/@playwright/test": { - "version": "1.41.0-beta-1705101589000", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.0-beta-1705101589000.tgz", - "integrity": "sha512-tpaEm+ih0PhJdk6yrRtk40I9ahErhGYZT2lR65ZZ6Il7tMnUgzxF5OOw3XLGJ59T29nMyI1+hKGqQnf8nUKkQw==", + "version": "1.42.0-beta-1708998235000", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.42.0-beta-1708998235000.tgz", + "integrity": "sha512-tPONSh/yGC9dApTJ/aSQ83lqqk0kV6mdlIpJYpmh5LWHyZnl2EcwAqLeNF6GxU2r4aIOMaZPzkEE+WPw/isxWw==", "dependencies": { - "playwright": "1.41.0-beta-1705101589000" + "playwright": "1.42.0-beta-1708998235000" }, "bin": { "playwright": "cli.js" @@ -36,11 +36,11 @@ } }, "node_modules/playwright": { - "version": "1.41.0-beta-1705101589000", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.0-beta-1705101589000.tgz", - "integrity": "sha512-3mMpZXmkw+fGIb+wBpvDZ4OEm7L1QYptgJgTdP8/OEitYjV5Q05MRZWbgelF/9ameoRpMz7rbkZTWUh/1UtrIw==", + "version": "1.42.0-beta-1708998235000", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.0-beta-1708998235000.tgz", + "integrity": "sha512-Iq85EGhAz0URDVtl9vKDVE0FjvEvPVs/46C/jWR5JpaI9tEsuXYoA1i1f5xA74ZhatskHigpuJYbm2YpwO3mIw==", "dependencies": { - "playwright-core": "1.41.0-beta-1705101589000" + "playwright-core": "1.42.0-beta-1708998235000" }, "bin": { "playwright": "cli.js" @@ -53,9 +53,9 @@ } }, "node_modules/playwright-core": { - "version": "1.41.0-beta-1705101589000", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.0-beta-1705101589000.tgz", - "integrity": "sha512-w3aDw2Kp/ZwAUSqLZdmd+mq6khl3ufb2csM51b85r9t8S4m2JYoz1WHFNGNHqFWHBXSrjSBXzFLNgKUmwizRuA==", + "version": "1.42.0-beta-1708998235000", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.0-beta-1708998235000.tgz", + "integrity": "sha512-kgr9rFXjX1pakRqXWwUIPlPMOdaUVHDu7vuGSryQOfoIld6CQI39vGkpb6rFiRwU3gpK4nL/UtXTqoxcMff3DA==", "bin": { "playwright-core": "cli.js" }, @@ -66,11 +66,11 @@ }, "dependencies": { "@playwright/test": { - "version": "1.41.0-beta-1705101589000", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.0-beta-1705101589000.tgz", - "integrity": "sha512-tpaEm+ih0PhJdk6yrRtk40I9ahErhGYZT2lR65ZZ6Il7tMnUgzxF5OOw3XLGJ59T29nMyI1+hKGqQnf8nUKkQw==", + "version": "1.42.0-beta-1708998235000", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.42.0-beta-1708998235000.tgz", + "integrity": "sha512-tPONSh/yGC9dApTJ/aSQ83lqqk0kV6mdlIpJYpmh5LWHyZnl2EcwAqLeNF6GxU2r4aIOMaZPzkEE+WPw/isxWw==", "requires": { - "playwright": "1.41.0-beta-1705101589000" + "playwright": "1.42.0-beta-1708998235000" } }, "fsevents": { @@ -80,18 +80,18 @@ "optional": true }, "playwright": { - "version": "1.41.0-beta-1705101589000", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.0-beta-1705101589000.tgz", - "integrity": "sha512-3mMpZXmkw+fGIb+wBpvDZ4OEm7L1QYptgJgTdP8/OEitYjV5Q05MRZWbgelF/9ameoRpMz7rbkZTWUh/1UtrIw==", + "version": "1.42.0-beta-1708998235000", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.0-beta-1708998235000.tgz", + "integrity": "sha512-Iq85EGhAz0URDVtl9vKDVE0FjvEvPVs/46C/jWR5JpaI9tEsuXYoA1i1f5xA74ZhatskHigpuJYbm2YpwO3mIw==", "requires": { "fsevents": "2.3.2", - "playwright-core": "1.41.0-beta-1705101589000" + "playwright-core": "1.42.0-beta-1708998235000" } }, "playwright-core": { - "version": "1.41.0-beta-1705101589000", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.0-beta-1705101589000.tgz", - "integrity": "sha512-w3aDw2Kp/ZwAUSqLZdmd+mq6khl3ufb2csM51b85r9t8S4m2JYoz1WHFNGNHqFWHBXSrjSBXzFLNgKUmwizRuA==" + "version": "1.42.0-beta-1708998235000", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.0-beta-1708998235000.tgz", + "integrity": "sha512-kgr9rFXjX1pakRqXWwUIPlPMOdaUVHDu7vuGSryQOfoIld6CQI39vGkpb6rFiRwU3gpK4nL/UtXTqoxcMff3DA==" } } } diff --git a/tests/playwright-test/stable-test-runner/package.json b/tests/playwright-test/stable-test-runner/package.json index 17ce2477d8..bb305f7aa5 100644 --- a/tests/playwright-test/stable-test-runner/package.json +++ b/tests/playwright-test/stable-test-runner/package.json @@ -1,6 +1,6 @@ { "private": true, "dependencies": { - "@playwright/test": "1.41.0-beta-1705101589000" + "@playwright/test": "1.42.0-beta-1708998235000" } } diff --git a/tests/playwright-test/stdio.spec.ts b/tests/playwright-test/stdio.spec.ts index 251cf527bb..e0609b1ac6 100644 --- a/tests/playwright-test/stdio.spec.ts +++ b/tests/playwright-test/stdio.spec.ts @@ -128,3 +128,30 @@ test('should not throw type error when using assert', async ({ runInlineTest }) expect(result.output).not.toContain(`TypeError: process.stderr.hasColors is not a function`); expect(result.output).toContain(`AssertionError`); }); + +test('should provide stubs for tty.WriteStream methods on process.stdout/stderr', async ({ runInlineTest }) => { + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29839' }); + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + import type * as tty from 'tty'; + async function checkMethods(stream: tty.WriteStream) { + expect(stream.isTTY).toBe(true); + await new Promise(r => stream.clearLine(-1, r));; + await new Promise(r => stream.clearScreenDown(r));; + await new Promise(r => stream.cursorTo(0, 0, r));; + await new Promise(r => stream.cursorTo(0, r));; + await new Promise(r => stream.moveCursor(1, 1, r));; + expect(stream.getWindowSize()).toEqual([stream.columns, stream.rows]); + // getColorDepth() and hasColors() are covered in other tests. + } + test('process.stdout implementd tty.WriteStream methods', () => { + checkMethods(process.stdout); + }); + test('process.stderr implementd tty.WriteStream methods', () => { + checkMethods(process.stderr); + }); + ` + }); + expect(result.exitCode).toBe(0); +}); diff --git a/tests/playwright-test/test-output-dir.spec.ts b/tests/playwright-test/test-output-dir.spec.ts index 6d74551303..7768bd5e86 100644 --- a/tests/playwright-test/test-output-dir.spec.ts +++ b/tests/playwright-test/test-output-dir.spec.ts @@ -413,7 +413,7 @@ test('should allow shorten long output dirs characters in the output dir', async `, }); const outputDir = result.outputLines[0]; - expect(outputDir).toBe(path.join(testInfo.outputDir, 'test-results', 'very-deep-and-long-file-name-that-i-want-to-be-99202--keeps-going-and-going-and-we-should-shorten-it')); + expect(outputDir).toBe(path.join(testInfo.outputDir, 'test-results', 'very-deep-and-long-file-na-99202-ng-and-we-should-shorten-it')); }); test('should not mangle double dashes', async ({ runInlineTest }, testInfo) => { diff --git a/tests/playwright-test/test-step.spec.ts b/tests/playwright-test/test-step.spec.ts index c00521e9ae..4aa07c6a52 100644 --- a/tests/playwright-test/test-step.spec.ts +++ b/tests/playwright-test/test-step.spec.ts @@ -261,6 +261,10 @@ test('should report before hooks step error', async ({ runInlineTest }) => { category: 'hook', title: 'After Hooks', }, + { + category: 'hook', + title: 'Worker Cleanup', + }, { error: expect.any(Object) } @@ -345,6 +349,12 @@ test('should not report nested after hooks', async ({ runInlineTest }) => { category: 'fixture', title: 'fixture: context', }, + ], + }, + { + category: 'hook', + title: 'Worker Cleanup', + steps: [ { category: 'fixture', title: 'fixture: browser', @@ -577,6 +587,10 @@ test('should report custom expect steps', async ({ runInlineTest }) => { category: 'hook', title: 'After Hooks', }, + { + category: 'hook', + title: 'Worker Cleanup', + }, { error: expect.any(Object) } @@ -658,6 +672,7 @@ test('should mark step as failed when soft expect fails', async ({ runInlineTest location: { file: 'a.test.ts', line: expect.any(Number), column: expect.any(Number) } }, { title: 'After Hooks', category: 'hook' }, + { title: 'Worker Cleanup', category: 'hook' }, { error: expect.any(Object) } ]); }); @@ -972,6 +987,12 @@ test('should not mark page.close as failed when page.click fails', async ({ runI }, ], }, + ], + }, + { + category: 'hook', + title: 'Worker Cleanup', + steps: [ { category: 'fixture', title: 'fixture: browser', @@ -1168,6 +1189,10 @@ test('should show final toPass error', async ({ runInlineTest }) => { title: 'After Hooks', category: 'hook', }, + { + title: 'Worker Cleanup', + category: 'hook', + }, { error: { message: expect.stringContaining('Error: expect(received).toBe(expected)'), @@ -1255,6 +1280,10 @@ test('should propagate nested soft errors', async ({ runInlineTest }) => { category: 'hook', title: 'After Hooks', }, + { + category: 'hook', + title: 'Worker Cleanup', + }, { error: { message: expect.stringContaining('Error: expect(received).toBe(expected)'), @@ -1348,6 +1377,10 @@ test('should not propagate nested hard errors', async ({ runInlineTest }) => { category: 'hook', title: 'After Hooks', }, + { + category: 'hook', + title: 'Worker Cleanup', + }, { error: { message: expect.stringContaining('Error: expect(received).toBe(expected)'), @@ -1404,6 +1437,10 @@ test('should step w/o box', async ({ runInlineTest }) => { category: 'hook', title: 'After Hooks', }, + { + category: 'hook', + title: 'Worker Cleanup', + }, { error: { message: expect.stringContaining('Error: expect(received).toBe(expected)'), @@ -1453,6 +1490,10 @@ test('should step w/ box', async ({ runInlineTest }) => { category: 'hook', title: 'After Hooks', }, + { + category: 'hook', + title: 'Worker Cleanup', + }, { error: { message: expect.stringContaining('expect(received).toBe(expected)'), @@ -1502,6 +1543,10 @@ test('should soft step w/ box', async ({ runInlineTest }) => { category: 'hook', title: 'After Hooks', }, + { + category: 'hook', + title: 'Worker Cleanup', + }, { error: { message: expect.stringContaining('Error: expect(received).toBe(expected)'), diff --git a/tests/playwright-test/test-tag.spec.ts b/tests/playwright-test/test-tag.spec.ts index e442c2acaa..9259e3b1c6 100644 --- a/tests/playwright-test/test-tag.spec.ts +++ b/tests/playwright-test/test-tag.spec.ts @@ -146,3 +146,32 @@ test('should enforce @ symbol', async ({ runInlineTest }) => { expect(result.exitCode).toBe(1); expect(result.output).toContain(`Error: Tag must start with "@" symbol, got "foo" instead.`); }); + +test('should be included in testInfo', async ({ runInlineTest }, testInfo) => { + const result = await runInlineTest({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('test without tag', async ({}, testInfo) => { + expect(testInfo.tags).toStrictEqual([]); + }); + test('test with tag',{ tag: '@tag1' }, async ({}, testInfo) => { + expect(testInfo.tags).toStrictEqual(["@tag1"]); + }); + `, + }); + expect(result.exitCode).toBe(0); +}); + +test('should be included in testInfo if comming from describe', async ({ runInlineTest }, testInfo) => { + const result = await runInlineTest({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test.describe('describe with tag', { tag: '@tag2' }, async ()=>{ + test('test with tag', async ({}, testInfo) => { + expect(testInfo.tags).toStrictEqual(["@tag2"]); + }); + }); + `, + }); + expect(result.exitCode).toBe(0); +}); diff --git a/tests/playwright-test/timeout.spec.ts b/tests/playwright-test/timeout.spec.ts index 741b10e62f..0ffa2164ce 100644 --- a/tests/playwright-test/timeout.spec.ts +++ b/tests/playwright-test/timeout.spec.ts @@ -455,3 +455,62 @@ test('should respect test.describe.configure', async ({ runInlineTest }) => { expect(result.output).toContain('test1-1000'); expect(result.output).toContain('test2-2000'); }); + +test('beforeEach timeout should prevent others from running', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test.beforeEach(async () => { + console.log('\\n%%beforeEach1'); + await new Promise(f => setTimeout(f, 2500)); + }); + test.beforeEach(async () => { + console.log('\\n%%beforeEach2'); + }); + test('test', async ({}) => { + }); + test.afterEach(async () => { + console.log('\\n%%afterEach'); + await new Promise(f => setTimeout(f, 1500)); + }); + ` + }, { timeout: 2000 }); + expect(result.exitCode).toBe(1); + expect(result.failed).toBe(1); + expect(result.outputLines).toEqual(['beforeEach1', 'afterEach']); +}); + +test('should report up to 3 timeout errors', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test as base } from '@playwright/test'; + + const test = base.extend<{}, { autoWorker: void }>({ + autoWorker: [ + async ({}, use) => { + await use(); + await new Promise(() => {}); + }, + { scope: 'worker', auto: true }, + ], + }) + + test('test1', async () => { + await new Promise(() => {}); + }); + + test.afterEach(async () => { + await new Promise(() => {}); + }); + + test.afterAll(async () => { + await new Promise(() => {}); + }); + ` + }, { timeout: 1000 }); + expect(result.exitCode).toBe(1); + expect(result.failed).toBe(1); + expect(result.output).toContain('Test timeout of 1000ms exceeded.'); + expect(result.output).toContain('Test timeout of 1000ms exceeded while running "afterEach" hook.'); + expect(result.output).toContain('Worker teardown timeout of 1000ms exceeded while tearing down "autoWorker".'); +}); diff --git a/tests/playwright-test/ui-mode-test-attachments.spec.ts b/tests/playwright-test/ui-mode-test-attachments.spec.ts index d5971bbe2c..71b67e3707 100644 --- a/tests/playwright-test/ui-mode-test-attachments.spec.ts +++ b/tests/playwright-test/ui-mode-test-attachments.spec.ts @@ -24,6 +24,7 @@ test('should contain text attachment', async ({ runUITest }) => { import { test } from '@playwright/test'; test('attach test', async () => { await test.info().attach('note', { path: __filename }); + await test.info().attach('🎭', { body: 'hi tester!', contentType: 'text/plain' }); }); `, }); @@ -31,12 +32,17 @@ test('should contain text attachment', async ({ runUITest }) => { await page.getByTitle('Run all').click(); await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)'); await page.getByText('Attachments').click(); - await page.getByText('attach "note"', { exact: true }).click(); - const downloadPromise = page.waitForEvent('download'); - await page.getByRole('link', { name: 'note' }).click(); - const download = await downloadPromise; - expect(download.suggestedFilename()).toBe('note'); - expect((await readAllFromStream(await download.createReadStream())).toString()).toContain('attach test'); + for (const { name, content } of [ + { name: 'note', content: 'attach test' }, + { name: '🎭', content: 'hi tester!' } + ]) { + await page.getByText(`attach "${name}"`, { exact: true }).click(); + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('link', { name: name }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe(name); + expect((await readAllFromStream(await download.createReadStream())).toString()).toContain(content); + } }); test('should contain binary attachment', async ({ runUITest }) => { diff --git a/tests/playwright-test/ui-mode-test-filters.spec.ts b/tests/playwright-test/ui-mode-test-filters.spec.ts index 3e21392b66..f35d4dec4a 100644 --- a/tests/playwright-test/ui-mode-test-filters.spec.ts +++ b/tests/playwright-test/ui-mode-test-filters.spec.ts @@ -24,7 +24,7 @@ const basicTestTree = { test('passes', () => {}); test('fails', () => { expect(1).toBe(2); }); test.describe('suite', () => { - test('inner passes', () => {}); + test('inner passes', { tag: '@smoke' }, () => {}); test('inner fails', () => { expect(1).toBe(2); }); }); `, @@ -46,6 +46,16 @@ test('should filter by title', async ({ runUITest }) => { `); }); +test('should filter by explicit tags', async ({ runUITest }) => { + const { page } = await runUITest(basicTestTree); + await page.getByPlaceholder('Filter').fill('@smoke inner'); + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ◯ a.test.ts + ▼ ◯ suite + ◯ inner passes + `); +}); + test('should filter by status', async ({ runUITest }) => { const { page } = await runUITest(basicTestTree); diff --git a/tests/playwright-test/ui-mode-test-setup.spec.ts b/tests/playwright-test/ui-mode-test-setup.spec.ts index 702fbf7640..4def43d078 100644 --- a/tests/playwright-test/ui-mode-test-setup.spec.ts +++ b/tests/playwright-test/ui-mode-test-setup.spec.ts @@ -40,9 +40,12 @@ test('should run global setup and teardown', async ({ runUITest }) => { }); await page.getByTitle('Run all').click(); await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)'); + + await page.getByTitle('Toggle output').click(); + await expect(page.getByTestId('output')).toContainText('from-global-setup'); await page.close(); + await expect.poll(() => testProcess.outputLines()).toEqual([ - 'from-global-setup', 'from-global-teardown', ]); }); @@ -70,9 +73,11 @@ test('should teardown on sigint', async ({ runUITest }) => { }); await page.getByTitle('Run all').click(); await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)'); + await page.getByTitle('Toggle output').click(); + await expect(page.getByTestId('output')).toContainText('from-global-setup'); + testProcess.process.kill('SIGINT'); await expect.poll(() => testProcess.outputLines()).toEqual([ - 'from-global-setup', 'from-global-teardown', ]); }); diff --git a/tests/playwright-test/ui-mode-test-shortcut.spec.ts b/tests/playwright-test/ui-mode-test-shortcut.spec.ts new file mode 100644 index 0000000000..8768ccbca0 --- /dev/null +++ b/tests/playwright-test/ui-mode-test-shortcut.spec.ts @@ -0,0 +1,69 @@ +/** + * 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 { test, expect, retries, dumpTestTree } from './ui-mode-fixtures'; + +test.describe.configure({ mode: 'parallel', retries }); + +const basicTestTree = { + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('test 0', () => { test.skip(); }); + test('test 1', () => {}); + test('test 2', async () => { await new Promise(() => {}); }); + test('test 3', async () => {}); + ` +}; + +test('should stop on F6', async ({ runUITest }) => { + const { page } = await runUITest(basicTestTree); + + await expect(page.getByTitle('Run all')).toBeEnabled(); + await expect(page.getByTitle('Stop')).toBeDisabled(); + + await page.getByTitle('Run all').click(); + + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ↻ a.test.ts + ⊘ test 0 + ✅ test 1 + ↻ test 2 + 🕦 test 3 + `); + + await expect(page.getByTitle('Run all')).toBeDisabled(); + await expect(page.getByTitle('Stop')).toBeEnabled(); + + await page.keyboard.press('F6'); + + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ◯ a.test.ts + ⊘ test 0 + ✅ test 1 + ◯ test 2 + ◯ test 3 + `); +}); + +test('should reload on F5', async ({ runUITest }) => { + const { page } = await runUITest(basicTestTree); + + await page.getByTitle('Run all').click(); + await expect(page.getByTestId('status-line')).toHaveText('Running 1/4 passed (25%)'); + + await page.keyboard.press('F5'); + await expect(page.getByTestId('status-line')).toBeHidden(); +}); \ No newline at end of file diff --git a/tests/playwright-test/ui-mode-test-update.spec.ts b/tests/playwright-test/ui-mode-test-update.spec.ts index 67102b55c0..4996c959e6 100644 --- a/tests/playwright-test/ui-mode-test-update.spec.ts +++ b/tests/playwright-test/ui-mode-test-update.spec.ts @@ -169,7 +169,7 @@ test('should pick new / deleted nested tests', async ({ runUITest, writeFiles, d `); }); -test('should update test locations', async ({ runUITest, writeFiles, deleteFile }) => { +test('should update test locations', async ({ runUITest, writeFiles }) => { const { page } = await runUITest({ 'a.test.ts': ` import { test, expect } from '@playwright/test'; @@ -182,8 +182,8 @@ test('should update test locations', async ({ runUITest, writeFiles, deleteFile ◯ passes `); - const messages: any = []; - await page.exposeBinding('_overrideProtocolForTest', (_, data) => messages.push(data)); + const messages: any[] = []; + await page.exposeBinding('__logForTest', (source, arg) => messages.push(arg)); const passesItemLocator = page.getByRole('listitem').filter({ hasText: 'passes' }); await passesItemLocator.hover(); @@ -192,7 +192,11 @@ test('should update test locations', async ({ runUITest, writeFiles, deleteFile expect(messages).toEqual([{ method: 'open', params: { - location: expect.stringContaining('a.test.ts:3'), + location: { + file: expect.stringContaining('a.test.ts'), + line: 3, + column: 11, + } }, }]); @@ -218,7 +222,11 @@ test('should update test locations', async ({ runUITest, writeFiles, deleteFile expect(messages).toEqual([{ method: 'open', params: { - location: expect.stringContaining('a.test.ts:5'), + location: { + file: expect.stringContaining('a.test.ts'), + line: 5, + column: 11, + } }, }]); diff --git a/tests/playwright-test/ui-mode-test-watch.spec.ts b/tests/playwright-test/ui-mode-test-watch.spec.ts index dd92a7bc9e..f248baaacd 100644 --- a/tests/playwright-test/ui-mode-test-watch.spec.ts +++ b/tests/playwright-test/ui-mode-test-watch.spec.ts @@ -286,6 +286,6 @@ test('should not watch output', async ({ runUITest }) => { await page.getByTitle('Run all').click(); await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)'); - expect(commands).toContain('run'); - expect(commands).not.toContain('list'); + expect(commands).toContain('runTests'); + expect(commands).not.toContain('listTests'); }); diff --git a/tests/playwright-test/ui-mode-trace.spec.ts b/tests/playwright-test/ui-mode-trace.spec.ts index 215afe1d2b..194e89afba 100644 --- a/tests/playwright-test/ui-mode-trace.spec.ts +++ b/tests/playwright-test/ui-mode-trace.spec.ts @@ -96,6 +96,7 @@ test('should merge screenshot assertions', async ({ runUITest }, testInfo) => { /expect.toHaveScreenshot[\d.]+m?s/, /attach "trace-test-1-actual.png/, /After Hooks[\d.]+m?s/, + /Worker Cleanup[\d.]+m?s/, ]); }); diff --git a/tests/playwright-test/watch.spec.ts b/tests/playwright-test/watch.spec.ts index c4b978819f..d697037801 100644 --- a/tests/playwright-test/watch.spec.ts +++ b/tests/playwright-test/watch.spec.ts @@ -91,8 +91,85 @@ test('should print dependencies in ESM mode', async ({ runInlineTest }) => { const output = result.output; const deps = JSON.parse(output.match(/###(.*)###/)![1]); expect(deps).toEqual({ - 'a.test.ts': ['helperA.ts', 'index.mjs'], - 'b.test.ts': ['helperA.ts', 'helperB.ts', 'index.mjs'], + 'a.test.ts': ['helperA.ts'], + 'b.test.ts': ['helperA.ts', 'helperB.ts'], + }); +}); + +test('should print dependencies in mixed CJS/ESM mode 1', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'package.json': `{ "type": "module" }`, + 'playwright.config.ts': ` + import { defineConfig } from '@playwright/test'; + export default defineConfig({ + globalTeardown: './globalTeardown.ts', + }); + `, + 'helperA.cjs': `exports.foo = () => {}`, + 'helperB.cjs': `require('./helperA');`, + 'a.test.ts': ` + import './helperA'; + import { test, expect } from '@playwright/test'; + test('passes', () => {}); + `, + 'b.test.cjs': ` + require('./helperB'); + const { test, expect } = require('@playwright/test'); + test('passes', () => {}); + `, + 'globalTeardown.ts': ` + import { fileDependencies } from 'playwright/lib/internalsForTest'; + export default () => { + console.log('###' + JSON.stringify(fileDependencies()) + '###'); + }; + ` + }, {}); + + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(2); + const output = result.output; + const deps = JSON.parse(output.match(/###(.*)###/)![1]); + expect(deps).toEqual({ + 'a.test.ts': ['helperA.cjs'], + 'b.test.cjs': ['helperA.cjs', 'helperB.cjs'], + }); +}); + +test('should print dependencies in mixed CJS/ESM mode 2', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.mts': ` + import { defineConfig } from '@playwright/test'; + export default defineConfig({ + globalTeardown: './globalTeardown.ts', + }); + `, + 'helperA.cjs': `exports.foo = () => {}`, + 'helperB.cts': `import './helperA';`, + 'a.test.mts': ` + import './helperA'; + import { test, expect } from '@playwright/test'; + test('passes', () => {}); + `, + 'b.test.ts': ` + import './helperB'; + const { test, expect } = require('@playwright/test'); + test('passes', () => {}); + `, + 'globalTeardown.ts': ` + import { fileDependencies } from 'playwright/lib/internalsForTest'; + export default () => { + console.log('###' + JSON.stringify(fileDependencies()) + '###'); + }; + ` + }, {}); + + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(2); + const output = result.output; + const deps = JSON.parse(output.match(/###(.*)###/)![1]); + expect(deps).toEqual({ + 'a.test.mts': ['helperA.cjs'], + 'b.test.ts': ['helperA.cjs', 'helperB.cts'], }); }); diff --git a/tests/playwright-test/web-server.spec.ts b/tests/playwright-test/web-server.spec.ts index 0cadbddad8..04e3c1d328 100644 --- a/tests/playwright-test/web-server.spec.ts +++ b/tests/playwright-test/web-server.spec.ts @@ -438,7 +438,8 @@ test(`should support self signed certificate`, async ({ runInlineTest, httpsServ test('should send Accept header', async ({ runInlineTest, server }) => { let acceptHeader: string | undefined | null = null; server.setRoute('/hello', (req, res) => { - if (acceptHeader === null) acceptHeader = req.headers.accept; + if (acceptHeader === null) + acceptHeader = req.headers.accept; res.end('hello'); }); const result = await runInlineTest({ @@ -661,7 +662,7 @@ test('should check ipv4 and ipv6 with happy eyeballs when URL is passed', async expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); expect(result.output).toContain('Process started'); - expect(result.output).toContain(`HTTP HEAD: http://localhost:${port}/`); + expect(result.output).toContain(`HTTP GET: http://localhost:${port}/`); expect(result.output).toContain('WebServer available'); }); diff --git a/tests/webview2/globalSetup.ts b/tests/webview2/globalSetup.ts index 7d8a340d9c..030a10401e 100644 --- a/tests/webview2/globalSetup.ts +++ b/tests/webview2/globalSetup.ts @@ -31,6 +31,7 @@ export default async () => { resolve(); })); const browser = await playwright.chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`); + console.log(`Using version ${browser.version()} WebView2 runtime`); const page = browser.contexts()[0].pages()[0]; await page.goto('data:text/html,'); const chromeVersion = await page.evaluate(() => navigator.userAgent.match(/Chrome\/(.*?) /)[1]); diff --git a/tsconfig.json b/tsconfig.json index 733973760a..56d9996996 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -35,11 +35,5 @@ "include": ["packages"], "exclude": [ "packages/*/lib", - "packages/playwright-ct-react", - "packages/playwright-ct-react17", - "packages/playwright-ct-solid", - "packages/playwright-ct-svelte", - "packages/playwright-ct-vue", - "packages/playwright-ct-vue2" ], } diff --git a/utils/build/build-playwright-driver.sh b/utils/build/build-playwright-driver.sh index 471d7505f5..5e56bf0896 100755 --- a/utils/build/build-playwright-driver.sh +++ b/utils/build/build-playwright-driver.sh @@ -22,7 +22,6 @@ function build { NODE_DIR=$1 SUFFIX=$2 ARCHIVE=$3 - RUN_DRIVER=$4 NODE_URL=https://nodejs.org/dist/v${NODE_VERSION}/${NODE_DIR}.${ARCHIVE} echo "Building playwright-${PACKAGE_VERSION}-${SUFFIX}" @@ -57,16 +56,6 @@ function build { rm package-lock.json cd .. - if [[ "${RUN_DRIVER}" == *".cmd" ]]; then - cp ../../${RUN_DRIVER} ./playwright.cmd - chmod +x ./playwright.cmd - elif [[ "${RUN_DRIVER}" == *".sh" ]]; then - cp ../../${RUN_DRIVER} ./playwright.sh - chmod +x ./playwright.sh - else - echo "Unsupported RUN_DRIVER ${RUN_DRIVER}" - exit 1 - fi # NPM install does intentionally set the modification date back to 1985 for all the files. This confuses language binding # update mechanisms, which expect the modification date to be recent to decide which file to override. See: @@ -77,8 +66,8 @@ function build { zip -q -r ../playwright-${PACKAGE_VERSION}-${SUFFIX}.zip . } -build "node-v${NODE_VERSION}-darwin-x64" "mac" "tar.gz" "run-driver-posix.sh" -build "node-v${NODE_VERSION}-darwin-arm64" "mac-arm64" "tar.gz" "run-driver-posix.sh" -build "node-v${NODE_VERSION}-linux-x64" "linux" "tar.gz" "run-driver-posix.sh" -build "node-v${NODE_VERSION}-linux-arm64" "linux-arm64" "tar.gz" "run-driver-posix.sh" -build "node-v${NODE_VERSION}-win-x64" "win32_x64" "zip" "run-driver-win.cmd" +build "node-v${NODE_VERSION}-darwin-x64" "mac" "tar.gz" +build "node-v${NODE_VERSION}-darwin-arm64" "mac-arm64" "tar.gz" +build "node-v${NODE_VERSION}-linux-x64" "linux" "tar.gz" +build "node-v${NODE_VERSION}-linux-arm64" "linux-arm64" "tar.gz" +build "node-v${NODE_VERSION}-win-x64" "win32_x64" "zip" diff --git a/utils/build/run-driver-posix.sh b/utils/build/run-driver-posix.sh deleted file mode 100755 index af4ed059c5..0000000000 --- a/utils/build/run-driver-posix.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/sh -SCRIPT_PATH="$(cd "$(dirname "$0")" ; pwd -P)" -if [ -z "$PLAYWRIGHT_NODEJS_PATH" ]; then - PLAYWRIGHT_NODEJS_PATH="$SCRIPT_PATH/node" -fi -"$PLAYWRIGHT_NODEJS_PATH" "$SCRIPT_PATH/package/cli.js" "$@" diff --git a/utils/build/run-driver-win.cmd b/utils/build/run-driver-win.cmd deleted file mode 100755 index 69e98709a0..0000000000 --- a/utils/build/run-driver-win.cmd +++ /dev/null @@ -1,4 +0,0 @@ -@echo off -setlocal -if not defined PLAYWRIGHT_NODEJS_PATH set PLAYWRIGHT_NODEJS_PATH=%~dp0node.exe -"%PLAYWRIGHT_NODEJS_PATH%" "%~dp0package\cli.js" %* \ No newline at end of file diff --git a/utils/docker/.gitignore b/utils/docker/.gitignore new file mode 100644 index 0000000000..f5ca1e18c1 --- /dev/null +++ b/utils/docker/.gitignore @@ -0,0 +1,2 @@ +oras/ + diff --git a/utils/docker/publish_docker.sh b/utils/docker/publish_docker.sh index 8f2d1910fd..a1fb63b81e 100755 --- a/utils/docker/publish_docker.sh +++ b/utils/docker/publish_docker.sh @@ -53,6 +53,27 @@ tag_and_push() { echo "-- tagging: $target" docker tag $source $target docker push $target + attach_eol_manifest $target +} + +attach_eol_manifest() { + local image="$1" + local today=$(date -u +'%Y-%m-%d') + install_oras_if_needed + # oras is re-using Docker credentials, so we don't need to login. + # Following the advice in https://portal.microsofticm.com/imp/v3/incidents/incident/476783820/summary + ./oras/oras attach --artifact-type application/vnd.microsoft.artifact.lifecycle --annotation "vnd.microsoft.artifact.lifecycle.end-of-life.date=$today" $image +} + +install_oras_if_needed() { + if [[ -x oras/oras ]]; then + return + fi + local version="1.1.0" + curl -sLO "https://github.com/oras-project/oras/releases/download/v${version}/oras_${version}_linux_amd64.tar.gz" + mkdir -p oras + tar -zxf oras_${version}_linux_amd64.tar.gz -C oras + rm oras_${version}_linux_amd64.tar.gz } publish_docker_images_with_arch_suffix() { diff --git a/utils/doclint/documentation.js b/utils/doclint/documentation.js index de35df27ba..f0527c4120 100644 --- a/utils/doclint/documentation.js +++ b/utils/doclint/documentation.js @@ -566,7 +566,7 @@ class Type { return type; } - if (parsedType.args) { + if (parsedType.args || parsedType.retType) { const type = new Type('function'); type.args = []; // @ts-ignore @@ -737,7 +737,8 @@ function parseTypeExpression(type) { if (type[i] === '(') { name = type.substring(0, i); const matching = matchingBracket(type.substring(i), '(', ')'); - args = parseTypeExpression(type.substring(i + 1, i + matching - 1)); + const argsString = type.substring(i + 1, i + matching - 1); + args = argsString ? parseTypeExpression(argsString) : null; i = i + matching; if (type[i] === ':') { retType = parseTypeExpression(type.substring(i + 1)); diff --git a/utils/doclint/linting-code-snippets/cli.js b/utils/doclint/linting-code-snippets/cli.js index 42e78fdcab..8bde076b7c 100644 --- a/utils/doclint/linting-code-snippets/cli.js +++ b/utils/doclint/linting-code-snippets/cli.js @@ -180,7 +180,10 @@ class JSLintingService extends LintingService { * @returns {Promise} */ async lint(snippets) { - return Promise.all(snippets.map(async snippet => this._lintSnippet(snippet))); + const result = []; + for (let i = 0; i < snippets.length; ++i) + result.push(await this._lintSnippet(snippets[i])); + return result; } } diff --git a/utils/doclint/linting-code-snippets/python/requirements.txt b/utils/doclint/linting-code-snippets/python/requirements.txt index 3619672425..70f3034c8d 100644 --- a/utils/doclint/linting-code-snippets/python/requirements.txt +++ b/utils/doclint/linting-code-snippets/python/requirements.txt @@ -1 +1 @@ -black==23.3.0 +black==24.3.0 diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index fd88e84dca..6cafdcc932 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -248,7 +248,7 @@ export interface PlaywrightWorkerOptions { } export type ScreenshotMode = 'off' | 'on' | 'only-on-failure'; -export type TraceMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry' | 'on-all-retries'; +export type TraceMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry' | 'on-all-retries' | 'retain-on-first-failure'; export type VideoMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry'; export interface PlaywrightTestOptions { @@ -341,7 +341,24 @@ interface GenericAssertions { type FunctionAssertions = { /** - * Retries the callback until it passes. + * Retries the callback until all assertions within it pass or the `timeout` value is reached. + * The `intervals` parameter can be used to establish the probing frequency or pattern. + * + * **Usage** + * ```js + * await expect(async () => { + * const response = await page.request.get('https://api.example.com'); + * expect(response.status()).toBe(200); + * }).toPass({ + * // Probe, wait 1s, probe, wait 2s, probe, wait 10s, probe, wait 10s, probe + * intervals: [1_000, 2_000, 10_000], // Defaults to [100, 250, 500, 1000]. + * timeout: 60_000 // Defaults to 0 + * }); + * ``` + * + * Note that by default `toPass` does not respect custom expect timeout. + * + * @param options */ toPass(options?: { timeout?: number, intervals?: number[] }): Promise; }; @@ -484,4 +501,5 @@ type MergedExpect = Expect>; export function mergeExpects(...expects: List): MergedExpect; // This is required to not export everything by default. See https://github.com/Microsoft/TypeScript/issues/19545#issuecomment-340490459 -export {}; +export { }; + diff --git a/utils/generate_types/overrides-testReporter.d.ts b/utils/generate_types/overrides-testReporter.d.ts index db8ae7c6de..9aeb9c9d31 100644 --- a/utils/generate_types/overrides-testReporter.d.ts +++ b/utils/generate_types/overrides-testReporter.d.ts @@ -15,7 +15,7 @@ */ import type { FullConfig, FullProject, TestStatus, Metadata } from './test'; -export type { FullConfig, TestStatus } from './test'; +export type { FullConfig, TestStatus, FullProject } from './test'; export interface Suite { project(): FullProject | undefined; diff --git a/utils/generate_types/test/test.ts b/utils/generate_types/test/test.ts index 05ede91adf..e13c2ac43c 100644 --- a/utils/generate_types/test/test.ts +++ b/utils/generate_types/test/test.ts @@ -323,6 +323,20 @@ playwright.chromium.launch().then(async browser => { console.log(await resultHandle.jsonValue()); await resultHandle.dispose(); + // evaluteHandle with two different return types (JSHandle) + { + const handle = await page.evaluateHandle(() => '' as string | number); + const result = await handle.evaluate(value => value); + const assertion: AssertType = true; + } + // evaluteHandle with two different return types (ElementHandle) + { + const handle = await page.evaluateHandle(() => '' as any as HTMLInputElement | HTMLTextAreaElement); + await handle.evaluate(element => element.value); + const assertion: AssertType, typeof handle> = true; + } + + await browser.close(); })(); @@ -606,7 +620,7 @@ playwright.chromium.launch().then(async browser => { { const handle = await page.waitForSelector('*'); const value = await handle.evaluateHandle((e: HTMLInputElement, x) => e.disabled || x, 123); - const assertion: AssertType | playwright.JSHandle, typeof value> = true; + const assertion: AssertType, typeof value> = true; } { diff --git a/utils/roll_browser.js b/utils/roll_browser.js index a72a48eab6..ba5c41b379 100755 --- a/utils/roll_browser.js +++ b/utils/roll_browser.js @@ -61,8 +61,6 @@ Example: 'wk': 'webkit', }[args[0].toLowerCase()] ?? args[0].toLowerCase(); const descriptors = [browsersJSON.browsers.find(b => b.name === browserName)]; - if (browserName === 'chromium') - descriptors.push(browsersJSON.browsers.find(b => b.name === 'chromium-with-symbols')); if (browserName === 'firefox') descriptors.push(browsersJSON.browsers.find(b => b.name === 'firefox-asan'));