From 943e4b46945698d4373c30ef4e8695845adab83d Mon Sep 17 00:00:00 2001 From: helly25 <6420169+helly25@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:22:14 +0100 Subject: [PATCH 1/8] Support Windows Bash and manifest-only runfiles --- .bcr/presubmit.yml | 3 + .github/workflows/main.yml | 18 ++++- CHANGELOG.md | 6 ++ README.md | 23 ++++++- bashtest/BUILD.bazel | 2 + bashtest/README.md | 4 ++ bashtest/bashtest.bzl | 22 +++++- bashtest/bashtest.sh | 86 ++++++++++++++++-------- bashtest/launcher.sh | 45 +++++++++++++ bashtest/tests/BUILD.bazel | 12 ++++ bashtest/tests/external_consumer_test.sh | 59 ++++++++++++++++ bashtest/tests/files_test.sh | 69 +++++++++++++++++++ bashtest/tests/launcher_test.sh | 70 +++++++++++++++++++ tools/infrastructure_test.py | 2 +- 14 files changed, 386 insertions(+), 35 deletions(-) create mode 100755 bashtest/launcher.sh create mode 100755 bashtest/tests/external_consumer_test.sh create mode 100755 bashtest/tests/files_test.sh create mode 100755 bashtest/tests/launcher_test.sh diff --git a/.bcr/presubmit.yml b/.bcr/presubmit.yml index d2b9bdc..bf5775b 100644 --- a/.bcr/presubmit.yml +++ b/.bcr/presubmit.yml @@ -9,6 +9,7 @@ matrix: - debian12 - rockylinux8 - macos_arm64 + - windows tasks: verify_targets: name: Verify build targets @@ -16,3 +17,5 @@ tasks: bazel: ${{ bazel }} build_targets: - '@mboworks_bashtest//bashtest/...' + test_targets: + - '@mboworks_bashtest//bashtest/tests/...' diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 51f98fd..1bc810e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -53,11 +53,16 @@ jobs: needs: pre-commit strategy: matrix: - # If `Windows` support is removed - state so inthe README.md - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 + - name: Configure Windows Bash and Unix utilities + if: runner.os == 'Windows' + shell: pwsh + run: | + "BAZEL_SH=C:/msys64/usr/bin/bash.exe" >> $env:GITHUB_ENV + "C:/msys64/usr/bin" >> $env:GITHUB_PATH - uses: bazel-contrib/setup-bazel@0.19.0 with: cache-save: ${{ github.ref == 'refs/heads/main' }} @@ -67,8 +72,17 @@ jobs: # Share repository cache between workflows. repository-cache: true - name: Built & Test + shell: bash + env: + MSYS_NO_PATHCONV: "1" run: | bazel test //... + bazel test --enable_runfiles=no //... + - name: Test external module consumer + env: + MSYS_NO_PATHCONV: "1" + shell: bash + run: bash bashtest/tests/external_consumer_test.sh done: permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md index 833cd4a..797bcb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 0.6.2 +* Added Windows support with MSYS2 Bash using rules_shell runfiles resolution. +* Preserved the existing source API while supporting manifest-only runfiles and + renamed external module dependencies. +* Fixed golden-file diagnostics and updates for manifest paths and filenames + containing spaces or regular-expression characters. + - Fetch shallow publication snapshots and report complete site size with a 250 MB advisory budget. # 0.6.1 diff --git a/README.md b/README.md index c9bd167..bd36a08 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,28 @@ hand-rolled pipelines. ## Installation and requirements -This repository requires bash to work (Linux, MacOs). +This repository requires Bash and Unix utilities (`coreutils`, `diff`, `sed`, and +`grep`) on Linux, macOS, or Windows. Windows uses native Bazel with MSYS2 Bash; +it does not require WSL. Set `BAZEL_SH` to the Bash executable, for example +`C:/msys64/usr/bin/bash.exe`, and put `C:/msys64/usr/bin` on `PATH`. Install the +MSYS2 packages `bash coreutils diffutils grep sed`. PCRE assertions additionally +need a supported PCRE tool, such as a `grep` with `-P` support. + +Run Bazel from PowerShell or Command Prompt on Windows. When invoking it from +MSYS2 or Git Bash, set `MSYS_NO_PATHCONV=1` to preserve Bazel labels such as `//...`. +Keep shell scripts checked out with LF line endings. + +The `bashtest` macro resolves its framework and test script through rules_shell's +runfiles library, including manifest-only runfiles on Windows. Existing tests +continue to use `source "${mboworks_bashtest}"`. The launcher also makes `rlocation` +available for declared `data` dependencies: pass `$(rlocationpath :file)` through +an `env` entry, then resolve it with `rlocation` in the script. This also works +when consumers rename the module with `repo_name`. + +Golden updates use the test workspace's logical runfile paths. Pass an explicit +workspace root with `--update-golden` and use `rlocation` to find golden files; +manifest-only execution does not require a runfiles symlink tree. File comparison +remains byte-for-byte, so CRLF and LF golden files are intentionally different. ### MODULE.bazel diff --git a/bashtest/BUILD.bazel b/bashtest/BUILD.bazel index 30354bd..69a92d9 100644 --- a/bashtest/BUILD.bazel +++ b/bashtest/BUILD.bazel @@ -58,3 +58,5 @@ genrule( tags = ["manual"], visibility = ["//visibility:private"], ) + +exports_files(["launcher.sh"]) diff --git a/bashtest/README.md b/bashtest/README.md index 673183c..09fa539 100644 --- a/bashtest/README.md +++ b/bashtest/README.md @@ -22,3 +22,7 @@ Bashtest provides `sh_test` wrapper that simplifies the creation of shell tests. * control `skip_test` "\${REASON}": Marks the current test as skipped and reports the reason. Use `skip_test "reason" && return` to end the test body. * special test function `test::test_init`: If present, then this function runs first! Tests will only be executed if it succeeds. * special test function `test::test_done`: If present, then this function runs last! + +The macro supports directory and manifest-only runfiles on Linux, macOS, and +Windows with MSYS2 Bash. See the root [installation requirements](../README.md#installation-and-requirements) +for Windows setup and resolving data dependencies with `rlocation`. diff --git a/bashtest/bashtest.bzl b/bashtest/bashtest.bzl index a3c384b..56257e3 100644 --- a/bashtest/bashtest.bzl +++ b/bashtest/bashtest.bzl @@ -23,11 +23,15 @@ load("@rules_shell//shell:sh_test.bzl", "sh_test") # it through bzlmod. This lets the module work without an apparent # repository-name alias. _BASHTEST_SH = Label("//bashtest:bashtest_sh") +_LAUNCHER = Label("//bashtest:launcher.sh") +_RUNFILES = Label("@rules_shell//shell/runfiles") def bashtest( name, deps = [], env = {}, + srcs = [], + data = [], **kwargs): """Bashtest wrapper. @@ -66,17 +70,31 @@ def bashtest( Args: name: Name of the test rule. Should end in `_test` + srcs: Exactly one Bash test script. + data: Runtime files made available through Bazel runfiles. deps: Dependencies which will have bashtest_sh automatically added. env: Environment name that automatically adds `mboworks_bashtest`. **kwargs: All other attributes are passed through as is. """ + # Keep configurable srcs working; rlocationpath validates that the selected + # filegroup expands to exactly one file during analysis. + script = name + "__bashtest_script" + native.filegroup( + name = script, + srcs = srcs, + testonly = True, + visibility = ["//visibility:private"], + ) extra_env = { - "mboworks_bashtest": "$(rootpath {label})".format(label = _BASHTEST_SH), + "mboworks_bashtest": "$(rlocationpath {label})".format(label = _BASHTEST_SH), + "_BASHTEST_SCRIPT": "$(rlocationpath {label})".format(label = ":" + script), } sh_test( name = name, - deps = deps + [_BASHTEST_SH], + srcs = [_LAUNCHER], + deps = deps + [_BASHTEST_SH, _RUNFILES], + data = data + [":" + script, _BASHTEST_SH], env = env | extra_env, **kwargs ) diff --git a/bashtest/bashtest.sh b/bashtest/bashtest.sh index 24b55a6..d8046f6 100644 --- a/bashtest/bashtest.sh +++ b/bashtest/bashtest.sh @@ -275,7 +275,7 @@ declare -r BASHTEST_TMPDIR # Call as `root="$(test_tmpdir tree)"`; the helper has no caller-state side effects. test_tmpdir() { local name="${1:-tmp}" - if [[ "${name}" == */* ]] || [[ "${name}" == "." ]] || [[ "${name}" == ".." ]]; then + if [[ "${name}" == */* || "${name}" == *\\* || "${name}" == *:* ]] || [[ "${name}" == "." ]] || [[ "${name}" == ".." ]]; then die "Temporary-directory name must be a basename: '${name}'." fi mktemp -d "${BASHTEST_TMPDIR}/${name}.XXXXXX" @@ -432,31 +432,66 @@ function test_runner() { # test needs to be run with certain precaution and additional flags, so that it # can update the golden file. +# Convert Windows drive paths to one spelling before comparing prefixes. Unix +# paths remain unchanged, including literal backslashes in Unix filenames. +_bashtest_normalize_path() { + case "${OSTYPE}" in + msys*|cygwin*) cygpath -m -- "${1}" ;; + *) printf '%s\n' "${1}" ;; + esac +} + +_bashtest_relative_path() { + local path root entry logical physical + path="$(_bashtest_normalize_path "${1}")" + for root in "${RUNFILES_DIR:-}/${TEST_WORKSPACE:-}" \ + "${TEST_SRCDIR:-}/${TEST_WORKSPACE:-}" \ + "${_BASHTEST_UPDATE_GOLDEN:-}" "${BUILD_WORKSPACE_DIRECTORY:-}" \ + "${TEST_TMPDIR:-}" "${PWD}"; do + [[ -n "${root}" && "${root}" != / ]] || continue + root="$(_bashtest_normalize_path "${root}")" + if [[ "${path}" == "${root%/}/"* ]]; then + printf '%s\n' "${path#"${root%/}/"}" + return 0 + fi + done + if [[ -f "${RUNFILES_MANIFEST_FILE:-}" && -n "${TEST_WORKSPACE:-}" ]]; then + while IFS= read -r entry; do + if [[ "${entry}" == ' '* ]]; then + entry="${entry# }" + logical="${entry%% *}" + physical="${entry#* }" + logical="${logical//\\s/ }" + logical="${logical//\\n/$'\n'}" + logical="${logical//\\b/\\}" + physical="${physical//\\n/$'\n'}" + physical="${physical//\\b/\\}" + else + logical="${entry%% *}" + physical="${entry#* }" + fi + [[ "${logical}" == "${TEST_WORKSPACE}/"* ]] || continue + physical="$(_bashtest_normalize_path "${physical}")" + if [[ "${physical}" == "${path}" ]]; then + printf '%s\n' "${logical#"${TEST_WORKSPACE}/"}" + return 0 + fi + done < "${RUNFILES_MANIFEST_FILE}" + fi + printf '%s\n' "${path}" +} + expect_files_eq() { FILE_GOLDEN="${1}" FILE_RESULT="${2}" shift shift FILE_OUTPUT="${FILE_RESULT}.diff" - # Short filenames for output. - FILE_REL_GOLDEN="${FILE_GOLDEN}" - FILE_REL_RESULT="${FILE_RESULT}" - FILE_REL_OUTPUT="${FILE_OUTPUT}" - if [[ -n "${RUNFILES_DIR}${TEST_WORKSPACE}" ]]; then - FILE_REL_GOLDEN="${FILE_REL_GOLDEN##"${RUNFILES_DIR}/${TEST_WORKSPACE}/"}" - FILE_REL_RESULT="${FILE_REL_RESULT##"${RUNFILES_DIR}/${TEST_WORKSPACE}/"}" - FILE_REL_OUTPUT="${FILE_REL_OUTPUT##"${RUNFILES_DIR}/${TEST_WORKSPACE}/"}" - fi - if [[ -n "${TEST_SRCDIR}" ]]; then - FILE_REL_GOLDEN="${FILE_REL_GOLDEN##"${TEST_SRCDIR}/"}" - FILE_REL_RESULT="${FILE_REL_RESULT##"${TEST_SRCDIR}/"}" - FILE_REL_OUTPUT="${FILE_REL_OUTPUT##"${TEST_SRCDIR}/"}" - fi - if [[ -n "${TEST_TMPDIR}" ]]; then - FILE_REL_GOLDEN="${FILE_REL_GOLDEN##"${TEST_TMPDIR}/"}" - FILE_REL_RESULT="${FILE_REL_RESULT##"${TEST_TMPDIR}/"}" - FILE_REL_OUTPUT="${FILE_REL_OUTPUT##"${TEST_TMPDIR}/"}" - fi + # A manifest may resolve a golden directly into the source tree. Recover its + # workspace-relative name for diagnostics and explicit golden updates. + FILE_REL_GOLDEN="$(_bashtest_relative_path "${FILE_GOLDEN}")" + FILE_REL_RESULT="$(_bashtest_relative_path "${FILE_RESULT}")" + FILE_REL_OUTPUT="$(_bashtest_relative_path "${FILE_OUTPUT}")" if [[ ! -r "${FILE_GOLDEN}" ]]; then _BASHTEST_HAS_ERROR=1 @@ -474,15 +509,8 @@ expect_files_eq() { echo >&2 " Result: '${FILE_REL_RESULT}'" return 1 fi - FILE_DIFF_GOLDEN="${FILE_GOLDEN##"${PWD}/"}" - FILE_DIFF_RESULT="${FILE_RESULT##"${PWD}/"}" - if ! diff -u "${FILE_DIFF_GOLDEN}" "${FILE_DIFF_RESULT}" > "${FILE_OUTPUT}" 2>&1; then - cp "${FILE_OUTPUT}" "${FILE_OUTPUT}.tmp" - # shellcheck disable=SC2002 - cat "${FILE_OUTPUT}.tmp" \ - | sed -re "s,^[-][-][-] ${FILE_DIFF_GOLDEN}(.*)\$,--- a/${FILE_REL_GOLDEN}\\1,g" \ - | sed -re "s,^[+][+][+] ${FILE_DIFF_RESULT}(.*)\$,+++ b/${FILE_REL_GOLDEN}\\1,g" \ - > "${FILE_OUTPUT}" + if ! diff -u --label "a/${FILE_REL_GOLDEN}" --label "b/${FILE_REL_GOLDEN}" \ + "${FILE_GOLDEN}" "${FILE_RESULT}" > "${FILE_OUTPUT}" 2>&1; then if [[ -n "${_BASHTEST_UPDATE_GOLDEN}" ]]; then UPDATE_FILE="${_BASHTEST_UPDATE_GOLDEN}/${FILE_REL_GOLDEN}" if [[ ! -w "${UPDATE_FILE}" ]]; then diff --git a/bashtest/launcher.sh b/bashtest/launcher.sh new file mode 100755 index 0000000..5024ddc --- /dev/null +++ b/bashtest/launcher.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Resolve both the framework and caller script before invoking Bash. In particular, +# Windows normally supplies a manifest instead of a runfiles symlink directory. +# This is the v3 bootstrap from rules_shell's runfiles library. +set -uo pipefail +set +e +f=bazel_tools/tools/bash/runfiles/runfiles.bash +# shellcheck disable=SC1090,SC1091 +source "${RUNFILES_DIR:-/dev/null}/$f" 2>/dev/null || +source "$(grep -sm1 "^$f " "${RUNFILES_MANIFEST_FILE:-/dev/null}" | cut -f2- -d' ')" 2>/dev/null || +source "$0.runfiles/$f" 2>/dev/null || +source "$(grep -sm1 "^$f " "$0.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || +source "$(grep -sm1 "^$f " "$0.exe.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || +{ echo >&2 "ERROR: cannot find $f"; exit 1; } +set -e + +# The macro supplies canonical rlocation paths, independent of the consumer's +# apparent repository name. Keep the public variable a sourceable physical path. +# shellcheck disable=SC2154 +mboworks_bashtest="$(rlocation "${mboworks_bashtest}")" +# shellcheck disable=SC2154 +script="$(rlocation "${_BASHTEST_SCRIPT}")" +if [[ ! -r "${mboworks_bashtest}" || ! -r "${script}" ]]; then + echo >&2 "ERROR: cannot resolve bashtest runtime or test script" + exit 1 +fi +export mboworks_bashtest +runfiles_export_envvars +exec "${BASH}" "${script}" "$@" diff --git a/bashtest/tests/BUILD.bazel b/bashtest/tests/BUILD.bazel index fefb994..2c21ba7 100644 --- a/bashtest/tests/BUILD.bazel +++ b/bashtest/tests/BUILD.bazel @@ -42,3 +42,15 @@ bashtest( srcs = ["bashtest_no_skip_test.sh"], args = ["--no-skip"], ) + +bashtest( + name = "launcher_test", + srcs = ["launcher_test.sh"], + data = ["//bashtest:launcher.sh"], + env = {"LAUNCHER_RUNFILE": "$(rlocationpath //bashtest:launcher.sh)"}, +) + +bashtest( + name = "files_test", + srcs = ["files_test.sh"], +) diff --git a/bashtest/tests/external_consumer_test.sh b/bashtest/tests/external_consumer_test.sh new file mode 100755 index 0000000..603329e --- /dev/null +++ b/bashtest/tests/external_consumer_test.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Run outside a Bazel test: this creates and builds a separate consumer module. +set -euo pipefail +repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +root="$(mktemp -d)" +trap 'rm -rf "${root}"' EXIT +case "${OSTYPE}" in + msys*|cygwin*) repo="$(cygpath -m "${repo}")" ;; +esac +mkdir -p "${root}/consumer with spaces" +cd "${root}/consumer with spaces" +cp "${repo}/.bazelversion" .bazelversion +cat > MODULE.bazel < BUILD.bazel <<'EOF' +load("@framework//bashtest:bashtest.bzl", "bashtest") +bashtest( + name = "consumer_test", + srcs = select({"//conditions:default": ["consumer.sh"]}), + data = ["golden.txt"], + env = {"GOLDEN": "$(rlocationpath :golden.txt)", "MESSAGE": "hello world"}, +) +EOF +printf 'hello world\n' > golden.txt +cat > consumer.sh <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +# shellcheck disable=SC1090,SC1091,SC2154 +source "${mboworks_bashtest}" +# shellcheck disable=SC2317 +test::consumer() { + local result + result="$(test_tmpdir data)/result.txt" + printf '%s\n' "${MESSAGE}" > "${result}" + expect_files_eq "$(rlocation "${GOLDEN}")" "${result}" +} +test_runner +EOF +bazel test --enable_runfiles=no --test_output=errors //:consumer_test +bazel run --enable_runfiles=no //:consumer_test -- --keep-tmpdir=never diff --git a/bashtest/tests/files_test.sh b/bashtest/tests/files_test.sh new file mode 100755 index 0000000..6f0082a --- /dev/null +++ b/bashtest/tests/files_test.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euo pipefail +# shellcheck disable=SC1090,SC1091,SC2154 +source "${mboworks_bashtest}" + +# shellcheck disable=SC2317 # Functions are discovered by test_runner. +test::golden_paths_and_updates() { + local root golden result + root="$(test_tmpdir golden)" + mkdir -p "${root}/source/data" "${root}/output with spaces" + golden="${root}/source/data/golden [1].txt" + result="${root}/output with spaces/result.txt" + printf 'old\n' > "${golden}" + cp "${golden}" "${result}" + expect_files_eq "${golden}" "${result}" + printf 'new\n' > "${result}" + # Run expected failures in a subshell so the enclosing case stays successful. + if (expect_files_eq "${golden}" "${result}"); then + expect_eq failure success + fi + expect_output_contains '+new' "$(cat "${result}.diff")" + # A manifest-only lookup supplies the physical source path, while the + # update destination must retain the logical workspace-relative path. + mkdir -p "${root}/update/data" + cp "${golden}" "${root}/update/data/golden [1].txt" + printf ' _main/data/golden\\s[1].txt %s\n' "${golden}" > "${root}/MANIFEST" + ( + export RUNFILES_DIR= + export TEST_SRCDIR= + export TEST_TMPDIR= + export TEST_WORKSPACE=_main + export RUNFILES_MANIFEST_FILE="${root}/MANIFEST" + export BUILD_WORKSPACE_DIRECTORY= + _BASHTEST_UPDATE_GOLDEN="${root}/update" + # Avoid matching the inherited working directory before the manifest. + cd "${root}/output with spaces" + expect_files_eq "${golden}" "${result}" + ) + expect_eq new "$(cat "${root}/update/data/golden [1].txt")" + expect_eq old "$(cat "${golden}")" +} + +# shellcheck disable=SC2317 +test::reject_windows_path_names() { + local name status + for name in 'dir\child' 'C:child' 'C:\child'; do + status=0 + (test_tmpdir "${name}") >/dev/null 2>&1 || status="${?}" + expect_ne 0 "${status}" + done +} + +test_runner diff --git a/bashtest/tests/launcher_test.sh b/bashtest/tests/launcher_test.sh new file mode 100755 index 0000000..3234754 --- /dev/null +++ b/bashtest/tests/launcher_test.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euo pipefail +# shellcheck disable=SC1090,SC1091,SC2154 +source "${mboworks_bashtest}" + +# shellcheck disable=SC2317 # Functions are discovered by test_runner. +test::manifest_only_with_spaces() { + local root library launcher output status=0 + root="$(test_tmpdir manifest)" + mkdir -p "${root}/files with spaces" "${root}/scratch" + library="$(rlocation bazel_tools/tools/bash/runfiles/runfiles.bash)" + # shellcheck disable=SC2154 + launcher="$(rlocation "${LAUNCHER_RUNFILE}")" + cp "${mboworks_bashtest}" "${root}/files with spaces/runtime.sh" + cat > "${root}/files with spaces/probe.sh" <<'PROBE' +set -euo pipefail +# shellcheck disable=SC1090,SC1091,SC2154 +source "${mboworks_bashtest}" + test::probe() { + expect_eq 'value with spaces' "${PROBE_VALUE}" + [[ -d "$(test_tmpdir fixture)" ]] +} +test_runner +PROBE + # Logical keys need no escaping; physical paths may contain spaces. + printf '%s %s\n' \ + bazel_tools/tools/bash/runfiles/runfiles.bash "${library}" \ + framework/runtime.sh "${root}/files with spaces/runtime.sh" \ + consumer/probe.sh "${root}/files with spaces/probe.sh" > "${root}/MANIFEST" + output="$(env RUNFILES_DIR= RUNFILES_MANIFEST_FILE="${root}/MANIFEST" \ + mboworks_bashtest=framework/runtime.sh _BASHTEST_SCRIPT=consumer/probe.sh \ + TEST_TMPDIR="${root}/scratch" PROBE_VALUE='value with spaces' \ + "${BASH}" "${launcher}" --keep-tmpdir=always 2>&1)" || status="${?}" + expect_eq 0 "${status}" + expect_output_contains 'All tests passed.' "${output}" + expect_output_contains 'Preserved test scratch directory:' "${output}" + + status=0 + output="$(env RUNFILES_DIR= RUNFILES_MANIFEST_FILE="${root}/MANIFEST" \ + mboworks_bashtest=framework/runtime.sh _BASHTEST_SCRIPT=consumer/probe.sh \ + TEST_TMPDIR="${root}/scratch" PROBE_VALUE=wrong \ + "${BASH}" "${launcher}" --keep-tmpdir=never 2>&1)" || status="${?}" + expect_eq 1 "${status}" + expect_output_contains 'Some tests failed.' "${output}" + + status=0 + output="$(env RUNFILES_DIR= RUNFILES_MANIFEST_FILE="${root}/MANIFEST" \ + mboworks_bashtest=framework/runtime.sh _BASHTEST_SCRIPT=consumer/missing.sh \ + "${BASH}" "${launcher}" 2>&1)" || status="${?}" + expect_ne 0 "${status}" + expect_output_contains 'cannot resolve bashtest runtime or test script' "${output}" +} + +test_runner diff --git a/tools/infrastructure_test.py b/tools/infrastructure_test.py index cfcb4be..927cbe0 100644 --- a/tools/infrastructure_test.py +++ b/tools/infrastructure_test.py @@ -15,7 +15,7 @@ def test_prs_restore_but_only_main_writes_caches(self): self.assertIn('repository-cache: true', workflow) self.assertIn('disk-cache: ${{ github.workflow }}', workflow) self.assertIn('needs: [release-site-tests, pre-commit, test]', workflow) - self.assertIn('os: [ubuntu-latest, macos-latest]', workflow) + self.assertIn('os: [ubuntu-latest, macos-latest, windows-latest]', workflow) def test_release_uploads_before_immutable_publication(self): workflow = (Path(__file__).parents[1] / '.github/workflows/release.yml').read_text() From 5323264b6cbe72ce0f37b2245400a2aa3e457900 Mon Sep 17 00:00:00 2001 From: helly25 <6420169+helly25@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:24:54 +0100 Subject: [PATCH 2/8] Remove invalid Bazel ignore glob on Windows --- .bazelignore | 1 - .github/workflows/main.yml | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 .bazelignore diff --git a/.bazelignore b/.bazelignore deleted file mode 100644 index ac51a05..0000000 --- a/.bazelignore +++ /dev/null @@ -1 +0,0 @@ -bazel-* diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1bc810e..776f6b4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -52,6 +52,7 @@ jobs: test: needs: pre-commit strategy: + fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} From de63bb4565f78efba064c7b4bfb440e0b635a4c3 Mon Sep 17 00:00:00 2001 From: helly25 <6420169+helly25@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:31:18 +0100 Subject: [PATCH 3/8] Install Windows diffutils and reject diff tool errors --- .github/workflows/main.yml | 2 ++ bashtest/bashtest.sh | 12 ++++++++++-- bashtest/tests/files_test.sh | 16 ++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 776f6b4..ee2b118 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -62,6 +62,8 @@ jobs: if: runner.os == 'Windows' shell: pwsh run: | + & C:/msys64/usr/bin/bash.exe -lc 'pacman -S --needed --noconfirm bash coreutils diffutils grep sed' + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } "BAZEL_SH=C:/msys64/usr/bin/bash.exe" >> $env:GITHUB_ENV "C:/msys64/usr/bin" >> $env:GITHUB_PATH - uses: bazel-contrib/setup-bazel@0.19.0 diff --git a/bashtest/bashtest.sh b/bashtest/bashtest.sh index d8046f6..40b38fd 100644 --- a/bashtest/bashtest.sh +++ b/bashtest/bashtest.sh @@ -509,8 +509,16 @@ expect_files_eq() { echo >&2 " Result: '${FILE_REL_RESULT}'" return 1 fi - if ! diff -u --label "a/${FILE_REL_GOLDEN}" --label "b/${FILE_REL_GOLDEN}" \ - "${FILE_GOLDEN}" "${FILE_RESULT}" > "${FILE_OUTPUT}" 2>&1; then + local diff_status=0 + diff -u --label "a/${FILE_REL_GOLDEN}" --label "b/${FILE_REL_GOLDEN}" \ + "${FILE_GOLDEN}" "${FILE_RESULT}" > "${FILE_OUTPUT}" 2>&1 || diff_status="${?}" + if [[ "${diff_status}" -gt 1 ]]; then + _BASHTEST_HAS_ERROR=1 + echo >&2 "ERROR: diff failed (status ${diff_status}); golden file was not updated." + cat >&2 "${FILE_OUTPUT}" + return 1 + fi + if [[ "${diff_status}" == 1 ]]; then if [[ -n "${_BASHTEST_UPDATE_GOLDEN}" ]]; then UPDATE_FILE="${_BASHTEST_UPDATE_GOLDEN}/${FILE_REL_GOLDEN}" if [[ ! -w "${UPDATE_FILE}" ]]; then diff --git a/bashtest/tests/files_test.sh b/bashtest/tests/files_test.sh index 6f0082a..d683ffe 100755 --- a/bashtest/tests/files_test.sh +++ b/bashtest/tests/files_test.sh @@ -66,4 +66,20 @@ test::reject_windows_path_names() { done } +# shellcheck disable=SC2317 +test::diff_error_does_not_update_golden() { + local root status=0 + root="$(test_tmpdir diff-error)" + printf 'old\n' > "${root}/golden.txt" + printf 'new\n' > "${root}/result.txt" + ( + # Simulate a tool failure, distinct from diff's content-mismatch status. + diff() { echo >&2 'simulated diff error'; return 2; } + _BASHTEST_UPDATE_GOLDEN="${root}" + expect_files_eq "${root}/golden.txt" "${root}/result.txt" + ) || status="${?}" + expect_eq 1 "${status}" + expect_eq old "$(cat "${root}/golden.txt")" +} + test_runner From 5de00f6ccf29d64c9efe6d8a844727f9397c31f5 Mon Sep 17 00:00:00 2001 From: helly25 <6420169+helly25@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:31:46 +0100 Subject: [PATCH 4/8] Explain indirect diff invocation to ShellCheck --- bashtest/tests/files_test.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/bashtest/tests/files_test.sh b/bashtest/tests/files_test.sh index d683ffe..d14f9f4 100755 --- a/bashtest/tests/files_test.sh +++ b/bashtest/tests/files_test.sh @@ -74,6 +74,7 @@ test::diff_error_does_not_update_golden() { printf 'new\n' > "${root}/result.txt" ( # Simulate a tool failure, distinct from diff's content-mismatch status. + # shellcheck disable=SC2329 # Called indirectly by expect_files_eq. diff() { echo >&2 'simulated diff error'; return 2; } _BASHTEST_UPDATE_GOLDEN="${root}" expect_files_eq "${root}/golden.txt" "${root}/result.txt" From 7ab2142386ae6d9265b1ac505a1b95a5b340157f Mon Sep 17 00:00:00 2001 From: helly25 <6420169+helly25@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:35:05 +0100 Subject: [PATCH 5/8] Make consumer cleanup and golden update arguments portable --- CHANGELOG.md | 3 ++- bashtest/bashtest.sh | 2 +- bashtest/tests/external_consumer_test.sh | 16 +++++++++++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 797bcb4..ef29489 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ * Preserved the existing source API while supporting manifest-only runfiles and renamed external module dependencies. * Fixed golden-file diagnostics and updates for manifest paths and filenames - containing spaces or regular-expression characters. + containing spaces or regular-expression characters. Tool errors do not update + golden files, and short options preserve spaces in their values. - Fetch shallow publication snapshots and report complete site size with a 250 MB advisory budget. diff --git a/bashtest/bashtest.sh b/bashtest/bashtest.sh index 40b38fd..2d21a5c 100644 --- a/bashtest/bashtest.sh +++ b/bashtest/bashtest.sh @@ -210,7 +210,7 @@ while getopts -- '-:f:hu:v' OPTION; do fi else OPTARG="${OPTARG:-}" - OPTARG="${OPTARG//[= ]}" + OPTARG="${OPTARG#=}" OPTERR="-${OPTION}" fi case "${OPTION}" in diff --git a/bashtest/tests/external_consumer_test.sh b/bashtest/tests/external_consumer_test.sh index 603329e..7e9cc63 100755 --- a/bashtest/tests/external_consumer_test.sh +++ b/bashtest/tests/external_consumer_test.sh @@ -19,7 +19,13 @@ set -euo pipefail repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" root="$(mktemp -d)" -trap 'rm -rf "${root}"' EXIT +cleanup() { + # Windows cannot remove a directory while it is a process working directory. + bazel shutdown >/dev/null 2>&1 || true + cd "${repo}" + rm -rf "${root}" +} +trap cleanup EXIT case "${OSTYPE}" in msys*|cygwin*) repo="$(cygpath -m "${repo}")" ;; esac @@ -57,3 +63,11 @@ test_runner EOF bazel test --enable_runfiles=no --test_output=errors //:consumer_test bazel run --enable_runfiles=no //:consumer_test -- --keep-tmpdir=never + +# Exercise both documented update spellings with a workspace path containing spaces. +printf 'stale\n' > golden.txt +bazel run --enable_runfiles=no //:consumer_test -- --update-golden="${PWD}" --keep-tmpdir=never +[[ "$(cat golden.txt)" == 'hello world' ]] +printf 'stale again\n' > golden.txt +bazel run --enable_runfiles=no //:consumer_test -- -u="${PWD}" --keep-tmpdir=never +[[ "$(cat golden.txt)" == 'hello world' ]] From 7fb3571aba33c39bf6eda5d84e3a0e878440ad68 Mon Sep 17 00:00:00 2001 From: helly25 <6420169+helly25@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:53:47 +0100 Subject: [PATCH 6/8] Add named executable tools to bashtest --- .github/workflows/main.yml | 4 +- CHANGELOG.md | 3 + README.md | 5 ++ bashtest/BUILD.bazel | 5 +- bashtest/README.md | 26 ++++++ bashtest/bashtest.bzl | 28 ++++++- bashtest/launcher.sh | 17 +++- bashtest/tests/BUILD.bazel | 27 +++++++ bashtest/tests/tool_fixture.bzl | 43 ++++++++++ bashtest/tests/tools_consumer_test.sh | 110 ++++++++++++++++++++++++++ bashtest/tests/tools_test.sh | 35 ++++++++ bashtest/tools.bzl | 98 +++++++++++++++++++++++ 12 files changed, 394 insertions(+), 7 deletions(-) create mode 100644 bashtest/tests/tool_fixture.bzl create mode 100755 bashtest/tests/tools_consumer_test.sh create mode 100755 bashtest/tests/tools_test.sh create mode 100644 bashtest/tools.bzl diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ee2b118..f4b8539 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -85,7 +85,9 @@ jobs: env: MSYS_NO_PATHCONV: "1" shell: bash - run: bash bashtest/tests/external_consumer_test.sh + run: | + bash bashtest/tests/external_consumer_test.sh + bash bashtest/tests/tools_consumer_test.sh done: permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md index ef29489..73002ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # 0.6.2 +* Added named `tools` mappings that build executables for the test execution + platform and export their resolved paths with their runtime runfiles. + * Added Windows support with MSYS2 Bash using rules_shell runfiles resolution. * Preserved the existing source API while supporting manifest-only runfiles and renamed external module dependencies. diff --git a/README.md b/README.md index bd36a08..9b13995 100644 --- a/README.md +++ b/README.md @@ -118,3 +118,8 @@ Check [Releases](https://github.com/mboworks/bashtest/releases) for details. All ``` bazel_dep(name = "mboworks_bashtest", version = "0.6.1") ``` + +Executable helpers can be declared with `tools = {"GENERATOR": "//tools:generator"}`. +Bashtest builds them for the test execution platform, includes their runfiles, +and exports their resolved paths as environment variables. Invoke them as +`"${GENERATOR}"`; see [executable tools](bashtest/README.md#executable-tools). diff --git a/bashtest/BUILD.bazel b/bashtest/BUILD.bazel index 69a92d9..320b26d 100644 --- a/bashtest/BUILD.bazel +++ b/bashtest/BUILD.bazel @@ -20,7 +20,10 @@ package(default_visibility = ["//visibility:private"]) bzl_library( name = "bashtest_bzl", - srcs = ["bashtest.bzl"], + srcs = [ + "bashtest.bzl", + "tools.bzl", + ], ) sh_library( diff --git a/bashtest/README.md b/bashtest/README.md index 09fa539..4789717 100644 --- a/bashtest/README.md +++ b/bashtest/README.md @@ -26,3 +26,29 @@ Bashtest provides `sh_test` wrapper that simplifies the creation of shell tests. The macro supports directory and manifest-only runfiles on Linux, macOS, and Windows with MSYS2 Bash. See the root [installation requirements](../README.md#installation-and-requirements) for Windows setup and resolving data dependencies with `rlocation`. + +## Executable tools + +Use `tools` to build helper executables for the test execution platform and export +resolved paths automatically: + +```starlark +bashtest( + name = "generator_test", + srcs = ["generator_test.sh"], + tools = {"GENERATOR": "//tools:generator"}, +) +``` + +The script can invoke `"${GENERATOR}" --output "${TEST_TMPDIR}/result"`. +Tools must be executable Bazel targets; their runtime runfiles are included. +This uses the test execution group, including `exec_group_compatible_with` on Bazel 8 and newer, so +cross-compilation builds helpers for the platform running the test. `data` +continues to use the target configuration. Do not put the same target in both +`data` and `tools`. Multiple names may refer to the same tool. + +Keys must be shell environment variable names and must not conflict with `env` +or `env_inherit`. Names beginning with `_BASHTEST`, `TEST_`, `RUNFILES_`, or `BASH`, +and `mboworks_bashtest`, `PATH`, `IFS`, `SHELLOPTS`, `ENV`, `EUID`, `UID`, and +`PPID` are reserved. Quote exported paths when invoking tools, including on +Windows. No `$(location ...)` expansion is needed for tool paths. diff --git a/bashtest/bashtest.bzl b/bashtest/bashtest.bzl index 56257e3..ae12228 100644 --- a/bashtest/bashtest.bzl +++ b/bashtest/bashtest.bzl @@ -16,6 +16,7 @@ """mboworks_bashtest/bashtest:bashtest""" load("@rules_shell//shell:sh_test.bzl", "sh_test") +load(":tools.bzl", "bashtest_tools_test") # Resolve the bashtest runtime relative to *this* module's repository. A `Label` # constructed in a `.bzl` file is resolved against the repo that defines the @@ -32,6 +33,7 @@ def bashtest( env = {}, srcs = [], data = [], + tools = {}, **kwargs): """Bashtest wrapper. @@ -74,6 +76,7 @@ def bashtest( data: Runtime files made available through Bazel runfiles. deps: Dependencies which will have bashtest_sh automatically added. env: Environment name that automatically adds `mboworks_bashtest`. + tools: Environment variable names mapped to executable targets built for test execution. **kwargs: All other attributes are passed through as is. """ @@ -90,11 +93,32 @@ def bashtest( "mboworks_bashtest": "$(rlocationpath {label})".format(label = _BASHTEST_SH), "_BASHTEST_SCRIPT": "$(rlocationpath {label})".format(label = ":" + script), } + runner_name = name + "__bashtest_runner" if tools else name + runner_kwargs = dict(kwargs) + if tools: + runner_kwargs["tags"] = kwargs.get("tags", []) + ["manual"] + runner_kwargs["visibility"] = ["//visibility:private"] sh_test( - name = name, + name = runner_name, srcs = [_LAUNCHER], deps = deps + [_BASHTEST_SH, _RUNFILES], data = data + [":" + script, _BASHTEST_SH], env = env | extra_env, - **kwargs + **runner_kwargs ) + if tools: + outer_kwargs = dict(kwargs) + + # These are sh_test attributes, rather than common test attributes. + outer_kwargs.pop("use_bash_launcher", None) + tool_names = {} + for variable, label in tools.items(): + tool_names.setdefault(label, []).append(variable) + bashtest_tools_test( + name = name, + runner = ":" + runner_name, + env = env | extra_env, + tools = {label: json.encode(names) for label, names in tool_names.items()}, + data = data + [":" + script, _BASHTEST_SH], + **outer_kwargs + ) diff --git a/bashtest/launcher.sh b/bashtest/launcher.sh index 5024ddc..e458725 100755 --- a/bashtest/launcher.sh +++ b/bashtest/launcher.sh @@ -35,11 +35,22 @@ set -e # shellcheck disable=SC2154 mboworks_bashtest="$(rlocation "${mboworks_bashtest}")" # shellcheck disable=SC2154 -script="$(rlocation "${_BASHTEST_SCRIPT}")" -if [[ ! -r "${mboworks_bashtest}" || ! -r "${script}" ]]; then +_BASHTEST_ENTRYPOINT="$(rlocation "${_BASHTEST_SCRIPT}")" +if [[ ! -r "${mboworks_bashtest}" || ! -r "${_BASHTEST_ENTRYPOINT}" ]]; then echo >&2 "ERROR: cannot resolve bashtest runtime or test script" exit 1 fi export mboworks_bashtest runfiles_export_envvars -exec "${BASH}" "${script}" "$@" +if [[ -n "${_BASHTEST_TOOLS_MANIFEST:-}" ]]; then + _BASHTEST_TOOLS_FILE="$(rlocation "${_BASHTEST_TOOLS_MANIFEST}")" + while IFS=$'\t' read -r _BASHTEST_TOOL_NAME _BASHTEST_TOOL_RUNFILE; do + _BASHTEST_TOOL_PATH="$(rlocation "${_BASHTEST_TOOL_RUNFILE}")" + if [[ ! -f "${_BASHTEST_TOOL_PATH}" ]]; then + echo >&2 "ERROR: cannot resolve tool ${_BASHTEST_TOOL_NAME}: ${_BASHTEST_TOOL_RUNFILE}" + exit 1 + fi + export "${_BASHTEST_TOOL_NAME}=${_BASHTEST_TOOL_PATH}" + done < "${_BASHTEST_TOOLS_FILE}" +fi +exec "${BASH}" "${_BASHTEST_ENTRYPOINT}" "$@" diff --git a/bashtest/tests/BUILD.bazel b/bashtest/tests/BUILD.bazel index 2c21ba7..d175ab6 100644 --- a/bashtest/tests/BUILD.bazel +++ b/bashtest/tests/BUILD.bazel @@ -13,7 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +load("@rules_shell//shell:sh_binary.bzl", "sh_binary") load("//bashtest:bashtest.bzl", "bashtest") +load(":tool_fixture.bzl", "configuration_data", "tool_source") package(default_visibility = ["//visibility:private"]) @@ -54,3 +56,28 @@ bashtest( name = "files_test", srcs = ["files_test.sh"], ) + +configuration_data(name = "tool_data") + +configuration_data(name = "target_data") + +tool_source( + name = "helper_source", + data = ":tool_data", +) + +sh_binary( + name = "helper", + srcs = [":helper_source"], +) + +bashtest( + name = "tools_test", + srcs = ["tools_test.sh"], + data = [":target_data"], + env = {"TARGET_DATA": "$(rlocationpath :target_data)"}, + tools = { + "HELPER": ":helper", + "HELPER_ALIAS": ":helper", + }, +) diff --git a/bashtest/tests/tool_fixture.bzl b/bashtest/tests/tool_fixture.bzl new file mode 100644 index 0000000..a6899dd --- /dev/null +++ b/bashtest/tests/tool_fixture.bzl @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Configuration-sensitive fixtures for executable helper tests.""" + +def _path(ctx, file): + return file.short_path[3:] if file.short_path.startswith("../") else ctx.workspace_name + "/" + file.short_path + +def _configuration_data_impl(ctx): + output = ctx.actions.declare_file(ctx.label.name + ".txt") + ctx.actions.write(output, ctx.attr.platform_marker + "\n") + return [DefaultInfo(files = depset([output]))] + +configuration_data = rule( + implementation = _configuration_data_impl, + attrs = {"platform_marker": attr.string(default = "default")}, +) + +def _tool_source_impl(ctx): + output = ctx.actions.declare_file(ctx.label.name + ".sh") + ctx.actions.write( + output, + "#!/usr/bin/env bash\nset -euo pipefail\ncat \"$(rlocation '" + _path(ctx, ctx.file.data) + "')\"\nprintf '%s\\n' \"$@\"\n", + is_executable = True, + ) + return [DefaultInfo(files = depset([output]), runfiles = ctx.runfiles(files = [ctx.file.data]))] + +tool_source = rule( + implementation = _tool_source_impl, + attrs = {"data": attr.label(allow_single_file = True)}, +) diff --git a/bashtest/tests/tools_consumer_test.sh b/bashtest/tests/tools_consumer_test.sh new file mode 100755 index 0000000..04b03d0 --- /dev/null +++ b/bashtest/tests/tools_consumer_test.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Integration test: distinct default and test execution platforms, external module. +set -euo pipefail +repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +root="$(mktemp -d)" +cleanup() { + bazel shutdown >/dev/null 2>&1 || true + cd "${repo}" + rm -rf "${root}" +} +trap cleanup EXIT +case "${OSTYPE}" in + msys*|cygwin*) repo="$(cygpath -m "${repo}")" ;; +esac +mkdir -p "${root}/tools consumer" +cd "${root}/tools consumer" +cp "${repo}/.bazelversion" .bazelversion +cp "${repo}/bashtest/tests/tool_fixture.bzl" tool_fixture.bzl +cat > MODULE.bazel < BUILD.bazel <<'EOF' +load("@framework//bashtest:bashtest.bzl", "bashtest") +load("@rules_shell//shell:sh_binary.bzl", "sh_binary") +load(":tool_fixture.bzl", "configuration_data", "tool_source") +constraint_setting(name = "role") +constraint_value(name = "ordinary_role", constraint_setting = ":role") +constraint_value(name = "test_role", constraint_setting = ":role") +platform(name = "ordinary_exec", parents = ["@platforms//host"], constraint_values = [":ordinary_role"]) +platform(name = "test_exec", parents = ["@platforms//host"], constraint_values = [":test_role"]) +config_setting(name = "is_test_exec", constraint_values = [":test_role"]) +configuration_data( + name = "tool_data", + platform_marker = select({":is_test_exec": "test-platform", "//conditions:default": "other-platform"}), +) +configuration_data(name = "target_data") +tool_source(name = "helper_source", data = ":tool_data") +sh_binary(name = "helper", srcs = [":helper_source"]) +bashtest( + name = "consumer_test", + srcs = ["consumer.sh"], + data = [":target_data"], + env = {"TARGET_DATA": "$(rlocationpath :target_data)"}, + tools = {"HELPER": ":helper"}, + exec_group_compatible_with = {"test": [":test_role"]}, +) +bashtest( + name = "invalid_tool_test", + srcs = ["consumer.sh"], + tools = {"HELPER": ":target_data"}, + tags = ["manual"], +) +bashtest( + name = "collision_test", + srcs = ["consumer.sh"], + tools = {"HELPER": ":helper"}, + env = {"HELPER": "collision"}, + tags = ["manual"], +) +EOF +# Bazel 7 predates per-group constraints; still verify execution-platform tools. +if [[ "$(bazel --version)" == 'bazel 7.'* ]]; then + sed 's/exec_group_compatible_with = {"test": \[":test_role"\]}/exec_compatible_with = [":test_role"]/' BUILD.bazel > BUILD.tmp + mv BUILD.tmp BUILD.bazel +fi +cat > consumer.sh <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +# shellcheck disable=SC1090,SC1091,SC2154 +source "${mboworks_bashtest}" +# shellcheck disable=SC2317 +test::configuration() { + expect_eq $'test-platform\nargument with spaces' "$("${HELPER}" 'argument with spaces')" + expect_eq 'default' "$(cat "$(rlocation "${TARGET_DATA}")")" +} +test_runner +EOF +flags=("--extra_execution_platforms=//:ordinary_exec,//:test_exec" --enable_runfiles=no) +bazel test "${flags[@]}" --test_output=errors //:consumer_test +bazel run "${flags[@]}" //:consumer_test -- --keep-tmpdir=never +if bazel build "${flags[@]}" //:invalid_tool_test >invalid.log 2>&1; then + echo >&2 'ERROR: non-executable tool was accepted' + exit 1 +fi +grep -F 'tools requires an executable target' invalid.log +if bazel build "${flags[@]}" //:collision_test >collision.log 2>&1; then + echo >&2 'ERROR: conflicting environment variable was accepted' + exit 1 +fi +grep -F 'tool environment variable is already defined' collision.log diff --git a/bashtest/tests/tools_test.sh b/bashtest/tests/tools_test.sh new file mode 100755 index 0000000..92705de --- /dev/null +++ b/bashtest/tests/tools_test.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euo pipefail +# shellcheck disable=SC1090,SC1091,SC2154 +source "${mboworks_bashtest}" + +# shellcheck disable=SC2317 # Functions are discovered by test_runner. +test::executable_and_runfiles() { + # shellcheck disable=SC2154 + expect_eq $'default\nargument with spaces' "$("${HELPER}" 'argument with spaces')" + # shellcheck disable=SC2154 + expect_eq 'default' "$(cat "$(rlocation "${TARGET_DATA}")")" + # shellcheck disable=SC2154 + expect_eq "${HELPER}" "${HELPER_ALIAS}" + case "${OSTYPE}" in + msys*|cygwin*) expect_matches '[.]exe$' "${HELPER}" ;; + esac +} + +test_runner diff --git a/bashtest/tools.bzl b/bashtest/tools.bzl new file mode 100644 index 0000000..2389234 --- /dev/null +++ b/bashtest/tools.bzl @@ -0,0 +1,98 @@ +# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Bind helper executables to the execution platform of the actual test action.""" + +def _rlocation_path(ctx, file): + if file.short_path.startswith("../"): + return file.short_path[3:] + return ctx.workspace_name + "/" + file.short_path + +def _validate_name(name): + letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_" + if not name or name[0] not in letters or any([c not in letters + "0123456789" for c in name.elems()]): + fail("tools keys must be shell environment variable names: " + repr(name)) + if name.startswith(("_BASHTEST", "TEST_", "RUNFILES_", "BASH")) or name in ["mboworks_bashtest", "PATH", "IFS", "SHELLOPTS", "ENV", "EUID", "UID", "PPID"]: + fail("tools key is reserved by the shell or test runtime: " + name) + +def _tools_test_impl(ctx): + runner = ctx.attr.runner[DefaultInfo] + + # Bazel 7 uses native sh_test, which does not expose RunEnvironmentInfo. + environment = ctx.attr.runner[RunEnvironmentInfo] if RunEnvironmentInfo in ctx.attr.runner else RunEnvironmentInfo( + environment = { + key: ctx.expand_make_variables("env", ctx.expand_location(value, ctx.attr.data), {}) + for key, value in ctx.attr.env.items() + }, + inherited_environment = ctx.attr.env_inherit, + ) + entries = [] + tool_files = [] + tool_runfiles = [] + names = [] + for tool, encoded_names in ctx.attr.tools.items(): + if tool.label in [target.label for target in ctx.attr.data]: + fail("a target cannot be both data and tools: " + str(tool.label)) + info = tool[DefaultInfo] + executable = info.files_to_run.executable + if executable == None: + fail("tools requires an executable target: " + str(tool.label)) + for name in json.decode(encoded_names): + _validate_name(name) + if name in names or name in environment.environment or name in environment.inherited_environment: + fail("tool environment variable is already defined: " + name) + names.append(name) + entries.append(name + "\t" + _rlocation_path(ctx, executable) + "\n") + tool_files.append(executable) + tool_runfiles.extend([info.default_runfiles, info.data_runfiles]) + manifest = ctx.actions.declare_file(ctx.label.name + ".tools") + ctx.actions.write(manifest, "".join(entries)) + + # Reuse rules_shell's platform-specific launcher, including its Windows exe. + extension = ".exe" if runner.files_to_run.executable.extension == "exe" else "" + executable = ctx.actions.declare_file(ctx.label.name + extension) + ctx.actions.symlink(output = executable, target_file = runner.files_to_run.executable, is_executable = True) + runfiles = ctx.runfiles(files = tool_files + [manifest]).merge(runner.default_runfiles).merge_all(tool_runfiles) + return [ + DefaultInfo(executable = executable, runfiles = runfiles), + RunEnvironmentInfo( + environment = environment.environment | {"_BASHTEST_TOOLS_MANIFEST": _rlocation_path(ctx, manifest)}, + inherited_environment = environment.inherited_environment, + ), + coverage_common.instrumented_files_info(ctx, dependency_attributes = ["runner"]), + ] + +bashtest_tools_test = rule( + implementation = _tools_test_impl, + test = True, + attrs = { + "data": attr.label_list(allow_files = True), + "env": attr.string_dict(), + "env_inherit": attr.string_list(), + "runner": attr.label(mandatory = True, executable = True, cfg = "target"), + "tools": attr.label_keyed_string_dict(cfg = config.exec("test")), + "_collect_cc_coverage": attr.label( + cfg = config.exec("test"), + default = "@bazel_tools//tools/test:collect_cc_coverage", + executable = True, + ), + "_lcov_merger": attr.label( + cfg = config.exec("test"), + default = configuration_field(fragment = "coverage", name = "output_generator"), + executable = True, + ), + }, + fragments = ["coverage"], +) From e6a149318189e49b5ca86323b1a1ddb608cd9f87 Mon Sep 17 00:00:00 2001 From: helly25 <6420169+helly25@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:19:47 +0100 Subject: [PATCH 7/8] Use a non-test launcher for tests with executable tools --- bashtest/bashtest.bzl | 56 +++++++++++++++++++++--------------- bashtest/tests/BUILD.bazel | 21 +++++++++++++- bashtest/tests/tools_test.sh | 6 ++++ bashtest/tools.bzl | 6 ++-- 4 files changed, 62 insertions(+), 27 deletions(-) diff --git a/bashtest/bashtest.bzl b/bashtest/bashtest.bzl index ae12228..ae0bc0a 100644 --- a/bashtest/bashtest.bzl +++ b/bashtest/bashtest.bzl @@ -15,6 +15,7 @@ """mboworks_bashtest/bashtest:bashtest""" +load("@rules_shell//shell:sh_binary.bzl", "sh_binary") load("@rules_shell//shell:sh_test.bzl", "sh_test") load(":tools.bzl", "bashtest_tools_test") @@ -93,32 +94,41 @@ def bashtest( "mboworks_bashtest": "$(rlocationpath {label})".format(label = _BASHTEST_SH), "_BASHTEST_SCRIPT": "$(rlocationpath {label})".format(label = ":" + script), } - runner_name = name + "__bashtest_runner" if tools else name - runner_kwargs = dict(kwargs) - if tools: - runner_kwargs["tags"] = kwargs.get("tags", []) + ["manual"] - runner_kwargs["visibility"] = ["//visibility:private"] - sh_test( + if not tools: + sh_test( + name = name, + srcs = [_LAUNCHER], + deps = deps + [_BASHTEST_SH, _RUNFILES], + data = data + [":" + script, _BASHTEST_SH], + env = env | extra_env, + **kwargs + ) + return + + # Define an executable launcher, not another test. The single test below + # owns both this executable and every execution-platform tool dependency. + runner_name = name + "__bashtest_launcher" + launcher_kwargs = dict(kwargs) + for attribute in ["args", "env_inherit", "size", "timeout", "flaky", "shard_count", "local", "exec_group_compatible_with"]: + launcher_kwargs.pop(attribute, None) + launcher_kwargs["testonly"] = True + sh_binary( name = runner_name, srcs = [_LAUNCHER], deps = deps + [_BASHTEST_SH, _RUNFILES], data = data + [":" + script, _BASHTEST_SH], + **launcher_kwargs + ) + outer_kwargs = dict(kwargs) + outer_kwargs.pop("use_bash_launcher", None) + tool_names = {} + for variable, label in tools.items(): + tool_names.setdefault(label, []).append(variable) + bashtest_tools_test( + name = name, + runner = ":" + runner_name, env = env | extra_env, - **runner_kwargs + tools = {label: json.encode(names) for label, names in tool_names.items()}, + data = data + [":" + script, _BASHTEST_SH], + **outer_kwargs ) - if tools: - outer_kwargs = dict(kwargs) - - # These are sh_test attributes, rather than common test attributes. - outer_kwargs.pop("use_bash_launcher", None) - tool_names = {} - for variable, label in tools.items(): - tool_names.setdefault(label, []).append(variable) - bashtest_tools_test( - name = name, - runner = ":" + runner_name, - env = env | extra_env, - tools = {label: json.encode(names) for label, names in tool_names.items()}, - data = data + [":" + script, _BASHTEST_SH], - **outer_kwargs - ) diff --git a/bashtest/tests/BUILD.bazel b/bashtest/tests/BUILD.bazel index d175ab6..b81debd 100644 --- a/bashtest/tests/BUILD.bazel +++ b/bashtest/tests/BUILD.bazel @@ -71,13 +71,32 @@ sh_binary( srcs = [":helper_source"], ) +configuration_data( + name = "second_tool_data", + platform_marker = "second-tool", +) + +tool_source( + name = "second_helper_source", + data = ":second_tool_data", +) + +sh_binary( + name = "second_helper", + srcs = [":second_helper_source"], +) + bashtest( name = "tools_test", srcs = ["tools_test.sh"], data = [":target_data"], - env = {"TARGET_DATA": "$(rlocationpath :target_data)"}, + env = { + "TARGET_DATA": "$(rlocationpath :target_data)", + "TARGET_LOCATION": "$(location :target_data)", + }, tools = { "HELPER": ":helper", "HELPER_ALIAS": ":helper", + "SECOND_HELPER": ":second_helper", }, ) diff --git a/bashtest/tests/tools_test.sh b/bashtest/tests/tools_test.sh index 92705de..2a1940d 100755 --- a/bashtest/tests/tools_test.sh +++ b/bashtest/tests/tools_test.sh @@ -27,6 +27,12 @@ test::executable_and_runfiles() { expect_eq 'default' "$(cat "$(rlocation "${TARGET_DATA}")")" # shellcheck disable=SC2154 expect_eq "${HELPER}" "${HELPER_ALIAS}" + # shellcheck disable=SC2154 + expect_eq 'default' "$(cat "${TARGET_LOCATION}")" + # Distinct executables must both be available in this single test invocation. + # shellcheck disable=SC2154 + expect_ne "${HELPER}" "${SECOND_HELPER}" + expect_eq $'second-tool\nsecond argument' "$("${SECOND_HELPER}" 'second argument')" case "${OSTYPE}" in msys*|cygwin*) expect_matches '[.]exe$' "${HELPER}" ;; esac diff --git a/bashtest/tools.bzl b/bashtest/tools.bzl index 2389234..03888c5 100644 --- a/bashtest/tools.bzl +++ b/bashtest/tools.bzl @@ -30,10 +30,10 @@ def _validate_name(name): def _tools_test_impl(ctx): runner = ctx.attr.runner[DefaultInfo] - # Bazel 7 uses native sh_test, which does not expose RunEnvironmentInfo. - environment = ctx.attr.runner[RunEnvironmentInfo] if RunEnvironmentInfo in ctx.attr.runner else RunEnvironmentInfo( + # sh_test interprets location(s) in env as rootpath(s), relative to runfiles. + environment = RunEnvironmentInfo( environment = { - key: ctx.expand_make_variables("env", ctx.expand_location(value, ctx.attr.data), {}) + key: ctx.expand_make_variables("env", ctx.expand_location(value.replace("$(location ", "$(rootpath ").replace("$(locations ", "$(rootpaths "), ctx.attr.data), {}) for key, value in ctx.attr.env.items() }, inherited_environment = ctx.attr.env_inherit, From 6d1f3316a989918016904b0ffe1293bfdf7d00bf Mon Sep 17 00:00:00 2001 From: helly25 <6420169+helly25@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:22:25 +0100 Subject: [PATCH 8/8] Keep fixtures private and test portable environment expansion --- bashtest/tests/tool_fixture.bzl | 4 +++- bashtest/tests/tools_test.sh | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/bashtest/tests/tool_fixture.bzl b/bashtest/tests/tool_fixture.bzl index a6899dd..5aed08b 100644 --- a/bashtest/tests/tool_fixture.bzl +++ b/bashtest/tests/tool_fixture.bzl @@ -13,7 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Configuration-sensitive fixtures for executable helper tests.""" +"""Test-only configuration fixtures; not part of bashtest's public API.""" + +visibility("private") def _path(ctx, file): return file.short_path[3:] if file.short_path.startswith("../") else ctx.workspace_name + "/" + file.short_path diff --git a/bashtest/tests/tools_test.sh b/bashtest/tests/tools_test.sh index 2a1940d..877bf63 100755 --- a/bashtest/tests/tools_test.sh +++ b/bashtest/tests/tools_test.sh @@ -28,7 +28,8 @@ test::executable_and_runfiles() { # shellcheck disable=SC2154 expect_eq "${HELPER}" "${HELPER_ALIAS}" # shellcheck disable=SC2154 - expect_eq 'default' "$(cat "${TARGET_LOCATION}")" + # location retains sh_test rootpath semantics; use rlocation above for I/O. + expect_eq 'bashtest/tests/target_data.txt' "${TARGET_LOCATION}" # Distinct executables must both be available in this single test invocation. # shellcheck disable=SC2154 expect_ne "${HELPER}" "${SECOND_HELPER}"