diff --git a/test/lib/pgxntool-loops.txt b/test/lib/pgxntool-loops.txt new file mode 100644 index 0000000..450bb7e --- /dev/null +++ b/test/lib/pgxntool-loops.txt @@ -0,0 +1,44 @@ +# Every loop in pgxntool's *.mk files, with the test proving that a failing +# iteration fails make. The steps for adding or updating an entry are in +# test/standard/make-loops.bats's failure message. +# +# test/standard/make-loops.bats fails on any loop missing from this list, any +# entry with no matching loop, any loop whose line no longer matches its +# entry's fingerprint, and any coverage test that doesn't exist. It prints the +# fingerprint of each new or changed loop. +# +# Format: five tab-separated fields per line. Lines starting with # and blank +# lines are ignored; order doesn't matter. +# path under pgxntool/ +# where the loop is, never a line number: +# rule: a recipe or rule line; is the +# unexpanded text before the rule's ':' +# var: a variable assignment +# define: a define body +# toplevel any other line, e.g. $(foreach ...$(eval ...)) +# for, while or until (shell), or foreach (make) +# cksum's CRC of the logical line containing the loop, with +# continuations joined and each run of blanks collapsed to +# one space; loops on one line share it +# test:: +# make-time: only for a $(foreach) that runs inside make +# and emits no shell commands +# not-a-loop: only for a shell for/while/until match that +# reading the line shows isn't a loop; +# says what the text is +# untested: grandfathered entries only, never a new loop +# A context with two loops of the same kind gets two lines. + +base.mk rule:$(_PGXNTOOL_INSTALL_SCHEDULE) for 2140132506 untested:grandfathered; an iteration can only fail appending to the schedule file that the `>` just before the loop created, so there is no practical way to fail a non-last one +base.mk rule:check-duplicate-docs foreach 3110729658 make-time:expands to $(warning) calls; the recipe itself runs only `:` +base.mk rule:pgtle for 3542486984 test:standard/multi-extension.bats:make pgtle fails when pgtle.sh fails for a non-last extension +base.mk rule:print-pgtle foreach 2102040647 test:standard/multi-extension.bats:make print-pgtle fails when cat fails for a non-last extension +base.mk rule:results for 2360069330 test:standard/make-test.bats:make results fails when a copy other than the last one fails (pgxntool#140) +base.mk rule:results-build for 2438470538 test:standard/make-test.bats:results-build fails when a copy fails (pgxntool#140) +base.mk toplevel foreach 4171952040 make-time:$(eval)s an ASCIIDOC_template rule for each of ASCIIDOC_EXTS +base.mk var:ASCIIDOC_FILES foreach 3923359060 make-time:builds a file list (outer $(foreach)) +base.mk var:ASCIIDOC_FILES foreach 3923359060 make-time:builds a file list (inner $(foreach)) +base.mk var:ASCIIDOC_HTML foreach 2225361476 make-time:builds a file list +base.mk var:DOCS foreach 510455491 make-time:builds a file list +base.mk var:_PGXNTOOL_DUPLICATE_DOCS foreach 3031171085 make-time:builds a name list +base.mk var:_pgxntool_docs_named foreach 2395367995 make-time:builds a file list diff --git a/test/lib/scan-make-loops.awk b/test/lib/scan-make-loops.awk new file mode 100644 index 0000000..1b6c658 --- /dev/null +++ b/test/lib/scan-make-loops.awk @@ -0,0 +1,202 @@ +# Lists the loops in one makefile, one "\t\t\t" line +# each, being the whole logical line containing the loop with each run +# of blanks collapsed to one space; make-loops.bats fingerprints it. +# test/standard/make-loops.bats says what counts as a loop and why; the header +# of test/lib/pgxntool-loops.txt describes the context keys. +# +# Usage: awk -v file= -f scan-make-loops.awk +# +# Every logical line is searched for both kinds of loop; classifying the line +# (recipe, define body, assignment, rule header, directive, other) only picks +# its context key and which text is searched: +# - recipe and define body lines: all of it, minus shell comment lines +# - assignments: the value, whose start counts as command position +# - rule headers: the prerequisites, plus any `; recipe` (taken from the raw +# line, since make doesn't strip comments from it) +# - everything else: the line minus its make comment +# A for/while/until at command position counts only when a `do` follows it in +# the searched text, except in a define body, where every one counts. + +BEGIN { + # Shell command position: start of the text, after a separator or opening + # character (quotes included, since `sh -c "..."` bodies are real shell), + # after a keyword that starts a command list, or at the start of $(shell). + CMDPOS = "(^|[;&|(){!`\"']|(^|[^A-Za-z0-9_])(do|then|else|elif)[ \t]|\\$+[({]shell[ \t])[ \t]*" + # `for NAME` must be followed by in/;/do, so prose ("for a reason") and + # awk's `for(i in A)` don't match. + LOOP = "(for[ \t]+[A-Za-z_][A-Za-z0-9_]*([ \t]+in([ \t;]|$)|[ \t]*;|[ \t]+do([ \t;]|$))|for[ \t]*\\(\\(|while([ \t({!]|$)|until([ \t({!]|$))" + SHELL_LOOP = CMDPOS LOOP + # The `do` that a real loop's keyword must be followed by outside a define + # body: after one of ; & ) }, or right after `for NAME`. + DO_AFTER = "[;&)}][ \t]*do([ \t]|$)" + FOR_NAME_DO = "^for[ \t]+[A-Za-z_][A-Za-z0-9_]*[ \t]+do([ \t;]|$)" +} + +# Physical lines are buffered until one doesn't end in an odd number of +# backslashes. phys[] keeps them apart because a shell comment ends at its own +# physical line even when that line is continued. +{ + if (match($0, /\\+$/) && RLENGTH % 2 == 1) { + phys[++np] = substr($0, 1, length($0) - 1) + next + } + phys[++np] = $0 + logical() + np = 0 +} +END { if (np) logical() } + +function logical( i, raw, r, t, p, s, name, key) { + raw = phys[1] + for (i = 2; i <= np; i++) raw = raw " " phys[i] + logical_text = norm(raw) + + # Like make, only lines that don't start with a tab can nest or end a define. + if (defdepth) { + if (raw !~ /^\t/) { + if (np == 1 && raw ~ /^[ \t]*endef([ \t#]|$)/) { defdepth--; return } + if (raw ~ /^[ \t]*((override|export|private)[ \t]+)*define([ \t]|$)/) defdepth++ + } + recipe("define:" defname) + return + } + + # A tab-led line is a recipe line only while a rule's recipe can follow; + # elsewhere make parses it as an ordinary line (e.g. an indented assignment). + if (inrule && raw ~ /^\t/) { recipe(ctx); return } + + r = raw + sub(/^[ \t]+/, "", r) + p = find0(r, "#") + t = p ? substr(r, 1, p - 1) : r + if (t ~ /^[ \t]*$/) return + + if (t ~ /^((override|export|private)[ \t]+)*define([ \t]|$)/) { + name = t + sub(/^((override|export|private)[ \t]+)*define[ \t]*/, "", name) + sub(/[ \t]*(:::=|::=|:=|[+?!]?=)[ \t]*$/, "", name) + defname = norm(name) + defdepth = 1 + inrule = 0 + return + } + + # Conditionals don't end a rule's recipe; the other directives do. + if (t ~ /^(ifeq|ifneq|ifdef|ifndef|else|endif)([ \t(]|$)/) { + search(t, "toplevel") + return + } + if (t ~ /^(-?include|sinclude|-?load|vpath|unexport|undefine)([ \t]|$)/) { + search(t, "toplevel") + inrule = 0 + return + } + + p = find0(t, ":=") + if (p && (substr(t, p, 1) == "=" || substr(t, p) ~ /^(:=|::=|:::=)/)) { + name = substr(t, 1, p - 1) + if (substr(t, p, 1) == "=") sub(/[+?!]$/, "", name) + sub(/^((override|export|private)[ \t]+)+/, "", name) + key = "var:" norm(name) + search_foreach(t, key) + match(substr(t, p), /^:*=/) + search_shell(substr(t, p + RLENGTH), key) + inrule = 0 + return + } + + if (p) { + ctx = "rule:" norm(substr(t, 1, p - 1)) + inrule = 1 + s = find0(substr(r, p + 1), ";#") + if (s && substr(r, p + s, 1) == ";") { + search(substr(r, 1, p + s - 1), ctx) + search(substr(r, p + s + 1), ctx) + } else { + search(t, ctx) + } + return + } + + search(t, "toplevel") + inrule = 0 +} + +# A recipe line, or a define body line (which make may later run as one). +# A physical line whose first non-blank character (after the @+- prefixes, on +# a recipe's first line) is # is a shell comment: skipped, but its newline +# still ends the command before it, hence the ";". +function recipe(key, i, p, sh) { + sh = "" + for (i = 1; i <= np; i++) { + p = phys[i] + if (i == 1) sub(/^[ \t@+-]*/, "", p) + else sub(/^[ \t]+/, "", p) + sh = sh (p ~ /^#/ ? " ;" : " " p) + } + search(sh, key) +} + +function search(s, key) { + search_foreach(s, key) + search_shell(s, key) +} + +function search_foreach(s, key) { + while (match(s, /\$+[({]foreach[ \t]/)) { + emit(key, "foreach") + s = substr(s, RSTART + RLENGTH) + } +} + +function search_shell(s, key, at, m, kind) { + while (match(s, SHELL_LOOP)) { + at = RSTART + m = substr(s, RSTART, RLENGTH) + match(m, /for|while|until/) + at += RSTART - 1 + kind = substr(m, RSTART, RLENGTH) + m = substr(m, RSTART) + if (key ~ /^define:/ || substr(s, at + RLENGTH) ~ DO_AFTER || m ~ FOR_NAME_DO) + emit(key, kind) + # Resume just past the keyword's first character; the "_" keeps the + # remainder's start from counting as command position. + s = "_" substr(s, at + 1) + } +} + +function emit(key, kind) { + print file "\t" key "\t" kind "\t" logical_text +} + +# Position of the first character of s that is in set and outside any +# $(...)/${...} reference, or 0. $$ and backslash-escaped characters never +# match. +function find0(s, set, i, n, c, d) { + n = length(s) + d = 0 + for (i = 1; i <= n; i++) { + c = substr(s, i, 1) + if (c == "\\") { i++; continue } + if (d) { + if (c == "(" || c == "{") d++ + else if (c == ")" || c == "}") d-- + continue + } + if (c == "$") { + c = substr(s, i + 1, 1) + if (c == "(" || c == "{") d++ + i++ + continue + } + if (index(set, c)) return i + } + return 0 +} + +function norm(s) { + gsub(/[ \t]+/, " ", s) + sub(/^ /, "", s) + sub(/ $/, "", s) + return s +} diff --git a/test/standard/make-loops.bats b/test/standard/make-loops.bats new file mode 100755 index 0000000..65ec09a --- /dev/null +++ b/test/standard/make-loops.bats @@ -0,0 +1,361 @@ +#!/usr/bin/env bats + +# Test: inventory of the loops in pgxntool's *.mk files +# +# Fails when pgxntool's *.mk files have a loop test/lib/pgxntool-loops.txt +# doesn't list, lack one it lists, or change the line containing one. A recipe +# loop hides the failure of every iteration but the last unless each one ends +# in `|| exit 1` or the iterations are chained with &&. The steps to take for +# each kind of failure are in the failure message. Needs no pg_config, +# foundation env, or Postgres. +# +# test/lib/scan-make-loops.awk reads the source text, not make's database +# (`make -p`), which shows only recipes in active conditional branches: with +# default settings it lacks the diffs-mode verify-results recipe. A missed loop +# is the one failure this test can't afford. +# +# Loops are keyed by file, context and kind, never by line number, so edits +# near a loop don't trip the test. Each entry's fingerprint covers the whole +# logical line containing the loop, since that line's exit status, including +# any command after `done`, is what make sees. +# +# A for/while/until counts only at shell command position, and outside define +# bodies only with a `do` after it: a recipe line, assignment value or rule +# line is one shell command line, so a real loop's `do` follows on that logical +# line. A define body may be handed whole to one shell, with `do` on a later +# line, so there every keyword at command position counts. Quoted strings +# aren't stripped (`sh -c "..."` bodies are real shell); text that still +# matches but isn't a loop gets a `not-a-loop:` entry. +# +# Not scanned: recipe text that the *.mk.sh generators echo into meta.mk and +# control.mk. They emit no loops today. + +load ../lib/helpers + +setup_file() { + setup_topdir + load_test_env "make-loops" +} + +setup() { + load_test_env "make-loops" + SCANNER="$TOPDIR/test/lib/scan-make-loops.awk" +} + +# Reads scan-make-loops.awk output and inserts each loop's fingerprint, the CRC +# that cksum prints for its text, before the text. +loop_fingerprints() { + local file context kind text crc + while IFS=$'\t' read -r file context kind text; do + [ -n "$file" ] || continue + crc=$(printf '%s\n' "$text" | cksum) || return + printf '%s\t%s\t%s\t%s\t%s\n' "$file" "$context" "$kind" "${crc%% *}" "$text" + done +} + +# Print "\t\t\t\t" for each loop in +# pgxntool's *.mk files, being the path under pgxntool/. +pgxntool_loops() { + local files f loops='' + files=$(cd "$PGXNREPO" && find . -name .git -prune -o -name '*.mk' -print | LC_ALL=C sort) || return + [ -n "$files" ] || error "no *.mk files found under $PGXNREPO" || return + for f in $files; do + loops+=$(awk -v file="${f#./}" -f "$SCANNER" "$PGXNREPO/$f") || return + loops+=$'\n' + done + loop_fingerprints <<< "$loops" +} + +# compare_loops : both are "\t\t\t +# \t..." lines, and has each loop's text in field 5. +# Prints sorted records, tab-separated, keyed by the first three fields: +# new per loop beyond the key's entry count +# unlisted +# per loop of such a key whose fingerprint no entry has +# stale per entry beyond the key's loop count +# changed +# per loop whose fingerprint no entry has, when the counts +# match, paired in input order with an unmatched entry +compare_loops() { + awk -F '\t' ' + NF == 0 { next } + { key = $1 FS $2 FS $3; keys[key] } + FILENAME == ARGV[1] { entry[key, ++entry_count[key]] = $4; next } + { loop[key, ++loop_count[key]] = $4; text[key, loop_count[key]] = $5 } + END { + for (key in keys) { + entries = entry_count[key] + 0 + loops = loop_count[key] + 0 + for (i = 1; i <= entries; i++) left[key, entry[key, i]]++ + unmatched_loops = 0 + for (i = 1; i <= loops; i++) { + if (left[key, loop[key, i]] > 0) left[key, loop[key, i]]-- + else unmatched_loop[++unmatched_loops] = i + } + unmatched_entries = 0 + for (i = 1; i <= entries; i++) { + if (left[key, entry[key, i]] > 0) { + left[key, entry[key, i]]-- + unmatched_entry[++unmatched_entries] = entry[key, i] + } + } + if (loops > entries) { + for (i = entries; i < loops; i++) print "new" FS key + for (i = 1; i <= unmatched_loops; i++) + print "unlisted" FS key FS loop[key, unmatched_loop[i]] FS text[key, unmatched_loop[i]] + } else if (loops < entries) { + for (i = loops; i < entries; i++) print "stale" FS key + } else { + for (i = 1; i <= unmatched_loops; i++) + print "changed" FS key FS loop[key, unmatched_loop[i]] FS text[key, unmatched_loop[i]] FS unmatched_entry[i] + } + } + }' <(printf '%s\n' "$1") <(printf '%s\n' "$2") | LC_ALL=C sort +} + +# fixture_fingerprint : the fingerprint of the text's only loop. +# A ">" at the start of a line stands for a tab. +fixture_fingerprint() { + local fixture="$BATS_TEST_TMPDIR/fingerprint.mk" loops + awk '{ sub(/^>/, "\t") } 1' > "$fixture" <<< "$1" || return + loops=$(awk -v file=fixture.mk -f "$SCANNER" "$fixture") || return + loops=$(loop_fingerprints <<< "$loops") || return + case $loops in + ''|*$'\n'*) error "expected exactly one loop in the fixture, got: +$loops"; return ;; + esac + cut -f4 <<< "$loops" +} + +@test "every loop in pgxntool's *.mk files is listed in test/lib/pgxntool-loops.txt" { + local rel=test/lib/pgxntool-loops.txt + local actual entries differences problems='' + actual=$(pgxntool_loops) + entries=$(awk '!/^#/ && !/^[[:space:]]*$/' "$TOPDIR/$rel") + differences=$(compare_loops "$entries" "$actual") + + local tag file context kind fingerprint text listed tab=$'\t' + local new='' unlisted='' stale='' changed='' changed_fingerprints='' + while IFS=$'\t' read -r tag file context kind fingerprint text listed; do + case "$tag" in + new) new+=" $file$tab$context$tab$kind"$'\n' ;; + unlisted) unlisted+=" $file$tab$context$tab$kind$tab$fingerprint$tab$text"$'\n' ;; + stale) stale+=" $file$tab$context$tab$kind"$'\n' ;; + changed) + changed+=" $file$tab$context$tab$kind$tab(listed fingerprint: $listed) + The line containing it is now: $text + To see what changed, run this in pgxntool: git log -p -- $file (or git diff -- $file) +" + changed_fingerprints+=" $file$tab$context$tab$kind$tab$fingerprint"$'\n' + ;; + esac + done <<< "$differences" + + [ -z "$new" ] || problems+=" +New loop(s) in pgxntool's *.mk files not listed in $rel: +${new%$'\n'} +A shell loop (or \$(foreach) that generates commands) in a recipe hides the failure of every iteration but the last unless each iteration ends in \`|| exit 1\` or the iterations are chained with &&. +Do these IN ORDER: + 1. Write a test that makes a NON-LAST iteration of this loop fail and asserts make exits non-zero. Confirm it catches the bug: temporarily remove the loop's \`|| exit 1\`, and the test must fail. + 2. Only after that test exists and passes, add the entry to $rel, with the loop's fingerprint from the list below and that test in the coverage column (test::<@test name>). +Do NOT add the entry just to make this test pass; this list exists to force step 1. +The only exceptions, each still an entry with the fingerprint below: + - make-time:, only for a \$(foreach) that runs inside make and emits no shell commands. + - not-a-loop:, only for a for/while/until match you have READ and confirmed is not shell code that runs a loop (e.g. the word \"while\" inside an echoed message); says what the text is. If any part of the line runs a loop, it is a loop: go back to step 1. +Never use untested: for a new loop. +Fingerprints of the unlisted loops, each followed by the line containing it: +${unlisted%$'\n'} +" + [ -z "$changed" ] || problems+=" +Changed loop(s) in pgxntool's *.mk files: the line containing each one no longer matches the fingerprint listed in $rel. +${changed%$'\n'} +Do these IN ORDER: + 1. Read the loop as it is now and confirm that a failure in ANY iteration, not just the last, still fails make: each iteration ends in \`|| exit 1\` or the iterations are chained with &&, there is no \`-\` prefix, and nothing after \`done\` replaces the loop's exit status. + 2. For a test: entry, confirm the named test still exercises the loop as changed: temporarily remove the loop's error handling, and the test must fail. For untested: or make-time:, recheck that the stated reason still holds. For not-a-loop:, reread the line and confirm the match still isn't a loop; if the line now runs one, it needs a failure test (see the new-loop steps) and a test: entry. + 3. Only then update the fingerprint. +New fingerprint(s), for step 3: +${changed_fingerprints%$'\n'} +Do NOT update the fingerprint just to make this test pass. +" + [ -z "$stale" ] || problems+=" +Listed in $rel but no longer in pgxntool's *.mk files -- remove: +${stale%$'\n'} +If a loop moved to another context (e.g. a renamed target), fix its entry instead, and check that its coverage test still exercises the loop where it is now. +" + + local coverage extra path name bad='' + while IFS=$'\t' read -r file context kind fingerprint coverage extra; do + [ -z "$extra" ] || { bad+=" more than five tab-separated fields: $file $context $kind"$'\n'; continue; } + case "$kind" in + for|while|until|foreach) ;; + *) bad+=" unknown kind '$kind': $file $context"$'\n' ;; + esac + case "$fingerprint" in + ''|*[!0-9]*) bad+=" fingerprint '$fingerprint' isn't a number: $file $context $kind"$'\n' ;; + esac + case "$coverage" in + test:?*:?*) + path=${coverage#test:} + name=${path#*:} + path=${path%%:*} + grep -Fq -- "@test \"$name\"" "$TOPDIR/test/$path" 2>/dev/null || + bad+=" test/$path has no @test \"$name\" (covering $file $context $kind) -- if it was renamed, update the entry; if it was removed, the loop needs a new failure test"$'\n' + ;; + make-time:?*) + [ "$kind" = foreach ] || + bad+=" make-time: is only for \$(foreach), not a shell $kind loop: $file $context"$'\n' + ;; + not-a-loop:?*) + [ "$kind" != foreach ] || + bad+=" not-a-loop: is only for a shell for/while/until match, never a \$(foreach): $file $context"$'\n' + ;; + untested:?*) ;; + *) bad+=" coverage must be test::<@test name>, make-time:, not-a-loop: or untested:: $file $context $kind"$'\n' ;; + esac + done <<< "$entries" + [ -z "$bad" ] || problems+=" +Problems with entries in $rel: +$bad" + + [ -z "$problems" ] || error "${problems#$'\n'}" +} + +@test "compare_loops tells a changed loop from a new or a stale one" { + local entries loops want got + entries=$(tr '|' '\t' <<'EOF' +f.mk|rule:same|for|1|test:x +f.mk|rule:changed|for|2|test:x +f.mk|rule:pair|foreach|7|make-time:x +f.mk|rule:pair|foreach|7|make-time:x +f.mk|rule:fewer|for|3|test:x +f.mk|rule:fewer|for|3|test:x +f.mk|rule:more|for|4|test:x +EOF +) + loops=$(tr '|' '\t' <<'EOF' +f.mk|rule:same|for|1|same text +f.mk|rule:changed|for|5|changed text +f.mk|rule:pair|foreach|8|pair text +f.mk|rule:pair|foreach|8|pair text +f.mk|rule:fewer|for|3|fewer text +f.mk|rule:more|for|4|more text +f.mk|rule:more|for|6|added text +EOF +) + want=$(tr '|' '\t' <<'EOF' +changed|f.mk|rule:changed|for|5|changed text|2 +changed|f.mk|rule:pair|foreach|8|pair text|7 +changed|f.mk|rule:pair|foreach|8|pair text|7 +new|f.mk|rule:more|for +stale|f.mk|rule:fewer|for +unlisted|f.mk|rule:more|for|6|added text +EOF +) + got=$(compare_loops "$entries" "$loops") + [ "$got" = "$want" ] || error "compare_loops output differs from expected: +$(diff -u <(echo "$want") <(echo "$got"))" +} + +@test "scan-make-loops.awk finds each loop shape and skips comments and prose" { + # Lines starting with ">" start with a tab once written out. The echo and + # $(error) lines name a keyword with no `do` after it, so only the define + # body's one counts. + local fixture="$BATS_TEST_TMPDIR/fixture.mk" + awk '{ sub(/^>/, "\t") } 1' > "$fixture" <<'EOF' +# make comment: for f in a b; do $(foreach x,y,z); done +RUN_ALL = $(foreach e,a b,./run.sh $(e);) +LOOP_CMD = for f in a b; do echo $$f; done +HASH := $(subst x,#,y) $(foreach z,a,b) +copy: +>@for f in a b; do cp $$f out/ || exit 1; done +>@ls | while read f; do echo $$f; done +>@if true; then for f in a b; do echo $$f; done; fi +>@# disabled: true; for f in a b; do echo $$f; done +>@awk '{for(i in A) print A[i]}' /dev/null +>@echo "for a reason" >&2 +>@echo "while building foo" +>@echo "ERROR: failed (while installing)" +>@echo "see notes (for example; the README)" +>$(error bad value (for instance; see docs)) +>@echo "until then, nothing" +>@while read f; do echo $$f; done < list +>@until false; do break; done +>@for f do echo $$f; done +>@while { true; } do break; done +>@while (false) do break; done +>@for ((i=0;i<2;i++)) do echo $$i; done +gen: +>@$(foreach e,a b,./run.sh $(e);) +use-helper: +>@$(RUN_ALL) +inline: ; for f in a b; do echo $$f; done +$(SRCS:.c=.o): +>@echo hi \ +># comment line inside a continued command \ +>for f in a b; do echo $$f; done +INDENTED = 1 +>INDENTED_LOOP := $(foreach x,a b,$(x)) +define TEMPLATE +$(1)-copy: +>for f in $(1); do cp $$$$f out/; done +>@echo "while building foo" +endef +ifeq (never,taken) +untaken: +>for f in a b; do echo $$f; done +endif +EOF + + local want got + want='var:RUN_ALL foreach +var:LOOP_CMD for +var:HASH foreach +rule:copy for +rule:copy while +rule:copy for +rule:copy while +rule:copy until +rule:copy for +rule:copy while +rule:copy while +rule:copy for +rule:gen foreach +rule:inline for +rule:$(SRCS:.c=.o) for +var:INDENTED_LOOP foreach +define:TEMPLATE for +define:TEMPLATE while +rule:untaken for' + got=$(awk -v file=fixture.mk -f "$SCANNER" "$fixture" | cut -f2,3 | tr '\t' ' ') + [ "$got" = "$want" ] || error "scanner output differs from expected: +$(diff -u <(echo "$want") <(echo "$got"))" +} + +@test "a loop's fingerprint covers its whole logical line and ignores only whitespace" { + local base='copy: +>@echo before +>@for f in a b; do \ +> cp $$f out/ || exit 1; \ +>done +>@echo after' + local original respaced no_exit ignored before after + original=$(fixture_fingerprint "$base") + case $original in + ''|*[!0-9]*) error "fingerprint '$original' isn't a number"; return ;; + esac + # Blanks become runs of spaces and tabs, and every recipe line is indented further. + respaced=$(fixture_fingerprint "$(awk '{ sub(/^>/, ">\t "); gsub(/ /, " \t ") } 1' <<< "$base")") + no_exit=$(fixture_fingerprint "${base/ || exit 1/}") + ignored=$(fixture_fingerprint "${base/>@for/>-@for}") + before=$(fixture_fingerprint "${base/echo before/echo other; echo before}") + after=$(fixture_fingerprint "${base/echo after/echo other}") + + [ "$respaced" = "$original" ] || error "a whitespace-only change altered the fingerprint" + [ "$no_exit" != "$original" ] || + error "removing \`|| exit 1\` from a continuation line left the fingerprint unchanged" + [ "$ignored" != "$original" ] || error "adding a \`-\` prefix left the fingerprint unchanged" + [ "$before" = "$original" ] || error "editing the recipe line before the loop's altered its fingerprint" + [ "$after" = "$original" ] || error "editing the recipe line after the loop's altered its fingerprint" +} + +# vi: expandtab sw=2 ts=2 diff --git a/test/standard/make-test.bats b/test/standard/make-test.bats index 9ab4169..9120af6 100755 --- a/test/standard/make-test.bats +++ b/test/standard/make-test.bats @@ -21,6 +21,8 @@ # brand-new test's first expected output through (issue #119), and can be # disabled. Its own pass/fail logic lives in verify-results-pgtap-script.bats # - PGXNTOOL_VERIFY_RESULTS_MODE is validated (pgtap/diffs, case-insensitive) +# - `make results` and `results-build` fail when any copy into expected/ +# fails (pgxntool#140) load ../lib/helpers @@ -753,6 +755,22 @@ EOF rm -f test/results/pgtap_fail.out } +@test "make results fails when a copy other than the last one fails (pgxntool#140)" { + # A directory at cp's destination makes cp fail even as root; the zz_ file + # guarantees the failing copy isn't the last one. + mkdir -p test/results test/expected/0_cp_fail.out/0_cp_fail.out + echo "copy fails" > test/results/0_cp_fail.out + echo "copy succeeds" > test/results/zz_cp_after.out + + # -o verify-results skips the test run, leaving only the copy loop. + run make -o verify-results results + rm -rf test/expected/0_cp_fail.out + rm -f test/expected/zz_cp_after.out test/results/0_cp_fail.out test/results/zz_cp_after.out + + assert_failure + assert_contains "$output" "cannot overwrite directory" +} + # ============================================================================ # results-build (issue #108) # ============================================================================ @@ -810,4 +828,20 @@ EOF git checkout -- test/build/simple_build_test.sql } +@test "results-build fails when a copy fails (pgxntool#140)" { + skip_if_no_postgres + + # A directory at cp's destination makes cp fail even as root; the template's + # build_check.out and simple_build_test.out sort after 0_cp_fail.out, so it isn't the last copy. + mkdir -p test/build/results test/build/expected/0_cp_fail.out/0_cp_fail.out + echo "copy fails" > test/build/results/0_cp_fail.out + + run make results-build + rm -rf test/build/expected/0_cp_fail.out + rm -f test/build/results/0_cp_fail.out + + assert_failure + assert_contains "$output" "cannot overwrite directory" +} + # vi: expandtab sw=2 ts=2 diff --git a/test/standard/multi-extension.bats b/test/standard/multi-extension.bats index 656a717..a5c5d9a 100644 --- a/test/standard/multi-extension.bats +++ b/test/standard/multi-extension.bats @@ -118,6 +118,14 @@ setup() { # trimming (issues #25, #26). run make pgtle assert_success + local dir found=0 + for dir in pg_tle/*/; do + [ -d "$dir" ] || break + found=1 + [ -f "${dir}ext_alpha.sql" ] || error "make pgtle did not generate ${dir}ext_alpha.sql" + [ -f "${dir}ext_beta.sql" ] || error "make pgtle did not generate ${dir}ext_beta.sql" + done + [ "$found" = 1 ] || error "make pgtle generated no pg_tle// directory" } @test "sql/ext_alpha--1.0.0.sql is generated" { @@ -164,4 +172,64 @@ setup() { assert_success } +# Sets first_ext, pgtle_version and pgtle_dir (pgtle_version's range +# directory) in the caller's scope. +load_first_ext_and_pgtle_dir() { + run make print-_PGXNTOOL_EXTENSIONS + assert_success + local list=${output#*set to \"} exts + read -ra exts <<< "${list%\"}" + first_ext=${exts[0]} + [ ${#exts[@]} -ge 2 ] || error "need at least 2 extensions, got: $output" + debug 2 "first extension: $first_ext" + pgtle_version=1.4.0 + pgtle_dir=$(pgxntool/pgtle.sh --get-dir "$pgtle_version") || + error "pgtle.sh --get-dir $pgtle_version failed" +} + +# Must run before the "make pgtle fails" test, which leaves print-pgtle broken. +@test "make print-pgtle fails when cat fails for a non-last extension" { + local first_ext pgtle_version pgtle_dir + load_first_ext_and_pgtle_dir + + local target="$pgtle_dir/$first_ext.sql" + local marker="$BATS_TEST_TMPDIR/fake-cat-matched" + + # Fails only on the file print-pgtle cats for the first extension; every + # other call goes to the real cat, since the pgtle prerequisite's pgtle.sh + # uses cat too. + local fakebin="$BATS_TEST_TMPDIR/fakebin" real_cat + real_cat=$(type -P cat) + mkdir -p "$fakebin" + cat > "$fakebin/cat" <&2 + exit 1 +fi +exec $(printf %q "$real_cat") "\$@" +EOF + chmod +x "$fakebin/cat" + + # A fixed target version keeps print-pgtle from querying a live pg_tle. + PATH="$fakebin:$PATH" run make print-pgtle PGXNTOOL_PGTLE_TARGET_VERSION="$pgtle_version" + assert_failure + [ -f "$marker" ] || error "fake cat never saw $target; output: $output" + assert_contains "$output" "fake cat: failing on $target" +} + +# Leaves a directory at pg_tle//.sql, which breaks print-pgtle; must run after it. +@test "make pgtle fails when pgtle.sh fails for a non-last extension" { + local first_ext pgtle_version pgtle_dir + load_first_ext_and_pgtle_dir + + # A directory where pgtle.sh writes its output file makes it fail for the + # first extension only; the make call's own exit status is what's asserted. + rm -rf pg_tle + mkdir -p "$pgtle_dir/$first_ext.sql" + run make pgtle + assert_failure +} + # vi: expandtab sw=2 ts=2