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..ae0bc0a 100644 --- a/bashtest/bashtest.bzl +++ b/bashtest/bashtest.bzl @@ -15,7 +15,9 @@ """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") # 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 +34,7 @@ def bashtest( env = {}, srcs = [], data = [], + tools = {}, **kwargs): """Bashtest wrapper. @@ -74,6 +77,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 +94,41 @@ def bashtest( "mboworks_bashtest": "$(rlocationpath {label})".format(label = _BASHTEST_SH), "_BASHTEST_SCRIPT": "$(rlocationpath {label})".format(label = ":" + script), } - sh_test( - name = name, + 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, - **kwargs + 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..b81debd 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,47 @@ 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"], +) + +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)", + "TARGET_LOCATION": "$(location :target_data)", + }, + tools = { + "HELPER": ":helper", + "HELPER_ALIAS": ":helper", + "SECOND_HELPER": ":second_helper", + }, +) diff --git a/bashtest/tests/tool_fixture.bzl b/bashtest/tests/tool_fixture.bzl new file mode 100644 index 0000000..5aed08b --- /dev/null +++ b/bashtest/tests/tool_fixture.bzl @@ -0,0 +1,45 @@ +# 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. + +"""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 + +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..877bf63 --- /dev/null +++ b/bashtest/tests/tools_test.sh @@ -0,0 +1,42 @@ +#!/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}" + # shellcheck disable=SC2154 + # 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}" + expect_eq $'second-tool\nsecond argument' "$("${SECOND_HELPER}" 'second argument')" + 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..03888c5 --- /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] + + # 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.replace("$(location ", "$(rootpath ").replace("$(locations ", "$(rootpaths "), 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"], +)