SLOPSHOPPER

test-watch

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 / Запускает…

newpanebandguardcommandprompt
★ 1v1.0.0MITupdated 2026-10-26ArtyaEXE/claude-mods/test-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · test-watch
│ ┃ Tests ✕ › fix the failing auth test and add an audit log call │ ┃ r: Run │ ┃ No tests have run yet. ⏺ 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 │ │ › /tests │ ⎿ test-watch: Opened the tests pane. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Tests
r: Run No tests have run yet.
README

test-watch

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.

English · Русский

English

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;
  • ✓ tests passed in 14 s, which goes away with your next prompt;
  • ✗ tests failed (code 1, 14 s) with the line that says what failed, and Hand to Claude (sends the end of the output to Claude with a request to fix it), Output (a pane with the last 200 lines), Hide.

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.

What it touches

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.

Settings (/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.

Install

/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 с и Остановить, пока тесты идут;
  • ✓ тесты прошли за 14 с, исчезает со следующим промптом;
  • ✗ тесты упали (код 1, 14 с) со строкой, где видно, что упало, и кнопками Отдать Claude (отправляет Claude хвост вывода с просьбой починить), Вывод (панель с последними 200 строками), Скрыть.

Команда определяется по проекту: 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.

Source 2 files
hooks/register.tsx 405 lines
1import { 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}
405
types/index.d.ts 23 lines
1export 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