From feebe138fc466389a4dde12ea91be0736709c488 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Wed, 5 Aug 2026 23:52:59 +0800 Subject: [PATCH] Fix Espresso tests failing on #4692 --- .../test/StoragePermissionHelper.kt | 51 +++--- .../ui/fragments/BackupPrefsFragmentTest.kt | 106 +++++++---- .../ui/fragments/TabFragmentTest.kt | 172 ++++++++++++------ 3 files changed, 213 insertions(+), 116 deletions(-) diff --git a/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt b/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt index 62bca738e..59c455424 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt @@ -21,6 +21,8 @@ package com.amaze.filemanager.test import android.content.Context +import android.os.Build +import android.os.Build.VERSION_CODES import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click @@ -40,34 +42,37 @@ object StoragePermissionHelper { */ @JvmStatic fun grantManageStoragePermission() { - // Ensure that an activity that has the dialog is launched - ActivityScenario.launch(MainActivity::class.java) + // Only need to run on Androids >= R + if (Build.VERSION.SDK_INT >= VERSION_CODES.R) { + // Ensure that an activity that has the dialog is launched + ActivityScenario.launch(MainActivity::class.java) - val context: Context = InstrumentationRegistry.getInstrumentation().targetContext - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val context: Context = InstrumentationRegistry.getInstrumentation().targetContext + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - val amazeResources = context.packageManager.getResourcesForApplication(context.packageName) - val grantPermissionExplanation = amazeResources.getString(R.string.grant_all_files_permission) + val amazeResources = context.packageManager.getResourcesForApplication(context.packageName) + val grantPermissionExplanation = amazeResources.getString(R.string.grant_all_files_permission) - if (device.hasObject(By.text(grantPermissionExplanation))) { - // First press Amaze's grant button - onView(withText(R.string.grant)).perform(click()) + if (device.hasObject(By.text(grantPermissionExplanation))) { + // First press Amaze's grant button + onView(withText(R.string.grant)).perform(click()) - // Identifier names are taken here: - // https://cs.android.com/android/platform/superproject/+/master:packages/apps/Settings/res/values/strings.xml - val resources = context.packageManager.getResourcesForApplication("com.android.settings") - val resId = - resources.getIdentifier( - "permit_manage_external_storage", - "string", - "com.android.settings", - ) - val permitManageExternalStorage = resources.getString(resId) + // Identifier names are taken here: + // https://cs.android.com/android/platform/superproject/+/master:packages/apps/Settings/res/values/strings.xml + val resources = context.packageManager.getResourcesForApplication("com.android.settings") + val resId = + resources.getIdentifier( + "permit_manage_external_storage", + "string", + "com.android.settings", + ) + val permitManageExternalStorage = resources.getString(resId) - val grantToggle = - device.findObject(UiSelector().textMatches("(?i)$permitManageExternalStorage")) - grantToggle.click() - device.pressBack() + val grantToggle = + device.findObject(UiSelector().textMatches("(?i)$permitManageExternalStorage")) + grantToggle.click() + device.pressBack() + } } } } diff --git a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt index 1e831fec2..5cff15bb5 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt @@ -27,13 +27,13 @@ import android.content.SharedPreferences import android.net.Uri import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION_CODES.TIRAMISU +import android.os.Environment import androidx.lifecycle.Lifecycle import androidx.preference.PreferenceManager import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions -import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.GrantPermissionRule @@ -43,6 +43,7 @@ import com.amaze.filemanager.ui.activities.PreferencesActivity import com.amaze.filemanager.ui.fragments.preferencefragments.BackupPrefsFragment import com.google.gson.GsonBuilder import com.google.gson.reflect.TypeToken +import org.awaitility.Awaitility.await import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -52,10 +53,11 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import java.io.File +import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class BackupPrefsFragmentTest { - var storagePath = "/storage/emulated/0" + var storagePath = Environment.getExternalStorageDirectory().absolutePath var fileName = "amaze_backup.json" @Rule @@ -93,6 +95,19 @@ class BackupPrefsFragmentTest { import(exportFile) } + /** + * Waits (with a timeout) for the given file to exist, since some writes to storage happen + * asynchronously on a background thread. + */ + private fun waitForFile( + file: File, + timeoutSeconds: Long = 5L, + ) { + await().atMost(timeoutSeconds, TimeUnit.SECONDS).until { + file.exists() + } + } + /** * Test whether the exported file contains the expected preference values */ @@ -103,59 +118,70 @@ class BackupPrefsFragmentTest { val backupPrefsFragment = BackupPrefsFragment() val activityScenario = ActivityScenario.launch(PreferencesActivity::class.java) - activityScenario.moveToState(Lifecycle.State.STARTED) + // Espresso requires an activity to be RESUMED to dispatch view actions/clicks. + activityScenario.moveToState(Lifecycle.State.RESUMED) - activityScenario.onActivity { - it.supportFragmentManager.beginTransaction() + lateinit var preferences: SharedPreferences + + activityScenario.onActivity { preferencesActivity -> + preferencesActivity.supportFragmentManager.beginTransaction() .add(backupPrefsFragment, null) .commitNow() backupPrefsFragment.exportPrefs() + + val tempFile = File("${context.cacheDir.absolutePath}${File.separator}$fileName") + + assertTrue(tempFile.exists()) + + preferences = PreferenceManager.getDefaultSharedPreferences(preferencesActivity) } - val tempFile = File("${context.cacheDir.absolutePath}${File.separator}$fileName") - - assertTrue(tempFile.exists()) - - onView(withId(R.id.home)).perform(ViewActions.click()) + // Espresso's onView().perform() must run on the instrumentation/test thread, never from + // inside onActivity {} or runOnUiThread {} (both of which run on the main/UI thread). + // Espresso internally synchronizes with the UI thread itself; calling it from the UI + // thread can deadlock or throw IllegalStateException. + // exportPrefs() launches MainActivity with an ACTION_SEND intent, which shows a Snackbar + // with a "Save" action; that is the only view action needed here. onView(withText(R.string.save)).perform(ViewActions.click()) - assertTrue(exportFile.exists()) + // The actual write to storagePath happens asynchronously (RxJava) after the "Save" click + // and after MainActivity finishes, so poll for the file instead of asserting immediately. + waitForFile(exportFile) - activityScenario.onActivity { preferencesActivity -> - val preferences = PreferenceManager.getDefaultSharedPreferences(preferencesActivity) - val preferenceMap: Map = preferences.all + val preferenceMap: Map = preferences.all - val inputString = - exportFile - .inputStream() - .bufferedReader() - .use { - it.readText() - } + val inputString = + exportFile + .inputStream() + .bufferedReader() + .use { + it.readText() + } - val type = object : TypeToken>() {}.type + val type = object : TypeToken>() {}.type - val importMap: Map = - GsonBuilder() - .create() - .fromJson( - inputString, - type, - ) + val importMap: Map = + GsonBuilder() + .create() + .fromJson( + inputString, + type, + ) - for ((key, value) in preferenceMap) { - val importedValue = importMap[key] - val mapValue = - if (importedValue != null && importedValue::class.simpleName.equals("Double")) { - (importedValue as Double).toInt() // since Gson parses Integer as Double - } else { - importedValue - } + for ((key, value) in preferenceMap) { + val importedValue = importMap[key] + val mapValue = + if (importedValue != null && importedValue::class.simpleName.equals("Double")) { + (importedValue as Double).toInt() // since Gson parses Integer as Double + } else { + importedValue + } - assertEquals("Difference found at key $key", value, mapValue) - } + assertEquals("Difference found at key $key", value, mapValue) } + + activityScenario.close() } /** @@ -211,6 +237,8 @@ class BackupPrefsFragmentTest { assertTrue("checkPrefEqual($key) failed", checkPrefEqual(preferences, importMap, key, value)) } } + + activityScenario.close() } private fun checkPrefEqual( diff --git a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt index b26a65854..b3d5c88c9 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt @@ -1,23 +1,20 @@ package com.amaze.filemanager.ui.fragments -import android.content.pm.ActivityInfo import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION_CODES.TIRAMISU -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.swipeLeft -import androidx.test.espresso.action.ViewActions.swipeRight -import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.rule.ActivityTestRule import androidx.test.rule.GrantPermissionRule +import androidx.viewpager2.widget.ViewPager2 import com.amaze.filemanager.R import com.amaze.filemanager.test.StoragePermissionHelper import com.amaze.filemanager.ui.activities.MainActivity +import org.awaitility.Awaitility.await import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import java.util.concurrent.TimeUnit /** * Tests for [TabFragment] functionality, mainly for @@ -28,9 +25,6 @@ import org.junit.runner.RunWith @Suppress("DEPRECATION") @RunWith(AndroidJUnit4::class) class TabFragmentTest { - @get:Rule - val activityRule = ActivityTestRule(MainActivity::class.java) - @Rule @JvmField val storagePermissionRule: GrantPermissionRule = @@ -52,25 +46,25 @@ class TabFragmentTest { } /** - * This test causes a rotation to happen while the MainFragment detaches, to check if it - * fails. This could happen in reality, but should be very rare + * This test saves state while a MainFragment is detached. */ @Test fun testFragmentStateSavingDuringDetachment() { - activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + withScenario { scenario -> + awaitTabFragment(scenario) - // Get the TabFragment - InstrumentationRegistry.getInstrumentation().runOnMainSync { - val activity = activityRule.activity - val tabFragment = - activity.supportFragmentManager - .findFragmentById(R.id.content_frame) as TabFragment + scenario.onActivity { activity -> + val tabFragment = + activity.supportFragmentManager + .findFragmentById(R.id.content_frame) as TabFragment - // Detach fragment through FragmentManager - activity.supportFragmentManager.beginTransaction().apply { - tabFragment.fragments.forEach { detach(it) } - commit() + activity.supportFragmentManager.beginTransaction().apply { + tabFragment.fragments.firstOrNull { it.isAdded }?.let { detach(it) } + commitNow() + } } + + recreateActivity(scenario) } } @@ -80,13 +74,11 @@ class TabFragmentTest { */ @Test fun testFragmentStateSavingDuringConfigChange() { - // First perform the swipe action - onView(withId(R.id.pager)).perform(swipeLeft()) - - // Force a configuration change by rotating the screen - activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - Thread.sleep(1000) // Give time for the rotation to complete - activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + withScenario { scenario -> + setCurrentItem(scenario, 1) + recreateActivity(scenario) + awaitCurrentItem(scenario, 1) + } } /** @@ -94,16 +86,15 @@ class TabFragmentTest { */ @Test fun testRapidTabSwitchingAndStateSaving() { - // Perform rapid tab switches - repeat(10) { - onView(withId(R.id.pager)).perform(swipeLeft()) - Thread.sleep(100) // Small delay to ensure swipe completes - onView(withId(R.id.pager)).perform(swipeRight()) - Thread.sleep(100) // Small delay to ensure swipe completes - } + withScenario { scenario -> + repeat(10) { + setCurrentItem(scenario, 1) + setCurrentItem(scenario, 0) + } - // Force a save state by rotating - activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + recreateActivity(scenario) + awaitCurrentItem(scenario, 0) + } } /** @@ -111,24 +102,97 @@ class TabFragmentTest { */ @Test fun testFragmentDetachmentAndStateSaving() { - // First switch to a different tab - onView(withId(R.id.pager)).perform(swipeLeft()) + withScenario { scenario -> + setCurrentItem(scenario, 1) + awaitTabFragment(scenario) - // Get the TabFragment - InstrumentationRegistry.getInstrumentation().runOnMainSync { - val activity = activityRule.activity - val tabFragment = - activity.supportFragmentManager - .findFragmentById(R.id.content_frame) as TabFragment + scenario.onActivity { activity -> + val tabFragment = + activity.supportFragmentManager + .findFragmentById(R.id.content_frame) as TabFragment - // Detach fragment through FragmentManager - activity.supportFragmentManager.beginTransaction().apply { - tabFragment.fragments.firstOrNull()?.let { detach(it) } - commit() + activity.supportFragmentManager.beginTransaction().apply { + tabFragment.fragments.firstOrNull { it.isAdded }?.let { detach(it) } + commitNow() + } } + + recreateActivity(scenario) + } + } + + private fun withScenario(testBody: (ActivityScenario) -> Unit) { + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + awaitPager(scenario) + testBody(scenario) + } + } + + private fun awaitPager(scenario: ActivityScenario): ViewPager2 { + var pager: ViewPager2? = null + + await().atMost(10, TimeUnit.SECONDS).until { + scenario.onActivity { activity -> + pager = activity.findViewById(R.id.pager) + } + + pager != null } - // Force state save through configuration change - activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + return requireNotNull(pager) } -} + + private fun awaitTabFragment(scenario: ActivityScenario): TabFragment { + var tabFragment: TabFragment? = null + + await().atMost(10, TimeUnit.SECONDS).until { + runCatching { + scenario.onActivity { activity -> + tabFragment = + activity.supportFragmentManager + .findFragmentById(R.id.content_frame) as? TabFragment + } + } + + tabFragment?.view != null && tabFragment?.fragments?.isNotEmpty() == true + } + + return requireNotNull(tabFragment) + } + + private fun setCurrentItem( + scenario: ActivityScenario, + index: Int, + ) { + awaitPager(scenario) + + scenario.onActivity { activity -> + activity.findViewById(R.id.pager).setCurrentItem(index, false) + } + + awaitCurrentItem(scenario, index) + } + + private fun awaitCurrentItem( + scenario: ActivityScenario, + index: Int, + ) { + await().atMost(5, TimeUnit.SECONDS).until { + var currentItem = -1 + + runCatching { + scenario.onActivity { activity -> + currentItem = activity.findViewById(R.id.pager).currentItem + } + } + + currentItem == index + } + } + + private fun recreateActivity(scenario: ActivityScenario) { + scenario.recreate() + awaitPager(scenario) + awaitTabFragment(scenario) + } +}