Skip to content

Commit 8369e05

Browse files
committed
Merge pull request TeamAmaze#4702 from TeamAmaze/bugfix/espresso-tests
Fix Espresso tests failing on TeamAmaze#4692
1 parent a43de42 commit 8369e05

3 files changed

Lines changed: 276 additions & 74 deletions

File tree

app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
package com.amaze.filemanager.test
2222

2323
import android.content.Context
24+
import android.os.Build
25+
import android.os.Build.VERSION_CODES
2426
import androidx.test.core.app.ActivityScenario
2527
import androidx.test.espresso.Espresso.onView
2628
import androidx.test.espresso.action.ViewActions.click
@@ -40,16 +42,21 @@ object StoragePermissionHelper {
4042
*/
4143
@JvmStatic
4244
fun grantManageStoragePermission() {
45+
// Only need to run on Androids >= R
46+
if (Build.VERSION.SDK_INT < VERSION_CODES.R) {
47+
return
48+
}
49+
4350
// Ensure that an activity that has the dialog is launched
4451
ActivityScenario.launch(MainActivity::class.java)
4552

4653
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
4754
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
4855

4956
val amazeResources = context.packageManager.getResourcesForApplication(context.packageName)
50-
val grantPermissionExplanation = amazeResources.getString(R.string.grant_all_files_permission)
57+
val grantPermissionHeader = amazeResources.getString(R.string.grantper)
5158

52-
if (device.hasObject(By.text(grantPermissionExplanation))) {
59+
if (device.hasObject(By.text(grantPermissionHeader))) {
5360
// First press Amaze's grant button
5461
onView(withText(R.string.grant)).perform(click())
5562

app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt

Lines changed: 70 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import android.content.SharedPreferences
2727
import android.net.Uri
2828
import android.os.Build.VERSION.SDK_INT
2929
import android.os.Build.VERSION_CODES.TIRAMISU
30+
import android.os.Environment
3031
import android.util.Log
3132
import androidx.lifecycle.Lifecycle
3233
import androidx.preference.PreferenceManager
@@ -52,18 +53,20 @@ import com.amaze.filemanager.ui.activities.PreferencesActivity
5253
import com.amaze.filemanager.ui.fragments.preferencefragments.BackupPrefsFragment
5354
import com.google.gson.GsonBuilder
5455
import com.google.gson.reflect.TypeToken
56+
import org.awaitility.Awaitility.await
5557
import org.junit.Assert.assertEquals
5658
import org.junit.Assert.assertFalse
5759
import org.junit.Assert.assertTrue
58-
import org.junit.Assert.fail
5960
import org.junit.Before
6061
import org.junit.Rule
6162
import org.junit.Test
6263
import org.junit.runner.RunWith
6364
import java.io.File
65+
import java.util.concurrent.TimeUnit
6466

6567
@RunWith(AndroidJUnit4::class)
6668
class BackupPrefsFragmentTest {
69+
var storagePath: String = Environment.getExternalStorageDirectory().absolutePath
6770
var fileName = "amaze_backup.json"
6871

6972
@Rule
@@ -112,6 +115,19 @@ class BackupPrefsFragmentTest {
112115
import(exportFile)
113116
}
114117

118+
/**
119+
* Waits (with a timeout) for the given file to exist, since some writes to storage happen
120+
* asynchronously on a background thread.
121+
*/
122+
private fun waitForFile(
123+
file: File,
124+
timeoutSeconds: Long = 5L,
125+
) {
126+
await().atMost(timeoutSeconds, TimeUnit.SECONDS).until {
127+
file.exists() && file.length() > 0L
128+
}
129+
}
130+
115131
/**
116132
* Test whether the exported file contains the expected preference values
117133
*/
@@ -120,6 +136,7 @@ class BackupPrefsFragmentTest {
120136
exportFile: File,
121137
) {
122138
val activityScenario = ActivityScenario.launch(PreferencesActivity::class.java)
139+
// Espresso requires an activity to be RESUMED to dispatch view actions/clicks.
123140
activityScenario.moveToState(Lifecycle.State.RESUMED)
124141

125142
onView(withText(R.string.backup)).perform(click())
@@ -144,44 +161,58 @@ class BackupPrefsFragmentTest {
144161
onView(withText(R.string.home)).perform(click())
145162
}
146163

147-
onView(withText(R.string.save)).perform(click())
148-
149-
assertTrue(exportFile.exists())
164+
lateinit var preferenceSnapshot: Map<String?, *>
150165

151166
activityScenario.onActivity { preferencesActivity ->
152167
val preferences = PreferenceManager.getDefaultSharedPreferences(preferencesActivity)
153-
val preferenceMap: Map<String?, *> = preferences.all
154-
155-
val inputString =
156-
exportFile
157-
.inputStream()
158-
.bufferedReader()
159-
.use {
160-
it.readText()
161-
}
162-
163-
val type = object : TypeToken<Map<String?, *>>() {}.type
168+
preferenceSnapshot = HashMap(preferences.all)
169+
}
164170

165-
val importMap: Map<String?, *> =
166-
GsonBuilder()
167-
.create()
168-
.fromJson(
169-
inputString,
170-
type,
171-
)
172-
173-
for ((key, value) in preferenceMap) {
174-
val importedValue = importMap[key]
175-
val mapValue =
176-
if (importedValue != null && importedValue::class.simpleName.equals("Double")) {
177-
(importedValue as Double).toInt() // since Gson parses Integer as Double
178-
} else {
179-
importedValue
180-
}
171+
// Espresso's onView().perform() must run on the instrumentation/test thread, never from
172+
// inside onActivity {} or runOnUiThread {} (both of which run on the main/UI thread).
173+
// Espresso internally synchronizes with the UI thread itself; calling it from the UI
174+
// thread can deadlock or throw IllegalStateException.
175+
// exportPrefs() launches MainActivity with an ACTION_SEND intent, which shows a Snackbar
176+
// with a "Save" action; that is the only view action needed here.
177+
onView(withText(R.string.save)).perform(click())
181178

182-
assertEquals("Difference found at key $key", value, mapValue)
179+
// The actual write to storagePath happens asynchronously (RxJava) after the "Save" click
180+
// and after MainActivity finishes, so poll for the file instead of asserting immediately.
181+
waitForFile(exportFile)
182+
183+
val inputString =
184+
exportFile
185+
.inputStream()
186+
.bufferedReader()
187+
.use {
188+
it.readText()
189+
}
190+
191+
val type = object : TypeToken<Map<String?, *>>() {}.type
192+
193+
// TODO This breaks the exported file's types, all Numbers get converted to Double
194+
val importMap: Map<String?, *> =
195+
GsonBuilder()
196+
.create()
197+
.fromJson(
198+
inputString,
199+
type,
200+
)
201+
202+
for ((key, value) in preferenceSnapshot) {
203+
val importedValue = importMap[key]
204+
205+
if (value is Number) {
206+
// HACK GsonBuilder().create().fromJson() breaks Number types
207+
assertEquals("Difference found at key $key", value.toDouble(), importedValue as Double, 0.1)
208+
} else {
209+
assertEquals("Different type at key $key", value?.javaClass, importedValue?.javaClass)
210+
211+
assertEquals("Difference found at key $key", value, importedValue)
183212
}
184213
}
214+
215+
activityScenario.close()
185216
}
186217

187218
/**
@@ -228,13 +259,14 @@ class BackupPrefsFragmentTest {
228259
assertFalse(preferenceMap.containsKey(null))
229260

230261
for ((k, v) in preferenceMap) {
231-
// This cast tells the kotlin type checker that fail() never returns
232-
val key = k ?: (fail() as Nothing)
233-
val value = v ?: (fail() as Nothing)
262+
val key = requireNotNull(k) { "Preference key unexpectedly null" }
263+
val value = requireNotNull(v) { "Preference value unexpectedly null for $key" }
234264

235265
assertTrue("checkPrefEqual($key) failed", checkPrefEqual(preferences, importMap, key, value))
236266
}
237267
}
268+
269+
activityScenario.close()
238270
}
239271

240272
private fun checkPrefEqual(
@@ -247,15 +279,14 @@ class BackupPrefsFragmentTest {
247279
"Boolean" -> return importMap[key] as Boolean ==
248280
preferences.getBoolean(key, false)
249281
"Float" ->
250-
importMap[key] as Float ==
282+
(importMap[key] as Number).toFloat() ==
251283
preferences.getFloat(key, 0f)
252284
"Int" -> {
253-
// since Gson parses Integer as Double
254-
val toInt = (importMap[key] as Double).toInt()
285+
val toInt = (importMap[key] as Number).toInt()
255286

256287
return toInt == preferences.getInt(key, 0)
257288
}
258-
"Long" -> return importMap[key] as Long ==
289+
"Long" -> return (importMap[key] as Number).toLong() ==
259290
preferences.getLong(key, 0L)
260291
"String" -> return importMap[key] as String ==
261292
preferences.getString(key, null)

0 commit comments

Comments
 (0)