diff --git a/docs/api/browser/context.md b/docs/api/browser/context.md index c6cf0f888..fdf37d745 100644 --- a/docs/api/browser/context.md +++ b/docs/api/browser/context.md @@ -79,6 +79,14 @@ export const page: { base64: string }> screenshot(options?: ScreenshotOptions): Promise + /** + * 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. */ @@ -116,6 +124,40 @@ 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, 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 +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 aea66be97..833e286a0 100644 --- a/docs/api/browser/locators.md +++ b/docs/api/browser/locators.md @@ -820,6 +820,30 @@ 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, 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' + +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 20ff55689..735ab038b 100644 --- a/docs/guide/browser/trace-view.md +++ b/docs/guide/browser/trace-view.md @@ -57,6 +57,48 @@ 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. + +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: @@ -69,6 +111,21 @@ 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 + +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. -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. +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). + +::: diff --git a/examples/lit/.gitignore b/examples/lit/.gitignore new file mode 100644 index 000000000..5c8de7847 --- /dev/null +++ b/examples/lit/.gitignore @@ -0,0 +1,2 @@ +__traces__ +__screenshots__ diff --git a/examples/lit/test/basic.test.ts b/examples/lit/test/basic.test.ts index 3373c9e14..654353eea 100644 --- a/examples/lit/test/basic.test.ts +++ b/examples/lit/test/basic.test.ts @@ -4,14 +4,20 @@ import { page } from 'vitest/browser' import '../src/my-button.js' describe('Button with increment', async () => { - beforeEach(() => { - document.body.innerHTML = '' + beforeEach(async () => { + await page.mark('render', async () => { + document.body.innerHTML = '' + await page.getByRole('button').mark('render button') + }) }) it('should increment the count on each click', async () => { await page.getByRole('button').click() await expect.element(page.getByRole('button')).toHaveTextContent('2') + 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 8f53fa034..727d195ad 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 } } diff --git a/packages/browser-playwright/src/commands/clear.ts b/packages/browser-playwright/src/commands/clear.ts index 6736b36f7..4eb52b289 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 75261a93a..447aa5855 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 a0a6b2dd3..4007d089c 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 30afcd259..6e97bb485 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/index.ts b/packages/browser-playwright/src/commands/index.ts index a5d1759ed..390c1649e 100644 --- a/packages/browser-playwright/src/commands/index.ts +++ b/packages/browser-playwright/src/commands/index.ts @@ -10,6 +10,9 @@ import { tab } from './tab' import { annotateTraces, deleteTracing, + groupTraceEnd, + groupTraceStart, + markTrace, startChunkTrace, startTracing, stopChunkTrace, @@ -39,4 +42,7 @@ 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, + __vitest_groupTraceStart: groupTraceStart as typeof groupTraceStart, + __vitest_groupTraceEnd: groupTraceEnd as typeof groupTraceEnd, } diff --git a/packages/browser-playwright/src/commands/screenshot.ts b/packages/browser-playwright/src/commands/screenshot.ts index 9e6c18a76..f40adacc7 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 4fbcb972b..b18e17518 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 55b26453d..addb43f87 100644 --- a/packages/browser-playwright/src/commands/trace.ts +++ b/packages/browser-playwright/src/commands/trace.ts @@ -1,7 +1,9 @@ +import type { ParsedStack } from 'vitest' import type { BrowserCommand, BrowserCommandContext, BrowserProvider } from 'vitest/node' 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)) { @@ -14,8 +16,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) }) @@ -57,6 +58,83 @@ export const stopChunkTrace: BrowserCommand<[{ name: string }]> = async ( throw new TypeError(`The ${context.provider.name} provider does not support tracing.`) } +export const markTrace: BrowserCommand<[payload: { name: string; selector?: string; stack?: string }]> = async ( + context, + payload, +) => { + 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) { + const locator = getDescribedLocator(context, selector) as any + if (typeof locator._expect === 'function') { + await locator._expect('to.be.attached', { + isNot: false, + timeout: 1, // don't wait when element doesn't exist + }) + } + else { + await context.page.evaluate(() => 0) + } + } + else { + await context.page.evaluate(() => 0) + } + } + catch {} + await context.context.tracing.groupEnd() + return + } + 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-playwright/src/commands/type.ts b/packages/browser-playwright/src/commands/type.ts index 2a3d6469f..c966f549b 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 2a39afabc..2f2f9763d 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,17 @@ export function defineBrowserCommand( ): BrowserCommand { 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, +): ReturnType { + const locator = context.iframe.locator(selector) + return typeof locator.describe === 'function' + ? locator.describe(asLocator('javascript', selector)) + : locator +} diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index 094f61e30..23fd3dd35 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -43,6 +43,14 @@ export interface ScreenshotOptions extends SelectorOptions { 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: { /** @@ -653,6 +661,12 @@ export interface Locator extends LocatorSelectors { }> screenshot(options?: LocatorScreenshotOptions): Promise + /** + * Add a trace marker for this locator when browser tracing is enabled. + * @see {@link https://vitest.dev/api/browser/locators#mark} + */ + mark(name: string, options?: MarkOptions): Promise + /** * Returns an element matching the selector. * @@ -816,6 +830,16 @@ export interface BrowserPage extends LocatorSelectors { path: string base64: string }> + /** + * Add a trace marker when browser tracing is enabled. + * @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 /** * 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 b09742acd..8dc081985 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' @@ -346,6 +347,50 @@ export const page: BrowserPage = { error, )) }, + mark( + name: string, + bodyOrOptions?: MarkOptions | (() => T | Promise), + options?: MarkOptions, + ): any { + const currentTest = getWorkerState().current + 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: bodyOrOptions?.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/expect-element.ts b/packages/browser/src/client/tester/expect-element.ts index 0200bd495..0847ed0c2 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/browser/src/client/tester/locators/index.ts b/packages/browser/src/client/tester/locators/index.ts index a80428c18..c64c68a62 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, SelectorOptions, UserEventClearOptions, UserEventClickOptions, @@ -26,7 +27,7 @@ import { } from 'ivya' import { page, server, utils } from 'vitest/browser' import { __INTERNAL, getSafeTimers } from 'vitest/internal/browser' -import { ensureAwaited, getBrowserState } from '../../utils' +import { ensureAwaited, getBrowserState, getWorkerState } from '../../utils' import { escapeForTextSelector, isLocator, processTimeoutOptions, resolveUserEventWheelOptions } from '../tester-utils' export { ensureAwaited } from '../../utils' @@ -201,6 +202,22 @@ export abstract class Locator { }) } + public mark(name: string, options?: MarkOptions): Promise { + const currentTest = getWorkerState().current + if (!currentTest || !getBrowserState().activeTraceTaskIds.has(currentTest.id)) { + return Promise.resolve() + } + return ensureAwaited(error => getBrowserState().commands.triggerCommand( + '__vitest_markTrace', + [{ + name, + selector: this.selector, + stack: options?.stack ?? error?.stack, + }], + error, + )) + } + protected abstract locator(selector: string): Locator protected abstract elementLocator(element: Element): Locator diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index cebf35858..839723c9a 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -81,16 +81,15 @@ 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) { + const shouldTrace = trace !== 'off' + && !(trace === 'on-all-retries' && retry === 0) + && !(trace === 'on-first-retry' && retry !== 1) + if (!shouldTrace) { + getBrowserState().activeTraceTaskIds.delete(test.id) return } + getBrowserState().activeTraceTaskIds.add(test.id) let title = getTestName(test) if (retry) { title += ` (retry x${retry})` @@ -107,16 +106,13 @@ 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) { - return - } - if (trace === 'on-first-retry' && retry !== 1) { + if (!getBrowserState().activeTraceTaskIds.has(test.id)) { return } + await this.commands.triggerCommand('__vitest_markTrace', [{ + name: `onAfterRetryTask [${test.result?.state}]`, + stack: test.result?.errors?.[0].stack, + }]) const name = getTraceName(test, retry, repeats) if (!this.traces.has(test.id)) { this.traces.set(test.id, []) diff --git a/packages/browser/src/client/tester/tester-utils.ts b/packages/browser/src/client/tester/tester-utils.ts index 1176a2c04..c6e72485b 100644 --- a/packages/browser/src/client/tester/tester-utils.ts +++ b/packages/browser/src/client/tester/tester-utils.ts @@ -95,6 +95,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)[] = [] @@ -114,6 +131,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))) } @@ -125,14 +149,39 @@ 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 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/client/tester/tester.ts b/packages/browser/src/client/tester/tester.ts index db415b6d7..8481ad259 100644 --- a/packages/browser/src/client/tester/tester.ts +++ b/packages/browser/src/client/tester/tester.ts @@ -108,6 +108,7 @@ const iframeId = url.searchParams.get('iframeId')! const commands = new CommandsManager() getBrowserState().commands = commands +getBrowserState().activeTraceTaskIds = new Set() getBrowserState().iframeId = iframeId let contextSwitched = false diff --git a/packages/browser/src/client/utils.ts b/packages/browser/src/client/utils.ts index 860b59d7c..98b2f6dc4 100644 --- a/packages/browser/src/client/utils.ts +++ b/packages/browser/src/client/utils.ts @@ -83,6 +83,7 @@ export interface BrowserRunnerState { method: 'run' | 'collect' orchestrator?: IframeOrchestrator commands: CommandsManager + activeTraceTaskIds: Set traces: Traces cleanups: Array<() => unknown> cdp?: { diff --git a/packages/browser/src/node/commands/index.ts b/packages/browser/src/node/commands/index.ts index 64e115b73..d56c5765c 100644 --- a/packages/browser/src/node/commands/index.ts +++ b/packages/browser/src/node/commands/index.ts @@ -6,12 +6,16 @@ import { } from './fs' import { screenshot } from './screenshot' import { screenshotMatcher } from './screenshotMatcher' +import { _groupTraceEnd, _groupTraceStart, _markTrace } from './trace' export default { readFile: readFile as typeof readFile, removeFile: removeFile as typeof removeFile, 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 new file mode 100644 index 000000000..7ae852598 --- /dev/null +++ b/packages/browser/src/node/commands/trace.ts @@ -0,0 +1,55 @@ +import type { BrowserCommand } from 'vitest/node' + +interface MarkTracePayload { + name: string + stack?: string + 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 + } +} + +export const _markTrace: BrowserCommand<[payload: MarkTracePayload]> = async ( + context, + payload, +) => { + if (context.provider.name === 'playwright') { + 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/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts index 06291b0d3..7da68231e 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/packages/vitest/src/integrations/chai/poll.ts b/packages/vitest/src/integrations/chai/poll.ts index 8f0833cfd..bc5432c0e 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) } diff --git a/packages/vitest/src/node/types/browser.ts b/packages/vitest/src/node/types/browser.ts index ca29d10b2..dada8b46f 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 } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff014a5af..7c2ed77ee 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.3 version: 66.6.3 @@ -135,6 +138,9 @@ catalogs: ws: specifier: ^8.19.0 version: 8.19.0 + yauzl: + specifier: ^3.2.0 + version: 3.2.0 overrides: '@types/node': 24.11.0 @@ -1179,6 +1185,9 @@ importers: '@types/react': specifier: ^19.2.14 version: 19.2.14 + '@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.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -1227,6 +1236,9 @@ importers: ws: specifier: 'catalog:' version: 8.19.0 + yauzl: + specifier: 'catalog:' + version: 3.2.0 test/cli: devDependencies: @@ -10201,6 +10213,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'} @@ -19494,6 +19510,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 3b869e55d..4ab1dfcb8 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.3 '@vitejs/plugin-vue': ^6.0.4 '@vueuse/core': ^14.2.1 @@ -89,6 +90,7 @@ catalog: vitest-sonar-reporter: 3.0.0 vue: ^3.5.29 ws: ^8.19.0 + yauzl: ^3.2.0 onlyBuiltDependencies: - '@sveltejs/kit' - '@swc/core' diff --git a/test/browser/README.md b/test/browser/README.md index e099eac74..e2896e9bf 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 ``` 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 000000000..b53888898 --- /dev/null +++ b/test/browser/fixtures/trace-mark/basic.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, expect, test, vi } 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 - locator"); +}); + +test("page.mark", async () => { + document.body.innerHTML = ""; + await page.mark("button rendered - page"); +}); + +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"); +}); + +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(""); +}); + +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 }); +}); + +test("mark group", async () => { + await page.mark("render group", async () => { + document.body.innerHTML = ""; + }) +}); 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 000000000..bdff7fe78 --- /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/package.json b/test/browser/package.json index cdf5888d7..b8f6f1d5a 100644 --- a/test/browser/package.json +++ b/test/browser/package.json @@ -31,6 +31,7 @@ }, "devDependencies": { "@types/react": "^19.2.14", + "@types/yauzl": "catalog:", "@vitejs/plugin-basic-ssl": "^2.1.4", "@vitest/browser": "workspace:*", "@vitest/browser-playwright": "workspace:*", @@ -46,6 +47,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 new file mode 100644 index 000000000..45b02faee --- /dev/null +++ b/test/browser/specs/playwright-trace-mark.test.ts @@ -0,0 +1,376 @@ +import { readdirSync, rmSync } from 'node:fs' +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' + +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, ctx } = 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": { + "click": "passed", + "expect.element fail": [ + "expect(element).toHaveTextContent() + + Expected element to have text content: + World + Received: + Hello", + ], + "expect.element pass": "passed", + "failure": [ + "Test failure", + ], + "helper": "passed", + "locator.mark": "passed", + "mark group": "passed", + "page.mark": "passed", + "stack": "passed", + }, + } + `) + + const traceFiles = readdirSync(basicTestTracesFolder) + .filter(file => file.startsWith(`${name}-`) && file.endsWith('.trace.zip')) + .sort() + expect.soft(traceFiles).toEqual([ + expect.stringContaining('click'), + expect.stringContaining('expect-element-fail'), + expect.stringContaining('expect-element-pass'), + expect.stringContaining('failure'), + expect.stringContaining('helper'), + expect.stringContaining('locator-mark'), + expect.stringContaining('mark-group'), + expect.stringContaining('page-mark'), + expect.stringContaining('stack'), + ]) + + 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) + const events = parsed.events.filter(event => 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: expect.stringContaining(`internal:describe="getByRole('button')`), + }), + }), + ]), + ) + const markerEvent = events.find(e => e.title === 'button rendered - locator') + const formattedFrame = formatStack(markerEvent) + if (name === 'webkit') { + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:10:38"`) + } + else { + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:10:33"`) + } + } + + if (traceFile.includes('page-mark') && !traceFile.includes('custom-stack')) { + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + method: 'tracingGroup', + title: 'button rendered - page', + }), + expect.objectContaining({ + method: 'evaluateExpression', + }), + ]), + ) + const markerEvent = events.find(e => e.title === 'button rendered - page') + const formattedFrame = formatStack(markerEvent) + if (name === 'webkit') { + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:15:18"`) + } + else { + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:15:13"`) + } + } + + 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: expect.stringContaining(`internal:describe="getByRole('button')`), + }), + }), + ]), + ) + const markerEvent = events.find(e => e.title === 'expect.element().toHaveTextContent') + const formattedFrame = formatStack(markerEvent) + if (name === 'webkit') { + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:20:23"`) + } + else { + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:20:15"`) + } + } + + 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: expect.stringContaining(`internal:describe="getByRole('button')`), + }), + }), + ]), + ) + const markerEvent = events.find(e => e.title === 'expect.element().toHaveTextContent [ERROR]') + const formattedFrame = formatStack(markerEvent) + if (name === 'webkit') { + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:26:23"`) + } + else { + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:26:15"`) + } + } + + if (traceFile.includes('failure')) { + const markerEvent = events.find(e => e.title === 'onAfterRetryTask [fail]') + const formattedFrame = formatStack(markerEvent) + if (name === 'webkit') { + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:31:18"`) + } + else { + expect(formattedFrame).toMatchInlineSnapshot(`"basic.test.ts:31:8"`) + } + } + + 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: expect.stringContaining(`internal:describe="getByRole('button')`), + }), + }), + ]), + ) + const markerEvent = events.find(e => e.title === '__vitest_click') + const formattedFrame = formatStack(markerEvent) + if (name === 'webkit') { + 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"`) + } + } + + 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"`) + } + } + + 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"`) + } + } + + 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"`) + } + } + } + } + }) +}) + +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 events = traceText + .split('\n') + .filter(Boolean) + .map((line) => { + return JSON.parse(line) + }) + return { entries, events } + } + finally { + 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() + } +}