diff --git a/android/instrumentation-testing/.bazelignore b/android/instrumentation-testing/.bazelignore new file mode 100644 index 000000000..ba4c7139b --- /dev/null +++ b/android/instrumentation-testing/.bazelignore @@ -0,0 +1 @@ +third_party/android-test/overlay diff --git a/android/instrumentation-testing/.bazelrc b/android/instrumentation-testing/.bazelrc new file mode 100644 index 000000000..befb08f9f --- /dev/null +++ b/android/instrumentation-testing/.bazelrc @@ -0,0 +1,10 @@ +common --enable_bzlmod +common --java_language_version=17 +common --java_runtime_version=remotejdk_17 +common --tool_java_language_version=17 +common --tool_java_runtime_version=remotejdk_17 + +test:local_device --test_strategy=exclusive +test:local_device --test_arg=--device_broker_type=LOCAL_ADB_SERVER + +try-import %workspace%/.bazelrc.user diff --git a/android/instrumentation-testing/.bazelversion b/android/instrumentation-testing/.bazelversion new file mode 100644 index 000000000..47da986f8 --- /dev/null +++ b/android/instrumentation-testing/.bazelversion @@ -0,0 +1 @@ +9.1.0 diff --git a/android/instrumentation-testing/MODULE.bazel b/android/instrumentation-testing/MODULE.bazel new file mode 100644 index 000000000..91ea8d589 --- /dev/null +++ b/android/instrumentation-testing/MODULE.bazel @@ -0,0 +1,15 @@ +module(name = "android_instrumentation_testing") + +bazel_dep(name = "android_test_overlay", version = "0.0.0") +bazel_dep(name = "rules_android", version = "0.7.2") + +android_sdk_repository_extension = use_extension("@rules_android//rules/android_sdk_repository:rule.bzl", "android_sdk_repository_extension") +use_repo(android_sdk_repository_extension, "androidsdk") + +android_test = use_extension("@android_test_overlay//:extensions.bzl", "android_test") +use_repo(android_test, "android_test_support") + +local_path_override( + module_name = "android_test_overlay", + path = "third_party/android-test", +) diff --git a/android/instrumentation-testing/README.md b/android/instrumentation-testing/README.md new file mode 100644 index 000000000..7e00194f9 --- /dev/null +++ b/android/instrumentation-testing/README.md @@ -0,0 +1,63 @@ +# Android Instrumentation Testing with Bazel + +This sample builds a small Java Android app and runs an AndroidX/JUnit4 +instrumentation test on a connected local device. + +## Run + +Point Bazel at an Android SDK: + +```shell +export ANDROID_HOME=/path/to/android/sdk +``` + +Alternatively, keep the SDK path in `.bazelrc.user`: + +```text +common --repo_env=ANDROID_HOME=/path/to/android/sdk +``` + +Build the app and instrumentation APK: + +```shell +bazel build //app/src/main:greeter_test_app +``` + +Run the device test: + +```shell +bazel test //app/src/main:greeter_instrumentation_test \ + --config=local_device \ + --nocache_test_results +``` + +`--config=local_device` uses the local adb server and runs the test +exclusively. `adb devices` should show one usable device; for multiple devices, +pass an explicit serial with `--test_arg=--device_serial_number=`. +Use either an emulator or a real device that is already running; this sample +does not create or manage an emulator. + +## Approach + +The sample uses a small Starlark rule in `bazel/android_instrumentation_test.bzl` +to connect `rules_android` APK providers to AndroidX Test's host-side runner. +The rule adds the target APK, instrumentation APK, and runner to runfiles, and +leaves device selection to the `local_device` Bazel config. + +AndroidX Test support is vendored as a separate module in +`third_party/android-test`. The root module reaches it through +`local_path_override` and a module extension that creates +`@android_test_support`. Keeping this as a separate module avoids putting the +AndroidX Test dependency graph and overrides directly in the sample module. + +The AndroidX Test repository is generated from an upstream source archive plus +the local `third_party/android-test/overlay` tree. The overlay replaces selected +BUILD files with Bzlmod-compatible labels and carries the small local-device +patches needed for retail devices. It also patches AndroidX Test's host-side +test discovery to use the Android SDK `dexdump` from `@androidsdk//:dexdump` +with modern SDK arguments, instead of falling back to AndroidX Test's embedded +Linux `dexdump_annotations` binary. + +`.bazelignore` excludes the overlay from normal package discovery because those +BUILD files are intended for the generated `@android_test_support` repository, +not the root sample workspace. diff --git a/android/instrumentation-testing/app/src/main/AndroidManifest.xml b/android/instrumentation-testing/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..20733fa6b --- /dev/null +++ b/android/instrumentation-testing/app/src/main/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/android/instrumentation-testing/app/src/main/AndroidTestManifest.xml b/android/instrumentation-testing/app/src/main/AndroidTestManifest.xml new file mode 100644 index 000000000..261cce334 --- /dev/null +++ b/android/instrumentation-testing/app/src/main/AndroidTestManifest.xml @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/android/instrumentation-testing/app/src/main/BUILD.bazel b/android/instrumentation-testing/app/src/main/BUILD.bazel new file mode 100644 index 000000000..bb7a04eb4 --- /dev/null +++ b/android/instrumentation-testing/app/src/main/BUILD.bazel @@ -0,0 +1,37 @@ +load("@rules_android//android:rules.bzl", "android_binary", "android_library") +load("//bazel:android_instrumentation_test.bzl", "android_instrumentation_test") + +android_binary( + name = "app", + manifest = "AndroidManifest.xml", + deps = [":greeter_lib"], +) + +android_library( + name = "greeter_lib", + srcs = ["java/com/example/android/instrumentation/Greeter.java"], + custom_package = "com.example.android.instrumentation", + manifest = "AndroidManifest.xml", + visibility = ["//visibility:public"], +) + +android_binary( + name = "greeter_test_app", + testonly = True, + srcs = ["java/com/example/android/instrumentation/GreeterInstrumentationTest.java"], + instruments = ":app", + manifest = "AndroidTestManifest.xml", + deps = [ + ":greeter_lib", + "@android_test_support//:junit", + "@android_test_support//runner/android_junit_runner", + ], +) + +android_instrumentation_test( + name = "greeter_instrumentation_test", + bootstrap_instrumentation_package = "com.example.android.instrumentation.test", + tags = ["manual"], + test_app = ":greeter_test_app", + test_packages = ["com.example.android.instrumentation.test"], +) diff --git a/android/instrumentation-testing/app/src/main/java/com/example/android/instrumentation/Greeter.java b/android/instrumentation-testing/app/src/main/java/com/example/android/instrumentation/Greeter.java new file mode 100644 index 000000000..0d62b8899 --- /dev/null +++ b/android/instrumentation-testing/app/src/main/java/com/example/android/instrumentation/Greeter.java @@ -0,0 +1,11 @@ +package com.example.android.instrumentation; + +public final class Greeter { + public String greet(String name) { + String normalizedName = name == null ? "" : name.trim(); + if (normalizedName.isEmpty()) { + return "Hello, Android!"; + } + return "Hello, " + normalizedName + "!"; + } +} diff --git a/android/instrumentation-testing/app/src/main/java/com/example/android/instrumentation/GreeterInstrumentationTest.java b/android/instrumentation-testing/app/src/main/java/com/example/android/instrumentation/GreeterInstrumentationTest.java new file mode 100644 index 000000000..5c214a55a --- /dev/null +++ b/android/instrumentation-testing/app/src/main/java/com/example/android/instrumentation/GreeterInstrumentationTest.java @@ -0,0 +1,20 @@ +package com.example.android.instrumentation; + +import static org.junit.Assert.assertEquals; + +import androidx.test.runner.AndroidJUnit4; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public final class GreeterInstrumentationTest { + @Test + public void testDefaultGreeting() { + assertEquals("Hello, Android!", new Greeter().greet(null)); + } + + @Test + public void testNamedGreeting() { + assertEquals("Hello, Bazel!", new Greeter().greet(" Bazel ")); + } +} diff --git a/android/instrumentation-testing/bazel/BUILD.bazel b/android/instrumentation-testing/bazel/BUILD.bazel new file mode 100644 index 000000000..410562035 --- /dev/null +++ b/android/instrumentation-testing/bazel/BUILD.bazel @@ -0,0 +1 @@ +exports_files(["android_instrumentation_test.sh.tpl"]) diff --git a/android/instrumentation-testing/bazel/android_instrumentation_test.bzl b/android/instrumentation-testing/bazel/android_instrumentation_test.bzl new file mode 100644 index 000000000..ac738e2c3 --- /dev/null +++ b/android/instrumentation-testing/bazel/android_instrumentation_test.bzl @@ -0,0 +1,117 @@ +load("@rules_android//providers:providers.bzl", "AndroidInstrumentationInfo", "ApkInfo") + +def _apk_from_target(target, attr_name): + if ApkInfo in target: + apk = target[ApkInfo].signed_apk + if not apk: + fail("%s must provide a signed APK" % attr_name) + return apk + + apks = [f for f in target[DefaultInfo].files.to_list() if f.basename.endswith(".apk")] + if len(apks) != 1: + fail("%s must provide exactly one .apk file, got %d" % (attr_name, len(apks))) + return apks[0] + +def _runfiles_path(file): + path = file.short_path + if path.startswith("../"): + return path[3:] + return path + +def _apparent_label(label): + return "//%s:%s" % (label.package, label.name) + +def _android_instrumentation_test_impl(ctx): + test_app = ctx.attr.test_app + test_apk = test_app[ApkInfo].signed_apk + target_apk = test_app[AndroidInstrumentationInfo].target.signed_apk + if not target_apk: + fail("test_app must set instruments to an android_binary that produces a signed APK") + + support_apks = [_apk_from_target(apk, "support_apks") for apk in ctx.attr.support_apks] + executable = ctx.actions.declare_file(ctx.label.name) + + substitutions = { + "%workspace%": ctx.workspace_name, + "%test_label%": _apparent_label(ctx.label), + "%test_entry_point%": _runfiles_path(ctx.executable._test_entry_point), + "%adb%": _runfiles_path(ctx.file._adb), + "%aapt%": _runfiles_path(ctx.executable._aapt), + "%dexdump%": _runfiles_path(ctx.file._dexdump), + "%target_apk%": _runfiles_path(target_apk), + "%instrumentation_apk%": _runfiles_path(test_apk), + "%support_apks%": " ".join([_runfiles_path(apk) for apk in support_apks]), + "%test_packages%": " ".join([ + "additional_test_packages=%s" % package + for package in ctx.attr.test_packages + ]), + "%device_broker_type%": ctx.attr.device_broker_type, + "%bootstrap_instrumentation_package%": ctx.attr.bootstrap_instrumentation_package, + "%install_basic_services%": str(ctx.attr.install_basic_services).lower(), + "%install_test_services%": str(ctx.attr.install_test_services).lower(), + "%scan_target_package_for_tests%": str(ctx.attr.scan_target_package_for_tests).lower(), + } + + ctx.actions.expand_template( + template = ctx.file._template, + output = executable, + substitutions = substitutions, + is_executable = True, + ) + + runfiles = ctx.runfiles(files = [ + executable, + ctx.executable._test_entry_point, + ctx.file._adb, + ctx.executable._aapt, + ctx.file._dexdump, + target_apk, + test_apk, + ] + support_apks) + runfiles = runfiles.merge(ctx.attr._test_entry_point[DefaultInfo].default_runfiles) + runfiles = runfiles.merge(ctx.attr._aapt[DefaultInfo].default_runfiles) + + return [DefaultInfo( + executable = executable, + runfiles = runfiles, + )] + +android_instrumentation_test = rule( + implementation = _android_instrumentation_test_impl, + attrs = { + "bootstrap_instrumentation_package": attr.string(), + "device_broker_type": attr.string(default = "LOCAL_ADB_SERVER"), + "install_basic_services": attr.bool(default = False), + "install_test_services": attr.bool(default = True), + "scan_target_package_for_tests": attr.bool(default = False), + "support_apks": attr.label_list(allow_files = [".apk"]), + "test_app": attr.label( + mandatory = True, + providers = [[ApkInfo, AndroidInstrumentationInfo]], + ), + "test_packages": attr.string_list(), + "_aapt": attr.label( + default = Label("@androidsdk//:aapt_binary"), + executable = True, + cfg = "exec", + ), + "_adb": attr.label( + default = Label("@androidsdk//:adb"), + allow_single_file = True, + ), + "_dexdump": attr.label( + default = Label("@androidsdk//:dexdump"), + allow_single_file = True, + ), + "_template": attr.label( + default = Label("//bazel:android_instrumentation_test.sh.tpl"), + allow_single_file = True, + ), + "_test_entry_point": attr.label( + default = Label("@android_test_support//:instrumentation_test_runner"), + executable = True, + cfg = "exec", + ), + }, + test = True, +) diff --git a/android/instrumentation-testing/bazel/android_instrumentation_test.sh.tpl b/android/instrumentation-testing/bazel/android_instrumentation_test.sh.tpl new file mode 100644 index 000000000..b56ea59cc --- /dev/null +++ b/android/instrumentation-testing/bazel/android_instrumentation_test.sh.tpl @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ -z "${TEST_SRCDIR:-}" && -z "${RUNFILES_DIR:-}" ]]; then + echo "TEST_SRCDIR or RUNFILES_DIR must be set by Bazel test." >&2 + exit 1 +fi + +workspace="%workspace%" + +function resolve_runfile() { + local path="$1" + local base + for base in "${RUNFILES_DIR:-}" "${TEST_SRCDIR:-}"; do + if [[ -z "${base}" ]]; then + continue + fi + if [[ -e "${base}/${path}" ]]; then + echo "${base}/${path}" + return + fi + if [[ -e "${base}/${workspace}/${path}" ]]; then + echo "${base}/${workspace}/${path}" + return + fi + done + echo "Could not resolve runfile: ${path}" >&2 + exit 1 +} + +function join_runfiles() { + local separator="$1" + shift + local result="" + local path + for path in "$@"; do + if [[ -z "${path}" ]]; then + continue + fi + if [[ -n "${result}" ]]; then + result+="${separator}" + fi + result+="$(resolve_runfile "${path}")" + done + echo "${result}" +} + +if [[ -z "${TESTBRIDGE_TEST_ONLY+set}" ]]; then + android_testbridge_test_only="" +else + android_testbridge_test_only="${TESTBRIDGE_TEST_ONLY}" + unset TESTBRIDGE_TEST_ONLY +fi + +test_entry_point="$(resolve_runfile "%test_entry_point%")" +adb="$(resolve_runfile "%adb%")" +aapt="$(resolve_runfile "%aapt%")" +dexdump="$(resolve_runfile "%dexdump%")" +target_apk="$(resolve_runfile "%target_apk%")" +instrumentation_apk="$(resolve_runfile "%instrumentation_apk%")" +support_apks="$(join_runfiles "," %support_apks%)" + +if [[ -n "${support_apks}" ]]; then + apks_to_install="${support_apks},${target_apk},${instrumentation_apk}" +else + apks_to_install="${target_apk},${instrumentation_apk}" +fi + +argv=$(cat <