Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions apps/backend/analysis/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/project/command_registry/frameworks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
81 changes: 81 additions & 0 deletions tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading