From a08afb2a1909c1c1d36e59787921dfeae2d767d4 Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Tue, 17 Mar 2026 15:25:38 -0700 Subject: [PATCH 1/3] add e2e tests --- .github/workflows/test.yml | 32 ++++ tests/helpers/setup.bash | 280 ++++++++++++++++++++++++++++ tests/test_find_venv.bats | 98 ++++++++++ tests/test_suggest_optimize.bats | 303 +++++++++++++++++++++++++++++++ 4 files changed, 713 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 tests/helpers/setup.bash create mode 100755 tests/test_find_venv.bats create mode 100755 tests/test_suggest_optimize.bats diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..bfd2e48 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,32 @@ +name: Tests + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install BATS + run: npm install -g bats + + - name: Verify dependencies + run: | + bats --version + jq --version + git --version + + - name: Run tests + run: bats tests/ \ No newline at end of file diff --git a/tests/helpers/setup.bash b/tests/helpers/setup.bash new file mode 100644 index 0000000..4302c5c --- /dev/null +++ b/tests/helpers/setup.bash @@ -0,0 +1,280 @@ +#!/usr/bin/env bash +# Shared test helpers for codeflash-cc-plugin tests + +PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SUGGEST_OPTIMIZE="$PLUGIN_ROOT/scripts/suggest-optimize.sh" +FIND_VENV_SCRIPT="$PLUGIN_ROOT/scripts/find-venv.sh" + +# --------------------------------------------------------------------------- +# Repo & session setup +# --------------------------------------------------------------------------- + +# Create a minimal git repo with an initial commit (no py/js files) +# and a transcript file representing the session start. +# Sets: REPO, TRANSCRIPT_DIR, TRANSCRIPT, MOCK_BIN +setup_test_repo() { + export REPO="$BATS_TEST_TMPDIR/repo" + export TRANSCRIPT_DIR="$BATS_TEST_TMPDIR/session" + export TRANSCRIPT="$TRANSCRIPT_DIR/transcript.jsonl" + export MOCK_BIN="$BATS_TEST_TMPDIR/mock-bin" + + mkdir -p "$REPO" "$TRANSCRIPT_DIR" "$MOCK_BIN" + + git init "$REPO" >/dev/null 2>&1 + git -C "$REPO" config user.email "test@test.com" + git -C "$REPO" config user.name "Test" + + echo "# Test project" > "$REPO/README.md" + git -C "$REPO" add -A >/dev/null 2>&1 + git -C "$REPO" commit -m "initial commit" >/dev/null 2>&1 + + # Transcript file — its mtime (or birth time) marks "session start" + touch "$TRANSCRIPT" +} + +# --------------------------------------------------------------------------- +# Portable timestamp helpers +# --------------------------------------------------------------------------- + +# Returns a Unix timestamp 60 seconds in the future. +# Commits created with this timestamp will always be "after" the session start. +future_timestamp() { + if [[ "$(uname)" == "Darwin" ]]; then + date -v+60S +%s + else + date -d '60 seconds' +%s + fi +} + +# --------------------------------------------------------------------------- +# Commit helpers (use future timestamps to avoid needing sleep) +# --------------------------------------------------------------------------- + +add_python_commit() { + local file="${1:-app.py}" + mkdir -p "$REPO/$(dirname "$file")" + echo "x = 1" > "$REPO/$file" + git -C "$REPO" add -A >/dev/null 2>&1 + local ts + ts=$(future_timestamp) + GIT_COMMITTER_DATE="@$ts" GIT_AUTHOR_DATE="@$ts" \ + git -C "$REPO" commit -m "add $file" >/dev/null 2>&1 +} + +add_js_commit() { + local file="${1:-app.js}" + mkdir -p "$REPO/$(dirname "$file")" + echo "const x = 1;" > "$REPO/$file" + git -C "$REPO" add -A >/dev/null 2>&1 + local ts + ts=$(future_timestamp) + GIT_COMMITTER_DATE="@$ts" GIT_AUTHOR_DATE="@$ts" \ + git -C "$REPO" commit -m "add $file" >/dev/null 2>&1 +} + +add_ts_commit() { + local file="${1:-app.ts}" + mkdir -p "$REPO/$(dirname "$file")" + echo "const x: number = 1;" > "$REPO/$file" + git -C "$REPO" add -A >/dev/null 2>&1 + local ts + ts=$(future_timestamp) + GIT_COMMITTER_DATE="@$ts" GIT_AUTHOR_DATE="@$ts" \ + git -C "$REPO" commit -m "add $file" >/dev/null 2>&1 +} + +add_irrelevant_commit() { + local file="${1:-data.txt}" + echo "some data" > "$REPO/$file" + git -C "$REPO" add -A >/dev/null 2>&1 + local ts + ts=$(future_timestamp) + GIT_COMMITTER_DATE="@$ts" GIT_AUTHOR_DATE="@$ts" \ + git -C "$REPO" commit -m "add $file" >/dev/null 2>&1 +} + +# --------------------------------------------------------------------------- +# Project configuration helpers +# --------------------------------------------------------------------------- + +# Create a fake Python venv with an activate script. +# Usage: create_fake_venv /path/to/venv [with_codeflash=true] +create_fake_venv() { + local venv_dir="$1" + local with_codeflash="${2:-true}" + + mkdir -p "$venv_dir/bin" + local abs_venv + abs_venv="$(cd "$venv_dir" && pwd)" + + # Minimal activate script — just sets VIRTUAL_ENV and PATH + cat > "$venv_dir/bin/activate" << ACTIVATE +export VIRTUAL_ENV="$abs_venv" +export PATH="\$VIRTUAL_ENV/bin:\$PATH" +ACTIVATE + + if [ "$with_codeflash" = "true" ]; then + cat > "$venv_dir/bin/codeflash" << 'BIN' +#!/bin/bash +echo "codeflash 0.1.0" +exit 0 +BIN + chmod +x "$venv_dir/bin/codeflash" + fi +} + +# Create pyproject.toml. configured=true adds [tool.codeflash]. +create_pyproject() { + local configured="${1:-true}" + if [ "$configured" = "true" ]; then + cat > "$REPO/pyproject.toml" << 'EOF' +[project] +name = "test-project" + +[tool.codeflash] +module-root = "src" +tests-root = "tests" +ignore-paths = [] +formatter-cmds = ["disabled"] +EOF + else + cat > "$REPO/pyproject.toml" << 'EOF' +[project] +name = "test-project" +EOF + fi + git -C "$REPO" add -A >/dev/null 2>&1 + git -C "$REPO" commit -m "add pyproject.toml" --allow-empty >/dev/null 2>&1 +} + +# Create package.json. configured=true adds "codeflash" key. +create_package_json() { + local configured="${1:-true}" + if [ "$configured" = "true" ]; then + cat > "$REPO/package.json" << 'EOF' +{ + "name": "test-project", + "codeflash": { + "moduleRoot": "src", + "testsRoot": "tests", + "formatterCmds": ["disabled"], + "ignorePaths": ["dist"] + } +} +EOF + else + cat > "$REPO/package.json" << 'EOF' +{ + "name": "test-project" +} +EOF + fi + git -C "$REPO" add -A >/dev/null 2>&1 + git -C "$REPO" commit -m "add package.json" --allow-empty >/dev/null 2>&1 +} + +# Create .claude/settings.json with Bash(*codeflash*) auto-allowed +create_auto_allow() { + mkdir -p "$REPO/.claude" + cat > "$REPO/.claude/settings.json" << 'EOF' +{ + "permissions": { + "allow": ["Bash(*codeflash*)"] + } +} +EOF +} + +# --------------------------------------------------------------------------- +# Mock npx for JS/TS tests +# --------------------------------------------------------------------------- + +# Create a mock npx binary in MOCK_BIN. +# Usage: setup_mock_npx [installed=true] +setup_mock_npx() { + local installed="${1:-true}" + mkdir -p "$MOCK_BIN" + + if [ "$installed" = "true" ]; then + cat > "$MOCK_BIN/npx" << 'MOCK' +#!/bin/bash +if [[ "$1" == "codeflash" ]]; then + echo "codeflash 0.1.0" + exit 0 +fi +exit 127 +MOCK + else + cat > "$MOCK_BIN/npx" << 'MOCK' +#!/bin/bash +if [[ "$1" == "codeflash" ]]; then + exit 1 +fi +exit 127 +MOCK + fi + chmod +x "$MOCK_BIN/npx" +} + +# --------------------------------------------------------------------------- +# Hook runner +# --------------------------------------------------------------------------- + +# Run suggest-optimize.sh with controlled environment. +# Usage: run_hook [ENV_VAR=value ...] +# stop_active: "true" or "false" (JSON boolean for stop_hook_active) +# remaining args: passed to env (e.g. VIRTUAL_ENV=/path, PATH=...) +# +# Always unsets VIRTUAL_ENV unless explicitly re-set via args. +run_hook() { + local stop_active="${1:-false}" + shift || true + + local input_file="$BATS_TEST_TMPDIR/hook_input.json" + jq -nc \ + --arg tp "$TRANSCRIPT" \ + --argjson sa "$stop_active" \ + '{transcript_path: $tp, stop_hook_active: $sa}' > "$input_file" + + cd "$REPO" + env -u VIRTUAL_ENV "$@" bash "$SUGGEST_OPTIMIZE" < "$input_file" +} + +# --------------------------------------------------------------------------- +# Assertions +# --------------------------------------------------------------------------- + +assert_block() { + [ "$status" -eq 0 ] + [ -n "$output" ] + local decision + decision=$(echo "$output" | jq -r '.decision') + [ "$decision" = "block" ] +} + +assert_no_block() { + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +assert_reason_contains() { + local expected="$1" + local reason + reason=$(echo "$output" | jq -r '.reason') + if [[ "$reason" != *"$expected"* ]]; then + echo "Expected reason to contain: $expected" >&2 + echo "Actual reason: $reason" >&2 + return 1 + fi +} + +assert_reason_not_contains() { + local unexpected="$1" + local reason + reason=$(echo "$output" | jq -r '.reason') + if [[ "$reason" == *"$unexpected"* ]]; then + echo "Expected reason NOT to contain: $unexpected" >&2 + echo "Actual reason: $reason" >&2 + return 1 + fi +} \ No newline at end of file diff --git a/tests/test_find_venv.bats b/tests/test_find_venv.bats new file mode 100755 index 0000000..d2c9caa --- /dev/null +++ b/tests/test_find_venv.bats @@ -0,0 +1,98 @@ +#!/usr/bin/env bats +# Tests for scripts/find-venv.sh — Python virtual environment discovery. +# +# find-venv.sh expects CHECK_DIR and REPO_ROOT to be set. +# If VIRTUAL_ENV is already set, it does nothing. +# Otherwise it searches CHECK_DIR/{.venv,venv} then REPO_ROOT/{.venv,venv}. + +load helpers/setup + +setup() { + export REPO="$BATS_TEST_TMPDIR/repo" + export CHECK_DIR="$REPO" + export REPO_ROOT="$REPO" + mkdir -p "$REPO" +} + +# Helper: source find-venv.sh in a subshell and print resulting VIRTUAL_ENV. +# Usage: run_find_venv [initial_virtual_env] +run_find_venv() { + local initial_venv="${1:-}" + ( + if [ -n "$initial_venv" ]; then + export VIRTUAL_ENV="$initial_venv" + else + unset VIRTUAL_ENV + fi + export CHECK_DIR REPO_ROOT + source "$FIND_VENV_SCRIPT" + echo "${VIRTUAL_ENV:-__EMPTY__}" + ) +} + +# ───────────────────────────────────────────────────────────────────────────── + +@test "preserves existing VIRTUAL_ENV" { + create_fake_venv "$REPO/.venv" + + run run_find_venv "/some/existing/venv" + [ "$status" -eq 0 ] + [ "$output" = "/some/existing/venv" ] +} + +@test "discovers .venv in CHECK_DIR" { + create_fake_venv "$CHECK_DIR/.venv" + + run run_find_venv + [ "$status" -eq 0 ] + [[ "$output" == *"/.venv" ]] + [ "$output" != "__EMPTY__" ] +} + +@test "discovers venv/ in CHECK_DIR" { + create_fake_venv "$CHECK_DIR/venv" + + run run_find_venv + [ "$status" -eq 0 ] + [[ "$output" == */venv ]] + [ "$output" != "__EMPTY__" ] +} + +@test "prefers CHECK_DIR/.venv over CHECK_DIR/venv" { + create_fake_venv "$CHECK_DIR/.venv" + create_fake_venv "$CHECK_DIR/venv" + + run run_find_venv + [ "$status" -eq 0 ] + [[ "$output" == *"/.venv" ]] +} + +@test "discovers .venv in REPO_ROOT when CHECK_DIR has none" { + local subdir="$REPO/src/subdir" + mkdir -p "$subdir" + export CHECK_DIR="$subdir" + create_fake_venv "$REPO_ROOT/.venv" + + run run_find_venv + [ "$status" -eq 0 ] + [[ "$output" == *"/.venv" ]] + [ "$output" != "__EMPTY__" ] +} + +@test "discovers venv/ in REPO_ROOT when CHECK_DIR has none" { + local subdir="$REPO/src/subdir" + mkdir -p "$subdir" + export CHECK_DIR="$subdir" + create_fake_venv "$REPO_ROOT/venv" + + run run_find_venv + [ "$status" -eq 0 ] + [[ "$output" == */venv ]] + [ "$output" != "__EMPTY__" ] +} + +@test "returns empty when no venv found anywhere" { + run run_find_venv + [ "$status" -eq 0 ] + [ "$output" = "__EMPTY__" ] +} \ No newline at end of file diff --git a/tests/test_suggest_optimize.bats b/tests/test_suggest_optimize.bats new file mode 100755 index 0000000..78a39f6 --- /dev/null +++ b/tests/test_suggest_optimize.bats @@ -0,0 +1,303 @@ +#!/usr/bin/env bats +# Integration tests for scripts/suggest-optimize.sh +# +# Each test creates an isolated git repo + transcript in $BATS_TEST_TMPDIR. +# Commits use future timestamps to guarantee they are "after" the session start +# without needing sleep. + +load helpers/setup + +setup() { + setup_test_repo +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# Early exits — hook should produce no output and exit 0 +# ═══════════════════════════════════════════════════════════════════════════════ + +@test "exits when stop_hook_active is true" { + add_python_commit + create_pyproject + create_fake_venv "$REPO/.venv" + + run run_hook true "VIRTUAL_ENV=$REPO/.venv" + assert_no_block +} + +@test "exits when transcript_path is empty" { + add_python_commit + + local input_file="$BATS_TEST_TMPDIR/hook_input.json" + jq -nc '{transcript_path: "", stop_hook_active: false}' > "$input_file" + cd "$REPO" + run bash "$SUGGEST_OPTIMIZE" < "$input_file" + assert_no_block +} + +@test "exits when transcript file does not exist" { + add_python_commit + + local input_file="$BATS_TEST_TMPDIR/hook_input.json" + jq -nc --arg tp "/nonexistent/path/transcript.jsonl" \ + '{transcript_path: $tp, stop_hook_active: false}' > "$input_file" + cd "$REPO" + run bash "$SUGGEST_OPTIMIZE" < "$input_file" + assert_no_block +} + +@test "exits when no commits have py/js/ts files" { + add_irrelevant_commit + + run run_hook false + assert_no_block +} + +@test "exits on second run (dedup via seen marker)" { + add_python_commit + create_pyproject + create_fake_venv "$REPO/.venv" + + # First run — should block + run run_hook false "VIRTUAL_ENV=$REPO/.venv" + assert_block + + # Second run with same commits — dedup marker exists + run run_hook false "VIRTUAL_ENV=$REPO/.venv" + assert_no_block +} + +@test "triggers again after a new commit (dedup hash changes)" { + create_pyproject + create_fake_venv "$REPO/.venv" + + add_python_commit "first.py" + run run_hook false "VIRTUAL_ENV=$REPO/.venv" + assert_block + + # New commit changes the dedup hash + add_python_commit "second.py" + run run_hook false "VIRTUAL_ENV=$REPO/.venv" + assert_block +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# Python projects +# ═══════════════════════════════════════════════════════════════════════════════ + +@test "python: configured + codeflash installed → run codeflash" { + add_python_commit + create_pyproject true + create_fake_venv "$REPO/.venv" + + run run_hook false "VIRTUAL_ENV=$REPO/.venv" + assert_block + assert_reason_contains "codeflash --subagent" + assert_reason_contains "run_in_background" +} + +@test "python: configured + codeflash NOT installed → install prompt" { + add_python_commit + create_pyproject true + create_fake_venv "$REPO/.venv" false + + run run_hook false "VIRTUAL_ENV=$REPO/.venv" + assert_block + assert_reason_contains "pip install codeflash" +} + +@test "python: NOT configured + codeflash installed → setup prompt" { + add_python_commit + create_pyproject false + create_fake_venv "$REPO/.venv" + + run run_hook false "VIRTUAL_ENV=$REPO/.venv" + assert_block + assert_reason_contains "[tool.codeflash]" + assert_reason_contains "module-root" +} + +@test "python: NOT configured + NOT installed → setup + install prompt" { + add_python_commit + create_pyproject false + create_fake_venv "$REPO/.venv" false + + run run_hook false "VIRTUAL_ENV=$REPO/.venv" + assert_block + assert_reason_contains "[tool.codeflash]" + assert_reason_contains "install codeflash" +} + +@test "python: no venv + configured → create venv prompt" { + add_python_commit + create_pyproject true + # No venv created, no VIRTUAL_ENV set + + run run_hook false + assert_block + assert_reason_contains "virtual environment" + assert_reason_contains "python3 -m venv" +} + +@test "python: no venv + NOT configured → create venv + setup prompt" { + add_python_commit + create_pyproject false + + run run_hook false + assert_block + assert_reason_contains "virtual environment" + assert_reason_contains "python3 -m venv" + assert_reason_contains "[tool.codeflash]" +} + +@test "python: auto-discovers .venv when VIRTUAL_ENV not set" { + add_python_commit + create_pyproject true + create_fake_venv "$REPO/.venv" true + # Don't pass VIRTUAL_ENV — script should find .venv itself + + run run_hook false + assert_block + assert_reason_contains "codeflash --subagent" +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# JavaScript / TypeScript projects +# ═══════════════════════════════════════════════════════════════════════════════ + +@test "js: configured + codeflash installed → run codeflash" { + add_js_commit + create_package_json true + setup_mock_npx true + + run run_hook false "PATH=$MOCK_BIN:$PATH" + assert_block + assert_reason_contains "npx codeflash --subagent" + assert_reason_contains "run_in_background" +} + +@test "js: configured + NOT installed → install prompt" { + add_js_commit + create_package_json true + setup_mock_npx false + + run run_hook false "PATH=$MOCK_BIN:$PATH" + assert_block + assert_reason_contains "npm install --save-dev codeflash" +} + +@test "js: NOT configured + installed → setup prompt" { + add_js_commit + create_package_json false + setup_mock_npx true + + run run_hook false "PATH=$MOCK_BIN:$PATH" + assert_block + assert_reason_contains "moduleRoot" + assert_reason_contains "testsRoot" +} + +@test "js: NOT configured + NOT installed → setup + install prompt" { + add_js_commit + create_package_json false + setup_mock_npx false + + run run_hook false "PATH=$MOCK_BIN:$PATH" + assert_block + assert_reason_contains "moduleRoot" + assert_reason_contains "npm install --save-dev codeflash" +} + +@test "js: typescript file triggers JS path" { + add_ts_commit "utils.ts" + create_package_json true + setup_mock_npx true + + run run_hook false "PATH=$MOCK_BIN:$PATH" + assert_block + assert_reason_contains "npx codeflash --subagent" +} + +@test "js: jsx file triggers JS path" { + add_js_commit "Component.jsx" + create_package_json true + setup_mock_npx true + + run run_hook false "PATH=$MOCK_BIN:$PATH" + assert_block + assert_reason_contains "npx codeflash --subagent" +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# Permissions — auto-allow instructions +# ═══════════════════════════════════════════════════════════════════════════════ + +@test "includes auto-allow instructions when settings.json missing" { + add_python_commit + create_pyproject true + create_fake_venv "$REPO/.venv" + + run run_hook false "VIRTUAL_ENV=$REPO/.venv" + assert_block + assert_reason_contains "permissions.allow" + assert_reason_contains 'Bash(*codeflash*)' +} + +@test "omits auto-allow when already configured" { + add_python_commit + create_pyproject true + create_fake_venv "$REPO/.venv" + create_auto_allow + + run run_hook false "VIRTUAL_ENV=$REPO/.venv" + assert_block + assert_reason_not_contains "permissions.allow" +} + +@test "js: includes auto-allow instructions when settings.json missing" { + add_js_commit + create_package_json true + setup_mock_npx true + + run run_hook false "PATH=$MOCK_BIN:$PATH" + assert_block + assert_reason_contains "permissions.allow" +} + +@test "js: omits auto-allow when already configured" { + add_js_commit + create_package_json true + setup_mock_npx true + create_auto_allow + + run run_hook false "PATH=$MOCK_BIN:$PATH" + assert_block + assert_reason_not_contains "permissions.allow" +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# Project detection precedence +# ═══════════════════════════════════════════════════════════════════════════════ + +@test "pyproject.toml takes precedence over package.json in same directory" { + add_python_commit + create_pyproject true + create_package_json true + create_fake_venv "$REPO/.venv" + + run run_hook false "VIRTUAL_ENV=$REPO/.venv" + assert_block + # Python path: uses bare codeflash, not npx + assert_reason_contains "codeflash --subagent" + assert_reason_not_contains "npx" +} + +@test "detects package.json when no pyproject.toml exists" { + add_js_commit + # Only package.json, no pyproject.toml + create_package_json true + setup_mock_npx true + + run run_hook false "PATH=$MOCK_BIN:$PATH" + assert_block + assert_reason_contains "npx codeflash --subagent" +} \ No newline at end of file From 7453cabc037048deeedf642f9fe908d946cb1c33 Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Tue, 17 Mar 2026 15:31:22 -0700 Subject: [PATCH 2/3] update node version to 22 --- .github/workflows/test.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bfd2e48..bc015bc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,12 +12,15 @@ jobs: matrix: os: [ubuntu-latest, macos-latest] + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" - name: Install BATS run: npm install -g bats From 1ac2827ca942d58f83204d1d34073c2bbbb031da Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Tue, 17 Mar 2026 15:43:12 -0700 Subject: [PATCH 3/3] add documentation --- tests/test_find_venv.bats | 45 ++++++++- tests/test_suggest_optimize.bats | 162 +++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 2 deletions(-) diff --git a/tests/test_find_venv.bats b/tests/test_find_venv.bats index d2c9caa..37ff7db 100755 --- a/tests/test_find_venv.bats +++ b/tests/test_find_venv.bats @@ -1,9 +1,14 @@ #!/usr/bin/env bats # Tests for scripts/find-venv.sh — Python virtual environment discovery. # -# find-venv.sh expects CHECK_DIR and REPO_ROOT to be set. +# find-venv.sh expects CHECK_DIR and REPO_ROOT to be set before sourcing. # If VIRTUAL_ENV is already set, it does nothing. -# Otherwise it searches CHECK_DIR/{.venv,venv} then REPO_ROOT/{.venv,venv}. +# Otherwise it searches these locations in order: +# 1. CHECK_DIR/.venv +# 2. CHECK_DIR/venv +# 3. REPO_ROOT/.venv +# 4. REPO_ROOT/venv +# and activates (sources bin/activate) the first one found. load helpers/setup @@ -32,6 +37,12 @@ run_find_venv() { # ───────────────────────────────────────────────────────────────────────────── +# Setup: VIRTUAL_ENV="/some/existing/venv" is set before sourcing. +# A real .venv directory also exists in the repo. +# Validates: When a venv is already active (VIRTUAL_ENV is non-empty), the +# script must not override it. Users who explicitly activated a +# specific venv should keep using it. +# Expected: VIRTUAL_ENV remains "/some/existing/venv" (unchanged). @test "preserves existing VIRTUAL_ENV" { create_fake_venv "$REPO/.venv" @@ -40,6 +51,11 @@ run_find_venv() { [ "$output" = "/some/existing/venv" ] } +# Setup: VIRTUAL_ENV is unset. A fake venv exists at CHECK_DIR/.venv +# (the project directory). CHECK_DIR == REPO_ROOT. +# Validates: The script's first search candidate is CHECK_DIR/.venv. When it +# finds bin/activate there, it sources it, which sets VIRTUAL_ENV. +# Expected: VIRTUAL_ENV is set to the .venv path. @test "discovers .venv in CHECK_DIR" { create_fake_venv "$CHECK_DIR/.venv" @@ -49,6 +65,11 @@ run_find_venv() { [ "$output" != "__EMPTY__" ] } +# Setup: VIRTUAL_ENV is unset. A fake venv exists at CHECK_DIR/venv +# (the "venv" naming convention instead of ".venv"). +# Validates: The script also checks the "venv" directory name (second +# candidate in the search order). +# Expected: VIRTUAL_ENV is set to the venv path. @test "discovers venv/ in CHECK_DIR" { create_fake_venv "$CHECK_DIR/venv" @@ -58,6 +79,11 @@ run_find_venv() { [ "$output" != "__EMPTY__" ] } +# Setup: VIRTUAL_ENV is unset. Both .venv and venv directories exist in +# CHECK_DIR, each with a valid activate script. +# Validates: The search order is deterministic — CHECK_DIR/.venv is checked +# before CHECK_DIR/venv. The first match wins and the loop breaks. +# Expected: VIRTUAL_ENV points to .venv (not venv). @test "prefers CHECK_DIR/.venv over CHECK_DIR/venv" { create_fake_venv "$CHECK_DIR/.venv" create_fake_venv "$CHECK_DIR/venv" @@ -67,6 +93,12 @@ run_find_venv() { [[ "$output" == *"/.venv" ]] } +# Setup: VIRTUAL_ENV is unset. CHECK_DIR is a subdirectory (src/subdir) +# with no venv. REPO_ROOT has a .venv directory. +# Validates: When CHECK_DIR has no venv candidates, the script falls back to +# REPO_ROOT. This covers monorepo setups where the venv lives at +# the repo root but CWD is deep in the tree. +# Expected: VIRTUAL_ENV is set to REPO_ROOT/.venv. @test "discovers .venv in REPO_ROOT when CHECK_DIR has none" { local subdir="$REPO/src/subdir" mkdir -p "$subdir" @@ -79,6 +111,9 @@ run_find_venv() { [ "$output" != "__EMPTY__" ] } +# Setup: Same as above but REPO_ROOT has "venv/" instead of ".venv/". +# Validates: REPO_ROOT/venv is the last candidate in the search order. +# Expected: VIRTUAL_ENV is set to REPO_ROOT/venv. @test "discovers venv/ in REPO_ROOT when CHECK_DIR has none" { local subdir="$REPO/src/subdir" mkdir -p "$subdir" @@ -91,6 +126,12 @@ run_find_venv() { [ "$output" != "__EMPTY__" ] } +# Setup: VIRTUAL_ENV is unset. No .venv or venv directory exists anywhere +# in CHECK_DIR or REPO_ROOT. +# Validates: When no venv is found after exhausting all candidates, VIRTUAL_ENV +# must remain unset. The caller (suggest-optimize.sh) will then +# detect this and prompt the user to create one. +# Expected: VIRTUAL_ENV is empty (__EMPTY__ sentinel). @test "returns empty when no venv found anywhere" { run run_find_venv [ "$status" -eq 0 ] diff --git a/tests/test_suggest_optimize.bats b/tests/test_suggest_optimize.bats index 78a39f6..9746aca 100755 --- a/tests/test_suggest_optimize.bats +++ b/tests/test_suggest_optimize.bats @@ -15,6 +15,12 @@ setup() { # Early exits — hook should produce no output and exit 0 # ═══════════════════════════════════════════════════════════════════════════════ +# Setup: Fully configured Python project with a .py commit. +# Hook input has stop_hook_active=true. +# Validates: When Claude Code signals that the stop hook has already fired +# (e.g., Claude already responded to a previous block), the hook +# must exit immediately to avoid an infinite block loop. +# Expected: Exit 0, no JSON output (no block). @test "exits when stop_hook_active is true" { add_python_commit create_pyproject @@ -24,6 +30,11 @@ setup() { assert_no_block } +# Setup: Git repo with a .py commit. Hook input has an empty transcript_path. +# Validates: The hook uses the transcript file's birth time to determine when +# the session started. Without a valid path, it cannot compute session +# start and must bail out. +# Expected: Exit 0, no JSON output. @test "exits when transcript_path is empty" { add_python_commit @@ -34,6 +45,11 @@ setup() { assert_no_block } +# Setup: Git repo with a .py commit. Hook input points to a transcript file +# that does not exist on disk. +# Validates: The hook checks `[ ! -f "$TRANSCRIPT_PATH" ]` before proceeding. +# A stale or incorrect transcript path must not cause errors. +# Expected: Exit 0, no JSON output. @test "exits when transcript file does not exist" { add_python_commit @@ -45,6 +61,12 @@ setup() { assert_no_block } +# Setup: Git repo with a commit that only touches a .txt file (no .py/.js/.ts). +# Valid transcript exists. +# Validates: The hook scans `git log` for commits touching *.py, *.js, *.ts, +# *.jsx, *.tsx. When no matching files are found, there is nothing +# to optimize. +# Expected: Exit 0, no JSON output. @test "exits when no commits have py/js/ts files" { add_irrelevant_commit @@ -52,6 +74,13 @@ setup() { assert_no_block } +# Setup: Fully configured Python project with one .py commit. +# Run the hook twice with the exact same commit state. +# Validates: The hook writes a dedup marker (SHA-256 of commit hashes) to +# $TRANSCRIPT_DIR/codeflash-seen. On the second invocation with +# identical commits, it finds the marker and skips to avoid +# suggesting optimization twice for the same changes. +# Expected: First run blocks; second run exits silently (dedup). @test "exits on second run (dedup via seen marker)" { add_python_commit create_pyproject @@ -66,6 +95,12 @@ setup() { assert_no_block } +# Setup: Fully configured Python project. Make one commit, run hook (blocks). +# Then make a second commit and run hook again. +# Validates: The dedup marker is a hash of all relevant commit SHAs. When a +# new commit is added, the hash changes, so the hook correctly +# recognizes there are new changes to optimize. +# Expected: Both runs produce a block decision. @test "triggers again after a new commit (dedup hash changes)" { create_pyproject create_fake_venv "$REPO/.venv" @@ -84,6 +119,14 @@ setup() { # Python projects # ═══════════════════════════════════════════════════════════════════════════════ +# Setup: pyproject.toml with [tool.codeflash] section. Fake venv at .venv/ +# with a mock codeflash binary. VIRTUAL_ENV pointed at the fake venv. +# One .py file committed after session start. +# Validates: The "happy path" — everything is set up, codeflash should just run. +# The hook instructs Claude to execute `codeflash --subagent` as a +# background task. +# Expected: Block with reason containing "codeflash --subagent" and +# "run_in_background". @test "python: configured + codeflash installed → run codeflash" { add_python_commit create_pyproject true @@ -95,6 +138,11 @@ setup() { assert_reason_contains "run_in_background" } +# Setup: pyproject.toml with [tool.codeflash]. Fake venv exists but does NOT +# contain a codeflash binary. VIRTUAL_ENV set. +# Validates: When codeflash is configured but not installed in the venv, the +# hook should prompt the user to install it before optimization can run. +# Expected: Block with reason containing "pip install codeflash". @test "python: configured + codeflash NOT installed → install prompt" { add_python_commit create_pyproject true @@ -105,6 +153,13 @@ setup() { assert_reason_contains "pip install codeflash" } +# Setup: pyproject.toml exists but has NO [tool.codeflash] section. Fake venv +# with codeflash binary installed. VIRTUAL_ENV set. +# Validates: When codeflash is installed but not configured, the hook should +# instruct Claude to discover the project structure (module root, +# tests folder) and write the [tool.codeflash] config section. +# Expected: Block with reason containing "[tool.codeflash]" and "module-root" +# (the config fields to be written). @test "python: NOT configured + codeflash installed → setup prompt" { add_python_commit create_pyproject false @@ -116,6 +171,13 @@ setup() { assert_reason_contains "module-root" } +# Setup: pyproject.toml without [tool.codeflash]. Fake venv WITHOUT codeflash +# binary. VIRTUAL_ENV set. +# Validates: When both installation and configuration are missing, the hook +# should instruct Claude to both install codeflash and set up the +# config. The install step is embedded within the setup instructions. +# Expected: Block with reason containing both "[tool.codeflash]" (setup) and +# "install codeflash" (installation). @test "python: NOT configured + NOT installed → setup + install prompt" { add_python_commit create_pyproject false @@ -127,6 +189,13 @@ setup() { assert_reason_contains "install codeflash" } +# Setup: pyproject.toml with [tool.codeflash]. No .venv or venv directory +# anywhere. VIRTUAL_ENV not set. +# Validates: Without a virtual environment, codeflash cannot run. The hook +# should instruct the user to create a venv, install codeflash in it, +# and restart Claude Code from within the activated environment. +# Expected: Block with reason containing "virtual environment" and +# "python3 -m venv" (the venv creation command). @test "python: no venv + configured → create venv prompt" { add_python_commit create_pyproject true @@ -138,6 +207,14 @@ setup() { assert_reason_contains "python3 -m venv" } +# Setup: pyproject.toml WITHOUT [tool.codeflash]. No venv anywhere. +# VIRTUAL_ENV not set. +# Validates: The worst case — nothing is set up. The hook should instruct the +# user to create a venv, install codeflash, AND set up the config. +# The setup instructions (with [tool.codeflash] template) are included +# alongside the venv creation steps. +# Expected: Block with reason containing "virtual environment", +# "python3 -m venv", and "[tool.codeflash]". @test "python: no venv + NOT configured → create venv + setup prompt" { add_python_commit create_pyproject false @@ -149,6 +226,14 @@ setup() { assert_reason_contains "[tool.codeflash]" } +# Setup: pyproject.toml with [tool.codeflash]. Fake venv at $REPO/.venv with +# codeflash binary. VIRTUAL_ENV is NOT set (not passed to env). +# Validates: The hook sources find-venv.sh which searches CHECK_DIR/.venv, +# CHECK_DIR/venv, REPO_ROOT/.venv, REPO_ROOT/venv for an activate +# script. It should find .venv, activate it (setting VIRTUAL_ENV), +# and then proceed as if the venv was active from the start. +# Expected: Block with reason containing "codeflash --subagent" (same as the +# happy path — auto-discovery is transparent). @test "python: auto-discovers .venv when VIRTUAL_ENV not set" { add_python_commit create_pyproject true @@ -164,6 +249,14 @@ setup() { # JavaScript / TypeScript projects # ═══════════════════════════════════════════════════════════════════════════════ +# Setup: package.json with "codeflash" config key. Mock npx that returns +# success for `codeflash --version`. PATH includes mock bin. +# One .js file committed after session start. No pyproject.toml. +# Validates: The JS "happy path" — package.json is configured, codeflash npm +# package is available via npx. The hook instructs Claude to run +# `npx codeflash --subagent` in the background. +# Expected: Block with reason containing "npx codeflash --subagent" and +# "run_in_background". @test "js: configured + codeflash installed → run codeflash" { add_js_commit create_package_json true @@ -175,6 +268,12 @@ setup() { assert_reason_contains "run_in_background" } +# Setup: package.json with "codeflash" key. Mock npx returns failure for +# `codeflash --version` (package not installed). One .js commit. +# Validates: When codeflash is configured in package.json but the npm package +# is not installed, the hook should prompt to install it as a dev +# dependency before running. +# Expected: Block with reason containing "npm install --save-dev codeflash". @test "js: configured + NOT installed → install prompt" { add_js_commit create_package_json true @@ -185,6 +284,13 @@ setup() { assert_reason_contains "npm install --save-dev codeflash" } +# Setup: package.json exists but has NO "codeflash" key. Mock npx returns +# success (codeflash is installed). One .js commit. +# Validates: When codeflash is installed but not configured, the hook should +# instruct Claude to discover project structure and add the "codeflash" +# config key to package.json with moduleRoot, testsRoot, etc. +# Expected: Block with reason containing "moduleRoot" and "testsRoot" +# (the config fields to be added to package.json). @test "js: NOT configured + installed → setup prompt" { add_js_commit create_package_json false @@ -196,6 +302,14 @@ setup() { assert_reason_contains "testsRoot" } +# Setup: package.json without "codeflash" key. Mock npx fails (not installed). +# One .js commit. +# Validates: When both installation and configuration are missing for a JS +# project. The setup message should include an install step +# ("npm install --save-dev codeflash") embedded within the broader +# config setup instructions. +# Expected: Block with reason containing both "moduleRoot" (setup) and +# "npm install --save-dev codeflash" (installation). @test "js: NOT configured + NOT installed → setup + install prompt" { add_js_commit create_package_json false @@ -207,6 +321,12 @@ setup() { assert_reason_contains "npm install --save-dev codeflash" } +# Setup: Configured package.json + mock npx. Commit touches a .ts file +# instead of .js. +# Validates: TypeScript files (*.ts) are detected by the git log filter and +# route through the JS project path (since package.json is the +# project config). The hook should treat .ts the same as .js. +# Expected: Block with reason containing "npx codeflash --subagent". @test "js: typescript file triggers JS path" { add_ts_commit "utils.ts" create_package_json true @@ -217,6 +337,11 @@ setup() { assert_reason_contains "npx codeflash --subagent" } +# Setup: Configured package.json + mock npx. Commit touches a .jsx file. +# Validates: JSX files (*.jsx) are also detected by the git log filter +# (-- '*.jsx') and processed via the JS path. Ensures React +# component files trigger optimization. +# Expected: Block with reason containing "npx codeflash --subagent". @test "js: jsx file triggers JS path" { add_js_commit "Component.jsx" create_package_json true @@ -231,6 +356,12 @@ setup() { # Permissions — auto-allow instructions # ═══════════════════════════════════════════════════════════════════════════════ +# Setup: Fully configured Python project. No .claude/settings.json exists. +# Validates: When codeflash is not yet auto-allowed, the hook appends +# instructions telling Claude to add `Bash(*codeflash*)` to the +# permissions.allow array in .claude/settings.json. This enables +# future runs to execute without user permission prompts. +# Expected: Block reason contains "permissions.allow" and "Bash(*codeflash*)". @test "includes auto-allow instructions when settings.json missing" { add_python_commit create_pyproject true @@ -242,6 +373,12 @@ setup() { assert_reason_contains 'Bash(*codeflash*)' } +# Setup: Fully configured Python project. .claude/settings.json exists and +# already has "Bash(*codeflash*)" in permissions.allow. +# Validates: When auto-allow is already configured, the hook should NOT include +# the permissions setup instructions. The message should only contain +# the "run codeflash" instruction. +# Expected: Block reason does NOT contain "permissions.allow". @test "omits auto-allow when already configured" { add_python_commit create_pyproject true @@ -253,6 +390,12 @@ setup() { assert_reason_not_contains "permissions.allow" } +# Setup: Fully configured JS project. No .claude/settings.json exists. +# Validates: Same as the Python auto-allow test, but for JS projects. The +# auto-allow logic is shared (checked at script top before branching +# on project type), but the instructions are appended separately in +# each path. This verifies the JS path also appends them. +# Expected: Block reason contains "permissions.allow". @test "js: includes auto-allow instructions when settings.json missing" { add_js_commit create_package_json true @@ -263,6 +406,10 @@ setup() { assert_reason_contains "permissions.allow" } +# Setup: Fully configured JS project. .claude/settings.json has +# "Bash(*codeflash*)" in permissions.allow. +# Validates: JS path correctly omits auto-allow instructions when already set. +# Expected: Block reason does NOT contain "permissions.allow". @test "js: omits auto-allow when already configured" { add_js_commit create_package_json true @@ -278,6 +425,16 @@ setup() { # Project detection precedence # ═══════════════════════════════════════════════════════════════════════════════ +# Setup: BOTH pyproject.toml (with [tool.codeflash]) and package.json (with +# "codeflash" key) exist in the same directory. Fake venv with +# codeflash installed. One .py commit. +# Validates: The detect_project function checks pyproject.toml before +# package.json at each directory level. When both exist, the Python +# path should be chosen. This ensures Python projects with a +# package.json (e.g., for JS tooling) don't accidentally take the +# JS path. +# Expected: Block with "codeflash --subagent" (bare, Python-style) and +# NOT "npx" (which would indicate the JS path). @test "pyproject.toml takes precedence over package.json in same directory" { add_python_commit create_pyproject true @@ -291,6 +448,11 @@ setup() { assert_reason_not_contains "npx" } +# Setup: Only package.json exists (no pyproject.toml). Configured with +# "codeflash" key. Mock npx available. One .js commit. +# Validates: When pyproject.toml is absent, detect_project correctly falls +# through to package.json and identifies the project as JS/TS. +# Expected: Block with "npx codeflash --subagent" (JS-style invocation). @test "detects package.json when no pyproject.toml exists" { add_js_commit # Only package.json, no pyproject.toml