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..44cde01dccf2 --- /dev/null +++ b/test/ui/fixtures/trace-custom/basic.test.ts @@ -0,0 +1,10 @@ +import { expect } from 'vitest' +import { test } from './trace/test' + +test('custom trace', async ({ page }) => { + await page.goto('/') + await page.getByRole('button', { name: 'Before action' }).click() + + await expect(page.getByRole('button', { name: 'After action' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Missing' }).click({ timeout: 10 })).rejects.toThrow() +}) 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/active.ts b/test/ui/fixtures/trace-custom/trace/active.ts new file mode 100644 index 000000000000..0425fc6ad4d8 --- /dev/null +++ b/test/ui/fixtures/trace-custom/trace/active.ts @@ -0,0 +1,20 @@ +import type { TraceRecorder } from './recorder' + +let activeTraceRecorder: TraceRecorder | undefined + +export function setActiveTraceRecorder(recorder: TraceRecorder): void { + activeTraceRecorder = recorder +} + +export function clearActiveTraceRecorder(recorder: TraceRecorder): void { + if (activeTraceRecorder === recorder) { + activeTraceRecorder = undefined + } +} + +export function getActiveTraceRecorder(): TraceRecorder { + if (!activeTraceRecorder) { + throw new Error('Trace expect was used outside of a traced test') + } + return activeTraceRecorder +} 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/expect.ts b/test/ui/fixtures/trace-custom/trace/expect.ts new file mode 100644 index 000000000000..b558f743175f --- /dev/null +++ b/test/ui/fixtures/trace-custom/trace/expect.ts @@ -0,0 +1,48 @@ +import type { Locator } from 'playwright' +// @ts-ignore +import { parseStacktrace } from '@vitest/utils/source-map' +import { expect } from 'vitest' +import { getActiveTraceRecorder } from './active' + +expect.extend({ + async toBeVisible(actual: unknown, options?: { timeout?: number }) { + if (!isLocator(actual)) { + throw new TypeError('toBeVisible expects a Playwright Locator') + } + + const frame = parseStacktrace(new Error().stack ?? '').find(({ file }) => { + return !file.includes('/node_modules/') && !file.includes('/trace/') + }) + const location = frame + ? { file: frame.file, line: frame.line, column: frame.column } + : undefined + const isNot = this.isNot + try { + await getActiveTraceRecorder().assert( + `expect.${isNot ? 'not.' : ''}toBeVisible`, + () => actual.waitFor({ + state: isNot ? 'hidden' : 'visible', + timeout: options?.timeout, + }), + { location }, + ) + return { + pass: !isNot, + message: () => `Expected locator ${isNot ? '' : 'not '}to be visible`, + } + } + catch (error) { + return { + pass: isNot, + message: () => error instanceof Error ? error.message : String(error), + } + } + }, +}) + +function isLocator(value: unknown): value is Locator { + return !!value + && typeof value === 'object' + && typeof (value as Locator).isVisible === 'function' + && typeof (value as Locator).locator === 'function' +} 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..a8757f1dfec5 --- /dev/null +++ b/test/ui/fixtures/trace-custom/trace/recorder.ts @@ -0,0 +1,360 @@ +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 { AsyncLocalStorage } from 'node:async_hooks' +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' + } +} + +interface ApiCallData { + apiName?: string + error?: Error + frames: Array<{ + file: string + line: number + column: number + }> +} + +interface ApiCallChannel { + method: string + params?: Record + type: string +} + +interface ApiCallState { + apiName?: string + data?: ApiCallData + location?: SnapshotOptions['location'] +} + +interface CapturedSnapshot { + snapshot: unknown + startTime: number +} + +export interface TraceRecorder { + assert: (name: string, body: () => T | Promise, options?: SnapshotOptions) => Promise + 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 { + const apiCallStorage = new AsyncLocalStorage<'internal' | ApiCallState>() + const instrumentation = (page as any)._instrumentation + const apiCallOwner = findApiCallOwner(page) + const originalWrapApiCall = apiCallOwner._wrapApiCall + let internalCallDepth = 0 + let finished = false + + const runInternal = async (body: () => T | Promise): Promise => { + internalCallDepth += 1 + try { + return await apiCallStorage.run('internal', body) + } + finally { + internalCallDepth -= 1 + } + } + + async function captureSnapshot(): Promise { + const startTime = performance.now() - attempt.startTime + return runInternal(async () => { + const snapshotReady = await page.evaluate(() => !!(globalThis as any).rrwebSnapshot) + if (!snapshotReady) { + await page.addScriptTag({ path: rrwebSnapshotPath }) + } + 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: {}, + } + }) + return { snapshot, startTime } + }) + } + + async function recordCapturedSnapshot( + name: string, + captured: CapturedSnapshot, + options: SnapshotEntryOptions = {}, + ): Promise { + const stackLocation = options.stack ? parseStacktrace(options.stack)[0] : undefined + const location = options.location ?? (stackLocation + ? { + file: stackLocation.file, + line: stackLocation.line, + column: stackLocation.column, + } + : undefined) + await recordArtifact(task, { + type: 'internal:browserTrace', + data: { + retry: attempt.retry, + repeats: attempt.repeats, + recordCanvas: false, + entries: [{ + name, + kind: options.kind ?? 'mark', + startTime: captured.startTime, + snapshot: captured.snapshot, + ...(options.range ? { range: options.range } : {}), + ...(options.status ? { status: options.status } : {}), + ...(location ? { location } : {}), + }], + }, + }) + } + + async function recordSnapshot(name: string, options: SnapshotEntryOptions = {}): Promise { + await recordCapturedSnapshot(name, await captureSnapshot(), options) + } + + const apiCallListener = { + onApiCallBegin(data: ApiCallData, channel: ApiCallChannel) { + const state = apiCallStorage.getStore() + if (state && state !== 'internal') { + state.data = data + state.apiName ??= getApiCallName(channel) + } + }, + } + + instrumentation.addListener(apiCallListener) + // Spike only: Playwright does not expose an awaited API-call instrumentation hook. + apiCallOwner._wrapApiCall = async function ( + body: (zone: unknown) => Promise, + options?: { internal?: boolean; title?: string }, + ): Promise { + if (options?.internal || internalCallDepth || apiCallStorage.getStore()) { + return originalWrapApiCall.call(this, body, options) + } + + const stack = new Error().stack + const apiName = inferApiName(stack) + const state: ApiCallState = { + apiName, + location: findUserLocation(stack), + } + return apiCallStorage.run(state, async () => { + const startSnapshot = await captureSnapshot() + let status: 'pass' | 'fail' = 'pass' + try { + return await originalWrapApiCall.call( + this, + body, + apiName ? { ...options, title: apiName } : options, + ) + } + catch (error) { + status = 'fail' + throw error + } + finally { + const recordedName = state.apiName ?? state.data?.apiName + if (recordedName) { + const endSnapshot = await captureSnapshot() + const rangeId = Math.random().toString(36).slice(2) + const frame = state.data?.frames[0] + const location = state.location ?? (frame + ? { file: frame.file, line: frame.line, column: frame.column } + : undefined) + await recordCapturedSnapshot(recordedName, startSnapshot, { + kind: 'action', + location, + range: { id: rangeId, phase: 'start' }, + }) + await recordCapturedSnapshot(recordedName, endSnapshot, { + kind: 'action', + location, + range: { id: rangeId, phase: 'end' }, + status, + }) + } + } + }) + } + + const finish = async (): Promise => { + if (finished) { + return + } + finished = true + apiCallOwner._wrapApiCall = originalWrapApiCall + instrumentation.removeListener(apiCallListener) + 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 assert = async ( + name: string, + body: () => T | Promise, + options?: SnapshotOptions, + ): Promise => { + const rangeId = Math.random().toString(36).slice(2) + await recordSnapshot(name, { + ...options, + kind: 'expect', + range: { id: rangeId, phase: 'start' }, + }) + + let status: 'pass' | 'fail' = 'pass' + try { + return await runInternal(body) + } + catch (error) { + status = 'fail' + throw error + } + finally { + await recordSnapshot(name, { + ...options, + kind: 'expect', + range: { id: rangeId, phase: 'end' }, + status, + }) + } + } + + 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 { + assert, + finish, + snapshot: vi.defineHelper(recordSnapshot), + mark: vi.defineHelper(mark), + } +} + +function findApiCallOwner(page: Page): any { + let prototype = page as any + while (prototype && !Object.hasOwn(prototype, '_wrapApiCall')) { + prototype = Object.getPrototypeOf(prototype) + } + if (!prototype) { + throw new Error('Playwright ChannelOwner._wrapApiCall was not found') + } + return prototype +} + +function inferApiName(stack: string | undefined): string | undefined { + let pageMethod: string | undefined + for (const line of stack?.split('\n') ?? []) { + const match = line.match(/at _?(Locator|Page|Frame)\.([^ ]+)/) + if (!match || match[2].startsWith('_') || match[2].includes('._')) { + continue + } + if (match[1] === 'Locator') { + return `locator.${match[2]}` + } + pageMethod ??= `page.${match[2]}` + } + return pageMethod +} + +function findUserLocation(stack: string | undefined): SnapshotOptions['location'] { + const frame = parseStacktrace(stack ?? '').find(({ file }) => { + return !file.includes('/node_modules/') && !file.includes('/trace/') + }) + return frame + ? { file: frame.file, line: frame.line, column: frame.column } + : undefined +} + +function getApiCallName(channel: ApiCallChannel): string { + const method = channel.method === 'evaluateExpression' ? 'evaluate' : channel.method + if (typeof channel.params?.selector === 'string') { + return `locator.${method}` + } + if (channel.type === 'Frame') { + return `page.${method}` + } + return `${channel.type.charAt(0).toLowerCase()}${channel.type.slice(1)}.${method}` +} 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..c96712a08fa7 --- /dev/null +++ b/test/ui/fixtures/trace-custom/trace/test.ts @@ -0,0 +1,28 @@ +import { chromium } from 'playwright' +import { inject, test as base } from 'vitest' +import { clearActiveTraceRecorder, setActiveTraceRecorder } from './active' +import { getTraceAttempt } from './attempt' +import './expect' +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)) + setActiveTraceRecorder(trace) + onCleanup(async () => { + clearActiveTraceRecorder(trace) + await 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..12c02844c854 --- /dev/null +++ b/test/ui/test/trace-custom.spec.ts @@ -0,0 +1,156 @@ +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([ + 'page.goto', + 'locator.click', + 'expect.toBeVisible', + 'locator.click', + 'test finished', + ]) + await expect(traceSteps.nth(0)).toHaveAttribute('data-test-range', 'end') + await expect(traceSteps.nth(1)).toHaveAttribute('data-test-range', 'end') + await expect(traceSteps.nth(2)).toHaveAttribute('data-test-range', 'end') + await expect(traceSteps.nth(3)).toHaveAttribute('data-test-range', 'end') + await expect(traceSteps.nth(0).locator('.text-blue-500')).toBeVisible() + await expect(traceSteps.nth(3)).toHaveClass(/text-red-600/) + + 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 page\.goto/) + + const traceEditorMarkers = editor.getByTestId('trace-editor-marker') + await expect(traceEditorMarkers).toHaveCount(5) + const gotoMarker = traceEditorMarkers.and(page.locator('[aria-label="Select trace step: page.goto"]')) + const clickMarker = traceEditorMarkers.and(page.locator('[aria-label="Select trace step: locator.click"]')).first() + const assertionMarker = traceEditorMarkers.and(page.locator('[aria-label="Select trace step: expect.toBeVisible"]')) + const failedActionMarker = traceEditorMarkers.and(page.locator('[aria-label="Select trace step: locator.click"]')).last() + const lifecycleMarker = traceEditorMarkers.and(page.locator('[aria-label="Select trace step: vitest:onAfterRetryTask"]')) + await expect(gotoMarker).toHaveAttribute('aria-current', 'step') + + await traceSteps.nth(1).click() + await expect(activeLine).toHaveText(/name: 'Before action'/) + await expect(clickMarker).toHaveAttribute('aria-current', 'step') + await expect(traceFrame.getByRole('button', { name: 'After action' })).toBeVisible() + + await traceSteps.nth(2).click() + await expect(activeLine).toHaveText(/await expect\(page\.getByRole/) + await expect(assertionMarker).toHaveAttribute('aria-current', 'step') + + await traceSteps.nth(3).click() + await expect(activeLine).toHaveText(/name: 'Missing'/) + await expect(failedActionMarker).toHaveAttribute('aria-current', 'step') + + await traceSteps.nth(4).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 page.goto(baseURL) + await openExplorerItem(page, 'custom trace attempts') + await traceOpenButtons.nth(index).click() + await expect(traceView.getByTestId('trace-step-name')).toHaveText([ + 'page.goto', + 'locator.fill', + 'attempt', + 'test finished', + ]) + await traceView.getByTestId('trace-step').nth(2).click() + await expect(traceFrame.getByText(`Attempt ${index}`)).toBeVisible() + const lifecycleStep = traceView.getByTestId('trace-step').nth(3) + if (index % 2 === 0) { + await expect(lifecycleStep).toHaveClass(/text-red-600/) + } + else { + await expect(lifecycleStep).not.toHaveClass(/text-red-600/) + } + } + + await page.goto(baseURL) + await openExplorerItem(page, 'custom trace attempts') + await traceOpenButtons.nth(0).click() + await traceView.getByTestId('trace-step').nth(3).click() + await expect(page.getByTestId('editor').locator('.CodeMirror-activeline')).toHaveText(/throw new Error\('Retry this attempt'\)/) +}