#!/usr/bin/env bash # Exercises the hook's state machine and its behaviour under concurrency. # # Run: tests/test-hook.sh set -u HOOK="$(cd "$(dirname "$0")/.." && pwd)/hooks/claude-status-hook.py" export XDG_STATE_HOME="$(mktemp -d)" DIR="$XDG_STATE_HOME/claude-code-status" SID="test-session" FILE="$DIR/$SID.json" failures=0 check() { # name expected actual if [ "$2" = "$3" ]; then echo "ok $1" else echo "FAIL $1 (expected '$2', got '$3')" failures=$((failures + 1)) fi } emit() { # json echo "$1" | "$HOOK" } field() { # key python3 -c "import json,sys;print(json.load(open('$FILE')).get('$1',''))" 2>/dev/null } ev() { # event [extra json] echo "{\"session_id\":\"$SID\",\"hook_event_name\":\"$1\",\"cwd\":\"/tmp/proj\"${2:+,$2}}" } # --- state machine --------------------------------------------------------- emit "$(ev SessionStart '"source":"startup"')" check "SessionStart -> idle" "idle" "$(field state)" emit "$(ev UserPromptSubmit '"prompt":"hi"')" check "UserPromptSubmit -> busy" "busy" "$(field state)" before=$(field event_ts) emit "$(ev PostToolUse '"tool_name":"Bash"')" check "PostToolUse while busy does not rewrite" "$before" "$(field event_ts)" emit "$(ev Notification '"notification_type":"permission_prompt","message":"run rm -rf"')" check "permission_prompt -> blocked" "blocked" "$(field state)" check "permission message kept" "run rm -rf" "$(field message)" emit "$(ev PostToolUse '"tool_name":"Bash"')" check "PostToolUse clears blocked" "busy" "$(field state)" emit "$(ev Notification '"notification_type":"auth_success","message":"logged in"')" check "auth_success ignored" "busy" "$(field state)" emit "$(ev Stop)" check "Stop -> waiting" "waiting" "$(field state)" before=$(field event_ts) emit "$(ev Notification '"notification_type":"idle_prompt","message":"waiting for input"')" check "idle_prompt while waiting does not rewrite" "$before" "$(field event_ts)" emit "$(ev Stop '"agent_id":"sub-1"')" check "subagent Stop ignored" "waiting" "$(field state)" since_before=$(field since) emit "$(ev SessionStart '"source":"compact"')" check "compaction does not reset a live session" "waiting" "$(field state)" check "compaction does not reset the clock" "$since_before" "$(field since)" # --- concurrency ----------------------------------------------------------- # The hazard: the last PostToolUse of a turn is async and can still be running # when the turn's Stop fires. Two mechanisms defend against it and they are # tested separately, because the burst below passes on the timestamp guard # alone -- it does not prove the lock is doing anything. emit "$(ev UserPromptSubmit '"prompt":"go"')" for _ in $(seq 30); do emit "$(ev PostToolUse '"tool_name":"Bash"')" & done sleep 0.3 # let the racers start, so Stop's timestamp is genuinely later emit "$(ev Stop)" wait check "Stop survives a burst of concurrent PostToolUse" "waiting" "$(field state)" python3 -c "import json;json.load(open('$FILE'))" 2>/dev/null check "state file is still valid JSON" "0" "$?" # The lock itself: what the timestamp guard cannot cover is one hook reading the # old state, being descheduled while another writes, then writing its own stale # decision on top. Holding the lock from outside makes that window observable -- # a hook must wait for it rather than read-and-write straight through. emit "$(ev UserPromptSubmit '"prompt":"go"')" python3 -c " import fcntl, time with open('$FILE.lock', 'w') as fh: fcntl.flock(fh, fcntl.LOCK_EX) time.sleep(1.5) " & holder=$! sleep 0.4 emit "$(ev Stop)" & sleep 0.5 check "hook blocks while the lock is held" "busy" "$(field state)" wait $holder wait check "hook proceeds once the lock is released" "waiting" "$(field state)" # --- teardown -------------------------------------------------------------- emit "$(ev SessionEnd '"reason":"other"')" [ -e "$FILE" ]; check "SessionEnd removes the state file" "1" "$?" [ -e "$FILE.lock" ]; check "SessionEnd removes the lock file" "1" "$?" rm -rf "$XDG_STATE_HOME" if [ "$failures" -gt 0 ]; then echo; echo "$failures failure(s)"; exit 1 fi echo; echo "all passed"