Your test suite's heartbeat: every test run Claude makes shows up in the status line (๐งช โ 44/44 passing ยท 2m ago), with a toast when tests go red or greenโฆ

Your test suite's heartbeat. Every test run Claude makes lands in the status line, a toast tells you the moment tests go red or green again, and
/testsshows the trend and the flakes.
<img src="../../../assets/screens/test-pulse.svg" alt="test-pulse in a real Claude Code session" width="100%">
๐งช โ 1/2 passing ยท just now
โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
โ test-pulse โ
โ tests went red: 1 failing ยท divides โ
โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
> /tests
โฟ test-pulse: Last 6 runs ยท pass rate โโ
โ
โโโ (oldest โ newest)
โ
just now 44/44 npm test
โ
3m ago 44/44 npm test
โ
5m ago 43/43 npx vitest run src/auth
โ 9m ago 41/44 npm test โ login โบ rejects a bad password
...
Possibly flaky (failed, then passed with no edits in between): checkout โบ retries payment
๐งช โ
44/44 passing ยท 2m ago, or ๐งช โ 41/44 passing ยท just now. The age updates once a minute. A run with no recognised summary shows tests passed or tests failed from the exit status.tests went red: 3 failing ยท login โบ rejects a bad password, then tests are green again ๐ (44 passing).-v), mocha, rspec, mix, bun, node --test (spec and TAP), phpunit, dotnet, deno, maven, gradle, ctest, swift and claude plugin test. It names the first failing tests where the output does.cd web && npm test 2>&1 | tail, uv run pytest -x and timeout 300 go test ./... count. cat jest.config.js and git commit -m "fix go test" don't./tests: the last 20 runs in this project, newest first, with a pass-rate sparkline, failing names, and likely flakes: tests that failed and then passed on the same command with no file edited in between. /tests clear wipes the history./plugin marketplace add Singh-AP/awesome-claude-mods
/plugin install test-pulse@awesome-claude-mods
Requires Claude Code 2.1.287 or later.
None. If a future Claude Code build ships its own /tests, the command falls back to /test-pulse.
| Event | Why |
|---|---|
tool.call on Bash | Spots a test run, await next(e), reads the summary from the output, records the run |
tool.call on Edit, Write, NotebookEdit | Counts edits, so flake detection knows whether code changed between two runs |
session.start | Loads this project's history and shows the last run |
command.run | /tests |
$.ui.status, $.ui.toast, $.clock.every | The status line, the colour-change toasts, the minute tick |
$.store | Up to 20 runs per project root |
The detector (hooks/detect.ts) and the parsers (hooks/parse.ts) are pure TypeScript, so other mods can import them.
claude plugin test mods/awareness/test-pulse # 78 tests
run_in_background) aren't recorded.sed -i, isn't counted as an edit.-v, go test reports packages rather than tests, so the counts are packages.hooks/register.ts 95 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { testRunner } from './detect'
4import { append, readRuns, report, statusLine, toRun, transition, type Run } from './history'
5import { parseOutput } from './parse'
6
7// This project's runs, newest last; persisted in $.store under its root.
8let runs: Run[] = []
9let storeKey = 'runs:'
10// Bumped on every successful file edit; a session token keeps epochs from
11// different sessions apart.
12const session = Math.random().toString(36).slice(2, 8)
13let edits = 0
14let ticker: { cancel: () => void } | undefined
15
16async function showPulse($: EngineInterface) {
17 const last = runs.at(-1)
18 $.ui.status(last === undefined ? undefined : statusLine(last, await $.clock.now()))
19}
20
21// Keeps "2m ago" honest; one cheap status write a minute, only once a run exists.
22function keepTicking($: EngineInterface) {
23 if (ticker !== undefined) return
24 ticker = $.clock.every(60_000, () => {
25 void showPulse($)
26 })
27}
28
29async function record($: EngineInterface, run: Run) {
30 const previous = [...runs].reverse().find(r => r.runner === run.runner)
31 runs = append(runs, run)
32 await $.store.set(storeKey, runs)
33 const news = transition(previous, run)
34 if (news !== undefined) $.ui.toast(news)
35 await showPulse($)
36 keepTicking($)
37}
38
39export const register: Register = on => {
40 on('session.start', async ($, e, next) => {
41 storeKey = `runs:${await $.session.root()}`
42 runs = readRuns(await $.store.get(storeKey))
43 if (runs.length > 0) {
44 await showPulse($)
45 keepTicking($)
46 }
47 const description = 'Test runs this project has seen: pass-rate sparkline, failures, flakes'
48 try {
49 await $.command.register({ name: 'tests', description, argumentHint: '[clear]' })
50 } catch {
51 // A build that already has /tests: fall back to the mod's own name.
52 await $.command.register({ name: 'test-pulse', description, argumentHint: '[clear]' }).catch(() => undefined)
53 }
54 return next(e)
55 })
56
57 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
58 const ran = await next(e)
59 if (ran.deny === undefined && ran.isError !== true) edits += 1
60 return ran
61 })
62
63 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
64 const runner = testRunner(e.command)
65 if (runner === undefined || e.run_in_background === true) return next(e)
66
67 const ran = await next(e)
68 if (ran.deny !== undefined) return ran
69 const result = ran.result as { stdout?: string; stderr?: string; interrupted?: boolean; backgroundTaskId?: string } | undefined
70 if (result?.interrupted === true || result?.backgroundTaskId !== undefined) return ran
71
72 const output = ran.text ?? `${result?.stdout ?? ''}\n${result?.stderr ?? ''}`
73 const run = toRun({
74 at: await $.clock.now(),
75 runner,
76 command: e.command,
77 parsed: parseOutput(output),
78 exitOk: ran.isError !== true,
79 epoch: `${session}:${edits}`,
80 })
81 await record($, run)
82 return ran
83 })
84
85 on('command.run', { command: ['tests', 'test-pulse'] }, async ($, e) => {
86 if (e.args.trim() === 'clear') {
87 runs = []
88 await $.store.delete(storeKey)
89 $.ui.status(undefined)
90 return { text: 'Test history cleared for this project.' }
91 }
92 return { text: report(runs, await $.clock.now()) }
93 })
94}
95hooks/detect.ts 105 lines1// Which Bash commands are test runs. Pure: no `$`.
2
3// Words that run the rest of the line as a command.
4const WRAPPERS = new Set(['npx', 'bunx', 'pnpx', 'time', 'timeout', 'nice', 'env', 'nohup', 'command', 'exec', 'sudo', 'xvfb-run', 'dotenv', 'caffeinate'])
5const TWO_WORD_WRAPPERS = new Set(['pnpm exec', 'pnpm dlx', 'yarn dlx', 'yarn exec', 'npm exec', 'uv run', 'poetry run', 'pipenv run', 'pdm run', 'hatch run', 'bundle exec', 'rye run', 'mise exec', 'doppler run', 'op run'])
6
7// The runners, matched against the command once wrappers are gone.
8const RUNNERS: ReadonlyArray<readonly [label: string, pattern: RegExp]> = [
9 ['claude plugin test', /^claude plugin test\b/],
10 ['node --test', /^node\b.*\s--test\b/],
11 ['npm test', /^npm (?:run(?:-script)? )?(?:test|t)(?::[\w:-]+)?\b/],
12 ['pnpm test', /^pnpm (?:run )?test(?::[\w:-]+)?\b/],
13 ['yarn test', /^yarn (?:run )?test(?::[\w:-]+)?\b/],
14 ['bun test', /^bun (?:run )?test\b/],
15 ['deno test', /^deno test\b/],
16 ['vitest', /^vitest\b/],
17 ['jest', /^jest\b/],
18 ['mocha', /^mocha\b/],
19 ['ava', /^ava\b/],
20 ['playwright', /^playwright test\b/],
21 ['cypress', /^cypress run\b/],
22 ['pytest', /^(?:pytest|py\.test)\b/],
23 ['pytest', /^python[\d.]* -m pytest\b/],
24 ['unittest', /^python[\d.]* -m unittest\b/],
25 ['tox', /^(?:tox|nox)\b/],
26 ['go test', /^go test\b/],
27 ['cargo test', /^cargo (?:test|nextest run)\b/],
28 ['maven', /^(?:mvn|\.\/mvnw|mvnw)\b.*\b(?:test|verify)\b/],
29 ['gradle', /^(?:gradle|\.\/gradlew|gradlew)\b.*\b(?:test|check)\b/],
30 ['rspec', /^rspec\b/],
31 ['rails test', /^(?:rails|bin\/rails|rake) test\b/],
32 ['phpunit', /^(?:phpunit|vendor\/bin\/phpunit|\.\/vendor\/bin\/phpunit)\b/],
33 ['phpunit', /^php artisan test\b/],
34 ['dotnet test', /^dotnet test\b/],
35 ['mix test', /^mix test\b/],
36 ['swift test', /^swift test\b/],
37 ['ctest', /^ctest\b/],
38 ['make test', /^make (?:\S+ )*(?:test|check)\b/],
39 ['just test', /^just test\b/],
40]
41
42/** Splits at `&&`, `||`, `;`, `|` and newlines outside quotes. */
43function parts(command: string): string[] {
44 const out: string[] = []
45 let current = ''
46 let quote: string | null = null
47 for (let i = 0; i < command.length; i++) {
48 const ch = command[i]!
49 if (quote !== null) {
50 if (ch === quote) quote = null
51 current += ch
52 continue
53 }
54 if (ch === '"' || ch === "'") {
55 quote = ch
56 current += ch
57 continue
58 }
59 if (ch === ';' || ch === '\n' || ch === '|' || (ch === '&' && command[i + 1] === '&')) {
60 out.push(current)
61 current = ''
62 if (command[i + 1] === ch) i++
63 continue
64 }
65 current += ch
66 }
67 out.push(current)
68 return out.map(p => p.trim()).filter(p => p !== '')
69}
70
71/** Drops `VAR=x`, `cd dir` prefixes and wrappers such as `npx` or `uv run`. */
72function unwrap(part: string): string {
73 let words = part.split(/\s+/)
74 for (;;) {
75 const head = words[0]
76 if (head === undefined) break
77 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(head)) {
78 words = words.slice(1)
79 continue
80 }
81 const pair = `${head} ${words[1] ?? ''}`
82 if (TWO_WORD_WRAPPERS.has(pair)) {
83 words = words.slice(2)
84 continue
85 }
86 if (WRAPPERS.has(head)) {
87 words = words.slice(1)
88 // the wrapper's own flags and a timeout's duration
89 while (words[0] !== undefined && (words[0].startsWith('-') || /^\d+[smhd]?$/.test(words[0]))) words = words.slice(1)
90 continue
91 }
92 break
93 }
94 return words.join(' ')
95}
96
97/** The runner a Bash command invokes (`pytest`, `npm test`...), or undefined when it runs no tests. */
98export function testRunner(command: string): string | undefined {
99 for (const part of parts(command)) {
100 const bare = unwrap(part)
101 for (const [label, pattern] of RUNNERS) if (pattern.test(bare)) return label
102 }
103 return undefined
104}
105hooks/history.ts 123 lines1// Runs, the status line, transitions, sparklines and flakes. Pure: no `$`.
2
3import type { Parsed } from './parse'
4
5export type Run = {
6 /** When it finished, ms since the epoch. */
7 at: number
8 runner: string
9 command: string
10 /** False when no summary was found and only the exit status is known. */
11 counted: boolean
12 passed: number
13 failed: number
14 skipped: number
15 /** Exit status zero and no failures. */
16 ok: boolean
17 failing: string[]
18 /** Which edit state of the session the run saw; equal epochs mean no file edits in between. */
19 epoch: string
20}
21
22export const KEEP = 20
23
24export function toRun(args: { at: number; runner: string; command: string; parsed: Parsed | undefined; exitOk: boolean; epoch: string }): Run {
25 const { parsed } = args
26 return {
27 at: args.at,
28 runner: args.runner,
29 command: args.command.replace(/\s+/g, ' ').trim().slice(0, 120),
30 counted: parsed !== undefined,
31 passed: parsed?.passed ?? 0,
32 failed: parsed?.failed ?? 0,
33 skipped: parsed?.skipped ?? 0,
34 ok: args.exitOk && (parsed?.failed ?? 0) === 0,
35 failing: (parsed?.failing ?? []).slice(0, 5),
36 epoch: args.epoch,
37 }
38}
39
40export function ago(ms: number): string {
41 const s = Math.max(0, Math.round(ms / 1000))
42 if (s < 45) return 'just now'
43 const m = Math.round(s / 60)
44 if (m < 60) return `${m}m ago`
45 const h = Math.round(m / 60)
46 if (h < 24) return `${h}h ago`
47 return `${Math.round(h / 24)}d ago`
48}
49
50/** `๐งช โ
44/44 passing ยท 2m ago`, `๐งช โ 41/44 passing ยท just now`. */
51export function statusLine(run: Run, now: number): string {
52 const mark = run.ok ? 'โ
' : 'โ'
53 const when = ago(now - run.at)
54 if (!run.counted) return `๐งช ${mark} tests ${run.ok ? 'passed' : 'failed'} ยท ${when}`
55 const total = run.passed + run.failed
56 if (!run.ok && run.failed === 0) return `๐งช ${mark} ${run.passed}/${total} passing, exit failed ยท ${when}`
57 return `๐งช ${mark} ${run.passed}/${total} passing ยท ${when}`
58}
59
60/** The toast for a change of colour, or undefined when the colour held. */
61export function transition(previous: Run | undefined, run: Run): string | undefined {
62 if (previous === undefined || previous.ok === run.ok) return undefined
63 if (run.ok) return `tests are green again ๐${run.counted ? ` (${run.passed} passing)` : ''}`
64 const first = run.failing[0]
65 const howMany = run.counted && run.failed > 0 ? `${run.failed} failing` : 'failing'
66 return `tests went red: ${howMany}${first !== undefined ? ` ยท ${first}` : ''}`
67}
68
69const BARS = 'โโโโโ
โโโ'
70
71/** One bar per run, oldest first: the pass rate, or full/empty when only the exit is known. */
72export function sparkline(runs: readonly Run[]): string {
73 return runs
74 .map(run => {
75 const total = run.passed + run.failed
76 const rate = run.counted && total > 0 ? run.passed / total : run.ok ? 1 : 0
77 return BARS[Math.round(rate * (BARS.length - 1))]
78 })
79 .join('')
80}
81
82/**
83 * Tests that failed in one run and passed in the next run of the same
84 * command with no file edited in between: likely flaky. Best effort.
85 */
86export function flaky(runs: readonly Run[]): string[] {
87 const found: string[] = []
88 for (let i = 1; i < runs.length; i++) {
89 const before = runs[i - 1]!
90 const after = runs[i]!
91 if (before.command !== after.command || before.epoch !== after.epoch || before.ok) continue
92 const cleared = after.ok ? before.failing : before.failing.filter(name => after.counted && !after.failing.includes(name))
93 const flips = cleared.length > 0 ? cleared : after.ok ? [`the whole run (${before.command})`] : []
94 for (const name of flips) if (!found.includes(name)) found.push(name)
95 }
96 return found
97}
98
99export function append(runs: readonly Run[], run: Run): Run[] {
100 return [...runs, run].slice(-KEEP)
101}
102
103/** The /tests report: newest first, with the sparkline and any flakes. */
104export function report(runs: readonly Run[], now: number): string {
105 if (runs.length === 0) return 'No test runs seen in this project yet. Ask Claude to run your tests.'
106 const rows = [...runs].reverse().map(run => {
107 const mark = run.ok ? 'โ
' : 'โ'
108 const score = run.counted ? `${run.passed}/${run.passed + run.failed}` : run.ok ? 'pass' : 'fail'
109 const fails = run.failing.length > 0 ? ` โ ${run.failing.slice(0, 3).join(', ')}` : ''
110 return ` ${mark} ${ago(now - run.at).padEnd(9)} ${score.padEnd(9)} ${run.command}${fails}`
111 })
112 const lines = [`Last ${runs.length} run${runs.length === 1 ? '' : 's'} ยท pass rate ${sparkline(runs)} (oldest โ newest)`, ...rows]
113 const flakes = flaky(runs)
114 if (flakes.length > 0) lines.push('', `Possibly flaky (failed, then passed with no edits in between): ${flakes.join(', ')}`)
115 return lines.join('\n')
116}
117
118/** Accepts only well-formed runs from the store. */
119export function readRuns(value: unknown): Run[] {
120 if (!Array.isArray(value)) return []
121 return value.filter((r): r is Run => typeof r === 'object' && r !== null && typeof (r as Run).at === 'number' && typeof (r as Run).ok === 'boolean').slice(-KEEP)
122}
123hooks/parse.ts 248 lines1// Reads a test run's summary out of its output. Pure: no `$`.
2
3export type Parsed = {
4 passed: number
5 failed: number
6 skipped: number
7 /** Failing test names, best effort, at most a few. */
8 failing: string[]
9 /** Which summary format matched. */
10 format: string
11}
12
13const MAX_NAMES = 5
14
15const num = (text: string | undefined): number => (text === undefined ? 0 : Number(text))
16
17function count(line: string, word: RegExp): number {
18 const m = line.match(new RegExp(`(\\d+) ${word.source}`))
19 return num(m?.[1])
20}
21
22function names(text: string, pattern: RegExp, pick: (m: RegExpMatchArray) => string = m => m[1]!): string[] {
23 const out: string[] = []
24 for (const m of text.matchAll(new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g'))) {
25 const name = pick(m).trim().slice(0, 120)
26 if (name !== '' && !out.includes(name)) out.push(name)
27 if (out.length === MAX_NAMES) break
28 }
29 return out
30}
31
32type Parser = (text: string) => Parsed | undefined
33
34const jest: Parser = text => {
35 const line = text.match(/^Tests:\s+(.*\d+ total.*)$/m)?.[1]
36 if (line === undefined) return undefined
37 return {
38 format: 'jest',
39 passed: count(line, /passed/),
40 failed: count(line, /failed/),
41 skipped: count(line, /skipped/) + count(line, /todo/),
42 failing: names(text, /^\s*โ (?!Test suite failed to run)(.+โบ.+|[^\n]+)$/m),
43 }
44}
45
46const vitest: Parser = text => {
47 const line = text.match(/^\s*Tests\s{2,}(.+?)\s*\(\d+\)\s*$/m)?.[1]
48 if (line === undefined) return undefined
49 return {
50 format: 'vitest',
51 passed: count(line, /passed/),
52 failed: count(line, /failed/),
53 skipped: count(line, /skipped/) + count(line, /todo/),
54 // `ร suite > test 5ms`, or `FAIL path/to.test.ts > suite > test`: the path dropped
55 failing: names(text, /^\s*(?:FAIL|ร|โ)\s+(?:\S+\.[cm]?[jt]sx? > )?(.+? > .+?)(?:\s+\d+m?s)?$/m),
56 }
57}
58
59const pytest: Parser = text => {
60 const lines = [...text.matchAll(/^=+ (.*?(?:passed|failed|error|skipped|xfailed|xpassed|no tests ran).*?) in [\d.]+s(?: \([^)]*\))? =+$/gm)]
61 const line = lines.at(-1)?.[1]
62 if (line === undefined) return undefined
63 return {
64 format: 'pytest',
65 passed: count(line, /passed/) + count(line, /xpassed/),
66 failed: count(line, /failed/) + count(line, /errors?/),
67 skipped: count(line, /skipped/) + count(line, /xfailed/) + count(line, /deselected/),
68 failing: names(text, /^(?:FAILED|ERROR) (\S+)/m),
69 }
70}
71
72const unittest: Parser = text => {
73 const ran = text.match(/^Ran (\d+) tests? in [\d.]+s/m)
74 const verdict = text.match(/^(OK|FAILED)(?: \((.*)\))?\s*$/m)
75 if (ran === null || verdict === null) return undefined
76 const detail = verdict[2] ?? ''
77 const pick = (key: string) => num(detail.match(new RegExp(`${key}=(\\d+)`))?.[1])
78 const failed = pick('failures') + pick('errors')
79 const skipped = pick('skipped') + pick('expected failures')
80 return {
81 format: 'unittest',
82 passed: Math.max(0, num(ran[1]) - failed - skipped),
83 failed,
84 skipped,
85 failing: names(text, /^(?:FAIL|ERROR): (\S+) \((\S+)\)/m, m => `${m[2]}.${m[1]}`),
86 }
87}
88
89const cargo: Parser = text => {
90 const results = [...text.matchAll(/^test result: (?:ok|FAILED)\. (\d+) passed; (\d+) failed; (\d+) ignored/gm)]
91 if (results.length === 0) return undefined
92 return {
93 format: 'cargo',
94 passed: results.reduce((n, m) => n + num(m[1]), 0),
95 failed: results.reduce((n, m) => n + num(m[2]), 0),
96 skipped: results.reduce((n, m) => n + num(m[3]), 0),
97 failing: names(text, /^test (\S+) \.\.\. FAILED$/m),
98 }
99}
100
101const goTest: Parser = text => {
102 const verbosePass = text.match(/^--- PASS: /gm)?.length ?? 0
103 const fails = text.match(/^--- FAIL: /gm)?.length ?? 0
104 const failing = names(text, /^--- FAIL: (\S+)/m)
105 if (verbosePass > 0) {
106 return { format: 'go', passed: verbosePass, failed: fails, skipped: text.match(/^--- SKIP: /gm)?.length ?? 0, failing }
107 }
108 const okPackages = text.match(/^ok\s+\S+/gm)?.length ?? 0
109 const failPackages = text.match(/^FAIL\s+\S+\s+[\d.]+s$/gm)?.length ?? text.match(/^FAIL\s+\S+/gm)?.length ?? 0
110 if (okPackages + failPackages === 0) return undefined
111 // Without -v, go reports packages; failing tests still name themselves.
112 return { format: 'go (packages)', passed: okPackages, failed: Math.max(failPackages, fails > 0 ? 1 : 0), skipped: 0, failing }
113}
114
115const mocha: Parser = text => {
116 const passing = text.match(/^\s*(\d+) passing\b/m)
117 if (passing === null) return undefined
118 return {
119 format: 'mocha',
120 passed: num(passing[1]),
121 failed: num(text.match(/^\s*(\d+) failing\b/m)?.[1]),
122 skipped: num(text.match(/^\s*(\d+) pending\b/m)?.[1]),
123 failing: names(text, /^\s{2}\d+\) (.+?):?$/m),
124 }
125}
126
127const rspecLike: Parser = text => {
128 const m = text.match(/^(\d+) (examples?|tests?), (\d+) failures?(?:, (\d+) (?:pending|skipped|excluded))?/m)
129 if (m === null) return undefined
130 const total = num(m[1])
131 const failed = num(m[3])
132 const skipped = num(m[4])
133 return {
134 format: m[2]!.startsWith('example') ? 'rspec' : 'mix',
135 passed: Math.max(0, total - failed - skipped),
136 failed,
137 skipped,
138 failing: names(text, /^rspec \S+ # (.+)$/m),
139 }
140}
141
142// bun, `claude plugin test` and other bun-style reporters
143const bun: Parser = text => {
144 const pass = text.match(/^\s*(\d+) pass\s*$/m)
145 const fail = text.match(/^\s*(\d+) fail\s*$/m)
146 if (pass === null || fail === null) return undefined
147 return {
148 format: 'bun',
149 passed: num(pass[1]),
150 failed: num(fail[1]),
151 skipped: num(text.match(/^\s*(\d+) skip\s*$/m)?.[1]) + num(text.match(/^\s*(\d+) todo\s*$/m)?.[1]),
152 failing: names(text, /^\(fail\) (.+?)(?: \[[\d.]+m?s\])?$/m),
153 }
154}
155
156const nodeTest: Parser = text => {
157 const spec = text.match(/^โน pass (\d+)/m)
158 const tap = text.match(/^# pass (\d+)/m)
159 if (spec === null && tap === null) return undefined
160 const key = spec !== null ? 'โน' : '#'
161 const read = (word: string) => num(text.match(new RegExp(`^${key} ${word} (\\d+)`, 'm'))?.[1])
162 const failing = spec !== null
163 ? names(text, /^โ (?!failing tests:)(.+?)(?: \([\d.]+m?s\))?$/m)
164 : names(text, /^not ok \d+ - (.+)$/m)
165 return { format: 'node --test', passed: read('pass'), failed: read('fail'), skipped: read('skipped') + read('skip') + read('todo'), failing }
166}
167
168const phpunit: Parser = text => {
169 const ok = text.match(/^OK \((\d+) tests?, \d+ assertions?\)/m)
170 if (ok !== null) return { format: 'phpunit', passed: num(ok[1]), failed: 0, skipped: 0, failing: [] }
171 const line = text.match(/^Tests: (\d+), Assertions: \d+.*$/m)
172 if (line === null) return undefined
173 const pick = (key: string) => num(line[0].match(new RegExp(`${key}: (\\d+)`))?.[1])
174 const failed = pick('Failures') + pick('Errors')
175 const skipped = pick('Skipped') + pick('Incomplete')
176 return { format: 'phpunit', passed: Math.max(0, num(line[1]) - failed - skipped), failed, skipped, failing: names(text, /^\d+\) (\S+::\S+)/m) }
177}
178
179const dotnet: Parser = text => {
180 const runs = [...text.matchAll(/(?:Passed|Failed)!\s+-\s+Failed:\s+(\d+),\s+Passed:\s+(\d+),\s+Skipped:\s+(\d+)/g)]
181 if (runs.length === 0) return undefined
182 return {
183 format: 'dotnet',
184 passed: runs.reduce((n, m) => n + num(m[2]), 0),
185 failed: runs.reduce((n, m) => n + num(m[1]), 0),
186 skipped: runs.reduce((n, m) => n + num(m[3]), 0),
187 failing: names(text, /^\s*Failed (\S+) \[/m),
188 }
189}
190
191const deno: Parser = text => {
192 const m = text.match(/^(?:ok|FAILED) \| (\d+) passed(?: \([^)]*\))? \| (\d+) failed(?: \| (\d+) ignored)?/m)
193 if (m === null) return undefined
194 return { format: 'deno', passed: num(m[1]), failed: num(m[2]), skipped: num(m[3]), failing: names(text, /^(.+?) \.\.\. FAILED/m) }
195}
196
197const maven: Parser = text => {
198 const lines = [...text.matchAll(/Tests run: (\d+), Failures: (\d+), Errors: (\d+), Skipped: (\d+)(, Time elapsed)?/g)]
199 if (lines.length === 0) return undefined
200 const summary = lines.filter(m => m[5] === undefined).at(-1)
201 const picked = summary !== undefined ? [summary] : lines
202 const total = picked.reduce((n, m) => n + num(m[1]), 0)
203 const failed = picked.reduce((n, m) => n + num(m[2]) + num(m[3]), 0)
204 const skipped = picked.reduce((n, m) => n + num(m[4]), 0)
205 return { format: 'maven', passed: Math.max(0, total - failed - skipped), failed, skipped, failing: names(text, /^\[ERROR\]\s{2,}(\w[\w$.]*\.\w+)(?::\d+)?\b/m) }
206}
207
208const gradle: Parser = text => {
209 const m = text.match(/(\d+) tests? completed, (\d+) failed(?:, (\d+) skipped)?/)
210 if (m === null) return undefined
211 const failed = num(m[2])
212 const skipped = num(m[3])
213 return { format: 'gradle', passed: Math.max(0, num(m[1]) - failed - skipped), failed, skipped, failing: names(text, /^(\S+ > .+?) FAILED$/m) }
214}
215
216const ctest: Parser = text => {
217 const m = text.match(/\d+% tests passed, (\d+) tests? failed out of (\d+)/)
218 if (m === null) return undefined
219 const failed = num(m[1])
220 return { format: 'ctest', passed: num(m[2]) - failed, failed, skipped: 0, failing: names(text, /^\s*\d+ - (\S+) \((?:Failed|SEGFAULT|Timeout)\)/m) }
221}
222
223const swift: Parser = text => {
224 const all = [...text.matchAll(/Executed (\d+) tests?, with (\d+) failures?(?: \((\d+) unexpected\))?/g)]
225 const m = all.at(-1)
226 if (m === undefined) return undefined
227 const failed = num(m[2])
228 return { format: 'swift', passed: num(m[1]) - failed, failed, skipped: 0, failing: names(text, /error: -\[(\S+ \S+)\]/m) }
229}
230
231// Most specific first.
232const PARSERS: readonly Parser[] = [jest, vitest, pytest, cargo, nodeTest, bun, deno, dotnet, phpunit, maven, gradle, ctest, swift, unittest, mocha, rspecLike, goTest]
233
234export function stripAnsi(text: string): string {
235 return text.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/\r/g, '')
236}
237
238/** The run's counts, or undefined when no known summary is in the output. */
239export function parseOutput(output: string): Parsed | undefined {
240 const text = stripAnsi(output)
241 for (const parser of PARSERS) {
242 const parsed = parser(text)
243 if (parsed !== undefined && parsed.passed + parsed.failed + parsed.skipped > 0) return parsed
244 if (parsed !== undefined && parser === pytest) return parsed
245 }
246 return undefined
247}
248