|
#!/usr/bin/env bash |
|
set -uo pipefail |
|
|
|
# Integration tests for code-review.sh hook. |
|
# Runs real `claude -p` calls against real CLAUDE.md rules. |
|
# Usage: ./test-code-review.sh [path-to-hook] [project-root] |
|
|
|
HOOK="${1:-$(git rev-parse --show-toplevel)/.claude/hooks/code-review.sh}" |
|
PROJECT_ROOT="${2:-$(git rev-parse --show-toplevel)}" |
|
PASS=0 |
|
FAIL=0 |
|
TOTAL=0 |
|
|
|
red() { printf "\033[31m%s\033[0m" "$*"; } |
|
green() { printf "\033[32m%s\033[0m" "$*"; } |
|
dim() { printf "\033[2m%s\033[0m" "$*"; } |
|
|
|
assert_violation() { |
|
local name="$1" output="$2" |
|
TOTAL=$((TOTAL + 1)) |
|
if echo "$output" | grep -q '"decision"'; then |
|
PASS=$((PASS + 1)) |
|
echo " $(green PASS) $name" |
|
else |
|
FAIL=$((FAIL + 1)) |
|
echo " $(red FAIL) $name (expected violation, got: $(dim "${output:-<empty>}"))" |
|
fi |
|
} |
|
|
|
assert_pass() { |
|
local name="$1" output="$2" |
|
TOTAL=$((TOTAL + 1)) |
|
if echo "$output" | grep -q '"decision"'; then |
|
FAIL=$((FAIL + 1)) |
|
echo " $(red FAIL) $name (expected pass, got violation: $(dim "$output"))" |
|
else |
|
PASS=$((PASS + 1)) |
|
echo " $(green PASS) $name" |
|
fi |
|
} |
|
|
|
assert_skip() { |
|
local name="$1" output="$2" |
|
TOTAL=$((TOTAL + 1)) |
|
if [[ -z "$output" ]]; then |
|
PASS=$((PASS + 1)) |
|
echo " $(green PASS) $name $(dim '(skipped, no output)')" |
|
else |
|
FAIL=$((FAIL + 1)) |
|
echo " $(red FAIL) $name (expected skip/empty, got: $(dim "$output"))" |
|
fi |
|
} |
|
|
|
# Helper: run the hook with a simulated Write input |
|
run_write() { |
|
local file_path="$1" content="$2" |
|
# Escape content for JSON |
|
local json_content |
|
json_content=$(printf '%s' "$content" | jq -Rs '.') |
|
|
|
echo "{ |
|
\"session_id\": \"test-session\", |
|
\"cwd\": \"${PROJECT_ROOT}\", |
|
\"hook_event_name\": \"PostToolUse\", |
|
\"tool_name\": \"Write\", |
|
\"tool_input\": { |
|
\"file_path\": \"${file_path}\", |
|
\"content\": ${json_content} |
|
} |
|
}" | bash "$HOOK" 2>/dev/null |
|
} |
|
|
|
# Helper: run the hook with a simulated Edit input |
|
run_edit() { |
|
local file_path="$1" old_string="$2" new_string="$3" |
|
local json_old json_new |
|
json_old=$(printf '%s' "$old_string" | jq -Rs '.') |
|
json_new=$(printf '%s' "$new_string" | jq -Rs '.') |
|
|
|
echo "{ |
|
\"session_id\": \"test-session\", |
|
\"cwd\": \"${PROJECT_ROOT}\", |
|
\"hook_event_name\": \"PostToolUse\", |
|
\"tool_name\": \"Edit\", |
|
\"tool_input\": { |
|
\"file_path\": \"${file_path}\", |
|
\"old_string\": ${json_old}, |
|
\"new_string\": ${json_new} |
|
} |
|
}" | bash "$HOOK" 2>/dev/null |
|
} |
|
|
|
echo "" |
|
echo "Testing code-review.sh hook" |
|
echo "Hook: $HOOK" |
|
echo "Project: $PROJECT_ROOT" |
|
echo "" |
|
|
|
# We need a real file on disk for the hook to find (it checks -f) |
|
TEST_DIR="${PROJECT_ROOT}/src/web/lib" |
|
mkdir -p "$TEST_DIR" |
|
|
|
cleanup() { |
|
rm -f "$TEST_DIR/test-hook-"*.ts "$TEST_DIR/test-hook-"*.tsx |
|
} |
|
trap cleanup EXIT |
|
|
|
# ============================================================ |
|
# SKIP tests (no claude -p call, instant) |
|
# ============================================================ |
|
echo "--- Skip conditions ---" |
|
|
|
# Non-code extension |
|
TEST_FILE="$TEST_DIR/test-hook-skip.json" |
|
echo '{}' > "$TEST_FILE" |
|
output=$(run_write "$TEST_FILE" '{}') |
|
assert_skip "skips .json files" "$output" |
|
rm -f "$TEST_FILE" |
|
|
|
# Test file |
|
TEST_FILE="$TEST_DIR/test-hook-skip.test.ts" |
|
echo '' > "$TEST_FILE" |
|
output=$(run_write "$TEST_FILE" 'const x = 1;') |
|
assert_skip "skips .test.ts files" "$output" |
|
rm -f "$TEST_FILE" |
|
|
|
# Config file |
|
TEST_FILE="$TEST_DIR/test-hook-skip.config.ts" |
|
echo '' > "$TEST_FILE" |
|
output=$(run_write "$TEST_FILE" 'export default {}') |
|
assert_skip "skips .config.ts files" "$output" |
|
rm -f "$TEST_FILE" |
|
|
|
# File outside project |
|
output=$(echo "{ |
|
\"tool_name\": \"Write\", |
|
\"tool_input\": {\"file_path\": \"/tmp/random.ts\", \"content\": \"const x: any = 1;\"} |
|
}" | bash "$HOOK" 2>/dev/null) |
|
assert_skip "skips files outside project" "$output" |
|
|
|
# Non-existent file |
|
output=$(echo "{ |
|
\"tool_name\": \"Write\", |
|
\"tool_input\": {\"file_path\": \"${PROJECT_ROOT}/does-not-exist.ts\", \"content\": \"x\"} |
|
}" | bash "$HOOK" 2>/dev/null) |
|
assert_skip "skips non-existent files" "$output" |
|
|
|
# Wrong tool |
|
output=$(echo "{ |
|
\"tool_name\": \"Bash\", |
|
\"tool_input\": {\"command\": \"echo hi\"} |
|
}" | bash "$HOOK" 2>/dev/null) |
|
assert_skip "skips non-Write/Edit tools" "$output" |
|
|
|
# ============================================================ |
|
# VIOLATION tests (calls claude -p, ~10s each) |
|
# ============================================================ |
|
echo "" |
|
echo "--- Violation detection (each test calls claude -p, ~10s) ---" |
|
|
|
# any type |
|
TEST_FILE="$TEST_DIR/test-hook-review.ts" |
|
echo 'const x: any = "bad";' > "$TEST_FILE" |
|
output=$(run_write "$TEST_FILE" 'const getData = (): any => { return null; }; |
|
export { getData };') |
|
assert_violation "catches 'any' type" "$output" |
|
|
|
# default export |
|
echo 'export default function bad() {}' > "$TEST_FILE" |
|
output=$(run_write "$TEST_FILE" 'export default function bad() { return null; }') |
|
assert_violation "catches default export" "$output" |
|
|
|
# localStorage (forbidden browser API) |
|
echo 'localStorage.getItem("x")' > "$TEST_FILE" |
|
output=$(run_write "$TEST_FILE" 'const save = () => { localStorage.setItem("key", "val"); }; |
|
export { save };') |
|
assert_violation "catches localStorage usage" "$output" |
|
|
|
# ============================================================ |
|
# PASS tests (calls claude -p, ~10s each) |
|
# ============================================================ |
|
echo "" |
|
echo "--- Clean code (should pass) ---" |
|
|
|
echo 'const DELIMITER = "-";' > "$TEST_FILE" |
|
output=$(run_write "$TEST_FILE" 'const DELIMITER = "-"; |
|
|
|
const joinParts = (parts: string[]) => parts.join(DELIMITER); |
|
|
|
export { joinParts };') |
|
assert_pass "clean code passes" "$output" |
|
|
|
# Small edit |
|
echo 'const X = 1;' > "$TEST_FILE" |
|
output=$(run_edit "$TEST_FILE" 'const X = 1;' 'const X = 2;') |
|
assert_pass "trivial constant change passes" "$output" |
|
|
|
cleanup |
|
|
|
# ============================================================ |
|
# Summary |
|
# ============================================================ |
|
echo "" |
|
echo "---" |
|
echo "Results: $PASS/$TOTAL passed, $FAIL failed" |
|
if [[ $FAIL -gt 0 ]]; then |
|
echo "$(red 'SOME TESTS FAILED')" |
|
exit 1 |
|
else |
|
echo "$(green 'ALL TESTS PASSED')" |
|
exit 0 |
|
fi |