This is an automated email from the ASF dual-hosted git repository.

wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git


The following commit(s) were added to refs/heads/main by this push:
     new 7e7dfad5 [tools][test][e2e] Make the bats suite fail on assertions 
that stop holding (#1043)
7e7dfad5 is described below

commit 7e7dfad5e9b007badb8beed2f8fafe6f7ce09fb6
Author: Weiqing Yang <[email protected]>
AuthorDate: Wed Aug 26 01:27:05 2026 -0700

    [tools][test][e2e] Make the bats suite fail on assertions that stop holding 
(#1043)
    
    Generated-by: Claude Code 2.1.240 (Claude Opus 5)
---
 .github/workflows/ci.yml                           |  2 +-
 .../test-scripts/test_submit_examples_to_flink.sh  |  6 +-
 tools/install.sh                                   | 13 ++++-
 tools/test/helpers/bash_version_guard.bash         | 23 ++++++++
 tools/test/integration/build_help.bats             | 16 ++---
 tools/test/run.sh                                  | 53 ++++++++++++++---
 tools/test/unit/check_license.bats                 | 20 +++----
 tools/test/unit/checkpoint_recovery_harness.bats   | 68 +++++++++++-----------
 tools/test/unit/edit_plan_quote.bats               | 53 ++++++++++++++++-
 tools/test/unit/parse_args.bats                    |  4 +-
 tools/test/unit/platform_detect.bats               |  2 +-
 tools/test/unit/revalidate_python_constraint.bats  |  8 +--
 tools/test/unit/shim_self_test.bats                |  2 +-
 tools/test/unit/ui_helpers.bats                    | 18 +++---
 tools/test/unit/verify_example_job.bats            | 30 +++++-----
 15 files changed, 221 insertions(+), 97 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 89df9c85..9a67c4eb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -62,7 +62,7 @@ jobs:
         os: [ubuntu-latest, macos-latest]
     steps:
       - uses: actions/checkout@v4
-      - name: Install bash 4+ (macOS)
+      - name: Install bash 4.1+ (macOS)
         if: runner.os == 'macOS'
         run: brew install bash
       - name: Run tools script tests
diff --git a/e2e-test/test-scripts/test_submit_examples_to_flink.sh 
b/e2e-test/test-scripts/test_submit_examples_to_flink.sh
index 7b479c42..97981449 100755
--- a/e2e-test/test-scripts/test_submit_examples_to_flink.sh
+++ b/e2e-test/test-scripts/test_submit_examples_to_flink.sh
@@ -359,7 +359,11 @@ remove_submission_pid() {
             remaining_pids+=("$pid")
         fi
     done
-    SUBMISSION_PIDS=("${remaining_pids[@]}")
+    # Rebuild with the `+` form: on bash 4.3 and older "${arr[@]}" on an empty
+    # array is an unbound-variable error under `set -u`, and "${arr[@]:-}" 
would
+    # rebuild one empty-string element instead of nothing. The `:-` in the loop
+    # above is correct: its empty iteration is dropped by the `-n` guard.
+    SUBMISSION_PIDS=("${remaining_pids[@]+"${remaining_pids[@]}"}")
 }
 
 start_attached_java_submission() {
diff --git a/tools/install.sh b/tools/install.sh
index 68963ca3..3f9033be 100755
--- a/tools/install.sh
+++ b/tools/install.sh
@@ -1022,9 +1022,20 @@ reconcile_plan_after_edit() {
 # Quote a value for safe re-sourcing by the parent shell via single-quoted
 # assignment. Any embedded single quotes are escaped by closing/reopening
 # the quoted string.
+#
+# Implementation note: the escape is supplied through a variable rather than
+# written inline. Written inline as ${v//\'/\'\\\'\'}, bash 3.2 (macOS
+# /bin/bash) emits 'it\'\\'\'s' where the intended output is 'it'\''s'. The
+# 3.2 form is not valid shell, so sourcing the dumped state file fails with
+# "unexpected EOF while looking for matching `'" and the value is lost.
+# Held in escaped_quote the escape reaches the output untouched: 3.2.57 and
+# 5.3.15 emit identical bytes. Keep escaped_quote at a single backslash;
+# with two, they stop matching.
+# tools/test/unit/edit_plan_quote.bats pins this under a real bash 3.x.
 edit_plan_quote() {
     local v="$1"
-    printf "'%s'" "${v//\'/\'\\\'\'}"
+    local escaped_quote="'\\''"
+    printf "'%s'" "${v//\'/$escaped_quote}"
 }
 
 # Write the subset of plan variables we may have modified to a sourceable
diff --git a/tools/test/helpers/bash_version_guard.bash 
b/tools/test/helpers/bash_version_guard.bash
new file mode 100644
index 00000000..e2734f1b
--- /dev/null
+++ b/tools/test/helpers/bash_version_guard.bash
@@ -0,0 +1,23 @@
+# Suite-level assertion that the interpreter running the test bodies is bash
+# 4.1 or newer. Wired in explicitly by run.sh via --setup-suite-file, because
+# auto-discovery probes the directory named by each path argument and would
+# resolve to unit/ instead.
+#
+# This backs up the PATH pin in run.sh rather than replacing it: bats starts
+# each test process through `#!/usr/bin/env bash`, so if the pin ever stops
+# resolving, the run would otherwise continue under whatever bash PATH finds.
+#
+# A failing setup_suite aborts the run before any test body executes. Use
+# `return 1`, not `exit 1`, which bats reports as "`exit 1' failed with
+# status 0".
+
+setup_suite() {
+    if (( 10 * BASH_VERSINFO[0] + BASH_VERSINFO[1] < 41 )); then
+        echo "ERROR: bats is running the test bodies under bash 
${BASH_VERSION}," >&2
+        echo "but this suite requires bash >= 4.1." >&2
+        echo "Start the suite through tools/test/run.sh, which pins the 
interpreter" >&2
+        echo "for the whole run. If it did start there, the pin is not 
resolving:" >&2
+        echo "inspect tools/test/.bats-cache/shim/bash." >&2
+        return 1
+    fi
+}
diff --git a/tools/test/integration/build_help.bats 
b/tools/test/integration/build_help.bats
index d4a4abeb..5fc5ccfd 100644
--- a/tools/test/integration/build_help.bats
+++ b/tools/test/integration/build_help.bats
@@ -24,24 +24,24 @@ BUILD_SCRIPT="${BATS_TEST_DIRNAME}/../../build.sh"
     run bash "$BUILD_SCRIPT" --help
 
     [ "$status" -eq 0 ]
-    [[ "$output" == *"Build Flink Agents Java and Python artifacts"* ]]
-    [[ "$output" == *"Usage:"* ]]
-    [[ "$output" == *"--java"* ]]
-    [[ "$output" == *"--python"* ]]
+    [[ "$output" == *"Build Flink Agents Java and Python artifacts"* ]] || 
false
+    [[ "$output" == *"Usage:"* ]] || false
+    [[ "$output" == *"--java"* ]] || false
+    [[ "$output" == *"--python"* ]] || false
 }
 
 @test "build -h prints usage and exits 0" {
     run bash "$BUILD_SCRIPT" -h
 
     [ "$status" -eq 0 ]
-    [[ "$output" == *"Usage:"* ]]
+    [[ "$output" == *"Usage:"* ]] || false
 }
 
 @test "build rejects an unknown option with usage" {
     run bash "$BUILD_SCRIPT" --no-such-option
 
     [ "$status" -eq 1 ]
-    [[ "$output" == *"Error: Unknown option '--no-such-option'"* ]]
-    [[ "$output" == *"Usage:"* ]]
-    [[ "$output" != *"show_help: command not found"* ]]
+    [[ "$output" == *"Error: Unknown option '--no-such-option'"* ]] || false
+    [[ "$output" == *"Usage:"* ]] || false
+    [[ "$output" != *"show_help: command not found"* ]] || false
 }
diff --git a/tools/test/run.sh b/tools/test/run.sh
index c5d303d7..24470ad3 100755
--- a/tools/test/run.sh
+++ b/tools/test/run.sh
@@ -17,13 +17,23 @@
 # limitations under the License.
 
################################################################################
 
-# Bash 4+ is required: bash 3.2 (macOS default) does not trigger `set -e`
-# on `[[ ]]` failures or fire the ERR trap on them, which means many
-# substring assertions in this suite would silently pass on bash 3.2.
-# Force a clean failure here rather than mislead developers.
-if [ -z "${BASH_VERSION:-}" ] || [ "${BASH_VERSION%%.*}" -lt 4 ]; then
-    echo "ERROR: bash >= 4 required (detected: ${BASH_VERSION:-unknown})." >&2
-    echo "macOS ships bash 3.2 at /bin/bash; install bash 4+ via Homebrew:" >&2
+# Bash 4.1 or newer is required: on older bash, `set -e` does not trigger on a
+# failing `[[ ]]` and the ERR trap does not fire for it, so many of the 
substring
+# assertions in this suite would silently pass. That changed in 4.1, so 4.0 is
+# rejected as well; macOS ships 3.2 at /bin/bash. Force a clean failure here
+# rather than mislead developers.
+#
+# The gate below binds this shell only. bats evaluates each test body in a
+# separate process started through `#!/usr/bin/env bash`, so the interpreter is
+# re-resolved from PATH at every hop. Two further steps cover that: the PATH 
pin
+# below points `bash` at the interpreter this gate accepted, and
+# helpers/bash_version_guard.bash re-checks the version from inside the run.
+#
+# The empty-BASH_VERSION test must stay first: it short-circuits, so the
+# arithmetic is never evaluated under a shell that has no BASH_VERSINFO.
+if [ -z "${BASH_VERSION:-}" ] || (( 10 * BASH_VERSINFO[0] + BASH_VERSINFO[1] < 
41 )); then
+    echo "ERROR: bash >= 4.1 required (detected: ${BASH_VERSION:-unknown})." 
>&2
+    echo "macOS ships bash 3.2 at /bin/bash; install bash 4.1+ via Homebrew:" 
>&2
     echo "    brew install bash" >&2
     echo "Then run with the new bash, e.g.:" >&2
     echo "    /opt/homebrew/bin/bash $0" >&2
@@ -44,10 +54,37 @@ clone_pinned() {
 }
 
 mkdir -p "$CACHE"
+
+# Pin the interpreter bats resolves, by putting a `bash` symlink to this shell
+# ahead of everything else on PATH. $BASH is the interpreter the gate above
+# accepted. $CACHE is gitignored, so the symlink does not show up in git 
status.
+#
+# This also re-interprets the scripts under test, which start with
+# `#!/usr/bin/env bash` themselves: while the pin is in place the suite runs
+# them under this interpreter instead of the one the developer's own PATH
+# selects.
+#
+# Build the link under a temp name and rename it into place. Renaming within a
+# directory replaces the name in one step, so a lookup running concurrently
+# sees either the old link or the new one; `ln -sfn` instead unlinks before it
+# re-creates, leaving a window in which the name does not exist and the lookup
+# falls through to the next PATH entry.
+SHIM="$CACHE/shim"
+mkdir -p "$SHIM"
+# A directory here would swallow the rename instead of being replaced by it.
+if [[ -d "$SHIM/bash" ]]; then rm -rf "$SHIM/bash"; fi
+rm -f "$SHIM/bash.$$"
+ln -s "$BASH" "$SHIM/bash.$$"
+mv -f "$SHIM/bash.$$" "$SHIM/bash"
+PATH="$SHIM:$PATH"
+export PATH
+
 clone_pinned bats-core    https://github.com/bats-core/bats-core.git    v1.11.0
 clone_pinned bats-support https://github.com/bats-core/bats-support.git v0.3.0
 clone_pinned bats-assert  https://github.com/bats-core/bats-assert.git  v2.1.0
 
 export BATS_LIB_PATH="$CACHE"
 
-exec "$CACHE/bats-core/bin/bats" --recursive "$HERE/unit" "$HERE/integration"
+exec "$CACHE/bats-core/bin/bats" \
+    --setup-suite-file "$HERE/helpers/bash_version_guard.bash" \
+    --recursive "$HERE/unit" "$HERE/integration"
diff --git a/tools/test/unit/check_license.bats 
b/tools/test/unit/check_license.bats
index d4691988..f501557c 100644
--- a/tools/test/unit/check_license.bats
+++ b/tools/test/unit/check_license.bats
@@ -40,7 +40,7 @@ setup() {
     run acquire_rat_jar
 
     [ "$status" -eq 0 ]
-    [[ "$output" == *"Warning: cannot validate cached Apache RAT JAR"* ]]
+    [[ "$output" == *"Warning: cannot validate cached Apache RAT JAR"* ]] || 
false
     [ -f "$rat_jar" ]
 }
 
@@ -52,7 +52,7 @@ setup() {
     run acquire_rat_jar
 
     [ "$status" -ne 0 ]
-    [[ "$output" == *"is invalid"* ]]
+    [[ "$output" == *"is invalid"* ]] || false
     [ ! -f "$rat_jar" ]
 }
 
@@ -64,7 +64,7 @@ setup() {
     run acquire_rat_jar
 
     [ "$status" -ne 0 ]
-    [[ "$output" == *"is invalid"* ]]
+    [[ "$output" == *"is invalid"* ]] || false
     [ ! -f "$rat_jar" ]
 }
 
@@ -86,7 +86,7 @@ setup() {
     run acquire_rat_jar
 
     [ "$status" -ne 0 ]
-    [[ "$output" == *"Failed to download Apache RAT"* ]]
+    [[ "$output" == *"Failed to download Apache RAT"* ]] || false
     [ ! -e "${rat_jar}.part" ]
     [ ! -e "$rat_jar" ]
 }
@@ -100,9 +100,9 @@ setup() {
 
     [ -f "$rat_jar" ]
     run cat "$SHIM_CALLS/curl.log"
-    [[ "$output" == *"--fail"* ]]
-    [[ "$output" == *"--show-error"* ]]
-    [[ "$output" == *"--location"* ]]
+    [[ "$output" == *"--fail"* ]] || false
+    [[ "$output" == *"--show-error"* ]] || false
+    [[ "$output" == *"--location"* ]] || false
     [ "$(shim_call_count unzip)" = "1" ]
 }
 
@@ -114,7 +114,7 @@ setup() {
     run acquire_rat_jar
 
     [ "$status" -ne 0 ]
-    [[ "$output" == *"Cannot validate the downloaded Apache RAT JAR"* ]]
+    [[ "$output" == *"Cannot validate the downloaded Apache RAT JAR"* ]] || 
false
     [ ! -e "$rat_jar" ]
     [ ! -e "${rat_jar}.part" ]
 }
@@ -139,7 +139,7 @@ setup() {
     run acquire_rat_jar
 
     [ "$status" -ne 0 ]
-    [[ "$output" == *"is invalid"* ]]
+    [[ "$output" == *"is invalid"* ]] || false
     [ ! -f "$rat_jar" ]
 }
 
@@ -151,7 +151,7 @@ setup() {
     run acquire_rat_jar
 
     [ "$status" -ne 0 ]
-    [[ "$output" == *"is invalid"* ]]
+    [[ "$output" == *"is invalid"* ]] || false
     [ ! -f "$rat_jar" ]
 }
 
diff --git a/tools/test/unit/checkpoint_recovery_harness.bats 
b/tools/test/unit/checkpoint_recovery_harness.bats
index a9a733c0..760696c7 100644
--- a/tools/test/unit/checkpoint_recovery_harness.bats
+++ b/tools/test/unit/checkpoint_recovery_harness.bats
@@ -250,8 +250,8 @@ setup() {
     REST_STUB_BODY=""
     run wait_for_rest "stub" "/stub" "int:counts.total" "int:counts.completed" 
"ge:99" 3
     [ "$status" -eq 1 ]
-    [[ "$output" == *"never parsed"* ]]
-    [[ "$output" == *"NOT evidence"* ]]
+    [[ "$output" == *"never parsed"* ]] || false
+    [[ "$output" == *"NOT evidence"* ]] || false
 }
 
 @test "wait_for_rest: probe readable but target not blames the field name" {
@@ -259,9 +259,9 @@ setup() {
     REST_STUB_BODY="$(fixture_checkpoints)"
     run wait_for_rest "stub" "/stub" "int:counts.total" 
"int:counts.completedx" "ge:1" 3
     [ "$status" -eq 1 ]
-    [[ "$output" == *"so the endpoint is right"* ]]
-    [[ "$output" == *"never parsed 'counts.completedx'"* ]]
-    [[ "$output" == *"NOT evidence"* ]]
+    [[ "$output" == *"so the endpoint is right"* ]] || false
+    [[ "$output" == *"never parsed 'counts.completedx'"* ]] || false
+    [[ "$output" == *"NOT evidence"* ]] || false
 }
 
 @test "wait_for_rest: a genuinely false condition reports the observed value" {
@@ -269,10 +269,10 @@ setup() {
     REST_STUB_BODY="$(fixture_checkpoints)"
     run wait_for_rest "stub" "/stub" "int:counts.total" "int:counts.completed" 
"ge:99" 3
     [ "$status" -eq 1 ]
-    [[ "$output" == *"was last '6'"* ]]
+    [[ "$output" == *"was last '6'"* ]] || false
     # A condition that really was evaluated and really was false must not carry
     # the disclaimer, or the disclaimer stops meaning anything.
-    [[ "$output" != *"NOT evidence"* ]]
+    [[ "$output" != *"NOT evidence"* ]] || false
 }
 
 @test "wait_for_rest: target observed but probe missing still reports the 
value" {
@@ -283,10 +283,10 @@ setup() {
     REST_STUB_BODY='{"counts":{"completed":6}}'
     run wait_for_rest "stub" "/stub" "int:counts.total" "int:counts.completed" 
"ge:99" 3
     [ "$status" -eq 1 ]
-    [[ "$output" == *"was last '6'"* ]]
-    [[ "$output" != *"NOT evidence"* ]]
+    [[ "$output" == *"was last '6'"* ]] || false
+    [[ "$output" != *"NOT evidence"* ]] || false
     # ...while still saying the response was not fully as expected.
-    [[ "$output" == *"only partly as expected"* ]]
+    [[ "$output" == *"only partly as expected"* ]] || false
 }
 
 @test "wait_for_rest: an uncomparable value stops the wait instead of 
spinning" {
@@ -294,7 +294,7 @@ setup() {
     REST_STUB_BODY="$(fixture_job_running)"
     run wait_for_rest "stub" "/stub" "str:state" "str:state" "ge:3" 6
     [ "$status" -eq 1 ]
-    [[ "$output" == *"cannot be evaluated"* ]]
+    [[ "$output" == *"cannot be evaluated"* ]] || false
 }
 
 @test "wait_for_rest: a slow endpoint does not multiply the budget" {
@@ -331,16 +331,16 @@ setup() {
 @test "wait_for_file: an absent file times out without inventing a twin" {
     run wait_for_file "stub" "$BATS_TEST_TMPDIR/verdict.json" 2
     [ "$status" -eq 1 ]
-    [[ "$output" == *"did not appear"* ]]
-    [[ "$output" != *"leftover"* ]]
+    [[ "$output" == *"did not appear"* ]] || false
+    [[ "$output" != *"leftover"* ]] || false
 }
 
 @test "wait_for_file: a leftover .tmp twin is reported and never accepted" {
     : > "$BATS_TEST_TMPDIR/verdict.json.tmp"
     run wait_for_file "stub" "$BATS_TEST_TMPDIR/verdict.json" 2
     [ "$status" -eq 1 ]
-    [[ "$output" == *"leftover"* ]]
-    [[ "$output" == *"verdict.json.tmp"* ]]
+    [[ "$output" == *"leftover"* ]] || false
+    [[ "$output" == *"verdict.json.tmp"* ]] || false
 }
 
 # ---------------------------------------------------------------------------
@@ -363,7 +363,7 @@ setup() {
 
     run prepare_python_venv
     [ "$status" -eq 0 ]
-    [[ "$output" == *"Reusing Python venv"* ]]
+    [[ "$output" == *"Reusing Python venv"* ]] || false
 }
 
 @test "prepare_python_venv: a non-empty directory that is not a venv is 
refused" {
@@ -374,7 +374,7 @@ setup() {
 
     run prepare_python_venv
     [ "$status" -eq 1 ]
-    [[ "$output" == *"not an empty directory or a valid venv"* ]]
+    [[ "$output" == *"not an empty directory or a valid venv"* ]] || false
 }
 
 @test "prepare_python_venv: creating one targets VENV_DIR with the configured 
interpreter" {
@@ -398,7 +398,7 @@ EOF
 
     run install_built_python_package
     [ "$status" -eq 1 ]
-    [[ "$output" == *"Python wheel not found"* ]]
+    [[ "$output" == *"Python wheel not found"* ]] || false
 }
 
 # A jar carrying a different version has a different filename, so copying the
@@ -429,7 +429,7 @@ EOF
 # The `|| false` after each [[ ]] is load-bearing, not decoration. errexit in 
bash
 # 3.2, the interpreter macOS supplies and bats takes test bodies from, does not
 # apply to [[ ]], so a bare [[ ]] assertion cannot fail a test there — it only
-# fails from bash 4 on. Chaining a simple command onto it restores the check.
+# fails from bash 4.1 on. Chaining a simple command onto it restores the check.
 # ---------------------------------------------------------------------------
 
 @test "prepare_work_dirs: writes a trigger file the source will read" {
@@ -695,7 +695,7 @@ file_mode() {
     REST_STUB_BODY="$(fixture_jobmanager_config | sed 
'/restart-strategy.type/d')"
     run assert_effective_config
     [ "$status" -eq 1 ]
-    [[ "$output" == *"absent from the cluster configuration"* ]]
+    [[ "$output" == *"absent from the cluster configuration"* ]] || false
 
     # Recorded, not just reported: the recorded FAIL is what makes the script
     # exit non-zero. `run` uses a subshell, so assert again in this one.
@@ -709,7 +709,7 @@ file_mode() {
     REST_STUB_BODY="$(fixture_jobmanager_config | sed 
's/"fixed-delay"/"disable"/')"
     run assert_effective_config
     [ "$status" -eq 1 ]
-    [[ "$output" == *"restart-strategy.type is 'disable', expected 
'fixed-delay'"* ]]
+    [[ "$output" == *"restart-strategy.type is 'disable', expected 
'fixed-delay'"* ]] || false
 }
 
 @test "assert_effective_config: an unreachable endpoint fails" {
@@ -717,7 +717,7 @@ file_mode() {
     REST_STUB_BODY=""
     run assert_effective_config
     [ "$status" -eq 1 ]
-    [[ "$output" == *"Could not read"* ]]
+    [[ "$output" == *"Could not read"* ]] || false
 }
 
 # ---------------------------------------------------------------------------
@@ -739,7 +739,7 @@ file_mode() {
     REST_STUB_BODY=""
     run assert_checkpointing_enabled
     [ "$status" -eq 1 ]
-    [[ "$output" == *"checkpointing is off"* ]]
+    [[ "$output" == *"checkpointing is off"* ]] || false
 }
 
 @test "assert_checkpointing_enabled: a different interval fails" {
@@ -747,7 +747,7 @@ file_mode() {
     REST_STUB_BODY="$(fixture_checkpoint_config | sed 
's/"interval":5000/"interval":180000/')"
     run assert_checkpointing_enabled
     [ "$status" -eq 1 ]
-    [[ "$output" == *"180000ms, expected 5000ms"* ]]
+    [[ "$output" == *"180000ms, expected 5000ms"* ]] || false
 }
 
 @test "assert_checkpointing_enabled: overlapping checkpoints warn about the 
gate" {
@@ -755,7 +755,7 @@ file_mode() {
     REST_STUB_BODY="$(fixture_checkpoint_config | sed 
's/"max_concurrent":1/"max_concurrent":3/')"
     run assert_checkpointing_enabled
     [ "$status" -eq 0 ]
-    [[ "$output" == *"weaker than intended"* ]]
+    [[ "$output" == *"weaker than intended"* ]] || false
 }
 
 # ---------------------------------------------------------------------------
@@ -781,7 +781,7 @@ verdict_setup() {
     verdict_setup
     run assert_verdict
     [ "$status" -eq 1 ]
-    [[ "$output" == *"nothing was verified"* ]]
+    [[ "$output" == *"nothing was verified"* ]] || false
 }
 
 @test "assert_verdict: a fail verdict fails and is recorded" {
@@ -789,7 +789,7 @@ verdict_setup() {
     printf '%s' '{"verdict":"fail","blob_observed_type":"NoneType"}' > 
"$VERDICT_DIR/verdict.json"
     run assert_verdict
     [ "$status" -eq 1 ]
-    [[ "$output" == *"the job reported 'fail'"* ]]
+    [[ "$output" == *"the job reported 'fail'"* ]] || false
 
     assert_verdict || true
     [ "${RESULT_STATES[0]}" = "FAIL" ]
@@ -810,7 +810,7 @@ verdict_setup() {
     printf '%s' '{"restored_blob":true}' > "$VERDICT_DIR/verdict.json"
     run assert_verdict
     [ "$status" -eq 1 ]
-    [[ "$output" == *"disagree about the verdict format"* ]]
+    [[ "$output" == *"disagree about the verdict format"* ]] || false
 }
 
 @test "assert_verdict: an unparsable verdict file fails" {
@@ -825,7 +825,7 @@ verdict_setup() {
     printf '%s' '{"verdict":"pass"}' > "$VERDICT_DIR/verdict.json.tmp"
     run assert_verdict
     [ "$status" -eq 1 ]
-    [[ "$output" == *"leftover"* ]]
+    [[ "$output" == *"leftover"* ]] || false
 }
 
 @test "assert_verdict: a job that died without a verdict fails fast" {
@@ -859,7 +859,7 @@ payload_budget_setup() {
     run assert_handshake_budget
     [ "$status" -eq 0 ]
     # The deadline is read from the payload module rather than restated here.
-    [[ "$output" == *"240s"* ]]
+    [[ "$output" == *"240s"* ]] || false
 }
 
 @test "assert_handshake_budget: a budget that fits nominally but not in wall 
clock is rejected" {
@@ -872,8 +872,8 @@ payload_budget_setup() {
     CHECKPOINT_TIMEOUT=45 TM_GONE_TIMEOUT=60 TM_UP_TIMEOUT=45 
RESTORE_TIMEOUT=45
     run assert_handshake_budget
     [ "$status" -eq 1 ]
-    [[ "$output" == *"could take up to"* ]]
-    [[ "$output" == *"exceeds"* ]]
+    [[ "$output" == *"could take up to"* ]] || false
+    [[ "$output" == *"exceeds"* ]] || false
 }
 
 @test "assert_handshake_budget: a grossly inflated budget is rejected" {
@@ -919,7 +919,7 @@ payload_budget_setup() {
     HANDSHAKE_DEADLINE_AT=$((SECONDS - 1))
     run charged_timeout "step" 30
     [ "$status" -eq 1 ]
-    [[ "$output" == *"elapsed before this step began"* ]]
+    [[ "$output" == *"elapsed before this step began"* ]] || false
 }
 
 @test "handshake_budget_left: never reports a negative remainder" {
@@ -999,7 +999,7 @@ run_cleanup_with_exit() {  # $1 = exit code, $2 = recorded 
state ("" for none),
     RESULT_STATES=()
     run print_summary
     [ "$status" -eq 1 ]
-    [[ "$output" == *"nothing was verified"* ]]
+    [[ "$output" == *"nothing was verified"* ]] || false
 }
 
 @test "print_summary: any recorded FAIL exits non-zero" {
diff --git a/tools/test/unit/edit_plan_quote.bats 
b/tools/test/unit/edit_plan_quote.bats
index ac6ae60b..27c81d97 100644
--- a/tools/test/unit/edit_plan_quote.bats
+++ b/tools/test/unit/edit_plan_quote.bats
@@ -2,8 +2,29 @@
 
 setup() {
     load '../helpers/load'
-    load_install_sh
-    reset_install_sh_state
+    # The bash 3.x test skips when no such interpreter is present, and sourcing
+    # install.sh replaces bats' EXIT trap, which `skip` needs in order to 
report.
+    # That test reaches edit_plan_quote through a child interpreter, so it 
wants
+    # nothing loaded here. Removing the trap instead of leaving it alone does 
not
+    # help; the skip is swallowed either way.
+    if [[ "$BATS_TEST_DESCRIPTION" != *"bash 3.x"* ]]; then
+        load_install_sh
+        reset_install_sh_state
+    fi
+}
+
+# Probes the usual bash locations and prints the first one whose major version
+# is 3, or nothing when none of them qualifies.
+find_bash3() {
+    local candidate
+    for candidate in /bin/bash /usr/bin/bash /usr/local/bin/bash 
/opt/homebrew/bin/bash; do
+        [[ -x "$candidate" ]] || continue
+        if [[ "$("$candidate" -c 'printf %s "${BASH_VERSINFO[0]}"' 
2>/dev/null)" == "3" ]]; then
+            printf '%s' "$candidate"
+            return 0
+        fi
+    done
+    return 0
 }
 
 # edit_plan_quote single-quotes its argument so the parent shell can
@@ -43,6 +64,34 @@ setup() {
     [ "$out" = "$input" ]
 }
 
+# The inline form this replaced is correct on 5.3.15 and broken on 3.2.57, so
+# a body that calls edit_plan_quote directly only catches the bug when it
+# happens to be running the older one. Invoking a real 3.x explicitly is what
+# covers the bash that install.sh's own users run.
+#
+# The value is adversarial on three axes at once. The adjacent pair of quotes
+# defeats an escape that replaces only the first occurrence. The lone quote
+# further along defeats an escape that handles only adjacent pairs. The $x
+# defeats wrapping the value in double quotes instead of single.
+@test "edit_plan_quote: repeated single quotes are escaped identically on bash 
3.x" {
+    local bash3
+    bash3="$(find_bash3)"
+    [[ -n "$bash3" ]] || skip "no bash 3.x interpreter found; install one to 
cover macOS /bin/bash"
+
+    local input="/tmp/o''brien's dir \$x"
+    local quoted
+    quoted="$(FLINK_AGENTS_INSTALL_SH_NO_RUN=1 "$bash3" -c \
+        '. "$1"; edit_plan_quote "$2"' _ \
+        "${BATS_TEST_DIRNAME}/../../install.sh" "$input")"
+
+    # Expected bytes: '/tmp/o'\'''\''brien'\''s dir $x'
+    [ "$quoted" = "'/tmp/o'\\'''\\''brien'\\''s dir \$x'" ]
+
+    local out
+    eval "out=$quoted"
+    [ "$out" = "$input" ]
+}
+
 @test "edit_plan_quote: shell metacharacters are not expanded on source-back" {
     local input='/tmp/$HOME-or-$(rm -rf /)'
     local quoted
diff --git a/tools/test/unit/parse_args.bats b/tools/test/unit/parse_args.bats
index 62b6b252..70f987af 100644
--- a/tools/test/unit/parse_args.bats
+++ b/tools/test/unit/parse_args.bats
@@ -49,7 +49,7 @@ setup() {
 @test "parse_args: --python without arg dies" {
     run parse_args --python
     [ "$status" -ne 0 ]
-    [[ "$output" == *"--python requires a path argument"* ]]
+    [[ "$output" == *"--python requires a path argument"* ]] || false
 }
 
 @test "parse_args: --help sets HELP=1" {
@@ -65,7 +65,7 @@ setup() {
 @test "parse_args: unknown flag warns but does not die" {
     run parse_args --no-such-flag
     [ "$status" -eq 0 ]
-    [[ "$output" == *"Unknown option: --no-such-flag"* ]]
+    [[ "$output" == *"Unknown option: --no-such-flag"* ]] || false
 }
 
 @test "parse_args: combined flags all apply" {
diff --git a/tools/test/unit/platform_detect.bats 
b/tools/test/unit/platform_detect.bats
index c1340080..b9aef8be 100644
--- a/tools/test/unit/platform_detect.bats
+++ b/tools/test/unit/platform_detect.bats
@@ -94,5 +94,5 @@ esac
     unset WSL_DISTRO_NAME
     run detect_os_or_die
     [ "$status" -ne 0 ]
-    [[ "$output" == *"Unsupported operating system"* ]]
+    [[ "$output" == *"Unsupported operating system"* ]] || false
 }
diff --git a/tools/test/unit/revalidate_python_constraint.bats 
b/tools/test/unit/revalidate_python_constraint.bats
index 96bbfa0a..c0580518 100644
--- a/tools/test/unit/revalidate_python_constraint.bats
+++ b/tools/test/unit/revalidate_python_constraint.bats
@@ -98,8 +98,8 @@ EOF
 
     run revalidate_python_constraint
     [ "$status" -eq 0 ]
-    [[ "$output" == *"incompatible with Flink Agents 0.2.1"* ]]
-    [[ "$output" == *"<3.12"* ]]
+    [[ "$output" == *"incompatible with Flink Agents 0.2.1"* ]] || false
+    [[ "$output" == *"<3.12"* ]] || false
 }
 
 # --- PYTHON_BIN re-resolution (Flink axis) ---
@@ -148,8 +148,8 @@ EOF
 
     run revalidate_existing_venv
     [ "$status" -ne 0 ]
-    [[ "$output" == *"uses Python 3.12"* ]]
-    [[ "$output" == *"different path"* ]]
+    [[ "$output" == *"uses Python 3.12"* ]] || false
+    [[ "$output" == *"different path"* ]] || false
 }
 
 @test "revalidate_existing_venv: no-op when the existing venv interpreter is 
compatible" {
diff --git a/tools/test/unit/shim_self_test.bats 
b/tools/test/unit/shim_self_test.bats
index 04def76a..a8695ac5 100644
--- a/tools/test/unit/shim_self_test.bats
+++ b/tools/test/unit/shim_self_test.bats
@@ -34,7 +34,7 @@ setup() {
 @test "PATH shim is preferred over real binary" {
     shim_bin curl
     run command -v curl
-    [[ "$output" == "$BATS_TEST_TMPDIR/bin/curl" ]]
+    [[ "$output" == "$BATS_TEST_TMPDIR/bin/curl" ]] || false
 }
 
 @test "shim_bin_missing makes command -v report missing" {
diff --git a/tools/test/unit/ui_helpers.bats b/tools/test/unit/ui_helpers.bats
index 61ad31d0..6032993c 100644
--- a/tools/test/unit/ui_helpers.bats
+++ b/tools/test/unit/ui_helpers.bats
@@ -11,44 +11,44 @@ setup() {
 @test "ui_info: prints the message in fallback branch" {
     run ui_info "hello world"
     [ "$status" -eq 0 ]
-    [[ "$output" == *"hello world"* ]]
+    [[ "$output" == *"hello world"* ]] || false
 }
 
 @test "ui_warn: prints the message in fallback branch" {
     run ui_warn "be careful"
     [ "$status" -eq 0 ]
-    [[ "$output" == *"be careful"* ]]
+    [[ "$output" == *"be careful"* ]] || false
 }
 
 @test "ui_success: prints the message in fallback branch" {
     run ui_success "all good"
     [ "$status" -eq 0 ]
-    [[ "$output" == *"all good"* ]]
+    [[ "$output" == *"all good"* ]] || false
 }
 
 @test "ui_error: prints the message in fallback branch" {
     run ui_error "uh oh"
     [ "$status" -eq 0 ]
-    [[ "$output" == *"uh oh"* ]]
+    [[ "$output" == *"uh oh"* ]] || false
 }
 
 @test "ui_kv: prints key and value" {
     run ui_kv "Flink version" "2.2.0"
     [ "$status" -eq 0 ]
-    [[ "$output" == *"Flink version"* ]]
-    [[ "$output" == *"2.2.0"* ]]
+    [[ "$output" == *"Flink version"* ]] || false
+    [[ "$output" == *"2.2.0"* ]] || false
 }
 
 @test "ui_stage: increments stage counter and prints title" {
     INSTALL_STAGE_CURRENT=0
     run ui_stage "First thing"
     [ "$status" -eq 0 ]
-    [[ "$output" == *"First thing"* ]]
-    [[ "$output" == *"[1/${INSTALL_STAGE_TOTAL}]"* ]]
+    [[ "$output" == *"First thing"* ]] || false
+    [[ "$output" == *"[1/${INSTALL_STAGE_TOTAL}]"* ]] || false
 }
 
 @test "die: prints message and exits non-zero" {
     run die "fatal boom"
     [ "$status" -ne 0 ]
-    [[ "$output" == *"fatal boom"* ]]
+    [[ "$output" == *"fatal boom"* ]] || false
 }
diff --git a/tools/test/unit/verify_example_job.bats 
b/tools/test/unit/verify_example_job.bats
index 2ed65a77..07f530d6 100644
--- a/tools/test/unit/verify_example_job.bats
+++ b/tools/test/unit/verify_example_job.bats
@@ -109,7 +109,7 @@ create_fake_executable() {
     run wait_for_job_healthy "job-id" "example" 5 2
 
     [ "$status" -eq 0 ]
-    [[ "$output" == *"reached FINISHED status"* ]]
+    [[ "$output" == *"reached FINISHED status"* ]] || false
 }
 
 @test "job health check accepts a continuously RUNNING job" {
@@ -118,7 +118,7 @@ create_fake_executable() {
     run wait_for_job_healthy "job-id" "example" 5 2
 
     [ "$status" -eq 0 ]
-    [[ "$output" == *"remained RUNNING for 2s"* ]]
+    [[ "$output" == *"remained RUNNING for 2s"* ]] || false
 }
 
 @test "job health check rejects a failing job" {
@@ -130,7 +130,7 @@ create_fake_executable() {
     run wait_for_job_healthy "job-id" "example" 5 2
 
     [ "$status" -ne 0 ]
-    [[ "$output" == *"entered unexpected state: FAILING"* ]]
+    [[ "$output" == *"entered unexpected state: FAILING"* ]] || false
 }
 
 @test "job health check resets the stability period after a restart" {
@@ -139,7 +139,7 @@ create_fake_executable() {
     run wait_for_job_healthy "job-id" "example" 4 2
 
     [ "$status" -ne 0 ]
-    [[ "$output" == *"did not become stably RUNNING or FINISHED"* ]]
+    [[ "$output" == *"did not become stably RUNNING or FINISHED"* ]] || false
 }
 
 @test "submitted job is marked failed when health verification fails" {
@@ -213,7 +213,7 @@ create_fake_executable() {
     run cancel_job_after_check "job-id" "example"
 
     [ "$status" -eq 0 ]
-    [[ "$output" == *"no longer occupies the cluster slot"* ]]
+    [[ "$output" == *"no longer occupies the cluster slot"* ]] || false
 }
 
 @test "slow slot cleanup restarts the standalone cluster" {
@@ -247,13 +247,13 @@ create_fake_executable() {
     run cancel_job_after_check "job-id" "example"
 
     [ "$status" -ne 0 ]
-    [[ "$output" == *"failed to cancel job job-id"* ]]
+    [[ "$output" == *"failed to cancel job job-id"* ]] || false
 }
 
 @test "CI chat model aliases cover every hardcoded quickstart model" {
-    [[ " ${OLLAMA_CHAT_MODEL_ALIASES[*]} " == *" qwen3:1.7b "* ]]
-    [[ " ${OLLAMA_CHAT_MODEL_ALIASES[*]} " == *" qwen3:8b "* ]]
-    [[ " ${OLLAMA_CHAT_MODEL_ALIASES[*]} " == *" qwen3.5:9b "* ]]
+    [[ " ${OLLAMA_CHAT_MODEL_ALIASES[*]} " == *" qwen3:1.7b "* ]] || false
+    [[ " ${OLLAMA_CHAT_MODEL_ALIASES[*]} " == *" qwen3:8b "* ]] || false
+    [[ " ${OLLAMA_CHAT_MODEL_ALIASES[*]} " == *" qwen3.5:9b "* ]] || false
 }
 
 @test "Java submission keeps the attached client alive during validation" {
@@ -310,7 +310,7 @@ create_fake_executable() {
     run main
 
     [ "$status" -ne 0 ]
-    [[ "$output" == *"Java example discovery failed"* ]]
+    [[ "$output" == *"Java example discovery failed"* ]] || false
 }
 
 @test "main propagates Python quickstart discovery failure" {
@@ -322,7 +322,7 @@ create_fake_executable() {
     run main
 
     [ "$status" -ne 0 ]
-    [[ "$output" == *"Python quickstart example discovery failed"* ]]
+    [[ "$output" == *"Python quickstart example discovery failed"* ]] || false
 }
 
 @test "main propagates RAG example discovery failure" {
@@ -334,7 +334,7 @@ create_fake_executable() {
     run main
 
     [ "$status" -ne 0 ]
-    [[ "$output" == *"Python RAG example discovery failed"* ]]
+    [[ "$output" == *"Python RAG example discovery failed"* ]] || false
 }
 
 @test "main propagates RAG knowledge base setup failure" {
@@ -346,7 +346,7 @@ create_fake_executable() {
     run main
 
     [ "$status" -ne 0 ]
-    [[ "$output" == *"Cannot run Python RAG examples because setup failed"* ]]
+    [[ "$output" == *"Cannot run Python RAG examples because setup failed"* ]] 
|| false
 }
 
 @test "main skips RAG setup when no RAG examples exist" {
@@ -461,6 +461,6 @@ create_fake_executable() {
 
     [ "$TEST_VENV_ACTIVATED" -eq 1 ]
     [ "$PYFLINK_CLIENT_EXECUTABLE" = "$VENV_DIR/bin/python" ]
-    [[ "$(cat "$PYTHON_CALLS")" == *"-m pip install --quiet"* ]]
-    [[ "$(cat "$PYTHON_CALLS")" == *"apache-flink==$FLINK_VERSION"* ]]
+    [[ "$(cat "$PYTHON_CALLS")" == *"-m pip install --quiet"* ]] || false
+    [[ "$(cat "$PYTHON_CALLS")" == *"apache-flink==$FLINK_VERSION"* ]] || false
 }

Reply via email to