Skip to content
Open
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
57 changes: 41 additions & 16 deletions apps/backend/analysis/analyzers/service_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]:
Expand Down
51 changes: 51 additions & 0 deletions apps/backend/analysis/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@
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
Expand All @@ -81,6 +85,7 @@
package_manager: str = ""
has_tests: bool = False
coverage_command: str | None = None
targeted_command: str | None = None


# =============================================================================
Expand Down Expand Up @@ -277,6 +282,44 @@
}


# 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
# =============================================================================
Expand All @@ -298,7 +341,7 @@
"""Initialize the test discovery."""
self._cache: dict[str, TestDiscoveryResult] = {}

def discover(self, project_dir: Path) -> TestDiscoveryResult:

Check failure on line 344 in apps/backend/analysis/test_discovery.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=OBenner_Auto-Coding&issues=AZ814PPMPgOMskWIEGb5&open=AZ814PPMPgOMskWIEGb5&pullRequest=402
"""
Discover test frameworks and configuration in the project.

Expand Down Expand Up @@ -371,6 +414,13 @@
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

Expand Down Expand Up @@ -967,6 +1017,7 @@
"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:
Expand Down
50 changes: 50 additions & 0 deletions tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
79 changes: 79 additions & 0 deletions tests/test_service_analyzer_testing.py
Original file line number Diff line number Diff line change
@@ -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
Loading