test(tests): make negated BATS assertions able to fail - #3790
test(tests): make negated BATS assertions able to fail#3790Aleksei Sviridkin (lexfrei) wants to merge 5 commits into
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThe PR documents a rule against negated BATS assertions, adds a repository-wide scanner, converts existing assertions to explicit failure branches, and strengthens SeaweedFS Helm template parity checks. ChangesNegated BATS assertion enforcement
SeaweedFS template parity validation
Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: ⚪ Minimal · up to This test-focused change has no actionable merge-blocking risk remaining and is merge-ready after normal checks and review. Sequence Diagram(s)sequenceDiagram
participant BatsRunner
participant NegatedAssertionAudit
participant BatsSuite
BatsRunner->>NegatedAssertionAudit: Run repository audit
NegatedAssertionAudit->>BatsSuite: Scan .bats statements
BatsSuite-->>NegatedAssertionAudit: Report statements beginning with !
NegatedAssertionAudit-->>BatsRunner: Fail with replacement guidance
Possibly related issues
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/agents/e2e-testing.md`:
- Line 141: Qualify the negated-assertion guidance so it says ! cmd is
unreliable and may pass unexpectedly rather than claiming it cannot fail; retain
the prohibition, including the tail-position bats exception. Apply this wording
change in docs/agents/e2e-testing.md:141-141 and AGENTS.md:31-31.
In `@hack/e2e-apps/monitoring-oidc-customconfig.bats`:
- Around line 123-128: Update both absence-check blocks in
hack/e2e-apps/monitoring-oidc-customconfig.bats:123-128 and
hack/e2e-install-cozystack.bats:617-621 to run kubectl get with
--ignore-not-found -o name, explicitly fail when kubectl returns an error, and
fail when the command produces non-empty output. Preserve the existing failure
messages for detected KeycloakClient and KeycloakClientScope resources.
In `@hack/seaweedfs-guard-parity.bats`:
- Around line 53-70: Replace the raw opener/closer counting checks with the
stateful parsing in template_code. Make template_code return nonzero when inc
remains set at EOF, while preserving its existing output behavior for valid
templates, and add a fixture covering {{/* inside an active Helm comment.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 1de527b3-132f-43fa-9a26-9bafd83c3d7e
📒 Files selected for processing (16)
AGENTS.mddocs/agents/e2e-testing.mdhack/bats-no-negated-assert.batshack/build-matrix_test.batshack/capture-dataplane.batshack/capture-previous-logs.batshack/common-envs_test.batshack/cozyreport.batshack/e2e-apps/monitoring-oidc-customconfig.batshack/e2e-install-cozystack.batshack/migration-50-etcd-adopt.batshack/migration-seaweedfs-db-adopt.batshack/nightly-mirror_test.batshack/promote-retag_test.batshack/release-changelog-contract.batshack/seaweedfs-guard-parity.bats
| if kubectl -n tenant-test get keycloakclient.v1.edp.epam.com "${CID}" 2>/dev/null; then | ||
| echo "FAIL: no KeycloakClient must exist in the cozy realm"; false | ||
| fi | ||
| if kubectl -n tenant-test get keycloakclientscope.v1.edp.epam.com "${CID}-audience" 2>/dev/null; then | ||
| echo "FAIL: no KeycloakClientScope must exist in the cozy realm"; false | ||
| fi |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -u
printf '%s\n' '--- target files ---'
git ls-files 'hack/e2e-apps/monitoring-oidc-customconfig.bats' 'hack/e2e-install-cozystack.bats'
printf '%s\n' '--- monitoring target ---'
sed -n '95,145p' hack/e2e-apps/monitoring-oidc-customconfig.bats
printf '%s\n' '--- install target ---'
sed -n '585,635p' hack/e2e-install-cozystack.bats
printf '%s\n' '--- related absence assertions ---'
rg -n -C 2 'ignore-not-found|must (not|be gone)|no .* must exist|kubectl .* get .*2>/dev/null' hack -g '*.bats' | head -240
printf '%s\n' '--- E2E guidance references ---'
rg -n -C 3 'fail.closed|fail-closed|kubectl wait|ignore-not-found|absence|NotFound' docs/agents hack AGENTS.md 2>/dev/null | head -240Repository: cozystack/cozystack
Length of output: 50377
🏁 Script executed:
#!/bin/bash
set -u
printf '%s\n' '--- runner and guidance ---'
sed -n '135,155p' docs/agents/e2e-testing.md
rg -n -C 5 'return 0|append|test function|errexit|set -e' hack/cozytest.sh docs/agents/e2e-testing.md | head -100
printf '%s\n' '--- kubectl availability and flag help ---'
if command -v kubectl >/dev/null 2>&1; then
kubectl version --client 2>/dev/null || true
kubectl get --help 2>/dev/null | grep -E -- '--ignore-not-found|-o|--output' | head -20 || true
else
echo 'kubectl: unavailable'
fi
printf '%s\n' '--- behavioral verifier ---'
python3 - <<'PY'
import subprocess
import tempfile
from pathlib import Path
# The mock models kubectl's relevant contract:
# 0 + object output: object exists
# 1 + no output: NotFound
# 2 + no output: auth/connection/discovery failure
mock = r'''#!/bin/sh
case "${MOCK_STATUS}:${MOCK_OUTPUT}" in
0:present) printf '%s\n' 'configmap/cozystack-version' ;;
0:empty) ;;
1:*) exit 1 ;;
2:*) exit 2 ;;
esac
'''
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "kubectl"
p.write_text(mock)
p.chmod(0o755)
original = r'''
if kubectl get configmap cozystack-version 2>/dev/null; then
echo "FAIL"
false
fi
'''
corrected = r'''
if ! output=$(kubectl get configmap cozystack-version --ignore-not-found -o name 2>/dev/null); then
echo "COMMAND_ERROR"
exit 1
fi
if [ -n "$output" ]; then
echo "OBJECT_PRESENT"
exit 1
fi
'''
cases = [
("present", "0", "present"),
("notfound", "1", "empty"),
("connection-error", "2", "empty"),
]
for name, status, output in cases:
env = {"PATH": f"{d}:/usr/bin:/bin", "MOCK_STATUS": status, "MOCK_OUTPUT": output}
old = subprocess.run(["sh", "-c", original], env=env, text=True,
capture_output=True)
new = subprocess.run(["sh", "-c", corrected], env=env, text=True,
capture_output=True)
print(f"{name}: original_rc={old.returncode} corrected_rc={new.returncode} "
f"corrected_out={new.stdout.strip()!r}")
PYRepository: cozystack/cozystack
Length of output: 38204
🏁 Script executed:
#!/bin/bash
set -u
printf '%s\n' '--- runner and guidance ---'
sed -n '135,155p' docs/agents/e2e-testing.md
rg -n -C 5 'return 0|append|test function|errexit|set -e' hack/cozytest.sh docs/agents/e2e-testing.md | head -100
printf '%s\n' '--- kubectl availability and flag help ---'
if command -v kubectl >/dev/null 2>&1; then
kubectl version --client 2>/dev/null || true
kubectl get --help 2>/dev/null | grep -E -- '--ignore-not-found|-o|--output' | head -20 || true
else
echo 'kubectl: unavailable'
fi
printf '%s\n' '--- behavioral verifier ---'
python3 - <<'PY'
import subprocess
import tempfile
from pathlib import Path
mock = r'''#!/bin/sh
case "${MOCK_STATUS}:${MOCK_OUTPUT}" in
0:present) printf '%s\n' 'configmap/cozystack-version' ;;
0:empty) ;;
1:*) exit 1 ;;
2:*) exit 2 ;;
esac
'''
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "kubectl"
p.write_text(mock)
p.chmod(0o755)
original = r'''
if kubectl get configmap cozystack-version 2>/dev/null; then
echo "FAIL"
false
fi
'''
corrected = r'''
if ! output=$(kubectl get configmap cozystack-version --ignore-not-found -o name 2>/dev/null); then
echo "COMMAND_ERROR"
exit 1
fi
if [ -n "$output" ]; then
echo "OBJECT_PRESENT"
exit 1
fi
'''
cases = [
("present", "0", "present"),
("notfound", "1", "empty"),
("connection-error", "2", "empty"),
]
for name, status, output in cases:
env = {"PATH": f"{d}:/usr/bin:/bin", "MOCK_STATUS": status, "MOCK_OUTPUT": output}
old = subprocess.run(["sh", "-c", original], env=env, text=True,
capture_output=True)
new = subprocess.run(["sh", "-c", corrected], env=env, text=True,
capture_output=True)
print(f"{name}: original_rc={old.returncode} corrected_rc={new.returncode} "
f"corrected_out={new.stdout.strip()!r}")
PYRepository: cozystack/cozystack
Length of output: 38204
🌐 Web query:
site:kubernetes.io kubectl get --ignore-not-found output name
💡 Result:
The command kubectl get --ignore-not-found -o name is a valid way to retrieve the names of Kubernetes resources while suppressing errors if the requested resource does not exist [1][2]. Here is a breakdown of how the components work together: - --ignore-not-found: This flag instructs kubectl to return an exit code of 0 even if the requested object is not found, effectively suppressing the "NotFound" error [1][3][4]. It is useful for scripts and automation where missing resources should not trigger a failure [1]. - -o name (or --output=name): This output format option directs kubectl to print only the resource name (e.g., pod/my-pod) and nothing else [2]. Example usage: kubectl get pod my-missing-pod --ignore-not-found -o name In this example, if "my-missing-pod" does not exist, the command will exit silently with code 0 rather than printing an error message [1][3][4]. If the pod does exist, it will output only its name [2].
Citations:
- 1: https://kubernetes.io/docs/reference/kubectl/generated/kubectl_get/
- 2: https://kubernetes.io/docs/reference/kubectl/quick-reference/
- 3: https://v1-31.docs.kubernetes.io/docs/reference/kubectl/kubectl-cmds/
- 4: https://v1-33.docs.kubernetes.io/docs/reference/generated/kubectl/kubectl-commands
🏁 Script executed:
#!/bin/bash
set -u
python3 - <<'PY'
import subprocess
import tempfile
from pathlib import Path
mock = r'''#!/bin/sh
# The mock implements kubectl get's relevant behavior:
# NotFound is converted to success and empty output only when --ignore-not-found
# is present. Other non-zero statuses remain errors.
case "$MOCK_CASE" in
present)
printf '%s\n' 'configmap/cozystack-version'
exit 0
;;
notfound)
case " $* " in
*' --ignore-not-found '*) exit 0 ;;
*) exit 1 ;;
esac
;;
connection-error|discovery-error)
exit 2
;;
esac
'''
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "kubectl"
p.write_text(mock)
p.chmod(0o755)
original = r'''
if kubectl get configmap cozystack-version 2>/dev/null; then
echo "FAIL"
false
fi
'''
corrected = r'''
if ! output=$(kubectl get configmap cozystack-version --ignore-not-found -o name 2>/dev/null); then
echo "COMMAND_ERROR"
exit 1
fi
if [ -n "$output" ]; then
echo "OBJECT_PRESENT"
exit 1
fi
'''
for case in ("present", "notfound", "connection-error", "discovery-error"):
env = {"PATH": f"{d}:/usr/bin:/bin", "MOCK_CASE": case}
old = subprocess.run(["sh", "-c", original], env=env, text=True,
capture_output=True)
new = subprocess.run(["sh", "-c", corrected], env=env, text=True,
capture_output=True)
print(f"{case}: original_rc={old.returncode}, corrected_rc={new.returncode}, "
f"corrected_out={new.stdout.strip()!r}")
PYRepository: cozystack/cozystack
Length of output: 443
🏁 Script executed:
#!/bin/bash
set -u
python3 - <<'PY'
import subprocess
import tempfile
from pathlib import Path
mock = r'''#!/bin/sh
case "$MOCK_CASE" in
present)
printf '%s\n' 'configmap/cozystack-version'
exit 0
;;
notfound)
case " $* " in
*' --ignore-not-found '*) exit 0 ;;
*) exit 1 ;;
esac
;;
connection-error|discovery-error)
exit 2
;;
esac
'''
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "kubectl"
p.write_text(mock)
p.chmod(0o755)
original = r'''
if kubectl get configmap cozystack-version 2>/dev/null; then
echo "FAIL"
false
fi
'''
corrected = r'''
if ! output=$(kubectl get configmap cozystack-version --ignore-not-found -o name 2>/dev/null); then
echo "COMMAND_ERROR"
exit 1
fi
if [ -n "$output" ]; then
echo "OBJECT_PRESENT"
exit 1
fi
'''
for case in ("present", "notfound", "connection-error", "discovery-error"):
env = {"PATH": f"{d}:/usr/bin:/bin", "MOCK_CASE": case}
old = subprocess.run(["sh", "-c", original], env=env, text=True,
capture_output=True)
new = subprocess.run(["sh", "-c", corrected], env=env, text=True,
capture_output=True)
print(f"{case}: original_rc={old.returncode}, corrected_rc={new.returncode}, "
f"corrected_out={new.stdout.strip()!r}")
PYRepository: cozystack/cozystack
Length of output: 443
Make both absence checks fail closed.
kubectl get failures currently pass as object absence. Use --ignore-not-found -o name, fail on command errors, and fail on non-empty output.
hack/e2e-apps/monitoring-oidc-customconfig.bats:123-128hack/e2e-install-cozystack.bats:617-621
📍 Affects 2 files
hack/e2e-apps/monitoring-oidc-customconfig.bats#L123-L128(this comment)hack/e2e-install-cozystack.bats#L617-L621
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@hack/e2e-apps/monitoring-oidc-customconfig.bats` around lines 123 - 128,
Update both absence-check blocks in
hack/e2e-apps/monitoring-oidc-customconfig.bats:123-128 and
hack/e2e-install-cozystack.bats:617-621 to run kubectl get with
--ignore-not-found -o name, explicitly fail when kubectl returns an error, and
fail when the command produces non-empty output. Preserve the existing failure
messages for detected KeycloakClient and KeycloakClientScope resources.
| template_code() { | ||
| awk ' | ||
| { | ||
| line = $0; out = "" | ||
| while (1) { | ||
| if (!inc) { | ||
| if (match(line, /\{\{-? *\/\*/)) { | ||
| out = out substr(line, 1, RSTART - 1) | ||
| line = substr(line, RSTART + RLENGTH); inc = 1 | ||
| } else { out = out line; break } | ||
| } else { | ||
| if (match(line, /\*\/ *-?\}\}/)) { line = substr(line, RSTART + RLENGTH); inc = 0 } | ||
| else break | ||
| } | ||
| } | ||
| if (out ~ /[^[:space:]]/) print out | ||
| } | ||
| ' "$1" |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
mkdir -p "$work/templates"
cat > "$work/Chart.yaml" <<'EOF'
apiVersion: v2
name: comment-fixture
version: 0.1.0
EOF
cat > "$work/templates/test.yaml" <<'EOF'
{{/* This documentation includes the opener syntax: {{/* */}}
apiVersion: v1
kind: ConfigMap
metadata:
name: fixture
EOF
helm template fixture "$work" >/dev/null
opens="$(grep -o '{{-\? *\/\*' "$work/templates/test.yaml" | wc -l)"
closes="$(grep -o '\*\/ *-\?}}' "$work/templates/test.yaml" | wc -l)"
test "$opens" -eq 2
test "$closes" -eq 1Repository: cozystack/cozystack
Length of output: 200
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- target file ---'
cat -n hack/seaweedfs-guard-parity.bats | sed -n '45,80p;165,205p'
printf '%s\n' '--- relevant tests and call sites ---'
rg -n -C 3 'template_code|opens=|closes=|seaweedfs-guard-parity' hack/seaweedfs-guard-parity.batsRepository: cozystack/cozystack
Length of output: 6487
🏁 Script executed:
#!/bin/bash
set -euo pipefail
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
cat > "$work/fixture.txt" <<'EOF'
{{/* This documentation includes the opener syntax: {{/* */}}
apiVersion: v1
kind: ConfigMap
metadata:
name: fixture
EOF
cat > "$work/template_code.awk" <<'AWK'
{
line = $0; out = ""
while (1) {
if (!inc) {
if (match(line, /\{\{-? *\/\*/)) {
out = out substr(line, 1, RSTART - 1)
line = substr(line, RSTART + RLENGTH); inc = 1
} else { out = out line; break }
} else {
if (match(line, /\*\/ *-?\}\}/)) { line = substr(line, RSTART + RLENGTH); inc = 0 }
else break
}
}
if (out ~ /[^[:space:]]/) print out
}
AWK
opens="$(grep -o '{{-\? *\/\*' "$work/fixture.txt" | wc -l)"
closes="$(grep -o '\*\/ *-\?}}' "$work/fixture.txt" | wc -l)"
printf 'raw counts: openers=%s closers=%s\n' "$opens" "$closes"
set +e
awk -f "$work/template_code.awk" "$work/fixture.txt" >"$work/code"
status_without_eof_check=$?
set -e
printf 'template_code status without EOF check=%s\n' "$status_without_eof_check"
printf 'template_code output without EOF check:\n'
cat "$work/code"
cat >> "$work/template_code.awk" <<'AWK'
END { if (inc) exit 1 }
AWK
set +e
awk -f "$work/template_code.awk" "$work/fixture.txt" >"$work/code-with-eof-check"
status_with_eof_check=$?
set -e
printf 'template_code status with EOF check=%s\n' "$status_with_eof_check"
printf 'template_code output with EOF check:\n'
cat "$work/code-with-eof-check"Repository: cozystack/cozystack
Length of output: 523
🌐 Web query:
Go text/template comment syntax comments may contain nested {{/* opener literal */}} Helm template comments
💡 Result:
No, Go text/template comments do not support nesting [1][2]. The documentation for the Go text/template package explicitly states that "comments do not nest and must start and end at the delimiters" [1][3]. Because Helm uses the Go text/template engine, this restriction also applies to Helm templates [4][5]. If you attempt to include another template comment opener {{/* or closer */}} inside an existing comment block, the template parser will fail because it does not recognize them as nested structures [6]. The parser treats the first */ it encounters as the termination of the comment, regardless of whether it is contained within what you intended to be a nested comment [1][6].
Citations:
- 1: https://pkg.go.dev/text/template@go1.26.5
- 2: https://github.com/golang/go/blob/master/src/text/template/doc.go
- 3: https://pkg.go.dev/text/template
- 4: https://helm.sh/docs/chart_best_practices/templates
- 5: Text inside comment incorrectly interpreted helm/helm#9634
- 6: text/template: template comment spacing golang/go#24767
🌐 Web query:
site:github.com/golang/go src/text/template/parse/lex.go lexComment scan comment "{{/*" inside comment
💡 Result:
In the Go text/template/parse package, the lexComment function in src/text/template/parse/lex.go is responsible for scanning comments within template actions [1][2]. Comments are defined by the syntax {{/... /}} [3]. When the lexer encounters the left comment marker (defined as /), it calls lexComment to process the content until it finds the closing right comment marker (/) [1][2]. The function performs the following steps: 1. It advances the lexer's position past the left comment marker [1][2]. 2. It uses strings.Index to locate the first occurrence of the right comment marker [1][2]. If no closing marker is found, it reports an "unclosed comment" error [1][2]. 3. It validates that the comment ends exactly at the expected right action delimiter (e.g., }}), ensuring that comments cannot span across multiple actions [1][2]. If the comment ends prematurely, it returns an error [1][2]. 4. It updates the lexer's line count to account for any newlines present within the comment [1][2]. 5. It captures the comment text as an itemComment token [1][2]. 6. If the parser is configured to emit comments (via the ParseComments mode in the parser options), the lexer will emit the itemComment token; otherwise, the comment is ignored [1][4]. Comments in Go templates do not nest and must start and end at the delimiters [3]. They are permitted to contain newlines [3].
Citations:
- 1: https://github.com/golang/go/blob/master/src/text/template/parse/lex.go
- 2: https://github.com/golang/go/blob/2403e594/src/text/template/parse/lex.go
- 3: https://github.com/golang/go/blob/master/src/text/template/doc.go
- 4: https://github.com/golang/go/blob/master/src/text/template/parse/parse.go
Use the comment-state parser to validate unclosed comments. The raw counts at lines 183-184 count {{/* inside an active comment. A valid Helm comment that contains this text produces two openers and one closer, so the guard fails before it checks template code. Remove the raw counts. Make template_code return nonzero when inc remains set at EOF, and add a fixture for {{/* inside a Helm comment.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@hack/seaweedfs-guard-parity.bats` around lines 53 - 70, Replace the raw
opener/closer counting checks with the stateful parsing in template_code. Make
template_code return nonzero when inc remains set at EOF, while preserving its
existing output behavior for valid templates, and add a fixture covering {{/*
inside an active Helm comment.
cd55ebf to
3c84002
Compare
3c84002 to
3f24b65
Compare
The parity suite forbids four mutable discriminators -- claim
timestamps, ready-replica counts and the two birth-order flags -- by
grepping each chart's whole file. Both charts carry a template comment
block arguing why those signals were rejected, so the grep matched the
argument and would have reported a chart for documenting the rule it
follows. It never did report: the checks were spelled `! grep`, a form
POSIX exempts from errexit, so they could not fail whatever they found.
Strip the {{/* ... */}} spans before matching and let the loop fail
through `false`, naming the signal it matched.
The span and not the line. Dropping every line that opens a comment is
shorter and silently loses a discriminator that shares its line with
one -- `{{- $x := .creationTimestamp }} {{/* why */}}` leaves with the
comment, the checks find nothing, and the balance count stays even
because that line both opens and closes. The two spellings agree on
today's charts, so a fixture pins the difference rather than trusting
it.
Assert the delimiters balance before trusting the strip. Every `{{/*`
is read as an opener, including one inside a quoted string -- these
charts carry long `fail (printf ...)` messages -- and a lone opener
would swallow the rest of the file and leave the checks reading
nothing. A canary token cannot cover that: it only proves whatever
precedes IT survived, and the block most likely to swallow is the last
one in the file. The counts come from `grep -o | wc -l` rather than
`grep -c`, which counts matching lines and would read a line holding
two openers and one closer as balanced. `wc -l` closes each pipeline
and always exits zero, so a chart with no comment blocks reports 0 = 0
and reaches the comparison instead of dying on the assignment.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <3811295@gmail.com>
An assertion written `! cmd` cannot fail a test. POSIX exempts a command whose return value is inverted with `!` from errexit, so the status is dropped where it is produced. hack/cozytest.sh then closes each test function with `return 0`, and the bats binary's ERR trap carries the same exemption -- it reports only a negated assertion standing last in the body, where the inverted status happens to be what the function returns. Every absence these suites assert was written that way: a command that must not reach the log, an image that must not be copied, a flag that must not be passed, an object that must not exist. On that half of each suite's contract the tests held for any input. Rewrite each as `if cmd; then echo "FAIL: ..."; false; fi`, the form already used where the trap had been noticed one file at a time, and give every one a message naming what must not have happened. The message states the property, not the pattern: where the forbidden string is a substring of a required value -- an https endpoint the test requires in its http form -- naming the string would describe the opposite of what is asserted. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <3811295@gmail.com>
A negated assertion is the natural way to spell "this must not
appear", and an absence is what nothing else in a test covers, so the
errexit exemption lands where it is least likely to be noticed and
invisible in a green run. Switching runners does not lift it: the ERR
trap the bats binary reports through carries the same exemption, so
neither runner sees a negated assertion that has any statement below
it.
Add a suite that scans every hack/**/*.bats, subdirectories included,
and fails on a statement whose first token is `!` -- naming the file,
the line the statement opens on, and the form to use instead.
Statement boundaries are the POSIX separators, each measured against
the runner before being admitted: `;` `&` `&&` `||` `{` `(`, the `)`
closing a case pattern, and the words `then` `do` `else`. A keyword may
legally be preceded only by whitespace or by `;` -- every other
separator in front of one is a shell syntax error, checked with `sh -n`
rather than recalled -- so the scan admits exactly those two and that
part of the class is closed rather than patched a spelling at a time.
Both spellings are vacuous under the runner, so missing either is a
silent miss, the one direction this scan must not err in. Four
over-counts remain, all erring loud, each named in the header: they are
enumerated rather than removed, because a removed error leaves no trace
while a named one tells the next reader what they are looking at.
Inverted conditions are untouched because errexit is not what decides
a condition. `[ ! -f x ]` is untouched for a different reason and
stays a normal failing assertion: the `!` there is an operator of
`test`, not negation of a command, so nothing exempts its status.
Each claim the suite makes is pinned against removing the thing it
claims. The recursion is exercised through the audit function on a
fixture in a subdirectory rather than through a second copy of its
find expression, because a copy passes while the function loses its
recursion -- and an audit that returns empty reads as a clean tree.
The condition-strip carries fixtures that put the negation after an
`&&` inside the condition, where only the strip can save it. The
continuation fold is pinned by a fixture whose negation sits on the
continued line, so folded and unfolded name different lines. Both
keyword spellings have fixtures of their own.
Both halves of the premise are pinned, not just the half about the
banned form. A test asserts the runner still passes a bare negated
assertion; a second asserts the prescribed `if cmd; then ...; false; fi`
does fail, under both runners, and fails AT the form rather than
somewhere before it. Every converted absence rests on that second
half, and its failure mode is asymmetric: were `false` in a `then`
body to stop propagating, the first test would still pass while every
conversion went silently green.
The rule joins the E2E conventions doc as its own section, and the
inline list in AGENTS.md that an agent may read instead of the doc --
it belongs beside the EXIT-trap ban for the same reason, both leaving
a green suite that checks nothing.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <3811295@gmail.com>
Six suites explain why they avoid `! cmd` by saying errexit is suppressed for a "negated pipeline". The exemption is not about pipelines: a bare `! true`, a negated `[ ... ]`, one inside a `for` body or a brace group, one in a case branch, and one following `;`, `&`, `&&` or `||` are all exempt too. Anyone reading the narrower wording could conclude the other shapes are safe. One of the six carries the sentence twice, and the second copy is split across two physical lines -- `` `!` `` ending one and `pipeline)` opening the next -- so a line-oriented search for the phrase does not see it. Prose wraps; a grep for it has to fold the lines first. Comment text only; every one of these files already uses the working form the comment recommends. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <3811295@gmail.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
3f24b65 to
a9ac54e
Compare
What this PR does
Seventy-seven assertions across seven BATS suites begin with
!. Seventy-two of them pass no matter what they find. POSIX exempts a command whose return value is inverted with!fromset -e, so the failing status is dropped where it is produced, andhack/cozytest.shcloses each test function withreturn 0, so it never resurfaces as the test's own status either. The other five are followed by|| { ... exit 1; }, which reads the inverted status and does work; they are rewritten too, because telling the two apart needs the tail of every statement parsed, and a guard that guesses wrong there is green on a real one.Switching to the
batsbinary does not rescue the seventy-two, which is the part worth knowing before reviewing this. Its ERR trap carries the identical exemption:bash -c 'set -eE; trap "echo TRAP" ERR; ! true; echo REACHED'printsREACHED, runs no trap and exits zero. The only negated assertionbatsreports is one standing last in the test body, where the inverted status happens to be what the function returns. One of the seventy-two stood last; seventy-one did not.What was lost is specific rather than diffuse.
!is how you spell "this must not appear", and an absence is what no other assertion in a test covers. The affected suites assert that a migration ran nothing destructive, that no image was copied, that a flag was not passed, that an object does not exist. On that half of their contract they held for any input.Each site is now written as
if cmd; then echo "FAIL: ..."; false; fi, the form already used in the files where this had been noticed one at a time, and each carries a message naming what must not have happened.hack/bats-no-negated-assert.batsscans everyhack/**/*.batsand refuses the old form, so a new one cannot be added silently. Inverted conditions (if !,while !,until !) are untouched, becauseset -eis not what decides a condition.[ ! -f x ]is untouched for a different reason and remains an ordinary failing assertion: the!there is an operator oftest, not negation of a command, so nothing exempts its status.The guard pins its own premise instead of describing it, on both sides. One test asserts the runner still passes a bare negated assertion; a second, that neither
set -enor the ERR trap sees an inverted status; a third, that the prescribedif cmd; then ...; false; fidoes fail, under both runners, and at the form rather than somewhere before it, since a run that died early would satisfy a bare status check and pin nothing.The third is the one every rewrite here rests on, and its failure mode is the reason it exists rather than being left implied. Were
falseinside athenbody ever to stop propagating, the first test would still pass, because a negated assertion would still be vacuous, while all seventy-seven converted absences went silently green. A ban that prescribes a replacement owes an observer on the replacement, or it is the defect it removes, one level up.Every claim the guard makes is pinned by removing the thing it claims, because on this file reading was repeatedly not enough. The recursion is exercised through the audit function against a fixture in a subdirectory, not through a second copy of its
findexpression: a copy keeps passing while the function loses its recursion, and an audit that returns empty reads as a clean tree.hack/e2e-apps/is what makes recursion matter, sinceBATS_UNIT_FILESis a non-recursivewildcardand that directory is the one placemake unit-testsnever reaches. It is not what the test pins against, though, because it is emptying as suites move to Chainsaw and would eventually fail the test for a reason unrelated to recursion.The rule's surface was checked in the other direction too. The same shape appears nowhere in
hack/**/*.sh(including the libraries bats files source) or in the Chainsawscript:steps, so.batsis the whole surface rather than the part that was convenient to guard. Both zeros were taken twice, line-by-line and with continuations folded, because a line-oriented search cannot see a statement split across a continuation. The first attempt at the folded pass disagreed until itsif-context filter was restored, which is why the answer is stated as measured twice rather than as measured.The scan is lexical, so its header names the statement boundaries it owns rather than implying it understands shell:
;&&&||{(, the)closing a case pattern, and the wordsthendoelse, each measured against the runner before being added. A keyword may legally be preceded only by whitespace or by;, since every other separator in front ofthen,doorelseis a shell syntax error, checked withsh -nrather than recalled. The scan therefore admits exactly those two, and that part of the class is closed rather than patched one spelling at a time. All three keywords are vacuous after either spelling, so missing one is a silent miss, the single direction this scan must not err in, given that its whole design trades noise for safety.Four over-counts remain and are deliberate, each named in the header:
( ! cmd )does propagate its inverted status and is reported anyway; so would a!opening a statement after a redirection&;x=$(true || ! false)is reported although the||reads the inverted status; and a condition continued by a trailing&&instead of a backslash is reported, because the strip only recognises a condition whose end it can see. Each costs one rewritten line, where the opposite error costs a silent pass. They are enumerated rather than engineered away on purpose, because a removed error leaves no trace while a listed one tells the next reader what they are looking at. For the same reason, the one place the scan errs quiet is named too: a file cut off midway through a line continuation loses its last buffered statement. That input is not a runnable test, sincesh -nrejects it withunexpected end of file, so nothing executable can hide there. The header still claims the scan errs loud or not at all, and this is the exception to that claim rather than to its coverage. The test naming the shapes claims only what its fixture holds. A lexical scan over shell cannot promise it has enumerated the language, and a name that promised it would be false whether or not a gap existed today.The branch name is narrower than the subject and I am leaving it as it is rather than churn it. This is not a defect in
hack/cozytest.shthat moving to thebatsbinary would fix. It is a POSIX rule both runners inherit, and moving tobatswould close one of the seventy-two sites.One suite went red for real once its assertions could fail.
seaweedfs-guard-parityforbids four mutable discriminators by grepping each chart, and both charts carry a template comment block arguing why those signals were rejected, so the grep matched the argument and would have reported a chart for documenting the rule it follows. The charts are correct; the assertion was not. It now strips{{/* ... */}}before matching. Two details there are load-bearing and neither is obvious. The strip removes the comment span, not the line that opens it: dropping the line is shorter and silently loses a discriminator sharing a line with a comment, which is a chart classifying on a forbidden signal and a test reporting that it does not. And the delimiters are checked to balance first, because every{{/*is read as an opener, so one inside a quoted string (these charts carry longfail (printf ...)messages) would swallow the rest of the file and leave the checks reading nothing.A last commit widens six comments that explained this trap as applying to a "negated pipeline". It is not about pipelines, and the narrower wording invites the conclusion that the other shapes are safe. Comment text only; each of those files already uses the working form.
Verification
make bats-unit-testspasses 556 tests and then stops atmigration-seaweedfs-db-adopt, so the suites ordered after it were run in a second pass: 387 more, 943 green in total, measured in a worktree separate from the one being edited.Three suites are red on this macOS host and each one reproduces at the merge base, so none is this branch's.
migration-seaweedfs-db-adopt: its container cannot write into the bind-mounted temp directory.seaweedfs-naming-audit: fails the same test with the same counts at the merge base.release-changelog-behaviour: gated oncommand -v actanddocker info, it skipped silently while this host had no Docker running, and began failing once Docker came up, on anactincompatibility with the Colima socket path. That last red is a property of the host having Docker at all, which is worth knowing before reading a local run as a regression.Each suite whose assertions changed and that runs on this host was measured before the edit and matches that baseline now. The six suites that received only a reworded comment were run after the change and not before, which is the weaker claim and the true one, since nothing executable in them was touched.
migration-seaweedfs-db-adoptwas then run on Linux, where it passes 15 of 15. Its nine rewritten sites also carry a two-sided check, because a suite that goes green proves nothing on its own: with the pattern swapped for one the same test proves is present, the rewritten form fails the suite and the original!form passes it. Same file, same test, same pattern, same runner, only the spelling differs.Two sites are still unverified and I would rather say so than imply otherwise: both are in
e2e-apps/monitoring-oidc-customconfig, which needs a live cluster. It will not get one soon either, because the directory holding it is wired into no workflow, noMakefiletarget and no suite selector, a leftover of the move to Chainsaw that is filed as #3786. Those two assertions were vacuous before this change and are real now, so whichever way that issue is resolved, they stop lying in the meantime.Screenshots
Not a UI change.
Downstream repositories
Walked the trigger map against the diff file by file. The change touches seven BATS suites, adds one, and adds a convention to
docs/agents/e2e-testing.md. It does not touchhack/e2e-prepare-cluster.bats, which is the node contractansible-cozystackandtalmrestate; it moves and renames nothing underhack/and changes no make target's behaviour, which is whatccpgates on; and it touches no package, schema, enum, default, CRD, label, annotation or metric.Release note
Summary by CodeRabbit
Documentation
Tests