aboutsummaryrefslogtreecommitdiff
path: root/scripts/tests/ai-launcher-characterization.bats
blob: 773181dd170b38956b82370d89bde0930eec9c35 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
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"* ]]
}