# Yorvana — Testing Setup & Infrastructure This document summarizes the testing architecture and the steps required to re-establish the environment, especially when working within a Distrobox or containerized setup. ## 1. Test Organization (Naming Convention) To maintain a clear distinction between fast JVM tests and Android-dependent tests, we follow these naming rules: - **`*Test.kt`**: Unit Tests or Local UI Tests. Run on the local JVM (extremely fast). UI tests use **Robolectric** to simulate the Android environment. - **`*IT.kt`**: Local Integration Tests. Use **Robolectric** to simulate Android and test interactions between multiple components (e.g., Repositories + Storage). These are found in `src/test/java`. - **`app/src/androidTest/...`**: Instrumented Integration Tests. Run on a **real Android OS** (Emulator or Device). Used for high-fidelity verification of features that depend heavily on actual OS behavior. - **`*ScreenshotTest.kt`**: Visual regression tests using Roborazzi running on the JVM (Robolectric). Note: Historically, some `*IT.kt` files were located in `src/androidTest`, but we are migrating them to `src/test` to leverage Robolectric for faster feedback loops. New integration tests should be added to `src/test`. ## 2. Instrumented Tests via Gradle Managed Devices (GMD) We use GMD to automate emulator management. This avoids the need to manually create AVDs. ### Running Tests ```bash # Clean existing snapshots/devices (recommended after env changes or GPU mode changes) ./gradlew cleanManagedDevices # Run the instrumented tests on the managed Pixel 2 (API 33) ./gradlew pixel2api33Check ``` ### Configuration Details - **Smoke device**: Pixel 2, API Level 33. - **Store screenshot devices**: Pixel 6, Nexus 7, and Pixel Tablet, API Level 34. - **Image**: `aosp`. - **Location**: Defined in `app/build.gradle.kts` under `testOptions.managedDevices`. - **GPU mode**: Set via `android.testoptions.manageddevices.emulator.gpu` in `gradle.properties` (see below). --- ## 3. GPU Rendering Modes The emulator GPU mode controls how the Android guest renders graphics. Choosing the right mode depends on your environment. ### `host` — Hardware acceleration (host GPU passthrough) The emulator uses the host machine's physical GPU via Vulkan/gfxstream. | | | |---|---| | **Pros** | Fastest; required for screenshot tests to produce accurate pixel output | | **Cons** | Requires KVM + GPU passthrough; can hang the host if the GPU driver conflicts | **Works on:** - Bare metal Linux with KVM and a compatible GPU driver - Distrobox with KVM + GPU passthrough configured **Does NOT work reliably on:** - Distrobox without GPU passthrough - GitHub Actions (Ubuntu runners have no physical GPU) ### `swiftshader_indirect` — Software rendering (Google SwiftShader) The emulator uses Google's SwiftShader Vulkan ICD bundled with the Android SDK. No host GPU involved. | | | |---|---| | **Pros** | No GPU required; used by CI | | **Cons** | Crashes (SIGSEGV in QEMU) on Linux kernel ≥ 6.17 due to a conflict in the gfxstream `GLAsyncSwap` code path | **Works on:** - GitHub Actions (`ubuntu-latest` with KVM, kernel < 6.17) - Bare metal Linux with kernel < 6.17 **Does NOT work on:** - Linux kernel ≥ 6.17 (e.g. Fedora 43 / kernel 6.17): QEMU segfaults during cold boot ### `angle_indirect` — Software rendering (ANGLE + lavapipe/llvmpipe) The emulator treats this as an invalid option and falls back to `auto`, which selects Mesa's lavapipe (llvmpipe, LLVM-JIT software rasterizer) via ANGLE. Crucially, lavapipe disables `GLAsyncSwap`, which avoids the SIGSEGV present in the SwiftShader path. | | | |---|---| | **Pros** | Works on kernel ≥ 6.17; no GPU required; does not hang the host | | **Cons** | Slower than `host`; the fallback to lavapipe is an implementation detail of the emulator, not an officially documented mode | **Works on:** - Distrobox on Linux kernel ≥ 6.17 (current local setup) - Any Linux without a GPU --- ## 4. Current Configuration | Environment | `gradle.properties` setting | Effective renderer | Notes | |---|---|---|---| | **Local (Distrobox, kernel 6.17+)** | `angle_indirect` | lavapipe (llvmpipe via ANGLE) | Set in `gradle.properties` | | **CI (GitHub Actions, ubuntu-latest)** | `swiftshader_indirect` | SwiftShader | Overridden at runtime via `-Pandroid.testoptions.manageddevices.emulator.gpu=swiftshader_indirect` | | **Bare metal with GPU** | `host` | Host GPU (NVIDIA etc.) | Override `gradle.properties` locally | To run locally with hardware acceleration (e.g. on bare metal): ```bash ./gradlew cleanManagedDevices ./gradlew pixel2api33Check -Pandroid.testoptions.manageddevices.emulator.gpu=host ``` --- ## 5. Continuous Integration (CI) CI is configured in `.github/workflows/ci.yml`. Instrumented tests run on `ubuntu-latest` with KVM enabled: ```yaml - name: Enable KVM permissions run: sudo chmod 666 /dev/kvm - name: Run All Tests & Generate Combined Coverage run: ./gradlew verifyWithCoverage -Pandroid.testoptions.manageddevices.emulator.gpu=swiftshader_indirect -Proborazzi.test.verify=true -Pcoverage --info ``` CI uses `swiftshader_indirect` for software rendering because GitHub-hosted runners lack a physical GPU. Local executions will default to the `host` GPU (specified in `gradle.properties`) for maximum performance, but can be overridden using the same `-P` flag if hardware acceleration is unavailable. --- ## 6. Troubleshooting - **Segfault (Exit Code 139) with `host` GPU on Distrobox (NVIDIA)**: The Android Emulator may crash on startup due to incompatible Mesa Vulkan wrappers (e.g., `dzn_icd.json`). To fix this, explicitly point the emulator to the NVIDIA ICD. Add `export VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/nvidia_icd.x86_64.json` to your `~/.bashrc` inside the Distrobox container. - **Segfault (Exit Code 139) with `swiftshader_indirect`**: Known crash on Linux kernel ≥ 6.17. Switch to `angle_indirect` in `gradle.properties`. - **Host machine hangs during tests with `host` GPU**: GPU driver conflict (gfxstream + NVIDIA via KVM). Switch to `angle_indirect` for software rendering. - **KVM Access**: Verify with `ls -l /dev/kvm`. It must be accessible to your user. - **Clean Slate**: If GMD gets into a weird state, always try `./gradlew cleanManagedDevices` before re-running. - **Changing GPU mode**: Always run `cleanManagedDevices` first — snapshots created under one GPU mode are not reusable under another. --- ## 7. Screenshot Testing (Roborazzi) Most screenshot tests use **Roborazzi** and are executed on the **JVM using Robolectric** rather than via Gradle Managed Devices (GMD). ### Why JVM instead of GMD? While GMD provides higher fidelity through hardware-accelerated rendering, it introduces two critical workflow blockers: 1. **Silent Failures:** Roborazzi running in GMD instrumentation fails to properly verify or assert missing snapshots, silently passing tests. 2. **Artifact Extraction:** The GMD emulator is ephemeral. It spins up, runs tests, writes snapshots to its internal storage, and tears down immediately. Pulling the newly recorded `*.png` files back to the host machine for commit becomes extremely difficult and manual. Running screenshot tests in `src/test` ensures snapshots are generated directly onto the host filesystem, providing a reliable and frictionless developer experience. The exception is the Play Store marketing suite in `app/src/androidTest/java/com/yorvana/screenshots/`. It is intentionally instrumented because store assets should use real platform rendering and native device dimensions. The suite captures PNGs from the emulator display into the managed device additional-output directory. Run it on demand with: ```bash ./gradlew generateStoreScreenshots ``` This task runs only the `com.yorvana.screenshots` package on the three API 34 store devices and copies the generated PNGs into `distribution/store-assets/screenshots/{phone,tablet-7,tablet-10}/`. The existing Pixel 2 smoke device is kept separate and excludes the screenshot package during smoke invocations. --- ## 8. Robolectric Configuration We use Robolectric to run Android-dependent tests (including UI and integration tests) on the JVM for faster execution and better developer experience. ### Graphics Mode By default, all Robolectric tests in this project inherit the **NATIVE** graphics mode. This is configured globally in `app/build.gradle.kts` via `systemProperty("robolectric.graphicsMode", "NATIVE")`. - **Native Graphics**: Required for Roborazzi screenshot tests and provides high-fidelity rendering for complex UI interactions. - **Overriding**: You only need to add `@GraphicsMode(GraphicsMode.Mode.NATIVE)` or `@GraphicsMode(GraphicsMode.Mode.LEGACY)` if a test specifically requires an override or for explicit documentation. ### Migration Checklist (androidTest to test) We prioritize running tests on the JVM via Robolectric. However, a test must remain in (or be added to) `src/androidTest` if it: - Uses `ActivityScenario`, `createAndroidComposeRule`, or otherwise requires a real instrumentation-backed `Activity`/scenario. (`createComposeRule()` is supported in `src/test` Robolectric/JVM tests.) - Depends on `UiDevice` or UiAutomator APIs for system-level interactions. - Interacts with the real filesystem via the Storage Access Framework (SAF) using a non-mocked `ContentResolver`, `DocumentsProvider`, or `DocumentFile`. - Requires a running `Instrumentation` instance (e.g., `InstrumentationRegistry.getInstrumentation()`). - Tests hardware-dependent behavior (camera, sensors, Bluetooth, etc.). If a test only *mocks* these dependencies (e.g., using `mockk()`), it is eligible for migration to the JVM. --- ## 9. Smoke Test Suite (GMD) The Smoke Test suite (`app/src/androidTest/java/com/yorvana/SmokeTest.kt`) performs end-to-end verification of critical happy-path journeys on a real Android OS using Gradle Managed Devices (GMD). ### Purpose Smoke tests catch issues that only surface on a real Android environment: - Actual Activity lifecycle behavior, plus Compose navigation, state restoration, and back-stack handling. - Real file-based vault I/O on device/emulator storage, plus attachment access through Android `ContentResolver` / `MediaStore` integration. - System-level intent flows (camera capture and file-picker interactions). - Cross-screen navigation and back-stack integrity. ### Scenarios Covered The suite covers 17 scenarios (S01–S17), including: - **Core Happy Path**: First launch, vault configuration, adding/viewing vehicles and records. - **Attachments**: Adding via file picker, viewing in the gallery, removing, and taking photos. - **Settings & Navigation**: Navigating to Settings/Categories and verifying back-stack behavior. - **Mutations**: Editing and deleting vehicles and records with confirmation guards. ### Running Smoke Tests ```bash # Run just the debug androidTest task on the device (fastest for local verification) ./gradlew pixel2api33DebugAndroidTest # Run the broader device check task ./gradlew pixel2api33Check # Run tests and refresh the coverage baseline (gmd_smoke.ec) ./gradlew generateGmdCoverage ``` ### Refreshing the coverage baseline When material changes land in `app/src/main` (UI flows, storage logic, ViewModels exercised by smoke tests), regenerate and commit the baseline: ```bash ./gradlew generateGmdCoverage git add app/coverage-baselines/gmd_smoke.ec git commit -m "chore: refresh smoke coverage baseline" ``` The `Baseline Reminder` workflow posts a sticky PR comment on any PR that touches `app/src/main/**`, prompting you to refresh the baseline if the changes affect smoke coverage. Note that the `Smoke` workflow itself is path-filtered to UI changes (`app/src/main/**/ui/**`) — non-UI production changes won't trigger smoke, but the reminder will still fire so coverage stays current. ### Coverage Integration Smoke test results are persisted as a static coverage baseline in `app/coverage-baselines/gmd_smoke.ec`. This baseline is combined with JVM unit test data during report generation: ```bash # Generate combined report (JVM tests + Smoke test baseline) ./gradlew verifyWithCoverage ``` Since `app/coverage-baselines/*.ec` is tracked via Git LFS, a fresh clone can contain only an LFS pointer; `verifyWithCoverage` will fail until `git lfs pull` is run. ### Troubleshooting **`verifyWithCoverage` fails with "Coverage baseline is a Git LFS pointer"** The baseline file in your working tree is the small LFS pointer instead of the real `.ec` content. Fix: ```bash git lfs pull ``` If you do not have Git LFS installed, install it (`sudo dnf install git-lfs` on Fedora, `brew install git-lfs` on macOS) and run `git lfs install` once before re-pulling. As a fallback you can regenerate the baseline locally with `./gradlew generateGmdCoverage` (requires the GMD emulator to run). **`verifyWithCoverage` fails with "No dynamic JaCoCo execution data files were found"** The JVM unit-test execution data is missing. Re-run with coverage enabled: ```bash ./gradlew testDebugUnitTest -Pcoverage ./gradlew verifyWithCoverage ``` **Smoke tests fail with "Activity opened at unexpected destination"** Stale DataStore state from a previous run. The `ClearAppStateRule` resets this before each test, but if you see it during local debugging, run `./gradlew cleanManagedDevices` before retrying.