SLOPSHOPPER

test-board

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

newbandguardpromptprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · test-board
› fix the failing auth test and add an audit log call ⏺ 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 run-/work/app::bun test Close test results ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
run-/work/app::bun test Close test results ⟨Claude Code's own drawing⟩
README

test-board

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  ✕

What the band shows

  • One line per recent run, up to 3. Runs in the same turn stack; the first run after you submit a prompt replaces the previous turn's lines.
  • Each line: repo and suite, counts, change in failures against the previous run (▲ more, ▼ fewer), duration, the time it finished (done 14:32), prev … (or first run), and via ….
  • Each line leaves the band 60 minutes after its run finished; the band closes with the last one. Press ✕ 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.
  • Press the line to list up to 10 failing tests with their locations.
  • Runs from a subagent are tagged via <the agent's description>. Workflow agents are tagged via workflow.
  • When a session starts, the newest stored run of the current worktree appears dimmed (stale) until a new run replaces it, if it finished within the last 60 minutes.
  • The band stays hidden while a survey is on screen. Narrow terminals drop via, then prev, then the duration, then the finish time.
  • These commands count as test runs, alone or after a leading 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.
  • It reads cartoon's test summary first, then raw xcodebuild, swift test, pytest, jest/vitest and bun output.
  • A recognised test command whose output it cannot read shows ran · exit N · counts unavailable.
  • It never blocks or changes a tool call, and makes no model calls.

Install

/plugin marketplace add abhijitbansal/claude-skills
/plugin install test-board@claude-skills

Data

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.

Known limits

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.

Develop

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-board
Source 6 files
hooks/register.tsx 292 lines
1import { 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}
292
hooks/format.ts 97 lines
1import 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}
97
hooks/history.ts 51 lines
1import 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}
51
hooks/parse.ts 141 lines
1import 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}
141
hooks/suite.ts 55 lines
1export 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}
55
types/index.d.ts 23 lines
1export 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