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. --- claude-templates/bin/ai | 125 ++++++------ scripts/tests/ai-launcher-characterization.bats | 245 ++++++++++++++++++++++++ 2 files changed, 314 insertions(+), 56 deletions(-) create mode 100644 scripts/tests/ai-launcher-characterization.bats diff --git a/claude-templates/bin/ai b/claude-templates/bin/ai index cf17875..7ce4337 100755 --- a/claude-templates/bin/ai +++ b/claude-templates/bin/ai @@ -138,6 +138,9 @@ create_window() { # Add a directory to candidates only if it's a Claude-template project. maybe_add_candidate() { local dir="$1" + # The "~/" is a deliberate literal display prefix, re-expanded downstream via + # ${c/#\~/$HOME}; it must not expand here, so SC2088 doesn't apply. + # shellcheck disable=SC2088 [ -f "$dir/.ai/protocols.org" ] && candidates+=("~/${dir#"$HOME"/}") } @@ -179,7 +182,7 @@ git_status_indicator() { local dir="$1" upstream ahead=0 behind=0 parts=() [ -d "$dir/.git" ] || return 0 - upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || true) + upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>/dev/null || true) if [ -n "$upstream" ]; then ahead=$(git -C "$dir" rev-list --count "$upstream..HEAD" 2>/dev/null || echo 0) behind=$(git -C "$dir" rev-list --count "HEAD..$upstream" 2>/dev/null || echo 0) @@ -225,7 +228,7 @@ auto_pull_if_clean() { return 0 fi - upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || true) + upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>/dev/null || true) [ -z "$upstream" ] && return 0 ahead=$(git -C "$dir" rev-list --count "$upstream..HEAD" 2>/dev/null || echo 0) @@ -312,7 +315,7 @@ prep_git_single() { fi [ "$fetch_stale" -eq 1 ] && git -C "$dir" fetch --quiet 2>/dev/null || true - upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || true) + upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>/dev/null || true) [ -z "$upstream" ] && return 0 ahead=$(git -C "$dir" rev-list --count "$upstream..HEAD" 2>/dev/null || echo 0) @@ -478,63 +481,73 @@ print_launch_mode() { # ---------- dispatch ---------- -print_launch="" -runtime_explicit="${AI_RUNTIME:+1}" -while [ $# -gt 0 ]; do - case "$1" in - -h|--help) - usage - ;; - --runtime) - [ -z "${2:-}" ] && { echo "ai: --runtime needs a value — valid runtimes: claude, codex, local" >&2; exit 2; } - RUNTIME="$2" - runtime_explicit=1 - shift 2 - ;; - --runtime=*) - RUNTIME="${1#--runtime=}" - runtime_explicit=1 - shift - ;; - --print-launch) - print_launch=1 - shift +# Argument parsing + mode dispatch. Wrapped so the file can be sourced (by the +# launcher's bats tests) to exercise individual functions without running a +# real launch. When executed as a program, BASH_SOURCE[0] equals $0 and the +# dispatch runs exactly as before; when sourced, it's skipped. +main() { + print_launch="" + runtime_explicit="${AI_RUNTIME:+1}" + while [ $# -gt 0 ]; do + case "$1" in + -h|--help) + usage + ;; + --runtime) + [ -z "${2:-}" ] && { echo "ai: --runtime needs a value — valid runtimes: claude, codex, local" >&2; exit 2; } + RUNTIME="$2" + runtime_explicit=1 + shift 2 + ;; + --runtime=*) + RUNTIME="${1#--runtime=}" + runtime_explicit=1 + shift + ;; + --print-launch) + print_launch=1 + shift + ;; + --print-runtimes) + build_runtime_choices + exit 0 + ;; + *) + break + ;; + esac + done + + resolve_agent_cmd + + if [ -n "$print_launch" ]; then + [ $# -eq 0 ] && { echo "ai: --print-launch needs a project directory" >&2; exit 2; } + print_launch_mode "$1" + fi + + case "${1:-}" in + --attach) + check_deps + attach_mode ;; - --print-runtimes) - build_runtime_choices - exit 0 + "") + # Bare `ai`: pick the agent first (skipped when --runtime or AI_RUNTIME + # already chose), then the familiar project multi-select. + if [ -z "$runtime_explicit" ]; then + pick_runtime || exit 0 + fi + check_deps + multi_mode ;; *) - break + check_deps + for arg in "$@"; do + single_mode "$arg" + done ;; esac -done - -resolve_agent_cmd +} -if [ -n "$print_launch" ]; then - [ $# -eq 0 ] && { echo "ai: --print-launch needs a project directory" >&2; exit 2; } - print_launch_mode "$1" +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + main "$@" fi - -case "${1:-}" in - --attach) - check_deps - attach_mode - ;; - "") - # Bare `ai`: pick the agent first (skipped when --runtime or AI_RUNTIME - # already chose), then the familiar project multi-select. - if [ -z "$runtime_explicit" ]; then - pick_runtime || exit 0 - fi - check_deps - multi_mode - ;; - *) - check_deps - for arg in "$@"; do - single_mode "$arg" - done - ;; -esac 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