Fix instrumented test compile errors - #3
Merged
Merged
Conversation
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.
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
AlarmListAccessibilityTest.ktreferenced non-existent APIs (onAllNodes/onNodeimported as top-level functions instead of members oncomposeRule, andSemanticsProperties.CustomActionswhich doesn't exist) and failed to compile in the instrumented test job (CI run 35293400415).performCustomAccessibilityActionWithLabel(a realExperimentalTestApiinandroidx.compose.ui.test), verified against the actualui-testjar viajavaprather 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 runon the changed file — ktlint and detekt both passedtestFullDebugUnitTest) — 136 tests, 0 failures, 0 errorsNot verified
Test plan