#!/usr/bin/env bats # Characterization tests for the ai launcher's internal functions. # # These pin the launcher's CURRENT behavior (record-not-spec, per testing.md) # before the hardening refactor, so the extraction of pure cores and the # footgun fixes can proceed against a green net. The pure/near-pure functions # are exercised by sourcing bin/ai (the run-vs-sourced guard keeps dispatch # off) and calling them directly; the tmux-coupled pipeline is exercised # against a throwaway tmux server on a PRIVATE socket (TMUX_TMPDIR under the # test tmpdir, TMUX unset) so nothing can reach Craig's live 'ai' session. # The launcher stores candidates as literal "~/..." display strings and expands # them downstream; the assertions below compare against those literal tildes on # purpose, so SC2088 (tilde-in-quotes) does not apply. SC2030/SC2031 fire on # the shared `candidates` global because bats runs each @test in its own # subshell — the array is set and read within that same subshell, so the # warning is a false positive here. # shellcheck disable=SC2088,SC2030,SC2031 setup() { REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)" AI="$REPO_ROOT/claude-templates/bin/ai" WORK="$(mktemp -d)" # Isolate every tmux call in this file onto a private server. export TMUX_TMPDIR="$WORK" unset TMUX # Source for direct function access. The guard skips main() when sourced. # shellcheck disable=SC1090 source "$AI" } teardown() { tmux kill-server 2>/dev/null || true rm -rf "$WORK" } # --- git repo helpers --------------------------------------------------------- # A committed repo with no remote/upstream. Auto-maintenance off so background # git can't race the teardown rm -rf (the rename-ai flake, fixed 2026-07-19). _mk_repo() { local d="$1" git init -q "$d" git -C "$d" config user.email t@example.com git -C "$d" config user.name tester git -C "$d" config commit.gpgsign false git -C "$d" config gc.auto 0 git -C "$d" config maintenance.auto false git -C "$d" commit -q --allow-empty -m init } # A repo with a bare origin and a tracked branch, in sync at one commit. _mk_repo_upstream() { local d="$1" remote="$1.remote" git init -q --bare "$remote" _mk_repo "$d" git -C "$d" remote add origin "$remote" git -C "$d" push -q -u origin HEAD } # --- usage() ------------------------------------------------------------------ @test "usage: -h prints the help banner and exits 0" { run bash "$AI" -h [ "$status" -eq 0 ] [[ "$output" == *"Usage:"* ]] [[ "$output" == *"--attach"* ]] [[ "$output" == *"Single-project mode"* ]] } # --- maybe_add_candidate() ---------------------------------------------------- @test "maybe_add_candidate: a HOME-rooted project dir is added as ~/rel" { HOME="$WORK/home" mkdir -p "$HOME/code/proj/.ai" touch "$HOME/code/proj/.ai/protocols.org" candidates=() maybe_add_candidate "$HOME/code/proj" [ "${#candidates[@]}" -eq 1 ] [ "${candidates[0]}" = "~/code/proj" ] } @test "maybe_add_candidate: a dir without .ai/protocols.org is filtered out" { HOME="$WORK/home" mkdir -p "$HOME/code/plain" candidates=() maybe_add_candidate "$HOME/code/plain" || true [ "${#candidates[@]}" -eq 0 ] } @test "maybe_add_candidate: a non-HOME dir keeps its full path after ~/ (current quirk)" { HOME="$WORK/home" mkdir -p "$WORK/outside/.ai" touch "$WORK/outside/.ai/protocols.org" candidates=() maybe_add_candidate "$WORK/outside" # $HOME/ prefix doesn't match, so the path is left whole behind the literal ~/. [ "${candidates[0]}" = "~/$WORK/outside" ] } # --- git_status_indicator() --------------------------------------------------- @test "git_status_indicator: a non-git dir yields no annotation" { mkdir -p "$WORK/plain" run git_status_indicator "$WORK/plain" [ "$status" -eq 0 ] [ -z "$output" ] } @test "git_status_indicator: clean repo with no upstream reads (no upstream)" { _mk_repo "$WORK/r" run git_status_indicator "$WORK/r" [ "$output" = " (no upstream)" ] } @test "git_status_indicator: an untracked file with no upstream reads (no upstream dirty)" { _mk_repo "$WORK/r" touch "$WORK/r/scratch" run git_status_indicator "$WORK/r" [ "$output" = " (no upstream dirty)" ] } @test "git_status_indicator: in-sync tracked repo reads (✓)" { _mk_repo_upstream "$WORK/r" run git_status_indicator "$WORK/r" [ "$output" = " (✓)" ] } @test "git_status_indicator: one unpushed commit reads (↑1)" { _mk_repo_upstream "$WORK/r" git -C "$WORK/r" commit -q --allow-empty -m ahead run git_status_indicator "$WORK/r" [ "$output" = " (↑1)" ] } @test "git_status_indicator: one commit behind upstream reads (↓1)" { _mk_repo_upstream "$WORK/r" git -C "$WORK/r" commit -q --allow-empty -m b git -C "$WORK/r" push -q origin HEAD git -C "$WORK/r" reset -q --hard HEAD~1 run git_status_indicator "$WORK/r" [ "$output" = " (↓1)" ] } # --- annotate_candidates() ---------------------------------------------------- @test "annotate_candidates: appends each candidate's status suffix" { _mk_repo_upstream "$WORK/home/code/clean" mkdir -p "$WORK/home/code/plain" HOME="$WORK/home" candidates=("~/code/clean" "~/code/plain") annotate_candidates [ "${candidates[0]}" = "~/code/clean (✓)" ] # A non-git candidate gets an empty suffix (git_status_indicator returns ""). [ "${candidates[1]}" = "~/code/plain" ] } # --- read_selections() -------------------------------------------------------- @test "read_selections: strips the ' (annotation)' suffix from each line" { read_selections "$(printf '~/code/a (✓)\n~/projects/b (↑1 dirty)')" [ "${#selected[@]}" -eq 2 ] [ "${selected[0]}" = "~/code/a" ] [ "${selected[1]}" = "~/projects/b" ] } @test "read_selections: a line with no annotation is kept verbatim" { read_selections "~/code/plain" [ "${selected[0]}" = "~/code/plain" ] } @test "read_selections: empty input yields a single empty element (current quirk)" { read_selections "" [ "${#selected[@]}" -eq 1 ] [ -z "${selected[0]}" ] } # --- build_candidates() ------------------------------------------------------- @test "build_candidates: discovers .ai projects under HOME, filters, sorts" { HOME="$WORK/home" mkdir -p "$HOME/.emacs.d/.ai" "$HOME/code/beta/.ai" "$HOME/code/alpha/.ai" \ "$HOME/code/plain" "$HOME/projects/gamma/.ai" touch "$HOME/.emacs.d/.ai/protocols.org" \ "$HOME/code/beta/.ai/protocols.org" \ "$HOME/code/alpha/.ai/protocols.org" \ "$HOME/projects/gamma/.ai/protocols.org" # '|| true' suspends bats's errexit so the function runs to completion as it # does in production (bin/ai runs without set -e). Its non-zero exit is # incidental — it inherits the last maybe_add_candidate's filter result, # which callers ignore; see the file's top-of-script NOTE. build_candidates || true # .emacs.d first (probed first), then ~/code sorted, then ~/projects. [ "${candidates[0]}" = "~/.emacs.d" ] [ "${candidates[1]}" = "~/code/alpha" ] [ "${candidates[2]}" = "~/code/beta" ] [ "${candidates[3]}" = "~/projects/gamma" ] [ "${#candidates[@]}" -eq 4 ] } @test "build_candidates: a HOME with no .ai projects yields an empty list" { HOME="$WORK/empty-home" mkdir -p "$HOME/code/plain" build_candidates || true [ "${#candidates[@]}" -eq 0 ] } # --- functional: tmux-coupled pipeline (private socket) ----------------------- @test "functional create_window: adds a named window and returns its id" { export AGENT_CMD="true" tmux new-session -d -s ai -n base -c "$WORK" run create_window "$WORK" "proj-x" [ "$status" -eq 0 ] [ -n "$output" ] tmux list-windows -t ai -F '#{window_name}' | grep -qx proj-x } @test "functional find_window_id: returns the id for a name, empty for a miss" { tmux new-session -d -s ai -n only -c "$WORK" run find_window_id only [ -n "$output" ] run find_window_id nope [ -z "$output" ] } @test "functional sort_windows: others alpha-first, then projects alpha" { HOME="$WORK/home" mkdir -p "$HOME/code/alpha/.ai" "$HOME/code/beta/.ai" touch "$HOME/code/alpha/.ai/protocols.org" "$HOME/code/beta/.ai/protocols.org" tmux new-session -d -s ai -n zzz-other -c "$WORK" tmux new-window -t ai -n beta tmux new-window -t ai -n alpha tmux new-window -t ai -n aaa-other run sort_windows [ "$status" -eq 0 ] order="$(tmux list-windows -t ai -F '#{window_name}' | paste -sd, -)" [ "$order" = "aaa-other,zzz-other,alpha,beta" ] } @test "functional attach_mode: no session prints an error and exits 1" { run bash "$AI" --attach [ "$status" -eq 1 ] [[ "$output" == *"no 'ai' session"* ]] } # --- extracted pure cores ----------------------------------------------------- @test "_git_prep_action: no upstream is always 'none'" { run _git_prep_action 0 0 0 5 [ "$output" = none ] } @test "_git_prep_action: clean and purely behind is 'pull'" { run _git_prep_action 1 0 0 3 [ "$output" = pull ] } @test "_git_prep_action: in sync with upstream is 'none'" { run _git_prep_action 1 0 0 0 [ "$output" = none ] } @test "_git_prep_action: ahead is 'report', never 'pull'" { run _git_prep_action 1 0 2 0 [ "$output" = report ] } @test "_git_prep_action: dirty is 'report', never 'pull'" { run _git_prep_action 1 1 0 0 [ "$output" = report ] } @test "_git_prep_action: behind AND dirty is 'report' — a dirty repo is never auto-pulled" { run _git_prep_action 1 1 0 3 [ "$output" = report ] } @test "_git_prep_action: diverged (ahead and behind) is 'report'" { run _git_prep_action 1 0 2 3 [ "$output" = report ] } @test "_order_windows: others alpha, then projects alpha" { local listing names out listing="$(printf 'beta\t@1\nzzz-other\t@2\nalpha\t@3\naaa-other\t@4')" names="$(printf 'alpha\nbeta')" out="$(printf '%s\n' "$listing" | _order_windows "$names" | cut -f1 | paste -sd, -)" [ "$out" = "aaa-other,zzz-other,alpha,beta" ] } @test "_order_windows: all-projects input keeps only the projects, sorted" { local out out="$(printf 'beta\t@1\nalpha\t@2\n' | _order_windows "$(printf 'alpha\nbeta')" | cut -f1 | paste -sd, -)" [ "$out" = "alpha,beta" ] } @test "_order_windows: empty input yields empty output" { run _order_windows "$(printf 'alpha\nbeta')" <<<"" [ -z "$output" ] } @test "_order_windows: a name matches only as a whole line, not as a substring" { # 'alph' must not match project 'alpha' (grep -qxF is a full-line match). local out out="$(printf 'alph\t@1\n' | _order_windows "$(printf 'alpha')" | cut -f1)" # 'alph' is not a project, so it lands in others, still present. [ "$out" = "alph" ] } @test "_match_window_id: returns the id for a name, empty for a miss" { local listing out listing="$(printf 'one\t@1\ntwo\t@2\n')" out="$(printf '%s\n' "$listing" | _match_window_id two)" [ "$out" = "@2" ] out="$(printf '%s\n' "$listing" | _match_window_id nope)" [ -z "$out" ] } @test "_match_window_id: first match wins and stops" { local out out="$(printf 'dup\t@1\ndup\t@2\n' | _match_window_id dup)" [ "$out" = "@1" ] }