Skip to content

Commit 8b655ed

Browse files
authored
Merge pull request #4439 from vector-im/feature/adm/developer-mode-sanity-check
Developer mode sanity check & failure screenshots
2 parents 474e2aa + 4264829 commit 8b655ed

File tree

10 files changed

+362
-61
lines changed

10 files changed

+362
-61
lines changed

.github/workflows/sanity_test.yml

+19-2
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
strategy:
1919
fail-fast: false
2020
matrix:
21-
api-level: [28]
21+
api-level: [ 29 ]
2222
steps:
2323
- uses: actions/checkout@v2
2424
with:
@@ -56,7 +56,24 @@ jobs:
5656
java-version: '11'
5757
- name: Run sanity tests on API ${{ matrix.api-level }}
5858
uses: reactivecircus/android-emulator-runner@v2
59+
continue-on-error: true # allow pipeline to upload failure results
5960
with:
61+
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
6062
api-level: ${{ matrix.api-level }}
61-
script: ./gradlew $CI_GRADLE_ARG_PROPERTIES -PallWarningsAsErrors=false connectedGplayDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=im.vector.app.ui.UiAllScreensSanityTest
63+
emulator-build: 7425822 # workaround to emulator bug: https://github.com./ReactiveCircus/android-emulator-runner/issues/160
64+
script: |
65+
adb root
66+
adb logcat -c
67+
touch emulator.log
68+
chmod 777 emulator.log
69+
adb logcat >> emulator.log &
70+
./gradlew $CI_GRADLE_ARG_PROPERTIES -PallWarningsAsErrors=false connectedGplayDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=im.vector.app.ui.UiAllScreensSanityTest || adb pull storage/emulated/0/Pictures/failure_screenshots
6271
72+
- name: Upload Failing Test Report Log
73+
if: failure()
74+
uses: actions/upload-artifact@v2
75+
with:
76+
name: sanity-error-results
77+
path: |
78+
emulator.log
79+
failure_screenshots/

vector/src/androidTest/java/im/vector/app/EspressoExt.kt

+61
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package im.vector.app
1919
import android.app.Activity
2020
import android.view.View
2121
import androidx.annotation.StringRes
22+
import androidx.fragment.app.FragmentActivity
2223
import androidx.lifecycle.Observer
2324
import androidx.test.espresso.Espresso
2425
import androidx.test.espresso.IdlingRegistry
@@ -35,6 +36,11 @@ import androidx.test.runner.lifecycle.ActivityLifecycleCallback
3536
import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry
3637
import androidx.test.runner.lifecycle.Stage
3738
import com.adevinta.android.barista.interaction.BaristaClickInteractions
39+
import com.google.android.material.bottomsheet.BottomSheetBehavior
40+
import com.google.android.material.bottomsheet.BottomSheetDialog
41+
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
42+
import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment
43+
import im.vector.app.espresso.tools.waitUntilViewVisible
3844
import org.hamcrest.Matcher
3945
import org.hamcrest.Matchers
4046
import org.hamcrest.StringDescription
@@ -52,6 +58,18 @@ object EspressoHelper {
5258
}
5359
return currentActivity
5460
}
61+
62+
inline fun <reified T : VectorBaseBottomSheetDialogFragment<*>> getBottomSheetDialog(): BottomSheetDialogFragment? {
63+
return (getCurrentActivity() as? FragmentActivity)
64+
?.supportFragmentManager
65+
?.fragments
66+
?.filterIsInstance<T>()
67+
?.firstOrNull()
68+
}
69+
}
70+
71+
fun getString(@StringRes id: Int): String {
72+
return EspressoHelper.getCurrentActivity()!!.resources.getString(id)
5573
}
5674

5775
fun waitForView(viewMatcher: Matcher<View>, timeout: Long = 10_000, waitForDisplayed: Boolean = true): ViewAction {
@@ -216,3 +234,46 @@ fun clickOnAndGoBack(@StringRes name: Int, block: () -> Unit) {
216234
block()
217235
Espresso.pressBack()
218236
}
237+
238+
inline fun <reified T : VectorBaseBottomSheetDialogFragment<*>> interactWithSheet(contentMatcher: Matcher<View>, noinline block: () -> Unit = {}) {
239+
waitUntilViewVisible(contentMatcher)
240+
val behaviour = (EspressoHelper.getBottomSheetDialog<T>()!!.dialog as BottomSheetDialog).behavior
241+
withIdlingResource(BottomSheetResource(behaviour, BottomSheetBehavior.STATE_EXPANDED), block)
242+
withIdlingResource(BottomSheetResource(behaviour, BottomSheetBehavior.STATE_HIDDEN)) {}
243+
}
244+
245+
class BottomSheetResource(
246+
private val bottomSheetBehavior: BottomSheetBehavior<*>,
247+
@BottomSheetBehavior.State private val wantedState: Int
248+
) : IdlingResource, BottomSheetBehavior.BottomSheetCallback() {
249+
250+
private var isIdle: Boolean = false
251+
private var resourceCallback: IdlingResource.ResourceCallback? = null
252+
253+
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
254+
255+
override fun onStateChanged(bottomSheet: View, newState: Int) {
256+
val wasIdle = isIdle
257+
isIdle = newState == BottomSheetBehavior.STATE_EXPANDED
258+
if (!wasIdle && isIdle) {
259+
bottomSheetBehavior.removeBottomSheetCallback(this)
260+
resourceCallback?.onTransitionToIdle()
261+
}
262+
}
263+
264+
override fun getName() = "BottomSheet awaiting state: $wantedState"
265+
266+
override fun isIdleNow() = isIdle
267+
268+
override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) {
269+
resourceCallback = callback
270+
271+
val state = bottomSheetBehavior.state
272+
isIdle = state == wantedState
273+
if (isIdle) {
274+
resourceCallback!!.onTransitionToIdle()
275+
} else {
276+
bottomSheetBehavior.addBottomSheetCallback(this)
277+
}
278+
}
279+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/*
2+
* Copyright (c) 2021 New Vector Ltd
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package im.vector.app.espresso.tools
18+
19+
import android.content.ContentResolver
20+
import android.content.ContentValues
21+
import android.graphics.Bitmap
22+
import android.net.Uri
23+
import android.os.Environment
24+
import android.provider.MediaStore
25+
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
26+
import org.junit.rules.TestWatcher
27+
import org.junit.runner.Description
28+
import timber.log.Timber
29+
import java.io.File
30+
import java.io.FileOutputStream
31+
import java.io.IOException
32+
import java.io.OutputStream
33+
import java.text.SimpleDateFormat
34+
import java.util.Date
35+
import java.util.Locale
36+
37+
private val SCREENSHOT_FOLDER_LOCATION = "${Environment.DIRECTORY_PICTURES}/failure_screenshots"
38+
private val deviceLanguage = Locale.getDefault().language
39+
40+
class ScreenshotFailureRule : TestWatcher() {
41+
override fun failed(e: Throwable?, description: Description) {
42+
val screenShotName = "$deviceLanguage-${description.methodName}-${SimpleDateFormat("EEE-MMMM-dd-HH:mm:ss").format(Date())}"
43+
val bitmap = getInstrumentation().uiAutomation.takeScreenshot()
44+
storeFailureScreenshot(bitmap, screenShotName)
45+
}
46+
}
47+
48+
/**
49+
* Stores screenshots in sdcard/Pictures/failure_screenshots
50+
*/
51+
private fun storeFailureScreenshot(bitmap: Bitmap, screenshotName: String) {
52+
val contentResolver = getInstrumentation().targetContext.applicationContext.contentResolver
53+
54+
val contentValues = ContentValues().apply {
55+
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
56+
put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
57+
}
58+
if (android.os.Build.VERSION.SDK_INT >= 29) {
59+
useMediaStoreScreenshotStorage(
60+
contentValues,
61+
contentResolver,
62+
screenshotName,
63+
SCREENSHOT_FOLDER_LOCATION,
64+
bitmap
65+
)
66+
} else {
67+
usePublicExternalScreenshotStorage(
68+
contentValues,
69+
contentResolver,
70+
screenshotName,
71+
SCREENSHOT_FOLDER_LOCATION,
72+
bitmap
73+
)
74+
}
75+
}
76+
77+
private fun useMediaStoreScreenshotStorage(
78+
contentValues: ContentValues,
79+
contentResolver: ContentResolver,
80+
screenshotName: String,
81+
screenshotLocation: String,
82+
bitmap: Bitmap
83+
) {
84+
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, "$screenshotName.jpeg")
85+
contentValues.put(MediaStore.Images.Media.RELATIVE_PATH, screenshotLocation)
86+
val uri: Uri? = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
87+
if (uri != null) {
88+
contentResolver.openOutputStream(uri)?.let { saveScreenshotToStream(bitmap, it) }
89+
contentResolver.update(uri, contentValues, null, null)
90+
}
91+
}
92+
93+
private fun usePublicExternalScreenshotStorage(
94+
contentValues: ContentValues,
95+
contentResolver: ContentResolver,
96+
screenshotName: String,
97+
screenshotLocation: String,
98+
bitmap: Bitmap
99+
) {
100+
val directory = File(Environment.getExternalStoragePublicDirectory(screenshotLocation).toString())
101+
if (!directory.exists()) {
102+
directory.mkdirs()
103+
}
104+
val file = File(directory, "$screenshotName.jpeg")
105+
saveScreenshotToStream(bitmap, FileOutputStream(file))
106+
contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
107+
}
108+
109+
private fun saveScreenshotToStream(bitmap: Bitmap, outputStream: OutputStream) {
110+
outputStream.use {
111+
try {
112+
bitmap.compress(Bitmap.CompressFormat.JPEG, 50, it)
113+
} catch (e: IOException) {
114+
Timber.e("Screenshot was not stored at this time")
115+
}
116+
}
117+
}

vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt

+26-2
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,15 @@ package im.vector.app.ui
1919
import androidx.test.ext.junit.rules.ActivityScenarioRule
2020
import androidx.test.ext.junit.runners.AndroidJUnit4
2121
import androidx.test.filters.LargeTest
22+
import im.vector.app.R
23+
import im.vector.app.espresso.tools.ScreenshotFailureRule
2224
import im.vector.app.features.MainActivity
25+
import im.vector.app.getString
2326
import im.vector.app.ui.robot.ElementRobot
27+
import im.vector.app.ui.robot.withDeveloperMode
2428
import org.junit.Rule
2529
import org.junit.Test
30+
import org.junit.rules.RuleChain
2631
import org.junit.runner.RunWith
2732
import java.util.UUID
2833

@@ -34,7 +39,9 @@ import java.util.UUID
3439
class UiAllScreensSanityTest {
3540

3641
@get:Rule
37-
val activityRule = ActivityScenarioRule(MainActivity::class.java)
42+
val testRule = RuleChain
43+
.outerRule(ActivityScenarioRule(MainActivity::class.java))
44+
.around(ScreenshotFailureRule())
3845

3946
private val elementRobot = ElementRobot()
4047

@@ -69,13 +76,30 @@ class UiAllScreensSanityTest {
6976
createNewRoom {
7077
crawl()
7178
createRoom {
72-
postMessage("Hello world!")
79+
val message = "Hello world!"
80+
postMessage(message)
7381
crawl()
82+
crawlMessage(message)
7483
openSettings { crawl() }
7584
}
7685
}
7786
}
7887

88+
elementRobot.withDeveloperMode {
89+
settings {
90+
advancedSettings { crawlDeveloperOptions() }
91+
}
92+
roomList {
93+
openRoom(getString(R.string.room_displayname_empty_room)) {
94+
val message = "Test view source"
95+
postMessage(message)
96+
openMessageMenu(message) {
97+
viewSource()
98+
}
99+
}
100+
}
101+
}
102+
79103
elementRobot.roomList {
80104
verifyCreatedRoom()
81105
}

vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt

+6
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,9 @@ class ElementRobot {
141141
}
142142

143143
private fun Boolean.toWarningType() = if (this) "shown" else "skipped"
144+
145+
fun ElementRobot.withDeveloperMode(block: ElementRobot.() -> Unit) {
146+
settings { toggleDeveloperMode() }
147+
block()
148+
settings { toggleDeveloperMode() }
149+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright (c) 2021 New Vector Ltd
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package im.vector.app.ui.robot
18+
19+
import androidx.test.espresso.Espresso.pressBack
20+
import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn
21+
import com.adevinta.android.barista.interaction.BaristaListInteractions.clickListItem
22+
import im.vector.app.R
23+
import java.lang.Thread.sleep
24+
25+
class MessageMenuRobot(
26+
var autoClosed: Boolean = false
27+
) {
28+
29+
fun viewSource() {
30+
clickOn(R.string.view_source)
31+
// wait for library
32+
sleep(1000)
33+
pressBack()
34+
autoClosed = true
35+
}
36+
37+
fun editHistory() {
38+
clickOn(R.string.message_view_edit_history)
39+
pressBack()
40+
autoClosed = true
41+
}
42+
43+
fun addQuickReaction(quickReaction: String) {
44+
clickOn(quickReaction)
45+
autoClosed = true
46+
}
47+
48+
fun addReactionFromEmojiPicker() {
49+
clickOn(R.string.message_add_reaction)
50+
// Wait for emoji to load, it's async now
51+
sleep(2000)
52+
clickListItem(R.id.emojiRecyclerView, 4)
53+
autoClosed = true
54+
}
55+
56+
fun edit() {
57+
clickOn(R.string.edit)
58+
autoClosed = true
59+
}
60+
}

0 commit comments

Comments
 (0)