diff --git a/apps/backend/analysis/analyzers/service_analyzer.py b/apps/backend/analysis/analyzers/service_analyzer.py index cd7201b93..45664f4f6 100644 --- a/apps/backend/analysis/analyzers/service_analyzer.py +++ b/apps/backend/analysis/analyzers/service_analyzer.py @@ -210,22 +210,47 @@ def _detect_dependencies(self) -> None: self.analysis["dependencies"] = deps[:20] def _detect_testing(self) -> None: - """Detect testing framework and configuration.""" - if self._exists("package.json"): - pkg = self._read_json("package.json") - if pkg: - deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})} - if "vitest" in deps: - self.analysis["testing"] = "Vitest" - elif "jest" in deps: - self.analysis["testing"] = "Jest" - if "@playwright/test" in deps: - self.analysis["e2e_testing"] = "Playwright" - elif "cypress" in deps: - self.analysis["e2e_testing"] = "Cypress" - - elif self._exists("pytest.ini") or self._exists("pyproject.toml"): - self.analysis["testing"] = "pytest" + """Detect testing framework, commands, and configuration.""" + # Full discovery across all supported ecosystems (JS, Python, JVM, + # .NET, C/C++, Rust, Go, Ruby, PHP, Elixir, Swift, Dart, ...) + try: + from analysis.test_discovery import TestDiscovery + + discovery = TestDiscovery().discover(self.path) + unit_frameworks = [f.name for f in discovery.frameworks if f.type != "e2e"] + e2e_frameworks = [f.name for f in discovery.frameworks if f.type == "e2e"] + + if unit_frameworks: + self.analysis["testing"] = unit_frameworks[0] + if e2e_frameworks: + self.analysis["e2e_testing"] = e2e_frameworks[0] + if discovery.test_command: + self.analysis["test_command"] = discovery.test_command + if discovery.coverage_command: + self.analysis["coverage_command"] = discovery.coverage_command + if discovery.targeted_command: + # Template with {target}: path for pytest/jest/go-style + # runners, test name/filter for mvn/gradle/dotnet/ctest + self.analysis["targeted_test_command"] = discovery.targeted_command + except ImportError: + # Fall back to the previous minimal detection + if self._exists("package.json"): + pkg = self._read_json("package.json") + if pkg: + deps = { + **pkg.get("dependencies", {}), + **pkg.get("devDependencies", {}), + } + if "vitest" in deps: + self.analysis["testing"] = "vitest" + elif "jest" in deps: + self.analysis["testing"] = "jest" + if "@playwright/test" in deps: + self.analysis["e2e_testing"] = "playwright" + elif "cypress" in deps: + self.analysis["e2e_testing"] = "cypress" + elif self._exists("pytest.ini") or self._exists("pyproject.toml"): + self.analysis["testing"] = "pytest" # Find test directory for test_dir in ["tests", "test", "__tests__", "spec"]: diff --git a/apps/backend/analysis/test_discovery.py b/apps/backend/analysis/test_discovery.py index 88884324c..378f1d9da 100644 --- a/apps/backend/analysis/test_discovery.py +++ b/apps/backend/analysis/test_discovery.py @@ -71,6 +71,10 @@ class TestDiscoveryResult: package_manager: Detected package manager has_tests: Whether any test files were found coverage_command: Command for coverage if available + targeted_command: Template to run a subset of tests; contains a + {target} placeholder (a path for path-based runners such as + pytest/jest/go, a test name/filter for filter-based runners + such as Maven/Gradle/dotnet/ctest) """ __test__ = False # Prevent pytest from collecting this as a test class @@ -81,6 +85,7 @@ class TestDiscoveryResult: package_manager: str = "" has_tests: bool = False coverage_command: str | None = None + targeted_command: str | None = None # ============================================================================= @@ -277,6 +282,44 @@ class TestDiscoveryResult: } +# Templates to run a subset of tests per framework. {target} is a path for +# path-based runners and a test name/filter for filter-based runners. +# Frameworks without a reliable targeted form are omitted. +FRAMEWORK_TARGETED_COMMANDS: dict[str, str] = { + "jest": "npx jest {target}", + "vitest": "npx vitest run {target}", + "mocha": "npx mocha {target}", + "playwright": "npx playwright test {target}", + "cypress": "npx cypress run --spec {target}", + "pytest": "pytest {target}", + "unittest": "python -m unittest {target}", + "cargo_test": "cargo test {target}", + "go_test": "go test {target}", + "rspec": "bundle exec rspec {target}", + "maven": "mvn test -Dtest={target}", + "gradle": "gradle test --tests {target}", + "sbt": 'sbt "testOnly {target}"', + "dotnet_test": 'dotnet test --filter "{target}"', + "ctest": "ctest --test-dir build -R {target} --output-on-failure", + "phpunit": "vendor/bin/phpunit --filter {target}", + "mix_test": "mix test {target}", + "swift_test": "swift test --filter {target}", + "flutter_test": "flutter test {target}", + "dart_test": "dart test {target}", +} + + +def _targeted_command_for(framework: TestFramework) -> str | None: + """Return the targeted-run template for a framework, if it has one.""" + template = FRAMEWORK_TARGETED_COMMANDS.get(framework.name) + if template is None: + return None + # Keep the Gradle wrapper when the full command uses it + if framework.name == "gradle" and framework.command.startswith("./gradlew"): + return template.replace("gradle ", "./gradlew ", 1) + return template + + # ============================================================================= # TEST DISCOVERY # ============================================================================= @@ -371,6 +414,13 @@ def discover(self, project_dir: Path) -> TestDiscoveryResult: result.coverage_command = framework.coverage_command break + # Set targeted command template from first framework that has one + for framework in result.frameworks: + targeted = _targeted_command_for(framework) + if targeted: + result.targeted_command = targeted + break + self._cache[cache_key] = result return result @@ -967,6 +1017,7 @@ def to_dict(self, result: TestDiscoveryResult) -> dict[str, Any]: "package_manager": result.package_manager, "has_tests": result.has_tests, "coverage_command": result.coverage_command, + "targeted_command": result.targeted_command, } def clear_cache(self) -> None: diff --git a/tests/test_discovery.py b/tests/test_discovery.py index ece966a42..40a66830d 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -546,6 +546,56 @@ def test_detect_cabal_test(self, discovery, temp_dir): assert cabal.command == "cabal test" +class TestTargetedCommands: + """Tests for targeted (subset) test command templates.""" + + def test_pytest_targeted_command(self, discovery, temp_dir): + """Python project gets a path-based targeted template.""" + (temp_dir / "requirements.txt").write_text("pytest\n") + + result = discovery.discover(temp_dir) + + assert result.targeted_command == "pytest {target}" + + def test_gradle_wrapper_targeted_command(self, discovery, temp_dir): + """Gradle project with wrapper keeps ./gradlew in targeted template.""" + (temp_dir / "build.gradle.kts").write_text('plugins { kotlin("jvm") }') + (temp_dir / "gradlew").write_text("#!/bin/sh") + + result = discovery.discover(temp_dir) + + assert result.targeted_command == "./gradlew test --tests {target}" + + def test_ctest_targeted_command(self, discovery, temp_dir): + """CMake project gets a ctest -R filter template.""" + (temp_dir / "CMakeLists.txt").write_text( + "project(demo)\nenable_testing()\nadd_test(NAME t COMMAND t)" + ) + + result = discovery.discover(temp_dir) + + assert result.targeted_command == ( + "ctest --test-dir build -R {target} --output-on-failure" + ) + + def test_no_targeted_command_for_zig(self, discovery, temp_dir): + """Frameworks without a reliable targeted form yield None.""" + (temp_dir / "build.zig").write_text("pub fn build() void {}") + + result = discovery.discover(temp_dir) + + assert result.targeted_command is None + + def test_targeted_command_in_to_dict(self, discovery, temp_dir): + """to_dict includes the targeted command template.""" + (temp_dir / "requirements.txt").write_text("pytest\n") + + result = discovery.discover(temp_dir) + data = discovery.to_dict(result) + + assert data["targeted_command"] == "pytest {target}" + + class TestPrimaryCommandPrecedence: """New ecosystems must not change the primary command of existing ones.""" diff --git a/tests/test_service_analyzer_testing.py b/tests/test_service_analyzer_testing.py new file mode 100644 index 000000000..42841e4f0 --- /dev/null +++ b/tests/test_service_analyzer_testing.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +""" +Tests for ServiceAnalyzer testing detection. + +Verifies that service analysis (which feeds project_index.json consumed by +coder/QA prompts) surfaces test commands discovered by TestDiscovery for +all supported ecosystems, not just JS/Python. +""" + +import sys +import tempfile +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "apps" / "backend")) + +from analysis.analyzers import ServiceAnalyzer + + +@pytest.fixture +def temp_dir(): + """Create a temporary directory for tests.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + +class TestServiceAnalyzerTesting: + """Tests for _detect_testing via TestDiscovery.""" + + def test_python_service_gets_test_commands(self, temp_dir): + """Python service surfaces pytest command and targeted template.""" + (temp_dir / "requirements.txt").write_text("pytest\n") + (temp_dir / "tests").mkdir() + + analyzer = ServiceAnalyzer(temp_dir, "backend") + analyzer._detect_testing() + + assert analyzer.analysis["testing"] == "pytest" + assert analyzer.analysis["test_command"] == "pytest" + assert analyzer.analysis["targeted_test_command"] == "pytest {target}" + assert analyzer.analysis["test_directory"] == "tests" + + def test_gradle_service_gets_test_commands(self, temp_dir): + """JVM/Gradle service surfaces gradle test commands.""" + (temp_dir / "build.gradle.kts").write_text('plugins { kotlin("jvm") }') + (temp_dir / "gradlew").write_text("#!/bin/sh") + + analyzer = ServiceAnalyzer(temp_dir, "api") + analyzer._detect_testing() + + assert analyzer.analysis["testing"] == "gradle" + assert analyzer.analysis["test_command"] == "./gradlew test" + assert ( + analyzer.analysis["targeted_test_command"] + == "./gradlew test --tests {target}" + ) + + def test_js_service_separates_unit_and_e2e(self, temp_dir): + """JS service reports unit and e2e frameworks separately.""" + (temp_dir / "package.json").write_text( + '{"devDependencies": {"vitest": "^1.0.0", "cypress": "^13.0.0"}}' + ) + + analyzer = ServiceAnalyzer(temp_dir, "frontend") + analyzer._detect_testing() + + assert analyzer.analysis["testing"] == "vitest" + assert analyzer.analysis["e2e_testing"] == "cypress" + + def test_service_without_tests_has_no_test_command(self, temp_dir): + """Service without test setup gets no test keys.""" + (temp_dir / "main.zig").write_text("pub fn main() void {}") + + analyzer = ServiceAnalyzer(temp_dir, "tool") + analyzer._detect_testing() + + assert "test_command" not in analyzer.analysis + assert "targeted_test_command" not in analyzer.analysis