From 7ef200a4969a31ae6976b87eb78494f7917b3200 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Fri, 15 May 2026 23:04:13 -0500 Subject: test(scripts): add bats harness for audit + install-ai edge cases Adds scripts/tests/audit.bats (6 tests) and scripts/tests/install-ai.bats (5 tests) covering the three destructive edge cases that the fold-epic test plan deferred yesterday: audit --apply --force clobbering a tracked dirty .ai/, audit's loop continuing past a missing-.ai/ project, and install-ai's interactive fzf-pick form. The first two go alongside happy-path sanity (clean sweep, drift detection, --apply convergence, dirty-skip); install-ai gets happy-path with explicit PROJECT, --track gitkeep stubs, refusal on existing .ai/, and notes.org placeholder substitution. Strategy: redirect HOME to a per-test mktemp dir, scaffold synthetic project trees under HOME/code/, and run the real scripts against them. The canonical source stays the real one (resolved relative to each script's own location), so tests exercise the production rsync paths without copying canonical content. Use PATH stubs for fzf and find to cover the interactive and race-condition edges. Makefile test: target extended with a bats stanza; description updated to "Run all test suites (pytest + ERT + bats)". make test now runs 352 green (296 pytest + 22 lint-org ERT + 23 todo-cleanup ERT + 6 audit bats + 5 install-ai bats), up from 341. --- scripts/tests/audit.bats | 140 ++++++++++++++++++++++++++++++++++++++++++ scripts/tests/install-ai.bats | 103 +++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 scripts/tests/audit.bats create mode 100644 scripts/tests/install-ai.bats (limited to 'scripts') diff --git a/scripts/tests/audit.bats b/scripts/tests/audit.bats new file mode 100644 index 0000000..3df69c9 --- /dev/null +++ b/scripts/tests/audit.bats @@ -0,0 +1,140 @@ +#!/usr/bin/env bats +# +# Tests for scripts/audit.sh — cross-project .ai/ drift detector. +# +# Strategy: redirect HOME to a temp dir per test, scaffold synthetic +# project trees under HOME/code/, run the real audit.sh against the +# synthetic state. The canonical source stays the real one (audit.sh +# resolves it relative to its own location, not HOME). + +REAL_REPO="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)" +AUDIT="$REAL_REPO/scripts/audit.sh" +CANONICAL="$REAL_REPO/claude-templates/.ai" + +setup() { + TEST_HOME="$(mktemp -d -t audit-bats.XXXXXX)" + HOME_BAK="$HOME" + export HOME="$TEST_HOME" + mkdir -p "$TEST_HOME/code" "$TEST_HOME/projects" "$TEST_HOME/.emacs.d" +} + +teardown() { + export HOME="$HOME_BAK" + rm -rf "$TEST_HOME" +} + +# Mirror canonical .ai/ content into a synthetic project, matching how +# the startup-rsync would leave a clean .ai/. +scaffold_synced_ai() { + local proj_dir="$1" + mkdir -p "$proj_dir/.ai" + rsync -a "$CANONICAL/protocols.org" "$proj_dir/.ai/protocols.org" + rsync -a --delete "$CANONICAL/workflows/" "$proj_dir/.ai/workflows/" + rsync -a --delete "$CANONICAL/scripts/" "$proj_dir/.ai/scripts/" +} + +# Make a project a tracked git checkout with .ai/ committed clean. +# Subsequent edits to .ai/ then count as uncommitted (dirty) per +# audit's git status check. +git_init_with_ai_tracked() { + local proj_dir="$1" + (cd "$proj_dir" \ + && git init -q \ + && git add -A \ + && git -c user.email=test@test -c user.name=test commit -q -m initial) +} + +@test "audit: clean projects report ok with exit 0" { + scaffold_synced_ai "$TEST_HOME/code/alpha" + scaffold_synced_ai "$TEST_HOME/code/beta" + + run bash "$AUDIT" --no-doctor + + [ "$status" -eq 0 ] + [[ "$output" == *"ok ~/code/alpha"* ]] + [[ "$output" == *"ok ~/code/beta"* ]] + [[ "$output" == *"Summary: 2 ok, 0 drift, 0 skipped, 0 failed"* ]] +} + +@test "audit: drift detection reports drift and exits 1" { + scaffold_synced_ai "$TEST_HOME/code/alpha" + echo "# drift marker" >> "$TEST_HOME/code/alpha/.ai/protocols.org" + + run bash "$AUDIT" --no-doctor + + [ "$status" -eq 1 ] + [[ "$output" == *"drift ~/code/alpha"* ]] + [[ "$output" == *"1 drift"* ]] +} + +@test "audit --apply: drifted untracked project converges" { + scaffold_synced_ai "$TEST_HOME/code/alpha" + echo "# drift marker" >> "$TEST_HOME/code/alpha/.ai/protocols.org" + + run bash "$AUDIT" --apply --no-doctor + + # Applied counts as non-ok in the audit summary, so exit 1. + [ "$status" -eq 1 ] + [[ "$output" == *"applied ~/code/alpha"* ]] + + # Re-run confirms convergence. + run bash "$AUDIT" --no-doctor + [ "$status" -eq 0 ] + [[ "$output" == *"ok ~/code/alpha"* ]] +} + +@test "audit: tracked project with dirty .ai/ is skipped" { + scaffold_synced_ai "$TEST_HOME/code/alpha" + git_init_with_ai_tracked "$TEST_HOME/code/alpha" + echo "# uncommitted" >> "$TEST_HOME/code/alpha/.ai/protocols.org" + + run bash "$AUDIT" --apply --no-doctor + + [ "$status" -eq 1 ] + [[ "$output" == *"skipped ~/code/alpha"* ]] + [[ "$output" == *"use --force"* ]] + + # The dirty edit survived (skip is a safety guard). + grep -q "# uncommitted" "$TEST_HOME/code/alpha/.ai/protocols.org" +} + +@test "audit --apply --force: tracked dirty .ai/ gets clobbered" { + # Edge case 1 from todo.org:1766. Verifies the override path. + scaffold_synced_ai "$TEST_HOME/code/alpha" + git_init_with_ai_tracked "$TEST_HOME/code/alpha" + echo "# uncommitted" >> "$TEST_HOME/code/alpha/.ai/protocols.org" + + run bash "$AUDIT" --apply --force --no-doctor + + [ "$status" -eq 1 ] + [[ "$output" == *"applied ~/code/alpha"* ]] + + # The uncommitted edit is gone — force clobbered it. + ! grep -q "# uncommitted" "$TEST_HOME/code/alpha/.ai/protocols.org" +} + +@test "audit: loop continues past .ai/-missing failure" { + # Edge case 2 from todo.org:1766. The defensive [ ! -d "$proj/.ai" ] + # branch fires when a discovered .ai/ disappears between find and + # the loop iteration. That race can't be timed reliably, so stub + # find to inject a fabricated path; the for-loop hits the FAIL + # branch on the ghost and must continue to the real project. + scaffold_synced_ai "$TEST_HOME/code/alpha" + + stub_bin="$(mktemp -d)" + cat > "$stub_bin/find" < "$TEST_HOME/code/already/.ai/marker.txt" + + run bash "$INSTALL_AI" --gitignore "$TEST_HOME/code/already" + + [ "$status" -eq 2 ] + [[ "$output" == *"already exists"* ]] + # Existing content untouched. + [ -f "$TEST_HOME/code/already/.ai/marker.txt" ] +} + +@test "install-ai: notes.org placeholders get substituted" { + mkdir -p "$TEST_HOME/code/named-proj" + (cd "$TEST_HOME/code/named-proj" && git init -q) + + run bash "$INSTALL_AI" --gitignore "$TEST_HOME/code/named-proj" + + [ "$status" -eq 0 ] + # Project name landed. + grep -q "named-proj" "$TEST_HOME/code/named-proj/.ai/notes.org" + # No raw placeholders left. + ! grep -q "\[Project Name\]" "$TEST_HOME/code/named-proj/.ai/notes.org" + ! grep -q "\[Date\]" "$TEST_HOME/code/named-proj/.ai/notes.org" +} + +@test "install-ai: fzf-pick form selects via stubbed fzf" { + # Edge case 3 from todo.org:1766. The fzf-pick form is interactive; + # stubbing fzf to take the first stdin line lets us exercise the + # discovery+selection path without an interactive terminal. + mkdir -p "$TEST_HOME/code/pickme" "$TEST_HOME/code/skipme" + (cd "$TEST_HOME/code/pickme" && git init -q) + (cd "$TEST_HOME/code/skipme" && git init -q) + + stub_bin="$(mktemp -d)" + cat > "$stub_bin/fzf" <<'EOF' +#!/bin/bash +# Pass through stdin's first line as the "selection". +head -n 1 +EOF + chmod +x "$stub_bin/fzf" + + PATH="$stub_bin:$PATH" run bash "$INSTALL_AI" --gitignore + rm -rf "$stub_bin" + + [ "$status" -eq 0 ] + # Sort order puts pickme first; fzf-stub returns the first line. + [ -d "$TEST_HOME/code/pickme/.ai" ] + [ ! -d "$TEST_HOME/code/skipme/.ai" ] +} -- cgit v1.2.3