Test results pane for Claude Code: every test run Claude makes becomes a pass/fail list with the failure detail and run history. vitest, jest, pytest, cargo…

<h1 align="center"> <img alt="redgreen" src="docs/brand/banner-dark.svg" width="640"> </h1>
<a href="https://github.com/hellosverre/redgreen/releases/latest"><img alt="release" src="https://img.shields.io/github/v/release/hellosverre/redgreen?style=flat-square&color=555"></a> <a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-555?style=flat-square"></a> <img alt="Claude Code 2.1.293+" src="https://img.shields.io/badge/Claude%20Code-2.1.293%2B-555?style=flat-square">
<img alt="Claude runs vitest, three tests fail, the Tests pane opens with the failures listed and the assertion diff of the picked one, the status line reads tests ✗ 3/4; after Claude's fix the rerun goes green" src="docs/demo.gif">
Needs Claude Code 2.1.293 or later, in a terminal or the desktop app's Code tab. Run both inside Claude Code:
/plugin marketplace add hellosverre/redgreen
/plugin install redgreen@redgreen
Restart Claude Code. Nothing to set up: the next time Claude runs your tests, redgreen reads them.
claude plugin marketplace update redgreen
claude plugin update redgreen@redgreen
claude plugin uninstall redgreen@redgreen
| What it does | |
|---|---|
| A failing run | Opens the Tests pane on its own and sets the status line to tests ✗ 3/4 |
| A green run | Updates the status line to tests ✓ 4; the pane stays shut |
/tests | Opens the pane on the latest run |
/tests clear | Empties the history and clears the status line |
| A failure's name | Shows its detail: the assertion, the diff, the code frame |
p · n · l | Older run, newer run, latest run |
k · j | Previous and next failure, when the list is longer than the pane |
The history keeps the session's last 50 runs, as red and green blocks you can step through.
| Runner | Read from | Counts |
|---|---|---|
| vitest | vitest, vitest run | tests, plus files that failed to load |
| jest | jest | tests, › paths as > |
| pytest | pytest, python -m pytest | tests, class tests as Class > test |
| cargo | cargo test, cargo +toolchain test | tests, ignored as skipped |
| go | go test | packages, or tests and subtests with -v |
| bun | bun test | tests |
| deno | deno test | tests |
Scripts that wrap a runner work too: npm test, pnpm test, yarn test, turbo run test, nx test, make test, tox, nox. redgreen finds the runner from the output, after stripping colour codes and the pkg:test: prefix turbo and pnpm -r put on every line.
redgreen watches Claude's Bash and PowerShell tool calls. When a command runs tests, it parses the output Claude already got back into counts and failures, and keeps the run in the session. When Claude Code cut a long output short, redgreen reads the full copy Claude Code saved to disk. It only reads: it never changes a command, its output or what the model sees, and if parsing throws, Claude's call keeps its result untouched. No network, no files written, no settings. Check it yourself with claude plugin validate ..
The demo above is drawn by scripts/demo.py from the parser's own reading of the vitest fixture, laid out row for row like the pane. It is not a screen recording.
git clone https://github.com/hellosverre/redgreen && cd redgreen
claude --plugin-dir .
Saving a file reloads the mod in the running session. Tests: claude plugin test .. A new runner is a parser in hooks/parse.ts, a fixture of its real output in tests/fixtures.ts and a test in tests/parse.test.ts.
MIT
hooks/register.tsx 131 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Run } from '../types'
5import { isGreen, keep, plural, statusText } from './format'
6import { testsPane } from './pane'
7import { parse, testCommand } from './parse'
8
9const PANE = 'redgreen'
10const TITLE = 'Tests'
11
12const runs = atom({ plugin: 'redgreen', key: 'runs' } as const, [])
13const viewing = atom({ plugin: 'redgreen', key: 'viewing' } as const, null)
14const picked = atom({ plugin: 'redgreen', key: 'picked' } as const, 0)
15
16type Ran = { isError?: true; text?: string; result?: unknown }
17
18// The whole output: Claude's copy is cut short past a size, the full one is on disk.
19async function outputOf($: EngineInterface, ran: Ran): Promise<string> {
20 const result = typeof ran.result === 'object' && ran.result !== null ? (ran.result as Record<string, unknown>) : {}
21 const path = result.persistedOutputPath
22
23 if (typeof path === 'string') {
24 try {
25 return await $.fs.read(path)
26 } catch {
27 // Past 4 MiB or gone already: the inline copy still holds the summary's tail.
28 }
29 }
30
31 if (typeof ran.text === 'string') {
32 return ran.text
33 }
34
35 return [result.stdout, result.stderr].filter(part => typeof part === 'string').join('\n')
36}
37
38// Bash and PowerShell share this; one hook per tool keeps each tool's types apart.
39async function record<R extends object>($: EngineInterface, command: string, toolUseId: string | undefined, ran: R): Promise<R> {
40 const test = testCommand(command)
41
42 if (test === undefined || ('deny' in ran && ran.deny !== undefined)) {
43 return ran
44 }
45
46 const parsed = parse(await outputOf($, ran as Ran), test.runner)
47
48 if (parsed === undefined) {
49 return ran
50 }
51
52 const at = await $.clock.now()
53 const run: Run = { ...parsed, id: `${at}-${toolUseId ?? ''}`, command, at }
54
55 await update($, runs, list => keep(list, run))
56 await update($, viewing, () => null)
57 await update($, picked, () => 0)
58 await $.ui.status(statusText(run))
59
60 // Unasked, a pane seats only on a wide terminal; below that it waits.
61 if (!isGreen(run)) {
62 void $.ui.open({ id: PANE, title: TITLE })
63 }
64
65 return ran
66}
67
68export const register: Register = on => {
69 on('session.start', async ($, e, next) => {
70 await $.command.register({ name: 'tests', description: 'Show test results: failures, their detail and run history (/tests clear empties it)' })
71
72 const last = (await read($, runs)).at(-1)
73
74 if (last !== undefined) {
75 await $.ui.status(statusText(last))
76 }
77
78 return next(e)
79 })
80
81 on('command.run', { command: 'tests' }, async ($, e) => {
82 if (e.args.trim() === 'clear') {
83 await update($, runs, () => [])
84 await update($, viewing, () => null)
85 await update($, picked, () => 0)
86 await $.ui.status(undefined)
87
88 return { text: 'Test history cleared.' }
89 }
90
91 await update($, viewing, () => null)
92 await $.ui.open({ id: PANE, title: TITLE })
93
94 const count = (await read($, runs)).length
95
96 return { text: count === 0 ? 'Tests pane opened. It fills on the next test run.' : `Tests pane opened (${plural(count, 'run')}).` }
97 })
98
99 // Only watches: if recording throws, Claude's call keeps the result it already had.
100 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
101 const ran = await next(e)
102
103 return record($, e.command, e.tool_use_id, ran)
104 }).catch(($, e, next) => next(e))
105
106 on('tool.call', { tool: 'PowerShell' }, async ($, e, next) => {
107 const ran = await next(e)
108
109 return record($, e.command, e.tool_use_id, ran)
110 }).catch(($, e, next) => next(e))
111
112 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
113 const list = await read($, runs)
114 const viewed = await read($, viewing)
115 const index = viewed === null ? list.length - 1 : list.findIndex(run => run.id === viewed)
116 const run = list[index]
117 const failures = run?.failures.length ?? 0
118 const chosen = Math.min(await read($, picked), Math.max(0, failures - 1))
119
120 return testsPane($.ui.resolve(e), {
121 list,
122 index,
123 chosen,
124 now: await $.clock.now(),
125 rows: e.viewport?.rows ?? 30,
126 show: to => void update($, viewing, () => (to >= list.length - 1 ? null : (list[to]?.id ?? null))).then(() => update($, picked, () => 0)),
127 pick: to => void update($, picked, () => Math.max(0, Math.min(failures - 1, to))),
128 })
129 })
130}
131hooks/format.ts 46 lines1// Text for the status line and the pane, kept apart from register.tsx so tests reach it.
2import type { Run } from '../types'
3
4export const HISTORY = 50
5
6export const isGreen = (run: Run): boolean => run.failed === 0
7
8export const total = (run: Run): number => run.passed + run.failed
9
10export function statusText(run: Run): string {
11 const unit = run.unit === 'packages' ? ' pkg' : ''
12
13 return isGreen(run) ? `tests ✓ ${run.passed}${unit}` : `tests ✗ ${run.failed}/${total(run)}${unit}`
14}
15
16export function duration(ms: number | undefined): string {
17 if (ms === undefined) {
18 return ''
19 }
20
21 if (ms < 1000) {
22 return `${ms}ms`
23 }
24
25 return ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`
26}
27
28export function ago(ms: number): string {
29 const s = Math.max(0, Math.round(ms / 1000))
30
31 if (s < 60) return `${s}s ago`
32 if (s < 3600) return `${Math.floor(s / 60)}m ago`
33 if (s < 86_400) return `${Math.floor(s / 3600)}h ago`
34
35 return `${Math.floor(s / 86_400)}d ago`
36}
37
38// The command as it reads in one line: a leading `cd … &&` says nothing about the run.
39export const shortCommand = (command: string): string =>
40 command.replace(/^\s*cd\s+("[^"]*"|'[^']*'|\S+)\s*(&&|;)\s*/, '').replace(/\s+/g, ' ').trim()
41
42export const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`
43
44// What the hook adds to the history: newest last, the oldest dropped past the limit.
45export const keep = (runs: Run[], run: Run): Run[] => [...runs, run].slice(-HISTORY)
46hooks/pane.tsx 152 lines1// The Tests pane, drawn from the history alone. register.tsx resolves the elements
2// and passes what a press should do; nothing here touches state.
3import type { ElementTable, RenderElement } from 'claude-code'
4
5import type { Failure, Run } from '../types'
6import { ago, duration, isGreen, shortCommand, total } from './format'
7
8export type Ui = Pick<ElementTable, 'Box' | 'Text' | 'Button'>
9
10export type View = {
11 list: Run[]
12 index: number
13 chosen: number
14 now: number
15 rows: number
16 show: (index: number) => void
17 pick: (failure: number) => void
18}
19
20const ERROR_LINE = /^\s*(E\s|>|\+|error|Error|\w*Error:|assert|panicked|thread ')/
21const QUIET_LINE = /^\s*(at |❯|\d+ \||-)/
22
23export function emptyPane(ui: Ui): RenderElement {
24 const { Box, Text } = ui
25
26 return (
27 <Box flexDirection="column" paddingX={1}>
28 <Text bold>No test runs yet</Text>
29 <Text dimColor wrap="wrap">
30 The next time Claude runs your tests they show up here: vitest, jest, pytest, cargo, go, bun and deno.
31 </Text>
32 </Box>
33 )
34}
35
36function header(ui: Ui, run: Run, now: number): RenderElement {
37 const { Box, Text } = ui
38 const unit = run.unit === 'packages' ? 'package' : 'test'
39 const verdict = isGreen(run)
40 ? `✓ ${total(run) === 1 ? `1 ${unit} passed` : `All ${total(run)} ${unit}s passed`}`
41 : `✗ ${run.failed} failed`
42 const meta = [duration(run.durationMs), run.runner, ago(now - run.at)].filter(Boolean).join(' · ')
43
44 return (
45 <Box key="header" flexDirection="column">
46 <Box flexDirection="row" gap={2}>
47 <Text color={isGreen(run) ? 'green' : 'red'} bold>
48 {verdict}
49 </Text>
50 {isGreen(run) ? null : <Text color="green">{`${run.passed} passed`}</Text>}
51 {run.skipped > 0 ? <Text color="yellow">{`${run.skipped} skipped`}</Text> : null}
52 <Text dimColor>{meta}</Text>
53 </Box>
54 <Text dimColor wrap="truncate-end">{`$ ${shortCommand(run.command)}`}</Text>
55 </Box>
56 )
57}
58
59// One block per run, oldest first; the shown one is a dot.
60function history(ui: Ui, view: View): RenderElement {
61 const { Box, Text, Button } = ui
62 const { list, index, show } = view
63 const blocks = list.map((one, i) => (
64 <Text key={`run-${i}`} color={isGreen(one) ? 'green' : 'red'}>
65 {i === index ? '●' : '■'}
66 </Text>
67 ))
68
69 return (
70 <Box key="history" flexDirection="row" gap={2} marginTop={1}>
71 <Text>{blocks}</Text>
72 {index > 0 ? <Button key="older" label="older" hotkey="p" plain dimColor onPress={() => show(index - 1)} /> : null}
73 {index < list.length - 1 ? <Button key="newer" label="newer" hotkey="n" plain dimColor onPress={() => show(index + 1)} /> : null}
74 {index < list.length - 1 ? <Button key="latest" label="latest" hotkey="l" plain dimColor onPress={() => show(list.length - 1)} /> : null}
75 </Box>
76 )
77}
78
79// A window of the list around the picked failure, so a long list never pushes the detail off.
80function failures(ui: Ui, run: Run, view: View, room: number): RenderElement {
81 const { Box, Text, Button } = ui
82 const { chosen, pick } = view
83 const first = Math.max(0, Math.min(chosen - Math.floor(room / 2), run.failures.length - room))
84 const rows = run.failures.slice(first, first + room).map((one, at) => {
85 const i = first + at
86
87 return (
88 <Box key={`row-${i}`} flexDirection="row" gap={1}>
89 <Text color="red">{i === chosen ? '›' : ' '}</Text>
90 <Button key={`fail-${i}`} label={one.name} plain dimColor={i !== chosen} onPress={() => pick(i)} />
91 {one.file === undefined ? null : <Text dimColor wrap="truncate-start">{` ${one.file}`}</Text>}
92 </Box>
93 )
94 })
95
96 return (
97 <Box key="failures" flexDirection="column" marginTop={1}>
98 {rows}
99 {run.failures.length > room ? (
100 <Box flexDirection="row" gap={2}>
101 <Text dimColor>{`${chosen + 1}/${run.failures.length}`}</Text>
102 <Button key="prev" label="prev" hotkey="k" plain dimColor onPress={() => pick(chosen - 1)} />
103 <Button key="next" label="next" hotkey="j" plain dimColor onPress={() => pick(chosen + 1)} />
104 </Box>
105 ) : null}
106 </Box>
107 )
108}
109
110function detail(ui: Ui, failure: Failure, room: number): RenderElement {
111 const { Box, Text } = ui
112 const lines =
113 failure.detail.length === 0
114 ? [<Text key="d-0" color="red" wrap="wrap">{failure.message || 'No error output captured.'}</Text>]
115 : failure.detail.slice(0, room).map((line, i) => (
116 <Text key={`d-${i}`} color={ERROR_LINE.test(line) ? 'red' : undefined} dimColor={QUIET_LINE.test(line)} wrap="truncate-end">
117 {line || ' '}
118 </Text>
119 ))
120 const more = failure.detail.length - room
121
122 return (
123 <Box key="detail" flexDirection="column" marginTop={1} borderStyle="round" borderColor="red" paddingX={1}>
124 <Text bold wrap="wrap">{failure.name}</Text>
125 {lines}
126 {more > 0 ? <Text dimColor>{`… ${more} more lines`}</Text> : null}
127 </Box>
128 )
129}
130
131export function testsPane(ui: Ui, view: View): RenderElement {
132 const { Box } = ui
133 const run = view.list[view.index]
134
135 if (run === undefined) {
136 return emptyPane(ui)
137 }
138
139 const failure = run.failures[view.chosen]
140 const listRoom = Math.min(run.failures.length, Math.max(3, Math.floor((view.rows - 8) / 3)))
141 const detailRoom = Math.max(3, view.rows - listRoom - 11)
142
143 return (
144 <Box flexDirection="column" paddingX={1}>
145 {header(ui, run, view.now)}
146 {history(ui, view)}
147 {run.failures.length > 0 ? failures(ui, run, view, listRoom) : null}
148 {failure === undefined ? null : detail(ui, failure, detailRoom)}
149 </Box>
150 )
151}
152hooks/parse.ts 427 lines1// Turns a test runner's console output into counts and failures. Pure: register.tsx
2// hands it the text of a Bash or PowerShell run and keeps what comes back.
3import type { Parsed, Runner } from '../types'
4
5const DETAIL_LINES = 30
6
7// A command that runs tests, and the runner it names when it names one. A bare
8// `npm test` names none: the output decides.
9const COMMANDS: [RegExp, Runner | undefined][] = [
10 [/\bvitest\b/, 'vitest'],
11 [/\bjest\b/, 'jest'],
12 [/\b(py\.test|pytest)\b/, 'pytest'],
13 [/\bcargo\s+(\+\S+\s+)?test\b/, 'cargo'],
14 [/\bgo\s+test\b/, 'go'],
15 [/\bbun\s+test\b/, 'bun'],
16 [/\bdeno\s+test\b/, 'deno'],
17 [/\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\bturbo\s+(run\s+)?test\b|\bnx\s+(run-many\s+.*)?test\b|\bmake\s+test\b|\b(tox|nox)\b/, undefined],
18]
19
20export function testCommand(command: string): { runner: Runner | undefined } | undefined {
21 for (const [pattern, runner] of COMMANDS) {
22 if (pattern.test(command)) {
23 return { runner }
24 }
25 }
26
27 return undefined
28}
29
30// ANSI colour codes, and the `pkg:test: ` prefix turbo and pnpm -r put on every line.
31const ANSI = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07/g
32const WORKSPACE_PREFIX = /^@?[\w.-]+(?:\/[\w.-]+)*(?::| )test(?::[\w-]+)?: ?/
33
34export const linesOf = (output: string): string[] =>
35 output.replace(ANSI, '').split(/\r?\n/).map(line => line.replace(WORKSPACE_PREFIX, ''))
36
37const toMs = (value: string, unit: string): number => Math.round(Number(value) * (unit === 'ms' ? 1 : 1000))
38
39const count = (text: string, word: RegExp): number => {
40 const match = new RegExp(`(\\d+) (?:${word.source})\\b`).exec(text)
41
42 return match === null ? 0 : Number(match[1])
43}
44
45// The lines of a failure's detail, indentation kept relative, blank edges dropped.
46function trimDetail(lines: string[]): string[] {
47 const kept = lines.slice(0, DETAIL_LINES)
48
49 while (kept.length > 0 && kept[0]?.trim() === '') kept.shift()
50 while (kept.length > 0 && kept[kept.length - 1]?.trim() === '') kept.pop()
51
52 const indent = Math.min(...kept.filter(line => line.trim() !== '').map(line => /^\s*/.exec(line)?.[0].length ?? 0))
53
54 return kept.map(line => line.slice(Number.isFinite(indent) ? indent : 0).trimEnd())
55}
56
57const firstLine = (detail: string[]): string => detail.find(line => line.trim() !== '')?.trim() ?? ''
58
59const empty = (runner: Runner): Parsed => ({ runner, passed: 0, failed: 0, skipped: 0, unit: 'tests', failures: [] })
60
61function vitest(lines: string[]): Parsed | undefined {
62 const parsed = empty('vitest')
63 let found = false
64
65 for (const line of lines) {
66 const summary = /^\s*Tests\s{2,}(.+?)\s+\((\d+)\)\s*$/.exec(line)
67
68 if (summary?.[1] !== undefined) {
69 found = true
70 parsed.failed += count(summary[1], /failed/)
71 parsed.passed += count(summary[1], /passed/)
72 parsed.skipped += count(summary[1], /skipped|todo/)
73 }
74
75 const took = /^\s*Duration\s+([\d.]+)(ms|s)\b/.exec(line)
76
77 if (took?.[1] !== undefined && took[2] !== undefined) {
78 parsed.durationMs = (parsed.durationMs ?? 0) + toMs(took[1], took[2])
79 }
80 }
81
82 if (!found) {
83 return undefined
84 }
85
86 lines.forEach((line, at) => {
87 const head = /^\s*FAIL\s+(?:\|[^|]+\|\s+)?(\S+?)(?:\s+>\s+(.+?))?(?:\s+\[.*\])?\s*$/.exec(line)
88
89 if (head?.[1] === undefined) {
90 return
91 }
92
93 const end = lines.findIndex((next, i) => i > at && (/^\s*FAIL\s/.test(next) || /^\s*⎯{3,}/.test(next)))
94 const detail = trimDetail(lines.slice(at + 1, end === -1 ? undefined : end))
95 const name = head[2] ?? `${head[1]} (file failed to run)`
96
97 if (!parsed.failures.some(f => f.name === name && f.file === head[1])) {
98 parsed.failures.push({ name, file: head[1], message: firstLine(detail), detail })
99 }
100 })
101
102 return parsed
103}
104
105function jest(lines: string[]): Parsed | undefined {
106 const parsed = empty('jest')
107 let found = false
108 let file: string | undefined
109
110 for (const line of lines) {
111 const summary = /^Tests:\s+(.+)$/.exec(line)
112
113 if (summary?.[1] !== undefined) {
114 found = true
115 parsed.failed += count(summary[1], /failed/)
116 parsed.passed += count(summary[1], /passed/)
117 parsed.skipped += count(summary[1], /skipped|todo|pending/)
118 }
119
120 const took = /^Time:\s+([\d.]+)\s*(ms|s)\b/.exec(line)
121
122 if (took?.[1] !== undefined && took[2] !== undefined) {
123 parsed.durationMs = (parsed.durationMs ?? 0) + toMs(took[1], took[2])
124 }
125 }
126
127 if (!found) {
128 return undefined
129 }
130
131 lines.forEach((line, at) => {
132 const suite = /^\s*FAIL\s+(\S+)/.exec(line)
133
134 if (suite?.[1] !== undefined) {
135 file = suite[1]
136 return
137 }
138
139 const head = /^\s*●\s+(.+)$/.exec(line)
140
141 if (head?.[1] === undefined || /^Console$/.test(head[1].trim())) {
142 return
143 }
144
145 const end = lines.findIndex((next, i) => i > at && (/^\s*●\s/.test(next) || /^\s*(FAIL|PASS)\s/.test(next) || /^Test Suites:/.test(next)))
146 const detail = trimDetail(lines.slice(at + 1, end === -1 ? undefined : end))
147
148 parsed.failures.push({ name: head[1].replace(/ › /g, ' > ').trim(), file, message: firstLine(detail), detail })
149 })
150
151 return parsed
152}
153
154function pytest(lines: string[]): Parsed | undefined {
155 const parsed = empty('pytest')
156 let found = false
157
158 for (const line of lines) {
159 const summary = /^=*\s*((?:\d+ \w+,? ?)+) in ([\d.]+)s\b/.exec(line)
160
161 if (summary?.[1] !== undefined && summary[2] !== undefined && /\b(passed|failed|errors?|skipped)\b/.test(summary[1])) {
162 found = true
163 parsed.failed += count(summary[1], /failed/) + count(summary[1], /errors?/)
164 parsed.passed += count(summary[1], /passed/)
165 parsed.skipped += count(summary[1], /skipped|xfailed|deselected/)
166 parsed.durationMs = (parsed.durationMs ?? 0) + toMs(summary[2], 's')
167 } else if (/^=*\s*no tests ran in [\d.]+s/.test(line)) {
168 found = true
169 }
170 }
171
172 if (!found) {
173 return undefined
174 }
175
176 // The FAILURES section holds one `____ test_name ____` block per failure.
177 const blocks = new Map<string, string[]>()
178
179 lines.forEach((line, at) => {
180 const head = /^_{3,} (.+?) _{3,}$/.exec(line)
181
182 if (head?.[1] === undefined) {
183 return
184 }
185
186 const end = lines.findIndex((next, i) => i > at && (/^_{3,} .+ _{3,}$/.test(next) || /^={3,}/.test(next)))
187 blocks.set(head[1], lines.slice(at + 1, end === -1 ? undefined : end))
188 })
189
190 for (const line of lines) {
191 const short = /^(?:FAILED|ERROR) (\S+?)(?:::(\S+))?(?: - (.*))?$/.exec(line)
192
193 if (short?.[1] === undefined) {
194 continue
195 }
196
197 const parts = short[2]?.split('::') ?? []
198 const name = parts.length > 0 ? parts.join(' > ') : short[1]
199 const block = blocks.get(parts.join('.')) ?? blocks.get(parts[parts.length - 1] ?? '') ?? []
200 const detail = trimDetail(block)
201 const raised = detail.find(entry => entry.startsWith('E ')) ?? ''
202
203 parsed.failures.push({ name, file: short[1], message: short[3]?.trim() || raised.slice(1).trim() || firstLine(detail), detail })
204 }
205
206 return parsed
207}
208
209function cargo(lines: string[]): Parsed | undefined {
210 const parsed = empty('cargo')
211 let found = false
212
213 for (const line of lines) {
214 const summary = /^test result: \w+\. (\d+) passed; (\d+) failed; (\d+) ignored;.*?finished in ([\d.]+)s/.exec(line)
215
216 if (summary !== null) {
217 found = true
218 parsed.passed += Number(summary[1])
219 parsed.failed += Number(summary[2])
220 parsed.skipped += Number(summary[3])
221 parsed.durationMs = (parsed.durationMs ?? 0) + toMs(summary[4] ?? '0', 's')
222 }
223 }
224
225 if (!found) {
226 return undefined
227 }
228
229 lines.forEach((line, at) => {
230 const head = /^---- (\S+) stdout ----$/.exec(line)
231
232 if (head?.[1] === undefined) {
233 return
234 }
235
236 const end = lines.findIndex((next, i) => i > at && (/^---- \S+ stdout ----$/.test(next) || /^failures:$/.test(next)))
237 const detail = trimDetail(lines.slice(at + 1, end === -1 ? undefined : end).filter(entry => !/^note: run with `RUST_BACKTRACE/.test(entry)))
238 const panic = detail.join('\n')
239 const place = /panicked at (?:'.*?', )?([^\s:]+\.rs):(\d+)/.exec(panic)
240 const inline = /panicked at '(.*?)'/.exec(panic)
241 const message = inline?.[1] ?? detail.find(entry => !/^thread '/.test(entry) && entry.trim() !== '')?.trim() ?? ''
242
243 parsed.failures.push({ name: head[1], file: place?.[1], message, detail })
244 })
245
246 return parsed
247}
248
249// A test's t.Log lines: under its `--- FAIL` line without -v, under its `=== RUN` line with it.
250function goLog(lines: string[], at: number): string[] | undefined {
251 if (at === -1) {
252 return undefined
253 }
254
255 const end = lines.findIndex((next, i) => i > at && (/^\s*(---|===) /.test(next) || /^(FAIL|ok|PASS)\b/.test(next)))
256 const detail = trimDetail(lines.slice(at + 1, end === -1 ? undefined : end))
257
258 return detail.length === 0 ? undefined : detail
259}
260
261function go(lines: string[]): Parsed | undefined {
262 const parsed = { ...empty('go'), durationMs: 0 }
263 let packagesOk = 0
264 let packagesFailed = 0
265 let found = false
266 let listedPasses = 0
267
268 for (const line of lines) {
269 const pkg = /^(ok|FAIL)\s+(\S+)\s+(?:([\d.]+)s|\[(.+)\]|\(cached\))/.exec(line)
270
271 if (pkg !== null) {
272 found = true
273
274 if (pkg[1] === 'ok') {
275 packagesOk += 1
276 } else {
277 packagesFailed += 1
278 }
279
280 parsed.durationMs += toMs(pkg[3] ?? '0', 's')
281
282 if (pkg[4] !== undefined) {
283 parsed.failures.push({ name: pkg[2] ?? '', message: pkg[4], detail: [] })
284 }
285 }
286
287 if (/^\s*--- PASS: /.test(line)) listedPasses += 1
288 if (/^\s*--- SKIP: /.test(line)) parsed.skipped += 1
289 }
290
291 if (!found) {
292 return undefined
293 }
294
295 const failed = lines.flatMap((line, at) => {
296 const head = /^(\s*)--- FAIL: (\S+)/.exec(line)
297
298 return head?.[2] === undefined ? [] : [{ at, indent: head[1]?.length ?? 0, name: head[2] }]
299 })
300
301 // A parent fails with its subtest; only the leaves are failures of their own.
302 for (const test of failed) {
303 if (failed.some(other => other.name.startsWith(`${test.name}/`))) {
304 continue
305 }
306
307 const detail = goLog(lines, test.at) ?? goLog(lines, lines.findIndex(line => line.trim() === `=== RUN ${test.name}`)) ?? []
308 const place = /^\s*(\S+\.go):\d+:/.exec(detail[0] ?? '')
309
310 parsed.failures.push({
311 name: test.name,
312 file: place?.[1],
313 message: firstLine(detail).replace(/^\S+\.go:\d+:\s*/, ''),
314 detail,
315 })
316 }
317
318 // Without -v go lists no passing test, only packages: count those instead.
319 if (listedPasses === 0) {
320 return { ...parsed, unit: 'packages', passed: packagesOk, failed: packagesFailed }
321 }
322
323 return { ...parsed, passed: listedPasses, failed: parsed.failures.length }
324}
325
326function bun(lines: string[]): Parsed | undefined {
327 const parsed = empty('bun')
328 let found = false
329 let from = 0
330 let file: string | undefined
331
332 lines.forEach((line, at) => {
333 const tally = /^\s*(\d+) (pass|fail|skip|todo)\s*$/.exec(line)
334
335 if (tally !== null) {
336 found = true
337 const n = Number(tally[1])
338
339 if (tally[2] === 'pass') parsed.passed += n
340 else if (tally[2] === 'fail') parsed.failed += n
341 else parsed.skipped += n
342 }
343
344 const ran = /^Ran \d+ tests? across .*\[([\d.]+)(ms|s)\]/.exec(line)
345
346 if (ran?.[1] !== undefined && ran[2] !== undefined) {
347 parsed.durationMs = (parsed.durationMs ?? 0) + toMs(ran[1], ran[2])
348 }
349
350 // bun prints a failure's error above its `(fail)` line, after the previous result.
351 const result = /^\((pass|fail|skip|todo)\) (.+?)(?: \[[\d.]+m?s\])?$/.exec(line)
352
353 if (result?.[2] !== undefined) {
354 if (result[1] === 'fail') {
355 const detail = trimDetail(lines.slice(from, at).filter(entry => !/^\S.*\.(test|spec)\.\w+:$/.test(entry)))
356 parsed.failures.push({ name: result[2], file, message: firstLine(detail), detail })
357 }
358
359 from = at + 1
360 } else if (/^\S.*\.(test|spec)\.\w+:$/.test(line)) {
361 file = line.slice(0, -1)
362 from = at + 1
363 }
364 })
365
366 return found ? parsed : undefined
367}
368
369function deno(lines: string[]): Parsed | undefined {
370 const parsed = empty('deno')
371 let found = false
372
373 for (const line of lines) {
374 const summary = /^(?:ok|FAILED) \| (\d+) passed.*?\| (\d+) failed(?:.*?\| (\d+) ignored)?.*\(([\d.]+)(ms|s)\)\s*$/.exec(line)
375
376 if (summary !== null) {
377 found = true
378 parsed.passed += Number(summary[1])
379 parsed.failed += Number(summary[2])
380 parsed.skipped += Number(summary[3] ?? 0)
381 parsed.durationMs = (parsed.durationMs ?? 0) + toMs(summary[4] ?? '0', summary[5] ?? 'ms')
382 }
383 }
384
385 if (!found) {
386 return undefined
387 }
388
389 const errors = lines.findIndex(line => /^\s*ERRORS\s*$/.test(line))
390 const failures = lines.findIndex(line => /^\s*FAILURES\s*$/.test(line))
391 const section = errors === -1 ? [] : lines.slice(errors + 1, failures === -1 ? undefined : failures)
392
393 section.forEach((line, at) => {
394 const head = /^(.+?) => (\S+?):\d+:\d+$/.exec(line)
395
396 if (head?.[1] === undefined) {
397 return
398 }
399
400 const end = section.findIndex((next, i) => i > at && / => \S+:\d+:\d+$/.test(next))
401 const detail = trimDetail(section.slice(at + 1, end === -1 ? undefined : end))
402
403 parsed.failures.push({ name: head[1], file: head[2], message: firstLine(detail).replace(/^error: /, ''), detail })
404 })
405
406 return parsed
407}
408
409const PARSERS: Record<Runner, (lines: string[]) => Parsed | undefined> = { vitest, jest, pytest, cargo, go, bun, deno }
410
411// The runner the command named goes first; a bare `npm test` tries each in turn.
412export function parse(output: string, hint?: Runner): Parsed | undefined {
413 const lines = linesOf(output)
414 const order = hint === undefined ? Object.keys(PARSERS) as Runner[] : [hint, ...(Object.keys(PARSERS) as Runner[]).filter(r => r !== hint)]
415
416 for (const runner of order) {
417 const parsed = PARSERS[runner](lines)
418
419 if (parsed !== undefined) {
420 // A file that failed to load fails no counted test, but is still a failure to look at.
421 return { ...parsed, failed: Math.max(parsed.failed, parsed.unit === 'tests' ? parsed.failures.length : 0) }
422 }
423 }
424
425 return undefined
426}
427types/index.d.ts 44 lines1export type Runner = 'vitest' | 'jest' | 'pytest' | 'cargo' | 'go' | 'bun' | 'deno'
2
3/** One failing test, as its runner printed it. */
4export type Failure = {
5 /** Suites and test joined with ` > ` */
6 name: string
7 file?: string
8 /** The first line of the error */
9 message: string
10 /** The error as printed, indentation kept, at most 30 lines */
11 detail: string[]
12}
13
14/** What one run's output came to. */
15export type Parsed = {
16 runner: Runner
17 passed: number
18 failed: number
19 skipped: number
20 durationMs?: number
21 /** `packages` when go ran without -v and listed no passing test */
22 unit: 'tests' | 'packages'
23 failures: Failure[]
24}
25
26/** A parsed run, kept in the history. */
27export type Run = Parsed & {
28 id: string
29 command: string
30 at: number
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 redgreen: {
36 runs: Run[]
37 /** The run the pane shows; null follows the latest */
38 viewing: string | null
39 /** The failure whose detail is open, by index in the shown run */
40 picked: number
41 }
42 }
43}
44