diff --git a/packages/ui/client/components/views/ViewEditor.vue b/packages/ui/client/components/views/ViewEditor.vue index 2cebb5e8c3ff..9fc3fdb7c5ee 100644 --- a/packages/ui/client/components/views/ViewEditor.vue +++ b/packages/ui/client/components/views/ViewEditor.vue @@ -141,7 +141,7 @@ function codemirrorChanges() { } const TRACE_GUTTER_ID = 'trace-step-gutter' -const traceGutterConfigs = isTraceViewEnabled(props.file) +const traceGutterConfigs = activeTraceView.value || isTraceViewEnabled(props.file) ? [{ className: TRACE_GUTTER_ID, style: 'width: 14px' }] : [] let traceGutterLines: number[] = [] diff --git a/packages/ui/client/composables/trace-view.ts b/packages/ui/client/composables/trace-view.ts index 53ecd489c4e2..49f647394bd2 100644 --- a/packages/ui/client/composables/trace-view.ts +++ b/packages/ui/client/composables/trace-view.ts @@ -97,7 +97,13 @@ export function getTraceAttemptMap(artifacts: TestArtifact[]): Map ({ + ...entry, + location: entry.location ?? artifact.location, + })), + }) } const merged = new Map() @@ -212,7 +218,9 @@ const selectedTestTask = computed(() => { const test = selectedTest.value ? client.state.idMap.get(selectedTest.value) : undefined - return test?.type === 'test' && isTraceViewEnabled(test.file) + const hasTrace = test?.type === 'test' + && test.artifacts.some(artifact => artifact.type === 'internal:browserTrace') + return test?.type === 'test' && (isTraceViewEnabled(test.file) || hasTrace) ? test : undefined }) @@ -226,6 +234,7 @@ watch(selectedTest, (testId) => { const test = selectedTestTask.value if (test) { // Auto-open trace view when selecting a trace-enabled test. + detailsPosition.value = 'bottom' setActiveTrace({ test, selectedStepIndex: 0 }) return } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 673cbca610ed..4102d76076a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1496,6 +1496,12 @@ importers: happy-dom: specifier: ^20.11.2 version: 20.11.2 + playwright: + specifier: 'catalog:' + version: 1.62.1 + rrweb-snapshot: + specifier: 2.1.1 + version: 2.1.1(patch_hash=b66b30796877352a5f887f3f4115c4e7265ddc11772af2223da4bd92293716f6) vitest: specifier: workspace:* version: link:../../packages/vitest diff --git a/test/ui/fixtures/trace-custom/app/app.js b/test/ui/fixtures/trace-custom/app/app.js new file mode 100644 index 000000000000..ba2268c3eb30 --- /dev/null +++ b/test/ui/fixtures/trace-custom/app/app.js @@ -0,0 +1,11 @@ +const button = document.querySelector('button') +const input = document.querySelector('input') +const output = document.querySelector('output') + +button.addEventListener('click', () => { + button.textContent = 'After action' +}) + +input.addEventListener('input', () => { + output.textContent = `Attempt ${input.value}` +}) diff --git a/test/ui/fixtures/trace-custom/app/index.html b/test/ui/fixtures/trace-custom/app/index.html new file mode 100644 index 000000000000..84e8ebab1a65 --- /dev/null +++ b/test/ui/fixtures/trace-custom/app/index.html @@ -0,0 +1,19 @@ + + + + + + Trace demo + + +
+ + + Attempt 0 +
+ + + diff --git a/test/ui/fixtures/trace-custom/attempts.test.ts b/test/ui/fixtures/trace-custom/attempts.test.ts new file mode 100644 index 000000000000..c8ae58cdbdf4 --- /dev/null +++ b/test/ui/fixtures/trace-custom/attempts.test.ts @@ -0,0 +1,15 @@ +import { test } from './trace/test' + +let attemptIndex = 0 + +test('custom trace attempts', { retry: 1, repeats: 1 }, async ({ page, trace }) => { + const currentAttempt = attemptIndex++ + + await page.goto('/') + await page.getByLabel('Attempt').fill(String(currentAttempt)) + await trace.snapshot('attempt') + + if (currentAttempt % 2 === 0) { + throw new Error('Retry this attempt') + } +}) diff --git a/test/ui/fixtures/trace-custom/basic.test.ts b/test/ui/fixtures/trace-custom/basic.test.ts new file mode 100644 index 000000000000..c4fdeb4f568f --- /dev/null +++ b/test/ui/fixtures/trace-custom/basic.test.ts @@ -0,0 +1,13 @@ +import { expect } from 'vitest' +import { test } from './trace/test' + +test('custom trace', async ({ page, trace }) => { + await page.goto('/') + await trace.snapshot('before action') + + const result = await trace.mark('action', async () => { + await page.getByRole('button', { name: 'Before action' }).click() + return page.getByRole('button').textContent() + }, { kind: 'action' }) + expect(result).toBe('After action') +}) diff --git a/test/ui/fixtures/trace-custom/global-setup.ts b/test/ui/fixtures/trace-custom/global-setup.ts new file mode 100644 index 000000000000..4e7badac1e46 --- /dev/null +++ b/test/ui/fixtures/trace-custom/global-setup.ts @@ -0,0 +1,29 @@ +import type { AddressInfo } from 'node:net' +import type { TestProject } from 'vitest/node' +import { fileURLToPath } from 'node:url' +import { preview } from 'vite' + +declare module 'vitest' { + interface ProvidedContext { + baseURL: string + } +} + +export async function setup({ provide }: TestProject): Promise<() => Promise> { + const root = fileURLToPath(new URL('./app', import.meta.url)) + const server = await preview({ + root, + logLevel: 'silent' as const, + build: { + outDir: root, + }, + preview: { + host: '127.0.0.1', + port: 0, + }, + }) + + const address = server.httpServer.address() as AddressInfo + provide('baseURL', `http://127.0.0.1:${address.port}`) + return () => server.close() +} diff --git a/test/ui/fixtures/trace-custom/trace/attempt.ts b/test/ui/fixtures/trace-custom/trace/attempt.ts new file mode 100644 index 000000000000..9f3d93b4bd2a --- /dev/null +++ b/test/ui/fixtures/trace-custom/trace/attempt.ts @@ -0,0 +1,27 @@ +import type { RunnerTestCase, TestContext, TestTryOptions } from 'vitest' +import type { TraceAttempt } from './recorder' + +const traceAttemptKey = Symbol.for('vitest.traceAttempt') +type TraceContext = TestContext & { [traceAttemptKey]?: TraceAttempt } + +export function startTraceAttempt(task: RunnerTestCase, options: TestTryOptions): void { + const context = task.context as TraceContext + context[traceAttemptKey] = { + ...options, + startTime: performance.now(), + } +} + +export function getTraceAttempt(task: TestContext['task']): TraceAttempt { + const context = task.context as TraceContext + const attempt = context[traceAttemptKey] + if (!attempt) { + throw new Error('Trace attempt was not initialized by the custom runner') + } + return attempt +} + +export function finishTraceAttempt(task: RunnerTestCase): void { + const context = task.context as TraceContext + delete context[traceAttemptKey] +} diff --git a/test/ui/fixtures/trace-custom/trace/recorder.ts b/test/ui/fixtures/trace-custom/trace/recorder.ts new file mode 100644 index 000000000000..b58f0d488fc7 --- /dev/null +++ b/test/ui/fixtures/trace-custom/trace/recorder.ts @@ -0,0 +1,152 @@ +import type { Page } from 'playwright' +import type { TestContext } from 'vitest' +import type { MarkOptions } from 'vitest/browser' +// @ts-ignore +import { parseStacktrace } from '@vitest/utils/source-map' +import { createRequire } from 'node:module' +import { recordArtifact, vi } from 'vitest' + +const require = createRequire(import.meta.url) +const rrwebSnapshotPath = require.resolve('rrweb-snapshot') + +export interface TraceAttempt { + retry: number + repeats: number + startTime: number +} + +export interface SnapshotOptions extends MarkOptions { + location?: { + file: string + line: number + column: number + } + status?: 'pass' | 'fail' +} + +interface SnapshotEntryOptions extends SnapshotOptions { + range?: { + id: string + phase: 'start' | 'end' + } +} + +export interface TraceRecorder { + finish: () => Promise + snapshot: (name: string, options?: SnapshotOptions) => Promise + mark: { + (name: string, options?: MarkOptions): Promise + (name: string, body: () => T | Promise, options?: MarkOptions): Promise + } +} + +export async function createTraceRecorder( + page: Page, + task: TestContext['task'], + attempt: TraceAttempt, +): Promise { + let snapshotReady: Promise | undefined + + async function recordSnapshot(name: string, options: SnapshotEntryOptions = {}): Promise { + const startTime = performance.now() - attempt.startTime + await (snapshotReady ??= page.addScriptTag({ path: rrwebSnapshotPath })) + const stackLocation = options.stack ? parseStacktrace(options.stack)[0] : undefined + const location = options.location ?? (stackLocation + ? { + file: stackLocation.file, + line: stackLocation.line, + column: stackLocation.column, + } + : undefined) + const snapshot = await page.evaluate(() => { + const { snapshot } = (globalThis as any).rrwebSnapshot + const serialized = snapshot(document) + if (!serialized) { + throw new Error('Failed to serialize document') + } + return { + serialized, + viewport: { + width: globalThis.innerWidth, + height: globalThis.innerHeight, + }, + scroll: { + x: globalThis.scrollX, + y: globalThis.scrollY, + }, + pseudoClassIds: {}, + } + }) + + await recordArtifact(task, { + type: 'internal:browserTrace', + data: { + retry: attempt.retry, + repeats: attempt.repeats, + recordCanvas: false, + entries: [{ + name, + kind: options.kind ?? 'mark', + startTime, + snapshot, + ...(options.range ? { range: options.range } : {}), + ...(options.status ? { status: options.status } : {}), + ...(location ? { location } : {}), + }], + }, + }) + } + + const finish = async (): Promise => { + const status = task.result?.state + const stack = status === 'fail' ? task.result?.errors?.[0].stack : undefined + const location = task.location + ? { ...task.location, file: task.file.filepath } + : undefined + await recordSnapshot('vitest:onAfterRetryTask', { + kind: 'lifecycle', + ...(status === 'pass' || status === 'fail' ? { status } : {}), + ...(stack ? { stack } : location ? { location } : {}), + }) + } + + const mark: TraceRecorder['mark'] = async ( + name: string, + bodyOrOptions?: MarkOptions | (() => T | Promise), + options?: MarkOptions, + ): Promise => { + if (typeof bodyOrOptions !== 'function') { + return recordSnapshot(name, bodyOrOptions) + } + + const rangeId = Math.random().toString(36).slice(2) + await recordSnapshot(name, { + ...options, + kind: 'mark', + range: { id: rangeId, phase: 'start' }, + }) + + let status: 'pass' | 'fail' = 'pass' + try { + return await bodyOrOptions() + } + catch (error) { + status = 'fail' + throw error + } + finally { + await recordSnapshot(name, { + ...options, + kind: options?.kind, + range: { id: rangeId, phase: 'end' }, + status, + }) + } + } + + return { + finish, + snapshot: vi.defineHelper(recordSnapshot), + mark: vi.defineHelper(mark) + } +} diff --git a/test/ui/fixtures/trace-custom/trace/runner.ts b/test/ui/fixtures/trace-custom/trace/runner.ts new file mode 100644 index 000000000000..c7d0ba6530fb --- /dev/null +++ b/test/ui/fixtures/trace-custom/trace/runner.ts @@ -0,0 +1,14 @@ +import type { RunnerTestCase, TestTryOptions } from 'vitest' +import { TestRunner } from 'vitest' +import { finishTraceAttempt, startTraceAttempt } from './attempt' + +export default class TraceRunner extends TestRunner { + override onBeforeTryTask(test: RunnerTestCase, options: TestTryOptions): void { + super.onBeforeTryTask(test, options) + startTraceAttempt(test, options) + } + + onAfterRetryTask(test: RunnerTestCase): void { + finishTraceAttempt(test) + } +} diff --git a/test/ui/fixtures/trace-custom/trace/test.ts b/test/ui/fixtures/trace-custom/trace/test.ts new file mode 100644 index 000000000000..06ffd476121c --- /dev/null +++ b/test/ui/fixtures/trace-custom/trace/test.ts @@ -0,0 +1,22 @@ +import { chromium } from 'playwright' +import { inject, test as base } from 'vitest' +import { getTraceAttempt } from './attempt' +import { createTraceRecorder } from './recorder' + +export const test = base + .extend('baseURL', () => inject('baseURL')) + .extend('browser', { scope: 'worker' }, async ({}, { onCleanup }) => { + const browser = await chromium.launch() + onCleanup(() => browser.close()) + return browser + }) + .extend('page', async ({ baseURL, browser }, { onCleanup }) => { + const page = await browser.newPage({ baseURL }) + onCleanup(() => page.close()) + return page + }) + .extend('trace', { auto: true }, async ({ page, task }, { onCleanup }) => { + const trace = await createTraceRecorder(page, task, getTraceAttempt(task)) + onCleanup(() => trace.finish()) + return trace + }) diff --git a/test/ui/fixtures/trace-custom/vitest.config.ts b/test/ui/fixtures/trace-custom/vitest.config.ts new file mode 100644 index 000000000000..bf77368146ae --- /dev/null +++ b/test/ui/fixtures/trace-custom/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globalSetup: './global-setup.ts', + runner: './trace/runner.ts', + ui: true, + }, +}) diff --git a/test/ui/package.json b/test/ui/package.json index 532812d7569e..23eca5964dbc 100644 --- a/test/ui/package.json +++ b/test/ui/package.json @@ -12,6 +12,8 @@ "@vitest/browser-playwright": "workspace:*", "@vitest/browser-preview": "workspace:*", "happy-dom": "^20.11.2", + "playwright": "catalog:", + "rrweb-snapshot": "2.1.1", "vitest": "workspace:*" } } diff --git a/test/ui/test/trace-custom.spec.ts b/test/ui/test/trace-custom.spec.ts new file mode 100644 index 000000000000..febcd003afca --- /dev/null +++ b/test/ui/test/trace-custom.spec.ts @@ -0,0 +1,133 @@ +import type { Page } from '@playwright/test' +import type { PreviewServer } from 'vite' +import type { Vitest } from 'vitest/node' +import { expect, test } from '@playwright/test' +import { assertTestCounts, openExplorerItem, startHtmlReportPreview, startVitestUi } from './helper' + +test.describe('custom trace artifact', () => { + let vitest: Vitest | undefined + let baseURL: string + + test.beforeAll(async () => { + const server = await startVitestUi({ + root: './fixtures/trace-custom', + watch: true, + ui: true, + open: false, + }) + vitest = server.vitest + baseURL = server.url + }) + + test.afterAll(async () => { + await vitest?.close() + }) + + test('replays trace recorded from a node test', async ({ page }) => { + await testCustomTrace(page, baseURL) + }) +}) + +test.describe('custom trace artifact html reporter', () => { + let previewServer: PreviewServer + let baseURL: string + + test.beforeAll(async () => { + const root = './fixtures/trace-custom' + const server = await startHtmlReportPreview( + { + root, + run: true, + ui: false, + reporters: 'html', + }, + { + root, + build: { outDir: '.vitest' }, + }, + ) + previewServer = server.previewServer + baseURL = `${server.url}/` + }) + + test.afterAll(async () => { + await previewServer.close() + }) + + test('replays trace recorded from a node test', async ({ page }) => { + await testCustomTrace(page, baseURL) + }) +}) + +async function testCustomTrace(page: Page, baseURL: string) { + await page.goto(baseURL) + await assertTestCounts(page, { pass: 2, fail: 0 }) + await openExplorerItem(page, 'custom trace') + + const traceView = page.getByTestId('trace-view') + await expect(traceView).toBeVisible() + await expect(page.locator('#details-splitpanes')).toHaveClass(/splitpanes--horizontal/) + + const traceSteps = traceView.getByTestId('trace-step') + await expect(traceView.getByTestId('trace-step-name')).toHaveText([ + 'before action', + 'action', + 'test finished', + ]) + await expect(traceSteps.nth(1)).toHaveAttribute('data-test-range', 'end') + await expect(traceSteps.nth(1).locator('.text-blue-500')).toBeVisible() + + const traceFrame = traceView.frameLocator('iframe') + await expect(traceFrame.getByRole('button', { name: 'Before action' })).toBeVisible() + + await traceSteps.nth(0).click() + const editor = page.getByTestId('editor') + const activeLine = editor.locator('.CodeMirror-activeline') + await expect(activeLine).toHaveText(/await trace\.snapshot\('before action'\)/) + + const traceEditorMarkers = editor.getByTestId('trace-editor-marker') + await expect(traceEditorMarkers).toHaveCount(3) + const snapshotMarker = traceEditorMarkers.and(page.locator('[aria-label="Select trace step: before action"]')) + const actionMarker = traceEditorMarkers.and(page.locator('[aria-label="Select trace step: action"]')) + const lifecycleMarker = traceEditorMarkers.and(page.locator('[aria-label="Select trace step: vitest:onAfterRetryTask"]')) + await expect(snapshotMarker).toHaveAttribute('aria-current', 'step') + + await traceSteps.nth(1).click() + await expect(activeLine).toHaveText(/const result = await trace\.mark\('action'/) + await expect(actionMarker).toHaveAttribute('aria-current', 'step') + await expect(traceFrame.getByRole('button', { name: 'After action' })).toBeVisible() + + await traceSteps.nth(2).click() + await expect(activeLine).toHaveText(/test\('custom trace'/) + await expect(lifecycleMarker).toHaveAttribute('aria-current', 'step') + + await page.goto(baseURL) + await openExplorerItem(page, 'custom trace attempts') + const traceOpenButtons = page.getByTestId('trace-open-button') + await expect(traceOpenButtons).toHaveText([ + 'Open trace viewer', + 'Open trace viewer Retry 1', + 'Open trace viewer Repeat 1', + 'Open trace viewer Retry 1 / Repeat 1', + ]) + + for (let index = 0; index < 4; index++) { + await traceOpenButtons.nth(index).click() + await expect(traceView.getByTestId('trace-step-name')).toHaveText([ + 'attempt', + 'test finished', + ]) + await expect(traceFrame.getByText(`Attempt ${index}`)).toBeVisible() + const lifecycleStep = traceView.getByTestId('trace-step').nth(1) + if (index % 2 === 0) { + await expect(lifecycleStep).toHaveClass(/text-red-600/) + } + else { + await expect(lifecycleStep).not.toHaveClass(/text-red-600/) + } + } + + await traceOpenButtons.nth(0).click() + await traceView.getByTestId('trace-step').nth(1).click() + await expect(page.getByTestId('editor').locator('.CodeMirror-activeline')).toHaveText(/throw new Error\('Retry this attempt'\)/) +}