Skip to content
Testing
Hook

Hooks

What e2e-testing runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
e2e-testing
159 skills2 hooks
Install
$ npx -y skills add burhankhatri/e2e-testing --agent claude-code

Ships with e2e-testing. Installing the plugin gets these hooks.

Where it lives

  • hooks/tdd-remind.shGitHub
    Read the script
    #!/bin/bash
    # UserPromptSubmit hook — keeps the two TDD rules in context on every turn,
    # so the discipline doesn't depend on remembering to invoke a skill.
    # Stays quiet outside code projects so it isn't noise in a notes folder.
    set -u
    
    # Switched off via `bash toggle.sh off` — check this before anything else.
    [ -f "$HOME/.claude/hooks/.disabled" ] && exit 0
    
    looks_like_code=0
    git rev-parse --git-dir >/dev/null 2>&1 && looks_like_code=1
    for f in package.json pyproject.toml go.mod Cargo.toml Gemfile pom.xml build.gradle Package.swift; do
      [ -e "$f" ] && looks_like_code=1 && break
    done
    ls *.xcodeproj >/dev/null 2>&1 && looks_like_code=1
    
    [ "$looks_like_code" -eq 0 ] && exit 0
    
    read -r -d '' NOTE <<'EOF'
    TDD rules for any code change in this project:
    1. Write the failing test FIRST and show it fail before implementing. Commit the red test on its own.
    2. Never skip, weaken, or delete a test to reach green. A skipped test is a failing test — always report passed/failed/SKIPPED counts.
    3. If a test cannot be made real (needs auth, a test DB, credentials), STOP and ask the user for that one-time setup. Do not mock around it.
    EOF
    
    jq -n --arg c "$NOTE" \
      '{hookSpecificOutput:{hookEventName:"UserPromptSubmit",additionalContext:$c}}'
    
  • hooks/tdd-verify.shGitHub
    Read the script
    #!/bin/bash
    # Stop hook — before work can be called done, actually run the tests.
    # This is the machinery half of the TDD skills: a rule the model grades
    # itself on is a rule it can talk itself out of. This one it cannot.
    #
    # Deliberately quiet unless it has something real to say:
    #   - no code changed this turn        -> silent
    #   - project has no runnable suite    -> silent
    #   - tests pass                       -> silent
    #   - tests fail, or tests are skipped -> block, hand the output back
    set -u
    
    # Switched off via `bash toggle.sh off` — check this before anything else,
    # including reading stdin.
    [ -f "$HOME/.claude/hooks/.disabled" ] && exit 0
    
    INPUT=$(cat 2>/dev/null || echo '{}')
    
    # Never fight our own previous block — prevents a stop/fix/stop loop.
    [ "$(printf '%s' "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null)" = "true" ] && exit 0
    
    git rev-parse --git-dir >/dev/null 2>&1 || exit 0
    
    # Only bother when source actually changed this turn.
    CHANGED=$(git status --porcelain 2>/dev/null \
      | grep -Ev '(^.. )?(tests?|__tests__|e2e)/' \
      | grep -Eic '\.(ts|tsx|js|jsx|mjs|cjs|py|swift|go|rb|rs|java|kt)"?$' || true)
    [ "${CHANGED:-0}" -eq 0 ] && exit 0
    
    # Work out how to run this project's tests. Prefer an explicit script, but
    # fall back to the runner the project obviously uses: a suite that exists
    # without being wired to a script is exactly the case that used to slip past
    # this hook entirely, which made it silent on the projects most likely to
    # have been built in a hurry.
    RUN=""
    
    if [ -f package.json ]; then
      PM=npm
      [ -f pnpm-lock.yaml ] && PM=pnpm
      [ -f yarn.lock ] && PM=yarn
      [ -f bun.lockb ] && PM=bun
    
      SCRIPT=""
      for s in test:run test:ci test; do
        if jq -e --arg s "$s" '.scripts[$s] // empty' package.json >/dev/null 2>&1; then SCRIPT="$s"; break; fi
      done
    
      if [ -n "$SCRIPT" ]; then
        CMD=$(jq -r --arg s "$SCRIPT" '.scripts[$s]' package.json)
        case "$CMD" in
          # a watch-mode script never exits; leave it alone rather than hang
          *--watch*|*watch*) exit 0 ;;
          # bare `vitest` also watches forever — force a single run
          *vitest*) case "$CMD" in
                      *" run"*|*--run*) RUN="$PM run $SCRIPT" ;;
                      *)                RUN="$PM run $SCRIPT -- --run" ;;
                    esac ;;
          *) RUN="$PM run $SCRIPT" ;;
        esac
      fi
    fi
    
    # No script, but there may still be a suite sitting right there.
    # NB: `ls a b` exits non-zero when ANY operand is missing, even if others
    # matched — so check the expanded globs for a real file instead.
    any_exists() { for f in "$@"; do [ -e "$f" ] && return 0; done; return 1; }
    
    if [ -z "$RUN" ]; then
      if any_exists ./*.test.js ./*.test.mjs ./*.test.cjs \
                    tests/*.test.js tests/*.test.mjs test/*.test.js test/*.test.mjs; then
        command -v node >/dev/null 2>&1 && RUN="node --test"
      elif any_exists ./test_*.py tests/test_*.py test/test_*.py; then
        command -v pytest >/dev/null 2>&1 && RUN="pytest -q"
      fi
    fi
    
    [ -z "$RUN" ] && exit 0
    
    OUT=$($RUN 2>&1) ; CODE=$?
    # Runners emit ANSI colour and carriage returns when they think they're on a
    # TTY; strip them so the report stays readable and can't corrupt the JSON.
    TAIL=$(printf '%s' "$OUT" | tail -c 1500 \
      | LC_ALL=C sed $'s/\033\[[0-9;?]*[a-zA-Z]//g' \
      | LC_ALL=C tr -d '\r\000-\010\013\014\016-\037')
    
    # Runners disagree on word order: vitest/playwright/pytest say "18 skipped",
    # node --test says "skipped 1". Match both, take the largest, and treat 0 as
    # healthy — blocking a suite that proudly reports "0 skipped" would be absurd.
    SKIPNUM=$(printf '%s' "$OUT" \
      | grep -Eio '([0-9]+[[:space:]]+skipped|skipped[[:space:]:]+[0-9]+)' \
      | grep -Eo '[0-9]+' | sort -rn | head -1 || true)
    
    if [ $CODE -ne 0 ]; then
      # A nonzero exit doesn't always mean a test failed — it can mean the test
      # tooling never got off the ground (missing package, broken import). Same
      # exit code, completely different fix: telling the agent to "fix the code"
      # for a missing dependency sends it hunting for a bug that isn't there.
      if printf '%s' "$OUT" | grep -qiE \
        'ModuleNotFoundError|ImportError|ERROR collecting|error(s)? during collection|Cannot find module|MODULE_NOT_FOUND|command not found|No such file or directory'; then
        jq -n --arg r "The test suite could not even start — this looks like a missing dependency or a broken environment, not a bug in the code. Install whatever the error names below, or ask the user to, then try again.
    
    $TAIL" '{decision:"block",reason:$r}'
        exit 0
      fi
    
      jq -n --arg r "Tests are failing, so this work is not done. Fix the CODE, not the test — do not skip, weaken, or delete any test to get to green. If a test cannot be made real without setup you can't do (auth, test DB, credentials), stop and ask the user for it.
    
    $TAIL" '{decision:"block",reason:$r}'
      exit 0
    fi
    
    if [ -n "$SKIPNUM" ] && [ "$SKIPNUM" -gt 0 ]; then
      jq -n --arg r "The suite reports $SKIPNUM skipped. A skipped test is a failing test — it has never proven anything. Either make those tests run for real, or ask the user for the setup they need (test account, database, credentials). Do not report this suite as passing.
    
    $TAIL" '{decision:"block",reason:$r}'
      exit 0
    fi
    
    exit 0
    

Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.

Ships withe2e-testing

A set of 8 global skills for Claude Code that enforce disciplined, test-driven agentic development. Install once, use in any project.

Get the whole plugin