diff --git a/apps/backend/analysis/test_discovery.py b/apps/backend/analysis/test_discovery.py index 378f1d9da..febadfd15 100644 --- a/apps/backend/analysis/test_discovery.py +++ b/apps/backend/analysis/test_discovery.py @@ -279,6 +279,13 @@ class TestDiscoveryResult: "command": "cabal test", "coverage_command": None, }, + # Android instrumented tests (emulator/device; managed devices preferred) + "android_instrumented": { + "config_files": ["build.gradle", "build.gradle.kts"], + "type": "e2e", + "command": "./gradlew connectedAndroidTest", + "coverage_command": None, + }, } @@ -387,6 +394,7 @@ def discover(self, project_dir: Path) -> TestDiscoveryResult: # Additional language ecosystems (each method checks its own markers) self._discover_jvm_frameworks(project_dir, result) + self._discover_android_instrumented(project_dir, result) self._discover_sbt_frameworks(project_dir, result) self._discover_dotnet_frameworks(project_dir, result) self._discover_c_cpp_frameworks(project_dir, result) @@ -712,6 +720,83 @@ def _discover_jvm_frameworks( ) ) + # Root and single-module Android build file locations + _ANDROID_BUILD_FILES = ( + "build.gradle", + "build.gradle.kts", + "app/build.gradle", + "app/build.gradle.kts", + ) + + def _discover_android_instrumented( + self, project_dir: Path, result: TestDiscoveryResult + ) -> None: + """Discover Android instrumented (emulator/device) test setups.""" + content = "" + config_file = None + for build_file in self._ANDROID_BUILD_FILES: + path = project_dir / build_file + if not path.exists(): + continue + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + if config_file is None: + config_file = build_file + content += text + + if "com.android" not in content: + return + + gradle = "./gradlew" if (project_dir / "gradlew").exists() else "gradle" + + # Gradle Managed Virtual Devices provision and tear down their own + # emulator, so they are the preferred instrumented-test path + device = self._find_managed_device_name(content) + if device: + command = f"{gradle} {device}DebugAndroidTest" + elif self._has_android_test_sources(project_dir): + # Requires an attached device or a running emulator + command = f"{gradle} connectedAndroidTest" + else: + return + + result.frameworks.append( + TestFramework( + name="android_instrumented", + type="e2e", + command=command, + config_file=config_file, + ) + ) + + @staticmethod + def _find_managed_device_name(build_content: str) -> str | None: + """Extract the first Gradle managed-device name, if configured.""" + if "managedDevices" not in build_content: + return None + # Limit the search to the section after the managedDevices block opens + section = build_content.split("managedDevices", 1)[1][:2000] + # Kotlin DSL: create("pixel2api30") { ... } + match = re.search(r'(?:create|maybeCreate|register)\("(\w+)"\)', section) + if match: + return match.group(1) + # Groovy DSL: localDevices { pixel2api30 { ... } } + match = re.search( + r"(?:localDevices|allDevices|devices)\s*\{\s*(\w+)\s*\{", section + ) + if match: + return match.group(1) + return None + + @staticmethod + def _has_android_test_sources(project_dir: Path) -> bool: + """Check for instrumented test sources (src/androidTest).""" + if (project_dir / "src" / "androidTest").is_dir(): + return True + return any(project_dir.glob("*/src/androidTest")) + def _discover_sbt_frameworks( self, project_dir: Path, result: TestDiscoveryResult ) -> None: diff --git a/apps/backend/project/command_registry/frameworks.py b/apps/backend/project/command_registry/frameworks.py index f811f1f84..37f50e0a1 100644 --- a/apps/backend/project/command_registry/frameworks.py +++ b/apps/backend/project/command_registry/frameworks.py @@ -167,7 +167,7 @@ "spring-boot": {"mvn", "gradle", "gradlew"}, "quarkus": {"quarkus", "mvn", "gradle"}, "micronaut": {"mn", "mvn", "gradle"}, - "android": {"gradlew", "adb"}, + "android": {"gradlew", "adb", "emulator", "avdmanager", "sdkmanager"}, "compose": {"gradle", "gradlew"}, # Jetpack/Multiplatform Compose # .NET frameworks "aspnet": {"dotnet"}, diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 40a66830d..3ed8eeca5 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -381,6 +381,87 @@ def test_detect_sbt(self, discovery, temp_dir): assert sbt.command == "sbt test" +class TestAndroidInstrumented: + """Tests for Android instrumented test detection.""" + + def test_managed_devices_kts(self, discovery, temp_dir): + """Kotlin-DSL managed device produces a managed-device task.""" + (temp_dir / "build.gradle.kts").write_text( + 'plugins { id("com.android.application") }\n' + "android { testOptions { managedDevices { localDevices {\n" + ' create("pixel2api30") { device = "Pixel 2" }\n' + "} } } }\n" + ) + (temp_dir / "gradlew").write_text("#!/bin/sh") + + result = discovery.discover(temp_dir) + + android = next(f for f in result.frameworks if f.name == "android_instrumented") + assert android.command == "./gradlew pixel2api30DebugAndroidTest" + assert android.type == "e2e" + + def test_managed_devices_groovy(self, discovery, temp_dir): + """Groovy named device block produces a managed-device task.""" + (temp_dir / "build.gradle").write_text( + "apply plugin: 'com.android.application'\n" + "android { testOptions { managedDevices { localDevices {\n" + " pixel6api33 { device = 'Pixel 6' }\n" + "} } } }\n" + ) + + result = discovery.discover(temp_dir) + + android = next(f for f in result.frameworks if f.name == "android_instrumented") + assert android.command == "gradle pixel6api33DebugAndroidTest" + + def test_android_test_sources_fall_back_to_connected(self, discovery, temp_dir): + """androidTest sources without managed devices use connectedAndroidTest.""" + app = temp_dir / "app" + (app / "src" / "androidTest").mkdir(parents=True) + (app / "build.gradle").write_text("apply plugin: 'com.android.application'") + (temp_dir / "gradlew").write_text("#!/bin/sh") + + result = discovery.discover(temp_dir) + + android = next(f for f in result.frameworks if f.name == "android_instrumented") + assert android.command == "./gradlew connectedAndroidTest" + + def test_android_without_instrumented_tests_not_detected(self, discovery, temp_dir): + """Android project without androidTest sources gets no e2e entry.""" + (temp_dir / "build.gradle").write_text( + "apply plugin: 'com.android.application'" + ) + + result = discovery.discover(temp_dir) + + framework_names = [f.name for f in result.frameworks] + assert "android_instrumented" not in framework_names + + def test_plain_jvm_gradle_project_not_detected(self, discovery, temp_dir): + """Non-Android Gradle project gets no instrumented entry.""" + (temp_dir / "build.gradle").write_text("plugins { id 'java' }") + (temp_dir / "src" / "androidTest").mkdir(parents=True) + + result = discovery.discover(temp_dir) + + framework_names = [f.name for f in result.frameworks] + assert "android_instrumented" not in framework_names + + def test_unit_gradle_command_stays_primary(self, discovery, temp_dir): + """Instrumented entry does not displace the unit-test command.""" + (temp_dir / "build.gradle.kts").write_text( + 'plugins { id("com.android.application") }\n' + "android { testOptions { managedDevices { localDevices {\n" + ' create("pixel2api30") { }\n' + "} } } }\n" + ) + (temp_dir / "gradlew").write_text("#!/bin/sh") + + result = discovery.discover(temp_dir) + + assert result.test_command == "./gradlew test" + + class TestDotnetFrameworks: """Tests for .NET test framework detection."""