From 8b23755659fd6b81965f976c51fbda8ecb52d92c Mon Sep 17 00:00:00 2001 From: Tim Malseed Date: Sat, 28 Mar 2026 18:10:02 +1100 Subject: [PATCH] Fix smoke tests for Compose Songs tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Songs tab was migrated from RecyclerView to Compose but the instrumented smoke tests still looked for R.id.recyclerView. Use ComposeTestRule alongside Espresso to find and interact with Compose text nodes on the Songs tab. Also fix Compose-Espresso idle timeout when navigating from Genres (Compose with FastScroller delay) to Playlists — advance the compose clock past the FastScroller auto-hide delay. --- android/app/build.gradle.kts | 1 + .../shuttle/smoke/NavigationSmokeTest.kt | 30 +++++++++---- .../shuttle/smoke/SmokeTestSuite.kt | 44 ++++++++++++------- 3 files changed, 50 insertions(+), 25 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 177512f8f..7562f17aa 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -292,6 +292,7 @@ android { testImplementation(libs.robolectric) testImplementation("androidx.compose.ui:ui-test-junit4") debugImplementation("androidx.compose.ui:ui-test-manifest") + androidTestImplementation("androidx.compose.ui:ui-test-junit4") androidTestImplementation(libs.androidx.runner) androidTestImplementation(libs.androidx.rules) androidTestImplementation(libs.androidx.core.ktx) diff --git a/android/app/src/androidTest/java/com/simplecityapps/shuttle/smoke/NavigationSmokeTest.kt b/android/app/src/androidTest/java/com/simplecityapps/shuttle/smoke/NavigationSmokeTest.kt index 7c098fb1f..23df9c93b 100644 --- a/android/app/src/androidTest/java/com/simplecityapps/shuttle/smoke/NavigationSmokeTest.kt +++ b/android/app/src/androidTest/java/com/simplecityapps/shuttle/smoke/NavigationSmokeTest.kt @@ -3,6 +3,10 @@ package com.simplecityapps.shuttle.smoke import android.Manifest import android.content.SharedPreferences import android.os.Build +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.recyclerview.widget.RecyclerView import androidx.test.core.app.ActivityScenario import androidx.test.core.app.launchActivity @@ -34,10 +38,13 @@ import org.junit.Test class NavigationSmokeTest { @get:Rule(order = 0) - var hiltRule = HiltAndroidRule(this) + val hiltRule = HiltAndroidRule(this) @get:Rule(order = 1) - var permissionRule: GrantPermissionRule = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val composeRule = createEmptyComposeRule() + + @get:Rule(order = 2) + val permissionRule: GrantPermissionRule = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { GrantPermissionRule.grant(Manifest.permission.READ_MEDIA_AUDIO) } else { GrantPermissionRule.grant(Manifest.permission.READ_EXTERNAL_STORAGE) @@ -93,7 +100,9 @@ class NavigationSmokeTest { onView(withId(R.id.libraryFragment)).perform(click()) onView(withText("Songs")).perform(click()) - waitForView(allOf(withId(R.id.recyclerView), hasDescendant(withText("Highway to Hell")))) + composeRule.waitUntil(5_000) { + composeRule.onAllNodesWithText("Highway to Hell").fetchSemanticsNodes().isNotEmpty() + } onView(withText("Albums")).perform(click()) waitForView(allOf(withId(R.id.recyclerView), hasDescendant(isDisplayed()))) @@ -102,8 +111,10 @@ class NavigationSmokeTest { waitForView(allOf(withId(R.id.recyclerView), hasDescendant(isDisplayed()))) onView(withText("Genres")).perform(click()) - // Genres uses Compose — just verify the tab didn't crash - waitForView(allOf(withId(R.id.tabLayout), isDescendantOfA(withId(R.id.constraintLayout)))) + // Genres uses Compose with a FastScroller delay that keeps the Compose + // idling resource busy. Advance the clock past it before Espresso tries + // to click the Playlists tab. + composeRule.mainClock.advanceTimeBy(2000) onView(withText("Playlists")).perform(click()) waitForView(allOf(withId(R.id.recyclerView), isDisplayed())) @@ -141,12 +152,13 @@ class NavigationSmokeTest { fun playbackScreens() { scenario = launchActivity() - // Play a song + // Play a song (Songs tab is Compose) onView(withId(R.id.libraryFragment)).perform(click()) onView(withText("Songs")).perform(click()) - waitForView(allOf(withId(R.id.recyclerView), hasDescendant(isDisplayed()))) - onView(allOf(withId(R.id.recyclerView), isDisplayed())) - .perform(RecyclerViewActions.actionOnItemAtPosition(0, click())) + composeRule.waitUntil(5_000) { + composeRule.onAllNodesWithText("Highway to Hell").fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNodeWithText("Highway to Hell").performClick() // Mini player waitForView(allOf(withId(R.id.titleTextView), isDescendantOfA(withId(R.id.sheet1PeekView)))) diff --git a/android/app/src/androidTest/java/com/simplecityapps/shuttle/smoke/SmokeTestSuite.kt b/android/app/src/androidTest/java/com/simplecityapps/shuttle/smoke/SmokeTestSuite.kt index 44651ff68..7a75347e4 100644 --- a/android/app/src/androidTest/java/com/simplecityapps/shuttle/smoke/SmokeTestSuite.kt +++ b/android/app/src/androidTest/java/com/simplecityapps/shuttle/smoke/SmokeTestSuite.kt @@ -3,6 +3,10 @@ package com.simplecityapps.shuttle.smoke import android.Manifest import android.content.SharedPreferences import android.os.Build +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.recyclerview.widget.RecyclerView import androidx.test.core.app.ActivityScenario import androidx.test.core.app.launchActivity @@ -37,10 +41,13 @@ import org.junit.Test class SmokeTestSuite { @get:Rule(order = 0) - var hiltRule = HiltAndroidRule(this) + val hiltRule = HiltAndroidRule(this) @get:Rule(order = 1) - var permissionRule: GrantPermissionRule = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val composeRule = createEmptyComposeRule() + + @get:Rule(order = 2) + val permissionRule: GrantPermissionRule = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { GrantPermissionRule.grant(Manifest.permission.READ_MEDIA_AUDIO) } else { GrantPermissionRule.grant(Manifest.permission.READ_EXTERNAL_STORAGE) @@ -97,8 +104,10 @@ class SmokeTestSuite { onView(withText("Songs")) .perform(click()) - // Wait for Flow to emit data, then verify a song from our test data is visible - waitForView(allOf(withId(R.id.recyclerView), hasDescendant(withText("Highway to Hell")))) + // Songs tab is Compose — use compose rule to find text + composeRule.waitUntil(5_000) { + composeRule.onAllNodesWithText("Highway to Hell").fetchSemanticsNodes().isNotEmpty() + } } @Test @@ -111,10 +120,11 @@ class SmokeTestSuite { onView(withText("Songs")) .perform(click()) - // Wait for Flow to emit data, then tap the first song in the list - waitForView(allOf(withId(R.id.recyclerView), hasDescendant(isDisplayed()))) - onView(allOf(withId(R.id.recyclerView), isDisplayed())) - .perform(RecyclerViewActions.actionOnItemAtPosition(0, click())) + // Songs tab is Compose — wait then tap + composeRule.waitUntil(5_000) { + composeRule.onAllNodesWithText("Highway to Hell").fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNodeWithText("Highway to Hell").performClick() // Mini player should show a song title waitForView(allOf(withId(R.id.titleTextView), isDescendantOfA(withId(R.id.sheet1PeekView)))) @@ -162,10 +172,11 @@ class SmokeTestSuite { onView(withText("Songs")) .perform(click()) - // Wait for Flow to emit data, then tap the first song - waitForView(allOf(withId(R.id.recyclerView), hasDescendant(isDisplayed()))) - onView(allOf(withId(R.id.recyclerView), isDisplayed())) - .perform(RecyclerViewActions.actionOnItemAtPosition(0, click())) + // Songs tab is Compose — wait then tap + composeRule.waitUntil(5_000) { + composeRule.onAllNodesWithText("Highway to Hell").fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNodeWithText("Highway to Hell").performClick() // Wait for mini player, then expand to full playback waitForView(allOf(withId(R.id.sheet1PeekView), isDisplayed())) @@ -190,10 +201,11 @@ class SmokeTestSuite { onView(withText("Songs")) .perform(click()) - // Wait for Flow to emit data, then tap the first song - waitForView(allOf(withId(R.id.recyclerView), hasDescendant(isDisplayed()))) - onView(allOf(withId(R.id.recyclerView), isDisplayed())) - .perform(RecyclerViewActions.actionOnItemAtPosition(0, click())) + // Songs tab is Compose — wait then tap + composeRule.waitUntil(5_000) { + composeRule.onAllNodesWithText("Highway to Hell").fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNodeWithText("Highway to Hell").performClick() // Wait for mini player to appear before expanding to queue waitForView(allOf(withId(R.id.titleTextView), isDescendantOfA(withId(R.id.sheet1PeekView))))