Runs the project's tests after a turn in which Claude changed files, shows pass / fail above the prompt and hands a failure to Claude in one click / Запускает…

Runs the project's tests after a turn in which Claude changed files, shows pass / fail above the prompt and hands a failure to Claude in one click.
Claude often finishes a change without running the tests, or runs only some of them. This mod runs them for it: at the end of every turn in which Claude changed files with Edit, Write or NotebookEdit, unless Claude itself ran the tests after its last change.
The first time in a project it asks above the prompt: run npm test after Claude's changes in this project? Yes / No. The answer is kept per project.
Above the prompt then:
npm test … 12 s with Stop while the tests run;The command is found from the project: npm / pnpm / yarn / bun test by the lock file (when package.json has a real test script), python -m pytest -q, cargo test, go test ./..., dotnet test; in a marketplace of Claude Code plugins, claude plugin test for each changed plugin only. The command setting overrides it.
/tests opens the output pane; /tests run runs the tests now; /tests on / /tests off switch the runs on or off for this project.
Runs the test command through cmd.exe in the session folder (with CI=1, FORCE_COLOR=0, NO_COLOR=1), only in a project where you said yes, and stops it past the time limit or on Stop (at its next output). Reads package.json, lock files and other project markers to find the command. Hand to Claude submits a prompt. Keeps the yes / no per project in the mod's store. Adds the /tests command.
/config)language: auto (default; follows LANG, else the Windows UI language or regional format), ru or en.command: empty (default) finds the command from the project; otherwise this command line, run through cmd /c.timeoutMinutes: a run longer than this is stopped, 10 by default./plugin install test-watch --marketplace ArtyaEXE/claude-mods
Part of claude-mods. MIT license.
Запускает тесты проекта после хода, в котором Claude менял файлы, показывает результат над полем ввода и отдаёт падение Claude в один клик.
Claude часто заканчивает правку, не запустив тесты, или запускает только часть. Мод делает это за него: в конце каждого хода, в котором Claude менял файлы через Edit, Write или NotebookEdit, если только сам Claude не прогнал тесты после последней правки.
В первый раз в проекте мод спрашивает над полем ввода: запускать npm test после правок Claude в этом проекте? Да / Нет. Ответ запоминается для проекта.
Дальше над полем ввода:
npm test … 12 с и Остановить, пока тесты идут;Команда определяется по проекту: npm / pnpm / yarn / bun test по lock-файлу (если в package.json есть настоящий скрипт test), python -m pytest -q, cargo test, go test ./..., dotnet test; в маркетплейсе плагинов Claude Code — claude plugin test только для изменённых плагинов. Настройка command её переопределяет.
/tests открывает панель с выводом; /tests run запускает тесты сейчас; /tests on / /tests off включают и выключают запуски в этом проекте.
Запускает команду тестов через cmd.exe в папке сессии (с CI=1, FORCE_COLOR=0, NO_COLOR=1), только в проекте, где вы ответили «Да», и останавливает её по лимиту времени или по Остановить (при её следующем выводе). Читает package.json, lock-файлы и другие признаки проекта, чтобы найти команду. Отдать Claude отправляет промпт. Хранит ответ «Да / Нет» для проекта в хранилище мода. Добавляет команду /tests.
/config)language: auto (по умолчанию; по LANG, иначе по языку интерфейса или региональному формату Windows), ru или en.command: пусто (по умолчанию) — команда определяется по проекту; иначе эта командная строка, запускается через cmd /c.timeoutMinutes: прогон дольше этого останавливается, по умолчанию 10./plugin install test-watch --marketplace ArtyaEXE/claude-mods
Часть claude-mods. Лицензия MIT.
hooks/register.tsx 405 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { TestRun } from '../types'
5
6type Lang = 'ru' | 'en'
7
8// `ru` / `en` as set; `auto`: the locale variables first, then Windows' UI language or regional format.
9async function resolveLang($: EngineInterface, setting: unknown): Promise<Lang> {
10 if (setting === 'ru' || setting === 'en') return setting
11 const env = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
12 if (env) return env.toLowerCase().startsWith('ru') ? 'ru' : 'en'
13 try {
14 const ran = await $.process.run(
15 ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', '(Get-UICulture).Name; (Get-Culture).Name'],
16 { timeoutMs: 10_000 },
17 )
18 if (ran.exitCode === 0 && /^ru\b/im.test(ran.stdout)) return 'ru'
19 } catch {
20 // Not Windows: English.
21 }
22
23 return 'en'
24}
25
26const PANE = 'test-watch'
27// Lines of output kept for the pane and for Claude.
28const TAIL_LINES = 200
29const FEED_LINES = 80
30
31const IDLE: TestRun = { state: 'idle', command: '', startedAt: 0, durationMs: 0, exitCode: null, summary: '', tail: '' }
32const run = atom({ plugin: 'test-watch', key: 'run' } as const, IDLE, { shape: 'run-1' })
33const now = atom({ plugin: 'test-watch', key: 'now' } as const, 0)
34
35type Command = { argv: string[]; label: string }
36
37const STRINGS = {
38 ru: {
39 seconds: (ms: number) => {
40 const s = Math.round(ms / 1000)
41
42 return s < 60 ? `${s} с` : `${Math.floor(s / 60)} мин ${s % 60} с`
43 },
44 ask: (c: string) => `test-watch: запускать \`${c}\` после правок Claude в этом проекте?`,
45 yes: 'Да',
46 no: 'Нет',
47 running: (c: string, t: string) => `⟳ ${c} … ${t}`,
48 passed: (c: string, t: string) => `✓ ${c}: тесты прошли за ${t}`,
49 failed: (c: string, code: number | null, t: string) => `✗ ${c}: тесты упали (код ${code ?? '?'}, ${t})`,
50 stopped: (c: string) => `■ ${c}: остановлено`,
51 stop: 'Остановить',
52 feed: 'Отдать Claude',
53 output: 'Вывод',
54 hide: 'Скрыть',
55 runNow: 'Запустить',
56 command: 'Тесты после правок Claude: /tests run | on | off',
57 paneTitle: 'Тесты',
58 opened: 'Открыл панель тестов.',
59 started: (c: string) => `Запустил ${c}.`,
60 on: (c: string) => `Буду запускать ${c} после ходов, в которых Claude менял файлы.`,
61 off: 'Больше не запускаю тесты в этом проекте. Вернуть: /tests on',
62 none: 'Не нашёл, как здесь запускать тесты. Задайте команду в /config → test-watch → command.',
63 noRun: 'Тесты ещё не запускались.',
64 feedPrompt: (c: string, code: number | null, tail: string) =>
65 `Тесты (\`${c}\`) упали после последних правок, код выхода ${code ?? '?'}. Хвост вывода:\n\n\`\`\`\n${tail}\n\`\`\`\n\nРазберись в причине и почини.`,
66 },
67 en: {
68 seconds: (ms: number) => {
69 const s = Math.round(ms / 1000)
70
71 return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${s % 60} s`
72 },
73 ask: (c: string) => `test-watch: run \`${c}\` after Claude's changes in this project?`,
74 yes: 'Yes',
75 no: 'No',
76 running: (c: string, t: string) => `⟳ ${c} … ${t}`,
77 passed: (c: string, t: string) => `✓ ${c}: tests passed in ${t}`,
78 failed: (c: string, code: number | null, t: string) => `✗ ${c}: tests failed (code ${code ?? '?'}, ${t})`,
79 stopped: (c: string) => `■ ${c}: stopped`,
80 stop: 'Stop',
81 feed: 'Hand to Claude',
82 output: 'Output',
83 hide: 'Hide',
84 runNow: 'Run',
85 command: "Tests after Claude's changes: /tests run | on | off",
86 paneTitle: 'Tests',
87 opened: 'Opened the tests pane.',
88 started: (c: string) => `Started ${c}.`,
89 on: (c: string) => `I will run ${c} after turns in which Claude changed files.`,
90 off: 'No more test runs in this project. Back on: /tests on',
91 none: 'Found no way to run tests here. Set the command in /config → test-watch → command.',
92 noRun: 'No tests have run yet.',
93 feedPrompt: (c: string, code: number | null, tail: string) =>
94 `The tests (\`${c}\`) failed after the last changes, exit code ${code ?? '?'}. The end of the output:\n\n\`\`\`\n${tail}\n\`\`\`\n\nFind the cause and fix it.`,
95 },
96}
97
98let lang: Lang = 'en'
99let root = ''
100let override = ''
101let timeoutMs = 10 * 60_000
102// Files changed in this turn (by Claude or a subagent), lower-cased with forward slashes.
103const edited = new Set<string>()
104// Claude ran tests itself after its last change in this turn: no need to run them again.
105let testedAfterEdit = false
106let stopAsked = false
107let running = false
108
109const slash = (p: string) => p.replace(/\\/g, '/')
110const key = (p: string) => slash(p).toLowerCase()
111const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/\r(?!\n)/g, '\n')
112const tailOf = (s: string, n: number) => s.split('\n').slice(-n).join('\n')
113
114// A test command run by Claude itself: any of the forms detect() finds, or a test runner called directly.
115const RUNS_TESTS =
116 /(?:^|[\s;&|(])(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b|(?:npx\s+|pnpm\s+(?:exec\s+)?)?(?:jest|vitest)\b|(?:python\s+-m\s+)?pytest\b|cargo\s+test\b|go\s+test\b|dotnet\s+test\b|claude\s+plugin\s+test\b)/
117
118const cmd = (line: string): Command => ({ argv: ['cmd.exe', '/d', '/s', '/c', line], label: line })
119
120async function readJson($: EngineInterface, path: string): Promise<Record<string, unknown> | undefined> {
121 try {
122 return JSON.parse(await $.fs.read(path)) as Record<string, unknown>
123 } catch {
124 return undefined
125 }
126}
127
128// How this project runs its tests, for the files changed in the turn (all of them for a run by hand: `files` absent).
129async function detect($: EngineInterface, files?: string[]): Promise<Command | undefined> {
130 if (override !== '') return cmd(override)
131 const at = (name: string) => $.fs.exists(`${root}/${name}`)
132
133 // A marketplace of Claude Code plugins: each changed plugin's own tests.
134 const market = await readJson($, `${root}/.claude-plugin/marketplace.json`)
135 if (market !== undefined) {
136 const sources = ((market.plugins ?? []) as { source?: unknown }[])
137 .map(p => (typeof p.source === 'string' ? p.source.replace(/^\.\//, '').replace(/\/$/, '') : ''))
138 .filter(s => s !== '')
139 const changed = files === undefined ? sources : sources.filter(s => files.some(f => f.startsWith(`${key(root)}/${s.toLowerCase()}/`)))
140 if (changed.length === 0) return undefined
141
142 return cmd(changed.map(s => `claude plugin test ${s}`).join(' && '))
143 }
144 if (await at('.claude-plugin/plugin.json')) return cmd('claude plugin test .')
145
146 const pkg = await readJson($, `${root}/package.json`)
147 const script = (pkg?.scripts as Record<string, unknown> | undefined)?.test
148 if (typeof script === 'string' && !/no test specified/.test(script)) {
149 const pm = (await at('pnpm-lock.yaml')) ? 'pnpm' : (await at('yarn.lock')) ? 'yarn' : (await at('bun.lockb')) || (await at('bun.lock')) ? 'bun run' : 'npm'
150
151 return cmd(`${pm} test`)
152 }
153 if (await at('Cargo.toml')) return cmd('cargo test')
154 if (await at('go.mod')) return cmd('go test ./...')
155 if ((await at('pytest.ini')) || (await at('pyproject.toml')) || (await at('setup.cfg')) || (await at('tox.ini'))) {
156 return cmd('python -m pytest -q')
157 }
158 try {
159 if ((await $.fs.list(root)).some(f => /\.(sln|csproj|fsproj)$/i.test(f.name))) return cmd('dotnet test')
160 } catch {
161 // An unreadable folder: nothing found.
162 }
163
164 return undefined
165}
166
167// The line that best says what failed: a summary or error line near the end, else the last line.
168function summaryOf(out: string): string {
169 const lines = out.split('\n').map(l => l.trim()).filter(l => l !== '')
170 const hit = [...lines].reverse().find(l => /\b\d+\s+(?:failed|failing|fail|errors?)\b|^FAIL\b|^FAILED\b|✗|AssertionError|Error:|panicked/i.test(l))
171 const line = hit ?? lines.at(-1) ?? ''
172
173 return line.length > 160 ? `${line.slice(0, 159)}…` : line
174}
175
176async function start($: EngineInterface, c: Command) {
177 if (running) return
178 running = true
179 stopAsked = false
180 const startedAt = Date.now()
181 await update($, now, () => startedAt)
182 await update($, run, () => ({ ...IDLE, state: 'running', command: c.label, startedAt }))
183
184 let out = ''
185 let state: TestRun['state'] = 'stopped'
186 let exitCode: number | null = null
187 try {
188 const stream = $.process.spawn({ argv: c.argv, cwd: root, env: { FORCE_COLOR: '0', NO_COLOR: '1', CI: '1' } })
189 let isCut = false
190 for await (const piece of stream) {
191 out = tailOf(out + stripAnsi(piece.text), TAIL_LINES * 2)
192 if (stopAsked || Date.now() - startedAt > timeoutMs) {
193 isCut = true
194 break
195 }
196 }
197 if (!isCut) {
198 exitCode = (await stream.result).code
199 state = exitCode === 0 ? 'passed' : 'failed'
200 }
201 } catch (error) {
202 out += `\n${String(error)}`
203 state = 'failed'
204 }
205 running = false
206 const tail = tailOf(out.replace(/\n+$/, ''), TAIL_LINES)
207 await update($, run, r => ({ ...r, state, exitCode, durationMs: Date.now() - startedAt, tail, summary: state === 'failed' ? summaryOf(tail) : '' }))
208}
209
210// The command asked about is read back from the question itself, which outlives a reload of the mod.
211async function consent($: EngineInterface, answer: 'yes' | 'no') {
212 await $.store.set(`consent:${key(root)}`, answer)
213 const asked = (await read($, run)).command
214 await update($, run, () => IDLE)
215 if (answer === 'yes' && asked !== '') await start($, cmd(asked))
216}
217
218// The end of a turn that changed files: run the tests, or ask first in a project not asked about yet.
219async function afterTurn($: EngineInterface, files: string[]) {
220 const c = await detect($, files)
221 if (c === undefined) return
222 const said = await $.store.get(`consent:${key(root)}`)
223 if (said === 'no') return
224 if (said !== 'yes') {
225 await update($, run, () => ({ ...IDLE, state: 'ask', command: c.label }))
226
227 return
228 }
229 await start($, c)
230}
231
232async function feed($: EngineInterface) {
233 const r = await read($, run)
234 await update($, run, () => IDLE)
235 await $.prompt.submit({ text: STRINGS[lang].feedPrompt(r.command, r.exitCode, tailOf(r.tail, FEED_LINES)) })
236}
237
238async function openPane($: EngineInterface) {
239 await $.ui.open({ id: PANE, title: STRINGS[lang].paneTitle, focus: true, closeOnEscape: true })
240}
241
242export const register: Register = (on, options) => {
243 const o = options as { language?: unknown; command?: unknown; timeoutMinutes?: unknown }
244 override = typeof o.command === 'string' ? o.command.trim() : ''
245 timeoutMs = (typeof o.timeoutMinutes === 'number' && o.timeoutMinutes > 0 ? o.timeoutMinutes : 10) * 60_000
246
247 on('session.start', async ($, e, next) => {
248 const started = await next(e)
249 lang = await resolveLang($, o.language)
250 root = slash(await $.session.cwd()).replace(/\/$/, '')
251 // A run the previous load of the mod was reading died with it: say it stopped, not that it still runs.
252 if ((await read($, run)).state === 'running') await update($, run, r => ({ ...r, state: 'stopped' as const }))
253 await $.command.register({ name: 'tests', description: STRINGS[lang].command, argumentHint: 'run | on | off' })
254 $.clock.every(1000, () => {
255 if (running) void update($, now, () => Date.now())
256 })
257
258 return started
259 })
260
261 on('command.run', { command: 'tests' }, async ($, e) => {
262 const L = STRINGS[lang]
263 const arg = e.args.trim().toLowerCase()
264 if (arg === 'off') {
265 await $.store.set(`consent:${key(root)}`, 'no')
266
267 return { text: L.off }
268 }
269 const c = await detect($)
270 if (arg === 'on' || arg === 'run') {
271 if (c === undefined) return { text: L.none }
272 if (arg === 'on') {
273 await $.store.set(`consent:${key(root)}`, 'yes')
274
275 return { text: L.on(c.label) }
276 }
277 void start($, c)
278
279 return { text: L.started(c.label) }
280 }
281 await openPane($)
282
283 return { text: L.opened }
284 })
285
286 // What changed in the turn, and whether Claude ran the tests itself after it.
287 on('tool.call', async ($, e, next) => {
288 const ran = await next(e)
289 if (ran.deny !== undefined) return ran
290 if (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit') {
291 const input = e as { file_path?: unknown; notebook_path?: unknown }
292 const path = String(input.file_path ?? input.notebook_path ?? '')
293 if (path !== '') {
294 edited.add(key(path))
295 testedAfterEdit = false
296 }
297 } else if ((e.tool === 'Bash' || e.tool === 'PowerShell') && RUNS_TESTS.test(String((e as { command?: unknown }).command ?? ''))) {
298 testedAfterEdit = true
299 }
300
301 return ran
302 })
303
304 on('turn.complete', async ($, e, next) => {
305 const done = await next(e)
306 if (e.agentId !== undefined) return done
307 const files = [...edited]
308 const changed = files.length > 0 && !testedAfterEdit && e.reason !== 'aborted'
309 edited.clear()
310 testedAfterEdit = false
311 if (changed) void afterTurn($, files)
312
313 return done
314 })
315
316 // A new prompt puts a passed or stopped run away; a failure stays until handed over or hidden.
317 on('prompt.submit', async ($, e, next) => {
318 const r = await read($, run)
319 if (r.state === 'passed' || r.state === 'stopped') await update($, run, () => IDLE)
320
321 return next(e)
322 })
323
324 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
325 const below = await next(e)
326 if (e.props.hasSurvey || e.props.view.agentId !== undefined) return below
327 const r = await read($, run)
328 if (r.state === 'idle') return below
329
330 const { Box, Button, Text } = $.ui.resolve(e)
331 const L = STRINGS[lang]
332 const t = (await read($, now)) || Date.now()
333 const hide = () => update($, run, () => IDLE)
334
335 return (
336 <Box flexDirection="column">
337 {r.state === 'ask' && (
338 <Box flexDirection="row" gap={1}>
339 <Text>{L.ask(r.command)}</Text>
340 <Button key="yes" variant="primary" label={L.yes} onPress={() => consent($, 'yes')} />
341 <Button key="no" label={L.no} onPress={() => consent($, 'no')} />
342 </Box>
343 )}
344 {r.state === 'running' && (
345 <Box flexDirection="row" gap={1}>
346 <Text color="cyan">{L.running(r.command, L.seconds(t - r.startedAt))}</Text>
347 <Button key="stop" label={L.stop} onPress={() => void (stopAsked = true)} />
348 </Box>
349 )}
350 {r.state === 'passed' && (
351 <Box flexDirection="row" gap={1}>
352 <Text color="green">{L.passed(r.command, L.seconds(r.durationMs))}</Text>
353 <Button key="hide" plain label={L.hide} onPress={hide} />
354 </Box>
355 )}
356 {r.state === 'failed' && (
357 <Box flexDirection="column">
358 <Text>
359 <Text color="red">{L.failed(r.command, r.exitCode, L.seconds(r.durationMs))}</Text>
360 {r.summary !== '' && <Text dimColor> {r.summary}</Text>}
361 </Text>
362 <Box flexDirection="row" gap={1}>
363 <Button key="feed" variant="primary" label={L.feed} onPress={() => feed($)} />
364 <Button key="output" label={L.output} onPress={() => openPane($)} />
365 <Button key="hide" label={L.hide} onPress={hide} />
366 </Box>
367 </Box>
368 )}
369 {r.state === 'stopped' && (
370 <Box flexDirection="row" gap={1}>
371 <Text color="yellow">{L.stopped(r.command)}</Text>
372 <Button key="hide" plain label={L.hide} onPress={hide} />
373 </Box>
374 )}
375 {below}
376 </Box>
377 )
378 })
379
380 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
381 const { Box, Button, Code, Text } = $.ui.resolve(e)
382 const L = STRINGS[lang]
383 const r = await read($, run)
384
385 return (
386 <Box flexDirection="column">
387 <Box flexDirection="row" gap={1}>
388 <Button
389 key="pane-run"
390 plain
391 hotkey="r"
392 label={L.runNow}
393 onPress={async () => {
394 const c = await detect($)
395 if (c !== undefined) await start($, c)
396 }}
397 />
398 {r.state === 'failed' && <Button key="pane-feed" plain hotkey="f" label={L.feed} onPress={() => feed($)} />}
399 </Box>
400 {r.tail === '' ? <Text dimColor>{L.noRun}</Text> : <Code source={r.tail} language="text" />}
401 </Box>
402 )
403 })
404}
405types/index.d.ts 23 lines1export type TestRun = {
2 // idle: nothing to show; ask: waiting for the person's yes / no for this project.
3 state: 'idle' | 'ask' | 'running' | 'passed' | 'failed' | 'stopped'
4 // What runs (or would run), as shown.
5 command: string
6 startedAt: number
7 durationMs: number
8 exitCode: number | null
9 // The line that best says what failed, '' when none.
10 summary: string
11 // The last lines of the output.
12 tail: string
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'test-watch': {
18 run: Shaped<TestRun>
19 now: number
20 }
21 }
22}
23