Files
cozystack/hack/bats-no-exit-trap.bats
Myasnikov Daniil cc942fed68 test(tests): run unit suites with Bats and retain POSIX coverage
The shell translator rewrites test bodies and loses Bats reporting.
The unit lane needs complete TAP results and JUnit reports without
changing how live-cluster suites run.

Sourced production helpers still need a dash pass, and unit test bodies
need nounset without exporting shell options into subprocesses.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-10-07 11:04:00 +03:00

703 lines
29 KiB
Bash

#!/usr/bin/env bats
# -----------------------------------------------------------------------------
# The ban on EXIT-trap cleanup in hack/*.bats, and the debt each file that still
# breaks it declares for itself.
#
# Why the ban. An EXIT trap inside an @test body replaces the one the bats binary
# installs for its own bookkeeping, and a test that then FAILS prints no TAP line
# at all. Not `not ok` -- nothing. The run ends with a warning that it executed
# fewer tests than it planned, so anyone reading the tail of the output, or
# grepping it for `not ok`, sees a green suite. Not hypothetical:
# hack/promote-retag_test.bats reported five passes and no failures while six of
# its eleven tests printed nothing. Clean up at the end of the test body instead.
# Both runners set -e, so on failure the cleanup is unreachable and the scratch
# directory survives for inspection, which is what a failed test wants anyway.
# Fuller reasoning, and the two exceptions -- a self-contained trap inside a
# Chainsaw `script` step, and one inside an explicit subshell -- are in
# docs/agents/e2e-testing.md.
#
# The debt belongs to the file that owes it, and not to a central inventory: put
# the count somewhere else and a change that adds a trap has to edit a suite it
# otherwise never opens, so two changes sharing no line invalidate each other --
# both green against their own base, both merging cleanly, red only once the
# second lands.
#
# The declaration is one comment line in the file's leading comment block, under
# the shebang and above the first line of code:
#
# # EXIT-TRAP DEBT: 12
#
# There and not merely somewhere, because a .bats file is shell that writes
# shell: the same line inside a heredoc, a fixture or an expected-output string
# is data belonging to one test, and honouring it there would let an unrelated
# fixture excuse a real trap. The leading comment block rather than everything
# above the first @test, because helper functions and their heredocs sit between
# the two; a comment block is the region with no interior.
#
# It is exact, not a ceiling. Adding a trap fails; removing one fails too, and
# the fix is to lower the number, or delete the line once it reaches zero. A file
# with no declaration must hold no traps, which covers the converted files, the
# files that never had one, and every file added tomorrow.
#
# NOT every counted handler is debt. A trap inside an explicit subshell does not
# replace the bats binary's own, so a test that fails inside `( ... )` still
# prints its `not ok` -- verified under Bats 1.14.0 against a test-level trap in
# the same file, where the TAP line vanishes. hack/e2e-test-openapi.bats holds
# one: it kills a background `kubectl proxy` from a trap inside a subshell, and
# moving that to the end of the body would leak the process on failure instead of
# suppressing a report. Its declaration says so, and because the ratchet is
# exact, REMOVING that trap fails too -- the count protects the construct rather
# than scheduling it for deletion.
#
# The limits are worth stating rather than discovering. The scan is lexical: a
# signal computed at runtime (`trap cleanup "$sig"`) is invisible, and so is a
# handler whose quoted action spans physical lines without a backslash, since
# only backslash continuations are folded. In the other direction the word is
# counted where no command exists -- inside a heredoc, or in a string a test
# compares against -- which inflates a file's debt rather than hiding one, and is
# why the fixture writers below assemble the keyword and the signal from separate
# arguments. `trap - EXIT`, which DISARMS a handler, scores as an install for the
# same inflating reason. An exact count catches addition and removal but never
# SUBSTITUTION: swap the openapi file's subshell trap for a test-level one and
# the total stays 1, the guard stays green, and that file's "this one is NOT
# debt" note quietly becomes false.
#
# The scan reads .bats and nothing else, so a handler reaching a test body from a
# sourced .sh is invisible to it. Not theoretical:
# hack/e2e-chainsaw/_lib/run-kubernetes.sh installs two, each benign for its own
# reason rather than by design -- cozy_capture_tenant_talos is declared with `(`
# and so runs in a subshell, and the diagnostics tests DO call it, so flipping
# its two delimiters to braces reinstates that handler in them with the guard
# green; run_kubernetes_test is declared with `{`, the shape that would bite, and
# is benign only because no @test calls it.
#
# The marker is anchored at column zero. That is NOT what protects the example
# above -- the nested `#` does, and relaxing the anchor leaves this file's own
# audit green. What the anchor buys is that a marker indented inside a function
# body or a heredoc somewhere else is not read as that file's declaration, and a
# file that indents its own reports "declares none" and fails loudly rather than
# passing quietly. Reflowing the example above to column zero would make the
# guard demand a debt of twelve from a file holding nothing.
#
# None of that makes the guard a proof that a file installs no handler; it is a
# ratchet over a known and common spelling. A suite reporting zero failures still
# has to have its `1..N` plan reconciled against its `ok` count, which is the
# check that catches whatever the pattern missed.
# -----------------------------------------------------------------------------
load test_helper
# The directory to audit. Under the bats binary that is this file's own
# location; under cozytest.sh, which does not set BATS_TEST_FILENAME, `$0` is
# the runner itself -- and the answer comes out the same only because the runner
# lives in the directory it runs files from. The first test below refuses to
# pass on an empty enumeration, so a wrong answer here fails rather than audits
# nothing.
BATS_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME:-$0}")" && pwd)"
# Print a file with comment lines dropped and shell line continuations folded, so
# the count below matches a COMMAND rather than a physical line. `trap 'rm -rf
# "$tmp"' \` with `EXIT` on the next line is a working EXIT trap that no
# single-line pattern sees, and in shell that is where the argument usually sits.
bnet_fold() {
grep -v '^[[:space:]]*#' "$1" \
| awk '{ line = line $0
if (line ~ /\\$/) { sub(/\\$/, " ", line); next }
print line; line = "" }
END { if (line != "") print line }'
}
# What an installed EXIT handler looks like.
#
# `0` as well as `EXIT`: POSIX names the exit condition signal 0, and both shells
# install the identical handler for it, so a pattern that knows only the spelling
# it has seen tests the spelling and not the construct. `EXIT` anywhere in the
# signal list rather than only as its last word: `trap ... EXIT HUP` installs the
# same exit handler plus one more, and anchoring to end-of-line accepts it. A
# quote around the signal, because `trap cleanup 'EXIT'` installs exactly what
# `trap cleanup EXIT` installs -- the quote is punctuation, not a different
# construct. The quote is optional around the SIGNAL only and never after the
# keyword, so `'trap'` as a shell argument stays unmatched; the fixture writers
# below depend on that to keep their own test data from reading as real traps.
#
# The signal ends at any character that cannot continue an identifier, not at
# whitespace. `trap ... EXIT; cd "$tmp"` and `(trap ... EXIT)` are both real
# handlers with punctuation straight after the word, and a terminator demanding
# whitespace or end-of-line scores them zero -- guard green, no declaration
# asked for. Excluding the identifier characters is what keeps `EXIT_CODE` from
# matching, which is the reason the narrower terminator looked right.
#
# The signal matches in either case, because the shell accepts it in either
# case: `trap ... exit` installs the handler `trap ... EXIT` installs, in bash
# and in dash alike. The folding is spelled into the alternation rather than
# running the whole pattern case-insensitively, since `trap` is a builtin name
# and `TRAP ... EXIT` is not a command anyone installs.
#
# The keyword needs its left boundary as much as the signal needs its right
# one, and for the same reason. `bootstrap ` ends in `trap `, so once the
# terminator accepts punctuation, `talosctl bootstrap -n ... ; do sleep 0.5`
# scores as a handler on the `0`. That is loud rather than silent, but not
# harmless: the documented answer to a red guard is to add a debt line, so a
# file would buy a permanent licence for one real trap in order to quiet a line
# that has none.
#
# Both boundaries are therefore spelled the same way -- any character that
# cannot be part of an identifier -- and NOT as whitespace. Whitespace on the
# left drops `tmp=$(mktemp -d);trap ... EXIT` and `(trap ... EXIT; true)`, which
# are the banned construct behind a semicolon and a paren. The first of those is
# the sharper lesson: the inventory this replaces carried no left boundary at
# all and caught it, so a boundary chosen carelessly here would have narrowed
# coverage while the commit message claimed to widen it.
BNET_TRAP_RE="(^|[^A-Za-z0-9_])trap[[:space:]].*[[:space:]][\"']?([Ee][Xx][Ii][Tt]|0)[\"']?([^A-Za-z0-9_]|\$)"
# How many EXIT traps a file installs.
#
# `|| true` because grep -c exits 1 on a count of zero, which is the answer for
# most of the tree; without it the caller dies under set -e on the first clean
# file and never reaches the comparison it exists for.
bnet_count() {
bnet_fold "$1" | grep -cE "$BNET_TRAP_RE" || true
}
# How many lines install more than one EXIT handler.
#
# bnet_count counts matching LINES, so `trap a EXIT; trap b EXIT` counts as one
# and a handler appended to a line that already carries one arrives without
# moving the file's total. Deciding where one command ends and the next begins
# needs a shell parser -- a semicolon inside a handler's own quoted action is not
# a separator -- and getting that wrong UNDERCOUNTS, which is the one direction a
# ratchet cannot afford. So refuse the ambiguous line instead of guessing at it.
# One handler per line is what every file in the tree already does.
bnet_ambiguous() {
bnet_fold "$1" | grep -E "$BNET_TRAP_RE" \
| awk '{ if (gsub(/(^|[^A-Za-z0-9_])trap[[:space:]]/, "&") > 1) n++ } END { print n + 0 }'
}
# The part of a file the declaration may live in: the leading comment block,
# ending at the first line that is neither a comment nor blank.
#
# Traps are counted over the whole file; the declaration is read from the header
# alone. A .bats file is shell that writes shell -- fixture writers, heredocs of
# expected output, assertion strings -- and the same line appearing in any of
# those is data belonging to one test, not a statement about the file. Reading it
# there would let an unrelated fixture excuse a real trap, and would do it
# silently, because nothing in that test's own diff looks like a declaration.
#
# The leading comment block rather than everything above the first @test: helper
# functions and their heredocs sit between the two, so stopping at the first
# @test would still read a line out of one. A comment block cannot contain a
# heredoc, which makes the narrower rule the one with no interior. It is also
# where a reviewer opening the file lands, which is the reason the declaration
# moved out of the central inventory in the first place.
bnet_header() {
awk '/^[[:space:]]*#/ || /^[[:space:]]*$/ { print; next } { exit }' "$1"
}
# Report every file in directory $1 whose traps and declaration disagree, one
# line each, and print nothing when they all agree.
#
# Through stdout rather than an exit status: cozytest.sh rewrites a function's
# closing brace into `return 0`, so a status set in here would be discarded
# before the caller could read it, and the check would pass unconditionally.
bnet_audit() {
find "$1" -name '*.bats' | sort | while IFS= read -r _f; do
[ -e "$_f" ] || continue
# Path relative to the audited root, not the basename: the scan recurses,
# and hack/foo.bats and hack/e2e-apps/foo.bats would otherwise report under
# the same name.
_b=${_f#"$1"/}
_amb=$(bnet_ambiguous "$_f")
if [ "$_amb" -gt 0 ]; then
echo "$_b: $_amb line(s) name the trap keyword more than once; keep one handler per line, and the word out of trailing comments, so the count is exact"
continue
fi
_n=$(bnet_count "$_f")
_decls=$(bnet_header "$_f" | grep -c '^# EXIT-TRAP DEBT:' || true)
if [ "$_decls" -gt 1 ]; then
echo "$_b: $_decls debt declarations; a file carries one or none"
continue
fi
if [ "$_decls" -eq 0 ]; then
if [ "$_n" -ne 0 ]; then
echo "$_b: holds $_n EXIT trap(s) and declares none; clean up at the end of the test body instead, or -- if the line is data rather than a command -- split the keyword from the signal the way the fixtures below do"
fi
continue
fi
_d=$(bnet_header "$_f" | sed -n 's/^# EXIT-TRAP DEBT:[[:space:]]*\([0-9][0-9]*\).*/\1/p')
if [ -z "$_d" ]; then
echo "$_b: the debt declaration names no count"
elif [ "$_d" -eq 0 ]; then
echo "$_b: declares a debt of zero; delete the line instead"
elif [ "$_n" -ne "$_d" ]; then
echo "$_b: declares $_d EXIT trap(s), holds $_n"
fi
done
return 0
}
# Fixture writers for the tests below. The word and the signal are assembled from
# separate arguments on purpose: this file is scanned by the guard it defines, so
# a fixture spelled out as a literal would be counted as a real trap in it and
# the guard would demand a declaration covering its own test data.
#
# The same constraint reaches the test titles and the failure messages, which are
# code lines rather than comments and so are read as well. `bnet_add_handler`
# rather than `bnet_add_trap`, and "a handler naming EXIT" rather than "a trap
# naming EXIT", because either of the shorter forms puts the word and the signal
# on one line and is matched. Editing this file means reading its own report
# afterwards; the first test below is the one that says so.
bnet_new_fixture() {
_dir=$1
printf '%s\n' '#!/usr/bin/env bats' > "$_dir/subject.bats"
return 0
}
bnet_add_handler() {
printf "%s 'rm -rf \"\$tmp\"' %s\n" 'trap' "${2:-EXIT}" >> "$1/subject.bats"
return 0
}
bnet_add_folded_handler() {
printf "%s 'rm -rf \"\$tmp\"' \\\\\n%s\n" 'trap' 'EXIT' >> "$1/subject.bats"
return 0
}
bnet_add_commented_handler() {
printf "# %s 'rm -rf \"\$tmp\"' %s\n" 'trap' 'EXIT' >> "$1/subject.bats"
return 0
}
# Put a declaration where a real file puts it: in the leading comment block, just
# under the shebang, whatever the fixture has appended by now. Rebuilt around
# line 1 rather than inserted with `sed -i`, whose append syntax differs between
# the GNU and BSD versions this suite runs under.
bnet_add_debt() {
_f=$1/subject.bats
{ head -n 1 "$_f"; printf '%s %s\n' '# EXIT-TRAP DEBT:' "$2"; tail -n +2 "$_f"; } > "$_f.new"
mv "$_f.new" "$_f"
return 0
}
# Put a declaration past the header, where it must NOT be honoured.
bnet_append_debt() {
printf '%s %s\n' '# EXIT-TRAP DEBT:' "$2" >> "$1/subject.bats"
return 0
}
bnet_add_test_body() {
printf '%s\n' '@test "something" {' ' :' '}' >> "$1/subject.bats"
return 0
}
bnet_add_code() {
printf '%s\n' 'BATS_DIR=.' >> "$1/subject.bats"
return 0
}
bnet_add_word_containing_trap() {
printf " timeout 60 sh -ec 'until talosctl %s -n 192.0.2.11; do sleep 0.5; done'\n" \
'bootstrap' >> "$1/subject.bats"
return 0
}
bnet_add_indented_debt() {
printf ' %s %s\n' '# EXIT-TRAP DEBT:' "$2" >> "$1/subject.bats"
return 0
}
bnet_add_command_then_handler() {
printf "a=1;%s 'rm -rf \"\$a\"' %s\n" 'trap' 'EXIT' >> "$1/subject.bats"
return 0
}
bnet_add_handler_in_parens() {
printf "(%s 'kill 1' %s; true)\n" 'trap' 'EXIT' >> "$1/subject.bats"
return 0
}
bnet_add_handler_then_command() {
printf "%s 'rm -rf \"\$a\"' %s; cd /\n" 'trap' 'EXIT' >> "$1/subject.bats"
return 0
}
bnet_add_nested_fixture() {
mkdir -p "$1/nested"
printf '%s\n' '#!/usr/bin/env bats' > "$1/nested/deep.bats"
printf "%s 'rm -rf \"\$a\"' %s\n" 'trap' 'EXIT' >> "$1/nested/deep.bats"
return 0
}
bnet_add_two_handlers_on_one_line() {
printf "%s 'rm -f \"\$a\"' %s; %s 'rm -f \"\$b\"' %s\n" \
'trap' 'EXIT' 'trap' 'EXIT' >> "$1/subject.bats"
return 0
}
@test "every bats file under hack holds exactly the EXIT traps it declares" {
# The whole point of the guard, run against the tree it governs. Everything
# below this test exists to prove that this one can fail.
# Counted with the enumeration bnet_audit actually walks, not a second one:
# a backstop that asks `ls` whether the `find` found anything answers about
# the wrong list, and would pass while the audit went vacuously green. That
# is the failure this file exists to talk about, so it may as well not commit
# it.
files=$(find "$BATS_DIR" -name '*.bats' | wc -l)
[ "$files" -gt 0 ] || { echo "FAIL: found no .bats files to audit at all"; false; }
offences=$(bnet_audit "$BATS_DIR")
if [ -n "$offences" ]; then
echo "FAIL: EXIT-trap cleanup and the declared debt disagree."
printf '%s\n' "$offences"
echo "Clean up at the end of the test body, or correct the file's own"
echo "'# EXIT-TRAP DEBT:' line; see docs/agents/e2e-testing.md."
false
fi
}
@test "a trap with no declaration is reported" {
tmp=$(mktemp -d)
bnet_new_fixture "$tmp"
bnet_add_handler "$tmp"
out=$(bnet_audit "$tmp")
case "$out" in
*"subject.bats: holds 1 EXIT trap(s) and declares none"*) ;;
*) echo "FAIL: an undeclared trap went unreported: $out"; false ;;
esac
rm -rf "$tmp"
}
@test "a declaration matching the file is accepted" {
tmp=$(mktemp -d)
bnet_new_fixture "$tmp"
bnet_add_handler "$tmp"
bnet_add_handler "$tmp"
bnet_add_debt "$tmp" 2
out=$(bnet_audit "$tmp")
[ -z "$out" ] || { echo "FAIL: an accurate declaration was reported: $out"; false; }
rm -rf "$tmp"
}
@test "a declaration below the real count is reported" {
tmp=$(mktemp -d)
bnet_new_fixture "$tmp"
bnet_add_handler "$tmp"
bnet_add_handler "$tmp"
bnet_add_debt "$tmp" 1
out=$(bnet_audit "$tmp")
case "$out" in
*"subject.bats: declares 1 EXIT trap(s), holds 2"*) ;;
*) echo "FAIL: a grown trap count went unreported: $out"; false ;;
esac
rm -rf "$tmp"
}
@test "a declaration above the real count is reported" {
# The ratchet is exact rather than a ceiling. A file that sheds a trap has to
# say so, which is what keeps the number from rotting upward and quietly
# licensing traps somebody removed years earlier.
tmp=$(mktemp -d)
bnet_new_fixture "$tmp"
bnet_add_handler "$tmp"
bnet_add_debt "$tmp" 3
out=$(bnet_audit "$tmp")
case "$out" in
*"subject.bats: declares 3 EXIT trap(s), holds 1"*) ;;
*) echo "FAIL: a stale declaration went unreported: $out"; false ;;
esac
rm -rf "$tmp"
}
@test "a trap folded across a line continuation is counted" {
tmp=$(mktemp -d)
bnet_new_fixture "$tmp"
bnet_add_folded_handler "$tmp"
out=$(bnet_audit "$tmp")
case "$out" in
*"holds 1 EXIT trap(s) and declares none"*) ;;
*) echo "FAIL: a trap whose signal sits on the next line went uncounted: $out"; false ;;
esac
rm -rf "$tmp"
}
@test "a trap installed on signal zero is counted" {
tmp=$(mktemp -d)
bnet_new_fixture "$tmp"
bnet_add_handler "$tmp" 0
out=$(bnet_audit "$tmp")
case "$out" in
*"holds 1 EXIT trap(s) and declares none"*) ;;
*) echo "FAIL: the POSIX spelling of the exit condition went uncounted: $out"; false ;;
esac
rm -rf "$tmp"
}
@test "a handler naming EXIT ahead of another signal is counted" {
tmp=$(mktemp -d)
bnet_new_fixture "$tmp"
bnet_add_handler "$tmp" 'EXIT HUP'
out=$(bnet_audit "$tmp")
case "$out" in
*"holds 1 EXIT trap(s) and declares none"*) ;;
*) echo "FAIL: a handler listing EXIT before another signal went uncounted: $out"; false ;;
esac
rm -rf "$tmp"
}
@test "a trap inside a comment is not counted" {
# Every converted file explains the ban in prose next to the cleanup it
# replaced. A check that read the explanation as a violation would push people
# to delete the explanation, which is the part that stops the next person
# reinstating the trap.
tmp=$(mktemp -d)
bnet_new_fixture "$tmp"
bnet_add_commented_handler "$tmp"
out=$(bnet_audit "$tmp")
[ -z "$out" ] || { echo "FAIL: a trap named in a comment was counted: $out"; false; }
rm -rf "$tmp"
}
@test "a declared debt of zero is reported" {
# A file that owes nothing says so by carrying no line, not by carrying a line
# that says nothing. Otherwise a fully converted file keeps a declaration that
# reads as an allowance the next reader has no reason to question.
tmp=$(mktemp -d)
bnet_new_fixture "$tmp"
bnet_add_debt "$tmp" 0
out=$(bnet_audit "$tmp")
case "$out" in
*"subject.bats: declares a debt of zero"*) ;;
*) echo "FAIL: an empty declaration was accepted: $out"; false ;;
esac
rm -rf "$tmp"
}
@test "an indented declaration is not honoured" {
# The marker is anchored at column zero. Not, as an earlier version of this
# comment claimed, to protect the header's own `# EXIT-TRAP DEBT: 12` example
# -- that one is nested behind a second `#` and stays unmatched either way;
# relaxing the anchor leaves this file's audit green, which is how the claim
# was caught. What the anchor buys is that a marker indented inside a function
# body or a heredoc somewhere else is not read as that file's declaration.
tmp=$(mktemp -d)
bnet_new_fixture "$tmp"
bnet_add_indented_debt "$tmp" 1
bnet_add_handler "$tmp"
out=$(bnet_audit "$tmp")
case "$out" in
*"subject.bats: holds 1 EXIT trap(s) and declares none"*) ;;
*) echo "FAIL: an indented declaration was honoured: $out"; false ;;
esac
rm -rf "$tmp"
}
@test "a handler preceded by punctuation is counted" {
# The keyword's left boundary is any character that cannot be part of an
# identifier, not whitespace specifically. `tmp=$(mktemp -d);trap ... EXIT`
# and `(trap ... EXIT; true)` both sit straight behind punctuation, and both
# are the banned construct. Requiring a space would be a REGRESSION -- the
# inventory this replaces had no left boundary at all and caught the first of
# them -- so the two boundaries have to be spelled the same way round.
tmp=$(mktemp -d)
bnet_new_fixture "$tmp"
bnet_add_command_then_handler "$tmp"
bnet_add_handler_in_parens "$tmp"
out=$(bnet_audit "$tmp")
case "$out" in
*"holds 2 EXIT trap(s) and declares none"*) ;;
*) echo "FAIL: a handler behind punctuation went uncounted: $out"; false ;;
esac
rm -rf "$tmp"
}
@test "a word merely ending in trap is not counted" {
# `bootstrap ` contains `trap `, and once the terminator accepts punctuation
# the `0` in `sleep 0.5` closes the match: a talosctl bootstrap line scores as
# a handler. Loud, but not harmless -- the documented answer to a red guard is
# to add a debt line, so a file would take a permanent licence for one real
# trap to silence a line that has none. The keyword needs its left boundary
# for the same reason the signal needed its right one.
tmp=$(mktemp -d)
bnet_new_fixture "$tmp"
bnet_add_word_containing_trap "$tmp"
out=$(bnet_audit "$tmp")
[ -z "$out" ] || { echo "FAIL: a word ending in trap was counted: $out"; false; }
rm -rf "$tmp"
}
@test "a handler naming the signal in lower case is counted" {
# Signal names are case-insensitive to the shell: `trap ... exit` installs the
# same handler as `trap ... EXIT`, verified in both bash and dash. Only the
# SIGNAL is; `trap` itself is a builtin name and stays lower case, which is
# why the pattern folds case on the signal alternation rather than being run
# case-insensitively end to end.
tmp=$(mktemp -d)
bnet_new_fixture "$tmp"
bnet_add_handler "$tmp" exit
out=$(bnet_audit "$tmp")
case "$out" in
*"holds 1 EXIT trap(s) and declares none"*) ;;
*) echo "FAIL: a lower-case signal name went uncounted: $out"; false ;;
esac
rm -rf "$tmp"
}
@test "a handler followed by another command on the same line is counted" {
# The signal does not have to end the line. `trap ... EXIT; cd "$tmp"` and a
# handler wrapped in `( ... )` both put punctuation straight after the word,
# and a terminator that insists on whitespace or end-of-line silently scores
# them zero -- a real handler, no declaration needed, guard green. Same class
# as the quoted signal, one keystroke away.
tmp=$(mktemp -d)
bnet_new_fixture "$tmp"
bnet_add_handler_then_command "$tmp"
out=$(bnet_audit "$tmp")
case "$out" in
*"holds 1 EXIT trap(s) and declares none"*) ;;
*) echo "FAIL: a handler followed by a semicolon went uncounted: $out"; false ;;
esac
rm -rf "$tmp"
}
@test "a bats file in a subdirectory is audited too" {
# The ban covers every bats file under hack/. Two pre-Chainsaw suites once
# lived one directory down; this synthetic fixture pins recursion after their
# removal.
tmp=$(mktemp -d)
bnet_new_fixture "$tmp"
bnet_add_nested_fixture "$tmp"
out=$(bnet_audit "$tmp")
case "$out" in
*"nested/deep.bats: holds 1 EXIT trap(s) and declares none"*) ;;
*) echo "FAIL: a nested bats file went unaudited or lost its path: $out"; false ;;
esac
rm -rf "$tmp"
}
@test "a declaration naming no count is reported" {
# The parser takes digits. A declaration whose count is a word, or missing,
# parses to nothing, and an unguarded comparison would then treat the file as
# if it had said something.
tmp=$(mktemp -d)
bnet_new_fixture "$tmp"
bnet_add_handler "$tmp"
bnet_add_debt "$tmp" "twelve"
out=$(bnet_audit "$tmp")
case "$out" in
*"subject.bats: the debt declaration names no count"*) ;;
*) echo "FAIL: a countless declaration was accepted: $out"; false ;;
esac
rm -rf "$tmp"
}
@test "a handler whose signal is quoted is counted" {
# `trap cleanup 'EXIT'` installs the handler `trap cleanup EXIT` installs. A
# pattern that demands a bare word tests the punctuation rather than the
# construct, and the quoted form is one keystroke away from whoever wants the
# guard to look past them.
tmp=$(mktemp -d)
bnet_new_fixture "$tmp"
bnet_add_handler "$tmp" "'EXIT'"
out=$(bnet_audit "$tmp")
case "$out" in
*"holds 1 EXIT trap(s) and declares none"*) ;;
*) echo "FAIL: a quoted signal went uncounted: $out"; false ;;
esac
rm -rf "$tmp"
}
@test "two handlers sharing one line are reported rather than counted as one" {
# The count is a count of LINES, so a second handler appended to a line that
# already carries one arrives free: the file's total does not move and the
# declaration keeps matching. Rather than guess at command boundaries -- which
# needs a shell parser, and whose failure mode is an UNDERCOUNT, the direction
# this guard cannot afford -- refuse the ambiguous line and say so. One handler
# per line is what the tree already does.
tmp=$(mktemp -d)
bnet_new_fixture "$tmp"
bnet_add_two_handlers_on_one_line "$tmp"
out=$(bnet_audit "$tmp")
case "$out" in
*"subject.bats: 1 line(s) name the trap keyword more than once"*) ;;
*) echo "FAIL: a doubled-up line was silently counted once: $out"; false ;;
esac
rm -rf "$tmp"
}
@test "a declaration below the leading comment block is not honoured" {
# The header ends at the first line of code, not at the first @test. A .bats
# file runs shell before its tests -- helper functions, fixture writers, a
# heredoc of expected output -- and a line inside any of that is data, not a
# statement about the file. Ending the header at the first @test would still
# read it.
tmp=$(mktemp -d)
bnet_new_fixture "$tmp"
bnet_add_code "$tmp"
bnet_append_debt "$tmp" 1
bnet_add_handler "$tmp"
out=$(bnet_audit "$tmp")
case "$out" in
*"subject.bats: holds 1 EXIT trap(s) and declares none"*) ;;
*) echo "FAIL: a declaration past the first code line excused a trap: $out"; false ;;
esac
rm -rf "$tmp"
}
@test "a declaration below the first test is not honoured" {
# Only the header counts, so that a line the file merely CONTAINS -- inside a
# heredoc, in a fixture a test writes, in an expected-output string -- cannot
# excuse a real trap. Such a line is written to be read as data by the test
# around it, and its author has no idea it is also being read as a promise
# about the file. Narrowing to the header also puts the declaration where a
# reviewer opening the file sees it, which is the whole reason it moved out of
# the central inventory.
tmp=$(mktemp -d)
bnet_new_fixture "$tmp"
bnet_add_handler "$tmp"
bnet_add_test_body "$tmp"
bnet_append_debt "$tmp" 1
out=$(bnet_audit "$tmp")
case "$out" in
*"subject.bats: holds 1 EXIT trap(s) and declares none"*) ;;
*) echo "FAIL: a declaration past the first test excused a trap: $out"; false ;;
esac
rm -rf "$tmp"
}
@test "a second declaration in one file is reported" {
# Two declarations make the file's own statement ambiguous, and a parser that
# silently takes the first would let the losing one drift without ever saying
# which number is in force.
tmp=$(mktemp -d)
bnet_new_fixture "$tmp"
bnet_add_handler "$tmp"
bnet_add_debt "$tmp" 1
bnet_add_debt "$tmp" 9
out=$(bnet_audit "$tmp")
case "$out" in
*"subject.bats: 2 debt declarations"*) ;;
*) echo "FAIL: contradictory declarations were accepted: $out"; false ;;
esac
rm -rf "$tmp"
}