diff --git a/docs/api/browser/commands.md b/docs/api/browser/commands.md index d8f08f54a460..6306ae6e4a05 100644 --- a/docs/api/browser/commands.md +++ b/docs/api/browser/commands.md @@ -124,6 +124,25 @@ declare module 'vitest/browser' { Custom functions will override built-in ones if they have the same name. ::: +### Recording trace markers + +Custom commands can record [trace markers](/api/browser/context#mark) for the test that triggered them through `context.mark`. This is the server-side equivalent of `page.mark` and helps annotate the [trace view](/guide/browser/trace-view) with custom actions performed inside a command. + +```ts +import type { BrowserCommand } from 'vitest/node' + +export const uploadFixture: BrowserCommand<[name: string]> = async ( + context, + name, +) => { + await context.mark(`upload start: ${name}`, { kind: 'action' }) + // ... do server-side work + await context.mark(`upload done: ${name}`, { kind: 'action' }) +} +``` + +`context.mark` is a no-op when browser tracing is not enabled or no test is currently running in the session. Unlike `page.mark`, it does not accept a callback form. + ### Custom `playwright` commands Vitest exposes several `playwright` specific properties on the command context. diff --git a/docs/api/browser/context.md b/docs/api/browser/context.md index 248e62c62604..f4e78b2c0167 100644 --- a/docs/api/browser/context.md +++ b/docs/api/browser/context.md @@ -158,6 +158,8 @@ await page.mark('submit flow', async () => { ::: tip This method is useful only when [`browser.trace`](/config/browser/trace) is enabled. + +A server-side equivalent is available on the [`BrowserCommandContext`](/api/browser/commands#recording-trace-markers) so [custom commands](/api/browser/commands#custom-commands) can record markers attributed to the test that triggered them. ::: ### frameLocator diff --git a/packages/browser/src/client/client.ts b/packages/browser/src/client/client.ts index b489d6046625..e6830a046439 100644 --- a/packages/browser/src/client/client.ts +++ b/packages/browser/src/client/client.ts @@ -1,6 +1,7 @@ import type { ModuleMocker } from '@vitest/mocker/browser' import type { CancelReason } from '@vitest/runner' import type { BirpcReturn } from 'birpc' +import type { MarkOptions } from 'vitest/browser' import type { WebSocketBrowserEvents, WebSocketBrowserHandlers } from '../types' import type { IframeOrchestrator } from './orchestrator' import { createBirpc } from 'birpc' @@ -26,6 +27,12 @@ export function onCancel(callback: (reason: CancelReason) => void): void { onCancelCallbacks.push(callback) } +let pageMarkHandler: ((name: string, options?: MarkOptions) => Promise) | null = null + +export function registerPageMarkHandler(handler: NonNullable): void { + pageMarkHandler = handler +} + export interface VitestBrowserClient { rpc: BrowserRPC ws: WebSocket @@ -93,6 +100,11 @@ function createClient() { } cdp.emit(event, payload) }, + async pageMark(name, options) { + if (pageMarkHandler) { + await pageMarkHandler(name, options) + } + }, async resolveManualMock(url: string) { // @ts-expect-error not typed global API const mocker = globalThis.__vitest_mocker__ as ModuleMocker | undefined diff --git a/packages/browser/src/client/tester/tester.ts b/packages/browser/src/client/tester/tester.ts index c82152911a42..18599b8cc7f2 100644 --- a/packages/browser/src/client/tester/tester.ts +++ b/packages/browser/src/client/tester/tester.ts @@ -1,6 +1,6 @@ import type { BrowserRPC, IframeChannelEvent } from '@vitest/browser/client' import type { FileSpecification } from '@vitest/runner' -import { channel, client, onCancel } from '@vitest/browser/client' +import { channel, client, onCancel, registerPageMarkHandler } from '@vitest/browser/client' import { parse } from 'flatted' import { page, server, userEvent } from 'vitest/browser' import { @@ -112,6 +112,8 @@ getBrowserState().activeTraceTaskIds = new Set() getBrowserState().browserTraceAttempts = new Map() getBrowserState().iframeId = iframeId +registerPageMarkHandler((name, options) => page.mark(name, options)) + let contextSwitched = false async function prepareTestEnvironment(options: PrepareOptions) { diff --git a/packages/browser/src/node/rpc.ts b/packages/browser/src/node/rpc.ts index 6325f16a95b2..3566a7848176 100644 --- a/packages/browser/src/node/rpc.ts +++ b/packages/browser/src/node/rpc.ts @@ -293,6 +293,10 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke provider, contextId: sessionId, sessionId, + mark: async (name: string, options?: any) => { + const tester = (project.browser!.state as BrowserServerState).testers.get(rpcId) + await tester?.pageMark(name, options) + }, triggerCommand: (name: string, ...args: any[]) => { return project.browser!.triggerCommand( name as any, diff --git a/packages/browser/src/types.ts b/packages/browser/src/types.ts index 3265d94c853f..5098cb5f03b2 100644 --- a/packages/browser/src/types.ts +++ b/packages/browser/src/types.ts @@ -11,6 +11,7 @@ import type { TestExecutionMethod, UserConsoleLog, } from 'vitest' +import type { MarkOptions } from 'vitest/browser' export interface WebSocketBrowserHandlers { resolveSnapshotPath: (testPath: string) => string @@ -75,6 +76,7 @@ export interface WebSocketBrowserEvents { createTesters: (options: BrowserTesterOptions) => Promise cleanupTesters: () => Promise cdpEvent: (event: string, payload: unknown) => void + pageMark: (name: string, options?: MarkOptions) => Promise resolveManualMock: (url: string) => Promise<{ url: string keys: string[] diff --git a/packages/vitest/src/node/types/browser.ts b/packages/vitest/src/node/types/browser.ts index 7d1503d26698..a439d1ca0165 100644 --- a/packages/vitest/src/node/types/browser.ts +++ b/packages/vitest/src/node/types/browser.ts @@ -3,7 +3,7 @@ import type { CancelReason } from '@vitest/runner' import type { Awaitable, ParsedStack, TestError } from '@vitest/utils' import type { StackTraceParserOptions } from '@vitest/utils/source-map' import type { Plugin, ViteDevServer } from 'vite' -import type { BrowserCommands, CDPSession } from 'vitest/browser' +import type { BrowserCommands, CDPSession, MarkOptions } from 'vitest/browser' import type { BrowserTraceViewMode } from '../../runtime/config' import type { BrowserTesterOptions } from '../../types/browser' import type { OTELCarrier } from '../../utils/traces' @@ -353,6 +353,7 @@ export interface BrowserCommandContext { provider: BrowserProvider project: TestProject sessionId: string + mark: (name: string, options?: MarkOptions) => Promise triggerCommand: ( name: K, ...args: Parameters diff --git a/test/browser/fixtures/trace/mark.test.ts b/test/browser/fixtures/trace/mark.test.ts index 0ec4f8fb1c19..a826c2f19a40 100644 --- a/test/browser/fixtures/trace/mark.test.ts +++ b/test/browser/fixtures/trace/mark.test.ts @@ -1,5 +1,5 @@ import { beforeEach, test, vi } from 'vitest' -import { page } from 'vitest/browser' +import { commands, page } from 'vitest/browser' beforeEach(() => { document.body.innerHTML = '' @@ -45,6 +45,14 @@ test('kind', async () => { await page.mark('lifecycle group', { kind: 'mark' }) }) +test('custom command', async () => { + document.body.innerHTML = 'UI on client side before server side creates mark' + + await (commands as any).markFromServer('from server command', 'action') + + document.body.innerHTML = 'UI on client side after server side creates mark' +}) + test('mark function fail', async () => { await page.mark('failed render group', async () => { document.body.innerHTML = '' diff --git a/test/browser/fixtures/trace/vitest.config.ts b/test/browser/fixtures/trace/vitest.config.ts index 9a548d935b37..e3c17b1239bc 100644 --- a/test/browser/fixtures/trace/vitest.config.ts +++ b/test/browser/fixtures/trace/vitest.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'vitest/config' +import type { MarkOptions } from 'vitest/browser'; import { instances, provider } from '../../settings' // TEST_BROWSER=chromium pnpm -C test/browser test-fixtures --root fixtures/trace @@ -18,6 +19,11 @@ export default defineConfig({ recordCanvas: true, }, screenshotFailures: false, + commands: { + async markFromServer(context, name: string, kind?: MarkOptions["kind"]) { + await context.mark(name, { kind }); + }, + }, }, }, }) diff --git a/test/browser/package.json b/test/browser/package.json index ce82afe9e147..11b67b81ca3e 100644 --- a/test/browser/package.json +++ b/test/browser/package.json @@ -19,6 +19,7 @@ "test-locators": "vitest --root ./fixtures/locators", "test-locators-custom": "vitest --root ./fixtures/locators-custom", "test-different-configs": "vitest --root ./fixtures/multiple-different-configs", + "test-trace": "vitest --root ./fixtures/trace", "test-setup-file": "vitest --root ./fixtures/setup-file", "test-snapshots": "vitest --root ./fixtures/update-snapshot", "test-broken-iframe": "vitest --root ./fixtures/broken-iframe", diff --git a/test/browser/specs/trace.test.ts b/test/browser/specs/trace.test.ts index 3a36c95b33af..af63238489c3 100644 --- a/test/browser/specs/trace.test.ts +++ b/test/browser/specs/trace.test.ts @@ -106,6 +106,7 @@ test('trace view artifacts', async () => { ], }, "mark.test.ts": { + "custom command": "passed", "helper": "passed", "kind": "passed", "locator.mark": "passed", @@ -459,6 +460,28 @@ test('trace view artifacts', async () => { ], }, "mark.test.ts": { + "custom command": [ + { + "entries": [ + { + "kind": "action", + "name": "from server command", + "snapshot": {}, + }, + ], + }, + { + "entries": [ + { + "kind": "lifecycle", + "location": "mark.test.ts:48", + "name": "vitest:onAfterRetryTask", + "snapshot": {}, + "status": "pass", + }, + ], + }, + ], "helper": [ { "entries": [ @@ -617,7 +640,7 @@ test('trace view artifacts', async () => { "entries": [ { "kind": "mark", - "location": "mark.test.ts:49", + "location": "mark.test.ts:57", "name": "failed render group", "range": { "phase": "start", @@ -630,7 +653,7 @@ test('trace view artifacts', async () => { "entries": [ { "kind": "mark", - "location": "mark.test.ts:49", + "location": "mark.test.ts:57", "name": "failed render group", "range": { "phase": "end", @@ -644,7 +667,7 @@ test('trace view artifacts', async () => { "entries": [ { "kind": "lifecycle", - "location": "mark.test.ts:51", + "location": "mark.test.ts:59", "name": "vitest:onAfterRetryTask", "snapshot": {}, "status": "fail", @@ -2013,6 +2036,28 @@ test('trace view artifacts', async () => { ], }, "mark.test.ts": { + "custom command": [ + { + "entries": [ + { + "kind": "action", + "name": "from server command", + "snapshot": {}, + }, + ], + }, + { + "entries": [ + { + "kind": "lifecycle", + "location": "mark.test.ts:48", + "name": "vitest:onAfterRetryTask", + "snapshot": {}, + "status": "pass", + }, + ], + }, + ], "helper": [ { "entries": [ @@ -2169,7 +2214,7 @@ test('trace view artifacts', async () => { "entries": [ { "kind": "mark", - "location": "mark.test.ts:49", + "location": "mark.test.ts:57", "name": "failed render group", "range": { "phase": "start", @@ -2182,7 +2227,7 @@ test('trace view artifacts', async () => { "entries": [ { "kind": "mark", - "location": "mark.test.ts:49", + "location": "mark.test.ts:57", "name": "failed render group", "range": { "phase": "end", @@ -2196,7 +2241,7 @@ test('trace view artifacts', async () => { "entries": [ { "kind": "lifecycle", - "location": "mark.test.ts:51", + "location": "mark.test.ts:59", "name": "vitest:onAfterRetryTask", "snapshot": {}, "status": "fail",