From cbd14a2e7e09fa9a6c721ab26c46f57fd24e540a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20No=C3=A9=20N=C3=BA=C3=B1ez=20L=C3=B3pez?= Date: Fri, 21 Aug 2026 21:45:28 -0700 Subject: [PATCH 1/4] test: cubrir versionado OTA, mapeo WMO y back-off del MCU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La lógica pura estaba atrapada como private dentro de clases que arrastran Android (AndroidViewModel, Context), lo que obligaba a Robolectric para probar un par de funciones sin dependencias. Se extraen a funciones top-level internal en su propio módulo; ningún call site cambia. - isNewer: de OtaViewModel a OtaInfo.kt - wmoToCondition: de OpenMeteoWeatherDataSource a WeatherCondition.kt - reconnectDelayMs: de TwUtilMcuDataSource (estaba inline en el retryWhen) De paso corrige el back-off: attempt es Long y se narrowaba a Int antes de recortar, así que Long.MAX_VALUE.toInt() daba -1 y shl solo lee los 6 bits bajos del contador, produciendo un delay negativo que convertía la reconexión en un busy loop sobre el UART. Ahora se recorta como Long antes de bajar a Int. 63 tests nuevos: 18 -> 81. --- ota/build.gradle.kts | 7 +++ ota/src/main/kotlin/dev/helm/ota/OtaInfo.kt | 20 ++++++ .../main/kotlin/dev/helm/ota/OtaViewModel.kt | 18 ------ .../kotlin/dev/helm/ota/OtaVersionTest.kt | 61 +++++++++++++++++++ .../helm/sdk/OpenMeteoWeatherDataSource.kt | 12 ---- .../dev/helm/sdk/TwUtilMcuDataSource.kt | 9 ++- .../kotlin/dev/helm/sdk/WeatherCondition.kt | 15 +++++ .../dev/helm/sdk/ReconnectBackoffTest.kt | 46 ++++++++++++++ .../kotlin/dev/helm/sdk/WmoConditionTest.kt | 45 ++++++++++++++ 9 files changed, 202 insertions(+), 31 deletions(-) create mode 100644 ota/src/test/kotlin/dev/helm/ota/OtaVersionTest.kt create mode 100644 sdk/src/test/kotlin/dev/helm/sdk/ReconnectBackoffTest.kt create mode 100644 sdk/src/test/kotlin/dev/helm/sdk/WmoConditionTest.kt diff --git a/ota/build.gradle.kts b/ota/build.gradle.kts index 4bd3787..3f45fe9 100644 --- a/ota/build.gradle.kts +++ b/ota/build.gradle.kts @@ -12,10 +12,17 @@ android { targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { jvmTarget = "17" } + testOptions { + unitTests.all { it.useJUnitPlatform() } + } } dependencies { implementation(libs.androidx.core.ktx) implementation(libs.kotlinx.coroutines.android) implementation(libs.androidx.lifecycle.viewmodel.ktx) + + testImplementation(libs.junit5.api) + testImplementation(libs.junit5.params) + testRuntimeOnly(libs.junit5.engine) } diff --git a/ota/src/main/kotlin/dev/helm/ota/OtaInfo.kt b/ota/src/main/kotlin/dev/helm/ota/OtaInfo.kt index 4931e85..c525904 100644 --- a/ota/src/main/kotlin/dev/helm/ota/OtaInfo.kt +++ b/ota/src/main/kotlin/dev/helm/ota/OtaInfo.kt @@ -5,3 +5,23 @@ data class OtaInfo( val apkUrl: String, val changelog: String, ) + +// Semantic-ish version compare. Tolerates a leading "v", ignores pre-release ("-rc1") +// and build metadata ("+42") suffixes, and pads missing components with 0 so +// "1.2" and "1.2.0" compare equal. +internal fun isNewer(server: String, installed: String): Boolean { + fun String.components(): List { + val core = substringBefore('+').substringBefore('-').trimStart('v') + val parts = core.split('.').mapNotNull { it.toIntOrNull() } + return parts.ifEmpty { listOf(0) } + } + val s = server.components() + val c = installed.components() + for (i in 0 until maxOf(s.size, c.size)) { + val sv = s.getOrElse(i) { 0 } + val cv = c.getOrElse(i) { 0 } + if (sv > cv) return true + if (sv < cv) return false + } + return false +} diff --git a/ota/src/main/kotlin/dev/helm/ota/OtaViewModel.kt b/ota/src/main/kotlin/dev/helm/ota/OtaViewModel.kt index 24077dd..7cf2d5c 100644 --- a/ota/src/main/kotlin/dev/helm/ota/OtaViewModel.kt +++ b/ota/src/main/kotlin/dev/helm/ota/OtaViewModel.kt @@ -85,22 +85,4 @@ class OtaViewModel(application: Application) : AndroidViewModel(application) { fun reset() { _state.value = OtaState.Idle } - - private fun isNewer(server: String, installed: String): Boolean { - fun String.components(): List { - val core = substringBefore('+').substringBefore('-').trimStart('v') - val parts = core.split('.').mapNotNull { it.toIntOrNull() } - return parts.ifEmpty { listOf(0) } - } - val s = server.components() - val c = installed.components() - val len = maxOf(s.size, c.size) - for (i in 0 until len) { - val sv = s.getOrElse(i) { 0 } - val cv = c.getOrElse(i) { 0 } - if (sv > cv) return true - if (sv < cv) return false - } - return false - } } diff --git a/ota/src/test/kotlin/dev/helm/ota/OtaVersionTest.kt b/ota/src/test/kotlin/dev/helm/ota/OtaVersionTest.kt new file mode 100644 index 0000000..e5009b8 --- /dev/null +++ b/ota/src/test/kotlin/dev/helm/ota/OtaVersionTest.kt @@ -0,0 +1,61 @@ +package dev.helm.ota + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource + +class OtaVersionTest { + + @ParameterizedTest(name = "server {0} is newer than installed {1}") + @CsvSource( + "0.2.0, 0.1.0", + "1.0.0, 0.9.9", + "0.1.1, 0.1.0", + "0.10.0, 0.9.0", // numeric compare, not lexicographic + "1.1, 1.0.9", // missing component pads to 0, compared left to right + ) + fun `newer server version triggers update`(server: String, installed: String) { + assertTrue(isNewer(server, installed)) + } + + @ParameterizedTest(name = "server {0} is NOT newer than installed {1}") + @CsvSource( + "0.1.0, 0.1.0", + "0.1.0, 0.2.0", + "0.9.0, 0.10.0", + "1.0, 1.0.0", // equal once padded + "0.1.0, 1.0.0", // downgrade must never be offered + ) + fun `same or older server version does not trigger update`(server: String, installed: String) { + assertFalse(isNewer(server, installed)) + } + + @Test + fun `leading v is stripped from the release tag`() { + assertTrue(isNewer("v0.2.0", "0.1.0")) + assertFalse(isNewer("v0.1.0", "0.1.0")) + } + + @Test + fun `pre-release and build metadata suffixes are ignored`() { + assertFalse(isNewer("0.1.0-rc1", "0.1.0")) + assertFalse(isNewer("0.1.0+42", "0.1.0")) + assertTrue(isNewer("0.2.0-rc1", "0.1.0")) + } + + @Test + fun `unparseable versions never trigger an update`() { + // A garbage tag_name must not push an APK onto the head unit. + assertFalse(isNewer("", "0.1.0")) + assertFalse(isNewer("latest", "0.1.0")) + assertFalse(isNewer("nightly", "0.1.0")) + } + + @Test + fun `unparseable installed version accepts any real release`() { + // versionName came back empty from PackageManager — treated as 0. + assertTrue(isNewer("0.1.0", "")) + } +} diff --git a/sdk/src/main/kotlin/dev/helm/sdk/OpenMeteoWeatherDataSource.kt b/sdk/src/main/kotlin/dev/helm/sdk/OpenMeteoWeatherDataSource.kt index a04d8e5..25ab3a7 100644 --- a/sdk/src/main/kotlin/dev/helm/sdk/OpenMeteoWeatherDataSource.kt +++ b/sdk/src/main/kotlin/dev/helm/sdk/OpenMeteoWeatherDataSource.kt @@ -110,16 +110,4 @@ class OpenMeteoWeatherDataSource( } } } - - private fun wmoToCondition(code: Int): WeatherCondition = when (code) { - 0 -> WeatherCondition.CLEAR - in 1..3 -> WeatherCondition.CLOUDY - 45, 48 -> WeatherCondition.HAZE - in 51..67 -> WeatherCondition.RAIN - in 71..77 -> WeatherCondition.SNOW - in 80..82 -> WeatherCondition.RAIN - 85, 86 -> WeatherCondition.SNOW - in 95..99 -> WeatherCondition.THUNDERSTORM - else -> WeatherCondition.CLOUDY - } } diff --git a/sdk/src/main/kotlin/dev/helm/sdk/TwUtilMcuDataSource.kt b/sdk/src/main/kotlin/dev/helm/sdk/TwUtilMcuDataSource.kt index 3e47dce..22d3411 100644 --- a/sdk/src/main/kotlin/dev/helm/sdk/TwUtilMcuDataSource.kt +++ b/sdk/src/main/kotlin/dev/helm/sdk/TwUtilMcuDataSource.kt @@ -12,10 +12,17 @@ internal class TwUtilMcuDataSource : McuDataSource { override fun events(): Flow = adapter.events() .retryWhen { _, attempt -> - delay(minOf(500L shl attempt.toInt().coerceAtMost(6), 30_000L)) + delay(reconnectDelayMs(attempt)) true } override suspend fun send(code: Int, arg1: Int, arg2: Int, data: ByteArray): Result = adapter.send(code, arg1, arg2, data) } + +// Exponential back-off for MCU reconnection: 500 ms, 1 s, 2 s, 4 s, 8 s, 16 s, then 30 s +// forever. Clamp the attempt as a Long *before* narrowing to Int: Long.MAX_VALUE.toInt() +// is -1, and `shl` only reads the low 6 bits of the count, so narrowing first turns a very +// long outage into a negative delay and busy-loops the UART. +internal fun reconnectDelayMs(attempt: Long): Long = + minOf(500L shl attempt.coerceIn(0L, 6L).toInt(), 30_000L) diff --git a/sdk/src/main/kotlin/dev/helm/sdk/WeatherCondition.kt b/sdk/src/main/kotlin/dev/helm/sdk/WeatherCondition.kt index 90adc0b..237a2ae 100644 --- a/sdk/src/main/kotlin/dev/helm/sdk/WeatherCondition.kt +++ b/sdk/src/main/kotlin/dev/helm/sdk/WeatherCondition.kt @@ -8,3 +8,18 @@ enum class WeatherCondition(val label: String) { SNOW("Snow"), HAZE("Haze"), } + +// WMO weather interpretation codes → Helm's 6 icon buckets. +// https://open-meteo.com/en/docs — unknown codes fall back to CLOUDY so the +// widget always renders something rather than blanking out. +internal fun wmoToCondition(code: Int): WeatherCondition = when (code) { + 0 -> WeatherCondition.CLEAR + in 1..3 -> WeatherCondition.CLOUDY + 45, 48 -> WeatherCondition.HAZE + in 51..67 -> WeatherCondition.RAIN + in 71..77 -> WeatherCondition.SNOW + in 80..82 -> WeatherCondition.RAIN + 85, 86 -> WeatherCondition.SNOW + in 95..99 -> WeatherCondition.THUNDERSTORM + else -> WeatherCondition.CLOUDY +} diff --git a/sdk/src/test/kotlin/dev/helm/sdk/ReconnectBackoffTest.kt b/sdk/src/test/kotlin/dev/helm/sdk/ReconnectBackoffTest.kt new file mode 100644 index 0000000..596017f --- /dev/null +++ b/sdk/src/test/kotlin/dev/helm/sdk/ReconnectBackoffTest.kt @@ -0,0 +1,46 @@ +package dev.helm.sdk + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource + +class ReconnectBackoffTest { + + @ParameterizedTest(name = "attempt {0} waits {1} ms") + @CsvSource( + "0, 500", + "1, 1000", + "2, 2000", + "3, 4000", + "4, 8000", + "5, 16000", + "6, 30000", // 32000 clamped to the 30 s ceiling + ) + fun `back-off follows the documented schedule`(attempt: Long, expectedMs: Long) { + assertEquals(expectedMs, reconnectDelayMs(attempt)) + } + + @ParameterizedTest(name = "attempt {0} stays at the 30 s ceiling") + @ValueSource(longs = [7, 8, 20, 63, 64, 1_000, Int.MAX_VALUE.toLong(), Long.MAX_VALUE]) + fun `long outages stay at the ceiling`(attempt: Long) { + assertEquals(30_000L, reconnectDelayMs(attempt)) + } + + @Test + fun `delay is never zero or negative`() { + // A non-positive delay would turn reconnection into a busy loop on the UART. + listOf(0L, 6L, 7L, 64L, Long.MAX_VALUE).forEach { + assertTrue(reconnectDelayMs(it) > 0, "attempt $it produced a non-positive delay") + } + } + + @Test + fun `back-off is monotonic up to the ceiling`() { + val delays = (0L..10L).map { reconnectDelayMs(it) } + delays.zipWithNext { a, b -> assertTrue(b >= a, "delay decreased: $a then $b") } + assertEquals(30_000L, delays.last()) + } +} diff --git a/sdk/src/test/kotlin/dev/helm/sdk/WmoConditionTest.kt b/sdk/src/test/kotlin/dev/helm/sdk/WmoConditionTest.kt new file mode 100644 index 0000000..cd562af --- /dev/null +++ b/sdk/src/test/kotlin/dev/helm/sdk/WmoConditionTest.kt @@ -0,0 +1,45 @@ +package dev.helm.sdk + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource + +class WmoConditionTest { + + @ParameterizedTest(name = "WMO {0} maps to {1}") + @CsvSource( + "0, CLEAR", + "1, CLOUDY", "2, CLOUDY", "3, CLOUDY", + "45, HAZE", "48, HAZE", + "51, RAIN", "61, RAIN", "67, RAIN", + "71, SNOW", "77, SNOW", + "80, RAIN", "82, RAIN", + "85, SNOW", "86, SNOW", + "95, THUNDERSTORM", "99, THUNDERSTORM", + ) + fun `documented WMO codes map to the right icon`(code: Int, expected: WeatherCondition) { + assertEquals(expected, wmoToCondition(code)) + } + + @ParameterizedTest(name = "gap code {0} falls back to CLOUDY") + @ValueSource(ints = [4, 44, 49, 50, 68, 70, 78, 79, 83, 84, 87, 94, 100]) + fun `codes in the gaps fall back to CLOUDY`(code: Int) { + assertEquals(WeatherCondition.CLOUDY, wmoToCondition(code)) + } + + @Test + fun `nonsense codes never crash the widget`() { + // A malformed API response must degrade to an icon, not blank the home screen. + assertEquals(WeatherCondition.CLOUDY, wmoToCondition(-1)) + assertEquals(WeatherCondition.CLOUDY, wmoToCondition(9999)) + assertEquals(WeatherCondition.CLOUDY, wmoToCondition(Int.MAX_VALUE)) + assertEquals(WeatherCondition.CLOUDY, wmoToCondition(Int.MIN_VALUE)) + } + + @Test + fun `every condition has a non-empty label for the widget`() { + WeatherCondition.entries.forEach { assertEquals(it.label, it.label.trim().ifEmpty { null }) } + } +} From 84c8feceee313e22c4723ce930a3fb106c95f65d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20No=C3=A9=20N=C3=BA=C3=B1ez=20L=C3=B3pez?= Date: Fri, 21 Aug 2026 21:45:39 -0700 Subject: [PATCH 2/4] test: smoke test de UI por adb y job de CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conduce la app sobre adb y asierta contra el árbol de accesibilidad que Compose ya expone (uiautomator ve cada nodo Text con sus bounds), así que no hace falta tocar el código de la app ni añadir dependencias. Localiza por texto y saca el centro de los bounds en vez de usar coordenadas fijas, espera con polling hasta 15 s antes de rendirse, y al fallar imprime todo el texto visible más un screenshot en build/smoke/. La aserción del BACK va invertida a propósito: no existe ningún BackHandler en el proyecto, así que el botón del sistema cierra la Activity desde cualquier pantalla en vez de volver a la anterior. El script pasa mientras el bug siga ahí y avisa el día que se arregle, en lugar de quedarse en rojo permanente y que todos aprendan a ignorarlo. En CI corre sobre API 29 a 768x1024, el mismo layout que el head unit. --- .github/workflows/ci.yml | 43 ++++++++++ tools/smoke.sh | 173 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100755 tools/smoke.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7934f6f..5b00512 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,3 +44,46 @@ jobs: name: helm-debug path: app/build/outputs/apk/debug/app-debug.apk retention-days: 14 + + smoke: + name: UI smoke test + runs-on: ubuntu-latest + needs: build + + steps: + - uses: actions/checkout@v4 + + - name: Download debug APK + uses: actions/download-artifact@v4 + with: + name: helm-debug + path: app/build/outputs/apk/debug + + # The emulator needs KVM, and the runner does not grant it by default. + - 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 + + # API 29 + 768x1024 portrait mirrors the real head unit, so the smoke run + # exercises the same layout the car does. + - name: Run smoke test + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 29 + target: google_apis + arch: x86_64 + emulator-options: >- + -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim + -camera-back emulated -skin 768x1024 + script: bash tools/smoke.sh + + - name: Upload smoke screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: smoke-screenshots + path: build/smoke/ + retention-days: 14 diff --git a/tools/smoke.sh b/tools/smoke.sh new file mode 100755 index 0000000..0a5e002 --- /dev/null +++ b/tools/smoke.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# Helm UI smoke test — drives the app over adb and asserts on the accessibility +# tree that Compose exposes (uiautomator sees every Text node, with bounds). +# +# tools/smoke.sh # install the debug APK, then run +# tools/smoke.sh --no-install # run against whatever is already installed +# tools/smoke.sh -s emulator-5554 +# +# Exits non-zero on the first failed assertion. Screenshots land in +# build/smoke/ so you can see what the screen looked like when it broke. +set -uo pipefail + +SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/AppData/Local/Android/Sdk}}" +ADB="$SDK/platform-tools/adb" +[ -x "$ADB" ] || ADB="$ADB.exe" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APK="$ROOT/app/build/outputs/apk/debug/app-debug.apk" +PKG="dev.helm.launcher" +OUT="$ROOT/build/smoke" +SERIAL="" +INSTALL=1 +TIMEOUT=15 # seconds an assertion waits before giving up, à la Playwright + +while [ $# -gt 0 ]; do + case "$1" in + --no-install) INSTALL=0; shift ;; + -s) SERIAL="-s $2"; shift 2 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +adb_() { "$ADB" $SERIAL "$@"; } + +step=0 +pass() { echo " ok $1"; } +fail() { + echo " FAIL $1" >&2 + adb_ exec-out screencap -p > "$OUT/FAIL-$step.png" 2>/dev/null + echo >&2 + echo "screenshot: $OUT/FAIL-$step.png" >&2 + echo "visible text was:" >&2 + ui_text | sed 's/^/ /' >&2 + exit 1 +} + +# Whole accessibility tree, one node per line. +ui_dump() { + adb_ shell 'uiautomator dump /sdcard/helm-ui.xml >/dev/null 2>&1; cat /sdcard/helm-ui.xml' \ + | tr '<' '\n' +} +ui_text() { ui_dump | grep -o 'text="[^"]*"' | sed 's/text="//; s/"$//' | grep -v '^$' | sort -u; } + +bounds_of() { + ui_dump | grep -F "text=\"$1\"" | grep -o 'bounds="\[[0-9]*,[0-9]*\]\[[0-9]*,[0-9]*\]"' | head -1 +} +center_of() { + local b; b="$(bounds_of "$1")" + [ -n "$b" ] || return 1 + echo "$b" | sed 's/bounds="\[//; s/\]\[/ /; s/\]"//; s/,/ /g' \ + | awk '{ printf "%d %d\n", ($1+$3)/2, ($2+$4)/2 }' +} + +# Poll until the text shows up, like Playwright's auto-waiting locators. +wait_for_text() { + local want="$1" deadline=$(( SECONDS + TIMEOUT )) + while [ $SECONDS -lt $deadline ]; do + ui_dump | grep -qF "text=\"$want\"" && return 0 + sleep 1 + done + return 1 +} + +assert_text() { + step=$((step+1)) + wait_for_text "$1" && pass "sees \"$1\"" || fail "never saw \"$1\"" +} + +tap_text() { + step=$((step+1)) + wait_for_text "$1" || fail "cannot tap \"$1\" — not on screen" + local xy; xy="$(center_of "$1")" || fail "cannot tap \"$1\" — no bounds" + adb_ shell input tap $xy + pass "tapped \"$1\"" + sleep 1 +} + +back() { adb_ shell input keyevent KEYCODE_BACK; sleep 2; } + +# Sees $1 right now, without the polling grace period. +sees_now() { ui_dump | grep -qF "text=\"$1\""; } + +launch() { + adb_ shell am start -n "$PKG/.MainActivity" >/dev/null + sleep 3 # splash auto-dismisses at 1.6 s +} + +assert_no_crash() { + step=$((step+1)) + local crashes + crashes="$(adb_ logcat -d -b crash 2>/dev/null | grep -F "$PKG" | head -20)" + [ -z "$crashes" ] || { echo "$crashes" >&2; fail "crash in logcat"; } + pass "no crash in logcat" +} + +# ── run ────────────────────────────────────────────────────────────────────── +mkdir -p "$OUT" +adb_ get-state >/dev/null 2>&1 || { echo "no device — start the AVD: emulator -avd helm" >&2; exit 1; } +echo "device: $(adb_ shell getprop ro.product.model | tr -d '\r') / Android $(adb_ shell getprop ro.build.version.release | tr -d '\r')" + +if [ "$INSTALL" = 1 ]; then + [ -f "$APK" ] || { echo "no APK at $APK — run ./gradlew assembleDebug" >&2; exit 1; } + echo "installing $(basename "$APK")" + adb_ install -r "$APK" >/dev/null || { echo "install failed" >&2; exit 1; } +fi + +for p in ACCESS_FINE_LOCATION ACCESS_COARSE_LOCATION CAMERA READ_EXTERNAL_STORAGE READ_PHONE_STATE; do + adb_ shell pm grant "$PKG" "android.permission.$p" >/dev/null 2>&1 +done + +adb_ logcat -b crash -c >/dev/null 2>&1 +adb_ shell am force-stop "$PKG" +launch + +echo +echo "home" +assert_text "km/h" # speed badge — the home screen's unique marker +assert_text "Navigation" +assert_text "Settings" +assert_text "Camera" +adb_ exec-out screencap -p > "$OUT/1-home.png" + +echo +echo "settings" +tap_text "Settings" +assert_text "Ajustes" +assert_text "Apariencia" +assert_text "Acerca de" +adb_ exec-out screencap -p > "$OUT/2-settings.png" + +echo +echo "appearance" +tap_text "Apariencia" +assert_text "Android Auto" # unique to the theme picker +assert_text "Tesla" +adb_ exec-out screencap -p > "$OUT/3-themes.png" + +echo +echo "system back" +# Known bug: there is no BackHandler anywhere in the app, so the system BACK +# finishes the Activity from every screen instead of popping to the previous +# one. Asserted inverted on purpose — this stays green while the bug is there +# and shouts the day someone fixes it, instead of rotting as a red test. +step=$((step+1)) +back +if sees_now "Apariencia" || sees_now "Ajustes"; then + echo " NOTE system BACK now pops to the previous screen — the BackHandler" + echo " bug is fixed. Turn this into a real assertion." +else + pass "system BACK still exits the app (known bug, no BackHandler)" +fi + +echo +echo "relaunch" +launch +assert_text "km/h" +assert_text "Settings" +adb_ exec-out screencap -p > "$OUT/4-home-again.png" + +echo +assert_no_crash + +echo +echo "PASS — $step checks, screenshots in build/smoke/" From 1170e02bac252763eb50034cffe3f7825116c6ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20No=C3=A9=20N=C3=BA=C3=B1ez=20L=C3=B3pez?= Date: Fri, 21 Aug 2026 21:53:06 -0700 Subject: [PATCH 3/4] =?UTF-8?q?fix(smoke):=20esperar=20a=20que=20la=20Acti?= =?UTF-8?q?vity=20est=C3=A9=20resumed=20en=20vez=20de=20dormir=20fijo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El arranque en frío en el emulador de CI tarda bastante más que en uno local ya caliente, así que el sleep 3 tras am start convertía la primera aserción en un fallo intermitente: el volcado del propio fallo mostraba km/h presente 5 s después de que wait_for_text se rindiera. Ahora espera a mResumedActivity hasta 60 s, y sube el timeout de las aserciones a 30 s — cada poll cuesta un ui_dump completo (~2 s en CI), así que 15 s eran apenas unos pocos intentos. --- tools/smoke.sh | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tools/smoke.sh b/tools/smoke.sh index 0a5e002..1223e6a 100755 --- a/tools/smoke.sh +++ b/tools/smoke.sh @@ -19,7 +19,9 @@ PKG="dev.helm.launcher" OUT="$ROOT/build/smoke" SERIAL="" INSTALL=1 -TIMEOUT=15 # seconds an assertion waits before giving up, à la Playwright +TIMEOUT=30 # seconds an assertion waits before giving up, à la Playwright. + # Each poll costs a full ui_dump (~2 s on a CI emulator), so this is + # only a handful of attempts, not 30. while [ $# -gt 0 ]; do case "$1" in @@ -91,6 +93,14 @@ sees_now() { ui_dump | grep -qF "text=\"$1\""; } launch() { adb_ shell am start -n "$PKG/.MainActivity" >/dev/null + # Wait for the activity to actually be resumed rather than sleeping a fixed + # amount: a cold start on a CI emulator takes far longer than on a warm local + # one, and a fixed sleep turns that into a flaky failure. + local deadline=$(( SECONDS + 60 )) + while [ $SECONDS -lt $deadline ]; do + adb_ shell dumpsys activity activities 2>/dev/null | grep -q "mResumedActivity.*$PKG" && break + sleep 1 + done sleep 3 # splash auto-dismisses at 1.6 s } From 52e7aa516ac5d5f49ba357136262b793cdc3f58b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20No=C3=A9=20N=C3=BA=C3=B1ez=20L=C3=B3pez?= Date: Fri, 21 Aug 2026 22:02:40 -0700 Subject: [PATCH 4/4] =?UTF-8?q?fix(smoke):=20no=20usar=20grep=20-q=20en=20?= =?UTF-8?q?tuber=C3=ADas=20bajo=20pipefail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grep -q sale en cuanto encuentra la primera coincidencia, lo que manda SIGPIPE al adb que sigue escribiendo. Con set -o pipefail el estado de la tubería pasa a ser ese 141, así que la aserción fallaba justo cuando el texto SÍ estaba presente — de ahí que el volcado del propio fallo lo mostrara. Localmente no se reproducía porque el dump es pequeño y adb termina de escribir antes de que grep cierre la tubería; en CI el volcado tarda más y el SIGPIPE sí ocurre. Verificado aparte: yes 'x' | head -200000 | grep -qF x -> PIPESTATUS = 141 141 0 Se sustituye por case sobre la captura, en wait_for_text/sees_now y en la espera de mResumedActivity de launch(). --- tools/smoke.sh | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tools/smoke.sh b/tools/smoke.sh index 1223e6a..718e2a7 100755 --- a/tools/smoke.sh +++ b/tools/smoke.sh @@ -66,7 +66,7 @@ center_of() { wait_for_text() { local want="$1" deadline=$(( SECONDS + TIMEOUT )) while [ $SECONDS -lt $deadline ]; do - ui_dump | grep -qF "text=\"$want\"" && return 0 + sees_now "$want" && return 0 sleep 1 done return 1 @@ -89,16 +89,20 @@ tap_text() { back() { adb_ shell input keyevent KEYCODE_BACK; sleep 2; } # Sees $1 right now, without the polling grace period. -sees_now() { ui_dump | grep -qF "text=\"$1\""; } +# Match with `case` on a captured dump rather than `ui_dump | grep -q`: under +# `pipefail`, grep -q exits on the first hit and SIGPIPEs adb, which makes the +# whole pipeline report 141 exactly when the text WAS found. +sees_now() { case "$(ui_dump)" in *"text=\"$1\""*) return 0 ;; *) return 1 ;; esac; } launch() { adb_ shell am start -n "$PKG/.MainActivity" >/dev/null # Wait for the activity to actually be resumed rather than sleeping a fixed # amount: a cold start on a CI emulator takes far longer than on a warm local # one, and a fixed sleep turns that into a flaky failure. - local deadline=$(( SECONDS + 60 )) + local deadline=$(( SECONDS + 60 )) top while [ $SECONDS -lt $deadline ]; do - adb_ shell dumpsys activity activities 2>/dev/null | grep -q "mResumedActivity.*$PKG" && break + top="$(adb_ shell dumpsys activity activities 2>/dev/null | grep -F mResumedActivity)" + case "$top" in *"$PKG"*) break ;; esac sleep 1 done sleep 3 # splash auto-dismisses at 1.6 s