diff --git a/tests/04_brief_extraction.bats b/tests/04_brief_extraction.bats index fe4c43f..fd51574 100644 --- a/tests/04_brief_extraction.bats +++ b/tests/04_brief_extraction.bats @@ -53,7 +53,7 @@ teardown() { local brief brief=$(git config --file "$proj/.repos/.orbit" --get repos.myrepo.brief) [ "${#brief}" -le 120 ] - [[ "$brief" != *" "* ]] || [[ "$brief" =~ [a-z]$ ]] + refute_contains "$brief" " " || assert_matches "$brief" '[a-z]$' } @test "brief: skips HTML block elements and their plain-text contents" { diff --git a/tests/05_workspace_lifecycle.bats b/tests/05_workspace_lifecycle.bats index bc84e4b..de5f810 100644 --- a/tests/05_workspace_lifecycle.bats +++ b/tests/05_workspace_lifecycle.bats @@ -45,7 +45,7 @@ teardown() { cd "$proj" && orbit new "first task" >/dev/null 2>&1 local created created=$(git config --file "$proj/task-01/.orbit" --get workspace.created) - [[ "$created" =~ ^[0-9]+$ ]] + assert_matches "$created" '^[0-9]+$' } @test "new: --name creates custom-named workspace directory" { @@ -126,7 +126,7 @@ teardown() { cd "$proj/dev" && orbit done >/dev/null 2>&1 local done_at done_at=$(git config --file "$proj/dev/.orbit" --get workspace.done-at) - [[ "$done_at" =~ ^[0-9]+$ ]] + assert_matches "$done_at" '^[0-9]+$' } @test "done: writes done-date in ISO format" { @@ -139,7 +139,7 @@ teardown() { cd "$proj/dev" && orbit done >/dev/null 2>&1 local done_date done_date=$(git config --file "$proj/dev/.orbit" --get workspace.done-date) - [[ "$done_date" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] + assert_matches "$done_date" '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' } @test "done: --pr writes PR URL to [pr] section" { diff --git a/tests/06_worktree_integration.bats b/tests/06_worktree_integration.bats index 86ddd19..290c65e 100644 --- a/tests/06_worktree_integration.bats +++ b/tests/06_worktree_integration.bats @@ -397,7 +397,7 @@ teardown() { # scoped branch prints without the ws// prefix; clean repo folds to 'clean' assert_contains "$output" "myrepo" assert_contains "$output" "clean" - [[ "$output" != *"ws/dev/main"* ]] + refute_contains "$output" "ws/dev/main" } @test "status: raw mode branch gets the conversion steering line (parity with context/done)" { diff --git a/tests/07_metadata.bats b/tests/07_metadata.bats index 39027c7..b599796 100644 --- a/tests/07_metadata.bats +++ b/tests/07_metadata.bats @@ -33,5 +33,5 @@ teardown() { cd "$proj/manual-ws" && orbit goal "set from nothing" >/dev/null 2>&1 local created created=$(git config --file "$proj/manual-ws/.orbit" --get workspace.created) - [[ "$created" =~ ^[0-9]+$ ]] + assert_matches "$created" '^[0-9]+$' } diff --git a/tests/09_prune.bats b/tests/09_prune.bats index 5d4241f..9329a85 100644 --- a/tests/09_prune.bats +++ b/tests/09_prune.bats @@ -699,8 +699,8 @@ setup_project_with_done_workspace() { run bash -c "cd '$proj/dev' && PATH='$stubs':\$PATH ORBIT_ROOT='$proj' bash '$ORBIT_CMD' prune --dry-run 2>&1" [ "$status" -ne 0 ] assert_contains "$output" "prune must be run from the project root" - [[ "$output" != *"&&"* ]] - [[ "$output" != *"cd $proj"* ]] + refute_contains "$output" "&&" + refute_contains "$output" "cd $proj" assert_dir_exists "$proj/dev" } @@ -1235,9 +1235,9 @@ setup_project_with_done_workspace() { assert_contains "$output" " myrepo:" assert_contains "$output" " raw-orphan (unmerged)" assert_contains "$output" 'git -C ".repos/myrepo" branch -D raw-orphan' - [[ "$output" != *"raw-pushed"* ]] - [[ "$output" != *"raw-active"* ]] - [[ "$output" != *"release-1.2"* ]] + refute_contains "$output" "raw-pushed" + refute_contains "$output" "raw-active" + refute_contains "$output" "release-1.2" } @test "prune: report does not leak git's native branch-deletion output" { @@ -1251,7 +1251,7 @@ setup_project_with_done_workspace() { run bash -c "cd '$proj' && ORBIT_ROOT='$proj' bash '$ORBIT_CMD' prune 2>&1" [ "$status" -eq 0 ] assert_contains "$output" "deleted branch (merged): ws/dev/main" - [[ "$output" != *"Deleted branch"* ]] + refute_contains "$output" "Deleted branch" } @test "prune --dry-run: ghost residue reported with would-forms, nothing deleted" { @@ -1300,7 +1300,7 @@ setup_project_with_done_workspace() { assert_contains "$output" "untraceable branches (raw, no remote, no workspace)" assert_contains "$output" " ws/lonely (merged)" # raw arm of the three-condition: residue present ⇒ NOT "nothing to prune" - [[ "$output" != *"nothing to prune"* ]] + refute_contains "$output" "nothing to prune" run git -C "$proj/.repos/myrepo" rev-parse --verify --quiet refs/heads/ws/lonely [ "$status" -eq 0 ] @@ -1331,7 +1331,7 @@ setup_project_with_done_workspace() { run bash -c "cd '$proj' && ORBIT_ROOT='$proj' bash '$ORBIT_CMD' prune 2>&1" [ "$status" -eq 0 ] assert_contains "$output" 'git -C ".repos/myrepo" branch -D evil\;name' - [[ "$output" != *'branch -D evil;name'* ]] + refute_contains "$output" 'branch -D evil;name' } @test "prune: raw current branch never enters the branch pipeline — raw report only" { @@ -1463,7 +1463,7 @@ setup_project_with_done_workspace() { run cat "$SANDBOX/e.txt" assert_contains "$output" "untraceable branches (raw, no remote, no workspace)" run cat "$SANDBOX/o.txt" - [[ "$output" != *"untraceable branches"* ]] + refute_contains "$output" "untraceable branches" # dry-run: everything is report → stdout run bash -c "cd '$proj' && ORBIT_ROOT='$proj' bash '$ORBIT_CMD' prune --dry-run >'$SANDBOX/o2.txt' 2>'$SANDBOX/e2.txt'" @@ -1471,7 +1471,7 @@ setup_project_with_done_workspace() { run cat "$SANDBOX/o2.txt" assert_contains "$output" "untraceable branches (raw, no remote, no workspace)" run cat "$SANDBOX/e2.txt" - [[ "$output" != *"untraceable branches"* ]] + refute_contains "$output" "untraceable branches" } @test "prune: closing block — single caveat, scoped suggestions before raw commands" { @@ -1578,7 +1578,7 @@ setup_project_with_done_workspace() { run bash -c "cd '$proj' && ORBIT_ROOT='$proj' bash '$ORBIT_CMD' prune 2>&1" [ "$status" -eq 0 ] assert_contains "$output" " raw-orphan (unknown)" - [[ "$output" != *"raw-orphan (unknown) — review"* ]] + refute_contains "$output" "raw-orphan (unknown) — review" } @test "prune: branch checked out in another workspace is NOT deleted — failure surfaces, no false 'deleted'" { @@ -1689,7 +1689,7 @@ setup_project_with_done_workspace() { [ "$status" -eq 0 ] assert_contains "$output" "pruned: dev (0 worktrees removed, 1 branch deleted)" # deletion lines carry the recovery handle - [[ "$output" =~ deleted\ branch\ \(merged\):\ ws/dev/main\ \(was\ [0-9a-f]+\) ]] + assert_matches "$output" 'deleted\ branch\ \(merged\):\ ws/dev/main\ \(was\ [0-9a-f]+\)' [ ! -d "$proj/dev" ] run git -C "$proj/.repos/myrepo" rev-parse --verify --quiet refs/heads/ws/dev/main [ "$status" -ne 0 ] diff --git a/tests/12_context.bats b/tests/12_context.bats index f57ceb4..902655c 100644 --- a/tests/12_context.bats +++ b/tests/12_context.bats @@ -44,7 +44,7 @@ teardown() { run bash -c "cd '$proj/ws1' && ORBIT_ROOT='$proj' bash '$ORBIT_CMD' context" [ "$status" -eq 0 ] assert_contains "$output" "state: active" - [[ "$output" != *"This is the memo for context test."* ]] + refute_contains "$output" "This is the memo for context test." } @test "context --json: bare form mirrors the cruise block (no memos)" { @@ -199,10 +199,10 @@ teardown() { # authoritative identity hint assert_contains "$output" "A mock repository for testing. (" assert_contains "$output" "no memo (write the card via orbit memo " - [[ "$output" != *"has no memo, using README instead"* ]] + refute_contains "$output" "has no memo, using README instead" # no stale header from the old format - [[ "$output" != *"=== PRIME"* ]] - [[ "$output" != *"primed"* ]] + refute_contains "$output" "=== PRIME" + refute_contains "$output" "primed" } @test "context --startup: reignite shows memos + per-repo status when populated" { @@ -218,7 +218,7 @@ teardown() { assert_contains "$output" "status: 1 jots | memo thin" assert_contains "$output" "residual finding" # roster is prime-only - [[ "$output" != *"available in pool"* ]] + refute_contains "$output" "available in pool" } @test "context: --startup and --prime are mutually exclusive" { diff --git a/tests/14_config.bats b/tests/14_config.bats index 9a3711a..7b96416 100644 --- a/tests/14_config.bats +++ b/tests/14_config.bats @@ -243,5 +243,5 @@ teardown() { # the index entry is untouched run git config --file "$proj/.repos/.orbit" --get repos.myrepo.url [ "$status" -eq 0 ] - [[ "$output" != "http://evil" ]] + [ "$output" != "http://evil" ] } diff --git a/tests/18_sync.bats b/tests/18_sync.bats index 7d609fd..948d69d 100644 --- a/tests/18_sync.bats +++ b/tests/18_sync.bats @@ -335,7 +335,7 @@ _push_update_to() { [ "$status" -eq 0 ] assert_contains "$output" "myrepo: already up to date" # no false fast-forward claim when HEAD did not move - [[ "$output" != *"fast-forwarded"* ]] + refute_contains "$output" "fast-forwarded" } @test "sync: batch mode distinguishes fast-forwarded vs up-to-date and tallies" { @@ -721,8 +721,8 @@ _push_update_to() { run bash -c "cd '$proj/dev' && PATH='$stubs':\$PATH ORBIT_ROOT='$proj' bash '$ORBIT_CMD' sync myrepo --force 2>&1" [ "$status" -ne 0 ] assert_contains "$output" "sync --force must be run from the project root" - [[ "$output" != *"&&"* ]] - [[ "$output" != *"cd $proj"* ]] + refute_contains "$output" "&&" + refute_contains "$output" "cd $proj" } @test "sync --force: replays the intended command when the session is clean" { diff --git a/tests/21_version.bats b/tests/21_version.bats index 6a25fec..2e7681f 100644 --- a/tests/21_version.bats +++ b/tests/21_version.bats @@ -14,7 +14,7 @@ teardown() { @test "version: prints a semver on stdout and exits 0" { run bash -c "bash '$ORBIT_CMD' version" [ "$status" -eq 0 ] - [[ "$output" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] + assert_matches "$output" '^[0-9]+\.[0-9]+\.[0-9]+$' } @test "version: --version and -v are aliases" { diff --git a/tests/23_install_cli.bats b/tests/23_install_cli.bats index 7dd4689..fc02e3e 100644 --- a/tests/23_install_cli.bats +++ b/tests/23_install_cli.bats @@ -61,39 +61,39 @@ EOF @test "help: --help exits 0 and prints usage" { run_install --help [ "$status" -eq 0 ] - [[ "$output" == *"usage:"* ]] + assert_contains "$output" "usage:" } @test "parse: unknown flag exits 1" { run_install --bogus [ "$status" -eq 1 ] - [[ "$output" == *"unknown option: --bogus"* ]] + assert_contains "$output" "unknown option: --bogus" } @test "uninstall: no target is rejected" { run_install --uninstall [ "$status" -eq 1 ] - [[ "$output" == *"--uninstall requires at least one target"* ]] + assert_contains "$output" "--uninstall requires at least one target" } @test "uninstall: a single plugin target no-ops when its CLI is absent" { run_install --uninstall --claude [ "$status" -eq 0 ] - [[ "$output" == *"claude CLI not found"* ]] + assert_contains "$output" "claude CLI not found" } @test "uninstall: --all runs every target and exits 0 when nothing is installed" { run_install --uninstall --all [ "$status" -eq 0 ] # --all expands to all plugin targets; missing CLIs are skipped, not fatal. - [[ "$output" == *"codex CLI not found"* ]] || [[ "$output" == *"Removed"* ]] - [[ "$output" == *"Done."* ]] + assert_contains "$output" "codex CLI not found" || assert_contains "$output" "Removed" + assert_contains "$output" "Done." } @test "uninstall: --cli reports nothing to remove when runtime is absent" { run_install --uninstall --cli [ "$status" -eq 0 ] - [[ "$output" == *"nothing to remove"* ]] + assert_contains "$output" "nothing to remove" } # --- network resilience (ORBIT_RETRY / ORBIT_RETRY_DELAY_SECONDS / ORBIT_TIMEOUT_SECONDS) --- @@ -101,19 +101,19 @@ EOF @test "env: non-numeric ORBIT_RETRY is rejected" { run env HOME="$FAKE_HOME" PATH="/usr/bin:/bin" ORBIT_RETRY=abc bash "$INSTALL" [ "$status" -eq 1 ] - [[ "$output" == *"ORBIT_RETRY must be a positive integer"* ]] + assert_contains "$output" "ORBIT_RETRY must be a positive integer" } @test "env: ORBIT_RETRY=0 is rejected" { run env HOME="$FAKE_HOME" PATH="/usr/bin:/bin" ORBIT_RETRY=0 bash "$INSTALL" [ "$status" -eq 1 ] - [[ "$output" == *"ORBIT_RETRY must be >= 1"* ]] + assert_contains "$output" "ORBIT_RETRY must be >= 1" } @test "env: non-numeric ORBIT_TIMEOUT_SECONDS is rejected" { run env HOME="$FAKE_HOME" PATH="/usr/bin:/bin" ORBIT_TIMEOUT_SECONDS=soon bash "$INSTALL" [ "$status" -eq 1 ] - [[ "$output" == *"ORBIT_TIMEOUT_SECONDS must be a positive integer"* ]] + assert_contains "$output" "ORBIT_TIMEOUT_SECONDS must be a positive integer" } @test "retry: a download that fails transiently succeeds within ORBIT_RETRY" { @@ -121,8 +121,8 @@ EOF run_install_mocked CURL_FAILS=2 ORBIT_SOURCE=acme/widgets \ ORBIT_RETRY=3 ORBIT_RETRY_DELAY_SECONDS=0 bash "$INSTALL" [ "$status" -eq 0 ] - [[ "$output" == *"attempt 1/3 failed"* ]] - [[ "$output" == *"Installed orbit command to:"* ]] + assert_contains "$output" "attempt 1/3 failed" + assert_contains "$output" "Installed orbit command to:" [ -x "$FAKE_HOME/.local/bin/orbit" ] } @@ -131,10 +131,10 @@ EOF run_install_mocked CURL_FAILS=99 ORBIT_SOURCE=acme/widgets \ ORBIT_RETRY=2 ORBIT_RETRY_DELAY_SECONDS=0 bash "$INSTALL" [ "$status" -eq 1 ] - [[ "$output" == *"giving up after 2 attempts"* ]] - [[ "$output" == *"Could not resolve host"* ]] # the real curl error - [[ "$output" == *"failed to fetch orbit.sh"* ]] - [[ "$output" == *"local path source needs no network"* ]] # escape-hatch hint + assert_contains "$output" "giving up after 2 attempts" + assert_contains "$output" "Could not resolve host" # the real curl error + assert_contains "$output" "failed to fetch orbit.sh" + assert_contains "$output" "local path source needs no network" # escape-hatch hint } @test "timeout: a hung download is killed at ORBIT_TIMEOUT_SECONDS, not waited out" { @@ -143,8 +143,8 @@ EOF run_install_mocked ORBIT_SOURCE=acme/widgets \ ORBIT_RETRY=2 ORBIT_RETRY_DELAY_SECONDS=0 ORBIT_TIMEOUT_SECONDS=1 bash "$INSTALL" [ "$status" -eq 1 ] - [[ "$output" == *"timed out (1s)"* ]] - [[ "$output" == *"failed to fetch orbit.sh"* ]] + assert_contains "$output" "timed out (1s)" + assert_contains "$output" "failed to fetch orbit.sh" } @test "timeout: a TERM-immune download is KILLed after the grace period" { @@ -158,8 +158,8 @@ EOF run_install_mocked ORBIT_SOURCE=acme/widgets \ ORBIT_RETRY=1 ORBIT_RETRY_DELAY_SECONDS=0 ORBIT_TIMEOUT_SECONDS=1 bash "$INSTALL" [ "$status" -eq 1 ] - [[ "$output" == *"timed out after 1s"* ]] # giving-up message names the timeout - [[ "$output" == *"failed to fetch orbit.sh"* ]] + assert_contains "$output" "timed out after 1s" # giving-up message names the timeout + assert_contains "$output" "failed to fetch orbit.sh" } # --- marketplace error surfacing (fake claude on PATH) --------------------- @@ -193,10 +193,10 @@ EOF run_install_mocked ORBIT_SOURCE="${BATS_TEST_DIRNAME}/.." \ ORBIT_RETRY=2 ORBIT_RETRY_DELAY_SECONDS=0 bash "$INSTALL" --claude [ "$status" -eq 1 ] - [[ "$output" == *"unable to connect to github.com (fake)"* ]] # the real add error - [[ "$output" == *"marketplace add/update failed"* ]] # the causal warning… - [[ "$output" == *"cause is the add error"* ]] # …naming the root cause - [[ "$output" == *"not found in marketplace"* ]] # the install error + assert_contains "$output" "unable to connect to github.com (fake)" # the real add error + assert_contains "$output" "marketplace add/update failed" # the causal warning… + assert_contains "$output" "cause is the add error" # …naming the root cause + assert_contains "$output" "not found in marketplace" # the install error } @test "marketplace: refresh failure warns but does not block an offline install" { @@ -204,8 +204,8 @@ EOF run_install_mocked ORBIT_SOURCE="${BATS_TEST_DIRNAME}/.." \ ORBIT_RETRY=2 ORBIT_RETRY_DELAY_SECONDS=0 CLAUDE_INSTALL_OK=1 bash "$INSTALL" --claude [ "$status" -eq 0 ] - [[ "$output" == *"cause is the add error"* ]] - [[ "$output" == *"Installed Orbit plugin into Claude Code"* ]] + assert_contains "$output" "cause is the add error" + assert_contains "$output" "Installed Orbit plugin into Claude Code" [ -e "$MOCK_STATE/plugin-installed" ] } @@ -278,7 +278,7 @@ run_install_chained() { [ "$(sed -n '1p' "$MOCK_STATE/calls")" = "curl https://raw.githubusercontent.com/orbcli/orbit/main/orbit.sh" ] [ "$(sed -n '2p' "$MOCK_STATE/calls")" = "git-clone https://github.com/orbcli/orbit.git" ] [ "$(wc -l < "$MOCK_STATE/calls" | tr -d ' ')" -eq 2 ] # SSH never needed - [[ "$output" == *"attempt 1/3 via orbcli/orbit failed"* ]] + assert_contains "$output" "attempt 1/3 via orbcli/orbit failed" [ -x "$FAKE_HOME/.local/bin/orbit" ] # The clone saw the no-prompt env (installer must never ask interactively): [ "$(sed -n '1p' "$MOCK_STATE/gtp")" = "gtp=0" ] diff --git a/tests/25_try_demo.bats b/tests/25_try_demo.bats index f0a30f6..2977444 100644 --- a/tests/25_try_demo.bats +++ b/tests/25_try_demo.bats @@ -75,45 +75,45 @@ run_try() { # One curl fetch, one install run. try.sh supplies the demo chain via # ORBIT_SOURCES but never invents a pinned ORBIT_SOURCE. [ "$(grep -c 'curl call' "$MOCK_STATE/calls")" -eq 1 ] - [[ "$(grep 'ORBIT_SOURCE=' "$MOCK_STATE/calls")" == *""* ]] + assert_contains "$(grep 'ORBIT_SOURCE=' "$MOCK_STATE/calls")" "" [ "$(grep 'ORBIT_SOURCES=' "$MOCK_STATE/calls")" = \ "ORBIT_SOURCES=orbcli/orbit https://github.com/orbcli/orbit.git git@github.com:orbcli/orbit.git" ] - [[ "$output" == *"plugin installed."* ]] - [[ "$output" == *"✓ Ready."* ]] + assert_contains "$output" "plugin installed." + assert_contains "$output" "✓ Ready." } @test "try.sh: a user-set ORBIT_SOURCE passes through untouched (no chain added)" { run_try CURL_FAILS=0 INSTALL_FAILS=0 ORBIT_SOURCE="https://pinned.example/orbit.git" [ "$status" -eq 0 ] [ "$(grep 'ORBIT_SOURCE=' "$MOCK_STATE/calls")" = "ORBIT_SOURCE=https://pinned.example/orbit.git" ] - [[ "$(grep 'ORBIT_SOURCES=' "$MOCK_STATE/calls")" == *""* ]] - [[ "$output" == *"plugin installed."* ]] + assert_contains "$(grep 'ORBIT_SOURCES=' "$MOCK_STATE/calls")" "" + assert_contains "$output" "plugin installed." } @test "try.sh: a user-set ORBIT_SOURCES passes through untouched" { run_try CURL_FAILS=0 INSTALL_FAILS=0 ORBIT_SOURCES="custom/chain only-one" [ "$status" -eq 0 ] [ "$(grep 'ORBIT_SOURCES=' "$MOCK_STATE/calls")" = "ORBIT_SOURCES=custom/chain only-one" ] - [[ "$output" == *"plugin installed."* ]] + assert_contains "$output" "plugin installed." } @test "try.sh: the install.sh fetch retries through a transient failure" { run_try CURL_FAILS=1 INSTALL_FAILS=0 [ "$status" -eq 0 ] [ "$(grep -c 'curl call' "$MOCK_STATE/calls")" -eq 2 ] # one retry, still one fetch - [[ "$output" == *"plugin installed."* ]] + assert_contains "$output" "plugin installed." } @test "try.sh: a failing install shows its real error, never fails silently" { run_try CURL_FAILS=0 INSTALL_FAILS=1 [ "$status" -eq 0 ] # plugin step degrades to manual launch instructions - [[ "$output" == *"simulated install failure (fake)"* ]] - [[ "$output" == *"falling back to manual launch instructions"* ]] + assert_contains "$output" "simulated install failure (fake)" + assert_contains "$output" "falling back to manual launch instructions" } @test "try.sh: a non-numeric ORBIT_RETRY is rejected before any network use" { run_try ORBIT_RETRY=abc [ "$status" -eq 1 ] - [[ "$output" == *"ORBIT_RETRY must be a positive integer"* ]] + assert_contains "$output" "ORBIT_RETRY must be a positive integer" [ ! -e "$TRY_DIR/upstream" ] # died in preflight, before any seeding/fetch } diff --git a/tests/27_assertion_form.bats b/tests/27_assertion_form.bats new file mode 100644 index 0000000..bdd1130 --- /dev/null +++ b/tests/27_assertion_form.bats @@ -0,0 +1,24 @@ +#!/usr/bin/env bats +# +# Assertion-form guard. On bash 3.2 (stock macOS /bin/bash, which the bats +# shebang resolves to), a failing bare `[[ … ]]` statement does NOT trip +# errexit — `[[ ]]`/`(( ))` only became subject to `set -e` in bash 4.1. Any +# mid-test bare `[[ ]]` assertion therefore passes silently on stock macOS, +# whatever it checks. Assertions must use the helpers in +# test_helper/common.bash (assert_contains / refute_contains / assert_matches) +# or plain `[ ]`, both of which fail through on every bash. +# +# This lint test greps the suite for the vacuous shape. The pattern is built +# at runtime so this file's own text never self-matches. + +setup() { + load test_helper/common + common_setup +} + +@test "lint: no bare [[ ]] assertions anywhere in tests/*.bats" { + pat='^[[:space:]]*\[\[' # bare-statement form; if/||/&& contexts are fine + run grep -rnE "$pat" "$BATS_TEST_DIRNAME"/*.bats + # grep exits 1 when nothing matches; 0 means a violation was found. + [ "$status" -eq 1 ] +} diff --git a/tests/test_helper/common.bash b/tests/test_helper/common.bash index 955ee6c..0836493 100644 --- a/tests/test_helper/common.bash +++ b/tests/test_helper/common.bash @@ -53,6 +53,13 @@ orbit() { } # --- Assertion helpers --- +# +# IMPORTANT: always use these helpers (or plain `[ ]`) for assertions — never +# a bare `[[ … ]]` statement. On bash 3.2 (stock macOS /bin/bash, which the +# bats shebang resolves to), a failing `[[ ]]`/`(( ))` does NOT trip errexit +# (they only became subject to `set -e` in bash 4.1), so a mid-test bare +# `[[ ]]` assertion passes silently no matter what it checks. These helpers +# fail through an external command (grep), which trips errexit on every bash. assert_contains() { local haystack="$1" needle="$2" @@ -96,6 +103,18 @@ assert_dir_exists() { fi } +assert_matches() { + local haystack="$1" regex="$2" + if printf '%s' "$haystack" | grep -qE -e "$regex"; then + return 0 + else + echo "assert_matches failed" + echo " expected to match: $regex" + echo " actual: $haystack" + return 1 + fi +} + # --- Mock repo utilities --- create_bare_repo() {