Runs your test suite after every turn in which Claude changed source, and keeps the result above the prompt: green with the pass count, red with the first…

Runs your tests after every turn in which Claude changed source, and keeps the result above the prompt.
✗ tests 3 pass, 1 fail revokes on logout bun run test · 2.1s d: Details t: Re-run h: Hide
✓ tests 12 pass bun run test · 1.4s d: Details t: Re-run h: Hide
| Hook | What it does |
|---|---|
tool.call on Write and Edit | Marks the session dirty when a file with a source extension changes. |
turn.complete | If dirty, runs the test command once, parses the counts, stores the run, and adds a line under the answer: test-pulse: ✗ 3 pass, 1 fail · first failure: revokes on logout (bun run test, 2.1s). Subagent turns and interrupted turns are skipped. |
ui.render on AbovePrompt | The last result, green or red, with the first failing test's name. |
ui.render on Pane | Details: the last 30 lines of output, with Re-run. |
/tests | Runs the suite now, even while Claude is working. |
A toast fires when the suite goes from green to red and when it recovers.
| Found in the working directory | Command |
|---|---|
package.json with a test script, and bun.lock | bun run test |
… and pnpm-lock.yaml / yarn.lock / neither | pnpm test / yarn test / npm test --silent |
bun.lock without a test script | bun test |
pytest.ini, conftest.py, pyproject.toml or tests/ | pytest -q, or uv run pytest -q with a uv.lock |
Cargo.toml | cargo test --quiet |
go.mod | go test ./... |
mix.exs, Gemfile | mix test, bundle exec rspec |
Set command to override detection. Counts are parsed for bun, jest/vitest, pytest, cargo, go, rspec and ExUnit; for anything else the band shows pass or fail from the exit code.
| Option | Default | What it does |
|---|---|---|
command | `` (detect) | The command to run, split on spaces. |
timeoutSeconds | 120 | How long to wait before giving up on a run. |
extensions | ts,tsx,js,jsx,mjs,py,rs,go,rb,java,kt,swift,c,cc,cpp,h,hpp,cs,ex,exs | Which file edits count as source changes. |
The suite runs inside the turn.complete hook through $.process.run, so a slow suite delays the end-of-turn line but not Claude's answer, and the time it takes does not count against the hook's budget.
claude plugin marketplace add ryx2/slopshopper
claude plugin install test-pulse@slopshopper
Tested with Claude Code 2.1.289. claude plugin validate and claude plugin test pass.
hooks/register.tsx 241 lines1// test-pulse: runs the tests after a turn that changed source, and keeps the
2// result above the prompt.
3//
4// tool.call (Write, Edit): marks the session dirty when a source file changes.
5// turn.complete: if dirty, runs the test command (detected, or configured),
6// parses the counts, stores the run, toasts a regression or a recovery, and
7// adds a line under the answer.
8// ui.render (AbovePrompt): the last result; Details opens the pane.
9// ui.render (Pane): the output tail, with Re-run.
10// /tests runs the suite now.
11
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, Register } from 'claude-code'
14
15import type { TestRun } from '../types'
16
17const PANE = 'test-pulse'
18const TAIL = 30
19
20const last = atom({ plugin: 'test-pulse', key: 'last' } as const, null)
21const isRunning = atom({ plugin: 'test-pulse', key: 'isRunning' } as const, false)
22const isHidden = atom({ plugin: 'test-pulse', key: 'isHidden' } as const, false)
23const dirty = atom({ plugin: 'test-pulse', key: 'dirty' } as const, false)
24
25export const register: Register = (on, options) => {
26 const configured = String(options.command ?? '').trim()
27 const timeoutMs = Math.min(600_000, Math.max(5_000, (typeof options.timeoutSeconds === 'number' ? options.timeoutSeconds : 120) * 1000))
28 const extensions = new Set(
29 String(options.extensions ?? 'ts,tsx,js,jsx,mjs,py,rs,go')
30 .split(',')
31 .map(s => s.trim().replace(/^\./, ''))
32 .filter(Boolean),
33 )
34
35 on('session.start', async ($, e, next) => {
36 try {
37 await $.command.register({ name: 'tests', description: 'Run the test suite now and show the result above the prompt', immediate: true })
38 } catch {
39 // the name is taken; the band still runs after turns
40 }
41 return next(e)
42 })
43
44 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
45 const ran = await next(e)
46 if (ran.deny !== undefined || ran.isError === true) return ran
47 const ext = String(e.file_path ?? '').split('.').pop() ?? ''
48 if (extensions.has(ext)) await update($, dirty, () => true)
49 return ran
50 })
51
52 on('turn.complete', async ($, e, next) => {
53 const result = await next(e)
54 if (e.agentId !== undefined || e.isAborted || !(await read($, dirty))) return result
55 const run = await runTests($, configured, timeoutMs)
56 if (run === null) return result
57 return { ...result, text: lineFor(run) }
58 })
59
60 on('command.run', { command: 'tests' }, async $ => {
61 const run = await runTests($, configured, timeoutMs)
62 if (run === null) return { text: 'test-pulse: no test command found. Set one in /config under test-pulse.' }
63 await $.ui.open({ id: PANE, title: 'Tests', focus: true, closeOnEscape: true })
64 return { text: lineFor(run) }
65 })
66
67 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
68 const run = await read($, last)
69 const running = await read($, isRunning)
70 if (e.props.hasSurvey || (await read($, isHidden)) || (run === null && !running)) return next(e)
71 const { Box, Text, Button } = $.ui.resolve(e)
72 if (running) {
73 return (
74 <Box flexDirection="row" columnGap={2} paddingX={1}>
75 <Text color="yellow">◌ tests running</Text>
76 <Text dimColor>{run ? `last: ${run.ok ? 'pass' : 'fail'}` : ''}</Text>
77 </Box>
78 )
79 }
80 const r = run!
81 const counts = r.passed !== null || r.failed !== null ? `${r.passed ?? 0} pass${r.failed ? `, ${r.failed} fail` : ''}` : r.ok ? 'passed' : 'failed'
82 return (
83 <Box flexDirection="row" columnGap={2} paddingX={1}>
84 <Text color={r.ok ? 'green' : 'red'} bold>
85 {r.ok ? '✓' : '✗'} tests {counts}
86 </Text>
87 {!r.ok && r.firstFailure !== null && (
88 <Text color="red" wrap="truncate-end">
89 {r.firstFailure.slice(0, Math.max(10, e.props.bodyColumns - 50))}
90 </Text>
91 )}
92 {r.timedOut && <Text color="yellow">timed out</Text>}
93 <Text dimColor>
94 {r.command} · {(r.durationMs / 1000).toFixed(1)}s
95 </Text>
96 <Button key="details" label="Details" hotkey="d" plain onPress={() => $.ui.open({ id: PANE, title: 'Tests', focus: true, closeOnEscape: true })} />
97 <Button key="rerun" label="Re-run" hotkey="t" plain onPress={() => runTests($, configured, timeoutMs)} />
98 <Button key="hide" label="Hide" hotkey="h" plain dimColor onPress={() => update($, isHidden, () => true)} />
99 </Box>
100 )
101 })
102
103 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
104 const { Box, Text, Button } = $.ui.resolve(e)
105 const r = await read($, last)
106 const running = await read($, isRunning)
107 return (
108 <Box flexDirection="column">
109 <Box flexDirection="row" columnGap={2}>
110 <Text bold color={running ? 'yellow' : r === null ? undefined : r.ok ? 'green' : 'red'}>
111 {running ? '◌ running' : r === null ? 'no run yet' : r.ok ? '✓ passing' : '✗ failing'}
112 </Text>
113 {r !== null && (
114 <Text dimColor>
115 {r.command} · {(r.durationMs / 1000).toFixed(1)}s{r.passed !== null ? ` · ${r.passed} pass` : ''}{r.failed ? ` · ${r.failed} fail` : ''}
116 </Text>
117 )}
118 </Box>
119 {r !== null && (
120 <Box flexDirection="column" marginTop={1}>
121 {r.tail.map(line => (
122 <Text color={/fail|error|✗|FAILED|panicked/i.test(line) ? 'red' : /pass|✓|ok\b/i.test(line) ? 'green' : undefined} dimColor={!/fail|error|✗|pass|✓|ok\b/i.test(line)} wrap="truncate-end">
123 {line.slice(0, Math.max(10, e.props.bodyColumns - 1))}
124 </Text>
125 ))}
126 </Box>
127 )}
128 <Box flexDirection="row" columnGap={2} marginTop={1}>
129 <Button key="rerun" label="Re-run" hotkey="t" variant="primary" onPress={() => runTests($, configured, timeoutMs)} />
130 <Button key="close" label="Close" hotkey="q" onPress={() => $.ui.close({ id: PANE })} />
131 </Box>
132 </Box>
133 )
134 })
135}
136
137/** Runs the suite once; null when no command applies. */
138async function runTests($: EngineInterface, configured: string, timeoutMs: number): Promise<TestRun | null> {
139 if (await read($, isRunning)) return read($, last)
140 const argv = configured ? configured.split(/\s+/) : await detect($)
141 if (argv === null || argv.length === 0) return null
142 await update($, isRunning, () => true)
143 await update($, dirty, () => false)
144 const started = await $.clock.now()
145 let run: TestRun
146 try {
147 const out = await $.process.run(argv, { timeoutMs })
148 const text = `${out.stdout}\n${out.stderr}`
149 const parsed = parse(text)
150 run = { command: argv.join(' '), ok: out.exitCode === 0, passed: parsed.passed, failed: parsed.failed, firstFailure: parsed.firstFailure, durationMs: (await $.clock.now()) - started, tail: tailOf(text), at: started, timedOut: false }
151 } catch (err) {
152 const message = String((err as Error)?.message ?? err)
153 run = { command: argv.join(' '), ok: false, passed: null, failed: null, firstFailure: null, durationMs: (await $.clock.now()) - started, tail: [message.slice(0, 200)], at: started, timedOut: /time|kill/i.test(message) }
154 }
155 const previous = await read($, last)
156 await update($, last, () => run)
157 await update($, isRunning, () => false)
158 if (previous !== null && previous.ok && !run.ok) $.ui.toast(`Tests went red: ${run.firstFailure ?? `${run.failed ?? ''} failing`}`)
159 else if (previous !== null && !previous.ok && run.ok) $.ui.toast(`Tests are green again (${run.passed ?? 'all'} pass)`)
160 return run
161}
162
163/** The project's test command, from what is in the working directory. */
164async function detect($: EngineInterface): Promise<string[] | null> {
165 try {
166 if (await $.fs.exists('package.json')) {
167 const pkg = JSON.parse(await $.fs.read('package.json')) as { scripts?: Record<string, string> }
168 if (pkg.scripts?.test && !/no test specified/.test(pkg.scripts.test)) {
169 if (await $.fs.exists('bun.lock')) return ['bun', 'run', 'test']
170 if (await $.fs.exists('bun.lockb')) return ['bun', 'run', 'test']
171 if (await $.fs.exists('pnpm-lock.yaml')) return ['pnpm', 'test']
172 if (await $.fs.exists('yarn.lock')) return ['yarn', 'test']
173 return ['npm', 'test', '--silent']
174 }
175 if (await $.fs.exists('bun.lock')) return ['bun', 'test']
176 }
177 if ((await $.fs.exists('pytest.ini')) || (await $.fs.exists('conftest.py')) || (await $.fs.exists('pyproject.toml')) || (await $.fs.exists('tests'))) {
178 if (await $.fs.exists('uv.lock')) return ['uv', 'run', 'pytest', '-q']
179 return ['pytest', '-q']
180 }
181 if (await $.fs.exists('Cargo.toml')) return ['cargo', 'test', '--quiet']
182 if (await $.fs.exists('go.mod')) return ['go', 'test', './...']
183 if (await $.fs.exists('mix.exs')) return ['mix', 'test']
184 if (await $.fs.exists('Gemfile')) return ['bundle', 'exec', 'rspec']
185 } catch {
186 // unreadable project files: no command
187 }
188 return null
189}
190
191export function parse(text: string): { passed: number | null; failed: number | null; firstFailure: string | null } {
192 const num = (re: RegExp) => {
193 const m = re.exec(text)
194 return m ? Number(m[1]) : null
195 }
196 let passed: number | null = null
197 let failed: number | null = null
198 // bun test
199 if (/^\s*\d+ pass\b/m.test(text)) {
200 passed = num(/^\s*(\d+) pass\b/m)
201 failed = num(/^\s*(\d+) fail\b/m) ?? 0
202 } else if (/Tests:\s/.test(text)) {
203 // jest / vitest
204 passed = num(/Tests:[^\n]*?(\d+) passed/) ?? 0
205 failed = num(/Tests:[^\n]*?(\d+) failed/) ?? 0
206 } else if (/\d+ passed|\d+ failed/.test(text) && /pytest|passed in|failed in|=+ /.test(text)) {
207 passed = num(/(\d+) passed/) ?? 0
208 failed = num(/(\d+) failed/) ?? 0
209 } else if (/test result:/.test(text)) {
210 passed = num(/test result:[^\n]*?(\d+) passed/) ?? 0
211 failed = num(/test result:[^\n]*?(\d+) failed/) ?? 0
212 } else if (/^(ok|FAIL)\s+\S+/m.test(text)) {
213 passed = (text.match(/^ok\s+\S+/gm) ?? []).length
214 failed = (text.match(/^FAIL\s+\S+/gm) ?? []).length
215 } else if (/\d+ examples?, \d+ failures?/.test(text)) {
216 const ex = num(/(\d+) examples?/) ?? 0
217 failed = num(/(\d+) failures?/) ?? 0
218 passed = ex - failed
219 } else if (/\d+ tests?, \d+ failures?/.test(text)) {
220 const ex = num(/(\d+) tests?,/) ?? 0
221 failed = num(/(\d+) failures?/) ?? 0
222 passed = ex - failed
223 }
224 const firstFailure =
225 /^(?:FAILED|ERROR)\s+(\S+)/m.exec(text)?.[1] ??
226 /^\s*(?:✗|✕|×|FAIL:?|not ok \d+ -?|---- )\s*(.+?)\s*(?:\[\d+(?:\.\d+)?m?s\])?\s*$/m.exec(text)?.[1]?.replace(/\s+stdout\s*----$/, '') ??
227 /^\s*●\s+(.+?)\s*$/m.exec(text)?.[1] ??
228 null
229 return { passed, failed, firstFailure: firstFailure ? firstFailure.slice(0, 120) : null }
230}
231
232function tailOf(text: string): string[] {
233 const lines = text.replace(/\u001b\[[0-9;]*m/g, '').split('\n').map(l => l.trimEnd()).filter((l, i, arr) => l !== '' || arr[i - 1] !== '')
234 return lines.slice(-TAIL)
235}
236
237function lineFor(run: TestRun): string {
238 const counts = run.passed !== null || run.failed !== null ? `${run.passed ?? 0} pass${run.failed ? `, ${run.failed} fail` : ''}` : run.ok ? 'passed' : 'failed'
239 return `test-pulse: ${run.ok ? '✓' : '✗'} ${counts}${!run.ok && run.firstFailure ? ` · first failure: ${run.firstFailure}` : ''} (${run.command}, ${(run.durationMs / 1000).toFixed(1)}s)${run.ok ? '' : ' · /tests to re-run'}`
240}
241tests/register.test.ts 89 lines1import { describe, expect, mock, test } from 'claude-code/testing'
2import { parse } from '../hooks/register'
3
4const BAND = {
5 plugin: 'test-pulse',
6 component: 'AbovePrompt',
7 requestId: 'band',
8 viewport: { columns: 140, rows: 40 },
9 props: { hasSurvey: false, isWorking: false, maxRows: 8, bodyColumns: 120, scroll: { offset: 0, bodyRows: 8 }, view: {} },
10} as const
11
12const BUN_FAIL = 'src/auth.test.ts:\n✓ refreshes expired token\n✗ revokes on logout\n error: expected 401, got 200\n\n 3 pass\n 1 fail\n'
13const BUN_PASS = 'src/auth.test.ts:\n✓ refreshes expired token\n\n 4 pass\n 0 fail\n'
14
15function project(on: Parameters<Parameters<typeof test>[1]>[1], output: { exitCode: number; stdout: string }) {
16 on('fs.exists', ($, e) => ({ value: /package\.json$|bun\.lock$/.test(e.path) }))
17 on('fs.read', () => ({ value: JSON.stringify({ scripts: { test: 'bun test' } }) }))
18 on('process.run', ($, e) => (e.argv.join(' ') === 'bun run test' ? { value: { ...output, stderr: '' } } : { deny: `unexpected ${e.argv.join(' ')}` }))
19}
20
21describe('register', () => {
22 test('runs the suite after a turn that changed source, and reports under the answer', async ($, on) => {
23 mock.clock(on)
24 on('tool.call', () => ({ result: 'ok' }))
25 on('turn.complete', () => ({ text: '' }))
26 on('ui.render', () => ({ type: 'Text', props: {}, children: ['engine'] }))
27 project(on, { exitCode: 1, stdout: BUN_FAIL })
28
29 await $.tool.call({ tool: 'Edit', file_path: '/work/app/src/auth.ts', old_string: 'a', new_string: 'b' })
30 const done = await $.turn.complete({ turnId: 't1', answer: 'done', durationMs: 10, isAborted: false, usage: null })
31 expect(done.text).toContain('✗ 3 pass, 1 fail')
32 expect(done.text).toContain('revokes on logout')
33
34 for (const surface of ['terminal', 'desktop'] as const) {
35 const ui = await $.ui.mount({ ...BAND, surface })
36 expect(await ui.find({ type: 'Text', text: /✗ tests 3 pass, 1 fail/ })).toBeDefined()
37 await ui.unmount()
38 }
39 })
40
41 test('a turn that changed no source runs nothing', async ($, on) => {
42 mock.clock(on)
43 let runs = 0
44 on('tool.call', () => ({ result: 'ok' }))
45 on('turn.complete', () => ({ text: '' }))
46 on('process.run', () => {
47 runs++
48 return { value: { exitCode: 0, stdout: '', stderr: '' } }
49 })
50 on('fs.exists', () => ({ value: true }))
51 on('fs.read', () => ({ value: '{}' }))
52 await $.tool.call({ tool: 'Write', file_path: '/work/app/README.md', content: '# hi' })
53 const done = await $.turn.complete({ turnId: 't1', answer: 'done', durationMs: 10, isAborted: false, usage: null })
54 expect(done.text).toBe('')
55 expect(runs).toBe(0)
56 })
57
58 test('toasts when the suite goes red and when it recovers', async ($, on) => {
59 mock.clock(on)
60 const toasts: string[] = []
61 on('ui.toast', ($, e) => {
62 toasts.push(e.text)
63 return { value: undefined }
64 })
65 on('ui.open', () => ({ value: { isPlaced: true } }))
66 let output = { exitCode: 0, stdout: BUN_PASS }
67 on('fs.exists', ($, e) => ({ value: /package\.json$|bun\.lock$/.test(e.path) }))
68 on('fs.read', () => ({ value: JSON.stringify({ scripts: { test: 'bun test' } }) }))
69 on('process.run', () => ({ value: { ...output, stderr: '' } }))
70
71 await $.command.run({ command: 'tests', args: '' })
72 expect(toasts).toEqual([])
73 output = { exitCode: 1, stdout: BUN_FAIL }
74 await $.command.run({ command: 'tests', args: '' })
75 expect(toasts[0]).toContain('went red')
76 output = { exitCode: 0, stdout: BUN_PASS }
77 await $.command.run({ command: 'tests', args: '' })
78 expect(toasts[1]).toContain('green again')
79 })
80
81 test('parses the common runners', () => {
82 expect(parse(BUN_FAIL)).toEqual({ passed: 3, failed: 1, firstFailure: 'revokes on logout' })
83 expect(parse('Tests: 1 failed, 11 passed, 12 total\n● auth › revokes on logout\n')).toMatchObject({ passed: 11, failed: 1 })
84 expect(parse('FAILED tests/test_auth.py::test_logout - assert 200 == 401\n==== 1 failed, 7 passed in 0.42s ====')).toEqual({ passed: 7, failed: 1, firstFailure: 'tests/test_auth.py::test_logout' })
85 expect(parse('test result: ok. 12 passed; 0 failed; 0 ignored')).toMatchObject({ passed: 12, failed: 0 })
86 expect(parse('ok \tapp/auth\t0.1s\nFAIL\tapp/session\t0.2s\n')).toMatchObject({ passed: 1, failed: 1 })
87 })
88})
89types/index.d.ts 27 lines1export type TestRun = {
2 command: string
3 ok: boolean
4 /** Counts parsed from the output; null when the runner's format is unknown. */
5 passed: number | null
6 failed: number | null
7 /** The first failing test's name, when the output names one. */
8 firstFailure: string | null
9 durationMs: number
10 /** The last lines of combined output. */
11 tail: string[]
12 at: number
13 /** The run was cut short by the timeout. */
14 timedOut: boolean
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'test-pulse': {
20 last: TestRun | null
21 isRunning: boolean
22 isHidden: boolean
23 dirty: boolean
24 }
25 }
26}
27README.md 55 lines1# test-pulse
2
3Runs your tests after every turn in which Claude changed source, and keeps the result above the prompt.
4
5```text
6 ✗ tests 3 pass, 1 fail revokes on logout bun run test · 2.1s d: Details t: Re-run h: Hide
7```
8
9```text
10 ✓ tests 12 pass bun run test · 1.4s d: Details t: Re-run h: Hide
11```
12
13| Hook | What it does |
14| --- | --- |
15| `tool.call` on `Write` and `Edit` | Marks the session dirty when a file with a source extension changes. |
16| `turn.complete` | If dirty, runs the test command once, parses the counts, stores the run, and adds a line under the answer: `test-pulse: ✗ 3 pass, 1 fail · first failure: revokes on logout (bun run test, 2.1s)`. Subagent turns and interrupted turns are skipped. |
17| `ui.render` on `AbovePrompt` | The last result, green or red, with the first failing test's name. |
18| `ui.render` on `Pane` | Details: the last 30 lines of output, with Re-run. |
19| `/tests` | Runs the suite now, even while Claude is working. |
20
21A toast fires when the suite goes from green to red and when it recovers.
22
23## Which command runs
24
25| Found in the working directory | Command |
26| --- | --- |
27| `package.json` with a `test` script, and `bun.lock` | `bun run test` |
28| … and `pnpm-lock.yaml` / `yarn.lock` / neither | `pnpm test` / `yarn test` / `npm test --silent` |
29| `bun.lock` without a test script | `bun test` |
30| `pytest.ini`, `conftest.py`, `pyproject.toml` or `tests/` | `pytest -q`, or `uv run pytest -q` with a `uv.lock` |
31| `Cargo.toml` | `cargo test --quiet` |
32| `go.mod` | `go test ./...` |
33| `mix.exs`, `Gemfile` | `mix test`, `bundle exec rspec` |
34
35Set `command` to override detection. Counts are parsed for bun, jest/vitest, pytest, cargo, go, rspec and ExUnit; for anything else the band shows pass or fail from the exit code.
36
37## Options
38
39| Option | Default | What it does |
40| --- | --- | --- |
41| `command` | `` (detect) | The command to run, split on spaces. |
42| `timeoutSeconds` | `120` | How long to wait before giving up on a run. |
43| `extensions` | `ts,tsx,js,jsx,mjs,py,rs,go,rb,java,kt,swift,c,cc,cpp,h,hpp,cs,ex,exs` | Which file edits count as source changes. |
44
45The suite runs inside the `turn.complete` hook through `$.process.run`, so a slow suite delays the end-of-turn line but not Claude's answer, and the time it takes does not count against the hook's budget.
46
47## Install
48
49```bash
50claude plugin marketplace add ryx2/slopshopper
51claude plugin install test-pulse@slopshopper
52```
53
54Tested with Claude Code 2.1.289. `claude plugin validate` and `claude plugin test` pass.
55