|
| 1 | +import type { TestArtifact } from '../../../runtime/runner/types' |
| 2 | +import { relative } from 'pathe' |
| 3 | +import c from 'tinyrainbow' |
| 4 | +import { F_TREE_NODE_END, F_TREE_NODE_MIDDLE } from './figures' |
| 5 | + |
| 6 | +interface TimelineRow { |
| 7 | + cells: [name: string, locator: string, start: string, duration: string, location: string] |
| 8 | + status?: 'pass' | 'fail' |
| 9 | +} |
| 10 | + |
| 11 | +const MAX_STEPS = 10 |
| 12 | + |
| 13 | +export function formatTraceTimeline(artifacts: TestArtifact[], root: string): string | undefined { |
| 14 | + const attempt = [...getTraceAttemptMap(artifacts).values()].at(-1) |
| 15 | + if (!attempt) { |
| 16 | + return |
| 17 | + } |
| 18 | + |
| 19 | + // the test end is already shown by the error location |
| 20 | + const visible = attempt.entries.filter((step) => step.name !== 'vitest:onAfterRetryTask') |
| 21 | + if (!visible.length) { |
| 22 | + return |
| 23 | + } |
| 24 | + const shown = visible.slice(-MAX_STEPS) |
| 25 | + const prefixes = getTreePrefixes(shown) |
| 26 | + const headerRow: TimelineRow = { cells: ['step', 'locator', 'start', 'duration', 'location'] } |
| 27 | + const rows = [headerRow, ...shown.map((step, i) => formatStep(step, prefixes[i], root))] |
| 28 | + |
| 29 | + const widths = headerRow.cells.map((_, i) => Math.max(...rows.map((row) => row.cells[i].length))) |
| 30 | + const lines = rows.map((row) => { |
| 31 | + // the last column is left unpadded so rows have no trailing spaces inside its color codes |
| 32 | + const [name, locator, start, duration, location] = row.cells.map((cell, i) => |
| 33 | + i === 4 ? cell : i === 2 || i === 3 ? cell.padStart(widths[i]) : cell.padEnd(widths[i]), |
| 34 | + ) |
| 35 | + if (row === headerRow) { |
| 36 | + return c.dim(` ${name} ${locator} ${start} ${duration} ${location}`) |
| 37 | + } |
| 38 | + const text = ` ${name} ${colorCell(locator, c.cyan)} ${start} ${colorCell(duration)} ${colorCell(location, c.dim)}` |
| 39 | + return row.status === 'fail' ? c.red(text) : text |
| 40 | + }) |
| 41 | + const omitted = visible.length - shown.length |
| 42 | + if (omitted) { |
| 43 | + lines.splice(1, 0, c.dim(` ... ${omitted} earlier ${omitted === 1 ? 'step' : 'steps'}`)) |
| 44 | + } |
| 45 | + |
| 46 | + const header: string[] = [] |
| 47 | + if (attempt.retry) { |
| 48 | + header.push(`retry ${attempt.retry}`) |
| 49 | + } |
| 50 | + if (attempt.repeats) { |
| 51 | + header.push(`repeat ${attempt.repeats}`) |
| 52 | + } |
| 53 | + const title = header.length ? `Trace (${header.join(', ')}):` : 'Trace:' |
| 54 | + return [`\n${title}`, ...lines].join('\n') |
| 55 | +} |
| 56 | + |
| 57 | +function getTreePrefixes(steps: NormalizedBrowserTraceEntry[]): string[] { |
| 58 | + const hasNextSibling = steps.map((step, i) => { |
| 59 | + const next = steps.slice(i + 1).find((other) => other.depth <= step.depth) |
| 60 | + return next?.depth === step.depth |
| 61 | + }) |
| 62 | + const lastAtDepth: number[] = [] |
| 63 | + return steps.map((step, i) => { |
| 64 | + lastAtDepth[step.depth] = i |
| 65 | + if (step.depth === 0) { |
| 66 | + return '' |
| 67 | + } |
| 68 | + let prefix = '' |
| 69 | + for (let level = 1; level < step.depth; level++) { |
| 70 | + prefix += hasNextSibling[lastAtDepth[level]] ? '│ ' : ' ' |
| 71 | + } |
| 72 | + return `${prefix}${hasNextSibling[i] ? F_TREE_NODE_MIDDLE : F_TREE_NODE_END} ` |
| 73 | + }) |
| 74 | +} |
| 75 | + |
| 76 | +function formatStep(step: NormalizedBrowserTraceEntry, prefix: string, root: string): TimelineRow { |
| 77 | + const location = |
| 78 | + step.location && |
| 79 | + `${relative(root, step.location.file)}:${step.location.line}:${step.location.column}` |
| 80 | + return { |
| 81 | + cells: [ |
| 82 | + `${prefix}${formatStepName(step.name)}`, |
| 83 | + step.element?.locator || '-', |
| 84 | + `+${formatMs(step.startTime)}`, |
| 85 | + step.duration == null ? '-' : formatMs(step.duration), |
| 86 | + location || '-', |
| 87 | + ], |
| 88 | + status: step.status, |
| 89 | + } |
| 90 | +} |
| 91 | + |
| 92 | +function colorCell(cell: string, format = (text: string) => text): string { |
| 93 | + return cell.trim() === '-' ? c.dim(cell) : format(cell) |
| 94 | +} |
| 95 | + |
| 96 | +function formatMs(ms: number): string { |
| 97 | + return `${Math.round(ms)}ms` |
| 98 | +} |
| 99 | + |
| 100 | +function formatStepName(name: string): string { |
| 101 | + return name.startsWith('vitest:') ? name.slice('vitest:'.length) : name |
| 102 | +} |
| 103 | + |
| 104 | +// TODO: everything below is copied from `BrowserTraceData`/`BrowserTraceEntry` in |
| 105 | +// packages/browser/src/client/tester/trace.ts (only the fields used here) and |
| 106 | +// `getTraceAttemptMap` in packages/ui/client/composables/trace-view.ts. |
| 107 | +// share them instead (https://github.com/vitest-dev/vitest/pull/11604) |
| 108 | + |
| 109 | +interface BrowserTraceData { |
| 110 | + retry: number |
| 111 | + repeats: number |
| 112 | + entries: BrowserTraceEntry[] |
| 113 | +} |
| 114 | + |
| 115 | +interface BrowserTraceEntry { |
| 116 | + name: string |
| 117 | + range?: { id: string; phase: 'start' | 'end' } |
| 118 | + status?: 'pass' | 'fail' |
| 119 | + startTime: number |
| 120 | + duration?: number |
| 121 | + location?: { file: string; line: number; column: number } |
| 122 | + element?: { locator: string } |
| 123 | +} |
| 124 | + |
| 125 | +interface NormalizedBrowserTraceData extends BrowserTraceData { |
| 126 | + entries: NormalizedBrowserTraceEntry[] |
| 127 | +} |
| 128 | + |
| 129 | +interface NormalizedBrowserTraceEntry extends BrowserTraceEntry { |
| 130 | + depth: number |
| 131 | +} |
| 132 | + |
| 133 | +function getTraceAttemptKey(trace: BrowserTraceData): string { |
| 134 | + return `${trace.repeats}:${trace.retry}` |
| 135 | +} |
| 136 | + |
| 137 | +function normalizeTraceEntries(entries: BrowserTraceEntry[]): NormalizedBrowserTraceEntry[] { |
| 138 | + const merged: NormalizedBrowserTraceEntry[] = [] |
| 139 | + const startMap = new Map<string, number>() |
| 140 | + const parents: string[] = [] |
| 141 | + |
| 142 | + for (const entry of entries) { |
| 143 | + const range = entry.range |
| 144 | + if (!range) { |
| 145 | + merged.push({ |
| 146 | + ...entry, |
| 147 | + depth: parents.length, |
| 148 | + }) |
| 149 | + continue |
| 150 | + } |
| 151 | + |
| 152 | + if (range.phase === 'start') { |
| 153 | + startMap.set(range.id, merged.length) |
| 154 | + merged.push({ |
| 155 | + ...entry, |
| 156 | + depth: parents.length, |
| 157 | + }) |
| 158 | + parents.push(range.id) |
| 159 | + continue |
| 160 | + } |
| 161 | + |
| 162 | + // when range.phase === 'end' |
| 163 | + const index = startMap.get(range.id) |
| 164 | + if (index == null) { |
| 165 | + // unpaired range shouldn't happen but just leave it there |
| 166 | + merged.push({ ...entry, depth: 0 }) |
| 167 | + continue |
| 168 | + } |
| 169 | + |
| 170 | + // Keep the start timestamp for positioning, but derive display duration |
| 171 | + // from the end event timestamp. |
| 172 | + const start = merged[index] |
| 173 | + merged[index] = { |
| 174 | + ...start, |
| 175 | + ...entry, |
| 176 | + startTime: start.startTime, |
| 177 | + duration: entry.startTime - start.startTime, |
| 178 | + } |
| 179 | + parents.pop() |
| 180 | + } |
| 181 | + |
| 182 | + return merged |
| 183 | +} |
| 184 | + |
| 185 | +function getTraceAttemptMap(artifacts: TestArtifact[]): Map<string, NormalizedBrowserTraceData> { |
| 186 | + const grouped: Record<string, BrowserTraceData[]> = {} |
| 187 | + for (const artifact of artifacts) { |
| 188 | + if (artifact.type !== 'internal:browserTrace') { |
| 189 | + continue |
| 190 | + } |
| 191 | + const trace = artifact.data as BrowserTraceData |
| 192 | + const key = getTraceAttemptKey(trace) |
| 193 | + grouped[key] ??= [] |
| 194 | + grouped[key].push(trace) |
| 195 | + } |
| 196 | + |
| 197 | + const merged = new Map<string, NormalizedBrowserTraceData>() |
| 198 | + for (const [key, traces] of Object.entries(grouped)) { |
| 199 | + const trace = traces[0] |
| 200 | + const entries = traces.flatMap((trace) => trace.entries) |
| 201 | + merged.set(key, { |
| 202 | + ...trace, |
| 203 | + entries: normalizeTraceEntries(entries), |
| 204 | + }) |
| 205 | + } |
| 206 | + return merged |
| 207 | +} |
0 commit comments