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 207b2b4e6 [tools][ci] Fix the bats suite's boundary with the scripts 
it tests (#1081)
207b2b4e6 is described below

commit 207b2b4e6683705a9f7077c0f0363ac2a49669af
Author: Weiqing Yang <[email protected]>
AuthorDate: Wed Sep 2 04:03:54 2026 -0700

    [tools][ci] Fix the bats suite's boundary with the scripts it tests (#1081)
    
    Generated-by: Claude Code 2.1.251 (Claude Opus 5)
---
 .github/workflows/ci.yml                           |  41 +++++++
 tools/install.sh                                   |  23 +++-
 tools/test/integration/build_help.bats             |   6 +-
 tools/test/integration/dry_run.bats                |  10 +-
 tools/test/integration/dry_run_extra.bats          |   8 +-
 tools/test/integration/err_trap.bats               |   4 +-
 tools/test/integration/help.bats                   |   4 +-
 tools/test/integration/venv_dir_validation.bats    |   4 +-
 tools/test/run.sh                                  |  44 ++++++-
 tools/test/unit/edit_plan_quote.bats               |  11 +-
 tools/test/unit/install_sh_trap_isolation.bats     |  91 +++++++++++++++
 tools/test/unit/spawn_site_interpreter.bats        | 126 +++++++++++++++++++++
 tools/test/unit/ut_sh_flink_flag_scope.bats        |  24 ++--
 tools/test/unit/ut_sh_flink_version_supported.bats |  16 +--
 tools/test/unit/ut_sh_version_default.bats         |  18 +--
 15 files changed, 364 insertions(+), 66 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 416aaa7cb..e260bacbd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -68,6 +68,47 @@ jobs:
       - name: Run tools script tests
         run: bash tools/test/run.sh
 
+  # install.sh and ut.sh both run on the bash macOS ships at /bin/bash for 
their
+  # own users: install.sh is documented as `curl ... | bash`, and ut.sh is
+  # invoked by path from the macOS jobs below. run.sh pins `bash` to the newer
+  # interpreter the harness itself needs, and that pin reaches the scripts 
under
+  # test as well, so this leg points them back at the one they ship against.
+  tools_script_tests_bash32:
+    name: tools script tests (macos-latest, subject on bash 3.2)
+    runs-on: macos-latest
+    # Declared once for the whole job so the check below and the run below read
+    # the same value. Setting it on the run step alone would let a misspelled
+    # key pass the check and then leave the suite spawning the subjects on the
+    # harness bash, which is the green-over-nothing outcome the check exists to
+    # prevent.
+    env:
+      FLINK_AGENTS_SUT_BASH: /bin/bash
+    steps:
+      - uses: actions/checkout@v4
+      # Only the subjects move; the harness keeps the version its own
+      # assertions need.
+      - name: Install bash 4.1+ for the harness
+        run: brew install bash
+      - name: Confirm the subject interpreter is the bash macOS ships
+        run: |
+          set -eu
+          echo "PATH bash: $(command -v bash)"
+          bash --version | head -1
+          : "${FLINK_AGENTS_SUT_BASH:?is not set, so run.sh would spawn the 
subjects on the harness bash and this job would cover nothing}"
+          echo "subject:   $FLINK_AGENTS_SUT_BASH"
+          "$FLINK_AGENTS_SUT_BASH" --version | head -1
+          major="$("$FLINK_AGENTS_SUT_BASH" -c 'printf %s 
"${BASH_VERSINFO[0]}"')"
+          if [ "$major" != "3" ]; then
+              echo "ERROR: $FLINK_AGENTS_SUT_BASH on this runner is bash 
${major}.x," >&2
+              echo "not 3.x. This job exists to run the scripts under test on 
the" >&2
+              echo "bash that macOS ships. On a ${major}.x interpreter it 
repeats" >&2
+              echo "the default macOS job and covers nothing, so it fails 
here" >&2
+              echo "rather than report a green result it did not earn." >&2
+              exit 1
+          fi
+      - name: Run tools script tests with the subjects on the bash macOS ships
+        run: bash tools/test/run.sh
+
   build_backend_tests:
     name: ut-build-backend
     runs-on: ubuntu-latest
diff --git a/tools/install.sh b/tools/install.sh
index 3f9033be0..dab4649be 100755
--- a/tools/install.sh
+++ b/tools/install.sh
@@ -38,11 +38,19 @@ cleanup_tmpfiles() {
         rm -rf "$f" 2>/dev/null || true
     done
 }
-trap cleanup_tmpfiles EXIT
-# Some interactive read combinations (notably `read -e` under stdin 
redirection)
-# can swallow SIGINT, leaving the user pressing Ctrl+C with no effect. Install
-# an explicit INT trap so Ctrl+C always lands.
-trap 'die_cancelled' INT
+# Traps belong to a run, not to a source. The test suite and the e2e scripts
+# source this file under FLINK_AGENTS_INSTALL_SH_NO_RUN=1 to call individual
+# functions, and a top-level `trap` replaces the handlers whoever sourced us
+# already installed: bats in particular reports a failing or skipped result 
from
+# inside its own EXIT trap, so displacing it makes that result vanish from the
+# output.
+if [[ "${FLINK_AGENTS_INSTALL_SH_NO_RUN:-0}" != "1" ]]; then
+    trap cleanup_tmpfiles EXIT
+    # Some interactive read combinations (notably `read -e` under stdin
+    # redirection) can swallow SIGINT, leaving the user pressing Ctrl+C with no
+    # effect. Install an explicit INT trap so Ctrl+C always lands.
+    trap 'die_cancelled' INT
+fi
 
 mktempfile() {
     local f
@@ -379,7 +387,10 @@ on_error() {
     } >&2
     exit "$rc"
 }
-trap 'on_error $? $LINENO "$BASH_COMMAND"' ERR
+# Armed only for a run, for the reason given at the EXIT and INT traps above.
+if [[ "${FLINK_AGENTS_INSTALL_SH_NO_RUN:-0}" != "1" ]]; then
+    trap 'on_error $? $LINENO "$BASH_COMMAND"' ERR
+fi
 
 INSTALL_STAGE_TOTAL=5
 INSTALL_STAGE_CURRENT=0
diff --git a/tools/test/integration/build_help.bats 
b/tools/test/integration/build_help.bats
index 5fc5ccfd2..bb9e99658 100644
--- a/tools/test/integration/build_help.bats
+++ b/tools/test/integration/build_help.bats
@@ -21,7 +21,7 @@
 BUILD_SCRIPT="${BATS_TEST_DIRNAME}/../../build.sh"
 
 @test "build --help prints usage and exits 0" {
-    run bash "$BUILD_SCRIPT" --help
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$BUILD_SCRIPT" --help
 
     [ "$status" -eq 0 ]
     [[ "$output" == *"Build Flink Agents Java and Python artifacts"* ]] || 
false
@@ -31,14 +31,14 @@ BUILD_SCRIPT="${BATS_TEST_DIRNAME}/../../build.sh"
 }
 
 @test "build -h prints usage and exits 0" {
-    run bash "$BUILD_SCRIPT" -h
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$BUILD_SCRIPT" -h
 
     [ "$status" -eq 0 ]
     [[ "$output" == *"Usage:"* ]] || false
 }
 
 @test "build rejects an unknown option with usage" {
-    run bash "$BUILD_SCRIPT" --no-such-option
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$BUILD_SCRIPT" --no-such-option
 
     [ "$status" -eq 1 ]
     [[ "$output" == *"Error: Unknown option '--no-such-option'"* ]] || false
diff --git a/tools/test/integration/dry_run.bats 
b/tools/test/integration/dry_run.bats
index f16982140..fc6b740b0 100644
--- a/tools/test/integration/dry_run.bats
+++ b/tools/test/integration/dry_run.bats
@@ -16,7 +16,7 @@ esac
 }
 
 @test "--dry-run --non-interactive prints plan and makes no external calls" {
-    run bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run 
--non-interactive
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" 
"${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --non-interactive
     [ "$status" -eq 0 ]
     case "$output" in *"Installation plan"*) ;; *) false ;; esac
     case "$output" in *"Dry run complete"*) ;; *) false ;; esac
@@ -26,7 +26,7 @@ esac
 }
 
 @test "--dry-run --install-flink --non-interactive shows Install Flink: Yes" {
-    run bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --install-flink 
--non-interactive
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" 
"${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --install-flink 
--non-interactive
     [ "$status" -eq 0 ]
     case "$output" in *"Install Flink"*) ;; *) false ;; esac
     case "$output" in *"Yes"*) ;; *) false ;; esac
@@ -35,20 +35,20 @@ esac
 
 @test "INSTALL_DIR=. does not produce a double-slash FLINK_HOME (review 
feedback guard)" {
     cd "$BATS_TEST_TMPDIR"
-    run env INSTALL_DIR="." bash "${BATS_TEST_DIRNAME}/../../install.sh" 
--dry-run --install-flink --non-interactive
+    run env INSTALL_DIR="." "${FLINK_AGENTS_SUT_BASH:-bash}" 
"${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --install-flink 
--non-interactive
     [ "$status" -eq 0 ]
     case "$output" in *".//flink-"*) false ;; *) ;; esac
 }
 
 @test "INSTALL_DIR with trailing slash does not produce double-slash (review 
#7b)" {
-    run env INSTALL_DIR="/tmp/flink-test/" bash 
"${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --install-flink 
--non-interactive
+    run env INSTALL_DIR="/tmp/flink-test/" "${FLINK_AGENTS_SUT_BASH:-bash}" 
"${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --install-flink 
--non-interactive
     [ "$status" -eq 0 ]
     case "$output" in *"//"*) false ;; *) ;; esac
     case "$output" in *"/tmp/flink-test"*) ;; *) false ;; esac
 }
 
 @test "INSTALL_DIR with consecutive slashes is collapsed (review #7b)" {
-    run env INSTALL_DIR="/tmp//flink-test" bash 
"${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --install-flink 
--non-interactive
+    run env INSTALL_DIR="/tmp//flink-test" "${FLINK_AGENTS_SUT_BASH:-bash}" 
"${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --install-flink 
--non-interactive
     [ "$status" -eq 0 ]
     case "$output" in *"//flink-"*) false ;; *) ;; esac
 }
diff --git a/tools/test/integration/dry_run_extra.bats 
b/tools/test/integration/dry_run_extra.bats
index 105ccf4c0..5ee8a85e8 100644
--- a/tools/test/integration/dry_run_extra.bats
+++ b/tools/test/integration/dry_run_extra.bats
@@ -17,25 +17,25 @@ esac
 }
 
 @test "dry-run: plan shows Flink Agents version (review #1 — JARs are 
implicit)" {
-    run bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run 
--non-interactive
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" 
"${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --non-interactive
     [ "$status" -eq 0 ]
     case "$output" in *"Flink Agents version"*"0.3.0"*) ;; *) false ;; esac
 }
 
 @test "dry-run: existing FLINK_HOME — plan shows detected version, not default 
(review #2)" {
-    run bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run 
--non-interactive
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" 
"${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --non-interactive
     [ "$status" -eq 0 ]
     case "$output" in *"v2.1.1"*) ;; *) false ;; esac
 }
 
 @test "dry-run: INSTALL_FLINK=No suppresses Install directory line (review 
#3)" {
-    run bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run 
--non-interactive
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" 
"${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --non-interactive
     [ "$status" -eq 0 ]
     case "$output" in *"Install directory"*) false ;; *) ;; esac
 }
 
 @test "dry-run: Environment section is shown separately from Plan (review #5)" 
{
-    run bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run 
--non-interactive
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" 
"${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --non-interactive
     [ "$status" -eq 0 ]
     case "$output" in *"Environment (read-only)"*) ;; *) false ;; esac
     case "$output" in *"Installation plan"*) ;; *) false ;; esac
diff --git a/tools/test/integration/err_trap.bats 
b/tools/test/integration/err_trap.bats
index bc553c53f..62e5ffb9b 100644
--- a/tools/test/integration/err_trap.bats
+++ b/tools/test/integration/err_trap.bats
@@ -13,7 +13,7 @@
     mkdir -p "$tmp"
     run env INSTALL_DIR="$tmp" FLINK_BASE_URL="https://dlcdn.apache.org/flink"; 
\
         FLINK_VERSION=99.99.0 \
-        bash "${BATS_TEST_DIRNAME}/../../install.sh" \
+        "${FLINK_AGENTS_SUT_BASH:-bash}" 
"${BATS_TEST_DIRNAME}/../../install.sh" \
             --install-flink --non-interactive
     [ "$status" -ne 0 ]
     # Banner must name the stage by title.
@@ -31,7 +31,7 @@
     # die() uses `exit`, which doesn't trigger ERR. Reach it by giving
     # plan_flink a non-existent FLINK_HOME under --non-interactive.
     run env FLINK_HOME="" \
-        bash "${BATS_TEST_DIRNAME}/../../install.sh" --non-interactive
+        "${FLINK_AGENTS_SUT_BASH:-bash}" 
"${BATS_TEST_DIRNAME}/../../install.sh" --non-interactive
     [ "$status" -ne 0 ]
     case "$output" in *"FLINK_HOME is not set"*) ;; *) false ;; esac
     # Banner box characters must NOT appear in a die() path.
diff --git a/tools/test/integration/help.bats b/tools/test/integration/help.bats
index d77335e37..91f055e75 100644
--- a/tools/test/integration/help.bats
+++ b/tools/test/integration/help.bats
@@ -1,7 +1,7 @@
 #!/usr/bin/env bats
 
 @test "--help prints usage and exits 0" {
-    run bash "${BATS_TEST_DIRNAME}/../../install.sh" --help
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" 
"${BATS_TEST_DIRNAME}/../../install.sh" --help
     [ "$status" -eq 0 ]
     case "$output" in *"Apache Flink Agents Installer"*) ;; *) false ;; esac
     case "$output" in *"Options:"*) ;; *) false ;; esac
@@ -10,7 +10,7 @@
 }
 
 @test "-h prints usage and exits 0" {
-    run bash "${BATS_TEST_DIRNAME}/../../install.sh" -h
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" 
"${BATS_TEST_DIRNAME}/../../install.sh" -h
     [ "$status" -eq 0 ]
     case "$output" in *"Apache Flink Agents Installer"*) ;; *) false ;; esac
 }
diff --git a/tools/test/integration/venv_dir_validation.bats 
b/tools/test/integration/venv_dir_validation.bats
index 55fc23155..11ed59c7c 100644
--- a/tools/test/integration/venv_dir_validation.bats
+++ b/tools/test/integration/venv_dir_validation.bats
@@ -26,7 +26,7 @@ esac
     : > "$foreign/unrelated.txt"
 
     run env VENV_DIR="$foreign" PYTHON_BIN=/no/such/python3 \
-        bash "${BATS_TEST_DIRNAME}/../../install.sh" \
+        "${FLINK_AGENTS_SUT_BASH:-bash}" 
"${BATS_TEST_DIRNAME}/../../install.sh" \
             --non-interactive --enable-pyflink
 
     [ "$status" -ne 0 ]
@@ -49,7 +49,7 @@ esac
     # plan_pyflink validation. Use --dry-run so plan succeeds and we
     # bail before stage 3.
     run env VENV_DIR="$venv" PYTHON_BIN=/no/such/python3 \
-        bash "${BATS_TEST_DIRNAME}/../../install.sh" \
+        "${FLINK_AGENTS_SUT_BASH:-bash}" 
"${BATS_TEST_DIRNAME}/../../install.sh" \
             --non-interactive --enable-pyflink --dry-run
     # Either the dry-run printout completes (status 0) or fails for an
     # unrelated reason (e.g. PYTHON_BIN missing in non-interactive 
resolve_python).
diff --git a/tools/test/run.sh b/tools/test/run.sh
index 24470ad3d..956e7fc84 100755
--- a/tools/test/run.sh
+++ b/tools/test/run.sh
@@ -55,14 +55,50 @@ clone_pinned() {
 
 mkdir -p "$CACHE"
 
+# Interpreter used to spawn the scripts under test, kept separate from the one
+# the harness runs on. The pin below resolves the default `bash` to this shell,
+# so leaving it alone keeps the behaviour the suite has always had. Set it to 
an
+# absolute path to exercise the subjects elsewhere: an absolute path is not a
+# PATH lookup, so the pin does not intercept it, and the scripts then run on 
the
+# interpreter their own users have rather than on the one this suite's
+# assertions need. Each spawn site repeats the `:-bash` default so a .bats file
+# invoked through bats directly, without this script, still names one.
+#
+# A value that is set has to name something that runs as a bash. The spawn 
sites
+# would otherwise carry it to a subject that cannot start, and a subject that
+# cannot start still satisfies every assertion written as "this must fail" --
+# so the suite would report a green run over nothing. Probe rather than test 
for
+# execute permission: /bin/echo is executable and answers this with its own
+# arguments instead of a version.
+if [ -n "${FLINK_AGENTS_SUT_BASH+set}" ]; then
+    sut_major=""
+    if [ -n "$FLINK_AGENTS_SUT_BASH" ]; then
+        # shellcheck disable=SC2016  # the expansion belongs to the child
+        sut_major="$("$FLINK_AGENTS_SUT_BASH" \
+            -c 'printf %s "${BASH_VERSINFO[0]}"' 2>/dev/null || true)"
+    fi
+    case "$sut_major" in
+        ''|*[!0-9]*)
+            echo "ERROR: FLINK_AGENTS_SUT_BASH is set to 
'${FLINK_AGENTS_SUT_BASH}'," >&2
+            echo "which did not report a bash version." >&2
+            echo "It names the interpreter the scripts under test are spawned 
on, so a" >&2
+            echo "value that is empty, missing, or not a bash leaves the suite 
covering" >&2
+            echo "nothing. Point it at a bash, or unset it to spawn the 
scripts on the" >&2
+            echo "interpreter running this suite." >&2
+            exit 1
+            ;;
+    esac
+fi
+export FLINK_AGENTS_SUT_BASH="${FLINK_AGENTS_SUT_BASH:-bash}"
+
 # 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.
+# This also reaches the scripts under test, which start with
+# `#!/usr/bin/env bash` themselves: a spawn site naming a bare `bash` runs the
+# subject on this interpreter instead of on the one the developer's own PATH
+# selects. That reach is what FLINK_AGENTS_SUT_BASH above exists to override.
 #
 # 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
diff --git a/tools/test/unit/edit_plan_quote.bats 
b/tools/test/unit/edit_plan_quote.bats
index 27c81d97a..6b181f30c 100644
--- a/tools/test/unit/edit_plan_quote.bats
+++ b/tools/test/unit/edit_plan_quote.bats
@@ -2,15 +2,8 @@
 
 setup() {
     load '../helpers/load'
-    # 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
+    load_install_sh
+    reset_install_sh_state
 }
 
 # Probes the usual bash locations and prints the first one whose major version
diff --git a/tools/test/unit/install_sh_trap_isolation.bats 
b/tools/test/unit/install_sh_trap_isolation.bats
new file mode 100644
index 000000000..22369353a
--- /dev/null
+++ b/tools/test/unit/install_sh_trap_isolation.bats
@@ -0,0 +1,91 @@
+#!/usr/bin/env bats
+
+# install.sh installs its EXIT, INT and ERR traps at top level, so a `source`
+# installs them too. Every file whose setup() calls load_install_sh sources it,
+# and bats prints a failing or skipped result from inside its own EXIT trap: a
+# handler laid over that one makes such a result vanish from the stream
+# altogether, and an ERR handler rewrites the trace of whatever survives.
+# Sourcing install.sh with FLINK_AGENTS_INSTALL_SH_NO_RUN=1 therefore has to
+# leave the caller's handlers as it found them.
+#
+# A test cannot observe its own missing result, so both tests below drive a
+# nested bats run over a throwaway file and read that run's TAP stream.
+
+setup() {
+    # Deliberately no `load '../helpers/load'` here: sourcing install.sh into
+    # this file is the condition under test, and would suppress its own 
results.
+    INSTALL_SH="${BATS_TEST_DIRNAME}/../../install.sh"
+}
+
+# Writes a bats file that sources install.sh the way load_install_sh does and
+# then fails and skips, and prints its path. It is written at run time rather
+# than committed because run.sh collects unit/ and integration/ recursively,
+# and a committed copy would be run as a real test file.
+#
+# It cannot `load '../helpers/load'`: bats resolves a helper path against the
+# directory of the file doing the loading, which here is the temp directory. 
The
+# path to install.sh arrives through the environment instead.
+write_sourcing_fixture() {
+    local fixture="$BATS_TEST_TMPDIR/sourcing_fixture.bats"
+    cat > "$fixture" <<'EOF'
+#!/usr/bin/env bats
+
+setup() {
+    export FLINK_AGENTS_INSTALL_SH_NO_RUN=1
+    # shellcheck disable=SC1090
+    source "$FIXTURE_INSTALL_SH"
+}
+
+@test "fixture: a failing assertion" {
+    [[ "expected" == "actual" ]] || false
+}
+
+@test "fixture: a skip" {
+    skip "the reason reaches the stream"
+}
+EOF
+    printf '%s' "$fixture"
+}
+
+# Fails the calling test after printing the nested run's TAP stream, which 
every
+# assertion below inspects and which is otherwise unrecoverable: bats removes
+# $BATS_TEST_TMPDIR with the rest of the run's temporary tree once the run 
ends,
+# taking the fixture and its output with it. A test body's stderr reaches the
+# report as comment lines.
+fail_with_tap() {
+    printf 'nested TAP stream:\n%s\n' "$output" >&2
+    false
+}
+
+@test "sourcing install.sh: a failing assertion is reported, and blamed on the 
test" {
+    local fixture
+    fixture="$(write_sourcing_fixture)"
+    # The line the trace has to name, read back from the fixture so it stays
+    # correct when the fixture above is edited.
+    local fail_line
+    fail_line="$(grep -n '"expected" == "actual"' "$fixture" | cut -d: -f1)"
+
+    run env FIXTURE_INSTALL_SH="$INSTALL_SH" \
+        "$BATS_ROOT/bin/bats" --formatter tap "$fixture"
+
+    [[ "$output" == *"not ok 1 fixture: a failing assertion"* ]] || 
fail_with_tap
+    # The opening of the trace, not just the file it names. A trace bats
+    # produced itself opens with `# (in test file `; install.sh's ERR handler
+    # prepends its own `from function 'on_error'` frame, displacing that
+    # opening.
+    [[ "$output" == *"# (in test file $fixture, line $fail_line)"* ]] || 
fail_with_tap
+    # A trace led by install.sh's handler, and the banner it prints, both blame
+    # the installer for a failure that belongs to the test.
+    [[ "$output" != *"on_error"* ]] || fail_with_tap
+    [[ "$output" != *"Installation failed"* ]] || fail_with_tap
+}
+
+@test "sourcing install.sh: a skip is reported" {
+    local fixture
+    fixture="$(write_sourcing_fixture)"
+
+    run env FIXTURE_INSTALL_SH="$INSTALL_SH" \
+        "$BATS_ROOT/bin/bats" --formatter tap "$fixture"
+
+    [[ "$output" == *"ok 2 fixture: a skip # skip the reason reaches the 
stream"* ]] || fail_with_tap
+}
diff --git a/tools/test/unit/spawn_site_interpreter.bats 
b/tools/test/unit/spawn_site_interpreter.bats
new file mode 100644
index 000000000..17fac76ca
--- /dev/null
+++ b/tools/test/unit/spawn_site_interpreter.bats
@@ -0,0 +1,126 @@
+#!/usr/bin/env bats
+
+# run.sh puts a `bash` symlink to its own interpreter at the front of PATH, so
+# every `#!/usr/bin/env bash` hop inside the run resolves to the shell whose
+# version the suite's assertions depend on. That pin reaches the scripts under
+# test as well, which is more than it was meant to do: those scripts run on
+# whatever interpreter their own users have. FLINK_AGENTS_SUT_BASH separates 
the
+# two, and every site that spawns a script under test names it instead of a 
bare
+# `bash`. An absolute path is not a PATH lookup, which is what lets it out from
+# under the pin.
+#
+# A test cannot observe the interpreter another test's subject ran on, so the
+# first test drives a nested bats run over a real file from the suite and reads
+# back what that run reached. The other two are static: they are what keeps a
+# site added later from quietly going back to the pinned interpreter.
+
+setup() {
+    SUITE_ROOT="$BATS_TEST_DIRNAME/.."
+    # The smallest file that spawns a script under test, and the only one that
+    # needs no helpers, so a nested run of it isolates the spawn.
+    HELP_BATS="$SUITE_ROOT/integration/help.bats"
+}
+
+# An interpreter that records having been reached and then hands its arguments
+# to the shell running this file. Standing in for a second real bash keeps the
+# assertion independent of which interpreters the machine happens to carry.
+write_recording_interpreter() {
+    local dir="$1"
+    mkdir -p "$dir"
+    cat > "$dir/bash" <<EOF
+#!/usr/bin/env bash
+printf 'reached\n' >> "$dir/reached"
+exec "$BASH" "\$@"
+EOF
+    chmod +x "$dir/bash"
+    printf '%s' "$dir/bash"
+}
+
+fail_with_tap() {
+    printf 'nested TAP stream:\n%s\n' "$output" >&2
+    false
+}
+
+@test "spawn site: a converted file spawns its subject on 
FLINK_AGENTS_SUT_BASH" {
+    local dir="$BATS_TEST_TMPDIR/sut"
+    local interpreter
+    interpreter="$(write_recording_interpreter "$dir")"
+
+    # Read off the file rather than written here: a test added to help.bats
+    # later moves the expectation with it, and a spawn that stops naming the
+    # interpreter still counts, so reverting one is what the difference 
reports.
+    local spawns
+    spawns="$(grep -cE '^[[:space:]]*run .*install\.sh' "$HELP_BATS")"
+
+    run env FLINK_AGENTS_SUT_BASH="$interpreter" \
+        "$BATS_ROOT/bin/bats" --formatter tap "$HELP_BATS"
+
+    [ "$status" -eq 0 ] || fail_with_tap
+    [ -f "$dir/reached" ] || fail_with_tap
+    # Every spawn arrived at the named interpreter, not just one of them.
+    [ "$(wc -l < "$dir/reached")" -eq "$spawns" ] || fail_with_tap
+}
+
+@test "spawn site: no test file spawns a script under test on the pinned 
interpreter" {
+    # Two ways a spawn ends up back on the harness's shell. A bare `bash` is a
+    # PATH lookup, which the pin claims. Handing the script's own path to `run`
+    # relies on its `#!/usr/bin/env bash`, which is the same lookup one step
+    # later. Either way the subject runs on the interpreter the assertions need
+    # rather than the one its users have, and nothing reports it.
+    #
+    # Both patterns read one line at a time, so a spawn split across a line
+    # continuation is outside what they can see; recognising that needs context
+    # a line-oriented rule does not have.
+    #
+    # Two bare spawns are legitimate and are recognised by what the line names
+    # rather than by where it sits, so neither rots when a file is edited:
+    #
+    #   - a line naming FLINK_AGENTS_SUT_BASH: the bare `bash -c` there owns a
+    #     redirection that `run` cannot carry, and the subject on that line is
+    #     spawned by the inner, named interpreter.
+    #   - a line naming FLINK_AGENTS_RECOVERY_SH_NO_RUN: it sources the
+    #     checkpoint-recovery script, which CI exercises on ubuntu only, so the
+    #     interpreter this variable selects is not one that subject ever meets.
+    #
+    # The second pattern names the three scripts under test and the variables
+    # that hold their paths, rather than any path at all: a rule wide enough to
+    # cover every conceivable spelling would fire on the fixtures and stubs
+    # these files write, and a check that cries wolf gets deleted.
+    local bare='(^|[^-[:alnum:]_/$"])bash[[:space:]]+("?\$|-c )'
+    local direct='(^|[[:space:]])run (env ([A-Za-z_][A-Za-z0-9_]*=("[^"]*"|[^ 
"]*) )+)?'
+    
direct+='"(\$\{?(UT_SH|BUILD_SCRIPT|INSTALL_SH)\}?|[^"]*/(install|ut|build)\.sh)"'
+
+    local offenders
+    offenders="$( { grep -rnE "$bare" \
+                        "$SUITE_ROOT/unit" "$SUITE_ROOT/integration" 
--include='*.bats' \
+                        | grep -v -e 'FLINK_AGENTS_SUT_BASH' -e 
'FLINK_AGENTS_RECOVERY_SH_NO_RUN'
+                    grep -rnE "$direct" \
+                        "$SUITE_ROOT/unit" "$SUITE_ROOT/integration" 
--include='*.bats'
+                  } | sort -u || true)"
+    if [[ -n "$offenders" ]]; then
+        printf 'spawn sites not naming the subject interpreter:\n%s\n' 
"$offenders" >&2
+        false
+    fi
+}
+
+@test "spawn site: every one falls back to the harness's \`bash\`" {
+    # The fallback has to be the bare word, so an unset variable keeps the
+    # behaviour the suite had before it existed, and it has to keep the colon,
+    # so a caller that exports the variable empty does not leave the site
+    # naming nothing. A fixed path here would move every subject off the
+    # harness silently.
+    #
+    # This file names the variable in prose and drives it in the nested run
+    # above, so it is excluded by name rather than by path: grep reports the
+    # path it walked, which is not the one BATS_TEST_FILENAME holds.
+    local wrong
+    wrong="$(grep -rn 'FLINK_AGENTS_SUT_BASH' \
+        "$SUITE_ROOT/unit" "$SUITE_ROOT/integration" --include='*.bats' \
+        | grep -v "/$(basename "$BATS_TEST_FILENAME"):" \
+        | grep -v '\${FLINK_AGENTS_SUT_BASH:-bash}' \
+        || true)"
+    if [[ -n "$wrong" ]]; then
+        printf 'spawn sites not using ${FLINK_AGENTS_SUT_BASH:-bash}:\n%s\n' 
"$wrong" >&2
+        false
+    fi
+}
diff --git a/tools/test/unit/ut_sh_flink_flag_scope.bats 
b/tools/test/unit/ut_sh_flink_flag_scope.bats
index fa437c2c6..67768bce0 100644
--- a/tools/test/unit/ut_sh_flink_flag_scope.bats
+++ b/tools/test/unit/ut_sh_flink_flag_scope.bats
@@ -73,12 +73,12 @@ EOF
 }
 
 @test "-f with the Java unit tests is rejected, on stderr" {
-    run bash "$UT_SH" -j -f 1.20
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$UT_SH" -j -f 1.20
     [ "$status" -eq 1 ]
     assert_rejected
     # Dropping stderr must drop the message with it: an error on stdout would
     # land in the middle of test output that gets parsed or piped.
-    run bash -c "bash '$UT_SH' -j -f 1.20 2>/dev/null"
+    run bash -c "'${FLINK_AGENTS_SUT_BASH:-bash}' '$UT_SH' -j -f 1.20 
2>/dev/null"
     [ "$status" -eq 1 ]
     assert_not_rejected
 }
@@ -87,7 +87,7 @@ EOF
     # The Python tests do install the version they are given, so this is the
     # combination a scope check written around the Java suite alone would let
     # through -- and outside -e it is just as inapplicable.
-    run bash "$UT_SH" -p -f 1.20
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$UT_SH" -p -f 1.20
     [ "$status" -eq 1 ]
     assert_rejected
 }
@@ -99,14 +99,14 @@ EOF
     # three-component value describes a precision the install does not have.
     local fake
     fake="$(make_fake_root_pinning 9.9.9 9.9)"
-    run bash "$fake/tools/ut.sh" -p -f 9.9
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$fake/tools/ut.sh" -p -f 9.9
     [ "$status" -eq 1 ]
     case "$(flowed_output)" in *"apache-flink~=9.9.0"*) ;; *) false ;; esac
     case "$(flowed_output)" in *"9.9.9"*) false ;; *) ;; esac
 }
 
 @test "-f is accepted with the e2e tests, and still selects the version they 
use" {
-    run bash "$UT_SH" -j -e -f 1.20
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$UT_SH" -j -e -f 1.20
     [ "$status" -eq 0 ]
     assert_not_rejected
     # Exit 0 alone would still hold if -f had become a no-op, so pin the two
@@ -114,7 +114,7 @@ EOF
     # profile the e2e run activates.
     case "$(shim_calls mvn)" in *"dist/flink-1.20"*) ;; *) false ;; esac
     case "$(shim_calls mvn)" in *"-Pflink-1.20"*) ;; *) false ;; esac
-    run bash "$UT_SH" -p -e -f 1.20
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$UT_SH" -p -e -f 1.20
     [ "$status" -eq 0 ]
     assert_not_rejected
     case "$(shim_calls uv)" in *"apache-flink~=1.20.0"*) ;; *) false ;; esac
@@ -123,20 +123,20 @@ EOF
 @test "-e is honored after -f, not only before it" {
     # The guard reads the parse loop's final state rather than the order the
     # flags arrive in; folding it into the -f case branch would break this.
-    run bash "$UT_SH" -p -f 1.20 -e
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$UT_SH" -p -f 1.20 -e
     [ "$status" -eq 0 ]
     assert_not_rejected
     case "$(shim_calls uv)" in *"apache-flink~=1.20.0"*) ;; *) false ;; esac
 }
 
 @test "a run that passes no -f is not rejected over the defaulted version" {
-    run bash "$UT_SH" -j
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$UT_SH" -j
     [ "$status" -eq 0 ]
     assert_not_rejected
 }
 
 @test "an unsupported version outside -e is reported as a scope error, not an 
unsupported one" {
-    run bash "$UT_SH" -p -f 9.9
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$UT_SH" -p -f 9.9
     [ "$status" -eq 1 ]
     assert_rejected
     # No value of -f applies here, so naming 9.9 as the problem would send the
@@ -149,7 +149,7 @@ EOF
     # neither shim may have been reached by the time the script exits. Driven
     # with no suite flag, which selects both, so a rejection reached from only
     # one of the two suite paths cannot pass this.
-    run bash "$UT_SH" -f 1.20
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$UT_SH" -f 1.20
     [ "$status" -eq 1 ]
     assert_rejected
     [ "$(shim_call_count mvn)" -eq 0 ]
@@ -159,10 +159,10 @@ EOF
 @test "the script demonstrates no -f form it would itself reject" {
     # Both places -f usage is advertised: the help text, and the error shown
     # when -f is given no version, which prints the help after it.
-    run bash "$UT_SH" --help
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$UT_SH" --help
     [ "$status" -eq 0 ]
     assert_no_rejectable_f_example
-    run bash -c "bash '$UT_SH' -f 2>&1"
+    run bash -c "'${FLINK_AGENTS_SUT_BASH:-bash}' '$UT_SH' -f 2>&1"
     [ "$status" -eq 1 ]
     assert_no_rejectable_f_example
 }
diff --git a/tools/test/unit/ut_sh_flink_version_supported.bats 
b/tools/test/unit/ut_sh_flink_version_supported.bats
index eba044f31..47f2cf571 100644
--- a/tools/test/unit/ut_sh_flink_version_supported.bats
+++ b/tools/test/unit/ut_sh_flink_version_supported.bats
@@ -33,7 +33,7 @@ dist_module_versions() {
 @test "a Flink version with no dist module is rejected before any test runs, 
on stderr" {
     shim_bin mvn
     shim_bin uv
-    run bash "$UT_SH" -e -f 9.9
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$UT_SH" -e -f 9.9
     [ "$status" -eq 1 ]
     case "$output" in *"Error: unsupported Flink version '9.9'"*) ;; *) false 
;; esac
     # The message has to carry the way out of the mistake, not just report it.
@@ -42,7 +42,7 @@ dist_module_versions() {
     # a typo costs no build.
     [ "$(shim_call_count mvn)" -eq 0 ]
     [ "$(shim_call_count uv)" -eq 0 ]
-    run bash -c "bash '$UT_SH' -e -f 9.9 2>/dev/null"
+    run bash -c "'${FLINK_AGENTS_SUT_BASH:-bash}' '$UT_SH' -e -f 9.9 
2>/dev/null"
     case "$output" in *"unsupported Flink version"*) false ;; *) ;; esac
 }
 
@@ -50,7 +50,7 @@ dist_module_versions() {
     shim_bin uv
     local version
     while read -r version; do
-        run bash "$UT_SH" -p -e -f "$version"
+        run "${FLINK_AGENTS_SUT_BASH:-bash}" "$UT_SH" -p -e -f "$version"
         [ "$status" -eq 0 ]
     done < <(dist_versions)
 }
@@ -58,7 +58,7 @@ dist_module_versions() {
 @test "--help lists the dist modules that exist rather than a literal" {
     local fake
     fake="$(make_fake_root_pinning 3.0.0 3.0 4.1)"
-    run bash "$fake/tools/ut.sh" --help
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$fake/tools/ut.sh" --help
     [ "$status" -eq 0 ]
     case "$output" in *"Supported versions: 3.0 4.1"*) ;; *) false ;; esac
 }
@@ -72,9 +72,9 @@ dist_module_versions() {
     shim_bin uv
     local fake
     fake="$(make_fake_root_pinning 3.0.0 3.0 4.1)"
-    run bash "$fake/tools/ut.sh" -p -e -f 4.1
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$fake/tools/ut.sh" -p -e -f 4.1
     [ "$status" -eq 0 ]
-    run bash "$fake/tools/ut.sh" -p -e -f 1.20
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$fake/tools/ut.sh" -p -e -f 1.20
     [ "$status" -eq 1 ]
     case "$output" in *"Error: unsupported Flink version '1.20'"*) ;; *) false 
;; esac
     case "$output" in *"supported versions: 3.0 4.1"*) ;; *) false ;; esac
@@ -89,7 +89,7 @@ dist_module_versions() {
     shim_bin uv
     local fake
     fake="$(make_fake_root_pinning 9.9.9 3.0 4.1)"
-    run bash "$fake/tools/ut.sh"
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$fake/tools/ut.sh"
     [ "$status" -eq 1 ]
     case "$output" in *"the root pom pins <flink.version> 9.9.9"*) ;; *) false 
;; esac
     case "$output" in *"carries no flink-9.9 module"*) ;; *) false ;; esac
@@ -106,7 +106,7 @@ dist_module_versions() {
     shim_bin uv
     local fake
     fake="$(make_fake_root_pinning 2.3.0)"
-    run bash "$fake/tools/ut.sh"
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$fake/tools/ut.sh"
     [ "$status" -eq 1 ]
     case "$output" in *"Error: found no dist/flink-* modules under"*) ;; *) 
false ;; esac
     case "$output" in *"carries no flink-2.3 module"*) false ;; *) ;; esac
diff --git a/tools/test/unit/ut_sh_version_default.bats 
b/tools/test/unit/ut_sh_version_default.bats
index 508549d54..e4f8737c5 100644
--- a/tools/test/unit/ut_sh_version_default.bats
+++ b/tools/test/unit/ut_sh_version_default.bats
@@ -16,14 +16,14 @@ repo_flink_minor() {
 }
 
 @test "--help does not claim every Flink version is tested by default" {
-    run bash "$UT_SH" --help
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$UT_SH" --help
     [ "$status" -eq 0 ]
     case "$output" in *"all versions"*) false ;; *) ;; esac
     case "$output" in *"all Flink versions"*) false ;; *) ;; esac
 }
 
 @test "--help states the default Flink version and which suites -f applies to" 
{
-    run bash "$UT_SH" --help
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$UT_SH" --help
     [ "$status" -eq 0 ]
     case "$output" in *"Default: $(repo_flink_minor)"*) ;; *) false ;; esac
     # The scope sentence wraps across help lines; compare on collapsed
@@ -56,7 +56,7 @@ repo_flink_minor() {
     # pinning a different version separates interpolation from a literal.
     local fake flowed
     fake="$(make_fake_root_pinning 9.9.9 9.9)"
-    run bash "$fake/tools/ut.sh" --help
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$fake/tools/ut.sh" --help
     [ "$status" -eq 0 ]
     flowed="$(printf '%s' "$output" | tr -s '[:space:]' ' ')"
     case "$flowed" in
@@ -67,7 +67,7 @@ repo_flink_minor() {
 
 @test "a bare Python run installs the Flink version the root pom pins" {
     shim_bin uv
-    run bash "$UT_SH" -p
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$UT_SH" -p
     [ "$status" -eq 0 ]
     case "$(shim_calls uv)" in
         *"apache-flink~=$(repo_flink_minor).0"*) ;;
@@ -81,7 +81,7 @@ repo_flink_minor() {
     # The fake tree carries the dist module its pom pins, so the defaulted
     # version is a supported one and only the value being read is under test.
     fake="$(make_fake_root '    <flink.version>9.9.9</flink.version>' 9.9)"
-    run bash "$fake/tools/ut.sh" -p
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$fake/tools/ut.sh" -p
     [ "$status" -eq 0 ]
     case "$(shim_calls uv)" in *"apache-flink~=9.9.0"*) ;; *) false ;; esac
 }
@@ -90,7 +90,7 @@ repo_flink_minor() {
     shim_bin uv
     local fake
     fake="$(make_fake_root '    <flink.version> 9.9.9 </flink.version>' 9.9)"
-    run bash "$fake/tools/ut.sh" -p
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$fake/tools/ut.sh" -p
     [ "$status" -eq 0 ]
     case "$(shim_calls uv)" in *"apache-flink~=9.9.0"*) ;; *) false ;; esac
 }
@@ -98,7 +98,7 @@ repo_flink_minor() {
 @test "a pom carrying no flink.version at all is fatal" {
     local fake
     fake="$(make_fake_root '    <other.version>1.0.0</other.version>')"
-    run bash "$fake/tools/ut.sh"
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$fake/tools/ut.sh"
     [ "$status" -eq 1 ]
     # Nothing was read, so the error must not quote a value as though one was.
     case "$output" in
@@ -111,7 +111,7 @@ repo_flink_minor() {
 @test "a two-component flink.version is fatal rather than a one-component 
token" {
     local fake
     fake="$(make_fake_root '    <flink.version>2.3</flink.version>')"
-    run bash "$fake/tools/ut.sh"
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$fake/tools/ut.sh"
     [ "$status" -eq 1 ]
     # The value was read; the error has to say so rather than claim it could 
not be.
     case "$output" in
@@ -124,7 +124,7 @@ repo_flink_minor() {
 @test "a flink.version holding a property reference is fatal rather than a 
broken token" {
     local fake
     fake="$(make_fake_root '    
<flink.version>${flink.2.3.version}</flink.version>')"
-    run bash "$fake/tools/ut.sh"
+    run "${FLINK_AGENTS_SUT_BASH:-bash}" "$fake/tools/ut.sh"
     [ "$status" -eq 1 ]
     # A value is present, so the error has to quote it rather than report an
     # absent element the way the missing-property case does.

Reply via email to