SLOPSHOPPER

redgreen

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…

newpaneguardcommandstatus
v0.1.0MITupdated 2026-10-09hellosverre/redgreen
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · redgreen
│ ┃ Tests ✕ › fix the failing auth test and add an audit log call │ ┃ ✗ 1 failed 3 passed bun · 0s ago │ ┃ $ bun test ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ● ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tests │ ⎿ redgreen: Tests pane opened (1 run). │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ redgreen: tests ✗ 1/4

Draws

Pane · Tests
✗ 1 failed 3 passed bun · 0s ago $ bun test ●
README

<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">

Install

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.

Update

claude plugin marketplace update redgreen
claude plugin update redgreen@redgreen

Uninstall

claude plugin uninstall redgreen@redgreen

Use

What it does
A failing runOpens the Tests pane on its own and sets the status line to tests ✗ 3/4
A green runUpdates the status line to tests ✓ 4; the pane stays shut
/testsOpens the pane on the latest run
/tests clearEmpties the history and clears the status line
A failure's nameShows its detail: the assertion, the diff, the code frame
p · n · lOlder run, newer run, latest run
k · jPrevious 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.

Runners

RunnerRead fromCounts
vitestvitest, vitest runtests, plus files that failed to load
jestjesttests, › paths as >
pytestpytest, python -m pytesttests, class tests as Class > test
cargocargo test, cargo +toolchain testtests, ignored as skipped
gogo testpackages, or tests and subtests with -v
bunbun testtests
denodeno testtests

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.

How it works

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.

Develop

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.

License

MIT

Source 5 files
hooks/register.tsx 131 lines
1import { 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}
131
hooks/format.ts 46 lines
1// 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)
46
hooks/pane.tsx 152 lines
1// 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}
152
hooks/parse.ts 427 lines
1// 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}
427
types/index.d.ts 44 lines
1export 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