[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

feat(browser): display nested mark trace in UI (#10437)

Co-authored-by: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>

authored by

Hiroshi Ogawa
Hiroshi Ogawa
Codex
and committed by
GitHub
(Jun 4, 2026, 9:09 AM +0200) 86ffc8ac 6a3bb02e

+188 -19
+13 -8
packages/ui/client/components/trace/TraceView.vue
··· 1 1 <script setup lang="ts"> 2 - import type { BrowserTraceData, BrowserTraceEntry } from '../../../../browser/src/client/tester/trace' 3 - import type { TraceSelection } from '~/composables/trace-view' 2 + import type { NormalizedBrowserTraceData, NormalizedBrowserTraceEntry, TraceSelection } from '~/composables/trace-view' 4 3 import { createCache, createMirror, rebuild } from 'rrweb-snapshot' 5 4 // @ts-expect-error missing types 6 5 import { Pane, Splitpanes } from 'splitpanes' ··· 9 8 import { getTraceEntryClass, selectActiveTraceStep } from '~/composables/trace-view' 10 9 11 10 const props = defineProps<{ 12 - trace: BrowserTraceData 11 + trace: NormalizedBrowserTraceData 13 12 selection: TraceSelection 14 13 }>() 15 14 ··· 90 89 } 91 90 }, { immediate: true }) 92 91 93 - function getStepButtonClass(step: BrowserTraceEntry, index: number) { 92 + function getStepButtonClass(step: NormalizedBrowserTraceEntry, index: number) { 94 93 const selected = props.selection.selectedStepIndex === index 95 94 // TODO: move trace step state colors to shared semantic UI shortcuts. 96 95 if (isTraceStepInProgress(step)) { ··· 112 111 : `${(ms / 1000).toFixed(1)}s` 113 112 } 114 113 115 - function formatTraceTiming(step: BrowserTraceEntry) { 114 + function formatTraceTiming(step: NormalizedBrowserTraceEntry) { 116 115 if (isTraceStepInProgress(step)) { 117 116 return 'running' 118 117 } ··· 123 122 : `${startTime} · ${formatTraceTime(step.duration)}` 124 123 } 125 124 126 - function formatStepName(step: BrowserTraceEntry) { 125 + function formatStepName(step: NormalizedBrowserTraceEntry) { 127 126 if (step.name === 'vitest:onAfterRetryTask') { 128 127 return 'test finished' 129 128 } ··· 133 132 return step.name 134 133 } 135 134 136 - function isTraceStepInProgress(step: BrowserTraceEntry) { 135 + function isTraceStepInProgress(step: NormalizedBrowserTraceEntry) { 137 136 return step.range?.phase === 'start' 138 137 } 139 138 </script> ··· 150 149 type="button" 151 150 data-testid="trace-step" 152 151 :data-test-range="step.range?.phase" 153 - class="w-full text-left px-2 py-1 rounded text-sm" 152 + class="relative w-full text-left px-2 py-1 rounded text-sm" 154 153 :class="getStepButtonClass(step, index)" 154 + :style="{ paddingInlineStart: `${0.5 + step.depth}rem` }" 155 155 :aria-current="selection.selectedStepIndex === index ? 'step' : undefined" 156 156 @click="onSelectStep(index)" 157 157 > 158 + <span 159 + v-if="step.depth > 0" 160 + class="absolute bottom-1 top-1 border-l border-gray/40 dark:border-gray/50" 161 + :style="{ insetInlineStart: `${step.depth - 0.05}rem` }" 162 + /> 158 163 <div class="flex items-start gap-2"> 159 164 <span class="mt-0.5 h-4 w-4 flex flex-shrink-0 items-center justify-center"> 160 165 <span
+27 -9
packages/ui/client/composables/trace-view.ts
··· 19 19 active?: boolean 20 20 } 21 21 22 + export interface NormalizedBrowserTraceData extends BrowserTraceData { 23 + entries: NormalizedBrowserTraceEntry[] 24 + } 25 + 26 + export interface NormalizedBrowserTraceEntry extends BrowserTraceEntry { 27 + depth: number 28 + } 29 + 22 30 export const activeTraceView = ref<TraceSelection>() 23 31 24 32 function getTraceAttemptKey(trace: BrowserTraceData): string { 25 33 return `${trace.repeats}:${trace.retry}` 26 34 } 27 35 28 - function mergeTraceRangeEntries(entries: BrowserTraceEntry[]): BrowserTraceEntry[] { 29 - const merged: BrowserTraceEntry[] = [] 36 + function normalizeTraceEntries(entries: BrowserTraceEntry[]): NormalizedBrowserTraceEntry[] { 37 + const merged: NormalizedBrowserTraceEntry[] = [] 30 38 const startMap = new Map<string, number>() 39 + const parents: string[] = [] 31 40 32 41 for (const entry of entries) { 33 42 const range = entry.range 34 43 if (!range) { 35 - merged.push(entry) 44 + merged.push({ 45 + ...entry, 46 + depth: parents.length, 47 + }) 36 48 continue 37 49 } 38 50 39 51 if (range.phase === 'start') { 40 52 startMap.set(range.id, merged.length) 41 - merged.push(entry) 53 + merged.push({ 54 + ...entry, 55 + depth: parents.length, 56 + }) 57 + parents.push(range.id) 42 58 continue 43 59 } 44 60 61 + // when range.phase === 'end' 45 62 const index = startMap.get(range.id) 46 63 if (index == null) { 47 64 // unpaired range shouldn't happen but just leave it there 48 - merged.push(entry) 65 + merged.push({ ...entry, depth: 0 }) 49 66 continue 50 67 } 51 68 ··· 58 75 startTime: start.startTime, 59 76 duration: entry.startTime - start.startTime, 60 77 } 78 + parents.pop() 61 79 } 62 80 63 81 return merged 64 82 } 65 83 66 - export function getTraceAttemptMap(artifacts: TestArtifact[]): Record<string, BrowserTraceData> { 84 + export function getTraceAttemptMap(artifacts: TestArtifact[]): Record<string, NormalizedBrowserTraceData> { 67 85 const grouped: Record<string, BrowserTraceData[]> = {} 68 86 for (const artifact of artifacts) { 69 87 if (artifact.type !== 'internal:browserTrace') { ··· 75 93 grouped[key].push(trace) 76 94 } 77 95 78 - const merged: Record<string, BrowserTraceData> = {} 96 + const merged: Record<string, NormalizedBrowserTraceData> = {} 79 97 for (const [key, traces] of Object.entries(grouped)) { 80 98 const trace = traces[0] 81 99 const entries = traces.flatMap(trace => trace.entries) 82 100 merged[key] = { 83 101 ...trace, 84 - entries: mergeTraceRangeEntries(entries), 102 + entries: normalizeTraceEntries(entries), 85 103 } 86 104 } 87 105 return merged 88 106 } 89 107 90 - export function getSelectedTrace(selection: TraceSelection): BrowserTraceData | undefined { 108 + export function getSelectedTrace(selection: TraceSelection): NormalizedBrowserTraceData | undefined { 91 109 const attempts = getTraceAttemptMap(selection.test.artifacts) 92 110 return selection.attemptKey 93 111 ? attempts[selection.attemptKey]
+4
test/ui/README.md
··· 8 8 # run fixture projects 9 9 pnpm test-fixtures --root fixtures/main --ui 10 10 pnpm test-fixtures --root fixtures/trace 11 + 12 + # generate html report and use it for UI dev 13 + pnpm -C test/ui test-fixtures --root fixtures/trace --reporter=html --ui=false --run 14 + HTML_REPORT_DIR="$PWD/test/ui/fixtures/trace/html" pnpm -C packages/ui dev:client 11 15 ```
+34
test/ui/fixtures/trace-stream/nested.test.ts
··· 1 + import { expect, test } from 'vitest' 2 + import { page } from 'vitest/browser' 3 + import { waitForGate } from './helper' 4 + 5 + test('nested', async () => { 6 + document.body.innerHTML = ` 7 + <main> 8 + <button>Outer</button> 9 + <button>Inner</button> 10 + <button>Sibling</button> 11 + </main> 12 + ` 13 + 14 + await page.mark('Outer group', async () => { 15 + await page.getByRole('button', { name: 'Outer' }).mark('Outer mark') 16 + await waitForGate('nested-inner') 17 + 18 + await page.mark('Inner group', async () => { 19 + await page.getByRole('button', { name: 'Inner' }).mark('Inner mark') 20 + await Promise.all([ 21 + expect 22 + .element(page.getByRole('button', { name: 'Leaf' }), { timeout: 10000 }) 23 + .toBeVisible(), 24 + (async () => { 25 + await waitForGate('nested-leaf') 26 + document.querySelector('main')!.insertAdjacentHTML('beforeend', '<button>Leaf</button>') 27 + })(), 28 + ]) 29 + }) 30 + 31 + await waitForGate('nested-sibling') 32 + await page.getByRole('button', { name: 'Sibling' }).mark('Sibling mark') 33 + }) 34 + })
+22
test/ui/fixtures/trace/nested.test.ts
··· 1 + import { test } from 'vitest' 2 + import { page } from 'vitest/browser' 3 + 4 + test('nested', async () => { 5 + document.body.innerHTML = ` 6 + <main> 7 + <button>Outer</button> 8 + <button>Inner</button> 9 + <button>Leaf</button> 10 + <button>Sibling</button> 11 + </main> 12 + ` 13 + 14 + await page.mark('Outer group', async () => { 15 + await page.getByRole('button', { name: 'Outer' }).mark('Outer mark') 16 + await page.mark('Inner group', async () => { 17 + await page.getByRole('button', { name: 'Inner' }).mark('Inner mark') 18 + await page.getByRole('button', { name: 'Leaf' }).click() 19 + }) 20 + await page.getByRole('button', { name: 'Sibling' }).mark('Sibling mark') 21 + }) 22 + })
+61
test/ui/test/trace-stream.spec.ts
··· 158 158 'test finished', 159 159 ]) 160 160 }) 161 + 162 + test('nested range', async ({ page }) => { 163 + await page.goto(baseURL) 164 + 165 + const runPromise = vitest!.runTestSpecifications( 166 + await vitest!.globTestSpecifications(['nested.test.ts']), 167 + ) 168 + 169 + const testItem = getExplorerItem(page, 'nested') 170 + await expect(testItem).toBeVisible() 171 + 172 + const traceView = page.getByTestId('trace-view') 173 + await expect(traceView).not.toBeVisible() 174 + await testItem.click() 175 + await expect(traceView).toBeVisible() 176 + 177 + const traceSteps = traceView.getByTestId('trace-step') 178 + const traceStepNames = traceView.getByTestId('trace-step-name') 179 + 180 + await expect.poll(() => traceStepNames.allInnerTexts()).toEqual([ 181 + 'Outer group', 182 + 'Outer mark', 183 + ]) 184 + await expect(traceSteps.nth(0)).toHaveAttribute('data-test-range', 'start') 185 + 186 + await writeFile(resolve(gatesDir, 'nested-inner.txt'), 'open') 187 + await expect.poll(() => traceStepNames.allInnerTexts()).toEqual([ 188 + 'Outer group', 189 + 'Outer mark', 190 + 'Inner group', 191 + 'Inner mark', 192 + 'toBeVisible', 193 + ]) 194 + await expect(traceSteps.nth(0)).toHaveAttribute('data-test-range', 'start') 195 + await expect(traceSteps.nth(2)).toHaveAttribute('data-test-range', 'start') 196 + await expect(traceSteps.nth(4)).toHaveAttribute('data-test-range', 'start') 197 + 198 + await writeFile(resolve(gatesDir, 'nested-leaf.txt'), 'open') 199 + await expect.poll(() => traceStepNames.allInnerTexts()).toEqual([ 200 + 'Outer group', 201 + 'Outer mark', 202 + 'Inner group', 203 + 'Inner mark', 204 + 'toBeVisible', 205 + ]) 206 + await expect(traceSteps.nth(2)).toHaveAttribute('data-test-range', 'end') 207 + await expect(traceSteps.nth(4)).toHaveAttribute('data-test-range', 'end') 208 + 209 + await writeFile(resolve(gatesDir, 'nested-sibling.txt'), 'open') 210 + await runPromise 211 + await expect.poll(() => traceStepNames.allInnerTexts()).toEqual([ 212 + 'Outer group', 213 + 'Outer mark', 214 + 'Inner group', 215 + 'Inner mark', 216 + 'toBeVisible', 217 + 'Sibling mark', 218 + 'test finished', 219 + ]) 220 + await expect(traceSteps.nth(0)).toHaveAttribute('data-test-range', 'end') 221 + }) 161 222 })
+27 -2
test/ui/test/trace.spec.ts
··· 26 26 27 27 test.beforeEach(async ({ page }) => { 28 28 await page.goto(baseURL) 29 - await assertTestCounts(page, { pass: 11, fail: 0 }) 29 + await assertTestCounts(page, { pass: 12, fail: 0 }) 30 30 }) 31 31 32 32 test('basic', async ({ page }) => { ··· 51 51 52 52 test('scroll', async ({ page }) => { 53 53 await testScroll(page) 54 + }) 55 + 56 + test('nested', async ({ page }) => { 57 + await testNested(page) 54 58 }) 55 59 56 60 test('attempts', async ({ page }) => { ··· 92 96 93 97 test.beforeEach(async ({ page }) => { 94 98 await page.goto(baseURL) 95 - await assertTestCounts(page, { pass: 11, fail: 0 }) 99 + await assertTestCounts(page, { pass: 12, fail: 0 }) 96 100 }) 97 101 98 102 test('basic', async ({ page }) => { ··· 117 121 118 122 test('scroll', async ({ page }) => { 119 123 await testScroll(page) 124 + }) 125 + 126 + test('nested', async ({ page }) => { 127 + await testNested(page) 120 128 }) 121 129 122 130 test('attempts', async ({ page }) => { ··· 301 309 await expect(traceFrame.getByText('retryCount: 2')).toBeVisible() 302 310 await expect(traceFrame.getByText('repeatCount: 0')).toBeVisible() 303 311 } 312 + 313 + async function testNested(page: Page) { 314 + await openExplorerItem(page, 'nested') 315 + 316 + const traceView = page.getByTestId('trace-view') 317 + const traceStepNames = traceView.getByTestId('trace-step-name') 318 + await expect(traceView).toBeVisible() 319 + await expect.poll(() => traceStepNames.allInnerTexts()).toEqual([ 320 + 'Outer group', 321 + 'Outer mark', 322 + 'Inner group', 323 + 'Inner mark', 324 + 'click', 325 + 'Sibling mark', 326 + 'test finished', 327 + ]) 328 + }