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