From 6395c9601436d5cb998fdcef836007b6661b87b5 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 13 Feb 2026 09:30:45 +0900 Subject: [PATCH 01/45] feat: improve playwright trace --- examples/lit/.gitignore | 1 + examples/lit/test/basic.test.ts | 8 ++++-- .../browser-playwright/src/commands/index.ts | 2 ++ .../browser-playwright/src/commands/trace.ts | 27 +++++++++++++++++++ packages/browser/src/node/commands/index.ts | 2 ++ packages/browser/src/node/commands/trace.ts | 18 +++++++++++++ .../browser/src/node/plugins/pluginContext.ts | 3 +++ packages/vitest/src/public/browser.ts | 1 + 8 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 examples/lit/.gitignore create mode 100644 packages/browser/src/node/commands/trace.ts diff --git a/examples/lit/.gitignore b/examples/lit/.gitignore new file mode 100644 index 000000000000..11660014ec49 --- /dev/null +++ b/examples/lit/.gitignore @@ -0,0 +1 @@ +__traces__ diff --git a/examples/lit/test/basic.test.ts b/examples/lit/test/basic.test.ts index 3373c9e149a5..44fbee606a44 100644 --- a/examples/lit/test/basic.test.ts +++ b/examples/lit/test/basic.test.ts @@ -1,11 +1,15 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { page } from 'vitest/browser' +import { commands, page } from 'vitest/browser' import '../src/my-button.js' describe('Button with increment', async () => { - beforeEach(() => { + beforeEach(async (ctx) => { document.body.innerHTML = '' + await commands.markTrace('beforeEach / render') + ctx.onTestFinished(async (ctx) => { + await commands.markTrace(`onTestFinished / ${ctx.task.result.state}`) + }) }) it('should increment the count on each click', async () => { diff --git a/packages/browser-playwright/src/commands/index.ts b/packages/browser-playwright/src/commands/index.ts index a5d1759ede52..f6b130f1abea 100644 --- a/packages/browser-playwright/src/commands/index.ts +++ b/packages/browser-playwright/src/commands/index.ts @@ -10,6 +10,7 @@ import { tab } from './tab' import { annotateTraces, deleteTracing, + markTrace, startChunkTrace, startTracing, stopChunkTrace, @@ -39,4 +40,5 @@ export default { __vitest_startTracing: startTracing as typeof startTracing, __vitest_stopChunkTrace: stopChunkTrace as typeof stopChunkTrace, __vitest_annotateTraces: annotateTraces as typeof annotateTraces, + __vitest_markTrace: markTrace as typeof markTrace, } diff --git a/packages/browser-playwright/src/commands/trace.ts b/packages/browser-playwright/src/commands/trace.ts index 55b26453d8c2..01f866ede7e5 100644 --- a/packages/browser-playwright/src/commands/trace.ts +++ b/packages/browser-playwright/src/commands/trace.ts @@ -1,3 +1,4 @@ +import type { ParsedStack } from 'vitest' import type { BrowserCommand, BrowserCommandContext, BrowserProvider } from 'vitest/node' import type { PlaywrightBrowserProvider } from '../playwright' import { unlink } from 'node:fs/promises' @@ -57,6 +58,32 @@ export const stopChunkTrace: BrowserCommand<[{ name: string }]> = async ( throw new TypeError(`The ${context.provider.name} provider does not support tracing.`) } +export const markTrace: BrowserCommand<[name: string, stack?: string]> = async ( + context, + name, + stack, +) => { + if (isPlaywrightProvider(context.provider)) { + // skip if tracing is not active + if (!context.provider.tracingContexts.has(context.sessionId)) { + return + } + let location: ParsedStack | undefined + if (stack) { + const parsedStacks = context.project.browser!.parseStacktrace(stack) + location = parsedStacks[0] + } + // mark trace via group/groupEnd with empty `evaluate` to force snapshot. + // TODO: request new tracing API in playwright to add trace point + // with arbitrary snapshot, screenshot, etc. options. + await context.context.tracing.group(name, { location }) + await context.page.evaluate(() => 0) + await context.context.tracing.groupEnd() + return + } + throw new TypeError(`The ${context.provider.name} provider does not support tracing.`) +} + function resolveTracesPath({ testPath, project }: BrowserCommandContext, name: string) { if (!testPath) { throw new Error(`This command can only be called inside a test file.`) diff --git a/packages/browser/src/node/commands/index.ts b/packages/browser/src/node/commands/index.ts index 64e115b731a6..be52fb742fcd 100644 --- a/packages/browser/src/node/commands/index.ts +++ b/packages/browser/src/node/commands/index.ts @@ -6,11 +6,13 @@ import { } from './fs' import { screenshot } from './screenshot' import { screenshotMatcher } from './screenshotMatcher' +import { markTrace } from './trace' export default { readFile: readFile as typeof readFile, removeFile: removeFile as typeof removeFile, writeFile: writeFile as typeof writeFile, + markTrace: markTrace as typeof markTrace, // private commands __vitest_fileInfo: _fileInfo as typeof _fileInfo, __vitest_screenshot: screenshot as typeof screenshot, diff --git a/packages/browser/src/node/commands/trace.ts b/packages/browser/src/node/commands/trace.ts new file mode 100644 index 000000000000..9366f0de40c6 --- /dev/null +++ b/packages/browser/src/node/commands/trace.ts @@ -0,0 +1,18 @@ +import type { BrowserCommand, BrowserCommandContext } from 'vitest/node' + +declare module 'vitest/browser' { + interface BrowserCommands { + /** + * @internal + */ + __vitest_markTrace: (name: string, stack?: string) => Promise + } +} + +export const markTrace = (async ( + context: BrowserCommandContext, + name: string, + stack?: string, +) => { + await context.triggerCommand('__vitest_markTrace', name, stack) +}) as BrowserCommand<[name: string]> diff --git a/packages/browser/src/node/plugins/pluginContext.ts b/packages/browser/src/node/plugins/pluginContext.ts index 19e09e45c3e1..1f5f1fb1d317 100644 --- a/packages/browser/src/node/plugins/pluginContext.ts +++ b/packages/browser/src/node/plugins/pluginContext.ts @@ -66,6 +66,9 @@ async function generateContextFile( const commandsCode = commands .filter(command => !command.startsWith('__vitest')) .map((command) => { + if (command === 'markTrace') { + return ` ["${command}"]: (name) => __vitest_browser_runner__.commands.triggerCommand("${command}", [name, new Error('__vitest_mark_trace__').stack]),` + } return ` ["${command}"]: (...args) => __vitest_browser_runner__.commands.triggerCommand("${command}", args),` }) .join('\n') diff --git a/packages/vitest/src/public/browser.ts b/packages/vitest/src/public/browser.ts index 64ee91c05172..5e1c28786745 100644 --- a/packages/vitest/src/public/browser.ts +++ b/packages/vitest/src/public/browser.ts @@ -42,6 +42,7 @@ export interface BrowserCommands { options?: BufferEncoding | (FsOptions & { mode?: number | string }), ) => Promise removeFile: (path: string) => Promise + markTrace: (name: string) => Promise } /** * @internal From 276210c901716f6da338c3268c0de06bdcdda6d4 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 13 Feb 2026 09:38:31 +0900 Subject: [PATCH 02/45] chore: todo --- packages/browser/src/node/plugins/pluginContext.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/browser/src/node/plugins/pluginContext.ts b/packages/browser/src/node/plugins/pluginContext.ts index 1f5f1fb1d317..ee6cec3add77 100644 --- a/packages/browser/src/node/plugins/pluginContext.ts +++ b/packages/browser/src/node/plugins/pluginContext.ts @@ -67,6 +67,7 @@ async function generateContextFile( .filter(command => !command.startsWith('__vitest')) .map((command) => { if (command === 'markTrace') { + // TODO: can we make this no-op on client side when tracing is not active? return ` ["${command}"]: (name) => __vitest_browser_runner__.commands.triggerCommand("${command}", [name, new Error('__vitest_mark_trace__').stack]),` } return ` ["${command}"]: (...args) => __vitest_browser_runner__.commands.triggerCommand("${command}", args),` From 409baf74c28739e4f37c80cae5349ef8d0a9ae10 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 13 Feb 2026 11:16:33 +0900 Subject: [PATCH 03/45] feat: markTrace on onTaskFinished errors --- docs/api/browser/assertions.md | 21 +++++++++++++++++-- examples/lit/.gitignore | 1 + examples/lit/test/basic.test.ts | 8 +++---- .../client/tester/expect/toHaveTextContent.ts | 12 ++++++++++- packages/browser/src/client/tester/runner.ts | 7 +++++++ 5 files changed, 41 insertions(+), 8 deletions(-) diff --git a/docs/api/browser/assertions.md b/docs/api/browser/assertions.md index 953bb93a6065..9b2a9baf639f 100644 --- a/docs/api/browser/assertions.md +++ b/docs/api/browser/assertions.md @@ -46,7 +46,10 @@ interface ExpectPollOptions { // Defaults to "expect.poll.interval" config option interval?: number // Time to retry the assertion for in milliseconds - // Defaults to "expect.poll.timeout" config option + // Defaults to "expect.poll.timeout" config option. + // In Playwright, if no timeout is provided and + // browser.providerOptions.actionTimeout is not set, + // Vitest caps this timeout by the remaining test timeout. timeout?: number // The message printed when the assertion fails message?: string @@ -54,7 +57,21 @@ interface ExpectPollOptions { ``` ::: tip -`expect.element` is a shorthand for `expect.poll(() => element)` and works in exactly the same way. +If you use the Playwright provider, `expect.element` timeout can be capped by +the remaining test time when `timeout` is omitted and +[`browser.providerOptions.actionTimeout`](/config/browser/playwright#actiontimeout) +is not configured. In this case, `expect.poll.timeout` is used as the base +timeout and capped by the remaining test time. + +Using a fixed assertion/action timeout that is lower than `testTimeout` usually +improves failure reporting: the failing assertion/action is reported directly, +instead of ending with a generic test timeout. +::: + +::: tip +`expect.element` is built on top of `expect.poll(() => element)`. +In Playwright mode, timeout handling can additionally use the remaining test +time (see note above). `toHaveTextContent` and all other assertions are still available on a regular `expect` without a built-in retry-ability mechanism: diff --git a/examples/lit/.gitignore b/examples/lit/.gitignore index 11660014ec49..5c8de7847e27 100644 --- a/examples/lit/.gitignore +++ b/examples/lit/.gitignore @@ -1 +1,2 @@ __traces__ +__screenshots__ diff --git a/examples/lit/test/basic.test.ts b/examples/lit/test/basic.test.ts index 44fbee606a44..4b4a06049032 100644 --- a/examples/lit/test/basic.test.ts +++ b/examples/lit/test/basic.test.ts @@ -4,18 +4,16 @@ import { commands, page } from 'vitest/browser' import '../src/my-button.js' describe('Button with increment', async () => { - beforeEach(async (ctx) => { + beforeEach(async () => { document.body.innerHTML = '' - await commands.markTrace('beforeEach / render') - ctx.onTestFinished(async (ctx) => { - await commands.markTrace(`onTestFinished / ${ctx.task.result.state}`) - }) + await commands.markTrace('render') }) it('should increment the count on each click', async () => { await page.getByRole('button').click() await expect.element(page.getByRole('button')).toHaveTextContent('2') + // await expect.element(page.getByRole('button'), { timeout: 3000 }).toHaveTextContent('3') }) it('should show name props', async () => { diff --git a/packages/browser/src/client/tester/expect/toHaveTextContent.ts b/packages/browser/src/client/tester/expect/toHaveTextContent.ts index a991c3262cc6..58b3e07b5c55 100644 --- a/packages/browser/src/client/tester/expect/toHaveTextContent.ts +++ b/packages/browser/src/client/tester/expect/toHaveTextContent.ts @@ -31,8 +31,18 @@ export default function toHaveTextContent( const checkingWithEmptyString = textContent !== '' && matcher === '' + const pass = !checkingWithEmptyString && matches(textContent, matcher) + + // TODO: + // - markTrace each matcher failure? + // - or globally markTrace based on result.errors on test end? + // - or custom logic inside expect.element/poll? + // if (!pass == !this.isNot) { + // console.log([pass, this.isNot]) + // } + return { - pass: !checkingWithEmptyString && matches(textContent, matcher), + pass, message: () => { const to = this.isNot ? 'not to' : 'to' return getMessage( diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index cebf35858e60..0ffe051d240e 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -104,6 +104,10 @@ export function createBrowserRunner( '__vitest_startChunkTrace', [{ name, title }], ) + // TODO: location? + // test.location; + // test.file.filepath; + // await this.commands.triggerCommand('__vitest_markTrace', ['onBeforeTryTask']) } onAfterRetryTask = async (test: Test, { retry, repeats }: { retry: number; repeats: number }) => { @@ -160,6 +164,9 @@ export function createBrowserRunner( } onTaskFinished = async (task: Task) => { + if (task.result?.state === 'fail') { + await this.commands.triggerCommand('__vitest_markTrace', [`onTaskFinished (fail)`, task.result?.errors?.[0].stack]) + } if ( this.config.browser.screenshotFailures && document.body.clientHeight > 0 From 0798165fe3fd0f204f750132b12a16e697cde056 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 13 Feb 2026 12:24:24 +0900 Subject: [PATCH 04/45] docs: revert --- docs/api/browser/assertions.md | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/docs/api/browser/assertions.md b/docs/api/browser/assertions.md index 9b2a9baf639f..953bb93a6065 100644 --- a/docs/api/browser/assertions.md +++ b/docs/api/browser/assertions.md @@ -46,10 +46,7 @@ interface ExpectPollOptions { // Defaults to "expect.poll.interval" config option interval?: number // Time to retry the assertion for in milliseconds - // Defaults to "expect.poll.timeout" config option. - // In Playwright, if no timeout is provided and - // browser.providerOptions.actionTimeout is not set, - // Vitest caps this timeout by the remaining test timeout. + // Defaults to "expect.poll.timeout" config option timeout?: number // The message printed when the assertion fails message?: string @@ -57,21 +54,7 @@ interface ExpectPollOptions { ``` ::: tip -If you use the Playwright provider, `expect.element` timeout can be capped by -the remaining test time when `timeout` is omitted and -[`browser.providerOptions.actionTimeout`](/config/browser/playwright#actiontimeout) -is not configured. In this case, `expect.poll.timeout` is used as the base -timeout and capped by the remaining test time. - -Using a fixed assertion/action timeout that is lower than `testTimeout` usually -improves failure reporting: the failing assertion/action is reported directly, -instead of ending with a generic test timeout. -::: - -::: tip -`expect.element` is built on top of `expect.poll(() => element)`. -In Playwright mode, timeout handling can additionally use the remaining test -time (see note above). +`expect.element` is a shorthand for `expect.poll(() => element)` and works in exactly the same way. `toHaveTextContent` and all other assertions are still available on a regular `expect` without a built-in retry-ability mechanism: From b7c75ef5a60a37858bc96311c2a57489e8f0bcc6 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 13 Feb 2026 12:25:47 +0900 Subject: [PATCH 05/45] fix: pw only --- packages/browser/src/node/commands/trace.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/browser/src/node/commands/trace.ts b/packages/browser/src/node/commands/trace.ts index 9366f0de40c6..8ad39415ef11 100644 --- a/packages/browser/src/node/commands/trace.ts +++ b/packages/browser/src/node/commands/trace.ts @@ -14,5 +14,7 @@ export const markTrace = (async ( name: string, stack?: string, ) => { - await context.triggerCommand('__vitest_markTrace', name, stack) + if (context.provider.name === 'playwright') { + await context.triggerCommand('__vitest_markTrace', name, stack) + } }) as BrowserCommand<[name: string]> From 4c740e7cbb2f3247fb42989ca80bc84d38c0c5ed Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 17 Feb 2026 16:22:20 +0900 Subject: [PATCH 06/45] fix: try/catch trace hack --- examples/lit/test/basic.test.ts | 4 ++-- packages/browser-playwright/src/commands/trace.ts | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/lit/test/basic.test.ts b/examples/lit/test/basic.test.ts index 4b4a06049032..bc8cfe821177 100644 --- a/examples/lit/test/basic.test.ts +++ b/examples/lit/test/basic.test.ts @@ -12,8 +12,8 @@ describe('Button with increment', async () => { it('should increment the count on each click', async () => { await page.getByRole('button').click() - await expect.element(page.getByRole('button')).toHaveTextContent('2') - // await expect.element(page.getByRole('button'), { timeout: 3000 }).toHaveTextContent('3') + // await expect.element(page.getByRole('button')).toHaveTextContent('2') + await expect.element(page.getByRole('button'), { timeout: 3000 }).toHaveTextContent('3') }) it('should show name props', async () => { diff --git a/packages/browser-playwright/src/commands/trace.ts b/packages/browser-playwright/src/commands/trace.ts index 01f866ede7e5..18e09e219695 100644 --- a/packages/browser-playwright/src/commands/trace.ts +++ b/packages/browser-playwright/src/commands/trace.ts @@ -77,7 +77,9 @@ export const markTrace: BrowserCommand<[name: string, stack?: string]> = async ( // TODO: request new tracing API in playwright to add trace point // with arbitrary snapshot, screenshot, etc. options. await context.context.tracing.group(name, { location }) - await context.page.evaluate(() => 0) + try { + await context.page.evaluate(() => 0) + } catch {} await context.context.tracing.groupEnd() return } From f7814630f44c14c61933d45162d20a27230bd143 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 17 Feb 2026 17:16:10 +0900 Subject: [PATCH 07/45] feat: support markTrace with locator --- .../browser-playwright/src/commands/trace.ts | 29 ++++++++++++++----- packages/browser/src/client/tester/runner.ts | 5 +++- packages/browser/src/node/commands/trace.ts | 15 ++++++---- .../browser/src/node/plugins/pluginContext.ts | 2 +- packages/vitest/src/public/browser.ts | 7 ++++- 5 files changed, 42 insertions(+), 16 deletions(-) diff --git a/packages/browser-playwright/src/commands/trace.ts b/packages/browser-playwright/src/commands/trace.ts index 18e09e219695..b9237c366ab4 100644 --- a/packages/browser-playwright/src/commands/trace.ts +++ b/packages/browser-playwright/src/commands/trace.ts @@ -58,28 +58,41 @@ export const stopChunkTrace: BrowserCommand<[{ name: string }]> = async ( throw new TypeError(`The ${context.provider.name} provider does not support tracing.`) } -export const markTrace: BrowserCommand<[name: string, stack?: string]> = async ( +export const markTrace: BrowserCommand<[payload: { name: string; selector?: string; stack?: string }]> = async ( context, - name, - stack, + payload, ) => { if (isPlaywrightProvider(context.provider)) { // skip if tracing is not active if (!context.provider.tracingContexts.has(context.sessionId)) { return } + const { name, selector, stack } = payload let location: ParsedStack | undefined if (stack) { const parsedStacks = context.project.browser!.parseStacktrace(stack) location = parsedStacks[0] } - // mark trace via group/groupEnd with empty `evaluate` to force snapshot. - // TODO: request new tracing API in playwright to add trace point - // with arbitrary snapshot, screenshot, etc. options. + // mark trace via group/groupEnd with dummy calls to force snapshot. await context.context.tracing.group(name, { location }) try { - await context.page.evaluate(() => 0) - } catch {} + if (selector) { + const locator = context.iframe.locator(selector) as any + if (typeof locator._expect === 'function') { + await locator._expect('to.be.attached', { + isNot: false, + timeout: 1, + }) + } + else { + await context.page.evaluate(() => 0) + } + } + else { + await context.page.evaluate(() => 0) + } + } + catch {} await context.context.tracing.groupEnd() return } diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index 0ffe051d240e..542b4818bca0 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -165,7 +165,10 @@ export function createBrowserRunner( onTaskFinished = async (task: Task) => { if (task.result?.state === 'fail') { - await this.commands.triggerCommand('__vitest_markTrace', [`onTaskFinished (fail)`, task.result?.errors?.[0].stack]) + await this.commands.triggerCommand('__vitest_markTrace', [{ + name: 'onTaskFinished (fail)', + stack: task.result?.errors?.[0].stack, + }]) } if ( this.config.browser.screenshotFailures diff --git a/packages/browser/src/node/commands/trace.ts b/packages/browser/src/node/commands/trace.ts index 8ad39415ef11..19c8ecdfbab1 100644 --- a/packages/browser/src/node/commands/trace.ts +++ b/packages/browser/src/node/commands/trace.ts @@ -1,20 +1,25 @@ import type { BrowserCommand, BrowserCommandContext } from 'vitest/node' +interface MarkTracePayload { + name: string + stack?: string + selector?: string +} + declare module 'vitest/browser' { interface BrowserCommands { /** * @internal */ - __vitest_markTrace: (name: string, stack?: string) => Promise + __vitest_markTrace: (payload: MarkTracePayload) => Promise } } export const markTrace = (async ( context: BrowserCommandContext, - name: string, - stack?: string, + payload: { name: string; selector?: string; stack?: string }, ) => { if (context.provider.name === 'playwright') { - await context.triggerCommand('__vitest_markTrace', name, stack) + await context.triggerCommand('__vitest_markTrace', payload) } -}) as BrowserCommand<[name: string]> +}) as BrowserCommand<[payload: { name: string; selector?: string; stack?: string }]> diff --git a/packages/browser/src/node/plugins/pluginContext.ts b/packages/browser/src/node/plugins/pluginContext.ts index ee6cec3add77..939b7b39ea8b 100644 --- a/packages/browser/src/node/plugins/pluginContext.ts +++ b/packages/browser/src/node/plugins/pluginContext.ts @@ -68,7 +68,7 @@ async function generateContextFile( .map((command) => { if (command === 'markTrace') { // TODO: can we make this no-op on client side when tracing is not active? - return ` ["${command}"]: (name) => __vitest_browser_runner__.commands.triggerCommand("${command}", [name, new Error('__vitest_mark_trace__').stack]),` + return ` ["${command}"]: (options) => __vitest_browser_runner__.commands.triggerCommand("${command}", [{ name: options.name, selector: options.locator?.selector, stack: new Error('__vitest_mark_trace__').stack }]),` } return ` ["${command}"]: (...args) => __vitest_browser_runner__.commands.triggerCommand("${command}", args),` }) diff --git a/packages/vitest/src/public/browser.ts b/packages/vitest/src/public/browser.ts index 5e1c28786745..217a26b85343 100644 --- a/packages/vitest/src/public/browser.ts +++ b/packages/vitest/src/public/browser.ts @@ -31,6 +31,11 @@ export interface FsOptions { flag?: string | number } +export interface MarkTraceOptions { + name: string + locator?: { selector: string } +} + export interface BrowserCommands { readFile: ( path: string, @@ -42,7 +47,7 @@ export interface BrowserCommands { options?: BufferEncoding | (FsOptions & { mode?: number | string }), ) => Promise removeFile: (path: string) => Promise - markTrace: (name: string) => Promise + markTrace: (options: MarkTraceOptions) => Promise } /** * @internal From 6c178175f263908905338f9e0acf15a4f71f62a7 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 17 Feb 2026 17:16:43 +0900 Subject: [PATCH 08/45] chore: example --- examples/lit/test/basic.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/lit/test/basic.test.ts b/examples/lit/test/basic.test.ts index bc8cfe821177..d8eb97ac57de 100644 --- a/examples/lit/test/basic.test.ts +++ b/examples/lit/test/basic.test.ts @@ -6,14 +6,15 @@ import '../src/my-button.js' describe('Button with increment', async () => { beforeEach(async () => { document.body.innerHTML = '' - await commands.markTrace('render') + await commands.markTrace({ name: 'render', locator: page.getByRole('buttonn') }) + await commands.markTrace({ name: 'heading', locator: page.getByRole('heading') }) }) it('should increment the count on each click', async () => { await page.getByRole('button').click() - // await expect.element(page.getByRole('button')).toHaveTextContent('2') - await expect.element(page.getByRole('button'), { timeout: 3000 }).toHaveTextContent('3') + await expect.element(page.getByRole('button')).toHaveTextContent('2') + // await expect.element(page.getByRole('button'), { timeout: 3000 }).toHaveTextContent('3') }) it('should show name props', async () => { From 1ae2e5c9652da4104f3af6d074d31bc2cb6fa8fb Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 17 Feb 2026 17:38:54 +0900 Subject: [PATCH 09/45] feat: page.markTrace and locator.markTrace --- packages/browser/context.d.ts | 11 +++++++++++ packages/browser/src/client/tester/context.ts | 10 ++++++++++ packages/browser/src/client/tester/locators/index.ts | 12 ++++++++++++ packages/browser/src/node/commands/index.ts | 4 ++-- packages/browser/src/node/commands/trace.ts | 2 +- packages/browser/src/node/plugins/pluginContext.ts | 8 +------- packages/vitest/src/public/browser.ts | 6 ------ 7 files changed, 37 insertions(+), 16 deletions(-) diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index c09dd565de9a..747de4d0fb80 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -627,6 +627,12 @@ export interface Locator extends LocatorSelectors { }> screenshot(options?: LocatorScreenshotOptions): Promise + /** + * Add a trace marker for this locator. + * Works best with providers that support tracing. + */ + markTrace(options: { name: string }): Promise + /** * Returns an element matching the selector. * @@ -776,6 +782,11 @@ export interface BrowserPage extends LocatorSelectors { path: string base64: string }> + /** + * Add a trace marker. + * Works best with providers that support tracing. + */ + markTrace(options: { name: string }): Promise /** * Extend default `page` object with custom methods. */ diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts index 349dfc890928..e4ef7bdf524c 100644 --- a/packages/browser/src/client/tester/context.ts +++ b/packages/browser/src/client/tester/context.ts @@ -344,6 +344,16 @@ export const page: BrowserPage = { error, )) }, + markTrace(options) { + return ensureAwaited(error => triggerCommand( + '__vitest_markTrace', + [{ + name: options.name, + stack: error?.stack, + }], + error, + )) + }, getByRole() { throw new Error(`Method "getByRole" is not supported by the "${provider}" provider.`) }, diff --git a/packages/browser/src/client/tester/locators/index.ts b/packages/browser/src/client/tester/locators/index.ts index ec9a2f9eb4b2..467e0225a0e6 100644 --- a/packages/browser/src/client/tester/locators/index.ts +++ b/packages/browser/src/client/tester/locators/index.ts @@ -180,6 +180,18 @@ export abstract class Locator { }) } + public markTrace(options: { name: string }): Promise { + return ensureAwaited(error => getBrowserState().commands.triggerCommand( + '__vitest_markTrace', + [{ + name: options.name, + selector: this.selector, + stack: error?.stack, + }], + error, + )) + } + protected abstract locator(selector: string): Locator protected abstract elementLocator(element: Element): Locator diff --git a/packages/browser/src/node/commands/index.ts b/packages/browser/src/node/commands/index.ts index be52fb742fcd..e7ce9fd029d7 100644 --- a/packages/browser/src/node/commands/index.ts +++ b/packages/browser/src/node/commands/index.ts @@ -6,14 +6,14 @@ import { } from './fs' import { screenshot } from './screenshot' import { screenshotMatcher } from './screenshotMatcher' -import { markTrace } from './trace' +import { _markTrace } from './trace' export default { readFile: readFile as typeof readFile, removeFile: removeFile as typeof removeFile, writeFile: writeFile as typeof writeFile, - markTrace: markTrace as typeof markTrace, // private commands + __vitest_markTrace: _markTrace as typeof _markTrace, __vitest_fileInfo: _fileInfo as typeof _fileInfo, __vitest_screenshot: screenshot as typeof screenshot, __vitest_screenshotMatcher: screenshotMatcher as typeof screenshotMatcher, diff --git a/packages/browser/src/node/commands/trace.ts b/packages/browser/src/node/commands/trace.ts index 19c8ecdfbab1..8a3a1340a04d 100644 --- a/packages/browser/src/node/commands/trace.ts +++ b/packages/browser/src/node/commands/trace.ts @@ -15,7 +15,7 @@ declare module 'vitest/browser' { } } -export const markTrace = (async ( +export const _markTrace = (async ( context: BrowserCommandContext, payload: { name: string; selector?: string; stack?: string }, ) => { diff --git a/packages/browser/src/node/plugins/pluginContext.ts b/packages/browser/src/node/plugins/pluginContext.ts index 939b7b39ea8b..98c41d1e6856 100644 --- a/packages/browser/src/node/plugins/pluginContext.ts +++ b/packages/browser/src/node/plugins/pluginContext.ts @@ -65,13 +65,7 @@ async function generateContextFile( const commandsCode = commands .filter(command => !command.startsWith('__vitest')) - .map((command) => { - if (command === 'markTrace') { - // TODO: can we make this no-op on client side when tracing is not active? - return ` ["${command}"]: (options) => __vitest_browser_runner__.commands.triggerCommand("${command}", [{ name: options.name, selector: options.locator?.selector, stack: new Error('__vitest_mark_trace__').stack }]),` - } - return ` ["${command}"]: (...args) => __vitest_browser_runner__.commands.triggerCommand("${command}", args),` - }) + .map(command => ` ["${command}"]: (...args) => __vitest_browser_runner__.commands.triggerCommand("${command}", args),`) .join('\n') const userEventNonProviderImport = await getUserEventImport( diff --git a/packages/vitest/src/public/browser.ts b/packages/vitest/src/public/browser.ts index 217a26b85343..64ee91c05172 100644 --- a/packages/vitest/src/public/browser.ts +++ b/packages/vitest/src/public/browser.ts @@ -31,11 +31,6 @@ export interface FsOptions { flag?: string | number } -export interface MarkTraceOptions { - name: string - locator?: { selector: string } -} - export interface BrowserCommands { readFile: ( path: string, @@ -47,7 +42,6 @@ export interface BrowserCommands { options?: BufferEncoding | (FsOptions & { mode?: number | string }), ) => Promise removeFile: (path: string) => Promise - markTrace: (options: MarkTraceOptions) => Promise } /** * @internal From 7a402a599da4bda636a6881dbf36087edd834fbf Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 17 Feb 2026 17:40:16 +0900 Subject: [PATCH 10/45] example --- examples/lit/test/basic.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/lit/test/basic.test.ts b/examples/lit/test/basic.test.ts index d8eb97ac57de..b9a86d065d56 100644 --- a/examples/lit/test/basic.test.ts +++ b/examples/lit/test/basic.test.ts @@ -1,16 +1,17 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { commands, page } from 'vitest/browser' +import { page } from 'vitest/browser' import '../src/my-button.js' describe('Button with increment', async () => { beforeEach(async () => { document.body.innerHTML = '' - await commands.markTrace({ name: 'render', locator: page.getByRole('buttonn') }) - await commands.markTrace({ name: 'heading', locator: page.getByRole('heading') }) + await page.getByRole('button').markTrace({ name: 'render' }) + await page.getByRole('heading').markTrace({ name: 'heading' }) }) it('should increment the count on each click', async () => { + await page.markTrace({ name: 'test-start' }) await page.getByRole('button').click() await expect.element(page.getByRole('button')).toHaveTextContent('2') From e108db8226639f08921885417c8220cc2a75e21f Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 17 Feb 2026 17:41:31 +0900 Subject: [PATCH 11/45] chore: cleanup --- packages/browser/src/node/plugins/pluginContext.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/browser/src/node/plugins/pluginContext.ts b/packages/browser/src/node/plugins/pluginContext.ts index 98c41d1e6856..19e09e45c3e1 100644 --- a/packages/browser/src/node/plugins/pluginContext.ts +++ b/packages/browser/src/node/plugins/pluginContext.ts @@ -65,7 +65,9 @@ async function generateContextFile( const commandsCode = commands .filter(command => !command.startsWith('__vitest')) - .map(command => ` ["${command}"]: (...args) => __vitest_browser_runner__.commands.triggerCommand("${command}", args),`) + .map((command) => { + return ` ["${command}"]: (...args) => __vitest_browser_runner__.commands.triggerCommand("${command}", args),` + }) .join('\n') const userEventNonProviderImport = await getUserEventImport( From 23971d5c4b2b82a066732bd1149e9b739786b57d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 17 Feb 2026 18:04:11 +0900 Subject: [PATCH 12/45] fix: make markTrace no-op when trace is not active --- packages/browser/src/client/tester/context.ts | 4 ++ .../src/client/tester/locators/index.ts | 6 ++- packages/browser/src/client/tester/runner.ts | 48 ++++++++++--------- packages/browser/src/client/tester/tester.ts | 1 + packages/browser/src/client/utils.ts | 1 + 5 files changed, 36 insertions(+), 24 deletions(-) diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts index e4ef7bdf524c..40878a316ad0 100644 --- a/packages/browser/src/client/tester/context.ts +++ b/packages/browser/src/client/tester/context.ts @@ -345,6 +345,10 @@ export const page: BrowserPage = { )) }, markTrace(options) { + const currentTest = getWorkerState().current + if (!currentTest || !getBrowserState().activeTraceTaskIds.has(currentTest.id)) { + return Promise.resolve() + } return ensureAwaited(error => triggerCommand( '__vitest_markTrace', [{ diff --git a/packages/browser/src/client/tester/locators/index.ts b/packages/browser/src/client/tester/locators/index.ts index 467e0225a0e6..80cc71476cd6 100644 --- a/packages/browser/src/client/tester/locators/index.ts +++ b/packages/browser/src/client/tester/locators/index.ts @@ -25,7 +25,7 @@ import { } from 'ivya' import { page, server, utils } from 'vitest/browser' import { __INTERNAL } from 'vitest/internal/browser' -import { ensureAwaited, getBrowserState } from '../../utils' +import { ensureAwaited, getBrowserState, getWorkerState } from '../../utils' import { escapeForTextSelector, isLocator, resolveUserEventWheelOptions } from '../tester-utils' export { convertElementToCssSelector, getIframeScale, processTimeoutOptions } from '../tester-utils' @@ -181,6 +181,10 @@ export abstract class Locator { } public markTrace(options: { name: string }): Promise { + const currentTest = getWorkerState().current + if (!currentTest || !getBrowserState().activeTraceTaskIds.has(currentTest.id)) { + return Promise.resolve() + } return ensureAwaited(error => getBrowserState().commands.triggerCommand( '__vitest_markTrace', [{ diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index 542b4818bca0..5576a8daaf4f 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -81,16 +81,12 @@ export function createBrowserRunner( await super.onBeforeTryTask?.(...args) const trace = this.config.browser.trace const test = args[0] - if (trace === 'off') { - return - } const { retry, repeats } = args[1] - if (trace === 'on-all-retries' && retry === 0) { - return - } - if (trace === 'on-first-retry' && retry !== 1) { + if (!shouldTraceAttempt(trace, retry)) { + getBrowserState().activeTraceTaskIds.delete(test.id) return } + getBrowserState().activeTraceTaskIds.add(test.id) let title = getTestName(test) if (retry) { title += ` (retry x${retry})` @@ -112,25 +108,25 @@ export function createBrowserRunner( onAfterRetryTask = async (test: Test, { retry, repeats }: { retry: number; repeats: number }) => { const trace = this.config.browser.trace - if (trace === 'off') { - return - } - if (trace === 'on-all-retries' && retry === 0) { + if (!shouldTraceAttempt(trace, retry)) { + getBrowserState().activeTraceTaskIds.delete(test.id) return } - if (trace === 'on-first-retry' && retry !== 1) { - return + try { + const name = getTraceName(test, retry, repeats) + if (!this.traces.has(test.id)) { + this.traces.set(test.id, []) + } + const traces = this.traces.get(test.id)! + const { tracePath } = await this.commands.triggerCommand( + '__vitest_stopChunkTrace', + [{ name }], + ) as { tracePath: string } + traces.push(tracePath) } - const name = getTraceName(test, retry, repeats) - if (!this.traces.has(test.id)) { - this.traces.set(test.id, []) + finally { + getBrowserState().activeTraceTaskIds.delete(test.id) } - const traces = this.traces.get(test.id)! - const { tracePath } = await this.commands.triggerCommand( - '__vitest_stopChunkTrace', - [{ name }], - ) as { tracePath: string } - traces.push(tracePath) } onAfterRunTask = async (task: Test) => { @@ -164,7 +160,7 @@ export function createBrowserRunner( } onTaskFinished = async (task: Task) => { - if (task.result?.state === 'fail') { + if (task.result?.state === 'fail' && getBrowserState().activeTraceTaskIds.has(task.id)) { await this.commands.triggerCommand('__vitest_markTrace', [{ name: 'onTaskFinished (fail)', stack: task.result?.errors?.[0].stack, @@ -414,6 +410,12 @@ async function updateTestFilesLocations(files: File[], sourceMaps: Map traces: Traces cleanups: Array<() => unknown> cdp?: { From a746f34c0730457a912b88cc0051c27903bc2c1c Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 17 Feb 2026 18:26:31 +0900 Subject: [PATCH 13/45] chore: cleanup --- .../src/client/tester/expect/toHaveTextContent.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/browser/src/client/tester/expect/toHaveTextContent.ts b/packages/browser/src/client/tester/expect/toHaveTextContent.ts index 58b3e07b5c55..a991c3262cc6 100644 --- a/packages/browser/src/client/tester/expect/toHaveTextContent.ts +++ b/packages/browser/src/client/tester/expect/toHaveTextContent.ts @@ -31,18 +31,8 @@ export default function toHaveTextContent( const checkingWithEmptyString = textContent !== '' && matcher === '' - const pass = !checkingWithEmptyString && matches(textContent, matcher) - - // TODO: - // - markTrace each matcher failure? - // - or globally markTrace based on result.errors on test end? - // - or custom logic inside expect.element/poll? - // if (!pass == !this.isNot) { - // console.log([pass, this.isNot]) - // } - return { - pass, + pass: !checkingWithEmptyString && matches(textContent, matcher), message: () => { const to = this.isNot ? 'not to' : 'to' return getMessage( From 88c57a869e0186b56b4346c38d19c8d564624265 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 17 Feb 2026 19:04:25 +0900 Subject: [PATCH 14/45] fix: trace on onAfterRetryTask --- packages/browser/src/client/tester/runner.ts | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index 5576a8daaf4f..2af97a91458e 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -100,18 +100,16 @@ export function createBrowserRunner( '__vitest_startChunkTrace', [{ name, title }], ) - // TODO: location? - // test.location; - // test.file.filepath; - // await this.commands.triggerCommand('__vitest_markTrace', ['onBeforeTryTask']) } onAfterRetryTask = async (test: Test, { retry, repeats }: { retry: number; repeats: number }) => { - const trace = this.config.browser.trace - if (!shouldTraceAttempt(trace, retry)) { - getBrowserState().activeTraceTaskIds.delete(test.id) + if (!getBrowserState().activeTraceTaskIds.has(test.id)) { return } + await this.commands.triggerCommand('__vitest_markTrace', [{ + name: `onAfterRetryTask [${test.result?.state}]`, + stack: test.result?.errors?.[0].stack, + }]) try { const name = getTraceName(test, retry, repeats) if (!this.traces.has(test.id)) { @@ -160,12 +158,6 @@ export function createBrowserRunner( } onTaskFinished = async (task: Task) => { - if (task.result?.state === 'fail' && getBrowserState().activeTraceTaskIds.has(task.id)) { - await this.commands.triggerCommand('__vitest_markTrace', [{ - name: 'onTaskFinished (fail)', - stack: task.result?.errors?.[0].stack, - }]) - } if ( this.config.browser.screenshotFailures && document.body.clientHeight > 0 From 6b67fe127165a2978c44e892dbb20e4acf444244 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 17 Feb 2026 19:06:35 +0900 Subject: [PATCH 15/45] chore: example --- examples/lit/test/basic.test.ts | 4 +++- examples/lit/tsconfig.json | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/lit/test/basic.test.ts b/examples/lit/test/basic.test.ts index b9a86d065d56..861c7f4f9f1c 100644 --- a/examples/lit/test/basic.test.ts +++ b/examples/lit/test/basic.test.ts @@ -15,7 +15,9 @@ describe('Button with increment', async () => { await page.getByRole('button').click() await expect.element(page.getByRole('button')).toHaveTextContent('2') - // await expect.element(page.getByRole('button'), { timeout: 3000 }).toHaveTextContent('3') + if (import.meta.env.VITE_FAIL_TEST) { + await expect.element(page.getByRole('button'), { timeout: 3000 }).toHaveTextContent('3') + } }) it('should show name props', async () => { diff --git a/examples/lit/tsconfig.json b/examples/lit/tsconfig.json index 8f53fa03471a..727d195ad4c7 100644 --- a/examples/lit/tsconfig.json +++ b/examples/lit/tsconfig.json @@ -5,6 +5,7 @@ "experimentalDecorators": true, "module": "node16", "moduleResolution": "Node16", + "types": ["vite/client"], "verbatimModuleSyntax": true } } From 8ef10945b0aa145f62240b6c2691ae9908a86e5d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 17 Feb 2026 19:27:35 +0900 Subject: [PATCH 16/45] feat: call trace on expect.element --- .../src/client/tester/expect-element.ts | 27 ++++++++++++++++++- packages/vitest/src/integrations/chai/poll.ts | 4 +++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/browser/src/client/tester/expect-element.ts b/packages/browser/src/client/tester/expect-element.ts index 0200bd495755..0847ed0c233e 100644 --- a/packages/browser/src/client/tester/expect-element.ts +++ b/packages/browser/src/client/tester/expect-element.ts @@ -1,7 +1,8 @@ -import type { ExpectPollOptions, PromisifyDomAssertion } from 'vitest' +import type { Assertion, ExpectPollOptions, PromisifyDomAssertion } from 'vitest' import type { Locator } from 'vitest/browser' import { chai, expect } from 'vitest' import { getType } from 'vitest/internal/browser' +import { getBrowserState, getWorkerState } from '../utils' import { matchers } from './expect' import { processTimeoutOptions } from './tester-utils' @@ -54,6 +55,30 @@ function element(elementOrL chai.util.flag(expectElement, '_poll.element', true) + // ask `expect.poll` to invoke trace after the assertion + const currentTest = getWorkerState().current + if (currentTest && getBrowserState().activeTraceTaskIds.has(currentTest.id)) { + const sourceError = new Error('__vitest_mark_trace__') + chai.util.flag(expectElement, '_poll.onSettled', async (meta: { assertion: Assertion; status: 'pass' | 'fail' }) => { + const isNot = chai.util.flag(meta.assertion, 'negate') + const name = chai.util.flag(meta.assertion, '_name') || '' + const baseName = `expect.element().${isNot ? 'not.' : ''}${name}` + const traceName = meta.status === 'fail' ? `${baseName} [ERROR]` : baseName + const selector = !elementOrLocator || elementOrLocator instanceof Element + ? undefined + : elementOrLocator.selector + await getBrowserState().commands.triggerCommand( + '__vitest_markTrace', + [{ + name: traceName, + selector, + stack: sourceError.stack, + }], + sourceError, + ) + }) + } + return expectElement } diff --git a/packages/vitest/src/integrations/chai/poll.ts b/packages/vitest/src/integrations/chai/poll.ts index 8f0833cfdb63..bc5432c0e30b 100644 --- a/packages/vitest/src/integrations/chai/poll.ts +++ b/packages/vitest/src/integrations/chai/poll.ts @@ -95,6 +95,8 @@ export function createExpectPoll(expect: ExpectStatic): ExpectStatic['poll'] { chai.util.flag(assertion, '_name', key) + const onSettled = chai.util.flag(assertion, '_poll.onSettled') as Function | undefined + try { while (true) { const isLastAttempt = hasTimedOut @@ -110,11 +112,13 @@ export function createExpectPoll(expect: ExpectStatic): ExpectStatic['poll'] { executionPhase = 'assertion' const output = await assertionFunction.call(assertion, ...args) + await onSettled?.({ assertion, status: 'pass' }) return output } catch (err) { if (isLastAttempt || (executionPhase === 'assertion' && chai.util.flag(assertion, '_poll.assert_once'))) { + await onSettled?.({ assertion, status: 'fail' }) throwWithCause(err, STACK_TRACE_ERROR) } From b07c25d745ddf7a2e30611d0981bfa6326189564 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 17 Feb 2026 19:30:47 +0900 Subject: [PATCH 17/45] refactor: types --- packages/browser/src/node/commands/trace.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/browser/src/node/commands/trace.ts b/packages/browser/src/node/commands/trace.ts index 8a3a1340a04d..2c2b61c33314 100644 --- a/packages/browser/src/node/commands/trace.ts +++ b/packages/browser/src/node/commands/trace.ts @@ -1,4 +1,4 @@ -import type { BrowserCommand, BrowserCommandContext } from 'vitest/node' +import type { BrowserCommand } from 'vitest/node' interface MarkTracePayload { name: string @@ -15,11 +15,11 @@ declare module 'vitest/browser' { } } -export const _markTrace = (async ( - context: BrowserCommandContext, - payload: { name: string; selector?: string; stack?: string }, +export const _markTrace: BrowserCommand<[payload: MarkTracePayload]> = async ( + context, + payload, ) => { if (context.provider.name === 'playwright') { await context.triggerCommand('__vitest_markTrace', payload) } -}) as BrowserCommand<[payload: { name: string; selector?: string; stack?: string }]> +} From c970d866e18dd71468d58544d32a16574b40895d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 17 Feb 2026 19:33:18 +0900 Subject: [PATCH 18/45] refactor: cleanup --- packages/browser/src/client/tester/runner.ts | 34 ++++++++------------ 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index 2af97a91458e..839723c9a52c 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -82,7 +82,10 @@ export function createBrowserRunner( const trace = this.config.browser.trace const test = args[0] const { retry, repeats } = args[1] - if (!shouldTraceAttempt(trace, retry)) { + const shouldTrace = trace !== 'off' + && !(trace === 'on-all-retries' && retry === 0) + && !(trace === 'on-first-retry' && retry !== 1) + if (!shouldTrace) { getBrowserState().activeTraceTaskIds.delete(test.id) return } @@ -110,21 +113,16 @@ export function createBrowserRunner( name: `onAfterRetryTask [${test.result?.state}]`, stack: test.result?.errors?.[0].stack, }]) - try { - const name = getTraceName(test, retry, repeats) - if (!this.traces.has(test.id)) { - this.traces.set(test.id, []) - } - const traces = this.traces.get(test.id)! - const { tracePath } = await this.commands.triggerCommand( - '__vitest_stopChunkTrace', - [{ name }], - ) as { tracePath: string } - traces.push(tracePath) - } - finally { - getBrowserState().activeTraceTaskIds.delete(test.id) + const name = getTraceName(test, retry, repeats) + if (!this.traces.has(test.id)) { + this.traces.set(test.id, []) } + const traces = this.traces.get(test.id)! + const { tracePath } = await this.commands.triggerCommand( + '__vitest_stopChunkTrace', + [{ name }], + ) as { tracePath: string } + traces.push(tracePath) } onAfterRunTask = async (task: Test) => { @@ -402,12 +400,6 @@ async function updateTestFilesLocations(files: File[], sourceMaps: Map Date: Wed, 18 Feb 2026 12:28:58 +0900 Subject: [PATCH 19/45] fix: include sources --- packages/browser-playwright/src/commands/trace.ts | 3 +-- packages/vitest/src/node/types/browser.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/browser-playwright/src/commands/trace.ts b/packages/browser-playwright/src/commands/trace.ts index b9237c366ab4..8bb2781e1ab0 100644 --- a/packages/browser-playwright/src/commands/trace.ts +++ b/packages/browser-playwright/src/commands/trace.ts @@ -15,8 +15,7 @@ export const startTracing: BrowserCommand<[]> = async ({ context, project, provi await context.tracing.start({ screenshots: options.screenshots ?? true, snapshots: options.snapshots ?? true, - // currently, PW shows sources in private methods - sources: false, + sources: options.sources ?? true, }).catch(() => { provider.tracingContexts.delete(sessionId) }) diff --git a/packages/vitest/src/node/types/browser.ts b/packages/vitest/src/node/types/browser.ts index ca29d10b2881..dada8b46f418 100644 --- a/packages/vitest/src/node/types/browser.ts +++ b/packages/vitest/src/node/types/browser.ts @@ -405,8 +405,7 @@ export interface ResolvedBrowserOptions extends BrowserConfigOptions { tracesDir?: string screenshots?: boolean snapshots?: boolean - // TODO: map locations to test ones - // sources?: boolean + sources?: boolean } } From 4651fd7799d181b118b29bc449ea86c54930c5b8 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 18 Feb 2026 19:12:26 +0900 Subject: [PATCH 20/45] feat: simplify browser trace mark API Rename page/locator markTrace and options-object calls to a single mark(name) signature so trace anchors are easier to call and consistent across page and locator APIs. --- examples/lit/test/basic.test.ts | 6 +++--- packages/browser/context.d.ts | 4 ++-- packages/browser/src/client/tester/context.ts | 4 ++-- packages/browser/src/client/tester/locators/index.ts | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/examples/lit/test/basic.test.ts b/examples/lit/test/basic.test.ts index 861c7f4f9f1c..c2ed1b578a8a 100644 --- a/examples/lit/test/basic.test.ts +++ b/examples/lit/test/basic.test.ts @@ -6,12 +6,12 @@ import '../src/my-button.js' describe('Button with increment', async () => { beforeEach(async () => { document.body.innerHTML = '' - await page.getByRole('button').markTrace({ name: 'render' }) - await page.getByRole('heading').markTrace({ name: 'heading' }) + await page.getByRole('button').mark('render') + await page.getByRole('heading').mark('heading') }) it('should increment the count on each click', async () => { - await page.markTrace({ name: 'test-start' }) + await page.mark('test-start') await page.getByRole('button').click() await expect.element(page.getByRole('button')).toHaveTextContent('2') diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index 747de4d0fb80..10910e497831 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -631,7 +631,7 @@ export interface Locator extends LocatorSelectors { * Add a trace marker for this locator. * Works best with providers that support tracing. */ - markTrace(options: { name: string }): Promise + mark(name: string): Promise /** * Returns an element matching the selector. @@ -786,7 +786,7 @@ export interface BrowserPage extends LocatorSelectors { * Add a trace marker. * Works best with providers that support tracing. */ - markTrace(options: { name: string }): Promise + mark(name: string): Promise /** * Extend default `page` object with custom methods. */ diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts index 40878a316ad0..cba3aee49b4a 100644 --- a/packages/browser/src/client/tester/context.ts +++ b/packages/browser/src/client/tester/context.ts @@ -344,7 +344,7 @@ export const page: BrowserPage = { error, )) }, - markTrace(options) { + mark(name) { const currentTest = getWorkerState().current if (!currentTest || !getBrowserState().activeTraceTaskIds.has(currentTest.id)) { return Promise.resolve() @@ -352,7 +352,7 @@ export const page: BrowserPage = { return ensureAwaited(error => triggerCommand( '__vitest_markTrace', [{ - name: options.name, + name, stack: error?.stack, }], error, diff --git a/packages/browser/src/client/tester/locators/index.ts b/packages/browser/src/client/tester/locators/index.ts index 80cc71476cd6..41f0b9a6d973 100644 --- a/packages/browser/src/client/tester/locators/index.ts +++ b/packages/browser/src/client/tester/locators/index.ts @@ -180,7 +180,7 @@ export abstract class Locator { }) } - public markTrace(options: { name: string }): Promise { + public mark(name: string): Promise { const currentTest = getWorkerState().current if (!currentTest || !getBrowserState().activeTraceTaskIds.has(currentTest.id)) { return Promise.resolve() @@ -188,7 +188,7 @@ export abstract class Locator { return ensureAwaited(error => getBrowserState().commands.triggerCommand( '__vitest_markTrace', [{ - name: options.name, + name, selector: this.selector, stack: error?.stack, }], From b60649019ad4bca0b6e3dc146f0a3e6db34f43f3 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 18 Feb 2026 19:13:04 +0900 Subject: [PATCH 21/45] docs: tweak --- packages/browser/context.d.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index 10910e497831..111385c0271e 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -629,7 +629,6 @@ export interface Locator extends LocatorSelectors { /** * Add a trace marker for this locator. - * Works best with providers that support tracing. */ mark(name: string): Promise @@ -784,7 +783,6 @@ export interface BrowserPage extends LocatorSelectors { }> /** * Add a trace marker. - * Works best with providers that support tracing. */ mark(name: string): Promise /** From 9e58b72b7c1565f1de65aece16f99881af4ae5c2 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 18 Feb 2026 19:27:59 +0900 Subject: [PATCH 22/45] docs: add browser trace mark documentation Document page/locator mark APIs, clarify trace-source limitations, and note marker-backed events as the reliable source-linked path in trace view. --- docs/api/browser/context.md | 24 ++++++++++++++++++++++++ docs/api/browser/locators.md | 22 ++++++++++++++++++++++ docs/guide/browser/trace-view.md | 22 +++++++++++++++++++++- packages/browser/context.d.ts | 4 ++-- 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/docs/api/browser/context.md b/docs/api/browser/context.md index c6cf0f8887ac..cf618714764e 100644 --- a/docs/api/browser/context.md +++ b/docs/api/browser/context.md @@ -79,6 +79,10 @@ export const page: { base64: string }> screenshot(options?: ScreenshotOptions): Promise + /** + * Add a trace marker when browser tracing is enabled. + */ + mark(name: string): Promise /** * Extend default `page` object with custom methods. */ @@ -116,6 +120,26 @@ Note that `screenshot` will always return a base64 string if `save` is set to `f The `path` is also ignored in that case. ::: +### mark + +```ts +function mark(name: string): Promise +``` + +Adds a named marker to the trace timeline for the current test. + +```ts +import { page } from 'vitest/browser' + +await page.mark('before submit') +await page.getByRole('button', { name: 'Submit' }).click() +await page.mark('after submit') +``` + +::: tip +This method is useful only when [`browser.trace`](/config/browser/trace) is enabled. +::: + ### frameLocator ```ts diff --git a/docs/api/browser/locators.md b/docs/api/browser/locators.md index f81e040f8bcc..421abf1581d7 100644 --- a/docs/api/browser/locators.md +++ b/docs/api/browser/locators.md @@ -820,6 +820,28 @@ Note that `screenshot` will always return a base64 string if `save` is set to `f The `path` is also ignored in that case. ::: +### mark + +```ts +function mark(name: string): Promise +``` + +Adds a named marker to the trace timeline and uses the current locator as marker context. + +```ts +import { page } from 'vitest/browser' + +const submitButton = page.getByRole('button', { name: 'Submit' }) + +await submitButton.mark('before submit') +await submitButton.click() +await submitButton.mark('after submit') +``` + +::: tip +This method is useful only when [`browser.trace`](/config/browser/trace) is enabled. +::: + ### query ```ts diff --git a/docs/guide/browser/trace-view.md b/docs/guide/browser/trace-view.md index 20ff556898bc..d3e3b4573c9e 100644 --- a/docs/guide/browser/trace-view.md +++ b/docs/guide/browser/trace-view.md @@ -57,6 +57,22 @@ export default defineConfig({ 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. +## Trace markers + +You can add explicit named markers to make the trace timeline easier to read: + +```ts +import { page } from 'vitest/browser' + +document.body.innerHTML = ` + +` + +await page.getByRole('button', { name: 'Sign in' }).mark('sign in button rendered') +``` + +Both `page.mark(name)` and `locator.mark(name)` are available. + ## Preview To open the trace file, you can use the Playwright Trace Viewer. Run the following command in your terminal: @@ -71,4 +87,8 @@ Alternatively, you can open the Trace Viewer in your browser at https://trace.pl ## Limitations -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. +Trace Viewer source linking is currently partially supported. + +Regular Playwright action events (for example `locator.click()`) might not include source entries, while marker-backed events do. `page.mark(name)`, `locator.mark(name)`, and automatic markers from `expect.element(...)` include callsite metadata and are the most reliable way to correlate trace steps with test source. + +Non-browser assertions (for example `expect(value).toBe(...)`) don't interact with the browser and won't create browser trace markers. diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index 111385c0271e..e04c379557bd 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -628,7 +628,7 @@ export interface Locator extends LocatorSelectors { screenshot(options?: LocatorScreenshotOptions): Promise /** - * Add a trace marker for this locator. + * Add a trace marker for this locator when browser tracing is enabled. */ mark(name: string): Promise @@ -782,7 +782,7 @@ export interface BrowserPage extends LocatorSelectors { base64: string }> /** - * Add a trace marker. + * Add a trace marker when browser tracing is enabled. */ mark(name: string): Promise /** From 8169fbdd146717df474a4fa7c9cbe91b13f012a1 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 18 Feb 2026 19:30:35 +0900 Subject: [PATCH 23/45] docs: add mark API links in JSDoc Add @see links for page.mark and locator.mark to match surrounding browser context API documentation references. --- packages/browser/context.d.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index e04c379557bd..1aa03bf426f2 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -629,6 +629,7 @@ export interface Locator extends LocatorSelectors { /** * Add a trace marker for this locator when browser tracing is enabled. + * @see {@link https://vitest.dev/api/browser/locators#mark} */ mark(name: string): Promise @@ -783,6 +784,7 @@ export interface BrowserPage extends LocatorSelectors { }> /** * Add a trace marker when browser tracing is enabled. + * @see {@link https://vitest.dev/api/browser/context#mark} */ mark(name: string): Promise /** From f2cd343ae79ce4d90c6c624041cd364d32965607 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Feb 2026 11:46:46 +0900 Subject: [PATCH 24/45] docs: add locator.describe tracing notes Document how Playwright locator.describe works internally and why it complements but does not replace Vitest mark-based trace anchoring. --- .dev-notes/dist/playwright-tracing.md | 671 ++++++++++++++++++++++++++ 1 file changed, 671 insertions(+) create mode 100644 .dev-notes/dist/playwright-tracing.md diff --git a/.dev-notes/dist/playwright-tracing.md b/.dev-notes/dist/playwright-tracing.md new file mode 100644 index 000000000000..f29b95dfa827 --- /dev/null +++ b/.dev-notes/dist/playwright-tracing.md @@ -0,0 +1,671 @@ +# Playwright Tracing Findings + +Playwright source in `~/code/others/playwright` + +## `test.step` implementation + +- `test.step` is implemented in `packages/playwright/src/common/testType.ts`. +- Wiring: + - `test.step = this._step.bind(this, 'pass')` + - `test.step.skip = this._step.bind(this, 'skip')` +- Core logic is in `TestTypeImpl._step(...)`. + +## How `test.step` relates to tracing + +- `test.step` creates a runner step via `testInfo._addStep(...)` and executes within `stepZone`. +- When a step starts/ends, `TestInfo` emits trace events through: + - `appendBeforeActionForStep(...)` + - `appendAfterActionForStep(...)` +- Those are recorded by `TestTracing` as trace `before/after` events with runner semantics (`class: 'Test'`, method = step category). + +## `TestTracing` vs core `Tracing` + +### `TestTracing` (`packages/playwright/src/worker/testTracing.ts`) + +- Per-test, runner-owned trace stream (`origin: 'testRunner'`). +- Captures test-level semantics: + - `test.step`, `expect` steps, hooks/fixtures + - test errors + - stdout/stderr + - test attachments +- Applies retention policy (`on`, `retain-on-failure`, retries variants). +- Produces final `trace.zip` attached to test output. + +### core `Tracing` (`packages/playwright-core/src/server/trace/recorder/tracing.ts`) + +- Per-browser-context / API-request-context recorder (`origin: 'library'`). +- Captures Playwright protocol/library actions, network HAR entries, snapshots, screencast frames, and resources. +- Handles chunking (`startChunk/stopChunk`) and resource collection. + +## How both streams are stitched together + +- API call steps receive a `stepId` in client instrumentation. +- `stepId` flows through client metadata -> protocol -> server call metadata -> trace events. +- Trace model merges runner and library actions by shared `stepId`. +- Result: trace viewer shows unified timeline/tree with test semantics + low-level browser actions. + +## Is `trace.TraceEvent` public API? + +- Underlying event union is defined in `packages/trace/src/trace.ts`. +- This is imported internally via tsconfig path alias (`@trace/*`). +- It is not exported as a documented stable public API from npm package exports. +- Practical implication: treat trace JSON/event schema as internal/versioned contract (currently version 8), not guaranteed stable as public API. + +## What Playwright Test adds over manual `context.tracing` + +- Manual tracing API captures browser/library operations and network activity. +- Playwright Test tracing adds runner-level semantics and artifacts: + - assertions / `expect` steps + - test steps and hierarchy + - hooks/fixtures + - test-level errors, stdio, attachments + - per-test retention behavior and automatic lifecycle management + +## Snapshot recording: what triggers it + +- Snapshots are triggered by instrumented API calls marked with `snapshot: true` in protocol metainfo. +- Snapshot capture happens in tracing instrumentation callbacks (`onBeforeCall`, `onBeforeInputAction`, `onAfterCall`) when enabled. +- `tracing.group()` / `tracing.groupEnd()` only create grouping structure; they do **not** directly trigger snapshots. +- There is no public API to explicitly "capture snapshot now". + +## Assertion behavior in traces + +- `expect(locator).toBeVisible()` becomes a protocol `Frame.expect` call via `locator._expect(...)`. +- `Frame.expect` is marked snapshot-eligible, so with `snapshots: true` it captures snapshots around that call. +- In Playwright Test, this is also represented as runner `expect` step and linked to library events through `stepId`. +- Non-locator assertions (pure JS value assertions) do not issue browser protocol calls, so they do not trigger library snapshots. + +## Workaround for explicit-ish snapshot point + +- Since there is no explicit snapshot API, perform a benign snapshot-eligible Playwright action while tracing is on. +- Examples: + - `await page.locator('body').click({ position: { x: 1, y: 1 } });` + - `await expect(page.locator('...')).toBeVisible();` (locator-based expect) + +## Vitest integration direction (public API only) + +- Current limitation in Vitest browser runner integration: traces depend heavily on user actions that hit snapshot-eligible Playwright APIs. +- `tracing.group()` / `tracing.groupEnd()` can improve structure/readability, but do not produce snapshots by themselves. +- Direction: + - keep using `start/startChunk/stopChunk` for lifecycle and retention policy; + - use `group/groupEnd` for semantic grouping only; + - add explicit Vitest-level trace anchors ("marks") that intentionally execute a low-impact snapshot-eligible action to create deterministic snapshot points. +- Candidate anchor points: + - before test body; + - after test body; + - around retries/repeats; + - before teardown/failure artifact collection. +- Design goal: deterministic trace density and better debugging even when tests do not call interaction APIs (or only perform non-browser assertions). + +## Should Playwright expose a public `tracing.mark(...)` API? + +- Requesting this upstream is reasonable and aligns with real runner integration needs outside Playwright Test. +- Expected value: + - deterministic, user-controlled trace checkpoints; + - no need for synthetic user interactions as snapshot workaround; + - portable semantics for frameworks that cannot rely on private Playwright internals. +- But implementation is not fully trivial: + - API needs cross-language design parity (JS/TS, Python, Java, .NET); + - behavior must be defined for browser contexts vs API request contexts; + - semantics for `snapshot: true/false`, stack/location, and trace-viewer rendering need to be stable; + - side-effect guarantees are important (mark should not mutate page state); + - interaction with existing `group/groupEnd` and chunk boundaries should be specified. +- Practical complexity estimate: + - API surface and docs: moderate; + - protocol + server/client plumbing: moderate; + - making it robust and backward-compatible across bindings/viewer: moderate-to-high. + +### Minimal viable shape to propose + +- Conceptual API: + - `await context.tracing.mark(name, { snapshot?: boolean, location?: { file, line, column }, metadata?: Record })` +- Recommended semantics: + - always emits an explicit marker event visible in Trace Viewer; + - optional `snapshot: true` captures a DOM snapshot at the mark point; + - no user-visible page interaction side effects. + +## Practical Vitest workaround today: snapshot poke action + +- If only public Playwright API is available, the best low-impact snapshot anchor is: + - `await page.evaluate(() => 0)` +- Rationale: + - routes through `Frame.evaluateExpression`, which is snapshot-eligible in protocol metainfo; + - typically no DOM mutation and minimal side effects; + - avoids synthetic click/focus interactions that can trigger app handlers. +- Usage note: + - ensure tracing started with `snapshots: true`; + - perform after a page exists; + - optionally wrap with `tracing.group()/groupEnd()` to label the anchor in the viewer. + +Example: + +```ts +await context.tracing.group('before assertion phase') +try { + await page.evaluate(() => 0) +} +finally { + await context.tracing.groupEnd() +} +``` + +Alternative (usually safe but less preferred): + +- `await page.locator('html').isVisible()` +- `await page.content()` + +Avoid as default workaround: + +- synthetic click/tap-based pokes, because they may trigger handlers, focus changes, or scrolling. + +## Vitest wrapper callsite in trace: approach matrix + +Goal: show end-user test source location (not Vitest wrapper internals) for actions like wrapped `click`. + +### 1) Preferred if available: explicit location option on API + +- If the API supports `location` (for example `tracing.group(..., { location })`), pass the test callsite explicitly. +- Pros: + - deterministic and clear; + - no global runtime side effects. +- Cons: + - only works on APIs that expose a location option; + - does not help generic Playwright actions like `locator.click()` directly. + +### 2) Internal Playwright mechanism: boxed stack prefixes + +- Playwright filters stack frames using an internal prefix list (`setBoxedStackPrefixes(...)`). +- `@playwright/test` uses this internally to hide runner/framework frames. +- Idea for Vitest POC: register Vitest wrapper directories as boxed prefixes so Playwright selects the next user frame. +- Pros: + - aligns with Playwright's own frame-filtering strategy; + - works for ordinary actions (`locator.click`, etc.) without per-call metadata. +- Cons: + - private/internal API (`playwright-core/lib/utils`), not stability-guaranteed; + - may break on Playwright upgrades and across packaging variants. + +#### Critical limitation for Vitest browser mode + +- `setBoxedStackPrefixes(...)` only filters frames that are already present in the runtime stack where Playwright API call executes. +- In Vitest browser mode architecture, end-user test code frame and `locator.click()` execution can be in different runtimes. +- Therefore, end-user frames may not exist in Playwright-captured stack at all. +- Implication: boxed prefixes can hide wrapper/internal frames, but cannot recover or synthesize missing end-user frames. +- Conclusion for this architecture: boxed prefixes are not a complete solution for true end-user callsite attribution. + +### 3) Global `Error.prepareStackTrace` interception + +- Technically can influence stack formatting/parsing, but strongly discouraged. +- Risks: + - global process-wide side effects; + - interference with test runner/tooling; + - async timing hazards and hard-to-debug behavior. +- Treat as experimental-only, not production approach. + +### 4) Public API direction (upstream) + +- A future `tracing.mark(...)` (or equivalent action-level location override) would provide stable explicit callsite/anchor semantics. +- This is the most maintainable long-term approach for non-Playwright-test runners. + +## Recommendation for Vitest (today) + +- Near-term POC: boxed stack prefixes may still improve noise, but are insufficient for true end-user callsite when runtimes are split. +- Medium-term: keep explicit trace anchors (`group` + snapshot poke) for deterministic snapshots. +- Long-term: propagate explicit user-runtime location metadata (where possible) and push upstream for stable public marker/callsite APIs. + +## Appendix: Playwright snapshot format and Trace Viewer reconstruction + +### Is this browser-native replay? + +- No. Playwright does not use a browser-native "replay" artifact for trace snapshots. +- Snapshot capture/reconstruction is mostly Playwright-owned: + - capture pipeline in injected script + server-side recorder; + - custom serialized DOM/resource model in trace events; + - Trace Viewer-side HTML reconstruction + post-processing script. + +### Snapshot data model (what is stored) + +- Trace event type: `frame-snapshot` with payload `FrameSnapshot`. +- Core fields include: + - `callId`, `snapshotName`, `pageId`, `frameId`, `frameUrl`, `timestamp`, `viewport`, `doctype`; + - `html: NodeSnapshot`; + - `resourceOverrides`. +- `NodeSnapshot` is compact and incremental: + - text node: string; + - element subtree: tuple-like `[tagName, attrs?, ...children]`; + - subtree reference: `[[deltaSnapshots, nodeIndex]]` (reuses node from prior snapshot). + +### Capture pipeline (high level) + +- `Tracing` decides when to capture (`before/input/after`) based on snapshot-eligible protocol metainfo. +- `Snapshotter` injects `frameSnapshotStreamer(...)` into pages and calls `captureSnapshot(...)` in each frame. +- Injected capture performs DOM traversal + state extraction, including: + - shadow DOM encoding; + - form values/checked/selected state; + - scroll positions; + - iframe linkage; + - stylesheet/adopted stylesheet capture and CSS override tracking; + - URL sanitization and security-related filtering. +- Resource/content blobs are stored in trace resources and referenced by sha1. + +### Reconstruction in Trace Viewer + +- Viewer loads snapshots/resources into `SnapshotStorage` and resolves by `(pageOrFrameId, snapshotName)`. +- `SnapshotRenderer` rebuilds HTML from `NodeSnapshot` recursively, resolving subtree references against earlier snapshots. +- Viewer serves reconstructed HTML via `/snapshot/...` and resources via `/sha1/...` or snapshot resource resolution (`SnapshotServer`). +- A post-load script then restores runtime-ish state in the rendered snapshot document: + - input values, checked/selected state; + - scroll offsets; + - shadow roots/adopted stylesheets; + - iframe nested snapshot URLs. +- Result: replay-like inspection, but it is deterministic reconstruction from recorded DOM + resources (not video replay). + +### Action target highlighting in snapshots + +- Highlighted "action element" is implemented via explicit metadata, not inferred visually. +- During action resolution/execution, Playwright marks matched target elements with current `callId` via injected events: + - `__playwright_mark_target__` / `__playwright_unmark_target__`. +- Snapshot capture records this as element attribute `__playwright_target__` inside serialized DOM. +- Trace Viewer script finds nodes matching the relevant target id and applies outline/background highlight. + +### Pointer/click marker + +- Pointer dot comes from action metadata `point` (recorded on input/after action events), passed to snapshot URL params (`pointX`, `pointY`). +- Snapshot script renders pointer indicator; if target element exists and layout differs, viewer may center marker on target and annotate mismatch. + +### Practical implication for Vitest + +- What we can influence with public API today: + - when snapshots get captured (through snapshot-eligible calls); + - grouping/labels (`group/groupEnd`) for readability; + - deterministic anchors (`page.mark` + low-impact snapshot poke). +- What we cannot directly control via stable public API: + - direct custom injection of Playwright target-highlight metadata; + - arbitrary explicit snapshot capture primitive separate from snapshot-eligible actions. + +## Appendix: mark/target highlight API layering notes + +Context for current draft (`group` + `mark`) in Playwright tracing recorder: + +- `Tracing.group(...)` and `Tracing.mark(...)` are implemented in recorder layer and emit synthetic `before/after` action events with `class: 'Tracing'` and methods `tracingGroup` / `tracingMark`. +- `mark` currently supports optional `snapshot` + `location`, and captures `beforeSnapshot = mark@` when snapshotter is active. + +### How action layer currently communicates with tracing/snapshot layer + +- Communication is primarily via instrumentation callbacks + `CallMetadata`: + - dispatcher emits `onBeforeCall` / `onAfterCall` around protocol calls; + - DOM/frame action paths emit `onBeforeInputAction` for input phases; + - recorder (`Tracing`) listens and writes trace events + triggers snapshot capture. +- Target highlighting uses a separate but coordinated path: + - action/locator resolution in `frames.ts` / `dom.ts` calls injected `markTargetElements(..., callId)`; + - injected script marks target nodes (`__playwright_target__ = callId`) via custom events; + - snapshotter serializes those attributes into `frame-snapshot`; + - viewer highlights nodes matching `[__playwright_target__=""]`. + +### Implication for `tracing.mark(...)` API design + +- Adding arbitrary metadata to mark events is straightforward via `BeforeActionTraceEvent.params` and will appear in Trace Viewer call details. +- But element highlight is not just event metadata: + - passing `params.targetSelector` alone does not produce snapshot highlight; + - highlight requires target marking in page/injected world before snapshot capture. +- Therefore, extending `context.tracing.mark(...)` with element targeting would couple tracing layer to DOM/action concerns (potential layering smell). + +### Practical recommendation (current stance) + +- Keep `context.tracing.mark(...)` minimal and timeline-oriented (name + optional snapshot + optional location). +- If highlight is desired, prefer one of: + - action-layer API that already has selector/element resolution semantics; + - a separate explicit API near page/locator domain, not in generic tracing surface. +- If upstream still wants mark-level highlight, it should be specified as best-effort and internally routed through existing `markTargetElements(..., callId)` pipeline, not solely as trace event params. + +### Locator-level hook idea (future ask) + +Vitest-specific context: + +- Vitest browser-playwright commands already operate at locator level (e.g. `context.iframe.locator(selector).click(...)`), which naturally enters Playwright action instrumentation and trace pipeline. +- Additional Vitest need is assertion-timing anchors with selector context (not generic tracing marks). + +Design direction: + +- Keep `context.tracing.mark(...)` as a pure timeline primitive. +- Add a separate locator/page-level `mark` API for element-aware snapshots/highlight. +- This preserves layering: selector resolution and DOM marking stay in frame/locator domain. + +Candidate API shapes (in increasing ambition): + +1) Minimal locator anchor + +- `await locator.mark({ name?: string, snapshot?: boolean })` +- Semantics: + - resolve selector using strict locator path; + - call existing injected `markTargetElements(new Set(elements), callId)`; + - emit trace action event with optional title/name; + - optionally capture snapshot at this anchor. + +2) Page/frame selector anchor + +- `await page.mark(selector, { strict?: boolean, name?: string, snapshot?: boolean })` +- Same internals, but string-selector entry point for non-locator ecosystems. + +3) Assertion-integrated anchor (runner/internal) + +- Reuse expect path (`Frame.expect`-style internals) to anchor around assertion attempts/failures. +- Useful for tools like Vitest that already hold selector + matcher context. +- This can remain internal/private API if public surface is undesirable. + +Expected behavior contract + +- Best effort only: if target resolution fails, still record timeline action; skip highlight. +- No persistent DOM side effects beyond transient mark attributes used for snapshot serialization. +- Use existing `callId` correlation so viewer works without new snapshot schema. + +Why this is preferable to `tracing.mark({ target })` + +- Avoids pulling selector/element semantics into context-tracing API. +- Reuses mature locator/action code paths that already handle strictness, frame targeting, and mark-to-snapshot plumbing. +- Aligns with Vitest architecture where browser commands originate from locator-level operations. + +Near-term Vitest workaround (without new Playwright API) + +- Continue using `page.mark` for generic timeline points. +- For selector-aware anchors, trigger a low-impact locator call that goes through selector resolution paths that invoke `markTargetElements` (careful per-method behavior), then pair with mark/group as needed. + +## Appendix: `Frame.expect` / `locator._expect` mechanics + +### How assertions connect to trace/snapshot + +- Playwright assertions are wired through protocol action `Frame.expect`, not directly through snapshotter calls inside matcher code. +- High-level matcher flow: + - `expect(locator).toX(...)` in `playwright/src/matchers/matchers.ts` calls `locator._expect('', options)`. + - `locator._expect(...)` forwards `{ selector: this._selector, ... }` to `frame._expect(...)`. + - client `frame._expect(...)` sends protocol `Frame.expect`. + - server dispatcher calls `frame.expect(progress, selector, options)`. +- Since `Frame.expect` is a regular instrumented protocol call and metainfo marks it as `snapshot: true`, tracing captures snapshots naturally via `onBeforeCall/onAfterCall` instrumentation path. + +### Is `Frame.expect` selector-aware? + +- Yes. `Frame.expect` receives selector and resolves it in frame context. +- Inside `_expectInternal`, server code evaluates injected script with selector info and current `callId`. +- It explicitly calls `injected.markTargetElements(new Set(elements), callId)` before running expectation logic. +- This is why snapshots can highlight target elements for expect steps. + +### What `expression` looks like + +- `expression` is an internal matcher opcode string consumed by injected expect engine. +- Common values include: + - `to.be.visible`, `to.be.hidden`, `to.be.checked`, `to.be.attached`; + - `to.have.text`, `to.have.text.array`, `to.contain.text.array`; + - `to.have.attribute`, `to.have.attribute.value`, `to.have.css`, `to.have.count`; + - `to.have.value`, `to.have.values`, `to.have.title`, `to.have.url`, `to.match.aria`. + +### Workaround potential for Vitest + +- Technical workaround: call `locator._expect(...)` from Vitest to trigger: + - selector resolution + target marking; + - snapshot-eligible `Frame.expect` action; + - natural trace entry with highlight. +- Trade-off: `_expect` is private/internal API (underscore) and not a stable public contract. +- Recommendation: treat `_expect`-based integration as a pragmatic short-term workaround only; keep pursuing public API shape for long-term stability. + +### Concrete short-term workaround shape + +- Goal: create selector-aware trace anchor with highlight, while preserving custom Vitest mark title/location. +- Suggested sequence: + 1. `context.tracing.group(name, { location })` + 2. `await (context.iframe.locator(selector) as any)._expect('to.be.attached', { isNot: false, timeout: 1 })` + 3. `context.tracing.groupEnd()` in `finally` +- Why this works: + - `_expect` routes to `Frame.expect` (snapshot-enabled action); + - server `Frame.expect` resolves selector and calls `markTargetElements(..., callId)`; + - trace snapshot captures target metadata; viewer highlights target. +- Safety notes: + - wrap in `try/catch/finally` so trace anchoring never changes test result; + - feature-detect `_expect` and fallback to existing no-op snapshot poke if missing; + - treat this as temporary due to private API status. + - use test iframe locator (`context.iframe.locator`) rather than top-level page locator. + - avoid `timeout: 0` for this internal anchor call (`0` means no timeout in Playwright); use a tiny bounded timeout (e.g. `1`). + +### Validation note (important) + +- Manual validation shows this iframe `locator._expect('to.be.attached', { timeout: 1 })` approach already produces the exact desired trace-view UX (snapshot timing + target highlight). +- Conclusion: Playwright tracing internals are already sufficient; the main gap is public API surface/stability for this pattern. + +## Appendix: upstream RFC draft (Playwright) + +### Title + +`[Feature]: Custom tracing marker API with selector and snapshot` + +### 🚀 Feature Request + +A public API on `page` and `locator` for inserting custom markers into Playwright traces, with optional DOM snapshot capture and selector-aware element highlighting. + +```ts +await page.mark(name, { snapshot?, location? }) +await locator.mark(name, { snapshot?, location? }) +``` + +- `page.mark(name)` — inserts a named timeline marker visible in Trace Viewer, with optional snapshot capture. +- `locator.mark(name)` — same, but also resolves the locator's selector and highlights the target element in the snapshot. This is the same highlight behavior that `expect(locator)` assertions already produce internally. + +### Example + +```ts +// page-level marker (snapshot, no element highlight) +await page.mark('before assertion phase', { + location: { file: testFile, line: 42 }, + snapshot: true, +}) + +// locator-level marker (snapshot with element highlight) +await page.locator('#submit-button').mark('assertion target', { + snapshot: true, +}) +``` + +### Motivation (short version) + +We are enhancing Playwright trace support in Vitest browser mode (in https://github.com/vitest-dev/vitest/pull/9652). There is currently no stable way to insert custom trace markers with snapshots or element highlighting. Our workarounds today: + +```ts +// page.mark workaround — snapshot without element highlight +await context.tracing.group(name, { location }) +await page.evaluate(() => 0) +await context.tracing.groupEnd() + +// locator.mark workaround — snapshot with element highlight (private API) +await context.tracing.group(name, { location }) +await (locator as any)._expect('to.be.attached', { isNot: false, timeout: 1 }) +await context.tracing.groupEnd() +``` + +The `page.evaluate(() => 0)` workaround is a no-op just to trigger a snapshot-eligible action, and `locator._expect(...)` is a private API that may break across versions. + +Public `page.mark()` / `locator.mark()` APIs would make this a stable, first-class capability for the broader Playwright ecosystem. We are happy to help prototype or test any proposed API shape. + +### Motivation (detailed) + +Third-party test runners that use Playwright's library tracing can produce rich traces today, but there is no stable public API for inserting custom markers with snapshot capture or selector-aware element highlighting. + +Playwright internals already support the needed behavior — snapshot capture around instrumented protocol calls, and target element highlighting via the existing mark-target pipeline. We validated that calling internal expect APIs from an iframe context produces the exact desired Trace Viewer output (timeline entry, snapshot, element highlight). The main gap is a stable public API surface for this pattern. + +Our current workaround depends on the private `locator._expect(...)` API, which may change without notice across Playwright versions. Public `page.mark()` / `locator.mark()` APIs would: + +- Let external runners insert named trace markers with snapshots without side-effect-laden workarounds. +- Enable selector-aware snapshot points with element highlighting through stable, documented APIs. +- Benefit the broader Playwright ecosystem beyond `@playwright/test`. + +We treat our current workaround as a stopgap and will migrate to public APIs once available. We are happy to help prototype or test any proposed API shape. + +## Appendix: upstream bug report draft (`sources` + `group location`) + +### Proposed issue title + +- Tracing source packaging misses files referenced by `tracing.group({ location })` when using `tracing.start({ sources: true })` + +### Problem statement + +- With library tracing (`context.tracing`), `start({ sources: true })` does not always include source files for stack locations emitted by tracing APIs. +- In particular, locations provided via `tracing.group(name, { location })` can appear in trace event stacks, but corresponding `resources/src@.txt` entries may be missing from the final trace zip. + +### Why this is user-visible + +- Local `playwright show-trace` appears to work because Source tab falls back to local `file?path=...` endpoint. +- Hosted `https://trace.playwright.dev` cannot read local filesystem and therefore only shows sources present in archive resources. +- Result: same trace can show source locally but not in hosted viewer. + +### Minimal repro outline + +1. `await context.tracing.start({ snapshots: true, sources: true })` +2. `await context.tracing.startChunk({ title: 'repro' })` +3. `await context.tracing.group('anchor', { location: { file: '/abs/path/to/test.ts', line: 10, column: 1 } })` +4. optional low-impact snapshot action (e.g. `await page.evaluate(() => 0)`) +5. `await context.tracing.groupEnd()` +6. `await context.tracing.stopChunk({ path: 'trace.zip' })` +7. open in `https://trace.playwright.dev` and inspect Source tab for group action + +Expected: + +- source for `/abs/path/to/test.ts` is available from bundled `resources/src@...`. + +Actual: + +- location is visible in stack, but source may be unavailable unless local-file fallback exists. + +### Suspected root cause + +- Source inclusion for `start({ sources: true })` is implemented in local zip step (`localUtils.zip`) by collecting files from `stackSession.callStacks`. +- `group` locations are written into trace events, but do not necessarily feed `stackSession.callStacks` source collection. +- Therefore event-stack file paths and packaged source-file set can diverge. + +## Appendix: solution ideas (upstream) + +### Option A: targeted fix (smallest) + +- When processing `tracing.group` with `location.file`, register that file path in the same source-file collector used by `includeSources`. +- Pros: minimal behavioral change, small patch, direct bug fix. +- Cons: adds one more special path into collector plumbing. + +### Option B: packaging robustness fix + +- During zip with `includeSources`, also collect source paths from trace event stacks (including synthetic tracing actions like group), not only `stackSession.callStacks`. +- Pros: resilient to future tracing actions carrying explicit locations. +- Cons: extra parsing/plumbing; should dedupe and avoid perf regressions. + +### Option C: instrumentation unification (larger) + +- Route `group` through the same client-side stack capture/instrumentation channel as ordinary API calls, so one source collector naturally covers all. +- Pros: cleaner architecture long-term. +- Cons: bigger refactor with higher risk. + +### Recommended upstream path + +- Start with Option A for quick correctness fix. +- Consider Option B afterward for defense-in-depth and future-proofing. + +## Appendix: Why `show-trace` shows source but `trace.playwright.dev` does not + +Observed behavior: + +- Local CLI viewer (`pnpm exec playwright show-trace ...`) can show user source files even when they are not inside the trace zip. +- Hosted PWA viewer (`https://trace.playwright.dev`) only shows sources that are actually embedded in the trace archive. + +### Source loading path in Trace Viewer UI + +In `packages/trace-viewer/src/ui/sourceTab.tsx`, source loading is: + +1. Try embedded source first: + +- fetch `sha1/src@.txt` from trace resources. + +2. If missing (404), fallback to host file endpoint: + +- fetch `file?path=`. + +So the viewer itself has a two-step strategy: bundled source first, local file fallback second. + +### What `show-trace` provides that hosted viewer cannot + +`show-trace` starts a local HTTP server (`packages/playwright-core/src/server/trace/viewer/traceViewer.ts`) with a `/trace/file?path=...` route that reads from local disk via Node `fs`. + +That means local viewer can resolve source content directly from your machine when `resources/src@...` is missing. + +`trace.playwright.dev` runs as a PWA/service-worker app and does not have a Node file-serving endpoint for your local filesystem. It can only read what is in the uploaded trace blob (`.trace/.network/resources/*`). + +### Why user TS source can still be absent from zip with `sources: true` + +Even when tracing is started with `sources: true`, source embedding is driven by Playwright's stack-session zip step (`localUtils.zip`), which collects files only from `stackSession.callStacks` and writes them as `resources/src@.txt`. + +Important limitation: + +- This collection does **not** scan all stack file paths present in trace events. +- In particular, explicit location stacks injected via APIs like `tracing.group(..., { location })` can appear in trace event `stack`, yet not be included in `stackSession.callStacks` source collection. + +Net effect: + +- Local `show-trace`: still shows these files through `/trace/file` fallback. +- Hosted `trace.playwright.dev`: cannot load them unless `resources/src@...` exists in the zip. + +### Practical conclusion for Vitest traces + +For portable traces (especially when sharing to `trace.playwright.dev`), we must ensure needed source files are truly embedded in `resources/src@...` and not rely on local-file fallback behavior of `show-trace`. + +## Appendix: `test.step({ location })` vs `_stackSessions` + +Question checked: if `test.step` allows explicit `location`, does that location flow into `_stackSessions` (same channel used by library tracing source packaging)? + +Answer: + +- No, it does not flow into `_stackSessions`. +- `_stackSessions` is populated from client protocol call metadata in `playwright-core` (`Connection.sendMessageToServer` -> `LocalUtils.addStackToTracingNoReply`), i.e. ordinary library API call stacks. +- `test.step` is runner-level (`playwright` package), not that protocol-call path. + +Why `test.step({ location })` still works for source embedding: + +- `test.step` stores explicit location on step creation (`testType._step(..., options.location)`). +- Runner trace emission writes this as event stack directly: `stack: step.location ? [step.location] : []` in `TestInfo._addStep` -> `TestTracing.appendBeforeActionForStep`. +- Runner trace packaging with `sources: true` scans `before` event stacks and embeds `resources/src@.txt` from those files. + +Implication: + +- For Playwright Test traces, custom `test.step` location is source-portable even without `_stackSessions`. +- For library `context.tracing` traces, `group({ location })` can still diverge from `_stackSessions` source collection unless fixed in library tracing path. + +## Appendix: `locator.describe(...)` assessment for Vitest use case + +Context: follow-up after upstream response in https://github.com/microsoft/playwright/issues/39308#issuecomment-3921247838. + +### What `locator.describe(...)` actually does + +- Implemented in client locator layer (`playwright-core/src/client/locator.ts`) as selector rewriting: + - `describe(description)` returns a new locator with ` >> internal:describe=` appended. + - `description()` extracts this trailing custom description. +- `internal:describe` is a built-in selector engine (`injectedScript.ts`) whose `queryAll` returns the current root element unchanged. + - Practically: it is selector metadata, not an action. + - It does not change which element is targeted. +- Trace Viewer action list renders selector labels via `asLocatorDescription(...)` (`trace-viewer/src/ui/actionList.tsx`). + - If selector tail is `internal:describe`, the description string is shown instead of generated locator text. +- Docs/release notes position it as trace viewer/report readability feature (v1.53), not tracing control primitive. + +### What it helps with + +- Better human-readable locator label in Trace Viewer and reports for events carrying `params.selector`. +- Useful to replace noisy generated selector text with domain wording (e.g. "Subscribe button"). + +### What it does **not** solve for our `mark` use case + +- No explicit timeline marker event (unlike our `page/locator.mark` wrapper). +- No deterministic snapshot anchor by itself. +- No stack/callsite/source-location override; stack metadata still comes from normal API call capture. +- No page-level equivalent (`page.describe(...)` is not the feature). + +### Semantics detail that matters + +- Description is only recognized when `internal:describe` is the trailing selector part. +- If further chained, custom description does not automatically propagate to the new locator unless re-applied (matches existing tests). + +### Practical conclusion for Vitest + +- `locator.describe(...)` is a good readability improvement and likely worth optional adoption where we already have semantic names. +- It is not a replacement for `page/locator.mark(...)` in Vitest: + - we still need explicit marker primitives for deterministic anchoring, + - and we still need our location-aware mark path for source-linking behavior. From 68841318ba3ae2c42eda1f2aff9bb54eac65f1d3 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Feb 2026 11:50:11 +0900 Subject: [PATCH 25/45] Revert "docs: add locator.describe tracing notes" This reverts commit f2cd343ae79ce4d90c6c624041cd364d32965607. --- .dev-notes/dist/playwright-tracing.md | 671 -------------------------- 1 file changed, 671 deletions(-) delete mode 100644 .dev-notes/dist/playwright-tracing.md diff --git a/.dev-notes/dist/playwright-tracing.md b/.dev-notes/dist/playwright-tracing.md deleted file mode 100644 index f29b95dfa827..000000000000 --- a/.dev-notes/dist/playwright-tracing.md +++ /dev/null @@ -1,671 +0,0 @@ -# Playwright Tracing Findings - -Playwright source in `~/code/others/playwright` - -## `test.step` implementation - -- `test.step` is implemented in `packages/playwright/src/common/testType.ts`. -- Wiring: - - `test.step = this._step.bind(this, 'pass')` - - `test.step.skip = this._step.bind(this, 'skip')` -- Core logic is in `TestTypeImpl._step(...)`. - -## How `test.step` relates to tracing - -- `test.step` creates a runner step via `testInfo._addStep(...)` and executes within `stepZone`. -- When a step starts/ends, `TestInfo` emits trace events through: - - `appendBeforeActionForStep(...)` - - `appendAfterActionForStep(...)` -- Those are recorded by `TestTracing` as trace `before/after` events with runner semantics (`class: 'Test'`, method = step category). - -## `TestTracing` vs core `Tracing` - -### `TestTracing` (`packages/playwright/src/worker/testTracing.ts`) - -- Per-test, runner-owned trace stream (`origin: 'testRunner'`). -- Captures test-level semantics: - - `test.step`, `expect` steps, hooks/fixtures - - test errors - - stdout/stderr - - test attachments -- Applies retention policy (`on`, `retain-on-failure`, retries variants). -- Produces final `trace.zip` attached to test output. - -### core `Tracing` (`packages/playwright-core/src/server/trace/recorder/tracing.ts`) - -- Per-browser-context / API-request-context recorder (`origin: 'library'`). -- Captures Playwright protocol/library actions, network HAR entries, snapshots, screencast frames, and resources. -- Handles chunking (`startChunk/stopChunk`) and resource collection. - -## How both streams are stitched together - -- API call steps receive a `stepId` in client instrumentation. -- `stepId` flows through client metadata -> protocol -> server call metadata -> trace events. -- Trace model merges runner and library actions by shared `stepId`. -- Result: trace viewer shows unified timeline/tree with test semantics + low-level browser actions. - -## Is `trace.TraceEvent` public API? - -- Underlying event union is defined in `packages/trace/src/trace.ts`. -- This is imported internally via tsconfig path alias (`@trace/*`). -- It is not exported as a documented stable public API from npm package exports. -- Practical implication: treat trace JSON/event schema as internal/versioned contract (currently version 8), not guaranteed stable as public API. - -## What Playwright Test adds over manual `context.tracing` - -- Manual tracing API captures browser/library operations and network activity. -- Playwright Test tracing adds runner-level semantics and artifacts: - - assertions / `expect` steps - - test steps and hierarchy - - hooks/fixtures - - test-level errors, stdio, attachments - - per-test retention behavior and automatic lifecycle management - -## Snapshot recording: what triggers it - -- Snapshots are triggered by instrumented API calls marked with `snapshot: true` in protocol metainfo. -- Snapshot capture happens in tracing instrumentation callbacks (`onBeforeCall`, `onBeforeInputAction`, `onAfterCall`) when enabled. -- `tracing.group()` / `tracing.groupEnd()` only create grouping structure; they do **not** directly trigger snapshots. -- There is no public API to explicitly "capture snapshot now". - -## Assertion behavior in traces - -- `expect(locator).toBeVisible()` becomes a protocol `Frame.expect` call via `locator._expect(...)`. -- `Frame.expect` is marked snapshot-eligible, so with `snapshots: true` it captures snapshots around that call. -- In Playwright Test, this is also represented as runner `expect` step and linked to library events through `stepId`. -- Non-locator assertions (pure JS value assertions) do not issue browser protocol calls, so they do not trigger library snapshots. - -## Workaround for explicit-ish snapshot point - -- Since there is no explicit snapshot API, perform a benign snapshot-eligible Playwright action while tracing is on. -- Examples: - - `await page.locator('body').click({ position: { x: 1, y: 1 } });` - - `await expect(page.locator('...')).toBeVisible();` (locator-based expect) - -## Vitest integration direction (public API only) - -- Current limitation in Vitest browser runner integration: traces depend heavily on user actions that hit snapshot-eligible Playwright APIs. -- `tracing.group()` / `tracing.groupEnd()` can improve structure/readability, but do not produce snapshots by themselves. -- Direction: - - keep using `start/startChunk/stopChunk` for lifecycle and retention policy; - - use `group/groupEnd` for semantic grouping only; - - add explicit Vitest-level trace anchors ("marks") that intentionally execute a low-impact snapshot-eligible action to create deterministic snapshot points. -- Candidate anchor points: - - before test body; - - after test body; - - around retries/repeats; - - before teardown/failure artifact collection. -- Design goal: deterministic trace density and better debugging even when tests do not call interaction APIs (or only perform non-browser assertions). - -## Should Playwright expose a public `tracing.mark(...)` API? - -- Requesting this upstream is reasonable and aligns with real runner integration needs outside Playwright Test. -- Expected value: - - deterministic, user-controlled trace checkpoints; - - no need for synthetic user interactions as snapshot workaround; - - portable semantics for frameworks that cannot rely on private Playwright internals. -- But implementation is not fully trivial: - - API needs cross-language design parity (JS/TS, Python, Java, .NET); - - behavior must be defined for browser contexts vs API request contexts; - - semantics for `snapshot: true/false`, stack/location, and trace-viewer rendering need to be stable; - - side-effect guarantees are important (mark should not mutate page state); - - interaction with existing `group/groupEnd` and chunk boundaries should be specified. -- Practical complexity estimate: - - API surface and docs: moderate; - - protocol + server/client plumbing: moderate; - - making it robust and backward-compatible across bindings/viewer: moderate-to-high. - -### Minimal viable shape to propose - -- Conceptual API: - - `await context.tracing.mark(name, { snapshot?: boolean, location?: { file, line, column }, metadata?: Record })` -- Recommended semantics: - - always emits an explicit marker event visible in Trace Viewer; - - optional `snapshot: true` captures a DOM snapshot at the mark point; - - no user-visible page interaction side effects. - -## Practical Vitest workaround today: snapshot poke action - -- If only public Playwright API is available, the best low-impact snapshot anchor is: - - `await page.evaluate(() => 0)` -- Rationale: - - routes through `Frame.evaluateExpression`, which is snapshot-eligible in protocol metainfo; - - typically no DOM mutation and minimal side effects; - - avoids synthetic click/focus interactions that can trigger app handlers. -- Usage note: - - ensure tracing started with `snapshots: true`; - - perform after a page exists; - - optionally wrap with `tracing.group()/groupEnd()` to label the anchor in the viewer. - -Example: - -```ts -await context.tracing.group('before assertion phase') -try { - await page.evaluate(() => 0) -} -finally { - await context.tracing.groupEnd() -} -``` - -Alternative (usually safe but less preferred): - -- `await page.locator('html').isVisible()` -- `await page.content()` - -Avoid as default workaround: - -- synthetic click/tap-based pokes, because they may trigger handlers, focus changes, or scrolling. - -## Vitest wrapper callsite in trace: approach matrix - -Goal: show end-user test source location (not Vitest wrapper internals) for actions like wrapped `click`. - -### 1) Preferred if available: explicit location option on API - -- If the API supports `location` (for example `tracing.group(..., { location })`), pass the test callsite explicitly. -- Pros: - - deterministic and clear; - - no global runtime side effects. -- Cons: - - only works on APIs that expose a location option; - - does not help generic Playwright actions like `locator.click()` directly. - -### 2) Internal Playwright mechanism: boxed stack prefixes - -- Playwright filters stack frames using an internal prefix list (`setBoxedStackPrefixes(...)`). -- `@playwright/test` uses this internally to hide runner/framework frames. -- Idea for Vitest POC: register Vitest wrapper directories as boxed prefixes so Playwright selects the next user frame. -- Pros: - - aligns with Playwright's own frame-filtering strategy; - - works for ordinary actions (`locator.click`, etc.) without per-call metadata. -- Cons: - - private/internal API (`playwright-core/lib/utils`), not stability-guaranteed; - - may break on Playwright upgrades and across packaging variants. - -#### Critical limitation for Vitest browser mode - -- `setBoxedStackPrefixes(...)` only filters frames that are already present in the runtime stack where Playwright API call executes. -- In Vitest browser mode architecture, end-user test code frame and `locator.click()` execution can be in different runtimes. -- Therefore, end-user frames may not exist in Playwright-captured stack at all. -- Implication: boxed prefixes can hide wrapper/internal frames, but cannot recover or synthesize missing end-user frames. -- Conclusion for this architecture: boxed prefixes are not a complete solution for true end-user callsite attribution. - -### 3) Global `Error.prepareStackTrace` interception - -- Technically can influence stack formatting/parsing, but strongly discouraged. -- Risks: - - global process-wide side effects; - - interference with test runner/tooling; - - async timing hazards and hard-to-debug behavior. -- Treat as experimental-only, not production approach. - -### 4) Public API direction (upstream) - -- A future `tracing.mark(...)` (or equivalent action-level location override) would provide stable explicit callsite/anchor semantics. -- This is the most maintainable long-term approach for non-Playwright-test runners. - -## Recommendation for Vitest (today) - -- Near-term POC: boxed stack prefixes may still improve noise, but are insufficient for true end-user callsite when runtimes are split. -- Medium-term: keep explicit trace anchors (`group` + snapshot poke) for deterministic snapshots. -- Long-term: propagate explicit user-runtime location metadata (where possible) and push upstream for stable public marker/callsite APIs. - -## Appendix: Playwright snapshot format and Trace Viewer reconstruction - -### Is this browser-native replay? - -- No. Playwright does not use a browser-native "replay" artifact for trace snapshots. -- Snapshot capture/reconstruction is mostly Playwright-owned: - - capture pipeline in injected script + server-side recorder; - - custom serialized DOM/resource model in trace events; - - Trace Viewer-side HTML reconstruction + post-processing script. - -### Snapshot data model (what is stored) - -- Trace event type: `frame-snapshot` with payload `FrameSnapshot`. -- Core fields include: - - `callId`, `snapshotName`, `pageId`, `frameId`, `frameUrl`, `timestamp`, `viewport`, `doctype`; - - `html: NodeSnapshot`; - - `resourceOverrides`. -- `NodeSnapshot` is compact and incremental: - - text node: string; - - element subtree: tuple-like `[tagName, attrs?, ...children]`; - - subtree reference: `[[deltaSnapshots, nodeIndex]]` (reuses node from prior snapshot). - -### Capture pipeline (high level) - -- `Tracing` decides when to capture (`before/input/after`) based on snapshot-eligible protocol metainfo. -- `Snapshotter` injects `frameSnapshotStreamer(...)` into pages and calls `captureSnapshot(...)` in each frame. -- Injected capture performs DOM traversal + state extraction, including: - - shadow DOM encoding; - - form values/checked/selected state; - - scroll positions; - - iframe linkage; - - stylesheet/adopted stylesheet capture and CSS override tracking; - - URL sanitization and security-related filtering. -- Resource/content blobs are stored in trace resources and referenced by sha1. - -### Reconstruction in Trace Viewer - -- Viewer loads snapshots/resources into `SnapshotStorage` and resolves by `(pageOrFrameId, snapshotName)`. -- `SnapshotRenderer` rebuilds HTML from `NodeSnapshot` recursively, resolving subtree references against earlier snapshots. -- Viewer serves reconstructed HTML via `/snapshot/...` and resources via `/sha1/...` or snapshot resource resolution (`SnapshotServer`). -- A post-load script then restores runtime-ish state in the rendered snapshot document: - - input values, checked/selected state; - - scroll offsets; - - shadow roots/adopted stylesheets; - - iframe nested snapshot URLs. -- Result: replay-like inspection, but it is deterministic reconstruction from recorded DOM + resources (not video replay). - -### Action target highlighting in snapshots - -- Highlighted "action element" is implemented via explicit metadata, not inferred visually. -- During action resolution/execution, Playwright marks matched target elements with current `callId` via injected events: - - `__playwright_mark_target__` / `__playwright_unmark_target__`. -- Snapshot capture records this as element attribute `__playwright_target__` inside serialized DOM. -- Trace Viewer script finds nodes matching the relevant target id and applies outline/background highlight. - -### Pointer/click marker - -- Pointer dot comes from action metadata `point` (recorded on input/after action events), passed to snapshot URL params (`pointX`, `pointY`). -- Snapshot script renders pointer indicator; if target element exists and layout differs, viewer may center marker on target and annotate mismatch. - -### Practical implication for Vitest - -- What we can influence with public API today: - - when snapshots get captured (through snapshot-eligible calls); - - grouping/labels (`group/groupEnd`) for readability; - - deterministic anchors (`page.mark` + low-impact snapshot poke). -- What we cannot directly control via stable public API: - - direct custom injection of Playwright target-highlight metadata; - - arbitrary explicit snapshot capture primitive separate from snapshot-eligible actions. - -## Appendix: mark/target highlight API layering notes - -Context for current draft (`group` + `mark`) in Playwright tracing recorder: - -- `Tracing.group(...)` and `Tracing.mark(...)` are implemented in recorder layer and emit synthetic `before/after` action events with `class: 'Tracing'` and methods `tracingGroup` / `tracingMark`. -- `mark` currently supports optional `snapshot` + `location`, and captures `beforeSnapshot = mark@` when snapshotter is active. - -### How action layer currently communicates with tracing/snapshot layer - -- Communication is primarily via instrumentation callbacks + `CallMetadata`: - - dispatcher emits `onBeforeCall` / `onAfterCall` around protocol calls; - - DOM/frame action paths emit `onBeforeInputAction` for input phases; - - recorder (`Tracing`) listens and writes trace events + triggers snapshot capture. -- Target highlighting uses a separate but coordinated path: - - action/locator resolution in `frames.ts` / `dom.ts` calls injected `markTargetElements(..., callId)`; - - injected script marks target nodes (`__playwright_target__ = callId`) via custom events; - - snapshotter serializes those attributes into `frame-snapshot`; - - viewer highlights nodes matching `[__playwright_target__=""]`. - -### Implication for `tracing.mark(...)` API design - -- Adding arbitrary metadata to mark events is straightforward via `BeforeActionTraceEvent.params` and will appear in Trace Viewer call details. -- But element highlight is not just event metadata: - - passing `params.targetSelector` alone does not produce snapshot highlight; - - highlight requires target marking in page/injected world before snapshot capture. -- Therefore, extending `context.tracing.mark(...)` with element targeting would couple tracing layer to DOM/action concerns (potential layering smell). - -### Practical recommendation (current stance) - -- Keep `context.tracing.mark(...)` minimal and timeline-oriented (name + optional snapshot + optional location). -- If highlight is desired, prefer one of: - - action-layer API that already has selector/element resolution semantics; - - a separate explicit API near page/locator domain, not in generic tracing surface. -- If upstream still wants mark-level highlight, it should be specified as best-effort and internally routed through existing `markTargetElements(..., callId)` pipeline, not solely as trace event params. - -### Locator-level hook idea (future ask) - -Vitest-specific context: - -- Vitest browser-playwright commands already operate at locator level (e.g. `context.iframe.locator(selector).click(...)`), which naturally enters Playwright action instrumentation and trace pipeline. -- Additional Vitest need is assertion-timing anchors with selector context (not generic tracing marks). - -Design direction: - -- Keep `context.tracing.mark(...)` as a pure timeline primitive. -- Add a separate locator/page-level `mark` API for element-aware snapshots/highlight. -- This preserves layering: selector resolution and DOM marking stay in frame/locator domain. - -Candidate API shapes (in increasing ambition): - -1) Minimal locator anchor - -- `await locator.mark({ name?: string, snapshot?: boolean })` -- Semantics: - - resolve selector using strict locator path; - - call existing injected `markTargetElements(new Set(elements), callId)`; - - emit trace action event with optional title/name; - - optionally capture snapshot at this anchor. - -2) Page/frame selector anchor - -- `await page.mark(selector, { strict?: boolean, name?: string, snapshot?: boolean })` -- Same internals, but string-selector entry point for non-locator ecosystems. - -3) Assertion-integrated anchor (runner/internal) - -- Reuse expect path (`Frame.expect`-style internals) to anchor around assertion attempts/failures. -- Useful for tools like Vitest that already hold selector + matcher context. -- This can remain internal/private API if public surface is undesirable. - -Expected behavior contract - -- Best effort only: if target resolution fails, still record timeline action; skip highlight. -- No persistent DOM side effects beyond transient mark attributes used for snapshot serialization. -- Use existing `callId` correlation so viewer works without new snapshot schema. - -Why this is preferable to `tracing.mark({ target })` - -- Avoids pulling selector/element semantics into context-tracing API. -- Reuses mature locator/action code paths that already handle strictness, frame targeting, and mark-to-snapshot plumbing. -- Aligns with Vitest architecture where browser commands originate from locator-level operations. - -Near-term Vitest workaround (without new Playwright API) - -- Continue using `page.mark` for generic timeline points. -- For selector-aware anchors, trigger a low-impact locator call that goes through selector resolution paths that invoke `markTargetElements` (careful per-method behavior), then pair with mark/group as needed. - -## Appendix: `Frame.expect` / `locator._expect` mechanics - -### How assertions connect to trace/snapshot - -- Playwright assertions are wired through protocol action `Frame.expect`, not directly through snapshotter calls inside matcher code. -- High-level matcher flow: - - `expect(locator).toX(...)` in `playwright/src/matchers/matchers.ts` calls `locator._expect('', options)`. - - `locator._expect(...)` forwards `{ selector: this._selector, ... }` to `frame._expect(...)`. - - client `frame._expect(...)` sends protocol `Frame.expect`. - - server dispatcher calls `frame.expect(progress, selector, options)`. -- Since `Frame.expect` is a regular instrumented protocol call and metainfo marks it as `snapshot: true`, tracing captures snapshots naturally via `onBeforeCall/onAfterCall` instrumentation path. - -### Is `Frame.expect` selector-aware? - -- Yes. `Frame.expect` receives selector and resolves it in frame context. -- Inside `_expectInternal`, server code evaluates injected script with selector info and current `callId`. -- It explicitly calls `injected.markTargetElements(new Set(elements), callId)` before running expectation logic. -- This is why snapshots can highlight target elements for expect steps. - -### What `expression` looks like - -- `expression` is an internal matcher opcode string consumed by injected expect engine. -- Common values include: - - `to.be.visible`, `to.be.hidden`, `to.be.checked`, `to.be.attached`; - - `to.have.text`, `to.have.text.array`, `to.contain.text.array`; - - `to.have.attribute`, `to.have.attribute.value`, `to.have.css`, `to.have.count`; - - `to.have.value`, `to.have.values`, `to.have.title`, `to.have.url`, `to.match.aria`. - -### Workaround potential for Vitest - -- Technical workaround: call `locator._expect(...)` from Vitest to trigger: - - selector resolution + target marking; - - snapshot-eligible `Frame.expect` action; - - natural trace entry with highlight. -- Trade-off: `_expect` is private/internal API (underscore) and not a stable public contract. -- Recommendation: treat `_expect`-based integration as a pragmatic short-term workaround only; keep pursuing public API shape for long-term stability. - -### Concrete short-term workaround shape - -- Goal: create selector-aware trace anchor with highlight, while preserving custom Vitest mark title/location. -- Suggested sequence: - 1. `context.tracing.group(name, { location })` - 2. `await (context.iframe.locator(selector) as any)._expect('to.be.attached', { isNot: false, timeout: 1 })` - 3. `context.tracing.groupEnd()` in `finally` -- Why this works: - - `_expect` routes to `Frame.expect` (snapshot-enabled action); - - server `Frame.expect` resolves selector and calls `markTargetElements(..., callId)`; - - trace snapshot captures target metadata; viewer highlights target. -- Safety notes: - - wrap in `try/catch/finally` so trace anchoring never changes test result; - - feature-detect `_expect` and fallback to existing no-op snapshot poke if missing; - - treat this as temporary due to private API status. - - use test iframe locator (`context.iframe.locator`) rather than top-level page locator. - - avoid `timeout: 0` for this internal anchor call (`0` means no timeout in Playwright); use a tiny bounded timeout (e.g. `1`). - -### Validation note (important) - -- Manual validation shows this iframe `locator._expect('to.be.attached', { timeout: 1 })` approach already produces the exact desired trace-view UX (snapshot timing + target highlight). -- Conclusion: Playwright tracing internals are already sufficient; the main gap is public API surface/stability for this pattern. - -## Appendix: upstream RFC draft (Playwright) - -### Title - -`[Feature]: Custom tracing marker API with selector and snapshot` - -### 🚀 Feature Request - -A public API on `page` and `locator` for inserting custom markers into Playwright traces, with optional DOM snapshot capture and selector-aware element highlighting. - -```ts -await page.mark(name, { snapshot?, location? }) -await locator.mark(name, { snapshot?, location? }) -``` - -- `page.mark(name)` — inserts a named timeline marker visible in Trace Viewer, with optional snapshot capture. -- `locator.mark(name)` — same, but also resolves the locator's selector and highlights the target element in the snapshot. This is the same highlight behavior that `expect(locator)` assertions already produce internally. - -### Example - -```ts -// page-level marker (snapshot, no element highlight) -await page.mark('before assertion phase', { - location: { file: testFile, line: 42 }, - snapshot: true, -}) - -// locator-level marker (snapshot with element highlight) -await page.locator('#submit-button').mark('assertion target', { - snapshot: true, -}) -``` - -### Motivation (short version) - -We are enhancing Playwright trace support in Vitest browser mode (in https://github.com/vitest-dev/vitest/pull/9652). There is currently no stable way to insert custom trace markers with snapshots or element highlighting. Our workarounds today: - -```ts -// page.mark workaround — snapshot without element highlight -await context.tracing.group(name, { location }) -await page.evaluate(() => 0) -await context.tracing.groupEnd() - -// locator.mark workaround — snapshot with element highlight (private API) -await context.tracing.group(name, { location }) -await (locator as any)._expect('to.be.attached', { isNot: false, timeout: 1 }) -await context.tracing.groupEnd() -``` - -The `page.evaluate(() => 0)` workaround is a no-op just to trigger a snapshot-eligible action, and `locator._expect(...)` is a private API that may break across versions. - -Public `page.mark()` / `locator.mark()` APIs would make this a stable, first-class capability for the broader Playwright ecosystem. We are happy to help prototype or test any proposed API shape. - -### Motivation (detailed) - -Third-party test runners that use Playwright's library tracing can produce rich traces today, but there is no stable public API for inserting custom markers with snapshot capture or selector-aware element highlighting. - -Playwright internals already support the needed behavior — snapshot capture around instrumented protocol calls, and target element highlighting via the existing mark-target pipeline. We validated that calling internal expect APIs from an iframe context produces the exact desired Trace Viewer output (timeline entry, snapshot, element highlight). The main gap is a stable public API surface for this pattern. - -Our current workaround depends on the private `locator._expect(...)` API, which may change without notice across Playwright versions. Public `page.mark()` / `locator.mark()` APIs would: - -- Let external runners insert named trace markers with snapshots without side-effect-laden workarounds. -- Enable selector-aware snapshot points with element highlighting through stable, documented APIs. -- Benefit the broader Playwright ecosystem beyond `@playwright/test`. - -We treat our current workaround as a stopgap and will migrate to public APIs once available. We are happy to help prototype or test any proposed API shape. - -## Appendix: upstream bug report draft (`sources` + `group location`) - -### Proposed issue title - -- Tracing source packaging misses files referenced by `tracing.group({ location })` when using `tracing.start({ sources: true })` - -### Problem statement - -- With library tracing (`context.tracing`), `start({ sources: true })` does not always include source files for stack locations emitted by tracing APIs. -- In particular, locations provided via `tracing.group(name, { location })` can appear in trace event stacks, but corresponding `resources/src@.txt` entries may be missing from the final trace zip. - -### Why this is user-visible - -- Local `playwright show-trace` appears to work because Source tab falls back to local `file?path=...` endpoint. -- Hosted `https://trace.playwright.dev` cannot read local filesystem and therefore only shows sources present in archive resources. -- Result: same trace can show source locally but not in hosted viewer. - -### Minimal repro outline - -1. `await context.tracing.start({ snapshots: true, sources: true })` -2. `await context.tracing.startChunk({ title: 'repro' })` -3. `await context.tracing.group('anchor', { location: { file: '/abs/path/to/test.ts', line: 10, column: 1 } })` -4. optional low-impact snapshot action (e.g. `await page.evaluate(() => 0)`) -5. `await context.tracing.groupEnd()` -6. `await context.tracing.stopChunk({ path: 'trace.zip' })` -7. open in `https://trace.playwright.dev` and inspect Source tab for group action - -Expected: - -- source for `/abs/path/to/test.ts` is available from bundled `resources/src@...`. - -Actual: - -- location is visible in stack, but source may be unavailable unless local-file fallback exists. - -### Suspected root cause - -- Source inclusion for `start({ sources: true })` is implemented in local zip step (`localUtils.zip`) by collecting files from `stackSession.callStacks`. -- `group` locations are written into trace events, but do not necessarily feed `stackSession.callStacks` source collection. -- Therefore event-stack file paths and packaged source-file set can diverge. - -## Appendix: solution ideas (upstream) - -### Option A: targeted fix (smallest) - -- When processing `tracing.group` with `location.file`, register that file path in the same source-file collector used by `includeSources`. -- Pros: minimal behavioral change, small patch, direct bug fix. -- Cons: adds one more special path into collector plumbing. - -### Option B: packaging robustness fix - -- During zip with `includeSources`, also collect source paths from trace event stacks (including synthetic tracing actions like group), not only `stackSession.callStacks`. -- Pros: resilient to future tracing actions carrying explicit locations. -- Cons: extra parsing/plumbing; should dedupe and avoid perf regressions. - -### Option C: instrumentation unification (larger) - -- Route `group` through the same client-side stack capture/instrumentation channel as ordinary API calls, so one source collector naturally covers all. -- Pros: cleaner architecture long-term. -- Cons: bigger refactor with higher risk. - -### Recommended upstream path - -- Start with Option A for quick correctness fix. -- Consider Option B afterward for defense-in-depth and future-proofing. - -## Appendix: Why `show-trace` shows source but `trace.playwright.dev` does not - -Observed behavior: - -- Local CLI viewer (`pnpm exec playwright show-trace ...`) can show user source files even when they are not inside the trace zip. -- Hosted PWA viewer (`https://trace.playwright.dev`) only shows sources that are actually embedded in the trace archive. - -### Source loading path in Trace Viewer UI - -In `packages/trace-viewer/src/ui/sourceTab.tsx`, source loading is: - -1. Try embedded source first: - -- fetch `sha1/src@.txt` from trace resources. - -2. If missing (404), fallback to host file endpoint: - -- fetch `file?path=`. - -So the viewer itself has a two-step strategy: bundled source first, local file fallback second. - -### What `show-trace` provides that hosted viewer cannot - -`show-trace` starts a local HTTP server (`packages/playwright-core/src/server/trace/viewer/traceViewer.ts`) with a `/trace/file?path=...` route that reads from local disk via Node `fs`. - -That means local viewer can resolve source content directly from your machine when `resources/src@...` is missing. - -`trace.playwright.dev` runs as a PWA/service-worker app and does not have a Node file-serving endpoint for your local filesystem. It can only read what is in the uploaded trace blob (`.trace/.network/resources/*`). - -### Why user TS source can still be absent from zip with `sources: true` - -Even when tracing is started with `sources: true`, source embedding is driven by Playwright's stack-session zip step (`localUtils.zip`), which collects files only from `stackSession.callStacks` and writes them as `resources/src@.txt`. - -Important limitation: - -- This collection does **not** scan all stack file paths present in trace events. -- In particular, explicit location stacks injected via APIs like `tracing.group(..., { location })` can appear in trace event `stack`, yet not be included in `stackSession.callStacks` source collection. - -Net effect: - -- Local `show-trace`: still shows these files through `/trace/file` fallback. -- Hosted `trace.playwright.dev`: cannot load them unless `resources/src@...` exists in the zip. - -### Practical conclusion for Vitest traces - -For portable traces (especially when sharing to `trace.playwright.dev`), we must ensure needed source files are truly embedded in `resources/src@...` and not rely on local-file fallback behavior of `show-trace`. - -## Appendix: `test.step({ location })` vs `_stackSessions` - -Question checked: if `test.step` allows explicit `location`, does that location flow into `_stackSessions` (same channel used by library tracing source packaging)? - -Answer: - -- No, it does not flow into `_stackSessions`. -- `_stackSessions` is populated from client protocol call metadata in `playwright-core` (`Connection.sendMessageToServer` -> `LocalUtils.addStackToTracingNoReply`), i.e. ordinary library API call stacks. -- `test.step` is runner-level (`playwright` package), not that protocol-call path. - -Why `test.step({ location })` still works for source embedding: - -- `test.step` stores explicit location on step creation (`testType._step(..., options.location)`). -- Runner trace emission writes this as event stack directly: `stack: step.location ? [step.location] : []` in `TestInfo._addStep` -> `TestTracing.appendBeforeActionForStep`. -- Runner trace packaging with `sources: true` scans `before` event stacks and embeds `resources/src@.txt` from those files. - -Implication: - -- For Playwright Test traces, custom `test.step` location is source-portable even without `_stackSessions`. -- For library `context.tracing` traces, `group({ location })` can still diverge from `_stackSessions` source collection unless fixed in library tracing path. - -## Appendix: `locator.describe(...)` assessment for Vitest use case - -Context: follow-up after upstream response in https://github.com/microsoft/playwright/issues/39308#issuecomment-3921247838. - -### What `locator.describe(...)` actually does - -- Implemented in client locator layer (`playwright-core/src/client/locator.ts`) as selector rewriting: - - `describe(description)` returns a new locator with ` >> internal:describe=` appended. - - `description()` extracts this trailing custom description. -- `internal:describe` is a built-in selector engine (`injectedScript.ts`) whose `queryAll` returns the current root element unchanged. - - Practically: it is selector metadata, not an action. - - It does not change which element is targeted. -- Trace Viewer action list renders selector labels via `asLocatorDescription(...)` (`trace-viewer/src/ui/actionList.tsx`). - - If selector tail is `internal:describe`, the description string is shown instead of generated locator text. -- Docs/release notes position it as trace viewer/report readability feature (v1.53), not tracing control primitive. - -### What it helps with - -- Better human-readable locator label in Trace Viewer and reports for events carrying `params.selector`. -- Useful to replace noisy generated selector text with domain wording (e.g. "Subscribe button"). - -### What it does **not** solve for our `mark` use case - -- No explicit timeline marker event (unlike our `page/locator.mark` wrapper). -- No deterministic snapshot anchor by itself. -- No stack/callsite/source-location override; stack metadata still comes from normal API call capture. -- No page-level equivalent (`page.describe(...)` is not the feature). - -### Semantics detail that matters - -- Description is only recognized when `internal:describe` is the trailing selector part. -- If further chained, custom description does not automatically propagate to the new locator unless re-applied (matches existing tests). - -### Practical conclusion for Vitest - -- `locator.describe(...)` is a good readability improvement and likely worth optional adoption where we already have semantic names. -- It is not a replacement for `page/locator.mark(...)` in Vitest: - - we still need explicit marker primitives for deterministic anchoring, - - and we still need our location-aware mark path for source-linking behavior. From 03d4c2e60b8e911ca16043ec6747dd8d7db3ceb5 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Feb 2026 12:43:08 +0900 Subject: [PATCH 26/45] test: wip --- .../browser/fixtures/trace-mark/basic.test.ts | 32 ++++ .../fixtures/trace-mark/vitest.config.ts | 14 ++ .../specs/playwright-trace-mark.test.ts | 148 ++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 test/browser/fixtures/trace-mark/basic.test.ts create mode 100644 test/browser/fixtures/trace-mark/vitest.config.ts create mode 100644 test/browser/specs/playwright-trace-mark.test.ts diff --git a/test/browser/fixtures/trace-mark/basic.test.ts b/test/browser/fixtures/trace-mark/basic.test.ts new file mode 100644 index 000000000000..cb8c8c9d7ec7 --- /dev/null +++ b/test/browser/fixtures/trace-mark/basic.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, expect, test } from "vitest"; +import { page } from "vitest/browser"; + +beforeEach(() => { + document.body.innerHTML = ""; +}); + +test("locator.mark", async () => { + document.body.innerHTML = ""; + await page.getByRole("button").mark("button rendered"); +}); + +test("page.mark", async () => { + document.body.innerHTML = ""; + await page.mark("button rendered"); +}); + +test("expect.element pass", async () => { + document.body.innerHTML = ""; + await expect.element(page.getByRole("button")).toHaveTextContent("Hello"); +}); + +test("expect.element fail", async () => { + document.body.innerHTML = ""; + await page.mark("button rendered"); + await expect.element(page.getByRole("button"), { timeout: 100 }).toHaveTextContent("World"); +}); + +test("failure", async () => { + document.body.innerHTML = ""; + throw new Error("Test failure"); +}); diff --git a/test/browser/fixtures/trace-mark/vitest.config.ts b/test/browser/fixtures/trace-mark/vitest.config.ts new file mode 100644 index 000000000000..bdff7fe78b18 --- /dev/null +++ b/test/browser/fixtures/trace-mark/vitest.config.ts @@ -0,0 +1,14 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; +import { instances, providers } from "../../settings"; + +export default defineConfig({ + cacheDir: fileURLToPath(new URL("./node_modules/.vite", import.meta.url)), + test: { + browser: { + enabled: true, + provider: providers.playwright(), + instances, + }, + }, +}); diff --git a/test/browser/specs/playwright-trace-mark.test.ts b/test/browser/specs/playwright-trace-mark.test.ts new file mode 100644 index 000000000000..f711cda2a089 --- /dev/null +++ b/test/browser/specs/playwright-trace-mark.test.ts @@ -0,0 +1,148 @@ +import { createRequire } from "node:module"; +import { readdirSync, rmSync } from "node:fs"; +import { dirname, resolve } from "pathe"; +import { afterEach, describe, expect, test } from "vitest"; +import { instances, provider, runBrowserTests } from "./utils"; +import { buildTestProjectTree } from "../../test-utils"; +import path from "node:path"; +import { stripVTControlCharacters } from "node:util"; + +type TraceBeforeEvent = { + type: "before"; + callId: string; + class: string; + method: string; + title?: string; + stack?: { file: string }[]; +}; + +type TraceAfterEvent = { + type: "after"; + callId: string; +}; + +type TraceEvent = TraceBeforeEvent | TraceAfterEvent | { type: string }; + +type ZipFileType = { + entries: () => Promise; + read: (entryPath: string) => Promise; + close: () => void; +}; + +const require = createRequire(import.meta.url); +const playwrightEntry = require.resolve("playwright"); +const zipFileEntry = resolve( + dirname(playwrightEntry), + "../playwright-core/lib/server/utils/zipFile.js", +); +const { ZipFile } = require(zipFileEntry) as { ZipFile: new (fileName: string) => ZipFileType }; + +const tracesFolder = resolve(import.meta.dirname, "../fixtures/trace-mark/__traces__"); +const basicTestTracesFolder = resolve(tracesFolder, "basic.test.ts"); + +describe.runIf(provider.name === "playwright")("playwright trace marks", () => { + afterEach(() => { + rmSync(tracesFolder, { recursive: true, force: true }); + }); + + test("vitest mark is present in zipped trace events", async () => { + const { results } = await runBrowserTests({ + root: "./fixtures/trace-mark", + browser: { + trace: { + mode: "on", + screenshots: false, // makes it lighter + }, + }, + }); + const projectTree = buildTestProjectTree(results, (testCase) => { + const result = testCase.result(); + return result.state === "failed" + ? result.errors.map((e) => stripVTControlCharacters(e.message)) + : result.state; + }); + expect(Object.keys(projectTree).sort()).toEqual(instances.map((i) => i.browser).sort()); + + for (const [_name, tree] of Object.entries(projectTree)) { + expect.soft(tree).toMatchInlineSnapshot(` + { + "basic.test.ts": { + "expect.element fail": [ + "expect(element).toHaveTextContent() + + Expected element to have text content: + World + Received: + Hello", + ], + "expect.element pass": "passed", + "failure": [ + "Test failure", + ], + "locator.mark": "passed", + "page.mark": "passed", + }, + } + `); + + // TODO: probe zip here + } + + // expect(stderr).toBe('') + // expect(readdirSync(tracesFolder)).toEqual(['basic.test.ts']) + + // const traceFiles = readdirSync(basicTestTracesFolder).sort() + // expect(traceFiles).toHaveLength(3) + // expect(traceFiles.every(file => file.endsWith('.trace.zip'))).toBe(true) + + // for (const traceFile of traceFiles) { + // const zipPath = resolve(basicTestTracesFolder, traceFile) + // const { entries, events } = await readTraceZip(zipPath) + + // expect(entries).toContain('trace.trace') + // expect(entries).toContain('trace.network') + // expect(entries.some(name => name.startsWith('resources/'))).toBe(true) + + // const renderMarker = events.find((event): event is TraceBeforeEvent => { + // return event.type === 'before' + // && event.class === 'Tracing' + // && event.title === 'render' + // }) + // expect(renderMarker).toBeDefined() + // expect(['tracingGroup', 'tracingMark']).toContain(renderMarker!.method) + + // const renderMarkerEnd = events.find((event): event is TraceAfterEvent => { + // return event.type === 'after' + // && event.callId === renderMarker!.callId + // }) + // expect(renderMarkerEnd).toBeDefined() + + // const stackFile = renderMarker!.stack?.[0]?.file.replaceAll('\\', '/') + // expect(stackFile).toContain('/trace-mark/basic.test.ts') + + // const expectElementMarker = events.find((event): event is TraceBeforeEvent => { + // return event.type === 'before' + // && event.class === 'Tracing' + // && event.title?.startsWith('expect.element().toHaveTextContent') + // }) + // expect(expectElementMarker).toBeDefined() + // } + }); +}); + +async function readTraceZip(zipPath: string): Promise<{ entries: string[]; events: TraceEvent[] }> { + const zipFile = new ZipFile(zipPath); + try { + const entries = await zipFile.entries(); + const traceText = (await zipFile.read("trace.trace")).toString("utf-8"); + const events = traceText + .split("\n") + .filter(Boolean) + .map((line) => { + return JSON.parse(line) as TraceEvent; + }); + return { entries, events }; + } finally { + zipFile.close(); + } +} From 9706e900dbccf00a47c2af4f59e1b9197026821b Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Feb 2026 13:21:12 +0900 Subject: [PATCH 27/45] test: wip --- .../browser/fixtures/trace-mark/basic.test.ts | 9 +- .../specs/playwright-trace-mark.test.ts | 156 +++++++++++++----- 2 files changed, 119 insertions(+), 46 deletions(-) diff --git a/test/browser/fixtures/trace-mark/basic.test.ts b/test/browser/fixtures/trace-mark/basic.test.ts index cb8c8c9d7ec7..61515b6f5e4b 100644 --- a/test/browser/fixtures/trace-mark/basic.test.ts +++ b/test/browser/fixtures/trace-mark/basic.test.ts @@ -7,12 +7,12 @@ beforeEach(() => { test("locator.mark", async () => { document.body.innerHTML = ""; - await page.getByRole("button").mark("button rendered"); + await page.getByRole("button").mark("button rendered - locator"); }); test("page.mark", async () => { document.body.innerHTML = ""; - await page.mark("button rendered"); + await page.mark("button rendered - page"); }); test("expect.element pass", async () => { @@ -30,3 +30,8 @@ test("failure", async () => { document.body.innerHTML = ""; throw new Error("Test failure"); }); + +test("click", async () => { + document.body.innerHTML = ""; + await page.getByRole("button").click(); +}); diff --git a/test/browser/specs/playwright-trace-mark.test.ts b/test/browser/specs/playwright-trace-mark.test.ts index f711cda2a089..408ae1c0eb2f 100644 --- a/test/browser/specs/playwright-trace-mark.test.ts +++ b/test/browser/specs/playwright-trace-mark.test.ts @@ -4,8 +4,8 @@ import { dirname, resolve } from "pathe"; import { afterEach, describe, expect, test } from "vitest"; import { instances, provider, runBrowserTests } from "./utils"; import { buildTestProjectTree } from "../../test-utils"; -import path from "node:path"; import { stripVTControlCharacters } from "node:util"; +import path from "node:path"; type TraceBeforeEvent = { type: "before"; @@ -46,7 +46,7 @@ describe.runIf(provider.name === "playwright")("playwright trace marks", () => { }); test("vitest mark is present in zipped trace events", async () => { - const { results } = await runBrowserTests({ + const { results, ctx } = await runBrowserTests({ root: "./fixtures/trace-mark", browser: { trace: { @@ -63,10 +63,11 @@ describe.runIf(provider.name === "playwright")("playwright trace marks", () => { }); expect(Object.keys(projectTree).sort()).toEqual(instances.map((i) => i.browser).sort()); - for (const [_name, tree] of Object.entries(projectTree)) { + for (const [name, tree] of Object.entries(projectTree)) { expect.soft(tree).toMatchInlineSnapshot(` { "basic.test.ts": { + "click": "passed", "expect.element fail": [ "expect(element).toHaveTextContent() @@ -85,48 +86,103 @@ describe.runIf(provider.name === "playwright")("playwright trace marks", () => { } `); - // TODO: probe zip here - } + const traceFiles = readdirSync(basicTestTracesFolder) + .filter((file) => file.startsWith(`${name}-`) && file.endsWith(".trace.zip")) + .sort(); + expect(traceFiles).toEqual([ + expect.stringContaining("click"), + expect.stringContaining("expect-element-fail"), + expect.stringContaining("expect-element-pass"), + expect.stringContaining("failure"), + expect.stringContaining("locator-mark"), + expect.stringContaining("page-mark"), + ]); + + for (const traceFile of traceFiles) { + const zipPath = resolve(basicTestTracesFolder, traceFile); + const parsed = await readTraceZip(zipPath); + const events: any[] = parsed.events.filter((event: any) => event.type === "before"); + + if (traceFile.includes("locator-mark")) { + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + method: "tracingGroup", + title: "button rendered - locator", + }), + expect.objectContaining({ + method: "expect", + params: expect.objectContaining({ + selector: + '[data-vitest="true"] >> internal:control=enter-frame >> internal:role=button', + }), + }), + ]), + ); + const frame = events.find(e => e.title === 'button rendered - locator')?.stack?.[0]; + frame.file = path.relative(ctx.config.root, frame.file); + expect(frame).toMatchInlineSnapshot(` + { + "column": 33, + "file": "basic.test.ts", + "line": 10, + } + `); + } - // expect(stderr).toBe('') - // expect(readdirSync(tracesFolder)).toEqual(['basic.test.ts']) - - // const traceFiles = readdirSync(basicTestTracesFolder).sort() - // expect(traceFiles).toHaveLength(3) - // expect(traceFiles.every(file => file.endsWith('.trace.zip'))).toBe(true) - - // for (const traceFile of traceFiles) { - // const zipPath = resolve(basicTestTracesFolder, traceFile) - // const { entries, events } = await readTraceZip(zipPath) - - // expect(entries).toContain('trace.trace') - // expect(entries).toContain('trace.network') - // expect(entries.some(name => name.startsWith('resources/'))).toBe(true) - - // const renderMarker = events.find((event): event is TraceBeforeEvent => { - // return event.type === 'before' - // && event.class === 'Tracing' - // && event.title === 'render' - // }) - // expect(renderMarker).toBeDefined() - // expect(['tracingGroup', 'tracingMark']).toContain(renderMarker!.method) - - // const renderMarkerEnd = events.find((event): event is TraceAfterEvent => { - // return event.type === 'after' - // && event.callId === renderMarker!.callId - // }) - // expect(renderMarkerEnd).toBeDefined() - - // const stackFile = renderMarker!.stack?.[0]?.file.replaceAll('\\', '/') - // expect(stackFile).toContain('/trace-mark/basic.test.ts') - - // const expectElementMarker = events.find((event): event is TraceBeforeEvent => { - // return event.type === 'before' - // && event.class === 'Tracing' - // && event.title?.startsWith('expect.element().toHaveTextContent') - // }) - // expect(expectElementMarker).toBeDefined() - // } + // if (traceFile.includes("page-mark")) { + // const marker = events.find((event) => event.title === "button rendered"); + // expect(marker).toBeDefined(); + // expect(["tracingGroup", "tracingMark"]).toContain(marker!.method); + // expect(hasAfterEvent(events, marker!.callId)).toBe(true); + // expect(hasTestFileStack(marker!, "trace-mark/basic.test.ts")).toBe(true); + // } + + // if (traceFile.includes("expect-element-pass")) { + // const marker = events.find((event) => { + // return event.title?.startsWith("expect.element().toHaveTextContent"); + // }); + // expect(marker).toBeDefined(); + // expect(marker!.title).not.toContain("[ERROR]"); + // expect(hasAfterEvent(events, marker!.callId)).toBe(true); + // expect(hasTestFileStack(marker!, "trace-mark/basic.test.ts")).toBe(true); + // } + // if (traceFile.includes("expect-element-fail")) { + // const marker = events.find((event) => { + // return ( + // event.title?.startsWith("expect.element().toHaveTextContent") && + // event.title.includes("[ERROR]") + // ); + // }); + // expect(marker).toBeDefined(); + // expect(hasAfterEvent(events, marker!.callId)).toBe(true); + // expect(hasTestFileStack(marker!, "trace-mark/basic.test.ts")).toBe(true); + + // const explicitMark = events.find((event) => event.title === "button rendered"); + // expect(explicitMark).toBeDefined(); + // } + + // if (traceFile.includes("failure")) { + // const userMarkers = events.filter((event) => { + // return ( + // event.title === "button rendered" || + // event.title?.startsWith("expect.element().toHaveTextContent") + // ); + // }); + // expect(userMarkers).toEqual([]); + // } + + // if (traceFile.includes("click")) { + // const userMarkers = events.filter((event) => { + // return ( + // event.title === "button rendered" || + // event.title?.startsWith("expect.element().toHaveTextContent") + // ); + // }); + // expect(userMarkers).toEqual([]); + // } + } + } }); }); @@ -146,3 +202,15 @@ async function readTraceZip(zipPath: string): Promise<{ entries: string[]; event zipFile.close(); } } + +// function hasAfterEvent(events: TraceEvent[], callId: string): boolean { +// return events.some((event): event is TraceAfterEvent => { +// return event.type === "after" && event.callId === callId; +// }); +// } + +// function hasTestFileStack(event: TraceBeforeEvent, fileSuffix: string): boolean { +// return (event.stack || []).some((frame) => { +// return frame.file.replaceAll("\\", "/").endsWith(fileSuffix); +// }); +// } From 524d078dde50848fb4a0bdf6d47151d1c8cddcee Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Feb 2026 13:32:59 +0900 Subject: [PATCH 28/45] test: wip --- .../specs/playwright-trace-mark.test.ts | 173 +++++++++++------- 1 file changed, 109 insertions(+), 64 deletions(-) diff --git a/test/browser/specs/playwright-trace-mark.test.ts b/test/browser/specs/playwright-trace-mark.test.ts index 408ae1c0eb2f..166c40d14a3e 100644 --- a/test/browser/specs/playwright-trace-mark.test.ts +++ b/test/browser/specs/playwright-trace-mark.test.ts @@ -119,7 +119,7 @@ describe.runIf(provider.name === "playwright")("playwright trace marks", () => { }), ]), ); - const frame = events.find(e => e.title === 'button rendered - locator')?.stack?.[0]; + const frame = events.find((e) => e.title === "button rendered - locator")?.stack?.[0]; frame.file = path.relative(ctx.config.root, frame.file); expect(frame).toMatchInlineSnapshot(` { @@ -130,57 +130,114 @@ describe.runIf(provider.name === "playwright")("playwright trace marks", () => { `); } - // if (traceFile.includes("page-mark")) { - // const marker = events.find((event) => event.title === "button rendered"); - // expect(marker).toBeDefined(); - // expect(["tracingGroup", "tracingMark"]).toContain(marker!.method); - // expect(hasAfterEvent(events, marker!.callId)).toBe(true); - // expect(hasTestFileStack(marker!, "trace-mark/basic.test.ts")).toBe(true); - // } - - // if (traceFile.includes("expect-element-pass")) { - // const marker = events.find((event) => { - // return event.title?.startsWith("expect.element().toHaveTextContent"); - // }); - // expect(marker).toBeDefined(); - // expect(marker!.title).not.toContain("[ERROR]"); - // expect(hasAfterEvent(events, marker!.callId)).toBe(true); - // expect(hasTestFileStack(marker!, "trace-mark/basic.test.ts")).toBe(true); - // } - // if (traceFile.includes("expect-element-fail")) { - // const marker = events.find((event) => { - // return ( - // event.title?.startsWith("expect.element().toHaveTextContent") && - // event.title.includes("[ERROR]") - // ); - // }); - // expect(marker).toBeDefined(); - // expect(hasAfterEvent(events, marker!.callId)).toBe(true); - // expect(hasTestFileStack(marker!, "trace-mark/basic.test.ts")).toBe(true); - - // const explicitMark = events.find((event) => event.title === "button rendered"); - // expect(explicitMark).toBeDefined(); - // } - - // if (traceFile.includes("failure")) { - // const userMarkers = events.filter((event) => { - // return ( - // event.title === "button rendered" || - // event.title?.startsWith("expect.element().toHaveTextContent") - // ); - // }); - // expect(userMarkers).toEqual([]); - // } - - // if (traceFile.includes("click")) { - // const userMarkers = events.filter((event) => { - // return ( - // event.title === "button rendered" || - // event.title?.startsWith("expect.element().toHaveTextContent") - // ); - // }); - // expect(userMarkers).toEqual([]); - // } + if (traceFile.includes("page-mark")) { + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + method: "tracingGroup", + title: "button rendered - page", + }), + expect.objectContaining({ + method: "evaluateExpression", + }), + ]), + ); + const frame = events.find((e) => e.title === "button rendered - page")?.stack?.[0]; + frame.file = path.relative(ctx.config.root, frame.file); + expect(frame).toMatchInlineSnapshot(` + { + "column": 13, + "file": "basic.test.ts", + "line": 15, + } + `); + } + + if (traceFile.includes("expect-element-pass")) { + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + method: "tracingGroup", + title: "expect.element().toHaveTextContent", + }), + expect.objectContaining({ + method: "expect", + params: expect.objectContaining({ + selector: + '[data-vitest="true"] >> internal:control=enter-frame >> internal:role=button', + }), + }), + ]), + ); + const frame = events.find((e) => e.title === "expect.element().toHaveTextContent") + ?.stack?.[0]; + frame.file = path.relative(ctx.config.root, frame.file); + expect(frame).toMatchInlineSnapshot(` + { + "column": 15, + "file": "basic.test.ts", + "line": 20, + } + `); + } + + if (traceFile.includes("expect-element-fail")) { + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + method: "tracingGroup", + title: "button rendered", + }), + expect.objectContaining({ + method: "tracingGroup", + title: "expect.element().toHaveTextContent [ERROR]", + }), + expect.objectContaining({ + method: "expect", + params: expect.objectContaining({ + selector: + '[data-vitest="true"] >> internal:control=enter-frame >> internal:role=button', + }), + }), + ]), + ); + const frame = events.find((e) => e.title === "expect.element().toHaveTextContent [ERROR]") + ?.stack?.[0]; + frame.file = path.relative(ctx.config.root, frame.file); + expect(frame).toMatchInlineSnapshot(` + { + "column": 15, + "file": "basic.test.ts", + "line": 26, + } + `); + } + + if (traceFile.includes("failure")) { + const frame = events.find((e) => e.title === "onAfterRetryTask [fail]")?.stack?.[0]; + frame.file = path.relative(ctx.config.root, frame.file); + expect(frame).toMatchInlineSnapshot(` + { + "column": 8, + "file": "basic.test.ts", + "line": 31, + } + `); + } + + if (traceFile.includes("click")) { + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + method: "click", + params: expect.objectContaining({ + selector: + '[data-vitest="true"] >> internal:control=enter-frame >> internal:role=button', + }), + }), + ]), + ); + } } } }); @@ -202,15 +259,3 @@ async function readTraceZip(zipPath: string): Promise<{ entries: string[]; event zipFile.close(); } } - -// function hasAfterEvent(events: TraceEvent[], callId: string): boolean { -// return events.some((event): event is TraceAfterEvent => { -// return event.type === "after" && event.callId === callId; -// }); -// } - -// function hasTestFileStack(event: TraceBeforeEvent, fileSuffix: string): boolean { -// return (event.stack || []).some((frame) => { -// return frame.file.replaceAll("\\", "/").endsWith(fileSuffix); -// }); -// } From 80d42c5ec54b78a34da159e711d3c45d9071726a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Feb 2026 13:35:10 +0900 Subject: [PATCH 29/45] chore: lint --- .../specs/playwright-trace-mark.test.ts | 221 ++++++++---------- 1 file changed, 103 insertions(+), 118 deletions(-) diff --git a/test/browser/specs/playwright-trace-mark.test.ts b/test/browser/specs/playwright-trace-mark.test.ts index 166c40d14a3e..12f001b0bca5 100644 --- a/test/browser/specs/playwright-trace-mark.test.ts +++ b/test/browser/specs/playwright-trace-mark.test.ts @@ -1,67 +1,51 @@ -import { createRequire } from "node:module"; -import { readdirSync, rmSync } from "node:fs"; -import { dirname, resolve } from "pathe"; -import { afterEach, describe, expect, test } from "vitest"; -import { instances, provider, runBrowserTests } from "./utils"; -import { buildTestProjectTree } from "../../test-utils"; -import { stripVTControlCharacters } from "node:util"; -import path from "node:path"; - -type TraceBeforeEvent = { - type: "before"; - callId: string; - class: string; - method: string; - title?: string; - stack?: { file: string }[]; -}; - -type TraceAfterEvent = { - type: "after"; - callId: string; -}; - -type TraceEvent = TraceBeforeEvent | TraceAfterEvent | { type: string }; - -type ZipFileType = { - entries: () => Promise; - read: (entryPath: string) => Promise; - close: () => void; -}; +import { readdirSync, rmSync } from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import { stripVTControlCharacters } from 'node:util' +import { dirname, resolve } from 'pathe' +import { afterEach, describe, expect, test } from 'vitest' +import { buildTestProjectTree } from '../../test-utils' +import { instances, provider, runBrowserTests } from './utils' + +interface ZipFileType { + entries: () => Promise + read: (entryPath: string) => Promise + close: () => void +} -const require = createRequire(import.meta.url); -const playwrightEntry = require.resolve("playwright"); +const require = createRequire(import.meta.url) +const playwrightEntry = require.resolve('playwright') const zipFileEntry = resolve( dirname(playwrightEntry), - "../playwright-core/lib/server/utils/zipFile.js", -); -const { ZipFile } = require(zipFileEntry) as { ZipFile: new (fileName: string) => ZipFileType }; + '../playwright-core/lib/server/utils/zipFile.js', +) +const { ZipFile } = require(zipFileEntry) as { ZipFile: new (fileName: string) => ZipFileType } -const tracesFolder = resolve(import.meta.dirname, "../fixtures/trace-mark/__traces__"); -const basicTestTracesFolder = resolve(tracesFolder, "basic.test.ts"); +const tracesFolder = resolve(import.meta.dirname, '../fixtures/trace-mark/__traces__') +const basicTestTracesFolder = resolve(tracesFolder, 'basic.test.ts') -describe.runIf(provider.name === "playwright")("playwright trace marks", () => { +describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { afterEach(() => { - rmSync(tracesFolder, { recursive: true, force: true }); - }); + rmSync(tracesFolder, { recursive: true, force: true }) + }) - test("vitest mark is present in zipped trace events", async () => { + test('vitest mark is present in zipped trace events', async () => { const { results, ctx } = await runBrowserTests({ - root: "./fixtures/trace-mark", + root: './fixtures/trace-mark', browser: { trace: { - mode: "on", + mode: 'on', screenshots: false, // makes it lighter }, }, - }); + }) const projectTree = buildTestProjectTree(results, (testCase) => { - const result = testCase.result(); - return result.state === "failed" - ? result.errors.map((e) => stripVTControlCharacters(e.message)) - : result.state; - }); - expect(Object.keys(projectTree).sort()).toEqual(instances.map((i) => i.browser).sort()); + const result = testCase.result() + return result.state === 'failed' + ? result.errors.map(e => stripVTControlCharacters(e.message)) + : result.state + }) + expect(Object.keys(projectTree).sort()).toEqual(instances.map(i => i.browser).sort()) for (const [name, tree] of Object.entries(projectTree)) { expect.soft(tree).toMatchInlineSnapshot(` @@ -84,178 +68,179 @@ describe.runIf(provider.name === "playwright")("playwright trace marks", () => { "page.mark": "passed", }, } - `); + `) const traceFiles = readdirSync(basicTestTracesFolder) - .filter((file) => file.startsWith(`${name}-`) && file.endsWith(".trace.zip")) - .sort(); + .filter(file => file.startsWith(`${name}-`) && file.endsWith('.trace.zip')) + .sort() expect(traceFiles).toEqual([ - expect.stringContaining("click"), - expect.stringContaining("expect-element-fail"), - expect.stringContaining("expect-element-pass"), - expect.stringContaining("failure"), - expect.stringContaining("locator-mark"), - expect.stringContaining("page-mark"), - ]); + expect.stringContaining('click'), + expect.stringContaining('expect-element-fail'), + expect.stringContaining('expect-element-pass'), + expect.stringContaining('failure'), + expect.stringContaining('locator-mark'), + expect.stringContaining('page-mark'), + ]) for (const traceFile of traceFiles) { - const zipPath = resolve(basicTestTracesFolder, traceFile); - const parsed = await readTraceZip(zipPath); - const events: any[] = parsed.events.filter((event: any) => event.type === "before"); + const zipPath = resolve(basicTestTracesFolder, traceFile) + const parsed = await readTraceZip(zipPath) + const events = parsed.events.filter(event => event.type === 'before') - if (traceFile.includes("locator-mark")) { + if (traceFile.includes('locator-mark')) { expect(events).toEqual( expect.arrayContaining([ expect.objectContaining({ - method: "tracingGroup", - title: "button rendered - locator", + method: 'tracingGroup', + title: 'button rendered - locator', }), expect.objectContaining({ - method: "expect", + method: 'expect', params: expect.objectContaining({ selector: '[data-vitest="true"] >> internal:control=enter-frame >> internal:role=button', }), }), ]), - ); - const frame = events.find((e) => e.title === "button rendered - locator")?.stack?.[0]; - frame.file = path.relative(ctx.config.root, frame.file); + ) + const frame = events.find(e => e.title === 'button rendered - locator')?.stack?.[0] + frame.file = path.relative(ctx.config.root, frame.file) expect(frame).toMatchInlineSnapshot(` { "column": 33, "file": "basic.test.ts", "line": 10, } - `); + `) } - if (traceFile.includes("page-mark")) { + if (traceFile.includes('page-mark')) { expect(events).toEqual( expect.arrayContaining([ expect.objectContaining({ - method: "tracingGroup", - title: "button rendered - page", + method: 'tracingGroup', + title: 'button rendered - page', }), expect.objectContaining({ - method: "evaluateExpression", + method: 'evaluateExpression', }), ]), - ); - const frame = events.find((e) => e.title === "button rendered - page")?.stack?.[0]; - frame.file = path.relative(ctx.config.root, frame.file); + ) + const frame = events.find(e => e.title === 'button rendered - page')?.stack?.[0] + frame.file = path.relative(ctx.config.root, frame.file) expect(frame).toMatchInlineSnapshot(` { "column": 13, "file": "basic.test.ts", "line": 15, } - `); + `) } - if (traceFile.includes("expect-element-pass")) { + if (traceFile.includes('expect-element-pass')) { expect(events).toEqual( expect.arrayContaining([ expect.objectContaining({ - method: "tracingGroup", - title: "expect.element().toHaveTextContent", + method: 'tracingGroup', + title: 'expect.element().toHaveTextContent', }), expect.objectContaining({ - method: "expect", + method: 'expect', params: expect.objectContaining({ selector: '[data-vitest="true"] >> internal:control=enter-frame >> internal:role=button', }), }), ]), - ); - const frame = events.find((e) => e.title === "expect.element().toHaveTextContent") - ?.stack?.[0]; - frame.file = path.relative(ctx.config.root, frame.file); + ) + const frame = events.find(e => e.title === 'expect.element().toHaveTextContent') + ?.stack?.[0] + frame.file = path.relative(ctx.config.root, frame.file) expect(frame).toMatchInlineSnapshot(` { "column": 15, "file": "basic.test.ts", "line": 20, } - `); + `) } - if (traceFile.includes("expect-element-fail")) { + if (traceFile.includes('expect-element-fail')) { expect(events).toEqual( expect.arrayContaining([ expect.objectContaining({ - method: "tracingGroup", - title: "button rendered", + method: 'tracingGroup', + title: 'button rendered', }), expect.objectContaining({ - method: "tracingGroup", - title: "expect.element().toHaveTextContent [ERROR]", + method: 'tracingGroup', + title: 'expect.element().toHaveTextContent [ERROR]', }), expect.objectContaining({ - method: "expect", + method: 'expect', params: expect.objectContaining({ selector: '[data-vitest="true"] >> internal:control=enter-frame >> internal:role=button', }), }), ]), - ); - const frame = events.find((e) => e.title === "expect.element().toHaveTextContent [ERROR]") - ?.stack?.[0]; - frame.file = path.relative(ctx.config.root, frame.file); + ) + const frame = events.find(e => e.title === 'expect.element().toHaveTextContent [ERROR]') + ?.stack?.[0] + frame.file = path.relative(ctx.config.root, frame.file) expect(frame).toMatchInlineSnapshot(` { "column": 15, "file": "basic.test.ts", "line": 26, } - `); + `) } - if (traceFile.includes("failure")) { - const frame = events.find((e) => e.title === "onAfterRetryTask [fail]")?.stack?.[0]; - frame.file = path.relative(ctx.config.root, frame.file); + if (traceFile.includes('failure')) { + const frame = events.find(e => e.title === 'onAfterRetryTask [fail]')?.stack?.[0] + frame.file = path.relative(ctx.config.root, frame.file) expect(frame).toMatchInlineSnapshot(` { "column": 8, "file": "basic.test.ts", "line": 31, } - `); + `) } - if (traceFile.includes("click")) { + if (traceFile.includes('click')) { expect(events).toEqual( expect.arrayContaining([ expect.objectContaining({ - method: "click", + method: 'click', params: expect.objectContaining({ selector: '[data-vitest="true"] >> internal:control=enter-frame >> internal:role=button', }), }), ]), - ); + ) } } } - }); -}); + }) +}) -async function readTraceZip(zipPath: string): Promise<{ entries: string[]; events: TraceEvent[] }> { - const zipFile = new ZipFile(zipPath); +async function readTraceZip(zipPath: string): Promise<{ entries: string[]; events: any[] }> { + const zipFile = new ZipFile(zipPath) try { - const entries = await zipFile.entries(); - const traceText = (await zipFile.read("trace.trace")).toString("utf-8"); + const entries = await zipFile.entries() + const traceText = (await zipFile.read('trace.trace')).toString('utf-8') const events = traceText - .split("\n") + .split('\n') .filter(Boolean) .map((line) => { - return JSON.parse(line) as TraceEvent; - }); - return { entries, events }; - } finally { - zipFile.close(); + return JSON.parse(line) + }) + return { entries, events } + } + finally { + zipFile.close() } } From d721d32f400e2c3983a9ff2e336c57df0ced3ef9 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Feb 2026 13:49:25 +0900 Subject: [PATCH 30/45] test: refactor --- pnpm-lock.yaml | 22 ++++- pnpm-workspace.yaml | 2 + test/browser/package.json | 4 +- .../specs/playwright-trace-mark.test.ts | 84 +++++++++++++++---- 4 files changed, 94 insertions(+), 18 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6201105c9113..4464ec87e747 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ catalogs: '@types/ws': specifier: ^8.18.1 version: 8.18.1 + '@types/yauzl': + specifier: ^2.10.3 + version: 2.10.3 '@unocss/reset': specifier: ^66.6.0 version: 66.6.0 @@ -132,6 +135,9 @@ catalogs: ws: specifier: ^8.19.0 version: 8.19.0 + yauzl: + specifier: ^3.2.0 + version: 3.2.0 overrides: '@types/node': 24.10.9 @@ -1176,6 +1182,9 @@ importers: '@types/react': specifier: ^19.2.10 version: 19.2.10 + '@types/yauzl': + specifier: 'catalog:' + version: 2.10.3 '@vitejs/plugin-basic-ssl': specifier: ^2.1.4 version: 2.1.4(vite@7.1.5(@types/node@24.10.9)(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)) @@ -1218,6 +1227,9 @@ importers: ws: specifier: 'catalog:' version: 8.19.0 + yauzl: + specifier: 'catalog:' + version: 3.2.0 test/cli: devDependencies: @@ -10108,6 +10120,10 @@ packages: yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yauzl@3.2.0: + resolution: {integrity: sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -13069,7 +13085,6 @@ snapshots: '@types/yauzl@2.10.3': dependencies: '@types/node': 24.10.9 - optional: true '@typescript-eslint/eslint-plugin@8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: @@ -19320,6 +19335,11 @@ snapshots: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 + yauzl@3.2.0: + dependencies: + buffer-crc32: 0.2.13 + pend: 1.2.0 + yocto-queue@0.1.0: {} yoctocolors-cjs@2.1.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e09518e9a6e1..dd38a548a033 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -58,6 +58,7 @@ catalog: '@types/istanbul-lib-source-maps': ^4.0.4 '@types/istanbul-reports': ^3.0.4 '@types/ws': ^8.18.1 + '@types/yauzl': ^2.10.3 '@unocss/reset': ^66.6.0 '@vitejs/plugin-vue': ^6.0.4 '@vueuse/core': ^14.2.0 @@ -88,6 +89,7 @@ catalog: vitest-sonar-reporter: 3.0.0 vue: ^3.5.27 ws: ^8.19.0 + yauzl: ^3.2.0 onlyBuiltDependencies: - '@sveltejs/kit' - '@swc/core' diff --git a/test/browser/package.json b/test/browser/package.json index 8e95a5e69b29..6fac7babe40d 100644 --- a/test/browser/package.json +++ b/test/browser/package.json @@ -31,6 +31,7 @@ }, "devDependencies": { "@types/react": "^19.2.10", + "@types/yauzl": "catalog:", "@vitejs/plugin-basic-ssl": "^2.1.4", "@vitest/browser": "workspace:*", "@vitest/browser-playwright": "workspace:*", @@ -44,6 +45,7 @@ "url": "^0.11.4", "vitest": "workspace:*", "vitest-browser-react": "^2.0.5", - "ws": "catalog:" + "ws": "catalog:", + "yauzl": "catalog:" } } diff --git a/test/browser/specs/playwright-trace-mark.test.ts b/test/browser/specs/playwright-trace-mark.test.ts index 12f001b0bca5..bfaa2ad84bd1 100644 --- a/test/browser/specs/playwright-trace-mark.test.ts +++ b/test/browser/specs/playwright-trace-mark.test.ts @@ -1,26 +1,12 @@ import { readdirSync, rmSync } from 'node:fs' -import { createRequire } from 'node:module' import path from 'node:path' import { stripVTControlCharacters } from 'node:util' -import { dirname, resolve } from 'pathe' +import { resolve } from 'pathe' import { afterEach, describe, expect, test } from 'vitest' +import * as yauzl from 'yauzl' import { buildTestProjectTree } from '../../test-utils' import { instances, provider, runBrowserTests } from './utils' -interface ZipFileType { - entries: () => Promise - read: (entryPath: string) => Promise - close: () => void -} - -const require = createRequire(import.meta.url) -const playwrightEntry = require.resolve('playwright') -const zipFileEntry = resolve( - dirname(playwrightEntry), - '../playwright-core/lib/server/utils/zipFile.js', -) -const { ZipFile } = require(zipFileEntry) as { ZipFile: new (fileName: string) => ZipFileType } - const tracesFolder = resolve(import.meta.dirname, '../fixtures/trace-mark/__traces__') const basicTestTracesFolder = resolve(tracesFolder, 'basic.test.ts') @@ -244,3 +230,69 @@ async function readTraceZip(zipPath: string): Promise<{ entries: string[]; event zipFile.close() } } + +// https://github.com/microsoft/playwright/blob/cd36dab6ecc7f4b3adeec333e55f9ac03711a9b1/packages/playwright-core/src/server/utils/zipFile.ts#L21 +class ZipFile { + private readonly fileName: string + private zipFile?: yauzl.ZipFile + private readonly openedPromise: Promise + private readonly entriesMap = new Map() + + constructor(fileName: string) { + this.fileName = fileName + this.openedPromise = this.open() + } + + private async open(): Promise { + this.zipFile = await new Promise((resolve, reject) => { + yauzl.open(this.fileName, { lazyEntries: true, autoClose: false }, (error, zipFile) => { + if (error || !zipFile) { + reject(error || new Error(`Cannot open zip: ${this.fileName}`)) + return + } + resolve(zipFile) + }) + }) + + await new Promise((resolve, reject) => { + this.zipFile!.readEntry() + this.zipFile!.on('entry', (entry) => { + this.entriesMap.set(entry.fileName, entry) + this.zipFile!.readEntry() + }) + this.zipFile!.on('end', resolve) + this.zipFile!.on('error', reject) + }) + } + + async entries(): Promise { + await this.openedPromise + return [...this.entriesMap.keys()] + } + + async read(entryPath: string): Promise { + await this.openedPromise + const entry = this.entriesMap.get(entryPath) + if (!entry || !this.zipFile) { + throw new Error(`${entryPath} not found in file ${this.fileName}`) + } + + return await new Promise((resolve, reject) => { + this.zipFile!.openReadStream(entry, (error, stream) => { + if (error || !stream) { + reject(error || new Error(`Cannot read ${entryPath} from file ${this.fileName}`)) + return + } + + const buffers: Buffer[] = [] + stream.on('data', data => buffers.push(data)) + stream.on('error', reject) + stream.on('end', () => resolve(Buffer.concat(buffers))) + }) + }) + } + + close(): void { + this.zipFile?.close() + } +} From a409b3edc8386d7207ba3252a04649c7db0a02a8 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Feb 2026 14:57:47 +0900 Subject: [PATCH 31/45] test: branch webkit --- .../specs/playwright-trace-mark.test.ts | 125 +++++++++++++----- 1 file changed, 90 insertions(+), 35 deletions(-) diff --git a/test/browser/specs/playwright-trace-mark.test.ts b/test/browser/specs/playwright-trace-mark.test.ts index bfaa2ad84bd1..0f2b04be7ac4 100644 --- a/test/browser/specs/playwright-trace-mark.test.ts +++ b/test/browser/specs/playwright-trace-mark.test.ts @@ -91,13 +91,24 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { ) const frame = events.find(e => e.title === 'button rendered - locator')?.stack?.[0] frame.file = path.relative(ctx.config.root, frame.file) - expect(frame).toMatchInlineSnapshot(` - { - "column": 33, - "file": "basic.test.ts", - "line": 10, - } - `) + if (name === 'webkit') { + expect(frame).toMatchInlineSnapshot(` + { + "column": 38, + "file": "basic.test.ts", + "line": 10, + } + `) + } + else { + expect(frame).toMatchInlineSnapshot(` + { + "column": 33, + "file": "basic.test.ts", + "line": 10, + } + `) + } } if (traceFile.includes('page-mark')) { @@ -114,13 +125,24 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { ) const frame = events.find(e => e.title === 'button rendered - page')?.stack?.[0] frame.file = path.relative(ctx.config.root, frame.file) - expect(frame).toMatchInlineSnapshot(` - { - "column": 13, - "file": "basic.test.ts", - "line": 15, - } - `) + if (name === 'webkit') { + expect(frame).toMatchInlineSnapshot(` + { + "column": 18, + "file": "basic.test.ts", + "line": 15, + } + `) + } + else { + expect(frame).toMatchInlineSnapshot(` + { + "column": 13, + "file": "basic.test.ts", + "line": 15, + } + `) + } } if (traceFile.includes('expect-element-pass')) { @@ -142,13 +164,24 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { const frame = events.find(e => e.title === 'expect.element().toHaveTextContent') ?.stack?.[0] frame.file = path.relative(ctx.config.root, frame.file) - expect(frame).toMatchInlineSnapshot(` - { - "column": 15, - "file": "basic.test.ts", - "line": 20, - } - `) + if (name === 'webkit') { + expect(frame).toMatchInlineSnapshot(` + { + "column": 23, + "file": "basic.test.ts", + "line": 20, + } + `) + } + else { + expect(frame).toMatchInlineSnapshot(` + { + "column": 15, + "file": "basic.test.ts", + "line": 20, + } + `) + } } if (traceFile.includes('expect-element-fail')) { @@ -174,25 +207,47 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { const frame = events.find(e => e.title === 'expect.element().toHaveTextContent [ERROR]') ?.stack?.[0] frame.file = path.relative(ctx.config.root, frame.file) - expect(frame).toMatchInlineSnapshot(` - { - "column": 15, - "file": "basic.test.ts", - "line": 26, - } - `) + if (name === 'webkit') { + expect(frame).toMatchInlineSnapshot(` + { + "column": 23, + "file": "basic.test.ts", + "line": 26, + } + `) + } + else { + expect(frame).toMatchInlineSnapshot(` + { + "column": 15, + "file": "basic.test.ts", + "line": 26, + } + `) + } } if (traceFile.includes('failure')) { const frame = events.find(e => e.title === 'onAfterRetryTask [fail]')?.stack?.[0] frame.file = path.relative(ctx.config.root, frame.file) - expect(frame).toMatchInlineSnapshot(` - { - "column": 8, - "file": "basic.test.ts", - "line": 31, - } - `) + if (name === 'webkit') { + expect(frame).toMatchInlineSnapshot(` + { + "column": 18, + "file": "basic.test.ts", + "line": 31, + } + `) + } + else { + expect(frame).toMatchInlineSnapshot(` + { + "column": 8, + "file": "basic.test.ts", + "line": 31, + } + `) + } } if (traceFile.includes('click')) { From 32067b59fd08a4589f7fe6a000fb1766562ce4a5 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Feb 2026 15:18:04 +0900 Subject: [PATCH 32/45] test: refactor --- .../specs/playwright-trace-mark.test.ts | 111 +++++------------- 1 file changed, 29 insertions(+), 82 deletions(-) diff --git a/test/browser/specs/playwright-trace-mark.test.ts b/test/browser/specs/playwright-trace-mark.test.ts index 0f2b04be7ac4..35b7d6ce3ec8 100644 --- a/test/browser/specs/playwright-trace-mark.test.ts +++ b/test/browser/specs/playwright-trace-mark.test.ts @@ -68,6 +68,15 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { expect.stringContaining('page-mark'), ]) + function formatStack(event: any) { + return event.stack + ?.map( + (frame: any) => + `${path.relative(ctx.config.root, frame.file)}:${frame.line}:${frame.column}`, + ) + .join('\n') + } + for (const traceFile of traceFiles) { const zipPath = resolve(basicTestTracesFolder, traceFile) const parsed = await readTraceZip(zipPath) @@ -89,25 +98,13 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { }), ]), ) - const frame = events.find(e => e.title === 'button rendered - locator')?.stack?.[0] - frame.file = path.relative(ctx.config.root, frame.file) + const markerEvent = events.find(e => e.title === 'button rendered - locator') + const formattedFrame = formatStack(markerEvent) if (name === 'webkit') { - expect(frame).toMatchInlineSnapshot(` - { - "column": 38, - "file": "basic.test.ts", - "line": 10, - } - `) + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:10:38"`) } else { - expect(frame).toMatchInlineSnapshot(` - { - "column": 33, - "file": "basic.test.ts", - "line": 10, - } - `) + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:10:33"`) } } @@ -123,25 +120,13 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { }), ]), ) - const frame = events.find(e => e.title === 'button rendered - page')?.stack?.[0] - frame.file = path.relative(ctx.config.root, frame.file) + const markerEvent = events.find(e => e.title === 'button rendered - page') + const formattedFrame = formatStack(markerEvent) if (name === 'webkit') { - expect(frame).toMatchInlineSnapshot(` - { - "column": 18, - "file": "basic.test.ts", - "line": 15, - } - `) + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:15:18"`) } else { - expect(frame).toMatchInlineSnapshot(` - { - "column": 13, - "file": "basic.test.ts", - "line": 15, - } - `) + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:15:13"`) } } @@ -161,26 +146,13 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { }), ]), ) - const frame = events.find(e => e.title === 'expect.element().toHaveTextContent') - ?.stack?.[0] - frame.file = path.relative(ctx.config.root, frame.file) + const markerEvent = events.find(e => e.title === 'expect.element().toHaveTextContent') + const formattedFrame = formatStack(markerEvent) if (name === 'webkit') { - expect(frame).toMatchInlineSnapshot(` - { - "column": 23, - "file": "basic.test.ts", - "line": 20, - } - `) + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:20:23"`) } else { - expect(frame).toMatchInlineSnapshot(` - { - "column": 15, - "file": "basic.test.ts", - "line": 20, - } - `) + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:20:15"`) } } @@ -204,49 +176,24 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { }), ]), ) - const frame = events.find(e => e.title === 'expect.element().toHaveTextContent [ERROR]') - ?.stack?.[0] - frame.file = path.relative(ctx.config.root, frame.file) + const markerEvent = events.find(e => e.title === 'expect.element().toHaveTextContent [ERROR]') + const formattedFrame = formatStack(markerEvent) if (name === 'webkit') { - expect(frame).toMatchInlineSnapshot(` - { - "column": 23, - "file": "basic.test.ts", - "line": 26, - } - `) + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:26:23"`) } else { - expect(frame).toMatchInlineSnapshot(` - { - "column": 15, - "file": "basic.test.ts", - "line": 26, - } - `) + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:26:15"`) } } if (traceFile.includes('failure')) { - const frame = events.find(e => e.title === 'onAfterRetryTask [fail]')?.stack?.[0] - frame.file = path.relative(ctx.config.root, frame.file) + const markerEvent = events.find(e => e.title === 'onAfterRetryTask [fail]') + const formattedFrame = formatStack(markerEvent) if (name === 'webkit') { - expect(frame).toMatchInlineSnapshot(` - { - "column": 18, - "file": "basic.test.ts", - "line": 31, - } - `) + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:31:18"`) } else { - expect(frame).toMatchInlineSnapshot(` - { - "column": 8, - "file": "basic.test.ts", - "line": 31, - } - `) + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:31:8"`) } } From 538e537fcfabdfb668e5cedb9251ffea97bd7995 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Feb 2026 16:04:08 +0900 Subject: [PATCH 33/45] test: test vi.defineHelper --- .../browser/fixtures/trace-mark/basic.test.ts | 11 +++++++++- .../specs/playwright-trace-mark.test.ts | 22 ++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/test/browser/fixtures/trace-mark/basic.test.ts b/test/browser/fixtures/trace-mark/basic.test.ts index 61515b6f5e4b..0c8580585160 100644 --- a/test/browser/fixtures/trace-mark/basic.test.ts +++ b/test/browser/fixtures/trace-mark/basic.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, expect, test } from "vitest"; +import { beforeEach, expect, test, vi } from "vitest"; import { page } from "vitest/browser"; beforeEach(() => { @@ -35,3 +35,12 @@ test("click", async () => { document.body.innerHTML = ""; await page.getByRole("button").click(); }); + +const myRender = vi.defineHelper(async (content: string) => { + document.body.innerHTML = content; + await page.elementLocator(document.body).mark("render helper"); +}); + +test("helper", async () => { + await myRender(""); +}); diff --git a/test/browser/specs/playwright-trace-mark.test.ts b/test/browser/specs/playwright-trace-mark.test.ts index 35b7d6ce3ec8..d97bf0f6aee2 100644 --- a/test/browser/specs/playwright-trace-mark.test.ts +++ b/test/browser/specs/playwright-trace-mark.test.ts @@ -50,6 +50,7 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { "failure": [ "Test failure", ], + "helper": "passed", "locator.mark": "passed", "page.mark": "passed", }, @@ -64,6 +65,7 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { expect.stringContaining('expect-element-fail'), expect.stringContaining('expect-element-pass'), expect.stringContaining('failure'), + expect.stringContaining('helper'), expect.stringContaining('locator-mark'), expect.stringContaining('page-mark'), ]) @@ -108,7 +110,7 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { } } - if (traceFile.includes('page-mark')) { + if (traceFile.includes('page-mark') && !traceFile.includes('custom-stack')) { expect(events).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -210,6 +212,24 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { ]), ) } + + if (traceFile.includes('helper')) { + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + title: 'render helper', + }), + ]), + ) + const markerEvent = events.find(e => e.title === 'render helper') + const formattedFrame = formatStack(markerEvent) + if (name === 'webkit') { + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:45:17"`) + } + else { + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:45:8"`) + } + } } } }) From c7087bd3f979865b3a04fd8e62948756a9ec27fd Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Feb 2026 16:16:10 +0900 Subject: [PATCH 34/45] feat: support custom stack --- docs/api/browser/context.md | 6 +++-- docs/api/browser/locators.md | 4 +++- packages/browser/context.d.ts | 12 ++++++++-- packages/browser/src/client/tester/context.ts | 5 +++-- .../src/client/tester/locators/index.ts | 5 +++-- .../browser/fixtures/trace-mark/basic.test.ts | 6 +++++ .../specs/playwright-trace-mark.test.ts | 22 ++++++++++++++++++- 7 files changed, 50 insertions(+), 10 deletions(-) diff --git a/docs/api/browser/context.md b/docs/api/browser/context.md index cf618714764e..061ff618fd40 100644 --- a/docs/api/browser/context.md +++ b/docs/api/browser/context.md @@ -82,7 +82,7 @@ export const page: { /** * Add a trace marker when browser tracing is enabled. */ - mark(name: string): Promise + mark(name: string, options?: { stack?: string }): Promise /** * Extend default `page` object with custom methods. */ @@ -123,11 +123,13 @@ The `path` is also ignored in that case. ### mark ```ts -function mark(name: string): Promise +function mark(name: string, options?: { stack?: string }): Promise ``` Adds a named marker to the trace timeline for the current test. +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. + ```ts import { page } from 'vitest/browser' diff --git a/docs/api/browser/locators.md b/docs/api/browser/locators.md index 421abf1581d7..f58f41f74b8f 100644 --- a/docs/api/browser/locators.md +++ b/docs/api/browser/locators.md @@ -823,11 +823,13 @@ The `path` is also ignored in that case. ### mark ```ts -function mark(name: string): Promise +function mark(name: string, options?: { stack?: string }): Promise ``` Adds a named marker to the trace timeline and uses the current locator as marker context. +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. + ```ts import { page } from 'vitest/browser' diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index 1aa03bf426f2..76d8faffcf1a 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -40,6 +40,14 @@ export interface ScreenshotOptions { save?: boolean } +export interface MarkOptions { + /** + * Optional stack string used to resolve marker location. + * Useful for wrapper libraries that need to forward the end-user callsite. + */ + stack?: string +} + interface StandardScreenshotComparators { pixelmatch: { /** @@ -631,7 +639,7 @@ export interface Locator extends LocatorSelectors { * Add a trace marker for this locator when browser tracing is enabled. * @see {@link https://vitest.dev/api/browser/locators#mark} */ - mark(name: string): Promise + mark(name: string, options?: MarkOptions): Promise /** * Returns an element matching the selector. @@ -786,7 +794,7 @@ export interface BrowserPage extends LocatorSelectors { * Add a trace marker when browser tracing is enabled. * @see {@link https://vitest.dev/api/browser/context#mark} */ - mark(name: string): Promise + mark(name: string, options?: MarkOptions): Promise /** * Extend default `page` object with custom methods. */ diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts index cba3aee49b4a..18c923c40973 100644 --- a/packages/browser/src/client/tester/context.ts +++ b/packages/browser/src/client/tester/context.ts @@ -8,6 +8,7 @@ import type { BrowserPage, Locator, LocatorSelectors, + MarkOptions, UserEvent, UserEventWheelOptions, } from 'vitest/browser' @@ -344,7 +345,7 @@ export const page: BrowserPage = { error, )) }, - mark(name) { + mark(name: string, options?: MarkOptions) { const currentTest = getWorkerState().current if (!currentTest || !getBrowserState().activeTraceTaskIds.has(currentTest.id)) { return Promise.resolve() @@ -353,7 +354,7 @@ export const page: BrowserPage = { '__vitest_markTrace', [{ name, - stack: error?.stack, + stack: options?.stack ?? error?.stack, }], error, )) diff --git a/packages/browser/src/client/tester/locators/index.ts b/packages/browser/src/client/tester/locators/index.ts index 41f0b9a6d973..343b8c4a5a4d 100644 --- a/packages/browser/src/client/tester/locators/index.ts +++ b/packages/browser/src/client/tester/locators/index.ts @@ -3,6 +3,7 @@ import type { LocatorByRoleOptions, LocatorOptions, LocatorScreenshotOptions, + MarkOptions, UserEventClearOptions, UserEventClickOptions, UserEventDragAndDropOptions, @@ -180,7 +181,7 @@ export abstract class Locator { }) } - public mark(name: string): Promise { + public mark(name: string, options?: MarkOptions): Promise { const currentTest = getWorkerState().current if (!currentTest || !getBrowserState().activeTraceTaskIds.has(currentTest.id)) { return Promise.resolve() @@ -190,7 +191,7 @@ export abstract class Locator { [{ name, selector: this.selector, - stack: error?.stack, + stack: options?.stack ?? error?.stack, }], error, )) diff --git a/test/browser/fixtures/trace-mark/basic.test.ts b/test/browser/fixtures/trace-mark/basic.test.ts index 0c8580585160..77ce00f43ae4 100644 --- a/test/browser/fixtures/trace-mark/basic.test.ts +++ b/test/browser/fixtures/trace-mark/basic.test.ts @@ -44,3 +44,9 @@ const myRender = vi.defineHelper(async (content: string) => { test("helper", async () => { await myRender(""); }); + +test("stack", async () => { + document.body.innerHTML = ""; + const error = new Error("Custom error for stack trace"); + await page.getByRole("button").mark("button rendered - stack", { stack: error.stack }); +}); diff --git a/test/browser/specs/playwright-trace-mark.test.ts b/test/browser/specs/playwright-trace-mark.test.ts index d97bf0f6aee2..b940d359e8bc 100644 --- a/test/browser/specs/playwright-trace-mark.test.ts +++ b/test/browser/specs/playwright-trace-mark.test.ts @@ -53,6 +53,7 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { "helper": "passed", "locator.mark": "passed", "page.mark": "passed", + "stack": "passed", }, } `) @@ -60,7 +61,7 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { const traceFiles = readdirSync(basicTestTracesFolder) .filter(file => file.startsWith(`${name}-`) && file.endsWith('.trace.zip')) .sort() - expect(traceFiles).toEqual([ + expect.soft(traceFiles).toEqual([ expect.stringContaining('click'), expect.stringContaining('expect-element-fail'), expect.stringContaining('expect-element-pass'), @@ -68,6 +69,7 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { expect.stringContaining('helper'), expect.stringContaining('locator-mark'), expect.stringContaining('page-mark'), + expect.stringContaining('stack'), ]) function formatStack(event: any) { @@ -230,6 +232,24 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:45:8"`) } } + + if (traceFile.includes('stack')) { + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + title: 'button rendered - stack', + }), + ]), + ) + const markerEvent = events.find(e => e.title === 'button rendered - stack') + const formattedFrame = formatStack(markerEvent) + if (name === 'webkit') { + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:50:26"`) + } + else { + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:50:16"`) + } + } } } }) From c529452a4b0914afa656d6e2f9da5408ac1e375f Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Feb 2026 17:14:50 +0900 Subject: [PATCH 35/45] feat: describe browser-playwright locators in traces Annotate iframe locator actions with Playwright locator descriptions so trace labels stay focused on end-user selectors instead of Vitest iframe plumbing. --- packages/browser-playwright/package.json | 1 + packages/browser-playwright/src/commands/clear.ts | 4 ++-- packages/browser-playwright/src/commands/click.ts | 10 ++++------ packages/browser-playwright/src/commands/fill.ts | 4 ++-- packages/browser-playwright/src/commands/hover.ts | 3 ++- .../browser-playwright/src/commands/screenshot.ts | 7 ++++--- packages/browser-playwright/src/commands/select.ts | 6 +++--- packages/browser-playwright/src/commands/trace.ts | 3 ++- packages/browser-playwright/src/commands/type.ts | 4 ++-- packages/browser-playwright/src/commands/upload.ts | 4 ++-- packages/browser-playwright/src/commands/utils.ts | 13 ++++++++++++- pnpm-lock.yaml | 3 +++ 12 files changed, 39 insertions(+), 23 deletions(-) diff --git a/packages/browser-playwright/package.json b/packages/browser-playwright/package.json index 81de474c4a3f..dbcc7ecef630 100644 --- a/packages/browser-playwright/package.json +++ b/packages/browser-playwright/package.json @@ -56,6 +56,7 @@ "dependencies": { "@vitest/browser": "workspace:*", "@vitest/mocker": "workspace:*", + "ivya": "^1.7.1", "tinyrainbow": "catalog:" }, "devDependencies": { diff --git a/packages/browser-playwright/src/commands/clear.ts b/packages/browser-playwright/src/commands/clear.ts index 6736b36f70eb..4eb52b289e30 100644 --- a/packages/browser-playwright/src/commands/clear.ts +++ b/packages/browser-playwright/src/commands/clear.ts @@ -1,11 +1,11 @@ import type { UserEvent } from 'vitest/browser' import type { UserEventCommand } from './utils' +import { getDescribedLocator } from './utils' export const clear: UserEventCommand = async ( context, selector, ) => { - const { iframe } = context - const element = iframe.locator(selector) + const element = getDescribedLocator(context, selector) await element.clear() } diff --git a/packages/browser-playwright/src/commands/click.ts b/packages/browser-playwright/src/commands/click.ts index 75261a93aed3..447aa5855afb 100644 --- a/packages/browser-playwright/src/commands/click.ts +++ b/packages/browser-playwright/src/commands/click.ts @@ -1,13 +1,13 @@ import type { UserEvent } from 'vitest/browser' import type { UserEventCommand } from './utils' +import { getDescribedLocator } from './utils' export const click: UserEventCommand = async ( context, selector, options = {}, ) => { - const tester = context.iframe - await tester.locator(selector).click(options) + await getDescribedLocator(context, selector).click(options) } export const dblClick: UserEventCommand = async ( @@ -15,8 +15,7 @@ export const dblClick: UserEventCommand = async ( selector, options = {}, ) => { - const tester = context.iframe - await tester.locator(selector).dblclick(options) + await getDescribedLocator(context, selector).dblclick(options) } export const tripleClick: UserEventCommand = async ( @@ -24,8 +23,7 @@ export const tripleClick: UserEventCommand = async ( selector, options = {}, ) => { - const tester = context.iframe - await tester.locator(selector).click({ + await getDescribedLocator(context, selector).click({ ...options, clickCount: 3, }) diff --git a/packages/browser-playwright/src/commands/fill.ts b/packages/browser-playwright/src/commands/fill.ts index a0a6b2dd3612..4007d089c870 100644 --- a/packages/browser-playwright/src/commands/fill.ts +++ b/packages/browser-playwright/src/commands/fill.ts @@ -1,5 +1,6 @@ import type { UserEvent } from 'vitest/browser' import type { UserEventCommand } from './utils' +import { getDescribedLocator } from './utils' export const fill: UserEventCommand = async ( context, @@ -7,7 +8,6 @@ export const fill: UserEventCommand = async ( text, options = {}, ) => { - const { iframe } = context - const element = iframe.locator(selector) + const element = getDescribedLocator(context, selector) await element.fill(text, options) } diff --git a/packages/browser-playwright/src/commands/hover.ts b/packages/browser-playwright/src/commands/hover.ts index 30afcd259073..6e97bb4855a9 100644 --- a/packages/browser-playwright/src/commands/hover.ts +++ b/packages/browser-playwright/src/commands/hover.ts @@ -1,10 +1,11 @@ import type { UserEvent } from 'vitest/browser' import type { UserEventCommand } from './utils' +import { getDescribedLocator } from './utils' export const hover: UserEventCommand = async ( context, selector, options = {}, ) => { - await context.iframe.locator(selector).hover(options) + await getDescribedLocator(context, selector).hover(options) } diff --git a/packages/browser-playwright/src/commands/screenshot.ts b/packages/browser-playwright/src/commands/screenshot.ts index 9e6c18a76dde..f40adacc7031 100644 --- a/packages/browser-playwright/src/commands/screenshot.ts +++ b/packages/browser-playwright/src/commands/screenshot.ts @@ -3,6 +3,7 @@ import type { BrowserCommandContext } from 'vitest/node' import { mkdir } from 'node:fs/promises' import { resolveScreenshotPath } from '@vitest/browser' import { dirname, normalize } from 'pathe' +import { getDescribedLocator } from './utils' interface ScreenshotCommandOptions extends Omit { element?: string @@ -41,11 +42,11 @@ export async function takeScreenshot( await mkdir(dirname(savePath), { recursive: true }) } - const mask = options.mask?.map(selector => context.iframe.locator(selector)) + const mask = options.mask?.map(selector => getDescribedLocator(context, selector)) if (options.element) { const { element: selector, ...config } = options - const element = context.iframe.locator(selector) + const element = getDescribedLocator(context, selector) const buffer = await element.screenshot({ ...config, mask, @@ -54,7 +55,7 @@ export async function takeScreenshot( return { buffer, path } } - const buffer = await context.iframe.locator('body').screenshot({ + const buffer = await getDescribedLocator(context, 'body').screenshot({ ...options, mask, path: savePath, diff --git a/packages/browser-playwright/src/commands/select.ts b/packages/browser-playwright/src/commands/select.ts index 4fbcb972b87a..b18e17518400 100644 --- a/packages/browser-playwright/src/commands/select.ts +++ b/packages/browser-playwright/src/commands/select.ts @@ -1,6 +1,7 @@ import type { ElementHandle } from 'playwright' import type { UserEvent } from 'vitest/browser' import type { UserEventCommand } from './utils' +import { getDescribedLocator } from './utils' export const selectOptions: UserEventCommand = async ( context, @@ -9,14 +10,13 @@ export const selectOptions: UserEventCommand = async options = {}, ) => { const value = userValues as any as (string | { element: string })[] - const { iframe } = context - const selectElement = iframe.locator(selector) + const selectElement = getDescribedLocator(context, selector) const values = await Promise.all(value.map(async (v) => { if (typeof v === 'string') { return v } - const elementHandler = await iframe.locator(v.element).elementHandle() + const elementHandler = await getDescribedLocator(context, v.element).elementHandle() if (!elementHandler) { throw new Error(`Element not found: ${v.element}`) } diff --git a/packages/browser-playwright/src/commands/trace.ts b/packages/browser-playwright/src/commands/trace.ts index 8bb2781e1ab0..6271eea9c5ff 100644 --- a/packages/browser-playwright/src/commands/trace.ts +++ b/packages/browser-playwright/src/commands/trace.ts @@ -3,6 +3,7 @@ import type { BrowserCommand, BrowserCommandContext, BrowserProvider } from 'vit import type { PlaywrightBrowserProvider } from '../playwright' import { unlink } from 'node:fs/promises' import { basename, dirname, relative, resolve } from 'pathe' +import { getDescribedLocator } from './utils' export const startTracing: BrowserCommand<[]> = async ({ context, project, provider, sessionId }) => { if (isPlaywrightProvider(provider)) { @@ -76,7 +77,7 @@ export const markTrace: BrowserCommand<[payload: { name: string; selector?: stri await context.context.tracing.group(name, { location }) try { if (selector) { - const locator = context.iframe.locator(selector) as any + const locator = getDescribedLocator(context, selector) as any if (typeof locator._expect === 'function') { await locator._expect('to.be.attached', { isNot: false, diff --git a/packages/browser-playwright/src/commands/type.ts b/packages/browser-playwright/src/commands/type.ts index 2a3d6469f578..c966f549bfd7 100644 --- a/packages/browser-playwright/src/commands/type.ts +++ b/packages/browser-playwright/src/commands/type.ts @@ -1,6 +1,7 @@ import type { UserEvent } from 'vitest/browser' import type { UserEventCommand } from './utils' import { keyboardImplementation } from './keyboard' +import { getDescribedLocator } from './utils' export const type: UserEventCommand = async ( context, @@ -11,8 +12,7 @@ export const type: UserEventCommand = async ( const { skipClick = false, skipAutoClose = false } = options const unreleased = new Set(Reflect.get(options, 'unreleased') as string[] ?? []) - const { iframe } = context - const element = iframe.locator(selector) + const element = getDescribedLocator(context, selector) if (!skipClick) { await element.focus() diff --git a/packages/browser-playwright/src/commands/upload.ts b/packages/browser-playwright/src/commands/upload.ts index 2a39afabccfd..2f2f9763d7be 100644 --- a/packages/browser-playwright/src/commands/upload.ts +++ b/packages/browser-playwright/src/commands/upload.ts @@ -1,6 +1,7 @@ import type { UserEventUploadOptions } from 'vitest/browser' import type { UserEventCommand } from './utils' import { resolve } from 'pathe' +import { getDescribedLocator } from './utils' export const upload: UserEventCommand<(element: string, files: Array { if (typeof file === 'string') { return resolve(root, file) @@ -29,5 +29,5 @@ export const upload: UserEventCommand<(element: string, files: Array any> = BrowserCommand< ConvertUserEventParameters> @@ -15,3 +16,13 @@ export function defineBrowserCommand( ): BrowserCommand { return fn } + +export function getDescribedLocator( + context: BrowserCommandContext, + selector: string, +): ReturnType { + const locator = context.iframe.locator(selector) + return typeof locator.describe === 'function' + ? locator.describe(asLocator('javascript', selector)) + : locator +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4464ec87e747..2c838059c5ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -566,6 +566,9 @@ importers: '@vitest/mocker': specifier: workspace:* version: link:../mocker + ivya: + specifier: ^1.7.1 + version: 1.7.1 tinyrainbow: specifier: 'catalog:' version: 3.0.3 From 3763d0bba4095d57cf1022ac4c7b0cce4d824669 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Feb 2026 18:44:59 +0900 Subject: [PATCH 36/45] feat: support mark(name, () => {}) --- examples/lit/test/basic.test.ts | 6 +- .../browser-playwright/src/commands/index.ts | 4 ++ .../browser-playwright/src/commands/trace.ts | 43 +++++++++++++-- packages/browser/context.d.ts | 10 ++++ packages/browser/src/client/tester/context.ts | 54 +++++++++++++++++- .../browser/src/client/tester/tester-utils.ts | 55 ++++++++++++++++++- packages/browser/src/node/commands/index.ts | 4 +- packages/browser/src/node/commands/trace.ts | 30 ++++++++++ .../specs/playwright-trace-mark.test.ts | 18 +++++- 9 files changed, 208 insertions(+), 16 deletions(-) diff --git a/examples/lit/test/basic.test.ts b/examples/lit/test/basic.test.ts index c2ed1b578a8a..4942b8f189b7 100644 --- a/examples/lit/test/basic.test.ts +++ b/examples/lit/test/basic.test.ts @@ -5,8 +5,10 @@ import '../src/my-button.js' describe('Button with increment', async () => { beforeEach(async () => { - document.body.innerHTML = '' - await page.getByRole('button').mark('render') + await page.mark('render', async () => { + document.body.innerHTML = '' + await page.getByRole('button').mark('render button') + }) await page.getByRole('heading').mark('heading') }) diff --git a/packages/browser-playwright/src/commands/index.ts b/packages/browser-playwright/src/commands/index.ts index f6b130f1abea..390c1649ee8e 100644 --- a/packages/browser-playwright/src/commands/index.ts +++ b/packages/browser-playwright/src/commands/index.ts @@ -10,6 +10,8 @@ import { tab } from './tab' import { annotateTraces, deleteTracing, + groupTraceEnd, + groupTraceStart, markTrace, startChunkTrace, startTracing, @@ -41,4 +43,6 @@ export default { __vitest_stopChunkTrace: stopChunkTrace as typeof stopChunkTrace, __vitest_annotateTraces: annotateTraces as typeof annotateTraces, __vitest_markTrace: markTrace as typeof markTrace, + __vitest_groupTraceStart: groupTraceStart as typeof groupTraceStart, + __vitest_groupTraceEnd: groupTraceEnd as typeof groupTraceEnd, } diff --git a/packages/browser-playwright/src/commands/trace.ts b/packages/browser-playwright/src/commands/trace.ts index 6271eea9c5ff..ca01224f4674 100644 --- a/packages/browser-playwright/src/commands/trace.ts +++ b/packages/browser-playwright/src/commands/trace.ts @@ -68,11 +68,7 @@ export const markTrace: BrowserCommand<[payload: { name: string; selector?: stri return } const { name, selector, stack } = payload - let location: ParsedStack | undefined - if (stack) { - const parsedStacks = context.project.browser!.parseStacktrace(stack) - location = parsedStacks[0] - } + const location = parseLocation(context, stack) // mark trace via group/groupEnd with dummy calls to force snapshot. await context.context.tracing.group(name, { location }) try { @@ -99,6 +95,43 @@ export const markTrace: BrowserCommand<[payload: { name: string; selector?: stri throw new TypeError(`The ${context.provider.name} provider does not support tracing.`) } +export const groupTraceStart: BrowserCommand<[payload: { name: string; stack?: string }]> = async ( + context, + payload, +) => { + if (isPlaywrightProvider(context.provider)) { + if (!context.provider.tracingContexts.has(context.sessionId)) { + return + } + const { name, stack } = payload + const location = parseLocation(context, stack) + await context.context.tracing.group(name, { location }) + return + } + throw new TypeError(`The ${context.provider.name} provider does not support tracing.`) +} + +export const groupTraceEnd: BrowserCommand<[]> = async ( + context, +) => { + if (isPlaywrightProvider(context.provider)) { + if (!context.provider.tracingContexts.has(context.sessionId)) { + return + } + await context.context.tracing.groupEnd() + return + } + throw new TypeError(`The ${context.provider.name} provider does not support tracing.`) +} + +function parseLocation(context: BrowserCommandContext, stack?: string): ParsedStack | undefined { + if (!stack) { + return + } + const parsedStacks = context.project.browser!.parseStacktrace(stack) + return parsedStacks[0] +} + function resolveTracesPath({ testPath, project }: BrowserCommandContext, name: string) { if (!testPath) { throw new Error(`This command can only be called inside a test file.`) diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index 76d8faffcf1a..67697c06b777 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -795,6 +795,16 @@ export interface BrowserPage extends LocatorSelectors { * @see {@link https://vitest.dev/api/browser/context#mark} */ mark(name: string, options?: MarkOptions): Promise + /** + * Group multiple operations under a trace marker when browser tracing is enabled. + * @see {@link https://vitest.dev/api/browser/context#mark} + */ + mark(name: string, body: () => T | Promise, options?: MarkOptions): Promise + /** + * Group multiple operations into a named trace group when browser tracing is enabled. + * @see {@link https://vitest.dev/api/browser/context#markgroup} + */ + markGroup(name: string, body: () => T | Promise, options?: MarkOptions): Promise /** * Extend default `page` object with custom methods. */ diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts index 18c923c40973..3fccaf1e428f 100644 --- a/packages/browser/src/client/tester/context.ts +++ b/packages/browser/src/client/tester/context.ts @@ -345,20 +345,68 @@ export const page: BrowserPage = { error, )) }, - mark(name: string, options?: MarkOptions) { + mark( + name: string, + bodyOrOptions?: MarkOptions | (() => T | Promise), + options?: MarkOptions, + ): any { const currentTest = getWorkerState().current - if (!currentTest || !getBrowserState().activeTraceTaskIds.has(currentTest.id)) { + const hasActiveTrace = !!currentTest && getBrowserState().activeTraceTaskIds.has(currentTest.id) + + if (typeof bodyOrOptions === 'function') { + return ensureAwaited(async (error) => { + if (hasActiveTrace) { + await triggerCommand( + '__vitest_groupTraceStart', + [{ + name, + stack: options?.stack ?? error?.stack, + }], + error, + ) + } + try { + return await bodyOrOptions() + } + finally { + if (hasActiveTrace) { + await triggerCommand('__vitest_groupTraceEnd', [], error) + } + } + }) + } + + if (!hasActiveTrace) { return Promise.resolve() } + return ensureAwaited(error => triggerCommand( '__vitest_markTrace', [{ name, - stack: options?.stack ?? error?.stack, + stack: bodyOrOptions?.stack ?? error?.stack, }], error, )) }, + markGroup(name: string, body: () => T | Promise, options?: MarkOptions): Promise { + return ensureAwaited(async (error) => { + await triggerCommand( + '__vitest_groupTraceStart', + [{ + name, + stack: options?.stack ?? error?.stack, + }], + error, + ) + try { + return await body() + } + finally { + await triggerCommand('__vitest_groupTraceEnd', [], error) + } + }) + }, getByRole() { throw new Error(`Method "getByRole" is not supported by the "${provider}" provider.`) }, diff --git a/packages/browser/src/client/tester/tester-utils.ts b/packages/browser/src/client/tester/tester-utils.ts index 2c2e9df2dfc3..7b7358c14a60 100644 --- a/packages/browser/src/client/tester/tester-utils.ts +++ b/packages/browser/src/client/tester/tester-utils.ts @@ -97,6 +97,23 @@ function getParent(el: Element) { return parent } +const ACTION_TRACE_COMMANDS = new Set([ + '__vitest_click', + '__vitest_dblClick', + '__vitest_tripleClick', + '__vitest_wheel', + '__vitest_type', + '__vitest_clear', + '__vitest_fill', + '__vitest_selectOptions', + '__vitest_dragAndDrop', + '__vitest_hover', + '__vitest_upload', + '__vitest_tab', + '__vitest_keyboard', + '__vitest_takeScreenshot', +]) + export class CommandsManager { private _listeners: ((command: string, args: any[]) => void)[] = [] @@ -116,6 +133,13 @@ export class CommandsManager { const { sessionId, traces } = getBrowserState() const filepath = state.filepath || state.current?.file?.filepath args = args.filter(arg => arg !== undefined) // remove optional fields + + const actionTraceGroupName = ACTION_TRACE_COMMANDS.has(command) ? command : undefined + const currentTest = getWorkerState().current + const shouldMarkTrace = actionTraceGroupName + && !!currentTest + && getBrowserState().activeTraceTaskIds.has(currentTest.id) + if (this._listeners.length) { await Promise.all(this._listeners.map(listener => listener(command, args))) } @@ -127,15 +151,40 @@ export class CommandsManager { 'code.file.path': filepath, }, }, - () => - rpc.triggerCommand(sessionId, command, filepath, args).catch((err) => { + async () => { + if (shouldMarkTrace) { + await rpc.triggerCommand( + sessionId, + '__vitest_groupTraceStart', + filepath, + [{ + name: actionTraceGroupName, + stack: clientError.stack, + }], + ) + } + try { + return await rpc.triggerCommand(sessionId, command, filepath, args) + } + catch (err: any) { // rethrow an error to keep the stack trace in browser // const clientError = new Error(err.message) clientError.message = err.message clientError.name = err.name clientError.stack = clientError.stack?.replace(clientError.message, err.message) throw clientError - }), + } + finally { + if (shouldMarkTrace) { + await rpc.triggerCommand( + sessionId, + '__vitest_groupTraceEnd', + filepath, + [], + ) + } + } + }, ) } } diff --git a/packages/browser/src/node/commands/index.ts b/packages/browser/src/node/commands/index.ts index e7ce9fd029d7..d56c5765cd6c 100644 --- a/packages/browser/src/node/commands/index.ts +++ b/packages/browser/src/node/commands/index.ts @@ -6,7 +6,7 @@ import { } from './fs' import { screenshot } from './screenshot' import { screenshotMatcher } from './screenshotMatcher' -import { _markTrace } from './trace' +import { _groupTraceEnd, _groupTraceStart, _markTrace } from './trace' export default { readFile: readFile as typeof readFile, @@ -14,6 +14,8 @@ export default { writeFile: writeFile as typeof writeFile, // private commands __vitest_markTrace: _markTrace as typeof _markTrace, + __vitest_groupTraceStart: _groupTraceStart as typeof _groupTraceStart, + __vitest_groupTraceEnd: _groupTraceEnd as typeof _groupTraceEnd, __vitest_fileInfo: _fileInfo as typeof _fileInfo, __vitest_screenshot: screenshot as typeof screenshot, __vitest_screenshotMatcher: screenshotMatcher as typeof screenshotMatcher, diff --git a/packages/browser/src/node/commands/trace.ts b/packages/browser/src/node/commands/trace.ts index 2c2b61c33314..7ae8525980de 100644 --- a/packages/browser/src/node/commands/trace.ts +++ b/packages/browser/src/node/commands/trace.ts @@ -6,12 +6,25 @@ interface MarkTracePayload { selector?: string } +interface GroupTracePayload { + name: string + stack?: string +} + declare module 'vitest/browser' { interface BrowserCommands { /** * @internal */ __vitest_markTrace: (payload: MarkTracePayload) => Promise + /** + * @internal + */ + __vitest_groupTraceStart: (payload: GroupTracePayload) => Promise + /** + * @internal + */ + __vitest_groupTraceEnd: () => Promise } } @@ -23,3 +36,20 @@ export const _markTrace: BrowserCommand<[payload: MarkTracePayload]> = async ( await context.triggerCommand('__vitest_markTrace', payload) } } + +export const _groupTraceStart: BrowserCommand<[payload: GroupTracePayload]> = async ( + context, + payload, +) => { + if (context.provider.name === 'playwright') { + await context.triggerCommand('__vitest_groupTraceStart', payload) + } +} + +export const _groupTraceEnd: BrowserCommand<[]> = async ( + context, +) => { + if (context.provider.name === 'playwright') { + await context.triggerCommand('__vitest_groupTraceEnd') + } +} diff --git a/test/browser/specs/playwright-trace-mark.test.ts b/test/browser/specs/playwright-trace-mark.test.ts index b940d359e8bc..6534dd4569b3 100644 --- a/test/browser/specs/playwright-trace-mark.test.ts +++ b/test/browser/specs/playwright-trace-mark.test.ts @@ -204,15 +204,29 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { if (traceFile.includes('click')) { expect(events).toEqual( expect.arrayContaining([ + // vitest command group (with source) + expect.objectContaining({ + method: 'tracingGroup', + title: '__vitest_click', + }), + // playwright action (without source) expect.objectContaining({ method: 'click', params: expect.objectContaining({ - selector: - '[data-vitest="true"] >> internal:control=enter-frame >> internal:role=button', + selector: expect.stringContaining(`internal:describe="getByRole('button')`), }), }), ]), ) + const markerEvent = events.find(e => e.title === '__vitest_click') + const formattedFrame = formatStack(markerEvent) + if (name === 'webkit') { + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:45:17"`) + } + else { + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:36:33"`) + } + return } if (traceFile.includes('helper')) { From 9faec43c5f2a3dfe66263c5c2cb25e0ea9bfeb9b Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Feb 2026 18:46:25 +0900 Subject: [PATCH 37/45] chore: remove markGroup --- packages/browser/context.d.ts | 5 ----- packages/browser/src/client/tester/context.ts | 18 ------------------ 2 files changed, 23 deletions(-) diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index 67697c06b777..a5fe5f023cb0 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -800,11 +800,6 @@ export interface BrowserPage extends LocatorSelectors { * @see {@link https://vitest.dev/api/browser/context#mark} */ mark(name: string, body: () => T | Promise, options?: MarkOptions): Promise - /** - * Group multiple operations into a named trace group when browser tracing is enabled. - * @see {@link https://vitest.dev/api/browser/context#markgroup} - */ - markGroup(name: string, body: () => T | Promise, options?: MarkOptions): Promise /** * Extend default `page` object with custom methods. */ diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts index 3fccaf1e428f..9ead4461d592 100644 --- a/packages/browser/src/client/tester/context.ts +++ b/packages/browser/src/client/tester/context.ts @@ -389,24 +389,6 @@ export const page: BrowserPage = { error, )) }, - markGroup(name: string, body: () => T | Promise, options?: MarkOptions): Promise { - return ensureAwaited(async (error) => { - await triggerCommand( - '__vitest_groupTraceStart', - [{ - name, - stack: options?.stack ?? error?.stack, - }], - error, - ) - try { - return await body() - } - finally { - await triggerCommand('__vitest_groupTraceEnd', [], error) - } - }) - }, getByRole() { throw new Error(`Method "getByRole" is not supported by the "${provider}" provider.`) }, From 85ca927228ea4b9ffe1d676917ad3467805ff303 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Feb 2026 18:53:46 +0900 Subject: [PATCH 38/45] test: test mark group --- .../browser/fixtures/trace-mark/basic.test.ts | 6 ++++ .../specs/playwright-trace-mark.test.ts | 32 ++++++++++++++----- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/test/browser/fixtures/trace-mark/basic.test.ts b/test/browser/fixtures/trace-mark/basic.test.ts index 77ce00f43ae4..b53888898051 100644 --- a/test/browser/fixtures/trace-mark/basic.test.ts +++ b/test/browser/fixtures/trace-mark/basic.test.ts @@ -50,3 +50,9 @@ test("stack", async () => { const error = new Error("Custom error for stack trace"); await page.getByRole("button").mark("button rendered - stack", { stack: error.stack }); }); + +test("mark group", async () => { + await page.mark("render group", async () => { + document.body.innerHTML = ""; + }) +}); diff --git a/test/browser/specs/playwright-trace-mark.test.ts b/test/browser/specs/playwright-trace-mark.test.ts index 6534dd4569b3..2243553ed1df 100644 --- a/test/browser/specs/playwright-trace-mark.test.ts +++ b/test/browser/specs/playwright-trace-mark.test.ts @@ -52,6 +52,7 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { ], "helper": "passed", "locator.mark": "passed", + "mark group": "passed", "page.mark": "passed", "stack": "passed", }, @@ -68,6 +69,7 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { expect.stringContaining('failure'), expect.stringContaining('helper'), expect.stringContaining('locator-mark'), + expect.stringContaining('mark-group'), expect.stringContaining('page-mark'), expect.stringContaining('stack'), ]) @@ -96,8 +98,7 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { expect.objectContaining({ method: 'expect', params: expect.objectContaining({ - selector: - '[data-vitest="true"] >> internal:control=enter-frame >> internal:role=button', + selector: expect.stringContaining(`internal:describe="getByRole('button')`), }), }), ]), @@ -144,8 +145,7 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { expect.objectContaining({ method: 'expect', params: expect.objectContaining({ - selector: - '[data-vitest="true"] >> internal:control=enter-frame >> internal:role=button', + selector: expect.stringContaining(`internal:describe="getByRole('button')`), }), }), ]), @@ -174,8 +174,7 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { expect.objectContaining({ method: 'expect', params: expect.objectContaining({ - selector: - '[data-vitest="true"] >> internal:control=enter-frame >> internal:role=button', + selector: expect.stringContaining(`internal:describe="getByRole('button')`), }), }), ]), @@ -221,12 +220,11 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { const markerEvent = events.find(e => e.title === '__vitest_click') const formattedFrame = formatStack(markerEvent) if (name === 'webkit') { - expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:45:17"`) + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:36:39"`) } else { expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:36:33"`) } - return } if (traceFile.includes('helper')) { @@ -264,6 +262,24 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:50:16"`) } } + + if (traceFile.includes('mark-group')) { + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + title: 'render group', + }), + ]), + ) + const markerEvent = events.find(e => e.title === 'render group') + const formattedFrame = formatStack(markerEvent) + if (name === 'webkit') { + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:55:18"`) + } + else { + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:55:13"`) + } + } } } }) From e9da4cfd00913a1b0121733175c5f7cbcf36d6cf Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 19 Feb 2026 19:11:53 +0900 Subject: [PATCH 39/45] docs: no limitations --- docs/api/browser/context.md | 16 +++++++++++++ docs/guide/browser/trace-view.md | 39 ++++++++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/docs/api/browser/context.md b/docs/api/browser/context.md index 061ff618fd40..fdf37d745f3a 100644 --- a/docs/api/browser/context.md +++ b/docs/api/browser/context.md @@ -83,6 +83,10 @@ export const page: { * Add a trace marker when browser tracing is enabled. */ mark(name: string, options?: { stack?: string }): Promise + /** + * Group multiple operations under a trace marker when browser tracing is enabled. + */ + mark(name: string, body: () => T | Promise, options?: { stack?: string }): Promise /** * Extend default `page` object with custom methods. */ @@ -124,18 +128,30 @@ The `path` is also ignored in that case. ```ts function mark(name: string, options?: { stack?: string }): Promise +function mark( + name: string, + body: () => T | Promise, + options?: { stack?: string }, +): Promise ``` Adds a named marker to the trace timeline for the current test. 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. +If you pass a callback, Vitest creates a trace group with this name, runs the callback, and closes the group automatically. + ```ts import { page } from 'vitest/browser' await page.mark('before submit') await page.getByRole('button', { name: 'Submit' }).click() await page.mark('after submit') + +await page.mark('submit flow', async () => { + await page.getByRole('textbox', { name: 'Email' }).fill('john@example.com') + await page.getByRole('button', { name: 'Submit' }).click() +}) ``` ::: tip diff --git a/docs/guide/browser/trace-view.md b/docs/guide/browser/trace-view.md index d3e3b4573c9e..18732a175b7b 100644 --- a/docs/guide/browser/trace-view.md +++ b/docs/guide/browser/trace-view.md @@ -73,6 +73,32 @@ await page.getByRole('button', { name: 'Sign in' }).mark('sign in button rendere Both `page.mark(name)` and `locator.mark(name)` are available. +You can also group multiple operations under one marker with `page.mark(name, callback)`: + +```ts +await page.mark('sign in flow', async () => { + await page.getByRole('textbox', { name: 'Email' }).fill('john@example.com') + await page.getByRole('textbox', { name: 'Password' }).fill('secret') + await page.getByRole('button', { name: 'Sign in' }).click() +}) +``` + +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: + +```ts +import { vi } from 'vitest' +import { page } from 'vitest/browser' + +const myRender = vi.defineHelper(async (content: string) => { + document.body.innerHTML = content + await page.elementLocator(document.body).mark('render helper') +}) + +test('renders content', async () => { + await myRender('') // trace points to this line +}) +``` + ## Preview To open the trace file, you can use the Playwright Trace Viewer. Run the following command in your terminal: @@ -85,10 +111,15 @@ This will start the Trace Viewer and load the specified trace file. Alternatively, you can open the Trace Viewer in your browser at https://trace.playwright.dev and upload the trace file there. -## Limitations +## Source Location + +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: + +- `expect.element(...)` assertions +- Interactive actions like `click`, `fill`, `type`, `hover`, `selectOptions`, `upload`, `dragAndDrop`, `tab`, `keyboard`, `wheel`, and screenshots -Trace Viewer source linking is currently partially supported. +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. -Regular Playwright action events (for example `locator.click()`) might not include source entries, while marker-backed events do. `page.mark(name)`, `locator.mark(name)`, and automatic markers from `expect.element(...)` include callsite metadata and are the most reliable way to correlate trace steps with test source. +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. -Non-browser assertions (for example `expect(value).toBe(...)`) don't interact with the browser and won't create browser trace markers. +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. From 04358617f39711a217d517cd0ddd7dfe5712291b Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 20 Feb 2026 10:03:27 +0900 Subject: [PATCH 40/45] chore: example --- examples/lit/test/basic.test.ts | 2 -- packages/browser-playwright/src/commands/utils.ts | 4 ++++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/lit/test/basic.test.ts b/examples/lit/test/basic.test.ts index 4942b8f189b7..654353eea9c5 100644 --- a/examples/lit/test/basic.test.ts +++ b/examples/lit/test/basic.test.ts @@ -9,11 +9,9 @@ describe('Button with increment', async () => { document.body.innerHTML = '' await page.getByRole('button').mark('render button') }) - await page.getByRole('heading').mark('heading') }) it('should increment the count on each click', async () => { - await page.mark('test-start') await page.getByRole('button').click() await expect.element(page.getByRole('button')).toHaveTextContent('2') diff --git a/packages/browser-playwright/src/commands/utils.ts b/packages/browser-playwright/src/commands/utils.ts index e85c0e0b28c7..425793072368 100644 --- a/packages/browser-playwright/src/commands/utils.ts +++ b/packages/browser-playwright/src/commands/utils.ts @@ -17,6 +17,10 @@ export function defineBrowserCommand( return fn } +// strip iframe locator part from the trace description e.g. +// - locator('[data-vitest="true"]').contentFrame().getByRole('button') +// ⇓ +// - getByRole('button') export function getDescribedLocator( context: BrowserCommandContext, selector: string, From 38fea7ccd54fb7f9e0183fe85c89447ef7b5f2db Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 20 Feb 2026 10:22:29 +0900 Subject: [PATCH 41/45] docs: warning playwright#39307 --- docs/guide/browser/trace-view.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/guide/browser/trace-view.md b/docs/guide/browser/trace-view.md index 18732a175b7b..735ab038b963 100644 --- a/docs/guide/browser/trace-view.md +++ b/docs/guide/browser/trace-view.md @@ -123,3 +123,9 @@ Under the hood, Playwright still records its own low-level action events as usua 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. 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. + +::: warning + +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). + +::: From a99ea355a98a1f1dce1e8ab40e9ee398cd3d5d03 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 20 Feb 2026 10:33:07 +0900 Subject: [PATCH 42/45] test: add test/browser/README.md --- test/browser/README.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/test/browser/README.md b/test/browser/README.md index e099eac7489d..e2896e9bfc0a 100644 --- a/test/browser/README.md +++ b/test/browser/README.md @@ -1,5 +1,22 @@ # Browser Tests +```sh +# run full test with all providers and all browsers +pnpm run test + +# run only playwright provider + all browsers +pnpm run test:playwright + +# run only playwright provider + chromium +BROWSER=chromium pnpm run test:playwright + +# run specific fixture (default is playwright provider + all browsers) +pnpm run test-fixtures --root ./fixtures/locators + +# run specific fixture with selected provider and browser +PROVIDER=webdriverio BROWSER=firefox pnpm run test-fixtures --root ./fixtures/locators +``` + ## Using docker playwright Some test suites don't support running it remotely (`fixtures/inspect` and `fixtures/insecure-context`). @@ -9,5 +26,5 @@ Some test suites don't support running it remotely (`fixtures/inspect` and `fixt pnpm docker up -d # Run tests with BROWSER_WS_ENDPOINT -BROWSER_WS_ENDPOINT=ws://127.0.0.1:6677/ pnpm test:playwright +BROWSER_WS_ENDPOINT=ws://127.0.0.1:6677/ pnpm run test:playwright ``` From 34f83b3ec11b5be8d6c9baa0306708741c88c43c Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 20 Feb 2026 10:40:09 +0900 Subject: [PATCH 43/45] chore: comment --- packages/browser-playwright/src/commands/trace.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/browser-playwright/src/commands/trace.ts b/packages/browser-playwright/src/commands/trace.ts index ca01224f4674..addb43f87c74 100644 --- a/packages/browser-playwright/src/commands/trace.ts +++ b/packages/browser-playwright/src/commands/trace.ts @@ -64,12 +64,15 @@ export const markTrace: BrowserCommand<[payload: { name: string; selector?: stri ) => { if (isPlaywrightProvider(context.provider)) { // skip if tracing is not active + // this is only safe guard and this isn't expected to happen since + // runner already checks if tracing is active before sending this command if (!context.provider.tracingContexts.has(context.sessionId)) { return } const { name, selector, stack } = payload const location = parseLocation(context, stack) // mark trace via group/groupEnd with dummy calls to force snapshot. + // https://github.com/microsoft/playwright/issues/39308 await context.context.tracing.group(name, { location }) try { if (selector) { @@ -77,7 +80,7 @@ export const markTrace: BrowserCommand<[payload: { name: string; selector?: stri if (typeof locator._expect === 'function') { await locator._expect('to.be.attached', { isNot: false, - timeout: 1, + timeout: 1, // don't wait when element doesn't exist }) } else { From 959aea4052344659d039ff9b49443d2b51259897 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 20 Feb 2026 10:48:40 +0900 Subject: [PATCH 44/45] fix: re-export asLocator from @vitest/browser --- packages/browser-playwright/package.json | 1 - packages/browser-playwright/src/commands/utils.ts | 2 +- packages/browser/src/node/index.ts | 2 ++ pnpm-lock.yaml | 3 --- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/browser-playwright/package.json b/packages/browser-playwright/package.json index dbcc7ecef630..81de474c4a3f 100644 --- a/packages/browser-playwright/package.json +++ b/packages/browser-playwright/package.json @@ -56,7 +56,6 @@ "dependencies": { "@vitest/browser": "workspace:*", "@vitest/mocker": "workspace:*", - "ivya": "^1.7.1", "tinyrainbow": "catalog:" }, "devDependencies": { diff --git a/packages/browser-playwright/src/commands/utils.ts b/packages/browser-playwright/src/commands/utils.ts index 425793072368..b0103880851a 100644 --- a/packages/browser-playwright/src/commands/utils.ts +++ b/packages/browser-playwright/src/commands/utils.ts @@ -1,6 +1,6 @@ import type { Locator } from 'vitest/browser' import type { BrowserCommand, BrowserCommandContext } from 'vitest/node' -import { asLocator } from 'ivya' +import { asLocator } from '@vitest/browser' export type UserEventCommand any> = BrowserCommand< ConvertUserEventParameters> diff --git a/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts index 06291b0d380c..7da68231ed57 100644 --- a/packages/browser/src/node/index.ts +++ b/packages/browser/src/node/index.ts @@ -20,6 +20,8 @@ export function defineBrowserCommand( // export type { ProjectBrowser } from './project' export { parseKeyDef, resolveScreenshotPath } from './utils' +export { asLocator } from 'ivya' + export const createBrowserServer: BrowserServerFactory = async (options) => { const project = options.project const configFile = project.vite.config.configFile diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c838059c5ef..4464ec87e747 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -566,9 +566,6 @@ importers: '@vitest/mocker': specifier: workspace:* version: link:../mocker - ivya: - specifier: ^1.7.1 - version: 1.7.1 tinyrainbow: specifier: 'catalog:' version: 3.0.3 From df71dd0812e80ca4e8a5241a099bcc0b6f900eb3 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 3 Mar 2026 18:26:38 +0900 Subject: [PATCH 45/45] test: rolldown again --- test/browser/specs/playwright-trace-mark.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/browser/specs/playwright-trace-mark.test.ts b/test/browser/specs/playwright-trace-mark.test.ts index 2243553ed1df..45b02faee2d0 100644 --- a/test/browser/specs/playwright-trace-mark.test.ts +++ b/test/browser/specs/playwright-trace-mark.test.ts @@ -3,6 +3,7 @@ import path from 'node:path' import { stripVTControlCharacters } from 'node:util' import { resolve } from 'pathe' import { afterEach, describe, expect, test } from 'vitest' +import { rolldownVersion } from 'vitest/node' import * as yauzl from 'yauzl' import { buildTestProjectTree } from '../../test-utils' import { instances, provider, runBrowserTests } from './utils' @@ -220,7 +221,12 @@ describe.runIf(provider.name === 'playwright')('playwright trace marks', () => { const markerEvent = events.find(e => e.title === '__vitest_click') const formattedFrame = formatStack(markerEvent) if (name === 'webkit') { - expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:36:39"`) + if (rolldownVersion) { + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:36:33"`) + } + else { + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:36:39"`) + } } else { expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:36:33"`)