diff --git a/apps/backend/analysis/test_discovery.py b/apps/backend/analysis/test_discovery.py index febadfd15..20ee4973f 100644 --- a/apps/backend/analysis/test_discovery.py +++ b/apps/backend/analysis/test_discovery.py @@ -286,6 +286,20 @@ class TestDiscoveryResult: "command": "./gradlew connectedAndroidTest", "coverage_command": None, }, + # Embedded: PlatformIO (host-side native env preferred, no hardware) + "pio_test": { + "config_files": ["platformio.ini"], + "type": "all", + "command": "pio test -e native", + "coverage_command": None, + }, + # Embedded: Zephyr twister runs the suite on QEMU boards (emulation) + "twister": { + "config_files": ["west.yml", "prj.conf"], + "type": "all", + "command": "west twister -p qemu_x86 -T tests", + "coverage_command": None, + }, } @@ -404,6 +418,7 @@ def discover(self, project_dir: Path) -> TestDiscoveryResult: self._discover_dart_frameworks(project_dir, result) self._discover_zig_frameworks(project_dir, result) self._discover_haskell_frameworks(project_dir, result) + self._discover_embedded_frameworks(project_dir, result) # Find test directories result.test_directories = self._find_test_directories(project_dir) @@ -1016,6 +1031,51 @@ def _discover_haskell_frameworks( ) ) + def _discover_embedded_frameworks( + self, project_dir: Path, result: TestDiscoveryResult + ) -> None: + """Discover embedded test setups that run without hardware. + + Only emulation/host-side runners are emitted: a QA agent cannot + flash a physical board, so anything requiring one is left out. + """ + pio_ini = project_dir / "platformio.ini" + if pio_ini.exists(): + try: + content = pio_ini.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + content = "" + # Prefer the host-side native environment when the project + # defines one; a bare `pio test` would target hardware envs + command = "pio test -e native" if "[env:native]" in content else "pio test" + result.frameworks.append( + TestFramework( + name="pio_test", + type="all", + command=command, + config_file="platformio.ini", + ) + ) + + # Zephyr: prj.conf marks an application, west.yml a workspace; + # twister runs the test suite on emulated QEMU boards + if (project_dir / "west.yml").exists() or ( + (project_dir / "prj.conf").exists() + and (project_dir / "CMakeLists.txt").exists() + ): + result.frameworks.append( + TestFramework( + name="twister", + type="all", + command="west twister -p qemu_x86 -T tests", + config_file=( + "west.yml" + if (project_dir / "west.yml").exists() + else "prj.conf" + ), + ) + ) + def _find_test_directories(self, project_dir: Path) -> list[str]: """Find test directories in the project.""" test_dir_patterns = [ diff --git a/apps/backend/project/command_registry/frameworks.py b/apps/backend/project/command_registry/frameworks.py index 37f50e0a1..9d8a27af8 100644 --- a/apps/backend/project/command_registry/frameworks.py +++ b/apps/backend/project/command_registry/frameworks.py @@ -179,6 +179,10 @@ "catch2": {"ctest"}, # Swift frameworks "vapor": {"vapor", "swift"}, + # Embedded ecosystems (emulation-capable runners only) + "platformio": {"pio", "platformio"}, + "zephyr": {"west", "twister", "qemu-system-arm", "qemu-system-riscv32"}, + "esp-idf": {"idf.py"}, } diff --git a/apps/backend/project/framework_detector.py b/apps/backend/project/framework_detector.py index f0b1a393f..d8bdaac20 100644 --- a/apps/backend/project/framework_detector.py +++ b/apps/backend/project/framework_detector.py @@ -46,6 +46,7 @@ def detect_all(self) -> list[str]: self.detect_cpp_frameworks() self.detect_elixir_frameworks() self.detect_swift_frameworks() + self.detect_embedded_frameworks() return self.frameworks def detect_nodejs_frameworks(self) -> None: @@ -406,3 +407,19 @@ def detect_swift_frameworks(self) -> None: if "vapor" in content.lower(): self.frameworks.append("vapor") + + def detect_embedded_frameworks(self) -> None: + """Detect embedded ecosystems from their config files.""" + if self.parser.file_exists("platformio.ini"): + self.frameworks.append("platformio") + + # Zephyr: a west workspace manifest or an application (prj.conf + CMake) + if self.parser.file_exists("west.yml") or ( + self.parser.file_exists("prj.conf") + and self.parser.file_exists("CMakeLists.txt") + ): + self.frameworks.append("zephyr") + + # ESP-IDF: sdkconfig lives next to the IDF CMake project + if self.parser.file_exists("sdkconfig", "sdkconfig.defaults"): + self.frameworks.append("esp-idf") diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 3ed8eeca5..d78f09937 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -627,6 +627,61 @@ def test_detect_cabal_test(self, discovery, temp_dir): assert cabal.command == "cabal test" +class TestEmbeddedFrameworks: + """Tests for embedded (PlatformIO/Zephyr) test detection.""" + + def test_platformio_with_native_env(self, discovery, temp_dir): + """PlatformIO project with a native env prefers host-side tests.""" + (temp_dir / "platformio.ini").write_text( + "[env:native]\nplatform = native\n\n[env:uno]\nplatform = atmelavr\n" + ) + + result = discovery.discover(temp_dir) + + pio = next(f for f in result.frameworks if f.name == "pio_test") + assert pio.command == "pio test -e native" + + def test_platformio_without_native_env(self, discovery, temp_dir): + """PlatformIO project without native env falls back to pio test.""" + (temp_dir / "platformio.ini").write_text("[env:uno]\nplatform = atmelavr\n") + + result = discovery.discover(temp_dir) + + pio = next(f for f in result.frameworks if f.name == "pio_test") + assert pio.command == "pio test" + + def test_zephyr_workspace(self, discovery, temp_dir): + """Zephyr west workspace gets a twister QEMU run.""" + (temp_dir / "west.yml").write_text("manifest:\n projects: []\n") + + result = discovery.discover(temp_dir) + + twister = next(f for f in result.frameworks if f.name == "twister") + assert "qemu_x86" in twister.command + assert twister.config_file == "west.yml" + + def test_zephyr_application(self, discovery, temp_dir): + """Zephyr app (prj.conf + CMake) gets a twister QEMU run.""" + (temp_dir / "prj.conf").write_text("CONFIG_GPIO=y\n") + (temp_dir / "CMakeLists.txt").write_text( + "find_package(Zephyr REQUIRED)\nproject(app)\n" + ) + + result = discovery.discover(temp_dir) + + framework_names = [f.name for f in result.frameworks] + assert "twister" in framework_names + + def test_prj_conf_without_cmake_not_zephyr(self, discovery, temp_dir): + """A stray prj.conf without CMake does not trigger twister.""" + (temp_dir / "prj.conf").write_text("CONFIG_GPIO=y\n") + + result = discovery.discover(temp_dir) + + framework_names = [f.name for f in result.frameworks] + assert "twister" not in framework_names + + class TestTargetedCommands: """Tests for targeted (subset) test command templates.""" diff --git a/tests/test_project_analyzer.py b/tests/test_project_analyzer.py index a6fce4b5e..49770c3c5 100644 --- a/tests/test_project_analyzer.py +++ b/tests/test_project_analyzer.py @@ -126,7 +126,7 @@ def test_detects_multiple_languages(self, temp_dir: Path): def test_detects_java_from_gradle_kts(self, temp_dir: Path): """Detects JVM/Gradle projects using Kotlin-DSL build files.""" - (temp_dir / "build.gradle.kts").write_text("plugins { kotlin(\"jvm\") }") + (temp_dir / "build.gradle.kts").write_text('plugins { kotlin("jvm") }') (temp_dir / "settings.gradle.kts").write_text('rootProject.name = "test"') analyzer = ProjectAnalyzer(temp_dir) @@ -136,7 +136,7 @@ def test_detects_java_from_gradle_kts(self, temp_dir: Path): def test_kotlin_gradle_project_allows_gradle(self, temp_dir: Path): """Kotlin-only Gradle project gets gradle in stack commands.""" - (temp_dir / "build.gradle.kts").write_text("plugins { kotlin(\"jvm\") }") + (temp_dir / "build.gradle.kts").write_text('plugins { kotlin("jvm") }') src = temp_dir / "src" / "main" / "kotlin" src.mkdir(parents=True) (src / "Main.kt").write_text("fun main() {}") @@ -152,7 +152,7 @@ def test_kotlin_gradle_project_allows_gradle(self, temp_dir: Path): def test_detects_haskell(self, temp_dir: Path): """Detects Haskell projects.""" (temp_dir / "stack.yaml").write_text("resolver: lts-22.0") - (temp_dir / "Main.hs").write_text("main = putStrLn \"hello\"") + (temp_dir / "Main.hs").write_text('main = putStrLn "hello"') analyzer = ProjectAnalyzer(temp_dir) analyzer._detect_languages() @@ -171,7 +171,7 @@ def test_detects_lua(self, temp_dir: Path): def test_detects_perl(self, temp_dir: Path): """Detects Perl projects.""" (temp_dir / "cpanfile").write_text("requires 'Mojolicious';") - (temp_dir / "app.pl").write_text("print \"hello\\n\";") + (temp_dir / "app.pl").write_text('print "hello\\n";') analyzer = ProjectAnalyzer(temp_dir) analyzer._detect_languages() @@ -410,6 +410,34 @@ def test_detects_vapor_from_package_swift(self, temp_dir: Path): assert "vapor" in analyzer.profile.detected_stack.frameworks + def test_detects_platformio(self, temp_dir: Path): + """Detects PlatformIO from platformio.ini.""" + (temp_dir / "platformio.ini").write_text("[env:native]\nplatform = native") + + analyzer = ProjectAnalyzer(temp_dir) + analyzer._detect_frameworks() + + assert "platformio" in analyzer.profile.detected_stack.frameworks + + def test_detects_zephyr(self, temp_dir: Path): + """Detects Zephyr from prj.conf + CMakeLists.txt.""" + (temp_dir / "prj.conf").write_text("CONFIG_GPIO=y") + (temp_dir / "CMakeLists.txt").write_text("find_package(Zephyr REQUIRED)") + + analyzer = ProjectAnalyzer(temp_dir) + analyzer._detect_frameworks() + + assert "zephyr" in analyzer.profile.detected_stack.frameworks + + def test_detects_esp_idf(self, temp_dir: Path): + """Detects ESP-IDF from sdkconfig.""" + (temp_dir / "sdkconfig").write_text("CONFIG_IDF_TARGET=esp32") + + analyzer = ProjectAnalyzer(temp_dir) + analyzer._detect_frameworks() + + assert "esp-idf" in analyzer.profile.detected_stack.frameworks + class TestDatabaseDetection: """Tests for database detection."""