[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: add `page/locator.mark` API to enhance playwright trace (#9652)

authored by

Hiroshi Ogawa and committed by
GitHub
(Mar 3, 2026, 10:53 AM +0100) d0ee546f 4072d013

+995 -52
+42
docs/api/browser/context.md
··· 80 80 }> 81 81 screenshot(options?: ScreenshotOptions): Promise<string> 82 82 /** 83 + * Add a trace marker when browser tracing is enabled. 84 + */ 85 + mark(name: string, options?: { stack?: string }): Promise<void> 86 + /** 87 + * Group multiple operations under a trace marker when browser tracing is enabled. 88 + */ 89 + mark<T>(name: string, body: () => T | Promise<T>, options?: { stack?: string }): Promise<T> 90 + /** 83 91 * Extend default `page` object with custom methods. 84 92 */ 85 93 extend(methods: Partial<BrowserPage>): BrowserPage ··· 114 122 ::: warning WARNING <Version>3.2.0</Version> 115 123 Note that `screenshot` will always return a base64 string if `save` is set to `false`. 116 124 The `path` is also ignored in that case. 125 + ::: 126 + 127 + ### mark 128 + 129 + ```ts 130 + function mark(name: string, options?: { stack?: string }): Promise<void> 131 + function mark<T>( 132 + name: string, 133 + body: () => T | Promise<T>, 134 + options?: { stack?: string }, 135 + ): Promise<T> 136 + ``` 137 + 138 + Adds a named marker to the trace timeline for the current test. 139 + 140 + Pass `options.stack` to override the callsite location in trace metadata. This is useful for wrapper libraries that need to preserve the end-user source location. 141 + 142 + If you pass a callback, Vitest creates a trace group with this name, runs the callback, and closes the group automatically. 143 + 144 + ```ts 145 + import { page } from 'vitest/browser' 146 + 147 + await page.mark('before submit') 148 + await page.getByRole('button', { name: 'Submit' }).click() 149 + await page.mark('after submit') 150 + 151 + await page.mark('submit flow', async () => { 152 + await page.getByRole('textbox', { name: 'Email' }).fill('john@example.com') 153 + await page.getByRole('button', { name: 'Submit' }).click() 154 + }) 155 + ``` 156 + 157 + ::: tip 158 + This method is useful only when [`browser.trace`](/config/browser/trace) is enabled. 117 159 ::: 118 160 119 161 ### frameLocator
+24
docs/api/browser/locators.md
··· 820 820 The `path` is also ignored in that case. 821 821 ::: 822 822 823 + ### mark 824 + 825 + ```ts 826 + function mark(name: string, options?: { stack?: string }): Promise<void> 827 + ``` 828 + 829 + Adds a named marker to the trace timeline and uses the current locator as marker context. 830 + 831 + Pass `options.stack` to override the callsite location in trace metadata. This is useful for wrapper libraries that need to preserve the end-user source location. 832 + 833 + ```ts 834 + import { page } from 'vitest/browser' 835 + 836 + const submitButton = page.getByRole('button', { name: 'Submit' }) 837 + 838 + await submitButton.mark('before submit') 839 + await submitButton.click() 840 + await submitButton.mark('after submit') 841 + ``` 842 + 843 + ::: tip 844 + This method is useful only when [`browser.trace`](/config/browser/trace) is enabled. 845 + ::: 846 + 823 847 ### query 824 848 825 849 ```ts
+59 -2
docs/guide/browser/trace-view.md
··· 57 57 58 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 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 + 60 102 ## Preview 61 103 62 104 To open the trace file, you can use the Playwright Trace Viewer. Run the following command in your terminal: ··· 69 111 70 112 Alternatively, you can open the Trace Viewer in your browser at https://trace.playwright.dev and upload the trace file there. 71 113 72 - ## Limitations 114 + ## Source Location 73 115 74 - At the moment, Vitest cannot populate the "Sources" tab in the Trace Viewer. This means that while you can see the actions and screenshots captured during the test, you won't be able to view the source code of your tests directly within the Trace Viewer. You will need to refer back to your code editor to see the test implementation. 116 + 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: 117 + 118 + - `expect.element(...)` assertions 119 + - Interactive actions like `click`, `fill`, `type`, `hover`, `selectOptions`, `upload`, `dragAndDrop`, `tab`, `keyboard`, `wheel`, and screenshots 120 + 121 + 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. 122 + 123 + Keep in mind that plain assertions like `expect(value).toBe(...)` run in Node, not the browser, so they won't show up in the trace. 124 + 125 + 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. 126 + 127 + ::: warning 128 + 129 + Currently a source view of a trace can be only displayed properly when viewing it on the machine generated a trace with `playwright show-trace` CLI. This is expected to be fixed soon (see https://github.com/microsoft/playwright/pull/39307). 130 + 131 + :::
+2
examples/lit/.gitignore
··· 1 + __traces__ 2 + __screenshots__
+8 -2
examples/lit/test/basic.test.ts
··· 4 4 import '../src/my-button.js' 5 5 6 6 describe('Button with increment', async () => { 7 - beforeEach(() => { 8 - document.body.innerHTML = '<my-button name="World"></my-button>' 7 + beforeEach(async () => { 8 + await page.mark('render', async () => { 9 + document.body.innerHTML = '<my-button name="World"></my-button>' 10 + await page.getByRole('button').mark('render button') 11 + }) 9 12 }) 10 13 11 14 it('should increment the count on each click', async () => { 12 15 await page.getByRole('button').click() 13 16 14 17 await expect.element(page.getByRole('button')).toHaveTextContent('2') 18 + if (import.meta.env.VITE_FAIL_TEST) { 19 + await expect.element(page.getByRole('button'), { timeout: 3000 }).toHaveTextContent('3') 20 + } 15 21 }) 16 22 17 23 it('should show name props', async () => {
+1
examples/lit/tsconfig.json
··· 5 5 "experimentalDecorators": true, 6 6 "module": "node16", 7 7 "moduleResolution": "Node16", 8 + "types": ["vite/client"], 8 9 "verbatimModuleSyntax": true 9 10 } 10 11 }
+2 -2
packages/browser-playwright/src/commands/clear.ts
··· 1 1 import type { UserEvent } from 'vitest/browser' 2 2 import type { UserEventCommand } from './utils' 3 + import { getDescribedLocator } from './utils' 3 4 4 5 export const clear: UserEventCommand<UserEvent['clear']> = async ( 5 6 context, 6 7 selector, 7 8 ) => { 8 - const { iframe } = context 9 - const element = iframe.locator(selector) 9 + const element = getDescribedLocator(context, selector) 10 10 await element.clear() 11 11 }
+4 -6
packages/browser-playwright/src/commands/click.ts
··· 1 1 import type { UserEvent } from 'vitest/browser' 2 2 import type { UserEventCommand } from './utils' 3 + import { getDescribedLocator } from './utils' 3 4 4 5 export const click: UserEventCommand<UserEvent['click']> = async ( 5 6 context, 6 7 selector, 7 8 options = {}, 8 9 ) => { 9 - const tester = context.iframe 10 - await tester.locator(selector).click(options) 10 + await getDescribedLocator(context, selector).click(options) 11 11 } 12 12 13 13 export const dblClick: UserEventCommand<UserEvent['dblClick']> = async ( ··· 15 15 selector, 16 16 options = {}, 17 17 ) => { 18 - const tester = context.iframe 19 - await tester.locator(selector).dblclick(options) 18 + await getDescribedLocator(context, selector).dblclick(options) 20 19 } 21 20 22 21 export const tripleClick: UserEventCommand<UserEvent['tripleClick']> = async ( ··· 24 23 selector, 25 24 options = {}, 26 25 ) => { 27 - const tester = context.iframe 28 - await tester.locator(selector).click({ 26 + await getDescribedLocator(context, selector).click({ 29 27 ...options, 30 28 clickCount: 3, 31 29 })
+2 -2
packages/browser-playwright/src/commands/fill.ts
··· 1 1 import type { UserEvent } from 'vitest/browser' 2 2 import type { UserEventCommand } from './utils' 3 + import { getDescribedLocator } from './utils' 3 4 4 5 export const fill: UserEventCommand<UserEvent['fill']> = async ( 5 6 context, ··· 7 8 text, 8 9 options = {}, 9 10 ) => { 10 - const { iframe } = context 11 - const element = iframe.locator(selector) 11 + const element = getDescribedLocator(context, selector) 12 12 await element.fill(text, options) 13 13 }
+2 -1
packages/browser-playwright/src/commands/hover.ts
··· 1 1 import type { UserEvent } from 'vitest/browser' 2 2 import type { UserEventCommand } from './utils' 3 + import { getDescribedLocator } from './utils' 3 4 4 5 export const hover: UserEventCommand<UserEvent['hover']> = async ( 5 6 context, 6 7 selector, 7 8 options = {}, 8 9 ) => { 9 - await context.iframe.locator(selector).hover(options) 10 + await getDescribedLocator(context, selector).hover(options) 10 11 }
+6
packages/browser-playwright/src/commands/index.ts
··· 10 10 import { 11 11 annotateTraces, 12 12 deleteTracing, 13 + groupTraceEnd, 14 + groupTraceStart, 15 + markTrace, 13 16 startChunkTrace, 14 17 startTracing, 15 18 stopChunkTrace, ··· 39 42 __vitest_startTracing: startTracing as typeof startTracing, 40 43 __vitest_stopChunkTrace: stopChunkTrace as typeof stopChunkTrace, 41 44 __vitest_annotateTraces: annotateTraces as typeof annotateTraces, 45 + __vitest_markTrace: markTrace as typeof markTrace, 46 + __vitest_groupTraceStart: groupTraceStart as typeof groupTraceStart, 47 + __vitest_groupTraceEnd: groupTraceEnd as typeof groupTraceEnd, 42 48 }
+4 -3
packages/browser-playwright/src/commands/screenshot.ts
··· 3 3 import { mkdir } from 'node:fs/promises' 4 4 import { resolveScreenshotPath } from '@vitest/browser' 5 5 import { dirname, normalize } from 'pathe' 6 + import { getDescribedLocator } from './utils' 6 7 7 8 interface ScreenshotCommandOptions extends Omit<ScreenshotOptions, 'element' | 'mask'> { 8 9 element?: string ··· 41 42 await mkdir(dirname(savePath), { recursive: true }) 42 43 } 43 44 44 - const mask = options.mask?.map(selector => context.iframe.locator(selector)) 45 + const mask = options.mask?.map(selector => getDescribedLocator(context, selector)) 45 46 46 47 if (options.element) { 47 48 const { element: selector, ...config } = options 48 - const element = context.iframe.locator(selector) 49 + const element = getDescribedLocator(context, selector) 49 50 const buffer = await element.screenshot({ 50 51 ...config, 51 52 mask, ··· 54 55 return { buffer, path } 55 56 } 56 57 57 - const buffer = await context.iframe.locator('body').screenshot({ 58 + const buffer = await getDescribedLocator(context, 'body').screenshot({ 58 59 ...options, 59 60 mask, 60 61 path: savePath,
+3 -3
packages/browser-playwright/src/commands/select.ts
··· 1 1 import type { ElementHandle } from 'playwright' 2 2 import type { UserEvent } from 'vitest/browser' 3 3 import type { UserEventCommand } from './utils' 4 + import { getDescribedLocator } from './utils' 4 5 5 6 export const selectOptions: UserEventCommand<UserEvent['selectOptions']> = async ( 6 7 context, ··· 9 10 options = {}, 10 11 ) => { 11 12 const value = userValues as any as (string | { element: string })[] 12 - const { iframe } = context 13 - const selectElement = iframe.locator(selector) 13 + const selectElement = getDescribedLocator(context, selector) 14 14 15 15 const values = await Promise.all(value.map(async (v) => { 16 16 if (typeof v === 'string') { 17 17 return v 18 18 } 19 - const elementHandler = await iframe.locator(v.element).elementHandle() 19 + const elementHandler = await getDescribedLocator(context, v.element).elementHandle() 20 20 if (!elementHandler) { 21 21 throw new Error(`Element not found: ${v.element}`) 22 22 }
+80 -2
packages/browser-playwright/src/commands/trace.ts
··· 1 + import type { ParsedStack } from 'vitest' 1 2 import type { BrowserCommand, BrowserCommandContext, BrowserProvider } from 'vitest/node' 2 3 import type { PlaywrightBrowserProvider } from '../playwright' 3 4 import { unlink } from 'node:fs/promises' 4 5 import { basename, dirname, relative, resolve } from 'pathe' 6 + import { getDescribedLocator } from './utils' 5 7 6 8 export const startTracing: BrowserCommand<[]> = async ({ context, project, provider, sessionId }) => { 7 9 if (isPlaywrightProvider(provider)) { ··· 14 16 await context.tracing.start({ 15 17 screenshots: options.screenshots ?? true, 16 18 snapshots: options.snapshots ?? true, 17 - // currently, PW shows sources in private methods 18 - sources: false, 19 + sources: options.sources ?? true, 19 20 }).catch(() => { 20 21 provider.tracingContexts.delete(sessionId) 21 22 }) ··· 55 56 return { tracePath: path } 56 57 } 57 58 throw new TypeError(`The ${context.provider.name} provider does not support tracing.`) 59 + } 60 + 61 + export const markTrace: BrowserCommand<[payload: { name: string; selector?: string; stack?: string }]> = async ( 62 + context, 63 + payload, 64 + ) => { 65 + if (isPlaywrightProvider(context.provider)) { 66 + // skip if tracing is not active 67 + // this is only safe guard and this isn't expected to happen since 68 + // runner already checks if tracing is active before sending this command 69 + if (!context.provider.tracingContexts.has(context.sessionId)) { 70 + return 71 + } 72 + const { name, selector, stack } = payload 73 + const location = parseLocation(context, stack) 74 + // mark trace via group/groupEnd with dummy calls to force snapshot. 75 + // https://github.com/microsoft/playwright/issues/39308 76 + await context.context.tracing.group(name, { location }) 77 + try { 78 + if (selector) { 79 + const locator = getDescribedLocator(context, selector) as any 80 + if (typeof locator._expect === 'function') { 81 + await locator._expect('to.be.attached', { 82 + isNot: false, 83 + timeout: 1, // don't wait when element doesn't exist 84 + }) 85 + } 86 + else { 87 + await context.page.evaluate(() => 0) 88 + } 89 + } 90 + else { 91 + await context.page.evaluate(() => 0) 92 + } 93 + } 94 + catch {} 95 + await context.context.tracing.groupEnd() 96 + return 97 + } 98 + throw new TypeError(`The ${context.provider.name} provider does not support tracing.`) 99 + } 100 + 101 + export const groupTraceStart: BrowserCommand<[payload: { name: string; stack?: string }]> = async ( 102 + context, 103 + payload, 104 + ) => { 105 + if (isPlaywrightProvider(context.provider)) { 106 + if (!context.provider.tracingContexts.has(context.sessionId)) { 107 + return 108 + } 109 + const { name, stack } = payload 110 + const location = parseLocation(context, stack) 111 + await context.context.tracing.group(name, { location }) 112 + return 113 + } 114 + throw new TypeError(`The ${context.provider.name} provider does not support tracing.`) 115 + } 116 + 117 + export const groupTraceEnd: BrowserCommand<[]> = async ( 118 + context, 119 + ) => { 120 + if (isPlaywrightProvider(context.provider)) { 121 + if (!context.provider.tracingContexts.has(context.sessionId)) { 122 + return 123 + } 124 + await context.context.tracing.groupEnd() 125 + return 126 + } 127 + throw new TypeError(`The ${context.provider.name} provider does not support tracing.`) 128 + } 129 + 130 + function parseLocation(context: BrowserCommandContext, stack?: string): ParsedStack | undefined { 131 + if (!stack) { 132 + return 133 + } 134 + const parsedStacks = context.project.browser!.parseStacktrace(stack) 135 + return parsedStacks[0] 58 136 } 59 137 60 138 function resolveTracesPath({ testPath, project }: BrowserCommandContext, name: string) {
+2 -2
packages/browser-playwright/src/commands/type.ts
··· 1 1 import type { UserEvent } from 'vitest/browser' 2 2 import type { UserEventCommand } from './utils' 3 3 import { keyboardImplementation } from './keyboard' 4 + import { getDescribedLocator } from './utils' 4 5 5 6 export const type: UserEventCommand<UserEvent['type']> = async ( 6 7 context, ··· 11 12 const { skipClick = false, skipAutoClose = false } = options 12 13 const unreleased = new Set(Reflect.get(options, 'unreleased') as string[] ?? []) 13 14 14 - const { iframe } = context 15 - const element = iframe.locator(selector) 15 + const element = getDescribedLocator(context, selector) 16 16 17 17 if (!skipClick) { 18 18 await element.focus()
+2 -2
packages/browser-playwright/src/commands/upload.ts
··· 1 1 import type { UserEventUploadOptions } from 'vitest/browser' 2 2 import type { UserEventCommand } from './utils' 3 3 import { resolve } from 'pathe' 4 + import { getDescribedLocator } from './utils' 4 5 5 6 export const upload: UserEventCommand<(element: string, files: Array<string | { 6 7 name: string ··· 18 19 } 19 20 const root = context.project.config.root 20 21 21 - const { iframe } = context 22 22 const playwrightFiles = files.map((file) => { 23 23 if (typeof file === 'string') { 24 24 return resolve(root, file) ··· 29 29 buffer: Buffer.from(file.base64, 'base64'), 30 30 } 31 31 }) 32 - await iframe.locator(selector).setInputFiles(playwrightFiles as string[], options) 32 + await getDescribedLocator(context, selector).setInputFiles(playwrightFiles as string[], options) 33 33 }
+16 -1
packages/browser-playwright/src/commands/utils.ts
··· 1 1 import type { Locator } from 'vitest/browser' 2 - import type { BrowserCommand } from 'vitest/node' 2 + import type { BrowserCommand, BrowserCommandContext } from 'vitest/node' 3 + import { asLocator } from '@vitest/browser' 3 4 4 5 export type UserEventCommand<T extends (...args: any) => any> = BrowserCommand< 5 6 ConvertUserEventParameters<Parameters<T>> ··· 15 16 ): BrowserCommand<T> { 16 17 return fn 17 18 } 19 + 20 + // strip iframe locator part from the trace description e.g. 21 + // - locator('[data-vitest="true"]').contentFrame().getByRole('button') 22 + // ⇓ 23 + // - getByRole('button') 24 + export function getDescribedLocator( 25 + context: BrowserCommandContext, 26 + selector: string, 27 + ): ReturnType<BrowserCommandContext['iframe']['locator']> { 28 + const locator = context.iframe.locator(selector) 29 + return typeof locator.describe === 'function' 30 + ? locator.describe(asLocator('javascript', selector)) 31 + : locator 32 + }
+24
packages/browser/context.d.ts
··· 43 43 save?: boolean 44 44 } 45 45 46 + export interface MarkOptions { 47 + /** 48 + * Optional stack string used to resolve marker location. 49 + * Useful for wrapper libraries that need to forward the end-user callsite. 50 + */ 51 + stack?: string 52 + } 53 + 46 54 interface StandardScreenshotComparators { 47 55 pixelmatch: { 48 56 /** ··· 654 662 screenshot(options?: LocatorScreenshotOptions): Promise<string> 655 663 656 664 /** 665 + * Add a trace marker for this locator when browser tracing is enabled. 666 + * @see {@link https://vitest.dev/api/browser/locators#mark} 667 + */ 668 + mark(name: string, options?: MarkOptions): Promise<void> 669 + 670 + /** 657 671 * Returns an element matching the selector. 658 672 * 659 673 * - If multiple elements match the selector, an error is thrown. ··· 816 830 path: string 817 831 base64: string 818 832 }> 833 + /** 834 + * Add a trace marker when browser tracing is enabled. 835 + * @see {@link https://vitest.dev/api/browser/context#mark} 836 + */ 837 + mark(name: string, options?: MarkOptions): Promise<void> 838 + /** 839 + * Group multiple operations under a trace marker when browser tracing is enabled. 840 + * @see {@link https://vitest.dev/api/browser/context#mark} 841 + */ 842 + mark<T>(name: string, body: () => T | Promise<T>, options?: MarkOptions): Promise<T> 819 843 /** 820 844 * Extend default `page` object with custom methods. 821 845 */
+45
packages/browser/src/client/tester/context.ts
··· 8 8 BrowserPage, 9 9 Locator, 10 10 LocatorSelectors, 11 + MarkOptions, 11 12 UserEvent, 12 13 UserEventWheelOptions, 13 14 } from 'vitest/browser' ··· 343 344 element, 344 345 } as any /** TODO */), 345 346 ], 347 + error, 348 + )) 349 + }, 350 + mark<T>( 351 + name: string, 352 + bodyOrOptions?: MarkOptions | (() => T | Promise<T>), 353 + options?: MarkOptions, 354 + ): any { 355 + const currentTest = getWorkerState().current 356 + const hasActiveTrace = !!currentTest && getBrowserState().activeTraceTaskIds.has(currentTest.id) 357 + 358 + if (typeof bodyOrOptions === 'function') { 359 + return ensureAwaited(async (error) => { 360 + if (hasActiveTrace) { 361 + await triggerCommand( 362 + '__vitest_groupTraceStart', 363 + [{ 364 + name, 365 + stack: options?.stack ?? error?.stack, 366 + }], 367 + error, 368 + ) 369 + } 370 + try { 371 + return await bodyOrOptions() 372 + } 373 + finally { 374 + if (hasActiveTrace) { 375 + await triggerCommand('__vitest_groupTraceEnd', [], error) 376 + } 377 + } 378 + }) 379 + } 380 + 381 + if (!hasActiveTrace) { 382 + return Promise.resolve() 383 + } 384 + 385 + return ensureAwaited(error => triggerCommand( 386 + '__vitest_markTrace', 387 + [{ 388 + name, 389 + stack: bodyOrOptions?.stack ?? error?.stack, 390 + }], 346 391 error, 347 392 )) 348 393 },
+26 -1
packages/browser/src/client/tester/expect-element.ts
··· 1 - import type { ExpectPollOptions, PromisifyDomAssertion } from 'vitest' 1 + import type { Assertion, ExpectPollOptions, PromisifyDomAssertion } from 'vitest' 2 2 import type { Locator } from 'vitest/browser' 3 3 import { chai, expect } from 'vitest' 4 4 import { getType } from 'vitest/internal/browser' 5 + import { getBrowserState, getWorkerState } from '../utils' 5 6 import { matchers } from './expect' 6 7 import { processTimeoutOptions } from './tester-utils' 7 8 ··· 53 54 }, processTimeoutOptions(options)) 54 55 55 56 chai.util.flag(expectElement, '_poll.element', true) 57 + 58 + // ask `expect.poll` to invoke trace after the assertion 59 + const currentTest = getWorkerState().current 60 + if (currentTest && getBrowserState().activeTraceTaskIds.has(currentTest.id)) { 61 + const sourceError = new Error('__vitest_mark_trace__') 62 + chai.util.flag(expectElement, '_poll.onSettled', async (meta: { assertion: Assertion; status: 'pass' | 'fail' }) => { 63 + const isNot = chai.util.flag(meta.assertion, 'negate') 64 + const name = chai.util.flag(meta.assertion, '_name') || '<unknown>' 65 + const baseName = `expect.element().${isNot ? 'not.' : ''}${name}` 66 + const traceName = meta.status === 'fail' ? `${baseName} [ERROR]` : baseName 67 + const selector = !elementOrLocator || elementOrLocator instanceof Element 68 + ? undefined 69 + : elementOrLocator.selector 70 + await getBrowserState().commands.triggerCommand( 71 + '__vitest_markTrace', 72 + [{ 73 + name: traceName, 74 + selector, 75 + stack: sourceError.stack, 76 + }], 77 + sourceError, 78 + ) 79 + }) 80 + } 56 81 57 82 return expectElement 58 83 }
+18 -1
packages/browser/src/client/tester/locators/index.ts
··· 3 3 LocatorByRoleOptions, 4 4 LocatorOptions, 5 5 LocatorScreenshotOptions, 6 + MarkOptions, 6 7 SelectorOptions, 7 8 UserEventClearOptions, 8 9 UserEventClickOptions, ··· 26 27 } from 'ivya' 27 28 import { page, server, utils } from 'vitest/browser' 28 29 import { __INTERNAL, getSafeTimers } from 'vitest/internal/browser' 29 - import { ensureAwaited, getBrowserState } from '../../utils' 30 + import { ensureAwaited, getBrowserState, getWorkerState } from '../../utils' 30 31 import { escapeForTextSelector, isLocator, processTimeoutOptions, resolveUserEventWheelOptions } from '../tester-utils' 31 32 32 33 export { ensureAwaited } from '../../utils' ··· 199 200 ...options, 200 201 element: this, 201 202 }) 203 + } 204 + 205 + public mark(name: string, options?: MarkOptions): Promise<void> { 206 + const currentTest = getWorkerState().current 207 + if (!currentTest || !getBrowserState().activeTraceTaskIds.has(currentTest.id)) { 208 + return Promise.resolve() 209 + } 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 + )) 202 219 } 203 220 204 221 protected abstract locator(selector: string): Locator
+11 -15
packages/browser/src/client/tester/runner.ts
··· 81 81 await super.onBeforeTryTask?.(...args) 82 82 const trace = this.config.browser.trace 83 83 const test = args[0] 84 - if (trace === 'off') { 85 - return 86 - } 87 84 const { retry, repeats } = args[1] 88 - if (trace === 'on-all-retries' && retry === 0) { 89 - return 90 - } 91 - if (trace === 'on-first-retry' && retry !== 1) { 85 + const shouldTrace = trace !== 'off' 86 + && !(trace === 'on-all-retries' && retry === 0) 87 + && !(trace === 'on-first-retry' && retry !== 1) 88 + if (!shouldTrace) { 89 + getBrowserState().activeTraceTaskIds.delete(test.id) 92 90 return 93 91 } 92 + getBrowserState().activeTraceTaskIds.add(test.id) 94 93 let title = getTestName(test) 95 94 if (retry) { 96 95 title += ` (retry x${retry})` ··· 107 106 } 108 107 109 108 onAfterRetryTask = async (test: Test, { retry, repeats }: { retry: number; repeats: number }) => { 110 - const trace = this.config.browser.trace 111 - if (trace === 'off') { 109 + if (!getBrowserState().activeTraceTaskIds.has(test.id)) { 112 110 return 113 111 } 114 - if (trace === 'on-all-retries' && retry === 0) { 115 - return 116 - } 117 - if (trace === 'on-first-retry' && retry !== 1) { 118 - return 119 - } 112 + await this.commands.triggerCommand('__vitest_markTrace', [{ 113 + name: `onAfterRetryTask [${test.result?.state}]`, 114 + stack: test.result?.errors?.[0].stack, 115 + }]) 120 116 const name = getTraceName(test, retry, repeats) 121 117 if (!this.traces.has(test.id)) { 122 118 this.traces.set(test.id, [])
+52 -3
packages/browser/src/client/tester/tester-utils.ts
··· 95 95 return parent 96 96 } 97 97 98 + const ACTION_TRACE_COMMANDS = new Set([ 99 + '__vitest_click', 100 + '__vitest_dblClick', 101 + '__vitest_tripleClick', 102 + '__vitest_wheel', 103 + '__vitest_type', 104 + '__vitest_clear', 105 + '__vitest_fill', 106 + '__vitest_selectOptions', 107 + '__vitest_dragAndDrop', 108 + '__vitest_hover', 109 + '__vitest_upload', 110 + '__vitest_tab', 111 + '__vitest_keyboard', 112 + '__vitest_takeScreenshot', 113 + ]) 114 + 98 115 export class CommandsManager { 99 116 private _listeners: ((command: string, args: any[]) => void)[] = [] 100 117 ··· 114 131 const { sessionId, traces } = getBrowserState() 115 132 const filepath = state.filepath || state.current?.file?.filepath 116 133 args = args.filter(arg => arg !== undefined) // remove optional fields 134 + 135 + const actionTraceGroupName = ACTION_TRACE_COMMANDS.has(command) ? command : undefined 136 + const currentTest = getWorkerState().current 137 + const shouldMarkTrace = actionTraceGroupName 138 + && !!currentTest 139 + && getBrowserState().activeTraceTaskIds.has(currentTest.id) 140 + 117 141 if (this._listeners.length) { 118 142 await Promise.all(this._listeners.map(listener => listener(command, args))) 119 143 } ··· 125 149 'code.file.path': filepath, 126 150 }, 127 151 }, 128 - () => 129 - rpc.triggerCommand<T>(sessionId, command, filepath, args).catch((err) => { 152 + async () => { 153 + if (shouldMarkTrace) { 154 + await rpc.triggerCommand<void>( 155 + sessionId, 156 + '__vitest_groupTraceStart', 157 + filepath, 158 + [{ 159 + name: actionTraceGroupName, 160 + stack: clientError.stack, 161 + }], 162 + ) 163 + } 164 + try { 165 + return await rpc.triggerCommand<T>(sessionId, command, filepath, args) 166 + } 167 + catch (err: any) { 130 168 // rethrow an error to keep the stack trace in browser 131 169 clientError.message = err.message 132 170 clientError.name = err.name 133 171 clientError.stack = clientError.stack?.replace(clientError.message, err.message) 134 172 throw clientError 135 - }), 173 + } 174 + finally { 175 + if (shouldMarkTrace) { 176 + await rpc.triggerCommand<void>( 177 + sessionId, 178 + '__vitest_groupTraceEnd', 179 + filepath, 180 + [], 181 + ) 182 + } 183 + } 184 + }, 136 185 ) 137 186 } 138 187 }
+1
packages/browser/src/client/tester/tester.ts
··· 108 108 109 109 const commands = new CommandsManager() 110 110 getBrowserState().commands = commands 111 + getBrowserState().activeTraceTaskIds = new Set() 111 112 getBrowserState().iframeId = iframeId 112 113 113 114 let contextSwitched = false
+1
packages/browser/src/client/utils.ts
··· 83 83 method: 'run' | 'collect' 84 84 orchestrator?: IframeOrchestrator 85 85 commands: CommandsManager 86 + activeTraceTaskIds: Set<string> 86 87 traces: Traces 87 88 cleanups: Array<() => unknown> 88 89 cdp?: {
+4
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 10 10 11 export default { 11 12 readFile: readFile as typeof readFile, 12 13 removeFile: removeFile as typeof removeFile, 13 14 writeFile: writeFile as typeof writeFile, 14 15 // private commands 16 + __vitest_markTrace: _markTrace as typeof _markTrace, 17 + __vitest_groupTraceStart: _groupTraceStart as typeof _groupTraceStart, 18 + __vitest_groupTraceEnd: _groupTraceEnd as typeof _groupTraceEnd, 15 19 __vitest_fileInfo: _fileInfo as typeof _fileInfo, 16 20 __vitest_screenshot: screenshot as typeof screenshot, 17 21 __vitest_screenshotMatcher: screenshotMatcher as typeof screenshotMatcher,
+55
packages/browser/src/node/commands/trace.ts
··· 1 + import type { BrowserCommand } from 'vitest/node' 2 + 3 + interface MarkTracePayload { 4 + name: string 5 + stack?: string 6 + selector?: string 7 + } 8 + 9 + interface GroupTracePayload { 10 + name: string 11 + stack?: string 12 + } 13 + 14 + declare module 'vitest/browser' { 15 + interface BrowserCommands { 16 + /** 17 + * @internal 18 + */ 19 + __vitest_markTrace: (payload: MarkTracePayload) => Promise<void> 20 + /** 21 + * @internal 22 + */ 23 + __vitest_groupTraceStart: (payload: GroupTracePayload) => Promise<void> 24 + /** 25 + * @internal 26 + */ 27 + __vitest_groupTraceEnd: () => Promise<void> 28 + } 29 + } 30 + 31 + export const _markTrace: BrowserCommand<[payload: MarkTracePayload]> = async ( 32 + context, 33 + payload, 34 + ) => { 35 + if (context.provider.name === 'playwright') { 36 + await context.triggerCommand('__vitest_markTrace', payload) 37 + } 38 + } 39 + 40 + export const _groupTraceStart: BrowserCommand<[payload: GroupTracePayload]> = async ( 41 + context, 42 + payload, 43 + ) => { 44 + if (context.provider.name === 'playwright') { 45 + await context.triggerCommand('__vitest_groupTraceStart', payload) 46 + } 47 + } 48 + 49 + export const _groupTraceEnd: BrowserCommand<[]> = async ( 50 + context, 51 + ) => { 52 + if (context.provider.name === 'playwright') { 53 + await context.triggerCommand('__vitest_groupTraceEnd') 54 + } 55 + }
+2
packages/browser/src/node/index.ts
··· 20 20 // export type { ProjectBrowser } from './project' 21 21 export { parseKeyDef, resolveScreenshotPath } from './utils' 22 22 23 + export { asLocator } from 'ivya' 24 + 23 25 export const createBrowserServer: BrowserServerFactory = async (options) => { 24 26 const project = options.project 25 27 const configFile = project.vite.config.configFile
+4
packages/vitest/src/integrations/chai/poll.ts
··· 95 95 96 96 chai.util.flag(assertion, '_name', key) 97 97 98 + const onSettled = chai.util.flag(assertion, '_poll.onSettled') as Function | undefined 99 + 98 100 try { 99 101 while (true) { 100 102 const isLastAttempt = hasTimedOut ··· 110 112 111 113 executionPhase = 'assertion' 112 114 const output = await assertionFunction.call(assertion, ...args) 115 + await onSettled?.({ assertion, status: 'pass' }) 113 116 114 117 return output 115 118 } 116 119 catch (err) { 117 120 if (isLastAttempt || (executionPhase === 'assertion' && chai.util.flag(assertion, '_poll.assert_once'))) { 121 + await onSettled?.({ assertion, status: 'fail' }) 118 122 throwWithCause(err, STACK_TRACE_ERROR) 119 123 } 120 124
+1 -2
packages/vitest/src/node/types/browser.ts
··· 405 405 tracesDir?: string 406 406 screenshots?: boolean 407 407 snapshots?: boolean 408 - // TODO: map locations to test ones 409 - // sources?: boolean 408 + sources?: boolean 410 409 } 411 410 } 412 411
+21
pnpm-lock.yaml
··· 42 42 '@types/ws': 43 43 specifier: ^8.18.1 44 44 version: 8.18.1 45 + '@types/yauzl': 46 + specifier: ^2.10.3 47 + version: 2.10.3 45 48 '@unocss/reset': 46 49 specifier: ^66.6.3 47 50 version: 66.6.3 ··· 135 138 ws: 136 139 specifier: ^8.19.0 137 140 version: 8.19.0 141 + yauzl: 142 + specifier: ^3.2.0 143 + version: 3.2.0 138 144 139 145 overrides: 140 146 '@types/node': 24.11.0 ··· 1179 1185 '@types/react': 1180 1186 specifier: ^19.2.14 1181 1187 version: 19.2.14 1188 + '@types/yauzl': 1189 + specifier: 'catalog:' 1190 + version: 2.10.3 1182 1191 '@vitejs/plugin-basic-ssl': 1183 1192 specifier: ^2.1.4 1184 1193 version: 2.1.4(vite@7.1.5(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) ··· 1227 1236 ws: 1228 1237 specifier: 'catalog:' 1229 1238 version: 8.19.0 1239 + yauzl: 1240 + specifier: 'catalog:' 1241 + version: 3.2.0 1230 1242 1231 1243 test/cli: 1232 1244 devDependencies: ··· 10200 10212 10201 10213 yauzl@2.10.0: 10202 10214 resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} 10215 + 10216 + yauzl@3.2.0: 10217 + resolution: {integrity: sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==} 10218 + engines: {node: '>=12'} 10203 10219 10204 10220 yocto-queue@0.1.0: 10205 10221 resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} ··· 19493 19509 dependencies: 19494 19510 buffer-crc32: 0.2.13 19495 19511 fd-slicer: 1.1.0 19512 + 19513 + yauzl@3.2.0: 19514 + dependencies: 19515 + buffer-crc32: 0.2.13 19516 + pend: 1.2.0 19496 19517 19497 19518 yocto-queue@0.1.0: {} 19498 19519
+2
pnpm-workspace.yaml
··· 58 58 '@types/istanbul-lib-source-maps': ^4.0.4 59 59 '@types/istanbul-reports': ^3.0.4 60 60 '@types/ws': ^8.18.1 61 + '@types/yauzl': ^2.10.3 61 62 '@unocss/reset': ^66.6.3 62 63 '@vitejs/plugin-vue': ^6.0.4 63 64 '@vueuse/core': ^14.2.1 ··· 89 90 vitest-sonar-reporter: 3.0.0 90 91 vue: ^3.5.29 91 92 ws: ^8.19.0 93 + yauzl: ^3.2.0 92 94 onlyBuiltDependencies: 93 95 - '@sveltejs/kit' 94 96 - '@swc/core'
+18 -1
test/browser/README.md
··· 1 1 # Browser Tests 2 2 3 + ```sh 4 + # run full test with all providers and all browsers 5 + pnpm run test 6 + 7 + # run only playwright provider + all browsers 8 + pnpm run test:playwright 9 + 10 + # run only playwright provider + chromium 11 + BROWSER=chromium pnpm run test:playwright 12 + 13 + # run specific fixture (default is playwright provider + all browsers) 14 + pnpm run test-fixtures --root ./fixtures/locators 15 + 16 + # run specific fixture with selected provider and browser 17 + PROVIDER=webdriverio BROWSER=firefox pnpm run test-fixtures --root ./fixtures/locators 18 + ``` 19 + 3 20 ## Using docker playwright 4 21 5 22 Some test suites don't support running it remotely (`fixtures/inspect` and `fixtures/insecure-context`). ··· 9 26 pnpm docker up -d 10 27 11 28 # Run tests with BROWSER_WS_ENDPOINT 12 - BROWSER_WS_ENDPOINT=ws://127.0.0.1:6677/ pnpm test:playwright 29 + BROWSER_WS_ENDPOINT=ws://127.0.0.1:6677/ pnpm run test:playwright 13 30 ```
+58
test/browser/fixtures/trace-mark/basic.test.ts
··· 1 + import { beforeEach, expect, 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 + test("expect.element pass", async () => { 19 + document.body.innerHTML = "<button>Hello</button>"; 20 + await expect.element(page.getByRole("button")).toHaveTextContent("Hello"); 21 + }); 22 + 23 + test("expect.element fail", async () => { 24 + document.body.innerHTML = "<button>Hello</button>"; 25 + await page.mark("button rendered"); 26 + await expect.element(page.getByRole("button"), { timeout: 100 }).toHaveTextContent("World"); 27 + }); 28 + 29 + test("failure", async () => { 30 + document.body.innerHTML = "<button>Hello</button>"; 31 + throw new Error("Test failure"); 32 + }); 33 + 34 + test("click", async () => { 35 + document.body.innerHTML = "<button>Hello</button>"; 36 + await page.getByRole("button").click(); 37 + }); 38 + 39 + const myRender = vi.defineHelper(async (content: string) => { 40 + document.body.innerHTML = content; 41 + await page.elementLocator(document.body).mark("render helper"); 42 + }); 43 + 44 + test("helper", async () => { 45 + await myRender("<button>Hello</button>"); 46 + }); 47 + 48 + test("stack", async () => { 49 + document.body.innerHTML = "<button>Hello</button>"; 50 + const error = new Error("Custom error for stack trace"); 51 + await page.getByRole("button").mark("button rendered - stack", { stack: error.stack }); 52 + }); 53 + 54 + test("mark group", async () => { 55 + await page.mark("render group", async () => { 56 + document.body.innerHTML = "<button>Hello</button>"; 57 + }) 58 + });
+14
test/browser/fixtures/trace-mark/vitest.config.ts
··· 1 + import { fileURLToPath } from "node:url"; 2 + import { defineConfig } from "vitest/config"; 3 + import { instances, providers } from "../../settings"; 4 + 5 + export default defineConfig({ 6 + cacheDir: fileURLToPath(new URL("./node_modules/.vite", import.meta.url)), 7 + test: { 8 + browser: { 9 + enabled: true, 10 + provider: providers.playwright(), 11 + instances, 12 + }, 13 + }, 14 + });
+3 -1
test/browser/package.json
··· 31 31 }, 32 32 "devDependencies": { 33 33 "@types/react": "^19.2.14", 34 + "@types/yauzl": "catalog:", 34 35 "@vitejs/plugin-basic-ssl": "^2.1.4", 35 36 "@vitest/browser": "workspace:*", 36 37 "@vitest/browser-playwright": "workspace:*", ··· 46 47 "url": "^0.11.4", 47 48 "vitest": "workspace:*", 48 49 "vitest-browser-react": "^2.0.5", 49 - "ws": "catalog:" 50 + "ws": "catalog:", 51 + "yauzl": "catalog:" 50 52 } 51 53 }
+376
test/browser/specs/playwright-trace-mark.test.ts
··· 1 + import { readdirSync, rmSync } from 'node:fs' 2 + import path from 'node:path' 3 + import { stripVTControlCharacters } from 'node:util' 4 + import { resolve } from 'pathe' 5 + import { afterEach, describe, expect, test } from 'vitest' 6 + import { rolldownVersion } from 'vitest/node' 7 + import * as yauzl from 'yauzl' 8 + import { buildTestProjectTree } from '../../test-utils' 9 + import { instances, provider, runBrowserTests } from './utils' 10 + 11 + const tracesFolder = resolve(import.meta.dirname, '../fixtures/trace-mark/__traces__') 12 + const basicTestTracesFolder = resolve(tracesFolder, 'basic.test.ts') 13 + 14 + describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { 15 + afterEach(() => { 16 + rmSync(tracesFolder, { recursive: true, force: true }) 17 + }) 18 + 19 + test('vitest mark is present in zipped trace events', async () => { 20 + const { results, ctx } = await runBrowserTests({ 21 + root: './fixtures/trace-mark', 22 + browser: { 23 + trace: { 24 + mode: 'on', 25 + screenshots: false, // makes it lighter 26 + }, 27 + }, 28 + }) 29 + const projectTree = buildTestProjectTree(results, (testCase) => { 30 + const result = testCase.result() 31 + return result.state === 'failed' 32 + ? result.errors.map(e => stripVTControlCharacters(e.message)) 33 + : result.state 34 + }) 35 + expect(Object.keys(projectTree).sort()).toEqual(instances.map(i => i.browser).sort()) 36 + 37 + for (const [name, tree] of Object.entries(projectTree)) { 38 + expect.soft(tree).toMatchInlineSnapshot(` 39 + { 40 + "basic.test.ts": { 41 + "click": "passed", 42 + "expect.element fail": [ 43 + "expect(element).toHaveTextContent() 44 + 45 + Expected element to have text content: 46 + World 47 + Received: 48 + Hello", 49 + ], 50 + "expect.element pass": "passed", 51 + "failure": [ 52 + "Test failure", 53 + ], 54 + "helper": "passed", 55 + "locator.mark": "passed", 56 + "mark group": "passed", 57 + "page.mark": "passed", 58 + "stack": "passed", 59 + }, 60 + } 61 + `) 62 + 63 + const traceFiles = readdirSync(basicTestTracesFolder) 64 + .filter(file => file.startsWith(`${name}-`) && file.endsWith('.trace.zip')) 65 + .sort() 66 + expect.soft(traceFiles).toEqual([ 67 + expect.stringContaining('click'), 68 + expect.stringContaining('expect-element-fail'), 69 + expect.stringContaining('expect-element-pass'), 70 + expect.stringContaining('failure'), 71 + expect.stringContaining('helper'), 72 + expect.stringContaining('locator-mark'), 73 + expect.stringContaining('mark-group'), 74 + expect.stringContaining('page-mark'), 75 + expect.stringContaining('stack'), 76 + ]) 77 + 78 + function formatStack(event: any) { 79 + return event.stack 80 + ?.map( 81 + (frame: any) => 82 + `${path.relative(ctx.config.root, frame.file)}:${frame.line}:${frame.column}`, 83 + ) 84 + .join('\n') 85 + } 86 + 87 + for (const traceFile of traceFiles) { 88 + const zipPath = resolve(basicTestTracesFolder, traceFile) 89 + const parsed = await readTraceZip(zipPath) 90 + const events = parsed.events.filter(event => event.type === 'before') 91 + 92 + if (traceFile.includes('locator-mark')) { 93 + expect(events).toEqual( 94 + expect.arrayContaining([ 95 + expect.objectContaining({ 96 + method: 'tracingGroup', 97 + title: 'button rendered - locator', 98 + }), 99 + expect.objectContaining({ 100 + method: 'expect', 101 + params: expect.objectContaining({ 102 + selector: expect.stringContaining(`internal:describe="getByRole('button')`), 103 + }), 104 + }), 105 + ]), 106 + ) 107 + const markerEvent = events.find(e => e.title === 'button rendered - locator') 108 + const formattedFrame = formatStack(markerEvent) 109 + if (name === 'webkit') { 110 + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:10:38"`) 111 + } 112 + else { 113 + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:10:33"`) 114 + } 115 + } 116 + 117 + if (traceFile.includes('page-mark') && !traceFile.includes('custom-stack')) { 118 + expect(events).toEqual( 119 + expect.arrayContaining([ 120 + expect.objectContaining({ 121 + method: 'tracingGroup', 122 + title: 'button rendered - page', 123 + }), 124 + expect.objectContaining({ 125 + method: 'evaluateExpression', 126 + }), 127 + ]), 128 + ) 129 + const markerEvent = events.find(e => e.title === 'button rendered - page') 130 + const formattedFrame = formatStack(markerEvent) 131 + if (name === 'webkit') { 132 + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:15:18"`) 133 + } 134 + else { 135 + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:15:13"`) 136 + } 137 + } 138 + 139 + if (traceFile.includes('expect-element-pass')) { 140 + expect(events).toEqual( 141 + expect.arrayContaining([ 142 + expect.objectContaining({ 143 + method: 'tracingGroup', 144 + title: 'expect.element().toHaveTextContent', 145 + }), 146 + expect.objectContaining({ 147 + method: 'expect', 148 + params: expect.objectContaining({ 149 + selector: expect.stringContaining(`internal:describe="getByRole('button')`), 150 + }), 151 + }), 152 + ]), 153 + ) 154 + const markerEvent = events.find(e => e.title === 'expect.element().toHaveTextContent') 155 + const formattedFrame = formatStack(markerEvent) 156 + if (name === 'webkit') { 157 + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:20:23"`) 158 + } 159 + else { 160 + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:20:15"`) 161 + } 162 + } 163 + 164 + if (traceFile.includes('expect-element-fail')) { 165 + expect(events).toEqual( 166 + expect.arrayContaining([ 167 + expect.objectContaining({ 168 + method: 'tracingGroup', 169 + title: 'button rendered', 170 + }), 171 + expect.objectContaining({ 172 + method: 'tracingGroup', 173 + title: 'expect.element().toHaveTextContent [ERROR]', 174 + }), 175 + expect.objectContaining({ 176 + method: 'expect', 177 + params: expect.objectContaining({ 178 + selector: expect.stringContaining(`internal:describe="getByRole('button')`), 179 + }), 180 + }), 181 + ]), 182 + ) 183 + const markerEvent = events.find(e => e.title === 'expect.element().toHaveTextContent [ERROR]') 184 + const formattedFrame = formatStack(markerEvent) 185 + if (name === 'webkit') { 186 + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:26:23"`) 187 + } 188 + else { 189 + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:26:15"`) 190 + } 191 + } 192 + 193 + if (traceFile.includes('failure')) { 194 + const markerEvent = events.find(e => e.title === 'onAfterRetryTask [fail]') 195 + const formattedFrame = formatStack(markerEvent) 196 + if (name === 'webkit') { 197 + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:31:18"`) 198 + } 199 + else { 200 + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:31:8"`) 201 + } 202 + } 203 + 204 + if (traceFile.includes('click')) { 205 + expect(events).toEqual( 206 + expect.arrayContaining([ 207 + // vitest command group (with source) 208 + expect.objectContaining({ 209 + method: 'tracingGroup', 210 + title: '__vitest_click', 211 + }), 212 + // playwright action (without source) 213 + expect.objectContaining({ 214 + method: 'click', 215 + params: expect.objectContaining({ 216 + selector: expect.stringContaining(`internal:describe="getByRole('button')`), 217 + }), 218 + }), 219 + ]), 220 + ) 221 + const markerEvent = events.find(e => e.title === '__vitest_click') 222 + const formattedFrame = formatStack(markerEvent) 223 + if (name === 'webkit') { 224 + if (rolldownVersion) { 225 + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:36:33"`) 226 + } 227 + else { 228 + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:36:39"`) 229 + } 230 + } 231 + else { 232 + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:36:33"`) 233 + } 234 + } 235 + 236 + if (traceFile.includes('helper')) { 237 + expect(events).toEqual( 238 + expect.arrayContaining([ 239 + expect.objectContaining({ 240 + title: 'render helper', 241 + }), 242 + ]), 243 + ) 244 + const markerEvent = events.find(e => e.title === 'render helper') 245 + const formattedFrame = formatStack(markerEvent) 246 + if (name === 'webkit') { 247 + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:45:17"`) 248 + } 249 + else { 250 + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:45:8"`) 251 + } 252 + } 253 + 254 + if (traceFile.includes('stack')) { 255 + expect(events).toEqual( 256 + expect.arrayContaining([ 257 + expect.objectContaining({ 258 + title: 'button rendered - stack', 259 + }), 260 + ]), 261 + ) 262 + const markerEvent = events.find(e => e.title === 'button rendered - stack') 263 + const formattedFrame = formatStack(markerEvent) 264 + if (name === 'webkit') { 265 + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:50:26"`) 266 + } 267 + else { 268 + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:50:16"`) 269 + } 270 + } 271 + 272 + if (traceFile.includes('mark-group')) { 273 + expect(events).toEqual( 274 + expect.arrayContaining([ 275 + expect.objectContaining({ 276 + title: 'render group', 277 + }), 278 + ]), 279 + ) 280 + const markerEvent = events.find(e => e.title === 'render group') 281 + const formattedFrame = formatStack(markerEvent) 282 + if (name === 'webkit') { 283 + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:55:18"`) 284 + } 285 + else { 286 + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:55:13"`) 287 + } 288 + } 289 + } 290 + } 291 + }) 292 + }) 293 + 294 + async function readTraceZip(zipPath: string): Promise<{ entries: string[]; events: any[] }> { 295 + const zipFile = new ZipFile(zipPath) 296 + try { 297 + const entries = await zipFile.entries() 298 + const traceText = (await zipFile.read('trace.trace')).toString('utf-8') 299 + const events = traceText 300 + .split('\n') 301 + .filter(Boolean) 302 + .map((line) => { 303 + return JSON.parse(line) 304 + }) 305 + return { entries, events } 306 + } 307 + finally { 308 + zipFile.close() 309 + } 310 + } 311 + 312 + // https://github.com/microsoft/playwright/blob/cd36dab6ecc7f4b3adeec333e55f9ac03711a9b1/packages/playwright-core/src/server/utils/zipFile.ts#L21 313 + class ZipFile { 314 + private readonly fileName: string 315 + private zipFile?: yauzl.ZipFile 316 + private readonly openedPromise: Promise<void> 317 + private readonly entriesMap = new Map<string, yauzl.Entry>() 318 + 319 + constructor(fileName: string) { 320 + this.fileName = fileName 321 + this.openedPromise = this.open() 322 + } 323 + 324 + private async open(): Promise<void> { 325 + this.zipFile = await new Promise<yauzl.ZipFile>((resolve, reject) => { 326 + yauzl.open(this.fileName, { lazyEntries: true, autoClose: false }, (error, zipFile) => { 327 + if (error || !zipFile) { 328 + reject(error || new Error(`Cannot open zip: ${this.fileName}`)) 329 + return 330 + } 331 + resolve(zipFile) 332 + }) 333 + }) 334 + 335 + await new Promise<void>((resolve, reject) => { 336 + this.zipFile!.readEntry() 337 + this.zipFile!.on('entry', (entry) => { 338 + this.entriesMap.set(entry.fileName, entry) 339 + this.zipFile!.readEntry() 340 + }) 341 + this.zipFile!.on('end', resolve) 342 + this.zipFile!.on('error', reject) 343 + }) 344 + } 345 + 346 + async entries(): Promise<string[]> { 347 + await this.openedPromise 348 + return [...this.entriesMap.keys()] 349 + } 350 + 351 + async read(entryPath: string): Promise<Buffer> { 352 + await this.openedPromise 353 + const entry = this.entriesMap.get(entryPath) 354 + if (!entry || !this.zipFile) { 355 + throw new Error(`${entryPath} not found in file ${this.fileName}`) 356 + } 357 + 358 + return await new Promise((resolve, reject) => { 359 + this.zipFile!.openReadStream(entry, (error, stream) => { 360 + if (error || !stream) { 361 + reject(error || new Error(`Cannot read ${entryPath} from file ${this.fileName}`)) 362 + return 363 + } 364 + 365 + const buffers: Buffer[] = [] 366 + stream.on('data', data => buffers.push(data)) 367 + stream.on('error', reject) 368 + stream.on('end', () => resolve(Buffer.concat(buffers))) 369 + }) 370 + }) 371 + } 372 + 373 + close(): void { 374 + this.zipFile?.close() 375 + } 376 + }