[bazel] Add skia_android_unit_test macro.

This CL defines a generic (i.e. non Android specific) skia_test macro that compiles a Skia C++ unit test with cc_binary_with_flags, and a wrapper test runner script that passes any necessary command-line arguments to the C++ binary. The test wrapper script is exposed to Bazel as a sh_test.

Then, this CL defines a skia_android_unit_test macro that compiles one or more C++ unit tests into a single Android binary and produces a script that runs the test on an attached Android device via adb. See the docstring for details.

Note to reviewer: The skia_test macro is similar enough to the existing skia_cpu_tests and skia_ganesh_tests macros that we could rewrite them as wrappers around skia_test, but that's out of scope for this CL.

Suggested review order:
- //tests/BUILD.bazel
- //tests/android.bzl

Bug: skia:14227
Change-Id: I43c38d530c4b2562673f319ea9dc87d3734cbb10
Reviewed-on: https://skia-review.googlesource.com/c/skia/+/668876
Commit-Queue: Leandro Lovisolo <lovisolo@google.com>
Reviewed-by: Kevin Lubick <kjlubick@google.com>
diff --git a/BUILD.bazel b/BUILD.bazel
index b1732f4..70eb45c 100644
--- a/BUILD.bazel
+++ b/BUILD.bazel
@@ -1,5 +1,6 @@
 load("//:defines.bzl", "DEFAULT_DEFINES", "DEFAULT_LOCAL_DEFINES")
 load("@skia_user_config//:copts.bzl", "DEFAULT_OBJC_COPTS")
+load("//bazel:flags.bzl", "selects")
 load("//bazel:skia_rules.bzl", "exports_files_legacy", "skia_cc_library", "skia_objc_library")
 
 licenses(["notice"])
@@ -114,3 +115,27 @@
     actual = "//infra:gazelle",
     visibility = ["//visibility:public"],
 )
+
+# Convenience condition that is always true. This condition is satisfied if an arbitrarily chosen
+# boolean built-in flag (https://bazel.build/docs/user-manual#stamp) is either true or false.
+#
+# Inspired by
+# https://github.com/bazelbuild/bazel-skylib/blob/2f0bb4cec0297bb38f830a72fa8961bee057c3cd/lib/selects.bzl#L227.
+selects.config_setting_group(
+    name = "always_true",
+    match_any = [
+        ":always_true_0",
+        ":always_true_1",
+    ],
+    visibility = ["//visibility:public"],
+)
+
+config_setting(
+    name = "always_true_0",
+    values = {"stamp": "0"},
+)
+
+config_setting(
+    name = "always_true_1",
+    values = {"stamp": "1"},
+)
diff --git a/bazel/buildrc b/bazel/buildrc
index 13fb3d0..9060f9e 100644
--- a/bazel/buildrc
+++ b/bazel/buildrc
@@ -52,6 +52,12 @@
 build:for_android_arm64 --platforms=//bazel/platform:android_arm64 --cc_output_directory_tag=android_arm64
 build:for_android_arm64_with_rbe --config=for_android_arm64 --config=linux_rbe
 
+# Android device-specific configurations.
+build:pixel_5 --platforms=//bazel/platform:pixel_5 --cc_output_directory_tag=pixel_5
+build:pixel_5_with_rbe --config=pixel_5 --config=linux_rbe
+build:pixel_7 --platforms=//bazel/platform:pixel_7 --cc_output_directory_tag=pixel_7
+build:pixel_7_with_rbe --config=pixel_7 --config=linux_rbe
+
 # =============================================================================
 # Configurations (what features we want on)
 # =============================================================================
diff --git a/bazel/devices/BUILD.bazel b/bazel/devices/BUILD.bazel
new file mode 100644
index 0000000..d127900
--- /dev/null
+++ b/bazel/devices/BUILD.bazel
@@ -0,0 +1,29 @@
+load("//bazel:flags.bzl", "selects")
+load("//bazel:skia_rules.bzl", "exports_files_legacy")
+
+exports_files_legacy()
+
+licenses(["notice"])
+
+constraint_setting(name = "android_device")
+
+constraint_value(
+    name = "pixel_5",
+    constraint_setting = ":android_device",
+    visibility = ["//visibility:public"],
+)
+
+constraint_value(
+    name = "pixel_7",
+    constraint_setting = ":android_device",
+    visibility = ["//visibility:public"],
+)
+
+selects.config_setting_group(
+    name = "has_android_device",
+    match_any = [
+        ":pixel_5",
+        ":pixel_7",
+    ],
+    visibility = ["//visibility:public"],
+)
diff --git a/bazel/platform/BUILD.bazel b/bazel/platform/BUILD.bazel
index 978c08b..d44d108 100644
--- a/bazel/platform/BUILD.bazel
+++ b/bazel/platform/BUILD.bazel
@@ -69,6 +69,24 @@
     ],
 )
 
+platform(
+    name = "pixel_5",
+    constraint_values = [
+        "@platforms//os:android",
+        "@platforms//cpu:arm64",
+        "//bazel/devices:pixel_5",
+    ],
+)
+
+platform(
+    name = "pixel_7",
+    constraint_values = [
+        "@platforms//os:android",
+        "@platforms//cpu:arm64",
+        "//bazel/devices:pixel_7",
+    ],
+)
+
 # This constraint allows us to force Bazel to resolve our hermetic toolchain to build
 # the target and not a default one (e.g. on the Linux RBE instance). We do this by
 # adding the constraint to our platforms that describe the target we want Bazel to build for.
diff --git a/bazel/remove_indentation.bzl b/bazel/remove_indentation.bzl
new file mode 100644
index 0000000..1c827c4
--- /dev/null
+++ b/bazel/remove_indentation.bzl
@@ -0,0 +1,69 @@
+"""This module defines the remove_indentation macro."""
+
+def remove_indentation(string):
+    """Removes indentation from a multiline string.
+
+    This utility function allows us to write multiline templates in a context that requires
+    indentation, for example inside a macro. It discards the first and last lines if they only
+    contain spaces or tabs. Then, it computes an indentation prefix based on the first remaining
+    line and removes that prefix from all lines.
+
+    Example:
+
+    ```
+    def greeter_script():
+        return remove_indentation('''
+            #!/bin/bash
+            echo "Hello, {name}!"
+        ''').format(name = "world")
+    ```
+
+    This is equivalent to:
+
+    ```
+    TEMPLATE = '''#!/bin/bash
+    echo "Hello, {name}!"
+    '''
+
+    def greeter_script():
+        return TEMPLATE.format(name = "world")
+    ```
+
+    This macro is similar to
+    https://github.com/bazelbuild/rules_rust/blob/937e63399b111a6d7ee53b187e4d113300b089e9/rust/private/utils.bzl#L386.
+
+    Args:
+        string: A multiline string.
+    Returns:
+        The input string minus any indentation.
+    """
+
+    def get_indentation(line):
+        indentation = ""
+        for char in line.elems():
+            if char in [" ", "\t"]:
+                indentation += char
+            else:
+                break
+        return indentation
+
+    lines = string.split("\n")
+
+    # Skip first line if empty.
+    if get_indentation(lines[0]) == lines[0]:
+        lines = lines[1:]
+
+    # Compute indentation based on the first remaining line, and remove indentation from all lines.
+    indentation = get_indentation(lines[0])
+    lines = [line.removeprefix(indentation) for line in lines]
+
+    # Skip last line if empty.
+    if get_indentation(lines[len(lines) - 1]) == lines[len(lines) - 1]:
+        lines = lines[:-1]
+
+    result = "\n".join(lines)
+    if result[:-1] != "\n":
+        # Ensure we always end with a newline.
+        result += "\n"
+
+    return result
diff --git a/resources/BUILD.bazel b/resources/BUILD.bazel
index 560ea49..ecb8d92 100644
--- a/resources/BUILD.bazel
+++ b/resources/BUILD.bazel
@@ -1,5 +1,19 @@
 load("//bazel:skia_rules.bzl", "skia_filegroup")
 
+# We export a known file inside the resources directory so that we can compute a path to said
+# directory from places that support "Make" variables[1], such as the "cmd" attribute[2] of a
+# genrule. For example, a genrule can compute the path to the resources directory from its "cmd"
+# attribute as follows:
+#
+#     $$(dirname $$(rootpath //resources:README))
+#
+# [1] https://bazel.build/reference/be/make-variables
+# [2] https://bazel.build/reference/be/general#genrule.cmd
+exports_files(
+    ["README"],
+    visibility = ["//tests:__pkg__"],
+)
+
 skia_filegroup(
     name = "resources",
     srcs = [
diff --git a/tests/BUILD.bazel b/tests/BUILD.bazel
index 860bfcc..2cc003d 100644
--- a/tests/BUILD.bazel
+++ b/tests/BUILD.bazel
@@ -11,6 +11,7 @@
     "PATHOPS_TESTS",
     "PDF_TESTS",
 )
+load(":android.bzl", "skia_android_unit_test")
 
 skia_cc_library(
     name = "tests_base",
@@ -280,24 +281,95 @@
     ],
 )
 
-# This target is known to build successfully for Android with the following Bazel invocations:
+# These targets can be run from a gLinux workstation. Example:
 #
-#     # For 64-bit ARM.
-#     $ bazel build //tests:known_good_android_ganesh_test \
-#           --config=for_android_arm64_with_rbe \
-#           --gpu_backend=gl_backend \
-#           --enable_gpu_test_utils
+# 1. Port-forward the ADB server from a Raspberry Pi that is connected to a Pixel 5:
 #
-#     # For 32-bit ARM.
-#     $ bazel build //tests:known_good_android_ganesh_test \
-#           --config=for_android_arm32_with_rbe \
-#           --gpu_backend=gl_backend \
-#           --enable_gpu_test_utils
+#     $ ssh -L 5037:localhost:5037 skia-rpi2-rack1-shelf1-036
 #
-# lovisolo@ has yet to test the resulting binaries on a physical Android device.
+# 2. Invoke Bazel as follows:
 #
-# Building this alias is equivalent to building //tests:text_blob_cache_test.
-alias(
-    name = "known_good_android_ganesh_test",
-    actual = ":TextBlobCacheTest",
+#     $ bazel test //tests:android_codec_test --config=pixel_5 --config=linux_rbe --test_output=streamed
+
+skia_android_unit_test(
+    name = "android_codec_test",
+    srcs = CODEC_TESTS,
+    flags = {
+        "include_decoder": [
+            "avif_decode_codec",
+            "gif_decode_codec",
+            "jpeg_decode_codec",
+            "jxl_decode_codec",
+            "png_decode_codec",
+            "raw_decode_codec",
+            "webp_decode_codec",
+        ],
+        "include_encoder": [
+            "jpeg_encode_codec",
+            "png_encode_codec",
+            "webp_encode_codec",
+        ],
+    },
+    requires_resources_dir = True,
+    deps = [
+        ":tests_base",
+        "//:skia_internal",
+    ],
+)
+
+skia_android_unit_test(
+    name = "android_ganesh_test",
+    srcs = GANESH_TESTS,
+    requires_condition = "//src/gpu:has_gpu_backend",
+    requires_resources_dir = True,
+    deps = [
+        ":tests_base",
+        "//:skia_internal",
+    ],
+)
+
+skia_android_unit_test(
+    name = "android_pathops_test",
+    srcs = PATHOPS_TESTS,
+    requires_resources_dir = True,
+    deps = [
+        ":pathops_tests_base",
+        "//:skia_internal",
+    ],
+)
+
+# Some test cases fail with --config=pixel_7.
+skia_android_unit_test(
+    name = "android_cpu_only_test",
+    srcs = CPU_ONLY_TESTS,
+    flags = {
+        "fontmgr_factory": ["custom_directory_fontmgr_factory"],
+        "enable_sksl": ["True"],
+        "enable_sksl_tracing": ["True"],
+        "include_decoder": [
+            "jpeg_decode_codec",
+            "png_decode_codec",
+        ],
+        "include_encoder": [
+            "png_encode_codec",
+        ],
+    },
+    requires_resources_dir = True,
+    deps = [
+        ":tests_base",
+        "//:skia_internal",
+    ],
+)
+
+skia_android_unit_test(
+    name = "android_discardable_memory_test",
+    srcs = DISCARDABLE_MEMORY_POOL_TESTS,
+    flags = {
+        "enable_discardable_memory": ["True"],
+        "use_default_global_memory_pool": ["True"],
+    },
+    deps = [
+        ":tests_base",
+        "//:skia_internal",
+    ],
 )
diff --git a/tests/adb_test.bzl b/tests/adb_test.bzl
new file mode 100644
index 0000000..51ee98d
--- /dev/null
+++ b/tests/adb_test.bzl
@@ -0,0 +1,113 @@
+"""This module defines the adb_test rule."""
+
+load("//bazel:remove_indentation.bzl", "remove_indentation")
+
+def _adb_test_impl(ctx):
+    # TODO(lovisolo): Add device-specific (via ctx.attr.device) setup steps such as turning cores
+    #                 on/off and setting the CPU/GPU frequencies.
+
+    # TODO(lovisolo): Replace this with a Go program.
+    template = remove_indentation("""
+        #!/bin/bash
+
+        # Runner script for device "{device}".
+
+        # TODO(lovisolo): Should we check that the machine is attached to the expected device type?
+        #                 E.g. run "adb devices -l" and check that the output contains
+        #                 "model:Pixel_5".
+
+        # Note: this script was only tested on Pixel devices.
+        #
+        # The /sdcard/revenge_of_the_skiabot directory is writable for non-root users, but files in
+        # this directory cannot be executed. For this reason, we extract the archive in a directory
+        # under /data, which allows executing files but requires root privileges.
+        #
+        # TODO(lovisolo): Can we do this without "su"-ing as root? Does this work on non-rooted
+        #                 devices?
+        # TODO(lovisolo): Test on more devices.
+
+        ARCHIVE_ON_DEVICE=/sdcard/revenge_of_the_skiabot/bazel-adb-test.tar.gz
+        DIRECTORY_ON_DEVICE=/data/bazel-adb-test
+
+        # Print commands and expand variables for easier debugging.
+        set -x
+
+        # Ensure that we clean up the device on exit, even in the case of failures.
+        # TODO(lovisolo): Also clean up before running the test, as the device might be in a dirty
+        #                 state if the previous task did not finish correctly (e.g. device reboot).
+        trap "adb shell su root rm -rf ${{ARCHIVE_ON_DEVICE}} ${{DIRECTORY_ON_DEVICE}}" EXIT
+
+        # Upload archive.
+        adb push $(rootpath {archive}) ${{ARCHIVE_ON_DEVICE}}
+
+        # Extract archive.
+        adb shell su root mkdir ${{DIRECTORY_ON_DEVICE}}
+        adb shell su root tar xzvf ${{ARCHIVE_ON_DEVICE}} -C ${{DIRECTORY_ON_DEVICE}}
+
+        # Run test inside the directory where the archive was extracted. We set the working
+        # directory to the root of the archive, which emulates the directory structure expected by
+        # the test when invoked with "bazel test". See
+        # https://bazel.build/reference/test-encyclopedia#initial-conditions.
+        echo "cd ${{DIRECTORY_ON_DEVICE}} && $(rootpath {test_runner})" | adb shell su root
+    """)
+
+    if ctx.attr.device == "unknown":
+        template = remove_indentation("""
+            #!/bin/bash
+
+            echo "FAILED: No Android device was specified. Try re-running with a Bazel flag that"
+            echo "        specifies an Android device under test, such as --config=pixel_5."
+
+            exit 1
+        """)
+
+    # Expand variables.
+    template = ctx.expand_location(template.format(
+        device = ctx.attr.device,
+        archive = ctx.attr.archive.label,
+        test_runner = ctx.attr.test_runner.label,
+    ), targets = [
+        ctx.attr.archive,
+        ctx.attr.test_runner,
+    ])
+
+    output_file = ctx.actions.declare_file(ctx.attr.name)
+    ctx.actions.write(output_file, template, is_executable = True)
+
+    return [DefaultInfo(
+        executable = output_file,
+        runfiles = ctx.runfiles(files = [ctx.file.archive]),
+    )]
+
+adb_test = rule(
+    doc = """Runs an Android test on device via `adb`.""",
+    implementation = _adb_test_impl,
+    attrs = {
+        "device": attr.string(
+            doc = "Device under test.",
+            mandatory = True,
+            values = [
+                "pixel_5",
+                "pixel_7",
+                "unknown",
+            ],
+        ),
+        "test_runner": attr.label(
+            doc = (
+                "Test runner script that calls the compiled C++ binary with any necessary " +
+                "command-line arguments. This script will be executed on the Android device."
+            ),
+            allow_single_file = [".sh"],
+            mandatory = True,
+        ),
+        "archive": attr.label(
+            doc = (
+                "Tarball containing the test runner script, the compiled C++ binary and any" +
+                "necessary static resources such as fonts, images, etc."
+            ),
+            allow_single_file = [".tar.gz"],
+            mandatory = True,
+        ),
+    },
+    test = True,
+)
diff --git a/tests/android.bzl b/tests/android.bzl
new file mode 100644
index 0000000..4eb8494
--- /dev/null
+++ b/tests/android.bzl
@@ -0,0 +1,272 @@
+"""This module defines the skia_android_unit_test macro."""
+
+load("//bazel:cc_binary_with_flags.bzl", "cc_binary_with_flags")
+load("//bazel:remove_indentation.bzl", "remove_indentation")
+load(":adb_test.bzl", "adb_test")
+
+def skia_test(
+        name,
+        srcs,
+        deps,
+        requires_resources_dir = False,
+        extra_args = [],
+        flags = {},
+        limit_to = [],
+        tags = [],
+        size = None):
+    """Defines a generic Skia C++ unit test.
+
+    This macro produces a <name>_binary C++ binary and a <name>.sh wrapper script that runs the
+    binary with the desired command-line arguments (see the extra_args and requires_resources_dir
+    arguments). The <name>.sh wrapper script is exposed as a Bazel test target via the sh_target
+    rule.
+
+    The reason why we place command-line arguments in a wrapper script is that it makes it easier
+    to run a Bazel-built skia_test outside of Bazel. This is useful e.g. for CI jobs where we want
+    to perform test compilation and execution as different steps on different hardware (e.g.
+    compile on a GCE machine, run tests on a Skolo device). In this scenario, the test could be
+    executed outside of Bazel by simply running the <name>.sh script without any arguments. See the
+    skia_android_unit_test macro for an example.
+
+    Note: The srcs attribute must explicitly include a test runner (e.g.
+    //tests/BazelTestRunner.cpp).
+
+    Args:
+        name: The name of the test.
+        srcs: C++ source files.
+        deps: C++ library dependencies.
+        requires_resources_dir: Indicates whether this test requires any files under //resources,
+            such as images, fonts, etc. If so, the compiled C++ binary will be invoked with flag
+            --resourcePath set to the path to the //resources directory under the runfiles tree.
+            Note that this implies the test runner must recognize the --resourcePath flag for this
+            to work.
+        extra_args: Any additional command-line arguments to pass to the compiled C++ binary.
+        flags: A map of strings to lists of strings to specify features that must be compiled in
+            for these tests to work. For example, tests targeting our codec logic will want the
+            various codecs included, but most tests won't need that.
+        limit_to: A list of platform labels (e.g. @platform//os:foo; @platform//cpu:bar) which
+            restrict where this test will be compiled and ran. If the list is empty, it will run
+            anywhere. If it is non-empty, it will only run on platforms which match the entire set
+            of constraints. See https://github.com/bazelbuild/platforms for these.
+        tags: A list of tags for the generated test target.
+        size: The size of the test.
+    """
+    test_binary = "%s_binary" % name
+
+    # We compile the test as a cc_binary, rather than as as a cc_test, because we will not
+    # "bazel test" this binary directly. Instead, we will "bazel test" a wrapper script that
+    # invokes this binary with the required command-line parameters.
+    cc_binary_with_flags(
+        name = test_binary,
+        srcs = srcs,
+        deps = deps,
+        data = ["//resources"] if requires_resources_dir else [],
+        set_flags = flags,
+        target_compatible_with = limit_to,
+        testonly = True,  # Needed to gain access to test-only files.
+    )
+
+    test_runner = "%s.sh" % name
+
+    test_args = ([
+        "--resourcePath",
+        "$$(realpath $$(dirname $(rootpath //resources:README)))",
+    ] if requires_resources_dir else []) + extra_args
+
+    # This test runner might run on Android devices, which might not have a /bin/bash binary.
+    test_runner_template = remove_indentation("""
+        #!/bin/sh
+        $(rootpath {test_binary}) {test_args}
+    """)
+
+    # TODO(lovisolo): This should be an actual rule. This will allow us to select() the arguments
+    #                 based on the device (e.g. for device-specific --skip flags to skip tests).
+    native.genrule(
+        name = "%s_runner" % name,
+        srcs = [test_binary] + (
+            # The script template computes the path to //resources under the runfiles tree via
+            # $$(dirname $(rootpath //resources:README)), so we need to list //resources:README
+            # here explicitly. This file was chosen arbitrarily; there is nothing special about it.
+            ["//resources", "//resources:README"] if requires_resources_dir else []
+        ),
+        outs = [test_runner],
+        cmd = "echo '%s' > $@" % test_runner_template.format(
+            test_binary = test_binary,
+            test_args = "\\\n    ".join(test_args),
+        ),
+        testonly = True,
+    )
+
+    native.sh_test(
+        name = name,
+        size = size,
+        srcs = [test_runner],
+        data = [test_binary] + (["//resources"] if requires_resources_dir else []),
+        tags = tags,
+    )
+
+def skia_android_unit_test(
+        name,
+        srcs,
+        deps = [],
+        flags = {},
+        extra_args = [],
+        requires_condition = "//:always_true",
+        requires_resources_dir = False):
+    """Defines a Skia Android unit test.
+
+    This macro compiles one or more C++ unit tests into a single Android binary and produces a
+    script that runs the test on an attached Android device via `adb`.
+
+    This macro requires a device-specific Android platform such as //bazel/devices:pixel_5. This is
+    used to decide what device-specific set-up steps to apply, such as setting CPU/GPU frequencies.
+
+    The test target produced by this macro can be executed on a machine attached to an Android
+    device. This can be either via USB, or by port-forwarding a remote ADB server (TCP port 5037)
+    running on a machine attached to the target device, such as a Skolo Raspberry Pi.
+
+    High-level overview of how this rule works:
+
+    - It produces a <name>.tar.gz archive containing the Android binary, a minimal launcher script
+      that invokes the binary with the necessary command-line arguments, and any static resources
+      needed by the test, such as fonts and images under //resources.
+    - It produces a <name>.sh test runner script that extracts the tarball into the device via
+      `adb`, sets up the device, runs the test, cleans up and pipes through the test's exit code.
+
+    For CI jobs, rather than invoking "bazel test" on a Raspberry Pi attached to the Android device
+    under test, we compile and run the test in two separate tasks:
+
+    - A build task running on a GCE machine compiles the test on RBE with Bazel and stores the
+      <name>.tar.gz and <name>.sh output files to CAS.
+    - A test task running on a Skolo Raspberry Pi downloads <name>.tar.gz and <name>.sh from CAS
+      and executes <name>.sh *outside of Bazel*.
+
+    The reason why we don't want to run Bazel on a Raspberry Pi is due to its constrained
+    resources.
+
+    Note: Although not currently supported, we could use a similar approach for Apple devices in
+    in the future.
+
+    Args:
+        name: The name of the test.
+        srcs: A list of C++ source files. This list should not include a main function (see the
+            requires_condition argument).
+        deps: Any dependencies needed by the srcs. This list should not include a main function
+            (see the requires_condition argument).
+        flags: A map of strings to lists of strings to specify features that must be compiled in
+            for these tests to work. For example, tests targeting our codec logic will want the
+            various codecs included, but most tests won't need that.
+        extra_args: Additional command-line arguments to pass to the test, for example, any
+            device-specific --skip flags to skip incompatible or buggy test cases.
+            TODO(lovisolo): Do we need to support skipping tests? IIUC today we only skip DMs, but
+                            we don't skip any unit tests.
+        requires_condition: A necessary condition for the test to work. For example, GPU tests
+            should set this argument to "//src/gpu:has_gpu_backend". If the condition is satisfied,
+            //tests:BazelTestRunner.cpp will be appended to the srcs attribute. If the condition is
+            not satisfied, //tests:BazelNoopRunner.cpp will be included instead, and no deps will
+            be included. This prevents spurious build failures when using wildcard expressions
+            (e.g. "bazel build //tests/...") with a configuration that is incompatible with this
+            test.
+        requires_resources_dir: If set, the contents of the //resources directory will be included
+            in the test runfiles, and the test binary will be invoked with flag --resourcePath set
+            to the path to said directory.
+    """
+
+    skia_test(
+        name = "%s_cpp_test" % name,
+        srcs = select({
+            requires_condition: srcs + ["//tests:BazelTestRunner.cpp"],
+            "//conditions:default": ["//tests:BazelNoopRunner.cpp"],
+        }),
+        deps = select({
+            requires_condition: deps,
+            "//conditions:default": [],
+        }),
+        flags = flags,
+        extra_args = extra_args,
+        requires_resources_dir = requires_resources_dir,
+        tags = [
+            # Exclude it from wildcards, e.g. "bazel test //...". We never want to run this binary
+            # directly.
+            "manual",
+            "no-remote",  # RBE workers cannot run Android tests.
+        ],
+        size = "large",  # Can take several minutes.
+    )
+
+    test_binary = "%s_cpp_test_binary" % name
+    test_runner = "%s_cpp_test.sh" % name
+
+    archive = "%s_archive" % name
+    archive_srcs = [test_runner, test_binary] + (
+        ["//resources"] if requires_resources_dir else []
+    )
+
+    # Create an archive containing the test and its resources, with a structure that emulates
+    # the environment expected by the test when executed via "bazel test". This archive can be
+    # pushed to an Android device via "adb push", and the contained test can be executed on the
+    # device via "adb shell" as long as the working directory is set to the directory where the
+    # archive is extracted.
+    #
+    # See https://bazel.build/reference/test-encyclopedia#initial-conditions.
+    #
+    # TODO(lovisolo): Replace this unreadable Bash script with a Go program.
+    native.genrule(
+        name = archive,
+        srcs = archive_srcs,
+        outs = ["%s.tar.gz" % name],
+        cmd = remove_indentation("""
+            # Create archive root directory, and ensure it is cleaned up on exit, even in the
+            # case of errors.
+            ARCHIVE_DIR=$$(mktemp -d);
+            trap "rm -rf $$ARCHIVE_DIR" EXIT;
+
+            # execpaths point to physical files generated by Bazel (e.g.
+            # bazel-out/k8-linux_x64-dbg/bin/tests/some_test), whereas rootpaths are the paths
+            # that a binary running via "bazel run" or "bazel test" expects (e.g
+            # tests/some_test). Thus, we must map the former to the latter.
+            #
+            # Reference:
+            # https://bazel.build/reference/be/make-variables#predefined_label_variables
+            EXECPATHS=({execpaths});
+            ROOTPATHS=({rootpaths});
+            if [ $${{#EXECPATHS[*]}} -ne $${{#ROOTPATHS[*]}} ]; then
+                echo EXECPATHS and ROOTPATHS have different lengths;
+                exit 1;
+            fi;
+
+            # Copy each file in EXECPATHS into ARCHIVE_DIR, renamed as its corresponding entry
+            # in ROOTPATHS.
+            for (( i=0; i<$${{#EXECPATHS[*]}}; i++ )); do
+                EXECPATH=$${{EXECPATHS[$$i]}};
+                ROOTPATH=$${{ROOTPATHS[$$i]}};
+                mkdir -p $$ARCHIVE_DIR/$$(dirname $$ROOTPATH);
+                cp $$EXECPATH $$ARCHIVE_DIR/$$ROOTPATH;
+            done;
+
+            # Create archive.
+            tar zcf $@ -C $$ARCHIVE_DIR .;
+        """.format(
+            execpaths = " ".join(["$(execpaths %s)" % src for src in archive_srcs]),
+            rootpaths = " ".join(["$(rootpaths %s)" % src for src in archive_srcs]),
+        )),
+        testonly = True,  # Needed to gain access to test-only files.
+    )
+
+    adb_test(
+        name = name,
+        archive = archive,
+        test_runner = test_runner,
+        device = select(
+            {
+                "//bazel/devices:pixel_5": "pixel_5",
+                "//bazel/devices:pixel_7": "pixel_7",
+                "//conditions:default": "unknown",
+            },
+        ),
+        tags = ["no-remote"],  # Incompatible with RBE because it requires an Android device.
+        target_compatible_with = select({
+            "//bazel/devices:has_android_device": [],
+            "//conditions:default": ["@platforms//:incompatible"],
+        }),
+    )
diff --git a/tests/tests.bzl b/tests/tests.bzl
index 60014d3..56da36e 100644
--- a/tests/tests.bzl
+++ b/tests/tests.bzl
@@ -1,6 +1,4 @@
-"""
-This file contains macros that generate multiple test targets, one per file.
-"""
+"""This module contains macros to generate C++ test targets."""
 
 load("//bazel:cc_test_with_flags.bzl", "cc_test_with_flags")