From 113e8d868fdd95b269c0cc2ce4ec265aa76b539c Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 19 Jul 2026 20:40:51 -0500 Subject: test(ai-launcher): net current behavior and make bin/ai source-testable Brings the session launcher under characterization tests ahead of the hardening refactor, so the pure-core extractions and footgun fixes land against a green net. The tmux/git-coupled functions can't be reached by running the launcher black-box, so I wrapped dispatch in main() behind a run-vs-sourced guard: run directly it dispatches as before, sourced it exposes the functions to the tests. The net pins current behavior (record-not-spec) with a Normal/Boundary/Error set per unit: git_status_indicator across no-upstream/ahead/behind/dirty/clean, candidate discovery and filtering, selection parsing, and functional tests over create_window, find_window_id, and sort_windows ordering against a throwaway tmux server. Every tmux call runs on a private socket so nothing touches the live ai session, and the throwaway repos disable git auto-maintenance to dodge the teardown race that flaked rename-ai. Also cleared the two pre-existing shellcheck warnings on the touched paths: quoted the "@{u}" revspecs (SC1083) and marked the intentionally-literal display tilde (SC2088). Both are behavior-neutral, and the existing runtime tests confirm the launch path is unchanged. --- scripts/tests/ai-launcher-characterization.bats | 245 ++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 scripts/tests/ai-launcher-characterization.bats (limited to 'scripts/tests') diff --git a/scripts/tests/ai-launcher-characterization.bats b/scripts/tests/ai-launcher-characterization.bats new file mode 100644 index 0000000..773181d --- /dev/null +++ b/scripts/tests/ai-launcher-characterization.bats @@ -0,0 +1,245 @@ +#!/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"* ]] +} -- cgit v1.2.3