{ "output": "# Service Book — Testing Setup & Infrastructure\n\nThis document summarizes the testing architecture and the steps required to re-establish the environment, especially when working within a Distrobox or containerized setup.\n\n## 1. Test Organization (Naming Convention)\n\nTo maintain a clear distinction between fast JVM tests and Android-dependent tests, we follow these naming rules:\n\n- **`*Test.kt`**: Unit Tests or Local UI Tests. Run on the local JVM (extremely fast). UI tests use **Robolectric** to simulate the Android environment.\n- **`*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`.\n- **`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.\n- **`*ScreenshotTest.kt`**: Visual regression tests using Roborazzi running on the JVM (Robolectric).\n\nNote: 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`.\n\n## 2. Instrumented Tests via Gradle Managed Devices (GMD)\n\nWe use GMD to automate emulator management. This avoids the need to manually create AVDs.\n\n### Running Tests\n\n```bash\n# Clean existing snapshots/devices (recommended after env changes or GPU mode changes)\n./gradlew cleanManagedDevices\n\n# Run the instrumented tests on the managed Pixel 2 (API 33)\n./gradlew pixel2api33Check\n```\n\n### Configuration Details\n- **Device**: Pixel 2, API Level 33.\n- **Image**: `aosp`.\n- **Location**: Defined in `app/build.gradle.kts` under `testOptions.managedDevices`.\n- **GPU mode**: Set via `android.testoptions.manageddevices.emulator.gpu` in `gradle.properties` (see below).\n\n---\n\n## 3. GPU Rendering Modes\n\nThe emulator GPU mode controls how the Android guest renders graphics. Choosing the right mode depends on your environment.\n\n### `host` — Hardware acceleration (host GPU passthrough)\n\nThe emulator uses the host machine's physical GPU via Vulkan/gfxstream.\n\n| | |\n|---|---|\n| **Pros** | Fastest; required for screenshot tests to produce accurate pixel output |\n| **Cons** | Requires KVM + GPU passthrough; can hang the host if the GPU driver conflicts |\n\n**Works on:**\n- Bare metal Linux with KVM and a compatible GPU driver\n- Distrobox with KVM + GPU passthrough configured\n\n**Does NOT work reliably on:**\n- Distrobox without GPU passthrough\n- GitHub Actions (Ubuntu runners have no physical GPU)\n\n### `swiftshader_indirect` — Software rendering (Google SwiftShader)\n\nThe emulator uses Google's SwiftShader Vulkan ICD bundled with the Android SDK. No host GPU involved.\n\n| | |\n|---|---|\n| **Pros** | No GPU required; used by CI |\n| **Cons** | Crashes (SIGSEGV in QEMU) on Linux kernel ≥ 6.17 due to a conflict in the gfxstream `GLAsyncSwap` code path |\n\n**Works on:**\n- GitHub Actions (`ubuntu-latest` with KVM, kernel < 6.17)\n- Bare metal Linux with kernel < 6.17\n\n**Does NOT work on:**\n- Linux kernel ≥ 6.17 (e.g. Fedora 43 / kernel 6.17): QEMU segfaults during cold boot\n\n### `angle_indirect` — Software rendering (ANGLE + lavapipe/llvmpipe)\n\nThe 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.\n\n| | |\n|---|---|\n| **Pros** | Works on kernel ≥ 6.17; no GPU required; does not hang the host |\n| **Cons** | Slower than `host`; the fallback to lavapipe is an implementation detail of the emulator, not an officially documented mode |\n\n**Works on:**\n- Distrobox on Linux kernel ≥ 6.17 (current local setup)\n- Any Linux without a GPU\n\n---\n\n## 4. Current Configuration\n\n| Environment | `gradle.properties` setting | Effective renderer | Notes |\n|---|---|---|---|\n| **Local (Distrobox, kernel 6.17+)** | `angle_indirect` | lavapipe (llvmpipe via ANGLE) | Set in `gradle.properties` |\n| **CI (GitHub Actions, ubuntu-latest)** | `swiftshader_indirect` | SwiftShader | Overridden at runtime via `-Pandroid.testoptions.manageddevices.emulator.gpu=swiftshader_indirect` |\n| **Bare metal with GPU** | `host` | Host GPU (NVIDIA etc.) | Override `gradle.properties` locally |\n\nTo run locally with hardware acceleration (e.g. on bare metal):\n```bash\n./gradlew cleanManagedDevices\n./gradlew pixel2api33Check -Pandroid.testoptions.manageddevices.emulator.gpu=host\n```\n\n---\n\n## 5. Continuous Integration (CI)\n\nCI is configured in `.github/workflows/ci.yml`. Instrumented tests run on `ubuntu-latest` with KVM enabled:\n\n```yaml\n- name: Enable KVM permissions\n run: sudo chmod 666 /dev/kvm\n\n- name: Run All Tests & Generate Combined Coverage\n run: ./gradlew verifyWithCoverage -Pandroid.testoptions.manageddevices.emulator.gpu=swiftshader_indirect -Proborazzi.test.verify=true -Pcoverage --info\n```\n\nCI 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.\n\n---\n\n## 6. Troubleshooting\n\n- **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.\n- **Segfault (Exit Code 139) with `swiftshader_indirect`**: Known crash on Linux kernel ≥ 6.17. Switch to `angle_indirect` in `gradle.properties`.\n- **Host machine hangs during tests with `host` GPU**: GPU driver conflict (gfxstream + NVIDIA via KVM). Switch to `angle_indirect` for software rendering.\n- **KVM Access**: Verify with `ls -l /dev/kvm`. It must be accessible to your user.\n- **Clean Slate**: If GMD gets into a weird state, always try `./gradlew cleanManagedDevices` before re-running.\n- **Changing GPU mode**: Always run `cleanManagedDevices` first — snapshots created under one GPU mode are not reusable under another.\n\n---\n\n## 7. Screenshot Testing (Roborazzi)\n\nScreenshot tests use **Roborazzi** and are executed on the **JVM using Robolectric** rather than via Gradle Managed Devices (GMD). \n\n### Why JVM instead of GMD?\nWhile GMD provides higher fidelity through hardware-accelerated rendering, it introduces two critical workflow blockers:\n1. **Silent Failures:** Roborazzi running in GMD instrumentation fails to properly verify or assert missing snapshots, silently passing tests.\n2. **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.\n\nRunning screenshot tests in `src/test` ensures snapshots are generated directly onto the host filesystem, providing a reliable and frictionless developer experience.\n\n---\n\n## 8. Robolectric Configuration\n\nWe use Robolectric to run Android-dependent tests (including UI and integration tests) on the JVM for faster execution and better developer experience.\n\n### Graphics Mode\nBy 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\")`.\n- **Native Graphics**: Required for Roborazzi screenshot tests and provides high-fidelity rendering for complex UI interactions.\n- **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.\n\n### Migration Checklist (androidTest to test)\nWe prioritize running tests on the JVM via Robolectric. However, a test must remain in (or be added to) `src/androidTest` if it:\n- Uses `ActivityScenario`, `createAndroidComposeRule`, or otherwise requires a real instrumentation-backed `Activity`/scenario. (`createComposeRule()` is supported in `src/test` Robolectric/JVM tests.)\n- Depends on `UiDevice` or UiAutomator APIs for system-level interactions.\n- Interacts with the real filesystem via the Storage Access Framework (SAF) using a non-mocked `ContentResolver`, `DocumentsProvider`, or `DocumentFile`.\n- Requires a running `Instrumentation` instance (e.g., `InstrumentationRegistry.getInstrumentation()`).\n- Tests hardware-dependent behavior (camera, sensors, Bluetooth, etc.).\n\nIf a test only *mocks* these dependencies (e.g., using `mockk()`), it is eligible for migration to the JVM.\n\n---\n\n## 9. Smoke Test Suite (GMD)\n\nThe Smoke Test suite (`app/src/androidTest/java/com/servicebook/SmokeTest.kt`) performs end-to-end verification of critical happy-path journeys on a real Android OS using Gradle Managed Devices (GMD).\n\n### Purpose\nSmoke tests catch issues that only surface on a real Android environment:\n- Actual Activity lifecycle behavior, plus Compose navigation, state restoration, and back-stack handling.\n- Real file-based vault I/O on device/emulator storage, plus attachment access through Android `ContentResolver` / `MediaStore` integration.\n- System-level intent flows (camera capture and file-picker interactions).\n- Cross-screen navigation and back-stack integrity.\n\n### Scenarios Covered\nThe suite covers 17 scenarios (S01–S17), including:\n- **Core Happy Path**: First launch, vault configuration, adding/viewing vehicles and records.\n- **Attachments**: Adding via file picker, viewing in the gallery, removing, and taking photos.\n- **Settings & Navigation**: Navigating to Settings/Categories and verifying back-stack behavior.\n- **Mutations**: Editing and deleting vehicles and records with confirmation guards.\n\n### Running Smoke Tests\n\n```bash\n# Run just the debug androidTest task on the device (fastest for local verification)\n./gradlew pixel2api33DebugAndroidTest\n\n# Run the broader device check task\n./gradlew pixel2api33Check\n\n# Run tests and refresh the coverage baseline (gmd_smoke.ec)\n./gradlew generateGmdCoverage\n```\n\n### Refreshing the coverage baseline\n\nWhen material changes land in `app/src/main` (UI flows, storage logic, ViewModels exercised by smoke tests), regenerate and commit the baseline:\n\n```bash\n./gradlew generateGmdCoverage\ngit add app/coverage-baselines/gmd_smoke.ec\ngit commit -m \"chore: refresh smoke coverage baseline\"\n```\n\nThe `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.\n\n### Coverage Integration\nSmoke 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:\n\n```bash\n# Generate combined report (JVM tests + Smoke test baseline)\n./gradlew verifyWithCoverage\n```\n\nSince `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.\n\n### Troubleshooting\n\n**`verifyWithCoverage` fails with \"Coverage baseline is a Git LFS pointer\"**\nThe baseline file in your working tree is the small LFS pointer instead of the real `.ec` content. Fix:\n```bash\ngit lfs pull\n```\nIf 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).\n\n**`verifyWithCoverage` fails with \"No dynamic JaCoCo execution data files were found\"**\nThe JVM unit-test execution data is missing. Re-run with coverage enabled:\n```bash\n./gradlew testDebugUnitTest -Pcoverage\n./gradlew verifyWithCoverage\n```\n\n**Smoke tests fail with \"Activity opened at unexpected destination\"**\nStale 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.\n" }