Skip to content

test(tests): make negated BATS assertions able to fail - #3790

Open
Aleksei Sviridkin (lexfrei) wants to merge 5 commits into
mainfrom
fix/cozytest-negated-assertions
Open

test(tests): make negated BATS assertions able to fail#3790
Aleksei Sviridkin (lexfrei) wants to merge 5 commits into
mainfrom
fix/cozytest-negated-assertions

Conversation

@lexfrei

@lexfrei Aleksei Sviridkin (lexfrei) commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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 ! from set -e, so the failing status is dropped where it is produced, and hack/cozytest.sh closes each test function with return 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 bats binary 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' prints REACHED, runs no trap and exits zero. The only negated assertion bats reports 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.bats scans every hack/**/*.bats and refuses the old form, so a new one cannot be added silently. Inverted conditions (if !, while !, until !) are untouched, because set -e is not what decides a condition. [ ! -f x ] is untouched for a different reason and remains an ordinary failing assertion: the ! there is an operator of test, 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 -e nor the ERR trap sees an inverted status; a third, that the prescribed if cmd; then ...; false; fi does 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 false inside a then body 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 find expression: 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, since BATS_UNIT_FILES is a non-recursive wildcard and that directory is the one place make unit-tests never 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 Chainsaw script: steps, so .bats is 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 its if-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 words then do else, 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 of then, do or else is a shell syntax error, checked with sh -n rather 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, since sh -n rejects it with unexpected 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.sh that moving to the bats binary would fix. It is a POSIX rule both runners inherit, and moving to bats would close one of the seventy-two sites.

One suite went red for real once its assertions could fail. seaweedfs-guard-parity forbids 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 long fail (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-tests passes 556 tests and then stops at migration-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 on command -v act and docker info, it skipped silently while this host had no Docker running, and began failing once Docker came up, on an act incompatibility 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-adopt was 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, no Makefile target 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 touch hack/e2e-prepare-cluster.bats, which is the node contract ansible-cozystack and talm restate; it moves and renames nothing under hack/ and changes no make target's behaviour, which is what ccp gates on; and it touches no package, schema, enum, default, CRD, label, annotation or metric.

Release note

NONE

Summary by CodeRabbit

  • Documentation

    • Added guidance prohibiting negated Bats assertions that can mask test failures.
    • Documented the required explicit conditional failure pattern and updated the reviewer checklist.
  • Tests

    • Added automated checks to detect unsafe negated assertions in E2E tests.
    • Updated existing tests with clearer failure messages and reliable conditional checks.
    • Expanded Helm template comment handling coverage in SeaweedFS guard tests.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: abc8a198-fbd2-458a-b712-d20f371eb932

📥 Commits

Reviewing files that changed from the base of the PR and between 3c84002 and 3f24b65.

📒 Files selected for processing (1)
  • docs/agents/e2e-testing.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/agents/e2e-testing.md

📝 Walkthrough

Walkthrough

The 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.

Changes

Negated BATS assertion enforcement

Layer / File(s) Summary
Policy and repository audit
AGENTS.md, docs/agents/e2e-testing.md, hack/bats-no-negated-assert.bats
Documents the prohibited ! assertion form, adds the explicit if ...; then ...; false; fi pattern, and tests recursive scanning, exclusions, line reporting, and runner behavior.
Explicit failure assertion migration
hack/*.bats, hack/e2e-apps/*, hack/migration-50-etcd-adopt.bats, hack/migration-seaweedfs-db-adopt.bats, hack/nightly-mirror_test.bats, hack/promote-retag_test.bats, hack/release-changelog-contract.bats
Replaces negated assertions with explicit conditional failures and diagnostic messages. Related comments explain the set -e behavior.

SeaweedFS template parity validation

Layer / File(s) Summary
Comment-stripped template validation
hack/seaweedfs-guard-parity.bats
Adds Helm comment stripping with inline and multiline fixture coverage. The discriminator checks validate balanced comments and inspect executable template content.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 3f24b

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
Loading

Possibly related issues

Possibly related PRs

Suggested labels: kind/bug, area/ci

Suggested reviewers: myasnikovdaniil

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: replacing ineffective negated BATS assertions so they fail correctly.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/cozytest-negated-assertions

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between e944619 and 001d62c.

📒 Files selected for processing (16)
  • AGENTS.md
  • docs/agents/e2e-testing.md
  • hack/bats-no-negated-assert.bats
  • hack/build-matrix_test.bats
  • hack/capture-dataplane.bats
  • hack/capture-previous-logs.bats
  • hack/common-envs_test.bats
  • hack/cozyreport.bats
  • hack/e2e-apps/monitoring-oidc-customconfig.bats
  • hack/e2e-install-cozystack.bats
  • hack/migration-50-etcd-adopt.bats
  • hack/migration-seaweedfs-db-adopt.bats
  • hack/nightly-mirror_test.bats
  • hack/promote-retag_test.bats
  • hack/release-changelog-contract.bats
  • hack/seaweedfs-guard-parity.bats

Comment thread docs/agents/e2e-testing.md
Comment on lines +123 to +128
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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 -240

Repository: 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}")
PY

Repository: 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}")
PY

Repository: 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:


🏁 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}")
PY

Repository: 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}")
PY

Repository: 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-128
  • hack/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.

Comment on lines +53 to +70
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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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 1

Repository: 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.bats

Repository: 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:


🌐 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:


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.

@github-actions github-actions Bot added size/XL This PR changes 500-999 lines, ignoring generated files area/testing Issues or PRs related to testing (e2e, bats, unit tests) labels Aug 12, 2026
@lexfrei Aleksei Sviridkin (lexfrei) added the debug Debugging in progress label Aug 14, 2026
@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the fix/cozytest-negated-assertions branch 3 times, most recently from cd55ebf to 3c84002 Compare August 15, 2026 07:54
@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the fix/cozytest-negated-assertions branch from 3c84002 to 3f24b65 Compare August 15, 2026 12:23
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>
@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the fix/cozytest-negated-assertions branch from 3f24b65 to a9ac54e Compare August 15, 2026 15:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/testing Issues or PRs related to testing (e2e, bats, unit tests) debug Debugging in progress size/XL This PR changes 500-999 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant