Port tests for ctx.actions.run_shell to Starlark (#60)
Existing Bazel tests are ported to analysis and build tests in
preparation for Starlarkification of run_shell.
diff --git a/MODULE.bazel b/MODULE.bazel
index cbb5d37..1dde16c 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -13,3 +13,5 @@
register_toolchains("@local_config_shell//:all")
bazel_dep(name = "rules_shellcheck", version = "0.3.3", dev_dependency = True)
+bazel_dep(name = "rules_testing", version = "0.9.0", dev_dependency = True)
+bazel_dep(name = "with_cfg.bzl", version = "0.14.1", dev_dependency = True)
diff --git a/tests/run_shell/.gitattributes b/tests/run_shell/.gitattributes
new file mode 100644
index 0000000..b108416
--- /dev/null
+++ b/tests/run_shell/.gitattributes
@@ -0,0 +1 @@
+*.golden text eol=lf
diff --git a/tests/run_shell/BUILD.bazel b/tests/run_shell/BUILD.bazel
new file mode 100644
index 0000000..c6ad417
--- /dev/null
+++ b/tests/run_shell/BUILD.bazel
@@ -0,0 +1,89 @@
+load("@bazel_skylib//rules:diff_test.bzl", "diff_test")
+load(
+ ":exec_fixtures.bzl",
+ "run_shell_long_output",
+ "run_shell_output",
+ "run_shell_output_with_action_env",
+)
+load(":run_shell_tests.bzl", "run_shell_test_suite")
+
+run_shell_test_suite(name = "run_shell_tests")
+
+run_shell_output(
+ name = "basic_output",
+ command = "echo 'hello world' > $1",
+)
+
+diff_test(
+ name = "basic_output_test",
+ size = "small",
+ file1 = ":basic_output",
+ file2 = "basic_output.golden",
+)
+
+run_shell_output(
+ name = "positional_arguments",
+ command = "echo \"$2 $3\" > $1",
+ extra_arguments = [
+ "foo",
+ "bar",
+ ],
+)
+
+diff_test(
+ name = "positional_arguments_test",
+ size = "small",
+ file1 = ":positional_arguments",
+ file2 = "positional_arguments.golden",
+)
+
+run_shell_output(
+ name = "env",
+ command = "echo \"$FOO\" > $1",
+ env = {"FOO": "bar"},
+)
+
+diff_test(
+ name = "env_test",
+ size = "small",
+ file1 = ":env",
+ file2 = "env.golden",
+)
+
+run_shell_long_output(
+ name = "long_command",
+)
+
+diff_test(
+ name = "long_command_test",
+ size = "small",
+ file1 = ":long_command",
+ file2 = "long_command.golden",
+)
+
+run_shell_output_with_action_env(
+ name = "use_default_shell_env",
+ command = "echo \"$FROM_ACTION_ENV\" > $1",
+ use_default_shell_env = True,
+)
+
+diff_test(
+ name = "use_default_shell_env_test",
+ size = "small",
+ file1 = ":use_default_shell_env",
+ file2 = "use_default_shell_env.golden",
+)
+
+run_shell_output_with_action_env(
+ name = "fixed_env_overrides_action_env",
+ command = "echo \"$FROM_ACTION_ENV\" > $1",
+ env = {"FROM_ACTION_ENV": "fixed_env_value"},
+ use_default_shell_env = True,
+)
+
+diff_test(
+ name = "fixed_env_overrides_action_env_test",
+ size = "small",
+ file1 = ":fixed_env_overrides_action_env",
+ file2 = "fixed_env_overrides_action_env.golden",
+)
diff --git a/tests/run_shell/a.txt b/tests/run_shell/a.txt
new file mode 100644
index 0000000..7898192
--- /dev/null
+++ b/tests/run_shell/a.txt
@@ -0,0 +1 @@
+a
diff --git a/tests/run_shell/b.img b/tests/run_shell/b.img
new file mode 100644
index 0000000..6178079
--- /dev/null
+++ b/tests/run_shell/b.img
@@ -0,0 +1 @@
+b
diff --git a/tests/run_shell/basic_output.golden b/tests/run_shell/basic_output.golden
new file mode 100644
index 0000000..3b18e51
--- /dev/null
+++ b/tests/run_shell/basic_output.golden
@@ -0,0 +1 @@
+hello world
diff --git a/tests/run_shell/env.golden b/tests/run_shell/env.golden
new file mode 100644
index 0000000..5716ca5
--- /dev/null
+++ b/tests/run_shell/env.golden
@@ -0,0 +1 @@
+bar
diff --git a/tests/run_shell/exec_fixtures.bzl b/tests/run_shell/exec_fixtures.bzl
new file mode 100644
index 0000000..886705e
--- /dev/null
+++ b/tests/run_shell/exec_fixtures.bzl
@@ -0,0 +1,61 @@
+"""Rules that execute `ctx.actions.run_shell` and expose the produced output.
+
+The single output file of each target is compared against a golden file with
+`diff_test`, which exercises the runtime behavior of `run_shell` (command
+execution, positional argument substitution, environment, helper scripts for
+long commands) without a Bazel-in-Bazel integration test.
+"""
+
+load("@with_cfg.bzl", "with_cfg")
+
+def _run_shell_output_impl(ctx):
+ out = ctx.actions.declare_file(ctx.label.name + ".out")
+ ctx.actions.run_shell(
+ outputs = [out],
+ command = ctx.attr.command,
+ arguments = [out.path] + ctx.attr.extra_arguments,
+ env = ctx.attr.env,
+ use_default_shell_env = ctx.attr.use_default_shell_env,
+ mnemonic = "RunShellOutput",
+ )
+ return [DefaultInfo(files = depset([out]))]
+
+run_shell_output = rule(
+ implementation = _run_shell_output_impl,
+ attrs = {
+ # The shell command. `$1` is the output path; `$2`, `$3`, ... are
+ # `extra_arguments`.
+ "command": attr.string(mandatory = True),
+ "extra_arguments": attr.string_list(),
+ "env": attr.string_dict(),
+ "use_default_shell_env": attr.bool(),
+ },
+)
+
+# Same as run_shell_output, but transitions --action_env so that targets can
+# observe an action env entry when use_default_shell_env = True.
+run_shell_output_with_action_env, _run_shell_output_with_action_env = (
+ with_cfg(run_shell_output)
+ .extend("action_env", ["FROM_ACTION_ENV=action_env_value"])
+ .build()
+)
+
+def _run_shell_long_output_impl(ctx):
+ out = ctx.actions.declare_file(ctx.label.name + ".out")
+
+ # A command well above the 64,000 char (8,000 on Windows) threshold at which
+ # run_shell spills the command into a helper script. All but the last
+ # statement are no-ops, so the output is deterministic. The output path is
+ # embedded directly because positional arguments are not forwarded into the
+ # helper script.
+ command = "( %s ; echo done ) > %s" % (" ; ".join(["true"] * 20000), out.path)
+ ctx.actions.run_shell(
+ outputs = [out],
+ command = command,
+ mnemonic = "RunShellLongOutput",
+ )
+ return [DefaultInfo(files = depset([out]))]
+
+run_shell_long_output = rule(
+ implementation = _run_shell_long_output_impl,
+)
diff --git a/tests/run_shell/fixed_env_overrides_action_env.golden b/tests/run_shell/fixed_env_overrides_action_env.golden
new file mode 100644
index 0000000..e30b371
--- /dev/null
+++ b/tests/run_shell/fixed_env_overrides_action_env.golden
@@ -0,0 +1 @@
+fixed_env_value
diff --git a/tests/run_shell/fixtures.bzl b/tests/run_shell/fixtures.bzl
new file mode 100644
index 0000000..8f7ca51
--- /dev/null
+++ b/tests/run_shell/fixtures.bzl
@@ -0,0 +1,114 @@
+"""Rules under test that exercise `ctx.actions.run_shell`."""
+
+def _command_string_impl(ctx):
+ out_a = ctx.actions.declare_file(ctx.label.name + "_a.txt")
+ out_b = ctx.actions.declare_file(ctx.label.name + "_b.img")
+ ctx.actions.run_shell(
+ inputs = ctx.files.srcs,
+ outputs = [out_a, out_b],
+ arguments = ["--a", "--b"],
+ mnemonic = "DummyMnemonic",
+ command = "dummy_command",
+ progress_message = "dummy_message",
+ env = {"a": "b"},
+ )
+ return [DefaultInfo(files = depset([out_a, out_b]))]
+
+command_string = rule(
+ implementation = _command_string_impl,
+ attrs = {"srcs": attr.label_list(allow_files = True)},
+)
+
+def _command_no_arguments_impl(ctx):
+ out = ctx.actions.declare_file(ctx.label.name + ".out")
+ ctx.actions.run_shell(
+ outputs = [out],
+ command = "echo foo123 > " + out.path,
+ )
+ return [DefaultInfo(files = depset([out]))]
+
+command_no_arguments = rule(
+ implementation = _command_no_arguments_impl,
+ attrs = {},
+)
+
+def _command_with_tools_impl(ctx):
+ out = ctx.actions.declare_file(ctx.label.name + ".out")
+ ctx.actions.run_shell(
+ inputs = ctx.files.tool if ctx.attr.tool_in_inputs else [],
+ tools = ctx.files.tool,
+ outputs = [out],
+ command = "boo bar baz",
+ )
+ return [DefaultInfo(files = depset([out]))]
+
+command_with_tools = rule(
+ implementation = _command_with_tools_impl,
+ attrs = {
+ "tool": attr.label(allow_files = True, cfg = "exec"),
+ "tool_in_inputs": attr.bool(default = False),
+ },
+)
+
+def _command_list_impl(ctx):
+ out = ctx.actions.declare_file(ctx.label.name + ".out")
+ ctx.actions.run_shell(
+ outputs = [out],
+ mnemonic = "DummyMnemonic",
+ command = ["dummy_command", "--arg1", "--arg2"],
+ )
+ return [DefaultInfo(files = depset([out]))]
+
+command_list = rule(
+ implementation = _command_list_impl,
+ attrs = {},
+)
+
+def _invalid_mnemonic_impl(ctx):
+ out = ctx.actions.declare_file(ctx.label.name + ".out")
+ ctx.actions.run_shell(
+ outputs = [out],
+ command = "false",
+ mnemonic = "@@@",
+ )
+ return [DefaultInfo(files = depset([out]))]
+
+invalid_mnemonic = rule(
+ implementation = _invalid_mnemonic_impl,
+ attrs = {},
+)
+
+def _lazy_args_impl(ctx):
+ out = ctx.actions.declare_file(ctx.label.name + ".out")
+ args = ctx.actions.args()
+ args.add("--foo")
+ ctx.actions.run_shell(
+ outputs = [out],
+ arguments = [args],
+ mnemonic = "DummyMnemonic",
+ command = "dummy_command",
+ )
+ return [DefaultInfo(files = depset([out]))]
+
+lazy_args = rule(
+ implementation = _lazy_args_impl,
+ attrs = {},
+)
+
+def _long_command_impl(ctx):
+ out = ctx.actions.declare_file(ctx.label.name + ".out")
+ long_command = "( %s ; ) > $1" % " ; ".join(
+ ["echo xxx%d" % i for i in range(0, 7000)],
+ )
+ ctx.actions.run_shell(
+ outputs = [out],
+ command = long_command,
+ mnemonic = "LongMnemonic",
+ arguments = [out.path],
+ )
+ return [DefaultInfo(files = depset([out]))]
+
+long_command = rule(
+ implementation = _long_command_impl,
+ attrs = {},
+)
diff --git a/tests/run_shell/long_command.golden b/tests/run_shell/long_command.golden
new file mode 100644
index 0000000..19f86f4
--- /dev/null
+++ b/tests/run_shell/long_command.golden
@@ -0,0 +1 @@
+done
diff --git a/tests/run_shell/positional_arguments.golden b/tests/run_shell/positional_arguments.golden
new file mode 100644
index 0000000..d675fa4
--- /dev/null
+++ b/tests/run_shell/positional_arguments.golden
@@ -0,0 +1 @@
+foo bar
diff --git a/tests/run_shell/run_shell_tests.bzl b/tests/run_shell/run_shell_tests.bzl
new file mode 100644
index 0000000..1130f13
--- /dev/null
+++ b/tests/run_shell/run_shell_tests.bzl
@@ -0,0 +1,187 @@
+"""Analysis tests for `ctx.actions.run_shell`."""
+
+load("@rules_testing//lib:analysis_test.bzl", "analysis_test", "test_suite")
+load("@rules_testing//lib:truth.bzl", "matching")
+load("@rules_testing//lib:util.bzl", "util")
+load(
+ ":fixtures.bzl",
+ "command_list",
+ "command_no_arguments",
+ "command_string",
+ "command_with_tools",
+ "invalid_mnemonic",
+ "lazy_args",
+ "long_command",
+)
+
+def _test_command_string(name):
+ util.helper_target(
+ command_string,
+ name = name + "_subject",
+ srcs = ["a.txt", "b.img"],
+ )
+ analysis_test(
+ name = name,
+ impl = _test_command_string_impl,
+ target = name + "_subject",
+ )
+
+def _test_command_string_impl(env, target):
+ action = env.expect.that_target(target).action_named("DummyMnemonic")
+
+ action.argv().contains_at_least([
+ "-c",
+ "dummy_command",
+ "",
+ "--a",
+ "--b",
+ ]).in_order()
+
+ action.inputs().contains_at_least_predicates([
+ matching.file_basename_equals("a.txt"),
+ matching.file_basename_equals("b.img"),
+ ])
+
+ action.env().contains_exactly({"a": "b"})
+
+def _test_command_no_arguments(name):
+ util.helper_target(
+ command_no_arguments,
+ name = name + "_subject",
+ )
+ analysis_test(
+ name = name,
+ impl = _test_command_no_arguments_impl,
+ target = name + "_subject",
+ )
+
+def _test_command_no_arguments_impl(env, target):
+ action = env.expect.that_target(target).action_generating(
+ "{package}/{name}.out",
+ )
+
+ action.argv().has_size(3)
+ action.argv().contains_at_least(["-c"]).in_order()
+ action.argv().not_contains("")
+
+def _test_command_with_tools(name):
+ util.helper_target(
+ command_with_tools,
+ name = name + "_subject",
+ tool = "t.exe",
+ )
+ analysis_test(
+ name = name,
+ impl = _test_command_with_tools_impl,
+ target = name + "_subject",
+ )
+
+def _test_command_with_tools_impl(env, target):
+ action = env.expect.that_target(target).action_generating("{package}/{name}.out")
+
+ action.inputs().contains_predicate(matching.file_basename_equals("t.exe"))
+
+def _test_command_with_tools_also_in_inputs(name):
+ util.helper_target(
+ command_with_tools,
+ name = name + "_subject",
+ tool = "t.exe",
+ tool_in_inputs = True,
+ )
+ analysis_test(
+ name = name,
+ impl = _test_command_with_tools_also_in_inputs_impl,
+ target = name + "_subject",
+ )
+
+def _test_command_with_tools_also_in_inputs_impl(env, target):
+ action = env.expect.that_target(target).action_generating("{package}/{name}.out")
+
+ action.inputs().contains_predicate(matching.file_basename_equals("t.exe"))
+
+def _test_command_list_rejected_by_default(name):
+ util.helper_target(
+ command_list,
+ name = name + "_subject",
+ )
+ analysis_test(
+ name = name,
+ impl = _test_command_list_rejected_by_default_impl,
+ target = name + "_subject",
+ expect_failure = True,
+ )
+
+def _test_command_list_rejected_by_default_impl(env, target):
+ env.expect.that_target(target).failures().contains_predicate(
+ matching.str_matches("*'command' must be of type string*"),
+ )
+
+def _test_invalid_mnemonic_rejected(name):
+ util.helper_target(
+ invalid_mnemonic,
+ name = name + "_subject",
+ )
+ analysis_test(
+ name = name,
+ impl = _test_invalid_mnemonic_rejected_impl,
+ target = name + "_subject",
+ expect_failure = True,
+ )
+
+def _test_invalid_mnemonic_rejected_impl(env, target):
+ env.expect.that_target(target).failures().contains_predicate(
+ matching.str_matches("*mnemonic must only contain letters and/or digits*"),
+ )
+
+def _test_lazy_args(name):
+ util.helper_target(
+ lazy_args,
+ name = name + "_subject",
+ )
+ analysis_test(
+ name = name,
+ impl = _test_lazy_args_impl,
+ target = name + "_subject",
+ )
+
+def _test_lazy_args_impl(env, target):
+ action = env.expect.that_target(target).action_named("DummyMnemonic")
+
+ action.argv().contains_at_least(["", "--foo"]).in_order()
+
+def _test_long_command_uses_helper_script(name):
+ util.helper_target(
+ long_command,
+ name = name + "_subject",
+ )
+ analysis_test(
+ name = name,
+ impl = _test_long_command_uses_helper_script_impl,
+ target = name + "_subject",
+ )
+
+def _test_long_command_uses_helper_script_impl(env, target):
+ helper = env.expect.that_target(target).action_generating(
+ "{package}/{name}.run_shell_0.sh",
+ )
+ helper.content().contains("echo xxx6999 ;")
+
+ spawn = env.expect.that_target(target).action_named("LongMnemonic")
+ spawn.inputs().contains_predicate(
+ matching.file_basename_contains(".run_shell_0.sh"),
+ )
+
+def run_shell_test_suite(name):
+ test_suite(
+ name = name,
+ tests = [
+ _test_command_string,
+ _test_command_no_arguments,
+ _test_command_with_tools,
+ _test_command_with_tools_also_in_inputs,
+ _test_command_list_rejected_by_default,
+ _test_invalid_mnemonic_rejected,
+ _test_lazy_args,
+ _test_long_command_uses_helper_script,
+ ],
+ )
diff --git a/tests/run_shell/t.exe b/tests/run_shell/t.exe
new file mode 100755
index 0000000..1a24852
--- /dev/null
+++ b/tests/run_shell/t.exe
@@ -0,0 +1 @@
+#!/bin/sh
diff --git a/tests/run_shell/use_default_shell_env.golden b/tests/run_shell/use_default_shell_env.golden
new file mode 100644
index 0000000..c223c3c
--- /dev/null
+++ b/tests/run_shell/use_default_shell_env.golden
@@ -0,0 +1 @@
+action_env_value