Skip to content

Fix instrumented test compile errors - #3

Merged
rajeshsub merged 11 commits into
mainfrom
feat/alarm-state-a11y-ci-fixes
Sep 18, 2026
Merged

rajeshsub merged 11 commits into
mainfrom
feat/alarm-state-a11y-ci-fixes

Conversation

@rajeshsub

Copy link
Copy Markdown
Owner

Summary

  • AlarmListAccessibilityTest.kt referenced non-existent APIs (onAllNodes/onNode imported as top-level functions instead of members on composeRule, and SemanticsProperties.CustomActions which doesn't exist) and failed to compile in the instrumented test job (CI run 35293400415).
  • Rewrote using performCustomAccessibilityActionWithLabel (a real ExperimentalTestApi in androidx.compose.ui.test), verified against the actual ui-test jar via javap rather than guessed. Identifies the alarm card by counting clickable nodes before/after creation instead of assuming node order.

Verified

  • ./gradlew :app:compileFullDebugAndroidTestKotlin — BUILD SUCCESSFUL (was failing with "Unresolved reference" errors)
  • pre-commit run on the changed file — ktlint and detekt both passed
  • Full unit suite (testFullDebugUnitTest) — 136 tests, 0 failures, 0 errors

Not verified

  • No emulator/device available in this environment, so the test's actual runtime pass/fail against a live Compose tree is unconfirmed. CI's instrumented-test job (with the emulator) is the real proof; compiling is necessary but not sufficient.

Test plan

  • CI instrumented-tests job runs the test against a real emulator and it passes

The test as written referenced APIs that don't exist:
onAllNodes/onNode imported as top-level functions (they're members
of SemanticsNodeInteractionsProvider, called directly on
composeRule, not extension functions to import), and
SemanticsProperties.CustomActions, which is not a real member
(confirmed via javap against the actual ui/ui-test jars - the real
accessor is the customActions property in SemanticsPropertiesKt,
used when building semantics, not reading them back in a test).

Rewrote to use performCustomAccessibilityActionWithLabel (a real,
purpose-built test API for exactly this - androidx.compose.ui.test
ActionsKt, confirmed via javap), which both invokes the action and
proves it's reachable by label the way TalkBack would present it.
Needs @OptIn(ExperimentalTestApi::class), matching this codebase's
existing @OptIn(ExperimentalMaterial3Api::class) pattern elsewhere.
Identifies the alarm card by counting clickable nodes before/after
creating the alarm (FAB and settings icon are the only other
clickable nodes on this screen and are unaffected), rather than
assuming which node is the card.

Verified locally with the now-installed JDK 17:
- ./gradlew :app:compileFullDebugAndroidTestKotlin -> BUILD
  SUCCESSFUL (was failing on CI with "Unresolved reference
  'onAllNodes'" / 'CustomActions' - run 35293400415)
- pre-commit run on this file -> ktlint and detekt both Passed

NOT verified: this machine has no emulator/device, so the test's
actual runtime behavior (whether the assertions pass against a
real Compose tree) is unconfirmed. Compiling is a genuine step up
from before (it didn't compile at all) but is not proof the test
passes - that needs the CI instrumented-test job.
@rajeshsub rajeshsub self-assigned this Sep 18, 2026
CI's real emulator run (PR #3, run 35294968280) caught what local
compile-only verification couldn't: the node-counting approach was
wrong on an actual Compose tree. hasClickAction() matched 46 nodes
on the Home screen, not the handful assumed, so
"clickableNodesBeforeCreate + 1" indexing and the before/after count
diff were both unreliable ("Expected '8' nodes but found '46' nodes").

Added a stable, explicit identifier instead: HomeScreen's AlarmCard
now carries Modifier.testTag("alarm_card_<id>"), a no-op for real
users. The test matches any card via a SemanticsMatcher on the
"alarm_card_" prefix (doesn't assume a specific id, since Room's
auto-increment isn't guaranteed fresh across instrumented test
runs), rather than assuming node order or counting all clickables.

Verified locally with JDK 17:
- ./gradlew :app:compileFullDebugKotlin :app:compileFullDebugAndroidTestKotlin
  -> BUILD SUCCESSFUL
- pre-commit run --all-files -> all hooks Passed
- ./gradlew testFullDebugUnitTest -> BUILD SUCCESSFUL

Still not verified: no emulator in this environment, so whether
this specific matcher correctly finds exactly one node in the real
tree is unconfirmed here - that's what CI's instrumented-tests job
(the only place with a real emulator) checks next.
CI's real emulator run (PR #3, run 35295703405) found the card
matcher never matched anything right after Save: "Expected exactly
'1' node but could not find any node that satisfies: (has test tag
starting with alarm_card_)".

Root cause: HomeScreen's alarm list comes from a StateFlow
(collectAsStateWithLifecycle), fed by a Room write that happens off
the main dispatcher. Compose's test synchronization only waits for
the UI thread's recomposition loop to go idle, not for that
off-thread write to finish and the Flow to emit - so asserting
immediately after performClick() on Save is a race, not a
guaranteed-synchronous check.

Same issue on the delete side, worse: HomeScreen.kt's deleteWithUndo
suspends on showSnackbar(duration = SnackbarDuration.Short) before
calling viewModel.delete - the card is deliberately still present
for the whole undo-snackbar window (several seconds), by design, not
a bug. Asserting it's gone right after triggering the action would
never have passed.

Replaced both immediate assertCountEquals calls with waitUntil
(real API on ComposeUiTest, confirmed via javap - default timeout
constant 1000, positional signature (String, Long, () -> Boolean)),
generous timeouts (5s for creation, 10s for the snackbar-gated
deletion).

Verified locally with JDK 17:
- ./gradlew :app:compileFullDebugAndroidTestKotlin -> BUILD
  SUCCESSFUL
- pre-commit run --all-files -> all hooks Passed
- ./gradlew testFullDebugUnitTest -> BUILD SUCCESSFUL

Still not verified: no emulator here, so whether the waits are
correctly sized against the real snackbar/DB timing is unconfirmed
locally - CI's instrumented-tests job is the next real check.
CI (run 35296477086) timed out at 5s: "Condition (the newly saved
alarm's card appears) still not satisfied after 5000 ms". Checked
EditAlarmViewModel.save() - it's not just a Room insert. The full
chain before savedOrDeleted flips (which is what triggers
EditAlarmScreen's LaunchedEffect to pop back to Home) is: two
DataStore reads (defaultSmartWindowMinutes, defaultRampDurationMinutes,
defaultMotionSensitivity), the Room insert, and a real
AlarmScheduler.schedule() call (AlarmManager). On a CI emulator
that was already showing adb/emulator-console instability in the
same run's logs, 5s was too tight for that whole sequence.

Widened to 15s. Confirmed AlarmScheduler.schedule() can't hang this
further: it checks canScheduleExactAlarms() and returns early
(logs a warning, no throw) if the permission isn't granted -
GrantSystemPermissionsRule doesn't grant SCHEDULE_EXACT_ALARM, so
this path short-circuits rather than blocking.

Verified locally with JDK 17:
- ./gradlew :app:compileFullDebugAndroidTestKotlin -> BUILD SUCCESSFUL
- pre-commit run --all-files -> all hooks Passed

Not verified locally (no emulator): whether 15s is actually enough
on CI's hardware. This is the third iteration on this test's timing;
if this also times out, the next step is checking whether the
emulator instability itself (not this test's logic) is the real
blocker, rather than continuing to widen timeouts blindly.
Three timeout-widening pushes on this test (5s, 15s, still failing)
without local emulator access made this pure guesswork - not
acceptable per "fix it properly before pushing". Tried running a
real local emulator matching CI's exact config (API 34, google_apis,
x86_64, pixel_6) to stop guessing; it died on cold boot, this
machine's swap was already at 7.6/8GB. Not safe to keep forcing -
same class of resource exhaustion that crashed the machine earlier
this session. Dropped that approach.

Traced the actual save/navigation code instead of guessing timing
again: EditAlarmScreen's LaunchedEffect(savedOrDeleted) calls
onSaved() -> navController.popBackStack() (real, no branch that
could dead-end); AlarmScheduler.schedule() cannot throw or hang on a
missing exact-alarm permission (logs and returns cleanly);
SaveAlarmUseCase is a one-line repository.save() delegate. No dead
end found in the code - this really does look like CI-environment
timing, not a logic bug, but three unverified guesses in a row means
"probably timing" isn't good enough to ship as the explanation.

Two changes instead of a fourth guess:
1. ci.yml: capture `adb logcat` for the whole emulator session
   (started before the test script, killed after) as a new
   `instrumented-test-logcat` artifact. There was no logcat capture
   at all before this - every failure so far has been diagnosed from
   the JUnit failure message alone, which only says "did not happen
   in time", never why. This is the actual gap.
2. The test itself now checkpoints navigation back to Home
   separately from the card appearing (was already one commit
   in progress before this), so if it does still fail, the failure
   message says which stage - still on the edit screen, or already
   on Home but the specific card missing - instead of one
   undifferentiated timeout.

Verified locally with JDK 17:
- ./gradlew :app:compileFullDebugAndroidTestKotlin -> BUILD SUCCESSFUL
- pre-commit run --all-files -> all hooks Passed (including yaml
  syntax check on ci.yml)
- ./gradlew testFullDebugUnitTest -> BUILD SUCCESSFUL
- python3 yaml.safe_load on ci.yml -> valid

Not verified locally: whether this passes on CI's actual emulator -
still can't run one here. If it fails again, the logcat artifact
should finally show why instead of another guess.
Logcat from run 35298759393 (finally captured, thanks to the last
commit's logcat step) confirmed the emulator host itself, not the
app or test: a 13-second window mid-test with zero process output
at all, "Davey! duration=2321ms" (one frame took 2.3s to render),
Choreographer skipping 126 frames, and "Failed to start Emulator
console" during boot - all before the test even reached the point
being measured. This matches a widely-reported ~25-30% flake rate
for reactivecircus/android-emulator-runner on GitHub-hosted runners
(software rendering only, no real GPU), not something specific to
this repo.

Two changes:
1. ci.yml: retry the whole emulator run once. GitHub Actions has no
   native retry for a `uses:` step, so this follows the documented
   community pattern - first attempt has continue-on-error, a second
   attempt is gated on `if: steps.<id>.outcome == 'failure'`. Job
   status still correctly depends on whichever attempt actually
   determines it (attempt 2 has no continue-on-error, so if it runs,
   its result is the job's result). Logcat from each attempt is kept
   separately (logcat-attempt-1.txt / -2.txt) rather than the second
   silently overwriting the first.
2. AlarmListAccessibilityTest.kt: widened all three waitUntil
   timeouts (15s->30s, 5s->15s, 10s->30s) to have real headroom
   against the observed 13s total-freeze class of failure, not just
   the save chain's own latency.

Verified locally with JDK 17:
- python3 yaml.safe_load on ci.yml -> valid
- ./gradlew :app:compileFullDebugAndroidTestKotlin -> BUILD SUCCESSFUL
- pre-commit run --all-files -> all hooks Passed
- ./gradlew testFullDebugUnitTest -> BUILD SUCCESSFUL

Not verified locally: the retry wiring itself (whether attempt 2
correctly triggers and whether its result correctly gates the job)
only runs for real on GitHub's infrastructure - no local emulator
available here (see prior commit: local emulator attempt exhausted
this machine's swap and was abandoned as unsafe).
Two independent CI emulator runs both froze for exactly the timeout
duration (30.000s, twice) with zero process output during the wait
- no GC, no frame renders, nothing - then resumed right as the test
gave up. AlarmFlowTest's two tests ran fine immediately before this
one in the same process, so it isn't a generic "this host is
unstable" story; it's specific to what this test exercises.

Two live hypotheses, can't distinguish between them without real
evidence: (a) EditAlarmViewModel.save()'s coroutine (DataStore reads
- possibly a cold first-ever DataStore file creation in the whole
test process, Room insert, a real AlarmManager Binder call) is
genuinely stuck or very slow on this emulator, or (b) the test's own
waitUntil { fetchSemanticsNodes() } polling loop is starving the
coroutine dispatcher on a resource-constrained runner. Reasoned
through both without being able to confirm either from the existing
CI log/logcat, which has zero output from save() itself - it was
never instrumented.

Added Timber.d("DIAG ...") calls at every stage of save() (before/
after each DataStore read, before/after saveAlarm, before/after
scheduler.schedule, before/after the final state update) so the
next CI logcat capture shows exactly which stage, if any, the
coroutine reaches - turning this into a real answer instead of a
fourth guess. Will remove once the actual stall point is confirmed;
flagging explicitly that this is temporary, not a change meant to
ship (rule 11).

Verified locally with JDK 17:
- ./gradlew :app:compileFullDebugKotlin -> BUILD SUCCESSFUL
- pre-commit run --all-files -> all hooks Passed
- ./gradlew testFullDebugUnitTest -> BUILD SUCCESSFUL

Not verified locally: no emulator here, so whether these logs
actually appear/help is unconfirmed until the next real CI run.
Run 35302473537's DIAG breadcrumbs (added last commit) never
printed a single line, including the very first one at the top of
save() - meaning EditAlarmViewModel.save() was never entered at
all. That rules out every async-timing hypothesis from the last
several commits: the problem is earlier, at the Save click itself.
Duration was 35.094s total against a 30s wait, meaning ~5s elapsed
on real, working interaction (FAB click, edit-title assertion)
before the click that doesn't register.

EditAlarmScreen.kt's content is a verticalScroll(rememberScrollState())
Column, and Save is the last element in it, after sound settings and
smart-wake config - not guaranteed to be within the initial viewport.
Clicking a node whose current layout position is outside the
scrolled bounds is a known way for a Compose test performClick() to
not actually reach the intended target, without throwing (so no
exception shows up - it just does nothing, matching the observed
"clicked, then dead silence" behavior exactly).

Added performScrollTo() before performClick() on the Save button.
Kept the DIAG logging from the prior commit rather than removing it
yet - if this fixes it, they'll show a clean sequence through
save() and can come out next; if it doesn't, they're still the
fastest way to find out why not.

Verified locally with JDK 17:
- ./gradlew :app:compileFullDebugAndroidTestKotlin -> BUILD SUCCESSFUL
- pre-commit run --all-files -> all hooks Passed
- ./gradlew testFullDebugUnitTest -> BUILD SUCCESSFUL

Not verified locally: no emulator here. This is a real, specific
hypothesis (not another timeout guess) backed by the DIAG evidence
from the previous run, but still unconfirmed until it actually runs.
Traced why the last commit's DIAG breadcrumbs never appeared in
logcat: this project's HiltTestRunner replaces the Application with
Hilt's generic HiltTestApplication for every instrumented test
(app/src/androidTest/kotlin/com/wakeiq/HiltTestRunner.kt) - it does
not extend WakeIQApp and never calls its onCreate(), so
Timber.plant(DebugTree()) never runs under instrumentation. This is
correct, standard Hilt testing behaviour, not a bug - but it means
Timber can never be used as a diagnostic channel in this test tier.
Confirmed, not guessed: tried android.util.Log.d as a bypass, which
immediately proved Timber was doing its job correctly by revealing
the opposite problem - raw Log.d throws "Method d in android.util.Log
not mocked" under plain JVM unit tests (no Robolectric), breaking 3
EditAlarmViewModelTest cases. Reverted to Timber and removed all the
DIAG lines from EditAlarmViewModel.kt - they cannot help here and
were never going to.

With that dead end closed off, the last real CI run's evidence
(run 35303282728: failure moved past the "navigation to Home"
checkpoint for the first time, failing later on "card appears")
pointed at the actual bug: HomeViewModel.seedDefaultAlarmIfNeeded()
seeds two default alarms ("Weekdays" hour=6, "Weekends" hour=7) on
a fresh install - exactly the CI emulator's state - before this
test creates its own. AlarmDao orders by (hour, minute) with no
tiebreaker, and EditAlarmViewModel's new-alarm default is also
7:00, tying with "Weekends". The test's old logic (checking for
exactly 1 card, or picking a fixed list index) could never work
reliably against 2-3 real cards in an order SQL doesn't guarantee.

Fixed by giving the test's alarm a distinctive label
("AlarmListAccessibilityTest <nanoTime>") via the real Label field
in EditAlarmScreen, and matching the card by
hasAnyDescendant(hasText(uniqueLabel)) instead of position or count.
This is now correct regardless of how many other alarms exist or
how they sort.

Verified locally with JDK 17:
- ./gradlew :app:compileFullDebugAndroidTestKotlin -> BUILD SUCCESSFUL
- pre-commit run --all-files -> all hooks Passed
- ./gradlew testFullDebugUnitTest -> BUILD SUCCESSFUL (confirms the
  3 EditAlarmViewModelTest cases broken by the Log.d detour are
  fixed by the Timber revert)

Not verified locally: no emulator here. This is a confirmed logic
bug with a direct fix, not a timing guess, but still unconfirmed
until it runs for real.
Run 35304497994's logcat showed the same class of failure as
earlier runs at this new checkpoint - the label-matching fix is
solid (test logic confirmed correct: real APIs, real disambiguation
via a unique label rather than position/count), but the app process
went dead silent for ~14s right around a genuine IME
show/hide interaction (ImeTracker onFailed at
PHASE_CLIENT_REQUEST_IME_SHOW, twice at PHASE_CLIENT_VIEW_SERVED on
hide) before the test gave up at 15s. performTextInput injects via
SemanticsActions.InsertTextAtCursor directly, not a real keyboard,
so those IME failures are very likely Compose's normal (harmless)
attempt to show a keyboard this headless, -no-window CI emulator
can't actually display - not the cause of the freeze itself, which
matches the same unexplained multi-second silence seen at other
checkpoints in earlier runs.

Widened "the newly saved alarm's card appears" from 15s to 30s,
matching the other two waits in this test, both of which have held
up against this exact freeze pattern already.

Verified locally with JDK 17:
- ./gradlew :app:compileFullDebugAndroidTestKotlin -> BUILD SUCCESSFUL
- pre-commit run --all-files -> all hooks Passed

Not verified locally: no emulator here.
Run 35307074554's logcat (30s timeout, same as before) revealed the
real cause behind the last two failures: "FrameTracker: force finish
cuj, time out: J<IME_INSETS_ANIMATION>" appearing twice, followed by
20+ seconds of dead process output, right after performTextInput
triggered a real keyboard show/hide cycle. Confirmed via javap that
every public text-input API (performTextInput,
performTextReplacement) routes through getNodeAndFocus - there is no
way to set text without triggering IME focus. On this headless,
-no-window CI emulator, that IME animation genuinely hangs; this was
never a timeout-margin problem.

Removed all label/text-input from the test. Instead, at test start,
delete both of HomeViewModel.seedDefaultAlarmIfNeeded()'s seeded
alarms using the same performCustomAccessibilityActionWithLabel
delete action this test exists to verify - thematically consistent,
and it removes the ordering collision at the source (no other alarms
left to tie with), not just the symptom. A stabilization wait
(two card-count reads 500ms apart agreeing) handles the seeding
coroutine's own race, since a single read at test start can catch
it still in flight.

Verified locally with JDK 17:
- ./gradlew :app:compileFullDebugAndroidTestKotlin -> BUILD SUCCESSFUL
- pre-commit run --all-files -> all hooks Passed
- ./gradlew testFullDebugUnitTest -> BUILD SUCCESSFUL

Not verified locally: no emulator here. This removes the specific,
now-confirmed IME failure mode rather than widening a timeout
against it again.
@rajeshsub
rajeshsub merged commit 47905ce into main Sep 18, 2026
2 checks passed
rajeshsub added a commit that referenced this pull request Sep 18, 2026
CI's real emulator run (PR #3, run 35294968280) caught what local
compile-only verification couldn't: the node-counting approach was
wrong on an actual Compose tree. hasClickAction() matched 46 nodes
on the Home screen, not the handful assumed, so
"clickableNodesBeforeCreate + 1" indexing and the before/after count
diff were both unreliable ("Expected '8' nodes but found '46' nodes").

Added a stable, explicit identifier instead: HomeScreen's AlarmCard
now carries Modifier.testTag("alarm_card_<id>"), a no-op for real
users. The test matches any card via a SemanticsMatcher on the
"alarm_card_" prefix (doesn't assume a specific id, since Room's
auto-increment isn't guaranteed fresh across instrumented test
runs), rather than assuming node order or counting all clickables.

Verified locally with JDK 17:
- ./gradlew :app:compileFullDebugKotlin :app:compileFullDebugAndroidTestKotlin
  -> BUILD SUCCESSFUL
- pre-commit run --all-files -> all hooks Passed
- ./gradlew testFullDebugUnitTest -> BUILD SUCCESSFUL

Still not verified: no emulator in this environment, so whether
this specific matcher correctly finds exactly one node in the real
tree is unconfirmed here - that's what CI's instrumented-tests job
(the only place with a real emulator) checks next.
rajeshsub added a commit that referenced this pull request Sep 18, 2026
CI's real emulator run (PR #3, run 35295703405) found the card
matcher never matched anything right after Save: "Expected exactly
'1' node but could not find any node that satisfies: (has test tag
starting with alarm_card_)".

Root cause: HomeScreen's alarm list comes from a StateFlow
(collectAsStateWithLifecycle), fed by a Room write that happens off
the main dispatcher. Compose's test synchronization only waits for
the UI thread's recomposition loop to go idle, not for that
off-thread write to finish and the Flow to emit - so asserting
immediately after performClick() on Save is a race, not a
guaranteed-synchronous check.

Same issue on the delete side, worse: HomeScreen.kt's deleteWithUndo
suspends on showSnackbar(duration = SnackbarDuration.Short) before
calling viewModel.delete - the card is deliberately still present
for the whole undo-snackbar window (several seconds), by design, not
a bug. Asserting it's gone right after triggering the action would
never have passed.

Replaced both immediate assertCountEquals calls with waitUntil
(real API on ComposeUiTest, confirmed via javap - default timeout
constant 1000, positional signature (String, Long, () -> Boolean)),
generous timeouts (5s for creation, 10s for the snackbar-gated
deletion).

Verified locally with JDK 17:
- ./gradlew :app:compileFullDebugAndroidTestKotlin -> BUILD
  SUCCESSFUL
- pre-commit run --all-files -> all hooks Passed
- ./gradlew testFullDebugUnitTest -> BUILD SUCCESSFUL

Still not verified: no emulator here, so whether the waits are
correctly sized against the real snackbar/DB timing is unconfirmed
locally - CI's instrumented-tests job is the next real check.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant