Skip to content

Commit 1329d39

Browse files
yasinBursaliclaude
andcommitted
test(dream-cli): BATS coverage for compose wrapper + set -u/pipefail guards
test-compose-summary-wrapper.bats pins _compose_run_with_summary success/error paths + zero-grep-match fallback + exit-code propagation (#406). test-dream-cli-flags.bats pins dream-cli shell-flag hygiene: set -euo pipefail at line 6, nounset safety under minimal env, static assertions on conditional-var syntax (#410). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ef27258 commit 1329d39

2 files changed

Lines changed: 315 additions & 0 deletions

File tree

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
#!/usr/bin/env bats
2+
# ============================================================================
3+
# BATS tests for _compose_run_with_summary in dream-cli.
4+
# ============================================================================
5+
# Guards against re-breakage of PR #406 (compose wrapper surfaces a compact
6+
# summary on success, an error banner + grep'd keywords + log path on
7+
# failure, and propagates the compose exit code so `dream restart/stop/start`
8+
# aborts under `set -e`).
9+
#
10+
# Test strategy:
11+
# - Extract _compose_run_with_summary + its helper (log/success/warn/
12+
# log_error) text from dream-cli.
13+
# - PATH-inject a `docker` stub that can be told to exit 0 / exit 1 with
14+
# error-keyword output / exit 1 with no-keyword output.
15+
# - Run the wrapper and assert on printed output + return code.
16+
17+
load '../bats/bats-support/load'
18+
load '../bats/bats-assert/load'
19+
20+
setup() {
21+
export TMPDIR_TEST="$BATS_TEST_TMPDIR"
22+
mkdir -p "$TMPDIR_TEST/bin"
23+
24+
# Color vars used by log/success/warn/log_error — stub to empty so
25+
# assert_output matches plain strings without escape sequences.
26+
export RED="" GREEN="" YELLOW="" BLUE="" CYAN="" NC=""
27+
28+
# Minimal logger helpers that match dream-cli's line 45–50 definitions.
29+
# We redefine rather than extract because (a) they're trivial and (b)
30+
# dream-cli's real definitions pull in color vars we've already stubbed.
31+
log() { echo "[dream] $1"; }
32+
success() { echo "$1"; }
33+
warn() { echo "$1"; }
34+
log_error() { echo "$1" >&2; }
35+
export -f log success warn log_error
36+
37+
# Extract _compose_run_with_summary from dream-cli.
38+
local _cli="$BATS_TEST_DIRNAME/../../dream-cli"
39+
eval "$(awk '/^_compose_run_with_summary\(\) \{/,/^\}$/' "$_cli")"
40+
41+
# docker stub — behavior driven by DOCKER_STUB_MODE env var:
42+
# success → exit 0 with benign stdout
43+
# fail-keyword→ exit 1, stdout contains 'error', 'failed', 'unhealthy', 'dependency'
44+
# fail-nomatch→ exit 1, stdout has no error keywords
45+
cat > "$TMPDIR_TEST/bin/docker" <<'STUB'
46+
#!/usr/bin/env bash
47+
echo "DOCKER_ARGS: $*" >> "$DOCKER_CALL_LOG"
48+
case "${DOCKER_STUB_MODE:-success}" in
49+
success)
50+
echo "Network dream-net created"
51+
echo "Container dream-foo started"
52+
exit 0
53+
;;
54+
fail-keyword)
55+
echo "Container dream-foo starting"
56+
echo "Error response from daemon: service dream-foo failed to start"
57+
echo "unhealthy container dream-bar"
58+
echo "dependency dream-baz not ready"
59+
exit 1
60+
;;
61+
fail-nomatch)
62+
echo "Container dream-foo starting"
63+
echo "network busy (try again later)"
64+
exit 1
65+
;;
66+
*)
67+
echo "unknown DOCKER_STUB_MODE: $DOCKER_STUB_MODE" >&2
68+
exit 127
69+
;;
70+
esac
71+
STUB
72+
chmod +x "$TMPDIR_TEST/bin/docker"
73+
export PATH="$TMPDIR_TEST/bin:$PATH"
74+
export DOCKER_CALL_LOG="$TMPDIR_TEST/docker.log"
75+
: > "$DOCKER_CALL_LOG"
76+
}
77+
78+
teardown() {
79+
rm -rf "$TMPDIR_TEST/bin" "$TMPDIR_TEST/docker.log"
80+
}
81+
82+
# ── success path ────────────────────────────────────────────────────────────
83+
84+
@test "wrapper: success prints compact '<verb> — done' banner and returns 0" {
85+
export DOCKER_STUB_MODE=success
86+
run _compose_run_with_summary "Restarting all services" up -d
87+
assert_success
88+
assert_output --partial "Restarting all services..."
89+
assert_output --partial "Restarting all services — done"
90+
}
91+
92+
@test "wrapper: success removes the compose log tmpfile" {
93+
export DOCKER_STUB_MODE=success
94+
# Track how many mktemp-produced files exist before & after. We rely on
95+
# $TMPDIR being the system tmp — the wrapper uses `mktemp` with no args.
96+
local before=$(find /tmp -maxdepth 1 -name 'tmp.*' -type f 2>/dev/null | wc -l)
97+
run _compose_run_with_summary "Starting all services" up -d
98+
assert_success
99+
local after=$(find /tmp -maxdepth 1 -name 'tmp.*' -type f 2>/dev/null | wc -l)
100+
# Zero net new temp files (create + rm).
101+
[ "$after" -le "$before" ]
102+
}
103+
104+
@test "wrapper: success does NOT print error banner or log path" {
105+
export DOCKER_STUB_MODE=success
106+
run _compose_run_with_summary "Starting all services" up -d
107+
assert_success
108+
refute_output --partial "Full compose output:"
109+
refute_output --partial "failed:"
110+
}
111+
112+
# ── failure path, matching keywords (PR #406) ───────────────────────────────
113+
114+
@test "wrapper: failure prints error banner, matched lines, and log path" {
115+
export DOCKER_STUB_MODE=fail-keyword
116+
run _compose_run_with_summary "Restarting service x" up -d x
117+
assert_failure
118+
assert_output --partial "Restarting service x failed:"
119+
# At least one error-keyword line should be surfaced (indented two spaces).
120+
assert_output --partial "Error response from daemon"
121+
assert_output --partial "Full compose output:"
122+
}
123+
124+
@test "wrapper: failure propagates the compose exit code (1)" {
125+
export DOCKER_STUB_MODE=fail-keyword
126+
run _compose_run_with_summary "Restarting" up -d
127+
# docker stub exits 1; wrapper must return 1.
128+
[ "$status" -eq 1 ]
129+
}
130+
131+
@test "wrapper: failure preserves the compose log file (not auto-removed)" {
132+
export DOCKER_STUB_MODE=fail-keyword
133+
run _compose_run_with_summary "Restarting" up -d
134+
assert_failure
135+
# Extract the "Full compose output: /tmp/tmp.XXXXXX" path from output.
136+
local log_line
137+
log_line=$(printf '%s\n' "$output" | grep -E 'Full compose output:' | head -1)
138+
[ -n "$log_line" ]
139+
local log_path="${log_line##*: }"
140+
[ -f "$log_path" ]
141+
rm -f "$log_path"
142+
}
143+
144+
# ── failure path, zero keyword matches (nounset/pipefail-hardening) ─────────
145+
146+
# These two tests must execute the wrapper under `set -euo pipefail` because
147+
# the `|| warn "(no error keywords matched...)"` branch only fires when
148+
# grep's exit-1 propagates through the pipeline — and only pipefail makes
149+
# grep the pipeline's final exit code. dream-cli line 6 IS `set -euo pipefail`
150+
# in production; bats' setup() doesn't inherit that, so we set it explicitly
151+
# via the subshell.
152+
153+
@test "wrapper: failure with no keyword match fires the warn fallback (under pipefail)" {
154+
export DOCKER_STUB_MODE=fail-nomatch
155+
# Re-extract the wrapper inside a pipefail-enabled subshell.
156+
run bash -c '
157+
set -euo pipefail
158+
RED="" GREEN="" YELLOW="" BLUE="" CYAN="" NC=""
159+
log() { echo "[dream] $1"; }
160+
success() { echo "✓ $1"; }
161+
warn() { echo "⚠ $1"; }
162+
log_error() { echo "✗ $1" >&2; }
163+
eval "$(awk "/^_compose_run_with_summary\(\) \{/,/^\}$/" "'"$BATS_TEST_DIRNAME/../../dream-cli"'")"
164+
_compose_run_with_summary "Stopping service y" down y
165+
'
166+
assert_failure
167+
assert_output --partial "Stopping service y failed:"
168+
assert_output --partial "(no error keywords matched in compose log)"
169+
assert_output --partial "Full compose output:"
170+
}
171+
172+
@test "wrapper: failure with no keyword match still propagates exit code" {
173+
export DOCKER_STUB_MODE=fail-nomatch
174+
run bash -c '
175+
set -euo pipefail
176+
RED="" GREEN="" YELLOW="" BLUE="" CYAN="" NC=""
177+
log() { echo "[dream] $1"; }
178+
success() { echo "✓ $1"; }
179+
warn() { echo "⚠ $1"; }
180+
log_error() { echo "✗ $1" >&2; }
181+
eval "$(awk "/^_compose_run_with_summary\(\) \{/,/^\}$/" "'"$BATS_TEST_DIRNAME/../../dream-cli"'")"
182+
_compose_run_with_summary "Stopping" down
183+
'
184+
[ "$status" -eq 1 ]
185+
}
186+
187+
# ── docker compose args passthrough ─────────────────────────────────────────
188+
189+
@test "wrapper: all args after verb are passed to docker compose" {
190+
export DOCKER_STUB_MODE=success
191+
run _compose_run_with_summary "Up" -f extra.yml up -d my-service
192+
assert_success
193+
# Stub records its argv to DOCKER_CALL_LOG; first arg must be `compose`
194+
# because the wrapper calls `docker compose <args>`.
195+
run cat "$DOCKER_CALL_LOG"
196+
assert_output --partial "DOCKER_ARGS: compose --progress quiet -f extra.yml up -d my-service"
197+
}
198+
199+
# ── propagation into callers (cmd_restart/cmd_stop/cmd_start) ───────────────
200+
201+
@test "wrapper: caller returns wrapper's non-zero exit (smoke)" {
202+
export DOCKER_STUB_MODE=fail-keyword
203+
# Simulate `cmd_restart`: wrapper is the last statement, so its exit
204+
# code becomes the function's exit code.
205+
_simulated_caller() {
206+
_compose_run_with_summary "Restarting all services" up -d
207+
}
208+
run _simulated_caller
209+
[ "$status" -eq 1 ]
210+
}
211+
212+
@test "wrapper: caller returns 0 on success (smoke)" {
213+
export DOCKER_STUB_MODE=success
214+
_simulated_caller() {
215+
_compose_run_with_summary "Restarting all services" up -d
216+
}
217+
run _simulated_caller
218+
[ "$status" -eq 0 ]
219+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
#!/usr/bin/env bats
2+
# ============================================================================
3+
# BATS tests for dream-cli's shell-flag hygiene.
4+
# ============================================================================
5+
# Guards against re-breakage of:
6+
# - PR #410 / nounset audit: line 6 must be `set -euo pipefail`
7+
# - Pipefail SIGPIPE audit: `sed -n '1p'` must be used (not `head -1`)
8+
# - Nounset audit: minimal-env invocations must not crash
9+
# on undefined variables
10+
#
11+
# These are static-assertion tests (grep, sed) plus one subprocess invocation
12+
# under `env -i` to catch any bare `${FOO}` that sneaks back in.
13+
14+
load '../bats/bats-support/load'
15+
load '../bats/bats-assert/load'
16+
17+
setup() {
18+
export CLI="$BATS_TEST_DIRNAME/../../dream-cli"
19+
}
20+
21+
# ── line 6 shell-mode assertion (PR #410) ───────────────────────────────────
22+
23+
@test "flags: line 6 is exactly 'set -euo pipefail'" {
24+
run sed -n '6p' "$CLI"
25+
assert_success
26+
assert_output "set -euo pipefail"
27+
}
28+
29+
@test "flags: no weaker shell-mode line exists anywhere in dream-cli" {
30+
# If someone accidentally re-introduces `set -eo pipefail` (dropping
31+
# nounset) or bare `set -e`, this catches it.
32+
run grep -nE '^set -e[^u]' "$CLI"
33+
# grep returning 1 means no match — that's what we want.
34+
assert_failure
35+
}
36+
37+
# ── sed -n '1p' replacement for `head -1` (pipefail SIGPIPE audit) ──────────
38+
39+
@test "flags: no bare '| head -1' pipelines in dream-cli" {
40+
# `| head -1` under `set -o pipefail` can SIGPIPE the upstream command
41+
# and abort. Project-blessed replacement is `| sed -n '1p'`.
42+
run grep -nE '\| head -1([^0-9]|$)' "$CLI"
43+
assert_failure
44+
}
45+
46+
@test "flags: sed -n '1p' idiom is present where bootstrap + preset code path needs it" {
47+
# Just assert the idiom is used — the exact count can drift with
48+
# refactors, but it must be >0 to prove the replacement happened.
49+
run grep -cE "sed -n '1p'" "$CLI"
50+
assert_success
51+
[ "$output" -gt 0 ]
52+
}
53+
54+
# ── nounset syntax: conditional var references use ${FOO:-default} ──────────
55+
56+
@test "flags: conditional var references follow \${FOO:-default} form" {
57+
# The nounset audit (PR #11) converted many bare `${VAR}` refs that
58+
# fire only on optional/env-dependent paths to `${VAR:-}` / `${VAR:-default}`.
59+
# Assert that the characteristic pattern is widely present; >20 occurrences
60+
# confirm the audit changes are still in place.
61+
run grep -cE '\$\{[A-Za-z_][A-Za-z_0-9]*:-' "$CLI"
62+
assert_success
63+
[ "$output" -gt 20 ]
64+
}
65+
66+
# ── end-to-end: dream-cli doesn't crash on nounset with minimal env ─────────
67+
68+
@test "flags: --version runs under minimal env without unbound-var crash" {
69+
# Strip the environment down to the bare minimum. If dream-cli references
70+
# any var without `:-` on the --version / --help code path, `set -u`
71+
# aborts here and this test fails.
72+
run env -i \
73+
HOME="/tmp" \
74+
PATH="/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/Volumes/X/homebrew/bin" \
75+
TERM="dumb" \
76+
bash "$CLI" --version
77+
# --version exits 0 or reports a help summary; either way it must not
78+
# crash with "unbound variable".
79+
refute_output --partial "unbound variable"
80+
}
81+
82+
@test "flags: help runs under minimal env without unbound-var crash" {
83+
run env -i \
84+
HOME="/tmp" \
85+
PATH="/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/Volumes/X/homebrew/bin" \
86+
TERM="dumb" \
87+
bash "$CLI" help
88+
refute_output --partial "unbound variable"
89+
}
90+
91+
# ── script parses under bash -n ─────────────────────────────────────────────
92+
93+
@test "flags: dream-cli passes bash -n (syntax check)" {
94+
run bash -n "$CLI"
95+
assert_success
96+
}

0 commit comments

Comments
 (0)