#!/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" }