Output:
Chunk ID: c7dc9e
Wall time: 0.3341 seconds
Process exited with code 0
Original token count: 619
Output:
# GMD smoke tests on CI.
#
# GitHub-hosted `ubuntu-latest` runners expose `/dev/kvm` for CPU acceleration
# (the "Enable KVM" step installs a world-writable `0666` udev rule, which is
# a common approach when running GMD tests on ephemeral Ubuntu runners to
# ensure the emulator process has necessary permissions).
# However, they have no GPU/Vulkan driver — so the GPU mode is forced to
# `swiftshader_indirect`. Note: swiftshader is documented to crash on Linux
# kernel >= 6.17 (see TESTING_SETUP.md). GitHub-hosted runners are currently
# on kernel ~6.8, but if that changes this workflow will need a different
# harness.
#
# To keep Free-tier Actions minutes in budget, this workflow does not auto-trigger.
# It runs only when the `smoke` label is added to a PR (via smoke-label.yml) or
# when manually invoked from the Actions tab (workflow_dispatch).
name: Smoke
permissions:
  contents: read

on:
  workflow_dispatch:
  workflow_call:

jobs:
  smoke:
    name: GMD Smoke Tests
    runs-on: ubuntu-latest
    timeout-minutes: 45
    concurrency:
      group: smoke-${{ github.ref }}
      cancel-in-progress: true
    steps:
      - name: Checkout code
        uses: actions/checkout@v6

      - name: Set up JDK 17
        uses: actions/setup-java@v5
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Set up Gradle
        uses: gradle/actions/setup-gradle@v6
        with:
          cache-read-only: ${{ github.ref != 'refs/heads/master' }}
          cache-cleanup: on-success

      - name: Grant execute permission for gradlew
        run: chmod +x gradlew

      - name: Enable KVM
        run: |
          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
            | sudo tee /etc/udev/rules.d/99-kvm4all.rules
          sudo udevadm control --reload-rules
          sudo udevadm trigger --name-match=kvm

      - name: Run GMD Smoke Tests
        run: ./gradlew pixel2api33Check -Pandroid.testoptions.manageddevices.emulator.gpu=swiftshader_indirect

      - name: Upload Managed Device Reports
        if: failure()
        uses: actions/upload-artifact@v7
        with:
          name: gmd-test-reports
          path: |
            app/build/reports/androidTests/managedDevice/
            app/build/outputs/androidTest-results/managedDevice/
            app/build/outputs/managed_device_android_test_additional_output/
          if-no-files-found: warn
          retention-days: 7


Output:
Chunk ID: a65400
Wall time: 0.3103 seconds
Process exited with code 0
Original token count: 113
Output:
app/src/androidTest
app/src/androidTest/assets
app/src/androidTest/assets/screenshots-fixture
app/src/androidTest/assets/screenshots-fixture/vehicles
app/src/androidTest/assets/screenshots-fixture/vehicles/2019-honda-civic
app/src/androidTest/java
app/src/androidTest/java/com
app/src/androidTest/java/com/yorvana
app/src/androidTest/java/com/yorvana/screenshots
app/src/androidTest/java/com/yorvana/testsupport
app/src/androidTest/java/com/yorvana/ui


Output:
Chunk ID: f0e95f
Wall time: 0.1196 seconds
Process exited with code 0
Original token count: 2968
Output:
        val myTask = this
        val taskName = name
        argsProperty.putAll(
            provider {
                val map = mutableMapOf<String, String>()
                // We only return arguments if the task is actually executing. This prevents
                // aliasing/merging issues when AGP shares the underlying MapProperty object across tasks.
                if (myTask.state.executing) {
                    when {
                        taskName.contains("phonePixel6api34", ignoreCase = true) -> {
                            map["package"] = screenshotTestPackage
                            map["additionalTestOutputDir"] = screenshotDeviceOutputDir
                        }
                        taskName.contains("tablet7Nexus7api34", ignoreCase = true) -> {
                            map["package"] = screenshotTestPackage
                            map["additionalTestOutputDir"] = screenshotDeviceOutputDir
                        }
                        taskName.contains("tablet10PixelTabletApi34", ignoreCase = true) -> {
                            map["package"] = screenshotTestPackage
                            map["additionalTestOutputDir"] = screenshotDeviceOutputDir
                        }
                        taskName.contains("pixel2api33", ignoreCase = true) -> {
                            map["notPackage"] = screenshotTestPackage
                            map["additionalTestOutputDir"] = smokeDeviceOutputDir
                        }
                    }
                }
                map
            },
        )
    }
}

tasks.register<Copy>("generateGmdCoverage") {
    group = "Verification"
    description = "Runs GMD tests and copies the coverage file to the baseline directory."

    // Coverage is always collected from the debug variant of the smoke device
    // regardless of the current -PscreenshotBuild setting.
    val smokeTask = "pixel2api33DebugAndroidTest"
    dependsOn(smokeTask)

    from(layout.buildDirectory.dir("outputs/managed_device_code_coverage/debug/pixel2api33")) {
        include("coverage.ec")
    }
    into(layout.projectDirectory.dir("coverage-baselines"))
    rename { "gmd_smoke.ec" }

    includeEmptyDirs = false
    duplicatesStrategy = DuplicatesStrategy.FAIL

    doLast {
        val destFile = layout.projectDirectory.file("coverage-baselines/gmd_smoke.ec").asFile
        if (!destFile.exists()) {
            throw GradleException("Failed to generate GMD coverage. Expected file not found: $destFile")
        }
    }
}

val storeScreenshotFiles =
    listOf(
        "screen_01_vehicle_list.png",
        "screen_02_vehicle_detail.png",
        "screen_03_add_record.png",
        "screen_04_categories.png",
        "screen_05_settings_privacy.png",
        "screen_06_paywall_dialog.png",
    )
val storeScreenshotsRoot = rootProject.layout.projectDirectory.dir("distribution/store-assets/screenshots")
val storeAdditionalOutputRoot = layout.buildDirectory.dir("outputs/managed_device_android_test_additional_output/$testVariant")

fun registerStoreScreenshotCopy(
    taskPrefix: String,
    deviceTaskName: String,
    outputDirectory: String,
    previousCopyTask: TaskProvider<out Task>? = null,
): TaskProvider<Copy> {
    tasks.named<Task>(deviceTaskName).configure {
        previousCopyTask?.let { mustRunAfter(it) }
        // We inject a doFirst block to wipe the additional output root before every run.
        // This ensures a clean state for the subsequent copy task, but has the side-effect
        // of clearing previous outputs even when running a single device test for debugging.
        doFirst {
            delete(storeAdditionalOutputRoot)
        }
    }

    return tasks.register<Copy>("copy${taskPrefix}StoreScreenshots") {
        previousCopyTask?.let { dependsOn(it) }
        dependsOn(deviceTaskName)
        into(storeScreenshotsRoot.dir(outputDirectory))
        includeEmptyDirs = false
        duplicatesStrategy = DuplicatesStrategy.FAIL

        doFirst {
            delete(storeScreenshotsRoot.dir(outputDirectory))
        }

        from(storeAdditionalOutputRoot) {
            // AGP sometimes aliases instrumentation arguments across GMD tasks,
            // leading to screenshots being saved in the wrong bucket subfolder on the host.
            // We use a wildcard to grab any screenshot found in the device's output root.
            include("**/$outputDirectory/screen_*.png")
            eachFile {
                relativePath = RelativePath(true, name)
            }
        }

        doLast {
            val missing =
                storeScreenshotFiles.mapNotNull { fileName ->
                    val file = storeScreenshotsRoot.file("$outputDirectory/$fileName").asFile
                    if (file.exists()) null else file
                }
            if (missing.isNotEmpty()) {
                throw GradleException(
                    "Missing generated store screenshots for $outputDirectory:\n" +
                        missing.joinToString(separator = "\n") { "- ${it.path}" },
                )
            }
        }
    }
}

afterEvaluate {
    val copyPhoneStoreScreenshots =
        registerStoreScreenshotCopy(
            taskPrefix = "Phone",
            deviceTaskName = "phonePixel6api34${testVariantCap}AndroidTest",
            outputDirectory = "phone",
        )
    val copyTablet7StoreScreenshots =
        registerStoreScreenshotCopy(
            taskPrefix = "Tablet7",
            deviceTaskName = "tablet7Nexus7api34${testVariantCap}AndroidTest",
            outputDirectory = "tablet-7",
            previousCopyTask = copyPhoneStoreScreenshots,
        )
    val copyTablet10StoreScreenshots =
        registerStoreScreenshotCopy(
            taskPrefix = "Tablet10",
            deviceTaskName = "tablet10PixelTabletApi34${testVariantCap}AndroidTest",
            outputDirectory = "tablet-10",
            previousCopyTask = copyTablet7StoreScreenshots,
        )

    tasks.register("generateStoreScreenshots") {
        group = "Verification"
        description = "Runs store screenshot tests on managed devices and copies PNGs into distribution assets."

        dependsOn(copyTablet10StoreScreenshots)
    }
}

tasks.register<JacocoReport>("verifyWithCoverage") {
    group = "Verification"
    description = "Runs JVM unit tests and generates a combined coverage report using JaCoCo with the static GMD baseline."

    // Unit tests are variant-agnostic; we always use debug for coverage consistency
    dependsOn("testDebugUnitTest")

    reports {
        xml.required.set(true)
        html.required.set(true)
    }

    val classFilter =
        listOf(
            "**/R.class",
            "**/R$*.class",
            "**/BuildConfig.*",
            "**/Manifest*.*",
            "**/*Test*.*",
            "android/**/*.*",
            "**/*_ViewBinding*.*",
            "**/*_MembersInjector*.*",
            "**/*_Factory*.*",
            "**/*_Provide*Factory*.*",
            "**/*ExtensionsKt*.*",
            // ComposableSingletons are compiler-generated classes for @Composable lambdas
            // that are often static and difficult to cover in unit tests, creating noise.
            "**/*ComposableSingletons*.*",
        )

    val debugTree =
        fileTree(layout.buildDirectory.dir("tmp/kotlin-classes/debug")) {
            exclude(classFilter)
        }
    val javaTree =
        fileTree(layout.buildDirectory.dir("intermediates/javac/debug/classes")) {
            exclude(classFilter)
        }

    classDirectories.setFrom(files(debugTree, javaTree))
    sourceDirectories.setFrom(
        files(
            "src/main/java",
            "src/main/kotlin",
            "src/debug/java",
            "src/debug/kotlin",
        ),
    )

    // Collect all execution data from both unit tests and instrumented tests
    // Using specific roots to avoid implicit dependency issues with other tasks' outputs in the build folder
    val executionDataFiles =
        files(
            fileTree(layout.buildDirectory.dir("outputs/unit_test_code_coverage")) { include("**/*.exec") },
            layout.projectDirectory.file("coverage-baselines/gmd_smoke.ec"),
            fileTree(layout.buildDirectory.dir("jacoco")) { include("**/*.exec") },
        )

    executionData.setFrom(executionDataFiles)

    doFirst {
        val lfsBaseline = layout.projectDirectory.file("coverage-baselines/gmd_smoke.ec").asFile
        if (!lfsBaseline.exists()) {
            throw GradleException(
                "Required coverage baseline is missing: ${lfsBaseline.path}. " +
                    "Run 'git lfs pull' to fetch tracked baselines or './gradlew generateGmdCoverage' to regenerate it.",
            )
        }
        if (lfsBaseline.length() < 1000) {
            val header = lfsBaseline.inputStream().use { it.readNBytes(100) }.toString(Charsets.UTF_8)
            if (header.startsWith("version https://git-lfs.github.com/spec/v1")) {
                throw GradleException("Coverage baseline is a Git LFS pointer. Please run 'git lfs pull' to fetch the actual file.")
            }
        }

        val existingFiles = executionDataFiles.files.filter { it.exists() }
        val dynamicExecutionFiles = existingFiles.filter { it.name != "gmd_smoke.ec" }
        if (dynamicExecutionFiles.isEmpty()) {
            throw GradleException(
                "No dynamic JaCoCo execution data files were found. " +
                    "Ensure tests were executed with coverage enabled (e.g., via -Pcoverage).\n" +
                    "Searched in:\n" +
                    "- ${layout.buildDirectory.dir("outputs/unit_test_code_coverage").get()}\n" +
                    "- ${layout.buildDirectory.dir("jacoco").get()}",
            )
        }
    }
}

tasks.withType<Test>().configureEach {
    if (isCoverageEnabled) {
        configure<JacocoTaskExtension> {
            isEnabled = true
            // Required for Robolectric
            isIncludeNoLocationClasses = true
            excludes = listOf("jdk.internal.*")
        }
    } else {
        configure<JacocoTaskExtension> { isEnabled = false }
    }

    val defaultForks = (Runtime.getRuntime().availableProcessors() / 4).coerceAtLeast(2)
    maxParallelForks = (project.findProperty("testForks") as String?)?.toInt() ?: defaultForks
    forkEvery = 0L

    minHeapSize = "512m"
    maxHeapSize = "1536m"
    jvmArgs =
        listOf(
            "-XX:+UseG1GC",
            "-XX:MaxMetaspaceSize=512m",
            "-Dfile.encoding=UTF-8",
        )

    systemProperty("robolectric.graphicsMode", "NATIVE")
    // Exclude tests tagged @Category(LocalOnly::class) from automated Gradle runs.
    // These tests are intended to be run directly from the IDE.
    // Note: This project uses JUnit 4 exclusively. If a Jupiter runner is ever added,
    // useJUnit { } will need to be scoped to only JUnit 4 tasks to avoid overriding
    // Jupiter configuration.
    if (name.contains("Release")) {
        enabled = false
    }
    useJUnit {
        excludeCategories("com.yorvana.LocalOnly")
    }

    reports {
        html.required.set(System.getenv("CI") != null)
        junitXml.required.set(true)
    }
}

dependencies {
    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.activity.compose)
    implementation(libs.androidx.documentfile)
    implementation(libs.sentry.android)
    implementation(platform(libs.compose.bom))
    implementation(libs.compose.ui)
    implementation(libs.compose.ui.tooling.preview)
    implementation(libs.compose.material3)
    implementation(libs.compose.material.icons.extended)
    implementation(libs.navigation3.runtime)
    implementation(libs.navigation3.ui)
    implementation(libs.lifecycle.viewmodel.compose)
    implementation(libs.lifecycle.viewmodel.savedstate)


Output:
Chunk ID: 188336
Wall time: 0.1191 seconds
Process exited with code 0
Original token count: 340
Output:
    implementation(libs.lifecycle.viewmodel.savedstate)
    implementation(libs.lifecycle.runtime.compose)
    implementation(libs.datastore.preferences)
    implementation(libs.kotlinx.serialization.json)
    implementation(libs.kotlinx.coroutines.android)
    implementation(libs.coil.compose)
    implementation(libs.google.billing)

    debugImplementation(libs.compose.ui.tooling)
    debugImplementation(libs.compose.ui.test.manifest)

    testImplementation(libs.junit)
    testImplementation(libs.kotlinx.coroutines.test)
    testImplementation(libs.mockk)
    testImplementation(libs.truth)
    testImplementation(libs.turbine)
    testImplementation(libs.robolectric)
    testImplementation(libs.compose.ui.test)
    testImplementation(libs.androidx.test.ext)
    testImplementation(libs.roborazzi)
    testImplementation(libs.roborazzi.compose)

    androidTestImplementation(platform(libs.compose.bom))
    androidTestImplementation(libs.androidx.test.ext)
    androidTestImplementation(libs.compose.ui.test)
    androidTestImplementation(libs.mockk.android)
    androidTestImplementation(libs.truth)
    androidTestImplementation(libs.turbine)
    androidTestImplementation(libs.roborazzi)
    androidTestImplementation(libs.roborazzi.compose)
    androidTestImplementation(libs.uiautomator)
    androidTestImplementation(libs.espresso.intents)
}


The build already uses a reflective `afterEvaluate` hook to inject per-GMD instrumentation arguments, so the plan should reuse that rather than introducing a separate mechanism for smoke filtering. I’m checking the current androidTest helpers and workflows now, especially to see whether existing smoke tests are annotated or only package-filtered.


find app/src/androidTest/java/com/yorvana -type f | sort


find .github/workflows -maxdepth 1 -type f -print | sort


find app/src/androidTest/assets -maxdepth 4 -type f | sort


Output:
Chunk ID: eb68c3
Wall time: 0.3393 seconds
Process exited with code 0
Original token count: 117
Output:
app/src/androidTest/java/com/yorvana/SmokeTest.kt
app/src/androidTest/java/com/yorvana/screenshots/StoreScreenshotBase.kt
app/src/androidTest/java/com/yorvana/screenshots/StoreScreenshotTest.kt
app/src/androidTest/java/com/yorvana/testsupport/ComposeWaitHelpers.kt
app/src/androidTest/java/com/yorvana/testsupport/DemoMode.kt
app/src/androidTest/java/com/yorvana/testsupport/ScreenshotConstants.kt
app/src/androidTest/java/com/yorvana/testsupport/VaultTestSetup.kt


Output:
Chunk ID: c589da
Wall time: 0.3503 seconds
Process exited with code 0
Original token count: 57
Output:
.github/workflows/baseline-reminder.yml
.github/workflows/ci-label.yml
.github/workflows/ci.yml
.github/workflows/deploy-play-store.yml
.github/workflows/semgrep.yml
.github/workflows/smoke-label.yml
.github/workflows/smoke.yml


Output:
Chunk ID: e2ea29
Wall time: 0.3680 seconds
Process exited with code 0
Original token count: 916
Output:
app/src/androidTest/java/com/yorvana/SmokeTest.kt:12:import androidx.compose.ui.test.junit4.createAndroidComposeRule
app/src/androidTest/java/com/yorvana/SmokeTest.kt:22:import androidx.test.ext.junit.runners.AndroidJUnit4
app/src/androidTest/java/com/yorvana/SmokeTest.kt:29:import com.yorvana.testsupport.installFreshVault
app/src/androidTest/java/com/yorvana/SmokeTest.kt:30:import com.yorvana.testsupport.resetYorvanaState
app/src/androidTest/java/com/yorvana/SmokeTest.kt:47:@RunWith(AndroidJUnit4::class)
app/src/androidTest/java/com/yorvana/SmokeTest.kt:50:    val composeTestRule = createAndroidComposeRule<MainActivity>()
app/src/androidTest/java/com/yorvana/SmokeTest.kt:54:        composeTestRule.resetYorvanaState()
app/src/androidTest/java/com/yorvana/SmokeTest.kt:59:        composeTestRule.resetYorvanaState()
app/src/androidTest/java/com/yorvana/SmokeTest.kt:70:        composeTestRule.installFreshVault()
app/src/androidTest/java/com/yorvana/SmokeTest.kt:119:    @Test
app/src/androidTest/java/com/yorvana/SmokeTest.kt:126:    @Test
app/src/androidTest/java/com/yorvana/SmokeTest.kt:140:    @Test
app/src/androidTest/java/com/yorvana/SmokeTest.kt:168:    @Test
app/src/androidTest/java/com/yorvana/SmokeTest.kt:186:    @Test
app/src/androidTest/java/com/yorvana/SmokeTest.kt:195:    @Test
app/src/androidTest/java/com/yorvana/SmokeTest.kt:213:    @Test
app/src/androidTest/java/com/yorvana/SmokeTest.kt:229:    @Test
app/src/androidTest/java/com/yorvana/SmokeTest.kt:248:    @Test
app/src/androidTest/java/com/yorvana/SmokeTest.kt:270:    @Test
app/src/androidTest/java/com/yorvana/SmokeTest.kt:305:    @Test
app/src/androidTest/java/com/yorvana/SmokeTest.kt:338:    @Test
app/src/androidTest/java/com/yorvana/SmokeTest.kt:377:    @Test
app/src/androidTest/java/com/yorvana/testsupport/VaultTestSetup.kt:12:fun MainActivityComposeRule.resetYorvanaState() {
app/src/androidTest/java/com/yorvana/testsupport/VaultTestSetup.kt:24:fun MainActivityComposeRule.installFreshVault(
app/src/androidTest/java/com/yorvana/screenshots/StoreScreenshotBase.kt:4:import androidx.compose.ui.test.junit4.createAndroidComposeRule
app/src/androidTest/java/com/yorvana/screenshots/StoreScreenshotBase.kt:9:import com.yorvana.testsupport.installFreshVault
app/src/androidTest/java/com/yorvana/screenshots/StoreScreenshotBase.kt:10:import com.yorvana.testsupport.resetYorvanaState
app/src/androidTest/java/com/yorvana/screenshots/StoreScreenshotBase.kt:18:    val composeTestRule = createAndroidComposeRule<MainActivity>()
app/src/androidTest/java/com/yorvana/screenshots/StoreScreenshotBase.kt:22:        composeTestRule.resetYorvanaState()
app/src/androidTest/java/com/yorvana/screenshots/StoreScreenshotBase.kt:25:        composeTestRule.installFreshVault(vaultName = "screenshots_vault") { vaultRoot ->
app/src/androidTest/java/com/yorvana/screenshots/StoreScreenshotBase.kt:33:        composeTestRule.resetYorvanaState()
app/src/androidTest/java/com/yorvana/screenshots/StoreScreenshotTest.kt:9:import androidx.test.ext.junit.runners.AndroidJUnit4
app/src/androidTest/java/com/yorvana/screenshots/StoreScreenshotTest.kt:20:@RunWith(AndroidJUnit4::class)
app/src/androidTest/java/com/yorvana/screenshots/StoreScreenshotTest.kt:23:    @Test
app/src/androidTest/java/com/yorvana/screenshots/StoreScreenshotTest.kt:30:    @Test
app/src/androidTest/java/com/yorvana/screenshots/StoreScreenshotTest.kt:38:    @Test
app/src/androidTest/java/com/yorvana/screenshots/StoreScreenshotTest.kt:47:    @Test
app/src/androidTest/java/com/yorvana/screenshots/StoreScreenshotTest.kt:60:    @Test
app/src/androidTest/java/com/yorvana/screenshots/StoreScreenshotTest.kt:72:    @Test


Output:
Chunk ID: 3d62b7
Wall time: 0.3788 seconds
Process exited with code 0
Original token count: 49
Output:
app/src/androidTest/assets/screenshots-fixture/categories.json
app/src/androidTest/assets/screenshots-fixture/vehicles/2019-honda-civic/vehicle.json
app/src/androidTest/assets/test_receipt.jpg


sed -n '1,220p' app/src/androidTest/java/com/yorvana/SmokeTest.kt


sed -n '1,220p' app/src/androidTest/java/com/yorvana/testsupport/VaultTestSetup.kt


sed -n '1,180p' app/src/androidTest/java/com/yorvana/testsupport/ComposeWaitHelpers.kt


sed -n '1,160p' .github/workflows/smoke-label.yml


Output:
Chunk ID: 763dd0
Wall time: 0.3531 seconds
Process exited with code 0
Original token count: 2116
Output:
package com.yorvana

import android.util.Log
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasStateDescription
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeLeft
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.yorvana.data.model.Vehicle
import com.yorvana.data.preferences.DebugBillingOverrideMode
import com.yorvana.testsupport.awaitTagGone
import com.yorvana.testsupport.awaitTagVisible
import com.yorvana.testsupport.awaitText
import com.yorvana.testsupport.installFreshVault
import com.yorvana.testsupport.resetYorvanaState
import com.yorvana.ui.TestTags
import com.yorvana.util.DateFormatter
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.util.UUID

/**
 * Smoke tests for Yorvana.
 *
 * These tests run on a real Android device (or GMD) and cover the primary user journeys
 * including filesystem access (SAF), camera integration, and the paywall.
 */
@RunWith(AndroidJUnit4::class)
class SmokeTest {
    @get:Rule
    val composeTestRule = createAndroidComposeRule<MainActivity>()

    @Before
    fun setUp() {
        composeTestRule.resetYorvanaState()
    }

    @After
    fun tearDown() {
        composeTestRule.resetYorvanaState()
    }

    private fun step(
        name: String,
        description: String,
    ) {
        Log.d("SmokeTest", "STEP: $name - $description")
    }

    private fun configureVaultAndNavigateToGarage() {
        composeTestRule.installFreshVault()
    }

    private fun navigateToAddVehicle() {
        composeTestRule.onNodeWithTag(TestTags.ADD_VEHICLE_FAB).performClick()
        composeTestRule.waitForIdle()
    }

    private fun navigateToSettings() {
        composeTestRule.awaitTagVisible(TestTags.SETTINGS_BUTTON)
        composeTestRule.onNodeWithTag(TestTags.SETTINGS_BUTTON).performClick()
        composeTestRule.waitForIdle()
    }

    private fun assertScreenTitle(titleResId: Int) {
        val expectedTitle = composeTestRule.activity.getString(titleResId)
        composeTestRule.onNodeWithText(expectedTitle).assertIsDisplayed()
    }

    private fun setBillingOverride(mode: DebugBillingOverrideMode) {
        step("setBillingOverride", "setting mode to $mode")
        val app = composeTestRule.activity.application as YorvanaApplication
        runBlocking {
            app.debugBillingOverride?.setMode(mode)
        }
        composeTestRule.waitForIdle()
    }

    /**
     * Seeds a vehicle directly via repository to bypass the "Add Vehicle" UI gate.
     * Note: Does not populate denormalized fields (recordCount, lastServiceDate)
     * as they are not needed for these smoke scenarios.
     */
    private fun addVehicleForTesting(nickname: String) {
        step("addVehicleForTesting", "adding $nickname")
        val app = composeTestRule.activity.application as YorvanaApplication
        val vehicle =
            Vehicle(
                id = UUID.randomUUID().toString(),
                nickname = nickname,
                createdAt = DateFormatter.nowIso(),
            )
        runBlocking {
            app.vehicleRepository.saveVehicle(vehicle)
        }
        composeTestRule.waitForIdle()
        composeTestRule.awaitText(nickname)
    }

    @Test
    fun s01_firstLaunch_showsWelcome() {
        step("S01", "verifying welcome screen")
        composeTestRule.onNodeWithText(composeTestRule.activity.getString(R.string.setup_title)).assertIsDisplayed()
        composeTestRule.onNodeWithText(composeTestRule.activity.getString(R.string.setup_choose_folder)).assertIsDisplayed()
    }

    @Test
    fun s03_addVehicle_savesCorrectly() {
        configureVaultAndNavigateToGarage()
        step("S03", "adding new vehicle")
        navigateToAddVehicle()

        composeTestRule.onNodeWithTag(TestTags.VEHICLE_NICKNAME_FIELD).performTextInput("Test Car")
        composeTestRule.onNodeWithTag(TestTags.SAVE_BUTTON).performClick()

        step("S03", "verifying vehicle in list")
        composeTestRule.awaitText("Test Car")
        composeTestRule.onNodeWithText("Test Car").assertIsDisplayed()
    }

    @Test
    fun s05_addRecord_savesCorrectly() {
        configureVaultAndNavigateToGarage()
        addVehicleForTesting("Record Car")

        step("S05", "navigating to vehicle detail")
        composeTestRule
            .onNode(hasText("Record Car") and hasClickAction())
            .performScrollTo()
            .performClick()
        composeTestRule.awaitTagVisible(TestTags.ADD_RECORD_FAB)

        step("S05", "adding new record")
        composeTestRule.onNodeWithTag(TestTags.ADD_RECORD_FAB).performClick()
        composeTestRule.awaitTagVisible(TestTags.RECORD_ODOMETER_FIELD)

        composeTestRule.onNodeWithTag(TestTags.RECORD_ODOMETER_FIELD).performTextInput("12345")
        composeTestRule.onNodeWithTag(TestTags.PERFORMER_FIELD).performScrollTo().performTextInput("Self")
        composeTestRule.waitForIdle()
        composeTestRule.onNodeWithTag(TestTags.SAVE_BUTTON).performClick()

        step("S05", "verifying record in list")
        composeTestRule.waitUntil(15000) {
            composeTestRule.onAllNodesWithText("12345 km", substring = true).fetchSemanticsNodes().isNotEmpty()
        }
        composeTestRule.onNodeWithText("12345 km", substring = true).assertIsDisplayed()
    }

    @Test
    fun s07_addAttachment_showsPickerButton() {
        // This test is currently flaky on GMD due to intent stubbing issues with SAF
        configureVaultAndNavigateToGarage()
        addVehicleForTesting("Attachment Car")

        step("S07", "navigating to add record")
        composeTestRule
            .onNode(hasText("Attachment Car") and hasClickAction())
            .performScrollTo()
            .performClick()
        composeTestRule.awaitTagVisible(TestTags.ADD_RECORD_FAB)
        composeTestRule.onNodeWithTag(TestTags.ADD_RECORD_FAB).performClick()

        step("S07", "verifying attachment button is present")
        composeTestRule.onNodeWithText(composeTestRule.activity.getString(R.string.attachment_pick_file)).assertIsDisplayed()
    }

    @Test
    fun s11_navigateToSettings_works() {
        configureVaultAndNavigateToGarage()
        step("S11", "navigating to settings")
        navigateToSettings()
        assertScreenTitle(R.string.settings_title)
        composeTestRule.onNodeWithText(composeTestRule.activity.getString(R.string.settings_odometer_unit)).assertIsDisplayed()
    }

    @Test
    fun s13_editVehicle_updatesList() {
        configureVaultAndNavigateToGarage()
        addVehicleForTesting("Old Name")

        step("S13", "editing vehicle")
        composeTestRule.onNodeWithText("Old Name").performScrollTo().performClick()
        composeTestRule.onNodeWithTag(TestTags.MORE_OPTIONS_BUTTON).performClick()
        composeTestRule.onNodeWithText(composeTestRule.activity.getString(R.string.vehicle_edit)).performClick()

        composeTestRule.onNodeWithTag(TestTags.VEHICLE_NICKNAME_FIELD).performTextReplacement("New Name")
        composeTestRule.onNodeWithTag(TestTags.SAVE_BUTTON).performClick()

        step("S13", "verifying update")
        composeTestRule.awaitText("New Name")
        composeTestRule.onNodeWithText("New Name").assertIsDisplayed()
    }

    @Test
    fun s16_deleteVehicle_returnsToGarage() {
        configureVaultAndNavigateToGarage()
        addVehicleForTesting("To Delete")

        step("S16", "deleting vehicle")
        composeTestRule.onNodeWithText("To Delete").performScrollTo().performClick()
        composeTestRule.onNodeWithTag(TestTags.MORE_OPTIONS_BUTTON).performClick()


Output:
Chunk ID: fba391
Wall time: 0.3550 seconds
Process exited with code 0
Original token count: 362
Output:
package com.yorvana.testsupport

import android.net.Uri
import com.yorvana.R
import com.yorvana.YorvanaApplication
import com.yorvana.data.preferences.CURRENT_CONSENT_VERSION
import com.yorvana.data.preferences.DebugBillingOverrideMode
import com.yorvana.util.CrashReporter
import kotlinx.coroutines.runBlocking
import java.io.File

fun MainActivityComposeRule.resetYorvanaState() {
    val app = activity.application as YorvanaApplication
    runBlocking {
        app.vaultStorage.clearVault()
        app.preferences.setVaultUri(null)
        app.preferences.setCrashReportingConsentPromptShownVersion(0)
        app.preferences.setCrashReportingEnabled(false)
        CrashReporter.clear(app)
        app.debugBillingOverride?.setMode(DebugBillingOverrideMode.NONE)
    }
}

fun MainActivityComposeRule.installFreshVault(
    vaultName: String = "test_vault",
    seed: ((File) -> Unit)? = null,
) {
    val app = activity.application as YorvanaApplication
    val vaultFile = File(activity.filesDir, vaultName)
    vaultFile.deleteRecursively()
    vaultFile.mkdirs()
    seed?.invoke(vaultFile)

    val uri = Uri.fromFile(vaultFile)
    runBlocking {
        app.preferences.setCrashReportingConsentPromptShownVersion(CURRENT_CONSENT_VERSION)
        app.preferences.setVaultUri(uri.toString())
        app.applyVaultUri(uri.toString())
    }
    waitForIdle()
    awaitText(activity.getString(R.string.my_garage), timeoutMillis = 15_000)
}


Output:
Chunk ID: aa8588
Wall time: 0.3625 seconds
Process exited with code 0
Original token count: 265
Output:
package com.yorvana.testsupport

import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import com.yorvana.MainActivity

typealias MainActivityComposeRule = AndroidComposeTestRule<ActivityScenarioRule<MainActivity>, MainActivity>

fun MainActivityComposeRule.awaitTagVisible(
    tag: String,
    timeoutMillis: Long = 15_000,
) {
    waitUntil(timeoutMillis) {
        onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
    }
}

fun MainActivityComposeRule.awaitTagGone(
    tag: String,
    timeoutMillis: Long = 15_000,
) {
    waitUntil(timeoutMillis) {
        onAllNodesWithTag(tag).fetchSemanticsNodes().isEmpty()
    }
}

fun MainActivityComposeRule.awaitText(
    text: String,
    timeoutMillis: Long = 15_000,
    substring: Boolean = false,
) {
    waitUntil(timeoutMillis) {
        onAllNodesWithText(text, substring = substring).fetchSemanticsNodes().isNotEmpty()
    }
}


Output:
Chunk ID: 1b0bc4
Wall time: 0.3666 seconds
Process exited with code 0
Original token count: 132
Output:
# Manual label trigger for smoke tests.
#
# This workflow is a lightweight, one-shot manual trigger. It consumes 0 runner
# minutes for unrelated PRs or unrelated labels because the job guard (if:) is
# evaluated before a runner is provisioned. It only runs when the 'smoke' label
# is explicitly applied.
name: Smoke (Manual Label)
permissions:
  contents: read
  pull-requests: read

on:
  pull_request:
    types: [labeled]

jobs:
  smoke:
    if: github.event.label.name == 'smoke'
    uses: ./.github/workflows/smoke.yml


rg "GradleBuild|instrumentationRunnerArguments|notAnnotation|Quarantined|Smoke" -n .


find app/src/test/java -maxdepth 4 -type f | sort | head -80


Output:
Chunk ID: 2ac2b4
Wall time: 0.3584 seconds
Process exited with code 0
Original token count: 77
Output:
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
app/build
/captures
.externalNativeBuild
.cxx
.kotlin
.claude
.gemini/
sample-vault/Yorvana/
sample-vault/Yorvana.zip

# JVM crash logs
hs_err_pid*.log

# Release signing secrets
app/upload.jks
app/keystore.properties
commit_msg*.txt
.antigravitycli/


Output:
Chunk ID: 14da90
Wall time: 0.3837 seconds
Process exited with code 0
Original token count: 2802
Output:
./TESTING_SETUP.md:31:- **Smoke device**: Pixel 2, API Level 33.
./TESTING_SETUP.md:190:## 9. Smoke Test Suite (GMD)
./TESTING_SETUP.md:192: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).
./TESTING_SETUP.md:195:Smoke tests catch issues that only surface on a real Android environment:
./TESTING_SETUP.md:208:### Running Smoke Tests
./TESTING_SETUP.md:231: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.
./TESTING_SETUP.md:234: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:
./TESTING_SETUP.md:237:# Generate combined report (JVM tests + Smoke test baseline)
./TESTING_SETUP.md:259:**Smoke tests fail with "Activity opened at unexpected destination"**
./docs/dependabot-workflow.md:36:#### D. Run Smoke Tests (with GPU Acceleration)
./docs/dependabot-workflow.md:69:- **GPU Acceleration:** Smoke tests are significantly faster with `-Pandroid.testoptions.manageddevices.emulator.gpu=host`.
./docs/plan-regression-tests.md:13:- **Flake budget: zero on PR-gated tier, ≤1% on nightly** — quarantine, don't ignore. Flaky tests get a `@Quarantined` marker annotation; PR-gated and core regression tasks pass `notAnnotation=com.yorvana.testsupport.tiers.Quarantined` so these tests are excluded from gating, while the nightly `regressionFullCheck` task does *not* set `notAnnotation` so they still execute and report.
./docs/plan-regression-tests.md:30:The project is on JUnit 4 with `AndroidJUnitRunner` (see `app/build.gradle.kts`), which does **not** support a `tag` filter — it filters via `annotation` / `notAnnotation` / `package` / `class` arguments. Define a small set of runtime-retained marker annotations in `app/src/androidTest/.../testsupport/tiers/` (`Smoke`, `Regression`, `RegressionFull`, `BillingSandbox`, `Quarantined`) and apply them at the test-method or class level.
./docs/plan-regression-tests.md:34:| Smoke | `@Smoke` *(existing scenarios re-annotated)* | PR (label-gated, unchanged) | <2 min |
./docs/plan-regression-tests.md:40:New Gradle tasks (marker classes live in `com.yorvana.testsupport.tiers.*`; AndroidJUnitRunner's `annotation` / `notAnnotation` arguments need the fully-qualified class name, so the `-P...` examples below are spelled in full for copy-paste fidelity):
./docs/plan-regression-tests.md:42:- **`regressionCheck`** — PR/core tier. Single `pixel2api33DebugAndroidTest` invocation with `-Pandroid.testInstrumentationRunnerArguments.annotation=com.yorvana.testsupport.tiers.Regression -Pandroid.testInstrumentationRunnerArguments.notAnnotation=com.yorvana.testsupport.tiers.Quarantined`. Excludes `RegressionFull` and `BillingSandbox` (neither carries `@Regression`).
./docs/plan-regression-tests.md:43:- **`regressionFullCheck`** — Nightly. Aggregator task that depends on three sibling tasks, plus the macrobenchmark module. It does *not* `dependsOn(regressionCheck)`, because that task's `notAnnotation=...Quarantined` filter would otherwise hide quarantined core tests from nightly too (contradicting §1's quarantine policy).
./docs/plan-regression-tests.md:45:    - **(a) `GradleBuild` child invocations** — register three `GradleBuild` tasks under `regressionFullCheck`, each setting `startParameter.projectProperties` for its own runner-argument set and `tasks = ["pixel2api33DebugAndroidTest"]` (or the Play device task for sandbox). Each child invocation gets a fresh Gradle build, so `-P` values do not collide.
./docs/plan-regression-tests.md:46:    - **(b) Variant- or device-scoped tasks** — declare extra GMD device entries (or a custom test-only variant) whose `instrumentationRunnerArguments` are set in `build.gradle.kts` rather than at the CLI, so each generated `*DebugAndroidTest` task carries its own baked-in filter. `regressionFullCheck` then plain-`dependsOn`s those tasks.
./docs/plan-regression-tests.md:48:    - `regressionFullNightlyCoreCheck` — `pixel2api33DebugAndroidTest` with `annotation=com.yorvana.testsupport.tiers.Regression` and **no** `notAnnotation`, so flaky `@Regression @Quarantined` tests still execute and report.
./docs/plan-regression-tests.md:51:  - None of the three set `notAnnotation`. The `:macrobenchmark` module's GMD-generated benchmark task (e.g. `:macrobenchmark:pixel2api33BenchmarkAndroidTest`, from the dedicated GMD entry registered in `:macrobenchmark` per Phase 6 and the §2 table) is wired as an additional `dependsOn` to keep "core + full + sandbox + benchmark" in one entry point. It is deliberately *not* `:macrobenchmark:connectedBenchmarkAndroidTest` — `connected*AndroidTest` targets ad-hoc currently-connected devices that CI doesn't have, so the nightly aggregate could otherwise pass without ever running the benchmark tier. (Separate tiers rather than a comma list because AndroidJUnitRunner's `annotation` argument takes a single class, and the sandbox tier needs a different device target anyway.)
./docs/plan-regression-tests.md:52:- **Smoke** — the existing PR-gated path. Today the `pixel2api33` device's `instrumentationRunnerArguments` in `app/build.gradle.kts` only sets `notPackage=com.yorvana.screenshots`, and `.github/workflows/smoke.yml` runs plain `./gradlew pixel2api33Check`. Once regression tests start landing in the same `app/src/androidTest` tree, that filter would sweep them into smoke and blow the <2 min budget. Phase 1 therefore must isolate smoke before any regression tests merge — either (i) add `notAnnotation=com.yorvana.testsupport.tiers.Regression,com.yorvana.testsupport.tiers.RegressionFull,com.yorvana.testsupport.tiers.BillingSandbox,com.yorvana.testsupport.tiers.Quarantined` to the smoke device's `instrumentationRunnerArguments` (keeps the existing `@Smoke` re-annotation as opt-in nice-to-have), or (ii) flip to a positive `annotation=com.yorvana.testsupport.tiers.Smoke` filter and make `@Smoke` annotation of existing smoke tests a hard prerequisite of Phase 1. Either way, the change lives in `app/build.gradle.kts` (smoke device's runner args) so `smoke.yml`'s `./gradlew pixel2api33Check` invocation stays unchanged.
./docs/plan-regression-tests.md:135:- Add custom marker annotations (`@Smoke`, `@Regression`, `@RegressionFull`, `@BillingSandbox`, `@Quarantined`) under `testsupport/tiers/`; wire `regressionCheck` and `regressionFullCheck` Gradle tasks per §2 — `regressionCheck` passes `-Pandroid.testInstrumentationRunnerArguments.annotation=com.yorvana.testsupport.tiers.Regression` *and* `-Pandroid.testInstrumentationRunnerArguments.notAnnotation=com.yorvana.testsupport.tiers.Quarantined` to the GMD task; `regressionFullCheck` aggregates three sibling tasks (`regressionFullNightlyCoreCheck`, `regressionFullExtendedCheck`, `regressionFullSandboxCheck`) — each carrying its own runner args via the §2 mechanism (`GradleBuild` child invocation or variant/device-scoped task) — none of which set `notAnnotation`, so quarantined tests run and report.
./docs/plan-regression-tests.md:136:- **Isolate the existing smoke task from regression tests before any `@Regression*` test merges**, per the §2 "Smoke" bullet. Concretely, update the `pixel2api33` device entry in `app/build.gradle.kts` so its `instrumentationRunnerArguments` either (i) adds `notAnnotation=com.yorvana.testsupport.tiers.Regression,com.yorvana.testsupport.tiers.RegressionFull,com.yorvana.testsupport.tiers.BillingSandbox,com.yorvana.testsupport.tiers.Quarantined` alongside the existing `notPackage=com.yorvana.screenshots`, or (ii) switches to a positive `annotation=com.yorvana.testsupport.tiers.Smoke` filter with `@Smoke` applied to every existing smoke test as a hard Phase 1 prerequisite. `.github/workflows/smoke.yml` keeps invoking `./gradlew pixel2api33Check` unchanged.
./docs/plan-smoke-tests.md:1:# GMD Smoke Test Scenarios (Phase 3)
./docs/plan-smoke-tests.md:7:Smoke tests are **not** intended to replace integration tests. They exist to catch issues that only surface on a real Android environment: actual Activity lifecycle, real filesystem access via SAF, real Compose rendering pipeline, and cross-screen navigation with real back-stack behavior.
./docs/plan-smoke-tests.md:15:- **Test file:** `app/src/androidTest/java/com/yorvana/SmokeTest.kt` — single canonical entry point (replaces the instrumented `app/src/androidTest/java/com/yorvana/MainActivityTest.kt`)
./docs/plan-smoke-tests.md:424:**Goal:** Set up the `SmokeTest.kt` file with all necessary test infrastructure before writing scenario logic.
./docs/plan-smoke-tests.md:426:1. **Rename entry point:** Rename `MainActivityTest.kt` to `SmokeTest.kt` (or create `SmokeTest.kt` and delete `MainActivityTest.kt`). Update the class name to `SmokeTest`. Keep the `@RunWith(AndroidJUnit4::class)` runner and `createAndroidComposeRule<MainActivity>()` rule.
./docs/plan-smoke-tests.md:436:**Deliverables:** `SmokeTest.kt` with infrastructure, helpers, fixtures, and cleanup — no scenario logic yet. `MainActivityTest.kt` removed.
./docs/plan-smoke-tests.md:521:5. **Commit:** Commit `SmokeTest.kt`, attachment fixtures, dependency changes, and the updated `gmd_smoke.ec` to Git LFS.
./docs/plan-paywall.md:31:| 6 | Smoke test coverage | `SmokeTest.kt` helpers (`setBillingOverride`, `addVehicleForTesting`); scenarios S18–S20; `gmd_smoke.ec` baseline refreshed; `docs/plan-smoke-tests.md` updated. |
./docs/plan-paywall.md:538:## Phase 6: Smoke Test Coverage
./docs/plan-paywall.md:546:**File:** `app/src/androidTest/java/com/yorvana/SmokeTest.kt`
./docs/plan-paywall.md:753:| `app/src/androidTest/java/com/yorvana/SmokeTest.kt` | 6 | Helpers + S18/S19/S20 |
./docs/plan-integration-tests-restructure.md:26:### 3. E2E Smoke Tests (GMD / Emulator)
./docs/plan-integration-tests-restructure.md:65:### Phase 3: Create GMD Smoke Test Suite
./docs/plan-integration-tests-restructure.md:67:1. **Consolidate Existing Smoke Coverage**: Review the existing instrumented smoke-style test at `app/src/androidTest/java/com/yorvana/MainActivityTest.kt` and convert it into the canonical smoke suite entry point. Prefer renaming/migrating it to `app/src/androidTest/java/com/yorvana/SmokeTest.kt`, or explicitly expand it in place if keeping the current filename is less disruptive, but do not leave both as overlapping smoke-test entry points.
./app/build.gradle.kts:211:        // We use reflection to reach into the internal testData property and its instrumentationRunnerArguments map.
./app/src/androidTest/java/com/yorvana/SmokeTest.kt:42: * Smoke tests for Yorvana.
./app/src/androidTest/java/com/yorvana/SmokeTest.kt:48:class SmokeTest {
./app/src/androidTest/java/com/yorvana/SmokeTest.kt:66:        Log.d("SmokeTest", "STEP: $name - $description")


Output:
Chunk ID: 6818c9
Wall time: 0.3698 seconds
Process exited with code 0
Original token count: 150
Output:
app/src/test/java/com/yorvana/LocalOnly.kt
app/src/test/java/com/yorvana/MainActivityTest.kt
app/src/test/java/com/yorvana/TestYorvanaApplication.kt
app/src/test/java/com/yorvana/YorvanaApplicationTest.kt
app/src/test/java/com/yorvana/domain/AppGateTest.kt
app/src/test/java/com/yorvana/util/CrashReporterTest.kt
app/src/test/java/com/yorvana/util/DateFormatterTest.kt
app/src/test/java/com/yorvana/util/FileProviderHelperTest.kt
app/src/test/java/com/yorvana/util/SentryWrapperTest.kt
app/src/test/java/com/yorvana/util/UuidGeneratorTest.kt
app/src/test/java/com/yorvana/util/VaultPathHelperTest.kt


Output:
Chunk ID: 919476
Wall time: 0.3855 seconds
Process exited with code 0
Original token count: 64
Output:
total 4
drwxr-xr-x. 1 Messier82 Messier82  70 May 21 16:07 .
drwxr-xr-x. 1 Messier82 Messier82  20 Apr 23 18:15 ..
drwxr-xr-x. 1 Messier82 Messier82  46 May 21 16:07 screenshots-fixture
-rw-r--r--. 1 Messier82 Messier82 148 Apr 23 18:15 test_receipt.jpg


sed -n '1,120p' TESTING_SETUP.md && sed -n '190,230p' TESTING_SETUP.md


Output:
Chunk ID: 40bf77
Wall time: 0.1272 seconds
Process exited with code 0
Original token count: 1810
Output:
# 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.

## 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"
```


<turn_aborted>
The user interrupted the previous turn on purpose. Any running unified exec processes may still be running in the background. If any tools/commands were aborted, they may have partially executed.
</turn_aborted>