[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

feat(browser): support dom snapshot trace view (#10102)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Codex <noreply@openai.com>

authored by

Hiroshi Ogawa
Claude Sonnet 4.6
Codex
and committed by
GitHub
(Apr 23, 2026, 11:12 AM +0200) 7eddd273 04d37e9d

+4180 -138
+5
docs/.vitepress/config.ts
··· 807 807 docFooterText: 'Trace View | Browser Mode', 808 808 }, 809 809 { 810 + text: 'Playwright Traces', 811 + link: '/guide/browser/playwright-traces', 812 + docFooterText: 'Playwright Traces | Browser Mode', 813 + }, 814 + { 810 815 text: 'ARIA Snapshots', 811 816 link: '/guide/browser/aria-snapshots', 812 817 docFooterText: 'ARIA Snapshots | Browser Mode',
+2
docs/config/browser/trace.md
··· 11 11 12 12 Capture a trace of your browser test runs. You can preview traces with [Playwright Trace Viewer](https://trace.playwright.dev/). 13 13 14 + See [Playwright Traces](/guide/browser/playwright-traces) for the full workflow. 15 + 14 16 This options supports the following values: 15 17 16 18 - `'on'` - capture trace for all tests. (not recommended as it's performance heavy)
+70
docs/config/browser/traceview.md
··· 1 + --- 2 + title: browser.traceView | Config 3 + outline: deep 4 + --- 5 + 6 + # browser.traceView <Badge type="warning" text="Experimental" /> <Version>5.0.0</Version> 7 + 8 + - **Type:** `boolean | { enabled?: boolean; recordCanvas?: boolean; inlineImages?: boolean }` 9 + - **CLI:** `--browser.traceView` 10 + - **Default:** `false` 11 + 12 + Enable trace-view collection for browser tests. Vitest captures DOM snapshots for browser interactions and can show them in the browser UI, Vitest UI, or HTML reporter when those surfaces are enabled — no external tools required. 13 + 14 + ```ts 15 + export default defineConfig({ 16 + test: { 17 + browser: { 18 + traceView: true, 19 + }, 20 + }, 21 + }) 22 + ``` 23 + 24 + Use the object form to enable additional snapshot fidelity options: 25 + 26 + ```ts 27 + export default defineConfig({ 28 + test: { 29 + browser: { 30 + traceView: { 31 + enabled: true, 32 + inlineImages: true, 33 + recordCanvas: true, 34 + }, 35 + }, 36 + }, 37 + }) 38 + ``` 39 + 40 + | Option | Default | Description | 41 + | --- | --- | --- | 42 + | `enabled` | `false` | Enables Vitest trace-view artifact collection. | 43 + | `inlineImages` | `false` | Inlines loaded `<img>` pixels into snapshots for more portable replay, useful in the HTML reporter. | 44 + | `recordCanvas` | `false` | Captures canvas pixels in snapshots. | 45 + 46 + ## browser.traceView.enabled {#traceview-enabled} 47 + 48 + - **Type:** `boolean` 49 + - **Default:** `false` 50 + - **CLI:** `--browser.traceView.enabled` 51 + 52 + Enables Vitest trace-view artifact collection. 53 + 54 + ## browser.traceView.inlineImages {#traceview-inlineimages} 55 + 56 + - **Type:** `boolean` 57 + - **Default:** `false` 58 + - **CLI:** `--browser.traceView.inlineImages` 59 + 60 + Inlines loaded `<img>` pixels into snapshots for more portable replay, useful in the HTML reporter. 61 + 62 + ## browser.traceView.recordCanvas {#traceview-recordcanvas} 63 + 64 + - **Type:** `boolean` 65 + - **Default:** `false` 66 + - **CLI:** `--browser.traceView.recordCanvas` 67 + 68 + Captures canvas pixels in snapshots. This enables a weaker replay iframe sandbox because rrweb needs scripts to redraw canvas data. 69 + 70 + See [Trace View](/guide/browser/trace-view) for full documentation.
+2
docs/guide/browser/index.md
··· 330 330 331 331 By default, Vitest will automatically open the browser UI for development. Your tests will run inside an iframe in the center. You can configure the viewport by selecting the preferred dimensions, calling `page.viewport` inside the test, or setting default values in [the config](/config/browser/viewport). 332 332 333 + For an alternative debugging model that captures DOM snapshots for every test instead of showing a live iframe, see [Trace View](/guide/browser/trace-view). 334 + 333 335 ## Headless 334 336 335 337 Headless mode is another option available in the browser mode. In headless mode, the browser runs in the background without a user interface, which makes it useful for running automated tests. The headless option in Vitest can be set to a boolean value to enable or disable headless mode.
+126
docs/guide/browser/playwright-traces.md
··· 1 + # Playwright Traces 2 + 3 + Vitest Browser Mode supports generating Playwright's [trace files](https://playwright.dev/docs/trace-viewer#viewing-remote-traces). To enable tracing, you need to set the [`trace`](/config/browser/trace) option in the `test.browser` configuration. 4 + 5 + ::: warning 6 + Generating trace files is only available when using the [Playwright provider](/config/browser/playwright). 7 + ::: 8 + 9 + ::: code-group 10 + ```ts [vitest.config.js] 11 + import { defineConfig } from 'vitest/config' 12 + import { playwright } from '@vitest/browser-playwright' 13 + 14 + export default defineConfig({ 15 + test: { 16 + browser: { 17 + provider: playwright(), 18 + trace: 'on', 19 + }, 20 + }, 21 + }) 22 + ``` 23 + ```bash [CLI] 24 + vitest --browser.trace=on 25 + ``` 26 + ::: 27 + 28 + By default, Vitest will generate a trace file for each test. You can also configure it to only generate traces on test failures by setting `trace` to `'on-first-retry'`, `'on-all-retries'` or `'retain-on-failure'`. The files will be saved in `__traces__` folder next to your test files. The name of the trace includes the project name, the test name, the [`repeats`](/api/test#repeats) count and [`retry`](/api/test#retry) count: 29 + 30 + ``` 31 + chromium-my-test-0-0.trace.zip 32 + ^^^^^^^^ project name 33 + ^^^^^^ test name 34 + ^ repeat count 35 + ^ retry count 36 + ``` 37 + 38 + To change the output directory, you can set the `tracesDir` option in the `test.browser.trace` configuration. This way all traces will be stored in the same directory, grouped by the test file. 39 + 40 + ```ts [vitest.config.js] 41 + import { defineConfig } from 'vitest/config' 42 + import { playwright } from '@vitest/browser-playwright' 43 + 44 + export default defineConfig({ 45 + test: { 46 + browser: { 47 + provider: playwright(), 48 + trace: { 49 + mode: 'on', 50 + // the path is relative to the root of the project 51 + tracesDir: './playwright-traces', 52 + }, 53 + }, 54 + }, 55 + }) 56 + ``` 57 + 58 + The traces are available in reporters as [annotations](/guide/test-annotations). For example, in the HTML reporter, you can find the link to the trace file in the test details. 59 + 60 + ## Trace markers 61 + 62 + You can add explicit named markers to make the trace timeline easier to read: 63 + 64 + ```ts 65 + import { page } from 'vitest/browser' 66 + 67 + document.body.innerHTML = ` 68 + <button type="button">Sign in</button> 69 + ` 70 + 71 + await page.getByRole('button', { name: 'Sign in' }).mark('sign in button rendered') 72 + ``` 73 + 74 + Both `page.mark(name)` and `locator.mark(name)` are available. 75 + 76 + You can also group multiple operations under one marker with `page.mark(name, callback)`: 77 + 78 + ```ts 79 + await page.mark('sign in flow', async () => { 80 + await page.getByRole('textbox', { name: 'Email' }).fill('john@example.com') 81 + await page.getByRole('textbox', { name: 'Password' }).fill('secret') 82 + await page.getByRole('button', { name: 'Sign in' }).click() 83 + }) 84 + ``` 85 + 86 + You can also wrap reusable helpers with [`vi.defineHelper()`](/api/vi#vi-defineHelper) so trace entries point to where the helper is called, not its internals: 87 + 88 + ```ts 89 + import { vi } from 'vitest' 90 + import { page } from 'vitest/browser' 91 + 92 + const myRender = vi.defineHelper(async (content: string) => { 93 + document.body.innerHTML = content 94 + await page.elementLocator(document.body).mark('render helper') 95 + }) 96 + 97 + test('renders content', async () => { 98 + await myRender('<button>Hello</button>') // trace points to this line 99 + }) 100 + ``` 101 + 102 + ## Preview 103 + 104 + To open the trace file, you can use the Playwright Trace Viewer. Run the following command in your terminal: 105 + 106 + ```bash 107 + npx playwright show-trace "path-to-trace-file" 108 + ``` 109 + 110 + This will start the Trace Viewer and load the specified trace file. 111 + 112 + Alternatively, you can open the Trace Viewer in your browser at https://trace.playwright.dev and upload the trace file there. 113 + 114 + <img alt="Trace Viewer showing the trace timeline and rendered component" img-light src="/trace-viewer-light.png"> 115 + <img alt="Trace Viewer showing the trace timeline and rendered component" img-dark src="/trace-viewer-dark.png"> 116 + 117 + ## Source Location 118 + 119 + When you open a trace, you'll notice that Vitest groups browser interactions and links them back to the exact line in your test that triggered them. This happens automatically for: 120 + 121 + - `expect.element(...)` assertions 122 + - Interactive actions like `click`, `fill`, `type`, `hover`, `selectOptions`, `upload`, `dragAndDrop`, `tab`, `keyboard`, `wheel`, and screenshots 123 + 124 + Under the hood, Playwright still records its own low-level action events as usual. Vitest wraps them with source-location groups so you can jump straight from the trace timeline to the relevant line in your test. 125 + 126 + For anything not covered automatically, you can use `page.mark()` or `locator.mark()` to add your own trace groups — see [Trace markers](#trace-markers) above.
+127 -64
docs/guide/browser/trace-view.md
··· 1 - # Trace View 1 + # Trace View <Badge type="warning" text="Experimental" /> <Version>5.0.0</Version> 2 2 3 - Vitest Browser Mode supports generating Playwright's [trace files](https://playwright.dev/docs/trace-viewer#viewing-remote-traces). To enable tracing, you need to set the [`trace`](/config/browser/trace) option in the `test.browser` configuration. 3 + `browser.traceView` records browser interactions as DOM snapshots and lets you replay them step by step in Vitest's built-in trace viewer. It is useful when the live browser view is not enough: you can inspect earlier tests, failed retries, screenshots, assertions, and user actions after the browser has already moved on. 4 4 5 - ::: warning 6 - Generating trace files is only available when using the [Playwright provider](/config/browser/playwright). 5 + Trace view is additive to the current browser testing workflow. Enabling it does not force a single debugging mode. You can use it with the normal local browser UI, with a headless browser and Vitest UI, or with the HTML reporter in CI. 6 + 7 + ::: tip Trace view, browser UI, and HTML reports 8 + 9 + The normal local browser mode opens the [browser UI](/config/browser/ui), where tests run in a visible iframe. This is useful while developing, but the iframe only shows the current browser state. When another test runs, the previous rendered state is gone. 10 + 11 + `browser.traceView` keeps a replayable record for each test. In local browser UI mode, the trace viewer appears alongside the existing live view so you can keep using the browser UI while also inspecting recorded steps. 12 + 13 + For static output, add the [HTML reporter](/guide/reporters#html-reporter). The same trace viewer can then be opened from the generated report, which is useful for run-mode and CI failures. 14 + 7 15 ::: 8 16 17 + ::: details Looking for Playwright traces? 18 + 19 + This page now documents Vitest's built-in `browser.traceView` feature. The previous `browser.trace` guide for Playwright traces moved to [Playwright Traces](./playwright-traces). 20 + 21 + ::: 22 + 23 + ## Quick Start 24 + 25 + Enable trace view with the [`browser.traceView`](/config/browser/traceview) option: 26 + 9 27 ::: code-group 10 - ```ts [vitest.config.js] 28 + 29 + ```ts [vitest.config.ts] 11 30 import { defineConfig } from 'vitest/config' 12 - import { playwright } from '@vitest/browser-playwright' 13 31 14 32 export default defineConfig({ 15 33 test: { 16 34 browser: { 17 - provider: playwright(), 18 - trace: 'on', 35 + traceView: true, 19 36 }, 20 37 }, 21 38 }) 22 39 ``` 40 + 23 41 ```bash [CLI] 24 - vitest --browser.trace=on 42 + vitest --browser.traceView 25 43 ``` 44 + 26 45 ::: 27 46 28 - By default, Vitest will generate a trace file for each test. You can also configure it to only generate traces on test failures by setting `trace` to `'on-first-retry'`, `'on-all-retries'` or `'retain-on-failure'`. The files will be saved in `__traces__` folder next to your test files. The name of the trace includes the project name, the test name, the [`repeats`](/api/test#repeats) count and [`retry`](/api/test#retry) count: 47 + When `browser.traceView` is enabled, tests with recorded traces can be opened in the trace viewer from the [browser UI](/config/browser/ui), [Vitest UI](/guide/ui), and [HTML reporter](/guide/reporters#html-reporter). The viewer has two resizable panes: 29 48 30 - ``` 31 - chromium-my-test-0-0.trace.zip 32 - ^^^^^^^^ project name 33 - ^^^^^^ test name 34 - ^ repeat count 35 - ^ retry count 36 - ``` 49 + - **Step list** (left) — every recorded action, assertion, mark, and lifecycle entry, with name, timing, selector, and source location. Failed actions and assertions are highlighted in red. 50 + - **DOM snapshot** (right) — a reconstruction of the page at the selected step. The interacted element is highlighted in blue. 37 51 38 - To change the output directory, you can set the `tracesDir` option in the `test.browser.trace` configuration. This way all traces will be stored in the same directory, grouped by the test file. 52 + Selecting a step also opens its source location in the Editor tab when that location is available. 39 53 40 - ```ts [vitest.config.js] 41 - import { defineConfig } from 'vitest/config' 42 - import { playwright } from '@vitest/browser-playwright' 54 + <img alt="Vitest UI trace viewer showing step list and DOM snapshot" img-light src="/browser/trace-view-light.png"> 55 + <img alt="Vitest UI trace viewer showing step list and DOM snapshot" img-dark src="/browser/trace-view-dark.png"> 56 + 57 + <small>Example replay uses [Vuetify's](https://github.com/vuetifyjs/vuetify) `VDateInput` component.</small> 58 + 59 + 60 + ## Common Setups 61 + 62 + <!-- 63 + TODO: The browser UI / Vitest UI / browser driver combinations are not specific to trace view and might be better documented in the Browser Mode guide. Something like: 64 + 65 + | top-level --ui | browser.ui | browser.headless | Result | 66 + | --- | --- | --- | --- | 67 + | off | true | false | browser UI/live iframe in headed browser | 68 + | on | false | true | pure Vitest UI, tests in headless browser | 69 + | on | false | false | pure Vitest UI, tests in separate headed browser window | 70 + 71 + --> 72 + 73 + `browser.traceView` records traces. The browser mode, UI, and reporter options determine where you inspect them. 74 + 75 + | Goal | Configuration | Result | 76 + | --- | --- | --- | 77 + | Add trace replay to the normal local browser UI | `vitest --browser.traceView` | Uses the default local headed browser UI and adds trace replay for recorded tests. | 78 + | Debug locally with a headless browser | `vitest --browser.traceView --browser.headless --ui` | The browser runs headless, while Vitest UI shows recorded trace steps and snapshots. | 79 + | Debug locally with a visible browser window and Vitest UI | `vitest --browser.traceView --browser.headless=false --browser.ui=false --ui` | Vitest UI shows recorded trace steps and snapshots, while tests run in a separate headed browser window. | 80 + | Generate a static report for CI or run mode | `vitest run --browser.traceView --reporter=html` | The HTML report includes the trace viewer for recorded tests. | 81 + 82 + ## Relation to Playwright Traces 83 + 84 + `browser.traceView` and [`browser.trace`](/config/browser/trace) are independent features: 85 + 86 + | | `browser.traceView` | `browser.trace` | 87 + | ---------------------- | --------------------------------------------------------- | ---------------------------------------------- | 88 + | Provider support | All providers (playwright, webdriverio, preview) | Playwright only | 89 + | Viewer | Browser UI / Vitest UI / HTML reporter | Playwright Trace Viewer / trace.playwright.dev | 90 + | Format | [rrweb](https://github.com/rrweb-io/rrweb) DOM snapshots | Playwright `.trace.zip` | 91 + | Requires external tool | No | Yes (`npx playwright show-trace`) | 92 + 93 + You can enable both at the same time. See [Playwright Traces](./playwright-traces) for the `browser.trace` workflow. 94 + 95 + ## Recorded Steps 96 + 97 + Trace entries are recorded automatically for: 98 + 99 + - `expect.element(...)` assertions 100 + - Interactive actions like `click`, `dblClick`, `tripleClick`, `fill`, `clear`, `type`, `hover`, `selectOptions`, `upload`, `dragAndDrop`, `tab`, `keyboard`, `wheel`, and screenshots 101 + - Test runner lifecycle event (e.g. `vitest:onAfterRetryTask` is recorded after each test and retry run) 43 102 44 - export default defineConfig({ 45 - test: { 46 - browser: { 47 - provider: playwright(), 48 - trace: { 49 - mode: 'on', 50 - // the path is relative to the root of the project 51 - tracesDir: './playwright-traces', 52 - }, 53 - }, 54 - }, 55 - }) 56 - ``` 103 + Each entry captures the DOM state at that point, along with timing information, the selector, and the source location that triggered it. 57 104 58 - The traces are available in reporters as [annotations](/guide/test-annotations). For example, in the HTML reporter, you can find the link to the trace file in the test details. 105 + Element highlighting is best-effort. Some provider-specific selectors, shadow DOM selectors, or elements that are not present in the captured snapshot may not be highlighted. 59 106 60 - ## Trace markers 107 + ## Custom Trace Entries 61 108 62 - You can add explicit named markers to make the trace timeline easier to read: 109 + You can insert your own named entries with `page.mark()` and `locator.mark()`: 63 110 64 111 ```ts 65 112 import { page } from 'vitest/browser' 66 113 67 - document.body.innerHTML = ` 68 - <button type="button">Sign in</button> 69 - ` 114 + await page.mark('content rendered') 70 115 71 - await page.getByRole('button', { name: 'Sign in' }).mark('sign in button rendered') 116 + await page.getByRole('button', { name: 'Sign in' }).mark('sign in button') 72 117 ``` 73 118 74 - Both `page.mark(name)` and `locator.mark(name)` are available. 75 - 76 - You can also group multiple operations under one marker with `page.mark(name, callback)`: 119 + You can also pass a callback to `page.mark()`. Note that grouping is not currently supported — each inner action is recorded individually, and the mark entry appears at the end: 77 120 78 121 ```ts 79 122 await page.mark('sign in flow', async () => { ··· 83 126 }) 84 127 ``` 85 128 86 - You can also wrap reusable helpers with [`vi.defineHelper()`](/api/vi#vi-defineHelper) so trace entries point to where the helper is called, not its internals: 129 + Use [`vi.defineHelper()`](/api/vi#vi-defineHelper) to make entries from reusable helpers point to the call site rather than the helper's internals: 87 130 88 131 ```ts 89 132 import { vi } from 'vitest' 90 133 import { page } from 'vitest/browser' 91 134 92 - const myRender = vi.defineHelper(async (content: string) => { 93 - document.body.innerHTML = content 94 - await page.elementLocator(document.body).mark('render helper') 135 + const renderContent = vi.defineHelper(async (html: string) => { 136 + document.body.innerHTML = html 137 + await page.elementLocator(document.body).mark('render') 95 138 }) 96 139 97 - test('renders content', async () => { 98 - await myRender('<button>Hello</button>') // trace points to this line 140 + test('shows button', async () => { 141 + await renderContent('<button>Hello</button>') // trace entry points here 99 142 }) 100 143 ``` 101 144 102 - ## Preview 145 + ## Retries and Repeats 103 146 104 - To open the trace file, you can use the Playwright Trace Viewer. Run the following command in your terminal: 147 + Each attempt — retry or repeat — is recorded as a separate trace. When a test has multiple attempts, the viewer opens the most recent one by default. You can switch between attempts in the Report tab. 105 148 106 - ```bash 107 - npx playwright show-trace "path-to-trace-file" 149 + ## Snapshot Fidelity 150 + 151 + By default, trace view captures the DOM tree, attributes, form values, same-origin readable CSS, element scroll positions, viewport size, and window scroll position. Images and canvas pixels are not inlined by default. 152 + 153 + Stylesheets are captured through the browser's CSSOM. Readable `<style>` tags and same-origin `<link rel="stylesheet">` files are serialized into the snapshot and replayed as inline styles, so normal component styles keep working in the trace viewer and HTML reporter. This captures the parsed CSS rules the browser applied, not the exact original stylesheet bytes: comments, formatting, invalid rules, and CSS resource files such as background images or fonts are not bundled this way. 154 + 155 + Enable additional fidelity options with the object form: 156 + 157 + ```ts 158 + import { defineConfig } from 'vitest/config' 159 + 160 + export default defineConfig({ 161 + test: { 162 + browser: { 163 + traceView: { 164 + enabled: true, 165 + inlineImages: true, 166 + recordCanvas: true, 167 + }, 168 + }, 169 + }, 170 + }) 108 171 ``` 109 172 110 - This will start the Trace Viewer and load the specified trace file. 173 + `inlineImages` stores loaded `<img>` pixels in the trace snapshot. This is mostly useful for the HTML reporter, where the report should be portable without depending on external image URLs. This is pixel capture, not original resource capture: SVGs are rasterized, animated images are not preserved as animations, and CSS background images or fonts are not covered. Cross-origin images need CORS-readable pixels to be inlined; otherwise they can still render from the external URL if it remains reachable. 111 174 112 - Alternatively, you can open the Trace Viewer in your browser at https://trace.playwright.dev and upload the trace file there. 175 + `recordCanvas` stores readable canvas pixels in the trace snapshot. This is useful for charts and simple 2D canvas output, but it is not a full canvas drawing timeline and does not provide complete WebGL replay. 113 176 114 - <img alt="Trace Viewer showing the trace timeline and rendered component" img-light src="/trace-viewer-light.png"> 115 - <img alt="Trace Viewer showing the trace timeline and rendered component" img-dark src="/trace-viewer-dark.png"> 177 + ### External Resource Limits 178 + 179 + Trace view does not currently provide a general resource store. Resources that are not captured into the snapshot remain URL-backed. 116 180 117 - ## Source Location 181 + This means CSS background images and `@font-face` files referenced from serialized CSS still depend on their original URLs. External images can still render in the viewer when the browser can load the URL, but they are not portable in the HTML reporter unless `inlineImages` can capture their pixels. Cross-origin images need CORS-readable pixels for that capture; otherwise the browser can display them, but rrweb cannot safely draw them into a canvas data URL. 118 182 119 - When you open a trace, you'll notice that Vitest groups browser interactions and links them back to the exact line in your test that triggered them. This happens automatically for: 183 + Use `inlineImages` for loaded `<img>` elements that need to be portable in the HTML reporter. CSS subresources, fonts, non-CORS cross-origin images, videos, and other external files remain limitations of the current snapshot-based trace format. 120 184 121 - - `expect.element(...)` assertions 122 - - Interactive actions like `click`, `fill`, `type`, `hover`, `selectOptions`, `upload`, `dragAndDrop`, `tab`, `keyboard`, `wheel`, and screenshots 185 + ::: warning Canvas replay sandbox 123 186 124 - Under the hood, Playwright still records its own low-level action events as usual. Vitest wraps them with source-location groups so you can jump straight from the trace timeline to the relevant line in your test. 187 + `recordCanvas` enables a weaker iframe sandbox in the trace viewer. rrweb replays canvas data through an image load handler, so Vitest allows scripts inside the replay iframe for traces recorded with `recordCanvas`. Keep this option enabled only when canvas pixels are useful for debugging. 125 188 126 - For anything not covered automatically, you can use `page.mark()` or `locator.mark()` to add your own trace groups — see [Trace markers](#trace-markers) above. 189 + :::
+21
docs/guide/cli-generated.md
··· 429 429 430 430 Enable trace view mode. Supported: "on", "off", "on-first-retry", "on-all-retries", "retain-on-failure". 431 431 432 + ### browser.traceView.enabled 433 + 434 + - **CLI:** `--browser.traceView.enabled` 435 + - **Config:** [browser.traceView.enabled](/config/browser/traceview#traceview-enabled) 436 + 437 + Enable Vitest trace-view collection for browser tests (default: `false`) 438 + 439 + ### browser.traceView.recordCanvas 440 + 441 + - **CLI:** `--browser.traceView.recordCanvas` 442 + - **Config:** [browser.traceView.recordCanvas](/config/browser/traceview#traceview-recordcanvas) 443 + 444 + Capture canvas pixels in trace-view snapshots (default: `false`) 445 + 446 + ### browser.traceView.inlineImages 447 + 448 + - **CLI:** `--browser.traceView.inlineImages` 449 + - **Config:** [browser.traceView.inlineImages](/config/browser/traceview#traceview-inlineimages) 450 + 451 + Inline loaded image pixels in trace-view snapshots (default: `false`) 452 + 432 453 ### browser.locators.exact 433 454 434 455 - **CLI:** `--browser.locators.exact`
docs/public/browser/trace-view-dark.png

This is a binary file and will not be displayed.

docs/public/browser/trace-view-light.png

This is a binary file and will not be displayed.

+1
examples/lit/.gitignore
··· 1 1 __traces__ 2 2 __screenshots__ 3 + html
+1
packages/browser/package.json
··· 94 94 "ivya": "^1.8.0", 95 95 "mime": "^4.1.0", 96 96 "pathe": "catalog:", 97 + "rrweb-snapshot": "2.0.0-alpha.20", 97 98 "vitest": "workspace:*" 98 99 } 99 100 }
+42 -10
packages/browser/src/client/tester/context.ts
··· 16 16 import type { IframeViewportEvent } from '../client' 17 17 import type { BrowserRunnerState } from '../utils' 18 18 import type { Locator as LocatorAPI } from './locators/index' 19 + import type { BrowserTraceEntryStatus } from './trace' 19 20 import { vi } from 'vitest' 20 21 import { __INTERNAL, stringify } from 'vitest/internal/browser' 21 - import { ensureAwaited, getBrowserState, getWorkerState } from '../utils' 22 + import { ensureAwaited, getBrowserState, getWorkerState, now } from '../utils' 22 23 import { convertToSelector, isLocator, processTimeoutOptions, resolveUserEventWheelOptions } from './tester-utils' 24 + import { recordBrowserTraceEntry } from './trace' 23 25 24 26 // this file should not import anything directly, only types and utils 25 27 ··· 361 363 ): any { 362 364 const currentTest = getWorkerState().current 363 365 const hasActiveTrace = !!currentTest && getBrowserState().activeTraceTaskIds.has(currentTest.id) 366 + const hasActiveTraceView = !!currentTest && getBrowserState().browserTraceAttempts.has(currentTest.id) 364 367 365 368 if (typeof bodyOrOptions === 'function') { 366 369 return ensureAwaited(async (error) => { 370 + let status: BrowserTraceEntryStatus = 'pass' 371 + const startTime = now() 367 372 if (hasActiveTrace) { 368 373 await triggerCommand( 369 374 '__vitest_groupTraceStart', ··· 377 382 try { 378 383 return await bodyOrOptions() 379 384 } 385 + catch (err) { 386 + status = 'fail' 387 + throw err 388 + } 380 389 finally { 390 + if (hasActiveTraceView) { 391 + // TODO: support nested trace 392 + recordBrowserTraceEntry(currentTest, { 393 + name, 394 + kind: 'mark', 395 + status, 396 + startTime, 397 + duration: now() - startTime, 398 + stack: options?.stack ?? error?.stack, 399 + }) 400 + } 381 401 if (hasActiveTrace) { 382 402 await triggerCommand('__vitest_groupTraceEnd', [], error) 383 403 } ··· 385 405 }) 386 406 } 387 407 388 - if (!hasActiveTrace) { 408 + if (!hasActiveTrace && !hasActiveTraceView) { 389 409 return Promise.resolve() 390 410 } 391 411 392 - return ensureAwaited(error => triggerCommand( 393 - '__vitest_markTrace', 394 - [{ 395 - name, 396 - stack: bodyOrOptions?.stack ?? error?.stack, 397 - }], 398 - error, 399 - )) 412 + return ensureAwaited((error) => { 413 + if (hasActiveTraceView) { 414 + recordBrowserTraceEntry(currentTest, { 415 + name, 416 + kind: 'mark', 417 + stack: bodyOrOptions?.stack ?? error?.stack, 418 + }) 419 + } 420 + if (!hasActiveTrace) { 421 + return Promise.resolve() 422 + } 423 + return triggerCommand( 424 + '__vitest_markTrace', 425 + [{ 426 + name, 427 + stack: bodyOrOptions?.stack ?? error?.stack, 428 + }], 429 + error, 430 + ) 431 + }) 400 432 }, 401 433 getByRole() { 402 434 throw new Error(`Method "getByRole" is not supported by the "${provider}" provider.`)
+27 -9
packages/browser/src/client/tester/expect-element.ts
··· 1 1 import type { Assertion, ExpectPollOptions, PromisifyDomAssertion } from 'vitest' 2 2 import type { Locator } from 'vitest/browser' 3 + import type { BrowserTraceEntryStatus } from './trace' 3 4 import { chai, expect } from 'vitest' 4 5 import { getType } from 'vitest/internal/browser' 5 - import { getBrowserState, getWorkerState } from '../utils' 6 + import { getBrowserState, getWorkerState, now } from '../utils' 6 7 import { ariaMatchers } from './aria' 7 8 import { matchers } from './expect' 8 9 import { processTimeoutOptions } from './tester-utils' 10 + import { recordBrowserTraceEntry } from './trace' 9 11 10 12 const kLocator = Symbol.for('$$vitest:locator') 11 13 ··· 58 60 59 61 // ask `expect.poll` to invoke trace after the assertion 60 62 const currentTest = getWorkerState().current 61 - if (currentTest && getBrowserState().activeTraceTaskIds.has(currentTest.id)) { 63 + const hasActiveTrace = !!currentTest && getBrowserState().activeTraceTaskIds.has(currentTest.id) 64 + const hasActiveTraceView = !!currentTest && getBrowserState().browserTraceAttempts.has(currentTest.id) 65 + if (currentTest && (hasActiveTrace || hasActiveTraceView)) { 62 66 const sourceError = new Error('__vitest_mark_trace__') 63 - chai.util.flag(expectElement, '_poll.onSettled', async (meta: { assertion: Assertion; status: 'pass' | 'fail' }) => { 67 + const startTime = now() 68 + chai.util.flag(expectElement, '_poll.onSettled', async (meta: { assertion: Assertion; status: BrowserTraceEntryStatus }) => { 64 69 const isNot = chai.util.flag(meta.assertion, 'negate') 65 70 const name = chai.util.flag(meta.assertion, '_name') || '<unknown>' 66 71 const baseName = `expect.element().${isNot ? 'not.' : ''}${name}` ··· 68 73 const selector = !elementOrLocator || elementOrLocator instanceof Element 69 74 ? undefined 70 75 : elementOrLocator.selector 71 - await getBrowserState().commands.triggerCommand( 72 - '__vitest_markTrace', 73 - [{ 76 + if (hasActiveTraceView) { 77 + recordBrowserTraceEntry(currentTest, { 74 78 name: traceName, 79 + kind: 'expect', 80 + status: meta.status, 81 + startTime, 82 + duration: now() - startTime, 75 83 selector, 76 84 stack: sourceError.stack, 77 - }], 78 - sourceError, 79 - ) 85 + }) 86 + } 87 + if (hasActiveTrace) { 88 + await getBrowserState().commands.triggerCommand( 89 + '__vitest_markTrace', 90 + [{ 91 + name: traceName, 92 + selector, 93 + stack: sourceError.stack, 94 + }], 95 + sourceError, 96 + ) 97 + } 80 98 }) 81 99 } 82 100
+28 -10
packages/browser/src/client/tester/locators/index.ts
··· 29 29 import { __INTERNAL, getSafeTimers } from 'vitest/internal/browser' 30 30 import { ensureAwaited, getBrowserState, getWorkerState } from '../../utils' 31 31 import { escapeForTextSelector, isLocator, processTimeoutOptions, resolveUserEventWheelOptions } from '../tester-utils' 32 + import { recordBrowserTraceEntry } from '../trace' 32 33 33 34 export { ensureAwaited } from '../../utils' 34 35 export { convertElementToCssSelector, getIframeScale, processTimeoutOptions } from '../tester-utils' ··· 67 68 })(server.config.browser.name), 68 69 testIdAttribute: server.config.browser.locators.testIdAttribute, 69 70 }) 71 + 72 + getBrowserState().selectorEngine = selectorEngine 70 73 71 74 const kLocator = Symbol.for('$$vitest:locator') 72 75 ··· 204 207 205 208 public mark(name: string, options?: MarkOptions): Promise<void> { 206 209 const currentTest = getWorkerState().current 207 - if (!currentTest || !getBrowserState().activeTraceTaskIds.has(currentTest.id)) { 210 + const hasActiveTrace = !!currentTest && getBrowserState().activeTraceTaskIds.has(currentTest.id) 211 + const hasActiveTraceView = !!currentTest && getBrowserState().browserTraceAttempts.has(currentTest.id) 212 + if (!currentTest || (!hasActiveTrace && !hasActiveTraceView)) { 208 213 return Promise.resolve() 209 214 } 210 - return ensureAwaited(error => getBrowserState().commands.triggerCommand<void>( 211 - '__vitest_markTrace', 212 - [{ 213 - name, 214 - selector: this.selector, 215 - stack: options?.stack ?? error?.stack, 216 - }], 217 - error, 218 - )) 215 + return ensureAwaited((error) => { 216 + if (hasActiveTraceView) { 217 + recordBrowserTraceEntry(currentTest, { 218 + name, 219 + kind: 'mark', 220 + selector: this.selector, 221 + stack: options?.stack ?? error?.stack, 222 + }) 223 + } 224 + if (!hasActiveTrace) { 225 + return Promise.resolve() 226 + } 227 + return getBrowserState().commands.triggerCommand<void>( 228 + '__vitest_markTrace', 229 + [{ 230 + name, 231 + selector: this.selector, 232 + stack: options?.stack ?? error?.stack, 233 + }], 234 + error, 235 + ) 236 + }) 219 237 } 220 238 221 239 protected abstract locator(selector: string): Locator
+38 -3
packages/browser/src/client/tester/runner.ts
··· 28 28 takeCoverageInsideWorker, 29 29 } from 'vitest/internal/browser' 30 30 import { createStackString, parseStacktrace } from '../../../../utils/src/source-map' 31 - import { getBrowserState, getWorkerState, moduleRunner } from '../utils' 31 + import { getBrowserState, getWorkerState, moduleRunner, now } from '../utils' 32 32 import { rpc } from './rpc' 33 33 import { VitestBrowserSnapshotEnvironment } from './snapshot' 34 + import { getBrowserTrace, recordBrowserTraceEntry } from './trace' 34 35 35 36 interface BrowserRunnerOptions { 36 37 config: SerializedConfig ··· 85 86 const shouldTrace = trace !== 'off' 86 87 && !(trace === 'on-all-retries' && retry === 0) 87 88 && !(trace === 'on-first-retry' && retry !== 1) 89 + const shouldTraceView = this.config.browser.traceView.enabled 90 + if (!shouldTraceView && !shouldTrace) { 91 + getBrowserState().activeTraceTaskIds.delete(test.id) 92 + getBrowserState().browserTraceAttempts.delete(test.id) 93 + return 94 + } 95 + if (shouldTraceView) { 96 + getBrowserState().browserTraceDomSnapshot = await import('rrweb-snapshot') 97 + getBrowserState().browserTraceAttempts.set(test.id, { retry, repeats, startTime: now() }) 98 + } 99 + else { 100 + getBrowserState().browserTraceAttempts.delete(test.id) 101 + } 88 102 if (!shouldTrace) { 89 103 getBrowserState().activeTraceTaskIds.delete(test.id) 90 104 return ··· 106 120 } 107 121 108 122 onAfterRetryTask = async (test: Test, { retry, repeats }: { retry: number; repeats: number }) => { 109 - if (!getBrowserState().activeTraceTaskIds.has(test.id)) { 123 + const hasActiveTraceView = getBrowserState().browserTraceAttempts.has(test.id) 124 + if (hasActiveTraceView) { 125 + const status = test.result?.state 126 + const stack = status === 'fail' ? test.result?.errors?.[0].stack : undefined 127 + const location = test.location ? { ...test.location, file: test.file.filepath } : undefined 128 + recordBrowserTraceEntry(test, { 129 + name: `vitest:onAfterRetryTask`, 130 + kind: 'lifecycle', 131 + ...(status === 'pass' || status === 'fail' ? { status } : {}), 132 + ...(stack ? { stack } : location ? { location } : {}), 133 + }) 134 + // TODO: model the same retention mechanism as playwright e.g. retain-on-failure 135 + const traceData = getBrowserTrace(test.id, repeats, retry) 136 + if (traceData) { 137 + await this.commands.triggerCommand( 138 + '__vitest_recordBrowserTrace', 139 + [{ testId: test.id, data: traceData }], 140 + ) 141 + } 142 + getBrowserState().browserTraceAttempts.delete(test.id) 143 + } 144 + const hasActiveTrace = getBrowserState().activeTraceTaskIds.has(test.id) 145 + if (!hasActiveTrace) { 110 146 return 111 147 } 112 148 await this.commands.triggerCommand('__vitest_markTrace', [{ ··· 143 179 ) 144 180 } 145 181 } 146 - 147 182 if (this.config.bail && task.result?.state === 'fail') { 148 183 const previousFailures = await rpc().getCountOfFailedTests() 149 184 const currentFailures = 1 + previousFailures
+26 -9
packages/browser/src/client/tester/tester-utils.ts
··· 1 1 import type { Locator, SelectorOptions, UserEventWheelDeltaOptions, UserEventWheelOptions } from 'vitest/browser' 2 2 import type { BrowserRPC } from '../client' 3 - import { getBrowserState, getWorkerState } from '../utils' 3 + import type { BrowserTraceEntryStatus } from './trace' 4 + import { getBrowserState, getWorkerState, now } from '../utils' 5 + import { recordBrowserTraceEntry } from './trace' 4 6 5 7 /* @__NO_SIDE_EFFECTS__ */ 6 8 export function convertElementToCssSelector(element: Element): string { ··· 132 134 const filepath = state.filepath || state.current?.file?.filepath 133 135 args = args.filter(arg => arg !== undefined) // remove optional fields 134 136 135 - const actionTraceGroupName = ACTION_TRACE_COMMANDS.has(command) ? command : undefined 137 + const actionTraceGroupName = ACTION_TRACE_COMMANDS.has(command) 138 + ? `vitest:${command.slice('__vitest_'.length)}` 139 + : undefined 136 140 const currentTest = getWorkerState().current 137 - const shouldMarkTrace = actionTraceGroupName 141 + const hasActiveTrace = !!actionTraceGroupName 138 142 && !!currentTest 139 143 && getBrowserState().activeTraceTaskIds.has(currentTest.id) 144 + const hasActiveTraceView = !!actionTraceGroupName 145 + && !!currentTest 146 + && getBrowserState().browserTraceAttempts.has(currentTest.id) 140 147 141 148 if (this._listeners.length) { 142 149 await Promise.all(this._listeners.map(listener => listener(command, args))) ··· 150 157 }, 151 158 }, 152 159 async () => { 153 - if (shouldMarkTrace) { 160 + if (hasActiveTrace) { 154 161 await rpc.triggerCommand<void>( 155 162 sessionId, 156 163 '__vitest_groupTraceStart', ··· 161 168 }], 162 169 ) 163 170 } 171 + let status: BrowserTraceEntryStatus = 'pass' 172 + const startTime = now() 164 173 try { 165 174 return await rpc.triggerCommand<T>(sessionId, command, filepath, args) 166 175 } 167 176 catch (err: any) { 177 + status = 'fail' 168 178 // rethrow an error to keep the stack trace in browser 169 179 clientError.message = err.message 170 180 clientError.name = err.name ··· 172 182 throw clientError 173 183 } 174 184 finally { 175 - if (shouldMarkTrace) { 185 + if (hasActiveTraceView) { 186 + recordBrowserTraceEntry(currentTest, { 187 + name: actionTraceGroupName, 188 + kind: 'action', 189 + status, 190 + startTime, 191 + duration: now() - startTime, 192 + selector: typeof args[0] === 'string' ? args[0] : undefined, 193 + stack: clientError.stack, 194 + }) 195 + } 196 + if (hasActiveTrace) { 176 197 await rpc.triggerCommand<void>( 177 198 sessionId, 178 199 '__vitest_groupTraceEnd', ··· 185 206 ) 186 207 } 187 208 } 188 - 189 - const now = globalThis.performance 190 - ? globalThis.performance.now.bind(globalThis.performance) 191 - : Date.now 192 209 193 210 export function processTimeoutOptions<T extends { timeout?: number }>(options_: T | undefined): T | undefined { 194 211 if (
+1
packages/browser/src/client/tester/tester.ts
··· 109 109 const commands = new CommandsManager() 110 110 getBrowserState().commands = commands 111 111 getBrowserState().activeTraceTaskIds = new Set() 112 + getBrowserState().browserTraceAttempts = new Map() 112 113 getBrowserState().iframeId = iframeId 113 114 114 115 let contextSwitched = false
+183
packages/browser/src/client/tester/trace.ts
··· 1 + import type { Task } from '@vitest/runner' 2 + import { getBrowserState, now } from '../utils' 3 + 4 + export interface BrowserTraceData { 5 + retry: number 6 + repeats: number 7 + recordCanvas: boolean 8 + entries: BrowserTraceEntry[] 9 + } 10 + 11 + export type BrowserTraceEntryKind = 'action' | 'expect' | 'mark' | 'lifecycle' 12 + export type BrowserTraceEntryStatus = 'pass' | 'fail' 13 + export type BrowserTraceSelectorResolution = 'matched' | 'missing' | 'error' 14 + 15 + export interface BrowserTraceEntry { 16 + name: string 17 + // not used yet for UI but tested 18 + kind: BrowserTraceEntryKind 19 + status?: BrowserTraceEntryStatus 20 + startTime: number 21 + duration?: number 22 + stack?: string 23 + // resolved server-side from stack in __vitest_recordBrowserTrace command 24 + location?: { file: string; line: number; column: number } 25 + selector?: string 26 + snapshot: TraceSnapshot 27 + } 28 + 29 + interface TraceSnapshot { 30 + serialized: unknown 31 + viewport: { 32 + width: number 33 + height: number 34 + } 35 + scroll: { 36 + x: number 37 + y: number 38 + } 39 + selectorId?: number 40 + // not used yet for UI but tested 41 + selectorResolution?: BrowserTraceSelectorResolution 42 + selectorError?: string 43 + pseudoClassIds: Record<PseudoClassName, number[]> 44 + } 45 + 46 + // rrweb-snapshot rewrites pseudo-class selectors in serialized styles so replay can 47 + // reproduce snapshot-time states. For example: 48 + // some-selector:hover { ... } 49 + // becomes: 50 + // some-selector:hover, some-selector.\:hover { ... } 51 + // Vitest side integration then adds matching pseudo-state classes in the replay DOM. 52 + // rrweb-snapshot only handles `:hover` upstream, so we patch it locally for the 53 + // other user-action pseudo-classes as well. 54 + // https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/Pseudo-classes#user_action_pseudo-classes 55 + const PSEUDO_CLASS_NAMES = [ 56 + ':hover', 57 + ':active', 58 + ':focus', 59 + ':focus-visible', 60 + ':focus-within', 61 + ] as const 62 + type PseudoClassName = (typeof PSEUDO_CLASS_NAMES)[number] 63 + 64 + export type BrowserTraceState = Record<string, BrowserTraceData> 65 + 66 + export interface BrowserTraceAttempt { 67 + retry: number 68 + repeats: number 69 + startTime: number 70 + } 71 + 72 + function getBrowserTraceState(): BrowserTraceState { 73 + return getBrowserState().browserTraceState ??= {} 74 + } 75 + 76 + function getTraceStateKey(testId: string, repeats: number, retry: number) { 77 + return `${testId}:${repeats}:${retry}` 78 + } 79 + 80 + // TODO: should we avoid accumulating? send and immediately clear each entry to save memory? 81 + export function recordBrowserTraceEntry( 82 + task: Task, 83 + options: Omit<BrowserTraceEntry, 'snapshot' | 'startTime'> & { 84 + startTime?: number 85 + }, 86 + ): void { 87 + // TODO: trace-view currently receives selectors after locator/action resolution, 88 + // so provider-specific lowered selectors can leak into snapshot lookup. Preserve 89 + // the original locator selector, or record the target node id before lowering. 90 + // for example, this causes shadow dom selectors to fail with `>>>` marker. 91 + // For now, remove trivial `html >` prefix generated by convertElementToCssSelector. 92 + // this is also necessary to `engine.querySelector + document.documentElement` 93 + // to find an element on webdriverio 94 + if (options.selector?.startsWith('html >')) { 95 + options.selector = options.selector.slice(6) 96 + } 97 + const attemptInfo = getBrowserState().browserTraceAttempts.get(task.id)! 98 + const relativeStartTime = (options.startTime ?? now()) - attemptInfo.startTime 99 + const snapshot = takeSnapshot(options.selector) 100 + const entry: BrowserTraceEntry = { 101 + ...options, 102 + startTime: relativeStartTime, 103 + snapshot, 104 + } 105 + const { retry, repeats } = attemptInfo 106 + const { recordCanvas } = getBrowserState().config.browser.traceView 107 + const state = getBrowserTraceState() 108 + const traceKey = getTraceStateKey(task.id, repeats, retry) 109 + state[traceKey] ??= { retry, repeats, recordCanvas, entries: [] } 110 + state[traceKey].entries.push(entry) 111 + } 112 + 113 + // Resolve ivya selector to a DOM element and take a snapshot with rrweb Mirror 114 + // so we can store the nodeId for provider-agnostic element highlighting in the viewer. 115 + // Note: Playwright's alternative approach is to store the raw selector and run the 116 + // selector engine inside the snapshot iframe at view time via injected script. 117 + // Our approach resolves at collection time (same moment as snapshot) — simpler but 118 + // requires Mirror plumbing. nodeId-based lookup also works across shadow DOM, unlike querySelector. 119 + function takeSnapshot(selector?: string): TraceSnapshot { 120 + const { snapshot, createMirror } = getBrowserState().browserTraceDomSnapshot! 121 + const traceView = getBrowserState().config.browser.traceView 122 + const engine = getBrowserState().selectorEngine! 123 + const mirror = createMirror() 124 + const serialized = snapshot(document, { 125 + mirror, 126 + inlineImages: traceView.inlineImages, 127 + recordCanvas: traceView.recordCanvas, 128 + }) 129 + const result: TraceSnapshot = { 130 + serialized, 131 + viewport: { 132 + width: window.innerWidth, 133 + height: window.innerHeight, 134 + }, 135 + scroll: { 136 + x: window.scrollX, 137 + y: window.scrollY, 138 + }, 139 + pseudoClassIds: {} as any, 140 + } 141 + for (const className of PSEUDO_CLASS_NAMES) { 142 + const elements = document.querySelectorAll(className) 143 + const ids = Array.from(elements, el => mirror.getId(el)).filter(id => id !== -1) 144 + result.pseudoClassIds[className] = ids 145 + } 146 + if (selector) { 147 + try { 148 + const el = engine.querySelector( 149 + engine.parseSelector(selector), 150 + document.documentElement, 151 + false, 152 + ) 153 + if (!el) { 154 + result.selectorResolution = 'missing' 155 + } 156 + else { 157 + const id = mirror.getId(el) 158 + if (id !== -1) { 159 + result.selectorId = id 160 + result.selectorResolution = 'matched' 161 + } 162 + else { 163 + result.selectorResolution = 'missing' 164 + } 165 + } 166 + } 167 + catch (error) { 168 + result.selectorResolution = 'error' 169 + result.selectorError = error instanceof Error ? error.message : String(error) 170 + } 171 + } 172 + return result 173 + } 174 + 175 + export function getBrowserTrace(testId: string, repeats: number, retry: number): BrowserTraceData | undefined { 176 + const state = getBrowserTraceState() 177 + const traceKey = getTraceStateKey(testId, repeats, retry) 178 + const result = state[traceKey] 179 + if (result) { 180 + delete state[traceKey] 181 + return result 182 + } 183 + }
+15
packages/browser/src/client/utils.ts
··· 1 1 import type { VitestRunner } from '@vitest/runner' 2 + import type { Ivya } from 'ivya' 2 3 import type { SerializedConfig, WorkerGlobalState } from 'vitest' 3 4 import type { OTELCarrier, Traces } from 'vitest/internal/browser' 4 5 import type { IframeOrchestrator } from './orchestrator' 5 6 import type { CommandsManager } from './tester/tester-utils' 7 + import type { BrowserTraceAttempt, BrowserTraceState } from './tester/trace' 6 8 7 9 export async function importId(id: string): Promise<any> { 8 10 const name = `/@id/${id}`.replace(/\\/g, '/') ··· 24 26 return importId(id) 25 27 }, 26 28 } 29 + 30 + export const now: () => number = globalThis.performance 31 + ? globalThis.performance.now.bind(globalThis.performance) 32 + : Date.now 27 33 28 34 export function getConfig(): SerializedConfig { 29 35 return getBrowserState().config ··· 65 71 } satisfies Promise<T> 66 72 } 67 73 74 + // This object is the shared runtime bus for browser artifacts loaded into the 75 + // same page. Some browser code is built and served as independent bundles, so 76 + // getBrowserState() is the API they use to share runtime state instead of 77 + // relying on imports resolving to the same module instance. 68 78 export interface BrowserRunnerState { 69 79 files: string[] 70 80 runningFiles: string[] ··· 86 96 orchestrator?: IframeOrchestrator 87 97 commands: CommandsManager 88 98 activeTraceTaskIds: Set<string> 99 + browserTraceAttempts: Map<string, BrowserTraceAttempt> 100 + // lazily loaded only when traceView is enabled 101 + browserTraceDomSnapshot?: typeof import('rrweb-snapshot') 102 + browserTraceState?: BrowserTraceState 103 + selectorEngine: Ivya 89 104 traces: Traces 90 105 cleanups: Array<() => unknown> 91 106 cdp?: {
+2 -1
packages/browser/src/node/commands/index.ts
··· 6 6 } from './fs' 7 7 import { screenshot } from './screenshot' 8 8 import { screenshotMatcher } from './screenshotMatcher' 9 - import { _groupTraceEnd, _groupTraceStart, _markTrace } from './trace' 9 + import { _groupTraceEnd, _groupTraceStart, _markTrace, _recordBrowserTrace } from './trace' 10 10 11 11 export default { 12 12 readFile: readFile as typeof readFile, ··· 16 16 __vitest_markTrace: _markTrace as typeof _markTrace, 17 17 __vitest_groupTraceStart: _groupTraceStart as typeof _groupTraceStart, 18 18 __vitest_groupTraceEnd: _groupTraceEnd as typeof _groupTraceEnd, 19 + __vitest_recordBrowserTrace: _recordBrowserTrace as typeof _recordBrowserTrace, 19 20 __vitest_fileInfo: _fileInfo as typeof _fileInfo, 20 21 __vitest_screenshot: screenshot as typeof screenshot, 21 22 __vitest_screenshotMatcher: screenshotMatcher as typeof screenshotMatcher,
+26
packages/browser/src/node/commands/trace.ts
··· 1 1 import type { BrowserCommand } from 'vitest/node' 2 + import type { BrowserTraceData, BrowserTraceEntry } from '../../client/tester/trace' 2 3 3 4 interface MarkTracePayload { 4 5 name: string ··· 25 26 * @internal 26 27 */ 27 28 __vitest_groupTraceEnd: () => Promise<void> 29 + /** 30 + * @internal 31 + */ 32 + __vitest_recordBrowserTrace: (payload: { testId: string; data: BrowserTraceData }) => Promise<void> 28 33 } 29 34 } 30 35 ··· 53 58 await context.triggerCommand('__vitest_groupTraceEnd') 54 59 } 55 60 } 61 + 62 + export const _recordBrowserTrace: BrowserCommand<[payload: { testId: string; data: BrowserTraceData }]> = async ( 63 + { project }, 64 + { testId, data }, 65 + ) => { 66 + // resolve stack strings → source locations server-side (requires source maps) 67 + const entries: BrowserTraceEntry[] = data.entries.map((entry) => { 68 + if (!entry.stack || entry.location) { 69 + return entry 70 + } 71 + const stacks = project.browser!.parseStacktrace(entry.stack) 72 + if (stacks[0]) { 73 + entry.location = stacks[0] 74 + } 75 + return entry 76 + }) 77 + await project.vitest._testRun.recordArtifact(testId, { 78 + type: 'internal:browserTrace', 79 + data: { ...data, entries }, 80 + }) 81 + }
+1
packages/runner/src/types.ts
··· 14 14 AroundEachListener, 15 15 BeforeAllListener, 16 16 BeforeEachListener, 17 + BrowserTraceArtifact, 17 18 FailureScreenshotArtifact, 18 19 File, 19 20 Fixture,
+11 -1
packages/runner/src/types/tasks.ts
··· 1358 1358 attachments: VisualRegressionArtifactAttachment[] 1359 1359 } 1360 1360 1361 + /** 1362 + * @experimental 1363 + */ 1364 + export interface BrowserTraceArtifact extends TestArtifactBase { 1365 + type: 'internal:browserTrace' 1366 + // TODO: should save a dedicated file/binary as attachments? 1367 + data: unknown 1368 + } 1369 + 1361 1370 interface FailureScreenshotArtifactAttachment extends TestAttachment { 1362 1371 path: string 1363 1372 /** Original file system path to the screenshot, before attachment resolution */ ··· 1457 1466 * This type automatically includes all artifacts registered via {@link TestArtifactRegistry}. 1458 1467 */ 1459 1468 export type TestArtifact 1460 - = | FailureScreenshotArtifact 1469 + = | BrowserTraceArtifact 1470 + | FailureScreenshotArtifact 1461 1471 | TestAnnotationArtifact 1462 1472 | VisualRegressionArtifact 1463 1473 | TestArtifactRegistry[keyof TestArtifactRegistry]
+10 -1
packages/ui/client/components/artifacts/Artifacts.vue
··· 3 3 import type { Component } from 'vue' 4 4 import { computed } from 'vue' 5 5 import { getLocationString, openLocation } from '~/composables/location' 6 + import TraceArtifactLauncher from '../trace/TraceArtifactLauncher.vue' 6 7 import VisualRegression from './visual-regression/VisualRegression.vue' 7 8 8 9 const { test } = defineProps<{ test: RunnerTestCase }>() 9 10 10 - interface HandledArtifact { artifact: TestArtifact; component: Component; props: object } 11 + interface HandledArtifact { artifact: TestArtifact; component?: Component; props: object } 11 12 12 13 type ComponentProps<T> = T extends new(...args: any) => { $props: infer P } ? NonNullable<P> 13 14 : T extends (props: infer P, ...args: any) => any ? P ··· 18 19 19 20 for (const artifact of test.artifacts) { 20 21 switch (artifact.type) { 22 + case 'internal:browserTrace': { 23 + handledArtifacts.push({ 24 + artifact, 25 + component: TraceArtifactLauncher, 26 + props: { trace: artifact, test } satisfies ComponentProps<typeof TraceArtifactLauncher>, 27 + }) 28 + continue 29 + } 21 30 case 'internal:toMatchScreenshot': { 22 31 if (artifact.kind === 'visual-regression') { 23 32 handledArtifacts.push({
+1 -1
packages/ui/client/components/dashboard/TestsEntry.vue
··· 19 19 </script> 20 20 21 21 <template> 22 - <div flex="~ wrap" justify-evenly gap-2 p="x-4" relative> 22 + <div flex="~ wrap" justify-evenly gap-2 p="x-4" relative data-testid="tests-entry"> 23 23 <DashboardEntry 24 24 text-green-700 dark:text-green-500 25 25 data-testid="pass-entry"
+28
packages/ui/client/components/trace/TraceArtifactLauncher.vue
··· 1 + <script setup lang="ts"> 2 + import type { BrowserTraceArtifact } from '@vitest/runner' 3 + import type { RunnerTestCase } from 'vitest' 4 + import type { BrowserTraceData } from '../../../../browser/src/client/tester/trace' 5 + import { computed } from 'vue' 6 + import { getTraceAttemptLabel, openTrace } from '~/composables/trace-view' 7 + 8 + const props = defineProps<{ 9 + trace: BrowserTraceArtifact 10 + test: RunnerTestCase 11 + }>() 12 + 13 + const attemptLabel = computed(() => getTraceAttemptLabel(props.trace.data as BrowserTraceData)) 14 + </script> 15 + 16 + <template> 17 + <button 18 + type="button" 19 + class="flex items-center gap-2 rounded px-2 py-1 hover:bg-yellow-500/10" 20 + @click="openTrace(trace, test)" 21 + > 22 + <span class="i-carbon:play-outline block" /> 23 + Open trace viewer 24 + <span v-if="attemptLabel" class="text-xs opacity-70"> 25 + {{ attemptLabel }} 26 + </span> 27 + </button> 28 + </template>
+206
packages/ui/client/components/trace/TraceView.vue
··· 1 + <script setup lang="ts"> 2 + import type { BrowserTraceArtifact } from '@vitest/runner' 3 + import type { RunnerTestCase } from 'vitest' 4 + import type { BrowserTraceData, BrowserTraceEntry } from '../../../../browser/src/client/tester/trace' 5 + import { createCache, createMirror, rebuild } from 'rrweb-snapshot' 6 + // @ts-expect-error missing types 7 + import { Pane, Splitpanes } from 'splitpanes' 8 + import { computed, ref, watch } from 'vue' 9 + import { getLocationString, openLocation } from '~/composables/location' 10 + 11 + // TODO: component test to demo trace view inside trace view 12 + 13 + const props = defineProps<{ 14 + trace: BrowserTraceArtifact 15 + test: RunnerTestCase 16 + }>() 17 + 18 + const traceData = computed(() => props.trace.data as BrowserTraceData) 19 + const entries = computed(() => traceData.value.entries) 20 + 21 + const selectedStepIndex = ref(0) 22 + const selectedStep = computed(() => entries.value[selectedStepIndex.value]) 23 + watch(() => props.trace, () => { 24 + selectedStepIndex.value = 0 25 + }) 26 + 27 + const iframeEl = ref<HTMLIFrameElement>() 28 + const iframeSandbox = computed(() => { 29 + // Canvas replay needs scripts for rrweb's image.onload -> drawImage path, 30 + // but allow-same-origin + allow-scripts gives replayed app HTML more capability. 31 + return traceData.value.recordCanvas ? 'allow-same-origin allow-scripts' : 'allow-same-origin' 32 + }) 33 + 34 + function onSelectStep(index: number) { 35 + selectedStepIndex.value = index 36 + const step = entries.value[index] 37 + if (step?.location) { 38 + openLocation(props.test, step.location) 39 + } 40 + } 41 + 42 + watch([selectedStep, iframeEl], ([step, iframe]) => { 43 + if (!step || !iframe) { 44 + return 45 + } 46 + const { serialized, selectorId, viewport, scroll, pseudoClassIds } = step.snapshot 47 + iframe.style.width = `${viewport.width}px` 48 + iframe.style.height = `${viewport.height}px` 49 + // Rebuild snapshot into iframe contentDocument — pattern from rrweb replayer: 50 + // https://github.com/rrweb-io/rrweb/blob/master/packages/rrweb/src/replay/index.ts 51 + // doc.open/close resets the iframe document to a blank state before rebuild. 52 + // Unlike Playwright which serves snapshots via HTTP, this is fully client-side 53 + // but external resources (images, stylesheets) won't load without a server. 54 + const doc = iframe.contentDocument! 55 + doc.open() 56 + doc.close() 57 + const mirror = createMirror() 58 + rebuild(serialized, { 59 + doc, 60 + cache: createCache(), 61 + mirror, 62 + }) 63 + for (const [className, ids] of Object.entries(pseudoClassIds)) { 64 + for (const id of ids) { 65 + const el = mirror.getNode(id) as Element | null 66 + if (el?.classList) { 67 + el.classList.add(className) 68 + } 69 + } 70 + } 71 + iframe.contentWindow!.scrollTo(scroll?.x ?? 0, scroll?.y ?? 0) 72 + if (selectorId != null) { 73 + const el = mirror.getNode(selectorId) 74 + if (el) { 75 + // Overlay highlight technique adapted from Playwright's highlight.ts: 76 + // https://github.com/microsoft/playwright/blob/main/packages/injected/src/highlight.ts 77 + // getBoundingClientRect() gives viewport-relative coords; position:fixed overlay matches. 78 + // Simplified version: no shadow DOM glass pane, no tooltip. 79 + iframe.contentWindow!.requestAnimationFrame(() => { 80 + const rect = (el as Element).getBoundingClientRect() 81 + const overlay = doc.createElement('div') 82 + overlay.setAttribute('data-testid', 'trace-view-highlight') 83 + overlay.style.cssText = ` 84 + position: fixed; 85 + pointer-events: none; 86 + z-index: 2147483647; 87 + left: ${rect.left}px; 88 + top: ${rect.top}px; 89 + width: ${rect.width}px; 90 + height: ${rect.height}px; 91 + background: rgba(59, 130, 246, 0.15); 92 + border: 2px solid #3b82f6; 93 + box-sizing: border-box; 94 + ` 95 + doc.documentElement.appendChild(overlay) 96 + }) 97 + } 98 + } 99 + }, { immediate: true }) 100 + 101 + function getStepButtonClass(step: BrowserTraceEntry, index: number) { 102 + const selected = selectedStepIndex.value === index 103 + if (step.status === 'fail') { 104 + return selected 105 + ? 'bg-red-500/20 text-red-600 dark:text-red-400' 106 + : 'text-red-600 hover:bg-red-500/10 dark:text-red-400' 107 + } 108 + return selected ? 'bg-blue-500/20' : 'hover:bg-gray/10' 109 + } 110 + 111 + function formatTraceTime(ms: number) { 112 + return ms < 1000 113 + ? `${Math.round(ms)}ms` 114 + : `${(ms / 1000).toFixed(1)}s` 115 + } 116 + 117 + function formatTraceTiming(step: BrowserTraceEntry) { 118 + const startTime = `+${formatTraceTime(step.startTime)}` 119 + return step.duration == null 120 + ? startTime 121 + : `${startTime} · ${formatTraceTime(step.duration)}` 122 + } 123 + 124 + function formatStepName(step: BrowserTraceEntry) { 125 + if (step.kind === 'lifecycle' && step.name === 'vitest:onAfterRetryTask') { 126 + return 'test finished' 127 + } 128 + if (step.kind === 'action' && step.name.startsWith('vitest:')) { 129 + return step.name.slice('vitest:'.length) 130 + } 131 + return step.name 132 + } 133 + 134 + function getStepMarkerClass(step: BrowserTraceEntry) { 135 + if (step.kind === 'action') { 136 + return 'bg-blue-500/80' 137 + } 138 + if (step.kind === 'expect') { 139 + return 'bg-green-500/80' 140 + } 141 + if (step.kind === 'mark') { 142 + return 'bg-amber-500/80' 143 + } 144 + return 'bg-gray-400/80 dark:bg-gray-500/80' 145 + } 146 + </script> 147 + 148 + <template> 149 + <Splitpanes 150 + class="h-full min-h-0" 151 + > 152 + <Pane :size="30" min-size="20"> 153 + <div class="h-full min-h-0 p-4" flex="~ col gap-1" overflow-auto> 154 + <button 155 + v-for="(step, index) of entries" 156 + :key="index" 157 + type="button" 158 + class="w-full text-left px-2 py-1 rounded text-sm" 159 + :class="getStepButtonClass(step, index)" 160 + @click="onSelectStep(index)" 161 + > 162 + <div class="flex items-start gap-2"> 163 + <span 164 + class="mt-1.5 h-2 w-2 rounded-full flex-shrink-0" 165 + :class="getStepMarkerClass(step)" 166 + /> 167 + <div class="min-w-0 flex-1"> 168 + <div truncate data-testid="trace-step-name"> 169 + {{ formatStepName(step) }} 170 + </div> 171 + <div class="text-xs opacity-60 truncate"> 172 + {{ formatTraceTiming(step) }} 173 + </div> 174 + <div 175 + v-if="step.selector" 176 + class="font-mono text-xs opacity-70 truncate" 177 + > 178 + {{ step.selector }} 179 + </div> 180 + <div 181 + v-if="step.location" 182 + class="text-xs opacity-50 truncate" 183 + > 184 + {{ getLocationString(step.location) }} 185 + </div> 186 + </div> 187 + </div> 188 + </button> 189 + </div> 190 + </Pane> 191 + <Pane :size="70" min-size="20"> 192 + <div class="h-full min-h-0" flex="~ col" overflow-auto> 193 + <iframe 194 + v-if="selectedStep" 195 + ref="iframeEl" 196 + :key="iframeSandbox" 197 + :sandbox="iframeSandbox" 198 + style="background: white; border: none; color-scheme: normal; flex: none" 199 + /> 200 + <div v-else class="text-sm opacity-50 p-4"> 201 + No trace step selected. 202 + </div> 203 + </div> 204 + </Pane> 205 + </Splitpanes> 206 + </template>
+35
packages/ui/client/components/trace/TraceViewPane.vue
··· 1 + <script setup lang="ts"> 2 + import { computed } from 'vue' 3 + import IconButton from '~/components/IconButton.vue' 4 + import { activeTraceView, closeTrace, getTraceAttemptLabel } from '~/composables/trace-view' 5 + import TraceView from './TraceView.vue' 6 + 7 + const trace = computed(() => activeTraceView.value!.trace) 8 + const test = computed(() => activeTraceView.value!.test) 9 + const attemptLabel = computed(() => getTraceAttemptLabel(trace.value.data)) 10 + </script> 11 + 12 + <template> 13 + <div data-testid="trace-view" h-full min-h-0 flex="~ col"> 14 + <div p="3" h-10 flex="~ gap-2" items-center bg-header border="b base"> 15 + <div class="i-carbon:data-vis-4" /> 16 + <span pl-1 font-bold text-sm flex-auto ws-nowrap overflow-hidden truncate>Trace Viewer</span> 17 + <span 18 + v-if="attemptLabel" 19 + class="text-xs opacity-70" 20 + > 21 + {{ attemptLabel }} 22 + </span> 23 + <IconButton 24 + v-tooltip.bottom="'Close Trace Viewer'" 25 + title="Close Trace Viewer" 26 + icon="i-carbon:close" 27 + @click="closeTrace()" 28 + /> 29 + </div> 30 + <TraceView 31 + :trace="trace" 32 + :test="test" 33 + /> 34 + </div> 35 + </template>
+90
packages/ui/client/composables/trace-view.ts
··· 1 + import type { BrowserTraceArtifact } from '@vitest/runner' 2 + import type { RunnerTestCase } from 'vitest' 3 + import type { BrowserTraceData } from '../../../browser/src/client/tester/trace' 4 + import { ref, watch } from 'vue' 5 + import { browserState, client, config } from './client' 6 + import { finished } from './client/state' 7 + import { detailsPosition } from './navigation' 8 + import { selectedTest } from './params' 9 + 10 + export interface ActiveTraceView { 11 + test: RunnerTestCase 12 + trace: BrowserTraceArtifactWithData 13 + } 14 + 15 + export interface BrowserTraceArtifactWithData extends Omit<BrowserTraceArtifact, 'data'> { 16 + // fill up actual data type since runner-level BrowserTraceArtifact has `unknown` 17 + data: BrowserTraceData 18 + } 19 + 20 + export const activeTraceView = ref<ActiveTraceView>() 21 + 22 + export function openTrace(trace: BrowserTraceArtifact, test: RunnerTestCase) { 23 + detailsPosition.value = 'bottom' 24 + activeTraceView.value = { 25 + test, 26 + trace: trace as BrowserTraceArtifactWithData, 27 + } 28 + } 29 + 30 + export function closeTrace() { 31 + activeTraceView.value = undefined 32 + } 33 + 34 + function openTraceForTest(testId: string) { 35 + const test = client.state.idMap.get(testId) 36 + if (test?.type !== 'test') { 37 + return 38 + } 39 + 40 + // skip if already open 41 + if (test === activeTraceView.value?.test) { 42 + return 43 + } 44 + 45 + const projectName = test.file.projectName || '' 46 + const project = config.value.projects?.find(project => project.name === projectName) 47 + const traceView 48 + = browserState?.config.browser?.traceView 49 + ?? project?.browser.traceView 50 + ?? config.value.browser?.traceView 51 + if (traceView?.enabled) { 52 + const trace = test.artifacts.find((artifact): artifact is BrowserTraceArtifact => artifact.type === 'internal:browserTrace') 53 + if (trace) { 54 + openTrace(trace, test) 55 + } 56 + } 57 + } 58 + 59 + // sync with selected test / url navigation 60 + watch(selectedTest, (testId) => { 61 + if (!testId || activeTraceView.value?.test.id !== testId) { 62 + closeTrace() 63 + } 64 + // auto open trace view 65 + if (testId) { 66 + openTraceForTest(testId) 67 + } 68 + }) 69 + 70 + watch(finished, (isFinished) => { 71 + // TODO: test in test/ui 72 + // auto reload currently active trace view on re-run 73 + if (isFinished) { 74 + const testId = activeTraceView.value?.test.id 75 + if (testId) { 76 + openTraceForTest(testId) 77 + } 78 + } 79 + }) 80 + 81 + export function getTraceAttemptLabel(trace: BrowserTraceData) { 82 + const parts: string[] = [] 83 + if (trace.retry) { 84 + parts.push(`Retry ${trace.retry}`) 85 + } 86 + if (trace.repeats) { 87 + parts.push(`Repeat ${trace.repeats}`) 88 + } 89 + return parts.join(' / ') 90 + }
+19 -2
packages/ui/client/pages/index.vue
··· 10 10 import FileDetails from '~/components/FileDetails.vue' 11 11 import Navigation from '~/components/Navigation.vue' 12 12 import ProgressBar from '~/components/ProgressBar.vue' 13 + import TraceViewPane from '~/components/trace/TraceViewPane.vue' 13 14 import { browserState } from '~/composables/client' 14 15 import { 15 16 coverageVisible, ··· 20 21 mainSizes, 21 22 panels, 22 23 } from '~/composables/navigation' 24 + import { activeTraceView } from '~/composables/trace-view' 23 25 24 26 const dashboardVisible = initializeNavigation() 25 27 ··· 91 93 <Navigation /> 92 94 </Pane> 93 95 <Pane :size="mainSizes[1]"> 94 - <transition v-if="!browserState" key="ui-detail"> 96 + <transition v-if="!browserState && !activeTraceView" key="ui-detail"> 95 97 <Dashboard v-if="dashboardVisible" key="summary" /> 96 98 <Coverage 97 99 v-else-if="coverageVisible" ··· 113 115 @resized="onModuleResized" 114 116 > 115 117 <Pane :size="detailSizes[0]" min-size="10"> 116 - <BrowserIframe v-once /> 118 + <template v-if="browserState"> 119 + <Splitpanes 120 + v-if="browserState.config.browser?.traceView.enabled" 121 + class="h-full" 122 + :horizontal="detailsPosition === 'right'" 123 + > 124 + <Pane :size="activeTraceView ? 55 : 100" min-size="10"> 125 + <BrowserIframe v-once /> 126 + </Pane> 127 + <Pane v-if="activeTraceView" size="45" min-size="10"> 128 + <TraceViewPane /> 129 + </Pane> 130 + </Splitpanes> 131 + <BrowserIframe v-else v-once /> 132 + </template> 133 + <TraceViewPane v-else-if="activeTraceView" /> 117 134 </Pane> 118 135 <Pane 119 136 v-if="detailsPanelVisible"
+3
packages/ui/package.json
··· 72 72 "@unocss/reset": "catalog:", 73 73 "@vitejs/plugin-vue": "catalog:", 74 74 "@vitest/browser-playwright": "workspace:*", 75 + "@vitest/browser-preview": "workspace:*", 76 + "@vitest/browser-webdriverio": "workspace:*", 75 77 "@vitest/runner": "workspace:*", 76 78 "@vitest/ws-client": "workspace:*", 77 79 "@vue/test-utils": "^2.4.6", ··· 84 86 "floating-vue": "^5.2.2", 85 87 "mime": "^4.1.0", 86 88 "rollup": "^4.59.0", 89 + "rrweb-snapshot": "2.0.0-alpha.20", 87 90 "splitpanes": "^4.0.4", 88 91 "typescript": "^5.9.3", 89 92 "unocss": "catalog:",
+39 -16
packages/ui/vitest.config.ts
··· 1 1 import { playwright } from '@vitest/browser-playwright' 2 + import { preview } from '@vitest/browser-preview' 3 + import { webdriverio } from '@vitest/browser-webdriverio' 2 4 import { mergeConfig } from 'vite' 3 5 import { defineConfig } from 'vitest/config' 4 6 import viteConfig from './vite.config' 5 7 6 - export default mergeConfig( 7 - viteConfig, 8 - defineConfig({ 9 - optimizeDeps: { 10 - include: ['vue-router', 'splitpanes', 'd3-graph-controller', 'vue-virtual-scroller'], 8 + // known working set of tests for providers 9 + // pnpm -C packages/ui test:ui 10 + // PROVIDER=webdriverio pnpm -C packages/ui test:ui SmallTab 11 + // PROVIDER=preview pnpm -C packages/ui test:ui SmallTab -t access 12 + 13 + const providerName = (process.env.PROVIDER || 'playwright') as 14 + | 'playwright' 15 + | 'webdriverio' 16 + | 'preview' 17 + 18 + const testConfig = defineConfig({ 19 + optimizeDeps: { 20 + include: [ 21 + 'vue-router', 22 + 'splitpanes', 23 + 'd3-graph-controller', 24 + 'vue-virtual-scroller', 25 + 'rrweb-snapshot', 26 + ], 27 + }, 28 + test: { 29 + browser: { 30 + enabled: true, 31 + traceView: true, 32 + provider: 33 + providerName === 'preview' 34 + ? preview() 35 + : providerName === 'webdriverio' 36 + ? webdriverio() 37 + : playwright({ 38 + actionTimeout: 5000, 39 + }), 40 + instances: [providerName === 'webdriverio' ? { browser: 'chrome' } : { browser: 'chromium' }], 11 41 }, 12 - test: { 13 - browser: { 14 - enabled: true, 15 - provider: playwright({ 16 - actionTimeout: 5000, 17 - }), 18 - instances: [{ browser: 'chromium' }], 19 - }, 20 - }, 21 - }), 22 - ) 42 + }, 43 + }) 44 + 45 + export default mergeConfig(viteConfig, testConfig)
+16
packages/vitest/src/node/cli/cli-config.ts
··· 413 413 return { mode: value } 414 414 }, 415 415 }, 416 + traceView: { 417 + description: 'Enable Vitest trace-view collection for browser tests (default: `false`)', 418 + argument: '', 419 + transform: transformNestedBoolean, 420 + subcommands: { 421 + enabled: { 422 + description: 'Enable Vitest trace-view collection for browser tests (default: `false`)', 423 + }, 424 + recordCanvas: { 425 + description: 'Capture canvas pixels in trace-view snapshots (default: `false`)', 426 + }, 427 + inlineImages: { 428 + description: 'Inline loaded image pixels in trace-view snapshots (default: `false`)', 429 + }, 430 + }, 431 + }, 416 432 orchestratorScripts: null, 417 433 commands: null, 418 434 viewport: null,
+27
packages/vitest/src/node/config/resolveConfig.ts
··· 911 911 if (typeof resolved.browser.trace === 'string' || !resolved.browser.trace) { 912 912 resolved.browser.trace = { mode: resolved.browser.trace || 'off' } 913 913 } 914 + 915 + const traceView = resolved.browser.traceView 916 + resolved.browser.traceView = typeof traceView === 'object' 917 + ? { 918 + enabled: traceView.enabled ?? false, 919 + recordCanvas: traceView.recordCanvas ?? false, 920 + inlineImages: traceView.inlineImages ?? false, 921 + } 922 + : { 923 + enabled: traceView ?? false, 924 + recordCanvas: false, 925 + inlineImages: false, 926 + } 927 + if (resolved.browser.enabled && resolved.browser.traceView.enabled) { 928 + resolved.browser.detailsPanelPosition = 'bottom' 929 + } 914 930 if (resolved.browser.trace.tracesDir != null) { 915 931 resolved.browser.trace.tracesDir = resolvePath( 916 932 resolved.browser.trace.tracesDir, ··· 928 944 929 945 if (htmlReporter) { 930 946 resolved.includeTaskLocation ??= true 947 + } 948 + else if (resolved.browser.enabled && resolved.browser.traceView.enabled && !resolved.watch) { 949 + logger.console.warn( 950 + c.yellow( 951 + withLabel( 952 + 'yellow', 953 + 'Vitest', 954 + '--browser.traceView is enabled without the HTML reporter.', 955 + ), 956 + ), 957 + ) 931 958 } 932 959 933 960 resolved.server ??= {}
+1
packages/vitest/src/node/config/serializeConfig.ts
··· 125 125 : {}, 126 126 trackUnhandledErrors: browser.trackUnhandledErrors ?? true, 127 127 trace: browser.trace.mode, 128 + traceView: browser.traceView, 128 129 } 129 130 })(config.browser), 130 131 standalone: config.standalone,
+46
packages/vitest/src/node/types/browser.ts
··· 53 53 export type BrowserBuiltinProvider = 'webdriverio' | 'playwright' | 'preview' 54 54 export interface _BrowserNames {} 55 55 56 + export interface BrowserTraceViewOptions { 57 + /** 58 + * Enable Vitest trace view artifacts collection. 59 + * 60 + * This option controls Vitest's own trace-view pipeline, independently from provider-specific trace retention. 61 + * 62 + * @default false 63 + * @experimental 64 + */ 65 + enabled?: boolean 66 + 67 + /** 68 + * Capture canvas pixels in trace view snapshots. 69 + * 70 + * This uses rrweb's canvas snapshot support and can increase trace artifact size. 71 + * In the trace viewer, this enables a weaker replay iframe sandbox because rrweb needs scripts to redraw canvas data. 72 + * 73 + * @default false 74 + * @experimental 75 + */ 76 + recordCanvas?: boolean 77 + 78 + /** 79 + * Inline loaded `<img>` element pixels in trace view snapshots. 80 + * 81 + * This uses rrweb's image inlining support. It improves offline replay for still images, 82 + * but can increase trace artifact size and does not preserve original image resource bytes. 83 + * 84 + * @default false 85 + * @experimental 86 + */ 87 + inlineImages?: boolean 88 + } 89 + 56 90 type UnsupportedProperties 57 91 = | 'browser' 58 92 | 'typecheck' ··· 234 268 } 235 269 236 270 /** 271 + * 272 + * Enable Vitest trace view artifacts collection. 273 + * 274 + * This option controls Vitest's own trace-view pipeline, independently from provider-specific trace retention. 275 + * 276 + * @default false 277 + * @experimental 278 + */ 279 + traceView?: boolean | BrowserTraceViewOptions 280 + 281 + /** 237 282 * Directory where screenshots will be saved when page.screenshot() is called 238 283 * If not set, all screenshots are saved to __screenshots__ directory in the same folder as the test file. 239 284 * If this is set, it will be resolved relative to the project root. ··· 408 453 snapshots?: boolean 409 454 sources?: boolean 410 455 } 456 + traceView: Required<BrowserTraceViewOptions> 411 457 } 412 458 413 459 type ToMatchScreenshotResolvePath = (data: {
+5
packages/vitest/src/runtime/config.ts
··· 116 116 actionTimeout?: number 117 117 } 118 118 trace: BrowserTraceViewMode 119 + traceView: { 120 + enabled: boolean 121 + recordCanvas: boolean 122 + inlineImages: boolean 123 + } 119 124 trackUnhandledErrors: boolean 120 125 detailsPanelPosition: 'right' | 'bottom' 121 126 }
+23
patches/rrweb-snapshot.patch
··· 1 + diff --git a/dist/rrweb-snapshot.js b/dist/rrweb-snapshot.js 2 + index 3a3b67808308f4b50ad906dccfaaf577b4264742..3cf40a7d3c7d847e34850b32c424e2b0bb9331ac 100644 3 + --- a/dist/rrweb-snapshot.js 4 + +++ b/dist/rrweb-snapshot.js 5 + @@ -1513,6 +1513,18 @@ const pseudoClassPlugin = { 6 + if (selector.includes(":hover")) { 7 + rule2.selector += ",\n" + selector.replace(/:hover/g, ".\\:hover"); 8 + } 9 + + if (selector.includes(":active")) { 10 + + rule2.selector += ",\n" + selector.replace(/:active/g, ".\\:active"); 11 + + } 12 + + if (selector.includes(":focus-visible")) { 13 + + rule2.selector += ",\n" + selector.replace(/:focus-visible/g, ".\\:focus-visible"); 14 + + } 15 + + if (selector.includes(":focus-within")) { 16 + + rule2.selector += ",\n" + selector.replace(/:focus-within/g, ".\\:focus-within"); 17 + + } 18 + + if (/:focus(?![-\w])/.test(selector)) { 19 + + rule2.selector += ",\n" + selector.replace(/:focus(?![-\w])/g, ".\\:focus"); 20 + + } 21 + }); 22 + } 23 + };
+22
pnpm-lock.yaml
··· 192 192 istanbul-lib-source-maps: 193 193 hash: be977704c4b9838da456619fe2421b5e24df2103a25967bd383b2246c88ddf6e 194 194 path: patches/istanbul-lib-source-maps.patch 195 + rrweb-snapshot: 196 + hash: d11ec9bd9b66247ba493ab5fd117fbf0b4cb6e5d0a6a7c600d1d844bfa3d3044 197 + path: patches/rrweb-snapshot.patch 195 198 196 199 importers: 197 200 ··· 578 581 pathe: 579 582 specifier: 'catalog:' 580 583 version: 2.0.3 584 + rrweb-snapshot: 585 + specifier: 2.0.0-alpha.20 586 + version: 2.0.0-alpha.20(patch_hash=d11ec9bd9b66247ba493ab5fd117fbf0b4cb6e5d0a6a7c600d1d844bfa3d3044) 581 587 vitest: 582 588 specifier: workspace:* 583 589 version: link:../vitest ··· 917 923 '@vitest/browser-playwright': 918 924 specifier: workspace:* 919 925 version: link:../browser-playwright 926 + '@vitest/browser-preview': 927 + specifier: workspace:* 928 + version: link:../browser-preview 929 + '@vitest/browser-webdriverio': 930 + specifier: workspace:* 931 + version: link:../browser-webdriverio 920 932 '@vitest/runner': 921 933 specifier: workspace:* 922 934 version: link:../runner ··· 953 965 rollup: 954 966 specifier: ^4.59.0 955 967 version: 4.59.0 968 + rrweb-snapshot: 969 + specifier: 2.0.0-alpha.20 970 + version: 2.0.0-alpha.20(patch_hash=d11ec9bd9b66247ba493ab5fd117fbf0b4cb6e5d0a6a7c600d1d844bfa3d3044) 956 971 splitpanes: 957 972 specifier: ^4.0.4 958 973 version: 4.0.4(vue@3.5.29(typescript@5.9.3)) ··· 9248 9263 resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} 9249 9264 engines: {node: '>=18.0.0', npm: '>=8.0.0'} 9250 9265 hasBin: true 9266 + 9267 + rrweb-snapshot@2.0.0-alpha.20: 9268 + resolution: {integrity: sha512-YTNf9YVeaGRo/jxY3FKBge2c/Ojd/KTHmuWloUSB+oyPXuY73ZeeG873qMMmhIpqEn7hn7aBF1eWEQmP7wjf8A==} 9251 9269 9252 9270 run-applescript@7.0.0: 9253 9271 resolution: {integrity: sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==} ··· 18579 18597 '@rollup/rollup-win32-x64-gnu': 4.59.0 18580 18598 '@rollup/rollup-win32-x64-msvc': 4.59.0 18581 18599 fsevents: 2.3.3 18600 + 18601 + rrweb-snapshot@2.0.0-alpha.20(patch_hash=d11ec9bd9b66247ba493ab5fd117fbf0b4cb6e5d0a6a7c600d1d844bfa3d3044): 18602 + dependencies: 18603 + postcss: 8.5.6 18582 18604 18583 18605 run-applescript@7.0.0: {} 18584 18606
+2 -1
pnpm-workspace.yaml
··· 3 3 cleanupUnusedCatalogs: true 4 4 ignoreWorkspaceRootCheck: true 5 5 shellEmulator: true 6 - trustPolicy: no-downgrade 7 6 strictPeerDependencies: false 7 + trustPolicy: no-downgrade 8 8 trustPolicyExclude: 9 9 - semver@6.3.1 10 10 - chokidar@4.0.3 ··· 44 44 cac@6.7.14: patches/cac@6.7.14.patch 45 45 istanbul-lib-instrument: patches/istanbul-lib-instrument.patch 46 46 istanbul-lib-source-maps: patches/istanbul-lib-source-maps.patch 47 + rrweb-snapshot: patches/rrweb-snapshot.patch 47 48 catalog: 48 49 '@babel/core': ^7.29.0 49 50 '@babel/plugin-proposal-decorators': ^7.29.0
+2 -2
test/browser/README.md
··· 8 8 pnpm run test:playwright 9 9 10 10 # run only playwright provider + chromium 11 - BROWSER=chromium pnpm run test:playwright 11 + TEST_BROWSER=chromium pnpm run test:playwright 12 12 13 13 # run specific fixture (default is playwright provider + all browsers) 14 14 pnpm run test-fixtures --root ./fixtures/locators 15 15 16 16 # run specific fixture with selected provider and browser 17 - PROVIDER=webdriverio BROWSER=firefox pnpm run test-fixtures --root ./fixtures/locators 17 + PROVIDER=webdriverio TEST_BROWSER=firefox pnpm run test-fixtures --root ./fixtures/locators 18 18 ``` 19 19 20 20 ## Using docker playwright
+1
test/browser/fixtures/trace/.gitignore
··· 1 + html/
+4
test/browser/fixtures/trace/assets/trace-pixel.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> 2 + <rect width="24" height="24" fill="#2563eb"/> 3 + <circle cx="12" cy="12" r="6" fill="#dc2626"/> 4 + </svg>
+20
test/browser/fixtures/trace/assets/trace-pseudo-style.css
··· 1 + .trace-pseudo-hover, 2 + .trace-pseudo-focus, 3 + .trace-pseudo-within { 4 + background: rgb(255, 200, 200); 5 + display: block; 6 + margin: 8px; 7 + padding: 8px; 8 + } 9 + .trace-pseudo-hover:hover { 10 + background: rgb(253, 224, 71); 11 + } 12 + .trace-pseudo-focus:focus { 13 + background: rgb(253, 224, 71); 14 + } 15 + .trace-pseudo-within:focus-within { 16 + background: rgb(253, 224, 71); 17 + } 18 + .trace-pseudo-within input { 19 + display: block; 20 + }
+4
test/browser/fixtures/trace/assets/trace-style.css
··· 1 + .trace-linked-css { 2 + color: rgb(37, 99, 235); 3 + font-weight: 700; 4 + }
+59
test/browser/fixtures/trace/exotic.test.ts
··· 1 + import { beforeEach, test } from 'vitest' 2 + import { page } from 'vitest/browser' 3 + 4 + beforeEach(() => { 5 + if ('adoptedStyleSheets' in document) { 6 + document.adoptedStyleSheets = [] 7 + } 8 + document.body.innerHTML = '' 9 + }) 10 + 11 + test('canvas pixels are captured for replay', async () => { 12 + document.body.innerHTML = '<canvas data-testid="trace-canvas" width="80" height="40"></canvas>' 13 + const canvas = document.querySelector<HTMLCanvasElement>('[data-testid="trace-canvas"]')! 14 + const context = canvas.getContext('2d')! 15 + context.fillStyle = 'rgb(220, 38, 38)' 16 + context.fillRect(0, 0, 80, 40) 17 + await page.getByTestId('trace-canvas').mark('canvas drawn before mark') 18 + }) 19 + 20 + test('shadow dom is rebuilt and highlightable by mirror id', async () => { 21 + const host = document.createElement('section') 22 + host.setAttribute('aria-label', 'shadow host') 23 + const shadow = host.attachShadow({ mode: 'open' }) 24 + shadow.innerHTML = '<button>Shadow button</button>' 25 + document.body.append(host) 26 + // TODO: webdriverio currently records this trace-view selector after it is 27 + // lowered to a provider-specific shadow DOM selector. 28 + await page.getByRole('button', { name: 'Shadow button' }).mark('shadow button rendered') 29 + }) 30 + 31 + // TODO: document or improve the boundary between rebuilt DOM shape and custom element runtime behavior. 32 + test('custom element dom shape is rebuilt without runtime behavior', async () => { 33 + if (!customElements.get('trace-widget')) { 34 + customElements.define( 35 + 'trace-widget', 36 + class extends HTMLElement { 37 + connectedCallback() { 38 + this.attachShadow({ mode: 'open' }).innerHTML = '<button>Custom element button</button>' 39 + } 40 + }, 41 + ) 42 + } 43 + 44 + document.body.innerHTML = '<trace-widget></trace-widget>' 45 + // TODO: webdriverio currently records this trace-view selector after it is 46 + // lowered to a provider-specific shadow DOM selector. 47 + await page.getByRole('button', { name: 'Custom element button' }).mark('custom element rendered') 48 + }) 49 + 50 + // TODO: collect adopted stylesheets so replay preserves constructable stylesheet styling. 51 + test('adopted stylesheets are not captured by snapshot alone', async () => { 52 + document.body.innerHTML = '<button>Adopted stylesheet button</button>' 53 + if ('adoptedStyleSheets' in document) { 54 + const sheet = new CSSStyleSheet() 55 + sheet.replaceSync('button { color: rgb(220, 38, 38); }') 56 + document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet] 57 + } 58 + await page.getByRole('button').mark('button rendered with adopted stylesheet') 59 + })
+41
test/browser/fixtures/trace/expect.test.ts
··· 1 + import { beforeEach, expect, test } from 'vitest' 2 + import { page } from 'vitest/browser' 3 + 4 + beforeEach(() => { 5 + document.body.innerHTML = '' 6 + }) 7 + 8 + test('expect.element pass', async () => { 9 + document.body.innerHTML = '<button>Hello</button>' 10 + await expect.element(page.getByRole('button')).toHaveTextContent('Hello') 11 + }) 12 + 13 + test('expect.element fail', async () => { 14 + document.body.innerHTML = '<button>Hello</button>' 15 + await expect.element(page.getByRole('button'), { timeout: 100 }).toHaveTextContent('World') 16 + }) 17 + 18 + test('failure', async () => { 19 + document.body.innerHTML = '<button>Hello</button>' 20 + throw new Error('Test failure') 21 + }) 22 + 23 + test('click', async () => { 24 + document.body.innerHTML = '<button>Hello</button>' 25 + await page.getByRole('button').click() 26 + }) 27 + 28 + test('click fail', async () => { 29 + document.body.innerHTML = '<button>Hello</button>' 30 + try { 31 + // TODO: webdriverio currently records only the lifecycle failure for this case, 32 + // not the failed action trace entry with selectorResolution: "missing". 33 + await page.getByRole('button', { name: 'Missing' }).click({ timeout: 100 }) 34 + } 35 + catch (e) { 36 + // error messages are different by providers. 37 + // this is irrelevant since this is a test for action trace metadata 38 + e.message = 'Click failed (normalized)' 39 + throw e 40 + } 41 + })
+37
test/browser/fixtures/trace/mark.test.ts
··· 1 + import { beforeEach, test, vi } from 'vitest' 2 + import { page } from 'vitest/browser' 3 + 4 + beforeEach(() => { 5 + document.body.innerHTML = '' 6 + }) 7 + 8 + test('locator.mark', async () => { 9 + document.body.innerHTML = '<button>Hello</button>' 10 + await page.getByRole('button').mark('button rendered - locator') 11 + }) 12 + 13 + test('page.mark', async () => { 14 + document.body.innerHTML = '<button>Hello</button>' 15 + await page.mark('button rendered - page') 16 + }) 17 + 18 + const myRender = vi.defineHelper(async (content: string) => { 19 + document.body.innerHTML = content 20 + await page.elementLocator(document.body.firstElementChild!).mark('render helper') 21 + }) 22 + 23 + test('helper', async () => { 24 + await myRender('<button>Hello</button>') 25 + }) 26 + 27 + test('stack', async () => { 28 + document.body.innerHTML = '<button>Hello</button>' 29 + const error = new Error('Custom error for stack trace') 30 + await page.getByRole('button').mark('button rendered - stack', { stack: error.stack }) 31 + }) 32 + 33 + test('mark function', async () => { 34 + await page.mark('render group', async () => { 35 + document.body.innerHTML = '<button>Hello</button>' 36 + }) 37 + })
+21
test/browser/fixtures/trace/resources.test.ts
··· 1 + import { beforeEach, test } from 'vitest' 2 + import { page } from 'vitest/browser' 3 + 4 + beforeEach(() => { 5 + document.body.innerHTML = '' 6 + }) 7 + 8 + // With traceView.inlineImages enabled, this is self-contained in the HTML reporter. 9 + test('same-origin image is inlined for offline replay', async () => { 10 + document.body.innerHTML = '<img src="/assets/trace-pixel.svg" alt="local trace asset" width="24" height="24">' 11 + await document.querySelector<HTMLImageElement>('img')!.decode() 12 + await page.getByAltText('local trace asset').mark('image rendered from same-origin url') 13 + }) 14 + 15 + // TODO(test): replace this live URL with deterministic CORS and non-CORS cross-origin fixtures. 16 + // This renders because the browser can load the external image, but rrweb cannot inline it without CORS-readable pixels. 17 + test('external image remains url dependent', async () => { 18 + document.body.innerHTML = '<img src="https://vitest.dev/logo.svg" alt="external trace asset" width="24" height="24">' 19 + await document.querySelector<HTMLImageElement>('img')!.decode() 20 + await page.getByAltText('external trace asset').mark('image rendered from external url') 21 + })
+40
test/browser/fixtures/trace/retry.test.ts
··· 1 + import type { TestContext } from 'vitest' 2 + import { test, vi } from 'vitest' 3 + import { page } from 'vitest/browser' 4 + 5 + const renderContext = vi.defineHelper(async (context: TestContext) => { 6 + const result = context.task.result 7 + const content = ` 8 + <ul> 9 + <li>retryCount: ${result?.retryCount ?? '(none)'}</li> 10 + <li>repeatCount: ${result?.repeatCount ?? '(none)'}</li> 11 + </ul> 12 + ` 13 + document.body.innerHTML = content 14 + await page.getByRole('list').mark(`renderHelper`) 15 + }) 16 + 17 + test('repeated test', { repeats: 2 }, async ({ task }) => { 18 + await renderContext(task.context) 19 + }) 20 + 21 + test('retried test', { retry: 2 }, async ({ task }) => { 22 + await renderContext(task.context) 23 + if (task.result?.retryCount !== 2) { 24 + throw new Error(`failed test at retry count ${task.result?.retryCount}`) 25 + } 26 + }) 27 + 28 + test('repeated retried tests', { repeats: 2, retry: 2 }, async ({ task }) => { 29 + await renderContext(task.context) 30 + if (task.result?.retryCount !== 2) { 31 + throw new Error(`failed test at retry count ${task.result?.retryCount}`) 32 + } 33 + }) 34 + 35 + test('repeated test retried on later repeat', { repeats: 2, retry: 2 }, async ({ task }) => { 36 + await renderContext(task.context) 37 + if (task.result?.repeatCount === 1 && task.result.retryCount !== 1) { 38 + throw new Error(`failed test at retry count ${task.result?.retryCount}`) 39 + } 40 + })
+108
test/browser/fixtures/trace/styles.test.ts
··· 1 + import { beforeEach, expect, test } from 'vitest' 2 + import { page } from 'vitest/browser' 3 + 4 + beforeEach(() => { 5 + document.head.querySelectorAll('[data-trace-fixture]').forEach(node => node.remove()) 6 + document.body.innerHTML = '' 7 + }) 8 + 9 + test('inline styles', async () => { 10 + document.body.innerHTML = '<button style="color: red">Hello</button>' 11 + await page.mark('button rendered with css') 12 + }) 13 + 14 + test('style tag css is inlined', async () => { 15 + const style = document.createElement('style') 16 + style.dataset.traceFixture = '' 17 + style.textContent = ` 18 + .trace-style-tag { 19 + color: rgb(220, 38, 38); 20 + font-weight: 700; 21 + } 22 + .trace-style-tag:hover { 23 + background: rgb(253, 224, 71); 24 + color: rgb(30, 64, 175); 25 + } 26 + ` 27 + document.head.append(style) 28 + document.body.innerHTML = '<button class="trace-style-tag">Hello</button>' 29 + await page.mark('button rendered with style tag css') 30 + }) 31 + 32 + test('same-origin link css is inlined', async () => { 33 + // TODO(test): wait for the stylesheet load event so this actually proves 34 + // rrweb inlined loaded CSS instead of racing the snapshot against asset load. 35 + const link = document.createElement('link') 36 + link.dataset.traceFixture = '' 37 + link.rel = 'stylesheet' 38 + link.href = '/assets/trace-style.css' 39 + document.head.append(link) 40 + document.body.innerHTML = '<button class="trace-linked-css">Hello</button>' 41 + await page.mark('button rendered with same-origin linked css') 42 + }) 43 + 44 + // TODO: capture or attach same-origin CSS subresources so replay does not depend on the original URL. 45 + test('css url resources stay as urls', async () => { 46 + const style = document.createElement('style') 47 + style.dataset.traceFixture = '' 48 + style.textContent = '.trace-bg { width: 24px; height: 24px; background: url("/assets/trace-pixel.svg") center / contain no-repeat; }' 49 + document.head.append(style) 50 + document.body.innerHTML = '<div class="trace-bg" role="img" aria-label="local css background"></div>' 51 + await page.mark('element rendered with css url resource') 52 + }) 53 + 54 + // TODO(test): this test is not meaningful for fidelity because the URL never loads; replace it with a loaded cross-origin fixture. 55 + test('external stylesheet remains url dependent', async () => { 56 + const link = document.createElement('link') 57 + link.dataset.traceFixture = '' 58 + link.rel = 'stylesheet' 59 + link.href = 'https://example.invalid/trace-external.css' 60 + document.head.append(link) 61 + document.body.innerHTML = '<button class="trace-external-css">Hello</button>' 62 + await page.mark('button rendered with external stylesheet') 63 + }) 64 + 65 + // TODO(test): this test is not meaningful for fidelity until /assets/trace-font.woff2 exists and successfully loads. 66 + test('font files remain url dependent', async () => { 67 + const style = document.createElement('style') 68 + style.dataset.traceFixture = '' 69 + style.textContent = ` 70 + @font-face { 71 + font-family: "TraceFixtureFont"; 72 + src: url("/assets/trace-font.woff2") format("woff2"); 73 + } 74 + .trace-font { 75 + font-family: "TraceFixtureFont", sans-serif; 76 + } 77 + ` 78 + document.head.append(style) 79 + document.body.innerHTML = '<button class="trace-font">Hello</button>' 80 + await page.mark('button rendered with font-face url') 81 + }) 82 + 83 + test('snapshot-time pseudo-state styles', async () => { 84 + const link = document.createElement('link') 85 + link.dataset.traceFixture = '' 86 + link.rel = 'stylesheet' 87 + link.href = '/assets/trace-pseudo-style.css' 88 + document.head.append(link) 89 + 90 + document.body.innerHTML = ` 91 + <button class="trace-pseudo-hover">First pseudo state</button> 92 + <button class="trace-pseudo-hover">Second pseudo state</button> 93 + <input class="trace-pseudo-focus" aria-label="Focused pseudo state" value="Focused pseudo state"> 94 + <label class="trace-pseudo-within"> 95 + Focus within target 96 + <input aria-label="Focus within pseudo state" value="Focus within pseudo state"> 97 + </label> 98 + ` 99 + 100 + // ensure stylesheet is ready so rrweb can reliably snapshot 101 + await expect.element(page.getByRole('button', { name: 'First pseudo state' })) 102 + .toHaveStyle({ background: 'rgb(255, 200, 200)' }) 103 + 104 + await page.getByRole('button', { name: 'First pseudo state' }).hover() 105 + await page.getByRole('button', { name: 'Second pseudo state' }).click() 106 + await page.getByRole("textbox", { name: 'Focused pseudo state' }).fill('Test focus') 107 + await page.getByRole("textbox", { name: 'Focus within pseudo state' }).fill('Test focus within') 108 + })
+48
test/browser/fixtures/trace/viewport.test.ts
··· 1 + import { beforeEach, test } from 'vitest' 2 + import { page } from 'vitest/browser' 3 + 4 + beforeEach(() => { 5 + document.head.querySelectorAll('[data-trace-fixture]').forEach(node => node.remove()) 6 + document.documentElement.scrollTop = 0 7 + document.body.innerHTML = '' 8 + }) 9 + 10 + test('viewport media query depends on replay viewport', async () => { 11 + const style = document.createElement('style') 12 + style.dataset.traceFixture = '' 13 + style.textContent = ` 14 + .trace-viewport::before { content: "narrow"; } 15 + @media (min-width: 400px) { 16 + .trace-viewport::before { content: "wide"; } 17 + } 18 + ` 19 + document.head.append(style) 20 + document.body.innerHTML = '<div class="trace-viewport">viewport-sensitive layout</div>' 21 + await page.mark('viewport sensitive layout rendered') 22 + }) 23 + 24 + test('document scroll is restored from trace metadata', async () => { 25 + document.body.innerHTML = ` 26 + <main style="height: 2000px; padding-top: 900px"> 27 + <button>Scrolled document button</button> 28 + </main> 29 + ` 30 + window.scrollTo(0, 450) 31 + await page.getByRole('button').mark('document scrolled before mark') 32 + }) 33 + 34 + test('overflow element scroll is stored in snapshot payload', async () => { 35 + document.body.innerHTML = ` 36 + <section 37 + data-testid="scroll-box" 38 + style="height: 80px; width: 180px; overflow: auto; border: 1px solid black" 39 + > 40 + <div style="height: 240px; padding-top: 150px"> 41 + <button>Nested scrolled button</button> 42 + </div> 43 + </section> 44 + ` 45 + const scrollBox = document.querySelector<HTMLElement>('[data-testid="scroll-box"]')! 46 + scrollBox.scrollTop = 120 47 + await page.getByTestId('scroll-box').mark('overflow container scrolled before mark') 48 + })
+23
test/browser/fixtures/trace/vitest.config.ts
··· 1 + import { defineConfig } from 'vitest/config' 2 + import { instances, provider } from '../../settings' 3 + 4 + // TEST_BROWSER=chromium pnpm -C test/browser test-fixtures --root fixtures/trace 5 + // TEST_BROWSER=chromium pnpm -C test/browser test-fixtures --root fixtures/trace --ui --browser.headless 6 + // TEST_BROWSER=chromium pnpm -C test/browser test-fixtures --root fixtures/trace --reporter=html --browser.headless --run 7 + // pnpm dlx serve test/browser/fixtures/trace/html 8 + export default defineConfig({ 9 + test: { 10 + includeTaskLocation: true, 11 + browser: { 12 + enabled: true, 13 + provider: provider, 14 + instances: instances, 15 + traceView: { 16 + enabled: true, 17 + inlineImages: true, 18 + recordCanvas: true, 19 + }, 20 + screenshotFailures: false, 21 + }, 22 + }, 23 + })
+6 -3
test/browser/settings.ts
··· 36 36 { browser: 'firefox' }, 37 37 ] 38 38 39 - export const instances: BrowserInstanceOption[] = process.env.BROWSER 39 + // use TEST_BROWSER to avoid BROWSER being selected for UI --open 40 + const testBrowser = process.env.TEST_BROWSER ?? process.env.BROWSER 41 + 42 + export const instances: BrowserInstanceOption[] = testBrowser 40 43 ? [ 41 44 { 42 - browser: process.env.BROWSER as any, 45 + browser: testBrowser as any, 43 46 headless: 44 47 wsEndpoint 45 48 ? true 46 - : process.env.BROWSER === 'safari' ? false : undefined, 49 + : testBrowser === 'safari' ? false : undefined, 47 50 }, 48 51 ] 49 52 : provider.name === 'playwright'
+2 -2
test/browser/specs/playwright-trace-mark.test.ts
··· 207 207 // vitest command group (with source) 208 208 expect.objectContaining({ 209 209 method: 'tracingGroup', 210 - title: '__vitest_click', 210 + title: 'vitest:click', 211 211 }), 212 212 // playwright action (without source) 213 213 expect.objectContaining({ ··· 218 218 }), 219 219 ]), 220 220 ) 221 - const markerEvent = events.find(e => e.title === '__vitest_click') 221 + const markerEvent = events.find(e => e.title === 'vitest:click') 222 222 const formattedFrame = formatStack(markerEvent) 223 223 if (name === 'webkit') { 224 224 if (rolldownVersion) {
+1971
test/browser/specs/trace.test.ts
··· 1 + import type { BrowserTraceData, BrowserTraceEntry } from '../../../packages/browser/src/client/tester/trace' 2 + import { stripVTControlCharacters } from 'node:util' 3 + import { relative } from 'pathe' 4 + import { expect, test } from 'vitest' 5 + import { buildTestProjectTree } from '../../test-utils' 6 + import { instances, provider, runBrowserTests } from './utils' 7 + 8 + test('trace view artifacts', async () => { 9 + const result = await runBrowserTests({ 10 + root: './fixtures/trace', 11 + }) 12 + const root = result.ctx.config.root 13 + 14 + function formatLocation(location: BrowserTraceEntry['location']) { 15 + // columns can differ between browsers 16 + // return `${relative(root, location.file)}:${location.line}:${location.column}` 17 + return `${relative(root, location.file)}:${location.line}` 18 + } 19 + 20 + function formatEntry(raw: BrowserTraceEntry) { 21 + // snapshot only stable data. 22 + // other integration features need to be tested separately 23 + const result = { 24 + ...raw, 25 + location: raw.location ? formatLocation(raw.location) : undefined, 26 + stack: undefined, 27 + startTime: undefined, 28 + duration: undefined, 29 + snapshot: { 30 + selectorResolution: raw.snapshot.selectorResolution, 31 + selectorError: raw.snapshot.selectorError, 32 + }, 33 + } 34 + // remove noisy undefined properties 35 + for (const key of Object.keys(result)) { 36 + if (result[key] === undefined) { 37 + delete result[key] 38 + } 39 + if (result[key] && typeof result[key] === 'object') { 40 + for (const subKey of Object.keys(result[key])) { 41 + if (result[key][subKey] === undefined) { 42 + delete result[key][subKey] 43 + } 44 + } 45 + } 46 + } 47 + return result 48 + } 49 + 50 + const projectErrorTree = buildTestProjectTree(result.results, (testCase) => { 51 + const result = testCase.result() 52 + return result.state === 'failed' 53 + ? result.errors.map(e => stripVTControlCharacters(e.message)) 54 + : result.state 55 + }) 56 + 57 + const projectArtifactTree = buildTestProjectTree(result.results, (testCase) => { 58 + const artifacts = testCase.artifacts() 59 + const traces = artifacts.map((artifact) => { 60 + if (artifact.type === 'internal:browserTrace') { 61 + const data = artifact.data as BrowserTraceData 62 + return { 63 + entries: data.entries.map(e => formatEntry(e)), 64 + ...(data.retry ? { retry: data.retry } : {}), 65 + ...(data.repeats ? { repeats: data.repeats } : {}), 66 + } 67 + } 68 + return artifact.type 69 + }) 70 + return traces 71 + }) 72 + 73 + for (const { browser } of instances) { 74 + expect.soft(projectErrorTree[browser], browser).toMatchInlineSnapshot(` 75 + { 76 + "exotic.test.ts": { 77 + "adopted stylesheets are not captured by snapshot alone": "passed", 78 + "canvas pixels are captured for replay": "passed", 79 + "custom element dom shape is rebuilt without runtime behavior": "passed", 80 + "shadow dom is rebuilt and highlightable by mirror id": "passed", 81 + }, 82 + "expect.test.ts": { 83 + "click": "passed", 84 + "click fail": [ 85 + "Click failed (normalized)", 86 + ], 87 + "expect.element fail": [ 88 + "expect(element).toHaveTextContent() 89 + 90 + Expected element to have text content: 91 + World 92 + Received: 93 + Hello", 94 + ], 95 + "expect.element pass": "passed", 96 + "failure": [ 97 + "Test failure", 98 + ], 99 + }, 100 + "mark.test.ts": { 101 + "helper": "passed", 102 + "locator.mark": "passed", 103 + "mark function": "passed", 104 + "page.mark": "passed", 105 + "stack": "passed", 106 + }, 107 + "resources.test.ts": { 108 + "external image remains url dependent": "passed", 109 + "same-origin image is inlined for offline replay": "passed", 110 + }, 111 + "retry.test.ts": { 112 + "repeated retried tests": "passed", 113 + "repeated test": "passed", 114 + "repeated test retried on later repeat": "passed", 115 + "retried test": "passed", 116 + }, 117 + "styles.test.ts": { 118 + "css url resources stay as urls": "passed", 119 + "external stylesheet remains url dependent": "passed", 120 + "font files remain url dependent": "passed", 121 + "inline styles": "passed", 122 + "same-origin link css is inlined": "passed", 123 + "snapshot-time pseudo-state styles": "passed", 124 + "style tag css is inlined": "passed", 125 + }, 126 + "viewport.test.ts": { 127 + "document scroll is restored from trace metadata": "passed", 128 + "overflow element scroll is stored in snapshot payload": "passed", 129 + "viewport media query depends on replay viewport": "passed", 130 + }, 131 + } 132 + `) 133 + // selector format is different between providers 134 + if (provider.name === 'webdriverio') { 135 + expect.soft(projectArtifactTree[browser], browser).toMatchInlineSnapshot(` 136 + { 137 + "exotic.test.ts": { 138 + "adopted stylesheets are not captured by snapshot alone": [ 139 + { 140 + "entries": [ 141 + { 142 + "kind": "mark", 143 + "location": "exotic.test.ts:58", 144 + "name": "button rendered with adopted stylesheet", 145 + "selector": " body > button", 146 + "snapshot": { 147 + "selectorResolution": "matched", 148 + }, 149 + }, 150 + { 151 + "kind": "lifecycle", 152 + "location": "exotic.test.ts:51", 153 + "name": "vitest:onAfterRetryTask", 154 + "snapshot": {}, 155 + "status": "pass", 156 + }, 157 + ], 158 + }, 159 + ], 160 + "canvas pixels are captured for replay": [ 161 + { 162 + "entries": [ 163 + { 164 + "kind": "mark", 165 + "location": "exotic.test.ts:17", 166 + "name": "canvas drawn before mark", 167 + "selector": " body > canvas", 168 + "snapshot": { 169 + "selectorResolution": "matched", 170 + }, 171 + }, 172 + { 173 + "kind": "lifecycle", 174 + "location": "exotic.test.ts:11", 175 + "name": "vitest:onAfterRetryTask", 176 + "snapshot": {}, 177 + "status": "pass", 178 + }, 179 + ], 180 + }, 181 + ], 182 + "custom element dom shape is rebuilt without runtime behavior": [ 183 + { 184 + "entries": [ 185 + { 186 + "kind": "mark", 187 + "location": "exotic.test.ts:47", 188 + "name": "custom element rendered", 189 + "selector": ">>>html > body > trace-widget > button", 190 + "snapshot": { 191 + "selectorError": "Unexpected token "" while parsing selector """, 192 + "selectorResolution": "error", 193 + }, 194 + }, 195 + { 196 + "kind": "lifecycle", 197 + "location": "exotic.test.ts:32", 198 + "name": "vitest:onAfterRetryTask", 199 + "snapshot": {}, 200 + "status": "pass", 201 + }, 202 + ], 203 + }, 204 + ], 205 + "shadow dom is rebuilt and highlightable by mirror id": [ 206 + { 207 + "entries": [ 208 + { 209 + "kind": "mark", 210 + "location": "exotic.test.ts:28", 211 + "name": "shadow button rendered", 212 + "selector": ">>>html > body > section > button", 213 + "snapshot": { 214 + "selectorError": "Unexpected token "" while parsing selector """, 215 + "selectorResolution": "error", 216 + }, 217 + }, 218 + { 219 + "kind": "lifecycle", 220 + "location": "exotic.test.ts:20", 221 + "name": "vitest:onAfterRetryTask", 222 + "snapshot": {}, 223 + "status": "pass", 224 + }, 225 + ], 226 + }, 227 + ], 228 + }, 229 + "expect.test.ts": { 230 + "click": [ 231 + { 232 + "entries": [ 233 + { 234 + "kind": "action", 235 + "location": "expect.test.ts:25", 236 + "name": "vitest:click", 237 + "selector": " body > button", 238 + "snapshot": { 239 + "selectorResolution": "matched", 240 + }, 241 + "status": "pass", 242 + }, 243 + { 244 + "kind": "lifecycle", 245 + "location": "expect.test.ts:23", 246 + "name": "vitest:onAfterRetryTask", 247 + "snapshot": {}, 248 + "status": "pass", 249 + }, 250 + ], 251 + }, 252 + ], 253 + "click fail": [ 254 + { 255 + "entries": [ 256 + { 257 + "kind": "lifecycle", 258 + "location": "expect.test.ts:33", 259 + "name": "vitest:onAfterRetryTask", 260 + "snapshot": {}, 261 + "status": "fail", 262 + }, 263 + ], 264 + }, 265 + ], 266 + "expect.element fail": [ 267 + { 268 + "entries": [ 269 + { 270 + "kind": "expect", 271 + "location": "expect.test.ts:15", 272 + "name": "expect.element().toHaveTextContent [ERROR]", 273 + "selector": " body > button", 274 + "snapshot": { 275 + "selectorResolution": "matched", 276 + }, 277 + "status": "fail", 278 + }, 279 + { 280 + "kind": "lifecycle", 281 + "location": "expect.test.ts:15", 282 + "name": "vitest:onAfterRetryTask", 283 + "snapshot": {}, 284 + "status": "fail", 285 + }, 286 + ], 287 + }, 288 + ], 289 + "expect.element pass": [ 290 + { 291 + "entries": [ 292 + { 293 + "kind": "expect", 294 + "location": "expect.test.ts:10", 295 + "name": "expect.element().toHaveTextContent", 296 + "selector": " body > button", 297 + "snapshot": { 298 + "selectorResolution": "matched", 299 + }, 300 + "status": "pass", 301 + }, 302 + { 303 + "kind": "lifecycle", 304 + "location": "expect.test.ts:8", 305 + "name": "vitest:onAfterRetryTask", 306 + "snapshot": {}, 307 + "status": "pass", 308 + }, 309 + ], 310 + }, 311 + ], 312 + "failure": [ 313 + { 314 + "entries": [ 315 + { 316 + "kind": "lifecycle", 317 + "location": "expect.test.ts:20", 318 + "name": "vitest:onAfterRetryTask", 319 + "snapshot": {}, 320 + "status": "fail", 321 + }, 322 + ], 323 + }, 324 + ], 325 + }, 326 + "mark.test.ts": { 327 + "helper": [ 328 + { 329 + "entries": [ 330 + { 331 + "kind": "mark", 332 + "location": "mark.test.ts:24", 333 + "name": "render helper", 334 + "selector": " body > button", 335 + "snapshot": { 336 + "selectorResolution": "matched", 337 + }, 338 + }, 339 + { 340 + "kind": "lifecycle", 341 + "location": "mark.test.ts:23", 342 + "name": "vitest:onAfterRetryTask", 343 + "snapshot": {}, 344 + "status": "pass", 345 + }, 346 + ], 347 + }, 348 + ], 349 + "locator.mark": [ 350 + { 351 + "entries": [ 352 + { 353 + "kind": "mark", 354 + "location": "mark.test.ts:10", 355 + "name": "button rendered - locator", 356 + "selector": " body > button", 357 + "snapshot": { 358 + "selectorResolution": "matched", 359 + }, 360 + }, 361 + { 362 + "kind": "lifecycle", 363 + "location": "mark.test.ts:8", 364 + "name": "vitest:onAfterRetryTask", 365 + "snapshot": {}, 366 + "status": "pass", 367 + }, 368 + ], 369 + }, 370 + ], 371 + "mark function": [ 372 + { 373 + "entries": [ 374 + { 375 + "kind": "mark", 376 + "location": "mark.test.ts:34", 377 + "name": "render group", 378 + "snapshot": {}, 379 + "status": "pass", 380 + }, 381 + { 382 + "kind": "lifecycle", 383 + "location": "mark.test.ts:33", 384 + "name": "vitest:onAfterRetryTask", 385 + "snapshot": {}, 386 + "status": "pass", 387 + }, 388 + ], 389 + }, 390 + ], 391 + "page.mark": [ 392 + { 393 + "entries": [ 394 + { 395 + "kind": "mark", 396 + "location": "mark.test.ts:15", 397 + "name": "button rendered - page", 398 + "snapshot": {}, 399 + }, 400 + { 401 + "kind": "lifecycle", 402 + "location": "mark.test.ts:13", 403 + "name": "vitest:onAfterRetryTask", 404 + "snapshot": {}, 405 + "status": "pass", 406 + }, 407 + ], 408 + }, 409 + ], 410 + "stack": [ 411 + { 412 + "entries": [ 413 + { 414 + "kind": "mark", 415 + "location": "mark.test.ts:29", 416 + "name": "button rendered - stack", 417 + "selector": " body > button", 418 + "snapshot": { 419 + "selectorResolution": "matched", 420 + }, 421 + }, 422 + { 423 + "kind": "lifecycle", 424 + "location": "mark.test.ts:27", 425 + "name": "vitest:onAfterRetryTask", 426 + "snapshot": {}, 427 + "status": "pass", 428 + }, 429 + ], 430 + }, 431 + ], 432 + }, 433 + "resources.test.ts": { 434 + "external image remains url dependent": [ 435 + { 436 + "entries": [ 437 + { 438 + "kind": "mark", 439 + "location": "resources.test.ts:20", 440 + "name": "image rendered from external url", 441 + "selector": " body > img", 442 + "snapshot": { 443 + "selectorResolution": "matched", 444 + }, 445 + }, 446 + { 447 + "kind": "lifecycle", 448 + "location": "resources.test.ts:17", 449 + "name": "vitest:onAfterRetryTask", 450 + "snapshot": {}, 451 + "status": "pass", 452 + }, 453 + ], 454 + }, 455 + ], 456 + "same-origin image is inlined for offline replay": [ 457 + { 458 + "entries": [ 459 + { 460 + "kind": "mark", 461 + "location": "resources.test.ts:12", 462 + "name": "image rendered from same-origin url", 463 + "selector": " body > img", 464 + "snapshot": { 465 + "selectorResolution": "matched", 466 + }, 467 + }, 468 + { 469 + "kind": "lifecycle", 470 + "location": "resources.test.ts:9", 471 + "name": "vitest:onAfterRetryTask", 472 + "snapshot": {}, 473 + "status": "pass", 474 + }, 475 + ], 476 + }, 477 + ], 478 + }, 479 + "retry.test.ts": { 480 + "repeated retried tests": [ 481 + { 482 + "entries": [ 483 + { 484 + "kind": "mark", 485 + "location": "retry.test.ts:29", 486 + "name": "renderHelper", 487 + "selector": " body > ul", 488 + "snapshot": { 489 + "selectorResolution": "matched", 490 + }, 491 + }, 492 + { 493 + "kind": "lifecycle", 494 + "location": "retry.test.ts:31", 495 + "name": "vitest:onAfterRetryTask", 496 + "snapshot": {}, 497 + "status": "fail", 498 + }, 499 + ], 500 + }, 501 + { 502 + "entries": [ 503 + { 504 + "kind": "mark", 505 + "location": "retry.test.ts:29", 506 + "name": "renderHelper", 507 + "selector": " body > ul", 508 + "snapshot": { 509 + "selectorResolution": "matched", 510 + }, 511 + }, 512 + { 513 + "kind": "lifecycle", 514 + "location": "retry.test.ts:31", 515 + "name": "vitest:onAfterRetryTask", 516 + "snapshot": {}, 517 + "status": "fail", 518 + }, 519 + ], 520 + "retry": 1, 521 + }, 522 + { 523 + "entries": [ 524 + { 525 + "kind": "mark", 526 + "location": "retry.test.ts:29", 527 + "name": "renderHelper", 528 + "selector": " body > ul", 529 + "snapshot": { 530 + "selectorResolution": "matched", 531 + }, 532 + }, 533 + { 534 + "kind": "lifecycle", 535 + "location": "retry.test.ts:28", 536 + "name": "vitest:onAfterRetryTask", 537 + "snapshot": {}, 538 + "status": "pass", 539 + }, 540 + ], 541 + "retry": 2, 542 + }, 543 + { 544 + "entries": [ 545 + { 546 + "kind": "mark", 547 + "location": "retry.test.ts:29", 548 + "name": "renderHelper", 549 + "selector": " body > ul", 550 + "snapshot": { 551 + "selectorResolution": "matched", 552 + }, 553 + }, 554 + { 555 + "kind": "lifecycle", 556 + "location": "retry.test.ts:28", 557 + "name": "vitest:onAfterRetryTask", 558 + "snapshot": {}, 559 + "status": "pass", 560 + }, 561 + ], 562 + "repeats": 1, 563 + }, 564 + { 565 + "entries": [ 566 + { 567 + "kind": "mark", 568 + "location": "retry.test.ts:29", 569 + "name": "renderHelper", 570 + "selector": " body > ul", 571 + "snapshot": { 572 + "selectorResolution": "matched", 573 + }, 574 + }, 575 + { 576 + "kind": "lifecycle", 577 + "location": "retry.test.ts:28", 578 + "name": "vitest:onAfterRetryTask", 579 + "snapshot": {}, 580 + "status": "pass", 581 + }, 582 + ], 583 + "repeats": 2, 584 + }, 585 + ], 586 + "repeated test": [ 587 + { 588 + "entries": [ 589 + { 590 + "kind": "mark", 591 + "location": "retry.test.ts:18", 592 + "name": "renderHelper", 593 + "selector": " body > ul", 594 + "snapshot": { 595 + "selectorResolution": "matched", 596 + }, 597 + }, 598 + { 599 + "kind": "lifecycle", 600 + "location": "retry.test.ts:17", 601 + "name": "vitest:onAfterRetryTask", 602 + "snapshot": {}, 603 + "status": "pass", 604 + }, 605 + ], 606 + }, 607 + { 608 + "entries": [ 609 + { 610 + "kind": "mark", 611 + "location": "retry.test.ts:18", 612 + "name": "renderHelper", 613 + "selector": " body > ul", 614 + "snapshot": { 615 + "selectorResolution": "matched", 616 + }, 617 + }, 618 + { 619 + "kind": "lifecycle", 620 + "location": "retry.test.ts:17", 621 + "name": "vitest:onAfterRetryTask", 622 + "snapshot": {}, 623 + "status": "pass", 624 + }, 625 + ], 626 + "repeats": 1, 627 + }, 628 + { 629 + "entries": [ 630 + { 631 + "kind": "mark", 632 + "location": "retry.test.ts:18", 633 + "name": "renderHelper", 634 + "selector": " body > ul", 635 + "snapshot": { 636 + "selectorResolution": "matched", 637 + }, 638 + }, 639 + { 640 + "kind": "lifecycle", 641 + "location": "retry.test.ts:17", 642 + "name": "vitest:onAfterRetryTask", 643 + "snapshot": {}, 644 + "status": "pass", 645 + }, 646 + ], 647 + "repeats": 2, 648 + }, 649 + ], 650 + "repeated test retried on later repeat": [ 651 + { 652 + "entries": [ 653 + { 654 + "kind": "mark", 655 + "location": "retry.test.ts:36", 656 + "name": "renderHelper", 657 + "selector": " body > ul", 658 + "snapshot": { 659 + "selectorResolution": "matched", 660 + }, 661 + }, 662 + { 663 + "kind": "lifecycle", 664 + "location": "retry.test.ts:35", 665 + "name": "vitest:onAfterRetryTask", 666 + "snapshot": {}, 667 + "status": "pass", 668 + }, 669 + ], 670 + }, 671 + { 672 + "entries": [ 673 + { 674 + "kind": "mark", 675 + "location": "retry.test.ts:36", 676 + "name": "renderHelper", 677 + "selector": " body > ul", 678 + "snapshot": { 679 + "selectorResolution": "matched", 680 + }, 681 + }, 682 + { 683 + "kind": "lifecycle", 684 + "location": "retry.test.ts:38", 685 + "name": "vitest:onAfterRetryTask", 686 + "snapshot": {}, 687 + "status": "fail", 688 + }, 689 + ], 690 + "repeats": 1, 691 + }, 692 + { 693 + "entries": [ 694 + { 695 + "kind": "mark", 696 + "location": "retry.test.ts:36", 697 + "name": "renderHelper", 698 + "selector": " body > ul", 699 + "snapshot": { 700 + "selectorResolution": "matched", 701 + }, 702 + }, 703 + { 704 + "kind": "lifecycle", 705 + "location": "retry.test.ts:35", 706 + "name": "vitest:onAfterRetryTask", 707 + "snapshot": {}, 708 + "status": "pass", 709 + }, 710 + ], 711 + "repeats": 1, 712 + "retry": 1, 713 + }, 714 + { 715 + "entries": [ 716 + { 717 + "kind": "mark", 718 + "location": "retry.test.ts:36", 719 + "name": "renderHelper", 720 + "selector": " body > ul", 721 + "snapshot": { 722 + "selectorResolution": "matched", 723 + }, 724 + }, 725 + { 726 + "kind": "lifecycle", 727 + "location": "retry.test.ts:35", 728 + "name": "vitest:onAfterRetryTask", 729 + "snapshot": {}, 730 + "status": "pass", 731 + }, 732 + ], 733 + "repeats": 2, 734 + }, 735 + ], 736 + "retried test": [ 737 + { 738 + "entries": [ 739 + { 740 + "kind": "mark", 741 + "location": "retry.test.ts:22", 742 + "name": "renderHelper", 743 + "selector": " body > ul", 744 + "snapshot": { 745 + "selectorResolution": "matched", 746 + }, 747 + }, 748 + { 749 + "kind": "lifecycle", 750 + "location": "retry.test.ts:24", 751 + "name": "vitest:onAfterRetryTask", 752 + "snapshot": {}, 753 + "status": "fail", 754 + }, 755 + ], 756 + }, 757 + { 758 + "entries": [ 759 + { 760 + "kind": "mark", 761 + "location": "retry.test.ts:22", 762 + "name": "renderHelper", 763 + "selector": " body > ul", 764 + "snapshot": { 765 + "selectorResolution": "matched", 766 + }, 767 + }, 768 + { 769 + "kind": "lifecycle", 770 + "location": "retry.test.ts:24", 771 + "name": "vitest:onAfterRetryTask", 772 + "snapshot": {}, 773 + "status": "fail", 774 + }, 775 + ], 776 + "retry": 1, 777 + }, 778 + { 779 + "entries": [ 780 + { 781 + "kind": "mark", 782 + "location": "retry.test.ts:22", 783 + "name": "renderHelper", 784 + "selector": " body > ul", 785 + "snapshot": { 786 + "selectorResolution": "matched", 787 + }, 788 + }, 789 + { 790 + "kind": "lifecycle", 791 + "location": "retry.test.ts:21", 792 + "name": "vitest:onAfterRetryTask", 793 + "snapshot": {}, 794 + "status": "pass", 795 + }, 796 + ], 797 + "retry": 2, 798 + }, 799 + ], 800 + }, 801 + "styles.test.ts": { 802 + "css url resources stay as urls": [ 803 + { 804 + "entries": [ 805 + { 806 + "kind": "mark", 807 + "location": "styles.test.ts:51", 808 + "name": "element rendered with css url resource", 809 + "snapshot": {}, 810 + }, 811 + { 812 + "kind": "lifecycle", 813 + "location": "styles.test.ts:45", 814 + "name": "vitest:onAfterRetryTask", 815 + "snapshot": {}, 816 + "status": "pass", 817 + }, 818 + ], 819 + }, 820 + ], 821 + "external stylesheet remains url dependent": [ 822 + { 823 + "entries": [ 824 + { 825 + "kind": "mark", 826 + "location": "styles.test.ts:62", 827 + "name": "button rendered with external stylesheet", 828 + "snapshot": {}, 829 + }, 830 + { 831 + "kind": "lifecycle", 832 + "location": "styles.test.ts:55", 833 + "name": "vitest:onAfterRetryTask", 834 + "snapshot": {}, 835 + "status": "pass", 836 + }, 837 + ], 838 + }, 839 + ], 840 + "font files remain url dependent": [ 841 + { 842 + "entries": [ 843 + { 844 + "kind": "mark", 845 + "location": "styles.test.ts:80", 846 + "name": "button rendered with font-face url", 847 + "snapshot": {}, 848 + }, 849 + { 850 + "kind": "lifecycle", 851 + "location": "styles.test.ts:66", 852 + "name": "vitest:onAfterRetryTask", 853 + "snapshot": {}, 854 + "status": "pass", 855 + }, 856 + ], 857 + }, 858 + ], 859 + "inline styles": [ 860 + { 861 + "entries": [ 862 + { 863 + "kind": "mark", 864 + "location": "styles.test.ts:11", 865 + "name": "button rendered with css", 866 + "snapshot": {}, 867 + }, 868 + { 869 + "kind": "lifecycle", 870 + "location": "styles.test.ts:9", 871 + "name": "vitest:onAfterRetryTask", 872 + "snapshot": {}, 873 + "status": "pass", 874 + }, 875 + ], 876 + }, 877 + ], 878 + "same-origin link css is inlined": [ 879 + { 880 + "entries": [ 881 + { 882 + "kind": "mark", 883 + "location": "styles.test.ts:41", 884 + "name": "button rendered with same-origin linked css", 885 + "snapshot": {}, 886 + }, 887 + { 888 + "kind": "lifecycle", 889 + "location": "styles.test.ts:32", 890 + "name": "vitest:onAfterRetryTask", 891 + "snapshot": {}, 892 + "status": "pass", 893 + }, 894 + ], 895 + }, 896 + ], 897 + "snapshot-time pseudo-state styles": [ 898 + { 899 + "entries": [ 900 + { 901 + "kind": "expect", 902 + "location": "styles.test.ts:101", 903 + "name": "expect.element().toHaveStyle", 904 + "selector": " body > button:nth-child(1)", 905 + "snapshot": { 906 + "selectorResolution": "matched", 907 + }, 908 + "status": "pass", 909 + }, 910 + { 911 + "kind": "action", 912 + "location": "styles.test.ts:104", 913 + "name": "vitest:hover", 914 + "selector": " body > button:nth-child(1)", 915 + "snapshot": { 916 + "selectorResolution": "matched", 917 + }, 918 + "status": "pass", 919 + }, 920 + { 921 + "kind": "action", 922 + "location": "styles.test.ts:105", 923 + "name": "vitest:click", 924 + "selector": " body > button:nth-child(2)", 925 + "snapshot": { 926 + "selectorResolution": "matched", 927 + }, 928 + "status": "pass", 929 + }, 930 + { 931 + "kind": "action", 932 + "location": "styles.test.ts:106", 933 + "name": "vitest:fill", 934 + "selector": " body > input", 935 + "snapshot": { 936 + "selectorResolution": "matched", 937 + }, 938 + "status": "pass", 939 + }, 940 + { 941 + "kind": "action", 942 + "location": "styles.test.ts:107", 943 + "name": "vitest:fill", 944 + "selector": " body > label > input", 945 + "snapshot": { 946 + "selectorResolution": "matched", 947 + }, 948 + "status": "pass", 949 + }, 950 + { 951 + "kind": "lifecycle", 952 + "location": "styles.test.ts:83", 953 + "name": "vitest:onAfterRetryTask", 954 + "snapshot": {}, 955 + "status": "pass", 956 + }, 957 + ], 958 + }, 959 + ], 960 + "style tag css is inlined": [ 961 + { 962 + "entries": [ 963 + { 964 + "kind": "mark", 965 + "location": "styles.test.ts:29", 966 + "name": "button rendered with style tag css", 967 + "snapshot": {}, 968 + }, 969 + { 970 + "kind": "lifecycle", 971 + "location": "styles.test.ts:14", 972 + "name": "vitest:onAfterRetryTask", 973 + "snapshot": {}, 974 + "status": "pass", 975 + }, 976 + ], 977 + }, 978 + ], 979 + }, 980 + "viewport.test.ts": { 981 + "document scroll is restored from trace metadata": [ 982 + { 983 + "entries": [ 984 + { 985 + "kind": "mark", 986 + "location": "viewport.test.ts:31", 987 + "name": "document scrolled before mark", 988 + "selector": " body > main > button", 989 + "snapshot": { 990 + "selectorResolution": "matched", 991 + }, 992 + }, 993 + { 994 + "kind": "lifecycle", 995 + "location": "viewport.test.ts:24", 996 + "name": "vitest:onAfterRetryTask", 997 + "snapshot": {}, 998 + "status": "pass", 999 + }, 1000 + ], 1001 + }, 1002 + ], 1003 + "overflow element scroll is stored in snapshot payload": [ 1004 + { 1005 + "entries": [ 1006 + { 1007 + "kind": "mark", 1008 + "location": "viewport.test.ts:47", 1009 + "name": "overflow container scrolled before mark", 1010 + "selector": " body > section", 1011 + "snapshot": { 1012 + "selectorResolution": "matched", 1013 + }, 1014 + }, 1015 + { 1016 + "kind": "lifecycle", 1017 + "location": "viewport.test.ts:34", 1018 + "name": "vitest:onAfterRetryTask", 1019 + "snapshot": {}, 1020 + "status": "pass", 1021 + }, 1022 + ], 1023 + }, 1024 + ], 1025 + "viewport media query depends on replay viewport": [ 1026 + { 1027 + "entries": [ 1028 + { 1029 + "kind": "mark", 1030 + "location": "viewport.test.ts:21", 1031 + "name": "viewport sensitive layout rendered", 1032 + "snapshot": {}, 1033 + }, 1034 + { 1035 + "kind": "lifecycle", 1036 + "location": "viewport.test.ts:10", 1037 + "name": "vitest:onAfterRetryTask", 1038 + "snapshot": {}, 1039 + "status": "pass", 1040 + }, 1041 + ], 1042 + }, 1043 + ], 1044 + }, 1045 + } 1046 + `) 1047 + } 1048 + if (provider.name === 'playwright') { 1049 + expect.soft(projectArtifactTree[browser], browser).toMatchInlineSnapshot(` 1050 + { 1051 + "exotic.test.ts": { 1052 + "adopted stylesheets are not captured by snapshot alone": [ 1053 + { 1054 + "entries": [ 1055 + { 1056 + "kind": "mark", 1057 + "location": "exotic.test.ts:58", 1058 + "name": "button rendered with adopted stylesheet", 1059 + "selector": "internal:role=button", 1060 + "snapshot": { 1061 + "selectorResolution": "matched", 1062 + }, 1063 + }, 1064 + { 1065 + "kind": "lifecycle", 1066 + "location": "exotic.test.ts:51", 1067 + "name": "vitest:onAfterRetryTask", 1068 + "snapshot": {}, 1069 + "status": "pass", 1070 + }, 1071 + ], 1072 + }, 1073 + ], 1074 + "canvas pixels are captured for replay": [ 1075 + { 1076 + "entries": [ 1077 + { 1078 + "kind": "mark", 1079 + "location": "exotic.test.ts:17", 1080 + "name": "canvas drawn before mark", 1081 + "selector": "internal:testid=[data-testid="trace-canvas"s]", 1082 + "snapshot": { 1083 + "selectorResolution": "matched", 1084 + }, 1085 + }, 1086 + { 1087 + "kind": "lifecycle", 1088 + "location": "exotic.test.ts:11", 1089 + "name": "vitest:onAfterRetryTask", 1090 + "snapshot": {}, 1091 + "status": "pass", 1092 + }, 1093 + ], 1094 + }, 1095 + ], 1096 + "custom element dom shape is rebuilt without runtime behavior": [ 1097 + { 1098 + "entries": [ 1099 + { 1100 + "kind": "mark", 1101 + "location": "exotic.test.ts:47", 1102 + "name": "custom element rendered", 1103 + "selector": "internal:role=button[name="Custom element button"i]", 1104 + "snapshot": { 1105 + "selectorResolution": "matched", 1106 + }, 1107 + }, 1108 + { 1109 + "kind": "lifecycle", 1110 + "location": "exotic.test.ts:32", 1111 + "name": "vitest:onAfterRetryTask", 1112 + "snapshot": {}, 1113 + "status": "pass", 1114 + }, 1115 + ], 1116 + }, 1117 + ], 1118 + "shadow dom is rebuilt and highlightable by mirror id": [ 1119 + { 1120 + "entries": [ 1121 + { 1122 + "kind": "mark", 1123 + "location": "exotic.test.ts:28", 1124 + "name": "shadow button rendered", 1125 + "selector": "internal:role=button[name="Shadow button"i]", 1126 + "snapshot": { 1127 + "selectorResolution": "matched", 1128 + }, 1129 + }, 1130 + { 1131 + "kind": "lifecycle", 1132 + "location": "exotic.test.ts:20", 1133 + "name": "vitest:onAfterRetryTask", 1134 + "snapshot": {}, 1135 + "status": "pass", 1136 + }, 1137 + ], 1138 + }, 1139 + ], 1140 + }, 1141 + "expect.test.ts": { 1142 + "click": [ 1143 + { 1144 + "entries": [ 1145 + { 1146 + "kind": "action", 1147 + "location": "expect.test.ts:25", 1148 + "name": "vitest:click", 1149 + "selector": "internal:role=button", 1150 + "snapshot": { 1151 + "selectorResolution": "matched", 1152 + }, 1153 + "status": "pass", 1154 + }, 1155 + { 1156 + "kind": "lifecycle", 1157 + "location": "expect.test.ts:23", 1158 + "name": "vitest:onAfterRetryTask", 1159 + "snapshot": {}, 1160 + "status": "pass", 1161 + }, 1162 + ], 1163 + }, 1164 + ], 1165 + "click fail": [ 1166 + { 1167 + "entries": [ 1168 + { 1169 + "kind": "action", 1170 + "location": "expect.test.ts:33", 1171 + "name": "vitest:click", 1172 + "selector": "internal:role=button[name="Missing"i]", 1173 + "snapshot": { 1174 + "selectorResolution": "missing", 1175 + }, 1176 + "status": "fail", 1177 + }, 1178 + { 1179 + "kind": "lifecycle", 1180 + "location": "expect.test.ts:33", 1181 + "name": "vitest:onAfterRetryTask", 1182 + "snapshot": {}, 1183 + "status": "fail", 1184 + }, 1185 + ], 1186 + }, 1187 + ], 1188 + "expect.element fail": [ 1189 + { 1190 + "entries": [ 1191 + { 1192 + "kind": "expect", 1193 + "location": "expect.test.ts:15", 1194 + "name": "expect.element().toHaveTextContent [ERROR]", 1195 + "selector": "internal:role=button", 1196 + "snapshot": { 1197 + "selectorResolution": "matched", 1198 + }, 1199 + "status": "fail", 1200 + }, 1201 + { 1202 + "kind": "lifecycle", 1203 + "location": "expect.test.ts:15", 1204 + "name": "vitest:onAfterRetryTask", 1205 + "snapshot": {}, 1206 + "status": "fail", 1207 + }, 1208 + ], 1209 + }, 1210 + ], 1211 + "expect.element pass": [ 1212 + { 1213 + "entries": [ 1214 + { 1215 + "kind": "expect", 1216 + "location": "expect.test.ts:10", 1217 + "name": "expect.element().toHaveTextContent", 1218 + "selector": "internal:role=button", 1219 + "snapshot": { 1220 + "selectorResolution": "matched", 1221 + }, 1222 + "status": "pass", 1223 + }, 1224 + { 1225 + "kind": "lifecycle", 1226 + "location": "expect.test.ts:8", 1227 + "name": "vitest:onAfterRetryTask", 1228 + "snapshot": {}, 1229 + "status": "pass", 1230 + }, 1231 + ], 1232 + }, 1233 + ], 1234 + "failure": [ 1235 + { 1236 + "entries": [ 1237 + { 1238 + "kind": "lifecycle", 1239 + "location": "expect.test.ts:20", 1240 + "name": "vitest:onAfterRetryTask", 1241 + "snapshot": {}, 1242 + "status": "fail", 1243 + }, 1244 + ], 1245 + }, 1246 + ], 1247 + }, 1248 + "mark.test.ts": { 1249 + "helper": [ 1250 + { 1251 + "entries": [ 1252 + { 1253 + "kind": "mark", 1254 + "location": "mark.test.ts:24", 1255 + "name": "render helper", 1256 + "selector": "internal:role=button[name="Hello"i]", 1257 + "snapshot": { 1258 + "selectorResolution": "matched", 1259 + }, 1260 + }, 1261 + { 1262 + "kind": "lifecycle", 1263 + "location": "mark.test.ts:23", 1264 + "name": "vitest:onAfterRetryTask", 1265 + "snapshot": {}, 1266 + "status": "pass", 1267 + }, 1268 + ], 1269 + }, 1270 + ], 1271 + "locator.mark": [ 1272 + { 1273 + "entries": [ 1274 + { 1275 + "kind": "mark", 1276 + "location": "mark.test.ts:10", 1277 + "name": "button rendered - locator", 1278 + "selector": "internal:role=button", 1279 + "snapshot": { 1280 + "selectorResolution": "matched", 1281 + }, 1282 + }, 1283 + { 1284 + "kind": "lifecycle", 1285 + "location": "mark.test.ts:8", 1286 + "name": "vitest:onAfterRetryTask", 1287 + "snapshot": {}, 1288 + "status": "pass", 1289 + }, 1290 + ], 1291 + }, 1292 + ], 1293 + "mark function": [ 1294 + { 1295 + "entries": [ 1296 + { 1297 + "kind": "mark", 1298 + "location": "mark.test.ts:34", 1299 + "name": "render group", 1300 + "snapshot": {}, 1301 + "status": "pass", 1302 + }, 1303 + { 1304 + "kind": "lifecycle", 1305 + "location": "mark.test.ts:33", 1306 + "name": "vitest:onAfterRetryTask", 1307 + "snapshot": {}, 1308 + "status": "pass", 1309 + }, 1310 + ], 1311 + }, 1312 + ], 1313 + "page.mark": [ 1314 + { 1315 + "entries": [ 1316 + { 1317 + "kind": "mark", 1318 + "location": "mark.test.ts:15", 1319 + "name": "button rendered - page", 1320 + "snapshot": {}, 1321 + }, 1322 + { 1323 + "kind": "lifecycle", 1324 + "location": "mark.test.ts:13", 1325 + "name": "vitest:onAfterRetryTask", 1326 + "snapshot": {}, 1327 + "status": "pass", 1328 + }, 1329 + ], 1330 + }, 1331 + ], 1332 + "stack": [ 1333 + { 1334 + "entries": [ 1335 + { 1336 + "kind": "mark", 1337 + "location": "mark.test.ts:29", 1338 + "name": "button rendered - stack", 1339 + "selector": "internal:role=button", 1340 + "snapshot": { 1341 + "selectorResolution": "matched", 1342 + }, 1343 + }, 1344 + { 1345 + "kind": "lifecycle", 1346 + "location": "mark.test.ts:27", 1347 + "name": "vitest:onAfterRetryTask", 1348 + "snapshot": {}, 1349 + "status": "pass", 1350 + }, 1351 + ], 1352 + }, 1353 + ], 1354 + }, 1355 + "resources.test.ts": { 1356 + "external image remains url dependent": [ 1357 + { 1358 + "entries": [ 1359 + { 1360 + "kind": "mark", 1361 + "location": "resources.test.ts:20", 1362 + "name": "image rendered from external url", 1363 + "selector": "internal:attr=[alt="external trace asset"i]", 1364 + "snapshot": { 1365 + "selectorResolution": "matched", 1366 + }, 1367 + }, 1368 + { 1369 + "kind": "lifecycle", 1370 + "location": "resources.test.ts:17", 1371 + "name": "vitest:onAfterRetryTask", 1372 + "snapshot": {}, 1373 + "status": "pass", 1374 + }, 1375 + ], 1376 + }, 1377 + ], 1378 + "same-origin image is inlined for offline replay": [ 1379 + { 1380 + "entries": [ 1381 + { 1382 + "kind": "mark", 1383 + "location": "resources.test.ts:12", 1384 + "name": "image rendered from same-origin url", 1385 + "selector": "internal:attr=[alt="local trace asset"i]", 1386 + "snapshot": { 1387 + "selectorResolution": "matched", 1388 + }, 1389 + }, 1390 + { 1391 + "kind": "lifecycle", 1392 + "location": "resources.test.ts:9", 1393 + "name": "vitest:onAfterRetryTask", 1394 + "snapshot": {}, 1395 + "status": "pass", 1396 + }, 1397 + ], 1398 + }, 1399 + ], 1400 + }, 1401 + "retry.test.ts": { 1402 + "repeated retried tests": [ 1403 + { 1404 + "entries": [ 1405 + { 1406 + "kind": "mark", 1407 + "location": "retry.test.ts:29", 1408 + "name": "renderHelper", 1409 + "selector": "internal:role=list", 1410 + "snapshot": { 1411 + "selectorResolution": "matched", 1412 + }, 1413 + }, 1414 + { 1415 + "kind": "lifecycle", 1416 + "location": "retry.test.ts:31", 1417 + "name": "vitest:onAfterRetryTask", 1418 + "snapshot": {}, 1419 + "status": "fail", 1420 + }, 1421 + ], 1422 + }, 1423 + { 1424 + "entries": [ 1425 + { 1426 + "kind": "mark", 1427 + "location": "retry.test.ts:29", 1428 + "name": "renderHelper", 1429 + "selector": "internal:role=list", 1430 + "snapshot": { 1431 + "selectorResolution": "matched", 1432 + }, 1433 + }, 1434 + { 1435 + "kind": "lifecycle", 1436 + "location": "retry.test.ts:31", 1437 + "name": "vitest:onAfterRetryTask", 1438 + "snapshot": {}, 1439 + "status": "fail", 1440 + }, 1441 + ], 1442 + "retry": 1, 1443 + }, 1444 + { 1445 + "entries": [ 1446 + { 1447 + "kind": "mark", 1448 + "location": "retry.test.ts:29", 1449 + "name": "renderHelper", 1450 + "selector": "internal:role=list", 1451 + "snapshot": { 1452 + "selectorResolution": "matched", 1453 + }, 1454 + }, 1455 + { 1456 + "kind": "lifecycle", 1457 + "location": "retry.test.ts:28", 1458 + "name": "vitest:onAfterRetryTask", 1459 + "snapshot": {}, 1460 + "status": "pass", 1461 + }, 1462 + ], 1463 + "retry": 2, 1464 + }, 1465 + { 1466 + "entries": [ 1467 + { 1468 + "kind": "mark", 1469 + "location": "retry.test.ts:29", 1470 + "name": "renderHelper", 1471 + "selector": "internal:role=list", 1472 + "snapshot": { 1473 + "selectorResolution": "matched", 1474 + }, 1475 + }, 1476 + { 1477 + "kind": "lifecycle", 1478 + "location": "retry.test.ts:28", 1479 + "name": "vitest:onAfterRetryTask", 1480 + "snapshot": {}, 1481 + "status": "pass", 1482 + }, 1483 + ], 1484 + "repeats": 1, 1485 + }, 1486 + { 1487 + "entries": [ 1488 + { 1489 + "kind": "mark", 1490 + "location": "retry.test.ts:29", 1491 + "name": "renderHelper", 1492 + "selector": "internal:role=list", 1493 + "snapshot": { 1494 + "selectorResolution": "matched", 1495 + }, 1496 + }, 1497 + { 1498 + "kind": "lifecycle", 1499 + "location": "retry.test.ts:28", 1500 + "name": "vitest:onAfterRetryTask", 1501 + "snapshot": {}, 1502 + "status": "pass", 1503 + }, 1504 + ], 1505 + "repeats": 2, 1506 + }, 1507 + ], 1508 + "repeated test": [ 1509 + { 1510 + "entries": [ 1511 + { 1512 + "kind": "mark", 1513 + "location": "retry.test.ts:18", 1514 + "name": "renderHelper", 1515 + "selector": "internal:role=list", 1516 + "snapshot": { 1517 + "selectorResolution": "matched", 1518 + }, 1519 + }, 1520 + { 1521 + "kind": "lifecycle", 1522 + "location": "retry.test.ts:17", 1523 + "name": "vitest:onAfterRetryTask", 1524 + "snapshot": {}, 1525 + "status": "pass", 1526 + }, 1527 + ], 1528 + }, 1529 + { 1530 + "entries": [ 1531 + { 1532 + "kind": "mark", 1533 + "location": "retry.test.ts:18", 1534 + "name": "renderHelper", 1535 + "selector": "internal:role=list", 1536 + "snapshot": { 1537 + "selectorResolution": "matched", 1538 + }, 1539 + }, 1540 + { 1541 + "kind": "lifecycle", 1542 + "location": "retry.test.ts:17", 1543 + "name": "vitest:onAfterRetryTask", 1544 + "snapshot": {}, 1545 + "status": "pass", 1546 + }, 1547 + ], 1548 + "repeats": 1, 1549 + }, 1550 + { 1551 + "entries": [ 1552 + { 1553 + "kind": "mark", 1554 + "location": "retry.test.ts:18", 1555 + "name": "renderHelper", 1556 + "selector": "internal:role=list", 1557 + "snapshot": { 1558 + "selectorResolution": "matched", 1559 + }, 1560 + }, 1561 + { 1562 + "kind": "lifecycle", 1563 + "location": "retry.test.ts:17", 1564 + "name": "vitest:onAfterRetryTask", 1565 + "snapshot": {}, 1566 + "status": "pass", 1567 + }, 1568 + ], 1569 + "repeats": 2, 1570 + }, 1571 + ], 1572 + "repeated test retried on later repeat": [ 1573 + { 1574 + "entries": [ 1575 + { 1576 + "kind": "mark", 1577 + "location": "retry.test.ts:36", 1578 + "name": "renderHelper", 1579 + "selector": "internal:role=list", 1580 + "snapshot": { 1581 + "selectorResolution": "matched", 1582 + }, 1583 + }, 1584 + { 1585 + "kind": "lifecycle", 1586 + "location": "retry.test.ts:35", 1587 + "name": "vitest:onAfterRetryTask", 1588 + "snapshot": {}, 1589 + "status": "pass", 1590 + }, 1591 + ], 1592 + }, 1593 + { 1594 + "entries": [ 1595 + { 1596 + "kind": "mark", 1597 + "location": "retry.test.ts:36", 1598 + "name": "renderHelper", 1599 + "selector": "internal:role=list", 1600 + "snapshot": { 1601 + "selectorResolution": "matched", 1602 + }, 1603 + }, 1604 + { 1605 + "kind": "lifecycle", 1606 + "location": "retry.test.ts:38", 1607 + "name": "vitest:onAfterRetryTask", 1608 + "snapshot": {}, 1609 + "status": "fail", 1610 + }, 1611 + ], 1612 + "repeats": 1, 1613 + }, 1614 + { 1615 + "entries": [ 1616 + { 1617 + "kind": "mark", 1618 + "location": "retry.test.ts:36", 1619 + "name": "renderHelper", 1620 + "selector": "internal:role=list", 1621 + "snapshot": { 1622 + "selectorResolution": "matched", 1623 + }, 1624 + }, 1625 + { 1626 + "kind": "lifecycle", 1627 + "location": "retry.test.ts:35", 1628 + "name": "vitest:onAfterRetryTask", 1629 + "snapshot": {}, 1630 + "status": "pass", 1631 + }, 1632 + ], 1633 + "repeats": 1, 1634 + "retry": 1, 1635 + }, 1636 + { 1637 + "entries": [ 1638 + { 1639 + "kind": "mark", 1640 + "location": "retry.test.ts:36", 1641 + "name": "renderHelper", 1642 + "selector": "internal:role=list", 1643 + "snapshot": { 1644 + "selectorResolution": "matched", 1645 + }, 1646 + }, 1647 + { 1648 + "kind": "lifecycle", 1649 + "location": "retry.test.ts:35", 1650 + "name": "vitest:onAfterRetryTask", 1651 + "snapshot": {}, 1652 + "status": "pass", 1653 + }, 1654 + ], 1655 + "repeats": 2, 1656 + }, 1657 + ], 1658 + "retried test": [ 1659 + { 1660 + "entries": [ 1661 + { 1662 + "kind": "mark", 1663 + "location": "retry.test.ts:22", 1664 + "name": "renderHelper", 1665 + "selector": "internal:role=list", 1666 + "snapshot": { 1667 + "selectorResolution": "matched", 1668 + }, 1669 + }, 1670 + { 1671 + "kind": "lifecycle", 1672 + "location": "retry.test.ts:24", 1673 + "name": "vitest:onAfterRetryTask", 1674 + "snapshot": {}, 1675 + "status": "fail", 1676 + }, 1677 + ], 1678 + }, 1679 + { 1680 + "entries": [ 1681 + { 1682 + "kind": "mark", 1683 + "location": "retry.test.ts:22", 1684 + "name": "renderHelper", 1685 + "selector": "internal:role=list", 1686 + "snapshot": { 1687 + "selectorResolution": "matched", 1688 + }, 1689 + }, 1690 + { 1691 + "kind": "lifecycle", 1692 + "location": "retry.test.ts:24", 1693 + "name": "vitest:onAfterRetryTask", 1694 + "snapshot": {}, 1695 + "status": "fail", 1696 + }, 1697 + ], 1698 + "retry": 1, 1699 + }, 1700 + { 1701 + "entries": [ 1702 + { 1703 + "kind": "mark", 1704 + "location": "retry.test.ts:22", 1705 + "name": "renderHelper", 1706 + "selector": "internal:role=list", 1707 + "snapshot": { 1708 + "selectorResolution": "matched", 1709 + }, 1710 + }, 1711 + { 1712 + "kind": "lifecycle", 1713 + "location": "retry.test.ts:21", 1714 + "name": "vitest:onAfterRetryTask", 1715 + "snapshot": {}, 1716 + "status": "pass", 1717 + }, 1718 + ], 1719 + "retry": 2, 1720 + }, 1721 + ], 1722 + }, 1723 + "styles.test.ts": { 1724 + "css url resources stay as urls": [ 1725 + { 1726 + "entries": [ 1727 + { 1728 + "kind": "mark", 1729 + "location": "styles.test.ts:51", 1730 + "name": "element rendered with css url resource", 1731 + "snapshot": {}, 1732 + }, 1733 + { 1734 + "kind": "lifecycle", 1735 + "location": "styles.test.ts:45", 1736 + "name": "vitest:onAfterRetryTask", 1737 + "snapshot": {}, 1738 + "status": "pass", 1739 + }, 1740 + ], 1741 + }, 1742 + ], 1743 + "external stylesheet remains url dependent": [ 1744 + { 1745 + "entries": [ 1746 + { 1747 + "kind": "mark", 1748 + "location": "styles.test.ts:62", 1749 + "name": "button rendered with external stylesheet", 1750 + "snapshot": {}, 1751 + }, 1752 + { 1753 + "kind": "lifecycle", 1754 + "location": "styles.test.ts:55", 1755 + "name": "vitest:onAfterRetryTask", 1756 + "snapshot": {}, 1757 + "status": "pass", 1758 + }, 1759 + ], 1760 + }, 1761 + ], 1762 + "font files remain url dependent": [ 1763 + { 1764 + "entries": [ 1765 + { 1766 + "kind": "mark", 1767 + "location": "styles.test.ts:80", 1768 + "name": "button rendered with font-face url", 1769 + "snapshot": {}, 1770 + }, 1771 + { 1772 + "kind": "lifecycle", 1773 + "location": "styles.test.ts:66", 1774 + "name": "vitest:onAfterRetryTask", 1775 + "snapshot": {}, 1776 + "status": "pass", 1777 + }, 1778 + ], 1779 + }, 1780 + ], 1781 + "inline styles": [ 1782 + { 1783 + "entries": [ 1784 + { 1785 + "kind": "mark", 1786 + "location": "styles.test.ts:11", 1787 + "name": "button rendered with css", 1788 + "snapshot": {}, 1789 + }, 1790 + { 1791 + "kind": "lifecycle", 1792 + "location": "styles.test.ts:9", 1793 + "name": "vitest:onAfterRetryTask", 1794 + "snapshot": {}, 1795 + "status": "pass", 1796 + }, 1797 + ], 1798 + }, 1799 + ], 1800 + "same-origin link css is inlined": [ 1801 + { 1802 + "entries": [ 1803 + { 1804 + "kind": "mark", 1805 + "location": "styles.test.ts:41", 1806 + "name": "button rendered with same-origin linked css", 1807 + "snapshot": {}, 1808 + }, 1809 + { 1810 + "kind": "lifecycle", 1811 + "location": "styles.test.ts:32", 1812 + "name": "vitest:onAfterRetryTask", 1813 + "snapshot": {}, 1814 + "status": "pass", 1815 + }, 1816 + ], 1817 + }, 1818 + ], 1819 + "snapshot-time pseudo-state styles": [ 1820 + { 1821 + "entries": [ 1822 + { 1823 + "kind": "expect", 1824 + "location": "styles.test.ts:101", 1825 + "name": "expect.element().toHaveStyle", 1826 + "selector": "internal:role=button[name="First pseudo state"i]", 1827 + "snapshot": { 1828 + "selectorResolution": "matched", 1829 + }, 1830 + "status": "pass", 1831 + }, 1832 + { 1833 + "kind": "action", 1834 + "location": "styles.test.ts:104", 1835 + "name": "vitest:hover", 1836 + "selector": "internal:role=button[name="First pseudo state"i]", 1837 + "snapshot": { 1838 + "selectorResolution": "matched", 1839 + }, 1840 + "status": "pass", 1841 + }, 1842 + { 1843 + "kind": "action", 1844 + "location": "styles.test.ts:105", 1845 + "name": "vitest:click", 1846 + "selector": "internal:role=button[name="Second pseudo state"i]", 1847 + "snapshot": { 1848 + "selectorResolution": "matched", 1849 + }, 1850 + "status": "pass", 1851 + }, 1852 + { 1853 + "kind": "action", 1854 + "location": "styles.test.ts:106", 1855 + "name": "vitest:fill", 1856 + "selector": "internal:role=textbox[name="Focused pseudo state"i]", 1857 + "snapshot": { 1858 + "selectorResolution": "matched", 1859 + }, 1860 + "status": "pass", 1861 + }, 1862 + { 1863 + "kind": "action", 1864 + "location": "styles.test.ts:107", 1865 + "name": "vitest:fill", 1866 + "selector": "internal:role=textbox[name="Focus within pseudo state"i]", 1867 + "snapshot": { 1868 + "selectorResolution": "matched", 1869 + }, 1870 + "status": "pass", 1871 + }, 1872 + { 1873 + "kind": "lifecycle", 1874 + "location": "styles.test.ts:83", 1875 + "name": "vitest:onAfterRetryTask", 1876 + "snapshot": {}, 1877 + "status": "pass", 1878 + }, 1879 + ], 1880 + }, 1881 + ], 1882 + "style tag css is inlined": [ 1883 + { 1884 + "entries": [ 1885 + { 1886 + "kind": "mark", 1887 + "location": "styles.test.ts:29", 1888 + "name": "button rendered with style tag css", 1889 + "snapshot": {}, 1890 + }, 1891 + { 1892 + "kind": "lifecycle", 1893 + "location": "styles.test.ts:14", 1894 + "name": "vitest:onAfterRetryTask", 1895 + "snapshot": {}, 1896 + "status": "pass", 1897 + }, 1898 + ], 1899 + }, 1900 + ], 1901 + }, 1902 + "viewport.test.ts": { 1903 + "document scroll is restored from trace metadata": [ 1904 + { 1905 + "entries": [ 1906 + { 1907 + "kind": "mark", 1908 + "location": "viewport.test.ts:31", 1909 + "name": "document scrolled before mark", 1910 + "selector": "internal:role=button", 1911 + "snapshot": { 1912 + "selectorResolution": "matched", 1913 + }, 1914 + }, 1915 + { 1916 + "kind": "lifecycle", 1917 + "location": "viewport.test.ts:24", 1918 + "name": "vitest:onAfterRetryTask", 1919 + "snapshot": {}, 1920 + "status": "pass", 1921 + }, 1922 + ], 1923 + }, 1924 + ], 1925 + "overflow element scroll is stored in snapshot payload": [ 1926 + { 1927 + "entries": [ 1928 + { 1929 + "kind": "mark", 1930 + "location": "viewport.test.ts:47", 1931 + "name": "overflow container scrolled before mark", 1932 + "selector": "internal:testid=[data-testid="scroll-box"s]", 1933 + "snapshot": { 1934 + "selectorResolution": "matched", 1935 + }, 1936 + }, 1937 + { 1938 + "kind": "lifecycle", 1939 + "location": "viewport.test.ts:34", 1940 + "name": "vitest:onAfterRetryTask", 1941 + "snapshot": {}, 1942 + "status": "pass", 1943 + }, 1944 + ], 1945 + }, 1946 + ], 1947 + "viewport media query depends on replay viewport": [ 1948 + { 1949 + "entries": [ 1950 + { 1951 + "kind": "mark", 1952 + "location": "viewport.test.ts:21", 1953 + "name": "viewport sensitive layout rendered", 1954 + "snapshot": {}, 1955 + }, 1956 + { 1957 + "kind": "lifecycle", 1958 + "location": "viewport.test.ts:10", 1959 + "name": "vitest:onAfterRetryTask", 1960 + "snapshot": {}, 1961 + "status": "pass", 1962 + }, 1963 + ], 1964 + }, 1965 + ], 1966 + }, 1967 + } 1968 + `) 1969 + } 1970 + } 1971 + })
-2
test/browser/vitest.config.mts
··· 40 40 }, 41 41 browser: { 42 42 enabled: true, 43 - headless: false, 44 43 instances, 45 44 provider, 46 45 // isolate: false, ··· 76 75 alias: { 77 76 '#src': resolve(dir, './src'), 78 77 }, 79 - open: false, 80 78 diff: './custom-diff-config.ts', 81 79 outputFile: { 82 80 html: './html/index.html',
+10
test/ui/README.md
··· 1 + # test/ui 2 + 3 + ```sh 4 + # run e2e 5 + pnpm test 6 + 7 + # run fixture projects 8 + pnpm test-fixtures 9 + pnpm test-fixtures --root fixtures-trace 10 + ```
+4
test/ui/fixtures-trace/assets/trace-pixel.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> 2 + <rect width="24" height="24" fill="#2563eb"/> 3 + <circle cx="12" cy="12" r="6" fill="#dc2626"/> 4 + </svg>
+4
test/ui/fixtures-trace/assets/trace-style.css
··· 1 + .trace-linked-css { 2 + color: rgb(50, 100, 255); 3 + font-weight: 700; 4 + }
+79
test/ui/fixtures-trace/basic.test.ts
··· 1 + import { expect, onTestFinished, test } from 'vitest' 2 + import { commands, page } from 'vitest/browser' 3 + 4 + // tests for full snapshot/replay integration. 5 + // partly extracted from artifact metadata tests in 6 + // test/browser/fixtures/trace/*.test.ts 7 + 8 + test('simple', async () => { 9 + document.body.innerHTML = '<button>Simple</button>' 10 + await page.getByRole('button').mark('Render simple') 11 + }) 12 + 13 + test('pseudo-state', async () => { 14 + document.body.innerHTML = ` 15 + <style> 16 + .test-target { 17 + background: rgb(255, 200, 200); 18 + } 19 + .test-hover:hover, 20 + .test-focus:focus, 21 + .test-focus-within:focus-within, 22 + .test-active:active, 23 + .test-focus-visible:focus-visible, 24 + .test-none 25 + { 26 + background: rgb(253, 224, 71); 27 + } 28 + </style> 29 + <button class="test-target test-hover">Test hover 1</button> 30 + <hr /> 31 + <button class="test-target test-hover">Test hover 2</button> 32 + <hr /> 33 + <label style="display: block; padding: 8px;"> 34 + Test focus 35 + <input class="test-target test-focus" placeholder="focus-placeholder"> 36 + </label> 37 + <hr /> 38 + <label class="test-target test-focus-within" style="display: block; padding: 8px;"> 39 + Test focus-within 40 + <input placeholder="focus-within-placeholder"> 41 + </label> 42 + <hr /> 43 + <button class="test-target test-active">Test active</button> 44 + <hr /> 45 + <label style="display: block; padding: 8px;"> 46 + Test focus-visible 47 + <input class="test-target test-focus-visible" placeholder="focus-visible-placeholder"> 48 + </label> 49 + ` 50 + await page.getByRole('button', { name: 'Test hover 1' }).hover() 51 + await page.getByRole('button', { name: 'Test hover 2' }).click() 52 + await page.getByPlaceholder('focus-placeholder').click() 53 + await page.getByPlaceholder('focus-placeholder').fill('focus-done') 54 + await page.getByPlaceholder('focus-within-placeholder').fill('focus-within-done') 55 + await (commands as any).mousedown('.test-active') 56 + await page.getByRole('button', { name: 'Test active' }).mark('Test active') 57 + await page.getByPlaceholder('focus-visible-placeholder').fill('focus-visible-done') 58 + }) 59 + 60 + test('css-link', async () => { 61 + const link = document.createElement('link') 62 + link.rel = 'stylesheet' 63 + link.href = '/assets/trace-style.css' 64 + document.head.append(link) 65 + onTestFinished(() => { 66 + link.remove() 67 + }) 68 + document.body.innerHTML = '<button class="trace-linked-css">Linked CSS</button>' 69 + await expect.element(page.getByRole('button', { name: 'Linked CSS' })) 70 + .toHaveStyle({ color: 'rgb(50, 100, 255)' }) 71 + }) 72 + 73 + test('image', async () => { 74 + document.body.innerHTML = '<img src="/assets/trace-pixel.svg" alt="local trace asset" width="24" height="24">' 75 + await expect.element(page.getByAltText('local trace asset')) 76 + .not 77 + .toHaveProperty('naturalWidth', 0) 78 + await page.getByAltText('local trace asset').mark('Render image') 79 + })
+34
test/ui/fixtures-trace/scroll.test.ts
··· 1 + import { test } from 'vitest' 2 + import { page } from 'vitest/browser' 3 + 4 + test('scroll', async () => { 5 + const [width, height] = [300, 300] 6 + await page.viewport(width, height) 7 + document.body.innerHTML = ` 8 + <style> 9 + html, 10 + body { 11 + margin: 0; 12 + height: 100vh; 13 + width: 100vw; 14 + background: linear-gradient(-45deg, purple, yellow); 15 + } 16 + .marker { 17 + position: absolute; 18 + width: 100px; 19 + background: white; 20 + border: 1px solid black; 21 + text-align: center; 22 + } 23 + </style> 24 + <main style="height: 600px; width: 600px;"> 25 + <div class="marker" style="top: 0; left: 0;">(0, 0)</div> 26 + <div class="marker" style="top: 0; left: 300px;">(300, 0)</div> 27 + <div class="marker" style="top: 300px; left: 0;">(0, 300)</div> 28 + <div class="marker" style="top: 300px; left: 300px;">(300, 300)</div> 29 + </main> 30 + ` 31 + // scroll to make (300, 300) visible and (0, 0) not visible 32 + window.scrollTo(250, 200) 33 + await page.getByRole('button').mark('Render scroll') 34 + })
+42
test/ui/fixtures-trace/viewport.test.ts
··· 1 + import { test } from 'vitest' 2 + import { page } from 'vitest/browser' 3 + 4 + test('viewport', async () => { 5 + const [width, height] = [500, 300] 6 + await page.viewport(width, height) 7 + document.body.innerHTML = ` 8 + <style> 9 + html, 10 + body { 11 + margin: 0; 12 + height: 100vh; 13 + background: linear-gradient(-45deg, blue, orange); 14 + } 15 + 16 + .viewport-pass { 17 + display: none; 18 + } 19 + 20 + .viewport-fail { 21 + display: block; 22 + } 23 + 24 + @media (width: ${width}px) and (height: ${height}px) { 25 + .viewport-pass { 26 + display: block; 27 + } 28 + 29 + .viewport-fail { 30 + display: none; 31 + } 32 + } 33 + </style> 34 + <div class="viewport-pass"> 35 + PASS: Viewport is ${width}x${height} 36 + </div> 37 + <div class="viewport-fail"> 38 + FAIL: Viewport is not ${width}x${height} 39 + </div> 40 + ` 41 + await page.mark('Render viewport') 42 + })
+34
test/ui/fixtures-trace/vitest.config.ts
··· 1 + import type { BrowserCommand } from 'vitest/node' 2 + import { playwright } from '@vitest/browser-playwright' 3 + import { defineConfig } from 'vitest/config' 4 + 5 + // mousedown through custom command 6 + // https://github.com/vitest-dev/vitest/issues/8190 7 + const mousedownCommand: BrowserCommand<[selector: string]> = async (ctx, selector) => { 8 + await ctx.iframe.locator(selector).hover() 9 + await ctx.page.mouse.down() 10 + } 11 + 12 + export default defineConfig({ 13 + test: { 14 + ui: true, 15 + browser: { 16 + enabled: true, 17 + provider: playwright(), 18 + instances: [ 19 + { browser: 'chromium' }, 20 + ], 21 + headless: true, 22 + ui: false, 23 + traceView: { 24 + enabled: true, 25 + // enabled only on html reporter e2e 26 + // inlineImages: true, 27 + }, 28 + screenshotFailures: false, 29 + commands: { 30 + mousedown: mousedownCommand, 31 + }, 32 + }, 33 + }, 34 + })
+256
test/ui/test/trace.spec.ts
··· 1 + import type { Page } from '@playwright/test' 2 + import type { PreviewServer } from 'vite' 3 + import type { Vitest } from 'vitest/node' 4 + import assert from 'node:assert' 5 + import { Writable } from 'node:stream' 6 + import { expect, test } from '@playwright/test' 7 + import { preview } from 'vite' 8 + import { startVitest } from 'vitest/node' 9 + 10 + test.describe('ui', () => { 11 + let vitest: Vitest | undefined 12 + let baseURL: string 13 + 14 + test.beforeAll(async () => { 15 + // silence Vitest logs 16 + const stdout = new Writable({ write: (_, __, callback) => callback() }) 17 + const stderr = new Writable({ write: (_, __, callback) => callback() }) 18 + vitest = await startVitest( 19 + 'test', 20 + undefined, 21 + { 22 + root: './fixtures-trace', 23 + watch: true, 24 + ui: true, 25 + open: false, 26 + }, 27 + {}, 28 + { stdout, stderr }, 29 + ) 30 + const address = vitest.vite.httpServer?.address() 31 + assert(address && typeof address === 'object', 'Invalid server address') 32 + baseURL = `http://localhost:${address.port}/__vitest__/` 33 + }) 34 + 35 + test.afterAll(async () => { 36 + await vitest?.close() 37 + }) 38 + 39 + test.beforeEach(async ({ page }) => { 40 + await page.goto(baseURL) 41 + await testReady(page) 42 + }) 43 + 44 + test('basic', async ({ page }) => { 45 + await testBasic(page) 46 + }) 47 + 48 + test('viewport', async ({ page }) => { 49 + await testViewport(page) 50 + }) 51 + 52 + test('pseudo-state', async ({ page }) => { 53 + await testPseudoState(page) 54 + }) 55 + 56 + test('css-link', async ({ page }) => { 57 + await testCssLink(page) 58 + }) 59 + 60 + test('image', async ({ page }) => { 61 + await testImage(page) 62 + }) 63 + 64 + test('scroll', async ({ page }) => { 65 + await testScroll(page) 66 + }) 67 + }) 68 + 69 + test.describe('html reporter', () => { 70 + let previewServer: PreviewServer 71 + let baseURL: string 72 + 73 + test.beforeAll(async () => { 74 + // silence Vitest logs 75 + const stdout = new Writable({ write: (_, __, callback) => callback() }) 76 + const stderr = new Writable({ write: (_, __, callback) => callback() }) 77 + await startVitest( 78 + 'test', 79 + undefined, 80 + { 81 + root: './fixtures-trace', 82 + run: true, 83 + ui: false, 84 + reporters: 'html', 85 + browser: { 86 + traceView: { 87 + enabled: true, 88 + inlineImages: true, 89 + }, 90 + }, 91 + }, 92 + {}, 93 + { stdout, stderr }, 94 + ) 95 + previewServer = await preview({ 96 + root: './fixtures-trace', 97 + build: { outDir: 'html' }, 98 + }) 99 + const address = previewServer.httpServer?.address() 100 + assert(address && typeof address === 'object', 'Invalid server address') 101 + baseURL = `http://localhost:${address.port}/` 102 + }) 103 + 104 + test.afterAll(async () => { 105 + await previewServer.close() 106 + }) 107 + 108 + test.beforeEach(async ({ page }) => { 109 + await page.goto(baseURL) 110 + await testReady(page) 111 + }) 112 + 113 + test('basic', async ({ page }) => { 114 + await page.goto(baseURL) 115 + await testBasic(page) 116 + }) 117 + 118 + test('viewport', async ({ page }) => { 119 + await testViewport(page) 120 + }) 121 + 122 + test('pseudo-state', async ({ page }) => { 123 + await testPseudoState(page) 124 + }) 125 + 126 + test('css-link', async ({ page }) => { 127 + await testCssLink(page) 128 + }) 129 + 130 + test('image', async ({ page }) => { 131 + await testImage(page) 132 + }) 133 + 134 + test('scroll', async ({ page }) => { 135 + await testScroll(page) 136 + }) 137 + }) 138 + 139 + async function testReady(page: Page) { 140 + const count = 6 141 + await expect.soft(page.getByTestId('tests-entry')) 142 + .toContainText(`${count} Pass 0 Fail ${count} Total`) 143 + } 144 + 145 + async function openExplorerItem(page: Page, name: string) { 146 + await page.getByTestId('explorer-item').and(page.getByLabel(name, { exact: true })).click() 147 + } 148 + 149 + async function testBasic(page: Page) { 150 + // selecting test case opens trace viewer 151 + const traceView = page.getByTestId('trace-view') 152 + await expect(traceView).toBeHidden() 153 + await openExplorerItem(page, 'simple') 154 + await expect(traceView).toBeVisible() 155 + 156 + // selecting steps should open source code view 157 + const traceSteps = traceView.getByTestId('trace-step-name') 158 + await expect(page.getByTestId('btn-report')).toContainClass('tab-button-active') 159 + await traceSteps.getByText('Render simple').click() 160 + await expect(page.getByTestId('btn-code')).toContainClass('tab-button-active') 161 + 162 + // verify snapshot replay in iframe 163 + const traceFrame = traceView.frameLocator('iframe') 164 + await expect(traceFrame.getByRole('button', { name: 'Simple' })).toBeVisible() 165 + 166 + // verify selector highlight 167 + await expect(traceFrame.getByTestId('trace-view-highlight')).toBeVisible() 168 + } 169 + 170 + async function testViewport(page: Page) { 171 + await openExplorerItem(page, 'viewport') 172 + 173 + const traceView = page.getByTestId('trace-view') 174 + const traceSteps = traceView.getByTestId('trace-step-name') 175 + const traceFrame = traceView.frameLocator('iframe') 176 + await expect(traceView).toBeVisible() 177 + await traceSteps.getByText('Render viewport').click() 178 + await expect(traceFrame.locator('.viewport-pass')).toBeVisible() 179 + } 180 + 181 + async function testPseudoState(page: Page) { 182 + await openExplorerItem(page, 'pseudo-state') 183 + 184 + const traceView = page.getByTestId('trace-view') 185 + const traceSteps = traceView.getByTestId('trace-step-name') 186 + const traceFrame = traceView.frameLocator('iframe') 187 + 188 + await expect(traceView).toBeVisible() 189 + 190 + const pseudoOff = ['background-color', 'rgb(255, 200, 200)'] as const 191 + const pseudoOn = ['background-color', 'rgb(253, 224, 71)'] as const 192 + 193 + // trace view replays hover state at the time of snapshot 194 + await traceSteps.nth(0).click() 195 + await expect(traceFrame.getByText('Test hover 1')).toHaveCSS(...pseudoOn) 196 + await expect(traceFrame.getByText('Test hover 2')).toHaveCSS(...pseudoOff) 197 + await traceSteps.nth(1).click() 198 + await expect(traceFrame.getByText('Test hover 1')).toHaveCSS(...pseudoOff) 199 + await expect(traceFrame.getByText('Test hover 2')).toHaveCSS(...pseudoOn) 200 + 201 + // trace view reacts to interaction during the replay 202 + // this is verified manually but seems flaky on playwright 203 + // maybe because it clicks inside shadow-dom inside iframe 204 + await expect(async () => { 205 + await traceFrame.getByText('Test hover 1').click() 206 + await expect(traceFrame.getByText('Test hover 1')).toHaveCSS(...pseudoOn) 207 + }).toPass() 208 + 209 + // focus 210 + await expect(traceFrame.locator('.test-focus')).toHaveCSS(...pseudoOff) 211 + await traceSteps.nth(3).click() 212 + await expect(traceFrame.locator('.test-focus')).toHaveCSS(...pseudoOn) 213 + 214 + // focus-within 215 + await expect(traceFrame.locator('.test-focus-within')).toHaveCSS(...pseudoOff) 216 + await traceSteps.nth(4).click() 217 + await expect(traceFrame.locator('.test-focus-within')).toHaveCSS(...pseudoOn) 218 + 219 + // active 220 + await expect(traceFrame.locator('.test-active')).toHaveCSS(...pseudoOff) 221 + await traceSteps.nth(5).click() 222 + await expect(traceFrame.locator('.test-active')).toHaveCSS(...pseudoOn) 223 + 224 + // focus-visible 225 + await expect(traceFrame.locator('.test-focus-visible')).toHaveCSS(...pseudoOff) 226 + await traceSteps.nth(7).click() 227 + await expect(traceFrame.locator('.test-focus-visible')).toHaveCSS(...pseudoOn) 228 + } 229 + 230 + async function testCssLink(page: Page) { 231 + await openExplorerItem(page, 'css-link') 232 + 233 + const traceView = page.getByTestId('trace-view') 234 + const traceFrame = traceView.frameLocator('iframe') 235 + await expect(traceView).toBeVisible() 236 + await expect(traceFrame.getByRole('button', { name: 'Linked CSS' })).toHaveCSS('color', 'rgb(50, 100, 255)') 237 + } 238 + 239 + async function testImage(page: Page) { 240 + await openExplorerItem(page, 'image') 241 + 242 + const traceView = page.getByTestId('trace-view') 243 + const traceFrame = traceView.frameLocator('iframe') 244 + await expect(traceView).toBeVisible() 245 + await expect(traceFrame.getByAltText('local trace asset')).not.toHaveJSProperty('naturalWidth', 0) 246 + } 247 + 248 + async function testScroll(page: Page) { 249 + await openExplorerItem(page, 'scroll') 250 + 251 + const traceView = page.getByTestId('trace-view') 252 + const traceFrame = traceView.frameLocator('iframe') 253 + await expect(traceView).toBeVisible() 254 + await expect(traceFrame.getByText('(0, 0)')).not.toBeInViewport() 255 + await expect(traceFrame.getByText('(300, 300)')).toBeInViewport() 256 + }
+2 -1
test/ui/vitest.config.ts
··· 1 1 import { resolve } from 'node:path' 2 2 import { playwright } from '@vitest/browser-playwright' 3 - import { defineConfig } from 'vitest/config' 3 + import { defaultExclude, defineConfig } from 'vitest/config' 4 4 5 5 export default defineConfig({ 6 6 test: { 7 + exclude: ['**/fixtures-trace/**', ...defaultExclude], 7 8 coverage: { 8 9 reportOnFailure: true, 9 10 },