Repository navigation
[Tracking] Shell test architecture: vanilla BATS for unit tests, assert library, testable suite shell #3453
Description
Summary
The repo has one shell test runner, hack/cozytest.sh, serving two different jobs: hermetic unit tests of hack/*.sh and live-cluster imperative tests. It reads a hand-rolled subset of the BATS grammar while every test file carries a #!/usr/bin/env bats shebang, so the format advertises semantics the runner does not implement, and the divergences fail silently rather than loudly. Now that Chainsaw owns the app suites (#2979), the live-cluster population has shrunk while the unit population has grown, and the two want opposite things from a runner. This issue proposes splitting them by execution context, moving the unit suite to real BATS (which measurably costs nothing), keeping cozytest for the live suite where its trace and snapshot behaviour genuinely earn their place, and fixing the swallowed-exit-status class in the shell that already runs against live clusters.
Status as of 2026-10-01
Checked against main at bc400dff1.
Two items moved. Item 1a is done: #3848 merged on 2026-09-30 and closed #3623, all eight unit files that declared test-level EXIT handlers (67 of them) are converted. 4 traps are left in two files, capk-provider_test.bats (3) and e2e-test-openapi.bats (1), both still declared in the # EXIT-TRAP DEBT ratchet. Item 7 is done too, #4019 merged on 2026-09-25 and deleted the two monitoring OIDC suites.
Item 1, the runner switch, is #3849 (stacked on #3848, open, CHANGES_REQUESTED). #3497 and #3498 were closed unmerged on 2026-08-16, and #3849 is the flattened result the previous status asked for. make bats-unit-tests on main is still cozytest.sh, but it runs as one make target per file since e49633208 (2026-09-02), so make -j works without bats, and #4633 (2026-10-01) makes it keep going after a failing file. The suite grew from 60 files / 1025 tests to 149 files / 2045 tests.
Items 2, 3, 5 and 6 are untouched and the surface keeps growing: run-kubernetes.sh went from 2893 to 6272 lines and || true in _lib/*.sh from 79 to 93. Item 4 is partial, _lib/etcd-cleanup.sh still has no test. skip is still undefined in cozytest.sh, no assert library exists and no lane runs shellcheck. Open PRs on the same defect class that this body didn't list: #3933 (the interim skip shim), #3790 (negated ! assertions), #3929 (negated containsDocument without any: true) and #4027 (parse every /bin/sh surface with dash).
Measurements
Re-measured against main on 2026-08-13 except where noted.
| Measurement | Result |
|---|---|
Unit .bats files, hack/*.bats minus the e2e- prefix |
149 files, 2045 @test blocks (was 60 / 1025) |
| Unit files passing under vanilla BATS | #3849 reports 1943 Bats cases in 139 files passing, on its own branch which predates some files now on main |
Unit files still installing test-level EXIT traps |
0 debt files left. 4 traps remain in capk-provider_test.bats (3) and e2e-test-openapi.bats (1). Was 8 files / 67 handlers before #3848 |
make bats-unit-tests (cozytest) |
one make target per file, CI runs make unit-tests test-controllers -j4 -k |
Vanilla BATS, serial / -j 8 |
128s / 26s (2026-07-31, over 32 files, not re-run) |
| Live-cluster BATS population | 4 files (hack/e2e-{install-cozystack,prepare-cluster,prepare-cluster-container,test-openapi}.bats), all on cozytest |
| Orphaned live-cluster BATS files | 0, the two monitoring OIDC suites were deleted in #4019 |
Chainsaw _lib/*.sh with a unit test |
7 of 8, only etcd-cleanup.sh (89 lines) uncovered |
Chainsaw _lib/ total size |
7455 lines across 8 files, run-kubernetes.sh alone is 6272 |
Chainsaw inline script: blocks |
92 across 20 of the 23 runnable suites, 39 of them over 10 lines, gateway has 22 (18 over 10 lines, the largest 231) |
|| true in the live-cluster shell |
93 in _lib/*.sh (72 in run-kubernetes.sh), 57 in the suite YAMLs |
Hand-rolled until/while … kubectl loops |
8 (_lib/run-kubernetes.sh 3, gateway 4, openbao 1) |
| Does BATS stream test output live? | No, it buffers until the test ends, even under -x (2026-07-31) |
The earlier load-bearing measurement was 32 of 32 unit files passing under vanilla BATS with no source edits, because their authors wrote to the intersection of both runners (${BATS_TEST_FILENAME:-$0}, repo-root-relative paths, no run/$status/skip). #3849 is the re-measurement over the current suite now, so quote its CI and not the 32/32 number.
Three execution contexts, three verdicts
1. Hermetic unit tests of hack/*.sh → vanilla BATS
Switch bats-unit-tests to invoke bats -j over the existing BATS_UNIT_FILES glob. What this buys, none of which cozytest can offer:
--filter-status failed, rerun only the previous run's failures, which resolves the fail-fast-versus-edit-loop tension instead of trading it off.-F junit/--report-formatter junit, CI annotates the failing test rather than requiring a log read. The Chainsaw suite already uploadschainsaw-report.xml, the shell suite currently emits nothing machine-readable.-j,--filter-tags, andbats-assert-style diffing.- Deletion of the bespoke parser, and with it a whole class of silent failure modes for 60 files at once (see below).
Two strictness regressions to guard, one of them found after this issue was filed.
The first is set -u: cozytest runs test bodies under set -eu -x and BATS does not set -u, so a test that today aborts on an unset variable would silently pass. Verified fix: set -u inside setup() does propagate into the test body, so a shared helper restores parity. hack/test_helper.bash in #3849 is that helper and it is not on main yet. Every unit file has to load it or the migration ships without -u on part of the suite. The [mm:ss] per-line stamps are also lost, which does not matter for tests that finish in milliseconds.
The second was the EXIT-trap population, a prerequisite for the flip. Per docs/agents/e2e-testing.md, verified against Bats 1.14.0, a test body that installs an EXIT trap replaces the one the bats binary installs for its own bookkeeping, and a test that then fails prints no TAP line at all, while hack/cozytest.sh reports the same failure correctly. #3848 cleared the eight files that declared 67 handlers between them. hack/bats-no-exit-trap.bats keeps the ban and the per-file # EXIT-TRAP DEBT ratchet, so the flip no longer waits on this.
On the pre-commit question: 26s makes a pre-commit gate viable, though that figure was taken over 32 files and needs re-running. The durable wins are --filter-status, JUnit and parser deletion, not wall-clock.
2. Live-cluster imperative tests → keep cozytest as the runner, stop growing it
The population here is 4 files (the 2 orphans are gone, see item 7), and the right change is subtractive.
Two of cozytest's properties are genuinely right for a 15-minute live test and are not available from BATS:
- Live streaming trace with elapsed stamps. BATS buffers a test's output until it completes (verified, including under
-x), so a long install step is a black box while it runs. - The EXIT-trap hook that snapshots before cleanup, perishable previous-instance container logs first, then
crust-gather, thencozy_cleanup.hack/cozytest-capture-gate.bats(fix(hack): arm cozytest cluster captures for e2e suites only #3645) pins thee2e-prefix that arms it.
These 4 files are already imperative scripts, install then assert, and they already have no per-test isolation: cozytest shares file scope and aborts at the first failure, which is exactly plain-script semantics. So convert each to a plain .sh sourcing the assertion library as it is next touched, and reduce cozytest to what it is actually good at, a traced snapshotting script runner with no @test parser. That also makes these files unit-testable under context 1, which is the ask in context 3 applied to context 2.
The two monitoring OIDC suites that had no invocation site are covered by item 7 below, they were deleted in #4019.
3. Shell embedded in Chainsaw suites → extract, then unit-test
Already largely done at the library level: 7 of the 8 _lib scripts have unit tests (ghcr-mirror.sh was removed in 186547d83, run-proxmox.sh is new). _lib/etcd-cleanup.sh still has none, and _lib/run-kubernetes.sh has grown to 6272 lines against 17 run-kubernetes-*_test.bats files, so its coverage fraction is not established by that count.
The real gap is the 92 inline script: blocks, which are untestable by construction and have grown from 44. Proposed rule: a script: body over roughly 10 lines must be extracted to _lib/<name>.sh taking arguments, with a hack/<name>_test.bats beside it, and inline stays for one-liners. 39 blocks are over that cap today. Enforceable by counting lines under content: | per block. gateway is the largest single win: 22 blocks, 18 of them over the cap, the largest at 231 lines.
The cross-cutting defect: swallowed exit status
The concrete failure that motivates this, a kubectl exec error inside a wait loop going unreported, is a shell problem rather than a runner problem. set -e does not fire in if/while conditions, in && chains, in subshells whose status is discarded, in pipelines without pipefail, or in a loop body whose last command happens to succeed. Changing runners does not address any of those.
The instances live in the shell that already runs against live clusters: the 7455 lines under hack/e2e-chainsaw/_lib/ and the 92 inline script: blocks. Measured surface: 93 || true in _lib/*.sh, 72 of them in run-kubernetes.sh, 57 more in the suite YAMLs, and 8 hand-rolled until/while … kubectl loops. That is where the audit-and-fix work belongs, and the shared helpers below should be written there as those call sites need them rather than up front as a library with no consumer. The unit suite does not need them, its files assert with plain shell today and pass, and no bats-assert vendoring is proposed.
Shared wait/assert helpers, shaped by their call sites
hack/e2e-chainsaw/_lib/ already hosts shared helpers of this kind (remediation-guard.sh), so they belong beside them, and move only if a second execution context turns out to need them.
assert_ok <cmd…>captures rc and stdout/stderr, prints both on failure, returns non-zero. Never|| true.retry_until <timeout> <cmd…>as the only sanctioned wait loop. A hand-rolleduntil … do sleep 2; donediscards every intermediate failure, which is precisely the reported bug. On exhaustion this prints the last rc and the last output.assert_eq/assert_contains/assert_json_eq, each printing both sides on mismatch.- POSIX-only, with no reliance on
pipefail.cozytest.shis#!/bin/shand sources a.batsinto its own process, so a sourced library's top-level code runs in dash on the Ubuntu runners, whereset -o pipefailis not POSIX and dash 0.5.12 rejects it outright (set: Illegal option -o pipefail, exit 2), aborting the file before the first test reports. It does not reproduce on Arch (/bin/shis bash) or Debian trixie (dash 0.5.13 addedpipefail), so it lands green locally and red in CI, and every BATS-sourced library in the tree is deliberatelypipefail-free for this reason. Each helper inspects and returns the status it cares about explicitly instead. Item 1 relaxes this for the unit suite only. The live-cluster path in item 6 keeps the constraint. - A lint to keep it true: shellcheck, plus a gate rejecting
|| trueand2>/dev/nullon assertion-bearing calls and bareuntil … kubectlloops outsideretry_until. Neither shellcheck nor such a gate exists in.pre-commit-config.yamlor under.github/workflows/today.
Silent failure modes the parser split removes
Each of these was reproduced against hack/cozytest.sh, and each is still live on main in hack/cozytest.sh.
- The parser voids helper function exit codes, which is the most consequential of these and the correctness argument for item 1 rather than a stylistic one. The awk
/^}$/branch emitsreturn 0before the closing brace, for every bare column-0}, including one that closes an ordinary helper defined at file scope, not only a@testblock. A helper that ends by falling off its own end therefore returns 0 whatever its body exited with: a three-linesubject_fails() { false }returns 0 undercozytest.shand 1 under vanilla BATS. The dangerous direction is a helper wrapping the subject under test, whererun_migration() { docker run … }reports success while the subject exits 1, so every assertion that the subject succeeded passes vacuously. Inline code in a@testbody is unaffected, so this bites precisely when factoring a helper out, which is the refactor that looks safest. The present workaround is to capture and return by hand (_rc=0; cmd || _rc=$?; return "$_rc"), which 5 unit files now do, or to return the status via stdout, ashack/md-no-hardwrap.batsdoes. A second instance washack/ghcr-mirror_test.bats, which defined atimeout()stub whose voided status was fatal under bash and invisible under dash. The file was deleted in186547d83.timeout()stubs remain inrun-kubernetes-block-io_test.bats,run-kubernetes-cpu-throttle_test.batsandrun-kubernetes-join-timing_test.bats, and I haven't checked them for the same bash/dash split. Real BATS does not rewrite function bodies, so item 1 removes the failure mode outright for every unit file. skipis undefined, so it resolves tocommand not found, exits 127 underset -e, and fails the test plus aborts the file, inverting the intent of a graceful-degradation branch. Live today inhack/e2e-install-cozystack.bats, dormant only because CI always has the ValidatingAdmissionPolicy API. It is the onlyskipcall in any.batsfile underhack/.! cmdis vacuous underset -e, since errexit is suppressed for a negated pipeline, so an absence assertion written that way asserts nothing. 24 of the 60 unit files (counted on 2026-08-13) carry a comment reasoning about a vacuous assertion and useif cmd; then …; false; fiinstead. Real BATS does not change this, it is a shell property rather than a parser one, but the workaround's rationale currently reads as cozytest-specific and will need rewording with the runner.- A column-0
}inside a heredoc silently corrupts fixture data. The awk transform rewrites the line wherever it appears, so a JSON fixture is written to disk withreturn 0injected into it. The probe only failed because the fixture was piped throughjq. A test that greps it, or feeds it to a tolerant consumer, passes while asserting against corrupted input. Correctness currently rests on unenforced comments such as "no line below is a bare column-0}". - Titles collide after sanitization.
"foo bar"and"foo-bar"both becometest_foo_bar, the second definition wins, and one test silently disappears. - A malformed
@testline kills the whole file with a barecommand not found. No test in it runs, including the well-formed ones, and nothing counts or reports tests.
The same "asserts nothing" class exists outside the shell suite and is worth fixing alongside it, because the reasoning is identical. helm-unittest exits 0 when it finds no suite files at all, and #3632 closed that by failing on Test Suites: 0 passed, 0 total and requiring positive evidence of a suite that ran (hack/helm-unit-tests.sh:87-93 and :109-120). Still open: a negated containsDocument without any: true negates "EVERY rendered document matches", which any multi-document render satisfies trivially. The rule is documented at packages/core/platform/tests/bundles_isp_hosted_dependency_closure_test.yaml:117-124 and seven assertions still break it (#3929 is open for them), at bundles_proxy_protocol_test.yaml:17,149,166, bundles_proxy_protocol_lookup_test.yaml:55, bundles_proxy_protocol_lookup_nil_test.yaml:27 and bundles_velero_default_test.yaml:36,41.
Plan
Each item is independently mergeable, and each is either self-contained or consumed by the one after it, so nothing is built ahead of a caller.
- 1a. Clear the test-level
EXIThandlers from the 8 unit files that declared them. Done in test(tests): preserve Bats failure reports during fixture cleanup #3848 (merged 2026-09-30, closed testing: 68 test-level EXIT traps in 8 bats files can silence a failing test's TAP line #3623).hack/bats-no-exit-trap.batskeeps the ban and the ratchet, 4 traps remain in two files (see the status). - 1. Switch
bats-unit-teststobats -j, add the sharedset -usetup()helper so strictness is kept across every unit file, emit a JUnit report, and wire the suite into pre-commit. This is test(tests): run unit suites with Bats and retain POSIX coverage #3849 (open,CHANGES_REQUESTED), stacked on test(tests): preserve Bats failure reports during fixture cleanup #3848, the flattened replacement for test(hack): run the unit bats suite under bats(1) #3497 and test(hack): restore set -u and refresh the cozytest-era headers #3498 (closed unmerged 2026-08-16). Until it landsmake bats-unit-testsstays on cozytest, run as one make target per file. - 2. Audit
_lib/*.shand the inlinescript:blocks for discarded exit status and fix the instances found: 93|| truein_lib, 57 in the suite YAMLs, 8 hand-rolleduntil … kubectlloops. Theassert_ok/retry_untilhelpers get written here, shaped by the call sites that need them. Not started. - 3. Extract
gateway's inlinescript:blocks into_libwith unit tests, on the helpers from item 2. 22 blocks now, 18 over the 10-line cap, the largest at 231 lines. Not started. - 4. Unit test for
_lib/etcd-cleanup.sh, and wider_lib/run-kubernetes.shcoverage. Partly done: 17run-kubernetes-*_test.batsfiles exist,etcd-cleanup.shstill has no test. - 5. Lint gate, once the helpers exist to redirect violators at: reject
|| trueand2>/dev/nullon assertion-bearing calls, hand-rolleduntil … kubectlloops, BATS-only idioms in the live-cluster files, and inlinescript:bodies over the line cap. Add shellcheck, which no lane runs today. Not started. - 6. Convert the 4 live-cluster files to plain
.shsourcing the helpers, and reducecozytest.shto a traced snapshotting script runner. Last: highest cost, smallest population. - 7.
GiveClosed by test(e2e): remove unused monitoring suites and audit BATS runners #4019 by DELETION rather than a port. The premise here ("deleting these files would drop the only coverage of the feature") does not hold:hack/e2e-apps/monitoring-oidc-system.batsandhack/e2e-apps/monitoring-oidc-customconfig.batsa lane.packages/system/monitoring/tests/oidc_test.yamlalready covers the same surface with 37 cases against their 11, and more of it, because everything those files inspect is rendered by the chart. Three assertions were theirs alone - the aggregated-API admission (the generated ApplicationDefinition schema, guarded by codegen-drift andrd-presets-check), grafana-operator injecting an env var into a Deployment it owns (not reachable by any change here), and that noKeycloakRealmGroupis provisioned, which was a real gap and is now a unit case.hack/e2e-apps/is empty and removed; the four gates matching that path shape pass without it. See the comment below for the detail.
Interim, independent of the sequence above: add a skip() shim to cozytest.sh so the existing call site in hack/e2e-install-cozystack.bats degrades as intended rather than exiting 127. #3933 is open for it, CHANGES_REQUESTED.
Out of scope here but the same defect class, tracked so it is not lost: the seven negated containsDocument assertions listed above need any: true or they assert nothing.
Related
- [Tracking] Remaining CI / build / release / E2E automation work #3278, umbrella CI/build/release/E2E tracker. This is a testing-infrastructure item under it.
- test(e2e): migrate the E2E app suite from BATS to Kyverno Chainsaw #2979, the BATS-to-Chainsaw app suite migration that shrank the live-cluster population and created the imbalance this issue addresses. Closed 2026-08-14. Its 2 orphaned BATS files became item 7 and were deleted in test(e2e): remove unused monitoring suites and audit BATS runners #4019.
- e2e: move per-test resource cleanup into bats teardown() #2583 proposes moving
hack/e2e-apps/*.batscleanup into a BATSteardown(). Both premises have since lapsed: cozytest has noteardown()(the hook iscozy_cleanup, as the two remaining files' own headers state atmonitoring-oidc-system.bats:22-25), and the app suite was superseded by the Chainsaw suites, which own their own cleanup. Closed as obsolete. Note this closes the cleanup-mechanism question only, the coverage question those same two files raise is item 7 and stays open. - test(e2e): fail when a bats file is run by no runner #3778 was closed unmerged on 2026-09-25. The runner audit went in with test(e2e): remove unused monitoring suites and audit BATS runners #4019 as
hack/bats-runner-coverage.bats, so a bats file that no runner runs fails the build. - test(tests): let each bats file declare the EXIT traps it still holds #3622, the
EXIT-trap ban and the per-file# EXIT-TRAP DEBTratchet, which is what makes item 1a countable. - fix(hack): arm cozytest cluster captures for e2e suites only #3645 pins the
e2e-prefix that arms cozytest's capture legs, so a unit runner no longer walks into them. - fix(tests): fail when a chart runs no Helm unit test suites #3632 closes the helm-unittest fail-open on absent suites, the same "asserts nothing" class as the parser defects above.
- test(tests): preserve Bats failure reports during fixture cleanup #3848, merged 2026-09-30, closed testing: 68 test-level EXIT traps in 8 bats files can silence a failing test's TAP line #3623 and cleared item 1a.
- test(tests): run unit suites with Bats and retain POSIX coverage #3849, the runner switch (item 1), open.
- fix(tests): support skipped tests in cozytest #3933,
skipsupport in cozytest (cozytest.sh implements no skip: a skipping test hard-fails at exit 127 #3827). test: cozytest.sh matches test names by substring, and a pattern that selects nothing exits 0 #3717 (cozytest matches test names by substring) and test: cozytest.sh stops a bats file at its first failing test #4620 (cozytest stops a file at the first failing test) are the other two runner defects open. - test(tests): make negated BATS assertions able to fail #3790, negated
!assertions that cannot fail (Assertions written as ! command cannot fail a test #3565). fix(platform tests): make negated containsDocument assertions load-bearing with any: true #3929, negatedcontainsDocumentwithoutany: true(testing: negatedcontainsDocumentwithoutany: truepasses on any multi-document render #3864).
Activity
test(hack): run the unit bats suite under bats(1)
test(hack): restore set -u and refresh the cozytest-era headers
myasnikovdaniil commentedon Aug 12, 2026
Hit this on #3768 and have a reproduction that adds to the first bullet, the parser voiding helper exit codes.
The
}rewrite also catches atimeout()stub, and whether it bites depends on what/bin/shis, so it is invisible in CI and fatal locally.hack/ghcr-mirror_test.bats:118-122defines atimeout()stub, the awk turns its closing brace intoreturn 0plus}, and callers read its status throughlog=$(timeout ... ).Under dash
set -eapplies inside the command substitution, so the stub dies on the failing"$@"and never reaches the injectedreturn 0. Under bash errexit is suppressed there,return 0runs, and the caller sees rc 0.Counts on that one file:
1..63, 63 ok, 0 not ok/bin/shis dash (CI): green/bin/shis bash (Arch): 19 OK then abort at test 20, 44 never run, exit 1Reproduces on base
cfaf2e086too, 18 OK and abort at the same test, so it is not from that PR.So on Arch
make unit-testsdies with two thirds of the file unrun while CI is green on the same commit. Another reason for item 1, real bats does not rewrite function bodies so both the voided status and this divergence go away.fix(tests): fail when a chart runs no Helm unit test suites (#3632)
myasnikovdaniil commentedon Aug 14, 2026
Audited the plan against
mainat09dc7f47f. Nothing on it has landed, and item 4 is the only one with progress, since_lib/run-kubernetes.shcoverage went from the drain path alone to seven suites (#3159, #3548, #3575, #3579, #3637, #3676, #3723) while_lib/etcd-cleanup.shstill has no test. Item 1 changes shape: #3497 and #3498 get flattened into one PR instead of landing as a stack, because split as they are #3497 alone regressesset -uacross all 60 unit files and #3498 alone is a no-op, so neither half is independently landable, and flattening is not sufficient either, since #3498's shim reaches 32 of the 60 files. It also gains a prerequisite, item 1a. #3622 made theEXIT-trap population countable, eight unit files still declare 67 test-level handlers between them, and underbats(1)a failing test inside an inline one prints no TAP line at all (docs/agents/e2e-testing.md:47, verified against Bats 1.14.0), so the runner must not be flipped until they are cleared or shown to be subshell-scoped. Only four of the eight were sampled andrelease-changelog-behaviour.batsopens 10 bare subshells against its 9 handlers, so the blocking count is between 14 and 67, not exactly 67. New item 7: the twohack/e2e-apps/monitoring-oidc-*.batsfiles have no invocation site anywhere, no Chainsaw suite covers monitoring OIDC, and they hold 11 assertions nothing else in the tree makes. They need a lane, and deleting them would drop the only coverage of the feature. Also folded in: the unit suite grew from 32 files / 328 tests to 60 / 1025 so the "32/32 under vanilla BATS with zero edits" result covers about half of it, inlinescript:blocks grew from 44 to 57 with 23 over the proposed cap, and #3431 is closed.myasnikovdaniil commentedon Aug 14, 2026
Adding one from #3795's review, because it is the same class this issue tracks rather than a new one: shell that lives somewhere it cannot be executed by a test.
scooby87 asked for fixture-driven coverage of the retention workflow's inline bash.
hack/promotion-retention-contract.batsgreps the workflow source for the protection and sweep logic but never executes the awk pin-extraction, the two-source protection, or the eligibility sweep. That matches how workflow-inline bash is tested in this repo today, which is the point: it is the same reason the 57 inline Chainsawscript:blocks are untestable by construction, one layer up.What makes it worth more than the general case is the side effect. Retention is the only path in that PR that deletes anything (
gh api -X DELETE), and two of its guards were found by reading rather than by a test: a listing failure aborting the whole job underset -euo pipefail, and a failed org-package listing exiting green above the guard that was supposed to catch it. Both are fixed, both are covered structurally, and neither is covered by anything that runs the code.A harness that stubs
ghandjqthe way the verifier suite stubsfluxwould cover it. Recording it here rather than opening a separate issue, since the fix is the same shape as items 2, 3 and 5 above and would share whatever they build.test(hack): make the strict-mode helper cover the unit suite by audit
test(hack): run the unit bats suite under bats(1)
test(hack): make the strict-mode helper cover the unit suite by audit
test(hack): run the unit bats suite under bats(1)
myasnikovdaniil commentedon Sep 2, 2026
Item 7 is closed by #4019, by deletion rather than by the Chainsaw port it asked for.
The item rests on "deleting these files would drop the only coverage of the feature". That turns out not to hold.
packages/system/monitoring/tests/oidc_test.yamlcovers the same surface with 37 cases against their 11, and covers more of it:mode=Noneand null handling, upgrade-safety guards on the users-Job, andoidc_crd_missing_test.yamlfor the absent-CRD path, none of which the BATS files have. Everything those files inspect is rendered by the chart itself (grafana/grafana.yaml,oidc-keycloak.yaml,oidc-users-job.yaml,oidc-client-secret.yaml), which is why the overlap is near total.Of the 11 assertions the item lists, three were theirs alone:
spec.oidc.mode=System/=CustomConfig— that is the ApplicationDefinition schema, already guarded by codegen-drift andrd-presets-check, and a broken schema reddens every suite rather than this one;GF_AUTH_GENERIC_OAUTH_CLIENT_SECRETinto the Deployment — that Deployment is the operator's, so no change in this repository can make it fail; the chart's half is already a unit case;KeycloakRealmGroupis provisioned — a real gap, sinceoidc-keycloak.yamlonly stated it in a comment. test(e2e): remove unused monitoring suites and audit BATS runners #4019 adds it as a unit case with a positive control beside it.So the repayment is one assertion, not a port.
hack/e2e-apps/is now empty and removed; the four gates that reference the path shape pass without it, and theselect-e2e.shmapping stays for the reason its own comment gives.lexfrei commentedon Sep 7, 2026
One more divergence in that hand-rolled subset, and it is the silent kind.
hack/cozytest.shrewrites every line consisting of a bare}in column zero intoreturn 0followed by}. The awk rule is/^}$/at line 298, so it does not distinguish a@testblock's closing brace from a helper function's. Any helper written in the usual shape getsreturn 0inserted before its close, and its exit status is gone before the caller ever sees it.That kills a whole class of check by construction: a helper that reports failure through its status reports nothing, and the caller reads success. It surfaced while implementing a reviewer's suggestion to have an awk-driven helper exit non-zero at EOF — written exactly as suggested, the test passed on a fixture that must fail. The workaround is to signal through stdout and grep for a marker in the caller, which is what that suite does now.
Same shape as the negated-assertion exemption in the same runner: the file advertises bats semantics, the runner implements something else, and the divergence comes out green rather than loud. Both are arguments for the split this issue proposes rather than separate complaints.
lexfrei commentedon Sep 7, 2026
Correcting my own comment above: the scope I gave it is wrong, and building the demonstration is what showed it.
I wrote that the caller reads success when a helper's status is erased. It does not, in the ordinary case. The converter also emits
set -eas the first line of every@test(same awk block, line 295), so a failing command inside a normally-invoked helper ends the test where it fails and never reaches the appendedreturn 0. A minimal case whose helper ends infalsefails rather than passes.What survives is narrower and still worth the split. The appended line makes the fall-through status unconditionally 0, so a helper that falls off its end cannot distinguish a successful read from an empty one. An explicit
return Nsurvives. And the masking is observable wherever errexit is suppressed — in a condition, after||, under!— which is exactly where a helper's status is being read on purpose.The sharper form of the argument for this issue is not the missing feature but the inverted verdict: the same file, the same helper, opposite results under the two runners. That is a divergence nothing in the tree detects, since neither runner is wrong on its own terms.
Worth noting how the error got this far: my comment carried the word "measured", which reads as checked and is exactly what stops anyone checking it. It took writing a command rather than a sentence.
lexfrei commentedon Sep 7, 2026
A third instance of the same divergence, and this one is load-bearing across the whole unit suite.
36 files under
hack/*.batsreadBATS_TEST_FILENAMEto locate the repo root, counted oncd675e113.hack/cozytest.shnever sets it:grep -c BATS_TEST_FILENAME hack/cozytest.shreturns 0. Under the real bats binary the variable is the path of the file being executed; under the repo's runner it is unset, so the resolution falls back to the runner's own$0.It works today for a reason that is not a property of the tests: the runner happens to live in
hack/, the same directory as the files it runs. Movecozytest.shanywhere else and those suites start resolving paths from the wrong place, with no error at the point of the mistake.The count is 37 once #3802 merges, because the guard it adds inherits the same shape. That is the sharper half of this: the form is not merely widespread, it reproduces in new code, since nothing declares it a convention and nothing checks it.
That makes it a third form of the pattern in this issue's summary. The first two silently invert or erase a verdict; this one silently relocates the tree the tests read. All three are the same shape: the file declares bats semantics, the runner implements a subset, and the gap is quiet.
lexfrei commentedon Sep 9, 2026
One more divergence between the two runners, and it points the opposite way from the ones listed above. Self-contained repro:
Under
batsthis isok 1. The helper's assignment takes the failing substitution, execution carries on to theif, and the verdict comes back normally. Underhack/cozytest.shthe run stops at that assignment and never reaches the check, with the trace ending at+ _p_body=/+ got=.The direction matters here. Elsewhere in this issue the runner implements less than the
#!/usr/bin/env batsshebang promises, so a move to real BATS picks up behaviour for free. In this case it is the runner that stops and BATS that carries on, and sincebats-unit-testscalls onlyhack/cozytest.sh(Makefile:191), the lane that decides in CI is the strict one while a contributor runningbatsby hand sees a pass on the same file.So this case needs covering explicitly in whatever replaces the runner rather than inherited along with the grammar.
lexfrei commentedon Sep 10, 2026
A fresh instance worth recording, because it shows the class surviving an author who knew about it.
hack/redis-backup-strategy.bats, added this week, has a cleanup test ending with:The comment shows the author knew negated assertions are a trap and deliberately placed them away from the tail. That reasoning is inverted. Under bats the tail is the one position that DOES get caught, because the inverted status becomes the test function's return value; a
!pipeline in the middle is exempt from errexit and reports nothing. So the placement chosen for safety is the placement that removes the only protection those two lines could have had.No hole results here, and that is worth stating plainly so nobody rushes a fix: the third line is
[ ! -s ... ], where the negation belongs to the[builtin rather than to a pipeline, so errexit applies normally. It is also strictly stronger than both lines above it — an empty log implies no FLUSHALL and no--pipe. The assertions that carry the test are fine. What is wrong is that two lines claim to enforce something they cannot, and a comment explains their position with a rule that is backwards.The general shape, for whoever picks this up: knowing about the class is not enough to avoid it, because the natural defence (move the negation off the last line) is precisely wrong, and the resulting code looks more careful than code that got it right by accident. Whatever the fix here ends up being, it is worth making the correct idiom easy to reach for. The working form already in the tree is
if grep -qE -- "PATTERN" "$log"; then echo "unexpected" >&2; return 1; fi.test(hack): make the strict-mode helper cover the unit suite by audit
test(hack): run the unit bats suite under bats(1)
myasnikovdaniil commentedon Oct 2, 2026
State refresh, 2026-10-01
Item 1a is done, #3848 merged on 2026-09-30 and closed #3623. All 8 files that declared test-level
EXIThandlers are converted, 4 traps are left in two files (capk-provider_test.batsande2e-test-openapi.bats). Item 7 is done too, #4019 deleted the two monitoring OIDC suites. #3849 is item 1 and waits for review.Items 2 to 6 have not moved and the surface grew.
run-kubernetes.shis 6272 lines (was 2893) and|| truein_libis 93 (was 79). The unit suite is 149 files / 2045 tests now. The body has the updated table and plan.A correction to my 2026-08-13 text: #3497 and #3498 were closed unmerged on 2026-08-16, #3849 is the flattened replacement.
lexfrei commentedon Oct 2, 2026
myasnikovdaniil one correction and a few links the refresh is missing.
#3849 is waiting on you, not on review. My last CHANGES_REQUESTED (2026-09-29) is on its current head
b6c204f1d, and it conflicts with main since #3848 merged.Open issues of the same class that the body doesn't link:
}case from "Silent failure modes" now has its own issue.One more for the counts: #4712 (the new isp-hosted lane) adds
hack/e2e-install-cozystack-hosted.bats, so the live-cluster population goes from 4 to 5 when it merges. It is a.batsso that cozytest's snapshot capture covers it until item 6.myasnikovdaniil commentedon Oct 2, 2026
Aleksei Sviridkin (@lexfrei) description on #3849 was already fixed after your review, rebase on main is what is left.
Everything about cozytest parser in this thread (heredoc
},return 0in helpers,skip,BATS_TEST_FILENAME) is argument for removing it, and removing is what this issue proposes (items 1 and 6). Items 1a and 7 are done, item 1 is #3849. I won't be fixing the parser, so #4667 is not something I will take, it asks to grow what item 2 says to stop growing. Case for removal is already in the body, more examples of parser defects add nothing to it, so please stop adding them here.#3711 and your 9 Sep repro where bats carries on and cozytest stops are requirements for what replaces the runner, so they go to #3849 review, not this issue.