Skip to content

[Tracking] Shell test architecture: vanilla BATS for unit tests, assert library, testable suite shell #3453

Open

Description

@myasnikovdaniil

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 uploads chainsaw-report.xml, the shell suite currently emits nothing machine-readable.
  • -j, --filter-tags, and bats-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, then cozy_cleanup. hack/cozytest-capture-gate.bats (fix(hack): arm cozytest cluster captures for e2e suites only #3645) pins the e2e- 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-rolled until … do sleep 2; done discards 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.sh is #!/bin/sh and sources a .bats into its own process, so a sourced library's top-level code runs in dash on the Ubuntu runners, where set -o pipefail is 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/sh is bash) or Debian trixie (dash 0.5.13 added pipefail), so it lands green locally and red in CI, and every BATS-sourced library in the tree is deliberately pipefail-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 || true and 2>/dev/null on assertion-bearing calls and bare until … kubectl loops outside retry_until. Neither shellcheck nor such a gate exists in .pre-commit-config.yaml or 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 emits return 0 before the closing brace, for every bare column-0 }, including one that closes an ordinary helper defined at file scope, not only a @test block. A helper that ends by falling off its own end therefore returns 0 whatever its body exited with: a three-line subject_fails() { false } returns 0 under cozytest.sh and 1 under vanilla BATS. The dangerous direction is a helper wrapping the subject under test, where run_migration() { docker run … } reports success while the subject exits 1, so every assertion that the subject succeeded passes vacuously. Inline code in a @test body 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, as hack/md-no-hardwrap.bats does. A second instance was hack/ghcr-mirror_test.bats, which defined a timeout() stub whose voided status was fatal under bash and invisible under dash. The file was deleted in 186547d83. timeout() stubs remain in run-kubernetes-block-io_test.bats, run-kubernetes-cpu-throttle_test.bats and run-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.
  • skip is undefined, so it resolves to command not found, exits 127 under set -e, and fails the test plus aborts the file, inverting the intent of a graceful-degradation branch. Live today in hack/e2e-install-cozystack.bats, dormant only because CI always has the ValidatingAdmissionPolicy API. It is the only skip call in any .bats file under hack/.
  • ! cmd is vacuous under set -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 use if cmd; then …; false; fi instead. 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 with return 0 injected into it. The probe only failed because the fixture was piped through jq. 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 become test_foo_bar, the second definition wins, and one test silently disappears.
  • A malformed @test line kills the whole file with a bare command 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 EXIT handlers 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.bats keeps the ban and the ratchet, 4 traps remain in two files (see the status).
  • 1. Switch bats-unit-tests to bats -j, add the shared set -u setup() 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 lands make bats-unit-tests stays on cozytest, run as one make target per file.
  • 2. Audit _lib/*.sh and the inline script: blocks for discarded exit status and fix the instances found: 93 || true in _lib, 57 in the suite YAMLs, 8 hand-rolled until … kubectl loops. The assert_ok / retry_until helpers get written here, shaped by the call sites that need them. Not started.
  • 3. Extract gateway's inline script: blocks into _lib with 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.sh coverage. Partly done: 17 run-kubernetes-*_test.bats files exist, etcd-cleanup.sh still has no test.
  • 5. Lint gate, once the helpers exist to redirect violators at: reject || true and 2>/dev/null on assertion-bearing calls, hand-rolled until … kubectl loops, BATS-only idioms in the live-cluster files, and inline script: bodies over the line cap. Add shellcheck, which no lane runs today. Not started.
  • 6. Convert the 4 live-cluster files to plain .sh sourcing the helpers, and reduce cozytest.sh to a traced snapshotting script runner. Last: highest cost, smallest population.
  • 7. Give hack/e2e-apps/monitoring-oidc-system.bats and hack/e2e-apps/monitoring-oidc-customconfig.bats a lane. Closed 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: packages/system/monitoring/tests/oidc_test.yaml already 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 and rd-presets-check), grafana-operator injecting an env var into a Deployment it owns (not reachable by any change here), and that no KeycloakRealmGroup is 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

Activity

  1. added
    kind/cleanupCategorizes issue or PR as related to cleanup of code, process, or technical debt
    area/ciIssues or PRs related to CI workflows, GitHub Actions, automation
    area/testingIssues or PRs related to testing (e2e, bats, unit tests)
    priority/important-longtermImportant over the long term, but may not be staffed and/or may need multiple releases to complete
    on Jul 27, 2026
  2. myasnikovdaniil commented on Aug 12, 2026

    @myasnikovdaniil
    ContributorAuthor

    Hit this on #3768 and have a reproduction that adds to the first bullet, the parser voiding helper exit codes.

    The } rewrite also catches a timeout() stub, and whether it bites depends on what /bin/sh is, so it is invisible in CI and fatal locally. hack/ghcr-mirror_test.bats:118-122 defines a timeout() stub, the awk turns its closing brace into return 0 plus }, and callers read its status through log=$(timeout ... ).

    Under dash set -e applies inside the command substitution, so the stub dies on the failing "$@" and never reaches the injected return 0. Under bash errexit is suppressed there, return 0 runs, and the caller sees rc 0.

    Counts on that one file:

    • real bats 1.14.0: 1..63, 63 ok, 0 not ok
    • cozytest, /bin/sh is dash (CI): green
    • cozytest, /bin/sh is bash (Arch): 19 OK then abort at test 20, 44 never run, exit 1
    • same with per test isolation: 61 pass, 2 fail

    Reproduces on base cfaf2e086 too, 18 OK and abort at the same test, so it is not from that PR.

    So on Arch make unit-tests dies 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.

  3. myasnikovdaniil commented on Aug 14, 2026

    @myasnikovdaniil
    ContributorAuthor

    Audited the plan against main at 09dc7f47f. Nothing on it has landed, and item 4 is the only one with progress, since _lib/run-kubernetes.sh coverage went from the drain path alone to seven suites (#3159, #3548, #3575, #3579, #3637, #3676, #3723) while _lib/etcd-cleanup.sh still 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 regresses set -u across 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 the EXIT-trap population countable, eight unit files still declare 67 test-level handlers between them, and under bats(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 and release-changelog-behaviour.bats opens 10 bare subshells against its 9 handlers, so the blocking count is between 14 and 67, not exactly 67. New item 7: the two hack/e2e-apps/monitoring-oidc-*.bats files 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, inline script: blocks grew from 44 to 57 with 23 over the proposed cap, and #3431 is closed.

  4. myasnikovdaniil commented on Aug 14, 2026

    @myasnikovdaniil
    ContributorAuthor

    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.bats greps 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 Chainsaw script: 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 under set -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 gh and jq the way the verifier suite stubs flux would 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.

  5. myasnikovdaniil commented on Sep 2, 2026

    @myasnikovdaniil
    ContributorAuthor

    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.yaml covers the same surface with 37 cases against their 11, and covers more of it: mode=None and null handling, upgrade-safety guards on the users-Job, and oidc_crd_missing_test.yaml for 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:

    • the aggregated API accepting spec.oidc.mode=System / =CustomConfig — that is the ApplicationDefinition schema, already guarded by codegen-drift and rd-presets-check, and a broken schema reddens every suite rather than this one;
    • grafana-operator injecting GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET into 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;
    • that no KeycloakRealmGroup is provisioned — a real gap, since oidc-keycloak.yaml only 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 the select-e2e.sh mapping stays for the reason its own comment gives.

  6. lexfrei commented on Sep 7, 2026

    @lexfrei
    Contributor

    One more divergence in that hand-rolled subset, and it is the silent kind.

    hack/cozytest.sh rewrites every line consisting of a bare } in column zero into return 0 followed by }. The awk rule is /^}$/ at line 298, so it does not distinguish a @test block's closing brace from a helper function's. Any helper written in the usual shape gets return 0 inserted 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.

  7. lexfrei commented on Sep 7, 2026

    @lexfrei
    Contributor

    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 -e as 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 appended return 0. A minimal case whose helper ends in false fails 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 N survives. 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.

  8. lexfrei commented on Sep 7, 2026

    @lexfrei
    Contributor

    A third instance of the same divergence, and this one is load-bearing across the whole unit suite.

    36 files under hack/*.bats read BATS_TEST_FILENAME to locate the repo root, counted on cd675e113. hack/cozytest.sh never sets it: grep -c BATS_TEST_FILENAME hack/cozytest.sh returns 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. Move cozytest.sh anywhere 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.

  9. lexfrei commented on Sep 9, 2026

    @lexfrei
    Contributor

    One more divergence between the two runners, and it points the opposite way from the ones listed above. Self-contained repro:

    _probe() {
      _p_body="$(bash -c 'exit 3' 2>/dev/null)"
      if [ -z "$_p_body" ]; then
        echo refused
        return 0
      fi
      echo body
    }
    
    @test "verdict from a helper whose assignment fails" {
      got="$(_probe)"
      [ "$got" = refused ]
    }

    Under bats this is ok 1. The helper's assignment takes the failing substitution, execution carries on to the if, and the verdict comes back normally. Under hack/cozytest.sh the 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 bats shebang 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 since bats-unit-tests calls only hack/cozytest.sh (Makefile:191), the lane that decides in CI is the strict one while a contributor running bats by 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.

  10. lexfrei commented on Sep 10, 2026

    @lexfrei
    Contributor

    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:

    # ... it must not be the last line.
    ! grep -qi 'FLUSHALL' "$tmp/rc.log"
    ! grep -q -- '--pipe' "$tmp/rc.log"
    [ ! -s "$tmp/rc.log" ]

    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.

  11. myasnikovdaniil commented on Oct 2, 2026

    @myasnikovdaniil
    ContributorAuthor

    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 EXIT handlers are converted, 4 traps are left in two files (capk-provider_test.bats and e2e-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.sh is 6272 lines (was 2893) and || true in _lib is 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.

  12. lexfrei commented on Oct 2, 2026

    @lexfrei
    Contributor

    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:

    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 .bats so that cozytest's snapshot capture covers it until item 6.

  13. myasnikovdaniil commented on Oct 2, 2026

    @myasnikovdaniil
    ContributorAuthor

    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 0 in 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area/ciIssues or PRs related to CI workflows, GitHub Actions, automationarea/testingIssues or PRs related to testing (e2e, bats, unit tests)kind/cleanupCategorizes issue or PR as related to cleanup of code, process, or technical debtpriority/important-longtermImportant over the long term, but may not be staffed and/or may need multiple releases to completetriage/acceptedIndicates an issue is ready to be actively worked on

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions