Fix Espresso tests failing on #4692

This commit is contained in:
Raymond Lai
2026-08-05 23:52:59 +08:00
committed by TranceLove
parent 1cd239eb83
commit feebe138fc
3 changed files with 213 additions and 116 deletions
@@ -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()
}
}
}
}
@@ -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<String?, *> = preferences.all
val preferenceMap: Map<String?, *> = preferences.all
val inputString =
exportFile
.inputStream()
.bufferedReader()
.use {
it.readText()
}
val inputString =
exportFile
.inputStream()
.bufferedReader()
.use {
it.readText()
}
val type = object : TypeToken<Map<String?, *>>() {}.type
val type = object : TypeToken<Map<String?, *>>() {}.type
val importMap: Map<String?, *> =
GsonBuilder()
.create()
.fromJson(
inputString,
type,
)
val importMap: Map<String?, *> =
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(
@@ -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<MainActivity>) -> Unit) {
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
awaitPager(scenario)
testBody(scenario)
}
}
private fun awaitPager(scenario: ActivityScenario<MainActivity>): 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<MainActivity>): 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<MainActivity>,
index: Int,
) {
awaitPager(scenario)
scenario.onActivity { activity ->
activity.findViewById<ViewPager2>(R.id.pager).setCurrentItem(index, false)
}
awaitCurrentItem(scenario, index)
}
private fun awaitCurrentItem(
scenario: ActivityScenario<MainActivity>,
index: Int,
) {
await().atMost(5, TimeUnit.SECONDS).until {
var currentItem = -1
runCatching {
scenario.onActivity { activity ->
currentItem = activity.findViewById<ViewPager2>(R.id.pager).currentItem
}
}
currentItem == index
}
}
private fun recreateActivity(scenario: ActivityScenario<MainActivity>) {
scenario.recreate()
awaitPager(scenario)
awaitTabFragment(scenario)
}
}