HookStackGitHub
Back to catalogue
ValidationStopStopWhen the agent finishes its task· non-blocking

Run tests at end of response

When the agent finishes its response, runs the test suite. If tests fail, sends the agent back to work to fix them before handing back control.

Use cases

  • Assisted TDD
  • Regression safety net
  • Quality before handoff

Providers & tags

Claude Code
#validation#tests#ci#quality

settings.json fragment

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/run-tests.mjs",
            "type": "command"
          }
        ]
      }
    ]
  }
}

Script · .claude/hooks/run-tests.mjs

#!/usr/bin/env node
// Exécute la suite de tests à la fin d'une session (Stop)
import { spawnSync } from 'child_process';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { fileURLToPath } from 'url';

// Détecte le runner de tests adapté au projet.
export function detect({ exists = existsSync, readFile = readFileSync, projectDir } = {}) {
  const pkg = join(projectDir, 'package.json');
  if (exists(pkg)) {
    try {
      const scripts = JSON.parse(readFile(pkg, 'utf8')).scripts ?? {};
      if (scripts.test) {
        const mgr = exists(join(projectDir, 'pnpm-lock.yaml')) ? 'pnpm'
          : exists(join(projectDir, 'bun.lockb')) || exists(join(projectDir, 'bun.lock')) ? 'bun'
          : exists(join(projectDir, 'yarn.lock')) ? 'yarn'
          : 'npm';
        // bun test is non-watch by default and doesn't accept --run
        if (mgr === 'bun') return ['bun', ['test']];
        return [mgr, ['test', '--', '--run']];
      }
    } catch {}
  }
  if (exists(join(projectDir, 'pytest.ini')) || exists(join(projectDir, 'pyproject.toml')))
    return ['python', ['-m', 'pytest', '--tb=short', '-q']];
  if (exists(join(projectDir, 'go.mod'))) return ['go', ['test', './...']];
  return null;
}

export function run({
  exists = existsSync,
  readFile = readFileSync,
  spawn = spawnSync,
  projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd(),
} = {}) {
  const runner = detect({ exists, readFile, projectDir });
  if (!runner) return null;

  const [cmd, args] = runner;
  const result = spawn(cmd, args, {
    cwd: projectDir,
    encoding: 'utf8',
    timeout: 120_000,
    stdio: ['ignore', 'pipe', 'pipe'],
  });

  const out = (result.stdout ?? '') + (result.stderr ?? '');
  let message = `[run-tests] Exécution : ${cmd} ${args.join(' ')}\n`;
  if (result.status !== 0) {
    message += `[run-tests] ÉCHEC (exit ${result.status})\n${out.slice(-2000)}\n`;
  } else {
    const last = out.split('\n').filter(Boolean).slice(-5).join('\n');
    message += `[run-tests] ✓ Tests passés\n${last}\n`;
  }
  return { runner, status: result.status, message };
}

/* v8 ignore next 6 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const result = run();
  if (result) {
    process.stderr.write(result.message);
    if (result.status !== 0) process.exit(2);
  }
}