Files
cozystack/hack/package-mk-phony.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

333 lines
14 KiB
Bash

#!/usr/bin/env bats
# -----------------------------------------------------------------------------
# Unit tests for the .PHONY declarations this tree depends on: the one in
# hack/package.mk, which most package Makefiles under packages/ include, and the
# root Makefile's, where one target is already shadowed by a real path.
#
# `.PHONY=a b c` assigns a variable; `.PHONY: a b c` declares targets. Only the
# second does anything, and make reports neither. The declaration lists the nine
# targets package.mk defines and stops there.
#
# `update` and `image` are deliberately absent: package.mk does not define them,
# the including package Makefile does, and naming an undefined target in .PHONY
# creates it as an empty target. That turns "No rule to make target" (exit 2)
# into "Nothing to be done" (exit 0) wherever the rule is missing, including the
# CI build matrix, which is parsed from the root Makefile `build:` target and
# runs `make -C <pkg> image` per unit. The last two tests pin the exclusion.
#
# The first test and the control below deliberately read different lists — the
# first the names on the `.PHONY:` line, the control the targets the file
# defines — and merging them back into one is the tidy-up to resist. Which list
# each takes is argued where it is taken.
#
# The first test is narrower than its name suggests, in two ways. It probes the
# names on the `.PHONY:` line, so it cannot see a name dropped from that line;
# the set-equality test is what guards every name's presence. And it decides by
# matching make's "is up to date", which make prints only for a target that has
# a recipe. A recipe-less alias never draws it: with no prerequisite both forms
# print "Nothing to be done", and with a prerequisite that is itself out of
# date, which every target here is for being phony, the working form prints that
# prerequisite's recipe while the broken form prints "Nothing to be done". The
# probe separates neither pair, so it would pass on such a name either way.
# Every target package.mk defines has a recipe, so nothing is uncovered today.
# The control test below does not close the gap either: an alias is a defined
# target, so the control probes it and requires "is up to date" from it, which
# turns the control red against the probe rather than against the alias.
# Adding one means revisiting both.
#
# The decoy files all carry one fixed timestamp, and that is load-bearing rather
# than tidy. Six of the nine targets take `check` as a prerequisite, and `apply`
# and `delete` reach it through `suspend` as well. Touch the decoys in list order
# and `check` lands newer than the targets that depend on it, so make rebuilds
# them for that reason alone and prints a recipe whatever `.PHONY` says. The
# probe then reports success against the broken form. Equal mtimes remove the
# question: nothing is ever newer than its own prerequisite, in any list order,
# so the recipe running means phony and nothing else. Measured on GNU Make 4.3,
# which is what CI runs, list order hid the bug for up to six of the nine, and
# which six moved with where the clock tick fell. On 3.81 the whole loop landed
# inside one timestamp and hid nothing, so a local run could not see it.
#
# Constraints on anyone editing this file: every make invocation is pinned to
# LC_ALL=C, because the assertions match English make wording; cozytest.sh
# recognizes only @test blocks and a bare `}` at column 0, and injects `set -e`,
# so exit statuses are captured explicitly rather than tested inline.
#
# Run with: bats hack/package-mk-phony.bats
# -----------------------------------------------------------------------------
load test_helper
@test "every target hack/package.mk declares phony still runs when a file of that name exists" {
repo=$(pwd)
targets=$(sed -n 's/^\.PHONY:[[:space:]]*//p' "$repo/hack/package.mk")
# Without this guard the loop below is a vacuous pass against `.PHONY=`,
# which the sed no longer matches.
if [ -z "$targets" ]; then
echo "no '.PHONY:' declaration found in hack/package.mk" >&2
exit 1
fi
tmp=$(mktemp -d)
printf 'include %s/hack/package.mk\n' "$repo" > "$tmp/Makefile"
# One fixed timestamp for every decoy, so no target is ever newer than a
# target it depends on. See the header: list order silently disarms this
# probe for the targets that hang off `check`.
for t in $targets; do
touch -t 200101010000 "$tmp/$t"
done
for t in $targets; do
# cozytest.sh runs with `set -e`, so capture the exit status explicitly
# rather than letting a failing make abort before the diagnostics below.
rc=0
out=$(LC_ALL=C make --dry-run --directory "$tmp" "$t" 2>&1) || rc=$?
if [ "$rc" -ne 0 ]; then
echo "make $t exited $rc under --dry-run:" >&2
echo "$out" >&2
rm -rf "$tmp"
exit 1
fi
case "$out" in
*"is up to date"*)
echo "make $t no-oped with a file named $t present:" >&2
echo "$out" >&2
echo "hack/package.mk does not actually declare $t phony" >&2
rm -rf "$tmp"
exit 1
;;
esac
done
rm -rf "$tmp"
}
@test "the probe reports up-to-date when .PHONY is written as an assignment" {
# Negative control: proves the probe can see a file-backed target at all.
# The list is the targets package.mk defines, not the names on its `.PHONY:`
# line, because "is up to date" is only a meaningful answer for a name that
# has a rule. A declared name the file does not define prints "Nothing to be
# done" in both forms, so probing it here fails against the probe instead of
# against the defect; the set-equality test is what catches that name, and
# it carries the message that says so. The list is read from the original
# file: the copy is the mutated one.
repo=$(pwd)
targets=$(grep -E '^[A-Za-z0-9][A-Za-z0-9_-]*([[:space:]]+[A-Za-z0-9][A-Za-z0-9_-]*)*:' \
"$repo/hack/package.mk" \
| grep -vE '^[^:]*:+=' | sed 's/:.*//' \
| tr ' ' '\n' | grep -v '^$' | sort -u)
if [ -z "$targets" ]; then
echo "no targets extracted from hack/package.mk — extraction broke" >&2
exit 1
fi
tmp=$(mktemp -d)
sed 's/^\.PHONY:/.PHONY=/' "$repo/hack/package.mk" > "$tmp/package.mk"
if ! grep -q '^\.PHONY=' "$tmp/package.mk"; then
echo "mutation to the assignment form did not apply to the copy" >&2
rm -rf "$tmp"
exit 1
fi
printf 'include %s/package.mk\n' "$tmp" > "$tmp/Makefile"
# Same fixed timestamp as the first test, for the same reason.
for t in $targets; do
touch -t 200101010000 "$tmp/$t"
done
for t in $targets; do
rc=0
out=$(LC_ALL=C make --dry-run --directory "$tmp" "$t" 2>&1) || rc=$?
if [ "$rc" -ne 0 ]; then
echo "make $t exited $rc under --dry-run:" >&2
echo "$out" >&2
rm -rf "$tmp"
exit 1
fi
case "$out" in
*"is up to date"*) ;;
*)
echo "probe failed to detect a file-backed $t:" >&2
echo "$out" >&2
rm -rf "$tmp"
exit 1
;;
esac
done
rm -rf "$tmp"
}
@test "the .PHONY list and the targets package.mk defines are the same set" {
# The first test only probes names already on the list, so this is the sole
# guard on a name being dropped from it. The reverse direction catches a
# declared-but-undefined name, which make turns into an empty target.
# `%-update` is out of reach for both: .PHONY matches literally.
repo=$(pwd)
# Flattened because the `case` tests match on " name " and an embedded
# newline makes a name fail that check and get reported wrongly.
declared=$(sed -n 's/^\.PHONY:[[:space:]]*//p' "$repo/hack/package.mk" \
| tr '\n' ' ')
# A target name outside this pattern — leading underscore, dot, slash — is
# legal in make and skipped here in silence, so keep new names inside it.
# One rule may name several targets, so the pattern accepts a run of them
# and the `tr` splits the run apart again; anchoring the colon to a single
# name instead would drop every name on such a line, silently for a target
# nothing depends on. The second grep drops assignments by the `:=` itself
# rather than by whatever precedes it, `:+` covering `::=` and `:::=`, so
# `export FOO:=bar` goes too — matching the name in front instead would let
# that line through and report `export` and `FOO` as undeclared targets.
defined=$(grep -E '^[A-Za-z0-9][A-Za-z0-9_-]*([[:space:]]+[A-Za-z0-9][A-Za-z0-9_-]*)*:' \
"$repo/hack/package.mk" \
| grep -vE '^[^:]*:+=' | sed 's/:.*//' \
| tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ')
if [ -z "$declared" ] || [ -z "$defined" ]; then
echo "could not extract targets from hack/package.mk — extraction broke" >&2
exit 1
fi
missing=""
for t in $defined; do
case " $declared " in
*" $t "*) ;;
*) missing="$missing $t" ;;
esac
done
if [ -n "$missing" ]; then
echo "hack/package.mk defines these targets but does not declare them phony:$missing" >&2
echo "a file of that name next to a package Makefile would silence the recipe" >&2
exit 1
fi
undefined=""
for t in $declared; do
case " $defined " in
*" $t "*) ;;
*) undefined="$undefined $t" ;;
esac
done
if [ -n "$undefined" ]; then
echo "hack/package.mk declares these targets phony but defines none of them:$undefined" >&2
echo "naming an undefined target in .PHONY creates it as an empty target, so the" >&2
echo "command reports success instead of failing with 'No rule to make target'" >&2
exit 1
fi
}
@test "update and image stay undeclared so a package without the rule fails loudly" {
# Pins the exclusion: a package with no `update:`/`image:` rule must still
# fail loudly. Declaring them centrally would make every such package report
# success having built nothing, CI included.
repo=$(pwd)
tmp=$(mktemp -d)
printf 'include %s/hack/package.mk\n' "$repo" > "$tmp/Makefile"
for t in update image; do
rc=0
out=$(LC_ALL=C make --directory "$tmp" "$t" 2>&1) || rc=$?
# Exit status and diagnostic are independent observables, so both are
# pinned. 2 is what make returns for a fatal error, and the number is
# what a caller branches on — a wrapper or a future make that kept the
# wording while returning something else would pass the case below.
if [ "$rc" -ne 2 ]; then
echo "expected 'make $t' to exit 2 in a package that defines no $t rule," >&2
echo "but it exited $rc — is $t named in the .PHONY list?" >&2
echo "$out" >&2
rm -rf "$tmp"
exit 1
fi
case "$out" in
*"No rule to make target"*) ;;
*)
echo "expected 'make $t' to report no rule to make target, got:" >&2
echo "$out" >&2
rm -rf "$tmp"
exit 1
;;
esac
done
rm -rf "$tmp"
}
@test "adding update and image to the .PHONY list silences that failure" {
# Negative control. Test 4 passes vacuously if its Makefile never reads
# package.mk at all: a broken `include` path also exits non-zero with "No
# rule to make target", naming the missing file. This mutates a copy and
# greps that the mutation landed, so a wrong path fails here instead.
repo=$(pwd)
tmp=$(mktemp -d)
sed 's/^\(\.PHONY:.*\)$/\1 update image/' "$repo/hack/package.mk" > "$tmp/package.mk"
if ! grep -q '^\.PHONY:.* update image$' "$tmp/package.mk"; then
echo "mutation extending the declaration did not apply to the copy" >&2
rm -rf "$tmp"
exit 1
fi
printf 'include %s/package.mk\n' "$tmp" > "$tmp/Makefile"
for t in update image; do
rc=0
out=$(LC_ALL=C make --directory "$tmp" "$t" 2>&1) || rc=$?
if [ "$rc" -ne 0 ]; then
echo "mutation did not take effect: 'make $t' still failed with exit $rc" >&2
echo "$out" >&2
rm -rf "$tmp"
exit 1
fi
case "$out" in
*"Nothing to be done"*) ;;
*)
echo "expected the mutated copy to report nothing to be done for $t, got:" >&2
echo "$out" >&2
rm -rf "$tmp"
exit 1
;;
esac
done
rm -rf "$tmp"
}
@test "the root Makefile declares test phony so the test directory cannot shadow it" {
# The one target in this tree that a real path already shadows. `test:` in
# the root Makefile produces no file, so make treats the existing test/
# directory as its product: undeclared, `make test` prints "is up to date"
# and the recipe never runs, while docs/agents/overview.md advertises the
# command as the way to run the full suite. Every other undeclared target
# in that file is unshadowed today, so this is the only live one.
#
# Probed with --dry-run because the recipe wants a live cluster. The path is
# what makes the probe mean anything, so its absence fails loudly rather
# than passing quietly.
repo=$(pwd)
if [ ! -e "$repo/test" ]; then
echo "no test/ path at the repo root: this probe no longer measures anything" >&2
exit 1
fi
rc=0
out=$(LC_ALL=C make --dry-run --directory "$repo" test 2>&1) || rc=$?
if [ "$rc" -ne 0 ]; then
echo "make test exited $rc under --dry-run:" >&2
echo "$out" >&2
exit 1
fi
case "$out" in
*"is up to date"*)
echo "make test no-oped with the test/ path present:" >&2
echo "$out" >&2
echo "the root Makefile does not declare test phony" >&2
exit 1
;;
esac
}