Merge pull request #4439 from vector-im/feature/adm/developer-mode-sanity-check

Developer mode sanity check & failure screenshots
This commit is contained in:
Benoit Marty 2021-11-10 18:53:50 +01:00 committed by GitHub
commit 8b655edd34
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 362 additions and 61 deletions

View File

@ -18,7 +18,7 @@ jobs:
strategy:
fail-fast: false
matrix:
api-level: [28]
api-level: [ 29 ]
steps:
- uses: actions/checkout@v2
with:
@ -56,7 +56,24 @@ jobs:
java-version: '11'
- name: Run sanity tests on API ${{ matrix.api-level }}
uses: reactivecircus/android-emulator-runner@v2
continue-on-error: true # allow pipeline to upload failure results
with:
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
api-level: ${{ matrix.api-level }}
script: ./gradlew $CI_GRADLE_ARG_PROPERTIES -PallWarningsAsErrors=false connectedGplayDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=im.vector.app.ui.UiAllScreensSanityTest
emulator-build: 7425822 # workaround to emulator bug: https://github.com/ReactiveCircus/android-emulator-runner/issues/160
script: |
adb root
adb logcat -c
touch emulator.log
chmod 777 emulator.log
adb logcat >> emulator.log &
./gradlew $CI_GRADLE_ARG_PROPERTIES -PallWarningsAsErrors=false connectedGplayDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=im.vector.app.ui.UiAllScreensSanityTest || adb pull storage/emulated/0/Pictures/failure_screenshots
- name: Upload Failing Test Report Log
if: failure()
uses: actions/upload-artifact@v2
with:
name: sanity-error-results
path: |
emulator.log
failure_screenshots/

View File

@ -19,6 +19,7 @@ package im.vector.app
import android.app.Activity
import android.view.View
import androidx.annotation.StringRes
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Observer
import androidx.test.espresso.Espresso
import androidx.test.espresso.IdlingRegistry
@ -35,6 +36,11 @@ import androidx.test.runner.lifecycle.ActivityLifecycleCallback
import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry
import androidx.test.runner.lifecycle.Stage
import com.adevinta.android.barista.interaction.BaristaClickInteractions
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment
import im.vector.app.espresso.tools.waitUntilViewVisible
import org.hamcrest.Matcher
import org.hamcrest.Matchers
import org.hamcrest.StringDescription
@ -52,6 +58,18 @@ object EspressoHelper {
}
return currentActivity
}
inline fun <reified T : VectorBaseBottomSheetDialogFragment<*>> getBottomSheetDialog(): BottomSheetDialogFragment? {
return (getCurrentActivity() as? FragmentActivity)
?.supportFragmentManager
?.fragments
?.filterIsInstance<T>()
?.firstOrNull()
}
}
fun getString(@StringRes id: Int): String {
return EspressoHelper.getCurrentActivity()!!.resources.getString(id)
}
fun waitForView(viewMatcher: Matcher<View>, timeout: Long = 10_000, waitForDisplayed: Boolean = true): ViewAction {
@ -216,3 +234,46 @@ fun clickOnAndGoBack(@StringRes name: Int, block: () -> Unit) {
block()
Espresso.pressBack()
}
inline fun <reified T : VectorBaseBottomSheetDialogFragment<*>> interactWithSheet(contentMatcher: Matcher<View>, noinline block: () -> Unit = {}) {
waitUntilViewVisible(contentMatcher)
val behaviour = (EspressoHelper.getBottomSheetDialog<T>()!!.dialog as BottomSheetDialog).behavior
withIdlingResource(BottomSheetResource(behaviour, BottomSheetBehavior.STATE_EXPANDED), block)
withIdlingResource(BottomSheetResource(behaviour, BottomSheetBehavior.STATE_HIDDEN)) {}
}
class BottomSheetResource(
private val bottomSheetBehavior: BottomSheetBehavior<*>,
@BottomSheetBehavior.State private val wantedState: Int
) : IdlingResource, BottomSheetBehavior.BottomSheetCallback() {
private var isIdle: Boolean = false
private var resourceCallback: IdlingResource.ResourceCallback? = null
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
override fun onStateChanged(bottomSheet: View, newState: Int) {
val wasIdle = isIdle
isIdle = newState == BottomSheetBehavior.STATE_EXPANDED
if (!wasIdle && isIdle) {
bottomSheetBehavior.removeBottomSheetCallback(this)
resourceCallback?.onTransitionToIdle()
}
}
override fun getName() = "BottomSheet awaiting state: $wantedState"
override fun isIdleNow() = isIdle
override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) {
resourceCallback = callback
val state = bottomSheetBehavior.state
isIdle = state == wantedState
if (isIdle) {
resourceCallback!!.onTransitionToIdle()
} else {
bottomSheetBehavior.addBottomSheetCallback(this)
}
}
}

View File

@ -0,0 +1,117 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.espresso.tools
import android.content.ContentResolver
import android.content.ContentValues
import android.graphics.Bitmap
import android.net.Uri
import android.os.Environment
import android.provider.MediaStore
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import org.junit.rules.TestWatcher
import org.junit.runner.Description
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.OutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val SCREENSHOT_FOLDER_LOCATION = "${Environment.DIRECTORY_PICTURES}/failure_screenshots"
private val deviceLanguage = Locale.getDefault().language
class ScreenshotFailureRule : TestWatcher() {
override fun failed(e: Throwable?, description: Description) {
val screenShotName = "$deviceLanguage-${description.methodName}-${SimpleDateFormat("EEE-MMMM-dd-HH:mm:ss").format(Date())}"
val bitmap = getInstrumentation().uiAutomation.takeScreenshot()
storeFailureScreenshot(bitmap, screenShotName)
}
}
/**
* Stores screenshots in sdcard/Pictures/failure_screenshots
*/
private fun storeFailureScreenshot(bitmap: Bitmap, screenshotName: String) {
val contentResolver = getInstrumentation().targetContext.applicationContext.contentResolver
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
}
if (android.os.Build.VERSION.SDK_INT >= 29) {
useMediaStoreScreenshotStorage(
contentValues,
contentResolver,
screenshotName,
SCREENSHOT_FOLDER_LOCATION,
bitmap
)
} else {
usePublicExternalScreenshotStorage(
contentValues,
contentResolver,
screenshotName,
SCREENSHOT_FOLDER_LOCATION,
bitmap
)
}
}
private fun useMediaStoreScreenshotStorage(
contentValues: ContentValues,
contentResolver: ContentResolver,
screenshotName: String,
screenshotLocation: String,
bitmap: Bitmap
) {
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, "$screenshotName.jpeg")
contentValues.put(MediaStore.Images.Media.RELATIVE_PATH, screenshotLocation)
val uri: Uri? = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
if (uri != null) {
contentResolver.openOutputStream(uri)?.let { saveScreenshotToStream(bitmap, it) }
contentResolver.update(uri, contentValues, null, null)
}
}
private fun usePublicExternalScreenshotStorage(
contentValues: ContentValues,
contentResolver: ContentResolver,
screenshotName: String,
screenshotLocation: String,
bitmap: Bitmap
) {
val directory = File(Environment.getExternalStoragePublicDirectory(screenshotLocation).toString())
if (!directory.exists()) {
directory.mkdirs()
}
val file = File(directory, "$screenshotName.jpeg")
saveScreenshotToStream(bitmap, FileOutputStream(file))
contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
}
private fun saveScreenshotToStream(bitmap: Bitmap, outputStream: OutputStream) {
outputStream.use {
try {
bitmap.compress(Bitmap.CompressFormat.JPEG, 50, it)
} catch (e: IOException) {
Timber.e("Screenshot was not stored at this time")
}
}
}

View File

@ -19,10 +19,15 @@ package im.vector.app.ui
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import im.vector.app.R
import im.vector.app.espresso.tools.ScreenshotFailureRule
import im.vector.app.features.MainActivity
import im.vector.app.getString
import im.vector.app.ui.robot.ElementRobot
import im.vector.app.ui.robot.withDeveloperMode
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.runner.RunWith
import java.util.UUID
@ -34,7 +39,9 @@ import java.util.UUID
class UiAllScreensSanityTest {
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
val testRule = RuleChain
.outerRule(ActivityScenarioRule(MainActivity::class.java))
.around(ScreenshotFailureRule())
private val elementRobot = ElementRobot()
@ -69,13 +76,30 @@ class UiAllScreensSanityTest {
createNewRoom {
crawl()
createRoom {
postMessage("Hello world!")
val message = "Hello world!"
postMessage(message)
crawl()
crawlMessage(message)
openSettings { crawl() }
}
}
}
elementRobot.withDeveloperMode {
settings {
advancedSettings { crawlDeveloperOptions() }
}
roomList {
openRoom(getString(R.string.room_displayname_empty_room)) {
val message = "Test view source"
postMessage(message)
openMessageMenu(message) {
viewSource()
}
}
}
}
elementRobot.roomList {
verifyCreatedRoom()
}

View File

@ -141,3 +141,9 @@ class ElementRobot {
}
private fun Boolean.toWarningType() = if (this) "shown" else "skipped"
fun ElementRobot.withDeveloperMode(block: ElementRobot.() -> Unit) {
settings { toggleDeveloperMode() }
block()
settings { toggleDeveloperMode() }
}

View File

@ -0,0 +1,60 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.ui.robot
import androidx.test.espresso.Espresso.pressBack
import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn
import com.adevinta.android.barista.interaction.BaristaListInteractions.clickListItem
import im.vector.app.R
import java.lang.Thread.sleep
class MessageMenuRobot(
var autoClosed: Boolean = false
) {
fun viewSource() {
clickOn(R.string.view_source)
// wait for library
sleep(1000)
pressBack()
autoClosed = true
}
fun editHistory() {
clickOn(R.string.message_view_edit_history)
pressBack()
autoClosed = true
}
fun addQuickReaction(quickReaction: String) {
clickOn(quickReaction)
autoClosed = true
}
fun addReactionFromEmojiPicker() {
clickOn(R.string.message_add_reaction)
// Wait for emoji to load, it's async now
sleep(2000)
clickListItem(R.id.emojiRecyclerView, 4)
autoClosed = true
}
fun edit() {
clickOn(R.string.edit)
autoClosed = true
}
}

View File

@ -17,21 +17,24 @@
package im.vector.app.ui.robot
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.pressBack
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import com.adevinta.android.barista.interaction.BaristaClickInteractions
import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn
import com.adevinta.android.barista.interaction.BaristaClickInteractions.longClickOn
import com.adevinta.android.barista.interaction.BaristaEditTextInteractions.writeTo
import com.adevinta.android.barista.interaction.BaristaListInteractions.clickListItem
import com.adevinta.android.barista.interaction.BaristaMenuClickInteractions.clickMenu
import com.adevinta.android.barista.interaction.BaristaMenuClickInteractions.openMenu
import im.vector.app.R
import im.vector.app.espresso.tools.waitUntilViewVisible
import im.vector.app.features.home.room.detail.timeline.action.MessageActionsBottomSheet
import im.vector.app.features.reactions.data.EmojiDataSource
import im.vector.app.interactWithSheet
import im.vector.app.waitForView
import java.lang.Thread.sleep
@ -39,7 +42,9 @@ class RoomDetailRobot {
fun postMessage(content: String) {
writeTo(R.id.composerEditText, content)
waitUntilViewVisible(withId(R.id.sendButton))
clickOn(R.id.sendButton)
waitUntilViewVisible(withText(content))
}
fun crawl() {
@ -55,61 +60,54 @@ class RoomDetailRobot {
pressBack()
clickMenu(R.id.search)
pressBack()
// Long click on the message
longClickOnMessageTest()
}
private fun longClickOnMessageTest() {
fun crawlMessage(message: String) {
// Test quick reaction
longClickOnMessage()
waitUntilViewVisible(withId(R.id.bottomSheetRecyclerView))
// Add quick reaction
clickOn("\uD83D\uDC4D") // 👍
waitUntilViewVisible(withId(R.id.composerEditText))
val quickReaction = EmojiDataSource.quickEmojis[0] // 👍
openMessageMenu(message) {
addQuickReaction(quickReaction)
}
// Open reactions
longClickOn("\uD83D\uDC4D") // 👍
longClickOn(quickReaction)
// wait for bottom sheet
pressBack()
// Test add reaction
longClickOnMessage()
waitUntilViewVisible(withId(R.id.bottomSheetRecyclerView))
clickOn(R.string.message_add_reaction)
// Filter
// TODO clickMenu(R.id.search)
// Wait for emoji to load, it's async now
sleep(2000)
clickListItem(R.id.emojiRecyclerView, 4)
waitUntilViewVisible(withId(R.id.composerEditText))
openMessageMenu(message) {
addReactionFromEmojiPicker()
}
// Test Edit mode
longClickOnMessage()
waitUntilViewVisible(withId(R.id.bottomSheetRecyclerView))
clickOn(R.string.edit)
waitUntilViewVisible(withId(R.id.composerEditText))
openMessageMenu(message) {
edit()
}
// TODO Cancel action
writeTo(R.id.composerEditText, "Hello universe!")
// Wait a bit for the keyboard layout to update
sleep(30)
waitUntilViewVisible(withId(R.id.sendButton))
clickOn(R.id.sendButton)
// Wait for the UI to update
sleep(1000)
waitUntilViewVisible(withText("Hello universe! (edited)"))
// Open edit history
longClickOnMessage("Hello universe! (edited)")
waitUntilViewVisible(withId(R.id.bottomSheetRecyclerView))
clickOn(R.string.message_view_edit_history)
pressBack()
openMessageMenu("Hello universe! (edited)") {
editHistory()
}
}
private fun longClickOnMessage(text: String = "Hello world!") {
Espresso.onView(withId(R.id.timelineRecyclerView))
fun openMessageMenu(message: String, block: MessageMenuRobot.() -> Unit) {
onView(withId(R.id.timelineRecyclerView))
.perform(
RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
ViewMatchers.hasDescendant(ViewMatchers.withText(text)),
ViewMatchers.hasDescendant(ViewMatchers.withText(message)),
ViewActions.longClick()
)
)
interactWithSheet<MessageActionsBottomSheet>(contentMatcher = withId(R.id.bottomSheetRecyclerView)) {
val messageMenuRobot = MessageMenuRobot()
block(messageMenuRobot)
if (!messageMenuRobot.autoClosed) {
pressBack()
}
}
}
fun openSettings(block: RoomSettingsRobot.() -> Unit) {

View File

@ -17,38 +17,46 @@
package im.vector.app.ui.robot
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.pressBack
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.withText
import com.adevinta.android.barista.assertion.BaristaVisibilityAssertions
import com.adevinta.android.barista.interaction.BaristaClickInteractions
import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn
import im.vector.app.R
import im.vector.app.espresso.tools.waitUntilActivityVisible
import im.vector.app.features.roomdirectory.RoomDirectoryActivity
class RoomListRobot {
fun openRoom(roomName: String, block: RoomDetailRobot.() -> Unit) {
clickOn(roomName)
block(RoomDetailRobot())
pressBack()
}
fun verifyCreatedRoom() {
Espresso.onView(ViewMatchers.withId(R.id.roomListView))
onView(ViewMatchers.withId(R.id.roomListView))
.perform(
RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
ViewMatchers.hasDescendant(ViewMatchers.withText(R.string.room_displayname_empty_room)),
ViewMatchers.hasDescendant(withText(R.string.room_displayname_empty_room)),
ViewActions.longClick()
)
)
Espresso.pressBack()
pressBack()
}
fun newRoom(block: NewRoomRobot.() -> Unit) {
BaristaClickInteractions.clickOn(R.id.createGroupRoomButton)
clickOn(R.id.createGroupRoomButton)
waitUntilActivityVisible<RoomDirectoryActivity> {
BaristaVisibilityAssertions.assertDisplayed(R.id.publicRoomsList)
}
val newRoomRobot = NewRoomRobot()
block(newRoomRobot)
if (!newRoomRobot.createdRoom) {
Espresso.pressBack()
pressBack()
}
}
}

View File

@ -17,8 +17,11 @@
package im.vector.app.ui.robot.settings
import androidx.test.espresso.Espresso.pressBack
import androidx.test.espresso.matcher.ViewMatchers.withText
import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn
import im.vector.app.R
import im.vector.app.espresso.tools.clickOnPreference
import im.vector.app.espresso.tools.waitUntilViewVisible
class SettingsAdvancedRobot {
@ -28,20 +31,19 @@ class SettingsAdvancedRobot {
clickOnPreference(R.string.settings_push_rules)
pressBack()
}
/* TODO P2 test developer screens
// Enable developer mode
clickOnSwitchPreference("SETTINGS_DEVELOPER_MODE_PREFERENCE_KEY")
fun toggleDeveloperMode() {
clickOn(R.string.settings_developer_mode_summary)
}
clickOnPreference(R.string.settings_account_data)
clickOn("m.push_rules")
pressBack()
pressBack()
clickOnPreference(R.string.settings_key_requests)
pressBack()
// Disable developer mode
clickOnSwitchPreference("SETTINGS_DEVELOPER_MODE_PREFERENCE_KEY")
*/
fun crawlDeveloperOptions() {
clickOnPreference(R.string.settings_account_data)
waitUntilViewVisible(withText("m.push_rules"))
clickOn("m.push_rules")
pressBack()
pressBack()
clickOnPreference(R.string.settings_key_requests)
pressBack()
}
}

View File

@ -21,6 +21,12 @@ import im.vector.app.clickOnAndGoBack
class SettingsRobot {
fun toggleDeveloperMode() {
advancedSettings {
toggleDeveloperMode()
}
}
fun general(block: SettingsGeneralRobot.() -> Unit) {
clickOnAndGoBack(R.string.settings_general_title) { block(SettingsGeneralRobot()) }
}
@ -50,7 +56,9 @@ class SettingsRobot {
}
fun advancedSettings(block: SettingsAdvancedRobot.() -> Unit) {
clickOnAndGoBack(R.string.settings_advanced_settings) { block(SettingsAdvancedRobot()) }
clickOnAndGoBack(R.string.settings_advanced_settings) {
block(SettingsAdvancedRobot())
}
}
fun helpAndAbout(block: SettingsHelpRobot.() -> Unit) {