Band above the prompt with pass/fail/skip counts for every test run, compared with the previous run of the same suite in the same worktree

A Claude Code mod. After every test run, from the main session or a subagent, a band above the prompt shows how many tests passed, failed and were skipped, next to the previous run of the same suite in the same worktree.
Cubby · xcodebuild test -scheme Cubby 412 ✓ 3 ✗ 2 skip ▲3 ✗ 1m12s done 14:32 prev 415 ✓ 0 ✗ · 2h ago via ios-fixer ✕
▲ more, ▼ fewer), duration, the time it finished (done 14:32), prev … (or first run), and via ….✕ at the end of the first line to close it sooner. It stays closed through a plugin reload; the next test run brings back a band holding only that run.via <the agent's description>. Workflow agents are tagged via workflow.via, then prev, then the duration, then the finish time.cd/env vars, behind npx, bunx, uv run, uv tool run, uvx, poetry run, python -m, pnpm exec, pnpm dlx or yarn: xcodebuild … test (or test-without-building), swift test, pytest, jest, vitest, bun test, npm|pnpm|yarn [run] test, cargo test, go test, fastlane scan|test. cat pytest.log is not one.ran · exit N · counts unavailable./plugin marketplace add abhijitbansal/claude-skills
/plugin install test-board@claude-skills
History lives in the plugin's store: the last 10 runs per suite per worktree. When the store passes a 2 MiB soft cap, the least recently used suites are dropped first. Session Compass reads the latest runs from test-board.latest when it is installed. Runs leave test-board.latest at 60 minutes, so Compass's tests segment clears with them; ✕ hides only this band and leaves the segment up.
A hot reload of the plugin mid-session forgets which agent ids belong to which subagent, so their later runs show as via workflow. It also resets the per-turn grouping once: the next run replaces the lines on the band. After a hot reload or worker restart the band may show the newest stored run dimmed until the next test run, if it finished within the last 60 minutes and was not closed with ✕.
Widths count terminal cells for CJK, Hangul, fullwidth forms and emoji drawn as emoji; other wide characters count as one cell, so a line holding them can wrap. A hot reload cancels the expiry timer; session.start re-arms it, so a line due in between leaves a moment late.
claude --plugin-dir plugins/test-board # lays .claude-plugin/types (gitignored)
npx -y -p typescript@5.6.3 tsc -p plugins/test-board
claude plugin validate plugins/test-board
claude plugin test plugins/test-boardhooks/register.tsx 292 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { TestRun } from '../types'
5import { GAP, lineParts, MAX_SHOWN_FAILURES, tail } from './format'
6import {
7 appendRun, BAND_TTL_MS, keysToDrop, MAX_LATEST, oldestQuarter, previousRun, STORE_SOFT_CAP_BYTES, touchIndex, visibleRuns,
8} from './history'
9import { parseTestOutput } from './parse'
10import { resolveDir, splitCommand, suiteKey, suiteLabel } from './suite'
11
12const latest = atom({ plugin: 'test-board', key: 'latest' } as const, [] as TestRun[])
13const expanded = atom({ plugin: 'test-board', key: 'expanded' } as const, false)
14const dismissed = atom({ plugin: 'test-board', key: 'dismissed' } as const, null as { key: string; at: number } | null)
15
16// The ✕ and the two cells of gap before it.
17const CLOSE_GAP = ' '
18const CLOSE_RESERVE = CLOSE_GAP.length + 1
19
20type Loop = { description: string; cwd?: string; isWorkflow: boolean }
21type Ran = { deny?: string; isError?: true; result?: unknown; text?: string }
22
23const asRuns = (v: unknown): TestRun[] => (Array.isArray(v) ? (v as TestRun[]) : [])
24const asKeys = (v: unknown): string[] => (Array.isArray(v) ? (v as string[]) : [])
25
26function outputOf(ran: Ran): string | null {
27 if (ran.deny !== undefined) return null
28 const result = ran.result as { stdout?: unknown; stderr?: unknown; backgroundTaskId?: unknown } | undefined
29 if (typeof result?.backgroundTaskId === 'string') return null
30 if (typeof result?.stdout === 'string') {
31 return `${result.stdout}\n${typeof result.stderr === 'string' ? result.stderr : ''}`
32 }
33 return ran.text ?? ''
34}
35
36function exitCodeOf(ran: Ran): number {
37 if (ran.isError !== true) return 0
38 return Number(/Exit code (\d+)/.exec(ran.text ?? '')?.[1]) || 1
39}
40
41type Roots = Map<string, string>
42type Loops = Map<string, Loop>
43type State = { loops: Loops; roots: Roots; turn: { isNew: boolean }; chain: Promise<void>; expiry?: Timer }
44
45async function worktreeRoot($: EngineInterface, roots: Roots, cwd: string): Promise<string> {
46 const cached = roots.get(cwd)
47 if (cached !== undefined) return cached
48 let root = cwd
49 try {
50 const out = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
51 if (out.exitCode === 0 && out.stdout.trim() !== '') root = out.stdout.trim()
52 } catch {
53 // not a repo, or git missing: the cwd stands in for the worktree
54 }
55 roots.set(cwd, root)
56 return root
57}
58
59async function writeRuns($: EngineInterface, key: string, runs: TestRun[], index: string[], drop: string[]) {
60 for (const k of drop) await $.store.delete(`runs:${k}`)
61 await $.store.set('index', index.filter(k => !drop.includes(k)))
62 await $.store.set(`runs:${key}`, runs)
63}
64
65// Runs whose key lost the shared index race (concurrent sessions) are never sized or trimmed; reclaim them.
66async function dropOrphans($: EngineInterface, index: string[]): Promise<void> {
67 const known = new Set(index.map(k => `runs:${k}`))
68 for (const k of asKeys(await $.store.keys())) {
69 if (k.startsWith('runs:') && !known.has(k)) await $.store.delete(k)
70 }
71}
72
73async function saveRun($: EngineInterface, run: TestRun): Promise<void> {
74 try {
75 await trySaveRun($, run)
76 } catch (err) {
77 $.ui.log(`test-board: history not saved: ${String(err).slice(0, 120)}`)
78 }
79}
80
81async function trySaveRun($: EngineInterface, run: TestRun): Promise<void> {
82 const runs = appendRun(asRuns(await $.store.get(`runs:${run.key}`)), run)
83 const index = touchIndex(asKeys(await $.store.get('index')), run.key)
84 const sizes = await Promise.all(index.map(async key => ({
85 key,
86 bytes: JSON.stringify(key === run.key ? runs : (await $.store.get(`runs:${key}`)) ?? []).length,
87 })))
88 const drop = keysToDrop(sizes, STORE_SOFT_CAP_BYTES, run.key)
89 try {
90 await writeRuns($, run.key, runs, index, drop)
91 } catch {
92 try {
93 await dropOrphans($, index)
94 const more = oldestQuarter(index.filter(k => !drop.includes(k)), run.key)
95 await writeRuns($, run.key, runs, index, [...drop, ...more])
96 } catch (err) {
97 $.ui.log(`test-board: history not saved: ${String(err).slice(0, 120)}`)
98 }
99 }
100}
101
102async function partsFor($: EngineInterface, run: TestRun, now: number, columns: number, reserve: number) {
103 let history: TestRun[] = []
104 try {
105 history = asRuns(await $.store.get(`runs:${run.key}`))
106 } catch {
107 // history unreadable: the run still draws, as a first run
108 }
109 return { run, parts: lineParts(run, previousRun(history, run), now, columns, reserve) }
110}
111
112// A draw is cached until something it read changes, so the hour has to end in a state write.
113// One timer, due when the oldest run in latest turns an hour old; each fire re-arms it.
114async function armExpiry($: EngineInterface, state: State): Promise<void> {
115 state.expiry?.cancel()
116 state.expiry = undefined
117 const runs = await read($, latest)
118 if (runs.length === 0) return
119 const due = Math.min(...runs.map(r => r.at)) + BAND_TTL_MS
120 state.expiry = $.clock.after(Math.max(0, due - await $.clock.now()), () => void expire($, state))
121}
122
123// Drops runs past the hour from latest: the write redraws every band that reads it (this one, session-compass).
124// Only expired runs go; a run dated after `now` (a clock stepped back) stays, and the render hides it.
125async function expire($: EngineInterface, state: State): Promise<void> {
126 try {
127 const now = await $.clock.now()
128 const isExpired = (r: TestRun): boolean => now - r.at >= BAND_TTL_MS
129 if ((await read($, latest)).some(isExpired)) {
130 const kept = await update($, latest, list => list.filter(r => !isExpired(r)))
131 if (kept.length === 0) await update($, expanded, () => false)
132 }
133 await armExpiry($, state)
134 } catch (err) {
135 $.ui.log(`test-board: expired runs not cleared: ${String(err).slice(0, 120)}`)
136 }
137}
138
139// Queued behind record(), so a run cannot land between reading and writing the dismissal.
140async function dismiss($: EngineInterface): Promise<void> {
141 const head = visibleRuns(await read($, latest), await $.clock.now())[0]
142 if (head === undefined) return
143 await update($, dismissed, () => ({ key: head.key, at: head.at }))
144 await update($, expanded, () => false)
145}
146
147const isDismissed = (d: { key: string; at: number } | null, run: TestRun | undefined): boolean =>
148 d !== null && run !== undefined && d.key === run.key && d.at === run.at
149
150async function record(
151 $: EngineInterface, state: State,
152 call: { command: string; agentId: string | undefined; startCwd: string }, ran: Ran,
153) {
154 const text = outputOf(ran)
155 if (text === null) return
156 const parts = splitCommand(call.command)
157 const parsed = parseTestOutput({ command: parts.normalized, text, exitCode: exitCodeOf(ran) })
158 if (parsed === null) return
159 const loop = call.agentId === undefined ? undefined : state.loops.get(call.agentId)
160 const base = loop?.cwd ?? call.startCwd
161 const cwd = parts.cdDir === undefined ? base : resolveDir(base, parts.cdDir)
162 const root = await worktreeRoot($, state.roots, cwd)
163 const via = call.agentId === undefined ? undefined : loop === undefined || loop.isWorkflow ? 'workflow' : loop.description
164 const run: TestRun = {
165 ...parsed,
166 suite: suiteLabel(parts.normalized),
167 key: suiteKey(root, parts.normalized),
168 at: await $.clock.now(),
169 ...(via === undefined ? {} : { via }),
170 }
171 await saveRun($, run)
172 const isFresh = state.turn.isNew
173 state.turn.isNew = false
174 // A run after ✕ starts the band afresh: the rows the person closed stay closed.
175 const wasDismissed = (await read($, dismissed)) !== null
176 await update($, latest, list =>
177 [run, ...(isFresh || wasDismissed ? [] : list.filter(r => r.key !== run.key && r.stale !== true))].slice(0, MAX_LATEST))
178 if (wasDismissed) await update($, dismissed, () => null)
179 await armExpiry($, state)
180}
181
182export const register: Register = on => {
183 const state = { loops: new Map<string, Loop>(), roots: new Map<string, string>(), turn: { isNew: true }, chain: Promise.resolve() } as State
184
185 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
186 // The directory the command starts in: read it before the command can change it.
187 let startCwd: string | undefined
188 try {
189 startCwd = await $.session.cwd()
190 } catch (err) {
191 $.ui.log(`test-board: cwd unavailable: ${String(err).slice(0, 120)}`)
192 }
193 const ran = await next(e)
194 if (startCwd !== undefined) {
195 // Serial queue: record() calls never interleave, so store and latest updates cannot be lost.
196 state.chain = state.chain
197 .then(() => record($, state, { command: e.command, agentId: e.agentId, startCwd }, ran as Ran))
198 .catch((err: unknown) => {
199 $.ui.log(`test-board: run not recorded: ${String(err).slice(0, 120)}`)
200 })
201 await state.chain
202 }
203 return ran
204 })
205
206 on('agent.spawn', async ($, e, next) => {
207 const started = await next(e)
208 if (started.deny === undefined && started.agentId !== undefined) {
209 state.loops.set(started.agentId, {
210 description: e.description,
211 ...(e.cwd === undefined ? {} : { cwd: e.cwd }),
212 isWorkflow: e.workflow !== undefined,
213 })
214 }
215 return started
216 })
217
218 on('prompt.submit', ($, e, next) => {
219 // A delivery into a running turn (peer message, notification) is not a new turn.
220 if (e.turnId === undefined) state.turn.isNew = true
221 return next(e)
222 })
223
224 on('session.start', async ($, e, next) => {
225 const result = next(e)
226 try {
227 const root = await worktreeRoot($, state.roots, e.cwd)
228 const key = asKeys(await $.store.get('index')).find(k => k.startsWith(`${root}::`))
229 const newest = key === undefined ? undefined : asRuns(await $.store.get(`runs:${key}`))[0]
230 const now = await $.clock.now()
231 // A reload fires session.start too: a run past the hour stays off the band, and a closed band stays closed.
232 const isClosed = (await read($, dismissed)) !== null
233 if (newest !== undefined && !isClosed && visibleRuns([newest], now).length > 0) {
234 await update($, latest, () => [{ ...newest, stale: true }])
235 }
236 // A hot reload cancels the old timer: re-arm for whatever latest now holds.
237 await armExpiry($, state)
238 } catch (err) {
239 $.ui.log(`test-board: history not loaded: ${String(err).slice(0, 120)}`)
240 }
241 return result
242 })
243 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
244 if (e.props.hasSurvey) return next(e)
245 const held = await read($, latest)
246 if (held.length === 0) return next(e)
247 const now = await $.clock.now()
248 // Hides runs dated after `now` and covers a timer that has not fired yet.
249 const runs = visibleRuns(held, now)
250 if (runs.length === 0 || isDismissed(await read($, dismissed), runs[0])) return next(e)
251 const isOpen = await read($, expanded)
252 const { Box, Button, Text } = $.ui.resolve(e)
253 const columns = e.props.bodyColumns
254 const rows = await Promise.all(runs.map((run, i) => partsFor($, run, now, columns, i === 0 ? CLOSE_RESERVE : 0)))
255 const close = (): Promise<void> => {
256 state.chain = state.chain.then(() => dismiss($)).catch((err: unknown) => {
257 $.ui.log(`test-board: band not closed: ${String(err).slice(0, 120)}`)
258 })
259 return state.chain
260 }
261 return (
262 <Box flexDirection="column">
263 {rows.map(({ run, parts }, i) => (
264 <Box flexDirection="column">
265 <Box flexDirection="row">
266 <Button key={`run-${run.key}`} plain onPress={() => update($, expanded, v => !v)}>
267 <Text dimColor={run.stale === true}>{parts.label}{GAP}</Text>
268 {parts.split === undefined && <Text dimColor={run.stale === true}>{parts.counts}</Text>}
269 {parts.split !== undefined && <Text dimColor={run.stale === true}>{parts.split.head}</Text>}
270 {parts.split !== undefined && (
271 <Text color={parts.isFailing ? 'error' : undefined} dimColor={run.stale === true}>{parts.split.failed}</Text>
272 )}
273 {parts.split !== undefined && <Text dimColor={run.stale === true}>{parts.split.tail}</Text>}
274 <Text dimColor={run.stale === true}>{tail(parts.trend, parts.duration)}</Text>
275 <Text dimColor>{tail(parts.doneAt, parts.prev, parts.via)}</Text>
276 </Button>
277 {i === 0 && <Text>{CLOSE_GAP}</Text>}
278 {i === 0 && (
279 <Button key="close" plain dimColor role="dismiss" label="Close test results" onPress={close}>✕</Button>
280 )}
281 </Box>
282 {isOpen && run.failures.slice(0, MAX_SHOWN_FAILURES).map(f => (
283 <Text dimColor>{` ${f.id}${f.loc === undefined ? '' : ` ${f.loc}`}`}</Text>
284 ))}
285 </Box>
286 ))}
287 {await next(e)}
288 </Box>
289 )
290 })
291}
292hooks/format.ts 97 lines1import type { TestRun } from '../types'
2
3export const NARROW_COLUMNS = 80
4export const MAX_SHOWN_FAILURES = 10
5export const GAP = ' '
6
7// East Asian Wide/Fullwidth blocks, emoji drawn as emoji, and an emoji variation selector: two cells.
8const WIDE = /[\u1100-\u115f\u2e80-\u303e\u3041-\u33ff\u3400-\u4dbf\u4e00-\u9fff\ua000-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6\u{20000}-\u{3fffd}\ufe0f]|\p{Emoji_Presentation}/u
9const segmenter = typeof Intl === 'object' && 'Segmenter' in Intl ? new Intl.Segmenter() : undefined
10
11const graphemes = (s: string): string[] =>
12 segmenter === undefined ? Array.from(s) : Array.from(segmenter.segment(s), g => g.segment)
13
14/** Terminal cells a string takes: a grapheme is one cell, two when wide. */
15export function cellWidth(s: string): number {
16 return graphemes(s).reduce((cells, g) => cells + (WIDE.test(g) ? 2 : 1), 0)
17}
18
19/** Each present part with the gap before it, as the band draws the end of a line. */
20export function tail(...parts: (string | undefined)[]): string {
21 return parts.filter(Boolean).map(s => `${GAP}${s}`).join('')
22}
23
24export type LineParts = {
25 label: string; counts: string; isFailing: boolean
26 /** `counts` cut around its failed segment so the UI can color only that part; absent when unparsed. */
27 split?: { head: string; failed: string; tail: string }
28 trend?: string; duration?: string; doneAt?: string; prev?: string; via?: string
29}
30
31export function formatDuration(seconds: number): string {
32 const s = Math.round(seconds)
33 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
34}
35
36export function formatAgo(ms: number): string {
37 const minutes = Math.floor(ms / 60_000)
38 if (minutes < 1) return 'just now'
39 if (minutes < 60) return `${minutes}m ago`
40 const hours = Math.floor(minutes / 60)
41 return hours < 48 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`
42}
43
44export function formatClock(at: number): string {
45 const d = new Date(at)
46 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
47}
48
49function repoName(key: string): string {
50 return key.slice(0, key.indexOf('::')).split('/').filter(Boolean).pop() ?? ''
51}
52
53function countSplit(run: TestRun): LineParts['split'] {
54 return run.parsed
55 ? { head: `${run.passed} ✓ `, failed: `${run.failed} ✗`, tail: ` ${run.skipped} skip` }
56 : undefined
57}
58
59function counts(run: TestRun): string {
60 const split = countSplit(run)
61 return split === undefined
62 ? `ran · exit ${run.exitCode} · counts unavailable`
63 : `${split.head}${split.failed}${split.tail}`
64}
65
66export function joinParts(p: LineParts): string {
67 return [p.label, p.counts, p.trend, p.duration, p.doneAt, p.prev, p.via].filter(Boolean).join(GAP)
68}
69
70/**
71 * The line's parts, dropping fields until it fits `columns - reserve` cells.
72 * `reserve` is width another control takes on the row; `via` drops on a
73 * narrow terminal by `columns` alone.
74 */
75export function lineParts(run: TestRun, prev: TestRun | undefined, now: number, columns: number, reserve = 0): LineParts {
76 const delta = prev?.parsed && run.parsed ? run.failed - prev.failed : 0
77 let parts: LineParts = {
78 label: `${repoName(run.key)} · ${run.suite}`,
79 counts: counts(run),
80 ...(run.parsed ? { split: countSplit(run) } : {}),
81 isFailing: run.parsed ? run.failed > 0 : run.exitCode !== 0,
82 ...(delta > 0 ? { trend: `▲${delta} ✗` } : delta < 0 ? { trend: `▼${-delta} ✗` } : {}),
83 ...(run.durationS === undefined ? {} : { duration: formatDuration(run.durationS) }),
84 doneAt: `done ${formatClock(run.at)}`,
85 prev: prev === undefined
86 ? 'first run'
87 : `prev ${prev.parsed ? `${prev.passed} ✓ ${prev.failed} ✗` : `ran · exit ${prev.exitCode}`} · ${formatAgo(now - prev.at)}`,
88 ...(run.via === undefined ? {} : { via: `via ${run.via}` }),
89 }
90 if (columns < NARROW_COLUMNS) parts = { ...parts, via: undefined }
91 for (const field of ['via', 'prev', 'duration', 'doneAt'] as const) {
92 if (cellWidth(joinParts(parts)) <= columns - reserve) break
93 parts = { ...parts, [field]: undefined }
94 }
95 return parts
96}
97hooks/history.ts 51 lines1import type { TestRun } from '../types'
2
3export const MAX_RUNS = 10
4export const MAX_LATEST = 3
5export const MAX_FAILURES = 20
6export const STORE_SOFT_CAP_BYTES = 2 * 1024 * 1024
7export const BAND_TTL_MS = 60 * 60_000
8
9export type KeySize = { key: string; bytes: number }
10
11function toStored(run: TestRun): TestRun {
12 const { stale: _stale, ...rest } = run
13 return { ...rest, failures: run.failures.slice(0, MAX_FAILURES) }
14}
15
16export function appendRun(history: TestRun[], run: TestRun): TestRun[] {
17 return [toStored(run), ...history].slice(0, MAX_RUNS)
18}
19
20export function previousRun(history: TestRun[], run: TestRun): TestRun | undefined {
21 return history.find(r => r.at < run.at)
22}
23
24/** Runs the band still shows: under an hour old, and not dated after `now` (a clock stepped back). */
25export function visibleRuns(latest: TestRun[], now: number): TestRun[] {
26 return latest.filter(r => now - r.at >= 0 && now - r.at < BAND_TTL_MS)
27}
28
29export function touchIndex(index: string[], key: string): string[] {
30 return [key, ...index.filter(k => k !== key)]
31}
32
33/** `sizes` is most-recent-first; drops from the end until the total fits. */
34export function keysToDrop(sizes: KeySize[], capBytes: number, keep: string): string[] {
35 let total = sizes.reduce((sum, s) => sum + s.bytes, 0)
36 const dropped: string[] = []
37 for (let i = sizes.length - 1; i >= 0 && total > capBytes; i -= 1) {
38 const size = sizes[i]
39 if (size === undefined || size.key === keep) continue
40 total -= size.bytes
41 dropped.push(size.key)
42 }
43 return dropped
44}
45
46export function oldestQuarter(index: string[], keep: string): string[] {
47 const candidates = index.filter(k => k !== keep)
48 const count = Math.max(1, Math.floor(index.length / 4))
49 return candidates.slice(-count)
50}
51hooks/parse.ts 141 lines1import type { TestFailure, TestRun } from '../types'
2
3export type ParsedRun = Pick<TestRun,
4 'total' | 'passed' | 'failed' | 'skipped' | 'durationS' | 'failures' | 'exitCode' | 'parsed'>
5type Counts = Omit<ParsedRun, 'exitCode' | 'parsed'>
6type Reader = (text: string) => Counts | null
7
8// Anchored to the start of a command segment (optionally behind a runner prefix), so `cat pytest.log` is not a test run.
9const RUNNER_PREFIX = '(?:(?:python3?\\s+-m|npx|bunx|uv\\s+run|uv\\s+tool\\s+run|uvx|poetry\\s+run|pnpm\\s+exec|pnpm\\s+dlx|yarn)\\s+)*'
10const TEST_COMMAND = new RegExp(
11 `(?:^|[;&|]\\s*)${RUNNER_PREFIX}(?:xcodebuild\\b(?:\\s+[^\\s;&|]+)*?\\s+test(?:-without-building)?(?=\\s|$|[;&|])|swift\\s+test\\b|pytest\\b|jest\\b|vitest\\b|bun\\s+test\\b|(?:npm|pnpm|yarn)\\s+(?:run\\s+)?test\\b|cargo\\s+test\\b|go\\s+test\\b|fastlane\\s+(?:scan|test)\\b)`,
12)
13
14export function isTestCommand(normalized: string): boolean {
15 return TEST_COMMAND.test(normalized)
16}
17
18function lastMatch(re: RegExp, text: string): RegExpExecArray | null {
19 let last: RegExpExecArray | null = null
20 for (const m of text.matchAll(re)) last = m as RegExpExecArray
21 return last
22}
23
24function unquote(cell: string): string {
25 return cell.startsWith('"') && cell.endsWith('"') ? cell.slice(1, -1).replace(/\\"/g, '"') : cell
26}
27
28const readCartoon: Reader = text => {
29 const m = /summary\{total,passed,failed,skipped,duration_s\}:\s*(\d+),(\d+),(\d+),(\d+),([\d.]+)/.exec(text)
30 if (!m) return null
31 const failures: TestFailure[] = []
32 const block = /failures\[\d+\]\{id,loc,msg\}:\n((?:[ \t]+.*(?:\n|$))*)/.exec(text)
33 for (const row of block?.[1]?.split('\n') ?? []) {
34 const cells = /^\s+("(?:[^"\\]|\\.)*"|[^,]*),("(?:[^"\\]|\\.)*"|[^,]*),/.exec(row)
35 if (cells?.[1]) failures.push({ id: unquote(cells[1]), ...(cells[2] ? { loc: unquote(cells[2]) } : {}) })
36 }
37 return {
38 total: Number(m[1]), passed: Number(m[2]), failed: Number(m[3]), skipped: Number(m[4]),
39 durationS: Number(m[5]), failures,
40 }
41}
42
43const readXcodebuild: Reader = text => {
44 const m = lastMatch(/Executed (\d+) tests?, with (?:(\d+) tests? skipped and )?(\d+) failures? \(\d+ unexpected\) in ([\d.]+)/g, text)
45 if (!m) return null
46 const locs = new Map<string, string>()
47 for (const l of text.matchAll(/^(.+?:\d+): error: -\[(\S+) (\S+)\]/gm)) locs.set(`${l[2]}.${l[3]}`, l[1] ?? '')
48 const ids = [...new Set([...text.matchAll(/Test Case '-\[(\S+) (\S+)\]' failed/g)].map(f => `${f[1]}.${f[2]}`))]
49 const total = Number(m[1]); const skipped = Number(m[2] ?? 0)
50 // XCTest's "N failures" counts failed assertions, not tests: prefer the distinct failed case ids.
51 const failed = Math.min(total, ids.length > 0 ? ids.length : Number(m[3]))
52 return {
53 total, skipped, failed, passed: Math.max(0, total - failed - skipped), durationS: Number(m[4]),
54 failures: ids.map(id => (locs.has(id) ? { id, loc: locs.get(id) } : { id })),
55 }
56}
57
58const readSwiftTesting: Reader = text => {
59 const m = /Test run with (\d+) tests?(?: in \d+ suites?)? (passed|failed) after ([\d.]+) seconds/.exec(text)
60 if (!m) return null
61 const ids = [...text.matchAll(/✘ Test (\S+) failed after/g)].map(f => f[1] ?? '')
62 const total = Number(m[1])
63 const failed = m[2] === 'failed' ? Math.max(1, ids.length) : 0
64 return { total, failed, skipped: 0, passed: total - failed, durationS: Number(m[3]), failures: ids.map(id => ({ id })) }
65}
66
67// Final pytest line: `=== 1 failed, 10 passed in 3.50s ===` (default) or bare `1 failed, 10 passed in 3.50s` (-q).
68const PYTEST_SUMMARY = /^(?:=+ )?((?:\d+ (?:passed|failed|skipped|errors?|xfailed|xpassed|deselected|warnings?)(?:, )?)+) in ([\d.]+)s\b/gm
69
70// `swift test` prints an XCTest summary (possibly "Executed 0 tests") and then a swift-testing run line.
71// Sum both when present; an empty XCTest run therefore never shadows swift-testing.
72const readSwiftPackage: Reader = text => {
73 const parts = [attempt(readXcodebuild, text), attempt(readSwiftTesting, text)].filter((c): c is Counts => c !== null)
74 const live = parts.filter(c => c.total > 0)
75 if (live.length === 0) return parts[0] ?? null
76 if (live.length === 1) return live[0] ?? null
77 const sum = (pick: (c: Counts) => number) => live.reduce((acc, c) => acc + pick(c), 0)
78 const durationS = live.reduce<number | undefined>((acc, c) => (c.durationS === undefined ? acc : (acc ?? 0) + c.durationS), undefined)
79 return {
80 total: sum(c => c.total), passed: sum(c => c.passed), failed: sum(c => c.failed), skipped: sum(c => c.skipped),
81 ...(durationS === undefined ? {} : { durationS }), failures: live.flatMap(c => c.failures),
82 }
83}
84
85const readPytest: Reader = text => {
86 const m = lastMatch(PYTEST_SUMMARY, text)
87 if (!m?.[1]) return null
88 const counts = m[1]
89 const n = (word: RegExp) => [...counts.matchAll(word)].reduce((sum, p) => sum + Number(p[1]), 0)
90 const passed = n(/(\d+) passed/g); const skipped = n(/(\d+) skipped/g)
91 const failed = n(/(\d+) failed/g) + n(/(\d+) errors?/g)
92 const ids = [...text.matchAll(/^FAILED (\S+)/gm)].map(f => ({ id: f[1] ?? '' }))
93 return { total: passed + failed + skipped, passed, failed, skipped, durationS: Number(m[2]), failures: ids }
94}
95
96function tokenCounts(list: string): { failed: number; skipped: number; passed: number; todo: number } {
97 const n = (word: string) => Number(new RegExp(`(\\d+) ${word}\\b`).exec(list)?.[1] ?? 0)
98 return { failed: n('failed'), skipped: n('skipped'), passed: n('passed'), todo: n('todo') }
99}
100
101const readJest: Reader = text => {
102 const jest = /^\s*Tests:\s+(.+)$/m.exec(text)
103 const jestTotal = jest?.[1] ? /(\d+) total/.exec(jest[1]) : null
104 if (jest?.[1] && jestTotal) {
105 const t = tokenCounts(jest[1])
106 return { failed: t.failed, skipped: t.skipped + t.todo, passed: t.passed, total: Number(jestTotal[1]), failures: [] }
107 }
108 const vitest = /^\s*Tests\s+(.+?) \((\d+)\)\s*$/m.exec(text)
109 if (vitest?.[1]) {
110 const t = tokenCounts(vitest[1])
111 return { failed: t.failed, skipped: t.skipped + t.todo, passed: t.passed, total: Number(vitest[2]), failures: [] }
112 }
113 const pass = /^\s*(\d+) pass\s*$/m.exec(text)
114 if (!pass) return null
115 const fail = Number(/^\s*(\d+) fail\s*$/m.exec(text)?.[1] ?? 0)
116 const skip = Number(/^\s*(\d+) skip\s*$/m.exec(text)?.[1] ?? 0)
117 const ids = [...text.matchAll(/^\(fail\) (.+?)(?: \[[\d.]+m?s\])?$/gm)].map(f => ({ id: f[1] ?? '' }))
118 return { passed: Number(pass[1]), failed: fail, skipped: skip, total: Number(pass[1]) + fail + skip, failures: ids }
119}
120
121const RAW_READERS: Reader[] = [readSwiftPackage, readPytest, readJest]
122
123function attempt(reader: Reader, text: string): Counts | null {
124 try {
125 return reader(text)
126 } catch {
127 return null
128 }
129}
130
131export function parseTestOutput(input: { command: string; text: string; exitCode: number }): ParsedRun | null {
132 const isTest = isTestCommand(input.command)
133 const readers = isTest ? [readCartoon, ...RAW_READERS] : [readCartoon]
134 for (const reader of readers) {
135 const counts = attempt(reader, input.text)
136 if (counts) return { ...counts, exitCode: input.exitCode, parsed: true }
137 }
138 if (!isTest) return null
139 return { total: 0, passed: 0, failed: 0, skipped: 0, failures: [], exitCode: input.exitCode, parsed: false }
140}
141hooks/suite.ts 55 lines1export const SUITE_LABEL_COLUMNS = 48
2
3const LEADING_CD = /^cd\s+(?:"([^"]+)"|'([^']+)'|(\S+))\s*(?:&&|;)\s*/
4const ENV_PREFIX = /^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+)+/
5const CARTOON_C = /^cartoon\s+(?:--\S+\s+)*-c\s+(?:'([^']*)'|"([^"]*)")\s*$/
6const CARTOON_PREFIX = /^cartoon\s+(?:--tag\s+\S+\s+)?(?:--\s+)?/
7const CARTOON_UTILITY = /^cartoon\s+(?:logs|stats|adapters|doctor|ingest|help|version)\b/
8const TRAILING_PIPE = /\s*\|\s*(?:head|tail|grep|rg)\b.*$/
9const STDERR_MERGE = /\s*2>&1/g
10
11function joinDirs(outer: string | undefined, inner: string | undefined): string | undefined {
12 if (outer === undefined || inner === undefined) return inner ?? outer
13 return inner.startsWith('/') || inner.startsWith('~') ? inner : `${outer}/${inner}`
14}
15
16export function splitCommand(command: string): { cdDir?: string; normalized: string } {
17 let rest = command.trim()
18 let cdDir: string | undefined
19 const cd = LEADING_CD.exec(rest)
20 if (cd) {
21 cdDir = cd[1] ?? cd[2] ?? cd[3]
22 rest = rest.slice(cd[0].length)
23 }
24 rest = rest.replace(ENV_PREFIX, '')
25 const wrapped = CARTOON_C.exec(rest)
26 if (wrapped) {
27 const inner = splitCommand(wrapped[1] ?? wrapped[2] ?? '')
28 const dir = joinDirs(cdDir, inner.cdDir)
29 return dir === undefined ? { normalized: inner.normalized } : { cdDir: dir, normalized: inner.normalized }
30 }
31 if (!CARTOON_UTILITY.test(rest)) rest = rest.replace(CARTOON_PREFIX, '')
32 rest = rest.replace(TRAILING_PIPE, '').replace(STDERR_MERGE, '')
33 const normalized = rest.replace(/\s+/g, ' ').trim()
34 return cdDir === undefined ? { normalized } : { cdDir, normalized }
35}
36
37export function resolveDir(base: string, dir: string): string {
38 if (dir.startsWith('/') || dir.startsWith('~')) return dir
39 const parts: string[] = []
40 for (const part of `${base}/${dir}`.split('/')) {
41 if (part === '' || part === '.') continue
42 if (part === '..') parts.pop()
43 else parts.push(part)
44 }
45 return `/${parts.join('/')}`
46}
47
48export function suiteKey(worktreeRoot: string, normalized: string): string {
49 return `${worktreeRoot}::${normalized}`
50}
51
52export function suiteLabel(normalized: string): string {
53 return normalized.slice(0, SUITE_LABEL_COLUMNS)
54}
55types/index.d.ts 23 lines1export type TestFailure = { id: string; loc?: string }
2
3export type TestRun = {
4 suite: string // label, e.g. "xcodebuild test -scheme Cubby"
5 key: string // `${worktreeRoot}::${normalizedCommand}`
6 total: number; passed: number; failed: number; skipped: number
7 durationS?: number
8 failures: TestFailure[] // capped at 20
9 exitCode: number
10 at: number // epoch ms
11 via?: string // subagent description, "workflow", or undefined for the main loop
12 parsed: boolean // false: looked like a test run, counts unavailable
13 stale?: boolean // true only in `latest` for a run loaded from history at
14 // session start; never written to the store
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 // dismissed: the newest run shown when ✕ was pressed; null when the band is open
20 'test-board': { latest: TestRun[]; expanded: boolean; dismissed: { key: string; at: number } | null }
21 }
22}
23